패키지 구조 표준컨트롤러 패키지 분리 표준이 컨트롤러가 사는 자리(api/web)를 정했다. 그런데 그 안의 메서드가 어떤 URL을 받을지는 한 번도 정한 적이 없다.

예시 코드마다 각자 알아서 지어왔다.

// 안 A — 도메인 이름을 그대로 접두사로
POST /order/orders

// 안 B — 버전을 박는다
POST /api/v1/orders

// 안 C — 아무 접두사 없이 리소스만
POST /orders

셋 다 “그럴듯”하다. 그런데 실제로 저장소에 있는 예시는 압도적으로 안 C다. 그 사실 자체를 아무도 규칙으로 적어둔 적이 없다. 「아직 안 정한 것」 장부의 10번이 이 자리였다 — “도메인 이름이 경로 접두사가 되는지, 복수형을 쓸지, 검색을 GET으로 받을지가 비어 있다.”


먼저: 이미 정해져 있어 다시 고르지 않은 것

라우팅을 정하기 전에 이미 서 있던 것들이 있다. 여기서 갈라두지 않으면 전부 이 글의 결정으로 보인다.

컨트롤러의 자리는 이미 갈라져 있다. 컨트롤러 패키지 분리 표준이 JSON을 내보내는 api와 HTML을 내보내는 web을 형제 패키지로 나눴다. 이 글은 api만 다룬다. web의 라우팅·네이밍은 「아직 안 정한 것」 장부 15번(web 패키지 안 규칙 자체가 없음)이 먼저 닫혀야 하고, 여기서 같이 정하면 SSR 프로젝트도 없이 규칙만 지어내는 꼴이 된다.

목록 조회의 파라미터 계약은 이미 있다. 목록 조회 요청 계약 표준page·size·sortXxxSearchRequest로 묶고 GET으로 받는 것까지 정했다. 이 글은 그 GET을 어떤 경로에 붙일지만 정한다.

성공 상태 코드는 이미 있다. 성공 응답 상태 표준이 메서드별 상태 코드(POST→201, DELETE→204 등)를 정했다. 이 글은 그 메서드를 어떤 URL에 붙일지만 정한다.

「삭제」는 이미 넷으로 갈려 있다. 애그리거트 삭제 표준이 상태 전이·자식 제거·대량 삭제·애그리거트 루트 제거를 갈라놨고, 흔적이 남는 것은 삭제가 아니라 상태 전이다. 이 글은 그 넷 중 무엇에 DELETE를 붙일지만 정한다 — 범위를 새로 긋지 않는다.


원칙 1. 리소스 경로는 복수 명사로 짓고, 도메인 이름을 반복하지 않는다

먼저 grep으로 실제 쓰임을 셌다. coding-standard/ 아래 예시 코드에서 URL이 나온 자리 전부다.

POST /orders        (7곳)
POST /users          (2곳)
GET  /admin/orders   (1곳)

전부 복수형이고, 전부 도메인 접두사가 없다. 패키지는 order/api/OrderController.java인데 URL은 /order/orders가 아니라 /orders다.

정해야 했던 건 이거였다 — 이 우연을 규칙으로 굳힐 것인가.

안 A — 단수 명사로 짓는다. /order. 영어 문법상 “주문 하나”에 가깝다는 감각은 있다.

버린 이유는 의미가 어긋난다는 것이다. GET /order는 컬렉션 조회(여러 건)인데 이름은 단수다. 그리고 이미 있는 예시 9곳을 전부 고쳐야 아무 근거 없이 관성을 깨는 셈이 된다.

안 B — 도메인 이름을 경로에 포함한다. /order/orders. 패키지 경로와 URL이 그대로 대응해서 코드를 찾기 쉬울 것 같다.

버린 이유는 관리자 하위 컨트롤러에서 바로 깨진다는 것이다. OrderAdminController는 이미 /admin/orders를 쓰는데, 도메인 이름까지 넣으면 /order/admin/orders가 된다. 접두사가 두 개가 되는 순간 어느 게 진짜 리소스 이름인지 안 읽힌다. 패키지 이름과 URL 세그먼트를 억지로 1:1 대응시키려는 시도이고, 그 대응이 필요한 순간(디버깅)은 로그의 컨트롤러 클래스명으로 이미 해결된다.

