← Posts

API · REST · HTTP

REST - 자원으로 말한다

/getUserList는 왜 REST가 아닌가. 동사를 URL에서 걷어내고 자원과 메서드로 말하는 방식을 잡는다.

목차
  1. /getUserList는 왜 REST가 아닌가
  2. 동사를 URL에서 걷어낸다
  3. 자원은 명사이고 URL은 그 이름표다
  4. 자원과 표현은 다르다
  5. 메서드에 이미 의미가 있다
  6. 안전한가, 멱등한가
  7. PUT과 PATCH는 다르다
  8. 서버는 이전 요청을 기억하지 않는다
  9. 자원으로 안 떨어지는 것들
  10. 그래서 실무에선 어떻게 쓰이나
  11. 정리

앞 글에서 API가 한 번 정하면 되돌리기 어려운 계약이라는 걸 봤다. 그 계약의 첫 줄, URL을 짓는 방식부터 본다.

/getUserList는 왜 REST가 아닌가

사용자 기능을 만들다 보면 URL이 이렇게 쌓인다.

plaintext
GET  /getUserList
GET  /getUser?id=1024
POST /createUser
POST /updateUser
POST /deleteUser
POST /updateUserEmail
POST /activateUser

각각은 멀쩡해 보인다. 이름만 봐도 뭘 하는지 안다. 문제는 여기서 멈추지 않는다는 것이다.

사용자에 주문이 붙고 상품이 붙으면 /getOrderList, /createOrder, /cancelOrder, /getProductList가 생긴다. 그리고 어느 날 다른 개발자가 /fetchUserList를 만든다. 이미 /getUserList가 있는 줄 모르고. 앞 글에서 본 대로 한 번 나간 URL은 지우기 어려우니, 둘 다 남는다.

동사를 URL에 쓰는 순간 URL의 개수가 기능의 개수를 따라 무한히 늘어난다. 그리고 동사에는 표준이 없다. get인지 fetch인지 find인지, delete인지 remove인지를 매번 사람이 고른다.

동사를 URL에서 걷어낸다

여기서 하나만 알아채면 된다. HTTP에는 이미 동사가 있다. GET, POST, PUT, PATCH, DELETE가 그 자리를 맡으라고 있는 것이다.

URL에서 동사를 빼고 대상만 남기면 이렇게 접힌다.

하던 일동사가 URL에 있을 때자원으로 말할 때
목록 조회GET /getUserListGET /users
단건 조회GET /getUser?id=1024GET /users/1024
생성POST /createUserPOST /users
수정POST /updateUserPATCH /users/1024
삭제POST /deleteUserDELETE /users/1024

일곱 개였던 URL이 두 개로 줄었다. /users/users/1024뿐이다.

diagramdiagram

기능이 늘어도 URL은 잘 안 는다. 사용자에 관한 새 동작이 생겨도 대상은 여전히 사용자이기 때문이다. URL은 대상을 가리키고, 무엇을 할지는 메서드가 말한다. 이게 REST의 출발점이다.

자원은 명사이고 URL은 그 이름표다

REST에서 자원(resource)은 API가 다루는 대상 하나하나를 말한다. 사용자, 주문, 상품, 리뷰. 대체로 명사이고, 대체로 시스템에 실제로 존재하는 무언가다.

그리고 그 자원에는 이름이 하나씩 붙는다. 그 이름이 URL이다.

plaintext
/users            사용자 컬렉션
/users/1024       1024번 사용자 한 명
/users/1024/orders   그 사용자의 주문 컬렉션
/orders/77        77번 주문 하나

여기서 규칙이라 할 만한 건 두 가지다. 컬렉션은 복수형이고, 개별 자원은 그 뒤에 식별자를 붙인다. /users는 사용자들이고 /users/1024는 그중 하나다.

자원을 어떻게 나누고 어디까지 중첩할지는 Endpoint 설계 편에서 따로 다룬다. 여기서는 “URL은 동작이 아니라 대상을 가리킨다”까지만 잡고 간다.

자원과 표현은 다르다

한 가지 구분을 짚고 가면 뒤가 편해진다. GET /users/1024로 받는 JSON은 1024번 사용자 자체가 아니다. 그 사용자를 JSON이라는 형식으로 그려낸 **표현(representation)**이다.

diagramdiagram

같은 자원을 여러 모양으로 그릴 수 있다. 그래서 HTTP는 “어떤 표현을 원하는지”를 헤더로 말하게 해뒀다.

http
GET /users/1024 HTTP/1.1
Accept: application/json
http
HTTP/1.1 200 OK
Content-Type: application/json

{"id": 1024, "name": "김개발"}

Accept는 클라이언트가 원하는 형식이고, Content-Type은 서버가 실제로 보낸 형식이다. 이 구분이 실무에서 왜 중요하냐면, /users/1024.json처럼 URL에 형식을 박아 넣는 설계를 안 하게 되기 때문이다. 형식은 자원의 일부가 아니라 표현의 문제라서, URL이 아니라 헤더가 다룰 일이다.

