에러 코드 및 성공 코드 도메인 별 분리하기(feat. interface)

밀크야살빼자·2024년 3월 18일
1

Spring Boot에는 다양한 예외 처리 방식을 제공하여 애플리케이션에서 예외가 발생했을 경우 적절하게 대응할 수 있습니다. 이전 아이돔은 각 도메인마다 성공 코드와 예외 코드를 하나의 파일로 관리하고 있었습니다. 이는 가독성 문제와 유지보수 문제를 야기할 수 있었습니다. 이를 개선하기 위해 헥사고날 아키텍처에 맞게 도메인별로 예외 코드를 유지하면서 ErrorResponse 와 SuccessResponse를 효율적으로 재사용하여 공통된 에러 응답을 체계적으로 관리하고자 예외 처리 방법과 동작 방법에 대해 알아보고 적용했습니다.

Error와 Exception

1. 예외 처리 동작 원리

Spring 요청 처리과정

예외 발생 시 요청 전달 흐름
Request → WAS(톰캣) → 필터 → 서블릿(디스패치 서블릿) → 인터셉터 → 컨트롤러 → 컨트롤러(예외발생) → 인터셉터 → 서블릿(디스패치 서블릿) → 필터 → WAS(톰캣) → WAS(톰캣) → 필터 → 서블릿(디스패치 서블릿) → 인터셉터 → 컨트롤러(BasicErrorController)

  1. ExceptionHandlerExceptionResolver는 에러 응답을 위한 ControllerControllerAdvice에 있는 @ExceptionHandler를 처리합니다.
    a. 예외가 발생한 컨트롤러 안에 적합한 @ExceptionHandler가 있는지 검사합니다.
    b. 컨트롤러의 @ExceptionHandler에서 처리가 가능하다면 처리하고, 그렇지 않으면 ControllerAdvice로 넘어갑니다.
    c. ControllerAdvice 안에 적합한 @ExceptionHandler가 있는지 검사하고, 없으면 다음 처리기로 넘어갑니다.
  2. ResponseStatusExceptionResolver는 HTTP 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException을 처리합니다.
    a. @ResponseStatus가 있는지 또는 ResponseStatusException인지 검사합니다.
    b. 맞으면 ServletResponsesendError()로 예외를 서블릿까지 전달하고, 서블릿이 BasicErrorController로 요청을 전달합니다.
  3. DefaultHandlerExceptionResolver는 스프링 내부의 기본 예외들을 처리합니다.
    a. Spring의 내부 예외인지를 검사하여 맞다면 에러를 처리하고 그렇지 않으면 넘어갑니다.
  4. 적합한 ExceptionResolver가 없다면 예외가 서블릿까지 전달되고, 서블릿은 SpringBoot가 진행한 자동 설정에 따라 BasicErrorController로 요청을 다시 전달합니다.

예를 들어, 서블릿에서 Exception이나 Response.sendError() 메서드가 호출되면, 해당 예외는 WAS로 전파됩니다. 이후 WAS는 해당 예외를 처리하기 위해 설정된 오류 페이지를 찾게 됩니다.

RuntimeException 예외가 WAS까지 전달되면, WAS는 설정된 오류 페이지 정보를 확인합니다. 만약 RuntimeException에 대한 오류 페이지로 "/error/500"이 지정되어 있다면, WAS는 오류 페이지를 출력하기 위해 내부에서 "/error-page/500"을 다시 요청합니다. 이후 해당 페이지가 사용자에게 보여지게 됩니다.

