JangBaGeum.gif

나만의? RESTful API 설계 원칙 본문

Backend/개발 방법론 & 디자인 패턴

나만의? RESTful API 설계 원칙

장바금 2026. 2. 7. 14:11

 AI가 생활화되면서 개발에 있어 더욱 중요해진 것이 있다. 바로 설계라고 생각한다.

 AI 에이전트들은 각자의 컨텍스트를 가지고 각자의 요구사항을 해결하는 데 집중한다. 하지만 우리는 하나의 프로젝트 전체 요구사항을 단일 AI 컨텍스트에 맡기지 않는다. AI의 컨텍스트도 거대한 요구사항을 감당하지 못한다. 이 과정에서 서비스의 일관성을 유지하고 유지보수성을 높이기 위해, 인간이 할 수 있는 '설계'의 역할은 더욱 중요해졌다.

 특히 외부로 제공되는 API는 한 번 정해지면 서비스가 종료되기 전까지 사라질 수 없다. 업데이트를 진행할 때도 하위 호환성은 반드시 고려해야 한다. 이런 면에서 API 설계는 대충 넘어갈 일이 아니라, 충분한 시간을 들여 고민해야 하는 영역이라고 생각한다.

 이전에 API 디자인 패턴이라는 글을 쓴 적이 있다. 그때는 여러 매체에서 배운 일반적인 원칙들을 정리한 느낌이었다면, 이번 글은 조금 다르다. 실제로 마이크로서비스 기반의 플랫폼을 설계하고 운영하면서 체득한, 말 그대로 **"나만의 기준"**을 정리해보려 한다.

 여기서 말하는 원칙들은 교과서적인 REST 규칙의 나열이 아니다. "왜 이렇게 해야 하는가"에 대한 나의 생각을 담은 것이다. 누군가에게는 당연하게 느껴질 수도 있고, 누군가에게는 동의하기 어려울 수도 있다. 하지만 여기서 내가 말하고 싶은 것은 "왜"를 기록하는 것이다.

 

 


 

 

1. 이야기하고 싶은 것들

이 글에서 다루는 내용은 다음과 같다.

0. 설계의 기조
1. URI 네이밍

2. HTTP Method 활용
3. HTTP 상태 코드
4. API 버전 관리
5. 에러 응답 구조
6. 응답 필드 네이밍
7. 필드 네이밍 접두사 접미사
8. 기타 헷갈리는 부분들

 

 


 

2. 설계의 기조

구체적인 원칙에 들어가기 전에, 내가 API를 설계할 때 항상 염두에 두는 두 가지 마음가짐이 있다.

 

명세만으로 이해할 수 있어야 한다.

 좋은 API란 무엇일까? 나는 명세만 보고도 동작을 예측할 수 있는 API라고 생각한다. 내부 구현을 몰라도, 코드를 읽지 않아도, API 문서만으로 "이 요청을 보내면 이런 응답이 오겠구나"를 알 수 있어야 한다. API를 처음 접하는 개발자가 별도의 설명 없이도 통합할 수 있다면, 그것이 잘 설계된 API다. 반대로 "이건 문서에 없는데 실제로 해보니까 이렇게 동작하더라"라는 말이 나온다면, 설계에 대한 고민이 부족했거나 서비스에 대한 애정이 부족했다고 생각한다.

 

수정에는 닫혀 있고, 확장에는 열려 있어야 한다.

 언젠가 후임자가 이 API를 유지보수하게 될 것이다. 그때 기존 클라이언트를 깨뜨리지 않으면서도 새로운 기능을 추가할 수 있는 구조여야 한다. 필드를 추가하는 것은 괜찮지만, 기존 필드의 의미를 바꾸거나 삭제하는 것은 위험하다. 새로운 엔드포인트를 만드는 것은 괜찮지만, 기존 엔드포인트의 동작을 변경하는 것은 피해야 한다. 처음 설계할 때 이 점을 염두에 두면, 나중에 하위호완성에 대한 고통받을 일이 훨씬 줄어들 것이라 생각된다.

 

 


 

3. 원칙들

