땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 9. API 예외 메시지 처리 본문

Project/Board-System

[토이프로젝트] 게시판 시스템(board-system) 9. API 예외 메시지 처리

ttasjwi 2024. 10. 21. 17:54

 

 

이번 글에서는 서비스 전반의 예외를 전역수준에서 어떻게 처리하여, 클라이언트에게 응답으로 내려줄지 다뤄보겠습니다.

 

기본적으로 스프링 웹 애플리케이션을 작성해보시면 오류가 발생했을 때 스프링이 알아서 에러 응답을 작성해줍니다만, 스프링이 작성한 예외 응답은 우리가 통제할 수 없는 영역 밖입니다. 우리가 통제할 수 있는 예외 응답을 내려줄 수 있도록 해야하는데요. 이 부분을 다뤄보겠습니다. 

 

사실 메시지/국제화 처리 및 커스텀 예외 등을 정의해둔 터라 예외 처리에 대한 기틀은 전부 지난 글들에서 다뤘습니다.

결론만 내려보면 전역 예외처리는 @RestControllerAdvice@ExceptionHandler 를 써주고, 필터쪽 설정만 살짝 해주면 되긴합니다.

 

그러나 이렇게만 작성하면 글 분량이 없어지고 위의 RestControllerAdvice, ExceptionHandler 기술이 어떻게 도입됐고 동작 원리가 어떻게 되는지 잊기 쉬운데, 어떤 원리로 작동되는지 모르면 나중에 문제가 발생했을 때 문제를 겪을 가능성이 큽니다. 이번 글에서는 여러 글, 강의들을 참고하면서 배웠던 스프링 예외처리 동작원리를 설명하고, 실질적으로 어떻게 예외를 처리할 지 실제 코드를 작성해보는 것을 해보겠습니다.


1. 예외 발생

 

 

서블릿 기반의 스프링 Web 아키텍처는 크게 두 곳으로 나뉠 수 있습니다.

 

1. 스프링 MVC 영역 : DispatcherServlet 이후에서 발생한 예외(인터셉터, 컨트롤러)

2. 스프링 MVC 가 시작되는 DispatcherServlet 이전 : 서블릿 필터

 

 

여기서 1에서 발생한 예외는 보통 1에서 대부분 처리할 수 있고, 처리되지 못 한 예외는 2로 넘어가게 됩니다.

2에서도 처리되지 못 하면 WAS 까지 예외가  전파되어 WAS에 전파됩니다.

 


2. DispatcherServlet 이후(컨트롤러 이후)에서 발생한 예외처리 - 원리

 

일단 기본적으로 DispatcherServlet 내부의 흐름은 간단하게 요약하면 위의 4가지 단계로 이루어진다고 볼 수 있습니다.

(HandlerMapping 등을 찾는 과정은 생략합니다.)

 

1. 인터셉터(HandlerInterceptor)가 등록되어 있다면 preHandle 호출

2. 컨트롤러가 호출

3. (인터셉터가 등록되어 있다면) 컨트롤러 로직이 성공하면 postHandle 이 호출

4. (인터셉터가 등록되어 있다면) 예외 발생 여부에 관계 없이 afterCompletion 이 호출

 

이런 흐름을 타게 됩니다.

 

 

여기서, 우리가 집중할 부분은 예외가 발생하는 지점인데요.

 

컨트롤러에서 예외가 발생할 경우 postHandle이 호출되지 않고 HandlerExceptionResolver를 통해 예외 처리를 위임하게 됩니다. 또는 인터셉터쪽에서 preHandle, postHandler 과정에서 예외가 발생하더라도 동일하게 HandlerExceptionResolve가 호출됩니다.

 

이때 HandlerExceptionResolver 를 통해 예외가 처리되지 못했거나 인터셉터의 afterCompletion에서 예외가 발생했다면 DispatcherServlet 바깥으로 예외가 전파(throw)됩니다.

 

			catch (Exception ex) {
				dispatchException = ex;
			}
			catch (Throwable err) {
				dispatchException = new ServletException("Handler dispatch failed: " + err, err);
			}
			processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
		}

실제 코드를 통해 설명해보면

 

DispatcherServlet 에서는 예외가 발생했을 때 try-catch 문으로 예외를 감싸고

processDispatchResult 라는 부분에서 예외를 처리하는 코드가 있습니다.

 

	private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
			@Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
			@Nullable Exception exception) throws Exception {

		boolean errorView = false;

		if (exception != null) {
			if (exception instanceof ModelAndViewDefiningException mavDefiningException) {
				logger.debug("ModelAndViewDefiningException encountered", exception);
				mv = mavDefiningException.getModelAndView();
			}
			else {
				Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
				mv = processHandlerException(request, response, handler, exception);
				errorView = (mv != null);
			}
		}

		// Did the handler return a view to render?
		if (mv != null && !mv.wasCleared()) {
			render(mv, request, response);
			if (errorView) {
				WebUtils.clearErrorRequestAttributes(request);
			}
		}

		if (mappedHandler != null) {
			// Exception (if any) is already handled..
			mappedHandler.triggerAfterCompletion(request, response, null);
		}
	}

 

몇 가지 코드는 생략했는데 잘 보시면

 

1. 예외가 있다면 processHandlerException 메서드가 호출되어 예외 처리를 위임하고 ModelAndView 를 반환받고 ModelAndView 변수(mv)에 재할당합니다.

 

2. ModelAndView 를 기반으로 render 합니다. (응답 메시지 작성)

  - 참고로 만약 앞의 과정에서 사용된 컴포넌트가 렌더링까지 다 한경우 빈 ModelAndView 가 반환되어 여기서는 render 작업은 사실상 이루어지지 않게됩니다.

 

3. TriggerAfterCompletion 을 통해 HandlerInterceptor 의 afterCompletion 을 호출합니다.

 

@Nullable
private List<HandlerExceptionResolver> handlerExceptionResolvers;

@Nullable
protected ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
        @Nullable Object handler, Exception ex) throws Exception {

    // 생략
    ModelAndView exMv = null;
    if (this.handlerExceptionResolvers != null) {
        for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
            exMv = resolver.resolveException(request, response, handler, ex);
            if (exMv != null) {
                break;
            }
        }
    }
    if (exMv != null) {
        // 생략
        return exMv;
    }

    throw ex;
}

 

processHandlerException 쪽에서는 HandlerExceptionResolver 목록을 순회하면서

 

각 resolver 들에게 resolveException 을 호출하고, 가장 먼저 null 이 아닌 ModelAndView 를 반환할 떄 순회를 멈추고 해당 객체를 사용하는 것을 알 수 있습니다.

 

결국 예외를 실질적으로 처리하는 역할은 HandlerExceptionResolver 가 담당합니다.

 

그리고 HandlerExceptionResolver 에서 예외를 처리하지 못 했다면 예외를 그대로 throw 하는 것을 볼 수 있습니다.

 

public class WebMvcAutoConfiguration {

