상세 컨텐츠

본문 제목

Spring Boot REST API 에러 응답 설계: HTTP 상태코드부터 ProblemDetail까지

Programming/Spring Boot

by 추천캐릭터 2026. 6. 10. 18:32

본문

728x90

Spring Boot REST API 에러 응답 설계: HTTP 상태코드부터 ProblemDetail까지

메타 설명

Spring Boot REST API에서 HTTP 상태코드를 어떻게 선택해야 하는지, 에러 응답은 어떤 구조로 내려줘야 하는지, RFC 9457 ProblemDetail을 활용해 일관된 API 에러 응답을 설계하는 방법까지 정리합니다.

도입

REST API를 처음 배울 때는 보통 URI, HTTP 메서드, JSON 응답부터 공부합니다.

예를 들면 이런 식입니다.

GET /users/1
POST /users
PATCH /users/1
DELETE /users/1

여기까지는 비교적 이해하기 쉽습니다.

그런데 실무에 들어가면 바로 다음 질문이 생깁니다.

요청이 실패했을 때는 어떤 상태코드를 내려줘야 할까?
에러 메시지는 그냥 문자열로 내려줘도 될까?
프론트엔드는 어떤 기준으로 에러를 처리해야 할까?
Spring Boot에서는 에러 응답을 어떻게 통일해야 할까?

REST API의 품질은 성공 응답보다 실패 응답에서 더 잘 드러납니다.

성공했을 때는 대부분 200 OK와 JSON 데이터만 내려줘도 큰 문제가 없습니다. 하지만 실패했을 때 응답 형식이 제각각이면 프론트엔드, 모바일 앱, 외부 연동 시스템이 모두 고생하게 됩니다.

이 글에서는 Spring Boot 기준으로 REST API 에러 응답을 어떻게 설계하면 좋은지 정리합니다.


핵심 요약

  • HTTP 상태코드는 에러의 성격을 나타내는 1차 신호입니다.
  • 400, 401, 403, 404, 409, 422, 500은 REST API에서 특히 자주 사용됩니다.
  • 에러 응답 본문은 문자열 하나보다 구조화된 JSON이 좋습니다.
  • Spring Framework는 ProblemDetail을 통해 표준 에러 응답 형식을 지원합니다.
  • 실무에서는 상태코드, 에러 코드, 메시지, 상세 정보, 요청 경로, 추적 ID를 일관되게 내려주는 것이 중요합니다.

1. REST API에서 상태코드가 중요한 이유

HTTP 상태코드는 서버가 클라이언트에게 보내는 가장 기본적인 결과 신호입니다.

예를 들어 다음 두 응답을 비교해 보겠습니다.

HTTP/1.1 200 OK
HTTP/1.1 404 Not Found

본문을 읽지 않아도 첫 번째는 성공, 두 번째는 자원을 찾지 못했다는 것을 알 수 있습니다.

REST API에서는 상태코드를 단순 장식으로 보면 안 됩니다. 상태코드는 클라이언트가 다음 행동을 결정하는 기준이 됩니다.

예를 들어:

  • 401 Unauthorized → 로그인 페이지로 이동
  • 403 Forbidden → 권한 없음 메시지 표시
  • 404 Not Found → 존재하지 않는 데이터 안내
  • 409 Conflict → 중복 요청 또는 상태 충돌 안내
  • 500 Internal Server Error → 서버 장애 안내

즉, 상태코드는 서버와 클라이언트 사이의 약속입니다.


2. 상태코드 그룹 먼저 이해하기

HTTP 상태코드는 크게 5개 그룹으로 나뉩니다.

범위 의미 설명
1xx 정보 응답 요청을 처리 중
2xx 성공 요청이 정상 처리됨
3xx 리다이렉션 다른 위치로 이동 필요
4xx 클라이언트 오류 요청 자체에 문제가 있음
5xx 서버 오류 서버 처리 중 문제가 발생함

REST API 에러 설계에서 가장 많이 보는 것은 4xx5xx입니다.

중요한 기준은 이것입니다.

클라이언트가 요청을 고쳐야 해결되는 문제면 4xx
서버가 고쳐야 해결되는 문제면 5xx


3. REST API에서 자주 쓰는 상태코드

200 OK

조회, 수정, 삭제 등 요청이 정상 처리되었을 때 사용합니다.

GET /users/1
{
  "id": 1,
  "name": "Steve"
}

조회 성공이라면 200 OK가 가장 일반적입니다.


201 Created

새로운 자원이 생성되었을 때 사용합니다.

POST /users
{
  "id": 10,
  "name": "Steve"
}

