본문 바로가기

MVC 실패 경계 진단

응답 상태와 요청 ID, 컨트롤러·서비스 호출 수로 매핑·인자·본문 변환·검증·업무·응답 쓰기 실패의 마지막 통과 경계를 찾습니다.

MVC 요청이 실패했을 때 애노테이션을 하나씩 바꿔 보면, 원인을 찾기보다 새로운 상태를 만들기 쉽습니다. 먼저 클라이언트가 실제로 받은 상태와 헤더, 본문을 고정하고, 애플리케이션에 도달했다는 기록과 핸들러 선택, 컨트롤러 본문 진입을 차례로 확인합니다. 목표는 예외 이름을 추측하는 것이 아니라, 요청이 마지막으로 통과한 단계를 찾는 것입니다.

응답 증거에서 마지막 통과 경계를 역추적하면 MVC 실패 위치를 좁힐 수 있다

status/header/body 고정 뒤 접근 기록·request ID·handler·controller 증거로 마지막 통과 경계를 좁힌다.

응답과app로그/내부requestID,handler선택,controller본문진입의양성증거를연결해마지막통과경계를찾습니다. fixture의mapping404/405/406/415와변환/read/검증400은controller/service0/0,업무404/409/412/의도한예상밖service500은1/1입니다. body의안정적code를확인하며header부재로proxy를단정하지않습니다. 응답commit과transaction결과를분리하고MockMvc가증명하지않는socket/proxy/partialwrite는별도live증거로확인합니다.

FLOWCHART · FAILURE BOUNDARY · LAST PASSED CHECKPOINT

응답 증거에서 마지막 통과 경계를 역추적하면 MVC 실패 위치를 좁힐 수 있다

status만 원인에 대응시키지 않고 request ID·access log와 handler 선택, controller 진입을 차례로 확인해 다음 조사 경계를 고른다.

응답에서 마지막으로 확인한 경계까지응답status header body를고정한뒤앱access log와내부requestID,handler선택,controller본문진입의증거를순서대로확인합니다. 증거가없다고실행되지않았음을단정하지않고proxy와app기록을대조합니다. 호출수가같아도정확한실패경계는요청조건과안정적code로구분합니다.
관찰한 응답status/header/body
연결 관계관찰한 응답 · status/header/ · body → app 도달 증거 · access log / ID
app 도달 증거access log / ID
연결 관계app 도달 증거 · access log / ID → handler 선택 · mapping 조건
handler 선택mapping 조건
연결 관계handler 선택 · mapping 조건 → controller 진입 · 메서드 첫 줄
controller 진입메서드 첫 줄
응답에서 증거를 연결하는 진단 순서원 데스크톱의 네 조사 단계와 세 관계를 세로로 재배치한다. 응답 status·header·body를 고정한 뒤 앱 도달 상관 증거, handler 선택, controller 본문 진입을 확인한다. 화살표는 조사 증거를 연결하는 순서이지 HTTP 요청이 응답부터 실행되는 순서가 아니다. 증거 부재만으로 미실행을 단정하지 않는다. 실제 fixture의 호출 계수와 안정적인 code를 함께 읽고 응답 commit·transaction·live socket 증거는 별도로 확인한다.상관 확인선택 증거진입 증거관찰한 응답status / headerbody앱 도달 증거access log / IDhandler 선택mapping 조건controller 진입메서드 첫 줄

화살표는 응답에서 증거를 대조하는 조사 순서입니다. 어떤 증거가 없다고 해당 실행이 없었다고 확정하지 않습니다. 아래 조건별 질문·호출 수·안정적인 code와 함께 마지막 확인 경계를 좁힙니다.

  1. DECISION 1 · APP ARRIVAL

    애플리케이션 도달 증거가 있는가?

    아니오 → proxy·Connector·edge/CDN 경계의 응답 주체를 access log와 request ID로 확인합니다. header 부재 하나만으로 proxy 404를 단정하지 않습니다.

    예 → handler 선택 증거를 확인합니다.

  2. DECISION 2 · HANDLER MAPPING

    HandlerMapping이 handler를 선택했는가?

    아니오 → path·method·produces ·consumes 조건에서 이 fixture의 404·405·406·415를 조사합니다.

    예 → controller 본문 진입 여부를 확인합니다.

  3. DECISION 3 · CONTROLLER ENTRY

    controller 본문에 진입했는가?

    아니오 → path variable, request body converter, validation 경계의 400을 조사합니다. 매핑은 되었지만 reader가 없어 생기는 415는 ch6-6의 별도 사례입니다.

    예 → service·domain·exception translation과 response write의 404·409·412·406·500을 조사하고 response commit과 transaction 결과를 따로 확인합니다.

FIXTURE · CONTROLLER 0 / SERVICE 0

같은 호출 수에서도 네 경계를 구분한다

미등록 path 404·다른 method 405·XML Accept 406·text/plain consumes 415는 mapping 조건입니다. 숫자 아닌 path variable·깨진 JSON·DTO 제약 위반은 각각 변환·read·validation의 400입니다. request ID header는 모두 있지만 호출 수만으로 세부 원인을 고르지 않습니다.

FIXTURE · CONTROLLER 1 / SERVICE 1

안정적인 업무 code가 실패를 설명한다

없는 ID404 POST_NOT_FOUND, duplicate제목409 DUPLICATE_TITLE, 오래된 태그412 STALE_VERSION, explode제목500 INTERNAL_ERROR입니다. 500은 새 request ID만 공유하고 내부 예외 문구나 stack trace를 본문에 노출하지 않습니다.

RESPONSE / TRANSACTION / RETRY

응답 쓰기 성공과 DB commit은 별개다

500 조사: request ID로 cause 확인 → response commit·client byte 확인 → 저장소 transaction 결과 확인 → 재시도 안전성 확인. HTTP commit 뒤에는 status/header를 안전하게 바꿀 수 없습니다. 업무 commit 뒤 직렬화·socket write가 실패해도 데이터는 저장되었을 수 있습니다.

MOCKMVC SCOPE

앱 문맥과 실제 socket의 증거를 나눈다

이 suite는 모의 Servlet 요청으로 등록 filter와 MVC 전략을 함께 실행합니다. listening socket·proxy/TLS·연결 종료·partial write·실제 배포 filter 순서는 증명하지 않습니다. 그런 열은 RANDOM_PORT 또는 배포 live HTTP 관찰로 따로 확인합니다.

