본문 바로가기

인터셉터와 인자 해석

선택된 핸들러의 로그인 정책을 인터셉터가 판정하고, 검증된 세션 주체를 전용 인자 해석기가 타입 안전한 컨트롤러 파라미터로 전달하는 경계를 실행합니다.

서블릿 필터가 원시 요청 전체를 본다면, HandlerInterceptor는 Spring MVC가 선택한 핸들러를 알고 실행됩니다. HandlerMethodArgumentResolver는 그보다 더 좁게, 컨트롤러의 파라미터 하나를 만듭니다. 세 도구에 인증 로직을 복제하지 않습니다. 필터가 만든 공통 요청 문맥은 그대로 쓰고, 인터셉터가 핸들러의 정책을 판정하며, 리졸버는 이미 검증된 주체를 정확한 Java 타입으로 바꾸기만 합니다.

인증 정보는 네 경계를 지나며 한 번만 좁아진다

filter·handler 로그인 정책·검증 주체 attribute·exact resolver·회원 41 query 1회로 신원을 좁힌다.

추적 Filter는 공통 요청 ID만 게시합니다. 선택된 HandlerMethod의 LoginRequired 정책을 LoginInterceptor가 검사합니다. 성공한 nested AuthenticatedMember를 request attribute에 저장하고 CurrentMember 애노테이션과 exact type을 모두 확인하는 resolver가 controller에 전달합니다. 실제 controller는 회원 41의 query를 한 번 호출하며 command와 소유권 transaction은 이 fixture의 구현이 아닙니다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

로그인 정책은 인터셉터가, typed 인자는 리졸버가 맡는다

선택된 핸들러의 로그인 요구를 한 번 검사합니다. 성공한 세션 주체만 request attribute에 게시하고, 애노테이션과 정확한 타입을 확인한 리졸버가 query controller에 전달합니다.

로그인 정책은 인터셉터가, typed 인자는 리졸버가 맡는다추적 Filter는 공통 요청 ID만 게시합니다. 선택된 HandlerMethod의 LoginRequired 정책을 LoginInterceptor가 검사합니다. 성공한 nested AuthenticatedMember를 request attribute에 저장하고 CurrentMember 애노테이션과 exact type을 모두 확인하는 resolver가 controller에 전달합니다. 실제 controller는 회원 41의 query를 한 번 호출하며 command와 소유권 transaction은 이 fixture의 구현이 아닙니다.
추적 FilterrequestId 공통 문맥
연결 관계추적 Filter · requestId 공통 문맥 → 선택된 HandlerMethod · @LoginRequired?
선택된 HandlerMethod@LoginRequired?
연결 관계선택된 HandlerMethod · @LoginRequired? → LoginInterceptor · typed 세션 주체?
LoginInterceptortyped 세션 주체?
연결 관계LoginInterceptor · typed 세션 주체? → request attribute · 같은 member를 게시
request attribute같은 member를 게시
연결 관계request attribute · 같은 member를 게시 → CurrentMember · resolver · annotation + exact type
CurrentMember resolverannotation + exact type
연결 관계CurrentMember · resolver · annotation + exact type → query controller · 회원 41 → query 1회
query controller회원 41 → query 1회
로그인 성공 시 주체 게시에서 typed 인자와 조회까지기존 데스크톱 여섯 노드와 다섯 관계를 세로 배치한다. 추적 Filter 뒤 선택된 핸들러의 LoginRequired를 인터셉터가 판정하고, 인증 성공 경로에서만 정확한 nested 회원을 request에 게시한다. CurrentMember와 exacttype을 모두 확인한 resolver가 회원41을 controller에 전달해 query가1회 호출된다. 익명 차단과 별도transaction 소유권은 supporting본문에서 따로 다룬다.인증 성공추적 FilterrequestId 문맥선택된 핸들러@LoginRequired?LoginInterceptortyped 세션 주체?request attribute같은 member 게시CurrentMember애노테이션 · 정확 타입query controller회원 41 · query 1회

ANONYMOUS API / QUERY 0

401 본문을 완성하고 false로 중단

/api 또는 /api/ URI의 익명 보호 handler는 authentication-required ProblemDetail 401입니다. 실제 MVC test는 content type·고정 type/title/detail·추적 header와 query 호출 0회를 확인합니다.

ANONYMOUS HTML / QUERY 0

서버 URI만 login next로 보낸다

실제 /ch8/current-member/posts?token=do-not-copy는 /login?next=%2Fch8%2Fcurrent-member%2Fposts redirect입니다. query는 제외합니다. 로그인 후 next 소비 시 내부 경로 재검증은 별도 구현이며 URI만 사용한다고 모든 배포 경로 검사가 완성되지는 않습니다.

ANNOTATION AND EXACT TYPE

@CurrentMember + nested record만 지원

SessionMemberReader.AuthenticatedMember에 애노테이션도 있어야 합니다. 같은 타입의 무애노테이션 인자와 annotated String은 가로채지 않습니다. 세션을 다시 읽지 않고 게시된 typed 속성이 없으면 전용 예외입니다.

AUTHENTICATED MVC / QUERY 1

회원 41이 정확한 controller 인자가 된다

