서블릿 오류 디스패치
미처리 예외가 REQUEST에서 ERROR로 재디스패치될 때 원래 URI·상태·요청 ID를 복원하고, 중복 부수 효과와 커밋된 응답의 한계를 실제 Spring MVC handler로 검증합니다.
컨트롤러에서 처리되지 않은 예외가 servlet 컨테이너까지 올라가면 최초 REQUEST가 그대로 끝나는 것만은 아닙니다. 컨테이너는 상태와 예외 정보를 request attribute에 저장하고 오류 경로로 DispatcherType.ERROR 재전송할 수 있습니다.
현재 URI가 /error로 바뀌어도 원래 요청은 사라지지 않습니다. 두 dispatch를 한 요청의 두 구간으로 관찰하고, 내부 예외 정보는 서버 진단에만 사용합니다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
도착한 ERROR 요청의 원래 URI와 안전한 응답을 검증한다
이 fixture는 ERROR dispatcher와 표준 속성을 직접 설정합니다. BoardErrorController#error 도달, 원래 URI·상태·요청 ID 복원과 내부 예외 정보의 비노출을 확인합니다.
CONTAINER TRANSITION / ADDITIONAL TEST
최초 예외의 자동 ERROR 전이는 별도 범위
현재 advice처럼 본문까지 완성한 오류 응답은 REQUEST에서 끝날 수 있습니다. resolver가 sendError를 호출한 경로의 ERROR 매핑은 별도로 따릅니다. 미처리 실패는 컨테이너 오류 매핑에 따라 ERROR로 재디스패치될 수 있습니다. 이 문서의 MVC test는 두 번째 요청을 직접 만들며 내장 컨테이너의 REQUEST→ERROR 기록을 검증하지 않습니다.
ACTUAL ERROR HANDLER / 404
현재 /error · 원래 /posts/999를 구분
정확한 handler는 BoardErrorController#error입니다. status 404, code=servlet-error, originalUri=/posts/999, requestId=req-error-41와 application/problem+json을 확인합니다. snapshot unit도 원래 URI·status·예외 타입·ID를 복원합니다.
STATUS NORMALIZATION
HTTP에 없는 799는 500으로 정규화
HttpStatus.resolve가 알 수 없는 상태면 INTERNAL_SERVER_ERROR를 사용합니다. 실제 두 번째 MVC case의 ERROR_STATUS_CODE=799는 status/body.status 500과 servlet-error입니다. 일반 404 detail은 경로 없음, 그 밖에는 일반 재시도 문구입니다.
DISPATCH SCOPES / FILTER REGISTRATION
같은 ID 재게시와 부수 효과 1회를 분리
앞 filter mock는 private ID를 ERROR에서 다시 게시하고 공개 속성/MDC를 각 구간 뒤 복원합니다. 실제 ERROR filter mapping은 별도 통합 검사입니다. rate-limit 차감·session touch·인증 redirect를 REQUEST 한 번에 제한하는 정책도 이 error-controller fixture에서 구현하지 않습니다.
SAFE CLIENT RESPONSE
예외 타입·원문은 본문에 넣지 않는다
실제 404 test는 exceptionType과 message 속성의 부재를 확인합니다. 원래 URI와 지원용 ID만 명시적으로 응답합니다. raw query·stack trace·SQL을 복사하지 않으며 metric에는 정규화된 route를 사용하는 별도 관찰 정책을 둡니다.
COMMIT / EXPORT EXERCISE
부분 CSV를 JSON 오류로 바꾸지 않는다
commit 뒤에는 status/content type/이미 보낸 본문을 원자적으로 교체할 수 없습니다. 연습 ExportJob은 SUCCEEDED의 objectKey 필수와 다른 상태의 key 금지를 구현합니다. 실제 파일 생성·checksum·다운로드 공개 또는 stream 장애 test는 추가 구현입니다.
MockMvc가 ERROR 요청과 표준 속성을 직접 만듭니다. status 404와 원래 URI posts/999, 요청 ID req-error-41로 BoardErrorController error handler가 안전한 ProblemDetail을 만듭니다. 알 수 없는 799 상태는 500으로 정규화됩니다. 최초 REQUEST의 예외에서 컨테이너가 자동 재디스패치하는 전체 동작 및 commit 뒤 stream 복구는 별도 검사입니다.
REQUEST와 ERROR는 같은 요청의 다른 dispatch다
/posts/999 핸들러가 예외를 던지고 MVC resolver가 처리하지 못했다고 가정합니다. 컨테이너는 오류 매핑을 선택하고 아래 표준 속성을 채운 뒤 /error를 호출합니다.
RequestDispatcher.ERROR_STATUS_CODE: 최종 후보 HTTP 상태RequestDispatcher.ERROR_REQUEST_URI: 최초 요청 URIRequestDispatcher.ERROR_EXCEPTION_TYPE: 서버가 기록할 예외 타입RequestDispatcher.ERROR_MESSAGE: 외부 응답에 그대로 내보내지 않을 원문
스냅샷은 현재 경로와 원래 경로를 구분하고, 앞 문서의 RequestTraceFilter.REQUEST_ID_ATTRIBUTE를 읽습니다. 오류용 Filter나 두 번째 ID 생성기는 만들지 않습니다.
package board.web.errorpath;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import board.web.RequestTraceFilter;
public record ServletErrorSnapshot(
int status,
String originalUri,
String exceptionType,
String message,
String requestId
) {
public static ServletErrorSnapshot from(
HttpServletRequest request
) {
Object status = request.getAttribute(
RequestDispatcher.ERROR_STATUS_CODE);
Object uri = request.getAttribute(
RequestDispatcher.ERROR_REQUEST_URI);
Object type = request.getAttribute(
RequestDispatcher.ERROR_EXCEPTION_TYPE);
Object message = request.getAttribute(
RequestDispatcher.ERROR_MESSAGE);
Object requestId = request.getAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE);
return new ServletErrorSnapshot(
status instanceof Number value
? value.intValue()
: 500,
uri == null
? request.getRequestURI()
: uri.toString(),
type instanceof Class<?> value
? value.getName()
: "unknown",
message == null ? "" : message.toString(),
requestId instanceof String value
&& !value.isBlank()
? value
: "");
}
}exceptionType과 message는 제한된 서버 로그에만 쓸 수 있습니다. 쿼리 문자열, stack trace, SQL, 사용자 입력 원문을 오류 본문에 복사하지 않습니다. 사용자가 지원 문의에 사용할 값은 검증된 요청 ID와 안정적인 문제 코드입니다.
/error는 commit 전 마지막 servlet 안전망이다
Spring Boot 4의 servlet MVC ErrorController는 org.springframework.boot.webmvc.error.ErrorController에 있습니다. 오류 컨트롤러는 표준 status만 유지하고, 알 수 없는 status는 500으로 정규화합니다.
package board.web.errorpath;
import java.net.URI;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.webmvc.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public final class BoardErrorController implements ErrorController {
@RequestMapping(
path = "/error",
produces = MediaType.APPLICATION_PROBLEM_JSON_VALUE)
public ResponseEntity<ProblemDetail> error(
HttpServletRequest request
) {
ServletErrorSnapshot failure =
ServletErrorSnapshot.from(request);
HttpStatus status = HttpStatus.resolve(failure.status());
if (status == null) {
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
status, safeDetail(status));
problem.setType(URI.create(
"https://board.example/problems/servlet-error"));
problem.setTitle(status.is5xxServerError()
? "요청 처리 실패"
: "요청을 처리할 수 없음");
problem.setProperty("code", "servlet-error");
problem.setProperty(
"originalUri", failure.originalUri());
if (!failure.requestId().isBlank()) {
problem.setProperty(
"requestId", failure.requestId());
}
return ResponseEntity.status(status).body(problem);
}
private String safeDetail(HttpStatus status) {
if (status == HttpStatus.NOT_FOUND) {
return "요청한 경로를 찾을 수 없습니다.";
}
return "잠시 후 다시 시도하세요.";
}
}도메인 예외를 controller advice가 먼저 처리했다면 이 컨트롤러까지 오지 않습니다. /error는 handler 이전 실패, 미매핑, 끝까지 처리되지 않은 예외를 위한 대체 경계이지 모든 업무 오류의 중심 router가 아닙니다.
dispatch별 Filter 책임을 구분한다
일반 Filter가 REQUEST와 ERROR에 모두 등록되면 rate limit 차감, 세션 접근 시각 갱신, 인증 redirect가 두 번 실행될 수 있습니다. 반면 요청 ID는 새로 만들지 않고 같은 값을 이어 써야 두 dispatch가 한 trace로 연결됩니다.
| 동작 | REQUEST | ERROR |
|---|---|---|
| 요청 ID | private request-lifetime 값 생성 후 public attribute 게시 | private lifetime 값에서 public attribute 재게시 |
| MDC | 현재 dispatch 동안 설정 | 같은 lifetime ID로 다시 설정 후 이전 값 복원 |
| 인증 redirect | 보호 handler 앞에서만 | 오류 endpoint는 제외 |
| rate limit·session touch | 최초 요청에서 한 번 | 다시 차감·갱신하지 않음 |
| 상태 로그 | 아직 중간 상태일 수 있음 | 최종 status와 원래 URI를 함께 기록 |
앞 문서의 RequestTraceFilter는 private request-lifetime attribute에 안정적인 ID를 보존하고 ERROR dispatch에서도 다시 실행됩니다. 각 dispatch 안에서만 public requestId attribute와 MDC에 같은 값을 게시한 뒤, finally에서 각각의 이전 값을 복원합니다. 이전 값이 없었다면 제거합니다.
이 동작은 OncePerRequestFilter 기본값만으로 자동 보존된다고 가정하지 않습니다. shouldNotFilterErrorDispatch() 선택과 private lifetime 저장소, public attribute·MDC의 대칭 게시/복원을 코드와 테스트가 함께 소유합니다.
현재 /error만 metric label로 남기면 원인 route가 사라집니다. 원래 URI는 진단 필드로 보존하되, metric에는 /posts/{id}처럼 정규화된 route pattern을 사용해 카디널리티를 제한합니다.
단위 테스트는 표준 ERROR 속성 복원을 고정한다
스냅샷 테스트는 현재 URI가 /error여도 원래 URI와 404 status, 예외 타입, 요청 ID가 복원됨을 고정합니다.
package board.web.errorpath;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.RequestDispatcher;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import board.web.RequestTraceFilter;
class ServletErrorSnapshotTest {
@Test
void ERROR_attributes에서_원래_request를_복원한다() {
var request = new MockHttpServletRequest(
"GET", "/error");
request.setDispatcherType(DispatcherType.ERROR);
request.setAttribute(
RequestDispatcher.ERROR_STATUS_CODE, 404);
request.setAttribute(
RequestDispatcher.ERROR_REQUEST_URI,
"/posts/999");
request.setAttribute(
RequestDispatcher.ERROR_EXCEPTION_TYPE,
IllegalStateException.class);
request.setAttribute(
RequestDispatcher.ERROR_MESSAGE,
"database detail must stay on server");
request.setAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE,
"req-error-41");
ServletErrorSnapshot snapshot =
ServletErrorSnapshot.from(request);
assertThat(request.getDispatcherType())
.isEqualTo(DispatcherType.ERROR);
assertThat(request.getRequestURI()).isEqualTo("/error");
assertThat(snapshot.status()).isEqualTo(404);
assertThat(snapshot.originalUri())
.isEqualTo("/posts/999");
assertThat(snapshot.exceptionType())
.isEqualTo(IllegalStateException.class.getName());
assertThat(snapshot.requestId())
.isEqualTo("req-error-41");
}
}MVC 테스트는 ERROR handler 도달과 안전한 본문을 증명한다
MockMvc의 ERROR dispatcher type과 표준 request attribute를 직접 설정합니다. 이것은 내장 컨테이너가 최초 예외를 두 번째 dispatch로 바꾸는 동작 전체를 흉내 내는 테스트가 아니라, 두 번째 dispatch가 도착했을 때 정확한 handler와 응답 계약을 검증하는 테스트입니다.
package board.web.errorpath;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.handler;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.RequestDispatcher;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import board.web.RequestTraceFilter;
class BoardErrorControllerMvcTest {
private final MockMvc mvc = standaloneSetup(
new BoardErrorController()).build();
@Test
void ERROR_dispatch가_exact_handler와_404_problem에_도달한다()
throws Exception {
mvc.perform(get("/error")
.with(request -> {
request.setDispatcherType(
DispatcherType.ERROR);
return request;
})
.requestAttr(
RequestDispatcher.ERROR_STATUS_CODE,
404)
.requestAttr(
RequestDispatcher.ERROR_REQUEST_URI,
"/posts/999")
.requestAttr(
RequestDispatcher.ERROR_EXCEPTION_TYPE,
IllegalStateException.class)
.requestAttr(
RequestDispatcher.ERROR_MESSAGE,
"database detail must stay on server")
.requestAttr(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE,
"req-error-41"))
.andExpect(handler().handlerType(
BoardErrorController.class))
.andExpect(handler().methodName("error"))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith(
MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.code")
.value("servlet-error"))
.andExpect(jsonPath("$.originalUri")
.value("/posts/999"))
.andExpect(jsonPath("$.requestId")
.value("req-error-41"))
.andExpect(jsonPath("$.exceptionType")
.doesNotExist())
.andExpect(jsonPath("$.message")
.doesNotExist());
}
@Test
void 알_수_없는_status는_500으로_정규화한다()
throws Exception {
mvc.perform(get("/error")
.with(request -> {
request.setDispatcherType(
DispatcherType.ERROR);
return request;
})
.requestAttr(
RequestDispatcher.ERROR_STATUS_CODE,
799)
.requestAttr(
RequestDispatcher.ERROR_REQUEST_URI,
"/exports/41"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.status").value(500))
.andExpect(jsonPath("$.code")
.value("servlet-error"));
}
}dispatcher type = ERROR
current URI = /error
original URI = /posts/999
selected handler = BoardErrorController#error
response status = 404
requestId = req-error-41
exception type/message exposed to client = false실제 컨테이너 통합 테스트에서는 최초 컨트롤러가 예외를 던지게 하고 dispatch 기록이 REQUEST, ERROR인지 확인합니다. MockMvc가 resolver 단계에서 예외를 호출자에게 다시 던지는 설정과 컨테이너의 오류 재디스패치는 동일하지 않으므로 두 테스트가 주장하는 범위를 섞지 않습니다.
응답 commit 이후에는 ProblemDetail로 되돌릴 수 없다
헤더와 본문 일부가 전송된 뒤 예외가 나면 status와 content type을 더 이상 바꿀 수 없습니다. 이때 기존 CSV 세 줄 뒤에 JSON ProblemDetail을 이어 붙이면 두 형식 모두 깨집니다.
예시
CSV를 세 줄 write()했다는 횟수만으로 응답이 이미 커밋됐다고 판단하지 않습니다. 아직 버퍼 안에 있고 isCommitted()가 false라면 오류 처리 전에 버퍼를 정리해 일관된 응답을 만들 여지가 있습니다. 반대로 flushBuffer()나 버퍼 초과 등으로 일부가 실제 전송돼 isCommitted()가 true라면 뒤에서 JSON을 이어 쓰는 것으로 앞 CSV를 되돌릴 수 없습니다. Servlet 응답 계약은 커밋 전후의 버퍼 초기화 조건을 구분합니다. 아래 연습에서는 세 줄 뒤에 커밋된 경우와 아직 버퍼에만 있는 경우를 따로 재현하세요.
가능한 전략은 응답 형태에 따라 달라집니다.
- 전체 파일을 임시 위치에 먼저 생성하고 성공 뒤에만 불변 다운로드 키를 공개합니다.
- 장시간 stream은 레코드별 오류 표식이나 재시도 cursor를 protocol에 포함합니다.
- 복구 계약이 없다면 연결을 종료하고 클라이언트가 불완전 전송을 탐지하게 합니다.
sendError는 컨테이너 오류 처리와 본문 생성을 촉발할 수 있지만 setStatus는 status만 설정합니다. 프레임워크가 선택한 경로를 관찰하고 같은 응답에서 둘을 혼합하지 않습니다.
연습 문제
CSV 내보내기가 세 줄을 쓴 뒤 repository 예외를 내는 상황을 재현하세요. 일반 advice가 JSON ProblemDetail로 바꿀 수 없는 이유를 확인하고, 생성 작업과 다운로드 endpoint를 분리해 실패한 파일이 공개되지 않게 설계하세요.
해설 보기
작업은 성공 상태에서만 object key를 가질 수 있게 불변식을 둡니다. 워커는 전체 파일과 checksum을 확정한 뒤에만 complete를 호출합니다.
package board.export;
import java.util.Objects;
public record ExportJob(
long id,
Status status,
String objectKey
) {
public ExportJob {
if (id <= 0) {
throw new IllegalArgumentException(
"id must be positive");
}
Objects.requireNonNull(status, "status");
if (status == Status.SUCCEEDED
&& (objectKey == null || objectKey.isBlank())) {
throw new IllegalArgumentException(
"succeeded job requires objectKey");
}
if (status != Status.SUCCEEDED && objectKey != null) {
throw new IllegalArgumentException(
"unfinished job must not expose objectKey");
}
}
public ExportJob complete(String key) {
if (status != Status.RUNNING) {
throw new IllegalStateException(
"only running job can complete");
}
return new ExportJob(id, Status.SUCCEEDED, key);
}
public enum Status {
PENDING,
RUNNING,
SUCCEEDED,
FAILED
}
}실패 작업은 내부 예외 대신 안정적인 오류 코드를 저장하고 objectKey를 남기지 않습니다. 다운로드는 성공 상태와 checksum이 모두 맞을 때만 파일을 공개합니다.
독립 확인
서로 다른 fixture에서 A는 new ExportJob(8, RUNNING, null), B는 new ExportJob(9, FAILED, null)로 만듭니다. A에서 complete("exports/8.csv")의 반환값을 C에 저장하고, A의 상태·키와 C의 상태·키를 비교하세요. B의 complete()와 new ExportJob(10, FAILED, "exports/10.csv")는 허용되나요? C만 얻었다고 파일 checksum과 공개 권한까지 확인됐다고 판단할 수도 있나요?
불변 객체의 완료와 실제 파일 공개를 나누는 풀이
A는 여전히 RUNNING이고 키는 null입니다. complete()가 새 record를 반환하므로 C가 SUCCEEDED와 exports/8.csv를 가집니다. 반환값을 저장하지 않으면 A 자체가 바뀌지 않습니다. B는 RUNNING이 아니어서 complete()가 IllegalStateException을 던지고, FAILED에 non-null 키를 넣는 생성은 불변식 위반으로 IllegalArgumentException입니다.
C의 생성자는 성공 상태의 비어 있지 않은 키만 확인합니다. 실제 object가 존재하는지, 전체 CSV와 checksum이 확정됐는지, 현재 회원이 다운로드할 권한이 있는지는 이 record가 검증하지 않습니다. 워커의 확정 절차와 다운로드 endpoint의 검사, 상태 저장·복구 계약을 별도로 연결해야 합니다. 이 문제의 예외는 원 생성자·메서드에서 도출한 결과이며 파일 저장소나 HTTP 서버를 실행한 결과는 아닙니다.
다음 문서에서는 servlet fallback보다 앞에서 동작하는 MVC exception resolver와 controller advice의 우선순위를 다룹니다.