Common Response DTO - 일관된 API 응답 설계

geoson·2025년 6월 5일

Spring & 백엔드

목록 보기
11/18

Common Response DTO란?

API 응답의 일관성을 위해 사용하는 공통 응답 객체입니다. 모든 API 엔드포인트에서 동일한 구조의 응답을 제공하여 프론트엔드에서 처리하기 쉽게 만들어줍니다.

문제 상황

❌ 일관성 없는 API 응답

@RestController
public class ScheduleController {
    
    @GetMapping("/schedules/{id}")
    public ResponseEntity<ScheduleResponseDto> getSchedule(@PathVariable Long id) {
        return ResponseEntity.ok(scheduleService.getSchedule(id));
    }
    
    @PostMapping("/schedules")
    public ResponseEntity<Map<String, Object>> createSchedule(@RequestBody ScheduleRequestDto request) {
        ScheduleResponseDto schedule = scheduleService.createSchedule(request);
        Map<String, Object> response = new HashMap<>();
        response.put("message", "일정이 생성되었습니다.");
        response.put("data", schedule);
        return ResponseEntity.ok(response);
    }
    
    @DeleteMapping("/schedules/{id}")
    public ResponseEntity<String> deleteSchedule(@PathVariable Long id) {
        scheduleService.deleteSchedule(id);
        return ResponseEntity.ok("일정이 삭제되었습니다.");
    }
}

문제점:

  • API마다 응답 구조가 다름
  • 프론트엔드에서 응답 처리 로직이 복잡해짐
  • 성공/실패 여부 판단이 어려움
  • 예외 처리 응답 형식도 일관성 없음

해결 방법: Common Response DTO

1. 기본 공통 응답 객체

public class ApiResponse<T> {
    private boolean success;
    private String message;
    private T data;
    private String timestamp;
    
    // 생성자를 private으로 제한
    private ApiResponse(boolean success, String message, T data) {
        this.success = success;
        this.message = message;
        this.data = data;
        this.timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
    }
    
    // 성공 응답 생성 메서드들
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, "성공", data);
    }
    
    public static <T> ApiResponse<T> success(String message, T data) {
        return new ApiResponse<>(true, message, data);
    }
    
    public static <T> ApiResponse<T> successWithoutData() {
        return new ApiResponse<>(true, "성공", null);
    }
    
    public static <T> ApiResponse<T> successWithoutData(String message) {
        return new ApiResponse<>(true, message, null);
    }
    
    // 실패 응답 생성 메서드들
    public static <T> ApiResponse<T> error(String message) {
        return new ApiResponse<>(false, message, null);
    }
    
    public static <T> ApiResponse<T> error(String message, T data) {
        return new ApiResponse<>(false, message, data);
    }
    
    // Getter 메서드들
    public boolean isSuccess() { return success; }
    public String getMessage() { return message; }
    public T getData() { return data; }
    public String getTimestamp() { return timestamp; }
}

2. 개선된 컨트롤러

@RestController
@RequestMapping("/api/schedules")
public class ScheduleController {
    
    private final ScheduleService scheduleService;
    
    @GetMapping("/{id}")
    public ResponseEntity<ApiResponse<ScheduleResponseDto>> getSchedule(@PathVariable Long id) {
        ScheduleResponseDto schedule = scheduleService.getSchedule(id);
        return ResponseEntity.ok(ApiResponse.success(schedule));
    }
    
    @GetMapping
    public ResponseEntity<ApiResponse<List<ScheduleResponseDto>>> getSchedules() {
        List<ScheduleResponseDto> schedules = scheduleService.getAllSchedules();
        return ResponseEntity.ok(ApiResponse.success("일정 목록 조회 성공", schedules));
    }
    