세션의 nested record 41이 request attribute와 resolver를 거쳐 query.summaryFor(41)에 한 번 전달됩니다. 응답은 200, member=41입니다. 실제 controller는 조회 포트를 호출하며 command 조립 예제는 아닙니다.

MVC EXTENSION REGISTRATION

기본 resolver 목록에 custom 하나를 추가

AuthWebMvcConfiguration이 interceptor와 resolver를 등록합니다. 직접 호출한 configuration unit test는 custom 목록에 정확히 한 개 추가됨을 확인합니다. 기본 resolver 목록 전체의 위치·개수까지 이 unit test가 확인하지는 않습니다.

ADDITIONAL APPLICATION POLICY

변경 소유권과 transaction은 별도 설계

연습 UpdatePostService는 required→requireOwner→rename→save를 보여 줍니다. 실제 MVC 조회 fixture에 이 service나 transaction이 연결되어 있지 않습니다. 변동 리소스의 권한과 상태를 같은 변경 transaction에서 검증하는 정책은 별도로 적용합니다.

추적 Filter는 공통 요청 ID만 게시합니다. 선택된 HandlerMethod의 LoginRequired 정책을 LoginInterceptor가 검사합니다. 성공한 nested AuthenticatedMember를 request attribute에 저장하고 CurrentMember 애노테이션과 exact type을 모두 확인하는 resolver가 controller에 전달합니다. 실제 controller는 회원 41의 query를 한 번 호출하며 command와 소유권 transaction은 이 fixture의 구현이 아닙니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 추적 Filter는 공통 요청 ID만 게시합니다. 선택된 HandlerMethod의 LoginRequired 정책을 LoginInterceptor가 검사합니다. 성공한 nested AuthenticatedMember를 request attribute에 저장하고 CurrentMember 애노테이션과 exact type을 모두 확인하는 resolver가 controller에 전달합니다. 실제 controller는 회원 41의 query를 한 번 호출하며 command와 소유권 transaction은 이 fixture의 구현이 아닙니다.
  • 기존 데스크톱 여섯 노드와 다섯 관계를 세로로 배치한다. 추적 Filter 뒤 선택된 핸들러의 LoginRequired를 인터셉터가 판정하고, 인증 성공 경로에서만 정확한 nested 회원을 request에 게시한다. CurrentMember와 exact type을 모두 확인한 resolver가 회원 41을 controller에 전달해 query가 1회 호출된다. 익명 차단과 별도 transaction의 소유권은 보조 본문에서 따로 다룬다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1인증 정보는 네 경계를 지나며 한 번만 좁아진다
20기존 데스크톱 여섯 노드와 다섯 관계를 세로로 배치한다. 추적 Filter 뒤 선택된 핸들러의 LoginRequired를 인터셉터가 판정하고, 인증 성공 경로에서만 정확한 nested 회원을 request에 게시한다. CurrentMember와 exact type을 모두 확인한 resolver가 회원 41을 controller에 전달해 query가 1회 호출된다. 익명 차단과 별도 transaction의 소유권은 보조 본문에서 따로 다룬다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
추적 ID 게시

Filter가 공통 요청 ID를 게시하고 선택된 HandlerMethod로 진행합니다.

Filter: 요청 ID · 정책 대상: HandlerMethod같은 요청의 추적 정보가 준비됩니다.
정책과 회원 검증

LoginInterceptor가 LoginRequired 정책을 검사하고 성공한 nested AuthenticatedMember를 attribute에 저장합니다.

정책: LoginRequired · attribute: AuthenticatedMember검증된 회원 정보가 요청 내부에 생깁니다.
정확한 resolver 적용

resolver가 CurrentMember 애노테이션과 exact type을 함께 확인해 controller로 전달합니다.

애노테이션: CurrentMember · 타입: exact match · 입력: 검증된 attributecontroller가 정책을 통과한 회원을 받습니다.
조회 한 번

실제 controller가 회원 41의 query를 한 번 호출합니다.

회원: 41 · query 호출: 1 · command·소유권 TX: fixture 범위 밖검증된 회원으로 실제 조회가 완료됩니다.

인증 정보는 네 경계를 지나며 한 번만 좁아진다

filter·handler 로그인 정책·검증 주체 attribute·exact resolver·회원 41 query 1회로 신원을 좁힌다.

추적 Filter는 공통 요청 ID만 게시합니다. 선택된 HandlerMethod의 LoginRequired 정책을 LoginInterceptor가 검사합니다. 성공한 nested AuthenticatedMember를 request attribute에 저장하고 CurrentMember 애노테이션과 exact type을 모두 확인하는 resolver가 controller에 전달합니다. 실제 controller는 회원 41의 query를 한 번 호출하며 command와 소유권 transaction은 이 fixture의 구현이 아닙니다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

로그인 정책은 인터셉터가, typed 인자는 리졸버가 맡는다

선택된 핸들러의 로그인 요구를 한 번 검사합니다. 성공한 세션 주체만 request attribute에 게시하고, 애노테이션과 정확한 타입을 확인한 리졸버가 query controller에 전달합니다.

