클린코드 chap 7. 우아하게 예외 처리하기

최준영·2021년 10월 7일
2

클린한 코드

목록 보기
8/16

1. 예외 처리 방식


  • 오류 코드를 리턴하지 말고 예외를 던져라
    • 현재는 예외를 던지는 것이 일반화 되었다. 처리 흐름이 깔끔하다.

예제 1. 좋은 예, try - catch로 예외처리

public class DeviceController {
    // ...
 
    public void sendShutDown() {
        try {
            tryToShutDown();
        } catch (DeviceShutDownError e) {
            logger.log(e);
        }
    }
 
    private void tryToShutDown() throws DeviceShutDownError {
        DeviceHandle handle = getHandle(DEV1);
        DeviceRecord record = retrieveDeviceRecord(handle);
 
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
    }
 
    private DeviceHandle getHandle(DeviceId id) {
        // ...
        throw new DeviceShutDownError("Invalid handle for: " + id.toString());
        // ...
    }
 
    // ...
}

2. Unchecked Exception을 사용하라


1) Exception 가계도

  • Exception을 상속하면 checked Exception.
    • 명시적인 예외 처리가 필요하다.
    • 예 : IOException, SQLException
  • RuntimeException을 상속하면 UncheckedException.
    • 명시적인 예외 처리가 필요하지 않다.
    • 예 : NullPointerException, IllegalArgumentException, IndexOutOfBoundException

2) Effective Java - Exception에 관한 규약

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

3) checked Exception이 나쁜 이유

  • 특정 메소드에서 checked exception을 throw하고 상위 메소드에서 그 exception을 catch 한다면, 모든 중간단계 메소드에 exception을 throws해야한다.
  • 이는 OCP(개방 폐쇄 원칙)위배이다. 상위 메소드에서 하위 레벨 메소드의 디테일에 대해 알아야하기 때문이다.
  • 필요한 경우 checked exception을 사용해야 되지만, 일반적인 경우 득보다 실이 많다.
  • 파이썬, C#, C++, 루비는 checked exception을 지원하지 않음에도 안정적인 소프트웨어를 구현하기에 무리가 없다.

3. Exception 잘 쓰기


1) 예외에 메시지를 담아라

  • 예제 1의 throw new DeviceShutDownError("Invalid handle for: " + id.toString());처럼 사용한다.
  • 오류가 발생한 원인과 위치를 찾기 쉽도록, 예외를 던질 때는 전후 상황을 충분히 덧붙인다.
  • 실패한 연산 이름과 유형 등 정보를 담아 예외를 던진다.

2) exception wrapper

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

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

예제 2. 나쁜 예

  • 로그로 찍을 뿐 할 수 있는 일이 없다.
ACMEPort port = new ACMEPort(12);
try {
  port.open();
} catch (DeviceResponseException e) {
  reportPortError(e);
  logger.log("Device response exception", e);
} catch (ATM1212UnlockedException e) {
  reportPortError(e);
  logger.log("Unlock exception", e);
} catch (GMXError e) {
  reportPortError(e);
  logger.log("Device response exception", e);
}

예제 3. 좋은 예

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

public class LocalPort {
    private ACMEPort innerPort;
 
    public LocalPort(int portNumber) {
        innerPort = new ACMEPort(portNumber);
    }
 
    public void open() {
        try {
            innerPort.open();
        } catch (DeviceResponseException e) {
            throw new PortDeviceFailure(e);
        } catch (ATM1212UnlockedException e) {
            throw new PortDeviceFailure(e);
        } catch (GMXError e) {
            throw new PortDeviceFailure(e);
        }
    }
 
    // ..
}

4. 실무 예외 처리 패턴


1) getOrElse

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

(1) null이 아닌 기본 값 예제

예제 4. 나쁜 예

List<Employee> employees = getEmployees();
if (employee != null) {
    for(Employee e : employees) {
        totalPay += e.getPay();
    }
}
  • null을 리턴한다면, 이후 코드에서도 모두 null 체크가 있어야 한다.

예제 5. 좋은 예

List<Employee> employees = getEmployees();
for(Employee e : employees) {
    totalPay += e.getPay();
}

public List<Employee> getEmployees() {
    if ( /*.. 직원이 없을 경우 .. */)
        return Collections.emptyList();
    }    
}
  • 복수 형의 데이터를 가져올 떄는 데이터가 없음을 의미하는 컬렉션을 리턴하면 된다.
  • null 보다 size가 0인 컬렉션이 훨씬 안전하다.
    • 빈 컬렉션이 for문에 들어가면 안전하게 종료된다.

(2) 도메인에 맞는 기본 값 예제

예제 6. 나쁜 예

UserLevel userLevel = null;
try {
  User user = user.Repository.findByUserId(userId);
  userLevel = user.getUserLevel();
} catch (UserNotfoundException e) {
  userLevel = UserLevel.BASIC;
}
  • 호출부에서 예외 처리를 통해 userLevel 값을 처리한다.
  • 코드를 계속 읽어나가면서 논리적인 흐름이 끊긴다.

예제 7. 좋은 예

  • 호출부 - 단순해짐
UserLevel userLevel = userService.getuserLevelOrDefault(userId);
  • UserService 클래스
public class UserService {
  public static final UserLevel USER_BASIC_LEVEL = UsrLevel.BASIC;
  
