Home > Backend Development > πŸ“š[Backend Development] API 일관성 κ°€μ΄λ“œ :)

πŸ“š[Backend Development] API 일관성 κ°€μ΄λ“œ :)
Backend Development API Spring Boot

πŸš€ Spring Boot API 일관성 μ™„λ²½ κ°€μ΄λ“œ

Java Backend κ°œλ°œμ—μ„œ μΌκ΄€λ˜κ³  예츑 κ°€λŠ₯ν•œ APIλ₯Ό κ΅¬μΆ•ν•˜κΈ° μœ„ν•œ 싀무 쀑심 κ°€μ΄λ“œμž…λ‹ˆλ‹€.


🎯 μ™œ API 일관성이 μ€‘μš”ν•œκ°€?

πŸ“Š 일관성 μ—†λŠ” API의 문제점

// ❌ BAD: 일관성 μ—†λŠ” API 응닡듀
// GET /users/1
{
  "id": 1,
  "name": "κΉ€κ°œλ°œ"
}

// GET /products/1  
{
  "success": true,
  "data": {
    "product_id": 1,
    "product_name": "λ§₯뢁"
  }
}

// POST /orders (μ—λŸ¬ λ°œμƒ)
{
  "error": "Invalid request",
  "code": 400
}

ν΄λΌμ΄μ–ΈνŠΈ 개발자의 고톡:

  • 🀯 APIλ§ˆλ‹€ λ‹€λ₯Έ 응닡 ꡬ쑰둜 μΈν•œ ν˜Όλž€
  • πŸ› μ˜ˆμΈ‘ν•  수 μ—†λŠ” μ—λŸ¬ 처리 둜직
  • ⏰ 개발 μ‹œκ°„ 증가 및 μœ μ§€λ³΄μˆ˜ 어렀움

πŸ’‘ ν•΄κ²°μ±… 1: 곡톡 응닡 래퍼 클래슀

πŸ—οΈ ApiResponse 클래슀 섀계

// βœ… λͺ¨λ“  APIκ°€ μ‚¬μš©ν•  곡톡 응닡 ꡬ쑰
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
    
    private boolean success;
    private T data;
    private ApiError error;
    private LocalDateTime timestamp;
    
    // 성곡 응닡 생성 λ©”μ„œλ“œ
    public static <T> ApiResponse<T> success(T data) {
        return ApiResponse.<T>builder()
                .success(true)
                .data(data)
                .timestamp(LocalDateTime.now())
                .build();
    }
    
    // μ‹€νŒ¨ 응닡 생성 λ©”μ„œλ“œ
    public static <T> ApiResponse<T> failure(String errorCode, String message) {
        return ApiResponse.<T>builder()
                .success(false)
                .error(ApiError.of(errorCode, message))
                .timestamp(LocalDateTime.now())
                .build();
    }
}

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiError {
    private String code;
    private String message;
    private List<FieldError> details;
    
    public static ApiError of(String code, String message) {
        return ApiError.builder()
                .code(code)
                .message(message)
                .build();
    }
}

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FieldError {
    private String field;
    private String message;
    private Object rejectedValue;
}

🎯 μ‹€μ œ 컨트둀러 적용 μ˜ˆμ‹œ

@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
    
    private final ProductService productService;
    
    // βœ… μƒν’ˆ 생성 - μΌκ΄€λœ 성곡 응닡
    @PostMapping
    public ResponseEntity<ApiResponse<ProductResponse>> createProduct(
            @Valid @RequestBody ProductCreateRequest request) {
        
        ProductResponse product = productService.createProduct(request);
        ApiResponse<ProductResponse> response = ApiResponse.success(product);
        
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
    
    // βœ… μƒν’ˆ 쑰회 - μΌκ΄€λœ 성곡 응닡
    @GetMapping("/{id}")
    public ResponseEntity<ApiResponse<ProductResponse>> getProduct(@PathVariable Long id) {
        ProductResponse product = productService.getProduct(id);
        ApiResponse<ProductResponse> response = ApiResponse.success(product);
        
        return ResponseEntity.ok(response);
    }
    
    // βœ… μƒν’ˆ λͺ©λ‘ 쑰회 - νŽ˜μ΄μ§• 포함
    @GetMapping
    public ResponseEntity<ApiResponse<PageResponse<ProductResponse>>> getProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {
        
        PageResponse<ProductResponse> products = productService.getProducts(page, size);
        ApiResponse<PageResponse<ProductResponse>> response = ApiResponse.success(products);
        
        return ResponseEntity.ok(response);
    }
}

