본문 바로가기
개발 팁

REST API 설계 원칙 — 네이밍부터 버전 관리까지

by 루까(Luka) 2026. 3. 19.
반응형

왜 이 글을 쓰나

팀 프로젝트를 하다 보면 API 설계 스타일이 제각각인 경우가 많다. 누군가는 /getUser를 쓰고, 누군가는 /user/fetch를 쓴다. 어떤 엔드포인트는 모든 응답을 200으로 내려보내고, 어떤 엔드포인트는 에러를 어떤 포맷으로 내려줄지 정해진 게 없다. 프론트엔드 개발자가 API 문서 없이 응답 구조를 유추해야 하는 상황이 생긴다.

REST API 설계에 정답은 없지만, 업계에서 통용되는 관례와 원칙이 있다. 이걸 팀이 공유하면 커뮤니케이션 비용이 줄고 코드 리뷰가 훨씬 편해진다.

이 글은 URL 네이밍부터 HTTP 메서드 올바른 사용, 상태코드 실전, 응답 구조 설계, 버전 관리, 쿼리 파라미터까지 REST API 설계에서 반복적으로 맞닥뜨리는 주제들을 정리한다.


URL 네이밍 규칙

REST에서 URL은 리소스를 나타낸다. 행위가 아니라 명사로 표현하는 게 원칙이다.

잘못된 예 vs 올바른 예

잘못된 URL 올바른 URL 이유
/getUsers /users 동사 불필요. GET 메서드가 이미 행위를 나타냄
/createUser /users POST 메서드가 생성을 의미
/deleteUser/1 /users/1 DELETE 메서드가 삭제를 의미
/user (단수) /users (복수) 컬렉션은 복수형이 관례
/Users /users 소문자 사용
/user_list /users 언더스코어 대신 하이픈 또는 그냥 복수형
/api/getUserPosts/1 /api/users/1/posts 계층 구조로 중첩 표현

중첩 리소스

소유 관계가 명확한 리소스는 계층으로 표현한다.

GET  /users/:id/posts          # 특정 사용자의 게시글 목록
GET  /users/:id/posts/:postId  # 특정 사용자의 특정 게시글
POST /users/:id/posts          # 특정 사용자의 게시글 작성

중첩은 2단계까지만 하는 게 실용적이다. 3단계 이상 깊어지면 URL이 너무 길어지고 의미도 불명확해진다. 예를 들어 /users/1/posts/5/comments/3/likes/likes/3 또는 /comments/3/likes로 분리하는 편이 낫다.


HTTP 메서드 올바른 사용

각 메서드의 역할

메서드 역할 요청 바디 응답 멱등성 안전성
GET 리소스 조회 없음 200 + 데이터 O O
POST 리소스 생성 있음 201 + 생성 데이터 X X
PUT 리소스 전체 교체 있음 200 or 204 O X
PATCH 리소스 부분 수정 있음 200 or 204 조건부 X
DELETE 리소스 삭제 없음 204 O X

멱등성(Idempotent): 같은 요청을 여러 번 해도 결과가 동일하다. GET, PUT, DELETE는 멱등하다. POST는 멱등하지 않다. 같은 POST 요청을 두 번 보내면 리소스가 두 개 생길 수 있다.

안전성(Safe): 서버 상태를 변경하지 않는다. GET만 안전하다.

PUT vs PATCH

이 둘을 혼용해서 쓰는 케이스가 많다.

// 현재 사용자 데이터
{
  "id": 1,
  "name": "김철수",
  "email": "chulsoo@example.com",
  "age": 30
}
// PUT: 전체 교체. 보내지 않은 필드는 null 또는 기본값이 됨
PUT /users/1
{
  "name": "김영희",
  "email": "yh@example.com",
  "age": 25
}

// PATCH: 보낸 필드만 변경
PATCH /users/1
{
  "name": "김영희"
}

PATCH로 이름만 바꾸면 나머지 필드는 그대로 유지된다. PUT으로 이름만 바꾸면 보내지 않은 email, age가 null이 되거나 에러가 발생할 수 있다.

실무에서는 부분 수정에는 PATCH를, 전체 교체에는 PUT을 쓰는 게 맞다. 단, PUT도 보내지 않은 필드를 null로 처리하지 않고 그대로 유지하는 구현도 있다. 팀에서 동작 방식을 명확히 정해두는 게 중요하다.


