본문 바로가기

인터셉터와 인자 해석

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

서블릿 Filter가 원시 요청 전체를 관찰한다면 HandlerInterceptor는 Spring MVC가 선택한 핸들러를 알고 실행됩니다. HandlerMethodArgumentResolver는 더 좁게, 컨트롤러 파라미터 하나를 만듭니다.

세 도구에 인증 로직을 복제하지 않습니다. Filter가 만든 공통 요청 문맥은 재사용하고, 인터셉터가 핸들러 정책을 판정하며, 리졸버는 이미 검증된 주체를 정확한 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 범위 밖검증된 회원으로 실제 조회가 완료됩니다.

공개 계약은 애노테이션과 주체 타입 두 축이다

@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 {
}

주체 타입은 앞 문서의 SessionMemberReader.AuthenticatedMember 하나만 사용합니다. 같은 이름의 top-level 타입을 다시 만들면 세션에 저장된 객체와 리졸버가 요구하는 객체가 겉보기만 같고 실제로는 다른 클래스가 됩니다.


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

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

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는 앞 Filter가 응답 헤더에 게시합니다.

원 MVC fixture의 요청은 루트 컨텍스트를 전제로 합니다. /academy 같은 컨텍스트 경로를 붙이면 원 getRequestURI()는 /academy/api/...가 되어 현재 /api/ 판정과 /login 목적지가 같은 배포 계약을 따르지 않습니다. 원 테스트를 먼저 루트 조건에서 확인한 뒤, 컨텍스트가 필요한 배포에서는 API 분류·내부 목적지·redirect 경로를 함께 수정한 별도 변형으로 검증하세요.

next는 서버가 관찰한 내부 URI에서 만들고 query는 전부 제외했습니다. 로그인 완료 시에도 /로 시작하고 //, 스킴, 역슬래시, 제어 문자가 없는지 다시 검증해야 합니다.

preHandle이 false를 반환하면 컨트롤러는 실행되지 않으므로 인터셉터가 redirect 응답을 완성해야 합니다. postHandle은 성공한 핸들러 뒤이지만 뷰 렌더링 전이고, afterCompletion은 MVC 처리가 끝난 뒤 정리에 적합합니다.


리졸버는 정책을 반복하지 않고 타입만 좁힌다

리졸버는 애노테이션과 파라미터 타입이 모두 일치할 때만 개입합니다. 세션을 다시 읽지 않고 인터셉터가 request에 게시한 값을 소비합니다.

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는 앞선 로그인 판정 계약입니다. 전자는 세션을 읽거나 후자를 대신하지 않습니다. 인증 세션이 있어도 핸들러의 로그인 요구를 빠뜨리면 이 인터셉터는 주체를 request에 게시하지 않으므로, 처음의 깨끗한 요청에서 리졸버가 요구하는 속성이 없을 수 있습니다. 두 애노테이션과 MVC 등록을 함께 확인해야 하는 이유입니다.

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


실제 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 경로만 query를 제외한 내부 next로 302가 됨을 고정합니다. 셋째 테스트는 세션의 정확한 nested record가 request attribute를 거쳐 파라미터가 되고, 컨트롤러가 회원 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

확장 지점은 가장 이른 필요 정보로 고른다

도구가장 이른 정보이 장의 책임
Filter원시 servlet request요청 ID·보안 헤더·본문 래퍼
Interceptor선택된 HandlerMethod로그인 요구 애노테이션·핸들러 도달 차단
ArgumentResolver메서드 파라미터검증된 request attribute의 타입 안전 변환
ControllerHTTP 입력과 검증된 주체application command 조립

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

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


연습 문제

@OwnerRequired가 붙은 게시글 수정에서 경로 변수의 소유권을 검사하려고 합니다. 인터셉터에서 엔티티를 미리 읽는 방법과 application transaction 안에서 검사하는 방법을 비교하고, 중복 조회와 TOCTOU 위험을 설명하세요.

해설 보기

인터셉터는 인증 주체 전달까지만 맡기고, 변경 가능한 리소스의 현재 상태와 소유권은 명령 transaction 안에서 함께 읽고 판정하는 편이 단순합니다.

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의 호출 순서를 보여 주는 예시입니다. 현재 코드에는 transaction 선언이나 저장소의 잠금·버전 조건 구현이 없으므로 그대로 실행한다고 동시 변경의 TOCTOU가 해소되지는 않습니다. 실제 저장 어댑터에서는 같은 변경 transaction의 현재 상태 조회와 필요한 잠금·낙관적 버전 조건을 정하고, 충돌 때 저장이 확정되지 않는지도 검사해야 합니다.

테스트는 권한 실패 뒤 save가 호출되지 않는지 확인하고, 웹 테스트는 그 실패를 403 또는 존재 은닉을 위한 404로 변환하는 계약만 담당합니다.

독립 확인 각각 독립된 깨끗한 request attribute 상태로 시작하고 회원 41 세션을 사용합니다. A는 원 CurrentMemberController 그대로입니다. B는 별도 변형에서 클래스의 @LoginRequired만 제거하며 다른 로그인 요구나 게시 필터가 없다고 가정합니다. C는 원 컨트롤러에서 인자의 @CurrentMember만 제거합니다. 이 사용자 리졸버의 지원 여부, 회원 주체의 게시 여부, A·B에서 조회 포트 도달 여부를 예측하세요.

정책 선택과 인자 선택을 따로 추적하는 풀이

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

B의 리졸버 지원 조건은 여전히 맞지만 인터셉터가 로그인 요구 없는 핸들러를 통과시키므로 주체를 게시하지 않습니다. 깨끗한 요청에서 속성이 없어 CurrentMemberMissingException이 발생하고 컨트롤러·조회 포트는 호출되지 않습니다. 이것은 두 계약의 연결 누락이며, 익명 요청을 차단해 정상적으로 401을 만든 경우와 다릅니다. 이 예외의 실제 HTTP 표현은 등록된 오류 처리 계약에서 따로 확인합니다.

C는 로그인 인터셉터가 주체를 게시해도 이 사용자 리졸버의 supportsParameter()가 false입니다. 이후 인자 해석은 기본 체인의 판단이므로 여기서 임의로 회원 41 전달이나 자동 401을 예상하지 않습니다. 원 리졸버 계약 테스트처럼 지원 여부를 먼저 검사하고 별도 MVC 변형에서 최종 결과를 확인하세요.

다음 문서에서는 컨트롤러에서 처리되지 않은 예외가 servlet 컨테이너의 ERROR dispatch로 다시 들어오는 경로를 추적합니다.