	@Configuration(proxyBeanMethods = false)
	@Import(EnableWebMvcConfiguration.class)
	@EnableConfigurationProperties({ WebMvcProperties.class, WebProperties.class })
	@Order(0)
	public static class WebMvcAutoConfigurationAdapter implements WebMvcConfigurer, ServletContextAware {

 

그렇다면 DispatcherServlet에서 사용되는 HandlerExceptionResolver는 구현체는 어떻게 등록될까요?

자동구성 클래스 흐름을 따라가보겠습니다.

 

스프링 부트의 자동구성 클래스 WebMvcAutoConfiguration 은 내부적으로 EnableWebMvcConfiguration 설정을 Import 해옵니다.

 

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(WebProperties.class)
public static class EnableWebMvcConfiguration extends DelegatingWebMvcConfiguration implements ResourceLoaderAware {
@Configuration(proxyBeanMethods = false)
public class DelegatingWebMvcConfiguration extends WebMvcConfigurationSupport {

 

이 설정 클래스는 DelegatingWebMvcConfiguration, 그보다 더 부모에는 WebMvcConfigurationSupport 를 상속하고 있습니다.

 

@Bean
public HandlerExceptionResolver handlerExceptionResolver(
        @Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager) {
    List<HandlerExceptionResolver> exceptionResolvers = new ArrayList<>();
    configureHandlerExceptionResolvers(exceptionResolvers);
    if (exceptionResolvers.isEmpty()) {
        addDefaultHandlerExceptionResolvers(exceptionResolvers, contentNegotiationManager);
    }
    extendHandlerExceptionResolvers(exceptionResolvers);
    HandlerExceptionResolverComposite composite = new HandlerExceptionResolverComposite();
    composite.setOrder(0);
    composite.setExceptionResolvers(exceptionResolvers);
    return composite;
}

 

찾았다! WebMvcConfigurationSupport 쪽에 HanlderExceptionResolver 등록 코드가 있습니다.

 

여기서 configureHandlerExceptionResolvers 에서는 우리가 별도로 설정으로 등록한 HandlerExceptionResolver가 등록되는 부분이고, 그런 설정이 없다면 addDefaultHandlerExceptionResolvers 를 통해 HandlerExceptionResolver 들이 등록됩니다.

 

	protected final void addDefaultHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers,
			ContentNegotiationManager mvcContentNegotiationManager) {

		ExceptionHandlerExceptionResolver exceptionHandlerResolver = createExceptionHandlerExceptionResolver();
		exceptionHandlerResolver.setContentNegotiationManager(mvcContentNegotiationManager);
		exceptionHandlerResolver.setMessageConverters(getMessageConverters());
		exceptionHandlerResolver.setCustomArgumentResolvers(getArgumentResolvers());
		exceptionHandlerResolver.setCustomReturnValueHandlers(getReturnValueHandlers());
		if (jackson2Present) {
			exceptionHandlerResolver.setResponseBodyAdvice(
					Collections.singletonList(new JsonViewResponseBodyAdvice()));
		}
		if (this.applicationContext != null) {
			exceptionHandlerResolver.setApplicationContext(this.applicationContext);
		}
		exceptionHandlerResolver.afterPropertiesSet();
		exceptionResolvers.add(exceptionHandlerResolver);

		ResponseStatusExceptionResolver responseStatusResolver = new ResponseStatusExceptionResolver();
		responseStatusResolver.setMessageSource(this.applicationContext);
		exceptionResolvers.add(responseStatusResolver);

		exceptionResolvers.add(new DefaultHandlerExceptionResolver());
	}

 

기본 구현체는 HandlerExceptionResolverComposite 이며 별다른 설정을 안 했다면 이것 내부에는 ExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver 가 주입됩니다.

 

 

 

간단히 내부 구현코드를 읽은 결과를 설명하겠습니다.(이것까지 기술하면 글이 길어질 것 같아서...)

 

1. ExceptionHandlerExceptionResolver : @ExceptionHandler 어노테이션이 달린 메서드를 찾아 실행 후 그 반환값을 기반으로 응답 메시지 작성

2. ResponseStatusExceptionResolver : @ResponseStatus 어노테이션이 달린 메서드일 경우 상태코드만 담긴 응답 작성

3. DefaultHandlerExceptionResolver : 스프링 MVC 표준 예외들을 기반으로 Spring MVC 의 기본 예외 응답 메시지 작성

 

이 세가지 처리가 이루어집니다.

 

1번은 우리가 @ExceptionHandler 어노테이션을 이용한 예외 처리 메서드를 작성해두면 그것이 우선시되어 처리되고

2,3 은 후순위로 밀리며,우리가 1을 설정하지 않으면 동작됩니다. 여기서도 처리하지 못 하면 ModelAndView 가 null 이 반환됩니다. 

 

