모든 소프트웨어 엔지니어가 알아야 할 API 개념: 완전 가이드

이 글에는 짧은 영상이 있습니다:TikTok

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은 안전해야 하고 (상태를 변경하지 않음) 멱등성이 있어야 한다. 만약 당신의 GET endpoint가 카운터를 증가시키거나 중요한 로그를 기록한다면, client와의 암묵적 계약을 위반하는 것이다 (그리고 proxy/CDN 캐시도 마찬가지).
  • PUT은 전체 리소스를 대체하고, PATCH는 변경 부분만 업데이트한다. PUT을 단일 필드 업데이트에 사용하는 것은 의미론적으로 잘못됐다. 코드는 작동하겠지만.
  • 상태 코드는 정확한 의미를 반영해야 한다: 새 리소스 생성 시 201 Created (header Location 포함), 성공적 삭제 시 본문 없이 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를 설계할 수 있는 기초다.

system-designapispring-bootrestsecurity