우테코 5기

에러코드, 상태코드, 예외 메세지를 쉽게 관리해보자!(+ 에러코드 문서화)

teo_99 2023. 8. 24. 00:32

들어가면서

웹 어플리케이션을 개발하다 보면 수많은 예외 상황에 마주하게 됩니다. 그리고 좋은 어플리케이션을 구축하기 위해서는 이런 예외 상황들에 대해 적절한 응답을 내려주어야만 합니다.

 

일반적으로 예외 핸들링을 위해 관리해야 하는 정보는 다음과 같습니다:

  • 상태코드
  • 에러코드
  • 예외 메세지

에러 코드의 경우에는 서비스마다 관리 여부가 다를 수 있겠으나, 제가 현재 개발하고 있는 '하루스터디' 서비스에서는 위 세 가지 요소를 전부 다 관리하고 있습니다.

 

그렇다면 위 요소들은 어떻게 관리가 되어야 할까요? 가장 먼저 떠올릴 수 있는 방안은 '예외 객체 자체에서 관리하는 것' 입니다.

public class StudyNotFoundException extends RuntimeException {
	
    private static final String MESSAGE = "스터디를 찾지 못했습니다.";
    private static final HttpStatus STATUS_CODE = HttpStatus.NOT_FOUND;
    private static final int ERROR_CODE = 1201;
    
    // ...
}

각 예외마다 관련된 정보(상태코드, 에러코드, 메세지)를 내부적으로 관리한다는 점이 효율적으로 보입니다. 하지만 몇 가지 문제점을 안고 있습니다.

 

1. 계층 구조가 지켜지고 있지 않다

예외 메세지, 상태 코드, 에러 코드는 뷰(View)에 종속적인 데이터에 가깝습니다. 물론 로깅용으로 활용되기도 하지만, 클라이언트에게 전달되는 용도로 사용되는 경우가 많습니다.

반면 예외 객체는 비즈니스에 종속적인 객체입니다. StudyNotFoundException은 '스터디를 찾지 못했다' 라는 문맥을 나타냅니다. 이런 예외 객체가 상태코드, 에러코드, 예외 메세지와 같은 뷰에 종속적인 데이터를 포함해야 할 필요가 있을지 의문이 들었습니다. 전통적인 계층형 아키텍처를 고수한다면 가장 신경써야하는 부분은 '계층의 격리'이기 때문입니다.

 

2. 변경 지점을 예측할 수 없다

예외 객체가 여러 정보를 담고 있다는 이야기는 변경 요인이 여러 개라는 의미입니다. 만약 모종의 이유로 에러코드를 바꾸어야 한다거나, 예외 메세지를 보다 상세하게 기술해야 하는 요구사항이 생긴다면 어떨까요?

// 과연 어느 패키지의 클래스가 변경될까?
├── content
│   ├── controller
│   ├── domain
│   ├── dto
│   ├── exception
│   ├── repository
│   └── service
├── member
│   ├── controller
│   ├── domain
│   ├── dto
│   ├── exception
│   ├── repository
│   └── service
├── progress
│   ├── controller
│   ├── domain
│   ├── dto
│   ├── exception
│   ├── repository
│   └── service
...

예외 객체 내에서 모든 정보를 관리하는 방식으로는 변경에 쉽게 대응하기가 어렵습니다. "에러코드 1201에 해당하는 예외의 메세지를 보다 상세하게 작성해줘!" 라는 클라이언트의 요구사항이 있다고 한다면 어느 패키지의 어느 클래스가 바뀌어야 할지 쉽게 예측할 수 없습니다. IDE의 도움을 받아 에러코드 1201에 해당하는 예외를 찾아서 메세지를 변경해야겠죠.

 

따라서 하루스터디 프로젝트에서는 에러 코드, 상태 코드, 예외 메세지를 한 데서 관리하고자 했고 해당 방법을 소개하고자 합니다. 


Mapper를 통해 변경지점을 하나로 모으기

예외와 관련된 정보들을 관리하는 데에는 여러 방법이 있지만, 하루스터디는 Map을 사용하기로 결정했습니다. Map은 key 값으로 예외 클래스를 가지고, Value 값으로는 예외와 관련된 정보들을 담은 객체를 가지게 했습니다.

