티스토리 뷰

이번엔 예외처리에 대한 이야기를 해보려한다. 실무에서도 흔히 보이는 방식과 그 방식에서 아쉬웠던 부분도 함께 정리해보고자 한다.

 

# 1개의 Exception 클래스와 error enum

실무에서 가장 많이 본 예외처리 방식이다. 대다수가 이런 방식을 사용하고 있을거라고 생각한다.

// 예외 클래스 정의
@Getter
public class ApiException extends RuntimeException {
    private ErrorCode errorCode;
}

// 에러 코드를 담는 상수 정의, 사용자노출 메시지와 http status 코드를 관리
@AllArgsConstructor
@Getter
public enum ErrorCode {
    ENTITY_NOT_FOUND("엔티티를 찾지 못했습니다.", 400);
  
    private String message;
    private int httpStatus;
}

// 실제 예외 상황
Optional<Entity> found = entityRepository.findById(id);
if(!found.isPresent()) {
    log.error("id 잘못 넘어옴 id: {}", id);
    throw new ApiException(ErrorCode.ENTITY_NOT_FOUND);
}

// 예외 발생시 처리할 핸들러
@ExceptionHandler(ApiException.class)                                                           
public ResponseEntity<?> handleException(ApiException ex){
    log.error("예외 발생!", ex);
    return ResponseEntity.status(ex.getErrorCode().getHttpStatus())                             
            .body(new ErrorResponse(ex.getErrorCode().getMessage(), ex.getErrorCode().name())); 
}                                                                                               
                                                                                                
@Getter                                                                                          
@AllArgsConstructor                                                                             
static class ErrorResponse {                                                                    
    private String message;                                                                     
    private String code;                                                                        
}

 

에러 관련한 상수들을 하나의 enum 에서 관리하고, 해당 enum 에서 메시지까지 관리하기 때문에 편리한 방식이다. 이 방식에서 어떤 불편함을 느꼈을까?

 

### stack trace 관리

예외는 실행되던 스레드를 중단시키는 일이다. 스레드는 실행되면서 자신의 실행 이력들을 stack 에 담게되는데 예외가 발생하면 해당 stack 을 출력할 수 있고, 이는 디버깅에 필수적인 요소이다. 때문에 예외를 중첩으로 던질때는 예외 체이닝을 꼭 넣어줘야한다.

try {
    // 조회결과가 2건 이상이라면 예외 발생
    Optional<Entity> entity = entityRepository.findById(id);
} catch(NonUniqueResultException e) {
    throw new ApiException(ErrorCode.TOO_MANY_ENTITY);
}

이런 형태의 예외가 발생했을때 스택트레이스를 출력하게되면 해당 스레드의 스택트레이스에는 5라인에서 예외가 발생했다는 정보만 담고있을 뿐 해당 예외 이전에 원천적인 문제인 NonUniqueResultException 에 대한 정보를 담지 못한다.

try {
    // 조회결과가 2건 이상이라면 예외 발생
    Optional<Entity> entity = entityRepository.findById(id);
} catch(NonUniqueResultException e) {
    throw new AnotherException(e);
}

// AnotherException 의 구현
public class AnotherException extends RuntimeException {
    public AnotherException(Exception cause) {
      super(cause);
    }
}

이런식으로 원천예외를 체이닝을 걸어줘야 비로소 스택트레이스에 NonUniqueResultException 정보까지 출력되어 디버깅에 활용할 수 있다.

 

### 과도한 로깅

서비스 운영 도중 이슈가 생겨서 로그를 확인하는건 흔히 있는 일이다. 하지만 이때 동일한 이슈에 대해 비슷한 로그가 여러개 찍히는 경험이 있을거라고 생각한다. 이렇게 되는 원인은 무엇일까?

class Service1 {
    public void someMethod(UserId id) {
        try {
            // ...
        } catch() {
            log.error("에러발생! id: {}", id);
            throw new ApiException(ErrorCode.ERROR);
        }
    }
}

class Service2 {
    private Service1 service1;

    public void someMethod(UserId id) {
        try {
            service1.someMethod(id);
        } catch(ApiException e) {
            log.error("에러발생! id: {}", id);
            throw new ApiException(ErrorCode.ERROR);
        }
    }
}