🌟 μΌκ΄€λœ 응닡 κ²°κ³Ό

// βœ… GOOD: λͺ¨λ“  APIκ°€ λ™μΌν•œ ꡬ쑰 μ‚¬μš©

// POST /api/v1/products (성곡)
{
  "success": true,
  "data": {
    "id": 1,
    "name": "λ§₯뢁 ν”„λ‘œ 16인치",
    "price": 2490000,
    "stockQuantity": 10
  },
  "error": null,
  "timestamp": "2025-08-19T10:00:00"
}

// GET /api/v1/products/999 (μ‹€νŒ¨ - μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μƒν’ˆ)
{
  "success": false,
  "data": null,
  "error": {
    "code": "PRODUCT_NOT_FOUND",
    "message": "μƒν’ˆμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.",
    "details": null
  },
  "timestamp": "2025-08-19T10:00:00"
}

πŸ’‘ ν•΄κ²°μ±… 2: κΈ€λ‘œλ²Œ μ˜ˆμ™Έ 처리

πŸ›‘οΈ @RestControllerAdvice둜 톡합 μ˜ˆμ™Έ 처리

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    // βœ… λΉ„μ¦ˆλ‹ˆμŠ€ μ˜ˆμ™Έ 처리
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
        log.warn("Business exception occurred: {}", ex.getMessage());
        
        ApiResponse<Void> response = ApiResponse.failure(ex.getErrorCode(), ex.getMessage());
        return ResponseEntity.status(ex.getHttpStatus()).body(response);
    }
    
    // βœ… μœ νš¨μ„± 검사 μ‹€νŒ¨ 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidationException(
            MethodArgumentNotValidException ex) {
        
        List<FieldError> fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> FieldError.builder()
                        .field(error.getField())
                        .message(error.getDefaultMessage())
                        .rejectedValue(error.getRejectedValue())
                        .build())
                .collect(Collectors.toList());
        
        ApiError apiError = ApiError.builder()
                .code("VALIDATION_ERROR")
                .message("μž…λ ₯ 값이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")
                .details(fieldErrors)
                .build();
        
        ApiResponse<Void> response = ApiResponse.<Void>builder()
                .success(false)
                .error(apiError)
                .timestamp(LocalDateTime.now())
                .build();
        
        return ResponseEntity.badRequest().body(response);
    }
    
    // βœ… μ˜ˆμƒμΉ˜ λͺ»ν•œ μ„œλ²„ 였λ₯˜ 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleUnexpectedException(Exception ex) {
        log.error("Unexpected exception occurred", ex);
        
        ApiResponse<Void> response = ApiResponse.failure(
                "INTERNAL_SERVER_ERROR", 
                "μ„œλ²„ λ‚΄λΆ€ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}

🎯 μ»€μŠ€ν…€ λΉ„μ¦ˆλ‹ˆμŠ€ μ˜ˆμ™Έ 클래슀

@Getter
public class BusinessException extends RuntimeException {
    private final String errorCode;
    private final HttpStatus httpStatus;
    
    public BusinessException(String errorCode, String message, HttpStatus httpStatus) {
        super(message);
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
    }
    
    // 자주 μ‚¬μš©ν•˜λŠ” μ˜ˆμ™Έλ“€μ„ 정적 νŒ©ν† λ¦¬ λ©”μ„œλ“œλ‘œ 제곡
    public static BusinessException notFound(String resource) {
        return new BusinessException(
                "RESOURCE_NOT_FOUND",
                resource + "을(λ₯Ό) 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.",
                HttpStatus.NOT_FOUND
        );
    }
    
    public static BusinessException badRequest(String message) {
        return new BusinessException(
                "BAD_REQUEST",
                message,
                HttpStatus.BAD_REQUEST
        );
    }
}

πŸ’‘ ν•΄κ²°μ±… 3: νŽ˜μ΄μ§• 응닡 ν‘œμ€€ν™”

πŸ“„ PageResponse 클래슀 섀계

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PageResponse<T> {
    private List<T> content;
    private PageInfo pageInfo;
    
    public static <T> PageResponse<T> from(Page<T> page) {
        return PageResponse.<T>builder()
                .content(page.getContent())
                .pageInfo(PageInfo.from(page))
                .build();
    }
}

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PageInfo {
    private int page;           // ν˜„μž¬ νŽ˜μ΄μ§€ (0-based)
    private int size;           // νŽ˜μ΄μ§€ λ‹Ή μ•„μ΄ν…œ 수
    private long totalElements; // 전체 μ•„μ΄ν…œ 수
    private int totalPages;     // 전체 νŽ˜μ΄μ§€ 수
    private boolean first;      // 첫 번째 νŽ˜μ΄μ§€ μ—¬λΆ€
    private boolean last;       // λ§ˆμ§€λ§‰ νŽ˜μ΄μ§€ μ—¬λΆ€
    
    public static PageInfo from(Page<?> page) {
        return PageInfo.builder()
                .page(page.getNumber())
                .size(page.getSize())
                .totalElements(page.getTotalElements())
                .totalPages(page.getTotalPages())
                .first(page.isFirst())
                .last(page.isLast())
                .build();
    }
}

🎯 μ„œλΉ„μŠ€ λ ˆμ΄μ–΄μ—μ„œ ν™œμš©

@Service
@RequiredArgsConstructor
public class ProductService {
    
    private final ProductRepository productRepository;
    
    public PageResponse<ProductResponse> getProducts(int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        Page<Product> productPage = productRepository.findAll(pageable);
        
        // Entityλ₯Ό DTO둜 λ³€ν™˜
        Page<ProductResponse> responsePage = productPage.map(ProductResponse::from);
        
        return PageResponse.from(responsePage);
    }
}

🌟 μΌκ΄€λœ νŽ˜μ΄μ§• 응닡

// βœ… GOOD: λͺ¨λ“  λͺ©λ‘ APIκ°€ λ™μΌν•œ νŽ˜μ΄μ§• ꡬ쑰 μ‚¬μš©
{
  "success": true,
  "data": {
    "content": [
      {
        "id": 1,
        "name": "λ§₯뢁 ν”„λ‘œ 16인치",
        "price": 2490000
      },
      {
        "id": 2,
        "name": "아이폰 15 Pro",
        "price": 1350000
      }
    ],
    "pageInfo": {
      "page": 0,
      "size": 10,
      "totalElements": 25,
      "totalPages": 3,
      "first": true,
      "last": false
    }
  },
  "error": null,
  "timestamp": "2025-08-19T10:00:00"
}

πŸ’‘ ν•΄κ²°μ±… 4: 넀이밍 μ»¨λ²€μ…˜ 톡일

🎨 JSON ν•„λ“œ 넀이밍 κ·œμΉ™

Jackson μ„€μ •μœΌλ‘œ μžλ™ λ³€ν™˜

# application.yml
spring:
  jackson:
    property-naming-strategy: SNAKE_CASE  # camelCase -> snake_case λ³€ν™˜
    # λ˜λŠ” LOWER_CAMEL_CASE (κΈ°λ³Έκ°’, camelCase μœ μ§€)

DTO 클래슀 μ˜ˆμ‹œ

// βœ… GOOD: μΌκ΄€λœ 넀이밍 μ»¨λ²€μ…˜
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductResponse {
    private Long productId;        // JSON: product_id (snake_case μ„€μ • μ‹œ)
    private String productName;    // JSON: product_name
    private BigDecimal unitPrice;  // JSON: unit_price
    private Integer stockQuantity; // JSON: stock_quantity
    private String categoryName;   // JSON: category_name
    private LocalDateTime createdAt; // JSON: created_at
    
    public static ProductResponse from(Product product) {
        return ProductResponse.builder()
                .productId(product.getProductId())
                .productName(product.getName())
                .unitPrice(product.getPrice())
                .stockQuantity(product.getStockQuantity())
                .categoryName(product.getCategory())
                .createdAt(product.getCreatedAt())
                .build();
    }
}

🌐 REST API URL μ»¨λ²€μ…˜

// βœ… GOOD: RESTfulν•˜κ³  μΌκ΄€λœ URL ꡬ쑰
@RequestMapping("/api/v1/products")  // λ³΅μˆ˜ν˜• λͺ…사 μ‚¬μš©
public class ProductController {
    
    @GetMapping                      // GET /api/v1/products
    @GetMapping("/{id}")            // GET /api/v1/products/1
    @PostMapping                    // POST /api/v1/products
    @PutMapping("/{id}")           // PUT /api/v1/products/1
    @DeleteMapping("/{id}")        // DELETE /api/v1/products/1
    
    // ν•˜μœ„ λ¦¬μ†ŒμŠ€ μ ‘κ·Ό
    @GetMapping("/{id}/stocks")    // GET /api/v1/products/1/stocks
}

πŸ’‘ ν•΄κ²°μ±… 5: HTTP μƒνƒœ μ½”λ“œ ν‘œμ€€ν™”

πŸ“Š μƒνƒœ μ½”λ“œ λ§€ν•‘ κ°€μ΄λ“œ

@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
    
    // βœ… 201 Created - λ¦¬μ†ŒμŠ€ 생성 성곡
    @PostMapping
    public ResponseEntity<ApiResponse<ProductResponse>> createProduct(
            @Valid @RequestBody ProductCreateRequest request) {
        ProductResponse product = productService.createProduct(request);
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(ApiResponse.success(product));
    }
    
    // βœ… 200 OK - 쑰회/μˆ˜μ • 성곡
    @GetMapping("/{id}")
    public ResponseEntity<ApiResponse<ProductResponse>> getProduct(@PathVariable Long id) {
        ProductResponse product = productService.getProduct(id);
        return ResponseEntity.ok(ApiResponse.success(product));
    }
    
    // βœ… 204 No Content - μ‚­μ œ 성곡
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }
}