    @PostMapping
    public ResponseEntity<ApiResponse<ScheduleResponseDto>> createSchedule(
            @Valid @RequestBody ScheduleRequestDto request) {
        ScheduleResponseDto schedule = scheduleService.createSchedule(request);
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(ApiResponse.success("일정이 생성되었습니다.", schedule));
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<ApiResponse<ScheduleResponseDto>> updateSchedule(
            @PathVariable Long id,
            @Valid @RequestBody ScheduleRequestDto request) {
        ScheduleResponseDto schedule = scheduleService.updateSchedule(id, request);
        return ResponseEntity.ok(ApiResponse.success("일정이 수정되었습니다.", schedule));
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<ApiResponse<Void>> deleteSchedule(@PathVariable Long id) {
        scheduleService.deleteSchedule(id);
        return ResponseEntity.ok(ApiResponse.successWithoutData("일정이 삭제되었습니다."));
    }
}

고급 응답 객체 설계

1. 페이징 정보 포함 📄

public class PageResponse<T> {
    private List<T> content;
    private PageInfo pageInfo;
    
    public PageResponse(Page<T> page) {
        this.content = page.getContent();
        this.pageInfo = PageInfo.from(page);
    }
    
    @Getter
    public static class PageInfo {
        private int page;
        private int size;
        private long totalElements;
        private int totalPages;
        private boolean first;
        private boolean last;
        
        private PageInfo(int page, int size, long totalElements, int totalPages, boolean first, boolean last) {
            this.page = page;
            this.size = size;
            this.totalElements = totalElements;
            this.totalPages = totalPages;
            this.first = first;
            this.last = last;
        }
        
        public static PageInfo from(Page<?> page) {
            return new PageInfo(
                page.getNumber(),
                page.getSize(),
                page.getTotalElements(),
                page.getTotalPages(),
                page.isFirst(),
                page.isLast()
            );
        }
    }
}

// 페이징 응답 사용
@GetMapping
public ResponseEntity<ApiResponse<PageResponse<ScheduleResponseDto>>> getSchedulesWithPaging(
        @PageableDefault(size = 10) Pageable pageable) {
    Page<ScheduleResponseDto> schedulePage = scheduleService.getSchedulesWithPaging(pageable);
    PageResponse<ScheduleResponseDto> pageResponse = new PageResponse<>(schedulePage);
    return ResponseEntity.ok(ApiResponse.success("일정 목록 조회 성공", pageResponse));
}

2. 에러 상세 정보 포함 ⚠️

public class ErrorDetail {
    private String field;
    private Object rejectedValue;
    private String message;
    
    public ErrorDetail(String field, Object rejectedValue, String message) {
        this.field = field;
        this.rejectedValue = rejectedValue;
        this.message = message;
    }
    
    // getters...
}

public class ApiResponse<T> {
    private boolean success;
    private String message;
    private T data;
    private List<ErrorDetail> errors;
    private String timestamp;
    
    // 유효성 검사 실패 응답
    public static <T> ApiResponse<T> validationError(String message, List<ErrorDetail> errors) {
        ApiResponse<T> response = new ApiResponse<>(false, message, null);
        response.errors = errors;
        return response;
    }
    
    public List<ErrorDetail> getErrors() { return errors; }
}

전역 예외 처리와 연동

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleEntityNotFoundException(EntityNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ApiResponse.error(e.getMessage()));
    }
    
    @ExceptionHandler(DuplicateException.class)
    public ResponseEntity<ApiResponse<Void>> handleDuplicateException(DuplicateException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ApiResponse.error(e.getMessage()));
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException e) {
        List<ErrorDetail> errors = e.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new ErrorDetail(
                error.getField(),
                error.getRejectedValue(),
                error.getDefaultMessage()
            ))
            .collect(Collectors.toList());
        
        return ResponseEntity.badRequest()
            .body(ApiResponse.validationError("입력값 검증에 실패했습니다.", errors));
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception e) {
        log.error("Unexpected error occurred", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ApiResponse.error("서버 내부 오류가 발생했습니다."));
    }
}

응답 예시

✅ 성공 응답

// GET /api/schedules/1
{
  "success": true,
  "message": "성공",
  "data": {
    "id": 1,
    "title": "회의",
    "content": "팀 회의",
    "createdAt": "2024-01-15T10:30:00"
  },
  "timestamp": "2024-01-15T10:30:00"
}

// POST /api/schedules
{
  "success": true,
  "message": "일정이 생성되었습니다.",
  "data": {
    "id": 2,
    "title": "새 일정",
    "content": "새로운 일정입니다",
    "createdAt": "2024-01-15T11:00:00"
  },
  "timestamp": "2024-01-15T11:00:00"
}

// DELETE /api/schedules/1
{
  "success": true,
  "message": "일정이 삭제되었습니다.",
  "data": null,
  "timestamp": "2024-01-15T11:15:00"
}

❌ 실패 응답

// 404 Not Found
{
  "success": false,
  "message": "일정을 찾을 수 없습니다. ID: 999",
  "data": null,
  "timestamp": "2024-01-15T11:30:00"
}

// 400 Validation Error
{
  "success": false,
  "message": "입력값 검증에 실패했습니다.",
  "data": null,
  "errors": [
    {
      "field": "title",
      "rejectedValue": "",
      "message": "제목은 필수입니다."
    },
    {
      "field": "content",
      "rejectedValue": null,
      "message": "내용은 필수입니다."
    }
  ],
  "timestamp": "2024-01-15T11:45:00"
}

📄 페이징 응답

// GET /api/schedules?page=0&size=5
{
  "success": true,
  "message": "일정 목록 조회 성공",
  "data": {
    "content": [
      {
        "id": 1,
        "title": "일정 1",
        "content": "내용 1",
        "createdAt": "2024-01-15T10:00:00"
      },
      {
        "id": 2,
        "title": "일정 2",
        "content": "내용 2",
        "createdAt": "2024-01-15T11:00:00"
      }
    ],
    "pageInfo": {
      "page": 0,
      "size": 5,
      "totalElements": 12,
      "totalPages": 3,
      "first": true,
      "last": false
    }
  },
  "timestamp": "2024-01-15T12:00:00"
}

