서블릿 오류 디스패치
미처리 예외가 REQUEST에서 ERROR로 재디스패치될 때 원래 URI·상태·요청 ID를 복원하고, 중복 부수 효과와 커밋된 응답의 한계를 실제 Spring MVC handler로 검증합니다.
컨트롤러에서 처리되지 않은 예외가 servlet 컨테이너까지 올라가면 최초 REQUEST가 그대로 끝나는 것만은 아닙니다. 컨테이너는 상태와 예외 정보를 request attribute에 저장하고 오류 경로로 DispatcherType.ERROR 재전송할 수 있습니다.
현재 URI가 /error로 바뀌어도 원래 요청은 사라지지 않습니다. 두 dispatch를 한 요청의 두 구간으로 관찰하고, 내부 예외 정보는 서버 진단에만 사용합니다.
STATE MACHINE · REQUEST → ERROR
미처리 예외는 같은 요청의 ERROR 구간으로 다시 들어온다
MVC resolver가 처리하지 못한 예외를 container가 ERROR dispatch로
전환한다. 현재 URI는 /error지만 원래 URI·status와
request-lifetime ID는 별도 attribute에서 복원한다.
STATE 1 · REQUEST
원래 handler 처리
/posts/999에서 controller와 MVC resolver가 실행된다.
처리 완료된 예외는 여기서 HTTP 응답으로 끝난다.
unhandled exception만 container 경계를 넘는다.
STATE 2 · ERROR
오류 endpoint 재디스패치
현재 path /error와
ERROR_REQUEST_URI=/posts/999를 구분해 읽고,
안전한 ProblemDetail을 만든다.
예외 class·message·stack trace는 server 진단에만 남긴다.
- PRESERVE
-
request-lifetime ID.
각 dispatch 안에서 public
requestId와 MDC로 다시 게시한다. - RESTORE
-
original URI + status.
표준
RequestDispatcher.ERROR_*attribute에서 복원한다. - DO ONCE
- rate limit · session touch. 최초 REQUEST에서만 차감하거나 갱신한다.
- COMMIT BOUNDARY
- status · header · body. commit 뒤에는 ProblemDetail로 원자적으로 교체할 수 없다.
dispatcher ERROR · current /error · original /posts/999 · handler BoardErrorController#error · client exception details 0
- 두 번째 ERROR dispatch
- container가 만든 전이
MockMvc의 명시적 ERROR attribute 테스트는 두 번째 dispatch handler의 계약을 증명한다. 최초 예외부터의 container 재디스패치 전체는 별도 내장-container 통합 테스트가 소유한다.
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을 이어 붙이면 두 형식 모두 깨집니다.
가능한 전략은 응답 형태에 따라 달라집니다.
- 전체 파일을 임시 위치에 먼저 생성하고 성공 뒤에만 불변 다운로드 키를 공개합니다.
- 장시간 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이 모두 맞을 때만 파일을 공개합니다.
다음 문서에서는 servlet fallback보다 앞에서 동작하는 MVC exception resolver와 controller advice의 우선순위를 다룹니다.