🎯 μƒνƒœ μ½”λ“œλ³„ μ‚¬μš© κΈ°μ€€

HTTP μƒνƒœ μ½”λ“œ μ‚¬μš© 상황 응닡 λ°”λ””
200 OK 쑰회, μˆ˜μ • 성곡 βœ… 데이터 포함
201 Created 생성 성곡 βœ… μƒμ„±λœ λ¦¬μ†ŒμŠ€ 정보
204 No Content μ‚­μ œ 성곡 ❌ λ°”λ”” μ—†μŒ
400 Bad Request μœ νš¨μ„± 검사 μ‹€νŒ¨ βœ… μ—λŸ¬ 정보
401 Unauthorized 인증 μ‹€νŒ¨ βœ… μ—λŸ¬ 정보
403 Forbidden κΆŒν•œ μ—†μŒ βœ… μ—λŸ¬ 정보
404 Not Found λ¦¬μ†ŒμŠ€ μ—†μŒ βœ… μ—λŸ¬ 정보
500 Internal Server Error μ„œλ²„ λ‚΄λΆ€ 였λ₯˜ βœ… μ—λŸ¬ 정보

πŸ› οΈ 싀무 적용 체크리슀트

βœ… API 응닡 ꡬ쑰 톡일

  • ApiResponse<T> 곡톡 래퍼 클래슀 κ΅¬ν˜„λ¨
  • 성곡/μ‹€νŒ¨ 응닡이 λ™μΌν•œ ꡬ쑰λ₯Ό 가짐
  • νƒ€μž„μŠ€νƒ¬ν”„κ°€ λͺ¨λ“  응닡에 포함됨

