
REST API 설계: 내 API를 3개월 후의 내가 이해할 수 있을까?
3개월 전에 만든 내 API를 보고 이게 뭔지 몰랐다. 일관된 REST API를 설계하기 위한 네이밍, 버전 관리, 페이지네이션 패턴을 정리했다.

3개월 전에 만든 내 API를 보고 이게 뭔지 몰랐다. 일관된 REST API를 설계하기 위한 네이밍, 버전 관리, 페이지네이션 패턴을 정리했다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

프링글스 통(Stack)과 맛집 대기 줄(Queue). 가장 기초적인 자료구조지만, 이걸 모르면 재귀 함수도 메시지 큐도 이해할 수 없습니다.

페이스북은 왜 REST API를 버렸을까? 원하는 데이터만 쏙쏙 골라 담는 GraphQL의 매력과 치명적인 단점 (캐싱, N+1 문제) 분석.

단순히 해시(Hash)만 하면 1초 만에 뚫립니다. 레인보우 테이블 공격을 막기 위해 소금(Salt)과 후추(Pepper)를 치는 원리.

사이드 프로젝트를 오랜만에 다시 열었다. 새 기능을 붙이려고 API 엔드포인트 목록을 펼쳤다. 그리고 멈췄다.
GET /getData
POST /process
GET /fetchUserInfo
POST /doUpdate
PUT /handleRequest
뭔가. 이게 다 뭔가. /getData가 무슨 데이터를 주는 건지, /process가 뭘 처리하는 건지, /handleRequest가 어떤 요청을 다루는 건지. 코드를 열어서 하나하나 확인해야 했다. 내가 만든 API인데, 내가 모른다.
3개월이면 충분히 까먹는다. 아니, 솔직히 말하면 만들 때부터 이미 미래의 나를 전혀 배려하지 않았다. "일단 돌아가면 됐지"로 짰고, 그 결과가 저 목록이었다.
그날 이후로 API를 짤 때마다 한 가지를 생각하게 됐다. 3개월 후의 내가 이 엔드포인트 목록만 보고 무슨 일을 하는지 알 수 있는가? 그게 기준이 됐다.
REST API에서 가장 먼저 와닿은 원칙은 이거였다. 엔드포인트는 행위가 아니라 자원(Resource)을 가리켜야 한다.
내가 처음에 저지른 실수를 보면 패턴이 있다. /getData, /process, /doUpdate—전부 동사다. API가 뭔가를 "한다"는 걸 이름에 넣으려고 했다. 그런데 그건 HTTP 메서드가 이미 하는 일이다. GET이 "가져온다"고, POST가 "만든다"고, DELETE가 "지운다"고 말해준다. 엔드포인트 이름이 또 동사를 얹을 필요가 없다.
레스토랑 메뉴로 비유하면 이렇다. 메뉴판에 "비빔밥을 주문한다"고 쓰인 항목은 없다. "비빔밥"이라고 적혀 있을 뿐이다. 주문한다는 행위는 웨이터에게 말하는 것(HTTP 메서드)으로 표현된다. API도 마찬가지다.
| 나쁜 예 | 좋은 예 | 이유 |
|---|---|---|
/getUsers | /users | GET 메서드가 "가져온다"고 이미 말함 |
/createPost | /posts | POST 메서드가 "만든다"고 이미 말함 |
/deleteComment | /comments/{id} | DELETE 메서드가 "지운다"고 이미 말함 |
/updateProfile | /users/{id}/profile | PATCH 메서드가 "수정한다"고 이미 말함 |
명사는 복수형으로 쓰는 게 관례다. /user보다 /users가 낫다. "사용자"라는 컬렉션을 다룬다는 의미가 명확해지고, 개별 리소스와 컬렉션을 URL 구조로 자연스럽게 구분할 수 있다.
GET /users → 사용자 목록 조회
POST /users → 사용자 생성
GET /users/{id} → 특정 사용자 조회
PATCH /users/{id} → 특정 사용자 수정
DELETE /users/{id} → 특정 사용자 삭제
이 다섯 가지가 거의 모든 API의 90%를 커버한다. 리소스 이름만 바뀐다. users 자리에 posts, orders, products를 넣어도 패턴은 같다.
네이밍 다음으로 헷갈렸던 게 이거다. POST, PUT, PATCH를 언제 써야 하는지. "수정하면 PUT 아닌가?"로만 알고 있었는데, 실제로 쓰다 보니 그게 전부가 아니었다.
정리해보면 이렇다.
POST: 새 리소스를 만들 때. 서버가 ID를 생성한다. POST /posts를 하면 서버가 새 게시글 ID를 정해서 만들어준다.
PUT: 리소스 전체를 교체할 때. 기존 데이터를 통째로 덮어쓴다. 빠진 필드는 null이 되거나 초기값으로 돌아간다. PUT /users/123에 이름만 보내면 나머지 정보가 다 사라질 수 있다.
PATCH: 리소스의 일부만 수정할 때. 보낸 필드만 업데이트한다. 실무에서는 대부분 PATCH가 맞다.
비유하자면 PUT은 이사(引越し)고 PATCH는 인테리어 리모델링이다. 이사는 모든 짐을 새로 정렬한다. 방에 있던 물건이 이삿짐 목록에 없으면 버려진다. 리모델링은 벽지만 바꾸거나 주방만 고친다. 나머지는 그대로 둔다. 사용자 프로필에서 이메일 하나 바꾸는데 PUT을 쓰면 이사 수준의 작업이 돌아가는 거다.
API에서 가장 신경 안 쓰기 쉬운 부분이 에러 응답이다. 성공 케이스를 먼저 만들고, 에러는 나중에 붙이다 보면 형식이 제각각이 된다.
처음엔 이런 식이었다.
{ "error": "not found" }
{ "message": "권한이 없습니다" }
{ "status": "fail", "reason": "invalid input" }
세 개 전부 에러인데 형태가 다르다. 이걸 클라이언트에서 처리하려면 케이스마다 다른 코드가 필요해진다. 결국 이런 구조로 통일했다.
{
"success": false,
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "요청한 게시글을 찾을 수 없습니다.",
"details": {
"field": "postId",
"value": "99999"
}
}
}
그리고 HTTP 상태 코드도 제대로 쓰는 게 중요했다. 에러가 발생했는데 200을 반환하면서 body에 에러 내용을 넣는 건 클라이언트를 혼란스럽게 한다.
| 상태 코드 | 언제 쓰나 |
|---|---|
| 200 OK | 성공 |
| 201 Created | 리소스 생성 성공 (POST 응답) |
| 400 Bad Request | 클라이언트 잘못 (입력값 오류) |
| 401 Unauthorized | 인증 안 됨 (로그인 필요) |
| 403 Forbidden | 인가 안 됨 (권한 없음) |
| 404 Not Found | 리소스 없음 |
| 409 Conflict | 이미 존재 (중복 이메일 등) |
| 422 Unprocessable Entity | 형식은 맞는데 처리 불가 |
| 500 Internal Server Error | 서버 잘못 |
401과 403을 헷갈리기 쉬운데, 401은 "누구세요?", 403은 "알긴 아는데 안 돼요"다. 로그인 안 한 상태면 401, 로그인했는데 다른 사람 게시글 삭제하려 하면 403이다.
Next.js App Router에서 에러 응답을 통일하면 이렇게 된다.
// src/lib/api-error.ts
export class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: Record<string, unknown>
) {
super(message);
}
}
export function errorResponse(error: ApiError) {
return Response.json(
{
success: false,
error: {
code: error.code,
message: error.message,
...(error.details && { details: error.details }),
},
},
{ status: error.statusCode }
);
}
// src/app/api/posts/[id]/route.ts
import { ApiError, errorResponse } from '@/lib/api-error';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const post = await db.posts.findUnique({ where: { id: params.id } });
if (!post) {
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '게시글을 찾을 수 없습니다.', {
field: 'id',
value: params.id,
});
}
return Response.json({ success: true, data: post });
} catch (error) {
if (error instanceof ApiError) {
return errorResponse(error);
}
return errorResponse(
new ApiError(500, 'INTERNAL_ERROR', '서버 오류가 발생했습니다.')
);
}
}
이렇게 하면 어느 엔드포인트든 에러 형태가 같아진다. 클라이언트는 success 필드 하나만 확인하면 된다.
데이터 목록을 반환할 때 전부 다 주면 안 된다. 게시글이 10만 개면 한 번에 10만 개를 보낼 수는 없다. 페이지네이션이 필요한데, 여기서 두 가지 방식이 나온다.
Offset 기반: "몇 번째부터 몇 개"로 지정한다. ?page=2&limit=20 같은 형태.
Cursor 기반: "이 ID 다음부터 몇 개"로 지정한다. ?cursor=abc123&limit=20 같은 형태.
처음엔 Offset이 직관적이라 좋았다. 페이지 번호를 URL에 보여주기도 쉽다. 그런데 문제가 있다.
페이지 2를 보는 사이에 새 게시글이 올라오면? 기존 데이터가 한 칸씩 밀린다. 이미 본 게시글이 다시 나오거나, 게시글 하나가 통째로 빠질 수 있다. 데이터가 자주 변하는 피드나 소셜 기능에는 맞지 않는다.
Cursor 기반은 "마지막으로 본 아이템 이후"를 기준으로 삼기 때문에 중간에 데이터가 추가돼도 안전하다. 인스타그램 피드를 스크롤하다가 중간에 새 게시물이 올라와도 이미 본 피드가 흔들리지 않는 게 이 덕분이다.
// Offset 기반 응답
{
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 340,
"totalPages": 17
}
}
// Cursor 기반 응답
{
"data": [...],
"pagination": {
"nextCursor": "eyJpZCI6MTIzfQ==", // base64 인코딩된 커서
"hasNextPage": true,
"limit": 20
}
}
어떤 걸 써야 할지는 상황에 따라 다르다. 단순한 관리 페이지나 페이지 번호가 URL에 보여야 하는 경우엔 Offset이 맞다. 무한 스크롤, 실시간성이 있는 피드, 데이터가 자주 변하는 목록은 Cursor가 맞다.
"버전 관리를 해야 하나요?" 처음엔 이 질문 자체가 좀 거창하게 느껴졌다. 큰 회사나 하는 거 아닌가 싶었다.
그런데 생각해보면 API를 한 번 배포하면 그걸 쓰는 클라이언트가 생긴다. 모바일 앱이면 사용자들이 업데이트 안 한 버전을 계속 쓴다. 어느 날 응답 구조를 바꾸면 구버전 앱이 다 터진다.
그때부터 버전 관리의 의미가 와닿았다.
가장 흔한 방법은 URL에 버전을 넣는 것이다.
GET /api/v1/users
GET /api/v2/users
헤더로 버전을 지정하는 방법도 있다.
GET /api/users
Accept: application/vnd.myapp.v2+json
URL 방식이 훨씬 직관적이다. 브라우저에서 바로 테스트할 수 있고, 로그 보기도 쉽다. 헤더 방식은 "REST 순수주의"에 더 가까운데, 실용성은 URL이 낫다는 게 내 판단이다. 에디션 번호가 붙은 책과 같다. 1판, 2판이 나란히 서점에 있으면 독자가 알아서 원하는 걸 고른다. 기존 독자는 1판을 계속 읽고, 새 독자는 2판을 읽는다.
버전을 올려야 하는 시점은 이렇다.
userName → username)반면 이런 건 버전을 올리지 않아도 된다.
버전 관리를 처음부터 완벽하게 할 필요는 없다. 내부 API나 혼자 쓰는 프로젝트라면 /v1만 있어도 충분하다. 외부 클라이언트가 생기는 순간부터 진지하게 고민하면 된다.
API 만들고 나서 문서 쓰려고 하면 절대 안 쓰게 된다. 나만 쓰면 되니까, 나중에 쓰면 되니까. 그러다 3개월이 지나면 아까 그 상황이 반복된다.
지금은 코드에 JSDoc 형태로 붙이거나, Next.js 프로젝트면 swagger-jsdoc과 swagger-ui-express를 붙여서 OpenAPI 문서를 자동으로 만드는 편이다. 코드와 문서가 같이 있으면 최소한 업데이트를 까먹는 상황은 줄어든다.
간단한 형태라도 엔드포인트 옆에 주석 하나씩이면 3개월 후의 내가 고마워한다.
/**
* GET /api/v1/posts
* 게시글 목록을 cursor 기반으로 반환합니다.
*
* Query params:
* - cursor: string (선택) - 마지막으로 받은 게시글 ID
* - limit: number (선택, 기본값 20, 최대 100)
* - category: string (선택) - 카테고리 필터
*
* Response:
* 200: { data: Post[], pagination: { nextCursor, hasNextPage, limit } }
* 400: 유효하지 않은 쿼리 파라미터
*/
export async function GET(request: Request) {
// ...
}
주석이 거창할 필요는 없다. 파라미터가 뭔지, 뭘 반환하는지, 어떤 에러가 날 수 있는지. 이 세 가지만 있어도 충분하다.
처음 API를 설계할 때 "일단 돌아가면 됐지"라고 생각했다. 그게 틀렸다는 걸 3개월 후에 알았다.
지금 지키려는 원칙들을 요약하면 이렇다.
v1만으로 충분하다.API는 나와 협업하는 미래의 나를 위한 인터페이스다. 지금 조금 더 공을 들이면, 3개월 후가 훨씬 수월해진다.