본문 바로가기

RequestMapping 조건

RequestMapping 조건의 교집합과 부분 일치 실패를 실행해 400·404·405·406·415의 경계를 구분합니다.

@RequestMapping은 URL 문자열 하나만 등록하지 않습니다.

경로, HTTP 메서드, 요청 파라미터, 헤더, consumes, produces가 하나의 매핑 조건을 이룹니다. 적용되는 조건을 모두 만족한 매핑 하나가 선택되어야 핸들러가 호출됩니다.

매핑 조건의 교집합과 부분 일치가 404·405·415·406을 가른다

매핑 실패·인자 400·호출 뒤 404/200의 마지막 도달 경계를 보여 주되 조건 평가의 전역 순서는 고정하지 않는다.

이 문서의 고정된 컨트롤러 fixture에서는 경로 후보가 없으면 mapping 404, 같은 경로의 HTTP 메서드가 맞지 않으면 405와 Allow, PUT 요청의 Content-Type이 application/json이 아니면 415, 응답으로 application/json을 받을 수 없으면 406이 됩니다. 적용되는 모든 조건을 만족할 때만 조회·교체 또는 구체적인 search handler가 호출됩니다. 숫자 경로 변수 변환 400과 handler 진입 뒤 리소스 없음 404는 별도 경계입니다.

FIXTURE-SCOPED PARTIAL MATCH · NO HANDLER INVOCATION

매핑 조건의 교집합과 부분 일치가 404·405·415·406을 가른다

고정 fixture의 구체 요청을 비교해 handler 호출 전 매핑·인자 변환 실패와 handler 진입 뒤 조회 실패를 구분합니다. HTTP 상태와 서비스 호출 증거를 함께 읽습니다.

고정 fixture의 매핑·인자·서비스 경계일곱 노드·여섯 관계로 이 문서의 구체 요청만 비교한다. 적용 매핑 조건이 실패하면 404·405·415·406 중 해당 사례로 끝나고 서비스는 호출되지 않는다. 조건이 맞으면 선택한 핸들러의 인자를 해석하며 숫자 변환 실패는 호출 전에 400으로 끝난다. 인자가 준비되어 컨트롤러와 서비스를 호출한 뒤 기존 GET의 리소스 없음은 find 기록과 404이고, 유효한 조회·교체·search는 200이다. 모든 후보의 전역 평가 총순서를 주장하지 않으며 정적 리소스·보안·프록시 경계를 포함하지 않는다.매핑 실패조건 충족변환 실패인자 준비조회 없음결과 있음고정 fixture 매핑경로 · HTTP methodconsumes · produces매핑 실패 · service 0404 · 경로 후보 없음405 · method 불일치415 · PUT Content-Type406 · Accept선택한 handler인자 해석 · 형변환숫자 경로 변수 변환400 · service 0컨트롤러 진입서비스 호출 증거find:999999 · 없음404 · 조회 기록 있음200 · 조회 / 교체42 또는 search 결과
고정 fixture의 매핑·인자·서비스 경계일곱 노드·여섯 관계로 이 문서의 구체 요청만 비교한다. 적용 매핑 조건이 실패하면 404·405·415·406 중 해당 사례로 끝나고 서비스는 호출되지 않는다. 조건이 맞으면 선택한 핸들러의 인자를 해석하며 숫자 변환 실패는 호출 전에 400으로 끝난다. 인자가 준비되어 컨트롤러와 서비스를 호출한 뒤 기존 GET의 리소스 없음은 find 기록과 404이고, 유효한 조회·교체·search는 200이다. 모든 후보의 전역 평가 총순서를 주장하지 않으며 정적 리소스·보안·프록시 경계를 포함하지 않는다.매핑 실패조건 충족변환 실패인자 준비조회 없음결과 있음고정 fixture 매핑경로 · methodconsumes · produces매핑 실패 · 호출 0404 · 경로 없음405 · method415 · 요청 타입406 · Accept선택한 handler인자 해석 · 형변환숫자 경로 변수 변환400 · service 0컨트롤러 진입서비스 호출 증거find:999999 · 없음404 · 조회 기록 있음200 · 조회 / 교체42 또는 search 결과

오른쪽 우회선은 조건을 통과한 경로입니다. 매핑 실패나 변환 실패 노드를 지난 뒤 성공하는 순서가 아닙니다. 이 비교는 고정 fixture에 한정되며 Spring 전체 후보의 총순서나 배포 경계 동작을 뜻하지 않습니다.

PATH · 404

경로 후보 없음

GET /api/other/42는 NoHandlerFoundException으로 해석됩니다. 컨트롤러·서비스 호출은 0입니다.

METHOD · 405

같은 path의 method 불일치