원칙 1. URI 네이밍은 꼭 정하고 꼭 지키자

 URI는 HTTP Method와 함께 API를 대표하는 이름이다. 사용자가 API 문서를 열었을 때 가장 먼저 보는 것이 URI다. 그만큼 URI 네이밍은 API의 첫인상을 결정한다.

 URI 네이밍에 자주 마주치는 고민들이 있다. "/sync-store"처럼 동사를 슬 것인가. "/stores"처럼 명사를 쓸 것인가? 단수형 "/store"와 복수형 "/stores"중 어떤 것이 맞는가? 경로가 길어지면 어떻게 표현할 것인가? 이런 질문들에 대해 명확한 기준이 없으면 API마다 스타일이 달라지고 사용하는 클라이언트에게 혼란을 줄 수밖에 없다.

 

규칙

리소스는 복수형 명사로 표현한다

 "/store"와 "/stores" 중 무엇이 맞을까? 보통 복수형을 사용하는 것이 일반적이다. 이유는 복잡하지 않다."/stores"는 매장 컬렉션을 의미하고, "/stores/:store_id"는 그 컬렉션에서 특정 매장 하나"를 의미한다. 단수형을 쓰면 "/store"가 하나의 매장을 가리키는 것처럼 보이는데, 실제로는 목록을 반환한다. 의미와 동작이 일치하지 않는다.

 

경로는 kebab-case, 경로 매개변수는 snake_case

URI 경로에는 kebab-case를, 경로 매개변수에는 snake_case를 사용한다.

 

GET      /v1/order-items/:order_item_id

 

 왜 이렇게 나눴을까? 경로(order-items)는 URL의 일부로, 대소문자를 구분하지 않는 환경도 있고 언더스코어가 링크에서 밑줄과 겹쳐 보이기도 한다. 반면 경로 매개변수(order_item_id)는 코드에서 변수로 바인딩되는 값이다. 대부분의 언어에서 변수명에 하이픈은 사용하지 않음으로, snake_case가 자연스럽다 생각된다.

// 변수 명에 하이픈을 사용하지 못하는 언어도 많고 부자연스럽다.
@Param("order-item-id") orderItemId
@Param("order_item_id") orderItemId

 

e.g.

Good

GET      /v1/users    -    사용자 목록 조회

GET      /v1/users/:user_id    -    특정 사용자 조회
POST    /v1/products    -    상품 생성
GET      /v1/orders/:order_id/items    -    특정 주문의 상품 목록
GET      /v1/products/:product_id/reviews    -    특정 상품의 리뷰 목록

Bad
GET      /v1/getUserList    -    동사 사용
GET      /v1/user/:userId     -     단수형 + camelCase
POST    /v1/product/create     -     경로에 행위 포함

 

컬렉션과 리소스의 관계 표현

 리소스 간에 소유관계가 있을 때는 "/"로 계층을 표현한다. "유저의 대출 목록", "특정 버전의 기능 값"처럼 A 없이는 B가 존재할 수 없는 관계일 때 유용하다.

 

GET      /v1/orders/:order_id/items     -     주문의 상품 목록
GET      /v1/orders/:order_id/items/:item_id     -     주문 내 특정 상품
GET      /v1/products/:product_id/reviews/:review_id     -     상품의 특정 리뷰

 

 다만 계층이 3단계를 넘어가면 URI가 매우 길어진다. 이럴 때는 무리하게 계층을 표현하기보다, 독립적인 리소스 엔드포인트로 분리하는 것이 나을 수 있다. URI의 가독성과 리소스 간 관계 표현 사이에서 타협을 보는 것이 매우 중요하다.

 

액션을 포함한 URI

 RESTful API 원칙에서는 URI에 동사를 넣지 않는 것이 정석이다. 하지만 이는 조금만 API를 설계를 하다 보면 현실적으로 어렵다고 느낄 때가 많다. "재시작", "롤백"처럼 어려운 행위는 어떻게 설계를 해야 하는 것일까?

 이 경우에는 "/actions/cancel", "/actions/refund"처럼 액션 네임스페이스를 두고 동사를 허용하는 방식으로 고민을 하고 있다.

 

POST     /v1/orders/:order_id/actions/cancel      -      주문 취소
POST     /v1/orders/:order_id/actions/refund      -      주문 환불
POST     /v1/payments/:payment_id/actions/retry      -     결제 재시도

 

 핵심읜 예외를 허용하되, 예외의 패턴은 일관되게 유지하는 것이다. "동사를 쓸 때는 항상 '/control/' 또는 '/actions/' 아래에 둔다"는 규칙만 지키면, 예외도 예측 가능해진다.

 