골랐다 — 안 C, 복수 명사 + 도메인 비중복. 안 A가 못 지켜주는 것은 컬렉션이라는 의미이고, 안 B가 못 지켜주는 것은 접두사가 하나로 유지되는 것이다. 그리고 이건 사실 고르는 게 아니라 이미 9번 반복된 걸 적어두는 것이었다.

// order/api/OrderController.java
@PostMapping("/orders")
public ResponseEntity<...> createOrder(...) { ... }

대가는 없다 — 새로 뭘 잃는 게 아니라 이미 하던 것의 이름을 붙인 것뿐이다.


원칙 2. 버전을 경로에 넣지 않는다

_posts/javascript/frontend-api-management.md/api/v1/users가 나온다. 이 시리즈 소속이 아니고, 표준을 쓰기 전에 작성된 글이다. 그런데 눈에 띈 이상 짚고 넘어가야 했다 — 왜 나머지 전부는 버전이 없는가.

안 A — /api/v1/orders처럼 버전을 박는다. 얻는 것은 breaking change가 났을 때 v2를 나란히 열 수 있다는 것이다.

버린 이유는 지금 이 저장소에 버전이 필요했던 적이 없다는 것이다. coding-standard/ 예시 열 곳 중 버전 접두사를 쓴 곳이 0곳이고, 그 한 곳(/api/v1/)조차 이 시리즈 밖의 글이다. 없는 필요를 미리 설계하면 모든 컨트롤러가 /v1/을 달고 시작하는데, 그 세그먼트가 하는 일이 하나도 없다.

골랐다 — 안 B, 버전 없음. 대가는 나중에 실제로 breaking change가 나면 버저닝 전략(경로냐 헤더냐)을 그때 새로 정해야 한다는 것이다. 지금 미리 짓지 않는 이유는 그 전략이 실제 breaking change의 모양(필드 삭제인지 의미 변경인지)에 따라 달라질 것이기 때문이다.


원칙 3. 수정은 PUT 하나로 받고 PATCH를 열지 않는다

여기는 예시가 갈려 있었다. 오래된 글(_posts/spring/)은 PUT /users/{userId}/password처럼 부분 수정에도 PUT을 썼는데, RFC 의미로는 PATCH가 더 맞는다.

안 A — PATCH를 연다. 전체 교체는 PUT, 일부 필드 변경은 PATCH로 가른다. RFC 의미에는 더 맞다.

버린 이유는 PATCH를 여는 것이 메서드 하나를 추가하는 일로 안 끝난다는 것이다. 부분 업데이트 바디 스펙(JSON Merge Patch인지, 필드 생략이 “안 바꾼다”인지 “null로 지운다”인지)까지 같이 정해야 완성되는데, 지금 이 저장소에 그런 요구가 예시로 한 번도 안 나왔다. 없는 요구에 스펙부터 짓는 셈이다.

골랐다 — 안 B, PUT만 쓴다. 대상을 좁힌 하위 경로(PUT /users/{userId}/password)로 “부분 수정”이라는 개념 자체를 없앤다 — 그 경로가 다루는 필드가 곧 그 경로의 전체다. 대가는 필드가 여러 개인 리소스는 경로가 늘어난다는 것이다. /users/{userId} 하나로 안 끝나고 세분화된 하위 경로가 필요할 수 있다.

단, 상태 필드는 이 하위 경로로 다루지 않는다. PUT /orders/{orderId}/status는 모양이 같아 보이지만 상태 전이라서 원칙 6으로 간다.


원칙 4. DELETE는 애그리거트 루트 제거에만 쓴다

DELETE를 어디까지 쓰는지는 예시가 아예 없었다. 그래서 “물리 삭제냐 논리 삭제냐”로 물을 뻔했는데, 그 축을 꺼내기 전에 애그리거트 삭제 표준이 이미 답을 갖고 있었다.

그 표준의 출발점이 “삭제”라는 한 단어가 네 가지를 가리킨다는 발견이었다 — 상태 전이·자식 제거·대량 삭제·애그리거트 루트 제거. 그리고 판단 기준이 이렇게 적혀 있다.

흔적이 남아야 하나? → 그렇다 → 삭제가 아니다. 상태 전이로 다룬다

