RequestMapping 조건
RequestMapping 조건의 교집합과 부분 일치 실패를 실행해 400·404·405·406·415의 경계를 구분합니다.
@RequestMapping은 URL 문자열 하나만 등록하지 않습니다. 경로와 HTTP 메서드, 요청 파라미터, 헤더, consumes, produces가 모여 하나의 매핑 조건을 이루며, 적용되는 조건을 모두 만족하는 매핑 하나가 선택되어야 핸들러가 호출됩니다.
매핑 조건의 교집합과 부분 일치가 404·405·415·406을 가른다
매핑 실패·인자 400·호출 뒤 404/200의 마지막 도달 경계를 보여 주되 조건 평가의 전역 순서는 고정하지 않는다.
FIXTURE-SCOPED PARTIAL MATCH · NO HANDLER INVOCATION
매핑 조건의 교집합과 부분 일치가 404·405·415·406을 가른다
고정 fixture의 구체 요청을 비교해 handler 호출 전 매핑·인자 변환 실패와 handler 진입 뒤 조회 실패를 구분합니다. HTTP 상태와 서비스 호출 증거를 함께 읽습니다.
오른쪽 우회선은 조건을 통과한 경로입니다. 매핑 실패나 변환 실패 노드를 지난 뒤 성공하는 순서가 아닙니다. 이 비교는 고정 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-SCOPED PARTIAL MATCH · NO HANDLER INVOCATION
매핑 조건의 교집합과 부분 일치가 404·405·415·406을 가른다
고정 fixture의 구체 요청을 비교해 handler 호출 전 매핑·인자 변환 실패와 handler 진입 뒤 조회 실패를 구분합니다. HTTP 상태와 서비스 호출 증거를 함께 읽습니다.
오른쪽 우회선은 조건을 통과한 경로입니다. 매핑 실패나 변환 실패 노드를 지난 뒤 성공하는 순서가 아닙니다. 이 비교는 고정 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, PUT의 Content-Type이 다른 415, Accept가 맞지 않는 406 가운데 하나로 끝나며 서비스는 호출되지 않습니다. 조건이 맞으면 선택된 핸들러의 인자를 해석하고, 숫자 변환에 실패하면 호출 전에 400이 됩니다. 인자가 준비되면 컨트롤러와 서비스가 호출되어, 없는 게시글은 find 기록과 함께 404가 되고 나머지는 200이 됩니다. 404·405·415·406의 순서는 이 예제에서 부분 일치 실패를 진단하는 모델일 뿐, 프레임워크가 모든 RequestMappingInfo 후보를 언제나 이 순서로 확인한다는 뜻은 아닙니다.
실행 기준을 정합니다
예제는 Java 25와 Spring Boot 4.1.2 BOM, Spring Framework 7.0.10, JUnit 6.0.3을 하나의 의존성 그래프로 씁니다. 모든 소스는 경로가 붙은 완전한 파일이며, 아래 Gradle 프로젝트로 그대로 실행할 수 있습니다.
rootProject.name = 'request-mapping-conditions'plugins {
id 'java'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation platform(
'org.springframework.boot:spring-boot-dependencies:4.1.2')
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
testImplementation platform(
'org.springframework.boot:spring-boot-dependencies:4.1.2')
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 플러그인은 실행 가능한 애플리케이션을 패키징할 때 필요합니다. 이 예제는 컨트롤러를 테스트할 뿐이므로 Java 플러그인과 Boot BOM만 씁니다. -parameters를 켜더라도 경로 변수 이름은 애노테이션에 명시해, 빌드 옵션에 의미가 숨어들지 않게 합니다.
도메인과 웹 타입을 완결합니다
매핑을 시험하는 코드라도 생략된 타입에 기대지 않습니다. 도메인 값과 서비스 포트는 Spring MVC를 모르고, 웹 요청과 응답 타입이 둘 사이를 명시적으로 변환합니다.
package board.post;
public record Post(
long id,
String title,
String content) {}package board.post;
public record ReplacePostCommand(
String title,
String content) {}package board.post;
import java.util.Optional;
public interface PostService {
Optional<Post> find(long postId);
Post replace(
long postId,
ReplacePostCommand command);
Post search();
}package board.web;
import board.post.ReplacePostCommand;
public record ReplacePostRequest(
String title,
String content) {
ReplacePostCommand toCommand() {
return new ReplacePostCommand(
title,
content);
}
}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를 메서드 수준에서 다시 선언하면 클래스 수준의 값에 더해지는 것이 아니라 메서드의 값이 우선합니다.
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은 경고를 남기고 첫 매핑만 쓰므로, 한 요소에는 매핑 하나만 둡니다.
부분 일치 실패와 애플리케이션 실패를 나눕니다
이 예제에서 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 | 핸들러 진입 뒤 리소스 조회 | 404 | find: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의 독립형 예제에서 첫 번째 404는 NoHandlerFoundException으로 해석됩니다. 마지막 404는 애플리케이션이 ResponseStatusException으로 명시한 결과입니다. 정적 리소스 조회 실패나 다른 404까지 이 예제가 대표하지는 않습니다.
아홉 조건을 실행으로 고정합니다
앞의 여덟 요청 테스트는 새 컨트롤러와 기록용 서비스로 시작하고, 중복 등록 테스트는 별도의 충돌 컨트롤러를 구성합니다. 실패 응답에서는 상태뿐 아니라 해석된 예외와 서비스가 호출되지 않았다는 것도 함께 확인합니다. Allow는 헤더 문자열의 순서가 아니라 이 예제에 등록된 GET과 PUT의 집합으로 검사합니다.
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() {}
}
}이 테스트는 아홉 메서드를 서로 독립된 예제로 실행합니다. 핸들러 호출 전에 실패하는 테스트는 작업을 기록하지 않습니다. 정상 GET과 PUT, search, 그리고 핸들러 진입 뒤의 애플리케이션 404만 작업을 기록하며, 마지막 경우에는 find:999999가 남습니다.
이 기록용 서비스는 매핑을 확인하기 위한 최소한의 예제입니다. replace는 기존 게시글을 찾지 않고 posts.put으로 저장하므로 없는 ID도 새 항목이 됩니다. GET에서 없는 ID가 404였다고 해서 PUT도 같은 정책이라고 결론 내릴 수는 없습니다. 실제 교체 API가 기존 리소스를 요구한다면, 서비스에서 존재 여부와 권한, 동시 수정 정책을 따로 구현하고 테스트해야 합니다.
경로 구체성과 등록 충돌
/api/posts/search와 /api/posts/{postId}는 서로 다른 패턴입니다. 고정 문자열 search가 변수 한 칸보다 구체적이므로, 테스트는 search()가 선택되는지 직접 확인합니다.
조건이 완전히 같은 두 메서드는 후보를 비교할 문제가 아니라 등록 규칙 위반입니다. DuplicateMappingController는 독립형 MVC 구성을 만드는 순간 IllegalStateException과 Ambiguous mapping으로 실패합니다. 서로 다른 패턴이 요청 시점에 같은 최적 점수를 얻는 실행 중의 모호성과, 시작 단계의 이 중복을 한 문제로 합치지 않습니다.
더 깊이: PathPattern이 구체성을 점수로 매기는 방법
Spring MVC는 6.0부터 미리 해석한 PathPattern을 기본 경로 매칭 방식으로 씁니다. 요청 매핑 문서에 따르면 이 방식은 요청 경로도 미리 해석한 PathContainer와 비교하므로 인코딩과 경로 파라미터를 효과적으로 다루고, 문자열끼리 비교하는 예전 AntPathMatcher 방식은 이제 더 이상 권장되지 않는(deprecated) 것으로 표시되어 있습니다.
여러 패턴이 같은 요청에 맞을 때는 PathPattern.SPECIFICITY_COMPARATOR가 순서를 정합니다. 요청 매핑 문서는 URI 변수와 단일 와일드카드를 1점, 이중 와일드카드를 2점으로 세는 규칙을 AntPathMatcher의 비교기와 함께 묶어 설명하지만, Spring Framework 7.0.10의 PathPattern 소스는 조금 다르게 셉니다. 먼저 이중 와일드카드나 {*path}처럼 남은 경로 전체를 받는 패턴을 언제나 뒤로 보내고, 둘 다 그런 패턴이면 더 긴 쪽을 앞에 둡니다. 나머지 패턴은 URI 변수 하나에 1점, * 같은 와일드카드 하나에 100점을 주어 점수가 낮은 쪽을 고르고, 점수가 같으면 변수 하나를 한 글자로 센 길이가 더 긴 쪽을 고릅니다. 그래서 /api/posts/search는 0점, /api/posts/{postId}는 1점이 되어 앞의 패턴이 이깁니다.
끝의 슬래시도 버전에 따라 달라졌습니다. Spring Framework 6.0 릴리스 노트는 끝 슬래시 매칭 설정을 더 이상 권장하지 않는 것으로 바꾸고 기본값을 false로 정했다고 밝힙니다. 5.x에서는 /api/posts/42/도 /api/posts/{postId}에 맞았지만, 6.0부터는 별도로 설정하지 않으면 맞지 않는 다른 경로입니다.
추가 조건과 Spring 7 API 버전
params와 headers는 실제 요청의 형태가 다를 때 쓸 수 있는 일반 매핑 조건입니다. 기능 플래그나 임의의 스위치를 수십 개의 헤더 조건으로 숨기면, 같은 URI의 동작을 추적하기 어려워집니다.
Spring Framework 7의 API 버전 조건은 @RequestMapping(version = "2")처럼 선언하고, 애플리케이션에 ApiVersionStrategy를 구성해 요청 헤더나 쿼리 파라미터, 경로 세그먼트, 미디어 타입 파라미터 가운데 버전을 읽을 곳을 하나 정합니다. version 속성만 붙이거나 headers = "X-Api-Version=2"를 버전 전략 전체라고 부르지 않습니다. 버전이 없거나 잘못된 경우와 지원 범위의 정책도 같은 전략과 통합 테스트에서 정합니다.
독립형 MockMvc가 보여 주지 않는 것
standaloneSetup은 이 컨트롤러와 필요한 MVC 구성 요소만 좁게 구성합니다. 따라서 다음은 보여 주지 않습니다.
- 실제 Boot 애플리케이션 컨텍스트의 컨트롤러 탐색
- 실제 서블릿 컨테이너의 경로 처리
- 프록시와 TLS, 인그레스의 정규화와 우회 차단
- Spring Security의
HttpFirewall과 보안 필터 체인 - 운영 구성에서 발견되는 전체 메시지 컨버터
인코딩된 슬래시와 경로 파라미터, 중복 슬래시를 포함한 정규화는 프록시와 컨테이너, 보안 체인을 모두 거치는 배포 테스트로 확인합니다. 이 글의 404·405·415·406 결과를 그 범위까지 넓혀 해석하지 않습니다.
이 글의 내용은 매핑 조건과 메서드 수준 consumes·produces의 재정의, 복수 매핑 애노테이션의 경고, 경로 구체성을 설명하는 Spring MVC 요청 매핑, Spring Framework 7 API 버전 관리와 RequestMapping.version API, MockMvc 설정 선택과 MockMvc 대 종단 간 테스트, 보안 경로의 정규화를 다루는 Spring Security HttpFirewall을 기준으로 합니다.
정리
- 매핑 조건은 경로와 메서드, 파라미터, 헤더,
consumes,produces의 교집합이며, 메서드 수준의consumes와produces는 클래스 수준의 값을 대신합니다. - 경로가 없으면 404, 메서드가 다르면 405와
Allow, 요청 본문 형식이 다르면 415, 응답 형식을 맞출 수 없으면 406이고, 모두 컨트롤러를 호출하기 전에 끝납니다. - 핸들러가 선택된 뒤의 인자 변환 실패는 400이고, 핸들러 안에서 리소스를 찾지 못한 404는 매핑의 404와 다른 단계입니다.
- 변환 성공이 ID의 유효성을 보장하지 않고, 미디어 타입 조건이 맞아도 잘린 JSON은 400입니다.
- 고정 문자열 경로는 변수 경로보다 구체적이며, 완전히 같은 매핑은 시작 단계에서
Ambiguous mapping으로 실패합니다.
연습 문제
1. 네 요청의 결과
아래 요청은 각각 게시글 42만 가진 새 예제에서 실행합니다. GET은 Accept: application/json을 보내고, 두 PUT은 Content-Type: application/json과 정상적인 ReplacePostRequest JSON 본문을 보냅니다. 프록시와 보안 필터, 추가 어드바이스는 없습니다. ① PUT /api/posts/not-a-number와 Accept: application/json, ② PUT /api/posts/42와 Accept: application/xml, ③ GET /api/posts/search, ④ GET /api/posts/999999의 상태와 멈춘 단계 또는 선택된 핸들러, operations()에 남을 목록을 적으세요. 숫자로 바꿀 수 없는 search라는 문자열과 not-a-number의 결과가 다른 이유도 설명하세요.
해설 보기
① PUT 매핑이 선택된 뒤 long 인자의 변환에서 400이 되며, 작업 목록은 비어 있습니다. ② JSON 응답을 허용하지 않으므로 produces 매핑에서 406이고 목록도 비어 있습니다. 정상적인 JSON 요청 본문을 보냈다고 해서 XML Accept가 맞춰지지는 않습니다.
③ 고정된 /search 패턴이 변수 패턴보다 구체적이어서 200과 [search]를 얻습니다. 이 메서드에는 숫자 경로 변수 인자가 없으므로 변환할 것이 없습니다. ④ 숫자 인자 999999를 만든 뒤 find가 호출되지만 게시글이 없어 애플리케이션의 404와 [find:999999]가 남습니다. 같은 404라도 경로 후보가 없는 경우와는 조사할 단계가 다릅니다. 각 요청을 새 예제에서 시작하므로 작업 목록은 누적되지 않으며, 이 풀이는 컨트롤러와 기록용 서비스의 코드를 따라간 예상이므로 테스트를 실행해 확인합니다.
2. 변환과 본문 읽기, 저장 정책
이 글의 기본 독립형 MVC 설정과 RecordingPostService를 쓰며, 예외 어드바이스와 보안 필터, 사용자 정의 컨버터는 없습니다. 처음에는 게시글 42만 있고, 모든 요청은 Accept: application/json을, PUT은 Content-Type: application/json을 보냅니다. ① 각각 새 예제에서 GET /api/posts/0과 GET /api/posts/9223372036854775808을 보냅니다. 실패한 단계와 예상 상태, 작업 목록을 구분하세요. ② 새 예제에서 PUT /api/posts/42의 본문으로 닫는 중괄호가 빠진 {"title":"새 제목","content":"새 내용"을 보냅니다. 415와 400 가운데 무엇이 예상되며 replace:42가 기록되나요? ③ 하나의 새 예제에서 PUT /api/posts/999999에 정상 본문 {"title":"새 제목","content":"새 내용"}을 보내고, 이어 같은 ID를 GET합니다. 두 응답의 상태와 마지막 누적 작업 목록을 구하세요. 실제 서비스도 없는 게시글을 만들어야 한다는 결론이 맞나요?
해설 보기
① 0은 변환할 수 있으므로 find:0이 기록되고, 항목이 없어 애플리케이션의 404입니다. 최댓값을 넘는 문자열은 인자 변환에서 400이며 작업 목록은 비어 있습니다. 변환 성공을 양수 검사나 존재 검사와 혼동하지 않습니다.
② JSON 미디어 타입 조건은 맞지만 본문을 읽을 수 없어 기본 예외 처리에서 400입니다. 컨트롤러 메서드와 서비스는 아직 호출되지 않았으므로 목록은 비어 있습니다. 415는 이 경우의 JSON 문법 오류를 설명하는 상태가 아닙니다.
③ 이 예제의 PUT은 200이며 replace:999999를 기록하고 항목을 저장합니다. 이어지는 GET도 200이고, 누적 목록은 [replace:999999, find:999999]입니다. 이것은 예제의 put 동작에서 나온 결과일 뿐, 운영 리소스의 생성과 교체, 권한 정책을 정하지 않습니다. 이 상태와 호출 단계는 MVC 구성과 기본 예외 처리를 따라간 예상이므로, 실제 MVC와 JSON 컨버터를 거치는 테스트로 확인합니다.
3. 회원별 게시글 조회 설계
별도의 MemberPostController에 /api/members/{memberId}/posts/{postId} 조회를 설계합니다. 숫자 변환 실패 400, 게시글 없음 404, 메서드 오류 405, XML Accept의 406을 각각 검증하려면 매핑과 테스트를 어떻게 구성해야 할까요? 다른 회원의 게시글을 요청했을 때의 상태 코드도 정해 보세요.
해설 보기
@RequestMapping("/api/members/{memberId}/posts")를 붙인 별도 클래스에 @GetMapping(path = "/{postId}", produces = MediaType.APPLICATION_JSON_VALUE)를 두고, 인자는 @PathVariable("memberId") long memberId와 @PathVariable("postId") long postId처럼 이름을 명시합니다. 기존 /api/posts 클래스 매핑 아래에 절대 경로처럼 붙여 의도하지 않은 결합 경로를 만들지 않습니다.
테스트는 네 가지로 나눕니다. 숫자가 아닌 memberId나 postId는 400이고 서비스 호출이 없습니다. 없는 게시글은 서비스가 두 ID를 받은 기록과 함께 404입니다. POST는 405이고 Allow에 GET이 들어 있으며 서비스 호출이 없습니다. Accept: application/xml은 406이고 역시 서비스 호출이 없습니다. 다른 회원의 게시글이 있다는 사실을 숨겨야 한다면 없는 게시글과 같은 404를, 권한 부족을 알려도 되는 관리자용 API라면 403을 고를 수 있습니다. 어느 쪽이든 상태만 보지 말고 서비스가 받은 두 ID와 권한 검사 호출을 함께 확인합니다.
다음 글 요청 파라미터 바인딩에서는 매핑된 핸들러가 쿼리와 폼 파라미터를 스칼라와 객체로 바인딩할 때, 기본값과 필수 여부, 컬렉션, 과다 바인딩을 어떻게 통제하는지 다룹니다.