	@Override
	@Nullable
	public ModelAndView resolveException(
			HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {

		if (this.resolvers != null) {
			for (HandlerExceptionResolver handlerExceptionResolver : this.resolvers) {
				ModelAndView mav = handlerExceptionResolver.resolveException(request, response, handler, ex);
				if (mav != null) {
					return mav;
				}
			}
		}
		return null;
	}

 

HandlerExceptionResolverComposite 는 내부적으로 위에서 언급한 3개의 HandlerExcepetionResolver 들을 목록으로 가지고 있고 이들을 순회하면서 가장 null 이 아닌 ModelAndView 를 반환될 때 그것을 반환합니다.

 

 

여기서 중간요약을 해보면

 

1. Spring MVC 영역에 해당하는 DispatcherServlet 에서는 컨트롤러 및 인터셉터의 preHandler, postHandler에서 발생하는 예외를 잡아서 HandlerExceptionResolver 목록에 위임하는데, 기본으로 사용되는 것이HandlerExceptionResolverComposite 입니다. (인터셉터의 afterCompletion에서 발생하는 예외는 그대로 throw 됨)

 

2. 이것의 내부에는 ExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver가 있습니다.

 

3. 개발자가 @ExceptionHandler 를 통해 작성한 예외 처리로직은 ExceptionHandlerExceptionResolver 에서 처리하며 이것이 가장 우선순위가 높습니다. 이때 그 예외가 발생한 컨트롤러쪽에 @ExceptionHandler 가 달린 메서드가있으면  우선적으로 처리되고, 그렇지 않을 경우 @ControllerAdvice 가 설정된 클래스의 @ExceptionHandler 를 찾아 우선적으로 처리됩니다. 또, @ExceptionHandler 안에서도 구체적인 예외일 수록 그 메서드가 먼저 가로채 처리합니다.

 

이때 @ExceptionHandler 가 달린 메서드가 반환한 값은 내부적으로 HandlerMethodReturnValueHandlerComposite 가 사용되어 반환 객체를 기반으로 응답 Http 메시지가 작성됩니다.

 

4. 이를 설정하지 않았다면 ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver 순으로 예외 처리가 위임됩니다.

 

5. 이런 HandlerExceptionResolver 들에서 예외가 처리되지 못 했다면 예외는 그대로 바깥으로 DispatcherServlet 바깥으로 전파(throw) 됩니다.

 

 

여기서 우리가 만약 예외 응답을 커스텀하게, 일관된 방식으로 작성하고 싶다면 어떻게 해야할까요?

 

@ExceptionHandler 를 이용한 예외처리를 하는 것이 제일 편할 것입니다. 가장 우선순위가 높기 때문이죠. 그 뒤에서는 우리가 통제할 수 없는 방식으로, 스프링의 기본 동작에 의한 응답이 나가거나, 그대로 예외가 throw 되어서 DispatcherServlet 바깥으로 예외가 나가겠죠.


3. DispatcherServlet 이후(컨트롤러 이후)에서 발생한 예외처리 - 구현

 

앞에서 설명하대로 HandlerExceptionResolver를 통해 예외를 처리할 수 있고, 가장 쉽게 처리하는 방법은

@ExceptionResolver , @ControllerAdvice를 활용하여 예외 처리 메서드를 작성한 뒤 ExceptionHandlerExceptionResolver가 예외를 처리할 수 있도록 하면 됩니다.

 

단, Exception 바깥에서 발생한 Throwable 예외는 개발자가 해결할 수 없는 심각한 예외에 해당하므로 따로 처리하지 않고 별도로 처리하지 않게 하면 될 것 같아요.

 

이걸 실제로 코드 작성을 통해 처리해보겠습니다.

 

3.1 예외 처리 모듈

rootProject.name = "board-system"

include(

    // 설정 최종 구성 및 실행
    "board-system-container",

    // 공용 모듈
    "board-system-core",
    "board-system-logging",

    // api
    "board-system-api:api-core",
    "board-system-api:api-deploy",

    // external : 외부 기술 의존성
    "board-system-external:external-message",
    "board-system-external:external-exception-handle"
)

 

settings.gradle.kts 에서 external-excepetion-handle 모듈을 선언했습니다.

 

dependencies {
    implementation(project(":board-system-api:api-core"))
    implementation(Dependencies.SPRING_BOOT_WEB.fullName)
    implementation(Dependencies.KOTLIN_JACKSON.fullName)
}

 

external-excepetion-handle 모듈의 build.gradle.kts 에서는 기본적인 웹 관련 의존성 및 api-core 모듈 의존성을 가지고 갑니다.

dependencies {
    implementation(Dependencies.SPRING_BOOT_STARTER.fullName)

    // api
    implementation(project(":board-system-api:api-core"))
    implementation(project(":board-system-api:api-deploy"))

    // external
    implementation(project(":board-system-external:external-message"))
    implementation(project(":board-system-external:external-exception-handle"))
}

tasks.getByName("bootJar") {
    enabled = true
}

tasks.getByName("jar") {
    enabled = false
}

 

컨테이너 모듈 쪽에서는 externral-exception-handle 모듈 의존성을 추가합니다.

 

 

본격적으로 DispatcherServlet 이후 예외처리를 시작해보겠습니다.

3.2 resolveHttpStatus 함수

package com.ttasjwi.board.system.core.exception.api

import com.ttasjwi.board.system.core.exception.ErrorStatus
import org.springframework.http.HttpStatus

internal fun resolveHttpStatus(status: ErrorStatus): HttpStatus {
    return when (status) {
        ErrorStatus.INVALID_ARGUMENT -> HttpStatus.BAD_REQUEST
        ErrorStatus.NOT_FOUND -> HttpStatus.NOT_FOUND
        ErrorStatus.UNAUTHENTICATED -> HttpStatus.UNAUTHORIZED
        ErrorStatus.FORBIDDEN -> HttpStatus.FORBIDDEN
        ErrorStatus.CONFLICT -> HttpStatus.CONFLICT
        ErrorStatus.APPLICATION_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR
    }
}

 

 

우선 커스텀 예외가 가지고 있던 ErrorStatus 를 기반으로 HttpStatus 를 만들기 위해 작성한 resolveHttpStatus 함수입니다.

package com.ttasjwi.board.system.core.exception.api

import com.ttasjwi.board.system.core.exception.ErrorStatus
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus

@DisplayName("ErrorHttpStatusResolveFunction 테스트")
class ErrorHttpStatusResolveFunctionTest {

    @Test
    @DisplayName("ErrorStatus.INVALID_ARGUMENT 는 BAD REQUEST 상태코드로 변환한다.")
    fun caseInvalidArgument() {
        val errorStatus = ErrorStatus.INVALID_ARGUMENT
        val httpStatus = resolveHttpStatus(errorStatus)
        assertThat(httpStatus).isEqualTo(HttpStatus.BAD_REQUEST)
    }

    @Test
    @DisplayName("ErrorStatus.NOT_FOUND 는 NOT FOUND 상태코드로 변환한다.")
    fun caseNotFound() {
        val errorStatus = ErrorStatus.NOT_FOUND
        val httpStatus = resolveHttpStatus(errorStatus)
        assertThat(httpStatus).isEqualTo(HttpStatus.NOT_FOUND)
    }

    @Test
    @DisplayName("ErrorStatus.UNAUTHENTICATED 는 UNAUTHORIZED 상태코드로 변환한다.")
    fun caseUnauthenticated() {
        val errorStatus = ErrorStatus.UNAUTHENTICATED
        val httpStatus = resolveHttpStatus(errorStatus)
        assertThat(httpStatus).isEqualTo(HttpStatus.UNAUTHORIZED)
    }

    @Test
    @DisplayName("ErrorStatus.FORBIDDEN 은 FORBIDDEN 상태코드로 변환한다.")
    fun caseForbidden() {
        val errorStatus = ErrorStatus.FORBIDDEN
        val httpStatus = resolveHttpStatus(errorStatus)
        assertThat(httpStatus).isEqualTo(HttpStatus.FORBIDDEN)
    }

    @Test
    @DisplayName("ErrorStatus.CONFLICT 는 CONFLICT 상태코드로 변환한다.")
    fun caseConflict() {
        val errorStatus = ErrorStatus.CONFLICT
        val httpStatus = resolveHttpStatus(errorStatus)
        assertThat(httpStatus).isEqualTo(HttpStatus.CONFLICT)
    }

    @Test
    @DisplayName("ErrorStatus.APPLICATION 은 INTERNAL_SERVER_ERROR 상태코드로 변환한다.")
    fun caseApplicationError() {
        val errorStatus = ErrorStatus.APPLICATION_ERROR
        val httpStatus = resolveHttpStatus(errorStatus)
        assertThat(httpStatus).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
    }
}

 

이에 대한 간단한 테스트 코드도 작성했습니다.

 

3.3 GlobalExceptionController

package com.ttasjwi.board.system.core.exception.api

import com.ttasjwi.board.system.core.api.ErrorResponse
import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.core.exception.ValidationExceptionCollector
import com.ttasjwi.board.system.core.message.MessageResolver
import com.ttasjwi.board.system.logging.getLogger
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
internal class GlobalExceptionController(
    private val messageResolver: MessageResolver
) {

    companion object {
        private val log = getLogger(GlobalExceptionController::class.java)
    }

    /**
     * 커스텀 예외가 아닌 모든 예외들은 기본적으로 여기로 오게 됩니다.
     * 커스텀 예외가 아닌 예외들은 커스텀 예외로 감싸거나 다른 ExceptionHandler 를 통해 처리할 수 있도록 만들어야 합니다.
     * 여기로 온 예외는 모두 500 예외로 나가게 됩니다.
     */
    @ExceptionHandler(Exception::class)
    fun handleException(e: Exception): ResponseEntity<ErrorResponse> {
        val code = "Error.Server"
        val httpStatus = HttpStatus.INTERNAL_SERVER_ERROR

        log.error(e)

        return ResponseEntity
            .status(httpStatus)
            .body(
                makeErrorResponse(
                    listOf(
                        ErrorResponse.ErrorItem(
                            code = code,
                            message = messageResolver.resolveMessage(code),
                            description = messageResolver.resolveDescription(code),
                            source = "server"
                        )
                    )
                )
            )
    }

    /**
     * 커스텀 예외를 처리합니다.
     */
    @ExceptionHandler(CustomException::class)
    fun handleCustomException(e: CustomException): ResponseEntity<ErrorResponse> {
        val code = e.code
        val httpStatus = resolveHttpStatus(e.status)

        return ResponseEntity
            .status(httpStatus)
            .body(
                makeErrorResponse(
                    listOf(
                        ErrorResponse.ErrorItem(
                            code = code,
                            message = messageResolver.resolveMessage(code),
                            description = messageResolver.resolveDescription(code, e.args),
                            source = e.source
                        )
                    )
                )
            )
    }

    /**
     * 검증 예외 수집기에 수집된 예외들을 모아서 하나의 응답으로 내립니다.
     */
    @ExceptionHandler(ValidationExceptionCollector::class)
    fun handleValidationExceptionCollector(exceptionCollector: ValidationExceptionCollector): ResponseEntity<ErrorResponse> {
        val httpStatus = resolveHttpStatus(exceptionCollector.status)
        return ResponseEntity
            .status(httpStatus)
            .body(
                makeErrorResponse(
                    exceptionCollector.getExceptions().map {
                        ErrorResponse.ErrorItem(
                            code = it.code,
                            message = messageResolver.resolveMessage(it.code),
                            description = messageResolver.resolveDescription(it.code, it.args),
                            source = it.source
                        )
                    }
                )
            )
    }

    private fun makeErrorResponse(
        errors: List<ErrorResponse.ErrorItem>
    ): ErrorResponse {

        val commonCode = "Error.Occurred"
        return ErrorResponse(
            code = commonCode,
            message = messageResolver.resolveMessage(commonCode),
            description = messageResolver.resolveDescription(commonCode),
            errors = errors
        )
    }
}

 

@RestControllerAdvice, @ExceptionHandler를 활용하여 DispatcherServlet 전역 예외 처리기를 만들었습니다.

 

 

handleException

커스텀 예외가 아닌 예외(순수 java 예외, spring 예외, 그외 서드파티 라이브러리 예외) 모두 500 예외 응답형태로, 서버 에러가 발생했다는 수준으로 추상화해서 응답을 내보냅니다.

 

이러면 사용자는 우리서비스에서 돌발적으로 발생한 예외가 발생했을 때 구체적으로 무슨 예외가 발생했는지를 알 지 못 합니다.

 

다만 500 에러가 많이 발생하는 것은 서비스의 신뢰도 관점에서 좋지 못 합니다.

이를 위해 예외의 로그를 남기도록 하고, 향후 개발자가 이 예외를 별도의 @ExceptioHandler 를 통해 처리하게 하거나, 그 예외의 발생 지점을 추척해서 커스텀 예외로 만들어 사용자에게 해당 예외에 특화된 적절한 오류 메시지를 작성할 수 있게 해야합니다.

 

handleCustomException

커스텀 예외를 처리합니다.

 

커스텀 예외에는 우리가 등록한 커스텀한 정보들(args, source, debugMessage, code, ...)가 있죠.

이를 활용하여 예외 응답 메시앞에서 만든 MessageResolver 를 통해 국제화된 오류 응답을 내보낼 수 있습니다.

 

handleValidationExceptionCollector

검증예외 수집기(ValidationExceptionCollector) 예외는 handleValidationExceptionCollector 에서 처리되게 했습니다.

 

 

이러면 Exception 이하의 모든 자손들은 이 처리기에서 처리될 수 있습니다.

 

3.4 GlobalExceptionController 테스트

 

테스트 코드를 작성해보겠습니다.

 

3.4.1 MessageResolverFixture / MessageResolverFixtureTest

package com.ttasjwi.board.system.core.message.fixture

import com.ttasjwi.board.system.core.message.MessageResolver

class MessageResolverFixture : MessageResolver {

    override fun resolveMessage(code: String): String {
        return "$code.message"
    }

    override fun resolveDescription(code: String, args: List<Any?>): String {
        return "$code.description(args=$args)"
    }
}
package com.ttasjwi.board.system.core.message.fixture

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

@DisplayName("MessageResolverFixture 테스트")
class MessageResolverFixtureTest {

    private val messageResolver = MessageResolverFixture()

    @Test
    @DisplayName("resolveMessage: code를 전달하면 code 뒤에 code.message 을 반환한다.")
    fun testGeneralTitle() {
        val code = "hello"
        val title = messageResolver.resolveMessage(code)
        assertThat(title).isEqualTo("$code.message")
    }

    @Test
    @DisplayName("resolveDescription: code와 args를 전달하면 `code.description(args=[args원소들])'을 반환한다.")
    fun testDescription() {
        val code = "hello"
        val args = listOf(1, 2)
        val description = messageResolver.resolveDescription(code, args)
        assertThat(description).isEqualTo("$code.description(args=[1, 2])")
    }

    @Test
    @DisplayName("resolveDescription: code만 전달하면 `code.description(args=[])'을 반환한다.")
    fun testDescriptionOnlyCode() {
        val code = "hello"
        val description = messageResolver.resolveDescription(code)
        assertThat(description).isEqualTo("$code.description(args=[])")
    }

    @Test
    @DisplayName("resolveDescription: code와 args(빈리스트)를 전달하면 `code.description(args=[])'을 반환한다.")
    fun testDescriptionWhenArgsNull() {
        val code = "hello"
        val description = messageResolver.resolveDescription(code, emptyList())
        assertThat(description).isEqualTo("$code.description(args=[])")
    }
}

 

GlobalMessageResolver 에서 MessageResolver 의존성이 필요한데 구현체가 없으므로 MessageResolverFixture 를 만들어줘야합니다. 그런데 앞서 저는 api-deploy 모듈에서 MessageResolverFixture 및 테스트 코드를 만들었으므로 이 코드를 복사/붙여넣기해서 재사용해보겠습니다.

 

+

그런데 이렇게 Fixture 코드가 다른 모듈(api-deploy, external-exception-handle) 에 복사/붙여넣기 되어서 중복작성 되는 것이 이상하게 느껴지고, 불편하게 느껴지실 수 있습니다. 앞으로도 향후 MessageSource 에 대한 Fixture가 필요해질 때마다 이렇게 각 모듈에서 매번 복사넣기를 해야할까 의문이 들 것이에요. 이 부분은 뒤의 글에서 중복을 없애는 방법을 설명하고 리팩토링을 하도록 하겠습니다.

 

3.4.2 GlobalExceptionControllerTest 작성

package com.ttasjwi.board.system.core.exception.api

import com.ttasjwi.board.system.core.api.ErrorResponse
import com.ttasjwi.board.system.core.exception.NullArgumentException
import com.ttasjwi.board.system.core.exception.ValidationExceptionCollector
import com.ttasjwi.board.system.core.message.fixture.MessageResolverFixture
import org.assertj.core.api.Assertions.*
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus


@DisplayName("GlobalExceptionController 테스트")
class GlobalExceptionControllerTest {

    private val exceptionController = GlobalExceptionController(MessageResolverFixture())

    @Test
    @DisplayName("Exception 및 그 자손은 서버 에러로 취급한다.")
    fun handleExceptionTest() {
        val exception = Exception()

        val responseEntity = exceptionController.handleException(exception)
        val response = responseEntity.body as ErrorResponse

        assertThat(responseEntity.statusCode.value()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value())
        assertThat(response.isSuccess).isFalse()
        assertThat(response.code).isEqualTo("Error.Occurred")
        assertThat(response.message).isEqualTo("Error.Occurred.message")
        assertThat(response.description).isEqualTo("Error.Occurred.description(args=[])")
        assertThat(response.errors.size).isEqualTo(1)

        val errorItem = response.errors[0]
        assertThat(errorItem.code).isEqualTo("Error.Server")
        assertThat(errorItem.message).isEqualTo("Error.Server.message")
        assertThat(errorItem.description).isEqualTo("Error.Server.description(args=[])")
        assertThat(errorItem.source).isEqualTo("server")
    }

    @Test
    @DisplayName("우리 서비스에서 정의한 CustomException 을 받으면 예외 정보를 기반으로 예외처리한다.")
    fun handleCustomException() {
        val exception = NullArgumentException("email")

        val responseEntity = exceptionController.handleCustomException(exception)
        val response = responseEntity.body as ErrorResponse

        assertThat(responseEntity.statusCode.value()).isEqualTo(HttpStatus.BAD_REQUEST.value())
        assertThat(response.isSuccess).isFalse()
        assertThat(response.code).isEqualTo("Error.Occurred")
        assertThat(response.message).isEqualTo("Error.Occurred.message")
        assertThat(response.description).isEqualTo("Error.Occurred.description(args=[])")
        assertThat(response.errors.size).isEqualTo(1)

        val errorItem = response.errors[0]
        assertThat(errorItem.code).isEqualTo(exception.code)
        assertThat(errorItem.message).isEqualTo("${exception.code}.message")
        assertThat(errorItem.description).isEqualTo("${exception.code}.description(args=${exception.args})")
        assertThat(errorItem.source).isEqualTo(exception.source)
    }

    @Test
    @DisplayName("ValidationExceptionCollector 을 잘 handle 하는 지 테스트")
    fun handleValidationExceptionCollectorTest() {
        val exceptionCollector = ValidationExceptionCollector()

        val exception1 = NullArgumentException("loginId")
        val exception2 = NullArgumentException("password")
        exceptionCollector.addCustomExceptionOrThrow(exception1)
        exceptionCollector.addCustomExceptionOrThrow(exception2)

        val responseEntity = exceptionController.handleValidationExceptionCollector(exceptionCollector)
        val response = responseEntity.body as ErrorResponse

        assertThat(responseEntity.statusCode.value()).isEqualTo(HttpStatus.BAD_REQUEST.value())
        assertThat(response.isSuccess).isFalse()
        assertThat(response.code).isEqualTo("Error.Occurred")
        assertThat(response.message).isEqualTo("Error.Occurred.message")
        assertThat(response.description).isEqualTo("Error.Occurred.description(args=[])")
        assertThat(response.errors.size).isEqualTo(2)

        val errorItem1 = response.errors[0]
        val errorItem2 = response.errors[1]

        assertThat(errorItem1.code).isEqualTo(exception1.code)
        assertThat(errorItem1.message).isEqualTo("${exception1.code}.message")
        assertThat(errorItem1.description).isEqualTo("${exception1.code}.description(args=${exception1.args})")
        assertThat(errorItem1.source).isEqualTo(exception1.source)
        assertThat(errorItem2.code).isEqualTo(exception2.code)
        assertThat(errorItem2.message).isEqualTo("${exception2.code}.message")
        assertThat(errorItem2.description).isEqualTo("${exception2.code}.description(args=${exception2.args})")
        assertThat(errorItem2.source).isEqualTo(exception2.source)
    }
}

 

GlobalExceptionController 자체에 대한 단위 테스트를 작성했습니다.


예외 처리 메서드를 직접 호출했을 때 의도한 ResponseEntity 메서드가 반환되는지 테스트하는 부분입니다.

MessageResolverFixture를 주입시켜서 테스트에 사용했습니다.

 

다만 이 부분만 놓고보면 SpringMVC와 결합하여 잘 돌아가는지 확인하기 힘든 한계가 있습니다.


5. DispatcherServlet 바깥의 예외 처리

 

DispatcherServlet 이후에서 throw 된 예외는 Filter로 되돌아가 전파됩니다.

물론 Filter에서 예외를 catch 해서 그것을 커스텀하게 처리할 수도 있습니다.(여기서 응답 메시지를 작성할 수 있습니다.)

 

그러나 Filter에서도 잡아 처리되지 못 한 예외들은 WAS까지 전파됩니다.

 

 

WAS, 즉 Tomcat 클래스 쪽에서는 다음과 같은 과정을 거쳐서 애플리케이션 내부 재요청을 하는 것으로 알려져 있습니다.

 

(+ 이 부분은 저도 하루 반나절 넘게 디버거를 까보면서 코드를 분석하다가...  야크털 깎기가 되는 것 같아서 더 이상 확인하지 않기로 했습니다. 궁금하신 분들은 CoyoteAdapter > StandardEngineValve > ErrorReportValve > StandardHostValve 이쪽 클래스를 참고해보시먄 될 것 같아요.)

 

WAS (톰캣)에서 예외를 처리하는 과정 

1. 개발자는 web.xml (서블릿 사양) 설정을 통해 오류페이지를 등록한다.

(참고로 Spring 이 제공하는 WebServerFactoryCustomizer 를 통해서도 설정 가능하긴 한데 이건 스프링 사양입니다.)

2. WAS는 자신에게 오류가 전파되면(throw 를 통해 전파된 예외 또는 response.sendError 메서드 호출) 오류 정보를 확인하여 그 정보에 매칭되는 오류페이지 경로를 찾아서 내부 재요청을 보낸다. 이떄 WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 request 의 attribute 에 추가해서 넘겨준다.

3. 이 과정에서 필터가 다시 호출됟고, DispatcherServlet 이 다시 호출된다.

 

 

스프링부트 개발자 입장에서 알아두면 좋은 것

 

1. 서블릿 사양에서는 web.xml 을 통해 오류페이지를 등록해줬었는데, 이 작업을 할 필요 없이 스프링의 WebServerFactoryCustomizer 인터페이스를 상속한 클래스를 빈으로 등록해서 오류페이지를 설정할 수 있습니다.

 

2. 내부 재요청이 발생하면 필터도 다시 통과할 수 있는데 별도의 설정을 하지 않으면 기본적으로 한번 통과한 필터는 다시 처리되지 않습니다. 내부 재요청 시 Request 의 DispatcherType 이 DispatcherType.ERROR 가 되는데 기본 설정에서는 이 DispatcherType 에 대해서 필터는 요청을 다시 처리하지 않아요.

 

3. 스프링부트는 기본적으로 /error 를 모든 에러에 대한 기본 에러페이지로 등록을 합니다.

 

4. 서블릿 밖으로 예외가 발생하거나, response.sendError(...) 가 호출되면 모든 오류는 /error 를 호출하게 됩니다.

 

5. 스프링부트는 BasicErrorController 라는 컨트롤러를 자동으로 등록합니다. 여기에는 /error 에 대응하는 엔드포인트가 구성되어 있어요.

 

6. 개발자가 커스텀 ErrorPage 를 등록하고 이 ErrorPage 에대응하는 컨트롤러를 만들어도 됩니다.

 


 

6. DispatcherServlet 바깥의 예외 처리 - 구현

 

DispatcherServlet 이후에서 발생한 예외는 HandlerExceptionResolver 를 사용해서 편리하게 처리하게 하면 됩니다.

 

하지만 DispatcherServlet 바깥, 즉 필터쪽에서 발생하거나 필터쪽으로 넘어온 예외는 기본적으로 HandlerExceptionResolver가 처리할 수 없죠. DispatcherServlet의 코드 흐름에 없으니까요. 그렇다고 여기서 발생한 예외를 WAS까지 그대로 전파되게 만들면 오류페이지 재요청이 가게 될겁니다. 우리가 ErrorPage를 등록하고 ErrorPage 에 대응하는 컨트롤러를 방법도 있겠지만 솔직히 말해 귀찮습니다.

 

DispatcherServlet 이후에서는 @ExceptionHandler를 이용해서 편리하게 예외를 처리할 수 있는데, Filter 쪽에서 발생하는 예외도 똑같이 편리하게 예외를 처리하도록 하는 방법은 없을까요?

 

 

답은 '있다'입니다.

다른 예외가 발생할 여지가 있는 필터들 앞에, 커스텀한 필터를 하나 만들고 try-catch 문으로 예외를 잡아서 HandlerExceptionResolver 에게 예외 메시지 작성을 위임시키면 됩니다.

 

이러면 DispatcherServlet 이후 및 필터에서 동일한 방식으로 예외를 처리할 수 있어요.

 

단, HandlerExceptionResolver는 앞에서 설명한 바에 의하면 handlerExceptionResolver 이름으로 자동구성 클래스에서 등록되어 있으므로 Qualifier 어노테이션을 사용해야합니다. 이 부분은 아래에서 설명할게요.

 

 

 

코드를 작성해보겠습니다.

6.1 CustomExceptionHandleFilter

package com.ttasjwi.board.system.core.exception.filter

import com.ttasjwi.board.system.logging.getLogger
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.servlet.HandlerExceptionResolver

class CustomExceptionHandleFilter(
    private val handlerExceptionResolver: HandlerExceptionResolver
) : OncePerRequestFilter() {

    companion object {
        private val log = getLogger(CustomExceptionHandleFilter::class.java)
    }

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        try {
            log.info{ "예외 처리 필터 - 필터 통과 전"}
            filterChain.doFilter(request, response)
            log.info{ "예외 처리 필터 - 요청 정상 처리 후"}
        } catch (e: Exception) {
            log.info{ "뒤에서 예외가 발생됐으나, 예외처리 필터에서 잡힘 -> HandlerExceptionResolver 위임" }
            handlerExceptionResolver.resolveException(request, response, null, e)
        }
    }
}

 

CustomExceptionHandleFilter 입니다.

 

try-catch 문으로 감싸서, 다음 필터체인을 호출하기 전 후 로그를 남기고

 

예외가 이곳까지 전파됐을 경우, catch 문에서는 예외 로그를 한번 남긴 뒤 HandlerExceptionResolver 에게 예외메시지 처리를 위임했어요.

 

6.2 ExceptionHandleFilterConfig

package com.ttasjwi.board.system.core.config

import com.ttasjwi.board.system.core.exception.filter.CustomExceptionHandleFilter
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.HandlerExceptionResolver

@Configuration
class ExceptionHandleFilterConfig {