참고

요즘 API는 사실상 JSON 하나만 내려주는 경우가 많아서 이 협상이 실제로 쓰이는 일은 드물다. 그래도 개념을 알아두면 도움이 되는 자리가 있다. 파일 다운로드 API에서 같은 자원을 JSON과 CSV로 내려주거나, 에러 응답만 application/problem+json으로 내려주는 식이다. 뒤쪽 이야기는 상태 코드와 에러 응답 편에서 다시 나온다.

메서드에 이미 의미가 있다

URL에서 동사를 걷어냈으니, 이제 동작은 메서드가 말한다. 메서드마다 규격이 정해둔 뜻이 있다.

메서드
GET가져온다. 상태를 바꾸지 않는다GET /orders/77
POST새로 만든다. 서버가 식별자를 정한다POST /orders
PUT통째로 교체한다PUT /orders/77
PATCH일부만 고친다PATCH /orders/77
DELETE지운다DELETE /orders/77

여기서 가장 자주 어기는 게 GET이다. GET은 조회만 해야 한다. 그런데 “간단하니까” 하고 GET /orders/77/cancel 같은 걸 만들면, 크롤러나 브라우저 프리페치가 URL을 한 번 열어보는 것만으로 주문이 취소된다(공격에도 그대로 쓰인다). 링크를 미리 당겨오는 동작은 조회라고 믿고 하는 일이라서, 그 믿음을 어긴 쪽이 사고를 낸다.

안전한가, 멱등한가

메서드를 가르는 축이 둘 있다.

  • 안전(safe) - 상태를 안 바꾼다. GET이 여기 해당한다.
  • 멱등(idempotent) - 여러 번 보내도 결과 상태가 한 번 보낸 것과 같다. GET, PUT, DELETE가 해당하고 POST는 아니다.
diagramdiagram

이 성질이 설계에 바로 걸린다. 네트워크는 새기 때문에 클라이언트는 응답을 못 받으면 재시도한다. 그때 POST만 위험하다. 주문이 둘 생기고 결제가 두 번 나간다.

이 문제를 어떻게 막는지는 Idempotency 글이 통째로 다룬다. HTTP 메서드로 보는 멱등성에서 메서드별 성질을, POST를 멱등하게 만드는 Idempotency Key에서 대책을 본다. 여기서는 “메서드를 고르는 일이 곧 재시도 안전성을 고르는 일”이라는 것만 챙기고 넘어간다.

PUT과 PATCH는 다르다

수정에 메서드가 둘 있어서 헷갈리는데, 차이는 한 줄이다. PUT은 통째로 교체하고 PATCH는 준 것만 고친다.

이름만 바꾸고 싶다고 하자. PATCH는 바꿀 것만 보낸다.

http
PATCH /users/1024
Content-Type: application/json

{"name": "김백엔드"}

PUT은 자원 전체를 보낸다. 보내지 않은 필드는 사라지는 게 원래 뜻이다.

http
PUT /users/1024
Content-Type: application/json

{"name": "김백엔드", "email": "dev@example.com", "nickname": "kdev"}

여기서 PUT에 {"name": "김백엔드"}만 보내면 emailnickname이 비워지는 게 규격에 맞는 해석이다. 실제로는 그렇게 구현하지 않고 준 것만 고치는 서버가 흔한데, 그러면 이름만 PUT이고 동작은 PATCH가 된다.

주의

PUT을 PATCH처럼 구현해두면 클라이언트가 “필드를 비우는 방법”을 잃는다. 값을 지우려고 필드를 빼고 보내면 서버는 “안 건드린다”로 읽기 때문이다. 그러면 결국 “빈 문자열을 보내라” 같은 규칙을 따로 만들어 붙이게 되고, 이건 문서에만 있고 URL에는 안 보이는 약속이라 새 클라이언트가 매번 걸린다. 부분 수정을 하고 싶으면 PATCH라고 이름을 붙이는 게 낫다.

앞 글의 관점으로 말하면, 메서드 선택도 계약이다. 한동안 PUT으로 열어둔 엔드포인트의 의미를 나중에 바꾸면 그것도 깨는 변경이다.

서버는 이전 요청을 기억하지 않는다

REST의 또 하나의 성질이 **무상태(stateless)**다. 서버가 요청 사이에 클라이언트의 상태를 들고 있지 않는다는 뜻이다. 요청 하나하나가 처리에 필요한 정보를 다 담고 온다.

diagramdiagram

“3페이지 다음”처럼 서버가 기억한 위치에 의존하는 설계였다면, 두 번째 요청이 다른 서버로 갔을 때 답이 없다. 그래서 페이지 위치 같은 상태를 요청이 직접 들고 온다. 무상태는 원칙 자체보다 이 결과가 중요하다. 서버를 여러 대로 늘려도 아무 대나 받으면 되고, 한 대가 죽어도 다음 요청은 다른 데서 처리된다.