2. 스프링의 다양한 예외 처리 방법

  1. @ControllerAdvice@ExceptionHandler를 사용한 전역 예외 처리

    ExceptionHandlerExceptionResolver 내부의 ExceptionHandlerCache에서 관리하게 됩니다. 초기에 ExceptionHandlerExceptionResolver가 생성될 때, InitExceptionHandlerAdviceCache 메서드를 통해 ControllerAdvice에 정의된 ExceptionHandler에 해당하는 HandlerMethodResolver가 캐시에 등록됩니다. 이는 getExceptionHandlerMothod 메서드에서 확인할 수 있는데, 여기서는 Controller에서 정의한 ExcpetionHandler를 우선하여 조회합니다.

    • @ControllerAdvice
      • 여러 컨트롤러에서 발생하는 예외를 한 곳에서 처리할 수 있도록 해줍니다.
      • 전역적인 예외 처리를 담당합니다.
      • 여러 개의 @ExceptionHandler 메서드를 포함할 수 있습니다.
    • @ExceptionHandler
      • 특정 예외가 발생했을 때 처리할 메서드를 지정하는 어노테이션입니다.
      • @ControllerAdvice 어노테이션이 붙은 클래스 내부에서 사용됩니다.
      • 메서드의 매개변수로 처리할 예외 타입을 지정할 수 있습니다.
      • 해당 메서드는 예외가 발생했을 때 처리할 로직을 구현합니다.

    ControllerAdvice를 사용함으로써 얻을 수 있는 이점

    • 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능해집니다.
    • 직접 정의한 에러 응답을 일관성 있게 클라이언트에게 내려줄 수 있습니다.
    • 별도의 try-catch문이 없어 코드의 가독성이 높아집니다.

    ControllerAdvice 사용시 주의해야 할 점

    • 한 프로젝트 당 하나의 ControllerAdvice만 관리하는 것이 좋습니다.
    • 만약 여러 ControllerAdvice가 필요하다면 BasePackages나 어노테이션 등을 지정해야합니다.
    • 직접 구현한 Exception 클래스들은 한 공간에서 관리합니다.
    @ControllerAdvice
    public class GlobalExceptionHandler {
    		@ExceptionHandler(Exception.class)
    		public ResponseEntity<String> handleException(Exception e) {
    		
    		return ResponseEntity.status(INTERNAL_SERVER_ERROR).body("에러 메시지 : " + e.getMessage());
    	}
    }
  2. RespnseEntityExceptionHandler를 확장한 커스텀 예외 처리

    Spring은 스프링 예외를 미리 처리해둔 ResponseEntityExceptionHandler를 추상 클래스로 제공하고 있습니다. 이 클래스에는 스프링 예외에 대한 ExceptionHandler가 모두 구현되어 있으므로 @ControllerAdvice 클래스가 이를 상속받도록 함으로써 예외 처리를 담당할 수 있습니다. 만약 @ControllerAdvice 클래스가 ResponseEntityExceptionHandler를 상속받지 않는다면, 스프링 예외는 기본적으로 DefaultHandlerExceptionResolver에 의해 처리됩니다. 이 경우에는 예외 처리가 달라지므로 클라이언트가 일관되지 못한 에러 응답을 받을 수 있습니다. 또한, 기본적으로 에러 메시지를 반환하지 않기 때문에, 스프링 예외에 대한 에러 응답을 제공하려면 handlerExceptionInternal 메서드를 오버라이딩하여 구현해야 합니다.

    @CoontrollerAdvice
    public class CustomResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
    @ExceptionHandler(NotFoundPostException.class)
        public final ResponseEntity<Object> NotFoundPostException(NotFoundPostException e) {
           
            return ResponseEntity.status(e.getExceptionCode());
        }
     }
  3. RestControllerAdvice를 이용한 RESTful 예외 처리

    • @ControllerAdvice와 유사하지만 반환값이 @ResponseBody로 처리됩니다. 즉, 메서드의 반환값이 HTTP 응답으로 직접 전송됩니다.
    • RESTful API에서 발생하는 예외에 대한 처리를 담당합니다.
    • 여러 컨트롤러에서 발생하는 예외를 한 곳에서 일관되게 처리할 수 있습니다.
    @RestControllerAdvice
    public class RestControllerExceptionHandler {
    
        @ExceptionHandler(CustomException.class)
        public ResponseEntity<String> handleRestException(CustomException e) {
            
            return ResponseEntity.status(INTERNAL_SERVER_ERROR).body("에러 메시지 : " + e.getMessage());
        }
    }
  4. 특정 컨트롤러 메서드에 @ExceptionHandler를 직접 사용

    ExceptionHandlerExceptionResolver 내부의 ExceptionHandlerCache에서 관리됩니다. 특이한 점은 getExceptionHandlerMethod()를 호출할 때 캐시를 조회하고, 캐시에 없는 경우에만 캐시에 등록한다는 것입니다. 즉, Controller에 정의된 ExceptionHandler는 해당 예외가 발생할 때에만 객체가 생성됩니다.

    예외를 처리할 메서드에 붙이는 어노테이션으로, 한 메서드가 여러 예외를 처리하려면 ()안에 배열로 작성해야 한다.

    @RestController
    public class MyController {
    
        @ExceptionHandler(CustomException.class)
        public ResponseEntity<String> handleSpecificException(CustomException e) {
            
            return ResponseEntity.status(BAD_REQUEST).body("에러 메시지 : " + e.getMessage());
        }
    }
  5. ExceptionResponseStatus 어노테이션 추가

    ResponseStatusExceptionResolver에서 예외 처리를 수행합니다.

    HandlerExceptionResolverComposite.resolveException() 메서드에서 resolver를 순회할 때, 우선적으로 ExceptionHandlerExceptionResolver를 순회합니다. 이 방식은 전역적인 예외처리가 아닌 특정 컨트롤러에서 발생하는 예외에 대해서만 처리합니다.

    • 예외 클래스에 @ResponseStatus를 추가하여 특정 HTTP 상태 코드를 설정할 수 있습니다.
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public class MyException extends RuntimeException {
    	//예외 클래스
    }