그러니 여기서 정할 것은 DELETE의 범위가 아니라, 이미 갈라진 넷 중 어디에 붙이느냐뿐이었다. 선택지가 하나뿐이었던 셈이고, 그건 삭제 표준이 나머지를 미리 지워놨기 때문이다.

골랐다 — 루트 제거에만. 삭제 표준이 「삭제」라고 부르는 것, 즉 아무도 참조하지 않아 행을 정말 없애는 것이다. 흔적이 남는 것은 상태 전이이므로 원칙 6(동사 세그먼트)으로 간다.

GET    /orders                      목록 조회 · 검색
POST   /orders                      생성
GET    /orders/{orderId}            단건 조회
PUT    /orders/{orderId}            수정 (원칙 3)
POST   /orders/{orderId}/cancel     취소 — 흔적이 남으므로 상태 전이다 (원칙 6)
DELETE /cart-items/{cartItemId}     루트 제거 — 아무도 참조하지 않는다

주문에는 DELETE가 없다. 삭제 표준이 “다른 도메인이 ID로 참조하는 애그리거트는 지우지 않는다”고 했고 Repository에 delete조차 두지 않는다. 그러니 붙일 URL도 없다.

대가는 URL이 흔적의 유무를 드러낸다는 것이다. 처음엔 이걸 감춰야 할 내부 구현으로 봤는데 아니었다 — 취소된 주문은 GET /orders/{orderId}가 200을 주고, 지워진 장바구니 항목은 404를 준다. 클라이언트가 알아야 하는 차이라서 URL에 드러나는 게 맞다.


원칙 5. 역할이 다른 하위 컨트롤러는 리소스 앞에 접두사를 붙인다

OrderAdminController가 이미 /admin/orders를 쓰고 있었다. /orders/admin이 아니라 앞이다. 이유를 적어둔 적은 없어서, 왜 그렇게 됐는지부터 봐야 했다.

안 A — 리소스 뒤에 붙인다. /orders/admin. “orders라는 리소스의 admin 하위 자원”으로 읽힌다.

버린 이유는 일반 사용자 라우트와 관리자 라우트를 한 글로브로 못 가른다는 것이다. 관리자 컨트롤러는 인증·인가 규칙이 다른데, /orders/로 시작하는 모든 경로에 같은 시큐리티 설정을 걸면 /orders/admin도 걸린다. 앞에 조건을 더 달아야 한다.