원칙 2 HTTP Method로 행위를 표현하되, 예외를 인정한다.

 원칙 1 중 액션을 포함한 URI 내용과 일부 겹치지만 이 부분에서는 행위를 표현하는 Method에 초점을 두고 이야기한다. RESTful API에서 HTTP Method는 곡 행위이다. 이건 누구나 아는 이야기이다.

 하지만 실무에서는 CRUD로 깔끔하게 떨어지지 않는 행위들이 정말 많다. "이 상품을 품절 처리해 줘", "주문을 취소해라" 같은 요청을 어떤 Method로 표현해야 하는가? 이 질문에 대한 기준이 필요하다.

 

규칙

Method 용도 상태 코드
GET 리소스 조회 (읽기 전용) 200 OK
POST 리소스 생성, 또는 프로세스 트리거 201 Created / 202 Accepted
PUT 리소스 전체 교체 200 OK
PATCH 리소스 부분 업데이트, 또는 리소스 속성 변경 200 OK
DELETE 리소스 삭제 200 OK / 204 No Content

 

 

PATCH와 POST, 어떻게 구분할까?

 헷갈리는 것이 "상품 품절 처리"와 "주문 취소"의 차이다. 둘 다 상태를 바꾸는 행위인데, 왜 하나는 PATCH고 하나는 POST일까?

 나는 단순히 리소스의 속성을 변경하냐 와 부수 효과가 따르냐로 판단을 하고 있다.

 

 PATCH는 리소스의 속성을 변경하는 것이다. "is_sold_out"을 "true"로 바꾸는 것은 상품이라는 리소스의 한 필드를 수정하는 것이다.

// 상품 품절 처리: 리소스의 속성 변경 → PATCH
@Patch("/products/:product_id")
updateProduct(@Body() body: { is_sold_out: boolean }) { ... }

 

 POST는 프로세스를 트리거하는 것이다. "주문 취소"는 단순히 주문의 "status"를 "cancelled"로 바꾸는 게 아니다. (물론 복잡하지 않은 비즈니스를 갖는 서비스는 아닐 수 있다.) 재고 복원, 결제 취소, 알림 발송 등 여러 부수 효과가 따른다. 이런 경우는 프로세스 트리거로 보고 POST를 사용한다. (URI 네이밍은 원칙 1에서 다룬 "/actions/" 패턴을 따른다.)

// 주문 취소: 프로세스 트리거 → POST
@Post("/orders/:order_id/actions/cancel")
cancelOrder() { ... }

 

예외를 허용하게 된다면,,,

 위에서도 이야기하였지만 실무에서는 이론대로 되지 않는 순간이 너무 많다. 예를 들어, 복합 조건으로 리소스를 조회해야 하는데 조건이 너무 많아 Query String이 지나치게 길어지는 경우 등이 그렇다.

// 원칙대로라면 GET이 맞지만, 검색 조건이 많아 POST로 처리
@Post("/search")
@HttpCode(HttpStatus.OK)
searchProducts(@Body() body: ProductSearchRequest) { ... }

 

 이런 경우에는 주석으로 "왜 원칙을 벗어났는가"를 반드시 남겨둔다. 원칙을 깨는 것 자체가 문제가 아니라, 왜 깼는지를 기록하지 않아 아무도 모르게 하는 것이 문제라 생각한다.

 


 

원칙 3. HTTP 상태 코드는 의미에 맞게 사용한다

 에러 응답 body에 "status_code" 필드를 넣는 API를 종종 본다. 하지만 HTTP 표준 자체가 이미 상태 코드를 응답 헤더에 포함하고 있다. body에 중복해서 넣을 필요가 없다. 상태 코드 자체를 정확하게 사용하는 것이 더 중요하다. 모든 에러에 400 혹은 500을 내려주거나, 성공 응답에 무조건 200만 쓰는 것은 HTTP를 제대로 활용하지 못하는 것이라 생각한다.

 

