본문 바로가기

Project

스프링 프로젝트에서 공통 응답 + 페이징 + 전역 예외처리

1. 통일된 응답 포맷 만들기 : ApiResponse<T>

API 마다 응답 형태가 다르면 프론트 개발자는 매번

if (response.data) {...}
else if (response.content) {...}

이런 식으로 응답을 파싱해야 한다.

 

그래서 프로젝트에서는 모든 응답을 하나의 포맷으로 통일하기로 했다.

{
    "status" : 200,
    "message" : "성공 메시지",
    "data" : {...}
}

해결 방법 : 제네릭 기반 응답 래퍼 만들기

@AllArgsConstructor
public class ApiResponse<T> {
   private int status;
   private String message;
   private T data;
   
   public static <T> ApiResponse<T> success(int status, String message, T data) {
       return new ApiResponse<>(status, message, data);
   }
   
   public static <T> ApiResponse<T> error(int status, String message) {
       return new ApiResponse<>(status, message, null);
   }    
}

 

응답 유틸 클래스 : ResponseUtil

public class ResponseUtil {

    public static <T> ResponseEntity<ApiResponse<T>> ok(String message, T data) {
        return ResponseEntity.ok(ApiResponse.success(200, message, data));
    }

    public static <T> ResponseEntity<ApiResponse<T>> created(String message, T data) {
        return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(201, message, data));
    }

    public static ResponseEntity<ApiResponse<Void>> noContent() {
        return ResponseEntity.status(HttpStatus.NO_CONTENT).body(ApiResponse.success(204, "처리 완료", null));
    }

    public static ResponseEntity<ApiResponse<Void>> error(int status, String message) {
        return ResponseEntity.status(status).body(ApiResponse.error(status, message));
    }
}

 

사용 예시

return ResponseUtil.ok("일정 조회 성공", dto);
return ResponseUtil.created("일정 등록 성공", dto);
return ResponseUtil.noContent();

2. 공통 페이징 DTO 만들기

문제

기본 Page<T>를 그대로 응답하면 pageable, sort, empty 등 너무 많은 필드가 붙는다.

{
  "content": [ ... ],
  "pageable": { "sort": { ... }, ... },
  "totalElements": 150,
  "totalPages": 15,
  "last": false,
  ...
}

 

그래서 필요한 정보만 따로 담는 Page용 DTO를 만들기로 했다.

public class PageUtil<T> {
    private List<T> content;
    private int number;
    private int size;
    private int totalPages;
    private boolean isLast;

    public PageUtil(Page<T> pageData) {
        this.content = pageData.getContent();     // 실제 데이터 리스트
        this.number = pageData.getNumber();       // 현재 페이지 번호
        this.size = pageData.getSize();           // 페이지 당 개수
        this.totalPages = pageData.getTotalPages(); // 전체 페이지 수
        this.isLast = pageData.isLast();          // 마지막 페이지 여부
    }
}

 

동작흐름

Controller -> Service -> Repository
                ↓
         Page<DTO> 결과 반환
                ↓
         PageUtil<T>로 감싸기
                ↓
       ApiResponse.success(...)로 통일된 포맷으로 응답
{
  "status": 200,
  "message": "조회 성공",
  "data": {
    "content": [ ... ],
    "number": 0,
    "size": 10,
    "totalPages": 3,
    "isLast": false
  }
}

3. 전역 예외 처리 + ErrorCode 관리

서비스나 컨트롤러에서 예외가 터지면 기본적으로는 이런 HTML 응답이 날라온다.

Whitelabel Error Page
This application has no explicit mapping for /error...

또는 500 에러만 떨어지고 왜 실패했는지 알기 어렵다.

그래서 예외처리 또한 일관된 JSON 에러 응답을 주기로 했다,

 


해결 전략

1. ErrorCode enum : 상태 코드 + 메시지 일괄 관리

2. CustomException : enum 기반 예외 객체

3. @RestControllerAdvice : 모든 예외를 JSON으로 변환


EnumCode

@Getter
@AllArgsConstructor
public enum ErrorCode {

    // 공통
    INTERNAL_SERVER_ERROR(500, "서버 내부 오류가 발생했습니다."),
    INVALID_INPUT_VALUE(400, "입력값이 올바르지 않습니다."),

    // 유저
    NOT_FOUND_EMAIL(404, "이메일이 존재하지 않습니다."),
    NOT_FOUND_USER(404, "해당 유저를 찾을 수 없습니다."),
    PASSWORD_IS_NOT_VALID(403, "비밀번호가 일치하지 않습니다."),

    // 일정
    NOT_FOUND_SCHEDULE(404, "해당 일정을 찾을 수 없습니다."),
    NOT_SCHEDULE_OWNER(403, "해당 일정에 대한 권한이 없습니다."),

    // 댓글
    NOT_FOUND_COMMENT(404, "해당 댓글을 찾을 수 없습니다."),
    REPLY_DEPTH_EXCEEDED(400, "대댓글에는 다시 댓글을 달 수 없습니다."),
    NOT_COMMENT_OWNER(403, "해당 일정에 대한 권한이 없습니다.");

    private final int status;
    private final String message;

}

예외 클래스

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

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

예외 처리기

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ApiResponse<Void>> handleCustomException(CustomException e) {
        ErrorCode code = e.getErrorCode();
        return ResponseUtil.error(code.getStatus(), code.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
        e.printStackTrace();
        return ResponseUtil.error(500, "서버 내부 오류가 발생했습니다");
    }
}

 


동작 흐름

Service 내부에서 예외 발생
   → throw new CustomException(ErrorCode.NOT_FOUND_SCHEDULE);

예외 발생
   ↓
@ExceptionHandler(CustomException.class) 포착
   ↓
에러 코드 → status + message 추출
   ↓
ApiResponse.error(...)로 응답 포장
   ↓
프론트로 JSON 에러 응답 전송

실제 응답 예시

 

정상 조회

{
  "status": 200,
  "message": "일정 조회 성공",
  "data": {
    "id": 1,
    "title": "캠핑 일정",
    "author": "sungho"
  }
}

 

예외 발생 시

{
  "status": 404,
  "message": "해당 일정을 찾을 수 없습니다.",
  "data": null
}