POST /api/posts/42는 405와 Allow를 반환합니다. 이 fixture는 지원 집합 GET, PUT을 순서와 무관하게 검사하며 서비스 호출은 0입니다.

CONSUMES · 415

PUT의 Content-Type 불일치

PUT /api/posts/42에 text/plain을 보내면 JSON consumes 조건에서 415입니다. GET 매핑에는 consumes 조건이 없으며 이 PUT 실패의 서비스 호출은 0입니다.

PRODUCES · 406

Accept가 JSON 응답을 허용하지 않음

GET /api/posts/42에 Accept: application/xml이면 406입니다. 컨트롤러·서비스 호출은 0입니다.

ALL APPLICABLE CONDITIONS

조건이 맞으면 선택한 handler를 호출한다

GET /api/posts/42는 조회, JSON PUT은 교체를 수행합니다. 고정 /api/posts/search는 변수 패턴보다 구체적이므로 search handler가 선택됩니다.

ARGUMENT CONVERSION · 400

숫자 경로 변수 변환 실패

/api/posts/not-a-number는 매핑 후 인자 변환 400이며 서비스 호출 0입니다.

APPLICATION LOOKUP · 404

유효한 ID의 리소스 없음

/api/posts/999999는 handler 진입 후 find:999999가 기록되고 애플리케이션 정책으로 404가 됩니다.

이 비교는 이 문서의 고정 fixture에 한정됩니다. 중복된 동일 매핑 등록은 요청 전 구성 실패로 거부합니다. 모든 RequestMappingInfo 후보를 프레임워크가 항상 이 총순서로 순회한다고 주장하지 않으며, 매핑 404, 변환 400, 조회 후 404는 서로 다른 실패 경계입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 일곱 노드·여섯 관계로 이 문서의 구체 요청만 비교한다. 적용 매핑 조건이 실패하면 404·405·415·406 중 해당 사례로 끝나고 서비스는 호출되지 않는다. 조건이 맞으면 선택한 핸들러의 인자를 해석하며 숫자 변환 실패는 호출 전에 400으로 끝난다. 인자가 준비되어 컨트롤러와 서비스를 호출한 뒤 기존 GET의 리소스 없음은 find 기록과 404이고, 유효한 조회·교체·search는 200이다. 모든 후보의 전역 평가 총순서를 주장하지 않으며 정적 리소스·보안·프록시 경계를 포함하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
구체 mapping 조건

해당 문서의 매핑 조건을 확인하고 실패는 서비스 전에 끝납니다.

실패 사례: 404·405·415·406 · 서비스: 0서로 다른 조건 실패의 도달 범위를 고정합니다.
인자 해석

선택 handler의 숫자 변환 실패는 호출 전에 400입니다.

변환 실패: 400 · handler·service: 0값 준비 실패가 업무 진입을 막습니다.
호출 뒤 결과

준비된 입력은 controller·service를 호출합니다.

없는 자원: find 기록·404 · 정상: 200호출 뒤 404와 호출 전 거부를 구분합니다.

매핑 조건의 교집합과 부분 일치가 404·405·415·406을 가른다

매핑 실패·인자 400·호출 뒤 404/200의 마지막 도달 경계를 보여 주되 조건 평가의 전역 순서는 고정하지 않는다.

이 문서의 고정된 컨트롤러 fixture에서는 경로 후보가 없으면 mapping 404, 같은 경로의 HTTP 메서드가 맞지 않으면 405와 Allow, PUT 요청의 Content-Type이 application/json이 아니면 415, 응답으로 application/json을 받을 수 없으면 406이 됩니다. 적용되는 모든 조건을 만족할 때만 조회·교체 또는 구체적인 search handler가 호출됩니다. 숫자 경로 변수 변환 400과 handler 진입 뒤 리소스 없음 404는 별도 경계입니다.

FIXTURE-SCOPED PARTIAL MATCH · NO HANDLER INVOCATION

매핑 조건의 교집합과 부분 일치가 404·405·415·406을 가른다

고정 fixture의 구체 요청을 비교해 handler 호출 전 매핑·인자 변환 실패와 handler 진입 뒤 조회 실패를 구분합니다. HTTP 상태와 서비스 호출 증거를 함께 읽습니다.

