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

안동민 개발노트

본문 시작
6장 : Spring MVC 요청·응답

RequestMapping 조건

게시판 요청 매핑 조건과 경로 변수를 설계하고 400·404·405·406·415 응답을 구분합니다.

@RequestMapping은 URL 문자열 하나를 등록하는 애노테이션이 아닙니다.

경로, HTTP 메서드, 요청 파라미터, 헤더, consumes, produces 조건이 모두 맞아야 핸들러 후보가 됩니다.

조건을 컨트롤러 본문의 if문으로 옮기면 DispatcherServlet이 405·415·406을 정확히 만들 기회를 잃습니다.


클래스·메서드 매핑 역할

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

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

import org.springframework.http.ResponseEntity;
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;

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

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

    @GetMapping("/{postId}")
    PostResponse get(
            @PathVariable long postId) {
        return PostResponse.from(
                service.required(postId));
    }

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

클래스 수준 produces가 두 메서드 모두의 응답 표현 조건이 됩니다.

PUT의 consumes는 요청 JSON만 허용합니다.

GET에는 요청 본문이 없으므로 consumes를 붙이지 않습니다.

메서드와 리소스 의미가 드러나는 합성 애노테이션 @GetMapping·@PutMapping을 사용합니다.

같은 메서드에 여러 매핑 애노테이션을 겹쳐 붙여 합성될 것이라 기대하지 않습니다.


경로 변수

/api/posts/42의 42는 리소스 동일성입니다.

@PathVariable long postId로 변환되며 숫자가 아니면 컨트롤러 호출 전 타입 변환에서 400이 됩니다.

존재하지 않는 숫자 ID는 애플리케이션 쿼리가 404로 바꿉니다.

두 실패를 합치지 않습니다.

  • /api/posts/not-a-number: URI 변수 형식 오류 → 400
  • /api/posts/999999: 형식은 맞지만 리소스 없음 → 404

ID 범위를 long 변환만으로 검증하지 않고 양수 여부 같은 애플리케이션 규칙도 둡니다.

클라이언트가 데이터베이스 순서를 추측할 수 있다는 점과 인가는 별도 문제입니다.


메서드·미디어 타입 조건

프레임워크 7 RestTestClient로 매핑 실패를 컨트롤러 호출 없이 확인합니다.

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

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;

import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;

class RequestMappingConditionsTest {
    @Test
    void method_content_type_accept_조건을_구분한다() {
        var invocations = new AtomicInteger();
        var service = new RecordingPostService(
                invocations, post(42L));
        var client = RestTestClient
                .bindToController(
                        new PostCommandController(service))
                .build();

        client.get()
                .uri("/api/posts/42")
                .accept(APPLICATION_JSON)
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.id").isEqualTo(42);

        client.post()
                .uri("/api/posts/42")
                .exchange()
                .expectStatus().isEqualTo(405)
                .expectHeader()
                .value("Allow", value ->
                        assertThat(value)
                                .contains("GET")
                                .contains("PUT"));

        client.put()
                .uri("/api/posts/42")
                .contentType(MediaType.TEXT_PLAIN)
                .body("content=plain-text")
                .exchange()
                .expectStatus().isEqualTo(415);

        client.get()
                .uri("/api/posts/42")
                .accept(MediaType.APPLICATION_XML)
                .exchange()
                .expectStatus().isEqualTo(406);

        assertThat(invocations.get()).isEqualTo(1);
    }
}

오직 첫 GET만 서비스를 호출했습니다.

405·415·406은 매핑과 컨버터 경계에서 거부되었습니다.

415가 매핑의 consumes 조건이나 컨버터 선택 중 어느 단계인지 상세 로그로 확인할 수 있지만 컨트롤러가 실행되지 않는다는 계약은 같습니다.

실행 결과
RequestMappingConditionsTest
  > method_content_type_accept_조건을_구분한다() PASSED
GET JSON = 200
POST same path = 405, Allow GET·PUT
PUT text/plain = 415
GET application/xml = 406
service invocations = 1
BUILD SUCCESSFUL

헤더·파라미터 조건

@GetMapping(params = "summary")headers = "X-Api-Version=2"로 핸들러를 나눌 수 있습니다.

이 기능은 정확한 요청 조건을 매핑에 반영하지만 지나치면 동일 URI의 동작을 찾기 어렵습니다.

API 버전은 경로, 미디어 타입, 헤더 방식 중 클라이언트와 게이트웨이가 관찰하기 쉬운 표준을 하나 정합니다.

임의 헤더 조건을 수십 개 만들지 않습니다.

기능 플래그는 핸들러 매핑보다 서비스 정책에서 처리할 수도 있습니다.

쿼리 파라미터 존재 여부로 전혀 다른 리소스를 반환하기보다 선택적 필터는 한 핸들러에서 타입이 지정된 쿼리 객체로 받습니다.

응답 스키마 자체가 달라진다면 URI나 표현 계약을 분리합니다.


경로 패턴 충돌

/api/posts/{postId}/api/posts/search가 함께 있으면 리터럴 경로가 변수 경로보다 구체적이라 선택될 수 있지만 모호한 경로를 계속 늘리면 유지가 어렵습니다.

컬렉션 작업은 /api/post-searches 같은 별도 리소스로 모델링할 수 있습니다.

파일 확장자를 미디어 타입 협상에 쓰는 관행은 URI와 표현이 결합되고 확장자 기반 보안 문제가 생길 수 있습니다.

Accept 또는 명시적인 다운로드 URI를 사용합니다.

후행 슬래시와 대소문자 구분을 클라이언트가 임의로 기대하지 않게 정규 URI를 정하고 필요하면 리다이렉트합니다.

프록시와 애플리케이션의 경로 정규화 규칙이 다르면 우회가 생길 수 있으므로 인코딩된 슬래시, 점 경로 조각, 중복 슬래시를 종단 간에 확인합니다.


시작 시 매핑 충돌

동일한 조건의 핸들러가 둘이면 등록 단계에서 모호한 매핑 오류가 나야 합니다.

한쪽 빈을 우연히 먼저 등록해 덮어쓰도록 허용하지 않습니다.

충돌원인개선
같은 메서드+경로컨트롤러 중복리소스 책임 통합
변수 패턴 겹침의미 없는 일반 경로리터럴·계층 재설계
produces만 차이협상 의도응답 스키마와 테스트 명시
프로필별 컨트롤러 둘조건 누락구성에서 하나만 선택

연습 문제

GET /api/members/{memberId}/posts/{postId}를 추가하고 경로의 회원와 실제 게시글 책임 주체가 다르면 404 또는 403 정책을 적용하세요.

숫자 변환 실패 400, 게시글 없음 404, 메서드 오류 405, Accept: application/xml 406도 함께 검증합니다.

해설 보기

컨트롤러는 두 경로 변수를 모두 타입이 지정된 파라미터로 받고 쿼리에 넘깁니다.

@GetMapping(
        "/api/members/{memberId}/posts/{postId}")
PostResponse get(
        @PathVariable long memberId,
        @PathVariable long postId) {
    return PostResponse.from(
            query.requiredForMember(
                    memberId, postId));
}

다른 회원의 리소스 존재를 숨길 보안 요구가 있으면 404를 일관되게 사용합니다.

관리자 API처럼 권한 부족을 명시해야 하면 403을 선택할 수 있습니다.

테스트는 상태뿐 아니라 쿼리가 받은 두 ID와 권한 검사를 확인합니다.

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