HTTP 상태 코드 실전

자주 쓰는 코드 총정리

코드 의미 사용 시점
200 OK 성공 GET, PUT, PATCH 성공
201 Created 생성 성공 POST로 리소스 생성 완료
204 No Content 성공, 응답 바디 없음 DELETE 성공, 수정 후 데이터 불필요 시
400 Bad Request 잘못된 요청 필수 파라미터 누락, 형식 오류
401 Unauthorized 인증 필요 로그인 안 됨, 토큰 없음/만료
403 Forbidden 권한 없음 로그인은 됐지만 접근 권한 없음
404 Not Found 리소스 없음 해당 ID의 데이터가 없음
409 Conflict 충돌 이미 존재하는 이메일로 가입 시도
422 Unprocessable Entity 처리 불가 형식은 맞지만 비즈니스 로직 검증 실패
429 Too Many Requests 요청 초과 Rate limit 초과
500 Internal Server Error 서버 오류 예상치 못한 서버 에러

잘못 쓰는 사례

가장 흔한 실수는 모든 에러를 200으로 내려보내는 것이다.

// 잘못된 패턴: 상태 코드는 200, 에러 정보는 바디에
HTTP 200 OK
{
  "success": false,
  "error": "User not found"
}

이렇게 하면 프론트엔드에서 HTTP 상태 코드로 분기 처리가 불가능하다. Axios 인터셉터나 fetch 래퍼에서 에러를 일괄 처리하려면 바디를 매번 파싱해야 한다. 로그 모니터링 툴에서도 5xx, 4xx 에러가 잡히지 않는다.

// 올바른 패턴: 상태 코드로 에러 유형을 표현
HTTP 404 Not Found
{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "해당 사용자를 찾을 수 없다"
  }
}

401과 403도 자주 혼용한다. 401은 인증(Authentication) 문제, 403은 인가(Authorization) 문제다. 로그인하지 않은 상태에서 접근하면 401, 로그인은 됐지만 관리자 페이지에 일반 유저가 접근하면 403이다.


응답 구조 설계

일관된 응답 포맷을 정해두면 프론트엔드 개발이 훨씬 편해진다.

성공 응답 포맷

// 단일 리소스
{
  "data": {
    "id": 1,
    "name": "김철수",
    "email": "chulsoo@example.com"
  }
}

// 컬렉션 (페이지네이션 포함)
{
  "data": [
    { "id": 1, "name": "김철수" },
    { "id": 2, "name": "이영희" }
  ],
  "meta": {
    "total": 100,
    "page": 1,
    "perPage": 20,
    "lastPage": 5
  },
  "pagination": {
    "prev": null,
    "next": "/api/v1/users?page=2"
  }
}

에러 응답 포맷

// 단순 에러
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "입력값이 올바르지 않다",
    "details": [
      { "field": "email", "message": "이메일 형식이 아니다" },
      { "field": "password", "message": "8자 이상 입력해야 한다" }
    ]
  }
}

error.code는 클라이언트가 에러 유형을 프로그래밍적으로 처리할 수 있도록 고정된 문자열 키를 쓴다. error.message는 사람이 읽을 수 있는 설명이다. details는 폼 검증처럼 여러 필드의 에러를 한꺼번에 내려줄 때 사용한다.

Node.js/Express 구현 예시

// utils/response.js
function success(res, data, statusCode = 200) {
  return res.status(statusCode).json({ data });
}

function paginated(res, data, meta) {
  return res.status(200).json({
    data,
    meta,
    pagination: {
      prev: meta.page > 1 ? `?page=${meta.page - 1}` : null,
      next: meta.page < meta.lastPage ? `?page=${meta.page + 1}` : null,
    },
  });
}

function error(res, statusCode, code, message, details = null) {
  const body = { error: { code, message } };
  if (details) body.error.details = details;
  return res.status(statusCode).json(body);
}

module.exports = { success, paginated, error };
// routes/users.js
const { success, paginated, error } = require('../utils/response');

router.get('/:id', async (req, res) => {
  const user = await User.findById(req.params.id);

  if (!user) {
    return error(res, 404, 'USER_NOT_FOUND', '해당 사용자를 찾을 수 없다');
  }

  return success(res, user);
});