회원가입, 게시글 등록, 주문 생성처럼 새로운 데이터가 만들어졌다면 201 Created가 적합합니다.

가능하면 응답 헤더에 생성된 자원의 위치도 함께 내려줄 수 있습니다.

Location: /users/10

204 No Content

요청은 성공했지만 응답 본문이 필요 없을 때 사용합니다.

DELETE /users/10

삭제 성공 후 굳이 JSON을 내려줄 필요가 없다면 204 No Content를 사용할 수 있습니다.

다만 204는 본문이 없는 응답이므로 아래처럼 메시지를 함께 내려주는 방식은 피하는 것이 좋습니다.

{
  "message": "삭제되었습니다."
}

본문을 내려줄 거라면 200 OK를 쓰는 편이 더 자연스럽습니다.


400 Bad Request

요청 형식이 잘못되었을 때 사용합니다.

예를 들어 필수 파라미터가 없거나, JSON 형식이 깨졌거나, 타입이 맞지 않는 경우입니다.

{
  "email": "이메일 형식이 올바르지 않습니다."
}

이런 검증 오류는 대체로 400 Bad Request로 처리합니다.


401 Unauthorized

인증이 필요한데 로그인하지 않았거나, 토큰이 없거나, 토큰이 만료되었을 때 사용합니다.

GET /me
Authorization: Bearer expired-token

이 경우 서버는 다음과 같이 응답할 수 있습니다.

{
  "code": "AUTHENTICATION_REQUIRED",
  "message": "로그인이 필요합니다."
}

핵심은 401은 “당신이 누구인지 확인되지 않았다”는 의미에 가깝다는 점입니다.


403 Forbidden

인증은 되었지만 권한이 부족할 때 사용합니다.

예를 들어 일반 사용자가 관리자 API를 호출하는 경우입니다.

DELETE /admin/users/1
{
  "code": "ACCESS_DENIED",
  "message": "해당 기능을 사용할 권한이 없습니다."
}

401403은 자주 헷갈립니다.

간단히 정리하면 다음과 같습니다.

상태코드 의미
401 로그인 또는 인증이 필요함
403 로그인은 됐지만 권한이 없음

404 Not Found

요청한 자원이 존재하지 않을 때 사용합니다.

GET /posts/9999
{
  "code": "POST_NOT_FOUND",
  "message": "게시글을 찾을 수 없습니다."
}

존재하지 않는 회원, 게시글, 주문 등을 조회할 때 가장 많이 사용합니다.


409 Conflict

요청이 현재 서버 상태와 충돌할 때 사용합니다.

예를 들어 이미 사용 중인 이메일로 회원가입을 시도하는 경우입니다.

{
  "code": "DUPLICATED_EMAIL",
  "message": "이미 사용 중인 이메일입니다."
}

단순 입력값 오류는 400, 비즈니스 상태 충돌은 409로 나누면 설계가 깔끔해집니다.

예를 들어:

  • 이메일 형식이 이상함 → 400 Bad Request
  • 이메일 형식은 맞지만 이미 가입되어 있음 → 409 Conflict

500 Internal Server Error

서버 내부에서 예상하지 못한 오류가 발생했을 때 사용합니다.

예를 들어 NullPointerException, DB 장애, 외부 API 장애 등이 있습니다.

{
  "code": "INTERNAL_SERVER_ERROR",
  "message": "서버 내부 오류가 발생했습니다."
}

주의할 점은 서버의 내부 예외 메시지를 그대로 노출하면 안 된다는 것입니다.

나쁜 예:

{
  "message": "java.lang.NullPointerException: Cannot invoke User.getName()..."
}

좋은 예:

{
  "code": "INTERNAL_SERVER_ERROR",
  "message": "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
}

내부 예외는 로그에 남기고, 사용자에게는 안전한 메시지만 내려주는 것이 좋습니다.


4. 나쁜 에러 응답 예시

REST API를 만들 때 흔히 하는 실수는 에러 응답 형식이 제각각인 경우입니다.

예를 들어 어떤 API는 문자열을 내려줍니다.

"사용자를 찾을 수 없습니다."

다른 API는 message만 내려줍니다.

{
  "message": "권한이 없습니다."
}

또 다른 API는 error를 사용합니다.

{
  "error": "INVALID_TOKEN"
}

이렇게 응답 구조가 매번 달라지면 클라이언트는 API마다 다른 방식으로 에러를 처리해야 합니다.

프론트엔드 입장에서는 다음과 같은 코드가 늘어납니다.

if (response.data.message) {
  alert(response.data.message);
} else if (response.data.error) {
  alert(response.data.error);
} else if (typeof response.data === "string") {
  alert(response.data);
}