    companion object {
        /**
         * 로케일 설정 필터 우선도는 -104 이므로 로케일이 설정되고 실행됨
         * 스프링 시큐리티 필터(DelegatingFilterProxy) 기본 순서는 -100 이므로 스프링 시큐리티보다 먼저 시행됨
         */
        private const val EXCEPTION_HANDLE_FILTER_ORDER = -103
    }

    @Bean
    fun customExceptionHandleFilter(
        @Qualifier(value = "handlerExceptionResolver")
        exceptionResolver: HandlerExceptionResolver
    ): FilterRegistrationBean<CustomExceptionHandleFilter> {
        
        val registration = FilterRegistrationBean<CustomExceptionHandleFilter>()
        registration.filter = CustomExceptionHandleFilter(exceptionResolver)
        registration.order = EXCEPTION_HANDLE_FILTER_ORDER
        return registration
    }
}

 

필터 설정 클래스입니다.

FilterRegistrationBean 을 빈으로 등록하고, 여기서 필터의 우선도를 -103 으로 설정했습니다.

 

그리고 HandlerExceptionResolver 빈을 "handlerExceptionResolver" 이름의 빈으로 주입했습니다. 이렇게 하면 Spring MVC의 DispatcherServlet 에서 사용하는 HandlerExceptionResolverComposite 과 동일한 빈이 주입됩니다.

 

앞서 설정한 CustomLocaleContextFilter 가 -104 였는데, 로케일이 설정된 다음 예외처리 필터가 작동할 수 있도록 순서를 -103 으로 하였습니다.

 

참고로 Spring Security 쪽 필터 우선도는 -100 이므로 스프링 시큐리티보다 앞에 위치해있습니다. 스프링 시큐리티쪽에서 예외가 발생했고 처리되지 않은 채 바깥으로 빠져나간다면 여기서 처리할 수 있어요.


7. MVC 결합 테스트

 

이번에는 Filter를 작성한 것과 결합해서 잘 작동되는 지 테스트 코드를 추가적으로 작성해보겠습니다.

 

7.1 CustomExceptionFixture / CustomExceptionFixtureTest

package com.ttasjwi.board.system.core.exception.fixture

import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.core.exception.ErrorStatus

fun customExceptionFixture(
    status: ErrorStatus = ErrorStatus.INVALID_ARGUMENT,
    code: String = "Error.Fixture",
    args: List<Any?> = emptyList(),
    source: String = "fixtureErrorSource",
    debugMessage: String = "커스텀 예외 픽스쳐입니다.",
    cause: Throwable? = null,
): CustomException {
    return CustomExceptionFixture(
        status = status,
        code = code,
        args = args,
        source = source,
        debugMessage = debugMessage,
        cause = cause
    )
}

internal class CustomExceptionFixture(
    status: ErrorStatus,
    code: String,
    args: List<Any?>,
    source: String,
    debugMessage: String,
    cause: Throwable?
) : CustomException(
    status = status,
    code = code,
    args = args,
    source = source,
    debugMessage = debugMessage,
    cause = cause
)
package com.ttasjwi.board.system.core.exception.fixture

import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.core.exception.ErrorStatus
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

@DisplayName("CustomExceptionFixture 테스트")
class CustomExceptionFixtureTest {