3. 표준 예외처리 및 커스텀 예외처리 방법

표준 예외 처리

public void activateLike(){
        if(this.status == LikePostStatus.ACTIVE){
            throw new IllegalStateException("이미 좋아요 한 글은 좋아요가 불가능합니다.");
        }
}

장점

  • 표준 예외를 사용하면 이미 많은 사람들에게 익숙해진 규약을 그대로 따르기 때문에 다른 사람이 익히고 사용하기 쉬워진다.
  • 낯선 예외보다는 익숙한 예외를 사용하므로 읽기 쉬워지고 예외를 빠르게 파악할 수 있다.
  • 예외 클래스를 별도로 만들지 않아도 된다. 따라서 메모리 사용량과 클래스 적재 시간이 줄어든다.

단점

  • 상황에 정확히 맞는 예외가 존재하지 않을 수 있다.
  • 특정 예외 상황만을 걸러내기 어렵다.
  • 예외 클래스의 생성자로 String만 넘겨줄 수 있다. 다른 객체를 생성자의 매개변수에 넘겨줄 수 없어 구체적인 예외 상황을 알기 어렵다.

커스텀 예외처리

// CustomExceptionCode
@Getter
@RequiredArgsConstructor
public enum CustomExceptionCode{
	FILED_REQUIRED(BAD_REQUEST, "입력은 필수 입니다."),
	INVALID_TARGET_SELF(BAD_REQUEST, "본인은 해당 요청의 설정 대상이 될 수 없습니다."),
	INVALID_MESSAGE_BODY(BAD_REQUEST, "요청 바디의 형식이 잘못되었습니다."),
}
//ErrorResponse
public record ErrorResponse(String responseCode, String responseMessage, @JsonIgnore HttpStatus status) {

	@Builder
	public ErrorResponse {
	}

	public static ErrorResponse of(CustomExceptionCode code) {
		return ErrorResponse.builder()
			.status(code.getStatus())
			.responseCode(code.getName())
			.responseMessage(code.getMessage())
			.build();
	}

	public static ErrorResponse of(HttpStatus status, String responseMessage, String responseCode) {
		return ErrorResponse.builder()
			.responseCode(responseCode)
			.responseMessage(responseMessage)
			.status(status).build();
	}
}
@Getter
@AllArgsConstructor
public class BaseException extends RuntimeException{
    private final CustomExceptionCode code;
}
  • 마지막에 Exception을 작성해주는 것이 예외 컨벤션입니다.
  • Exception이나 RuntimeException을 상속 받습니다.
//Handler
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

	@ExceptionHandler(BaseException.class)
	protected ResponseEntity<ErrorResponse> handleCustomException(BaseException exception) {
		ErrorResponse error = ErrorResponse.of(exception.getCode());
		return ResponseEntity.status(error.status()).body(error);
	}
}

장점

  • 이름으로도 정보 전달이 가능하다.
  • 상세한 예외 정보를 제공할 수 있다.
  • 예외에 대한 응집도가 향상된다. 클래스를 만드는 행위는 관련 정보를 해당 클래스에서 최대한 관리하겠다는 이야기입니다. 표준예외를 통해서도 충분히 정볼르 전달할 수 있지만, 전달하는 정보의 양이 많아질수록 예외 발생 코드가 더러워집니다. 하지만 사용자 정의 예외를 사용하면 예외에 필요한 메시지, 전달할 정보의 데이터, 데이터 가공 메소드들을 한 곳에서 관리할 수 있습니다. 이는 객체의 책임이 분리된 깔끔한 코드를 볼 수 있습니다.
  • 예외 발생 후처리가 용이하다. 표준 예외를 사용하면 외부라이브러리에서 발생시켰을지 프레임워크 자체에서 발생시켰을지 등 어디에서 예외가 발생했는지 알 수 없습니다. 하지만 커스텀 예외를 사용하면 이러한 혼란을 줄일 수 있습니다. 또한, 예측 가능한 상황에서 의도적으로 발생시킨 예외와 그렇지 않은 예외를 구분할 수 있으며, 예외에 대한 후처리는 각각의 사용자 정의 예외 내부에서 처리한 뒤 일관성 있게 error.getMessage()등으로 처리할 수도 있습니다.
  • 예외 생성 비용을 절감한다. 자바에서는 Stack Trace로 인해 예외를 생성하는 행위는 생각보다 많은 비용이 소모됩니다. Stack Trace는 예외 발생 시 call stack에 있는 메소드 리스트를 저장합니다. 이를 통해 예외가 발생한 정확한 위치를 파악할 수 있지만 try-catch나 Advice를 통해 예외를 처리한다면 해당 예외의 Stack Trace는 사용하지 않을 때가 많다. 이는 만들어 놓고 사용하지 않는 형태로 비효율적이다.