로그인 정책은 인터셉터가, typed 인자는 리졸버가 맡는다추적 Filter는 공통 요청 ID만 게시합니다. 선택된 HandlerMethod의 LoginRequired 정책을 LoginInterceptor가 검사합니다. 성공한 nested AuthenticatedMember를 request attribute에 저장하고 CurrentMember 애노테이션과 exact type을 모두 확인하는 resolver가 controller에 전달합니다. 실제 controller는 회원 41의 query를 한 번 호출하며 command와 소유권 transaction은 이 fixture의 구현이 아닙니다.
추적 FilterrequestId 공통 문맥
연결 관계추적 Filter · requestId 공통 문맥 → 선택된 HandlerMethod · @LoginRequired?
선택된 HandlerMethod@LoginRequired?
연결 관계선택된 HandlerMethod · @LoginRequired? → LoginInterceptor · typed 세션 주체?
LoginInterceptortyped 세션 주체?
연결 관계LoginInterceptor · typed 세션 주체? → request attribute · 같은 member를 게시
request attribute같은 member를 게시
연결 관계request attribute · 같은 member를 게시 → CurrentMember · resolver · annotation + exact type
CurrentMember resolverannotation + exact type
연결 관계CurrentMember · resolver · annotation + exact type → query controller · 회원 41 → query 1회
query controller회원 41 → query 1회
로그인 성공 시 주체 게시에서 typed 인자와 조회까지기존 데스크톱 여섯 노드와 다섯 관계를 세로 배치한다. 추적 Filter 뒤 선택된 핸들러의 LoginRequired를 인터셉터가 판정하고, 인증 성공 경로에서만 정확한 nested 회원을 request에 게시한다. CurrentMember와 exacttype을 모두 확인한 resolver가 회원41을 controller에 전달해 query가1회 호출된다. 익명 차단과 별도transaction 소유권은 supporting본문에서 따로 다룬다.인증 성공추적 FilterrequestId 문맥선택된 핸들러@LoginRequired?LoginInterceptortyped 세션 주체?request attribute같은 member 게시CurrentMember애노테이션 · 정확 타입query controller회원 41 · query 1회

ANONYMOUS API / QUERY 0

401 본문을 완성하고 false로 중단

/api 또는 /api/ URI의 익명 보호 handler는 authentication-required ProblemDetail 401입니다. 실제 MVC test는 content type·고정 type/title/detail·추적 header와 query 호출 0회를 확인합니다.

ANONYMOUS HTML / QUERY 0

서버 URI만 login next로 보낸다

실제 /ch8/current-member/posts?token=do-not-copy는 /login?next=%2Fch8%2Fcurrent-member%2Fposts redirect입니다. query는 제외합니다. 로그인 후 next 소비 시 내부 경로 재검증은 별도 구현이며 URI만 사용한다고 모든 배포 경로 검사가 완성되지는 않습니다.

ANNOTATION AND EXACT TYPE

@CurrentMember + nested record만 지원

SessionMemberReader.AuthenticatedMember에 애노테이션도 있어야 합니다. 같은 타입의 무애노테이션 인자와 annotated String은 가로채지 않습니다. 세션을 다시 읽지 않고 게시된 typed 속성이 없으면 전용 예외입니다.

AUTHENTICATED MVC / QUERY 1

회원 41이 정확한 controller 인자가 된다

세션의 nested record 41이 request attribute와 resolver를 거쳐 query.summaryFor(41)에 한 번 전달됩니다. 응답은 200, member=41입니다. 실제 controller는 조회 포트를 호출하며 command 조립 예제는 아닙니다.

MVC EXTENSION REGISTRATION

기본 resolver 목록에 custom 하나를 추가

AuthWebMvcConfiguration이 interceptor와 resolver를 등록합니다. 직접 호출한 configuration unit test는 custom 목록에 정확히 한 개 추가됨을 확인합니다. 기본 resolver 목록 전체의 위치·개수까지 이 unit test가 확인하지는 않습니다.

ADDITIONAL APPLICATION POLICY

변경 소유권과 transaction은 별도 설계

연습 UpdatePostService는 required→requireOwner→rename→save를 보여 줍니다. 실제 MVC 조회 fixture에 이 service나 transaction이 연결되어 있지 않습니다. 변동 리소스의 권한과 상태를 같은 변경 transaction에서 검증하는 정책은 별도로 적용합니다.

추적 Filter는 공통 요청 ID만 게시합니다. 선택된 HandlerMethod의 LoginRequired 정책을 LoginInterceptor가 검사합니다. 성공한 nested AuthenticatedMember를 request attribute에 저장하고 CurrentMember 애노테이션과 exact type을 모두 확인하는 resolver가 controller에 전달합니다. 실제 controller는 회원 41의 query를 한 번 호출하며 command와 소유권 transaction은 이 fixture의 구현이 아닙니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 추적 Filter는 공통 요청 ID만 게시합니다. 선택된 HandlerMethod의 LoginRequired 정책을 LoginInterceptor가 검사합니다. 성공한 nested AuthenticatedMember를 request attribute에 저장하고 CurrentMember 애노테이션과 exact type을 모두 확인하는 resolver가 controller에 전달합니다. 실제 controller는 회원 41의 query를 한 번 호출하며 command와 소유권 transaction은 이 fixture의 구현이 아닙니다.
  • 기존 데스크톱 여섯 노드와 다섯 관계를 세로로 배치한다. 추적 Filter 뒤 선택된 핸들러의 LoginRequired를 인터셉터가 판정하고, 인증 성공 경로에서만 정확한 nested 회원을 request에 게시한다. CurrentMember와 exact type을 모두 확인한 resolver가 회원 41을 controller에 전달해 query가 1회 호출된다. 익명 차단과 별도 transaction의 소유권은 보조 본문에서 따로 다룬다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1인증 정보는 네 경계를 지나며 한 번만 좁아진다