골랐다 — 안 B, 리소스 앞에 붙인다. /admin/orders/admin/** 하나로 관리자 전용 시큐리티 설정을 걸 수 있다. 접두사가 인가 경계와 그대로 겹친다. 대가는 같은 리소스가 URL 두 곳(/orders, /admin/orders)에 나뉘어 산다는 것이고, 그건 컨트롤러 패키지 분리 표준이 이미 감수하기로 한 것과 같은 대가다 — 컨트롤러 자체가 OrderController/OrderAdminController로 나뉘어 있다.


원칙 6. 상태 전이는 동사를 마지막 경로 세그먼트로 쓴다

원칙 4가 DELETE를 루트 제거로 좁히면서 흔적이 남는 동작이 전부 이리로 넘어왔다. 주문 취소, 회원 탈퇴, 익명화 — 애그리거트 삭제 표준이 상태 전이로 갈라놓은 것들이다. 여기에 원래부터 CRUD로 표현이 안 되던 것(배송 시작, 승인)이 더해진다.

취소는 생성도 삭제도 수정도 아니다. 수정처럼 보이지만 “가능한 상태 전이인가”라는 별도 검증이 붙는다.

안 A — 상태 변경도 PUT으로 욱여넣는다. PUT /orders/{orderId} + 바디에 {"status": "CANCELLED"}.

버린 이유는 모든 상태 전이가 같은 자격을 갖는 게 아니라는 것이다. PENDING → CANCELLED는 되지만 SHIPPED → CANCELLED는 안 된다. 이걸 “필드 하나 바꾸는 PUT”으로 표현하면 어떤 전이가 허용되는지가 바디 값에 묻히고, 422인지 400인지도 애매해진다.

안 B — 전이 자체를 리소스로 만든다. POST /orders/{orderId}/cancellations. REST 순수주의 관점에서는 가장 “리소스답다.”

버린 이유는 이름이 억지스럽다는 것이다. 배송 시작처럼 명사형이 안 나오는 동작이 많다(shipshipments로 욱여넣어야 하나). 매 상태 전이마다 명사를 찾아야 하는 부담이 생긴다.

골랐다 — 안 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}처럼 독립 리소스 + 쿼리 파라미터로만 표현한다.

얻는 것은 경로 깊이가 항상 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는 서버·브라우저·프록시마다 다르게 다뤄질 위험이 있다.


판단 기준 정리

질문
리소스 경로 형태 복수 명사 컬렉션 의미와 일치, 이미 9곳이 그랬다
도메인 이름 포함 여부 안 넣는다 관리자 접두사와 겹치면 접두사가 두 개가 된다
버전 접두사 안 넣는다 필요했던 적이 한 번도 없다
목록·검색 GET 목록 조회 요청 계약 표준이 이미 결정
생성 POST
전체·부분 수정 PUT 하나로 PATCH는 부분 업데이트 바디 스펙까지 필요해 미룬다
DELETE의 범위 애그리거트 루트 제거만 흔적이 남으면 삭제 표준이 이미 상태 전이로 갈라놨다
흔적이 남는 삭제(탈퇴·취소) DELETE가 아니라 동사 세그먼트 그건 삭제가 아니다. 「소프트 삭제」라는 말을 쓰지 않는다
관리자 등 하위 컨트롤러 리소스 에 접두사 인가 경계(/admin/**)와 겹친다
상태 전이 POST /orders/{orderId}/{동사} 새 리소스를 안 만든다는 사실이 URL에 드러난다
그 동사를 무엇으로 Domain의 의도 기반 메서드 이름 이름을 두 번 짓지 않는다
경로 변수 이름 {orderId} 등 도메인 접두 중첩 경로에서 충돌하지 않는다
중첩 리소스 1단계까지만 관계 가시성과 경로 길이의 타협
복수 단어 리소스명 kebab-case URL 표준 관례, 대소문자 위험 없음

이 표준을 정하기까지

시작은 grep이었다. 장부 10번을 열자마자 “정해야 하나”보다 먼저 “이미 정해져 있는 거 아닌가”를 물었다. coding-standard/ 아래 @PostMapping·@GetMapping 예시를 전부 세니 열 곳 중 아홉 곳이 복수 명사 + 도메인 비중복이었고, 어긋난 예시가 하나도 없었다. 규칙을 새로 고르는 게 아니라 이미 아홉 번 반복된 걸 적어두는 일이라는 걸 거기서 알았다.

그런데 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했어야 했다.


정리

  • 리소스 경로는 복수 명사로 짓고 도메인 이름을 반복하지 않는다. /orders, /order/orders 아님
  • 버전을 경로에 넣지 않는다. 필요했던 적이 없다
  • HTTP 메서드는 CRUD와 1:1이다. 조회/검색=GET, 생성=POST, 수정(전체·부분 모두)=PUT
  • DELETE는 애그리거트 루트 제거에만 쓴다. 흔적이 남으면 삭제가 아니라 상태 전이고, 동사 세그먼트로 간다. 「소프트 삭제」라는 말을 쓰지 않는다 — 이 저장소는 삭제를 넷으로 가른다
  • 역할이 다른 하위 컨트롤러는 리소스 앞에 접두사를 붙인다. /admin/orders, 인가 경계와 접두사가 겹친다
  • 상태 전이는 동사를 마지막 세그먼트로 쓴다. POST /orders/{orderId}/cancel, 동사는 Domain 메서드 이름 그대로
  • 경로 변수는 도메인을 접두로 붙인다. {orderId}, 중첩에서 안 부딪힌다
  • 중첩 리소스는 1단계까지만. 그 이상은 평탄화한다
  • 여러 단어 리소스명은 kebab-case. /order-items

AI 코드 어시스턴트에 바로 적용하기

Claude Code — .claude/skills/route-naming-standard/SKILL.md

---
name: route-naming-standard
description: URL 경로와 HTTP 메서드 네이밍 규칙. Controller에 새 엔드포인트를 만들거나 라우팅을 리뷰할 때 반드시 적용한다.
---

# URL 라우팅 네이밍 표준

`api` 패키지의 REST 엔드포인트에만 적용한다. `web`(SSR) 패키지의 라우팅은 아직 정하지 않았다.

## 경로

- 리소스 경로는 복수 명사로 짓는다. `/orders`, `/users`.
- 도메인 이름을 경로에 반복하지 않는다. 패키지가 `order/api`여도 URL은 `/order/orders`가 아니라 `/orders`다.
- 버전 접두사(`/api/v1/...`)를 쓰지 않는다.
- 여러 단어로 된 리소스명은 kebab-case로 쓴다. `/order-items`.
- 관리자 등 역할이 다른 하위 컨트롤러는 리소스 **앞**에 접두사를 붙인다. `/admin/orders`. `/orders/admin`이 아니다.
- 상태 전이처럼 새 리소스를 만들지 않는 요청은 동사를 마지막 경로 세그먼트로 쓴다. `POST /orders/{orderId}/cancel`.
- 중첩 리소스는 1단계까지만 허용한다. `/orders/{orderId}/items`는 되지만 그 아래는 다시 평탄화한다.
- 경로 변수 이름은 도메인을 접두로 붙인다. `{id}`가 아니라 `{orderId}`.

## HTTP 메서드

- 목록 조회·검색은 GET. 파라미터는 목록 조회 요청 계약 표준의 `XxxSearchRequest`로 받는다.
- 생성은 POST.
- 수정은 전체·부분 구분 없이 PUT 하나로 받는다. PATCH를 열지 않는다.
- DELETE는 **애그리거트 루트 제거에만** 쓴다. 애그리거트 삭제 표준이 「삭제」라고 부르는 것, 즉 아무도 참조하지 않아 행을 정말 없애는 것이다.
- 흔적이 남는 것(주문 취소·회원 탈퇴·익명화)은 삭제가 아니라 **상태 전이**다. DELETE가 아니라 POST + 동사 세그먼트로 받는다.
- **「소프트 삭제」라는 말을 쓰지 않는다.** 이 저장소는 "삭제"를 상태 전이·자식 제거·대량 삭제·루트 제거 넷으로 가른다.
- 상태 전이는 POST + 동사 세그먼트로 받는다. PUT으로 상태 필드를 바꾸지 않는다.
- 그 동사는 `Domain`의 의도 기반 메서드 이름(`cancel`·`withdraw`·`anonymize`)을 그대로 쓴다.

GitHub Copilot — .github/instructions/route-naming-standard.instructions.md

---
description: URL 경로와 HTTP 메서드 네이밍 규칙
applyTo: "**/api/**/*.java"
---

