기존에는 accessToken
, refreshToken
을 UserController
에서 직접 HttpServletResponse
를 통해 헤더에 수동으로 설정하고 있었다.
response.setHeader("Authorization", accessToken);
response.setHeader("Refresh-Token", refreshToken);
하지만 이 방식은 Controller가 Servlet API에 강하게 의존하게 되며 관심사 분리가 잘 되지 않고 계층 구조가 무너지게 되었다.
UserService.login()
에서 토큰을 생성한 뒤, TokenHolder
(ThreadLocal 기반) 에 저장한다.ThreadLocal
을 활용하면 현재 요청 스레드에서만 유지되는 안전한 공간에 데이터를 저장할 수 있어서 Servlet 객체 없이도 토큰을 다른 계층에 전달할 수 있다.ResponseBodyAdvice
를 구현한 TokenHeaderAdvice
가 login()
메서드의 응답일 경우에만 TokenHolder
에서 토큰을 꺼내 HttpServletResponse
헤더에 자동으로 주입한다.ResponseBodyAdvice
는 응답 바디 직전에 개입할 수 있는 매우 유용한 확장 포인트로 헤더 주입 로직을 분리하고 응답 흐름에 깔끔하게 녹여낼 수 있다.UserController.login()
은 순수하게 DTO만 반환하게 됐고 Servlet API와 완전히 분리된다.TokenHeaderAdvice
@ControllerAdvice
public class TokenHeaderAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
Method method = returnType.getMethod();
return method != null && method.getName().equals("login");
}
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
String access = TokenHolder.getAccessToken();
String refresh = TokenHolder.getRefreshToken();
if (access != null && refresh != null) {
response.getHeaders().add(JwtProvider.AUTHORIZATION_HEADER, access);
response.getHeaders().add(JwtProvider.REFRESH_TOKEN_HEADER, refresh);
}
// 메모리 누수 방지를 위해 반드시 clear() 호출
TokenHolder.clear();
return body;
}
}
TokenHolder
public class TokenHolder {
private TokenHolder() {}
private static final ThreadLocal<String> accessToken = new ThreadLocal<>();
private static final ThreadLocal<String> refreshToken = new ThreadLocal<>();
public static void set(String access, String refresh) {
accessToken.set(access);
refreshToken.set(refresh);
}
public static String getAccessToken() { return accessToken.get(); }
public static String getRefreshToken() { return refreshToken.get(); }
public static void clear() {
accessToken.remove();
refreshToken.remove();
}
}
[요청]
↓
LoggingFilter (요청 바디 JSON 로깅, traceId 설정)
↓
JwtAuthenticationFilter (JWT 토큰 검증 및 인증 처리)
↓
LoggingInterceptor (multipart/form-data 로깅, 응답 바디 로깅)
↓
UserController.login() 진입
↓
UserService.login() → 토큰 생성 → TokenHolder.set()
↓
ResponseBodyAdvice(TokenHeaderAdvice) → 응답 헤더에 토큰 자동 주입
↓
[응답]
이 흐름 덕분에 Controller는 어떤 Servlet API에도 의존하지 않으며 모든 인증/로깅/헤더처리는 필터와 인터셉터, 어드바이스에서 처리하도록 설계되었다.
Servlet 객체를 Controller에서 직접 사용하는 건 구현은 간단하지만, 구조적인 측면에서는 단점이 많다는 걸 느꼈다. 이번 구조 변경을 통해서 인증/응답 헤더 설정은 서블릿 계층, 비즈니스 로직은 서비스 계층, 응답 DTO 생성은 컨트롤러 계층으로 분리되어 계층 간 책임이 명확하게 마뉘는 게 유지보수에 유리하다는 걸 알 수 있었다.
앞으로 처리 위치를 한 번 더 고민하고 구조적으로 깔끔한 방향을 고려하며 설계해야겠다는 생각이 들었다.