βœ… μ˜ˆμ™Έ 처리 ν‘œμ€€ν™”

  • @RestControllerAdvice둜 κΈ€λ‘œλ²Œ μ˜ˆμ™Έ 처리 κ΅¬ν˜„
  • λΉ„μ¦ˆλ‹ˆμŠ€ μ˜ˆμ™Έμ™€ μ‹œμŠ€ν…œ μ˜ˆμ™Έλ₯Ό κ΅¬λΆ„ν•˜μ—¬ 처리
  • μœ νš¨μ„± 검사 μ‹€νŒ¨ μ‹œ μƒμ„Έν•œ ν•„λ“œ 였λ₯˜ 정보 제곡

βœ… νŽ˜μ΄μ§• 응닡 톡일

  • PageResponse<T> 클래슀둜 νŽ˜μ΄μ§• 정보 ν‘œμ€€ν™”
  • Spring Data JPA의 Page 객체 ν™œμš©
  • νŽ˜μ΄μ§• 메타데이터 (총 개수, νŽ˜μ΄μ§€ 수 λ“±) 포함

βœ… 넀이밍 μ»¨λ²€μ…˜ 톡일

  • JSON ν•„λ“œ 넀이밍 κ·œμΉ™ (camelCase λ˜λŠ” snake_case) 선택 및 적용
  • REST API URL ꡬ쑰 ν‘œμ€€ν™” (λ³΅μˆ˜ν˜• λͺ…사, 계측 ꡬ쑰)
  • 직관적이고 μΌκ΄€λœ λ³€μˆ˜/λ©”μ„œλ“œλͺ… μ‚¬μš©

