본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 웹 상태와 경계 처리

인터셉터와 인자 해석

필터·인터셉터·인자 해석기의 관찰 범위를 구분하고 로그인 차단과 현재 회원 주입을 검증합니다.

Filter가 서블릿 요청 전체를 본다면 HandlerInterceptor는 Spring MVC가 어떤 핸들러를 선택했는지 알고 실행됩니다.

HandlerMethodArgumentResolver는 더 좁게 컨트롤러 파라미터 하나를 만들어 줍니다.

세 도구를 모두 인증용으로 중복 사용하지 않고, 차단 정책·핸들러 메타데이터·타입-안전 전달이라는 서로 다른 책임으로 나눕니다.


인터셉터 생명주기

preHandlefalse를 반환하면 컨트롤러가 실행되지 않으므로 인터셉터가 응답을 완성해야 합니다.

postHandle은 핸들러 성공 후 뷰 렌더링 전이고, afterCompletion은 렌더링과 예외 처리가 끝난 뒤 정리에 적합합니다.

preHandle에서 만든 자원은 afterCompletion에서 해제하되 실제 호출 조건을 테스트합니다.

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.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public final class LoginInterceptor implements HandlerInterceptor {
    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("authenticatedMember", member.get());
            return true;
        }
        String destination = safeDestination(request);
        response.sendRedirect("/login?next=" + URLEncoder.encode(
                destination, StandardCharsets.UTF_8));
        return false;
    }

    private boolean requiresLogin(HandlerMethod method) {
        return method.hasMethodAnnotation(LoginRequired.class)
                || method.getBeanType().isAnnotationPresent(
                        LoginRequired.class);
    }

    private String safeDestination(HttpServletRequest request) {
        String uri = request.getRequestURI();
        String query = request.getQueryString();
        return query == null ? uri : uri + "?" + query;
    }
}

next는 현재 서버가 관찰한 내부 URI에서 만들고 로그인 요청의 외부 URL 파라미터를 그대로 쓰지 않습니다.

로그인 후에도 /로 시작하고 //, 스킴, 제어 문자가 없는지 검증합니다.

쿼리에 토큰이나 개인 정보가 있다면 리다이렉트 보존에서 제외합니다.


핸들러 메타데이터 정책

