스프링 시큐리티: 커스텀 에러 처리 실패 원인 분석 및 해결 방법
원하는 프로세스
스프링 시큐리티를 활용한 CustomAuthenticationProvider 클래스에서 이메일은 맞았는데 비밀번호가 틀렸을 때 커스텀 에러 클래스인 MemberApplicationException을 던지도록 설정했으나, 예상과 달리 NullPointerException이 발생하며 예외 처리가 제대로 이루어지지 않았다. 이에 대한 원인 분석 및 해결 방안을 제시해 보겠다.
정상 작동 안 된 실패 코드
스프링 시큐리티를 이용해 로그인을 시도할 때 비밀번호가 틀리면 내가 정의한 에러코드를 throw 하는 로직으로 개발했다.
아래는 CustomAuthenticationProvider 클래스에서 로그인 검증을 처리 중 비밀번호가 다를 때 커스텀 클래스인 MemberApplicationException 에러를 발생 시키는 로직이다.
MemberApplicationException 클래스는 아래와 같이 정의했다.
위에서 사용된 ErrorCode는 아래와 같이 정의했다.
그리고 로그인 시도할 때 비밀번호를 틀리게 입력했을 때 아래와 같은 에러가 나왔다.
이 에러로그를 해석하면 NullPointerException이 발생하며, MemberApplicationException이 null로 로그에 기록되었다.
2023-12-23T15:15:44.612+09:00 ERROR [member,,] 38639 --- [nio-8081-exec-5] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception com.recipia.member.common.exception.MemberApplicationException: null at com.recipia.member.config.handler.CustomAuthenticationProvider.authenticate(CustomAuthenticationProvider.java:46) ~[classes/:na] at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:182) ~[spring-security-core-6.1.2.jar:6.1.2] at com.recipia.member.config.filter.CustomAuthenticationFilter.attemptAuthentication(CustomAuthenticationFilter.java:50) ~[classes/:na] at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:231) ~[spring-security-web-6.1.2.jar:6.1.2] at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:221) ~[spring-security-web-6.1.2.jar:6.1.2] at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) ~[spring-security-web-6.1.2.jar:6.1.2] at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) ~[spring-security-web-6.1.2.jar:6.1.2] at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) ~[spring-security-web-6.1.2.jar:6.1.2] at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:107) ~[spring-security-web-6.1.2.jar:6.1.2] at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:93) ~[spring-security-web-6.1.2.jar:6.1.2] at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) ~[spring-security-web-6.1.2.jar:6.1.2] at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) ~[spring-security-web-6.1.2.jar:6.1.2] at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) ~[spring-security-web-6.1.2.jar:6.1.2] at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) ~[spring-web-6.0.11.jar:6.0.11] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.0.11.jar:6.0.11] at
MemberApplicationException이 제대로 동작하지 않은 이유
null이 로그에 기록된 이유를 이해하기 위해서는 MemberApplicationException의 생성과 처리 과정을 자세히 살펴볼 필요가 있다.
문제의 핵심은 MemberApplicationException이 생성될 때 예외 메시지가 제대로 설정되지 않았다는 것이다.
MemberApplicationException 클래스는 사용자 정의 예외로, RuntimeException을 상속받는다. 예외 클래스의 생성자에서 ErrorCode 객체를 받고, 이를 통해 예외에 관련된 상태 코드, 에러 코드, 메시지를 관리한다.
CustomAuthenticationProvider 클래스에서 잘못된 비밀번호가 입력되었을 때, 다음과 같은 코드로 MemberApplicationException을 발생시킨다.
if (!bCryptPasswordEncoder.matches(userCryptPassword, securityUserDetailsDto.getTokenMemberInfoDto().password())) {
throw new MemberApplicationException(ErrorCode.USER_NOT_FOUND);
}
기존 MemberApplicationException 생성자는 ErrorCode만을 받고 있었으며, RuntimeException의 message 필드에 직접 값을 설정하지 않았다. 따라서, 예외 객체가 생성될 때 RuntimeException의 message 필드가 null로 남게 되었다. 이로 인해 로그에 null로 표시된 것이다.
MemberApplicationException의 생성자에서 super 호출을 통해 RuntimeException의 message 필드에 에러 메시지를 설정해 주는 코드를 추가해줘야 한다. 이렇게 수정하면 예외 객체에 실제 에러 메시지가 포함되어 로그에도 해당 메시지가 정상적으로 출력된다.
@Getter
@NoArgsConstructor
public class MemberApplicationException extends RuntimeException {
private final ErrorCode errorCode;
public MemberApplicationException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
이 변경으로, MemberApplicationException이 생성될 때 errorCode.getMessage()를 통해 얻은 메시지가 RuntimeException의 message 필드에 설정된다. 이후 예외가 발생하고 처리될 때, 이 메시지가 로그에 올바르게 기록될 수 있다.
error message를 가져오거나 설정할 때 MemberApplicationException에서 errorCode.getMessage()를 사용하는 것과 super를 이용해 부모의 message 필드를 사용하는 것 사이에는 중요한 차이가 있다.
자바 예외 처리 시스템에서 Throwable 클래스는 모든 에러와 예외의 최상위 클래스다. 이 클래스에는 detailMessage라는 필드가 있으며, 이 필드는 예외에 대한 설명을 저장한다. Exception과 RuntimeException 클래스는 Throwable을 상속받으며, Throwable의 기능을 확장한다.
public class Throwable implements Serializable {
@java.io.Serial
private static final long serialVersionUID = -3042686055658047285L;
private transient Object backtrace;
private String detailMessage;
// ...
public Throwable(String message) {
fillInStackTrace();
detailMessage = message;
}
// ...
}
이 상속 체인을 통해, RuntimeException과 그 서브클래스는 Throwable의 detailMessage 필드를 활용할 수 있다. RuntimeException의 생성자는 이 detailMessage 필드를 설정하는 기능을 제공한다.
MemberApplicationException은 RuntimeException을 상속받으며, 생성자에서 super(errorCode.getMessage())를 호출한다. 이 호출은 RuntimeException의 생성자를 통해 Throwable의 detailMessage 필드를 설정한다. 결과적으로, MemberApplicationException 객체에서 getMessage() 메서드를 호출하면, Throwable의 detailMessage 필드에 저장된 에러 메시지를 반환받게 된다.
// Throwable 클래스
public class Throwable {
private String detailMessage;
public Throwable(String message) {
this.detailMessage = message;
}
public String getMessage() {
return detailMessage;
}
}
// Exception 클래스
public class Exception extends Throwable {
public Exception(String message) {
super(message);
}
}
// RuntimeException 클래스
public class RuntimeException extends Exception {
public RuntimeException(String message) {
super(message);
}
}
// 사용자 정의 예외 클래스 MemberApplicationException
public class MemberApplicationException extends RuntimeException {
private ErrorCode errorCode;
public MemberApplicationException(ErrorCode errorCode) {
super(errorCode.getMessage()); // 여기에서 super() 호출이 Throwable의 detailMessage에 접근한다
this.errorCode = errorCode;
}
}
null이 로그에 기록된 이유는 MemberApplicationException이 생성될 때 RuntimeException의 message 필드에 실제 메시지가 설정되지 않았기 때문이다. 이는 예외 처리 로직의 구현 방식에서 발생한 오류로, 생성자를 수정하여 해결할 수 있다.
이러한 문제는 예외 처리 로직을 설계할 때 주의해야 할 중요한 포인트이며, 예외의 메시지와 상태를 명확하게 관리하는 것이 중요하다.
그러나!!!! 여전히 클라이언트로 반환되는 값은 계속 403 Forbidden 에러였다.
GlobalControllerAdvice에서 설정한 대로 왜 클라이언트에 응답되지 않는지 디버깅을 찍어봐도 GlobalControllerAdvice 이 클래스에 디버깅이 멈추지도 않았다.
몇 번 삽질한 결과 그 이유를 알아냈다,,,
GlobalControllerAdvice가 작동하지 않은 이유
로그인 과정에서 비밀번호가 틀린 경우 403 에러가 발생하는 이유는 스프링 시큐리티의 인증 메커니즘이 작동하는 방식 때문이다.
스프링 시큐리티와 관련된 인증 과정에서 발생하는 예외는 스프링 시큐리티의 필터 체인에 의해 처리되며, 이 과정에서 발생하는 예외는 일반적인 스프링 MVC의 @RestControllerAdvice에 정의된 예외 핸들러에 의해 처리되지 않는다.
인증 필터 체인 동작 방식
스프링 시큐리티의 인증 과정에서는 AuthenticationException과 그 서브클래스들이 주로 사용된다. 이러한 예외들은 스프링 시큐리티의 필터 체인 내부에서 처리되며, 인증 과정 중에 발생한 예외는 AuthenticationFailureHandler와 같은 핸들러에 의해 처리된다.
403 Forbidden 오류
사용자 정의 예외인 MemberApplicationException이 발생했을 때, 이는 AuthenticationException을 상속받지 않았기 때문에, 스프링 시큐리티의 필터 체인에서는 이 예외를 적절히 처리할 수 없다. 따라서, GlobalControllerAdvice에 의해 처리되어야 한다.
그러나, 스프링 시큐리티의 필터 체인이 예외를 먼저 캡처하고 기본 오류 처리(403 Forbidden)를 반환하므로, GlobalControllerAdvice에 도달하지 않게 된다.
로그 출력 부재
GlobalControllerAdvice의 MemberApplicationException 핸들러에 로그가 출력되지 않는 것도 같은 이유로 설명된다.
아래는 우리가 정의한 스프링 시큐리티 인증 프로세스다.
해결 방법
CustomAuthenticationProvider 수정
CustomAuthenticationProvider에서 발생하는 예외를 MemberApplicationException 대신 스프링 시큐리티가 처리할 수 있는 AuthenticationException의 서브클래스로 변경한다.
예를 들어, CustomAuthenticationProvider에서 사용자 상태 검증 실패 시 MemberApplicationException 대신 BadCredentialsException을 던지는 방식으로 변경할 수 있다:
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 기존 코드...
// 암호화된 비밀번호 비교
if (!bCryptPasswordEncoder.matches(userCryptPassword, securityUserDetailsDto.getTokenMemberInfoDto().password())) {
throw new BadCredentialsException("유저를 찾을 수 없습니다.");
}
// 기존 코드...
}
이렇게 CustomAuthenticationProvider에서 내가 정의한 에러가 아니라 BadCredentialsException을 던지는 것으로 수정하면, 스프링 시큐리티는 이 예외를 인식하고 CustomAuthFailureHandler를 호출한다다.
CustomAuthFailureHandler는 AuthenticationException의 서브클래스에 해당하는 예외들을 처리하도록 설계되어 있으며, 이 경우에는 인증 과정에서 발생한 BadCredentialsException을 처리하게 된다.
아래는 인증에 실패했을 때 실행되는 CustomAuthFailureHandler 클래스다.
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.*;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
/**
* 사용자 인증이 실패했을 때 호출되는 핸들러.
* 실패한 인증의 이유를 클라이언트에 JSON 형식으로 반환합니다.
*/@Slf4j
@Component
public class CustomAuthFailureHandler implements AuthenticationFailureHandler {
private final ObjectMapper objectMapper = new ObjectMapper(); // Jackson 라이브러리의 ObjectMapper를 활용해 JSON 변환
/**
* 사용자 인증이 실패했을 때 호출되는 메서드.
* 실패 이유를 파악한 후, 이를 JSON 형식으로 클라이언트에 반환한다.
* * @param request 클라이언트로부터의 요청 정보
* @param response 서버의 응답 정보
* @param exception 인증 중 발생한 예외 정보
* @throws IOException 입출력 예외가 발생한 경우
*/
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
Map<String, Object> resultMap = new HashMap<>();
String failMessage = getFailureMessage(exception);
// log.debug(failMessage);
log.error(failMessage);
resultMap.put("memberInfo", null);
resultMap.put("resultCode", 9999);
resultMap.put("failMessage", failMessage);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
try (PrintWriter printWriter = response.getWriter()) {
printWriter.print(objectMapper.writeValueAsString(resultMap));
printWriter.flush();
}
}
/**
* 인증 중 발생한 예외의 타입에 따라 실패 메시지를 반환합니다.
* * @param exception 인증 중 발생한 예외 정보
* @return 실패 메시지
*/
private String getFailureMessage(AuthenticationException exception) {
if (exception instanceof BadCredentialsException) {
return "로그인 정보가 일치하지 않습니다.";
} else if (exception instanceof LockedException) {
return "계정이 잠겨 있습니다.";
} else if (exception instanceof DisabledException) {
return "계정이 비활성화되었습니다.";
} else if (exception instanceof AccountExpiredException) {
return "계정이 만료되었습니다.";
} else if (exception instanceof CredentialsExpiredException) {
return "인증 정보가 만료되었습니다.";
}
return "알 수 없는 오류가 발생하였습니다.";
}
}
CustomAuthFailureHandler 동작 원리
1. onAuthenticationFailure 메서드가 호출되며, 인증 과정에서 발생한 AuthenticationException을 인자로 받는다.
2. 이 메서드 내에서 exception의 타입을 확인하여 적절한 실패 메시지를 생성한다.
3. 생성된 메시지는 클라이언트에 JSON 형식으로 반환된다.
따라서, CustomAuthenticationProvider에서 사용자의 계정 상태에 따라 BadCredentialsException을 던지면, 이 예외는 CustomAuthFailureHandler에서 처리되어 클라이언트에게 적절한 에러 메시지를 전달하게 된다. 이 방식을 사용하면 스프링 시큐리티의 인증 과정과 일관성을 유지하면서, 사용자에게 보다 상세한 에러 정보를 제공할 수 있다.
1. 스프링 시큐리티 필터 체인 밖에서 처리되어야 하는 예외의 경우, GlobalControllerAdvice를 사용할 수 있다.
2. 스프링 시큐리티 필터 체인 내에서 처리되어야 하는 예외의 경우, AuthenticationException의 서브클래스를 사용해야 한다. 이 경우 AuthenticationFailureHandler를 사용하여 클라이언트에 적절한 응답을 제공할 수 있다.
3. GlobalControllerAdvice는 @RestControllerAdvice 어노테이션이 붙어있어, 스프링 MVC 컨트롤러에서 발생하는 예외를 처리한다. 그러나 스프링 시큐리티 필터 체인에서 처리되는 예외는 여기에 포함되지 않을 수 있다.
이러한 이해를 바탕으로, 스프링 시큐리티와 관련된 예외 처리 방식을 설계할 때 적절한 예외 클래스와 핸들러를 선택하는 것이 중요하다.
결론
이 글을 통해 스프링 시큐리티 내에서 사용자 정의 에러 처리가 제대로 이루어지지 않는 문제의 원인을 분석하고 그 해결 방법을 알아보았다.
이 문제의 핵심 원인은 MemberApplicationException이 RuntimeException의 message 필드에 적절한 에러 메시지를 할당하지 않은 것이었다. 이를 해결하기 위해, 우리는 예외 클래스의 생성자를 개선하여 RuntimeException의 message 필드에 에러 메시지를 명시적으로 설정하는 방식으로 접근했다.
또한, 스프링 시큐리티의 인증 필터 체인이 AuthenticationException 및 그 서브클래스를 어떻게 처리하는지에 대해서도 알아봤다. 이 과정에서 GlobalControllerAdvice가 작동하지 않는 원인을 이해하고, CustomAuthFailureHandler를 활용하여 인증 실패 시 적절한 클라이언트 응답을 제공하는 방법을 알아봤다.
그리고 이번 문제를 해결하면서 GlobalControllerAdvice에서 개발자들끼리 내부적으로 에러 로그를 알아보기 쉽게 ErrorCode에 작성한 message가 그대로 클라이언트에 반환하는 문제도 발견했다. 이 부분도 나중에 수정해야겠다,,
이번 문제 해결을 통해 스프링 시큐리티와 에러 처리에 대한 이해가 더욱 명확해졌으며, 이러한 문제를 발견하고 해결할 수 있어서 다행이라고 생각한다,,,
이 포스트는 Team chillwave에서 사이드 프로젝트를 하던 중 적용했던 부분을 다시 공부하면서 기록한 것입니다.
시간이 괜찮다면 팀원 '개발자의 서랍'님의 블로그도 한번 봐주세요 :)
'Spring > Spring 트러블 슈팅' 카테고리의 다른 글
QueryDSL에서 Projections.constructor 사용해서 SQL 함수 사용하기 (0) | 2024.02.01 |
---|---|
스칼라 서브쿼리(Scalar Subquery)에서 Limit절 오류 해결 (0) | 2024.01.25 |
QueryDSL에서 NPE 해결하기 - 서브쿼리와 외부 조인의 활용 (2) | 2024.01.16 |
단위테스트에서 Static 메소드 Mock 주입 문제 해결 방법 (5) | 2023.12.25 |
로그인 실패 문제 - BCryptPasswordEncoder의 Salt 값과 Matches 메소드 이해하기 (2) | 2023.12.21 |