상태 코드 의미 사용 시점
200 OK 요청 성공 조회, 수정, 삭제 성공
201 Created 리소스 생성 성공 POST로 새 리소스 생성 시
202 Accepted 요청 접수됨 (처리 미완료) 비동기 작업 요청 시
204 No Content 성공, 응답 본문 없음 삭제 성공 등 본문이 불필요한 경우
400 Bad Request 잘못된 요청 입력값 검증 실패
401 Unauthorized 인증 실패 토큰 없음, 만료 등
403 Forbidden 인가 실패 권한 부족
404 Not Found 리소스 없음 존재하지 않는 리소스 접근
409 Conflict 충돌 이미 존재하는 리소스 생성 시도 등
422 Unprocessable Entity 요청은 올바르나 처리 불가 비즈니스 규칙 위반
500 Internal Server Error 서버 내부 오류 예상치 못한 예외

 

참고

MDN Web Docs - HTTP response status codes

 

흔한 실수

 400과 422의 구분이 대표적이다. 400은 요청 자체가 잘못된 경우(필수 필드 누락, 타입 불일치 등)이고, 422는 요청 형식은 올바르지만 비즈니스 규칙에 의해 처리할 수 없는 경우(이미 탈퇴한 회원의 주문 요청 등)이다.

 또 하나 자주 헷갈리는 것은 401과 403이다. 401은 "너 누구냐"(인증 실패)이고, 403은 "너인 것은 아는데 넌 안돼"(인가 실패)다.

 

정말 다양한 상황에 쓸 수 있는 다양한 상태 코드가 있으니 한 번 정독해 보는 것도 추천이다.


원칙 4. API 버전 관리는 처음부터 한다.

 "나중에", "나중에",  "나중에",,,라는 말을 정말 많이 하기도 하고 들어봤을 것이다. 결론부터 말하면, 나중은 절대 오지 않았다. 이미 버전 없이 배포된 API에 버전을 붙이는 작업은 생각보다 훨씬 고통스럽다. 기존 클라이언트를 모두 마이그레이션해야 하고 URI를 변경한다는 것 자체가 하위호완성을 박살 내는 행위이다.

 

규칙

경로 기반으로 버전을 관리한다.

// NestJS 설정
app.enableVersioning({
  type: VersioningType.URI,
  prefix: "v",
});

// Controller에서 버전 지정
@Controller({ path: "/products", version: "1" })
export class ProductsController { ... }

// 결과 경로: /v1/products

NestJS 프레임워크의 경우 Controller 데코레이터에서 "version" 속성으로 간단하게 관리할 수 있다.

 

여러 버전의 공존

 서비스가 성장하면 v1과 v2가 동시에 살아있어야 하는 시점이 반드시 오는 것 같다. 지금 만든 API는 영원하지만 또 모두가 영원히 사용하지 않는다.

apps/
├── controllers/
│   └── products/
│       ├── v1/
│       │   └── products.v1.controller.ts    # 기존 API
│       └── v2/
│           └── products.v2.controller.ts    # 개선된 API

 

 컨트롤러 레이어 개발 관점에서 중요한 것은 v1코드를 v2에 복붙 하는 것이 아니라. 변경이 필요한 부분만 v2에 오버라이드 하는 구조를 만드는 것이 유지보수에서 유리한 것 같다.

 

 Header 기반 버전 관리 (e.g. Accept: application/vnd.api+v1+json)가 좀 더 "RESTful"하다는 의견도 있는 것 같다. 하지만 경험상 URI 기반이 훨씬 직관적이고, Trace 모니터링 시, 확인도 빠르고 로드밸런서에서 경로 기반 라우팅도 가능하기에 실용 측면에서는 이점이 많은 듯하다.


원칙 5. 에러 응답은 일관된 구조를 갖는다.

 20개가 넘는 마이크로서비스가 각각 다른 형태로 에러를 내려주면 어떻게 될까? API Gateway에서 에러를 통합 처리하는 것이 불가능해진다. 클라이언트 개발하는 사람은 각 이용하는 서비스마다 에러를 처리하는 로직을 여러 개로 작성해야 한다. 이는 지속 가능하지 못한다고 본다.

 

규칙

{
  "code": "ERR_SHOP_ORDER_0400"
  "message": "Invalid request.",
  "detail": "product_id must be a non-empty string"
}
필드 역할
code 에러 식별. 클라이언트가 분기 처리할 때 사용
message 무엇이 잘못됐는지. 개발자가 로그에서 빠르게 파악
detail 왜 잘못됐는지. 디버깅에 필요한 구체적인 정보

 

 

"code"가 있으면 클라이언트가 메시지 문자열에 의존하지 않고 에러를 처리할 수 있다.

 