고정 fixture의 매핑·인자·서비스 경계일곱 노드·여섯 관계로 이 문서의 구체 요청만 비교한다. 적용 매핑 조건이 실패하면 404·405·415·406 중 해당 사례로 끝나고 서비스는 호출되지 않는다. 조건이 맞으면 선택한 핸들러의 인자를 해석하며 숫자 변환 실패는 호출 전에 400으로 끝난다. 인자가 준비되어 컨트롤러와 서비스를 호출한 뒤 기존 GET의 리소스 없음은 find 기록과 404이고, 유효한 조회·교체·search는 200이다. 모든 후보의 전역 평가 총순서를 주장하지 않으며 정적 리소스·보안·프록시 경계를 포함하지 않는다.매핑 실패조건 충족변환 실패인자 준비조회 없음결과 있음고정 fixture 매핑경로 · HTTP methodconsumes · produces매핑 실패 · service 0404 · 경로 후보 없음405 · method 불일치415 · PUT Content-Type406 · Accept선택한 handler인자 해석 · 형변환숫자 경로 변수 변환400 · service 0컨트롤러 진입서비스 호출 증거find:999999 · 없음404 · 조회 기록 있음200 · 조회 / 교체42 또는 search 결과
고정 fixture의 매핑·인자·서비스 경계일곱 노드·여섯 관계로 이 문서의 구체 요청만 비교한다. 적용 매핑 조건이 실패하면 404·405·415·406 중 해당 사례로 끝나고 서비스는 호출되지 않는다. 조건이 맞으면 선택한 핸들러의 인자를 해석하며 숫자 변환 실패는 호출 전에 400으로 끝난다. 인자가 준비되어 컨트롤러와 서비스를 호출한 뒤 기존 GET의 리소스 없음은 find 기록과 404이고, 유효한 조회·교체·search는 200이다. 모든 후보의 전역 평가 총순서를 주장하지 않으며 정적 리소스·보안·프록시 경계를 포함하지 않는다.매핑 실패조건 충족변환 실패인자 준비조회 없음결과 있음고정 fixture 매핑경로 · methodconsumes · produces매핑 실패 · 호출 0404 · 경로 없음405 · method415 · 요청 타입406 · Accept선택한 handler인자 해석 · 형변환숫자 경로 변수 변환400 · service 0컨트롤러 진입서비스 호출 증거find:999999 · 없음404 · 조회 기록 있음200 · 조회 / 교체42 또는 search 결과

오른쪽 우회선은 조건을 통과한 경로입니다. 매핑 실패나 변환 실패 노드를 지난 뒤 성공하는 순서가 아닙니다. 이 비교는 고정 fixture에 한정되며 Spring 전체 후보의 총순서나 배포 경계 동작을 뜻하지 않습니다.

PATH · 404

경로 후보 없음

GET /api/other/42는 NoHandlerFoundException으로 해석됩니다. 컨트롤러·서비스 호출은 0입니다.

METHOD · 405

같은 path의 method 불일치

POST /api/posts/42는 405와 Allow를 반환합니다. 이 fixture는 지원 집합 GET, PUT을 순서와 무관하게 검사하며 서비스 호출은 0입니다.

CONSUMES · 415

PUT의 Content-Type 불일치

PUT /api/posts/42에 text/plain을 보내면 JSON consumes 조건에서 415입니다. GET 매핑에는 consumes 조건이 없으며 이 PUT 실패의 서비스 호출은 0입니다.

PRODUCES · 406

Accept가 JSON 응답을 허용하지 않음

GET /api/posts/42에 Accept: application/xml이면 406입니다. 컨트롤러·서비스 호출은 0입니다.

ALL APPLICABLE CONDITIONS

조건이 맞으면 선택한 handler를 호출한다

GET /api/posts/42는 조회, JSON PUT은 교체를 수행합니다. 고정 /api/posts/search는 변수 패턴보다 구체적이므로 search handler가 선택됩니다.

ARGUMENT CONVERSION · 400

숫자 경로 변수 변환 실패

/api/posts/not-a-number는 매핑 후 인자 변환 400이며 서비스 호출 0입니다.

APPLICATION LOOKUP · 404

유효한 ID의 리소스 없음

/api/posts/999999는 handler 진입 후 find:999999가 기록되고 애플리케이션 정책으로 404가 됩니다.

이 비교는 이 문서의 고정 fixture에 한정됩니다. 중복된 동일 매핑 등록은 요청 전 구성 실패로 거부합니다. 모든 RequestMappingInfo 후보를 프레임워크가 항상 이 총순서로 순회한다고 주장하지 않으며, 매핑 404, 변환 400, 조회 후 404는 서로 다른 실패 경계입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 일곱 노드·여섯 관계로 이 문서의 구체 요청만 비교한다. 적용 매핑 조건이 실패하면 404·405·415·406 중 해당 사례로 끝나고 서비스는 호출되지 않는다. 조건이 맞으면 선택한 핸들러의 인자를 해석하며 숫자 변환 실패는 호출 전에 400으로 끝난다. 인자가 준비되어 컨트롤러와 서비스를 호출한 뒤 기존 GET의 리소스 없음은 find 기록과 404이고, 유효한 조회·교체·search는 200이다. 모든 후보의 전역 평가 총순서를 주장하지 않으며 정적 리소스·보안·프록시 경계를 포함하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
구체 mapping 조건