    @Test
    @DisplayName("필드 값을 전달하지 않아도 기본적인 예외 인스턴스가 생성된다.")
    fun test() {
        // given
        // when
        val exception = customExceptionFixture()

        assertThat(exception).isNotNull
        assertThat(exception).isInstanceOf(CustomException::class.java)
        assertThat(exception.status).isNotNull
        assertThat(exception.code).isNotNull
        assertThat(exception.args).isNotNull
        assertThat(exception.source).isNotNull
        assertThat(exception.debugMessage).isNotNull()
        assertThat(exception.cause).isNull()
    }

    @Test
    @DisplayName("커스텀하게 필드를 지정해서 전달하여 인스턴스를 생성할 수 있다.")
    fun testCustom() {
        // given
        val status = ErrorStatus.INVALID_ARGUMENT
        val code = "Error.InvalidArgumentFixture"
        val args = listOf(1, 2)
        val source = "nickname"
        val debugMessage = "some debug message fixture"
        val cause = IllegalArgumentException("some thing is wrong")

        // when
        val exception = customExceptionFixture(
            status = status,
            code = code,
            args = args,
            source = source,
            debugMessage = debugMessage,
            cause = cause
        )

        assertThat(exception).isNotNull
        assertThat(exception).isInstanceOf(CustomException::class.java)
        assertThat(exception.status).isEqualTo(status)
        assertThat(exception.code).isEqualTo(code)
        assertThat(exception.args).containsExactlyElementsOf(args)
        assertThat(exception.source).isEqualTo(source)
        assertThat(exception.debugMessage).isEqualTo(debugMessage)
        assertThat(exception.cause).isEqualTo(cause)
    }
}

 

이후 작성할 코드에서 가짜 커스텀 예외를 터트릴건데, CustomException 은 추상클래스이므로 구체 클래스가 필요합니다.

 

여기서는 가짜 예외를 테스트에서 편리하게 만들 수 있도록 하기 위해 customExceptionFixture 를 정의해서 사용하도록 할거에요. 디폴트 파라미터를 지정해둬서, 기본값이 사용될 수 있도록 했어요.

 

+

앞에서 MessageResolverFixture 처럼, 향후 이 예외 Fixture 역시 다른 곳에서 사용될 여지가 있고 복사 붙여넣기를 해야할 수 있는데 이 문제는 다음번 글에서 다룰 것입니다.

 

7.2 ExceptionApiTestController

package com.ttasjwi.board.system

import com.ttasjwi.board.system.core.exception.ErrorStatus
import com.ttasjwi.board.system.core.exception.fixture.customExceptionFixture
import com.ttasjwi.board.system.logging.getLogger
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class ExceptionApiTestController {

    companion object {
        private val log = getLogger(ExceptionApiTestController::class.java)
    }


    @GetMapping("/api/test-ex")
    fun throwException(): String {
        log.info{ "컨트롤러에서 예외를 발생시킵니다" }
        throw customExceptionFixture(
            code = "Error.Fixture",
            source = "controller",
            status = ErrorStatus.INVALID_ARGUMENT,
            args = emptyList(),
            debugMessage = "컨트롤러에서 발생한 예외"
        )
    }

    @GetMapping("/api/test-success")
    fun success(): String {
        return "hello"
    }
}

 

MVC 와 결합하여 잘 작동되는 지 테스트하기 위해 컨트롤러, ExceptionApiTestController 를 작성했습니다.

 

1. /api/test-ex : 호출하면 예외를 발생시킵니다.

2. /api/test-success : 호출하면 성공합니다. 단순하게 hello 문자열을 반환할거에요.

 

7.3 ExceptionApiTestFilter

package com.ttasjwi.board.system

import com.ttasjwi.board.system.core.exception.ErrorStatus
import com.ttasjwi.board.system.core.exception.fixture.customExceptionFixture
import com.ttasjwi.board.system.logging.getLogger
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.web.filter.OncePerRequestFilter

class ExceptionApiTestFilter : OncePerRequestFilter() {

