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

안동민 개발노트

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

API 예외 응답

Spring MVC 예외 리졸버의 우선순위를 추적하고 게시판의 찾을 수 없음·충돌·검증·알 수 없는 실패를 안정적인 RFC 9457 응답으로 매핑합니다.

컨트롤러 또는 서비스 예외가 서블릿 컨테이너까지 올라가기 전에 Spring MVC의 HandlerExceptionResolver 목록이 처리 기회를 갖습니다. 처리한 리졸버는 상태·헤더·본문 또는 뷰를 완성하고 예외 전파를 끝냅니다. 따라서 도메인 예외를 HTTP로 바꾸는 규칙을 한 어드바이스에 모으면 엔드포인트마다 반복되는 try·예외 처리와 서로 다른 오류 JSON을 줄일 수 있습니다.


예외 리졸버 순서

MVC 기본 구성에는 @ExceptionHandler를 찾는 리졸버, @ResponseStatusResponseStatusException을 해석하는 리졸버, Spring MVC 표준 예외를 상태로 바꾸는 리졸버가 있습니다. 사용자 정의 리졸버를 추가할 때 기존 목록을 덮어쓸지 확장할지 주의합니다.

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

import java.net.URI;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public final class RequestExceptionAdvice {
    @ExceptionHandler(PostNotFoundException.class)
    ProblemDetail postNotFound(PostNotFoundException exception) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND,
                "요청한 게시글을 찾을 수 없습니다.");
        problem.setType(URI.create(
                "https://board.example/problems/post-not-found"));
        problem.setTitle("게시글 없음");
        problem.setProperty("code", "post-not-found");
        problem.setProperty("postId", exception.postId());
        return problem;
    }

    @ExceptionHandler(DuplicatePostException.class)
    ProblemDetail duplicate(DuplicatePostException exception) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.CONFLICT,
                "같은 제목과 날짜의 기록이 이미 존재합니다.");
        problem.setType(URI.create(
                "https://board.example/problems/post-conflict"));
        problem.setTitle("게시글 충돌");
        problem.setProperty("code", "post-conflict");
        return problem;
    }
}

찾을 수 없음과 충돌은 같은 RuntimeException이어도 클라이언트 행동이 다릅니다. 404는 URI를 확인하거나 목록으로 돌아가고, 409는 현재 상태를 새로 조회해 입력을 조정할 수 있습니다. 스택 트레이스와 SQL 제약 조건 이름은 detail에 넣지 않습니다.

ProblemDetail 필드

RFC 9457의 type은 문제 종류의 안정적인 URI, title은 짧은 요약, status는 HTTP 상태, detail은 이 발생에 대한 사람이 읽는 설명, instance는 발생 리소스 식별에 사용됩니다. 사용자 정의 code, fields, requestId는 확장 속성으로 추가할 수 있습니다.

필드안정성클라이언트 사용
type버전이 있는 문제 종류문서와 분기 기준
statusHTTP 의미재인증·재시도 판단
title, detail로케일·문구 변경 가능사용자 표시
code애플리케이션 계약세밀한 프로그램 분기
requestId발생별 고유지원 문의와 로그 연결

status 본문과 실제 응답 상태가 어긋나지 않게 프레임워크가 ProblemDetail 상태를 사용하도록 합니다. type=about:blank만 모든 오류에 쓰면 기계-읽을 수 있는 구분을 잃습니다. 반대로 예외 인스턴스마다 새 타입 URI를 만들면 규칙 카디널리티가 폭발합니다.

클라이언트·서버 오류 분리

전체 예외 처리 핸들러는 내부 예외를 500으로 정규화하고 요청 ID로 추적하되 원인을 로그에서 잃지 않아야 합니다. 단, 모든 예외를 200이나 400으로 바꾸지 않습니다. NullPointerException을 “잘못된 요청”으로 처리하면 서버 결함이 클라이언트 책임으로 숨습니다.

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

import java.net.URI;

import jakarta.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public final class UnexpectedExceptionAdvice {
    private static final Logger log = LoggerFactory.getLogger(
            UnexpectedExceptionAdvice.class);

    @ExceptionHandler(Exception.class)
    ProblemDetail unexpected(
            Exception exception,
            HttpServletRequest request
    ) {
        String requestId = String.valueOf(
                request.getAttribute("requestId"));
        log.error("unexpected request failure requestId={}",
                requestId, exception);

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR,
                "잠시 후 다시 시도하세요.");
        problem.setType(URI.create(
                "https://board.example/problems/internal"));
        problem.setTitle("요청 처리 실패");
        problem.setProperty("code", "internal-error");
        problem.setProperty("requestId", requestId);
        return problem;
    }
}