같은 status라도 마지막으로 확인한 경계가 다를 수 있습니다. header나 로그가 없다는 사실만으로 proxy 404 또는 미실행을 단정하지 않습니다. TRACE는 필요한 route·logger·request ID와 종료 시간을 정하고 비밀 값은 수집하지 않거나 redact합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 응답 status·header·body를 고정한 뒤 앱 access log와 내부 request ID, handler 선택, controller 본문 진입의 증거를 순서대로 확인합니다. 증거가 없다고 실행되지 않았음을 단정하지 않고 proxy와 app 기록을 대조합니다. 호출 수가 같아도 정확한 실패 경계는 요청 조건과 안정적인 code로 구분합니다.
  • 원 데스크톱의 네 조사 단계와 세 관계를 세로로 재배치한다. 응답 status·header·body를 고정한 뒤 앱 도달 상관 증거, handler 선택, controller 본문 진입을 확인한다. 화살표는 조사 증거를 연결하는 순서이지 HTTP 요청이 응답부터 실행되는 순서가 아니다. 증거 부재만으로 미실행을 단정하지 않는다. 실제 fixture의 호출 계수와 안정적인 code를 함께 읽고 응답 commit·transaction·live socket 증거는 별도로 확인한다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
2응답과 app 로그/내부 request ID, handler 선택, controller 본문 진입의 양성 증거를 연결해 마지막 통과 경계를 찾습니다. fixture의 mapping 404/405/406/415와 변환/read/검증 400은 controller/service 0/0, 업무 404/409/412/의도한 예상 밖 service 500은 1/1입니다. body의 안정적인 code를 확인하며 header 부재로 proxy를 단정하지 않습니다. 응답 commit과 transaction 결과를 분리하고 MockMvc가 증명하지 않는 socket/proxy/partial write는 별도 live 증거로 확인합니다.
6응답 status·header·body를 고정한 뒤 앱 access log와 내부 request ID, handler 선택, controller 본문 진입의 증거를 순서대로 확인합니다. 증거가 없다고 실행되지 않았음을 단정하지 않고 proxy와 app 기록을 대조합니다. 호출 수가 같아도 정확한 실패 경계는 요청 조건과 안정적인 code로 구분합니다.
26아니요 →
44미등록 path 404·다른 method 405·XML Accept 406·text/plain consumes 415는 mapping 조건입니다. 숫자가 아닌 path variable·깨진 JSON·DTO 제약 위반은 각각 변환·read·validation의 400입니다. request ID header는 모두 있지만 호출 수만으로 세부 원인을 고르지 않습니다.
47없는 ID 404
49, duplicate 제목 409
51, 오래된 태그 412
53, explode 제목 500
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
응답 증거 고정

status·header·body를 먼저 기록합니다.

근거: 응답 세 요소 · 경계: 아직 단정 없음문제 요청의 관측 결과가 고정됩니다.
내부 증거 대조

access log·request ID·handler 선택·본문 진입 근거를 대조합니다.

자료: app·proxy 기록 · 미기록: 미실행 단정 안 함같은 요청의 도달 증거를 모읍니다.
마지막 통과 경계

요청 조건·안정 code로 실패 위치를 좁힙니다.

결과: 마지막 확인 경계 · 호출 수 동일: 정확 위치 증명 아님근거가 있는 범위에서만 원인을 좁힙니다.

응답 증거에서 마지막 통과 경계를 역추적하면 MVC 실패 위치를 좁힐 수 있다

status/header/body 고정 뒤 접근 기록·request ID·handler·controller 증거로 마지막 통과 경계를 좁힌다.

응답과app로그/내부requestID,handler선택,controller본문진입의양성증거를연결해마지막통과경계를찾습니다. fixture의mapping404/405/406/415와변환/read/검증400은controller/service0/0,업무404/409/412/의도한예상밖service500은1/1입니다. body의안정적code를확인하며header부재로proxy를단정하지않습니다. 응답commit과transaction결과를분리하고MockMvc가증명하지않는socket/proxy/partialwrite는별도live증거로확인합니다.

FLOWCHART · FAILURE BOUNDARY · LAST PASSED CHECKPOINT

응답 증거에서 마지막 통과 경계를 역추적하면 MVC 실패 위치를 좁힐 수 있다

status만 원인에 대응시키지 않고 request ID·access log와 handler 선택, controller 진입을 차례로 확인해 다음 조사 경계를 고른다.

응답에서 마지막으로 확인한 경계까지응답status header body를고정한뒤앱access log와내부requestID,handler선택,controller본문진입의증거를순서대로확인합니다. 증거가없다고실행되지않았음을단정하지않고proxy와app기록을대조합니다. 호출수가같아도정확한실패경계는요청조건과안정적code로구분합니다.
관찰한 응답status/header/body
연결 관계관찰한 응답 · status/header/ · body → app 도달 증거 · access log / ID
app 도달 증거access log / ID
연결 관계app 도달 증거 · access log / ID → handler 선택 · mapping 조건
handler 선택mapping 조건
연결 관계handler 선택 · mapping 조건 → controller 진입 · 메서드 첫 줄
controller 진입메서드 첫 줄
응답에서 증거를 연결하는 진단 순서원 데스크톱의 네 조사 단계와 세 관계를 세로로 재배치한다. 응답 status·header·body를 고정한 뒤 앱 도달 상관 증거, handler 선택, controller 본문 진입을 확인한다. 화살표는 조사 증거를 연결하는 순서이지 HTTP 요청이 응답부터 실행되는 순서가 아니다. 증거 부재만으로 미실행을 단정하지 않는다. 실제 fixture의 호출 계수와 안정적인 code를 함께 읽고 응답 commit·transaction·live socket 증거는 별도로 확인한다.상관 확인선택 증거진입 증거관찰한 응답status / headerbody앱 도달 증거access log / IDhandler 선택mapping 조건controller 진입메서드 첫 줄

화살표는 응답에서 증거를 대조하는 조사 순서입니다. 어떤 증거가 없다고 해당 실행이 없었다고 확정하지 않습니다. 아래 조건별 질문·호출 수·안정적인 code와 함께 마지막 확인 경계를 좁힙니다.

  1. DECISION 1 · APP ARRIVAL

    애플리케이션 도달 증거가 있는가?

    아니오 → proxy·Connector·edge/CDN 경계의 응답 주체를 access log와 request ID로 확인합니다. header 부재 하나만으로 proxy 404를 단정하지 않습니다.

    예 → handler 선택 증거를 확인합니다.

  2. DECISION 2 · HANDLER MAPPING

    HandlerMapping이 handler를 선택했는가?

    아니오 → path·method·produces ·consumes 조건에서 이 fixture의 404·405·406·415를 조사합니다.

    예 → controller 본문 진입 여부를 확인합니다.

  3. DECISION 3 · CONTROLLER ENTRY

    controller 본문에 진입했는가?

    아니오 → path variable, request body converter, validation 경계의 400을 조사합니다. 매핑은 되었지만 reader가 없어 생기는 415는 ch6-6의 별도 사례입니다.

    예 → service·domain·exception translation과 response write의 404·409·412·406·500을 조사하고 response commit과 transaction 결과를 따로 확인합니다.