βœ… HTTP μƒνƒœ μ½”λ“œ ν‘œμ€€ν™”

  • 상황별 μ μ ˆν•œ HTTP μƒνƒœ μ½”λ“œ μ‚¬μš©
  • μƒνƒœ μ½”λ“œμ™€ 응닡 λ°”λ”” ꡬ쑰의 일관성 μœ μ§€
  • ν΄λΌμ΄μ–ΈνŠΈκ°€ μƒνƒœ μ½”λ“œλ§ŒμœΌλ‘œ κ²°κ³Όλ₯Ό μ˜ˆμΈ‘ν•  수 있음

πŸŽ‰ API μΌκ΄€μ„±μ˜ 효과

πŸ“ˆ 개발 생산성 ν–₯상

  • ν΄λΌμ΄μ–ΈνŠΈ 개발자: 예츑 κ°€λŠ₯ν•œ API둜 λΉ λ₯Έ 개발
  • Backend 개발자: ν‘œμ€€ν™”λœ ꡬ쑰둜 μΌκ΄€λœ μ½”λ“œ μž‘μ„±
  • QA ν…ŒμŠ€ν„°: λͺ…ν™•ν•œ 응닡 ꡬ쑰둜 효율적인 ν…ŒμŠ€νŠΈ

πŸ”§ μœ μ§€λ³΄μˆ˜μ„± κ°œμ„ 

  • μ—λŸ¬ 디버깅: μΌκ΄€λœ μ—λŸ¬ ꡬ쑰둜 λΉ λ₯Έ 문제 νŒŒμ•…
  • API λ¬Έμ„œν™”: ν‘œμ€€ν™”λœ ꡬ쑰둜 μžλ™ν™”λœ λ¬Έμ„œ 생성
  • μ½”λ“œ 리뷰: μΌκ΄€λœ νŒ¨ν„΄μœΌλ‘œ 리뷰 μ‹œκ°„ 단좕

πŸš€ ν™•μž₯μ„± 확보

  • μƒˆλ‘œμš΄ API μΆ”κ°€: κΈ°μ‘΄ νŒ¨ν„΄μ„ 따라 λΉ λ₯Έ 개발
  • νŒ€ ν™•μž₯: μƒˆλ‘œμš΄ κ°œλ°œμžλ„ μ‰½κ²Œ νŒ¨ν„΄ ν•™μŠ΅
  • λ§ˆμ΄ν¬λ‘œμ„œλΉ„μŠ€: μ„œλΉ„μŠ€ κ°„ μΌκ΄€λœ 톡신 ꡬ쑰

πŸ“š μΆ”κ°€ ν•™μŠ΅ 자료

  • Spring Boot 곡식 λ¬Έμ„œ: https://spring.io/projects/spring-boot
  • OpenAPI/Swagger: https://swagger.io/specification/
  • REST API 베슀트 ν”„λž™ν‹°μŠ€: RESTful Web Services 섀계 κ°€μ΄λ“œ
  • HTTP μƒνƒœ μ½”λ“œ 상세: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status

API 일관성은 ν•œ 번 잘 섀계해두면 λͺ¨λ“  νŒ€μ›μ΄ μ˜€λž«λ™μ•ˆ ν˜œνƒμ„ λ°›λŠ” νˆ¬μžμž…λ‹ˆλ‹€! 🎯