π 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 μΌκ΄μ±μ ν λ² μ μ€κ³ν΄λλ©΄ λͺ¨λ νμμ΄ μ€λ«λμ ννμ λ°λ ν¬μμ λλ€! π―