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 에러 응답을 어떻게 설계하면 좋은지 정리합니다.
400, 401, 403, 404, 409, 422, 500은 REST API에서 특히 자주 사용됩니다.ProblemDetail을 통해 표준 에러 응답 형식을 지원합니다.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 → 서버 장애 안내즉, 상태코드는 서버와 클라이언트 사이의 약속입니다.
HTTP 상태코드는 크게 5개 그룹으로 나뉩니다.
| 범위 | 의미 | 설명 |
|---|---|---|
| 1xx | 정보 응답 | 요청을 처리 중 |
| 2xx | 성공 | 요청이 정상 처리됨 |
| 3xx | 리다이렉션 | 다른 위치로 이동 필요 |
| 4xx | 클라이언트 오류 | 요청 자체에 문제가 있음 |
| 5xx | 서버 오류 | 서버 처리 중 문제가 발생함 |
REST API 에러 설계에서 가장 많이 보는 것은 4xx와 5xx입니다.
중요한 기준은 이것입니다.
클라이언트가 요청을 고쳐야 해결되는 문제면 4xx
서버가 고쳐야 해결되는 문제면 5xx
조회, 수정, 삭제 등 요청이 정상 처리되었을 때 사용합니다.
GET /users/1
{
"id": 1,
"name": "Steve"
}
조회 성공이라면 200 OK가 가장 일반적입니다.
새로운 자원이 생성되었을 때 사용합니다.
POST /users
{
"id": 10,
"name": "Steve"
}
회원가입, 게시글 등록, 주문 생성처럼 새로운 데이터가 만들어졌다면 201 Created가 적합합니다.
가능하면 응답 헤더에 생성된 자원의 위치도 함께 내려줄 수 있습니다.
Location: /users/10
요청은 성공했지만 응답 본문이 필요 없을 때 사용합니다.
DELETE /users/10
삭제 성공 후 굳이 JSON을 내려줄 필요가 없다면 204 No Content를 사용할 수 있습니다.
다만 204는 본문이 없는 응답이므로 아래처럼 메시지를 함께 내려주는 방식은 피하는 것이 좋습니다.
{
"message": "삭제되었습니다."
}
본문을 내려줄 거라면 200 OK를 쓰는 편이 더 자연스럽습니다.
요청 형식이 잘못되었을 때 사용합니다.
예를 들어 필수 파라미터가 없거나, JSON 형식이 깨졌거나, 타입이 맞지 않는 경우입니다.
{
"email": "이메일 형식이 올바르지 않습니다."
}
이런 검증 오류는 대체로 400 Bad Request로 처리합니다.
인증이 필요한데 로그인하지 않았거나, 토큰이 없거나, 토큰이 만료되었을 때 사용합니다.
GET /me
Authorization: Bearer expired-token
이 경우 서버는 다음과 같이 응답할 수 있습니다.
{
"code": "AUTHENTICATION_REQUIRED",
"message": "로그인이 필요합니다."
}
핵심은 401은 “당신이 누구인지 확인되지 않았다”는 의미에 가깝다는 점입니다.
인증은 되었지만 권한이 부족할 때 사용합니다.
예를 들어 일반 사용자가 관리자 API를 호출하는 경우입니다.
DELETE /admin/users/1
{
"code": "ACCESS_DENIED",
"message": "해당 기능을 사용할 권한이 없습니다."
}
401과 403은 자주 헷갈립니다.
간단히 정리하면 다음과 같습니다.
| 상태코드 | 의미 |
|---|---|
| 401 | 로그인 또는 인증이 필요함 |
| 403 | 로그인은 됐지만 권한이 없음 |
요청한 자원이 존재하지 않을 때 사용합니다.
GET /posts/9999
{
"code": "POST_NOT_FOUND",
"message": "게시글을 찾을 수 없습니다."
}
존재하지 않는 회원, 게시글, 주문 등을 조회할 때 가장 많이 사용합니다.
요청이 현재 서버 상태와 충돌할 때 사용합니다.
예를 들어 이미 사용 중인 이메일로 회원가입을 시도하는 경우입니다.
{
"code": "DUPLICATED_EMAIL",
"message": "이미 사용 중인 이메일입니다."
}
단순 입력값 오류는 400, 비즈니스 상태 충돌은 409로 나누면 설계가 깔끔해집니다.
예를 들어:
400 Bad Request409 Conflict서버 내부에서 예상하지 못한 오류가 발생했을 때 사용합니다.
예를 들어 NullPointerException, DB 장애, 외부 API 장애 등이 있습니다.
{
"code": "INTERNAL_SERVER_ERROR",
"message": "서버 내부 오류가 발생했습니다."
}
주의할 점은 서버의 내부 예외 메시지를 그대로 노출하면 안 된다는 것입니다.
나쁜 예:
{
"message": "java.lang.NullPointerException: Cannot invoke User.getName()..."
}
좋은 예:
{
"code": "INTERNAL_SERVER_ERROR",
"message": "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
}
내부 예외는 로그에 남기고, 사용자에게는 안전한 메시지만 내려주는 것이 좋습니다.
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);
}
이런 구조는 유지보수성이 떨어집니다.
에러 응답은 반드시 통일해야 합니다.
가장 단순한 형태는 다음과 같습니다.
{
"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 | 요청 경로 |
이 정도만 통일해도 클라이언트 개발이 훨씬 쉬워집니다.
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 에러 응답을 매번 직접 새로 정의하지 않아도 된다는 점입니다.
간단한 예외 클래스를 만들어 보겠습니다.
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를 기준으로 분기하는 경우가 많습니다.
회원가입 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": "비밀번호는 필수입니다."
}
]
}
이렇게 하면 프론트엔드는 어떤 필드에서 어떤 오류가 발생했는지 정확히 알 수 있습니다.
에러 코드는 사람이 읽기 쉬우면서도 클라이언트가 분기하기 좋은 형태가 좋습니다.
예를 들어 다음과 같은 방식입니다.
USER_NOT_FOUND
DUPLICATED_EMAIL
INVALID_PASSWORD
ACCESS_DENIED
TOKEN_EXPIRED
VALIDATION_FAILED
INTERNAL_SERVER_ERROR
좋은 에러 코드의 기준은 다음과 같습니다.
나쁜 예:
ERROR
FAIL
BAD_REQUEST
EXCEPTION
좋은 예:
USER_NOT_FOUND
ORDER_ALREADY_CANCELED
EMAIL_ALREADY_EXISTS
INVALID_REFRESH_TOKEN
BAD_REQUEST는 HTTP 상태코드에 가까운 표현입니다. 실제 비즈니스 원인을 담기에는 부족합니다.
많은 개발자가 상태코드와 에러 코드를 혼동합니다.
둘은 역할이 다릅니다.
| 구분 | 역할 | 예시 |
|---|---|---|
| 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 상태코드는 같지만, 서비스 에러 코드는 다릅니다.
클라이언트는 상태코드로 큰 흐름을 판단하고, 에러 코드로 세부 처리를 할 수 있습니다.
REST API 에러 응답을 설계할 때는 다음을 확인하면 좋습니다.
특히 운영 환경에서는 traceId가 중요합니다.
예를 들어 다음처럼 응답에 포함할 수 있습니다.
{
"status": 500,
"code": "INTERNAL_SERVER_ERROR",
"message": "서버 내부 오류가 발생했습니다.",
"traceId": "a1b2c3d4"
}
사용자가 문의할 때 traceId를 함께 전달하면 서버 로그에서 문제를 빠르게 찾을 수 있습니다.
REST API는 성공 응답만 잘 만든다고 좋은 API가 되지 않습니다.
실무에서 더 중요한 것은 실패했을 때입니다.
요청이 잘못됐는지, 권한이 없는지, 데이터가 없는지, 서버가 고장 났는지를 클라이언트가 명확히 알 수 있어야 합니다.
정리하면 다음과 같습니다.
@RestControllerAdvice와 ProblemDetail을 활용하면 깔끔하게 처리할 수 있다.REST API의 완성도는 성공 응답보다 에러 응답에서 드러납니다.
처음에는 귀찮아 보여도, 에러 응답 규칙을 한 번 잡아두면 프론트엔드, 백엔드, 모바일, 외부 연동까지 모두 편해집니다.
| Spring Boot에서 REST API 응답 구조를 일관되게 설계하는 방법 (0) | 2026.06.25 |
|---|---|
| Spring Boot 4.0 핵심 변경점 정리 — Java 25 지원부터 API 버저닝까지 (0) | 2026.04.29 |
| ☕ Maven vs Gradle, 자바 개발자를 위한 빌드툴 완전 정리! (0) | 2022.10.29 |
| ☕ JAR vs WAR vs EAR 차이점 완벽 정리! (Spring Boot 기준) (0) | 2022.10.29 |