이런 구조는 유지보수성이 떨어집니다.

에러 응답은 반드시 통일해야 합니다.


5. 기본적인 에러 응답 구조 설계

가장 단순한 형태는 다음과 같습니다.

{
  "code": "USER_NOT_FOUND",
  "message": "사용자를 찾을 수 없습니다.",
  "status": 404
}

조금 더 실무적으로 만들면 다음처럼 구성할 수 있습니다.

{
  "timestamp": "2026-06-10T10:30:00",
  "status": 404,
  "code": "USER_NOT_FOUND",
  "message": "사용자를 찾을 수 없습니다.",
  "path": "/users/9999"
}

필드 의미는 다음과 같습니다.

필드 설명
timestamp 에러 발생 시간
status HTTP 상태코드
code 서비스 내부 에러 코드
message 사용자 또는 개발자에게 보여줄 메시지
path 요청 경로

이 정도만 통일해도 클라이언트 개발이 훨씬 쉬워집니다.


6. ProblemDetail이란?

Spring Boot 3 이후의 Spring 생태계에서는 ProblemDetail을 사용할 수 있습니다.

ProblemDetail은 HTTP API 에러 응답을 표준화하기 위한 구조입니다.

기본 필드는 다음과 같습니다.

필드 의미
type 문제 유형을 식별하는 URI
title 에러 제목
status HTTP 상태코드
detail 구체적인 설명
instance 문제가 발생한 요청 URI

예시는 다음과 같습니다.

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "사용자를 찾을 수 없습니다.",
  "instance": "/users/9999"
}

이 방식의 장점은 API 에러 응답을 매번 직접 새로 정의하지 않아도 된다는 점입니다.


7. Spring Boot에서 ProblemDetail 사용하기

간단한 예외 클래스를 만들어 보겠습니다.

public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(Long userId) {
        super("사용자를 찾을 수 없습니다. id=" + userId);
    }
}

그리고 전역 예외 처리 클래스를 작성합니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ProblemDetail handleUserNotFound(UserNotFoundException e) {
        ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        problemDetail.setTitle("User Not Found");
        problemDetail.setDetail(e.getMessage());
        problemDetail.setProperty("code", "USER_NOT_FOUND");

        return problemDetail;
    }
}

이렇게 하면 UserNotFoundException이 발생했을 때 일관된 JSON 에러 응답을 내려줄 수 있습니다.

응답 예시는 다음과 같습니다.

{
  "type": "about:blank",
  "title": "User Not Found",
  "status": 404,
  "detail": "사용자를 찾을 수 없습니다. id=9999",
  "instance": "/users/9999",
  "code": "USER_NOT_FOUND"
}

여기서 code는 표준 필드는 아니지만, setProperty()를 통해 추가한 커스텀 필드입니다.

실무에서는 프론트엔드가 message보다 code를 기준으로 분기하는 경우가 많습니다.


8. Validation 에러 처리 예시

회원가입 API가 있다고 가정해 보겠습니다.

public record SignUpRequest(
        @NotBlank(message = "이메일은 필수입니다.")
        @Email(message = "이메일 형식이 올바르지 않습니다.")
        String email,

        @NotBlank(message = "비밀번호는 필수입니다.")
        String password
) {
}

컨트롤러는 다음과 같습니다.

@PostMapping("/users")
public ResponseEntity<Void> signUp(@Valid @RequestBody SignUpRequest request) {
    userService.signUp(request);
    return ResponseEntity.status(HttpStatus.CREATED).build();
}

검증 실패를 전역으로 처리하려면 다음처럼 작성할 수 있습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException e) {
        ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problemDetail.setTitle("Validation Failed");
        problemDetail.setDetail("요청 값이 올바르지 않습니다.");
        problemDetail.setProperty("code", "VALIDATION_FAILED");

        List<Map<String, String>> errors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> Map.of(
                        "field", error.getField(),
                        "message", error.getDefaultMessage()
                ))
                .toList();

        problemDetail.setProperty("errors", errors);

        return problemDetail;
    }
}

응답은 다음처럼 나올 수 있습니다.

{
  "type": "about:blank",
  "title": "Validation Failed",
  "status": 400,
  "detail": "요청 값이 올바르지 않습니다.",
  "instance": "/users",
  "code": "VALIDATION_FAILED",
  "errors": [
    {
      "field": "email",
      "message": "이메일 형식이 올바르지 않습니다."
    },
    {
      "field": "password",
      "message": "비밀번호는 필수입니다."
    }
  ]
}