    companion object {
        private val log = getLogger(ExceptionApiTestFilter::class.java)
    }


    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val param: String? = request.getParameter("ex")

        if (param == "true") {
            log.info{ "ex param 이 true 이므로 필터에서 예외를 발생시킵니다." }
            throw customExceptionFixture(
                code = "Error.Fixture",
                source = "filter",
                status = ErrorStatus.INVALID_ARGUMENT,
                args = emptyList(),
                debugMessage = "필터에서 발생한 예외"
            )
        }
        filterChain.doFilter(request, response)
    }
}

 

테스트용 필터를 하나 만들어보겠습니다.

파라미터에 "ex" 파라미터가 있고, 이 값이 true 이면 예외를 발생시키도록 하겠습니다.

 

7.4 ExceptionApiTestApplication

package com.ttasjwi.board.system

import com.ttasjwi.board.system.core.message.MessageResolver
import com.ttasjwi.board.system.core.message.fixture.MessageResolverFixture
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import

@SpringBootApplication
@Import(TestConfig::class)
class ExceptionApiTestApplication

fun main(args: Array<String>) {
    runApplication<ExceptionApiTestApplication>(*args)
}

@Configuration
class TestConfig {

    @Bean
    fun messageResolverFixture(): MessageResolver {
        return MessageResolverFixture()
    }