과연 더 많은 에러 정보가 필요하지 않을까?

 에러 응답의 모범사례를 검색해 보면 훨씬 많은 필드를 권장하는 예시들을 종종 볼 수 있다.

 

e.g.

// 출처 - https://blog.postman.com/best-practices-for-api-error-handling/

{
  "status": "error",
  "status_code": 404,
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "요청한 리소스를 찾을 수 없습니다.",
    "detail": "ID가 '12345'인 사용자는 존재하지 않습니다.",
    "timestamp": "2023-12-08T12:30:45Z",
    "path": "/api/v1/users/12345",
    "suggestion": "사용자 ID가 올바른지 확인하세요."
  },
  "request_id": "a1b2c3d4-e5f6-7890",
  "documentation_url": "https://api.example.com/docs/errors"
}

 

하지만 나는 이 많은 필드들은 제공하지 않아도 된다 생각한다.\

 

- status, status_code: HTTP 응답 헤더에 이미 있다. 중복이다.

- timestamp, path, request_id: 디버깅에 유용하지만, 서버 로깅 시스템에서 관리할 영역이다. 굳이 클라이언트에게 내려줄 필요가 없다. request_id가 필요하다면 응답 헤더로 내려주면 된다. 그리고 비동기 응답을 다루는 API라면 필요할 수도 있다.

- documentation_url, suggestion: 친절해 보이지만, 클라이언트가 이 URL을 자동으로 열어줄까 의문이다. 결국 개발자가 읽는 것이고, 개발자는 이미 문서를 읽고 잘 찾지 않을까 생각된다.

 

에러 코드 체계

 에러 코드는 각 조직마다 정하기 나름이다. 내가 운영하는 서비스의 경우 마이크로 서비스를 운영하고 있기 때문에 어떤 서비스로부터 전파된 에러인지 알기 위해서 개발자가 확인할 수 있는 프로젝트, 서비스 코드를 함께 넣어 코드를 만들고 있다.

ERR_{프로젝트}_{서비스}_{코드번호}

ERR_SHOP_ORDER_0400  → Shop / Order Service / 400번 에러
ERR_SHOP_USER_0001   → Shop / User Service / 커스텀 에러 0001
ERR_SHOP_PAY_0422    → Shop / Payment Service / 422번 에러

원칙 6. 응답 필드 네이밍은 snake_case로 통일한다

 JavaScript/TypeScript 생태계에서 변수명은 camelCase가 표준이다. 그래서인지 Google, Microsoft 같은 JS/TS 생태계 중심의 API는 camelCase를 권장하기도 한다. 반면 GitHub, Stripe, AWS처럼 다양한 언어의 클라이언트를 지원하는 API들은 snake_case를 채택하고 있다.

 

 어떤 것을 선택하든 결국 클라이언트는 직렬화/역직렬화 라이브러리를 사용하게 된다. API가 snake_case를 쓰든 camelCase를 쓰든, 클라이언트 측 언어의 린팅 규칙에 맞게 변환하는 과정은 어차피 필요하다. 그 과정에서 데이터 타입 변환(문자열 → 날짜 등)도 함께 처리되므로, API 측 네이밍이 클라이언트 언어와 다르다는 것은 실질적인 문제가 되지 않는다.

 

 그렇다면 선택의 기준은 가독성일관성이다. order_item_id는 orderItemId 보다 단어 경계가 명확해서 읽기 쉽다. 그리고 어떤 표기법을 택하느냐보다, 한번 정한 규칙을 모든 API에서 일관되게 지키는 것이 훨씬 중요하다. 나는 이러한 이유로 snake_case를 선택했다.

 

규칙

- API 응답 필드: snake_case

- 내부 도메인 모델: camelCase (나는 주로 TypeScript를 사용하기 때문)

- 변환은 Mapper에서 담당

// API 응답 (snake_case)
{
  "product_id": "prod_abc123",
  "product_name": "무선 키보드",
  "is_sold_out": false,
  "created_at": "2024-01-15T10:30:45.123Z"
}

// 내부 도메인 모델 (camelCase)
interface ProductEntity {
  productId: string;
  productName: string;
  isSoldOut: boolean;
  createdAt: Date;
}

 