20기존 데스크톱 여섯 노드와 다섯 관계를 세로로 배치한다. 추적 Filter 뒤 선택된 핸들러의 LoginRequired를 인터셉터가 판정하고, 인증 성공 경로에서만 정확한 nested 회원을 request에 게시한다. CurrentMember와 exact type을 모두 확인한 resolver가 회원 41을 controller에 전달해 query가 1회 호출된다. 익명 차단과 별도 transaction의 소유권은 보조 본문에서 따로 다룬다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
추적 ID 게시

Filter가 공통 요청 ID를 게시하고 선택된 HandlerMethod로 진행합니다.

Filter: 요청 ID · 정책 대상: HandlerMethod같은 요청의 추적 정보가 준비됩니다.
정책과 회원 검증

LoginInterceptor가 LoginRequired 정책을 검사하고 성공한 nested AuthenticatedMember를 attribute에 저장합니다.

정책: LoginRequired · attribute: AuthenticatedMember검증된 회원 정보가 요청 내부에 생깁니다.
정확한 resolver 적용

resolver가 CurrentMember 애노테이션과 exact type을 함께 확인해 controller로 전달합니다.

애노테이션: CurrentMember · 타입: exact match · 입력: 검증된 attributecontroller가 정책을 통과한 회원을 받습니다.
조회 한 번

실제 controller가 회원 41의 query를 한 번 호출합니다.

회원: 41 · query 호출: 1 · command·소유권 TX: fixture 범위 밖검증된 회원으로 실제 조회가 완료됩니다.

그림은 추적 필터가 요청 ID를 게시한 뒤, 선택된 핸들러에 @LoginRequired가 있으면 LoginInterceptor가 타입이 있는 세션 주체를 확인하고, 인증에 성공했을 때만 같은 회원을 요청 속성에 게시하는 흐름을 보여 줍니다. 이어서 @CurrentMember와 정확한 타입을 모두 확인한 리졸버가 회원 41을 컨트롤러에 넘기고, 컨트롤러는 조회 포트를 한 번 호출합니다. 아래 칸들은 익명 API와 익명 HTML의 차단, 리졸버의 지원 조건, MVC 확장 등록, 그리고 이 예제가 구현하지 않은 변경 소유권과 트랜잭션을 따로 설명합니다.


바깥에 공개하는 것은 어노테이션과 주체 타입 두 가지입니다

@CurrentMember는 다른 장의 컨트롤러도 import하는 공개 애노테이션입니다. 자체 파일의 public @interface로 두고, 파라미터에만 붙일 수 있게 범위를 좁힙니다.

src/main/java/board/web/auth/CurrentMember.java
package board.web.auth;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

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

로그인 요구는 핸들러 메서드나 컨트롤러 타입에 명시합니다. 애노테이션이 직접 검사를 하는 것은 아니며, 뒤의 MVC 구성이 인터셉터를 등록해야 효과가 생깁니다.

src/main/java/board/web/auth/LoginRequired.java
package board.web.auth;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
}

주체 타입은 HTTP 세션 수명 주기에서 만든 SessionMemberReader.AuthenticatedMember 하나만 씁니다. 이름이 같은 최상위 타입을 다시 만들면, 세션에 저장된 객체와 리졸버가 요구하는 객체가 겉보기만 같고 실제로는 다른 클래스가 됩니다.


인터셉터는 선택된 핸들러에서 정책을 판정합니다

정적 리소스처럼 HandlerMethod가 아닌 핸들러는 통과시킵니다. 로그인을 요구하는 핸들러에서는 세션 리더의 판정만 쓰고, 인증 주체가 있으면 리더가 공개한 같은 키로 요청 속성에 게시합니다.

src/main/java/board/web/auth/LoginInterceptor.java
package board.web.auth;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.http.MediaType;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

public final class LoginInterceptor implements HandlerInterceptor {
    private static final String AUTHENTICATION_REQUIRED_PROBLEM = """
            {"type":"https://board.example/problems/authentication-required",
             "title":"인증 필요","status":401,
             "detail":"로그인이 필요합니다.",
             "code":"authentication-required"}
            """;
    private final SessionMemberReader sessions;