단점

  • 표준 예외를 사용하는 것이 가독성이 좋습니다.
  • 커스텀 예외 클래스가 많아지면 복잡해지고 관리해야 할 클래스가 많아집니다.

4. 적용

확장 가능한 열거타입 사용하기
@RestControlerAdvice를 사용하여 전역적으로 발생하는 예외를 처리하고, @ExceptionHandlerResponseEntityExceptionHandler를 상속받아 특정 예외를 잡아서 하나의 메서드에서 공통적으로 처리했습니다. 또한, 특정 예외 메시지를 전달하고 어느 부분에서 예외가 발생했는지 파악하기 위해 커스텀 예외 방식을 택했습니다.

각 도메인에 따라 ResponseCode를 개별적으로 관리하고 ErrorResponseSuccessResponse를 일관되게 처리하기 위한 방법과 또 새로운 도메인 추가로 인한 응답 코드를 관리해야할 때의 확장성에 대한 고민이 있었습니다. 이러한 고민 끝에 Enum타입에 인터페이스를 추상화하여 사용하기로 결정 했습니다.

이 방식을 사용했을 때 다음과 같은 장점이 있습니다.

  • Enum 타입을 사용하여 각 도메인별로 응답 코드를 분리하고 관리할 수 있습니다.
  • 새로운 도메인이 추가되거나 응답 코드가 변경되어도 쉽게 대응할 수 있습니다.
  • 각 도메인에서 발생하는 예외에 대해 일관된 방식으로 처리할 수 있습니다.
  1. 컨트롤러에서 발생한 커스텀 예외들을 처리할 수 있도록 예외처리 핸들러 파일 내부를 아래와 같이 작성하였으며, 클라이언트에게 보내줄 에러 코드를 한 곳에서 관리할 수 있게하기 위해 에러 이름, HTTP 상태 및 메시지를 가지고 있는 Enum 클래스를 도메인마다 작성했습니다.
  1. 발생한 예외를 처리해줄 예외 클래스에 RuntimeException을 상속 받고 BaseResponseCode를 필드로 갖고 있습니다.
  1. Enum 클래스에서 사용할 메서드들을 인터페이스를 활용하여 정의합니다.
  1. 다음으로, 각 도메인에 맞는 Enum 클래스를 정의하고 BaseResponseCode 인터페이스를 implements합니다.
public enum PostResponseCode implemts BaseResponseCode {
	NOT_FOUND_POST(404, "게시글을 찾을 수 없습니다."),
	ALREADY_POST_EXISTS(409, "게시글이 이미 존재합니다.");
	
	private final HttpStatus status;
	private final String message;
	
		@Override
    public int getCode() {
        return code;
    }

    @Override
    public String getMessage() {
        return message;
    }
	  
	  @Override
	  public String getName() {
				 return name;
		}  
  1. 그 후, SuccessResponse, ErrorResponse와 같은 클래스들이 BaseResponseCode 하나로 응답을 넘겨주어 실제 사용자에게 JSON 형식으로 보여주기 위한 에러 응답, 성공 응답 형식을 지정해주었습니다

5. 마무리

예외 처리를 구현하는 과정에서 Enum 타입을 확장하여 응답 코드를 관리하고, 각 도메인에 맞는 예외 코드를 구분하여 커스텀 예외처리 방법을 선택했습니다. 이러한 선택은 도메인 마다 응답 코드를 관리하고 코드의 일관성과 가독성을 높이고 클라이언트에게 명확하고 유용한 정보를 제공하여 디버깅 및 문제 해결을 용이 위함이었습니다. 또한, 각 예외 코드에 대응하는 메시지를 인터페이스로 추상화하여 관리하여 유지보수성을 향상시켰습니다. 이를 통해 코드의 관리가 용이해지고, 프로젝트의 아키텍처에 맞게 예외 처리를 구현할 수 있었습니다.


참고 자료

profile
기록기록기록기록기록

0개의 댓글