init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
# API Controller 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 Spring MVC 기반 API controller의 책임, 위치, 작성 방식, 금지사항을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- controller를 HTTP boundary 역할에 집중시킨다.
|
||||
- request parsing / validation / authentication context extraction / response shaping의 위치를 명확히 한다.
|
||||
- business rule, transaction, persistence access, external integration이 controller로 새어 들어오지 않게 한다.
|
||||
- API controller가 프로젝트 전체에서 일관된 구조를 가지게 한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 문서의 확장 지점 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 API endpoint는 기본적으로 @RestController를 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 @RestController는 @Controller와 @ResponseBody를 결합한 annotation이며, @RequestMapping 메서드가 기본적으로 response body semantics를 가진다. JSON/HTTP body를 반환하는 API controller의 기본 선택지는 @RestController다. HTML view rendering이 목적일 때만 @Controller를 사용한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- REST API endpoint의 기본값은 @RestController
|
||||
- 서버사이드 템플릿 렌더링 등 view name 반환이 목적일 때만 @Controller
|
||||
- 같은 controller 안에 API 응답과 view 렌더링을 섞지 않는다
|
||||
|
||||
### 3.2 controller는 HTTP boundary translator다
|
||||
|
||||
Spring MVC는 controller 메서드에서 request mapping, request input, exception handling, return value rendering을 담당할 수 있게 해 준다. 그러나 이 프로젝트에서 controller의 핵심 책임은 “HTTP 요청을 애플리케이션 입력으로 번역하고, 애플리케이션 결과를 HTTP 응답으로 번역하는 것”이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 request를 해석한다
|
||||
- 필요한 인증 주체/식별자/입력을 추출한다
|
||||
- application service / use case를 호출한다
|
||||
- 응답 DTO 또는 ApiResult로 응답을 만든다
|
||||
|
||||
그 외의 핵심 business decision은 controller의 기본 책임이 아니다.
|
||||
|
||||
### 3.3 controller는 얇게 유지한다
|
||||
|
||||
이 항목은 Official + Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 가능한 한 짧고 예측 가능해야 한다
|
||||
- HTTP concern 외의 분기와 규칙은 application/domain으로 이동한다
|
||||
- controller 하나가 여러 repository, external client, transaction concern을 직접 조립하지 않는다
|
||||
- “request 파싱 → use case 호출 → response 반환” 흐름이 한눈에 보여야 한다
|
||||
|
||||
## 4. 매핑 표준
|
||||
|
||||
### 4.1 class-level base path + method-level endpoint를 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 @RequestMapping은 class level에서 shared mapping을, method level에서 구체 endpoint mapping을 표현할 수 있다. 또한 대부분의 controller method는 모든 HTTP method를 받는 일반 @RequestMapping보다 @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping 같은 method-specific shortcut을 쓰는 것이 자연스럽다. 같은 element에 여러 @RequestMapping 계열을 함께 두면 첫 번째만 사용되고 warning이 남는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller 클래스에는 resource base path를 둔다
|
||||
- endpoint 메서드에는 HTTP method-specific mapping을 사용한다
|
||||
- endpoint 메서드에 일반 @RequestMapping을 남발하지 않는다
|
||||
- 한 메서드에 중복 mapping annotation을 두지 않는다
|
||||
|
||||
### 4.2 매핑 조건은 필요한 만큼만 사용한다
|
||||
|
||||
Spring은 mapping에 URL, HTTP method, params, headers, media types를 모두 조건으로 줄 수 있다. 하지만 이 프로젝트에서는 매핑 조건을 너무 많이 걸어 endpoint를 읽기 어렵게 만드는 것을 지양한다. 꼭 필요한 경우에만 consumes, produces, params, headers를 사용한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본은 path + HTTP method
|
||||
- content-type이 중요한 endpoint만 consumes
|
||||
- response media type이 실제로 갈리는 endpoint만 produces
|
||||
- params/headers 조건은 routing necessity가 분명할 때만 사용
|
||||
|
||||
### 4.3 endpoint path는 리소스 중심으로 둔다
|
||||
|
||||
이 항목은 주로 Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- path는 동사보다 리소스 중심으로 설계한다
|
||||
- action semantics는 가능한 한 HTTP method로 표현한다
|
||||
- 도메인적으로 특별한 command endpoint가 필요하면 예외적으로 명시적 action path를 사용할 수 있다
|
||||
- controller 이름, class path, method path가 함께 읽혔을 때 자원이 자연스럽게 보이게 한다
|
||||
|
||||
## 5. 메서드 시그니처 표준
|
||||
|
||||
### 5.1 인자는 명시적으로 바인딩한다
|
||||
|
||||
Spring MVC controller method는 매우 다양한 인자를 지원한다. HttpServletRequest, HttpServletResponse, WebRequest, HttpSession, @PathVariable, @RequestParam, @RequestHeader, @ModelAttribute, @RequestBody 등 여러 타입과 annotation을 사용할 수 있다. 그러나 지원된다고 해서 아무 인자나 다 쓰는 방향을 기본값으로 두지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- path 값은 @PathVariable
|
||||
- query 값은 @RequestParam
|
||||
- header 값은 @RequestHeader
|
||||
- JSON body는 @RequestBody
|
||||
- form/query binding object는 @ModelAttribute
|
||||
- 각 입력의 출처가 메서드 시그니처에 드러나야 한다
|
||||
|
||||
### 5.2 request DTO를 명시적으로 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 @ModelAttribute는 request parameters, URI path variables, headers를 model object에 바인딩하며, 보안상 웹 바인딩 전용 객체를 쓰거나 constructor binding only를 고려하고, property binding이 필요하면 allowedFields를 제한하라고 권장한다. 즉, 웹 입력용 객체 설계는 신중해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON body는 전용 request DTO에 받는다
|
||||
- form/query binding도 가능하면 전용 request model로 받는다
|
||||
- entity/domain object를 직접 @RequestBody / @ModelAttribute 대상으로 쓰지 않는다
|
||||
- request DTO는 transport model이지 domain model이 아니다
|
||||
|
||||
### 5.3 Servlet API 의존은 정말 필요할 때만 허용한다
|
||||
|
||||
Spring MVC는 HttpServletRequest, HttpServletResponse, WebRequest 등 직접적인 request/response 접근을 지원한다. 하지만 API controller의 기본 시그니처는 annotation 기반 인자 바인딩으로 충분해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request body, path, query, header는 annotation 기반 인자 바인딩을 우선한다
|
||||
- HttpServletRequest / HttpServletResponse는 다음처럼 정말 필요한 경우에만 사용한다
|
||||
- request attribute 접근
|
||||
- low-level header 처리
|
||||
- cookie 직접 제어
|
||||
- file streaming / low-level response control
|
||||
- 단순 입력 추출 때문에 Servlet API를 들고 오지 않는다
|
||||
|
||||
### 5.4 변환과 포맷팅은 ad-hoc parsing보다 binder/converter를 우선 검토한다
|
||||
|
||||
Spring 공식 문서 기준으로 @InitBinder나 전역 MVC config를 통해 Converter, Formatter, PropertyEditor 등을 등록해 타입 변환과 formatting을 구성할 수 있다. controller 안에서 문자열 파싱 로직을 계속 반복하는 것보다 framework extension point를 쓰는 편이 낫다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 동일한 문자열 → 타입 변환이 반복되면 converter/formatter를 검토한다
|
||||
- web binding 전용 커스터마이징은 @InitBinder 또는 전역 conversion 설정으로 뺀다
|
||||
- controller 본문에 파싱 로직을 반복해서 쓰지 않는다
|
||||
|
||||
## 6. 반환값 표준
|
||||
|
||||
### 6.1 기본 반환은 body 중심이다
|
||||
|
||||
Spring MVC는 @ResponseBody, ResponseEntity, HttpHeaders, ProblemDetail, String view name 등 다양한 반환형을 지원한다. API controller에서는 body 중심 반환이 기본이다. @RestController는 class level @ResponseBody semantics를 제공한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 API 성공 응답은 ApiResult<ResponseDto> 또는 프로젝트 표준 response DTO를 반환한다
|
||||
- view name 반환은 API controller에서 사용하지 않는다
|
||||
- Map<String, Object> 같은 임시 응답은 승인 후보 코드에서 지양한다
|
||||
|
||||
### 6.2 ResponseEntity는 “정말 HTTP 응답을 제어해야 할 때” 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 ResponseEntity<B>는 전체 응답, 즉 HTTP status, headers, body를 함께 지정하는 반환형이다. 따라서 모든 endpoint에서 습관적으로 ResponseEntity를 쓸 필요는 없고, HTTP 응답 제어가 필요한 경우에 의미가 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 성공 200 응답이고 header 제어가 없다면 굳이 ResponseEntity를 강제하지 않는다
|
||||
- 다음 경우에는 ResponseEntity를 사용한다
|
||||
- 201 Created + Location
|
||||
- 204 No Content
|
||||
- header 제어
|
||||
- 캐시/조건부 응답
|
||||
- 파일 다운로드
|
||||
- endpoint별로 status가 의미 있게 달라지는 경우
|
||||
|
||||
### 6.3 응답 body 공통화는 controller보다 advice에서 해결할 수 있다
|
||||
|
||||
Spring의 ResponseBodyAdvice는 @ResponseBody 또는 ResponseEntity controller method의 body가 HttpMessageConverter로 쓰이기 직전에 응답을 커스터마이징할 수 있고, @ControllerAdvice로 등록하면 자동 적용된다. 따라서 전역 ApiResult 래핑이나 공통 필드 보강은 controller 메서드마다 반복하지 않고 advice에 둘 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 응답 envelope 공통화 정책이 있으면 controller 반복보다 advice를 우선 검토한다
|
||||
- 다만 controller가 이미 명시적으로 ApiResult를 반환하는 프로젝트라면 이중 래핑을 피한다
|
||||
|
||||
## 7. 예외, 검증, 인증 경계
|
||||
|
||||
### 7.1 controller는 예외를 직접 처리하는 기본 위치가 아니다
|
||||
|
||||
Spring 공식 문서 기준으로 @ExceptionHandler, @InitBinder, @ModelAttribute는 local controller에도 둘 수 있고, @ControllerAdvice / @RestControllerAdvice로 전역 적용도 가능하다. 전역 @ExceptionHandler는 local handler 뒤에 적용된다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반적인 API 예외 처리는 @RestControllerAdvice에 둔다
|
||||
- controller 메서드 안에서 try-catch로 공통 예외 응답을 반복해서 만들지 않는다
|
||||
- controller local @ExceptionHandler는 endpoint-local 정책이 정말 필요할 때만 사용한다
|
||||
|
||||
### 7.2 controller의 검증 책임은 request boundary까지다
|
||||
|
||||
이 항목은 앞서 정리한 validation 문서와 연결되는 Official + Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 request DTO / scalar input의 구조 검증을 트리거한다
|
||||
- business rule 검증, 상태 조회 기반 검증, 불변식 보장은 application/domain에서 담당한다
|
||||
- controller validation 통과를 domain correctness의 보장으로 간주하지 않는다
|
||||
|
||||
### 7.3 인증 주체 접근은 명시적이고 제한적으로 한다
|
||||
|
||||
Spring MVC는 다양한 method argument를 허용하므로 인증 객체도 여러 방식으로 전달될 수 있다. 이 프로젝트에서는 인증 객체 접근을 controller 시그니처에서 명시적으로 드러내되, security implementation 세부사항이 controller 전체에 번지지 않게 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 인증 주체는 가능한 한 전용 principal / auth object / argument resolver 결과로 받는다
|
||||
- controller가 security framework의 저수준 타입에 과도하게 묶이지 않게 한다
|
||||
- 인증 객체 접근 표준은 이후 Authentication Object Access Standard 문서에서 더 구체화한다
|
||||
|
||||
## 8. 의존성 및 금지 규칙
|
||||
|
||||
이 항목은 주로 Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 repository를 직접 호출하지 않는다
|
||||
- controller는 EntityManager, JDBC template, external client를 직접 조립하지 않는다
|
||||
- controller에 @Transactional을 기본 금지한다
|
||||
- controller는 domain entity를 외부 응답 모델로 직접 반환하지 않는다
|
||||
- controller에서 event 발행, retry, async orchestration을 직접 수행하지 않는다
|
||||
- controller 메서드가 “HTTP boundary 번역” 이상으로 커지기 시작하면 application service / mapper / advisor / resolver 분리를 검토한다
|
||||
|
||||
## 9. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 클래스는 REST API endpoint이므로 @RestController가 자연스러운가?
|
||||
- class-level path와 method-level HTTP mapping이 명확한가?
|
||||
- 입력 출처가 메서드 시그니처에서 드러나는가?
|
||||
- request DTO와 domain/entity가 분리되어 있는가?
|
||||
- controller가 repository/transaction/business rule을 직접 품고 있지 않은가?
|
||||
- 응답 형식이 프로젝트 표준(ApiResult 등)에 맞는가?
|
||||
- 예외 처리와 공통 응답 보강을 advice 쪽으로 밀어냈는가?
|
||||
@@ -0,0 +1,243 @@
|
||||
# API Versioning 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API version을 언제 도입하고, 어떤 위치에 두며, 어떤 경우에 올릴지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- API 변경을 안전하게 진화시킨다
|
||||
- 이미 배포된 클라이언트를 불필요하게 깨뜨리지 않는다
|
||||
- 버전 전략이 endpoint마다 제각각 달라지는 일을 막는다
|
||||
- 버전과 deprecation 정책을 문서화 가능하게 만든다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework, HTTP/REST 관련 공식 문서/가이드에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 제약 위에 일반적인 실무 API 운영 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 API versioning에는 표준 단일 방식이 없다
|
||||
|
||||
Spring 공식 문서는 API version을 지정하는 표준 방법은 없다고 설명하며, 버전을 header, query parameter, media type parameter, URL path 중 어디에서 읽을지 애플리케이션이 정해야 한다고 말한다. Spring MVC의 ApiVersionStrategy도 이 여러 전략을 지원한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- “업계 표준이라 무조건 이 방식”이라는 전제를 두지 않는다
|
||||
- 한 서비스 안에서는 반드시 하나의 기본 전략을 정한다
|
||||
- 예외 전략을 허용하더라도 기준과 이유를 남긴다
|
||||
|
||||
### 3.2 버전은 “breaking change 관리 수단”이다
|
||||
|
||||
Azure REST 가이드도 버저닝의 핵심 요구를 “기존 고객 workload가 깨지지 않아야 하고, 고객이 새 버전을 선택적으로 채택할 수 있어야 한다”는 점으로 설명한다. 즉, 버전은 새 기능 홍보 수단이 아니라 호환성 관리 수단이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- breaking change가 아니면 새 major version을 만들지 않는다
|
||||
- additive change, optional field 추가, 하위 호환 가능한 확장은 기존 major 안에서 처리한다
|
||||
- version은 endpoint 개수 늘리기 수단이 아니다
|
||||
|
||||
### 3.3 버전 전략보다 더 중요한 것은 일관성이다
|
||||
|
||||
Azure는 query parameter 전략을 강하게 권장하고 path versioning을 금지하지만, Stripe는 header 기반 version pinning을 사용한다. 업계의 실제 운영 방식이 서로 다르다는 뜻이다. 따라서 프로젝트에서 더 중요한 것은 “어느 방식이냐”보다 “같은 API 군에서 전략을 섞지 않느냐”다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 API product 안에서 path/header/query versioning을 혼용하지 않는다
|
||||
- version 협상 위치가 달라지면 문서, 테스트, 운영, client SDK가 모두 복잡해진다
|
||||
|
||||
## 4. 이 프로젝트의 기본 전략
|
||||
|
||||
### 4.1 기본값은 path major versioning이다
|
||||
|
||||
프로젝트 기본 전략:
|
||||
|
||||
```text
|
||||
/api/v1/...
|
||||
/api/v2/...
|
||||
```
|
||||
|
||||
형태의 path major versioning을 기본으로 한다.
|
||||
|
||||
이 규칙은 Spring이 path version resolver를 공식 지원한다는 사실 위에, 실무적으로 다음 장점 때문에 선택한 Project Recommendation 이다.
|
||||
|
||||
- URL만 봐도 버전이 드러난다
|
||||
- 로그, 게이트웨이, 캐시, 문서화에서 식별이 쉽다
|
||||
- client가 명시적으로 어떤 major를 호출하는지 드러난다
|
||||
- Spring 7 이전/이후 여부와 관계없이 구현이 단순하다
|
||||
|
||||
Spring은 path, header, query parameter, media type parameter 모두 지원한다. 따라서 path 전략은 framework 차원에서도 무리 없는 선택이다.
|
||||
|
||||
### 4.2 기본 path version은 major만 올린다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- URL에는 기본적으로 v1, v2처럼 major 만 노출한다
|
||||
- v1.1, v1.2, v1.2.3 같은 minor/patch를 path에 올리지 않는다
|
||||
- minor/patch 수준 진화는 같은 major 안에서 하위 호환으로 처리한다
|
||||
|
||||
Spring 7의 native API versioning은 semantic parser를 통해 major/minor/patch까지 다룰 수 있고, 1.2+ 같은 baseline version도 지원한다. 하지만 그것이 곧 공개 URL에 minor/patch를 그대로 드러내야 한다는 뜻은 아니다. 이 프로젝트는 공개 계약 단순성을 위해 path에는 major만 둔다.
|
||||
|
||||
### 4.3 header/query/media type versioning은 예외적으로만 사용한다
|
||||
|
||||
Spring은 header, query parameter, media type parameter도 공식 지원한다. Azure는 query parameter를, Stripe는 header를 대표적으로 사용한다. 그러나 이 프로젝트에서는 이를 기본값이 아닌 예외 전략으로 둔다.
|
||||
|
||||
허용 가능한 예외 예:
|
||||
|
||||
- 하나의 URL을 유지해야 하는 강한 외부 계약이 있을 때
|
||||
- API gateway/product 정책이 이미 header versioning을 강제할 때
|
||||
- 내부 SDK가 header pinning에 맞춰 설계되어 있을 때
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 예외 전략을 도입하면 해당 API 군 전체에서 일관되게 유지한다
|
||||
- path와 header versioning을 같은 리소스에 동시에 섞지 않는다
|
||||
|
||||
## 5. 언제 버전을 올리는가
|
||||
|
||||
### 5.1 major version을 올려야 하는 경우
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 breaking change 로 보고 major version을 올린다.
|
||||
|
||||
- 필수 request field 추가
|
||||
- 기존 field 의미 변경
|
||||
- response field 삭제
|
||||
- response field 타입 변경
|
||||
- status code 의미 변경
|
||||
- error code 계약 변경
|
||||
- 인증 방식/권한 요구의 비호환 변경
|
||||
- pagination/filter/sort 의미의 비호환 변경
|
||||
|
||||
이 항목은 Practice + Project Recommendation 이다. 버전은 기존 client를 깨뜨릴 수 있는 변경을 분리하기 위한 수단으로 쓴다. 이 원칙은 Azure의 “기존 workload는 깨지지 않아야 한다”는 요구와도 맞닿아 있다.
|
||||
|
||||
### 5.2 major version을 올리지 않아도 되는 경우
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 원칙적으로 같은 major 안에서 처리한다.
|
||||
|
||||
- optional response field 추가
|
||||
- optional request field 추가
|
||||
- backward compatible validation 완화
|
||||
- 새로운 endpoint 추가
|
||||
- 새로운 enum value 추가 가능성을 미리 허용한 경우
|
||||
- 기존 의미를 깨지 않는 내부 구현 변경
|
||||
|
||||
Azure 가이드는 버전 가능성을 위해 확장 가능한 계약 설계를 강조하고, 새 값이 생길 수 있음을 문서화하라고 권장한다. 이는 불필요한 버전 증가를 줄이는 방향과 맞는다.
|
||||
|
||||
## 6. Spring 사용 규칙
|
||||
|
||||
### 6.1 Spring 7+를 쓰는 경우 native API versioning을 활용할 수 있다
|
||||
|
||||
Spring MVC는 API versioning을 공식 지원하고, request에서 버전을 읽어 @RequestMapping 계열의 version 속성과 매핑할 수 있다. version 속성은 고정 버전("1.2"), baseline 버전("1.2+"), 또는 미지정(any version, lowest priority)을 지원한다. 지원되지 않는 버전이나 누락된 버전은 기본적으로 400으로 처리된다. deprecated version에 대해서는 Deprecation, Sunset, Link 헤더도 보낼 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Spring 7+라면 native version mapping은 사용 가능
|
||||
- 다만 이 프로젝트의 공개 API 기본 전략은 여전히 path major versioning
|
||||
- native versioning을 쓰더라도 외부 계약 복잡도를 늘리지 않게 사용한다
|
||||
|
||||
### 6.2 Spring 6.x 이하 또는 단순 운영이 목표라면 explicit path versioning을 선호한다
|
||||
|
||||
Spring 7 이전에는 지금처럼 통합된 first-class version mapping이 없었으므로, 실무에서는 path를 통해 명시적으로 controller를 나누는 방식이 운영상 단순했다. 이 프로젝트도 버전 전략 자체보다 명시성을 우선한다. Spring 7을 쓰지 않더라도 /api/v1/**, /api/v2/** 구조는 그대로 유효하다. 이 항목은 Project Recommendation 이다.
|
||||
|
||||
### 6.3 version 누락 정책은 프로젝트에서 명시한다
|
||||
|
||||
Spring은 versioning을 활성화하면 기본적으로 version이 필수이고, 누락되면 MissingApiVersionException으로 400이 된다. 다만 optional로 두고 가장 최신 버전을 쓰게 하거나, default version을 둘 수도 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- path major versioning을 쓰는 공개 API에서는 버전 명시를 필수로 한다
|
||||
- “버전 없으면 최신 버전 사용” 같은 암묵 규칙을 두지 않는다
|
||||
- 클라이언트가 어떤 계약을 호출하는지 URL에서 명확해야 한다
|
||||
|
||||
## 7. deprecation / sunset 규칙
|
||||
|
||||
### 7.1 deprecated version은 공지와 함께 단계적으로 종료한다
|
||||
|
||||
Spring의 built-in deprecation handler는 deprecated version에 대해 Deprecation, Sunset, Link 헤더를 보낼 수 있다. Azure 가이드도 deprecating behavior를 응답 헤더로 공지하라고 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- deprecated version은 문서, 릴리스 노트, 응답 헤더 중 최소 2개 이상으로 공지한다
|
||||
- sunset 일정은 명확한 날짜와 마이그레이션 경로를 함께 제공한다
|
||||
- 구버전을 숨겨서 갑자기 끊지 않는다
|
||||
|
||||
### 7.2 구버전과 신버전은 일정 기간 병행 운영할 수 있다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- breaking change가 있는 경우 신버전 출시 후 일정 기간 구버전을 병행 운영한다
|
||||
- 병행 운영 기간과 종료 시점은 문서화한다
|
||||
- 병행 중에는 error code, 인증, 주요 리소스 의미가 버전별로 뒤섞이지 않게 관리한다
|
||||
|
||||
## 8. URL / 버전 구조 규칙
|
||||
|
||||
### 8.1 권장 구조
|
||||
|
||||
프로젝트 권장 구조:
|
||||
|
||||
```text
|
||||
/api/v1/sessions
|
||||
/api/v1/users/{userId}
|
||||
/api/v2/users/{userId}
|
||||
```
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- api prefix 아래에 version segment를 둔다
|
||||
- version은 리소스 path 앞쪽에서 빠르게 식별 가능해야 한다
|
||||
- resource naming 규칙은 버전과 별도로 일관되게 유지한다
|
||||
|
||||
### 8.2 버전과 리소스 의미를 함께 바꾸지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- version을 올릴 때 path naming convention 자체까지 불필요하게 같이 바꾸지 않는다
|
||||
- 버전 차이는 “계약 변화”를 표현하는 데 집중한다
|
||||
- v1/users에서 v2/members처럼 naming까지 동시에 바꾸는 것은 진짜 의미 변화가 있을 때만 허용한다
|
||||
|
||||
## 9. 구현 규칙
|
||||
|
||||
### 9.1 controller/package 분리는 버전 경계를 드러내야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 버전별 controller는 package 또는 클래스 구조에서 분리한다
|
||||
- v1, v2 endpoint가 뒤섞여서 읽히지 않게 한다
|
||||
- shared application/domain 로직은 재사용하되, transport contract는 버전별로 분리한다
|
||||
|
||||
### 9.2 DTO와 응답 형식도 버전 경계를 존중한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- v1 request/response DTO와 v2 DTO는 필요하면 분리한다
|
||||
- 버전이 다르면 같은 이름의 DTO를 무리하게 재사용하지 않는다
|
||||
- ApiResult 같은 envelope는 major 간에도 최대한 유지하되, payload contract는 버전별로 독립적으로 관리한다
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 같은 API 군에서 path/header/query versioning 혼용
|
||||
- breaking change인데 version을 올리지 않음
|
||||
- minor/patch를 공개 URL에 무분별하게 노출
|
||||
- 버전 미지정 시 최신 버전으로 암묵 fallback
|
||||
- deprecated version 종료 일정 없이 장기 방치
|
||||
- 신버전 도입과 동시에 resource naming/convention까지 불필요하게 전면 변경
|
||||
- controller 하나에서 여러 major 계약을 뒤섞어 처리
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 변경은 정말 breaking change인가?
|
||||
- 새 major가 필요한 이유를 설명할 수 있는가?
|
||||
- version 전략이 이 API 군 전체에서 일관적인가?
|
||||
- 버전 위치가 client, gateway, 로그, 문서에서 쉽게 드러나는가?
|
||||
- deprecated/sunset 계획이 있는가?
|
||||
- DTO와 controller 구조가 버전 경계를 드러내는가?
|
||||
@@ -0,0 +1,255 @@
|
||||
# Authentication Object Access 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 인증된 사용자 정보, 주체(principal), 인증 컨텍스트를 어디서 어떻게 접근할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- controller에서 현재 사용자 접근 방식을 일관되게 만든다
|
||||
- Spring Security 저수준 타입이 application/domain으로 번지는 것을 막는다
|
||||
- 인증 객체 접근과 권한 검사 책임을 구분한다
|
||||
- SecurityContextHolder 직접 접근을 최소화한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Security 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 확장 지점 위에 일반적인 실무 구조를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 인증 객체 접근은 web boundary concern이다
|
||||
|
||||
Spring Security에서 현재 인증 정보는 SecurityContextHolder의 SecurityContext 안 Authentication으로 관리됩니다. Spring MVC는 Principal을 controller method argument로 지원하고, Spring Security는 @AuthenticationPrincipal과 @CurrentSecurityContext로 그 접근을 더 직접적으로 노출합니다. 이 프로젝트에서는 이를 web boundary concern 으로 본다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 현재 사용자 접근은 기본적으로 controller/web adapter 경계에서 끝낸다
|
||||
- application/domain은 “현재 인증 컨텍스트를 조회하는 법”을 몰라야 한다
|
||||
- 내부 로직에는 필요한 최소 actor 정보만 전달한다
|
||||
|
||||
### 3.2 기본 선호는 @AuthenticationPrincipal 기반 전용 현재 사용자 객체다
|
||||
|
||||
Spring Security 문서는 @AuthenticationPrincipal을 쓰면 MVC 레이어를 SecurityContextHolder 직접 접근에서 분리할 수 있다고 설명하고, 더 나아가 @CurrentUser 같은 메타 애노테이션으로 Spring Security 의존을 한 파일로 격리하는 방식을 권장 예시로 보여 줍니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller의 기본 인증 객체 접근 방식은 @AuthenticationPrincipal 또는 그 위에 올린 프로젝트 전용 애노테이션
|
||||
- 프로젝트 기본 애노테이션은 @CurrentUser 또는 이에 준하는 이름을 권장
|
||||
- controller가 매번 SecurityContextHolder를 직접 읽지 않는다
|
||||
|
||||
### 3.3 인가 규칙은 인증 객체 접근 방식과 별개로 다룬다
|
||||
|
||||
Spring Security 문서는 요청 매칭 기반 보안 규칙을 일찍 적용하고, 동시에 method security를 함께 두는 defense in depth 를 권장합니다. 따라서 인증 객체를 꺼내는 문제와 권한 검사를 어디서 할지는 분리해서 설계해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 인증 객체 접근은 “현재 사용자가 누구인가”의 문제
|
||||
- 인가 규칙은 “이 사용자가 이 동작을 할 수 있는가”의 문제
|
||||
- controller 안에서 if (role == ...) 식으로 인가를 기본 구현하지 않는다
|
||||
- 인가는 security config + method security + application/domain 정책으로 나눈다
|
||||
|
||||
## 4. 접근 방식별 규칙
|
||||
|
||||
### 4.1 Principal
|
||||
|
||||
Spring MVC는 java.security.Principal을 controller method argument로 지원하며, 현재 인증된 사용자를 나타냅니다. Spring Security 환경에서는 Authentication이 Principal이므로 HttpServletRequest#getUserPrincipal() 경유로 주입될 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순히 현재 사용자 이름/식별자 정도만 필요하면 Principal 사용 가능
|
||||
- 하지만 principal 구현 타입 캐스팅을 기대하는 기본 스타일로는 쓰지 않는다
|
||||
- Principal은 가장 단순한 읽기 전용 접근에만 쓴다
|
||||
|
||||
권장 예:
|
||||
|
||||
- /me 같은 endpoint에서 현재 username만 필요한 경우
|
||||
|
||||
### 4.2 Authentication
|
||||
|
||||
Spring Security의 Authentication은 현재 사용자와 권한 정보를 담는 핵심 타입입니다. @CurrentSecurityContext(expression = "authentication")로도 controller 인자로 받을 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Authentication은 예외적으로만 controller에서 직접 받는다
|
||||
- 권한 목록, credentials, details 같은 Security framework 세부정보가 정말 필요할 때만 허용
|
||||
- 일반 비즈니스 endpoint의 기본 시그니처로 사용하지 않는다
|
||||
|
||||
즉, Authentication은 가능하지만 기본값은 아니다.
|
||||
|
||||
### 4.3 @AuthenticationPrincipal
|
||||
|
||||
Spring Security는 AuthenticationPrincipalArgumentResolver를 제공하고, @EnableWebSecurity를 쓰면 이를 MVC에 자동 추가합니다. 이 애노테이션은 Authentication.getPrincipal()을 controller method argument로 직접 받게 해 줍니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 현재 사용자 객체 접근의 기본값은 @AuthenticationPrincipal
|
||||
- 단, controller 시그니처가 Spring Security 애노테이션에 직접 결합되는 것이 싫다면 메타 애노테이션으로 감싼다
|
||||
- controller는 principal 내부 구조를 깊게 탐색하기보다 필요한 전용 타입을 주입받는다
|
||||
|
||||
### 4.4 프로젝트 전용 @CurrentUser 메타 애노테이션
|
||||
|
||||
Spring Security 문서는 @AuthenticationPrincipal을 감싼 @CurrentUser 메타 애노테이션 예시를 직접 제공하고, 이렇게 하면 MVC 레이어의 Spring Security 의존을 한 파일로 격리할 수 있다고 설명합니다. 또한 expression을 통해 JWT claim 같은 값만 바로 꺼내는 방식도 예시로 보여 줍니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 프로젝트 기본 방식은 @CurrentUser
|
||||
- @CurrentUser는 @AuthenticationPrincipal의 메타 애노테이션으로 구현
|
||||
- 필요하면 expression 기반으로 userId, subject, claims['sub'] 같은 값만 주입하는 파생 애노테이션도 허용
|
||||
|
||||
권장 방향:
|
||||
|
||||
- @CurrentUser AuthenticatedUser currentUser
|
||||
- 또는 @CurrentUserId String userId
|
||||
|
||||
### 4.5 @CurrentSecurityContext
|
||||
|
||||
Spring Security는 @CurrentSecurityContext로 SecurityContext 또는 Authentication을 controller method argument로 직접 주입할 수 있게 지원합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- @CurrentSecurityContext는 예외적 escape hatch
|
||||
- 일반 endpoint의 기본 접근 방식으로 사용하지 않는다
|
||||
- security context 전체가 필요한 framework-adjacent endpoint에서만 제한적으로 허용한다
|
||||
|
||||
예:
|
||||
|
||||
- 디버그/진단 endpoint
|
||||
- 보안 관련 내부 운영 endpoint
|
||||
|
||||
## 5. 계층별 규칙
|
||||
|
||||
### 5.1 Controller
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 인증 객체를 전용 현재 사용자 타입 또는 최소 식별자로 받는다
|
||||
- controller가 SecurityContextHolder를 직접 조회하지 않는다
|
||||
- controller는 principal에서 필요한 최소 정보만 추출해 application command/use case에 전달한다
|
||||
- controller가 Authentication, SecurityContext, UserDetails를 그대로 내부로 넘기지 않는다
|
||||
|
||||
### 5.2 Application
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- application service/use case는 Spring Security 타입을 모른다
|
||||
- 입력으로는 actorId, actorRoleSet, tenantId 같은 의미 있는 값만 받는다
|
||||
- “현재 로그인 사용자 조회”를 application 내부에서 직접 하지 않는다
|
||||
|
||||
즉, application은 현재 사용자가 누구인지가 아니라, 호출 주체가 누구라고 전달받았는지만 다룬다.
|
||||
|
||||
### 5.3 Domain
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- domain은 Spring Security 의존을 가지지 않는다
|
||||
- domain 객체/도메인 서비스/값 객체가 Authentication, Principal, UserDetails를 참조하지 않는다
|
||||
- 도메인 규칙이 호출 주체를 필요로 하면 명시적 값(ActorId, ActorType)으로 전달한다
|
||||
|
||||
### 5.4 Infrastructure / Security Adapter
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Spring Security principal 구성, claim 해석, JWT → 현재 사용자 변환은 infrastructure/security adapter에서 담당한다
|
||||
- principal 구현체, converter, resolver, 인증 토큰 해석 로직은 이 계층에 모은다
|
||||
- web/business 계층이 JWT claim 구조를 직접 파싱하지 않는다
|
||||
|
||||
## 6. 현재 사용자 타입 규칙
|
||||
|
||||
### 6.1 AuthenticatedUser 같은 전용 타입을 둔다
|
||||
|
||||
Spring Security는 principal 타입을 자유롭게 둘 수 있고, @AuthenticationPrincipal은 그 principal을 그대로 주입할 수 있습니다. 이 프로젝트에서는 controller용 인증 객체를 프로젝트 전용 타입 으로 두는 방식을 기본 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 전용 타입 예: AuthenticatedUser
|
||||
- 최소 권장 필드 예:
|
||||
- userId
|
||||
- authorities 또는 역할 집합
|
||||
- tenantId(필요 시)
|
||||
- password, credentials, provider-specific raw claim map을 기본 공개 필드로 두지 않는다
|
||||
|
||||
### 6.2 전용 타입은 “비즈니스에 필요한 최소 정보”만 담는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 현재 사용자 타입은 보안 프레임워크 내부 표현이 아니다
|
||||
- JWT 전체 claims map, raw token string, authentication details를 무비판적으로 싣지 않는다
|
||||
- 컨트롤러/유스케이스가 자주 필요로 하는 값만 담는다
|
||||
|
||||
## 7. 전달 규칙
|
||||
|
||||
### 7.1 application에는 최소 actor 정보만 넘긴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- AuthenticatedUser 전체를 application에 넘기는 것도 기본적으로 지양
|
||||
- 더 선호하는 것은 command/query 생성 시 필요한 최소 값만 복사하는 방식
|
||||
|
||||
예:
|
||||
|
||||
- CreateSessionCommand(actorId, email, password)
|
||||
- ChangePasswordCommand(actorId, currentPassword, newPassword)
|
||||
|
||||
### 7.2 principal을 전역 static 접근으로 다시 조회하지 않는다
|
||||
|
||||
Spring Security에서 현재 인증은 SecurityContextHolder에 저장되지만, 그 저장소가 있다는 사실이 곧 아무 계층에서나 static 접근으로 꺼내 써도 된다는 뜻은 아닙니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- service/domain/util에서 SecurityContextHolder.getContext() 직접 호출 금지
|
||||
- “현재 사용자 필요”는 메서드 인자로 드러나야 한다
|
||||
- 숨겨진 전역 의존성을 만들지 않는다
|
||||
|
||||
## 8. 권한 검사 규칙
|
||||
|
||||
### 8.1 권한 검사는 security rule + method security를 우선한다
|
||||
|
||||
Spring Security는 요청 매칭 기반 보안과 method security를 함께 두는 defense in depth를 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 역할/권한 검사는 기본적으로 security config 또는 method security에서 처리
|
||||
- controller 안의 imperative role check를 기본 금지
|
||||
- application/domain에서 추가 business authorization이 필요하면 명시적 정책으로 구현한다
|
||||
|
||||
### 8.2 “현재 사용자와 리소스 소유자 비교”는 business rule일 수 있다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순 권한(ROLE_ADMIN 등)은 security rule에 두는 쪽을 우선
|
||||
- “현재 사용자 ID와 리소스 owner가 같은가” 같은 규칙은 application/domain 정책일 수 있다
|
||||
- 이 경우에도 현재 사용자 정보는 최소 actor 값으로 전달한다
|
||||
|
||||
## 9. 테스트 규칙
|
||||
|
||||
### 9.1 controller 테스트는 프로젝트 전용 현재 사용자 접근을 기준으로 짠다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 테스트도 @CurrentUser 또는 프로젝트 principal 타입 기준으로 작성한다
|
||||
- 테스트 때문에 production code가 raw Authentication에 과도하게 결합되지 않게 한다
|
||||
- 보안 프레임워크 타입보다 프로젝트의 현재 사용자 계약을 검증한다
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- controller에서 SecurityContextHolder 직접 조회
|
||||
- controller 시그니처에 raw Authentication 남발
|
||||
- application/domain/service에서 Spring Security 타입 직접 사용
|
||||
- service/util에서 전역 static 방식으로 현재 사용자 조회
|
||||
- JWT claims/raw token을 여러 계층에서 직접 파싱
|
||||
- controller 안에서 if (role == ...) 식 인가 로직 구현
|
||||
- principal 구현체를 persistence/domain 모델로 겸용 사용
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 현재 사용자 접근이 controller/web 경계에 머무르는가?
|
||||
- 기본 접근 방식이 @CurrentUser 또는 이에 준하는 전용 애노테이션인가?
|
||||
- Spring Security 타입이 application/domain으로 번지지 않는가?
|
||||
- 유스케이스에는 필요한 최소 actor 정보만 전달되는가?
|
||||
- 권한 검사가 controller imperative code가 아니라 보안 규칙/정책으로 표현되는가?
|
||||
@@ -0,0 +1,245 @@
|
||||
# Error Code / HTTP Status Separation 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API 실패 응답에서 HTTP status와 application error code의 역할을 분리하는 기준을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- HTTP status를 프로토콜 의미에 맞게 사용한다
|
||||
- business/domain/application 오류 식별은 별도의 ErrorCode로 관리한다
|
||||
- controller/advice에서 status와 code를 뒤섞어 쓰는 일을 막는다
|
||||
- 실패 응답이 운영, 클라이언트 처리, 로그 분석에서 일관되게 동작하게 한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: HTTP Semantics(RFC 9110), Spring Framework 공식 문서/Javadoc에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 의미 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 HTTP status는 프로토콜 수준 의미다
|
||||
|
||||
HTTP status code는 응답의 결과와 의미를 나타내는 표준 3자리 코드이며, 첫 번째 자리가 응답 클래스(1xx~5xx)를 결정합니다. 4xx는 요청 자체가 잘못되었거나 현재 요청을 이행할 수 없는 경우이고, 5xx는 서버가 유효해 보이는 요청을 수행하지 못한 경우입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- HTTP status는 HTTP 관점의 결과를 나타낸다
|
||||
- status는 transport/protocol 의미를 표현한다
|
||||
- status를 business/domain 세부 사유 식별자로 남용하지 않는다
|
||||
|
||||
### 3.2 ErrorCode는 애플리케이션 수준 의미다
|
||||
|
||||
ErrorCode는 HTTP 표준 개념이 아니라, 프로젝트가 정의하는 기계 판독용 애플리케이션 오류 식별자다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ErrorCode는 business/application/framework error를 구분하는 식별자다
|
||||
- 클라이언트의 세부 분기, 운영 로그 분류, 문서화, 모니터링에 사용한다
|
||||
- ErrorCode는 HTTP status를 대체하지 않는다
|
||||
|
||||
이 항목은 Official + Practice 이다. HTTP가 status의 의미를 정의하고, 세부 오류 분류는 애플리케이션이 별도로 설계하는 것이 자연스럽다.
|
||||
|
||||
### 3.3 status와 code는 서로 다른 질문에 답한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- HTTP status는 “HTTP 요청을 어떤 범주로 처리했는가?”에 답한다
|
||||
- ErrorCode는 “애플리케이션에서 정확히 어떤 종류의 실패인가?”에 답한다
|
||||
|
||||
예:
|
||||
|
||||
- 400 Bad Request + REQUEST_VALIDATION_FAILED
|
||||
- 409 Conflict + DUPLICATE_EMAIL
|
||||
- 401 Unauthorized + INVALID_ACCESS_TOKEN
|
||||
- 503 Service Unavailable + UPSTREAM_AUTH_SERVER_UNAVAILABLE
|
||||
|
||||
즉, 두 값은 중복이 아니라 서로 다른 층위의 정보다.
|
||||
|
||||
## 4. HTTP status 사용 규칙
|
||||
|
||||
### 4.1 유효한 HTTP status만 사용한다
|
||||
|
||||
HTTP status의 유효 범위는 100~599이며, 그 밖의 값은 HTTP status로는 유효하지 않습니다. RFC 9110도 600~999 같은 값은 내부 통신에서 비표준적으로 쓰일 수는 있어도 HTTP 응답 status로는 유효하지 않다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 6xx, 7xx 같은 custom status code 사용 금지
|
||||
- status는 표준 HTTP status만 사용
|
||||
- 세부 오류 분기는 status가 아니라 ErrorCode로 해결한다
|
||||
|
||||
### 4.2 status는 최대한 표준 의미에 가깝게 선택한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 입력 형식/검증 실패 → 400 계열
|
||||
- 인증 실패 → 401
|
||||
- 권한 부족 → 403
|
||||
- 리소스 없음 → 404
|
||||
- 상태 충돌/중복/현재 상태와의 모순 → 409
|
||||
- 의미적으로 처리 불가능한 요청을 별도로 구분할 합의가 있으면 422 검토 가능
|
||||
- 예상 못 한 서버 오류 → 500
|
||||
- 일시적 외부 의존성 실패 → 502/503/504 중 의미에 맞는 값 선택
|
||||
|
||||
이 항목은 Official + Practice 이다. RFC 9110이 status class 의미를 정의하고, 세부 매핑은 API 설계자가 해당 의미에 맞게 선택해야 한다.
|
||||
|
||||
### 4.3 같은 business family가 항상 같은 status일 필요는 없지만, 이유는 분명해야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 ErrorCode family라도 상황에 따라 status가 달라질 수 있다
|
||||
- 다만 같은 의미의 오류에 status가 들쭉날쭉하면 안 된다
|
||||
- status 선택 기준은 문서와 ErrorCode 정책에 남긴다
|
||||
|
||||
예:
|
||||
|
||||
- AUTHENTICATION_FAILED 류는 보통 401
|
||||
- AUTHORIZATION_DENIED 류는 보통 403
|
||||
- RESOURCE_CONFLICT 류는 보통 409
|
||||
|
||||
## 5. ErrorCode 사용 규칙
|
||||
|
||||
### 5.1 ErrorCode는 중앙 정책 타입으로 관리한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ErrorCode는 enum 또는 이에 준하는 중앙 정책 타입으로 관리한다
|
||||
- controller/advice/service 각 파일에 문자열 리터럴로 흩뿌리지 않는다
|
||||
- code, 기본 message, 기본 httpStatus를 함께 관리할 수 있다
|
||||
|
||||
이 규칙은 실무적으로 가장 흔한 안정화 방식이며, 앞선 응답 포맷 규약과도 맞물린다.
|
||||
|
||||
### 5.2 ErrorCode는 외부 계약이다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 한 번 공개된 ErrorCode는 API 계약으로 취급한다
|
||||
- 이름 변경, 삭제, 의미 변경은 호환성 영향이 있다
|
||||
- 로그용 내부 키와 외부 응답용 code를 필요하면 분리한다
|
||||
|
||||
### 5.3 message는 ErrorCode의 기본 외부 메시지로 관리할 수 있다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 외부 메시지는 ErrorCode가 가진다
|
||||
- advice는 예외를 적절한 ErrorCode로 매핑하는 책임에 집중한다
|
||||
- ex.getMessage()를 외부 응답 메시지 기본값으로 쓰지 않는다
|
||||
|
||||
## 6. status와 ErrorCode의 관계
|
||||
|
||||
### 6.1 하나의 status 아래 여러 ErrorCode가 올 수 있다
|
||||
|
||||
HTTP는 status class로 넓은 의미를 표현하므로, 하나의 400/409/500 아래에 여러 세부 application code가 오는 것이 자연스럽다. 이는 HTTP status가 세부 business 오류 식별용이 아니기 때문이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
400 아래:
|
||||
|
||||
- REQUEST_VALIDATION_FAILED
|
||||
- INVALID_QUERY_PARAMETER
|
||||
- MALFORMED_JSON_REQUEST
|
||||
|
||||
409 아래:
|
||||
|
||||
- DUPLICATE_EMAIL
|
||||
- SESSION_ALREADY_REVOKED
|
||||
- RESOURCE_VERSION_CONFLICT
|
||||
|
||||
처럼 관리할 수 있다.
|
||||
|
||||
### 6.2 하나의 ErrorCode는 기본 status를 가진다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 각 ErrorCode는 기본적으로 하나의 대표 HTTP status를 가진다
|
||||
- 기본 status는 중앙 정책 타입에서 관리한다
|
||||
- 예외적 override가 필요한 경우에만 advice에서 분기한다
|
||||
|
||||
### 6.3 “200 OK + success=false”를 기본 실패 전략으로 쓰지 않는다
|
||||
|
||||
HTTP status는 응답의 결과 의미를 담는 표준 필드이므로, 실패를 body의 success=false에만 넣고 status를 무조건 200으로 보내는 방식은 status 의미를 약화시킨다. RFC 9110은 status code가 요청 결과와 응답 의미를 나타낸다고 명확히 정의한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 실패 응답은 적절한 4xx/5xx status를 함께 사용한다
|
||||
- ApiResult.success=false는 body 규약 보강용이지, status 대체물이 아니다
|
||||
- “모든 응답은 200” 전략을 기본 금지한다
|
||||
|
||||
## 7. Spring 사용 규칙
|
||||
|
||||
### 7.1 일반 실패 응답은 @RestControllerAdvice + ResponseEntity를 기본으로 한다
|
||||
|
||||
Spring은 @ExceptionHandler에서 ResponseEntity를 반환해 status와 body를 함께 제어할 수 있게 하고, @ControllerAdvice/@RestControllerAdvice로 전역 적용할 수 있다. ResponseEntity는 status·headers·body를 함께 표현하는 타입이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공통 실패 응답은 @RestControllerAdvice에서 생성
|
||||
- body는 ApiResult.fail(ErrorCode...)
|
||||
- status는 ErrorCode가 가진 기본 status 또는 정책에 맞는 값 사용
|
||||
|
||||
### 7.2 예외 클래스에 @ResponseStatus를 기본 전략으로 두지 않는다
|
||||
|
||||
Spring의 ResponseStatusExceptionResolver는 @ResponseStatus와 ResponseStatusException을 status로 매핑한다. 하지만 @ResponseStatus Javadoc은 예외 클래스에 이 애노테이션을 붙이거나 reason을 주면 sendError가 사용되고, REST API에는 부적합할 수 있으므로 이런 경우 ResponseEntity를 선호하라고 명시합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- business exception 클래스에 @ResponseStatus를 기본적으로 붙이지 않는다
|
||||
- 특히 reason 사용 금지
|
||||
- 예외는 domain/application 의미를 표현하고, HTTP status 변환은 advice에서 수행한다
|
||||
|
||||
### 7.3 ResponseStatusException은 제한적으로 사용한다
|
||||
|
||||
Spring은 ResponseStatusException을 공식 지원하고 resolver가 이를 status로 처리한다. 다만 이것은 HTTP-aware 예외이므로 controller/web adapter 쪽에서는 유용할 수 있지만, application/domain 핵심 로직까지 전파되는 기본 모델로 두는 것은 바람직하지 않다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- web adapter/controller 레벨의 즉시 HTTP 실패 표현이 필요할 때 제한적으로 사용 가능
|
||||
- application/domain/service의 기본 예외 모델로 채택하지 않는다
|
||||
- 프로젝트 기본 경로는 여전히 “도메인/애플리케이션 예외 → advice에서 ErrorCode/status 매핑”이다
|
||||
|
||||
## 8. 설계 권장안
|
||||
|
||||
### 8.1 ErrorCode가 기본 status를 가진다
|
||||
|
||||
권장 구조:
|
||||
|
||||
- ErrorCode
|
||||
- httpStatus
|
||||
- code
|
||||
- message
|
||||
- ApiResult.fail(ErrorCode)
|
||||
- advice는 예외를 ErrorCode로 매핑
|
||||
|
||||
이 구조는 status와 code의 역할을 분리하면서도, 운영 시 일관된 실패 정책을 유지하기 쉽다.
|
||||
|
||||
### 8.2 advice는 문자열 조립보다 매핑에 집중한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- advice는 예외 → ErrorCode 선택
|
||||
- ApiResult는 ErrorCode에서 code/message를 읽어 body 생성
|
||||
- status는 ErrorCode.httpStatus() 또는 명시적 override로 결정
|
||||
|
||||
## 9. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 6xx/7xx 같은 custom HTTP status 사용
|
||||
- 실패를 무조건 200 OK로 응답하고 body에만 실패 표시
|
||||
- advice/controller에 "DUPLICATE_EMAIL" 같은 문자열 하드코딩
|
||||
- ex.getMessage()를 그대로 외부 응답 메시지로 사용
|
||||
- domain/application 예외 클래스에 @ResponseStatus(reason=...) 사용
|
||||
- HTTP status와 ErrorCode를 사실상 같은 값처럼 중복 설계
|
||||
- endpoint마다 같은 오류에 다른 status를 제멋대로 사용
|
||||
|
||||
## 10. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 status는 HTTP 의미로 설명 가능한가?
|
||||
- 이 세부 실패 식별은 ErrorCode로 따로 표현되는가?
|
||||
- 100~599 범위의 표준 status만 쓰고 있는가?
|
||||
- 실패인데도 200으로 보내고 있지 않은가?
|
||||
- ErrorCode가 중앙 정책 타입으로 관리되는가?
|
||||
- @ResponseStatus(reason=...) 대신 advice + ResponseEntity를 쓰고 있는가?
|
||||
@@ -0,0 +1,297 @@
|
||||
# Idempotency 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API에서 멱등성(idempotency)을 어떻게 정의하고, 어디에 적용하며, 어떤 방식으로 구현할지 정한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- HTTP 메서드 자체의 멱등성과 애플리케이션 수준 멱등성을 구분한다.
|
||||
- 네트워크 타임아웃, 응답 유실, 재시도 상황에서 중복 생성/중복 실행을 막는다.
|
||||
- Idempotency-Key 기반 중복 방지 정책을 프로젝트 단위로 통일한다.
|
||||
- controller, service, storage, 응답 규약에서 멱등성 책임을 분명히 한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: RFC 9110, IETF HTTPAPI draft, 공개 API 가이드/공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 의미 위에 Stripe/PayPal 같은 실무 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 HTTP 메서드의 멱등성과 애플리케이션 멱등성은 다르다
|
||||
|
||||
RFC 9110 기준으로 GET, HEAD, OPTIONS, TRACE는 safe 이고, PUT, DELETE, 그리고 safe 메서드들은 idempotent 입니다. 같은 요청을 여러 번 보내도 서버에 의도된 효과는 한 번과 같아야 합니다. 반면 POST는 기본적으로 idempotent가 아니고, 클라이언트는 특별한 근거가 없으면 자동 재시도하면 안 됩니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- HTTP 멱등성은 메서드 의미에 관한 규칙이다.
|
||||
- 애플리케이션 멱등성은 중복 요청 방지와 재시도 안전성에 관한 규칙이다.
|
||||
- PUT/DELETE가 HTTP 차원에서 idempotent라고 해서, 모든 business side effect까지 자동으로 안전하다고 가정하지 않는다.
|
||||
- POST/PATCH가 기본적으로 비멱등이므로, 재시도 안전성이 필요하면 별도 설계를 둔다.
|
||||
|
||||
### 3.2 이 프로젝트의 멱등성 기본 전략은 Idempotency-Key다
|
||||
|
||||
IETF 초안은 Idempotency-Key 요청 헤더를 사용해 POST·PATCH 같은 비멱등 메서드를 fault-tolerant 하게 만드는 방향을 제시하고 있고, 서버는 키의 유일성·만료 정책·중복 처리 방식을 문서화해야 한다고 설명합니다. Stripe와 PayPal도 같은 취지로 client-generated key/header를 사용합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 비멱등 command endpoint의 기본 멱등성 수단은 Idempotency-Key 요청 헤더
|
||||
- 공개 API에서 별도 사유가 없으면 proprietary header보다 Idempotency-Key를 우선 사용
|
||||
- 외부 third-party 연동에서 상대방이 다른 이름의 헤더를 요구하면 adapter에서 변환한다
|
||||
|
||||
### 3.3 멱등성의 목적은 “같은 의도”의 안전한 재시도다
|
||||
|
||||
IETF 초안은 같은 key가 같은 요청의 재시도를 식별하기 위한 것이라고 설명하고, Stripe도 동일 key에 대해 첫 결과를 재사용한다고 설명합니다. 즉, 멱등 키는 “대충 중복 방지용 문자열”이 아니라 같은 요청 의도에 대한 재시도 식별자입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 멱등 키는 “같은 요청을 다시 보내는 경우”에만 재사용한다
|
||||
- 요청 의도가 바뀌면 새 키를 생성한다
|
||||
- 멱등 키를 “세션 ID”나 “사용자 식별자”처럼 장기 재사용 식별자로 쓰지 않는다
|
||||
|
||||
## 4. 적용 대상
|
||||
|
||||
### 4.1 기본적으로 적용해야 하는 endpoint
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음처럼 중복 실행 위험이 큰 비멱등 요청에는 멱등성을 기본 검토 대상으로 둔다.
|
||||
|
||||
- 리소스 생성 POST
|
||||
- 상태 변경 command POST/PATCH
|
||||
- 외부 결제/인증/발급/전송과 연결된 요청
|
||||
- 타임아웃 후 client 재시도가 현실적으로 자주 일어날 수 있는 요청
|
||||
- “한 번만 수행돼야 하는” business command
|
||||
|
||||
예:
|
||||
|
||||
- 회원 가입
|
||||
- 세션/토큰 발급
|
||||
- 비밀번호 변경
|
||||
- 이메일 인증 발송
|
||||
- 환불/정산/결제 확정
|
||||
|
||||
### 4.2 기본적으로 적용하지 않는 endpoint
|
||||
|
||||
RFC 9110 기준으로 GET/HEAD/OPTIONS/TRACE는 safe이고, PUT/DELETE는 idempotent입니다. Stripe도 GET/DELETE에 idempotency key를 보내도 효과가 없다고 안내합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- GET/HEAD/OPTIONS/TRACE에는 Idempotency-Key를 기본적으로 사용하지 않는다
|
||||
- PUT/DELETE는 HTTP 의미상 이미 idempotent이므로, 별도 애플리케이션 멱등 키는 기본값이 아니다
|
||||
- 다만 PUT/DELETE가 추가 외부 side effect를 동반하는 특수 endpoint면 별도 검토할 수 있다
|
||||
|
||||
## 5. 키 규칙
|
||||
|
||||
### 5.1 키는 클라이언트가 생성한다
|
||||
|
||||
IETF 초안은 key를 client가 생성한 고유 값으로 설명하고, UUID 같은 random identifier 사용을 권장합니다. Stripe도 V4 UUID 또는 충분한 entropy를 가진 랜덤 문자열을 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Idempotency-Key는 클라이언트 생성
|
||||
- 서버가 멱등 키를 대신 생성해서 응답으로 내려주고 다음 요청에서 재사용하게 하는 방식을 기본으로 두지 않는다
|
||||
- 권장 형식은 UUID v4 또는 이에 준하는 고엔트로피 opaque string
|
||||
|
||||
### 5.2 키에는 민감정보를 넣지 않는다
|
||||
|
||||
Stripe는 idempotency key에 이메일 주소나 개인 식별자 같은 민감정보를 넣지 말라고 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 키에는 이메일, 전화번호, 주민번호, 사용자명 같은 의미 있는 개인정보를 넣지 않는다
|
||||
- 키는 opaque value 로 취급한다
|
||||
- 로그에도 원문 전체를 무분별하게 남기지 않는다
|
||||
|
||||
### 5.3 키의 유효 범위(scope)를 정의한다
|
||||
|
||||
IETF 초안은 key의 유일성 기준은 resource owner가 정의해야 한다고 설명합니다. 즉, “어디까지 같은 key로 보느냐”는 서버 정책입니다.
|
||||
|
||||
프로젝트 기본 규칙:
|
||||
|
||||
멱등 키 scope는 최소한 다음을 포함해 판단한다
|
||||
|
||||
- HTTP method
|
||||
- 정규화된 operation/resource
|
||||
- 호출 주체(actor/client)
|
||||
- idempotency key
|
||||
- 같은 key라도 다른 operation 이면 충돌로 보지 않는다
|
||||
- 같은 key라도 다른 사용자/클라이언트 면 같은 요청으로 취급하지 않는다
|
||||
|
||||
권장 예:
|
||||
|
||||
```text
|
||||
(actorId, operationName, idempotencyKey)
|
||||
```
|
||||
|
||||
## 6. fingerprint 규칙
|
||||
|
||||
### 6.1 키만 보지 말고 fingerprint도 비교한다
|
||||
|
||||
IETF 초안은 서버가 request payload로부터 idempotency fingerprint 를 생성할 수 있고, checksum·선택 필드 비교·request digest 등으로 요청 동일성을 판단할 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 서버는 key만 저장하지 말고 request fingerprint 도 함께 관리한다
|
||||
- fingerprint는 다음 요소를 기반으로 구성한다
|
||||
- method
|
||||
- operation/resource
|
||||
- actor/client
|
||||
- request body의 canonical form 또는 의미 필드
|
||||
- fingerprint 비교 없이 key만 믿고 중복 처리하지 않는다
|
||||
|
||||
### 6.2 fingerprint는 “의미적으로 같은 요청” 기준으로 만든다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순 raw JSON 문자열 비교보다 의미 필드 기준 비교를 우선 검토한다
|
||||
- 필드 순서 차이, 불필요한 공백 차이, 서버가 무시하는 필드 차이 때문에 다른 요청으로 오판하지 않게 한다
|
||||
- 반대로 실제 business 의미가 다른 요청은 반드시 다른 fingerprint가 되게 한다
|
||||
|
||||
## 7. 저장/처리 규칙
|
||||
|
||||
### 7.1 첫 완료 결과를 저장하고 같은 결과를 재생한다
|
||||
|
||||
IETF 초안은 중복 요청이 원래 요청 완료 후 재시도된 경우, 서버가 이전에 완료된 작업의 결과를 다시 응답해야 한다고 설명합니다. Stripe도 같은 key에 대해 첫 요청의 status code와 body를 재사용하고, 성공뿐 아니라 실패 결과도 재사용한다고 명시합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
같은 key + 같은 fingerprint + 이미 완료된 요청이면
|
||||
|
||||
- 같은 status
|
||||
- 같은 body
|
||||
- 필요하면 같은 핵심 header(Location 등)
|
||||
|
||||
를 재응답한다.
|
||||
중복 요청이라고 해서 새 business execution을 다시 시작하지 않는다.
|
||||
|
||||
### 7.2 요청이 아직 처리 중이면 409를 기본으로 한다
|
||||
|
||||
IETF 초안은 원 요청이 아직 처리 중인 상태에서 같은 key로 재시도되면 409 Conflict 를 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 key + 같은 fingerprint인데 원 요청이 in progress 면 기본 응답은 409 Conflict
|
||||
- body code는 예:
|
||||
- IDEMPOTENCY_REQUEST_IN_PROGRESS
|
||||
- 이 경우 client는 잠시 후 같은 key로 다시 재시도할 수 있다
|
||||
|
||||
### 7.3 같은 키를 다른 요청에 재사용하면 거절한다
|
||||
|
||||
IETF 초안은 같은 key를 다른 payload 로 재사용하면 422 Unprocessable Content 를 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 key + 다른 fingerprint는 기본적으로 422 Unprocessable Content
|
||||
- body code는 예:
|
||||
- IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST
|
||||
- 서버는 조용히 새 요청으로 처리하지 않는다
|
||||
|
||||
### 7.4 키가 필요한 endpoint에서 키가 없으면 400을 기본으로 한다
|
||||
|
||||
IETF 초안은 멱등 키가 문서상 필수인 operation에서 헤더가 없으면 400 Bad Request 를 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 멱등 키 필수 endpoint에서 헤더 누락 시 400 Bad Request
|
||||
- body code는 예:
|
||||
- IDEMPOTENCY_KEY_REQUIRED
|
||||
|
||||
## 8. 만료(TTL) 규칙
|
||||
|
||||
### 8.1 TTL은 반드시 문서화한다
|
||||
|
||||
IETF 초안은 서버가 key의 expiration policy를 문서화해야 한다고 설명합니다. Stripe는 key를 최소 24시간 이후 자동 제거 가능 하다고 말하고, PayPal은 일부 POST API에서 PayPal-Request-Id를 최대 45일 예시로 보여 줍니다. 즉, TTL에는 업계 단일 정답이 없습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 멱등 키 TTL은 endpoint 문서에 명시한다
|
||||
- 프로젝트 기본 최소 TTL 권장값은 24시간
|
||||
- 금융/정산/고비용 side effect는 더 긴 TTL을 검토한다
|
||||
- TTL이 지나면 같은 key는 새 요청으로 처리될 수 있음을 문서화한다
|
||||
|
||||
### 8.2 TTL은 business 위험에 따라 다르게 줄 수 있다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순 생성/변경: 24시간 전후
|
||||
- 고비용 외부 side effect: 더 긴 TTL 가능
|
||||
- 너무 긴 TTL은 key storage 비용과 오탐 가능성을 높이므로 무작정 늘리지 않는다
|
||||
|
||||
## 9. 구현 규칙
|
||||
|
||||
### 9.1 멱등성 저장소는 다중 인스턴스 환경에서도 일관돼야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- production에서는 프로세스 메모리만으로 멱등성 보장 금지
|
||||
- 다중 인스턴스에서 공유되는 저장소를 사용한다
|
||||
- RDB
|
||||
- Redis
|
||||
- 기타 내구성 있는 shared store
|
||||
- “한 서버에만 있는 ConcurrentHashMap” 으로 끝내지 않는다
|
||||
|
||||
### 9.2 business write와 멱등성 기록은 원자성 경계를 검토한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- “실제 side effect는 일어났는데 idempotency record는 안 남는” 상태를 최대한 줄인다
|
||||
- 가능하면 business state write와 idempotency completion 기록의 원자성/정합성을 맞춘다
|
||||
- 외부 시스템까지 걸친 완전 원자성은 어렵더라도, 적어도 중복 실행을 줄이는 방향 으로 설계한다
|
||||
|
||||
### 9.3 controller보다 application/service 경계에 두는 것을 기본으로 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 Idempotency-Key를 읽어 application command로 전달
|
||||
- 실제 중복 방지 판정, fingerprint 비교, 결과 재생은 application/service 전용 구성요소가 담당
|
||||
- controller에서 직접 storage를 만지며 멱등성 로직을 구현하지 않는다
|
||||
|
||||
## 10. 응답 규칙
|
||||
|
||||
### 10.1 멱등성 오류도 일반 실패 응답 규약을 따른다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 멱등성 관련 오류도 ApiResult.fail(ErrorCode...) 형식을 따른다
|
||||
|
||||
예:
|
||||
|
||||
- IDEMPOTENCY_KEY_REQUIRED
|
||||
- IDEMPOTENCY_REQUEST_IN_PROGRESS
|
||||
- IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST
|
||||
- 멱등성 오류라고 해서 별도 임시 JSON 구조를 만들지 않는다
|
||||
|
||||
### 10.2 재생된 응답임을 알려야 할지 여부는 API군 단위로 정한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 필요하면 X-Idempotent-Replay: true 같은 응답 헤더를 둘 수 있다
|
||||
- 하지만 body 계약을 바꿔서 “재생 응답” 전용 구조를 만들지는 않는다
|
||||
- 헤더 사용 여부는 API군 단위로 일관되게 정한다
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- GET/HEAD에 멱등 키를 기본 요구
|
||||
- 같은 key를 다른 요청 의도에 재사용
|
||||
- 민감정보를 key에 포함
|
||||
- controller 안에서 멱등성 저장/판정을 직접 구현
|
||||
- 다중 인스턴스 환경에서 로컬 메모리만으로 멱등성 보장
|
||||
- 같은 key + 다른 payload를 조용히 새 요청으로 처리
|
||||
- 실패 응답을 무조건 200 OK로 보내고 body만 실패로 표시
|
||||
- TTL/적용 대상/재시도 정책 문서 없이 운영
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 endpoint는 비멱등 요청이며 재시도 안전성이 필요한가?
|
||||
- Idempotency-Key 적용 여부가 문서화돼 있는가?
|
||||
- key scope와 fingerprint 기준이 정의돼 있는가?
|
||||
- 같은 key + 같은 fingerprint 재시도 시 같은 결과를 재생하는가?
|
||||
- 같은 key + 다른 fingerprint 재사용을 거절하는가?
|
||||
- in-flight duplicate를 409로 처리하는가?
|
||||
- TTL과 저장소 전략이 production 환경에 맞는가?
|
||||
@@ -0,0 +1,330 @@
|
||||
# Pagination / Sort / Filter 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 목록 조회 API의 pagination, sort, filter 규약을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 목록 조회 endpoint의 query contract를 일관되게 만든다
|
||||
- page 기반과 cursor 기반 pagination의 사용 기준을 구분한다
|
||||
- 정렬과 필터의 허용 범위를 명확히 한다
|
||||
- Spring Data의 편의 기능과 공개 API 계약을 분리한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Data / 공개 API 가이드에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 문서의 확장 지점 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 목록 조회의 입력은 query parameter를 기본으로 한다
|
||||
|
||||
Spring MVC에서 @RequestParam은 query parameter나 form data를 controller method argument에 바인딩하는 공식 방법이며, 같은 이름의 파라미터를 여러 번 보내는 경우 리스트나 배열로도 받을 수 있습니다. Azure 가이드도 pagination과 filtering을 query parameter로 제공하라고 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 목록 조회 입력은 기본적으로 query parameter로 받는다
|
||||
- pagination, sort, filter는 request body가 아니라 query contract로 노출한다
|
||||
- GET 목록 조회에 body filtering을 기본 전략으로 쓰지 않는다
|
||||
|
||||
### 3.2 Spring의 Pageable/Sort 지원은 “프레임워크 편의”이지 “공개 API 계약”은 아니다
|
||||
|
||||
Spring Data web support는 controller method argument로 Pageable과 Sort를 바로 받을 수 있게 해 주고, 기본 페이지 해석도 제공한다. 하지만 그것은 Spring 애플리케이션 내부 편의 기능이지, 외부 클라이언트에 그대로 노출해야 하는 계약이라는 뜻은 아닙니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공개 API controller는 raw Pageable/Sort를 기본 시그니처로 사용하지 않는다
|
||||
- 외부 계약은 명시적 request DTO 또는 명시적 query parameter 규약으로 드러낸다
|
||||
- repository/application 내부에서는 필요 시 Pageable/Sort를 사용할 수 있다
|
||||
|
||||
### 3.3 pagination, sort, filter는 함께 설계한다
|
||||
|
||||
Azure 가이드는 sorting이 filtering과 조합되어야 하며, paginated list에서는 모든 페이지에서 같은 filtering options와 sort order를 유지하라고 권장합니다. GitHub도 실제 paginated endpoint에서 다음 페이지 URL을 Link header로 주며, page/cursor 계열 query parameter를 계속 이어서 사용합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- pagination, sort, filter는 서로 독립 기능처럼 보이더라도 하나의 목록 조회 계약으로 설계한다
|
||||
- 다음 페이지를 조회할 때 filter/sort가 바뀌지 않게 설계한다
|
||||
- 페이지 이동 중 계약이 흔들리면 안 된다
|
||||
|
||||
## 4. Pagination 표준
|
||||
|
||||
### 4.1 기본값은 page-based pagination이다
|
||||
|
||||
Azure는 일반적인 데이터 조회에서 limit/offset 형태의 pagination을 예시로 제시하고, GitHub는 page 및 cursor 계열 파라미터를 실제로 사용합니다. Spring Data도 Pageable 기반 paging을 폭넓게 지원합니다.
|
||||
|
||||
프로젝트 기본 규칙:
|
||||
|
||||
- 일반 목록 조회 endpoint의 기본 pagination은 page-based
|
||||
- 외부 계약 기본 파라미터는 page, size
|
||||
- 공개 API에서는 외부 page는 1-based 로 둔다
|
||||
- 내부 Spring Data 변환 시 필요하면 0-based PageRequest로 변환한다
|
||||
|
||||
### 4.2 공개 API는 Spring 내부의 0-based 페이지 번호를 그대로 노출하지 않는다
|
||||
|
||||
Spring Data의 Pageable은 첫 페이지를 0으로 다루는 API를 제공하고, Pageable.ofSize(...)도 첫 페이지를 page number 0으로 생성합니다. 하지만 공개 API가 반드시 그 내부 표현을 따라야 하는 것은 아닙니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API는 page=1부터 시작한다
|
||||
- controller 또는 mapper에서 내부 0-based Pageable로 변환한다
|
||||
- Spring 내부 표현을 외부 계약에 그대로 새어 나오게 하지 않는다
|
||||
|
||||
### 4.3 page size는 기본값과 최대값을 반드시 둔다
|
||||
|
||||
Azure는 pagination 예시에서 limit와 offset에 의미 있는 기본값을 둘 것을 권장합니다. GitHub도 per_page를 통해 페이지 크기를 조절하지만, endpoint별 한도가 존재합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- size는 기본값과 최대값을 가진다
|
||||
- 기본값 예: 20
|
||||
- 최대값 예: 100
|
||||
- 최대값을 넘는 요청은 보정하거나 400으로 거절하는 정책을 API군 단위로 일관되게 정한다
|
||||
|
||||
### 4.4 Page가 항상 정답은 아니다
|
||||
|
||||
Spring Data에서 Page는 전체 개수와 전체 페이지 수를 알기 위해 추가 count query를 수행할 수 있고, 그 비용이 비쌀 수 있습니다. Slice는 다음 페이지 존재 여부만 알고, List는 count metadata를 만들지 않습니다. 또한 큰 offset 기반 조회는 비효율적일 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- totalCount가 반드시 필요한 목록만 Page 스타일 메타데이터를 제공한다
|
||||
- 다음 페이지 존재만 알면 충분한 목록은 Slice 스타일 응답을 선호한다
|
||||
- count query 비용이 큰 도메인에서는 무조건 total count를 주지 않는다
|
||||
|
||||
### 4.5 대용량/변동이 큰 목록은 cursor pagination을 우선 검토한다
|
||||
|
||||
Spring Data의 scrolling/keyset filtering은 stable sort order를 전제로 다음 구간을 더 효율적으로 가져올 수 있고, 큰 offset을 건너뛰는 비용을 줄이도록 설계되어 있습니다. GitHub도 실제 API에서 before/after/since 같은 cursor 계열 파라미터를 사용합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음 조건이면 cursor pagination을 우선 검토한다
|
||||
|
||||
- 데이터가 매우 크다
|
||||
- 최신순 피드/로그/이벤트처럼 계속 변한다
|
||||
- 큰 offset 페이지를 자주 조회한다
|
||||
- cursor는 opaque string 으로 노출한다
|
||||
- 내부 keyset 구조를 외부에 직접 노출하지 않는다
|
||||
|
||||
### 4.6 cursor pagination은 stable sort가 필수다
|
||||
|
||||
Spring Data keyset filtering은 stable sorting order를 전제로 하고, sort 필드와 primary key를 함께 사용해 다음 위치를 계산합니다. 또한 keyset 필드는 non-nullable이어야 하며, 적절한 인덱스가 있을 때 가장 잘 동작합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- cursor pagination에는 안정적인 정렬 기준이 반드시 있어야 한다
|
||||
- 기본 정렬 필드만으로 충돌 가능성이 있으면 tie-breaker로 id 같은 고유 키를 추가한다
|
||||
- nullable field를 cursor 핵심 정렬 키로 쓰는 것은 지양한다
|
||||
|
||||
## 5. Sort 표준
|
||||
|
||||
### 5.1 정렬은 명시적 허용 목록 기반으로 제공한다
|
||||
|
||||
Azure 가이드는 정렬에 사용할 수 없는 필드를 요청하면 에러를 반환하라고 권장하고, 값의 inherent order를 따르라고 안내합니다. Spring Data는 Sort와 Pageable로 정렬을 처리할 수 있지만, 어떤 필드를 정렬 가능하게 열 것인지는 애플리케이션이 결정해야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 정렬 가능 필드는 allowlist로 관리한다
|
||||
- 지원하지 않는 필드 정렬 요청은 무시하지 말고 400 으로 응답한다
|
||||
- DB 컬럼명이나 내부 경로를 그대로 외부 sort key로 노출하지 않는다
|
||||
|
||||
### 5.2 기본 정렬을 반드시 둔다
|
||||
|
||||
Spring Data keyset scrolling도 stable order를 전제로 하고, pagination이 있는 목록은 정렬 기준이 흐리면 페이지 이동 중 결과가 흔들릴 수 있습니다. Azure도 sorting과 pagination의 일관성을 강조합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 목록 endpoint는 기본 정렬을 가진다
|
||||
- 권장 기본 정렬 예:
|
||||
- 생성일 내림차순
|
||||
- 수정일 내림차순
|
||||
- 이름 오름차순
|
||||
- tie-breaker가 필요하면 id를 마지막 정렬 키로 고정한다
|
||||
|
||||
### 5.3 외부 정렬 파라미터 형식은 하나로 통일한다
|
||||
|
||||
Spring Data는 query parameter로 Sort를 해석하는 지원을 제공하지만, 공개 API는 더 읽기 쉬운 별도 규약을 가질 수 있습니다. Azure 예시는 orderby=name desc,hireDate 같은 문법도 보여 줍니다. 업계 관행은 다양하므로 프로젝트가 하나를 고정하는 것이 더 중요합니다.
|
||||
|
||||
프로젝트 기본 규칙:
|
||||
|
||||
- 기본 형식은 sortBy + direction
|
||||
- 다중 정렬이 정말 필요한 API군에서만 반복 sort 같은 확장 형식을 허용
|
||||
- 같은 API product 안에서 sortBy/direction, orderby, 반복 sort를 혼용하지 않는다
|
||||
|
||||
### 5.4 정렬은 의미 단위로 노출한다
|
||||
|
||||
Azure는 필드 타입의 inherent order를 따르라고 권장합니다. 즉, 날짜는 시간순, 숫자는 숫자순으로 정렬되어야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 날짜는 시간순 의미로 정렬한다
|
||||
- 문자열 표시명과 내부 저장 키가 다르면 외부 의미 기준의 정렬 키를 정의한다
|
||||
- “보여지는 값”과 “정렬되는 값”의 의미가 다르면 문서화한다
|
||||
|
||||
## 6. Filter 표준
|
||||
|
||||
### 6.1 필터는 명시적 query parameter를 기본으로 한다
|
||||
|
||||
Spring MVC @RequestParam은 query parameter 바인딩의 기본 도구이고, 반복 파라미터도 리스트로 받을 수 있습니다. Azure도 filtering을 query 기반으로 제공하라고 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
기본 필터는 명시적 named query parameter로 노출한다
|
||||
|
||||
예:
|
||||
|
||||
- status=ACTIVE
|
||||
- role=ADMIN
|
||||
- keyword=alice
|
||||
- createdFrom=...
|
||||
- createdTo=...
|
||||
- 외부 계약은 읽기 쉬운 이름을 사용한다
|
||||
|
||||
### 6.2 다중 값 필터는 반복 query parameter를 우선한다
|
||||
|
||||
Spring은 같은 이름의 request parameter를 여러 번 보내면 배열/리스트로 받을 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다중 선택 필터 기본 형식은 반복 parameter
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
status=ACTIVE&status=PENDING
|
||||
```
|
||||
|
||||
- comma-separated 형식은 API군 전체 합의가 있을 때만 허용
|
||||
- 같은 API군에서 두 방식을 혼용하지 않는다
|
||||
|
||||
### 6.3 범위 필터는 의미가 드러나는 이름을 쓴다
|
||||
|
||||
이 항목은 주로 Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 시간 범위: createdFrom, createdTo
|
||||
- 수치 범위: minPrice, maxPrice
|
||||
- 불리언 필터: includeInactive=true
|
||||
- 비교 연산자를 query string DSL로 억지로 숨기기보다 의미가 드러나는 파라미터명을 우선한다
|
||||
|
||||
### 6.4 generic filter DSL은 기본 금지다
|
||||
|
||||
Azure는 query-based filtering을 권장하지만, 모든 API가 OData 수준의 범용 filter 문법을 가져야 한다고 요구하지는 않습니다. 실제 실무에서도 공개 API는 명시적 필터 파라미터를 더 많이 사용합니다. GitHub 역시 endpoint별로 filter, state, sort 등 명시적 파라미터를 사용합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 공개 API에서는 범용 문자열 DSL filter를 도입하지 않는다
|
||||
- 정말 복잡한 검색이 필요하면 별도 search endpoint 또는 명시적 검색 모델을 설계한다
|
||||
- 단순 목록 API를 mini query language로 만들지 않는다
|
||||
|
||||
## 7. 응답 형식 규칙
|
||||
|
||||
### 7.1 page-based 응답은 커스텀 page DTO를 사용한다
|
||||
|
||||
Spring Data는 Page, Slice, Window 같은 내부 추상화를 제공하지만, 공개 API 응답 계약은 그것과 분리하는 편이 안정적입니다. 또한 Page는 count metadata 비용이 있을 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공개 응답은 raw Page<T>를 그대로 노출하지 않는다
|
||||
|
||||
기본 응답 형식 예:
|
||||
|
||||
```text
|
||||
ApiResult<PageResponse<T>>
|
||||
```
|
||||
|
||||
PageResponse<T> 권장 필드:
|
||||
|
||||
- items
|
||||
- page
|
||||
- size
|
||||
- hasNext
|
||||
- totalCount (필요 시만)
|
||||
|
||||
### 7.2 cursor 응답은 opaque cursor를 반환한다
|
||||
|
||||
GitHub는 paginated response에서 다음 페이지 URL 또는 cursor 계열 parameter를 계속 사용하게 하고, Spring Data scrolling은 ScrollPosition을 통해 다음 위치를 이어 갑니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
cursor 응답 기본 형식 예:
|
||||
|
||||
```text
|
||||
ApiResult<CursorPageResponse<T>>
|
||||
```
|
||||
|
||||
CursorPageResponse<T> 권장 필드:
|
||||
|
||||
- items
|
||||
- nextCursor
|
||||
- hasNext
|
||||
- cursor는 내부 정렬 키 원본을 그대로 노출하지 않고 인코딩/추상화한다
|
||||
|
||||
### 7.3 페이지 응답은 현재 조회 조건을 바꾸지 않게 설계한다
|
||||
|
||||
Azure는 모든 페이지에서 같은 filtering options와 sort order를 유지하라고 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 다음 페이지 요청은 같은 filter/sort를 유지해야 한다
|
||||
- cursor 기반이면 cursor 자체에 정렬 문맥이 포함되거나 서버가 이를 안전하게 검증해야 한다
|
||||
- page 기반이면 client가 같은 필터/정렬을 재전송하도록 문서화한다
|
||||
|
||||
## 8. Spring 사용 규칙
|
||||
|
||||
### 8.1 controller는 명시적 request DTO를 우선한다
|
||||
|
||||
Spring Data는 controller argument로 Pageable과 Sort를 직접 받을 수 있게 해 줍니다. 하지만 이 프로젝트는 공개 API 가독성과 계약 안정성을 위해 명시적 request DTO를 우선합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
공개 API controller:
|
||||
|
||||
- ListUsersRequest, SearchSessionsRequest 같은 명시적 query DTO 우선
|
||||
|
||||
내부 관리용/운영용 endpoint:
|
||||
|
||||
- 필요하면 Pageable/Sort 직접 사용 가능
|
||||
|
||||
외부 계약은 Spring Data parameter naming에 종속되지 않게 한다
|
||||
|
||||
### 8.2 repository/application 내부에서는 Pageable/Slice/Window를 사용할 수 있다
|
||||
|
||||
Spring Data는 Page, Slice, Sort, Pageable, Window/scrolling을 모두 지원합니다. 각각은 비용과 의미가 다릅니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
repository 레이어:
|
||||
|
||||
- 일반 paging: Pageable
|
||||
- count 불필요: Slice
|
||||
- 대규모 scrolling/keyset: Window/scroll position 검토
|
||||
- 공개 응답은 커스텀 DTO로 변환한다
|
||||
|
||||
## 9. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 공개 API controller에 raw Pageable/Sort를 무비판적으로 노출
|
||||
- page 기반 응답에서 무조건 totalCount 계산
|
||||
- 큰 목록에 깊은 offset paging을 기본 전략으로 고정
|
||||
- 지원하지 않는 sort field를 조용히 무시
|
||||
- 정렬 기준 없이 cursor pagination 구현
|
||||
- filter/sort가 페이지마다 달라질 수 있게 설계
|
||||
- generic filter DSL을 기본 공개 API에 도입
|
||||
- raw Page<Entity> 또는 Slice<Entity>를 외부 응답으로 직접 반환
|
||||
|
||||
## 10. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 목록 API에 page 방식과 cursor 방식 중 어떤 것이 더 맞는가?
|
||||
- 외부 query contract가 Spring 내부 타입에 종속되지 않는가?
|
||||
- page size 기본값과 최대값이 있는가?
|
||||
- 지원 가능한 sort field가 명시되어 있는가?
|
||||
- unsupported sort 요청을 400으로 처리하는가?
|
||||
- filter/sort가 페이지 이동 중에도 일관되게 유지되는가?
|
||||
- count query 비용이 큰데도 무조건 total count를 계산하고 있지 않은가?
|
||||
- cursor를 쓴다면 stable sort와 tie-breaker가 보장되는가?
|
||||
@@ -0,0 +1,226 @@
|
||||
# Request / Response DTO 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API 요청/응답에 사용하는 DTO의 역할, 위치, 설계 규칙을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- request DTO와 response DTO를 명확히 분리한다.
|
||||
- web transport model과 domain/application model을 섞지 않는다.
|
||||
- controller 바인딩 모델의 보안과 변경 가능성을 통제한다.
|
||||
- 응답 포맷을 domain/entity 구조가 아니라 API 계약 중심으로 설계한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 문서의 확장 지점 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 DTO는 transport model이다
|
||||
|
||||
Spring MVC는 @RequestBody와 @ModelAttribute를 통해 웹 입력을 객체로 바인딩하고, @ResponseBody/ResponseEntity를 통해 객체를 응답으로 직렬화한다. 이 프로젝트에서 DTO는 그 바운더리에서만 쓰는 transport model로 정의한다. DTO는 HTTP 요청/응답 계약을 표현하는 객체이지, domain/entity/application command 자체가 아니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO는 HTTP 입력 계약을 표현한다
|
||||
- response DTO는 HTTP 출력 계약을 표현한다
|
||||
- DTO는 domain/entity/persistence model을 그대로 노출하는 수단이 아니다
|
||||
|
||||
### 3.2 request DTO와 response DTO를 분리한다
|
||||
|
||||
Spring 공식 문서는 @ModelAttribute 대상에 대해 웹 바인딩 전용 객체 사용을 권장한다. 이 원칙을 request/response 전체로 확장하면, 읽기 모델과 쓰기 모델을 분리하는 것이 안전하고 유지보수성이 높다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 하나의 DTO를 request와 response에 동시에 재사용하지 않는다
|
||||
- create/update 입력 DTO와 조회 응답 DTO를 분리한다
|
||||
- “필드가 비슷하니까 같은 DTO”를 기본값으로 두지 않는다
|
||||
|
||||
### 3.3 entity와 DTO를 섞지 않는다
|
||||
|
||||
Spring은 @ModelAttribute 바인딩 모델 설계 시 보안을 고려해 전용 모델 객체 또는 constructor binding only를 권장한다. 이는 웹 바인딩 대상을 domain/entity와 분리하라는 실무 방향과 맞닿아 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- entity를 @RequestBody 대상으로 쓰지 않는다
|
||||
- entity를 @ModelAttribute 대상으로 쓰지 않는다
|
||||
- entity를 API response body로 직접 반환하지 않는다
|
||||
- domain object를 외부 계약 모델로 직접 노출하지 않는다
|
||||
|
||||
## 4. Request DTO 표준
|
||||
|
||||
### 4.1 JSON body는 전용 request DTO로 받는다
|
||||
|
||||
Spring 공식 문서 기준으로 @RequestBody는 요청 본문을 HttpMessageConverter로 객체에 역직렬화한다. 따라서 JSON API 입력은 전용 request DTO에 받는 것이 기본이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON body는 전용 request DTO에 받는다
|
||||
- request DTO는 controller boundary에서만 사용한다
|
||||
- request DTO를 그대로 domain/service 내부에 전파하지 않는다
|
||||
- controller 또는 mapper에서 application/domain 입력으로 변환한다
|
||||
|
||||
### 4.2 form/query/path 기반 입력도 web 전용 모델로 받는다
|
||||
|
||||
Spring 공식 문서 기준으로 @ModelAttribute는 request parameters, path variables, headers를 모델 객체에 바인딩할 수 있다. Spring은 이 모델을 웹 바인딩 전용으로 설계하거나 constructor binding only를 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- query/form/multipart 조합 입력도 전용 request model 사용을 우선 검토한다
|
||||
- @ModelAttribute 대상은 web binding 전용 객체로 제한한다
|
||||
- setter/property binding을 허용해야 한다면 바인딩 가능한 필드를 의식적으로 통제한다
|
||||
|
||||
### 4.3 request DTO는 입력 계약만 표현한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO에는 HTTP 입력 필드만 둔다
|
||||
- 서버 내부 계산값, 조회 결과, 인증 결과, 저장 전용 필드를 넣지 않는다
|
||||
- “나중에 응답에도 쓸 수 있으니 미리 넣어두기”를 금지한다
|
||||
|
||||
이 규칙은 Practice + Project Recommendation 이다.
|
||||
|
||||
### 4.4 request DTO는 validation 경계를 드러낼 수 있어야 한다
|
||||
|
||||
Spring MVC는 @RequestBody/@ModelAttribute 입력에 @Valid/@Validated 검증을 적용할 수 있다. 따라서 request DTO는 request shape validation이 걸릴 자리라는 점이 명확해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO에는 입력 형식 검증에 필요한 constraint를 둘 수 있다
|
||||
- business rule을 request DTO constraint에 과도하게 숨기지 않는다
|
||||
- request DTO validation 통과를 domain correctness의 보장으로 간주하지 않는다
|
||||
|
||||
### 4.5 request DTO는 API 변경에 안전해야 한다
|
||||
|
||||
Spring이 전용 바인딩 객체와 constructor binding을 권장하는 이유는 바인딩 범위를 명시적으로 통제하기 위함이다. 이 프로젝트에서는 이를 API 변경 안전성 원칙으로 확장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO는 허용된 입력만 명시적으로 받는다
|
||||
- 무분별한 setter/property 확장을 지양한다
|
||||
- 클라이언트가 보내면 안 되는 필드가 우연히 열리지 않도록 설계한다
|
||||
|
||||
## 5. Response DTO 표준
|
||||
|
||||
### 5.1 응답은 response DTO 또는 표준 envelope로 반환한다
|
||||
|
||||
Spring MVC는 @ResponseBody와 ResponseEntity를 통해 객체를 응답으로 직렬화한다. 이 프로젝트에서는 응답 body를 domain/entity가 아니라 response DTO 또는 ApiResult<ResponseDto> 같은 표준 envelope로 반환한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 응답 본문은 response DTO 또는 ApiResult<ResponseDto>로 표현한다
|
||||
- entity, aggregate, persistence projection을 그대로 반환하지 않는다
|
||||
- Map<String, Object> 기반 임시 응답은 승인 후보 코드에서 지양한다
|
||||
|
||||
### 5.2 response DTO는 외부 계약 중심으로 설계한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- response DTO는 클라이언트가 실제로 필요한 필드만 포함한다
|
||||
- 내부 식별자, 상태값, 구현 세부사항을 무분별하게 노출하지 않는다
|
||||
- domain 모델 필드 구조를 그대로 따라가는 것을 기본값으로 두지 않는다
|
||||
|
||||
이 항목은 Practice + Project Recommendation 이다.
|
||||
|
||||
### 5.3 request DTO와 response DTO를 상호 재사용하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 생성 요청 DTO를 조회 응답 DTO로 재사용하지 않는다
|
||||
- 응답에서만 필요한 필드와 요청에서만 필요한 필드를 분리한다
|
||||
- “대칭 구조”를 위해 무의미한 필드를 추가하지 않는다
|
||||
|
||||
## 6. 매핑 규칙
|
||||
|
||||
### 6.1 DTO ↔ domain/application 변환은 명시적으로 한다
|
||||
|
||||
Spring의 바인딩/직렬화는 controller boundary까지의 편의를 제공하지만, DTO를 domain/application 모델로 자동 동일시하지는 않는다. 따라서 DTO ↔ 내부 모델 변환은 명시적으로 관리한다. 이 규칙은 Spring의 boundary-oriented programming model에 대한 Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO → application input 변환은 controller 또는 전용 mapper가 담당한다
|
||||
- domain/application result → response DTO 변환도 명시적으로 수행한다
|
||||
- mapper는 transport concern과 domain concern을 구분해서 작성한다
|
||||
|
||||
### 6.2 controller가 DTO를 내부 모델처럼 들고 다니지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- use case 메서드 시그니처에 request DTO를 그대로 넘기지 않는다
|
||||
- application/domain이 response DTO를 직접 생성하지 않는다
|
||||
- DTO는 presentation 경계 안에서 생성/소비를 끝내는 쪽을 기본값으로 둔다
|
||||
|
||||
## 7. 바인딩 보안 규칙
|
||||
|
||||
### 7.1 @ModelAttribute 대상은 특히 더 조심한다
|
||||
|
||||
Spring 공식 문서는 @ModelAttribute에서 전용 웹 바인딩 객체 또는 constructor binding only를 권장하고, property binding이 필요하면 allowedFields로 제한하라고 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- @ModelAttribute 대상은 전용 DTO로 둔다
|
||||
- mutable property binding을 열어야 한다면 바인딩 가능한 필드를 통제한다
|
||||
- 도메인 객체나 영속 객체를 @ModelAttribute로 받지 않는다
|
||||
|
||||
### 7.2 request DTO에 서버 소유 필드를 두지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- userId, role, status, createdAt, updatedAt 같은 서버 소유 값은 request DTO에 기본적으로 두지 않는다
|
||||
- 필요한 경우에도 클라이언트 입력값과 서버 결정값의 출처를 명확히 분리한다
|
||||
|
||||
이 항목은 Practice + Project Recommendation 이다.
|
||||
|
||||
## 8. 반환/응답 제어 규칙
|
||||
|
||||
### 8.1 ResponseEntity는 전체 응답 제어가 필요할 때 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 ResponseEntity는 status, headers, body를 함께 지정하는 전체 응답 표현이다. 따라서 단순 성공 200 응답에는 항상 필요하지 않다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 성공 응답은 DTO 또는 ApiResult 반환으로 충분하면 그렇게 한다
|
||||
- 다음 경우에는 ResponseEntity를 사용한다
|
||||
- 201 Created + Location
|
||||
- 204 No Content
|
||||
- custom header
|
||||
- 캐시/조건부 응답
|
||||
- 파일 다운로드/streaming 등 HTTP 제어가 중요한 경우
|
||||
|
||||
### 8.2 공통 envelope는 DTO 설계와 충돌하지 않게 한다
|
||||
|
||||
Spring의 ResponseBodyAdvice는 응답 body 공통 가공 지점이다. 따라서 ApiResult 같은 공통 응답 envelope를 쓴다면 response DTO 설계와 중복/충돌이 없도록 해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- response DTO는 business payload를 표현한다
|
||||
- ApiResult는 공통 wrapper 역할에 집중한다
|
||||
- response DTO 안에 다시 status/code/message 같은 공통 wrapper 성격 필드를 중복으로 넣지 않는다
|
||||
|
||||
## 9. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- entity를 request DTO로 사용
|
||||
- entity를 response DTO로 직접 노출
|
||||
- request/response DTO를 하나로 합치기
|
||||
- request DTO를 application/domain 메서드 시그니처에 그대로 넘기기
|
||||
- response DTO 생성을 domain/application 안에서 직접 수행하기
|
||||
- 임시 Map<String, Object> 응답 남발
|
||||
- @ModelAttribute 대상에 무분별한 property binding 열기
|
||||
- DTO에 서버 소유 필드를 무심코 포함시키기
|
||||
|
||||
## 10. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 객체는 request용인가, response용인가가 명확한가?
|
||||
- web transport model과 domain/entity가 분리되어 있는가?
|
||||
- JSON body라면 @RequestBody 전용 DTO인가?
|
||||
- form/query binding이라면 @ModelAttribute 전용 DTO인가?
|
||||
- response DTO가 외부 계약 중심으로 설계되어 있는가?
|
||||
- DTO가 서버 내부 정책/엔티티 구조를 그대로 노출하지 않는가?
|
||||
- DTO ↔ 내부 모델 변환 위치가 명확한가?
|
||||
@@ -0,0 +1,251 @@
|
||||
# Response Format 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API 응답 본문의 형식과 공통 규약을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 성공/실패 응답의 구조를 일관되게 만든다
|
||||
- controller마다 제각각인 응답 body 형식을 막는다
|
||||
- HTTP status와 응답 body의 역할을 구분한다
|
||||
- 공통 응답 envelope와 실제 business payload의 책임을 분리한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 문서의 확장 지점 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 이 프로젝트의 JSON API 기본 응답 형식은 ApiResult<T>다
|
||||
|
||||
Spring은 응답 body를 @ResponseBody/ResponseEntity로 직렬화하고, 필요하면 ResponseBodyAdvice로 body를 공통 가공할 수 있게 한다. 따라서 프로젝트는 Spring의 공식 응답 처리 지점을 그대로 사용하되, 실제 JSON 응답 본문 형식은 custom envelope인 ApiResult<T>로 표준화한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 JSON API 응답의 기본 형식은 ApiResult<T>
|
||||
- controller마다 서로 다른 임의 JSON 구조를 만들지 않는다
|
||||
- ApiResult는 transport-level envelope이고, 실제 payload는 T가 담당한다
|
||||
|
||||
### 3.2 응답 형식은 “공통 envelope”와 “실제 data”를 분리한다
|
||||
|
||||
Spring 공식 문서가 ResponseEntity와 body object를 분리해서 다루는 구조를 제공하는 것처럼, 이 프로젝트도 응답의 공통 필드와 business payload를 분리한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공통 응답 정보는 ApiResult가 담당
|
||||
- 실제 비즈니스 데이터는 data가 담당
|
||||
- business DTO 안에 다시 success, code, message를 중복으로 넣지 않는다
|
||||
|
||||
### 3.3 응답 형식은 전역 규약이어야 한다
|
||||
|
||||
Spring은 ResponseBodyAdvice를 통해 @ResponseBody나 ResponseEntity 응답을 전역적으로 가공할 수 있다. 즉, 응답 형식 통일은 controller 개별 구현이 아니라 프레임워크 확장 지점에서 중앙 관리할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 응답 형식 표준화는 controller마다 수동으로 맞추는 것보다 공통 규약으로 관리한다
|
||||
- 같은 API 군 안에서는 성공/실패 응답 형식이 일관되어야 한다
|
||||
- endpoint마다 envelope 유무가 달라지는 surprise를 만들지 않는다
|
||||
|
||||
## 4. 표준 응답 구조
|
||||
|
||||
### 4.1 성공 응답
|
||||
|
||||
프로젝트 기본 형식 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": "SUCCESS",
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"userId": "u_123",
|
||||
"email": "user@example.com"
|
||||
},
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 성공 응답은 success=true
|
||||
- 성공 응답의 표준 code는 기본적으로 SUCCESS
|
||||
- 실제 payload는 data
|
||||
- 부가 정보가 필요하면 meta 사용 가능
|
||||
- 단순 성공이더라도 응답 구조를 임의로 바꾸지 않는다
|
||||
|
||||
이 항목은 Project Recommendation 이다.
|
||||
|
||||
### 4.2 실패 응답
|
||||
|
||||
Spring은 예외를 HTTP 응답으로 렌더링하는 공식 지점으로 @ExceptionHandler, @ControllerAdvice, ResponseEntityExceptionHandler를 제공한다. 이 프로젝트는 그 지점을 사용해 실패 응답도 ApiResult 형식으로 통일한다.
|
||||
|
||||
프로젝트 기본 형식 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"code": "REQUEST_VALIDATION_FAILED",
|
||||
"message": "Request validation failed",
|
||||
"data": {
|
||||
"email": "must not be blank"
|
||||
},
|
||||
"meta": {
|
||||
"requestId": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 실패 응답은 success=false
|
||||
- 실패 원인 식별자는 반드시 code에 둔다
|
||||
- 외부 노출 메시지는 message
|
||||
- 상세 오류 정보가 필요하면 data 또는 별도 표준 필드에 둔다
|
||||
- 내부 예외 스택트레이스, 클래스명, 민감정보를 응답 body에 넣지 않는다
|
||||
|
||||
## 5. ApiResult 설계 규칙
|
||||
|
||||
### 5.1 envelope는 얇고 안정적이어야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ApiResult 필드는 최소한으로 유지한다
|
||||
- envelope 구조는 쉽게 자주 바꾸지 않는다
|
||||
- 응답 본문 규약은 business DTO보다 더 안정적인 계약으로 다룬다
|
||||
|
||||
권장 기본 필드:
|
||||
|
||||
- success
|
||||
- code
|
||||
- message
|
||||
- data
|
||||
- meta (선택)
|
||||
|
||||
### 5.2 공통 필드와 business 필드를 섞지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ApiResult 바깥과 data 안의 의미를 섞지 않는다
|
||||
- pagination, cursor, totalCount 같은 응답 보조 정보는 규칙적으로 meta 또는 명시적 pagination DTO에 둔다
|
||||
- business payload 안에 공통 상태 필드를 섞어 넣지 않는다
|
||||
|
||||
### 5.3 code는 문자열이지만 정책적으로 중앙 관리한다
|
||||
|
||||
이 문서는 응답 형식 문서이므로 code 체계 자체의 상세 규칙은 다음 문서인 Error Code/HTTP Status Separation Standard에서 다룬다. 다만 응답 형식 관점에서 code는 항상 존재하는 공통 식별자여야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 성공/실패 모두 code 필드를 가진다
|
||||
- code는 advice/controller에서 임의 문자열로 흩뿌리지 않는다
|
||||
- ErrorCode 같은 중앙 정책 타입을 통해 관리한다
|
||||
|
||||
## 6. ResponseEntity 사용 규칙
|
||||
|
||||
Spring 공식 문서 기준으로 ResponseEntity는 headers, body, status를 함께 지정하는 반환형이다. 따라서 전체 HTTP 응답을 제어할 필요가 있을 때 의미가 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순 200 JSON 응답이면 꼭 ResponseEntity를 강제하지 않는다
|
||||
- 다음 경우에는 ResponseEntity를 사용한다
|
||||
- 201 Created
|
||||
- 204 No Content
|
||||
- custom header
|
||||
- 캐시/조건부 응답
|
||||
- 다운로드/streaming
|
||||
- endpoint별 status 제어가 중요한 경우
|
||||
|
||||
### 6.1 body 표준화와 ResponseEntity는 충돌하지 않아야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ResponseEntity<ApiResult<T>>는 허용된다
|
||||
- 다만 ResponseEntity는 HTTP 제어용이고, ApiResult는 body 규약용이라는 역할 분리를 유지한다
|
||||
- controller가 HTTP 제어도 없는데 습관적으로 ResponseEntity<ApiResult<T>>를 남발하지 않는다
|
||||
|
||||
## 7. ResponseBodyAdvice 사용 규칙
|
||||
|
||||
Spring의 ResponseBodyAdvice는 @ResponseBody 또는 ResponseEntity controller method 실행 후, HttpMessageConverter가 body를 쓰기 전에 응답을 커스터마이징하는 확장 지점이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 전역 envelope 적용이 필요하면 ResponseBodyAdvice를 사용할 수 있다
|
||||
- 이미 ApiResult인 응답은 다시 감싸지 않는다
|
||||
- file response, streaming response, SSE, 이미 형식이 고정된 외부 계약 응답은 전역 래핑 대상에서 제외한다
|
||||
- supports(...) 조건은 넓게 열기보다 명시적으로 제어한다
|
||||
|
||||
### 7.1 전역 래핑은 “마법”이 아니라 명시적 규약이어야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 팀이 “모든 JSON 성공 응답을 자동으로 ApiResult.success(...)로 감싼다”는 규칙을 합의한 경우에만 전역 래핑을 쓴다
|
||||
- 그렇지 않으면 controller가 명시적으로 ApiResult를 반환하게 한다
|
||||
- 두 방식이 혼재되면 응답 규약 이해 비용이 커지므로 기본 전략 하나를 정한다
|
||||
|
||||
## 8. 예외 응답 형식 규칙
|
||||
|
||||
Spring은 @ControllerAdvice/@ExceptionHandler, ResponseEntityExceptionHandler, DefaultHandlerExceptionResolver 등을 통해 예외를 HTTP 응답으로 연결할 수 있다. 이 프로젝트는 그 공식 메커니즘 위에서 예외 응답도 ApiResult 형식으로 통일한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공통 예외 응답은 @RestControllerAdvice에서 생성한다
|
||||
- controller 안에서 실패 응답 body를 직접 조립하는 것을 기본 금지한다
|
||||
- framework 예외와 business 예외가 서로 다른 JSON 구조를 가지지 않게 한다
|
||||
|
||||
### 8.1 실패 응답의 message는 외부 노출용이어야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- message는 클라이언트에 보여줄 수 있는 수준으로 제한한다
|
||||
- ex.getMessage()를 그대로 외부에 노출하는 것을 기본값으로 두지 않는다
|
||||
- 내부 로그 메시지와 외부 응답 메시지를 분리한다
|
||||
|
||||
## 9. 예외적 응답 형식
|
||||
|
||||
### 9.1 envelope를 적용하지 않는 응답
|
||||
|
||||
Spring MVC는 body object뿐 아니라 HttpHeaders, file/streaming 관련 반환형 등도 지원한다. 모든 응답이 JSON envelope여야 하는 것은 아니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 ApiResult envelope 적용 대상에서 제외할 수 있다.
|
||||
|
||||
- 파일 다운로드
|
||||
- binary response
|
||||
- streaming/SSE
|
||||
- redirect
|
||||
- 204 No Content
|
||||
- 외부 표준 계약이 별도 형식을 강제하는 응답
|
||||
|
||||
### 9.2 HTML/view 응답과 JSON API 응답을 섞지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON API는 ApiResult 또는 명시적 API 응답 규약을 따른다
|
||||
- view rendering 응답은 별도 controller/경계로 분리한다
|
||||
- 한 controller 안에서 HTML 응답 규약과 JSON envelope 규약을 섞지 않는다
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- endpoint마다 제각각 다른 성공/실패 JSON 형식 사용
|
||||
- controller 안에서 임시 Map<String, Object>로 응답 구조 조립
|
||||
- ApiResult 바깥과 data 안에 공통 필드 중복
|
||||
- ResponseBodyAdvice에서 무조건 이중 래핑
|
||||
- 예외 메시지를 그대로 외부 응답에 노출
|
||||
- HTTP status와 응답 body code/message 역할을 뒤섞기
|
||||
- 파일/스트리밍 응답까지 무리하게 JSON envelope로 감싸기
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 응답은 프로젝트 표준 envelope(ApiResult)를 따르는가?
|
||||
- ApiResult와 business payload의 역할이 분리되어 있는가?
|
||||
- ResponseEntity를 쓰는 이유가 status/header 제어 때문인가?
|
||||
- 전역 응답 래핑이 있다면 이중 래핑을 막고 있는가?
|
||||
- 실패 응답도 성공 응답과 같은 큰 형식을 유지하는가?
|
||||
- envelope 예외 대상(파일, 스트리밍 등)을 따로 처리하고 있는가?
|
||||
@@ -0,0 +1,272 @@
|
||||
# Serialization / Jackson 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API JSON 직렬화/역직렬화와 Jackson 사용 기준을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- JSON 계약을 DTO 중심으로 안정적으로 관리한다
|
||||
- Jackson 설정과 애노테이션 사용을 일관되게 만든다
|
||||
- domain/entity에 transport concern이 스며들지 않게 한다
|
||||
- 날짜/시간, null, unknown field, 민감 필드, 커스텀 serializer의 기준을 명확히 한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot / Jackson 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 확장 지점 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 직렬화는 web boundary concern이다
|
||||
|
||||
Spring MVC에서 JSON 직렬화/역직렬화는 HttpMessageConverter가 담당하고, Jackson converter는 typed bean이나 untyped map을 JSON으로 읽고 쓸 수 있습니다. 따라서 이 프로젝트에서 serialization/Jackson 규칙은 presentation/web boundary concern 으로 본다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON 계약은 controller boundary의 request/response DTO가 중심이다
|
||||
- domain/entity가 JSON 계약의 중심이 되지 않는다
|
||||
- Jackson 규칙 때문에 domain 모델을 뒤틀지 않는다
|
||||
|
||||
### 3.2 DTO 설계가 Jackson 애노테이션보다 우선한다
|
||||
|
||||
Spring과 Jackson은 애노테이션으로 매핑을 많이 바꿀 수 있게 해 주지만, 이 프로젝트의 기본값은 애노테이션으로 억지로 맞추기보다 DTO를 명시적으로 분리하는 것이다. Spring이 @JsonView, mixin, custom mapper를 지원하더라도, 그 지원 자체가 곧 그것을 기본 설계 수단으로 삼으라는 뜻은 아니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO / response DTO 분리를 기본값으로 둔다
|
||||
- Jackson 애노테이션은 보조 수단 이다
|
||||
- JSON shape를 맞추기 위해 entity/domain에 애노테이션을 덕지덕지 붙이지 않는다
|
||||
|
||||
### 3.3 전역 규칙은 mapper 설정으로, 예외는 DTO에서 처리한다
|
||||
|
||||
Spring Boot는 Jackson mapper를 자동 구성하고 다양한 spring.jackson.* 설정을 제공한다. 즉, naming, inclusion, timezone, visibility 같은 공통 규칙은 전역 mapper 정책으로 관리할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공통 규칙은 전역 Jackson 설정으로 관리한다
|
||||
- 특정 계약 예외만 DTO/필드 레벨 애노테이션으로 처리한다
|
||||
- controller마다 new ObjectMapper()를 만들어 제각각 직렬화하지 않는다
|
||||
|
||||
## 4. DTO 우선 규칙
|
||||
|
||||
### 4.1 request/response DTO가 JSON 계약의 source of truth다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON 요청 형식은 request DTO가 정의한다
|
||||
- JSON 응답 형식은 response DTO가 정의한다
|
||||
- entity/domain/persistence model을 직렬화 계약의 source of truth로 두지 않는다
|
||||
|
||||
### 4.2 entity와 Jackson 애노테이션을 결합하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JPA entity를 직접 JSON 응답으로 내보내지 않는다
|
||||
- entity에 @JsonIgnore, @JsonManagedReference, @JsonBackReference 같은 애노테이션으로 API 문제를 해결하는 것을 기본 금지한다
|
||||
- 엔티티 순환 참조, lazy loading, 내부 식별자 노출 문제는 DTO 변환으로 해결한다
|
||||
|
||||
### 4.3 request DTO와 response DTO를 하나로 합치지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 하나의 DTO를 입력/출력 겸용으로 두는 것을 기본 금지한다
|
||||
- 비밀번호, 내부 상태, 서버 소유 필드 같은 방향성 차이는 DTO 분리로 해결한다
|
||||
- @JsonProperty(access = WRITE_ONLY/READ_ONLY)는 예외적 escape hatch일 뿐, 기본 설계 수단이 아니다
|
||||
|
||||
## 5. 필드 이름 규칙
|
||||
|
||||
### 5.1 기본 naming은 lowerCamelCase다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 내부 API JSON 기본 naming은 lowerCamelCase
|
||||
- 프로젝트 전체 기본 naming 전략은 하나로 유지한다
|
||||
- DTO마다 제각각 snake_case / kebab-case / camelCase를 섞지 않는다
|
||||
|
||||
### 5.2 외부 계약 이름 변경은 @JsonProperty로 국소화한다
|
||||
|
||||
Jackson의 @JsonProperty는 외부에 노출할 property name을 지정하는 데 사용할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 공급자 계약이나 레거시 호환 때문에 필드명이 다를 때만 @JsonProperty를 사용한다
|
||||
- 내부 표준 naming을 DTO 전체에 포기하지 않는다
|
||||
- naming mismatch를 해결하려고 domain 필드명을 외부 계약에 맞춰 바꾸지 않는다
|
||||
|
||||
## 6. 날짜/시간 규칙
|
||||
|
||||
### 6.1 public timestamp는 java.time + ISO-8601 문자열을 기본으로 한다
|
||||
|
||||
JavaTimeModule은 java.time 타입을 지원하며, WRITE_DATES_AS_TIMESTAMPS가 꺼져 있으면 대부분의 java.time 타입을 ISO-8601 문자열로 직렬화합니다. Spring Boot Actuator API도 timestamp 입력을 ISO 8601 offset date-time으로 요구합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- public timestamp 기본값은 ISO-8601 문자열
|
||||
- epoch number timestamp를 공개 API 기본값으로 두지 않는다
|
||||
- java.util.Date보다 java.time 타입을 우선한다
|
||||
|
||||
### 6.2 시점(timestamp)은 OffsetDateTime 또는 Instant를 우선한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 시스템과 교환하는 timestamp는 OffsetDateTime 또는 Instant를 우선 검토한다
|
||||
- 시간대 정보가 없는 LocalDateTime을 public timestamp 기본값으로 두지 않는다
|
||||
- 날짜만 필요하면 LocalDate
|
||||
- 시간만 의미가 있으면 정말 필요한 경우에만 LocalTime
|
||||
|
||||
### 6.3 전역 timezone/date-format은 명시적으로 관리한다
|
||||
|
||||
Spring Boot는 spring.jackson.date-format, spring.jackson.time-zone 같은 전역 설정 프로퍼티를 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 시간 직렬화 정책은 전역 설정 또는 공통 mapper 설정으로 명시한다
|
||||
- DTO별 임의 @JsonFormat 남발을 지양한다
|
||||
- 특정 필드만 예외 형식이 필요한 경우에만 필드 레벨 포맷을 둔다
|
||||
|
||||
## 7. null / absent 규칙
|
||||
|
||||
### 7.1 null omission은 계약 변경 효과가 있으므로 신중하게 쓴다
|
||||
|
||||
Jackson의 @JsonInclude와 Boot의 spring.jackson.default-property-inclusion은 null/empty 값을 응답에서 제외하도록 설정할 수 있습니다. 하지만 필드 omission은 단순 직렬화 최적화가 아니라 응답 계약 의미 변경 이 될 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 전역 NON_NULL을 무비판적으로 켜지 않는다
|
||||
- 필드 omission이 API 의미상 “존재하지 않음”을 뜻할 때만 선택적으로 쓴다
|
||||
- “null일 때 숨기면 보기 좋다”는 이유만으로 계약을 흔들지 않는다
|
||||
|
||||
### 7.2 envelope와 payload의 null 정책을 구분한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ApiResult 같은 공통 envelope 필드는 가능한 안정적으로 유지한다
|
||||
- business payload 필드 omission 여부는 DTO 계약 단위에서 결정한다
|
||||
- data, meta, 상세 필드의 null/absent 정책을 뒤섞지 않는다
|
||||
|
||||
## 8. unknown property 규칙
|
||||
|
||||
### 8.1 Spring 기본 동작에 기대기보다 프로젝트 정책을 명시한다
|
||||
|
||||
Spring의 Jackson2ObjectMapperBuilder 기본값은 FAIL_ON_UNKNOWN_PROPERTIES를 끄고, Jackson의 @JsonIgnoreProperties(ignoreUnknown=true)도 unknown input field를 무시하는 용도로 쓸 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- unknown property 정책은 API군 단위의 명시적 정책 으로 둔다
|
||||
- DTO마다 제멋대로 strict / lenient를 섞지 않는다
|
||||
|
||||
### 8.2 first-party API request DTO는 기본적으로 strict를 권장한다
|
||||
|
||||
프로젝트 권장 규칙:
|
||||
|
||||
- 우리가 소유한 public/internal API request DTO는 기본적으로 unknown field를 실패 처리 하도록 권장
|
||||
- client 오타, 잘못된 계약 사용, 조용한 무시를 빨리 발견하는 쪽을 선호한다
|
||||
- 필요하면 Spring 기본값을 프로젝트 정책에 맞게 override한다
|
||||
|
||||
### 8.3 external webhook / third-party callback DTO는 lenient를 허용한다
|
||||
|
||||
Jackson의 @JsonIgnoreProperties(ignoreUnknown=true)는 deserialization 시 인식하지 못하는 필드를 무시하도록 하는 공식 수단입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 공급자 webhook, callback, third-party response DTO는 ignoreUnknown=true를 허용할 수 있다
|
||||
- 외부가 필드를 추가해도 우리 파싱이 깨지지 않아야 하는 integration DTO에 한해 사용한다
|
||||
- 이 경우에도 first-party API request DTO와 같은 기준으로 섞지 않는다
|
||||
|
||||
## 9. 애노테이션 사용 규칙
|
||||
|
||||
### 9.1 @JsonProperty(access = ...)는 예외적으로만 쓴다
|
||||
|
||||
Jackson은 property access를 read-only / write-only / read-write로 제어할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- WRITE_ONLY는 비밀번호처럼 입력만 받고 출력하면 안 되는 필드에 한해 제한적으로 사용
|
||||
- READ_ONLY는 서버 계산값처럼 응답에는 나가지만 입력받으면 안 되는 필드에 한해 제한적으로 사용
|
||||
- 기본 해결책은 여전히 request/response DTO 분리다
|
||||
|
||||
### 9.2 @JsonView는 public API 기본 설계 수단으로 쓰지 않는다
|
||||
|
||||
Spring MVC는 @JsonView를 controller method에서 지원하지만, 메서드당 직접 지정 가능한 view는 하나이며, 여러 shape를 장기 유지하는 public API 계약 관리에는 DTO 분리가 더 명확하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- @JsonView를 public API summary/detail/versioning의 기본 수단으로 사용하지 않는다
|
||||
- endpoint별 shape 차이는 별도 response DTO로 표현한다
|
||||
- @JsonView는 관리용/내부용 제한된 케이스에서만 예외적으로 검토한다
|
||||
|
||||
### 9.3 @JsonIgnore는 마지막 수단으로 쓴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 필드 숨김 문제를 @JsonIgnore로 즉석에서 막기보다 DTO 구조를 먼저 재검토한다
|
||||
- @JsonIgnore가 많아지면 DTO/entity 책임이 흐려졌다는 신호로 본다
|
||||
|
||||
## 10. 커스텀 serializer/deserializer 규칙
|
||||
|
||||
### 10.1 교차 절단(cross-cutting) 직렬화는 전역 구성요소로 등록한다
|
||||
|
||||
Spring MVC는 custom JsonMapper/builder를 converter에 주입할 수 있고, Spring Boot는 전역 mapper 설정과 커스터마이저, 모듈, mixin 등록 지점을 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 여러 DTO에서 반복되는 직렬화 규칙은 전역 Jackson 설정/모듈/커스터마이저로 올린다
|
||||
- controller 안에서 ad-hoc serializer를 만들지 않는다
|
||||
- DTO 한두 개만을 위한 국소 예외는 DTO 애노테이션으로 처리할 수 있다
|
||||
|
||||
### 10.2 third-party 타입 수정은 mixin을 우선 검토한다
|
||||
|
||||
Spring Boot는 @JacksonMixin을 스캔해 auto-configured mapper에 등록할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 직접 수정할 수 없는 third-party 타입의 직렬화 변경은 mixin을 우선 검토한다
|
||||
- 우리 코드의 DTO에까지 mixin을 남발하지 않는다
|
||||
- mixin은 “타입 소유권이 우리에게 없을 때”의 수단이다
|
||||
|
||||
## 11. ObjectMapper 사용 규칙
|
||||
|
||||
### 11.1 controller에서 new ObjectMapper()를 만들지 않는다
|
||||
|
||||
Spring MVC와 Boot는 이미 message converter와 전역 mapper 구성을 제공한다. controller가 직접 새 mapper를 만들면 전역 규칙, module, naming, inclusion, time 설정을 우회하기 쉽다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller/service에서 new ObjectMapper() 금지
|
||||
- 정말 수동 직렬화가 필요하면 주입된 공용 mapper 또는 전용 serializer 컴포넌트를 사용한다
|
||||
- “이 endpoint만 예외”를 위해 로컬 mapper를 만들지 않는다
|
||||
|
||||
### 11.2 테스트도 production mapper와 같은 규칙을 검증한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- serialization contract가 중요한 DTO는 직렬화/역직렬화 테스트를 둔다
|
||||
- production과 다른 임시 mapper 설정으로 테스트하지 않는다
|
||||
- 날짜, null, enum, unknown property, field name 같은 계약 포인트를 테스트한다
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- entity를 직접 JSON request/response 모델로 사용
|
||||
- controller에서 new ObjectMapper() 생성
|
||||
- @JsonView를 public API versioning/shape 관리 기본 수단으로 사용
|
||||
- 전역 NON_NULL 같은 omission 정책을 계약 검토 없이 켜기
|
||||
- unknown property strict/lenient 정책을 DTO마다 제각각 섞기
|
||||
- 외부 계약 이름 변경 문제를 domain/entity 필드명 변경으로 해결
|
||||
- 민감 필드 숨김을 @JsonIgnore만으로 땜질
|
||||
- 숫자 timestamp를 public API 기본값으로 사용
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 JSON 계약은 DTO가 정의하고 있는가?
|
||||
- entity/domain에 Jackson concern이 새어 나오지 않았는가?
|
||||
- 날짜/시간이 ISO-8601 + 적절한 java.time 타입으로 표현되는가?
|
||||
- null omission이 계약 의도를 반영하는가?
|
||||
- unknown property 정책이 API군 단위로 일관적인가?
|
||||
- @JsonProperty, @JsonIgnore, @JsonView 사용이 정말 필요한 예외인가?
|
||||
- 전역 mapper 규칙을 우회하는 로컬 ObjectMapper가 없는가?
|
||||
Reference in New Issue
Block a user