무상태는 “로그인을 못 한다”는 뜻이 아니다. 사용자 정보를 서버 메모리에 쌓아두지 않고 요청마다 자격 증명을 실어 보낸다는 뜻이다. 그 자격 증명을 어떻게 만들고 검증하는지는 인증의 영역이라 이 시리즈에서 다루지 않는다.

자원으로 안 떨어지는 것들

여기까지 오면 반례가 떠오른다. 주문 취소는 무슨 자원인가. 검색은? 결제 승인은?

정직하게 말하면, 세상 모든 동작이 자원으로 깔끔하게 떨어지지는 않는다. 흔히 쓰는 접근이 셋 있다.

상태를 바꾸는 것으로 본다. 주문 취소는 주문의 상태가 바뀌는 일이니, 자원의 상태 필드를 고치는 것으로 표현한다.

http
PATCH /orders/77
{"status": "CANCELED"}

결과물을 자원으로 본다. 취소가 단순한 필드 변경이 아니라 취소 이력·환불 기록을 남기는 일이라면, 그 기록 자체가 자원이다.

http
POST /orders/77/cancellation

그래도 안 되면 동작을 URL에 쓴다. 검색이 대표적이다. GET /orders?q=...로 되면 그게 낫지만, 조건이 너무 커서 본문에 실어야 한다면 POST /orders/search 같은 모양을 쓰기도 한다.

셋 중 무엇을 고를지는 그 동작이 남기는 게 무엇인가로 보면 대체로 갈린다. 필드 하나만 바뀌면 상태 변경으로, 별도의 기록이 생기면 그 기록을 자원으로 만든다. 억지로 명사를 지어내서 /orders/77/canceler 같은 걸 만드는 것보다는, 예외를 예외로 두고 팀 안에서 일관되게 쓰는 편이 낫다.

그래서 실무에선 어떻게 쓰이나

실무에서 “REST API”라고 부르는 것 대부분은 지금까지 본 것 정도다. 자원 중심 URL + HTTP 메서드 + JSON. 이것만 지켜도 앞의 /getUserList 목록보다 훨씬 오래 버티는 API가 된다.

Spring에서는 이 구조가 애너테이션에 그대로 드러난다.

java
@RestController
@RequestMapping("/users")            // 자원 하나에 컨트롤러 하나
public class UserController {

    @GetMapping                       // GET /users
    public List<UserResponse> list() { ... }

    @GetMapping("/{id}")              // GET /users/1024
    public UserResponse get(@PathVariable Long id) { ... }

    @PostMapping                      // POST /users
    public UserResponse create(@RequestBody @Valid UserCreateRequest req) { ... }

    @PatchMapping("/{id}")            // PATCH /users/1024
    public UserResponse update(@PathVariable Long id, @RequestBody UserUpdateRequest req) { ... }

    @DeleteMapping("/{id}")           // DELETE /users/1024
    public void delete(@PathVariable Long id) { ... }
}

@RequestMapping("/users")가 컨트롤러 하나를 자원 하나에 묶고, 메서드별 애너테이션이 동작을 가른다. 컨트롤러 이름에 getUserList 같은 메서드가 안 보이는 게 정상이다.

참고

학술적으로 정의된 REST에는 여기서 다루지 않은 제약이 더 있다. 대표적인 게 HATEOAS로, 응답 안에 “여기서 갈 수 있는 다음 링크”를 같이 실어 클라이언트가 URL을 하드코딩하지 않게 하자는 것이다. 이 기준으로 채점하는 REST 성숙도 모델도 있어서, 그 잣대로 보면 실무의 대다수 API는 REST가 아니다. 알아둘 값은 있지만, 초중급 단계에서 이 논쟁을 먼저 붙잡으면 정작 매일 하는 결정에 도움이 안 된다. 자원과 메서드로 말하는 것부터 몸에 익히는 편이 실익이 크다.

정리

핵심URL은 대상을 가리키고, 무엇을 할지는 HTTP 메서드가 말한다
동사를 URL에 쓰면 URL이 기능 수만큼 늘고 이름 규칙도 제각각이 된다
자원API가 다루는 대상. 컬렉션은 복수형, 개별 자원은 뒤에 식별자
표현받은 JSON은 자원 자체가 아니라 그 표현. 형식은 URL이 아니라 헤더로
메서드 의미GET은 조회만, POST는 생성, PUT은 교체, PATCH는 부분 수정, DELETE는 삭제
안전·멱등GET은 안전, GET·PUT·DELETE는 멱등, POST는 아니라 재시도가 위험
무상태요청이 필요한 정보를 다 들고 온다. 어느 서버가 받아도 같은 결과
예외자원으로 안 떨어지는 동작은 상태 변경·결과물 자원·동작 URL 순으로

자원으로 말한다는 원칙은 잡았는데, 정작 자원을 무슨 이름으로 어떻게 나눌지는 아직 안 정했다. 목록을 거르고 정렬하고 페이지를 나누는 것도 남았다.

다음 글은 그 자리다. 같은 목록을 주는 URL이 자꾸 늘어나는 상황부터 시작한다.

관련 글