본문 바로가기

MVC 실패 경계 진단

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

MVC 요청이 실패했을 때 애노테이션을 하나씩 바꾸면 원인이 아니라 새로운 상태를 만들기 쉽습니다.

먼저 클라이언트가 실제로 받은 status·header·body를 고정하고, 애플리케이션 도달 증거와 handler 선택, 컨트롤러 본문 진입을 차례로 확인합니다. 목표는 예외 이름을 추측하는 것이 아니라 마지막으로 통과한 경계를 찾는 것입니다.

응답 증거에서 마지막 통과 경계를 역추적하면 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로 실패 위치를 좁힙니다.

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

이 문서가 소유하는 진단 경계

이 문서는 앞 절의 규칙을 다시 구현하지 않습니다.

  • ch6-3은 요청 파라미터·폼 인자 계약을, ch6-4는 JSON 요청 본문·검증 계약을 소유합니다. 경로 변수의 매핑·변환 예제는 ch6-2에서 다룹니다.
  • ch6-6은 Accept·produces·canRead·canWrite와 converter 선택을 소유합니다.
  • ch6-7은 CRUD의 Location·ETag·If-Match와 404·409·412 업무 의미를 소유합니다.

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


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

아래 표의 호출 수는 이 절의 fixture에만 해당합니다. controller는 컨트롤러 메서드 본문의 첫 줄, service는 서비스 메서드의 첫 줄에서 증가합니다. handler가 선택됐어도 인자 해석에서 실패하면 둘 다 0입니다.

사례Status마지막으로 확인할 경계ControllerService안정적인 증거
등록되지 않은 경로404HandlerMapping00애플리케이션 access log·request ID
경로는 맞고 method가 다름405HandlerMapping00Allow와 등록 method
Accept: application/xml406produces·표현 후보00이 fixture의 JSON-only mapping
Content-Type: text/plain415consumes·request reader00요청 media type
숫자가 아닌 path variable400인자 변환00실패한 인자 이름·값
깨진 JSON400request body converter00JSON 문법·media type
DTO 제약 위반400Bean Validation00안정적인 field code
없는 게시글404service lookup11POST_NOT_FOUND
중복 제목409domain conflict11DUPLICATE_TITLE
오래된 If-Match412conditional update11STALE_VERSION
이 fixture의 예상 밖 service 예외500exception translation11INTERNAL_ERROR·request ID

406이 언제나 컨트롤러 전에 생기거나 500이 언제나 서비스 뒤에 생긴다는 뜻은 아닙니다. 표의 406은 이 mapping의 produces 불일치이고, 500은 아래 서비스가 의도적으로 던지는 한 사례입니다. 응답 converter나 view가 쓰는 중 실패한 406·500은 다른 호출 수와 commit 상태를 가질 수 있습니다.


격리된 진단 fixture

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

요청 DTO는 converter를 통과한 뒤 검증됩니다.

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) {
}

성공 응답은 이 절의 실패 분류에 필요한 최소 필드만 둡니다.

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

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

관찰 probe는 컨트롤러와 서비스 진입을 서로 다른 계수로 기록합니다. 운영 코드에서는 전역으로 공유하는 가변 계수 대신 metrics·trace를 사용합니다.

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) {
    }
}

서비스는 하나의 현재 버전을 가진 결정적 fixture입니다. 제목과 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);
        }
    }
}

컨트롤러 본문의 첫 동작이 probe 증가이므로, 그 전에 만들어진 400·406·415는 controller=0입니다.

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를 만듭니다. 이 예제는 상관 필드의 최소 형태만 보여 주며, 실제 신뢰 프록시 정책은 ch6-1의 범위입니다.

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);
    }
}

예상한 애플리케이션 예외는 안정적인 status와 code로 바꿉니다. 예상 밖 예외는 stack trace를 body에 넣지 않고 일반 500과 request 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);
    }
}

테스트 애플리케이션은 component scan을 사용하지 않습니다. 따라서 같은 공유 프로젝트에 있는 ch6-6·ch6-7 fixture의 route와 service를 이 문맥에 끌어오지 않습니다.

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 {
}

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

