RequestMapping 조건
RequestMapping 조건의 교집합과 부분 일치 실패를 실행해 400·404·405·406·415의 경계를 구분합니다.
@RequestMapping은 URL 문자열 하나만 등록하지 않습니다.
경로, HTTP 메서드, 요청 파라미터, 헤더, consumes, produces가 하나의 매핑 조건을 이룹니다. 적용되는 조건을 모두 만족한 매핑 하나가 선택되어야 핸들러가 호출됩니다.
FIXTURE-SCOPED PARTIAL MATCH · NO HANDLER INVOCATION
매핑 조건의 교집합과 부분 일치가 404·405·415·406을 가른다
경로 후보 → method → consumes → produces를 진단 순서로 읽으면 handler 호출 전 실패를 서로 다른 HTTP 계약으로 구분할 수 있습니다.
-
PATH
경로 후보가 없으면 fixture의 mapping 404다
후보가 있으면 HTTP method 조건으로 진행합니다.
-
METHOD
불일치하면 405와 Allow를 반환한다
Allow는 문자열 순서가 아니라 GET·PUT 지원 집합으로 검증합니다.
-
CONSUMES
PUT의 Content-Type 불일치는 415다
GET처럼 consumes 조건이 없는 매핑은 이 조건으로 거부하지 않습니다.
-
PRODUCES
Accept가 JSON을 허용하지 않으면 406이다
응답 표현 조건까지 맞아야 선택 단계로 진행합니다.
-
SELECT
적용되는 모든 조건을 만족한 handler 하나를 호출한다
이 fixture에서는 get() 또는 replace()가 선택됩니다.
이 흐름은 이 문서의 고정 fixture에서 path 후보와 부분 일치 실패를 진단하는 순서입니다. 모든 RequestMappingInfo 후보를 프레임워크가 항상 이 총순서로 순회한다고 주장하지 않으며, 숫자 경로 변수 변환 400과 handler 진입 뒤 리소스 없음 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 프로젝트로 그대로 실행할 수 있습니다.
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.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를 모르고, 웹 요청과 응답 타입이 그 경계를 명시적으로 변환합니다.
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은 경고를 남기고 첫 매핑만 사용하므로 한 요소에는 하나의 매핑 계약만 둡니다.
부분 일치 실패와 애플리케이션 실패를 분리한다
이 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 | 핸들러 진입 뒤 리소스 조회 | 404 | find:999999 |
독립형 Spring Framework 7 fixture에서 첫 404는 NoHandlerFoundException으로 해석됩니다. 마지막 404는 ResponseStatusException으로 명시한 애플리케이션 계약입니다. 정적 리소스 조회 실패나 다른 404 계층까지 이 예제가 대표하지 않습니다.
아홉 조건을 실행으로 고정한다
앞의 여덟 요청 테스트는 새 컨트롤러와 기록용 서비스로 시작하고, 중복 등록 테스트는 별도의 충돌 컨트롤러를 구성합니다. 실패 응답에서는 상태뿐 아니라 해석된 예외와 서비스 호출 부재를 함께 확인합니다. Allow는 헤더 문자열 순서가 아니라 이 fixture에 등록된 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() {}
}
}이 테스트는 아홉 메서드를 서로 독립된 fixture로 실행합니다. 핸들러 호출 전 실패 테스트는 작업을 기록하지 않습니다. 정상 GET·PUT·search와 핸들러 진입 뒤 애플리케이션 404만 작업을 기록하며, 마지막 경우에는 find:999999가 남습니다.
경로 구체성과 등록 충돌
/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 결과를 그 경계의 증거로 확대하지 않습니다.
연습 문제
별도 MemberPostController에 /api/members/{memberId}/posts/{postId} 조회를 설계하세요. 두 경로 변수 이름을 명시하고 숫자 변환 실패 400, 게시글 없음 404, 메서드 오류 405, XML Accept 406을 각각 검증합니다.
다른 회원의 리소스 존재를 숨겨야 하면 일관된 404를, 권한 부족을 공개하는 관리자 계약이라면 403을 선택할 수 있습니다. 테스트는 상태만 보지 말고 서비스가 받은 두 ID와 권한 검사 호출도 확인합니다. 기존 /api/posts 클래스 매핑 아래에 절대 경로처럼 붙여 의도치 않은 결합 경로를 만들지 않습니다.
공식 근거
- Spring MVC 요청 매핑은 매핑 조건, 메서드 수준
consumes·produces재정의, 복수 매핑 애노테이션 경고, 경로 구체성을 설명합니다. - Spring Framework 7 API 버전 관리와
RequestMapping.versionAPI는 버전 조건과ApiVersionStrategy구성의 근거입니다. - MockMvc 설정 선택과 MockMvc 대 종단 간 테스트는 독립형 fixture의 범위를 구분합니다.
- Spring Security
HttpFirewall은 보안 경로 정규화가 별도 배포 경계인 근거입니다.
다음 문서에서는 매핑된 핸들러의 쿼리·폼 파라미터를 스칼라와 객체로 바인딩할 때 기본값, 필수, 컬렉션, 중첩 필드, 과다 바인딩을 통제합니다.