티스토리 뷰

문제의 시작, 이 요청은 누가 했을까?

API를 개발하다 보면 등록, 수정, 삭제와 같은 변경 작업에서 요청자가 누구인지 반드시 기록해야 하는 경우가 대부분이다.

  • 게시글 작성자
  • 수정 요청자
  • 삭제 요청자
  • 감사 로그 기록

Spring Security + JWT를 사용하는 환경이라면 보통 아래와 같이 구현한다.

@PostMapping
public ApiResponse<Integer> createPost(@RequestBody PostCreateRequest request) {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    String userId = auth != null ? auth.getName() : null;
    request.setRequestUserId(userId);
    return ApiResponse.success(postService.createPost(request));
}

소스에 문제는 없다. 하지만 몇 가지 아쉬움이 있다.

  • 모든 컨트롤러 메소드에서 SecurityContextHolder 를 직접 열어야 한다는 것
  • 인증 여부 검증이 각 메소드에 흩어지게 되는 것
  • 보안 인프라 코드가 표현 계층에 노출되는 것
  • 인증 방식이 변경될 경우 컨트롤러 메소드 수정이 필요한 것

이는 곧 관심사 분리가 깨지고 있는 것으로 볼 수 있다. 컨트롤러는 요청과 응답을 다루는 표현 계층이지만, 보안 컨텍스트 접근은 인프라 레벨의 책임이다.

 

관심사 분리를 유지하고 개선하기 위해 몇 가지 목표를 세웠다.

  • 컨트롤러에서는 요청자를 선언적으로 받을 것
  • 인증 여부 판단과 사용자 추출은 한 곳에서 처리할 것
  • SecurityContextHolder에 대한 직접적인 의존 제거

Spring MVC의 HandlerMethodArgumentResolver

Spring MVC에는 컨트롤러 파라미터를 커스터마이징할 수 있는 확장 포인트가 있다. 이것을 이용하면 컨트롤러는 보안 구현을 몰라도 된다.

public interface HandlerMethodArgumentResolver {
    boolean supportsParameter(MethodParameter parameter);
    Object resolveArgument(...) throws Exception;
}

@CurrentUser 설계

현재 인증된 사용자 ID를 주입하기 위한 전용 어노테이션을 정의했다.

어노테이션 정의

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

@Target(PARAMETER) 는 파라미터 전용임을 표시하는 것이고, 리졸버에서 읽을 수 있도록 RUNTIME으로 했다.

ArgumentResolver 구현

@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class)
                && parameter.getParameterType().equals(String.class);
    }

    @Override
    public Object resolveArgument(
            MethodParameter parameter,
            ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            WebDataBinderFactory binderFactory) {

        Authentication authentication =
                SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null
                || !authentication.isAuthenticated()
                || authentication instanceof AnonymousAuthenticationToken) {
            throw new IllegalStateException("사용자가 인증되지 않았습니다.");
        }

        // 본 프로젝트에서는 JWT subject(sub)를 userId로 사용한다.
        return authentication.getName();
    }
}

여기서 AnonymousAuthenticationToken 체크를 하는 이유는, AnonymousAuthenticationToken은 Spring Security에서 익명 사용자를 표현하기 위한 구현체이며, 기본 설정에서는 isAuthenticated()가 true를 반환한다. 따라서 단순히 isAuthenticated()만으로 인증 여부를 판단하는 것은 안전하지 않으므로, 추가적인 검사가 더 필요했다.

 

WebMvc 설정 등록

이제 @CurrentUser가 붙은 파라미터는 자동으로 이 리졸버를 통해 채워진다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    private final CurrentUserArgumentResolver currentUserArgumentResolver;

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

사용 예시

@PostMapping
@Operation(summary = "포스트 등록")
public ApiResponse<Integer> createPost(
        @RequestBody @Valid PostCreateRequest request,
        @CurrentUser String userId) {
    request.setRequestUserId(userId);
    return ApiResponse.success(postService.createPost(request));
}

이제 컨트롤러는 SecurityContextHolder, Authentication 구조, JWT 구현 방식을 몰라도 된다.

하지만, @AuthenticationPrincipal을 쓰면 되지 않을까?

Spring Security에는 이미 다음 기능이 있다.

@AuthenticationPrincipal CustomUserDetails user

또는 Principal principal 을 직접 주입받을 수도 있다.
@AuthenticationPrincipal 을 사용할 경우, 컨트롤러가 UserDetails와 같은 보안 구현 타입에 직접 의존하게 된다. 이는 표현 계층의 메소드 시그니처에 Security 구현 세부사항이 드러난다는 의미가 되기도 한다.
또한 만약 인증 객체 구조가 변경된다면(UserDetails → OAuth2User 등), 컨트롤러까지 수정이 전파될 가능성이 있다.이번 구현에서는 컨트롤러가 “보안 객체”가 아닌 “현재 사용자”라는 도메인 개념에만 의존하도록 만들고 싶었다. 따라서 Security 구현 타입을 노출하는 대신, 필요한 최소 정보(userId)만 선언적으로 주입받는 방식을 선택했다.

설계 의도

  • 표현 계층에서 보안 구현 제거
    • 컨트롤러는 더 이상 SecurityContextHolder에 의존하지 않는다.
  • 인증 처리 일원화
    • 인증 검증 로직이 한 곳에 존재하게 된다.
  • 인증 방식 변경에 대한 유연성
    • JWT → OAuth2 → Session 변경 시, Resolver 내부만 수정하면 된다.

이로써, 위에서 언급한 3개의 목표는 모두 달성하며 의존성의 방향이 명확해졌다.

마무리 하며

처음에는 단순히 SecurityContextHolder를 매번 호출하는 코드가 번거롭게 느껴져서 개선을 하고자 했다. 하지만 정리하다 보니 문제는 “코드가 길다”가 아니라, 보안 구현 세부사항이 표현 계층에 노출되어 있는 것이 과연 맞을까였다.
컨트롤러는 요청을 받고 응답을 반환하는 역할에 집중해야 하고, 현재 인증 사용자를 어떻게 가져오는지는 컨트롤러가 알아야 할 이유가 없다.
덕분에 컨트롤러는 읽기 쉬워졌고, 보안 방식이 바뀌더라도 수정 범위는 ArgumentResolver 에 국한된다.
작은 추상화였지만, 계층 간 경계를 정리하고 다시 한 번 되돌아 볼 수 있는 계기가 되었다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함