본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 웹 상태와 경계 처리

서블릿 오류 디스패치

게시판 컨트롤러 예외가 컨테이너 오류 페이지로 다시 디스패치되는 과정을 따라가며 디스패처 타입, 오류 속성, 필터 중복 실행과 커밋된 응답을 검증합니다.

서블릿 요청 처리 중 잡히지 않은 예외가 컨테이너까지 올라가면 최초 REQUEST가 그대로 끝나는 것만은 아닙니다. 컨테이너는 상태와 예외 정보를 요청 속성에 넣고 설정된 오류 경로로 DispatcherType.ERROR 재전송할 수 있습니다. 이 두 번째 디스패치를 모르고 필터와 컨트롤러 로그를 보면 요청이 두 번 실행됐거나 상태가 사라진 것처럼 보입니다.


REQUEST·ERROR 디스패치

예를 들어 /posts/999 핸들러가 PostNotFoundException을 던지고 아무 리졸버도 처리하지 않으면 컨테이너가 오류 처리를 시작합니다. 오류 디스패치에서는 URI가 /error일 수 있지만 원래 URI, 상태, 예외 타입은 RequestDispatcher.ERROR_* 속성에 남습니다.

src/main/java/board/web/ServletErrorSnapshot.java
package board.web;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;

public record ServletErrorSnapshot(
        int status,
        String originalUri,
        String exceptionType,
        String message
) {
    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);
        return new ServletErrorSnapshot(
                status instanceof Integer value ? value : 500,
                uri == null ? request.getRequestURI() : uri.toString(),
                type instanceof Class<?> value
                        ? value.getName()
                        : "unknown",
                message == null ? "" : message.toString());
    }
}

이 스냅샷을 그대로 클라이언트에 출력하면 내부 클래스와 예외 메시지가 노출됩니다. 운영 로그에는 요청 ID와 함께 제한적으로 남기고 사용자 응답에는 안정적인 문제 타입과 일반 설명을 제공합니다. 쿼리 문자열, 스택 트레이스, SQL은 오류 페이지에 표시하지 않습니다.

오류 컨트롤러 노출 정책

Spring Boot의 기본 /error 처리도 유용하지만, API 계약을 고정할 때는 ErrorAttributes를 확장하거나 예외 리졸버에서 미리 ProblemDetail을 만들 수 있습니다. 서블릿 오류 경계의 대체는 이미 응답이 커밋되지 않았을 때 마지막 안전망 역할을 합니다.

src/main/java/board/web/BoardErrorController.java
package board.web;

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.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("/error")
    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");
        return ResponseEntity.status(status).body(problem);
    }

    private String safeDetail(HttpStatus status) {
        if (status == HttpStatus.NOT_FOUND) {
            return "요청한 경로를 찾을 수 없습니다.";
        }
        return "잠시 후 다시 시도하세요.";
    }
}

Spring Boot 4의 서블릿 MVC 패키지와 오류 컨트롤러 API는 실제 사용 버전의 공식 문서와 컴파일 결과로 확인합니다. 프레임워크 내부 클래스 이름을 암기해 복사하지 않고 의존성 업그레이드 때 통합 테스트를 실행합니다. 컨트롤러 어드바이스가 처리한 도메인 예외는 이 대체까지 오지 않게 다음 문서에서 계층화합니다.

오류 디스패치 필터

일반 Filter가 REQUEST와 오류 모두에 등록되면 감사 카운터, 본문 래퍼, 인증 리다이렉트가 두 번 실행될 수 있습니다. 트레이싱은 같은 요청 ID를 유지한 채 각 디스패치를 구분해 기록할 수 있지만 세션 접근 시각 갱신이나 요청률 제한 차감은 최초 요청에서만 해야 할 수 있습니다.

OncePerRequestFilter#shouldNotFilterErrorDispatch()의 기본과 재정의 여부를 확인합니다. 오류 디스패치에서도 실행한다면 요청 속성으로 최초 처리 여부를 식별하고 finally 정리를 디스패처마다 대칭으로 수행합니다. /error가 인증 필터에 막혀 다시 오류를 만드는 반복도 테스트합니다.

동작REQUEST오류
요청 ID 생성없을 때 한 번기존 값 재사용
인증 리다이렉트보호 핸들러 전오류 엔드포인트에서는 제외
요청률 제한 차감한 번다시 차감하지 않음
최종 상태 로그중간 상태 가능최종 응답과 디스패치 표시

