본문으로 건너뛰기

안동민 개발노트

본문 시작

서블릿 오류 디스패치

미처리 예외가 REQUEST에서 ERROR로 재디스패치될 때 원래 URI·상태·요청 ID를 복원하고, 중복 부수 효과와 커밋된 응답의 한계를 실제 Spring MVC handler로 검증합니다.

컨트롤러에서 처리되지 않은 예외가 servlet 컨테이너까지 올라가면 최초 REQUEST가 그대로 끝나는 것만은 아닙니다. 컨테이너는 상태와 예외 정보를 request attribute에 저장하고 오류 경로로 DispatcherType.ERROR 재전송할 수 있습니다.

현재 URI가 /error로 바뀌어도 원래 요청은 사라지지 않습니다. 두 dispatch를 한 요청의 두 구간으로 관찰하고, 내부 예외 정보는 서버 진단에만 사용합니다.

미처리 예외는 같은 요청의 ERROR 구간으로 다시 들어온다

STATE MACHINE · REQUEST → ERROR

미처리 예외는 같은 요청의 ERROR 구간으로 다시 들어온다

MVC resolver가 처리하지 못한 예외를 container가 ERROR dispatch로 전환한다. 현재 URI는 /error지만 원래 URI·status와 request-lifetime ID는 별도 attribute에서 복원한다.

미처리 예외는 같은 요청의 ERROR 구간으로 다시 들어온다 최초 REQUEST의 핸들러 예외는 MVC resolver가 처리하면 바로 응답이 되고, 처리하지 못하면 container가 같은 요청을 ERROR로 다시 디스패치한다. 오류 컨트롤러는 현재 error 경로와 원래 URI를 구분하며, 추적 필터는 같은 요청 수명 ID를 각 dispatch 안에 다시 게시하고 종료 시 이전 attribute와 MDC를 복원한다. REQUEST DISPATCH · UNHANDLED TRANSITION · ERROR DISPATCH REQUEST /posts/999 원래 handler 실행 trace context 게시 MVC RESOLVERS exception handled? yes → HTTP 응답 종료 no → container 경계 CONTAINER unhandled failure ERROR attributes 설정 같은 request 재사용 ERROR DISPATCH current URI = /error original = /posts/999 stable ID 다시 게시 HANDLER BoardError Controller safe response HANDLED IN MVC REQUEST에서 종료 ERROR handler 미선택 SAFE PROBLEM DETAIL status + original URI exception details 공개 0 REQUEST TRACE FILTER private stable request ID REQUEST에서 최초 확정 EACH DISPATCH SCOPE public attr + MDC 재게시 finally에서 prior value 복원·없으면 제거 RATE LIMIT · SESSION TOUCH: REQUEST ONCE · STATUS/HEADER/BODY: BEFORE COMMIT

STATE 1 · REQUEST

원래 handler 처리

/posts/999에서 controller와 MVC resolver가 실행된다. 처리 완료된 예외는 여기서 HTTP 응답으로 끝난다.

unhandled exception만 container 경계를 넘는다.

STATE 2 · ERROR

오류 endpoint 재디스패치

현재 path /errorERROR_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: 최초 요청 URI
  • RequestDispatcher.ERROR_EXCEPTION_TYPE: 서버가 기록할 예외 타입
  • RequestDispatcher.ERROR_MESSAGE: 외부 응답에 그대로 내보내지 않을 원문

스냅샷은 현재 경로와 원래 경로를 구분하고, 앞 문서의 RequestTraceFilter.REQUEST_ID_ATTRIBUTE를 읽습니다. 오류용 Filter나 두 번째 ID 생성기는 만들지 않습니다.

src/main/java/board/web/errorpath/ServletErrorSnapshot.java
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
                        : "");
    }
}

exceptionTypemessage는 제한된 서버 로그에만 쓸 수 있습니다. 쿼리 문자열, stack trace, SQL, 사용자 입력 원문을 오류 본문에 복사하지 않습니다. 사용자가 지원 문의에 사용할 값은 검증된 요청 ID와 안정적인 문제 코드입니다.


/error는 commit 전 마지막 servlet 안전망이다

Spring Boot 4의 servlet MVC ErrorControllerorg.springframework.boot.webmvc.error.ErrorController에 있습니다. 오류 컨트롤러는 표준 status만 유지하고, 알 수 없는 status는 500으로 정규화합니다.

src/main/java/board/web/errorpath/BoardErrorController.java
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 책임을 구분한다

일반 FilterREQUESTERROR에 모두 등록되면 rate limit 차감, 세션 접근 시각 갱신, 인증 redirect가 두 번 실행될 수 있습니다. 반면 요청 ID는 새로 만들지 않고 같은 값을 이어 써야 두 dispatch가 한 trace로 연결됩니다.

동작REQUESTERROR
요청 IDprivate 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가 복원됨을 고정합니다.

src/test/java/board/web/errorpath/ServletErrorSnapshotTest.java
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와 응답 계약을 검증하는 테스트입니다.

src/test/java/board/web/errorpath/BoardErrorControllerMvcTest.java
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"));
    }
}
ERROR dispatch exact oracle
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을 이어 붙이면 두 형식 모두 깨집니다.

가능한 전략은 응답 형태에 따라 달라집니다.

  1. 전체 파일을 임시 위치에 먼저 생성하고 성공 뒤에만 불변 다운로드 키를 공개합니다.
  2. 장시간 stream은 레코드별 오류 표식이나 재시도 cursor를 protocol에 포함합니다.
  3. 복구 계약이 없다면 연결을 종료하고 클라이언트가 불완전 전송을 탐지하게 합니다.

sendError는 컨테이너 오류 처리와 본문 생성을 촉발할 수 있지만 setStatus는 status만 설정합니다. 프레임워크가 선택한 경로를 관찰하고 같은 응답에서 둘을 혼합하지 않습니다.


연습 문제

CSV 내보내기가 세 줄을 쓴 뒤 repository 예외를 내는 상황을 재현하세요. 일반 advice가 JSON ProblemDetail로 바꿀 수 없는 이유를 확인하고, 생성 작업과 다운로드 endpoint를 분리해 실패한 파일이 공개되지 않게 설계하세요.

해설 보기

작업은 성공 상태에서만 object key를 가질 수 있게 불변식을 둡니다. 워커는 전체 파일과 checksum을 확정한 뒤에만 complete를 호출합니다.

src/main/java/board/export/ExportJob.java
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의 우선순위를 다룹니다.