    public LoginInterceptor(SessionMemberReader sessions) {
        this.sessions = sessions;
    }

    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler
    ) throws Exception {
        if (!(handler instanceof HandlerMethod method)
                || !requiresLogin(method)) {
            return true;
        }

        var member = sessions.read(request);
        if (member.isPresent()) {
            request.setAttribute(
                    SessionMemberReader.AUTHENTICATED_MEMBER_ATTRIBUTE,
                    member.get());
            return true;
        }

        String requestUri = request.getRequestURI();
        if (requestUri.equals("/api")
                || requestUri.startsWith("/api/")) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType(
                    MediaType.APPLICATION_PROBLEM_JSON_VALUE);
            response.setCharacterEncoding(
                    StandardCharsets.UTF_8.name());
            response.getWriter().write(
                    AUTHENTICATION_REQUIRED_PROBLEM);
        } else {
            response.sendRedirect("/login?next=" + URLEncoder.encode(
                    safeDestination(request), StandardCharsets.UTF_8));
        }
        return false;
    }

    private boolean requiresLogin(HandlerMethod method) {
        return AnnotatedElementUtils.hasAnnotation(
                        method.getMethod(), LoginRequired.class)
                || AnnotatedElementUtils.hasAnnotation(
                        method.getBeanType(), LoginRequired.class);
    }

    private String safeDestination(HttpServletRequest request) {
        return request.getRequestURI();
    }
}

익명 API 요청은 고정된 application/problem+json 401로 끝내고, 익명 HTML 요청만 로그인 화면으로 보냅니다. 문제 본문에는 사용자 입력을 넣지 않으며, 추적 ID는 앞의 필터가 응답 헤더에 게시합니다.

MVC 예제의 요청은 루트 컨텍스트를 전제로 합니다. /academy 같은 컨텍스트 경로를 붙이면 getRequestURI()가 /academy/api/...가 되어, 지금의 /api/ 판정과 /login 목적지가 같은 배포 규칙을 따르지 않습니다. 테스트를 먼저 루트 조건에서 확인한 뒤, 컨텍스트가 필요한 배포에서는 API 분류와 내부 목적지, 리다이렉트 경로를 함께 고친 별도의 변형으로 검증합니다.

next는 서버가 본 내부 URI로 만들고 쿼리는 모두 뺍니다. 로그인을 마친 뒤에도 /로 시작하는지, //나 스킴, 역슬래시, 제어 문자가 없는지 다시 검증해야 합니다.

preHandle이 false를 돌려주면 컨트롤러가 실행되지 않으므로, 인터셉터가 리다이렉트 응답을 완성해야 합니다. postHandle은 성공한 핸들러 뒤, 뷰를 렌더링하기 전에 실행되고, afterCompletion은 MVC 처리가 끝난 뒤의 정리에 알맞습니다.


리졸버는 정책을 되풀이하지 않고 타입만 좁힙니다

리졸버는 애노테이션과 파라미터 타입이 모두 맞을 때만 나섭니다. 세션을 다시 읽지 않고, 인터셉터가 요청에 게시한 값을 씁니다.

src/main/java/board/web/auth/CurrentMemberMissingException.java
package board.web.auth;

public final class CurrentMemberMissingException
        extends IllegalStateException {
    public CurrentMemberMissingException() {
        super("verified current member is missing");
    }
}
src/main/java/board/web/auth/CurrentMemberArgumentResolver.java
package board.web.auth;

import jakarta.servlet.http.HttpServletRequest;

import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import board.web.auth.SessionMemberReader.AuthenticatedMember;

public final class CurrentMemberArgumentResolver
        implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentMember.class)
                && parameter.getParameterType()
                        .equals(AuthenticatedMember.class);
    }

    @Override
    public Object resolveArgument(
            MethodParameter parameter,
            ModelAndViewContainer container,
            NativeWebRequest webRequest,
            WebDataBinderFactory binderFactory
    ) {
        HttpServletRequest request = webRequest.getNativeRequest(
                HttpServletRequest.class);
        if (request == null) {
            throw new CurrentMemberMissingException();
        }

        Object value = request.getAttribute(
                SessionMemberReader.AUTHENTICATED_MEMBER_ATTRIBUTE);
        if (value instanceof AuthenticatedMember member) {
            return member;
        }
        throw new CurrentMemberMissingException();
    }
}

@CurrentMember는 어떤 인자를 고를지 정하는 애노테이션이고, @LoginRequired는 그보다 앞의 로그인 판정을 정하는 애노테이션입니다. 앞의 것은 세션을 읽거나 뒤의 것을 대신하지 않습니다. 인증 세션이 있어도 핸들러에 로그인 요구를 빠뜨리면 이 인터셉터가 주체를 요청에 게시하지 않으므로, 깨끗한 요청에서는 리졸버가 찾는 속성이 없을 수 있습니다. 두 애노테이션과 MVC 등록을 함께 확인해야 하는 이유입니다.

파라미터 타입만 보고 모든 AuthenticatedMember를 가로채지 않습니다. 애노테이션이 없는 같은 타입과, 애노테이션이 붙은 다른 타입은 기본 리졸버 체인이 판단하게 둡니다. 공개 엔드포인트에서 회원이 선택적으로 필요하다면 별도의 규칙을 설계하고, 여기서 슬그머니 null을 돌려주지 않습니다.

더 깊이: 사용자 리졸버는 목록의 어디에 들어갈까

addArgumentResolvers로 더한 리졸버는 기본 리졸버 목록의 맨 앞이 아니라 중간에 들어갑니다. Spring Framework 7.0.10의 RequestMappingHandlerAdapter는 먼저 @RequestParam, @PathVariable, @RequestBody 같은 애노테이션 기반 리졸버를 두고, 그다음 HttpServletRequest, Model 같은 타입 기반 리졸버를 둔 뒤 사용자 리졸버를 붙입니다. 맨 끝에는 어디에도 맞지 않는 파라미터를 받아 주는 리졸버들이 옵니다. 그래서 사용자 리졸버로는 @RequestParam이 붙은 파라미터의 처리를 바꿀 수 없고, WebMvcConfigurer의 API 문서도 기본 해석을 바꾸려면 RequestMappingHandlerAdapter를 직접 구성하라고 안내합니다.

