부트캠프/단기심화2기 TIL

[TIL] - ThreadLocal을 활용해 사용자 정보 관리하기

byungmin 2024. 12. 20. 01:46
Ch2 - MSA 기반 물류 시스템 개발 프로젝트

 

배운 것

이번 프로젝트를 진행하면서 MSA 환경에서 서비스를 나눠서 개발을 진행했다. 그 중 사용자 인증과 권한 관리를 효율적으로 처리하기 위해 JWT(Json Web Token)를 활용했다.

유저 서비스에서 사용자 인증이 이루어지면, 게이트웨이 서비스에서 토큰 검증을 수행하고, 유효한 토큰에 포함된 사용자 정보를 헤더에 추가하여 각 서비스로 전달하며, 이후 각 서비스는 이 헤더 값을 활용하여 사용자 정보를 확인하고 API를 처리하도록 설계했다. 

각 서비스에서는 넘어온 사용자 정보를 어떻게 저장하고 활용할지 고민을 하던 중 Spring Security를 사용하는 방법을 먼저 고려했다. 하지만 Security는 모든 요청에 대해 필터 체인을 거치고 컨텍스트를 관리해야 하는 구조라서 추가적인 부하와 복잡성을 초래할 수 있다는 단점이 있었다. 특히 간단한 사용자 정보 저장과 접근만 필요했던 상황에서는 조금 무겁다는 생각이 들었다.

대신 ThreadLocal을 활용해 사용자 정보를 저장하고 관리하는 방안을 선택했다. ThreadLocal은 각 요청 스레드에서 독립적인 저장소를 제공하므로, 사용자 정보를 쉽게 저장하고 요청 처리 흐름에서 이를 사용할 수 있었다. 

 

문제 상황

  • API 요청마다 헤더로 사용자 정보를 전달받아야 하는 상황에서 Spring Security를 도입하지 않고 사용자 정보를 관리할 방법 필요
  • 각 요청마다 사용자 정보를 안전하게 관리하며, 이를 컨트롤러나 서비스 레이어에서 쉽게 사용할 수 있는 구조를 고민

해결 방법

  • Filter를 통해 모든 요청에서 사용자 정보를 추출.
  • 사용자 정보를 AuthContext라는 컨텍스트 객체에 저장.
  • 요청 종료 후 ThreadLocal 데이터를 초기화하여 메모리 누수를 방지.
  • 컨트롤러 및 서비스에서 AuthContext를 통해 간단하게 사용자 정보를 사용.

코드 구현

1. Filter를 통해 사용자 정보 추출

@Component
public class AuthHeaderFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;

        // 헤더에서 사용자 정보 추출
        String userId = httpRequest.getHeader("X-USER-ID");
        String role = httpRequest.getHeader("X-USER-ROLE");

        if (userId != null && role != null) {
            // 사용자 정보를 ThreadLocal에 저장
            AuthContext.set(new AuthHeaderInfo(Long.valueOf(userId), role));
        }

        // 필터 체인 실행
        chain.doFilter(request, response);

        // 요청 종료 후 초기화
        AuthContext.clear();

    }
}

 

2. ThreadLocal을 사용해 사용자 정보 관리

public class AuthContext {
    private static final ThreadLocal<AuthHeaderInfo> CONTEXT = new ThreadLocal<>();

    public static void set(AuthHeaderInfo info) {
        CONTEXT.set(info);
    }

    public static AuthHeaderInfo get() {
        return CONTEXT.get();
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

 

3. 사용자 정보 객체 정의

public record AuthHeaderInfo(
        Long userId,
        String role
) {
}

 

4. Controller에서 사용자 정보 활용

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping
    public ResponseEntity<String> getOrderDetails() {
        AuthHeaderInfo authInfo = AuthContext.get();
        if (authInfo == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized Access");
        }

        return ResponseEntity.ok("User ID: " + authInfo.userId() + ", Role: " + authInfo.role());
    }
}

 

 

개선 사항

ThreadLocal을 사용해 AuthContext를 통해 사용자 정보를 관리하는 방법은 간단하고 유연하지만, 코드가 분산되면 어디서든 AuthContext를 호출할 수 있다는 단점이 있었다. 이로 인해 코드의 의존성이 높아지고, 예상치 못한 장소에서 AuthContext가 사용될 가능성이 있었다.

이 문제를 개선하고 더 일관된 방식으로 사용자 정보를 사용할 수 있도록 하기 위해, Spring Security의 @AuthenticationPrincipal처럼 컨트롤러 메서드에서 사용자 정보를 간단히 가져올 수 있는 방식을 도입했다. 이를 위해 다음과 같은 단계를 구현했다.

 

1. 사용자 정의 어노테이션 생성

사용자 정보를 매개변수로 간편하게 주입받기 위해 @AuthHeader라는 사용자 정의 어노테이션을 생성

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthHeader {
}

 

2. Argument Resolver 구현

이 어노테이션이 사용된 매개변수에 대해 AuthContext의 정보를 주입하기 위해, HandlerMethodArgumentResolver를 구현

public class AuthHeaderArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        // @AuthHeader 어노테이션이 있는 경우 지원
        return parameter.hasParameterAnnotation(AuthHeader.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        // ThreadLocal에서 AuthHeaderInfo 가져오기
        AuthHeaderInfo authHeaderInfo = AuthContext.get();
        if (authHeaderInfo == null) {
            throw new throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication information is missing.");
        }
        return authHeaderInfo;
    }
}

 

3. Argument Resolver 등록

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthHeaderArgumentResolver());
    }
}

 

4. Controller에서 사용

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping
    public ResponseEntity<String> getOrderDetails(@AuthHeader AuthHeaderInfo authInfo) {
        return ResponseEntity.ok("User ID: " + authInfo.userId() + ", Role: " + authInfo.role());
    }
}

 

개선점

  • 코드 가독성 향상: 컨트롤러 메서드에서 사용자 정보를 명시적으로 선언할 수 있어 코드의 의도가 명확해졌다.
  • 의존성 관리: AuthContext를 직접 호출하지 않고, 일관된 방식으로 사용자 정보를 주입받을 수 있게 됐다.
  • 확장성: 향후 인증 로직이나 사용자 정보의 저장 방식을 변경해야 할 때, Argument Resolver만 수정하면 된다.