로그인/로그아웃 토큰 응답 구조 개선

송현진·2025년 4월 15일
0

Spring Boot

목록 보기
15/23

⚠️ 기존 문제

기존에는 accessToken, refreshTokenUserController에서 직접 HttpServletResponse를 통해 헤더에 수동으로 설정하고 있었다.

response.setHeader("Authorization", accessToken);
response.setHeader("Refresh-Token", refreshToken);

하지만 이 방식은 Controller가 Servlet API에 강하게 의존하게 되며 관심사 분리가 잘 되지 않고 계층 구조가 무너지게 되었다.

✅ 개선한 방식

  • UserService.login()에서 토큰을 생성한 뒤, TokenHolder(ThreadLocal 기반) 에 저장한다.
    ThreadLocal을 활용하면 현재 요청 스레드에서만 유지되는 안전한 공간에 데이터를 저장할 수 있어서 Servlet 객체 없이도 토큰을 다른 계층에 전달할 수 있다.
  • 이후 ResponseBodyAdvice를 구현한 TokenHeaderAdvicelogin() 메서드의 응답일 경우에만 TokenHolder에서 토큰을 꺼내 HttpServletResponse 헤더에 자동으로 주입한다.
    ResponseBodyAdvice응답 바디 직전에 개입할 수 있는 매우 유용한 확장 포인트로 헤더 주입 로직을 분리하고 응답 흐름에 깔끔하게 녹여낼 수 있다.
  • 이로 인해 UserController.login()은 순수하게 DTO만 반환하게 됐고 Servlet API와 완전히 분리된다.
    결과적으로 Controller는 오직 요청 처리와 응답 생성에만 집중할 수 있고 인증/토큰 로직은 완전히 분리되었다.

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 생성은 컨트롤러 계층으로 분리되어 계층 간 책임이 명확하게 마뉘는 게 유지보수에 유리하다는 걸 알 수 있었다.

앞으로 처리 위치를 한 번 더 고민하고 구조적으로 깔끔한 방향을 고려하며 설계해야겠다는 생각이 들었다.

profile
개발자가 되고 싶은 취준생

0개의 댓글