맨 끝의 리졸버들은 보안 면에서도 알아 둘 만합니다. 그중 하나는 단순 값 타입이 아닌 파라미터를 애노테이션이 없어도 @ModelAttribute로 보고, 요청 파라미터로 생성자를 채워 객체를 만듭니다. 만약 컨트롤러가 @CurrentMember를 빠뜨린 채 AuthenticatedMember member만 선언하면, 이 파라미터는 사용자 리졸버를 지나쳐 모델 속성이 되고 ?id=1 같은 쿼리 파라미터가 레코드의 생성자 인자가 될 수 있습니다. 세션 주체 타입을 요구하는 파라미터에 애노테이션이 빠졌는지를 테스트로 확인해야 하는 이유입니다.

또 supportsParameter의 결과는 요청마다 다시 계산되지 않습니다. 리졸버 목록을 감싼 HandlerMethodArgumentResolverComposite는 파라미터마다 처음 맞은 리졸버를 캐시에 넣어 두고 다음 요청부터 바로 씁니다. 그러니 supportsParameter는 지금처럼 애노테이션과 타입만 보고 판단해야 하며, 세션이나 요청 상태에 따라 답을 바꾸면 첫 요청의 결과가 굳어 버립니다.


실제 MVC 구성은 한곳에서 두 확장 지점을 연결합니다

구성은 인터셉터와 리졸버를 같은 SessionMemberReader에 연결합니다. addArgumentResolvers는 기본 목록을 바꾸지 않고 사용자 리졸버를 더하므로, Spring MVC의 표준 파라미터 해석은 그대로 유지됩니다.

src/main/java/board/web/auth/AuthWebMvcConfiguration.java
package board.web.auth;

import java.util.List;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration(proxyBeanMethods = false)
public class AuthWebMvcConfiguration implements WebMvcConfigurer {
    private final LoginInterceptor loginInterceptor;
    private final CurrentMemberArgumentResolver currentMemberResolver;

    public AuthWebMvcConfiguration() {
        var sessions = new SessionMemberReader();
        this.loginInterceptor = new LoginInterceptor(sessions);
        this.currentMemberResolver =
                new CurrentMemberArgumentResolver();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor);
    }

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

예제 컨트롤러는 HTTP의 조립만 맡습니다. 인증 주체의 ID와 조회 포트를 결합하되, 세션 키나 형 변환은 알지 않습니다.

src/main/java/board/web/auth/MemberPostQuery.java
package board.web.auth;

public interface MemberPostQuery {
    String summaryFor(long memberId);
}
src/main/java/board/web/auth/CurrentMemberController.java
package board.web.auth;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import board.web.auth.SessionMemberReader.AuthenticatedMember;

@RestController
@LoginRequired
public final class CurrentMemberController {
    private final MemberPostQuery query;

    public CurrentMemberController(MemberPostQuery query) {
        this.query = query;
    }

    @GetMapping("/api/ch8/current-member/posts")
    String mine(@CurrentMember AuthenticatedMember member) {
        return query.summaryFor(member.id());
    }
}

통합 테스트는 차단과 도달을 함께 고정합니다

첫 테스트는 익명 API 요청이 401 ProblemDetail로 끝나고 조회 포트가 한 번도 호출되지 않았음을 확인합니다. 둘째 테스트는 HTML 경로만 쿼리를 뺀 내부 next로 302가 되는 것을 고정합니다. 셋째 테스트는 세션의 정확한 중첩 레코드가 요청 속성을 거쳐 파라미터가 되고, 컨트롤러가 회원 ID 41을 조회 포트에 정확히 한 번 넘겼음을 확인합니다.

src/test/java/board/web/auth/CurrentMemberMvcTest.java
package board.web.auth;

import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;

import board.web.RequestTraceFilter;
import board.web.auth.SessionMemberReader.AuthenticatedMember;

@WebMvcTest(CurrentMemberController.class)
@Import({AuthWebMvcConfiguration.class, HtmlLoginProbeController.class})
class CurrentMemberMvcTest {
    @Autowired
    MockMvc mvc;

    @MockitoBean
    MemberPostQuery query;

    @Test
    void 익명_API는_401_problem에서_controller_전에_중단된다()
            throws Exception {
        mvc.perform(get("/api/ch8/current-member/posts")
                        .header(RequestTraceFilter.REQUEST_ID_HEADER,
                                "auth-api-01"))
                .andExpect(status().isUnauthorized())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(header().string(
                        RequestTraceFilter.REQUEST_ID_HEADER,
                        "auth-api-01"))
                .andExpect(jsonPath("$.type").value(
                        "https://board.example/problems/"
                                + "authentication-required"))
                .andExpect(jsonPath("$.title").value("인증 필요"))
                .andExpect(jsonPath("$.status").value(401))
                .andExpect(jsonPath("$.detail")
                        .value("로그인이 필요합니다."))
                .andExpect(jsonPath("$.code")
                        .value("authentication-required"));

        verifyNoInteractions(query);
    }

