이번 시간에는 API 응답 통일과 에러 핸들링을 스프링 부트에서 어떻게 구현하는지 알아봅시다! 😊 이를 제대로 배우면 스프링부트 프로젝트의 상당 부분을 구현할 수 있게 됩니다.
API란 애플리케이션들 간의 대화하는 통로 역할을 하는 것이었는데요.
API 개발 시 응답 형식이 통일되지 않으면 협업과 유지보수에 큰 문제가 생깁니다. 특히 프론트엔드와 대화를 해야하는 수단인 API에서 응답의 형태가 제각각이면, 이를 파악하기가 어렵겠죠?
따라서 API의 응답을 통일 하는 것은 프로젝트 진행에 있어 매우 필요한 작업입니다.
API의 형태는 프로젝트마다 다르지만, 응답 형식은 대개 다음과 같은 형태를 가집니다:
{
"isSuccess": true,
"code": "200",
"message": "Request Successful",
"result": {
"data": "Your Data Here"
}
}
각 필드의 의미
isSuccess: 요청 성공 여부 (true 또는 false) code: 상태 코드를 나타내는 값. HTTP 상태코드로는 너무 제한적인 정보만 줄 수 있어서 조금 더 세부적인 응답 상황을 알려주기 위한 필드message: 상태 코드(code)에 추가적으로 내용 설명 result: 요청 결과 데이터를 포함 (null로 설정 가능, DTO와 같은 것) 실제로 클라이언트에게 필요한 데이터가 담깁니다. 보통 에러 상황에는 null을 담지만, 아닌 경우도 있음HTTP 상태 코드는 여러가지가 있지만 200번 대, 400번 대, 500번 대만 알아봅시다.
아래에 작성한 상태 코드 정도만 알아도 충분합니다만, 궁금하시면 더 찾아보세요!
이제 실제로 차근차근 API 응답을 어떻게 통일하는지 살펴봅시다. 응답의 경우 enum으로 그 형태를 관리합니다.
이 때, 성공 응답과 실패 응답을 하나의 enum으로 관리할 수도 있고, 분리할 수도 있습니다. 이번에는 분리해서 관리하는 걸 같이 봐봅시다.
먼저, apiPayload라는 디렉토리를 만들고 그 아래에 API 응답 통일을 위한 ApiResponse 클래스를 만들어주세요. 그리고 code라는 패키지도 디렉토리 내에 만들어주세요

이 클래스는 모든 API 응답의 기본 구조를 제공합니다.
@Getter
@AllArgsConstructor
@JsonPropertyOrder({"isSuccess", "code", "message", "result"})
public class ApiResponse<T> {
private final Boolean isSuccess; // 성공 여부
private final String code; // 상태 코드
private final String message; // 응답 메시지
@JsonInclude(JsonInclude.Include.NON_NULL)
private final T result; // 실제 데이터
// 성공 응답 생성 메서드
public static <T> ApiResponse<T> onSuccess(T result) {
return new ApiResponse<>(true, "2000", "Request Successful", result);
}
// 실패 응답 생성 메서드
public static <T> ApiResponse<T> onFailure(String code, String message) {
return new ApiResponse<>(false, code, message, null);
}
}
result는 어떤 형태의 값이 올지 모르기에 Generic으로 만들어 줍니다.
아까 만든 폴더 아래에 아래와 같은 폴더 형태를 만들어주세요

