서블릿 오류 디스패치
미처리 예외가 REQUEST에서 ERROR로 재디스패치될 때 원래 URI·상태·요청 ID를 복원하고, 중복 부수 효과와 커밋된 응답의 한계를 실제 Spring MVC handler로 검증합니다.
컨트롤러에서 처리하지 못한 예외가 서블릿 컨테이너까지 올라가면, 처음의 REQUEST가 그대로 끝나지 않을 수 있습니다. 컨테이너는 상태와 예외 정보를 요청 속성에 저장한 뒤, 오류 경로로 DispatcherType.ERROR를 다시 보낼 수 있습니다. 현재 URI가 /error로 바뀌어도 원래 요청은 사라지지 않으므로, 두 디스패치를 한 요청의 두 구간으로 보고 내부 예외 정보는 서버의 진단에만 씁니다.
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 복구는 별도 검사입니다.
그림은 이 글의 MockMvc가 ERROR 요청과 표준 속성을 직접 만들고, ServletErrorSnapshot이 현재 URI /error와 원래 URI, 상태, 요청 ID를 구분해 복원한 뒤, BoardErrorController가 안전한 ProblemDetail을 만드는 순서를 보여 줍니다. 아래 칸들은 404와 알 수 없는 799 상태의 결과, 내부 정보를 노출하지 않는 응답, 디스패치마다의 필터 처리, 커밋된 뒤의 CSV 문제를 따로 설명합니다. 처음의 REQUEST에서 예외가 났을 때 컨테이너가 자동으로 다시 디스패치하는 전체 과정과, 커밋 뒤의 스트림 복구는 별도로 검사해야 합니다.
REQUEST와 ERROR는 같은 요청의 다른 디스패치입니다
/posts/999 핸들러가 예외를 던지고 MVC 리졸버가 처리하지 못했다고 합시다. 컨테이너는 오류 매핑을 고르고, 다음 표준 속성을 채운 뒤 /error를 호출합니다.
RequestDispatcher.ERROR_STATUS_CODE: 최종 후보 HTTP 상태RequestDispatcher.ERROR_REQUEST_URI: 처음 요청한 URIRequestDispatcher.ERROR_EXCEPTION_TYPE: 서버가 기록할 예외 타입RequestDispatcher.ERROR_MESSAGE: 바깥 응답에 그대로 내보내지 않을 원문
스냅샷은 현재 경로와 원래 경로를 구분하고, 서블릿 필터에서 만든 RequestTraceFilter.REQUEST_ID_ATTRIBUTE를 읽습니다. 오류용 필터나 두 번째 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는 접근이 제한된 서버 로그에만 쓸 수 있습니다. 쿼리 문자열이나 스택 트레이스, SQL, 사용자 입력의 원문을 오류 본문에 복사하지 않습니다. 사용자가 문의할 때 쓸 값은 검증된 요청 ID와 바뀌지 않는 문제 코드입니다.
/error는 커밋 전의 마지막 서블릿 안전망입니다
Spring Boot 4의 서블릿 MVC ErrorController는 org.springframework.boot.webmvc.error.ErrorController에 있습니다. 오류 컨트롤러는 표준 상태만 그대로 쓰고, 알 수 없는 상태는 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 "잠시 후 다시 시도하세요.";
}
}도메인 예외를 컨트롤러 어드바이스가 먼저 처리했다면 이 컨트롤러까지 오지 않습니다. /error는 핸들러 이전의 실패와 매핑되지 않은 경로, 끝까지 처리되지 않은 예외를 위한 대체 경로일 뿐, 모든 업무 오류를 모아 처리하는 곳이 아닙니다.
더 깊이: 컨테이너는 /error를 어떻게 알게 될까
/error는 서블릿 명세가 정한 이름이 아닙니다. Spring Boot 4.1.2의 ErrorMvcAutoConfiguration은 ErrorPageCustomizer로 상태 코드나 예외 타입을 지정하지 않은 전역 오류 페이지(global error page) 하나를 컨테이너에 등록하고, 그 경로를 spring.web.error.path 속성(기본값 /error)에서 가져옵니다. 그래서 처리되지 않은 예외든 sendError든, 따로 매핑한 오류 페이지가 없으면 컨테이너는 이 경로로 ERROR 디스패치를 보냅니다. Spring Boot 3까지 server.error.*였던 오류 처리 속성은 4.0부터 spring.web.error.* 아래로 옮겨졌으므로, 예전 자료의 속성 이름을 그대로 쓰면 적용되지 않습니다.
같은 자동 구성은 ErrorController 타입의 빈이 없을 때만 기본 BasicErrorController를 등록합니다. 이 글의 BoardErrorController가 ErrorController를 구현하므로 기본 컨트롤러는 물러나고, 같은 /error 경로를 두 컨트롤러가 함께 매핑하는 일이 생기지 않습니다. 기본 컨트롤러를 그대로 쓸 때 응답에 무엇을 담을지는 ErrorProperties가 정하는데, Spring Boot 4.1.2의 소스에서 include-message, include-binding-errors, include-stacktrace의 기본값은 never이고 include-exception은 false이며, include-path만 always입니다. 기본 오류 응답도 예외 메시지와 스택 트레이스를 내보내지 않도록 시작한다는 뜻입니다.
디스패치마다 필터가 할 일을 나눕니다
일반 필터가 REQUEST와 ERROR에 모두 등록되면, 요청 수 제한의 차감이나 세션 접근 시각의 갱신, 인증 리다이렉트가 두 번 실행될 수 있습니다. 반면 요청 ID는 새로 만들지 않고 같은 값을 이어 써야 두 디스패치가 하나의 추적으로 이어집니다.
| 동작 | REQUEST | ERROR |
|---|---|---|
| 요청 ID | 요청 수명 동안의 private 값을 만들고 공개 속성에 게시 | private 값에서 공개 속성에 다시 게시 |
| MDC | 지금의 디스패치 동안 설정 | 같은 ID로 다시 설정한 뒤 이전 값으로 복원 |
| 인증 리다이렉트 | 보호된 핸들러 앞에서만 | 오류 엔드포인트는 제외 |
| 요청 수 제한과 세션 갱신 | 처음 요청에서 한 번 | 다시 차감하거나 갱신하지 않음 |
| 상태 로그 | 아직 중간 상태일 수 있음 | 최종 상태와 원래 URI를 함께 기록 |
서블릿 필터에서 만든 RequestTraceFilter는 요청 수명 동안의 private 속성에 안정된 ID를 보존하고, ERROR 디스패치에서도 다시 실행됩니다. 각 디스패치 안에서만 공개 requestId 속성과 MDC에 같은 값을 게시하고, finally에서 각각의 이전 값을 복원하며, 이전 값이 없었다면 지웁니다.
이 동작이 OncePerRequestFilter의 기본값만으로 저절로 지켜진다고 가정하지 않습니다. shouldNotFilterErrorDispatch()의 선택과 private 저장소, 공개 속성과 MDC의 짝이 맞는 게시와 복원을 코드와 테스트가 함께 지켜야 합니다.
지금의 /error만 메트릭 레이블로 남기면 원인이 된 경로가 사라집니다. 원래 URI는 진단 필드로 보존하되, 메트릭에는 /posts/{id}처럼 정규화된 경로 패턴을 써서 카디널리티를 제한합니다.
단위 테스트는 표준 ERROR 속성의 복원을 고정합니다
스냅샷 테스트는 현재 URI가 /error여도 원래 URI와 404 상태, 예외 타입, 요청 ID가 복원되는 것을 고정합니다. 테스트는 컨테이너 없이 MockHttpServletRequest에 서블릿 컨테이너가 ERROR 디스패치 때 넣는 표준 속성을 직접 채웁니다. ERROR_MESSAGE에 넣은 내부 문구는 스냅샷의 message에 담길 뿐 BoardErrorController가 응답 본문에 쓰지 않으며, 이 점은 다음 절의 MVC 테스트가 $.message가 없는지로 확인합니다.
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 핸들러 도달과 안전한 본문을 확인합니다
MockMvc에서 ERROR 디스패처 타입과 표준 요청 속성을 직접 설정합니다. 이것은 내장 컨테이너가 처음의 예외를 두 번째 디스패치로 바꾸는 전체 동작을 흉내 내는 테스트가 아니라, 두 번째 디스패치가 도착했을 때 정확한 핸들러와 응답을 검증하는 테스트입니다.
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실제 컨테이너의 통합 테스트에서는 처음 컨트롤러가 예외를 던지게 하고, 디스패치 기록이 REQUEST, ERROR인지 확인합니다. MockMvc가 리졸버 단계에서 예외를 호출자에게 다시 던지는 설정과 컨테이너의 오류 재디스패치는 같지 않으므로, 두 테스트가 확인하는 범위를 섞지 않습니다.
응답이 커밋된 뒤에는 ProblemDetail로 되돌릴 수 없습니다
헤더와 본문의 일부가 전송된 뒤 예외가 나면 상태와 콘텐츠 타입을 더 이상 바꿀 수 없습니다. 이때 이미 보낸 CSV 세 줄 뒤에 JSON ProblemDetail을 이어 붙이면 두 형식이 모두 깨집니다.
CSV를 세 줄 write()했다는 횟수만으로 응답이 커밋되었다고 판단하지는 않습니다. 아직 버퍼 안에 있고 isCommitted()가 거짓이라면, 오류를 처리하기 전에 버퍼를 비워 일관된 응답을 만들 여지가 있습니다. 반대로 flushBuffer()나 버퍼 초과로 일부가 실제로 전송되어 isCommitted()가 참이라면, 뒤에 JSON을 이어 써도 앞의 CSV를 되돌릴 수 없습니다. 서블릿 응답 명세는 커밋 전후의 버퍼 초기화 조건을 구분합니다. 응답의 형태에 따라 쓸 수 있는 전략은 다음과 같습니다.
- 파일 전체를 임시 위치에 먼저 만들고, 성공한 뒤에만 바뀌지 않는 다운로드 키를 공개합니다.
- 오래 걸리는 스트림은 레코드별 오류 표시나 재시도 커서를 프로토콜에 넣습니다.
- 복구 방법을 정해 두지 않았다면 연결을 끊고, 클라이언트가 불완전한 전송을 알아채게 합니다.
sendError는 컨테이너의 오류 처리와 본문 생성을 일으킬 수 있지만, setStatus는 상태만 설정합니다. 프레임워크가 고른 경로를 확인하고, 한 응답에서 둘을 섞지 않습니다.
정리
- 처리되지 못한 예외는 컨테이너가 표준 속성을 채운 뒤 같은 요청을
DispatcherType.ERROR로/error에 다시 보낼 수 있습니다. - 오류 컨트롤러는 현재 URI와 원래 URI를 구분하고, 알 수 없는 상태는 500으로 정규화하며, 예외 타입과 원문은 응답에 넣지 않습니다.
- 요청 ID는 ERROR 디스패치에서도 같은 값을 다시 게시하고, 요청 수 제한이나 세션 갱신 같은 부수 효과는 처음 요청에서 한 번만 실행합니다.
- MockMvc로 직접 만든 ERROR 요청은 핸들러와 응답만 확인하므로, 컨테이너의 자동 재디스패치는 실제 컨테이너 테스트로 확인합니다.
- 응답이 커밋된 뒤에는 상태와 형식을 바꿀 수 없으므로, 내보내기는 생성과 다운로드를 나누거나 스트림의 프로토콜에 복구 방법을 넣습니다.
연습 문제
1. 세 줄 쓴 뒤에 실패하는 CSV 내보내기
CSV 내보내기가 세 줄을 쓴 뒤 저장소 예외를 내는 상황을 재현하려고 합니다. 일반 어드바이스가 이 응답을 JSON ProblemDetail로 바꿀 수 없는 이유를 확인하고, 생성 작업과 다운로드 엔드포인트를 나눠 실패한 파일이 공개되지 않게 하려면 어떻게 설계해야 할까요?
해설 보기
세 줄이 실제로 전송되어 응답이 커밋되었다면, 어드바이스가 상태와 콘텐츠 타입을 바꾸거나 이미 보낸 CSV를 지울 수 없습니다. 그래서 작업은 성공 상태일 때만 객체 키를 가질 수 있도록 불변식을 두고, 워커는 파일 전체와 체크섬을 확정한 뒤에만 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를 남기지 않습니다. 다운로드는 성공 상태와 체크섬이 모두 맞을 때만 파일을 공개합니다. 테스트에서는 커밋된 경우와 아직 버퍼에만 있는 경우를 따로 재현해, 앞의 경우는 연결이 끊기고 뒤의 경우는 버퍼를 비운 뒤 오류 응답을 만들 수 있는지 확인합니다.
2. 불변 객체의 완료와 실제 파일 공개
서로 다른 예제에서 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를 얻었다는 것만으로 파일의 체크섬과 공개 권한까지 확인되었다고 판단할 수 있나요?
해설 보기
A는 여전히 RUNNING이고 키는 null입니다. complete()가 새 레코드를 돌려주므로 C가 SUCCEEDED와 exports/8.csv를 가지며, 돌려준 값을 저장하지 않으면 A 자체는 바뀌지 않습니다. B는 RUNNING이 아니므로 complete()가 IllegalStateException을 던지고, FAILED에 null이 아닌 키를 넣는 생성은 불변식 위반이라 IllegalArgumentException이 납니다.
C의 생성자는 성공 상태에 비어 있지 않은 키가 있는지만 확인합니다. 실제 객체가 있는지, CSV 전체와 체크섬이 확정되었는지, 현재 회원에게 다운로드할 권한이 있는지는 이 레코드가 검증하지 않습니다. 워커의 확정 절차와 다운로드 엔드포인트의 검사, 상태 저장과 복구를 따로 연결해야 합니다. 이 문제의 예외는 생성자와 메서드의 코드에서 끌어낸 결과이며, 파일 저장소나 HTTP 서버를 실행한 결과는 아닙니다.
다음 글 API 예외 응답에서는 서블릿의 대체 경로보다 앞에서 동작하는 MVC 예외 리졸버와 컨트롤러 어드바이스의 우선순위를 다룹니다.