API Design Basics
분류: Layer 1 - 백엔드 기초 | 선수지식: HTTP Basics
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”API(Application Programming Interface) 설계는 서비스 간 또는 클라이언트-서버 간 데이터를 주고받는 인터페이스를 일관되고 예측 가능하게 만드는 것이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”API는 서비스의 “문”이다. 문이 체계적이지 않으면 쓰는 사람이 매번 헷갈리고, 유지보수가 어렵다. BackOps에서 내부 API를 리뷰하거나, 외부 서비스와 연동할 때 API 설계 원칙을 알아야 “이 API가 잘 만들어진 건지, 뭐가 문제인지” 판단할 수 있다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”REST(Representational State Transfer) — SOAP/XML-RPC의 한계를 푼 방법
REST는 진공에서 나오지 않았다. 2000년 Roy Fielding의 박사논문(Architectural Styles and the Design of Network-based Software Architectures) 이전, 분산 시스템 통신의 표준은 SOAP와 XML-RPC였고 다음 4가지 한계로 웹 환경에서 깨졌다.
- HTTP를 단순 transport로만 사용: 조회든 수정이든 전부
POST /service+ XML envelope. HTTP가 가진 메서드 의미체계(GET=safe, PUT=idempotent, DELETE=원자 삭제)와 URL의 리소스 표현 능력이 모두 사라졌다. - HTTP-level 캐싱 불가: 모든 요청이 POST이므로 CDN·브라우저 캐시·중간 프록시 캐시가 의미를 잃었다. 조회 응답도 매번 원본 서버까지 도달했다 (출처: Mertech — API protocols).
- payload 4~5배 무거움: 동일 응답에서 SOAP XML envelope ~5KB ↔ REST JSON ~1KB. envelope 1개당 약 30ms 처리 overhead 추가 (출처: Aalto Univ. SOAP vs REST 벤치마크 2017).
- 별도 규약 학습 비용: WSDL · SOAPAction 헤더 · namespace를 다 외워야 호출 한 줄을 작성할 수 있었다.
REST가 푼 방식은 단순했다 — “HTTP가 이미 가진 의미체계를 그대로 인터페이스 계약으로 노출한다.”
- GET/POST/PUT/DELETE 자체가 동사 의미 → URL은 명사(리소스)로 단순화.
getUsers같은 동사를 새로 만들 필요 없음. - GET을 메서드 단위로 분리 → CDN·브라우저 캐시·HTTP
304 Not Modified가 자동 적용 → SOAP가 풀 수 없던 캐싱 문제 해결. - JSON 채택 → payload 25~80% 절감 + 파싱 비용 감소.
- 클라이언트가 새 규약을 외울 필요 없이 HTTP만 알면 됨 → 2017년 기준 전체 API의 약 83%가 REST.
→ 이 토픽이 사라지면: HTTP 메서드와 URL을 인터페이스 계약으로 쓰는 관행이 사라진다. 아래에서 다루는 PUT vs PATCH · URL Versioning · Idempotency-Key 같은 결정들은 전부 “HTTP 메서드가 의미를 가진다”는 전제 위에서만 성립한다. 선수지식인 HTTP Basics가 단순한 “전송 프로토콜 소개”가 아니라 “REST가 의미체계를 빌려 쓰는 표준”인 이유가 여기 있다.
비유하자면, REST는 “전 세계가 동의한 도서관 규칙”이다. 책(리소스)을 찾을 때 /books/123이라는 주소로 가고, 빌릴 때는 POST, 반납은 DELETE. 어느 도서관(서버)에 가도 같은 규칙이라 새로 배울 것이 없다.
GET /users→ 사용자 목록 조회GET /users/123→ 특정 사용자 조회POST /users→ 사용자 생성PUT /users/123→ 사용자 전체 수정PATCH /users/123→ 사용자 일부 수정DELETE /users/123→ 사용자 삭제
NestJS에서 REST API 구현 — 컨트롤러 구조
@Controller("users")export class UsersController { constructor(private readonly usersService: UsersService) {}
@Get() // GET /users findAll() { return this.usersService.findAll(); }
@Get(":id") // GET /users/123 findOne(@Param("id") id: string) { return this.usersService.findOne(+id); }
@Post() // POST /users @HttpCode(201) create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); }
@Delete(":id") // DELETE /users/123 @HttpCode(204) remove(@Param("id") id: string) { return this.usersService.remove(+id); }}// GET /users 예상 응답 (200 OK){ "data": [ ], "total": 2}
// POST /users 잘못된 요청 예상 응답 (400 Bad Request){ "error": { "code": "VALIDATION_ERROR", "message": "email은 필수 필드입니다" }}📖 더 보기: RESTful API Best Practices in NestJS — NestJS에서 자주 묻는 REST 설계 질문과 답변 모음 (입문)
리소스 중심 설계
URL은 “동사”가 아니라 “명사(리소스)“로 설계한다.
- ✅
GET /orders(주문 목록) - ❌
GET /getOrders(동사 사용)
PUT vs PATCH — 왜 두 가지가 필요한가
비유하자면, PUT은 “서류 전체를 새 서류로 교체”하는 것이고, PATCH는 “서류의 특정 칸만 수정”하는 것이다.
PUT은 멱등성(idempotent)을 가진다. 같은 PUT 요청을 10번 보내도 결과가 같다. 반면 PATCH는 반드시 멱등성이 보장되지 않는다(예: 배열에 항목을 추가하는 PATCH는 실행 횟수마다 결과가 달라진다).
실무에서 PUT은 리소스의 모든 필드를 포함해야 한다. 필드를 빠뜨리면 그 필드가 null 또는 기본값으로 덮어써진다.
// NestJS에서 PUT vs PATCH 구현 비교@Controller("users")export class UsersController { // PUT: 전체 교체 — 빠진 필드는 기본값으로 덮어씀 @Put(":id") replace(@Param("id") id: string, @Body() dto: UpdateUserDto) { return this.usersService.replace(+id, dto); }
// PATCH: 부분 수정 — 보낸 필드만 업데이트 @Patch(":id") update(@Param("id") id: string, @Body() dto: Partial<UpdateUserDto>) { return this.usersService.update(+id, dto); }}// 기존 사용자: { "id": 1, "name": "홍길동", "email": "[email protected]", "role": "user" }
// PUT /users/1 with { "name": "홍길동(수정)" }// → 결과: { "id": 1, "name": "홍길동(수정)", "email": null, "role": null }// ⚠️ email과 role이 null로 덮어써짐 — 위험!
// PATCH /users/1 with { "name": "홍길동(수정)" }// → 결과: { "id": 1, "name": "홍길동(수정)", "email": "[email protected]", "role": "user" }// ✅ name만 바뀌고 나머지는 유지됨📖 더 보기: PATCH vs PUT in REST APIs — ApyHub — 멱등성 개념과 함께 두 메서드의 차이를 실제 예시로 설명 (입문)
버전 관리
API가 바뀌면 기존 클라이언트가 깨질 수 있다. URL에 버전을 넣어서 관리하는 것이 가장 보편적이다.
/v1/users ← 기존 클라이언트가 계속 사용/v2/users ← 새 기능이 추가된 버전 (Breaking Change 포함)버전 관리 전략 4가지:
- URL Path:
/v1/users— 가장 직관적이고 가장 많이 쓰임 (Facebook, GitHub 등) - Query Parameter:
/users?version=1— 구현은 쉽지만 캐싱에 불리 - Header:
Accept: application/vnd.myapi.v1+json— URL 깔끔하지만 눈에 안 보임 - Content Negotiation: 헤더의 미디어 타입으로 버전 분기 — 세밀한 제어 가능하지만 복잡
실무에서는 하나의 전략을 정하고 일관되게 사용하는 것이 중요하다. 팀 내 전략이 혼재하면 클라이언트가 혼란스럽다.
NestJS는 URI 버전 관리를 내장 기능으로 지원한다:
// main.ts — URI 버전 관리 활성화import { VersioningType } from "@nestjs/common";
async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableVersioning({ type: VersioningType.URI, // /v1/, /v2/ 형식 }); await app.listen(3000);}
// users.controller.ts — 버전별 컨트롤러 분리@Controller({ path: "users", version: "1" }) // GET /v1/usersexport class UsersV1Controller { @Get() findAll() { return { data: [{ id: 1, name: "홍길동" }] }; // v1 응답 형식 }}
@Controller({ path: "users", version: "2" }) // GET /v2/usersexport class UsersV2Controller { @Get() findAll() { // v2: 페이지네이션 추가 (Breaking Change) return { data: [{ id: 1, name: "홍길동" }], meta: { total: 1, page: 1 } }; }}# 요청/응답 예시GET /v1/users → { "data": [...] }GET /v2/users → { "data": [...], "meta": { "total": 10, "page": 1 } }페이지네이션 — Offset vs Cursor 방식의 차이
데이터가 많을 때 한 번에 다 보내지 않고 나눠서 보낸다. 방식은 크게 두 가지다.
Offset 기반 페이지네이션 (전통적, 구현 단순):
GET /users?page=2&limit=20→ OFFSET 20 LIMIT 20 SQL 쿼리 실행단점: 데이터가 추가/삭제될 때 페이지 경계가 흔들린다. 예: 1페이지 마지막 항목이 2페이지로 밀려나거나, 삭제 시 빠지는 항목이 생긴다.
Cursor 기반 페이지네이션 (대규모 데이터 권장):
GET /orders?limit=20&cursor=eyJpZCI6MTAwfQ==→ WHERE id > 100 ORDER BY id LIMIT 20비유하자면, Cursor는 “책갈피”다. 책(데이터)에 새 페이지(새 데이터)가 추가되어도 책갈피가 꽂힌 위치는 변하지 않는다.
// NestJS에서 Cursor 페이지네이션 구현 예시@Get()async findAll(@Query('cursor') cursor?: string, @Query('limit') limit = 20) { const decodedCursor = cursor ? parseInt(Buffer.from(cursor, 'base64').toString()) : 0;
const items = await this.usersRepository.find({ where: { id: MoreThan(decodedCursor) }, take: limit + 1, // 다음 페이지 존재 여부 확인용으로 1개 더 조회 order: { id: 'ASC' }, });
const hasNextPage = items.length > limit; const data = hasNextPage ? items.slice(0, limit) : items; const nextCursor = hasNextPage ? Buffer.from(String(data[data.length - 1].id)).toString('base64') : null;
return { data, nextCursor };}// GET /users?limit=2 응답{ "data": [ { "id": 1, "name": "홍길동" }, { "id": 2, "name": "김철수" } ], "nextCursor": "eyJpZCI6Mn0=" // Base64(id:2)}
// GET /users?limit=2&cursor=eyJpZCI6Mn0= 응답 (다음 페이지){ "data": [ { "id": 3, "name": "이영희" }, { "id": 4, "name": "박민수" } ], "nextCursor": null // 마지막 페이지}Cursor 방식은 특히 실시간 피드(알림, 타임라인 등)처럼 데이터가 계속 추가되는 환경에서 Offset보다 안정적이다.
Offset → Cursor 전환 기준 (정량적)
언제 전환해야 하는지 막막할 때 다음 기준을 참고한다.
| 기준 | Offset 유지 | Cursor 전환 권장 |
|---|---|---|
| 데이터셋 크기 | 수만 건 이하, 안정적 | 수십만 건 이상 또는 빠르게 증가 |
| 쓰기 빈도 | 거의 읽기 전용 | 실시간 삽입/삭제 빈번 |
| 쿼리 성능 지표 | OFFSET 100 이하, 응답 < 50ms | OFFSET 1,000 이상 또는 응답 > 100ms |
| UX 패턴 | 페이지 번호 탐색, 어드민 테이블 | 무한 스크롤, 타임라인, 알림 피드 |
| 데이터 일관성 요건 | 약간의 중복/누락 허용 | 중복·누락 절대 안 됨 (결제, 주문 이력) |
실측 사례: Offset 기반 페이지네이션은 OFFSET 0에서 0.025ms, OFFSET 100,000에서 30ms 이상으로 급등한다. Cursor 기반은 동일 조건에서 0.025~0.027ms로 일정하게 유지된다. (출처: PingCAP — Limit/Offset vs. Cursor Pagination in MySQL)
결정 사례 (위 표를 한 시스템에 적용): 알림 피드(쓰기 빈도 높음·무한 스크롤 UX)와 어드민 사용자 목록(읽기 위주·페이지 번호 UI)을 모두 Offset으로 운영하다, 알림 피드의
OFFSET 50000응답이 200ms를 넘기 시작했다. 표의 “쿼리 성능 지표”·“UX 패턴” 두 축이 모두 Cursor 권장 → 알림 피드만 Cursor로 전환. 어드민은OFFSET 100이하에서 50ms 이내라 Offset 유지가 비용·구현 복잡도 면에서 유리 → 그대로 둠. 결론: 표를 “전체 시스템 일괄 전환”의 도구로 쓰지 말고 엔드포인트 단위로 5개 축을 다시 적용해야 한다. 같은 시스템 안에서 두 페이지네이션이 공존하는 게 정답이었다.
필드 필터링 — 응답 크기 줄이기
클라이언트가 필요한 필드만 요청하면 응답 크기를 크게 줄일 수 있다. 특히 풍부한 사용자 프로필이나 제품 카탈로그에서 70% 이상 크기 절감이 가능하다.
GET /users?fields=name,email→ { "name": "홍길동", "email": "[email protected]" } // 불필요한 필드 제거좋은 API 응답의 조건
- 일관된 구조: 성공/실패 모두 같은 JSON 형식
- 명확한 HTTP 상태 코드: 200(성공), 201(생성), 400(잘못된 요청), 404(없음), 500(서버 오류)
- 의미 있는 에러 메시지: 코드와 설명을 함께
- 불필요한 데이터 미포함: 클라이언트가 필요한 필드만 포함
- 일관된 날짜 형식: ISO 8601(
2026-03-21T10:00:00Z)로 통일
// ✅ 성공 응답{ "data": { "id": 123, "name": "홍길동" } }
// ✅ 실패 응답 (같은 구조 유지){ "error": { "code": "NOT_FOUND", "message": "사용자를 찾을 수 없습니다" } }💡 400 vs 422:
400 Bad Request는 요청 구조 자체가 잘못된 것(JSON 파싱 불가 등),422 Unprocessable Entity는 구조는 맞지만 유효성 검증 실패(이메일 형식 오류, 필수 필드 누락 등). NestJS의 기본ValidationPipe는 400을 반환하지만,exceptionFactory로 422로 변경 가능하다. 팀 내 에러 코드 체계를 통일하는 것이 핵심이다.
멱등성 키(Idempotency Key) — 왜 같은 요청이 두 번 실행되면 안 되는가
비유하자면, 멱등성 키는 “중복 접수 방지 번호표”이다. 고객이 같은 번호표를 두 번 제출해도 창구에서는 한 번만 처리하고, 두 번째는 이전 결과를 그대로 돌려준다.
네트워크 장애로 클라이언트가 타임아웃을 받으면 같은 POST 요청을 재시도한다. 서버에 멱등성 키가 없으면 결제가 두 번 되거나 주문이 중복 생성될 수 있다. Stripe, Toss 등 결제 API가 Idempotency-Key 헤더를 필수로 요구하는 이유가 바로 이것이다.
// NestJS Interceptor로 멱등성 키 구현@Injectable()export class IdempotencyInterceptor implements NestInterceptor { constructor(private readonly redis: Redis) {}
async intercept(context: ExecutionContext, next: CallHandler) { const req = context.switchToHttp().getRequest(); const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) return next.handle();
// 1. Redis에서 이전 결과 확인 const cached = await this.redis.get(`idempotency:${idempotencyKey}`); if (cached) return of(JSON.parse(cached)); // 이전 결과 그대로 반환
// 2. 새 요청 처리 후 결과 저장 return next.handle().pipe( tap(async (response) => { await this.redis.setex( `idempotency:${idempotencyKey}`, 86400, // 24시간 TTL JSON.stringify(response), ); }), ); }}# 클라이언트 요청 (멱등성 키 포함)POST /ordersIdempotency-Key: order-abc-123{ "product_id": 1, "quantity": 2 }
# 첫 번째 요청: 주문 생성 → 201 Created# 두 번째 요청 (같은 키): Redis에서 이전 결과 반환 → 201 Created (중복 생성 없음)Idempotency-Key의 silent failure — 500 에러도 캐싱된다
직관과 달리 idempotency 레이어는 성공 응답만 캐시하지 않는다. Stripe는 24시간 동안 500을 포함한 응답을 통째로 캐시한다고 명시한다 — 첫 요청이 일시적 DB 장애로 500을 받았다면, 같은 키로 24시간 안에 재시도해도 동일한 캐시된 500이 반환되어 결제가 영원히 성공하지 못한다(출처: Stripe Blog — Designing robust APIs with idempotency, Stripe API Reference — Idempotent requests). 클라이언트는 “외부 PG가 또 죽었나” 의심하지만 실제로는 첫 실패가 박제된 상태다 — 200 OK가 아니라서 success-rate 알람도 안 울리고, retry가 늘어나는 양상도 아니라서 traffic 대시보드도 평소와 같다.
# 감지 — 동일 idempotency_key가 5xx만 반복하는 패턴을 찾는다psql -d payments -c " SELECT idempotency_key, COUNT(*) AS retries, MAX(status_code) AS last_status FROM payment_attempts WHERE created_at > now() - interval '24 hours' GROUP BY idempotency_key HAVING COUNT(*) > 3 AND BOOL_AND(status_code >= 500);"# 예상 출력 — 키별 N회 시도 + 마지막 status가 5xx면 silent retry storm# idempotency_key | retries | last_status# order-abc-123 | 7 | 500복구는 새 idempotency_key로 재시도한다(이전 키는 24시간 후 자동 만료, 즉시 무효화 API는 제공되지 않음). 사전 방지로는 retryable 오류(network 오류, 5xx)와 non-retryable(카드 거절, validation 실패)을 분리해서 클라이언트가 카테고리에 따라 같은 키를 재사용할지 새 키를 발급할지 결정하도록 만든다.
📖 더 보기: Implementing Idempotency in NestJS with an Interceptor — NestJS에서 Redis 기반 멱등성 키를 Interceptor로 구현하는 실전 가이드 (중급)
NestJS 글로벌 예외 필터 — 응답 형식 통일
모든 에러를 같은 형식으로 반환하는 것이 좋은 API의 기본이다. NestJS ExceptionFilter로 전역 처리를 구현한다.
// http-exception.filter.ts — 전역 예외 필터@Catch(HttpException)export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception.getStatus(); const exceptionResponse = exception.getResponse();
response.status(status).json({ error: { code: typeof exceptionResponse === "object" ? (exceptionResponse as any).error || "ERROR" : "ERROR", message: typeof exceptionResponse === "object" ? (exceptionResponse as any).message : exceptionResponse, timestamp: new Date().toISOString(), path: request.url, }, }); }}
// main.ts — 전역 등록app.useGlobalFilters(new HttpExceptionFilter());// 전역 필터 적용 후 모든 에러 응답이 통일됨{ "error": { "code": "Not Found", "message": "사용자를 찾을 수 없습니다", "timestamp": "2026-04-01T09:00:00.000Z", "path": "/users/999" }}API 버전 관리 실전 — Deprecation 전략과 Breaking Change 관리
API를 버전업할 때 기존 클라이언트를 어떻게 보호하는가가 실무의 핵심이다. 공개 API는 최소 6~12개월 Deprecation 기간을 제공하는 것이 업계 표준이다.
// NestJS — Sunset 헤더로 Deprecation 고지@Controller({ path: "users", version: "1" })export class UsersV1Controller { @Get() @Header("Deprecation", "true") @Header("Sunset", "Sat, 01 Jan 2027 00:00:00 GMT") // 서비스 종료 예정일 @Header("Link", '</v2/users>; rel="successor-version"') // 새 버전 안내 findAll() { return this.usersService.findAllV1(); }}# v1 클라이언트가 받는 응답 헤더HTTP/1.1 200 OKDeprecation: trueSunset: Sat, 01 Jan 2027 00:00:00 GMTLink: </v2/users>; rel="successor-version"Breaking Change vs Non-Breaking Change 구분:
Breaking Change (버전 증가 필요): - 필드 제거 또는 이름 변경 (name → fullName) - 응답 타입 변경 (string → object) - 필수 파라미터 추가 - HTTP 메서드 변경 (GET → POST) - 상태 코드 변경 (200 → 201)
Non-Breaking Change (버전 증가 불필요): - 선택적 필드 추가 (클라이언트가 무시 가능) - 새 엔드포인트 추가 - 성능 개선 - 버그 수정 (동일 동작)OpenAPI/Swagger 자동화 — 문서가 곧 계약
NestJS에서 Swagger를 설정하면 코드가 바뀔 때 문서도 자동으로 업데이트된다. API 계약을 수동으로 관리할 필요가 없다.
// main.ts — Swagger 설정import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
async function bootstrap() { const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder() .setTitle("BackOps API") .setVersion("2.0") .addBearerAuth() // JWT 인증 UI 추가 .build();
const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup("api/docs", app, document); // /api/docs 에서 UI 접근
await app.listen(3000);}
// users.controller.ts — Swagger 데코레이터로 문서화@ApiTags("users")@Controller("users")export class UsersController { @Get(":id") @ApiOperation({ summary: "사용자 단건 조회" }) @ApiParam({ name: "id", description: "사용자 ID", example: 1 }) @ApiResponse({ status: 200, description: "조회 성공", type: UserResponseDto }) @ApiResponse({ status: 404, description: "사용자 없음" }) findOne(@Param("id") id: string) { return this.usersService.findOne(+id); }}# Swagger UI 접근http://localhost:3000/api/docs→ 브라우저에서 바로 API 테스트 가능→ DTO 스키마 자동 생성→ JWT 토큰 입력 후 인증된 요청 테스트 가능Rate Limiting — API 남용 방지와 공정한 자원 배분
Rate Limiting은 API 설계의 필수 요소다. 없으면 하나의 클라이언트가 서버 자원을 독점하거나 DDoS의 일부로 악용될 수 있다.
// NestJS + express-rate-limitimport { rateLimit } from "express-rate-limit";
// main.ts — 엔드포인트별 다른 Rate Limit 전략async function bootstrap() { const app = await NestFactory.create(AppModule);
// 전역: IP당 15분에 100건 app.use( rateLimit({ windowMs: 15 * 60 * 1000, // 15분 max: 100, message: { error: { code: "RATE_LIMIT_EXCEEDED", message: "잠시 후 다시 시도해주세요", }, }, standardHeaders: true, // RateLimit-* 헤더 포함 legacyHeaders: false, }), );
await app.listen(3000);}// 민감한 엔드포인트는 더 강하게 — 로그인 엔드포인트const loginRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, // 15분 max: 5, // 브루트포스 방어: 5번만 허용 skipSuccessfulRequests: true, // 성공 요청은 카운트 안 함});
@Controller('auth')export class AuthController { @Post('login') @UseGuards(loginRateLimit) // 또는 미들웨어로 적용 async login(@Body() dto: LoginDto) { ... }}Rate Limit 응답 헤더 (클라이언트가 알아야 할 정보):
HTTP/1.1 200 OKRateLimit-Limit: 100 ← 허용 한도RateLimit-Remaining: 87 ← 남은 횟수RateLimit-Reset: 1712345678 ← 리셋 시각 (Unix timestamp)
# 한도 초과 시:HTTP/1.1 429 Too Many RequestsRetry-After: 900 ← 900초(15분) 후 재시도📖 더 보기: Mastering RESTful APIs with NestJS — Startup House — NestJS REST API 설계 전체를 실무 기준으로 정리한 가이드 (입문)
4. 실무에서 어디에 쓰이나
섹션 제목: “4. 실무에서 어디에 쓰이나”- 백엔드 서비스 간 내부 API 호출
- 프론트엔드에서 데이터를 가져오는 API
- 외부 서비스 연동 (결제 API, 알림 API 등)
- API 문서 작성 (Swagger/OpenAPI)
5. 현재 내 업무와 연결점
섹션 제목: “5. 현재 내 업무와 연결점”- 팀 내부 API를 읽고 이해해야 할 때 설계 원칙을 알면 구조를 빠르게 파악 가능
- API 관련 이슈 디버깅 시 “요청이 잘못된 건지, 응답이 잘못된 건지” 판단 가능
- 새로운 기능 제안 시 API 구조를 함께 제안할 수 있음
6. 자주 헷갈리는 개념 비교
섹션 제목: “6. 자주 헷갈리는 개념 비교”| 개념 A | 개념 B | 차이점 |
|---|---|---|
| REST | GraphQL | REST는 엔드포인트별 고정 응답, GraphQL은 클라이언트가 필요한 데이터를 선택 |
| PUT | PATCH | PUT은 리소스 전체를 교체(누락 필드는 null), PATCH는 보낸 필드만 수정 |
| PUT | POST | POST는 새로 만들기, PUT은 기존 것을 덮어쓰기 |
| Path Parameter | Query Parameter | Path(/users/123)는 특정 리소스 식별, Query(?status=active)는 필터/옵션 |
| API | SDK | API는 통신 규약, SDK는 API를 쉽게 쓸 수 있게 만든 라이브러리 |
| Offset 페이지 | Cursor 페이지 | Offset은 구현 단순하지만 데이터 변동 시 불안정, Cursor는 안정적이나 복잡 |
REST vs GraphQL vs gRPC — 언제 무엇을 선택하는가
표면적 차이(“REST는 엔드포인트, GraphQL은 쿼리”)를 넘어, 팀 규모·클라이언트 다양성·성능 요구사항을 기준으로 선택한다.
| 기준 | REST | GraphQL | gRPC |
|---|---|---|---|
| 클라이언트 다양성 | 단일 또는 소수 (웹 위주) | 다수 (웹 + 모바일 + 써드파티) | 내부 서비스 간 (브라우저 직접 호출 어려움) |
| 데이터 페칭 패턴 | 엔드포인트별 고정 응답 (over-fetching 有) | 클라이언트가 필요한 필드만 선택 | Protobuf 직렬화, 스트리밍 지원 |
| 성능 요구사항 | 보통 (HTTP/1.1 캐싱 유리) | 보통~높음 (복잡 쿼리 시 오버헤드 발생) | 매우 높음 (HTTP/2, gRPC는 REST 대비 최대 10× 낮은 지연) |
| 팀 규모 / 생태계 | 소~중규모, 학습 비용 낮음 | 중~대규모, 스키마 관리 필요 | 대규모 마이크로서비스, IDL(proto) 계약 필수 |
| 캐싱 | CDN/HTTP 캐시 기본 지원 | POST 기반이라 HTTP 캐싱 어려움 | HTTP/2 기반이나 캐싱 설정 복잡 |
| 공개 API 적합성 | 가장 높음 (범용 호환) | 중간 (복잡도 ↑) | 낮음 (브라우저 직접 호환 제한) |
실무에서는 한 시스템 내에서도 복수 프로토콜을 함께 쓰는 것이 일반적이다. 예: 백엔드 서비스 간 gRPC, 클라이언트 BFF 레이어 GraphQL, 파트너 공개 API REST. 엔터프라이즈 AI 플랫폼의 60% 이상이 이런 하이브리드 구조를 채택하고 있다. (출처: Baeldung — REST vs. GraphQL vs. gRPC, Kong Engineering)
결정 사례 (표의 5개 축을 결제 도메인에 차례로 적용):
- 클라이언트 다양성 — 결제 코어는 내부 서비스만 호출, 모바일/웹은 BFF 경유 → 코어 후보 gRPC.
- 성능 요구사항 — 결제 승인 p99 50ms 목표 → HTTP/2 + Protobuf의 gRPC가 적합.
- 캐싱 — 결제 응답은 캐싱 불가 → REST의 HTTP 캐시 이점 무관.
- 공개 API 적합성 — 외부 파트너에 결제 API 공개 필요 → 별도 REST 게이트웨이를 BFF로 두고 내부에서 gRPC 호출.
- 팀 규모/생태계 — 코어 팀은 proto 계약 관리 가능, 파트너 통합 팀은 OpenAPI 우선 → 팀별로 다른 인터페이스 운영.
최종 구성: 코어 gRPC + 파트너 REST + 모바일 GraphQL의 3-protocol 하이브리드. 표의 어느 한 축으로 단독 결정하지 말고 5개 축을 모두 통과시켜 도메인 단위 답을 내는 것이 핵심이다. “성능이니까 gRPC” 같은 단축은 공개 API 호환성을 깨뜨린다.
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”🔧 400 Bad Request — 요청이 계속 거절됨
섹션 제목: “🔧 400 Bad Request — 요청이 계속 거절됨”증상: API를 호출하면 400 Bad Request가 오고, 어디가 잘못됐는지 에러 메시지가 불명확함
원인: 요청 바디의 필드명 오타, 필수 필드 누락, 데이터 타입 불일치. NestJS에서 DTO validation이 실패한 경우가 대부분
해결:
// DTO에 class-validator 적용import { IsEmail, IsString, IsNotEmpty } from "class-validator";
export class CreateUserDto { @IsString() @IsNotEmpty() name: string;
@IsEmail() email: string;}// NestJS ValidationPipe 적용 시 400 응답 예시{ "statusCode": 400, "message": ["email must be an email"], "error": "Bad Request"}main.ts에 app.useGlobalPipes(new ValidationPipe())가 없으면 validation이 동작하지 않는다.
🔧 405 Method Not Allowed — 엔드포인트는 있는데 메서드가 안 됨
섹션 제목: “🔧 405 Method Not Allowed — 엔드포인트는 있는데 메서드가 안 됨”증상: POST /users는 되는데 PUT /users/123이 405 응답
원인: 해당 HTTP 메서드를 처리하는 핸들러가 없음. 컨트롤러에 @Put(':id')가 없거나, 라우트 경로가 다름
해결:
// ❌ 누락된 경우@Controller('users')export class UsersController { @Post() create() { ... } // @Put(':id') 가 없음!}
// ✅ 추가@Put(':id')update(@Param('id') id: string, @Body() dto: UpdateUserDto) { return this.usersService.update(+id, dto);}🔧 응답 구조가 엔드포인트마다 달라서 프론트엔드와 충돌
섹션 제목: “🔧 응답 구조가 엔드포인트마다 달라서 프론트엔드와 충돌”증상: 어떤 API는 { data: ... }, 어떤 API는 바로 객체 반환. 프론트엔드가 파싱에 어려움
원인: 팀 내 API 응답 구조 합의가 없고, 개발자마다 다르게 구현
해결: NestJS Interceptor로 응답 구조를 전역으로 통일
@Injectable()export class ResponseTransformInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler) { return next.handle().pipe(map((data) => ({ data, success: true }))); }}
// main.tsapp.useGlobalInterceptors(new ResponseTransformInterceptor());🔧 PUT 요청 후 기존 데이터가 사라짐
섹션 제목: “🔧 PUT 요청 후 기존 데이터가 사라짐”증상: 사용자 이름만 수정하려고 PUT /users/1을 호출했는데, email 등 다른 필드가 null 또는 빈 값이 됨
원인: PUT은 전체 교체이므로 요청 바디에 포함되지 않은 필드가 덮어써짐. PATCH가 필요한 상황에 PUT을 사용한 것
해결: 일부 필드만 수정할 때는 PUT 대신 PATCH를 사용하고, DTO를 Partial<>로 처리
// PATCH 처리 — 보낸 필드만 업데이트@Patch(':id')update(@Param('id') id: string, @Body() dto: Partial<UpdateUserDto>) { return this.usersService.update(+id, dto);}
// 서비스 레이어에서 undefined 필드 제거 후 업데이트async update(id: number, dto: Partial<UpdateUserDto>) { // TypeORM의 save 또는 update 사용 await this.usersRepository.update(id, dto);}이미 손상된 데이터를 어떻게 복구하는가 — PUT으로 인한 null 덮어쓰기는 200 OK가 정상 반환되어 모니터링에서 자동 감지되지 않는다(silent failure). 사고 인지 후 명령 단위 복구 절차:
# 1. 손상 시각 식별 — 액세스 로그에서 해당 행에 닿은 PUT 요청 추출grep "PUT /users/1" /var/log/api/access.log | awk '$9 == 200'# 예상 출력: 2026-04-01T09:14:22Z PUT /users/1 200 ...
# 2. 별도 PITR 인스턴스에 손상 직전 시점 복구# postgresql.conf 와 recovery.signal 설정 후 재시작:# restore_command = 'cp /archive/%f %p'# recovery_target_time = '2026-04-01 09:14:21+09'pg_ctl restart -D $PGDATA_RECOVERED
# 3. 복구된 인스턴스에서 영향받은 행만 조회psql -d recovered_db -c "SELECT email, role FROM users WHERE id = 1;"# 예상 출력: [email protected] | user
# 4. 운영 DB에 UPDATE만 적용 (덮어쓰기 보호 조건 포함) WHERE id = 1 AND email IS NULL;"# email IS NULL 가드 — 그 사이 다른 사용자가 정상 PATCH 했을 가능성 보호WAL 아카이브가 없으면 PITR 자체가 불가능하다. 운영 DB는 PITR 또는 trigger 기반 audit table 중 하나는 반드시 사전에 설정해둬야 사고 후 복구가 가능하다. (출처: PostgreSQL PITR 공식 문서)
🔧 페이지네이션 결과가 불안정 — 같은 페이지를 요청했는데 데이터가 다름
섹션 제목: “🔧 페이지네이션 결과가 불안정 — 같은 페이지를 요청했는데 데이터가 다름”증상: GET /orders?page=2&limit=20을 두 번 호출했는데 결과가 다름. 또는 첫 페이지와 두 번째 페이지에 같은 항목이 중복 등장함
원인: Offset 기반 페이지네이션 사용 중 데이터가 추가/삭제됨. 예: 1페이지 조회 후 새 주문이 앞에 추가되면 기존 데이터가 한 칸씩 밀려 2페이지에서 중복이 발생함
해결: 실시간 데이터 환경에서는 Cursor 기반 페이지네이션으로 전환
// Cursor 기반으로 변경 — WHERE id > :cursor 방식은 데이터 삽입에 영향받지 않음const items = await this.ordersRepository.find({ where: { id: MoreThan(decodedCursor) }, take: limit, order: { id: "ASC" },});Cursor를 써도 발생하는 silent failure — created_at 같이 unique가 아닌 컬럼만으로 정렬·커서를 잡으면, 동일 timestamp 행들의 정렬이 매 쿼리마다 무작위로 바뀌어 페이지 간 중복/누락이 발생한다. 응답은 200 OK이므로 모니터링이 잡지 못한다.
-- ❌ 위험 — created_at만 사용. 동일 timestamp 충돌 시 silent dup/skipWHERE created_at > '2026-04-01 09:00:00' ORDER BY created_at LIMIT 20;
-- ✅ composite cursor — id를 tie-breaker로 추가 (튜플 비교)WHERE (created_at, id) > ('2026-04-01 09:00:00', 1234)ORDER BY created_at, id LIMIT 20;
CREATE INDEX idx_orders_created_id ON orders(created_at, id);# 사고 후 감지 — 받은 모든 페이지의 id를 합쳐 unique 카운트와 비교psql -c " SELECT COUNT(*) AS received, COUNT(DISTINCT id) AS unique_ids FROM order_log_received;"# received != unique_ids → 중복 발생# received < (전체 카운트 with same WHERE) → 누락 발생(출처: Cursor pagination tie-breaker — Milan Jovanović)
🔧 버전 관리 API에서 v1 클라이언트가 v2 응답을 받음
섹션 제목: “🔧 버전 관리 API에서 v1 클라이언트가 v2 응답을 받음”증상: GET /v1/users를 호출했는데 v2 형식({ data, meta }) 응답이 옴. 또는 GET /v2/users가 라우팅되지 않고 항상 v1이 응답함
원인: NestJS 버전 관리 설정 누락 또는 Controller 데코레이터에 version이 없음
해결:
// 1. main.ts에 버전 관리 활성화 여부 확인 — 이게 없으면 @Controller version이 무시됨app.enableVersioning({ type: VersioningType.URI });
// 2. Controller에 version 지정@Controller({ path: 'users', version: '1' }) // ← 반드시 명시export class UsersV1Controller { ... }
@Controller({ path: 'users', version: '2' })export class UsersV2Controller { ... }
// 3. 두 Controller가 같은 Module에 등록됐는지 확인@Module({ controllers: [UsersV1Controller, UsersV2Controller], // 둘 다 등록})export class UsersModule {}# 버전 라우팅 동작 확인curl http://localhost:3000/v1/users# → { "data": [...] } ← v1 형식
curl http://localhost:3000/v2/users# → { "data": [...], "meta": { "total": 10 } } ← v2 형식6.7. API 설계 원리의 전이 — 다른 도메인에서도 같은 원리가 쓰인다
섹션 제목: “6.7. API 설계 원리의 전이 — 다른 도메인에서도 같은 원리가 쓰인다”REST API에서 배운 핵심 원리는 HTTP 밖에서도 동일하게 적용된다. “API 설계 = HTTP 규약”이 아니라 “분산 시스템에서 데이터 교환 인터페이스를 예측 가능하게 만드는 원리”임을 이해하면 전이가 자연스럽다.
새 데이터 교환 인터페이스를 만났을 때 적용하는 4질문 (분석 공식)
이 4질문은 메시지 큐, RPC, 이벤트 스트림, 파일 기반 batch까지 동일하게 적용된다. REST를 모르는 동료도 같은 4질문이면 같은 답에 도달한다.
- 전달 보장은 무엇인가? (at-most-once / at-least-once / exactly-once) — at-least-once면 멱등성 키 또는 처리 ID 추적이 필수. REST의
Idempotency-Key헤더와 동형이다 (Stripe는 24시간 응답 캐시 — Stripe API Reference). - 상태는 어디에 있는가? (클라이언트 / 서버 / 둘 다) — 양쪽이면 동기화 비용이 발생한다. REST Stateless 원칙은 “서버는 상태 없음”으로 이 비용을 0으로 만드는 선택.
- 호환성이 깨질 때 누가 책임지는가? (생산자 / 소비자) — 답에 따라 versioning 전략이 결정된다. “생산자가 신버전 추가 + 구버전 일정 기간 유지” 패턴이 REST API Versioning, 이벤트 스키마 진화 모두 동일.
- 읽기/쓰기 비대칭이 큰가? — 읽기 압도면 캐싱 가능 인터페이스(REST/HTTP), 쓰기 많고 실시간이면 streaming(gRPC, WebSocket) 또는 큐.
적용 시나리오 — 결제 이벤트를 SQS로 다른 서비스에 전파한다고 하자:
- Q1 SQS 표준 큐는 at-least-once → consumer가 처리한 이벤트 ID를 DB에 저장하고 중복 시 폐기 (
Idempotency-Key캐시와 동형 구조). - Q2 결제 컨텍스트는 producer만 보유, consumer는 idempotency 테이블만 → 동기화 비용 최소.
- Q3 이벤트 스키마에
version필드를 두고OrderCreatedV1/V2동시 지원 (URL versioning과 같은 형태). - Q4 결제 이벤트 1건이 N개 서비스로 fan-out → SQS+SNS로 읽기 비대칭 흡수.
이렇게 4질문이 통과되면 매핑 표(아래)는 결과물의 검증용 체크리스트로 작동한다.
| REST/API 원리 | 다른 도메인에서의 적용 |
|---|---|
| 멱등성 (Idempotency) | 메시지 큐: SQS/Kafka 같은 at-least-once 큐에서 같은 메시지가 2번 전달될 수 있다. Consumer가 멱등하게 설계되면(처리된 ID 추적 + 중복 폐기) 실질적 exactly-once를 달성한다 (Idempotent Consumer 패턴). 이벤트 소싱: Projection이 이벤트를 재처리할 때 동일 이벤트 ID를 DB에서 확인하고 중복 적용을 방지한다(Deduplication 전략). |
| 리소스 중심 설계 (명사) | DB 스키마: 테이블명을 동사(getUserData)가 아닌 명사(users, orders)로 설계하는 관행이 같은 원리다. 이벤트 명명: 이벤트 소싱에서 이벤트명은 UserCreated, OrderShipped 같이 “리소스 + 과거형 동사”로 짓는다 — URL의 POST /users 구조와 대칭된다. |
| Stateless 원칙 | 마이크로서비스: 각 서비스가 다른 서비스의 상태를 들고 있지 않아야 수평 확장이 쉽다 — REST의 Stateless와 같은 이유다. 서버리스(Lambda/Cloud Functions): 함수가 요청 간 메모리를 공유하지 않는 구조가 REST Stateless의 구현체다. |
| 버전 관리 | 이벤트 스키마 진화: 이벤트 소싱에서 OrderCreatedV2 같이 이벤트 버전을 관리하고, 구버전 Consumer도 하위 호환되도록 유지하는 전략이 API Versioning과 동일 문제를 푼다. |
핵심 통찰: “멱등성 키”(Idempotency-Key 헤더)가 API에 있는 이유는 네트워크가 신뢰할 수 없기 때문이다. 메시지 큐도 같은 이유로 at-least-once를 기본으로 하며, Idempotent Consumer 패턴으로 해결한다. 원리는 하나, 적용 레이어만 다르다. (출처: microservices.io — Idempotent Consumer, CockroachLabs — Idempotency and ordering in event-driven systems)
7. 체크리스트
섹션 제목: “7. 체크리스트”- REST API의 URL 설계 원칙을 설명할 수 있다
- HTTP 메서드와 CRUD의 매핑을 설명할 수 있다
- PUT과 PATCH의 차이를 설명할 수 있다
- 좋은 API 응답 형식의 조건을 3개 이상 말할 수 있다
- 팀 서비스의 API 하나를 읽고 구조를 설명할 수 있다
- Offset 페이지네이션과 Cursor 페이지네이션의 차이를 설명할 수 있다
8. 추가 학습 키워드
섹션 제목: “8. 추가 학습 키워드”OpenAPI/Swagger, GraphQL, gRPC, API Gateway, Rate Limiting, API Key vs OAuth, HATEOAS, ETag, Cursor Pagination
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 RESTful API Design — Best Practices (Philipp Hauer) — URL 설계, 상태 코드, 에러 포맷까지 실무 기준으로 정리된 가이드 (입문)
- 📖 Build a CRUD REST API with NestJS, Swagger, Prisma — freeCodeCamp — NestJS로 실제 REST API를 만드는 전체 실습 (입문)
- 📖 Common Mistakes in RESTful API Design — Zuplo — 2025년 기준 REST API 설계에서 가장 흔한 실수와 해결법 (입문)
- 📖 REST API Error Handling Best Practices — Baeldung — 에러 응답 구조를 어떻게 설계해야 하는지 상세 설명 (중급)
- 📖 OpenAPI/Swagger 공식 문서 — API 문서 자동화의 기준인 OpenAPI 스펙 공식 가이드 (중급)
📚 표준 스펙 (공식)
섹션 제목: “📚 표준 스펙 (공식)”- 📖 RFC 5789 — PATCH Method for HTTP — PATCH 메서드의 공식 정의 및 멱등성 요건 (IETF)
- 📖 RFC 7807 — Problem Details for HTTP APIs — 에러 응답 포맷 표준 (
type,title,status,detail필드) (IETF) - 📖 RFC 8594 — The Sunset HTTP Header Field — API Deprecation 고지를 위한 Sunset 헤더 공식 스펙 (IETF)
- 📖 Zalando RESTful API Guidelines — Deprecation — 실무에서 쓰이는 Deprecation 기간 가이드라인 (공개 API 최소 6개월, 내부 API 최소 2주 권장)
9. 내가 직접 확인해볼 것
섹션 제목: “9. 내가 직접 확인해볼 것”- 팀 서비스의 API 엔드포인트 목록 확인 → REST 원칙에 맞는지 살펴보기
# curl로 팀 API 테스트해보기# GET 요청curl -X GET https://api.example.com/v1/users \ -H "Authorization: Bearer <token>"
# 예상 출력:# HTTP/1.1 200 OK# { "data": [...], "total": 10 }
# PATCH 요청 (부분 수정)curl -X PATCH https://api.example.com/v1/users/1 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"name": "변경된 이름"}'
# 예상 출력:# HTTP/1.1 200 OK# { "data": { "id": 1, "name": "변경된 이름", "email": "[email protected]" } }# Cursor 페이지네이션 API 테스트# 첫 페이지curl "https://api.example.com/v1/orders?limit=5"# 예상 출력:# { "data": [...5건...], "nextCursor": "eyJpZCI6NX0=" }
# 다음 페이지 (cursor 파라미터 사용)curl "https://api.example.com/v1/orders?limit=5&cursor=eyJpZCI6NX0="# 예상 출력:# { "data": [...5건...], "nextCursor": null } ← 마지막 페이지- Swagger/OpenAPI 문서가 있다면 열어보고 구조 파악
- 외부 API(GitHub API 등) 문서를 읽고 설계 패턴 관찰
10. 요약 — 이것만 기억해도 된다
섹션 제목: “10. 요약 — 이것만 기억해도 된다”빠른 판단 기준
섹션 제목: “빠른 판단 기준”| 상황 | 선택 |
|---|---|
| 리소스 전체 교체 | PUT (모든 필드 필수 포함) |
| 특정 필드만 수정 | PATCH (빠진 필드는 유지됨) |
| 새 리소스 생성 | POST → 201 Created 반환 |
| 데이터가 많아서 느림 | 페이지네이션 — 실시간 피드면 Cursor 방식 |
| API 에러 메시지가 제각각 | 글로벌 ExceptionFilter로 형식 통일 |
| API 바꿔야 하는데 기존 유지 | URL 버전관리 /v2/ 추가 |
5줄 핵심
섹션 제목: “5줄 핵심”- API는 서비스 간 데이터를 주고받는 인터페이스이다
- REST는 HTTP 메서드 + URL(리소스)로 CRUD를 표현하는 가장 보편적 스타일이다
- PUT은 전체 교체, PATCH는 부분 수정 — 실수로 PUT을 쓰면 기존 데이터가 날아간다
- URL은 명사 중심, 응답은 일관된 JSON 형식, 버전 관리는 팀 내 전략을 통일해야 한다
- 좋은 API를 판별하는 눈이 있으면 코드 리뷰와 디버깅이 빨라진다