이렇게 하면 프론트엔드는 어떤 필드에서 어떤 오류가 발생했는지 정확히 알 수 있습니다.


9. 실무에서 추천하는 에러 코드 규칙

에러 코드는 사람이 읽기 쉬우면서도 클라이언트가 분기하기 좋은 형태가 좋습니다.

예를 들어 다음과 같은 방식입니다.

USER_NOT_FOUND
DUPLICATED_EMAIL
INVALID_PASSWORD
ACCESS_DENIED
TOKEN_EXPIRED
VALIDATION_FAILED
INTERNAL_SERVER_ERROR

좋은 에러 코드의 기준은 다음과 같습니다.

  • 대문자 스네이크 케이스를 사용한다.
  • 너무 추상적인 이름을 피한다.
  • HTTP 상태코드와 역할을 나눈다.
  • 프론트엔드가 분기할 수 있을 만큼 안정적으로 유지한다.

나쁜 예:

ERROR
FAIL
BAD_REQUEST
EXCEPTION

좋은 예:

USER_NOT_FOUND
ORDER_ALREADY_CANCELED
EMAIL_ALREADY_EXISTS
INVALID_REFRESH_TOKEN

BAD_REQUEST는 HTTP 상태코드에 가까운 표현입니다. 실제 비즈니스 원인을 담기에는 부족합니다.


10. 상태코드와 에러 코드는 역할이 다르다

많은 개발자가 상태코드와 에러 코드를 혼동합니다.

둘은 역할이 다릅니다.

구분 역할 예시
HTTP 상태코드 에러의 큰 분류 400, 401, 404, 500
서비스 에러 코드 서비스 내부의 구체적 원인 USER_NOT_FOUND, TOKEN_EXPIRED

예를 들어 다음 두 에러는 모두 404 Not Found일 수 있습니다.

{
  "status": 404,
  "code": "USER_NOT_FOUND",
  "message": "사용자를 찾을 수 없습니다."
}
{
  "status": 404,
  "code": "POST_NOT_FOUND",
  "message": "게시글을 찾을 수 없습니다."
}

HTTP 상태코드는 같지만, 서비스 에러 코드는 다릅니다.

클라이언트는 상태코드로 큰 흐름을 판단하고, 에러 코드로 세부 처리를 할 수 있습니다.


11. 실무 체크리스트

REST API 에러 응답을 설계할 때는 다음을 확인하면 좋습니다.

  • 성공 응답과 실패 응답의 구조가 명확한가?
  • 같은 종류의 에러는 항상 같은 상태코드를 사용하는가?
  • 에러 응답 JSON 구조가 모든 API에서 일관적인가?
  • 프론트엔드가 사용할 수 있는 에러 코드가 있는가?
  • 사용자에게 노출하면 안 되는 내부 예외 메시지를 숨기고 있는가?
  • Validation 에러는 필드 단위로 내려주는가?
  • 서버 로그에는 원인을 충분히 남기고 있는가?
  • traceId 또는 requestId를 함께 내려줄 수 있는가?

특히 운영 환경에서는 traceId가 중요합니다.

예를 들어 다음처럼 응답에 포함할 수 있습니다.

{
  "status": 500,
  "code": "INTERNAL_SERVER_ERROR",
  "message": "서버 내부 오류가 발생했습니다.",
  "traceId": "a1b2c3d4"
}

사용자가 문의할 때 traceId를 함께 전달하면 서버 로그에서 문제를 빠르게 찾을 수 있습니다.


12. 마무리

REST API는 성공 응답만 잘 만든다고 좋은 API가 되지 않습니다.

실무에서 더 중요한 것은 실패했을 때입니다.

요청이 잘못됐는지, 권한이 없는지, 데이터가 없는지, 서버가 고장 났는지를 클라이언트가 명확히 알 수 있어야 합니다.

정리하면 다음과 같습니다.

  • 상태코드는 에러의 큰 성격을 나타낸다.
  • 에러 코드는 서비스 내부의 구체적인 원인을 나타낸다.
  • 에러 응답 형식은 모든 API에서 일관되어야 한다.
  • Spring Boot에서는 @RestControllerAdviceProblemDetail을 활용하면 깔끔하게 처리할 수 있다.
  • 내부 예외 메시지는 사용자에게 그대로 노출하지 않는다.

REST API의 완성도는 성공 응답보다 에러 응답에서 드러납니다.

처음에는 귀찮아 보여도, 에러 응답 규칙을 한 번 잡아두면 프론트엔드, 백엔드, 모바일, 외부 연동까지 모두 편해집니다.

728x90

관련글 더보기