FIXTURE · CONTROLLER 0 / SERVICE 0

같은 호출 수에서도 네 경계를 구분한다

미등록 path 404·다른 method 405·XML Accept 406·text/plain consumes 415는 mapping 조건입니다. 숫자 아닌 path variable·깨진 JSON·DTO 제약 위반은 각각 변환·read·validation의 400입니다. request ID header는 모두 있지만 호출 수만으로 세부 원인을 고르지 않습니다.

FIXTURE · CONTROLLER 1 / SERVICE 1

안정적인 업무 code가 실패를 설명한다

없는 ID404 POST_NOT_FOUND, duplicate제목409 DUPLICATE_TITLE, 오래된 태그412 STALE_VERSION, explode제목500 INTERNAL_ERROR입니다. 500은 새 request ID만 공유하고 내부 예외 문구나 stack trace를 본문에 노출하지 않습니다.

RESPONSE / TRANSACTION / RETRY

응답 쓰기 성공과 DB commit은 별개다

500 조사: request ID로 cause 확인 → response commit·client byte 확인 → 저장소 transaction 결과 확인 → 재시도 안전성 확인. HTTP commit 뒤에는 status/header를 안전하게 바꿀 수 없습니다. 업무 commit 뒤 직렬화·socket write가 실패해도 데이터는 저장되었을 수 있습니다.

MOCKMVC SCOPE

앱 문맥과 실제 socket의 증거를 나눈다

이 suite는 모의 Servlet 요청으로 등록 filter와 MVC 전략을 함께 실행합니다. listening socket·proxy/TLS·연결 종료·partial write·실제 배포 filter 순서는 증명하지 않습니다. 그런 열은 RANDOM_PORT 또는 배포 live HTTP 관찰로 따로 확인합니다.

같은 status라도 마지막으로 확인한 경계가 다를 수 있습니다. header나 로그가 없다는 사실만으로 proxy 404 또는 미실행을 단정하지 않습니다. TRACE는 필요한 route·logger·request ID와 종료 시간을 정하고 비밀 값은 수집하지 않거나 redact합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 응답 status·header·body를 고정한 뒤 앱 access log와 내부 request ID, handler 선택, controller 본문 진입의 증거를 순서대로 확인합니다. 증거가 없다고 실행되지 않았음을 단정하지 않고 proxy와 app 기록을 대조합니다. 호출 수가 같아도 정확한 실패 경계는 요청 조건과 안정적인 code로 구분합니다.
  • 원 데스크톱의 네 조사 단계와 세 관계를 세로로 재배치한다. 응답 status·header·body를 고정한 뒤 앱 도달 상관 증거, handler 선택, controller 본문 진입을 확인한다. 화살표는 조사 증거를 연결하는 순서이지 HTTP 요청이 응답부터 실행되는 순서가 아니다. 증거 부재만으로 미실행을 단정하지 않는다. 실제 fixture의 호출 계수와 안정적인 code를 함께 읽고 응답 commit·transaction·live socket 증거는 별도로 확인한다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
2응답과 app 로그/내부 request ID, handler 선택, controller 본문 진입의 양성 증거를 연결해 마지막 통과 경계를 찾습니다. fixture의 mapping 404/405/406/415와 변환/read/검증 400은 controller/service 0/0, 업무 404/409/412/의도한 예상 밖 service 500은 1/1입니다. body의 안정적인 code를 확인하며 header 부재로 proxy를 단정하지 않습니다. 응답 commit과 transaction 결과를 분리하고 MockMvc가 증명하지 않는 socket/proxy/partial write는 별도 live 증거로 확인합니다.
6응답 status·header·body를 고정한 뒤 앱 access log와 내부 request ID, handler 선택, controller 본문 진입의 증거를 순서대로 확인합니다. 증거가 없다고 실행되지 않았음을 단정하지 않고 proxy와 app 기록을 대조합니다. 호출 수가 같아도 정확한 실패 경계는 요청 조건과 안정적인 code로 구분합니다.
26아니요 →
44미등록 path 404·다른 method 405·XML Accept 406·text/plain consumes 415는 mapping 조건입니다. 숫자가 아닌 path variable·깨진 JSON·DTO 제약 위반은 각각 변환·read·validation의 400입니다. request ID header는 모두 있지만 호출 수만으로 세부 원인을 고르지 않습니다.
47없는 ID 404
49, duplicate 제목 409
51, 오래된 태그 412
53, explode 제목 500
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
응답 증거 고정

status·header·body를 먼저 기록합니다.

근거: 응답 세 요소 · 경계: 아직 단정 없음문제 요청의 관측 결과가 고정됩니다.
내부 증거 대조

access log·request ID·handler 선택·본문 진입 근거를 대조합니다.

자료: app·proxy 기록 · 미기록: 미실행 단정 안 함같은 요청의 도달 증거를 모읍니다.
마지막 통과 경계

요청 조건·안정 code로 실패 위치를 좁힙니다.

결과: 마지막 확인 경계 · 호출 수 동일: 정확 위치 증명 아님근거가 있는 범위에서만 원인을 좁힙니다.

그림의 화살표는 HTTP 요청이 실행되는 순서가 아니라, 응답에서 출발해 기록을 대조하는 조사 순서입니다. 응답의 상태와 헤더, 본문을 고정한 뒤, 애플리케이션의 접근 로그와 내부 요청 ID, 핸들러 선택, 컨트롤러 본문 진입을 차례로 확인합니다. 아래 칸들은 각 질문에 아니오일 때 조사할 곳과, 이 예제에서 컨트롤러와 서비스 호출이 0·0인 실패와 1·1인 실패를 나눠 보여 줍니다. 어떤 기록이 없다고 해서 그 단계가 실행되지 않았다고 확정하지는 않습니다.


이 글이 다루는 진단 범위

이 글은 앞 글들의 규칙을 다시 구현하지 않습니다.

여기서는 그 규칙들이 만든 응답을 보고, 실패가 매핑 전후와 컨트롤러 전후, 응답 쓰기 전후 가운데 어디서 멈췄는지 구분합니다. 같은 404도 프록시가 만든 응답일 수도, MVC에 핸들러가 없는 응답일 수도, 서비스가 리소스가 없다고 알린 응답일 수도 있습니다. 상태 하나만으로 계층을 단정하지 않습니다.


