init: 클린 기반 auth 서버 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:30:18 +09:00
parent 471db0203d
commit 8a1ac1e769
3642 changed files with 275893 additions and 1 deletions
+232
View File
@@ -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 쪽으로 밀어냈는가?
+243
View File
@@ -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를 쓰고 있는가?
+297
View File
@@ -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가 보장되는가?
+226
View File
@@ -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 ↔ 내부 모델 변환 위치가 명확한가?
+251
View File
@@ -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 예외 대상(파일, 스트리밍 등)을 따로 처리하고 있는가?
+272
View File
@@ -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가 없는가?