    @Test
    void 익명_HTML만_query를_제외한_login_next로_redirect한다()
            throws Exception {
        mvc.perform(get(
                        "/ch8/current-member/posts?token=do-not-copy"))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl(
                        "/login?next=%2Fch8%2Fcurrent-member%2Fposts"));

        verifyNoInteractions(query);
    }

    @Test
    void 인증_요청은_exact_member_argument로_controller에_도달한다()
            throws Exception {
        var session = new MockHttpSession();
        session.setAttribute(
                SessionMemberReader.AUTHENTICATED_MEMBER_ATTRIBUTE,
                new AuthenticatedMember(41L));
        org.mockito.Mockito.when(query.summaryFor(41L))
                .thenReturn("member=41");

        mvc.perform(get("/api/ch8/current-member/posts")
                        .session(session))
                .andExpect(status().isOk())
                .andExpect(content().string("member=41"));

        verify(query).summaryFor(41L);
    }
}

@Controller
@LoginRequired
class HtmlLoginProbeController {
    @GetMapping("/ch8/current-member/posts")
    String posts() {
        return "posts/member-list";
    }
}

리졸버의 선택 조건과 구성 목록의 위치도 고정합니다. 이 테스트가 실패한다면, 애노테이션만 붙은 String이나 애노테이션 없는 회원 타입을 새 리졸버가 실수로 가로채고 있다는 뜻입니다.

src/test/java/board/web/auth/CurrentMemberResolverContractTest.java
package board.web.auth;

import static org.assertj.core.api.Assertions.assertThat;

import java.lang.reflect.Method;
import java.util.ArrayList;

import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;

import board.web.auth.SessionMemberReader.AuthenticatedMember;

class CurrentMemberResolverContractTest {
    private final CurrentMemberArgumentResolver resolver =
            new CurrentMemberArgumentResolver();

    @Test
    void annotation과_exact_type이_모두_맞을_때만_지원한다()
            throws Exception {
        assertThat(resolver.supportsParameter(
                parameter("annotatedMember"))).isTrue();
        assertThat(resolver.supportsParameter(
                parameter("plainMember"))).isFalse();
        assertThat(resolver.supportsParameter(
                parameter("annotatedText"))).isFalse();
    }

    @Test
    void mvc_configuration은_custom_resolver를_한_번_추가한다() {
        var configuration = new AuthWebMvcConfiguration();
        var resolvers = new ArrayList<HandlerMethodArgumentResolver>();

        configuration.addArgumentResolvers(resolvers);

        assertThat(resolvers)
                .hasSize(1)
                .first()
                .isInstanceOf(CurrentMemberArgumentResolver.class);
    }

    private MethodParameter parameter(String methodName)
            throws NoSuchMethodException {
        Method method = Signatures.class.getDeclaredMethod(
                methodName,
                methodName.equals("annotatedText")
                        ? String.class
                        : AuthenticatedMember.class);
        return new MethodParameter(method, 0);
    }

    static final class Signatures {
        void annotatedMember(
                @CurrentMember AuthenticatedMember member
        ) {
        }

        void plainMember(AuthenticatedMember member) {
        }

        void annotatedText(@CurrentMember String value) {
        }
    }
}
MVC 인증 연결 exact oracle
anonymous GET /api/ch8/current-member/posts -> 401 application/problem+json
authentication code = authentication-required, X-Request-Id = auth-api-01
anonymous GET /ch8/current-member/posts?token=do-not-copy
-> 302 /login?next=%2Fch8%2Fcurrent-member%2Fposts
anonymous controller/query calls = 0 for both paths
member 41 GET /api/ch8/current-member/posts -> 200
resolved argument type = SessionMemberReader.AuthenticatedMember
query.summaryFor(41) calls = 1
custom resolver registrations = 1

확장 지점은 필요한 정보를 가장 먼저 아는 곳으로 고릅니다

도구가장 먼저 아는 정보이 장에서 맡는 일
필터원시 서블릿 요청요청 ID, 보안 헤더, 본문 래퍼
인터셉터선택된 HandlerMethod로그인 요구 애노테이션, 핸들러 도달 차단
인자 리졸버메서드 파라미터검증된 요청 속성의 타입 안전한 변환
컨트롤러HTTP 입력과 검증된 주체애플리케이션 명령의 조립

같은 요청 ID를 세 계층에서 각각 만들지 않습니다. 앞 글의 필터가 requestId를 만들면 뒤의 계층은 그 속성을 읽습니다. 반대로 핸들러의 애노테이션은 필터가 알기 어려우므로 인터셉터가 맡습니다.

Spring Security를 도입하면 인증과 인가는 보안 필터 체인과 메서드 보안에 맡기고, 사용자 정의 인터셉터는 기록이나 표현 정책으로 제한합니다. 두 인증 체계가 서로 겨루는 호환 계층을 만들지 말고, 표준 보안 컨텍스트로 한 번에 옮깁니다.


