본문으로 건너뛰기

안동민 개발노트

본문 시작

인터셉터와 인자 해석

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

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

세 도구에 인증 로직을 복제하지 않습니다. Filter가 만든 공통 요청 문맥은 재사용하고, 인터셉터가 핸들러 정책을 판정하며, 리졸버는 이미 검증된 주체를 정확한 Java 타입으로만 바꿉니다.

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

PROCESS · ONE DECISION · ONE TYPE

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

Filter가 공통 요청 문맥을 게시하고, Interceptor가 선택된 handler의 로그인 정책을 판정한 뒤, Resolver가 이미 검증된 주체만 정확한 parameter type으로 전달한다.

인증 정보는 네 경계를 지나며 한 번만 좁아진다 요청 추적 필터가 공통 문맥을 게시하고 로그인 인터셉터가 세션과 LoginRequired 애노테이션을 확인한다. 인증 성공은 CurrentMember 인자 리졸버와 컨트롤러로 진행하고, 익명 API는 401 ProblemDetail, 익명 HTML은 query를 제외한 내부 경로로 302 로그인 이동을 반환한다. ONE AUTHENTICATED MEMBER · NARROWER AT EACH BOUNDARY FILTER request context requestId · MDC · header 인증 판정 없음 LOGIN INTERCEPTOR @LoginRequired? server session member? 성공 때 exact attribute 게시 실패 때 controller calls 0 ARGUMENT RESOLVER @CurrentMember AuthenticatedMember exact type session 재조회 없음 CONTROLLER member + HTTP input application command member 41 → query 1회 ANONYMOUS API 401 ProblemDetail authentication-required X-Request-Id는 Filter 소유 ANONYMOUS HTML 302 /login next = internal URI only query는 보존하지 않음 AUTHORIZATION transaction 내부 판정 resource ownership은 별도 경계 VERIFIED CONTEXT FLOWS RIGHT · AUTH FAILURE TERMINATES BEFORE CONTROLLER
  • STEP 1 · FILTER

    공통 request context 게시

    원시 header와 dispatcher type을 보고 requestId를 attribute·MDC·response header에 같은 값으로 게시한다.

    인증 주체나 handler annotation은 판정하지 않는다.

  • STEP 2 · INTERCEPTOR

    선택된 handler의 로그인 요구 판정

    @LoginRequired와 server-side session을 확인한다. 익명 API는 401, HTML은 302를 완성하고 false로 controller 도달을 막는다.

    인증 성공 때만 exact member를 request attribute에 게시한다.

  • STEP 3 · ARGUMENT RESOLVER

    검증된 attribute를 exact type으로 변환

    @CurrentMemberSessionMemberReader.AuthenticatedMember가 모두 맞을 때만 값을 반환한다.

    session 재조회·권한 판정·암묵적 null 반환은 없다.

  • STEP 4 · CONTROLLER

    HTTP 입력과 현재 회원으로 command 조립

    session key와 형 변환을 알지 않고, 검증된 member ID와 요청 입력만 application port에 전달한다.

    resource 소유권은 변경 transaction 안에서 다시 판정한다.

anonymous API → 401 · anonymous HTML → 302 · controller calls 0 · member 41 → 200 · query.summaryFor(41) calls 1

  • 로그인 정책의 단일 판정 지점
  • 검증된 문맥의 단방향 전달

각 단계는 이전 단계보다 좁은 정보만 소유한다. 같은 인증 검사를 여러 확장 지점에 복제하지 않으므로 정책과 type 변환이 서로 어긋나지 않는다.


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

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

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

preHandlefalse를 반환하면 컨트롤러는 실행되지 않으므로 인터셉터가 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();
    }
}

파라미터 타입만 보고 모든 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);
    }
}

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

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