이 suite는 status뿐 아니라 두 호출 수가 모두 0인지 확인합니다. @SpringBootTest와 Boot 4의 @AutoConfigureMockMvc는 실제 애플리케이션 문맥의 MVC 전략과 등록된 필터를 사용하지만 네트워크 socket은 열지 않습니다. 기본 /** 정적 리소스 handler가 미등록 경로를 먼저 받아 404를 만들지 않도록 이 fixture에서만 spring.web.resources.add-mappings=false를 명시해 no-handler 경계를 고정합니다.

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\"}";
    }
}

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

이 suite의 네 요청은 converter와 검증을 통과합니다. 컨트롤러와 서비스가 각각 한 번 호출된 뒤, 예외 handler가 404·409·412·500을 안정적인 code와 request ID로 바꿉니다. 500 요청은 공격자가 보낸 X-Request-Id가 응답 header와 문제 본문에 재사용되지 않고 새 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가 증명하는 범위

이 fixture의 @SpringBootTest와 @AutoConfigureMockMvc는 Boot 애플리케이션 문맥 안에서 등록된 filter, DispatcherServlet, HandlerMapping, 인자 resolver, request/response converter, validation, controller advice를 함께 실행합니다. 그래서 표의 이 route와 이 입력에 대한 status·호출 수·ProblemDetail code를 회귀 계약으로 삼을 수 있습니다.

하지만 MockMvc는 Servlet API를 모의 request·response로 구동하며 실제 listening socket을 열지 않습니다. 다음 사실은 이 suite만으로 증명되지 않습니다.

  • 프록시·load balancer·TLS 종료·방화벽·실제 Servlet connector가 요청을 애플리케이션까지 전달했는가
  • container의 socket timeout, 최대 header/body 크기, 연결 종료, 압축·인코딩, 실제 filter 등록 순서가 배포와 같은가
  • 클라이언트가 일부 body byte를 받은 뒤 연결이 끊긴 상황이나 streaming backpressure가 같은가

프록시가 자체 404를 만들거나 request ID header를 제거·재작성할 수 있으므로 “header가 없다”만으로 프록시 404를 단정하지 않습니다. 프록시 access log와 애플리케이션 access log, 신뢰 경계에서 만든 correlation field를 함께 봅니다. 실제 connector와 socket 경계를 검증하려면 RANDOM_PORT 또는 배포 환경의 live HTTP 테스트를 별도로 실행합니다.


응답 commit과 transaction을 분리한다

status와 header가 commit되기 전이라면 exception handler가 새 응답으로 바꿀 수 있습니다. 첫 body byte가 flush된 뒤라면 status와 header를 안전하게 교체할 수 없고, client는 부분 응답이나 연결 종료를 볼 수 있습니다. MockMvc의 buffered response는 실제 socket의 partial write·disconnect를 충실히 재현하지 않습니다.

DB transaction의 commit 시점도 HTTP 응답 성공과 동일하지 않습니다. 업무 변경이 commit된 뒤 JSON 직렬화나 socket write가 실패하면 client가 500 또는 연결 종료를 봐도 데이터는 저장됐을 수 있습니다. 반대로 테스트 메서드의 rollback은 운영 transaction 시점을 증명하지 않습니다. 저장 상태·transaction event·outbox 같은 별도 관찰값을 검증하고, 특히 POST 재시도에는 멱등성 키나 업무 중복 규칙을 둡니다.

500을 조사할 때는 다음 순서를 고정합니다.

  1. request ID로 가장 안쪽 cause와 같은 요청의 로그를 찾습니다.
  2. response가 commit됐는지와 client가 받은 byte가 있는지 확인합니다.
  3. transaction이 commit·rollback 중 어느 쪽인지 저장소에서 확인합니다.
  4. 같은 method와 입력을 재시도해도 안전한지 확인합니다.

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

매핑·converter TRACE는 로컬 재현이나 승인된 짧은 시간 창에서, 필요한 logger만 대상으로 사용합니다. 시작 전에 대상 route·request ID·가설·종료 시각을 기록하고 재현 증거를 얻으면 원래 level로 되돌립니다.

Authorization, cookie, 원문 body, query의 비밀 값은 수집하지 않거나 redact합니다. 프레임워크 내부 예외 문구 전체를 회귀 계약으로 고정하지 않고 status, 애플리케이션 code, media type, 호출 경계처럼 안정적인 관찰값을 검증합니다.

진단 절차의 종료점은 “애노테이션을 바꿔 통과했다”가 아니라 마지막으로 통과한 경계와 실패 전후의 불변 관찰값을 설명할 수 있다는 것입니다.


연습 문제

독립 확인

한 요청에 실패 후보가 둘 이상 있으면 표의 행을 임의로 고르지 말고 실제 통과 순서를 읽어야 합니다. 각 경우는 원 진단 fixture와 probe 0·0에서 독립적으로 시작합니다. PUT 경로는 /api/pipeline/posts/{id}, Content-Type과 Accept는 JSON이며, content는 원 유효 값 ten characters or more입니다.

  1. ID 404, title duplicate, If-Match="post-42-v6"
  2. ID 42, title duplicate, If-Match="post-42-v6"
  3. ID 42, title explode, If-Match="post-42-v6"
  4. ID 42, title explode, If-Match="post-42-v7"
  5. ID 42, title duplicate, If-Match="post-42-v7"이지만 content만 short로 변경

상태, 애플리케이션 code를 정할 수 있는 경우의 code, controller·service 호출 수와 마지막으로 통과한 경계를 설명하세요. 1번과 2번에서 버전도 오래됐는데 항상 412가 되지 않는 이유는 무엇인가요? 같은 호출 수만으로 정확한 실패 위치를 구분할 수 있나요?

겹친 실패 조건에서 먼저 도달한 분기를 찾는 풀이

1번은 두 메서드에 진입한 뒤 ID 검사에서 404 POST_NOT_FOUND, 호출 수 1·1입니다. 제목과 태그를 조사하는 다음 분기에는 도달하지 않습니다. 2번은 ID가 맞지만 제목 검사에서 409 DUPLICATE_TITLE, 호출 수 1·1로 끝나 태그 불일치는 평가하지 않습니다.

3번은 중복 제목 분기를 통과한 뒤 태그 검사에서 412 STALE_VERSION, 호출 수 1·1입니다. explode 예외는 태그 확인 뒤의 분기이므로 아직 던지지 않습니다. 4번은 태그까지 맞아 예상 밖 서비스 예외와 어드바이스의 500 INTERNAL_ERROR, 호출 수 1·1입니다. 이 순서는 실패 선택용 원 서비스의 고정 정책이며 모든 도메인의 업무 검사 순서가 아닙니다.

5번은 content 길이 제약에 걸려 400, 호출 수 0·0입니다. JSON을 읽어 DTO를 만든 뒤 검증에서 멈추므로 원 컨트롤러의 첫 probe 증가와 중복 제목 서비스 검사에 도달하지 않습니다. 이 요청은 애플리케이션의 DUPLICATE_TITLE code를 얻는 사례가 아닙니다.

따라서 1·1이라는 동일한 계수는 서비스 도달까지만 알려 줍니다. 구체 입력과 안정적인 code, 실제 분기 위치를 함께 읽어야 합니다. 반대로 0·0도 매핑·인자·본문 읽기·검증을 모두 구분해 주지는 않습니다. 원 필터의 새 request ID는 상관관계에 쓰며, 특정 UUID 문자열을 정답으로 고정하지 않습니다. 위 새 복합 입력은 소스 추론이고, 실제 MockMvc 실행·응답 commit·DB transaction·배포 네트워크 관찰은 별도입니다.

현재 표에 missing If-Match 400과 response serializer 500을 추가하세요. 각각 controller·service 호출 수, response commit 여부, transaction 결과를 따로 기록하고, MockMvc만으로 증명할 수 없는 열에는 별도 live test 필요라고 표시하세요.