패키지 구조 표준과 컨트롤러 패키지 분리 표준이 컨트롤러가 사는 자리(api/web)를 정했다. 그런데 그 안의 메서드가 어떤 URL을 받을지는 한 번도 정한 적이 없다.
예시 코드마다 각자 알아서 지어왔다.
// 안 A — 도메인 이름을 그대로 접두사로
POST /order/orders
// 안 B — 버전을 박는다
POST /api/v1/orders
// 안 C — 아무 접두사 없이 리소스만
POST /orders
셋 다 “그럴듯”하다. 이 글을 처음 쓸 때 내가 설명용으로 지은 예시는 전부 안 C였고, 그 사실 자체를 아무도 규칙으로 적어둔 적이 없었다. 그런데 이 셋에는 질문 두 개가 섞여 있다 — 도메인 이름을 넣는가(원칙 1)와 버전을 넣는가(원칙 2)다. 원칙 1·2는 각자 안 이름을 A부터 새로 붙이므로, 위 안 A는 원칙 1의 안 B, 위 안 B는 원칙 2의 안 C에 해당한다. 지금의 결론은 위 안 A의 도메인 접두사는 버리고 위 안 B의 버전은 넣는 POST /api/v1/orders다. 처음에는 버전도 버렸다가 그 근거가 틀렸다는 것을 알고 뒤집었다(원칙 2) — 센 개수는 그날 맞았지만, 센 대상이 실제 코드가 아니라 내가 지은 예시였다(무엇이 맞고 무엇이 틀렸는지는 「이 표준을 정하기까지」에서 가른다). 「아직 안 정한 것」 장부의 10번이 이 자리였다 — “도메인 이름이 경로 접두사가 되는지, 복수형을 쓸지, 검색을 GET으로 받을지가 비어 있다.”
먼저: 이미 정해져 있어 다시 고르지 않은 것
라우팅을 정하기 전에 이미 서 있던 것들이 있다. 여기서 갈라두지 않으면 전부 이 글의 결정으로 보인다.
컨트롤러의 자리는 이미 갈라져 있다. 컨트롤러 패키지 분리 표준이 JSON을 내보내는 api와 HTML을 내보내는 web을 형제 패키지로 나눴다. 이 글은 api만 다룬다. web의 라우팅·네이밍은 「아직 안 정한 것」 장부 15번(web 패키지 안 규칙 자체가 없음)이 먼저 닫혀야 하고, 여기서 같이 정하면 SSR 프로젝트도 없이 규칙만 지어내는 꼴이 된다.
목록 조회의 파라미터 계약은 이미 있다. 목록 조회 요청 계약 표준이 page·size·sort를 XxxSearchRequest로 묶고 GET으로 받는 것까지 정했다. 이 글은 그 GET을 어떤 경로에 붙일지만 정한다.
성공 상태 코드는 이미 있다. 성공 응답 상태 표준이 메서드별 상태 코드(POST→201, DELETE→204 등)를 정했다. 이 글은 그 메서드를 어떤 URL에 붙일지만 정한다.
「삭제」는 이미 넷으로 갈려 있다. 애그리거트 삭제 표준이 상태 전이·자식 제거·대량 삭제·애그리거트 루트 제거를 갈라놨고, 흔적이 남는 것은 삭제가 아니라 상태 전이다. 이 글은 그 넷 중 무엇에 DELETE를 붙일지만 정한다 — 범위를 새로 긋지 않는다.
원칙 1. 리소스 경로는 복수 명사로 짓고, 도메인 이름을 반복하지 않는다
먼저 grep으로 실제 쓰임을 셌다. 이 글을 쓴 2026-08-10 시점에 coding-standard/ 아래 예시 코드에서 @PostMapping·@GetMapping으로 URL을 적은 자리 전부다.
@PostMapping("/orders") (9곳)
@GetMapping("/admin/orders") (1곳)
열 곳 전부 복수형이고, 전부 도메인 접두사가 없다. 패키지는 order/api/OrderController.java인데 URL은 /order/orders가 아니라 /orders다. 이 예시들에는 버전이 없었는데, 버전은 원칙 2에서 따로 정한다. 이 원칙이 보는 것은 /api/v1 뒤에 오는 리소스 부분이다.
이 열 곳에는 한계가 있다. 전부 내가 설명용으로 지은 예시라서, 이 개수는 실제 코드가 아니라 내가 예시를 짓는 버릇을 센 것이다. 원칙 2가 바로 이런 개수에 기댔다가 뒤집혔다(「이 표준을 정하기까지」). 그래서 2026-10-20에 실제 두 프로젝트의 컨트롤러 경로를 grep으로 봤다. arimom은 클래스에 경로를 단 컨트롤러 36개가 전부 도메인 이름 없이 복수 명사로 바로 시작했다(review 패키지 → /api/v1/reviews). farm-predict는 갈렸다 — kamis 패키지 아래 컨트롤러 11개가 /api/v1/kamis/corporations처럼 패키지 이름을 리소스 앞에 붙이고, /api/v1/auction/data처럼 단수형도 있다. 이 원칙은 실제 코드 둘 중 하나와 맞고 하나와 어긋난다. 그래서 아래 결론은 개수가 아니라 안끼리의 비교에 기대고, farm-predict의 경로를 이 원칙에 맞출지는 아직 정하지 않았다.
정해야 했던 건 이거였다 — 이 우연을 규칙으로 굳힐 것인가.
안 A — 단수 명사로 짓는다. /order. 영어 문법상 “주문 하나”에 가깝다는 감각은 있다.
버린 이유는 의미가 어긋난다는 것이다. GET /order는 컬렉션 조회(여러 건)인데 이름은 단수다.
안 B — 도메인 이름을 경로에 포함한다. /order/orders. 패키지 경로와 URL이 그대로 대응해서 코드를 찾기 쉬울 것 같다.
버린 이유는 관리자 하위 컨트롤러에서 바로 깨진다는 것이다. OrderAdminController는 이미 /admin/orders를 쓰는데, 도메인 이름까지 넣으면 /order/admin/orders가 된다. 접두사가 두 개가 되는 순간 어느 게 진짜 리소스 이름인지 안 읽힌다. 패키지 이름과 URL 세그먼트를 억지로 1:1 대응시키려는 시도이고, 그 대응이 필요한 순간(디버깅)은 로그의 컨트롤러 클래스명으로 이미 해결된다.
골랐다 — 안 C, 복수 명사 + 도메인 비중복. 안 A가 못 지켜주는 것은 컬렉션이라는 의미이고, 안 B가 못 지켜주는 것은 접두사가 하나로 유지되는 것이다. 처음에는 이것을 고르는 일이 아니라 내 예시에서 10번 반복된 걸 적어 두는 일로 봤다. 그러나 그 10곳은 내가 지은 예시라 근거가 되지 못하고, 이 결론을 받치는 것은 이 두 대비다.
// order/api/OrderController.java
@PostMapping("/api/v1/orders")
public ResponseEntity<...> createOrder(...) { ... }
/api/v1(원칙 2) 뒤에 복수 명사 orders 하나만 온다. order라는 도메인 이름이 한 번 더 끼지 않는다.
대가는 farm-predict처럼 이미 도메인 이름을 경로에 붙인 프로젝트는 이 원칙에 맞추려면 주소를 고쳐야 한다는 것이다. /api/v1/kamis/corporations를 /api/v1/corporations로 바꾸면 그 주소를 부르는 화면도 같이 바꿔야 한다. farm-predict를 실제로 맞출지는 위에서 적었듯 아직 정하지 않았다.
원칙 2. 모든 API 경로는 /api/v1으로 시작한다
처음에 나는 이 원칙을 반대로 정했다 — 「버전을 경로에 넣지 않는다」. 그 근거로 센 개수는 그날 맞았지만, 센 대상이 실제 코드가 아니라 내가 지은 예시였다는 것을 나중에 알았고(경위는 「이 표준을 정하기까지」) 다시 열어 뒤집었다. 그래서 이 원칙에는 옛 결정까지 버린 안으로 남는다.
버전이 왜 필요한지는 장면 하나로 보인다. 휴대폰 앱이 GET /orders/{orderId}의 응답에서 price라는 필드를 읽고 있다고 하자. 서버가 이 필드 이름을 amount로 바꾸면, 이미 사람들 휴대폰에 깔린 옛 앱은 price를 못 찾아 화면이 깨진다. 이렇게 이미 쓰고 있는 쪽이 코드를 바꾸지 않으면 동작이 깨지는 API 변경을 깨는 변경(breaking change)이라고 부른다. 앱은 사용자가 업데이트해야 바뀌므로, 서버는 옛 앱이 다 사라질 때까지 옛 모양도 계속 내줘야 한다.
정해야 했던 건 이거였다 — 그날을 위해 주소 맨 앞에 버전을 미리 둘 것인가.
안 A — 버전을 넣지 않는다. POST /orders. 이 글이 처음 고른 안이다.
얻는 것은 주소가 짧고, 지금은 아무 일도 안 하는 세그먼트가 없다는 것이다. 처음 이 안을 고른 근거는 둘이었다. 하나는 「v2가 필요했던 적이 없다」이고, 이건 지금도 맞다 — 실제 프로젝트 두 곳(arimom, farm-predict)에서 /api/v2부터 /api/v9까지를 찾으면 0건이다. 다른 하나는 「coding-standard/ 예시 중 버전을 쓴 곳이 0곳」이었다. 그날 기준으로 0곳이라는 개수는 맞았지만, 센 대상이 내가 설명용으로 지은 예시였다(「이 표준을 정하기까지」). 그때 눈에 띈 /api/v1/users가 이 시리즈 밖의 옛 글(_posts/javascript/frontend-api-management.md) 하나뿐이라고 보고 넘어갔지만, 실제 프로젝트는 처음부터 /api/v1을 쓰고 있었다.
버린 이유는 깨는 변경이 처음 생기는 날 주소 규칙이 두 벌로 섞인다는 것이다. 그날도 버전 없는 /orders를 그대로 둔 채 /v2/orders를 옆에 열 수는 있다. 그러면 버전 없는 주소가 사실상 v1이 되어, 한 서버에 「버전 없는 주소」와 「버전 붙은 주소」라는 규칙이 두 벌 생긴다. 그걸 하나로 맞추려고 모든 주소를 /api/v1/...로 옮기면 이미 쓰고 있는 화면·앱의 주소를 한꺼번에 바꿔야 한다. 처음부터 넣어 두면 규칙이 섞이지도, 쓰는 쪽 주소를 바꾸지도 않고 /api/v2를 옆에 열면 된다. 그리고 「지금까지 필요 없었다」는 앞으로도 필요 없다는 증거가 아니다. 넣어 두는 비용은 경로 앞 글자 몇 개뿐이다.
안 B — 넣든 안 넣든 프로젝트 안에서 통일만 한다. 반대로 정해도 동작이 같고 통일이 목적인 규칙을 이 저장소는 「약속」이라고 부른다. 이 안은 버전도 약속으로 보고, 한 프로젝트 안에서 한쪽으로만 맞춘다.
얻는 것은 이미 버전 없이 돌고 있는 프로젝트를 고칠 필요가 없다는 것이다. 오늘 당장은 어느 쪽이든 똑같이 동작한다는 점에서 정직하기도 하다. 버린 이유는 새 프로젝트마다 넣을지 말지가 또 갈린다는 것이다. 위의 이유(깨는 변경이 생긴 날 옛 주소를 살려 둔다)는 새 프로젝트에도 똑같이 성립하므로, 프로젝트 수만큼 같은 질문을 다시 묻고 같은 답을 내게 된다. 오늘 동작이 같다고 깨는 변경이 생긴 날에도 같지는 않다 — 그래서 이 원칙은 약속이 아니다.
골랐다 — 안 C, 모든 API 경로는 /api/v1으로 시작한다. 안 A가 못 지켜주는 것은 깨는 변경이 생긴 날 옛 주소를 그대로 살려 둔 채 새 주소를 옆에 여는 것이고, 안 B가 못 지켜주는 것은 그 대비가 프로젝트마다 갈리지 않는 것이다.
GET /api/v1/orders/{orderId} 이미 깔린 옛 앱이 계속 부른다 — price 필드
GET /api/v2/orders/{orderId} 업데이트한 새 앱부터 부른다 — amount 필드
위 줄은 지금 있는 주소다. 아래 줄은 깨는 변경이 생긴 날 옆에 더하는 주소이고, 위 줄은 지우지 않는다. 옛 앱은 아무것도 바꾸지 않아도 계속 돈다. /api까지 붙인 모양은 두 실제 프로젝트가 이미 쓰는 모양을 그대로 따른 것이다. v1과 달리 /api 세그먼트를 두는 이유는 따로 정하지 않았다. 다만 원칙 5의 관리자 보안 설정(/api/*/admin/**)이 주소가 /api로 시작한다는 데 기대고 있어서, /api를 빼면 원칙 5도 같이 바뀐다.
이 글의 원칙 3부터 나오는 예시는 리소스 부분을 보이려고 /api/v1을 빼고 적는다. 실제 경로는 /api/v1/orders/{orderId}/cancel처럼 언제나 버전이 맨 앞이고, 관리자 경로도 /api/v1/admin/orders처럼 버전이 /admin보다 앞이다. 그래서 관리자 주소는 더 이상 /admin으로 시작하지 않고, 원칙 5의 관리자 보안 설정도 /admin/**가 아니라 /api/*/admin/**로 건다.
대가는 지금은 아무 일도 안 하는 세그먼트를 모든 경로에 둔다는 것이다. /api/v2를 언제 열고 옛 /api/v1을 언제 닫을지는 아직 정하지 않았다 — 실제 깨는 변경이 처음 생길 때 정한다.
원칙 3. 수정은 PUT 하나로 받고 PATCH를 열지 않는다
여기는 예시가 갈려 있었다. 오래된 글(_posts/spring/)은 PUT /users/{userId}/password처럼 부분 수정에도 PUT을 썼는데, RFC 의미로는 PATCH가 더 맞는다.
안 A — PATCH를 연다. 전체 교체는 PUT, 일부 필드 변경은 PATCH로 가른다. RFC 의미에는 더 맞다.
버린 이유는 PATCH를 여는 것이 메서드 하나를 추가하는 일로 안 끝난다는 것이다. 부분 업데이트 바디 스펙(JSON Merge Patch인지, 필드 생략이 “안 바꾼다”인지 “null로 지운다”인지)까지 같이 정해야 완성되는데, 지금 이 저장소에 그런 요구가 예시로 한 번도 안 나왔다. JSON Merge Patch는 PATCH 요청의 본문에 “바뀐 필드만” 적어 보내고 그 필드만 덮어쓰도록 정한 또 다른 RFC다. 없는 요구에 스펙부터 짓는 셈이다.
골랐다 — 안 B, PUT만 쓴다. 대상을 좁힌 하위 경로(PUT /users/{userId}/password)로 “부분 수정”이라는 개념 자체를 없앤다 — 그 경로가 다루는 필드가 곧 그 경로의 전체다. 대가는 필드가 여러 개인 리소스는 경로가 늘어난다는 것이다. /users/{userId} 하나로 안 끝나고 세분화된 하위 경로가 필요할 수 있다.
단, 상태 필드는 이 하위 경로로 다루지 않는다. PUT /orders/{orderId}/status는 모양이 같아 보이지만 상태 전이라서 원칙 6으로 간다.
원칙 4. DELETE는 애그리거트 루트 제거에만 쓴다
이 원칙에서 루트인지 자식인지로
DELETE를 가르는 부분만 도메인 드리븐 전용이다 — 3계층에는 애그리거트가 없다.
표시가 가리키는 부분은 이것이다. 행을 정말 없애더라도 애그리거트의 자식(주문 항목)이면 DELETE를 따로 열지 않고 루트를 고치는 요청으로 보내는데, 3계층에는 무엇이 루트이고 무엇이 자식인지 가를 기준이 없다. 흔적이 남는 동작(탈퇴·취소)을 DELETE가 아니라 POST + 동사 세그먼트로 받는 것과, DELETE는 아무도 참조하지 않는(다른 데이터가 그 ID를 들고 있지 않은) 행을 정말 없앨 때만 쓰는 것은 양쪽에 성립한다. 표시를 다는 기준은 아키텍처 소속 표준에 있다.
DELETE를 어디까지 쓰는지는 예시가 아예 없었다. 그래서 “물리 삭제냐 논리 삭제냐”로 물을 뻔했는데, 그 축을 꺼내기 전에 애그리거트 삭제 표준이 이미 답을 갖고 있었다.
그 표준의 출발점이 “삭제”라는 한 단어가 네 가지를 가리킨다는 발견이었다 — 상태 전이·자식 제거·대량 삭제·애그리거트 루트 제거. 그리고 판단 기준이 이렇게 적혀 있다.
흔적이 남아야 하나? → 그렇다 → 삭제가 아니다. 상태 전이로 다룬다
그러니 여기서 정할 것은 DELETE의 범위가 아니라, 이미 갈라진 넷 중 어디에 붙이느냐뿐이었다. 선택지가 하나뿐이었던 셈이고, 그건 삭제 표준이 나머지를 미리 지워놨기 때문이다.
골랐다 — 루트 제거에만. 삭제 표준이 「삭제」라고 부르는 것, 즉 아무도 참조하지 않는 애그리거트 루트의 행을 정말 없애는 것이다. 흔적이 남는 것은 상태 전이이므로 원칙 6(동사 세그먼트)으로 간다. 행이 정말 지워지더라도 자식 제거(주문에서 주문 항목 빼기)에는 DELETE를 따로 열지 않는다 — 삭제 표준이 자식 제거를 루트를 고치면 빠진 자식이 함께 지워지는 일로 다루므로, 루트의 수정(원칙 3의 PUT)으로 간다. 「참조 없는 행에만 DELETE」와 「루트 제거에만 DELETE」의 차이가 이 한 줄이다.
GET /orders 목록 조회 · 검색
POST /orders 생성
GET /orders/{orderId} 단건 조회
PUT /orders/{orderId} 수정 (원칙 3)
POST /orders/{orderId}/cancel 취소 — 흔적이 남으므로 상태 전이다 (원칙 6)
DELETE /cart-items/{cartItemId} 루트 제거 — 아무도 참조하지 않는다
여기서 {orderId}·{cartItemId}처럼 중괄호로 감싼 부분은 경로 변수다 — 실제 요청에서는 /orders/7처럼 그 자리에 구체적인 값이 들어간다. Controller 메서드가 @PathVariable로 그 값을 꺼내 받는다.
주문에는 DELETE가 없다. 삭제 표준이 “다른 도메인이 ID로 참조하는 애그리거트는 지우지 않는다”고 했고 Repository에 delete조차 두지 않는다. 그러니 붙일 URL도 없다.
대가는 URL이 흔적의 유무를 드러낸다는 것이다. 처음엔 이걸 감춰야 할 내부 구현으로 봤는데 아니었다 — 취소된 주문은 GET /orders/{orderId}가 200을 주고, 지워진 장바구니 항목은 404를 준다. 클라이언트가 알아야 하는 차이라서 URL에 드러나는 게 맞다.
원칙 5. 역할이 다른 하위 컨트롤러는 리소스 앞에 접두사를 붙인다
OrderAdminController가 이미 /admin/orders를 쓰고 있었다. /orders/admin이 아니라 앞이다. 이유를 적어둔 적은 없어서, 왜 그렇게 됐는지부터 봐야 했다.
안 A — 리소스 뒤에 붙인다. /orders/admin. “orders라는 리소스의 admin 하위 자원”으로 읽힌다.
버린 이유는 관리자 주소를 시큐리티 설정 한 줄로 모을 수 없다는 것이다. 관리자 컨트롤러는 인증·인가 규칙이 달라서, 관리자 주소만 골라 따로 시큐리티 설정(Spring Security에서 “이 경로는 이런 규칙으로 보호한다”고 정해 두는 것)을 걸어야 한다. 그런데 뒤에 붙이면 admin이 놓이는 칸이 리소스 깊이마다 달라진다 — /orders/admin에서는 리소스 부분의 둘째 칸, 원칙 8이 허용하는 중첩 /orders/{orderId}/items/admin에서는 넷째 칸이다. 칸마다 줄을 적으면 깊이가 늘 때마다 줄을 더해야 하고, 빠뜨린 깊이의 관리자 주소는 관리자 규칙 밖에 남는다. 「어느 깊이든 admin이 있으면」을 한 줄로 적으려면 admin 앞과 뒤 양쪽에 「몇 칸이든」을 뜻하는 **를 둬야 하는데(/**/admin/**), Spring이 이 주소 모양을 읽는 PathPattern은 **를 한 모양에 하나만, 맨 앞이나 맨 끝에만 허용한다(근거는 아래 설정 안 B 뒤 소스 문단). 앞에 붙이면 admin이 늘 같은 칸에 와서 이 문제가 없다.
골랐다 — 안 B, 리소스 앞에 붙인다. 접두사가 리소스 앞에 오면 관리자 주소가 전부 같은 자리에 admin을 갖게 되어, 시큐리티 설정 한 줄로 관리자 전용 규칙을 걸 수 있다. 접두사가 인가 경계와 그대로 겹친다. 대가는 같은 리소스가 URL 두 곳(/orders, /admin/orders)에 나뉘어 산다는 것이고, 그건 컨트롤러 패키지 분리 표준이 이미 감수하기로 한 것과 같은 대가다 — 컨트롤러 자체가 OrderController/OrderAdminController로 나뉘어 있다.
처음 쓴 한 줄은 원칙 2를 뒤집으면서 맞지 않게 됐다. 그 줄은 /admin/**였다. 원칙 2가 모든 경로를 /api/v1으로 시작하게 하자 실제 관리자 주소는 /api/v1/admin/orders가 됐다. 이 주소는 /admin으로 시작하지 않으니 /admin/**에 걸리지 않는다. Spring Security는 설정 줄을 위에서부터 보고 처음 맞는 줄의 규칙만 쓰므로, 관리자 줄에 안 걸린 요청은 그 아래의 일반 사용자 줄(예: 로그인만 했으면 들이는 줄)에 걸려 관리자 권한 검사 없이 통과한다. 컴파일은 이걸 알려 주지 않는다 — 설정은 문자열이라 틀려도 그냥 아무 주소에도 안 맞을 뿐이다.
정해야 했던 건 이거였다 — 버전이 앞에 붙은 관리자 주소를 시큐리티 설정 몇 줄로 막을 것인가.
// 설정 안 A — 지금 버전을 그대로 적는다
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
// 설정 안 B — 버전 자리를 * 로 비운다
.requestMatchers("/api/*/admin/**").hasRole("ADMIN")
// 설정 안 C — 주소를 /admin 으로 시작하게 바꾸고 설정은 그대로 둔다
.requestMatchers("/admin/**").hasRole("ADMIN") // 주소는 /admin/api/v1/orders
한 줄씩 보면, requestMatchers(...)는 「이 모양의 주소로 온 요청에」, hasRole("ADMIN")은 「관리자 역할을 가진 사용자만 들인다」다. 주소 모양의 **는 그 자리부터 끝까지 몇 칸이 오든 다 맞는다는 뜻이고, *는 /와 / 사이 한 칸만 맞는다는 뜻이다(글로브와 같은 기호다).
설정 안 A — 지금 버전을 그대로 적는다. /api/v1/admin/**다. 얻는 것은 지금 있는 주소에 정확히 맞고, 읽는 사람이 기호를 해석할 필요가 없다는 것이다. 버린 이유는 /api/v2를 여는 날 줄을 하나 더 넣어야 한다는 것이다. 원칙 2는 깨는 변경이 생기면 /api/v2를 옆에 연다고 정했다. 그날 /api/v2/admin/orders를 만들고 이 설정에 /api/v2/admin/**를 더하는 것을 빠뜨리면, v2 관리자 API가 관리자 권한 검사 없이 나간다. 위에서 겪은 것과 같은 구멍이 버전을 열 때마다 다시 생긴다.
설정 안 C — 주소를 /admin/api/v1/...로 바꾼다. 얻는 것은 원래 설정 /admin/**를 한 글자도 안 바꾸고 쓸 수 있다는 것이다. 버린 이유는 원칙 2의 「모든 API 경로는 /api/v1으로 시작한다」가 관리자 주소에서만 깨진다는 것이다. 주소 규칙에 예외가 하나 생기고, 실제 두 프로젝트의 관리자 주소와도 달라진다 — 2026-10-20에 grep해 보니 관리자 경로를 단 컨트롤러가 arimom은 29개 파일, farm-predict는 8개 파일이었고 전부 /api/v1/admin/...으로 시작했다. 보안 설정 한 줄을 지키려고 주소 규칙을 바꾸는 셈이다.
골랐다 — 설정 안 B, 버전 자리를 비워 둔다. /api/*/admin/**다. *가 버전 칸 하나를 비워 두므로 /api/v1/admin/orders도, 나중에 열 /api/v2/admin/orders도 이 한 줄에 걸린다. 설정 안 A가 못 지켜주는 것은 버전이 늘어도 줄을 더할 필요가 없는 것이고, 설정 안 C가 못 지켜주는 것은 「모든 API 경로는 /api/v1으로 시작한다」는 주소 규칙이다. 이 원칙이 처음 노린 「관리자는 한 줄로 막는다」가 버전이 늘어도 그대로 지켜진다.
*가 한 칸만 맞는다는 것은 내가 실행해 잰 것이 아니다. 이 저장소 open-source/에 받아 둔 Spring Framework 7.1.0-SNAPSHOT 소스(spring-web의 org/springframework/web/util/pattern/)를 읽어 확인했다. PathPattern.java 44행의 설명이 「*는 한 경로 칸 안에서 0개 이상의 글자에 맞는다」이고, *를 맡는 WildcardPathElement.java는 54행에서 경로 칸을 하나만 넘긴 뒤 다음 패턴으로 넘어가며, 68행 주석대로 경로 중간의 *는 빈 칸에는 맞지 않는다. Spring Security 쪽은 PathPatternRequestMatcher.java가 322행에서 주소 문자열을 이 PathPattern으로 만들고 143행에서 그것으로 요청 주소를 비교한다. requestMatchers("...")에 넘긴 문자열이 이 매처로 이어진다는 것은 공식 문서에서 읽은 것이고, 실제 요청을 보내 막히는지는 재지 않았다. 안 A(리소스 뒤에 붙인다)를 버린 근거인 **의 자리 제한도 같은 소스에서 읽었다 — InternalPathPatternParser.java 256~265행이 한 모양에 **가 둘이거나, **가 맨 앞도 맨 끝도 아닌 자리에 있으면 예외를 던진다.
대가는 /api/ 바로 다음 칸이 무엇이든 그 뒤가 admin이면 관리자 규칙이 걸린다는 것이다. 버전이 아닌 다른 말이 그 칸에 오는 경로를 만들지 않는 한 문제가 되지 않고, 원칙 2가 그 칸을 버전으로 정해 두었다.
원칙 6. 상태 전이는 동사를 마지막 경로 세그먼트로 쓴다
이 원칙에서 동사를 무엇으로 짓는가만 도메인 드리븐 전용이다 — 3계층에는
Domain이 없어 의도 기반 메서드에서 이름을 가져올 자리가 없다.
동사 세그먼트를 쓴다는 규칙 자체는 양쪽에 성립한다. 기준은 아키텍처 소속 표준에 있다.
원칙 4가 DELETE를 루트 제거로 좁히면서 흔적이 남는 동작이 전부 이리로 넘어왔다. 주문 취소, 회원 탈퇴, 익명화 — 애그리거트 삭제 표준이 상태 전이로 갈라놓은 것들이다. 여기에 원래부터 CRUD로 표현이 안 되던 것(배송 시작, 승인)이 더해진다.
취소는 생성도 삭제도 수정도 아니다. 수정처럼 보이지만 “가능한 상태 전이인가”라는 별도 검증이 붙는다.
안 A — 상태 변경도 PUT으로 욱여넣는다. PUT /orders/{orderId} + 바디에 {"status": "CANCELLED"}.
버린 이유는 모든 상태 전이가 같은 자격을 갖는 게 아니라는 것이다. PENDING → CANCELLED는 되지만 SHIPPED → CANCELLED는 안 된다. 이걸 “필드 하나 바꾸는 PUT”으로 표현하면 어떤 전이가 허용되는지가 바디 값에 묻히고, 422인지 400인지도 애매해진다.
안 B — 전이 자체를 리소스로 만든다. POST /orders/{orderId}/cancellations. REST 순수주의 관점에서는 가장 “리소스답다.”
버린 이유는 이름이 억지스럽다는 것이다. 배송 시작처럼 명사형이 안 나오는 동작이 많다(ship을 shipments로 욱여넣어야 하나). 매 상태 전이마다 명사를 찾아야 하는 부담이 생긴다.
골랐다 — 안 C, 동사를 마지막 세그먼트로.
POST /orders/{orderId}/cancel 주문 취소
POST /users/{userId}/withdraw 회원 탈퇴
성공 응답 상태 표준의 예시가 이미 이 모양이었다 — 201이 아니라 200으로 응답해야 하는 이유가 “새 리소스를 안 만들어서”였는데, 그 근거 자체가 이 모양의 URL을 전제하고 있었다.
동사는 Domain의 의도 기반 메서드 이름을 그대로 쓴다. 삭제 표준이 order.cancel()·user.anonymize()처럼 의도로 이름 짓게 했으므로, URL 세그먼트와 도메인 메서드가 같은 단어가 된다. 이름을 두 번 짓지 않는다.
대가는 URL이 REST의 “명사만 쓴다” 원칙에서 벗어난다는 것이다. 그건 감수한다 — 상태 전이는 애초에 리소스 생성이 아니라서, 억지로 명사로 바꾸면 의미가 더 흐려진다.
원칙 7. 경로 변수 이름은 도메인을 접두로 붙인다
지금까지 예시가 갈려 있었다. 오래된 글은 {orderId}·{userId}를 썼고, 코딩 스탠다드 시리즈 안의 한 예시(http-success-response-standard의 반려된 안을 설명하던 코드)는 {id}를 썼다.
안 A — {id}로 통일한다. 짧고 모든 리소스에 똑같이 쓸 수 있다.
버린 이유는 중첩 경로에서 충돌한다는 것이다. 원칙 8에서 중첩을 허용하면 /orders/{id}/items/{id}처럼 같은 이름이 두 번 나온다. Spring이 @PathVariable을 이름으로 바인딩하므로 이러면 억지로 @PathVariable("orderId") String id처럼 코드에서만 구분해야 한다.
골랐다 — 안 B, {orderId}처럼 도메인을 접두로. 중첩 경로에서 자동으로 구분되고, DTO 필드명과도 그대로 일치한다.
GET /orders/{orderId}
GET /orders/{orderId}/items/{itemId}
대가는 이름이 조금 길어진다는 것이다.
원칙 8. 중첩 리소스는 1단계까지만 허용한다
부모 없이는 존재할 수 없는 자식(주문 없는 주문 항목은 없다)을 URL에 어떻게 표현할지가 남아 있었다.
안 A — 중첩을 아예 안 연다. /order-items?orderId={orderId}처럼 독립 리소스 + 쿼리 파라미터로만 표현한다. ?orderId={orderId}는 주소 끝에 물음표를 붙이고 그 뒤에 조건값을 적는 문법이다 — 실제 요청에서는 /order-items?orderId=7처럼 {orderId} 자리에 값이 들어간다.
얻는 것은 경로 깊이가 항상 1단계로 고정된다는 것이다. 버린 이유는 부모-자식 관계가 URL에서 안 보인다는 것이다. /order-items만 보면 이게 주문에 종속된 자원인지 독립된 자원인지 이름만으로는 모른다.
안 B — 깊이 제한 없이 연다. /orders/{orderId}/items/{itemId}/reviews/{reviewId}처럼 관계가 이어지는 만큼 계속 중첩한다.
버린 이유는 경로가 실제로 다뤄본 적 없는 깊이까지 열려 있다는 것이다. 클라이언트가 항상 최상위 부모의 id부터 가지고 있어야 하는 부담도 깊이에 비례해서 커진다.
골랐다 — 1단계까지만. /orders/{orderId}/items는 되지만, 그 항목의 하위 자원은 다시 평탄화한다(/order-items/{orderItemId}/reviews). 안 A가 못 지켜주는 건 관계의 가시성이고, 안 B가 못 지켜주는 건 경로 길이의 상한이다. 대가는 1단계를 넘는 관계는 어디서 끊을지를 매번 판단해야 한다는 것이다 — 이건 규칙이 아니라 감각이라, 다음에 실제로 3단 이상 중첩이 필요한 리소스가 나오면 이 원칙을 다시 열어야 한다.
원칙 9. 복수 단어 리소스명은 kebab-case로 쓴다
지금까지 예시는 전부 한 단어(orders, users)라 이 문제가 드러난 적이 없었다. 원칙 8에서 order-items가 먼저 나와버려서 여기서 정하고 넘어가야 했다.
안 A — 단일 명사로 줄인다. 여러 단어를 조합하는 대신 도메인 자체를 더 잘게 쪼개 한 단어짜리 리소스로 만든다.
버린 이유는 도메인을 URL 편의를 위해 쪼개는 게 본말전도라는 것이다. 주문 항목이 자연스러운 이름인데 URL에 맞추겠다고 억지 단어를 만들 이유가 없다.
골랐다 — kebab-case. /order-items. HTTP 헤더·쿼리 파라미터 관례와 같고, 대소문자 구분 없는 URL에서 camelCase(orderItems처럼 단어 경계를 대문자로 표시하는 방식)는 서버·브라우저·프록시마다 다르게 다뤄질 위험이 있다.
판단 기준 정리
| 질문 | 답 | 왜 |
|---|---|---|
| 리소스 경로 형태 | 복수 명사 | GET /orders는 여러 건을 돌려주므로 컬렉션 의미와 일치한다. 단수는 이 의미와 어긋난다 |
| 도메인 이름 포함 여부 | 안 넣는다 | 관리자 접두사와 겹치면 접두사가 두 개가 된다 |
| 버전 접두사 | 모든 경로를 /api/v1으로 시작한다 |
깨는 변경이 생기면 옛 주소를 살린 채 /api/v2를 옆에 연다. 안 넣고 시작하면 규칙이 두 벌로 섞이고, 하나로 맞추려면 쓰는 쪽 주소를 한꺼번에 바꿔야 한다 |
| 목록·검색 | GET | 목록 조회 요청 계약 표준이 이미 결정 |
| 생성 | POST | — |
| 전체·부분 수정 | PUT 하나로 | PATCH는 부분 업데이트 바디 스펙까지 필요해 미룬다 |
DELETE의 범위 |
애그리거트 루트 제거만 | 흔적이 남으면 삭제 표준이 이미 상태 전이로 갈라놨다 |
| 흔적이 남는 삭제(탈퇴·취소) | DELETE가 아니라 동사 세그먼트 |
그건 삭제가 아니다. 「소프트 삭제」라는 말을 쓰지 않는다 |
| 관리자 등 하위 컨트롤러 | 리소스 앞에 접두사 | 인가 경계와 겹친다 |
| 관리자 시큐리티 설정 | /api/*/admin/** 한 줄 |
*가 버전 칸 하나를 비워 v1·v2를 한 줄로 막는다. /admin/**는 /api/v1/admin/...에 안 걸린다 |
| 상태 전이 | POST /orders/{orderId}/{동사} |
새 리소스를 안 만든다는 사실이 URL에 드러난다 |
| 그 동사를 무엇으로 | Domain의 의도 기반 메서드 이름 |
이름을 두 번 짓지 않는다 |
| 경로 변수 이름 | {orderId} 등 도메인 접두 |
중첩 경로에서 충돌하지 않는다 |
| 중첩 리소스 | 1단계까지만 | 관계 가시성과 경로 길이의 타협 |
| 복수 단어 리소스명 | kebab-case | URL 표준 관례, 대소문자 위험 없음 |
이 표준을 정하기까지
시작은 grep이었다. 장부 10번을 열자마자 “정해야 하나”보다 먼저 “이미 정해져 있는 거 아닌가”를 물었다. coding-standard/ 아래 @PostMapping·@GetMapping 예시를 전부 세니 열 곳 모두가 복수 명사 + 도메인 비중복이었고, 어긋난 예시가 하나도 없었다. 규칙을 새로 고르는 게 아니라 이미 열 번 반복된 걸 적어두는 일이라는 걸 거기서 알았다. 다만 그 열 곳은 내가 지은 예시였다. 같은 한계를 가진 개수가 원칙 2의 근거였다는 것이 2026-09-16에 드러났고, 원칙 2를 실제로 뒤집은 것은 2026-10-20이다(아래).
그런데 grep이 끝나자 진짜 빈틈이 따로 남았다. 복수형·도메인 접두는 예시가 있어서 확인만 하면 됐는데, 경로 변수 이름은 예시 자체가 갈려 있었다({orderId} vs {id}) — 이건 물어서 정할 문제였다.
PUT과 PATCH를 물으면서 그동안 안 보이던 것도 하나 나왔다. 부분 수정을 열려면 부분 업데이트 바디 스펙까지 정해야 하는데, 이 저장소에 그 요구가 나온 적이 없었다. PATCH를 여는 것 자체가 아직 근거 없는 확장이라 PUT 하나로 좁혔다.
원칙 8과 9는 순서가 뒤바뀌면서 나왔다. 중첩 리소스를 먼저 정하다가 /order-items라는 예시가 튀어나왔고, 그제서야 “여러 단어 리소스명을 어떻게 쓸지” 자체가 정한 적 없는 문제라는 걸 알았다. 중첩을 묻지 않았으면 kebab-case 문제는 한동안 안 보였을 것이다.
원칙 6에서 뜻밖의 연결을 봤다. 성공 응답 상태 표준이 “POST /orders/{id}/cancel은 새 리소스를 안 만드니 201이 아니다”라고 적어뒀을 때, 그 글은 상태 코드만 얘기하고 있었다. 그런데 그 논리가 성립하려면 URL이 애초에 동사-서픽스 모양이어야 했다. 다른 글이 상태 코드를 정하면서 라우팅 규칙을 이미 전제로 깔아뒀던 셈이고, 이 글은 그 전제를 뒤늦게 명시적으로 세운 것이다.
그리고 한 번 커밋한 뒤에야 모순을 만들었다는 걸 알았다. 삭제를 “물리냐 소프트냐”로 물었는데 그 축이 이 저장소에 없었다. 애그리거트 삭제 표준이 이미 “삭제”를 넷으로 갈라놨고, 흔적이 남는 것은 삭제가 아니라 상태 전이였다. 「소프트 삭제」를 grep하니 저장소 전체에서 이 글이 처음 쓴 말이었다 — 일반 통용어를 끌어와, 삭제 표준이 애써 가른 넷을 도로 둘로 뭉갠 것이다.
회원 탈퇴 하나로 깨졌다. 원칙 6은 “상태 전이니까 POST /users/{userId}/withdraw“라 하고, 원칙 4는 “소프트 삭제니까 DELETE /users/{userId}“라 했다. 같은 동작에 두 URL이 지시된다. 원칙 4의 DELETE를 루트 제거로 좁혀 닫았고, 그러자 흔적이 남는 동작이 전부 원칙 6으로 모여 두 원칙의 경계가 오히려 선명해졌다.
남는 교훈은 답이 아니라 질문 쪽에 있다. 선택지를 두 개 놓고 골랐는데도 모순이 났다. 질문의 축이 기존 용어 밖에 있으면 어느 답을 골라도 어긋난다. 안을 나열하기 전에 그 축이 이 저장소에 있는 말인지부터 grep했어야 했다.
한 달쯤 뒤, 원칙 2의 근거가 틀렸다는 것이 드러났다(#148). 2026-09-16 증명 후보를 훑던 에이전트가 「버전을 넣지 않는다」와 어긋나는 예시를 모순 후보로 올렸다. grep -rn '/api/v1' _posts/coding-standard를 돌리니 표준 글 6편 11줄이 /api/v1을 쓰고 있었다. git 기록을 따라가 보니 이 글을 쓴 날에는 coding-standard/ 예시에 /api/v1이 정말 없었다. 하지만 그 예시들은 내가 설명용으로 지은 것이었고, 8월 18일부터 실제 프로젝트 코드를 옮겨 온 표준 글들이 하나씩 /api/v1을 들고 들어왔다. 실제 프로젝트를 보니 arimom은 운영 컨트롤러 36개 전부, farm-predict는 라우트 컨트롤러 전부가 /api/v1이었다. 내가 센 것은 실제 코드가 아니라 내가 지은 예시였고, 실제 코드는 처음부터 반대로 하고 있었다. 원래 문장(「열 곳 중 0곳이고, 그 한 곳조차 시리즈 밖」)에는 세 가지가 섞여 있었다. 첫째, 0곳이라는 개수는 그날 기준으로 맞았다. 둘째, 같은 문장이 「0곳」이라 해 놓고 바로 「그 한 곳」이라고 써서 문장 안에서 어긋났다 — 「한 곳」은 시리즈 밖 옛 글의 /api/v1/users를 가리켰다. 셋째, 센 대상이 실제 코드가 아니라 내가 지은 예시였다. 원칙 2를 뒤집게 만든 것은 셋째다.
그래서 2026-10-20에 원칙 2를 다시 열었고, 뒤집었다. 「v2가 필요했던 적이 없다」는 여전히 맞았다 — 두 프로젝트 모두 /api/v2 이상은 0건이다. 그래서 질문이 「지금까지 필요했나」에서 「필요해지는 날 무엇이 달라지나」로 바뀌었다. 버전 없이 시작하면 그날 버전 없는 주소 옆에 /v2를 열어 규칙이 두 벌로 섞이거나, 하나로 맞추려고 이미 쓰고 있는 화면·앱의 주소를 한꺼번에 바꿔야 한다. 처음부터 넣어 두면 옛 주소를 살린 채 /api/v2를 옆에 열면 된다. 「프로젝트마다 통일만 하자」는 중간 안도 봤지만, 새 프로젝트마다 같은 질문이 다시 갈리고 그 답은 매번 같아서 버렸다. 그 김에 원칙 1의 개수(9와 10이 섞여 있던 것)도 다시 세어 10으로 맞췄다.
원칙 2를 뒤집자 원칙 5의 보안 설정이 조용히 어긋났다. 고친 글을 주니어 독자 검토에 넘겼더니, 원칙 5가 여전히 「/admin/** 한 줄로 관리자를 막는다」고 적고 있는데 관리자 주소는 이제 /api/v1/admin/orders라서 그 줄에 걸리지 않는다는 지적이 나왔다. 원칙 2를 고치면서 원칙 5는 손대지 않았는데, 원칙 5는 주소가 /admin으로 시작한다는 데 기대고 있었다. 그대로 두었으면 관리자 API가 관리자 권한 검사 없이 나갔다. 세 안을 놓고 /api/*/admin/**로 맞췄다 — /api/v1/admin/**는 /api/v2를 열 때 줄을 더하다 빠뜨릴 수 있고, 주소를 /admin/api/v1/...로 바꾸면 방금 정한 「모든 API 경로는 /api/v1으로 시작한다」가 관리자 주소에서만 깨진다. 한 원칙을 뒤집을 때는 그 원칙이 만든 주소 모양에 기대는 다른 원칙까지 같이 봐야 했다.
같은 스캔이 원칙 4의 표시가 과하다는 것도 잡았다(#147). 이 글 다음에 쓴 아키텍처 소속 표준 원칙 4는 「원칙 전체가 아니라 막히는 부분만 지목한다」고 정했는데, 이 글 원칙 4는 「도메인 드리븐에만 성립한다」를 원칙 전체에 달아 두었다. 원칙 4 안을 나눠 보니 흔적이 남는 동작을 POST + 동사로 받는 것과 DELETE를 아무도 참조하지 않는 행에만 쓰는 것은 3계층에서도 그대로 되고, 루트 제거와 자식 제거를 가르는 부분만 애그리거트가 있어야 했다. 그래서 표시를 그 부분으로 좁혔다. 버린 안은 둘이다. 하나는 소속 표준 쪽을 이 글에 맞춰 「통째로 표시해도 된다」고 고치는 것이다 — 그러면 3계층 프로젝트가 원칙 4를 통째로 건너뛰어, 바로 위 「회원 탈퇴」 문단에서 겪은 혼란(탈퇴에 DELETE를 붙인다)이 3계층에서 되살아난다. 다른 하나는 「통째로 막히면 통째로 적는다」는 예외 조항을 소속 표준에 더하는 것인데, 통째로 막히는 원칙이 한 건도 없는 채로 조항부터 만드는 셈이라 버렸다.
정리
- 모든 API 경로는
/api/v1으로 시작한다. 깨는 변경이 생기면 옛 주소를 살린 채/api/v2를 옆에 연다. 안 넣고 시작하면 그날 버전 없는 주소와/v2가 섞이고, 하나로 맞추려면 쓰는 쪽 주소를 한꺼번에 바꿔야 한다 - 리소스 경로는 복수 명사로 짓고 도메인 이름을 반복하지 않는다.
/api/v1/orders,/api/v1/order/orders아님 - HTTP 메서드는 CRUD와 1:1이다. 조회/검색=GET, 생성=POST, 수정(전체·부분 모두)=PUT
DELETE는 애그리거트 루트 제거에만 쓴다. 흔적이 남으면 삭제가 아니라 상태 전이고, 동사 세그먼트로 간다. 「소프트 삭제」라는 말을 쓰지 않는다 — 이 저장소는 삭제를 넷으로 가른다- 역할이 다른 하위 컨트롤러는 리소스 앞에 접두사를 붙인다.
/api/v1/admin/orders, 인가 경계와 접두사가 겹친다. 관리자 시큐리티 설정은/api/*/admin/**한 줄이다 — 버전이 늘어도 줄을 더하지 않는다 - 상태 전이는 동사를 마지막 세그먼트로 쓴다.
POST /orders/{orderId}/cancel, 동사는Domain메서드 이름 그대로 - 경로 변수는 도메인을 접두로 붙인다.
{orderId}, 중첩에서 안 부딪힌다 - 중첩 리소스는 1단계까지만. 그 이상은 평탄화한다
- 여러 단어 리소스명은 kebab-case.
/order-items
자신만의 철학을 만들어가는 중입니다.
댓글남기기