init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Documentation Hub
|
||||
|
||||
Project-Auth-Server 의 문서 영역.
|
||||
|
||||
## 문서 영역
|
||||
|
||||
| 경로 | 내용 |
|
||||
|------|------|
|
||||
| [`architecture/`](./architecture/README.md) | 시스템 구조, 레이어링, 설계 의도 + 아키텍처 다이어그램 |
|
||||
| [`topics/`](./topics/README.md) | 토픽별 ADR + 운영 런북 / 트러블슈팅 (Clean Architecture, Keycloak, Logging) |
|
||||
| [`exception-handling-policy.md`](./exception-handling-policy.md) | 예외 처리 13개 정책 — 코드/테스트/게이트로 강제하는 항목 한눈에 |
|
||||
| [`testing-coverage-policy.md`](./testing-coverage-policy.md) | JaCoCo / PIT / jqwik Tier 분류와 임계치 정책 |
|
||||
| [`testing-history/`](./testing-history/README.md) | 사건 단위 before/after 비교 기록 (jqwik 도입 / Tier 1 도달 / PIT 게이트 활성 등) |
|
||||
| [`development/`](./development/) | 로컬 개발 환경 — Keycloak realm import, Postman collection |
|
||||
| [`standards/`](./standards/) | 언어 / 스프링 / DB / 웹 / 테스트 코딩 가이드 |
|
||||
| [`examples/`](./examples/) | `standards/` 의 적용 예시 코드 |
|
||||
| [`templates/`](./templates/README.md) | ADR / Runbook / Troubleshooting 등 재사용 템플릿 |
|
||||
| [`documentation-guide.md`](./documentation-guide.md) | 문서 작성 기준과 스타일 규칙 |
|
||||
|
||||
## 빠른 링크
|
||||
|
||||
- [Architecture Overview](./architecture/README.md)
|
||||
- [Topic Docs Index](./topics/README.md)
|
||||
- [Exception Handling 13개 정책](./exception-handling-policy.md)
|
||||
- [Testing Coverage Policy (Tier + 임계치)](./testing-coverage-policy.md)
|
||||
- [Testing History — 사건별 SUMMARY](./testing-history/README.md)
|
||||
- [Templates Index](./templates/README.md)
|
||||
@@ -0,0 +1,196 @@
|
||||
# Architecture
|
||||
|
||||
## Context & Scope
|
||||
|
||||
이 문서는 `Project-Auth-Server`의 레이어 구조와 조립 방식을 설명합니다.
|
||||
핵심 질문은 "왜 이 구조를 택했고, 실제로 의존 방향을 어떻게 통제하는가"입니다.
|
||||
|
||||
문서 작성 기준은 [docs/documentation-guide.md](../documentation-guide.md)를 따릅니다.
|
||||
|
||||
### Scope
|
||||
|
||||
- 포함: 레이어 책임, 의존 방향, 웹 앱과 migration 앱의 조립 방식
|
||||
- 제외: 개별 인증 플로우, 운영 환경별 GitOps 선언 상세
|
||||
|
||||
## Why
|
||||
|
||||
이 프로젝트는 인증/인가 서버이기 때문에 비즈니스 규칙, HTTP 계약, 외부 기술 구현이 빠르게 섞이기 쉽습니다.
|
||||
특히 보안, DB migration, 외부 시스템 연동이 함께 존재하면 "편해서 한 군데에 몰아넣은 코드"가 빠르게 늘어납니다.
|
||||
|
||||
이 구조가 해결하려는 문제는 다음과 같습니다.
|
||||
|
||||
- 도메인 규칙이 Spring MVC, JPA, 보안 프레임워크에 오염되는 문제
|
||||
- 응답 계약과 비즈니스 규칙이 뒤섞이는 문제
|
||||
- migration 같은 운영성 실행을 웹 애플리케이션과 강하게 결합하는 문제
|
||||
- 기술 교체 영향 범위를 좁히기 어려운 문제
|
||||
|
||||
## What
|
||||
|
||||
### 한눈에 보는 의존 흐름
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Bootstrap[bootstrap]
|
||||
Presentation[presentation]
|
||||
Infrastructure[infrastructure]
|
||||
Application[application]
|
||||
Domain[domain]
|
||||
|
||||
Bootstrap --> Presentation
|
||||
Bootstrap --> Infrastructure
|
||||
Bootstrap --> Application
|
||||
Bootstrap -. compileOnly .-> Domain
|
||||
Presentation --> Application
|
||||
Infrastructure --> Application
|
||||
Infrastructure --> Domain
|
||||
Application --> Domain
|
||||
```
|
||||
|
||||
핵심은 **의존 방향이 항상 안쪽으로만 흐른다**는 점입니다.
|
||||
|
||||
- `domain`은 가장 안쪽에 있고 아무 것도 모릅니다.
|
||||
- `application`은 유스케이스와 포트를 통해 도메인 규칙을 조합합니다.
|
||||
- `presentation`, `infrastructure`는 바깥쪽 어댑터로서 `application`에 의존합니다.
|
||||
- `bootstrap`은 실제 Spring Boot 실행 모듈로, 바깥 레이어들을 조립합니다.
|
||||
|
||||
### 레이어별 책임
|
||||
|
||||
### domain
|
||||
|
||||
도메인 모델과 순수 규칙만 둡니다.
|
||||
|
||||
- 예: `User`, `UserEmail`, `UserName`, `UserPasswordPolicy`
|
||||
- 금지: Spring annotation, JPA annotation, HTTP/Servlet 타입
|
||||
|
||||
### application
|
||||
|
||||
“무슨 일을 한다”를 담당합니다.
|
||||
|
||||
- 유스케이스
|
||||
- 커맨드/결과 DTO
|
||||
- 포트(`port/in`, `port/out`)
|
||||
- 비즈니스 에러 코드/예외
|
||||
|
||||
여기서는 더 이상 HTTP status를 다루지 않습니다.
|
||||
에러는 `code`, `message`만 가지고 있고, HTTP status 매핑은 바깥쪽 `presentation`에서 담당합니다.
|
||||
|
||||
### presentation
|
||||
|
||||
HTTP 입출력과 API 응답 계약만 담당합니다.
|
||||
|
||||
- Controller
|
||||
- Request/Response DTO
|
||||
- `ApiResult`
|
||||
- `ApiSuccessCode`
|
||||
- `ValidationExceptionHandler`
|
||||
- `RequestExceptionHandler`
|
||||
- `ApplicationExceptionHandler`
|
||||
- `ApiErrorHttpStatusMapper`
|
||||
|
||||
즉 API 응답 모양과 HTTP status는 이 레이어의 책임입니다.
|
||||
|
||||
### infrastructure
|
||||
|
||||
기술 구현체만 담당합니다.
|
||||
|
||||
- JPA repository adapter
|
||||
- persistence mapper/entity
|
||||
- password encoder adapter
|
||||
- JWT/Vault integration
|
||||
|
||||
즉 `application`이 정의한 포트를 실제 기술로 연결합니다.
|
||||
|
||||
### bootstrap
|
||||
|
||||
전체를 조립합니다.
|
||||
|
||||
- Spring Boot entrypoint
|
||||
- configuration
|
||||
- security wiring
|
||||
- `presentation`이 직접 의존할 수 없는 기술 예외를 HTTP 경계에서 번역하는 bootstrap 전용 web adapter
|
||||
- migration 전용 app entrypoint
|
||||
|
||||
이 프로젝트에서는 일반 웹 애플리케이션 진입점과 별도로 `MigrationApplication`을 둬서 DB migration을 전용 실행 단위로 분리했습니다.
|
||||
|
||||
`InfrastructureExceptionHandler`가 bootstrap에 있는 이유도 여기 있습니다.
|
||||
이 핸들러는 HTTP 응답을 만들지만, 이를 presentation으로 옮기면 `presentation -> infrastructure` 의존이 생겨 현재 ArchUnit 규칙을 깨게 됩니다.
|
||||
그래서 security filter chain 예외나 `InfrastructureException`처럼 presentation이 직접 알 수 없는 타입을 HTTP로 번역하는 adapter는 bootstrap이 맡습니다.
|
||||
|
||||
## How
|
||||
|
||||
### 웹 앱과 migration 앱의 조립
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Web[AuthApplication]
|
||||
Migration[MigrationApplication]
|
||||
App[application]
|
||||
Infra[infrastructure]
|
||||
Pres[presentation]
|
||||
Domain[domain]
|
||||
DB[(PostgreSQL)]
|
||||
|
||||
Web --> Pres
|
||||
Web --> App
|
||||
Web --> Infra
|
||||
Pres --> App
|
||||
Infra --> App
|
||||
Infra --> Domain
|
||||
App --> Domain
|
||||
Infra --> DB
|
||||
|
||||
Migration --> App
|
||||
Migration --> Infra
|
||||
Migration --> DB
|
||||
```
|
||||
|
||||
이 구조의 의도는 이렇습니다.
|
||||
|
||||
- 웹 앱은 `presentation + application + infrastructure`를 조립
|
||||
- migration 앱은 웹 어댑터 없이 DB migration만 수행
|
||||
|
||||
즉 migration을 위해 auth-server 전체 웹 컨텍스트를 억지로 띄우지 않도록 분리했습니다.
|
||||
|
||||
### 레이어 규칙을 어떻게 유지하는가
|
||||
|
||||
구조는 설명만으로 유지되지 않기 때문에, ArchUnit 테스트로 핵심 규칙을 검증합니다.
|
||||
|
||||
- 위치: `bootstrap/src/test/java/com/project/auth/architecture/LayerDependencyArchitectureTest.java`
|
||||
|
||||
현재 강제하는 규칙:
|
||||
|
||||
- `domain`은 Spring/JPA/Servlet에 의존하지 않는다
|
||||
- `application`은 `presentation`/`infrastructure`에 의존하지 않는다
|
||||
- `presentation`은 `domain`/`infrastructure`에 직접 의존하지 않는다
|
||||
- `bootstrap`만 `config` 패키지를 조립 지점으로 사용한다
|
||||
|
||||
즉 이 문서는 "설계 설명"이고, ArchUnit은 "설계 위반 방지 장치"입니다.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 대안 1: 패키지 수준 구분만 두고 단일 모듈에 집중
|
||||
|
||||
- 장점: 초기 개발 속도가 빠르고 진입 장벽이 낮습니다.
|
||||
- 단점: 시간이 지나면 HTTP, DB, 도메인 규칙이 같은 계층에서 섞이기 쉽습니다.
|
||||
- 결론: 지금 프로젝트처럼 인증, 보안, 외부 시스템 연동이 많은 경우 경계가 빨리 무너질 가능성이 높아 채택하지 않았습니다.
|
||||
|
||||
### 대안 2: 웹 앱과 migration을 같은 진입점에서 처리
|
||||
|
||||
- 장점: 실행 경로가 단순합니다.
|
||||
- 단점: migration만 수행해도 웹 관련 빈과 설정이 함께 로딩될 수 있습니다.
|
||||
- 결론: 운영 절차를 분리하고 실패 반경을 줄이기 위해 별도 `MigrationApplication`을 유지합니다.
|
||||
|
||||
## Result / Trade-offs
|
||||
|
||||
이 구조로 얻는 이점은 다음과 같습니다.
|
||||
|
||||
- 도메인/유스케이스가 웹 프레임워크에 오염되지 않습니다.
|
||||
- 기술 교체 영향 범위를 주로 `infrastructure`로 제한할 수 있습니다.
|
||||
- 응답 계약과 비즈니스 규칙의 경계를 분명히 할 수 있습니다.
|
||||
- migration 같은 운영성 실행을 별도 진입점으로 분리할 수 있습니다.
|
||||
|
||||
동시에 감수하는 비용도 있습니다.
|
||||
|
||||
- 모듈과 패키지 경계를 지키기 위한 학습 비용이 있습니다.
|
||||
- 단기적으로는 파일 수와 설정 포인트가 늘어납니다.
|
||||
- 구조를 설명하는 문서와 ArchUnit 테스트를 함께 관리해야 합니다.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 710 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 606 KiB |
@@ -0,0 +1,116 @@
|
||||
# Keycloak 로컬 설정
|
||||
|
||||
이 문서는 로컬 개발 환경에서 Keycloak을 띄우고 연결하는 **runbook**입니다.
|
||||
Keycloak의 아키텍처, Identity Broker 패턴, 프로젝트 적용 배경은 [docs/topics/03-keycloak/README.md](../../topics/03-keycloak/README.md)를 참고합니다.
|
||||
|
||||
## 기본 전제
|
||||
|
||||
- `docker compose`는 `deploy/docker/docker-compose.yml`을 사용합니다.
|
||||
- 실제 소셜 로그인용 `Google`/`GitHub` Client ID와 Secret은 auth-server가 아니라 Keycloak에 등록합니다.
|
||||
- **회원가입과 로그인 UX는 Keycloak realm이 담당합니다.** auth-server는 Keycloak이 발급한 token을 검증만 하며, signup/login endpoint나 password 저장을 가지지 않습니다.
|
||||
- auth-server는 Keycloak issuer를 Resource Server 설정으로 사용하고, token 발급 client secret을 보관하지 않습니다.
|
||||
|
||||
## 1. 로컬 인프라 실행
|
||||
|
||||
`.env.local` 값을 현재 환경에 맞게 확인한 뒤 아래 명령으로 실행합니다.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.local up -d
|
||||
```
|
||||
|
||||
- PostgreSQL: `localhost:5432`
|
||||
- Keycloak: `http://localhost:8081`
|
||||
|
||||
## 2. Keycloak 초기 상태
|
||||
|
||||
처음 기동하면 아래 내용이 자동으로 import 됩니다.
|
||||
|
||||
- realm: `project-auth`
|
||||
- client: `project-auth-server`
|
||||
|
||||
위 값은 로컬 개발 기준 고정 예시입니다. 운영이나 공유 환경에서는 환경값에 맞춰 다시 설정해야 합니다.
|
||||
|
||||
## 3. Realm 회원가입(Self-service registration) 활성화
|
||||
|
||||
auth-server는 회원가입을 받지 않으므로 Keycloak realm의 self-service registration을 켭니다.
|
||||
|
||||
1. `project-auth` realm 선택
|
||||
2. `Realm Settings → Login` 탭
|
||||
3. `User registration`: **ON**
|
||||
4. 필요 시 `Email as username`, `Verify email`, `Forgot password` 활성화
|
||||
5. `Authentication → Password policy`에서 최소 길이/복잡도 정책 설정
|
||||
|
||||
결과: Keycloak의 로그인 화면에 "Register" 링크가 노출되고, 사용자는 Keycloak에서 직접 계정을 만듭니다.
|
||||
|
||||
## 4. Google Identity Provider 등록
|
||||
|
||||
Keycloak Admin Console에서 아래 순서로 진행합니다.
|
||||
|
||||
1. `project-auth` realm 선택
|
||||
2. `Identity providers`
|
||||
3. `Google` 추가
|
||||
4. alias를 `google`로 설정
|
||||
5. Google Cloud Console에서 발급한 Client ID / Client Secret 입력
|
||||
6. Keycloak이 보여주는 Redirect URI를 Google OAuth 설정에 등록
|
||||
|
||||
## 5. GitHub Identity Provider 등록
|
||||
|
||||
1. `project-auth` realm 선택
|
||||
2. `Identity providers`
|
||||
3. `GitHub` 추가
|
||||
4. alias를 `github`로 설정
|
||||
5. GitHub OAuth App에서 발급한 Client ID / Client Secret 입력
|
||||
6. Keycloak이 보여주는 Redirect URI를 GitHub OAuth 설정에 등록
|
||||
|
||||
## 6. Realm role / token claim 점검
|
||||
|
||||
auth-server는 다음 claim을 신뢰합니다(상세: [03-claim-role-design.md](../../topics/03-keycloak/03-claim-role-design.md)).
|
||||
|
||||
- `sub`, `email`, `name`, `preferred_username`
|
||||
- `realm_access.roles` (Spring authority `ROLE_*`)
|
||||
- `scope` (Spring authority `SCOPE_*`)
|
||||
|
||||
점검 항목:
|
||||
|
||||
1. `Realm roles`에서 `user`, `admin` 생성
|
||||
2. `Realm Settings → User registration`의 기본 역할에 `user` 포함
|
||||
3. `Client scopes → roles → Mappers`에 `realm roles` mapper가 활성화되어 있는지 확인 (기본 제공)
|
||||
4. `Client scopes → email` / `profile`이 project-auth-server client의 default client scope에 들어 있는지 확인
|
||||
|
||||
## 7. auth-server와 맞춰야 하는 값
|
||||
|
||||
auth-server의 로컬 설정은 아래 값을 기준으로 Keycloak과 연결됩니다.
|
||||
|
||||
- issuer: `http://localhost:8081/realms/project-auth`
|
||||
- Spring 설정: `spring.security.oauth2.resourceserver.jwt.issuer-uri`
|
||||
|
||||
즉 auth-server는 Keycloak token의 `iss`와 realm JWKS를 검증합니다. 소셜 로그인 분기는 Keycloak 또는 프론트엔드 로그인 흐름에서 처리합니다.
|
||||
|
||||
## 8. auth-server 실행
|
||||
|
||||
```bash
|
||||
set -a
|
||||
source .env.local
|
||||
set +a
|
||||
|
||||
./gradlew :bootstrap:bootRun
|
||||
```
|
||||
|
||||
## 9. 인증된 호출 예시
|
||||
|
||||
Keycloak에서 access token을 받은 뒤 auth-server에는 Bearer token으로 요청합니다.
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST \
|
||||
"http://localhost:8081/realms/project-auth/protocol/openid-connect/token" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=project-auth-server" \
|
||||
-d "username=<kc-user>" \
|
||||
-d "password=<kc-password>" \
|
||||
-d "scope=openid profile email" | jq -r .access_token)
|
||||
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/auth/me
|
||||
```
|
||||
|
||||
첫 호출 시 auth-server가 token의 `sub`로 내부 DB에 사용자 행을 lazy-create하고, 두 번째 호출부터는 기존 내부 식별자를 재사용합니다.
|
||||
같은 email 이 이미 다른 Keycloak subject 에 연결되어 있으면 auth-server는 자동 연결하지 않고 `AUTH-005` / `409 Conflict`를 반환합니다.
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"realm": "project-auth",
|
||||
"enabled": true,
|
||||
"displayName": "Project Auth",
|
||||
"sslRequired": "NONE",
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "project-auth-server",
|
||||
"name": "project-auth-server",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": false,
|
||||
"secret": "project-auth-server-secret",
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"baseUrl": "http://localhost:8080",
|
||||
"redirectUris": [
|
||||
"http://localhost:3000/*",
|
||||
"http://localhost:8080/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:8080"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "7f87f966-14d8-4ea0-bd6f-2a41d657aef2",
|
||||
"name": "project-auth-server resource-server",
|
||||
"description": "Keycloak access token 검증과 연결된 내부 사용자 조회 확인용 Postman 컬렉션",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "현재 사용자 조회",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{
|
||||
"key": "token",
|
||||
"value": "{{keycloakAccessToken}}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/v1/auth/me",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"auth",
|
||||
"me"
|
||||
]
|
||||
},
|
||||
"description": "Keycloak access token을 검증하고 이미 연결된 내부 users row를 provider=KEYCLOAK, provider_subject=sub 기준으로 조회합니다."
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('200 OK 반환', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"",
|
||||
"pm.test('현재 사용자 응답 확인', function () {",
|
||||
" var json = pm.response.json();",
|
||||
" pm.expect(json.success).to.eql(true);",
|
||||
" pm.expect(json.code).to.eql('AUTHENTICATED_USER_LOADED');",
|
||||
" pm.expect(json.data.provider).to.eql('KEYCLOAK');",
|
||||
" pm.expect(json.data.subject).to.be.a('string').and.not.empty;",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Bearer token 없음",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/v1/auth/me",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"auth",
|
||||
"me"
|
||||
]
|
||||
},
|
||||
"description": "Authorization 헤더가 없을 때 resource server가 401을 반환하는지 확인합니다."
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('401 Unauthorized 반환', function () {",
|
||||
" pm.response.to.have.status(401);",
|
||||
"});",
|
||||
"",
|
||||
"pm.test('인증 필요 에러 코드 확인', function () {",
|
||||
" var json = pm.response.json();",
|
||||
" pm.expect(json.success).to.eql(false);",
|
||||
" pm.expect(json.code).to.eql('AUTH-001');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "http://localhost:8080"
|
||||
},
|
||||
{
|
||||
"key": "keycloakAccessToken",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# Documentation Guide
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 가이드는 이 저장소의 기술 문서를 일관된 방식으로 작성하기 위한 기준입니다. 핵심 목표는 문서를 읽는 사람이 다음 질문에 답할 수 있게 만드는 것입니다.
|
||||
|
||||
- 왜 이 결정을 했는가?
|
||||
- 대안은 무엇이었는가?
|
||||
- 실제로 어떻게 동작하는가?
|
||||
- 운영과 유지보수에 어떤 영향이 있는가?
|
||||
|
||||
## 2. 핵심 원칙
|
||||
|
||||
### 2.1 기본 서술 순서
|
||||
|
||||
모든 기술 문서는 가능하면 다음 순서를 따릅니다.
|
||||
|
||||
1. `Why`: 어떤 문제를 해결하려는가
|
||||
2. `What`: 구조와 핵심 컴포넌트가 무엇인가
|
||||
3. `How`: 내부 동작과 적용 방법이 무엇인가
|
||||
4. `Result`: 결과, 트레이드오프, 남은 리스크가 무엇인가
|
||||
|
||||
### 2.2 좋은 문서의 기준
|
||||
|
||||
- 사용법보다 의사결정 근거가 더 중요합니다.
|
||||
- 대안 비교가 있어야 합니다.
|
||||
- 실제 겪은 문제와 해결 과정이 있어야 합니다.
|
||||
- 코드와 설명이 분리되지 않아야 합니다.
|
||||
- 운영 관점의 영향이 있으면 반드시 적습니다.
|
||||
|
||||
## 3. 문서 유형별 기준
|
||||
|
||||
이 repo 는 **ADR** + **트러블슈팅 / 런북** 두 축만 다룹니다. 일반 학습 노트나 인터뷰 준비, postmortem 같은 형식은 두지 않습니다 (개인 프로젝트 규모에 맞춤).
|
||||
|
||||
| 유형 | 목적 | 권장 스타일 | 반드시 포함할 것 |
|
||||
|------|------|-------------|------------------|
|
||||
| ADR | 기술 선택 근거 기록 | ThoughtWorks Lightweight ADR | Context, Options, Decision, Consequences |
|
||||
| Architecture Doc | 구조와 동작 설명 | Google Design Doc | Scope, Goals, Diagram, Alternatives |
|
||||
| Runbook | 운영 절차 문서 | Docs-as-Code | Preconditions, Steps, Verification, Rollback |
|
||||
| Troubleshooting | 문제 해결 기록 | 기술블로그 스타일 | Symptom, Cause, Fix, Lesson |
|
||||
|
||||
## 4. 폴더 구조 원칙
|
||||
|
||||
### 4.1 상위 구조
|
||||
|
||||
`docs/` 아래를 목적별로 나눕니다.
|
||||
|
||||
- `docs/architecture/`: 시스템 구조와 설계 의도
|
||||
- `docs/topics/`: 토픽별 ADR + 트러블슈팅 / 런북
|
||||
- `docs/development/`: 로컬 개발 환경 / 실험 자료
|
||||
- `docs/standards/`: 코딩 가이드 (언어 / 스프링 / DB / 웹 / 테스트 표준)
|
||||
- `docs/examples/`: 위 standards 의 적용 예시
|
||||
- `docs/templates/`: 재사용 템플릿 (ADR / 런북 / 트러블슈팅)
|
||||
|
||||
같은 기술이 두 영역에 동시에 나타날 수 있습니다. 이때 기준은 *주제* 가 아니라 *문서 목적* 입니다.
|
||||
|
||||
- `docs/topics/03-keycloak/`: Keycloak 아키텍처 ADR + 트러블슈팅
|
||||
- `docs/development/keycloak/`: 로컬 실행 방법, realm import 파일
|
||||
|
||||
### 4.2 토픽 폴더 구조
|
||||
|
||||
특정 기술을 깊이 정리할 때는 `docs/topics/` 아래에 번호형 폴더를 사용합니다.
|
||||
|
||||
```text
|
||||
docs/topics/
|
||||
├── 02-clean-architecture/
|
||||
│ ├── README.md
|
||||
│ ├── 02-error-handling.md
|
||||
│ ├── 02a-validation-deep-dive.md
|
||||
│ └── 03-adr-boundary-refactoring.md
|
||||
├── 03-keycloak/
|
||||
│ ├── README.md
|
||||
│ ├── 01-architecture.md
|
||||
│ ├── 02-adr-keycloak-resource-server.md
|
||||
│ ├── 03-claim-role-design.md
|
||||
│ └── 04-adr-token-ownership-cleanup.md
|
||||
└── 04-logging/
|
||||
├── README.md
|
||||
├── 01-architecture.md
|
||||
└── 02-runbook-log-correlation-and-dev-actuator.md
|
||||
```
|
||||
|
||||
### 4.3 파일명 규칙
|
||||
|
||||
- 순서를 표현하는 문서는 번호를 붙입니다. 예: `01-architecture.md`
|
||||
- ADR 은 `<n>-adr-<topic>.md` 또는 `adr-why-<topic>.md` 형태를 사용합니다.
|
||||
- Troubleshooting / Runbook 은 제목만 봐도 목적이 드러나야 합니다. 예: `<n>-runbook-log-correlation.md`
|
||||
- 각 토픽 폴더에는 반드시 `README.md` 를 둡니다.
|
||||
- 다이어그램 산출물은 `docs/architecture/diagrams/` 폴더에 모읍니다 (PNG / `.drawio` / `.excalidraw`).
|
||||
|
||||
## 5. 문서 작성 체크리스트
|
||||
|
||||
문서를 마무리하기 전에 아래 항목을 확인합니다.
|
||||
|
||||
- 이 문서가 해결하려는 질문이 첫 화면에 드러나는가?
|
||||
- 선택 근거와 대안 비교가 있는가?
|
||||
- 다이어그램이나 흐름 설명이 있는가?
|
||||
- 실제 코드나 설정 예시가 있는가?
|
||||
- 실제 문제 사례 또는 운영 관점이 포함되었는가?
|
||||
- 결과와 남은 리스크가 정리되었는가?
|
||||
- 관련 README 에 링크를 추가했는가?
|
||||
|
||||
## 6. 문서별 최소 섹션 가이드
|
||||
|
||||
### 6.1 ADR
|
||||
|
||||
```markdown
|
||||
# [선택한 결정]
|
||||
## Context — 어떤 문제 / 제약이 있었는가
|
||||
## Options — 어떤 대안들이 있었는가
|
||||
## Decision — 무엇을 골랐는가
|
||||
## Consequences — 결과 / 트레이드오프 / 남은 리스크
|
||||
```
|
||||
|
||||
### 6.2 아키텍처 문서
|
||||
|
||||
- Context & Scope
|
||||
- Goals / Non-Goals
|
||||
- Architecture Overview (다이어그램 필수)
|
||||
- Detailed Design
|
||||
- Alternatives Considered
|
||||
- Cross-cutting Concerns
|
||||
- Result / Enforcement
|
||||
|
||||
### 6.3 트러블슈팅 문서
|
||||
|
||||
- 한 줄 요약
|
||||
- 배경
|
||||
- 증상
|
||||
- 원인 분석
|
||||
- 해결
|
||||
- 교훈
|
||||
|
||||
### 6.4 Runbook
|
||||
|
||||
- Preconditions (시작 전 상태)
|
||||
- Steps (실제 명령)
|
||||
- Verification (완료 확인)
|
||||
- Rollback (실패 시 복구)
|
||||
|
||||
## 7. 이 저장소에서의 권장 적용 방식
|
||||
|
||||
- 시스템 전체 구조는 `docs/architecture/` 에 둡니다.
|
||||
- 토픽 ADR + 트러블슈팅 / 런북은 `docs/topics/` 에 누적합니다.
|
||||
- 로컬 실행, 배포 보조 절차는 `docs/development/` 에 Runbook 템플릿으로 작성합니다.
|
||||
- 신규 문서를 추가할 때는 템플릿을 복사해 시작하고, 해당 토픽의 `README.md` 를 함께 갱신합니다.
|
||||
- 인프라 운영 문서 (k8s / vault / argocd / cert-manager 등) 는 이 repo 가 아니라 [Project-Infra](https://github.com/donghyeon-ka/Project-Infra) 에 둡니다.
|
||||
|
||||
## 8. 가장 중요한 원칙
|
||||
|
||||
가장 가치 있는 문서는 *"삽질 기록"* 입니다. 깔끔한 이론 요약만 있는 문서보다, 실제 문제를 어떻게 관찰하고 가설을 세우고 원인을 찾고 해결했는지를 남긴 문서가 더 오래 쓰입니다.
|
||||
@@ -0,0 +1,254 @@
|
||||
# Audit Columns 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 최소 감사 컬럼은 created_at, updated_at을 공통으로 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT current_timestamp,
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT current_timestamp
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 생성/수정 시각을 공통 규칙으로 표준화한다
|
||||
- 두 컬럼 모두 절대 시점 타입을 사용한다
|
||||
- insert 시점 기본값을 DB가 일관되게 채운다
|
||||
|
||||
PostgreSQL은 `CURRENT_TIMESTAMP`를 timestamp column default의 대표 예시로 설명하고, default expression은 row 삽입 시 평가된다고 설명한다. `CURRENT_TIMESTAMP`/`now()`는 transaction start time 의미를 갖는다.
|
||||
|
||||
### 예시 2. updated_at은 DB trigger 하나만 source of truth로 둔다
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION common_set_updated_at()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := current_timestamp;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_users_set_updated_at
|
||||
BEFORE UPDATE ON auth.users
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.* IS DISTINCT FROM NEW.*)
|
||||
EXECUTE FUNCTION common_set_updated_at();
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- `updated_at` 갱신 책임이 DB에 명확하게 모인다
|
||||
- BEFORE ROW trigger가 NEW를 수정해 반환하는 PostgreSQL 공식 모델과 맞다
|
||||
- `WHEN (OLD.* IS DISTINCT FROM NEW.*)`로 실제 변경이 있을 때만 갱신하게 만들 수 있다
|
||||
|
||||
PostgreSQL은 BEFORE row trigger가 NEW를 수정하고 반환할 수 있다고 설명하고, `WHEN (OLD.* IS DISTINCT FROM NEW.*)` 예시도 공식 문서에 제공한다.
|
||||
|
||||
### 예시 3. 주체 컬럼은 애플리케이션 principal 식별자로 둔다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@CreatedBy
|
||||
@Column(name = "created_by")
|
||||
private Long createdBy;
|
||||
|
||||
@LastModifiedBy
|
||||
@Column(name = "updated_by")
|
||||
private Long updatedBy;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- `created_by`, `updated_by`를 DB 연결 계정이 아니라 애플리케이션 actor 식별자로 다룬다
|
||||
- Spring Data JPA의 표준 auditing metadata를 사용한다
|
||||
- 시간 컬럼과 주체 컬럼의 source를 분리할 수 있다
|
||||
|
||||
Spring Data JPA는 `@CreatedBy`, `@LastModifiedBy`를 제공하고, `AuditorAware<T>`가 현재 애플리케이션 principal을 제공하도록 설명한다.
|
||||
|
||||
### 예시 4. 시간과 주체를 필요한 만큼만 선택적으로 둔다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
@Table(name = "login_failures", schema = "auth")
|
||||
public class LoginFailure {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 시간 정보만 필요하면 날짜 감사 컬럼만 써도 된다
|
||||
- Spring Data JPA는 시간 추적만 하는 경우 `AuditorAware`가 필수가 아니라고 설명한다
|
||||
- 모든 테이블에 주체 컬럼을 기계적으로 강제하지 않는다
|
||||
|
||||
Spring Data JPA는 생성/수정 날짜만 추적하는 경우 `AuditorAware`가 필요 없다고 설명한다.
|
||||
|
||||
### 예시 5. Hibernate timestamp를 쓴다면 source를 명시적으로 선택한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "sessions", schema = "auth")
|
||||
public class Session {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@CreationTimestamp(source = SourceType.DB)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp(source = SourceType.DB)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Hibernate의 기본 VM source 대신 DB source를 명시적으로 선택한다
|
||||
- 다중 인스턴스 환경에서 시간 source를 DB로 맞추고 싶다는 의도가 드러난다
|
||||
- 같은 컬럼을 또 다른 trigger가 동시에 갱신하지만 않는다면 일관성이 높다
|
||||
|
||||
Hibernate는 `@CreationTimestamp`, `@UpdateTimestamp`가 기본적으로 VM(in memory)에서 생성되지만, `source()`로 변경할 수 있고 `SourceType.DB`는 DB가 값을 생성함을 뜻한다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 감사 컬럼 없이 테이블마다 임의 이름을 쓴다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
reg_dt timestamp,
|
||||
mod_ymd text
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공통 규칙이 없어 해석과 조회가 어려워진다
|
||||
- 수정 시각을 문자열로 저장해 타입 의미가 무너진다
|
||||
- 생성/수정 메타데이터가 표준화되지 않는다
|
||||
|
||||
PostgreSQL은 timestamp/date/time 타입을 제공하고, `CURRENT_TIMESTAMP` 같은 기본 시간 함수도 제공한다. 감사 컬럼은 이런 표준 타입 위에서 일관되게 두는 편이 안전하다.
|
||||
|
||||
### 예시 2. default에 TIMESTAMP 'now' literal을 쓴다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT TIMESTAMP 'now'
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL이 default 절에서는 이 형태를 쓰지 말라고 명시한다
|
||||
- row 삽입 시점이 아니라 table creation 시점으로 고정될 수 있다
|
||||
- `CURRENT_TIMESTAMP` 또는 `now()` 같은 함수형 표현이 맞다
|
||||
|
||||
PostgreSQL은 later evaluation이 필요한 DEFAULT 절에서 `TIMESTAMP 'now'`를 사용하지 말라고 설명한다.
|
||||
|
||||
### 예시 3. 애플리케이션 사용자 식별자를 current_user에 맡긴다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_by text NOT NULL DEFAULT current_user,
|
||||
updated_by text NOT NULL DEFAULT current_user
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- `current_user`는 DB 권한 검사에 쓰이는 사용자다
|
||||
- connection pool, `SET ROLE`, `SECURITY DEFINER` 환경에서는 최종 애플리케이션 사용자와 다를 수 있다
|
||||
- 일반 웹 애플리케이션의 actor 추적 컬럼으로는 부적절하다
|
||||
|
||||
PostgreSQL은 `current_user`가 권한 검사에 쓰이는 사용자이고, `SET ROLE`이나 `SECURITY DEFINER`에 의해 바뀔 수 있다고 설명한다. Spring Data JPA는 이런 경우 현재 principal을 `AuditorAware<T>`로 제공하도록 설계한다.
|
||||
|
||||
### 예시 4. 같은 updated_at을 DB trigger와 ORM이 동시에 관리한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TRIGGER trg_users_set_updated_at
|
||||
BEFORE UPDATE ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION common_set_updated_at();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 컬럼에 두 개의 writer가 생긴다
|
||||
- VM source와 DB source가 섞일 수 있다
|
||||
- 어떤 값이 권위 있는지 흐려지고 디버깅이 어려워진다
|
||||
|
||||
Hibernate는 `@UpdateTimestamp`의 기본 source가 VM이라고 설명하고, PostgreSQL trigger는 NEW row를 수정해 저장값을 바꿀 수 있다고 설명한다. 둘을 같은 컬럼에 동시에 쓰면 source of truth가 모호해진다.
|
||||
|
||||
### 예시 5. created_at을 business code가 직접 덮어쓴다
|
||||
|
||||
```java
|
||||
user.setCreatedAt(OffsetDateTime.now());
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 생성 감사 컬럼이 도메인 로직에서 임의로 바뀔 수 있다
|
||||
- DB default나 프레임워크 auditing 의미를 깨뜨린다
|
||||
- 생성 메타데이터의 불변성이 사라진다
|
||||
|
||||
Hibernate는 `@CreationTimestamp` 필드는 애플리케이션이 직접 설정할 수 없다고 설명한다. 프로젝트도 같은 철학으로, 생성 감사 컬럼을 business code가 임의로 다루지 않게 한다.
|
||||
|
||||
### 예시 6. deleted_at과 version을 기본 감사 컬럼처럼 섞어 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
version bigint NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- `deleted_at`은 soft delete 정책의 일부이고, `version`은 optimistic locking 메타데이터다
|
||||
- 현재 row 감사 컬럼과 다른 목적의 컬럼을 한 범주로 섞는다
|
||||
- 문서 경계와 코드 책임이 흐려진다
|
||||
|
||||
현재 문서 체계에서도 soft delete와 concurrency/version은 별도 주제로 분리되어 있다.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Column Types 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 문자열 기본값은 text, 실제 길이 규칙은 varchar(n)
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email varchar(320) NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
bio text,
|
||||
created_at timestamp with time zone NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 이메일은 실제 길이 제한을 반영했다
|
||||
- 자유 텍스트는 text
|
||||
- 시점은 timestamptz
|
||||
- 생성 키는 identity를 사용한다
|
||||
|
||||
### 예시 2. 금액은 numeric, 공개 식별자는 uuid
|
||||
|
||||
```sql
|
||||
CREATE TABLE billing.payments (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
public_id uuid NOT NULL,
|
||||
amount numeric(19,4) NOT NULL,
|
||||
currency varchar(3) NOT NULL,
|
||||
paid_at timestamp with time zone
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 정밀도가 필요한 금액을 numeric 으로 저장한다
|
||||
- UUID를 문자열로 저장하지 않는다
|
||||
- 시간은 시점 의미로 저장한다
|
||||
|
||||
### 예시 3. 외부 payload 보관은 jsonb
|
||||
|
||||
```sql
|
||||
CREATE TABLE integration.webhook_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
provider varchar(50) NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
received_at timestamp with time zone NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 반정형 외부 payload 저장 용도에 맞다
|
||||
- 나중에 검색/인덱싱 여지도 있다
|
||||
- 원문 텍스트 보존이 핵심이 아니라면 jsonb 가 더 실용적이다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 모든 문자열을 varchar(255) 로 통일한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
email varchar(255) NOT NULL,
|
||||
display_name varchar(255) NOT NULL,
|
||||
bio varchar(255)
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 길이 규칙을 표현하지 못한다
|
||||
- 자유 텍스트까지 임의 길이로 잘라 버린다
|
||||
- PostgreSQL은 text 와 varchar 사이에 일반 성능 이점이 없다고 설명한다
|
||||
|
||||
### 예시 2. 금액을 부동소수점으로 저장한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE billing.payments (
|
||||
amount double precision NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- double precision 은 부정확한 floating-point 타입이다
|
||||
- 금액/정산에 부적절하다
|
||||
|
||||
### 예시 3. UUID를 문자열에 저장한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.sessions (
|
||||
session_id varchar(36) NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL이 네이티브 uuid 타입을 제공하는데 활용하지 않는다
|
||||
- 문자열 유효성/연산/저장 의미가 흐려진다
|
||||
|
||||
### 예시 4. 시점을 로컬 datetime처럼 저장한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.audit_logs (
|
||||
created_at timestamp NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 절대 시점인지 로컬 시각인지 의미가 불명확하다
|
||||
- 일반적인 생성/수정 시각은 timestamp with time zone 이 더 안전하다
|
||||
@@ -0,0 +1,279 @@
|
||||
# DB Concurrency 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 동시에 수정될 수 있는 aggregate root에는 @Version을 둔다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
@Column(name = "display_name", nullable = false)
|
||||
private String displayName;
|
||||
|
||||
public void changeDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 같은 사용자를 여러 요청이 동시에 수정할 때 conflicting update를 감지할 수 있다
|
||||
- JPA 표준 메커니즘이라 provider portability가 높다
|
||||
- 숫자형 version은 timestamp보다 기본값으로 더 신뢰하기 쉽다
|
||||
|
||||
Jakarta Persistence는 version 필드가 있는 엔티티에 대해 provider가 자동 optimistic locking을 수행해야 한다고 설명하고, Hibernate는 `@Version`이 lost update를 막는 기본 메커니즘이라고 설명합니다.
|
||||
|
||||
### 예시 2. 상태 전이는 조건부 UPDATE 한 번으로 처리한다
|
||||
|
||||
```sql
|
||||
UPDATE billing.payments
|
||||
SET status = 'CONFIRMED',
|
||||
confirmed_at = now()
|
||||
WHERE id = :paymentId
|
||||
AND status = 'PENDING'
|
||||
RETURNING id, status, confirmed_at;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- PENDING일 때만 확정된다
|
||||
- 선조회와 후행 update를 분리하지 않아 경쟁 상태를 줄인다
|
||||
- 반환 row가 없으면 이미 다른 트랜잭션이 상태를 바꿨다고 해석할 수 있다
|
||||
|
||||
PostgreSQL은 concurrent update 뒤 WHERE 조건을 다시 평가하고, `UPDATE ... RETURNING`으로 실제 반영된 row를 바로 받을 수 있다고 설명합니다.
|
||||
|
||||
### 예시 3. 중복 생성은 UNIQUE + ON CONFLICT로 닫는다
|
||||
|
||||
```sql
|
||||
CREATE TABLE integration.webhook_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
provider text NOT NULL,
|
||||
provider_event_id text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
CONSTRAINT uq_webhook_events__provider_event_id
|
||||
UNIQUE (provider, provider_event_id)
|
||||
);
|
||||
|
||||
INSERT INTO integration.webhook_events (provider, provider_event_id, payload)
|
||||
VALUES (:provider, :eventId, CAST(:payload AS jsonb))
|
||||
ON CONFLICT (provider, provider_event_id) DO NOTHING
|
||||
RETURNING id;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 중복 webhook 반영을 애플리케이션 선조회가 아니라 DB 제약으로 닫는다
|
||||
- high concurrency에서도 중복 row가 생기지 않는다
|
||||
- 성공 여부를 RETURNING 결과 존재로 판정할 수 있다
|
||||
|
||||
PostgreSQL은 제약 위반 시 저장을 막고, `ON CONFLICT`가 high concurrency에서도 atomic outcome을 제공한다고 설명합니다.
|
||||
|
||||
### 예시 4. create-or-update는 UPSERT로 처리한다
|
||||
|
||||
```sql
|
||||
INSERT INTO auth.login_failures (user_id, fail_count, last_failed_at)
|
||||
VALUES (:userId, 1, now())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE
|
||||
SET fail_count = auth.login_failures.fail_count + 1,
|
||||
last_failed_at = EXCLUDED.last_failed_at
|
||||
RETURNING user_id, fail_count, last_failed_at;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- "없으면 생성, 있으면 누적 갱신"을 한 문장으로 처리한다
|
||||
- 경쟁 상태에서 insert/update 사이가 찢어지지 않는다
|
||||
- 원자적 upsert semantics를 그대로 활용한다
|
||||
|
||||
PostgreSQL은 `ON CONFLICT DO UPDATE`가 atomic한 insert-or-update outcome을 보장한다고 설명합니다.
|
||||
|
||||
### 예시 5. 낙관적 락 실패는 유스케이스 실패로 올린다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void changeDisplayName(Long userId, String newName) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(UserNotFoundException::new);
|
||||
|
||||
user.changeDisplayName(newName);
|
||||
|
||||
entityManager.flush();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- optimistic lock 충돌을 트랜잭션 후반 commit 시점이 아니라, 서비스 내부에서 더 빨리 드러나게 할 수 있다
|
||||
- 실패를 조기에 감지하고 응답 정책을 결정하기 쉽다
|
||||
- 예외를 숨기지 않고 유스케이스 실패로 처리한다
|
||||
|
||||
Jakarta Persistence는 optimistic lock check가 commit 시점까지 지연될 수 있고, 더 일찍 처리해야 하면 `flush()`를 사용할 수 있다고 설명합니다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. version 없이 마지막 커밋이 이기게 둔다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Column(name = "display_name", nullable = false)
|
||||
private String displayName;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 두 요청이 같은 사용자를 수정하면 나중에 commit한 요청이 앞선 변경을 덮어쓸 수 있다
|
||||
- detached merge나 동시 수정에서 lost update 위험이 크다
|
||||
- 표준 optimistic locking 보호가 없다
|
||||
|
||||
Jakarta Persistence는 version이 없는 엔티티는 애플리케이션이 직접 일관성을 책임져야 하고, version을 쓰지 않으면 lost update와 inconsistent state를 초래할 수 있다고 설명합니다.
|
||||
|
||||
### 예시 2. 먼저 읽고, 자바에서 검사한 뒤, 별도 UPDATE를 날린다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void confirmPayment(Long paymentId) {
|
||||
Payment payment = paymentRepository.findById(paymentId)
|
||||
.orElseThrow(PaymentNotFoundException::new);
|
||||
|
||||
if (payment.getStatus() != PaymentStatus.PENDING) {
|
||||
throw new IllegalStateException("not pending");
|
||||
}
|
||||
|
||||
payment.confirm();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 PENDING row를 두 트랜잭션이 동시에 읽고 둘 다 confirm하려고 할 수 있다
|
||||
- 버전 락이나 조건부 update가 없으면 경쟁 상태를 막기 어렵다
|
||||
- 상태 전이 조건이 DB 최종 판정이 아니라 애플리케이션 선조회에만 의존한다
|
||||
|
||||
PostgreSQL은 concurrent update 시 row를 다시 적용하고 WHERE를 재평가한다고 설명하므로, 이런 read-then-act 패턴보다 단일 조건부 mutation이 더 안전합니다.
|
||||
|
||||
### 예시 3. 중복 생성 방지를 선조회로만 처리한다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void registerWebhookEvent(String provider, String eventId, String payload) {
|
||||
boolean exists = webhookEventRepository.existsByProviderAndProviderEventId(provider, eventId);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
webhookEventRepository.save(new WebhookEvent(provider, eventId, payload));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 두 트랜잭션이 동시에 exists = false를 볼 수 있다
|
||||
- UNIQUE 제약이 없으면 중복 row가 실제로 저장될 수 있다
|
||||
- 선조회는 보조일 수 있어도, 최종 정합성 보장 수단이 아니다
|
||||
|
||||
PostgreSQL은 제약이 저장 시점의 위반을 막는다고 설명합니다. 이런 문제는 UNIQUE 없이 안전하지 않습니다.
|
||||
|
||||
### 예시 4. OptimisticLockException을 잡아서 무시한다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void changeDisplayName(Long userId, String newName) {
|
||||
try {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(UserNotFoundException::new);
|
||||
user.changeDisplayName(newName);
|
||||
entityManager.flush();
|
||||
} catch (OptimisticLockException ignored) {
|
||||
// 무시
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 현재 트랜잭션은 이미 rollback 대상으로 표시될 수 있다
|
||||
- 유스케이스 실패를 숨기고 잘못된 성공처럼 보이게 만든다
|
||||
- 재시도 여부는 상위 application boundary에서 다시 판단해야 한다
|
||||
|
||||
Jakarta Persistence는 optimistic lock failure 시 `OptimisticLockException`을 던지고 현재 트랜잭션을 rollback 대상으로 표시해야 한다고 규정합니다.
|
||||
|
||||
### 예시 5. 핵심 필드를 optimistic lock에서 제외한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class Payment {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@OptimisticLock(excluded = true)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Version
|
||||
private Long version;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 금액처럼 핵심 비즈니스 필드를 제외하면 concurrent overwrite를 받아들인다는 뜻이 된다
|
||||
- 이런 필드는 lost update를 허용하면 안 된다
|
||||
- `excluded=true`는 부수적 카운터 같은 제한된 경우에만 의미가 있다
|
||||
|
||||
Hibernate는 `excluded` 속성이 version 증가를 막아 lost update를 수용할 수 있는 필드에만 써야 함을 예시로 설명합니다.
|
||||
|
||||
### 예시 6. unique violation을 무한 재시도한다
|
||||
|
||||
```java
|
||||
while (true) {
|
||||
try {
|
||||
userRepository.save(user);
|
||||
break;
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// 계속 재시도
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- unique violation은 transient concurrency일 수도 있지만, 영구적인 비즈니스 오류일 수도 있다
|
||||
- 원인을 구분하지 않으면 무한 루프나 불필요한 부하를 만든다
|
||||
- 재시도는 전체 유스케이스와 에러 코드 의미를 보고 제한적으로만 해야 한다
|
||||
|
||||
PostgreSQL은 23505가 때로는 concurrency 실패일 수 있지만, 항상 그런 것은 아니므로 더 신중해야 한다고 설명합니다.
|
||||
|
||||
### 예시 7. 단순 중복 생성 문제를 곧바로 FOR UPDATE로 푼다
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM auth.users
|
||||
WHERE email = :email
|
||||
FOR UPDATE;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 중복 생성 방지의 기본 문제는 uniqueness인데, row lock을 먼저 가져가면 설계가 과도하게 무거워진다
|
||||
- 아직 row가 없는 경우에는 lock으로도 해결되지 않는다
|
||||
- 이런 문제는 보통 unique constraint와 `ON CONFLICT`가 더 직접적이다
|
||||
|
||||
PostgreSQL은 `FOR UPDATE`가 기존 row를 잠그는 수단이라고 설명합니다. "존재하지 않는 row의 uniqueness"는 제약이 더 적절합니다.
|
||||
@@ -0,0 +1,286 @@
|
||||
# Index 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 자주 조회되는 FK에 단일 컬럼 B-tree 인덱스를 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE auth.sessions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
user_id bigint NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_sessions PRIMARY KEY (id),
|
||||
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
|
||||
REFERENCES auth.users (id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX ix_sessions__user_id ON auth.sessions (user_id);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- FK는 참조 무결성을 보장하지만, 참조하는 쪽 인덱스는 자동 생성되지 않는다
|
||||
- 세션을 사용자 기준으로 자주 조회하거나, 사용자 삭제 시 자식 세션을 찾는 경로에 도움이 된다
|
||||
- 단순하고 신뢰도 높은 기본 패턴이다
|
||||
|
||||
이 예시는 PostgreSQL의 FK 동작과 B-tree 기본 사용 패턴에 맞는 안전한 best practice다.
|
||||
|
||||
### 예시 2. 복합 검색 패턴에는 왼쪽 컬럼 순서를 고려한 멀티 컬럼 인덱스를 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE ordering.orders (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
tenant_id bigint NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_orders PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_orders__tenant_status_created_at
|
||||
ON ordering.orders (tenant_id, status, created_at DESC);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- tenant_id = ? AND status = ? AND created_at < ? ORDER BY created_at DESC 같은 패턴에 잘 맞는다
|
||||
- equality 필터를 왼쪽에 두고, range/정렬 컬럼을 뒤에 둔 전형적인 B-tree 설계다
|
||||
- 멀티 컬럼 인덱스가 실제 쿼리 패턴에 직접 대응한다
|
||||
|
||||
PostgreSQL은 B-tree 멀티 컬럼 인덱스가 leading columns 제약에 가장 효율적이라고 설명한다.
|
||||
|
||||
### 예시 3. soft delete 활성 행만 자주 조회되면 partial index를 사용한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
tenant_id bigint NOT NULL,
|
||||
email text NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_users__tenant_id_email__active
|
||||
ON auth.users (tenant_id, email)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 전체 사용자보다 “삭제되지 않은 사용자”만 자주 조회되는 경우에 맞는 설계다
|
||||
- hot subset만 인덱싱하므로 전체 인덱스보다 더 작고 유지 비용도 낮을 수 있다
|
||||
- predicate가 단순하고 쿼리와 맞추기 쉽다
|
||||
|
||||
PostgreSQL 공식 문서도 partial index를 이런 subset 최적화에 쓰도록 설명한다.
|
||||
|
||||
### 예시 4. 대소문자 무시 검색은 expression index로 맞춘다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
email text NOT NULL,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_users__lower_email
|
||||
ON auth.users (lower(email));
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 검색과 고유성 규칙이 모두 lower(email) 의미에 맞춰져 있다
|
||||
- 애플리케이션에서 임시 변환만 하는 것보다 DB 규칙이 더 명확하다
|
||||
- 표현식 인덱스의 대표적인 안전 사용 사례다
|
||||
|
||||
PostgreSQL은 lower(col) 같은 expression index를 공식 지원하고, unique expression index로 단순 unique constraint로 표현하기 어려운 규칙도 강제할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. 목록 조회 최적화가 확실하면 INCLUDE를 보수적으로 사용한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE ordering.orders (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
tenant_id bigint NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
total_amount numeric(19,4) NOT NULL,
|
||||
CONSTRAINT pk_orders PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_orders__tenant_status_created_at
|
||||
ON ordering.orders (tenant_id, status, created_at DESC)
|
||||
INCLUDE (total_amount);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 자주 실행되는 목록 조회가 tenant_id, status, created_at 기준으로 필터/정렬되고, 응답에는 total_amount가 필요할 때 유효하다
|
||||
- total_amount는 non-key payload라 uniqueness/탐색 키 의미를 어지럽히지 않는다
|
||||
- 작은 payload 컬럼만 추가한 보수적 covering index다
|
||||
|
||||
PostgreSQL은 INCLUDE가 index-only scan을 돕지만, 인덱스 크기를 키우므로 보수적으로 사용해야 한다고 설명한다.
|
||||
|
||||
### 예시 6. jsonb 전체 containment 검색은 GIN을 검토한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE integration.webhook_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
payload jsonb NOT NULL,
|
||||
received_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_webhook_events PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_webhook_events__payload_gin
|
||||
ON integration.webhook_events
|
||||
USING GIN (payload);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- payload @> ..., key existence, jsonpath 검색 같은 jsonb 검색 패턴에 맞는다
|
||||
- B-tree로 해결할 수 없는 composite value 내부 검색을 GIN으로 처리한다
|
||||
- jsonb를 유지해야 하는 상황에서 가장 전형적인 공식 패턴이다
|
||||
|
||||
PostgreSQL은 jsonb 검색에 GIN을 사용할 수 있고, key/key-value search 및 containment에 적합하다고 설명한다.
|
||||
|
||||
### 예시 7. 대형 append-only 로그는 BRIN을 검토한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit.audit_logs (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
occurred_at timestamp with time zone NOT NULL,
|
||||
actor_id bigint,
|
||||
action text NOT NULL,
|
||||
CONSTRAINT pk_audit_logs PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_audit_logs__occurred_at_brin
|
||||
ON audit.audit_logs
|
||||
USING BRIN (occurred_at);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 아주 큰 로그 테이블에서 occurred_at이 물리 저장 순서와 자연스럽게 상관될 가능성이 높다
|
||||
- BRIN은 작은 인덱스로 큰 범위를 건너뛸 수 있다
|
||||
- append-only 성격이 강한 로그/이력성 테이블과 잘 맞는다
|
||||
|
||||
PostgreSQL은 BRIN이 매우 큰 테이블과 물리 순서 상관성이 있는 컬럼에 적합하다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. PK가 이미 만든 인덱스를 다시 만든다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
email text NOT NULL,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_users__id ON auth.users (id);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PK가 이미 unique B-tree 인덱스를 자동 생성한다
|
||||
- 같은 의미의 중복 인덱스라 쓰기 비용과 저장 비용만 늘린다
|
||||
- 운영상 관리 포인트만 증가한다
|
||||
|
||||
PostgreSQL 공식 문서는 PK/UNIQUE 제약이 자동으로 인덱스를 만든다고 설명한다.
|
||||
|
||||
### 예시 2. 실제 쿼리와 무관하게 긴 멀티 컬럼 인덱스를 만든다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_orders__tenant_status_type_created_at_updated_at
|
||||
ON ordering.orders (tenant_id, status, type, created_at, updated_at);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 어떤 쿼리를 위한 인덱스인지 설명하기 어렵다
|
||||
- 멀티 컬럼이 길어질수록 유지 비용이 커지고 활용 범위도 애매해진다
|
||||
- 대부분의 경우 단일/짧은 복합 인덱스 조합이 더 낫다
|
||||
|
||||
PostgreSQL도 멀티 컬럼 인덱스는 신중히 사용해야 하고, 3개를 넘는 경우는 드물게만 유효하다고 설명한다.
|
||||
|
||||
### 예시 3. 단일 컬럼 DESC 인덱스를 습관적으로 만든다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_orders__created_at_desc
|
||||
ON ordering.orders (created_at DESC);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 단일 컬럼 B-tree는 backward scan이 가능하므로 대개 별도 DESC 인덱스 이점이 없다
|
||||
- mixed ordering이 아닌데도 특수 정렬을 도입해 관리 복잡도만 올린다
|
||||
- 실제 쿼리 근거가 부족하다
|
||||
|
||||
PostgreSQL은 ordered scan을 forward/backward 모두 지원하고, 단일 컬럼에서는 별도 DESC 인덱스가 대체로 유용하지 않다고 설명한다.
|
||||
|
||||
### 예시 4. partial index predicate를 쿼리와 다르게 만든다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_users__active_recent
|
||||
ON auth.users (tenant_id, email)
|
||||
WHERE deleted_at IS NULL AND last_login_at > now() - interval '30 days';
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- now() 같은 시간 의존 조건은 안정적인 partial index predicate로 부적절하다
|
||||
- 쿼리와 predicate가 정확히 맞지 않으면 planner가 인덱스를 잘 쓰지 못한다
|
||||
- 데이터 분포와 시간 경계가 계속 바뀌므로 유지 신뢰도가 낮다
|
||||
|
||||
PostgreSQL은 partial index predicate가 planner가 인식 가능한 형태여야 한다고 설명한다.
|
||||
|
||||
### 예시 5. 큰 payload를 INCLUDE에 넣는다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_posts__author_created_at
|
||||
ON board.posts (author_id, created_at DESC)
|
||||
INCLUDE (content, metadata_json);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 큰 text/json payload는 인덱스를 크게 부풀린다
|
||||
- 인덱스 tuple 크기 제한에 걸릴 수 있고, 검색 자체도 느려질 수 있다
|
||||
- INCLUDE는 작은 응답용 컬럼에만 보수적으로 써야 한다
|
||||
|
||||
PostgreSQL은 wide non-key column을 INCLUDE에 넣는 것을 보수적으로 하라고 명시한다.
|
||||
|
||||
### 예시 6. 운영 중 대형 테이블에 일반 CREATE INDEX를 바로 실행한다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_events__occurred_at
|
||||
ON audit.audit_logs (occurred_at);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 일반 CREATE INDEX는 빌드 동안 writes를 막는다
|
||||
- 운영 중 대형 테이블에서는 서비스 영향이 매우 클 수 있다
|
||||
- 이런 경우는 CREATE INDEX CONCURRENTLY 여부를 먼저 검토해야 한다
|
||||
|
||||
PostgreSQL 공식 문서도 일반 index build는 writes를 block하고, 운영 환경에서는 종종 unacceptable하다고 설명한다.
|
||||
|
||||
### 예시 7. jsonb 키 검색에 B-tree를 건다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_webhook_events__payload
|
||||
ON integration.webhook_events (payload);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- jsonb @>, ?, @?, @@ 같은 검색은 B-tree 기본 패턴과 맞지 않는다
|
||||
- composite value 내부 검색은 GIN 같은 전용 접근 방식이 더 적합하다
|
||||
- 타입/연산자 특성을 무시한 설계다
|
||||
|
||||
PostgreSQL은 jsonb key/key-value 검색에 GIN을 공식적으로 권장 가능한 접근 방식으로 설명한다.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Transaction Isolation 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 일반적인 서비스 로직은 기본값을 따른다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserCommandService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional
|
||||
public void changeDisplayName(Long userId, String newName) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(UserNotFoundException::new);
|
||||
|
||||
user.changeDisplayName(newName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring 기본값인 `Isolation.DEFAULT`를 사용한다
|
||||
- PostgreSQL에서는 보통 `READ COMMITTED`가 적용된다
|
||||
- 일반적인 단건 수정 유스케이스에 과도한 isolation을 강제하지 않는다
|
||||
|
||||
Spring은 `@Transactional` 기본 isolation이 `ISOLATION_DEFAULT`라고 설명하고, PostgreSQL은 기본 isolation이 보통 `READ COMMITTED`라고 설명한다.
|
||||
|
||||
### 예시 2. 같은 트랜잭션 안에서 stable snapshot이 필요하면 REPEATABLE_READ를 검토한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SettlementPreviewService {
|
||||
|
||||
private final SettlementRepository settlementRepository;
|
||||
|
||||
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
|
||||
public SettlementPreview preview(Long merchantId, LocalDate from, LocalDate to) {
|
||||
List<SettlementLine> lines = settlementRepository.findLines(merchantId, from, to);
|
||||
BigDecimal fee = settlementRepository.sumFee(merchantId, from, to);
|
||||
|
||||
return SettlementPreview.of(lines, fee);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 여러 query가 같은 snapshot을 기준으로 계산되길 원할 때 의미가 있다
|
||||
- PostgreSQL의 `REPEATABLE READ`는 트랜잭션 시작 시점 snapshot을 유지한다
|
||||
- 단, 이 설계는 여전히 serialization failure 재시도 필요성을 함께 고려해야 한다
|
||||
|
||||
PostgreSQL은 `REPEATABLE READ`에서 successive SELECT가 같은 snapshot을 보고, serialization failure에 대비해야 한다고 설명한다.
|
||||
|
||||
### 예시 3. cross-row invariant가 중요하면 SERIALIZABLE과 재시도를 함께 둔다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SeatAllocationService {
|
||||
|
||||
private final SeatRepository seatRepository;
|
||||
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public void allocateSeat(Long eventId, Long userId) {
|
||||
if (seatRepository.countAllocated(eventId) >= seatRepository.capacityOf(eventId)) {
|
||||
throw new NoSeatLeftException();
|
||||
}
|
||||
|
||||
seatRepository.insertAllocation(eventId, userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 집합 단위 정합성이 중요한 유스케이스를 명시적으로 serial semantics로 올린다
|
||||
- 단순 snapshot 안정성이 아니라 serialization anomaly 방지가 목적이다
|
||||
- 이 경우 40001 전체 재시도 정책이 같이 있어야 설계가 완성된다
|
||||
|
||||
PostgreSQL은 `SERIALIZABLE`이 serial execution과 같은 효과를 보장하지만, serialization failure가 발생할 수 있으므로 재시도가 필요하다고 설명한다.
|
||||
|
||||
### 예시 4. stronger isolation보다 더 직접적인 수단이 있으면 그쪽을 먼저 쓴다
|
||||
|
||||
```sql
|
||||
UPDATE billing.payments
|
||||
SET status = 'CONFIRMED',
|
||||
confirmed_at = now()
|
||||
WHERE id = :paymentId
|
||||
AND status = 'PENDING';
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 단순 상태 전이는 stronger isolation보다 조건부 UPDATE가 더 직접적이다
|
||||
- `READ COMMITTED`에서도 원자적으로 성공 여부를 판단할 수 있다
|
||||
- isolation level을 과도하게 올리지 않아도 된다
|
||||
|
||||
PostgreSQL은 `READ COMMITTED`에서 concurrent update 시 WHERE 조건이 재평가될 수 있고, 조건부 mutation이 유용하게 동작한다고 설명한다.
|
||||
|
||||
### 예시 5. inner method isolation override를 기대하지 않고 outer boundary에서 선언한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportFacade {
|
||||
|
||||
private final ReportQueryService reportQueryService;
|
||||
|
||||
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
|
||||
public ReportResponse generate(Long reportId) {
|
||||
return reportQueryService.generate(reportId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- isolation을 outer use case boundary에서 선언한다
|
||||
- inner service가 기존 트랜잭션에 참여하면서 의미가 흐려지는 것을 피한다
|
||||
- Spring의 isolation 적용 규칙과 맞다
|
||||
|
||||
Spring은 isolation setting이 새로 시작된 트랜잭션에만 적용되고, 기존 트랜잭션에 참여하는 inner scope의 local isolation은 기본적으로 무시된다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. PostgreSQL에서 READ_UNCOMMITTED를 dirty read 용도로 기대한다
|
||||
|
||||
```java
|
||||
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
|
||||
public User findUser(Long id) {
|
||||
return userRepository.findById(id).orElseThrow();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL에서는 `READ UNCOMMITTED`가 별도 dirty-read 모드로 동작하지 않는다
|
||||
- 실제로는 `READ COMMITTED`처럼 동작한다
|
||||
- 성능이나 동작 의미가 달라질 것이라 기대하면 틀린 설계가 된다
|
||||
|
||||
PostgreSQL은 `READ UNCOMMITTED`가 내부적으로 `READ COMMITTED`처럼 동작한다고 명시한다.
|
||||
|
||||
### 예시 2. READ_COMMITTED에서 같은 트랜잭션 안의 두 조회가 반드시 같을 것이라 가정한다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public boolean canStillShip(Long orderId) {
|
||||
Order order1 = orderRepository.findById(orderId).orElseThrow();
|
||||
|
||||
// 중간에 다른 트랜잭션이 상태를 바꿀 수 있음
|
||||
|
||||
Order order2 = orderRepository.findById(orderId).orElseThrow();
|
||||
return order1.getStatus() == order2.getStatus();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL `READ COMMITTED`에서는 각 query가 자기 시작 시점 snapshot을 본다
|
||||
- 따라서 같은 트랜잭션 안에서도 두 조회 결과가 달라질 수 있다
|
||||
- stable snapshot이 필요한 로직이라면 isolation 또는 더 직접적인 동시성 제어를 다시 설계해야 한다
|
||||
|
||||
PostgreSQL은 `READ COMMITTED`에서 subsequent commands in the same transaction이 committed concurrent transaction의 효과를 보게 된다고 설명한다.
|
||||
|
||||
### 예시 3. REPEATABLE_READ를 쓰면서 serialization failure 재시도를 준비하지 않는다
|
||||
|
||||
```java
|
||||
@Transactional(isolation = Isolation.REPEATABLE_READ)
|
||||
public void runComplexSettlement(Long merchantId) {
|
||||
// 복잡한 다단계 조회/갱신
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL의 `REPEATABLE READ`도 serialization anomaly를 막기 위해 실패할 수 있다
|
||||
- stronger isolation만 올리고 재시도 정책을 설계하지 않으면 운영 시 장애로 이어질 수 있다
|
||||
- "repeatable read니까 실패는 없을 것"이라는 가정이 틀리다
|
||||
|
||||
PostgreSQL은 `REPEATABLE READ`에서도 애플리케이션이 serialization failure 재시도를 준비해야 한다고 설명한다.
|
||||
|
||||
### 예시 4. SERIALIZABLE을 전역 기본값처럼 남발한다
|
||||
|
||||
```java
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public void doAnything() {
|
||||
// 일반 CRUD, 단순 조회, 목록 조회까지 전부 같은 정책
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- serial semantics가 필요하지 않은 경로에도 monitoring overhead와 retry 부담을 강제한다
|
||||
- 더 직접적인 수단으로 닫을 수 있는 문제까지 모두 isolation으로 해결하려 든다
|
||||
- stronger isolation은 증명 가능한 요구가 있을 때만 써야 한다
|
||||
|
||||
PostgreSQL은 `SERIALIZABLE`이 monitoring overhead를 가지며, serialization failure를 일으킬 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. inner method에서 isolation을 바꾸면 outer transaction을 override할 수 있다고 기대한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class OuterService {
|
||||
|
||||
@Transactional
|
||||
public void outer() {
|
||||
inner();
|
||||
}
|
||||
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public void inner() {
|
||||
// 여기서 isolation이 바뀔 것이라고 기대
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기존 트랜잭션에 참여하면 inner isolation 선언은 기본적으로 무시될 수 있다
|
||||
- 게다가 self-invocation 구조라 transaction advice 자체가 적용되지 않을 수도 있다
|
||||
- isolation override 의도가 코드에 반영되지 않는다
|
||||
|
||||
Spring은 isolation setting이 새 트랜잭션에만 적용되고, 기존 트랜잭션 참여 시 local isolation은 기본적으로 무시된다고 설명한다.
|
||||
|
||||
### 예시 6. sequence 값이 rollback되리라 기대한다
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
INSERT INTO auth.users DEFAULT VALUES;
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- sequence/serial 계열 값은 다른 트랜잭션에 즉시 visible할 수 있고 rollback되지 않는다
|
||||
- gap 없는 연속 번호를 기대하면 안 된다
|
||||
- 이는 isolation 문제가 아니라 PostgreSQL sequence 동작 특성이다
|
||||
|
||||
PostgreSQL은 sequence 변경이 즉시 visible하고 transaction abort 시에도 rollback되지 않는다고 설명한다.
|
||||
@@ -0,0 +1,269 @@
|
||||
# JPA Fetch Strategy 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. to-one 기본값도 명시적으로 LAZY로 바꾼다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "sessions", schema = "auth")
|
||||
public class Session {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private OffsetDateTime expiresAt;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- JPA 기본값인 @ManyToOne EAGER에 기대지 않는다
|
||||
- 세션 조회에서 항상 사용자 전체를 강제로 가져오지 않는다
|
||||
- 필요한 경우 query 또는 entity graph에서만 user를 함께 가져오게 만들 수 있다
|
||||
|
||||
Jakarta Persistence는 @ManyToOne 기본 fetch가 EAGER라고 정의하고, Hibernate는 to-one EAGER 기본값을 그대로 쓰기보다 모든 association을 LAZY로 두는 편을 권장한다.
|
||||
|
||||
### 예시 2. 상세 조회의 to-one 연관은 join fetch로 가져온다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select s
|
||||
from Session s
|
||||
join fetch s.user u
|
||||
where s.id = :id
|
||||
""")
|
||||
Optional<Session> findDetailById(@Param("id") Long id);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 기본 매핑은 LAZY로 두고, 이 상세 조회에서만 user를 함께 가져온다
|
||||
- to-one 연관 fetch join은 Hibernate가 권장하는 대표 패턴이다
|
||||
- use case 단위로 eager 요구를 제어한다
|
||||
|
||||
Hibernate는 join fetch가 laziness를 override하여 같은 SQL join으로 데이터를 가져오게 하며, to-one association에 특히 적합하다고 설명한다.
|
||||
|
||||
### 예시 3. repository 메서드 단위 eager 요구는 EntityGraph로 선언한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@NamedEntityGraph(
|
||||
name = "User.withRoles",
|
||||
attributeNodes = {
|
||||
@NamedAttributeNode("roles")
|
||||
}
|
||||
)
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
// ...
|
||||
}
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
@EntityGraph(value = "User.withRoles", type = EntityGraph.EntityGraphType.FETCH)
|
||||
Optional<User> findByEmail(String email);
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 정적 매핑을 EAGER로 바꾸지 않고, 특정 repository 메서드에서만 roles를 가져온다
|
||||
- FETCH graph로 명시한 속성만 eager 범위에 넣는다
|
||||
- entity graph를 fetch plan template로 사용하는 정석적인 방식이다
|
||||
|
||||
Jakarta Persistence는 fetchgraph와 loadgraph를 공식적으로 지원하고, Spring Data JPA는 repository method에서 @EntityGraph와 동적 attributePaths()를 지원한다.
|
||||
|
||||
### 예시 4. 여러 부모의 같은 LAZY 컬렉션 접근에는 BatchSize를 보조적으로 사용한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 50)
|
||||
private List<UserRole> roles = new ArrayList<>();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 여러 사용자의 roles 컬렉션을 순차적으로 초기화할 때 round trip 수를 줄일 수 있다
|
||||
- join fetch가 항상 적절하지 않은 경우의 보조 최적화다
|
||||
- 기본 전략을 EAGER로 바꾸지 않는다
|
||||
|
||||
Hibernate는 @BatchSize가 여러 uninitialized association을 한 번에 가져오도록 도와주며, N+1보다 낫지만 대개 JOIN FETCH나 DTO projection이 더 좋다고 설명한다.
|
||||
|
||||
### 예시 5. 조회 전용 목록은 DTO projection으로 닫는다
|
||||
|
||||
```java
|
||||
public record UserSummary(Long id, String email, String status) {}
|
||||
|
||||
@Query("""
|
||||
select new com.example.auth.user.UserSummary(u.id, u.email, u.status)
|
||||
from User u
|
||||
where u.deletedAt is null
|
||||
order by u.id desc
|
||||
""")
|
||||
List<UserSummary> findActiveUserSummaries();
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 수정 목적이 아닌 조회에서 엔티티 그래프 전체를 관리하지 않는다
|
||||
- 필요한 컬럼만 선택한다
|
||||
- persistence context 부담을 줄이고 fetch 전략 고민도 단순화한다
|
||||
|
||||
Hibernate는 read-only transaction에는 DTO projection이 더 적합하고, 필요한 컬럼만 조회할 수 있어 persistence context 부담을 줄인다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. to-one 기본값을 그대로 두어 암묵적 EAGER를 만든다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "sessions", schema = "auth")
|
||||
public class Session {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- @ManyToOne 기본값은 EAGER라서, 의도하지 않아도 user를 항상 가져오게 된다
|
||||
- query별 fetch 정책을 세밀하게 제어하기 어려워진다
|
||||
- JPQL에서 fetch join을 빠뜨리면 secondary select로 이어질 수 있다
|
||||
|
||||
Jakarta Persistence는 @ManyToOne 기본 fetch를 EAGER로 정의하고, Hibernate는 이런 EAGER 기본값을 피하라고 권장한다.
|
||||
|
||||
### 예시 2. 여러 컬렉션을 한 query에서 동시에 fetch join한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u
|
||||
from User u
|
||||
left join fetch u.roles
|
||||
left join fetch u.sessions
|
||||
left join fetch u.loginHistories
|
||||
where u.id = :id
|
||||
""")
|
||||
Optional<User> findEverything(@Param("id") Long id);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 여러 to-many를 병렬 fetch join하면 Cartesian product가 발생할 수 있다
|
||||
- row 폭발과 중복으로 성능이 매우 나빠질 수 있다
|
||||
- “한 번에 다 가져오자”는 발상이 오히려 가장 위험하다
|
||||
|
||||
Hibernate는 여러 컬렉션/to-many를 병렬 fetch join하면 Cartesian product가 생겨 매우 나쁜 성능을 낼 수 있다고 명시한다.
|
||||
|
||||
### 예시 3. pagination query에 컬렉션 fetch join을 사용한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u
|
||||
from User u
|
||||
left join fetch u.roles
|
||||
order by u.id desc
|
||||
""")
|
||||
Page<User> findAllWithRoles(Pageable pageable);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 컬렉션 fetch join과 pagination은 충돌하기 쉽다
|
||||
- page boundary가 불안정해지고 row duplication 문제가 생길 수 있다
|
||||
- 목록/페이지 조회는 다른 방식으로 설계해야 한다
|
||||
|
||||
Hibernate는 fetch join을 제한/페이징 query에서 보통 피해야 한다고 설명한다.
|
||||
|
||||
### 예시 4. 지연 로딩 문제를 presentation 계층에서 우연히 해결되길 기대한다
|
||||
|
||||
```java
|
||||
@Transactional(readOnly = true)
|
||||
public User getUser(Long id) {
|
||||
return userRepository.findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
// controller
|
||||
@GetMapping("/users/{id}")
|
||||
public UserResponse getUser(@PathVariable Long id) {
|
||||
User user = userService.getUser(id);
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getRoles().stream().map(UserRole::getName).toList()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- service 트랜잭션이 끝난 뒤 controller에서 lazy 컬렉션 접근을 시도한다
|
||||
- 환경에 따라 LazyInitializationException 또는 우연한 추가 SQL에 의존하게 된다
|
||||
- fetch plan 책임이 service/application 경계 밖으로 새어 나간다
|
||||
|
||||
Hibernate는 필요한 연관은 persistence context가 닫히기 전에 fetch해야 하며, 그렇지 않으면 LazyInitializationException이 발생한다고 설명한다.
|
||||
|
||||
### 예시 5. read-only 목록인데도 엔티티 전체를 억지로 로딩한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u
|
||||
from User u
|
||||
left join fetch u.department
|
||||
left join fetch u.roles
|
||||
where u.deletedAt is null
|
||||
order by u.id desc
|
||||
""")
|
||||
List<User> findAllForAdminList();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 목록 화면에 필요한 필드보다 훨씬 많은 엔티티 상태를 가져오기 쉽다
|
||||
- 관리 대상 엔티티 수와 SQL row 수가 불필요하게 커진다
|
||||
- 이런 경우는 DTO projection이 더 적합할 가능성이 높다
|
||||
|
||||
Hibernate는 read-only transaction에서는 DTO projection이 더 적절하고, 필요한 컬럼만 선택할 수 있다고 설명한다.
|
||||
|
||||
### 예시 6. @BatchSize로 구조 문제를 덮는다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 1000)
|
||||
private List<UserRole> roles;
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 1000)
|
||||
private List<Session> sessions;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 왜 secondary select 구조가 필요한지 설명 없이 숫자만 크게 올린다
|
||||
- batch fetching은 보조 최적화이지 1차 설계 수단이 아니다
|
||||
- query 설계, DTO projection, entity graph 같은 더 직접적인 해법을 가릴 수 있다
|
||||
|
||||
Hibernate도 @BatchSize는 N+1보다 낫지만 대개 JOIN FETCH나 DTO projection이 더 좋은 선택이라고 설명한다.
|
||||
@@ -0,0 +1,232 @@
|
||||
# Lock 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 현재 row를 선점한 뒤 곧바로 상태를 바꾸는 작업은 NOWAIT로 fail-fast 한다
|
||||
|
||||
```sql
|
||||
SELECT id, status
|
||||
FROM billing.payments
|
||||
WHERE id = :paymentId
|
||||
FOR UPDATE NOWAIT;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 같은 결제를 동시에 하나만 처리해야 할 때 즉시 충돌을 드러낸다
|
||||
- 기다림보다 빠른 실패가 더 맞는 사용자 요청에 적합하다
|
||||
- 가장 강한 row lock이 필요한 상황을 명확하게 표현한다
|
||||
|
||||
PostgreSQL은 `NOWAIT`가 락을 즉시 못 잡으면 기다리지 않고 오류를 반환한다고 설명한다. `FOR UPDATE`는 해당 row에 대한 다른 수정과 row lock을 막는다.
|
||||
|
||||
### 예시 2. queue claim은 SKIP LOCKED를 작업 큐에만 제한해서 사용한다
|
||||
|
||||
```sql
|
||||
WITH picked AS (
|
||||
SELECT id
|
||||
FROM integration.outbox_events
|
||||
WHERE status = 'READY'
|
||||
ORDER BY id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 10
|
||||
)
|
||||
UPDATE integration.outbox_events e
|
||||
SET status = 'IN_PROGRESS',
|
||||
claimed_at = now()
|
||||
FROM picked
|
||||
WHERE e.id = picked.id
|
||||
RETURNING e.id;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 여러 worker가 같은 큐를 경쟁 소비할 때 이미 잠긴 row를 건너뛸 수 있다
|
||||
- queue-like workload에 맞는 전형적인 사용법이다
|
||||
- `ORDER BY`와 `LIMIT`를 함께 두어 claim 범위를 결정적으로 만든다
|
||||
|
||||
PostgreSQL은 `SKIP LOCKED`가 inconsistent view를 만들기 때문에 general-purpose work에는 부적합하지만 queue-like table에는 사용할 수 있다고 설명한다. 또한 locking clause와 `LIMIT`를 함께 쓸 수 있고, `ORDER BY` 없는 제한 조회는 예측 가능한 subset을 보장하지 않는다고 설명한다.
|
||||
|
||||
### 예시 3. join query에서는 OF로 실제 lock 대상만 잠근다
|
||||
|
||||
```sql
|
||||
SELECT o.id, o.status
|
||||
FROM ordering.orders o
|
||||
JOIN auth.users u ON u.id = o.user_id
|
||||
WHERE o.id = :orderId
|
||||
FOR UPDATE OF o NOWAIT;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 주문 row만 잠그고, join에 참여한 사용자 row까지 불필요하게 잠그지 않는다
|
||||
- join이 있다고 해서 전체 테이블 범위를 넓게 잠그지 않는다
|
||||
- lock 범위가 SQL만 봐도 명확하다
|
||||
|
||||
PostgreSQL은 locking clause에 table list를 지정하면 해당 테이블에서 나온 row만 잠그고, list를 생략하면 statement에 사용된 모든 테이블에 적용될 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. JPA에서는 특정 단건 처리 메서드에만 PESSIMISTIC_WRITE를 붙인다
|
||||
|
||||
```java
|
||||
public interface PaymentRepository extends JpaRepository<Payment, Long> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select p from Payment p where p.id = :id")
|
||||
Optional<Payment> findForUpdate(@Param("id") Long id);
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 일반 조회 메서드와 락 조회 메서드를 분리한다
|
||||
- lock intent가 repository 메서드에 명시적으로 드러난다
|
||||
- 단건 처리 유스케이스에서만 비관적 락을 올린다
|
||||
|
||||
Spring Data JPA는 query method에 `@Lock`으로 `LockModeType`을 지정할 수 있다고 설명한다. Jakarta Persistence는 `PESSIMISTIC_WRITE`가 즉시 장기 DB 락을 얻는 pessimistic lock mode라고 설명한다.
|
||||
|
||||
### 예시 5. non-key 상태 수정만 예정되어 있으면 SQL 레벨에서 FOR NO KEY UPDATE를 검토한다
|
||||
|
||||
```sql
|
||||
SELECT id, status
|
||||
FROM billing.payments
|
||||
WHERE id = :paymentId
|
||||
FOR NO KEY UPDATE;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 이후 status 같은 non-key 컬럼만 바꿀 예정이라면 `FOR UPDATE`보다 약한 락으로 충분할 수 있다
|
||||
- 필요 이상으로 강한 락을 쓰지 않는다
|
||||
- ordinary update semantics와 더 잘 맞는다
|
||||
|
||||
PostgreSQL은 `FOR NO KEY UPDATE`가 `FOR UPDATE`보다 약하고, key를 바꾸지 않는 일반 UPDATE가 이 수준의 락을 획득한다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 일반 목록 조회에 PESSIMISTIC_WRITE를 건다
|
||||
|
||||
```java
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
Page<User> findAll(Pageable pageable);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 단건 선점이 아니라 넓은 목록 조회에 비관적 락을 건다
|
||||
- 대기, contention, deadlock 가능성을 크게 올린다
|
||||
- locking use case와 plain listing use case가 섞여 있다
|
||||
|
||||
Spring Data JPA는 CRUD/query method에 `@Lock`을 적용할 수 있지만, PostgreSQL row lock은 같은 row에 대한 writer/locker를 막고 대기를 만들 수 있다. 이런 넓은 조회에 쓰면 비용이 과도하다.
|
||||
|
||||
### 예시 2. 일반 사용자 목록 API에 SKIP LOCKED를 사용한다
|
||||
|
||||
```sql
|
||||
SELECT id, email
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 일반 업무 조회에서 잠긴 row를 조용히 건너뛰면 결과 집합 의미가 깨진다
|
||||
- 운영자와 사용자 모두 "왜 어떤 데이터가 안 보였는지" 설명하기 어렵다
|
||||
- `SKIP LOCKED`는 queue-like workload가 아닌 경우 기본적으로 부적절하다
|
||||
|
||||
PostgreSQL은 `SKIP LOCKED`가 inconsistent view를 만들기 때문에 general-purpose work에는 적합하지 않다고 직접 경고한다.
|
||||
|
||||
### 예시 3. queue claim query에 OFFSET을 넣는다
|
||||
|
||||
```sql
|
||||
SELECT id
|
||||
FROM integration.outbox_events
|
||||
WHERE status = 'READY'
|
||||
ORDER BY id
|
||||
OFFSET 100
|
||||
LIMIT 10
|
||||
FOR UPDATE SKIP LOCKED;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- `OFFSET`으로 건너뛴 row도 잠길 수 있다
|
||||
- claim 범위와 실제 lock 범위가 어긋날 수 있다
|
||||
- queue 소비 문맥에서는 특히 예측이 어려워진다
|
||||
|
||||
PostgreSQL은 locking clause와 함께 `LIMIT`를 쓸 때 필요한 row까지만 잠그지만, `OFFSET`으로 건너뛴 row도 잠길 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. ordinary row 처리에 LOCK TABLE을 사용한다
|
||||
|
||||
```sql
|
||||
LOCK TABLE billing.payments IN ACCESS EXCLUSIVE MODE;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- plain SELECT까지 막을 수 있는 매우 강한 table-level lock이다
|
||||
- 특정 row 경쟁을 해결하려는 문제에 비해 범위가 과도하다
|
||||
- 일반 비즈니스 처리에 쓰기에는 영향 범위가 너무 크다
|
||||
|
||||
PostgreSQL은 `ACCESS EXCLUSIVE`가 모든 lock mode와 충돌하고, plain SELECT를 막는 유일한 lock mode라고 설명한다.
|
||||
|
||||
### 예시 5. 락을 잡은 뒤 외부 호출을 오래 수행한다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void approve(Long paymentId) {
|
||||
Payment payment = paymentRepository.findForUpdate(paymentId)
|
||||
.orElseThrow();
|
||||
|
||||
externalGatewayClient.call(payment); // 오래 걸리는 네트워크 호출
|
||||
|
||||
payment.approve();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- row lock을 쥔 채 외부 대기 시간을 모두 끌고 간다
|
||||
- 다른 트랜잭션이 같은 row를 오래 기다리게 만든다
|
||||
- deadlock과 lock wait 문제를 악화시킨다
|
||||
|
||||
PostgreSQL은 lock 요청이 deadlock이 아니면 오래 기다릴 수 있고, 애플리케이션이 긴 시간 트랜잭션을 열어 두는 것은 나쁜 생각이라고 설명한다. idle in transaction도 오래 유지되면 문제가 된다고 설명한다.
|
||||
|
||||
### 예시 6. join query에서 lock 대상을 좁히지 않는다
|
||||
|
||||
```sql
|
||||
SELECT o.id, u.id
|
||||
FROM ordering.orders o
|
||||
JOIN auth.users u ON u.id = o.user_id
|
||||
WHERE o.id = :orderId
|
||||
FOR UPDATE;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제로는 order row만 선점하면 되는데 join 참여 row까지 넓게 잠글 수 있다
|
||||
- lock 범위가 과도하고, 예상보다 큰 contention을 만들 수 있다
|
||||
- 이런 query는 `FOR UPDATE OF o` 같은 형태로 범위를 좁히는 편이 안전하다
|
||||
|
||||
PostgreSQL은 locking clause에서 table list를 생략하면 statement에 사용된 모든 테이블에 영향을 줄 수 있다고 설명한다.
|
||||
|
||||
### 예시 7. PessimisticLockException과 LockTimeoutException을 같은 것으로 처리한다
|
||||
|
||||
```java
|
||||
try {
|
||||
repository.findForUpdate(id);
|
||||
} catch (PersistenceException e) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- transaction-level rollback이 난 경우와 statement-level timeout만 난 경우를 구분하지 못한다
|
||||
- 후속 처리와 재시도 정책이 흐려진다
|
||||
- 잠금 실패 의미를 application boundary에서 잃어버린다
|
||||
|
||||
Jakarta Persistence는 pessimistic lock failure가 transaction rollback이면 `PessimisticLockException`, statement rollback이면 `LockTimeoutException`이라고 구분한다. 두 예외는 현재 트랜잭션 상태도 다를 수 있다.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Migration 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 새 nullable 컬럼 추가 → backfill → default 설정으로 나눈다
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN status text;
|
||||
|
||||
-- 배치/스크립트로 backfill 수행
|
||||
-- UPDATE auth.users SET status = 'ACTIVE' WHERE status IS NULL;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ALTER COLUMN status SET DEFAULT 'ACTIVE';
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 컬럼 추가를 빠른 additive change로 분리했다
|
||||
- 과거 데이터 보정과 future default를 분리했다
|
||||
- `SET DEFAULT`가 기존 row를 바꾸지 않는다는 점을 전제로 설계했다
|
||||
|
||||
PostgreSQL은 컬럼 추가가 가능하고, default 변경은 이후 insert/update에만 영향을 주며 기존 row를 바꾸지 않는다고 설명한다.
|
||||
|
||||
### 예시 2. 큰 테이블의 CHECK/FK는 NOT VALID 후 나중에 검증한다
|
||||
|
||||
```sql
|
||||
ALTER TABLE ordering.orders
|
||||
ADD CONSTRAINT ck_orders__amount_positive
|
||||
CHECK (amount > 0) NOT VALID;
|
||||
|
||||
ALTER TABLE ordering.orders
|
||||
VALIDATE CONSTRAINT ck_orders__amount_positive;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 제약 추가 시점의 긴 full scan 영향을 줄인다
|
||||
- 새로 들어오는/갱신되는 row에는 바로 제약이 적용된다
|
||||
- 기존 데이터 검증은 별도 단계로 분리한다
|
||||
|
||||
PostgreSQL은 `NOT VALID` 제약이 기존 row scan을 건너뛰고, 이후 `VALIDATE CONSTRAINT`로 검증할 수 있으며 validation은 `SHARE UPDATE EXCLUSIVE` lock으로 수행된다고 설명한다.
|
||||
|
||||
### 예시 3. NOT NULL은 backfill과 증명 단계를 거쳐 올린다
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.users
|
||||
ADD CONSTRAINT ck_users__status_not_null
|
||||
CHECK (status IS NOT NULL) NOT VALID;
|
||||
|
||||
-- backfill 수행
|
||||
-- UPDATE auth.users SET status = 'ACTIVE' WHERE status IS NULL;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
VALIDATE CONSTRAINT ck_users__status_not_null;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ALTER COLUMN status SET NOT NULL;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
DROP CONSTRAINT ck_users__status_not_null;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- null 방지 강화를 한 번에 몰아넣지 않았다
|
||||
- PostgreSQL이 valid CHECK로 null 불가능함을 증명하면 `SET NOT NULL` scan을 건너뛸 수 있는 점과 잘 맞는다
|
||||
- 운영 중 영향도를 줄이기 좋은 패턴이다
|
||||
|
||||
PostgreSQL은 `SET NOT NULL`이 보통 table scan을 하지만, valid CHECK가 null 불가능함을 증명하면 scan을 생략할 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. 운영 인덱스는 CONCURRENTLY로 만들고, 필요하면 제약으로 승격한다
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX CONCURRENTLY uq_users__email_idx
|
||||
ON auth.users (email);
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ADD CONSTRAINT uq_users__email
|
||||
UNIQUE USING INDEX uq_users__email_idx;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 인덱스 build 중 write block을 줄인다
|
||||
- 기존 인덱스를 활용해 빠르게 UNIQUE 제약으로 승격한다
|
||||
- 큰 테이블의 unique 추가에 적합한 공식 경로다
|
||||
|
||||
PostgreSQL은 `CREATE INDEX CONCURRENTLY`가 production environment에 유용하고, 기존 unique index를 UNIQUE/PRIMARY KEY 제약으로 전환할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. PostgreSQL 11+에서는 상수 default 컬럼 추가를 안전한 후보로 본다
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.sessions
|
||||
ADD COLUMN source text DEFAULT 'LOCAL';
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 상수 default라면 PostgreSQL 11+에서 빠른 metadata 기반 처리 경로에 들어갈 수 있다
|
||||
- 운영 중 큰 rewrite를 피할 가능성이 높다
|
||||
- 상수 default라는 점이 분명하다
|
||||
|
||||
PostgreSQL 11 release notes와 현재 ALTER TABLE 문서는 non-volatile/default constant 컬럼 추가가 table rewrite를 피할 수 있다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 큰 테이블에 volatile default를 바로 추가한다
|
||||
|
||||
```sql
|
||||
ALTER TABLE audit.audit_logs
|
||||
ADD COLUMN created_bucket timestamp with time zone DEFAULT clock_timestamp();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- `clock_timestamp()`는 volatile default다
|
||||
- PostgreSQL은 이런 경우 전체 테이블과 인덱스 rewrite가 일어날 수 있다고 설명한다
|
||||
- 운영 테이블에서는 매우 위험할 수 있다
|
||||
|
||||
공식 문서는 volatile default 컬럼 추가가 rewrite를 유발한다고 설명한다.
|
||||
|
||||
### 예시 2. 운영 인덱스를 일반 CREATE INDEX로 바로 만든다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_orders__created_at
|
||||
ON ordering.orders (created_at);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- write를 막을 수 있는 일반 index build를 사용한다
|
||||
- 운영 중 대형 테이블에는 영향도가 과도할 수 있다
|
||||
- 이런 경우는 `CREATE INDEX CONCURRENTLY`를 먼저 검토해야 한다
|
||||
|
||||
PostgreSQL은 `CREATE INDEX CONCURRENTLY`가 write를 막지 않고 production environment에 유용하다고 설명한다. 그 반대 의미로, 일반 build는 운영 중 더 보수적으로 다뤄야 한다.
|
||||
|
||||
### 예시 3. CREATE INDEX CONCURRENTLY를 트랜잭션 블록 안에 넣는다
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
|
||||
CREATE INDEX CONCURRENTLY ix_users__email
|
||||
ON auth.users (email);
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL은 `CREATE INDEX CONCURRENTLY`가 transaction block 안에서 실행될 수 없다고 명시한다
|
||||
- migration 도구가 이 구분을 지원하지 않으면 배포 시 실패한다
|
||||
- non-transactional step으로 분리해야 한다
|
||||
|
||||
공식 문서는 regular `CREATE INDEX`는 transaction block 안에서 가능하지만, `CREATE INDEX CONCURRENTLY`는 불가능하다고 설명한다.
|
||||
|
||||
### 예시 4. backfill 없이 바로 NOT NULL을 건다
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN status text;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ALTER COLUMN status SET NOT NULL;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기존 row가 null일 가능성을 무시한다
|
||||
- `SET NOT NULL`은 보통 전체 테이블 scan을 요구한다
|
||||
- 데이터 보정 없이 곧바로 강한 제약을 올리는 구조다
|
||||
|
||||
PostgreSQL은 `SET NOT NULL`이 기존 row에 null이 없어야 하고, 보통 이를 확인하기 위해 전체 테이블을 스캔한다고 설명한다.
|
||||
|
||||
### 예시 5. 타입 변경을 무심코 직접 수행한다
|
||||
|
||||
```sql
|
||||
ALTER TABLE billing.payments
|
||||
ALTER COLUMN amount TYPE numeric(19,4);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 타입 변경은 기본적으로 rewrite/rebuild 후보다
|
||||
- 큰 테이블에서는 시간과 디스크 사용량이 매우 커질 수 있다
|
||||
- 더 안전한 staged migration이 필요한지 먼저 검토해야 한다
|
||||
|
||||
PostgreSQL은 타입 변경이 보통 테이블과 인덱스를 rewrite/rebuild하고, 상당한 시간과 최대 거의 두 배 디스크를 일시적으로 요구할 수 있다고 설명한다.
|
||||
|
||||
### 예시 6. 앱 호환성 검증 전에 rename/drop부터 수행한다
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.users
|
||||
RENAME COLUMN email TO login_id;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
DROP COLUMN username;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기술적으로는 가능한 DDL이지만, 애플리케이션/쿼리/배치/운영 스크립트와의 호환성을 즉시 깨뜨릴 수 있다
|
||||
- backward-compatible rollout 경로가 없다
|
||||
- 이런 변경은 마지막 cleanup 단계에서만 허용하는 것이 안전하다
|
||||
|
||||
PostgreSQL은 rename/drop을 지원하지만, 프로젝트 운영 기준에서는 destructive/비호환 변경으로 본다. 특히 `DROP COLUMN`은 빠르지만 디스크 공간도 즉시 줄지 않는다.
|
||||
|
||||
### 예시 7. failed concurrent build 뒤 INVALID 인덱스를 방치한다
|
||||
|
||||
```sql
|
||||
-- 실패한 CREATE INDEX CONCURRENTLY 이후
|
||||
-- 아무 조치 없이 배포 종료
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL은 실패한 concurrent build가 INVALID 인덱스를 남길 수 있다고 설명한다
|
||||
- 이 인덱스는 query에는 안 쓰여도 update overhead는 계속 발생한다
|
||||
- drop 후 재시도나 `REINDEX INDEX CONCURRENTLY` 같은 정리 절차가 필요하다
|
||||
|
||||
공식 문서는 invalid index가 남을 수 있고, 권장 복구 방법은 drop 후 다시 시도하거나 concurrent reindex라고 설명한다.
|
||||
@@ -0,0 +1,267 @@
|
||||
# N+1 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 상세 조회의 to-one 연관은 join fetch로 한 번에 가져온다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select s
|
||||
from Session s
|
||||
join fetch s.user
|
||||
where s.id = :id
|
||||
""")
|
||||
Optional<Session> findDetailById(@Param("id") Long id);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Session 상세 조회에서 필요한 user를 같은 query로 가져온다
|
||||
- to-one 연관에 대한 N+1 위험을 가장 직접적으로 제거한다
|
||||
- 기본 매핑은 LAZY로 유지하면서, 이 use case에서만 eager 요구를 적용한다
|
||||
|
||||
Hibernate는 JOIN FETCH가 @ManyToOne / @OneToOne에 좋고, 필요한 association은 persistence context가 닫히기 전에 fetch하는 것이 가장 좋은 대응이라고 설명한다.
|
||||
|
||||
### 예시 2. query 단위 fetch plan은 EntityGraph로 선언한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@NamedEntityGraph(
|
||||
name = "User.withRoles",
|
||||
attributeNodes = @NamedAttributeNode("roles")
|
||||
)
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
// ...
|
||||
}
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
@EntityGraph(value = "User.withRoles", type = EntityGraph.EntityGraphType.FETCH)
|
||||
Optional<User> findByEmail(String email);
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 정적 매핑을 EAGER로 바꾸지 않고, 특정 repository method에서만 roles를 함께 가져온다
|
||||
- fetch plan이 use case 단위로 분리된다
|
||||
- named graph를 재사용할 수 있다
|
||||
|
||||
Jakarta Persistence는 fetchgraph와 loadgraph를 표준으로 정의하고, Spring Data JPA는 repository method에 @EntityGraph를 붙여 fetch/load graph를 적용할 수 있다고 설명한다.
|
||||
|
||||
### 예시 3. read-only 목록은 DTO projection으로 닫는다
|
||||
|
||||
```java
|
||||
public record UserSummary(Long id, String email, String status) {}
|
||||
|
||||
@Query("""
|
||||
select new com.example.auth.user.UserSummary(u.id, u.email, u.status)
|
||||
from User u
|
||||
where u.deletedAt is null
|
||||
order by u.id desc
|
||||
""")
|
||||
List<UserSummary> findActiveUserSummaries();
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 목록 조회에서 엔티티와 연관 그래프 전체를 관리하지 않는다
|
||||
- 필요한 컬럼만 가져오므로 N+1 설계 자체를 피한다
|
||||
- 가장 단순하고 예측 가능한 목록 조회 구조다
|
||||
|
||||
Hibernate는 @BatchSize보다 DTO projection이나 JOIN FETCH가 더 좋은 대안인 경우가 많다고 설명한다. 특히 read-only 목록에서는 DTO projection이 더 신뢰도 높은 선택이다.
|
||||
|
||||
### 예시 4. 여러 부모의 같은 컬렉션을 뒤이어 접근한다면 BatchSize를 보조적으로 사용한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 50)
|
||||
private List<UserRole> roles = new ArrayList<>();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 여러 User의 roles 컬렉션을 순차적으로 초기화할 때 secondary select를 묶어 줄 수 있다
|
||||
- N+1을 완화하지만, 기본 fetch를 EAGER로 바꾸지 않는다
|
||||
- 보조 최적화로서 의미가 분명하다
|
||||
|
||||
Hibernate는 @BatchSize가 여러 연관을 single database round trip 또는 적은 수의 round trip으로 묶을 수 있다고 설명한다. 다만 DTO projection이나 JOIN FETCH가 더 좋은 대안인 경우가 많다고도 함께 설명한다.
|
||||
|
||||
### 예시 5. 페이징 목록은 ID 페이지 조회와 후속 query를 분리한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u.id
|
||||
from User u
|
||||
where u.deletedAt is null
|
||||
order by u.createdAt desc, u.id desc
|
||||
""")
|
||||
Page<Long> findUserIds(Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
select distinct u
|
||||
from User u
|
||||
left join fetch u.department
|
||||
where u.id in :ids
|
||||
""")
|
||||
List<User> findUsersWithDepartment(@Param("ids") Collection<Long> ids);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- page boundary는 root ID query에서 안정적으로 결정한다
|
||||
- 후속 query는 page 범위 안에서만 필요한 연관을 가져온다
|
||||
- 컬렉션 fetch join + pagination 충돌을 피하면서 N+1도 막는다
|
||||
|
||||
Hibernate는 paged query에서 fetch join, 특히 many-valued association에 대한 fetch join을 피하라고 설명한다. 이 분리 전략은 해당 제약을 피해 가는 실무 best practice다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. N+1을 막겠다며 to-one을 EAGER로 선언한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "sessions", schema = "auth")
|
||||
public class Session {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 이 설정은 모든 query에서 user 로딩 비용을 고정한다
|
||||
- JPQL query에서 fetch join을 빠뜨리면 오히려 secondary select가 association마다 발생해 N+1로 이어질 수 있다
|
||||
- 문제를 매핑에 숨기고 use case 단위 제어를 잃는다
|
||||
|
||||
Hibernate는 EAGER fetching is almost always a bad choice라고 설명하고, EAGER association을 query에서 join fetch하지 않으면 secondary select로 N+1이 생길 수 있다고 명시한다.
|
||||
|
||||
### 예시 2. 루트 목록을 가져온 뒤 컬렉션을 루프에서 접근한다
|
||||
|
||||
```java
|
||||
List<User> users = entityManager.createQuery("""
|
||||
select u
|
||||
from User u
|
||||
where u.deletedAt is null
|
||||
order by u.id desc
|
||||
""", User.class).getResultList();
|
||||
|
||||
for (User user : users) {
|
||||
user.getRoles().size();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- root query 1번 뒤에, user 수만큼 roles 초기화 SQL이 반복될 수 있다
|
||||
- 가장 전형적인 N+1 구조다
|
||||
- 목록 조회라면 DTO projection, join fetch, batch fetching 등으로 구조를 바꿔야 한다
|
||||
|
||||
Hibernate는 collection을 first access 시 secondary select로 초기화하는 FetchMode.SELECT가 N+1을 일으킬 수 있다고 설명한다.
|
||||
|
||||
### 예시 3. 여러 컬렉션을 한 query에서 병렬 fetch join한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u
|
||||
from User u
|
||||
left join fetch u.roles
|
||||
left join fetch u.sessions
|
||||
left join fetch u.loginHistories
|
||||
where u.id = :id
|
||||
""")
|
||||
Optional<User> findEverything(@Param("id") Long id);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 여러 to-many/collection을 동시에 fetch join하면 Cartesian product가 발생할 수 있다
|
||||
- 중복 row가 폭증하고 성능이 매우 나빠질 수 있다
|
||||
- “한 번에 다 가져오기”가 오히려 더 위험하다
|
||||
|
||||
Hibernate는 multiple collections or to-many associations in parallel fetch join results in a Cartesian product and might exhibit very poor performance라고 명시한다.
|
||||
|
||||
### 예시 4. pagination query에 컬렉션 fetch join을 사용한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u
|
||||
from User u
|
||||
left join fetch u.roles
|
||||
order by u.id desc
|
||||
""")
|
||||
Page<User> findAllWithRoles(Pageable pageable);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- many-valued association fetch join과 pagination이 충돌한다
|
||||
- limit가 DB가 아니라 메모리에서 적용될 수 있다
|
||||
- page boundary와 성능이 모두 불안정해진다
|
||||
|
||||
Hibernate는 fetch joins should usually be avoided in limited or paged queries라고 설명하고, 컬렉션 fetch join과 pagination 조합은 terrible performance characteristics를 만들 수 있다고 경고한다.
|
||||
|
||||
### 예시 5. 트랜잭션 밖에서 lazy 연관이 알아서 초기화되기를 기대한다
|
||||
|
||||
```java
|
||||
@Transactional(readOnly = true)
|
||||
public User getUser(Long id) {
|
||||
return userRepository.findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
@GetMapping("/users/{id}")
|
||||
public UserResponse getUser(@PathVariable Long id) {
|
||||
User user = userService.getUser(id);
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getRoles().stream().map(UserRole::getName).toList()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- service 트랜잭션 종료 후 controller에서 lazy collection 접근을 시도한다
|
||||
- 환경에 따라 LazyInitializationException이 발생하거나, 우연한 추가 query에 의존할 수 있다
|
||||
- fetch plan 책임이 presentation 계층으로 새어 나간다
|
||||
|
||||
Hibernate는 필요한 association은 persistence context가 닫히기 전에 fetch하는 것이 가장 좋은 대응이라고 설명한다.
|
||||
|
||||
### 예시 6. BatchSize만 크게 올려 구조 문제를 덮는다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 1000)
|
||||
private List<UserRole> roles;
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 1000)
|
||||
private List<Session> sessions;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 왜 secondary select 구조를 유지하는지 설명 없이 숫자만 키운다
|
||||
- batch fetching은 보조 완화 수단이지 1차 설계 수단이 아니다
|
||||
- DTO projection, join fetch, query 분리 같은 더 직접적인 해결책을 가릴 수 있다
|
||||
|
||||
Hibernate도 @BatchSize는 N+1보다 낫지만, 대부분의 경우 DTO projection이나 JOIN FETCH가 더 좋은 대안이라고 설명한다.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Pagination Query 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 얕은 관리자 목록은 결정적 ORDER BY + LIMIT/OFFSET으로 처리한다
|
||||
|
||||
```sql
|
||||
SELECT id, email, created_at
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- `LIMIT/OFFSET`을 쓰면서 결정적 `ORDER BY`를 함께 둔다
|
||||
- `created_at` 동률을 `id`로 해소한다
|
||||
- active-row predicate가 soft delete 계약과 맞는다
|
||||
|
||||
PostgreSQL은 `LIMIT/OFFSET`에 `ORDER BY`가 없으면 예측 불가능한 subset을 얻게 된다고 설명하고, 큰 `OFFSET`은 비효율적일 수 있다고 설명한다.
|
||||
|
||||
### 예시 2. 무한 스크롤은 keyset/cursor 방식으로 설계한다
|
||||
|
||||
```sql
|
||||
SELECT id, created_at, title
|
||||
FROM board.posts
|
||||
WHERE deleted_at IS NULL
|
||||
AND (created_at, id) < (:lastCreatedAt, :lastId)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 깊은 페이지에서도 큰 `OFFSET`을 피할 수 있다
|
||||
- 정렬 기준과 seek 조건이 같은 의미를 가진다
|
||||
- `(created_at, id)`가 tie-breaker까지 포함한 cursor 역할을 한다
|
||||
|
||||
PostgreSQL은 row constructor comparison의 `<`, `>`가 좌→우 비교로 동작한다고 설명하고, `ORDER BY ... LIMIT n`에서 B-tree ordered scan이 유리할 수 있다고 설명한다. 이 예시는 그 두 기능을 pagination에 적용한 전형적 패턴이다.
|
||||
|
||||
### 예시 3. 필터와 정렬이 함께 있는 페이지 query는 인덱스와 같이 설계한다
|
||||
|
||||
```sql
|
||||
SELECT id, tenant_id, status, created_at
|
||||
FROM ordering.orders
|
||||
WHERE tenant_id = :tenantId
|
||||
AND status = 'READY'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit;
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_orders__tenant_status_created_at_id
|
||||
ON ordering.orders (tenant_id, status, created_at DESC, id DESC);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- leading equality filter 뒤에 정렬 키를 배치한다
|
||||
- pagination query와 인덱스가 같은 access pattern을 공유한다
|
||||
- 적은 수의 앞 row를 직접 가져오기에 유리하다
|
||||
|
||||
PostgreSQL은 multicolumn B-tree가 leading equality와 그 다음 inequality/정렬 문맥에서 가장 효율적이라고 설명하고, `ORDER BY ... LIMIT n`에서 정렬을 만족하는 인덱스가 특히 유용하다고 설명한다.
|
||||
|
||||
### 예시 4. total count가 꼭 필요할 때만 별도 query로 분리한다
|
||||
|
||||
```sql
|
||||
SELECT id, email, created_at
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT count(*)
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 페이지 조회와 총건수 계산을 명시적으로 분리한다
|
||||
- 목록 일부 조회와 전체 집계의 비용을 섞지 않는다
|
||||
- count가 필요 없는 API에서는 두 번째 query를 생략할 수 있다
|
||||
|
||||
PostgreSQL은 `count(*)`가 입력 row 수를 계산하는 aggregate라고 설명하고, planner statistics인 `reltuples`는 근사치라고 설명한다.
|
||||
|
||||
### 예시 5. 다음 페이지 존재 여부만 필요하면 한 건 더 가져온다
|
||||
|
||||
```sql
|
||||
SELECT id, created_at, title
|
||||
FROM board.posts
|
||||
WHERE deleted_at IS NULL
|
||||
AND (created_at, id) < (:lastCreatedAt, :lastId)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limitPlusOne;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- exact total count 없이도 next page 존재 여부를 계산할 수 있다
|
||||
- 큰 목록에서 count 비용을 매번 강제하지 않는다
|
||||
- keyset/cursor 방식과 잘 맞는다
|
||||
|
||||
PostgreSQL 공식 문서는 `LIMIT`이 결과 일부를 가져오는 기본 도구임을 설명하고, 큰 `OFFSET`이 비효율적일 수 있음을 설명한다. 이 예시는 total count를 피하는 실무 패턴이다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. LIMIT/OFFSET을 정렬 없이 사용한다
|
||||
|
||||
```sql
|
||||
SELECT id, email
|
||||
FROM auth.users
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 어떤 row 집합을 잘라 오는지 정의되지 않는다
|
||||
- `LIMIT/OFFSET` 값에 따라 plan과 결과 subset이 달라질 수 있다
|
||||
- 페이지 계약이 성립하지 않는다
|
||||
|
||||
PostgreSQL은 `ORDER BY` 없이 `LIMIT`을 쓰면 예측 불가능한 subset을 얻게 된다고 명시한다.
|
||||
|
||||
### 예시 2. tie-breaker 없는 정렬로 페이지를 자른다
|
||||
|
||||
```sql
|
||||
SELECT id, created_at, title
|
||||
FROM board.posts
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 `created_at`을 가진 row의 상대 순서가 API 계약상 명시되지 않는다
|
||||
- 페이지 경계가 흔들리거나 중복/누락처럼 보일 수 있다
|
||||
- `id` 같은 유니크 tie-breaker가 필요하다
|
||||
|
||||
PostgreSQL은 `LIMIT`과 함께 사용할 때 `ORDER BY`가 결과를 unique order로 제약하는 것이 중요하다고 설명한다.
|
||||
|
||||
### 예시 3. 깊은 페이지를 큰 OFFSET으로 계속 읽는다
|
||||
|
||||
```sql
|
||||
SELECT id, created_at, title
|
||||
FROM board.posts
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 20
|
||||
OFFSET 200000;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 건너뛴 200,000 row도 서버 내부에서 계산해야 한다
|
||||
- 깊은 페이지로 갈수록 비용이 커질 수 있다
|
||||
- 이런 요구는 keyset/cursor 방식이 더 적합하다
|
||||
|
||||
PostgreSQL은 `OFFSET`으로 건너뛴 row도 내부에서 계산되어야 하므로 큰 `OFFSET`이 비효율적일 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. keyset인데 seek 조건과 정렬이 맞지 않는다
|
||||
|
||||
```sql
|
||||
SELECT id, created_at, title
|
||||
FROM board.posts
|
||||
WHERE id < :lastId
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- `ORDER BY`는 `(created_at, id)` 의미인데 seek 조건은 `id`만 본다
|
||||
- 페이지 경계가 정렬 의미와 어긋난다
|
||||
- keyset/cursor는 정렬 기준과 동일한 키 의미를 써야 한다
|
||||
|
||||
PostgreSQL의 row comparison은 여러 정렬 키를 좌→우로 비교할 수 있으므로, 이런 경우 `(created_at, id)` 형태가 더 자연스럽다.
|
||||
|
||||
### 예시 5. soft delete 테이블인데 active-row predicate 없이 페이지를 자른다
|
||||
|
||||
```sql
|
||||
SELECT id, email, created_at
|
||||
FROM auth.users
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 삭제 row가 일반 목록에 섞일 수 있다
|
||||
- active-row partial index와도 잘 맞지 않는다
|
||||
- soft delete 기본 조회 계약을 깨뜨린다
|
||||
|
||||
PostgreSQL partial index는 query의 WHERE가 predicate를 함의할 때 가장 자연스럽게 사용되므로, soft delete 테이블은 active-row predicate를 일관되게 포함해야 한다.
|
||||
|
||||
### 예시 6. 페이지 query와 exact count를 항상 묶는다
|
||||
|
||||
```sql
|
||||
SELECT id, email, created_at
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
|
||||
SELECT count(*)
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 총건수가 항상 필요한지 검토하지 않는다
|
||||
- 목록 일부 조회와 전체 집계를 무조건 함께 수행한다
|
||||
- 큰 목록에서는 불필요한 비용이 될 수 있다
|
||||
|
||||
PostgreSQL은 `count(*)`가 입력 row 수를 계산하는 aggregate이고, planner statistics는 근사치라고 설명한다. exact total count는 별도 비용을 가진다고 보고 설계해야 한다.
|
||||
@@ -0,0 +1,261 @@
|
||||
# PK / FK / UNIQUE / CHECK 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 대표 식별자는 PK, 비즈니스 고유성은 UNIQUE로 분리한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
email text NOT NULL,
|
||||
username text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id),
|
||||
CONSTRAINT uq_users__email UNIQUE (email),
|
||||
CONSTRAINT uq_users__username UNIQUE (username)
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대표 식별자 id를 PK로 둔다
|
||||
- 이메일과 username은 business unique로 분리한다
|
||||
- identity에만 기대지 않고 PK가 유일성을 보장한다
|
||||
|
||||
PostgreSQL은 PK가 대표 식별자이며 unique B-tree 인덱스를 자동 생성한다고 설명하고, identity는 자동 생성일 뿐 uniqueness를 보장하지 않는다고 명시한다.
|
||||
|
||||
### 예시 2. 필수 관계는 FK + NOT NULL, 삭제 정책은 관계 의미에 맞춘다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE auth.sessions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
user_id bigint NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_sessions PRIMARY KEY (id),
|
||||
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
|
||||
REFERENCES auth.users (id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX ix_sessions__user_id ON auth.sessions (user_id);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 세션은 사용자에 종속된 구성요소이므로 ON DELETE CASCADE가 자연스럽다
|
||||
- 필수 관계를 NOT NULL로 닫았다
|
||||
- FK 컬럼 인덱스를 별도로 생성했다
|
||||
|
||||
PostgreSQL 공식 문서도 구성요소 관계에는 CASCADE가 적절할 수 있고, FK는 참조하는 쪽 인덱스를 자동 생성하지 않는다고 설명한다.
|
||||
|
||||
### 예시 3. soft delete 환경의 조건부 고유성은 partial unique index로 표현한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
tenant_id bigint NOT NULL,
|
||||
email text NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_users__tenant_email_active
|
||||
ON auth.users (tenant_id, email)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “삭제되지 않은 사용자만 tenant 내 email 유일”이라는 조건부 고유성을 정확히 표현한다
|
||||
- 일반 UNIQUE constraint로는 일부 행에만 적용되는 uniqueness를 표현할 수 없다
|
||||
|
||||
PostgreSQL 공식 문서는 일부 행에만 적용되는 uniqueness restriction은 unique constraint가 아니라 unique partial index로 표현해야 한다고 설명한다.
|
||||
|
||||
### 예시 4. CHECK는 같은 행 안의 불변식에만 사용한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE billing.payments (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
amount numeric(19,4) NOT NULL,
|
||||
refunded_amount numeric(19,4) NOT NULL DEFAULT 0,
|
||||
status text NOT NULL,
|
||||
CONSTRAINT pk_payments PRIMARY KEY (id),
|
||||
CONSTRAINT ck_payments__amount_positive CHECK (amount > 0),
|
||||
CONSTRAINT ck_payments__refunded_amount_range
|
||||
CHECK (refunded_amount >= 0 AND refunded_amount <= amount),
|
||||
CONSTRAINT ck_payments__status
|
||||
CHECK (status IN ('PENDING', 'PAID', 'CANCELLED', 'REFUNDED'))
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 모두 같은 행 안에서 평가 가능한 규칙이다
|
||||
- null 금지는 NOT NULL로, 값 범위는 CHECK로 역할을 분리했다
|
||||
- CHECK에 cross-table 의존이 없다
|
||||
|
||||
PostgreSQL은 CHECK가 row-local invariant에 적합하고, null 금지는 NOT NULL로 표현하는 것이 맞다고 설명한다.
|
||||
|
||||
### 예시 5. 조인 테이블은 복합 PK를 예외적으로 사용할 수 있다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE auth.roles (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
CONSTRAINT pk_roles PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE auth.user_roles (
|
||||
user_id bigint NOT NULL,
|
||||
role_id bigint NOT NULL,
|
||||
granted_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_user_roles PRIMARY KEY (user_id, role_id),
|
||||
CONSTRAINT fk_user_roles__users FOREIGN KEY (user_id)
|
||||
REFERENCES auth.users (id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_roles__roles FOREIGN KEY (role_id)
|
||||
REFERENCES auth.roles (id)
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 조인 테이블에서는 (user_id, role_id) 조합 자체가 자연스러운 정체성이다
|
||||
- 복합 PK가 FK와 겹쳐도 의미가 분명하다
|
||||
- 삭제 정책도 관계 의미에 따라 다르게 선택했다
|
||||
|
||||
PostgreSQL은 복합 PK와 FK를 모두 지원하며, 문서 예시에서도 many-to-many 구조에서 이런 형태를 보여준다. 다만 JPA에서는 복합 PK가 별도 키 클래스를 요구하므로 예외적으로 사용하는 편이 안전하다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. PK 없이 UNIQUE만으로 테이블을 운영한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
email text NOT NULL UNIQUE,
|
||||
username text NOT NULL UNIQUE
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 대표 식별자가 없다
|
||||
- FK 기본 참조 대상과 ORM 식별 의미가 불명확하다
|
||||
- “고유한 컬럼 몇 개”와 “대표 PK”의 역할이 섞인다
|
||||
|
||||
PostgreSQL도 모든 테이블에 PK를 강제하지는 않지만, 일반적으로 두는 것이 가장 좋다고 설명한다.
|
||||
|
||||
### 예시 2. nullable UNIQUE를 두고 NULL도 하나만 허용된다고 착각한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
external_subject text UNIQUE
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL 기본 동작에서는 NULL이 서로 다른 값으로 취급된다
|
||||
- 따라서 external_subject가 NULL인 행이 여러 개 들어갈 수 있다
|
||||
- “값이 없으면 하나만 허용” 의미라면 현재 설계는 틀렸다
|
||||
|
||||
이 경우는 NOT NULL, UNIQUE NULLS NOT DISTINCT, 또는 모델 재설계가 필요하다.
|
||||
|
||||
### 예시 3. 필수 관계인데 nullable FK로 열어 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE auth.sessions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id bigint,
|
||||
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
|
||||
REFERENCES auth.users (id)
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 세션이 반드시 사용자에 속해야 한다면 user_id는 NOT NULL이어야 한다
|
||||
- FK만으로는 NULL을 막지 못한다
|
||||
- 필수 관계를 스키마가 보장하지 못한다
|
||||
|
||||
PostgreSQL은 참조 컬럼에 null이 있으면 FK를 회피할 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. 독립 객체 관계에 무분별하게 CASCADE를 건다
|
||||
|
||||
```sql
|
||||
CREATE TABLE catalog.products (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE ordering.order_items (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
product_id bigint NOT NULL,
|
||||
CONSTRAINT fk_order_items__products FOREIGN KEY (product_id)
|
||||
REFERENCES catalog.products (id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- product와 order item 관계는 도메인에 따라 독립 객체일 수 있다
|
||||
- 상품 삭제가 주문 이력 일부를 연쇄 삭제하면 운영상 매우 위험할 수 있다
|
||||
- 이 경우는 RESTRICT 또는 NO ACTION이 더 자연스러운 경우가 많다
|
||||
|
||||
PostgreSQL 공식 문서도 독립 객체 관계라면 RESTRICT 또는 NO ACTION이 더 적절하다고 설명한다.
|
||||
|
||||
### 예시 5. null 금지를 CHECK로 우회한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email text,
|
||||
CONSTRAINT ck_users__email_not_null CHECK (email IS NOT NULL)
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- null 금지는 NOT NULL이 더 직접적이고 효율적이다
|
||||
- 역할이 다른 제약을 섞고 있다
|
||||
- 팀 규칙 해석도 흐려진다
|
||||
|
||||
PostgreSQL 공식 문서도 CHECK (col IS NOT NULL)보다 explicit NOT NULL이 더 효율적이라고 설명한다.
|
||||
|
||||
### 예시 6. cross-row 규칙을 CHECK로 해결하려 한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.user_roles (
|
||||
user_id bigint NOT NULL,
|
||||
role_id bigint NOT NULL,
|
||||
is_primary boolean NOT NULL,
|
||||
CONSTRAINT ck_user_roles__only_one_primary
|
||||
CHECK (
|
||||
NOT is_primary
|
||||
OR role_id IS NOT NULL
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- “사용자당 primary role은 하나만” 같은 규칙은 이런 CHECK로 보장되지 않는다
|
||||
- CHECK는 다른 행을 기준으로 유일성을 유지하는 수단이 아니다
|
||||
- 이런 요구는 (user_id) 조건부 unique index 같은 방식으로 풀어야 한다
|
||||
|
||||
PostgreSQL은 cross-row / cross-table 규칙에 CHECK를 쓰지 말고 UNIQUE, FK, EXCLUDE, trigger를 검토하라고 안내한다.
|
||||
@@ -0,0 +1,310 @@
|
||||
# Query 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. LIMIT에는 결정적 ORDER BY를 함께 둔다
|
||||
|
||||
```sql
|
||||
SELECT id, user_id, created_at
|
||||
FROM auth.sessions
|
||||
WHERE user_id = :userId
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- LIMIT 결과가 어떤 20건인지 결정적으로 정의된다
|
||||
- created_at 동률에서도 id가 tie-breaker가 된다
|
||||
- 같은 query를 반복 실행하거나 페이지를 넘겨도 의미가 흔들리지 않는다
|
||||
|
||||
PostgreSQL은 LIMIT 사용 시 ORDER BY가 없으면 예측 불가능한 부분집합을 얻게 된다고 설명한다.
|
||||
|
||||
### 예시 2. LEFT JOIN의 매칭 조건은 ON에 둔다
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.email, s.id AS active_session_id
|
||||
FROM auth.users u
|
||||
LEFT JOIN auth.sessions s
|
||||
ON s.user_id = u.id
|
||||
AND s.revoked_at IS NULL
|
||||
WHERE u.deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “사용자 전체를 유지하면서 revoked 되지 않은 세션만 매칭”이라는 의미가 정확하다
|
||||
- 오른쪽 조건을 ON에 두어 outer join 의미를 유지한다
|
||||
- 사용자 필터와 join 매칭 조건이 분리되어 읽기 쉽다
|
||||
|
||||
PostgreSQL은 outer join에서 ON과 WHERE의 위치가 결과를 다르게 만든다고 명시한다.
|
||||
|
||||
### 예시 3. 존재 여부 확인은 EXISTS로 처리한다
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.email
|
||||
FROM auth.users u
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM auth.user_roles ur
|
||||
WHERE ur.user_id = u.id
|
||||
AND ur.role_name = 'ADMIN'
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “ADMIN role이 하나라도 있으면 됨”이라는 의미가 직접적이다
|
||||
- role이 여러 개여도 바깥 사용자 row가 증폭되지 않는다
|
||||
- COUNT(*) > 0보다 존재 여부 의도를 더 잘 드러낸다
|
||||
|
||||
PostgreSQL은 EXISTS가 행 존재 여부만 판단하며, 일반적으로 전부 끝까지 수행하지 않는다고 설명한다.
|
||||
|
||||
### 예시 4. anti-join은 NOT EXISTS를 사용한다
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.email
|
||||
FROM auth.users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM auth.sessions s
|
||||
WHERE s.user_id = u.id
|
||||
AND s.revoked_at IS NULL
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- null semantics 함정 없이 “활성 세션이 없는 사용자”를 표현한다
|
||||
- NOT IN보다 더 안전한 기본값이다
|
||||
- anti-join 의도가 분명하다
|
||||
|
||||
PostgreSQL은 NOT IN에 null이 섞이면 결과가 true가 아니라 null이 될 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. row filter는 WHERE, 조건부 집계는 FILTER를 사용한다
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
user_id,
|
||||
count(*) FILTER (WHERE revoked_at IS NULL) AS active_count,
|
||||
count(*) FILTER (WHERE revoked_at IS NOT NULL) AS revoked_count
|
||||
FROM auth.sessions
|
||||
WHERE created_at >= :from
|
||||
GROUP BY user_id;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 기간 제한은 row filter이므로 WHERE
|
||||
- 집계별 조건은 FILTER
|
||||
- grouped query의 의미가 분명하다
|
||||
|
||||
PostgreSQL은 WHERE와 HAVING의 역할이 다르고, aggregate input을 FILTER로 제한할 수 있다고 설명한다.
|
||||
|
||||
### 예시 6. one-row-per-group에는 DISTINCT ON을 의도적으로 사용한다
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT ON (user_id)
|
||||
user_id,
|
||||
id,
|
||||
created_at
|
||||
FROM auth.sessions
|
||||
WHERE revoked_at IS NULL
|
||||
ORDER BY user_id, created_at DESC, id DESC;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 사용자별 최신 활성 세션 1건이라는 의미가 분명하다
|
||||
- DISTINCT ON (user_id)와 ORDER BY user_id, ...가 맞춰져 있다
|
||||
- 어떤 row를 남길지 예측 가능하다
|
||||
|
||||
PostgreSQL은 DISTINCT ON의 first row는 ORDER BY 없이는 예측 불가능하며, DISTINCT ON 식은 ORDER BY의 leftmost expressions와 맞아야 한다고 설명한다.
|
||||
|
||||
### 예시 7. deduplication이 불필요하면 UNION ALL을 사용한다
|
||||
|
||||
```sql
|
||||
SELECT user_id, created_at, 'LOGIN' AS event_type
|
||||
FROM auth.login_events
|
||||
WHERE created_at >= :from
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT user_id, created_at, 'LOGOUT' AS event_type
|
||||
FROM auth.logout_events
|
||||
WHERE created_at >= :from;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 두 이벤트 집합을 단순 병합하는 요구다
|
||||
- 중복 제거가 필요하지 않다
|
||||
- UNION보다 의미와 비용이 더 적절하다
|
||||
|
||||
PostgreSQL은 UNION ALL이 중복 제거를 하지 않으므로 보통 더 빠르다고 설명한다.
|
||||
|
||||
### 예시 8. CTE는 단계 분해에 쓰되, 필요하면 NOT MATERIALIZED를 명시한다
|
||||
|
||||
```sql
|
||||
WITH recent_sessions AS NOT MATERIALIZED (
|
||||
SELECT user_id, created_at
|
||||
FROM auth.sessions
|
||||
WHERE created_at >= :from
|
||||
)
|
||||
SELECT u.id, rs.created_at
|
||||
FROM auth.users u
|
||||
JOIN recent_sessions rs ON rs.user_id = u.id
|
||||
WHERE u.deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- CTE로 query 단계를 읽기 쉽게 분해했다
|
||||
- predicate pushdown 이점이 중요한 경우 NOT MATERIALIZED 의도를 드러낸다
|
||||
- CTE를 성능 힌트처럼 무의식적으로 쓰지 않는다
|
||||
|
||||
PostgreSQL은 side-effect-free CTE의 folding/materialization 규칙과 NOT MATERIALIZED의 trade-off를 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. LIMIT만 두고 정렬을 생략한다
|
||||
|
||||
```sql
|
||||
SELECT id, user_id, created_at
|
||||
FROM auth.sessions
|
||||
WHERE user_id = :userId
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 어떤 20건인지 정의되지 않는다
|
||||
- 실행 계획과 실행 시점에 따라 다른 부분집합이 나올 수 있다
|
||||
- API와 배치 결과의 재현성이 깨진다
|
||||
|
||||
PostgreSQL은 LIMIT을 ORDER BY 없이 쓰면 예측 불가능한 subset을 얻는다고 설명한다.
|
||||
|
||||
### 예시 2. LEFT JOIN인데 오른쪽 조건을 WHERE에 내려 의미를 바꾼다
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.email, s.id AS active_session_id
|
||||
FROM auth.users u
|
||||
LEFT JOIN auth.sessions s
|
||||
ON s.user_id = u.id
|
||||
WHERE s.revoked_at IS NULL;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- session이 없는 사용자도 남겨야 하는데 결과에서 사라질 수 있다
|
||||
- 사실상 inner join처럼 동작할 수 있다
|
||||
- outer join의 핵심 의미를 망가뜨린다
|
||||
|
||||
PostgreSQL은 outer join에서 ON과 WHERE가 동등하지 않다고 설명한다.
|
||||
|
||||
### 예시 3. NATURAL JOIN을 사용한다
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM auth.users
|
||||
NATURAL JOIN auth.user_profiles;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 동일 이름 컬럼이 추가되면 join 의미가 바뀔 수 있다
|
||||
- 스키마 변경에 매우 취약하다
|
||||
- 장기 유지보수 신뢰도가 낮다
|
||||
|
||||
PostgreSQL은 NATURAL이 USING보다 훨씬 위험하다고 명시한다.
|
||||
|
||||
### 예시 4. 존재 여부 확인을 COUNT(*)로 처리한다
|
||||
|
||||
```sql
|
||||
SELECT CASE
|
||||
WHEN count(*) > 0 THEN true
|
||||
ELSE false
|
||||
END
|
||||
FROM auth.user_roles
|
||||
WHERE user_id = :userId
|
||||
AND role_name = 'ADMIN';
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 존재 여부만 필요해도 집계를 수행한다
|
||||
- COUNT(*)는 공짜가 아니며, 전체 집계는 테이블 크기에 비례하는 비용이 들 수 있다
|
||||
- EXISTS가 더 직접적인 표현이다
|
||||
|
||||
PostgreSQL은 전체 count(*)가 테이블 또는 전체 인덱스를 스캔해야 할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. NOT IN에 null 가능성을 남겨 둔다
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.email
|
||||
FROM auth.users u
|
||||
WHERE u.id NOT IN (
|
||||
SELECT s.user_id
|
||||
FROM auth.sessions s
|
||||
WHERE s.revoked_at IS NULL
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- subquery 결과에 null이 섞이면 NOT IN 결과가 true가 아니라 null이 될 수 있다
|
||||
- null semantics를 이해하지 못하면 버그를 만들기 쉽다
|
||||
- 이런 anti-join은 보통 NOT EXISTS가 더 안전하다
|
||||
|
||||
PostgreSQL은 NOT IN의 null semantics를 명시적으로 경고한다.
|
||||
|
||||
### 예시 6. row filter를 HAVING으로 올린다
|
||||
|
||||
```sql
|
||||
SELECT user_id, count(*)
|
||||
FROM auth.sessions
|
||||
GROUP BY user_id
|
||||
HAVING max(created_at) >= :from
|
||||
AND user_id = :userId;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- user_id = :userId는 grouping 전에 걸러도 되는 row filter다
|
||||
- 불필요하게 더 많은 행을 grouping하게 만든다
|
||||
- WHERE와 HAVING의 역할이 섞여 있다
|
||||
|
||||
PostgreSQL 튜토리얼은 aggregate가 필요 없는 제한은 WHERE가 더 효율적이라고 설명한다.
|
||||
|
||||
### 예시 7. 잘못된 join을 DISTINCT로 덮는다
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT u.id, u.email
|
||||
FROM auth.users u
|
||||
JOIN auth.user_roles ur ON ur.user_id = u.id
|
||||
WHERE ur.role_name IN ('ADMIN', 'MANAGER');
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- row multiplication 원인을 해결하지 않고 중복만 제거한다
|
||||
- 결과 의미가 DISTINCT에 의존하게 된다
|
||||
- 존재 여부가 목적이라면 EXISTS가 더 직접적이다
|
||||
|
||||
PostgreSQL에서 DISTINCT는 실제로 duplicate row를 제거하는 의미 연산이다. 따라서 이 패턴은 보통 구조 문제를 가리는 나쁜 신호다.
|
||||
|
||||
### 예시 8. DISTINCT ON을 ORDER BY 없이 사용한다
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT ON (user_id)
|
||||
user_id, id, created_at
|
||||
FROM auth.sessions;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 사용자별 어떤 세션이 남는지 예측할 수 없다
|
||||
- first row 선택 기준이 정의되지 않는다
|
||||
- 운영 결과가 비결정적이다
|
||||
|
||||
PostgreSQL은 DISTINCT ON의 first row는 ORDER BY가 없으면 예측 불가능하다고 설명한다.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Schema Structure 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 애플리케이션 전용 schema를 migration으로 만든다
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
CREATE TABLE auth.users (
|
||||
id BIGINT PRIMARY KEY,
|
||||
email VARCHAR(320) NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 애플리케이션 객체가 public 과 분리된다
|
||||
- schema 생성과 테이블 생성 위치가 명확하다
|
||||
- PostgreSQL의 CREATE SCHEMA 와 qualified name 사용 방식에 맞다.
|
||||
|
||||
### 예시 2. ORM 기본 schema를 한 곳에서 맞춘다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 기본 schema를 연결/ORM 설정에서 맞춘다는 전제를 따른다
|
||||
- 모든 엔티티에 같은 schema 문자열을 반복하지 않는다
|
||||
- Hibernate는 schema를 따로 지정하지 않으면 현재 연결의 기본 schema를 사용한다.
|
||||
|
||||
### 예시 3. 정말 필요한 경우에만 특정 엔티티에 schema를 명시한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(schema = "auth_audit", name = "login_audit")
|
||||
public class LoginAudit {
|
||||
@Id
|
||||
private Long id;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예외적 별도 schema 의도를 코드에서 드러낸다
|
||||
- Hibernate가 지원하는 공식 매핑 방식이다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 기본 public 에 그냥 테이블을 만든다
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id BIGINT PRIMARY KEY
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 애플리케이션 schema 의도가 없다
|
||||
- search_path 와 public 기본값에 기대게 된다
|
||||
- PostgreSQL 기본 동작상 unqualified create는 현재 스키마, 기본적으로는 public 에 들어갈 수 있다.
|
||||
|
||||
### 예시 2. 같은 schema를 모든 엔티티에 반복해서 박아 둔다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(schema = "auth", name = "users")
|
||||
public class User { ... }
|
||||
|
||||
@Entity
|
||||
@Table(schema = "auth", name = "sessions")
|
||||
public class Session { ... }
|
||||
|
||||
@Entity
|
||||
@Table(schema = "auth", name = "login_histories")
|
||||
public class LoginHistory { ... }
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기본 schema가 하나인데 코드 중복만 늘어난다
|
||||
- schema 변경 시 수정 범위가 불필요하게 커진다
|
||||
- Hibernate는 기본 schema 연결을 사용할 수 있다.
|
||||
|
||||
### 예시 3. search_path에 기대어 운영마다 다른 schema를 본다
|
||||
|
||||
```sql
|
||||
SET search_path TO auth, public;
|
||||
SELECT * FROM users;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 환경별 search_path 차이에 취약하다
|
||||
- PostgreSQL 공식 문서도 search_path 는 해석 결과와 신뢰 경계를 바꾸므로 주의하라고 설명한다
|
||||
@@ -0,0 +1,221 @@
|
||||
# Soft Delete 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 기본 soft delete 컬럼은 deleted_at으로 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
deleted_at timestamp with time zone
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- soft delete를 삭제 시점으로 표현한다
|
||||
- active row 조건이 `deleted_at IS NULL`로 단순해진다
|
||||
- boolean-only보다 운영 추적성이 좋다
|
||||
|
||||
Hibernate는 TIMESTAMP soft delete 전략이 삭제된 시점을 추적한다고 설명한다. 프로젝트 기본값을 `deleted_at`으로 두는 것은 그 전략과 잘 맞는다.
|
||||
|
||||
### 예시 2. active row uniqueness는 partial unique index로 강제한다
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_users__email__active
|
||||
ON auth.users (email)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- active row 사이에서만 이메일 중복을 막는다
|
||||
- soft-deleted row는 uniqueness 대상에서 제외할 수 있다
|
||||
- PostgreSQL이 공식적으로 지원하는 부분집합 uniqueness 패턴이다
|
||||
|
||||
PostgreSQL은 일부 row에만 적용되는 uniqueness restriction은 unique constraint가 아니라 unique partial index로 표현해야 한다고 설명한다.
|
||||
|
||||
### 예시 3. soft delete는 DELETE가 아니라 UPDATE로 수행한다
|
||||
|
||||
```sql
|
||||
UPDATE auth.users
|
||||
SET deleted_at = current_timestamp
|
||||
WHERE id = :userId
|
||||
AND deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- soft delete를 상태 전이로 표현한다
|
||||
- 이미 삭제된 row에 대한 중복 처리도 막기 쉽다
|
||||
- 일반 삭제와 물리 삭제를 분리하기 좋다
|
||||
|
||||
Hibernate는 soft delete를 실제 삭제 대신 indicator column update로 설명한다.
|
||||
|
||||
### 예시 4. Hibernate를 쓴다면 TIMESTAMP soft delete를 공식 기능으로 쓸 수 있다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Hibernate의 공식 soft delete 기능을 사용한다
|
||||
- TIMESTAMP 전략이 삭제 시점을 추적한다
|
||||
- 컬럼 이름을 프로젝트 표준인 `deleted_at`으로 맞출 수 있다
|
||||
|
||||
Hibernate는 `@SoftDelete`가 TIMESTAMP 전략을 지원하고, indicator column 이름은 `columnName`으로 정의할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. @ManyToMany / @ElementCollection join table에는 soft delete를 제한적으로 적용할 수 있다
|
||||
|
||||
```java
|
||||
@ManyToMany
|
||||
@JoinTable(
|
||||
name = "user_roles",
|
||||
joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "role_id")
|
||||
)
|
||||
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
|
||||
private Set<Role> roles;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Hibernate 공식 지원 범위 안에서 join table row를 soft delete할 수 있다
|
||||
- 관계 row의 논리 삭제가 필요한 경우에만 제한적으로 쓸 수 있다
|
||||
- 엔티티 자체 soft delete와 컬렉션 테이블 soft delete를 구분한다
|
||||
|
||||
Hibernate는 `@SoftDelete`를 `@ElementCollection`과 `@ManyToMany` collection table에 적용할 수 있다고 설명한다.
|
||||
|
||||
### 예시 6. soft delete와 bulk purge를 분리한다
|
||||
|
||||
```java
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
delete from User u
|
||||
where u.deletedAt < :cutoff
|
||||
""")
|
||||
int purgeDeletedBefore(@Param("cutoff") OffsetDateTime cutoff);
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 일반 삭제는 soft delete 경로로 두고, 오래된 삭제 row 정리는 purge 전용 경로로 분리한다
|
||||
- bulk delete가 direct database delete임을 명시적으로 드러낸다
|
||||
- soft delete와 physical purge를 같은 경로로 섞지 않는다
|
||||
|
||||
Spring Data JPA는 JPQL/Criteria bulk delete가 DB 직접 delete로 매핑되고 persistence context를 동기화하지 않는다고 설명한다. 이런 방식은 purge 전용 경로에서만 명시적으로 쓰는 편이 안전하다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. soft delete 컬럼은 있지만 기본 조회에서 빼지 않는다
|
||||
|
||||
```sql
|
||||
SELECT id, email
|
||||
FROM auth.users
|
||||
ORDER BY id DESC;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 삭제 row가 일반 조회에 섞인다
|
||||
- soft delete가 조회 계약으로 완성되지 않는다
|
||||
- active-row partial index와도 잘 맞지 않는다
|
||||
|
||||
PostgreSQL은 partial index가 query WHERE 조건이 predicate를 함의할 때만 사용될 수 있다고 설명한다. active-row 조회는 predicate를 명시적으로 포함해야 한다.
|
||||
|
||||
### 예시 2. active-only uniqueness를 일반 UNIQUE로 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
deleted_at timestamp with time zone
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- soft-deleted row까지 포함해 테이블 전체에서 email 중복을 막아 버린다
|
||||
- "삭제된 이메일은 다시 쓸 수 있다"는 정책을 표현할 수 없다
|
||||
- active subset uniqueness가 아니라 전체-table uniqueness다
|
||||
|
||||
PostgreSQL은 일부 row에만 적용되는 uniqueness restriction은 unique constraint로 쓸 수 없고, partial unique index로 표현해야 한다고 설명한다.
|
||||
|
||||
### 예시 3. partial unique index natural key를 FK target으로 사용하려고 한다
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_users__email__active
|
||||
ON auth.users (email)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE auth.login_audit (
|
||||
user_email text REFERENCES auth.users(email)
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- FK 대상은 non-partial unique index 또는 PK/UNIQUE 제약이어야 한다
|
||||
- active-row partial unique index는 FK target 자격이 없다
|
||||
- soft delete와 natural key 참조를 섞으면 설계가 불안정해진다
|
||||
|
||||
PostgreSQL은 FK 참조 대상이 non-deferrable unique/primary key 제약 또는 non-partial unique index여야 한다고 설명한다.
|
||||
|
||||
### 예시 4. @OneToMany 컬렉션에 @SoftDelete를 붙인다
|
||||
|
||||
```java
|
||||
@OneToMany(mappedBy = "user")
|
||||
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
|
||||
private List<Session> sessions;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Hibernate 공식 지원 범위가 아니다
|
||||
- `@OneToMany`에 `@SoftDelete`를 붙이면 예외가 난다
|
||||
- 자식 엔티티 자체를 soft delete해야 한다
|
||||
|
||||
Hibernate는 `@OneToMany` association에 `@SoftDelete`를 붙이면 예외를 던진다고 설명한다.
|
||||
|
||||
### 예시 5. soft-deletable 엔티티를 batch delete로 지운다
|
||||
|
||||
```java
|
||||
userRepository.deleteAllInBatch(users);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 단일 query physical delete가 일어난다
|
||||
- persistence context가 DB와 동기화되지 않을 수 있다
|
||||
- JPA cascade semantics와 lifecycle events도 존중되지 않는다
|
||||
- soft delete semantics를 우회할 위험이 크다
|
||||
|
||||
Spring Data JPA는 `deleteAllInBatch(Iterable)`가 단일 query를 만들고, first level cache와 DB를 out of sync 상태로 만들 수 있으며, JPA cascade와 lifecycle event를 존중하지 않는다고 설명한다.
|
||||
|
||||
### 예시 6. restore를 uniqueness 검증 없이 수행한다
|
||||
|
||||
```sql
|
||||
UPDATE auth.users
|
||||
SET deleted_at = NULL
|
||||
WHERE id = :userId;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 이메일을 가진 다른 active row가 이미 있으면 active subset uniqueness를 깨뜨릴 수 있다
|
||||
- restore는 단순 null 복원이 아니라 active set에 다시 들어가는 상태 전이다
|
||||
- partial unique index와 충돌 가능성을 고려해야 한다
|
||||
|
||||
PostgreSQL unique partial index는 predicate를 만족하는 row subset 안에서 uniqueness를 강제한다. restore는 그 subset으로 다시 들어가는 행위다.
|
||||
@@ -0,0 +1,317 @@
|
||||
# DB Transaction 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 쓰기 유스케이스 경계는 application service에서 잡는다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserRoleCommandService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
|
||||
@Transactional
|
||||
public void assignRole(Long userId, Long roleId) {
|
||||
User user = userRepository.getById(userId);
|
||||
Role role = roleRepository.getById(roleId);
|
||||
|
||||
user.assign(role);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 유스케이스 전체를 하나의 트랜잭션으로 묶는다
|
||||
- 여러 repository 호출을 outer service boundary가 소유한다
|
||||
- controller나 repository가 아니라 application service가 일관성 경계를 대표한다
|
||||
|
||||
Spring Data JPA는 여러 repository 호출을 묶는 facade/service가 transactional boundary를 정의한다고 설명한다.
|
||||
|
||||
### 예시 2. 조회 유스케이스는 readOnly=true를 최적화 힌트로 사용한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserQueryService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserDetailResponse getUserDetail(Long userId) {
|
||||
User user = userRepository.getDetailById(userId)
|
||||
.orElseThrow(UserNotFoundException::new);
|
||||
|
||||
return new UserDetailResponse(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getStatus()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 읽기 유스케이스임을 트랜잭션 속성으로 명확히 표현한다
|
||||
- readOnly=true를 최적화 힌트로 사용하고, write 시도를 섞지 않는다
|
||||
- 필요한 조회와 response mapping을 service 경계 안에서 끝낸다
|
||||
|
||||
Spring은 readOnly를 최적화 힌트로 설명하고, Spring Data JPA는 Hibernate 사용 시 flush mode를 NEVER로 두어 dirty check를 건너뛸 수 있다고 설명한다.
|
||||
|
||||
### 예시 3. checked exception도 롤백해야 하면 좁게 지정한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserImportService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional(rollbackFor = InvalidUserImportException.class)
|
||||
public void importUsers(List<UserImportRow> rows) throws InvalidUserImportException {
|
||||
for (UserImportRow row : rows) {
|
||||
if (!row.isValid()) {
|
||||
throw new InvalidUserImportException("invalid row");
|
||||
}
|
||||
userRepository.save(User.from(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- checked exception이 유스케이스 실패를 의미한다는 점을 transaction 설정에 반영한다
|
||||
- rollbackFor = Exception.class처럼 과도하게 넓히지 않는다
|
||||
- rollback 규칙이 예외 의미와 맞는다
|
||||
|
||||
Spring은 기본적으로 checked exception에서 rollback하지 않으며, rollback rules로 필요한 예외만 지정할 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. commit 이후에만 실행돼야 하는 후속 작업은 after-commit에 연결한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class UserCreatedEventHandler {
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void handle(UserCreatedEvent event) {
|
||||
// 메일 발송, 후속 발행, 외부 통지 등
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- DB commit 성공 이후에만 실행돼야 하는 후속 작업을 분리한다
|
||||
- 트랜잭션 내부 write와 외부 side effect를 같은 시점에 섞지 않는다
|
||||
- “저장 실패인데 메일은 발송됨” 같은 불일치를 줄일 수 있다
|
||||
|
||||
Spring은 @TransactionalEventListener가 AFTER_COMMIT 같은 phase를 지원한다고 설명한다.
|
||||
|
||||
### 예시 5. outer transaction이 있어야 하는 일반 호출 체인은 REQUIRED에 맡긴다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderCommandService {
|
||||
|
||||
private final PaymentService paymentService;
|
||||
private final OrderRepository orderRepository;
|
||||
|
||||
@Transactional
|
||||
public void confirmOrder(Long orderId) {
|
||||
Order order = orderRepository.getById(orderId);
|
||||
paymentService.validatePayment(order.getPaymentId());
|
||||
order.confirm();
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
public class PaymentService {
|
||||
|
||||
@Transactional
|
||||
public void validatePayment(Long paymentId) {
|
||||
// 같은 physical transaction에 참여
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 같은 유스케이스 안에서는 기본 propagation인 REQUIRED로 충분하다
|
||||
- 불필요하게 REQUIRES_NEW를 쓰지 않는다
|
||||
- 하나의 물리 트랜잭션 안에서 일관성을 유지한다
|
||||
|
||||
Spring은 PROPAGATION_REQUIRED가 common call stack arrangement에서 좋은 기본값이라고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller에 트랜잭션을 둔다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final UserRoleCommandService userRoleCommandService;
|
||||
|
||||
@PostMapping("/users/{id}/roles/{roleId}")
|
||||
@Transactional
|
||||
public void assignRole(@PathVariable Long id, @PathVariable Long roleId) {
|
||||
userRoleCommandService.assignRole(id, roleId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- request handling 범위 전체가 DB 트랜잭션에 포함되기 쉽다
|
||||
- controller는 입출력 경계이고, 일관성 경계를 소유하는 계층이 아니다
|
||||
- transaction scope를 불필요하게 길게 만든다
|
||||
|
||||
Spring Data JPA는 facade/service가 transactional boundary를 정의한다고 설명하고, Hibernate는 물리 트랜잭션은 가능한 짧아야 한다고 설명한다.
|
||||
|
||||
### 예시 2. self-invocation에 @Transactional을 기대한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
public void createUser(CreateUserRequest request) {
|
||||
validate(request);
|
||||
saveUser(request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
void saveUser(CreateUserRequest request) {
|
||||
// 저장
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 클래스 내부 호출이라 proxy를 통과하지 않는다
|
||||
- saveUser()의 @Transactional이 실제로 적용되지 않을 수 있다
|
||||
- 동작하는 것처럼 보여도 rollback 시나리오에서 깨지기 쉽다
|
||||
|
||||
Spring은 proxy mode에서 external method call만 interception 대상이고, self-invocation은 실제 트랜잭션을 만들지 않는다고 설명한다.
|
||||
|
||||
### 예시 3. readOnly=true에서 엔티티를 수정한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserQueryService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public void touchLastViewedAt(Long userId) {
|
||||
User user = userRepository.getById(userId);
|
||||
user.updateLastViewedAt();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- readOnly=true는 쓰기 차단 장치가 아니다
|
||||
- 코드 의미와 트랜잭션 의미가 서로 충돌한다
|
||||
- Hibernate 최적화와 코드 의도가 어긋난다
|
||||
|
||||
Spring과 Spring Data JPA는 readOnly를 최적화 힌트로 설명하며, write attempt 자체를 반드시 막지 않는다고 명시한다.
|
||||
|
||||
### 예시 4. 외부 호출을 길게 물고 있는 long transaction
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentCommandService {
|
||||
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final ExternalGatewayClient externalGatewayClient;
|
||||
|
||||
@Transactional
|
||||
public void approve(Long paymentId) {
|
||||
Payment payment = paymentRepository.getById(paymentId);
|
||||
|
||||
externalGatewayClient.call(payment); // 오래 걸리는 외부 호출
|
||||
|
||||
payment.approve();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 네트워크 대기 시간 동안 DB 트랜잭션이 열린 채로 유지될 수 있다
|
||||
- lock contention과 확장성 문제가 커진다
|
||||
- 외부 호출 실패와 DB 일관성 경계를 분리해서 설계해야 할 가능성이 높다
|
||||
|
||||
Hibernate는 DB 트랜잭션은 가능한 짧아야 하고, 긴 트랜잭션은 확장성을 해친다고 설명한다. Spring도 transaction context가 remote call로 전파되지 않는다고 설명한다.
|
||||
|
||||
### 예시 5. 무심코 rollbackFor = Exception.class를 붙인다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void doSomething() throws Exception {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 모든 checked exception을 일괄 rollback 대상으로 만들어 예외 의미 구분을 흐린다
|
||||
- 복구 가능한 checked exception까지 전부 트랜잭션 실패로 취급할 수 있다
|
||||
- rollback 규칙이 너무 넓다
|
||||
|
||||
Spring은 기본 rollback 규칙이 unchecked exception 중심이며, rollback rules는 필요한 예외에 맞춰 세밀하게 설정할 수 있다고 설명한다. 따라서 광범위한 기본 확대는 신중해야 한다.
|
||||
|
||||
### 예시 6. 일반 helper에 REQUIRES_NEW를 붙여 부분 커밋을 만든다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class AuditHelper {
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void saveAudit(AuditLog log) {
|
||||
// 저장
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- outer transaction과 무관하게 commit될 수 있다
|
||||
- partial commit을 만들기 때문에 business semantics가 달라진다
|
||||
- “트랜잭션 충돌 회피용”으로 쓰면 의도치 않은 데이터 잔존을 만든다
|
||||
|
||||
Spring은 REQUIRES_NEW가 항상 독립 물리 트랜잭션을 사용하고, outer rollback과 독립적으로 commit/rollback 된다고 설명한다.
|
||||
|
||||
### 예시 7. flush와 commit을 같은 것으로 가정한다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void updateUser(Long id) {
|
||||
User user = userRepository.getById(id);
|
||||
user.changeName("new-name");
|
||||
|
||||
userRepository.findAll(); // "아직 commit 전이니까 DB에 영향 없겠지"라고 가정
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Hibernate는 겹치는 query 실행 전 flush를 일으킬 수 있다
|
||||
- commit 전에도 SQL이 먼저 나갈 수 있다
|
||||
- flush timing을 잘못 이해하면 query ordering과 side effect를 오판하게 된다
|
||||
|
||||
Hibernate는 기본 AUTO flush 모드에서 commit 전뿐 아니라, 겹치는 JPQL/HQL query 전과 native query 전에도 flush가 일어날 수 있다고 설명한다.
|
||||
@@ -0,0 +1,88 @@
|
||||
# common module 예시
|
||||
|
||||
## 좋은 예시 1: common 대신 owning module에 둠
|
||||
|
||||
```text
|
||||
presentation/support/response/ApiResult.java
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- HTTP 응답 구조는 presentation 소유다
|
||||
- 다른 레이어가 알 필요가 없다
|
||||
- 공용으로 빼면 오히려 경계가 흐려진다
|
||||
|
||||
## 좋은 예시 2: common 대신 module API로 노출
|
||||
|
||||
```text
|
||||
order/
|
||||
OrderManagement.java
|
||||
order/spi/
|
||||
package-info.java (@NamedInterface("spi"))
|
||||
OrderLookup.java
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 필요한 범위만 공개한다
|
||||
- 전체 common으로 빼지 않고 모듈 API를 좁게 노출한다
|
||||
|
||||
## 좋은 예시 3: 예외적으로 허용 가능한 작은 공용 타입
|
||||
|
||||
```text
|
||||
common/types/NormalizedHost.java
|
||||
```
|
||||
|
||||
**허용 조건:**
|
||||
|
||||
- 여러 모듈이 실제로 사용
|
||||
- framework/business/persistence 의존 없음
|
||||
- 값 기반 타입
|
||||
- 변화 이유가 동일함
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 진짜 공통 값 의미를 담는다
|
||||
- owning module이 특정되기 어렵다
|
||||
- 경계를 섞지 않는다
|
||||
|
||||
## 나쁜 예시 1: 잡동사니 common
|
||||
|
||||
```text
|
||||
common/
|
||||
StringUtils.java
|
||||
DateUtils.java
|
||||
ErrorUtils.java
|
||||
ValidationUtils.java
|
||||
AuthConstants.java
|
||||
ApiResult.java
|
||||
UserMapper.java
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 소유권이 불명확하다
|
||||
- web/domain/infrastructure가 섞인다
|
||||
- dump zone이 된다
|
||||
|
||||
## 나쁜 예시 2: 경계 회피용 common
|
||||
|
||||
```text
|
||||
common/UserDto.java
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- presentation DTO를 공용으로 올려 application/infrastructure도 기대게 만들 수 있다
|
||||
- DTO/Domain/Entity 경계가 무너진다
|
||||
|
||||
## 나쁜 예시 3: premature abstraction common
|
||||
|
||||
```text
|
||||
common/DeadlineHelper.java
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- task와 payment가 지금은 비슷해 보여도 미래에 독립 진화할 수 있다
|
||||
- owning module 안에 두는 편이 더 안전할 수 있다
|
||||
@@ -0,0 +1,136 @@
|
||||
# DTO / Domain / Entity separation 예시
|
||||
|
||||
## 좋은 예시 1: request DTO -> command -> domain
|
||||
|
||||
```java
|
||||
public record CreateUserRequest(
|
||||
String email,
|
||||
String password,
|
||||
String name
|
||||
) {}
|
||||
|
||||
public record CreateUserCommand(
|
||||
String email,
|
||||
String password,
|
||||
String name
|
||||
) {}
|
||||
|
||||
public final class UserWebMapper {
|
||||
|
||||
public CreateUserCommand toCommand(CreateUserRequest request) {
|
||||
return new CreateUserCommand(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 웹 입력 모델과 application 입력 모델이 분리된다
|
||||
- request binding과 business 의미 부여 경계가 생긴다
|
||||
|
||||
## 좋은 예시 2: entity -> domain 분리
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class UserJpaEntity {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String encodedPassword;
|
||||
private String name;
|
||||
protected UserJpaEntity() {}
|
||||
}
|
||||
|
||||
public class User {
|
||||
private final UserId id;
|
||||
private final UserEmail email;
|
||||
private final UserName name;
|
||||
private final EncodedPassword password;
|
||||
|
||||
private User(...) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- JPA 제약과 도메인 의미가 분리된다
|
||||
- domain이 persistence annotation에 오염되지 않는다
|
||||
|
||||
## 좋은 예시 3: domain -> response DTO 분리
|
||||
|
||||
```java
|
||||
public record UserResponse(
|
||||
Long id,
|
||||
String email,
|
||||
String name
|
||||
) {}
|
||||
|
||||
public final class UserResponseMapper {
|
||||
|
||||
public UserResponse toResponse(User user) {
|
||||
return new UserResponse(
|
||||
user.id().value(),
|
||||
user.email().value(),
|
||||
user.name().value()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 외부 응답 계약이 명시적이다
|
||||
- domain 전체를 그대로 노출하지 않는다
|
||||
|
||||
## 나쁜 예시 1: entity를 바로 response로 반환
|
||||
|
||||
```java
|
||||
@GetMapping("/{id}")
|
||||
public UserJpaEntity getUser(@PathVariable Long id) {
|
||||
return userRepository.findById(id).orElseThrow();
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- persistence 구조가 외부 계약으로 새어 나간다
|
||||
- 민감정보/지연로딩/관계 구조가 노출될 수 있다
|
||||
- API와 persistence가 강하게 결합된다
|
||||
|
||||
## 나쁜 예시 2: request DTO를 그대로 domain으로 사용
|
||||
|
||||
```java
|
||||
public User createUser(CreateUserRequest request) {
|
||||
return userService.create(request);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 웹 입력 모델이 business layer로 직접 흘러간다
|
||||
- validation/binding shape가 domain/application 설계를 오염시킨다
|
||||
|
||||
## 나쁜 예시 3: domain에 JPA/JSON/validation annotation 혼합
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@JsonProperty("email")
|
||||
@NotBlank
|
||||
private String email;
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- persistence / serialization / validation / business 의미가 한 타입에 섞인다
|
||||
- 변경 이유가 서로 다른 관심사가 강결합된다
|
||||
@@ -0,0 +1,141 @@
|
||||
# interface 생성 예시
|
||||
|
||||
## 좋은 예시 1: application output port
|
||||
|
||||
```java
|
||||
public interface UserReader {
|
||||
Optional<User> findByEmail(UserEmail email);
|
||||
Optional<User> findById(UserId userId);
|
||||
}
|
||||
|
||||
@Repository
|
||||
public class JpaUserReader implements UserReader {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- application이 persistence 구현을 모른다
|
||||
- 바깥 구현 교체와 테스트 대역 주입이 쉽다
|
||||
- 레이어 경계가 분명하다
|
||||
|
||||
## 좋은 예시 2: 외부 시스템 client contract
|
||||
|
||||
```java
|
||||
public interface VaultTransitClient {
|
||||
String sign(String keyName, byte[] input);
|
||||
PublicKey readPublicKey(String keyName);
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 외부 연동 경계가 분명하다
|
||||
- HttpClient/WebClient/Jackson 세부가 계약에 새지 않는다
|
||||
- fake/stub 구현으로 테스트하기 쉽다
|
||||
|
||||
## 좋은 예시 3: 교체 가능한 정책 객체
|
||||
|
||||
```java
|
||||
public interface PasswordHasher {
|
||||
String hash(String rawPassword);
|
||||
boolean matches(String rawPassword, String encodedPassword);
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 알고리즘 교체 가능성이 실제로 있다
|
||||
- application/domain이 구체 해시 구현을 모른다
|
||||
|
||||
## 좋은 예시 4: 인터페이스 없이 concrete class 유지
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class LoginResponseAssembler {
|
||||
public LoginResponse toResponse(User user, TokenPair tokenPair) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 내부 presentation helper일 뿐 계약 경계가 아니다
|
||||
- 구현체 1개, 교체 가치 낮음, 인터페이스 이득 작음
|
||||
- 불필요한 LoginResponseAssemblerImpl을 만들지 않는다
|
||||
|
||||
## 나쁜 예시 1: 의미 없는 Service/Impl 쌍
|
||||
|
||||
```java
|
||||
public interface UserService {
|
||||
User create(CreateUserCommand command);
|
||||
}
|
||||
|
||||
@Service
|
||||
public class UserServiceImpl implements UserService {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 실제 경계/교체/테스트 seam 의미가 약하다
|
||||
- 타입만 늘고 추상화 이득이 거의 없다
|
||||
- “관성적인 인터페이스”에 가깝다
|
||||
|
||||
## 나쁜 예시 2: 기술 세부를 계약에 노출
|
||||
|
||||
```java
|
||||
public interface UserClient {
|
||||
ResponseEntity<String> getUser(String id);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- Spring Web 타입이 계약에 박힌다
|
||||
- 호출자가 구현 기술에 묶인다
|
||||
|
||||
**개선:**
|
||||
|
||||
- 도메인/애플리케이션에 더 맞는 결과 타입으로 계약 정의
|
||||
|
||||
## 나쁜 예시 3: 여러 책임을 한 인터페이스에 몰아넣기
|
||||
|
||||
```java
|
||||
public interface UserManager {
|
||||
User findUser(...);
|
||||
User saveUser(...);
|
||||
void sendEmail(...);
|
||||
String issueToken(...);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 하나의 역할이 아니다
|
||||
- 호출자마다 일부만 필요할 가능성이 높다
|
||||
- 응집도가 낮다
|
||||
|
||||
## 나쁜 예시 4: 조기 추상화
|
||||
|
||||
```java
|
||||
public interface DeadlineService {
|
||||
void setDeadline(...);
|
||||
}
|
||||
|
||||
public class TaskDeadlineService implements DeadlineService { ... }
|
||||
|
||||
public class PaymentDeadlineService implements DeadlineService { ... }
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 지금은 비슷해 보여도 미래에 다르게 진화할 수 있다
|
||||
- 아직 공통 계약이 자연스러운지 검증되지 않았다
|
||||
|
||||
**개선 방향:**
|
||||
|
||||
- 충분한 공통성/경계 필요가 생길 때까지 분리된 concrete class 유지
|
||||
@@ -0,0 +1,152 @@
|
||||
# mapper separation 예시
|
||||
|
||||
## 좋은 예시 1: web request -> command 매핑
|
||||
|
||||
```java
|
||||
public final class UserWebMapper {
|
||||
|
||||
public CreateUserCommand toCommand(CreateUserRequest request) {
|
||||
return new CreateUserCommand(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- HTTP request 구조를 application command 구조로만 번역한다
|
||||
- 비즈니스 정책을 결정하지 않는다
|
||||
|
||||
## 좋은 예시 2: domain -> response DTO 매핑
|
||||
|
||||
```java
|
||||
public final class UserResponseMapper {
|
||||
|
||||
public UserResponse toResponse(User user) {
|
||||
return new UserResponse(
|
||||
user.id().value(),
|
||||
user.email().value(),
|
||||
user.name().value()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 응답 계약만 만든다
|
||||
- repository/service 호출이 없다
|
||||
|
||||
## 좋은 예시 3: persistence entity -> domain 매핑 분리
|
||||
|
||||
```java
|
||||
public final class UserPersistenceMapper {
|
||||
|
||||
public User toDomain(UserJpaEntity entity) {
|
||||
return User.restore(
|
||||
entity.getId(),
|
||||
entity.getEmail(),
|
||||
entity.getName(),
|
||||
entity.getEncodedPassword()
|
||||
);
|
||||
}
|
||||
|
||||
public UserJpaEntity toEntity(User user) {
|
||||
return new UserJpaEntity(
|
||||
user.id().value(),
|
||||
user.email().value(),
|
||||
user.name().value(),
|
||||
user.password().encodedValue()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- persistence 구조와 domain 구조를 별도 경계에서 번역한다
|
||||
- JPA 세부가 domain으로 직접 새지 않는다
|
||||
|
||||
## 좋은 예시 4: update mapping을 명시적으로 분리
|
||||
|
||||
```java
|
||||
public interface UserPersistenceMapper {
|
||||
|
||||
UserJpaEntity toNewEntity(User user);
|
||||
|
||||
void updateEntity(User user, UserJpaEntity target);
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 생성과 수정의 계약이 다름을 드러낸다
|
||||
- side effect가 있는 매핑을 명시한다
|
||||
|
||||
## 나쁜 예시 1: 매퍼에서 repository 호출
|
||||
|
||||
```java
|
||||
public final class UserMapper {
|
||||
|
||||
private final RoleRepository roleRepository;
|
||||
|
||||
public User toDomain(UserRequest request) {
|
||||
Role role = roleRepository.findByName(request.roleName()).orElseThrow();
|
||||
return User.create(request.email(), role);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 매퍼가 번역을 넘어 DB 조회까지 한다
|
||||
- 테스트와 책임 분리가 어려워진다
|
||||
|
||||
**개선:**
|
||||
|
||||
- 호출자가 Role을 먼저 준비해서 전달한다
|
||||
|
||||
## 나쁜 예시 2: 매퍼에서 비즈니스 규칙 결정
|
||||
|
||||
```java
|
||||
public UserStatus toStatus(UserRequest request) {
|
||||
if (request.provider().equals("google")) {
|
||||
return UserStatus.ACTIVE;
|
||||
}
|
||||
return UserStatus.PENDING;
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 상태 결정 정책이 매퍼에 숨어 있다
|
||||
- 단순 구조 변환이 아니라 비즈니스 의미를 만든다
|
||||
|
||||
**개선:**
|
||||
|
||||
- status 결정은 application/domain 정책으로 이동
|
||||
|
||||
## 나쁜 예시 3: web + persistence + integration를 한 매퍼에 몰아넣기
|
||||
|
||||
```java
|
||||
public final class UserMapper {
|
||||
CreateUserCommand toCommand(CreateUserRequest request) { ... }
|
||||
UserJpaEntity toEntity(User user) { ... }
|
||||
ExternalUserPayload toPayload(User user) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 경계가 섞인다
|
||||
- 변경 이유가 달라 함께 진화하기 어렵다
|
||||
|
||||
**개선:**
|
||||
|
||||
- UserWebMapper
|
||||
- UserPersistenceMapper
|
||||
- UserExternalMapper
|
||||
- 로 분리
|
||||
@@ -0,0 +1,127 @@
|
||||
# port abstraction 예시
|
||||
|
||||
## 좋은 예시 1: outbound port를 application이 소유
|
||||
|
||||
```java
|
||||
public interface UserReader {
|
||||
Optional<User> findByEmail(UserEmail email);
|
||||
Optional<User> findById(UserId userId);
|
||||
}
|
||||
|
||||
@Repository
|
||||
public class JpaUserReader implements UserReader {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- application이 persistence 기술을 모른다
|
||||
- 코어가 필요한 조회 능력만 계약으로 드러난다
|
||||
- adapter만 JPA를 안다
|
||||
|
||||
## 좋은 예시 2: inbound port를 use case 계약으로 사용
|
||||
|
||||
```java
|
||||
public interface LoginUseCase {
|
||||
LoginResult login(LoginCommand command);
|
||||
}
|
||||
|
||||
@RestController
|
||||
class LoginController {
|
||||
private final LoginUseCase loginUseCase;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- controller가 구현체보다 use case 계약에 의존한다
|
||||
- HTTP 세부와 비즈니스 흐름이 분리된다
|
||||
|
||||
## 좋은 예시 3: external API 경계 포트
|
||||
|
||||
```java
|
||||
public interface TokenSigner {
|
||||
Signature sign(SigningRequest request);
|
||||
}
|
||||
|
||||
public class VaultTokenSigner implements TokenSigner {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 포트는 “서명한다”는 능력만 표현한다
|
||||
- HTTP, JSON, Vault path/header는 adapter 구현으로 숨긴다
|
||||
|
||||
## 좋은 예시 4: 하나의 포트에 여러 adapter 가능
|
||||
|
||||
```java
|
||||
public interface RateRepository {
|
||||
BigDecimal findDiscountRate(Money amount);
|
||||
}
|
||||
|
||||
public class InMemoryRateRepository implements RateRepository { ... }
|
||||
|
||||
public class JdbcRateRepository implements RateRepository { ... }
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 테스트와 운영 구현이 같은 계약을 공유한다
|
||||
- 포트는 기술 수와 무관하게 같은 대화를 표현한다
|
||||
|
||||
## 나쁜 예시 1: 기술 타입이 새는 포트
|
||||
|
||||
```java
|
||||
public interface UserApiPort {
|
||||
ResponseEntity<String> getUser(String id);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- HTTP 세부가 코어 계약으로 올라온다
|
||||
- 비즈니스 의미가 아니라 transport 형식이 중심이 된다
|
||||
|
||||
## 나쁜 예시 2: adapter 편의 중심 포트
|
||||
|
||||
```java
|
||||
public interface DatabasePort {
|
||||
String query(String sql);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 코어가 SQL/DB 기술 세부를 알게 된다
|
||||
- “무엇을 원하나”가 아니라 “어떻게 하냐”를 말한다
|
||||
|
||||
## 나쁜 예시 3: 너무 범용적인 outbound port
|
||||
|
||||
```java
|
||||
public interface ExternalSystemPort {
|
||||
Object execute(Object input);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 역할이 불명확하다
|
||||
- 타입 안정성과 계약 의미가 없다
|
||||
- 여러 외부 시스템 책임을 한 곳에 섞기 쉽다
|
||||
|
||||
## 나쁜 예시 4: 내부 helper까지 포트화
|
||||
|
||||
```java
|
||||
public interface EmailNormalizerPort {
|
||||
String normalize(String raw);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 외부 경계가 아니라 내부 로직 detail이다
|
||||
- 포트 추상화 비용이 이득보다 크다
|
||||
@@ -0,0 +1,136 @@
|
||||
# value object 예시
|
||||
|
||||
## 좋은 예시 1: 이메일 Value Object
|
||||
|
||||
```java
|
||||
public record UserEmail(String value) {
|
||||
|
||||
public UserEmail {
|
||||
Objects.requireNonNull(value, "value must not be null");
|
||||
value = value.trim().toLowerCase(Locale.ROOT);
|
||||
|
||||
if (value.isBlank()) {
|
||||
throw new IllegalArgumentException("email must not be blank");
|
||||
}
|
||||
if (!EMAIL_PATTERN.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("invalid email format");
|
||||
}
|
||||
}
|
||||
|
||||
public static UserEmail from(String raw) {
|
||||
return new UserEmail(raw);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 문자열 의미를 타입으로 끌어올린다
|
||||
- 정규화와 검증이 한 곳에 모인다
|
||||
- 값 기반 equality가 자연스럽다
|
||||
|
||||
## 좋은 예시 2: 금액 Value Object
|
||||
|
||||
```java
|
||||
public record Money(BigDecimal amount) {
|
||||
|
||||
public Money {
|
||||
Objects.requireNonNull(amount, "amount must not be null");
|
||||
amount = amount.setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
if (amount.signum() < 0) {
|
||||
throw new IllegalArgumentException("amount must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
public Money add(Money other) {
|
||||
return new Money(this.amount.add(other.amount));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 숫자 primitive를 그대로 흘리지 않는다
|
||||
- scale/음수 금지 규칙이 타입에 들어간다
|
||||
- 값 관련 행위가 같이 있다
|
||||
|
||||
## 좋은 예시 3: entity와 분리된 domain Value Object
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class UserJpaEntity {
|
||||
private String email;
|
||||
}
|
||||
|
||||
public record UserEmail(String value) { ... }
|
||||
|
||||
public final class UserPersistenceMapper {
|
||||
public User toDomain(UserJpaEntity entity) {
|
||||
return User.restore(
|
||||
UserEmail.from(entity.getEmail())
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- persistence 문자열과 domain 의미 타입이 분리된다
|
||||
- domain invariant를 mapper 경계에서 회복한다
|
||||
|
||||
## 나쁜 예시 1: identity를 가진 것을 Value Object처럼 사용
|
||||
|
||||
```java
|
||||
public record User(Long id, String name) {}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- User는 identity가 본질인 entity일 가능성이 높다
|
||||
- 값 객체로 만들면 의미가 흐려진다
|
||||
|
||||
## 나쁜 예시 2: mutable Value Object
|
||||
|
||||
```java
|
||||
public class UserName {
|
||||
private String value;
|
||||
|
||||
public void setValue(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 생성 후 불변이 아니다
|
||||
- 검증/정규화 이후 상태가 깨질 수 있다
|
||||
|
||||
## 나쁜 예시 3: 의미 없는 래퍼
|
||||
|
||||
```java
|
||||
public record NameString(String value) {}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- business meaning이 약하다
|
||||
- 검증/정규화/행위가 전혀 없다
|
||||
- 래퍼 비용만 생길 수 있다
|
||||
|
||||
## 나쁜 예시 4: Value Object에서 외부 의존
|
||||
|
||||
```java
|
||||
public class UserEmail {
|
||||
public boolean exists(UserRepository repository) {
|
||||
return repository.existsByEmail(value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 값 객체가 외부 의존과 오케스트레이션을 떠안는다
|
||||
- 순수한 값 의미 타입이 아니다
|
||||
@@ -0,0 +1,216 @@
|
||||
# External API Client Structure 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. imperative 서비스에서 RestClient adapter를 infrastructure에 둔다
|
||||
|
||||
```java
|
||||
public interface ExternalTokenPort {
|
||||
ExternalTokenResult issueToken(ExternalTokenCommand command);
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
class KeycloakTokenClient implements ExternalTokenPort {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final KeycloakTokenMapper keycloakTokenMapper;
|
||||
|
||||
KeycloakTokenClient(RestClient.Builder restClientBuilder,
|
||||
KeycloakProperties properties,
|
||||
KeycloakAuthHeaderCustomizer authHeaderCustomizer) {
|
||||
this.restClient = restClientBuilder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.defaultHeader("User-Agent", "project-auth-server")
|
||||
.requestInterceptor(authHeaderCustomizer)
|
||||
.build();
|
||||
this.keycloakTokenMapper = new KeycloakTokenMapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExternalTokenResult issueToken(ExternalTokenCommand command) {
|
||||
KeycloakTokenRequest request = keycloakTokenMapper.toRequest(command);
|
||||
|
||||
KeycloakTokenResponse response = restClient.post()
|
||||
.uri("/protocol/openid-connect/token")
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.body(KeycloakTokenResponse.class);
|
||||
|
||||
return keycloakTokenMapper.toResult(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 호출이 infrastructure adapter에 있다
|
||||
- RestClient.Builder를 주입받아 공통 구성과 관측을 따른다
|
||||
- 외부 DTO와 내부 결과가 분리된다.
|
||||
|
||||
### 예시 2. reactive 경계에서는 WebClient를 사용한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class ExternalAuditClient {
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
ExternalAuditClient(WebClient.Builder webClientBuilder, AuditProperties properties) {
|
||||
this.webClient = webClientBuilder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.build();
|
||||
}
|
||||
|
||||
Mono<Void> send(AuditEventRequest request) {
|
||||
return webClient.post()
|
||||
.uri("/events")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- non-blocking 흐름에 맞는 client를 쓴다
|
||||
- Boot가 권장하는 WebClient.Builder 주입 방식을 따른다.
|
||||
|
||||
### 예시 3. HTTP Service Client를 group 기반으로 묶는다
|
||||
|
||||
```java
|
||||
@HttpExchange
|
||||
public interface KeycloakUserHttpClient {
|
||||
|
||||
@GetExchange("/admin/realms/{realm}/users/{id}")
|
||||
KeycloakUserResponse getUser(@PathVariable String realm, @PathVariable String id);
|
||||
}
|
||||
|
||||
@ImportHttpServices(group = "keycloak", types = KeycloakUserHttpClient.class)
|
||||
@Configuration
|
||||
class KeycloakHttpClientsConfiguration {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 선언형 인터페이스로 계약이 분명하다
|
||||
- group을 통해 URL, timeout, SSL, auth customization을 함께 묶을 수 있다.
|
||||
|
||||
### 예시 4. 외부 DTO와 내부 결과를 명시적으로 분리한다
|
||||
|
||||
```java
|
||||
public record KeycloakUserResponse(
|
||||
@JsonProperty("id") String id,
|
||||
@JsonProperty("email") String email,
|
||||
@JsonProperty("enabled") boolean enabled
|
||||
) {
|
||||
}
|
||||
|
||||
public record ExternalUserResult(
|
||||
String externalUserId,
|
||||
String email,
|
||||
boolean active
|
||||
) {
|
||||
}
|
||||
|
||||
public class KeycloakUserMapper {
|
||||
|
||||
ExternalUserResult toResult(KeycloakUserResponse response) {
|
||||
return new ExternalUserResult(
|
||||
response.id(),
|
||||
response.email(),
|
||||
response.enabled()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider JSON 계약이 내부 모델로 그대로 번지지 않는다
|
||||
- 필드명 mismatch와 provider 의미를 adapter 경계에 가둔다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 외부 API를 직접 호출한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
class BadTokenController {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
@PostMapping("/api/v1/tokens")
|
||||
ApiResult<?> create(@RequestBody CreateTokenRequest request) {
|
||||
KeycloakTokenResponse response = restClientBuilder.build()
|
||||
.post()
|
||||
.uri("https://keycloak.example.com/token")
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.body(KeycloakTokenResponse.class);
|
||||
|
||||
return ApiResult.success(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller가 외부 연동과 transport 변환을 직접 수행한다
|
||||
- base URL이 하드코딩돼 있다
|
||||
- 외부 DTO가 내부 API 응답으로 그대로 노출된다
|
||||
|
||||
### 예시 2. 외부 DTO를 application 시그니처에 그대로 넘긴다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class BadIssueTokenService {
|
||||
|
||||
public void issue(KeycloakTokenRequest request) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 provider 계약에 결합된다
|
||||
- 외부 필드명/형식 변화가 내부 계층으로 번진다
|
||||
|
||||
### 예시 3. RestClient.create()를 직접 써서 공통 구성을 우회한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class BadExternalClient {
|
||||
|
||||
private final RestClient client = RestClient.create("https://example.org");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Boot auto-configuration, customizer, instrumentation 적용을 우회한다.
|
||||
|
||||
### 예시 4. provider-specific 예외를 그대로 내부로 던진다
|
||||
|
||||
```java
|
||||
public ExternalUserResult getUser(String id) {
|
||||
try {
|
||||
return webClient.get()
|
||||
.uri("/users/{id}", id)
|
||||
.retrieve()
|
||||
.bodyToMono(ExternalUserResult.class)
|
||||
.block();
|
||||
} catch (WebClientResponseException ex) {
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 HTTP status와 client exception 타입에 직접 묶인다
|
||||
- 예외 번역 책임이 adapter 밖으로 새어 나간다
|
||||
@@ -0,0 +1,191 @@
|
||||
# Fallback 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 외부 추천 실패 시 빈 추천 목록으로 degrade한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecommendationIntegrationService {
|
||||
|
||||
private final RecommendationClient recommendationClient;
|
||||
|
||||
public RecommendationResult getRecommendations(String userId) {
|
||||
try {
|
||||
return recommendationClient.getRecommendations(userId);
|
||||
} catch (ExternalRecommendationTemporaryFailure ex) {
|
||||
return RecommendationResult.degradedEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 추천은 soft dependency로 다룰 수 있다
|
||||
- 핵심 기능을 깨지 않고 degraded mode를 제공한다
|
||||
- fallback 위치가 integration 경계에 있다
|
||||
|
||||
### 예시 2. 캐시된 공개키로 fallback한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class JwkIntegrationService {
|
||||
|
||||
private final JwkClient jwkClient;
|
||||
private final JwkCache jwkCache;
|
||||
|
||||
public JwkSetResult getJwkSet() {
|
||||
try {
|
||||
JwkSetResult result = jwkClient.fetch();
|
||||
jwkCache.put(result);
|
||||
return result;
|
||||
} catch (ExternalJwkTemporaryFailure ex) {
|
||||
return jwkCache.get()
|
||||
.orElseThrow(() -> ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 조회성 데이터에 짧은 TTL 캐시 fallback을 적용할 수 있다
|
||||
- fallback 가능성과 불가능성이 함께 표현된다
|
||||
- 외부 실패를 무조건 숨기지 않는다
|
||||
|
||||
### 예시 3. CircuitBreaker fallback을 명시적으로 둔다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalProfileService {
|
||||
|
||||
private final CircuitBreakerFactory<?, ?> circuitBreakerFactory;
|
||||
private final ExternalProfileClient externalProfileClient;
|
||||
|
||||
public ProfileSupplementResult getSupplement(String userId) {
|
||||
return circuitBreakerFactory.create("external-profile")
|
||||
.run(
|
||||
() -> externalProfileClient.getProfile(userId),
|
||||
throwable -> ProfileSupplementResult.degradedUnavailable()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring Cloud CircuitBreaker의 공식 fallback 모델을 따른다
|
||||
- fallback 결과가 별도 degraded result로 표현된다.
|
||||
|
||||
### 예시 4. 이메일 발송은 비동기 접수로 degrade할 수 있다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MailIntegrationService {
|
||||
|
||||
private final MailClient mailClient;
|
||||
private final MailOutboxRepository mailOutboxRepository;
|
||||
|
||||
public MailDispatchResult sendVerificationMail(MailCommand command) {
|
||||
try {
|
||||
mailClient.send(command);
|
||||
return MailDispatchResult.sent();
|
||||
} catch (ExternalMailTemporaryFailure ex) {
|
||||
mailOutboxRepository.enqueue(command);
|
||||
return MailDispatchResult.acceptedForRetry();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 즉시 발송 실패를 비동기 재처리로 전환한다
|
||||
- API 의미를 “즉시 완료”가 아니라 “접수됨”으로 명확히 바꿀 수 있다
|
||||
- hard dependency를 soft dependency로 바꾸는 사례다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 결제 확정 실패를 성공처럼 fallback한다
|
||||
|
||||
```java
|
||||
public PaymentCaptureResult capture(CaptureCommand command) {
|
||||
try {
|
||||
return paymentClient.capture(command);
|
||||
} catch (Exception ex) {
|
||||
return PaymentCaptureResult.success();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 결제 확정 실패를 성공처럼 숨긴다
|
||||
- 정합성과 감사 가능성을 깨뜨린다
|
||||
- fallback을 쓰면 안 되는 대표 사례다
|
||||
|
||||
### 예시 2. 오래된 캐시를 무기한 사용한다
|
||||
|
||||
```java
|
||||
public ExchangeRateResult getRate(String currency) {
|
||||
try {
|
||||
return exchangeRateClient.getRate(currency);
|
||||
} catch (Exception ex) {
|
||||
return foreverCache.get(currency);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- stale budget이 없다
|
||||
- 오래된 데이터를 최신 사실처럼 쓰게 된다
|
||||
- 운영에서 품질 저하를 통제할 수 없다
|
||||
|
||||
### 예시 3. controller에서 fallback을 직접 구현한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final ExternalProfileClient externalProfileClient;
|
||||
|
||||
@GetMapping("/api/v1/users/{userId}")
|
||||
public ApiResult<UserResponse> get(@PathVariable String userId) {
|
||||
try {
|
||||
ExternalProfileResponse response = externalProfileClient.getProfile(userId);
|
||||
return ApiResult.success(UserResponse.from(response));
|
||||
} catch (Exception ex) {
|
||||
return ApiResult.success(UserResponse.withoutProfile());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- fallback이 controller로 새어 나갔다
|
||||
- provider-aware 로직이 presentation 경계에 있다
|
||||
- 공통 observability와 정책 일관성이 깨진다
|
||||
|
||||
### 예시 4. fallback 발생을 전혀 기록하지 않는다
|
||||
|
||||
```java
|
||||
try {
|
||||
return recommendationClient.getRecommendations(userId);
|
||||
} catch (Exception ex) {
|
||||
return RecommendationResult.degradedEmpty();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- degraded mode가 운영에서 보이지 않는다
|
||||
- fallback rate를 추적할 수 없다
|
||||
- upstream 장애가 숨어 버린다
|
||||
@@ -0,0 +1,133 @@
|
||||
# Integration Idempotency 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. provider 공식 header를 adapter에서 설정한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
class StripePaymentClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
ChargeResult createCharge(CreateChargeCommand command) {
|
||||
return restClient.post()
|
||||
.uri("/v1/payment_intents")
|
||||
.header("Idempotency-Key", command.providerIdempotencyKey())
|
||||
.body(StripeCreateChargeRequest.from(command))
|
||||
.retrieve()
|
||||
.body(ChargeResult.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider 공식 idempotency header를 adapter 경계에서 설정한다
|
||||
- application/domain이 HTTP 헤더 이름을 몰라도 된다
|
||||
- 같은 command 재전송 시 같은 key를 쓸 수 있다.
|
||||
|
||||
### 예시 2. 내부 command id와 provider key를 분리해 관리한다
|
||||
|
||||
```java
|
||||
public record OutboundCallKey(
|
||||
String outboundCommandId,
|
||||
String provider,
|
||||
String operation,
|
||||
String providerIdempotencyKey
|
||||
) {
|
||||
}
|
||||
|
||||
public record OutboundFingerprint(
|
||||
String requestDigest
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 내부 추적 키와 provider 전송 키가 분리된다
|
||||
- provider별 operation scope 차이를 표현하기 쉽다
|
||||
- fingerprint 충돌 검사를 붙이기 좋다
|
||||
|
||||
### 예시 3. timeout 후 같은 key로 재전송한다
|
||||
|
||||
```java
|
||||
try {
|
||||
return paypalCaptureClient.capture(command);
|
||||
} catch (ExternalTimeoutException ex) {
|
||||
return paypalCaptureClient.capture(command.withSameProviderIdempotencyKey());
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- side effect 재시도 시 새 key를 만들지 않는다
|
||||
- 같은 요청 의도에 같은 provider key를 재사용한다
|
||||
- PayPal/Stripe 문서 취지와 맞는다.
|
||||
|
||||
### 예시 4. 같은 key 동시 송신을 막는다
|
||||
|
||||
```java
|
||||
if (!outboundIdempotencyCoordinator.tryAcquire(command.provider(), command.operation(), command.providerIdempotencyKey())) {
|
||||
throw new DuplicateOutboundCallInProgressException();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 같은 key 두 번 동시 전송을 줄인다
|
||||
- PayPal이 설명한 concurrent duplicate 문제를 완화할 수 있다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. timeout 후 새 key로 다시 보낸다
|
||||
|
||||
```java
|
||||
try {
|
||||
return stripeClient.createCharge(command.withNewProviderIdempotencyKey());
|
||||
} catch (ExternalTimeoutException ex) {
|
||||
return stripeClient.createCharge(command.withNewProviderIdempotencyKey());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 외부 side effect 요청이 새 요청으로 처리될 수 있다
|
||||
- 중복 생성/중복 결제 위험이 커진다
|
||||
|
||||
### 예시 2. 같은 key를 다른 operation에 재사용한다
|
||||
|
||||
```java
|
||||
String key = "7f6d...";
|
||||
authorizePayment(key);
|
||||
capturePayment(key);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider마다 operation scope가 다를 수 있다
|
||||
- PayPal은 API call type 단위 고유성을 요구한다.
|
||||
|
||||
### 예시 3. provider key에 이메일을 넣는다
|
||||
|
||||
```java
|
||||
String providerIdempotencyKey = request.email() + ":" + request.orderId();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PII가 key에 섞인다
|
||||
- Stripe도 민감정보를 key로 쓰지 말라고 권고한다.
|
||||
|
||||
### 예시 4. replay semantics를 무시하고 항상 “새 성공”으로 해석한다
|
||||
|
||||
```java
|
||||
return new PaymentCapturedResult(true, true);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider가 이전 결과 재생인지 최신 상태 조회인지 구분하지 못한다
|
||||
- 내부 감사/운영 추적이 부정확해진다
|
||||
@@ -0,0 +1,159 @@
|
||||
# Retry 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. retry 대상 예외와 backoff를 명시한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalTokenGateway {
|
||||
|
||||
private final ExternalTokenClient externalTokenClient;
|
||||
|
||||
@Retryable(
|
||||
retryFor = {
|
||||
SocketTimeoutException.class,
|
||||
ConnectException.class,
|
||||
ResourceAccessException.class
|
||||
},
|
||||
noRetryFor = {
|
||||
IllegalArgumentException.class,
|
||||
ExternalAuthenticationRejectedException.class
|
||||
},
|
||||
maxAttempts = 3,
|
||||
backoff = @Backoff(delay = 300, maxDelay = 2_000, multiplier = 2.0, random = true)
|
||||
)
|
||||
public TokenResult issueToken(TokenCommand command) {
|
||||
return externalTokenClient.issueToken(command);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- retry 대상을 좁혔다
|
||||
- business rejection은 제외했다
|
||||
- backoff + jitter 성격(random = true)을 명시했다.
|
||||
|
||||
### 예시 2. 최종 실패만 내부 예외로 번역한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class KeycloakTokenClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
public TokenResult issue(TokenCommand command) {
|
||||
try {
|
||||
return doIssue(command);
|
||||
} catch (HttpServerErrorException | ResourceAccessException ex) {
|
||||
throw new ExternalAuthTemporaryFailureException(ex);
|
||||
} catch (HttpClientErrorException.Unauthorized ex) {
|
||||
throw new ExternalAuthRejectedException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private TokenResult doIssue(TokenCommand command) {
|
||||
return restClient.post()
|
||||
.uri("/protocol/openid-connect/token")
|
||||
.body(command)
|
||||
.retrieve()
|
||||
.body(TokenResult.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider-specific HTTP 오류를 내부 의미로 번역한다
|
||||
- application이 raw HTTP client 예외를 직접 보지 않는다
|
||||
|
||||
### 예시 3. retry 후 성공은 WARN으로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("External auth request succeeded after retry. provider={} operation={} attempts={}",
|
||||
"keycloak", "issue-token", attemptCount);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 중간 장애 징후를 추적 가능하게 남긴다
|
||||
- 최종 성공을 장애처럼 ERROR로 과장하지 않는다
|
||||
|
||||
### 예시 4. provider rate limit 신호를 존중한다
|
||||
|
||||
```java
|
||||
if (response.getStatusCode().value() == 429) {
|
||||
Duration retryAfter = parseRetryAfter(response.getHeaders());
|
||||
throw new RetryableRateLimitedException(retryAfter);
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider가 주는 throttling 신호를 반영할 수 있다
|
||||
- 무작정 같은 간격으로 재시도하지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 모든 예외를 그대로 retry한다
|
||||
|
||||
```java
|
||||
@Retryable
|
||||
public void callExternalApi() {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring 기본값은 모든 예외를 재시도할 수 있다
|
||||
- deterministic failure와 business rejection까지 재시도될 수 있다.
|
||||
|
||||
### 예시 2. backoff 없이 즉시 재시도한다
|
||||
|
||||
```java
|
||||
for (int i = 0; i < 3; i++) {
|
||||
try {
|
||||
return call();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- retry without backoff anti-pattern이다
|
||||
- 순간 장애 시 부하를 더 키운다.
|
||||
|
||||
### 예시 3. side effect API를 idempotency 검토 없이 다시 호출한다
|
||||
|
||||
```java
|
||||
try {
|
||||
paymentClient.capture(request);
|
||||
} catch (TimeoutException ex) {
|
||||
paymentClient.capture(request);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- timeout은 side effect 미발생을 보장하지 않는다
|
||||
- non-idempotent retry anti-pattern에 가깝다.
|
||||
|
||||
### 예시 4. SDK retry와 adapter retry를 동시에 켠다
|
||||
|
||||
```java
|
||||
public void send() {
|
||||
sdkClient.send(); // SDK 내부 retry 있음
|
||||
}
|
||||
```
|
||||
|
||||
그리고 바깥에서 다시 @Retryable 적용
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- retry layering anti-pattern이다
|
||||
- 실제 요청 수와 부하가 폭증할 수 있다.
|
||||
@@ -0,0 +1,208 @@
|
||||
# Integration Serialization / Deserialization 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 외부 response DTO만 관대하게 읽는다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record KeycloakUserResponse(
|
||||
@JsonProperty("id") String id,
|
||||
@JsonProperty("email") String email,
|
||||
@JsonProperty("enabled") boolean enabled
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider가 필드를 추가해도 파싱이 덜 깨진다
|
||||
- 외부 필드명 mismatch를 DTO 경계에서 해결한다
|
||||
- 내부 모델로 바로 새지 않는다.
|
||||
|
||||
### 예시 2. 성공 응답과 오류 응답 DTO를 분리한다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record StripeChargeResponse(
|
||||
@JsonProperty("id") String id,
|
||||
@JsonProperty("status") String status
|
||||
) {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record StripeErrorResponse(
|
||||
@JsonProperty("error") StripeErrorBody error
|
||||
) {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record StripeErrorBody(
|
||||
@JsonProperty("type") String type,
|
||||
@JsonProperty("code") String code,
|
||||
@JsonProperty("message") String message
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- success/error shape를 억지로 하나의 DTO에 우겨 넣지 않는다
|
||||
- adapter가 provider failure semantics를 더 명확하게 번역할 수 있다
|
||||
|
||||
### 예시 3. form-urlencoded 계약은 JSON으로 억지 변환하지 않는다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
class KeycloakTokenClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
TokenResult issue(KeycloakTokenCommand command) {
|
||||
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||
form.add("grant_type", "password");
|
||||
form.add("client_id", command.clientId());
|
||||
form.add("username", command.username());
|
||||
form.add("password", command.password());
|
||||
|
||||
KeycloakTokenResponse response = restClient.post()
|
||||
.uri("/protocol/openid-connect/token")
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(form)
|
||||
.retrieve()
|
||||
.body(KeycloakTokenResponse.class);
|
||||
|
||||
return new TokenResult(response.accessToken(), response.expiresIn());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider media type을 정확히 따른다
|
||||
- form 계약을 JSON DTO로 왜곡하지 않는다
|
||||
- Spring converter 지원과도 맞는다.
|
||||
|
||||
### 예시 4. provider-specific weird format은 adapter mapper에서 흡수한다
|
||||
|
||||
```java
|
||||
public record ExternalPaymentResult(
|
||||
String paymentId,
|
||||
PaymentState state
|
||||
) {
|
||||
}
|
||||
|
||||
public class StripePaymentMapper {
|
||||
|
||||
ExternalPaymentResult toResult(StripeChargeResponse response) {
|
||||
return new ExternalPaymentResult(
|
||||
response.id(),
|
||||
switch (response.status()) {
|
||||
case "succeeded" -> PaymentState.SUCCEEDED;
|
||||
case "processing" -> PaymentState.PROCESSING;
|
||||
default -> PaymentState.UNKNOWN;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider string enum이 domain enum으로 직접 새지 않는다
|
||||
- 새 값이 추가돼도 UNKNOWN으로 흡수할 수 있다
|
||||
|
||||
### 예시 5. 공통 builder를 주입받아 client를 만든다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class ExternalUserClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
ExternalUserClient(RestClient.Builder builder, ExternalUserProperties properties) {
|
||||
this.restClient = builder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 공통 HttpMessageConverters와 request factory를 따른다
|
||||
- 로컬 ObjectMapper/client 생성을 줄인다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 내부 entity를 외부 request body로 직접 보낸다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String password;
|
||||
private String role;
|
||||
}
|
||||
|
||||
restClient.post()
|
||||
.uri("/users")
|
||||
.body(user)
|
||||
.retrieve();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 내부 모델이 외부 계약으로 새어 나간다
|
||||
- provider에 보내면 안 되는 필드까지 함께 나갈 수 있다
|
||||
- serialization concern이 domain/entity를 오염시킨다
|
||||
|
||||
### 예시 2. adapter 메서드 안에서 new ObjectMapper()를 만든다
|
||||
|
||||
```java
|
||||
public ExternalUserResult getUser(String id) throws Exception {
|
||||
String body = httpClient.get(...);
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
return objectMapper.readValue(body, ExternalUserResult.class);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공통 mapper/configuration을 우회한다
|
||||
- client별 일관성이 깨진다
|
||||
- message converter 경계를 스스로 무너뜨린다.
|
||||
|
||||
### 예시 3. external response를 raw Map으로 받아 business 로직에 넘긴다
|
||||
|
||||
```java
|
||||
Map<String, Object> response = restClient.get()
|
||||
.uri("/users/{id}", id)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
|
||||
return userService.handle(response);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- payload reading 경계가 application으로 번진다
|
||||
- contract drift가 여러 계층에 퍼진다
|
||||
- tolerant reader가 아니라 “아무도 책임지지 않는 reader”가 된다
|
||||
|
||||
### 예시 4. 외부 오류 본문을 그대로 예외 메시지로 올린다
|
||||
|
||||
```java
|
||||
catch (HttpClientErrorException ex) {
|
||||
throw new RuntimeException(ex.getResponseBodyAsString());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider raw payload가 내부 예외/로그로 새어 나간다
|
||||
- 민감정보나 과도한 본문이 포함될 수 있다
|
||||
- success/error parsing 규칙이 사라진다
|
||||
@@ -0,0 +1,163 @@
|
||||
# Timeout 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 전역 기본값은 공통 설정으로 둔다
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
http:
|
||||
clients:
|
||||
connect-timeout: 500ms
|
||||
read-timeout: 2s
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 서비스 전체 기본값이 한 곳에 있다
|
||||
- 모든 client에 최소 timeout 정책이 적용된다.
|
||||
|
||||
### 예시 2. provider별 차이는 HTTP service group에서 override한다
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
http:
|
||||
clients:
|
||||
connect-timeout: 500ms
|
||||
serviceclient:
|
||||
keycloak:
|
||||
base-url: https://keycloak.example.com
|
||||
read-timeout: 3s
|
||||
payment:
|
||||
base-url: https://payment.example.com
|
||||
read-timeout: 5s
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 공통 기본값과 provider별 차이가 함께 보인다
|
||||
- Spring Boot가 제공하는 group-level connect/read timeout 구조와 맞는다.
|
||||
|
||||
### 예시 3. RestClient는 주입된 builder를 사용한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class KeycloakTokenClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
KeycloakTokenClient(RestClient.Builder builder, KeycloakProperties properties) {
|
||||
this.restClient = builder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Boot auto-configuration과 공통 timeout/customizer를 따른다
|
||||
- RestClient.create()로 공통 구성을 우회하지 않는다.
|
||||
|
||||
### 예시 4. WebClient는 Reactor Netty timeout을 명시적으로 구성할 수 있다
|
||||
|
||||
```java
|
||||
@Bean
|
||||
WebClient paymentWebClient(WebClient.Builder builder) {
|
||||
HttpClient httpClient = HttpClient.create()
|
||||
.responseTimeout(Duration.ofSeconds(3))
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000);
|
||||
|
||||
return builder
|
||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||
.baseUrl("https://payment.example.com")
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- connect timeout과 response timeout을 분리한다
|
||||
- Reactor Netty의 구체 timeout 지점을 활용한다.
|
||||
|
||||
### 예시 5. timeout 값은 operation별로 명시적 override만 허용한다
|
||||
|
||||
```java
|
||||
Mono<ResponseDto> callLongRunningOperation(RequestDto request) {
|
||||
return webClient.post()
|
||||
.uri("/reports")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(ResponseDto.class)
|
||||
.timeout(Duration.ofSeconds(8));
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “이 operation만 더 길다”는 의도가 코드에 드러난다
|
||||
- 기본값과 다른 이유를 문서화하기 쉽다
|
||||
|
||||
**주의:**
|
||||
|
||||
- reactive 전체 timeout()은 최후 수단에 가깝고, 가능하면 client-specific timeout이 더 우선이다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 외부 호출에 timeout이 없다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class BadExternalClient {
|
||||
|
||||
private final RestClient restClient = RestClient.create("https://example.com");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공통 timeout/customizer/관측 구성을 우회한다
|
||||
- 무제한 또는 의도 불명확한 대기에 빠질 수 있다.
|
||||
|
||||
### 예시 2. 너무 낮은 timeout을 근거 없이 하드코딩한다
|
||||
|
||||
```java
|
||||
webClient.get()
|
||||
.uri("/token")
|
||||
.retrieve()
|
||||
.bodyToMono(TokenResponse.class)
|
||||
.timeout(Duration.ofMillis(20));
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- TLS handshake, 새 연결, DNS 비용을 고려하지 않은 값일 수 있다
|
||||
- 배포 직후/콜드 커넥션에서 false timeout을 유발하기 쉽다.
|
||||
|
||||
### 예시 3. timeout 값을 서비스 전체에 하나의 숫자로 강제한다
|
||||
|
||||
```yaml
|
||||
external:
|
||||
timeout-ms: 1000
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider별 latency와 business 중요도가 다를 수 있다
|
||||
- connect/read/response 구분도 사라진다
|
||||
- operation별 차이를 담기 어렵다
|
||||
|
||||
### 예시 4. timeout 이후 side effect API를 무심코 재시도한다
|
||||
|
||||
```java
|
||||
try {
|
||||
paymentClient.capture(request);
|
||||
} catch (TimeoutException ex) {
|
||||
paymentClient.capture(request);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- timeout이 side effect 미발생을 보장하지 않는다
|
||||
- idempotency 검토 없이 중복 실행 위험이 생긴다.
|
||||
@@ -0,0 +1,194 @@
|
||||
# collections / immutability 예시
|
||||
|
||||
이 문서는 [collections / immutability 기준](../../standards/language/collections-immutability.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 컬렉션을 immutable-first로 다루고, 변경이 필요한 로컬 조립 단계가 끝나면 경계를 넘기기 전에 수정 불가 snapshot으로 고정하는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 생성자에서 defensive copy
|
||||
|
||||
```java
|
||||
public class RolePolicy {
|
||||
|
||||
private final List<String> allowedRoles;
|
||||
|
||||
public RolePolicy(List<String> allowedRoles) {
|
||||
this.allowedRoles = List.copyOf(allowedRoles);
|
||||
}
|
||||
|
||||
public List<String> allowedRoles() {
|
||||
return allowedRoles;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 외부에서 넘긴 mutable list를 그대로 보관하지 않는다.
|
||||
- 내부 필드를 안정된 snapshot으로 고정한다.
|
||||
|
||||
## 좋은 예시 2: 상수성 데이터는 of 사용
|
||||
|
||||
```java
|
||||
private static final Set<String> PUBLIC_PATHS = Set.of(
|
||||
"/",
|
||||
"/login",
|
||||
"/swagger-ui.html"
|
||||
);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 상수 컬렉션 의도가 분명하다.
|
||||
- `null`과 중복을 조기에 차단한다.
|
||||
|
||||
주의:
|
||||
|
||||
- 순서를 기대하면 `List.of`가 더 적합할 수 있다.
|
||||
|
||||
## 좋은 예시 3: stream 결과를 수정 불가로 고정
|
||||
|
||||
```java
|
||||
List<String> activeEmails = users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.toList();
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 결과가 읽기 전용이라는 의도가 분명하다.
|
||||
- 후속 코드가 실수로 수정하지 못한다.
|
||||
|
||||
## 좋은 예시 4: mutable 조립 후 경계에서 snapshot
|
||||
|
||||
```java
|
||||
List<String> buildScopes(User user) {
|
||||
List<String> scopes = new ArrayList<>();
|
||||
scopes.add("profile");
|
||||
if (user.isAdmin()) {
|
||||
scopes.add("admin");
|
||||
}
|
||||
return List.copyOf(scopes);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 로컬 조립 단계에서는 mutable 컬렉션을 실용적으로 사용한다.
|
||||
- 반환 시점에는 안정된 snapshot으로 바꾼다.
|
||||
|
||||
## 좋은 예시 5: 구체 mutable 결과가 필요하면 명시
|
||||
|
||||
```java
|
||||
List<UserDto> result = users.stream()
|
||||
.map(UserMapper::toDto)
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- mutable 결과가 필요하다는 점을 코드에 드러낸다.
|
||||
- `Collectors.toList()`의 mutability를 가정하지 않는다.
|
||||
|
||||
## 나쁜 예시 1: 내부 mutable collection 그대로 노출
|
||||
|
||||
```java
|
||||
public class UserGroup {
|
||||
|
||||
private final List<User> users = new ArrayList<>();
|
||||
|
||||
public List<User> getUsers() {
|
||||
return users;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 외부에서 내부 상태를 직접 수정할 수 있다.
|
||||
- 캡슐화가 깨진다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public List<User> getUsers() {
|
||||
return List.copyOf(users);
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: unmodifiable view를 immutable로 착각
|
||||
|
||||
```java
|
||||
List<String> source = new ArrayList<>();
|
||||
source.add("A");
|
||||
|
||||
List<String> readOnly = Collections.unmodifiableList(source);
|
||||
source.add("B");
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `readOnly`는 immutable snapshot이 아니라 view다.
|
||||
- `source`가 바뀌면 `readOnly`도 바뀐다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
List<String> readOnly = List.copyOf(source);
|
||||
```
|
||||
|
||||
## 나쁜 예시 3: null collection 반환
|
||||
|
||||
```java
|
||||
public List<Role> findRoles(Long userId) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 호출자마다 null-check를 강요한다.
|
||||
- 컬렉션 결과의 계약이 흐려진다.
|
||||
|
||||
개선:
|
||||
|
||||
- empty list를 반환한다.
|
||||
- 또는 입력 자체를 경계에서 검증한다.
|
||||
|
||||
## 나쁜 예시 4: 순서를 기대하면서 Set.of 사용
|
||||
|
||||
```java
|
||||
Set<String> statuses = Set.of("NEW", "PROCESSING", "DONE");
|
||||
String first = statuses.iterator().next();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `Set.of` iteration order를 비즈니스 로직에 기대고 있다.
|
||||
- JVM 실행마다 순서가 달라질 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
List<String> statuses = List.of("NEW", "PROCESSING", "DONE");
|
||||
String first = statuses.getFirst();
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: shallow immutability 오해
|
||||
|
||||
```java
|
||||
List<UserProfile> profiles = List.copyOf(sourceProfiles);
|
||||
profiles.get(0).changeNickname("new-name");
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 컬렉션은 수정 불가지만 원소는 mutable이라 상태가 바뀔 수 있다.
|
||||
- 공유 상태 안정성을 보장하지 못한다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- immutable element를 사용한다.
|
||||
- mutable element는 공유하지 않도록 복사하거나 변환한다.
|
||||
@@ -0,0 +1,137 @@
|
||||
# duplication 예시
|
||||
|
||||
## 좋은 예시 1: 같은 정책 중복은 private method로 추출
|
||||
|
||||
```java
|
||||
private String normalizeEmail(String rawEmail) {
|
||||
return rawEmail.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public User register(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
...
|
||||
}
|
||||
|
||||
public User login(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 같은 정책이다
|
||||
- 같은 이유로 바뀐다
|
||||
- 한 곳에서 수정 가능하다
|
||||
|
||||
## 좋은 예시 2: 외부 API 예외 번역 중복 추출
|
||||
|
||||
```java
|
||||
private InfrastructureException vaultFailure(String message, Exception cause) {
|
||||
return new InfrastructureException(
|
||||
InfrastructureErrorCode.VAULT_TRANSIT_FAILED,
|
||||
message,
|
||||
cause
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 기술 실패 번역 정책이 한 곳에 모인다
|
||||
- 누락/불일치 위험이 줄어든다
|
||||
|
||||
## 좋은 예시 3: 테스트는 중복을 일부 허용
|
||||
|
||||
```java
|
||||
@Test
|
||||
void registers_two_users() {
|
||||
User user1 = new User("alice");
|
||||
User user2 = new User("bob");
|
||||
|
||||
forum.register(user1);
|
||||
forum.register(user2);
|
||||
|
||||
assertTrue(forum.hasRegisteredUser(user1));
|
||||
assertTrue(forum.hasRegisteredUser(user2));
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- helper/loop보다 읽기 쉽다
|
||||
- 테스트 의도가 바로 드러난다
|
||||
|
||||
## 좋은 예시 4: 3회 이상 반복되는 mapper 규칙 추출
|
||||
|
||||
```java
|
||||
private ApiResult<Void> failureOf(ApplicationException exception) {
|
||||
return ApiResult.failure(exception.getCode(), exception.getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 응답 실패 조립 규칙이 공통 정책이다
|
||||
- presentation 전반에서 같은 이유로 바뀔 가능성이 높다
|
||||
|
||||
## 나쁜 예시 1: 우연한 유사성을 억지로 공통화
|
||||
|
||||
```java
|
||||
public Object process(Object input, String mode, Map<String, Object> options) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 맥락이 다른 두세 개 흐름을 한 메서드로 억지로 합친다
|
||||
- 이름이 모호해지고 분기만 늘어난다
|
||||
- 이후 독립 진화가 어렵다
|
||||
|
||||
## 나쁜 예시 2: 레이어를 넘는 공통화
|
||||
|
||||
```java
|
||||
public final class CommonValidationUtil {
|
||||
public static void validateUser(User user, CreateUserRequest request, UserJpaEntity entity) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- domain/presentation/infrastructure 경계를 한 곳에 섞는다
|
||||
- 중복 제거보다 아키텍처 손상이 더 크다
|
||||
|
||||
## 나쁜 예시 3: 테스트를 너무 DRY하게 만들어 의미 숨김
|
||||
|
||||
```java
|
||||
private void registerAll(List<User> users) { ... }
|
||||
|
||||
@Test
|
||||
void registers_users() {
|
||||
registerAll(defaultUsers());
|
||||
assertAllRegistered(defaultUsers());
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 테스트 본문만 보면 실제 행위가 잘 드러나지 않는다
|
||||
- helper를 따라가야 해서 검증이 어려워진다
|
||||
|
||||
## 나쁜 예시 4: common 모듈로 너무 빨리 이동
|
||||
|
||||
```text
|
||||
common/
|
||||
StringUtils.java
|
||||
DateUtils.java
|
||||
ErrorUtils.java
|
||||
ValidationUtils.java
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- “중복 제거” 명분으로 소유권 없는 잡동사니 모듈이 된다
|
||||
- 진짜 공통인지, 그냥 아직 설계가 안 된 것인지 구분이 사라진다
|
||||
@@ -0,0 +1,213 @@
|
||||
# enum / constants 예시
|
||||
|
||||
이 문서는 [enum / constants 기준](../../standards/language/enum-constants.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 고정된 의미 집합을 enum으로 표현하고, external code / display label / internal name을 섞지 않는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 문자열 상수 대신 enum
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
LOCAL,
|
||||
GOOGLE,
|
||||
KAKAO
|
||||
}
|
||||
|
||||
if (user.getProvider() == AuthProvider.LOCAL) {
|
||||
// local login flow
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 고정된 값 집합을 타입으로 표현한다.
|
||||
- 오타와 매직 스트링 분기를 줄인다.
|
||||
|
||||
## 좋은 예시 2: external code를 명시적 필드로 분리
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
LOCAL("local"),
|
||||
GOOGLE("google"),
|
||||
KAKAO("kakao");
|
||||
|
||||
private final String code;
|
||||
|
||||
AuthProvider(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `name()`에 외부 계약을 맡기지 않는다.
|
||||
- 내부 enum 이름 변경과 외부 계약을 분리할 수 있다.
|
||||
|
||||
## 좋은 예시 3: EnumSet 사용
|
||||
|
||||
```java
|
||||
private static final EnumSet<AuthProvider> SOCIAL_PROVIDERS =
|
||||
EnumSet.of(AuthProvider.GOOGLE, AuthProvider.KAKAO);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- enum 집합이라는 의도가 직접 드러난다.
|
||||
- 비트 플래그나 일반 `Set`보다 타입 안전하고 목적에 맞다.
|
||||
|
||||
## 좋은 예시 4: EnumMap 사용
|
||||
|
||||
```java
|
||||
private final EnumMap<AuthProvider, OAuthClient> clients =
|
||||
new EnumMap<>(AuthProvider.class);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- enum key 전용 자료구조라는 점이 명확하다.
|
||||
- 일반 `HashMap`보다 목적에 더 잘 맞는다.
|
||||
|
||||
## 좋은 예시 5: 진짜 상수만 상수로 둠
|
||||
|
||||
```java
|
||||
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(30);
|
||||
private static final List<String> PUBLIC_PATHS = List.of("/", "/login");
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 값이 immutable이다.
|
||||
- `static final`뿐 아니라 실제 의미도 안정적이다.
|
||||
|
||||
## 나쁜 예시 1: ordinal 저장/분기
|
||||
|
||||
```java
|
||||
int providerCode = provider.ordinal();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- enum 순서 변경이나 값 추가에 취약하다.
|
||||
- stable contract가 아니다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
String providerCode = provider.code();
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: name/toString 문자열 비교
|
||||
|
||||
```java
|
||||
if (provider.name().equals("GOOGLE")) {
|
||||
// google login flow
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- enum 의미 비교를 문자열 비교로 내린다.
|
||||
- 타입 안전성이 사라진다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
if (provider == AuthProvider.GOOGLE) {
|
||||
// google login flow
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 3: 잡다한 constants class
|
||||
|
||||
```java
|
||||
public final class AppConstants {
|
||||
|
||||
public static final String PROVIDER_LOCAL = "LOCAL";
|
||||
public static final String PROVIDER_GOOGLE = "GOOGLE";
|
||||
public static final String PROVIDER_KAKAO = "KAKAO";
|
||||
|
||||
private AppConstants() {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 고정된 의미 집합을 타입으로 표현하지 않는다.
|
||||
- 문자열 오타와 분기 누락에 취약하다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
LOCAL,
|
||||
GOOGLE,
|
||||
KAKAO
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 4: mutable collection을 상수처럼 사용
|
||||
|
||||
```java
|
||||
private static final Set<String> PUBLIC_PATHS = new HashSet<>();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `static final`이어도 내부 상태는 바뀔 수 있다.
|
||||
- 진짜 상수라고 보기 어렵다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
private static final Set<String> PUBLIC_PATHS = Set.of("/", "/login");
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: default로 enum 추가 누락 숨김
|
||||
|
||||
```java
|
||||
return switch (provider) {
|
||||
case LOCAL -> localHandler();
|
||||
default -> socialHandler();
|
||||
};
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 새 enum 값이 생겨도 의도치 않게 `default`에 흡수될 수 있다.
|
||||
- 분기 누락이 컴파일 시점에 드러나기 어렵다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
return switch (provider) {
|
||||
case LOCAL -> localHandler();
|
||||
case GOOGLE, KAKAO -> socialHandler();
|
||||
};
|
||||
```
|
||||
|
||||
## 나쁜 예시 6: null 회피용 UNKNOWN 남용
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
UNKNOWN,
|
||||
LOCAL,
|
||||
GOOGLE,
|
||||
KAKAO
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `UNKNOWN`이 실제 비즈니스 상태가 아니라면 의미 없는 상태가 생긴다.
|
||||
- 단순 null 회피가 enum 모델에 섞인다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- boundary 입력은 검증하거나 nullable로 명시한다.
|
||||
- 조회 결과의 부재는 필요하면 `Optional<AuthProvider>`로 표현한다.
|
||||
- 실제 비즈니스 상태일 때만 `UNKNOWN` 또는 `UNSPECIFIED`를 둔다.
|
||||
@@ -0,0 +1,216 @@
|
||||
# exceptions 예시
|
||||
|
||||
이 문서는 [exceptions 기준](../../standards/language/exceptions.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 예외를 정상 흐름 제어가 아니라 예외 상황 전달 수단으로 사용하고, catch는 번역 / 문맥 추가 / 복구 목적이 있을 때만 두는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 기술 예외를 계층 예외로 번역하면서 cause 보존
|
||||
|
||||
```java
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
return parse(response.body());
|
||||
} catch (IOException exception) {
|
||||
throw new InfrastructureException(
|
||||
InfrastructureErrorCode.EXTERNAL_API_FAILED,
|
||||
"Failed to call external API",
|
||||
exception
|
||||
);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new InfrastructureException(
|
||||
InfrastructureErrorCode.EXTERNAL_API_FAILED,
|
||||
"External API call was interrupted",
|
||||
exception
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- broad catch가 아니다.
|
||||
- `InterruptedException`을 별도로 처리한다.
|
||||
- cause를 보존한다.
|
||||
- 기술 실패를 infrastructure 의미로 번역한다.
|
||||
|
||||
## 좋은 예시 2: try-with-resources 사용
|
||||
|
||||
```java
|
||||
try (InputStream in = Files.newInputStream(path)) {
|
||||
return objectMapper.readValue(in, Payload.class);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 자원 해제를 자동화한다.
|
||||
- close 중 예외가 발생해도 suppressed exception으로 보존될 수 있다.
|
||||
|
||||
## 좋은 예시 3: 상위 경계에서만 broad catch
|
||||
|
||||
```java
|
||||
try {
|
||||
return useCase.execute(command);
|
||||
} catch (ApplicationException exception) {
|
||||
return errorResponse(exception.getCode(), exception.getMessage());
|
||||
} catch (Exception exception) {
|
||||
log.error("Unhandled exception while processing request", exception);
|
||||
return errorResponse("INTERNAL_SERVER_ERROR", "Unexpected server error");
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 최상위 boundary에서 마지막 방어선으로만 broad catch를 쓴다.
|
||||
- 내부 계층에서는 더 구체적인 예외 처리를 유지한다.
|
||||
- 예상 가능한 application 예외와 예상하지 못한 실패를 구분한다.
|
||||
|
||||
## 좋은 예시 4: checked 예외 rollback 필요 시 명시
|
||||
|
||||
```java
|
||||
@Transactional(rollbackFor = IOException.class)
|
||||
public void importUsers(Path path) throws IOException {
|
||||
// import users from file
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- Spring 기본 rollback 규칙을 명시적으로 보완한다.
|
||||
- checked exception이 rollback 대상인지 계약으로 드러난다.
|
||||
|
||||
## 좋은 예시 5: 테스트는 assertThrows 우선
|
||||
|
||||
```java
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.createUser(command)
|
||||
);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `try-catch + fail` 패턴보다 의도가 직접적이다.
|
||||
- 예외 객체를 받아 메시지나 상태를 추가로 검증할 수 있다.
|
||||
|
||||
## 나쁜 예시 1: broad catch + 삼키기
|
||||
|
||||
```java
|
||||
try {
|
||||
saveUser(user);
|
||||
} catch (Exception exception) {
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 예외가 사라진다.
|
||||
- 디버깅이 어려워진다.
|
||||
- interruption 같은 중요한 신호도 놓칠 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 복구할 수 있는 구체 예외만 catch한다.
|
||||
- 계층 예외로 번역하거나, 문맥을 붙여 다시 던진다.
|
||||
- 정말 무시해야 한다면 이유를 남기고 logging / metrics / 상태 기록 중 하나를 수행한다.
|
||||
|
||||
## 나쁜 예시 2: printStackTrace 후 계속 진행
|
||||
|
||||
```java
|
||||
try {
|
||||
sync();
|
||||
} catch (IOException exception) {
|
||||
exception.printStackTrace();
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 운영 로그 정책을 깨뜨린다.
|
||||
- 실패를 구조적으로 전달하지 못한다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- logging framework로 기록한다.
|
||||
- 또는 계층 예외로 번역해 상위 boundary로 전달한다.
|
||||
|
||||
## 나쁜 예시 3: finally에서 return
|
||||
|
||||
```java
|
||||
try {
|
||||
return load();
|
||||
} finally {
|
||||
return fallback();
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- try 블록 결과와 예외를 덮어쓴다.
|
||||
- 실제 실패가 호출자에게 전달되지 않을 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- `finally`는 정리 작업만 수행한다.
|
||||
- fallback이 필요하면 catch나 명시적 분기에서 처리한다.
|
||||
|
||||
## 나쁜 예시 4: InterruptedException 뭉개기
|
||||
|
||||
```java
|
||||
try {
|
||||
queue.take();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- interruption을 별도 의미로 처리하지 않는다.
|
||||
- 스레드 인터럽트 상태를 잃을 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
try {
|
||||
queue.take();
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while waiting for queue item", exception);
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: 너무 넓은 일반 예외 던지기
|
||||
|
||||
```java
|
||||
throw new RuntimeException("bad request");
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 의미가 너무 넓다.
|
||||
- 호출자가 어떤 실패인지 이해하기 어렵다.
|
||||
- 경계에서 일관된 에러 코드나 응답으로 번역하기 어렵다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 계약 위반이면 `IllegalArgumentException` 같은 구체 예외를 사용한다.
|
||||
- 계층 의미가 있으면 domain / application / infrastructure 예외로 표현한다.
|
||||
|
||||
## 나쁜 예시 6: 정상적인 결과 없음에 예외 사용
|
||||
|
||||
```java
|
||||
public User findUser(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("user not found"));
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 결과 없음이 정상적인 조회 결과일 수 있는데 예외로만 표현한다.
|
||||
- 호출자가 부재를 처리할 수 있는 선택지를 잃는다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 단건 조회의 부재가 정상 흐름이면 `Optional<User>`를 반환한다.
|
||||
- 유스케이스 계약상 반드시 있어야 하는 값이면 구체적인 application 예외로 번역한다.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Javadoc 예시
|
||||
|
||||
## 좋은 예시 1: 반환 계약과 예외 조건이 드러나는 메서드
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns the active user for the given email.
|
||||
*
|
||||
* @param email normalized user email, never {@code null}
|
||||
* @return the matching active user
|
||||
* @throws UserNotFoundException if no user exists for the given email
|
||||
* @throws InactiveUserException if the user exists but is inactive
|
||||
*/
|
||||
public User getActiveUserByEmail(String email) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 호출자가 믿을 수 있는 계약이 보인다
|
||||
- null 허용 여부와 실패 조건이 드러난다
|
||||
- 구현 세부가 아니라 API 의미를 설명한다
|
||||
|
||||
## 좋은 예시 2: value object 생성 제약 문서화
|
||||
|
||||
```java
|
||||
/**
|
||||
* Value object representing a normalized email address.
|
||||
*
|
||||
* <p>The value is always lowercase and trimmed.
|
||||
*/
|
||||
public record UserEmail(String value) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 타입의 핵심 invariant를 문서화한다
|
||||
- typical reader가 놓치기 쉬운 제약을 설명한다
|
||||
|
||||
## 좋은 예시 3: override는 문서 상속 활용
|
||||
|
||||
```java
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 상위 계약이 충분하면 중복 문서를 쓰지 않는다
|
||||
- 불필요한 복붙 Javadoc을 줄인다
|
||||
|
||||
## 좋은 예시 4: package/class 수준에서 구조 설명
|
||||
|
||||
```java
|
||||
/**
|
||||
* HTTP request/response contracts and exception translation for the auth API.
|
||||
*
|
||||
* <p>This package owns controllers, request/response DTOs, and client-facing
|
||||
* error handling. It must not depend directly on infrastructure implementations.
|
||||
*/
|
||||
package com.project.auth.presentation;
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- package 책임과 금지사항이 드러난다
|
||||
- architecture 문서와 연결되는 설명이다
|
||||
|
||||
## 나쁜 예시 1: 자명한 getter 설명
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns the user name.
|
||||
*/
|
||||
public String getUserName() {
|
||||
return userName;
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 이름만 읽어도 알 수 있다
|
||||
- 유지보수 시 stale 될 가능성만 늘어난다
|
||||
|
||||
개선:
|
||||
|
||||
- 생략하거나
|
||||
- 정말 추가 계약이 있을 때만 적는다
|
||||
|
||||
## 나쁜 예시 2: 구현 설명만 적음
|
||||
|
||||
```java
|
||||
/**
|
||||
* Uses ArrayList internally and loops over all elements to find the user.
|
||||
*/
|
||||
public User findUser(String email) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 구현 세부에 과도하게 묶인다
|
||||
- 리팩터링 시 쉽게 거짓 문서가 된다
|
||||
|
||||
개선:
|
||||
|
||||
- 호출 계약, 검색 조건, 실패 조건을 설명한다
|
||||
|
||||
## 나쁜 예시 3: 태그만 채우는 문서
|
||||
|
||||
```java
|
||||
/**
|
||||
* @param email the email
|
||||
* @return the user
|
||||
*/
|
||||
public User findUser(String email) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 독자에게 새로운 정보가 없다
|
||||
- 형식만 있고 계약이 없다
|
||||
|
||||
개선:
|
||||
|
||||
- summary와 제약/의미를 써라
|
||||
- 아니면 생략하라
|
||||
|
||||
## 나쁜 예시 4: stale Javadoc 방치
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns a mutable list of authorities.
|
||||
*/
|
||||
public List<String> getAuthorities() {
|
||||
return List.copyOf(authorities);
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 코드와 문서가 충돌한다
|
||||
- 거짓 문서가 된다
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns an unmodifiable snapshot of authorities.
|
||||
*/
|
||||
public List<String> getAuthorities() {
|
||||
return List.copyOf(authorities);
|
||||
}
|
||||
```
|
||||
|
||||
또는 Javadoc 삭제 후 더 적절한 형태로 재작성
|
||||
@@ -0,0 +1,140 @@
|
||||
# null 처리 예시
|
||||
|
||||
이 문서는 [null 처리 기준](../../standards/language/null.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 `null`을 경계에서만 제한적으로 받고, 내부 로직에 들어가기 전에 non-null 값이나 명시적 상태로 정리하는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 생성자/경계에서 즉시 검증
|
||||
|
||||
```java
|
||||
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = Objects.requireNonNull(userRepository, "userRepository must not be null");
|
||||
this.passwordEncoder = Objects.requireNonNull(passwordEncoder, "passwordEncoder must not be null");
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- boundary에서 non-null 계약을 바로 강제한다.
|
||||
- 내부 필드는 이후 non-null로 다룰 수 있다.
|
||||
|
||||
## 좋은 예시 2: 외부 입력은 DTO에서 받고 내부에서 정리
|
||||
|
||||
```java
|
||||
public CreateUserCommand toCommand(CreateUserRequest request) {
|
||||
return new CreateUserCommand(
|
||||
UserEmail.from(request.email()),
|
||||
request.nickname() == null ? null : request.nickname().trim()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
더 좋은 경우:
|
||||
|
||||
- nullable `nickname`을 value object 또는 명시적 규칙으로 바로 정리한다.
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- nullable 입력이 boundary에 머문다.
|
||||
- 내부 의미로 들어가기 전에 정리할 수 있다.
|
||||
|
||||
## 좋은 예시 3: 컬렉션은 null 대신 empty 반환
|
||||
|
||||
```java
|
||||
public List<Role> findRoles(Long userId) {
|
||||
List<Role> roles = roleRepository.findAllByUserId(userId);
|
||||
return roles == null ? List.of() : roles;
|
||||
}
|
||||
```
|
||||
|
||||
더 좋은 경우:
|
||||
|
||||
- repository 계약 자체를 null이 아닌 empty 반환으로 고정한다.
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 호출자가 불필요한 null-check를 하지 않아도 된다.
|
||||
|
||||
## 좋은 예시 4: persistence -> domain 변환에서 nullable 차단
|
||||
|
||||
```java
|
||||
public User toDomain(UserJpaEntity entity) {
|
||||
return User.restore(
|
||||
Objects.requireNonNull(entity.getId(), "id must not be null"),
|
||||
UserEmail.from(Objects.requireNonNull(entity.getEmail(), "email must not be null")),
|
||||
Objects.requireNonNull(entity.getEncodedPassword(), "encodedPassword must not be null"),
|
||||
UserName.from(Objects.requireNonNull(entity.getName(), "name must not be null"))
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- DB nullable/오염 상태를 domain으로 전파하지 않는다.
|
||||
- invariant 경계가 분명하다.
|
||||
|
||||
## 나쁜 예시 1: Optional과 null 혼용
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
if (email == null) {
|
||||
return null;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `Optional` 반환 계약을 깨뜨린다.
|
||||
- 호출자는 `Optional`과 `null`을 동시에 처리해야 한다.
|
||||
|
||||
개선:
|
||||
|
||||
- null 입력 자체를 검증한다.
|
||||
- 또는 `Optional.empty()`를 반환한다.
|
||||
- 또는 파라미터를 non-null로 강제한다.
|
||||
|
||||
## 나쁜 예시 2: null을 business 의미로 사용
|
||||
|
||||
```java
|
||||
if (user.getProvider() == null) {
|
||||
// local user
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- business state가 `null`에 숨는다.
|
||||
- 의미가 타입으로 드러나지 않는다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
if (user.getProvider() == AuthProvider.LOCAL) {
|
||||
// local user
|
||||
}
|
||||
```
|
||||
|
||||
또는 명시적 enum/state를 사용한다.
|
||||
|
||||
## 나쁜 예시 3: 여러 계층으로 nullable 전파
|
||||
|
||||
```java
|
||||
public String handle(String nickname) {
|
||||
return service.process(nickname);
|
||||
}
|
||||
|
||||
public String process(String nickname) {
|
||||
return repository.saveNickname(nickname);
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- nullable 여부가 계약으로 명시되지 않는다.
|
||||
- 모든 계층이 방어 책임을 떠넘긴다.
|
||||
|
||||
개선:
|
||||
|
||||
- boundary에서 검증/정규화한다.
|
||||
- nullable이면 `Optional`, value object, 명시적 command로 변환한다.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Optional 사용 예시
|
||||
|
||||
이 문서는 [Optional 사용 기준](../../standards/language/optional.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 `Optional`을 값의 부재가 가능한 단건 반환 타입에 쓰고, 필드/파라미터/직렬화 경계에는 기본적으로 쓰지 않는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 단건 조회 결과 없음 표현
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return userRepository.findByEmail(email);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 단건 조회 결과의 부재를 반환 타입에서 명시한다.
|
||||
- 호출자에게 "없을 수 있음"을 강제한다.
|
||||
|
||||
## 좋은 예시 2: transform chain
|
||||
|
||||
```java
|
||||
public Optional<String> findActiveUserEmail(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `isPresent() + get()` 없이 선언적으로 표현한다.
|
||||
- 값이 없으면 자연스럽게 empty로 전파된다.
|
||||
|
||||
## 좋은 예시 3: nested Optional 방지
|
||||
|
||||
```java
|
||||
public Optional<Token> resolveToken(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.flatMap(tokenService::findValidToken);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `flatMap`으로 `Optional<Optional<Token>>`를 만들지 않는다.
|
||||
|
||||
## 좋은 예시 4: expensive default는 orElseGet
|
||||
|
||||
```java
|
||||
UserProfile profile = profileRepository.findByUserId(userId)
|
||||
.orElseGet(() -> profileFactory.createDefault(userId));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 기본값 생성 비용이 있을 때 lazy supplier를 사용한다.
|
||||
|
||||
## 좋은 예시 5: 단건은 Optional, 다건은 빈 컬렉션
|
||||
|
||||
```java
|
||||
public List<Role> findRoles(Long userId) {
|
||||
return roleRepository.findAllByUserId(userId);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 다건 결과의 부재를 `Optional<List<Role>>`로 감싸지 않는다.
|
||||
- 호출자는 빈 컬렉션으로 처리하면 된다.
|
||||
|
||||
## 나쁜 예시 1: Optional 반환인데 null 반환
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `Optional` 자체가 `null`이 되어 의미가 깨진다.
|
||||
- 호출자는 `Optional`과 `null`을 둘 다 처리해야 한다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return Optional.empty();
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: 필드에 Optional 저장
|
||||
|
||||
```java
|
||||
public class UserResponse {
|
||||
private Optional<String> nickname;
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- DTO 경계에서 표현이 복잡해진다.
|
||||
- 직렬화/스키마/API 계약이 불명확해질 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public class UserResponse {
|
||||
private String nickname;
|
||||
}
|
||||
```
|
||||
|
||||
또는 nullable 여부를 API 계약에서 명시한다.
|
||||
|
||||
## 나쁜 예시 3: 파라미터에 Optional 사용
|
||||
|
||||
```java
|
||||
public User createUser(Optional<String> nickname) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 호출자가 `Optional.empty()`와 `null` 실수를 섞기 쉽다.
|
||||
- 오버로드/명시적 request object보다 의도가 약하다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public User createUser(String nickname) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
또는:
|
||||
|
||||
```java
|
||||
public User createUser(CreateUserCommand command) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 4: isPresent + get
|
||||
|
||||
```java
|
||||
if (userOpt.isPresent()) {
|
||||
return userOpt.get().getEmail();
|
||||
}
|
||||
return "unknown";
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- imperative null-check와 다를 바 없는 패턴이다.
|
||||
- `get()` 의존이 생긴다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
return userOpt.map(User::getEmail)
|
||||
.orElse("unknown");
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: map 결과를 안 쓰고 side-effect
|
||||
|
||||
```java
|
||||
userOpt.map(user -> {
|
||||
audit(user.getId());
|
||||
return user;
|
||||
});
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `map`은 값 변환인데 반환값을 사용하지 않는다.
|
||||
- side-effect 목적이면 `ifPresent`가 더 맞다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
userOpt.ifPresent(user -> audit(user.getId()));
|
||||
```
|
||||
@@ -0,0 +1,150 @@
|
||||
# Stream 사용 예시
|
||||
|
||||
이 문서는 [Stream 사용 기준](../../standards/language/stream.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 Stream을 집계, 변환, 검색 파이프라인에 쓰고, 외부 상태 변경이나 필수 부작용에는 쓰지 않는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 조회 + 변환 + 불변 결과
|
||||
|
||||
```java
|
||||
List<String> activeEmails = users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.toList();
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 집계/변환 파이프라인이다.
|
||||
- side-effect가 없다.
|
||||
- 결과가 명확하다.
|
||||
- 읽는 사람이 "무엇을 만들었는지" 바로 이해할 수 있다.
|
||||
|
||||
## 좋은 예시 2: 존재 여부 판단
|
||||
|
||||
```java
|
||||
boolean hasExpiredToken = tokens.stream()
|
||||
.anyMatch(Token::isExpired);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- for 루프보다 의도가 직접적이다.
|
||||
- short-circuit terminal operation이라 불필요한 순회를 줄일 수 있다.
|
||||
|
||||
## 좋은 예시 3: 그룹화
|
||||
|
||||
```java
|
||||
Map<AuthProvider, List<User>> usersByProvider = users.stream()
|
||||
.collect(Collectors.groupingBy(User::getProvider));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- grouping이 핵심인 aggregate operation이다.
|
||||
- 외부 mutable map을 직접 관리하지 않는다.
|
||||
|
||||
## 좋은 예시 4: 숫자 집계는 primitive stream 사용
|
||||
|
||||
```java
|
||||
int totalQuantity = orderLines.stream()
|
||||
.mapToInt(OrderLine::getQuantity)
|
||||
.sum();
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 숫자 집계 의도가 분명하다.
|
||||
- boxed `Integer` stream보다 표현이 명확하다.
|
||||
|
||||
## 좋은 예시 5: 구체 컬렉션 타입이 필요할 때만 toCollection
|
||||
|
||||
```java
|
||||
LinkedHashSet<String> roles = authorities.stream()
|
||||
.map(Authority::getRole)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 결과 타입 요구사항이 있을 때만 명시적으로 선택한다.
|
||||
- `Collectors.toList()`의 구현/가변성에 기대지 않는다.
|
||||
|
||||
## 좋은 예시 6: I/O 기반 stream은 닫기
|
||||
|
||||
```java
|
||||
try (Stream<String> lines = Files.lines(path)) {
|
||||
List<String> words = lines
|
||||
.flatMap(line -> Stream.of(line.split("\\s+")))
|
||||
.filter(word -> !word.isBlank())
|
||||
.toList();
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- I/O 기반 stream을 명시적으로 닫는다.
|
||||
- transform pipeline과 resource lifecycle이 분리되어 있다.
|
||||
|
||||
## 나쁜 예시 1: 외부 mutable accumulator 사용
|
||||
|
||||
```java
|
||||
List<String> emails = new ArrayList<>();
|
||||
users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.forEach(emails::add);
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 불필요한 side-effect가 있다.
|
||||
- 병렬화나 리팩터링에 취약하다.
|
||||
- reduction/collection으로 더 안전하게 표현할 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
List<String> emails = users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.toList();
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: 상태를 가진 람다
|
||||
|
||||
```java
|
||||
AtomicInteger seq = new AtomicInteger(0);
|
||||
|
||||
List<UserView> result = users.stream()
|
||||
.map(user -> new UserView(seq.incrementAndGet(), user.getEmail()))
|
||||
.toList();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 람다가 외부 상태에 의존한다.
|
||||
- 병렬 스트림이나 리팩터링 시 의미가 불안정하다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 순번이 비즈니스적으로 필요하면 스트림 밖에서 명시적으로 설계한다.
|
||||
- 단순 변환이면 순번 생성을 제거한다.
|
||||
|
||||
## 나쁜 예시 3: 비즈니스 로직에서 peek 사용
|
||||
|
||||
```java
|
||||
List<User> result = users.stream()
|
||||
.peek(user -> audit("USER_READ", user.getId()))
|
||||
.filter(User::isActive)
|
||||
.toList();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `peek`를 필수 부작용 채널로 쓰고 있다.
|
||||
- 최적화/재구성 시 기대가 깨질 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- audit가 필수라면 terminal boundary에서 명시적으로 처리한다.
|
||||
- `peek`는 임시 디버깅 용도로만 제한한다.
|
||||
@@ -0,0 +1,221 @@
|
||||
# time 타입 / 포맷 예시
|
||||
|
||||
이 문서는 [time 타입 / 포맷 기준](../../standards/language/time.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 시간 값을 문자열이나 숫자로 들고 다니지 않고, 시점 / 날짜 / 시각 / 기간 / 시간대 의미에 맞는 `java.time` 타입으로 표현하는 것입니다.
|
||||
|
||||
## 좋은 예시 1: event timestamp는 Instant
|
||||
|
||||
```java
|
||||
public record AuditEvent(
|
||||
String action,
|
||||
Long userId,
|
||||
Instant occurredAt
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- timeline 위 한 점을 명확하게 표현한다.
|
||||
- 로깅, 저장, 비교에 적합하다.
|
||||
|
||||
## 좋은 예시 2: 사람 기준 날짜 의미는 LocalDate / YearMonth
|
||||
|
||||
```java
|
||||
public record UserProfile(
|
||||
LocalDate birthDate,
|
||||
YearMonth cardExpiry
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 시간대와 무관한 사람 기준 날짜 의미를 타입으로 드러낸다.
|
||||
- 생일과 카드 만료월처럼 서로 다른 날짜 의미를 구분한다.
|
||||
|
||||
## 좋은 예시 3: 현재 시각은 Clock 기반
|
||||
|
||||
```java
|
||||
public class TokenIssuer {
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
public TokenIssuer(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public Instant issueTime() {
|
||||
return Instant.now(clock);
|
||||
}
|
||||
|
||||
public Instant expiryTime(Duration ttl) {
|
||||
return Instant.now(clock).plus(ttl);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 현재 시각을 고정해 테스트하기 쉽다.
|
||||
- static `now()` 호출이 코드 곳곳에 흩어지지 않는다.
|
||||
|
||||
## 좋은 예시 4: 시간 간격은 Duration
|
||||
|
||||
```java
|
||||
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(30);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `1800` 같은 매직 숫자보다 의미가 분명하다.
|
||||
- 초, 밀리초, 분 단위 혼동이 줄어든다.
|
||||
|
||||
## 좋은 예시 5: 외부 응답 포맷은 경계에서 처리
|
||||
|
||||
```java
|
||||
String value = DateTimeFormatter.ISO_INSTANT.format(event.occurredAt());
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 내부 로직은 `Instant`를 유지한다.
|
||||
- 문자열 포맷은 serialization, logging, external API adapter 같은 boundary에서만 수행한다.
|
||||
|
||||
## 좋은 예시 6: 실제 zone 계산이 필요할 때만 ZonedDateTime
|
||||
|
||||
```java
|
||||
ZonedDateTime reservationTime = localReservationTime.atZone(ZoneId.of("Asia/Seoul"));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 서울 지역 wall-clock 시간이라는 의미가 필요할 때만 zone을 붙인다.
|
||||
- 시간대 규칙이 필요한 계산임을 코드에 드러낸다.
|
||||
|
||||
## 나쁜 예시 1: createdAt을 LocalDateTime으로 저장
|
||||
|
||||
```java
|
||||
private LocalDateTime createdAt;
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 절대 시점이 아니라 zone/offset 없는 wall-clock 값이 된다.
|
||||
- 시스템 간 교환, 저장, 비교에서 의미가 흔들린다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
private Instant createdAt;
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: business logic에서 기본 시스템 zone 의존
|
||||
|
||||
```java
|
||||
LocalDate today = LocalDate.now();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- JVM 기본 time-zone에 암묵적으로 의존한다.
|
||||
- 테스트와 운영 환경에 따라 결과가 달라질 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
```
|
||||
|
||||
또는:
|
||||
|
||||
```java
|
||||
LocalDate today = LocalDate.now(zoneId);
|
||||
```
|
||||
|
||||
## 나쁜 예시 3: 문자열로 시간 비교
|
||||
|
||||
```java
|
||||
if (request.startTime().compareTo("09:00") >= 0) {
|
||||
// open
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 타입 의미가 사라진다.
|
||||
- 포맷 변화에 취약하다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
if (!request.startTime().isBefore(LocalTime.of(9, 0))) {
|
||||
// open
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 4: legacy API 사용
|
||||
|
||||
```java
|
||||
Date now = new Date();
|
||||
Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + 1_800_000);
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 새 코드 기준으로 `java.time`보다 의미가 덜 명확하다.
|
||||
- 시간 단위와 시스템 clock 의존이 코드에 흩어진다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
Instant now = Instant.now(clock);
|
||||
Instant expiresAt = now.plus(Duration.ofMinutes(30));
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: wall-clock 의미인데 Instant 남용
|
||||
|
||||
```java
|
||||
public record StoreHours(
|
||||
Instant opensAt,
|
||||
Instant closesAt
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 영업 시작/종료는 보통 지역 wall-clock 의미다.
|
||||
- 절대 시점 타입이 도메인 의미를 흐린다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public record StoreHours(
|
||||
LocalTime opensAt,
|
||||
LocalTime closesAt
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 6: Instant를 DTO에서 문자열로 직접 조립
|
||||
|
||||
```java
|
||||
public record TokenResponse(
|
||||
String expiresAt
|
||||
) {
|
||||
public static TokenResponse from(Instant expiresAt) {
|
||||
return new TokenResponse(expiresAt.toString());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- DTO 조립 코드가 시간 포맷 정책을 직접 가진다.
|
||||
- 응답 포맷 변경이 여러 DTO 생성 코드로 퍼질 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- response serialization 설정이나 전용 formatter 경계에서 포맷한다.
|
||||
- 내부 모델과 유스케이스 결과는 `Instant` 같은 typed value를 유지한다.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Exception Log 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 대표 실패만 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(ExternalAuthUnavailableException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleExternalAuthUnavailable(
|
||||
ExternalAuthUnavailableException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
log.error("Failed request. requestPath={} method={} errorCode={} actorId={}",
|
||||
request.getRequestURI(),
|
||||
request.getMethod(),
|
||||
ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE.code(),
|
||||
request.getAttribute("actorId"),
|
||||
ex);
|
||||
|
||||
ErrorCode errorCode = ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대표 ERROR 로그가 한 곳에 모인다
|
||||
- 메시지와 응답 코드가 분리된다
|
||||
- 운영 키와 stack trace가 함께 남는다
|
||||
|
||||
### 예시 2. 재시도 중간 실패는 WARN 또는 DEBUG로만 남긴다
|
||||
|
||||
```java
|
||||
try {
|
||||
return externalAuthClient.issueToken(command);
|
||||
} catch (SocketTimeoutException ex) {
|
||||
log.warn("External auth attempt failed. provider={} actorId={} attempt={}",
|
||||
"keycloak", command.actorId(), attemptNumber);
|
||||
throw ex;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 중간 실패를 곧바로 대표 장애처럼 기록하지 않는다
|
||||
- 재시도 맥락이 드러난다
|
||||
|
||||
### 예시 3. validation 실패는 ERROR로 과장하지 않는다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
log.info("Rejected invalid request. requestPath={} method={} actorId={}",
|
||||
request.getRequestURI(),
|
||||
request.getMethod(),
|
||||
request.getAttribute("actorId"));
|
||||
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResult.fail(ErrorCode.REQUEST_VALIDATION_FAILED, errors));
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예상 가능한 4xx를 서버 장애처럼 기록하지 않는다
|
||||
- 필요한 요청 맥락은 남긴다
|
||||
|
||||
### 예시 4. 메시지는 사건 설명 중심으로 쓴다
|
||||
|
||||
```java
|
||||
log.error("Failed external auth request. provider={} requestPath={} actorId={} durationMs={}",
|
||||
provider, requestPath, actorId, durationMs, ex);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 무엇이 실패했는지 바로 보인다
|
||||
- 예외 메시지 품질에 로그 제목이 종속되지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 같은 예외를 여러 레이어에서 반복 ERROR로 찍는다
|
||||
|
||||
```java
|
||||
log.error("Client failed", ex);
|
||||
log.error("Service failed", ex);
|
||||
log.error("Controller failed", ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 한 실패가 여러 번 기록된다
|
||||
- 검색/알림/집계 품질이 나빠진다
|
||||
|
||||
### 예시 2. 예외 메시지를 그대로 제목으로 쓴다
|
||||
|
||||
```java
|
||||
log.error(ex.getMessage(), ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 사건 맥락이 없다
|
||||
- 민감정보가 메시지에 섞일 수 있다
|
||||
- 운영 키가 없다
|
||||
|
||||
### 예시 3. 요청 본문 전체를 예외 로그에 남긴다
|
||||
|
||||
```java
|
||||
log.error("Failed create user request. requestBody={}", requestBody, ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PII/비밀번호/토큰이 유출될 수 있다
|
||||
- payload 전문 로그는 기본 금지다
|
||||
|
||||
### 예시 4. 예상 가능한 business rejection을 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
log.error("Duplicate email sign-up attempt. email={}", request.email());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 서버 장애처럼 과장된다
|
||||
- 개인식별정보 전체값이 그대로 남는다
|
||||
- 운영 신호를 오염시킨다
|
||||
@@ -0,0 +1,82 @@
|
||||
# Log Level 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 최종 실패만 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
try {
|
||||
externalAuthClient.issueToken(command);
|
||||
} catch (ExternalAuthException ex) {
|
||||
log.error("Failed to issue external auth token. provider={}, actorId={}", "auth-provider", command.actorId(), ex);
|
||||
throw ex;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 최종 실패를 명확히 드러낸다
|
||||
- 운영자가 바로 봐야 할 사건이다
|
||||
|
||||
### 예시 2. 재시도 후 성공은 WARN으로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("External auth request succeeded after retry. provider={}, actorId={}, attempts={}",
|
||||
"auth-provider", actorId, attemptCount);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 즉시 실패는 아니지만 이상 징후다
|
||||
- 운영 추적 가치가 있다
|
||||
|
||||
### 예시 3. 상세 분기 정보는 DEBUG에 둔다
|
||||
|
||||
```java
|
||||
log.debug("Mapped external auth response to internal token result. provider={}, tokenType={}",
|
||||
"auth-provider", response.tokenType());
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 상세 흐름 파악용이다
|
||||
- 기본 운영 레벨에서는 숨겨진다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 예상 가능한 비즈니스 거절을 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
log.error("Duplicate email sign-up attempt. email={}", request.email());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 서버 장애처럼 과장된다
|
||||
- 실제 운영 신호가 묻힌다
|
||||
|
||||
### 예시 2. 같은 예외를 여러 계층에서 모두 ERROR로 찍는다
|
||||
|
||||
```java
|
||||
log.error("Repository failed", ex);
|
||||
log.error("Service failed", ex);
|
||||
log.error("Controller failed", ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 한 실패가 세 번 기록된다
|
||||
- 검색/알림/분석 품질이 떨어진다
|
||||
|
||||
### 예시 3. production 상시 로그에 과도한 상세를 남긴다
|
||||
|
||||
```java
|
||||
log.info("Request payload={}", requestBody);
|
||||
log.info("Response payload={}", responseBody);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 노이즈가 많다
|
||||
- 민감정보 노출 위험이 크다
|
||||
- INFO 레벨 의미를 무너뜨린다
|
||||
@@ -0,0 +1,102 @@
|
||||
# Log Message Format 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 사건 설명 + key-value 필드를 함께 남긴다
|
||||
|
||||
```java
|
||||
log.info("Created user. actorId={} userId={} requestPath={}",
|
||||
actorId, userId, requestPath);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 사건이 짧게 드러난다
|
||||
- 검색 가능한 핵심 키가 있다
|
||||
- free text만으로 끝나지 않는다
|
||||
|
||||
### 예시 2. 실패 메시지에 운영 키를 먼저 담고 예외를 붙인다
|
||||
|
||||
```java
|
||||
log.error("Failed external auth request. provider={} actorId={} durationMs={}",
|
||||
provider, actorId, durationMs, ex);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 메시지 자체만 봐도 무엇이 실패했는지 알 수 있다
|
||||
- stack trace는 추가 정보로 붙는다
|
||||
- 운영 키가 빠지지 않는다
|
||||
|
||||
### 예시 3. 재시도/대체 경로도 명시적 사건으로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("Applied external auth fallback. provider={} actorId={} fallback={} durationMs={}",
|
||||
provider, actorId, "cached-public-key", durationMs);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- fallback 발생 사실이 바로 드러난다
|
||||
- 이후 검색과 집계가 쉽다
|
||||
|
||||
### 예시 4. structured logging 전환을 고려한 키 이름을 쓴다
|
||||
|
||||
```java
|
||||
log.info("Completed session cleanup. job={} deletedCount={} durationMs={}",
|
||||
"expired-session-cleanup", deletedCount, durationMs);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 텍스트 로그에서도 구조가 보인다
|
||||
- JSON 로그로 전환해도 의미가 유지된다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 설명만 길고 검색 키가 없다
|
||||
|
||||
```java
|
||||
log.info("The user registration process was completed successfully after all checks had been passed");
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 누가, 어떤 요청에서, 어떤 리소스가 생성됐는지 알 수 없다
|
||||
- 검색/집계가 어렵다
|
||||
|
||||
### 예시 2. 예외 메시지를 그대로 제목으로 쓴다
|
||||
|
||||
```java
|
||||
log.error(ex.getMessage(), ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 사건 맥락이 없다
|
||||
- 운영 키가 없다
|
||||
- 예외 메시지 품질에 로그 제목이 종속된다
|
||||
|
||||
### 예시 3. 민감정보를 그대로 남긴다
|
||||
|
||||
```java
|
||||
log.debug("Login request. email={} password={} accessToken={}",
|
||||
request.email(), request.password(), accessToken);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 민감정보가 원문으로 노출된다
|
||||
- 디버그 로그라도 허용되지 않는다
|
||||
|
||||
### 예시 4. 같은 의미를 제각각 다른 키 이름으로 쓴다
|
||||
|
||||
```java
|
||||
log.info("Created user. uid={} path={} timeMs={}", userId, requestPath, durationMs);
|
||||
log.info("Deleted user. userId={} requestUri={} duration={}", userId, requestPath, durationMs);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 의미의 키 이름이 섞인다
|
||||
- 검색/집계/알람 규칙이 복잡해진다
|
||||
@@ -0,0 +1,150 @@
|
||||
# Operation Indicator / Health Check 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 기본 actuator health와 probe 경로를 그대로 사용한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: 8081
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: 8081
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring Boot 기본 probe group을 그대로 사용한다
|
||||
- Kubernetes가 기대하는 liveness/readiness 의미와 맞다.
|
||||
|
||||
### 예시 2. management 포트 분리 시 main port에도 /livez, /readyz를 노출한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
server:
|
||||
port: 8081
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
add-additional-paths: true
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- actuator 전용 포트만 살아 있고 실제 애플리케이션 포트는 문제인 상황을 줄일 수 있다
|
||||
- Spring Boot도 이 구성을 좋은 아이디어로 안내한다.
|
||||
|
||||
### 예시 3. readiness에만 필수 내부 준비 상태를 추가한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
group:
|
||||
readiness:
|
||||
include: "readinessState,customCheck"
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- readiness에 필요한 추가 체크만 명시적으로 포함한다
|
||||
- liveness와 readiness를 구분해서 설계한다.
|
||||
|
||||
### 예시 4. startup이 긴 서비스에는 startup probe를 둔다
|
||||
|
||||
```yaml
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: 8080
|
||||
failureThreshold: 30
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 느린 시작 중 liveness 오탐을 줄일 수 있다
|
||||
- startup probe는 성공 전까지 liveness/readiness 실행을 지연시킨다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. liveness에 DB 상태를 직접 넣는다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadDatabaseLivenessIndicator implements HealthIndicator {
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
return databaseClient.ping() ? Health.up().build() : Health.down().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 외부 DB 장애가 모든 인스턴스 재시작으로 이어질 수 있다
|
||||
- Spring Boot는 liveness를 외부 체크 기반으로 두지 말라고 권고한다.
|
||||
|
||||
### 예시 2. 모든 외부 시스템을 readiness에 무조건 포함한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
group:
|
||||
readiness:
|
||||
include: "readinessState,db,redis,kafka,s3,externalApiA,externalApiB"
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공유 외부 시스템 장애 시 전체 인스턴스가 동시에 ready=false가 될 수 있다
|
||||
- fallback 가능한 비필수 시스템도 서비스 제외 원인이 된다.
|
||||
|
||||
### 예시 3. probe 용 controller를 별도로 만든다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
public class BadHealthController {
|
||||
|
||||
@GetMapping("/health")
|
||||
public Map<String, Object> health() {
|
||||
return Map.of("status", "UP");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- actuator가 이미 제공하는 운영 계약과 분리된다
|
||||
- liveness/readiness/group 정책과 연계되지 않는다
|
||||
- health semantics를 임의 JSON으로 약화시킨다.
|
||||
|
||||
### 예시 4. health indicator에서 무거운 쿼리를 수행한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadSlowHealthIndicator implements HealthIndicator {
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
analyticsRepository.runExpensiveAggregation();
|
||||
return Health.up().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- health endpoint 자체가 느려진다
|
||||
- Spring Boot도 느린 indicator를 warning 대상으로 본다.
|
||||
@@ -0,0 +1,122 @@
|
||||
# PII Masking 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 내부 식별자만 로그에 남긴다
|
||||
|
||||
```java
|
||||
log.info("Completed password reset request. actorId={} requestPath={}",
|
||||
actorId, requestPath);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 누구의 요청인지는 추적 가능하다
|
||||
- 이메일/전화번호/비밀번호는 남기지 않는다
|
||||
|
||||
### 예시 2. 토큰은 일부만 식별 가능하게 남긴다
|
||||
|
||||
```java
|
||||
String maskedToken = TokenMasker.prefix(token);
|
||||
log.warn("Rejected external callback due to invalid token. provider={} tokenPrefix={}",
|
||||
provider, maskedToken);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 토큰 전체 원문을 남기지 않는다
|
||||
- 운영상 일부 식별은 가능하다
|
||||
|
||||
### 예시 3. 이메일은 부분 마스킹한다
|
||||
|
||||
```java
|
||||
String maskedEmail = EmailMasker.mask(request.email());
|
||||
log.info("Started email verification. actorId={} email={}",
|
||||
actorId, maskedEmail);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 메일 발송 대상 추적은 가능하다
|
||||
- 개인 식별정보 전체값을 남기지 않는다
|
||||
|
||||
### 예시 4. structured logging에도 안전한 필드만 넣는다
|
||||
|
||||
```java
|
||||
MDC.put("actorId", actorId);
|
||||
MDC.put("requestPath", request.getRequestURI());
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 운영 상관관계 필드는 남긴다
|
||||
- token/session/password 같은 값은 MDC에 올리지 않는다
|
||||
|
||||
### 예시 5. 외부 오류 메시지는 정제해서 남긴다
|
||||
|
||||
```java
|
||||
log.error("Failed external auth request. provider={} status={} errorCode={}",
|
||||
provider, status, "UPSTREAM_AUTH_SERVER_UNAVAILABLE", ex);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 시스템 에러 본문 원문을 그대로 노출하지 않는다
|
||||
- 운영 키와 표준 에러 코드 중심으로 남긴다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. Authorization 헤더를 그대로 남긴다
|
||||
|
||||
```java
|
||||
log.debug("Incoming request. authorization={}", request.getHeader("Authorization"));
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- access token 원문이 로그로 유출된다
|
||||
- 디버그 로그라도 허용되지 않는다
|
||||
|
||||
### 예시 2. request body 전체를 남긴다
|
||||
|
||||
```java
|
||||
log.info("Create user request body={}", requestBody);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 비밀번호, 이메일, 전화번호 등 민감값이 함께 들어갈 수 있다
|
||||
- 운영 로그 노이즈도 크다
|
||||
|
||||
### 예시 3. 세션 ID를 그대로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("Invalid session. sessionId={}", sessionId);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 세션 식별값 원문이 노출된다
|
||||
- OWASP도 세션 식별값은 직접 로그에 남기지 말라고 권고한다.
|
||||
|
||||
### 예시 4. 예외 메시지를 그대로 제목으로 쓴다
|
||||
|
||||
```java
|
||||
log.error(ex.getMessage(), ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 예외 메시지 안 민감정보가 그대로 노출될 수 있다
|
||||
- 사건 설명과 안전한 운영 키가 없다
|
||||
|
||||
### 예시 5. 외부 입력을 정제 없이 로그에 넣는다
|
||||
|
||||
```java
|
||||
log.warn("Rejected request. keyword={}", request.getParameter("keyword"));
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 줄바꿈/제어문자/악성 문자열이 로그 형식을 깨뜨릴 수 있다
|
||||
- 민감 검색어가 그대로 남을 수 있다
|
||||
@@ -0,0 +1,161 @@
|
||||
# Trace / Principal / Path Recording 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. controller는 현재 사용자 식별자를 명시적으로 받는다
|
||||
|
||||
```java
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@AuthenticationPrincipal(expression = "userId")
|
||||
public @interface CurrentUserId {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@CurrentUserId String actorId,
|
||||
@PathVariable String userId
|
||||
) {
|
||||
UserResult result = userQueryUseCase.getUser(actorId, userId);
|
||||
return ApiResult.success(new UserResponse(result.userId(), result.email()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- principal 접근이 controller 시그니처에서 드러난다
|
||||
- SecurityContextHolder 직접 접근이 없다
|
||||
- application에는 최소 actor 정보만 전달한다
|
||||
|
||||
### 예시 2. 공통 요청 로그는 한 곳에서 남긴다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class RequestLoggingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
long startNanos = System.nanoTime();
|
||||
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
long durationMs = Duration.ofNanos(System.nanoTime() - startNanos).toMillis();
|
||||
|
||||
log.info("Completed request. requestPath={} method={} status={} durationMs={}",
|
||||
request.getRequestURI(),
|
||||
request.getMethod(),
|
||||
response.getStatus(),
|
||||
durationMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대표 요청 로그가 공통 위치에 있다
|
||||
- requestPath와 method/status/duration이 일관되게 남는다
|
||||
- controller마다 요청 로그를 복붙하지 않는다
|
||||
|
||||
### 예시 3. principal은 내부 식별자만 남긴다
|
||||
|
||||
```java
|
||||
log.warn("Rejected request. requestPath={} actorId={} errorCode={}",
|
||||
requestPath, actorId, "ACCESS_DENIED");
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 이메일/토큰 같은 민감정보 대신 내부 식별자를 남긴다
|
||||
- principal 검색 가능성과 개인정보 보호를 함께 고려한다
|
||||
|
||||
### 예시 4. route template를 별도 필드로 둘 수 있다
|
||||
|
||||
```java
|
||||
log.info("Completed request. requestPath={} route={} method={} status={} durationMs={}",
|
||||
"/api/v1/users/123",
|
||||
"/api/v1/users/{userId}",
|
||||
"GET",
|
||||
200,
|
||||
21);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 실제 요청과 집계용 route를 분리할 수 있다
|
||||
- 고카디널리티 문제를 운영에서 다루기 쉬워진다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 SecurityContextHolder를 직접 읽는다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/v1/me")
|
||||
public ApiResult<String> me() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
|
||||
|
||||
log.info("Current request. path={} principal={}", request.getRequestURI(), principal);
|
||||
return ApiResult.success(principal.getUserId());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- principal 접근과 로깅 규칙이 controller에 퍼진다
|
||||
- principal 전체 객체가 로그에 노출될 수 있다
|
||||
- current user 접근 방식이 일관되지 않다
|
||||
|
||||
### 예시 2. query string 전체를 기본 로그에 남긴다
|
||||
|
||||
```java
|
||||
log.info("Incoming request. requestPath={} query={}",
|
||||
request.getRequestURI(),
|
||||
request.getQueryString());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 검색어, 토큰, 식별자 등 민감정보가 섞일 수 있다
|
||||
- 운영 로그에 노이즈가 많아진다
|
||||
|
||||
### 예시 3. 같은 의미를 여러 키 이름으로 섞는다
|
||||
|
||||
```java
|
||||
log.info("Completed request. uri={} userId={}", requestPath, actorId);
|
||||
log.info("Failed request. path={} principalId={}", requestPath, actorId);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- uri/path/requestPath, userId/actorId/principalId가 혼용된다
|
||||
- 검색/집계 규칙이 깨진다
|
||||
|
||||
### 예시 4. 요청 로그를 여러 레이어에서 반복한다
|
||||
|
||||
```java
|
||||
log.info("Controller request. requestPath={}", requestPath);
|
||||
log.info("Service request. requestPath={}", requestPath);
|
||||
log.info("Client request. requestPath={}", requestPath);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 대표 요청 로그가 중복된다
|
||||
- 실제 중요한 비즈니스/연동 로그가 묻힌다
|
||||
@@ -0,0 +1,171 @@
|
||||
# AOP 예시
|
||||
|
||||
## 좋은 예시 1: 실행 시간 측정
|
||||
|
||||
```java
|
||||
@Aspect
|
||||
@Component
|
||||
public class TimingAspect {
|
||||
|
||||
@Pointcut("execution(public * com.project.auth.application..*(..))")
|
||||
public void applicationOperation() {}
|
||||
|
||||
@Around("applicationOperation()")
|
||||
public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
return joinPoint.proceed();
|
||||
} finally {
|
||||
long elapsed = System.nanoTime() - start;
|
||||
log.info("method={} elapsedNanos={}", joinPoint.getSignature(), elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 횡단 관심사인 timing만 다룬다
|
||||
- 비즈니스 로직을 바꾸지 않는다
|
||||
- pointcut이 이름 있는 작은 단위다
|
||||
|
||||
## 좋은 예시 2: 예외 기록
|
||||
|
||||
```java
|
||||
@Aspect
|
||||
@Component
|
||||
public class ExceptionLoggingAspect {
|
||||
|
||||
@Pointcut("execution(public * com.project.auth.application..*(..))")
|
||||
public void applicationOperation() {}
|
||||
|
||||
@AfterThrowing(pointcut = "applicationOperation()", throwing = "exception")
|
||||
public void logFailure(Exception exception) {
|
||||
log.error("application failure", exception);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 예외를 숨기지 않고 기록만 한다
|
||||
- 비즈니스 의미를 변경하지 않는다
|
||||
|
||||
## 좋은 예시 3: annotation 기반 감사
|
||||
|
||||
```java
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface Audited {
|
||||
String action();
|
||||
}
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
public class AuditAspect {
|
||||
|
||||
@Around("@annotation(audited)")
|
||||
public Object audit(ProceedingJoinPoint joinPoint, Audited audited) throws Throwable {
|
||||
Object result = joinPoint.proceed();
|
||||
auditLog.record(audited.action(), joinPoint.getSignature().toShortString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- annotation으로 의도를 드러낸다
|
||||
- 횡단 concern만 수행한다
|
||||
- service 흐름을 숨기지 않는다
|
||||
|
||||
## 좋은 예시 4: named pointcut 조합
|
||||
|
||||
```java
|
||||
@Aspect
|
||||
@Component
|
||||
public class CommonPointcuts {
|
||||
|
||||
@Pointcut("execution(public * *(..))")
|
||||
public void publicMethod() {}
|
||||
|
||||
@Pointcut("within(com.project.auth.application..*)")
|
||||
public void inApplicationLayer() {}
|
||||
|
||||
@Pointcut("publicMethod() && inApplicationLayer()")
|
||||
public void applicationPublicOperation() {}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 작은 pointcut을 조합한다
|
||||
- 범위를 읽고 설명하기 쉽다
|
||||
|
||||
## 나쁜 예시 1: 핵심 비즈니스 로직을 AOP로 이동
|
||||
|
||||
```java
|
||||
@Around("execution(* ..LoginService.login(..))")
|
||||
public Object issueTokenAndSaveAudit(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 핵심 use case 흐름이 숨는다
|
||||
- 코드 추적이 어려워진다
|
||||
- 서비스가 해야 할 결정을 aspect가 가져간다
|
||||
|
||||
## 나쁜 예시 2: self-invocation 기대
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class SampleService {
|
||||
|
||||
public void foo() {
|
||||
this.bar(); // aspect 기대
|
||||
}
|
||||
|
||||
public void bar() {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- proxy를 통과하지 않아 advice가 적용되지 않을 수 있다
|
||||
|
||||
## 나쁜 예시 3: 너무 넓은 pointcut
|
||||
|
||||
```java
|
||||
@Before("execution(* *(..))")
|
||||
public void logEverything() {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 범위가 지나치게 넓다
|
||||
- 성능/디버깅/예측 가능성 모두 나빠질 수 있다
|
||||
- 어떤 코드가 영향을 받는지 설명하기 어렵다
|
||||
|
||||
## 나쁜 예시 4: @Around로 예외 숨김
|
||||
|
||||
```java
|
||||
@Around("execution(* ..*(..))")
|
||||
public Object swallow(ProceedingJoinPoint joinPoint) {
|
||||
try {
|
||||
return joinPoint.proceed();
|
||||
} catch (Throwable ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 실패를 정상값처럼 숨긴다
|
||||
- 디버깅과 계약을 깨뜨린다
|
||||
@@ -0,0 +1,175 @@
|
||||
# ApplicationEvent 예시
|
||||
|
||||
## 좋은 예시 1: 핵심 작업 후 후속 반응 분리
|
||||
|
||||
```java
|
||||
public record UserRegisteredEvent(
|
||||
Long userId,
|
||||
String email,
|
||||
Instant occurredAt
|
||||
) {}
|
||||
|
||||
@Service
|
||||
public class RegisterUserService {
|
||||
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final Clock clock;
|
||||
|
||||
public RegisterUserService(ApplicationEventPublisher eventPublisher, Clock clock) {
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Long register(CreateUserCommand command) {
|
||||
User user = ...;
|
||||
userRepository.save(user);
|
||||
|
||||
eventPublisher.publishEvent(
|
||||
new UserRegisteredEvent(user.getId(), user.getEmail(), Instant.now(clock))
|
||||
);
|
||||
|
||||
return user.getId();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 핵심 등록 작업과 후속 반응을 분리한다
|
||||
- 이벤트 payload가 필요한 상태를 직접 담는다
|
||||
- publisher가 listener 구현을 모른다
|
||||
|
||||
## 좋은 예시 2: commit 후에만 처리
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class UserRegisteredAuditListener {
|
||||
|
||||
@TransactionalEventListener
|
||||
public void handle(UserRegisteredEvent event) {
|
||||
auditLog.record("USER_REGISTERED", event.userId(), event.occurredAt());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 기본 AFTER_COMMIT 의미를 활용한다
|
||||
- rollback된 작업에 대해 잘못된 후속 기록을 남기지 않는다
|
||||
|
||||
## 좋은 예시 3: listener는 짧고 부가적
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class WelcomeMetricListener {
|
||||
|
||||
@EventListener
|
||||
public void handle(UserRegisteredEvent event) {
|
||||
metrics.counter("user.registered").increment();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 짧고 효율적이다
|
||||
- 핵심 비즈니스 흐름을 숨기지 않는다
|
||||
|
||||
## 좋은 예시 4: 테스트에서 이벤트 검증
|
||||
|
||||
```java
|
||||
@RecordApplicationEvents
|
||||
@SpringBootTest
|
||||
class RegisterUserServiceTest {
|
||||
|
||||
@Test
|
||||
void publishes_user_registered_event(ApplicationEvents events) {
|
||||
service.register(command);
|
||||
|
||||
assertThat(events.stream(UserRegisteredEvent.class)).hasSize(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 이벤트 발행 사실을 테스트로 확인할 수 있다
|
||||
- “어딘가에서 되겠지” 상태를 줄인다
|
||||
|
||||
## 나쁜 예시 1: 핵심 오케스트레이션을 이벤트에 숨김
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class LoginService {
|
||||
|
||||
public LoginResponse login(LoginCommand command) {
|
||||
eventPublisher.publishEvent(new LoginRequestedEvent(command));
|
||||
return LoginResponse.pending();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 핵심 로그인 흐름이 listener들 뒤로 숨어버린다
|
||||
- 메인 결과가 이벤트 체인에 의존하게 된다
|
||||
|
||||
## 나쁜 예시 2: payload가 너무 빈약함
|
||||
|
||||
```java
|
||||
public record UserRegisteredEvent(Long userId) {}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 모든 listener가 다시 조회를 강요받을 수 있다
|
||||
- 필요한 최소 상태가 누락되면 결합과 조회 비용이 커진다
|
||||
|
||||
**개선:**
|
||||
|
||||
- 정말 필요한 상태를 payload에 포함
|
||||
|
||||
## 나쁜 예시 3: 무거운 작업을 listener에 직접 넣음
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class HeavyListener {
|
||||
|
||||
@EventListener
|
||||
public void handle(UserRegisteredEvent event) {
|
||||
externalApi.call(...);
|
||||
fileExporter.export(...);
|
||||
Thread.sleep(5000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- listener가 너무 무겁고 느리다
|
||||
- event hand-off의 장점을 해친다
|
||||
- 장애 반경이 커진다
|
||||
|
||||
## 나쁜 예시 4: listener 순서에 핵심 의존
|
||||
|
||||
```java
|
||||
@Component
|
||||
class FirstListener {
|
||||
@Order(1)
|
||||
@EventListener
|
||||
void handle(UserRegisteredEvent event) { ... }
|
||||
}
|
||||
|
||||
@Component
|
||||
class SecondListener {
|
||||
@Order(2)
|
||||
@EventListener
|
||||
void handle(UserRegisteredEvent event) { ... } // 첫 번째가 반드시 먼저 돌 것을 기대
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 이벤트 기반 구조가 사실상 숨은 절차형 흐름이 된다
|
||||
- 순서 의존이 커질수록 명시적 호출이 더 낫다
|
||||
@@ -0,0 +1,199 @@
|
||||
# async / scheduler / retry 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 스케줄 트리거는 얇게 두고, 재시도는 외부 경계에 둔다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ExpiredSessionCleanupJob {
|
||||
|
||||
private final ExpiredSessionCleanupUseCase expiredSessionCleanupUseCase;
|
||||
|
||||
@Scheduled(cron = "${auth.session.cleanup-cron}")
|
||||
public void run() {
|
||||
expiredSessionCleanupUseCase.cleanUpExpiredSessions();
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExpiredSessionCleanupUseCase {
|
||||
|
||||
private final SessionRepository sessionRepository;
|
||||
private final TokenRevocationGateway tokenRevocationGateway;
|
||||
private final CleanupAuditAsyncPublisher cleanupAuditAsyncPublisher;
|
||||
|
||||
public void cleanUpExpiredSessions() {
|
||||
List<ExpiredSession> expiredSessions = sessionRepository.findExpiredSessions();
|
||||
|
||||
for (ExpiredSession expiredSession : expiredSessions) {
|
||||
tokenRevocationGateway.revoke(expiredSession.tokenId());
|
||||
}
|
||||
|
||||
cleanupAuditAsyncPublisher.publish(expiredSessions.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
public class TokenRevocationGateway {
|
||||
|
||||
@Retryable(
|
||||
retryFor = {
|
||||
ResourceAccessException.class,
|
||||
SocketTimeoutException.class,
|
||||
ConnectException.class
|
||||
},
|
||||
noRetryFor = {
|
||||
IllegalArgumentException.class
|
||||
},
|
||||
maxAttempts = 3,
|
||||
backoff = @Backoff(delay = 500, maxDelay = 2_000, multiplier = 2.0)
|
||||
)
|
||||
public void revoke(String tokenId) {
|
||||
// 외부 인증/폐기 시스템 호출
|
||||
}
|
||||
|
||||
@Recover
|
||||
public void recover(Exception ex, String tokenId) {
|
||||
throw new ExternalDependencyException("Token revocation failed after retries. tokenId=" + tokenId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CleanupAuditAsyncPublisher {
|
||||
|
||||
@Async("auditAsyncExecutor")
|
||||
public CompletableFuture<Void> publish(int cleanedCount) {
|
||||
// 감사 로그/알림 전송
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- scheduler는 트리거만 담당
|
||||
- retry는 외부 호출 경계에만 존재
|
||||
- async는 비핵심 후속 처리로 분리
|
||||
- 각 책임이 bean 경계로 나뉘어 프록시 적용 여부가 명확함
|
||||
|
||||
### 예시 2. executor / scheduler를 명시적으로 분리한다
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
@EnableRetry
|
||||
public class TaskExecutionConfig implements AsyncConfigurer {
|
||||
|
||||
@Bean(name = "auditAsyncExecutor")
|
||||
public ThreadPoolTaskExecutor auditAsyncExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setThreadNamePrefix("audit-async-");
|
||||
executor.setCorePoolSize(4);
|
||||
executor.setMaxPoolSize(8);
|
||||
executor.setQueueCapacity(200);
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Bean(name = "maintenanceTaskScheduler")
|
||||
public ThreadPoolTaskScheduler maintenanceTaskScheduler() {
|
||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||
scheduler.setThreadNamePrefix("maintenance-scheduler-");
|
||||
scheduler.setPoolSize(2);
|
||||
scheduler.initialize();
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Executor getAsyncExecutor() {
|
||||
return auditAsyncExecutor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
||||
return (ex, method, params) ->
|
||||
log.error("Async error in method={}, params={}", method.getName(), Arrays.toString(params), ex);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- async executor와 scheduler를 분리
|
||||
- thread prefix로 운영 추적 가능
|
||||
- void @Async 예외를 방치하지 않음
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 한 메서드에 스케줄/비동기/재시도를 다 겹친다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class BadCleanupService {
|
||||
|
||||
@Scheduled(fixedRate = 1000)
|
||||
@Async
|
||||
@Retryable
|
||||
public void run() {
|
||||
// 핵심 업무 + 외부 호출 + 후속 처리까지 한곳에 몰아넣음
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실행 경계가 불명확함
|
||||
- 실패 전파/관측/재시도 범위가 애매함
|
||||
- 어떤 책임 때문에 실패했는지 읽기 어려움
|
||||
- 기본 retry 정책에 의존하기 쉬움
|
||||
|
||||
### 예시 2. self-invocation으로 @Async / @Retryable 효과를 기대한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BadNotificationService {
|
||||
|
||||
public void sendAll(List<String> ids) {
|
||||
for (String id : ids) {
|
||||
this.sendOne(id); // 프록시를 거치지 않음
|
||||
}
|
||||
}
|
||||
|
||||
@Async
|
||||
public void sendOne(String id) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 클래스 내부 호출이라 프록시 적용을 기대하면 안 됨
|
||||
|
||||
### 예시 3. retry를 결정적 실패에 건다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadMapper {
|
||||
|
||||
@Retryable(maxAttempts = 5)
|
||||
public UserId map(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException("raw must not be blank");
|
||||
}
|
||||
return new UserId(raw);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 입력 검증 실패는 재시도로 해결되지 않음
|
||||
- retry 대상 예외를 좁히지 않음
|
||||
- domain/value 생성 로직에 retry를 붙임
|
||||
@@ -0,0 +1,138 @@
|
||||
# bean registration 예시
|
||||
|
||||
## 좋은 예시 1: application service는 stereotype 등록
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class RegisterUserService implements RegisterUserUseCase {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 애플리케이션 주 컴포넌트라는 역할이 드러난다
|
||||
- scanning 기반 등록에 자연스럽다
|
||||
|
||||
## 좋은 예시 2: external client는 configuration + bean
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class VaultClientConfiguration {
|
||||
|
||||
@Bean
|
||||
public VaultTransitClient vaultTransitClient(
|
||||
VaultProperties properties,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
return new VaultTransitClient(
|
||||
properties.address(),
|
||||
properties.token(),
|
||||
HttpClient.newHttpClient(),
|
||||
objectMapper
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 외부 라이브러리/인프라 객체 조립이 한 곳에 모인다
|
||||
- 생성 로직이 명시적이다
|
||||
|
||||
## 좋은 예시 3: security/filter wiring은 configuration에 둠
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class WebConfiguration {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<TraceIdFilter> traceIdFilter() {
|
||||
FilterRegistrationBean<TraceIdFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(new TraceIdFilter());
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- framework wiring 성격이 분명하다
|
||||
- business component와 분리된다
|
||||
|
||||
## 좋은 예시 4: domain object는 bean으로 등록하지 않음
|
||||
|
||||
```java
|
||||
public record UserEmail(String value) {}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- value object는 container 관리 대상이 아니다
|
||||
- 생성/검증 책임은 domain에 남는다
|
||||
|
||||
## 나쁜 예시 1: domain entity를 bean으로 등록
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class User {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- domain object 생명주기를 container가 소유하게 된다
|
||||
- 의미가 맞지 않는다
|
||||
|
||||
## 나쁜 예시 2: @Component 안에 습관적 @Bean
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class UserFactoryComponent {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- full @Configuration 대신 lite mode가 된다
|
||||
- configuration 역할과 component 역할이 섞인다
|
||||
|
||||
**개선:**
|
||||
|
||||
- 별도 @Configuration 클래스로 이동
|
||||
|
||||
## 나쁜 예시 3: 의미 없는 잡다한 config
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class CommonConfig {
|
||||
@Bean ...
|
||||
@Bean ...
|
||||
@Bean ...
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 어떤 조립을 담당하는지 이름만 보고 알기 어렵다
|
||||
- 변경 이유가 다른 bean이 섞이기 쉽다
|
||||
|
||||
## 나쁜 예시 4: 단순 helper까지 bean으로 올림
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class StringMaskingHelper {
|
||||
public String mask(String input) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- lifecycle/DI 이득이 작다
|
||||
- plain helper로 둘 수 있다면 굳이 bean일 필요가 없다
|
||||
@@ -0,0 +1,150 @@
|
||||
# @ConfigurationProperties 예시
|
||||
|
||||
## 좋은 예시 1: 의미 있는 설정 집합을 타입으로 묶음
|
||||
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "auth.jwt")
|
||||
@Validated
|
||||
public record JwtProperties(
|
||||
@NotNull Duration accessTokenTtl,
|
||||
@NotNull Duration refreshTokenTtl,
|
||||
@NotBlank String issuer
|
||||
) {}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 관련 설정이 하나의 계약으로 묶인다
|
||||
- 타입 안전성과 검증이 있다
|
||||
- scattered @Value를 줄인다
|
||||
|
||||
## 좋은 예시 2: configuration properties scanning 사용
|
||||
|
||||
```java
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class AuthApplication {
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 애플리케이션 내부 properties 타입을 명시적으로 스캔한다
|
||||
- @Component에 기대지 않는다
|
||||
|
||||
## 좋은 예시 3: 조건부/명시 등록은 EnableConfigurationProperties
|
||||
|
||||
```java
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(VaultProperties.class)
|
||||
public class VaultConfiguration {
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 어떤 설정 타입을 활성화하는지 명확하다
|
||||
- auto-configuration/조건부 wiring에 잘 맞는다
|
||||
|
||||
## 좋은 예시 4: third-party bean에 바인딩
|
||||
|
||||
```java
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class ClientConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConfigurationProperties("app.http.client")
|
||||
public HttpClientProperties httpClientProperties() {
|
||||
return new HttpClientProperties();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 외부 타입/서드파티 설정을 명시적 config 안에 가둔다
|
||||
- prefix와 등록 위치가 분명하다
|
||||
|
||||
## 나쁜 예시 1: 산발적 @Value 남발
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class JwtIssuer {
|
||||
|
||||
@Value("${auth.jwt.access-token-ttl}")
|
||||
private Duration accessTokenTtl;
|
||||
|
||||
@Value("${auth.jwt.refresh-token-ttl}")
|
||||
private Duration refreshTokenTtl;
|
||||
|
||||
@Value("${auth.jwt.issuer}")
|
||||
private String issuer;
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 관련 설정이 흩어진다
|
||||
- 타입 집합과 검증이 약해진다
|
||||
- 재사용/문서화가 어려워진다
|
||||
|
||||
**개선:**
|
||||
|
||||
- JwtProperties로 묶어서 주입
|
||||
|
||||
## 나쁜 예시 2: Optional 필드 사용
|
||||
|
||||
```java
|
||||
@ConfigurationProperties("vault")
|
||||
public record VaultProperties(
|
||||
Optional<String> namespace
|
||||
) {}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- Spring Boot 공식 문서가 권장하지 않는다
|
||||
- 값이 없으면 empty Optional이 아니라 null이 바인딩될 수 있다
|
||||
|
||||
**개선:**
|
||||
|
||||
- nullable String
|
||||
- 명시적 기본값
|
||||
- 별도 default 처리
|
||||
|
||||
## 나쁜 예시 3: properties class에 business logic 포함
|
||||
|
||||
```java
|
||||
@ConfigurationProperties("auth.jwt")
|
||||
public class JwtProperties {
|
||||
|
||||
private Duration accessTokenTtl;
|
||||
|
||||
public String issueToken(User user) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 설정 계약과 비즈니스 로직이 섞인다
|
||||
- 테스트/책임 분리가 흐려진다
|
||||
|
||||
## 나쁜 예시 4: CommonProperties dump zone
|
||||
|
||||
```java
|
||||
@ConfigurationProperties("app")
|
||||
public class AppProperties {
|
||||
private String jwtIssuer;
|
||||
private Duration retryDelay;
|
||||
private String vaultAddress;
|
||||
private String mailFrom;
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 소유 기능이 다 다르다
|
||||
- prefix와 책임이 너무 넓다
|
||||
- 기능별 변경이 서로 얽힌다
|
||||
@@ -0,0 +1,165 @@
|
||||
# dependency injection 예시
|
||||
|
||||
## 좋은 예시 1: 필수 의존성은 생성자 주입
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class RegisterUserService implements RegisterUserUseCase {
|
||||
|
||||
private final UserReader userReader;
|
||||
private final UserAppender userAppender;
|
||||
private final PasswordHasher passwordHasher;
|
||||
|
||||
public RegisterUserService(
|
||||
UserReader userReader,
|
||||
UserAppender userAppender,
|
||||
PasswordHasher passwordHasher
|
||||
) {
|
||||
this.userReader = userReader;
|
||||
this.userAppender = userAppender;
|
||||
this.passwordHasher = passwordHasher;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 필수 의존성이 시그니처에 드러난다
|
||||
- final field를 사용할 수 있다
|
||||
- 객체가 완전한 상태로 생성된다
|
||||
|
||||
## 좋은 예시 2: single constructor면 @Autowired 생략
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class JwtTokenIssuer {
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
public JwtTokenIssuer(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- Spring은 단일 생성자를 자동으로 사용할 수 있다
|
||||
- annotation noise를 줄인다
|
||||
|
||||
## 좋은 예시 3: 선택 의존성은 setter/config method 검토
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class AuditClient {
|
||||
|
||||
private RetryTemplate retryTemplate = RetryTemplate.defaultInstance();
|
||||
|
||||
@Autowired(required = false)
|
||||
public void setRetryTemplate(RetryTemplate retryTemplate) {
|
||||
this.retryTemplate = retryTemplate;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 선택 의존성이라는 점이 드러난다
|
||||
- reasonable default가 있다
|
||||
|
||||
## 좋은 예시 4: 다중 구현은 qualifier로 명시
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class OAuthLoginService {
|
||||
|
||||
private final OAuthClient googleOAuthClient;
|
||||
|
||||
public OAuthLoginService(@Qualifier("googleOAuthClient") OAuthClient googleOAuthClient) {
|
||||
this.googleOAuthClient = googleOAuthClient;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 여러 구현체 중 무엇을 주입받는지 명확하다
|
||||
- 우연한 후보 선택에 기대지 않는다
|
||||
|
||||
## 나쁜 예시 1: production field injection
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class RegisterUserService {
|
||||
|
||||
@Autowired
|
||||
private UserReader userReader;
|
||||
|
||||
@Autowired
|
||||
private UserAppender userAppender;
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 필수 의존성이 시그니처에 안 드러난다
|
||||
- final field 사용이 어렵다
|
||||
- plain unit test가 불편하다
|
||||
|
||||
## 나쁜 예시 2: service locator 사용
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class RegisterUserService {
|
||||
|
||||
@Autowired
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
public void register(...) {
|
||||
UserAppender userAppender = applicationContext.getBean(UserAppender.class);
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- DI가 아니라 lookup으로 퇴행한다
|
||||
- 숨은 의존성이 생긴다
|
||||
|
||||
## 나쁜 예시 3: 생성자 인자 과다를 setter로 숨김
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class ComplexService {
|
||||
|
||||
@Autowired
|
||||
public void setA(A a) { ... }
|
||||
|
||||
@Autowired
|
||||
public void setB(B b) { ... }
|
||||
|
||||
@Autowired
|
||||
public void setC(C c) { ... }
|
||||
|
||||
@Autowired
|
||||
public void setD(D d) { ... }
|
||||
|
||||
@Autowired
|
||||
public void setE(E e) { ... }
|
||||
|
||||
@Autowired
|
||||
public void setF(F f) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 책임이 큰 문제를 주입 방식으로 숨긴다
|
||||
- 객체의 필수/선택 의존성이 흐려진다
|
||||
|
||||
**개선:**
|
||||
|
||||
- collaborator 분리
|
||||
- orchestration 재설계
|
||||
- 설정 묶기 검토
|
||||
@@ -0,0 +1,495 @@
|
||||
# Filter / Interceptor / Resolver / Advice 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. request/response 수준의 공통 처리만 filter에 둔다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class RequestIdFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String REQUEST_ID_ATTRIBUTE = "requestId";
|
||||
public static final String REQUEST_ID_HEADER = "X-Request-Id";
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilterErrorDispatch() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
String requestId = request.getHeader(REQUEST_ID_HEADER);
|
||||
if (requestId == null || requestId.isBlank()) {
|
||||
requestId = UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId);
|
||||
response.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- HTTP request/response concern만 다룬다
|
||||
- controller 이전에 처리되어도 자연스럽다
|
||||
- business/service/repository에 의존하지 않는다
|
||||
|
||||
### 예시 2. handler 전후의 가벼운 공통 처리는 interceptor에 둔다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuditActorInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final AuditContextHolder auditContextHolder;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler
|
||||
) {
|
||||
String actorId = (String) request.getAttribute(RequestAttributes.AUTHENTICATED_ACTOR_ID);
|
||||
if (actorId != null) {
|
||||
auditContextHolder.bind(actorId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex
|
||||
) {
|
||||
auditContextHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public final class RequestAttributes {
|
||||
|
||||
public static final String AUTHENTICATED_ACTOR_ID = "authenticatedActorId";
|
||||
|
||||
private RequestAttributes() {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- handler 실행 전후의 공통 처리라는 interceptor 책임에 맞는다
|
||||
- 인증 자체를 구현하지 않고, 인증 이후 컨텍스트 연결만 수행한다
|
||||
- 핵심 business logic을 수행하지 않는다
|
||||
|
||||
### 예시 3. resolver는 낮은 수준의 예외만 제한적으로 변환한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class RequestBindingExceptionResolver implements HandlerExceptionResolver {
|
||||
|
||||
@Override
|
||||
public ModelAndView resolveException(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex
|
||||
) throws IOException {
|
||||
if (!(ex instanceof HttpMessageNotReadableException)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write("""
|
||||
{
|
||||
"success": false,
|
||||
"code": "MALFORMED_JSON_REQUEST",
|
||||
"message": "Malformed request body"
|
||||
}
|
||||
""");
|
||||
|
||||
return new ModelAndView();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- resolver를 “전역 business exception 처리기”가 아니라 저수준 예외 처리 지점으로 제한한다
|
||||
- null 반환으로 다른 예외는 다음 resolver/advice에 넘긴다
|
||||
- resolver 사용 이유가 명확하다
|
||||
|
||||
**주의:**
|
||||
|
||||
- 실제 프로젝트에서는 이조차도 가능하면 advice/기본 처리로 흡수할 수 있는지 먼저 검토하는 편이 낫다
|
||||
- 이 예시는 “resolver가 허용되는 좁은 자리”를 보여주기 위한 예시다
|
||||
|
||||
### 예시 4. 에러 정책은 ErrorCode로 중앙 관리한다
|
||||
|
||||
```java
|
||||
public enum ErrorCode {
|
||||
DOMAIN_RULE_VIOLATION(HttpStatus.CONFLICT, "DOMAIN_RULE_VIOLATION", "Domain rule violation"),
|
||||
EXTERNAL_DEPENDENCY_FAILURE(HttpStatus.BAD_GATEWAY, "EXTERNAL_DEPENDENCY_FAILURE", "Temporary external dependency failure"),
|
||||
REQUEST_VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "REQUEST_VALIDATION_FAILED", "Request validation failed");
|
||||
|
||||
private final HttpStatus httpStatus;
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
ErrorCode(HttpStatus httpStatus, String code, String message) {
|
||||
this.httpStatus = httpStatus;
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public HttpStatus httpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 에러 코드, 메시지, 상태값이 분산되지 않는다
|
||||
- advice가 문자열 조립 대신 매핑 책임에 집중할 수 있다
|
||||
|
||||
### 예시 5. 전역 예외 응답은 @RestControllerAdvice에서 ApiResult로 통일한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DomainRuleViolationException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDomainRuleViolation(
|
||||
HttpServletRequest request
|
||||
) {
|
||||
ErrorCode errorCode = ErrorCode.DOMAIN_RULE_VIOLATION;
|
||||
Map<String, String> metadata = requestMetadata(request);
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(
|
||||
errorCode,
|
||||
null,
|
||||
metadata
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ExternalDependencyException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleExternalDependency(
|
||||
HttpServletRequest request
|
||||
) {
|
||||
ErrorCode errorCode = ErrorCode.EXTERNAL_DEPENDENCY_FAILURE;
|
||||
Map<String, String> metadata = requestMetadata(request);
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(
|
||||
errorCode,
|
||||
null,
|
||||
metadata
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
ErrorCode errorCode = ErrorCode.REQUEST_VALIDATION_FAILED;
|
||||
Map<String, String> metadata = requestMetadata(request);
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(
|
||||
errorCode,
|
||||
errors,
|
||||
metadata
|
||||
));
|
||||
}
|
||||
|
||||
private Map<String, String> requestMetadata(HttpServletRequest request) {
|
||||
Object requestId = request.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE);
|
||||
if (!(requestId instanceof String value) || value.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
return Map.of("requestId", value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- business exception, validation exception을 한곳에서 다룬다
|
||||
- 응답 포맷이 ApiResult로 일관된다
|
||||
- advice는 예외를 ErrorCode로 매핑하는 책임만 가진다
|
||||
|
||||
### 예시 6. 성공 응답 공통 래핑은 ResponseBodyAdvice에서 처리한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class<?> converterType) {
|
||||
Class<?> parameterType = returnType.getParameterType();
|
||||
|
||||
return !ApiResult.class.isAssignableFrom(parameterType)
|
||||
&& !ResponseEntity.class.isAssignableFrom(parameterType)
|
||||
&& !Resource.class.isAssignableFrom(parameterType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response
|
||||
) {
|
||||
if (body == null) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
|
||||
if (body instanceof ApiResult<?>) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return ApiResult.success(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 성공 응답 공통화 위치가 명확하다
|
||||
- controller가 반복해서 ApiResult.success(...)를 만들지 않아도 된다
|
||||
- 이미 래핑된 응답은 다시 감싸지 않는다
|
||||
|
||||
### 예시 7. controller는 정상 흐름만 표현한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/sessions")
|
||||
public class SessionQueryController {
|
||||
|
||||
private final SessionQueryUseCase sessionQueryUseCase;
|
||||
|
||||
@GetMapping("/{sessionId}")
|
||||
public ApiResult<SessionResponse> getSession(@PathVariable String sessionId) {
|
||||
SessionResponse response = sessionQueryUseCase.getSession(sessionId);
|
||||
return ApiResult.success(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller가 예외 정책까지 떠안지 않는다
|
||||
- 정상 흐름과 예외 흐름이 분리된다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. filter에서 business/service를 직접 호출한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class BadAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final LoginPolicyService loginPolicyService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
String userId = request.getHeader("X-User-Id");
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
loginPolicyService.validate(user);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- filter에 repository/business validation이 들어갔다
|
||||
- HTTP concern과 business concern이 섞였다
|
||||
|
||||
### 예시 2. interceptor를 보안의 주 레이어로 사용한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadAuthorizationInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler
|
||||
) throws Exception {
|
||||
if (request.getHeader("Authorization") == null) {
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 인증/인가의 중심을 interceptor에 두고 있다
|
||||
- security/filter chain과 역할이 충돌한다
|
||||
|
||||
### 예시 3. resolver를 business exception 처리의 기본 수단으로 사용한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadBusinessExceptionResolver implements HandlerExceptionResolver {
|
||||
|
||||
@Override
|
||||
public ModelAndView resolveException(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex
|
||||
) throws IOException {
|
||||
if (ex instanceof DomainRuleViolationException) {
|
||||
response.setStatus(HttpStatus.CONFLICT.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write("""
|
||||
{
|
||||
"success": false,
|
||||
"code": "DOMAIN_RULE_VIOLATION",
|
||||
"message": "Domain rule violation"
|
||||
}
|
||||
""");
|
||||
return new ModelAndView();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- business exception 처리의 중심이 resolver로 내려갔다
|
||||
- advice보다 의도가 덜 드러난다
|
||||
- 응답 정책이 저수준 구현으로 흩어진다
|
||||
|
||||
### 예시 4. advice에서 에러 코드 문자열을 직접 하드코딩한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class BadApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DomainRuleViolationException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDomainRuleViolation(
|
||||
DomainRuleViolationException ex
|
||||
) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResult.fail(
|
||||
"DOMAIN_RULE_VIOLATION",
|
||||
ex.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 에러 코드 문자열이 advice에 박혀 있다
|
||||
- 메시지 정책과 예외 메시지가 섞인다
|
||||
- 코드/메시지/상태값 정책이 중앙화되지 않는다
|
||||
|
||||
### 예시 5. controller가 예외를 직접 잡아 ApiResult를 만든다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final UserRegisterUseCase userRegisterUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<Void>> register(@RequestBody RegisterUserRequest request) {
|
||||
try {
|
||||
userRegisterUseCase.register(request.email(), request.password());
|
||||
return ResponseEntity.ok(ApiResult.success(null));
|
||||
} catch (DuplicateEmailException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResult.fail(ErrorCode.DOMAIN_RULE_VIOLATION));
|
||||
} catch (Exception ex) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResult.fail(ErrorCode.EXTERNAL_DEPENDENCY_FAILURE));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller마다 예외 처리 로직이 중복된다
|
||||
- 전역 예외 처리 규약이 깨진다
|
||||
|
||||
### 예시 6. ResponseBodyAdvice에서 무조건 이중 래핑한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class BadApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class<?> converterType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response
|
||||
) {
|
||||
return ApiResult.success(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 이미 ApiResult인 응답도 다시 감싼다
|
||||
- file response, streaming response 같은 예외 케이스를 고려하지 않았다
|
||||
@@ -0,0 +1,171 @@
|
||||
# @Transactional 위치 예시
|
||||
|
||||
## 좋은 예시 1: use case 경계에 transaction
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class RegisterUserService implements RegisterUserUseCase {
|
||||
|
||||
private final UserReader userReader;
|
||||
private final UserAppender userAppender;
|
||||
private final PasswordHasher passwordHasher;
|
||||
|
||||
public RegisterUserService(
|
||||
UserReader userReader,
|
||||
UserAppender userAppender,
|
||||
PasswordHasher passwordHasher
|
||||
) {
|
||||
this.userReader = userReader;
|
||||
this.userAppender = userAppender;
|
||||
this.passwordHasher = passwordHasher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserId register(CreateUserCommand command) {
|
||||
if (userReader.findByEmail(UserEmail.from(command.email())).isPresent()) {
|
||||
throw new DuplicateUserException();
|
||||
}
|
||||
|
||||
User user = User.create(
|
||||
UserEmail.from(command.email()),
|
||||
UserName.from(command.name()),
|
||||
passwordHasher.hash(command.password())
|
||||
);
|
||||
|
||||
return userAppender.append(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 비즈니스 작업 단위가 transaction 경계와 일치한다
|
||||
- repository 호출들이 하나의 원자적 작업으로 묶인다
|
||||
- controller나 repository에 흩어지지 않는다
|
||||
|
||||
## 좋은 예시 2: 조회 use case는 readOnly
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class GetUserProfileService implements GetUserProfileUseCase {
|
||||
|
||||
private final UserReader userReader;
|
||||
|
||||
public GetUserProfileService(UserReader userReader) {
|
||||
this.userReader = userReader;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserProfileResult get(UserId userId) {
|
||||
User user = userReader.findById(userId).orElseThrow(UserNotFoundException::new);
|
||||
return UserProfileResult.from(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 순수 조회라는 의도가 드러난다
|
||||
- 쓰기 작업과 구분된다
|
||||
|
||||
## 좋은 예시 3: 별도 확정 단위가 필요한 경우만 REQUIRES_NEW
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class AuditLogService {
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void record(LoginAuditCommand command) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 본 작업과 독립된 commit 단위를 의도적으로 분리한다
|
||||
- 예외적 사용이라는 점이 분명하다
|
||||
|
||||
## 나쁜 예시 1: controller에 transaction
|
||||
|
||||
```java
|
||||
@RestController
|
||||
public class UserController {
|
||||
|
||||
@Transactional
|
||||
@PostMapping("/users")
|
||||
public UserResponse create(@RequestBody CreateUserRequest request) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- HTTP 경계와 transaction 경계가 섞인다
|
||||
- web layer가 persistence 세부를 과도하게 끌어안는다
|
||||
|
||||
## 나쁜 예시 2: repository마다 습관적 transaction
|
||||
|
||||
```java
|
||||
@Repository
|
||||
public class JpaUserRepository {
|
||||
|
||||
@Transactional
|
||||
public UserJpaEntity save(UserJpaEntity entity) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 상위 use case 경계가 아니라 하위 collaborator에 transaction이 흩어진다
|
||||
- 작업 단위가 잘게 찢어진다
|
||||
|
||||
## 나쁜 예시 3: self-invocation 기대
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
public void doWork() {
|
||||
this.saveAudit(); // transactional 기대
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void saveAudit() {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- proxy mode에서는 self-invocation이 interception 되지 않는다
|
||||
- 기대한 transaction이 실제로 열리지 않을 수 있다
|
||||
|
||||
**개선:**
|
||||
|
||||
- 클래스를 분리하거나 public entry boundary를 다시 설계
|
||||
|
||||
## 나쁜 예시 4: 긴 외부 API 호출을 transaction 안에 유지
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void completeLogin(LoginCommand command) {
|
||||
userRepository.save(...);
|
||||
externalOAuthClient.fetchProfile(...); // 긴 네트워크 호출
|
||||
tokenRepository.save(...);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- DB 자원/잠금을 오래 붙잡을 수 있다
|
||||
- 실패 반경과 지연 시간이 커진다
|
||||
|
||||
**개선 방향:**
|
||||
|
||||
- 외부 호출과 DB transaction 경계를 재설계
|
||||
- 후속 작업/event/outbox 구조 검토
|
||||
@@ -0,0 +1,311 @@
|
||||
# Validation Location 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. request DTO 구조 검증은 presentation에서 처리한다
|
||||
|
||||
```java
|
||||
public record CreateSessionRequest(
|
||||
@NotBlank String email,
|
||||
@NotBlank String password,
|
||||
@NotNull LoginType loginType
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(@Valid @RequestBody CreateSessionRequest request) {
|
||||
CreateSessionResponse response = createSessionUseCase.create(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.loginType()
|
||||
);
|
||||
return ApiResult.success(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- request shape 검증이 web boundary에 있다
|
||||
- controller는 transport DTO를 domain object와 분리한다
|
||||
- business rule 판단은 use case로 넘긴다
|
||||
|
||||
### 예시 2. path variable / request param 제약은 메서드 파라미터에 직접 둔다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@PathVariable @NotBlank String userId,
|
||||
@RequestParam(defaultValue = "1") @Min(1) int page
|
||||
) {
|
||||
UserResponse response = userQueryUseCase.getUser(userId, page);
|
||||
return ApiResult.success(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- scalar input 제약이 controller boundary에 명확히 드러난다
|
||||
- request DTO가 필요 없는 단순 입력을 과하게 감싸지 않는다
|
||||
|
||||
### 예시 3. application은 조회가 필요한 정책 검증을 담당한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CreateSessionUseCase {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public CreateSessionResponse create(CreateSessionCommand command) {
|
||||
if (!userRepository.existsActiveUserByEmail(command.email())) {
|
||||
throw new UserNotFoundException(command.email());
|
||||
}
|
||||
|
||||
if (command.loginType() == LoginType.PASSWORDLESS
|
||||
&& command.credential() instanceof PasswordCredential) {
|
||||
throw new InvalidLoginRequestException();
|
||||
}
|
||||
|
||||
// 실제 세션 생성
|
||||
return new CreateSessionResponse(...);
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateSessionCommand(
|
||||
String email,
|
||||
LoginCredential credential,
|
||||
LoginType loginType
|
||||
) {
|
||||
public CreateSessionCommand {
|
||||
Objects.requireNonNull(email, "email must not be null");
|
||||
Objects.requireNonNull(credential, "credential must not be null");
|
||||
Objects.requireNonNull(loginType, "loginType must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed interface LoginCredential permits PasswordCredential, PasswordlessCredential {
|
||||
}
|
||||
|
||||
public record PasswordCredential(String value) implements LoginCredential {
|
||||
public PasswordCredential {
|
||||
Objects.requireNonNull(value, "value must not be null");
|
||||
if (value.isBlank()) {
|
||||
throw new InvalidLoginRequestException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record PasswordlessCredential() implements LoginCredential {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- DB 조회가 필요한 규칙을 controller validation에 두지 않았다
|
||||
- use case 전제조건 검증이 application에 있다
|
||||
- nullable password를 application 내부로 전파하지 않고 명시적 credential 타입으로 표현한다
|
||||
|
||||
### 예시 4. domain은 자기 불변식을 스스로 보장한다
|
||||
|
||||
```java
|
||||
public final class Email {
|
||||
|
||||
private final String value;
|
||||
|
||||
private Email(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new DomainRuleViolationException("Email must not be blank");
|
||||
}
|
||||
if (!value.contains("@")) {
|
||||
throw new DomainRuleViolationException("Email format is invalid");
|
||||
}
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public static Email of(String value) {
|
||||
return new Email(value);
|
||||
}
|
||||
|
||||
public String value() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- domain invariant를 controller에 의존하지 않는다
|
||||
- 어디서 생성되더라도 유효한 상태만 허용한다
|
||||
|
||||
### 예시 5. web 전용 복잡한 입력 검증은 @InitBinder + custom Validator로 제한적으로 둔다
|
||||
|
||||
```java
|
||||
public class ChangePasswordRequestValidator implements Validator {
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> clazz) {
|
||||
return ChangePasswordRequest.class.equals(clazz);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Object target, Errors errors) {
|
||||
ChangePasswordRequest request = (ChangePasswordRequest) target;
|
||||
|
||||
if (request.newPassword() != null
|
||||
&& request.newPasswordConfirm() != null
|
||||
&& !request.newPassword().equals(request.newPasswordConfirm())) {
|
||||
errors.rejectValue("newPasswordConfirm", "password.confirm.mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/password")
|
||||
public class PasswordController {
|
||||
|
||||
@InitBinder("changePasswordRequest")
|
||||
void initBinder(WebDataBinder binder) {
|
||||
binder.addValidators(new ChangePasswordRequestValidator());
|
||||
}
|
||||
|
||||
@PostMapping("/change")
|
||||
public ApiResult<Void> changePassword(
|
||||
@Valid @RequestBody ChangePasswordRequest changePasswordRequest
|
||||
) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- request-object 내부의 web 입력 규칙만 binder validator에 둔다
|
||||
- business rule 전체를 validator에 몰아넣지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. entity를 request binding 대상으로 직접 노출한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String role;
|
||||
}
|
||||
|
||||
@PostMapping("/users")
|
||||
public ApiResult<Void> create(@Valid @RequestBody User user) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- web input model과 domain/persistence model이 섞였다
|
||||
- 바인딩 범위가 불필요하게 넓다
|
||||
- request schema 변경이 domain/persistence 모델에 직접 번진다
|
||||
|
||||
### 예시 2. controller validation만 믿고 domain에서 아무 것도 보장하지 않는다
|
||||
|
||||
```java
|
||||
public final class Email {
|
||||
|
||||
private final String value;
|
||||
|
||||
public Email(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 다른 진입 경로에서 잘못된 값이 들어오면 막지 못한다
|
||||
- domain이 자기 불변식을 보장하지 못한다
|
||||
|
||||
### 예시 3. controller 클래스에 @Validated를 붙여 구식 proxy 방식에 기대한다
|
||||
|
||||
```java
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> get(@PathVariable @NotBlank String userId) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring MVC 6.1+ built-in method validation 대신 class-level AOP proxy 경로로 흐를 수 있다
|
||||
- 이 프로젝트의 controller 규칙과 맞지 않는다
|
||||
|
||||
### 예시 4. filter / interceptor에서 business validation을 수행한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class BadLoginValidationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final LoginPolicyService loginPolicyService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
loginPolicyService.validateLoginWindow();
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- business validation이 web infrastructure 훅으로 새어 나갔다
|
||||
- 요청 바운더리 검증과 use case 규칙이 섞였다
|
||||
|
||||
### 예시 5. service method validation만 믿고 복잡한 정책을 숨긴다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@Validated
|
||||
public class BadCreateSessionService {
|
||||
|
||||
public void create(
|
||||
@NotBlank String email,
|
||||
@NotBlank String password,
|
||||
@NotNull LoginType loginType
|
||||
) {
|
||||
// 복잡한 도메인 정책을 전부 메서드 시그니처 제약에 기대함
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- method validation은 보조 수단이지 핵심 정책 엔진이 아니다
|
||||
- proxy 기반 동작 특성 때문에 경계가 흐려질 수 있다
|
||||
- business rule이 시그니처 제약 뒤에 숨어 버린다
|
||||
@@ -0,0 +1,270 @@
|
||||
# Fixture / Factory 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. factory는 새 유효 객체를 매번 반환한다
|
||||
|
||||
```java
|
||||
public final class UserFactory {
|
||||
|
||||
private UserFactory() {
|
||||
}
|
||||
|
||||
public static User user() {
|
||||
return new User(
|
||||
"user-" + UUID.randomUUID() + "@test.com",
|
||||
"ACTIVE"
|
||||
);
|
||||
}
|
||||
|
||||
public static User user(UnaryOperator<UserBuilder> customizer) {
|
||||
UserBuilder builder = UserBuilder.defaultUser();
|
||||
return customizer.apply(builder).build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 매 호출마다 새 객체를 만든다
|
||||
- 기본값은 유효한 상태다
|
||||
- 테스트는 필요한 값만 override할 수 있다
|
||||
|
||||
JUnit은 기본적으로 테스트 메서드마다 새 테스트 인스턴스를 만들어 격리를 보장하려고 하므로, 테스트 데이터 helper도 같은 방향으로 fresh object를 주는 것이 자연스럽다.
|
||||
|
||||
### 예시 2. fixture 이름이 시나리오를 설명한다
|
||||
|
||||
```java
|
||||
public final class UserFixture {
|
||||
|
||||
private UserFixture() {
|
||||
}
|
||||
|
||||
public static User activeUser() {
|
||||
return UserFactory.user();
|
||||
}
|
||||
|
||||
public static User deletedUser() {
|
||||
return UserFactory.user(builder -> builder.deletedAt(OffsetDateTime.now()));
|
||||
}
|
||||
|
||||
public static User invalidEmailUser() {
|
||||
return UserFactory.user(builder -> builder.email("not-an-email"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- fixture 이름만 봐도 상태 의미가 드러난다
|
||||
- invalid 상태도 명시적으로 분리된다
|
||||
- factory와 fixture의 역할이 나뉜다
|
||||
|
||||
이런 분리는 공식 어노테이션이 강제하는 것은 아니지만, Spring 테스트 지원이 fixture 준비를 쉽게 해 주는 목적과 잘 맞는 실무 패턴이다.
|
||||
|
||||
### 예시 3. repository test에서는 persisted fixture를 분리한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PersistedUserFactory {
|
||||
|
||||
private final EntityManager em;
|
||||
|
||||
public User persistedUser() {
|
||||
User user = UserFactory.user();
|
||||
em.persist(user);
|
||||
em.flush();
|
||||
return user;
|
||||
}
|
||||
|
||||
public User persistedUserAndClear() {
|
||||
User user = UserFactory.user();
|
||||
em.persist(user);
|
||||
em.flush();
|
||||
em.clear();
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- DB에 반영된 fixture와 메모리 상태 fixture를 구분한다
|
||||
- repository test에서 영속성 상태를 더 명확히 다룰 수 있다
|
||||
- 표준 `EntityManager`만 사용하므로 `@DataJpaTest`와 `@SpringBootTest` 양쪽 컨텍스트에서 동일하게 동작한다
|
||||
|
||||
Spring Boot는 `@DataJpaTest` 슬라이스에서 `TestEntityManager`를 `persist`/`flush`/`find` 같은 common testing task를 위한 대안 `EntityManager`로 제공한다고 설명한다. 다만 `TestEntityManager`는 `@DataJpaTest` 컨텍스트에서만 자동 구성되므로, `@SpringBootTest`에서도 재사용할 helper에는 표준 `EntityManager`를 주입하는 것이 안전하다.
|
||||
|
||||
### 예시 4. 핵심 차이는 테스트 본문에 남긴다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail_returnsMatchingUser() {
|
||||
User saved = userRepository.save(
|
||||
UserFactory.user(builder -> builder.email("target@test.com"))
|
||||
);
|
||||
|
||||
Optional<User> result = userRepository.findByEmail("target@test.com");
|
||||
|
||||
assertThat(result).contains(saved);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 반복 필드는 factory가 채우지만, 핵심 조건인 email은 테스트 본문에 드러난다
|
||||
- 테스트를 읽는 사람이 왜 이 테스트가 중요한지 바로 이해할 수 있다
|
||||
- fixture/factory가 assertion의 핵심을 숨기지 않는다
|
||||
|
||||
Spring 테스트 문서는 DI와 테스트 지원이 테스트를 더 쉽게 만들 수 있다고 설명하지만, 그 목적은 테스트 의미를 감추는 것이 아니다.
|
||||
|
||||
### 예시 5. Spring context 테스트에서도 fixture helper는 test support로 분리한다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class UserCommandServiceTest {
|
||||
|
||||
@Autowired
|
||||
private UserCommandService userCommandService;
|
||||
|
||||
@Autowired
|
||||
private PersistedUserFactory persistedUserFactory;
|
||||
|
||||
@Test
|
||||
void deactivateUser_marksUserInactive() {
|
||||
User user = persistedUserFactory.persistedUser();
|
||||
|
||||
userCommandService.deactivate(user.getId());
|
||||
|
||||
// assertion ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- fixture 준비가 재사용 가능하다
|
||||
- 그래도 테스트의 핵심 동작은 서비스 호출과 assertion에 남아 있다
|
||||
- Spring DI는 helper 주입에만 쓰고, fixture 자체를 production 로직처럼 다루지 않는다
|
||||
|
||||
Spring Framework는 테스트 인스턴스에 field, setter, constructor injection을 지원한다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. mutable shared fixture를 static으로 재사용한다
|
||||
|
||||
```java
|
||||
public final class SharedFixtures {
|
||||
public static final User USER = new User("a@test.com", "ACTIVE");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 한 테스트의 변경이 다른 테스트에 영향을 줄 수 있다
|
||||
- JUnit의 기본 per-method 격리 철학과 맞지 않는다
|
||||
- 테스트 순서 의존과 flaky test를 만들기 쉽다
|
||||
|
||||
JUnit은 기본 lifecycle이 테스트 간 mutable state 부작용을 피하기 위한 `PER_METHOD`라고 설명한다.
|
||||
|
||||
### 예시 2. boolean 나열형 factory로 의미를 숨긴다
|
||||
|
||||
```java
|
||||
public static User user(boolean deleted, boolean admin, boolean locked, boolean invalidEmail) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 호출부에서 각 boolean이 무엇을 뜻하는지 바로 알기 어렵다
|
||||
- 상태 의미가 시나리오 이름으로 드러나지 않는다
|
||||
- 잘못된 조합도 쉽게 생긴다
|
||||
|
||||
이런 형태는 fixture/factory가 테스트 가독성을 높여야 한다는 목적에 어긋난다. 프로젝트에서는 명시적 이름의 fixture나 builder override를 선호한다.
|
||||
|
||||
### 예시 3. invalid 상태를 기본 factory에 섞는다
|
||||
|
||||
```java
|
||||
public static User user() {
|
||||
return new User(null, "ACTIVE");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기본 factory가 유효하지 않은 객체를 만든다
|
||||
- 여러 테스트가 뜻하지 않게 invalid 상태를 끌고 들어온다
|
||||
- invalid 검증 테스트와 정상 경로 테스트가 섞인다
|
||||
|
||||
factory 기본값은 특별한 이유가 없으면 유효한 객체여야 테스트 의도가 분명해진다. 이는 프로젝트 fixture/factory 기본 규칙이다.
|
||||
|
||||
### 예시 4. repository test에서 DB round-trip 의미를 helper가 완전히 숨긴다
|
||||
|
||||
```java
|
||||
public User persistedUser() {
|
||||
User user = UserFactory.user();
|
||||
em.persist(user);
|
||||
em.flush();
|
||||
em.clear();
|
||||
return em.find(User.class, user.getId());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 언제 `flush`/`clear`가 일어나는지 테스트 본문에서 보이지 않는다
|
||||
- 어떤 테스트는 `flush`까지만 필요하고, 어떤 테스트는 `clear`가 핵심인데 모두 같은 helper 뒤에 숨는다
|
||||
- repository semantics를 읽기 어렵게 만든다
|
||||
|
||||
`TestEntityManager`는 helper를 제공하지만, 그 목적은 테스트를 보조하는 것이지 중요한 JPA 의미를 완전히 숨기는 것이 아니다.
|
||||
|
||||
### 예시 5. fixture/factory를 production source에 넣는다
|
||||
|
||||
```java
|
||||
src/main/java/com/example/user/UserFixture.java
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 지원 코드가 production code와 경계를 잃는다
|
||||
- 실제 애플리케이션 책임과 테스트 전용 책임이 섞인다
|
||||
- 유지보수 시 production API처럼 오해되기 쉽다
|
||||
|
||||
Spring 테스트 문서는 테스트 인스턴스에 대한 DI를 지원하지만, 테스트 준비 코드를 production source에 두라고 요구하지는 않는다. 프로젝트에서는 test support를 test source에 두는 것이 기본이다.
|
||||
|
||||
### 예시 6. PER_CLASS lifecycle에 기대어 상태를 공유한다
|
||||
|
||||
```java
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class UserRepositoryTest {
|
||||
|
||||
private final List<User> users = new ArrayList<>();
|
||||
|
||||
@Test
|
||||
void test1() {
|
||||
users.add(UserFactory.user());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test2() {
|
||||
assertThat(users).hasSize(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 간 상태가 공유된다
|
||||
- 순서와 실행 방식에 따라 쉽게 깨질 수 있다
|
||||
- fixture 편의 때문에 lifecycle을 바꾼 나쁜 예다
|
||||
|
||||
JUnit은 `PER_CLASS`를 쓰면 instance state를 직접 reset해야 할 수 있고, 기본 lifecycle을 일관되지 않게 바꾸면 fragile build가 될 수 있다고 경고한다.
|
||||
@@ -0,0 +1,316 @@
|
||||
# Mock 사용 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 순수 단위 테스트에서는 MockitoExtension과 @Mock를 사용한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserNotifierTest {
|
||||
|
||||
@Mock
|
||||
private MailSender mailSender;
|
||||
|
||||
private UserNotifier userNotifier;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userNotifier = new UserNotifier(mailSender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendWelcomeMail_delegatesToMailSender() {
|
||||
userNotifier.sendWelcomeMail("a@test.com");
|
||||
|
||||
verify(mailSender).send("a@test.com");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring 컨텍스트 없이 외부 협력자만 대체한다
|
||||
- 테스트 대상 생성이 명시적이라 의존성이 잘 드러난다
|
||||
- interaction verification도 외부 경계에만 한정된다
|
||||
|
||||
Mockito는 `MockitoExtension`이 JUnit Jupiter용 확장이고 strict stubbings를 처리한다고 설명한다. `@InjectMocks`는 편의 기능이지만, 프로젝트 기본값은 명시적 생성자 조립을 우선한다.
|
||||
|
||||
### 예시 2. @InjectMocks는 보조 편의 수단으로 제한적으로 사용한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void findUser_returnsRepositoryResult() {
|
||||
User user = new User(1L, "a@test.com");
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
|
||||
Optional<User> result = userService.findUser(1L);
|
||||
|
||||
assertThat(result).contains(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대상이 단순하고 의존성도 적을 때 보일러플레이트를 줄일 수 있다
|
||||
- `@InjectMocks`를 “자동 wiring 마법”이 아니라 편의 기능으로만 사용한다
|
||||
- 테스트의 핵심 stub과 assertion은 여전히 본문에 남아 있다
|
||||
|
||||
Mockito는 `@InjectMocks`가 constructor/property/setter injection 순서로 mock 주입을 시도한다고 설명한다.
|
||||
|
||||
### 예시 3. Spring 컨텍스트 테스트에서 bean 하나만 대체할 때는 @MockitoBean을 사용한다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class PaymentFacadeTest {
|
||||
|
||||
@MockitoBean
|
||||
private PaymentGatewayClient paymentGatewayClient;
|
||||
|
||||
@Autowired
|
||||
private PaymentFacade paymentFacade;
|
||||
|
||||
@Test
|
||||
void approve_usesGatewayClient() {
|
||||
when(paymentGatewayClient.approve(any())).thenReturn(new GatewayResult(true));
|
||||
|
||||
boolean result = paymentFacade.approve(1L);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- full context가 필요한 테스트에서 특정 bean만 override한다
|
||||
- 신규 기준에 맞는 `@MockitoBean`을 사용한다
|
||||
- 외부 연동 경계만 mock으로 대체한다
|
||||
|
||||
Spring Framework는 `@MockitoBean`이 테스트 `ApplicationContext`의 bean을 Mockito mock으로 override한다고 설명하고, Spring Boot도 이를 공식 테스트 기능으로 안내한다.
|
||||
|
||||
### 예시 4. spy가 꼭 필요하면 doReturn(...).when(spy)...를 사용한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LegacyUserServiceTest {
|
||||
|
||||
@Test
|
||||
void spy_stubsWithoutCallingRealMethod() {
|
||||
LegacyUserService spy = spy(new LegacyUserService());
|
||||
|
||||
doReturn("stubbed").when(spy).loadExternalValue();
|
||||
|
||||
String result = spy.read();
|
||||
|
||||
assertThat(result).isEqualTo("stubbed");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- spy가 필요한 예외 상황에서도 실제 메서드 부작용을 피한다
|
||||
- Mockito가 권장하는 spy stubbing 방식과 맞다
|
||||
- partial mock을 최소 범위로 제한한다
|
||||
|
||||
Mockito는 spy를 carefully and occasionally 사용하라고 설명하고, spy stubbing에는 `doReturn` 계열을 고려하라고 설명한다.
|
||||
|
||||
### 예시 5. verifyNoMoreInteractions()는 정말 의미가 있을 때만 쓴다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuditPublisherTest {
|
||||
|
||||
@Mock
|
||||
private EventBus eventBus;
|
||||
|
||||
@Test
|
||||
void publishExactlyOneAuditEvent() {
|
||||
AuditPublisher publisher = new AuditPublisher(eventBus);
|
||||
|
||||
publisher.publish("LOGIN");
|
||||
|
||||
verify(eventBus).publish("LOGIN");
|
||||
verifyNoMoreInteractions(eventBus);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “정확히 한 번만 발행되어야 한다”는 의미가 테스트 요구와 직접 연결된다
|
||||
- 습관적 사용이 아니라 비즈니스 의미가 있을 때만 사용한다
|
||||
- interaction assertion이 과도하지 않다
|
||||
|
||||
Mockito는 `verifyNoMoreInteractions()`를 every test method에 사용하는 것을 권장하지 않지만, interaction testing toolkit의 일부로는 유용하다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 테스트 대상 자체를 mock한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void findUser() {
|
||||
when(userService.findUser(1L)).thenReturn(Optional.of(new User(1L, "a@test.com")));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 검증하려는 대상을 아예 가짜로 바꿔 버린다
|
||||
- 테스트가 대상 로직을 전혀 실행하지 않는다
|
||||
- mock은 협력자 경계에만 써야 한다
|
||||
|
||||
Mockito mock은 협력 객체를 대체하는 도구이지, 테스트 대상을 없애는 도구가 아니다. Spring의 `@MockitoBean`도 마찬가지로 bean override 용도다.
|
||||
|
||||
### 예시 2. repository test에서 repository를 mock한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail() {
|
||||
when(userRepository.findByEmail("a@test.com"))
|
||||
.thenReturn(Optional.of(new User(1L, "a@test.com")));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- repository 경계의 실제 DB 의미, query, 매핑을 전혀 검증하지 않는다
|
||||
- 이런 테스트는 repository test가 아니라 service 단위 테스트의 협력자 stub에 가깝다
|
||||
- repository 자체 검증은 실제 repository/DB로 해야 한다
|
||||
|
||||
repository test의 목적은 영속성 경계 검증이므로 mock repository는 목적과 맞지 않는다.
|
||||
|
||||
### 예시 3. 모든 테스트에 verifyNoMoreInteractions()를 습관적으로 붙인다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserNotifierTest {
|
||||
|
||||
@Mock
|
||||
private MailSender mailSender;
|
||||
|
||||
@Test
|
||||
void sendWelcomeMail() {
|
||||
UserNotifier notifier = new UserNotifier(mailSender);
|
||||
|
||||
notifier.sendWelcomeMail("a@test.com");
|
||||
|
||||
verify(mailSender).send("a@test.com");
|
||||
verifyNoMoreInteractions(mailSender);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 추가 상호작용 금지가 이 테스트의 핵심 의미가 아닐 수도 있다
|
||||
- 테스트가 불필요하게 취약해진다
|
||||
- Mockito도 이 메서드를 every test method에 쓰는 것은 권장하지 않는다
|
||||
|
||||
Mockito는 `verifyNoMoreInteractions()`를 모든 테스트마다 쓰는 것을 권장하지 않는다고 설명한다.
|
||||
|
||||
### 예시 4. Spring 신규 테스트에서 @MockBean을 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class PaymentFacadeTest {
|
||||
|
||||
@MockBean
|
||||
private PaymentGatewayClient paymentGatewayClient;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 현재 Spring 기준에서는 신규 코드 기본값이 아니다
|
||||
- Spring Boot는 `@MockBean`이 3.4.0부터 deprecated 되었고 `@MockitoBean`을 쓰라고 안내한다
|
||||
- 장기적으로 제거 예정 API에 새 테스트를 얹는 셈이다
|
||||
|
||||
Spring Boot API 문서는 `@MockBean`이 3.4.0부터 4.0.0 제거 예정으로 deprecated 되었고 `MockitoBean`을 대안으로 제시한다.
|
||||
|
||||
### 예시 5. spy에서 when(spy.method())로 실제 메서드를 먼저 호출한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LegacyUserServiceTest {
|
||||
|
||||
@Test
|
||||
void badSpyUsage() {
|
||||
LegacyUserService spy = spy(new LegacyUserService());
|
||||
|
||||
when(spy.loadExternalValue()).thenReturn("stubbed");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- stub 과정에서 실제 메서드가 호출될 수 있다
|
||||
- 부작용이나 예외를 일으킬 수 있다
|
||||
- spy에서는 `doReturn(...).when(spy)...`가 더 안전하다
|
||||
|
||||
Mockito는 spy stubbing에서 `when(...)`가 부적절할 수 있고, `doReturn` 계열을 고려하라고 설명한다.
|
||||
|
||||
### 예시 6. static mock을 길게 열어 두고 일반 테스트처럼 사용한다
|
||||
|
||||
```java
|
||||
@Test
|
||||
void badStaticMockUsage() {
|
||||
MockedStatic<ClockUtil> mocked = mockStatic(ClockUtil.class);
|
||||
mocked.when(ClockUtil::now).thenReturn(Instant.parse("2026-01-01T00:00:00Z"));
|
||||
|
||||
// 여러 로직 수행
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- scope가 길고 close가 명확하지 않다
|
||||
- static mock은 생성된 thread에만 영향을 주고 동시 사용에도 안전하지 않다
|
||||
- try-with-resources로 짧게 감싸는 편이 맞다
|
||||
|
||||
Mockito는 `MockedStatic`이 생성된 thread에만 영향을 주며 concurrent use에 안전하지 않다고 설명한다.
|
||||
|
||||
### 예시 7. non-singleton bean을 무심코 @MockitoBean으로 바꾼다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class ScopedBeanTest {
|
||||
|
||||
@MockitoBean
|
||||
private RequestScopedClient requestScopedClient;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring은 non-singleton bean을 mock/spy하면 singleton처럼 취급될 수 있다고 설명한다
|
||||
- scope 의미가 깨질 수 있다
|
||||
- 이런 경우는 테스트 구조 자체를 다시 설계하는 편이 더 안전하다
|
||||
|
||||
Spring Framework는 non-singleton bean을 `@MockitoBean`/`@MockitoSpyBean`으로 override하면 singleton처럼 다뤄질 수 있다고 설명한다.
|
||||
@@ -0,0 +1,297 @@
|
||||
# Repository Test 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 기본 repository test는 @DataJpaTest로 시작한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail_returnsUser() {
|
||||
// given
|
||||
User user = new User("a@test.com", "active");
|
||||
userRepository.save(user);
|
||||
|
||||
// when
|
||||
Optional<User> result = userRepository.findByEmail("a@test.com");
|
||||
|
||||
// then
|
||||
assertThat(result).isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- JPA slice만 좁게 로딩한다
|
||||
- repository 자체를 테스트 대상으로 유지한다
|
||||
- full application context를 불필요하게 띄우지 않는다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 JPA components에 초점을 맞추고, 엔티티와 repository를 스캔한다고 설명한다.
|
||||
|
||||
### 예시 2. 제약 위반은 flush()까지 가서 검증한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryConstraintTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void save_duplicateEmail_throwsExceptionOnFlush() {
|
||||
userRepository.save(new User("dup@test.com", "active"));
|
||||
userRepository.save(new User("dup@test.com", "active"));
|
||||
|
||||
assertThatThrownBy(() -> userRepository.flush())
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- unique constraint 위반이 실제 DB 동기화 시점에 드러난다는 점을 반영한다
|
||||
- `save()`만 보고 통과한 테스트를 방지한다
|
||||
- 영속성 경계의 실제 실패 시점을 검증한다
|
||||
|
||||
Hibernate의 영속성 컨텍스트는 1차 캐시로 동작하므로, DB 의미를 보려면 `flush`가 중요하다. `TestEntityManager`도 `persistAndFlush` 같은 helper를 제공한다.
|
||||
|
||||
### 예시 3. 실제 재조회 의미를 검증할 때는 flush() 후 clear()를 사용한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryReloadTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private TestEntityManager em;
|
||||
|
||||
@Test
|
||||
void findActiveUsers_excludesDeletedRows() {
|
||||
userRepository.save(new User("a@test.com", "active", null));
|
||||
userRepository.save(new User("b@test.com", "active", OffsetDateTime.now()));
|
||||
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<User> users = userRepository.findAllByDeletedAtIsNullOrderByIdDesc();
|
||||
|
||||
assertThat(users).extracting(User::getEmail)
|
||||
.containsExactly("a@test.com");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 1차 캐시가 아니라 실제 DB round-trip 이후 결과를 검증한다
|
||||
- soft delete predicate 같은 조회 semantics를 더 신뢰도 높게 확인한다
|
||||
- `TestEntityManager`를 보조 도구로만 사용한다
|
||||
|
||||
Hibernate는 persistence context가 transaction-scoped first-level cache로 동작한다고 설명하고, `TestEntityManager`는 test에서 `persist`/`flush`/`find` helper를 제공한다고 설명한다.
|
||||
|
||||
### 예시 4. PostgreSQL 특화 query는 실제 DB 계열에서 검증한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
class PostgresUserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findRecentlyCreatedUsers_worksWithPostgresSpecificQuery() {
|
||||
// given / when / then
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 임베디드 DB 대체를 끄고 실제 DB 계열을 사용하도록 의도를 드러낸다
|
||||
- PostgreSQL native query나 dialect 의존 쿼리를 더 신뢰도 높게 검증할 수 있다
|
||||
- repository test의 범위는 유지하면서 DB 의미를 맞춘다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 기본적으로 임베디드 DB를 구성할 수 있고, 실제 DB를 선호하면 `@AutoConfigureTestDatabase`로 제어할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. commit이 정말 필요한 경우에만 예외적으로 commit 테스트를 쓴다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
@Commit
|
||||
class UserRepositoryCommitTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void savesDataThatMustBeObservedAfterCommit() {
|
||||
userRepository.save(new User("commit@test.com", "active"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 기본 rollback 규칙을 알고, 필요한 경우에만 예외를 사용한다
|
||||
- commit 이후에만 보이는 DB 효과를 검증하는 목적이 분명하다
|
||||
- rollback이 기본, commit은 예외라는 기준을 지킨다
|
||||
|
||||
Spring 테스트는 transactional test를 기본 rollback하고, `@Commit`/`@Rollback`으로 이를 바꿀 수 있다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. repository만 보는데 @SpringBootTest를 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- repository 경계만 볼 테스트에 full application context를 띄운다
|
||||
- 테스트 범위와 비용이 과도하다
|
||||
- `@DataJpaTest`가 더 적합한 기본값이다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`를 JPA slice test로 제공하고, 일반 `@Component`는 로드하지 않는다고 설명한다.
|
||||
|
||||
### 예시 2. 제약 테스트를 flush 없이 작성한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryConstraintTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void save_duplicateEmail_throwsException() {
|
||||
userRepository.save(new User("dup@test.com", "active"));
|
||||
assertThatThrownBy(() ->
|
||||
userRepository.save(new User("dup@test.com", "active"))
|
||||
).isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 DB 제약 위반이 아직 드러나지 않을 수 있다
|
||||
- 영속성 컨텍스트 안에서 테스트가 가짜로 통과하거나 실패 시점이 늦어질 수 있다
|
||||
- 이런 검증은 보통 `flush` 시점까지 가야 한다
|
||||
|
||||
Hibernate의 persistence context는 1차 캐시로 동작하므로, DB 의미를 드러내려면 `flush`가 중요하다.
|
||||
|
||||
### 예시 3. 재조회 검증인데 clear() 없이 같은 엔티티만 본다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryReloadTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findById_readsUpdatedState() {
|
||||
User user = userRepository.save(new User("a@test.com", "active"));
|
||||
user.changeStatus("inactive");
|
||||
|
||||
User found = userRepository.findById(user.getId()).orElseThrow();
|
||||
|
||||
assertThat(found.getStatus()).isEqualTo("inactive");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 persistence context 안의 같은 엔티티 인스턴스를 다시 본 것일 수 있다
|
||||
- 실제 DB에서 다시 읽은 결과인지 보장되지 않는다
|
||||
- 조회 semantics 검증으로는 신뢰도가 낮다
|
||||
|
||||
Hibernate는 `Session`/`EntityManager`가 first-level cache를 유지한다고 설명한다.
|
||||
|
||||
### 예시 4. PostgreSQL 특화 native query를 임베디드 DB만으로 신뢰한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class NativePostgresQueryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void works() {
|
||||
userRepository.runPostgresSpecificQuery();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 DB가 운영 DB 의미를 충분히 재현하지 못할 수 있다
|
||||
- dialect 차이, 함수, JSONB, partial index 전제, locking clause 같은 부분은 놓치기 쉽다
|
||||
- DB 특화 query는 실제 DB 계열 검증이 더 적합하다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 임베디드 DB를 기본 구성할 수 있고, 실제 DB를 쓰려면 `@AutoConfigureTestDatabase`로 제어할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. repository test 안에서 service 정책까지 같이 검증한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 범위가 repository 경계를 벗어난다
|
||||
- slice 목적과 맞지 않는다
|
||||
- service 정책 검증은 상위 통합 테스트 책임이다
|
||||
|
||||
`@DataJpaTest`는 JPA components에만 초점을 맞추고 일반 컴포넌트를 로드하지 않는 slice다.
|
||||
|
||||
### 예시 6. repository test에서 repository 대신 EntityManager만 직접 사용한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager em;
|
||||
|
||||
@Test
|
||||
void test() {
|
||||
em.persist(new User("a@test.com", "active"));
|
||||
em.createQuery("select u from User u", User.class).getResultList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- repository를 검증하겠다면서 repository를 거의 사용하지 않는다
|
||||
- 결국 repository contract가 아니라 JPA API 자체를 테스트하게 된다
|
||||
- `EntityManager`는 보조 도구로만 쓰는 편이 적절하다
|
||||
|
||||
Spring Boot는 `TestEntityManager`를 repository/JPA test의 보조 도구로 제공한다고 설명한다. 중심은 repository여야 한다.
|
||||
@@ -0,0 +1,254 @@
|
||||
# @SpringBootTest 사용 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 애플리케이션 기동 smoke test는 NONE으로 충분하다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
class ApplicationContextSmokeTest {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 실제 서버가 필요 없는 full-context 기동 확인이다
|
||||
- Boot 방식으로 애플리케이션이 정상 조립되는지 확인한다
|
||||
- non-web full-context 테스트에 가장 좁은 환경을 선택했다
|
||||
|
||||
Spring Boot는 `@SpringBootTest`가 `SpringApplication`으로 컨텍스트를 만들고, `NONE`은 웹 환경 없이 `ApplicationContext`만 로드한다고 설명한다.
|
||||
|
||||
### 예시 2. 여러 레이어가 함께 필요한 서비스 통합 테스트는 NONE을 사용한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
class OrderCommandServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private OrderCommandService orderCommandService;
|
||||
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Test
|
||||
void confirmOrder_changesState() {
|
||||
// given
|
||||
|
||||
// when
|
||||
orderCommandService.confirm(1L);
|
||||
|
||||
// then
|
||||
assertThat(orderRepository.findById(1L)).isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 서비스, 리포지토리, 트랜잭션, 설정 조합까지 함께 검증한다
|
||||
- 실제 웹 서버는 필요 없으므로 `NONE`으로 범위를 제한했다
|
||||
- full context가 필요한 이유가 분명하다
|
||||
|
||||
`@SpringBootTest`는 Boot features가 필요한 full application context 테스트에 적합하고, `NONE`은 웹 환경을 만들지 않는다고 Boot 문서가 설명한다.
|
||||
|
||||
### 예시 3. 실제 서버는 필요 없지만 MVC/보안/직렬화까지 함께 보고 싶으면 MOCK + MockMvc를 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@AutoConfigureMockMvc
|
||||
class UserApiIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void getUser_returns200() throws Exception {
|
||||
mockMvc.perform(get("/users/1"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 전체 컨텍스트는 유지하면서 실제 내장 서버는 띄우지 않는다
|
||||
- MVC 설정, 보안 필터, Jackson, 예외 처리 등을 함께 볼 수 있다
|
||||
- mock 기반 웹 통합 테스트 목적과 잘 맞는다
|
||||
|
||||
Spring Boot는 `MOCK`이 내장 서버를 시작하지 않는 mock web environment이고, `@AutoConfigureMockMvc`와 함께 사용할 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. 실제 HTTP round-trip이 필요하면 RANDOM_PORT를 사용한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class UserHttpIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Test
|
||||
void getUser_returns200() {
|
||||
var response = restTemplate.getForEntity("/users/1", String.class);
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 실제 내장 서버와 실제 HTTP 경로를 검증한다
|
||||
- 고정 포트 충돌 없이 자동화 테스트에 적합하다
|
||||
- mock 환경으로는 검증하기 어려운 실제 서버 동작을 본다
|
||||
|
||||
Spring Boot는 `RANDOM_PORT`가 실제 `WebServerApplicationContext`를 만들고 임의 포트에 서버를 시작한다고 설명한다.
|
||||
|
||||
### 예시 5. full context가 필요하지만 테스트 편의 기능도 원하면 @AutoConfigure…를 조합한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@AutoConfigureMockMvc
|
||||
class UserAdminFlowTest {
|
||||
// full context + MockMvc 편의 빈 사용
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- slice로 자를 수는 없지만 테스트 편의 빈은 활용한다
|
||||
- `@SpringBootTest`와 `@AutoConfigure…`의 역할이 분명하다
|
||||
- Boot가 공식적으로 허용한 조합이다
|
||||
|
||||
Spring Boot는 `@AutoConfigure…` 계열을 `@SpringBootTest`와 함께 사용할 수 있다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 순수 단위 테스트에 @SpringBootTest를 붙인다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class MoneyCalculatorTest {
|
||||
|
||||
@Test
|
||||
void add() {
|
||||
MoneyCalculator calculator = new MoneyCalculator();
|
||||
assertThat(calculator.add(1, 2)).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring 컨테이너가 전혀 필요 없다
|
||||
- full context 로딩 비용만 추가한다
|
||||
- 이런 테스트는 순수 JUnit 단위 테스트가 맞다
|
||||
|
||||
Spring 문서는 IoC 덕분에 단위 테스트와 통합 테스트를 구분해서 설계할 수 있다고 설명하고, Boot는 `@SpringBootTest`를 Boot features가 필요할 때 쓰는 어노테이션으로 설명한다.
|
||||
|
||||
### 예시 2. repository만 검증하려고 @SpringBootTest를 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail() {
|
||||
assertThat(userRepository.findByEmail("a@test.com")).isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- JPA repository만 볼 테스트에 전체 애플리케이션을 띄운다
|
||||
- 데이터 접근 slice로 충분한 범위를 과도하게 넓힌다
|
||||
- 테스트 목적 대비 로딩 비용이 크다
|
||||
|
||||
Spring Boot는 `@DataJpaTest` 같은 데이터 slice를 별도로 제공한다고 설명한다.
|
||||
|
||||
### 예시 3. MVC 계층 검증인데 full context를 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class UserControllerTest {
|
||||
// request mapping, validation, status code만 검증
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 관심사가 MVC 계층에 머무르면 `@WebMvcTest`가 더 정확하다
|
||||
- full context를 기본값으로 잡으면 테스트 범위와 책임이 흐려진다
|
||||
- slice로 충분한 대상을 과도하게 넓힌다
|
||||
|
||||
Spring Boot는 Spring MVC controller 테스트에 `@WebMvcTest`를 제공한다고 설명한다.
|
||||
|
||||
### 예시 4. RANDOM_PORT 테스트에서 @Transactional이면 서버 변경도 롤백된다고 기대한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@Transactional
|
||||
class UserHttpRollbackTest {
|
||||
|
||||
@Test
|
||||
void createUser() {
|
||||
// HTTP 호출 후 테스트 종료되면 DB도 원복될 것이라고 기대
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 서버와 테스트 메서드는 별도 스레드/별도 트랜잭션이다
|
||||
- 테스트 메서드 롤백이 서버 쪽 트랜잭션에는 적용되지 않는다
|
||||
- 데이터 정리 전략을 별도로 가져가야 한다
|
||||
|
||||
Spring Boot는 `RANDOM_PORT`/`DEFINED_PORT`에서 서버와 클라이언트가 별도 스레드에서 실행되므로 서버 쪽 트랜잭션은 테스트 롤백으로 되돌아가지 않는다고 명시한다.
|
||||
|
||||
### 예시 5. 여러 slice annotation을 동시에 섞는다
|
||||
|
||||
```java
|
||||
@WebMvcTest
|
||||
@DataJpaTest
|
||||
class MixedSliceTest {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring Boot가 지원하지 않는 조합이다
|
||||
- 테스트 범위가 애매하고 자동 구성도 예측하기 어렵다
|
||||
- slice가 여러 개 필요하면 하나를 고르고 나머지는 수동으로 추가해야 한다
|
||||
|
||||
Spring Boot는 여러 `@…Test` slice annotation을 한 테스트에 함께 사용하는 것은 지원하지 않는다고 설명한다.
|
||||
|
||||
### 예시 6. 작은 차이마다 다른 @SpringBootTest 구성을 만들어 컨텍스트 캐시를 깨뜨린다
|
||||
|
||||
```java
|
||||
@SpringBootTest(properties = "feature.a=true")
|
||||
class ATest {
|
||||
}
|
||||
|
||||
@SpringBootTest(properties = "feature.a=false")
|
||||
class BTest {
|
||||
}
|
||||
|
||||
@SpringBootTest(properties = "feature.b=true")
|
||||
class CTest {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 목적이 비슷한 테스트인데 서로 다른 컨텍스트를 계속 만든다
|
||||
- static context cache 재사용이 줄어든다
|
||||
- 전체 테스트 시간이 불필요하게 늘 수 있다
|
||||
|
||||
Spring TestContext Framework는 `ApplicationContext`를 static cache에 저장해 재사용한다고 설명한다. 컨텍스트 구성이 달라질수록 재사용 이점이 줄어든다.
|
||||
@@ -0,0 +1,218 @@
|
||||
# API Controller 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. controller는 request DTO를 받아 use case를 호출하고 표준 응답을 반환한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(
|
||||
@Valid @RequestBody CreateSessionRequest request
|
||||
) {
|
||||
CreateSessionResult result = createSessionUseCase.create(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.loginType()
|
||||
);
|
||||
|
||||
return ApiResult.success(CreateSessionResponse.from(result));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- @RestController가 API 용도와 맞다
|
||||
- JSON body를 전용 request DTO로 받는다
|
||||
- controller가 use case 호출과 응답 반환에 집중한다
|
||||
|
||||
### 예시 2. ResponseEntity는 HTTP 제어가 필요할 때만 사용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<UserCreatedResponse>> register(
|
||||
@Valid @RequestBody RegisterUserRequest request
|
||||
) {
|
||||
UserCreatedResult result = registerUserUseCase.register(request.email(), request.password());
|
||||
UserCreatedResponse response = UserCreatedResponse.from(result);
|
||||
|
||||
URI location = URI.create("/api/users/" + response.userId());
|
||||
|
||||
return ResponseEntity.created(location)
|
||||
.body(ApiResult.success(response));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 201 Created와 Location 헤더가 필요한 경우에만 ResponseEntity를 사용한다
|
||||
- 모든 endpoint를 습관적으로 ResponseEntity로 감싸지 않는다
|
||||
|
||||
### 예시 3. 입력 출처를 시그니처에 명시한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@PathVariable String userId,
|
||||
@RequestParam(defaultValue = "false") boolean includeInactive
|
||||
) {
|
||||
UserResult result = userQueryUseCase.getUser(userId, includeInactive);
|
||||
return ApiResult.success(UserResponse.from(result));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- path와 query 입력 출처가 시그니처에서 구분된다
|
||||
- HttpServletRequest 전체를 들고 오지 않아도 되는 입력은 annotation으로 처리한다
|
||||
- request id 같은 관측용 헤더는 use case 입력으로 섞지 않는다
|
||||
|
||||
### 예시 4. controller는 예외를 직접 잡지 않는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/password")
|
||||
public class PasswordController {
|
||||
|
||||
private final ChangePasswordUseCase changePasswordUseCase;
|
||||
|
||||
@PostMapping("/change")
|
||||
public ApiResult<Void> changePassword(
|
||||
@Valid @RequestBody ChangePasswordRequest request
|
||||
) {
|
||||
changePasswordUseCase.change(
|
||||
request.userId(),
|
||||
request.currentPassword(),
|
||||
request.newPassword()
|
||||
);
|
||||
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예외는 @RestControllerAdvice에서 통합 처리할 수 있다
|
||||
- controller가 공통 에러 응답 정책을 직접 품지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 repository를 직접 호출한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<User> getUser(@PathVariable Long userId) {
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
return ApiResult.success(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller가 persistence access를 직접 수행한다
|
||||
- domain/entity가 외부 응답 모델로 직접 노출된다
|
||||
- application boundary가 사라진다
|
||||
|
||||
### 예시 2. entity를 request body로 직접 받는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class BadUserCommandController {
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<Void> create(@Valid @RequestBody User user) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- request model과 domain/persistence model이 섞인다
|
||||
- 웹 입력 변경이 domain/entity 구조에 직접 번진다
|
||||
|
||||
### 예시 3. 모든 응답을 습관적으로 ResponseEntity로 감싼다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/health")
|
||||
public class BadHealthController {
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResult<String>> health() {
|
||||
return ResponseEntity.ok(ApiResult.success("ok"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 추가로 제어할 status/header가 없다
|
||||
- 불필요한 ceremony만 늘어난다
|
||||
|
||||
### 예시 4. controller 안에서 공통 예외를 직접 처리한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/sessions")
|
||||
public class BadSessionController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<?>> create(@RequestBody CreateSessionRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResult.success(
|
||||
createSessionUseCase.create(request.email(), request.password(), request.loginType())
|
||||
));
|
||||
} catch (InvalidCredentialException ex) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResult.fail(ErrorCode.INVALID_CREDENTIAL));
|
||||
} catch (Exception ex) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResult.fail(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller마다 예외 정책이 중복된다
|
||||
- 전역 advice 기준과 충돌한다
|
||||
- 정상 흐름과 에러 흐름이 한 메서드에 뒤섞인다
|
||||
@@ -0,0 +1,181 @@
|
||||
# API Versioning 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. path major versioning으로 계약을 명시한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserQueryV1Controller {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserV1Response> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
return ApiResult.success(new UserV1Response(
|
||||
result.userId(),
|
||||
result.email()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v2/users")
|
||||
public class UserQueryV2Controller {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserV2Response> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
return ApiResult.success(new UserV2Response(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- URL만 보고 major version이 드러난다
|
||||
- 버전별 계약 차이가 controller와 DTO에서 명확하다
|
||||
- 내부 use case는 공유하면서 외부 계약은 분리할 수 있다
|
||||
|
||||
### 예시 2. Spring 7+ native version mapping을 제한적으로 활용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/accounts/{id}")
|
||||
public class AccountController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<AccountLegacyResponse> getDefault(@PathVariable String id) {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
|
||||
@GetMapping(version = "1.1")
|
||||
public ApiResult<AccountV11Response> getV11(@PathVariable String id) {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
|
||||
@GetMapping(version = "1.2+")
|
||||
public ApiResult<AccountV12Response> getV12Plus(@PathVariable String id) {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring이 공식 지원하는 version mapping 규칙을 따른다
|
||||
- fixed version과 baseline version의 의미가 분명하다
|
||||
- 단, 이 방식은 Spring 7+에 맞는 선택지다.
|
||||
|
||||
### 예시 3. deprecated version에 sunset 공지를 준비한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/sessions")
|
||||
public class SessionV1Controller {
|
||||
// 구버전 유지
|
||||
}
|
||||
```
|
||||
|
||||
운영 정책 예:
|
||||
|
||||
- 문서에 v1 deprecation 공지
|
||||
- 릴리스 노트에 종료 일정 공지
|
||||
- 응답 헤더에 deprecation/sunset/link 추가
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 버전 종료가 갑작스럽지 않다
|
||||
- 클라이언트가 마이그레이션할 시간을 가진다
|
||||
- Spring도 deprecation 관련 응답 헤더 전송을 지원한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 같은 API 군에서 path와 header versioning을 섞는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class MixedVersionController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<List<UserResponse>> getUsers() {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
|
||||
@GetMapping(headers = "API-Version=2")
|
||||
public ApiResult<List<UserResponse>> getUsersV2() {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 버전 협상 위치가 두 군데다
|
||||
- client, gateway, 문서, 테스트가 모두 복잡해진다
|
||||
- 한 API product 안의 일관성을 깨뜨린다
|
||||
|
||||
### 예시 2. breaking change인데 version을 올리지 않는다
|
||||
|
||||
```java
|
||||
public record UserResponse(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName,
|
||||
String role
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
기존에 email만 응답하던 endpoint가 같은 /api/v1/users/{id} 에서
|
||||
|
||||
- 기존 필드 삭제
|
||||
- 필수 필드 의미 변경
|
||||
- 구조 변경
|
||||
|
||||
을 해 버리는 경우
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기존 client를 조용히 깨뜨린다
|
||||
- versioning 목적 자체를 무력화한다
|
||||
|
||||
### 예시 3. minor/patch를 path에 과하게 노출한다
|
||||
|
||||
```java
|
||||
@RequestMapping("/api/v1.0.3/users")
|
||||
public class UserController {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공개 URL이 불필요하게 복잡해진다
|
||||
- minor/patch 수준 변화까지 client 계약에 노출된다
|
||||
- 프로젝트의 major-only path 전략과 맞지 않는다
|
||||
|
||||
### 예시 4. 버전 누락 시 최신 버전으로 암묵 fallback한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/users/{userId}")
|
||||
public ApiResult<UserResponse> getUser(@PathVariable String userId) {
|
||||
// 내부적으로 최신 버전 계약으로 응답
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- client가 어떤 계약을 호출하는지 불명확하다
|
||||
- 시간이 지나며 응답 의미가 조용히 바뀔 수 있다
|
||||
- 명시적 계약 원칙과 맞지 않는다
|
||||
@@ -0,0 +1,214 @@
|
||||
# Authentication Object Access 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 프로젝트 전용 @CurrentUser를 정의한다
|
||||
|
||||
```java
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@AuthenticationPrincipal
|
||||
public @interface CurrentUser {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller가 Spring Security 애노테이션에 직접 결합되지 않는다
|
||||
- 현재 사용자 접근 규칙이 한 파일에 모인다
|
||||
- Spring 공식 문서도 같은 메타 애노테이션 방식을 예시로 보여 준다.
|
||||
|
||||
### 예시 2. controller는 전용 현재 사용자 타입만 받는다
|
||||
|
||||
```java
|
||||
public record AuthenticatedUser(
|
||||
String userId,
|
||||
Set<String> authorities
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(
|
||||
@CurrentUser AuthenticatedUser currentUser,
|
||||
@Valid @RequestBody CreateSessionRequest request
|
||||
) {
|
||||
CreateSessionResult result = createSessionUseCase.create(
|
||||
new CreateSessionCommand(
|
||||
currentUser.userId(),
|
||||
request.email(),
|
||||
request.password()
|
||||
)
|
||||
);
|
||||
|
||||
return ApiResult.success(new CreateSessionResponse(
|
||||
result.sessionId(),
|
||||
result.accessToken()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller가 현재 사용자 접근을 명시적으로 드러낸다
|
||||
- application에는 필요한 값만 전달한다
|
||||
- SecurityContextHolder 직접 접근이 없다
|
||||
|
||||
### 예시 3. ID만 필요하면 claim/field만 바로 주입한다
|
||||
|
||||
```java
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@AuthenticationPrincipal(expression = "userId")
|
||||
public @interface CurrentUserId {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/password")
|
||||
public class PasswordController {
|
||||
|
||||
private final ChangePasswordUseCase changePasswordUseCase;
|
||||
|
||||
@PostMapping("/change")
|
||||
public ApiResult<Void> changePassword(
|
||||
@CurrentUserId String userId,
|
||||
@Valid @RequestBody ChangePasswordRequest request
|
||||
) {
|
||||
changePasswordUseCase.change(
|
||||
new ChangePasswordCommand(
|
||||
userId,
|
||||
request.currentPassword(),
|
||||
request.newPassword()
|
||||
)
|
||||
);
|
||||
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 필요한 최소 actor 정보만 유스케이스로 간다
|
||||
- current user 타입 전체를 넘기지 않아도 된다
|
||||
- 공식 문서의 expression 기반 메타 애노테이션 패턴과 맞는다.
|
||||
|
||||
### 예시 4. Principal은 단순 확인 endpoint에 제한적으로 쓴다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me")
|
||||
public class MeController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<Map<String, String>> me(Principal principal) {
|
||||
return ApiResult.success(Map.of("name", principal.getName()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 단순 identity 확인 수준에는 충분하다
|
||||
- 복잡한 Security 타입을 노출하지 않는다
|
||||
- Spring MVC가 공식 지원하는 기본 method argument다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 SecurityContextHolder를 직접 읽는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me")
|
||||
public class BadMeController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<String> me() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
|
||||
return ApiResult.success(principal.getUserId());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller가 보안 저장소 접근과 캐스팅 책임까지 가진다
|
||||
- 시그니처에서 현재 사용자 의존이 드러나지 않는다
|
||||
- Spring 공식 문서도 이 패턴보다 @AuthenticationPrincipal 쪽을 권장 예시로 보여 준다.
|
||||
|
||||
### 예시 2. application이 Spring Security 타입을 직접 받는다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class BadChangePasswordService {
|
||||
|
||||
public void change(Authentication authentication, String currentPassword, String newPassword) {
|
||||
String userId = ((CustomUserPrincipal) authentication.getPrincipal()).getUserId();
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 Spring Security에 결합된다
|
||||
- 유스케이스 입력이 보안 프레임워크 타입에 종속된다
|
||||
- 테스트와 재사용성이 나빠진다
|
||||
|
||||
### 예시 3. controller가 role check로 인가를 직접 처리한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin")
|
||||
public class BadAdminController {
|
||||
|
||||
@PostMapping("/users/{userId}/lock")
|
||||
public ApiResult<Void> lock(
|
||||
@CurrentUser AuthenticatedUser currentUser,
|
||||
@PathVariable String userId
|
||||
) {
|
||||
if (!currentUser.authorities().contains("ROLE_ADMIN")) {
|
||||
throw new AccessDeniedException("forbidden");
|
||||
}
|
||||
|
||||
// ...
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 인가 규칙이 controller imperative code로 새어 나갔다
|
||||
- security rule/method security와 역할이 충돌한다
|
||||
- defense in depth 구조가 흐려진다.
|
||||
|
||||
### 예시 4. JWT claim을 여러 계층에서 직접 파싱한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class BadUserService {
|
||||
|
||||
public void doSomething(JwtAuthenticationToken authentication) {
|
||||
String userId = authentication.getToken().getClaimAsString("sub");
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 특정 인증 메커니즘(JWT claim 구조)에 결합된다
|
||||
- principal 해석 책임이 security adapter에 모이지 않는다
|
||||
- 토큰 구조 변경이 여러 계층으로 번진다
|
||||
@@ -0,0 +1,185 @@
|
||||
# Error Code / HTTP Status Separation 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. ErrorCode가 status와 외부 메시지를 함께 관리한다
|
||||
|
||||
```java
|
||||
public enum ErrorCode {
|
||||
REQUEST_VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "REQUEST_VALIDATION_FAILED", "Request validation failed"),
|
||||
MALFORMED_JSON_REQUEST(HttpStatus.BAD_REQUEST, "MALFORMED_JSON_REQUEST", "Malformed request body"),
|
||||
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "DUPLICATE_EMAIL", "Email already exists"),
|
||||
INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_ACCESS_TOKEN", "Invalid access token"),
|
||||
ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "Access denied"),
|
||||
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "User not found"),
|
||||
UPSTREAM_AUTH_SERVER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "UPSTREAM_AUTH_SERVER_UNAVAILABLE", "Authentication server is temporarily unavailable"),
|
||||
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Unexpected server error");
|
||||
|
||||
private final HttpStatus httpStatus;
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
ErrorCode(HttpStatus httpStatus, String code, String message) {
|
||||
this.httpStatus = httpStatus;
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public HttpStatus httpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- HTTP status와 application code가 함께 정책화된다
|
||||
- 문자열 하드코딩이 흩어지지 않는다
|
||||
- 같은 code가 어디서든 같은 기본 status를 갖는다
|
||||
|
||||
### 예시 2. advice는 예외를 ErrorCode로 매핑하고, status와 body를 함께 만든다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DuplicateEmailException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail() {
|
||||
ErrorCode errorCode = ErrorCode.DUPLICATE_EMAIL;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidAccessTokenException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleInvalidAccessToken() {
|
||||
ErrorCode errorCode = ErrorCode.INVALID_ACCESS_TOKEN;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(UserNotFoundException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleUserNotFound() {
|
||||
ErrorCode errorCode = ErrorCode.USER_NOT_FOUND;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- status와 body code가 같은 정책 타입에서 나온다
|
||||
- controller가 실패 응답을 직접 만들지 않는다
|
||||
- ErrorCode와 HTTP status 역할이 모두 드러난다
|
||||
|
||||
### 예시 3. 같은 400 계열 아래 여러 세부 ErrorCode를 둔다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class RequestExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex
|
||||
) {
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
ErrorCode errorCode = ErrorCode.REQUEST_VALIDATION_FAILED;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode, errors));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleMalformedJson() {
|
||||
ErrorCode errorCode = ErrorCode.MALFORMED_JSON_REQUEST;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 둘 다 400이지만 세부 원인은 ErrorCode로 구분된다
|
||||
- status는 넓은 범주, code는 세부 식별자라는 역할 분리가 분명하다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 모든 실패를 200으로 응답한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(DuplicateEmailException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail() {
|
||||
return ResponseEntity.ok(ApiResult.fail(ErrorCode.DUPLICATE_EMAIL));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- body는 실패인데 HTTP status는 성공이다
|
||||
- HTTP semantics와 application semantics가 충돌한다
|
||||
|
||||
### 예시 2. custom 6xx status를 사용한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(UpstreamAuthServerUnavailableException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleUpstreamFailure() {
|
||||
return ResponseEntity.status(601)
|
||||
.body(ApiResult.fail(ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 601은 유효한 HTTP status가 아니다
|
||||
- 세부 원인 구분은 ErrorCode로 해야 한다
|
||||
|
||||
### 예시 3. @ResponseStatus(reason=...)를 REST API 기본 실패 전략으로 사용한다
|
||||
|
||||
```java
|
||||
@ResponseStatus(code = HttpStatus.CONFLICT, reason = "Email already exists")
|
||||
public class DuplicateEmailException extends RuntimeException {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- HTTP status와 REST body 정책을 예외 클래스에 고정해 버린다
|
||||
- reason 기반 sendError는 REST API 응답 규약과 잘 맞지 않는다
|
||||
- body envelope 통일과 충돌하기 쉽다
|
||||
|
||||
### 예시 4. advice에서 문자열 코드와 예외 메시지를 직접 하드코딩한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(DuplicateEmailException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail(DuplicateEmailException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResult.fail("DUPLICATE_EMAIL", ex.getMessage()));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- status/code/message 정책이 중앙화되지 않는다
|
||||
- 외부 메시지와 내부 예외 메시지가 섞인다
|
||||
- 다른 파일에서도 같은 문자열이 반복되기 쉽다
|
||||
@@ -0,0 +1,290 @@
|
||||
# Idempotency 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 멱등 키가 필요한 POST endpoint는 헤더를 명시적으로 받는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<CreateUserResponse>> register(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody CreateUserRequest request,
|
||||
AuthenticatedUser authenticatedUser
|
||||
) {
|
||||
CreateUserCommand command = new CreateUserCommand(
|
||||
authenticatedUser.userId(),
|
||||
idempotencyKey,
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
CreateUserResult result = registerUserUseCase.register(command);
|
||||
|
||||
return ResponseEntity.created(URI.create("/api/v1/users/" + result.userId()))
|
||||
.body(ApiResult.success(new CreateUserResponse(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
)));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller는 헤더를 읽고 command로 전달만 한다
|
||||
- 멱등성 구현 책임이 controller에 머무르지 않는다
|
||||
- POST 생성 endpoint에서 멱등 키 요구가 명확하다
|
||||
|
||||
### 예시 2. application/service에서 키 + fingerprint로 중복을 판정한다
|
||||
|
||||
```java
|
||||
public record IdempotencyScope(
|
||||
String actorId,
|
||||
String operation
|
||||
) {
|
||||
}
|
||||
|
||||
public record StoredRegistrationResult(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
public interface IdempotencyStore {
|
||||
Optional<StoredRegistrationResult> findCompleted(
|
||||
IdempotencyScope scope,
|
||||
String key,
|
||||
String fingerprint
|
||||
);
|
||||
|
||||
IdempotencyStartResult tryStart(
|
||||
IdempotencyScope scope,
|
||||
String key,
|
||||
String fingerprint
|
||||
);
|
||||
|
||||
void complete(
|
||||
IdempotencyScope scope,
|
||||
String key,
|
||||
String fingerprint,
|
||||
StoredRegistrationResult result
|
||||
);
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RegisterUserUseCase {
|
||||
|
||||
private final IdempotencyStore idempotencyStore;
|
||||
private final UserRegistrationService userRegistrationService;
|
||||
|
||||
public CreateUserResult register(CreateUserCommand command) {
|
||||
IdempotencyScope scope = new IdempotencyScope(command.actorId(), "register-user");
|
||||
String fingerprint = fingerprint(command);
|
||||
|
||||
idempotencyStore.findCompleted(scope, command.idempotencyKey(), fingerprint)
|
||||
.ifPresent(storedResponse -> {
|
||||
throw new IdempotentReplayException(storedResponse);
|
||||
});
|
||||
|
||||
IdempotencyStartResult startResult = idempotencyStore.tryStart(
|
||||
scope,
|
||||
command.idempotencyKey(),
|
||||
fingerprint
|
||||
);
|
||||
|
||||
if (startResult == IdempotencyStartResult.IN_PROGRESS) {
|
||||
throw new IdempotencyRequestInProgressException();
|
||||
}
|
||||
|
||||
if (startResult == IdempotencyStartResult.KEY_REUSED_WITH_DIFFERENT_REQUEST) {
|
||||
throw new IdempotencyKeyMismatchException();
|
||||
}
|
||||
|
||||
CreateUserResult result = userRegistrationService.register(command);
|
||||
|
||||
StoredRegistrationResult storedResult = new StoredRegistrationResult(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
);
|
||||
|
||||
idempotencyStore.complete(scope, command.idempotencyKey(), fingerprint, storedResult);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private String fingerprint(CreateUserCommand command) {
|
||||
return DigestUtils.sha256Hex(
|
||||
command.actorId() + "|" +
|
||||
command.email() + "|" +
|
||||
command.displayName()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 멱등성 판정이 application 경계에 있다
|
||||
- key뿐 아니라 fingerprint도 비교한다
|
||||
- 완료 결과 재생, 진행 중 충돌, key 재사용 충돌을 분리한다
|
||||
- application은 HTTP status, ApiResult, JSON 직렬화 세부를 알지 않는다
|
||||
|
||||
### 예시 3. 멱등성 오류도 공통 에러 응답 규약으로 처리한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class IdempotencyExceptionHandler {
|
||||
|
||||
@ExceptionHandler(IdempotencyKeyMissingException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleMissingKey() {
|
||||
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_KEY_REQUIRED;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IdempotencyRequestInProgressException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleInProgress() {
|
||||
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_REQUEST_IN_PROGRESS;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IdempotencyKeyMismatchException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleMismatch() {
|
||||
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 400/409/422 같은 HTTP status와 application error code를 함께 유지한다
|
||||
- 멱등성 오류도 전체 API 에러 규약에 맞춰진다
|
||||
|
||||
### 예시 4. key는 opaque UUID를 사용한다
|
||||
|
||||
요청 예:
|
||||
|
||||
```http
|
||||
POST /api/v1/users
|
||||
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 민감정보가 없다
|
||||
- 재시도 시 같은 key를 다시 보낼 수 있다
|
||||
- 운영/추적에도 적당한 opaque identifier다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. GET에 멱등 키를 요구한다
|
||||
|
||||
```java
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@PathVariable String userId
|
||||
) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- GET은 이미 safe/idempotent다
|
||||
- 불필요한 계약 복잡도만 늘어난다
|
||||
|
||||
### 예시 2. 서버가 멱등 키를 생성한다
|
||||
|
||||
```java
|
||||
@PostMapping
|
||||
public ApiResult<CreateUserResponse> register(@RequestBody CreateUserRequest request) {
|
||||
String idempotencyKey = UUID.randomUUID().toString();
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- client가 타임아웃 후 같은 요청을 재시도할 때 같은 key를 다시 보낼 수 없다
|
||||
- 재시도 안전성이라는 목적을 달성하지 못한다
|
||||
|
||||
### 예시 3. 이메일을 멱등 키로 사용한다
|
||||
|
||||
```http
|
||||
Idempotency-Key: donghyun@example.com
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 개인정보가 key에 노출된다
|
||||
- 요청 의도 식별자와 사용자 식별자가 뒤섞인다
|
||||
- 같은 사용자의 다른 요청을 구분하기 어렵다
|
||||
|
||||
### 예시 4. controller의 로컬 메모리로만 중복을 막는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final Set<String> processedKeys = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<Void> register(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestBody CreateUserRequest request
|
||||
) {
|
||||
if (!processedKeys.add(idempotencyKey)) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
|
||||
// 실제 생성 처리
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 다중 인스턴스 환경에서 깨진다
|
||||
- fingerprint 비교가 없다
|
||||
- 애플리케이션 재기동 시 기록이 사라진다
|
||||
- controller가 멱등성 구현 책임까지 떠안는다
|
||||
|
||||
### 예시 5. 같은 key를 다른 payload에 재사용해도 새 요청으로 처리한다
|
||||
|
||||
```java
|
||||
public void handle(String key, CreateUserRequest request) {
|
||||
if (store.contains(key)) {
|
||||
process(request); // 그냥 다시 처리
|
||||
return;
|
||||
}
|
||||
process(request);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- key 재사용 오용을 막지 못한다
|
||||
- 중복 생성/중복 실행 위험이 남는다
|
||||
- “같은 요청의 재시도”와 “다른 요청”을 구분하지 못한다
|
||||
@@ -0,0 +1,285 @@
|
||||
# Pagination / Sort / Filter 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 일반 목록 조회는 명시적 query DTO + page 응답으로 표현한다
|
||||
|
||||
```java
|
||||
public record UserListRequest(
|
||||
@Min(1) int page,
|
||||
@Min(1) @Max(100) int size,
|
||||
String keyword,
|
||||
UserStatus status,
|
||||
String sortBy,
|
||||
SortDirection direction
|
||||
) {
|
||||
}
|
||||
|
||||
public record PageResponse<T>(
|
||||
List<T> items,
|
||||
int page,
|
||||
int size,
|
||||
boolean hasNext,
|
||||
Long totalCount
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final SearchUsersUseCase searchUsersUseCase;
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<PageResponse<UserResponse>> search(
|
||||
@Valid @ModelAttribute UserListRequest request
|
||||
) {
|
||||
SearchUsersQuery query = SearchUsersQuery.of(
|
||||
request.page(),
|
||||
request.size(),
|
||||
request.keyword(),
|
||||
request.status(),
|
||||
request.sortBy(),
|
||||
request.direction()
|
||||
);
|
||||
|
||||
UserPageResult result = searchUsersUseCase.search(query);
|
||||
|
||||
return ApiResult.success(new PageResponse<>(
|
||||
result.items().stream()
|
||||
.map(UserResponse::from)
|
||||
.toList(),
|
||||
result.page(),
|
||||
result.size(),
|
||||
result.hasNext(),
|
||||
result.totalCount()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 계약이 명시적이다
|
||||
- query input과 내부 paging 모델이 분리된다
|
||||
- 응답도 raw Page가 아니라 API 전용 DTO다
|
||||
|
||||
### 예시 2. sort field는 allowlist로 받고, persistence adapter에서 내부 정렬로 변환한다
|
||||
|
||||
```java
|
||||
public enum UserSortField {
|
||||
CREATED_AT("createdAt"),
|
||||
DISPLAY_NAME("displayName");
|
||||
|
||||
private final String externalName;
|
||||
|
||||
UserSortField(String externalName) {
|
||||
this.externalName = externalName;
|
||||
}
|
||||
|
||||
public static UserSortField from(String value) {
|
||||
return Arrays.stream(values())
|
||||
.filter(field -> field.externalName.equals(value))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new InvalidSortFieldException(value));
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
public class JpaUserSortMapper {
|
||||
|
||||
public Sort toSort(UserSortField sortField, SortDirection direction) {
|
||||
return switch (sortField) {
|
||||
case CREATED_AT -> Sort.by(direction.toSpring(), "createdAt", "id");
|
||||
case DISPLAY_NAME -> Sort.by(direction.toSpring(), "displayName", "id");
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 정렬 키와 내부 컬럼/프로퍼티가 분리된다
|
||||
- unsupported sort field를 명시적으로 거절할 수 있다
|
||||
- tie-breaker가 포함되어 정렬이 안정적이다
|
||||
- Spring Data `Sort` 변환은 persistence adapter 경계에 머문다
|
||||
|
||||
### 예시 3. count가 필요 없으면 Slice 스타일 응답으로 줄인다
|
||||
|
||||
```java
|
||||
public record SliceResponse<T>(
|
||||
List<T> items,
|
||||
int page,
|
||||
int size,
|
||||
boolean hasNext
|
||||
) {
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SearchAuditLogUseCase {
|
||||
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
|
||||
public AuditLogSliceResult search(SearchAuditLogQuery query) {
|
||||
return auditLogRepository.findSliceByCondition(query);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- application은 count가 필요 없는 slice 결과를 typed result로 반환한다
|
||||
- Spring Data `Slice` / `PageRequest`와 response DTO 변환은 바깥 adapter 책임으로 남긴다
|
||||
|
||||
### 예시 4. 대용량 피드는 cursor pagination을 쓴다
|
||||
|
||||
```java
|
||||
public record CursorPageResponse<T>(
|
||||
List<T> items,
|
||||
String nextCursor,
|
||||
boolean hasNext
|
||||
) {
|
||||
}
|
||||
|
||||
public record TimelineRequest(
|
||||
String cursor,
|
||||
@Min(1) @Max(100) int size
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/timeline")
|
||||
public class TimelineController {
|
||||
|
||||
private final ReadTimelineUseCase readTimelineUseCase;
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<CursorPageResponse<TimelineItemResponse>> read(
|
||||
@Valid @ModelAttribute TimelineRequest request
|
||||
) {
|
||||
TimelineWindowResult result = readTimelineUseCase.read(request.cursor(), request.size());
|
||||
|
||||
return ApiResult.success(new CursorPageResponse<>(
|
||||
result.items(),
|
||||
result.nextCursor(),
|
||||
result.hasNext()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대용량/변동이 큰 목록에 더 적합하다
|
||||
- 외부에는 opaque cursor만 노출한다
|
||||
- page number 깊이에 따라 성능이 급격히 나빠지는 구조를 피할 수 있다
|
||||
|
||||
### 예시 5. 다중 값 필터는 반복 query parameter로 받는다
|
||||
|
||||
```java
|
||||
public record UserSearchRequest(
|
||||
List<UserStatus> status,
|
||||
String keyword,
|
||||
@Min(1) int page,
|
||||
@Min(1) @Max(100) int size
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
요청 예:
|
||||
|
||||
```http
|
||||
GET /api/v1/users?status=ACTIVE&status=PENDING&keyword=kim&page=1&size=20
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 다중 값 필터가 명확하다
|
||||
- query parameter 규약이 읽기 쉽다
|
||||
- Spring 바인딩과도 자연스럽게 맞는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 공개 API controller에 raw Pageable을 그대로 노출한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping
|
||||
public Page<User> getUsers(Pageable pageable) {
|
||||
return userRepository.findAll(pageable);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 외부 계약이 Spring Data 내부 타입에 종속된다
|
||||
- entity와 raw Page가 그대로 노출된다
|
||||
- request/response 계약을 프로젝트가 통제하기 어렵다
|
||||
|
||||
### 예시 2. 지원하지 않는 sort field를 조용히 무시한다
|
||||
|
||||
```java
|
||||
public Sort toSort(String sortBy, SortDirection direction) {
|
||||
if ("createdAt".equals(sortBy)) {
|
||||
return Sort.by(direction.toSpring(), "createdAt");
|
||||
}
|
||||
return Sort.unsorted();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 잘못된 요청을 성공처럼 처리한다
|
||||
- client는 정렬이 적용된 줄 오해할 수 있다
|
||||
- unsupported sort는 명시적으로 거절해야 한다
|
||||
|
||||
### 예시 3. 깊은 페이지까지 offset만 강제한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/v1/events")
|
||||
public ApiResult<PageResponse<EventResponse>> getEvents(
|
||||
@RequestParam int page,
|
||||
@RequestParam int size
|
||||
) {
|
||||
// 수백만 건 로그를 무조건 offset paging으로 조회
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 큰 offset paging은 성능이 급격히 나빠질 수 있다
|
||||
- 이벤트/로그/피드 계열에는 cursor 전략 검토가 필요하다
|
||||
|
||||
### 예시 4. 페이지마다 sort/filter가 달라질 수 있게 한다
|
||||
|
||||
```http
|
||||
GET /api/v1/users?page=1&size=20&sortBy=createdAt&direction=desc
|
||||
GET /api/v1/users?page=2&size=20&sortBy=displayName&direction=asc
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 목록의 다음 페이지라는 의미가 깨진다
|
||||
- 중복/누락/순서 흔들림이 생길 수 있다
|
||||
|
||||
### 예시 5. 범용 filter DSL을 기본 공개 API에 도입한다
|
||||
|
||||
```http
|
||||
GET /api/v1/users?filter=(status eq ACTIVE and (createdAt gt 2026-01-01)) or (role in [ADMIN,OWNER])
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 단순 목록 API치고 계약이 과도하게 복잡하다
|
||||
- 문서화, 검증, 운영 비용이 커진다
|
||||
- 기본 공개 API로는 명시적 필터 파라미터가 더 낫다
|
||||
@@ -0,0 +1,202 @@
|
||||
# Request / Response DTO 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. request와 response를 명확히 분리한다
|
||||
|
||||
```java
|
||||
public record CreateUserRequest(
|
||||
@NotBlank String email,
|
||||
@NotBlank String password,
|
||||
@NotBlank String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
public record CreateUserResponse(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateUserResponse> register(@Valid @RequestBody CreateUserRequest request) {
|
||||
RegisteredUser registeredUser = registerUserUseCase.register(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
return ApiResult.success(new CreateUserResponse(
|
||||
registeredUser.userId(),
|
||||
registeredUser.email(),
|
||||
registeredUser.displayName()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- request와 response 역할이 분리된다
|
||||
- request DTO가 그대로 내부 모델처럼 전파되지 않는다
|
||||
- 응답이 entity 구조가 아니라 API 계약 중심으로 표현된다
|
||||
|
||||
### 예시 2. query/form 입력은 전용 @ModelAttribute DTO로 받는다
|
||||
|
||||
```java
|
||||
public record UserSearchRequest(
|
||||
@NotBlank String keyword,
|
||||
@Min(1) int page,
|
||||
@Min(1) @Max(100) int size
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final SearchUsersUseCase searchUsersUseCase;
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<UserSearchResponse> search(@Valid @ModelAttribute UserSearchRequest request) {
|
||||
UserSearchResult result = searchUsersUseCase.search(
|
||||
request.keyword(),
|
||||
request.page(),
|
||||
request.size()
|
||||
);
|
||||
|
||||
return ApiResult.success(UserSearchResponse.from(result));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- query 입력도 전용 web model로 분리된다
|
||||
- @ModelAttribute 대상이 domain/entity가 아니다
|
||||
- 검색 조건과 응답 모델이 분리된다
|
||||
|
||||
### 예시 3. ResponseEntity는 HTTP 제어가 필요할 때만 사용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<CreateUserResponse>> register(
|
||||
@Valid @RequestBody CreateUserRequest request
|
||||
) {
|
||||
RegisteredUser registeredUser = registerUserUseCase.register(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
CreateUserResponse response = new CreateUserResponse(
|
||||
registeredUser.userId(),
|
||||
registeredUser.email(),
|
||||
registeredUser.displayName()
|
||||
);
|
||||
|
||||
return ResponseEntity.created(URI.create("/api/users/" + response.userId()))
|
||||
.body(ApiResult.success(response));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 201 Created와 Location 제어가 필요해 ResponseEntity 사용 이유가 분명하다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. entity를 request body로 직접 받는다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String role;
|
||||
}
|
||||
|
||||
@PostMapping("/api/users")
|
||||
public ApiResult<Void> create(@Valid @RequestBody User user) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- web input model과 persistence/domain model이 섞인다
|
||||
- 바인딩 범위가 불필요하게 넓다
|
||||
- API 변경이 entity 구조에 직접 번진다
|
||||
|
||||
### 예시 2. request와 response를 하나의 DTO로 재사용한다
|
||||
|
||||
```java
|
||||
public record UserDto(
|
||||
String userId,
|
||||
String email,
|
||||
String password,
|
||||
String displayName,
|
||||
String role
|
||||
) {
|
||||
}
|
||||
|
||||
@PostMapping("/api/users")
|
||||
public ApiResult<UserDto> create(@RequestBody UserDto request) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 요청과 응답의 책임이 섞인다
|
||||
- 응답에 불필요하거나 민감한 필드가 섞이기 쉽다
|
||||
- write model과 read model이 분리되지 않는다
|
||||
|
||||
### 예시 3. DTO를 내부 모델처럼 그대로 넘긴다
|
||||
|
||||
```java
|
||||
@PostMapping("/api/users")
|
||||
public ApiResult<Void> create(@Valid @RequestBody CreateUserRequest request) {
|
||||
registerUserUseCase.register(request);
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- request DTO가 presentation 경계를 넘어 application 시그니처로 새어 나간다
|
||||
- 내부 use case가 transport model에 결합된다
|
||||
|
||||
### 예시 4. 응답으로 entity를 직접 반환한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/users/{userId}")
|
||||
public ApiResult<User> get(@PathVariable Long userId) {
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
return ApiResult.success(user);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- persistence/domain 구조가 외부 계약이 된다
|
||||
- 내부 필드가 의도치 않게 노출되기 쉽다
|
||||
- controller가 repository와 entity에 직접 결합된다
|
||||
@@ -0,0 +1,233 @@
|
||||
# Response Format 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 일반 성공 응답은 ApiResult<T>로 반환한다
|
||||
|
||||
```java
|
||||
public record UserResponse(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
|
||||
return ApiResult.success(new UserResponse(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 성공 응답 형식이 명확하다
|
||||
- business payload와 공통 envelope가 분리된다
|
||||
- controller가 임시 JSON을 조립하지 않는다
|
||||
|
||||
### 예시 2. HTTP 제어가 필요할 때만 ResponseEntity<ApiResult<T>>를 사용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<CreateUserResponse>> register(
|
||||
@Valid @RequestBody CreateUserRequest request
|
||||
) {
|
||||
RegisteredUser result = registerUserUseCase.register(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
CreateUserResponse response = new CreateUserResponse(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
);
|
||||
|
||||
return ResponseEntity.created(URI.create("/api/users/" + response.userId()))
|
||||
.body(ApiResult.success(response));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- ResponseEntity 사용 이유가 201 Created + Location으로 분명하다
|
||||
- body 형식은 여전히 프로젝트 표준을 따른다
|
||||
|
||||
### 예시 3. 실패 응답은 advice에서 ApiResult로 통일한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex
|
||||
) {
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResult.fail(ErrorCode.REQUEST_VALIDATION_FAILED, errors));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예외 응답 형식이 중앙에서 통일된다
|
||||
- controller가 실패 body를 직접 조립하지 않는다
|
||||
- 상세 오류 정보가 규칙적으로 담긴다
|
||||
|
||||
### 예시 4. 전역 응답 래핑은 이중 래핑을 피한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class<?> converterType) {
|
||||
Class<?> parameterType = returnType.getParameterType();
|
||||
|
||||
return !ApiResult.class.isAssignableFrom(parameterType)
|
||||
&& !ResponseEntity.class.isAssignableFrom(parameterType)
|
||||
&& !Resource.class.isAssignableFrom(parameterType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response
|
||||
) {
|
||||
if (body == null) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
|
||||
if (body instanceof ApiResult<?>) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return ApiResult.success(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 공통 envelope 적용 지점이 분명하다
|
||||
- 이미 래핑된 응답을 다시 감싸지 않는다
|
||||
- file/resource 응답을 무심코 건드리지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller마다 임시 응답 구조를 만든다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/users/{userId}")
|
||||
public Map<String, Object> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("ok", true);
|
||||
response.put("payload", result);
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 프로젝트 공통 응답 형식을 깨뜨린다
|
||||
- 다른 endpoint와 구조가 달라진다
|
||||
- 임시 필드명이 계약이 되어 버린다
|
||||
|
||||
### 예시 2. ResponseEntity를 의미 없이 남발한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/health")
|
||||
public ResponseEntity<ApiResult<String>> health() {
|
||||
return ResponseEntity.ok(ApiResult.success("ok"));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 별도 header/status 제어가 없다
|
||||
- 불필요한 ceremony만 늘어난다
|
||||
|
||||
### 예시 3. 실패 응답에 내부 예외 메시지를 그대로 노출한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResult<Void>> handle(Exception ex) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(ApiResult.fail("INTERNAL_SERVER_ERROR", ex.getMessage()));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 내부 메시지가 외부 계약이 된다
|
||||
- 민감한 구현 세부사항이 노출될 수 있다
|
||||
- 외부 응답 메시지 정책이 없다
|
||||
|
||||
### 예시 4. ResponseBodyAdvice에서 무조건 감싼다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class BadApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class<?> converterType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response
|
||||
) {
|
||||
return ApiResult.success(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 이미 ApiResult인 응답도 이중 래핑한다
|
||||
- 파일/리소스/스트리밍 응답을 망가뜨릴 수 있다
|
||||
- 규약 적용이 아니라 무차별 변환이 된다
|
||||
@@ -0,0 +1,234 @@
|
||||
# Serialization / Jackson 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. request/response DTO를 분리하고 timestamp는 offset 기반으로 노출한다
|
||||
|
||||
```java
|
||||
public record CreateSessionRequest(
|
||||
@NotBlank String email,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
|
||||
public record CreateSessionResponse(
|
||||
String sessionId,
|
||||
String accessToken,
|
||||
OffsetDateTime issuedAt,
|
||||
OffsetDateTime expiresAt
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(
|
||||
@Valid @RequestBody CreateSessionRequest request
|
||||
) {
|
||||
SessionResult result = createSessionUseCase.create(request.email(), request.password());
|
||||
|
||||
return ApiResult.success(new CreateSessionResponse(
|
||||
result.sessionId(),
|
||||
result.accessToken(),
|
||||
result.issuedAt(),
|
||||
result.expiresAt()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- request/response 계약이 분리된다
|
||||
- password는 응답 DTO에 존재하지 않는다
|
||||
- timestamp가 OffsetDateTime으로 명확하다
|
||||
|
||||
### 예시 2. 외부 공급자 webhook DTO만 lenient하게 받는다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ExternalAuthWebhookRequest(
|
||||
String eventId,
|
||||
String eventType,
|
||||
String subjectId
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 공급자가 필드를 추가해도 파싱이 덜 깨진다
|
||||
- lenient 정책이 third-party integration DTO로 국소화된다
|
||||
- first-party API request DTO와 기준이 분리된다
|
||||
|
||||
### 예시 3. 외부 계약 이름 mismatch만 @JsonProperty로 보정한다
|
||||
|
||||
```java
|
||||
public record ExternalUserResponse(
|
||||
@JsonProperty("user_id") String userId,
|
||||
@JsonProperty("display_name") String displayName
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 내부 표준 naming을 전체 프로젝트에 퍼뜨리지 않는다
|
||||
- mismatch를 DTO 경계에서 해결한다
|
||||
|
||||
### 예시 4. write-only 필드는 예외적으로만 사용한다
|
||||
|
||||
```java
|
||||
public record ResetPasswordCommandRequest(
|
||||
@NotBlank String userId,
|
||||
@NotBlank @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String newPassword
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 입력만 받고 다시 내보내면 안 되는 필드를 제한적으로 막는다
|
||||
- 그래도 request DTO 안에 국소화돼 있다
|
||||
|
||||
### 예시 5. 공통 직렬화 예외는 전역 컴포넌트로 등록한다
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
Module userIdModule() {
|
||||
SimpleModule module = new SimpleModule();
|
||||
module.addSerializer(UserId.class, new JsonSerializer<>() {
|
||||
@Override
|
||||
public void serialize(UserId value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
gen.writeString(value.value());
|
||||
}
|
||||
});
|
||||
return module;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 반복되는 값 객체 직렬화를 전역 정책으로 올린다
|
||||
- controller나 DTO마다 같은 로직을 복붙하지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. entity를 그대로 응답으로 내보낸다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String password;
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
private Organization organization;
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/users/{id}")
|
||||
public ApiResult<User> getUser(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElseThrow();
|
||||
return ApiResult.success(user);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- persistence 모델이 외부 계약이 된다
|
||||
- 민감 필드와 lazy relation 노출 위험이 있다
|
||||
- API shape가 entity 구조에 끌려간다
|
||||
|
||||
### 예시 2. controller에서 로컬 ObjectMapper를 만든다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/v1/users/{id}")
|
||||
public String getUser(@PathVariable Long id) throws JsonProcessingException {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.writeValueAsString(userService.getUser(id));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 전역 Jackson 규칙을 우회한다
|
||||
- converter, module, naming, time 설정이 끊어진다
|
||||
- controller 책임이 과도해진다
|
||||
|
||||
### 예시 3. public API shape를 @JsonView로 관리한다
|
||||
|
||||
```java
|
||||
public class UserViewModel {
|
||||
|
||||
@JsonView(Summary.class)
|
||||
private String userId;
|
||||
|
||||
@JsonView(Summary.class)
|
||||
private String displayName;
|
||||
|
||||
@JsonView(Detail.class)
|
||||
private String email;
|
||||
|
||||
interface Summary {}
|
||||
interface Detail extends Summary {}
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/users/{id}")
|
||||
@JsonView(UserViewModel.Summary.class)
|
||||
public UserViewModel getUser(@PathVariable String id) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- summary/detail 계약이 DTO 분리 대신 view 규칙에 숨어든다
|
||||
- public API contract evolution이 읽기 어려워진다
|
||||
- versioning/응답 shape 관리 수단으로는 과도하게 간접적이다
|
||||
|
||||
### 예시 4. first-party request DTO에서 unknown field를 무비판적으로 무시한다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record CreateUserRequest(
|
||||
String email,
|
||||
String password,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 클라이언트 오타나 잘못된 필드 전송을 조용히 숨길 수 있다
|
||||
- 우리가 소유한 API 계약이 흐려진다
|
||||
- strict 정책을 택한 API군과 충돌한다
|
||||
|
||||
### 예시 5. null omission을 보기 좋다는 이유만으로 남발한다
|
||||
|
||||
```java
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record UserResponse(
|
||||
String userId,
|
||||
String displayName,
|
||||
String email,
|
||||
String phoneNumber
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 필드 omission이 계약 의미를 바꾼다
|
||||
- 클라이언트가 null과 absent를 구분해야 하는 경우 혼란이 생긴다
|
||||
- 전역/DTO별 정책이 뒤섞이기 쉽다
|
||||
@@ -0,0 +1,229 @@
|
||||
# 예외 처리 정책 (Exception Handling Policy)
|
||||
|
||||
이 문서는 본 인증 서버의 **예외 처리·응답·로그·audit 인프라가 표현하는 13가지 정책**과, 각 정책이 코드/테스트의 어느 위치에 어떤 메커니즘으로 박혀 있는지 정리한다. 이 정책은 비즈니스 예외(서비스 로직 도메인 예외)는 다루지 않으며, 그것은 도메인/서비스 설계의 일부로 별도로 관리한다.
|
||||
|
||||
## 0. 사용 원칙
|
||||
|
||||
각 정책은 다음 4가지 중 **최소 둘 이상**으로 표현되어 있어야 한다. 셋 이상이면 강한 정책이고, 하나뿐이면 회귀 위험이 있다.
|
||||
|
||||
1. **타입 시스템** (sealed, 시그니처 좁히기) — 컴파일 단계에서 위반 차단
|
||||
2. **명시적 단언** (정확값 테스트, classpath 가드) — 런타임 회귀 차단
|
||||
3. **운영 가시성** (WARN 로그, sentinel 값, audit) — 사일런트 실패 노출
|
||||
4. **코드 옆 가이드** (Javadoc) — 미래 변경자가 의도를 알 수 있음
|
||||
|
||||
> 정책을 새로 추가하거나 기존 정책을 변경할 때는, 위 4가지 중 어느 축으로 강제할지 *설계 결정*을 SUMMARY 문서나 PR 본문에 함께 남긴다.
|
||||
|
||||
---
|
||||
|
||||
## 정책 1. "내부 에러 코드는 클라이언트 응답 경로에 절대 닿지 않는다"
|
||||
|
||||
| 표현 위치 | 메커니즘 |
|
||||
|---|---|
|
||||
| `application/ErrorCode.java` | `sealed permits ClientFacingErrorCode, ExternalErrorCode` — 모든 ErrorCode는 두 갈래 중 하나로만 분류 가능 |
|
||||
| `application/ClientFacingErrorCode.java` | `non-sealed` 마커 — 클라이언트 노출 가능한 코드의 표식 |
|
||||
| `application/ExternalErrorCode.java` | `non-sealed` 마커 — 내부 분류용 (InfrastructureErrorCode 구현) |
|
||||
| `presentation/ApiErrorHttpStatusMapper.java` | `map(ClientFacingErrorCode)` — **시그니처가 ExternalErrorCode를 받지 않음** → InfrastructureErrorCode를 매퍼에 넣으면 컴파일 실패 |
|
||||
| `application/BusinessException.java` | `errorCode` 필드를 `ClientFacingErrorCode`로 좁힘 — BusinessException 생성 시점에 ExternalErrorCode를 넣을 수 없음 |
|
||||
| `bootstrap/InfrastructureExceptionHandler.java` | InfrastructureException은 별도 핸들러에서 항상 `COMMON-999`로 정규화 |
|
||||
|
||||
**검증 방법:** "InfrastructureErrorCode가 클라이언트 응답에 들어간다"는 시나리오는 **컴파일 단계에서 차단**된다. 런타임 테스트가 아니라 타입 시스템.
|
||||
|
||||
**축:** 타입 시스템 + 코드 옆 가이드
|
||||
|
||||
---
|
||||
|
||||
## 정책 2. "신규 ClientFacingErrorCode가 추가되면 매핑/테스트 누락이 자동 감지된다"
|
||||
|
||||
| 표현 위치 | 메커니즘 |
|
||||
|---|---|
|
||||
| `presentation/ApiErrorHttpStatusMapper.java` `mapXxx` | enum별 exhaustive switch — 새 enum 값이 추가되면 컴파일 실패 |
|
||||
| `presentation/ApiErrorHttpStatusMapper.java` default 분기 | non-sealed 구현이 늘어난 경우 default 분기가 **WARN 로그**로 운영에 노출 (조용한 500 방지) |
|
||||
| `bootstrap/test/ApiErrorHttpStatusMapperClientFacingCoverageTest` `exact_mapping_table_must_cover_every_client_facing_error_code_enum_value` | 신규 enum 값이 매핑 테이블에 없으면 테스트 실패 |
|
||||
| 같은 파일 `every_client_facing_error_code_implementation_on_classpath_is_covered_by_the_table` | 신규 ClientFacingErrorCode **구현체 클래스**가 테이블에 없으면 테스트 실패 (ArchUnit ClassFileImporter로 classpath 스캔) |
|
||||
| 같은 파일 `each_client_facing_error_code_maps_to_its_exact_expected_http_status` | 모든 enum별로 정확한 status를 표 형식으로 단언 — mutation 저항 |
|
||||
| 같은 파일 `all_client_facing_error_codes_have_unique_string_codes` | code 문자열 중복 금지 |
|
||||
| 같은 파일 + jqwik | `ApiErrorHttpStatusMapperPropertyTest` — 모든 enum이 4xx/5xx, 결정론, 401/403 정확값 |
|
||||
|
||||
**검증 방법:** 누군가 `BillingErrorCode implements ClientFacingErrorCode`를 추가하면, 매퍼에 분기를 추가하고 테이블에 등록하지 않는 한 **classpath 가드 테스트가 실패**한다.
|
||||
|
||||
**축:** 타입 시스템 + 명시적 단언 + 운영 가시성
|
||||
|
||||
---
|
||||
|
||||
## 정책 3. "@RestControllerAdvice를 우회하는 경로에서도 ApiResult 계약과 status는 보존된다"
|
||||
|
||||
| 표현 위치 | 메커니즘 |
|
||||
|---|---|
|
||||
| `bootstrap/ApiErrorController.java` `error()` | `/error` 엔드포인트가 컨테이너의 `RequestDispatcher.ERROR_STATUS_CODE`를 직접 읽고 그대로 보존 |
|
||||
| 같은 파일 본문 작성부 | 본문은 ApiResult로 정규화하되 status는 컨테이너 결정 그대로 |
|
||||
| 같은 파일 `classify(HttpStatus)` | 5xx → COMMON-999 정규화, 4xx → PresentationErrorCode 분류 매핑 |
|
||||
| `bootstrap/test/ApiErrorControllerIntegrationTest` | 404/405/418/503/missing/unknown status 모두 status 보존을 검증 |
|
||||
|
||||
**검증 방법:** 필터 단 예외나 `sendError(404)` 호출이 `/error`로 흘러가도 클라이언트는 **진짜 404**를 받는다. 통합 테스트로 강제.
|
||||
|
||||
**축:** 명시적 단언 + 코드 옆 가이드
|
||||
|
||||
---
|
||||
|
||||
## 정책 4. "예외는 advice → /error → 무응답 순으로 다층 안전망을 거친다"
|
||||
|
||||
| 계층 | 표현 위치 | 책임 |
|
||||
|---|---|---|
|
||||
| HIGHEST_PRECEDENCE | `bootstrap/InfrastructureExceptionHandler` | 인프라 장애 → COMMON-999 |
|
||||
| HIGHEST_PRECEDENCE+5 | `bootstrap/SecurityResponseExceptionHandler` | 인증/인가 예외 → 401/403 + audit |
|
||||
| HIGHEST_PRECEDENCE+10 | `presentation/ValidationExceptionHandler` | 검증 실패 → 400 + errors |
|
||||
| HIGHEST_PRECEDENCE+20 | `presentation/RequestExceptionHandler` | 요청 형식 오류 + 프레임워크 throws |
|
||||
| LOWEST_PRECEDENCE | `presentation/ApplicationExceptionHandler` | BusinessException + MessageNotWritable + **Exception.class 안전망** |
|
||||
| ErrorController | `bootstrap/ApiErrorController` | advice를 우회한 모든 경로 |
|
||||
|
||||
`@Order` 값으로 우선순위가 코드에 명시되어 있고, **`Exception.class` 안전망**이 advice 안에 있어 모든 RuntimeException이 일관된 ApiResult로 정규화된다.
|
||||
|
||||
**축:** 코드 옆 가이드(`@Order`) + 명시적 단언(통합 테스트)
|
||||
|
||||
---
|
||||
|
||||
## 정책 5. "AccessDenied는 익명 401과 인증된 403으로 의미 분기"
|
||||
|
||||
| 표현 위치 | 메커니즘 |
|
||||
|---|---|
|
||||
| `bootstrap/SecurityResponseExceptionHandler.java` `handleAccessDeniedException` | `isAnonymous(...)` 1차 — SecurityContext 인증 상태 |
|
||||
| 같은 파일 `isAnonymous(Authentication, HttpServletRequest)` | `request.getUserPrincipal()` 폴백 — 비동기 ThreadLocal 전파 누락 시 잘못된 401 방어 |
|
||||
| `bootstrap/test/ExceptionHandlingIntegrationTest` `access_denied_for_anonymous_user_maps_to_401_authentication_required` | 익명 401 검증 |
|
||||
| 같은 파일 `access_denied_for_authenticated_user_remains_403_forbidden` | 인증된 403 검증 |
|
||||
|
||||
**축:** 명시적 단언 + 코드 옆 가이드(스레드 가정 Javadoc)
|
||||
|
||||
---
|
||||
|
||||
## 정책 6. "보안 이벤트는 어느 경로로 들어와도 동일한 audit 채널에 적재된다"
|
||||
|
||||
| 진입 경로 | 표현 위치 |
|
||||
|---|---|
|
||||
| 필터 단(AuthenticationEntryPoint/AccessDeniedHandler) | `bootstrap/SecurityExceptionHandler` `recordAudit(...)` |
|
||||
| 컨트롤러 단(@PreAuthorize 등) | `bootstrap/SecurityResponseExceptionHandler` 모든 분기 `recordAudit(...)` |
|
||||
| 공통 | 둘 다 `SecurityAuditTrailWriter.record(...)` 사용 → `audit.auth` 로거로 동일 형식 적재 |
|
||||
| audit 실패 격리 | 양쪽 핸들러 모두 try/catch로 격리 — audit 실패가 응답을 막지 않음 |
|
||||
|
||||
**축:** 명시적 단언(audit 호출) + 운영 가시성(공통 로거 채널)
|
||||
|
||||
---
|
||||
|
||||
## 정책 7. "응답 본문은 ApiResult 한 가지 envelope으로 통일된다"
|
||||
|
||||
| 표현 위치 | 메커니즘 |
|
||||
|---|---|
|
||||
| `presentation/ApiResult.java` | record 형태로 봉인된 응답 envelope |
|
||||
| 같은 파일 `errors` 필드 | validation 진단을 `data`와 분리 → OpenAPI에서 oneOf 모델링 불필요 |
|
||||
| 같은 파일 `@JsonInclude(NON_NULL)` | null 필드는 직렬화 제외 |
|
||||
| `presentation/ApiResultFactory.java` | success/failure/failure(errors) 4가지 진입점만 노출 |
|
||||
| `bootstrap/RequestBoundApiResultFactory.java` | 모든 응답에 traceId·timestamp 자동 주입 |
|
||||
| 같은 파일 `MISSING_TRACE_ID = "-"` | TraceIdFilter 누락 시에도 명시적 sentinel 노출 (조용한 null 방지) |
|
||||
|
||||
**축:** 타입 시스템(record + interface) + 운영 가시성(sentinel)
|
||||
|
||||
---
|
||||
|
||||
## 정책 8. "검증 실패 응답은 단일 키 형식(JSON Pointer)을 사용한다"
|
||||
|
||||
| 표현 위치 | 메커니즘 |
|
||||
|---|---|
|
||||
| `presentation/ValidationExceptionHandler.java` `toJsonPointer(Path)` | ConstraintViolation의 Jakarta Path → JSON Pointer (RFC 6901) |
|
||||
| 같은 파일 `fieldFieldToJsonPointer(String)` | Spring BindingResult의 dotted/bracketed path → JSON Pointer |
|
||||
| 같은 파일 `GLOBAL_ERROR_KEY = "__global__"` | 클래스 레벨/cross-field 에러 전용 버킷 — `getGlobalErrors()` 누락 방지 |
|
||||
| 같은 파일 `UNRESOLVED_VIOLATION_MESSAGE` | null 메시지 폴백 — JSON에 `[null]` 노출 방지 |
|
||||
| 같은 파일 `escapeJsonPointerSegment` | `~`, `/` 정확한 RFC 6901 이스케이프 |
|
||||
| `bootstrap/test/ValidationExceptionHandlerIntegrationTest` | 세 가지 핸들러 모두 JSON Pointer 형식 검증 |
|
||||
| `presentation/test/JsonPointerConversionPropertyTest` (jqwik) | 8개 속성 — 이스케이프, dotted/indexed/nested |
|
||||
|
||||
**축:** 명시적 단언(테스트 + 속성) + 운영 가시성(sentinel)
|
||||
|
||||
---
|
||||
|
||||
## 정책 9. "운영 로그에는 PII나 raw URI가 절대 들어가지 않는다"
|
||||
|
||||
| 표현 위치 | 메커니즘 |
|
||||
|---|---|
|
||||
| `application/LogSanitizer.java` | 모든 외부 입력값 sanitization 진입점 (제어문자/`=`/`|`/길이 제한) |
|
||||
| `actorId(...)` | 이메일 마스킹 또는 sha256 해시 prefix |
|
||||
| `clientIp(...)` | IPv4 마지막 옥텟 0으로 마스킹, IPv6 해시 |
|
||||
| 모든 핸들러 | `LogSanitizer.requestPath(request.getRequestURI())` 통일 — raw URI 직접 로깅 금지 |
|
||||
| `infrastructure/InfrastructureException.java` Javadoc | detailMessage에 금지/허용 항목 명시 (가이드 코드 옆에 박힘) |
|
||||
| `application/test/LogSanitizerPropertyTest` (jqwik) | 11개 속성 — 길이 한도, 제어문자 제거, IPv4 마스킹, 이메일 마스킹, 해시 prefix |
|
||||
|
||||
**축:** 명시적 단언(속성 테스트) + 코드 옆 가이드(Javadoc 금지/허용 목록)
|
||||
|
||||
---
|
||||
|
||||
## 정책 10. "응답이 이미 커밋되면 재시도하지 않는다"
|
||||
|
||||
| 표현 위치 | 메커니즘 |
|
||||
|---|---|
|
||||
| `presentation/ApplicationExceptionHandler.java` `handleMessageNotWritableException` | HttpMessageNotWritable 핸들러가 `response.isCommitted()` 체크 후 빈 ResponseEntity 반환 (null 반환 회피로 재진입 차단) |
|
||||
| `bootstrap/SecurityExceptionHandler.java` `commence()`/`handle()` | 양쪽 모두 `response.isCommitted()` 체크 |
|
||||
|
||||
**축:** 명시적 단언 + 코드 옆 가이드(왜 빈 ResponseEntity인지 Javadoc)
|
||||
|
||||
---
|
||||
|
||||
## 정책 11. "엔드포인트 호출 한 건의 로그 1줄로 운영 트리아지가 가능하다"
|
||||
|
||||
모든 핸들러 로그 라인이 다음 필드를 포함:
|
||||
- `method=` (request.getMethod())
|
||||
- `requestPath=` (LogSanitizer.requestPath 통일)
|
||||
- `errorCode=` 또는 `status=`
|
||||
- 추가 컨텍스트(parameter, header, contentType 등)
|
||||
- traceId는 MDC를 통해 SLF4J 패턴(`%X{traceId}`)으로 자동 prefix
|
||||
|
||||
→ 한 줄만 봐도 "어떤 사용자가 어떤 경로에 어떤 메서드로 어떤 에러를 받았다"가 즉시 보인다.
|
||||
|
||||
**축:** 코드 옆 가이드(로그 포맷 규약) + 명시적 단언(통합 테스트의 로그 단언)
|
||||
|
||||
---
|
||||
|
||||
## 정책 12. "presentation 모듈은 Spring Security를 직접 의존하지 않는다"
|
||||
|
||||
| 표현 위치 | 메커니즘 |
|
||||
|---|---|
|
||||
| `bootstrap/test/LayerDependencyArchitectureTest` `presentation_must_not_read_security_context_directly` | ArchUnit으로 강제 |
|
||||
| 같은 파일 `presentation_must_not_accept_raw_spring_security_authentication` | ArchUnit으로 강제 |
|
||||
| `bootstrap/SecurityResponseExceptionHandler.java` | Security 의존 advice는 bootstrap에 둠 (룰 우회 없이 정직하게 분리) |
|
||||
|
||||
**축:** 타입 시스템(ArchUnit이 사실상 컴파일 게이트) + 코드 옆 가이드(Javadoc에 "왜 bootstrap에 있는가")
|
||||
|
||||
---
|
||||
|
||||
## 정책 13. "한 PR 단위의 정책 변경은 회귀 테스트의 단언과 동시에 변경된다"
|
||||
|
||||
회귀 안전망의 두께:
|
||||
|
||||
| 단언 강도 | 표현 위치 |
|
||||
|---|---|
|
||||
| 정확값 매핑 | `each_client_facing_error_code_maps_to_its_exact_expected_http_status` (16개 케이스) |
|
||||
| 누락 가드 | `exact_mapping_table_must_cover_every_client_facing_error_code_enum_value` |
|
||||
| classpath 가드 | `every_client_facing_error_code_implementation_on_classpath_is_covered_by_the_table` |
|
||||
| 코드 유일성 | `all_client_facing_error_codes_have_unique_string_codes` |
|
||||
| status 보존 | `ApiErrorControllerIntegrationTest`의 6 케이스 |
|
||||
| 익명 vs 인증 | `ExceptionHandlingIntegrationTest`의 2 케이스 |
|
||||
| 프레임워크 4xx/5xx | 동일 파일의 2 케이스 |
|
||||
| validation 통합 | `ValidationExceptionHandlerIntegrationTest`의 3 케이스 |
|
||||
| 안전망 | `uncaught_runtime_exception_hits_safety_net_and_returns_common_999` |
|
||||
| traceId | `client_supplied_trace_id_is_ignored_and_server_generated_value_is_returned` |
|
||||
|
||||
**축:** 명시적 단언 + 운영 가시성
|
||||
|
||||
---
|
||||
|
||||
## 후속 작업 (정책 변경/추가 대기 중)
|
||||
|
||||
- **i18n** — `ClientFacingErrorCode.message()`가 한국어 하드코딩. MessageSource 도입 후 정책 14 추가 예정.
|
||||
- **Aggregate JaCoCo 리포트** — 통합 테스트가 다른 모듈 코드를 커버하는 정도를 정확히 측정 (정책 13 강화).
|
||||
- **`**/config/**` exclusion 정밀화** — 핵심 핸들러를 normal coverage report에 포함.
|
||||
- **PIT mutation 점수 게이트 활성화** — 6개월 후 Tier 1 85%+.
|
||||
|
||||
---
|
||||
|
||||
## 참고
|
||||
|
||||
- 커버리지 임계치 정책: [`testing-coverage-policy.md`](testing-coverage-policy.md)
|
||||
- 사건 단위 측정 기록: [`testing-history/README.md`](testing-history/README.md)
|
||||
- 1~3차 리뷰 적용 이력: git log `refactor(error)`, `fix(error)` 커밋 메시지
|
||||
@@ -0,0 +1,194 @@
|
||||
# Audit Columns 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 PostgreSQL 테이블에서 생성 시각, 수정 시각, 생성 주체, 수정 주체 같은 감사 컬럼을 어떤 기준으로 두고 채울지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 감사 컬럼을 테이블마다 제멋대로 두지 않고 공통 규칙으로 표준화한다
|
||||
- 시간 컬럼과 주체 컬럼의 의미를 분리한다
|
||||
- DB 기본값, trigger, Spring Data JPA auditing, Hibernate timestamp 기능 중 무엇을 어디까지 맡길지 정한다
|
||||
- soft delete, 전체 변경 이력(audit log), version/concurrency 컬럼과 역할을 섞지 않는다
|
||||
|
||||
PostgreSQL은 컬럼 기본값으로 `CURRENT_TIMESTAMP` 같은 표현식을 둘 수 있고, 그 식은 row 삽입 시 평가된다고 설명한다. Spring Data JPA는 `@CreatedDate`, `@LastModifiedDate`, `@CreatedBy`, `@LastModifiedBy`를 제공하고, Hibernate는 `@CreationTimestamp`, `@UpdateTimestamp`를 제공한다. 즉 시간과 주체를 채우는 기술 수단은 여러 개가 있지만, 어떤 컬럼을 왜 두는지는 프로젝트 표준이 먼저 정해야 한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL / Spring Data JPA / Hibernate 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 PostgreSQL의 default value, date/time function, trigger, system information 함수 문서와 Spring Data JPA auditing, Hibernate `@CreationTimestamp` / `@UpdateTimestamp` 문서를 기준으로 작성한다. PostgreSQL은 `CURRENT_TIMESTAMP`와 `now()`가 transaction start time 의미를 갖는다고 설명하고, trigger는 `NEW` row를 수정해 반환할 수 있다고 설명한다. Spring Data JPA는 현재 principal을 `AuditorAware<T>`로 제공하도록 정의하고, Hibernate는 timestamp 값을 JVM 메모리 또는 DB에서 생성하도록 선택할 수 있다고 설명한다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 감사 컬럼은 “변경 이력 저장소”가 아니라 “현재 row의 메타데이터”다
|
||||
|
||||
`created_at`, `updated_at`, `created_by`, `updated_by`는 현재 row가 언제 누구에 의해 만들어지고 마지막으로 바뀌었는지를 담는 컬럼이다. 전체 변경 히스토리를 남기는 audit table, CDC, outbox, event log와는 역할이 다르다. PostgreSQL 공식 문서도 `CURRENT_TIMESTAMP`를 row insertion 시각 같은 기본값 예시로 설명하고, Spring Data JPA auditing 역시 “누가/언제 만들고 바꿨는지”를 엔티티 메타데이터로 다룬다.
|
||||
|
||||
### 3.2 시간 감사 컬럼과 주체 감사 컬럼은 분리해서 설계한다
|
||||
|
||||
시간 컬럼은 “언제”를, 주체 컬럼은 “누가”를 표현한다. 시간만 필요한 테이블도 있고, 주체까지 필요한 테이블도 있다. Spring Data JPA도 날짜 계열만 추적하는 경우 `AuditorAware`가 필요 없다고 설명한다. 따라서 프로젝트에서는 `created_at`/`updated_at`을 기본 세트로 보고, `created_by`/`updated_by`는 실제 운영·보안·추적 요구가 있을 때 추가한다.
|
||||
|
||||
### 3.3 한 컬럼은 한 가지 권위 있는 채움 방식만 가진다
|
||||
|
||||
`updated_at`을 DB trigger도 갱신하고 애플리케이션 auditing도 갱신하면, 어떤 값이 source of truth인지 흐려진다. Hibernate의 `@CreationTimestamp` / `@UpdateTimestamp`는 기본적으로 JVM 메모리에서 값을 만들 수 있고, PostgreSQL의 `CURRENT_TIMESTAMP`는 DB에서 생성된다. 따라서 같은 컬럼을 두 체계가 동시에 관리하게 두지 않는다. 프로젝트 기본 원칙은 한 감사 컬럼당 하나의 authoritative writer다.
|
||||
|
||||
## 4. 표준 감사 컬럼 세트
|
||||
|
||||
### 4.1 기본 필수 세트는 created_at, updated_at
|
||||
|
||||
프로젝트 기본 감사 컬럼 세트는 다음 두 컬럼이다.
|
||||
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
이 둘은 대부분의 비즈니스 테이블에서 현재 row의 생성 시점과 마지막 수정 시점을 설명하는 최소 메타데이터다. PostgreSQL은 timestamp 기본값에 `CURRENT_TIMESTAMP`를 쓰는 것을 대표 예시로 제시하고, Spring Data JPA도 `@CreatedDate`, `@LastModifiedDate`를 기본적인 auditing metadata로 제시한다.
|
||||
|
||||
### 4.2 선택 세트는 created_by, updated_by
|
||||
|
||||
다음 컬럼은 운영/보안/추적 요구가 있을 때 선택적으로 둔다.
|
||||
|
||||
- `created_by`
|
||||
- `updated_by`
|
||||
|
||||
Spring Data JPA는 `@CreatedBy`, `@LastModifiedBy`를 통해 “누가 생성·수정했는지”를 저장할 수 있고, 이를 위해 `AuditorAware<T>`가 현재 principal을 제공해야 한다고 설명한다. 프로젝트에서는 이 요구가 있는 도메인에만 주체 컬럼을 추가한다. 모든 테이블에 기계적으로 붙이지는 않는다.
|
||||
|
||||
### 4.3 다음 컬럼들은 이 문서의 기본 감사 컬럼 범위에 넣지 않는다
|
||||
|
||||
- `deleted_at` / `deleted_by`: soft delete 문서에서 별도로 다룬다
|
||||
- `version`: 낙관적 락 / concurrency 문서에서 다룬다
|
||||
- 변경 사유, 변경 요청 id, 전체 이전값/이후값: audit log / domain event / outbox 범위다
|
||||
|
||||
이 구분은 PostgreSQL 기능 차이라기보다 프로젝트 문서 경계에 대한 규칙이다. 현재 문서 순서에서도 soft delete는 별도 주제로 분리되어 있다.
|
||||
|
||||
## 5. 시간 감사 컬럼 기준
|
||||
|
||||
### 5.1 시간 감사 컬럼 타입은 timestamp with time zone
|
||||
|
||||
PostgreSQL은 date/time 타입을 제공하고, `CURRENT_TIMESTAMP` 같은 함수는 `timestamp with time zone` 의미로 동작한다. 프로젝트의 이전 column-types 기준과도 일치하게, 감사 시각 컬럼은 로컬 시각이 아니라 절대 시점을 표현해야 하므로 `timestamp with time zone`을 기본으로 한다.
|
||||
|
||||
### 5.2 created_at은 NOT NULL + DB 기본값을 기본으로 한다
|
||||
|
||||
PostgreSQL은 default value 식이 row 삽입 시 평가되며, `CURRENT_TIMESTAMP`를 timestamp column default의 전형적 예시로 설명한다. 프로젝트 기본값은 `created_at timestamp with time zone not null default current_timestamp`다. 생성 시각은 삽입 시점 메타데이터이므로, 애플리케이션이 매번 수동으로 채우게 두기보다 DB 기본값으로 닫는 편이 더 안정적이다.
|
||||
|
||||
### 5.3 updated_at은 insert 시점과 update 시점을 모두 고려해 채운다
|
||||
|
||||
`updated_at`은 insert 때도 값이 있어야 하고, 이후 row가 바뀔 때마다 갱신되어야 한다. PostgreSQL의 default value는 insert 시점에만 적용되므로, `updated_at default current_timestamp`만으로는 update 반영까지 해결되지 않는다. 따라서 `updated_at`은 insert 초기값은 default로 두되, update 시점 갱신은 DB trigger 또는 애플리케이션 auditing 중 하나로 별도 책임을 둬야 한다.
|
||||
|
||||
### 5.4 CURRENT_TIMESTAMP는 “실제 벽시계 현재 시각”이 아니라 transaction start time이다
|
||||
|
||||
PostgreSQL은 `transaction_timestamp()`가 `CURRENT_TIMESTAMP`와 같고, `now()`도 전통적 동등어라고 설명한다. 반면 `statement_timestamp()`는 현재 statement 시작 시각, `clock_timestamp()`는 실제 현재 시각으로 statement 안에서도 변할 수 있다고 설명한다. 프로젝트에서는 일반 감사 컬럼의 기본 의미를 트랜잭션 기준 시각으로 두고, 특별히 벽시계 실시간이 필요하지 않다면 `CURRENT_TIMESTAMP`를 사용한다.
|
||||
|
||||
### 5.5 default에서는 TIMESTAMP 'now' 같은 literal 형태를 사용하지 않는다
|
||||
|
||||
PostgreSQL은 later evaluation이 필요한 `DEFAULT` 절에서 `TIMESTAMP 'now'` 형태를 쓰지 말라고 명시한다. 이 형태는 상수가 파싱될 때 고정되어, 실제로는 table creation 시점 값이 들어갈 수 있기 때문이다. 프로젝트에서도 default에는 `CURRENT_TIMESTAMP` 또는 `now()` 같은 함수형 표현만 허용한다.
|
||||
|
||||
## 6. 주체 감사 컬럼 기준
|
||||
|
||||
### 6.1 created_by / updated_by는 기본적으로 “애플리케이션 principal”을 저장한다
|
||||
|
||||
Spring Data JPA는 `AuditorAware<T>`가 “현재 애플리케이션과 상호작용하는 current user or system”을 제공하도록 정의한다. 따라서 웹 애플리케이션에서 주체 감사 컬럼은 보통 DB role 이름이 아니라, 서비스가 인지하는 사용자/시스템 actor 식별자를 저장하는 것이 맞다. 프로젝트 기본값도 이 해석을 따른다.
|
||||
|
||||
### 6.2 current_user / session_user를 애플리케이션 사용자 식별자로 기본 사용하지 않는다
|
||||
|
||||
PostgreSQL은 `session_user`가 DB 연결을 시작한 사용자이고, `current_user`는 권한 검사에 쓰이는 사용자이며 `SET ROLE`이나 `SECURITY DEFINER`로 바뀔 수 있다고 설명한다. 이 값들은 DB 세션/권한 맥락에는 유용하지만, 일반적인 애플리케이션 사용자 principal과는 다를 수 있다. 특히 connection pool을 쓰는 서비스에서는 “DB 계정”과 “최종 사용자”가 거의 항상 다르다. 그래서 프로젝트에서는 `created_by` / `updated_by`를 DB의 `current_user`에 기본적으로 매핑하지 않는다.
|
||||
|
||||
### 6.3 주체 컬럼 타입은 도메인 식별자 규칙에 맞춘다
|
||||
|
||||
주체 컬럼은 문자열, 숫자, UUID 중 어떤 것이든 가능하지만, 중요한 것은 현재 애플리케이션 actor를 안정적으로 식별할 수 있는 값이어야 한다는 점이다. Spring Data JPA의 `AuditorAware<T>`도 제네릭 타입 `T`로 주체 컬럼 타입을 자유롭게 정할 수 있게 설계되어 있다. 프로젝트에서는 보통 사용자 PK나 시스템 actor code 같은 내부 식별자를 저장하고, 표시용 이름은 감사 컬럼에 넣지 않는다.
|
||||
|
||||
## 7. 값을 채우는 방식 기준
|
||||
|
||||
### 7.1 created_at 기본값은 DB가 채우는 것을 기본으로 한다
|
||||
|
||||
PostgreSQL이 default value를 row insertion 시 평가해 주므로, `created_at`은 DB 기본값으로 채우는 것이 가장 단순하고 신뢰도가 높다. 이 값은 insert 경로가 JPA이든 native SQL이든 배치든 일관되게 적용된다. 프로젝트 기본값은 `created_at`을 애플리케이션 코드에서 직접 세팅하지 않고, DB 기본값으로 닫는 것이다.
|
||||
|
||||
### 7.2 updated_at의 프로젝트 기본값은 DB trigger다
|
||||
|
||||
PostgreSQL의 BEFORE ROW trigger는 `NEW` row를 수정해서 반환할 수 있고, UPDATE 전용 trigger를 만들 수 있으며, `WHEN (OLD.* IS DISTINCT FROM NEW.*)` 같은 조건도 줄 수 있다고 설명한다. 프로젝트에서는 `updated_at`을 DB trigger로 유지하는 것을 기본 권장안으로 둔다. 이유는 이 방식이 JPA 엔티티 저장, native SQL, 운영 스크립트, 배치 업데이트처럼 여러 write path를 가장 일관되게 커버하기 때문이다. 이것은 공식 기능 위에 얹는 프로젝트 best practice다.
|
||||
|
||||
### 7.3 단, ORM auditing을 쓴다면 DB trigger와 섞지 않는다
|
||||
|
||||
Spring Data JPA auditing은 `@CreatedDate`, `@LastModifiedDate`, `@CreatedBy`, `@LastModifiedBy`를 제공하고, Hibernate `@CreationTimestamp` / `@UpdateTimestamp`도 timestamp 생성을 지원한다. 다만 Hibernate는 두 annotation의 기본 source가 VM(in memory)라고 설명하고, Spring Data auditing의 시간 공급자도 기본적으로 애플리케이션 쪽 `CurrentDateTimeProvider`다. 따라서 ORM auditing을 채택하면, 같은 컬럼을 DB trigger가 다시 덮어쓰지 않도록 한쪽만 authoritative writer로 선택해야 한다.
|
||||
|
||||
### 7.4 클러스터/다중 writer 환경의 시간 일관성이 중요하면 DB time source를 우선한다
|
||||
|
||||
Hibernate는 timestamp source를 VM 또는 DB로 선택할 수 있다고 설명한다. 프로젝트에서는 여러 애플리케이션 인스턴스, 여러 write path, 배치/운영 SQL까지 함께 고려해야 하는 감사 컬럼이라면, 시간 source는 DB가 더 보수적이고 일관된 기본값이다. 따라서 시간 감사 컬럼은 DB source, 주체 감사 컬럼은 애플리케이션 principal source로 나누는 구성이 기본 권장안이다. 이는 공식 기능 조합 위에 얹는 프로젝트 best practice다.
|
||||
|
||||
## 8. 변경 의미 기준
|
||||
|
||||
### 8.1 created_at / created_by는 삽입 후 불변이다
|
||||
|
||||
생성 감사 컬럼은 row가 처음 만들어진 사실을 설명하므로, 이후 update에서 바뀌면 안 된다. Hibernate도 `@CreationTimestamp`는 insert 시 한 번만 생성된다고 설명한다. 프로젝트에서는 생성 감사 컬럼을 비즈니스 코드에서 수정하지 않으며, 필요하면 DB 권한/trigger/ORM 매핑으로 보호한다.
|
||||
|
||||
### 8.2 updated_at / updated_by는 마지막 유효 변경을 반영한다
|
||||
|
||||
수정 감사 컬럼은 row가 마지막으로 바뀐 시점을 나타내므로, update 시 갱신되어야 한다. Hibernate `@UpdateTimestamp`는 row update마다 재생성된다고 설명하고, PostgreSQL trigger는 변경된 `NEW` row를 반환해 저장 row를 바꿀 수 있다. 프로젝트에서는 “마지막 유효 변경”의 기준을 명확히 두고, `updated_at`을 사람이 임의로 세팅하는 방식은 금지한다.
|
||||
|
||||
### 8.3 no-op update에까지 updated_at을 바꿀지 여부는 명시적으로 정한다
|
||||
|
||||
PostgreSQL `CREATE TRIGGER` 문서는 `WHEN (OLD.* IS DISTINCT FROM NEW.*)` 또는 특정 컬럼이 실제 바뀐 경우에만 trigger를 실행하는 예시를 보여 준다. 프로젝트 기본값은 실제 row 값이 바뀐 경우에만 `updated_at`을 바꾸는 것이다. 단순 재저장이나 동일값 overwrite까지 모두 “수정”으로 간주할지 여부는 팀 규칙으로 명시해야 한다.
|
||||
|
||||
## 9. 프로젝트 권장안
|
||||
|
||||
### 9.1 기본 권장 조합
|
||||
|
||||
프로젝트 기본 권장 조합은 다음과 같다.
|
||||
|
||||
- `created_at timestamptz not null default current_timestamp`
|
||||
- `updated_at timestamptz not null default current_timestamp`
|
||||
- `updated_at`은 BEFORE UPDATE row trigger로 갱신
|
||||
- `created_by` / `updated_by`는 필요할 때만 추가
|
||||
- 주체 컬럼은 `AuditorAware` 등으로 애플리케이션 principal을 채움
|
||||
- 시간 컬럼과 주체 컬럼은 서로 다른 source를 가져도 되지만, 같은 컬럼에 이중 writer를 두지 않음
|
||||
|
||||
이 조합은 PostgreSQL의 default/trigger 기능과 Spring Data JPA의 auditing principal 모델을 가장 안정적으로 결합하는 프로젝트 권장안이다.
|
||||
|
||||
### 9.2 DB role 이름을 감사 주체로 저장하는 것은 예외적이다
|
||||
|
||||
DB 내부 배치, 직접 SQL 운영 도구, 보안성 높은 DB 중심 워크플로처럼 DB 세션 주체 자체가 의미 있는 시스템이 아니라면, `current_user` / `session_user`를 row 감사 주체의 기본값으로 삼지 않는다. PostgreSQL은 이 값들이 DB 연결/권한 문맥을 나타낸다고 설명하고, Spring Data JPA는 애플리케이션 principal을 `AuditorAware`로 공급하도록 설계한다. 프로젝트 기본값은 애플리케이션 actor 식별자다.
|
||||
|
||||
## 10. 문서 경계
|
||||
|
||||
이 문서는 현재 row 수준의 감사 컬럼만 다룬다.
|
||||
|
||||
다음 내용은 별도 문서에서 확장한다.
|
||||
|
||||
- `deleted_at` / `deleted_by`와 soft delete
|
||||
- 전체 변경 이력 테이블
|
||||
- CDC / outbox / event log
|
||||
- 낙관적 락용 version
|
||||
- 운영 감사 로그와 DB session audit
|
||||
|
||||
현재 문서 체계에서도 soft delete는 별도 주제로 남아 있다.
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- `created_at` / `updated_at` 없이 테이블마다 제각각 감사 컬럼을 두는 것
|
||||
- 시간 감사 컬럼을 로컬 시각 문자열로 저장하는 것
|
||||
- `DEFAULT TIMESTAMP 'now'` 같은 literal 형태를 사용하는 것
|
||||
- 같은 `updated_at` 컬럼을 DB trigger와 ORM auditing이 동시에 관리하는 것
|
||||
- `created_by` / `updated_by`를 애플리케이션 principal 대신 DB `current_user`에 기본 매핑하는 것
|
||||
- 생성 감사 컬럼을 business code가 임의로 수정하는 것
|
||||
- `updated_at` 의미를 정하지 않은 채 no-op update와 실제 변경을 섞어 쓰는 것
|
||||
- `deleted_at`이나 `version`을 기본 감사 컬럼과 같은 범주로 취급하는 것
|
||||
|
||||
이 금지 규칙은 PostgreSQL default/trigger/`current_user` semantics와 Spring Data JPA / Hibernate auditing semantics를 운영 규칙으로 압축한 것이다.
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 테이블의 최소 감사 컬럼이 `created_at`, `updated_at`으로 표준화되어 있는가?
|
||||
- 시간 감사 컬럼 타입이 절대 시점을 표현하는 타입인가?
|
||||
- `created_at`은 DB default로 안전하게 채워지는가?
|
||||
- `updated_at`의 authoritative writer가 하나로 정해져 있는가?
|
||||
- `created_by` / `updated_by`가 필요하다면 actor source가 애플리케이션 principal로 정의되어 있는가?
|
||||
- DB role 이름과 애플리케이션 사용자 식별자를 혼동하지 않는가?
|
||||
- 생성 감사 컬럼은 삽입 후 불변으로 취급되는가?
|
||||
- no-op update에 대한 `updated_at` 정책이 팀 규칙으로 정해져 있는가?
|
||||
- soft delete / version / full audit log와 문서 경계가 섞이지 않는가?
|
||||
@@ -0,0 +1,315 @@
|
||||
# Column Types 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 PostgreSQL 컬럼 타입을 어떤 기준으로 선택할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 데이터 의미에 맞는 타입을 고른다
|
||||
- 애매한 범용 타입 남용을 줄인다
|
||||
- JPA/Hibernate 매핑과 PostgreSQL 네이티브 타입을 가능한 한 잘 맞춘다
|
||||
- 정확도, 시간대, 검색성, 인덱싱 특성을 고려한 기본값을 만든다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL / Hibernate 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 타입 특성 위에 일반적인 실무 기준을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 컬럼 타입은 “저장 가능”이 아니라 “의미 표현”으로 고른다
|
||||
|
||||
PostgreSQL은 다양한 내장 타입을 제공하고, 각 타입은 정확도, 정렬, 비교, 저장 형식, 인덱싱 특성이 다릅니다. 따라서 타입 선택은 “일단 들어가기만 하면 된다”가 아니라 이 값이 무엇을 의미하는가를 기준으로 해야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 문자열처럼 보여도 사실상 수치면 numeric/integer 계열을 먼저 검토한다
|
||||
- 시간처럼 보여도 실제 의미가 시점인지, 날짜인지, 로컬 시각인지 구분한다
|
||||
- JSON처럼 유연한 타입은 정말 반정형 데이터일 때만 쓴다
|
||||
|
||||
### 3.2 PostgreSQL 네이티브 타입을 우선 사용한다
|
||||
|
||||
PostgreSQL은 uuid, jsonb, numeric, timestamp with time zone 같은 풍부한 네이티브 타입을 제공하고, Hibernate도 PostgreSQL Dialect에서 UUID 같은 타입을 자연스럽게 매핑합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 의미가 분명한 PostgreSQL 네이티브 타입을 우선 사용한다
|
||||
- varchar 하나로 모든 것을 표현하는 구조를 지양한다
|
||||
- 애플리케이션 문자열 파싱 로직으로 DB 타입 의미를 대신하지 않는다
|
||||
|
||||
### 3.3 JPA/Hibernate 타입과 DB 타입은 충돌하지 않게 맞춘다
|
||||
|
||||
Hibernate는 Java 기본 타입과 java.time 타입을 SQL 타입으로 매핑합니다. 따라서 자바 쪽 의미와 PostgreSQL 쪽 타입 의미를 어긋나게 두면 조회/직렬화/시간대 처리에서 혼란이 생길 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Java 타입 의미와 DB 타입 의미를 같이 본다
|
||||
- OffsetDateTime 같은 시점 타입을 쓰면서 DB에 로컬 시각 의미로 저장하는 구조를 지양한다
|
||||
- UUID를 문자열로 다루지 않고 UUID 타입으로 저장할 수 있으면 그렇게 한다
|
||||
|
||||
## 4. 정수 타입 기준
|
||||
|
||||
### 4.1 범위에 맞는 정수 타입을 고른다
|
||||
|
||||
PostgreSQL은 smallint, integer, bigint 를 제공하며 각각 표현 가능한 범위가 다릅니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 정수 타입은 integer 또는 bigint
|
||||
- 작은 코드성 값이나 작은 범위만 보장되는 값에만 smallint
|
||||
- 장기 누적되거나 식별자 성격이 강한 값은 bigint 우선
|
||||
- “혹시 모르니 전부 smallint” 또는 “전부 bigint”를 기계적으로 택하지 않는다
|
||||
|
||||
### 4.2 business identifier와 sequence 성격 값은 bigint를 기본 검토한다
|
||||
|
||||
이 항목은 Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- PK 후보, 누적 카운터, 이벤트 번호, 정렬용 일련번호는 bigint를 기본 검토한다
|
||||
- 현재는 작아 보여도 장기 증가 가능성이 있으면 integer보다 bigint를 선호한다
|
||||
|
||||
## 5. 정확 수치 타입 기준
|
||||
|
||||
### 5.1 금액/정산/정확도 중요 값은 numeric(p,s)를 사용한다
|
||||
|
||||
PostgreSQL 공식 문서에서 numeric 은 정확한 수치 타입이고, real/double precision 은 부정확한 부동소수 타입입니다. 따라서 정확도가 필요한 값에는 numeric 이 맞습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음에는 numeric(p,s) 사용
|
||||
|
||||
- 금액
|
||||
- 수수료
|
||||
- 환율
|
||||
- 정산 수치
|
||||
- 회계 수치
|
||||
- 정확한 비율 계산값
|
||||
|
||||
double precision 을 금액 기본값으로 두지 않는다
|
||||
|
||||
### 5.2 precision/scale을 명시한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- numeric 은 가능하면 precision/scale을 명시한다
|
||||
- 예:
|
||||
- 금액: numeric(19,4) 같은 형태 검토
|
||||
- 퍼센트/비율: 도메인에 맞는 scale 명시
|
||||
- “정확하다”는 이유로 무제한 numeric 을 습관적으로 쓰지 않는다
|
||||
|
||||
## 6. 부동소수 타입 기준
|
||||
|
||||
### 6.1 근사치가 허용되는 경우에만 real / double precision
|
||||
|
||||
PostgreSQL은 real 과 double precision 이 IEEE 754 기반의 inexact type이라고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음처럼 근사치가 허용되는 경우에만 사용
|
||||
|
||||
- 측정값
|
||||
- 통계값
|
||||
- 랭킹 점수
|
||||
- 추천 score
|
||||
- 과학/센서 데이터
|
||||
|
||||
돈, 정산, 계약 수치는 사용 금지
|
||||
|
||||
## 7. 문자열 타입 기준
|
||||
|
||||
### 7.1 기본 문자열 타입은 text
|
||||
|
||||
PostgreSQL 공식 문서는 text, varchar(n), char(n) 사이에 일반적인 성능 차이는 없고, char(n) 은 공백 패딩으로 추가 비용이 있을 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 문자열 타입은 text
|
||||
- 길이 제한이 도메인 규칙 일 때만 varchar(n)
|
||||
- char(n) 은 기본 금지
|
||||
|
||||
### 7.2 varchar(n) 은 도메인 길이 제약을 표현할 때만 쓴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음처럼 실제 규칙이 있을 때만 varchar(n) 사용
|
||||
|
||||
- 이메일 최대 길이
|
||||
- 국가 코드 길이
|
||||
- ISO 코드
|
||||
- 외부 계약상 길이가 고정된 값
|
||||
|
||||
“문자열이면 일단 varchar(255)” 를 기본 금지한다
|
||||
|
||||
### 7.3 고정폭 문자열은 기본적으로 피한다
|
||||
|
||||
char(n) 은 공백 패딩 특성이 있고, PostgreSQL도 char(n) 사용 시 추가 저장 공간과 일부 처리 비용이 있을 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 고정폭 포맷이 정말 필요한 경우가 아니면 char(n) 사용 금지
|
||||
- 국가코드, 상태코드 같은 값도 보통 text 또는 varchar(n) 로 충분하다
|
||||
|
||||
## 8. UUID 타입 기준
|
||||
|
||||
### 8.1 UUID는 문자열이 아니라 uuid 타입으로 저장한다
|
||||
|
||||
PostgreSQL은 uuid 타입을 네이티브로 지원하고, UUIDv4/UUIDv7 생성도 지원합니다. Hibernate PostgreSQL Dialect도 UUID를 PostgreSQL UUID 타입으로 매핑합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- UUID 의미의 값은 text/varchar(36) 대신 uuid
|
||||
- 외부 식별자, 공개 식별자, 비순차 식별자에 UUID를 쓴다면 DB 타입도 UUID로 맞춘다
|
||||
- UUID를 문자열 컬럼에 저장하는 것을 기본 금지한다
|
||||
|
||||
### 8.2 UUID 버전 선택은 별도 식별자 정책에서 다룬다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- UUID를 쓸지, bigint를 쓸지, UUIDv4/v7 중 무엇을 쓸지는 PK/식별자 기준 문서에서 다룬다
|
||||
- 이 문서에서는 “UUID를 저장할 때는 uuid 타입을 쓴다”를 기본으로 한다
|
||||
|
||||
## 9. 불리언 타입 기준
|
||||
|
||||
### 9.1 참/거짓은 boolean
|
||||
|
||||
PostgreSQL은 boolean 타입을 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음 같은 참/거짓 상태는 boolean
|
||||
|
||||
- 활성/비활성
|
||||
- 삭제 여부
|
||||
- 사용 여부
|
||||
- 잠금 여부
|
||||
|
||||
Y/N, 0/1, "true"/"false" 문자열 저장을 기본 금지한다
|
||||
|
||||
### 9.2 tri-state가 필요하면 boolean 하나로 우겨 넣지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- true/false/unknown 이 필요하면 nullable boolean, 별도 상태 컬럼, enum/코드 컬럼 중 의미에 맞는 구조를 택한다
|
||||
- “모름” 상태를 boolean과 주석으로 해결하지 않는다
|
||||
|
||||
## 10. 날짜/시간 타입 기준
|
||||
|
||||
### 10.1 시점(timestamp)은 기본적으로 timestamp with time zone
|
||||
|
||||
PostgreSQL 공식 문서는 시간대가 관련되면 date/time을 따로 쓰기보다 날짜와 시간을 함께 가진 타입을 권장하고, time with time zone 은 권장하지 않는다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- created_at, updated_at, deleted_at, expires_at, issued_at 같은 시점 은 기본적으로 timestamp with time zone
|
||||
- Java 쪽은 Instant 또는 OffsetDateTime 우선 검토
|
||||
- timestamp without time zone 을 시점 저장 기본값으로 두지 않는다
|
||||
|
||||
### 10.2 날짜만 필요하면 date
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음처럼 시각이 없는 값은 date
|
||||
|
||||
- 생년월일
|
||||
- 영업일
|
||||
- 정산 기준일
|
||||
- 이벤트 날짜
|
||||
|
||||
날짜만 필요한데 timestamp를 습관적으로 쓰지 않는다
|
||||
|
||||
### 10.3 로컬 시각만 의미가 있으면 time 또는 timestamp without time zone 을 예외적으로 쓴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음처럼 “절대 시점”이 아닌 로컬 시간은 예외적으로 time 또는 로컬 datetime 타입 검토
|
||||
|
||||
- 영업 시작 시각
|
||||
- 반복 스케줄의 로컬 시각
|
||||
- 매장 오픈 시각
|
||||
|
||||
단, time with time zone 은 기본 금지
|
||||
|
||||
절대 시점과 로컬 시각 의미를 혼동하지 않는다
|
||||
|
||||
## 11. JSON 타입 기준
|
||||
|
||||
### 11.1 기본 JSON 저장 타입은 jsonb
|
||||
|
||||
PostgreSQL 공식 문서는 json 은 입력 텍스트를 그대로 보존하고, jsonb 는 공백·키 순서·중복 키를 보존하지 않는다고 설명합니다. 반대로 jsonb 는 비교 연산과 인덱싱 등에서 더 실용적입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 반정형 데이터 저장이 정말 필요하면 기본값은 jsonb
|
||||
- 조회/검색/인덱스 가능성이 있으면 jsonb 우선
|
||||
- 원문 텍스트 보존이 정말 중요할 때만 json
|
||||
|
||||
### 11.2 JSON은 예외적 타입이지 기본 설계 도구가 아니다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 정형 모델로 표현 가능한 값을 무조건 jsonb 로 몰지 않는다
|
||||
- 핵심 비즈니스 속성, 조인 키, 자주 필터링하는 값은 일반 컬럼 우선
|
||||
- jsonb 는 확장 필드, 외부 payload 저장, 유연한 metadata 같은 경우에 한정한다
|
||||
|
||||
## 12. 바이너리 타입 기준
|
||||
|
||||
### 12.1 바이너리 데이터는 bytea
|
||||
|
||||
PostgreSQL은 binary data 저장용으로 bytea 를 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 해시값, 서명값, 바이너리 토큰, 작은 바이너리 payload는 bytea
|
||||
- 바이너리 데이터를 base64 문자열로 억지 저장하지 않는다
|
||||
- 큰 파일 자체를 DB에 넣을지 여부는 별도 저장 전략 문서에서 다룬다
|
||||
|
||||
## 13. ID 생성 관련 타입 기준
|
||||
|
||||
### 13.1 숫자 자동 생성 컬럼은 IDENTITY 를 우선 검토한다
|
||||
|
||||
PostgreSQL은 identity column을 공식 지원하고, GENERATED ALWAYS AS IDENTITY / GENERATED BY DEFAULT AS IDENTITY 구문을 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 자동 생성 숫자 컬럼은 serial 관성보다 identity 우선 검토
|
||||
- 기본 타입은 bigint identity를 선호
|
||||
- PK/FK 전략 자체는 다음 PK 문서에서 더 구체화한다
|
||||
|
||||
## 14. enum/상태값 관련 타입 기준
|
||||
|
||||
### 14.1 비즈니스 상태값은 DB enum보다 문자열 + 제약을 우선 검토한다
|
||||
|
||||
PostgreSQL은 enum 타입도 지원하지만, 이 프로젝트에서는 상태값이 자주 바뀌거나 애플리케이션 enum과 함께 움직일 가능성이 높다면 문자열 컬럼 + check 제약 을 우선 검토한다. 이 부분은 공식 기능이라기보다 Practice + Project Recommendation 이다. PostgreSQL enum 자체는 가능하지만, 상태값 변화 운영성도 같이 봐야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 상태값 타입 선택은 컬럼 타입만이 아니라 제약/마이그레이션 비용까지 함께 본다
|
||||
- 자세한 내용은 check 기준, migration 기준 문서와 연결해서 다룬다
|
||||
|
||||
## 15. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 문자열이면 일괄 varchar(255)
|
||||
- UUID를 varchar(36) 에 저장
|
||||
- 돈/정산 값을 double precision 으로 저장
|
||||
- 시점을 timestamp without time zone 에 습관적으로 저장
|
||||
- time with time zone 사용
|
||||
- 핵심 정형 속성을 무분별하게 jsonb 로 저장
|
||||
- 참/거짓 값을 Y/N, 0/1 문자열로 저장
|
||||
- 고정폭이 아닌 문자열에 char(n) 사용
|
||||
|
||||
## 16. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 컬럼 타입이 값의 의미를 정확히 표현하는가?
|
||||
- 정밀도가 필요한 값에 numeric 을 사용했는가?
|
||||
- 시점 컬럼에 timestamp with time zone 을 검토했는가?
|
||||
- UUID라면 문자열이 아니라 uuid 타입인가?
|
||||
- 문자열 기본값을 text 로 보고, 실제 길이 제약이 있을 때만 varchar(n) 을 썼는가?
|
||||
- JSON은 정말 반정형 데이터일 때만 쓰는가?
|
||||
- JPA/Hibernate 매핑과 PostgreSQL 타입 의미가 충돌하지 않는가?
|
||||
@@ -0,0 +1,194 @@
|
||||
# DB Concurrency 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 Spring + JPA/Hibernate + PostgreSQL 환경에서 동시성 문제를 어떤 방식으로 모델링하고 해결할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 동시성 문제를 “락을 걸까 말까” 수준이 아니라 문제 유형별로 나눈다
|
||||
- 동일 row 동시 수정, 중복 생성, 상태 전이 경쟁, 중복 요청을 서로 다른 방식으로 다룬다
|
||||
- 기본 해법을 `@Version`, DB 제약, 원자적 SQL, 재시도 정책으로 정리한다
|
||||
- lock / isolation 문서로 넘겨야 할 문제와 여기서 해결할 문제를 구분한다
|
||||
|
||||
Jakarta Persistence는 버전 필드가 있는 엔티티에 대해 provider가 optimistic locking을 자동으로 수행해야 한다고 규정하고, version이 없는 동시 접근 엔티티는 애플리케이션이 직접 데이터 일관성을 책임져야 한다고 설명합니다. PostgreSQL은 `READ COMMITTED`에서 각 명령이 시작 시점의 snapshot으로 대상을 찾고, concurrent update가 있으면 대기 후 `WHERE` 조건을 다시 평가한다고 설명합니다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL / Jakarta Persistence / Hibernate 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 PostgreSQL의 transaction isolation, explicit locking, constraints, `INSERT ... ON CONFLICT`, serialization failure handling 문서와 Jakarta Persistence 3.2의 locking/concurrency 규정, Hibernate User Guide의 optimistic locking 문서를 기준으로 작성한다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 동시성 문제는 한 가지 도구로 풀지 않는다
|
||||
|
||||
동일 엔티티의 동시 수정은 optimistic locking으로 다루는 것이 자연스럽고, 중복 생성은 `UNIQUE` 제약이나 `ON CONFLICT`가 더 직접적이며, 큐 선점이나 작업 할당은 row lock 계열이 더 적합하다. PostgreSQL은 제약이 위반되면 저장 자체를 막고, `ON CONFLICT`는 high concurrency에서도 atomic한 INSERT/UPDATE 결과를 보장한다고 설명합니다. Jakarta Persistence는 낙관적 락을 버전 필드 기반으로 정의합니다.
|
||||
|
||||
### 3.2 애플리케이션 선조회만으로 정합성을 보장하지 않는다
|
||||
|
||||
“먼저 조회해서 없으면 insert”, “먼저 읽고 상태가 READY면 update” 같은 read-then-act 패턴은 경쟁 상태에서 깨질 수 있다. PostgreSQL은 `READ COMMITTED`에서 concurrent updater가 먼저 커밋하면 두 번째 updater가 갱신된 row에 다시 연산을 적용하면서 `WHERE`를 재평가한다고 설명한다. 따라서 선조회 결과를 믿기보다, 제약 또는 단일 SQL 조건식으로 최종 판정을 DB에 맡기는 편이 더 안전하다.
|
||||
|
||||
### 3.3 기본 전략은 “낙관적 기본값 + 필요 시 더 강한 수단”이다
|
||||
|
||||
Jakarta Persistence는 concurrent access 또는 detached merge가 가능한 엔티티에 optimistic locking 사용을 강하게 권장하고, version이 없으면 inconsistent state, lost update 같은 문제가 생길 수 있다고 설명합니다. 이 기준에 따라 프로젝트 기본값은 동시에 수정될 수 있는 엔티티는 `@Version`으로 보호하고, 더 강한 락이 필요한 경우만 별도 lock 문서 기준으로 확장하는 것이다.
|
||||
|
||||
## 4. 동시성 문제 분류 기준
|
||||
|
||||
### 4.1 같은 row를 여러 사용자가 수정하는 문제는 optimistic locking으로 본다
|
||||
|
||||
JPA는 optimistic locking을 버전 번호 또는 타임스탬프를 읽고, 갱신 시 검증 및 증가시키는 방식으로 정의합니다. Hibernate도 `@Version` 컬럼을 사용해 conflicting updates를 감지하고, last-commit-wins로 덮어써지는 lost update를 막는다고 설명합니다. 따라서 동일 aggregate root를 여러 사용자가 편집하는 문제는 기본적으로 `@Version` 문제다.
|
||||
|
||||
### 4.2 중복 생성과 비즈니스 uniqueness는 제약 문제로 본다
|
||||
|
||||
PostgreSQL 제약은 저장 시점에 위반을 막고, `UNIQUE`/`PRIMARY KEY`는 동시 insert 경쟁에서도 최종 정합성을 보장한다. 또한 PostgreSQL은 어떤 unique-key failure는 실제로는 직전 읽기와 연관된 serialization류 경쟁의 결과일 수 있다고 설명합니다. 따라서 “중복 생성 방지”는 application lock보다 DB uniqueness가 기본 해법이다.
|
||||
|
||||
### 4.3 상태 전이 경쟁은 조건부 UPDATE 문제로 본다
|
||||
|
||||
주문 승인, 결제 확정, 세션 폐기처럼 “현재 상태가 특정 값일 때만 전이”되는 작업은 보통 SELECT 후 if 검사보다 `UPDATE ... WHERE status = 'READY'` 같은 단일 SQL이 더 안전하다. PostgreSQL은 concurrent update 뒤 `WHERE` 조건을 다시 평가하므로, 이런 조건부 update는 경쟁 상태에서도 더 직접적인 해법이 된다.
|
||||
|
||||
### 4.4 작업 선점과 큐 소비는 lock 문서로 넘긴다
|
||||
|
||||
PostgreSQL은 `SELECT ... FOR UPDATE`가 해당 row를 현재 트랜잭션 종료까지 잠그고 다른 UPDATE/DELETE/row lock 요청을 막는다고 설명합니다. 하지만 이런 문제는 낙관적 락 기본 문맥보다 명시적 row lock 문제에 가깝다. 따라서 큐 선점, 작업 할당, 재처리 방지용 row claim은 이 문서의 기본 해법이 아니라 lock.md에서 상세히 다룬다.
|
||||
|
||||
## 5. Optimistic Locking 기준
|
||||
|
||||
### 5.1 동시에 수정될 수 있는 엔티티는 기본적으로 @Version을 둔다
|
||||
|
||||
Jakarta Persistence는 version이 있는 엔티티에 대해 provider가 optimistic locking을 자동 수행해야 하며, concurrent access 또는 detached merge가 가능한 엔티티에는 optimistic locking을 강하게 권장한다고 설명합니다. version이 없으면 lost update와 inconsistent state를 애플리케이션이 직접 막아야 한다고도 명시합니다. 프로젝트 기본값은 동시 수정 가능성이 있는 aggregate root에는 `@Version` 필수다.
|
||||
|
||||
### 5.2 기본 버전 타입은 숫자형을 우선한다
|
||||
|
||||
Jakarta Persistence는 optimistic locking을 version number 또는 timestamp 전략으로 정의하고, Hibernate는 timestamp도 가능하지만 version number보다 덜 신뢰할 수 있다고 설명합니다. 따라서 프로젝트 기본값은 `long`/`Long` 같은 숫자형 version column이고, timestamp version은 특별한 이유가 있을 때만 검토한다.
|
||||
|
||||
### 5.3 version 컬럼은 애플리케이션이 직접 조작하지 않는다
|
||||
|
||||
Hibernate는 `@Version` 속성은 entity manager가 conflicting updates를 감지하는 데 사용하며, 애플리케이션이 version number를 임의로 바꾸는 것은 금지라고 설명합니다. 프로젝트 규칙도 동일하다. version은 비즈니스 값이 아니라 concurrency control 메타데이터다.
|
||||
|
||||
### 5.4 optimistic lock 실패는 유스케이스 실패로 취급한다
|
||||
|
||||
Jakarta Persistence는 optimistic lock failure가 감지되면 provider가 `OptimisticLockException`을 던지고 현재 트랜잭션을 rollback 대상으로 표시해야 한다고 규정합니다. 따라서 이 예외는 단순 warning이 아니라 현재 유스케이스를 다시 판단해야 하는 실패다. 프로젝트에서는 이 예외를 삼키고 계속 진행하지 않는다.
|
||||
|
||||
### 5.5 optimistic lock 예외는 flush/commit 시점에 늦게 나타날 수 있다
|
||||
|
||||
Jakarta Persistence는 provider가 DB 쓰기를 트랜잭션 끝까지 미룰 수 있고, 이 경우 optimistic lock check도 commit 직전까지 지연될 수 있다고 설명합니다. 예외를 애플리케이션이 더 이른 시점에 처리해야 한다면 `flush()`로 강제 동기화를 유도할 수 있습니다. 프로젝트에서는 “예외가 `save()` 시점에 바로 나올 것”이라고 가정하지 않는다.
|
||||
|
||||
### 5.6 versionless optimistic locking은 예외적이고 비표준적이다
|
||||
|
||||
Jakarta Persistence는 버전 필드 없이 동작하는 alternative optimistic locking이 provider별로 존재할 수 있지만 portable하지 않다고 설명합니다. Hibernate도 `OptimisticLockType.ALL` / `DIRTY` 같은 versionless optimistic locking을 제공하지만, 이는 provider-specific 기능입니다. 프로젝트 기본값은 **표준 `@Version`**이고, legacy schema 때문에 불가피할 때만 예외적으로 검토한다.
|
||||
|
||||
### 5.7 @OptimisticLock(excluded = true)는 아주 제한적으로만 허용한다
|
||||
|
||||
Hibernate는 `excluded` 속성은 해당 필드 변경 시 version 증가를 막아, 다른 트랜잭션의 업데이트와 충돌하지 않게 만들 수 있다고 설명합니다. 하지만 그 결과 lost update를 수용해야 하는 필드가 생길 수 있습니다. 프로젝트에서는 조회 카운터처럼 정말 덮어써져도 되는 부수적 필드에만 극히 제한적으로 허용하고, 핵심 비즈니스 상태에는 금지한다.
|
||||
|
||||
## 6. DB 제약과 UPSERT 기준
|
||||
|
||||
### 6.1 uniqueness와 중복 방지는 제약으로 닫는다
|
||||
|
||||
PostgreSQL은 제약 위반 시 저장을 거부하고, 이는 기본값·동시 요청 여부와 무관하게 적용된다고 설명합니다. 따라서 이메일, 외부 id, 비즈니스 natural key, idempotency key 같은 값의 중복 방지는 “먼저 조회해서 없으면 저장”이 아니라 `UNIQUE` 제약으로 닫는다.
|
||||
|
||||
### 6.2 insert-or-update는 PostgreSQL-native ON CONFLICT를 우선 검토한다
|
||||
|
||||
PostgreSQL은 `ON CONFLICT DO UPDATE`가 high concurrency에서도 atomic한 insert-or-update 결과를 보장한다고 설명하고, `READ COMMITTED`에서도 각 row에 대해 insert 또는 update 둘 중 하나의 결과가 보장된다고 설명합니다. 따라서 “없으면 insert, 있으면 update”는 두 SQL로 나누기보다 UPSERT 한 문장을 우선 검토한다.
|
||||
|
||||
### 6.3 ON CONFLICT는 arbiter 제약이 분명할 때만 사용한다
|
||||
|
||||
PostgreSQL은 `ON CONFLICT DO UPDATE`가 arbiter unique index 또는 unique/PK 제약을 기준으로 동작하며, NOT DEFERRABLE unique 제약/인덱스만 arbiter로 사용될 수 있다고 설명합니다. `EXCLUDE` 제약은 `ON CONFLICT`의 arbiter로 사용할 수 없습니다. 따라서 UPSERT는 “어떤 uniqueness가 충돌 기준인지”가 스키마에 명확해야 한다. 프로젝트에서는 충돌 기준이 흐린 상태의 포괄적 UPSERT를 금지한다.
|
||||
|
||||
### 6.4 duplicate request와 duplicate row는 같은 문제가 아니다
|
||||
|
||||
중복 요청은 API idempotency 문제이고, duplicate row 생성은 DB uniqueness 문제다. 둘은 서로 보완적이지만 같은 수단으로 해결하지 않는다. PostgreSQL 제약과 `ON CONFLICT`는 최종 저장 정합성을 보장하지만, 같은 요청 재실행에 대한 응답 재생성 의미까지 대신해 주지는 않는다. 프로젝트에서는 API idempotency는 별도 문서 기준을 따르되, 저장 계층은 여전히 제약으로 닫는다.
|
||||
|
||||
## 7. 원자적 상태 전이 기준
|
||||
|
||||
### 7.1 read-then-act보다 single-statement mutation을 우선한다
|
||||
|
||||
PostgreSQL은 `READ COMMITTED`에서 concurrent updater가 먼저 커밋하면 두 번째 updater가 갱신된 row에 작업을 다시 적용할 수 있고, 이때 `WHERE`가 재평가된다고 설명합니다. 따라서 상태 전이는 SELECT status 후 자바 if문으로 분기하기보다, `UPDATE ... WHERE id = ? AND status = 'READY'` 같은 조건부 mutation을 우선한다.
|
||||
|
||||
### 7.2 성공 여부는 조회 결과가 아니라 affected row count/RETURNING으로 판단한다
|
||||
|
||||
PostgreSQL `UPDATE`와 `INSERT ... RETURNING`은 실제로 갱신되거나 삽입된 row를 반환할 수 있습니다. 또한 `ON CONFLICT DO UPDATE ... WHERE`에서 조건을 만족하지 않아 update되지 않은 row는 반환되지 않는다고 설명합니다. 따라서 상태 전이와 compare-and-set 계열 로직은 “업데이트 SQL이 1건 반영되었는가”를 기준으로 성공을 판정한다.
|
||||
|
||||
### 7.3 숫자 증감과 잔액 차감도 원자적 SQL을 우선 검토한다
|
||||
|
||||
`counter = counter + 1`, `stock = stock - 1` 같은 연산은 현재 값을 읽어서 자바에서 계산한 뒤 다시 저장하면 경쟁 상태를 만들기 쉽다. PostgreSQL `UPDATE` 표현식은 기존 컬럼 값을 읽어 새 값을 계산할 수 있으므로, 이런 연산은 DB 표현식 기반 update가 기본이다. 재고 부족 같은 조건이 있으면 `WHERE stock >= :qty`까지 함께 넣어 원자적으로 처리한다. PostgreSQL `UPDATE`는 expression이 기존 컬럼 값을 사용할 수 있다고 설명합니다.
|
||||
|
||||
## 8. 재시도 기준
|
||||
|
||||
### 8.1 serialization failure와 deadlock은 전체 트랜잭션 재시도 대상이다
|
||||
|
||||
PostgreSQL은 `40001`(serialization_failure)은 재시도를 준비해야 하고, `40P01`(deadlock_detected)도 재시도가 적절할 수 있다고 설명합니다. 또한 재시도는 전체 트랜잭션과 그 안의 의사결정 로직 전체를 다시 실행해야 한다고 명시합니다. 프로젝트에서도 부분 SQL만 재실행하지 않고 유스케이스 전체를 다시 수행한다.
|
||||
|
||||
### 8.2 unique violation도 때로는 concurrency 재시도 대상일 수 있지만, 무조건은 아니다
|
||||
|
||||
PostgreSQL은 `23505`(unique_violation)나 `23P01`(exclusion_violation)도 어떤 경우에는 사실상 serialization류 경쟁의 결과일 수 있다고 설명합니다. 하지만 항상 transient failure는 아니므로, 무조건 자동 재시도하면 persistent business error를 반복할 수 있습니다. 프로젝트에서는 unique violation 재시도는 키 선택 로직이 직전 읽기에 의존한 경우처럼 concurrency 원인이 분명할 때만 제한적으로 허용한다.
|
||||
|
||||
### 8.3 optimistic lock 재시도는 application boundary에서만 검토한다
|
||||
|
||||
Jakarta Persistence는 optimistic lock failure 시 현재 트랜잭션을 rollback 대상으로 표시한다고 규정합니다. 따라서 한 persistence context 내부에서 예외를 무시하고 계속 진행하는 방식은 안전하지 않다. 프로젝트에서 재시도가 필요하다면, 새 트랜잭션에서 유스케이스 전체를 다시 실행하는 형태로만 검토한다.
|
||||
|
||||
### 8.4 자동 재시도는 멱등성과 사용자 의미가 맞을 때만 허용한다
|
||||
|
||||
재시도는 기술적으로 가능해도 business semantics가 다르면 위험하다. PostgreSQL도 자동 재시도 시설을 제공하지 않으며, 전체 로직을 다시 실행해야 correctness를 보장할 수 있다고 설명합니다. 프로젝트에서는 “같은 입력으로 다시 실행해도 의미가 같은가”가 분명할 때만 제한적으로 자동 재시도를 허용한다.
|
||||
|
||||
## 9. 프로젝트 권장안
|
||||
|
||||
### 9.1 aggregate root 동시 수정은 @Version이 기본값
|
||||
|
||||
동일 사용자, 주문, 결제, 권한 묶음처럼 한 aggregate root를 여러 요청이 동시에 수정할 수 있다면, 기본 해법은 `@Version`이다. Jakarta Persistence와 Hibernate 모두 이것을 표준적 optimistic locking 메커니즘으로 설명하고, version이 없으면 lost update 가능성이 커진다고 안내합니다.
|
||||
|
||||
### 9.2 create-or-ignore / create-or-update는 제약 + UPSERT 우선
|
||||
|
||||
중복 생성 경쟁은 row lock보다 uniqueness가 더 직접적이다. 프로젝트에서는 등록, webhook dedup, 외부 이벤트 반영, 자연키 insert는 `UNIQUE` + `ON CONFLICT`를 우선 검토한다. PostgreSQL은 `ON CONFLICT DO UPDATE`가 atomic outcome을 보장한다고 설명합니다.
|
||||
|
||||
### 9.3 상태 전이는 “조건부 UPDATE 1건 성공”으로 설계한다
|
||||
|
||||
승인/취소/확정/만료 같은 상태 전이는 SELECT 후 분기보다 `UPDATE ... WHERE current_state = ?`를 기본으로 하고, row count 0이면 “이미 다른 트랜잭션이 선점하거나 상태를 바꿨다”로 해석한다. PostgreSQL의 command-level 재평가 semantics와 잘 맞는 방식이다.
|
||||
|
||||
### 9.4 명시적 row lock은 정말 필요한 경우만 lock 문서 기준으로 올린다
|
||||
|
||||
낙관적 락, 제약, 원자적 update로 해결할 수 있는 문제를 곧바로 `SELECT FOR UPDATE`로 풀지 않는다. row lock은 강력하지만 contention과 대기 비용을 늘릴 수 있고, 이 문서의 기본 전략보다 한 단계 무거운 수단이다. PostgreSQL은 `FOR UPDATE`가 해당 row에 대한 다른 수정/락 획득을 막는다고 설명합니다.
|
||||
|
||||
## 10. 문서 경계
|
||||
|
||||
이 문서는 동시성 문제 분류와 기본 해법 선택 기준을 다룬다.
|
||||
|
||||
다음 내용은 별도 문서에서 확장한다.
|
||||
|
||||
- `SELECT FOR UPDATE`, `NOWAIT`, `SKIP LOCKED` 같은 명시적 락
|
||||
- isolation level 선택과 anomaly 상세
|
||||
- deadlock 분석
|
||||
- outbox / 중복 요청 / idempotency 상세
|
||||
- distributed lock과 cross-process coordination
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 동시에 수정될 수 있는 엔티티에 version 없이 last-commit-wins를 허용하는 것
|
||||
- `@Version` 대신 애플리케이션 시각이나 임의 숫자를 직접 관리하는 것
|
||||
- 중복 생성 방지를 “먼저 조회한 뒤 없으면 insert”로만 처리하는 것
|
||||
- 상태 전이를 SELECT 후 자바 if문 + 별도 UPDATE로 처리하는 것
|
||||
- `OptimisticLockException`을 잡아서 무시하고 계속 진행하는 것
|
||||
- `@OptimisticLock(excluded = true)`를 핵심 비즈니스 필드에 사용하는 것
|
||||
- unique violation을 아무 조건 없이 무한 재시도하는 것
|
||||
- lock 문제와 optimistic locking 문제를 구분하지 않고 모두 `FOR UPDATE`로 해결하려는 것
|
||||
|
||||
이 금지 규칙은 Jakarta Persistence의 optimistic locking 규정, Hibernate의 lost update 방지 설명, PostgreSQL의 제약/UPSERT/serialization failure guidance를 바탕으로 한 best practice다.
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 문제는 같은 row 동시 수정인가, 중복 생성인가, 상태 전이 경쟁인가?
|
||||
- 동시 수정 가능 엔티티에 `@Version`이 있는가?
|
||||
- version 타입은 숫자형을 기본으로 검토했는가?
|
||||
- 중복 생성은 DB `UNIQUE`/`PK`/`FK`로 닫혀 있는가?
|
||||
- create-or-update는 `ON CONFLICT` 또는 동등한 atomic 방식으로 처리하는가?
|
||||
- 상태 전이는 단일 `UPDATE ... WHERE ...`로 설계했는가?
|
||||
- 성공 여부를 row count 또는 `RETURNING`으로 판정하는가?
|
||||
- `OptimisticLockException`/`40001`/`40P01` 재시도 범위를 전체 유스케이스로 정의했는가?
|
||||
- unique violation 재시도를 정말 transient concurrency로 해석할 근거가 있는가?
|
||||
- 락이 꼭 필요한 문제를 optimistic locking 문제와 혼동하고 있지 않은가?
|
||||
@@ -0,0 +1,340 @@
|
||||
# Index 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 PostgreSQL 인덱스를 어떤 기준으로 생성하고, 어떤 경우에 멀티 컬럼 / partial / expression / covering index를 사용할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 인덱스를 “많을수록 좋다”가 아니라 읽기 이익과 쓰기 비용의 교환으로 이해한다
|
||||
- 조회 패턴에 맞는 인덱스를 만들고, 습관적 인덱스 추가를 줄인다
|
||||
- B-tree를 기본값으로 두되, GIN / BRIN 같은 특수 인덱스는 근거가 있을 때만 사용한다
|
||||
- JPA/Hibernate를 쓰더라도 인덱스 설계 기준은 엔티티 구조가 아니라 실제 SQL 접근 패턴을 기준으로 잡는다
|
||||
|
||||
PostgreSQL 공식 문서도 인덱스는 조회를 빠르게 만들 수 있지만, 동시에 INSERT/UPDATE/DELETE 비용과 저장 공간 비용을 추가하므로 신중하게 사용해야 한다고 설명한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 PostgreSQL 공식 문서의 Chapter 11(Indexes), CREATE INDEX, EXPLAIN, 통계 뷰 문서를 기준으로 작성한다. 인덱스 종류, 멀티 컬럼 규칙, partial / expression / covering index, 운영 중 생성 방식은 모두 공식 문서로 직접 확인 가능하다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 인덱스는 컬럼 기준이 아니라 쿼리 기준으로 만든다
|
||||
|
||||
인덱스는 “이 컬럼이 중요해 보이니까”가 아니라 어떤 WHERE / JOIN / ORDER BY / pagination 경로를 빠르게 만들고 싶은가를 기준으로 설계해야 한다. PostgreSQL planner는 쿼리 구조와 통계에 따라 인덱스 사용 여부를 결정하므로, 인덱스 정의는 실제 질의 패턴과 분리해서 생각할 수 없다. EXPLAIN은 planner가 선택한 실행 계획을 보여 주며, EXPLAIN ANALYZE는 실제 실행 통계까지 확인하게 해 준다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 인덱스 생성 이유를 항상 쿼리 패턴으로 설명할 수 있어야 한다
|
||||
- “자주 조회될 것 같다” 수준의 추측만으로 인덱스를 추가하지 않는다
|
||||
- 인덱스 추가/변경 전후는 EXPLAIN (ANALYZE)로 확인하는 것을 기본으로 한다
|
||||
|
||||
### 3.2 모든 인덱스는 쓰기 비용과 저장 비용을 만든다
|
||||
|
||||
PostgreSQL 공식 문서는 인덱스가 테이블과 별도로 저장되는 secondary index이며, 생성 후에는 테이블 변경 시점마다 계속 동기화되어야 한다고 설명한다. 또한 거의 사용되지 않는 인덱스는 제거하는 편이 좋다고 안내한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 인덱스 하나를 추가할 때마다 INSERT/UPDATE/DELETE 비용이 증가한다고 가정한다
|
||||
- 사용 빈도가 낮거나 중복되는 인덱스는 유지하지 않는다
|
||||
- “읽기 성능 개선”만 보고 추가하고, 쓰기 비용은 무시하는 설계를 금지한다
|
||||
|
||||
### 3.3 제약이 이미 만든 인덱스를 중복 생성하지 않는다
|
||||
|
||||
PostgreSQL은 PRIMARY KEY와 UNIQUE 제약을 만들면 자동으로 unique B-tree 인덱스를 생성한다. 따라서 PK/UNIQUE 컬럼에 동일한 의미의 인덱스를 다시 만드는 것은 중복인 경우가 많다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- PK/UNIQUE 제약이 이미 만든 인덱스를 먼저 확인한다
|
||||
- 같은 컬럼, 같은 순서, 같은 의미의 중복 인덱스를 금지한다
|
||||
- 제약 인덱스로 해결되지 않는 조회 패턴이 있을 때만 별도 인덱스를 검토한다
|
||||
|
||||
## 4. 기본 인덱스 타입 기준
|
||||
|
||||
### 4.1 기본값은 B-tree
|
||||
|
||||
PostgreSQL은 여러 인덱스 타입을 제공하지만, CREATE INDEX의 기본은 B-tree이며 가장 일반적인 상황에 적합하다고 설명한다. B-tree는 정렬 가능한 값에 대해 equality, range, BETWEEN, IN, IS NULL, prefix LIKE 'foo%' 같은 조건과 ORDER BY 처리에 특히 강하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 특별한 이유가 없으면 기본 인덱스 타입은 B-tree
|
||||
- equality 조회, 범위 조회, 정렬, 일반 pagination은 먼저 B-tree로 검토
|
||||
- 비-B-tree는 연산자/데이터 타입 요구가 분명할 때만 사용
|
||||
|
||||
### 4.2 비-B-tree는 데이터 특성과 연산자가 맞을 때만 사용한다
|
||||
|
||||
PostgreSQL은 GIN, GiST, BRIN 등 각 인덱스 타입이 서로 다른 알고리즘과 적합한 연산자 집합을 가진다고 설명한다. 예를 들어 GIN은 composite value 안의 element 검색에 적합하고, BRIN은 물리적 저장 순서와 자연 상관성이 있는 아주 큰 테이블에 적합하다. jsonb 검색도 GIN이 대표적이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- jsonb containment / key search는 GIN을 우선 검토
|
||||
- append-only에 가깝고 시간/순번 컬럼이 물리 순서와 잘 맞는 대형 테이블은 BRIN을 검토
|
||||
- Hash/GiST/SP-GiST는 특별한 연산 요구가 분명할 때만 사용
|
||||
- “성능이 안 나와서 일단 GIN/BRIN” 같은 추측성 선택을 금지한다
|
||||
|
||||
## 5. 단일 컬럼 인덱스 기준
|
||||
|
||||
### 5.1 먼저 단일 컬럼 인덱스로 충분한지 본다
|
||||
|
||||
PostgreSQL 공식 문서는 멀티 컬럼 인덱스가 가능하지만, 대부분의 상황에서는 단일 컬럼 인덱스가 충분하며 공간과 유지 비용 면에서 유리하다고 설명한다. 멀티 컬럼 인덱스는 신중히 사용해야 하고, 3개를 넘는 키 컬럼은 매우 정형화된 사용 패턴이 아니면 도움이 되기 어렵다고 안내한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 인덱스 검토의 출발점은 단일 컬럼 인덱스
|
||||
- 멀티 컬럼 인덱스는 실제 복합 검색 패턴이 반복될 때만 추가
|
||||
- “혹시 도움이 될 수 있으니 여러 컬럼을 한 번에 묶는” 설계를 금지한다
|
||||
|
||||
### 5.2 FK 컬럼은 조회/삭제 경로를 보고 단일 인덱스를 검토한다
|
||||
|
||||
PostgreSQL은 FK를 선언해도 참조하는 쪽 컬럼에 인덱스를 자동 생성하지 않는다. 하지만 부모 삭제/갱신과 자식 조인 경로에서 FK 컬럼 인덱스가 자주 필요하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- FK 컬럼은 기본적으로 인덱스 후보로 본다
|
||||
- 단, 항상 자동 생성하지는 않고 실제 join / delete / lookup 경로를 함께 본다
|
||||
- FK가 있다고 해서 인덱스가 이미 있다고 가정하지 않는다
|
||||
|
||||
## 6. 멀티 컬럼 인덱스 기준
|
||||
|
||||
### 6.1 B-tree 멀티 컬럼 인덱스는 왼쪽 컬럼 순서가 핵심이다
|
||||
|
||||
PostgreSQL 공식 문서는 B-tree 멀티 컬럼 인덱스가 leading(leftmost) columns 제약에 가장 효율적이라고 설명한다. 선행 컬럼에 equality 조건이 있고, 그 다음 첫 non-equality 컬럼에 range 조건이 있을 때 인덱스가 가장 잘 작동한다. 뒤쪽 컬럼 조건은 table 방문을 줄이는 데는 도움을 줄 수 있어도, 인덱스 스캔 범위를 줄이는 효과는 제한적일 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 멀티 컬럼 B-tree는 가장 자주 쓰는 equality 필터를 왼쪽에 둔다
|
||||
- 그 다음에 range / 정렬 컬럼을 배치한다
|
||||
- 단순히 엔티티 필드 순서대로 인덱스 순서를 정하지 않는다
|
||||
|
||||
### 6.2 멀티 컬럼 인덱스는 “자주 함께 쓰는 조건”에만 사용한다
|
||||
|
||||
PostgreSQL은 separate index들을 bitmap scan으로 조합할 수 있다. 즉 (x, y) 멀티 컬럼 인덱스만이 유일한 선택지는 아니며, 경우에 따라서는 x 인덱스와 y 인덱스를 따로 두고 planner가 결합하는 편이 나을 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 항상 함께 쓰이는 조건 조합이면 멀티 컬럼 인덱스를 우선 검토
|
||||
- 독립적으로도 자주 쓰이는 컬럼이면 separate indexes 가능성도 같이 본다
|
||||
- 멀티 컬럼 인덱스가 있으면 단일 인덱스가 전부 불필요하다고 단정하지 않는다
|
||||
|
||||
### 6.3 4개 이상 키 컬럼 인덱스는 예외적으로만 허용한다
|
||||
|
||||
공식 문서도 멀티 컬럼 인덱스는 신중히 써야 하며, 3개를 넘는 경우는 사용 패턴이 매우 정형화되지 않으면 대개 도움이 되기 어렵다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 키 컬럼 4개 이상 인덱스는 기본 금지
|
||||
- 정말 필요한 경우에도 EXPLAIN (ANALYZE) 근거와 함께 예외적으로 허용
|
||||
- “모든 검색 조건을 한 인덱스로 커버”하려는 설계를 금지한다
|
||||
|
||||
## 7. ORDER BY / 정렬 기준
|
||||
|
||||
### 7.1 정렬 최적화 기본값은 B-tree
|
||||
|
||||
PostgreSQL 공식 문서는 현재 정렬된 출력(ORDER BY)을 직접 제공할 수 있는 인덱스는 B-tree뿐이라고 설명한다. 또한 소수 행을 가져오는 쿼리에서는 인덱스로 정렬을 피하는 이점이 크지만, 테이블 대부분을 읽는 경우에는 sequential access + explicit sort가 더 빠를 수 있다고 안내한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ORDER BY 최적화는 B-tree 기준으로 설계
|
||||
- 작은 결과 집합 + 정렬 회피가 중요한 쿼리에 인덱스 정렬 최적화를 적용
|
||||
- 대량 스캔 쿼리에 무조건 정렬용 인덱스를 추가하지 않는다
|
||||
|
||||
### 7.2 단일 컬럼 DESC 인덱스는 보통 불필요하다
|
||||
|
||||
PostgreSQL은 B-tree를 forward/backward 모두 스캔할 수 있으므로, 단일 컬럼 DESC 전용 인덱스는 일반적으로 별 이점이 없다. 특별한 의미가 생기는 것은 ORDER BY x ASC, y DESC처럼 혼합 정렬 방향의 멀티 컬럼 인덱스일 때다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단일 컬럼 DESC 인덱스는 기본 금지
|
||||
- mixed ordering 쿼리가 빈번할 때만 (a ASC, b DESC) 같은 특수 정렬 인덱스를 검토
|
||||
- 정렬 방향 지정은 실제 쿼리 계약이 있을 때만 사용한다
|
||||
|
||||
## 8. Partial Index 기준
|
||||
|
||||
### 8.1 일부 행만 자주 조회될 때 partial index를 검토한다
|
||||
|
||||
PostgreSQL은 WHERE predicate가 붙은 partial index를 지원하며, 전체 행이 아니라 “더 유용한 일부 행”만 인덱싱할 수 있다고 설명한다. 이는 soft delete, 미처리 상태, 활성 행 등 특정 부분집합만 자주 조회될 때 유효하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음 같은 패턴에 partial index를 검토한다.
|
||||
|
||||
- soft delete에서 deleted_at IS NULL
|
||||
- 활성 행만 자주 조회하는 경우 is_active = true
|
||||
- 처리 대기 상태만 자주 조회하는 경우 processed_at IS NULL
|
||||
|
||||
### 8.2 partial index는 predicate가 쿼리와 잘 맞아야 한다
|
||||
|
||||
PostgreSQL 공식 문서는 partial index가 사용되려면 planner가 쿼리의 WHERE 조건이 인덱스 predicate를 함의한다고 인식해야 하며, 일반적인 theorem prover는 없다고 설명한다. 단순 부등식은 일부 인식하지만, 대개는 쿼리 조건이 partial index predicate와 매우 가깝게 맞아야 한다. 또한 matching은 planning time에 일어나므로, parameterized query clause는 partial index와 잘 맞지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- partial index predicate는 쿼리 조건과 동일하거나 매우 가깝게 유지
|
||||
- predicate를 과하게 복잡하게 만들지 않는다
|
||||
- parameterized dynamic predicate에 의존하는 partial index 설계를 지양한다
|
||||
|
||||
### 8.3 partial index는 분포가 안정적일 때 더 적합하다
|
||||
|
||||
PostgreSQL은 partial index가 common values를 제외하는 방식일 때, 데이터 분포가 자주 바뀌지 않는 경우에 더 적합하다고 설명한다. 분포가 변하면 재생성/재조정 비용이 생긴다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- partial index는 “오랫동안 hot subset이 유지되는 조건”에 사용
|
||||
- rapidly changing predicate에는 기본 해법으로 사용하지 않는다
|
||||
- 분포가 자주 바뀌는 경우 일반 인덱스 또는 쿼리 재설계를 우선 검토한다
|
||||
|
||||
## 9. Expression Index 기준
|
||||
|
||||
### 9.1 컬럼이 아니라 표현식으로 검색한다면 expression index를 검토한다
|
||||
|
||||
PostgreSQL은 expression index를 공식 지원하며, lower(col) 같은 계산 결과를 인덱싱할 수 있다. 대소문자 무시 검색처럼 쿼리가 항상 동일한 표현식을 적용할 때 유효하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음에 한해 expression index를 검토한다.
|
||||
|
||||
- lower(email) 기반의 case-insensitive search
|
||||
- 특정 JSON 경로/표현식 기반 검색
|
||||
- 문자열 결합 또는 계산 결과를 자주 찾는 경우
|
||||
|
||||
### 9.2 함수/표현식은 immutable이어야 한다
|
||||
|
||||
PostgreSQL은 인덱스 정의에 사용되는 함수와 연산자는 immutable이어야 한다고 설명한다. 결과가 외부 상태나 현재 시간 등에 따라 바뀌면 인덱스 의미가 깨진다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- expression index에는 immutable 함수만 사용
|
||||
- now() 같은 시간 의존 표현식은 금지
|
||||
- 사용자 정의 함수는 volatility 속성을 확인한 뒤에만 사용
|
||||
|
||||
### 9.3 expression index는 쓰기 비용이 더 크다
|
||||
|
||||
PostgreSQL 공식 문서는 expression index가 삽입과 non-HOT update마다 표현식을 계산해야 하므로 유지 비용이 높다고 설명한다. 검색 성능 이점이 확실할 때만 쓰는 것이 안전하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- expression index는 read-heavy 경로에서만 채택
|
||||
- 단순 컬럼 인덱스로 충분하면 expression index를 만들지 않는다
|
||||
- “쿼리를 고치기 어렵다”는 이유만으로 남발하지 않는다
|
||||
|
||||
## 10. Covering Index / INCLUDE 기준
|
||||
|
||||
### 10.1 INCLUDE는 index-only scan이 실제로 이득일 때만 사용한다
|
||||
|
||||
PostgreSQL은 INCLUDE로 non-key column을 인덱스 leaf tuple에 넣어 index-only scan을 돕는다. 하지만 index-only scan은 인덱스 타입 지원, 쿼리가 인덱스 내 컬럼만 참조할 것, 그리고 visibility map 상태가 좋아야 실제 이점이 크다. 특히 자주 변경되는 테이블에서는 heap 방문을 완전히 피하지 못할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 자주 실행되는 read-heavy 목록 조회에서만 INCLUDE를 검토
|
||||
- 자주 갱신되는 hot table에는 신중히 적용
|
||||
- 단순히 “커버링이 좋아 보인다”는 이유로 추가하지 않는다
|
||||
|
||||
### 10.2 INCLUDE 컬럼은 보수적으로 선택한다
|
||||
|
||||
PostgreSQL은 INCLUDE 컬럼이 인덱스 크기를 키우고, 너무 크면 삽입 실패까지 일어날 수 있으며, B-tree deduplication도 사용되지 않는다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- INCLUDE에는 작은 payload 컬럼만 넣는다
|
||||
- 큰 text/json/blob 계열 컬럼 포함은 기본 금지
|
||||
- key column과 non-key column을 구분해서 설계한다
|
||||
|
||||
## 11. 특수 인덱스 사용 기준
|
||||
|
||||
### 11.1 jsonb 검색은 기본적으로 GIN을 검토한다
|
||||
|
||||
PostgreSQL 공식 문서는 jsonb에서 key exists, containment, jsonpath matching을 효율적으로 처리하기 위해 GIN 인덱스를 사용할 수 있다고 설명한다. 또한 전체 jsonb 컬럼에 GIN을 두는 방식과, 자주 조회하는 하위 경로에 expression + GIN을 두는 방식의 trade-off도 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- jsonb 전체 containment 조회가 많으면 GIN
|
||||
- 특정 하위 키/배열 경로만 자주 조회하면 expression index + GIN도 검토
|
||||
- 단, 핵심 필터 조건이면 jsonb보다 일반 컬럼으로 승격하는 것을 우선 검토한다
|
||||
|
||||
### 11.2 대형 append-only 테이블은 BRIN을 검토한다
|
||||
|
||||
PostgreSQL은 BRIN이 물리적 저장 위치와 자연 상관성이 있는 매우 큰 테이블에 적합하며, 인덱스 크기가 매우 작고 큰 범위를 건너뛸 수 있다고 설명한다. 다만 lossy index라서 재검사가 필요하고, 요약 단위(pages_per_range)와 summarization 특성을 이해하고 써야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- audit log, event log, append-only history 같은 대형 테이블에 검토
|
||||
- created_at, 증가하는 sequence/id처럼 물리 순서와 상관성이 큰 컬럼에 우선 적용
|
||||
- OLTP 소형 테이블에 BRIN을 기본값으로 사용하지 않는다
|
||||
|
||||
## 12. 운영 기준
|
||||
|
||||
### 12.1 운영 중 대형 테이블 인덱스 추가는 CONCURRENTLY를 기본 검토한다
|
||||
|
||||
PostgreSQL은 일반 CREATE INDEX가 읽기는 허용하지만 쓰기를 막고, 운영 환경에서는 이 잠금이 받아들이기 어려울 수 있다고 설명한다. CREATE INDEX CONCURRENTLY는 writes를 막지 않고 인덱스를 만들 수 있지만 여러 caveat가 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 운영 중 대형 테이블 인덱스 추가는 CREATE INDEX CONCURRENTLY를 우선 검토
|
||||
- 로컬/테스트/배치 전용 환경에서는 일반 CREATE INDEX도 가능
|
||||
- 구체적 migration 절차는 migration 문서에서 확장한다
|
||||
|
||||
### 12.2 CONCURRENTLY는 만능이 아니다
|
||||
|
||||
PostgreSQL 공식 문서는 concurrent build가 실패하면 invalid index가 남을 수 있고, transaction block 안에서 실행할 수 없으며, 같은 테이블에서는 동시에 하나만 수행할 수 있다고 설명한다. partitioned table에도 제약이 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- CONCURRENTLY는 운영 안전성 도구이지 단순 기본값이 아니다
|
||||
- 실패 시 invalid index 정리 절차를 준비한다
|
||||
- migration 도구에서 transaction wrapping과 충돌하는지 먼저 확인한다
|
||||
|
||||
### 12.3 인덱스 효과 검증은 EXPLAIN + 통계 뷰로 확인한다
|
||||
|
||||
PostgreSQL은 EXPLAIN/EXPLAIN ANALYZE로 실행 계획을 확인할 수 있고, pg_stat_user_indexes/pg_stat_all_indexes로 index scan 수와 접근 통계를 확인할 수 있다고 설명한다. per-index statistics는 어떤 인덱스가 실제로 사용되는지 판단하는 데 유용하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 인덱스 추가 전후는 EXPLAIN (ANALYZE)로 확인
|
||||
- 장기적으로는 pg_stat_user_indexes로 사용 빈도 확인
|
||||
- 거의 쓰이지 않는 비제약 인덱스는 제거 후보로 관리
|
||||
|
||||
## 13. 네이밍 기준
|
||||
|
||||
프로젝트에서는 인덱스 이름을 명시적으로 선언한다. PostgreSQL은 이름을 생략하면 자동 생성 이름을 만들지만, 운영 추적성과 migration diff 명확성을 위해 명시 이름이 더 안전하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 인덱스: ix_<table>__<columns>
|
||||
- partial index: ix_<table>__<columns>__<predicate_hint>
|
||||
- unique index: uq_<table>__<columns>
|
||||
- expression index: ix_<table>__<expression_hint>
|
||||
|
||||
## 14. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 모든 FK/모든 컬럼에 기계적으로 인덱스를 생성
|
||||
- PK/UNIQUE가 이미 만든 인덱스를 중복 생성
|
||||
- 근거 없이 4개 이상 키 컬럼 멀티 컬럼 인덱스 생성
|
||||
- partial index predicate를 지나치게 복잡하게 설계
|
||||
- mutable 함수 기반 expression index 생성
|
||||
- 큰 payload 컬럼을 INCLUDE에 무분별하게 추가
|
||||
- 단일 컬럼 DESC 인덱스를 습관적으로 생성
|
||||
- jsonb/배열/전문검색 요구가 아닌데도 GIN을 남발
|
||||
- 대형 운영 테이블에 일반 CREATE INDEX를 무심코 실행
|
||||
|
||||
이 금지 규칙은 PostgreSQL 공식 문서의 동작 특성과 실무 운영 리스크를 함께 반영한 best practice다.
|
||||
|
||||
## 15. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 인덱스는 특정 쿼리 패턴으로 설명 가능한가?
|
||||
- PK/UNIQUE/FK가 이미 제공하는 인덱스와 중복되지 않는가?
|
||||
- 단일 컬럼 인덱스로 충분한지 먼저 검토했는가?
|
||||
- 멀티 컬럼이면 왼쪽 컬럼 순서가 실제 필터 패턴과 맞는가?
|
||||
- 정렬 최적화가 정말 필요한 쿼리인가?
|
||||
- partial index predicate가 실제 쿼리와 정확히 맞는가?
|
||||
- expression index의 함수/연산자가 immutable인가?
|
||||
- INCLUDE 컬럼이 작고, index-only scan 이점이 실제로 기대되는가?
|
||||
- 비-B-tree 선택 이유가 연산자/데이터 타입 특성으로 설명되는가?
|
||||
- 운영 반영 시 CONCURRENTLY 필요 여부를 검토했는가?
|
||||
- EXPLAIN (ANALYZE) 또는 통계 뷰로 효과를 검증했는가?
|
||||
@@ -0,0 +1,208 @@
|
||||
# Transaction Isolation 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 PostgreSQL과 Spring 트랜잭션 환경에서 트랜잭션 격리수준(isolation level) 을 어떤 기준으로 선택할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- `READ COMMITTED`, `REPEATABLE READ`, `SERIALIZABLE`의 PostgreSQL 실제 동작을 기준으로 해석한다
|
||||
- Spring `Isolation` enum의 일반적 설명과 PostgreSQL 구현 차이를 구분한다
|
||||
- 격리수준을 “성능 문제 생기면 일단 올리는 옵션”이 아니라 증명해야 하는 정합성 요구로 다룬다
|
||||
- stronger isolation이 필요할 때도 재시도 정책까지 포함해서 설계하게 만든다
|
||||
|
||||
PostgreSQL은 SQL 표준의 네 가지 isolation level을 요청할 수 있지만, 내부적으로는 세 가지 distinct level만 구현하며, `READ UNCOMMITTED`는 `READ COMMITTED`처럼 동작한다고 설명한다. 또한 PostgreSQL의 `REPEATABLE READ`는 phantom read도 허용하지 않는다고 설명한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL / Spring Framework 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 PostgreSQL의 Transaction Isolation, `SET TRANSACTION`, Serialization Failure Handling 문서와 Spring Framework의 `@Transactional` / `Isolation` 문서를 기준으로 작성한다. Spring은 `@Transactional` 기본 isolation이 `ISOLATION_DEFAULT`라고 설명하고, PostgreSQL은 기본 isolation이 보통 `READ COMMITTED`라고 설명한다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 격리수준은 기본값을 바꾸는 튜닝 옵션이 아니라 정합성 요구다
|
||||
|
||||
트랜잭션 격리수준은 “더 안전해 보이니까 올린다”가 아니라, 어떤 이상 현상(anomaly)을 허용할 수 없는가를 기준으로 선택해야 한다. PostgreSQL은 각 isolation level을 dirty read, nonrepeatable read, phantom read, serialization anomaly 관점에서 설명하고 있다. 따라서 선택 기준도 “이 유스케이스가 어떤 이상 현상에 취약한가”여야 한다.
|
||||
|
||||
### 3.2 Spring enum 설명보다 PostgreSQL 실제 구현을 source of truth로 둔다
|
||||
|
||||
Spring의 `Isolation` enum은 일반적인 JDBC 의미를 설명하며, 예를 들어 `REPEATABLE_READ`는 phantom read가 발생할 수 있다고 기술한다. 하지만 PostgreSQL은 `REPEATABLE READ` 구현이 phantom read를 허용하지 않는다고 명시한다. 따라서 PostgreSQL 기반 프로젝트에서는 Spring enum의 일반 설명을 참고하되, 실제 의미는 PostgreSQL 문서를 기준으로 해석해야 한다.
|
||||
|
||||
### 3.3 격리수준을 올렸으면 재시도까지 함께 설계해야 한다
|
||||
|
||||
PostgreSQL은 `REPEATABLE READ`와 `SERIALIZABLE` 모두에서 serialization anomaly를 막기 위해 실패가 발생할 수 있고, 애플리케이션은 serialization failure를 재시도할 준비가 되어 있어야 한다고 설명한다. 따라서 stronger isolation을 선택하는 것은 단순 설정 변경이 아니라 실패와 재시도 모델을 도입하는 일이다.
|
||||
|
||||
## 4. Spring 기준
|
||||
|
||||
### 4.1 기본값은 Isolation.DEFAULT
|
||||
|
||||
Spring은 `@Transactional`의 기본 isolation이 `ISOLATION_DEFAULT`라고 설명한다. Spring `Isolation.DEFAULT`는 underlying data store의 기본 isolation level을 사용한다. PostgreSQL의 기본 isolation은 보통 `READ COMMITTED`이므로, 별도 지정이 없으면 대부분의 경우 PostgreSQL 기본값을 따른다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본값은 `Isolation.DEFAULT`
|
||||
- PostgreSQL 기본값이 곧 프로젝트의 일반 기본값이다
|
||||
- 특별한 근거 없이 모든 서비스 메서드에 isolation을 명시하지 않는다
|
||||
|
||||
### 4.2 isolation 선언은 새 트랜잭션을 시작할 때만 적용된다고 본다
|
||||
|
||||
Spring `@Transactional` javadoc은 isolation setting이 `REQUIRED` 또는 `REQUIRES_NEW`처럼 새로 시작된 트랜잭션에만 적용된다고 설명한다. 또한 기존 트랜잭션에 참여하는 inner scope에서는 local isolation declaration이 기본적으로 무시된다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- isolation은 outer use case 메서드에서 선언하는 것을 기본으로 한다
|
||||
- 내부 helper 메서드에서 isolation만 다르게 선언해도 실제로는 반영되지 않을 수 있다고 본다
|
||||
- isolation mismatch를 엄격히 다루고 싶다면 Spring transaction manager의 `validateExistingTransactions` 검토를 별도 운영 설정으로 다룬다
|
||||
|
||||
## 5. PostgreSQL 격리수준 해석 기준
|
||||
|
||||
### 5.1 READ UNCOMMITTED
|
||||
|
||||
PostgreSQL은 `READ UNCOMMITTED`를 요청할 수 있지만 내부적으로는 `READ COMMITTED`처럼 동작한다고 설명한다. 이는 PostgreSQL MVCC 구조에서 그 방식이 유일하게 sensible한 매핑이라고 명시한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- PostgreSQL에서는 `READ_UNCOMMITTED`를 별도 전략으로 사용하지 않는다
|
||||
- dirty read를 기대하고 `READ_UNCOMMITTED`를 쓰는 것은 금지한다
|
||||
- `READ_UNCOMMITTED` 요청은 사실상 `READ_COMMITTED`와 같다고 본다
|
||||
|
||||
### 5.2 READ COMMITTED
|
||||
|
||||
PostgreSQL은 `READ COMMITTED`가 기본 isolation level이며, 각 SELECT가 그 쿼리 시작 시점 기준의 snapshot만 본다고 설명한다. 따라서 같은 트랜잭션 안의 두 SELECT라도, 그 사이 다른 트랜잭션이 commit하면 서로 다른 결과를 볼 수 있다. 또한 concurrent update가 먼저 commit되면 뒤늦게 도착한 update/delete는 갱신된 row 버전에 대해 `WHERE` 조건을 다시 평가할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 해석:
|
||||
|
||||
- 기본 CRUD, 일반 API, 대부분의 업무 트랜잭션 기본값은 `READ COMMITTED`
|
||||
- 같은 트랜잭션 안이라도 여러 query가 같은 snapshot을 본다고 가정하지 않는다
|
||||
- 복잡한 read-check-write나 cross-row invariant 검증에는 기본값만으로 충분한지 별도 검토한다
|
||||
|
||||
### 5.3 REPEATABLE READ
|
||||
|
||||
PostgreSQL은 `REPEATABLE READ`가 트랜잭션 시작 이후 다른 트랜잭션이 commit한 변화를 보지 않으며, 같은 트랜잭션 안의 successive SELECT가 같은 snapshot을 본다고 설명한다. 또한 PostgreSQL의 구현은 phantom read도 허용하지 않으며, SQL 표준 최소 보장보다 더 강하다고 명시한다. 하지만 serialization anomalies는 여전히 가능하고, 애플리케이션은 실패 재시도를 준비해야 한다고 설명한다.
|
||||
|
||||
프로젝트 해석:
|
||||
|
||||
- point-in-time read consistency가 필요한 다단계 조회/검증에는 `REPEATABLE READ`를 검토한다
|
||||
- 하지만 cross-row / cross-predicate invariant가 정말 깨지면 안 되는 유스케이스에는 `SERIALIZABLE`이 더 적절할 수 있다
|
||||
- `REPEATABLE READ`를 쓴다고 해서 serialization anomaly까지 사라진다고 오해하지 않는다
|
||||
|
||||
### 5.4 SERIALIZABLE
|
||||
|
||||
PostgreSQL은 `SERIALIZABLE`이 가장 엄격한 isolation이며, 성공적으로 commit한 트랜잭션 집합이 마치 한 번에 하나씩 실행된 것과 같은 효과를 보장한다고 설명한다. 구현은 `REPEATABLE READ`처럼 동작하되, serialization anomaly를 만들 수 있는 read/write dependency를 감시하고 필요하면 한 트랜잭션을 `40001`로 rollback시킨다. PostgreSQL은 이를 위해 predicate locking을 사용하며, 이 lock은 anomaly 감지용이지 blocking을 추가로 만드는 lock은 아니라고 설명한다.
|
||||
|
||||
프로젝트 해석:
|
||||
|
||||
- cross-row 합계, 범위 기반 제약, 조건부 집합 연산처럼 serial execution과 같은 의미가 필요한 경우에만 `SERIALIZABLE`
|
||||
- `SERIALIZABLE`을 선택하면 재시도 정책이 설계에 포함되어야 한다
|
||||
- 단순히 “더 안전하니까” 전체 시스템 기본값으로 올리지 않는다
|
||||
|
||||
## 6. isolation 선택 기준
|
||||
|
||||
### 6.1 기본값은 READ COMMITTED 해석이다
|
||||
|
||||
Spring 기본값은 `DEFAULT`, PostgreSQL 기본값은 보통 `READ COMMITTED`이다. PostgreSQL도 이 수준이 빠르고 단순하며 많은 애플리케이션에 충분하다고 설명한다. 프로젝트 기본값도 동일하다. 일반적인 단건/소규모 CRUD와, DB 제약·원자적 UPDATE·낙관적 락으로 이미 정합성이 닫히는 유스케이스는 `READ COMMITTED`를 기본으로 한다.
|
||||
|
||||
### 6.2 같은 트랜잭션 안에서 동일 snapshot이 꼭 필요하면 REPEATABLE READ를 검토한다
|
||||
|
||||
보고서 생성, 다단계 조회 기반 검증, point-in-time 정산 미리보기처럼 한 트랜잭션 안에서 여러 query가 같은 세계를 봐야 하는 경우에는 `REPEATABLE READ`를 검토한다. PostgreSQL은 이 수준에서 successive SELECT가 같은 snapshot을 본다고 설명한다.
|
||||
|
||||
### 6.3 집합 단위 정합성까지 깨지면 안 되면 SERIALIZABLE을 검토한다
|
||||
|
||||
“동시에 두 트랜잭션이 서로 다른 조건을 읽고, 각각 다른 row를 추가/갱신해서 전체 집합 규칙을 깨는” 종류의 문제는 `READ COMMITTED`나 `REPEATABLE READ`만으로는 막지 못할 수 있다. PostgreSQL은 바로 이런 serialization anomaly를 `SERIALIZABLE`이 막는다고 설명한다.
|
||||
|
||||
### 6.4 stronger isolation보다 더 직접적인 수단이 있으면 먼저 쓴다
|
||||
|
||||
중복 생성은 `UNIQUE`/`ON CONFLICT`, lost update는 `@Version`, 상태 전이는 조건부 UPDATE, 작업 선점은 row lock처럼 더 직접적인 수단이 있을 수 있다. PostgreSQL도 `READ COMMITTED`에서 `ON CONFLICT`와 조건부 UPDATE가 원자적으로 유용하게 동작하는 사례를 설명한다. 프로젝트에서는 isolation level을 올리기 전에 더 작은 수단으로 문제가 닫히는지 먼저 검토한다.
|
||||
|
||||
## 7. 재시도 기준
|
||||
|
||||
### 7.1 REPEATABLE READ와 SERIALIZABLE은 재시도 준비가 필요하다
|
||||
|
||||
PostgreSQL은 `REPEATABLE READ`와 `SERIALIZABLE` 모두 serialization failure를 낼 수 있고, 이때 애플리케이션은 트랜잭션을 재시도할 준비가 되어 있어야 한다고 설명한다. 또한 serialization failure의 SQLSTATE는 `40001`이라고 명시한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- stronger isolation을 도입하면 `40001` 재시도 정책을 함께 설계한다
|
||||
- 재시도는 트랜잭션 전체 유스케이스를 새 트랜잭션에서 다시 수행하는 방식으로 한다
|
||||
- 일부 SQL만 재실행하는 방식은 기본 금지다
|
||||
|
||||
### 7.2 SERIALIZABLE read-only deferrable은 예외적 고급 옵션이다
|
||||
|
||||
PostgreSQL은 `SERIALIZABLE READ ONLY DEFERRABLE` 트랜잭션이 anomaly-free snapshot을 확보할 때까지 기다릴 수 있고, 이 경우 읽은 데이터는 읽는 즉시 유효하다고 설명한다. 다만 일반 read-only 트랜잭션의 기본값은 아니며, 고정 snapshot 품질이 중요한 특수 보고/검증 시나리오에만 의미가 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 read-only 서비스에 기본 적용하지 않는다
|
||||
- 장시간 보고서/정산 검증처럼 snapshot 품질이 매우 중요한 특수 작업에서만 검토한다
|
||||
|
||||
## 8. PostgreSQL 특이사항 기준
|
||||
|
||||
### 8.1 sequence 값은 트랜잭션 rollback과 무관하게 보일 수 있다
|
||||
|
||||
PostgreSQL은 sequence 변경이 다른 트랜잭션에 즉시 visible하고, 해당 트랜잭션이 abort되어도 rollback되지 않는다고 설명한다. 따라서 serial/sequence 값의 연속성이나 “rollback되면 번호도 되돌아갈 것” 같은 기대는 isolation과 무관하게 틀릴 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- sequence/identity 값의 gap 없는 연속성을 비즈니스 의미로 사용하지 않는다
|
||||
- 번호 연속성 자체가 중요하면 별도 번호 정책으로 다룬다
|
||||
|
||||
## 9. 프로젝트 권장안
|
||||
|
||||
### 9.1 기본값은 Isolation.DEFAULT + PostgreSQL READ COMMITTED
|
||||
|
||||
프로젝트 기본값은 Spring에서 isolation을 생략하고, PostgreSQL 기본 isolation인 `READ COMMITTED`를 따른다. 이는 대부분의 CRUD와 일반 서비스 로직에 충분하며, PostgreSQL도 이 수준이 fast and simple하다고 설명한다.
|
||||
|
||||
### 9.2 REPEATABLE READ는 “같은 트랜잭션 안의 안정된 조회 시점”이 필요할 때만
|
||||
|
||||
여러 단계 조회가 모두 같은 snapshot을 봐야 하지만, serial equivalence 전체까지는 필요 없는 경우에만 `REPEATABLE READ`를 검토한다. 단, serialization failure 가능성이 있으므로 “읽기 안정화”만 생각하고 재시도를 잊지 않는다.
|
||||
|
||||
### 9.3 SERIALIZABLE은 “정말 serial semantics가 필요한 유스케이스”에만
|
||||
|
||||
범위 조건 기반 정합성, 집합 단위 규칙, 서로 다른 조건을 읽은 뒤 상호 영향을 주는 write가 있는 경우처럼 실제로 serial execution과 같은 의미가 필요한 곳에만 `SERIALIZABLE`을 사용한다. PostgreSQL은 이 수준이 predicate locking과 dependency tracking을 통해 serialization anomaly를 막는다고 설명한다.
|
||||
|
||||
### 9.4 isolation은 기본 해결책이 아니라 마지막 선택지에 가깝다
|
||||
|
||||
대부분의 비즈니스 동시성 문제는 `UNIQUE`, `ON CONFLICT`, `@Version`, 조건부 UPDATE, explicit row lock 등 더 직접적인 수단으로 더 명확하게 풀린다. isolation level은 그런 수단으로도 닫히지 않는 snapshot/invariant 문제에 한해 올린다. 이는 PostgreSQL의 MVCC, `READ COMMITTED` update semantics, serialization failure model을 함께 고려한 프로젝트 권장안이다.
|
||||
|
||||
## 10. 문서 경계
|
||||
|
||||
이 문서는 격리수준의 의미와 선택 기준을 다룬다.
|
||||
|
||||
다음 내용은 별도 문서에서 확장한다.
|
||||
|
||||
- optimistic locking과 `@Version`
|
||||
- explicit row lock (`FOR UPDATE`, `NOWAIT`, `SKIP LOCKED`)
|
||||
- deadlock/serialization failure 재시도 구현
|
||||
- idempotency / UPSERT / uniqueness 설계
|
||||
- outbox / queue / worker claim 설계
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- PostgreSQL에서 `READ_UNCOMMITTED`를 dirty read 용도로 기대하는 것
|
||||
- 같은 트랜잭션 안이면 항상 같은 조회 결과를 본다고 가정하는 것
|
||||
- `REPEATABLE_READ`가 serialization anomaly까지 막는다고 오해하는 것
|
||||
- `SERIALIZABLE`을 재시도 설계 없이 도입하는 것
|
||||
- stronger isolation을 전체 서비스의 기본값으로 기계적으로 올리는 것
|
||||
- isolation으로 해결할 문제가 아닌 uniqueness / lost update / 상태 전이를 격리수준만으로 풀려는 것
|
||||
- outer transaction이 있는데 inner method isolation 선언이 실제로 override될 것이라고 기대하는 것
|
||||
- sequence 값이 rollback될 것이라고 기대하는 것
|
||||
|
||||
이 금지 규칙은 PostgreSQL의 actual isolation semantics와 Spring transaction 선언 규칙을 실무 운영 기준으로 압축한 것이다.
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 유스케이스는 정말 기본 `READ COMMITTED`로 충분하지 않은가?
|
||||
- 필요한 것이 stable snapshot인가, serial semantics인가?
|
||||
- stronger isolation 대신 제약 / 원자적 update / 낙관적 락 / row lock으로 더 직접적으로 풀 수 없는가?
|
||||
- `REPEATABLE READ`를 쓴다면 같은 snapshot이 필요한 이유를 설명할 수 있는가?
|
||||
- `SERIALIZABLE`을 쓴다면 어떤 serialization anomaly를 막으려는지 설명할 수 있는가?
|
||||
- `40001` 재시도 정책이 함께 설계되어 있는가?
|
||||
- Spring isolation 선언이 실제로 새 트랜잭션에서만 의미 있음을 알고 있는가?
|
||||
- sequence/identity 값의 특수 동작을 비즈니스 의미로 오해하지 않는가?
|
||||
@@ -0,0 +1,224 @@
|
||||
# JPA Fetch Strategy 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 JPA/Hibernate에서 연관관계 fetch 전략을 어떤 기준으로 선택할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 엔티티 매핑의 기본 fetch 정책을 안전하게 정한다
|
||||
- 연관관계 로딩 시점을 엔티티 정의가 아니라 use case 기준으로 선택한다
|
||||
- EAGER 기본값 남용, 무분별한 fetch join, 트랜잭션 밖 lazy 접근 같은 위험한 패턴을 줄인다
|
||||
- 실제 실행 SQL 기준으로 fetch 전략을 판단하는 습관을 만든다
|
||||
|
||||
Jakarta Persistence는 FetchType을 연관 데이터 로딩 정책으로 정의하고, Hibernate는 fetching이 성능에 가장 큰 영향을 미치는 요소 중 하나라고 설명한다. 또한 Hibernate는 연관 데이터 과다 로딩이 대부분의 JPA 애플리케이션에서 가장 큰 성능 문제라고 지적한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Jakarta Persistence / Hibernate / Spring Data JPA 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 Jakarta Persistence 3.2 spec의 연관관계 기본 fetch 규칙, fetch graph / load graph semantics, Hibernate User Guide의 fetch join / batch fetching / eager fetching 권장사항, Spring Data JPA의 @EntityGraph 지원 문서를 기반으로 작성한다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 fetch 전략은 “엔티티 구조”가 아니라 “use case의 읽기 경계”를 표현해야 한다
|
||||
|
||||
JPA의 fetch는 연관 속성을 언제 어떤 범위까지 가져올지를 결정하는 정책이다. 따라서 fetch 전략은 엔티티를 선언할 때 한 번 정해 두고 끝나는 설정이 아니라, 어떤 조회에서 어떤 연관 데이터가 필요한지를 기준으로 해석해야 한다. Hibernate도 정적 매핑 기본값은 보수적으로 두고, eager 요구는 동적으로 적용하는 방향을 권장한다.
|
||||
|
||||
### 3.2 fetch 전략은 cascade, orphanRemoval, 연관관계 소유자 개념과 다르다
|
||||
|
||||
fetch는 로딩 시점 정책이고, cascade와 orphanRemoval은 생명주기 전파 정책이다. 같은 연관관계라도 쓰기 전파와 읽기 로딩 정책은 별개로 설계해야 한다. Jakarta Persistence는 연관관계 애노테이션에서 fetch와 cascade를 독립 요소로 정의한다.
|
||||
|
||||
### 3.3 fetch 전략 판단 기준은 “엔티티 그래프”가 아니라 “실제 SQL 개수와 shape”다
|
||||
|
||||
Hibernate는 join fetch, batch fetching, entity graph, secondary select 같은 여러 전략을 제공하지만, 실제 성능은 어떤 SQL이 몇 번 나가느냐에 의해 결정된다. 따라서 fetch 전략은 추상 개념이 아니라 실제 SQL과 실행 계획으로 검증해야 한다.
|
||||
|
||||
## 4. 기본 fetch type 기준
|
||||
|
||||
### 4.1 JPA 기본값은 to-one EAGER, to-many LAZY다
|
||||
|
||||
Jakarta Persistence 3.2 spec에서 @ManyToOne과 @OneToOne의 기본 fetch는 EAGER, @OneToMany와 @ManyToMany의 기본 fetch는 LAZY다. 또한 spec은 EAGER는 provider가 반드시 eager하게 가져와야 하는 요구이고, LAZY는 지연 로딩에 대한 힌트라고 설명한다.
|
||||
|
||||
### 4.2 프로젝트 기본값은 “모든 연관관계 LAZY 명시”다
|
||||
|
||||
JPA 기본값이 존재하더라도, 프로젝트 기본 규칙은 연관관계에 fetch를 명시하고 기본적으로 모두 LAZY로 둔다이다. Hibernate는 EAGER fetching is almost always a bad choice라고 설명하고, 모든 연관관계를 LAZY로 두고 eager 요구는 동적으로 적용하는 편이 낫다고 권장한다. 또한 EAGER는 query별로 덮어쓸 수 없고, JPQL query에서 빠뜨리면 secondary select가 발생해 N+1 문제를 만들 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음을 기본으로 한다.
|
||||
|
||||
- @ManyToOne(fetch = FetchType.LAZY)
|
||||
- @OneToOne(fetch = FetchType.LAZY)
|
||||
- @OneToMany(fetch = FetchType.LAZY) 또는 기본값 유지
|
||||
- @ManyToMany(fetch = FetchType.LAZY) 또는 기본값 유지
|
||||
|
||||
### 4.3 LAZY는 “보장”이 아니라 힌트라는 점을 전제로 설계한다
|
||||
|
||||
Jakarta Persistence spec은 LAZY를 힌트로 정의하고, 구현체가 필요하면 eager하게 가져오는 것을 허용한다. 따라서 “반드시 SQL이 늦게 나간다”는 수준의 가정 위에 설계하면 안 된다. 프로젝트 기준은 LAZY를 기본값으로 두되, 필요한 데이터는 query 또는 entity graph에서 명시적으로 가져오는 방식이다.
|
||||
|
||||
## 5. EAGER 기준
|
||||
|
||||
### 5.1 연관관계 EAGER는 기본 금지다
|
||||
|
||||
Hibernate는 EAGER가 거의 항상 나쁜 선택이며, query 단위로 덮어쓸 수 없고, 필요하지 않은 연관관계까지 항상 가져오게 만든다고 설명한다. 특히 JPQL query에서 EAGER 연관을 fetch join으로 포함하지 않으면 secondary select가 발생할 수 있고, 이것이 N+1 문제로 이어질 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 연관관계에 EAGER를 기본 금지
|
||||
- “항상 필요해 보인다”는 직관만으로 EAGER를 사용하지 않는다
|
||||
- EAGER가 필요해 보여도 먼저 use case 단위 fetch join / entity graph / DTO projection을 검토한다
|
||||
|
||||
### 5.2 EAGER는 “항상 같이 로딩돼야 하는 구조”가 아니라 “항상 비용을 강제하는 설정”이다
|
||||
|
||||
to-one EAGER는 편해 보이지만, 실제로는 조회 경로 대부분에서 필요 없는 조인 또는 secondary select 비용을 고정시킨다. Hibernate는 entity query에서 EAGER association fetch policy는 query 단위로 override되지 않으므로 secondary select가 필요해질 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 엔티티 설계 단계에서 “읽기 편의” 때문에 EAGER를 채택하지 않는다
|
||||
- domain model convenience보다 SQL 비용의 고정화를 더 크게 본다
|
||||
|
||||
## 6. 동적 fetch 전략 기준
|
||||
|
||||
### 6.1 기본값은 LAZY, eager 요구는 query/use case 단위로 적용한다
|
||||
|
||||
Hibernate는 정적으로는 모든 association을 lazy로 두고, eager 요구는 dynamic fetching strategy로 적용하는 것을 권장한다. 이 원칙은 use case마다 필요한 데이터 폭이 다르다는 현실과 잘 맞는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 엔티티 매핑은 보수적
|
||||
- 특정 조회 화면/상세 조회/배치 job에서만 eager 요구를 명시
|
||||
- 같은 엔티티라도 query별로 fetch plan이 달라질 수 있음을 전제로 한다
|
||||
|
||||
### 6.2 to-one 중심 상세 조회는 join fetch를 우선 검토한다
|
||||
|
||||
Hibernate는 join fetch가 laziness를 override하여 같은 SQL join으로 연관 데이터를 가져오는 방식이라고 설명하고, acceptable performance를 위해 자주 사용하게 된다고 안내한다. 또한 Hibernate는 JOIN FETCH가 @ManyToOne / @OneToOne에 특히 적합하다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 상세 조회에서 필요한 to-one 연관은 join fetch를 우선 검토
|
||||
- 한 query 안에서 여러 to-one fetch join은 허용 범위로 본다
|
||||
- 단, 실제 SQL row 수 증가를 설명할 수 있어야 한다
|
||||
|
||||
### 6.3 컬렉션 fetch는 “한 번에 몇 개를 같이 가져올 것인가”를 더 엄격하게 본다
|
||||
|
||||
Hibernate는 여러 to-one을 함께 fetch join하는 것은 안전하지만, 여러 to-many/collection을 병렬 fetch join하면 Cartesian product가 발생해 매우 나쁜 성능을 낼 수 있다고 설명한다. 또한 fetch join은 보통 제한/페이징 query에서 피해야 한다고 명시한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 한 query에서 여러 컬렉션 fetch join은 기본 금지
|
||||
- 컬렉션 fetch join은 최대 1개까지만 매우 신중하게 허용
|
||||
- 목록/페이징 query에서 컬렉션 fetch join은 기본 금지
|
||||
- 여러 컬렉션이 필요하면 secondary query, batch fetching, DTO 조회 분리 등을 검토한다
|
||||
|
||||
### 6.4 read-only use case는 DTO projection을 우선 검토한다
|
||||
|
||||
Hibernate는 read-only transaction에서는 DTO projection이 더 적절하며, 필요한 컬럼만 선택할 수 있고 persistence context 부담도 줄인다고 설명한다. fetch 전략 논의가 항상 엔티티 조회여야 하는 것은 아니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 수정이 목적이 아닌 목록/조회 전용 use case는 DTO projection 우선 검토
|
||||
- 엔티티 그래프 전체를 굳이 관리할 필요가 없는 화면 조회는 엔티티보다 projection을 선호
|
||||
- “조회라서 일단 entity”를 기본값으로 두지 않는다
|
||||
|
||||
## 7. Entity Graph 기준
|
||||
|
||||
### 7.1 fetch plan 제어가 필요하면 Entity Graph를 공식 수단으로 사용한다
|
||||
|
||||
Jakarta Persistence는 entity graph를 query나 find() operation에 적용할 수 있는 fetch plan template로 정의한다. fetchgraph는 명시한 속성만 eager로 취급하고 나머지는 LAZY로 취급하며, loadgraph는 명시한 속성은 eager로 취급하되 나머지는 원래의 default/spec fetch를 따른다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- query별 fetch 계획이 분명하면 entity graph 사용 허용
|
||||
- “기본 연관관계는 LAZY, 특정 조회에서만 일부 연관을 함께 로딩” 패턴에 적합
|
||||
- fetchgraph와 loadgraph 의미 차이를 구분해서 사용한다
|
||||
|
||||
### 7.2 Spring Data JPA에서는 @EntityGraph를 use case 단위로 사용한다
|
||||
|
||||
Spring Data JPA는 repository method에 @EntityGraph를 붙여 JPA 2.1 EntityGraph를 설정할 수 있고, attributePaths()를 이용한 동적 fetch-graph도 지원한다. 이는 정적 매핑을 건드리지 않고 repository method 단위로 fetch plan을 선언하기에 적합하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- repository 메서드 단위 eager 요구에는 @EntityGraph 허용
|
||||
- attributePaths는 필요한 경로만 최소 범위로 선언
|
||||
- 재사용성이 높으면 named entity graph 검토
|
||||
|
||||
## 8. Batch Fetching 기준
|
||||
|
||||
### 8.1 batch fetching은 LAZY secondary select를 완화하는 보조 수단이다
|
||||
|
||||
Hibernate는 @BatchSize가 여러 uninitialized proxy 또는 collection을 한 번에 가져오게 해 주는 최적화라고 설명한다. 이는 LAZY select fetching을 완전히 없애는 것이 아니라 round trip 수를 줄이는 방식이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 여러 엔티티의 같은 LAZY association을 뒤이어 접근하는 패턴이면 @BatchSize 검토
|
||||
- 목록 이후 자식 컬렉션/연관을 묶어서 초기화하는 경우에 적합
|
||||
- 하지만 기본 전략은 아니고, join fetch/DTO projection보다 한 단계 뒤의 최적화 수단으로 본다
|
||||
|
||||
### 8.2 batch fetching은 응급처치이지 기본 해법이 아니다
|
||||
|
||||
Hibernate는 @BatchSize가 N+1보다 낫지만, 대부분의 경우 DTO projection 또는 JOIN FETCH가 더 좋은 대안이라고 설명한다. 따라서 batch fetching은 “이미 LAZY secondary select 구조를 유지해야 하는 상황”에서 보조적으로 검토하는 것이 맞다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 먼저 query 구조 자체를 바꿀 수 있는지 검토
|
||||
- 그래도 여러 연관 초기화가 남으면 @BatchSize를 보조적으로 사용
|
||||
- @BatchSize를 남발해서 구조적 query 문제를 숨기지 않는다
|
||||
|
||||
## 9. 트랜잭션 경계와 LazyInitialization 기준
|
||||
|
||||
### 9.1 LAZY 연관은 persistence context가 열려 있는 동안 필요한 범위까지 초기화해야 한다
|
||||
|
||||
Hibernate는 필요한 연관은 persistence context가 닫히기 전에 가져와야 하며, 그렇지 않으면 LazyInitializationException이 발생한다고 설명한다. 또한 가장 좋은 해결책은 필요한 association을 미리 fetch하는 것이라고 안내한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 트랜잭션 밖에서 엔티티 lazy 연관 접근을 기대하지 않는다
|
||||
- controller/view 단계에서 우연히 lazy loading이 되기를 기대하는 패턴을 금지
|
||||
- application service 내부에서 필요한 fetch plan을 끝낸 뒤 DTO/응답 모델로 변환한다
|
||||
|
||||
### 9.2 fetch 전략 문제를 presentation 계층으로 밀어내지 않는다
|
||||
|
||||
지연 로딩 오류를 화면/직렬화 단계에서 우회하는 것은 fetch 전략 설계 실패를 뒤로 미루는 것이다. 프로젝트 기준은 조회 use case 내부에서 필요한 데이터를 명시적으로 준비하고, presentation 경계 밖으로 미완성 entity graph를 흘리지 않는 것이다. Hibernate의 권장도 필요한 association을 persistence context 종료 전에 fetch하는 방향이다.
|
||||
|
||||
## 10. 문서 경계
|
||||
|
||||
이 문서는 매핑 기본값과 fetch plan 선택 기준을 다룬다.
|
||||
다음 내용은 별도 문서에서 확장한다.
|
||||
|
||||
- N+1 탐지와 방지 패턴
|
||||
- pagination query와 fetch join 충돌
|
||||
- query 최적화와 index 설계
|
||||
- DTO projection 기준
|
||||
- OSIV/open-in-view 운영 정책
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- @ManyToOne, @OneToOne 기본값을 그대로 두고 암묵적 EAGER에 의존
|
||||
- 연관관계에 EAGER를 편의상 선언
|
||||
- 여러 컬렉션을 한 query에서 동시에 fetch join
|
||||
- 페이징 query에 컬렉션 fetch join 사용
|
||||
- @BatchSize를 구조적 query 문제 은폐 수단으로 사용
|
||||
- 트랜잭션 밖 lazy initialization 기대
|
||||
- controller/serializer가 entity lazy loading을 유발하는 구조
|
||||
- 조회 전용 use case인데도 무조건 entity graph 전체를 로딩
|
||||
|
||||
이 금지 규칙은 Jakarta Persistence 기본 동작과 Hibernate 공식 권장사항 위에 얹는 실무 best practice다. 특히 EAGER, multiple collection fetch join, transaction boundary 밖 lazy access는 신뢰도 낮은 설계를 만드는 대표 패턴이다.
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 모든 연관관계의 fetch type이 명시적이거나, 최소한 암묵 기본값을 알고 있는가?
|
||||
- to-one 연관에 기본 EAGER를 그대로 두지 않았는가?
|
||||
- 이 조회는 entity가 정말 필요한가, DTO projection이 더 맞는가?
|
||||
- eager 요구를 매핑이 아니라 query/use case 단위로 풀고 있는가?
|
||||
- fetch join이 to-one 중심인지, 컬렉션은 최대 1개 이내인지 확인했는가?
|
||||
- pagination query와 fetch join 충돌을 검토했는가?
|
||||
- @EntityGraph 또는 fetchgraph/loadgraph 의미를 올바르게 선택했는가?
|
||||
- @BatchSize가 1차 해법이 아니라 보조 최적화인지 설명할 수 있는가?
|
||||
- 필요한 lazy 연관을 트랜잭션 안에서 모두 준비했는가?
|
||||
- 실제 SQL 개수와 shape를 로그/테스트로 검증했는가?
|
||||
@@ -0,0 +1,187 @@
|
||||
# Lock 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 PostgreSQL과 JPA/Hibernate 환경에서 명시적 락(explicit lock) 을 어떤 기준으로 사용할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 낙관적 락, 제약, 원자적 UPDATE로 해결 가능한 문제와 명시적 락이 필요한 문제를 구분한다
|
||||
- PostgreSQL의 row-level lock과 table-level lock을 혼동하지 않게 한다
|
||||
- `FOR UPDATE`, `FOR NO KEY UPDATE`, `FOR SHARE`, `FOR KEY SHARE`, `NOWAIT`, `SKIP LOCKED`의 의미를 안전하게 해석한다
|
||||
- Spring Data JPA의 `@Lock`과 JPA `PESSIMISTIC_*`를 PostgreSQL 락 의미와 연결해서 사용한다
|
||||
|
||||
PostgreSQL은 explicit locking이 MVCC만으로 원하는 동작을 얻을 수 없을 때 애플리케이션이 직접 사용하는 도구라고 설명한다. 따라서 락은 기본값이 아니라 예외적 수단으로 보는 것이 맞다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL / Jakarta Persistence / Spring Data JPA 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 PostgreSQL의 explicit locking, SELECT locking clause, timeout 설정 문서와 Jakarta Persistence 3.2의 `LockModeType`, pessimistic lock timeout/scope, Spring Data JPA의 `@Lock` 문서를 근거로 한다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 명시적 락은 기본 해법이 아니다
|
||||
|
||||
PostgreSQL은 explicit locking을 “MVCC가 원하는 동작을 주지 않을 때” 사용하는 application-controlled locking이라고 설명한다. 따라서 프로젝트 기본 원칙은 다음 순서다. 먼저 `UNIQUE`/`FK`/`CHECK` 같은 제약, `ON CONFLICT`, 조건부 UPDATE, `@Version` 같은 더 직접적이고 덜 무거운 수단을 검토하고, 그래도 해결되지 않을 때만 명시적 락을 올린다.
|
||||
|
||||
### 3.2 기본 대상은 table lock이 아니라 row lock이다
|
||||
|
||||
PostgreSQL은 table-level lock과 row-level lock을 구분하며, 일반적인 비즈니스 동시성 제어에서는 row-level lock이 더 직접적이다. 또한 일반 SELECT는 `ACCESS SHARE`를 잡고, plain SELECT를 막는 유일한 table-level lock은 `ACCESS EXCLUSIVE` 라고 설명한다. 프로젝트 기본값은 `LOCK TABLE`이 아니라 필요한 row만 잠그는 것이다.
|
||||
|
||||
### 3.3 락은 가능한 짧게 잡는다
|
||||
|
||||
PostgreSQL은 row-level lock과 table-level lock이 보통 트랜잭션 종료 시점까지 유지되고, savepoint 이후 잡은 락은 savepoint rollback 시 해제된다고 설명한다. 또한 deadlock을 피하는 최선의 방어는 일관된 순서로 잠그는 것과 트랜잭션을 길게 잡지 않는 것이라고 설명한다. 프로젝트에서도 락을 잡은 뒤 외부 호출, 사용자 대기, 긴 계산을 넣지 않는다.
|
||||
|
||||
## 4. PostgreSQL 락 모델 기준
|
||||
|
||||
### 4.1 row-level lock은 일반 조회를 막지 않는다
|
||||
|
||||
PostgreSQL은 row-level lock이 plain read를 막지 않고, 같은 row에 대한 writer와 locker만 막는다고 설명한다. 따라서 row lock은 “모든 접근 차단”이 아니라 “같은 row의 경쟁 갱신/락 획득 제어”로 이해해야 한다. 이 점을 오해하면 불필요하게 table lock을 선택하게 된다.
|
||||
|
||||
### 4.2 join query의 락 범위는 명시적으로 제한한다
|
||||
|
||||
PostgreSQL `SELECT ... FOR ...` 문법은 `FOR lock_strength [ OF from_reference ... ]` 형태를 지원하고, table list를 생략하면 statement에 사용된 모든 테이블에 적용될 수 있다고 설명한다. 따라서 join query에서 특정 alias만 잠그려면 `OF o`처럼 대상을 명시하는 것이 더 안전하다. 프로젝트에서는 join query + row lock 조합에서 락 대상 alias 명시를 기본 검토한다.
|
||||
|
||||
### 4.3 LIMIT과 락을 함께 쓰면 충분한 row를 찾는 시점까지만 잠근다
|
||||
|
||||
PostgreSQL은 locking clause와 `LIMIT`를 함께 쓰면 필요한 수의 row를 반환할 만큼까지만 locking이 진행된다고 설명한다. 하지만 `OFFSET`으로 건너뛴 row도 잠길 수 있다고 명시한다. 따라서 queue claim 같은 락 기반 조회에서 `OFFSET`은 기본 금지다.
|
||||
|
||||
## 5. row-level lock 강도 선택 기준
|
||||
|
||||
### 5.1 FOR UPDATE
|
||||
|
||||
PostgreSQL은 `FOR UPDATE`가 선택된 row를 현재 트랜잭션 종료 시까지 잠그고, 다른 트랜잭션의 `UPDATE`, `DELETE`, `SELECT FOR UPDATE`, `SELECT FOR NO KEY UPDATE`, `SELECT FOR SHARE`, `SELECT FOR KEY SHARE`를 막는다고 설명한다. 가장 강한 row lock이므로, 삭제 또는 key 변경을 포함한 강한 배타 제어가 필요할 때 사용한다.
|
||||
|
||||
### 5.2 FOR NO KEY UPDATE
|
||||
|
||||
PostgreSQL은 `FOR NO KEY UPDATE`가 `FOR UPDATE`와 유사하지만 더 약하며, `SELECT FOR KEY SHARE`를 막지 않는다고 설명한다. 또한 key 변경을 동반하지 않는 일반 `UPDATE`는 이 수준의 잠금을 획득한다고 설명한다. 프로젝트에서는 row를 나중에 갱신할 예정이지만 key 보존이 전제인 경우, SQL 레벨에서는 `FOR UPDATE`보다 `FOR NO KEY UPDATE`를 먼저 검토한다.
|
||||
|
||||
### 5.3 FOR SHARE
|
||||
|
||||
PostgreSQL은 `FOR SHARE`가 shared row lock을 획득하고, 다른 트랜잭션의 `UPDATE`, `DELETE`, `SELECT FOR UPDATE`, `SELECT FOR NO KEY UPDATE`를 막지만, 다른 `FOR SHARE`와 `FOR KEY SHARE`는 허용한다고 설명한다. 프로젝트에서는 읽은 값을 트랜잭션 끝까지 변경/삭제되지 않게 보호하면서, 다른 shared reader는 허용하고 싶은 경우에 제한적으로 사용한다.
|
||||
|
||||
### 5.4 FOR KEY SHARE
|
||||
|
||||
PostgreSQL은 `FOR KEY SHARE`가 더 약한 shared row lock이며, `DELETE`나 key 값을 바꾸는 `UPDATE`는 막지만, 일반 non-key `UPDATE`와 다른 `FOR KEY SHARE`/`FOR SHARE`는 허용한다고 설명한다. 프로젝트에서는 부모 row가 삭제되거나 참조 key가 바뀌지 않도록 보호하는 수준이 필요할 때만 검토한다.
|
||||
|
||||
### 5.5 항상 가장 약한 충분 조건을 선택한다
|
||||
|
||||
PostgreSQL은 row-level lock mode마다 충돌 관계가 다르다. 따라서 프로젝트 기본 원칙은 “익숙하니 무조건 `FOR UPDATE`”가 아니라, 필요한 보호 수준을 충족하는 가장 약한 row lock을 고르는 것이다. 락 강도가 강할수록 대기와 deadlock 가능성도 커진다.
|
||||
|
||||
## 6. NOWAIT, SKIP LOCKED, timeout 기준
|
||||
|
||||
### 6.1 기본 wait 전략을 무심코 두지 않는다
|
||||
|
||||
PostgreSQL은 conflicting lock이 있으면 row/table lock 요청이 해제될 때까지 기다릴 수 있고, deadlock이 아니면 오래 대기할 수 있다고 설명한다. 또한 `lock_timeout`은 lock 획득을 기다리는 동안만 적용되는 별도 timeout이며, 전역 `postgresql.conf`에서 세션 전체에 거는 것은 권장하지 않는다고 설명한다. 프로젝트에서는 “무한 대기”를 기본값으로 방치하지 않고, API 성격에 따라 `NOWAIT` 또는 제한된 timeout을 검토한다.
|
||||
|
||||
### 6.2 NOWAIT는 fail-fast가 필요할 때 사용한다
|
||||
|
||||
PostgreSQL은 `NOWAIT`가 락을 즉시 잡지 못하면 기다리지 않고 오류를 반환한다고 설명한다. 따라서 사용자 요청 처리처럼 지금 선점할 수 없으면 즉시 충돌 응답을 주는 것이 맞는 작업에는 `NOWAIT`가 적합하다. 프로젝트에서는 “동시에 하나만 처리해야 하는데 기다리기보다 실패가 낫다”는 경우에만 사용한다.
|
||||
|
||||
### 6.3 SKIP LOCKED는 queue-like workload에만 제한한다
|
||||
|
||||
PostgreSQL은 `SKIP LOCKED`가 잠긴 row를 건너뛰며, 이 방식이 inconsistent view를 만들기 때문에 general-purpose work에는 적합하지 않지만 queue-like table을 여러 consumer가 처리할 때는 유용할 수 있다고 설명한다. 프로젝트에서도 `SKIP LOCKED`는 작업 큐 선점에만 허용하고, 일반 목록 조회나 관리자 화면에는 금지한다.
|
||||
|
||||
### 6.4 JPA jakarta.persistence.lock.timeout은 힌트일 뿐이다
|
||||
|
||||
Jakarta Persistence는 `jakarta.persistence.lock.timeout`을 pessimistic locking용 timeout 값(밀리초)으로 정의하지만, hint only라고 설명한다. 즉 provider가 관찰하려고 노력해야 하는 값이지, DB별로 완전히 같은 방식으로 강제된다고 가정하면 안 된다. 프로젝트에서는 JPA hint를 사용하더라도 DB/driver/provider 조합에서 실제 동작을 검증한다.
|
||||
|
||||
## 7. JPA / Spring Data JPA 기준
|
||||
|
||||
### 7.1 Spring Data JPA의 @Lock은 query method에 lock mode intent를 붙이는 수단이다
|
||||
|
||||
Spring Data JPA는 repository query method나 CRUD 재선언 메서드에 `@Lock`을 붙여 `LockModeType`을 적용할 수 있다고 설명한다. 따라서 JPA 계층에서 비관적 락을 사용할 때는 repository method에 의도적으로 선언하고, 일반 조회 메서드에 무심코 넓게 붙이지 않는다.
|
||||
|
||||
### 7.2 JPA의 pessimistic lock은 PESSIMISTIC_READ, PESSIMISTIC_WRITE, PESSIMISTIC_FORCE_INCREMENT를 기준으로 해석한다
|
||||
|
||||
Jakarta Persistence는 `PESSIMISTIC_READ`, `PESSIMISTIC_WRITE`, `PESSIMISTIC_FORCE_INCREMENT`가 즉시 장기 DB 락을 얻는 모드라고 설명한다. 또한 `PESSIMISTIC_READ`는 다른 트랜잭션의 read를 막지 않으면서 repeatable-read 성격의 보호를 제공하고, `PESSIMISTIC_WRITE`는 update 시도들 사이의 직렬화를 강제할 수 있다고 설명한다. 프로젝트에서는 JPA 레벨에서 기본은 `PESSIMISTIC_WRITE`와 `PESSIMISTIC_READ`만 제한적으로 사용하고, `PESSIMISTIC_FORCE_INCREMENT`는 버전 증가 의미가 분명한 경우에만 예외적으로 검토한다.
|
||||
|
||||
### 7.3 비관적 락 실패 예외는 두 종류로 나뉜다
|
||||
|
||||
Jakarta Persistence는 pessimistic locking 실패가 transaction-level rollback을 일으키면 `PessimisticLockException`을 던지고, statement-level rollback만 일으키면 `LockTimeoutException`을 던지며 현재 트랜잭션은 rollback 표시되지 않을 수 있다고 설명한다. 따라서 프로젝트에서는 두 예외를 같은 것으로 보지 않고, 특히 `LockTimeoutException`을 “statement 실패”로 다룰지 “유스케이스 실패”로 올릴지 application boundary에서 명확히 정한다.
|
||||
|
||||
### 7.4 PessimisticLockScope.EXTENDED는 기본값이 아니다
|
||||
|
||||
Jakarta Persistence는 pessimistic locking의 기본 scope가 `NORMAL`이고, `EXTENDED`를 쓰면 join table/collection table에 들어 있는 owned relationship과 element collection까지 lock 범위가 넓어진다고 설명한다. 하지만 이 경우에도 참조된 엔티티 상태 자체가 잠기는 것은 아니고, phantom은 여전히 가능하다고 설명한다. 프로젝트에서는 scope를 넓힌다고 “연관 엔티티 전체를 잠근다”고 오해하지 않으며, `EXTENDED`는 기본 금지다.
|
||||
|
||||
## 8. deadlock 기준
|
||||
|
||||
### 8.1 deadlock은 DB가 자동 감지하지만, 예방이 더 중요하다
|
||||
|
||||
PostgreSQL은 deadlock을 자동으로 감지하고, 관련 트랜잭션 중 하나를 abort해서 다른 쪽이 진행되게 만든다고 설명한다. 하지만 어느 쪽이 죽는지는 예측할 수 없고 의존해서는 안 된다. 따라서 프로젝트 기본 원칙은 항상 같은 순서로 잠그고, 처음부터 필요한 가장 강한 락을 잡는 것이다.
|
||||
|
||||
### 8.2 deadlock은 재시도 대상이지만, 부분 SQL만 재실행하지 않는다
|
||||
|
||||
PostgreSQL은 deadlock으로 abort된 트랜잭션은 on-the-fly retry로 처리할 수 있다고 설명한다. 프로젝트에서는 deadlock 재시도가 필요하면 유스케이스 전체를 새 트랜잭션에서 다시 수행하고, 중간 SQL 몇 줄만 재실행하는 방식은 금지한다.
|
||||
|
||||
## 9. 프로젝트 권장안
|
||||
|
||||
### 9.1 기본 선택 순서
|
||||
|
||||
프로젝트의 기본 선택 순서는 다음과 같다.
|
||||
|
||||
- 제약, `ON CONFLICT`, 조건부 UPDATE, `@Version`으로 해결 가능한지 먼저 본다
|
||||
- 그래도 “현재 존재하는 특정 row를 선점해야” 한다면 row-level pessimistic lock을 검토한다
|
||||
- queue consumer라면 `SKIP LOCKED`를 검토하되, 일반 업무 조회에는 쓰지 않는다
|
||||
- table lock은 migration/DDL/운영 작업 수준이 아니면 기본 금지다
|
||||
|
||||
이 순서는 PostgreSQL이 explicit locking을 MVCC의 보완 수단으로 설명하고, `SKIP LOCKED`도 queue-like table에 제한적으로 적합하다고 설명하는 점을 프로젝트 규칙으로 압축한 것이다.
|
||||
|
||||
### 9.2 일반 비즈니스 row 수정은 lock보다 원자적 update를 먼저 검토한다
|
||||
|
||||
단순 상태 전이, 카운터 차감, 중복 방지는 lock보다 단일 SQL 조건식이 더 작고 명확한 해결책인 경우가 많다. explicit lock은 “선택한 row를 이후 로직 동안 반드시 잡고 있어야 하는” 경우에만 올린다. 이는 PostgreSQL의 MVCC/explicit lock 구분과 `SELECT FOR UPDATE`의 대기 비용을 함께 고려한 best practice다.
|
||||
|
||||
### 9.3 join query에서는 OF <alias>를 기본 검토한다
|
||||
|
||||
join query에 locking clause를 붙일 때 table list를 생략하면 더 넓은 범위가 잠길 수 있다. 프로젝트에서는 lock 대상이 하나의 aggregate root alias로 명확하면 `FOR UPDATE OF o`처럼 범위를 좁히는 것을 기본으로 한다.
|
||||
|
||||
### 9.4 queue claim query는 ORDER BY와 함께 설계한다
|
||||
|
||||
PostgreSQL은 `LIMIT` query는 `ORDER BY`가 없으면 예측 가능한 subset을 보장하지 않는다고 설명하고, `SKIP LOCKED`는 lock contention 회피용 queue-like case에 적합하다고 설명한다. 프로젝트에서는 queue claim query에 결정적 `ORDER BY` + `LIMIT` + `SKIP LOCKED` 를 함께 검토하고, `OFFSET`은 쓰지 않는다.
|
||||
|
||||
## 10. 문서 경계
|
||||
|
||||
이 문서는 명시적 락의 선택 기준과 사용 규칙을 다룬다.
|
||||
|
||||
다음 내용은 별도 문서에서 확장한다.
|
||||
|
||||
- optimistic locking과 `@Version`
|
||||
- isolation level 선택
|
||||
- serialization failure / deadlock retry 정책
|
||||
- queue/outbox/idempotency 전체 설계
|
||||
- advisory lock / distributed lock
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 중복 생성, 단순 상태 전이, lost update 문제를 무조건 pessimistic lock으로 해결하는 것
|
||||
- `LOCK TABLE`을 일반 비즈니스 row 처리에 사용하는 것
|
||||
- `SKIP LOCKED`를 일반 목록 API나 관리자 검색 화면에 사용하는 것
|
||||
- `OFFSET`과 row lock을 함께 써서 queue claim 범위를 흐리게 만드는 것
|
||||
- join query에서 lock 대상 alias를 고려하지 않고 넓게 잠그는 것
|
||||
- 락을 잡은 뒤 외부 API 호출, 긴 계산, 사용자 입력 대기를 넣는 것
|
||||
- `PESSIMISTIC_WRITE`를 대량 목록/페이지 조회에 거는 것
|
||||
- `PessimisticLockException`과 `LockTimeoutException`을 같은 의미로 처리하는 것
|
||||
- `PessimisticLockScope.EXTENDED`를 연관 엔티티 전체 잠금으로 오해하는 것
|
||||
|
||||
이 금지 규칙은 PostgreSQL locking clause, explicit locking, timeout, deadlock 문서와 Jakarta Persistence의 pessimistic lock/timeout/scope 규정을 실무 운영 기준으로 요약한 것이다.
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 문제는 정말 explicit lock이 필요한가?
|
||||
- row lock이면 충분한데 table lock으로 올리지 않았는가?
|
||||
- `FOR UPDATE`보다 약한 lock mode로 충분하지 않은가?
|
||||
- join query라면 `OF <alias>`로 lock 대상을 좁혔는가?
|
||||
- 사용자 요청이라면 `NOWAIT` 또는 bounded wait가 더 맞지 않는가?
|
||||
- `SKIP LOCKED`를 queue-like workload에서만 쓰고 있는가?
|
||||
- queue claim query에 결정적 `ORDER BY`가 있는가?
|
||||
- `OFFSET`과 row lock을 함께 쓰고 있지 않은가?
|
||||
- JPA에서 lock failure 예외 종류를 구분하고 있는가?
|
||||
- 락을 잡은 트랜잭션이 짧게 끝나는가?
|
||||
- 여러 row/object를 잠글 때 항상 같은 순서를 지키는가?
|
||||
@@ -0,0 +1,215 @@
|
||||
# Migration 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 PostgreSQL 스키마 변경을 운영 중에도 안전하게 배포 가능한 방식으로 설계하고 실행하는 기준을 정의한다. 목표는 다음과 같다. 첫째, migration을 “DDL 한 번 실행”이 아니라 애플리케이션 배포와 함께 움직이는 변경 절차로 다룬다. 둘째, 긴 테이블 rewrite, 강한 잠금, 비호환 rename/drop, 실패 시 복구 곤란한 변경을 줄인다. 셋째, 인덱스/제약/컬럼 추가/타입 변경을 공식 문서가 보장하는 동작 위에서 단계적으로 적용한다. PostgreSQL은 `ALTER TABLE` 하위 명령마다 필요한 lock level이 다르고, 명시되지 않으면 `ACCESS EXCLUSIVE`가 기본이라고 설명한다. 또한 `CREATE INDEX CONCURRENTLY`, `NOT VALID`/`VALIDATE CONSTRAINT`, 빠른 `ADD COLUMN ... DEFAULT` 같은 운영 친화적 경로를 공식적으로 제공한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 운영 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 PostgreSQL의 `ALTER TABLE`, `CREATE INDEX`, Modifying Tables, Transaction Isolation, PostgreSQL 11 release notes를 기준으로 작성한다. 특히 운영 중 영향도를 줄이는 핵심 기능은 `CREATE INDEX CONCURRENTLY`, `ADD CONSTRAINT ... NOT VALID` + `VALIDATE CONSTRAINT`, PostgreSQL 11 이후의 “상수 기본값 컬럼 추가 시 테이블 rewrite 회피”다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 migration은 expand-contract를 기본으로 한다
|
||||
|
||||
프로젝트 기본 원칙은 한 번에 바꾸고 바로 치우는 방식보다, 호환 가능한 단계를 나눠 배포하는 방식이다. PostgreSQL 공식 문서가 직접 “expand-contract”라는 표현을 쓰지는 않지만, 운영 친화적 DDL 경로는 대부분 additive change → 데이터 보정 → 제약 검증 → cleanup 순서에 맞춰 제공된다. 예를 들어 컬럼 추가는 빠르게 할 수 있고, 기본값 변경은 기존 row를 바꾸지 않으며, 제약은 `NOT VALID`로 추가 후 나중에 검증할 수 있고, 인덱스는 `CONCURRENTLY`로 따로 만들 수 있다. 프로젝트에서는 이 공식 동작들을 조합해 additive first, destructive later를 기본값으로 둔다.
|
||||
|
||||
### 3.2 migration은 “DDL 문법 가능 여부”가 아니라 “운영 중 lock/scan/rewrite 영향”으로 평가한다
|
||||
|
||||
같은 `ALTER TABLE`이라도 어떤 하위 명령은 빠르고, 어떤 것은 긴 scan이나 rewrite를 유발한다. PostgreSQL은 `ALTER TABLE`의 하위 명령마다 lock requirement가 다르며, 명시되지 않으면 `ACCESS EXCLUSIVE`가 기본이라고 설명한다. 또한 타입 변경은 보통 테이블과 인덱스를 rewrite/rebuild하고, 큰 테이블에서는 시간과 디스크를 크게 사용할 수 있다고 설명한다. 프로젝트에서는 migration review 시 SQL 문법보다 잠금 수준, rewrite 여부, 전체 스캔 여부, 디스크 추가 사용량을 먼저 본다.
|
||||
|
||||
### 3.3 backward-compatible migration을 기본으로 한다
|
||||
|
||||
rename, drop, 의미 변경은 대개 배포 순서에 민감하다. 반면 컬럼 추가, 인덱스 추가, 새 제약의 단계적 검증은 기존 애플리케이션과 더 쉽게 공존할 수 있다. PostgreSQL 공식 문서도 컬럼 추가, 기본값 변경, 제약 검증, 인덱스 추가를 각각 독립된 단계로 지원한다. 프로젝트에서는 앱 선배포/DB 선배포 순서가 뒤바뀌어도 일정 기간 함께 버틸 수 있는 migration을 기본값으로 둔다. 이는 공식 기능 위에 얹는 운영 best practice다.
|
||||
|
||||
## 4. migration 단계 기준
|
||||
|
||||
### 4.1 기본 4단계: 추가 → 이중 호환 → 검증/전환 → 제거
|
||||
|
||||
프로젝트 권장 기본 흐름은 다음과 같다.
|
||||
|
||||
- 새 컬럼/인덱스/제약을 호환 가능한 형태로 추가한다.
|
||||
- 애플리케이션이 구구조와 신구조를 함께 읽거나 함께 쓰도록 전환한다.
|
||||
- backfill과 검증을 끝낸 뒤 제약을 강화한다.
|
||||
- 마지막 배포에서 구컬럼/구제약/구코드를 제거한다.
|
||||
|
||||
이 흐름은 PostgreSQL의 빠른 컬럼 추가, `NOT VALID` 제약, `VALIDATE CONSTRAINT`, `CREATE INDEX CONCURRENTLY`, 기존 인덱스를 제약으로 승격하는 기능과 잘 맞는다.
|
||||
|
||||
### 4.2 destructive change는 마지막 단계로 미룬다
|
||||
|
||||
`DROP COLUMN`, 의미 변경, 이름 변경, 타입 변경은 호환성 파손 위험이 높다. PostgreSQL은 `DROP COLUMN`이 빠르지만 즉시 디스크 공간을 회수하지 않고, 타입 변경은 보통 rewrite를 유발한다고 설명한다. 따라서 프로젝트에서는 destructive change를 초기에 넣지 않고, 모든 애플리케이션이 새 구조를 사용한다는 것이 검증된 뒤 마지막 단계로 미룬다.
|
||||
|
||||
## 5. 컬럼 추가/변경 기준
|
||||
|
||||
### 5.1 nullable 컬럼 추가는 기본적으로 안전한 additive change다
|
||||
|
||||
PostgreSQL은 `ALTER TABLE ... ADD COLUMN`이 기본적으로 새 컬럼을 추가하고, 기본값이 없으면 기존 row에서는 `NULL`처럼 보인다고 설명한다. 이 경우 rewrite가 필요 없다. 프로젝트에서 새 필드를 도입할 때 기본 경로는 nullable 컬럼 추가 → 앱 쓰기 시작 → backfill → 제약 강화다.
|
||||
|
||||
### 5.2 상수 기본값이 있는 컬럼 추가는 PostgreSQL 11+에서 빠르게 처리될 수 있다
|
||||
|
||||
PostgreSQL 11 release notes와 현재 `ALTER TABLE` 문서는, `ADD COLUMN`에 non-volatile constant default가 있으면 전체 테이블 rewrite를 피할 수 있다고 설명한다. 현재 문서 표현으로는 non-volatile default 값이 metadata에 저장되고, 기존 row는 테이블이 나중에 rewrite될 때 물리적으로 반영된다. 프로젝트에서는 PostgreSQL 11+ 기준이라면 상수 기본값 컬럼 추가를 안전한 1차 선택지로 볼 수 있다. 다만 버전 호환성과 도메인/identity/generated column 여부는 반드시 확인한다.
|
||||
|
||||
### 5.3 volatile default, identity, stored generated column 추가는 신중히 본다
|
||||
|
||||
PostgreSQL은 `clock_timestamp()` 같은 volatile default, stored generated column, identity column, 제약이 있는 domain type column 추가가 테이블과 인덱스 전체 rewrite를 유발한다고 설명한다. 따라서 프로젝트에서는 이런 변경을 단순 additive change로 간주하지 않는다. 운영 테이블에는 보통 컬럼을 먼저 nullable/no default로 추가하고, backfill 후 default나 별도 정책을 도입하는 방식을 우선 검토한다.
|
||||
|
||||
### 5.4 SET DEFAULT는 기존 row를 바꾸지 않는다
|
||||
|
||||
PostgreSQL은 `ALTER COLUMN ... SET DEFAULT`가 이후의 INSERT/UPDATE에만 영향을 주고, 기존 row 값은 바꾸지 않는다고 설명한다. 따라서 “기본값을 바꿨으니 과거 데이터도 다 맞춰졌다”라고 해석하면 안 된다. 프로젝트에서는 default 변경과 historical data correction을 분리해서 설계한다.
|
||||
|
||||
### 5.5 타입 변경은 기본적으로 rewrite 후보로 본다
|
||||
|
||||
PostgreSQL은 기존 컬럼 타입 변경이 보통 테이블과 인덱스를 rewrite/rebuild하고, 큰 테이블에서는 상당한 시간과 임시 디스크를 요구할 수 있다고 설명한다. 예외적으로 binary coercible change처럼 rewrite가 필요 없는 경우도 있지만, 기본 가정은 “위험한 변경”이다. 프로젝트에서는 타입 변경을 직접 `ALTER`보다 신규 컬럼 추가 → backfill → 애플리케이션 전환 → 구컬럼 제거 방식으로 우선 검토한다.
|
||||
|
||||
### 5.6 rename은 기술적으로 빠를 수 있어도 배포 호환성 관점에서는 보수적으로 다룬다
|
||||
|
||||
PostgreSQL은 컬럼명/테이블명 rename을 지원한다. 하지만 rename은 애플리케이션 SQL, ORM 매핑, ETL, 운영 스크립트와 동시에 맞물린다. 따라서 프로젝트에서는 rename을 단순 메타데이터 변경으로만 보지 않고, dual-write/dual-read가 불가능한 비호환 변경으로 간주한다. 가능하면 새 이름의 컬럼을 추가하고 단계적으로 이전하는 방식을 우선 검토한다. 공식 문서는 rename 자체를 지원하지만, 이 보수적 해석은 프로젝트 운영 best practice다.
|
||||
|
||||
## 6. backfill 기준
|
||||
|
||||
### 6.1 backfill은 schema change와 분리된 단계로 본다
|
||||
|
||||
PostgreSQL 문서도 volatile default 대량 반영이 길 수 있으니, 컬럼을 먼저 추가하고 이후 `UPDATE`로 채운 뒤 default/constraint를 추가하는 방식을 제안한다. 프로젝트에서도 backfill은 DDL 한 문장에 숨기지 않고, 명시적 데이터 이행 단계로 분리한다.
|
||||
|
||||
### 6.2 대량 backfill은 작은 배치로 나눈다
|
||||
|
||||
이 원칙은 운영 best practice다. PostgreSQL 공식 문서가 “작은 배치”를 강제하지는 않지만, rewrite/scan/lock 비용이 큰 변경을 경고하고 있고, 긴 트랜잭션은 vacuum, bloat, contention에 불리하다. 프로젝트에서는 대형 테이블 backfill을 작은 배치 + 명시적 진행률 + 재실행 가능 구조로 나눈다. 이 규칙은 공식 문서의 rewrite/scan 비용 설명 위에 얹는 운영 권장안이다.
|
||||
|
||||
### 6.3 backfill 완료 확인 없이 NOT NULL/강한 제약으로 바로 올리지 않는다
|
||||
|
||||
PostgreSQL은 `SET NOT NULL`이나 즉시 검증되는 제약 추가가 테이블 scan을 요구할 수 있다고 설명한다. 따라서 프로젝트에서는 backfill이 끝났다는 증거 없이 곧바로 강한 제약을 걸지 않는다. 먼저 null/invalid row가 0건임을 점검하고, 그 다음에 제약을 강화한다.
|
||||
|
||||
## 7. 제약 추가 기준
|
||||
|
||||
### 7.1 큰 테이블의 FK/CHECK/NOT NULL 추가는 NOT VALID + VALIDATE CONSTRAINT를 기본 검토한다
|
||||
|
||||
PostgreSQL은 `ADD CONSTRAINT ... NOT VALID`가 기존 row 전체 검사를 생략하고 즉시 commit될 수 있으며, 이후 `VALIDATE CONSTRAINT`로 기존 데이터 검증을 수행할 수 있다고 설명한다. 또한 validation은 `SHARE UPDATE EXCLUSIVE` lock으로 수행되어, 제약 추가 시점보다 concurrent update에 미치는 영향이 더 작다. 프로젝트에서는 큰 테이블의 FK/CHECK/NOT NULL 추가에 이 경로를 기본값으로 검토한다.
|
||||
|
||||
### 7.2 SET NOT NULL은 증명 가능한 CHECK와 함께 단계적으로 올릴 수 있다
|
||||
|
||||
PostgreSQL은 `SET NOT NULL`이 보통 전체 테이블을 스캔하지만, 이미 존재하는 valid `CHECK` constraint가 null 불가능함을 증명하면 그 스캔을 건너뛸 수 있다고 설명한다. 프로젝트에서는 운영 중 큰 테이블에 `NOT NULL`을 올릴 때,
|
||||
|
||||
- `CHECK (col IS NOT NULL) NOT VALID` 추가
|
||||
- backfill
|
||||
- `VALIDATE CONSTRAINT`
|
||||
- `ALTER COLUMN SET NOT NULL`
|
||||
- 보조 `CHECK` 제거
|
||||
|
||||
순서를 기본 검토한다.
|
||||
|
||||
### 7.3 UNIQUE/PK는 기존 인덱스를 활용해 승격할 수 있다
|
||||
|
||||
PostgreSQL은 기존 unique index를 이용해 `PRIMARY KEY`나 `UNIQUE` 제약을 빠르게 추가할 수 있다고 설명한다. 또한 운영 중 장시간 update block을 줄이려면 먼저 `CREATE UNIQUE INDEX CONCURRENTLY`로 인덱스를 만들고, 그 뒤 `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX`로 제약으로 바꾸는 방식을 권장 예시로 제시한다. 프로젝트에서는 큰 테이블의 unique/pk 추가에 이 경로를 기본 검토한다.
|
||||
|
||||
### 7.4 FK 추가는 잠금 범위도 같이 본다
|
||||
|
||||
PostgreSQL은 `ADD FOREIGN KEY`가 일반 제약 추가보다 약한 `SHARE ROW EXCLUSIVE` lock을 사용하고, 참조 대상 테이블에도 같은 수준의 lock을 잡는다고 설명한다. 프로젝트에서는 FK migration review 시 양쪽 테이블 영향을 함께 검토한다.
|
||||
|
||||
## 8. 인덱스 migration 기준
|
||||
|
||||
### 8.1 운영 중 새 인덱스는 CREATE INDEX CONCURRENTLY를 기본 검토한다
|
||||
|
||||
PostgreSQL은 `CREATE INDEX CONCURRENTLY`가 write를 막지 않고 인덱스를 만들 수 있어 production environment에 유용하다고 설명한다. 대신 두 번의 테이블 스캔, 여러 대기 구간, 더 큰 CPU/I/O 비용이 있고, 일반 index build보다 오래 걸린다. 프로젝트 기본값은 운영 테이블 인덱스 추가 = 먼저 `CONCURRENTLY` 검토다.
|
||||
|
||||
### 8.2 CREATE INDEX CONCURRENTLY는 트랜잭션 블록 안에서 실행할 수 없다
|
||||
|
||||
PostgreSQL은 regular `CREATE INDEX`는 transaction block 안에서 가능하지만, `CREATE INDEX CONCURRENTLY`는 불가능하다고 명시한다. 따라서 migration 도구나 배포 파이프라인은 non-transactional migration step을 지원해야 한다. 프로젝트에서는 concurrent index build를 일반 DDL 묶음과 같은 트랜잭션 안에 넣지 않는다.
|
||||
|
||||
### 8.3 concurrent build 실패 후 INVALID 인덱스를 방치하지 않는다
|
||||
|
||||
PostgreSQL은 concurrent index build가 deadlock이나 uniqueness violation 등으로 실패하면 `INVALID` 인덱스를 남길 수 있고, 이 인덱스는 query에는 쓰이지 않지만 update overhead는 계속 발생한다고 설명한다. 또한 권장 복구 방법은 해당 인덱스를 drop하고 다시 시도하거나 `REINDEX INDEX CONCURRENTLY`를 쓰는 것이라고 안내한다. 프로젝트에서는 failed concurrent migration 뒤 `INVALID` 인덱스 정리 확인을 필수로 둔다.
|
||||
|
||||
### 8.4 unique concurrent index는 “아직 valid가 아니어도” uniqueness를 일찍 강제할 수 있다
|
||||
|
||||
PostgreSQL은 unique index를 concurrently build할 때, 두 번째 scan이 시작되면 다른 트랜잭션에 대해 uniqueness가 이미 강제될 수 있고, 최종적으로 index build가 실패해도 invalid index가 uniqueness를 계속 강제할 수 있다고 설명한다. 프로젝트에서는 unique concurrent index migration을 완전히 무해한 준비 작업으로 오해하지 않는다. 실패 시 후속 영향까지 함께 점검한다.
|
||||
|
||||
## 9. destructive change 기준
|
||||
|
||||
### 9.1 DROP COLUMN은 빠르지만 즉시 디스크를 줄이지 않는다
|
||||
|
||||
PostgreSQL은 `DROP COLUMN`이 컬럼을 SQL에서 보이지 않게 할 뿐, 물리 저장공간은 기존 row update가 진행되며 점진적으로 회수된다고 설명한다. 따라서 프로젝트에서는 drop 후 즉시 디스크 이익을 기대하지 않는다. 또한 drop은 backward compatibility를 깨므로 cleanup phase 마지막에만 허용한다.
|
||||
|
||||
### 9.2 destructive migration은 “코드 미사용 확인” 이후에만 수행한다
|
||||
|
||||
이 항목은 운영 best practice다. PostgreSQL은 컬럼 drop이나 rename을 지원하지만, DB가 애플리케이션 사용 여부를 대신 판단해 주지는 않는다. 프로젝트에서는
|
||||
|
||||
- 애플리케이션이 더 이상 해당 컬럼/인덱스/제약을 사용하지 않음
|
||||
- dual-write/dual-read가 끝남
|
||||
- 모니터링 및 쿼리 검증 완료
|
||||
|
||||
이후에만 destructive migration을 허용한다. 공식 기능 위에 얹는 프로젝트 권장안이다.
|
||||
|
||||
## 10. 배포 및 실행 기준
|
||||
|
||||
### 10.1 한 migration 파일에 잠금 요구가 크게 다른 작업을 무심코 섞지 않는다
|
||||
|
||||
PostgreSQL은 여러 `ALTER TABLE` subcommand를 한 statement에 넣으면 가장 강한 lock requirement를 따른다고 설명한다. 따라서 프로젝트에서는
|
||||
|
||||
- 빠른 metadata 변경
|
||||
- 오래 걸리는 validate
|
||||
- concurrent index build
|
||||
- destructive cleanup
|
||||
|
||||
을 같은 파일/같은 트랜잭션으로 무심코 합치지 않는다.
|
||||
|
||||
### 10.2 non-transactional step과 transactional step을 구분한다
|
||||
|
||||
`CREATE INDEX CONCURRENTLY`는 transaction block 안에서 실행할 수 없고, 일반 `ALTER TABLE`은 보통 transaction 안에서 실행된다. 프로젝트에서는 migration 도구가 transactional migration과 non-transactional migration을 구분하게 설계한다. 이 구분이 없으면 운영 친화적 경로를 쓰기 어렵다.
|
||||
|
||||
### 10.3 migration은 재실행 가능성과 중단 복구를 고려한다
|
||||
|
||||
PostgreSQL은 concurrent index build 실패 시 invalid index가 남을 수 있고, 제약 검증은 단계적으로 수행할 수 있다고 설명한다. 프로젝트에서는 migration을 “한 번에 무조건 성공” 전제로 쓰지 않고, 중간 실패 후 상태 점검과 재실행 경로가 있는 구조를 기본으로 한다.
|
||||
|
||||
## 11. 프로젝트 권장안
|
||||
|
||||
### 11.1 기본 패턴: additive first, validated later, destructive last
|
||||
|
||||
프로젝트 기본 migration 패턴은 다음과 같다.
|
||||
|
||||
- 새 컬럼/인덱스/제약을 먼저 추가
|
||||
- 앱을 신구조와 호환되게 배포
|
||||
- backfill 수행
|
||||
- 제약 검증 및 강도 상승
|
||||
- 마지막에 rename/drop/cleanup
|
||||
|
||||
이 패턴은 PostgreSQL이 제공하는 빠른 `ADD COLUMN`, `SET DEFAULT`, `NOT VALID`/`VALIDATE`, `CREATE INDEX CONCURRENTLY`, `USING INDEX` 경로와 가장 잘 맞는다.
|
||||
|
||||
### 11.2 large table migration은 “즉시 완료”보다 “운영 영향 최소화”를 우선한다
|
||||
|
||||
정식 문서도 rewrite, validation scan, concurrent build의 시간과 비용 차이를 분명히 설명한다. 프로젝트에서는 migration 시간을 줄이는 것보다 write block 최소화, rollback 복구성, 배포 호환성을 더 우선한다.
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 큰 테이블에 volatile default / identity / stored generated column을 무심코 추가
|
||||
- 큰 테이블 타입 변경을 rewrite 영향 검토 없이 바로 수행
|
||||
- FK/CHECK/NOT NULL을 운영 테이블에 즉시 검증 방식으로만 추가
|
||||
- 운영 인덱스를 `CREATE INDEX`로 바로 만들어 write block을 유발
|
||||
- `CREATE INDEX CONCURRENTLY`를 트랜잭션 블록 안에 넣기
|
||||
- failed concurrent build 뒤 `INVALID` 인덱스를 방치
|
||||
- backfill 없이 바로 `SET NOT NULL`
|
||||
- 같은 migration에 lock 강도가 크게 다른 작업을 한꺼번에 섞기
|
||||
- 코드 호환성 검증 없이 rename/drop부터 수행
|
||||
- destructive change를 rollout 초기에 배치
|
||||
|
||||
이 금지 규칙은 PostgreSQL 공식 문서의 lock/rewrite/validation/concurrent index semantics를 운영 best practice로 압축한 것이다.
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 migration은 additive 단계와 destructive 단계를 분리했는가?
|
||||
- 컬럼 추가가 rewrite를 유발하는 형태인지 확인했는가?
|
||||
- backfill은 DDL과 분리되어 있는가?
|
||||
- 제약 추가는 `NOT VALID` + `VALIDATE` 경로를 검토했는가?
|
||||
- `NOT NULL` 강화 전에 데이터가 이미 null-free인지 증명했는가?
|
||||
- 운영 인덱스는 `CONCURRENTLY` 필요 여부를 검토했는가?
|
||||
- concurrent index build 실패 시 `INVALID` 인덱스 처리 계획이 있는가?
|
||||
- migration 도구가 transactional / non-transactional step을 구분할 수 있는가?
|
||||
- rename/drop이 모든 애플리케이션 배포 이후 cleanup 단계인지 확인했는가?
|
||||
- lock level, rewrite, disk usage, rollback/retry 경로를 리뷰했는가?
|
||||
@@ -0,0 +1,148 @@
|
||||
# N+1 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 JPA/Hibernate 환경에서 N+1 query 문제를 어떤 기준으로 정의하고, 어떻게 탐지하고, 어떤 우선순위로 해결할지 정의한다. 이 문서의 목표는 다음과 같다.
|
||||
|
||||
- N+1을 단순히 “쿼리가 많다”가 아니라 루트 조회 1번 이후 연관 로딩이 엔티티 수만큼 반복되는 SQL shape 문제로 다룬다.
|
||||
- EAGER 선언이나 임시 캐시 같은 우회책이 아니라, query/use case 단위의 fetch plan 설계로 해결한다.
|
||||
- 목록·상세·배치·페이징 상황에 맞는 해결 수단을 구분한다.
|
||||
|
||||
Hibernate는 fetch tuning이 전체 성능에 매우 큰 영향을 주며, SELECT 기반 secondary select 전략이 바로 일반적으로 말하는 N+1이라고 설명한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Jakarta Persistence / Hibernate / Spring Data JPA 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 Jakarta Persistence 3.2의 fetch semantics, Hibernate ORM User Guide의 fetching / join fetch / batch fetching / pagination over collection fetch / LazyInitializationException guidance, Spring Data JPA의 @EntityGraph 지원 문서를 근거로 한다.
|
||||
|
||||
## 3. 정의
|
||||
|
||||
### 3.1 N+1은 “루트 1번 + 연관 N번”의 SQL shape 문제다
|
||||
|
||||
Hibernate 공식 문서는 SELECT 기반 fetching이 연관 데이터를 별도 SQL로 가져오는 방식이며, 이것이 일반적으로 N+1이라고 불리는 전략이라고 설명한다. 또한 루트 엔티티 여러 건을 먼저 가져온 뒤, 각 엔티티의 연관 컬렉션이나 to-one 연관을 접근할 때마다 secondary select가 반복되면 N+1이 발생한다고 설명한다. 따라서 N+1은 단순한 “쿼리 개수 증가”가 아니라, 연관 로딩 시점이 루트 결과 개수에 선형으로 종속되는 구조로 정의하는 것이 맞다.
|
||||
|
||||
### 3.2 N+1은 엔티티 수가 늘수록 비용이 같이 커지는 구조다
|
||||
|
||||
Hibernate 예시에서도 부모 여러 건을 먼저 조회한 뒤 각 부모의 자식 컬렉션을 개별 secondary select로 초기화하면, 부모 수가 늘수록 추가 SQL도 같이 늘어난다고 설명한다. 반대로 @BatchSize나 SUBSELECT는 이 반복을 줄여 SQL 수를 완화한다. 즉 N+1의 본질은 “한 번의 루트 조회 뒤에 같은 형태의 연관 조회가 반복적으로 누적되는 것”이다.
|
||||
|
||||
## 4. 기본 원칙
|
||||
|
||||
### 4.1 N+1의 기본 해법은 EAGER가 아니라 fetch plan 재설계다
|
||||
|
||||
Jakarta Persistence는 EAGER를 provider가 반드시 즉시 가져와야 하는 요구로 정의하고, LAZY는 힌트라고 설명한다. 하지만 Hibernate는 EAGER fetching is almost always a bad choice라고 명시하고, EAGER association을 JPQL query에서 JOIN FETCH로 포함하지 않으면 secondary select가 association마다 발생하여 N+1로 이어질 수 있다고 설명한다. 따라서 프로젝트 기본 원칙은 “EAGER로 막는다”가 아니라 “기본은 LAZY, 필요한 조회에서만 명시적으로 fetch plan을 지정한다”이다.
|
||||
|
||||
### 4.2 N+1 대응은 매핑이 아니라 use case 단위로 결정한다
|
||||
|
||||
Hibernate는 static fetching은 매핑에 정의되지만, dynamic fetching은 use-case centric하다고 설명하며, HQL/JPQL, entity graph, fetch profile 등으로 runtime fetch plan을 정할 수 있다고 안내한다. 즉 같은 엔티티라도 상세 조회, 목록 조회, 관리자 화면, 배치 처리에서 필요한 연관이 다르면 같은 fetch 전략을 강제하면 안 된다.
|
||||
|
||||
### 4.3 실제 판단 기준은 “엔티티 그래프”가 아니라 “실행된 SQL 개수와 shape”다
|
||||
|
||||
Hibernate는 프레임워크가 SQL을 대신 생성할수록, 실제로 생성된 SQL이 의도한 것과 같은지 반드시 확인해야 한다고 설명한다. 또한 DataSource proxy 방식 등을 이용하면 테스트 시 실행된 statement 수를 검증해 N+1 문제를 자동 탐지할 수 있다고 안내한다. 프로젝트 기준도 동일하다. N+1 여부는 추상적인 매핑만 보고 판단하지 않고, SQL 로그 또는 statement count 검증으로 확인한다.
|
||||
|
||||
## 5. 탐지 기준
|
||||
|
||||
### 5.1 N+1 탐지는 SQL 로그 확인을 기본으로 한다
|
||||
|
||||
Hibernate는 SQL logging을 통해 생성된 statement를 확인해야 한다고 설명한다. 프로젝트에서는 목록/상세/배치 주요 조회 경로에 대해, 루트 query 뒤에 같은 패턴의 secondary select가 엔티티 수만큼 반복되는지 확인하는 것을 기본 탐지 방식으로 둔다.
|
||||
|
||||
### 5.2 주요 조회 경로는 통합 테스트에서 statement count를 검증한다
|
||||
|
||||
Hibernate는 DataSource proxy 기반 접근을 쓰면 테스트 시 statement 수를 단언하여 N+1을 자동 탐지할 수 있다고 설명한다. 특정 라이브러리 선택은 이 문서 범위 밖이지만, 프로젝트 권장안은 핵심 repository/service 통합 테스트에 statement count 검증을 넣는 것이다. 단순 기능 통과만으로는 N+1 regressions를 막기 어렵다.
|
||||
|
||||
### 5.3 페이징 목록은 특히 별도 검증한다
|
||||
|
||||
Hibernate는 컬렉션 fetch join과 pagination을 함께 쓰면 limit가 DB가 아니라 메모리에서 적용될 수 있고, 성능 특성이 매우 나빠질 수 있다고 설명한다. 따라서 목록 API는 “N+1이 없는가”만 보지 말고, “컬렉션 fetch join으로 더 큰 문제를 만들지 않았는가”까지 같이 검증해야 한다.
|
||||
|
||||
## 6. 해결 우선순위
|
||||
|
||||
### 6.1 1차 선택: read-only 조회는 DTO projection을 우선 검토한다
|
||||
|
||||
Hibernate는 @BatchSize가 N+1보다 낫지만, 대부분의 경우 DTO projection이나 JOIN FETCH가 더 좋은 대안이라고 설명한다. 특히 목록 화면, 조회 전용 API, 백오피스 표 조회처럼 수정 목적이 아닌 use case에서는 엔티티 그래프를 억지로 채우기보다 필요한 컬럼만 조회하는 DTO projection이 가장 단순하고 신뢰도가 높다. 프로젝트 기본 우선순위에서도 read-only query는 DTO projection을 먼저 검토한다.
|
||||
|
||||
### 6.2 2차 선택: to-one 연관은 join fetch를 우선 검토한다
|
||||
|
||||
Hibernate는 acceptable performance를 위해 join fetch를 자주 사용해야 하며, LazyInitializationException을 피하는 가장 좋은 방법은 필요한 연관을 persistence context가 닫히기 전에 미리 fetch하는 것이라고 설명한다. 특히 JOIN FETCH는 @ManyToOne, @OneToOne, 그리고 많아야 하나의 컬렉션에 적합하다고 안내한다. 따라서 상세 조회나 소수 건 조회에서 필요한 to-one 연관은 join fetch가 기본 해법이다.
|
||||
|
||||
### 6.3 3차 선택: query 단위 fetch plan에는 Entity Graph를 사용한다
|
||||
|
||||
Jakarta Persistence는 fetchgraph와 loadgraph를 표준으로 정의하고, entity graph를 query나 find()의 fetch plan template로 사용할 수 있다고 설명한다. Spring Data JPA도 repository method에서 @EntityGraph로 named graph 또는 attributePaths() 기반 동적 graph를 지원한다. 프로젝트에서는 query method별로 필요한 연관이 비교적 명확하지만 JPQL fetch join을 직접 쓰고 싶지 않은 경우, Entity Graph를 공식 수단으로 사용한다.
|
||||
|
||||
### 6.4 4차 선택: batch fetching은 보조 완화 수단으로만 사용한다
|
||||
|
||||
Hibernate는 @BatchSize가 여러 uninitialized proxy/collection을 한 번에 가져와 SQL round trip 수를 줄인다고 설명한다. 또한 @BatchSize가 N+1보다 낫지만, 대부분은 DTO projection이나 JOIN FETCH가 더 좋은 대안이라고 명시한다. 따라서 프로젝트 규칙은, 구조적으로 secondary select를 유지해야 하는 경우에만 @BatchSize를 보조 수단으로 사용하고, 이것을 1차 해법으로 삼지 않는 것이다.
|
||||
|
||||
### 6.5 5차 선택: 같은 persistence context 안의 여러 컬렉션 초기화에는 SUBSELECT를 제한적으로 검토한다
|
||||
|
||||
Hibernate는 FetchMode.SUBSELECT가 한 번의 secondary select로 이전에 조회된 여러 owner의 같은 컬렉션 역할(role)을 함께 초기화하여 N+1을 피할 수 있다고 설명한다. 다만 이는 컬렉션에만 적용되는 Hibernate 전용 방식이며, query 구조를 단순화하는 1차 선택지는 아니다. 프로젝트에서는 동일한 root set를 먼저 가져온 뒤, 같은 컬렉션을 묶어서 지연 초기화해야 하는 특수 상황에서만 제한적으로 검토한다.
|
||||
|
||||
## 7. 페이징과 N+1 기준
|
||||
|
||||
### 7.1 페이징 query에 컬렉션 fetch join을 기본 금지한다
|
||||
|
||||
Hibernate는 fetch join이 paged query나 setFirstResult() / setMaxResults() 같은 제한 query에서는 보통 피해야 한다고 설명한다. 또한 컬렉션 또는 many-valued association에 fetch join을 적용한 상태에서 pagination을 쓰면 limit가 DB가 아니라 메모리에서 적용될 수 있고, 성능 특성이 매우 나쁘다고 설명한다. 따라서 프로젝트에서는 페이징 목록 + 컬렉션 fetch join을 기본 금지하고, 페이지 ID 조회 후 2차 query로 필요한 연관을 가져오는 분리 전략을 권장한다.
|
||||
|
||||
### 7.2 페이징 목록은 “ID 페이지 조회 + 후속 로딩” 구조를 기본 검토한다
|
||||
|
||||
Hibernate 공식 문서가 컬렉션 fetch join + pagination을 피하라고 명시하기 때문에, 실무 best practice는 먼저 root ID page를 안정적으로 조회하고, 그 결과 범위 안에서 필요한 to-one/collection/summary를 별도 query로 가져오는 구조다. 이 방식은 N+1을 피하면서도 page boundary를 안정적으로 유지한다. 이는 공식 제약 위에 얹는 프로젝트 권장안이다.
|
||||
|
||||
### 7.3 운영 환경에서는 hibernate.query.fail_on_pagination_over_collection_fetch를 검토한다
|
||||
|
||||
Hibernate는 hibernate.query.fail_on_pagination_over_collection_fetch 설정을 제공하며, 컬렉션 fetch join에 pagination이 걸려 limit가 메모리에서 적용되는 경우 예외를 던지게 할 수 있다고 설명한다. 기본값은 false라서 실수해도 조용히 넘어갈 수 있으므로, 프로젝트에서는 운영 안정성 관점에서 이 설정을 활성화할지 검토한다.
|
||||
|
||||
## 8. 트랜잭션 경계 기준
|
||||
|
||||
### 8.1 트랜잭션 밖 lazy 접근으로 N+1 또는 LazyInitializationException을 해결하려 하지 않는다
|
||||
|
||||
Hibernate는 LazyInitializationException의 가장 좋은 해결책은 persistence context가 닫히기 전에 필요한 연관을 미리 fetch하는 것이라고 설명한다. 따라서 controller, serializer, view layer가 lazy association을 우연히 초기화해 주기를 기대하는 방식은 금지한다. 프로젝트 기준은 application/service 경계 안에서 필요한 데이터를 모두 준비한 뒤 DTO/response model로 변환하는 것이다.
|
||||
|
||||
## 9. 프로젝트 권장안
|
||||
|
||||
### 9.1 기본 fetch는 LAZY, 해결은 query 단위로 한다
|
||||
|
||||
Hibernate가 EAGER를 피하고 LAZY를 기본으로 하라고 권장하므로, 프로젝트 기본값은 연관관계를 LAZY로 두고 N+1은 query별 fetch plan으로 해결한다. 즉 문제를 엔티티 선언으로 묶어 두지 않는다.
|
||||
|
||||
### 9.2 목록 API는 엔티티 그래프보다 DTO projection 우선
|
||||
|
||||
N+1이 가장 자주 터지는 곳은 목록 API다. 목록은 대개 수정 목적이 아니고, 필요한 필드 집합도 제한적이다. Hibernate도 DTO projection을 더 좋은 대안으로 제시하므로, 프로젝트에서는 목록 API와 백오피스 표 조회를 DTO projection 우선 대상으로 본다.
|
||||
|
||||
### 9.3 상세 API는 to-one join fetch 우선, 컬렉션은 최대 1개만 신중히
|
||||
|
||||
Hibernate는 JOIN FETCH가 to-one에 좋고, 많아야 하나의 컬렉션에만 쓰는 편이 낫다고 설명한다. 프로젝트도 상세 조회 기준으로 여러 to-one fetch join은 허용하되, 컬렉션 fetch join은 최대 1개까지만 신중히 허용한다.
|
||||
|
||||
### 9.4 여러 컬렉션이 동시에 필요하면 query를 분리한다
|
||||
|
||||
Hibernate는 여러 컬렉션 또는 to-many를 병렬 fetch join하면 Cartesian product가 발생하고 매우 나쁜 성능을 낼 수 있다고 명시한다. 따라서 프로젝트에서는 여러 컬렉션이 필요하면 “한 번에 다 fetch join”하지 않고, root query + 후속 batch/subselect/별도 query 조합으로 분리한다.
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- N+1 해법으로 연관관계에 EAGER를 선언하는 것
|
||||
- JPQL query에서 EAGER association을 빠뜨리고 secondary select에 의존하는 것
|
||||
- 목록/페이징 query에서 컬렉션 fetch join을 사용하는 것
|
||||
- 여러 컬렉션을 한 query에서 병렬 fetch join하는 것
|
||||
- @BatchSize를 구조적 query 문제 은폐 수단으로 쓰는 것
|
||||
- controller/serializer 단계에서 lazy association이 알아서 초기화되기를 기대하는 것
|
||||
- SQL 로그나 statement count 검증 없이 “N+1이 없을 것”이라고 추정하는 것
|
||||
|
||||
이 금지 규칙은 Hibernate 공식 문서가 직접 경고하는 EAGER, parallel collection fetch join, paged collection fetch join, LazyInitializationException 대응 원칙을 바탕으로 한 best practice다.
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 query는 루트 1번 + 연관 N번 구조가 아닌가?
|
||||
- 해결 방법으로 EAGER 선언 대신 query/use case 단위 fetch plan을 선택했는가?
|
||||
- read-only 목록이라면 DTO projection을 먼저 검토했는가?
|
||||
- to-one 연관은 join fetch 또는 entity graph로 해결했는가?
|
||||
- 컬렉션 fetch join은 정말 1개 이내인가?
|
||||
- pagination query와 컬렉션 fetch join 충돌을 검토했는가?
|
||||
- @BatchSize는 보조 수단으로만 사용하고 있는가?
|
||||
- 필요한 연관은 트랜잭션 안에서 모두 초기화되는가?
|
||||
- SQL 로그 또는 statement count 테스트로 실제 query 수를 확인했는가?
|
||||
- 운영 환경에서 fail_on_pagination_over_collection_fetch 검토 여부를 기록했는가?
|
||||
|
||||
위 체크리스트는 Hibernate의 fetching, join fetch, batch fetching, pagination 경고, LazyInitializationException guidance를 실무 운영 기준으로 압축한 것이다.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Pagination Query 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 PostgreSQL에서 목록 조회를 페이지 단위로 가져오는 query를 어떤 기준으로 설계할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 페이지 경계가 결정적이고 재현 가능한 query를 기본값으로 둔다
|
||||
- `LIMIT`/`OFFSET`와 keyset/cursor 방식의 적용 조건을 구분한다
|
||||
- 페이지 query와 count query를 분리해서 생각하게 만든다
|
||||
- 정렬 조건과 인덱스 구성을 pagination query와 맞물려 설계하게 만든다
|
||||
|
||||
PostgreSQL은 `LIMIT`/`OFFSET`을 사용할 때 `ORDER BY`로 결과를 고유한 순서로 제약하는 것이 중요하다고 설명하고, 큰 `OFFSET`은 건너뛴 row도 서버 내부에서 계산해야 하므로 비효율적일 수 있다고 설명한다. 또한 `ORDER BY ... LIMIT n`은 정렬과 인덱스 설계를 함께 볼 때 특히 중요하다고 설명한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 PostgreSQL의 `LIMIT`/`OFFSET`, `ORDER BY`, multicolumn index, index ordering, row constructor comparison, planner statistics 문서를 기준으로 작성한다. PostgreSQL은 `LIMIT`/`OFFSET`의 비결정성, B-tree만의 ordered scan, 멀티 컬럼 B-tree의 leftmost 규칙, row constructor `<`/`>` 비교의 좌→우 비교 규칙을 공식적으로 설명한다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 pagination query는 “목록 일부”가 아니라 “순서가 정의된 연속 구간”을 가져와야 한다
|
||||
|
||||
페이지네이션은 단순히 `LIMIT n`을 붙여 일부 row를 가져오는 것이 아니라, 정렬 기준이 명확한 전체 순서 위에서 연속 구간을 잘라 오는 계약이어야 한다. PostgreSQL은 `ORDER BY`가 없으면 결과 행 순서를 보장하지 않고, `LIMIT`/`OFFSET` 값이 달라지면 planner가 다른 plan을 선택해 서로 다른 부분집합을 반환할 수 있다고 설명한다.
|
||||
|
||||
### 3.2 정렬 기준은 결정적이어야 한다
|
||||
|
||||
페이지 경계가 흔들리지 않으려면 `ORDER BY`가 tie-breaker까지 포함한 결정적(unique) 순서여야 한다. PostgreSQL은 `LIMIT`과 함께 쓸 때 `ORDER BY`가 결과를 unique order로 제약하는 것이 중요하다고 설명한다. 또한 B-tree 인덱스는 실제로 정렬된 출력 자체를 제공할 수 있고, 내부적으로 table TID를 동률 tie-breaker처럼 다루지만, 애플리케이션 query 계약은 이를 암묵적으로 의존하지 말고 명시적 정렬 컬럼으로 닫는 편이 안전하다.
|
||||
|
||||
### 3.3 pagination 방식은 접근 패턴에 맞게 고른다
|
||||
|
||||
관리자 화면처럼 “몇 페이지든 자유롭게 점프”가 중요하면 `LIMIT`/`OFFSET`이 단순할 수 있고, 무한 스크롤·타임라인·깊은 페이지 이동이 많으면 keyset/cursor 방식이 더 적합하다. 이는 PostgreSQL 문서가 직접 “keyset pagination”이라는 이름으로 규정하지는 않지만, 큰 `OFFSET`의 비효율성과 B-tree ordered scan, row constructor comparison 규칙을 함께 보면 자연스럽게 도출되는 운영 best practice다.
|
||||
|
||||
## 4. OFFSET 기반 페이지네이션 기준
|
||||
|
||||
### 4.1 LIMIT/OFFSET은 얕은 페이지와 임의 페이지 점프가 필요한 경우에 사용한다
|
||||
|
||||
PostgreSQL은 `LIMIT`/`OFFSET`이 결과 일부를 가져오는 기본 수단이라고 설명한다. 프로젝트에서는 백오피스 표, 관리 화면, 데이터 탐색처럼 “3페이지로 점프”, “27페이지로 이동” 같은 요구가 실제로 중요한 경우 `LIMIT`/`OFFSET`을 허용한다. 단, 이는 얕은 페이지를 전제로 할 때 가장 자연스럽다.
|
||||
|
||||
### 4.2 LIMIT/OFFSET에는 항상 결정적 ORDER BY를 함께 둔다
|
||||
|
||||
PostgreSQL은 `LIMIT` 사용 시 `ORDER BY`가 없으면 예측 불가능한 subset을 얻게 된다고 명시한다. 따라서 프로젝트에서는 `LIMIT`/`OFFSET` query에 `ORDER BY`를 필수로 보고, 가능하면 마지막 tie-breaker까지 포함한다. 예를 들어 `ORDER BY created_at DESC, id DESC`처럼 정렬한다.
|
||||
|
||||
### 4.3 큰 OFFSET은 기본적으로 비효율적이라고 본다
|
||||
|
||||
PostgreSQL은 `OFFSET`으로 건너뛴 row도 서버 내부에서는 계산되어야 하므로 큰 `OFFSET`이 비효율적일 수 있다고 설명한다. 따라서 프로젝트에서는 page number가 깊어질수록 `OFFSET` 기반 pagination 성능이 떨어질 수 있음을 기본 가정으로 둔다. “10,000번째 페이지” 같은 요구는 `LIMIT`/`OFFSET`의 기본 사용처가 아니다.
|
||||
|
||||
### 4.4 데이터가 계속 바뀌는 목록에서 OFFSET은 경계가 흔들릴 수 있다
|
||||
|
||||
PostgreSQL은 `LIMIT`/`OFFSET` 값이 달라지면 다른 subset이 선택될 수 있고, `ORDER BY`가 없으면 특히 비일관적이라고 설명한다. 여기에 일반적인 `READ COMMITTED` 읽기 특성까지 결합하면, 요청 사이에 insert/delete/update가 일어나는 목록에서는 page boundary가 움직여 중복/누락처럼 보이는 사용자 경험이 생길 수 있다. 이 문장은 PostgreSQL의 `LIMIT`/`OFFSET` semantics와 기본 읽기 모델을 결합한 실무 해석이다.
|
||||
|
||||
## 5. keyset / cursor 페이지네이션 기준
|
||||
|
||||
### 5.1 깊은 페이지, 무한 스크롤, 시간순 피드는 keyset/cursor를 기본 검토한다
|
||||
|
||||
PostgreSQL 공식 문서는 큰 `OFFSET`이 비효율적일 수 있다고 설명하고, B-tree 인덱스는 `ORDER BY ... LIMIT n`에서 정렬된 앞부분을 직접 빠르게 반환할 수 있다고 설명한다. 이 둘을 종합하면, “마지막으로 본 정렬 키 이후의 다음 n건”을 가져오는 keyset/cursor 방식이 깊은 페이지나 연속 스크롤에 더 잘 맞는다. 이는 공식 문서 위에 얹는 best practice다.
|
||||
|
||||
### 5.2 keyset pagination은 ORDER BY와 동일한 의미의 seek 조건을 사용한다
|
||||
|
||||
keyset/cursor pagination은 보통 “마지막으로 본 정렬 키보다 뒤(또는 앞)에 있는 row”를 가져온다. PostgreSQL은 row constructor comparison이 `<`, `<=`, `>`, `>=`를 지원하고, 왼쪽에서 오른쪽으로 비교를 진행한다고 설명한다. 따라서 `(created_at, id)`처럼 정렬한 경우, 같은 순서 의미를 `WHERE (created_at, id) < (:lastCreatedAt, :lastId)` 같은 형태로 표현할 수 있다. 이는 PostgreSQL row comparison 기능을 pagination에 적용한 프로젝트 권장안이다.
|
||||
|
||||
### 5.3 keyset 정렬에도 tie-breaker는 필수다
|
||||
|
||||
정렬 컬럼 하나만으로는 동률이 생길 수 있으므로, keyset/cursor도 마지막 tie-breaker까지 포함해야 한다. PostgreSQL의 row comparison은 좌→우 비교이므로, `(created_at, id)`처럼 동률 해소용 유니크 컬럼을 마지막에 두면 query 의미가 명확해진다. 프로젝트에서는 timestamp 단독 cursor를 기본 금지하고, 항상 유니크 tie-breaker를 붙인다.
|
||||
|
||||
### 5.4 keyset pagination은 “임의 페이지 번호 이동”보다 “다음/이전 탐색”에 적합하다
|
||||
|
||||
PostgreSQL 공식 문서는 cursor-style API pagination을 직접 설계해 주지는 않지만, 큰 `OFFSET` 비효율과 ordered index scan의 장점을 분명히 설명한다. 이 특성상 keyset/cursor는 “page 57로 점프”보다 “다음 20건”, “이전 20건” 같은 연속 탐색에 더 잘 맞는다. 프로젝트에서는 keyset/cursor를 page number UI에 억지로 맞추기보다, 연속 탐색형 계약에 사용한다.
|
||||
|
||||
## 6. 정렬 기준과 인덱스 기준
|
||||
|
||||
### 6.1 pagination query의 정렬 기준은 인덱스 설계와 함께 정한다
|
||||
|
||||
PostgreSQL은 B-tree만 ordered output을 만들 수 있고, `ORDER BY ... LIMIT n`에서는 정렬을 만족하는 인덱스가 있으면 앞의 n개를 직접 가져올 수 있다고 설명한다. 따라서 pagination query는 정렬 기준을 먼저 정하고, 그 정렬을 실제로 공급할 인덱스를 같이 설계해야 한다.
|
||||
|
||||
### 6.2 멀티 컬럼 인덱스는 필터와 정렬의 앞부분을 함께 고려한다
|
||||
|
||||
PostgreSQL은 multicolumn B-tree가 leftmost column 제약에서 가장 효율적이고, leading column의 equality와 그 다음 첫 non-equality column의 inequality가 스캔 범위를 가장 잘 줄인다고 설명한다. 따라서 pagination query가 `WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT 20`라면, 인덱스도 `(tenant_id, created_at DESC, id DESC)`처럼 고정 필터 + 정렬 키 순서를 함께 검토하는 것이 자연스럽다.
|
||||
|
||||
### 6.3 단일 컬럼 DESC 인덱스를 기계적으로 만들지는 않는다
|
||||
|
||||
PostgreSQL은 B-tree가 forward/backward scan을 모두 지원하므로, 단일 컬럼에서는 별도 DESC 인덱스가 보통 유용하지 않다고 설명한다. 다만 멀티 컬럼에서 혼합 정렬 방향이 있으면 별도 정렬 지정이 의미를 가질 수 있다. 프로젝트에서는 pagination query의 정렬 방향이 단순 1컬럼이면 먼저 일반 B-tree로 충분한지 검토한다.
|
||||
|
||||
### 6.4 active row pagination은 soft delete predicate와 인덱스를 맞춘다
|
||||
|
||||
soft delete를 쓰는 테이블에서 일반 목록은 `deleted_at IS NULL`이 기본 predicate여야 하고, PostgreSQL partial index는 query의 `WHERE`가 그 predicate를 함의할 때 가장 자연스럽게 사용된다. 따라서 active row pagination query는 soft delete predicate를 항상 포함하고, 필요하면 `WHERE deleted_at IS NULL` partial index와 맞춘다. 이 원칙은 PostgreSQL partial index semantics를 pagination에 적용한 프로젝트 규칙이다.
|
||||
|
||||
## 7. count 기준
|
||||
|
||||
### 7.1 page query와 total count query는 분리해서 본다
|
||||
|
||||
pagination에서는 “현재 페이지 20건 조회”와 “전체 몇 건인지 계산”이 서로 다른 비용 구조를 가진다. PostgreSQL은 `count(*)`가 입력 row 수를 세는 aggregate라고 설명하고, planner statistics인 `reltuples`는 VACUUM/ANALYZE 기반의 근사치라고 설명한다. 따라서 프로젝트에서는 page query와 total count를 하나의 당연한 세트로 보지 않고, 정말 필요한 경우에만 별도 count query를 수행한다. exact count가 필요 없는 화면이라면 next page 존재 여부만 계산하는 방식도 허용한다.
|
||||
|
||||
### 7.2 대규모 목록에서는 “정확한 총건수”를 항상 요구하지 않는다
|
||||
|
||||
PostgreSQL 공식 문서상 planner 통계는 근사치이며, `count(*)`는 row 수를 실제로 집계하는 aggregate다. 이 특성을 고려하면, 아주 큰 목록에서 모든 요청마다 정확한 total count를 함께 구하는 것은 기본값으로 보기 어렵다. 프로젝트에서는 화면 요구가 약하면 “더 보기 가능 여부” 또는 근사치 메타데이터를 우선 검토한다. 이 부분은 공식 문서의 aggregate/statistics semantics 위에 얹는 운영 best practice다.
|
||||
|
||||
## 8. 페이지 크기 기준
|
||||
|
||||
### 8.1 page size는 API 계약으로 제한한다
|
||||
|
||||
PostgreSQL이 직접 “API page size upper bound”를 규정하지는 않지만, `ORDER BY ... LIMIT`가 적은 수의 row를 빠르게 가져오는 데 특히 유리하다고 설명하는 반면, 더 큰 범위를 읽을수록 인덱스 이점은 약해질 수 있다고 설명한다. 프로젝트에서는 무제한 `LIMIT`을 허용하지 않고, endpoint별로 허용 가능한 최대 page size를 계약으로 둔다. 이 규칙은 PostgreSQL ordered scan 특성 위에 얹는 best practice다.
|
||||
|
||||
### 8.2 page size 변경은 cursor 계약에도 영향을 준다
|
||||
|
||||
keyset/cursor pagination에서 cursor는 정렬 키와 page boundary 의미를 담는다. 따라서 client가 임의로 page size를 크게 바꾸면 응답 shape와 캐시/UX 의미가 달라질 수 있다. PostgreSQL이 이를 직접 규정하지는 않지만, pagination query를 안정적인 계약으로 유지하려면 page size도 정렬/seek 조건과 함께 관리해야 한다. 이 부분은 공식 정렬/limit semantics 위에 얹는 프로젝트 권장안이다.
|
||||
|
||||
## 9. 문서 경계
|
||||
|
||||
이 문서는 페이지 단위 목록 query 설계를 다룬다.
|
||||
|
||||
다음 내용은 별도 문서에서 확장한다.
|
||||
|
||||
- 일반 query semantics와 `WHERE`/`HAVING`/`DISTINCT` 기준
|
||||
- JPA fetch join과 pagination 충돌
|
||||
- soft delete 기본 조회 계약
|
||||
- cursor token 인코딩과 API 응답 포맷
|
||||
- 검색 결과 캐싱 전략
|
||||
|
||||
현재 문서 체계에서도 query, jpa-fetch-strategy, n-plus-one, soft-delete는 이미 별도 주제로 분리되어 있다.
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- `LIMIT`/`OFFSET`을 `ORDER BY` 없이 사용하는 것
|
||||
- tie-breaker 없는 비결정적 정렬로 페이지를 자르는 것
|
||||
- 깊은 페이지 요구에 무조건 `OFFSET`만 사용하는 것
|
||||
- keyset/cursor에서 정렬 기준과 seek 조건이 다른 것
|
||||
- timestamp 단독 cursor처럼 동률 처리가 불명확한 설계
|
||||
- page query와 count query 비용을 같은 것으로 보는 것
|
||||
- 큰 목록에서 매 요청마다 exact total count를 기본 강제하는 것
|
||||
- soft delete predicate가 필요한 테이블에서 active-row 조건 없이 페이지를 자르는 것
|
||||
- pagination query 인덱스를 정렬 기준과 무관하게 만드는 것
|
||||
|
||||
이 금지 규칙은 PostgreSQL의 `LIMIT`/`OFFSET`, `ORDER BY`, B-tree ordered scan, multicolumn index, row comparison semantics를 실무 규칙으로 압축한 것이다.
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 pagination query의 전체 순서가 결정적으로 정의되어 있는가?
|
||||
- `LIMIT`/`OFFSET`이라면 tie-breaker까지 포함한 `ORDER BY`가 있는가?
|
||||
- 깊은 페이지/무한 스크롤이라면 keyset/cursor를 검토했는가?
|
||||
- keyset이라면 `ORDER BY`와 seek 조건이 같은 의미를 가지는가?
|
||||
- 정렬 기준과 인덱스 구성이 맞물려 있는가?
|
||||
- soft delete 테이블이라면 active-row predicate가 query와 index에 일관되게 반영되어 있는가?
|
||||
- page query와 total count query를 분리해서 설계했는가?
|
||||
- exact total count가 정말 필요한지 확인했는가?
|
||||
- 최대 page size가 API 계약으로 제한되어 있는가?
|
||||
@@ -0,0 +1,265 @@
|
||||
# PK / FK / UNIQUE / CHECK 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 행 식별자와 비즈니스 고유성을 구분한다
|
||||
- 참조 무결성을 애플리케이션 로직이 아니라 DB 제약으로 보장한다
|
||||
- 행 단위 불변식과 관계 무결성을 서로 다른 수단으로 표현한다
|
||||
- JPA/Hibernate 사용 시에도 해석이 흔들리지 않는 안전한 기본값을 만든다
|
||||
|
||||
PostgreSQL 공식 문서는 PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK를 서로 다른 의미의 제약으로 설명하며, 특히 cross-row / cross-table 규칙은 CHECK보다 UNIQUE나 FOREIGN KEY 같은 더 직접적인 수단으로 표현하라고 안내한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL / Jakarta Persistence 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
PostgreSQL 공식 문서는 PK/UNIQUE/FK/CHECK의 의미와 제약 범위를 명확히 정의하고 있고, Jakarta Persistence 공식 문서는 복합 PK가 별도의 primary key class를 요구한다고 설명한다. 이 둘을 함께 봐야 JPA 기반 프로젝트에서 신뢰도 높은 기준이 된다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 제약은 “검증 로직”이 아니라 “데이터 의미”를 표현해야 한다
|
||||
|
||||
PRIMARY KEY는 행의 대표 식별자, UNIQUE는 대체 식별자 또는 비즈니스 고유성, FOREIGN KEY는 참조 무결성, CHECK는 같은 행 안에서 평가 가능한 불변식을 표현한다. 제약의 의미를 섞으면 스키마 해석이 불안정해진다. PostgreSQL도 각 제약을 이런 역할로 구분한다.
|
||||
|
||||
### 3.2 애플리케이션 중복 검사만으로 끝내지 말고 DB 제약으로 닫아야 한다
|
||||
|
||||
중복 방지, 부모 존재 보장, 행 내부 불변식은 서비스 코드에서 한 번 확인하는 것으로 충분하지 않다. PostgreSQL 제약은 저장 시점에 위반을 막는 마지막 안전장치다. 기본 정책은 “애플리케이션 검증 + DB 제약” 이중 방어다.
|
||||
|
||||
### 3.3 제약 선택은 JPA 매핑 비용과도 충돌하지 않아야 한다
|
||||
|
||||
PostgreSQL은 복합 PK를 지원하지만, JPA에서는 복합 PK가 별도의 primary key class와 더 복잡한 매핑 규칙을 요구한다. 따라서 DB에서 가능하다는 이유만으로 복합 PK를 기본값으로 두는 것은 실무적으로 불리하다.
|
||||
|
||||
## 4. PRIMARY KEY 기준
|
||||
|
||||
### 4.1 모든 애플리케이션 테이블은 명시적 PRIMARY KEY를 둔다
|
||||
|
||||
PostgreSQL은 모든 테이블에 PK가 강제되지는 않지만, 공식 문서도 “일반적으로 그렇게 하는 것이 가장 좋다”고 설명한다. PK는 행의 대표 식별자이고, FK의 기본 참조 대상이며, 클라이언트와 ORM이 행을 안정적으로 식별하는 기준이 된다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 모든 애플리케이션 테이블은 명시적 PK를 가진다
|
||||
- 로그성/임시성 테이블도 특별한 이유가 없으면 PK를 둔다
|
||||
- “어차피 unique가 있으니 PK는 없어도 된다”를 허용하지 않는다
|
||||
|
||||
### 4.2 기본 PK 전략은 단일 컬럼 surrogate key
|
||||
|
||||
PostgreSQL에서 PK는 단일 컬럼도, 복합 컬럼도 가능하다. 다만 JPA는 복합 PK에 대해 @EmbeddedId 또는 @IdClass 기반의 primary key class를 요구한다. 실무에서는 대부분의 엔티티에 단일 컬럼 surrogate key를 두고, 비즈니스 식별자는 UNIQUE로 분리하는 쪽이 매핑과 운영에 더 안전하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 PK는 id 단일 컬럼
|
||||
- 비즈니스 식별자, 공개 식별자, 외부 식별자는 PK가 아니라 UNIQUE
|
||||
- 복합 PK는 조인 테이블 또는 “조합 자체가 정체성”인 경우에만 예외적으로 허용
|
||||
|
||||
### 4.3 PK는 UNIQUE + NOT NULL의 의미를 명확하게 드러내는 제약이다
|
||||
|
||||
PostgreSQL 공식 문서에 따르면 PK는 사실상 UNIQUE + NOT NULL과 동등한 저장 제약을 가지며, 생성 시 unique B-tree 인덱스도 자동으로 만든다. 하지만 PK는 단순 고유 제약이 아니라 “대표 식별자”라는 의미를 추가로 부여한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- “대표 식별자”가 아닌 컬럼에 PK를 두지 않는다
|
||||
- 고유하더라도 nullable 하거나 변경 가능성이 큰 컬럼은 PK로 두지 않는다
|
||||
- PK 이름은 명시적으로 선언한다
|
||||
|
||||
### 4.4 숫자 자동 생성 PK는 IDENTITY를 기본 검토하되, IDENTITY만으로 유일성이 보장된다고 생각하지 않는다
|
||||
|
||||
PostgreSQL의 identity column은 암묵 시퀀스에서 값을 생성하지만, 공식 문서상 identity 자체는 uniqueness를 보장하지 않는다. 유일성은 PRIMARY KEY 또는 UNIQUE가 별도로 보장해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 숫자 자동 생성 PK는 GENERATED ... AS IDENTITY를 기본 검토
|
||||
- identity 컬럼은 반드시 PK 또는 UNIQUE와 함께 사용
|
||||
- “자동 증가니까 중복이 없을 것”이라는 가정만으로 설계하지 않는다
|
||||
|
||||
## 5. UNIQUE 기준
|
||||
|
||||
### 5.1 비즈니스 고유성은 UNIQUE로 표현한다
|
||||
|
||||
PostgreSQL의 unique constraint는 단일 컬럼뿐 아니라 복합 컬럼 조합에도 적용할 수 있고, 생성 시 unique B-tree 인덱스를 자동으로 만든다. 이메일, 외부 subject, tenant 내부 natural key처럼 “중복되면 안 되는 값”은 서비스 로직이 아니라 UNIQUE로 닫아야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 비즈니스상 중복 금지 값은 DB UNIQUE로 표현
|
||||
- 조합 고유성은 복합 UNIQUE 사용
|
||||
- 단순 조회 인덱스와 고유성 제약을 혼동하지 않는다
|
||||
|
||||
### 5.2 nullable UNIQUE의 의미를 명시적으로 설계한다
|
||||
|
||||
PostgreSQL에서 UNIQUE는 기본적으로 NULL을 서로 같은 값으로 보지 않는다. 따라서 nullable unique 컬럼에는 여러 개의 NULL이 저장될 수 있다. 하나의 NULL만 허용하려면 NULLS NOT DISTINCT가 필요하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- nullable unique는 기본적으로 재검토한다
|
||||
- “값이 없을 수 있음”과 “값이 없을 때도 하나만 허용”을 구분한다
|
||||
- 필요하면 NOT NULL, NULLS NOT DISTINCT, 또는 별도 모델링으로 의도를 명확히 한다
|
||||
|
||||
### 5.3 조건부 고유성은 UNIQUE constraint가 아니라 partial unique index
|
||||
|
||||
PostgreSQL 공식 문서는 “일부 행에만 적용되는 uniqueness restriction”은 unique constraint로 쓸 수 없고 unique partial index로 표현해야 한다고 설명한다. soft delete, 활성 데이터만 유일, 특정 상태에서만 유일 같은 요구는 이 범주다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음 같은 요구는 partial unique index로 표현한다.
|
||||
|
||||
- deleted_at is null 조건에서만 유일
|
||||
- is_active = true 인 행만 유일
|
||||
- 특정 상태 집합에서만 유일
|
||||
|
||||
### 5.4 UNIQUE는 “대체 식별자”이지 PK 대용 기본값이 아니다
|
||||
|
||||
UNIQUE가 있다고 해서 PK가 불필요해지는 것은 아니다. PostgreSQL도 PK는 테이블의 대표 식별자이고, FK 기본 참조 대상이며, 한 테이블에 하나만 둘 수 있는 특별한 제약이라고 설명한다. 실무 best practice는 PK와 business unique를 분리하는 것이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- PK와 business unique는 역할을 분리한다
|
||||
- 이메일, username, external_id는 대부분 UNIQUE
|
||||
- 대표 식별자만 PK
|
||||
|
||||
## 6. FOREIGN KEY 기준
|
||||
|
||||
### 6.1 참조 무결성은 FOREIGN KEY로 표현한다
|
||||
|
||||
PostgreSQL의 FK는 자식 테이블 값이 부모 테이블의 어떤 행과 일치해야 함을 보장한다. 참조 대상은 PK, unique constraint, 또는 non-partial unique index여야 한다. 부모 존재 보장을 코드에만 맡기지 않고 FK로 닫는 것이 기본이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 연관 관계는 가능하면 FK를 건다
|
||||
- “애플리케이션이 알아서 맞출 것”이라는 이유로 FK를 생략하지 않는다
|
||||
- FK 없는 조인은 예외적 상황에서만 허용
|
||||
|
||||
### 6.2 필수 관계는 FK + NOT NULL, 선택 관계만 nullable FK
|
||||
|
||||
PostgreSQL 공식 문서상 FK는 참조 컬럼이 NULL이면 제약을 회피할 수 있다. 필수 관계를 보장하려면 FK만으로는 부족하고 NOT NULL이 함께 있어야 한다. 또한 PostgreSQL은 CHECK (col IS NOT NULL)보다 explicit NOT NULL이 더 효율적이라고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 필수 부모 관계: FK + NOT NULL
|
||||
- 선택 관계: nullable FK 허용
|
||||
- “필수 관계인데 nullable FK”를 기본 금지
|
||||
|
||||
### 6.3 ON DELETE 정책은 라이프사이클 관계를 기준으로 고른다
|
||||
|
||||
PostgreSQL 공식 문서는 NO ACTION이 기본값이며, RESTRICT는 더 엄격하고, CASCADE는 부모 삭제 시 자식도 함께 삭제한다고 설명한다. 또한 자식이 부모의 구성요소라면 CASCADE가 적절할 수 있지만, 두 테이블이 독립 객체라면 RESTRICT 또는 NO ACTION이 더 적절하다고 안내한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본값: NO ACTION 또는 RESTRICT
|
||||
- 부모 없이는 존재 의미가 없는 구성요소 테이블에만 CASCADE
|
||||
- 선택 관계를 끊는 의미가 분명할 때만 SET NULL
|
||||
- aggregate 경계를 넘는 무분별한 CASCADE 삭제는 금지
|
||||
|
||||
### 6.4 FK는 참조하는 쪽에 인덱스를 자동 생성하지 않는다
|
||||
|
||||
PostgreSQL은 참조 대상 쪽에는 PK/UNIQUE로 인해 인덱스가 확보되지만, 참조하는 쪽 컬럼에는 FK 선언만으로 인덱스를 자동 생성하지 않는다. 다만 부모 삭제나 참조 값 변경 시 referencing table scan이 필요할 수 있으므로, 공식 문서도 referencing columns 인덱스를 자주 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- FK 컬럼은 조인 경로 / 삭제 경로 / 갱신 경로를 보고 인덱스를 검토한다
|
||||
- “FK가 있으니 인덱스도 자동 생성된다”는 가정을 금지한다
|
||||
- 인덱스 상세 기준은 별도 index 문서에서 다룬다
|
||||
|
||||
### 6.5 복합 FK는 식별 규칙이 조합 단위일 때만 사용한다
|
||||
|
||||
PostgreSQL은 복합 FK를 지원하지만, 컬럼 수와 타입이 정확히 맞아야 하고, nullable 처리도 더 신중해야 한다. MATCH FULL까지 고려해야 하는 경우가 생기므로, 실무적으로는 정말 조합 단위 식별이 필요한 경우에만 쓰는 것이 안전하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 복합 FK는 기본값이 아니다
|
||||
- tenant scoped key, 복합 PK 참조처럼 의미가 분명할 때만 허용
|
||||
- 혼합 null 상태 문제가 우려되면 NOT NULL 또는 MATCH FULL 검토
|
||||
|
||||
## 7. CHECK 기준
|
||||
|
||||
### 7.1 CHECK는 같은 행 안에서 평가 가능한 불변식에만 사용한다
|
||||
|
||||
PostgreSQL의 check constraint는 Boolean 식이 true 또는 NULL이면 통과한다. 공식 문서는 CHECK가 기본적으로 새로 삽입되거나 수정되는 그 행만 대상으로 해야 하며, 다른 행이나 다른 테이블 데이터를 참조하는 규칙 표현에는 적합하지 않다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음 같은 규칙에만 CHECK를 사용한다.
|
||||
|
||||
- 수치 범위
|
||||
- 시작/종료 순서
|
||||
- 상호 배타 컬럼 조합
|
||||
- 상태와 값의 행 단위 일관성
|
||||
|
||||
### 7.2 null 금지는 CHECK가 아니라 NOT NULL
|
||||
|
||||
PostgreSQL 공식 문서는 CHECK가 식 결과가 NULL이어도 통과한다고 설명하고, CHECK (column IS NOT NULL)보다 명시적 NOT NULL이 더 효율적이라고 안내한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- null 금지는 항상 NOT NULL
|
||||
- CHECK (col IS NOT NULL)을 기본 금지
|
||||
- 필수 컬럼 대부분은 명시적으로 NOT NULL
|
||||
|
||||
### 7.3 cross-row / cross-table 규칙은 CHECK로 우회하지 않는다
|
||||
|
||||
PostgreSQL은 다른 행 또는 다른 테이블 데이터를 참조하는 CHECK를 지원 대상으로 보지 않으며, dump/restore 시 깨질 수 있다고 경고한다. 그런 경우 UNIQUE, FOREIGN KEY, EXCLUDE, 또는 필요 시 trigger를 사용하라고 공식 문서가 안내한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 중복 금지는 UNIQUE
|
||||
- 부모 존재 보장은 FK
|
||||
- 기간 겹침 같은 특수 제약은 EXCLUDE 검토
|
||||
- 단발성 교차 검사만 필요하면 trigger를 예외적으로 검토
|
||||
- cross-row / cross-table 규칙을 CHECK 서브쿼리로 해결하려 하지 않는다
|
||||
|
||||
### 7.4 CHECK는 immutable한 조건이어야 한다
|
||||
|
||||
PostgreSQL은 CHECK 조건이 같은 입력에 항상 같은 결과를 내는 immutable 조건이라고 가정한다. 사용자 정의 함수 동작을 나중에 바꾸면 기존 행이 제약을 위반하게 될 수 있고, 이후 dump/restore에서 실패할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- CHECK에는 단순 비교식, 범위식, enum 유사 값 검증처럼 안정적인 식만 사용
|
||||
- 동작 변경 가능성이 있는 사용자 정의 함수 의존을 지양
|
||||
- 함수 변경이 필요한 경우 제약 drop/re-add까지 고려한다
|
||||
|
||||
## 8. 제약 이름 기준
|
||||
|
||||
PostgreSQL은 제약에 명시적 이름을 부여할 수 있고, 이는 오류 메시지 해석과 변경 작업을 더 명확하게 만든다. 운영과 migration 추적성을 위해 자동 생성 이름에 의존하지 않는 편이 안전하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
```text
|
||||
pk_<table>
|
||||
uq_<table>__<columns>
|
||||
fk_<from_table>__<to_table>
|
||||
ck_<table>__<rule>
|
||||
```
|
||||
|
||||
형식으로 명시적으로 선언한다.
|
||||
|
||||
## 9. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- PK 없이 UNIQUE만 두고 대표 식별자를 생략
|
||||
- 필수 관계인데 nullable FK만 두기
|
||||
- 일부 행에만 필요한 uniqueness를 일반 UNIQUE로 억지 표현
|
||||
- nullable UNIQUE를 두고 “NULL도 하나만 들어갈 것”이라고 가정
|
||||
- 독립 객체 관계에 무분별하게 ON DELETE CASCADE
|
||||
- null 금지를 CHECK (col IS NOT NULL)로 표현
|
||||
- cross-row / cross-table 규칙을 CHECK로 해결
|
||||
- identity 컬럼만 두고 PK/UNIQUE 없이 유일성이 보장된다고 간주
|
||||
|
||||
위 금지 규칙은 PostgreSQL 공식 동작과 실무 운영 리스크를 함께 반영한 best practice다.
|
||||
|
||||
## 10. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 테이블의 대표 식별자는 명시적 PK인가?
|
||||
- 비즈니스 고유성은 PK가 아니라 UNIQUE로 분리했는가?
|
||||
- nullable UNIQUE의 NULL 의미를 명확히 설계했는가?
|
||||
- 조건부 uniqueness가 필요하면 partial unique index를 검토했는가?
|
||||
- 필수 부모 관계는 FK + NOT NULL인가?
|
||||
- ON DELETE 정책이 라이프사이클 관계와 맞는가?
|
||||
- FK 참조 컬럼 인덱스 필요 여부를 검토했는가?
|
||||
- CHECK는 같은 행 안에서만 평가되는가?
|
||||
- null 금지를 CHECK가 아니라 NOT NULL로 표현했는가?
|
||||
- 제약 이름을 명시적으로 선언했는가?
|
||||
@@ -0,0 +1,250 @@
|
||||
# Query 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 PostgreSQL에서 조회 쿼리를 어떤 기준으로 작성할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 쿼리가 의도한 결과 집합을 정확하게 반환하게 만든다
|
||||
- 같은 의미의 쿼리라도 더 안전하고 해석 가능한 형태를 기본값으로 둔다
|
||||
- planner가 최적화하기 쉬운 구조를 기본으로 선택한다
|
||||
- JPA/Hibernate를 쓰더라도 실제로 실행되는 SQL 기준으로 판단하는 습관을 만든다
|
||||
|
||||
PostgreSQL 공식 문서는 SELECT의 처리 순서를 FROM → WHERE → GROUP BY/HAVING → SELECT list → DISTINCT → ORDER BY → LIMIT/OFFSET 순서로 설명한다. 따라서 query 기준은 “문법이 되느냐”보다 “어떤 단계에서 어떤 의미를 만들고 있는가”를 기준으로 세워야 한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 PostgreSQL의 SELECT, table expressions, LIMIT/OFFSET, subquery expressions, aggregates, CTE, EXPLAIN 문서를 기반으로 작성한다. 특히 LIMIT의 비결정성, outer join에서 ON과 WHERE의 의미 차이, EXISTS/NOT IN의 null semantics, DISTINCT ON, UNION ALL, WITH materialization 규칙은 공식 문서로 직접 확인할 수 있다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 쿼리는 먼저 “정확한 결과 집합”을 정의해야 한다
|
||||
|
||||
성능 최적화는 중요하지만, 그보다 먼저 결과 집합의 의미가 흔들리지 않아야 한다. PostgreSQL은 ORDER BY가 없으면 결과 행 순서를 보장하지 않고, DISTINCT, GROUP BY, HAVING, LIMIT이 각각 다른 단계에서 의미를 바꾼다. 따라서 이 문서의 기본 원칙은 “빠른 쿼리”보다 “의도가 명확하고 재현 가능한 쿼리”를 먼저 만드는 것이다.
|
||||
|
||||
### 3.2 성능 판단은 추측이 아니라 실행 계획으로 검증한다
|
||||
|
||||
PostgreSQL planner는 쿼리 구조와 통계에 따라 sequential scan, index scan, bitmap index scan 등 서로 다른 접근 방식을 선택한다. 따라서 query 개선은 감으로 판단하지 않고 EXPLAIN, 가능하면 EXPLAIN ANALYZE로 확인해야 한다.
|
||||
|
||||
## 4. ORDER BY / LIMIT / OFFSET 기준
|
||||
|
||||
### 4.1 LIMIT / OFFSET / FETCH를 사용할 때는 항상 ORDER BY를 명시한다
|
||||
|
||||
PostgreSQL 공식 문서는 LIMIT을 사용할 때 결과 행을 **고유한 순서로 제약하는 ORDER BY**를 함께 두는 것이 중요하다고 설명한다. ORDER BY가 없으면 어떤 행이 “앞쪽 몇 개”인지 정의되지 않으며, LIMIT/OFFSET 값이 달라지면 planner가 다른 실행 계획을 택해 서로 다른 부분집합을 반환할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- LIMIT, OFFSET, FETCH FIRST, DISTINCT ON이 있으면 ORDER BY를 기본 필수로 본다
|
||||
- ORDER BY는 가능하면 결정적(unique) 순서가 되도록 마지막 tie-breaker까지 포함한다
|
||||
- “현재는 우연히 같은 순서로 보인다”를 신뢰하지 않는다
|
||||
|
||||
### 4.2 ORDER BY의 tie-breaker를 명시한다
|
||||
|
||||
PostgreSQL은 ORDER BY의 왼쪽 표현식이 같으면 다음 표현식으로 비교하고, 모두 같으면 구현 의존 순서로 반환한다고 설명한다. 따라서 created_at DESC만으로는 동률이 생길 수 있고, 목록/최신 1건/상위 N건 쿼리에서는 id DESC 같은 보조 정렬 기준까지 포함하는 편이 안전하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 운영 API나 배치 기준 쿼리의 정렬은 tie-breaker까지 포함한다
|
||||
- “최신 1건”, “최근 N건”, “top N” 같은 쿼리에서는 특히 tie-breaker를 생략하지 않는다
|
||||
|
||||
## 5. JOIN 기준
|
||||
|
||||
### 5.1 INNER JOIN은 명시적 JOIN 문법을 기본으로 사용한다
|
||||
|
||||
PostgreSQL은 inner join 조건을 WHERE에 쓰는 방식과 JOIN ... ON에 쓰는 방식이 동등할 수 있다고 설명한다. 하지만 실무에서는 명시적 JOIN ... ON이 관계를 더 분명하게 드러내고, join 조건과 후행 필터를 구분하기 쉽다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본은 FROM a JOIN b ON ...
|
||||
- join 조건은 ON
|
||||
- join 후 결과 필터는 WHERE
|
||||
- 오래된 comma join (FROM a, b WHERE ...)은 기본 금지
|
||||
|
||||
### 5.2 OUTER JOIN에서는 ON과 WHERE의 위치가 의미를 바꾼다
|
||||
|
||||
PostgreSQL 공식 문서는 outer join에서 ON 절은 “어떤 행이 매칭되는가”를 결정하고, WHERE 절은 join 결과가 만들어진 뒤에 적용된다고 설명한다. 따라서 LEFT JOIN의 오른쪽 테이블 조건을 WHERE에 두면 의도치 않게 inner join처럼 동작할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- outer join의 매칭 조건은 ON
|
||||
- unmatched row를 유지해야 하는데 오른쪽 테이블 조건을 WHERE로 내리지 않는다
|
||||
- outer join을 썼다면 ON과 WHERE가 각각 무엇을 의미하는지 설명할 수 있어야 한다
|
||||
|
||||
### 5.3 USING은 허용하지만 NATURAL JOIN은 금지한다
|
||||
|
||||
PostgreSQL은 USING이 지정한 컬럼만 결합하므로 컬럼 변화에 비교적 안전한 반면, NATURAL JOIN은 두 테이블에 같은 이름의 컬럼이 새로 생기면 join 의미가 바뀔 수 있어 훨씬 위험하다고 명시한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 동일한 이름의 키 컬럼을 조인할 때 USING (col)은 허용
|
||||
- NATURAL JOIN은 기본 금지
|
||||
- 스키마 변경에 따라 silently 의미가 바뀌는 join을 허용하지 않는다
|
||||
|
||||
## 6. EXISTS / IN / NOT IN 기준
|
||||
|
||||
### 6.1 존재 여부 확인은 EXISTS를 우선 사용한다
|
||||
|
||||
PostgreSQL은 EXISTS가 “한 행이라도 반환되는지”만 확인하며, 일반적으로 전체를 끝까지 실행하지 않고 존재 여부를 판단할 만큼만 수행한다고 설명한다. 또한 공식 예시도 EXISTS (SELECT 1 ...)가 inner join과 비슷하지만 중복 매칭이 있어도 바깥 행을 한 번만 반환한다는 점을 보여 준다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 존재 여부 확인은 COUNT(*) > 0보다 EXISTS를 우선
|
||||
- join으로 row multiplication을 만든 뒤 DISTINCT로 지우는 패턴보다 EXISTS를 우선 검토
|
||||
- EXISTS 서브쿼리의 output list는 관례적으로 SELECT 1
|
||||
|
||||
### 6.2 배제 조건은 NOT EXISTS를 기본 검토한다
|
||||
|
||||
PostgreSQL은 NOT IN (subquery)에서 오른쪽 결과에 null이 하나라도 섞이면 결과가 true가 아니라 null이 될 수 있다고 설명한다. 이 null semantics는 실무에서 자주 실수를 만든다. 따라서 subquery 쪽 null 가능성을 완전히 통제하지 못하면 anti-join은 NOT EXISTS가 더 안전한 기본값이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- anti-join 기본값은 NOT EXISTS
|
||||
- NOT IN은 서브쿼리 값이 null이 아님을 명확히 보장할 때만 제한적으로 사용
|
||||
- null semantics를 설명할 수 없으면 NOT IN을 사용하지 않는다
|
||||
|
||||
## 7. GROUP BY / HAVING / Aggregate 기준
|
||||
|
||||
### 7.1 행 필터는 WHERE, 그룹 필터는 HAVING
|
||||
|
||||
PostgreSQL은 WHERE가 grouping 전에 개별 행을 필터링하고, HAVING은 grouping 후 그룹 행을 필터링한다고 설명한다. 튜토리얼 문서도 aggregate가 필요 없는 조건은 WHERE에 두는 편이 더 효율적이라고 명시한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- aggregate와 무관한 조건은 WHERE
|
||||
- aggregate 결과를 기준으로 한 조건만 HAVING
|
||||
- row filter를 HAVING으로 올려서 grouping 비용을 불필요하게 만들지 않는다
|
||||
|
||||
### 7.2 조건부 집계는 FILTER를 우선 검토한다
|
||||
|
||||
PostgreSQL은 aggregate input을 FILTER (WHERE ...)로 개별 aggregate마다 따로 제한할 수 있다고 설명한다. 같은 grouped query 안에서 여러 조건부 카운트/합계를 계산해야 할 때 FILTER는 의미를 분명하게 만든다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 그룹에서 여러 조건부 aggregate가 필요하면 FILTER 우선 검토
|
||||
- SUM(CASE WHEN ... THEN 1 ELSE 0 END) 패턴은 표현력이 부족할 때만 사용
|
||||
- aggregate별 조건을 명확하게 드러내는 쪽을 선호한다
|
||||
|
||||
### 7.3 순서가 중요한 aggregate는 내부 ORDER BY를 명시한다
|
||||
|
||||
PostgreSQL은 array_agg, json_agg, jsonb_agg, string_agg, xmlagg 같은 aggregate는 입력 순서에 따라 결과가 달라질 수 있다고 설명한다. 따라서 결과 순서가 계약이라면 aggregate 안쪽 ORDER BY를 명시해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 순서가 중요한 array_agg / json_agg / string_agg는 내부 ORDER BY를 명시
|
||||
- “현재 우연히 원하는 순서로 보인다”를 신뢰하지 않는다
|
||||
|
||||
## 8. DISTINCT / DISTINCT ON / UNION 기준
|
||||
|
||||
### 8.1 DISTINCT는 의미가 필요할 때만 사용한다
|
||||
|
||||
PostgreSQL은 SELECT DISTINCT가 중복 행을 제거한다고 설명한다. 즉 DISTINCT는 단순 성능 옵션이 아니라 결과 의미를 바꾸는 연산이다. 따라서 join이 잘못되어 생긴 row multiplication을 가리기 위한 반사적 DISTINCT는 기본 금지다. 이는 공식 동작 위에 얹는 best practice다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- DISTINCT를 쓰면 “어떤 중복을 왜 제거하는가”를 설명할 수 있어야 한다
|
||||
- join multiplicity 문제를 DISTINCT로 덮지 않는다
|
||||
- 중복이 생기지 않도록 join 또는 EXISTS 구조를 먼저 바로잡는다
|
||||
|
||||
### 8.2 one-row-per-group이 필요하면 DISTINCT ON을 제한적으로 사용한다
|
||||
|
||||
PostgreSQL의 DISTINCT ON은 같은 key 그룹에서 첫 행 하나만 남기며, 어떤 행이 “첫 행”인지 예측 가능하게 하려면 ORDER BY가 필요하다. 또한 DISTINCT ON 식은 ORDER BY의 leftmost expressions와 일치해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- “최신 1건 per key” 같은 PostgreSQL 특화 패턴에는 DISTINCT ON 허용
|
||||
- 반드시 ORDER BY와 함께 사용
|
||||
- DISTINCT ON 없이도 window function이 더 명확하면 그쪽을 우선 검토
|
||||
|
||||
### 8.3 deduplication이 불필요하면 UNION ALL을 기본으로 한다
|
||||
|
||||
PostgreSQL은 UNION이 중복 제거를 수행하고, UNION ALL은 그 제거를 하지 않기 때문에 보통 훨씬 빠르다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 두 결과 집합을 단순 합치기만 하면 UNION ALL
|
||||
- 진짜 set semantics가 필요할 때만 UNION
|
||||
- “일단 UNION”을 기본값으로 두지 않는다
|
||||
|
||||
## 9. CTE / 서브쿼리 기준
|
||||
|
||||
### 9.1 CTE는 가독성을 위해 사용하되, 항상 최적화 이점을 준다고 가정하지 않는다
|
||||
|
||||
PostgreSQL은 non-recursive, side-effect-free CTE가 부모 쿼리에서 한 번만 참조되면 folding될 수 있지만, 여러 번 참조되면 기본적으로 materialized 될 수 있다고 설명한다. 즉 CTE는 “가독성 도구”이지 자동 성능 향상 도구가 아니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 복잡한 쿼리 단계 분해를 위해 CTE 사용 가능
|
||||
- 하지만 CTE를 썼다는 이유만으로 planner가 항상 최적으로 밀어 넣는다고 가정하지 않는다
|
||||
- 성능 민감 쿼리는 CTE 도입 전후를 EXPLAIN으로 확인한다
|
||||
|
||||
### 9.2 MATERIALIZED / NOT MATERIALIZED는 의도를 갖고 선택한다
|
||||
|
||||
PostgreSQL은 NOT MATERIALIZED가 parent restrictions를 아래로 밀어 넣어 이득을 줄 수 있지만, 반대로 비싼 계산을 여러 번 반복하게 만들 수도 있다고 설명한다. 반면 materialization은 중복 계산을 막을 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- NOT MATERIALIZED는 predicate pushdown 이득이 분명할 때만 사용
|
||||
- 비싼 표현식을 재사용하는 CTE는 materialization이 더 나을 수 있음을 고려
|
||||
- 힌트처럼 무심코 붙이지 않는다
|
||||
|
||||
## 10. COUNT 기준
|
||||
|
||||
### 10.1 COUNT(*)는 공짜가 아니다
|
||||
|
||||
PostgreSQL 공식 문서는 전체 테이블에 대한 count(*)가 테이블 크기에 비례하는 비용을 요구하며, 전체 테이블 또는 모든 행을 포함한 인덱스를 스캔해야 할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 존재 여부 확인에는 COUNT(*) > 0 대신 EXISTS
|
||||
- 목록 API나 배치에서 total count는 정말 필요한 경우에만 계산
|
||||
- count가 비싸다는 사실을 전제로 설계한다
|
||||
|
||||
## 11. 검증 기준
|
||||
|
||||
### 11.1 성능 이슈가 있는 query는 EXPLAIN (ANALYZE)로 검증한다
|
||||
|
||||
PostgreSQL은 실행 계획이 scan node와 join node의 트리로 표현되며, planner가 sequential scan, index scan, bitmap index scan 등을 선택한다고 설명한다. 따라서 query 기준 문서에서의 모든 성능 판단은 실행 계획 확인을 전제로 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 느린 query 개선은 EXPLAIN (ANALYZE, BUFFERS)를 기본 검토
|
||||
- 인덱스 추가와 query 재작성은 전후 계획을 비교
|
||||
- “이 쿼리가 빠를 것 같다” 수준의 추측으로 merge하지 않는다
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- LIMIT/OFFSET을 ORDER BY 없이 사용
|
||||
- outer join의 오른쪽 테이블 필터를 무심코 WHERE에 내려서 의미를 바꾸는 것
|
||||
- NATURAL JOIN 사용
|
||||
- 존재 여부 확인을 COUNT(*) > 0로 처리
|
||||
- null 가능성이 있는 subquery에 NOT IN 사용
|
||||
- row filter를 HAVING으로 올려서 grouping 후 필터링
|
||||
- join 중복을 DISTINCT로 숨기기
|
||||
- deduplication이 불필요한데 UNION 사용
|
||||
- CTE를 성능 힌트처럼 기계적으로 사용
|
||||
- 성능 논쟁을 EXPLAIN 없이 종료
|
||||
|
||||
이 금지 규칙은 PostgreSQL 공식 의미 위에 얹는 실무 best practice이며, 특히 운영 장애를 만들기 쉬운 query semantics 오류를 줄이기 위한 프로젝트 권장안이다.
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 쿼리는 결과 집합 의미를 명확히 설명할 수 있는가?
|
||||
- LIMIT/OFFSET이 있다면 결정적 ORDER BY가 있는가?
|
||||
- outer join에서 ON과 WHERE가 의도대로 배치되었는가?
|
||||
- 존재 여부 확인에 EXISTS를 검토했는가?
|
||||
- anti-join에서 NOT EXISTS가 더 안전한지 검토했는가?
|
||||
- row filter와 group filter를 WHERE/HAVING으로 올바르게 나눴는가?
|
||||
- 순서가 중요한 aggregate에 내부 ORDER BY를 명시했는가?
|
||||
- DISTINCT가 실제 의미 요구인지, join 문제를 가리는 것인지 구분했는가?
|
||||
- UNION ALL로 충분한데 UNION을 쓰고 있지 않은가?
|
||||
- CTE materialization 규칙을 이해하고 있는가?
|
||||
- 성능 판단을 EXPLAIN (ANALYZE)로 검증했는가?
|
||||
@@ -0,0 +1,226 @@
|
||||
# Schema Structure 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 PostgreSQL schema를 어떻게 나누고, 어떤 경우에 추가 schema를 만들며, 애플리케이션이 schema를 어떤 방식으로 참조할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- schema를 namespace와 운영 경계로 일관되게 사용한다
|
||||
- public 과 search_path 에 대한 암묵 의존을 줄인다
|
||||
- JPA/Hibernate 매핑과 migration 구조가 충돌하지 않게 한다
|
||||
- 멀티 schema 도입을 “필요해서” 하는지, “습관처럼” 하는지 구분한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL / Hibernate 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 제약 위에 일반적인 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 schema는 namespace다
|
||||
|
||||
PostgreSQL에서 schema는 객체를 담는 namespace이고, 같은 데이터베이스 안에서 이름 충돌을 분리하는 단위다. 객체를 스키마 없이 참조하면 search_path 를 따라 찾고, 현재 스키마는 search_path 의 첫 번째 스키마다. 스키마를 명시하지 않고 CREATE TABLE 같은 DDL을 실행하면 현재 스키마에 생성된다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- schema는 단순 폴더 비슷한 개념이 아니라 객체 해석과 생성 위치를 결정하는 DB 경계로 본다
|
||||
- schema 구조는 migration, 권한, 기본 스키마 설정과 함께 결정한다
|
||||
|
||||
### 3.2 이 프로젝트의 기본값은 “애플리케이션 전용 단일 schema”다
|
||||
|
||||
PostgreSQL은 기본적으로 public 스키마를 두고, unqualified name은 search_path 를 따라 해석한다. 하지만 공식 문서가 search_path 에 포함된 스키마를 신뢰 문제와 연결해서 설명하는 만큼, 일반 업무 서비스에서는 애플리케이션 테이블을 public 에 흩뿌리기보다 전용 schema 하나에 모으는 쪽이 더 안전하고 명확하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 구조는 서비스당 하나의 애플리케이션 전용 schema
|
||||
- 예: auth, project_auth, pas_auth
|
||||
- 애플리케이션 테이블을 기본 public 스키마에 두는 것을 기본값으로 두지 않는다
|
||||
|
||||
### 3.3 public 과 search_path 에 암묵적으로 기대지 않는다
|
||||
|
||||
PostgreSQL 공식 문서는 search_path 가 이름 해석과 생성 위치를 바꾸고, 그 path 안의 스키마는 신뢰 경계가 된다고 설명한다. 또한 public 스키마는 기본으로 존재하지만 특별한 의미가 있는 것은 아니고, 필요하면 CREATE 권한을 회수할 수도 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- migration에서는 생성 대상 schema를 명시한다
|
||||
- 운영 DB에서 애플리케이션 객체를 public 에 생성하는 것을 기본 금지한다
|
||||
- search_path 가 우연히 맞아서 동작하는 구조를 지양한다
|
||||
- “로컬에서는 되는데 운영에서는 안 되는” schema 해석 차이를 만들지 않는다
|
||||
|
||||
### 3.4 JPA/Hibernate의 기본 스키마는 한 곳에서 정한다
|
||||
|
||||
Hibernate는 엔티티에 schema를 지정하지 않으면 현재 DB 연결의 기본 스키마를 사용하고, 필요하면 @Table(schema = "...") 로 스키마를 지정할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 schema는 DB 연결/ORM 설정에서 한 번 정하는 것을 우선한다
|
||||
- 같은 애플리케이션에서 모든 엔티티에 @Table(schema = "...") 를 반복해서 박는 구조를 기본값으로 두지 않는다
|
||||
- 엔티티별 schema 지정은 멀티 schema가 정말 필요한 경우에만 허용한다
|
||||
|
||||
### 3.5 멀티 schema는 명확한 운영 이유가 있을 때만 도입한다
|
||||
|
||||
PostgreSQL은 여러 schema를 지원하고, Hibernate도 schema별 매핑을 지원한다. 하지만 공식 문서 어디에도 “레이어마다 schema를 나눠라” 같은 권장은 없고, schema는 결국 namespace/권한/해석 경계다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음처럼 이유가 분명할 때만 멀티 schema를 검토한다.
|
||||
|
||||
- 테넌트별 schema 분리
|
||||
- 운영상 강한 권한 분리
|
||||
- 외부 시스템이 만든 객체와 애플리케이션 객체 분리
|
||||
- 감사/audit 전용 schema 분리
|
||||
- 레거시 공존
|
||||
|
||||
반대로 다음 이유만으로는 기본 도입하지 않는다.
|
||||
|
||||
- 패키지 레이어별 분리
|
||||
- “깔끔해 보인다”는 이유
|
||||
- 도메인마다 무조건 schema를 쪼개려는 습관
|
||||
|
||||
## 4. 권장 구조
|
||||
|
||||
### 4.1 기본 권장 구조
|
||||
|
||||
프로젝트 기본 권장 구조:
|
||||
|
||||
- 애플리케이션 테이블: 전용 schema 하나
|
||||
- PostgreSQL 내장/system 객체: 기본 system schema
|
||||
- 필요 시 audit/history 전용 schema 별도 검토
|
||||
- 필요 시 extension이 요구하는 별도 schema 검토
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 업무 테이블은 한 schema에 모은다
|
||||
- schema를 늘릴 때는 “이 schema가 어떤 운영 책임을 분리하는가”를 설명할 수 있어야 한다
|
||||
|
||||
### 4.2 audit/history schema는 예외적으로 분리할 수 있다
|
||||
|
||||
Hibernate Envers는 audit table의 기본 schema를 따로 둘 수 있고, 별도 audit schema 구성이 가능하다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- audit/history 테이블이 많고 운영 목적이 분명하면 별도 schema 검토 가능
|
||||
- 다만 업무 테이블과 audit 테이블을 무조건 다른 schema로 분리하는 것을 기본값으로 두지 않는다
|
||||
- audit schema 분리는 조회 패턴, 권한, migration 운영성을 함께 보고 결정한다
|
||||
|
||||
### 4.3 멀티테넌시용 schema 분리는 별도 전략으로 다룬다
|
||||
|
||||
Hibernate 문서는 schema-per-tenant 방식과 단일 schema + discriminator 방식을 구분해 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 멀티테넌시를 한다면 schema 구조 문서에서 살짝 언급만 하지 말고 별도 전략 문서로 분리한다
|
||||
- tenant schema 구조와 일반 서비스 단일 schema 구조를 같은 규칙으로 섞지 않는다
|
||||
|
||||
## 5. 권한 규칙
|
||||
|
||||
### 5.1 애플리케이션 객체를 두는 schema는 명시적 권한 경계로 본다
|
||||
|
||||
PostgreSQL은 schema에 대해 USAGE, CREATE 권한을 구분하고, public 스키마의 CREATE 권한을 회수하는 패턴도 문서에 예시로 보여 준다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 애플리케이션 schema는 필요한 역할만 USAGE/CREATE 를 갖게 한다
|
||||
- 운영 애플리케이션 계정에 불필요한 광범위 schema 권한을 주지 않는다
|
||||
- public schema에 대한 CREATE 권한을 그대로 열어 둘지 기본 검토한다
|
||||
|
||||
### 5.2 migration 계정과 runtime 계정의 책임을 구분할 수 있다
|
||||
|
||||
이 항목은 주로 Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- migration 실행 계정은 schema 변경 권한을 가질 수 있다
|
||||
- runtime 애플리케이션 계정은 DDL 권한 없이 DML 중심 권한만 갖도록 분리할 수 있다
|
||||
- schema 구조를 정할 때 권한 운영 모델도 함께 설계한다
|
||||
|
||||
## 6. 이름 규칙
|
||||
|
||||
### 6.1 schema 이름은 짧고 명확하게 둔다
|
||||
|
||||
PostgreSQL은 schema 이름이 기존 schema와 달라야 하고, pg_ 로 시작하는 이름은 시스템 schema용으로 예약돼 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- schema 이름은 소문자 snake_case를 기본으로 한다
|
||||
- pg_ 접두사는 사용 금지
|
||||
- 너무 일반적인 이름(app, data)보다 서비스/기능 의미가 드러나는 이름을 쓴다
|
||||
|
||||
권장 예:
|
||||
|
||||
- auth
|
||||
- auth_audit
|
||||
- billing
|
||||
- billing_audit
|
||||
|
||||
비권장 예:
|
||||
|
||||
- pg_auth
|
||||
- schema1
|
||||
- appdata
|
||||
|
||||
## 7. JPA / Native SQL / Migration 정렬 규칙
|
||||
|
||||
### 7.1 ORM과 migration이 같은 schema를 바라봐야 한다
|
||||
|
||||
Hibernate는 현재 연결의 기본 schema 또는 @Table(schema=...) 를 기준으로 매핑하고, PostgreSQL은 search_path 또는 명시 schema 기준으로 객체를 찾는다. 두 기준이 어긋나면 ORM은 한 schema를 보고 migration은 다른 schema에 테이블을 만들 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- migration 대상 schema와 ORM 기본 schema를 일치시킨다
|
||||
- native SQL이 있다면 그 SQL도 같은 schema 규칙을 따른다
|
||||
- 로컬/테스트/운영에서 schema가 달라지지 않게 한다
|
||||
|
||||
### 7.2 native SQL은 schema 규칙을 더 엄격히 본다
|
||||
|
||||
이 항목은 Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- native SQL은 search_path 의 우연한 해석에 기대지 않게 한다
|
||||
- 운영에서 search_path가 달라져도 문제 없게 schema 명시 여부를 일관되게 정한다
|
||||
- JPA entity 매핑과 native SQL 참조 방식이 서로 다른 schema를 가리키지 않게 한다
|
||||
|
||||
## 8. migration과의 관계
|
||||
|
||||
### 8.1 schema 생성은 migration으로 관리한다
|
||||
|
||||
PostgreSQL은 CREATE SCHEMA 를 통해 schema를 만들고, schema 안 객체를 생성할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- schema 생성/변경은 migration 도구로 관리한다
|
||||
- 애플리케이션 부팅 시 우연히 schema가 생기길 기대하지 않는다
|
||||
- “운영 DB에 수동으로 하나 만들어 둔 상태”를 기본 전제로 두지 않는다
|
||||
|
||||
### 8.2 schema 존재 자체도 버전 관리 대상이다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 테이블만이 아니라 schema 생성/권한/기본 객체도 migration 이력에 남긴다
|
||||
- 새 환경에서 migration만으로 같은 schema 구조를 재현할 수 있어야 한다
|
||||
|
||||
## 9. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 애플리케이션 테이블을 무비판적으로 public 에 생성
|
||||
- search_path 우연 해석에 기대는 구조
|
||||
- 모든 엔티티에 같은 @Table(schema=...) 반복
|
||||
- 레이어별 분리만을 이유로 schema를 여러 개 도입
|
||||
- migration 대상 schema와 ORM 기본 schema 불일치
|
||||
- schema 이름에 pg_ 접두사 사용
|
||||
- runtime 계정에 불필요한 schema CREATE 권한 부여
|
||||
|
||||
## 10. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 서비스는 기본적으로 하나의 전용 schema를 사용하는가?
|
||||
- public 과 search_path 에 암묵적으로 기대지 않는가?
|
||||
- migration과 ORM이 같은 schema를 바라보는가?
|
||||
- 멀티 schema 도입 이유를 운영 관점에서 설명할 수 있는가?
|
||||
- schema 권한이 명시적으로 통제되는가?
|
||||
- native SQL도 같은 schema 규칙을 따르는가?
|
||||
@@ -0,0 +1,183 @@
|
||||
# Soft Delete 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 PostgreSQL에서 row를 물리 삭제하지 않고 논리적으로 삭제 상태로 전환하는 soft delete를 어떤 기준으로 설계할지 정의한다. 이 문서의 목표는 다음과 같다. 첫째, soft delete를 단순 boolean 플래그가 아니라 상태 전이와 조회 계약으로 다룬다. 둘째, active row 조회 기준, uniqueness, FK, 복구, purge를 분리해서 설계한다. 셋째, Hibernate/Spring Data JPA를 사용할 때 ORM 차원의 soft delete와 DB 차원의 정합성 규칙을 혼동하지 않게 만든다. Hibernate는 soft delete를 “row를 실제 삭제하지 않고, 더 이상 active하지 않음을 나타내는 컬럼을 갱신하는 것”으로 설명한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: PostgreSQL / Hibernate / Spring Data JPA 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 PostgreSQL의 partial index, unique constraint/index, foreign key 규칙과 Hibernate의 `@SoftDelete`, Spring Data JPA의 delete/bulk delete 동작 문서를 기준으로 작성한다. PostgreSQL은 일부 행에만 적용되는 uniqueness는 unique constraint가 아니라 unique partial index로 강제해야 한다고 설명하고, FK 대상은 non-partial unique index 또는 PK/UNIQUE 제약이어야 한다고 설명한다. Hibernate는 `@SoftDelete`가 truth-based와 TIMESTAMP 전략을 지원한다고 설명한다. Spring Data JPA는 bulk delete 계열이 persistence context를 동기화하지 않고, 일부 배치 delete는 JPA cascade/lifecycle event도 존중하지 않는다고 설명한다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 soft delete는 “DELETE의 다른 문법”이 아니라 “상태 전이”다
|
||||
|
||||
soft delete는 row를 제거하는 것이 아니라, row를 비활성/삭제 상태로 바꾸는 것이다. Hibernate도 soft delete를 실제 삭제 대신 “더 이상 active하지 않음을 표시하는 컬럼 갱신”으로 설명한다. 따라서 soft delete가 도입된 테이블에서는 삭제가 곧 `DELETE`가 아니라, 보통 `UPDATE ... SET deleted_at = ...` 또는 그에 상응하는 상태 전이로 해석되어야 한다.
|
||||
|
||||
### 3.2 soft delete는 조회 계약이 함께 정의되어야 한다
|
||||
|
||||
row를 남겨 두기만 하고 조회 기본값을 정하지 않으면 soft delete는 의미가 반쯤만 구현된 것이다. PostgreSQL의 partial index 문서는 partial predicate를 만족하는 일부 row만 인덱싱할 수 있다고 설명하고, planner가 query의 `WHERE` 조건이 그 predicate를 함의한다고 인식해야 해당 인덱스를 사용할 수 있다고 설명한다. 프로젝트에서는 active row 기본 조회 조건을 `deleted_at IS NULL` 같은 단순하고 일관된 predicate로 고정하는 것을 기본값으로 둔다.
|
||||
|
||||
### 3.3 soft delete는 DB 무결성 규칙을 없애지 않는다
|
||||
|
||||
soft delete된 row도 DB에는 계속 존재한다. 따라서 FK는 여전히 그 row를 “존재하는 row”로 본다. PostgreSQL은 FK가 참조 컬럼 값이 대상 테이블의 어떤 row와 일치해야 한다고 설명한다. 또 일반 unique constraint는 테이블 전체에 적용되고, 일부 row에만 적용되는 uniqueness는 partial unique index로 따로 표현해야 한다고 설명한다. 즉 soft delete는 FK/UNIQUE를 자동으로 soft-delete-aware하게 바꾸지 않는다.
|
||||
|
||||
## 4. soft delete 표현 방식 기준
|
||||
|
||||
### 4.1 프로젝트 기본 soft delete 컬럼은 deleted_at
|
||||
|
||||
Hibernate는 `@SoftDelete`가 truth-based 전략과 TIMESTAMP 전략을 모두 지원하고, TIMESTAMP 전략은 row가 삭제된 시점을 추적한다고 설명한다. 프로젝트 기본 권장안은 boolean 플래그보다 `deleted_at timestamp with time zone null` 을 soft delete indicator로 두는 것이다. 이 방식은 “삭제되었는가?”뿐 아니라 “언제 삭제되었는가?”까지 한 컬럼으로 표현할 수 있고, active row 조건도 `deleted_at IS NULL`로 자연스럽다. 이것은 Hibernate의 TIMESTAMP 전략과도 잘 맞는 프로젝트 권장안이다.
|
||||
|
||||
### 4.2 deleted_at 타입은 timestamp with time zone
|
||||
|
||||
삭제 시점은 절대 시점이어야 하므로, 프로젝트의 기존 시간 타입 기준과 일관되게 `timestamp with time zone`을 사용한다. Hibernate soft delete의 TIMESTAMP 전략도 삭제 시점을 추적하는 전략으로 설명된다. 프로젝트에서는 soft delete를 단순 상태 플래그가 아니라 운영 시점 정보로 보기 때문에, 로컬 시각이나 문자열보다 절대 시점 타입을 기본으로 한다.
|
||||
|
||||
### 4.3 deleted_by는 선택 컬럼으로 둔다
|
||||
|
||||
누가 삭제했는지까지 운영상 중요한 도메인이라면 `deleted_by`를 추가할 수 있다. 이는 이전 audit-columns 문서의 `created_by` / `updated_by`와 같은 성격의 actor metadata다. 다만 soft delete의 최소 핵심은 `deleted_at`이며, `deleted_by`는 보안·감사 요구가 있을 때 추가하는 선택 항목으로 둔다. 이 구분은 Hibernate가 soft delete 자체를 indicator column 중심으로 설명하는 점 위에 얹는 프로젝트 권장안이다.
|
||||
|
||||
### 4.4 boolean-only soft delete는 기본값으로 두지 않는다
|
||||
|
||||
Hibernate는 truth-based soft delete도 지원하지만, TIMESTAMP 전략도 공식적으로 지원하며, TIMESTAMP 전략은 삭제 시점을 추적한다고 설명한다. 프로젝트에서는 운영 추적성 때문에 boolean-only (`deleted`, `is_deleted`)보다 `deleted_at`를 기본값으로 둔다. boolean 전략은 도입 가능하지만, 프로젝트 표준 기본값은 아니다.
|
||||
|
||||
## 5. 조회 기준
|
||||
|
||||
### 5.1 일반 조회의 기본 predicate는 deleted_at IS NULL
|
||||
|
||||
PostgreSQL partial index 문서는 partial index가 일부 row만 인덱싱하고, query planner가 query `WHERE` 조건이 index predicate를 함의한다고 인식해야 사용된다고 설명한다. 프로젝트에서는 soft delete 테이블의 일반 조회 기본 predicate를 `deleted_at IS NULL` 로 통일한다. 이렇게 해야 active row만 대상으로 하는 partial index와 partial unique index를 단순하게 맞출 수 있다.
|
||||
|
||||
### 5.2 관리자/복구/감사 조회만 삭제 row를 명시적으로 포함한다
|
||||
|
||||
soft delete의 기본 의미는 “일반 비즈니스 경로에서는 보이지 않아야 한다”는 것이다. 따라서 삭제 row를 포함하는 조회는 예외 경로로 분리하고, 일반 repository/query method가 이를 묵시적으로 포함하지 않게 한다. 이 원칙은 partial index predicate를 query와 일관되게 맞추라는 PostgreSQL planner 규칙 위에 얹는 운영 best practice다.
|
||||
|
||||
### 5.3 active row predicate는 단순하고 동일한 형태를 유지한다
|
||||
|
||||
PostgreSQL은 partial index가 사용되려면 query의 `WHERE` 조건이 index predicate를 수학적으로 함의한다고 planner가 알아야 하고, 일반적인 theorem prover는 없으며, matching은 planning time에 일어난다고 설명한다. 또한 parameterized clause는 partial index와 잘 맞지 않는다고 설명한다. 따라서 프로젝트에서는 active row filter를 `deleted_at IS NULL`처럼 항상 같은 단순 표현으로 유지한다.
|
||||
|
||||
## 6. uniqueness 기준
|
||||
|
||||
### 6.1 active row만 unique해야 하면 partial unique index를 사용한다
|
||||
|
||||
PostgreSQL은 전체 테이블이 아니라 일부 row에만 적용되는 uniqueness restriction은 unique constraint로 쓸 수 없고, unique partial index로 표현해야 한다고 설명한다. soft delete에서 가장 대표적인 요구는 “삭제되지 않은 row끼리만 이메일/코드/외부 id가 유일해야 한다”는 것이다. 프로젝트에서는 이런 요구를 `UNIQUE` 제약이 아니라 `WHERE deleted_at IS NULL` partial unique index로 표현한다.
|
||||
|
||||
### 6.2 soft delete 후 같은 natural key를 재사용할 수 있게 할지 명시적으로 정한다
|
||||
|
||||
partial unique index를 쓰면 active row 사이의 uniqueness만 강제되고, soft-deleted row는 uniqueness 대상에서 빠질 수 있다. 따라서 삭제 후 같은 이메일/코드를 다시 등록할 수 있게 된다. 이것이 맞는지 아닌지는 비즈니스 정책이다. PostgreSQL은 일부 row에만 uniqueness를 강제할 수 있다고 설명하므로, 프로젝트에서는 재사용 허용 여부를 명시적으로 결정하고, partial unique index를 그 정책에 맞게 사용한다.
|
||||
|
||||
### 6.3 restore는 uniqueness를 다시 만족해야 한다
|
||||
|
||||
soft-deleted row를 복구하면 그 row는 다시 active subset에 들어간다. active subset에 partial unique index가 걸려 있다면, 동일 natural key를 가진 다른 active row가 이미 존재할 때 restore는 실패해야 한다. 이는 PostgreSQL unique partial index semantics의 직접적인 결과다. 프로젝트에서는 restore를 단순 플래그 복원으로 보지 않고, active uniqueness를 다시 통과해야 하는 상태 전이로 본다.
|
||||
|
||||
## 7. FK와 연관관계 기준
|
||||
|
||||
### 7.1 soft delete는 FK를 자동으로 끊지 않는다
|
||||
|
||||
PostgreSQL은 FK가 참조 컬럼 값이 대상 row와 일치해야 한다고 설명한다. soft delete는 row를 지우지 않으므로, 참조 대상 row는 여전히 존재한다. 따라서 부모를 soft delete해도 자식 FK는 기본적으로 그대로 유효하다. 프로젝트에서는 soft delete가 “관계 제거”가 아니라 “row 비활성화”임을 전제로 설계한다.
|
||||
|
||||
### 7.2 자식 존재를 이유로 부모 soft delete를 막을지 여부를 별도 규칙으로 둔다
|
||||
|
||||
DB FK는 soft-deleted parent를 특별 취급하지 않는다. 따라서 “활성 자식이 있는 부모는 soft delete 금지” 같은 규칙이 필요하면, 그것은 FK 자체가 아니라 애플리케이션 규칙 또는 추가 제약 설계의 문제다. 프로젝트에서는 이런 규칙을 도메인 서비스/유스케이스 레벨에서 명시적으로 다룬다. 이는 PostgreSQL FK가 존재성만 보장한다는 공식 의미 위에 얹는 best practice다.
|
||||
|
||||
### 7.3 partial unique index 위의 natural key는 FK 참조 대상으로 기본 사용하지 않는다
|
||||
|
||||
PostgreSQL은 FK가 참조할 대상 컬럼이 non-deferrable unique/primary key 제약 또는 non-partial unique index여야 한다고 설명한다. 따라서 soft delete 때문에 active row만 unique하도록 partial unique index를 만든 natural key는 FK target으로 적합하지 않다. 프로젝트에서는 soft-delete-aware natural key가 있더라도 FK는 안정적인 surrogate PK를 참조하는 것을 기본값으로 둔다.
|
||||
|
||||
## 8. 삭제 동작 기준
|
||||
|
||||
### 8.1 soft delete는 기본적으로 UPDATE다
|
||||
|
||||
Hibernate soft delete도 실제 삭제 대신 indicator column을 갱신하는 방식이라고 설명한다. 따라서 프로젝트에서 soft delete는 `DELETE` SQL이 아니라, 보통 `UPDATE ... SET deleted_at = current_timestamp` 또는 ORM의 soft delete 기능이 만들어내는 update semantics로 해석한다.
|
||||
|
||||
### 8.2 일반 delete 경로와 physical purge 경로를 분리한다
|
||||
|
||||
soft delete가 있는 테이블에서도 영구 삭제가 아예 불가능한 것은 아니다. 다만 일반 비즈니스 삭제와 운영 purge는 다른 행위다. 프로젝트에서는
|
||||
|
||||
- 일반 비즈니스 삭제: soft delete
|
||||
- 운영 정리/보존기간 만료 purge: physical delete
|
||||
|
||||
를 분리한다. 이 구분은 Hibernate가 soft delete를 “실제 삭제 대신 indicator 갱신”으로 설명하는 점 위에 얹는 프로젝트 규칙이다.
|
||||
|
||||
### 8.3 soft-deletable 엔티티에서 bulk delete를 기본 금지한다
|
||||
|
||||
Spring Data JPA는 `deleteAllInBatch`, `deleteAllByIdInBatch` 같은 배치 delete가 단일 query를 생성하고, persistence context를 DB와 동기화하지 않으며, JPA cascade semantics와 lifecycle events도 존중하지 않는다고 설명한다. 또한 JPQL bulk delete와 Criteria bulk delete도 DB 직접 delete로 매핑되고 persistence context를 동기화하지 않는다고 설명한다. soft delete가 ORM lifecycle, entity mapping, soft-delete annotation/정책에 의존한다면 이런 bulk delete는 그 경로를 우회할 수 있으므로, 프로젝트에서는 soft-deletable 엔티티에 대한 bulk physical delete를 기본 금지하고, purge 전용 경로에서만 명시적으로 사용한다.
|
||||
|
||||
## 9. Hibernate / JPA 기준
|
||||
|
||||
### 9.1 Hibernate를 쓴다면 @SoftDelete를 공식 선택지로 본다
|
||||
|
||||
Hibernate는 `@SoftDelete`를 1급 기능으로 제공하고, entity와 collection table(`@ElementCollection`, `@ManyToMany`)에 적용할 수 있다고 설명한다. 또한 TIMESTAMP 전략과 truth-based 전략을 지원하고, TIMESTAMP 전략은 삭제 시각을 추적한다고 설명한다. 프로젝트에서는 Hibernate 6.4+/7.x 기능셋을 사용하는 경우, entity soft delete에 `@SoftDelete(strategy = TIMESTAMP, columnName = "deleted_at")`를 공식 후보로 본다.
|
||||
|
||||
### 9.2 @SoftDelete는 @OneToMany 컬렉션에 붙이지 않는다
|
||||
|
||||
Hibernate는 `@SoftDelete`를 collection table 기반인 `@ElementCollection`과 `@ManyToMany`에는 적용할 수 있지만, `@OneToMany` association에 붙이면 예외를 던진다고 설명한다. 따라서 프로젝트에서는 엔티티 자체를 soft delete하거나, join/collection table에만 제한적으로 적용한다. `@OneToMany`의 자식 엔티티 soft delete는 자식 엔티티 자체가 soft-deletable해야 한다.
|
||||
|
||||
### 9.3 provider-specific soft delete와 DB 표준은 구분한다
|
||||
|
||||
JPA 표준 자체는 soft delete를 표준 annotation으로 정의하지 않는다. Hibernate `@SoftDelete`는 유용한 공식 기능이지만 provider-specific이다. 프로젝트에서는 ORM 기능을 쓰더라도 DB 차원의 predicate, partial unique index, FK 설계는 별도로 명시한다. 즉 ORM 기능은 편의 수단이지 정합성 그 자체가 아니다. 이 원칙은 Hibernate soft delete 기능과 PostgreSQL partial index/FK 규칙을 함께 볼 때 자연스럽다.
|
||||
|
||||
## 10. 프로젝트 권장안
|
||||
|
||||
### 10.1 기본 soft delete 스키마
|
||||
|
||||
프로젝트 기본 권장안은 다음과 같다.
|
||||
|
||||
- `deleted_at timestamp with time zone null`
|
||||
- 필요 시 `deleted_by`
|
||||
- 일반 조회 기본 predicate는 `deleted_at IS NULL`
|
||||
- active-row uniqueness는 partial unique index
|
||||
- FK는 surrogate PK 기준 유지
|
||||
- physical purge는 별도 배치/운영 경로 분리
|
||||
|
||||
이 구성은 Hibernate의 TIMESTAMP soft delete 전략, PostgreSQL의 partial unique index, FK target 제약 규칙을 함께 고려한 프로젝트 기본값이다.
|
||||
|
||||
### 10.2 soft delete는 “조회 은닉 + 정합성 유지 + 나중 purge”까지 포함해서 설계한다
|
||||
|
||||
단순히 `deleted_at`만 추가해 두고 조회, uniqueness, restore, purge를 정하지 않으면 soft delete는 반쪽 설계다. PostgreSQL 공식 문서가 제공하는 것은 partial index, unique partial index, FK 규칙 같은 building block이고, 프로젝트는 이 위에 active-row 계약을 얹는다. 따라서 soft delete 도입 시 반드시 조회 기본값, active uniqueness, restore 실패 가능성, purge 경로를 함께 정의한다.
|
||||
|
||||
## 11. 문서 경계
|
||||
|
||||
이 문서는 논리 삭제 상태와 active-row 계약을 다룬다.
|
||||
|
||||
다음 내용은 별도 문서에서 확장한다.
|
||||
|
||||
- `created_at` / `updated_at` / `deleted_by` 같은 감사 컬럼 세부
|
||||
- purge job과 보존 기간 정책
|
||||
- outbox/event sourcing/전체 변경 이력
|
||||
- multi-tenant row visibility
|
||||
- 낙관적 락과 복구 시 version 충돌
|
||||
|
||||
현재 문서 체계에서도 audit-columns, concurrency, migration은 이미 별도 문서로 분리되어 있다.
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- soft delete indicator만 두고 일반 조회 predicate를 표준화하지 않는 것
|
||||
- soft-deleted row가 있는데도 전체-table `UNIQUE`가 active-only uniqueness를 대신해 줄 것이라 기대하는 것
|
||||
- soft-delete-aware natural key를 partial unique index로 두고, 그 키를 FK target으로 사용하려는 것
|
||||
- restore가 active uniqueness를 다시 만족해야 한다는 점을 무시하는 것
|
||||
- soft-deletable 엔티티에 대해 `deleteAllInBatch`, JPQL bulk delete, Criteria bulk delete를 일반 삭제 경로로 사용하는 것
|
||||
- `@SoftDelete`를 `@OneToMany` 컬렉션에 붙이는 것
|
||||
- 일반 목록/API 조회에 삭제 row를 묵시적으로 섞는 것
|
||||
- soft delete와 physical purge를 같은 경로로 다루는 것
|
||||
|
||||
이 금지 규칙은 PostgreSQL partial unique index/FK 규칙, Hibernate `@SoftDelete` 제약, Spring Data JPA bulk delete semantics를 운영 규칙으로 압축한 것이다.
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- soft delete indicator가 `deleted_at` 기준으로 일관되게 정의되어 있는가?
|
||||
- 일반 조회 기본 predicate가 `deleted_at IS NULL`로 고정되어 있는가?
|
||||
- active-row uniqueness가 필요하면 partial unique index로 설계했는가?
|
||||
- restore 시 uniqueness 재검증이 필요하다는 점을 고려했는가?
|
||||
- FK가 soft-deleted parent를 자동으로 차단하지 않는다는 점을 알고 있는가?
|
||||
- natural key partial unique index를 FK target으로 삼지 않았는가?
|
||||
- soft delete 경로와 physical purge 경로를 분리했는가?
|
||||
- soft-deletable 엔티티에서 bulk delete가 ORM soft delete semantics를 우회하지 않는가?
|
||||
- Hibernate `@SoftDelete`를 쓴다면 provider-specific 기능임을 알고 있는가?
|
||||
- `@OneToMany` 컬렉션에 `@SoftDelete`를 잘못 적용하지 않았는가?
|
||||
@@ -0,0 +1,241 @@
|
||||
# DB Transaction 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 Spring + JPA/Hibernate + PostgreSQL 환경에서 트랜잭션을 어떤 기준으로 시작하고 끝낼지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 트랜잭션 경계를 repository 호출 단위가 아니라 use case의 일관성 경계로 잡는다
|
||||
- @Transactional의 기본 의미, rollback 규칙, readOnly 의미를 정확히 해석한다
|
||||
- long-running transaction, self-invocation, REQUIRES_NEW 남용 같은 신뢰도 낮은 패턴을 줄인다
|
||||
- 실제 DB 트랜잭션과 JPA persistence context의 관계를 혼동하지 않게 만든다
|
||||
|
||||
Spring Data JPA는 보통 여러 repository 호출을 묶는 facade/service가 비-CRUD 작업의 transactional boundary를 정의한다고 설명하고, Hibernate는 물리적 DB 트랜잭션은 가능한 한 짧아야 한다고 설명한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Data JPA / Hibernate / PostgreSQL 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 Spring Framework의 @Transactional, rollback rules, propagation semantics, Spring Data JPA의 transaction boundary guidance, Hibernate의 transaction / flush 문서, PostgreSQL의 transaction isolation 문서를 기준으로 작성한다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 트랜잭션은 “코드 묶음”이 아니라 “일관성 경계”다
|
||||
|
||||
트랜잭션은 여러 repository 메서드를 그냥 감싸는 편의 기능이 아니라, 하나의 use case가 모두 반영되거나 모두 취소되어야 하는 일관성 경계를 표현해야 한다. Spring Data JPA도 여러 repository 호출을 묶는 facade/service가 transactional boundary를 정의한다고 설명한다.
|
||||
|
||||
### 3.2 물리적 DB 트랜잭션은 가능한 짧게 유지한다
|
||||
|
||||
Hibernate는 DB 트랜잭션은 lock contention을 줄이기 위해 가능한 한 짧아야 하고, end-user think time 동안 열어 두지 말라고 설명한다. 또한 Spring의 imperative @Transactional은 현재 실행 스레드에 바인딩되며, 새로 시작한 스레드로는 전파되지 않는다. 프로젝트 기본 원칙은 “트랜잭션은 짧고, 한 스레드 안에서, 필요한 DB 작업만 감싼다”이다.
|
||||
|
||||
### 3.3 트랜잭션은 애플리케이션 서비스 메서드에서 시작하는 것을 기본으로 한다
|
||||
|
||||
Spring Data JPA는 transaction boundary를 non-CRUD 작업의 시작점, 즉 facade/service 쪽에서 선언하는 방식을 권장한다. 따라서 프로젝트 기본값은 controller도 아니고 repository도 아니라 application service / use case 메서드에서 트랜잭션을 선언하는 것이다.
|
||||
|
||||
## 4. 트랜잭션 경계 위치 기준
|
||||
|
||||
### 4.1 기본 위치는 application service / use case 메서드
|
||||
|
||||
하나의 유스케이스가 여러 repository, domain operation, event publication 준비를 묶는다면 그 외곽 application service 메서드가 트랜잭션 경계가 된다. Spring Data JPA도 여러 repository 호출을 묶는 facade/service가 transactional boundary를 정의한다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- command use case는 application service 메서드에 @Transactional
|
||||
- 복수 repository를 묶는 read use case도 필요하면 service 메서드에 @Transactional(readOnly = true)
|
||||
- repository는 데이터 접근 구현 세부이며, 외곽 일관성 경계의 기본 소유자가 아니다
|
||||
|
||||
### 4.2 controller에 트랜잭션을 두지 않는다
|
||||
|
||||
Hibernate는 물리적 트랜잭션을 짧게 유지하라고 설명하고, Spring은 트랜잭션이 thread-bound execution 안에서 동작한다고 설명한다. controller에 트랜잭션을 두면 request parsing, 외부 호출, response mapping까지 DB 트랜잭션이 불필요하게 길어지기 쉽다. 프로젝트 기본값은 controller에서 입력을 해석하고, service가 트랜잭션을 시작하는 구조다.
|
||||
|
||||
### 4.3 domain model 내부에서 트랜잭션을 시작하지 않는다
|
||||
|
||||
Spring의 선언적 트랜잭션은 AOP proxy 기반이며, 서비스 계층 객체에 적용되는 것이 일반적이다. transaction boundary는 인프라 관심사이므로 entity / value object / domain service가 직접 시작점이 되지 않게 한다. 이는 Spring의 proxy 기반 transaction model과 계층 분리에 맞는 프로젝트 권장안이다.
|
||||
|
||||
## 5. @Transactional 적용 기준
|
||||
|
||||
### 5.1 기본 propagation은 REQUIRED
|
||||
|
||||
Spring은 PROPAGATION_REQUIRED가 현재 스레드의 공통적인 call stack arrangement에서 좋은 기본값이며, outer service-level transaction에 참여한다고 설명한다. 프로젝트 기본 propagation도 REQUIRED다. 같은 유스케이스 안에서 호출되는 내부 service/repository는 기본적으로 하나의 물리 트랜잭션에 참여한다고 본다.
|
||||
|
||||
### 5.2 isolation / timeout 선언은 “새로 시작하는 트랜잭션”에서만 의미가 있다
|
||||
|
||||
Spring @Transactional javadoc은 isolation과 timeout이 REQUIRED 또는 REQUIRES_NEW처럼 새로 시작된 트랜잭션에만 적용된다고 설명한다. 기존 트랜잭션에 참여하는 inner scope에서는 outer scope의 특성을 따르며, 기본적으로 local declaration이 조용히 무시될 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- isolation/timeout은 outer use case 메서드에서 선언하는 것을 기본으로 한다
|
||||
- inner helper 메서드에서 isolation/timeout을 바꿔도 실제로 반영되지 않을 수 있음을 전제로 한다
|
||||
- 상세 기준은 별도 isolation.md, lock.md에서 다룬다
|
||||
|
||||
### 5.3 REQUIRES_NEW는 독립 커밋이 정말 필요할 때만 사용한다
|
||||
|
||||
Spring은 REQUIRES_NEW가 항상 독립적인 물리 트랜잭션을 만들고, outer transaction과 무관하게 commit/rollback 되며, inner transaction의 lock도 완료 시 즉시 해제된다고 설명한다. 따라서 REQUIRES_NEW는 단순 편의 옵션이 아니라 “부분 커밋을 허용하겠다”는 강한 의미다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본값은 REQUIRED
|
||||
- REQUIRES_NEW는 outer rollback과 무관하게 남아야 하는 audit 기록, 독립 보상 기록 등 정말 독립 커밋이 필요한 경우에만 예외적으로 허용
|
||||
- “현재 트랜잭션과 충돌하니 일단 REQUIRES_NEW” 같은 사용은 금지한다
|
||||
|
||||
## 6. read/write use case 기준
|
||||
|
||||
### 6.1 쓰기 use case는 기본적으로 @Transactional
|
||||
|
||||
여러 row 변경, aggregate 변경, domain event 적재, 상태 전이 같은 쓰기 use case는 기본적으로 하나의 트랜잭션 안에서 처리해야 한다. Spring Data JPA가 service/facade에서 transactional boundary를 정의하라고 권장하는 이유도 이런 일관성 단위를 서비스 메서드에서 닫기 위해서다.
|
||||
|
||||
### 6.2 읽기 use case는 필요 시 @Transactional(readOnly = true)
|
||||
|
||||
Spring의 readOnly는 최적화 힌트이며, Spring Data JPA는 대부분의 query method는 읽기이므로 readOnly=true가 일반적이라고 설명한다. 또한 Hibernate와 함께 쓰면 flush mode를 NEVER로 두어 dirty check를 건너뛰는 최적화가 가능하다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순 read query는 readOnly=true를 기본 검토
|
||||
- 여러 query를 묶거나, lazy association 초기화가 service 안에서 필요하거나, read model 조립이 필요한 경우 service 메서드에 @Transactional(readOnly = true)
|
||||
- 상세 조회/목록 조회의 fetch plan은 별도 fetch/N+1 문서 기준을 따른다
|
||||
|
||||
### 6.3 readOnly=true는 쓰기 차단 장치가 아니다
|
||||
|
||||
Spring @Transactional javadoc은 readOnly가 실제 트랜잭션 서브시스템에 대한 힌트일 뿐이며, 쓰기 시도를 반드시 실패시키는 것은 아니라고 설명한다. Spring Data JPA도 readOnly는 조작 query를 막는 검사 장치가 아니라고 명시한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- readOnly=true를 “절대 write 불가”라고 해석하지 않는다
|
||||
- readOnly 트랜잭션 안에서 managed entity를 수정하는 코드를 금지한다
|
||||
- write를 막고 싶다면 설계를 분리하고, 코드 리뷰로 read path와 write path를 명확히 구분한다
|
||||
|
||||
## 7. rollback 기준
|
||||
|
||||
### 7.1 기본 rollback 규칙을 정확히 이해한다
|
||||
|
||||
Spring은 기본적으로 RuntimeException과 Error에서만 rollback하고, checked exception은 기본 설정에서 rollback하지 않는다고 설명한다. 또한 Spring이 권장하는 rollback 신호는 현재 트랜잭션 안에서 예외를 던지는 것이라고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- unchecked exception이면 기본 rollback
|
||||
- checked exception은 기본적으로 rollback되지 않음을 전제로 한다
|
||||
- checked exception이 곧 유스케이스 실패라면 rollbackFor를 명시하거나 예외 계층을 재설계한다
|
||||
|
||||
### 7.2 rollbackFor = Exception.class를 기본값으로 두지 않는다
|
||||
|
||||
Spring은 rollback 규칙을 세밀하게 지정할 수 있다고 설명하지만, 기본 규칙은 unchecked exception 기반이다. 모든 checked exception까지 일괄 rollback 대상으로 바꾸면 “복구 가능한 예외”와 “트랜잭션 자체를 취소해야 하는 예외” 구분이 흐려진다. 이 문서의 프로젝트 권장안은 정말 rollback이 필요한 checked exception만 좁게 지정하는 것이다.
|
||||
|
||||
### 7.3 rollback 여부는 예외 의미와 맞아야 한다
|
||||
|
||||
트랜잭션 rollback 규칙은 기술 설정이 아니라 business outcome을 반영해야 한다. “이 예외가 발생하면 지금까지의 상태 변경을 모두 되돌려야 하는가?”를 기준으로 정해야 한다. Spring이 rollback rules를 예외 타입별로 선언적으로 제어하게 한 이유도 그 의미를 코드 밖에서 명확히 표현하기 위해서다.
|
||||
|
||||
## 8. flush / commit 기준
|
||||
|
||||
### 8.1 flush와 commit을 같은 것으로 보지 않는다
|
||||
|
||||
Hibernate는 flush를 “persistence context 상태를 DB와 동기화하는 과정”으로 정의하고, 변경사항은 먼저 메모리에 반영된 뒤 flush 시 INSERT/UPDATE/DELETE SQL로 변환된다고 설명한다. commit은 flush 이후 실제 트랜잭션 완료와 durability까지 포함하는 더 큰 경계다.
|
||||
|
||||
### 8.2 SQL은 commit 직전에만 나간다고 가정하지 않는다
|
||||
|
||||
Hibernate는 기본 AUTO flush 모드에서 flush가 transaction commit 직전뿐 아니라, queued entity action과 겹치는 JPQL/HQL query 실행 전, 그리고 EntityManager의 native query 실행 전에도 발생할 수 있다고 설명한다. 따라서 “아직 commit 안 했으니 SQL도 안 나갔을 것”이라는 가정은 안전하지 않다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 트랜잭션 안에서 조회 query가 flush를 유발할 수 있음을 전제로 한다
|
||||
- write use case 중간에 불필요한 query를 많이 넣지 않는다
|
||||
- flush timing에 의존하는 로직보다 명시적 순서와 명확한 트랜잭션 설계를 우선한다
|
||||
|
||||
## 9. PostgreSQL 관점의 기본 해석
|
||||
|
||||
### 9.1 기본 isolation은 PostgreSQL 기본값을 따른다
|
||||
|
||||
Spring @Transactional의 isolation 기본값은 Isolation.DEFAULT이고, PostgreSQL의 기본 isolation level은 Read Committed다. PostgreSQL은 Read Uncommitted를 요청해도 내부적으로 Read Committed처럼 동작한다고 설명한다.
|
||||
|
||||
### 9.2 Read Committed에서는 한 트랜잭션 안의 두 SELECT가 서로 다른 결과를 볼 수 있다
|
||||
|
||||
PostgreSQL은 Read Committed에서 각 SELECT가 query 시작 시점의 snapshot을 보기 때문에, 같은 트랜잭션 안에서도 두 번의 SELECT가 서로 다른 결과를 볼 수 있다고 설명한다. 따라서 “한 트랜잭션 안이면 읽기 결과가 항상 고정된다”는 가정은 틀릴 수 있다. 상세 기준은 별도 isolation 문서에서 다룬다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 서비스 기본값은 DB 기본 isolation을 따른다
|
||||
- stronger isolation이 정말 필요할 때만 명시적으로 올린다
|
||||
- isolation 문제를 transaction 길이나 REQUIRES_NEW 남용으로 우회하지 않는다
|
||||
|
||||
## 10. 프록시 / self-invocation 기준
|
||||
|
||||
### 10.1 @Transactional은 프록시를 통과한 외부 호출에서만 기본적으로 동작한다
|
||||
|
||||
Spring 공식 문서는 proxy mode가 기본이고, proxy를 통과하는 external method call만 interception 대상이라고 설명한다. 따라서 같은 클래스 안의 self-invocation은 호출된 메서드에 @Transactional이 있어도 실제 트랜잭션이 적용되지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- transactional method는 같은 클래스 내부에서 자기 자신이 호출하는 구조로 설계하지 않는다
|
||||
- helper 분리가 필요하면 별도 bean으로 분리하거나 outer service에서 경계를 다시 설계한다
|
||||
- @PostConstruct 같은 초기화 코드에서 트랜잭션을 기대하지 않는다
|
||||
|
||||
### 10.2 트랜잭션 애노테이션은 구체 클래스 메서드에 두는 것을 기본으로 한다
|
||||
|
||||
Spring은 구체 클래스 메서드에 @Transactional을 두는 것을 권장하고, interface 선언에만 의존하면 AspectJ mode 등에서 무시될 수 있다고 설명한다. 또한 proxy mode에서는 보통 public method 중심으로 사용하는 것이 자연스럽다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본은 concrete service class의 public method에 @Transactional
|
||||
- interface에만 선언해 두고 동작을 기대하지 않는다
|
||||
- method visibility와 proxy 종류 차이를 이해하지 못한 채 비공개 메서드에 남용하지 않는다
|
||||
|
||||
## 11. 외부 호출 / 비동기 / after-commit 기준
|
||||
|
||||
### 11.1 트랜잭션 안에서 원격 호출이나 오래 걸리는 작업을 길게 잡지 않는다
|
||||
|
||||
Hibernate는 DB 트랜잭션을 길게 유지하지 말라고 설명하고, Spring은 imperative 트랜잭션이 현재 스레드에만 바인딩되며 새 스레드에는 전파되지 않는다고 설명한다. 또한 Spring은 transaction context가 remote call로 전파되지 않는다고 설명한다. 따라서 프로젝트 기본값은 DB 작업과 외부 네트워크 호출을 무분별하게 한 트랜잭션 안에 길게 묶지 않는 것이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 HTTP/API 호출, 메일 발송, 파일 업로드, 오래 걸리는 연산을 DB 트랜잭션 안에 오래 물고 있지 않는다
|
||||
- 새 스레드나 @Async성 작업이 같은 트랜잭션에 참여할 것이라고 기대하지 않는다
|
||||
- 외부 연동 결과 반영은 별도 integration / outbox 기준과 함께 설계한다
|
||||
|
||||
### 11.2 commit 이후에만 일어나야 하는 후속 작업은 after-commit에 연결한다
|
||||
|
||||
Spring은 @TransactionalEventListener가 AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION 같은 phase를 지원한다고 설명한다. 프로젝트에서는 “DB commit이 성공한 뒤에만 발행되어야 하는 후속 처리”는 inline side effect로 섞기보다 after-commit 시점과 연결하는 것을 기본 검토한다.
|
||||
|
||||
## 12. 문서 경계
|
||||
|
||||
이 문서는 트랜잭션 경계, rollback, readOnly, propagation의 기본 해석을 다룬다.
|
||||
|
||||
다음 내용은 별도 문서에서 확장한다.
|
||||
|
||||
- isolation level 상세
|
||||
- optimistic/pessimistic locking
|
||||
- 동시성 충돌 처리
|
||||
- outbox / transactional event / integration retry 설계
|
||||
- migration 실행 트랜잭션 정책
|
||||
|
||||
## 13. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- controller에 @Transactional을 두는 것
|
||||
- repository 개별 메서드가 outer use case boundary를 소유하게 두는 것
|
||||
- readOnly=true를 쓰기 방지 장치처럼 오해하는 것
|
||||
- rollbackFor = Exception.class를 습관적으로 선언하는 것
|
||||
- helper 메서드 충돌을 피하려고 무심코 REQUIRES_NEW를 붙이는 것
|
||||
- self-invocation 구조에서 @Transactional이 동작할 것이라 기대하는 것
|
||||
- 트랜잭션 안에서 새 스레드를 시작하고 같은 트랜잭션 참여를 기대하는 것
|
||||
- long-running external call을 DB 트랜잭션 안에 오래 포함시키는 것
|
||||
- flush와 commit을 같은 것으로 보는 것
|
||||
|
||||
이 금지 규칙은 Spring의 proxy/rollback/propagation semantics, Hibernate의 short transaction 및 flush semantics, PostgreSQL의 isolation 기본 동작을 실무 운영 기준으로 압축한 것이다.
|
||||
|
||||
## 14. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 메서드는 하나의 use case 일관성 경계를 대표하는가?
|
||||
- 트랜잭션이 controller가 아니라 application service에서 시작되는가?
|
||||
- transaction scope가 불필요하게 길지 않은가?
|
||||
- readOnly=true를 최적화 힌트로만 해석하고 있는가?
|
||||
- checked exception rollback 여부를 명시적으로 판단했는가?
|
||||
- REQUIRES_NEW가 정말 독립 커밋 의미와 맞는가?
|
||||
- flush가 commit 전에 일어날 수 있음을 고려했는가?
|
||||
- self-invocation 때문에 @Transactional이 무시되지 않는가?
|
||||
- 새 스레드/비동기 작업이 같은 트랜잭션에 참여한다고 가정하지 않는가?
|
||||
- isolation/lock 요구를 transaction boundary 문제와 분리해서 생각하고 있는가?
|
||||
@@ -0,0 +1,140 @@
|
||||
# common module 허용 기준
|
||||
|
||||
## 목적
|
||||
|
||||
`common` 모듈은 공유 편의 때문에 만드는 기본 모듈이 아니다.
|
||||
기본값은 **만들지 않음** 이다.
|
||||
|
||||
이 문서의 목적은:
|
||||
- “일단 common으로 보내자”를 막고
|
||||
- 모듈 경계를 흐리는 공유를 줄이고
|
||||
- 정말 필요한 공용 코드만 예외적으로 허용하는 것이다
|
||||
|
||||
## 공식/원전 기준 요약
|
||||
|
||||
- 모듈은 외부에 노출하는 API와 내부 구현을 구분해야 한다.
|
||||
- 다른 모듈은 공개된 API에만 의존해야 하며, 내부 구현 의존은 막아야 한다.
|
||||
- 헥사고날 아키텍처의 핵심도 경계와 역할 분리다.
|
||||
- 겉보기 중복만 보고 조기 추상화를 만들면 이후 변경 비용이 커질 수 있다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. `common`은 기본 금지
|
||||
새 코드를 추가할 때 기본 선택지는 아래 순서다.
|
||||
|
||||
1. owning layer/module 내부에 둔다
|
||||
2. 경계가 필요하면 해당 module의 API로 노출한다
|
||||
3. 그래도 어느 곳에도 자연스럽게 속하지 않을 때만 `common`을 검토한다
|
||||
|
||||
즉 “어디 둘지 애매하면 common”을 금지한다.
|
||||
|
||||
### 2. `common`은 아래 조건을 모두 만족할 때만 허용
|
||||
다음이 모두 참일 때만 `common` 도입을 검토한다.
|
||||
|
||||
- 3개 이상 모듈에서 실제로 재사용된다
|
||||
- 변화 이유가 같다
|
||||
- 특정 모듈이 소유한다고 보기 어렵다
|
||||
- `common`으로 옮겨도 의존 방향이 더 나빠지지 않는다
|
||||
- 모듈 API를 좁게 노출하는 방식으로 해결하기 어렵다
|
||||
|
||||
하나라도 애매하면 기존 owning module에 둔다.
|
||||
|
||||
### 3. “중복 존재”만으로는 common 근거가 아니다
|
||||
코드가 비슷해 보여도 다음이면 `common`으로 보내지 않는다.
|
||||
|
||||
- 서로 다른 비즈니스 문맥에서 독립적으로 변할 가능성이 큼
|
||||
- 지금은 비슷하지만 미래 요구가 갈라질 가능성이 큼
|
||||
- common으로 모으면 이름과 책임이 모호해짐
|
||||
|
||||
즉 진짜 공통 원인(common cause)일 때만 허용한다.
|
||||
|
||||
### 4. 레이어 경계를 깨는 공유 금지
|
||||
`common`이 아래를 섞는 것을 금지한다.
|
||||
|
||||
- presentation + domain
|
||||
- domain + infrastructure
|
||||
- application + web transport
|
||||
- persistence 모델 + API 모델
|
||||
- 외부 API payload + 내부 domain meaning
|
||||
|
||||
공유보다 경계 보존이 우선이다.
|
||||
|
||||
### 5. `common`에는 business policy를 두지 않는다
|
||||
다음은 `common`에 두지 않는다.
|
||||
|
||||
- 도메인 규칙
|
||||
- 상태 전이 규칙
|
||||
- 권한 판정
|
||||
- 에러 코드 정책
|
||||
- 외부 연동별 특화 규칙
|
||||
|
||||
이런 것은 반드시 owning module 또는 boundary contract가 소유한다.
|
||||
|
||||
### 6. `common`에 둘 수 있는 것
|
||||
예외적으로 허용 가능한 후보:
|
||||
|
||||
- 경량 value type
|
||||
- 순수하고 작은 utility
|
||||
- 여러 모듈이 같은 이유로 쓰는 매우 안정적인 helper
|
||||
- 모듈 경계를 깨지 않는 공통 annotation / marker / tiny abstraction
|
||||
- 명확한 소유자가 없는 순수한 language-level helper
|
||||
|
||||
단, 이것도 실제 재사용과 변화 이유가 검증되어야 한다.
|
||||
|
||||
### 7. `common`의 utility는 특히 좁게 제한
|
||||
utility는 아래 조건을 만족할 때만 허용한다.
|
||||
|
||||
- side effect 없음
|
||||
- framework/business/persistence 의존 없음
|
||||
- 이름만 보고 역할이 분명함
|
||||
- 단순 문자열/시간/컬렉션 helper라도 owning type 안에 둘 수 없는 이유가 있음
|
||||
|
||||
`StringUtils`, `DateUtils`, `CommonUtils`, `AppUtils` 같은 잡동사니 묶음은 금지한다.
|
||||
|
||||
### 8. 모듈 API 노출이 common보다 우선
|
||||
Spring Modulith의 방향처럼, 공용화가 필요해 보일 때 먼저 검토할 것은:
|
||||
|
||||
- 해당 모듈의 공개 API로 노출할 수 있는가
|
||||
- named interface처럼 노출 범위를 좁게 지정할 수 있는가
|
||||
- explicit dependency로 필요한 부분만 허용할 수 있는가
|
||||
|
||||
즉 “common으로 이동”보다 “모듈 API 설계 개선”을 먼저 본다.
|
||||
|
||||
### 9. 공용 타입은 더 보수적으로 관리
|
||||
`common`으로 이동한 타입은 사실상 여러 모듈이 기대는 기반이 된다.
|
||||
|
||||
기본:
|
||||
- 변경에 더 보수적이어야 한다
|
||||
- naming을 더 명확히 해야 한다
|
||||
- Javadoc/문서가 더 중요하다
|
||||
- examples/tests가 함께 있어야 한다
|
||||
|
||||
### 10. `common`은 dump zone이 아니다
|
||||
다음 징후가 보이면 잘못된 `common`이다.
|
||||
|
||||
- 이름이 `Common*`, `Util*`, `Base*`, `Helper*` 위주
|
||||
- business/domain/web/persistence 코드가 섞여 있음
|
||||
- 모듈 간 순환 의존을 가리기 위해 common을 사용
|
||||
- “일단 여기 두자”가 반복됨
|
||||
|
||||
이 경우 common을 늘리는 대신 다시 소유 모듈로 분해한다.
|
||||
|
||||
### 11. common 도입은 문서화한다
|
||||
새로운 `common` 타입/패키지를 추가할 때는 최소한 아래를 설명한다.
|
||||
|
||||
- 왜 owning module에 둘 수 없는가
|
||||
- 어떤 모듈들이 실제로 재사용하는가
|
||||
- 같은 이유로 어떻게 함께 바뀌는가
|
||||
- 어떤 경계를 깨지 않는가
|
||||
|
||||
설명할 수 없으면 common으로 보내지 않는다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- `common` 기본 금지
|
||||
- 재사용만으로는 부족하고 “같은 이유로 함께 변함”이 필요
|
||||
- 모듈 API 설계 개선이 common보다 우선
|
||||
- 경계/레이어를 섞는 공용화 금지
|
||||
- business policy는 common 금지
|
||||
- utility/common dump zone 금지
|
||||
- 예외적 허용 시에도 좁고 순수하고 안정적인 타입만
|
||||
@@ -0,0 +1,173 @@
|
||||
# DTO / Domain / Entity separation 기준
|
||||
|
||||
## 목적
|
||||
|
||||
DTO, Domain, Entity는 이름만 다른 비슷한 객체가 아니라 **서로 다른 경계와 책임을 가진 모델**이다.
|
||||
형태가 비슷하더라도 역할이 다르면 분리한다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- DTO는 프로세스/경계 사이에서 데이터를 운반하는 객체다.
|
||||
- Entity는 persistence provider가 관리하는 영속 모델이다.
|
||||
- Spring request binding은 강력하지만, 외부 입력을 객체 그래프에 직접 바인딩하는 것은 보안상 주의가 필요하다.
|
||||
- 따라서 web 입력 모델, 비즈니스 모델, persistence 모델을 하나로 섞지 않는 것이 안전하다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. DTO는 경계 데이터 모델이다
|
||||
DTO는 다음 경계에서만 사용한다.
|
||||
|
||||
- HTTP request / response
|
||||
- external API request / response
|
||||
- message payload
|
||||
- batch/file I/O payload
|
||||
|
||||
DTO의 책임:
|
||||
- 데이터 운반
|
||||
- 직렬화/역직렬화 경계 표현
|
||||
- validation annotation 수용 가능
|
||||
- API 계약 표현
|
||||
|
||||
DTO의 책임이 아닌 것:
|
||||
- 비즈니스 규칙
|
||||
- 도메인 불변식 유지
|
||||
- 영속성 상태 관리
|
||||
- repository/service 호출
|
||||
|
||||
### 2. Domain은 비즈니스 의미 모델이다
|
||||
Domain은 다음을 표현한다.
|
||||
|
||||
- entity
|
||||
- value object
|
||||
- domain service
|
||||
- domain policy
|
||||
- 불변식
|
||||
- 상태 전이 의미
|
||||
|
||||
Domain의 책임:
|
||||
- 비즈니스 의미
|
||||
- 유효한 상태 보장
|
||||
- 행위와 규칙 표현
|
||||
|
||||
Domain의 책임이 아닌 것:
|
||||
- JSON 구조
|
||||
- HTTP 요청/응답 형식
|
||||
- JPA annotation/persistence lifecycle
|
||||
- 외부 API payload 구조
|
||||
|
||||
### 3. Entity는 persistence 모델이다
|
||||
Entity는 DB와 persistence provider가 다루는 모델이다.
|
||||
|
||||
Entity의 책임:
|
||||
- 테이블/컬럼/관계 매핑
|
||||
- persistence lifecycle 대응
|
||||
- 저장 가능한 상태 표현
|
||||
|
||||
Entity의 책임이 아닌 것:
|
||||
- API 응답 계약
|
||||
- 비즈니스 정책 결정
|
||||
- 외부 연동 payload 표현
|
||||
|
||||
### 4. 하나의 타입으로 세 역할을 겸하지 않는다
|
||||
다음은 기본 금지한다.
|
||||
|
||||
- request DTO를 그대로 domain으로 사용
|
||||
- JPA entity를 그대로 response DTO로 반환
|
||||
- domain object에 JPA/JSON/validation annotation을 한꺼번에 섞기
|
||||
|
||||
형태가 비슷해도 책임이 다르면 타입을 분리한다.
|
||||
|
||||
### 5. 경계마다 변환을 명시한다
|
||||
기본 흐름:
|
||||
- request DTO -> command / domain input
|
||||
- entity -> domain
|
||||
- domain -> response DTO
|
||||
- external payload -> internal model
|
||||
|
||||
변환은 mapper/assembler/factory 같은 명시적 경계에서 수행한다.
|
||||
|
||||
### 6. DTO에는 validation을 둘 수 있지만, 최종 의미 검증은 domain이 맡는다
|
||||
DTO에 둘 수 있는 것:
|
||||
- `@NotBlank`
|
||||
- `@Size`
|
||||
- 형식 검증
|
||||
- 웹 입력 범위 검증
|
||||
|
||||
하지만 다음은 domain 책임이다.
|
||||
- 불변식
|
||||
- 상태 전이 타당성
|
||||
- 정책 기반 허용 여부
|
||||
- aggregate 일관성
|
||||
|
||||
즉 DTO validation이 domain validation을 대체하지 않는다.
|
||||
|
||||
### 7. Entity의 nullable/관계/지연로딩 특성을 domain에 그대로 전파하지 않는다
|
||||
DB 제약 때문에 entity가 nullable/관계 중심일 수는 있다.
|
||||
하지만 domain은 비즈니스 invariant 기준으로 더 엄격할 수 있다.
|
||||
|
||||
기본:
|
||||
- entity -> domain 변환 시 검증/정규화
|
||||
- domain은 persistence 편의보다 비즈니스 의미 우선
|
||||
|
||||
### 8. Response DTO는 domain을 그대로 노출하지 않는다
|
||||
response는 외부 계약이다.
|
||||
기본적으로 domain object를 그대로 JSON으로 노출하지 않는다.
|
||||
|
||||
이유:
|
||||
- 내부 구조 변경이 외부 계약에 새어 나간다
|
||||
- 민감정보/불필요 필드 노출 위험
|
||||
- 직렬화 shape가 domain 설계를 오염시킨다
|
||||
|
||||
### 9. Request DTO를 entity에 직접 바인딩하지 않는다
|
||||
Spring DataBinder/WebDataBinder는 강력하지만 보안상 주의가 필요하다.
|
||||
따라서 외부 입력을 entity나 깊은 도메인 객체에 직접 바인딩하지 않는다.
|
||||
|
||||
기본:
|
||||
- request DTO에만 바인딩
|
||||
- 이후 명시적 변환을 거쳐 command/domain으로 이동
|
||||
|
||||
### 10. Entity를 domain과 1:1로 맞추려 하지 않는다
|
||||
entity와 domain은 비슷할 수 있지만 항상 같아야 할 필요는 없다.
|
||||
|
||||
예:
|
||||
- entity는 FK/nullable/지연 로딩 중심
|
||||
- domain은 value object/invariant/행위 중심
|
||||
|
||||
“필드가 같아 보이니 하나로 합친다”를 금지한다.
|
||||
|
||||
### 11. DTO는 record/단순 데이터 구조를 우선 검토
|
||||
DTO는 경계 데이터 운반이 목적이므로, 불변/단순 구조를 우선 검토한다.
|
||||
단, framework binding/serialization 요구사항이 있으면 그 제약을 따른다.
|
||||
|
||||
### 12. Domain은 DTO naming을 따라가지 않는다
|
||||
도메인 타입 이름은 API 필드명/JSON 필드명보다 비즈니스 의미를 우선한다.
|
||||
|
||||
금지 예:
|
||||
- `UserResponseName`
|
||||
- `ProviderRequestCode`
|
||||
|
||||
도메인은 business language를 사용한다.
|
||||
|
||||
### 13. Entity는 persistence 편의 메서드를 가질 수 있지만 도메인 규칙 중심 타입이 되지 않게 한다
|
||||
entity에 persistence 편의 메서드가 있을 수는 있다.
|
||||
하지만 핵심 비즈니스 규칙을 entity/JPA lifecycle에 과도하게 묶지 않는다.
|
||||
|
||||
### 14. Mapping 비용보다 경계 명확성이 더 중요하다
|
||||
DTO/Domain/Entity 분리는 변환 코드가 늘 수 있다.
|
||||
그러나 그 비용보다:
|
||||
- 경계 명확성
|
||||
- 보안성
|
||||
- 변경 범위 제한
|
||||
- 직렬화/영속성 오염 방지
|
||||
의 이익이 더 크다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- DTO = 경계 데이터
|
||||
- Domain = 비즈니스 의미
|
||||
- Entity = persistence 모델
|
||||
- 하나의 타입으로 세 역할 겸용 금지
|
||||
- request는 DTO에만 바인딩
|
||||
- DTO validation과 domain invariant를 분리
|
||||
- entity nullable/관계를 domain에 그대로 전파하지 않음
|
||||
- response DTO로 외부 계약을 명시
|
||||
@@ -0,0 +1,163 @@
|
||||
# interface 생성 기준
|
||||
|
||||
## 목적
|
||||
|
||||
인터페이스는 “있으면 좋아 보이는 추상화”가 아니라, **경계와 계약을 안정적으로 표현해야 할 때만** 만든다.
|
||||
기본값은 “인터페이스를 무조건 만들지 않는다”이다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- 인터페이스는 구현과 분리된 계약(contract)을 표현하는 타입이다.
|
||||
- 구현체는 인터페이스가 정한 메서드 계약을 따른다.
|
||||
- 인터페이스는 추상 메서드, default method, static method, 상수를 가질 수 있다.
|
||||
- Spring DI는 의존 객체를 생성자/팩토리 메서드/세터를 통해 주입하며, 인터페이스나 추상 베이스 타입에 의존할 때 결합도가 낮아지고 테스트 대역 사용이 쉬워진다.
|
||||
- Spring AOP는 대상이 인터페이스를 구현하면 JDK dynamic proxy를 기본으로 사용한다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 기본값은 “필요할 때만 만든다”
|
||||
다음 중 하나가 아니라면 인터페이스를 만들지 않는다.
|
||||
|
||||
- 모듈/레이어 경계를 표현해야 한다
|
||||
- 교체 가능한 구현이 실제로 존재하거나 가까운 미래에 예상된다
|
||||
- 테스트에서 대역(stub/mock/fake)으로 치환하는 가치가 크다
|
||||
- 프레임워크/프록시/AOP/플러그인 구조상 계약 타입이 분명히 필요하다
|
||||
- 라이브러리/외부 모듈에 공개할 안정된 API 계약이 필요하다
|
||||
|
||||
### 2. “구현체 1개” 자체는 금지 근거가 아니지만, “이유 없는 인터페이스”는 금지
|
||||
구현체가 1개여도 아래 중 하나면 인터페이스를 둘 수 있다.
|
||||
|
||||
- application port
|
||||
- 외부 연동 client contract
|
||||
- repository-like boundary
|
||||
- 인증/토큰/암호화 같은 교체 가능한 정책
|
||||
|
||||
반대로 구현체가 1개이고 아래도 아니면 인터페이스를 만들지 않는다.
|
||||
|
||||
- 내부 helper/service
|
||||
- 단순 orchestration class
|
||||
- 프레임워크가 요구하지 않는 내부 컴포넌트
|
||||
|
||||
### 3. 레이어 경계는 인터페이스를 우선 검토
|
||||
특히 다음 경계는 인터페이스를 우선 검토한다.
|
||||
|
||||
- application `port/in`
|
||||
- application `port/out`
|
||||
- infrastructure adapter가 구현하는 계약
|
||||
- 외부 시스템 client contract
|
||||
- 교체 가능한 정책 객체
|
||||
|
||||
즉 “안쪽이 바깥 구현을 모르면 좋은 곳”은 인터페이스 후보가 된다.
|
||||
|
||||
### 4. 내부 구현 디테일에는 기본적으로 인터페이스를 만들지 않는다
|
||||
같은 모듈 내부에서만 쓰이고, 교체 가능성도 낮고, 테스트 seam 가치도 낮은 클래스는 concrete class 그대로 둔다.
|
||||
|
||||
금지 예:
|
||||
- `UserService` + `UserServiceImpl`
|
||||
- `EmailNormalizer` + `EmailNormalizerImpl`
|
||||
- `AuthFacade` + `AuthFacadeImpl`
|
||||
|
||||
단, 정말 계약 타입이 먼저이고 구현이 뒤따르는 구조면 예외다.
|
||||
|
||||
### 5. 인터페이스는 “역할”을 표현해야 한다
|
||||
인터페이스 이름은 구현 방식이 아니라 역할/능력을 드러내야 한다.
|
||||
|
||||
좋은 방향:
|
||||
- `UserReader`
|
||||
- `PasswordHasher`
|
||||
- `JwtSigner`
|
||||
- `TokenIssuer`
|
||||
- `UserRepository`
|
||||
- `OAuthClient`
|
||||
|
||||
지양:
|
||||
- `DefaultUserService`
|
||||
- `CommonManager`
|
||||
- `BaseHandler`
|
||||
|
||||
### 6. 인터페이스는 작고 응집도 있게 유지
|
||||
인터페이스는 하나의 역할/계약에 집중해야 한다.
|
||||
|
||||
금지:
|
||||
- unrelated method를 한 인터페이스에 몰아넣기
|
||||
- “편해서” 여러 책임을 한 계약에 합치기
|
||||
- consumer마다 일부만 필요한 fat interface
|
||||
|
||||
### 7. 인터페이스는 구현 세부보다 호출 계약을 고정
|
||||
인터페이스는 아래를 고정해야 한다.
|
||||
|
||||
- 어떤 입력을 받는가
|
||||
- 어떤 결과를 돌려주는가
|
||||
- 어떤 예외/실패 의미가 가능한가
|
||||
- 어떤 side effect/보장이 있는가
|
||||
|
||||
반대로 아래는 인터페이스에 새지 않게 한다.
|
||||
|
||||
- HttpClient/WebClient/JPA/Redis/Jackson 등 기술 세부
|
||||
- 프레임워크 구체 타입
|
||||
- 구현체 내부 최적화 방식
|
||||
|
||||
### 8. default method는 “계약 핵심”보다 “하위 호환/작은 공통 동작”에만 제한
|
||||
Oracle 문서상 default method는 기존 구현과의 binary compatibility를 유지하면서 기능을 추가할 수 있다.
|
||||
프로젝트에서는 아래일 때만 제한적으로 허용한다.
|
||||
|
||||
- 라이브러리/공용 계약의 하위 호환이 중요하다
|
||||
- 매우 작은 convenience 동작이다
|
||||
- 구현체 대부분에 동일하게 자연스럽다
|
||||
|
||||
기본값은 추상 메서드다.
|
||||
비즈니스 핵심 로직을 default method로 밀어 넣지 않는다.
|
||||
|
||||
### 9. 인터페이스 static method는 그 계약에만 밀접한 helper일 때만
|
||||
Oracle 문서상 인터페이스는 static method를 가질 수 있다.
|
||||
프로젝트에서는 그 helper가 해당 인터페이스 계약과 아주 밀접할 때만 허용한다.
|
||||
|
||||
그 외 일반 helper는 별도 타입/유틸로 분리한다.
|
||||
|
||||
### 10. 인터페이스 상수 남용 금지
|
||||
인터페이스는 상수 묶음 용도로 만들지 않는다.
|
||||
상수는 계약의 본질이 아닐 경우 별도 적절한 소유 타입에 둔다.
|
||||
|
||||
### 11. Spring 프록시/AOP 때문에 인터페이스를 만들 수는 있지만, 그 이유를 과장하지 않는다
|
||||
Spring은 인터페이스가 있으면 JDK dynamic proxy를 기본으로 사용한다.
|
||||
하지만 “프록시 가능”만으로 모든 클래스 앞에 인터페이스를 두지 않는다.
|
||||
|
||||
기본 판단 순서:
|
||||
1. 이 타입이 경계/계약인가?
|
||||
2. 교체/테스트/AOP 가치가 있는가?
|
||||
3. concrete class로 두는 것이 더 단순한가?
|
||||
|
||||
### 12. 테스트를 위해서만 인터페이스를 남발하지 않는다
|
||||
Spring DI 문서는 인터페이스/추상 베이스 타입이 테스트 대역 사용을 쉽게 한다고 설명한다.
|
||||
하지만 “테스트가 쉬워 보인다”는 이유만으로 의미 없는 인터페이스를 만들지 않는다.
|
||||
|
||||
다음도 대안이 될 수 있다.
|
||||
- package-private concrete class 테스트
|
||||
- 더 작은 collaborator 분리
|
||||
- test fixture/fake 구현
|
||||
- 포트 레벨에서만 seam 만들기
|
||||
|
||||
### 13. public API / multi-module contract는 인터페이스 우선 검토
|
||||
다른 모듈/패키지/팀이 사용할 public contract면 인터페이스를 우선 검토한다.
|
||||
이 경우 호출자와 구현체를 느슨하게 분리할 가치가 크다.
|
||||
|
||||
### 14. 조기 추상화 금지
|
||||
겉보기 유사성만 보고 인터페이스를 먼저 만들지 않는다.
|
||||
|
||||
다음 질문 중 “예”가 충분히 쌓일 때 만든다.
|
||||
- 정말 다른 구현이 필요한가?
|
||||
- 호출자가 구현이 아니라 계약에 의존해야 하는가?
|
||||
- 이 추상화가 6개월 뒤에도 자연스러운가?
|
||||
- 이 인터페이스가 테스트/교체/경계 보호에 실제 도움 되는가?
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 인터페이스 기본값은 “필요할 때만”
|
||||
- application port / 외부 경계 / 교체 정책은 인터페이스 우선
|
||||
- 내부 helper/orchestration에는 기본적으로 인터페이스 금지
|
||||
- `XService` + `XServiceImpl` 자동 생성 금지
|
||||
- 역할 중심 이름 사용
|
||||
- 기술 세부를 계약에 노출하지 않음
|
||||
- default/static method는 제한적으로만 허용
|
||||
- 프록시 가능성만으로 인터페이스를 만들지 않음
|
||||
- 조기 추상화 금지
|
||||
@@ -0,0 +1,165 @@
|
||||
# mapper separation 기준
|
||||
|
||||
## 목적
|
||||
|
||||
매퍼는 **한 모델의 데이터를 다른 모델로 번역하는 역할**만 맡는다.
|
||||
비즈니스 규칙, 영속성 접근, 외부 호출, 부수효과를 매퍼에 섞지 않는다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- DTO는 프로세스/경계 사이에서 데이터를 옮기는 객체다.
|
||||
- DTO와 domain object 사이를 옮기는 assembler/mapper를 둘 수 있다.
|
||||
- Spring DataBinder는 입력 바인딩이 강력하지만, 바인딩 대상 설계를 신중히 해야 하며 보안상 주의가 필요하다.
|
||||
- MapStruct 같은 매퍼 도구는 type-safe bean mapping 코드를 생성한다.
|
||||
- 기존 target 객체 갱신은 `@MappingTarget` 같은 명시적 update mapping으로 표현할 수 있다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 매퍼는 “번역”만 한다
|
||||
매퍼의 책임은 아래 중 하나다.
|
||||
|
||||
- request DTO -> command
|
||||
- domain -> response DTO
|
||||
- persistence entity -> domain
|
||||
- domain -> persistence entity
|
||||
- external DTO -> internal model
|
||||
- internal model -> external DTO
|
||||
|
||||
즉 “형태를 바꾸는 일”까지만 한다.
|
||||
|
||||
### 2. 비즈니스 규칙은 매퍼에 넣지 않는다
|
||||
다음은 매퍼 책임이 아니다.
|
||||
|
||||
- 상태 전이 결정
|
||||
- 권한 판정
|
||||
- 에러 코드 결정
|
||||
- 정책 선택
|
||||
- 유효성 최종 판정
|
||||
- 도메인 invariant 강제의 주 책임
|
||||
|
||||
단, 단순한 정규화/포맷 수준의 보조 변환은 허용될 수 있다.
|
||||
|
||||
### 3. 매퍼에서 repository / external API / service 호출 금지
|
||||
매퍼는 pure mapping에 가깝게 유지한다.
|
||||
|
||||
금지:
|
||||
- DB 조회
|
||||
- 외부 API 호출
|
||||
- 다른 aggregate 탐색을 위한 repository 호출
|
||||
- 메시지 발행
|
||||
- 파일/네트워크 접근
|
||||
|
||||
매핑에 필요한 부가 데이터가 있으면 호출자가 먼저 준비해서 매퍼에 전달한다.
|
||||
|
||||
### 4. 경계마다 매퍼를 분리한다
|
||||
다음 경계를 하나의 매퍼로 섞지 않는다.
|
||||
|
||||
- web request/response 변환
|
||||
- persistence entity 변환
|
||||
- external integration 변환
|
||||
|
||||
예:
|
||||
- `UserWebMapper`
|
||||
- `UserPersistenceMapper`
|
||||
- `VaultApiMapper`
|
||||
|
||||
처럼 경계별로 분리한다.
|
||||
|
||||
### 5. DTO / Domain / Entity를 직접 섞지 않는다
|
||||
하나의 매퍼가 다음을 동시에 다루며 의미를 섞지 않게 한다.
|
||||
|
||||
- request DTO
|
||||
- domain
|
||||
- JPA entity
|
||||
- external API payload
|
||||
|
||||
필요하면 경계별로 mapper를 여러 개 둔다.
|
||||
|
||||
### 6. 매퍼는 기술 세부보다 구조적 대응 관계를 표현
|
||||
좋은 매퍼는 아래를 명확하게 보여야 한다.
|
||||
|
||||
- 어떤 source를 어떤 target으로 바꾸는가
|
||||
- 어떤 필드가 대응되는가
|
||||
- 어떤 값이 누락되거나 기본값 처리되는가
|
||||
|
||||
반대로 아래는 매퍼에 새기지 않는다.
|
||||
|
||||
- HTTP 상태 코드
|
||||
- DB 트랜잭션
|
||||
- serialization 포맷 정책
|
||||
- retry/fallback 정책
|
||||
|
||||
### 7. update mapping은 명시적으로만
|
||||
기존 target을 수정하는 매핑은 “새로 생성하는 매핑”과 구분한다.
|
||||
|
||||
기본:
|
||||
- create mapping
|
||||
- update mapping
|
||||
|
||||
을 별도 메서드로 둔다.
|
||||
|
||||
기존 객체 갱신은 side effect가 있으므로 이름과 계약을 분명히 한다.
|
||||
|
||||
### 8. null / empty / default 처리 정책을 숨기지 않는다
|
||||
매퍼는 다음을 명확히 해야 한다.
|
||||
|
||||
- null source를 허용하는가
|
||||
- null field를 무시하는가
|
||||
- null이면 target을 덮어쓰는가
|
||||
- empty collection을 그대로 넣는가
|
||||
- 기본값을 넣는가
|
||||
|
||||
정책이 중요하면 호출자/standard 문서에서 먼저 정하고 매퍼에 일관되게 반영한다.
|
||||
|
||||
### 9. mapper는 가능한 한 결정적이고 테스트 가능해야 한다
|
||||
같은 입력이면 같은 결과가 나와야 한다.
|
||||
|
||||
지양:
|
||||
- 현재 시각 생성
|
||||
- 랜덤 값 생성
|
||||
- 환경값 조회
|
||||
- thread-local/MDC 접근
|
||||
|
||||
정말 필요하면 호출자가 값을 주입한다.
|
||||
|
||||
### 10. request binding과 도메인 생성은 구분한다
|
||||
Spring DataBinder/Web binding이 request를 객체로 바꿔 주더라도,
|
||||
그 객체를 domain으로 승격하는 과정은 별도 매퍼/assembler 또는 factory에서 통제한다.
|
||||
|
||||
즉:
|
||||
- web binding = 입력 수집
|
||||
- mapper = 구조 변환
|
||||
- domain factory/value object = 의미/불변식 부여
|
||||
|
||||
### 11. persistence mapper는 DB nullable/오염 상태를 domain으로 직접 흘리지 않는다
|
||||
entity -> domain 매핑에서는:
|
||||
- nullable column
|
||||
- legacy 값
|
||||
- 잘못된 저장 데이터
|
||||
를 명시적으로 처리한다.
|
||||
|
||||
필요하면 예외를 던지거나 복원 규칙을 적용하지만, 조용히 의미를 바꾸지 않는다.
|
||||
|
||||
### 12. external integration mapper는 wire format을 코어에 새기지 않는다
|
||||
외부 API JSON/XML/HTTP payload 구조는 integration 전용 mapper에서 소화한다.
|
||||
core/application/domain은 외부 wire format 세부를 몰라야 한다.
|
||||
|
||||
### 13. 자동 매핑 도구를 써도 책임은 그대로
|
||||
MapStruct 같은 도구를 써도 아래 원칙은 변하지 않는다.
|
||||
|
||||
- 비즈니스 로직을 매퍼에 넣지 않는다
|
||||
- update/create를 구분한다
|
||||
- 경계별 매퍼를 분리한다
|
||||
- null/default 정책을 숨기지 않는다
|
||||
|
||||
도구는 구현 보조일 뿐 설계 기준을 대체하지 않는다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 매퍼는 번역만 한다
|
||||
- 비즈니스 정책/DB 조회/외부 호출 금지
|
||||
- web / persistence / integration 매퍼 분리
|
||||
- create mapping과 update mapping 분리
|
||||
- null/default 정책 명시
|
||||
- 결정적이고 테스트 가능하게 유지
|
||||
- domain 의미 부여와 매핑을 혼동하지 않음
|
||||
@@ -0,0 +1,162 @@
|
||||
# port abstraction 기준
|
||||
|
||||
## 목적
|
||||
|
||||
포트는 “인터페이스를 많이 만들기 위한 수단”이 아니라,
|
||||
애플리케이션 코어가 외부 기술과 직접 결합되지 않도록 **경계를 고정하는 계약**이다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- 포트는 애플리케이션과 외부 세계 사이의 purposeful conversation을 식별하는 계약이다.
|
||||
- 어댑터는 기술별 입력/출력을 포트 계약에 맞게 번역한다.
|
||||
- 헥사고날 아키텍처에서는 코어가 외부 컴포넌트에 의존하지 않고, 외부 컴포넌트가 코어가 정의한 포트에 맞춘다.
|
||||
- 포트는 inbound(입력) / outbound(출력) 성격으로 나눠 볼 수 있다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 포트는 “경계”에만 만든다
|
||||
다음 중 하나가 아니면 포트를 만들지 않는다.
|
||||
|
||||
- application use case 진입 계약
|
||||
- persistence / external API / message broker / file system 같은 외부 의존 경계
|
||||
- 교체 가능한 보안/토큰/암호화/알림 정책 경계
|
||||
- 테스트에서 fake/stub로 바꿔 끼울 가치가 큰 경계
|
||||
|
||||
같은 모듈 내부 helper 호출에는 포트를 만들지 않는다.
|
||||
|
||||
### 2. 코어가 소유하는 포트만 허용
|
||||
포트는 코어(application/domain)가 필요로 하는 계약이어야 한다.
|
||||
|
||||
기본:
|
||||
- inbound port: 코어가 외부 호출자에게 제공하는 use case 계약
|
||||
- outbound port: 코어가 외부 시스템에 요구하는 계약
|
||||
|
||||
금지:
|
||||
- infrastructure 기술 구조에 맞춘 인터페이스를 코어에 올리기
|
||||
- adapter 편의를 위해 포트 모양을 결정하기
|
||||
|
||||
### 3. 포트는 비즈니스 의미로 말하고, 기술 세부를 숨긴다
|
||||
포트 메서드는 “무엇을 원하는가”를 표현해야 한다.
|
||||
|
||||
좋은 방향:
|
||||
- `findUserByEmail`
|
||||
- `saveUser`
|
||||
- `signToken`
|
||||
- `publishUserCreated`
|
||||
|
||||
지양:
|
||||
- `callHttp`
|
||||
- `executeQuery`
|
||||
- `postJson`
|
||||
- `sendKafkaRecord`
|
||||
|
||||
기술 세부는 adapter 구현으로 내린다.
|
||||
|
||||
### 4. 포트 시그니처에 기술 타입을 노출하지 않는다
|
||||
포트 계약에는 가능하면 아래 타입을 직접 노출하지 않는다.
|
||||
|
||||
- `ResponseEntity`
|
||||
- `HttpClient`, `WebClient`
|
||||
- `ResultSet`
|
||||
- `JpaRepository`
|
||||
- `JsonNode`
|
||||
- 프레임워크 request/response 타입
|
||||
|
||||
포트는 domain/application에 더 가까운 command/result/value type으로 표현한다.
|
||||
|
||||
### 5. 포트는 작고 응집도 있게 유지
|
||||
포트 하나는 하나의 역할/대화에 집중해야 한다.
|
||||
|
||||
금지:
|
||||
- unrelated use case를 한 inbound port에 몰아넣기
|
||||
- 여러 외부 시스템 책임을 한 outbound port에 섞기
|
||||
- 너무 범용적인 `CommonPort`, `IntegrationPort`
|
||||
|
||||
### 6. inbound port는 use case 단위로 생각
|
||||
입력 포트는 보통 “사용자가 시스템에 요구하는 행위” 단위로 설계한다.
|
||||
|
||||
예:
|
||||
- 회원 가입
|
||||
- 로그인
|
||||
- 토큰 발급
|
||||
- 공개키 조회
|
||||
|
||||
즉 controller나 scheduler가 직접 서비스 구현을 알기보다, use case 계약을 호출하는 방향을 우선 검토한다.
|
||||
|
||||
### 7. outbound port는 코어가 정말 필요한 능력만 노출
|
||||
출력 포트는 adapter가 할 수 있는 모든 기능이 아니라, 코어가 실제로 필요한 능력만 드러낸다.
|
||||
|
||||
예:
|
||||
- `UserReader` / `UserAppender`
|
||||
- `VaultSigner`
|
||||
- `TokenPublisher`
|
||||
|
||||
금지:
|
||||
- adapter의 내부 옵션/기술 선택지를 포트에 그대로 노출
|
||||
- “혹시 나중에 필요할지도” 모드를 미리 넣기
|
||||
|
||||
### 8. 포트 이름은 역할 중심으로 짓는다
|
||||
좋은 방향:
|
||||
- `UserReader`
|
||||
- `UserSaver`
|
||||
- `TokenSigner`
|
||||
- `AuthLoginUseCase`
|
||||
- `PublicKeyQuery`
|
||||
|
||||
지양:
|
||||
- `UserPort`
|
||||
- `CommonPort`
|
||||
- `InfraPort`
|
||||
- `ExternalApiPort`
|
||||
|
||||
이름만 보고 어떤 대화를 하는지 보여야 한다.
|
||||
|
||||
### 9. 포트 반환값은 없음/실패 의미를 명확히 표현
|
||||
- 단건 조회 없음 -> `Optional<T>` 검토
|
||||
- 다건 조회 없음 -> empty collection
|
||||
- 실패는 예외 또는 명시적 result type으로 표현
|
||||
- null 반환 금지
|
||||
|
||||
### 10. 포트는 transaction/transport를 직접 소유하지 않는다
|
||||
포트 자체는 transaction, HTTP, serialization 정책을 직접 설명하지 않는다.
|
||||
|
||||
기본:
|
||||
- transaction boundary는 application service/use case 쪽에서 결정
|
||||
- transport 형식은 controller/adapter에서 결정
|
||||
- serialization은 adapter에서 처리
|
||||
|
||||
### 11. adapter는 포트를 “구현”하거나 “호출”하면서 번역 책임을 진다
|
||||
- inbound adapter: HTTP, scheduler, message consumer, CLI 등에서 입력을 받아 포트 호출
|
||||
- outbound adapter: DB, external API, queue, cache 등에 맞게 포트를 구현
|
||||
|
||||
adapter는 기술 번역을 담당하지만 business meaning을 새로 만들지 않는다.
|
||||
|
||||
### 12. 테스트 seam이 실제 가치가 있을 때 포트를 둔다
|
||||
포트는 테스트를 쉽게 만들 수 있지만, 테스트 때문에 모든 내부 호출을 포트로 만들지는 않는다.
|
||||
|
||||
기본:
|
||||
- 외부 경계 seam은 포트 우선
|
||||
- 내부 구현 detail seam은 concrete class 유지 가능
|
||||
|
||||
### 13. 포트와 adapter는 1:1일 필요가 없다
|
||||
하나의 포트에 여러 adapter가 붙을 수 있다.
|
||||
예:
|
||||
- mock repository / real repository
|
||||
- REST adapter / batch adapter / test harness
|
||||
|
||||
즉 포트는 기술 구현 수가 아니라 “대화 계약” 기준으로 잡는다.
|
||||
|
||||
### 14. 포트는 framework proxy/AOP 이유만으로 만들지 않는다
|
||||
Spring이 인터페이스 기반 프록시를 잘 지원하더라도, 프록시 가능성만으로 포트를 만들지 않는다.
|
||||
먼저 경계/계약 의미가 있는지 확인한다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 포트는 경계에만 만든다
|
||||
- 포트는 코어가 소유한다
|
||||
- 포트는 비즈니스 의미로 말하고 기술 세부를 숨긴다
|
||||
- inbound는 use case 중심
|
||||
- outbound는 코어가 필요한 능력만
|
||||
- 포트는 작고 응집도 있게
|
||||
- adapter가 기술 번역을 담당
|
||||
- 모든 내부 호출을 포트로 만들지 않는다
|
||||
@@ -0,0 +1,183 @@
|
||||
# value object 기준
|
||||
|
||||
## 목적
|
||||
|
||||
Value Object는 **식별자보다 값 자체가 본질인 도메인 개념**을 표현한다.
|
||||
문자열, 숫자, primitive 조합으로 흩뿌려진 의미를 작은 타입으로 끌어올려,
|
||||
- 의미를 드러내고
|
||||
- 불변식을 한 곳에 모으고
|
||||
- 잘못된 조합을 줄이는 것이 목적이다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- Value Object는 개념적 identity가 없다.
|
||||
- Value Object는 생성 후 immutable하게 다루는 것이 기본이다.
|
||||
- 값이 같으면 서로 interchangeable 하다.
|
||||
- 값 기반 객체는 identity-sensitive 연산(`==`, identity hash, synchronization)에 의존하지 않는다.
|
||||
- `equals` / `hashCode`는 identity가 아니라 상태값 기준이어야 한다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. identity가 아니라 값이 본질이면 Value Object를 우선 검토
|
||||
다음은 Value Object 후보다.
|
||||
|
||||
- 이메일
|
||||
- 사용자 이름
|
||||
- 금액
|
||||
- 통화
|
||||
- 기간
|
||||
- 주소
|
||||
- 토큰 문자열
|
||||
- 공개키 식별자
|
||||
- provider code
|
||||
- 정규화된 path/host/url 일부
|
||||
- 비즈니스 규칙이 붙은 ID wrapper
|
||||
|
||||
질문:
|
||||
- “무엇인가”보다 “어떤 값인가”가 본질인가?
|
||||
- 같은 값이면 같은 것으로 취급해야 하는가?
|
||||
- 생성 시점에 검증/정규화 규칙을 묶고 싶은가?
|
||||
|
||||
### 2. Value Object는 기본적으로 immutable
|
||||
Value Object는 생성 후 상태가 바뀌지 않게 설계한다.
|
||||
|
||||
기본:
|
||||
- final field
|
||||
- setter 없음
|
||||
- 변경이 필요하면 새 인스턴스 반환
|
||||
|
||||
변경 가능한 컬렉션/객체를 내부에 들고 있으면 defensive copy 또는 immutable snapshot을 사용한다.
|
||||
|
||||
### 3. equality는 값 기준
|
||||
Value Object의 동등성은 값으로 판단한다.
|
||||
|
||||
기본:
|
||||
- `equals` / `hashCode` 구현
|
||||
- record를 쓸 수 있으면 record 우선 검토
|
||||
- `==` 비교 금지
|
||||
- identity-based lock/synchronization 금지
|
||||
|
||||
### 4. 생성 시점에 불변식을 강제
|
||||
Value Object는 가능한 한 생성 시점에 유효한 상태만 허용한다.
|
||||
|
||||
예:
|
||||
- `UserEmail.from(...)` 에서 trim/lowercase/형식 검증
|
||||
- `Money.of(...)` 에서 음수 금지/scale 정리
|
||||
- `UserName.from(...)` 에서 길이/문자 규칙 검증
|
||||
|
||||
“일단 넣고 나중에 확인”을 금지한다.
|
||||
|
||||
### 5. primitive obsession을 줄이는 방향으로 도입
|
||||
다음과 같은 경우 Value Object 도입을 우선 검토한다.
|
||||
|
||||
- 같은 `String`이지만 의미가 여러 개라 실수 가능성이 큼
|
||||
- 여러 곳에서 같은 검증/정규화가 반복됨
|
||||
- 메서드 시그니처에서 의미가 안 드러남
|
||||
- 잘못된 값 조합을 타입 수준에서 줄이고 싶음
|
||||
|
||||
### 6. 너무 사소한 래퍼는 만들지 않는다
|
||||
다음은 도입을 보류할 수 있다.
|
||||
|
||||
- 검증/정규화/행위가 전혀 없음
|
||||
- 의미가 너무 자명하고 혼동 위험이 낮음
|
||||
- 래퍼 비용이 실제 이득보다 큼
|
||||
|
||||
즉 모든 primitive를 기계적으로 감싸지 않는다.
|
||||
|
||||
### 7. Value Object는 도메인 언어를 사용
|
||||
이름은 기술 표현이 아니라 business meaning을 드러내야 한다.
|
||||
|
||||
좋은 방향:
|
||||
- `UserEmail`
|
||||
- `Money`
|
||||
- `AuthProviderCode`
|
||||
- `DisplayName`
|
||||
- `TokenTtl`
|
||||
|
||||
지양:
|
||||
- `StringWrapper`
|
||||
- `ValueHolder`
|
||||
- `CommonValue`
|
||||
|
||||
### 8. Value Object는 nullable 대신 명시적 의미를 우선
|
||||
가능하면 Value Object 자체는 non-null로 다룬다.
|
||||
|
||||
부재 표현이 필요하면:
|
||||
- Optional 반환
|
||||
- nullable boundary 입력
|
||||
- 별도 상태 타입
|
||||
- empty/unknown 값을 실제 business state로 둘지 신중히 검토
|
||||
|
||||
null을 Value Object 의미의 일부처럼 쓰지 않는다.
|
||||
|
||||
### 9. Value Object는 DTO/Entity와 분리
|
||||
Value Object는 domain 의미 타입이다.
|
||||
|
||||
기본:
|
||||
- request DTO field를 그대로 Value Object로 바인딩하지 않음
|
||||
- entity field를 그대로 Value Object로 대체하지 않고 매핑 전략을 명시
|
||||
- DTO <-> domain, entity <-> domain 변환에서 Value Object를 생성/복원
|
||||
|
||||
### 10. 컬렉션을 포함하는 Value Object는 특히 신중
|
||||
컬렉션이 들어가는 Value Object는 아래를 만족해야 한다.
|
||||
|
||||
- 컬렉션 자체가 immutable/unmodifiable
|
||||
- 원소도 가능한 한 immutable
|
||||
- equality/hashCode 의미가 분명함
|
||||
- 순서 중요 여부가 명확함
|
||||
|
||||
### 11. 행위가 있어도 된다. 단, 값 의미와 관련된 행위여야 한다
|
||||
Value Object는 단순 data carrier일 필요는 없다.
|
||||
|
||||
허용 예:
|
||||
- 정규화
|
||||
- 포맷 변환
|
||||
- 비교
|
||||
- 계산
|
||||
- 조합
|
||||
- 규칙 기반 convenience method
|
||||
|
||||
금지 예:
|
||||
- repository 호출
|
||||
- 외부 API 호출
|
||||
- 전역 상태 의존
|
||||
- 객체 그래프 조립의 중심이 되는 orchestration
|
||||
|
||||
### 12. persistence는 domain 의미를 우선하되 별도 매핑으로 해결
|
||||
JPA entity는 persistence 제약을 받으므로 Value Object와 1:1로 같아야 할 필요는 없다.
|
||||
|
||||
기본:
|
||||
- entity <-> domain mapper에서 Value Object 생성/복원
|
||||
- 가능하면 embeddable/owned type 등 적절한 persistence 모델 사용 검토
|
||||
- persistence 편의 때문에 domain Value Object를 포기하지 않음
|
||||
|
||||
### 13. record는 좋은 기본 선택지일 수 있다
|
||||
Java record는 값 중심 타입 표현에 잘 맞을 수 있다.
|
||||
단, 아래를 만족할 때 사용한다.
|
||||
|
||||
- 불변 구조가 자연스럽다
|
||||
- 값 기반 equality가 맞다
|
||||
- 생성 시 검증/정규화를 canonical constructor/factory로 명확히 표현할 수 있다
|
||||
|
||||
단, record를 쓴다고 자동으로 좋은 Value Object가 되는 것은 아니다.
|
||||
|
||||
### 14. Value Object는 작은 타입이지만 경계 비용을 줄여야 한다
|
||||
도입 후 얻는 이득:
|
||||
- 의미가 타입에 드러남
|
||||
- 검증 중복 감소
|
||||
- 잘못된 조합 감소
|
||||
- 테스트 용이성 증가
|
||||
|
||||
단, 의미 없는 래퍼 남발은 금지한다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- identity보다 값이 본질이면 Value Object 우선 검토
|
||||
- 기본은 immutable
|
||||
- equality는 값 기준
|
||||
- 생성 시점에 불변식 강제
|
||||
- primitive obsession 줄이기
|
||||
- 너무 사소한 래퍼는 지양
|
||||
- DTO/Entity와 분리
|
||||
- persistence는 mapper/별도 매핑 전략으로 해결
|
||||
- record는 좋은 선택지일 수 있으나 자동 정답은 아님
|
||||
@@ -0,0 +1,300 @@
|
||||
# External API Client Structure 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API 호출용 client 구조와 책임 분리를 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 외부 연동 코드를 application/domain에서 분리한다
|
||||
- HTTP client 선택 기준을 일관되게 만든다
|
||||
- request/response DTO, mapper, exception translation 위치를 명확히 한다
|
||||
- 관측 가능성, 설정, 인증 헤더 주입, 공통 customization을 한곳에 모은다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 지원 방식 위에 일반적인 실무 연동 구조를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 외부 API client는 infrastructure adapter다
|
||||
|
||||
Spring은 RestClient, WebClient, HTTP Service Client를 모두 “원격 HTTP 서비스 호출” 도구로 제공한다. 즉, 이들은 비즈니스 로직이 아니라 외부 시스템 경계 접근 수단이다. 이 프로젝트에서는 외부 API client를 기본적으로 infrastructure 레이어의 adapter로 본다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API client는 infrastructure/integration adapter에 둔다
|
||||
- application/domain이 RestClient, WebClient, ResponseEntity, HTTP status, provider-specific DTO를 직접 다루지 않는다
|
||||
- 외부 호출은 port/adapter 경계를 통해 사용한다
|
||||
|
||||
### 3.2 외부 API 연동 구조의 기본 단위는 “adapter + DTO + mapper + translation”이다
|
||||
|
||||
Spring이 HTTP client 자체는 제공하지만, 어떤 DTO를 어떻게 매핑하고 어떤 예외로 번역할지는 애플리케이션이 책임져야 한다. 따라서 이 프로젝트는 외부 API client를 단순한 HTTP 호출 클래스가 아니라 연동 adapter 패키지로 다룬다.
|
||||
|
||||
프로젝트 기본 구조:
|
||||
|
||||
- client adapter
|
||||
- external request DTO
|
||||
- external response DTO
|
||||
- mapper / translator
|
||||
- provider-specific exception translation
|
||||
- 설정(properties / builder customization)
|
||||
|
||||
### 3.3 외부 연동 계약과 내부 모델은 분리한다
|
||||
|
||||
Spring의 message conversion과 HTTP service interface는 DTO를 손쉽게 직렬화/역직렬화해 주지만, 그것이 곧 외부 DTO를 내부 모델처럼 써도 된다는 뜻은 아니다. 이 프로젝트에서는 외부 API request/response DTO를 내부 application/domain 모델과 분리한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API request/response DTO는 provider contract 전용 타입이다
|
||||
- application/domain은 외부 DTO를 직접 알지 않는다
|
||||
- adapter 경계에서 내부 command/result 또는 domain 값으로 변환한다
|
||||
|
||||
## 4. HTTP client 선택 기준
|
||||
|
||||
### 4.1 imperative 애플리케이션 기본값은 RestClient
|
||||
|
||||
Spring Boot는 비리액티브 애플리케이션이면 RestClient 또는 RestTemplate를 사용할 수 있다고 설명하고, Spring Framework는 RestClient를 동기식 fluent API 로 설명하며, RestTemplate는 현재 RestClient 쪽이 더 권장되는 방향이라고 명시한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 Spring MVC / imperative 애플리케이션의 외부 HTTP 호출 기본값은 RestClient
|
||||
- 새 코드에서 RestTemplate를 기본 선택지로 두지 않는다
|
||||
- 동기 블로킹 호출이 자연스러운 use case에는 RestClient를 우선한다
|
||||
|
||||
### 4.2 reactive 애플리케이션 또는 진짜 non-blocking 경계에는 WebClient
|
||||
|
||||
Spring Boot는 WebFlux 기반 non-blocking reactive 애플리케이션이면 WebClient 사용을 권장한다. WebClient는 fully reactive client이며, Boot는 WebClient.Builder를 미리 구성해서 제공한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 애플리케이션 자체가 reactive이거나, non-blocking end-to-end 흐름이 필요한 경우 WebClient
|
||||
- 단순히 최신 API라는 이유만으로 imperative 서비스에 WebClient를 기본 도입하지 않는다
|
||||
- reactive client를 도입할 때는 호출부부터 반환 타입, backpressure, timeout 모델까지 함께 고려한다
|
||||
|
||||
### 4.3 HTTP Service Client는 선언적 계약이 분명할 때 허용한다
|
||||
|
||||
Spring은 @HttpExchange, @GetExchange, @PostExchange 등으로 정의한 인터페이스에 프록시를 붙이는 HTTP Service Client를 공식 지원하고, Boot는 이를 import하고 group으로 묶는 기능도 제공한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API 계약이 안정적이고 메서드 시그니처가 선언적으로 잘 드러나는 경우 HTTP Service Client 허용
|
||||
- 다만 복잡한 동적 요청 조립, 세밀한 에러 처리, 낮은 수준의 HTTP 제어가 많으면 RestClient/WebClient를 우선 검토한다
|
||||
- 선언형 인터페이스를 쓰더라도 adapter 경계와 DTO 분리 규칙은 그대로 유지한다
|
||||
|
||||
## 5. Builder / 공통 구성 규칙
|
||||
|
||||
### 5.1 Boot가 자동 구성한 builder를 주입해서 사용한다
|
||||
|
||||
Spring Boot는 WebClient.Builder와 RestClient.Builder를 prototype bean으로 자동 구성하고, 이를 주입해 사용하는 것을 강하게 권장한다. Boot가 제공하는 builder를 사용해야 HTTP resource 공유, codec 반영, 적절한 request factory, 그리고 관측/계측이 함께 적용된다. RestClient.create()를 직접 쓰면 auto-configuration과 customizer 적용이 따라오지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- RestClient.Builder / WebClient.Builder는 주입받아 사용한다
|
||||
- RestClient.create() / WebClient.builder()를 코드 곳곳에서 직접 호출하는 것을 기본 금지한다
|
||||
- 공통 관측, SSL, codec, 인증 헤더, timeout 설정을 우회하지 않는다
|
||||
|
||||
### 5.2 공통 customization은 builder/customizer/group에 둔다
|
||||
|
||||
Spring Boot는 RestClientCustomizer, WebClient.Builder, SSL bundle 적용, HTTP Service client group 등을 통해 공통 구성을 모을 수 있다고 설명한다. HTTP Service group은 URL뿐 아니라 timeout, SSL, auth customization 같은 공통 특성을 공유할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- base URL, timeout, SSL, 공통 header, user-agent, auth header 삽입은 공통 구성으로 관리
|
||||
- client마다 같은 interceptor/filter/header 삽입 로직을 복붙하지 않는다
|
||||
- provider 단위의 공통 설정은 group 또는 전용 configuration으로 묶는다
|
||||
|
||||
### 5.3 builder는 “전역 기본값 + 클라이언트별 좁은 추가 설정” 구조로 쓴다
|
||||
|
||||
Spring Boot 문서는 RestClient.Builder customization은 범위를 좁게 적용할수록 좋고, builder가 stateful이므로 필요하면 clone을 고려하라고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 전역 공통값은 customizer/configuration
|
||||
- 특정 provider에만 필요한 설정은 그 adapter 구성 지점에서 추가
|
||||
- 하나의 builder를 여러 외부 시스템에 무비판적으로 뒤섞어 쓰지 않는다
|
||||
|
||||
## 6. 패키지 / 타입 구조 규칙
|
||||
|
||||
### 6.1 provider별 또는 capability별로 구조를 분리한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 시스템이 다르면 패키지를 분리한다
|
||||
- 하나의 외부 시스템 안에서도 계약이 크면 capability 단위로 나눌 수 있다
|
||||
|
||||
권장 예:
|
||||
|
||||
```text
|
||||
integration/keycloak/...
|
||||
integration/payment/...
|
||||
integration/email/...
|
||||
```
|
||||
|
||||
또는
|
||||
|
||||
```text
|
||||
integration/keycloak/token/...
|
||||
integration/keycloak/user/...
|
||||
```
|
||||
|
||||
### 6.2 한 adapter는 한 외부 계약 또는 한 capability를 담당한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 하나의 client class가 외부 시스템 전체를 거대한 god client처럼 다루지 않는다
|
||||
- 토큰 발급, 사용자 조회, 세션 폐기처럼 책임이 다르면 분리한다
|
||||
- 다만 지나치게 잘게 쪼개서 공통 설정이 흩어지지 않게 provider 구성과 capability 구성을 함께 본다
|
||||
|
||||
### 6.3 외부 DTO, 내부 결과, 매퍼를 분리한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- *Request, *Response는 외부 계약용 DTO
|
||||
- *Result, *Command, *FailureReason 등은 내부용 모델
|
||||
- DTO → 내부 결과 변환은 mapper/translator가 담당
|
||||
- application/domain은 외부 JSON 필드명과 provider-specific enum을 모른다
|
||||
|
||||
## 7. 인증 / 헤더 / URL 규칙
|
||||
|
||||
### 7.1 base URL은 코드 하드코딩이 아니라 설정 기반으로 둔다
|
||||
|
||||
Spring Boot는 HTTP Service groups에서 logical name과 property 기반 URL lookup을 사용하는 방향을 설명하며, absolute URL 하드코딩은 production에 이상적이지 않다고 말한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- base URL은 properties/configuration으로 관리
|
||||
- 코드 안 https://... 하드코딩을 기본 금지
|
||||
- 환경별 URL 차이는 설정으로 해결한다
|
||||
|
||||
### 7.2 인증 헤더 삽입은 adapter 공통 레이어에서 처리한다
|
||||
|
||||
Spring 문서는 RestClient에 default header, interceptor, request initializer를 둘 수 있고, HTTP Service group에도 authorization header 삽입 같은 customization을 연결할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Authorization, API key, user-agent, correlation header는 공통 client 구성에서 삽입
|
||||
- business 로직에서 매번 header를 조립하지 않는다
|
||||
- 토큰 갱신/획득 로직도 provider adapter 경계에 둔다
|
||||
|
||||
### 7.3 URI template와 path variable을 우선 사용한다
|
||||
|
||||
Spring Framework는 RestClient, WebClient, RestTemplate가 URI template와 URI builder를 지원한다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- string concatenation으로 URL을 만들지 않는다
|
||||
- path/query 조립은 template / builder 방식으로 처리한다
|
||||
- query parameter 의미가 드러나게 작성한다
|
||||
|
||||
## 8. 반환 / 예외 / 번역 규칙
|
||||
|
||||
### 8.1 adapter는 ResponseEntity, raw status, client exception을 그대로 위로 올리지 않는다
|
||||
|
||||
Spring의 client는 HTTP status, body, exception을 직접 다룰 수 있지만, application/domain이 그 디테일을 그대로 보게 두면 외부 계약이 내부 계층으로 번진다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- adapter는 내부 결과 타입 또는 port 계약 타입을 반환한다
|
||||
- application은 WebClientResponseException, HttpStatusCodeException, ClientResponse 같은 타입을 직접 다루지 않는다
|
||||
- HTTP status 해석은 adapter 안에서 끝낸다
|
||||
|
||||
### 8.2 provider-specific 실패는 integration exception으로 번역한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 401/403/404/409/5xx를 그대로 application에 노출하지 않는다
|
||||
- provider-specific error body는 integration exception 또는 내부 failure reason으로 번역한다
|
||||
- 예외 번역 상세 규칙은 별도 exception-translation.md에서 source of truth로 둔다
|
||||
|
||||
### 8.3 2xx만 성공으로 보는 단순 규칙을 넘어서 provider 계약을 해석한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- HTTP 200이어도 business failure payload이면 실패로 번역할 수 있다
|
||||
- 반대로 일부 4xx가 provider 계약상 “정상적인 부재/중복 상태”라면 내부 의미로 적절히 번역한다
|
||||
- 성공/실패 판정 기준은 provider contract 단위로 명시한다
|
||||
|
||||
## 9. DTO / 직렬화 규칙
|
||||
|
||||
### 9.1 외부 요청/응답 DTO는 provider contract에 맞춘다
|
||||
|
||||
Spring은 RestClient, WebClient, HTTP Service Client 모두 message conversion으로 DTO를 JSON과 매핑한다. 이 DTO는 provider JSON 계약에 맞춰야 하며, 내부 표준 DTO와 동일할 필요가 없다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 JSON 필드명은 외부 DTO에서만 해결한다
|
||||
- provider-specific field naming, enum, optionality는 외부 DTO에 국소화한다
|
||||
- 내부 모델 필드명을 외부 계약 때문에 바꾸지 않는다
|
||||
|
||||
### 9.2 외부 응답 파싱 정책은 first-party API보다 더 lenient할 수 있다
|
||||
|
||||
Spring/Jackson 조합은 DTO 역직렬화를 유연하게 지원한다. 외부 시스템은 필드 추가/응답 shape 변화가 일어날 수 있으므로, third-party response DTO는 first-party API request DTO보다 lenient 정책을 택할 수 있다. 이 세부 기준은 별도 serialization 문서에서 다루되, external client 구조에서도 이 방향을 따른다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 response DTO는 unknown field 허용 가능
|
||||
- 외부 request DTO는 provider 요구에 맞춰 엄격하게 작성
|
||||
- 내부 domain/application DTO와 정책을 섞지 않는다
|
||||
|
||||
## 10. 관측 가능성 규칙
|
||||
|
||||
### 10.1 외부 API client는 관측 가능해야 한다
|
||||
|
||||
Spring Boot Actuator는 RestTemplate, WebClient, RestClient의 HTTP client instrumentation을 지원하고, 이를 위해 auto-configured builder를 사용하라고 설명한다. Spring Framework observability 문서는 기본 저카디널리티 키로 method, uri template, client.name, status, outcome, error를 정의한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API client는 auto-configured builder를 통해 관측 가능성을 확보한다
|
||||
- metrics/traces/logs에서 최소한 client.name, method, uri template, status, error를 추적 가능하게 한다
|
||||
- raw full URL과 payload 전문을 로그 기본값으로 남기지 않는다
|
||||
|
||||
### 10.2 URI template를 유지한다
|
||||
|
||||
Spring observability 문서는 low cardinality key로 uri template를 쓰고, host/port를 제외한 template 개념을 사용한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 호출 관측에서는 가능한 한 URI template를 유지한다
|
||||
- /users/123 같은 실제 path 대신 /users/{id} 같은 템플릿이 추적 가능하게 한다
|
||||
- 메트릭 태그에 고카디널리티 path를 그대로 쓰지 않는다
|
||||
|
||||
## 11. 문서 간 경계
|
||||
|
||||
이 문서는 구조와 책임 분리를 다룬다. 아래 주제의 세부 규칙은 별도 문서를 source of truth로 둔다.
|
||||
|
||||
- timeout
|
||||
- retry
|
||||
- idempotency
|
||||
- serialization/deserialization
|
||||
- fallback
|
||||
- exception translation
|
||||
|
||||
이 문서는 위 주제들을 다시 처음부터 반복하지 않고, 외부 API client 구조 안에서 어디에 둘지만 정의한다.
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- controller/application/domain에서 RestClient/WebClient 직접 호출
|
||||
- RestClient.create() / WebClient.builder()를 여기저기서 직접 생성
|
||||
- base URL 하드코딩
|
||||
- 외부 DTO를 내부 application/domain 메서드 시그니처에 그대로 전달
|
||||
- ResponseEntity, raw status code, client exception을 그대로 내부에 전파
|
||||
- giant external client 하나에 모든 provider capability 몰아넣기
|
||||
- 인증 헤더/공통 header를 business code에서 매번 조립
|
||||
- provider payload 전문을 기본 로그로 남기기
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 외부 연동 코드는 infrastructure adapter에 위치하는가?
|
||||
- 애플리케이션 성격에 맞게 RestClient/WebClient/HTTP Service Client를 선택했는가?
|
||||
- auto-configured builder를 주입해 사용하고 있는가?
|
||||
- base URL, auth, timeout, SSL, 공통 customization이 공통 구성에 모여 있는가?
|
||||
- 외부 request/response DTO와 내부 모델이 분리되어 있는가?
|
||||
- adapter가 provider-specific HTTP 디테일을 내부로 누수시키지 않는가?
|
||||
- 관측 가능성(metrics/traces/logs)이 확보되어 있는가?
|
||||
@@ -0,0 +1,318 @@
|
||||
# Fallback 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API / integration 호출 실패 시 fallback을 어떻게 적용할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- fallback을 retry와 구분한다
|
||||
- 어떤 외부 의존성을 soft dependency로 바꿀 수 있는지 판단 기준을 만든다
|
||||
- fallback 결과가 비즈니스 의미를 왜곡하지 않게 한다
|
||||
- 캐시, 정적 기본값, 제한된 기능, 비동기 전환 같은 대체 전략을 일관되게 사용한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Cloud CircuitBreaker, Resilience4j, AWS Well-Architected 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 graceful degradation 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 fallback은 retry의 연장이 아니라 별도 결과 전략이다
|
||||
|
||||
Spring Cloud CircuitBreaker는 원래 실행 코드와 별도로 fallback function을 받으며, 실패 시 그 fallback이 실행된다고 설명합니다. Resilience4j도 fallback method를 try/catch와 유사한 대체 경로로 설명합니다. 즉 fallback은 “한 번 더 시도”가 아니라 “다른 결과 경로”입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- retry는 원래 호출을 다시 시도하는 것
|
||||
- fallback은 원래 호출을 포기하고 다른 결과를 내는 것
|
||||
- 두 개념을 한 문서/한 코드 블록에서 섞어 흐리게 만들지 않는다
|
||||
|
||||
### 3.2 fallback은 hard dependency를 soft dependency로 바꿀 수 있을 때만 사용한다
|
||||
|
||||
AWS Well-Architected는 graceful degradation을 통해 적용 가능한 hard dependency를 soft dependency로 바꾸라고 권장합니다. 의존성이 unhealthy해도 컴포넌트가 degraded mode로 계속 동작할 수 있어야 한다는 뜻입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback은 “이 의존성이 없어도 핵심 기능이 여전히 의미 있게 동작하는가?”를 먼저 묻는다
|
||||
- 핵심 정합성/보안/결제 확정처럼 정답이 아니면 안 되는 기능에는 fallback을 기본 금지한다
|
||||
- 보조 기능, 부가 정보, 랭킹, 추천, 프로필 부가 데이터, 캐시 가능한 조회는 fallback 후보가 될 수 있다
|
||||
|
||||
### 3.3 fallback은 실패를 숨기지 말고 degraded mode를 명시해야 한다
|
||||
|
||||
Spring Cloud CircuitBreaker의 fallback은 예외를 받아 대체 결과를 만들 수 있습니다. AWS도 degraded response를 반환하되, 그것이 대체 응답이라는 사실을 이해하고 설계해야 한다고 보는 맥락입니다. 즉 fallback은 “조용히 다른 값을 넣기”가 아니라 품질 저하 상태를 의도적으로 선택하는 것입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback 결과는 내부적으로 추적 가능해야 한다
|
||||
- 운영 로그/메트릭에서 fallback 발생 여부를 구분할 수 있어야 한다
|
||||
- 호출자가 알아야 하는 degraded semantics를 숨기지 않는다
|
||||
|
||||
## 4. 언제 fallback을 허용하는가
|
||||
|
||||
### 4.1 허용 가능한 대표 경우
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 fallback 후보가 될 수 있다.
|
||||
|
||||
- 외부 추천 시스템 실패 시 빈 추천 목록 반환
|
||||
- 외부 프로필 부가 정보 실패 시 핵심 프로필만 반환
|
||||
- 외부 공개키/JWK 조회 실패 시 짧은 TTL 캐시값 사용
|
||||
- 외부 feature flag 조회 실패 시 안전한 기본값 사용
|
||||
- 외부 알림 발송 실패 시 outbox 적재 후 비동기 재시도 전환
|
||||
- 외부 검색/랭킹 실패 시 기본 정렬 결과 반환
|
||||
|
||||
이 방향은 AWS가 말하는 graceful degradation, 즉 정적 응답·사전 결정된 대체 응답으로 hard dependency를 soft dependency로 바꾸는 사고와 맞습니다.
|
||||
|
||||
### 4.2 기본적으로 허용하지 않는 경우
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 기본적으로 fallback을 금지한다.
|
||||
|
||||
- 결제 승인/캡처/환불 확정
|
||||
- 인증/인가의 핵심 판정
|
||||
- 비밀번호 변경/토큰 발급/보안 민감 작업
|
||||
- 재고 차감/정산 반영/회원 상태 확정
|
||||
- 법적/감사적 정합성이 필요한 기록 확정
|
||||
- “성공처럼 보이면 안 되는” 핵심 command
|
||||
|
||||
이 경우는 graceful degradation보다 명시적 실패가 더 안전하다. AWS의 graceful degradation도 모든 dependency를 soft dependency로 바꾸라는 뜻은 아니며, “applicable hard dependencies”에 한정합니다.
|
||||
|
||||
## 5. fallback 종류
|
||||
|
||||
### 5.1 정적 기본값 fallback
|
||||
|
||||
AWS는 predetermined static response를 fallback 예시로 듭니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 추천 없음 → 빈 리스트
|
||||
- 부가 배지 없음 → 빈 값
|
||||
- 외부 설명문 없음 → 기본 문구
|
||||
|
||||
처럼 명백히 안전한 기본값만 허용
|
||||
|
||||
핵심 비즈니스 의미를 바꾸는 가짜 성공값은 금지
|
||||
|
||||
### 5.2 캐시 기반 fallback
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 최근 성공 응답을 짧은 TTL로 캐시해 두고 외부 장애 시 사용 가능
|
||||
- 단, stale 허용 범위가 문서화돼야 한다
|
||||
- 캐시 fallback은 조회성 데이터에 우선 적용한다
|
||||
- 오래된 데이터를 최신 사실처럼 보이게 하면 안 된다
|
||||
|
||||
### 5.3 기능 축소(degraded mode) fallback
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 부가 서비스가 죽으면 핵심 기능만 제공
|
||||
- 예:
|
||||
- “프로필 상세 + 외부 배지” → “프로필 상세만”
|
||||
- “개인화 추천 + 일반 목록” → “일반 목록만”
|
||||
- 기능 축소 후에도 결과 의미가 일관돼야 한다
|
||||
|
||||
### 5.4 비동기 전환 fallback
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 동기 호출 실패 시 즉시 실패 대신 outbox/queue 적재 후 비동기 처리로 전환할 수 있다
|
||||
- 예:
|
||||
- 이메일 전송 요청 → “접수됨” 응답 후 비동기 발송
|
||||
- 단, 이 경우 API 의미가 “즉시 완료”가 아니라 “접수”로 바뀌므로 계약이 명확해야 한다
|
||||
|
||||
### 5.5 fallback 없이 명시적 실패
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback이 어색하거나 의미를 왜곡하면 실패가 정답이다
|
||||
- “fallback이 없으면 덜 우아해 보인다”는 이유로 억지 fallback을 두지 않는다
|
||||
- 실패가 더 정직한 경우에는 실패를 택한다
|
||||
|
||||
## 6. 설계 규칙
|
||||
|
||||
### 6.1 fallback 결과는 원래 결과와 같은 의미를 가장하지 않는다
|
||||
|
||||
Spring Cloud CircuitBreaker fallback은 예외를 받아 대체 결과를 리턴할 수 있지만, 그 결과가 원래 외부 호출 성공과 동일한 의미를 가진다고 보장하지는 않습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback 응답을 “정상 외부 응답”처럼 위장하지 않는다
|
||||
- 내부 result 모델에서 degraded 여부를 표현할 수 있으면 표현한다
|
||||
- API 바깥으로 드러나야 하는 경우에는 metadata/flag로 구분 가능하게 한다
|
||||
|
||||
### 6.2 fallback은 provider-specific 예외보다 내부 의미로 판단한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
fallback 조건은 SocketTimeoutException, WebClientResponseException 같은 저수준 타입 그 자체보다
|
||||
|
||||
- ExternalProfileTemporaryFailure
|
||||
- RecommendationProviderUnavailable
|
||||
|
||||
같은 내부 번역 예외 기준으로 두는 편을 선호한다
|
||||
|
||||
adapter가 provider 예외를 먼저 번역하고, 상위 integration service가 fallback 여부를 판단할 수 있다
|
||||
|
||||
### 6.3 fallback은 조용한 데이터 오염을 만들면 안 된다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 캐시 fallback은 stale 가능성을 고려한다
|
||||
- 기본값 fallback은 진짜 부재와 fallback 결과를 혼동시키지 않는다
|
||||
- 외부 검증 실패를 내부 성공으로 바꾸는 fallback을 금지한다
|
||||
|
||||
## 7. 위치 규칙
|
||||
|
||||
### 7.1 fallback은 integration adapter 바로 위 또는 integration service 경계에 둔다
|
||||
|
||||
Spring Cloud CircuitBreaker fallback은 wrapped supplier를 대체하는 함수로 붙습니다. 실무적으로도 fallback은 외부 호출 의미를 가장 잘 아는 integration 경계에 두는 것이 맞습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback은 external client adapter 바로 위의 integration service에서 우선 검토
|
||||
- controller/application/domain에 provider-aware fallback 로직을 두지 않는다
|
||||
- domain이 fallback 존재를 알아야 하는 구조를 기본 금지한다
|
||||
|
||||
### 7.2 controller에서 fallback 결과를 직접 조립하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 fallback 여부를 판단하는 위치가 아니다
|
||||
- 외부 의존성 실패와 대체 전략은 integration 경계에서 끝낸다
|
||||
- controller는 최종 내부 result만 받아 응답으로 번역한다
|
||||
|
||||
## 8. Circuit Breaker와의 관계
|
||||
|
||||
### 8.1 fallback은 circuit breaker와 함께 쓰일 수 있다
|
||||
|
||||
Spring Cloud CircuitBreaker는 fallback function을 공식 지원하고, OpenFeign + CircuitBreaker 문서도 fallback class를 둘 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 회로 차단기와 fallback을 함께 사용하는 것은 허용
|
||||
- 단, circuit open 상태라고 항상 fallback이 정답인 것은 아니다
|
||||
- “실패를 빠르게 차단”과 “대체 결과 제공”은 별도 결정으로 본다
|
||||
|
||||
### 8.2 circuit open fallback과 단일 호출 실패 fallback을 구분한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일시적 단일 실패에서의 fallback
|
||||
- circuit open 상태에서의 fallback
|
||||
- 은 운영 의미가 다를 수 있다
|
||||
- observability에서는 이 둘을 구분할 수 있어야 한다
|
||||
|
||||
## 9. cache/staleness 규칙
|
||||
|
||||
### 9.1 캐시 fallback은 staleness budget이 있어야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 캐시 fallback은 “얼마나 오래된 값을 허용할지”가 먼저 정해져야 한다
|
||||
- 무기한 stale fallback 금지
|
||||
- provider 데이터 성격에 따라 허용 TTL을 문서화한다
|
||||
|
||||
### 9.2 stale 데이터는 최신 사실처럼 취급하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 프로필, 환율, 추천, 재고성 정보는 stale일 수 있다
|
||||
- stale 허용이 어려운 정보에는 캐시 fallback을 두지 않는다
|
||||
- stale 사용 사실이 내부적으로 추적 가능해야 한다
|
||||
|
||||
## 10. observability 규칙
|
||||
|
||||
### 10.1 fallback 발생은 반드시 관측 가능해야 한다
|
||||
|
||||
AWS의 graceful degradation은 장애 시 soft dependency로 동작을 바꾸는 것이므로, 운영자는 fallback이 언제 얼마나 발생하는지 알아야 합니다. Spring Cloud CircuitBreaker fallback도 Throwable을 인자로 받아 원인과 함께 처리할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
fallback 발생 로그/메트릭을 남긴다
|
||||
|
||||
최소한 다음을 구분 가능해야 한다
|
||||
|
||||
- provider
|
||||
- operation
|
||||
- fallback type(정적/캐시/비동기 전환 등)
|
||||
- 원인 예외
|
||||
- 최종 결과(success degraded / fail)
|
||||
|
||||
### 10.2 fallback 후 성공은 ERROR로 기록하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback이 적용돼 요청이 의미 있게 처리됐다면 기본 WARN 또는 INFO
|
||||
- 최종 실패만 대표 ERROR
|
||||
- fallback 성공을 장애처럼 과장하지 않는다
|
||||
- 다만 fallback 비율이 높아지면 경고 신호로 본다
|
||||
|
||||
### 10.3 fallback 비율은 SLO/품질 지표로 본다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 성공률만 보지 않고 fallback rate도 본다
|
||||
- “서비스는 성공했지만 품질은 저하된 상태”를 따로 추적한다
|
||||
- fallback이 많으면 upstream 문제 또는 timeout/retry 설정 문제를 의심한다
|
||||
|
||||
## 11. 예외와 응답 규칙
|
||||
|
||||
### 11.1 fallback 결과가 있으면 예외를 그대로 밖으로 던지지 않는다
|
||||
|
||||
Resilience4j fallback도 실패를 대체 결과로 바꾸는 구조입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback이 최종 결과를 제공하면 외부 예외를 그대로 상위 계층에 올리지 않는다
|
||||
- 대신 degraded result를 반환한다
|
||||
- 원인 예외는 observability에 남긴다
|
||||
|
||||
### 11.2 fallback이 불가능하면 실패를 번역해 올린다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback이 적용되지 않거나 의미가 없으면 integration exception으로 번역해 올린다
|
||||
- “fallback도 실패했는데 기본값으로 성공처럼 처리”를 금지한다
|
||||
|
||||
## 12. 다른 문서와의 경계
|
||||
|
||||
이 문서는 fallback만 다룬다.
|
||||
아래 주제의 source of truth는 별도 문서다.
|
||||
|
||||
- timeout
|
||||
- retry
|
||||
- outbound idempotency
|
||||
- serialization/deserialization
|
||||
- exception translation
|
||||
|
||||
이 문서는 위 내용을 반복하지 않고, 외부 의존성 실패 시 어떤 대체 결과를 허용할지만 정의한다.
|
||||
|
||||
## 13. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 핵심 정합성/보안/결제 확정에 억지 fallback 적용
|
||||
- fallback 결과를 원래 정상 결과처럼 위장
|
||||
- stale 데이터 무기한 사용
|
||||
- controller에서 provider-aware fallback 구현
|
||||
- fallback 발생을 관측하지 않음
|
||||
- fallback으로 실패를 전부 숨김
|
||||
- provider 저수준 예외 타입에 강하게 결합된 fallback 분기
|
||||
- 캐시/정적 기본값이 비즈니스 의미를 왜곡하는데도 사용
|
||||
|
||||
## 14. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 외부 의존성은 soft dependency로 바꿔도 되는가?
|
||||
- fallback 결과가 비즈니스 의미를 왜곡하지 않는가?
|
||||
- fallback 종류(정적/캐시/기능 축소/비동기 전환)가 명확한가?
|
||||
- stale 허용 범위가 문서화돼 있는가?
|
||||
- fallback 발생이 로그/메트릭에서 관측 가능한가?
|
||||
- fallback 성공을 실패처럼 ERROR로 과장하지 않는가?
|
||||
- controller/application/domain이 아니라 integration 경계에 fallback이 있는가?
|
||||
@@ -0,0 +1,316 @@
|
||||
# Integration Idempotency 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API / integration 호출에서 outbound idempotency 를 어떻게 다룰지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 외부 provider가 제공하는 idempotency 기능을 안전하게 사용한다
|
||||
- timeout, partial failure, 응답 유실 상황에서 중복 side effect 를 막는다
|
||||
- 우리 내부 idempotency key와 provider idempotency key의 관계를 명확히 한다
|
||||
- 외부 API 재시도 시 어떤 조건에서 같은 key를 재사용해야 하는지 정한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: IETF HTTPAPI draft, Stripe, PayPal 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 규약 위에 일반적인 연동 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 이 문서는 inbound가 아니라 outbound idempotency를 다룬다
|
||||
|
||||
IETF 초안은 Idempotency-Key를 클라이언트가 서버에 보내는 중복 방지 키로 설명한다. 우리 서비스가 provider를 호출할 때는, 우리가 그 provider 입장에서 “클라이언트”가 된다. 따라서 이 문서는 “사용자가 우리 API를 다시 호출하는 상황”이 아니라, “우리가 외부 provider에 같은 요청을 다시 보내는 상황”을 다룬다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- inbound idempotency와 outbound idempotency를 같은 문서로 뒤섞지 않는다
|
||||
- 이 문서는 provider 호출용 키 생성/재사용/저장/오류 처리 규칙만 정의한다
|
||||
|
||||
### 3.2 outbound idempotency의 핵심 목적은 “같은 외부 side effect를 한 번만 일으키는 것”이다
|
||||
|
||||
Stripe는 생성/수정 요청에 idempotency key를 사용하면 연결 오류나 응답 유실이 있어도 같은 요청을 안전하게 반복할 수 있다고 설명한다. PayPal도 POST 호출에서 PayPal-Request-Id를 사용하면 서버가 중복 생성/처리를 피할 수 있다고 설명한다. 즉 outbound idempotency는 “같은 외부 요청 의도”를 다시 보내더라도 provider 쪽에서 한 번만 처리되게 만드는 장치다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 생성/확정/발급/결제/전송 같은 side effect 호출에는 outbound idempotency를 기본 검토한다
|
||||
- “응답을 못 받았으니 다시 보내자” 상황에서 중복 side effect가 나지 않아야 한다
|
||||
|
||||
### 3.3 retry와 outbound idempotency는 함께 설계한다
|
||||
|
||||
Stripe는 네트워크 오류가 나더라도 같은 idempotency key를 써서 다시 보내면 중복 생성 위험을 줄일 수 있다고 설명한다. 반대로 key 없이 같은 POST를 다시 보내면 중복 호출이 될 수 있다. PayPal도 PayPal-Request-Id를 생략하면 요청이 중복될 수 있다고 설명한다. 따라서 retry는 outbound idempotency와 분리해서 설계할 수 없다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- side effect가 있는 외부 API retry는 outbound idempotency 검토 없이 자동화하지 않는다
|
||||
- timeout 이후 retry 전략은 반드시 provider idempotency 지원 여부와 함께 본다
|
||||
|
||||
## 4. 언제 필요한가
|
||||
|
||||
### 4.1 기본 검토 대상
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 outbound idempotency 기본 검토 대상이다.
|
||||
|
||||
- 결제 승인/확정/캡처
|
||||
- 토큰/세션/쿠폰/번호 발급
|
||||
- 이메일/SMS/웹훅 발송 요청
|
||||
- 외부 시스템에 리소스를 생성하는 POST
|
||||
- 외부 상태를 irreversible 하게 바꾸는 요청
|
||||
- timeout 이후 retry 가능성이 높은 provider 호출
|
||||
|
||||
Stripe와 PayPal의 공식 idempotency 문서도 이런 POST 중심 side effect 요청을 주된 대상으로 설명한다.
|
||||
|
||||
### 4.2 기본 검토 대상이 아닌 경우
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 outbound idempotency header를 기본값으로 요구하지 않는다.
|
||||
|
||||
- 단순 GET 조회
|
||||
- provider가 이미 HTTP 의미상 idempotent한 PUT/DELETE만 제공하는 경우
|
||||
- 읽기 전용 상태 확인 API
|
||||
- side effect가 없는 health/ping/check API
|
||||
|
||||
Stripe도 GET/DELETE에는 idempotency key를 보내도 의미가 없다고 안내한다.
|
||||
|
||||
## 5. provider key와 내부 key의 관계
|
||||
|
||||
### 5.1 provider가 공식 idempotency key를 지원하면 그 계약을 우선 따른다
|
||||
|
||||
Stripe는 Idempotency-Key 헤더를, PayPal은 PayPal-Request-Id 헤더를 공식 지원한다. PayPal은 API call type마다 고유해야 한다고도 설명한다. 따라서 provider가 지원하는 공식 키 규약이 있으면 그 규약을 먼저 따른다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider 공식 idempotency header가 있으면 그 이름과 제약을 그대로 따른다
|
||||
- 우리 내부 표준 헤더 이름을 provider에 억지로 강요하지 않는다
|
||||
- adapter가 provider별 차이를 캡슐화한다
|
||||
|
||||
### 5.2 내부 idempotency key와 provider idempotency key는 같을 수도, 다를 수도 있다
|
||||
|
||||
IETF 초안과 Stripe 문서는 key를 클라이언트가 생성하는 고유 값으로 설명하지만, 실제 운영에서는 우리 내부 command id 와 provider 전송용 key 를 같은 값으로 쓸지 별도 매핑할지 설계 선택이 있다. PayPal은 API call type 단위 고유성을 요구하므로, 단순히 “사용자 요청 ID 하나”를 모든 provider operation에 그대로 쓰는 방식은 맞지 않을 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 내부 command id와 provider key를 1:1로 매핑할 수는 있다
|
||||
- 하지만 provider가 operation scope를 다르게 요구하면 별도 provider key를 만든다
|
||||
- 내부 키와 provider 키를 무조건 동일시하지 않는다
|
||||
|
||||
권장 예:
|
||||
|
||||
- 내부 키: outboundCommandId
|
||||
- provider 키: (provider, operation, outboundCommandId) 기반 생성
|
||||
|
||||
### 5.3 provider key scope는 provider 계약을 따른다
|
||||
|
||||
PayPal은 PayPal-Request-Id가 “요청마다 그리고 API call type마다” 고유해야 한다고 설명한다. Stripe도 endpoint와 파라미터가 다르면 idempotency error가 난다고 설명한다. 즉 key scope는 provider마다 다를 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 key를 다른 provider operation에 재사용하지 않는다
|
||||
- 같은 provider라도 다른 endpoint/call type에 key 재사용 여부를 provider 계약 기준으로 판단한다
|
||||
- scope는 최소한 provider + operation + key 수준으로 본다
|
||||
|
||||
## 6. 키 생성 규칙
|
||||
|
||||
### 6.1 키는 우리가 생성한다
|
||||
|
||||
Stripe는 V4 UUID 또는 충분한 entropy를 가진 랜덤 문자열을 권장하고, 민감정보를 key로 쓰지 말라고 말한다. PayPal도 UUID 사용을 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider key는 우리 서비스가 생성한다
|
||||
- 권장 형식은 UUID v4 또는 이에 준하는 opaque random string
|
||||
- 이메일, 전화번호, 사용자명, 주문번호 같은 의미 있는 PII를 key에 넣지 않는다
|
||||
|
||||
### 6.2 키는 “같은 외부 요청 의도”에만 재사용한다
|
||||
|
||||
Stripe는 동일 key에 대해 원래 요청과 들어온 파라미터를 비교하고, 다르면 에러를 반환한다고 설명한다. 따라서 key는 장기 식별자가 아니라 같은 요청의 재전송용 식별자 여야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 provider 호출을 다시 보낼 때만 같은 key를 재사용한다
|
||||
- 요청 의미가 달라지면 새 key를 생성한다
|
||||
- key를 “사용자별 고정 키”처럼 쓰지 않는다
|
||||
|
||||
## 7. 저장 규칙
|
||||
|
||||
### 7.1 outbound provider 호출에도 내부적으로 key 매핑 기록을 남긴다
|
||||
|
||||
Stripe와 PayPal은 provider 측 idempotency를 제공하지만, 우리 서비스가 timeout/partial failure를 겪었을 때 “이 key로 이미 보냈는가, 응답을 받았는가, 재전송해야 하는가”를 판단하려면 내부 기록이 필요하다. 공식 문서들도 provider가 이전 요청의 결과나 최신 상태를 반환한다고 설명하므로, 우리 쪽에서도 그 연관관계를 추적해야 운영이 가능하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
내부적으로 다음을 기록할 수 있어야 한다
|
||||
|
||||
- provider
|
||||
- operation
|
||||
- provider idempotency key
|
||||
- 내부 command id
|
||||
- request fingerprint
|
||||
- provider request status(시도 중/완료/최종 실패)
|
||||
- provider response reference
|
||||
- provider가 idempotency를 제공해도 우리 내부 기록을 완전히 생략하지 않는다
|
||||
|
||||
### 7.2 request fingerprint를 함께 저장한다
|
||||
|
||||
Stripe는 같은 key 재사용 시 들어온 파라미터를 원래 요청과 비교해 다르면 에러를 낸다고 설명한다. 우리도 내부적으로 같은 key가 다른 요청 의미로 재사용되지 않았는지 확인할 수 있어야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 내부 저장소에는 key뿐 아니라 request fingerprint도 함께 둔다
|
||||
- fingerprint는 provider operation 의미를 기준으로 계산한다
|
||||
- 같은 key + 다른 fingerprint는 버그 또는 오용으로 본다
|
||||
|
||||
## 8. 재전송 규칙
|
||||
|
||||
### 8.1 timeout/응답 유실 시에는 같은 key로 재전송한다
|
||||
|
||||
Stripe는 네트워크 연결 오류로 응답을 못 받아도 같은 key로 재시도하면 안전하다고 설명한다. PayPal도 동일한 PayPal-Request-Id를 다시 보내면 이전 요청의 최신 상태를 반환한다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider에 요청을 보냈지만 응답을 못 받았으면 같은 key 재전송을 기본 검토한다
|
||||
- 새 key로 다시 보내는 것을 기본값으로 두지 않는다
|
||||
- 이 판단은 retry/timeout 정책과 함께 묶어서 설계한다
|
||||
|
||||
### 8.2 provider가 “실행이 시작되지 않았다”고 말한 경우는 새 시도로 볼 수 있다
|
||||
|
||||
Stripe는 validation 실패나 concurrent conflict처럼 endpoint 실행이 시작되지 않은 경우에는 결과를 저장하지 않으며, 이런 경우는 다시 시도할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider가 execution not started에 해당하는 오류를 명시하면 같은 key 재시도 가능성을 검토한다
|
||||
- validation 자체가 잘못된 요청이라면 재시도보다 요청 수정이 우선이다
|
||||
- “실행이 시작되지 않았음”과 “응답만 못 받음”을 구분한다
|
||||
|
||||
### 8.3 동시 중복 송신을 피한다
|
||||
|
||||
PayPal은 같은 PayPal-Request-Id로 동시에 두 요청을 보내면 첫 번째를 처리하고 두 번째는 실패할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 provider key를 가진 outbound 호출은 동시에 두 개 이상 송신하지 않는다
|
||||
- 내부적으로 키 단위 동시성 제어를 검토한다
|
||||
- 같은 command를 여러 worker가 동시에 처리하는 구조라면 key-level dedup/lock을 둔다
|
||||
|
||||
## 9. provider 응답 해석 규칙
|
||||
|
||||
### 9.1 replay 응답은 새 성공과 같은 의미로 취급하되, 출처는 구분 가능해야 한다
|
||||
|
||||
Stripe는 같은 key에 대해 첫 결과의 status와 body를 재사용한다고 설명하고, PayPal은 이전 요청의 최신 상태를 반환한다고 설명한다. 즉, provider가 반환한 응답이 “새로 실행된 결과”인지 “기존 실행의 재생/현재 상태”인지 내부적으로는 구분할 수 있는 편이 좋다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider replay 응답도 비즈니스적으로는 성공/실패 결과로 받아들인다
|
||||
- 다만 내부 observability에는
|
||||
- new execution
|
||||
- replayed result
|
||||
- latest known status
|
||||
- 를 구분할 수 있게 한다
|
||||
- 외부 API 응답 body를 우리 내부 의미로 무조건 “새로 생성됨”으로 번역하지 않는다
|
||||
|
||||
### 9.2 provider의 “latest status”와 “original result” 차이를 이해한다
|
||||
|
||||
PayPal은 이전 요청의 “원래 응답”이 아니라 “현재 시점의 최신 상태”를 반환한다고 설명한다. Stripe는 첫 실행 결과를 재사용하는 쪽에 더 가깝다. provider마다 의미가 다르므로, outbound adapter는 이 차이를 내부로 올바르게 번역해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider replay semantics를 문서화한다
|
||||
- “같은 key면 항상 동일 body 재생”이라고 일반화하지 않는다
|
||||
- provider별로
|
||||
- original response replay
|
||||
- latest status lookup
|
||||
- concurrent duplicate failure
|
||||
- 를 구분한다
|
||||
|
||||
## 10. TTL 규칙
|
||||
|
||||
### 10.1 provider TTL을 존중한다
|
||||
|
||||
Stripe는 키를 최소 24시간 이후 정리할 수 있다고 설명한다. PayPal은 일부 API에서 PayPal-Request-Id 보관 기간이 정해져 있고, 그동안 재시도 가능하다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider key TTL은 provider 공식 문서 기준을 따른다
|
||||
- TTL 내 재전송은 같은 key 사용
|
||||
- TTL 이후는 새 요청으로 처리될 수 있음을 전제로 한다
|
||||
|
||||
### 10.2 내부 기록 TTL은 provider TTL보다 짧게 두지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 내부 key 매핑 기록 TTL은 provider TTL 이상을 기본 검토한다
|
||||
- provider는 아직 기억하는데 우리는 잊어버리는 상태를 만들지 않는다
|
||||
- 최소한 “왜 같은 key가 다시 쓰였는지” 추적 가능한 기간을 확보한다
|
||||
|
||||
## 11. observability 규칙
|
||||
|
||||
### 11.1 outbound idempotency key는 로그에 원문 전체를 남기지 않는다
|
||||
|
||||
Stripe는 key에 민감정보를 넣지 말라고 하지만, 그렇다고 로그에 원문 전체를 항상 남겨도 된다는 뜻은 아니다. 외부 키도 운영 식별자일 뿐 민감도 없는 공개값으로 취급하지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider key 원문 전체 로그를 기본 금지
|
||||
- 필요하면 prefix 또는 내부 correlation id만 남긴다
|
||||
- 로그에는
|
||||
- provider
|
||||
- operation
|
||||
- outboundCommandId
|
||||
- providerRequestId
|
||||
- 정도의 내부 식별자를 우선 사용한다
|
||||
|
||||
### 11.2 replay / duplicate / key mismatch는 관측 가능해야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
outbound idempotency 관련 운영 이벤트는 최소한 다음을 구분 가능해야 한다
|
||||
|
||||
- 새 호출
|
||||
- 같은 key 재전송
|
||||
- provider replay 응답
|
||||
- 같은 key 다른 fingerprint 충돌
|
||||
- 동시 중복 송신 차단
|
||||
- retry와 idempotency를 함께 분석할 수 있어야 한다
|
||||
|
||||
## 12. 다른 문서와의 경계
|
||||
|
||||
이 문서는 outbound idempotency만 다룬다.
|
||||
아래 주제의 source of truth는 별도 문서다.
|
||||
|
||||
- retry
|
||||
- timeout
|
||||
- fallback
|
||||
- serialization/deserialization
|
||||
- exception translation
|
||||
|
||||
이 문서는 위 주제들을 다시 반복하지 않고, 외부 provider idempotency를 어떻게 써야 하는지만 정의한다.
|
||||
|
||||
## 13. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- provider 공식 idempotency key 지원이 있는데 무시하고 새 요청처럼 재전송
|
||||
- 같은 key를 다른 provider operation에 재사용
|
||||
- 같은 key를 다른 fingerprint 요청에 재사용
|
||||
- timeout 후 새 key로 같은 side effect 요청 재전송
|
||||
- provider key와 내부 command 추적 관계를 저장하지 않음
|
||||
- 같은 key의 동시 중복 송신 허용
|
||||
- key에 이메일/전화번호/주문자명 같은 의미 있는 PII 사용
|
||||
- replay semantics가 다른 provider를 같은 규칙으로 단순화
|
||||
|
||||
## 14. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 outbound 호출은 side effect가 있어 idempotency가 필요한가?
|
||||
- provider가 공식 idempotency key/header를 지원하는가?
|
||||
- 내부 key와 provider key의 scope가 명확한가?
|
||||
- timeout/응답 유실 시 같은 key 재전송 전략이 정의되어 있는가?
|
||||
- 같은 key의 fingerprint 충돌을 감지할 수 있는가?
|
||||
- provider TTL과 내부 저장 TTL이 정렬되어 있는가?
|
||||
- replay/new/latest-status semantics를 provider별로 구분하고 있는가?
|
||||
- retry와 idempotency가 함께 관측 가능한가?
|
||||
@@ -0,0 +1,292 @@
|
||||
# Retry 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API / integration 호출에서 retry를 어떻게 적용할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- retry 대상을 일시적 실패로 제한한다
|
||||
- retry가 장애를 증폭시키지 않게 한다
|
||||
- retry, timeout, idempotency, fallback의 책임을 구분한다
|
||||
- 외부 HTTP 호출 retry를 adapter 경계 안에서 일관되게 처리한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Retry / AWS / Google Cloud 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 retry는 기본값이 아니라 제한적 도구다
|
||||
|
||||
Spring은 retry 기능을 제공하지만, Spring Framework resilience 문서 기준 기본 retry는 모든 예외를 대상으로 최대 3회 재시도하며 1초 간격을 둡니다. 즉, 프레임워크 기본값만 믿으면 너무 넓게 재시도할 수 있습니다. AWS도 retry는 유용하지만, 부하가 높은 상황에서는 서버에 더 많은 요청을 보내 상황을 악화시킬 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API retry는 명시적으로 허용한 경우에만 사용한다
|
||||
- 기본 retry 정책에 의존하지 않는다
|
||||
- retry를 “일단 켜 두는 안정화 옵션”으로 쓰지 않는다
|
||||
|
||||
### 3.2 retry는 timeout, idempotency, fallback과 분리해서 설계한다
|
||||
|
||||
AWS는 timeout, retry, backoff를 별도 도구로 설명하고, side effect가 있는 API는 retry 전에 idempotency 가 중요하다고 말합니다. Google Cloud도 non-idempotent operation retry를 anti-pattern으로 경고합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- timeout은 “얼마나 기다릴지”
|
||||
- retry는 “다시 시도할지”
|
||||
- idempotency는 “다시 시도해도 안전한지”
|
||||
- fallback은 “재시도 후에도 실패하면 대체 경로가 있는지”
|
||||
- 를 각각 따로 판단한다
|
||||
|
||||
### 3.3 retry는 외부 연동 경계(adapter)에 둔다
|
||||
|
||||
Spring이 RestClient, WebClient, HTTP Service Client 같은 외부 호출 도구를 제공하는 만큼, retry도 외부 연동 경계에서 결정하는 것이 자연스럽습니다. application/domain이 provider-specific HTTP 오류나 retry 대상 예외를 직접 다루는 구조는 피하는 것이 좋습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 HTTP retry는 integration adapter/client 경계에서 처리한다
|
||||
- application/domain이 SocketTimeoutException, WebClientResponseException, ConnectException 분류를 직접 하지 않는다
|
||||
- retry 이후 최종 실패만 내부 예외로 번역한다
|
||||
|
||||
## 4. 언제 retry하는가
|
||||
|
||||
### 4.1 retry 대상은 “일시적 실패”다
|
||||
|
||||
AWS는 retry가 부분 실패, 일시적 네트워크 문제, 순간적인 과부하 같은 상황에 유용하다고 설명합니다. Google Cloud도 retry는 response criteria와 idempotency criteria를 동시에 만족 하는 요청에만 적용하라고 안내합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 retry 후보가 될 수 있다.
|
||||
|
||||
- connection timeout / connection reset
|
||||
- read timeout / response timeout
|
||||
- 일시적 5xx
|
||||
- 일시적 429
|
||||
- 네트워크 단절/짧은 DNS/TLS 실패
|
||||
- provider가 transient failure로 명시한 에러
|
||||
|
||||
### 4.2 retry하지 않는 대상
|
||||
|
||||
Google Cloud는 retrying unretryable errors, non-idempotent operation retry를 anti-pattern으로 명시합니다. Spring 기본 retry는 모든 예외를 재시도할 수 있으므로, 이 프로젝트에서는 반드시 대상을 좁혀야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 기본적으로 retry하지 않는다.
|
||||
|
||||
- 4xx business 오류
|
||||
- 인증 실패(401/403)
|
||||
- 잘못된 요청 형식(400)
|
||||
- provider 계약 위반
|
||||
- validation/parsing 오류
|
||||
- deterministic failure
|
||||
- side effect가 있는데 idempotency가 보장되지 않는 호출
|
||||
|
||||
### 4.3 404/409 같은 상태는 provider 계약에 따라 해석한다
|
||||
|
||||
Spring의 HTTP client는 status를 읽을 수 있지만, 어떤 status를 transient로 볼지는 provider 계약이 결정합니다. 일부 409/404는 정상적인 부재/중복 의미일 수 있고, 일부 429/503은 transient일 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- retry 가능 여부는 HTTP status 숫자만 으로 결정하지 않는다
|
||||
- provider 계약 문서와 실제 운영 의미를 함께 본다
|
||||
- 같은 provider 안에서는 status 해석 기준을 문서화한다
|
||||
|
||||
## 5. retry 전제조건
|
||||
|
||||
### 5.1 retry 전에는 idempotency 가능성을 확인한다
|
||||
|
||||
AWS는 side effect가 있는 API는 timeout/partial failure 이후 재시도 시 중복 side effect가 생길 수 있으므로 idempotent API 설계 가 중요하다고 설명합니다. Google Cloud도 non-idempotent operation retry를 anti-pattern으로 지적합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- side effect 없는 read 호출은 retry 허용을 더 쉽게 검토한다
|
||||
- side effect 있는 write 호출은
|
||||
- provider idempotency support
|
||||
- 우리 쪽 idempotency key
|
||||
- 중복 실행 허용 여부
|
||||
- 를 먼저 확인한다
|
||||
- 이 검토 없이 자동 retry를 켜지 않는다
|
||||
|
||||
### 5.2 retry는 timeout이 먼저 있어야 의미가 있다
|
||||
|
||||
timeout이 없으면 호출이 오래 붙잡힌 채 retry까지 가지 못하고, AWS도 timeout을 원격 호출의 기본 안전장치로 설명합니다. retry는 timeout과 함께 설계되어야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- retry가 있는 외부 API 호출에는 timeout이 먼저 정의되어 있어야 한다
|
||||
- timeout 없이 retry만 정의하는 것을 금지한다
|
||||
- retry 문서는 timeout 문서와 함께 읽는 것을 전제로 한다
|
||||
|
||||
## 6. backoff 규칙
|
||||
|
||||
### 6.1 즉시 반복 retry를 금지한다
|
||||
|
||||
Google Cloud는 retry without backoff 를 대표 anti-pattern으로 지적합니다. AWS도 retry는 backoff와 함께 써야 하고, 그렇지 않으면 부하를 폭증시킬 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 자동 retry는 backoff 없이 즉시 연속 재시도하지 않는다
|
||||
- 최소한 exponential backoff를 기본으로 한다
|
||||
- “짧게 여러 번 때리면 되겠지”를 금지한다
|
||||
|
||||
### 6.2 jitter를 기본으로 한다
|
||||
|
||||
AWS는 retries and backoff with jitter를 공식적으로 설명하고, 동시 재시도로 인한 thundering herd를 줄이기 위해 jitter가 중요하다고 강조합니다. Google Cloud도 exponential backoff with jitter를 일반적으로 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- backoff에는 jitter를 기본 포함한다
|
||||
- 같은 장애 시점에 모든 인스턴스가 같은 간격으로 동시에 재시도하지 않게 한다
|
||||
- jitter 없는 fixed backoff를 기본값으로 두지 않는다
|
||||
|
||||
### 6.3 backoff는 무한히 커지지 않게 상한을 둔다
|
||||
|
||||
AWS는 exponential backoff를 설명하면서도 상한을 두고, 전체 retry budget 안에서 움직이게 설계해야 한다고 말합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- initial interval
|
||||
- multiplier
|
||||
- max interval
|
||||
- max attempts
|
||||
- 를 모두 명시한다
|
||||
- 무한 증가형 backoff를 금지한다
|
||||
|
||||
## 7. retry 횟수 규칙
|
||||
|
||||
### 7.1 짧고 보수적인 max attempts를 기본으로 한다
|
||||
|
||||
Spring Framework 기본 retry는 최대 3회 재시도입니다. Google Cloud는 unnecessarily layering retries를 anti-pattern으로 지적합니다. 이 프로젝트도 외부 HTTP 호출은 짧고 보수적인 횟수를 기본으로 둡니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API 자동 retry 기본값은 짧게
|
||||
- 일반 권장 시작점:
|
||||
- 총 시도 2~3회 수준
|
||||
- “10번까지 해보자” 같은 공격적 retry를 기본 금지한다
|
||||
|
||||
### 7.2 상위/하위 레이어 retry 중복을 금지한다
|
||||
|
||||
Google Cloud는 unnecessarily layering retries 를 anti-pattern으로 지적합니다. client library, gateway, adapter, application service가 모두 retry하면 실제 요청 수가 폭증할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 한 호출 경로에서 대표 retry layer를 정한다
|
||||
- provider SDK가 이미 retry를 하면 우리 adapter retry를 다시 올리지 않는다
|
||||
- gateway / SDK / client / application retry가 겹치지 않게 한다
|
||||
|
||||
## 8. 기술 선택 규칙
|
||||
|
||||
### 8.1 선언적 retry와 프로그래밍식 retry를 구분한다
|
||||
|
||||
Spring은 @Retryable 애노테이션과 RetryPolicy/RetryTemplate 계열을 제공하고, Spring Framework 7 resilience 기능도 method-level retry를 지원합니다. @Retryable은 간단하지만 기본적으로 프록시 기반이고, 대상/횟수/backoff를 명시적으로 설정해야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순한 adapter method 재시도에는 선언적 retry 허용
|
||||
- 복잡한 분기, 일부 코드 블록만 재시도, 동적 정책은 프로그래밍식 retry 우선
|
||||
- 어떤 방식을 쓰든 retry 대상 예외와 backoff를 명시한다
|
||||
|
||||
### 8.2 새 코드의 기본 선택은 provider/client 구조에 맞춰 명시적으로 한다
|
||||
|
||||
Spring 자체는 retry 기능을 제공하지만, Boot의 외부 client 구조와 결합할 때는 “어디에 적용할지”가 더 중요합니다. integration adapter 단위의 retry가 가장 기본이며, Spring Cloud CircuitBreaker/Resilience4j 같은 도구가 있다면 공통 정책화도 가능합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- retry는 external client adapter 또는 그 바로 위 integration service에 둔다
|
||||
- controller/application/domain에 retry 애노테이션을 붙이지 않는다
|
||||
- 공통 라이브러리를 쓰더라도 source of truth는 프로젝트 문서다
|
||||
|
||||
## 9. observability 규칙
|
||||
|
||||
### 9.1 retry는 관측 가능해야 한다
|
||||
|
||||
AWS는 retry가 장애를 완화할 수도 있지만 반대로 악화시킬 수도 있으므로, retry 동작을 이해할 수 있어야 한다고 설명합니다. Spring/Boot의 HTTP client instrumentation과 함께 retry attempt, provider, operation, 최종 결과를 추적 가능하게 해야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
retry 로그/메트릭에는 최소한 다음을 남긴다
|
||||
|
||||
- provider/client name
|
||||
- operation
|
||||
- attempt number
|
||||
- final outcome
|
||||
- error type
|
||||
- retry가 있었는지, 몇 번 있었는지, 결국 성공/실패했는지 구분 가능해야 한다
|
||||
|
||||
### 9.2 최종 실패만 대표 ERROR로 남긴다
|
||||
|
||||
AWS/Google Cloud가 경고하는 retry storm와 중복 부하 문제를 고려하면, 중간 실패를 모두 ERROR로 남기면 운영 신호가 오염됩니다. 최종 실패만 대표 ERROR, 중간 실패는 WARN 또는 DEBUG가 기본입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 중간 retry 실패: WARN 또는 DEBUG
|
||||
- retry 후 성공: WARN 또는 INFO
|
||||
- retry 후 최종 실패: 대표 ERROR
|
||||
|
||||
## 10. provider 계약과의 관계
|
||||
|
||||
### 10.1 Retry-After 같은 provider 신호를 존중한다
|
||||
|
||||
HTTP/공급자 문서가 retry 간격이나 throttling 신호를 주는 경우, 그 신호를 우선 고려하는 것이 일반적 운영 원칙입니다. Google Cloud도 response criteria를 보고 retry해야 한다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider가 Retry-After 또는 throttling 가이드를 주면 우선 따른다
|
||||
- 로컬 backoff 정책이 provider 신호와 충돌하지 않게 한다
|
||||
- provider rate limit 계약을 무시한 retry를 금지한다
|
||||
|
||||
### 10.2 provider별 retryable 오류 목록을 문서화한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
각 주요 외부 시스템별로
|
||||
|
||||
- retryable status
|
||||
- retryable exception
|
||||
- non-retryable business error
|
||||
|
||||
를 문서화한다
|
||||
|
||||
코드 안 산발적인 if status == 503 식 분기를 줄인다
|
||||
|
||||
## 11. 이 문서와 다른 문서의 경계
|
||||
|
||||
이 문서는 외부 API retry만 다룬다. 아래 주제의 source of truth는 별도 문서다.
|
||||
|
||||
- timeout
|
||||
- idempotency
|
||||
- fallback
|
||||
- exception translation
|
||||
- serialization/deserialization
|
||||
|
||||
이 문서는 위 내용을 반복하지 않고, 외부 연동 retry에서 어디까지 함께 고려해야 하는지만 정의한다.
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 모든 예외 retry
|
||||
- backoff 없는 즉시 재시도
|
||||
- jitter 없는 고정 간격 재시도 기본값
|
||||
- side effect API를 idempotency 검토 없이 자동 retry
|
||||
- SDK + gateway + adapter + application 중복 retry
|
||||
- retry 대상 아닌 4xx/business error retry
|
||||
- retry가 있는데 timeout이 없음
|
||||
- retry 동작이 관측되지 않음
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 실패는 정말 transient인가?
|
||||
- 이 호출은 retry해도 안전한가, 특히 idempotent한가?
|
||||
- timeout이 먼저 정의되어 있는가?
|
||||
- retry 대상 예외/status가 명시되어 있는가?
|
||||
- backoff와 jitter가 있는가?
|
||||
- max attempts가 짧고 보수적인가?
|
||||
- 상위/하위 레이어 retry 중복이 없는가?
|
||||
- retry attempt와 최종 결과가 관측 가능한가?
|
||||
@@ -0,0 +1,279 @@
|
||||
# Integration Serialization / Deserialization 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API / integration 호출에서 request serialization과 response deserialization 기준을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- provider 계약과 내부 모델을 분리한다
|
||||
- 외부 payload 변화에 대한 내성을 높인다
|
||||
- media type, 필드명, null/absent, 에러 바디, 날짜/시간 포맷을 일관되게 처리한다
|
||||
- serialization concern이 application/domain으로 번지지 않게 한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot / Jackson 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 Tolerant Reader 같은 실무 패턴을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 외부 serialization은 provider contract가 결정한다
|
||||
|
||||
Spring의 REST client는 HTTP 본문을 상위 Java 객체로 읽고 쓰며, JSON 외에도 application/x-www-form-urlencoded, multipart, byte array, XML 같은 형식을 converter로 다룹니다. 따라서 외부 연동 직렬화 기준은 우리 내부 JSON 취향이 아니라 provider가 요구하는 content type, field shape, wire format 이 먼저다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider가 JSON을 요구하면 JSON으로
|
||||
- provider가 form-urlencoded를 요구하면 form으로
|
||||
- provider가 XML/byte/binary를 요구하면 그 형식으로 보낸다
|
||||
- “우리 서비스는 JSON 표준이 있으니 외부도 JSON으로 통일”을 금지한다
|
||||
|
||||
### 3.2 외부 DTO와 내부 모델은 반드시 분리한다
|
||||
|
||||
Spring client가 DTO 변환을 쉽게 해 준다고 해서, provider DTO를 내부 모델처럼 써도 된다는 뜻은 아니다. Tolerant Reader 관점에서도 payload를 읽는 코드는 한 경계에 모아야 나머지 시스템이 변경에 덜 민감해진다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 request DTO / response DTO는 provider 계약 전용 타입
|
||||
- application/domain은 외부 DTO를 직접 모른다
|
||||
- adapter 경계에서 내부 command/result/failure reason으로 변환한다
|
||||
|
||||
### 3.3 읽기는 느슨하게, 쓰기는 명시적으로 한다
|
||||
|
||||
Jackson의 ignoreUnknown=true는 외부 응답이 필드를 추가해도 파싱을 덜 깨뜨리게 해 준다. 반면 outbound request는 provider가 받지 않는 필드를 보내거나, null과 absent를 헷갈리게 보내면 계약 오류가 생긴다. 따라서 외부 연동에서는 response는 tolerant reader, request는 explicit writer 전략을 기본으로 둔다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 response DTO는 additive change에 관대할 수 있다
|
||||
- 외부 request DTO는 보내는 필드를 명시적으로 통제한다
|
||||
- 내부 객체를 그대로 직렬화해서 provider에 보내지 않는다
|
||||
|
||||
## 4. Request serialization 표준
|
||||
|
||||
### 4.1 outbound request는 provider 계약에 정확히 맞춘다
|
||||
|
||||
Spring REST client는 DTO를 바탕으로 본문을 직렬화하지만, 실제 field name과 media type은 adapter가 정해야 한다. Jackson의 @JsonProperty는 외부 필드명을 DTO 경계에서 맞추는 공식 수단이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider field name mismatch는 외부 request DTO에서 해결한다
|
||||
- 내부 필드명/도메인 용어를 provider 계약에 맞춰 바꾸지 않는다
|
||||
- provider-specific enum/string/value shape를 request DTO에 국소화한다
|
||||
|
||||
### 4.2 request DTO는 allowlist 방식으로 설계한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider에 보낼 필드만 request DTO에 둔다
|
||||
- 내부 계산값, 디버그 값, 서버 내부 상태를 request DTO에 섞지 않는다
|
||||
- “언젠가 쓸 수 있으니 같이 보내자”를 금지한다
|
||||
|
||||
### 4.3 null과 absent는 provider 계약 기준으로 명시한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider가 null과 필드 omission을 다르게 해석하면 반드시 구분한다
|
||||
- 기본 정책은 “의미가 다르면 DTO와 mapper에서 명시적으로 처리”
|
||||
- 전역 NON_NULL 같은 설정으로 provider별 의미를 무심코 바꾸지 않는다
|
||||
|
||||
### 4.4 media type은 명시적으로 맞춘다
|
||||
|
||||
Spring converter는 JSON, form, multipart, byte array, XML 등을 지원한다. 외부 연동에서는 특히 OAuth/token 발급, webhook, 파일 업로드, binary download처럼 JSON이 아닌 형식이 흔하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- application/json을 기본 추정값으로 두지 않는다
|
||||
- provider가 요구하는 Content-Type과 Accept를 adapter에서 명시한다
|
||||
- form 요청은 JSON DTO를 억지로 보내지 않는다
|
||||
|
||||
## 5. Response deserialization 표준
|
||||
|
||||
### 5.1 external response DTO는 tolerant reader를 기본 검토한다
|
||||
|
||||
Jackson의 @JsonIgnoreProperties(ignoreUnknown = true)는 인식하지 못한 필드를 deserialization에서 무시한다. Fowler의 Tolerant Reader도 producer가 필드를 추가해도 consumer가 덜 깨지도록 payload reading을 느슨하게 설계하라고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- third-party response DTO는 ignoreUnknown=true를 기본 검토한다
|
||||
- provider가 필드를 추가해도 우리 파싱이 즉시 깨지지 않게 한다
|
||||
- 단, first-party API request DTO까지 이 정책을 일반화하지 않는다
|
||||
|
||||
### 5.2 success body와 error body를 분리한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 성공 응답 DTO와 오류 응답 DTO를 따로 둔다
|
||||
- provider error JSON을 success DTO에 억지로 파싱하지 않는다
|
||||
- error body는 adapter가 읽고 내부 failure reason/exception으로 번역한다
|
||||
|
||||
### 5.3 raw Map/JsonNode는 마지막 수단이다
|
||||
|
||||
Spring/Jackson은 상위 객체 매핑과 custom deserializer를 지원한다. 따라서 외부 응답 구조가 완전히 동적이지 않다면 typed DTO가 기본이다. raw map/tree는 계약이 너무 불안정하거나 일부 필드만 읽을 때의 마지막 수단으로 본다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본은 typed response DTO
|
||||
- 정말 불안정한 payload만 JsonNode/Map 허용
|
||||
- raw tree를 application/domain까지 들고 가지 않는다
|
||||
- boundary에서 읽고 안정적인 내부 모델로 바꾼다
|
||||
|
||||
### 5.4 외부 enum은 바로 domain enum에 연결하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider enum/string 값은 외부 DTO 또는 mapper 단계에서 해석한다
|
||||
- domain enum에 provider 값을 직접 박아 넣지 않는다
|
||||
- provider가 새 enum 값을 추가할 수 있으면 UNKNOWN/기본 처리 전략을 둔다
|
||||
|
||||
## 6. 날짜/시간/숫자 규칙
|
||||
|
||||
### 6.1 날짜/시간 형식은 provider 계약을 따른다
|
||||
|
||||
Jackson의 JavaTimeModule은 java.time 타입을 지원하고, timestamps 기능이 꺼져 있으면 보통 ISO-8601 문자열을 사용한다. 하지만 외부 연동에서는 provider가 epoch millis, string, custom format 중 무엇을 쓰는지가 더 중요하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider가 ISO-8601을 쓰면 Instant/OffsetDateTime 등으로 명시적으로 읽는다
|
||||
- provider가 epoch number를 쓰면 그 계약을 DTO/커스텀 deserializer에서 처리한다
|
||||
- 내부 표준 시간 타입을 provider wire format 때문에 오염시키지 않는다
|
||||
|
||||
### 6.2 숫자/정밀도는 domain 의미를 잃지 않게 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 금액, 환율, 정산 수치처럼 정밀도가 중요한 값은 double을 기본값으로 두지 않는다
|
||||
- provider가 문자열 금액을 보내면 문자열 → 안전한 내부 수치 타입으로 변환한다
|
||||
- 숫자 파싱 실패는 provider parsing failure로 다루고 domain 예외와 섞지 않는다
|
||||
|
||||
## 7. Jackson / mapper / module 규칙
|
||||
|
||||
### 7.1 메서드 안에서 new ObjectMapper()를 만들지 않는다
|
||||
|
||||
Spring Boot는 auto-configured JSON mapper와 RestClient.Builder/WebClient.Builder를 제공하고, 그 builder에는 converter/codecs와 적절한 공통 구성이 반영된다. 메서드마다 새 mapper를 만들면 그 구성을 우회하게 된다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- adapter 메서드 안 new ObjectMapper() 금지
|
||||
- 공통 builder와 공통 mapper를 우선 사용한다
|
||||
- provider 특수 규칙이 있으면 adapter configuration에서 분리해 구성한다
|
||||
|
||||
### 7.2 전역 @JacksonComponent / @JacksonMixin은 진짜 공통 규칙에만 쓴다
|
||||
|
||||
Spring Boot는 @JacksonComponent를 자동 등록하고, @JacksonMixin도 auto-configured mapper에 등록한다. 즉, 이 둘은 전역 영향 이 있다. 따라서 provider 하나만을 위한 특수 직렬화 규칙을 전역에 뿌리는 것은 신중해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 여러 연동/여러 DTO에 공통인 serializer/deserializer만 전역 등록
|
||||
- 특정 provider 전용 weird format은 adapter-local configuration 우선
|
||||
- provider 하나 때문에 전체 애플리케이션 JSON 규칙을 바꾸지 않는다
|
||||
|
||||
### 7.3 imperative/reactive client가 쓰는 JSON mapper 경계를 의식한다
|
||||
|
||||
Boot는 imperative HTTP clients와 reactive HTTP clients에 대해 각각 pre-configured builder를 제공하고, preferred JSON mapper 설정도 분리해 둔다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- RestClient/WebClient에서 provider-specific codec/mapper를 바꿀 때 범위를 명시한다
|
||||
- imperative client용 변경이 reactive client 전체에 번지지 않게 한다
|
||||
- “한 군데 바꾸면 다 되겠지” 식 전역 변경을 지양한다
|
||||
|
||||
## 8. 검증 / 번역 규칙
|
||||
|
||||
### 8.1 파싱 성공과 비즈니스 성공을 같은 것으로 보지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON/XML/form parsing 성공은 “wire format 해석 성공”일 뿐
|
||||
- provider가 business failure body를 200으로 줄 수도 있다
|
||||
- adapter는 파싱 후에 success/error semantics를 다시 해석한다
|
||||
|
||||
### 8.2 deserialization 예외는 provider parsing failure로 번역한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- malformed payload, required field missing, unexpected type mismatch는 integration parsing failure로 번역한다
|
||||
- application/domain이 Jackson 예외 타입을 직접 보지 않게 한다
|
||||
- provider contract drift 여부를 운영에서 추적 가능하게 한다
|
||||
|
||||
## 9. 관측 가능성 규칙
|
||||
|
||||
### 9.1 payload 전문 로그를 기본 금지한다
|
||||
|
||||
외부 payload는 PII, 토큰, 비밀값, 내부 식별자 등을 포함할 수 있다. 이전 observability 기준과 마찬가지로, serialization/deserialization 문제를 추적한다는 이유로 request/response 전문을 기본 로그에 남기지 않는다. 이 점은 OWASP의 민감정보 로그 금지 원칙과도 맞다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 로그는 provider, operation, status, contentType, payloadBytes, parse failure type 정도만
|
||||
- payload 원문은 기본 금지
|
||||
- 꼭 필요하면 테스트/격리 환경에서 제한적으로 남긴다
|
||||
|
||||
### 9.2 parse failure는 contract drift 신호로 남긴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- deserialization 실패는 단순 예외로 묻지 않는다
|
||||
- provider, operation, content type, failing field/shape 정도를 안전하게 남긴다
|
||||
- “provider contract가 변했을 수 있음”을 운영에서 추적할 수 있어야 한다
|
||||
|
||||
## 10. 테스트 규칙
|
||||
|
||||
### 10.1 외부 DTO는 fixture 기반 계약 테스트를 둔다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 대표 성공 응답
|
||||
- 대표 오류 응답
|
||||
- provider가 필드를 추가한 응답
|
||||
- 일부 필드 누락 응답
|
||||
- 에 대한 parsing 테스트를 둔다
|
||||
- provider 예시 payload나 실제 캡처 샘플을 fixture로 관리할 수 있다
|
||||
|
||||
### 10.2 request serialization도 golden sample로 확인한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider에 보내는 JSON/form/XML shape를 golden sample로 검증한다
|
||||
- field name, null/absent, date/time format, enum value가 계약대로 직렬화되는지 확인한다
|
||||
- “직렬화는 framework가 알아서 하겠지”에 기대지 않는다
|
||||
|
||||
## 11. 다른 문서와의 경계
|
||||
|
||||
이 문서는 외부 provider payload의 serialization/deserialization 만 다룬다.
|
||||
아래 주제의 source of truth는 별도 문서다.
|
||||
|
||||
- external API client structure
|
||||
- timeout
|
||||
- retry
|
||||
- outbound idempotency
|
||||
- fallback
|
||||
- exception translation
|
||||
|
||||
이 문서는 위 문서를 반복하지 않고, payload contract를 읽고 쓰는 경계 규칙 만 정의한다.
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 내부 domain/entity를 외부 request/response DTO로 직접 사용
|
||||
- provider response DTO를 application/domain 시그니처에 그대로 전달
|
||||
- 메서드 안 new ObjectMapper() 생성
|
||||
- 특정 provider 응답 대응을 위해 전역 mapper 규칙을 무심코 변경
|
||||
- first-party API strict 정책과 external response tolerant 정책을 혼동
|
||||
- success/error body를 같은 DTO로 억지 파싱
|
||||
- payload 전문 로그를 기본으로 남김
|
||||
- 외부 enum/string 값을 바로 domain enum에 박아 넣음
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- provider request/response DTO와 내부 모델이 분리되어 있는가?
|
||||
- outbound request가 provider contract를 정확히 반영하는가?
|
||||
- external response는 additive change에 대해 필요한 만큼 tolerant한가?
|
||||
- success body와 error body DTO가 분리되어 있는가?
|
||||
- null과 absent 의미를 provider 계약 기준으로 다루는가?
|
||||
- provider-specific weird format이 adapter 경계 안에 갇혀 있는가?
|
||||
- RestClient/WebClient의 공통 builder/mapper 구성을 우회하지 않는가?
|
||||
- serialization/deserialization fixture 테스트가 있는가?
|
||||
@@ -0,0 +1,286 @@
|
||||
# Timeout 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API 호출 timeout의 의미, 위치, 기본 정책을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 외부 연동 호출이 무기한 대기하지 않게 한다
|
||||
- connect/read/response/pool acquire 같은 timeout 종류를 구분한다
|
||||
- timeout을 retry, fallback, idempotency와 혼동하지 않게 한다
|
||||
- provider별/operation별 timeout을 일관되게 설계한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Boot / Spring Framework / Reactor Netty 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 AWS Builders’ Library 같은 실무 운영 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 모든 원격 호출에는 timeout이 있어야 한다
|
||||
|
||||
AWS는 원격 호출에는 timeout을 두는 것이 모범 사례이며, 같은 프로세스 내부가 아닌 프로세스 간 호출 전반에 timeout을 두라고 설명합니다. timeout이 없으면 오래 걸리는 요청이 메모리, 스레드, 연결, ephemeral port 같은 자원을 오래 붙잡아 시스템 전체를 악화시킬 수 있습니다. Reactor Netty도 response timeout을 설정하는 것이 좋은 실천이라고 명시합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 HTTP 호출에 무제한 대기 금지
|
||||
- 모든 외부 API client는 최소한 timeout 정책을 가져야 한다
|
||||
- timeout이 설정되지 않은 외부 연동 코드는 승인 후보에서 지양한다
|
||||
|
||||
### 3.2 timeout은 retry가 아니다
|
||||
|
||||
AWS는 timeout, retry, backoff를 별도 도구로 설명하며, timeout은 대기 시간을 제한하고, retry는 부분 실패/일시 실패를 다시 시도하는 메커니즘이라고 설명합니다. timeout이 발생했다고 해서 side effect가 없었다고 보장되지 않으며, side effect가 있는 API는 idempotency가 있어야 안전하게 재시도할 수 있다고도 말합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- timeout은 “언제 포기할지”를 정하는 규칙
|
||||
- retry는 “포기 전에 다시 시도할지”를 정하는 규칙
|
||||
- timeout 문서에서 retry를 중복 정의하지 않는다
|
||||
- side effect가 있는 외부 API는 timeout 이후 재시도 전에 idempotency 가능 여부를 반드시 검토한다
|
||||
|
||||
### 3.3 timeout은 외부 시스템별, operation별로 달라질 수 있다
|
||||
|
||||
AWS는 timeout 값을 고를 때 downstream latency와 허용 가능한 false timeout 비율을 보고 정해야 하며, 인터넷 구간처럼 네트워크 편차가 큰 경우와 p99.9와 p50이 가까운 tight latency 서비스는 같은 방식으로 잡으면 안 된다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 시스템마다 timeout이 다를 수 있다
|
||||
- 같은 외부 시스템 안에서도 operation별 timeout이 다를 수 있다
|
||||
- “전 서비스 공통 3초” 같은 일괄값을 기본 전략으로 두지 않는다
|
||||
- timeout은 실제 latency와 business 중요도에 근거해 결정한다
|
||||
|
||||
## 4. timeout 종류 구분
|
||||
|
||||
### 4.1 최소한 connect timeout과 read/response timeout을 구분한다
|
||||
|
||||
Spring Boot는 전역 HTTP client 설정으로 spring.http.clients.connect-timeout와 spring.http.clients.read-timeout를 제공합니다. HTTP Service group도 connection/read timeout을 그룹별로 설정할 수 있습니다. Reactor Netty는 response timeout과 connection timeout을 별도 개념으로 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 HTTP client는 최소한 다음 둘을 구분한다
|
||||
- connect timeout
|
||||
- read/response timeout
|
||||
- 연결이 안 되는 상황과, 연결은 되었지만 응답이 늦는 상황을 같은 timeout 하나로 퉁치지 않는다
|
||||
|
||||
### 4.2 reactive client에서는 pool acquire / TLS / DNS도 별도 고려 대상이다
|
||||
|
||||
Reactor Netty는 connection pool acquire timeout, SSL/TLS handshake timeout, proxy timeout, DNS query timeout까지 별도 timeout 옵션으로 설명합니다. connection pool의 pendingAcquireTimeout 기본값은 45초이고, SSL handshake timeout 기본값은 10초이며, DNS query timeout 기본값은 5초입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
WebClient + Reactor Netty를 쓸 때는 단순 response timeout만 볼 것이 아니라 다음도 검토한다
|
||||
|
||||
- connection pool acquire timeout
|
||||
- SSL handshake timeout
|
||||
- DNS resolution timeout
|
||||
- 트래픽이 많거나 TLS/프록시/DNS 영향이 큰 환경에서는 이 고급 timeout을 운영 설계에 포함한다
|
||||
|
||||
### 4.3 “전체 호출 deadline”과 client-level timeout을 구분한다
|
||||
|
||||
Reactor Netty는 specific timeout 옵션을 두는 편이 Reactor timeout 연산자보다 더 목적에 맞는 제어를 준다고 설명합니다. timeout 연산자는 연결부터 응답 수신까지 전체 동작에 걸리는 시간을 통째로 제한하지만, client-specific timeout은 더 세밀합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- client-level timeout은 connect/read/pool/TLS 같은 기술적 단계별 timeout
|
||||
- business/application deadline은 “이 유스케이스가 전체적으로 몇 초 안에 끝나야 하는가”라는 별도 개념
|
||||
- 둘을 혼동하지 않는다
|
||||
- reactive 체인 전체에 무턱대고 timeout()만 거는 것을 기본값으로 두지 않는다
|
||||
|
||||
## 5. client 종류별 표준
|
||||
|
||||
### 5.1 RestClient 기본값은 Boot 전역 설정 + provider별 override다
|
||||
|
||||
Spring Boot는 RestClient.Builder를 자동 구성하고, spring.http.clients.connect-timeout / read-timeout 같은 전역 속성을 제공합니다. 또한 HTTP Service client group별로 connection/read timeout을 다르게 둘 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- imperative 외부 HTTP 호출 기본값은 RestClient
|
||||
- timeout 기본값은 전역 spring.http.clients.*
|
||||
- provider별/그룹별 차이는 spring.http.serviceclient.<group> 또는 전용 configuration에서 override
|
||||
- RestClient.create()를 직접 만들어 timeout 구성을 우회하지 않는다
|
||||
|
||||
### 5.2 WebClient는 Reactor Netty timeout까지 함께 본다
|
||||
|
||||
Spring Boot는 WebClient.Builder를 자동 구성하고, Boot가 제공하는 builder를 주입해서 쓰는 것을 강하게 권장합니다. Spring Framework는 Reactor Netty HttpClient를 미리 구성해 ReactorClientHttpConnector로 WebClient에 붙일 수 있다고 설명합니다. Reactor Netty는 response timeout, connect timeout, pool timeout, TLS timeout, DNS timeout을 별도로 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- reactive/non-blocking 외부 호출은 WebClient
|
||||
- 단순 Boot 전역 read-timeout만 믿지 않고, 필요하면 Reactor Netty HttpClient를 명시적으로 구성한다
|
||||
- 대기 원인이 connection인지 response인지 pool acquire인지 구분 가능한 구조를 선호한다
|
||||
|
||||
### 5.3 HTTP Service Client는 group 속성으로 timeout을 관리한다
|
||||
|
||||
Spring Boot는 @ImportHttpServices와 group 개념을 제공하고, spring.http.serviceclient.<group-name> 아래에서 base URL, default headers, redirect, connection/read timeout, SSL bundle 등을 설정할 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 선언형 HTTP interface client를 쓸 때는 timeout도 group 단위로 관리한다
|
||||
- interface마다 개별 하드코딩하지 않는다
|
||||
- 같은 provider 아래 여러 인터페이스가 공통 timeout을 공유하게 한다
|
||||
|
||||
## 6. timeout 값 선택 기준
|
||||
|
||||
### 6.1 timeout은 downstream latency와 허용 가능한 false timeout 비율로 잡는다
|
||||
|
||||
AWS는 intra-region 서비스 호출의 경우 허용 가능한 false timeout 비율(예: 0.1%)을 먼저 정하고, downstream latency percentile(예: p99.9)을 참고해 timeout을 고르는 방식을 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- timeout 값은 “감”으로 정하지 않는다
|
||||
- 가능하면 provider/operation latency 기준을 본다
|
||||
- 기본 질문은 다음과 같다
|
||||
- 이 호출이 몇 ms/초 이상 걸리면 사실상 실패로 봐야 하는가?
|
||||
- false timeout을 얼마나 허용할 것인가?
|
||||
- timeout 이후 retry/fallback이 가능한가?
|
||||
|
||||
### 6.2 인터넷 구간과 내부 구간은 같은 값으로 잡지 않는다
|
||||
|
||||
AWS는 인터넷처럼 네트워크 편차가 큰 경우에는 downstream percentile만 보고 timeout을 잡으면 안 되고, reasonable worst-case network latency를 추가로 고려해야 한다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
같은 데이터센터/같은 리전에 있는 내부 서비스 호출과
|
||||
인터넷을 거치는 SaaS/third-party 호출은
|
||||
timeout 기준을 다르게 둔다
|
||||
|
||||
외부 공개 인터넷 API는 더 큰 네트워크 변동성을 감안한다
|
||||
|
||||
### 6.3 너무 낮은 timeout은 배포/콜드 커넥션/TLS 구간에서 오탐을 만든다
|
||||
|
||||
AWS는 아주 낮은 timeout(예: 20ms)을 썼을 때 배포 직후 새 secure connection 수립 시간이 timeout에 포함되어 오탐이 생긴 사례를 설명하며, 이후 연결을 미리 준비(prewarm)하는 방식으로 개선했다고 말합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- timeout을 지나치게 공격적으로 줄이지 않는다
|
||||
- 새 연결 수립, TLS handshake, DNS lookup이 포함되는지 확인한다
|
||||
- 낮은 timeout을 쓰려면 connection reuse/prewarm 전략도 함께 검토한다
|
||||
|
||||
### 6.4 “tight latency service”에는 padding을 둔다
|
||||
|
||||
AWS는 p99.9와 p50이 가까운 서비스에서는 작은 latency 증가에도 timeout이 급증할 수 있으므로 padding을 두라고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- timeout은 percentile 값에 기계적으로 딱 맞추지 않는다
|
||||
- 급격한 오탐 증가를 막기 위한 안전 여유를 둔다
|
||||
- 극단적으로 빡빡한 timeout은 특별한 근거가 있을 때만 허용한다
|
||||
|
||||
## 7. operation별 기준
|
||||
|
||||
### 7.1 사용자 요청 경로의 외부 호출은 더 엄격한 timeout을 가진다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- synchronous request path 안의 외부 호출은 사용자 응답 SLA를 고려해 더 엄격한 timeout을 둔다
|
||||
- 장시간 대기가 UX와 thread/resource 점유를 악화시키는 경우가 많다
|
||||
- “느리지만 언젠가 오면 된다”는 기준을 기본값으로 두지 않는다
|
||||
|
||||
### 7.2 백그라운드/배치 호출은 더 긴 timeout을 가질 수 있다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 배치/백그라운드 호출은 사용자 직접 응답보다 긴 timeout을 가질 수 있다
|
||||
- 다만 무기한 대기를 허용하지는 않는다
|
||||
- 작업 단위 SLA와 재시도/보상 전략을 함께 본다
|
||||
|
||||
### 7.3 읽기와 쓰기 호출을 구분한다
|
||||
|
||||
AWS는 side effect가 있는 API는 timeout 이후 retry가 중복 side effect를 만들 수 있으므로 idempotency가 중요하다고 설명합니다. timeout 자체도 읽기 호출과 쓰기 호출의 의미가 다를 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- read-only 조회는 상대적으로 더 짧은 timeout을 선호할 수 있다
|
||||
- side effect가 있는 write 호출은 timeout 후 retry 가능성까지 함께 본다
|
||||
- “timeout 값만” 정하지 말고, 그 timeout 이후 어떤 동작이 이어질지도 함께 문서화한다
|
||||
|
||||
## 8. 설정 위치 규칙
|
||||
|
||||
### 8.1 전역 기본값은 공통 설정으로 둔다
|
||||
|
||||
Spring Boot는 모든 HTTP client에 적용되는 전역 spring.http.clients.* 속성을 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- connect/read timeout의 공통 기본값은 전역 설정으로 둔다
|
||||
- 서비스 전체 기본값을 문서화한다
|
||||
- 각 adapter가 제각각 timeout을 하드코딩하지 않는다
|
||||
|
||||
### 8.2 provider별 차이는 group 또는 전용 configuration으로 override한다
|
||||
|
||||
Spring Boot는 HTTP Service group에 대해 base URL, headers, redirect, connect/read timeout, SSL bundle 등을 그룹별로 둘 수 있다고 설명합니다. 또한 RestClient/WebClient는 injected builder에 좁은 범위 customization을 추가하는 방식을 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider별 timeout 차이는 group 설정 또는 전용 configuration으로 둔다
|
||||
- adapter 생성자 안 상수 하드코딩을 기본 금지한다
|
||||
- 왜 override가 필요한지 근거를 남긴다
|
||||
|
||||
### 8.3 operation별 차이는 client 내부에서 명시적으로 표현한다
|
||||
|
||||
Reactor Netty는 기본 response timeout 외에 request별 response timeout override도 지원합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 provider 안에서도 operation별로 timeout이 다르면 코드에 의도를 드러낸다
|
||||
- “특정 operation만 더 길다/짧다”를 숨긴 magic number를 금지한다
|
||||
- operation별 override는 드물고 명시적이어야 한다
|
||||
|
||||
## 9. observability 규칙
|
||||
|
||||
### 9.1 timeout은 관측 가능해야 한다
|
||||
|
||||
Spring Boot는 auto-configured builders를 통해 HTTP client instrumentation을 함께 적용할 수 있다고 설명합니다. timeout이 일어나도 어떤 provider, 어떤 operation, 어느 단계에서 발생했는지 관측 가능해야 운영이 됩니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
timeout 발생 시 최소한 다음 맥락을 로그/메트릭에서 추적 가능하게 한다
|
||||
|
||||
- provider 또는 client name
|
||||
- operation
|
||||
- method
|
||||
- uri template
|
||||
- timeout type(connect/read/response/pool 등)
|
||||
- “그냥 timed out” 한 줄 로그로 끝내지 않는다
|
||||
|
||||
### 9.2 timeout은 retry/fallback과 함께 해석 가능해야 한다
|
||||
|
||||
AWS는 timeout, retry, backoff를 함께 설계해야 하고, retry는 부하를 악화시킬 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
timeout 로그에는 retry/fallback 결과 맥락이 이어져야 한다
|
||||
|
||||
- timeout이 났지만 retry 후 성공했는지
|
||||
- timeout이 최종 실패인지
|
||||
- fallback으로 복구됐는지
|
||||
|
||||
를 운영자가 구분할 수 있어야 한다
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 외부 HTTP 호출에 무제한 timeout
|
||||
- connect/read/response timeout을 구분하지 않고 하나의 감각적 숫자로 통일
|
||||
- RestClient.create() / WebClient.builder() 직접 생성으로 공통 timeout 설정 우회
|
||||
- base URL, timeout을 adapter 코드 안에 상수로 하드코딩
|
||||
- reactive 체인 전체에 무턱대고 timeout()만 걸어 세부 원인을 잃어버림
|
||||
- 매우 낮은 timeout을 두고 TLS/DNS/새 연결 비용을 고려하지 않음
|
||||
- timeout 이후 retry/idempotency 전략 없이 side effect 호출을 재시도
|
||||
- timeout 발생 로그에 provider/operation 맥락이 없음
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 외부 호출에는 timeout이 명시되어 있는가?
|
||||
- 최소 connect timeout과 read/response timeout을 구분하고 있는가?
|
||||
- timeout 값이 downstream latency와 business SLA에 근거하는가?
|
||||
- 인터넷 구간, TLS handshake, DNS 비용을 고려했는가?
|
||||
- timeout 기본값과 provider별 override 위치가 일관적인가?
|
||||
- reactive client라면 pool acquire / TLS / DNS timeout도 필요한지 검토했는가?
|
||||
- timeout 이후 retry/fallback/idempotency 동작이 함께 설계돼 있는가?
|
||||
- timeout 발생 시 provider/operation/timeout type이 관측 가능한가?
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user