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

안동민 개발노트

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

MVC 요청 처리 과정

게시판 MVC 요청의 404·405·406·415·400·409·500을 매핑, 인자, 컨버터, 검증, 컨트롤러, 서비스, 응답 단계로 역추적하고 회귀 표로 검증합니다.

완성된 CRUD가 실패할 때 애노테이션을 하나씩 바꾸는 방식은 다른 상태를 새로 만들 뿐 원인을 좁히지 못합니다. 클라이언트가 본 상태와 응답 헤더에서 시작해 컨트롤러가 호출됐는지, 매핑·바인딩·컨버터·업무 중 어느 경계에서 멈췄는지 역순으로 확인합니다.


컨트롤러 이전 실패

다음 상태는 흔히 컨트롤러 메서드 본문에 들어오기 전에 만들어집니다.

Status먼저 볼 경계대표 원인
404HandlerMapping경로·컨텍스트 경로 불일치
405HandlerMapping경로는 있으나 메서드 불일치
406produces·컨버터Accept 만족 표현 없음
415consumes·컨버터Content-Type 리더 없음
400인자 리졸버경로/쿼리 타입 변환
400JSON 컨버터잘못된 형식 본문·필드 타입
400검증DTO 제약 조건 실패

컨트롤러 중단점이 오지 않는 것이 프레임워크 오류라는 뜻은 아닙니다. 앞 경계가 규칙대로 잘못된 요청을 거부한 것입니다. 매핑 디버그, 요청 Content-Type·Accept, ProblemDetail 코드를 확인합니다.

컨트롤러 이후 실패

컨트롤러가 호출된 뒤 404는 서비스가 리소스를 찾지 못한 경우, 409는 중복·현재 상태 충돌, 412는 오래된 버전일 수 있습니다. 처리 결과 객체를 컨버터가 쓰지 못하면 500이 될 수 있고 뷰 렌더링 중 템플릿 오류도 500입니다.

예외 타입을 상태로 바꾸는 어드바이스가 없거나 너무 넓은 Exception.class 핸들러가 모든 오류를 400으로 바꾸면 진단 정보가 사라집니다. 예상 애플리케이션 예외만 안정적인 상태와 코드로 변환하고 예상하지 못한 오류는 요청 ID와 함께 500으로 기록합니다.

엔드포인트 실패표

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

import jakarta.validation.Valid;
import java.util.concurrent.atomic.AtomicInteger;
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("/api/posts")
class PipelineProbeController {
    private final AtomicInteger invocations;
    private final PostService service;

    PipelineProbeController(
            AtomicInteger invocations,
            PostService service) {
        this.invocations = invocations;
        this.service = service;
    }

    @PutMapping(
            path = "/{id}",
            consumes = "application/json",
            produces = "application/json")
    PostResponse replace(
            @PathVariable long id,
            @Valid @RequestBody
                    ReplacePostRequest request) {
        invocations.incrementAndGet();
        return PostResponse.from(
                service.replace(
                        id, request.toCommand()));
    }
}

호출 카운터는 테스트 관찰용입니다. 운영 컨트롤러에 전역 변경 가능한 카운터를 두지 않고 메트릭 레지스트리를 사용합니다.

