ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • @MemberId 도입기: 스프링 MVC 요청 흐름과 커스ArgumentResolver 활용
    Spring 2025. 9. 8. 18:15

     

    서론

    최근 진행한 프로젝트에서  컨트롤러에서 사용자 ID를 매번 직접 꺼내서 처리하는 중복을 줄이고, 비즈니스 로직과 요청 파싱을 분리하기 위해 @MemberId 커스텀 애노테이션과 이에 대응하는 ArgumentResolver 패턴을 도입하기로 결정했습니다.

     

    간단히 말하면 컨트롤러는 현재 로그인한 사용자 ID만 받는다는 의도를 명확히 하고, 그 값을 어디서 어떻게 얻는지는 필터/리졸버가 책임지게 한 것입니다.

     

    이 과정에서 헷갈리는 스프링 MVC의 요청 흐름의 개념을 다시 공부하고, 제가 @MemberId 커스텀 애노테이션을 사용하는지 정리하려고 합니다.

     


     

    스프링 MVC의 전체 요청 흐름

    클라이언트 → 서블릿 컨테이너(Tomcat) → 서블릿 필터 체인 → DispatcherServlet → HandlerMapping → HandlerAdapter → Argument Resolution → 컨트롤러 메서드 실행 → 응답 처리

     

    1. 서블릿 컨테이너

    • 서블릿 컨테이너는 쉽게 말해 웹 서버 + 자바 코드 실행기 역할입니다. (예: tomcat)
    • 클라이언트가 요청을 보내면 Tomcat이 소켓을 열고, HttpServletRequest(요청), HttpServletResponse(응답)를 만들어 DispatcherServlet에 넘깁니다.

     

     

    2. 서블릿 필터 체인

    • 요청을 처리하는 곳으로, Low레벨의 task를 감지할 때 사용합니다. 
    • 필터는 요청을 전처리하거나, 차단하거나, 요청/응답을 래핑하는 데 사용됩니다. 
      • doFilter()에서 기능에 대한 로직을 수행합니다. 
      • doFilter()를 호출해야 다음 필터 또는 DispatcherServlet으로 넘어갑니다.
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
        System.out.println("요청 들어옴");
        chain.doFilter(request, response); 
        System.out.println("응답 나감");
    }
    • CORS 처리, 로깅, 인증 토큰 파싱, 요청 바디 래핑처럼 공통적으로 적용해야 하는 전처리나 접근 제어 로직을 넣기에 적합합니다.
    • OncePerRequestFilter: 같은 요청이여러 번 처리되더라도, 필터 로직이 한 번만 실행되도록 보장해 주는 스프링의 편의 클래스입니다.
    • 필터는 DispatcherServlet 실행 이전에 동작합니다. 따라서 어떤 컨트롤러가 호출될지는 알 수 없기 때문에, 특정 컨트롤러나 핸들러 정보에 의존하는 로직을 구현하기에는 적절하지 않습니다.

     

     

    3. DispatcherServlet

    • DispatcherServlet은 안내 데스크와 비슷역할로 손님이 오면 어느 게이트로 가야 할지 알려줍니다.
    • 위의 역할이 스프링 MVC의 프론트 컨트롤러입니다. 
    • 어떤 컨트롤러가 요청을 처리할지 찾기 위해 HandlerMapping을 사용합니다.
      • DispatcherServlet은 직접 "누가 처리해야 해"를 아는 게 아니라, HandlerMapping에게 물어보는 조정자입니다.
    • HandlerMapping이 알려준 정보를 받아서, 해당 컨트롤러를 호출할 준비를 합니다. (실제 호출은 HandlerAdapter가)

     

     

    4. HandlerMapping 

    • 스프링이 애플리케이션을 실행할 때, 모든 @RequestMapping 정보를 등록해둡니다.
    • 요청이 들어오면 이 매핑 정보를 검색해서 적절한 컨트롤러 메서드를 찾아냅니다.
      • /users 요청이 들어오면 HandlerMapping은 "UserController.getUsers()가 처리해"라고 DispatcherServlet에게 알려줍니다.

     

     

     

     

    5. HandlerAdapter 

    • HandlerMapping 후 찾은 HandlerMethod를 호출할 때 필요한 준비 작업(파라미터 바인딩, 리졸버 호출 등)을 수행하고 실제컨트롤러를 호출합니다.
    • 예를들어, 아래와 같이 id=1 쿼리 파라미터를 Long id로 변환해 넣어주는 작업이 HandlerAdapter 단계에서 이뤄집니다. 
    @GetMapping("/users")
    public String getUser(@RequestParam("id") Long id) {}

     

     

     

    6. Argument Resolution

    • 컨트롤러 메서드의 각 파라미터에 대해 등록된 여러 리졸버가 supportsParameter()로 처리 가능 여부를 판단한 뒤 resolveArgument()로 값을 생성/주입합니다.
    • 스프링은 다양한 ArgumentResolver를 제공합니다.
      • @RequestParam: 쿼리 파라미터를 파라미터로 매핑
      • @RequestHeader: 요청 헤더를 파라미터로 매핑
      • @PathVariable: URL 경로 변수를 파라미터로 매핑
      • @RequestBody: JSON 본문을 객체로 변환 후 주입
    • 제가 만든 @MemberId도 같은 원리입니다.

     

     

    7. 컨트롤러 실행 → 반환값 처리

    • 모든 파라미터가 준비되면 메서드가 실행되고, 반환값은 메시지 컨버터 또는 뷰 리졸버에 의해 응답으로 변환됩니다.
    • String을 리턴하면 뷰 리졸버가 html로 매핑합니다.
    • @ResponseBody나 ResponseEntity를 리턴하면 메시지 컨버터가 JSON 등으로 직렬화합니다.

     

     

     


     

     

    HandlerInterceptor와 ArgumentResolver의 역할

    • HandlerInterceptor
      • DispatcherServlet이 핸들러를 찾은 이후에 호출됩니다. preHandle, postHandle, afterCompletion 같은 메서드를 통해 핸들러 전후 로직을 넣을 수 있습니다.
      • 핸들러(컨트롤러 메서드) 정보가 필요하거나, 핸들러별로 다른 처리를 하고 싶을 때 유용합니다.
    • HandlerMethodArgumentResolver
      • 컨트롤러 메서드 호출 직전에 파라미터를 계산해 주입하는 전략입니다. supportsParameter()로 적용 대상을 판단하고, resolveArgument()에서 값을 만들어 반환합니다.
      • @RequestParam, @PathVariable, @RequestBody 등 스프링이 제공하는 많은 리졸버와 마찬가지로, 커스텀 애노테이션과 함께 사용하면 컨트롤러는 도메인 의미만 표현하고 추상화된 파라미터를 받도록 깔끔히 구성할 수 있습니다.

     

     

     

    @MemberId를 HandlerInterceptor대신 Filter에서 처리한 이유는?

     

     

    • 토큰 파싱 책임의 위치
      • @MemberId가 동작하려면 결국 요청 헤더에서 토큰을 꺼내고, 검증해서 memberId를 추출해야 합니다.
      • 이 과정은 컨트롤러에 진입하기 전 애플리케이션 전반에 공통 적용되어야 하는 로직입니다.
      • HandlerInterceptor나 ArgumentResolver에 넣으면, 결국 DispatcherServlet 이후 단계에서 실행되므로 이미 스프링 MVC 계층 안까지 들어와 버린 뒤에 차단하게 됩니다.
        → 그래서 토큰 파싱은 Filter에서 한 번만 처리하고, 그 결과를 요청 속성에 저장하는 것이 적절합니다.
    • 역할 분리
      • Filter에서는 토큰에서 MemberId를 뽑아내는 전처리 역할
      • ArgumentResolver은 컨트롤러 파라미터에 @MemberId를 주입하는 역할
      • 이렇게 분리하면 인증 로직과 컨트롤러 바인딩 로직이 섞이지 않고 깔끔합니다.
    • 재사용성과 일관성
      • 필터에서 request.setAttribute("memberId", id)를 두면 @MemberId뿐만 아니라 Interceptor, 다른 Resolver에서도 동일하게 활용할 수 있습니다.
      • 만약 ArgumentResolver에 토큰 파싱을 넣어버리면, @MemberId가 아닌 다른 상황에서는 같은 로직을 중복 구현해야 할 수 있습니다.
    • 성능향상
      • ArgumentResolver는 컨트롤러 파라미터 단위로 호출됩니다.
        • 예를 들어 @MemberId Long id1, @MemberId Long id2 처럼 파라미터가 여러 개면 Resolver 안에서 토큰 파싱을 하면 두 번 실행될 수 있습니다.
      • Filter에서 미리 파싱해놓으면 한 번만 수행 후 공유할 수 있습니다.

     


     

     

    적용한 구조

    저희는 다음과 같이 분담하고 구현했습니다.

     

    1. MemberIdFilter (OncePerRequestFilter)

    • HTTP 요청 헤더에서 memberId 값을 읽습니다.
    • 문자열을 Long으로 파싱한 뒤 request.setAttribute("memberId", memberId)으로 요청 스코프에 저장합니다.
    String memberIdHeader = request.getHeader("memberId");
    if (memberIdHeader != null) {
        try {
            long memberId = Long.parseLong(memberIdHeader);
            request.setAttribute("memberId", memberId);
        } catch (NumberFormatException e) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid user ID format in header");
            return;
        }
    }
    filterChain.doFilter(request, response);

     

     

    2. MemberIdArgumentResolver

    • 컨트롤러 메서드 파라미터에 @MemberId 애노테이션이 붙어 있고 타입이 Long일 경우 이를 처리합니다.
    • resolveArgument()에서 request.getAttribute("memberId")를 꺼내서 반환합니다. 
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(MemberId.class)
               && parameter.getParameterType().equals(Long.class);
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        if (request == null) {
            throw new TugoRuntimeException(ErrorCode.SERVER_ERROR);
        }
    
        return request.getAttribute("memberId");
    }

     

     

     

    3. WebConfig에서 리졸버 등록

    • addArgumentResolvers에 MemberIdArgumentResolver를 추가해서 스프링이 컨트롤러 호출 시 해당 리졸버를 사용하도록 등록했습니다.
    @Configuration
    @RequiredArgsConstructor
    public class WebConfig implements WebMvcConfigurer {
        private final MemberIdArgumentResolver memberIdArgumentResolver;
        @Override
        public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
            resolvers.add(memberIdArgumentResolver);
        }
    }

     

     

    4. 컨트롤러에서 사용

    @GetMapping("/profile")
    public ResponseEntity<MemberResponse> getProfile(@MemberId Long memberId) {
    }

     

     

     


     

     

    왜 이 구조가 유용한가? 

    1. 관심사의 분리
      • 헤더 파싱/검증/유효성 체크는 앞단계 레이어(필터/리졸버)가 맡고, 컨트롤러는 순수 비즈니스 로직에만 집중할 수 있습니다.
    2. 코드 중복 제거
      • 동일한 검증 로직이 중앙화되어 중복이 사라집니다.
    3. 유연성
      • 현재는 헤더에서 가져오지만, 추후 세션이나 JWT로 인증 방식을 바꿔야 할 경우 Resolver 내부(또는 필터)를 수정하면 컨트롤러 코드를 전혀 바꾸지 않아도 됩니다.
    4. 가독성과 협업 능력
      • @MemberId는 "이 파라미터는 현재 로그인한 사용자 ID다”라는 도메인 의미를 바로 전달합니다.
      • 다른 팀원도 빠르게 이해할 수 있습니다.

     


     

     

    다른 대안은 없을까?

    1. @RequestHeader("memberId")

    • 단순한 방법은 컨트롤러 메서드에서 @RequestHeader("memberId")를 직접 사용하는 것입니다.
    • 컨트롤러 곳곳에 "memberId"로 하드코딩 해야하고, 인증 방식이 바뀔 경우(예: 헤더에서 JWT 토큰으로) 전체 코드를 수정해야 하는 문제가 있습니다.

    2. @RequestAttribute("memberId")

    • @RequestHeader("memberId")보다 나은 방식이긴 하나, 코드만 봐서는 이 값이 "로그인 사용자 id"라는 의미를 알기 어렵습니다. 

    → 따라서 @MemberId 애노테이션과 HandlerMethodArgumentResolver를 사용하는 방식이 가장 효율적

    • 컨트롤러 입장에서는 @MemberId Long id라는 선언만으로 로그인 사용자의 id임을 명확히 드러낼 수 있고, 파싱·검증 로직은 모두 인프라 레벨에서 처리됩니다.
    • 추후 인증 체계가 바뀌더라도 리졸버나 필터만 수정하면 되고, 컨트롤러는 그대로 둘 수 있다는 점이 큰 장점입니다.

     

     


     

     

    결론

    저희 팀은 @MemberId + ArgumentResolver 패턴을 도입하여 컨트롤러의 의도를 명확히 하고, 인증/파싱 로직을 인프라 레이어(필터/리졸브)에 모아 유지보수성과 확장성을 높였습니다.


    구체적으로는 필터에서 헤더를 읽어 request 어트리뷰트로 저장하고, ArgumentResolver가 해당 어트리뷰트를 읽어 컨트롤러 파라미터로 주입하는 구조를 사용했습니다. 이 방식은 현재의 요구를 깔끔히 해결하면서도, 인증 방식이 바뀌어도 컨트롤러 수정 없이 대응할 수 있다는 장점이 있습니다.

     

     

     

     

     

Designed by Tistory.