모든 소프트웨어 엔지니어가 알아야 할 API 개념: 완전 가이드
1. 개요
API를 호출하는 건 쉽다 — 요청을 보내고, JSON을 읽고, 끝. 하지만 다른 팀이나 심지어 6개월 뒤의 자신도 코드를 읽지 않고도 동작을 예측할 수 있는 API를 설계하는 것은 전혀 다른 이야기다. 이 글에서는 모든 백엔드 엔지니어가 알아야 할 API 개념들을 정리했다: HTTP 기초, 아키텍처 스타일(REST, GraphQL, gRPC, webhook, WebSocket), 지속 가능한 설계, 보안, 신뢰성, 그리고 문서화까지. 이 글을 읽은 후, 당신은 공개하기 전에 자신의 API를 검토할 수 있는 구체적인 체크리스트를 갖추게 될 것이다.
2. HTTP 기초: method, status code, request/response
REST나 GraphQL을 얘기하기 전에, 먼저 기본 HTTP 의미론을 정확하게 사용해야 한다. 여기서 많은 API가 “REST처럼 보이지만” 사실 완전히 틀린다.
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
ResponseEntity<Order> getOrder(@PathVariable Long id) {
return orderService.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
Order created = orderService.create(request);
return ResponseEntity.created(URI.create("/api/orders/" + created.id())).body(created);
}
@PutMapping("/{id}")
ResponseEntity<Order> replaceOrder(@PathVariable Long id, @RequestBody Order order) {
return ResponseEntity.ok(orderService.replace(id, order));
}
@DeleteMapping("/{id}")
ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
orderService.cancel(id);
return ResponseEntity.noContent().build();
}
}
흔히 하는 실수들:
GET은 안전해야 하고 (상태를 변경하지 않음) 멱등성이 있어야 한다. 만약 당신의GETendpoint가 카운터를 증가시키거나 중요한 로그를 기록한다면, client와의 암묵적 계약을 위반하는 것이다 (그리고 proxy/CDN 캐시도 마찬가지).PUT은 전체 리소스를 대체하고,PATCH는 변경 부분만 업데이트한다.PUT을 단일 필드 업데이트에 사용하는 것은 의미론적으로 잘못됐다. 코드는 작동하겠지만.- 상태 코드는 정확한 의미를 반영해야 한다: 새 리소스 생성 시
201 Created(headerLocation포함), 성공적 삭제 시 본문 없이204 No Content, 상태 충돌 시409 Conflict— 모든 오류를400이나500으로 돌릴 수 없다.
주의: 모든 응답에
200 OK를 반환하고 본문에"success": false필드를 포함하는 것은 흔한 anti-pattern이다. Client, 로드 밸런서, APM 도구 모두 status code를 기반으로 retry 여부나 alert를 결정한다 — 오류를 본문에 숨기는 것은 전체 흐름을 망친다.
3. API 스타일 선택: REST, GraphQL, gRPC, webhook, WebSocket
각 아키텍처 스타일은 서로 다른 문제를 해결한다. 잘못된 선택은 기술적 오류가 아니라 use case를 잘못 이해한 것이다.
3.1. REST — HTTP를 통한 CRUD의 기본값
리소스가 명확하고 (Order, Customer, Invoice) client가 다양할 때 (웹, 모바일, 써드파티) 적합하다. 섹션 2의 예제가 정확한 REST다.
3.2. GraphQL — client가 필드 유연성을 원할 때
type Order {
id: ID!
customer: Customer!
total: Float!
items: [OrderItem!]!
}
type Query {
order(id: ID!): Order
}
@Controller
class OrderGraphQlController {
private final OrderService orderService;
OrderGraphQlController(OrderService orderService) {
this.orderService = orderService;
}
@QueryMapping
Order order(@Argument Long id) {
return orderService.findById(id).orElse(null);
}
}
Client가 필요한 필드를 선택한다 (3개의 REST endpoint를 호출하는 대신 { order(id: 1) { total } }). 대신 URL 기반의 HTTP 캐싱을 잃게 되고, N+1 쿼리 문제를 직접 해결해야 한다 (DataLoader 사용). GraphQL은 복잡한 UI와 다양한 client가 서로 다른 데이터를 필요로 하는 앱에 적합하다 — 간단한 API나 client가 적은 경우엔 부담이다.
3.3. gRPC — 내부 service 간 높은 성능이 필요할 때
service PaymentService {
rpc Charge (ChargeRequest) returns (ChargeResponse);
}
message ChargeRequest {
string order_id = 1;
int64 amount_cents = 2;
}
gRPC는 HTTP/2 + Protobuf (바이너리, JSON보다 작음)를 사용하고 양방향 streaming을 지원한다. microservices의 service-to-service 통신에 최적이지만, 수동 디버깅이 어렵고 (일반 curl로 읽을 수 없음) 브라우저용 공개 API에는 부적합하다.
3.4. Webhook — 수신자가 능동적으로 알림을 받아야 할 때
@PostMapping("/webhooks/payment-status")
ResponseEntity<Void> handlePaymentWebhook(@RequestBody PaymentStatusEvent event,
@RequestHeader("X-Signature") String signature) {
if (!signatureValidator.isValid(event, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
paymentEventHandler.handle(event);
return ResponseEntity.ok().build();
}
Webhook는 호출 방향을 역전시킨다: PaymentService의 서버가 이벤트가 발생하면 당신의 서버를 역으로 호출한다. 위조된 요청을 피하기 위해 항상 signature (HMAC)를 검증하고, 빠르게 200을 반환한 후 비동기로 처리하라 — 송신자는 보통 timeout이나 오류 시 재시도한다.
3.5. WebSocket — 양방향 실시간 통신이 필요할 때
채팅, 실시간 추적, 지속적으로 업데이트되는 대시보드에 사용한다. 단순한 request-response에는 부적합하다. 연결을 유지하는 오버헤드가 REST보다 훨씬 크기 때문이다.
4. 지속 가능한 설계: naming, pagination, versioning, error response
4.1. 일관성 있는 명명
컬렉션에는 복수형 명사를 사용하고 (/orders, /order나 /getOrders는 아님), 관계를 나타내려면 중첩된 명사를 사용하라 (/customers/{id}/orders). 같은 API에서 camelCase와 snake_case를 섞지 말아야 한다.
4.2. Pagination
@GetMapping
Page<Order> listOrders(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return orderService.findAll(PageRequest.of(page, size));
}
GET /api/orders?page=1&size=20
Offset 기반 (page/size)은 이해하기 쉽지만 큰 테이블에서 느리고, 페이징 중에 insert/delete가 있으면 중복되거나 누락된 레코드를 반환할 수 있다. Cursor 기반 (?after=order_1234)은 큰 데이터셋에서 더 안정적이지만, “10페이지로 이동” 기능을 구현하기는 더 복잡하다.
4.3. Versioning
@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { /* ... */ }
URI path를 통한 versioning은 읽기 쉽고 gateway로 라우팅하기 쉽다. Header를 통한 versioning (Accept: application/vnd.myapp.v2+json)은 URL이 깔끔하지만 브라우저에서 디버깅하기 어렵다. 어느 방식을 선택하든 중요하지 않다. 중요한 건 명확한 deprecation 전략을 가지는 것이다: 미리 알리고, 오래된 버전을 충분히 오래 유지하고, Deprecation과 Sunset header를 반환하라.
4.4. 표준화된 오류 응답
@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ProblemDetail> handleNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setProperty("orderId", ex.getOrderId());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Order 99 not found",
"orderId": 99
}
ProblemDetail (Spring Boot 3.x가 기본 지원하며, RFC 7807을 따름)은 전체 시스템에서 일관된 오류 형식을 제공한다 — client는 단일 schema만 파싱하면 되고, 어느 필드에 오류 메시지가 있는지 추측할 필요가 없다.
5. 보안: API key, OAuth2, JWT, scope
5.1. API key — 간단함, 신뢰도 낮은 service-to-service에 적합
@Component
class ApiKeyFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String apiKey = req.getHeader("X-Api-Key");
if (apiKey == null || !apiKeyRepository.isValid(apiKey)) {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
return;
}
chain.doFilter(req, res);
}
}
API key는 누가 누군가 대신 호출하는지 구별할 수 없다 — 앱을 인증할 뿐, 사용자를 인증하지 않는다. 최종 사용자의 개인 데이터를 보호하는 데 사용하지 말아야 한다.
5.2. OAuth2 + JWT — scope별 권한 분화가 필요할 때
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.nonladev.com
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
return ResponseEntity.ok(orderService.create(request));
}
JWT는 claim (user id, scope, exp)을 포함하고 public key로 자체 검증 가능하므로, 매 요청마다 auth server에 물어볼 필요가 없다 — 이것이 기존 session보다 확장성이 뛰어난 이유다. 하지만 JWT는 만료 전 쉽게 취소될 수 없다: 사용자 계정이 잠기면, 기존 token은 exp까지 유효한 상태로 남는다. 흔한 해결책: 짧은 exp (5–15분) + refresh token을 사용하거나, gateway layer에서 blacklist를 확인하는 것이다.
주의: Scope은 “이 token이 뭘 할 수 있는가”만 말할 뿐, 비즈니스 규칙 검사를 대체하지 않는다 (예: user A는
orders:write를 가졌지만 user B의 order를 수정할 권한이 없음). 항상 service layer에서 ownership을 검사하라.
6. 신뢰성: timeout, retry, idempotency, rate limit, cache
6.1. Timeout과 retry
@Bean
RestClient paymentClient() {
return RestClient.builder()
.baseUrl("https://payment.internal")
.requestFactory(ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(2))
.withReadTimeout(Duration.ofSeconds(3))
.buildRequestFactory())
.build();
}
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
@Retry(name = "paymentService", fallbackMethod = "chargeFallback")
Charge charge(ChargeRequest request) {
return paymentClient.post().uri("/charge").body(request).retrieve().body(Charge.class);
}
timeout을 설정하지 않으면 = downstream이 다운되었을 때 스레드가 무한정 멈춘다. 그러면 domino effect로 당신의 service까지 다운된다. 부주의한 retry는 request storm을 유발해 downstream을 더 심하게 망가뜨린다 — retry를 exponential backoff와 circuit breaker와 항상 함께 사용하라.
6.2. 금전 관련 작업을 위한 Idempotency key
@PostMapping("/charges")
ResponseEntity<Charge> charge(@RequestHeader("Idempotency-Key") String key,
@RequestBody ChargeRequest request) {
return idempotencyStore.findByKey(key)
.map(existing -> ResponseEntity.ok(existing))
.orElseGet(() -> {
Charge charge = paymentService.charge(request);
idempotencyStore.save(key, charge);
return ResponseEntity.ok(charge);
});
}
Client가 같은 Idempotency-Key로 같은 요청을 재전송하면 (timeout, network glitch 때문에), 기존 결과를 받게 되어 중복 charge를 피할 수 있다. 이것이 금융 side-effect를 가진 모든 API에서 가장 중요한 개념이다.
@Test
void shouldReturnSameChargeForDuplicateIdempotencyKey() {
ChargeRequest request = new ChargeRequest("order-1", 10_000L);
Charge first = client.charge("idem-key-1", request);
Charge second = client.charge("idem-key-1", request);
assertThat(second).isEqualTo(first);
assertThat(paymentGateway.chargeCallCount()).isEqualTo(1);
}
6.3. Rate limit과 cache
@Bean
Bucket4jConfiguration rateLimitConfig() {
return Bucket4jConfiguration.builder()
.capacity(100)
.refillGreedy(100, Duration.ofMinutes(1))
.build();
}
@Cacheable(value = "orders", key = "#id")
Order findById(Long id) {
return orderRepository.findById(id).orElseThrow();
}
Rate limit은 결함 있는 client (의도적이든 아니든)로 인한 과부하로부터 시스템을 보호한다. Cache는 downstream의 부하를 줄이지만, TTL과 cache invalidation을 신중히 고려해야 한다 — 처리 중인 order 데이터에 대한 stale cache는 cache가 없는 것보다 상태 오해를 훨씬 더 심각하게 만들 수 있다.
7. 문서화, observability, contract testing
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
@Operation(summary = "새로운 order 생성", responses = {
@ApiResponse(responseCode = "201", description = "Order가 성공적으로 생성되었습니다")
})
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) { /* ... */ }
annotation에서 자동으로 생성된 OpenAPI spec은 spec이 실제 코드와 “맞지 않는” 문제를 방지한다 — 수동으로 작성한 문서가 코드는 변경되었는데 update를 까먹는 전형적인 문제를 해결한다.
Contract testing (Spring Cloud Contract, Pact)은 consumer와 provider가 독립적으로 배포할 때 계약을 위반하지 않도록 보장한다:
@Test
void providerShouldHonorOrderContract() {
Order order = orderService.findById(1L).orElseThrow();
assertThat(order.id()).isEqualTo(1L);
assertThat(order.status()).isIn("PENDING", "PAID", "CANCELLED");
}
observability (structured logging, header traceparent를 통한 service 간 trace ID, endpoint별 latency/error rate metrics)와 결합하면, 운영 팀은 production 디버깅에 필요한 충분한 정보를 가지게 되어, 단편적인 로그를 통해 추측하는 대신 체계적으로 문제를 찾을 수 있다.
8. 결론
API 설계는 올바른 framework를 선택하는 데 있지 않다 — 그것은 수많은 작은 결정들이다: 어떤 method, 어떤 status code, 어떤 pagination 방식, 누가 어떤 scope로 호출할 수 있는지, retry는 몇 번까지 시도할지. 각각의 결정은 단순해 보이지만, 모두 합쳐지면 당신의 API가 사용하기 쉬운지 아니면 통합하는 모든 팀의 골치거리인지를 결정한다. 위의 개념들을 확실히 이해하자 — HTTP semantics, use case에 맞는 아키텍처 선택, 계층별 보안, reliability pattern, 그리고 코드와 동기화된 문서화 — 이것이 다른 사람들이 장기적으로 실제로 신뢰하고 사용하고 싶어 하는 API를 설계할 수 있는 기초다.