/posts/** 문자열만 등록하면 경로 이름 변경과 새 엔드포인트 추가 때 보호 누락이 생길 수 있습니다.

애노테이션으로 핸들러 또는 컨트롤러 클래스에 의도를 붙이면 코드 검토에서 로그인 요구가 보입니다.

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

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

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
}
src/main/java/board/web/MyPostController.java
package board.web;

import board.web.auth.CurrentMember;
import board.web.auth.LoginRequired;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
@LoginRequired
public final class MyPostController {
    private final PostQuery query;

    public MyPostController(PostQuery query) {
        this.query = query;
    }

    @GetMapping("/my/posts")
    String mine(@CurrentMember AuthenticatedMember member, Model model) {
        model.addAttribute("posts", query.byMember(member.id()));
        return "posts/mine";
    }
}

애노테이션이 있다고 실제 권한 검사가 자동으로 생기는 것은 아닙니다.

인터셉터 등록이 빠지면 아무 효과가 없으므로 MVC 컨텍스트 테스트에서 보호 엔드포인트를 열거하거나 Spring 보안의 authorizeHttpRequests처럼 실패 시 차단하는 정책을 선호합니다.

관리자·소유권 같은 세부 인가는 서비스에서도 확인합니다.


ArgumentResolver 변환

컨트롤러마다 HttpSession과 문자열 키를 반복하면 형 변환과 null 처리가 퍼집니다.

리졸버는 애노테이션과 파라미터 타입이 모두 맞을 때만 동작하고 인터셉터가 넣은 인증 주체를 읽습니다.

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;

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);
        Object value = request.getAttribute("authenticatedMember");
        if (value instanceof AuthenticatedMember member) {
            return member;
        }
        throw new UnauthenticatedException();
    }
}

리졸버가 세션을 다시 읽고 인터셉터와 별도 정책을 구현하면 두 경로가 어긋날 수 있습니다.

인터셉터는 인증 여부 결정과 속성 설정, 리졸버는 타입 변환만 맡깁니다.

공개 엔드포인트에서 선택적 구성원이 필요하면 @CurrentMember Optional<AuthenticatedMember> 지원을 명시적으로 추가하거나 별도 애노테이션을 둡니다.

WebMvcConfigurer#addArgumentResolvers에 등록해야 동작합니다.

테스트 슬라이스가 실제 구성을 포함하는지 확인합니다.

파라미터 타입만 보고 모든 AuthenticatedMember를 자동 주입하면 테스트나 다른 의미의 파라미터까지 가로챌 수 있으므로 애노테이션을 함께 요구합니다.


인터셉터·리졸버 호출 검증

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

import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
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.mock.web.MockHttpSession;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(MyPostController.class)
@ContextConfiguration(classes = AuthMvcTestConfiguration.class)
class CurrentMemberMvcTest {
    @Autowired
    MockMvc mvc;

    @MockitoBean
    PostQuery query;

    @Test
    void 익명_요청은_controller_전에_login으로_이동한다() throws Exception {
        mvc.perform(get("/my/posts"))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrlPattern("/login?next=**"));
    }

    @Test
    void 인증_요청은_resolver가_회원_argument를_제공한다() throws Exception {
        var session = new MockHttpSession();
        session.setAttribute(
                "authenticatedMember",
                new AuthenticatedMember(41L));

        mvc.perform(get("/my/posts").session(session))
                .andExpect(status().isOk());

        verify(query).byMember(41L);
    }
}
MVC 인증 연결 결과
anonymous GET /my/posts -> 302 /login
anonymous controller calls = 0
member 41 GET /my/posts -> 200
resolved argument id = 41
query.byMember calls = 1

구성 클래스에는 인터셉터와 리졸버를 실제 순서로 등록합니다.

단위 테스트에서 리졸버 메서드만 호출하는 것보다 핸들러 매핑부터 컨트롤러 호출까지 통과시켜 애노테이션 탐색과 등록 누락을 함께 잡습니다.


웹 확장 지점 선택

도구가장 이른 정보적합한 책임
Filter원시 서블릿 요청트레이싱·보안 헤더·본문 래퍼
Interceptor선택된 핸들러애노테이션 정책·MVC 지연 시간
ArgumentResolver메서드 파라미터인증 주체·페이지 요청 변환

같은 요청 ID를 세 계층에서 각각 만들지 않습니다.

Filter가 생성하고 요청 속성·MDC에 보관하면 인터셉터와 컨트롤러가 읽습니다.

반대로 핸들러 애노테이션은 Filter가 알기 어렵기 때문에 Interceptor가 적절합니다.

Spring Security를 도입하면 인증·인가는 보안 필터 체인과 메서드 보안에 맡기고 사용자 정의 인터셉터는 업무 외 관찰이나 표현 정책에 제한합니다.

자체 인터셉터가 표준 보안 컨텍스트와 경쟁하지 않게 마이그레이션 경계를 정합니다.


연습 문제

@OwnerRequired가 붙은 게시글 상세 핸들러에서 경로 변수의 리소스 소유권을 검사하려고 합니다.

인터셉터에서 모든 도메인 조회를 수행할지, 컨트롤러/서비스에서 검사할지 결정하고 중복 조회와 TOCTOU 위험을 설명하세요.

다른 회원, 삭제된 리소스, 관리자 우회 세 경우를 테스트합니다.

해설 보기

인터셉터는 경로 변수를 얻을 수 있지만 트랜잭션 밖에서 엔티티를 미리 조회하면 서비스가 다시 조회하고 두 시점 사이 상태가 바뀔 수 있습니다.

인증 주체 전달까지만 리졸버가 맡고 소유권은 명령 트랜잭션 안에서 확인하는 구조가 단순합니다.

package board.application;

public final class UpdatePostService {
    private final PostRepository posts;

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

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

관리자 정책이 외부 권한 리포지토리에 있다면 같은 트랜잭션 일관성이 필요한지 정합니다.

테스트는 서비스가 권한 실패 뒤 저장을 호출하지 않는지 확인하고 웹 테스트는 실패를 403으로 변환하는지만 담당합니다.

다음 문서에서는 컨트롤러에서 난 예외가 컨테이너의 오류 디스패치로 다시 들어오는 서블릿 오류 처리 경로를 추적합니다.