프론트엔드에서의 활용

TypeScript 타입 정의

// 공통 응답 타입 정의
interface ApiResponse<T> {
  success: boolean;
  message: string;
  data: T | null;
  errors?: ErrorDetail[];
  timestamp: string;
}

interface ErrorDetail {
  field: string;
  rejectedValue: any;
  message: string;
}

// API 호출 함수
async function fetchSchedule(id: number): Promise<Schedule> {
  const response = await fetch(`/api/schedules/${id}`);
  const apiResponse: ApiResponse<Schedule> = await response.json();
  
  if (!apiResponse.success) {
    throw new Error(apiResponse.message);
  }
  
  return apiResponse.data!;
}

// 에러 처리
async function createSchedule(request: ScheduleRequest): Promise<Schedule> {
  try {
    const response = await fetch('/api/schedules', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(request)
    });
    
    const apiResponse: ApiResponse<Schedule> = await response.json();
    
    if (!apiResponse.success) {
      if (apiResponse.errors) {
        // 유효성 검사 에러 처리
        apiResponse.errors.forEach(error => {
          console.log(`${error.field}: ${error.message}`);
        });
      }
      throw new Error(apiResponse.message);
    }
    
    return apiResponse.data!;
  } catch (error) {
    console.error('일정 생성 실패:', error);
    throw error;
  }
}

실무 팁

1. 응답 코드 추가 🔢

public enum ResponseCode {
    SUCCESS("S001", "성공"),
    CREATED("S002", "생성 성공"),
    
    BAD_REQUEST("E001", "잘못된 요청"),
    UNAUTHORIZED("E002", "인증 필요"),
    FORBIDDEN("E003", "권한 없음"),
    NOT_FOUND("E004", "리소스를 찾을 수 없음"),
    CONFLICT("E005", "데이터 충돌"),
    
    INTERNAL_SERVER_ERROR("E999", "서버 내부 오류");
    
    private final String code;
    private final String message;
    
    ResponseCode(String code, String message) {
        this.code = code;
        this.message = message;
    }
    
    public String getCode() { return code; }
    public String getMessage() { return message; }
}

// ApiResponse에 코드 추가
public class ApiResponse<T> {
    private boolean success;
    private String code;
    private String message;
    private T data;
    private String timestamp;
    
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, ResponseCode.SUCCESS.getCode(), 
                                ResponseCode.SUCCESS.getMessage(), data);
    }
    
    public static <T> ApiResponse<T> error(ResponseCode responseCode) {
        return new ApiResponse<>(false, responseCode.getCode(), 
                                responseCode.getMessage(), null);
    }
}

2. 빌더 패턴 적용 🔧

public class ApiResponse<T> {
    
    public static class Builder<T> {
        private boolean success;
        private String code;
        private String message;
        private T data;
        private List<ErrorDetail> errors;
        
        public Builder<T> success(boolean success) {
            this.success = success;
            return this;
        }
        
        public Builder<T> message(String message) {
            this.message = message;
            return this;
        }
        
        public Builder<T> data(T data) {
            this.data = data;
            return this;
        }
        
        public ApiResponse<T> build() {
            return new ApiResponse<>(this);
        }
    }
    
    public static <T> Builder<T> builder() {
        return new Builder<>();
    }
}

// 사용 예시
ApiResponse<Schedule> response = ApiResponse.<Schedule>builder()
    .success(true)
    .message("일정 조회 성공")
    .data(schedule)
    .build();

장점

🎯 일관된 API 구조

  • 모든 API에서 동일한 응답 형식
  • 프론트엔드에서 예측 가능한 응답 처리

⚡ 개발 효율성 향상

  • 응답 처리 로직을 한 번만 작성
  • 에러 처리 표준화

🔧 유지보수성 향상

  • 응답 구조 변경 시 한 곳에서만 수정
  • API 문서화 간소화

📊 모니터링 용이

  • 성공/실패 여부를 쉽게 추적
  • 에러 패턴 분석 가능

적용 방법

  1. 기본 ApiResponse 클래스 생성
  2. 컨트롤러에서 ApiResponse 사용
  3. Global Exception Handler와 연동
  4. 페이징, 에러 상세 정보 등 필요한 기능 추가
  5. 프론트엔드 타입 정의 및 활용

주의사항

  • 제네릭 타입 안전성: 타입 안전성을 위해 제네릭 적극 활용
  • 필드 확장성: 나중에 필드 추가가 용이하도록 설계
  • 직렬화: JSON 직렬화/역직렬화 고려
  • 문서화: API 응답 형식을 명확히 문서화

결론

Common Response DTO를 도입하면:

  • 일관된 API 응답 구조
  • 프론트엔드 개발 효율성 향상
  • 에러 처리 표준화
  • 유지보수성 향상

처음에는 간단하게 시작해서 점진적으로 기능을 확장해나가는 것이 좋습니다! 🚀

0개의 댓글