내부적으로 camelCase를 쓰고 외부로는 snake_case를 내려주는 구조는 변환 레이어(Mapper)가 필요하다. 그러나 앞에서 이야기한 듯 제대로 된 클라이언트라면 어차피 직렬화/역직렬화를 거친다 생각하기 때문에 이 비용은 크게 신경 안 써도 될 것이라 생각한다.


원칙 7. 필드 네이밍에 접두사와 접미사를 명확히 사용하자

 필드 이름만 보고도 타입과 용도를 예측할 수 있으면 너무 좋다.

 "status"라는 필드가 있다고 하자. 이게 문자열인가, 숫자인가, Boolean인가 한 번에 알기가 쉽지 않다. created는 생성자인가, 생성 여부인가, 생성 시각인가 명확히 알 수가 없다. 필드 이름이 모호하면 매번 문서를 확인해야 하는 수고가 생긴다. 접두사와 접미사를 일관되게 사용하면, 필드 이름 자체만으로 행위를 파악하는데 많은 시간을 단축할 수 있다.

 

Boolean: "is_", "has_", "can_"

Boolean 필드는 "예/아니요"로 답할 수 있는 질문 형태로 만든다.

접두사 의미 예시
is_ ~인가? (상태) is_sold_out, is_verified, is_active
has_ ~을 가지고 있는가? has_coupon, has_children, has_permission
can_ ~할 수 있는가? can_cancel, can_edit, can_refund

 

"sold_out"만 쓰면 품절 상태인지, 품절 시각인지, 품절된 수량인지 모호하다. "is_sold_out"은 Boolean 임이 분명하게 느껴진다.

 

 

날짜/시간: "_at", "_date"

접두사 의미 예시
_at 특정 시점 (시각 포함) created_at, ordered_at
_date 날짜만 birth_date, expiry_date

"created"만 쓰면 생성자를 뜻하는지, 생성 시각을 뜻하는지 알 수 없다. "created_at"은 시각임이 분명하다.

 

 

식별자: "id", "_code", "_number"

식별자는 성격에 따라 접미사를 구분한다. 이 부분이 설계할 때 자주 헷갈리는 영역이다.

접미사성격예시

접미사 성격 예시
id 해당 리소스 자체의 식별자 id
{resource}_id 다른 리소스를 참조할 때 user_id, order_id, product_id
_code 짧고 의미 있는 코드. 사람이 읽고 기억할 수 있음 category_code, country_code
_number 순차적으로 부여되는 번호. 사용자에게 노출됨 order_number, invoice_number

 

"id"는 접미사가 아니다. 리소스 자체의 식별자는 그냥 id로 표현한다. "user_id"나 "order_id"처럼 접미사 형태로 쓰는 것은 다른 리소스를 참조할 때뿐이다.

// User 리소스 응답
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "홍길동",
  "email": "hong@example.com"
}

// Order 리소스 응답 - 다른 리소스(User)를 참조할 때 user_id 사용
{
  "id": "order_abc123",
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "order_number": "ORD-2024-00001234"
}

 

 User 응답에서 user_id가 아닌 id를 쓰는 이유는 간단하다. 이미 User 리소스의 응답이라는 맥락 안에 있기 때문에, user_라는 접두사는 중복이다. 반면 Order 응답에서 주문한 사용자를 참조할 때는 user_id라고 명시해야 어떤 리소스의 식별자인지 알 수 있다.

 

 order_number는 고객에게 보여주는 주문번호다. 고객센터에 문의할 때 "ORD-2024-00001234 주문 확인해 주세요"라고 말한다. id와는 용도가 완전히 다르다. _code는 enum처럼 미리 정의된 값 중 하나를 선택하는 경우에 주로 쓴다.

 

 category_code: "ELECTRONICS", status_code: "PENDING" 같은 식이다.

 

 

배역/목록: 복수형

컬렉션은 복수형으로 표현한다.

{
  "products": [...],
  "reviews": [...],
  "order_items": [...]
}

 


원칙 8. 자주 헷갈리는 설계들

 설계하다 보면 사소하지만 매번 고민되는 부분들이 있다. 정답이 있다기보다는 한번 정도 일관되게 정하면 되는 부분들이다.

 

시간의 포맷: ISO 8601 vs Unix Timestamp

시간을 어떤 포맷으로 내려주는 것이 좋을까? 처음에는 ISO 8601 표준의 문자열을 자주 사용했다.