정리

  • 필터는 원시 요청을, 인터셉터는 선택된 핸들러를, 인자 리졸버는 메서드 파라미터를 먼저 알므로 각자 그에 맞는 일만 맡습니다.
  • 인터셉터는 @LoginRequired가 있는 핸들러에서만 로그인을 판정하고, 익명이면 API는 401, HTML은 쿼리를 뺀 next로 리다이렉트하며 false를 돌려줍니다.
  • 인증에 성공한 주체만 요청 속성에 게시하고, 리졸버는 세션을 다시 읽지 않고 그 속성을 정확한 타입으로 넘깁니다.
  • 리졸버는 @CurrentMember와 정확한 주체 타입이 모두 맞을 때만 나서며, 로그인 요구를 빠뜨리면 주체가 없어 전용 예외가 납니다.
  • 변경할 리소스의 소유권은 인터셉터가 아니라 명령의 트랜잭션 안에서 현재 상태와 함께 판정합니다.

연습 문제

1. 소유권 검사는 어디서 할까

@OwnerRequired가 붙은 게시글 수정에서 경로 변수의 소유권을 검사하려고 합니다. 인터셉터에서 엔티티를 미리 읽는 방법과 애플리케이션 트랜잭션 안에서 검사하는 방법을 비교하고, 중복 조회와 TOCTOU(time-of-check to time-of-use) 위험을 설명하세요.

해설 보기

인터셉터에서 엔티티를 미리 읽으면 서비스가 같은 엔티티를 다시 읽어야 하고, 검사한 시점과 실제로 바꾸는 시점 사이에 다른 요청이 소유자나 상태를 바꿀 수 있습니다. 인터셉터는 인증 주체를 넘기는 데까지만 맡고, 바뀔 수 있는 리소스의 현재 상태와 소유권은 명령 트랜잭션 안에서 함께 읽고 판정하는 편이 단순합니다.

src/main/java/board/application/ownership/UpdatePostService.java
package board.application.ownership;

public final class UpdatePostService {
    private final PostRepository posts;

    public UpdatePostService(PostRepository posts) {
        this.posts = posts;
    }

    public void rename(
            long postId,
            long memberId,
            String title
    ) {
        OwnedPost post = posts.required(postId);
        post.requireOwner(memberId);
        post.rename(title);
        posts.save(post);
    }

    public interface PostRepository {
        OwnedPost required(long postId);

        void save(OwnedPost post);
    }

    public interface OwnedPost {
        void requireOwner(long memberId);

        void rename(String title);
    }
}

위의 UpdatePostService는 required, requireOwner, rename, save의 호출 순서를 보여 주는 예입니다. 지금 코드에는 트랜잭션 선언이나 저장소의 잠금과 버전 조건이 없으므로, 그대로 실행한다고 동시 변경의 TOCTOU가 사라지지는 않습니다. 실제 저장 어댑터에서는 같은 변경 트랜잭션에서 현재 상태를 조회하고 필요한 잠금이나 낙관적 버전 조건을 정하며, 충돌할 때 저장이 확정되지 않는지도 검사해야 합니다. 테스트는 권한 실패 뒤 save가 호출되지 않는지 확인하고, 웹 테스트는 그 실패를 403이나 존재를 숨기기 위한 404로 바꾸는 것만 확인합니다.

2. 정책 선택과 인자 선택

각 경우는 깨끗한 요청 속성 상태에서 따로 시작하고 회원 41의 세션을 씁니다. A는 CurrentMemberController를 그대로 씁니다. B는 별도로 고친 코드에서 클래스의 @LoginRequired만 지우고, 다른 로그인 요구나 게시 필터는 없다고 가정합니다. C는 원래 컨트롤러에서 인자의 @CurrentMember만 지웁니다. 사용자 리졸버의 지원 여부와 회원 주체의 게시 여부, A와 B에서 조회 포트에 도달하는지를 예측하세요.

해설 보기

A는 로그인 요구를 판정하고 회원 41을 요청에 게시합니다. 애노테이션과 정확한 중첩 타입이 모두 맞아 리졸버가 그 주체를 돌려주며, query.summaryFor(41)이 1회 호출됩니다.

B에서도 리졸버의 지원 조건은 맞지만, 인터셉터가 로그인 요구가 없는 핸들러를 그냥 통과시키므로 주체를 게시하지 않습니다. 깨끗한 요청에는 속성이 없어 CurrentMemberMissingException이 나고, 컨트롤러와 조회 포트는 호출되지 않습니다. 이것은 두 애노테이션의 연결을 빠뜨린 것이며, 익명 요청을 막아 정상적으로 401을 만든 경우와 다릅니다. 이 예외가 HTTP로 어떻게 표현되는지는 등록된 오류 처리 설정에서 따로 확인합니다.

C에서는 로그인 인터셉터가 주체를 게시해도 사용자 리졸버의 supportsParameter()가 false입니다. 그 뒤의 인자 해석은 기본 체인이 판단하므로, 여기서 회원 41이 넘어가거나 저절로 401이 된다고 예상하지 않습니다. 리졸버 테스트처럼 지원 여부를 먼저 검사하고, 별도의 MVC 변형으로 최종 결과를 확인합니다.

다음 글 서블릿 오류 디스패치에서는 컨트롤러가 처리하지 못한 예외가 서블릿 컨테이너의 ERROR 디스패치로 다시 들어오는 경로를 따라갑니다.