해당 문서의 매핑 조건을 확인하고 실패는 서비스 전에 끝납니다.

실패 사례: 404·405·415·406 · 서비스: 0서로 다른 조건 실패의 도달 범위를 고정합니다.
인자 해석

선택 handler의 숫자 변환 실패는 호출 전에 400입니다.

변환 실패: 400 · handler·service: 0값 준비 실패가 업무 진입을 막습니다.
호출 뒤 결과

준비된 입력은 controller·service를 호출합니다.

없는 자원: find 기록·404 · 정상: 200호출 뒤 404와 호출 전 거부를 구분합니다.

그림의 404·405·415·406 순서는 아래의 고정된 컨트롤러 fixture에서 부분 일치 실패를 진단하는 모델입니다. 프레임워크가 모든 RequestMappingInfo 후보를 언제나 이 총순서로 순회한다고 일반화하지 않습니다.


실행 기준 고정

예제는 Java 25, Spring Boot 4.1.1 BOM, Spring Framework 7.0.9, JUnit 6.0.3을 한 의존성 그래프로 사용합니다. 모든 소스는 경로가 붙은 완전한 파일이며 아래 Gradle 프로젝트로 그대로 실행할 수 있습니다.

settings.gradle
rootProject.name = 'request-mapping-conditions'
build.gradle
plugins {
    id 'java'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation platform(
            'org.springframework.boot:spring-boot-dependencies:4.1.1')
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    testImplementation platform(
            'org.springframework.boot:spring-boot-dependencies:4.1.1')
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.withType(JavaCompile).configureEach {
    options.encoding = 'UTF-8'
    options.release = 25
    options.compilerArgs += ['-parameters']
}

tasks.named('test') {
    useJUnitPlatform()
}

Boot Gradle 플러그인은 실행 가능한 애플리케이션을 패키징할 때 필요합니다. 이 예제는 컨트롤러 fixture를 테스트하므로 Java 플러그인과 Boot BOM만 사용합니다. -parameters를 켜더라도 경로 변수 이름은 애노테이션에 명시해 빌드 옵션에 의미가 숨어들지 않게 합니다.


도메인과 웹 경계를 완결한다

매핑을 시험하는 코드라도 생략된 타입에 기대지 않습니다. 도메인 값과 서비스 포트는 Spring MVC를 모르고, 웹 요청과 응답 타입이 그 경계를 명시적으로 변환합니다.

src/main/java/board/post/Post.java
package board.post;

public record Post(
        long id,
        String title,
        String content) {}
src/main/java/board/post/ReplacePostCommand.java
package board.post;

public record ReplacePostCommand(
        String title,
        String content) {}
src/main/java/board/post/PostService.java
package board.post;

import java.util.Optional;

public interface PostService {
    Optional<Post> find(long postId);

    Post replace(
            long postId,
            ReplacePostCommand command);

    Post search();
}
src/main/java/board/web/ReplacePostRequest.java
package board.web;

import board.post.ReplacePostCommand;

public record ReplacePostRequest(
        String title,
        String content) {
    ReplacePostCommand toCommand() {
        return new ReplacePostCommand(
                title,
                content);
    }
}
src/main/java/board/web/PostResponse.java
package board.web;

import board.post.Post;

public record PostResponse(
        long id,
        String title,
        String content) {
    static PostResponse from(Post post) {
        return new PostResponse(
                post.id(),
                post.title(),
                post.content());
    }
}

본문 검증과 Jackson 바인딩의 상세 정책은 다음 문서가 소유합니다. 여기서는 요청·응답 타입을 완결해 매핑 조건의 실행 여부만 고립시킵니다.


클래스와 메서드 조건을 결합한다

/api/posts는 클래스 수준 경로이고 /search와 /{postId}는 메서드 수준 경로입니다. 서로 다른 범주의 조건은 함께 적용됩니다. 같은 범주의 consumes 또는 produces를 메서드 수준에서 다시 선언하면 클래스 수준 값에 누적되는 것이 아니라 메서드 값이 우선합니다.

src/main/java/board/web/PostCommandController.java
package board.web;

import static org.springframework.http.MediaType
        .APPLICATION_JSON_VALUE;

import board.post.PostService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping(
        path = "/api/posts",
        produces = APPLICATION_JSON_VALUE)
public final class PostCommandController {
    private final PostService service;

    public PostCommandController(PostService service) {
        this.service = service;
    }

    @GetMapping("/search")
    PostResponse search() {
        return PostResponse.from(service.search());
    }

    @GetMapping("/{postId}")
    PostResponse get(
            @PathVariable("postId") long postId) {
        return service.find(postId)
                .map(PostResponse::from)
                .orElseThrow(() ->
                        new ResponseStatusException(
                                HttpStatus.NOT_FOUND,
                                "post not found"));
    }

    @PutMapping(
            path = "/{postId}",
            consumes = APPLICATION_JSON_VALUE)
    PostResponse replace(
            @PathVariable("postId") long postId,
            @RequestBody ReplacePostRequest request) {
        return PostResponse.from(
                service.replace(
                        postId,
                        request.toCommand()));
    }
}

@GetMapping과 @PutMapping은 메서드와 리소스 의미가 드러나는 합성 애노테이션입니다. 같은 요소에 여러 매핑 애노테이션을 붙여 조건이 합성되기를 기대하면 안 됩니다. Spring은 경고를 남기고 첫 매핑만 사용하므로 한 요소에는 하나의 매핑 계약만 둡니다.


부분 일치 실패와 애플리케이션 실패를 분리한다

이 fixture에서 POST /api/posts/42는 경로 후보가 있지만 메서드가 맞지 않아 405입니다. PUT의 Content-Type이 application/json이 아니면 consumes 부분 일치에서 415이고, GET 요청이 JSON 응답을 받을 수 없으면 produces 부분 일치에서 406입니다. 세 경우 모두 컨트롤러와 서비스를 호출하지 않습니다.

404와 경로 변수 오류는 한 종류가 아닙니다.

요청실패 경계상태서비스 호출
GET /api/other/42매핑 경로 후보 없음404없음
GET /api/posts/not-a-number핸들러 인자 변환400없음
GET /api/posts/999999핸들러 진입 뒤 리소스 조회404find:999999

예시

PUT /api/posts/not-a-number에 Content-Type: application/json, Accept: application/json과 정상 JSON 본문을 보냈다고 가정합니다. 경로 패턴은 문자열 not-a-number를 한 칸의 변수로 받을 수 있으므로 경로 후보가 없는 404가 아닙니다. PUT·consumes·produces도 맞아 replace 핸들러가 선택됩니다. 그러나 선택과 메서드 호출은 다릅니다. 첫 인자인 long postId를 만들 때 숫자 변환이 실패하므로 400이 되고, replace 메서드와 서비스는 호출되지 않습니다. 정상 JSON 본문이 있어도 다른 인자의 변환 실패를 고쳐 주지는 않습니다.

타입 변환 성공은 업무상 ID 유효성을 보장하지 않습니다. 이 코드에는 양수 제약이나 별도의 ID 검사도 없습니다. 따라서 0과 -1은 long으로 변환되고 find에 전달될 수 있습니다. 반대로 9223372036854775808은 long 최댓값을 넘으므로 기본 숫자 변환에서 실패합니다. 숫자의 표현 범위, 리소스 존재, 접근 권한은 각각 다른 검사입니다.

매핑의 미디어 타입 조건은 JSON 문법 검사와도 구별합니다. Content-Type은 보낸 본문의 형식을, Accept는 받을 응답 형식을 나타냅니다. 둘 다 application/json이어도 잘린 JSON 본문은 @RequestBody를 만드는 메시지 컨버터에서 읽기 실패를 일으킬 수 있습니다. 기본 MVC 예외 처리의 HttpMessageNotReadableException은 400이며, 매핑 조건의 415와 다릅니다. 본문 읽기와 검증의 자세한 설정은 다음 절에서 이어갑니다.

독립형 Spring Framework 7 fixture에서 첫 404는 NoHandlerFoundException으로 해석됩니다. 마지막 404는 ResponseStatusException으로 명시한 애플리케이션 계약입니다. 정적 리소스 조회 실패나 다른 404 계층까지 이 예제가 대표하지 않습니다.


아홉 조건을 실행으로 고정한다

앞의 여덟 요청 테스트는 새 컨트롤러와 기록용 서비스로 시작하고, 중복 등록 테스트는 별도의 충돌 컨트롤러를 구성합니다. 실패 응답에서는 상태뿐 아니라 해석된 예외와 서비스 호출 부재를 함께 확인합니다. Allow는 헤더 문자열 순서가 아니라 이 fixture에 등록된 GET·PUT 집합으로 검사합니다.

src/test/java/board/web/RequestMappingConditionsTest.java
package board.web;

import static java.util.stream.Collectors.toSet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.handler;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;

import board.post.Post;
import board.post.PostService;
import board.post.ReplacePostCommand;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.NoHandlerFoundException;

class RequestMappingConditionsTest {
    private static final Post POST =
            new Post(42L, "기존 제목", "기존 본문");

    @Test
    void 모든_조건이_맞은_GET과_PUT은_handler를_호출한다()
            throws Exception {
        var fixture = fixture(POST);

        fixture.mvc().perform(get("/api/posts/42")
                        .accept(APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content()
                        .contentTypeCompatibleWith(
                                APPLICATION_JSON))
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.title")
                        .value("기존 제목"));

        fixture.mvc().perform(put("/api/posts/42")
                        .contentType(APPLICATION_JSON)
                        .accept(APPLICATION_JSON)
                        .content("""
                                {
                                  "title": "교체 제목",
                                  "content": "교체 본문"
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.title")
                        .value("교체 제목"));

        assertThat(fixture.service().operations())
                .containsExactly("find:42", "replace:42");
    }

    @Test
    void 경로_후보가_없으면_mapping_404다()
            throws Exception {
        var fixture = fixture(POST);

        var result = fixture.mvc()
                .perform(get("/api/other/42"))
                .andExpect(status().isNotFound())
                .andReturn();

        assertThat(result.getResolvedException())
                .isInstanceOf(
                        NoHandlerFoundException.class);
        assertThat(fixture.service().operations()).isEmpty();
    }

    @Test
    void 경로만_맞고_method가_다르면_405와_Allow를_반환한다()
            throws Exception {
        var fixture = fixture(POST);

        var result = fixture.mvc()
                .perform(post("/api/posts/42"))
                .andExpect(status().isMethodNotAllowed())
                .andReturn();

        assertThat(result.getResolvedException())
                .isInstanceOf(
                        HttpRequestMethodNotSupportedException.class);
        var allow = result.getResponse()
                .getHeader(HttpHeaders.ALLOW);
        assertThat(allow).isNotNull();
        var methods = Arrays.stream(allow.split(","))
                .map(String::trim)
                .collect(toSet());
        assertThat(methods)
                .containsExactlyInAnyOrder(
                        "GET", "PUT");
        assertThat(fixture.service().operations()).isEmpty();
    }

    @Test
    void PUT의_ContentType이_다르면_415다()
            throws Exception {
        var fixture = fixture(POST);

        var result = fixture.mvc()
                .perform(put("/api/posts/42")
                        .contentType(MediaType.TEXT_PLAIN)
                        .content("plain text"))
                .andExpect(status()
                        .isUnsupportedMediaType())
                .andReturn();

        assertThat(result.getResolvedException())
                .isInstanceOf(
                        HttpMediaTypeNotSupportedException.class);
        assertThat(fixture.service().operations()).isEmpty();
    }

    @Test
    void GET의_Accept가_다르면_406이다()
            throws Exception {
        var fixture = fixture(POST);

        var result = fixture.mvc()
                .perform(get("/api/posts/42")
                        .accept(MediaType.APPLICATION_XML))
                .andExpect(status().isNotAcceptable())
                .andReturn();

        assertThat(result.getResolvedException())
                .isInstanceOf(
                        HttpMediaTypeNotAcceptableException.class);
        assertThat(fixture.service().operations()).isEmpty();
    }

    @Test
    void literal_search가_경로_변수보다_구체적이다()
            throws Exception {
        var fixture = fixture(POST);

        fixture.mvc().perform(get("/api/posts/search")
                        .accept(APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(handler().methodName("search"))
                .andExpect(jsonPath("$.id").value(7));

        assertThat(fixture.service().operations())
                .containsExactly("search");
    }

    @Test
    void 숫자가_아닌_경로_변수는_handler_인자_변환_400이다()
            throws Exception {
        var fixture = fixture(POST);

        var result = fixture.mvc()
                .perform(get("/api/posts/not-a-number"))
                .andExpect(status().isBadRequest())
                .andReturn();

        assertThat(result.getResolvedException())
                .isInstanceOf(
                        MethodArgumentTypeMismatchException.class);
        assertThat(fixture.service().operations()).isEmpty();
    }

    @Test
    void 숫자_ID의_리소스_없음은_handler_진입_뒤_404다()
            throws Exception {
        var fixture = fixture(POST);

        var result = fixture.mvc()
                .perform(get("/api/posts/999999"))
                .andExpect(status().isNotFound())
                .andReturn();

        assertThat(result.getResolvedException())
                .isInstanceOf(ResponseStatusException.class);
        assertThat(fixture.service().operations())
                .containsExactly("find:999999");
    }

    @Test
    void 완전히_같은_mapping은_등록할_수_없다() {
        assertThatThrownBy(() ->
                standaloneSetup(
                        new DuplicateMappingController())
                        .build())
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("Ambiguous mapping");
    }

    private static Fixture fixture(Post... posts) {
        var service = new RecordingPostService(posts);
        var mvc = standaloneSetup(
                        new PostCommandController(service))
                .build();
        return new Fixture(mvc, service);
    }

    private record Fixture(
            MockMvc mvc,
            RecordingPostService service) {}

    private static final class RecordingPostService
            implements PostService {
        private final LinkedHashMap<Long, Post> posts =
                new LinkedHashMap<>();
        private final ArrayList<String> operations =
                new ArrayList<>();

        RecordingPostService(Post... posts) {
            for (var post : posts) {
                this.posts.put(post.id(), post);
            }
        }

        @Override
        public Optional<Post> find(long postId) {
            operations.add("find:" + postId);
            return Optional.ofNullable(posts.get(postId));
        }

        @Override
        public Post replace(
                long postId,
                ReplacePostCommand command) {
            operations.add("replace:" + postId);
            var post = new Post(
                    postId,
                    command.title(),
                    command.content());
            posts.put(postId, post);
            return post;
        }

        @Override
        public Post search() {
            operations.add("search");
            return new Post(
                    7L,
                    "검색 결과",
                    "literal mapping");
        }

        List<String> operations() {
            return List.copyOf(operations);
        }
    }

    @RestController
    @RequestMapping("/duplicate")
    private static final class DuplicateMappingController {
        @GetMapping
        void first() {}

        @GetMapping
        void second() {}
    }
}

이 테스트는 아홉 메서드를 서로 독립된 fixture로 실행합니다. 핸들러 호출 전 실패 테스트는 작업을 기록하지 않습니다. 정상 GET·PUT·search와 핸들러 진입 뒤 애플리케이션 404만 작업을 기록하며, 마지막 경우에는 find:999999가 남습니다.

이 기록용 서비스는 매핑을 관찰하기 위한 최소 fixture입니다. replace는 기존 게시글을 찾지 않고 posts.put으로 저장하므로 없는 ID도 새 항목이 됩니다. GET의 없는 ID가 404였다는 사실만으로 PUT도 같은 정책이라고 결론 내릴 수 없습니다. 실제 교체 API가 기존 리소스를 요구한다면 서비스에서 존재 여부·권한·동시 수정 정책을 별도로 구현하고 그 계약을 검증해야 합니다.


경로 구체성과 등록 충돌

/api/posts/search와 /api/posts/{postId}는 서로 다른 패턴입니다. 고정 문자열 search가 변수 한 칸보다 구체적이므로 테스트는 search() 선택을 직접 확인합니다.

완전히 같은 조건의 두 메서드는 후보 비교 문제가 아니라 등록 불변식 위반입니다. DuplicateMappingController는 독립형 MVC 구성을 만드는 순간 IllegalStateException과 Ambiguous mapping으로 실패합니다. 서로 다른 패턴이 요청 시점에 같은 최적 점수를 얻는 런타임 모호성과 이 시작 단계 중복을 한 문제로 합치지 않습니다.


추가 조건과 Spring 7 API 버전

params와 headers는 실제 요청 계약이 다를 때 사용할 수 있는 일반 매핑 조건입니다. 기능 플래그나 임의 스위치를 수십 개의 헤더 조건으로 숨기면 같은 URI의 동작을 추적하기 어려워집니다.

Spring Framework 7의 API 버전 조건은 @RequestMapping(version = "2")처럼 선언하고 애플리케이션에 ApiVersionStrategy를 구성해 요청 헤더, 쿼리 파라미터, 경로 세그먼트 또는 미디어 타입 파라미터 중 버전 출처를 하나 정합니다. version 속성만 붙이거나 headers = "X-Api-Version=2"를 버전 전략 전체로 부르지 않습니다. 누락·유효하지 않은 버전과 지원 범위 정책도 같은 전략과 통합 테스트에서 고정합니다.


독립형 MockMvc가 증명하지 않는 것

standaloneSetup은 이 컨트롤러와 필요한 MVC 인프라만 좁게 구성합니다. 따라서 다음을 증명하지 않습니다.

  • 실제 Boot 애플리케이션 컨텍스트의 컨트롤러 탐색
  • 라이브 Servlet 컨테이너의 경로 처리
  • 프록시·TLS·인그레스의 정규화와 우회 차단
  • Spring Security HttpFirewall과 보안 필터 체인
  • 운영 구성에서 발견되는 전체 메시지 컨버터

인코딩된 슬래시, 경로 파라미터, 중복 슬래시를 포함한 정규화는 프록시·컨테이너·보안 체인을 모두 통과하는 배포 테스트로 확인합니다. 이 문서의 404·405·415·406 결과를 그 경계의 증거로 확대하지 않습니다.

독립 확인

아래 요청은 각각 게시글 42만 가진 새 원 fixture에서 실행한다고 가정합니다. GET은 Accept: application/json을 보내며, 두 PUT은 Content-Type: application/json과 정상 ReplacePostRequest JSON 본문을 보냅니다. 프록시·보안 필터·추가 어드바이스는 끼워 넣지 않습니다.

  1. PUT /api/posts/not-a-number, Accept: application/json
  2. PUT /api/posts/42, Accept: application/xml
  3. GET /api/posts/search
  4. GET /api/posts/999999

각각의 상태, 실패 또는 선택 경계, operations()에 남을 목록을 적으세요. 숫자로 바꿀 수 없는 search라는 문자열과 not-a-number의 결과가 다른 이유도 설명하세요.

선택·인자·서비스 증거를 나누는 풀이

1번은 PUT 매핑 선택 뒤 long 인자 변환에서 400이며 작업 목록은 비어 있습니다. 2번은 JSON 응답을 허용하지 않아 produces 매핑에서 406이고 목록도 비어 있습니다. 정상 JSON 요청 본문을 보냈다는 사실은 XML Accept를 맞춰 주지 않습니다.

3번은 고정 /search 패턴이 변수 패턴보다 구체적이어서 200과 [search]를 얻습니다. 이 메서드에는 숫자 경로 변수 인자가 없습니다. 4번은 숫자 인자 999999를 만든 뒤 find가 호출되지만 게시글이 없어 애플리케이션 404와 [find:999999]가 남습니다. 같은 404라도 경로 후보 없음과 조사할 경계가 다릅니다.

각 요청을 새 fixture에서 시작하므로 작업 목록을 서로 누적하지 않습니다. 이 예상은 원 컨트롤러·기록 서비스와 매핑 계약에서 계산하며, 새로운 조합의 실제 MVC 실행과 구분합니다.


연습 문제

별도 MemberPostController에 /api/members/{memberId}/posts/{postId} 조회를 설계하세요. 두 경로 변수 이름을 명시하고 숫자 변환 실패 400, 게시글 없음 404, 메서드 오류 405, XML Accept 406을 각각 검증합니다.

다른 회원의 리소스 존재를 숨겨야 하면 일관된 404를, 권한 부족을 공개하는 관리자 계약이라면 403을 선택할 수 있습니다. 테스트는 상태만 보지 말고 서비스가 받은 두 ID와 권한 검사 호출도 확인합니다. 기존 /api/posts 클래스 매핑 아래에 절대 경로처럼 붙여 의도치 않은 결합 경로를 만들지 않습니다.


공식 근거

다음 문서에서는 매핑된 핸들러의 쿼리·폼 파라미터를 스칼라와 객체로 바인딩할 때 기본값, 필수, 컬렉션, 중첩 필드, 과다 바인딩을 통제합니다.

변환·본문 읽기·저장 정책을 구별하는 연습

이 문서의 기본 standalone MVC 설정과 원 RecordingPostService를 사용하며 예외 어드바이스·보안 필터·사용자 정의 컨버터는 없습니다. 처음에는 게시글 42만 있습니다. 모든 요청은 Accept: application/json을 보내고 PUT은 Content-Type: application/json을 보냅니다.

  1. 각각 새 fixture에서 GET /api/posts/0과 GET /api/posts/9223372036854775808을 보냅니다. 실패 단계·예상 상태·작업 목록을 구별하세요.
  2. 새 fixture에서 PUT /api/posts/42의 본문으로 닫는 중괄호가 빠진 {"title":"새 제목","content":"새 내용"을 보냅니다. 415와 400 중 무엇이 예상되며 replace:42가 기록되나요?
  3. 하나의 새 fixture에서 PUT /api/posts/999999에 정상 본문 {"title":"새 제목","content":"새 내용"}을 보내고, 이어 같은 ID를 GET합니다. 두 응답 상태와 마지막 누적 작업 목록을 구하세요. 실제 서비스도 없는 게시글을 생성해야 한다는 결론이 맞나요?
정답과 단계별 근거
  1. 0은 변환 가능하므로 find:0이 기록되고, 항목이 없어 애플리케이션 404입니다. 최댓값 초과 문자열은 인자 변환에서 400이며 작업 목록은 비어 있습니다. 변환 성공을 양수·존재 검사와 혼동하지 않습니다.
  2. JSON 미디어 타입 조건은 맞지만 본문을 읽을 수 없어 기본 예외 처리에서 400입니다. 컨트롤러 메서드와 서비스는 아직 호출되지 않아 목록은 비어 있습니다. 415는 이 사례의 JSON 문법 오류를 설명하는 상태가 아닙니다.
  3. 이 fixture의 PUT은 200이며 replace:999999를 기록하고 항목을 저장합니다. 후속 GET도 200이고, 누적 목록은 [replace:999999, find:999999]입니다. 이는 원 fixture의 put 동작에서 나온 결과이며 운영 리소스의 생성·교체·권한 계약을 확정하지 않습니다.

상태와 호출 경계는 원 MVC 구성 및 기본 예외 처리에 대한 예상입니다. 독립 JDK의 숫자 변환·Map 연산 확인은 실제 MVC·JSON 컨버터·HTTP 요청 실행을 대신하지 않습니다.