router.get('/', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const perPage = parseInt(req.query.perPage) || 20;

  const { users, total } = await User.findAll({ page, perPage });
  const lastPage = Math.ceil(total / perPage);

  return paginated(res, users, { total, page, perPage, lastPage });
});

API 버전 관리

API가 한 번 공개되면 클라이언트가 의존하기 시작한다. 응답 구조를 변경하거나 엔드포인트를 없애면 기존 클라이언트가 깨진다. 버전 관리는 하위 호환성을 유지하면서 API를 발전시키는 방법이다.

URL 버전 vs Header 버전

방식 예시 장점 단점
URL 경로 /api/v1/users 직관적, 브라우저에서 테스트 쉬움 URL이 길어짐
쿼리 파라미터 /api/users?version=1 기존 URL 유지 비표준적, 잊어버리기 쉬움
요청 헤더 Accept: application/vnd.myapi.v1+json REST 원칙에 더 부합 테스트 불편, 캐싱 어려움
커스텀 헤더 X-API-Version: 1 유연함 비표준

실무에서는 URL 경로 방식이 가장 많이 쓰인다. 직관적이고 디버깅이 쉽다. Stripe, GitHub, Twitter 등 주요 API들이 URL 버전을 사용한다.

/api/v1/users    # v1 엔드포인트
/api/v2/users    # v2 엔드포인트 (응답 구조 변경)

버전을 올리는 시점은 하위 호환이 불가능한 변경이 생길 때다. 필드 추가는 버전 업 없이 가능하다. 기존 필드 삭제, 타입 변경, 엔드포인트 경로 변경은 버전 업이 필요하다.


쿼리 파라미터 설계

필터링

GET /api/v1/users?role=admin
GET /api/v1/posts?status=published&authorId=1
GET /api/v1/products?minPrice=10000&maxPrice=50000

정렬

GET /api/v1/users?sort=createdAt&order=desc
GET /api/v1/posts?sort=viewCount&order=asc

sort에 필드명, order에 방향(asc/desc)을 분리하는 게 명확하다. 일부는 -createdAt 형태(마이너스 접두사로 내림차순)를 쓰기도 한다. 팀에서 하나로 통일하면 된다.

페이지네이션

두 가지 방식이 있다.

오프셋 기반 (Offset-based)

GET /api/v1/posts?page=2&perPage=20

구현이 단순하고 특정 페이지로 바로 이동 가능하다. 단, 데이터가 자주 변하는 경우 페이지 간 데이터가 중복되거나 누락될 수 있다. 게시판처럼 페이지 번호가 필요한 UI에 적합하다.

커서 기반 (Cursor-based)

GET /api/v1/posts?cursor=eyJpZCI6MTAwfQ&limit=20

커서(보통 마지막 항목의 ID 또는 인코딩된 값)를 기준으로 다음 데이터를 가져온다. 데이터 변경에 영향을 받지 않고 성능이 일정하다. 무한 스크롤 UI에 적합하다.

// 커서 기반 응답 예시
{
  "data": [...],
  "meta": {
    "hasNext": true,
    "nextCursor": "eyJpZCI6MTIwfQ"
  }
}

REST vs GraphQL vs gRPC 간단 비교

항목 REST GraphQL gRPC
프로토콜 HTTP HTTP HTTP/2
데이터 형식 JSON JSON Protocol Buffers (바이너리)
요청 방식 엔드포인트 단위 단일 엔드포인트, 쿼리 언어 메서드 정의 기반
Overfetching/Underfetching 있음 없음 (필요한 필드만 요청) 없음
학습 곡선 낮음 중간 높음
실시간 지원 제한적 (SSE, WebSocket 별도) Subscription 지원 Streaming 지원
도구/생태계 풍부 풍부 상대적으로 적음
적합한 상황 범용 API, 외부 공개 API 복잡한 데이터 요구사항, 다양한 클라이언트 마이크로서비스 내부 통신, 고성능

대부분의 경우 REST로 충분하다. GraphQL은 모바일/웹 클라이언트마다 필요한 데이터가 다를 때 유용하다. gRPC는 마이크로서비스 간 내부 통신이나 성능이 극도로 중요한 케이스에서 쓴다.


