MVC 실패 경계 진단
응답 상태와 요청 ID, 컨트롤러·서비스 호출 수로 매핑·인자·본문 변환·검증·업무·응답 쓰기 실패의 마지막 통과 경계를 찾습니다.
MVC 요청이 실패했을 때 애노테이션을 하나씩 바꾸면 원인이 아니라 새로운 상태를 만들기 쉽습니다.
먼저 클라이언트가 실제로 받은 status·header·body를 고정하고, 애플리케이션 도달 증거와 handler 선택, 컨트롤러 본문 진입을 차례로 확인합니다. 목표는 예외 이름을 추측하는 것이 아니라 마지막으로 통과한 경계를 찾는 것입니다.
FLOWCHART · FAILURE BOUNDARY · LAST PASSED CHECKPOINT
응답 증거에서 마지막 통과 경계를 역추적하면 MVC 실패 위치를 좁힐 수 있다
status만 원인에 대응시키지 않고 request ID·access log와 handler 선택, controller 진입을 차례로 확인해 다음 조사 경계를 고른다.
-
DECISION 1 · APP ARRIVAL
애플리케이션 도달 증거가 있는가?
아니오 → proxy·Connector·edge/CDN 경계의 응답 주체를 access log와 request ID로 확인합니다. header 부재 하나만으로 proxy 404를 단정하지 않습니다.
예 → handler 선택 증거를 확인합니다.
-
DECISION 2 · HANDLER MAPPING
HandlerMapping이 handler를 선택했는가?
아니오 → path·method·
produces조건에서 404·405·이 fixture의 406을 조사합니다.예 → controller 본문 진입 여부를 확인합니다.
-
DECISION 3 · CONTROLLER ENTRY
controller 본문에 진입했는가?
아니오 → path variable, request body converter, validation 경계의 400·415를 조사합니다.
예 → service·domain·exception translation과 response write의 404·409·412·406·500을 조사하고 response commit과 transaction 결과를 따로 확인합니다.
같은 status도 마지막으로 통과한 경계가 다를 수 있다. 이 흐름은 request ID와 access log, controller·service 호출 수로 다음 조사 범위를 고르며, 실제 proxy·socket·partial write·transaction 결과는 별도 live evidence로 확인한다.
이 문서가 소유하는 진단 경계
이 문서는 앞 절의 규칙을 다시 구현하지 않습니다.
- ch6-4는 경로 변수·요청 본문·검증 계약을 소유합니다.
- 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 | 마지막으로 확인할 경계 | Controller | Service | 안정적인 증거 |
|---|---|---|---|---|---|
| 등록되지 않은 경로 | 404 | HandlerMapping | 0 | 0 | 애플리케이션 access log·request ID |
| 경로는 맞고 method가 다름 | 405 | HandlerMapping | 0 | 0 | Allow와 등록 method |
Accept: application/xml | 406 | produces·표현 후보 | 0 | 0 | 이 fixture의 JSON-only mapping |
Content-Type: text/plain | 415 | consumes·request reader | 0 | 0 | 요청 media type |
| 숫자가 아닌 path variable | 400 | 인자 변환 | 0 | 0 | 실패한 인자 이름·값 |
| 깨진 JSON | 400 | request body converter | 0 | 0 | JSON 문법·media type |
| DTO 제약 위반 | 400 | Bean Validation | 0 | 0 | 안정적인 field code |
| 없는 게시글 | 404 | service lookup | 1 | 1 | POST_NOT_FOUND |
| 중복 제목 | 409 | domain conflict | 1 | 1 | DUPLICATE_TITLE |
오래된 If-Match | 412 | conditional update | 1 | 1 | STALE_VERSION |
| 이 fixture의 예상 밖 service 예외 | 500 | exception translation | 1 | 1 | INTERNAL_ERROR·request ID |
406이 언제나 컨트롤러 전에 생기거나 500이 언제나 서비스 뒤에 생긴다는 뜻은 아닙니다. 표의 406은 이 mapping의 produces 불일치이고, 500은 아래 서비스가 의도적으로 던지는 한 사례입니다. 응답 converter나 view가 쓰는 중 실패한 406·500은 다른 호출 수와 commit 상태를 가질 수 있습니다.
격리된 진단 fixture
이 장의 세 실행 fixture는 한 Gradle 프로젝트에서 실행되지만 애플리케이션 문맥은 섞지 않습니다. 이 절의 모든 타입은 board.pipeline 아래에 있고, 테스트 애플리케이션은 필요한 구성요소만 명시적으로 import합니다.
요청 DTO는 converter를 통과한 뒤 검증됩니다.
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) {
}성공 응답은 이 절의 실패 분류에 필요한 최소 필드만 둡니다.
package board.pipeline;
public record PostResponse(
long id,
String title,
String content,
long version) {
}관찰 probe는 컨트롤러와 서비스 진입을 서로 다른 계수로 기록합니다. 운영 코드에서는 전역 변경 가능한 계수 대신 metrics·trace를 사용합니다.
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는 실패 사례를 선택하는 테스트 입력일 뿐 실제 도메인 설계가 아닙니다.
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입니다.
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의 범위입니다.
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만 반환합니다.
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를 이 문맥에 끌어오지 않습니다.
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 경계를 고정합니다.
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로 교체되는지도 함께 검증합니다.
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을 조사할 때는 다음 순서를 고정합니다.
- request ID로 가장 안쪽 cause와 같은 요청의 로그를 찾습니다.
- response가 commit됐는지와 client가 받은 byte가 있는지 확인합니다.
- transaction이 commit·rollback 중 어느 쪽인지 저장소에서 확인합니다.
- 같은 method와 입력을 재시도해도 안전한지 확인합니다.
TRACE는 범위와 종료 조건을 먼저 정한다
매핑·converter TRACE는 로컬 재현이나 승인된 짧은 시간 창에서, 필요한 logger만 대상으로 사용합니다. 시작 전에 대상 route·request ID·가설·종료 시각을 기록하고 재현 증거를 얻으면 원래 level로 되돌립니다.
Authorization, cookie, 원문 body, query의 비밀 값은 수집하지 않거나 redact합니다. 프레임워크 내부 예외 문구 전체를 회귀 계약으로 고정하지 않고 status, 애플리케이션 code, media type, 호출 경계처럼 안정적인 관찰값을 검증합니다.
진단 절차의 종료점은 “애노테이션을 바꿔 통과했다”가 아니라 마지막으로 통과한 경계와 실패 전후의 불변 관찰값을 설명할 수 있다는 것입니다.
연습 문제
현재 표에 missing If-Match 400과 response serializer 500을 추가하세요. 각각 controller·service 호출 수, response commit 여부, transaction 결과를 따로 기록하고, MockMvc만으로 증명할 수 없는 열에는 별도 live test 필요라고 표시하세요.