6. 안전하게 예외 처리하기



안전하게 예외 처리하기


1. 예외 처리 방식

오류 코드를 리턴하지 말고, 예외를 던져라

  • 옛날에는 오류를 나타낼 때 에러코드를 던졌다.
  • 하지만 예외를 던지는 것이 명확하고, 처리 흐름이 깔끔해진다.
// 👎👎
if (handle != DeviceHandle.INVALID) {
  ...
} else {
  logger.log("Invalid handle for: " + DEV1.toString())
}
  • 오류가 발생한 부분에서 예외를 던진다. (별도의 처리가 필요한 예외라면 checked exception 으로 던진다.)
  • checked exception 에 대한 예외처리를 하지 않는다면 메서드 선언부에 throws 를 명시해야 한다.
  • 예외를 처리할 수 있는 곳에서 catch 하여 처리한다.
// 👍👍
public void sendShutDown() {
  try {
    tryToShutDown();
  } catch (DeviceShutDownError e) {
    logger.log(e); //3
  }
}

private void tryToShutDown() throws DeviceShutDownError {
  ... //2
}

private DeviceHandle getHandle(DeviceID id) {
  ...
  throw new DeviceShutDownError("Invalid handle for: " + DEV1.toString()") //1
  ...

}



2. Unchecked Exception 을 사용하라

Checked vs Unchecked Exception

Exception 을 상속하면 Checked Exception

  • 명시적인 예외처리가 필요하다.
  • ex) IOEception, SQLException


RuntimeExcetion 을 상속하면 UncheckedException

  • 명시적인 예외처리가 필요하지 않다
  • ex) NullPionterEception, IllegalArgumentException, IndexOtuOfBoundException


Exception에 관한 규약

자바 언어 명세가 요구하는 것은 아니지만, 업계에 널리 퍼진 규약으로
Error 클래스를 상속해 하위 클래스를 만드는 일은 자제하자
즉 사용자가 직접 구현하는 unchecked throwable 은 모두 RuntimeException의 하위 클래스여야 한다.
Exception, RuntimeExcetion, Error 를 상속하지 않는 throwable 을 만들수도 있지만, 이러한 throwable은 정상적인 사항보다 나을 게 하나도 없으면서 API 사용자를 헷갈리게 할 뿐이므로 절대로 사용하지 말자.


Checked Exception 이 나쁜 이유

  • 특정 메소드에서 checked exception 을 throw 하고 상위 메소드에서 그 exception 을 catch 한다면 모든 중간단계 메소드에 exception을 throws 해야 한다.
  • OCP (개방 폐쇄 원칙) 위배 : 상위 레벨 메소드에서 하위 레벨 메소드의 디테일에 대해 알아야 하기 때문에 OCP 원칙에 위배된다.
  • 필요한 경우 checked exception 을 사용해야 되지만 일반적인 경우 득보다 실이 많다 디미터의 법칙에 어긋나는 상황
  • 마치 depth 가 적어보이더라도 그 내부가 여러번이라면 그건 잘못된 것!


Unchecked Exception 을 사용하자

안정적인 소프트웨어를 제작하는 요소로 확인된 예외가 반드시 필요하지는 않다는 사실이 분명해졌다. C#은 확인된 예외를 지원하지 않는다. 영웅적인 시도에도 불구하고 C++ 역시 확인된 예외를 지원하지 않는다. 파이썬이나 루비도 마찬가지다. 그럼에도 불구하고 C#, C++, 파이썬, 루비는 안정적인 소프트웨어를 구현하기에 무리가 없다.



3. Exception 잘 쓰기

예외에 메시지 담기

예외에 의미있는 정보 담기

  • 오류가 발생한 원인과 위치를 찾기 쉽도록, 예외를 던질 때는 전후 상황을 충분히 덧붙인다.
  • 실패한 연산 이름과 유형 등 정보를 담아 예외를 던진다.


exception wrapper

예외를 감싸는 클래스를 만든다.

