콘텐츠로 이동

API Design Basics

분류: Layer 1 - 백엔드 기초 | 선수지식: HTTP Basics

API(Application Programming Interface) 설계는 서비스 간 또는 클라이언트-서버 간 데이터를 주고받는 인터페이스를 일관되고 예측 가능하게 만드는 것이다.

API는 서비스의 “문”이다. 문이 체계적이지 않으면 쓰는 사람이 매번 헷갈리고, 유지보수가 어렵다. BackOps에서 내부 API를 리뷰하거나, 외부 서비스와 연동할 때 API 설계 원칙을 알아야 “이 API가 잘 만들어진 건지, 뭐가 문제인지” 판단할 수 있다.

REST(Representational State Transfer) — 왜 이렇게 설계하는가

비유하자면, REST는 “전 세계가 동의한 도서관 규칙”이다. 책(리소스)을 찾을 때 /books/123이라는 주소로 가고, 빌릴 때는 POST, 반납은 DELETE. 어느 도서관(서버)에 가도 같은 규칙이니 새로 배울 것이 없다.

REST가 HTTP 메서드와 URL을 조합하는 이유: HTTP는 이미 전 세계가 쓰는 프로토콜이고, 메서드(GET/POST/PUT/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": [
{ "id": 1, "name": "홍길동", "email": "hong@example.com" },
{ "id": 2, "name": "김철수", "email": "kim@example.com" }
],
"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": "hong@example.com", "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": "hong@example.com", "role": "user" }
// ✅ name만 바뀌고 나머지는 유지됨

📖 더 보기: PATCH vs PUT in REST APIs — ApyHub — 멱등성 개념과 함께 두 메서드의 차이를 실제 예시로 설명 (입문)

버전 관리

API가 바뀌면 기존 클라이언트가 깨질 수 있다. URL에 버전을 넣어서 관리하는 것이 가장 보편적이다.

/v1/users ← 기존 클라이언트가 계속 사용
/v2/users ← 새 기능이 추가된 버전 (Breaking Change 포함)

버전 관리 전략 4가지:

  1. URL Path: /v1/users — 가장 직관적이고 가장 많이 쓰임 (Facebook, GitHub 등)
  2. Query Parameter: /users?version=1 — 구현은 쉽지만 캐싱에 불리
  3. Header: Accept: application/vnd.myapi.v1+json — URL 깔끔하지만 눈에 안 보임
  4. 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/users
export class UsersV1Controller {
@Get()
findAll() {
return { data: [{ id: 1, name: "홍길동" }] }; // v1 응답 형식
}
}
@Controller({ path: "users", version: "2" }) // GET /v2/users
export 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 이하, 응답 < 50msOFFSET 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)

필드 필터링 — 응답 크기 줄이기

클라이언트가 필요한 필드만 요청하면 응답 크기를 크게 줄일 수 있다. 특히 풍부한 사용자 프로필이나 제품 카탈로그에서 70% 이상 크기 절감이 가능하다.

GET /users?fields=name,email
→ { "name": "홍길동", "email": "hong@example.com" } // 불필요한 필드 제거

좋은 API 응답의 조건

  1. 일관된 구조: 성공/실패 모두 같은 JSON 형식
  2. 명확한 HTTP 상태 코드: 200(성공), 201(생성), 400(잘못된 요청), 404(없음), 500(서버 오류)
  3. 의미 있는 에러 메시지: 코드와 설명을 함께
  4. 불필요한 데이터 미포함: 클라이언트가 필요한 필드만 포함
  5. 일관된 날짜 형식: 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 /orders
Idempotency-Key: order-abc-123
{ "product_id": 1, "quantity": 2 }
# 첫 번째 요청: 주문 생성 → 201 Created
# 두 번째 요청 (같은 키): Redis에서 이전 결과 반환 → 201 Created (중복 생성 없음)

