얼마 전 한국대학생IT경영학회(KUSITMS) 30기의 스텝업데이가 있었다.
스텝업데이는 다른 밋업 프로젝트 팀과 상호 피드백을 진행하며,
모르는 부분은 물어보기도 하고 UT도 진행하며 각 팀의 역량을 향상시키는 커리큘럼이다!
나는 첫 타임 때 G팀의 백엔드 개발자들과 대화를 나누었고, 해당 팀에서 겪고 있는 에러에 대해서도 공유 받게 되었다.
마침 얼마 전에 에러 처리에 대해서 공부를 하고 리팩토링을 진행했던 터라, 도움을 주고자 하였지만 처음 보는 문제였기에 짧은 시간 내에 해결할 수는 없었다.
때문에 양해를 구하고 코드를 받아 세션이 끝난 후 트러블 슈팅을 진행했고, 결국 문제를 해결할 수 있었다!
이 과정에서 새롭게 알게 된 점들이 있어 글로 한 번 정리해보려고 한다 ✍🏻
문제는 다음과 같았다.
@PostMapping("/register")
public ResponseEntity<ApiResponse<UserResponse.UserDto>> registerUser(
HttpServletResponse response,
@RequestHeader("registerToken") String registerToken,
@RequestBody UserRequest.UserRegisterDto userRegisterDto
) {
UserResponse.UserDto registerResponse = userService.registerUser(response, registerToken, userRegisterDto);
return ApiResponse.success(UserSuccessStatus.USER_REGISTER_SUCCESS, registerResponse);
}
유저를 등록하는 POST 메서드 API에 GET 요청을 하였을 때,
405 METHOD_NOT_ALLOWED
에러를 반환해야했지만
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport"
content="user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, width=device-width">
<meta name="next-head-count" content="2">
<meta http-equiv="x-ua-compatible" content="IE=edge">
<meta content="website" property="og:type">
<meta content="카카오계정" property="og:title">
<meta content="https://accounts.kakaocdn.net/images/og_kakao.png" property="og:image">
<script type="text/javascript" src="//t1.daumcdn.net/tiara/js/v1/tiara.min.js" defer></script>
<link rel="preload" href="https://accounts.kakaocdn.net/_next/static/css/4c10cf947c806172.css" as="style">
<link rel="stylesheet" href="https://accounts.kakaocdn.net/_next/static/css/4c10cf947c806172.css" data-n-g="">
<link rel="preload" href="https://accounts.kakaocdn.net/_next/static/css/a9486ddd6d4b3bef.css" as="style">
<link rel="stylesheet" href="https://accounts.kakaocdn.net/_next/static/css/a9486ddd6d4b3bef.css" data-n-p="">
<link rel="preload" href="https://accounts.kakaocdn.net/_next/static/css/2f1d8a5bd195ff97.css" as="style">
<link rel="stylesheet" href="https://accounts.kakaocdn.net/_next/static/css/2f1d8a5bd195ff97.css" data-n-p="">
<noscript data-n-css=""></noscript>
<script defer nomodule="" src="https://accounts.kakaocdn.net/_next/static/chunks/polyfills-5cd94c89d3acac5f.js">
</script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/webpack-06fc05ffda14b141.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/framework-f8115f7fae64930e.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/main-a7d45bce11193232.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/pages/_app-7c592d7e99f1ec02.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/29107295-f5d3d9a71e7e292a.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/39694-4cf1f4ff8ab2886e.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/69695-9ee32dd2295c9b6f.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/34853-0efce61660864821.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/47490-94363953cc8690c8.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/95939-120f25eb86d0ade6.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/5461-fa3639993a4cbbb0.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/3889-47f37a84cb88373a.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/93171-b76eb1e385744e24.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/pages/login/login-3917652c4ae8dd7b.js" defer>
</script>
<script src="https://accounts.kakaocdn.net/_next/static/WIzczCRZ_Fqwm1G9ryDEv/_buildManifest.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/WIzczCRZ_Fqwm1G9ryDEv/_ssgManifest.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/WIzczCRZ_Fqwm1G9ryDEv/_middlewareManifest.js" defer>
</script>
</head>
<body class="os_other pc type_responsive app_view">
<div id="__next" data-reactroot=""></div>
<script id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{"pageContext":{"commonContext":{"locale":"ko","uaClass":"os_other pc","responsiveView":true,"responsivePopup":false,"mobile":false,"webview":{"app":"web","webViewType":"none","appVersion":"","os":"other","osVersion":"","supportNavigation":false,"supportFilePicker":true,"supportExecUrlScheme":false,"supportMarketUrlScheme":true},"supportRefererMetaTag":false,"showHeader":false,"showFooter":true,"isLogin":false,"linkParams":{},"displayId":null,"isTalkUser":false,"isCorporation":false,"syncUrl":null,"showPasskey":true,"darkMode":null,"_csrf":"b16403e5-ada7-4950-9949-b3ddbec82106","kage_file_max_size":100,"upload_kage_url":"https://up-api1-kage.kakao.com/up/kaccount-p/","p":"01SayCrnha4sLgx8zt6z_CjJRwQL7yBZo-CuP67vHUg"},"context":{"webType":"web","defaultEmail":null,"showStaySignIn":true,"defaultStaySignIn":false,"appendStaySignedIn":false,"defaultCountryCode":"KR_82","showQrLogin":true,"showWebTalkLogin":false,"showDeviceFormLogin":false,"showPasskeyLogin":true,"needCaptcha":false,"showIpSecurity":false,"loginUrl":"/login?continue=https%3A%2F%2Fkauth.kakao.com%2Foauth%2Fauthorize%3Fresponse_type%3Dcode%26state%3DYOV2Ai4MRpuzLEEBRGNLCEiZ056SU5AVH49YlrMyToE%253D%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8080%252Flogin%252Foauth2%252Fcode%252Fkakao%26client_id%3D1f1181f4cf9440667d5179e0a87f63d7%26scope%3Dprofile_nickname%26through_account%3Dtrue","continueUrl":"https://kauth.kakao.com/oauth/authorize?response_type=code&state=YOV2Ai4MRpuzLEEBRGNLCEiZ056SU5AVH49YlrMyToE%3D&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Flogin%2Foauth2%2Fcode%2Fkakao&client_id=1f1181f4cf9440667d5179e0a87f63d7&scope=profile_nickname&through_account=true","useSimpleLogin":true,"exceedSimpleLoginLimit":false,"defaultSaveSignIn":false,"isTalkLoginError":false,"linkParams":{"lang":["ko"]},"requests":{"check_daum_sso":["get","https://logins.daum.net/accounts/endpoint/favicon.ico"]},"isDaum":false,"banner":null}}}},"page":"/login/login","query":{},"buildId":"WIzczCRZ_Fqwm1G9ryDEv","assetPrefix":"https://accounts.kakaocdn.net","nextExport":true,"isFallback":false,"gip":true,"scriptLoader":[]}
</script>
</body>
</html>
위와 같은 요상한 html문만 계속해서 반환을 했다.
@Slf4j
@RestControllerAdvice(annotations = {RestController.class})
public class GeneralExceptionAdvice extends ResponseEntityExceptionHandler {
...
전역 에러 처리를 담당하는 GeneralExceptionAdvice
클래스에서 405 에러에 대한 커스텀을 진행하지는 않았지만, ResponseEntityExceptionHandler
를 상속 받았기에 기본적인 에러 처리는 되었어야했다.
에러 처리가 제대로 되지 않는 위의 문제를 해결하고자 아래와 같은 여러 방법들을 시도했다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity, AuthenticationEntryPoint authenticationEntryPoint) throws Exception {
httpSecurity
.httpBasic(HttpBasicConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.requestCache(AbstractHttpConfigurer::disable)
.cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.exceptionHandling(exceptionHandlingConfigurer ->
exceptionHandlingConfigurer
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(customAccessDeniedHandler)
)
.addFilterBefore(new JwtFilter(jwtUtil, cookieUtil), ExceptionTranslationFilter.class)
.oauth2Login(oauth ->
oauth
.successHandler(oAuthLoginSuccessHandler)
.failureHandler(oAuthLoginFailureHandler)
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize ->
authorize
.requestMatchers(allowedUrls).permitAll()
.anyRequest().authenticated()
);
return httpSecurity.build();
}
SecurityConfig 코드에서
.exceptionHandling(exceptionHandlingConfigurer ->
exceptionHandlingConfigurer
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(customAccessDeniedHandler)
)
해당 부분을 추가하여 명시적으로 예외 처리 핸들러를 설정해준다.
@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, authException) -> {
ObjectMapper mapper = new ObjectMapper();
ErrorStatus errorStatus = ErrorStatus.UNAUTHORIZED;
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.setStatus(errorStatus.getHttpStatus().value());
ApiResponse<Object> errorResponse = ApiResponse.error(ErrorStatus.UNAUTHORIZED).getBody();
response.getWriter().write(mapper.writeValueAsString(errorResponse));
};
}
AuthenticationEntryPoint은 위처럼 커스터마이징하여,
사용자가 인증되지 않은 상태로 리소스에 접근하려고 하면 UNAUTHORIZED
에러 응답을 반환한다.
그 결과 위처럼 401 에러는 받을 수 있었지만,
우리가 원하는 것은 405 에러 응답이었기에 다른 방법을 시도했다.
지원하지 않는 HTTP 메소드 요청이 들어온 경우에는 HttpRequestMethodNotSupportedException
발생하게 되고, 이 경우에 405 METHOD_NOT_ALLOWED
에러를 반환해준다.
때문에 위 예외를 처리해줄 수 있는 메서드를 GeneralExceptionAdvice
내에 만들어주었다.
// HttpRequestMethodNotSupportedException 처리 (지원하지 않는 HTTP 메소드 요청이 들어온 경우)
@Override
protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String errorMessage = "지원하지 않는 HTTP 메소드 요청입니다: " + ex.getMethod();
logError("HttpRequestMethodNotSupportedException", errorMessage);
return ApiResponse.error(ErrorStatus.METHOD_NOT_ALLOWED, errorMessage);
}
위에서 언급한 것처럼 ResponseEntityExceptionHandler
를 상속 받고 있기 때문에, 오버라이드를 통해서 메서드를 구현해야했다.
이에 대한 내용은 전편에서 확인해 보아도 좋을 것 같다!
하지만 그럼에도 여전히 html을 반환했다..사실 애초에 에러 처리 자체가 안되고 있는 것이기에 당연한 결과이기는 했다.
지금까지 내가 아는 지식으로는 해결할 수 없다는 것을 깨닫았다 🤯
결국 기존에 내 프로젝트에서 사용 중인 전역 에러 처리 코드와 무언가 다른 점이 있을 것이라고 생각하고 비교를 해 보았다.
그러다 한 가지 다른 점을 발견하게 되었는데...
@Slf4j
@RestControllerAdvice(annotations = {RestController.class})
public class GeneralExceptionAdvice extends ResponseEntityExceptionHandler {
...
여기서 @RestControllerAdvice(annotations = {RestController.class})
이 부분이었다.
(annotations = {RestController.class})
를 제거하고 API를 호출하자..
화면과 같이 405 에러 처리가 잘 진행이 되었다! 🫢
문제는 해결되어 기분이 좋았으나...
(annotations = {RestController.class})
라는 코드는 처음 보아 정확히 어떤 것 때문에 해결이 된 것인지 알 수 없었다.
찾아보니 해당 코드는 @RestController
어노테이션을 붙인 클래스에서 발생한 에러들만을 처리해준다는 의미라고 한다.
여기서 한 가지 또 의문이 든 점은..
405 에러도 API 호출할 때 발생하는 것이 아닌가?
였다.
의문점을 해결하기 위해, Chat GPT와 함께 Spring MVC의 요청 처리 과정에 대해서 알아보았다.
사용자가 브라우저나 클라이언트에서 특정 URL로 요청을 보낸다.
요청은 톰캣(Tomcat) 등의 서블릿 컨테이너로 들어온다.
서블릿 컨테이너는 요청을 가장 먼저 받아들이며, Spring Boot에서는 이 요청이 DispatcherServlet
으로 전달된다.
Spring MVC의 핵심인 DispatcherServlet은 요청을 처리하기 위한 중앙 진입점이다.
DispatcherServlet은 요청을 처리하기 위해 아래의 순서를 따른다.
DispatcherServlet은 요청에 맞는 컨트롤러를 찾기 위해 HandlerMapping을 사용한다.
만약 여기서 매핑된 컨트롤러가 없으면 404 Not Found 에러가 발생한다.
HandlerMapping이 매핑된 핸들러를 찾으면, 요청을 처리할 수 있는 HandlerAdapter를 조회한다.
HandlerAdapter는 실제로 핸들러 메서드를 호출하는 역할을 한다.
HandlerAdapter는 실제 컨트롤러 메서드(@RestController, @Controller)를 호출한다.
컨트롤러는 요청을 처리한 후 적절한 응답(ResponseEntity, JSON 등)을 반환한다.
만약 컨트롤러에서 예외가 발생하면 다음과 같은 순서로 예외를 처리한다.
만약 HttpRequestMethodNotSupportedException
처럼 특정 예외가 발생하면, ResponseEntityExceptionHandler
가 이를 처리한다.
예를 들어, 지원되지 않는 HTTP 메서드(405 Method Not Allowed) 요청이 들어올 경우, handleHttpRequestMethodNotSupported
가 호출된다.
최종적으로 DispatcherServlet은 컨트롤러에서 반환된 결과 또는 예외 처리 결과를 클라이언트로 반환한다.
위에서 나열한 내용들을 기반으로 문제점을 정리해보겠다.
DispatcherServlet에서 클라이언트의 요청을 받은 후 3-1. HandlerMapping 단계에서 요청에 맞는 컨트롤러를 찾게 되는데, 만약 HTTP 메서드를 잘못 호출한 경우 해당 단계에서 HttpRequestMethodNotSupportedException
예외를 발생하게 된다.
하지만 (annotations = {RestController.class})
코드로 인해서,
3-3. Controller 호출 단계부터 발생하는 예외에 대해서만 전역 처리를 하였기에 그 이전 단계에서 발생한 예외들은 처리할 수 없었던 것이다.
또한 3-5. ResponseEntityExceptionHandler 부분을 보면 특정 예외들에 대해서는 ResponseEntityExceptionHandler
에서 자동으로 처리해주기에, (annotations = {RestController.class})
를 제거한다면 꼭 오버라이드를 하지 않아도 아래와 같은 기본 응답은 받을 수 있다.
결론적으로 (annotations = {RestController.class})
코드를 제거함으로써,
컨트롤러 단계 뿐만 아니라 다른 단계에서 발생하는 예외들도 처리할 수 있게 되었다.
지금까지의 과정으로 다행히 문제는 잘 해결이 되었다 😮💨
하지만 응답으로 온 요상한 html문이 자꾸 마음에 걸려 이를 한 번 파악해보기로 했다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport"
content="user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, width=device-width">
<meta name="next-head-count" content="2">
<meta http-equiv="x-ua-compatible" content="IE=edge">
<meta content="website" property="og:type">
<meta content="카카오계정" property="og:title">
<meta content="https://accounts.kakaocdn.net/images/og_kakao.png" property="og:image">
<script type="text/javascript" src="//t1.daumcdn.net/tiara/js/v1/tiara.min.js" defer></script>
<link rel="preload" href="https://accounts.kakaocdn.net/_next/static/css/4c10cf947c806172.css" as="style">
<link rel="stylesheet" href="https://accounts.kakaocdn.net/_next/static/css/4c10cf947c806172.css" data-n-g="">
<link rel="preload" href="https://accounts.kakaocdn.net/_next/static/css/a9486ddd6d4b3bef.css" as="style">
<link rel="stylesheet" href="https://accounts.kakaocdn.net/_next/static/css/a9486ddd6d4b3bef.css" data-n-p="">
<link rel="preload" href="https://accounts.kakaocdn.net/_next/static/css/2f1d8a5bd195ff97.css" as="style">
<link rel="stylesheet" href="https://accounts.kakaocdn.net/_next/static/css/2f1d8a5bd195ff97.css" data-n-p="">
<noscript data-n-css=""></noscript>
<script defer nomodule="" src="https://accounts.kakaocdn.net/_next/static/chunks/polyfills-5cd94c89d3acac5f.js">
</script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/webpack-06fc05ffda14b141.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/framework-f8115f7fae64930e.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/main-a7d45bce11193232.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/pages/_app-7c592d7e99f1ec02.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/29107295-f5d3d9a71e7e292a.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/39694-4cf1f4ff8ab2886e.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/69695-9ee32dd2295c9b6f.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/34853-0efce61660864821.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/47490-94363953cc8690c8.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/95939-120f25eb86d0ade6.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/5461-fa3639993a4cbbb0.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/3889-47f37a84cb88373a.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/93171-b76eb1e385744e24.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/chunks/pages/login/login-3917652c4ae8dd7b.js" defer>
</script>
<script src="https://accounts.kakaocdn.net/_next/static/WIzczCRZ_Fqwm1G9ryDEv/_buildManifest.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/WIzczCRZ_Fqwm1G9ryDEv/_ssgManifest.js" defer></script>
<script src="https://accounts.kakaocdn.net/_next/static/WIzczCRZ_Fqwm1G9ryDEv/_middlewareManifest.js" defer>
</script>
</head>
...
이를 GPT에게 분석해달라 하니,
카카오 로그인 페이지의 정적 HTML 콘텐츠
라고 알려주었다.
이는 보통 인증이 필요한 요청에서의 인증이 실패했을 때 반환이 되는데,
유저 등록은 인증이 필요하지 않았기 때문에 더욱 의문점이 남았었다.
위의 과정들을 겪고 나니 3-1. HandlerMapping 단계에서 발생한 예외가 정상적으로 처리 되지 못 했기 때문에, Spring Security
에서 이를 자체적으로 처리한 것이라는 걸 알 수 있었다.
카카오 소셜 로그인을 구현한 상태이고, 아래 application.yaml 파일의
security:
oauth2:
client:
registration:
해당 부분의 하위에 있는 플랫폼 로그인 화면으로 리다이렉트 되었던 것이다.
만약에 annotations = {RestController.class})
코드를 꼭 써야한다면,
@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, authException) -> {
ObjectMapper mapper = new ObjectMapper();
ErrorStatus errorStatus = ErrorStatus.UNAUTHORIZED;
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.setStatus(errorStatus.getHttpStatus().value());
ApiResponse<Object> errorResponse = ApiResponse.error(ErrorStatus.UNAUTHORIZED).getBody();
response.getWriter().write(mapper.writeValueAsString(errorResponse));
};
}
위에서도 언급한 AuthenticationEntryPoint 커스텀을 진행해야 다른 단계에서도 발생하는 에러들을 잡을 수 있을 것이다.
html문을 복사해서 vscode에서 붙여넣은 후 저장하고
저장된 파일을 더블클릭하게 되면
이렇게 카카오톡 로그인 기본 페이지가 열리는 것을 볼 수 있다!
물론 이는 정적 파일이기 때문에 동작은 되지 않는다.
Spring MVC의 요청 처리 과정
에 대해서 알 수 있어서 좋았다! 에러 처리를 여러 번 진행하고 공부했지만, 지금까지 어느 단계에서 예외가 발생하는지에 대해서는 알지 못했던 것 같다. 이를 알고 나니 다음부터는 조금 더 상황에 맞는 에러 처리 기능을 구현할 수 있을 것 같다.
진짜 고퀄 미쳤다.. html까지 열어본거 👍👍