Tiny Star

프로젝트/뉴스 요약 (AI)

[뉴스 요약] REST API 예외 응답 처리

하얀지 2025. 6. 25. 00:14

 

왜 지금?

기존 예외 처리

 

기능 구현에 집중하다 보니 예외 처리는 항상 뒷전으로 밀리곤 했다.
그러다 보니 코드 곳곳에서 쓸데없는 분기 처리나 중복 조건문이 생기고, 전체 흐름이 점점 복잡해지기 시작했다.
이런 구조가 쌓이면 결국 나중에 대대적인 리팩터링이 불가피해질 것 같다는 생각이 들어, 이번에는 예외 처리 구조부터 먼저 정리하고 들어가기로 했다.

 


 

 

구조

에러 처리 구조 클래스 다이어그램

 

전체적인 예외 처리 흐름은 복잡하지 않다.
핵심은 전역 예외 핸들러에서 원하는 형식으로 에러 응답을 통일해주고, 각 에러 상황에 맞는 에러 코드를 enum으로 정의해서 재사용하는 구조다.

이렇게 구성하면 컨트롤러나 서비스 레이어에서는 `throw new CustomException(...)` 한 줄로 예외를 던질 수 있고, 핸들러에서는 이를 받아 `status`, `code`, `message` 세 가지 정보로 정제해 프론트엔드에 전달하게 된다.

 

 

나는 이 구조를 가능한 단순하게 가져가고 싶었고, 에러 응답 포맷은 다음 기준을 고려해 설계했다.

 

  1. 구조를 단순화해 개발자가 쓰기 편할 것
    → 컨트롤러나 서비스에서 try-catch 없이 예외 던지기만 하면 됨
  2. 프론트엔드에서 처리하기 쉬울 것
    → 현재는 res.ok 여부만 확인한 뒤 message만 alert으로 보여주지만, 추후 code 필드를 기준으로 UI를 분기하거나, 특정 예외에 대한 사용자 대응을 세분화할 수 있음
  3. 문서화가 쉬울 것
    → ErrorCode를 enum으로 정의했기 때문에 전체 코드 목록을 반복문으로 순회해 .adoc 파일로 자동 생성 가능
    → RestDocs와 연동하면 에러 응답 포맷뿐 아니라, 사용 가능한 code 목록도 API 문서에 그대로 포함할 수 있다

 

 

 

상세 코드

1. ErrorCode

예외 처리의 기준이 되는 enum

 

에러 코드 관리는 서비스가 커질수록 점점 복잡해진다.
같은 400 오류라도 "요청값이 잘못됨", "URL이 비어 있음", "분석 횟수를 초과함" 등 상황은 다르지만 상태 코드는 동일한 경우가 많기 때문이다. 그래서 나는 아예 처음부터 `ErrorCode`를 enum으로 정의해두고, 모든 예외는 이 `ErrorCode`를 통해 구분하도록 했다.

@Getter
@AllArgsConstructor
public enum ErrorCode {
    INVALID_INPUT(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."),
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."),
    FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."),
    NOT_FOUND(HttpStatus.NOT_FOUND, "리소스를 찾을 수 없습니다."),
    RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청 가능 횟수를 초과했습니다."),
    INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");

    private final HttpStatus httpStatus;
    private final String message;
}

여기서 `HttpStatus`는 실제 응답의 상태 코드를 결정하고, `message`는 사용자에게 보여줄 설명이다.

이 enum을 사용하는 가장 큰 이유는 다음과 같다.

  • 정해진 코드만 쓰게 하여 일관성 유지 (IDE 자동완성의 힘..!)
  • 코드와 메시지를 한 곳에서 관리할 수 있어 유지보수 용이
  • 문서화를 자동화하기 쉽다 (`ErrorCode.values()`로 전체 목록을 순회해 `.adoc`으로 추출 가능)

보통은 그냥 `"INVALID_INPUT"` 같은 문자열을 쓰고 싶을 수도 있지만, 이렇게 `enum`으로 강제해두면 서비스 전반에서 어떤 에러가 발생할 수 있는지를 체계적으로 관리할 수 있다.
즉, 단순한 메시지의 집합이 아니라, 하나의 API 계약처럼 에러를 다루는 구조가 되는 셈이다. 덕분에 문서화, 테스트, 프론트 분기 처리까지 모두 일관되게 이어질 수 있다.

 

 

2. CustomException

에러 코드를 담는 사용자 정의 예외

 

에러 코드를 `enum`으로 정리했으면, 이제 그 코드를 실제로 던질 수 있는 구조가 필요하다. 나는 모든 비즈니스 예외를 `CustomException`이라는 사용자 정의 예외를 통해 처리했다. 핵심은 이 예외 객체 안에 `ErrorCode`를 포함시킨다는 점이다.

@Getter
public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;
    private final String customMessage;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
        this.customMessage = null;
    }

    public CustomException(ErrorCode errorCode, String customMessage) {
        super(customMessage);
        this.errorCode = errorCode;
        this.customMessage = customMessage;
    }

    public String getMessageToShow() {
        return customMessage != null ? customMessage : errorCode.getMessage();
    }
}