삽질 기록

PUT vs PATCH 혼용으로 생긴 버그

PATCH 엔드포인트를 만들면서 내부적으로 PUT처럼 동작하게 구현한 적이 있다. 클라이언트에서 이름만 바꾸려고 { "name": "새이름" }을 PATCH로 보냈더니 다른 필드들이 전부 null이 됐다.

// 잘못된 PATCH 구현
router.patch('/users/:id', async (req, res) => {
  await User.update(req.params.id, req.body); // body에 없는 필드는 null이 됨
});

// 올바른 PATCH 구현
router.patch('/users/:id', async (req, res) => {
  const existing = await User.findById(req.params.id);
  const updated = { ...existing, ...req.body }; // 기존 데이터에 변경사항만 병합
  await User.update(req.params.id, updated);
});

DB 레벨에서도 UPDATE users SET name = ? WHERE id = ?처럼 변경할 컬럼만 명시하는 게 맞다. ORM을 쓴다면 save()가 아닌 변경 필드만 업데이트하는 메서드를 확인해야 한다.

상태 코드를 잘못 써서 생긴 클라이언트 혼란

초기에 API를 만들면서 모든 에러를 400으로 통일한 적이 있다. 인증 에러도, 권한 에러도, 리소스 없음도 전부 400이었다.

프론트엔드에서는 에러 메시지 내용을 파싱해서 분기 처리해야 했다. 로그인 만료인지, 권한 문제인지, 데이터가 없는 건지를 메시지 문자열로 구분했다. 나중에 에러 메시지를 한글에서 영어로 바꾸는 작업을 하면서 프론트엔드 조건문이 줄줄이 깨졌다.

HTTP 상태 코드를 제대로 쓰는 것의 중요성을 그 때 알았다. 상태 코드는 계약이다.

버전 관리 없이 API 변경했다가 생긴 문제

첫 번째 프로젝트에서 버전 관리를 하지 않았다. 응답 구조를 바꿔야 할 일이 생겼을 때 기존 클라이언트(모바일 앱)가 있어서 바꾸지 못했다. 새로운 구조로 마이그레이션하면서 클라이언트와 서버를 동시 배포해야 했고, 잠깐이라도 타이밍이 어긋나면 앱이 깨지는 상황이었다.

버전 관리를 처음부터 해뒀다면 /api/v2를 만들고 점진적으로 마이그레이션할 수 있었다. 버전 관리는 "변경이 있을 때" 시작하는 게 아니라 처음부터 URL 구조에 포함시켜두는 게 맞다.

페이지네이션 없이 API 만들었다가

사용자 목록 API를 처음에 페이지네이션 없이 만들었다. 개발 초기에는 데이터가 몇 건 없어서 문제가 없었다. 실제 서비스를 올리고 나서 데이터가 수천 건이 되자 API 응답이 수 초가 걸리기 시작했다.

나중에 페이지네이션을 추가했는데 기존 클라이언트는 전체 배열을 기대하고 있었다. 응답 구조 변경이 불가피해서 또 버전 업을 해야 했다.

처음 설계할 때 컬렉션 API에는 반드시 페이지네이션을 넣어두는 게 맞다. 데이터가 적어도 구조는 미리 갖춰두면 나중에 수정 비용이 없다.


마무리

REST API 설계에서 가장 중요한 건 일관성이다. 완벽한 REST API보다는 팀이 합의한 규칙을 일관되게 적용하는 것이 현실적으로 더 중요하다.

최소한 이 것들은 처음부터 정해두는 게 좋다.

  • URL 네이밍 규칙 (명사 복수형, 소문자, 하이픈)
  • HTTP 메서드 사용 기준 (특히 PUT vs PATCH 동작 방식)
  • 상태 코드 사용 기준 (401 vs 403, 400 vs 422)
  • 응답 포맷 (성공, 에러, 페이지네이션)
  • URL 버전 관리 (/api/v1/)
  • 페이지네이션 기본 포함

이것들이 정해지면 PR 리뷰에서 "이 엔드포인트 왜 /getUsers야?" 같은 기본적인 지적이 사라진다. 팀의 에너지를 비즈니스 로직에 집중할 수 있다.


기술 스택: REST API, HTTP, Node.js, Express, JSON

반응형