요청 속성이 없을 때 문자열 null을 넣는 대신 실제 구현에서는 선택적 처리와 트레이스 컨텍스트를 사용합니다. 클라이언트가 보낸 요청 ID를 검증 없이 그대로 신뢰하지 않습니다. 로그에는 예외를 마지막 인자로 전달해 스택 트레이스를 남기고 응답에는 일반 문구만 둡니다.

전체 예외 처리 어드바이스가 프레임워크의 표준 예외 처리보다 먼저 가로채지 않는지도 확인합니다. 메서드 인자 타입 불일치, 지원하지 않는 미디어 타입, 핸들러 없음 같은 MVC 예외는 각각 400, 415, 404 의미가 있습니다. ResponseEntityExceptionHandler를 상속해 필요한 메서드만 재정의하거나 어드바이스 순서를 명시할 수 있습니다.

리졸버와 ERROR 디스패치

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

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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest({PostApiQueryController.class, RequestExceptionAdvice.class})
class RequestExceptionAdviceTest {
    @Autowired
    MockMvc mvc;

    @MockitoBean
    PostQuery query;

    @Test
    void domain_not_found는_404_ProblemDetail로_해결된다()
            throws Exception {
        org.mockito.Mockito.when(query.required(999L))
                .thenThrow(new PostNotFoundException(999L));

        mvc.perform(get("/api/posts/999"))
                .andExpect(status().isNotFound())
                .andExpect(content().contentTypeCompatibleWith(
                        "application/problem+json"))
                .andExpect(jsonPath("$.type").value(
                        "https://board.example/problems/post-not-found"))
                .andExpect(jsonPath("$.code").value("post-not-found"))
                .andExpect(jsonPath("$.postId").value(999));
    }
}
resolver 처리 결과
GET /api/posts/999 -> 404
Content-Type = application/problem+json
code = post-not-found
postId = 999
servlet ERROR redispatch count = 0

MockMvc 검증은 본문 스키마와 상태를 함께 고정합니다. resolvedException으로 내부 클래스도 확인할 수 있지만 외부 계약 테스트는 특정 예외 구현보다 응답 의미에 집중합니다. 별도 단위 테스트에서 도메인 예외의 페이로드를 검증합니다.

HTML·API 오류 표현

브라우저 HTML 상세 페이지의 찾을 수 없음은 오류 뷰와 목록 링크가 더 유용하고, API는 ProblemDetail이 적절합니다. 하나의 어드바이스에서 수락 헤더로 복잡한 분기를 만들거나 HTML 컨트롤러와 API 컨트롤러 범위별 어드바이스를 분리할 수 있습니다. 같은 도메인 실패가 상태 의미를 바꾸지는 않아야 합니다.

예외 핸들러에서 재시도, DB 쓰기, 이메일 전송 같은 부수 효과를 시작하지 않습니다. 리졸버는 이미 실패한 요청의 표현을 책임지고 원래 트랜잭션이 롤백됐는지 확인 가능한 범위에 머뭅니다. 오류 통계는 이벤트나 관찰 가능성 계층에서 수집합니다.

핸들러가 null인 미매핑 상황, 필터에서 난 예외, 응답 커밋 후 예외는 컨트롤러 어드바이스가 처리하지 못할 수 있습니다. 따라서 리졸버는 전체 오류 시스템의 한 층이며 서블릿 대체와 프록시 오류 페이지를 함께 설계합니다.

연습 문제

게시판 수정 API에 낙관적 잠금 충돌을 추가하세요. 리포지토리의 버전 불일치를 post-version-conflict 409 ProblemDetail로 바꾸고 현재 버전과 클라이언트가 보낸 버전을 확장 필드에 넣으세요. SQL이나 엔티티 전체는 노출하지 말고 재조회 링크를 제공할지 결정합니다.

해설 보기

애플리케이션 계층은 영속성 예외를 도메인 의미가 있는 예외로 바꿉니다. 웹 어드바이스는 그 예외만 알아야 JPA에서 JDBC로 바뀌어도 API 규칙이 유지됩니다.

package board.application;

public final class PostVersionConflictException
        extends RuntimeException {
    private final long postId;
    private final long expectedVersion;
    private final long actualVersion;

    public PostVersionConflictException(
            long postId,
            long expectedVersion,
            long actualVersion
    ) {
        super("post version conflict");
        this.postId = postId;
        this.expectedVersion = expectedVersion;
        this.actualVersion = actualVersion;
    }

    public long postId() { return postId; }
    public long expectedVersion() { return expectedVersion; }
    public long actualVersion() { return actualVersion; }
}

테스트는 응답 409, 안정적 타입과 코드, 세 버전 필드를 확인합니다. 예외 메시지와 스택 트레이스가 JSON에 없는지도 검증합니다.

다음 문서에서는 요청 문자열과 도메인 값 객체 사이를 연결하는 Converter와 로케일-인식 Formatter의 선택 기준을 다룹니다.