`CustomException`은 상황에 따라 메시지를 바꿀 수 있도록 `customMessage`를 선택적으로 받을 수 있게 했다. 예를 들어 `INVALID_INPUT` 에러를 던질 때, "URL이 비어 있습니다"처럼 더 구체적인 메시지를 전달하고 싶다면 `new CustomException(ErrorCode.INVALID_INPUT, "URL이 비어 있습니다")`처럼 사용할 수 있다.

 

이 구조의 장점은 크게 두 가지다.

첫째, 서비스나 컨트롤러에서 발생하는 비즈니스 예외가 하나의 타입으로 통일되기 때문에, 예외 처리가 간결해진다. 단순히 `throw new CustomException(...)` 한 줄이면 된다.
둘째, 전역 핸들러에서 이 예외를 잡아 `ErrorCode`와 `message`만 꺼내면 되기 때문에 응답 포맷이 자동으로 일관성을 갖게 된다.

이 덕분에 예외 처리 구조가 깔끔해지고, 나중에 에러 케이스가 추가되더라도 새로운 클래스를 만들 필요 없이 `ErrorCode`에만 항목을 추가하면 된다.

 

 

 

3. ErrorResponse

프론트엔드에 내려줄 에러 응답 포맷

 

예외가 발생하면 서버는 단순히 에러를 로깅하는 것을 넘어서, 프론트엔드에 일관된 형식으로 응답을 내려줘야 한다. 이를 위해 정의한 것이 `ErrorResponse` 클래스다.

이 클래스는 에러 응답에 필요한 핵심 정보만 담는다: `status`, `code`, `message`. 각각의 의미는 아래와 같다.

  • `status`: HTTP 상태 코드 (예: 400, 401, 500 등)
  • `code`: 프론트 분기나 문서화를 위한 비즈니스 에러 코드 (예: `INVALID_INPUT`, `RATE_LIMIT_EXCEEDED`)
  • `message`: 사용자에게 보여줄 수 있는 에러 메시지
@Getter
@AllArgsConstructor
public class ErrorResponse {
    private int status;
    private String code;
    private String message;
}

핸들러에서 예외를 잡은 뒤, `ErrorResponse` 객체를 만들어 응답에 담기만 하면 된다.
예외마다 JSON 형식을 다르게 구성할 필요 없이, 모든 에러가 같은 포맷으로 내려가게 되는 것이다.

이 구조의 장점은 명확하다. 프론트엔드 입장에서는 어떤 에러가 오든 동일한 구조로 처리할 수 있고, API 문서화 시에도 예외 응답 예시는 `ErrorResponse` 하나만 설명하면 된다. 나중에 `timestamp` 같은 필드를 추가하고 싶을 때도 클래스를 한 번만 수정하면 되기 때문에, 확장성과 유지보수 측면에서도 매우 효율적이다.

 

 

4. GlobalExceptionHandler

모든 예외를 하나로 처리하는 중심 핸들러

 

서비스에서 예외를 일관되게 관리하려면, 가장 중요한 건 모든 예외를 하나의 출구로 모아주는 것이다. 나는 이를 위해 스프링의 `@RestControllerAdvice`를 사용했고, 실제 예외 핸들링은 GlobalExceptionHandler에서 담당하도록 했다.

핵심은 예외가 발생했을 때 그 예외를 적절하게 잡아서 ErrorResponse 형태로 변환해 응답을 내려주는 것이다. 예를 들어 비즈니스 로직에서 발생하는 `CustomException`은 아래와 같이 처리된다.

@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
    ErrorCode errorCode = e.getErrorCode();
    ErrorResponse response = new ErrorResponse(
        errorCode.getHttpStatus().value(),
        errorCode.name(),
        e.getMessageToShow()
    );
    return ResponseEntity
        .status(errorCode.getHttpStatus())
        .contentType(MediaType.APPLICATION_JSON)
        .body(response);
}

