포스팅 배경
우테코 레벨2 두번째 미션인 장바구니 기능 구현을 진행하면서 로그인 인증기능을 구현하기 위해 Interceptor를 구현해서 사용하게 되었다. 이 과정에서 Interceptor 내부에서 예외를 던져야할 필요성이 생겼는데 여기서 발생하는 예외는 어떻게 처리해야할지 고민하다 스프링의 전체적인 예외 처리 과정이 궁금해졌다. 스프링에서 예외를 처리하는 전체적인 흐름을 살펴보고 어떻게 미션에서 발생했던 문제를 해결할 수 있었는지 정리해보고자 한다.
우리가 직접 구현한 Controller에서 발생하는 Exception들은 어떻게 처리될까?
Spring은 기본적인 에러 처리를 위한 BasicErrorController를 구현해뒀다. 이로 인해 별도의 추가 설정이 없다면 스프링 부트는 예외가 발생했을 때 기본적으로 /error로 에러 요청을 다시 전달하도록 WAS 설정을 해둔다. 일반적인 클라이언트의 요청이 들어왔을 때 흐름을 살펴보면 다음과 같은 과정을 통해 컨트롤러까지 요청이 전달된다.
WAS(톰캣) → 필터 → 서블릿(디스패처 서블릿) → 인터셉터 → 컨트롤러
컨트롤러 하위에서 예외가 발생했을 때 개발자가 예외에 대한 처리를 진행하지 않으면 WAS까지 예외가 전달된다. 그러면 WAS는 처리되지 못한 예외가 발생했다고 판단하고 대응 작업을 진행하게 된다. 즉 WAS는 아직 처리되지 못한 예외를 처리하기 위해 에러 설정(/error)에 맞게 요청을 BasicErrorController로 전달하게 된다.
(Request) → WAS(톰캣) → 필터 → 서블릿(디스패처 서블릿) → 인터셉터 → 컨트롤러
컨트롤러(예외 발생) → 인터셉터 → 서블릿(디스패처 서블릿) → 필터 → WAS(톰캣)
WAS(톰캣) (Error)→ 필터 → 서블릿(디스패처 서블릿) → 인터셉터 → 컨트롤러(BasicErrorController)
정리하면 스프링의 예외처리 방식은 기본적으로 BasicErrorController를 한번 더 호출하여 진행되는 것이다. 이 과정에서 필터나 인터셉터가 재호출될 수 있는데 이를 제어하기 위한 별도의 설정이 필요할 수 있다. 클라이언트 입장에서는 한 번의 요청에 대한 한 번의 응답을 받는 것일 뿐 내부적으로 에러에 대한 요청이 이뤄졌는지는 알 수 없다.
서블릿의 경우 HTTP 요청이 서블릿 컨테이너에 도착할 때, 어떤 방식으로 해당 요청이 처리되어야 하는지 판단하는 근거로 dispatcherType이라는 상수를 사용한다. 일반적인 클라이언트의 HTTP 요청같은 경우 REQUEST 라는 값을 사용하고, 예외를 처리하는 경우 ERROR 라는 값을 사용하게 된다. 필터는 스프링이 아닌 서블릿 기술이므로 필터를 등록할 때 어떤 dispatcherType의 요청을 처리하게 할 것인지 설정 가능하다. 별도의 설정이 없다면 REQUEST일 때만 필터가 호출된다.
반면 인터셉터는 스프링 기술이므로 dispatcherType으로 요청에 대한 처리를 제어할 수 없기 때문에 URI 패턴으로 이를 별도 처리해줘야 한다.
BasicErrorController
BasicErrorController는 에러 설정에 맞춰서 요청이 들어오면 요청 메세지의 accept 헤더에 따라 에러 페이지를 랜더링하거나 에러 메세지를 반환한다. 에러 처리 요청에 대해 어떤 타입의 데이터를 반환하든지 간에 BasicErrorController는 getErrorAttributeOptions 메서드를 내부적으로 호출해서 클라이언트에게 전달할 예외 메세지의 항목들을 선택한다. 기본적으로 timestamp, status, error, path 정보가 담겨지고 그 외 정보들을 담고 싶다면 추가 설정을 해주면된다.
- timestamp : 에러가 발생한 시간
- status : 에러의 HTTP Status 코드
- error : 에러코드
- path : 에러가 발생한 URI
- exception : 발생한 예외 클래스 이름 (추가 설정 필요)
- message : 에러 메세지(추가 설정 필요)
- errors: BindingException에 의해 생긴 에러 목록(추가 설정 필요)
- trace: 에러 스택 트레이스(추가 설정 필요)
// application.properties 설정 추가
server.error.include-message = always // 발생한 예외의 메세지를 응답 메세지에 담는다.
문제 제기
미션을 진행하면서 클라이언트 요청 메세지 헤더에 담긴 Authorization 값을 통해 인증 기능을 수행하는 LoginInterceptor를 다음과 같이 구현해서 사용했다.
public class LoginInterceptor implements HandlerInterceptor {
private static final StringBASIC_TYPE= "Basic";
private static final StringDELIMITER= ":";
private static final intCREDENTIALS_EMAIL_INDEX= 0;
public static final intCREDENTIALS_PASSWORD_INDEX= 1;
private final MembersService membersService;
public LoginInterceptor(MembersService membersService) {
this.membersService = membersService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String accessToken = request.getHeader("Authorization");
if (accessToken == null) {
throw new MissingAuthorizationHeaderException();
}
if (accessToken.toLowerCase().startsWith(BASIC_TYPE.toLowerCase())) {
return handleAuthorization(request, accessToken);
}
throw new IncorrectAuthorizationMethodException();
}
private boolean handleAuthorization(HttpServletRequest request, String accessToken) {
String[] credentials = getCredentialsByBase64Decoding(accessToken);
String email = credentials[CREDENTIALS_EMAIL_INDEX];
String password = credentials[CREDENTIALS_PASSWORD_INDEX];
validateAuthorization(new AuthInfo(email, password));
setCredentialsInRequest(request, email, password);
return true;
}
private String[] getCredentialsByBase64Decoding(String accessToken) {
String authHeader = accessToken.substring(BASIC_TYPE.length()).strip();
String decodedAuthHeader = new String(Base64.decodeBase64(authHeader));
String[] credentials = decodedAuthHeader.split(DELIMITER);
return credentials;
}
private void validateAuthorization(AuthInfo authInfo) {
if (!membersService.isMemberCertified(authInfo)) {
throw new UncertifiedMemberException();
}
}
private void setCredentialsInRequest(HttpServletRequest request, String email, String password) {
request.setAttribute("email", email);
request.setAttribute("password", password);
}
}
다른 코드들은 제껴두고 preHandle 메서드를 오버라이딩한 부분을 집중해서 보자. 인증과정을 진행하면서 Authorization 헤더 값이 존재하지 않거나 Basic 인증 방식을 사용하지 않은 경우 각각 예외를 던지도록 구현되어 있다.
public class MissingAuthorizationHeaderException extends RuntimeException {
public MissingAuthorizationHeaderException() {
super("[ERROR] 인증 정보가 헤더에 없습니다.");
}
}
public class IncorrectAuthorizationMethodException extends RuntimeException {
public IncorrectAuthorizationMethodException() {
super("[ERROR] 헤더의 인증 방식이 적절하지 않습니다.");
}
}
이처럼 구현된 customException들에는 예외가 발생했을 때 클라이언트에게 전달되기를 바라는 예외 메세지들 또한 정의해줬다. 그러면 스프링에서는 기본적으로 예외처리를 WAS에서 해준다고 했으니 우리의 의도대로 예외가 발생했을 때 메세지를 클라이언트에게 잘 전달해줄 수 있을까?
다음처럼 basic 방식이 아닌 bearer 방식으로 Authorization 헤더를 구성해서 보냈을 때와 헤더에 Authorization 값이 없는 경우 우리가 작성해준 예외 메세지가 잘 반환됨을 확인할 수 있다. 하지만 이렇게 클라이언트에게 예외 응답 메세지를 보내는 데에는 몇가지 문제점이 있다.
- HttpStatus와 error가 각각 500, Internal Server Error다.
- 서버측에서 예외를 처리해서 응답메세지를 보냈음에도 응답 메세지의 ststus가 500이고 Internal Server Error가 발생했다고 전달되고 있다. 이 때 클라이언트 입장에서 이 응답 메세지가 서버측에서 예외를 잘 처리해서 넘겨준 응답 메세지인지, 아니면 서버측에서 처리하지 못하는 예상치 못한 문제로 인해 전달된 응답 메세지인지 알 수 없게 된다.(물론 이 경우 message 내용을 확인하면 어느정도 의도된 예외 응답이 반환되었는지 알 수 있겠으나 이 부분에도 문제가 있는데 다음 항목에서 후술한다.)
- 예외 메세지가 클라이언트에게 드러나면 안되는 경우에도 메세지가 노출될 수 있다.
- 스프링 프로젝트에는 외부 라이브러리를 끌고 와서 사용하는 경우가 비일비재하다. 그런데 이 라이브러리에서 발생하는 예외들의 경우 우리가 직접 처리해주지 않는 이상 Internal Server Error로 처리되어야 한다. 그런데 여기에 담겨있는 message가 과연 클라이언트에게 노출될 것을 감안하고 작성된 메세지일까? 그렇지 않을 확률이 매우 높을 것이다. 그러나 우리가 application.properties에서 message 노출 옵션을 always로 해줬기에 이런 에외가 발생했을 때의 message 또한 클라이언트의 응답 메세지에 담기게 되는 문제가 발생한다.
첫 번째 문제의 경우 원인은 WAS에게 Exception이 처리가 이뤄지지 않은 상태로 전달된다는 것이다. 즉 우리는 LoginInterceptor에서 Exception을 throw만 했을 뿐 이에 대한 별도 처리 로직은 구현해주지 않았다는 것이다. WAS 입장에서는 처리되지 않은 Exception이 전달되면 이를 예상치 못한 문제로 인해 발생했다고 간주하고 status코드 500에 Internal Server Error를 메세지에 설정하게 된다. 이를 해결하기 위해서는 별도의 예외처리 전략을 통해 상황에 맞게 예외 응답 메세지를 반환할 수 있도록 해야한다.
두 번째 문제의 경우 application.properties에 옵션을 변경해서 message를 응답 메세지에 담도록 설정한 것이 원인이 되었다. 그래서 우리는 예외의 message를 BasicErrorController에서 담도록 설정할 것이 아니라 그 이전에 처리가 되어 WAS에서 BasicErrorController로 다시 에러 처리 요청을 하지 않고 완성된 에러 응답 메세지를 바로 반환하도록 해야 한다.
다음 포스팅에서 스프링이 제공하는 다양한 예외처리 방법들과 이를 통해 우리의 문제를 어떻게 해결할 수 있는지 알아보자.
Reference
https://mangkyu.tistory.com/204
'우아한테크코스 > 학습 정리' 카테고리의 다른 글
스프링 @Transactional(readOnly=true)에 관한 간단한 고찰 (1) | 2023.06.04 |
---|---|
스프링의 예외처리 과정 및 처리 방법에 대하여 (2) (2) | 2023.05.12 |
[Level 2] Layered Architecture에 대한 개인적인 고찰 (0) | 2023.04.27 |
[Level 2] Repository와 Dao를 분리하는 기준 (5) | 2023.04.21 |
[Level 2] 공식문서를 통한 프레임워크 학습 방법에 대한 고찰 (1) | 2023.04.16 |