오류 디스패치의 경로만 로그하면 /error가 가장 많이 호출된 엔드포인트처럼 보입니다. 원래 URI와 선택된 경로, 디스패처 타입을 별도 필드로 기록합니다. 메트릭 레이블에 원시 URI를 쓰면 ID마다 카디널리티가 늘어나므로 정규화된 경로를 사용합니다.

오류 속성 검증

src/test/java/board/web/ServletErrorSnapshotTest.java
package board.web;

import static org.assertj.core.api.Assertions.assertThat;

import jakarta.servlet.RequestDispatcher;

import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;

class ServletErrorSnapshotTest {
    @Test
    void ERROR_attribute에서_원래_URI와_status를_복원한다() {
        var request = new MockHttpServletRequest("GET", "/error");
        request.setAttribute(
                RequestDispatcher.ERROR_STATUS_CODE, 404);
        request.setAttribute(
                RequestDispatcher.ERROR_REQUEST_URI, "/posts/999");
        request.setAttribute(
                RequestDispatcher.ERROR_EXCEPTION_TYPE,
                PostNotFoundException.class);
        request.setAttribute(
                RequestDispatcher.ERROR_MESSAGE,
                "post 999 was not found");

        ServletErrorSnapshot snapshot =
                ServletErrorSnapshot.from(request);

        assertThat(snapshot.status()).isEqualTo(404);
        assertThat(snapshot.originalUri()).isEqualTo("/posts/999");
        assertThat(snapshot.exceptionType())
                .isEqualTo(PostNotFoundException.class.getName());
    }

    static final class PostNotFoundException
            extends RuntimeException {
    }
}
ERROR dispatch 복원 결과
current URI = /error
original URI = /posts/999
dispatcher status = 404
exception type captured for server log = true
exception message exposed to client = false

실제 내장 컨테이너 테스트에서는 컨트롤러가 예외를 던지게 하고 필터의 디스패처 타입 기록을 확인합니다. MockMvc가 리졸버 단계에서 예외를 다시 던지는 설정과 컨테이너 오류 디스패치는 같지 않을 수 있으므로 테스트 도구의 범위를 구분합니다.

응답 커밋 이후 오류

스트리밍 응답 일부를 클라이언트에 보낸 뒤 예외가 나면 상태와 헤더를 이미 바꿀 수 없습니다. 컨테이너가 오류 디스패치를 시도해도 기존 본문과 오류 본문이 섞이거나 연결이 끊길 수 있습니다. 큰 내보내기와 서버 전송 이벤트는 일반 JSON 오류 계약과 다른 실패 전략이 필요합니다.

버퍼를 무한히 키워 모든 응답을 원자적으로 만들 수는 없습니다. 생성 작업을 먼저 완료한 뒤 다운로드 리소스를 제공하거나, 스트림 안에 레코드별 오류 프로토콜을 정의하거나, 연결 종료와 재시도 커서를 사용합니다. “예외는 항상 ProblemDetail로 변환된다”는 가정은 응답 커밋 전까지만 성립합니다.

sendErrorsetStatus도 다릅니다. sendError는 컨테이너 오류 처리와 본문 생성을 촉발할 수 있고, setStatus는 상태만 설정합니다. 프레임워크가 어떤 경로를 사용하는지 관찰하고 같은 응답에 둘을 섞지 않습니다.

연습 문제

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

해설 보기

POST로 내보내기 작업을 만들고 워커가 임시 위치에 전체 파일을 생성한 뒤 성공 상태에서만 불변 다운로드 키를 연결합니다. 실패 작업은 오류 코드를 저장하되 내부 예외는 서버 로그에만 둡니다.

package board.export;

public record ExportJob(long id, Status status, String objectKey) {
    public enum Status {
        PENDING, RUNNING, SUCCEEDED, FAILED
    }

    public ExportJob complete(String key) {
        if (status != Status.RUNNING || key == null || key.isBlank()) {
            throw new IllegalStateException("running job and key required");
        }
        return new ExportJob(id, Status.SUCCEEDED, key);
    }
}

테스트는 워커 실패 뒤 objectKey가 없고 다운로드가 404 또는 409인지 확인합니다. 성공 파일은 콘텐츠 길이와 체크섬을 저장해 부분 업로드가 노출되지 않게 합니다.

다음 문서에서는 컨테이너 대체보다 앞에서 동작하는 HandlerExceptionResolver와 컨트롤러 어드바이스가 예외를 HTTP ProblemDetail로 바꾸는 우선순위를 다룹니다.