`CustomException`에서 꺼낸 `ErrorCode`로부터 상태 코드와 코드명, 사용자 메시지를 추출하고, 이를 조합해 `ErrorResponse`를 생성한 후 그대로 내려주면 된다. 이 구조 덕분에 에러 응답은 어떤 상황에서도 같은 포맷으로 유지된다.

그리고 비즈니스 예외 외에도, 예상치 못한 서버 오류(`NullPointerException`, `IOException` 등)는 `Exception.class`로 묶어서 처리한다. 이 경우는 시스템 에러이기 때문에, 코드명은 `"INTERNAL_ERROR"`, 상태 코드는 `500`으로 고정한다.

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
    ErrorResponse response = new ErrorResponse(
        500,
        "INTERNAL_ERROR",
        "예상치 못한 서버 오류가 발생했습니다."
    );
    return ResponseEntity
        .status(HttpStatus.INTERNAL_SERVER_ERROR)
        .contentType(MediaType.APPLICATION_JSON)
        .body(response);
}

이처럼 전역 핸들러가 구성되면, 서비스나 컨트롤러에서는 try-catch 없이 예외만 던지면 되고, 에러 응답은 이 한 곳에서 일관되게 관리된다. 즉, 예외 처리 책임이 완전히 분리되고, 응답 포맷도 항상 예측 가능하게 된다. 개발자 입장에서는 코드 흐름이 깔끔해지고, 프론트엔드와의 연동도 훨씬 수월해진다.

 

 

5. 문서화

예외 응답은 단순히 구조만 명확하게 만드는 것으로 끝나는 게 아니다. 실제 어떤 에러 코드들이 발생할 수 있는지, 그리고 그 코드들의 의미가 무엇인지를 API 사용자에게 명확하게 보여주는 것도 중요하다. 그래서 나는 에러 코드 목록을 정리한 `ErrorCode` enum을 기반으로 .adoc 문서를 직접 생성해서 RestDocs에 포함시키는 방식을 선택했다.

public class DocsTest {
    @Test
    void generateErrorCodeAdoc() throws Exception {
        StringBuilder sb = new StringBuilder();
        sb.append("|===\n");
        sb.append("| 코드 | 이름 | 설명\n\n");

        for (ErrorCode code : ErrorCode.values()) {
            sb.append("| ").append(code.getHttpStatus())
              .append(" | ").append(code.name())
              .append(" | ").append(code.getMessage()).append("\n");
        }
        sb.append("|===\n");

        Path outputPath = Paths.get("target/generated-snippets/error-codes/generated-error-codes.adoc");
        Files.createDirectories(outputPath.getParent());
        Files.writeString(outputPath, sb.toString());
    }
}

이 테스트는 `ErrorCode`에 정의된 모든 값을 순회하면서, 상태 코드, 에러 이름, 메시지를 표 형태로 정리한 .adoc 파일을 생성한다. 생성 위치는 `target/generated-snippets/error-codes/`로 지정했기 때문에, RestDocs에서 사용하는 snippet 경로와도 연동된다. 직접 enum을 문서에 복붙하는 수고 없이도 항상 정확한 API 명세를 유지할 수 있다는 점에서 만족스러운 구조였다.

생성된 문서

 

 


 

마무리

처음엔 “예외 처리는 나중에 하지 뭐” 하고 넘겼는데, 막상 기능이 쌓이기 시작하니 지저분한 분기랑 중복 로직이 슬슬 거슬리기 시작했다. 그래서 나름 초반에 예외 처리 구조부터 정리해봤다.

구조는 단순했다. 에러는 그냥 던지고, 응답은 한 곳에서 받고, 프론트는 같은 포맷만 읽으면 끝.
거기에 RestDocs까지 붙여두니까 예외도 하나의 API 스펙처럼 관리할 수 있게 됐다.

앞으로도 이런 구조를 초반에 정리해두면 유지보수에 도움이 될 것 같다는 생각이 든다. 물론 예외 처리를 설계하는 방식은 팀의 스타일이나 프로젝트 성격에 따라 다양할 수 있지만, 나에게는 이 방식이 가장 단순하면서도 확장 가능하다고 느껴졌다. 작은 부분처럼 보였지만, 정리해두길 잘했다 싶다.

 

[적용 깃허브]

top