# URL 라우팅 네이밍

- 리소스 경로는 복수 명사로 짓는다. `/orders`, `/users`.
- 도메인 이름을 경로에 반복하지 않는다. 패키지가 `order/api`여도 URL은 `/orders`다.
- 버전 접두사(`/api/v1/...`)를 쓰지 않는다.
- 여러 단어로 된 리소스명은 kebab-case로 쓴다. `/order-items`.
- 관리자 등 역할이 다른 하위 컨트롤러는 리소스 앞에 접두사를 붙인다. `/admin/orders`. `/orders/admin`이 아니다.
- 상태 전이처럼 새 리소스를 만들지 않는 요청은 동사를 마지막 경로 세그먼트로 쓴다. `POST /orders/{orderId}/cancel`.
- 중첩 리소스는 1단계까지만 허용한다. 그 아래는 평탄화한다.
- 경로 변수 이름은 도메인을 접두로 붙인다. `{id}`가 아니라 `{orderId}`.
- 목록 조회·검색은 GET, 생성은 POST.
- 수정은 전체·부분 구분 없이 PUT 하나로 받는다. PATCH를 열지 않는다.
- DELETE는 애그리거트 루트 제거에만 쓴다. 아무도 참조하지 않아 행을 정말 없애는 것이다.
- 흔적이 남는 것(주문 취소·회원 탈퇴·익명화)은 삭제가 아니라 상태 전이다. DELETE가 아니라 POST + 동사 세그먼트로 받는다.
- 「소프트 삭제」라는 말을 쓰지 않는다. 이 저장소는 "삭제"를 상태 전이·자식 제거·대량 삭제·루트 제거 넷으로 가른다.
- 상태 전이는 POST + 동사 세그먼트로 받는다. PUT으로 상태 필드를 바꾸지 않는다.
- 그 동사는 `Domain`의 의도 기반 메서드 이름(`cancel`·`withdraw`·`anonymize`)을 그대로 쓴다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기