상태와 호출 수를 함께 기록합니다

아래 표의 호출 수는 이 글의 예제에만 해당합니다. controller는 컨트롤러 메서드 본문의 첫 줄에서, service는 서비스 메서드의 첫 줄에서 늘어납니다. 핸들러가 선택되었어도 인자 해석에서 실패하면 둘 다 0입니다.

사례상태마지막으로 확인할 단계컨트롤러서비스확인할 신호
등록되지 않은 경로404HandlerMapping00애플리케이션 접근 로그와 요청 ID
경로는 맞고 메서드가 다름405HandlerMapping00Allow와 등록된 메서드
Accept: application/xml406produces와 표현 후보00이 예제의 JSON 전용 매핑
Content-Type: text/plain415consumes와 요청 리더00요청의 미디어 타입
숫자가 아닌 경로 변수400인자 변환00실패한 인자의 이름과 값
깨진 JSON400요청 본문 컨버터00JSON 문법과 미디어 타입
DTO 제약 위반400Bean Validation00바뀌지 않는 필드 코드
없는 게시글404서비스 조회11POST_NOT_FOUND
중복 제목409도메인 충돌11DUPLICATE_TITLE
오래된 If-Match412조건부 수정11STALE_VERSION
이 예제의 예상 밖 서비스 예외500예외 변환11INTERNAL_ERROR와 요청 ID

406이 언제나 컨트롤러 전에 생기거나 500이 언제나 서비스 뒤에 생긴다는 뜻은 아닙니다. 표의 406은 이 매핑의 produces 불일치이고, 500은 아래 서비스가 일부러 던지는 한 사례입니다. 응답 컨버터나 뷰가 쓰는 도중에 실패한 406이나 500은 호출 수와 커밋 상태가 다를 수 있습니다.


격리된 진단 예제

6장의 세 실행 예제는 한 Gradle 프로젝트에서 실행되지만 애플리케이션 컨텍스트는 섞지 않습니다. 이 글의 모든 타입은 board.pipeline 아래에 있고, 테스트 애플리케이션은 필요한 구성 요소만 명시적으로 import합니다.

요청 DTO는 컨버터를 통과한 뒤 검증됩니다.

src/main/java/board/pipeline/ReplacePostRequest.java
package board.pipeline;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record ReplacePostRequest(
        @NotBlank String title,
        @Size(min = 10, max = 500) String content) {
}

검증은 Jackson이 본문을 ReplacePostRequest로 읽은 다음에야 실행되므로, 깨진 JSON은 이 제약에 닿기 전에 실패합니다. 또 @Size는 null을 유효하다고 보므로, content를 아예 보내지 않은 요청은 이 DTO의 검증을 통과합니다. 성공 응답에는 이 글의 실패 분류에 필요한 최소한의 필드만 둡니다.

src/main/java/board/pipeline/PostResponse.java
package board.pipeline;

public record PostResponse(
        long id,
        String title,
        String content,
        long version) {
}

프로브는 컨트롤러와 서비스 진입을 서로 다른 카운터로 기록합니다. 운영 코드에서는 전역으로 공유하는 가변 카운터 대신 메트릭과 트레이스를 씁니다.

src/main/java/board/pipeline/InvocationProbe.java
package board.pipeline;

import java.util.concurrent.atomic.AtomicInteger;

public final class InvocationProbe {
    private final AtomicInteger controllerCalls = new AtomicInteger();
    private final AtomicInteger serviceCalls = new AtomicInteger();

    public void controllerEntered() {
        controllerCalls.incrementAndGet();
    }

    public void serviceEntered() {
        serviceCalls.incrementAndGet();
    }

    public Snapshot snapshot() {
        return new Snapshot(controllerCalls.get(), serviceCalls.get());
    }

    public void reset() {
        controllerCalls.set(0);
        serviceCalls.set(0);
    }

    public record Snapshot(int controllerCalls, int serviceCalls) {
    }
}

서비스는 현재 버전을 하나만 가진, 결과가 정해진 예제입니다. 제목과 ID는 실패 사례를 고르기 위한 테스트 입력일 뿐 실제 도메인 설계가 아닙니다.

src/main/java/board/pipeline/PostService.java
package board.pipeline;

public final class PostService {
    public static final String CURRENT_ETAG = "\"post-42-v7\"";

    private final InvocationProbe probe;

    public PostService(InvocationProbe probe) {
        this.probe = probe;
    }

    public PostResponse replace(
            long id,
            String ifMatch,
            ReplacePostRequest request) {
        probe.serviceEntered();

        if (id != 42L) {
            throw new PostNotFound(id);
        }
        if ("duplicate".equals(request.title())) {
            throw new DuplicateTitle(request.title());
        }
        if (!CURRENT_ETAG.equals(ifMatch)) {
            throw new StaleVersion(ifMatch, CURRENT_ETAG);
        }
        if ("explode".equals(request.title())) {
            throw new IllegalStateException("simulated unexpected service failure");
        }

        return new PostResponse(
                id,
                request.title(),
                request.content(),
                8L);
    }

    public static final class PostNotFound extends RuntimeException {
        public PostNotFound(long id) {
            super("post not found: " + id);
        }
    }

    public static final class DuplicateTitle extends RuntimeException {
        public DuplicateTitle(String title) {
            super("duplicate title: " + title);
        }
    }

    public static final class StaleVersion extends RuntimeException {
        public StaleVersion(String actual, String expected) {
            super("stale version: actual=" + actual + ", expected=" + expected);
        }
    }
}

서비스의 검사 순서는 고정되어 있습니다. 식별자가 42가 아니면 PostNotFound, 제목이 duplicate면 DuplicateTitle, If-Match가 현재 태그와 다르면 StaleVersion, 제목이 explode면 예상하지 못한 IllegalStateException을 던지고, 모두 통과하면 버전 8의 응답을 돌려줍니다. 어느 경우든 첫 줄에서 진입을 기록하므로 서비스 안의 실패는 service=1로 남습니다. 컨트롤러 본문의 첫 동작이 프로브 증가이므로, 그보다 먼저 만들어진 400·406·415는 controller=0입니다. @RequestHeader의 If-Match는 기본으로 필수이므로, 이 헤더가 없는 요청도 메서드 본문 전에 400이 됩니다.

src/main/java/board/pipeline/PipelineController.java
package board.pipeline;