    /**
     * CustomExceptionHandleFilter 뒤에서 예외를 던지기 위한 필터
     */
    @Bean
    fun testFilter(): FilterRegistrationBean<ExceptionApiTestFilter> {
        val registration = FilterRegistrationBean<ExceptionApiTestFilter>()
        registration.filter = ExceptionApiTestFilter()

        // CustomExceptionHandleFilter (-103) 보다 뒤에 둠
        registration.order = -102
        return registration
    }
}

 

테스트용 스프링 부트 애플리케이션을 선언하고, 테스트 설정(TestConfig)을 추가합니다.

 

1. MessageResolver 는 MessageResolverFixture 를 사용합니다.

2. FilterRegistrationBean 빈을 추가하여 ExceptionApiTestFilter 를 추가합니다. 이 때 필터의 순서는 CustomExceptionHandleFilter 의 순서값 -103 보다 더 늦은 -102로 둘게요.

 

 

여기까지 놓고보면 만약 우리가 /api/test-ex 를 호출했다면 컨트롤러에서 예외가 발생했으니, DispatcherServlet 에서 예외를 잡아서, HandlerExceptionResolver 에 처리를 위임할거에요.

 

 

또, /api/test-sucess?ex=true 를 호출한다면, ExceptionTestFilter 에서 예외가 발생하는데 앞에 있는 CustomExceptionHandleFilter 에서 잡아서 HandlerExceptionResolver 에게 처리를 위임할거에요.

 

7.5 WebMvcExceptionHandleTest

package com.ttasjwi.board.system.core.exception

import com.ttasjwi.board.system.ExceptionApiTestController
import com.ttasjwi.board.system.TestConfig
import com.ttasjwi.board.system.core.config.ExceptionHandleFilterConfig
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*

@ActiveProfiles("test")
@WebMvcTest(controllers = [ExceptionApiTestController::class])
@AutoConfigureMockMvc
@Import(TestConfig::class, ExceptionHandleFilterConfig::class)
@DisplayName("WebMvc에서 예외 처리가 잘 적용되는 지 테스트")
class WebMvcExceptionHandleTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Test
    @DisplayName("'/api/test-success' 엔드포인트를 호출하면 hello 문자열이 반환된다.")
    fun caseTestSuccessEndPointSuccess() {
        mockMvc
            .perform(get("/api/test-success"))
            .andDo(print())
            .andExpectAll(
                status().isOk,
                content().contentType("text/plain;charset=UTF-8"),
                content().string("hello")
            )
    }

    @Test
    @DisplayName("'/api/test-success' 엔드포인트 호출시 ex=true 파라미터를 전달하면 필터에서 예외가 발생하고, 이를 앞단의 필터가 처리한다.")
    fun caseFilterException() {
        mockMvc
            .perform(get("/api/test-success?ex=true")) // ex=true 파라미터를 전달하면 ExceptionApiTestFilter에서 예외 발생함
            .andDo(print())
            .andExpectAll(
                status().isBadRequest,
                content().contentType(MediaType.APPLICATION_JSON),
                jsonPath("$.isSuccess").value(false),
                jsonPath("$.code").value("Error.Occurred"),
                jsonPath("$.message").value("Error.Occurred.message"),
                jsonPath("$.description").value("Error.Occurred.description(args=[])"),
                jsonPath("$.data").doesNotExist(),
                jsonPath("$.errors[0].code").value("Error.Fixture"),
                jsonPath("$.errors[0].message").value("Error.Fixture.message"),
                jsonPath("$.errors[0].description").value("Error.Fixture.description(args=[])"),
                jsonPath("$.errors[0].source").value("filter"),
            )
    }

    @Test
    @DisplayName("컨트롤러에서 예외 발생하면 HandlerExceptionResolver 에서 처리한다.")
    fun caseControllerException() {
        mockMvc
            .perform(get("/api/test-ex")) // 컨트롤러에서 예외 발생함
            .andDo(print())
            .andExpectAll(
                status().isBadRequest,
                content().contentType(MediaType.APPLICATION_JSON),
                jsonPath("$.isSuccess").value(false),
                jsonPath("$.code").value("Error.Occurred"),
                jsonPath("$.message").value("Error.Occurred.message"),
                jsonPath("$.description").value("Error.Occurred.description(args=[])"),
                jsonPath("$.data").doesNotExist(),
                jsonPath("$.errors[0].code").value("Error.Fixture"),
                jsonPath("$.errors[0].message").value("Error.Fixture.message"),
                jsonPath("$.errors[0].description").value("Error.Fixture.description(args=[])"),
                jsonPath("$.errors[0].source").value("controller"),
            )
    }
}

 

실제 MVC 와 결합해서 잘 예외처리가 작동하는 지 테스트코드를 작성했습니다.

TestConfig, ExceptionHandleFilterConfig 을 끌어와서 테스트해요.

 

 

CustomExceptionHandleFilter : 예외 처리 필터 - 필터 통과 전
CustomExceptionHandleFilter : 예외 처리 필터 - 요청 정상 처리 후

-- 생략

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"text/plain;charset=UTF-8", Content-Length:"5"]
     Content type = text/plain;charset=UTF-8
             Body = hello
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