// 👎👎 로그를 찍을 뿐 할 수 있는 일이 없다.
try {
  port.open()
} catch (PortDeviceFailure e) {
  reportError(e)
  logger.log("Device response exception", e)
} catch (...)


  • port.open() 시 발생하는 checked exception 들을 감싸도록 port 를 가지는 LocalPort 클래스는 만든다.
  • port.open() 이 던지는 checked exception 들을 하나의 PortDeviceFailure exception으로 감싸서 던진다.


LocalPort port = new LocalPort(12);
try {
  port.open()
} catch (PortDeviceFailure e) {
  reportError(e)
  logger.log(e.getMessage(), e)
}

// 👍👍
public class LocalPort {
  ...
  public void open() {
    try {
      innerPort.open()
    } catch (DeviceResponseException e) {
      throw new PortDeviceFailure(e)
    } catch ... {
      throw new ...
    }
  }
}



4. 실무 예외 처리 패턴

getOrElse

  • 예외 대신 기본 값을 리턴한다.
  • null 이 아닌 기본 값
  • 도메인에 맞는 기본값


getOrElseThrow

  • null 대신 예외를 던진다 (기본값이 없다면)
  • null 체크 지옥에서 벗어나자
// 👎
User user = userRepository.findByUserId(userId)
if (user != null) {
  // user를 이용한 처리
}


// 👍
User user = userService.getUserOrElseThrow(userId);

public class UserService {
  public User getUserOrElseThrow(Long userId) {
    User user = userRepository.findByUserId(userId)
    if (user == null) {
      throw new IllegalArgumentException("User is not found. userId = " + userId)
    }
    return user
  }
}

  • 데이터를 제공하는 쪽에서 null 체크를 하여, 데이터가 없는 경우엔 예외를 던진다.
  • 호출부에선 매번 null 체크를 할 필요 없이 안전하게 데이터를 사용할 수 있다.
  • 호출부의 가독성이 올라간다.


파라미터의 null을 점검하라

  • null을 리턴하는 것도 나쁘지만 null을 메서드로 넘기는 것은 더 나쁘다.
  • null을 메서드의 파라미터로 넣어야 하는 API 를 사용하는 경우가 아니면 null을 메서드로 넘기지 마라.

null을 파라미터로 받지 못하게 한다

public class MetricsCalculator {
  public double xProjection(Point p1, Point p2) {
    if (p1 == null || p2 == null) {
      throw InvalidArgumentException("Invalid argument for MetricsCalculator.xProjection")
    }
    return (p2.x - p1.x) * 1.5
  }
}


  • null 이 들어오면 unchecked exception을 발생시킨다.


실무에서는 보통 자신의 예외를 정의한다.

public class MyProjectException extends RuntimeException {
  private MyErrorCode errorCode;
  private String errorMessage;

  public MyProjectException(MyErrorCode errorCode) {
    ...
  }

  public MyProjectException(MyErrorCode errorCode, String errorMessage) {
    ...
  }

}

public enum MyErrorCode {
  private String defaultErrorMessage;

  INVALI_REQUEST("잘못된 요청입니다.")
  DUPLICATED_REQUEST("기존 요청과 중복되어 처리할 수 없습니다.")
  ...
  INTERNAL_SERVER_ERROR("처리 중 에러가 발생했습니다.")
}

// 호출부
if (request.getUserName() == null) {
  throw new MyProjectException(errorCode.INVALID_REQUEST, "userName is null")
}


  • 에러 로그에서 stacktrace 해봤을 때 우리가 발생시킨 예외라는 것을 바로 인지할 수 있다.
  • 다른 라이브러리에서 발생한 에러와 섞이지 않는다. 우리도 IllegalArgumentException을 던지는 것보다 우리 예외로 던지는게 어느 부분에서 에러가 났는지 파악하기에 용이하다.
  • 우리 시스템에서 발생한 에러의 종류를 나열할 수 있다.



5. 오픈소스 속 Exception 살펴보기

apps-android-commons