REST API 설계할 때 헷갈리는 HTTP 규칙 체크리스트
실무에서 REST API를 설계할 때 리소스, URI, HTTP 메서드, 상태 코드, 캐싱, 메타데이터를 점검하기 위한 규칙을 정리했습니다.
On this page
실무에서 깨달은 건 한번 배포된 API는 쉽게 바꾸기 어렵고, 따라서 설계 단계에서 API 디자인을 꼼꼼히 점검하는 것이 필요하다는 것이다.
GPT와 디자인 토론을 하더라도 결국엔 스스로 검증할 수 있어야 판단을 내릴 수 있기 때문에 한 번은 꼭 짚고 넘어갈 필요가 있다. 그래서 이번 글에서는 REST API 설계할 때 헷갈리기 쉬운 HTTP 규칙을 체크리스트 형태로 정리해봤다.
책 『일관성 있는 웹 서비스 인터페이스 설계를 위한 REST API 디자인 규칙』을 읽으면서 남긴 메모와 필기를 바탕으로 작성했다. 단, 책의 내용을 그대로 옮기기보다는 내가 API를 설계할 때 다시 확인하고 싶은 규칙과 헷갈렸던 지점을 체크리스트 형태로 재구성했다.
핵심은 URI에 동작을 욱여넣지 않고 리소스를 명확히 식별하는 것, HTTP 메서드와 상태 코드의 의미를 API 계약에 맞게 일관되게 쓰는 것이다.
리소스 원형
도큐먼트
레코드 한 개를 표현한다.
컬렉션
레코드의 집합을 표현한다. 데이터베이스 관점에서는 테이블과 비슷하게 볼 수 있다.
스토어
클라이언트가 관리할 수 있는 리소스 저장소를 표현한다.
컨트롤러
CRUD로 자연스럽게 표현하기 어려운 함수형 기능을 수행한다.
URI 식별자 설계
체크리스트
- 도큐먼트는 단수로 표기한다.
- 컬렉션은 복수로 표기한다.
- 스토어는 복수로 표기한다.
- 컨트롤러는 동사형으로 표기한다.
- 컨트롤러는 자식 리소스를 두지 않는다.
- URI 마지막 문자에 슬래시를 포함하지 않는다.
- URI에는 밑줄과 대문자를 사용하지 않는다.
- 하나의 리소스에 여러 리소스 원형을 섞지 않는다.
- CRUD 기능을 URI에 직접 넣지 않는다.
- 컬렉션과 스토어를 검색, 필터링, 페이징할 때는 쿼리 파라미터를 사용한다.
- 검색, 페이징, 필터링 요구사항이 복잡하면
POST /users/search처럼 검색 컨트롤러를 둘 수 있다. - 쿼리 파라미터를 사용한다는 이유만으로 캐싱을 포기하지 않는다. 캐시 키와 캐시 정책은 별도로 설계한다.
일관된 원형 사용 예시는 다음과 같다.
/users
/users/{id}/posts
반대로 여러 리소스를 억지로 하나의 URI에 섞는 방식은 피한다.
/users_and_posts
HTTP 인터랙션 설계
메서드
컨트롤러를 실행할 때는 보통 POST를 사용한다.
PUT은 새로운 리소스를 추가하거나 기존 리소스의 전체 표현을 갱신할 때 쓴다. 즉, 대상 리소스의 상태를 요청 본문에 담긴 표현으로 대체한다는 의도다. 누락된 필드를 null로 볼지, 기존 값을 유지하지 않을지는 HTTP 표준 자체가 아니라 API 계약과 서버 구현 정책으로 명확히 해야 한다.
PATCH는 리소스 일부를 갱신할 때 쓴다. PATCH가 항상 멱등성을 보장한다고 단정하면 안 된다. 다만 구현 방식에 따라 멱등하게 만들 수는 있다.
GET 요청에는 의미 있는 본문을 싣지 않는다. 조회 조건은 URI와 쿼리 파라미터, 필요한 경우 헤더로 표현한다. 짧게 메모하면: GET은 바디 없다.
DELETE는 대상 리소스를 삭제하는 의도를 표현한다. 실제 데이터베이스 레코드를 물리 삭제할지, 리소스 상태를 비활성화할지는 서버 구현에 따라 다를 수 있다. 다만 soft delete나 비활성화처럼 상태 전이에 가까운 동작은 PATCH /users/{id} 또는 POST /users/{id}/deactivate처럼 더 명시적인 모델링이 나을 수 있다.
2xx
200 OK는 요청이 성공했고 응답 본문으로 결과 표현을 돌려줄 때 사용한다.
201 Created는 리소스가 새로 생성됐을 때 사용한다. 생성된 리소스의 URI는 Location 헤더에 담는 것이 좋다.
// 새로운 유저 생성 요청
POST /users HTTP/1.1
Host: api.example.com
Content-Type: application/json
{
"name": "John Doe",
"email": "john.doe@example.com"
}
// 생성된 유저의 URI 로케이션에 명시
HTTP/1.1 201 Created
Location: /users/123
Content-Type: application/json
{
"id": 123,
"name": "John Doe",
"email": "john.doe@example.com"
}
202 Accepted는 비동기 처리가 성공적으로 시작됐을 때 사용한다. 응답에는 상태 확인을 위한 엔드포인트를 제공하는 것이 좋다.
POST /files/upload HTTP/1.1
Content-Type: application/json
{
"fileName": "example.pdf",
"fileUrl": "https://example.com/files/example.pdf"
}
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"status": "processing",
"message": "Your file is being processed. You can check the status at /files/status/12345."
}
204 No Content는 요청이 성공했지만 응답 본문이 필요 없을 때 사용한다. DELETE, PUT, POST, OPTIONS 요청에서 본문 없는 성공 응답으로 사용할 수 있다.
3xx
301 Moved Permanently는 리소스가 영구적으로 이동됐을 때 사용한다. 서버는 Location 헤더에 새로운 URI를 담는다. 메서드가 바뀔 수도 있다. 그러므로 API에서는 주의해서 써야 한다.
GET /resource HTTP/1.1
Host: old-domain.com
HTTP/1.1 301 Moved Permanently
Location: http://new-domain.com/resource
303 See Other는 POST, PUT, DELETE 같은 상태 변경 작업이 성공적으로 완료됐고, 결과 확인 URI를 안내하고 싶을 때 사용한다. 303 응답을 받은 클라이언트는 Location 헤더의 URI에 GET으로 접근한다.
POST /form-submit HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
name=John&email=john@example.com
HTTP/1.1 303 See Other
Location: /form-success
304 Not Modified는 클라이언트가 이미 최신 데이터를 가지고 있을 때 불필요한 리소스 전송을 피하기 위해 사용한다. 응답 본문은 없다.
GET /api/users HTTP/1.1
Host: api.example.com
If-None-Match: "12345etag"
HTTP/1.1 304 Not Modified
307 Temporary Redirect는 클라이언트를 다른 URI로 임시 리다이렉션할 때 사용한다. 여기서 중요한 점은 요청 메서드가 변경되지 않고 그대로 유지된다는 것이다.
4xx
401 Unauthorized는 인증 정보가 없거나 유효하지 않을 때 사용한다. 즉, 인증 자체에 문제가 있는 경우다.
403 Forbidden은 인증 여부와 별개로 접근 권한이 없을 때 사용한다.
404 Not Found는 요청 URI에 해당하는 리소스가 없을 때 사용한다. 검색 결과가 비어 있는 경우에는 보통 404가 아니라 빈 컬렉션을 반환하는 편이 자연스럽다. 없다고 무조건 404는 아니다.
GET /users/{id}
GET /users/search
405 Method Not Allowed는 URI는 존재하지만 지원하지 않는 메서드로 요청했을 때 사용한다. 응답의 Allow 헤더에 지원하는 메서드를 명시한다.
406 Not Acceptable은 클라이언트가 Accept 헤더로 요청한 미디어 타입을 서버가 제공할 수 없을 때 사용한다.
GET /users HTTP/1.1
Host: api.example.com
Accept: application/xml
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{
"error": "The requested content type is not supported. Supported types: application/json"
}
409 Conflict는 클라이언트 요청이 리소스의 현재 상태와 충돌할 때 사용한다.
예를 들면 다음과 같은 경우다.
- 이미 존재하는 데이터를 생성하려고 한 경우
- 삭제 요청이 제약 조건 때문에 수행될 수 없는 경우
- 두 클라이언트가 같은 리소스를 동시에 수정하려고 한 경우
DELETE /projects/123 HTTP/1.1
// 제약조건 위반
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "Project cannot be deleted because it contains active tasks."
}
412 Precondition Failed는 클라이언트가 요청 헤더로 특정 조건을 명시했고, 서버가 그 조건을 충족하지 못했을 때 사용한다. 조건은 If-Match, If-Unmodified-Since, If-None-Match, If-Modified-Since 같은 HTTP 헤더로 명시한다. 주로 동시성 제어나 리소스 상태 검증에 활용한다.
// 리소스를 업데이트 할 건데, Etag랑 매치하는 상태인지 확인하고 싶음
PUT /documents/123 HTTP/1.1
If-Match: "etag-value-abc123"
Content-Type: application/json
{
"title": "Updated Document"
}
// 현재 리소소의 ETag가 요청한 ETag랑 일치하지 않아서 수행 불가함
// 이미 다른 클라에 의해 수정된 상태인 것
HTTP/1.1 412 Precondition Failed
Content-Type: application/json
{
"error": "The resource has been modified by another process.",
"currentETag": "etag-value-def456"
}
// 클라이언트가 리소스를 삭제하려고 할 때, 마지막으로 수정된 이후에만 삭제하고 싶음.
DELETE /posts/456 HTTP/1.1
If-Unmodified-Since: Mon, 01 Jan 2025 10:00:00 GMT
// 클라이언트가 If-Unmodified-Since 조건을 설정했으나, 리소스가 지정한 시간 이후에 수정됨.
HTTP/1.1 412 Precondition Failed
Content-Type: application/json
{
"error": "The resource has been modified after the specified time.",
"lastModified": "Mon, 02 Jan 2025 12:00:00 GMT"
}
415 Unsupported Media Type은 클라이언트가 요청에 포함한 Content-Type 헤더 값 또는 본문 형식을 서버가 지원하지 않을 때 반환한다.
406과 415의 차이는 다음과 같다.
406:Accept헤더에 명시한 응답 형식을 서버가 제공할 수 없는 경우415:Content-Type헤더에 명시한 요청 본문 형식을 서버가 처리할 수 없는 경우
5xx
500 Internal Server Error는 서버에서 예기치 않은 문제가 발생했을 때 사용한다. 예를 들면 데이터베이스 연결 실패, 런타임 에러, 외부 API 통신 에러 등이 있다.
메타데이터 디자인
체크리스트
- 요청과 응답 본문이 있을 때
Content-Type을 사용한다. - 본문 길이를 명시해야 할 때
Content-Length를 사용한다. 값은 바이트 단위다. Last-Modified는 응답에 사용한다. 리소스가 마지막으로 수정된 시간을 나타낸다.ETag는 리소스 표현의 식별자로 사용한다. 클라이언트는 캐싱이나 조건부 요청 목적으로 이 값을 저장할 수 있다.PUT으로 갱신할 때는If-Unmodified-Since,If-Match같은 헤더를 이용해 의도를 더 명확히 할 수 있다.Location헤더는 새로 생성된 리소스의 URI, 리다이렉션 URI, 비동기 처리 상태 확인 URI 등을 전달할 때 사용한다.Cache-Control을 이용해 캐싱을 제어한다.- 커스텀 헤더는 HTTP 메서드의 동작을 바꾸는 데 쓰지 않는다. 정보 전달이 목적일 때만 선택적으로 사용한다.
Last-Modified는 초 단위까지만 지원하므로 더 정밀한 검증이 필요하면 ETag를 함께 사용하는 것이 좋다.
Cache-Control
Cache-Control 예시는 다음과 같다.
캐시 기능은 사용해야 한다. 짧은 시간이라도…
Cache-Control: max-age=60
클라이언트는 60초 동안 캐시된 데이터를 사용할 수 있다.
Cache-Control: max-age=0, must-revalidate
리소스가 캐시되어 있더라도 서버에 재검증을 요청해야 한다.
Cache-Control: no-store
민감한 데이터를 브라우저나 중간 네트워크 노드에 캐싱하지 않도록 한다.
Cache-Control: public
정적 리소스를 브라우저와 공용 캐시에서 사용할 수 있도록 한다.
상태 변경 메서드인 POST, PUT, DELETE에는 캐싱을 적용하지 않는 것이 일반적이다. 실무에서는 주로 GET과 HEAD 응답에 캐싱 헤더를 둔다. 다만 HTTP 의미론상 POST 응답도 조건을 만족하면 캐시 가능할 수 있으므로, API 계약에서 명확히 정하는 것이 좋다.
3xx와 4xx 응답도 경우에 따라 캐싱할 수 있다. 예를 들어 301 Moved Permanently는 캐싱될 수 있고, 404 Not Found 응답을 짧게 캐싱하면 잘못된 요청 반복을 줄일 수 있다.
// 존재하지 않는 리소스를 요청
GET /non-existent-resource HTTP/1.1
Host: api.example.com
// 5분 동안 Not Found 응답 캐싱
HTTP/1.1 404 Not Found
Cache-Control: max-age=300
커스텀 헤더
커스텀 헤더는 리소스 식별이나 핵심 요청 처리 조건을 숨기는 곳으로 쓰면 안 된다.
// ❌ 커스텀 헤더에 리소스 식별 값을 포함함
GET /resources/123 HTTP/1.1
X-Custom-UserID: 456
요청 추적을 위한 값처럼 부가 정보를 담는 용도로는 사용할 수 있다.
// ⭕ 커스텀 헤더에 요청 추적을 위한 값 포함
GET /resources/123 HTTP/1.1
X-Correlation-ID: abc123
표준 인증 헤더를 사용할 수 없는 경우에는 커스텀 헤더로 대체 정보를 전달할 수 있다. 이때 HTTPS 사용은 필수다.
// ⭕ 표준 인증 헤더를 사용할 수 없는 경우, 커스텀 헤더로 대체 정보를 전달
// HTTPS 암호화 필요
GET /resources/123 HTTP/1.1
X-API-Key: my-api-key
서버의 확장 기능 정보를 전달하는 데에도 사용할 수 있다.
// ⭕ 서버의 확장 기능 활용
// `X-RateLimit-Remaining`은 클라이언트가 사용할 수 있는 남은 요청 수를 전달
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Remaining: 100
미디어 타입
Content-Type 헤더 값을 미디어 타입이라고 한다. 미디어 타입 문법은 다음과 같다.
type/subtype(; attribute=value)
type에는 application, audio, image, message, model, multipart, text, video 등이 들어간다.
attribute=value는 선택 옵션이다. 파라미터 이름은 대소문자를 구분하지 않지만, 파라미터 값은 보통 대소문자를 구분하고 따옴표 안에 쓴다. 파라미터 순서는 중요하지 않다.
벤더 고유 미디어 타입은 vnd 접두어를 사용한다.
application/vnd.ms-excel
모든 미디어 타입은 IANA Media Types에서 확인할 수 있다.
표현 디자인
JSON:API는 JSON 기반 API 표현을 설계할 때 참고할 수 있는 명세다. 리소스, 관계, 링크, 부분 응답 같은 표현 규칙을 정리할 때 도움이 된다.
클라이언트 영역
리소스의 상태나 표현 방식이 변경되더라도 URI는 리소스를 명확히 식별해야 하며 혼란을 주지 않아야 한다. 표현 방식이나 버전은 Accept 같은 HTTP 헤더를 통해 관리하는 것이 REST 원칙에 더 가깝다.
다만 현실적으로는 URI에 v1, v2처럼 버전을 명시하는 방식이 널리 사용된다. URI 기반 버저닝은 엄격한 REST 원칙에서는 아쉬운 점이 있지만, 클라이언트와 서버가 API 호환성을 관리하기 쉽다는 장점이 있다.
부분 응답을 지원할 때는 URI 쿼리를 사용할 수 있다.
GET /articles?fields[articles]=title,body
GET /students/morgan?fields=(firstName,birthDate)
GET /students/morgan?fields=!(address,schedule!(wednesday,friday))
참고) 필드 선택과 관계 조회가 복잡해지면 GraphQL이라는 대안이 있다.
참고 자료
- 『일관성 있는 웹 서비스 인터페이스 설계를 위한 REST API 디자인 규칙』
- RFC 9110: HTTP Semantics
- HTTP request methods - MDN Web Docs
- IANA Media Types
- JSON:API
Keep reading
Related posts



Comments