이 코드는 단순히 데이터를 담는 컨테이너 역할으로, 프로젝트가 확장될수록 DTO는 응답 구조를 구체화하는 데 활용될 것입니다.
@Getter
@Builder
public class ErrorReasonDTO {
private HttpStatus httpStatus;
private final boolean isSuccess;
private final String code;
private final String message;
public boolean getIsSuccess(){return isSuccess;}
}
@Getter
@Builder
public class ReasonDTO {
private HttpStatus httpStatus;
private final boolean isSuccess;
private final String code;
private final String message;
public boolean getIsSuccess(){return isSuccess;}
}
둘의 코드가 같은데 왜 따로 만들지?
두 클래스가 현재는 동일한 코드를 가지고 있지만, 구체적인 용도와 의미를 구분하기 위해 따로 정의된 것
=> 이러한 구조는 확장성과 유지보수성을 고려한 설계입니다!
두 코드는 code 내용을 구체화 하는 Status에서 두 개의 메소드를 반드시 Override할 것을 강제하는 역할을 합니다. 상태 코드, 메시지 등의 구조를 정의하고, 이를 Enum과 결합하여 일관된 상태 코드 관리를 가능하게 하는 것이죠.
public interface BaseCode {
Reason getReason();
Reason getReasonHttpStatus();
}
public interface BaseErrorCode {
ErrorReason getReason();
ErrorReason getReasonHttpStatus();
}
Q. DTO와 BaseCode는 어떻게 활용되는 건가요?
사용 흐름 예시
1.BaseCode/BaseErrorCode에서 상태 코드와 메시지를 정의.
2. 컨트롤러에서 상태 코드와 메시지를 가져와ReasonDTO/ReasonErrorDTO를 생성.
3. DTO를 클라이언트에게 응답으로 전달.
@Getter
@AllArgsConstructor
public enum SuccessStatus implements BaseCode {
// 일반적인 응답
_OK(HttpStatus.OK, "COMMON200", "성공입니다.");
private final HttpStatus httpStatus;
private final String code;
private final String message;
@Override
public ReasonDTO getReason() {
return ReasonDTO.builder()
.message(message)
.code(code)
.isSuccess(true)
.build();
}
@Override
public ReasonDTO getReasonHttpStatus() {
return ReasonDTO.builder()
.message(message)
.code(code)
.isSuccess(true)
.httpStatus(httpStatus)
.build()
;
}
}
코드를 자세히 보면, enum 자신의 값으로 가지고 있던 message, code, httpStatus값을 interface의 메소드 오버라이딩을 통하여 DTO를 만드는 것을 확인할 수 있습니다.
API 정보
GET/temp/test{
"isSuccess": true,
"code": "2000",
"message": "OK",
"result": {
"testString": "This is test!"
}
}DTO는 데이터를 전달하기 위한 객체입니다.
이번 API에서는 TempResponse 클래스를 작성하여 응답 데이터 구조를 정의합니다. RequestBody에 담겨오는 값은 없으므로, 응답용 DTO만 작성합니다.
TempResponse 코드
public class TempResponse {
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class TempTestDTO {
String testString;
}
}
코드 분석
1. @Builder
@Getter@NoArgsConstructor
@AllArgsConstructor
Static Class 사용
추가 설명
빌더 패턴이란?
빌더 패턴(Builder Pattern)은 객체 생성 패턴 중 하나로, 복잡한 객체를 단계별로 구성할 수 있도록 설계된 디자인 패턴입니다.
- 주로 객체 생성 과정이 복잡하거나, 동일한 생성 프로세스를 통해 다양한 객체 구성을 제공해야 할 때 사용.
- 객체 생성 시 생성자 대신
@Builder를 활용하여 직관적이고 가독성이 높은 코드 작성이 가능.- 필드가 많거나 선택적 필드가 있는 객체를 생성할 때 유리.
빌더 패턴 사용 예시빌더 패턴의 장점TempResponse.TempTestDTO dto = TempResponse.TempTestDTO.builder() .testString("This is Test!") .build();- 필드 순서에 상관없이 원하는 값만 설정 가능.
- 코드 가독성과 유지보수성 향상.
왜
public static class를 사용할까?
- DTO는 여러 곳에서 사용될 수 있으므로 범용적인 사용을 위해 내부 static 클래스로 정의.
- 장점:
- 별도의 DTO 파일을 만들지 않아도 됨.
- 관련된 DTO들을 하나의 클래스로 묶어 관리 가능.
Converter는 데이터 변환을 담당하는 클래스입니다.
여기서는 TempConverter 클래스를 작성해 TempResponse.TempTestDTO 객체를 생성합니다.
TempConverter 코드
public class TempConverter {
public static TempResponse.TempTestDTO toTempTestDTO() {
return TempResponse.TempTestDTO.builder()
.testString("This is Test!")
.build();
}
}
코드 분석
1. toTempTestDTO(): TempTestDTO 객체를 생성하고 반환하는 정적 메서드.
2. 빌더 패턴을 활용하여 가독성과 유연성을 높임.
Controller는 클라이언트 요청을 처리하고 응답을 반환하는 역할을 담당합니다.
이번 API는 비즈니스 로직이나 DB 연동이 없으므로 Service와 Repository 계층을 생략하고 작성합니다.
TempRestController 코드
@RestController
@RequestMapping("/temp")
@RequiredArgsConstructor
public class TempRestController {
@GetMapping("/test")
public ApiResponse<TempResponse.TempTestDTO> testAPI() {
return ApiResponse.onSuccess(TempConverter.toTempTestDTO());
}
}
코드 분석
1. @RestController: RESTful 웹 서비스 컨트롤러를 나타냄. JSON 응답 자동 반환.
2. @RequestMapping("/temp"): 모든 엔드포인트에 /temp URL prefix를 설정.
3. @GetMapping("/test"): /temp/test 경로의 GET 요청을 처리.
4. ApiResponse.onSuccess(): 성공적인 응답 구조를 표준화하여 생성.
로컬 환경에서 API 호출
localhost:8080/temp/test로 GET 요청을 보내면 다음과 같은 JSON 응답을 반환합니다.
{
"isSuccess": true,
"code": "2000",
"message": "OK",
"result": {
"testString": "This is test!"
}
}
지금은 로컬호스트이지만, 원격 서버 Nginx에 Spring Boot을 연동했다면...
1. 클라이언트 요청은 Nginx에 도달.
2. Nginx는 요청을 Spring Boot 애플리케이션으로 전달(리버스 프록시).
3. Spring Boot에서 처리한 결과를 Nginx가 클라이언트에 반환.
이 순서로 작동되게 됩니다.
로컬서버 vs Ngnix
- 로컬 서버:
내 컴퓨터 안에서만 작동.
예를 들어, localhost:3000 같은 주소를 브라우저에 입력하면, 내 컴퓨터가 서버처럼 작동해서 결과를 보여줌.- Nginx:
인터넷에 연결된 컴퓨터(서버)에 설치됨.
다른 사람들이 내 웹사이트 주소(www.mywebsite.com)에 접속하면, Nginx가 "어! 여기에 파일 있네!" 하고 데이터를 전달해줌.
추가로, 내가 만든 프로그램(예: Django, Spring 같은 백엔드)도 연결해서 사용자 요청을 처리할 수 있게 도와줌.
Enum으로 에러 코드 관리하기 - 추천 방식
1. common 에러는 COMMON000 으로 둔다. <- 잘 안쓰지만 마땅하지 않을 때 사용
2. 관련된 경우마다 code에 명시적으로 표현한다.
- 예를 들어 멤버 관련이면 MEMBER001 이런 식으로
3. 2번에 이어서 4000번대를 붙인다. 서버측 잘못은 그냥 COMMON 에러의 서버 에러를 쓰면 됨.
- MEMBER400_1 아니면 MEMBER4001 이런 식으로
Spring Boot에서는 @RestControllerAdvice를 사용하여 전역적으로 발생하는 예외를 처리하고, 표준화된 응답을 반환할 수 있습니다.
ErrorStatus는 애플리케이션에서 발생하는 다양한 에러를 관리하는 에러 상태 코드 클래스입니다.
@Getter
@AllArgsConstructor
public enum ErrorStatus implements BaseErrorCode {
// 공통 에러
_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."),
_BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."),
_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."),
_FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."),
// 사용자 관련 에러
MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4001", "사용자가 없습니다."),
NICKNAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "MEMBER4002", "닉네임은 필수입니다."),
// 기타 에러
ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다.");
private final HttpStatus httpStatus;
private final String code;
private final String message;
@Override
public ErrorReasonDTO getReason() {
return ErrorReasonDTO.builder()
.message(message)
.code(code)
.isSuccess(false)
.build();
}
@Override
public ErrorReasonDTO getReasonHttpStatus() {
return ErrorReasonDTO.builder()
.message(message)
.code(code)
.isSuccess(false)
.httpStatus(httpStatus)
.build();
}
}
GeneralException은 특정 조건에서 발생하는 커스텀 에러를 처리하기 위해 작성된 사용자 정의 예외 클래스입니다.
@Getter
@AllArgsConstructor
public class GeneralException extends RuntimeException {
private BaseErrorCode code;
public ErrorReasonDTO getErrorReason() {
return this.code.getReason();
}
public ErrorReasonDTO getErrorReasonHttpStatus() {
return this.code.getReasonHttpStatus();
}
}
ExceptionAdvice 클래스는 애플리케이션의 모든 예외를 처리하고 표준화된 JSON 응답을 반환합니다.
유효성 검사 예외를 처리하려면 validation 관련 라이브러리를 추가해야 합니다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Slf4j
@RestControllerAdvice(annotations = {RestController.class})
public class ExceptionAdvice extends ResponseEntityExceptionHandler {
@ExceptionHandler
public ResponseEntity<Object> validation(ConstraintViolationException e, WebRequest request) {
String errorMessage = e.getConstraintViolations().stream()
.map(constraintViolation -> constraintViolation.getMessage())
.findFirst()
.orElseThrow(() -> new RuntimeException("ConstraintViolationException 추출 도중 에러 발생"));
return handleExceptionInternalConstraint(e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY, request);
}
@Override
public ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
e.getBindingResult().getFieldErrors().forEach(fieldError -> {
String fieldName = fieldError.getField();
String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse("");
errors.merge(fieldName, errorMessage, (existing, newMsg) -> existing + ", " + newMsg);
});
return handleExceptionInternalArgs(e, HttpHeaders.EMPTY, ErrorStatus._BAD_REQUEST, request, errors);
}
@ExceptionHandler
public ResponseEntity<Object> exception(Exception e, WebRequest request) {
e.printStackTrace();
return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(), request, e.getMessage());
}
@ExceptionHandler(value = GeneralException.class)
public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) {
ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus();
return handleExceptionInternal(generalException, errorReasonHttpStatus, null, request);
}
private ResponseEntity<Object> handleExceptionInternal(Exception e, ErrorReasonDTO reason, HttpHeaders headers, HttpServletRequest request) {
ApiResponse<Object> body = ApiResponse.onFailure(reason.getCode(), reason.getMessage(), null);
WebRequest webRequest = new ServletWebRequest(request);
return super.handleExceptionInternal(e, body, headers, reason.getHttpStatus(), webRequest);
}
private ResponseEntity<Object> handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus, HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) {
ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), errorPoint);
return super.handleExceptionInternal(e, body, headers, status, request);
}
private ResponseEntity<Object> handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorStatus errorCommonStatus, WebRequest request, Map<String, String> errorArgs) {
ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), errorArgs);
return super.handleExceptionInternal(e, body, headers, errorCommonStatus.getHttpStatus(), request);
}
private ResponseEntity<Object> handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus, HttpHeaders headers, WebRequest request) {
ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null);
return super.handleExceptionInternal(e, body, headers, errorCommonStatus.getHttpStatus(), request);
}
}
validation(): ConstraintViolationException 처리
handleMethodArgumentNotValid(): MethodArgumentNotValidException 처리
exception(): 일반 예외 처리
onThrowException(): 사용자 정의 예외 처리
GeneralException을 처리하여, 비즈니스 로직에 따라 커스텀 응답을 생성합니다.handleExceptionInternal(): 기본 예외 응답 생성
{
"success": false,
"code": "MEMBER4001",
"message": "사용자가 없습니다.",
"data": null
}
GET /temp/exception 요청에 대한 예외 처리 구현 과정을 예시로 살펴봅시다. 이 API는 Query String으로 flag를 받아오며, flag 값이 2일 경우 Exception을 발생시킵니다.
ErrorStatus에 테스트용 에러 상태를 추가합니다.
// For test
TEMP_EXCEPTION(HttpStatus.BAD_REQUEST, "TEMP4001", "이거는 테스트");
handler 패키지에 TempHandler 클래스를 생성합니다. 이 클래스는 GeneralException을 상속받습니다.
public class TempHandler extends GeneralException {
public TempHandler(BaseErrorCode errorCode) {
super(errorCode);
}
}
TempHandler는 테스트용 예외를 처리하기 위해 작성되었고, 부모 클래스의 생성자를 호출해 에러 코드를 전달합니다.
TempResponse 클래스는 예외와 테스트 응답 데이터를 포함합니다.
public class TempResponse {
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class TempTestDTO {
String testString;
}
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class TempExceptionDTO {
Integer flag;
}
}
TempTestDTO: 테스트 API 응답 데이터.TempExceptionDTO: 예외 발생 시 전달되는 데이터.TempConverter는 DTO를 생성하는 정적 메서드를 제공합니다. 이를 통해 TempResponse DTO를 생성해서 반환합니다.
public class TempConverter {
public static TempResponse.TempTestDTO toTempTestDTO() {
return TempResponse.TempTestDTO.builder()
.testString("This is Test!")
.build();
}
public static TempResponse.TempExceptionDTO toTempExceptionDTO(Integer flag) {
return TempResponse.TempExceptionDTO.builder()
.flag(flag)
.build();
}
}
TempRestController는 @RestController를 사용하여 API 요청을 처리합니다.
@RestController
@RequestMapping("/temp")
@RequiredArgsConstructor
public class TempRestController {
@GetMapping("/test")
public ApiResponse<TempResponse.TempTestDTO> testAPI() {
return ApiResponse.onSuccess(TempConverter.toTempTestDTO());
}
@GetMapping("/exception")
public ApiResponse<TempResponse.TempExceptionDTO> exceptionAPI(@RequestParam Integer flag) {
return null;
}
}
@RequestParam을 통해 Query String에서 값을 받아옵니다./test API는 테스트 데이터를 반환합니다./exception API는 서비스 로직과 연동됩니다.이때, RestControllerAdvice를 통해 @RestController가 붙은 대상에서 Exception이 발생하는 것을 감지하게 됩니다.
🥕서비스 설계 원칙
1. GET 요청과 나머지 요청의 로직을 분리합니다.
- GET 요청:TempQueryService.
- 기타 요청:TempCommandService.
2. 인터페이스를 먼저 정의한 뒤 구체화 클래스를 작성합니다.
- 인터페이스:TempQueryService.
- 구현체:TempQueryServiceImpl.
3. 컨트롤러는 인터페이스에 의존하며, Spring의 의존성 주입을 통해 구현체를 주입받습니다.
public interface TempQueryService {
void checkFlag(Integer flag);
}
@Service
@RequiredArgsConstructor
public class TempQueryServiceImpl implements TempQueryService {
@Override
public void checkFlag(Integer flag) {
if (flag == 1)
throw new TempHandler(ErrorStatus.TEMP_EXCEPTION);
}
}
checkFlag: flag 값이 1인 경우 TempHandler 예외를 던집니다.TempRestController의 /exception API를 완성합니다.
@RestController
@RequestMapping("/temp")
@RequiredArgsConstructor
public class TempRestController {
private final TempQueryService tempQueryService;
@GetMapping("/test")
public ApiResponse<TempResponse.TempTestDTO> testAPI() {
return ApiResponse.onSuccess(TempConverter.toTempTestDTO());
}
@GetMapping("/exception")
public ApiResponse<TempResponse.TempExceptionDTO> exceptionAPI(@RequestParam Integer flag) {
tempQueryService.checkFlag(flag);
return ApiResponse.onSuccess(TempConverter.toTempExceptionDTO(flag));
}
}
tempQueryService.checkFlag(flag)를 호출하여 예외 발생 여부를 확인하고, 예외가 발생하지 않으면 DTO를 반환합니다.
Service에서 예외 발생
TempQueryServiceImpl의 checkFlag 메서드에서 flag 값이 1인 경우 TempHandler를 통해 예외를 발생시킵니다.
@Override
public void checkFlag(Integer flag) {
if (flag == 1)
throw new TempHandler(ErrorStatus.TEMP_EXCEPTION);
}
ExceptionAdvice에서 예외 처리
TempHandler는 GeneralException을 상속받기 때문에 @RestControllerAdvice에 등록된 ExceptionAdvice가 예외를 처리합니다.
@ExceptionHandler(value = GeneralException.class)
public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) {
ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus();
return handleExceptionInternal(generalException, errorReasonHttpStatus, null, request);
}
표준화된 응답 반환
handleExceptionInternal 메서드를 통해 표준화된 JSON 응답이 클라이언트로 반환됩니다.
private ResponseEntity<Object> handleExceptionInternal(Exception e, ErrorReasonDTO reason, HttpHeaders headers, HttpServletRequest request) {
ApiResponse<Object> body = ApiResponse.onFailure(reason.getCode(), reason.getMessage(), null);
WebRequest webRequest = new ServletWebRequest(request);
return super.handleExceptionInternal(e, body, headers, reason.getHttpStatus(), webRequest);
}
응답 결과 확인
클라이언트는 에러 코드와 메시지를 포함한 표준화된 응답을 받습니다.
{
"success": false,
"code": "TEMP4001",
"message": "이거는 테스트"
}
🎯 다음 시간에는 오늘 배운 내용을 더 구체적인 과정으로 하나하나씩 살펴보고 실제 예제를 같이 보겠습니다!