이런 구조에서 Service2 의 메서드를 호출하고, Service1 에서 예외가 발생했다고하면 error 로그가 2번 찍히게 된다. Service2 를 감싸고 로그를 찍고있는 객체가 더 있다면 로그는 더 찍힐 것이다. 그리고 최종적으로 ExceptionHandler 에서 로그를 1번 더 찍게 된다. 이 문제를 해결하려면 어떻게 해야할까? 로그를 ExceptionHandler 에서만 찍는다는 정책으로 간다면 과도하게 로그를 남기는 이슈는 사라지게된다. 하지만 ExceptionHandler 에서는 문제가 발생한 UserId 의 값을 알 수 없으므로 정확히 문제가 발생한 id 를 로그로 남길 수 없다. 이 때문에 핸들러에서만 로그를 남길수 없게 되고, 결국 각 객체별로 로그를 찍어야하는 것이다.

 

### Metric & Alram

엔터프라이즈 환경에서 서비스를 운영한다면 에러에 대한 지표수집과 알람을 활용하고 있을 것이다. 다양한 툴들이 있을텐데 많은 툴들이 예외 클래스명을 기준으로 하고있다. 때문에 동일한 예외가 100번 발생한건지, 각기 다른 100개의 예외가 1번씩 발생한건지 쉽게 확인하기가 어렵다. 그리고 알람의 설정도 클래스명을 기준으로 하는 툴들이 많아 errorCode 만 가지고 특정 알림을 제외하는 등의 설정이 번거로운 경우가 많다.

 

# 예외처리는 어떻게 하는게 좋을까

위에서 마주한 불편함들은 예외도 객체라는걸 잘 인지하지 못해 발생하는 문제다. 비즈니스 로직에선 각각의 상황에 대한 컨텍스트가 있고, 그 컨텍스트에 맞는 객체들을 만들어서 처리하는데 예외만큼은 가칭 ApiExcepcetion 하나만 만들어놓고 모든 상황에 돌려쓰려는 것이다. 그리고 예외상황에 대한 분리는 enum 으로 하게되는데 enum 은 애초부터가 정적 상수이기 때문에 동적인 정보를 담을 수 없다. 엔티티를 찾지 못했다는 정보는 정적으로 담을 수 있지만 어떤 id 의 엔티티를 찾지 못한건지에 대한 동적 값, 즉 id 는 담을 수 없기 때문에 해당 위치에서 로그를 찍게되고 그렇게 로그가 과도하게 찍히는 것이다. 이 불편함들을 어떻게 해소할지에 대한 고민이 많았고, 예외 객체를 객체처럼 사용하는게 좋겠다고 생각했다.

// 애플리케이션 표준 추상 클래스
@Getter
public abstract class AbstractMiddleException extends RuntimeException {
    private String logMessage;
    private String userMessage;
    private String errorCode;
    private int httpStatus;
    private LogLevel logLevel;

    public AbstractMiddleException(String logMessage, String userMessage, String errorCode, int httpStatus, LogLevel logLevel) {
        this(logMessage, userMessage, errorCode, httpStatus, logLevel, null);
    }

    public AbstractMiddleException(String logMessage, String userMessage, String errorCode, int httpStatus, LogLevel logLevel, Exception cause) {
        super(logMessage, cause);
        this.logMessage = logMessage;
        this.userMessage = userMessage;
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
        this.logLevel = logLevel;
    }
}

// 표준 예외를 기반으로 도메인 상황별 예외 클래스 정의
public class ShopNotFoundException extends AbstractMiddleException {
    public ShopNotFoundException(Long shopId) {
        super("Shop Not Found id: " + shopId, "id가 잘못됐습니다.", "SHOP_NOT_FOUND", 400, LogLevel.WARN);
    }
}

애플리케이션 전반에서 사용할 표준 예외 클래스를 하나 정의한다. 해당 클래스는 각 도메인에서 상속받아 사용하고, 애플리케이션 예외처리에 필요한 정보들을 담는다.

 

- logMessage: 사용자에게 노출될 메시지가 아니라 로그로 남길 메시지이다. 로그용이기 때문에 좀 더 상세한 정보들을 담아도 된다.

- userMessage: 예외 상황에 사용자에게 노출될 메시지다. 디버깅에 필요한 상세정보보단 해당 상황에 맞는 적절한 메시지를 담는다.

- errorCode: 클라이언트에 전달할 에러코드를 담는다.

- httpStatus: 클라이언트에 응답할 http status 를 담는다.

- logLevel: 예외 상황에 보통 error 레벨로 로그를 찍지만 진짜 발생하면 안되는 상황이 있고, 예외는 맞지만 발생할 수 있는 상황이 있다. 이에 따라 해당 예외별로 적절한 로그레벨을 담는다.

 

그리고 예외에 대한 로깅은 ExceptionHandler 에서만 처리한다.