import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/pipeline/posts")
public final class PipelineController {
    private final InvocationProbe probe;
    private final PostService service;

    public PipelineController(
            InvocationProbe probe,
            PostService service) {
        this.probe = probe;
        this.service = service;
    }

    @PutMapping(
            path = "/{id}",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public PostResponse replace(
            @PathVariable long id,
            @RequestHeader(HttpHeaders.IF_MATCH) String ifMatch,
            @Valid @RequestBody ReplacePostRequest request) {
        probe.controllerEntered();
        return service.replace(id, ifMatch, request);
    }
}

요청 ID 필터는 외부의 값을 믿지 않고 이 애플리케이션에서 새 ID를 만듭니다. 이 예제는 상관 필드의 최소한의 형태만 보여 주며, 믿을 수 있는 프록시의 정책은 MVC 요청 관찰에서 다뤘습니다.

src/main/java/board/pipeline/RequestIdFilter.java
package board.pipeline;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
import org.springframework.web.filter.OncePerRequestFilter;

public final class RequestIdFilter extends OncePerRequestFilter {
    public static final String HEADER = "X-Request-Id";
    public static final String ATTRIBUTE =
            RequestIdFilter.class.getName() + ".requestId";

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain)
            throws ServletException, IOException {
        var requestId = UUID.randomUUID().toString();
        request.setAttribute(ATTRIBUTE, requestId);
        response.setHeader(HEADER, requestId);
        filterChain.doFilter(request, response);
    }
}

예상한 애플리케이션 예외는 바뀌지 않는 상태와 코드로 바꿉니다. 예상 밖의 예외는 스택 트레이스를 본문에 넣지 않고 일반적인 500과 요청 ID만 돌려줍니다.

src/main/java/board/pipeline/PipelineExceptionHandler.java
package board.pipeline;

import board.pipeline.PostService.DuplicateTitle;
import board.pipeline.PostService.PostNotFound;
import board.pipeline.PostService.StaleVersion;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice(assignableTypes = PipelineController.class)
public final class PipelineExceptionHandler {
    @ExceptionHandler(PostNotFound.class)
    public ResponseEntity<ProblemDetail> missing(
            PostNotFound exception,
            HttpServletRequest request) {
        return problem(
                HttpStatus.NOT_FOUND,
                "POST_NOT_FOUND",
                "게시글을 찾을 수 없습니다.",
                request);
    }

    @ExceptionHandler(DuplicateTitle.class)
    public ResponseEntity<ProblemDetail> duplicate(
            DuplicateTitle exception,
            HttpServletRequest request) {
        return problem(
                HttpStatus.CONFLICT,
                "DUPLICATE_TITLE",
                "같은 제목의 게시글이 있습니다.",
                request);
    }

    @ExceptionHandler(StaleVersion.class)
    public ResponseEntity<ProblemDetail> stale(
            StaleVersion exception,
            HttpServletRequest request) {
        return problem(
                HttpStatus.PRECONDITION_FAILED,
                "STALE_VERSION",
                "읽은 뒤 게시글 버전이 변경됐습니다.",
                request);
    }

    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<ProblemDetail> unexpected(
            IllegalStateException exception,
            HttpServletRequest request) {
        return problem(
                HttpStatus.INTERNAL_SERVER_ERROR,
                "INTERNAL_ERROR",
                "예상하지 못한 요청 처리 실패",
                request);
    }

    private static ResponseEntity<ProblemDetail> problem(
            HttpStatus status,
            String code,
            String detail,
            HttpServletRequest request) {
        var body = ProblemDetail.forStatusAndDetail(status, detail);
        body.setTitle(status.getReasonPhrase());
        body.setProperty("code", code);
        body.setProperty(
                "requestId",
                request.getAttribute(RequestIdFilter.ATTRIBUTE));
        return ResponseEntity.status(status).body(body);
    }
}

테스트 애플리케이션은 컴포넌트 스캔을 쓰지 않습니다. 그래서 같은 프로젝트에 있는 앞의 두 글의 예제 경로와 서비스를 이 컨텍스트로 끌어오지 않습니다.

src/test/java/board/pipeline/PipelineTestApplication.java
package board.pipeline;

import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Import;

@SpringBootConfiguration
@EnableAutoConfiguration
@Import({
        InvocationProbe.class,
        PipelineController.class,
        PipelineExceptionHandler.class,
        PostService.class,
        RequestIdFilter.class
})
public class PipelineTestApplication {
}

컨트롤러 전의 일곱 실패를 실행합니다