{
  "created_at": "2026-02-01T10:30:45.123Z"
}

 

 사람이 읽기 좋고, 표준이라는 장점이 있다. 하지만 클라이언트에서 이 문자열을 파싱 하는 과정에서 문제가 종종 생겼다. 로컬 장비의 시간대 설정에 따라 파싱 결과가 달라지는 것이다. 같은 "2026-02-01T 10:30:45.123Z"를 파싱해도, 어떤 환경에서는 UTC로, 어떤 환경에서는 로컬 시간대로 해석하여 시간이 어긋나는 버그가 발생했다.

 

그러하여 문자열 파싱 오류를 최소화할 수 있는 Unix Timestamp(밀리초)를 사용해 보았다.

{
  "created_at": 1705314645123
}

 

 Timestamp는 시간대와 무관한 절댓값이다. 1970년 1월 1일 UTC 기준으로 몇 밀리초가 지났는지를 나타내는 숫자일 뿐이다. 파싱 과정에서 시간대로 인한 오류가 발생할 여지가 없다. 클라이언트는 이 값을 받아서 자신의 시간대에 맞게 변환해서 보여주면 된다.

사람이 읽기 어렵다는 단점이 있지만, 어차피 API 응답을 사람이 직업 읽는 경우는 디버깅할 때 분이니 안정성이 더 중요하다 생각한다.

 

과거분사 vs 동사 원형

"create_at"인가, "created_at"인가?

{ "create_at": 1705314645123 }
{ "created_at": 1705314645123 }

과거분사(created_at)를 사용하는 것이 일반적으로 보인다.

  • created_at: "생성된 시각" - 이미 일어난 사건의 시점
  • create_at: "생성하다 시각" - 문법적으로 어색함

동사 원형을 쓰면 행위를 나타내는 것처럼 보인다. 반면 과거분사는 "~된 시각"이라는 완료된 상태를 명확히 표현한다.

 

다만 예정된 시점을 나타낼 때는 현재형이 자연스럽니다.

{
  "created_at": 1705314645123,
  "expires_at": 1735689599000
}

 

"expires_at"은 "만료되는 시각"으로, 아직 일어나지 않은 미래 시점이다.

 

단수/복수 형태가 같은 단어

goodsnewsspecies처럼 단수와 복수가 같은 단어는 어떻게 할까? 필드명만 보고는 배열인지 단일 객체인지 구분하기 곤란하다.

이런 경우 간단하게 "_list" 접미사를 사용하여 사용하거나, 혹은 아예 다른 단어로 대체한다.

// 방법 1: _list 접미사
{
  "goods_list": [...]
}

// 방법 2: 다른 단어로 대체
{
  "items": [...],
  "products": [...]
}

 

"goods"를 굳이 고집할 이유가 없다면, "products"나 "items"처럼 복수형이 명확한 단어를 선택하는 게 낫다.

 

 


 

 

 

 내가 위와 같이 정한 내용은 모두 절대적인 정답이 아니다. 내가 실무를 하면서 외부 서비스 연동을 위해 다양한 타사의 API 정의서를 본 경험가 20개가 넘는 마이크로서비스를 설계하고 운영하면서, 반복적으로 부딪힘과 함께 고민한 내용들을 정리한 것이다.

 

 처음에는 하나하나 정하기 번거롭고 무슨 이런 걸 정해두나 과해 보일 수도 있지만 이 규칙들이 시간이 지날수록 유지보수에 큰 도움을 주고 생산성에 큰 영향을 준다. 반면 성능과 안정성 사이에서 절충점을 찾아야 하는 순간도 분명히 온다.

 

 내가 여기서 강조하고 싶은 것은 "내가 이렇게 정했다!"가 아니다. 결국 API 설계에서 가장 중요한 것은 일관성명확성의 중요성이다. 어떤 규칙을 따르느냐보다, 한번 정한 규칙을 일관되게 지키는 것이 훨씬 중요하다. 그래서 이런 원칙들을 기록해 두는 것이다.

 

 그리고 앞에서 이야기했던 기조를 다시 한번 강조하고 싶다. 명세만으로 이해할 수 있어야 하고, 수정에는 닫혀 있되 확장에는 열려 있어야 한다. 지금 당장은 번거로워 보여도, 이 두 가지를 지키려고 노력하면 나중에 고통받을 일이 현저히 줄어들 것이라 생각된다.