@ExceptionHandler(AbstractMiddleException.class)                                
public ResponseEntity<?> handleMiddleException(AbstractMiddleException ex){     
    switch(ex.getLogLevel()) {                                                  
        case WARN -> log.warn("예외 발생! {}", ex.getLogMessage(), ex);             
        case ERROR -> log.error("예외 발생! {}", ex.getLogMessage(), ex);           
    }                                                                           
                                                                                
    return ResponseEntity.status(ex.getHttpStatus())                            
            .body(new ErrorResponse(ex.getUserMessage(), ex.getErrorCode()));   
}

이제 예외에 대한 로그는 핸들러에서만 찍으면 된다. 무언가 로그를 찍어야하는 데이터가 있다면 그 자리에서 로그로 찍는게 아니라 예외 객체에 담아주면 된다. 표준 예외의 필드들은 복잡한 편이지만 샘플 코드의 ShopNotFoundException 은 복잡성을 스스로 내부에서 해결하고 외부로는 Long 타입의 id 만 요구하기 때문에 사용할때 훨씬 간편하다. 타입 안정성이 보장되지 않는, 메시지를 통으로 받는 String 이 아니기 때문에 동일한 예외를 두 곳 이상에서 사용하더라도 메시지 포맷이 변경될 일도 없다.

 

이 방식의 단점은 클래스가 많아진다는 점인데, 기존의 ApiException 과 비교하면 클래스가 많아지는게 분명하나 객체지향언어에서 도메인별로 클래스를 정의하는게 단점인지는 잘 모르겠다. 엔티티 클래스 많아진다고 JPA 를 안쓰지 않지 않은가, 더욱이 예외 클래스를 늘리지 않으면서 위에서 얘기한 불편함을 해소할 방법이 있으면 모르겠는데 아직 난 잘 모르겠다.

 

AbstractMiddleException 을 추상클래스로 정의하지 않고, 저 클래스를 객체화시키면 상속받는 클래스가 많아지지 않으면서 문제를 해결할 수도 있다. 하지만 그러면 예외를 던지는 부분마다 예외 클래스 사용이 매번 불편할 것이다. 매번 4~5개의 아규먼트를 전달해야 하기 때문이다. 그리고 메시지에 대한 타입 안정성도 보장하기 어렵고, 메시지를 만드는 연산도 예외 객체 바깥으로 전이될 것이다.

 

위에서 잠깐 얘기했던 하이버네이트의 NonUniqueResultException 를 다시 살펴보자. 이 예외는 딱 내가 언급한대로 구현되어있다. 때문에 예외 타입명만 보고도 어떤 예외인지 직관적으로 알 수 있다. HibernateException 이라고 던져지는 것보다는 이게 더 도메인 정보를 담고있는 객체라고 할 수 있다.

// 실제 NonUniqueResultException 클래스 정의
public class NonUniqueResultException extends HibernateException {
	public NonUniqueResultException(int resultCount) {
		super( "query did not return a unique result: " + resultCount );
	}

}

 

### 표준 예외 클래스를 사용하라

이펙티브 자바에는 표준 예외 클래스를 사용하라는 지침이 담겨있고, 이 영향으로 표준 예외를 사용하는 개발자들도 많다. 내 경험상 표준 예외 클래스를 쌩으로 사용하는건 예외처리에 불편함이 많았다. message 필드도 하나 뿐이라 디버깅 로그와 사용자 메시지를 구분하는 것도 어려웠고, 그 외 정보를 넣기도 어렵다.

 

더욱이 서비스 운영상 추가로 불편한 것들이 있었는데, 표준 예외를 적극적으로 쓸 경우 예외가 발생했을때 이게 서비스 개발자가 의도적으로 일으킨 예외인지, 아니면 개발자가 예상하지 못한 부분에서 발생한 예외인지 구분하기가 어려웠다. AbstractMiddleException 같은 애플리케이션 표준 예외를 둘 경우, 이 예외를 상속받지 않은 (IllegalStateException 같은) 예외가 발생한다면 타입명만 보고 바로 개발자가 예상하지 못한 부분에서 예외가 발생했다는걸 알 수 있었다.

 

# 정리

예외처리에 대한 고민을 하다가 어떻게하면 좀 더 유려하게 예외처리를 할 수 있을지 고민이 많았고, 내 나름대로의 답을 정리해봤다. 나는 꽤 긴 시간 이 방식대로 예외처리 로직을 작성하고 있는데 아직은 불편함을 느끼지 못했다. 오히려 특정 예외 상황에 대해 한 개의 로그만 남고, 그 로그 안에서 스택트레이스를 통해 모든 정보를 파악할 수 있어서 디버깅에도 편리했고 로그파일이 불필요하게 커지는것도 줄일 수 있었다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함