이 테스트 묶음은 상태뿐 아니라 두 호출 수가 모두 0인지 확인합니다. @SpringBootTest와 Boot 4의 @AutoConfigureMockMvc는 실제 애플리케이션 컨텍스트의 MVC 전략과 등록된 필터를 쓰지만 네트워크 소켓은 열지 않습니다. 기본 /** 정적 리소스 핸들러가 등록되지 않은 경로를 먼저 받아 404를 만들지 않도록, 이 예제에서만 spring.web.resources.add-mappings=false를 지정해 핸들러가 없는 경우를 고정합니다.

src/test/java/board/pipeline/BeforeControllerFailureTest.java
package board.pipeline;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.http.MediaType.APPLICATION_XML;
import static org.springframework.http.MediaType.TEXT_PLAIN;
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.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpHeaders;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest(
        classes = PipelineTestApplication.class,
        properties = "spring.web.resources.add-mappings=false")
@AutoConfigureMockMvc
class BeforeControllerFailureTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private InvocationProbe probe;

    @BeforeEach
    void resetProbe() {
        probe.reset();
    }

    @Test
    void unregisteredPathStopsAtMappingWith404() throws Exception {
        mockMvc.perform(put("/api/pipeline/missing/42")
                        .contentType(APPLICATION_JSON)
                        .accept(APPLICATION_JSON)
                        .header(HttpHeaders.IF_MATCH, PostService.CURRENT_ETAG)
                        .content(validJson()))
                .andExpect(status().isNotFound())
                .andExpect(header().exists(RequestIdFilter.HEADER));

        assertNoApplicationInvocation();
    }

    @Test
    void wrongMethodStopsAtMappingWith405() throws Exception {
        mockMvc.perform(post("/api/pipeline/posts/42")
                        .contentType(APPLICATION_JSON)
                        .accept(APPLICATION_JSON)
                        .content(validJson()))
                .andExpect(status().isMethodNotAllowed())
                .andExpect(header().exists(RequestIdFilter.HEADER));

        assertNoApplicationInvocation();
    }

    @Test
    void unacceptableRepresentationStopsWith406() throws Exception {
        mockMvc.perform(put("/api/pipeline/posts/42")
                        .contentType(APPLICATION_JSON)
                        .accept(APPLICATION_XML)
                        .header(HttpHeaders.IF_MATCH, PostService.CURRENT_ETAG)
                        .content(validJson()))
                .andExpect(status().isNotAcceptable())
                .andExpect(header().exists(RequestIdFilter.HEADER));

        assertNoApplicationInvocation();
    }

    @Test
    void unsupportedRequestMediaTypeStopsWith415() throws Exception {
        mockMvc.perform(put("/api/pipeline/posts/42")
                        .contentType(TEXT_PLAIN)
                        .accept(APPLICATION_JSON)
                        .header(HttpHeaders.IF_MATCH, PostService.CURRENT_ETAG)
                        .content("title=plain&content=not-json"))
                .andExpect(status().isUnsupportedMediaType())
                .andExpect(header().exists(RequestIdFilter.HEADER));

        assertNoApplicationInvocation();
    }

    @Test
    void pathVariableConversionStopsWith400() throws Exception {
        mockMvc.perform(put("/api/pipeline/posts/not-a-number")
                        .contentType(APPLICATION_JSON)
                        .accept(APPLICATION_JSON)
                        .header(HttpHeaders.IF_MATCH, PostService.CURRENT_ETAG)
                        .content(validJson()))
                .andExpect(status().isBadRequest())
                .andExpect(header().exists(RequestIdFilter.HEADER));

        assertNoApplicationInvocation();
    }

    @Test
    void malformedJsonStopsAtRequestConverterWith400() throws Exception {
        mockMvc.perform(put("/api/pipeline/posts/42")
                        .contentType(APPLICATION_JSON)
                        .accept(APPLICATION_JSON)
                        .header(HttpHeaders.IF_MATCH, PostService.CURRENT_ETAG)
                        .content("{broken"))
                .andExpect(status().isBadRequest())
                .andExpect(header().exists(RequestIdFilter.HEADER));

        assertNoApplicationInvocation();
    }

    @Test
    void invalidDtoStopsAtValidationWith400() throws Exception {
        mockMvc.perform(put("/api/pipeline/posts/42")
                        .contentType(APPLICATION_JSON)
                        .accept(APPLICATION_JSON)
                        .header(HttpHeaders.IF_MATCH, PostService.CURRENT_ETAG)
                        .content("{\"title\":\" \",\"content\":\"short\"}"))
                .andExpect(status().isBadRequest())
                .andExpect(header().exists(RequestIdFilter.HEADER));

        assertNoApplicationInvocation();
    }

    private void assertNoApplicationInvocation() {
        assertThat(probe.snapshot())
                .isEqualTo(new InvocationProbe.Snapshot(0, 0));
    }

    private static String validJson() {
        return "{\"title\":\"updated\","
                + "\"content\":\"ten characters or more\"}";
    }
}

더 깊이: add-mappings를 끄지 않았다면 404는 어디서 생길까

이 예제는 spring.web.resources.add-mappings=false로 정적 리소스 매핑을 꺼 두었으므로, 등록되지 않은 경로는 어떤 핸들러도 찾지 못합니다. Spring Framework 7.0.10의 DispatcherServlet은 이때 언제나 NoHandlerFoundException을 던지고, 기본 예외 처리가 이를 404로 바꿉니다. 그런데 Boot의 기본 설정에서는 정적 리소스가 모든 경로를 받는 패턴에 매핑되어 있어서, 컨트롤러 매핑에 맞지 않는 거의 모든 경로가 결국 ResourceHttpRequestHandler에 도착합니다. 즉 핸들러는 선택된 상태입니다.

이 처리기의 handleRequest는 /처럼 아주 넓은 매핑을 고려해 자원이 있는지부터 확인하고, 없으면 NoResourceFoundException을 던집니다. 지원하는 메서드(기본 GET과 HEAD)를 검사하는 것은 그다음이므로, 이 예제의 PUT /api/pipeline/missing/42도 405가 아니라 404가 됩니다. Spring Framework 6.1 릴리스 노트에 따르면 이 예외는 6.1에서 도입되었고 기본적으로 404로 처리됩니다.

그래서 기본 설정의 Boot 애플리케이션에서 404를 진단할 때는 상태 코드만 보지 말고 해결된 예외의 타입을 함께 봅니다. NoHandlerFoundException이면 어떤 핸들러도 찾지 못한 것이고, NoResourceFoundException이면 정적 리소스 핸들러까지 갔다는 뜻입니다. 뒤의 경우에는 핸들러가 선택되었으므로 모든 경로에 등록한 인터셉터의 preHandle도 실행될 수 있어, 인터셉터 로그가 있다고 컨트롤러 매핑에 성공했다고 판단하면 안 됩니다.


컨트롤러 뒤의 네 실패를 실행합니다

이 묶음의 네 요청은 컨버터와 검증을 통과합니다. 컨트롤러와 서비스가 각각 한 번 호출된 뒤, 예외 핸들러가 404·409·412·500을 바뀌지 않는 코드와 요청 ID로 바꿉니다. 500 요청에서는 공격자가 보낸 X-Request-Id가 응답 헤더와 문제 본문에 다시 쓰이지 않고 새 ID로 바뀌는지도 함께 확인합니다.

src/test/java/board/pipeline/AfterControllerFailureTest.java
package board.pipeline;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;
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.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

@SpringBootTest(
        classes = PipelineTestApplication.class,
        properties = "spring.web.resources.add-mappings=false")
@AutoConfigureMockMvc
class AfterControllerFailureTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private InvocationProbe probe;

    @BeforeEach
    void resetProbe() {
        probe.reset();
    }

    @Test
    void missingResourceReachesServiceAndReturns404() throws Exception {
        mockMvc.perform(replace(404L, PostService.CURRENT_ETAG, "updated"))
                .andExpect(status().isNotFound())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(header().exists(RequestIdFilter.HEADER))
                .andExpect(jsonPath("$.code").value("POST_NOT_FOUND"));

        assertOneApplicationInvocation();
    }

    @Test
    void duplicateTitleReachesServiceAndReturns409() throws Exception {
        mockMvc.perform(replace(42L, PostService.CURRENT_ETAG, "duplicate"))
                .andExpect(status().isConflict())
                .andExpect(header().exists(RequestIdFilter.HEADER))
                .andExpect(jsonPath("$.code").value("DUPLICATE_TITLE"));

        assertOneApplicationInvocation();
    }

    @Test
    void staleIfMatchReachesServiceAndReturns412() throws Exception {
        mockMvc.perform(replace(42L, "\"post-42-v6\"", "updated"))
                .andExpect(status().isPreconditionFailed())
                .andExpect(header().exists(RequestIdFilter.HEADER))
                .andExpect(jsonPath("$.code").value("STALE_VERSION"));

        assertOneApplicationInvocation();
    }

    @Test
    void unexpectedServiceFailureReturnsGeneric500AndRequestId() throws Exception {
        var result = mockMvc.perform(
                        replace(42L, PostService.CURRENT_ETAG, "explode")
                                .header(
                                        RequestIdFilter.HEADER,
                                        "attacker-controlled"))
                .andExpect(status().isInternalServerError())
                .andExpect(header().exists(RequestIdFilter.HEADER))
                .andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
                .andExpect(jsonPath("$.requestId").isNotEmpty())
                .andExpect(jsonPath("$.detail")
                        .value("예상하지 못한 요청 처리 실패"))
                .andReturn();

        var response = result.getResponse();
        var requestId = response.getHeader(RequestIdFilter.HEADER);
        assertThat(requestId).isNotBlank();
        assertThat(requestId).isNotEqualTo("attacker-controlled");
        assertThat(response.getContentAsString())
                .contains("\"requestId\":\"" + requestId + "\"")
                .doesNotContain(
                        "attacker-controlled",
                        "IllegalStateException",
                        "simulated unexpected service failure",
                        "java.lang.",
                        "board.pipeline.PostService",
                        "stackTrace");

        assertOneApplicationInvocation();
    }

    private MockHttpServletRequestBuilder replace(
            long id,
            String ifMatch,
            String title) {
        var body = "{\"title\":\"" + title + "\","
                + "\"content\":\"ten characters or more\"}";
        return put("/api/pipeline/posts/{id}", id)
                .contentType(APPLICATION_JSON)
                .accept(APPLICATION_JSON)
                .header(HttpHeaders.IF_MATCH, ifMatch)
                .content(body);
    }

    private void assertOneApplicationInvocation() {
        assertThat(probe.snapshot())
                .isEqualTo(new InvocationProbe.Snapshot(1, 1));
    }
}

MockMvc가 확인하는 범위

이 예제의 @SpringBootTest와 @AutoConfigureMockMvc는 Boot 애플리케이션 컨텍스트 안에서 등록된 필터와 DispatcherServlet, HandlerMapping, 인자 리졸버, 요청과 응답의 컨버터, 검증, 컨트롤러 어드바이스를 함께 실행합니다. 그래서 이 경로와 이 입력에 대한 상태와 호출 수, ProblemDetail의 코드는 회귀 테스트의 기준으로 삼을 수 있습니다.

하지만 MockMvc는 모의 요청과 응답으로 서블릿 API를 구동하며 실제 소켓을 열지 않습니다. 다음은 이 테스트만으로 알 수 없습니다.

  • 프록시와 로드 밸런서, TLS 종료, 방화벽, 실제 서블릿 커넥터가 요청을 애플리케이션까지 전달했는가
  • 컨테이너의 소켓 타임아웃과 최대 헤더·본문 크기, 연결 종료, 압축과 인코딩, 실제 필터 등록 순서가 배포 환경과 같은가
  • 클라이언트가 본문의 일부를 받은 뒤 연결이 끊긴 상황이나, 받는 쪽이 느릴 때 보내는 쪽의 속도를 늦추는 스트리밍의 배압(backpressure)이 같은가

프록시가 자체적으로 404를 만들거나 요청 ID 헤더를 지우거나 다시 쓸 수 있으므로, 헤더가 없다는 것만으로 프록시의 404라고 단정하지 않습니다. 프록시의 접근 로그와 애플리케이션의 접근 로그, 믿을 수 있는 지점에서 만든 상관 필드를 함께 봅니다. 실제 커넥터와 소켓을 확인하려면 RANDOM_PORT나 배포 환경의 실제 HTTP 테스트를 따로 실행합니다.


응답 커밋과 트랜잭션을 나눕니다

상태와 헤더가 커밋되기 전이라면 예외 핸들러가 새 응답으로 바꿀 수 있습니다. 첫 본문 바이트가 플러시된 뒤라면 상태와 헤더를 안전하게 바꿀 수 없고, 클라이언트는 부분 응답이나 연결 종료를 보게 될 수 있습니다. MockMvc의 버퍼링된 응답은 실제 소켓의 부분 쓰기나 연결 끊김을 그대로 재현하지 않습니다.

DB 트랜잭션이 커밋되는 시점도 HTTP 응답의 성공과 같지 않습니다. 업무 변경이 커밋된 뒤 JSON 직렬화나 소켓 쓰기가 실패하면, 클라이언트는 500이나 연결 종료를 보더라도 데이터는 저장되었을 수 있습니다. 반대로 테스트 메서드의 롤백이 운영 트랜잭션의 시점을 알려 주지는 않습니다. 저장 상태나 트랜잭션 이벤트, 업무 변경과 같은 트랜잭션에 함께 저장해 두었다가 나중에 전달하는 이벤트 행인 아웃박스(outbox) 같은 별도의 신호를 확인하고, 특히 POST의 재시도에는 멱등성 키나 업무의 중복 규칙을 둡니다. 500을 조사할 때는 다음 순서를 지킵니다.

  1. 요청 ID로 가장 안쪽의 원인과 같은 요청의 로그를 찾습니다.
  2. 응답이 커밋되었는지, 클라이언트가 받은 바이트가 있는지 확인합니다.
  3. 트랜잭션이 커밋과 롤백 가운데 어느 쪽으로 끝났는지 저장소에서 확인합니다.
  4. 같은 메서드와 입력으로 다시 시도해도 안전한지 확인합니다.

TRACE는 범위와 종료 조건을 먼저 정합니다

매핑과 컨버터의 TRACE 로그는 로컬 재현이나 승인된 짧은 시간 동안, 필요한 로거에만 켭니다. 시작하기 전에 대상 경로와 요청 ID, 가설, 종료 시각을 적어 두고, 재현에 성공하면 원래 레벨로 되돌립니다.

Authorization과 쿠키, 원문 본문, 쿼리의 비밀 값은 수집하지 않거나 가립니다. 프레임워크 내부 예외의 문구 전체를 회귀 테스트의 기준으로 고정하지 않고, 상태와 애플리케이션 코드, 미디어 타입, 호출 위치처럼 바뀌지 않는 신호를 검증합니다. 진단이 끝났다고 말할 수 있는 시점은 애노테이션을 바꿔 테스트가 통과했을 때가 아니라, 요청이 마지막으로 통과한 단계와 실패 전후에 변하지 않은 신호를 설명할 수 있을 때입니다.


정리

  • 실패를 진단할 때는 응답의 상태와 헤더, 본문을 먼저 고정하고, 애플리케이션 도달과 핸들러 선택, 컨트롤러 진입의 기록을 차례로 대조합니다.
  • 이 예제에서 매핑과 인자 변환, 본문 읽기, 검증의 실패는 컨트롤러와 서비스 호출이 0·0이고, 업무 실패와 예상 밖 예외는 1·1입니다.
  • 호출 수가 같아도 실패 위치는 다를 수 있으므로, 구체적인 입력과 바뀌지 않는 오류 코드를 함께 봅니다.
  • MockMvc는 실제 소켓과 프록시, 부분 쓰기를 보여 주지 않으며, 응답 커밋과 DB 트랜잭션의 커밋은 서로 다른 사건입니다.
  • 헤더나 로그가 없다는 것만으로 프록시의 응답이나 미실행을 단정하지 않고, TRACE는 범위와 종료 조건을 정해 짧게 씁니다.

연습 문제

1. 겹친 실패 조건

한 요청에 실패할 이유가 둘 이상 있으면, 표의 행을 아무렇게나 고르지 말고 실제로 통과하는 순서를 따라가야 합니다. 각 경우는 진단 예제와 프로브 0·0에서 따로 시작합니다. PUT 경로는 /api/pipeline/posts/{id}, Content-Type과 Accept는 JSON이며, content는 올바른 값 ten characters or more입니다. ① ID 404, title duplicate, If-Match: "post-42-v6", ② ID 42, title duplicate, If-Match: "post-42-v6", ③ ID 42, title explode, If-Match: "post-42-v6", ④ ID 42, title explode, If-Match: "post-42-v7", ⑤ ID 42, title duplicate, If-Match: "post-42-v7"이지만 content만 short로 바꾼 경우의 상태와 정할 수 있는 애플리케이션 코드, 컨트롤러와 서비스 호출 수, 마지막으로 통과한 단계를 설명하세요. ①과 ②는 버전도 오래되었는데 왜 412가 되지 않나요? 같은 호출 수만으로 정확한 실패 위치를 구분할 수 있나요?

해설 보기

① 두 메서드에 들어간 뒤 ID 검사에서 404 POST_NOT_FOUND이고 호출 수는 1·1입니다. 제목과 태그를 검사하는 다음 분기에는 도달하지 않습니다. ② ID는 맞지만 제목 검사에서 409 DUPLICATE_TITLE, 호출 수 1·1로 끝나며 태그 불일치는 평가하지 않습니다.

③ 중복 제목 분기를 통과한 뒤 태그 검사에서 412 STALE_VERSION, 호출 수 1·1입니다. explode의 예외는 태그 확인 뒤의 분기이므로 아직 던지지 않습니다. ④ 태그까지 맞으므로 예상 밖의 서비스 예외가 나고 어드바이스가 500 INTERNAL_ERROR로 바꾸며, 호출 수는 1·1입니다. 이 순서는 실패를 고르기 위한 예제 서비스의 고정된 정책일 뿐, 모든 도메인이 이 순서로 업무를 검사한다는 뜻은 아닙니다.

⑤ content의 길이 제약에 걸려 400이고 호출 수는 0·0입니다. JSON을 읽어 DTO를 만든 뒤 검증에서 멈추므로, 컨트롤러의 첫 프로브 증가와 서비스의 중복 제목 검사에 도달하지 않습니다. 따라서 애플리케이션의 DUPLICATE_TITLE 코드를 얻지 못합니다.

결국 1·1이라는 같은 카운터는 서비스에 도달했다는 것까지만 알려 주므로, 구체적인 입력과 바뀌지 않는 코드, 실제 분기 위치를 함께 읽어야 합니다. 반대로 0·0도 매핑과 인자, 본문 읽기, 검증을 모두 구분해 주지 못합니다. 필터가 만든 새 요청 ID는 상관관계를 잇는 데 쓰며, 특정 UUID 문자열을 정답으로 고정하지 않습니다. 이 풀이는 코드를 따라간 예상이므로 MockMvc 테스트에 추가해 확인합니다.

2. 진단표에 두 행 더하기

지금의 표에 If-Match가 없는 400과 응답 직렬화 단계의 500을 추가하려고 합니다. 각 행에 컨트롤러와 서비스 호출 수, 응답 커밋 여부, 트랜잭션 결과를 어떻게 적어야 하며, MockMvc만으로 채울 수 없는 칸은 무엇인지 설명하세요.

해설 보기

이 예제의 컨트롤러는 @RequestHeader(HttpHeaders.IF_MATCH) String ifMatch로 헤더를 필수 인자로 받으므로, If-Match가 없으면 인자 해석 단계에서 MissingRequestHeaderException으로 400이 됩니다. 컨트롤러와 서비스는 모두 0이고, 응답은 커밋 전이며 트랜잭션은 시작되지 않습니다. 컨트롤러 안에서 헤더를 직접 검사하도록 바꾼다면 컨트롤러 1, 서비스 0이 되므로, 행을 적을 때는 실제 선언을 먼저 확인합니다.

응답 직렬화 단계의 500은 컨트롤러와 서비스가 모두 1회 호출된 뒤에 생깁니다. 직렬화가 첫 바이트를 쓰기 전에 실패했다면 예외 핸들러가 500으로 바꿀 수 있지만, 일부를 쓴 뒤라면 상태를 바꿀 수 없고 연결이 끊길 수 있습니다. 트랜잭션은 서비스가 반환할 때 이미 커밋되었을 수 있으므로 저장된 상태를 따로 확인합니다. 응답 커밋 여부와 클라이언트가 받은 바이트, 연결 종료 여부는 MockMvc로 재현되지 않으므로 그 칸에는 별도 live test 필요라고 적습니다.

이것으로 6장을 마칩니다. 6장에서는 요청 ID와 MDC로 요청을 관찰하는 데서 시작해, 매핑 조건과 파라미터·JSON 바인딩, 반환 값의 처리 경로와 메시지 컨버터, CRUD의 ETag와 조건부 수정을 거쳐, 실패한 요청이 마지막으로 통과한 단계를 찾는 방법까지 살펴봤습니다. 다음 장의 Thymeleaf 표현식부터는 서버에서 HTML을 만드는 화면 쪽으로 돌아가, Thymeleaf의 표현식과 폼 바인딩, 메시지와 국제화, 검증 오류를 화면에 보여 주는 방법을 다룹니다.