public class ExceptionMapper {

    private static final Map<Class<? extends Exception>, ExceptionSituation> mapper = new LinkedHashMap<>();
    ...
}

ExceptionSituation은 아래와 같은 형태의 객체입니다.

@Getter
public class ExceptionSituation {

    private final String message;
    private final HttpStatus statusCode;
    private final Integer errorCode;
    ...
}

그리고 아래와 같이 static 블록을 통해 예외 객체와 관련된 정보를 매핑합니다. 

static {
        mapper.put(PomodoroProgressNotFoundException.class,
                ExceptionSituation.of("해당 스터디에 참여한 상태가 아닙니다.", NOT_FOUND, 1201));
        mapper.put(PomodoroProgressStatusException.class,
                ExceptionSituation.of("스터디 진행 상태가 적절하지 않습니다.", BAD_REQUEST, 1202));
        mapper.put(ProgressNotBelongToRoomException.class,
                ExceptionSituation.of("해당 스터디에 참여한 기록이 없습니다.", BAD_REQUEST, 1203));
        ...
}

이후에는 단순히 아래와 같은 메소드를 통해 관련된 정보를 조회하기만 하면 됩니다. getSituationOf 메소드는 ExceptionHandler에서 예외가 발생했을 때 사용되는 관련된 정보를 얻기 위해서 사용되는 메소드이며, Object의 getClass() 메소드를 통해 런타임 시점의 구현체 클래스를 Key 값으로 사용합니다.

// ExceptionMapper.java
public static ExceptionSituation getSituationOf(Exception exception) {
    return mapper.get(exception.getClass());
}
    @ExceptionHandler(HaruStudyException.class)
    public ResponseEntity<ExceptionResponse> handleHaruStudyException(HaruStudyException e) {
        ExceptionSituation exceptionSituation = ExceptionMapper.getSituationOf(e);
        return ResponseEntity.status(exceptionSituation.getStatusCode())
                .body(ExceptionResponse.from(exceptionSituation));
    }

만약 위처럼 Mapper를 구성하면 예외와 관련된 정보에 대한 변경 지점이 하나로 묶이게 됩니다. 또한, 메소드 분리를 통해 도메인 별 혹은 기능 별로 예외 객체를 묶어서 확인하는 것도 가능합니다. 하루스터디의 경우에는 기능별로 예외 객체를 메소드로 묶어서 관리하는데, 다음과 같은 형태입니다.

public class ExceptionMapper {

    private static final Map<Class<? extends Exception>, ExceptionSituation> mapper = new LinkedHashMap<>();

    static {
        setUpMemberException();
        setUpPomodoroContentException();
        setUpPomodoroProgressException();
        setUpRoomException();
        setUpAuthenticationException();
        setUpAuthorizationException();
    }

    private static void setUpMemberException() {
        mapper.put(MemberNotFoundException.class,
                ExceptionSituation.of("해당하는 멤버가 없습니다.", NOT_FOUND, 1002));
    }
    ...
}

 

예외 명세 자동화

또한, 예외와 관련된 정보를 한 군데에서 관리하는 방식이므로 에러코드 명세 자동화가 가능해집니다.

 

thymeleaf와 위 Mapper의 내용을 조합하면 에러코드 명세 페이지를 손쉽게 만들 수 있다는 의미입니다. 실제로 하루스터디에서는 아래와 같이 에러코드 명세 페이지를 별다른 추가적인 비용 없이 생성해서 사용하고 있습니다. 

thymeleaf 페이지를 렌더링하는 핸들러는 다음과 같습니다.

@Controller
public class ErrorCodeView {

    @GetMapping("/error-code")
    public String errorCodeView(Model model) {
        List<ExceptionSituation> exceptionSituations = ExceptionMapper.getExceptionSituations();
        model.addAttribute("exceptionSituations", exceptionSituations);
        return "error-code";
    }
}

 

마무리하며

본문에서 제시한 메커니즘을 구축함으로써 예외와 관련한 정보를 한 지점에서 관리할 수 있게 되었습니다. 또한, 예외와 관련한 정보가 한 군데에 모이게 되면서 에러코드 페이지도 손쉽게 만들어낼 수 있었습니다.

 

감사합니다.