  public UserLevel getUserLevelOrdefault(Long userId) {
    try {
      User user = userRepository.findByUserId(userId);
      return user.getUserLevel();
    } catch(UserNotFoundException e) {
      return USER_BASIC_LEVEL;
    }
  }
}
  • 예외 처리를 데이터를 제공하는 쪽에서 처리해 호출부 코드가 심플해진다.
  • 코드를 읽어가며 논리적인 흐름이 끊기지 않는다.
  • 도메인에 맞는 기본 값을 도메인 서비스에서 관리한다.

2) getOrElseThrow

(1) (기본 값이 없다면) null 대신 예외를 던진다.

예제 8. 나쁜 예

User user = userRepository.findByUseId(userId);
if (user != null) {
  // user를 이용한 처리
}
  • user를 사용하는 쪽에서 매번 체크를 해야한다.
  • 가독성 뿐만 아니라 안정성도 떨어진다.
  • null 체크가 빠진 부분이 발생할 수 있다.

예제 9. 좋은 예

  • 호출부
User user = user.Service.getUserOrElseThrow(userId);
  • UserService 클래스
public class UserService {
  public static final UserLevel USER_BASIC_LEVEL = UsrLevel.BASIC;
  
  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 체크를 할 필요 없이 안전하게 데이터를 사용할 수 있다.
  • 호출부의 가독성이 올라간다.

(2) 파라미터의 null을 점검하라

예제 10. 나쁜 예

public class MetricsCalculator {
  public double xProjection(Point p1, Point p2) {
    return (p2.x - p1.x) * 1.5;
  }
}

// calculator.xProjection(null, new Point(12, 13));
// NullPointerException 발생
  • null을 리턴하는 것도 나쁘지만 null을 메서드로 넘기는 것은 더 나쁘다.
  • null을 메서드의 파라미터로 넣어야 하는 API를 사용하는 경우가 아니면 null을 메서드로 넘기지 마라.

예제 11. 좋은 예 - throw new ~ 사용

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을 발생시킨다.
  • 실무에서는 assert보다 예외를 던지는 방법을 훨씬 많이 사용한다.

예제 12. 좋은 예 - assert 사용

public class MetricsCalculator {
  public double xProjection(Point p1, Point p2) {
    assert p1 != null : "p1 should not be null";
    assert p2 != null : "p2 should not be null";
    return (p2.x - p1.x) * 1.5;
  }
}

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

예제 13

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;
  
  INVALID_REQUEST("잘못된 요청입니다."),
  DUPLICATED_REQUEST("기존 요청과 중복되어 처리할 수 없습니다."),
  // ...
  INTERNAL_SERVER_ERROR("처리 중 에러가 발생했습니다.");
}

//호출부
if (request.getUserName() == null) {
  throw new MyProjectException(ErrorCode.INVALID_REQUEST, "userName is null");
}
  • 장점
    • 에러 로그에서 stacktrace 해봤을 때 우리가 발생시킨 예외라는 것을 바로 인지할 수 있다.
    • 다른 라이브러리에서 발생한 에러와 섞이지 않는다. 우리도 IllegalArgumentException을 던지는 것보다 우리 예외로 던지는게 어느 부분에서 에러가 났는지 파악하기에 용이하다.
    • 우리 시스템에서 발생한 에러의 종류를 나열할 수 있다.

6. 실습 강의에서 배운 것


예외 정의하기 전

  • BrokeragePolicyFactory.java
public class BrokeragePolicyFactory {
  public static BrokeragePolicy of(ActionType actionType) {
    switch (actionType) {
      case RENT:
        return new RentBrokeragePolicy();
      case PURCHASE:
        return new PurchaseBrokeragePolicy();
      default:
        throw new IllegalArgumentException("해당 actionType에 대한 정책이 존재하지 않습니다.");
    }
  }
}

예외 정의한 후

  • BrokeragePolicyFactory.java
public class BrokeragePolicyFactory {
  public static BrokeragePolicy of(ActionType actionType) {
    switch (actionType) {
      case RENT:
        return new RentBrokeragePolicy();
      case PURCHASE:
        return new PurchaseBrokeragePolicy();
      default:
        throw new HouseUtilsExceptionException(ErrorCode.INVALID_REQUEST, "해당 actionType에 대한 정책이 존재하지 않습니다.");
    }
  }
}
  • HouseUtilsException
public classs HouseUtilsException extends RuntimeException {
  private ErrorCode errorCode;
  private String message;
  
  public HouseUtilsException(ErrorCode errorCode) {
    this(errorCode, errorCode.getMessage());
  }
  
  public HouseUtilsException(ErrorCode errorCode, String customMessage) {
    super(customMessage);
    this.errorCode = errorCode;
    this.message = customMessage;
  }
}
  • ErrorCode.java
@AllArgsContrustor
// getMessage를 없애고 여기에 @Getter(롬북의 일종)를 붙여도 된다.
public enum ErrorCode {
  INVALID_REQUEST("잘못된 요청입니다.");
  INTERNAL_ERROR("알 수 없는 에러가 발생했습니다.");
  ENTITY_NOT_FOUND("데이터를 찾을 수 없습니다.");
  
  private String message;
  
  private String getMessage() {
    return this.message();
  }
}

폴더 구조

profile
do for me

2개의 댓글

comment-user-thumbnail
2022년 5월 9일

내용 너무 좋아요!

답글 달기
comment-user-thumbnail
2023년 7월 11일

많은 도움 됐습니다 감사합니다!

답글 달기