📖 더 보기: 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 OK
Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: </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-limit
import { 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 OK
RateLimit-Limit: 100 ← 허용 한도
RateLimit-Remaining: 87 ← 남은 횟수
RateLimit-Reset: 1712345678 ← 리셋 시각 (Unix timestamp)
# 한도 초과 시:
HTTP/1.1 429 Too Many Requests
Retry-After: 900 ← 900초(15분) 후 재시도

📖 더 보기: Mastering RESTful APIs with NestJS — Startup House — NestJS REST API 설계 전체를 실무 기준으로 정리한 가이드 (입문)


  • 백엔드 서비스 간 내부 API 호출
  • 프론트엔드에서 데이터를 가져오는 API
  • 외부 서비스 연동 (결제 API, 알림 API 등)
  • API 문서 작성 (Swagger/OpenAPI)
  • 팀 내부 API를 읽고 이해해야 할 때 설계 원칙을 알면 구조를 빠르게 파악 가능
  • API 관련 이슈 디버깅 시 “요청이 잘못된 건지, 응답이 잘못된 건지” 판단 가능
  • 새로운 기능 제안 시 API 구조를 함께 제안할 수 있음
개념 A개념 B차이점
RESTGraphQLREST는 엔드포인트별 고정 응답, GraphQL은 클라이언트가 필요한 데이터를 선택
PUTPATCHPUT은 리소스 전체를 교체(누락 필드는 null), PATCH는 보낸 필드만 수정
PUTPOSTPOST는 새로 만들기, PUT은 기존 것을 덮어쓰기
Path ParameterQuery ParameterPath(/users/123)는 특정 리소스 식별, Query(?status=active)는 필터/옵션
APISDKAPI는 통신 규약, SDK는 API를 쉽게 쓸 수 있게 만든 라이브러리
Offset 페이지Cursor 페이지Offset은 구현 단순하지만 데이터 변동 시 불안정, Cursor는 안정적이나 복잡

REST vs GraphQL vs gRPC — 언제 무엇을 선택하는가

표면적 차이(“REST는 엔드포인트, GraphQL은 쿼리”)를 넘어, 팀 규모·클라이언트 다양성·성능 요구사항을 기준으로 선택한다.

기준RESTGraphQLgRPC
클라이언트 다양성단일 또는 소수 (웹 위주)다수 (웹 + 모바일 + 써드파티)내부 서비스 간 (브라우저 직접 호출 어려움)
데이터 페칭 패턴엔드포인트별 고정 응답 (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)

🔧 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.tsapp.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로 응답 구조를 전역으로 통일

response-transform.interceptor.ts
@Injectable()
export class ResponseTransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(map((data) => ({ data, success: true })));
}
}
// main.ts
app.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);
}

🔧 페이지네이션 결과가 불안정 — 같은 페이지를 요청했는데 데이터가 다름

섹션 제목: “🔧 페이지네이션 결과가 불안정 — 같은 페이지를 요청했는데 데이터가 다름”

증상: 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" },
});

🔧 버전 관리 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 {}
Terminal window
# 버전 라우팅 동작 확인
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 규약”이 아니라 “분산 시스템에서 데이터 교환 인터페이스를 예측 가능하게 만드는 원리”임을 이해하면 전이가 자연스럽다.

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)

  • REST API의 URL 설계 원칙을 설명할 수 있다
  • HTTP 메서드와 CRUD의 매핑을 설명할 수 있다
  • PUT과 PATCH의 차이를 설명할 수 있다
  • 좋은 API 응답 형식의 조건을 3개 이상 말할 수 있다
  • 팀 서비스의 API 하나를 읽고 구조를 설명할 수 있다
  • Offset 페이지네이션과 Cursor 페이지네이션의 차이를 설명할 수 있다

OpenAPI/Swagger, GraphQL, gRPC, API Gateway, Rate Limiting, API Key vs OAuth, HATEOAS, ETag, Cursor Pagination

  • 팀 서비스의 API 엔드포인트 목록 확인 → REST 원칙에 맞는지 살펴보기
Terminal window
# 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": "hong@example.com" } }
Terminal window
# 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/ 추가
  1. API는 서비스 간 데이터를 주고받는 인터페이스이다
  2. REST는 HTTP 메서드 + URL(리소스)로 CRUD를 표현하는 가장 보편적 스타일이다
  3. PUT은 전체 교체, PATCH는 부분 수정 — 실수로 PUT을 쓰면 기존 데이터가 날아간다
  4. URL은 명사 중심, 응답은 일관된 JSON 형식, 버전 관리는 팀 내 전략을 통일해야 한다
  5. 좋은 API를 판별하는 눈이 있으면 코드 리뷰와 디버깅이 빨라진다