src/test/java/board/web/MvcPipelineFailureTableTest.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 MvcPipelineFailureTableTest {
    @Test
    void mapping_converter_validation_service_실패를_구분한다() {
        var invocations = new AtomicInteger();
        var service =
                new ConfigurablePostService()
                        .withPost(post(42L))
                        .duplicateTitle("duplicate");
        var client = RestTestClient
                .bindToController(
                        new PipelineProbeController(
                                invocations, service))
                .configureServer(builder ->
                        builder.setControllerAdvice(
                                new ApiInputExceptionHandler(),
                                new CrudExceptionHandler(),
                                new DuplicateExceptionHandler()))
                .build();

        request(client, "/api/missing/42",
                APPLICATION_JSON, validJson())
                .expectStatus().isNotFound();

        client.post()
                .uri("/api/posts/42")
                .exchange()
                .expectStatus().isEqualTo(405);

        request(client, "/api/posts/42",
                MediaType.TEXT_PLAIN, "content=plain-text")
                .expectStatus().isEqualTo(415);

        request(client, "/api/posts/42",
                APPLICATION_JSON, "{broken")
                .expectStatus().isBadRequest();

        request(client, "/api/posts/42",
                APPLICATION_JSON, invalidContentJson())
                .expectStatus().isBadRequest();

        request(client, "/api/posts/42",
                APPLICATION_JSON,
                json("duplicate", "중복 게시글 본문"))
                .expectStatus().isEqualTo(409);

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

첫 다섯 요청은 컨트롤러 본문에 도달하지 않습니다. 중복 JSON은 변환과 검증을 통과한 뒤 컨트롤러와 서비스에서 409가 되어 호출이 한 번입니다.

실행 결과
MvcPipelineFailureTableTest
  > mapping_converter_validation_service_실패를_구분한다() PASSED
404 mapping -> controller 0
405 method -> controller 0
415 media type -> controller 0
400 malformed·validation -> controller 0
409 duplicate -> controller 1
BUILD SUCCESSFUL

404 발생 계층

프록시 경로가 없어서 404인지, 애플리케이션 HandlerMapping이 없어서 404인지, 컨트롤러가 리소스 없음으로 404를 반환했는지 본문과 응답 헤더, 접근 로그를 비교합니다.

  • 프록시 404: 애플리케이션 요청 ID 헤더가 없을 수 있음
  • MVC 매핑 404: 애플리케이션에 도착했지만 핸들러 없음
  • 리소스 404: 컨트롤러/서비스가 호출되고 애플리케이션 오류 코드 존재
  • 정적 리소스 404: 리소스 핸들러가 시도했지만 파일 없음

모든 404 본문을 같은 일반 HTML로 바꾸면 API 클라이언트가 리소스 없음 코드를 잃을 수 있습니다. 앞단과 애플리케이션의 오류 표현 계약을 정합니다.

500 진단 기준

500을 400으로 바꿔 숨기기 전에 다음을 확인합니다.

  1. 요청 ID와 가장 안쪽 원인이 무엇인가.
  2. 트랜잭션은 커밋됐는가 롤백됐는가.
  3. 응답이 이미 커밋됐는가.
  4. 클라이언트 재시도가 안전한 메서드인가.
  5. 동일 입력으로 재현 가능한가 외부 의존성인가.

DB 커밋 뒤 응답 직렬화 실패라면 POST는 저장됐을 수 있습니다. 멱등성 키 없이 클라이언트에게 무조건 재시도를 지시하지 않습니다. 컨버터가 지연 엔티티를 읽다가 실패했다면 응답 DTO를 트랜잭션 안에서 완성하는 구조로 바꿉니다.

진단 로그 최소화

로컬 재현에서 매핑과 컨버터 트레이스를 사용할 수 있지만 문제를 찾은 뒤 기본 수준으로 되돌립니다. 원시 인가, 쿠키, 본문, 쿼리 비밀 정보가 로그에 남지 않는지 확인합니다.

회귀 테스트는 상태 표 전체를 유지하되 프레임워크 내부 메시지 문자열을 과도하게 고정하지 않습니다. 안정적인 애플리케이션 코드와 호출 경계, 응답 미디어 타입을 검증합니다.

연습 문제

위 실패 표에 Accept: application/xml 406, 경로 변수 문자열 400, 없는 게시글 404, 오래된 If-Match 412, 예상하지 못한 서비스 예외 500을 추가하세요. 각 요청의 컨트롤러와 서비스 호출 수, ProblemDetail 코드, 요청 ID 헤더를 기록합니다.

해설 보기

매핑·변환·검증 실패는 서비스가 0회, 리소스 없음과 오래된 버전은 서비스가 1회입니다. 예상하지 못한 예외는 어드바이스가 상세 스택을 본문에 넣지 않고 500과 일반 코드, 요청 ID만 반환하게 합니다.

record ExpectedFailure(
        String name,
        RunnableExchange exchange,
        int status,
        int serviceCalls,
        String code) {
}

파라미터화된 테스트로 표를 실행할 수 있지만 각 실패 요청 본문과 예상된 코드가 읽기 어렵게 숨지 않도록 사례 이름과 픽스처를 명시합니다. 진단 절차는 “애노테이션 변경”이 아니라 “마지막으로 통과한 파이프라인 단계 찾기”여야 합니다.

이제 HTTP 메시지에서 Servlet 컨테이너, DispatcherServlet, 매핑·바인딩·컨버터, CRUD까지 하나의 수직 경로가 연결됐습니다. 다음 장에서는 이 MVC API의 검증·오류 응답·파일·비동기 처리 같은 경계를 더 깊게 확장합니다.