caseTestSuccessEndPointSuccess

"/api/test-success" 를 직접 호출하면 성공하는 것을 테스트합니다.

필터가 호출되고, 별 일 없이 CustomExceptionHandleFilter 를 통과했다가 다시 돌아오게 됩니다.

응답은 의도한대로 "hello" 문자열이 옵니다.

 

CustomExceptionHandleFilter : 예외 처리 필터 - 필터 통과 전
ExceptionApiTestFilter  : ex param 이 true 이므로 필터에서 예외를 발생시킵니다.
CustomExceptionHandleFilter : 뒤에서 예외가 발생됐으나, 예외처리 필터에서 잡힘 -> HandlerExceptionResolver 위임

-- 생략

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"code":"Error.Occurred","message":"Error.Occurred.message","description":"Error.Occurred.description(args=[])","errors":[{"code":"Error.Fixture","message":"Error.Fixture.message","description":"Error.Fixture.description(args=[])","source":"filter"}],"isSuccess":false}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

caseFilterException

"/api/test-success?ex=true" 를 호출하면 ExceptionApiTestFilter 에서 예외가 발생할 텐데 앞단의 CustomExceptionHandleFilter가 이를 잡아서 HandlerExceptionResolver 에게 예외처리를 위임했고, 그 결과 우리가 정의했던 커스텀 예외 응답이 잘 나가는 지 테스트합니다.

 

로그를 확인해보면 최초 CustomExceptionHandle 필터 통과는 했는데, ExceptionApiTestFilter 에서 예외가 발생합니다.

그리고 CustomExceptionHandleFilter 의 catch문의 로그가 실행되는 것을 볼 수 있어요.

 

CustomExceptionHandleFilter : 예외 처리 필터 - 필터 통과 전
ExceptionApiTestController  : 컨트롤러에서 예외를 발생시킵니다
CustomExceptionHandleFilter : 예외 처리 필터 - 요청 정상 처리 후

-- 생략

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"code":"Error.Occurred","message":"Error.Occurred.message","description":"Error.Occurred.description(args=[])","errors":[{"code":"Error.Fixture","message":"Error.Fixture.message","description":"Error.Fixture.description(args=[])","source":"controller"}],"isSuccess":false}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

caseControllerException

"/api/test-ex" 를 호출하면 컨트롤러에서 예외가 발생할텐데, DispatcherServlet 이 이를 잡아다 HandlerExceptionResolver 에게 예외처리를 위임하고, 그 결과 우리가 정의했던 커스텀 예외 응답이 잘 나가는 지 테스트합니다.

 

로그를 보면 CustomExceptionHandlerFilter 를 최초엔 잘 통과합니다. 그러나 컨트롤러에서 예외가 터졌죠.

이것이 HandlerExceptionResolver 에서 응답 메시지 작성이 이루어지고 다시 필터로 돌아왔는데

CustomExceptionHandler 필터에 돌아온 시점에선 이미 예외가 처리된 시점이라서 catch 문에 잡히지 않습니다.

Error:
  Occurred:
    message: "에러 발생"
    description: "요청 처리에 실패했습니다. 에러가 발생했습니다."
  Server:
    message: "서버 에러"
    description: "알 수 없는 예러가 발생했습니다. 문제가 지속되면 고객 지원팀에 문의해 주세요."
  NullArgument:
    message: "필수값 누락"
    description: "''{0}''은(는) 필수입니다."
Error:
  Occurred:
    message: "Error occurred"
    description: "Failed to process the request. An error has occurred."
  Server:
    message: "Server Error"
    description: "An unknown error has occurred. If the issue persists, please contact customer support."
  NullArgument:
    message: "Missing required value"
    description: "The field ''{0}'' is required."

 

그리고, 새로 추가작성한 코드에 맞춰서 예외 관련 메시지 를 처리할 수 있도록 예외메시지 파일도 추가 작성해줬습니다.

 

실제로 테스트 코드는 잘 작동되는 것을 볼 수 있고 인텔리 제이 내부에서 봤을 때 커버리지도 좋게 측정됩니다.

 


8. 배포 후

기존 배포 서버에서는 우리 서비스의 루트 경로로 요청을 해보면 Whitelable Error Page 가 나타납니다.

 

"/"에 해당하는 엔드포인트를 찾다가, 찾지 못하여 정적 리소스를 찾는 것을 시도했으나 못 찾았을 때 스프링 내부에서 동작하는 기본 로직에 의해 작성된 페이지 응답인데요.

 

새로 배포를 하게 되면 어떤 일이 발생할까요?

 

이제 "/"로 접근하면 저희가 정의한 커스텀 Api 응답이 발생합니다. 500 응답이 올거에요.

 

본래는 디스패처 서블릿에서 "/" 접근 과정에서 예외가 발생했던 것이 HandlerExceptionResolverComposite에게 처리가 위임됐을 겁니다.

 

저희는 이것에 대한 예외 처리로직을 작성하지 않았으므로 DefaultHandlerExceptionResolver 가 이 예외를 가로채게됩니다. sendError 를 통해 WAS 에게 에러 발생을 알리고, WAS 는 /error 로 에러페이지 재요청을 보내게 되서 WhiteLabelError 페이지가 출력됐던 것입니다. (내부 코드는 생략합니다.)

 

그런데 그 앞에 있는 ExceptionHandlerExceptionResolver에서 먼처 예외 처리 메서드를 찾아서 예외를 가로채 처리하므로 저희가 작성한 예외 처리 로직이 먼저 실행되는 것이죠.

 

 

로컬 서버에서 동일한 방식으로 실행해보면 내부 로그에는 NoResourceFoundException이 발생해요.

 

본래라면 404 응답이 발행했을텐데 이것에 맞게 저희는 커스텀하게 @ExceptionHandler 가 달린 메서드를 작성해주면 되긴합니다.

 

그런데 이 예외 말고도 여러 MVC 예외들이 발생할 여지가 있는데 한번에 처리할 예정이므로 일단 이 예외에 대한 처리는 아직 하지 않도록 하겠습니다.


 

어쨌든 저희가 의도한 대로 예외 처리 로직이 잘 동작하는 것을 확인했습니다.

글 읽어주셔서 감사합니다.

 

GitHub Repository: https://github.com/ttasjwi/board-system

 

GitHub - ttasjwi/board-system

Contribute to ttasjwi/board-system development by creating an account on GitHub.

github.com

 

PR: https://github.com/ttasjwi/board-system/pull/26

 

Feature: (BRD-66) API 예외 메시지 처리 by ttasjwi · Pull Request #26 · ttasjwi/board-system

JIRA 티켓 BRD-66 작업 내역 @RestControllerAdvice, @ExceptionHandler 를 사용하여 예외 처리기 작성 Filter 앞단에 예외 처리 필터를 두고 HandlerExceptionResolver를 통해 예외 처리 위임을 할 수 있도록 함 테스트코

github.com

 

Comments