init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -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이 관측 가능한가?
|
||||
@@ -0,0 +1,127 @@
|
||||
# collections / immutability 기준
|
||||
|
||||
## 목적
|
||||
|
||||
컬렉션은 기본적으로 **immutable-first**로 다룬다.
|
||||
변경이 꼭 필요한 로컬 조립 단계에서만 mutable 컬렉션을 허용하고, 경계를 넘길 때는 수정 불가 snapshot 또는 명시적 불변 구조로 바꾼다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- JDK의 `List.of`, `Set.of`, `Map.of`, `copyOf`, `toUnmodifiable*`는 수정 불가 컬렉션을 만든다.
|
||||
- `Collections.unmodifiableXXX`는 원본 컬렉션을 감싼 view일 뿐이다. 원본이 바뀌면 view도 바뀐다.
|
||||
- 컬렉션이 수정 불가여도 원소가 mutable이면 내용이 바뀐 것처럼 보일 수 있다.
|
||||
- `List.of` / `List.copyOf` 계열은 null을 허용하지 않는다.
|
||||
- `Set.of`와 `Map.of` 계열은 null을 허용하지 않으며, 중복 원소/중복 키를 허용하지 않는다.
|
||||
- `Set.of`, `Map.of`, `Map.ofEntries`, `toUnmodifiableSet`, `toUnmodifiableMap`는 iteration order가 JVM 실행마다 달라질 수 있다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 기본은 immutable-first
|
||||
다음 경우에는 mutable보다 immutable/unmodifiable 결과를 기본값으로 사용한다.
|
||||
|
||||
- 상수 컬렉션
|
||||
- 설정/정책/권한 목록
|
||||
- 외부에 반환하는 결과
|
||||
- 공유되는 데이터
|
||||
- 생성 후 더 이상 바뀌지 않아야 하는 상태
|
||||
|
||||
### 2. mutable 컬렉션은 로컬 조립 단계에서만 허용
|
||||
다음 경우에는 mutable 컬렉션을 허용한다.
|
||||
|
||||
- 여러 source를 모아 결과를 만드는 임시 버퍼
|
||||
- 반복적으로 add/remove가 필요한 내부 알고리즘
|
||||
- aggregate/entity 내부에서 실제 상태 변경이 본질인 경우
|
||||
|
||||
단, 경계를 넘기기 전에 immutable/unmodifiable로 바꾼다.
|
||||
|
||||
### 3. 경계를 넘길 때는 defensive copy를 우선
|
||||
다음 상황에서는 원본 컬렉션을 그대로 넘기지 않는다.
|
||||
|
||||
- 생성자에 받아서 필드로 저장할 때
|
||||
- getter/응답 DTO/결과 객체로 반환할 때
|
||||
- 다른 레이어로 넘길 때
|
||||
|
||||
기본 선택:
|
||||
- snapshot이 목적 -> `List.copyOf`, `Set.copyOf`, `Map.copyOf`
|
||||
- 작은 상수 컬렉션 -> `List.of`, `Set.of`, `Map.of`
|
||||
- stream 수집 결과를 수정 불가로 고정 -> `Collectors.toUnmodifiableList/Set/Map`
|
||||
|
||||
### 4. `Collections.unmodifiableXXX`는 “view”가 필요할 때만
|
||||
이 API는 기본 선택이 아니다.
|
||||
|
||||
허용되는 경우:
|
||||
- 원본과 동기화되는 read-only view가 정말 필요할 때
|
||||
- legacy API와의 호환 때문에 wrapper view가 필요한 경우
|
||||
|
||||
기본 금지 이유:
|
||||
- 원본이 바뀌면 view도 바뀐다
|
||||
- defensive copy나 true immutable 의도와 다르다
|
||||
|
||||
### 5. `copyOf`를 snapshot 기본값으로 사용
|
||||
이미 가지고 있는 mutable collection을 안전하게 보관/반환해야 하면 `copyOf`를 우선 검토한다.
|
||||
|
||||
예:
|
||||
- 생성자에서 받은 list를 필드에 저장
|
||||
- service 결과를 외부에 반환
|
||||
- mapper 결과를 response/domain에 전달
|
||||
|
||||
### 6. 컬렉션 null 금지, empty 우선
|
||||
컬렉션 필드/반환값/파라미터는 가능하면 null을 금지한다.
|
||||
|
||||
기본값:
|
||||
- 없음 -> empty list / set / map
|
||||
- null collection 금지
|
||||
|
||||
### 7. 원소의 immutability를 따로 본다
|
||||
컬렉션만 수정 불가여도 원소가 mutable이면 완전한 불변이 아니다.
|
||||
|
||||
기본 원칙:
|
||||
- 공유되는 컬렉션은 가능하면 immutable element를 담는다
|
||||
- mutable element를 담는 경우 “shallow immutable only”라는 점을 의식한다
|
||||
- 외부에서 element mutation이 가능한 구조를 장기 공유 상태로 두지 않는다
|
||||
|
||||
### 8. 결과 컬렉션의 의미를 명시적으로 선택
|
||||
- 순서가 중요하면 `List`
|
||||
- 중복 제거가 목적이면 `Set`
|
||||
- key lookup이 목적이면 `Map`
|
||||
|
||||
불변이 목적이라면:
|
||||
- `List.copyOf`
|
||||
- `Set.copyOf`
|
||||
- `Map.copyOf`
|
||||
- `Collectors.toUnmodifiable*`
|
||||
|
||||
를 우선 검토한다.
|
||||
|
||||
### 9. iteration order를 가정하지 않는다
|
||||
`Set.of`, `Map.of`, `Map.ofEntries`, `toUnmodifiableSet`, `toUnmodifiableMap`는 iteration order가 랜덤화될 수 있다.
|
||||
순서가 중요하면 `List`나 순서를 보장하는 별도 컬렉션 타입을 명시적으로 사용한다.
|
||||
|
||||
### 10. `Collectors.toList()` 결과를 mutable이라고 가정하지 않는다
|
||||
수집 결과의 mutability가 중요하면 명시적으로 선택한다.
|
||||
|
||||
- 수정 불가 결과 필요 -> `stream.toList()` 또는 `Collectors.toUnmodifiableList()`
|
||||
- 구체 mutable 타입 필요 -> `Collectors.toCollection(ArrayList::new)` 등
|
||||
|
||||
### 11. getter는 내부 mutable collection을 노출하지 않는다
|
||||
다음 패턴을 금지한다.
|
||||
|
||||
- 내부 `ArrayList` 참조를 그대로 반환
|
||||
- 생성자에서 받은 collection 참조를 그대로 필드에 저장
|
||||
- 외부에서 수정 가능한 map/set/list를 그대로 보관
|
||||
|
||||
### 12. domain/application에서 컬렉션은 가능한 한 non-null + stable
|
||||
도메인/유스케이스 내부에서는:
|
||||
- null collection 금지
|
||||
- mutable shared state 최소화
|
||||
- 변경 가능성이 없다면 불변 구조로 고정
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 기본은 immutable-first
|
||||
- mutable은 로컬 조립 단계에서만 허용
|
||||
- 경계에서는 `copyOf`/`of`/`toUnmodifiable*`를 기본값으로 사용
|
||||
- `Collections.unmodifiableXXX`는 view가 필요할 때만 제한적으로 사용
|
||||
- null collection 대신 empty collection
|
||||
- 원소가 mutable이면 컬렉션만 막아도 완전한 불변이 아님
|
||||
- iteration order가 중요한 곳에서 `Set.of`/`Map.of` 계열 순서를 가정하지 않음
|
||||
@@ -0,0 +1,154 @@
|
||||
# duplication 기준
|
||||
|
||||
## 목적
|
||||
|
||||
중복은 무조건 제거하지 않는다.
|
||||
반복 수정 비용이 큰 **진짜 중복**은 제거하고, 서로 다른 이유로 바뀔 가능성이 있는 **우연히 비슷한 코드**는 성급히 합치지 않는다.
|
||||
|
||||
## 공식/실무 기준 요약
|
||||
|
||||
- 중복 코드는 수정/리팩터링 시 같은 변경을 여러 곳에 반복하게 만들고, 누락 위험을 높인다.
|
||||
- 정적 분석 도구는 일정 크기 이상의 구조적 중복을 duplication으로 탐지한다.
|
||||
- 하지만 DRY를 너무 빨리 적용하면 premature abstraction이 생겨 이후 변경을 더 어렵게 만들 수 있다.
|
||||
- 특히 테스트 코드는 production code보다 DRY보다 가독성을 더 우선할 수 있다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 기본 원칙: “같아 보인다”와 “같은 이유로 바뀐다”를 구분
|
||||
다음 둘을 구분한다.
|
||||
|
||||
- **진짜 중복**
|
||||
- 같은 의미
|
||||
- 같은 정책
|
||||
- 같은 이유로 바뀜
|
||||
- 한 군데 고치면 나머지도 같이 바뀌어야 함
|
||||
|
||||
- **우연한 유사성**
|
||||
- 지금은 비슷해 보여도
|
||||
- 맥락/책임/변화 이유가 다름
|
||||
- 나중에 독립적으로 진화할 가능성이 큼
|
||||
|
||||
기본적으로 **같은 이유로 바뀌는 경우만 추출**한다.
|
||||
|
||||
### 2. Rule of Three를 기본값으로 사용
|
||||
추상화는 보통 아래 순서를 따른다.
|
||||
|
||||
- 1회: 그냥 작성
|
||||
- 2회: 비슷한 패턴을 인식하되 아직 참음
|
||||
- 3회 이상: 변화 이유가 같다면 추출을 적극 검토
|
||||
|
||||
단, 보안/예외 번역/외부 API 호출처럼 실수 비용이 큰 중복은 2회부터도 추출 가능하다.
|
||||
|
||||
### 3. 반복 수정 비용이 크면 추출
|
||||
다음 조건이 크면 중복 제거를 우선 검토한다.
|
||||
|
||||
- 같은 정책 변경을 여러 파일에 반복해야 함
|
||||
- 누락 시 장애/보안/데이터 불일치 위험이 큼
|
||||
- 테스트도 같이 여러 군데 바뀌어야 함
|
||||
- 팀원이 쉽게 한쪽만 수정할 수 있음
|
||||
|
||||
### 4. 추상화 비용이 더 크면 중복 허용
|
||||
다음 경우는 중복을 허용한다.
|
||||
|
||||
- 서로 다른 레이어 책임을 억지로 합쳐야 하는 경우
|
||||
- 공통화하면 이름이 모호해지는 경우
|
||||
- 분기 옵션이 계속 늘어나 helper가 더 복잡해지는 경우
|
||||
- 미래 변화 방향이 아직 불확실한 경우
|
||||
- 테스트 가독성이 helper 때문에 더 나빠지는 경우
|
||||
|
||||
### 5. 레이어를 넘는 중복 제거는 특히 신중
|
||||
중복 제거를 위해 레이어 경계를 깨지 않는다.
|
||||
|
||||
금지 예:
|
||||
- presentation과 infrastructure의 비슷한 코드라는 이유로 공통 유틸로 합치기
|
||||
- domain 규칙과 controller 검증을 한 helper로 합치기
|
||||
- 외부 API 포맷과 내부 도메인 규칙을 같은 mapper로 합치기
|
||||
|
||||
### 6. 복붙보다 작은 추출부터
|
||||
중복 제거는 아래 순서로 작게 시작한다.
|
||||
|
||||
1. local variable 추출
|
||||
2. private method 추출
|
||||
3. mapper/helper 추출
|
||||
4. 같은 클래스 계층이면 pull up / template method 검토
|
||||
5. 그래도 명확할 때만 더 큰 추상화
|
||||
|
||||
처음부터 범용 util/service/common으로 키우지 않는다.
|
||||
|
||||
### 7. helper는 “짧아진 코드”보다 “명확해진 이름”이 있을 때만
|
||||
다음 중 하나가 아니면 helper 추출을 보류한다.
|
||||
|
||||
- 반복되는 의미를 정확히 설명하는 이름이 있음
|
||||
- 공통 정책/계약을 하나로 모아야 함
|
||||
- 테스트/검증/예외 처리 중복을 안정적으로 줄임
|
||||
|
||||
“줄 수 있으니까 줄인다”는 이유만으로 추출하지 않는다.
|
||||
|
||||
### 8. 테스트는 DRY보다 가독성 우선 가능
|
||||
테스트는 production code와 기준이 다를 수 있다.
|
||||
|
||||
기본:
|
||||
- 테스트는 사람이 바로 읽어 이해할 수 있어야 함
|
||||
- 과한 helper, loop, setup 공유로 의미가 숨겨지면 중복을 허용
|
||||
- DAMP를 우선하고, 진짜 반복 보일러플레이트만 줄인다
|
||||
|
||||
### 9. 생성/변환/정책 중복은 추출 우선
|
||||
다음은 실수 비용이 높아 추출을 우선 검토한다.
|
||||
|
||||
- 에러 응답 조립
|
||||
- 외부 API request/response 변환
|
||||
- 시간 생성/포맷 정책
|
||||
- 권한/역할 판별 정책
|
||||
- 공통 validation 규칙
|
||||
- persistence <-> domain 매핑 규칙
|
||||
- exception translation 규칙
|
||||
|
||||
### 10. 우연한 한두 줄 중복은 허용
|
||||
다음은 무리해서 추출하지 않는다.
|
||||
|
||||
- 간단한 guard clause
|
||||
- 명확한 builder/setter 호출 몇 줄
|
||||
- 테스트의 준비/검증 코드
|
||||
- 각 레이어에서 맥락상 당연한 짧은 변환 코드
|
||||
|
||||
### 11. 중복 제거는 behavior-preserving으로 작게
|
||||
중복 제거는 리팩터링이다.
|
||||
행동 보존을 전제로 작은 단계로 진행한다.
|
||||
|
||||
기본:
|
||||
- 테스트가 있으면 먼저 보호
|
||||
- 한 번에 큰 범용 추상화로 가지 않음
|
||||
- 단계적으로 추출 후 검증
|
||||
|
||||
### 12. common/util 모듈은 중복 제거 수단으로 남용 금지
|
||||
중복을 본다고 바로 `common`, `util`, `helper`로 보내지 않는다.
|
||||
|
||||
먼저 묻는다:
|
||||
- 이 중복의 소유 레이어는 어디인가?
|
||||
- 정말 여러 모듈이 같은 이유로 바뀌는가?
|
||||
- 경계를 안 깨고도 추출 가능한가?
|
||||
|
||||
### 13. 도구 경고는 “신호”이지 자동 수정 명령은 아님
|
||||
Sonar/PMD가 duplication을 잡았다고 무조건 추출하지 않는다.
|
||||
다음 둘을 함께 본다.
|
||||
|
||||
- 구조적 중복 크기
|
||||
- 변화 이유의 동일성
|
||||
|
||||
### 14. 문서화 기준
|
||||
중복을 의도적으로 남겼다면 이유를 짧게 남길 수 있다.
|
||||
특히 아래 경우:
|
||||
- 테스트 가독성
|
||||
- 레이어 분리 유지
|
||||
- 조기 추상화 방지
|
||||
- 외부 계약의 독립 진화 가능성
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 진짜 중복만 제거
|
||||
- 같은 이유로 바뀌는 경우만 추출
|
||||
- Rule of Three 기본
|
||||
- 레이어 경계를 깨는 공통화 금지
|
||||
- 작은 추출부터 시작
|
||||
- 테스트는 DAMP 우선 가능
|
||||
- 도구 경고는 신호일 뿐 자동 추출 근거가 아님
|
||||
@@ -0,0 +1,140 @@
|
||||
# enum / constants 기준
|
||||
|
||||
## 목적
|
||||
|
||||
고정된 의미 집합은 문자열/정수 상수 남발 대신 enum으로 표현한다.
|
||||
상수는 단순히 `static final`인 값이 아니라, 실제로 변경 불가능하고 의미가 분명한 값으로 관리한다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- enum은 미리 정의된 상수 집합을 표현하는 특별한 클래스다.
|
||||
- 각 enum constant는 해당 enum 타입의 고유 인스턴스다.
|
||||
- enum은 필드, 메서드, 생성자를 가질 수 있다.
|
||||
- `ordinal()`은 대부분의 개발자가 사용할 용도가 아니며 `EnumSet`, `EnumMap` 같은 저수준 구조를 위한 값이다.
|
||||
- `name()`은 선언된 정확한 이름을 반환한다.
|
||||
- `toString()`은 더 사람이 읽기 좋은 문자열이 필요할 때만 재정의한다.
|
||||
- enum key/set에는 `EnumMap`, `EnumSet` 같은 특화 구현을 우선 검토한다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 고정된 값 집합이면 enum 우선
|
||||
다음과 같은 경우는 enum을 우선 검토한다.
|
||||
|
||||
- 상태
|
||||
- provider 종류
|
||||
- 역할 종류
|
||||
- 모드
|
||||
- 정책 선택지
|
||||
- 결과 코드의 제한된 집합
|
||||
|
||||
금지 예:
|
||||
- `"LOCAL"`, `"GOOGLE"`, `"KAKAO"` 같은 문자열 분기
|
||||
- `1`, `2`, `3` 같은 매직 넘버 상태값 분기
|
||||
|
||||
### 2. 값 집합이 열려 있으면 enum을 쓰지 않는다
|
||||
외부 시스템이 임의 값을 추가할 수 있거나, 런타임에 확장 가능한 값이면 enum보다 다른 모델을 검토한다.
|
||||
|
||||
예:
|
||||
- 자유 입력 태그
|
||||
- 외부 설정으로 계속 늘어나는 코드값
|
||||
- tenant별 커스텀 상태
|
||||
|
||||
### 3. `ordinal()` 의존 금지
|
||||
다음 용도로 `ordinal()`을 사용하지 않는다.
|
||||
|
||||
- DB 저장값
|
||||
- 외부 API/JSON 값
|
||||
- 비즈니스 분기
|
||||
- 배열 인덱스 계약
|
||||
- stable code/wire number
|
||||
|
||||
안정적인 숫자/문자 코드가 필요하면 enum 필드로 명시한다.
|
||||
|
||||
### 4. `name()`은 내부 안정 식별자, 사용자 표시값은 별도 필드/메서드
|
||||
- 내부 고정 식별이 필요하면 `name()`
|
||||
- 사용자에게 보이는 문자열은 별도 필드나 메서드
|
||||
- API/DB/wire 값도 별도 필드로 분리
|
||||
|
||||
즉:
|
||||
- internal name
|
||||
- display label
|
||||
- external code
|
||||
를 섞지 않는다.
|
||||
|
||||
### 5. `toString()`은 신중하게
|
||||
공식 문서상 `toString()`은 필요하면 더 programmer-friendly 한 문자열로 재정의할 수 있다.
|
||||
하지만 프로젝트에서는 다음 기준을 따른다.
|
||||
|
||||
- 로깅/디버깅/개발자용 표현만 바꿀 때 제한적으로 허용
|
||||
- 외부 계약, JSON, DB 저장값을 `toString()`에 의존하지 않는다
|
||||
- 외부 계약값은 명시적 getter/field를 사용한다
|
||||
|
||||
### 6. enum 비교는 enum끼리 직접 비교
|
||||
동일 enum 타입 비교는 enum 값 자체로 직접 비교한다.
|
||||
비즈니스 의미 비교를 위해 문자열로 변환해서 비교하지 않는다.
|
||||
|
||||
금지 예:
|
||||
- `status.name().equals("ACTIVE")`
|
||||
- `provider.toString().equals(input)`
|
||||
|
||||
### 7. enum set/map은 `EnumSet` / `EnumMap` 우선 검토
|
||||
enum을 key/set element로 사용할 때는 다음을 우선 검토한다.
|
||||
|
||||
- 집합 -> `EnumSet`
|
||||
- 맵 -> `EnumMap`
|
||||
|
||||
비트 플래그용 `int`나 일반 `HashSet`/`HashMap`을 습관적으로 쓰지 않는다.
|
||||
|
||||
### 8. switch/분기는 enum 의미 중심으로 작성
|
||||
enum 분기는 문자열/정수 코드가 아니라 enum 값 자체를 기준으로 작성한다.
|
||||
|
||||
또한:
|
||||
- default로 조용히 삼키지 않는다
|
||||
- 새로운 enum 값 추가 시 분기 누락이 드러나도록 작성한다
|
||||
|
||||
### 9. 상수는 진짜 immutable일 때만 상수로 취급
|
||||
상수는 다음을 만족해야 한다.
|
||||
|
||||
- `static final`
|
||||
- 값 자체가 immutable
|
||||
- 내부 원소도 변경으로 의미가 흔들리지 않음
|
||||
|
||||
즉:
|
||||
- mutable collection
|
||||
- mutable object reference
|
||||
- 변경 가능한 배열
|
||||
은 상수처럼 다루지 않는다.
|
||||
|
||||
### 10. 관련 상수는 “상수 클래스”보다 소유 타입 근처에 둔다
|
||||
다음 순서를 우선한다.
|
||||
|
||||
1. enum으로 승격 가능한 값이면 enum
|
||||
2. 특정 클래스 책임이면 해당 클래스 내부 상수
|
||||
3. 여러 모듈에 걸친 진짜 전역 상수만 별도 위치
|
||||
|
||||
의미 없는 `Constants`, `CommonConstants`, `AppConstants` 집합소는 기본 금지다.
|
||||
|
||||
### 11. null enum 기본 금지
|
||||
enum 값은 가능한 한 non-null로 다룬다.
|
||||
부재 표현이 필요하면:
|
||||
- Optional 반환
|
||||
- nullable boundary 입력
|
||||
- 명시적 `UNKNOWN` / `UNSPECIFIED` 도입 여부를 신중히 검토
|
||||
|
||||
단, `UNKNOWN`이 실제 비즈니스 상태가 아닐 경우 null 회피용으로 남용하지 않는다.
|
||||
|
||||
### 12. persistence / serialization 계약은 별도 기준을 따른다
|
||||
enum을 DB나 JSON에 노출할 때는 language 차원 기본 동작에 기대지 않고,
|
||||
- 어떤 값을 저장/전송할지
|
||||
- 호환성을 어떻게 유지할지
|
||||
를 명시적 기준으로 정의한다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 고정된 의미 집합은 enum 우선
|
||||
- 열린 값 집합은 enum 지양
|
||||
- `ordinal()` 의존 금지
|
||||
- internal name / display label / external code 분리
|
||||
- enum set/map은 `EnumSet` / `EnumMap` 우선
|
||||
- mutable 값은 상수처럼 다루지 않음
|
||||
- 잡다한 constants class 기본 금지
|
||||
@@ -0,0 +1,151 @@
|
||||
# exceptions 기준
|
||||
|
||||
## 목적
|
||||
|
||||
예외는 정상 흐름 제어 도구가 아니라, **예외 상황을 명시적으로 전달하고 경계에서 번역하는 수단**으로 사용한다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- Java 예외는 `Throwable` 계층에 속한다.
|
||||
- `RuntimeException`과 `Error`가 아닌 예외는 checked exception으로 취급된다.
|
||||
- 예외는 cause와 suppressed exception을 함께 가질 수 있다.
|
||||
- `try-with-resources`는 자원 정리를 보장하며, close 중 발생한 예외는 suppressed로 보존될 수 있다.
|
||||
- Spring transaction은 기본적으로 `RuntimeException`과 `Error`에서 rollback한다. checked exception은 기본 rollback 대상이 아니다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 예외는 정상 분기 대신 예외 상황에만 사용
|
||||
다음 용도로 예외를 쓰지 않는다.
|
||||
|
||||
- 일반적인 조건 분기
|
||||
- 결과 없음 표현
|
||||
- 루프 탈출
|
||||
- 검증 실패를 무조건 예외로만 처리하는 패턴
|
||||
|
||||
기본:
|
||||
- 정상적인 “없음”은 Optional/empty collection/명시적 결과 타입
|
||||
- 예외는 정말 비정상적이거나 계약 위반인 경우
|
||||
|
||||
### 2. catch는 번역 / 문맥 추가 / 복구일 때만
|
||||
예외를 catch하는 목적은 아래 셋 중 하나여야 한다.
|
||||
|
||||
- 계층 번역
|
||||
- 의미 있는 문맥 추가
|
||||
- 명시적 복구
|
||||
|
||||
그 외 목적 없는 catch는 금지한다.
|
||||
|
||||
### 3. broad catch 기본 금지
|
||||
다음 catch는 기본 금지한다.
|
||||
|
||||
- `catch (Exception e)`
|
||||
- `catch (Throwable t)`
|
||||
|
||||
예외:
|
||||
- 최상위 boundary에서 마지막 방어선으로 처리할 때
|
||||
- framework integration 때문에 정말 필요한 경우
|
||||
- 테스트 코드에서 의도가 분명한 경우
|
||||
|
||||
기본값은 더 구체적인 예외를 잡는 것이다.
|
||||
|
||||
### 4. 빈 catch 금지
|
||||
아무 일도 하지 않는 catch block은 금지한다.
|
||||
|
||||
정말 무시해야 하는 경우만:
|
||||
- 왜 안전하게 무시 가능한지 주석으로 설명
|
||||
- 가능하면 metrics/logging/상태 기록 중 하나 수행
|
||||
|
||||
### 5. `printStackTrace()` 금지
|
||||
업무 코드에서는 `printStackTrace()`를 사용하지 않는다.
|
||||
|
||||
기본:
|
||||
- logging framework로 기록
|
||||
- 또는 적절한 예외로 rethrow
|
||||
- 또는 상위 계층으로 전파
|
||||
|
||||
### 6. cause를 버리지 않는다
|
||||
예외를 번역할 때 원인 예외를 cause로 보존한다.
|
||||
|
||||
좋은 예:
|
||||
- `throw new InfrastructureException(code, message, cause)`
|
||||
|
||||
나쁜 예:
|
||||
- `throw new InfrastructureException(code, "failed")` 만 하고 원인을 버림
|
||||
|
||||
### 7. 메시지는 문맥을 더하고, 중복은 줄인다
|
||||
예외 메시지는 “무엇을 하다가 왜 실패했는지”를 추가한다.
|
||||
단, 하위 예외 메시지를 그대로 복붙해 중복하지 않는다.
|
||||
|
||||
예:
|
||||
- 좋음: `Failed to sign JWT with Vault transit key`
|
||||
- 나쁨: `IOException occurred` / `Error happened`
|
||||
|
||||
### 8. checked / unchecked 선택은 복구 가능성 기준
|
||||
기본 방향:
|
||||
- 호출자가 의미 있게 복구/대응할 수 있는 경우 -> checked exception 검토
|
||||
- 프로그래밍 오류, 불변식 위반, 계약 위반, 복구 불가 -> unchecked exception 우선
|
||||
|
||||
프로젝트 기본은:
|
||||
- domain/application/infrastructure 내부의 의미 예외는 대체로 unchecked
|
||||
- 외부 API/IO 경계에서는 원래 checked 예외를 잡고 계층 예외로 번역 가능
|
||||
|
||||
### 9. 계층별 번역 책임을 지킨다
|
||||
- domain: 도메인 의미만 표현
|
||||
- application: business outcome / error code로 번역
|
||||
- infrastructure: 기술 실패를 infrastructure exception으로 번역
|
||||
- presentation/bootstrap: HTTP/security/framework boundary로 번역
|
||||
|
||||
예외를 아무 계층에서나 최종 사용자 메시지로 확정하지 않는다.
|
||||
|
||||
### 10. try-with-resources 우선
|
||||
닫아야 하는 자원은 `try-with-resources`를 기본값으로 사용한다.
|
||||
|
||||
금지:
|
||||
- `finally`에서 close 하다가 기존 예외를 덮어쓰는 패턴
|
||||
- 자원 해제를 수동으로 반복 구현하는 패턴
|
||||
|
||||
### 11. `finally`에서 return/throw 금지
|
||||
`finally`에서 return/throw 하면 try/catch의 결과를 덮어쓸 수 있다.
|
||||
`finally`는 정리 작업만 수행하고 정상 종료해야 한다.
|
||||
|
||||
### 12. InterruptedException은 별도 처리
|
||||
`InterruptedException` 가능성이 있는 코드를 `Exception`으로 뭉뚱그려 잡지 않는다.
|
||||
|
||||
기본 방향:
|
||||
- 가능하면 별도로 catch
|
||||
- 현재 스레드 인터럽트 상태 복원 검토 (`Thread.currentThread().interrupt()`)
|
||||
- interruption 의미를 상위로 전달
|
||||
|
||||
### 13. transaction rollback 규칙을 예외 설계에 반영
|
||||
Spring transaction 경계 안에서는 예외 타입이 rollback에 영향을 준다.
|
||||
|
||||
기본:
|
||||
- unchecked -> 기본 rollback
|
||||
- checked -> 기본 no rollback
|
||||
- checked도 rollback해야 하면 `rollbackFor` 등으로 명시
|
||||
|
||||
따라서 “checked냐 unchecked냐”를 스타일이 아니라 transaction 의미까지 보고 결정한다.
|
||||
|
||||
### 14. 테스트에서 try-catch 남용 금지
|
||||
예외 기대 테스트는 `assertThrows` 류를 우선 사용한다.
|
||||
예외를 catch하고 `fail()`만 호출하는 패턴은 지양한다.
|
||||
|
||||
### 15. throws 선언은 실제로 던질 수 있는 checked 예외만
|
||||
checked exception을 실제로 던지지 않는데 시그니처에 선언하지 않는다.
|
||||
호출자에게 불필요한 처리 부담을 준다.
|
||||
|
||||
### 16. 구체 예외를 던진다
|
||||
`Exception`, `RuntimeException` 같은 너무 넓은 기반 예외를 직접 던지지 않는다.
|
||||
의미에 맞는 더 구체적인 예외를 사용한다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 예외는 정상 흐름 제어 수단이 아니다
|
||||
- catch는 번역 / 문맥 추가 / 복구일 때만
|
||||
- broad catch, empty catch, printStackTrace 금지
|
||||
- cause 보존
|
||||
- try-with-resources 우선
|
||||
- `finally`에서 return/throw 금지
|
||||
- `InterruptedException` 별도 처리
|
||||
- Spring rollback 규칙을 예외 타입 설계에 반영
|
||||
- 구체 예외 사용
|
||||
@@ -0,0 +1,153 @@
|
||||
# Javadoc 작성 / 수정 / 삭제 기준
|
||||
|
||||
## 목적
|
||||
|
||||
Javadoc은 코드 설명서가 아니라 **API 계약과 오해 가능성이 있는 의미를 문서화하는 도구**로 사용한다.
|
||||
“코드를 읽으면 바로 아는 내용”을 반복하지 않는다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- Javadoc doc comment는 선언 바로 앞에 있어야 인식된다.
|
||||
- 한 doc comment는 설명(description)과 block tags로 구성된다.
|
||||
- 첫 요약 문장은 summary/index에 재사용되므로 특히 중요하다.
|
||||
- override/implement 메서드는 자체 문서가 없으면 상위 문서를 상속할 수 있다.
|
||||
- visible한 class/member에는 Javadoc이 있는 것이 기본이지만, 정말 단순하고 obvious한 경우나 override는 예외가 될 수 있다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. Javadoc은 계약을 문서화할 때만 쓴다
|
||||
다음 중 하나가 있으면 Javadoc을 작성한다.
|
||||
|
||||
- public/protected API
|
||||
- 외부 모듈이 호출하는 계약
|
||||
- null 허용 여부가 중요함
|
||||
- 예외 조건/전제조건/후조건이 중요함
|
||||
- thread-safety, side effect, state change가 중요함
|
||||
- 경계 조건이나 corner case를 안 쓰면 오해할 수 있음
|
||||
- 이름만으로는 의미가 충분하지 않음
|
||||
|
||||
### 2. “코드를 읽으면 아는 내용”은 쓰지 않는다
|
||||
다음은 기본 금지한다.
|
||||
|
||||
- getter/setter를 그대로 풀어쓴 설명
|
||||
- 필드명/파라미터명을 문장으로 반복
|
||||
- 구현을 한 줄씩 설명하는 Javadoc
|
||||
- 리팩터링 후 쉽게 stale 해질 정보
|
||||
|
||||
금지 예:
|
||||
- `Returns the user name.` 만 있는 자명한 `getUserName()`
|
||||
- `Sets the value.` 같은 설명
|
||||
|
||||
### 3. 첫 줄은 summary로 쓴다
|
||||
Javadoc 첫 줄은 짧고 독립적으로 읽히는 요약이어야 한다.
|
||||
|
||||
기본:
|
||||
- 요약 1줄
|
||||
- 필요한 경우 상세 설명
|
||||
- 그 후 block tags
|
||||
|
||||
### 4. 구현 세부보다 호출 계약을 우선
|
||||
Javadoc은 아래를 더 우선해서 쓴다.
|
||||
|
||||
- 무엇을 보장하는가
|
||||
- 어떤 입력이 허용되는가
|
||||
- 어떤 경우 실패하는가
|
||||
- 호출자가 믿어도 되는 동작은 무엇인가
|
||||
- 반환값의 의미는 무엇인가
|
||||
|
||||
다음은 기본 지양한다.
|
||||
- 내부 알고리즘 설명
|
||||
- 현재 구현 방식
|
||||
- 성능 미세 최적화 세부
|
||||
|
||||
### 5. `@param`, `@return`, `@throws`는 의미가 있을 때만 정확히 쓴다
|
||||
태그를 채우기 위한 태그를 금지한다.
|
||||
|
||||
기본:
|
||||
- `@param`: 파라미터 의미/제약/허용 범위/nullable 여부
|
||||
- `@return`: 반환값 의미, empty/optional/null/ordering/ownership
|
||||
- `@throws`: 실제 계약상 중요한 예외 조건
|
||||
|
||||
### 6. 예외 문서는 “언제 왜 던지는가”를 쓴다
|
||||
단순히 예외 타입만 나열하지 않는다.
|
||||
|
||||
좋은 방향:
|
||||
- 어떤 입력/상태에서
|
||||
- 어떤 이유로
|
||||
- 호출자가 무엇을 기대해야 하는지
|
||||
|
||||
### 7. self-explanatory 멤버는 Javadoc 생략 가능
|
||||
정말 단순하고 obvious한 멤버는 Javadoc을 생략할 수 있다.
|
||||
|
||||
예:
|
||||
- 의미가 완전히 자명한 getter
|
||||
- record component 중 이름만으로 충분한 경우
|
||||
|
||||
단, typical reader가 모를 수 있는 의미가 있으면 생략하지 않는다.
|
||||
|
||||
### 8. override는 상속 문서를 우선 활용
|
||||
override/implement 메서드에서 상위 문서가 충분하면 Javadoc을 반복하지 않는다.
|
||||
|
||||
다만 아래 경우에는 다시 쓴다.
|
||||
- 하위 타입에서 계약이 추가됨
|
||||
- 예외/부작용/동시성 보장이 달라짐
|
||||
- 더 좁은 의미가 생김
|
||||
|
||||
### 9. Javadoc과 코드가 어긋나면 Javadoc이 잘못된 것
|
||||
코드가 바뀌면 Javadoc도 같이 수정한다.
|
||||
맞출 수 없으면 지운다.
|
||||
|
||||
기본 규칙:
|
||||
- stale Javadoc 금지
|
||||
- 애매한 Javadoc보다 없는 편이 낫다
|
||||
- 거짓 문서 금지
|
||||
|
||||
### 10. Javadoc은 boundary/API 중심으로 우선 배치
|
||||
프로젝트에서는 아래 우선순위로 작성한다.
|
||||
|
||||
1. public/protected API
|
||||
2. 외부 호출되는 application/presentation boundary
|
||||
3. 의미가 어려운 domain type / value object
|
||||
4. 예외/정책/동시성 규약이 중요한 infrastructure API
|
||||
5. package-level overview가 필요한 package
|
||||
|
||||
### 11. implementation comment로 계약을 설명하지 않는다
|
||||
class/member의 전체 목적이나 호출 계약을 설명하는 내용이면 `//` 주석 대신 Javadoc으로 쓴다.
|
||||
|
||||
### 12. package/class level Javadoc은 구조 설명에 사용
|
||||
package/class 수준에서는:
|
||||
- 목적
|
||||
- 포함 내용
|
||||
- 관계
|
||||
- 사용 시 주의점
|
||||
- 외부 문서 링크
|
||||
를 설명할 수 있다.
|
||||
|
||||
긴 설명은 외부 architecture/spec 문서로 분리하고 링크한다.
|
||||
|
||||
### 13. 한 줄 Javadoc은 정말 짧을 때만
|
||||
한 줄로 끝나는 Javadoc은:
|
||||
- 매우 짧고
|
||||
- block tag가 없고
|
||||
- 요약만으로 충분할 때만 사용한다
|
||||
|
||||
### 14. 포맷보다 의미를 우선하되 형식은 일관되게
|
||||
기본 형식:
|
||||
- summary
|
||||
- 빈 줄
|
||||
- 상세 설명 (필요 시)
|
||||
- block tags (`@param`, `@return`, `@throws`, `@deprecated` 순)
|
||||
|
||||
### 15. Javadoc은 examples보다 계약 우선
|
||||
examples/tutorial 성격의 설명은 docs/examples나 외부 문서가 더 적합할 수 있다.
|
||||
Javadoc은 먼저 API contract를 충실히 담는다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- Javadoc은 계약/제약/의미를 문서화할 때만 작성
|
||||
- 자명한 설명, 구현 반복 설명 금지
|
||||
- 첫 줄 summary 필수
|
||||
- `@param/@return/@throws`는 의미 있을 때만 정확히 작성
|
||||
- self-explanatory 멤버와 override는 생략 가능
|
||||
- stale Javadoc 금지
|
||||
- 맞출 수 없으면 수정하거나 삭제
|
||||
@@ -0,0 +1,134 @@
|
||||
# null 처리 기준
|
||||
|
||||
## 목적
|
||||
|
||||
`null`은 “값이 없음”을 표현하는 기본 도구가 아니라, **명시적으로 허용된 경계에서만 제한적으로 다루는 값**으로 취급한다.
|
||||
|
||||
기본값은:
|
||||
- non-null
|
||||
- 명시적으로 nullable인 경우만 `null` 허용
|
||||
- 내부 로직에서는 가능한 빨리 null을 제거하고 더 명확한 표현으로 바꾼다
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- Java는 null-safety를 타입 시스템으로 직접 표현하지 못한다.
|
||||
- 따라서 null 허용 여부는 API 계약, annotation, 검증 코드로 명시해야 한다.
|
||||
- `Objects.requireNonNull(...)`은 메서드/생성자 파라미터 검증 용도로 우선 사용한다.
|
||||
- Spring 계열에서는 package/type 수준의 nullability 기본값과 `@Nullable` 명시를 통해 API 계약을 드러내는 방식을 권장한다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 기본값은 non-null
|
||||
명시적으로 nullable이라고 선언되지 않은 값은 non-null로 간주한다.
|
||||
|
||||
프로젝트 기본 태도:
|
||||
- 파라미터: 기본 non-null
|
||||
- 반환값: 기본 non-null
|
||||
- 필드: 기본 non-null
|
||||
- nullable은 예외적 상황만 명시
|
||||
|
||||
### 2. nullable 여부는 계약으로 드러낸다
|
||||
다음 중 하나로 null 허용 여부를 명시한다.
|
||||
|
||||
- nullability annotation
|
||||
- Optional 반환
|
||||
- 빈 컬렉션/빈 문자열이 아닌 명시적 상태 타입
|
||||
- API/문서/Javadoc 계약
|
||||
|
||||
“읽어보면 알 수 있음” 상태를 금지한다.
|
||||
|
||||
### 3. 경계에서만 null을 받는다
|
||||
다음 경계에서는 null이 들어올 수 있다고 가정하고 방어한다.
|
||||
|
||||
- 외부 요청 입력
|
||||
- DB/JPA 결과
|
||||
- 외부 API 응답
|
||||
- 설정값/환경변수
|
||||
- legacy library API
|
||||
|
||||
하지만 경계를 지나 내부 로직으로 들어오면:
|
||||
- 즉시 검증하거나
|
||||
- Optional/value object/default object/명시적 상태로 변환한다
|
||||
|
||||
### 4. 내부 로직에서 null 전파 금지
|
||||
application/domain/business 로직에서는 nullable 값을 계속 흘려보내지 않는다.
|
||||
|
||||
금지:
|
||||
- 여러 계층을 거치며 nullable String/DTO field를 계속 전달
|
||||
- null 여부를 business 의미처럼 암묵적으로 해석
|
||||
- “일단 null로 두고 나중에 확인” 패턴
|
||||
|
||||
### 5. 파라미터 검증은 가능한 한 즉시
|
||||
public/protected/package boundary 또는 생성자에서는 필요한 경우 초기에 검증한다.
|
||||
|
||||
기본 방식:
|
||||
- `Objects.requireNonNull(...)`
|
||||
- 명시적 argument validation
|
||||
- request binding / validation annotation
|
||||
- value object 생성 시 검증
|
||||
|
||||
### 6. Optional과 null을 섞지 않는다
|
||||
- `Optional` 자체를 null로 두지 않는다
|
||||
- Optional을 반환하면 null 반환 금지
|
||||
- nullable field를 억지로 Optional field로 바꾸지 않는다
|
||||
|
||||
### 7. 컬렉션/배열/스트림은 null 대신 빈 값 우선
|
||||
다건 결과는 가능한 한 null 대신 다음을 사용한다.
|
||||
|
||||
- empty list
|
||||
- empty set
|
||||
- empty map
|
||||
- empty stream
|
||||
- empty array
|
||||
|
||||
`null` 컬렉션은 기본 금지다.
|
||||
|
||||
### 8. DTO/직렬화 경계는 nullable을 명시적으로 관리
|
||||
Request/Response DTO에서는 nullable field가 필요할 수 있다.
|
||||
이 경우:
|
||||
- nullable 여부를 명시하고
|
||||
- 내부 로직 진입 전에 변환/검증한다.
|
||||
|
||||
DTO의 nullable 상태를 domain/application 전체로 전파하지 않는다.
|
||||
|
||||
### 9. Entity와 Domain은 구분해서 본다
|
||||
- JPA entity는 DB nullable 제약을 반영할 수 있다
|
||||
- domain model은 비즈니스 invariant 기준으로 더 엄격해야 한다
|
||||
|
||||
즉:
|
||||
- DB가 nullable이어도 domain은 non-null일 수 있다
|
||||
- persistence mapper에서 변환/검증 책임을 진다
|
||||
|
||||
### 10. null-check는 가능한 한 한 곳에서 끝낸다
|
||||
같은 값에 대해 여러 레이어에서 반복 null-check 하지 않는다.
|
||||
|
||||
기본 방향:
|
||||
- 경계에서 검증
|
||||
- 값 객체로 승격
|
||||
- 이후는 non-null 가정
|
||||
|
||||
### 11. null을 business state로 쓰지 않는다
|
||||
다음 패턴을 금지한다.
|
||||
|
||||
- `status == null`이면 임시 상태
|
||||
- `provider == null`이면 로컬 로그인
|
||||
- `deletedAt == null` 같은 인프라 관례를 business 의미로 직접 사용
|
||||
|
||||
business state는 enum, value object, explicit flag, 상태 타입으로 표현한다.
|
||||
|
||||
### 12. annotation 기반 nullability는 일관되게 사용
|
||||
Spring/JSpecify 스타일을 도입하면:
|
||||
- package/type default를 먼저 정하고
|
||||
- 예외만 `@Nullable`로 표시한다
|
||||
- 무의미하게 nullable/non-null annotation을 섞지 않는다
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 기본은 non-null
|
||||
- nullable은 계약으로 명시
|
||||
- 경계에서 null을 받고 내부에서 제거
|
||||
- public/constructor 경계는 `requireNonNull` 등으로 빠르게 검증
|
||||
- 컬렉션/스트림은 null 대신 empty
|
||||
- Optional과 null 혼용 금지
|
||||
- DTO/JPA nullable을 domain/application에 그대로 전파 금지
|
||||
- null을 business meaning으로 사용 금지
|
||||
@@ -0,0 +1,161 @@
|
||||
# Optional 사용 기준
|
||||
|
||||
## 목적
|
||||
|
||||
`Optional`은 값의 부재를 **메서드 반환 타입에서 명시적으로 표현**할 때 사용한다.
|
||||
`null`을 감추기 위한 만능 래퍼로 쓰지 않는다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- `Optional`은 값이 있을 수도 없을 수도 있는 non-null 컨테이너다.
|
||||
- `Optional`은 **주로 메서드 반환 타입**으로 사용하도록 의도되었다.
|
||||
- `Optional` 변수 자체는 `null`이면 안 된다.
|
||||
- `Optional`은 value-based class이므로 동기화나 `==` 같은 identity-sensitive 사용을 하지 않는다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 기본값은 반환 타입으로만 사용
|
||||
|
||||
다음 경우에만 `Optional<T>`를 우선 검토한다.
|
||||
|
||||
- 조회 결과가 없을 수 있는 단건 반환
|
||||
- 파싱/탐색/검색 결과가 없을 수 있는 반환
|
||||
- 호출자가 "없음"을 명시적으로 처리해야 하는 API
|
||||
|
||||
예:
|
||||
|
||||
- `findById`
|
||||
- `findUserByEmail`
|
||||
- `parseXxx`
|
||||
- `resolveCurrentTenant`
|
||||
|
||||
### 2. 필드 타입으로는 기본 금지
|
||||
|
||||
`Optional`을 엔티티 필드, DTO 필드, 설정 필드, 상태 저장 필드로 두지 않는다.
|
||||
|
||||
이유:
|
||||
|
||||
- 공식적으로 `Optional`은 주로 반환 타입 용도다.
|
||||
- 상태 저장 필드에서는 nullable field, value object, 별도 타입, 또는 명시적 상태 모델이 더 적합하다.
|
||||
- nullable `Optional`, empty `Optional`, present `Optional`의 혼합은 의미를 더 복잡하게 만든다.
|
||||
|
||||
### 3. 파라미터 타입으로는 기본 금지
|
||||
|
||||
메서드/생성자 파라미터에 `Optional<T>`를 직접 받지 않는다.
|
||||
|
||||
대신 아래를 우선 검토한다.
|
||||
|
||||
- 메서드 오버로드
|
||||
- 별도 명시적 파라미터 타입
|
||||
- nullable + 명확한 nullability annotation
|
||||
- 요청 객체/커맨드 객체로 캡슐화
|
||||
|
||||
예외:
|
||||
|
||||
- 외부 라이브러리 API와의 일관성을 맞춰야 하는 경우
|
||||
- 함수형 조합 API에서 정말 표현력이 좋아지는 경우
|
||||
|
||||
### 4. 절대 금지: `Optional`을 null로 사용
|
||||
|
||||
`Optional<T>` 타입 변수/반환값/파라미터에 `null`을 쓰지 않는다.
|
||||
|
||||
금지 예:
|
||||
|
||||
```java
|
||||
return null; // Optional<T> 반환 메서드
|
||||
Optional<User> user = null;
|
||||
someMethod(null); // Optional<User> 파라미터
|
||||
```
|
||||
|
||||
없음은 반드시 `Optional.empty()`로 표현한다.
|
||||
|
||||
### 5. `get()`은 기본 금지
|
||||
|
||||
`get()`은 값이 없으면 예외를 던진다.
|
||||
공식 문서도 preferred alternative로 `orElseThrow()`를 제시한다.
|
||||
|
||||
기본 규칙:
|
||||
|
||||
- `get()`은 테스트 코드나 명백한 증명 직후의 제한된 상황이 아니면 사용하지 않는다.
|
||||
- 업무 코드에서는 `map`, `flatMap`, `filter`, `ifPresent`, `orElse`, `orElseGet`, `orElseThrow`를 사용한다.
|
||||
|
||||
### 6. `isPresent()` + `get()` 조합은 기본 금지
|
||||
|
||||
다음 패턴은 기본 금지한다.
|
||||
|
||||
```java
|
||||
if (opt.isPresent()) {
|
||||
return opt.get();
|
||||
}
|
||||
```
|
||||
|
||||
대신 아래를 우선 검토한다.
|
||||
|
||||
- `map`
|
||||
- `ifPresent`
|
||||
- `orElse`
|
||||
- `orElseGet`
|
||||
- `orElseThrow`
|
||||
|
||||
### 7. 기본값 계산 비용이 있으면 `orElseGet`
|
||||
|
||||
단순 상수/기존 값이면 `orElse`를 검토한다.
|
||||
계산/조회/생성 비용이 있거나 side-effect 가능성이 있으면 `orElseGet`을 사용한다.
|
||||
|
||||
기본 원칙:
|
||||
|
||||
- expensive default는 `orElseGet`
|
||||
- 단순 literal/default object는 `orElse` 검토
|
||||
|
||||
### 8. nested Optional 금지
|
||||
|
||||
`Optional<Optional<T>>` 형태를 만들지 않는다.
|
||||
|
||||
- mapper가 다시 `Optional`을 반환하면 `map` 대신 `flatMap`을 사용한다.
|
||||
- 계층 탐색은 `map(...).flatMap(...)` 또는 `flatMap(...)` 조합으로 평탄화한다.
|
||||
|
||||
### 9. `map`은 값 변환, `ifPresent`는 최종 부작용
|
||||
|
||||
- 변환/계산/연결은 `map`, `flatMap`, `filter`를 사용한다.
|
||||
- 최종 경계 side-effect는 `ifPresent`, `ifPresentOrElse`를 사용한다.
|
||||
|
||||
반환값을 쓰지 않을 때 `map`을 사용하지 않는다.
|
||||
|
||||
### 10. 컬렉션/스트림은 Optional보다 빈 표현 우선
|
||||
|
||||
컬렉션/배열/스트림 결과는 `Optional<List<T>>`보다 빈 컬렉션/빈 스트림을 우선한다.
|
||||
|
||||
기본 규칙:
|
||||
|
||||
- 단건 부재 -> `Optional<T>`
|
||||
- 다건 부재 -> empty collection / empty stream
|
||||
|
||||
### 11. Optional은 직렬화/DTO 경계 기본 금지
|
||||
|
||||
Request/Response DTO, JPA Entity, 설정 프로퍼티 객체에 `Optional` 필드를 두지 않는다.
|
||||
|
||||
이유:
|
||||
|
||||
- 경계 타입은 직렬화/바인딩/스키마 명확성이 우선이다.
|
||||
- `Optional`은 표현 모델보다 API 결과 표현 쪽에 더 적합하다.
|
||||
|
||||
### 12. primitive optional은 정말 필요할 때만
|
||||
|
||||
`OptionalInt`, `OptionalLong`, `OptionalDouble`은 primitive 부재 표현이 정말 중요한 경우에만 사용한다.
|
||||
일반 업무 도메인에서는 보통 `Optional<T>` 또는 명시적 값 객체가 더 읽기 쉽다.
|
||||
|
||||
## Spring / Repository 기준
|
||||
|
||||
- 단건 조회 결과 없음은 `Optional<T>`를 우선 검토한다.
|
||||
- 컬렉션 반환은 `null` 대신 empty collection을 반환한다.
|
||||
- repository package의 nullability는 `@NonNullApi`, `@NonNull`, `@Nullable` 같은 도구와 함께 설계한다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- `Optional`은 기본적으로 반환 타입에만 사용한다.
|
||||
- 필드/파라미터는 기본 금지한다.
|
||||
- `Optional` 자체를 `null`로 두지 않는다.
|
||||
- `get()`과 `isPresent() + get()`은 기본 금지한다.
|
||||
- 값 변환은 `map`, `flatMap`, `filter`를 사용한다.
|
||||
- 최종 부작용은 `ifPresent`, `ifPresentOrElse`를 사용한다.
|
||||
- 다건 결과는 `Optional<List<T>>` 대신 empty collection을 사용한다.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Stream 사용 기준
|
||||
|
||||
## 목적
|
||||
|
||||
`Stream`은 컬렉션을 직접 조작하는 도구가 아니라, 데이터 소스에 대한 집계/변환/검색 파이프라인을 선언적으로 표현하는 도구로 사용한다.
|
||||
|
||||
## 사용할 때
|
||||
|
||||
다음 중 하나에 해당하면 `Stream`을 우선 검토한다.
|
||||
|
||||
- filter / map / flatMap / grouping / reduction처럼 **집계 파이프라인**이 중심일 때
|
||||
- “무엇을 만들 것인가”가 분명하고, 루프보다 **의도가 더 잘 드러날 때**
|
||||
- `anyMatch`, `findFirst`, `max`, `min`, `sum`, `groupingBy`, `partitioningBy` 같은 **집계 연산**이 핵심일 때
|
||||
- 결과를 새로운 컬렉션이나 요약값으로 만드는 작업일 때
|
||||
|
||||
## 사용하지 않을 때
|
||||
|
||||
다음 중 하나에 해당하면 일반 `for` / `for-each`를 기본값으로 사용한다.
|
||||
|
||||
- 핵심이 **부작용(side-effect)** 인 경우
|
||||
- 인덱스 기반 접근이 본질인 경우
|
||||
- 중간 분기/예외 처리/조기 탈출이 많아져 파이프라인이 오히려 읽기 어려운 경우
|
||||
- 디버깅이나 상태 변경이 중심인 경우
|
||||
- 성능 민감 경로인데 스트림이 더 낫다는 측정 근거가 없는 경우
|
||||
|
||||
## 핵심 규칙
|
||||
|
||||
### 1. source를 수정하지 않는다
|
||||
스트림 실행 중 source collection/data source를 수정하지 않는다.
|
||||
|
||||
- 스트림 내부 람다에서 source를 변경하지 않는다
|
||||
- 외부에서 동시에 source를 수정하지 않는다
|
||||
- source 변경이 필요하면 스트림 밖에서 먼저 끝내고, 새 스트림을 만든다
|
||||
|
||||
### 2. 람다는 non-interfering, stateless를 기본값으로 한다
|
||||
`filter`, `map`, `sorted`, `reduce`, `collect` 등에 넘기는 람다는 기본적으로 상태를 들고 있지 않아야 하며, source나 외부 공유 상태를 변경하지 않아야 한다.
|
||||
|
||||
금지 예:
|
||||
- 외부 `List`에 `forEach`로 accumulate
|
||||
- `AtomicInteger` 같은 외부 상태를 카운터처럼 밀어넣기
|
||||
- 람다 안에서 source collection 변경
|
||||
|
||||
### 3. side-effect에 의존하지 않는다
|
||||
`forEach` / `forEachOrdered`를 제외하면, 스트림 구현은 최적화를 위해 일부 연산이나 람다 호출을 생략할 수 있다.
|
||||
따라서 비즈니스 로직은 side-effect가 아니라 **reduction / collection** 으로 표현한다.
|
||||
|
||||
### 4. 한 번 사용한 Stream은 재사용하지 않는다
|
||||
terminal operation 이후 스트림은 소모된다.
|
||||
같은 데이터를 다시 순회하려면 source에서 새 stream을 만든다.
|
||||
|
||||
### 5. stateful intermediate operation은 비용을 의식한다
|
||||
`sorted`, `distinct`, 일부 `limit/skip` 조합은 비용이 크거나 버퍼링이 필요할 수 있다.
|
||||
정렬/중복 제거는 정말 필요할 때만 넣는다.
|
||||
|
||||
### 6. 기본은 sequential stream이다
|
||||
기본값은 `stream()`이다.
|
||||
`parallel()` / `parallelStream()`은 아래를 모두 만족할 때만 검토한다.
|
||||
|
||||
- 병렬화 이득이 측정됨
|
||||
- 공유 상태/side-effect 없음
|
||||
- 순서 보장이 중요하지 않거나 비용을 감수 가능
|
||||
- collector/연산이 병렬 친화적임
|
||||
|
||||
### 7. `forEach`는 결과 생성이 아니라 최종 경계 부작용에만 쓴다
|
||||
`forEach`는 보통 아래와 같은 최종 경계에서만 허용한다.
|
||||
|
||||
- 로그 출력
|
||||
- 이벤트 발행
|
||||
- 외부 시스템 호출
|
||||
- 이미 계산된 결과를 최종 전달
|
||||
|
||||
컬렉션 생성/집계는 `collect`, `reduce`, `toList` 등으로 표현한다.
|
||||
|
||||
### 8. `peek`는 디버깅용으로만 제한한다
|
||||
`peek`는 비즈니스 로직, 상태 변경, 필수 검증 로직에 사용하지 않는다.
|
||||
임시 디버깅 후 제거를 기본으로 한다.
|
||||
|
||||
### 9. 컬렉션 결과 규칙을 명확히 한다
|
||||
- 수정 불가능한 결과가 목적이면 `toList()` 또는 `Collectors.toUnmodifiableList()`를 우선 검토한다
|
||||
- 구체 컬렉션 타입이 필요하면 `Collectors.toCollection(...)`을 사용한다
|
||||
- `Collectors.toList()` 결과를 mutable이라고 가정하지 않는다
|
||||
|
||||
### 10. 숫자 집계는 primitive stream을 우선 검토한다
|
||||
합계/평균/최댓값/최솟값 중심이면 `mapToInt`, `mapToLong`, `mapToDouble`을 우선 검토해 boxing 비용과 표현 복잡도를 줄인다.
|
||||
|
||||
### 11. I/O 기반 stream은 닫는다
|
||||
`Files.lines(...)` 같은 I/O 기반 stream은 `try-with-resources`로 닫는다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 집계/변환/검색 파이프라인이면 Stream
|
||||
- 부작용/복잡한 제어 흐름이면 loop
|
||||
- source 변경 금지
|
||||
- 외부 mutable state 의존 금지
|
||||
- `forEach`로 수집 금지
|
||||
- `parallel()`은 측정 기반으로만 허용
|
||||
- 결과 컬렉션의 mutability를 명시적으로 선택
|
||||
@@ -0,0 +1,160 @@
|
||||
# time 타입 / 포맷 기준
|
||||
|
||||
## 목적
|
||||
|
||||
시간은 문자열이나 숫자 조합이 아니라 **의미에 맞는 타입**으로 표현한다.
|
||||
“시점”, “날짜”, “시간”, “벽시계 기준 날짜-시간”, “시간대 포함 날짜-시간”, “기간”을 서로 다른 개념으로 취급한다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- `java.time`는 날짜/시간/instant/duration의 기본 API다.
|
||||
- `java.time` 타입은 immutable, thread-safe 다.
|
||||
- 시스템 경계를 넘는 저장/네트워크에는 ISO-8601 기반 `java.time` 타입을 우선 사용한다.
|
||||
- `Instant`는 timestamp에 해당한다.
|
||||
- `LocalDate`는 날짜만 표현한다.
|
||||
- `LocalTime`은 시간만 표현한다.
|
||||
- `LocalDateTime`은 날짜+시간이지만 offset/time-zone이 없다.
|
||||
- `ZonedDateTime`은 time-zone까지 포함한 “full” date-time이다.
|
||||
- `OffsetDateTime`은 UTC offset을 포함하며 네트워크 프로토콜/DB 접근에서 주로 쓰인다.
|
||||
- 현재 시각은 `Clock`에서 얻을 수 있고, 이는 테스트를 쉽게 만든다.
|
||||
- `now()` 계열은 시스템 clock과 기본 time-zone을 사용한다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 문자열 대신 타입으로 표현
|
||||
시간/날짜를 내부 로직에서 `String`으로 들고 다니지 않는다.
|
||||
|
||||
금지:
|
||||
- `"2026-04-12T10:15:30Z"`를 business field로 보관
|
||||
- `"2026-04-12"`를 domain/application 로직에서 날짜 대신 사용
|
||||
- `"09:00"`를 opening time 의미로 직접 비교
|
||||
|
||||
기본:
|
||||
- 날짜 -> `LocalDate`
|
||||
- 시각만 -> `LocalTime`
|
||||
- 시점 -> `Instant`
|
||||
- 날짜+시간(시간대 없음) -> `LocalDateTime`
|
||||
- 시간대 포함 wall-clock -> `ZonedDateTime`
|
||||
- offset 포함 경계 표현 -> `OffsetDateTime`
|
||||
- 시간 간격 -> `Duration`
|
||||
- 사람 기준 기간(일/월/년) -> `Period`
|
||||
|
||||
### 2. 시점(point in time)은 `Instant`를 기본값으로
|
||||
로그 시각, 생성 시각, 만료 시각, 이벤트 발생 시각처럼 timeline 위 한 점이면 `Instant`를 우선한다.
|
||||
|
||||
예:
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
- `expiresAt`
|
||||
- `issuedAt`
|
||||
|
||||
### 3. 사람 기준 날짜/시간은 Local 타입 우선
|
||||
시간대와 무관한 도메인 의미에는 Local 타입을 쓴다.
|
||||
|
||||
예:
|
||||
- 생일 -> `LocalDate`
|
||||
- 영업 시작 시각 -> `LocalTime`
|
||||
- UI에서 입력한 예약 시각(아직 zone 미확정) -> `LocalDateTime`
|
||||
|
||||
### 4. `LocalDateTime`은 시점이 아니다
|
||||
`LocalDateTime`은 offset/time-zone이 없으므로 절대적인 시점으로 저장/비교/교환할 때 기본값으로 쓰지 않는다.
|
||||
|
||||
금지 예:
|
||||
- `createdAt`를 `LocalDateTime`으로 저장
|
||||
- 외부 시스템과 절대 시각을 `LocalDateTime`으로 주고받기
|
||||
|
||||
### 5. `ZonedDateTime`은 정말 시간대 계산이 필요할 때만
|
||||
실제 지역 시간대 규칙(DST 포함)을 고려해야 하는 계산만 `ZonedDateTime`을 쓴다.
|
||||
|
||||
예:
|
||||
- 특정 도시/지역 wall-clock 기준 예약
|
||||
- 사용자 time-zone 기준 만료/알림 계산
|
||||
|
||||
기본값은 아니다. 시간대는 복잡도를 크게 높인다.
|
||||
|
||||
### 6. 경계/프로토콜/DB에서는 `Instant` 또는 `OffsetDateTime`을 우선 검토
|
||||
시스템 경계를 넘는 시간 값은 ISO-8601 기반 `java.time` 타입을 사용한다.
|
||||
|
||||
기본 선택:
|
||||
- “절대 시점” 저장/전송 -> `Instant`
|
||||
- offset이 포함된 wire/db 표현 필요 -> `OffsetDateTime`
|
||||
|
||||
### 7. 현재 시각은 `Clock` 기반으로 다룰 수 있게 설계
|
||||
테스트 가능성이 중요한 코드에서는 `Instant.now()` / `LocalDate.now()` / `LocalDateTime.now()`를 직접 박지 않는다.
|
||||
|
||||
기본 방향:
|
||||
- 현재 시각을 얻는 위치를 경계/서비스로 모은다
|
||||
- 필요 시 `Clock`을 주입한다
|
||||
- 테스트에서는 고정 clock을 사용한다
|
||||
|
||||
### 8. 기본 시스템 time-zone 의존 최소화
|
||||
기본 시스템 time-zone을 암묵적으로 쓰는 호출은 신중히 제한한다.
|
||||
|
||||
기본 원칙:
|
||||
- zone이 중요하면 `ZoneId`를 명시한다
|
||||
- `now()` 계열의 기본 zone 의존은 casual code에서만 허용
|
||||
- business logic, persistence, cross-system contract에서는 zone을 명시적으로 다룬다
|
||||
|
||||
### 9. 포맷/파싱은 경계에서만
|
||||
날짜/시간 포맷팅과 파싱은 주로 boundary에서 수행한다.
|
||||
|
||||
예:
|
||||
- controller/request binding
|
||||
- response serialization
|
||||
- external API adapter
|
||||
- logging formatter
|
||||
|
||||
domain/application 내부에서는 typed value를 유지한다.
|
||||
|
||||
### 10. 기본 포맷은 ISO-8601 우선
|
||||
새로운 커스텀 날짜 포맷을 기본값으로 만들지 않는다.
|
||||
시스템 간 교환은 ISO-8601을 우선한다.
|
||||
|
||||
### 11. `Duration`과 `Period`를 구분
|
||||
- 기계적 시간 간격 -> `Duration`
|
||||
- 사람 기준 달력 기간 -> `Period`
|
||||
|
||||
예:
|
||||
- access token TTL -> `Duration`
|
||||
- “30일 후”, “1개월 후” 같은 달력 의미 -> `Period`
|
||||
|
||||
### 12. legacy 시간 API는 새 코드에서 기본 금지
|
||||
새 코드에서는 다음을 기본 금지한다.
|
||||
|
||||
- `java.util.Date`
|
||||
- `java.util.Calendar`
|
||||
- `java.sql.Timestamp`
|
||||
- `TimeZone` 중심 설계
|
||||
- `System.currentTimeMillis()`를 직접 흩뿌리는 패턴
|
||||
|
||||
기본은 `java.time` 사용이다.
|
||||
|
||||
### 13. 저장/조회/직렬화 기준을 명시
|
||||
시간 값을 DB/JSON/API에 노출할 때는 다음을 명확히 한다.
|
||||
|
||||
- 어떤 타입을 저장하는가
|
||||
- 어떤 zone/offset 가정을 하는가
|
||||
- 어떤 포맷으로 직렬화하는가
|
||||
- 정밀도(초/밀리초/나노초)를 어느 수준까지 쓸 것인가
|
||||
|
||||
### 14. 문자열 비교/부분 파싱으로 시간 로직 작성 금지
|
||||
시간 판단을 문자열 조작으로 하지 않는다.
|
||||
|
||||
금지:
|
||||
- `timestamp.startsWith(...)`
|
||||
- `"09:00".compareTo(...)`
|
||||
- substring으로 연/월/일 추출
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 시간은 타입으로 표현한다
|
||||
- 시점은 `Instant`
|
||||
- 날짜는 `LocalDate`
|
||||
- 시각은 `LocalTime`
|
||||
- `LocalDateTime`은 절대 시점 용도 금지
|
||||
- 실제 time-zone 계산만 `ZonedDateTime`
|
||||
- 경계/DB/네트워크는 `Instant` 또는 `OffsetDateTime`
|
||||
- 현재 시각은 가능하면 `Clock` 기반
|
||||
- 기본 시스템 time-zone 의존 최소화
|
||||
- 포맷/파싱은 경계에서만
|
||||
- 새 코드에서 legacy 시간 API 금지
|
||||
@@ -0,0 +1,298 @@
|
||||
# Exception Log 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 예외를 로그로 남길 때의 기준을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 같은 실패를 여러 레이어에서 중복 로그하는 일을 줄인다
|
||||
- 대표 예외 로그가 운영에 필요한 맥락을 충분히 담게 한다
|
||||
- 예외 메시지, stack trace, 민감정보 노출을 통제한다
|
||||
- 예외 처리와 예외 로깅의 책임을 분리한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot / OWASP 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 예외 처리와 예외 로깅은 같은 문제가 아니다
|
||||
|
||||
Spring은 @ExceptionHandler, @ControllerAdvice, @RestControllerAdvice, ResponseEntityExceptionHandler로 예외를 HTTP 응답으로 변환할 수 있게 합니다. 하지만 예외를 응답으로 바꾸는 위치가 곧 예외를 항상 거기서만 로그해야 한다는 뜻은 아닙니다. 이 프로젝트에서는 예외 변환 책임과 대표 예외 로그 책임을 구분합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 예외를 HTTP/API 응답으로 만드는 책임과
|
||||
- 예외를 운영 로그로 남기는 책임을
|
||||
- 같은 지점에 둘 수도 있지만, 개념적으로는 구분한다
|
||||
|
||||
### 3.2 대표 예외 로그는 한 번만 남긴다
|
||||
|
||||
Spring은 예외를 전역 advice에서 일관되게 처리할 수 있게 해 주므로, 같은 예외가 controller, service, client, advice에서 모두 stack trace와 함께 반복 기록될 필요는 없습니다. 실무적으로도 한 실패에 대해 대표 ERROR 로그 한 번을 남기고, 나머지는 보조 맥락만 남기는 편이 검색·알림·분석 품질이 좋습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 한 실패에 대해 대표 예외 로그 한 번을 원칙으로 한다
|
||||
- 하위 계층은 필요하면 DEBUG 또는 WARN으로 맥락만 남긴다
|
||||
- 같은 stack trace를 여러 레이어에서 반복 ERROR로 남기지 않는다
|
||||
|
||||
### 3.3 예외 로그는 “무슨 일이 왜 어디서 실패했는지”를 설명해야 한다
|
||||
|
||||
Spring Boot는 기본 로그에 level, thread, logger, correlation 정보를 담을 수 있고, structured logging도 지원합니다. 따라서 예외 로그도 단순 ex.getMessage()가 아니라, 요청/작업/외부 시스템/소요시간/에러 코드 같은 운영 키를 함께 남겨야 의미가 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 예외 로그는 사건 설명을 먼저 쓴다
|
||||
- 그 뒤에 운영 필드(key=value)를 붙인다
|
||||
- 예외 객체(stack trace)는 마지막 인자로 넘긴다
|
||||
|
||||
권장 예:
|
||||
|
||||
```text
|
||||
Failed external auth request. provider=keycloak actorId=u_001 requestPath=/api/v1/sessions errorCode=UPSTREAM_AUTH_SERVER_UNAVAILABLE
|
||||
```
|
||||
|
||||
## 4. 대표 로그 위치 규칙
|
||||
|
||||
### 4.1 HTTP 요청 실패의 대표 로그는 공통 경계에서 남긴다
|
||||
|
||||
Spring MVC는 전역 @ControllerAdvice/@RestControllerAdvice에서 controller 예외를 공통 처리할 수 있습니다. 따라서 일반 HTTP 요청 실패의 대표 예외 로그는 보통 전역 예외 처리 경계 또는 요청 완료 공통 로깅 경계 중 한 곳에서 일관되게 남기는 것이 적절합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 요청 실패는 공통 advice 또는 공통 요청 로깅 경계에서 대표 로그를 남긴다
|
||||
- controller 메서드마다 try-catch + log.error를 반복하지 않는다
|
||||
- controller local @ExceptionHandler가 있어도 대표 예외 로그 위치는 프로젝트 단위로 일관되게 유지한다
|
||||
|
||||
### 4.2 외부 API 실패의 대표 로그는 “최종 실패가 확정된 경계”에서 남긴다
|
||||
|
||||
외부 연동은 client/adapter 계층에서 많은 중간 실패가 생길 수 있습니다. 이런 중간 실패를 모두 ERROR로 남기면 재시도 후 성공한 케이스도 장애처럼 보일 수 있습니다. 따라서 대표 로그는 최종적으로 호출 결과가 실패로 확정된 시점에 남기는 것이 좋습니다. 이 원칙은 앞서 정한 log level 기준과도 맞습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 재시도 전 단일 실패는 기본적으로 DEBUG 또는 WARN
|
||||
- 재시도 후 최종 실패가 되면 대표 ERROR
|
||||
- fallback으로 정상 복구되면 WARN 또는 INFO로 남기고 ERROR로 과장하지 않는다
|
||||
|
||||
### 4.3 배치/스케줄/비동기 작업은 작업 경계에서 대표 로그를 남긴다
|
||||
|
||||
Spring의 예외 처리 문맥은 HTTP controller만을 위한 것이 아니므로, 스케줄/비동기/배치 작업에서는 작업 진입점이나 orchestration 경계에서 대표 예외 로그를 남겨야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 스케줄 작업은 job/unit-of-work 경계에서 대표 로그를 남긴다
|
||||
- 비동기 후속 작업도 작업 단위 식별자와 함께 실패를 기록한다
|
||||
- 내부 helper 메서드들이 모두 각자 ERROR를 찍지 않는다
|
||||
|
||||
## 5. 로그 레벨 규칙
|
||||
|
||||
### 5.1 최종 실패 예외는 ERROR
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 요청/작업이 최종 실패로 끝났으면 ERROR
|
||||
- 응답이 5xx이거나, 작업 결과가 실패로 종료되면 ERROR
|
||||
- 복구되지 않은 예외는 ERROR
|
||||
|
||||
### 5.2 복구된 예외는 WARN 또는 DEBUG
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 재시도 후 성공
|
||||
- fallback 후 성공
|
||||
- 대체 경로로 정상 처리
|
||||
|
||||
이 경우 대표 로그는 WARN 또는 필요 시 INFO
|
||||
|
||||
stack trace가 꼭 필요하지 않으면 DEBUG/WARN 요약 로그만 남긴다
|
||||
|
||||
### 5.3 예상 가능한 클라이언트 오류는 무조건 ERROR로 남기지 않는다
|
||||
|
||||
Spring에서 validation 예외나 request parsing 예외도 전역 advice에서 처리할 수 있지만, 그것이 모두 서버 이상을 뜻하는 것은 아닙니다. OWASP도 보안상 가치 있는 실패는 남기라고 하지만, 민감정보 노출 없이 맥락 중심으로 남기라고 권고합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- validation 실패
|
||||
- 잘못된 요청 파라미터
|
||||
- business rule rejection
|
||||
- 권한 없음
|
||||
|
||||
같은 예상 가능한 4xx는 기본적으로 INFO 또는 WARN
|
||||
|
||||
대량 이상 징후가 아니면 ERROR로 과장하지 않는다
|
||||
|
||||
## 6. 메시지 구성 규칙
|
||||
|
||||
### 6.1 예외 로그 제목은 예외 메시지가 아니라 사건 설명이다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- log.error(ex.getMessage(), ex)를 기본 금지
|
||||
- 로그 제목은 애플리케이션이 통제하는 사건 설명으로 쓴다
|
||||
- 예외 메시지는 보조 정보일 뿐, 로그 제목의 전부가 아니다
|
||||
|
||||
권장:
|
||||
|
||||
```java
|
||||
log.error("Failed to create session. actorId={} requestPath={} errorCode={}",
|
||||
actorId, requestPath, errorCode, ex);
|
||||
```
|
||||
|
||||
### 6.2 대표 예외 로그의 권장 필드
|
||||
|
||||
권장 필드:
|
||||
|
||||
- traceId 또는 correlation ID 연결 가능 정보
|
||||
- requestPath
|
||||
- method
|
||||
- actorId
|
||||
- operation
|
||||
- resourceId
|
||||
- externalSystem
|
||||
- errorCode
|
||||
- status
|
||||
- durationMs
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 모든 필드를 다 강제하지는 않는다
|
||||
- 해당 실패를 운영에서 추적하는 데 필요한 최소 필드를 남긴다
|
||||
- 같은 종류의 예외 로그는 같은 필드 이름을 유지한다
|
||||
|
||||
### 6.3 stack trace만으로 맥락을 대체하지 않는다
|
||||
|
||||
Spring Boot는 structured logging에서 stack trace 출력 방식도 조정할 수 있지만, stack trace는 어디까지나 원인 분석용입니다. 운영자가 “무슨 요청/작업이 왜 실패했는지”를 빠르게 이해하려면 메시지 맥락이 필요합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- stack trace가 있으니 메시지를 대충 쓰지 않는다
|
||||
- 메시지는 사건 설명과 운영 키를 담고
|
||||
- stack trace는 원인 분석을 보조한다
|
||||
|
||||
## 7. stack trace 규칙
|
||||
|
||||
### 7.1 대표 ERROR 로그에는 기본적으로 stack trace를 포함한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 최종 실패를 나타내는 대표 ERROR 로그는 기본적으로 예외 객체를 함께 남긴다
|
||||
- stack trace 없는 ERROR 로그는 원인 분석에 불리하므로 예외적 경우에만 허용한다
|
||||
|
||||
### 7.2 WARN/INFO에서는 stack trace를 신중하게 남긴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 재시도 후 성공, fallback 후 성공 같은 경우에는 stack trace 없이 요약 로그를 우선한다
|
||||
- 같은 원인의 경고가 고빈도로 반복될 수 있으면 stack trace를 매번 남기지 않는다
|
||||
- 필요하면 최초 1회만 stack trace, 이후는 요약만 남기는 전략을 검토한다
|
||||
|
||||
### 7.3 너무 큰 stack trace는 구조화 포맷과 수집 비용을 고려한다
|
||||
|
||||
Spring Boot는 structured logging에서 stack trace 포함과 길이, 출력 방식을 조정할 수 있습니다. 큰 예외가 자주 발생하는 시스템에서는 수집 비용과 검색성을 고려해야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 대량 반복 예외의 stack trace 출력 정책은 운영 비용을 고려해 조정한다
|
||||
- 하지만 비용을 이유로 대표 실패의 원인 정보가 완전히 사라지게 만들지는 않는다
|
||||
|
||||
## 8. 민감정보 규칙
|
||||
|
||||
### 8.1 예외 로그도 PII/sensitive 규칙을 그대로 따른다
|
||||
|
||||
OWASP는 세션 식별값, 토큰, 비밀번호, 민감 PII, 키/비밀값 등은 직접 로그에 남기지 말라고 권고합니다. 예외 로그도 예외가 아닙니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 예외 로그 제목에 민감정보 원문 금지
|
||||
- 예외 메시지에 민감정보가 포함될 수 있으면 그대로 재사용 금지
|
||||
- request/response body 전문을 예외 맥락으로 붙이지 않는다
|
||||
- 외부 시스템 에러 본문도 원문 그대로 남기지 않는다
|
||||
|
||||
### 8.2 stack trace에도 비밀값이 섞일 수 있음을 전제한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 예외 생성 메시지에 비밀값을 넣지 않는 것이 우선
|
||||
- 예외에 포함된 URL, 헤더, payload, connection string, token 등을 주의한다
|
||||
- 비밀값이 exception message에 들어가도록 코드를 짜지 않는다
|
||||
|
||||
## 9. 번역(translation) 규칙
|
||||
|
||||
### 9.1 내부 예외와 외부 응답 메시지를 분리한다
|
||||
|
||||
Spring은 @ExceptionHandler와 ResponseEntityExceptionHandler로 응답 변환을 지원합니다. 이 프로젝트는 예외 로그와 API 응답 메시지도 분리합니다. 즉, 로그에는 운영에 필요한 안전한 맥락을 남기고, 응답은 ErrorCode와 외부 메시지 규약으로 보냅니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 로그 메시지 ≠ API 응답 메시지
|
||||
- ex.getMessage()를 API 응답에도, 로그 제목에도 그대로 재사용하지 않는다
|
||||
- advice는 응답 변환을, 대표 로그는 운영 맥락 기록을 담당한다
|
||||
|
||||
### 9.2 예외 번역 계층이 있다면 원인 체인을 잃지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- external/client 예외 → integration 예외 → application 예외로 번역할 수 있다
|
||||
- 이 과정에서 root cause를 완전히 잃지 않는다
|
||||
- 대표 로그는 번역된 비즈니스 의미와 원인 예외를 함께 남길 수 있어야 한다
|
||||
|
||||
## 10. 위치별 세부 규칙
|
||||
|
||||
### 10.1 controller
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller에서 try-catch + log.error를 기본 금지
|
||||
- 공통 advice가 있는 구조에서는 controller는 예외를 그대로 위로 전파한다
|
||||
- endpoint-local 특별 정책이 있어도 대표 예외 로그는 한 번만 남긴다
|
||||
|
||||
### 10.2 application service
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- application은 예외를 business/application 의미로 번역할 수 있다
|
||||
- 하지만 같은 예외를 무조건 ERROR로 남기지는 않는다
|
||||
- 최종 실패 책임이 상위 경계에 있으면 여기서는 DEBUG/WARN 맥락만 남길 수 있다
|
||||
|
||||
### 10.3 external client / integration adapter
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 호출 1회 실패는 기본적으로 요약 로그
|
||||
- 재시도/fallback/최종 실패 여부에 따라 상위에서 대표 로그를 결정한다
|
||||
- 외부 payload/headers/token 원문은 남기지 않는다
|
||||
|
||||
### 10.4 advice / global exception handler
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 요청 실패의 대표 로그 위치를 advice로 정했다면 거기서 일관되게 남긴다
|
||||
- validation/4xx/5xx에 따라 레벨을 다르게 적용할 수 있다
|
||||
- advice가 응답 생성만 하고 로그는 요청 공통 경계에서 남기는 구조도 허용하되, 프로젝트 전체로 하나를 택한다
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 같은 예외를 여러 레이어에서 모두 ERROR로 기록
|
||||
- log.error(ex.getMessage(), ex) 남발
|
||||
- request/response body 전문을 예외 로그에 포함
|
||||
- 토큰, 세션 ID, 비밀번호, 키, PII를 예외 로그에 원문으로 기록
|
||||
- validation/예상 가능한 4xx를 무조건 ERROR 처리
|
||||
- controller마다 try-catch + log.error + ResponseEntity 반복
|
||||
- stack trace 없이 맥락도 없는 ERROR 한 줄만 남김
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 실패에 대해 대표 예외 로그가 한 번만 남는가?
|
||||
- 대표 로그는 사건 설명과 운영 키를 담는가?
|
||||
- 최종 실패만 ERROR로 남기고 있는가?
|
||||
- 복구된 예외를 과도하게 ERROR로 찍지 않는가?
|
||||
- 예외 로그에도 민감정보 마스킹 규칙이 그대로 적용되는가?
|
||||
- 로그 메시지와 API 응답 메시지를 분리하고 있는가?
|
||||
- stack trace가 필요한 곳에는 남고, 불필요한 중복은 줄였는가?
|
||||
@@ -0,0 +1,290 @@
|
||||
# Log Level 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 로그 레벨의 의미와 사용 기준을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 같은 종류의 사건이 서비스마다 다른 레벨로 찍히는 일을 줄인다
|
||||
- 운영에서 필요한 신호와 디버깅용 노이즈를 구분한다
|
||||
- 로그 레벨이 알림, 검색, 장애 대응에 일관되게 쓰이게 한다
|
||||
- ERROR/WARN/INFO/DEBUG/TRACE의 역할을 명확히 한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Boot / Spring Framework 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 설명 위에 일반적인 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 로그 레벨은 “중요도”와 “운영 행동 필요성”을 표현한다
|
||||
|
||||
Spring Boot는 기본 로그 출력에 level을 포함하고, observability를 logging, metrics, traces의 세 축으로 설명한다. 이 프로젝트에서 로그 레벨은 단순 출력 강도가 아니라 운영 의미를 표현한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 레벨은 “이 사건이 얼마나 심각한가”를 표현한다
|
||||
- 레벨은 “운영자가 지금 당장 봐야 하는가”를 암시한다
|
||||
- 디버깅 편의를 위해 레벨을 올려 쓰지 않는다
|
||||
|
||||
### 3.2 기본 운영 레벨은 INFO다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- production 기본 로그 레벨은 INFO
|
||||
- DEBUG/TRACE는 상시 기본값으로 열지 않는다
|
||||
- 장애 분석이나 특정 모듈 추적이 필요할 때 범위를 좁혀 일시적으로 올린다
|
||||
|
||||
이 규칙은 Spring Framework가 TRACE와 DEBUG를 과도한 fire hose가 되지 않게 다뤄야 한다고 설명하는 방향과도 맞다.
|
||||
|
||||
### 3.3 FATAL은 사용하지 않는다
|
||||
|
||||
Spring Boot 공식 문서는 Logback에는 FATAL 레벨이 없고 ERROR로 매핑된다고 명시한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- FATAL 레벨 사용 금지
|
||||
- 치명적 장애도 ERROR로 기록
|
||||
- “매우 치명적”이라는 구분은 레벨이 아니라 메시지, 에러 코드, 알림 정책으로 표현한다
|
||||
|
||||
## 4. 레벨별 기준
|
||||
|
||||
### 4.1 ERROR
|
||||
|
||||
의미
|
||||
|
||||
요청, 작업, 배치, 연동, 내부 처리 중 실패가 발생했고 정상 경로로 복구되지 않았음을 뜻한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 요청이 최종 실패한 경우
|
||||
- 외부 연동 실패가 호출 결과 실패로 이어진 경우
|
||||
- 비동기 작업/스케줄 작업이 실패로 종료된 경우
|
||||
- 복구되지 않은 예외가 발생한 경우
|
||||
- 데이터 정합성 문제, 시스템 오작동, 예상하지 못한 분기
|
||||
|
||||
이런 경우 ERROR
|
||||
|
||||
쓰지 말아야 할 경우
|
||||
|
||||
- 예외가 발생했지만 의도된 비즈니스 흐름인 경우
|
||||
- 재시도로 정상 복구된 중간 실패
|
||||
- validation 실패 같은 예상 가능한 클라이언트 오류를 서버 이상처럼 과장하는 경우
|
||||
|
||||
### 4.2 WARN
|
||||
|
||||
의미
|
||||
|
||||
즉시 실패는 아니지만 문제 가능성이 높거나, 운영자가 추후 확인할 가치가 있는 비정상 상황을 뜻한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 자동 복구되었지만 이상 징후가 있는 경우
|
||||
- fallback이 실행된 경우
|
||||
- 재시도 후 성공했지만 원인 추적이 필요한 경우
|
||||
- 느린 외부 의존성, 느린 health indicator, 비정상 입력 패턴
|
||||
- deprecation 사용, 설정 누락 대체값 적용, 비권장 경로 사용
|
||||
|
||||
이런 경우 WARN
|
||||
|
||||
Spring Boot는 느린 health indicator에 대해 기본적으로 warning 로그를 남기며, 그 임계값을 설정할 수 있다고 설명한다.
|
||||
|
||||
쓰지 말아야 할 경우
|
||||
|
||||
- 정상적인 분기
|
||||
- 자주 발생하는 business rejection
|
||||
- 운영상 행동이 전혀 필요 없는 정보성 사건
|
||||
|
||||
### 4.3 INFO
|
||||
|
||||
의미
|
||||
|
||||
서비스의 주요 정상 상태 변화와 운영상 의미 있는 사건을 뜻한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 애플리케이션 시작/종료
|
||||
- 중요한 배치 시작/완료
|
||||
- 의미 있는 상태 전이
|
||||
- 주요 외부 연동 시작/완료
|
||||
- 보안상 중요한 성공 이벤트
|
||||
- 운영자가 흐름을 이해하는 데 필요한 핵심 사건
|
||||
|
||||
이런 경우 INFO
|
||||
|
||||
쓰지 말아야 할 경우
|
||||
|
||||
- 모든 요청 진입/종료를 무조건 INFO로 남기는 것
|
||||
- 고빈도 내부 반복 처리
|
||||
- 디버깅용 변수 dump
|
||||
|
||||
### 4.4 DEBUG
|
||||
|
||||
의미
|
||||
|
||||
장애 분석이나 개발 중 원인 파악에 도움이 되는 상세 내부 흐름 정보다.
|
||||
|
||||
Spring Framework는 TRACE가 DEBUG와 유사한 원칙을 따르며 과도한 로그가 되어선 안 된다고 설명한다. 이 원칙은 DEBUG에도 그대로 적용된다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 주요 분기 결정 근거
|
||||
- 내부 상태 변화 상세
|
||||
- 외부 API 요청/응답 요약
|
||||
- 매핑 결과 요약
|
||||
- 재시도/백오프/fallback 판단 근거
|
||||
|
||||
이런 경우 DEBUG
|
||||
|
||||
쓰지 말아야 할 경우
|
||||
|
||||
- 민감정보 원문 출력
|
||||
- 너무 큰 payload 전체 dump
|
||||
- 운영 기본 레벨에서 계속 쌓이면 안 되는 고빈도 로그
|
||||
|
||||
### 4.5 TRACE
|
||||
|
||||
의미
|
||||
|
||||
매우 세밀한 흐름 추적용 로그다.
|
||||
|
||||
Spring Framework는 TRACE도 DEBUG처럼 다뤄야 하며 fire hose가 되어서는 안 된다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 프레임워크/바인딩/직렬화/세밀한 분기 추적
|
||||
- 특정 문제 재현 시에만 필요한 상세 이벤트
|
||||
- 고빈도 루프/반복 처리의 초미세 추적
|
||||
|
||||
쓰지 말아야 할 경우
|
||||
|
||||
- production 상시 활성화
|
||||
- 요청 본문, 응답 본문, 대용량 객체 전체 출력
|
||||
- 민감정보 포함 가능성이 있는 상세 dump
|
||||
|
||||
## 5. 상황별 권장 기준
|
||||
|
||||
### 5.1 요청 처리
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 정상 요청 전체를 기본적으로 INFO로 남기지 않는다
|
||||
- 요청 실패는 최종 실패 시 ERROR
|
||||
- 비정상 입력 패턴을 별도 관찰할 필요가 있으면 WARN
|
||||
- 상세 요청 흐름은 DEBUG 이하
|
||||
|
||||
### 5.2 외부 API 연동
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 최종 실패: ERROR
|
||||
- 재시도 후 성공: 기본 WARN
|
||||
- fallback 성공: 기본 WARN
|
||||
- 단일 시도 상세 요청/응답 요약: DEBUG
|
||||
- payload 전문 출력: 기본 금지, 정말 필요하면 TRACE에서도 마스킹 필수
|
||||
|
||||
### 5.3 비즈니스 거절
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 예상 가능한 비즈니스 거절은 기본적으로 INFO 또는 무로그
|
||||
- 같은 거절이 이상 징후를 뜻하면 WARN
|
||||
- 정상 정책 집행을 ERROR로 기록하지 않는다
|
||||
|
||||
예:
|
||||
|
||||
- 중복 가입 시도
|
||||
- 권한 없음
|
||||
- 유효성 검사 실패
|
||||
|
||||
이런 것은 기본적으로 서버 장애가 아니다
|
||||
|
||||
### 5.4 예외
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 요청/작업을 실패시키는 예외: ERROR
|
||||
- 재시도/대체 경로로 복구된 예외: WARN 또는 DEBUG
|
||||
- controller advice에서 이미 최종 실패를 기록했다면 하위 계층 중복 ERROR 로그를 피한다
|
||||
|
||||
## 6. 중복 로그 방지 규칙
|
||||
|
||||
### 6.1 같은 실패를 여러 레이어에서 모두 ERROR로 찍지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 한 실패에 대해 대표 ERROR 로그 한 번을 원칙으로 한다
|
||||
- 하위 계층은 필요하면 DEBUG/WARN으로 맥락만 남긴다
|
||||
- controller, service, client, advice가 같은 예외를 모두 stack trace와 함께 ERROR로 찍지 않는다
|
||||
|
||||
### 6.2 로깅 위치는 책임과 함께 정한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 최종 실패 책임을 가진 경계에서 대표 로그를 남긴다
|
||||
- 외부 연동 세부 맥락은 client/adapter에서 DEBUG/WARN
|
||||
- 공통 예외 응답 생성은 advice가 하더라도, 실제 대표 에러 로그 위치는 프로젝트 단위로 일관되게 정한다
|
||||
|
||||
## 7. 민감정보와 레벨의 관계
|
||||
|
||||
### 7.1 레벨이 낮다고 민감정보를 찍어도 되는 것은 아니다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- DEBUG/TRACE라도 비밀번호, 토큰, 주민번호, 카드번호, 이메일 전체값 등 민감정보 원문 출력 금지
|
||||
- 낮은 레벨은 더 상세할 수 있을 뿐, 보안 예외 구간이 아니다
|
||||
- 상세 추적이 필요하면 마스킹/요약/해시 처리한다
|
||||
|
||||
## 8. Correlation / Trace와의 관계
|
||||
|
||||
Spring Boot는 tracing이 활성화되면 로그에 correlation ID를 포함할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 레벨과 무관하게 운영 로그는 trace/correlation 연결이 가능해야 한다
|
||||
- 같은 오류가 여러 로그로 흩어져도 추적 가능한 키가 있어야 한다
|
||||
- traceId, principal, request path 기록 규칙은 별도 문서에서 구체화한다
|
||||
|
||||
## 9. 운영 설정 규칙
|
||||
|
||||
### 9.1 production 기본값
|
||||
|
||||
프로젝트 권장 기본값:
|
||||
|
||||
- root: INFO
|
||||
- 애플리케이션 주요 패키지: INFO
|
||||
- noisy framework package: 필요 시 WARN
|
||||
- 특정 문제 조사 시 모듈 단위로만 DEBUG/TRACE 상향
|
||||
|
||||
### 9.2 일시적 상향은 범위를 좁힌다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- root를 DEBUG/TRACE로 올리는 것을 기본 금지
|
||||
- 특정 패키지, 특정 client, 특정 기능 단위로만 조정
|
||||
- 문제 해결 후 원래 레벨로 되돌린다
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- FATAL 사용
|
||||
- 정상 흐름을 ERROR/WARN으로 과장
|
||||
- 실패 하나를 여러 레이어에서 중복 ERROR 출력
|
||||
- production 기본 레벨을 DEBUG/TRACE로 설정
|
||||
- DEBUG/TRACE에서 민감정보 원문 출력
|
||||
- 모든 요청/응답을 INFO로 남기는 것
|
||||
- 디버깅 편의 때문에 레벨 의미를 무너뜨리는 것
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 로그는 해당 레벨의 운영 의미와 맞는가?
|
||||
- 정상 흐름을 불필요하게 경고/오류로 올리지 않았는가?
|
||||
- 같은 실패를 대표 로그 한 번으로 정리하고 있는가?
|
||||
- DEBUG/TRACE가 상시 fire hose가 되지 않는가?
|
||||
- 민감정보가 레벨과 무관하게 보호되는가?
|
||||
- tracing/correlation과 연결 가능한가?
|
||||
@@ -0,0 +1,359 @@
|
||||
# Log Message Format 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 로그 메시지의 형식과 구성 원칙을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 로그가 사람과 시스템 모두에게 읽기 쉬운 형식을 갖게 한다
|
||||
- 검색, 집계, 상관 분석에 필요한 키를 일관되게 남긴다
|
||||
- free text만으로 의미를 전달하는 로그를 줄인다
|
||||
- 운영 로그와 디버깅 로그의 메시지 품질을 일정 수준 이상으로 유지한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Boot 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 로그 메시지는 “짧은 사건 설명 + 핵심 키-값” 구조를 기본으로 한다
|
||||
|
||||
Spring Boot 기본 로그는 timestamp, level, thread, correlation ID, logger, message 같은 요소를 이미 분리해서 출력한다. 이 프로젝트에서는 message 본문도 이와 같은 방향으로 짧은 사건 설명 + 핵심 필드 구조를 따르도록 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 메시지 앞부분은 한 문장 사건 설명
|
||||
- 뒤에는 검색 가능한 핵심 필드를 key-value로 붙인다
|
||||
- 긴 문장 서술형 로그보다 구조화된 짧은 메시지를 선호한다
|
||||
|
||||
권장 예:
|
||||
|
||||
```text
|
||||
Failed to issue external auth token. provider=keycloak actorId=123 requestPath=/api/v1/sessions
|
||||
```
|
||||
|
||||
### 3.2 free text보다 필드가 더 중요하다
|
||||
|
||||
Spring Boot는 구조화 로그를 공식 지원하고, JSON 필드 include/exclude/rename/add까지 제공한다. 이는 운영에서 로그를 사람이 읽기만 하는 것이 아니라 시스템이 수집·검색·집계한다는 뜻이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 메시지에 사건 설명은 필요하지만, 분석용 핵심 정보는 필드 형태로 남긴다
|
||||
- 같은 종류의 로그는 가능한 한 같은 키 이름을 쓴다
|
||||
- 검색/집계 가능한 키를 free text 속에만 숨기지 않는다
|
||||
|
||||
### 3.3 로그 형식은 전역적으로 일관되어야 한다
|
||||
|
||||
Spring Boot는 기본 포맷과 구조화 포맷을 전역 설정으로 관리할 수 있다. 이 프로젝트도 메시지 형식을 클래스마다 제각각 두지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 종류의 사건은 같은 키 이름과 같은 순서를 최대한 유지한다
|
||||
- 운영 로그 형식은 팀 공통 규약으로 다룬다
|
||||
- 특정 개발자 취향에 따라 메시지 문체가 바뀌지 않게 한다
|
||||
|
||||
## 4. 기본 메시지 형식
|
||||
|
||||
### 4.1 권장 기본 형식
|
||||
|
||||
권장 형식:
|
||||
|
||||
```text
|
||||
<짧은 사건 설명>. key1=value1 key2=value2 key3=value3
|
||||
```
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 사건 설명은 과도하게 길지 않게 쓴다
|
||||
- 사건 설명 뒤에 핵심 필드를 공백으로 구분해 이어 붙인다
|
||||
- 문장 속에 값을 길게 섞어 넣기보다 key=value 형태를 우선한다
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
User registration completed. actorId=123 userId=u_001
|
||||
External auth request failed. provider=keycloak actorId=123 status=503
|
||||
```
|
||||
|
||||
### 4.2 메시지는 과거형/완료형보다 사건 중심으로 쓴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- “무슨 일이 일어났는가”가 바로 보이게 쓴다
|
||||
- 장황한 설명보다 사건명 중심으로 쓴다
|
||||
- 성공/실패/재시도/대체 경로가 제목 수준에서 드러나야 한다
|
||||
|
||||
권장:
|
||||
|
||||
- Created session
|
||||
- Failed to create session
|
||||
- Retried external auth request
|
||||
- Applied fallback token validation
|
||||
|
||||
비권장:
|
||||
|
||||
- Trying to do session creation and got an unexpected issue while processing
|
||||
|
||||
## 5. 필수/권장 필드 규칙
|
||||
|
||||
### 5.1 운영 핵심 로그의 필수 후보 필드
|
||||
|
||||
Spring Boot 기본 로그에는 이미 thread, logger, correlation ID 같은 정보가 들어갈 수 있고, tracing이 활성화되면 correlation ID도 로그에 포함된다. 이 프로젝트는 메시지 본문에서도 운영에 필요한 business key를 추가로 남긴다.
|
||||
|
||||
운영 핵심 로그의 권장 필드:
|
||||
|
||||
- traceId 또는 correlation ID 연결 가능 정보
|
||||
- actorId 또는 principal 식별자
|
||||
- requestPath
|
||||
- operation
|
||||
- resourceId
|
||||
- externalSystem
|
||||
- status 또는 errorCode
|
||||
- durationMs
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 모든 로그에 다 넣으라는 뜻은 아니다
|
||||
- 그 사건을 운영에서 추적하는 데 필요한 최소 필드를 고른다
|
||||
- 메시지마다 필드 이름을 바꾸지 않는다
|
||||
|
||||
### 5.2 같은 의미에는 같은 키 이름을 쓴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 사용자 식별자는 userId 또는 actorId 중 하나로 표준화한다
|
||||
- 경로는 path가 아니라 requestPath처럼 의미를 분명히 한다
|
||||
- 외부 연동 대상은 provider, externalSystem, clientName 중 문서로 정한 하나를 사용한다
|
||||
|
||||
예:
|
||||
|
||||
- userId, uid, memberId를 섞지 않는다
|
||||
- url, uri, path를 상황마다 바꾸지 않는다
|
||||
|
||||
## 6. 사람이 읽는 로그와 구조화 로그의 관계
|
||||
|
||||
### 6.1 기본 텍스트 로그도 구조화 가능해야 한다
|
||||
|
||||
Spring Boot는 콘솔/파일 로그를 기본 텍스트 형식으로 출력하면서도, correlation ID와 핵심 정보를 포함할 수 있게 설계되어 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 텍스트 로그라도 key=value 패턴을 유지한다
|
||||
- grep/search가 가능해야 한다
|
||||
- “말이 되는 문장”보다 “검색 가능한 문장”을 우선한다
|
||||
|
||||
### 6.2 구조화 로그(JSON)는 수집 시스템이 있으면 우선 검토한다
|
||||
|
||||
Spring Boot는 ECS, GELF, Logstash JSON structured logging을 공식 지원한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 중앙 수집 시스템(예: ELK, Graylog, Datadog 등)이 있으면 structured logging을 우선 검토한다
|
||||
- 다만 JSON 로그를 쓰더라도 필드 naming 규칙과 메시지 사건 설명 규칙은 그대로 유지한다
|
||||
- 텍스트 로그와 JSON 로그가 서로 전혀 다른 의미 체계를 가지지 않게 한다
|
||||
|
||||
### 6.3 JSON 구조는 ingestion 시스템에 맞추되, 프로젝트 핵심 필드는 유지한다
|
||||
|
||||
Spring Boot는 JSON structured logging에서 include/exclude/rename/add를 지원한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 수집 시스템 요구에 맞게 JSON 필드명을 조정할 수 있다
|
||||
- 그러나 프로젝트 핵심 검색 키(traceId, actorId, requestPath, errorCode)는 일관되게 유지한다
|
||||
- ingestion 편의 때문에 business 의미가 흐려지지 않게 한다
|
||||
|
||||
## 7. 예외와 스택트레이스 메시지 규칙
|
||||
|
||||
### 7.1 메시지와 예외 스택트레이스는 역할이 다르다
|
||||
|
||||
Spring Boot는 구조화 로그에서 예외가 함께 로그되면 stack trace도 포함되며, 비용을 줄이기 위해 출력 방식을 조정할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 메시지는 “무슨 요청/작업이 왜 실패했는지”를 요약한다
|
||||
- stack trace는 원인 분석용이다
|
||||
- stack trace가 있으니 메시지를 대충 쓰지 않는다
|
||||
- 반대로 메시지가 충분하다고 stack trace를 무조건 생략하지도 않는다
|
||||
|
||||
### 7.2 예외 메시지를 그대로 로그 제목으로 쓰지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- log.error(ex.getMessage(), ex) 형태를 기본값으로 쓰지 않는다
|
||||
- 사건 설명과 운영 키를 먼저 쓰고 예외를 마지막 인자로 붙인다
|
||||
- 예외 메시지는 보조 정보이지, 로그 제목의 전부가 아니다
|
||||
|
||||
권장:
|
||||
|
||||
```java
|
||||
log.error("Failed to issue token. provider={} actorId={}", provider, actorId, ex);
|
||||
```
|
||||
|
||||
비권장:
|
||||
|
||||
```java
|
||||
log.error(ex.getMessage(), ex);
|
||||
```
|
||||
|
||||
## 8. 민감정보/대용량 데이터 규칙
|
||||
|
||||
### 8.1 메시지 본문에 민감정보 원문을 넣지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 비밀번호, 액세스 토큰, 리프레시 토큰, 인증 헤더, 주민번호, 카드번호, 이메일 전체값 등은 원문 출력 금지
|
||||
- 필요한 경우 일부 마스킹, 해시, 길이/유형 정보만 출력
|
||||
- 구조화 로그에서도 같은 기준을 적용한다
|
||||
|
||||
### 8.2 payload 전문을 기본 로그 메시지에 넣지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request/response 전문 출력은 기본 금지
|
||||
- 꼭 필요하면 별도 debug/trace 전용 경로에서 제한적으로 출력
|
||||
- 긴 배열, JSON 본문, 바이너리 응답, HTML 전체를 로그 메시지에 직접 넣지 않는다
|
||||
|
||||
### 8.3 식별자는 최소한으로 남긴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 사람을 직접 식별하는 값보다 내부 식별자(userId, sessionId, orderId)를 우선 사용
|
||||
- 외부 식별자가 필요해도 전체 원문 대신 일부만 남기는 방식을 검토한다
|
||||
|
||||
## 9. 메시지 작성 세부 규칙
|
||||
|
||||
### 9.1 시제와 문체를 통일한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 사건 제목은 영어 기준 단순 과거/완료형 또는 failed/succeeded 형식으로 통일한다
|
||||
- 같은 팀 안에서 create user success, user created, successfully created user처럼 문체가 섞이지 않게 한다
|
||||
- 문장 종결 부호는 짧게 유지한다
|
||||
|
||||
권장 예:
|
||||
|
||||
- Created user
|
||||
- Failed to create user
|
||||
- Completed session cleanup
|
||||
- Rejected invalid request
|
||||
|
||||
### 9.2 불필요한 수식어를 줄인다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- very, really, unexpectedly, seriously 같은 감정/강조 표현 지양
|
||||
- 심각도는 레벨과 에러 코드가 표현하게 한다
|
||||
- 메시지는 사실 중심으로 작성한다
|
||||
|
||||
### 9.3 단위가 있는 값은 키 이름에 단위를 포함한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 시간은 durationMs
|
||||
- 바이트는 payloadBytes
|
||||
- 개수는 itemCount
|
||||
- 단위를 메시지 문장 속에 숨기지 않는다
|
||||
|
||||
## 10. 권장 메시지 패턴
|
||||
|
||||
### 10.1 요청 처리
|
||||
|
||||
성공:
|
||||
|
||||
```text
|
||||
Completed request. requestPath=/api/v1/users method=POST status=201 durationMs=42
|
||||
```
|
||||
|
||||
실패:
|
||||
|
||||
```text
|
||||
Failed request. requestPath=/api/v1/users method=POST status=500 errorCode=INTERNAL_SERVER_ERROR durationMs=42
|
||||
```
|
||||
|
||||
### 10.2 외부 연동
|
||||
|
||||
성공:
|
||||
|
||||
```text
|
||||
Completed external auth request. provider=keycloak status=200 durationMs=84
|
||||
```
|
||||
|
||||
재시도 후 성공:
|
||||
|
||||
```text
|
||||
Succeeded external auth request after retry. provider=keycloak attempts=2 durationMs=312
|
||||
```
|
||||
|
||||
실패:
|
||||
|
||||
```text
|
||||
Failed external auth request. provider=keycloak status=503 errorCode=UPSTREAM_AUTH_SERVER_UNAVAILABLE
|
||||
```
|
||||
|
||||
### 10.3 배치/스케줄
|
||||
|
||||
시작:
|
||||
|
||||
```text
|
||||
Started expired session cleanup. job=expired-session-cleanup
|
||||
```
|
||||
|
||||
완료:
|
||||
|
||||
```text
|
||||
Completed expired session cleanup. job=expired-session-cleanup deletedCount=143 durationMs=1820
|
||||
```
|
||||
|
||||
실패:
|
||||
|
||||
```text
|
||||
Failed expired session cleanup. job=expired-session-cleanup durationMs=905
|
||||
```
|
||||
|
||||
## 11. 구현 규칙
|
||||
|
||||
### 11.1 logger name에 의미를 실지 말고 메시지에 실는다
|
||||
|
||||
Spring Boot 기본 포맷은 logger name을 별도로 출력한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- logger name은 클래스/패키지 출처를 나타내는 데 충분하다
|
||||
- 사건 의미는 메시지와 key-value 필드에 둔다
|
||||
- logger 이름 자체를 읽어야만 사건을 이해할 수 있게 만들지 않는다
|
||||
|
||||
### 11.2 MDC/trace와 겹치는 필드는 중복을 줄인다
|
||||
|
||||
Spring Boot tracing은 correlation ID를 로그에 포함할 수 있고, 패턴 커스터마이징도 지원한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 이미 공통 패턴에 있는 값(traceId 등)을 메시지에 또 반복하지 않는다
|
||||
- 다만 수집 시스템이나 검색 UX상 필요한 경우만 선택적으로 중복한다
|
||||
- 메시지 필드와 로그 패턴 필드의 책임을 나눈다
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- free text만 길게 쓰고 검색 가능한 키를 남기지 않음
|
||||
- 예외 메시지 자체를 로그 제목으로 사용
|
||||
- 같은 의미의 키 이름을 로그마다 다르게 사용
|
||||
- request/response payload 전문을 기본 로그에 출력
|
||||
- 민감정보 원문 출력
|
||||
- structured logging을 쓰면서도 JSON 필드 의미가 일관되지 않음
|
||||
- traceId, actorId, requestPath 같은 핵심 필드를 상황마다 제멋대로 이름 붙임
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 메시지는 한 줄 사건 설명으로 빠르게 이해 가능한가?
|
||||
- 운영에 필요한 핵심 값이 key=value 형태로 드러나는가?
|
||||
- 같은 종류의 로그와 키 이름/순서/문체가 일관적인가?
|
||||
- 메시지와 stack trace의 역할이 분리되어 있는가?
|
||||
- 민감정보나 대용량 payload가 원문으로 들어가지 않았는가?
|
||||
- structured logging으로 전환해도 의미가 유지되는 형식인가?
|
||||
@@ -0,0 +1,260 @@
|
||||
# Operation Indicator / Health Check 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 운영 상태 노출과 health check 기준을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- health endpoint를 단순 ping이 아니라 운영 계약으로 다룬다
|
||||
- liveness, readiness, startup의 의미를 구분한다
|
||||
- 외부 의존성 포함 여부를 일관되게 결정한다
|
||||
- Kubernetes probe, Actuator health group, custom health indicator의 역할을 분리한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Boot / Kubernetes 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 health check는 “살아 있음”과 “트래픽 수용 가능”을 구분해야 한다
|
||||
|
||||
Kubernetes는 probe를 startup, liveness, readiness 세 종류로 구분합니다. liveness는 컨테이너를 재시작해야 하는지 판단하고, readiness는 현재 트래픽을 받을 준비가 되었는지 판단하며, startup은 느린 시작 동안 liveness/readiness 실행을 지연시키는 역할을 합니다. Spring Boot도 ApplicationAvailability를 통해 liveness와 readiness 상태를 별도로 다루고, 이를 health group으로 노출합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- health check를 하나의 “UP/DOWN” 개념으로만 보지 않는다
|
||||
- liveness 와 readiness 를 분리한다
|
||||
- startup 시간이 긴 서비스는 startup probe 검토를 기본으로 한다
|
||||
|
||||
### 3.2 health endpoint는 운영자와 오케스트레이터를 위한 계약이다
|
||||
|
||||
Spring Boot Actuator의 health endpoint는 운영과 모니터링을 위한 엔드포인트이며, health group, status severity order, HTTP status mapping, show-details 정책 등을 설정할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- health endpoint는 애플리케이션 내부 디버그 API가 아니다
|
||||
- 외부 공개 API처럼 임의 shape를 만들지 않는다
|
||||
- Actuator health 규약 위에 필요한 최소 custom indicator만 추가한다
|
||||
|
||||
### 3.3 health는 “무엇을 자동화할 것인가”를 기준으로 설계한다
|
||||
|
||||
Kubernetes는 liveness 실패 시 컨테이너를 재시작하고, readiness 실패 시 그 인스턴스로 트래픽을 보내지 않습니다. 따라서 어떤 검사를 어디에 넣을지는 “실패했을 때 플랫폼이 어떤 행동을 해도 안전한가”를 기준으로 정해야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 재시작이 정답인 문제만 liveness에 반영한다
|
||||
- 일시적 과부하/의존성 문제/초기화 대기는 readiness에 반영할지 검토한다
|
||||
- 단순 상태 조회와 자동 운영 액션 유발 신호를 혼동하지 않는다
|
||||
|
||||
## 4. 엔드포인트 표준
|
||||
|
||||
### 4.1 기본 health endpoint는 /actuator/health다
|
||||
|
||||
Spring Boot는 기본적으로 health endpoint를 제공하고, 일반적으로 /actuator/health에 매핑합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 health endpoint는 Actuator 기본 경로를 따른다
|
||||
- 커스텀 /health를 별도로 만드는 것을 기본값으로 두지 않는다
|
||||
- 외부 노출 정책은 actuator exposure/security 정책과 함께 설계한다
|
||||
|
||||
### 4.2 Kubernetes probe는 health group 경로를 사용한다
|
||||
|
||||
Spring Boot는 Kubernetes 환경에서 /actuator/health/liveness 와 /actuator/health/readiness 를 별도 HTTP probe로 노출할 수 있고, management.endpoint.health.probes.enabled로 제어할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
Kubernetes probe는 기본적으로
|
||||
|
||||
- liveness: /actuator/health/liveness
|
||||
- readiness: /actuator/health/readiness
|
||||
- 별도 이유가 없으면 커스텀 probe URL을 새로 만들지 않는다
|
||||
|
||||
### 4.3 관리 포트 분리 시 메인 포트 추가 노출을 검토한다
|
||||
|
||||
Spring Boot는 actuator가 별도 management port에만 있으면 실제 애플리케이션 연결 상태와 probe 결과가 어긋날 수 있다고 설명하고, management.endpoint.health.probes.add-additional-paths=true로 메인 포트에 /livez, /readyz를 추가할 수 있다고 안내합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- management port를 분리한 서비스는 메인 포트에도 probe path를 노출할지 기본 검토한다
|
||||
- Kubernetes 운영이면 /livez, /readyz 추가 노출을 우선 검토한다
|
||||
- “actuator만 살아 있고 앱은 실제로 못 받는” false positive를 피한다
|
||||
|
||||
## 5. Liveness 표준
|
||||
|
||||
### 5.1 liveness는 “재시작이 필요한가”를 판단한다
|
||||
|
||||
Kubernetes는 liveness probe를 컨테이너 재시작 판단에 사용하고, Spring Boot는 liveness를 “애플리케이션이 스스로 회복 가능한가, 아니면 broken state라 재시작이 필요한가”의 의미로 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- liveness는 프로세스/애플리케이션 내부가 더 이상 정상 진행 불가한가 를 표현한다
|
||||
- deadlock, 내부 상태 붕괴, 회복 불가능한 오류는 liveness 대상이 될 수 있다
|
||||
- 일시적 외부 의존성 장애는 기본적으로 liveness 대상이 아니다
|
||||
|
||||
### 5.2 liveness는 외부 시스템 의존을 기본 금지한다
|
||||
|
||||
Spring Boot는 liveness 상태를 DB, 외부 Web API, 외부 캐시 같은 외부 체크에 기반하면 대규모 재시작과 cascading failure를 유발할 수 있으므로 일반적으로 그렇게 하지 말라고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- DB 연결 상태를 liveness에 넣지 않는다
|
||||
- 외부 API 상태를 liveness에 넣지 않는다
|
||||
- Redis, Kafka, S3 같은 외부 의존성도 기본적으로 liveness에 넣지 않는다
|
||||
|
||||
## 6. Readiness 표준
|
||||
|
||||
### 6.1 readiness는 “현재 트래픽을 받을 준비가 되었는가”를 판단한다
|
||||
|
||||
Kubernetes는 readiness probe를 트래픽 수용 가능 여부 판단에 사용하고, 초기 연결 수립, 파일 로딩, 캐시 warming, 일시적 과부하 복구 같은 상황에 유용하다고 설명합니다. Spring Boot도 readiness 상태를 별도 availability state로 다룹니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- readiness는 현재 인스턴스가 요청을 받아도 되는가 를 표현한다
|
||||
- 시작 중, 일시적 overload, 내부 준비 미완료는 readiness false 대상이 될 수 있다
|
||||
- readiness 실패는 기본적으로 “트래픽 제외” 의미다
|
||||
|
||||
### 6.2 readiness의 외부 의존성 포함은 신중하게 결정한다
|
||||
|
||||
Spring Boot는 readiness probe에 외부 체크를 넣을지 여부는 개발자가 신중히 판단해야 하며, 기본적으로 추가 health check를 넣지 않는다고 설명합니다. 공유된 외부 시스템을 readiness에 넣으면 전체 서비스가 한꺼번에 서비스 제외될 수 있고, 반대로 fallback/circuit breaker가 있는 비필수 의존성은 readiness에 넣지 말아야 한다고도 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
readiness에 외부 의존성을 넣기 전에 아래를 판단한다
|
||||
|
||||
- 이 의존성이 필수 인가
|
||||
- 장애 시 이 인스턴스만 제외하는 것이 맞는가
|
||||
- fallback/circuit breaker로 계속 서비스 가능 한가
|
||||
- 전체 인스턴스가 동시에 readiness false가 될 위험은 없는가
|
||||
- 비필수 외부 시스템은 readiness에서 제외한다
|
||||
- fallback 가능한 외부 시스템은 readiness에서 제외하는 쪽을 기본 선호한다
|
||||
|
||||
## 7. Startup probe 표준
|
||||
|
||||
### 7.1 startup probe는 느린 시작을 보호할 때 사용한다
|
||||
|
||||
Kubernetes는 startup probe가 있으면 그것이 성공하기 전까지 liveness/readiness를 실행하지 않는다고 설명합니다. Spring Boot도 애플리케이션이 liveness 기간보다 오래 걸려 시작할 수 있으면 startup probe를 가능한 해결책으로 언급합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 시작 시간이 긴 서비스는 startup probe를 기본 검토한다
|
||||
- migration, warm-up, 대규모 cache load, 대형 model load 같은 작업이 있으면 startup probe를 우선 검토한다
|
||||
- startup probe 없이 liveness 초기 threshold만 무작정 늘리는 방식을 기본값으로 두지 않는다
|
||||
|
||||
### 7.2 readiness만으로 충분한 경우도 있다
|
||||
|
||||
Spring Boot는 일반적으로 readiness probe가 시작 완료 전까지 실패하므로, startup probe가 항상 필요한 것은 아니라고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- startup probe는 필수 기본값 이 아니다
|
||||
- readiness만으로 충분하면 추가하지 않는다
|
||||
- startup probe는 “느린 시작 때문에 liveness가 오탐하는 경우”에 우선 도입한다
|
||||
|
||||
## 8. Health indicator 표준
|
||||
|
||||
### 8.1 indicator는 “운영상 의미 있는 상태”만 검사한다
|
||||
|
||||
Spring Boot Actuator는 built-in health endpoint와 health group을 제공하고, health group에 include/exclude를 둘 수 있으며, 존재하지 않는 contributor를 참조하면 기본적으로 startup validation에 실패하도록 할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- custom health indicator는 운영상 실제 의미가 있을 때만 추가한다
|
||||
- “검사할 수 있으니 넣는다”를 금지한다
|
||||
- health indicator가 너무 많아져 endpoint가 내부 진단 덤프가 되지 않게 한다
|
||||
|
||||
### 8.2 느린 health indicator는 운영 비용으로 본다
|
||||
|
||||
Spring Boot는 느린 health indicator에 대해 warning 로그를 남기는 threshold 설정을 제공하며 기본값은 10초입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- health check는 저비용이어야 한다
|
||||
- 느린 indicator는 원인 분석 대상이다
|
||||
- health endpoint가 무거운 DB query나 외부 API full round-trip을 기본 수행하지 않게 한다
|
||||
|
||||
### 8.3 health group은 의미 단위로 나눈다
|
||||
|
||||
Spring Boot는 health endpoint groups를 지원하고, liveness/readiness도 그 위에 구현되어 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
기본 그룹은
|
||||
|
||||
- global health
|
||||
- liveness
|
||||
- readiness
|
||||
|
||||
별도 그룹이 필요하면 운영 목적이 분명할 때만 만든다
|
||||
|
||||
그룹 membership은 존재하지 않는 indicator 참조 없이 명시적으로 관리한다
|
||||
|
||||
## 9. 응답/노출 규칙
|
||||
|
||||
### 9.1 health detail 노출은 최소화한다
|
||||
|
||||
Spring Boot는 management.endpoint.health.show-details와 show-components로 상세 노출 범위를 제어할 수 있고, 기본 show-details는 never입니다. 또한 health endpoint 접근과 세부 노출 권한도 제어할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부/일반 접근에서는 상세 health 정보를 기본 비노출
|
||||
- 운영자/내부 접근에서만 상세 정보 노출을 검토한다
|
||||
- health endpoint를 내부 시스템 구조 설명 API처럼 만들지 않는다
|
||||
|
||||
### 9.2 health status와 HTTP status mapping은 임의 변경을 지양한다
|
||||
|
||||
Spring Boot는 health status를 HTTP status로 매핑하는 설정을 제공하고, 기본 등록된 status는 sensible default로 매핑된다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 health status → HTTP status 매핑을 우선 유지한다
|
||||
- 특별한 운영 이유가 없으면 custom mapping을 남발하지 않는다
|
||||
- health endpoint HTTP status는 probe와 모니터링이 기대하는 의미를 깨지 않게 한다
|
||||
|
||||
## 10. 구현 위치 규칙
|
||||
|
||||
### 10.1 probe 경로는 controller가 아니라 Actuator가 담당한다
|
||||
|
||||
Spring Boot는 health endpoint, health group, liveness/readiness probe를 actuator로 제공하므로, probe 용 controller를 별도로 만드는 것이 기본 설계가 아닙니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- /actuator/health* 계열은 actuator에 맡긴다
|
||||
- @RestController("/health") 같은 수제 구현을 기본 금지한다
|
||||
- 필요한 확장은 custom HealthIndicator나 health group 설정으로 해결한다
|
||||
|
||||
### 10.2 외부 의존성 상태 판단은 integration 경계에서, 최종 반영은 health group에서 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 시스템 상태 판단 로직은 관련 integration component가 책임질 수 있다
|
||||
- 하지만 probe 포함 여부는 readiness/liveness 정책 문맥에서 최종 결정한다
|
||||
- 연동 로직이 있다고 해서 자동으로 health indicator에 포함하지 않는다
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- liveness에 DB/외부 API/캐시 같은 외부 의존성 기본 포함
|
||||
- readiness에 모든 외부 시스템을 무비판적으로 포함
|
||||
- 느린/무거운 쿼리를 health indicator에 넣음
|
||||
- probe 용 endpoint를 controller로 직접 새로 만듦
|
||||
- management 포트 분리 상태에서 메인 앱 수용성 차이를 무시
|
||||
- startup이 긴 서비스에 startup probe 검토 없이 liveness 오탐을 방치
|
||||
- health detail을 외부에 과도하게 노출
|
||||
- health group에 존재하지 않는 indicator를 느슨하게 참조
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- liveness와 readiness의 의미를 분리했는가?
|
||||
- liveness가 외부 의존성 상태에 흔들리지 않는가?
|
||||
- readiness에 포함한 외부 의존성은 정말 필수인가?
|
||||
- startup이 긴 서비스라면 startup probe를 검토했는가?
|
||||
- health indicator가 저비용인가?
|
||||
- management 포트 분리 시 main port 추가 probe 경로를 검토했는가?
|
||||
- health detail 노출 범위를 최소화했는가?
|
||||
@@ -0,0 +1,305 @@
|
||||
# PII Masking 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 로그, 트레이스, 운영 이벤트 기록에서 개인정보(PII)와 민감정보를 어떻게 마스킹하거나 제거할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 운영에 필요한 관측 가능성을 유지하면서 민감정보 노출을 막는다
|
||||
- 로그 레벨과 무관하게 보호해야 하는 데이터를 명확히 한다
|
||||
- 마스킹 책임을 개별 개발자 습관이 아니라 공통 규칙으로 만든다
|
||||
- request/response, exception, external API payload, structured logging 모두에 같은 기준을 적용한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: OWASP, Spring Boot 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 가이드 위에 일반적인 운영/보안 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 민감정보 보호는 예외가 아니라 기본값이다
|
||||
|
||||
OWASP는 세션 식별값, 액세스 토큰, 민감한 개인정보, 비밀번호, DB 연결 문자열, 암호화 키, 결제 데이터 등은 로그에 직접 기록하지 말고 제거·마스킹·정제·해시·암호화하라고 권고합니다. 따라서 이 프로젝트에서는 “필요할 때만 숨긴다”가 아니라 기본적으로 숨기고, 꼭 필요한 최소 정보만 남긴다를 원칙으로 둔다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 민감정보는 기본 비기록(non-log) 이 원칙
|
||||
- 필요하면 원문 대신 마스킹/해시/요약값을 남긴다
|
||||
- “디버깅 중이라서”, “DEBUG니까”, “내부망이라서” 같은 이유로 예외를 만들지 않는다
|
||||
|
||||
### 3.2 로그 레벨이 낮다고 민감정보를 찍어도 되는 것은 아니다
|
||||
|
||||
OWASP는 민감정보 자체를 직접 기록하지 말라고 하며, 이는 특정 로그 레벨에 한정된 예외를 두지 않습니다. 이 프로젝트도 DEBUG/TRACE를 보안 예외 구간으로 취급하지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- INFO, DEBUG, TRACE 모두 같은 마스킹 규칙을 따른다
|
||||
- 낮은 레벨은 더 자세할 수 있을 뿐, 더 위험한 데이터를 허용하는 레벨이 아니다
|
||||
|
||||
### 3.3 민감정보 마스킹은 로깅 호출부와 공통 로깅 파이프라인이 함께 책임진다
|
||||
|
||||
Spring Boot는 MDC를 로그 패턴과 structured logging JSON에 포함할 수 있고, JSON 필드의 include/exclude/rename/add와 customizer도 지원합니다. 따라서 민감정보 보호는 “개발자가 매번 조심해서 안 찍는 것”만으로 끝내지 않고, 공통 로깅 구성을 통한 2차 방어선도 둘 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 1차 방어: 애플리케이션 코드에서 원문을 로그 인자로 넘기지 않는다
|
||||
- 2차 방어: 공통 로깅 설정/structured logging customizer/appender 등에서 추가 마스킹을 검토한다
|
||||
- 둘 중 하나만 믿지 않는다
|
||||
|
||||
## 4. 데이터 분류 규칙
|
||||
|
||||
### 4.1 절대 원문 기록 금지 데이터
|
||||
|
||||
OWASP 기준으로 직접 기록하지 말아야 할 대표 데이터는 다음과 같습니다. 세션 식별값, 액세스 토큰, 민감한 개인정보와 일부 PII, 인증 비밀번호, DB 연결 문자열, 암호화 키 및 주요 비밀값, 은행 계좌/카드 정보 등이 여기에 해당합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 원문 로그 금지 다.
|
||||
|
||||
- 비밀번호
|
||||
- access token / refresh token / bearer token
|
||||
- session id / cookie session value / JWT raw token
|
||||
- DB connection string
|
||||
- encryption key / signing key / secret key / API secret
|
||||
- 카드번호 / 계좌번호 / 결제 식별정보 원문
|
||||
- 주민등록번호/여권번호/정부 식별번호류
|
||||
- 건강정보, 법적으로 민감한 개인정보
|
||||
- 외부 시스템 인증 헤더 원문
|
||||
- source code, stack dump 속 비밀 설정값
|
||||
|
||||
### 4.2 기본적으로 직접 기록하지 않는 데이터
|
||||
|
||||
OWASP는 민감한 개인정보 외에도 personal names, telephone numbers, email addresses, internal network names/addresses, file paths 등은 특별한 취급이 필요할 수 있다고 설명합니다. 이 프로젝트에서는 다음 데이터도 원문 기록을 기본값으로 두지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 원문 비권장 이며, 필요 시 축약/부분 마스킹/내부 식별자 대체를 우선한다.
|
||||
|
||||
- 이메일 전체값
|
||||
- 전화번호 전체값
|
||||
- 개인 이름 전체값
|
||||
- 내부 IP/호스트명/내부 네트워크 이름
|
||||
- 로컬 파일 경로
|
||||
- 상세 주소
|
||||
- 주민등록번호 일부를 유추할 수 있는 조합값
|
||||
|
||||
### 4.3 운영에 필요한 식별자는 내부 식별자를 우선한다
|
||||
|
||||
OWASP는 direct/indirect identifier에 대해 de-identification을 검토하라고 권고합니다. 이 프로젝트에서는 사람이 직접 식별되는 값보다 내부 식별자를 우선 남기는 방향을 기본으로 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- email 대신 userId
|
||||
- accountNumber 대신 accountId
|
||||
- phoneNumber 대신 customerId
|
||||
- 꼭 외부 식별자가 필요하면 일부만 남긴다
|
||||
|
||||
## 5. 처리 방식 규칙
|
||||
|
||||
### 5.1 제거, 마스킹, 해시, 암호화의 기본 선택
|
||||
|
||||
OWASP는 민감값을 제거, 마스킹, 정제, 해시, 암호화 중 적절한 방식으로 처리하라고 설명합니다. 이 프로젝트는 다음 우선순위를 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 가장 먼저 검토: 아예 기록하지 않기
|
||||
- 운영 식별이 필요: 부분 마스킹
|
||||
- 동일 값 상관관계만 필요: 해시/토큰화
|
||||
- 정말 복구 가능한 보관이 필요: 별도 보호 저장소를 검토하고 일반 애플리케이션 로그에는 두지 않는다
|
||||
|
||||
### 5.2 부분 마스킹 규칙
|
||||
|
||||
프로젝트 권장 예:
|
||||
|
||||
- 이메일: do***@example.com
|
||||
- 전화번호: 010-****-1234
|
||||
- 카드번호: ************1234
|
||||
- 계좌번호: ******7890
|
||||
- 주민번호류: 기본 로그 금지, 부분 마스킹도 최소화
|
||||
- 토큰/세션 ID: 원문 금지, 필요하면 앞/뒤 일부 + 해시 일부
|
||||
|
||||
### 5.3 해시 사용 규칙
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 값의 반복 여부만 추적하면 될 때 해시 사용 가능
|
||||
- salt/secret이 필요한 경우 보안 정책을 따른다
|
||||
- 해시값도 장기 식별자로 과도하게 남용하지 않는다
|
||||
- 원문 복구가 필요한 요구를 해시로 해결하려 하지 않는다
|
||||
|
||||
## 6. 위치별 규칙
|
||||
|
||||
### 6.1 request logging
|
||||
|
||||
OWASP는 HTTP request body, response body, headers 같은 확장 상세 정보는 민감할 수 있으므로 특별한 주의가 필요하다고 설명합니다. 이 프로젝트에서는 request logging에서 다음을 기본 금지한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request body 전문 로그 금지
|
||||
- Authorization 헤더 원문 금지
|
||||
- Cookie 헤더 원문 금지
|
||||
- query string 전체 로그 금지
|
||||
- multipart 파일명/본문 원문 금지
|
||||
|
||||
허용 가능한 예:
|
||||
|
||||
- requestPath
|
||||
- method
|
||||
- contentType
|
||||
- payloadBytes
|
||||
- allowlist된 소수의 비민감 query field
|
||||
|
||||
### 6.2 response logging
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- response body 전문 로그 금지
|
||||
- token 발급 응답, 인증 응답, 결제 응답 원문 금지
|
||||
- 상태코드, duration, 외부 시스템 상태, 응답 크기 같은 메타데이터만 우선 기록한다
|
||||
|
||||
### 6.3 exception logging
|
||||
|
||||
OWASP는 stack trace, system error messages, debug information, request/response body 등이 민감해질 수 있다고 설명합니다. 따라서 예외 로그는 stack trace를 남기더라도 민감 데이터를 포함한 message/context를 같이 남기지 않도록 조심해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 예외 메시지에 포함된 민감정보 원문을 그대로 로그 제목에 사용하지 않는다
|
||||
- log.error(ex.getMessage(), ex)를 기본값으로 두지 않는다
|
||||
- 사건 설명 + 안전한 식별자 + 예외 객체 순으로 기록한다
|
||||
- stack trace에 비밀값이 포함될 위험이 있는 경로는 별도 검토한다
|
||||
|
||||
### 6.4 external API / integration logging
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 요청/응답 payload 전문 로그 금지
|
||||
- 외부 API key, bearer token, cookie 원문 금지
|
||||
- provider 이름, endpoint path, status, duration, request id 같은 메타데이터만 우선 기록
|
||||
- third-party error body도 민감정보를 포함할 수 있으므로 원문 그대로 남기지 않는다
|
||||
|
||||
### 6.5 audit/security logging
|
||||
|
||||
OWASP는 보안/감사 로그도 중요하지만, 그렇다고 민감값을 그대로 기록하라는 뜻은 아니라고 설명합니다. “무엇이 일어났는지”는 남기되, 값 원문은 보호해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- “비밀번호 변경 시도”, “토큰 재발급 요청”, “민감 데이터 조회” 같은 사건 자체는 기록
|
||||
- 하지만 실제 비밀번호, 토큰, 데이터 원문은 기록하지 않는다
|
||||
- audit log가 필요하면 일반 운영 로그와 목적을 구분한다
|
||||
|
||||
## 7. structured logging / MDC 규칙
|
||||
|
||||
### 7.1 MDC에 넣는 값도 같은 기준으로 마스킹한다
|
||||
|
||||
Spring Boot는 MDC 값을 로그 패턴과 structured JSON에 포함할 수 있고, ECS/GELF/Logstash 포맷에서도 MDC key-value가 JSON에 들어갑니다. 따라서 MDC는 안전한 공통 필드만 넣는 용도로 써야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- MDC에는 traceId, requestPath, actorId, operation, clientIp(필요 시) 정도의 안전한 메타데이터만 둔다
|
||||
- MDC에 token, email 전체값, session id, raw principal object를 넣지 않는다
|
||||
- “MDC니까 괜찮다”는 예외를 두지 않는다
|
||||
|
||||
### 7.2 structured logging JSON 필드도 마스킹 정책 대상이다
|
||||
|
||||
Spring Boot는 structured JSON에서 include/exclude/rename/add, customizer를 지원합니다. 이는 JSON 로그가 plain text보다 안전하다는 뜻이 아니라, 같은 보호 규칙을 중앙에서 적용할 수 있다는 뜻에 가깝다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- structured logging을 쓰면 JSON field 수준 마스킹/제외 규칙을 검토한다
|
||||
- 수집 시스템에 맞춘 필드 rename은 허용하지만, 민감 필드 유입 자체를 방지하는 것이 우선이다
|
||||
- ingestion 단계에서 추가 마스킹이 가능해도 애플리케이션 단계의 원문 출력 금지를 대체하지 않는다
|
||||
|
||||
## 8. 로그 인젝션 방지 규칙
|
||||
|
||||
### 8.1 외부 입력은 로깅 전에 sanitization을 고려한다
|
||||
|
||||
OWASP는 다른 trust zone에서 들어오는 이벤트 데이터는 형식 검증을 하고, CR/LF 및 delimiter 같은 문자를 정제해 로그 인젝션을 막으라고 권고합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 사용자 입력, 외부 시스템 응답, 헤더, query parameter를 로그에 넣기 전 길이/형식 검토
|
||||
- CR/LF, 탭, 구분자, 제어문자 정제
|
||||
- multi-line injection이 가능한 원문을 그대로 로그 메시지에 넣지 않는다
|
||||
|
||||
### 8.2 예외 메시지와 외부 오류 메시지도 신뢰하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 시스템의 에러 메시지를 그대로 로그 제목에 사용하지 않는다
|
||||
- 예외 메시지를 한 줄 요약/정제 후 보조 정보로만 사용한다
|
||||
- 로그 제목은 애플리케이션이 통제하는 문장으로 쓴다
|
||||
|
||||
## 9. 구현 규칙
|
||||
|
||||
### 9.1 공통 마스킹 유틸/컴포넌트를 둔다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 이메일, 전화번호, 토큰, 계좌/카드 마스킹은 공통 유틸이나 formatter로 제공한다
|
||||
- 서비스마다 제각각 다른 마스킹 패턴을 쓰지 않는다
|
||||
- 로그 메시지 안에서 직접 substring으로 잘라 쓰는 임시 구현을 줄인다
|
||||
|
||||
### 9.2 logger 호출 전에 안전한 값으로 변환한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- log.info("...", rawToken) 금지
|
||||
- log.info("...", maskedToken(rawToken)) 형태로만 허용
|
||||
- 가능하면 값을 마스킹한 뒤 변수에 담아 의미 있는 이름으로 사용한다
|
||||
|
||||
### 9.3 가능한 경우 중앙 로깅 구성에서 2차 필터링을 둔다
|
||||
|
||||
Spring Boot는 structured logging JSON customizer와 MDC 기반 필드 구성을 지원합니다. 이를 이용해 민감 필드가 특정 이름으로 유입될 경우 제거/대체하는 2차 방어선을 둘 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- authorization, accessToken, password, sessionId 같은 공통 금지 키는 중앙 필터링을 검토한다
|
||||
- 다만 중앙 필터링이 있으니 코드에서 원문을 찍어도 된다는 뜻은 아니다
|
||||
|
||||
## 10. 테스트/검증 규칙
|
||||
|
||||
### 10.1 로그에 금지 데이터가 실제로 남지 않는지 검증한다
|
||||
|
||||
OWASP는 로깅 메커니즘의 설계/구현/검증을 강조합니다. 이 프로젝트도 마스킹 정책을 문서만 두지 않고 테스트/리뷰 대상으로 본다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 주요 인증/결제/개인정보 흐름은 로그 검증 테스트를 둘 수 있다
|
||||
- 보안 리뷰 체크리스트에 “민감정보 로그 노출 여부”를 포함한다
|
||||
- 샘플 로그/운영 로그 점검으로 정책 위반을 찾아낸다
|
||||
|
||||
### 10.2 규칙 위반은 기능 버그가 아니라 보안 버그로 취급한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 토큰/비밀번호/세션 ID/PII 원문 노출은 보안 결함으로 분류
|
||||
- “로그일 뿐”이라고 축소하지 않는다
|
||||
- 수정 우선순위를 높게 둔다
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 비밀번호 원문 로그
|
||||
- access token / refresh token / session id / cookie 값 원문 로그
|
||||
- Authorization 헤더 원문 로그
|
||||
- request/response body 전문 로그
|
||||
- 이메일/전화번호/이름 등 개인식별자 전체값 무비판적 로그
|
||||
- 외부 API secret, DB connection string, key material 로그
|
||||
- DEBUG/TRACE라는 이유로 민감정보 예외 허용
|
||||
- MDC/structured JSON에 민감정보 적재
|
||||
- CR/LF 등 제어문자 포함 원문을 그대로 로그에 기록
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 값은 정말 로그에 남겨야 하는가?
|
||||
- 원문 대신 내부 식별자/마스킹/해시로 충분하지 않은가?
|
||||
- 토큰, 세션 ID, 비밀번호, key material, 결제정보가 원문으로 남지 않는가?
|
||||
- request/response 전문을 기본 로그에 넣고 있지 않은가?
|
||||
- MDC와 structured logging 필드에도 같은 보호 규칙이 적용되는가?
|
||||
- 외부 입력을 로그에 넣기 전에 sanitization을 검토했는가?
|
||||
- 공통 마스킹 유틸/구성과 코드 레벨 금지 규칙이 함께 적용되는가?
|
||||
@@ -0,0 +1,302 @@
|
||||
# Trace / Principal / Path Recording 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 운영 로그와 트레이싱에서 traceId, principal(현재 사용자/호출 주체), requestPath를 어디서, 어떤 이름으로, 어느 정도까지 기록할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 한 요청/작업을 trace 단위로 추적 가능하게 만든다
|
||||
- 사용자/호출 주체와 요청 경로를 일관된 키로 검색 가능하게 만든다
|
||||
- Security 타입과 Servlet 저수준 접근이 여러 계층으로 퍼지는 것을 막는다
|
||||
- 민감정보를 보호하면서도 운영에 필요한 상관관계를 유지한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Boot / Spring Framework / Spring Security 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 traceId, principal, requestPath는 운영 상관관계의 핵심 키다
|
||||
|
||||
Spring Boot는 tracing이 켜져 있으면 로그에 correlation ID를 기본 포함할 수 있다고 설명한다. 이 프로젝트에서는 그 위에 누가(actor/principal), 어떤 경로(requestPath) 에서 발생한 사건인지까지 함께 남겨서, 장애 분석과 감사 추적이 가능한 최소 세트를 만든다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 대표 운영 로그는 가능한 한 traceId, principal/actor, requestPath 중 필요한 값을 함께 가진다
|
||||
- 세 값은 “있으면 좋은 정보”가 아니라, 요청/작업 상관 분석의 기본 키로 본다
|
||||
- 다만 모든 로그에 세 값을 기계적으로 다 넣는 것은 지양하고, 사건 종류에 맞게 최소 세트를 고른다
|
||||
|
||||
### 3.2 공통 상관 키는 로그 패턴/MDC에, 비즈니스 식별자는 메시지 필드에 둔다
|
||||
|
||||
Spring Boot는 correlation ID를 로그 패턴에 포함시키고, 형식도 조정할 수 있게 한다. 따라서 trace/correlation 같은 공통 값은 패턴/MDC 레벨에서 처리하고, principal, requestPath, operation, resourceId 같은 값은 로그 메시지 필드로 남기는 것이 자연스럽다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- traceId는 기본적으로 로그 패턴/MDC에 두는 것을 우선
|
||||
- principal, requestPath, operation, resourceId는 메시지 key-value 필드로 남긴다
|
||||
- 이미 패턴에 있는 값을 메시지에 불필요하게 중복하지 않는다
|
||||
|
||||
### 3.3 principal 기록은 인증 객체 접근 방식과 분리해서 생각하지 않는다
|
||||
|
||||
Spring Security는 @AuthenticationPrincipal과 메타 애노테이션 기반 @CurrentUser 패턴을 제공한다. 이 프로젝트에서는 현재 사용자 접근 방식과 principal 로깅 규칙을 같은 방향으로 맞춘다. 즉, controller가 SecurityContextHolder를 직접 뒤져서 principal을 로그에 넣는 식의 접근을 기본 금지한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- principal 기록은 @CurrentUser 같은 전용 현재 사용자 접근 규칙과 함께 설계한다
|
||||
- 보안 프레임워크 내부 타입 전체를 로그에 덤프하지 않는다
|
||||
- 로그에는 필요한 최소 principal 식별자만 남긴다
|
||||
|
||||
## 4. TraceId 기록 규칙
|
||||
|
||||
### 4.1 traceId는 tracing이 활성화된 서비스에서 기본적으로 로그에 포함되어야 한다
|
||||
|
||||
Spring Boot는 Micrometer Tracing을 사용하는 경우 로그에 correlation ID를 기본 포함할 수 있다고 설명한다. 기본 correlation ID는 traceId-spanId 형태다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- tracing이 켜진 서비스는 운영 로그에 trace/correlation 식별자가 기본 포함되어야 한다
|
||||
- traceId는 가능한 한 로그 패턴/MDC 레벨에서 일관되게 출력한다
|
||||
- 서비스마다 trace 키 이름이나 형식을 제각각 바꾸지 않는다
|
||||
|
||||
### 4.2 traceId는 대표 로그 검색의 1차 키로 본다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 요청 실패, 외부 연동 실패, 배치 실패 같은 대표 로그는 trace로 묶여야 한다
|
||||
- 한 요청 흐름에서 여러 로그가 흩어져도 traceId로 묶어 검색 가능해야 한다
|
||||
- traceId가 없다면 같은 요청의 controller/client/db 연관 로그를 잇기 어렵다는 점을 기본 전제로 둔다
|
||||
|
||||
### 4.3 비동기/스케줄/외부 연동 로그도 가능한 한 trace 연결성을 유지한다
|
||||
|
||||
Spring Boot observability는 logging, metrics, traces를 함께 다루고, tracing은 서비스 내부/외부 경계를 따라 상관관계를 유지하는 데 쓰인다. 이 프로젝트에서는 비동기 작업이나 외부 연동도 가능한 한 원 요청과 연결 가능한 trace 문맥을 유지하는 방향을 기본 권장으로 둔다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 동기 요청 경로에서 시작한 비동기 후속 작업은 가능한 경우 trace 연결성을 유지한다
|
||||
- 스케줄/배치처럼 원 요청이 없는 작업은 자체 trace/correlation을 생성해 대표 로그를 남긴다
|
||||
- 외부 시스템 호출 로그도 내부 요청 trace와 연계되는 쪽을 우선한다
|
||||
|
||||
## 5. Principal 기록 규칙
|
||||
|
||||
### 5.1 principal은 “누가 호출했는가”를 식별하는 최소 값만 남긴다
|
||||
|
||||
Spring MVC는 Principal을 메서드 인자로 지원하고, Spring Security는 @AuthenticationPrincipal로 principal을 직접 주입할 수 있다. 하지만 이 프로젝트에서 로그에 남길 principal은 Authentication 전체나 raw claim map이 아니라, 운영에 필요한 최소 식별자다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 로그 principal 필드명은 actorId 또는 principalId 중 하나로 통일한다
|
||||
- 권장 기본값은 actorId
|
||||
- principal 전체 객체, authorities 전체, credentials, token 원문은 로그에 남기지 않는다
|
||||
|
||||
### 5.2 인증된 사용자가 없으면 그 상태도 일관되게 표현한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 비로그인 요청, 공개 endpoint, 시스템 내부 작업은 principal 부재를 일관되게 표현한다
|
||||
- 예:
|
||||
- actorId=anonymous
|
||||
- actorId=system
|
||||
- actorId=batch
|
||||
- null, empty string, guest, unknownUser 같은 값을 서비스마다 섞지 않는다
|
||||
|
||||
### 5.3 principal은 사람이 직접 식별되는 값보다 내부 식별자를 우선한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 이메일, 전화번호, 로그인 이름 전체값보다 내부 userId/subjectId를 우선 기록한다
|
||||
- 외부 노출 식별자가 꼭 필요해도 전체 원문 대신 최소한으로 남긴다
|
||||
- principal 로깅이 곧 PII 노출로 이어지지 않게 한다
|
||||
|
||||
### 5.4 principal 로깅은 controller/web 경계에서 필요한 값을 추출해 전달한다
|
||||
|
||||
Spring Security는 @AuthenticationPrincipal과 메타 애노테이션 기반 접근을 제공하므로, controller/web 경계에서 전용 현재 사용자 타입 또는 필요한 필드만 받을 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 @CurrentUser 또는 @CurrentUserId 같은 방식으로 actor 정보를 얻는다
|
||||
- service/domain/util에서 SecurityContextHolder를 다시 조회하지 않는다
|
||||
- 로그에 principal이 필요하면 web 경계나 공통 interceptor/filter가 이를 정규화해서 넣는다
|
||||
|
||||
## 6. RequestPath 기록 규칙
|
||||
|
||||
### 6.1 requestPath는 대표 HTTP 로그의 기본 필드다
|
||||
|
||||
Spring Framework는 request logging filter 계열에서 request URI와 필요 시 query string도 로그에 넣을 수 있게 제공한다. 또한 OncePerRequestFilter는 요청 시작 시 1회 실행을 기본으로 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 대표 HTTP 로그에는 requestPath를 기본 필드로 둔다
|
||||
- 필드명은 requestPath로 통일한다
|
||||
- path, uri, requestUri, url을 혼용하지 않는다
|
||||
|
||||
### 6.2 기본값은 path만 기록하고, query string은 선택적으로 다룬다
|
||||
|
||||
AbstractRequestLoggingFilter 계열은 query string을 선택적으로 포함할 수 있다. 이는 query string이 항상 로그에 적합한 것은 아니라는 뜻이기도 하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 기록 대상은 path만
|
||||
- query string은 기본적으로 로그에 포함하지 않는다
|
||||
- query string이 운영상 꼭 필요하면 별도 마스킹/allowlist 기준 아래 제한적으로 기록한다
|
||||
|
||||
### 6.3 경로는 템플릿이 아니라 실제 요청 path를 기본으로 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 requestPath는 실제 요청된 path
|
||||
- 예: /api/v1/users/123
|
||||
- 다만 집계/카디널리티 문제가 크면 별도 필드로 route template를 함께 관리할 수 있다
|
||||
- 예:
|
||||
- requestPath=/api/v1/users/123
|
||||
- route=/api/v1/users/{userId}
|
||||
|
||||
이 항목은 실무 운영 편의를 위한 Practice + Project Recommendation 이다.
|
||||
|
||||
## 7. 기록 위치 규칙
|
||||
|
||||
### 7.1 공통 HTTP 기록은 filter 또는 interceptor에서 수행할 수 있다
|
||||
|
||||
Spring Framework는 OncePerRequestFilter를 통해 요청 시작 시 1회 실행되는 filter를 만들 수 있고, request logging filter 계열도 제공한다. 또한 Spring MVC interceptor는 handler 전후 공통 처리에 쓰인다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- traceId, requestPath, 시작 시각, 응답 상태 같은 공통 HTTP 기록은 filter 또는 interceptor에서 공통 처리할 수 있다
|
||||
- 인증 완료 이후 principal까지 같이 기록해야 하면 보안 필터 체인 이후 시점을 고려한다
|
||||
- request/response wrapping 같은 HTTP concern은 filter, handler 전후 메타데이터는 interceptor 쪽을 우선 검토한다
|
||||
|
||||
### 7.2 대표 요청 로그는 한 곳에서 남긴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 요청 시작/완료/실패 대표 로그는 공통 컴포넌트 한 곳에서 남긴다
|
||||
- controller마다 요청 진입/종료 로그를 반복 작성하지 않는다
|
||||
- 공통 요청 로그와 개별 비즈니스 로그를 구분한다
|
||||
|
||||
### 7.3 principal이 결정되기 전/후를 구분한다
|
||||
|
||||
Spring Security 문서는 커스텀 필터가 현재 사용자를 알아야 한다면 인증 필터 뒤에 배치해야 함을 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 요청 시작 시점에는 principal이 아직 없을 수 있다
|
||||
- principal까지 포함한 대표 로그가 필요하면 인증 이후 시점에서 기록한다
|
||||
- “pre-auth request log”와 “authenticated request log”를 같은 규칙 없이 섞지 않는다
|
||||
|
||||
## 8. MDC / Structured Logging 규칙
|
||||
|
||||
### 8.1 traceId는 MDC/패턴에, actorId/requestPath는 필요 시 MDC 또는 구조화 필드에 둔다
|
||||
|
||||
Spring Boot는 correlation ID를 로그 패턴에 포함하고, 구조화 로그도 지원한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- traceId는 패턴/MDC 기본값으로 두는 것을 우선
|
||||
- actorId, requestPath는
|
||||
- 메시지 key-value
|
||||
- MDC
|
||||
- structured JSON field
|
||||
- 중 하나로 일관되게 선택한다
|
||||
- 같은 서비스 안에서 세 방식을 뒤섞지 않는다
|
||||
|
||||
### 8.2 MDC를 쓴다면 누수 없이 정리한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 요청 단위 MDC 값은 요청 종료 시 반드시 정리한다
|
||||
- async 경계가 있으면 MDC 전파/정리 전략을 별도 검토한다
|
||||
- 이전 요청의 principal/path가 다음 로그에 새어 나가지 않게 한다
|
||||
|
||||
## 9. 민감정보/카디널리티 규칙
|
||||
|
||||
### 9.1 principal과 path는 유용하지만 무제한으로 남기지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- principal은 내부 식별자 중심
|
||||
- path는 기본 path만
|
||||
- query string, 전체 URL, raw header, token, cookie는 기본 금지
|
||||
- traceId는 민감정보가 아니더라도 외부 공개 출력 정책은 별도 검토한다
|
||||
|
||||
### 9.2 고카디널리티 값을 메트릭 태그처럼 남발하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 로그 본문에는 requestPath=/api/v1/users/123 같은 고유 path가 들어갈 수 있다
|
||||
- 하지만 메트릭 태그/라벨에는 그대로 쓰지 않고 route template를 우선 검토한다
|
||||
- observability에서 로그와 메트릭의 카디널리티 전략을 혼동하지 않는다
|
||||
|
||||
## 10. 권장 기본 필드 세트
|
||||
|
||||
### 10.1 대표 요청 완료 로그
|
||||
|
||||
권장 필드:
|
||||
|
||||
- requestPath
|
||||
- method
|
||||
- status
|
||||
- durationMs
|
||||
- actorId(가능할 때)
|
||||
- trace/correlation ID(패턴/MDC)
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
Completed request. requestPath=/api/v1/users/123 method=GET status=200 durationMs=34 actorId=u_001
|
||||
```
|
||||
|
||||
### 10.2 대표 요청 실패 로그
|
||||
|
||||
권장 필드:
|
||||
|
||||
- requestPath
|
||||
- method
|
||||
- status
|
||||
- errorCode
|
||||
- durationMs
|
||||
- actorId(가능할 때)
|
||||
- trace/correlation ID
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
Failed request. requestPath=/api/v1/users method=POST status=500 errorCode=INTERNAL_SERVER_ERROR durationMs=88 actorId=u_001
|
||||
```
|
||||
|
||||
### 10.3 외부 연동 실패 로그
|
||||
|
||||
권장 필드:
|
||||
|
||||
- requestPath
|
||||
- actorId
|
||||
- externalSystem
|
||||
- status
|
||||
- errorCode
|
||||
- durationMs
|
||||
- trace/correlation ID
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- controller/service/util에서 SecurityContextHolder 직접 조회 후 제각각 principal 로깅
|
||||
- requestPath, path, uri, url 등 키 이름 혼용
|
||||
- query string 전체를 기본 로그에 포함
|
||||
- principal 전체 객체 또는 JWT/raw token 덤프
|
||||
- 요청 대표 로그를 여러 레이어에서 중복 출력
|
||||
- MDC 값 정리 없이 다음 요청으로 누수
|
||||
- traceId 없이 대표 오류 로그를 남겨 상관관계가 끊기는 것
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- trace/correlation 식별자가 대표 로그에 연결되는가?
|
||||
- principal은 최소 식별자만 기록되는가?
|
||||
- requestPath 필드명이 일관적인가?
|
||||
- query string/민감정보를 기본으로 남기지 않는가?
|
||||
- principal 기록 시점이 인증 완료 여부와 맞는가?
|
||||
- 공통 요청 로그가 한 곳에서 일관되게 생성되는가?
|
||||
- MDC/structured logging 전략이 누수 없이 운영 가능한가?
|
||||
@@ -0,0 +1,212 @@
|
||||
# 표준 적용 우선순위와 충돌 해결 규칙
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 프로젝트 내 모든 standards/examples 문서를 **어떤 상황에서 어떤 순서로 읽고 적용할지** 정의한다.
|
||||
|
||||
이 문서의 목적은 다음과 같다.
|
||||
|
||||
- 작업마다 필요한 표준이 빠지지 않게 한다
|
||||
- 여러 표준이 동시에 걸릴 때 우선순위를 명확히 한다
|
||||
- AGENTS.md를 짧게 유지하면서도 실제 표준 적용 누락을 막는다
|
||||
- AI가 “문서가 있었지만 읽지 않았다”는 상태를 줄인다
|
||||
|
||||
---
|
||||
|
||||
## 기본 원칙
|
||||
|
||||
- `AGENTS.md`는 **백과사전이 아니라 라우터**다
|
||||
- 실제 규칙의 source of truth는 `docs/standards/**` 이다
|
||||
- 실제 구현의 기준 예시는 `docs/examples/**` 이다
|
||||
- architecture 문서는 레이어 책임과 의존 방향의 최상위 기준이다
|
||||
- examples는 standards를 대체하지 않는다. 항상 **standards -> examples** 순서로 본다
|
||||
|
||||
---
|
||||
|
||||
## 우선순위
|
||||
|
||||
충돌 시 아래 순서대로 우선한다.
|
||||
|
||||
1. 직접적인 system / developer / user instruction
|
||||
2. 더 깊게 중첩된 `AGENTS.md`
|
||||
3. 더 바깥의 `AGENTS.md`
|
||||
4. `/docs/architecture/README.md`
|
||||
5. 이 문서에 의해 강제되는 relevant standards
|
||||
6. relevant examples
|
||||
7. 현재 코드베이스의 기존 패턴
|
||||
8. 개인 선호 / 임시 편의
|
||||
|
||||
즉:
|
||||
- “기존 코드가 이렇게 되어 있다”는 이유만으로 architecture나 standards를 깨면 안 된다
|
||||
- examples가 standards와 충돌하면 standards를 우선한다
|
||||
- 표준이 없을 때만 기존 코드 패턴을 참고한다
|
||||
|
||||
---
|
||||
|
||||
## 작업 시작 절차
|
||||
|
||||
모든 작업은 아래 순서로 진행한다.
|
||||
|
||||
1. 수정 대상 레이어를 식별한다
|
||||
2. 경계 crossing 여부를 식별한다
|
||||
- HTTP 경계
|
||||
- transaction 경계
|
||||
- DB query 경계
|
||||
- external API 경계
|
||||
- security/authentication 경계
|
||||
3. root AGENTS와 nearest module AGENTS를 읽는다
|
||||
4. 해당 작업에 필요한 standards를 읽는다
|
||||
5. 필요한 경우 examples를 읽는다
|
||||
6. 구현 전 “적용할 표준 목록”을 짧게 정리한다
|
||||
7. 구현한다
|
||||
8. 구현 후 standards 위반 여부를 다시 확인한다
|
||||
|
||||
---
|
||||
|
||||
## 표준 읽기 규칙
|
||||
|
||||
### 전역 기준: 항상 먼저 읽는다
|
||||
|
||||
아래 문서는 모든 작업 전에 기본적으로 적용된다.
|
||||
|
||||
- `/docs/standards/language/stream.md`
|
||||
- `/docs/standards/language/optional.md`
|
||||
- `/docs/standards/language/null.md`
|
||||
- `/docs/standards/language/collections-immutability.md`
|
||||
- `/docs/standards/language/enum-constants.md`
|
||||
- `/docs/standards/language/time.md`
|
||||
- `/docs/standards/language/exceptions.md`
|
||||
- `/docs/standards/language/duplication.md`
|
||||
- `/docs/standards/language/javadoc.md`
|
||||
|
||||
### 상황별 기준: 작업 유형에 따라 추가로 읽는다
|
||||
|
||||
#### controller / dto / api 응답 / validation / 인증 객체 접근 변경
|
||||
추가로 읽는다:
|
||||
- `/docs/standards/web/**`
|
||||
- `/docs/standards/spring/filter-interceptor-resolver-advice.md`
|
||||
|
||||
#### use case / service / transaction / port 변경
|
||||
추가로 읽는다:
|
||||
- `/docs/standards/spring/transaction.md`
|
||||
- `/docs/standards/spring/abstraction.md`
|
||||
|
||||
#### external API / client / serialization / timeout / retry 변경
|
||||
추가로 읽는다:
|
||||
- `/docs/standards/integration/**`
|
||||
|
||||
#### repository / entity / query / lock / migration 변경
|
||||
추가로 읽는다:
|
||||
- `/docs/standards/db/**`
|
||||
|
||||
#### configuration / bean wiring / security filter / bootstrap adapter 변경
|
||||
추가로 읽는다:
|
||||
- `/docs/standards/spring/**`
|
||||
- `/docs/standards/integration/logging.md`
|
||||
|
||||
#### env / profile / docs / runbook / migration 절차 변경
|
||||
추가로 읽는다:
|
||||
- `/docs/standards/ops/**`
|
||||
|
||||
---
|
||||
|
||||
## examples 사용 규칙
|
||||
|
||||
examples는 아래 조건을 만족할 때만 사용한다.
|
||||
|
||||
- relevant standard를 먼저 읽었다
|
||||
- example가 같은 레이어/비슷한 책임을 가진다
|
||||
- architecture와 충돌하지 않는다
|
||||
|
||||
examples 사용 규칙:
|
||||
- examples는 복붙 대상이 아니라 **형태와 책임 분리의 기준**이다
|
||||
- example가 현재 standard와 충돌하면 example를 버린다
|
||||
- example가 오래되었거나 애매하면 standard만 따르고 example는 무시한다
|
||||
|
||||
---
|
||||
|
||||
## 충돌 해결 규칙
|
||||
|
||||
### 1. example vs standard
|
||||
- standard 우선
|
||||
|
||||
### 2. 기존 코드 패턴 vs standard
|
||||
- standard 우선
|
||||
- 단, 기존 코드가 널리 퍼져 있으면 한 번에 다 고치지 않고 현재 변경 범위에서만 맞춘다
|
||||
|
||||
### 3. 모듈 AGENTS vs root AGENTS
|
||||
- 더 가까운 module AGENTS 우선
|
||||
- 단, root의 전역 기준을 무시하는 근거로 쓰면 안 된다
|
||||
|
||||
### 4. 성능 최적화 vs 가독성
|
||||
- 측정 근거 없는 성능 주장은 금지
|
||||
- 기본값은 명확한 코드
|
||||
- 성능 민감 경로는 측정 결과가 있으면 예외 허용
|
||||
|
||||
### 5. 빠른 구현 vs 구조 일관성
|
||||
- 임시 구현으로 레이어를 깨는 것 금지
|
||||
- 오늘 편한 구조보다 이후 반복 작업에서 덜 무너지는 구조를 우선
|
||||
|
||||
---
|
||||
|
||||
## AI 작업 지시 규칙
|
||||
|
||||
AI에게 작업을 줄 때는 다음을 포함한다.
|
||||
|
||||
- 수정 목표
|
||||
- 파일 경로 또는 모듈 이름
|
||||
- 변경 범위
|
||||
- 관련 standards 파일
|
||||
- 관련 examples 파일
|
||||
- 금지사항
|
||||
- 완료 조건
|
||||
|
||||
프롬프트는 이슈처럼 쓴다.
|
||||
즉:
|
||||
- 무엇을 바꿀지
|
||||
- 어디를 바꿀지
|
||||
- 어떤 기준을 따를지
|
||||
- 무엇을 하지 말아야 하는지
|
||||
를 명확히 적는다.
|
||||
|
||||
큰 변경은 바로 구현부터 시키지 말고:
|
||||
1. Ask mode로 구현 계획
|
||||
2. relevant standards/examples 확인
|
||||
3. Code mode로 구현
|
||||
순서로 진행한다.
|
||||
|
||||
---
|
||||
|
||||
## 표준 누락 방지 규칙
|
||||
|
||||
새 standards 파일을 만들면 반드시 아래를 함께 갱신한다.
|
||||
|
||||
- root AGENTS의 전역/폴더 라우팅 또는 relevant module AGENTS
|
||||
- 관련 module AGENTS의 `Read first`
|
||||
- 관련 examples 연결
|
||||
- 이 문서의 상황별 기준 목록이 바뀌어야 하는지 검토
|
||||
|
||||
즉 파일만 만들고 라우팅하지 않는 것을 금지한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 전 체크리스트
|
||||
|
||||
- 이 작업의 owning layer는 어디인가?
|
||||
- 어떤 boundary를 건드리는가?
|
||||
- 전역 language standard를 읽었는가?
|
||||
- 이 작업에 필요한 module-specific standard를 읽었는가?
|
||||
- example는 relevant standard를 읽은 뒤에 봤는가?
|
||||
- 충돌 시 무엇을 우선할지 명확한가?
|
||||
|
||||
---
|
||||
|
||||
## 구현 후 체크리스트
|
||||
|
||||
- architecture 위반이 없는가?
|
||||
- 레이어 책임이 흐려지지 않았는가?
|
||||
- 예외 번역 위치가 맞는가?
|
||||
- transaction 범위가 맞는가?
|
||||
- query/lock/migration 기준을 위반하지 않았는가?
|
||||
- stale Javadoc/docs가 남지 않았는가?
|
||||
- example를 그대로 복붙해 책임이 섞이지 않았는가?
|
||||
@@ -0,0 +1,168 @@
|
||||
# AOP 사용 기준
|
||||
|
||||
## 목적
|
||||
|
||||
AOP는 비즈니스 로직을 숨기는 우회 수단이 아니라,
|
||||
여러 타입과 객체를 가로지르는 **cross-cutting concern** 을 모듈화할 때만 사용한다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- Spring AOP는 OOP를 보완하는 방식이다.
|
||||
- AOP의 핵심 단위는 class가 아니라 aspect다.
|
||||
- Spring AOP는 proxy-based다.
|
||||
- proxy-based AOP에서는 proxy를 통과하는 외부 호출만 advice가 적용된다.
|
||||
- self-invocation은 기본적으로 advice가 적용되지 않는다.
|
||||
- Spring은 AOP의 대표적 용도로 declarative transaction 같은 cross-cutting concern을 든다.
|
||||
- pointcut은 더 작은 named pointcut으로 조합하는 것이 권장된다.
|
||||
- 대부분의 경우 static pointcut이 충분하고 더 낫다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. AOP는 cross-cutting concern에만 사용
|
||||
다음은 AOP 후보가 된다.
|
||||
|
||||
- 공통 로깅
|
||||
- 메트릭/트레이싱
|
||||
- 권한 체크의 반복 경계
|
||||
- 재시도/타이밍 측정
|
||||
- 공통 감사(audit)
|
||||
- 선언적 트랜잭션
|
||||
|
||||
다음은 AOP로 풀지 않는다.
|
||||
|
||||
- 핵심 비즈니스 흐름
|
||||
- 상태 전이 정책
|
||||
- 도메인 규칙
|
||||
- 복잡한 분기 로직
|
||||
- 외부 API orchestration
|
||||
|
||||
### 2. 기본값은 “명시적 코드”, 예외적으로 AOP
|
||||
같은 기능을 명시적 코드로 더 잘 읽을 수 있으면 AOP를 쓰지 않는다.
|
||||
|
||||
기본:
|
||||
- use case/service 안에서 명시적으로 보이는 흐름 우선
|
||||
- 반복되는 횡단 관심사만 AOP로 추출
|
||||
|
||||
### 3. proxy-based 한계를 항상 전제로 한다
|
||||
Spring AOP는 proxy 기반이므로 다음을 전제로 설계한다.
|
||||
|
||||
- proxy를 통과하는 외부 호출만 interception
|
||||
- self-invocation은 적용되지 않음
|
||||
- “같은 클래스 안에서 호출되면 aspect가 붙겠지”를 금지
|
||||
|
||||
### 4. self-invocation 해결을 위해 AOP 남용 금지
|
||||
self-invocation 문제를 해결하려고 다음을 기본 금지한다.
|
||||
|
||||
- 자기 자신 proxy 주입
|
||||
- `AopContext.currentProxy()` 의존
|
||||
- 구현을 proxy semantics에 강하게 묶는 설계
|
||||
|
||||
기본 대응:
|
||||
- 경계를 다시 분리
|
||||
- 클래스를 분리
|
||||
- 더 명시적인 구조로 변경
|
||||
|
||||
### 5. AOP는 경계가 뚜렷한 곳에만 적용
|
||||
좋은 적용 지점:
|
||||
- service/use-case public method
|
||||
- controller 경계
|
||||
- repository 경계
|
||||
- 명시된 package/bean naming convention
|
||||
|
||||
지양:
|
||||
- 너무 넓은 전체 패키지
|
||||
- “일단 다 잡고 보자” 식 표현식
|
||||
- private/internal 세부 구현까지 얽는 pointcut
|
||||
|
||||
### 6. pointcut은 작고 이름 있게 조합
|
||||
공식 권장대로 pointcut은 작은 named pointcut을 조합해 만든다.
|
||||
|
||||
기본:
|
||||
- package 범위 pointcut
|
||||
- role 기반 pointcut
|
||||
- public method pointcut
|
||||
- bean naming 기반 pointcut
|
||||
|
||||
를 분리하고 조합한다.
|
||||
|
||||
### 7. 대부분 static pointcut 우선
|
||||
동적 조건보다 static pointcut이 충분하면 static 쪽을 우선한다.
|
||||
성능/이해도/예측 가능성이 더 좋다.
|
||||
|
||||
### 8. `@Around`는 최소화
|
||||
`@Around`는 가장 강력하지만 가장 위험하다.
|
||||
반환값/예외/호출 자체를 제어할 수 있으므로 꼭 필요할 때만 쓴다.
|
||||
|
||||
기본 우선순위:
|
||||
- 단순 전처리 -> `@Before`
|
||||
- 정상 반환 후 후처리 -> `@AfterReturning`
|
||||
- 예외 기록/번역 -> `@AfterThrowing`
|
||||
- 무조건 정리 -> `@After`
|
||||
- 호출 제어/타이밍/재시도 등 정말 필요할 때만 `@Around`
|
||||
|
||||
### 9. advice 안에서 비즈니스 의미를 새로 만들지 않는다
|
||||
advice는 보조 concern을 수행해야 한다.
|
||||
|
||||
금지:
|
||||
- 상태 전이 결정
|
||||
- 비즈니스 실패를 성공처럼 바꾸기
|
||||
- 핵심 정책 우회
|
||||
- controller/service가 해야 할 결정을 aspect에서 대신하기
|
||||
|
||||
### 10. 예외를 숨기지 않는다
|
||||
AOP에서 예외를 잡더라도 기본은:
|
||||
- 기록
|
||||
- 문맥 추가
|
||||
- 그대로 전파
|
||||
중 하나다.
|
||||
|
||||
금지:
|
||||
- 예외 삼키기
|
||||
- 정상값으로 은폐
|
||||
- 실패를 조용히 무시
|
||||
|
||||
### 11. 트랜잭션 대체 수단으로 일반 AOP를 남용하지 않는다
|
||||
선언적 트랜잭션은 Spring이 제공하는 표준 메커니즘을 우선 사용한다.
|
||||
일반 custom aspect로 transaction semantics를 흉내 내지 않는다.
|
||||
|
||||
### 12. AOP는 observability / policy enforcement에 더 적합
|
||||
프로젝트에서 AOP는 아래 유형에 더 적합하다.
|
||||
|
||||
- 실행 시간 측정
|
||||
- 공통 로깅
|
||||
- 감사 기록
|
||||
- annotation 기반 정책 강제
|
||||
- 공통 예외 기록
|
||||
|
||||
복잡한 use case orchestration에는 부적합하다.
|
||||
|
||||
### 13. pointcut 범위는 문서화 가능해야 한다
|
||||
pointcut을 보고 아래를 설명할 수 있어야 한다.
|
||||
|
||||
- 어디에 적용되는가
|
||||
- 왜 거기에만 적용되는가
|
||||
- 새 코드가 추가되면 어떤 naming/package 규칙으로 포함되는가
|
||||
|
||||
설명하기 어려우면 범위가 너무 넓거나 모호한 것이다.
|
||||
|
||||
### 14. bean naming / package convention을 설계와 함께 쓴다
|
||||
Spring 공식 문서가 bean PCD나 package 기반 pointcut 예시를 드는 것처럼,
|
||||
AOP를 쓸 거면 package 구조나 bean naming convention이 일정해야 한다.
|
||||
|
||||
즉:
|
||||
- `*Service`
|
||||
- `..application..`
|
||||
- `..infrastructure..`
|
||||
같은 규칙은 pointcut과 함께 관리한다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- AOP는 cross-cutting concern에만 사용
|
||||
- 기본값은 명시적 코드
|
||||
- Spring AOP는 proxy-based라는 점을 전제로 설계
|
||||
- self-invocation 기대 금지
|
||||
- pointcut은 작고 이름 있게 조합
|
||||
- 대부분 static pointcut 우선
|
||||
- `@Around` 최소화
|
||||
- advice에서 비즈니스 의미를 만들지 않음
|
||||
- 예외를 숨기지 않음
|
||||
@@ -0,0 +1,175 @@
|
||||
# ApplicationEvent 사용 기준
|
||||
|
||||
## 목적
|
||||
|
||||
Application Event는 같은 애플리케이션 내부에서 **느슨하게 결합된 후속 반응**을 분리하기 위해 사용한다.
|
||||
핵심 비즈니스 오케스트레이션을 숨기는 수단으로 사용하지 않는다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- `ApplicationEventPublisher`는 이벤트 발행 기능을 제공한다.
|
||||
- `publishEvent(Object)`는 일반 객체도 이벤트로 발행할 수 있으며, 필요 시 `PayloadApplicationEvent`로 감싸진다.
|
||||
- 이벤트 발행은 multicaster로의 hand-off일 뿐, 그 자체로 synchronous/asynchronous 또는 immediate execution을 보장하지 않는다.
|
||||
- 리스너는 가능한 한 효율적이어야 하며, 오래 걸리거나 blocking 가능한 작업은 개별적으로 비동기 실행을 고려한다.
|
||||
- 트랜잭션 결과와 묶어 처리해야 하면 `@TransactionalEventListener`를 사용한다.
|
||||
- `@TransactionalEventListener`의 기본 phase는 `AFTER_COMMIT`이다.
|
||||
- 트랜잭션 밖에서 발행된 이벤트는 기본적으로 discard되며, `fallbackExecution = true`일 때만 예외적으로 처리된다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. Application Event는 “후속 반응”에만 사용
|
||||
다음은 이벤트 후보가 된다.
|
||||
|
||||
- 감사 로그 기록
|
||||
- 메트릭/알림 발행
|
||||
- 후속 캐시 정리
|
||||
- 읽기 모델 갱신
|
||||
- 부가적인 notification
|
||||
- core use case 이후의 느슨한 반응
|
||||
|
||||
다음은 이벤트로 풀지 않는다.
|
||||
|
||||
- 핵심 비즈니스 흐름 자체
|
||||
- 반드시 순서대로 수행되어야 하는 오케스트레이션
|
||||
- 즉시 실패/성공 여부가 핵심인 주 경로
|
||||
- 도메인 규칙 판정
|
||||
- controller/service가 직접 보여줘야 하는 결과 계산
|
||||
|
||||
### 2. 이벤트는 같은 애플리케이션 내부 경계로 본다
|
||||
기본적으로 Spring Application Event는 in-process 이벤트다.
|
||||
|
||||
즉:
|
||||
- 다른 시스템과의 통합 이벤트 브로커 대체제가 아니다
|
||||
- Kafka/RabbitMQ 같은 외부 메시징과 같은 의미로 쓰지 않는다
|
||||
- 프로세스 내부의 느슨한 반응 분리에 한정한다
|
||||
|
||||
### 3. 발행은 “hand-off”일 뿐, 실행 모델을 가정하지 않는다
|
||||
`publishEvent(...)`를 호출했다고 해서 아래를 가정하지 않는다.
|
||||
|
||||
- 반드시 동기적으로 끝난다
|
||||
- 반드시 즉시 실행된다
|
||||
- 반드시 같은 스레드에서 다 처리된다
|
||||
|
||||
즉 발행자(publisher)는 listener의 실행 방식에 의존하지 않는다.
|
||||
|
||||
### 4. listener는 짧고 효율적으로 유지
|
||||
공식 문서 취지대로 listener는 가능한 한 짧고 효율적으로 유지한다.
|
||||
|
||||
기본 금지:
|
||||
- 긴 블로킹 작업
|
||||
- 대규모 외부 API 호출
|
||||
- 무거운 batch 처리
|
||||
- 여러 단계 오케스트레이션
|
||||
|
||||
정말 오래 걸리면 별도 비동기/후속 처리 구조를 검토한다.
|
||||
|
||||
### 5. 트랜잭션 결과가 중요하면 `@TransactionalEventListener`
|
||||
다음은 `@TransactionalEventListener`를 우선 검토한다.
|
||||
|
||||
- DB commit 성공 후에만 실행되어야 하는 후속 처리
|
||||
- rollback되면 수행하면 안 되는 반응
|
||||
- 저장 완료 이후에만 의미가 있는 알림/감사/후속 처리
|
||||
|
||||
기본 phase:
|
||||
- 특별한 이유가 없으면 `AFTER_COMMIT`
|
||||
|
||||
### 6. `fallbackExecution = true`는 예외적으로만
|
||||
트랜잭션이 없을 때도 listener를 실행해야 하는 경우가 정말 명확할 때만 사용한다.
|
||||
|
||||
기본값:
|
||||
- 트랜잭션 경계가 없는 발행은 discard되어도 괜찮다고 본다
|
||||
|
||||
### 7. 이벤트 payload는 처리에 필요한 상태를 포함
|
||||
공식 문서상 reactive/async hand-off에서는 thread-local 상태를 기대하면 안 된다.
|
||||
따라서 이벤트 객체에는 listener가 처리하는 데 필요한 최소 상태를 자체적으로 담는다.
|
||||
|
||||
금지:
|
||||
- listener가 `SecurityContext`, MDC, thread-local만 믿고 동작
|
||||
- payload 없이 “가서 다시 다 조회해라” 식으로 과도하게 빈약한 이벤트
|
||||
|
||||
기본:
|
||||
- 식별자
|
||||
- 필요한 시점 정보
|
||||
- 필요한 타입/상태
|
||||
를 명시적으로 포함
|
||||
|
||||
### 8. payload는 작고 안정적으로
|
||||
이벤트는 무거운 객체 그래프 전체보다, listener가 필요한 최소 데이터만 담는다.
|
||||
|
||||
기본:
|
||||
- entity 전체보다 id/필수 상태 우선
|
||||
- JPA lazy proxy를 payload로 넘기지 않음
|
||||
- 직렬화/로그에 취약한 대형 객체를 그대로 넘기지 않음
|
||||
|
||||
### 9. 이벤트 이름은 business fact 또는 completed action으로 짓는다
|
||||
좋은 방향:
|
||||
- `UserRegisteredEvent`
|
||||
- `LoginSucceededEvent`
|
||||
- `PublicKeyRotatedEvent`
|
||||
|
||||
지양:
|
||||
- `DoSomethingEvent`
|
||||
- `CommonEvent`
|
||||
- `UserProcessEvent`
|
||||
|
||||
이름만 보고 무슨 일이 일어났는지 보여야 한다.
|
||||
|
||||
### 10. 발행자는 listener 존재를 몰라야 한다
|
||||
publisher는 listener가 몇 개인지, 누가 듣는지, 어떤 순서인지에 기대지 않는다.
|
||||
|
||||
금지:
|
||||
- “이 이벤트를 쏘면 저 listener가 반드시 먼저 실행된다”는 설계
|
||||
- 이벤트 발행으로 핵심 결과를 암묵적으로 완성하는 구조
|
||||
|
||||
### 11. listener 순서 의존 최소화
|
||||
`@Order`를 줄 수는 있지만, 가능하면 listener 간 순서 의존을 설계하지 않는다.
|
||||
|
||||
정말 필요할 때만:
|
||||
- 같은 phase 안에서 우선순위 조정
|
||||
- 매우 명확한 부가 처리 순서
|
||||
|
||||
기본은 서로 독립적으로 동작해야 한다.
|
||||
|
||||
### 12. listener 안에서 핵심 business decision 금지
|
||||
listener는 후속 반응을 수행해야 한다.
|
||||
|
||||
금지:
|
||||
- 핵심 상태 전이 결정
|
||||
- 메인 use case 성공/실패를 뒤집는 판단
|
||||
- 여러 하위 흐름을 연결한 복잡한 오케스트레이션
|
||||
|
||||
### 13. listener 예외는 의도를 분명히
|
||||
listener에서 예외가 나면 어떤 영향을 기대하는지 명확해야 한다.
|
||||
|
||||
기본:
|
||||
- 주 흐름과 강결합이면 이벤트보다 명시적 호출이 더 적합
|
||||
- 후속 반응이면 실패 처리/재시도/로그 정책을 분리해서 설계
|
||||
- 예외를 조용히 삼키지 않는다
|
||||
|
||||
### 14. 이벤트는 남발하지 않는다
|
||||
“느슨하게 연결하고 싶다”는 이유만으로 이벤트를 남발하지 않는다.
|
||||
|
||||
다음 질문 중 여러 개가 “예”일 때만 검토한다.
|
||||
- 발행자와 반응자를 분리할 가치가 큰가?
|
||||
- 반응자가 하나가 아닐 수 있는가?
|
||||
- 후속 반응이 핵심 흐름이 아닌가?
|
||||
- 트랜잭션 완료 후 처리로 분리하는 이점이 큰가?
|
||||
|
||||
### 15. 테스트에서 이벤트를 검증할 수 있어야 한다
|
||||
Spring 테스트는 `ApplicationEvents`를 기록하고 검증할 수 있다.
|
||||
|
||||
기본:
|
||||
- 이벤트를 발행하는 use case는 발행 여부를 테스트 가능하게 설계
|
||||
- listener 동작도 별도 테스트 가능하게 유지
|
||||
- “이벤트가 어딘가에서 되겠지”를 금지
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- Application Event는 내부 후속 반응 분리 수단
|
||||
- 핵심 오케스트레이션에는 기본 금지
|
||||
- 발행은 hand-off일 뿐 실행 모델을 가정하지 않음
|
||||
- listener는 짧고 효율적으로
|
||||
- commit 결과가 중요하면 `@TransactionalEventListener`
|
||||
- payload는 작고 필요한 상태를 명시적으로 포함
|
||||
- publisher는 listener 순서/존재를 몰라야 함
|
||||
- 이벤트 남발 금지
|
||||
@@ -0,0 +1,280 @@
|
||||
# Async / Scheduler / Retry 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 Spring의 비동기 실행(@Async), 스케줄 실행(@Scheduled), 재시도(@Retryable / RetryTemplate)를 프로젝트에서 언제, 어디에, 어떤 방식으로 사용할지 정의한다.
|
||||
|
||||
이 문서의 목적은 다음과 같다.
|
||||
|
||||
- 실행 경계를 명확히 한다.
|
||||
- 프록시 기반 동작의 함정을 피한다.
|
||||
- 스레드 풀/스케줄러를 암묵적 기본값에 맡기지 않는다.
|
||||
- 재시도를 “일시적 실패”에만 제한한다.
|
||||
- 핵심 비즈니스 로직이 비동기/스케줄/재시도 애노테이션 뒤에 숨지 않게 한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot / Spring Retry 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 제약 위에 일반적인 실무 운영 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 동기 실행을 기본값으로 둔다
|
||||
|
||||
비동기, 스케줄, 재시도는 기본 선택지가 아니라 명시적 필요가 있을 때만 도입한다.
|
||||
|
||||
- @Async는 호출자가 즉시 반환되어도 되는 후속 작업에만 사용한다.
|
||||
- @Scheduled는 요청-응답 흐름이 아닌 주기성/지연 실행 작업에만 사용한다.
|
||||
- retry는 외부 시스템 호출 등 일시적 실패 가능성이 있는 작업에만 사용한다.
|
||||
- 검증 오류, 도메인 규칙 위반, 매핑 오류, 프로그래밍 오류에 retry를 걸지 않는다.
|
||||
|
||||
Spring Retry 공식 문서도 “항상 같은 예외가 발생하는 결정적 실패는 재시도해도 도움이 되지 않으며, 모든 예외 타입에 대해 재시도하지 말라”고 설명한다.
|
||||
|
||||
### 3.2 애노테이션은 도메인 규칙이 아니라 실행 메커니즘이다
|
||||
|
||||
@Async, @Scheduled, @Retryable은 모두 실행 방식에 대한 인프라 성격의 도구다. 따라서 이 프로젝트에서는 다음을 기본으로 한다.
|
||||
|
||||
- domain 레이어에는 사용하지 않는다.
|
||||
- 주 사용 위치는 application / infrastructure 레이어로 제한한다.
|
||||
- controller, entity, value object, mapper에 실행 메커니즘 애노테이션을 붙여 책임을 섞지 않는다.
|
||||
|
||||
이 규칙은 프로젝트 아키텍처 정렬을 위한 Project Recommendation이다.
|
||||
|
||||
## 4. @Async 표준
|
||||
|
||||
### 4.1 사용 기준
|
||||
|
||||
@Async는 호출 즉시 반환해도 되는 작업에만 사용한다.
|
||||
|
||||
허용 예:
|
||||
|
||||
- 알림 전송
|
||||
- 감사 로그 전송
|
||||
- 비핵심 후속 연산
|
||||
- 핵심 트랜잭션 완료 뒤의 독립 작업
|
||||
|
||||
비허용 예:
|
||||
|
||||
- 핵심 비즈니스 결과를 결정하는 로직
|
||||
- 호출자가 반드시 성공/실패를 알아야 하는 로직
|
||||
- 트랜잭션 경계를 우회하려는 용도
|
||||
- 초기화 콜백(@PostConstruct)에 직접 붙이는 방식
|
||||
|
||||
Spring 공식 문서상 @Async는 호출 시 작업을 TaskExecutor에 제출해 비동기로 실행하며, @PostConstruct 같은 lifecycle callback과 함께 사용할 수 없다.
|
||||
|
||||
### 4.2 프록시 경계 규칙
|
||||
|
||||
@Async는 기본적으로 proxy mode로 처리되므로 같은 클래스 내부 호출(self-invocation) 에는 적용되지 않는다. 따라서:
|
||||
|
||||
- this.someAsyncMethod() 형태를 금지한다.
|
||||
- @Async가 필요한 로직은 별도 bean 으로 분리한다.
|
||||
- 프록시 경계가 드러나게 설계한다.
|
||||
|
||||
Spring 공식 문서도 @Async의 기본 advice mode는 proxy이며 같은 클래스 내부 로컬 호출은 가로채지 못한다고 명시한다.
|
||||
|
||||
### 4.3 메서드 시그니처 규칙
|
||||
|
||||
Spring 공식 기준에서 @Async 메서드는 파라미터는 자유롭지만 반환형은 void 또는 Future 계열이어야 하며, CompletableFuture 사용이 가능하다. 또한 @Configuration 클래스 내부 메서드에는 지원되지 않는다.
|
||||
|
||||
프로젝트 규칙은 다음과 같다.
|
||||
|
||||
- 호출자가 결과를 관찰해야 하면 CompletableFuture<T>를 사용한다.
|
||||
- 단순 fire-and-forget이면 void를 사용할 수 있다.
|
||||
- 새 코드에서 구식 Future는 특별한 호환 요구가 없으면 사용하지 않는다.
|
||||
- @Configuration 내부 메서드에 @Async를 사용하지 않는다.
|
||||
|
||||
### 4.4 예외 처리 규칙
|
||||
|
||||
void 반환 @Async 메서드의 예외는 호출자에게 전달되지 않고, 기본적으로는 로깅만 된다. 호출자가 실패를 알아야 하는 경우 CompletableFuture를 사용하고, void를 쓰는 경우에는 AsyncUncaughtExceptionHandler 또는 메서드 내부 명시적 예외 처리 전략을 둔다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- void @Async는 실패를 호출자에게 전달할 필요가 없는 작업에만 사용한다.
|
||||
- void @Async를 도입하면 예외 처리/로그/모니터링 전략을 같이 정의한다.
|
||||
- 실패가 비즈니스적으로 중요하면 @Async로 숨기지 않는다.
|
||||
|
||||
## 5. Executor / Scheduler 구성 표준
|
||||
|
||||
### 5.1 기본 자동 구성을 무심코 공유하지 않는다
|
||||
|
||||
Spring Boot는 AsyncTaskExecutor를 자동 구성하고, 그 실행기는 @EnableAsync뿐 아니라 MVC 비동기 요청 처리, WebFlux blocking 지원, GraphQL, JPA bootstrap, background initialization 등 여러 통합 지점에서 사용될 수 있다. 또한 스케줄러도 자동 구성되며, 가상 스레드를 쓰지 않을 때 기본 ThreadPoolTaskScheduler는 기본 스레드 수 1로 동작한다.
|
||||
|
||||
따라서 프로젝트 규칙은 다음과 같다.
|
||||
|
||||
- @Async용 executor와 @Scheduled용 scheduler를 개념적으로 분리한다.
|
||||
- 무거운 업무성 비동기 작업을 web 요청 처리와 우연히 같은 executor에 태우지 않는다.
|
||||
- 스레드 이름 prefix를 명시한다.
|
||||
- pool size / queue capacity / rejection 정책을 의도적으로 설정한다.
|
||||
- 운영에서 식별 가능한 bean 이름을 사용한다.
|
||||
|
||||
### 5.2 가상 스레드 사용 시 주의
|
||||
|
||||
Spring Framework는 SimpleAsyncTaskScheduler가 가상 스레드 정렬 옵션으로 동작할 수 있지만, fixed-delay 작업은 단일 scheduler thread에서 동작하므로 이 경우 fixed-rate나 cron을 권장한다고 설명한다. Spring Boot도 가상 스레드 활성화 시 scheduler가 SimpleAsyncTaskScheduler가 되며 pooling 관련 설정을 무시한다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 가상 스레드를 켠다고 해서 scheduler 설계를 생략하지 않는다.
|
||||
- fixed-delay 중심 작업이 많다면 가상 스레드 scheduler를 무비판적으로 선택하지 않는다.
|
||||
|
||||
## 6. @Scheduled 표준
|
||||
|
||||
### 6.1 사용 기준
|
||||
|
||||
@Scheduled는 주기 작업/지연 작업의 진입점으로만 사용한다.
|
||||
|
||||
Spring 공식 기준에서:
|
||||
|
||||
- 주기 작업에는 cron, fixedDelay, fixedRate 중 정확히 하나를 지정해야 한다.
|
||||
- initialDelay는 선택 사항이다.
|
||||
- 메서드는 인자를 받을 수 없다.
|
||||
- 반환값은 일반적으로 무시된다.
|
||||
- 같은 메서드에 여러 스케줄 선언을 둘 수 있으며, 이 경우 서로 독립적으로 실행되어 겹칠 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 이 프로젝트의 @Scheduled 메서드는 반드시 void 로 작성한다.
|
||||
- @Scheduled 메서드는 얇은 트리거(thin trigger) 로 유지하고 실제 업무는 application service/use case로 위임한다.
|
||||
- 하나의 메서드에 여러 @Scheduled를 붙이지 않는다.
|
||||
- 각 스케줄 작업은 재실행 가능(idempotent) 하고 중복 실행/겹침에 안전해야 한다.
|
||||
|
||||
### 6.2 fixedDelay와 fixedRate 선택 기준
|
||||
|
||||
Spring 공식 문서 기준:
|
||||
|
||||
- fixedDelay는 이전 실행 완료 시점 기준
|
||||
- fixedRate는 이전 실행 시작 시점 기준 으로 간격이 계산된다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 이전 실행이 끝난 뒤 다음 실행을 시작해야 하면 fixedDelay
|
||||
- 일정 간격 기준으로 계속 트리거되어도 괜찮으면 fixedRate
|
||||
- 업무 시간이 분명한 배치성 작업은 cron
|
||||
- timezone 의미가 중요한 cron은 zone을 명시하는 방향을 우선 검토한다
|
||||
|
||||
### 6.3 스케줄러 풀 크기 규칙
|
||||
|
||||
Spring Boot의 기본 scheduler는 단일 스레드일 수 있다. 따라서 둘 이상의 작업이 있거나, 하나라도 오래 걸리는 작업이 있으면 pool size를 명시적으로 설계한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- scheduler bean 또는 spring.task.scheduling.* 설정을 명시한다.
|
||||
- “기본값 1개 스레드”에 의존한 채 운영에 올리지 않는다.
|
||||
- 긴 작업과 짧은 작업이 섞이면 분리 가능성도 검토한다.
|
||||
|
||||
## 7. Retry 표준
|
||||
|
||||
### 7.1 retry를 사용할 수 있는 위치
|
||||
|
||||
Retry는 다음 같은 경우에만 사용한다.
|
||||
|
||||
- 외부 HTTP/gRPC/API 호출
|
||||
- 메시지 브로커 일시 실패
|
||||
- 네트워크 일시 장애
|
||||
- 잠깐 후 재시도하면 회복될 수 있는 외부 의존성 오류
|
||||
|
||||
Spring Retry는 @EnableRetry로 @Retryable bean에 프록시를 만들며, RetryTemplate 기반의 프로그래밍 방식도 제공한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- retry는 외부 경계(adapter/client/gateway) 에 가깝게 둔다.
|
||||
- controller / domain / entity / mapper / validation 로직에는 두지 않는다.
|
||||
- “왜 재시도 가능한가?”를 설명할 수 없는 경우 retry를 두지 않는다.
|
||||
|
||||
### 7.2 기본값을 그대로 쓰지 않는다
|
||||
|
||||
Spring Retry의 현재 API 기준:
|
||||
|
||||
- maxAttempts 기본값은 3
|
||||
- retryFor와 noRetryFor를 비워두면 기본적으로 모든 예외가 재시도 대상이 될 수 있다
|
||||
- backoff는 지정 가능하며 기본은 단순 Backoff 사양이다
|
||||
- noRetryFor, notRecoverable 같은 세밀한 제어가 가능하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- @Retryable에는 반드시 retryFor를 명시한다.
|
||||
- 필요하면 noRetryFor 또는 notRecoverable도 함께 명시한다.
|
||||
- maxAttempts를 명시한다.
|
||||
- backoff 전략을 명시한다.
|
||||
- “기본적으로 모든 예외 재시도” 형태를 금지한다.
|
||||
|
||||
### 7.3 @Recover 사용 기준
|
||||
|
||||
Spring Retry 공식 문서상 recovery method는:
|
||||
|
||||
- @Retryable 메서드와 같은 클래스 에 있어야 하고
|
||||
- @Recover 로 표시해야 하며
|
||||
- 반환형이 @Retryable 메서드와 맞아야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 재시도 소진 뒤 대체 경로가 의미 있을 때만 @Recover를 둔다.
|
||||
- recover는 “실패를 조용히 삼키는 메서드”가 아니라, 대체 동작 또는 명시적 실패 변환 역할이어야 한다.
|
||||
- recover가 있어도 관측 가능성(log/metric/alert)을 잃지 않는다.
|
||||
|
||||
### 7.4 @Retryable vs RetryTemplate
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
애노테이션 기반이 더 읽기 쉬운 경우: @Retryable
|
||||
|
||||
다음 경우에는 RetryTemplate을 우선 검토:
|
||||
|
||||
- 한 메서드 전체가 아니라 일부 코드 블록만 재시도해야 하는 경우
|
||||
- 루프 내부 각 항목마다 다른 retry 문맥이 필요한 경우
|
||||
- 정책을 동적으로 조합해야 하는 경우
|
||||
- 테스트에서 retry 경계를 더 명시적으로 다루고 싶은 경우
|
||||
|
||||
이 구분은 @Retryable이 bean method 경계의 선언적 방식이고, RetryTemplate은 임의 코드 블록의 프로그래밍 방식이라는 공식 구조에 맞춘 Official + Practice 규칙이다.
|
||||
|
||||
## 8. 조합 규칙
|
||||
|
||||
### 8.1 한 메서드에 다 몰아넣지 않는다
|
||||
|
||||
프로젝트 기본 규칙:
|
||||
|
||||
- @Scheduled + @Async + @Retryable를 같은 메서드에 겹쳐 붙이는 것을 기본 금지한다.
|
||||
- 트리거, 업무 오케스트레이션, 외부 재시도, 후속 비동기 작업은 서로 다른 bean / 메서드 경계 로 나눈다.
|
||||
|
||||
이 규칙의 이유는 @Async와 @Retryable 모두 프록시 기반이며, 경계가 흐려질수록 self-invocation·예외 전파·관측 가능성 문제가 커지기 때문이다.
|
||||
|
||||
권장 구조:
|
||||
|
||||
- @Scheduled → application use case
|
||||
- application use case → retry가 붙은 external gateway/client
|
||||
- 비핵심 후속 작업 → 별도 async bean 또는 event listener
|
||||
|
||||
### 8.2 트랜잭션과의 결합
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 핵심 트랜잭션 오케스트레이션은 application service에 둔다.
|
||||
- retry 대상 외부 호출과 async 후속 처리까지 같은 메서드에 한꺼번에 섞지 않는다.
|
||||
- 프록시 경계가 필요한 경우 메서드 분리가 아니라 bean 분리를 우선한다.
|
||||
|
||||
이 항목은 기존 transaction 문서와 맞물리는 Project Recommendation이다.
|
||||
|
||||
## 9. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 도입 가능하다.
|
||||
|
||||
@Async:
|
||||
|
||||
- 호출자가 즉시 반환되어도 되는가?
|
||||
- 실패가 호출자에게 반드시 전달될 필요가 없는가, 또는 CompletableFuture로 전달되는가?
|
||||
- 같은 클래스 내부 호출이 아닌가?
|
||||
- 전용 executor와 예외 처리 전략이 있는가?
|
||||
|
||||
@Scheduled:
|
||||
|
||||
- 요청 흐름이 아닌 주기 작업인가?
|
||||
- 메서드가 얇은 트리거인가?
|
||||
- 중복 실행/겹침에 안전한가?
|
||||
- scheduler pool 설정이 의도적으로 잡혀 있는가?
|
||||
|
||||
Retry:
|
||||
|
||||
- 실패가 일시적이라는 근거가 있는가?
|
||||
- retryFor, maxAttempts, backoff가 명시되어 있는가?
|
||||
- validation/domain/programming 오류는 제외되어 있는가?
|
||||
- recover 또는 최종 실패 경로가 분명한가?
|
||||
@@ -0,0 +1,166 @@
|
||||
# bean registration 기준
|
||||
|
||||
## 목적
|
||||
|
||||
Spring bean 등록은 “컨테이너가 생명주기와 의존성을 관리해야 하는 객체”에만 사용한다.
|
||||
아무 객체나 bean으로 올리지 않고, stereotype scanning과 `@Configuration` + `@Bean`을 역할에 따라 구분한다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- Spring IoC container는 configuration metadata를 읽어 bean definition을 만들고 객체를 관리한다.
|
||||
- 설정 메타데이터는 주로 annotation-based component class, `@Configuration` + `@Bean`, 또는 외부 설정으로 표현할 수 있다.
|
||||
- `@Component`와 그 특수화(`@Repository`, `@Service`, `@Controller`)는 classpath scanning 대상이다.
|
||||
- `@Bean`은 객체를 생성·설정·초기화하는 factory method를 bean definition으로 등록한다.
|
||||
- `@Bean`은 `@Configuration` 클래스에서 사용하는 것이 기본 권장 방식이다.
|
||||
- bean overriding은 일반적으로 권장되지 않으며 설정 가독성을 해친다.
|
||||
- bean/runtime registration은 초기 단계가 아니라 live access 중에는 공식적으로 지원되지 않는다.
|
||||
- 일반적으로 fine-grained domain object는 Spring container에 등록하지 않는다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. bean은 “컨테이너 관리 가치”가 있는 객체만 등록
|
||||
다음은 bean 등록 후보이다.
|
||||
|
||||
- application service / use case entry object
|
||||
- repository adapter
|
||||
- external API client
|
||||
- configuration / security / filter / interceptor
|
||||
- shared infrastructure object
|
||||
- framework integration object
|
||||
|
||||
다음은 기본적으로 bean 등록하지 않는다.
|
||||
|
||||
- domain entity
|
||||
- value object
|
||||
- request / response DTO
|
||||
- command / result DTO
|
||||
- 단순 임시 helper object
|
||||
- 매 요청/매 호출마다 새로 만들어도 되는 순수 data object
|
||||
|
||||
### 2. 애플리케이션 주 컴포넌트는 stereotype annotation 우선
|
||||
다음은 stereotype을 우선 검토한다.
|
||||
|
||||
- `@Service`
|
||||
- `@Repository`
|
||||
- `@Controller` / `@RestController`
|
||||
- generic component면 `@Component`
|
||||
|
||||
즉 프로젝트 코드 안의 “주된 역할 객체”는 scanning 기반 등록을 기본값으로 한다.
|
||||
|
||||
### 3. `@Bean`은 명시적 조립이 필요할 때 사용
|
||||
다음은 `@Configuration` + `@Bean`을 우선 검토한다.
|
||||
|
||||
- 외부 라이브러리 타입 등록
|
||||
- 생성자가 복잡하거나 factory method가 필요한 경우
|
||||
- 조건부 조립이 필요한 경우
|
||||
- 여러 collaborator를 엮어 명시적으로 wiring해야 하는 경우
|
||||
- infrastructure object / client / encoder / formatter / strategy bean 생성
|
||||
- 같은 config 안에서 inter-bean wiring을 명시적으로 보여주고 싶은 경우
|
||||
|
||||
### 4. `@Bean`은 기본적으로 `@Configuration` 안에서만
|
||||
`@Bean` method는 기본적으로 `@Configuration` 클래스 안에 둔다.
|
||||
|
||||
이유:
|
||||
- full configuration mode가 inter-bean dependency를 더 안전하게 다룬다
|
||||
- lite mode의 subtle bug 가능성을 줄일 수 있다
|
||||
|
||||
기본 금지:
|
||||
- 일반 `@Component` 안에 습관적으로 `@Bean` method 두기
|
||||
|
||||
예외:
|
||||
- 아주 제한된 factory-style component가 필요하고, inter-bean dependency 호출을 하지 않는 경우
|
||||
|
||||
### 5. bean 등록 이유가 이름만 보고 드러나야 한다
|
||||
- scanning bean이면 stereotype이 역할을 드러내야 한다
|
||||
- `@Configuration` 클래스는 조립 목적이 이름에 드러나야 한다
|
||||
|
||||
좋은 방향:
|
||||
- `SecurityConfiguration`
|
||||
- `WebConfiguration`
|
||||
- `VaultClientConfiguration`
|
||||
- `OAuth2SecurityConfiguration`
|
||||
|
||||
지양:
|
||||
- `CommonConfig`
|
||||
- `AppBeans`
|
||||
- `GeneralConfiguration`
|
||||
|
||||
### 6. domain object를 bean으로 등록하지 않는다
|
||||
Spring 공식 문서도 fine-grained domain object는 보통 container가 아니라 repository/business logic이 만들고 로드한다고 설명한다.
|
||||
|
||||
기본 금지:
|
||||
- `User`, `Money`, `UserEmail`, `CreateUserCommand`를 bean으로 등록
|
||||
- domain 생성 책임을 container로 넘기기
|
||||
|
||||
### 7. bean 이름은 기본 규칙을 따르고, 명시적 이름은 정말 필요할 때만
|
||||
Spring은 scanning bean의 이름을 일반적으로 decapitalize된 simple class name으로 만든다.
|
||||
|
||||
기본:
|
||||
- 이름 충돌이 없으면 기본 이름 사용
|
||||
- qualifier/alias/explicit name은 실제 필요가 있을 때만 사용
|
||||
|
||||
무분별한 명시적 이름 지정 지양:
|
||||
- `"mySpecialUserServiceBean"`
|
||||
- `"appMainPrimaryService"`
|
||||
|
||||
### 8. bean overriding 기본 금지
|
||||
같은 이름의 bean을 덮어쓰는 방식으로 조립하지 않는다.
|
||||
|
||||
이유:
|
||||
- 설정 가독성이 나빠진다
|
||||
- 어떤 bean이 실제로 쓰이는지 추적이 어려워진다
|
||||
|
||||
테스트에서만 예외적으로 필요하면 별도 테스트 설정/지원 메커니즘을 사용한다.
|
||||
|
||||
### 9. 런타임 동적 bean 등록 금지
|
||||
애플리케이션 실행 중 live container에 새 bean을 동적으로 등록하는 방식은 기본 금지한다.
|
||||
|
||||
기본:
|
||||
- bean definition은 startup 시점에 확정
|
||||
- 동적 확장이 필요하면 registry/plugin/factory 전략을 따로 설계
|
||||
|
||||
### 10. bean은 역할 단위로 등록하고, 잡동사니 helper를 bean으로 올리지 않는다
|
||||
container가 관리할 필요가 없는 순수 helper는 bean 대신:
|
||||
- static utility
|
||||
- package-private helper
|
||||
- mapper instance
|
||||
- plain object 생성
|
||||
을 우선 검토한다.
|
||||
|
||||
### 11. configuration class는 “조립”만 하고 business logic은 넣지 않는다
|
||||
`@Configuration` 클래스는 bean wiring과 설정 소유만 담당한다.
|
||||
|
||||
금지:
|
||||
- 비즈니스 흐름
|
||||
- 상태 전이
|
||||
- 외부 호출 오케스트레이션
|
||||
- 의미 있는 계산 로직
|
||||
|
||||
### 12. stereotype는 의미에 맞게 쓴다
|
||||
- persistence adapter면 `@Repository`
|
||||
- use case/application service면 `@Service`
|
||||
- web adapter면 `@Controller` / `@RestController`
|
||||
- 그 외 일반 Spring-managed component면 `@Component`
|
||||
|
||||
의미 없는 전부 `@Component` 관성 사용은 지양한다.
|
||||
|
||||
### 13. public API 역할이 없는 내부 구현은 과도한 bean 분해를 피한다
|
||||
container bean 수를 늘리는 것이 곧 좋은 설계는 아니다.
|
||||
|
||||
질문:
|
||||
- lifecycle 관리가 필요한가?
|
||||
- 외부에서 주입받아야 하는가?
|
||||
- 테스트 seam 가치가 있는가?
|
||||
- 명시적 wiring으로 읽기 쉬워지는가?
|
||||
|
||||
아니면 plain object가 더 낫다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- bean은 컨테이너 관리 가치가 있는 객체만 등록
|
||||
- application 주 컴포넌트는 stereotype scanning 우선
|
||||
- 외부 라이브러리/명시적 조립은 `@Configuration` + `@Bean`
|
||||
- `@Bean`은 기본적으로 `@Configuration` 안에서만
|
||||
- domain object / DTO / value object는 bean 등록 금지
|
||||
- bean overriding, 런타임 동적 등록 기본 금지
|
||||
- configuration class는 조립만 담당
|
||||
@@ -0,0 +1,180 @@
|
||||
# @ConfigurationProperties 사용 기준
|
||||
|
||||
## 목적
|
||||
|
||||
외부 설정은 산발적인 문자열 주입이 아니라, **의미 있는 설정 객체**로 묶어 관리한다.
|
||||
설정은 business object가 아니라 **configuration contract** 로 취급한다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- `@ConfigurationProperties`는 externalized configuration을 타입 안전하게 바인딩하기 위한 애노테이션이다.
|
||||
- 클래스 또는 `@Configuration` 안의 `@Bean` 메서드에 붙일 수 있다.
|
||||
- 바인딩은 setter 또는 생성자 인자를 통해 수행될 수 있다.
|
||||
- `@EnableConfigurationProperties` 또는 `@ConfigurationPropertiesScan`으로 등록할 수 있다.
|
||||
- `@ConfigurationProperties`는 `@Value`보다 relaxed binding, metadata 지원에 유리하다.
|
||||
- `@ConfigurationProperties`는 SpEL을 평가하지 않는다.
|
||||
- `@ConfigurationPropertiesScan`은 `@Component`가 붙은 클래스를 스캔 대상으로 잡지 않는다.
|
||||
- `@Validated`로 properties validation을 수행할 수 있다.
|
||||
- `Optional`은 `@ConfigurationProperties`에서 권장되지 않는다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 의미 있는 설정 그룹은 `@ConfigurationProperties` 우선
|
||||
다음은 `@ConfigurationProperties`를 우선 검토한다.
|
||||
|
||||
- 같은 prefix 아래 여러 설정값이 함께 움직임
|
||||
- 계층형/nested 설정이 있음
|
||||
- 설정 검증이 중요함
|
||||
- 여러 bean이 같은 설정 집합을 참조함
|
||||
- 운영 문서와 IDE metadata 지원이 중요함
|
||||
|
||||
예:
|
||||
- Vault 설정
|
||||
- OAuth client 설정
|
||||
- JWT 설정
|
||||
- scheduler/retry 설정
|
||||
- feature toggle 묶음
|
||||
|
||||
### 2. 단발성 한두 값만 필요하면 `@Value`를 제한적으로 허용
|
||||
다음은 `@Value`를 허용할 수 있다.
|
||||
|
||||
- 단일 상수성 설정값
|
||||
- 로컬 config class 안에서만 쓰는 매우 작은 값
|
||||
- SpEL이 실제로 필요한 경우
|
||||
|
||||
단, 애플리케이션 자체 설정 키 집합이라면 `@ConfigurationProperties`를 우선한다.
|
||||
|
||||
### 3. properties class는 설정 계약만 표현
|
||||
`@ConfigurationProperties` 클래스의 책임은:
|
||||
- 설정값 구조 표현
|
||||
- 타입 안전 바인딩
|
||||
- 검증
|
||||
- 합리적 기본값 표현
|
||||
|
||||
다음은 넣지 않는다.
|
||||
- business logic
|
||||
- 외부 API 호출
|
||||
- repository/service 호출
|
||||
- 큰 계산 로직
|
||||
- runtime mutable state
|
||||
|
||||
### 4. prefix는 명확하고 안정적으로 설계
|
||||
prefix는 기능/도메인 경계를 드러내야 한다.
|
||||
|
||||
좋은 방향:
|
||||
- `auth.jwt`
|
||||
- `auth.oauth.google`
|
||||
- `vault.transit`
|
||||
- `app.retry`
|
||||
|
||||
지양:
|
||||
- `config`
|
||||
- `common`
|
||||
- `misc`
|
||||
- 의미가 너무 넓은 prefix
|
||||
|
||||
### 5. 등록 방식은 스캔과 명시 등록을 구분
|
||||
기본 선택:
|
||||
- 애플리케이션 내부 일반 설정 타입 -> `@ConfigurationPropertiesScan`
|
||||
- 조건부 등록/auto-configuration/명시적 wiring 필요 -> `@EnableConfigurationProperties` 또는 `@Bean` + `@ConfigurationProperties`
|
||||
|
||||
### 6. `@Component`와 `@ConfigurationProperties`를 습관적으로 같이 쓰지 않는다
|
||||
properties class는 설정 바인딩 타입이지 일반 component가 아니다.
|
||||
|
||||
기본:
|
||||
- scanning 대상 properties -> `@ConfigurationPropertiesScan`
|
||||
- 명시 등록이 필요하면 `@EnableConfigurationProperties`
|
||||
|
||||
`@Component`를 붙여 일반 bean처럼 다루는 패턴은 지양한다.
|
||||
|
||||
### 7. 가능한 한 immutable 구조를 선호
|
||||
설정은 보통 startup 후 바뀌지 않는 계약이다.
|
||||
|
||||
기본 방향:
|
||||
- 생성자 기반 바인딩 또는 immutable한 구조 선호
|
||||
- 변경 가능한 setter-only bag object를 기본값으로 삼지 않음
|
||||
- 필수 설정은 생성 시점에 확정되게 설계
|
||||
|
||||
### 8. `Optional` 필드 금지
|
||||
Spring Boot 공식 문서상 `Optional`은 `@ConfigurationProperties`에서 권장되지 않는다.
|
||||
|
||||
기본:
|
||||
- nullable field
|
||||
- 기본값
|
||||
- nested object
|
||||
- 명시적 default object
|
||||
중 하나로 표현한다.
|
||||
|
||||
### 9. validation은 startup에서 최대한 실패하게
|
||||
설정이 잘못되면 런타임 깊은 지점에서 터지지 않게, 바인딩 시점 검증을 우선한다.
|
||||
|
||||
기본:
|
||||
- `@Validated`
|
||||
- Bean Validation annotation (`@NotNull`, `@Min`, `@Pattern` 등)
|
||||
- nested properties는 필요 시 `@Valid`
|
||||
|
||||
### 10. 기본값 정책을 숨기지 않는다
|
||||
기본값은 다음 중 하나로 명시한다.
|
||||
|
||||
- 필드 기본값
|
||||
- 생성자 기본값
|
||||
- 명시적 nested default object
|
||||
- 문서화된 운영 기본값
|
||||
|
||||
“값이 없으면 나중에 어딘가에서 처리”를 금지한다.
|
||||
|
||||
### 11. Environment 직접 조회보다 properties bean 주입 우선
|
||||
application/service/infrastructure 코드에서 `Environment#getProperty(...)`를 흩뿌리지 않는다.
|
||||
|
||||
기본:
|
||||
- 관련 설정은 properties 객체로 묶고
|
||||
- 필요한 bean에 주입한다
|
||||
|
||||
예외:
|
||||
- truly dynamic property lookup
|
||||
- framework/bootstrap 초기화 특수 상황
|
||||
|
||||
### 12. 설정 객체는 소유 모듈 가까이에 둔다
|
||||
properties class는 그것을 사용하는 기능/모듈 옆에 둔다.
|
||||
|
||||
예:
|
||||
- `vault` 설정은 vault adapter/config 근처
|
||||
- `jwt` 설정은 jwt/token 모듈 근처
|
||||
|
||||
`CommonProperties`, `AppProperties`처럼 전역 잡동사니 설정 객체는 지양한다.
|
||||
|
||||
### 13. third-party bean 바인딩도 가능하지만, 범위를 제한
|
||||
외부 라이브러리 객체를 `@Bean` 메서드 + `@ConfigurationProperties`로 바인딩할 수 있다.
|
||||
단, 그 경우도:
|
||||
- 명확한 prefix
|
||||
- 명확한 config class
|
||||
- 외부 라이브러리 설정 범위 제한
|
||||
을 지킨다.
|
||||
|
||||
### 14. 설정 계약과 business meaning을 혼동하지 않는다
|
||||
예:
|
||||
- `token-expiration-seconds`는 설정 계약
|
||||
- `TokenTtl`은 도메인 의미일 수 있다
|
||||
|
||||
필요하면 properties -> domain config/value object 변환 단계를 둔다.
|
||||
설정 타입을 그대로 domain everywhere에 흘려보내지 않는다.
|
||||
|
||||
### 15. 문서와 metadata를 함께 고려
|
||||
`@ConfigurationProperties`의 장점 중 하나는 metadata/IDE 지원이다.
|
||||
애플리케이션이 제공하는 설정 키는:
|
||||
- prefix 일관성
|
||||
- 이름 일관성
|
||||
- 설명/문서화
|
||||
를 함께 고려한다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 의미 있는 설정 집합은 `@ConfigurationProperties` 우선
|
||||
- 한두 개 단발성 값만 `@Value` 제한 허용
|
||||
- properties class는 설정 계약만 표현
|
||||
- `@Component`와 습관적 결합 금지
|
||||
- immutable 구조 선호
|
||||
- `Optional` 필드 금지
|
||||
- validation은 startup에서 최대한 실패하게
|
||||
- `Environment` 직접 조회보다 properties bean 주입 우선
|
||||
- 설정 객체는 owning module 가까이에 둔다
|
||||
@@ -0,0 +1,166 @@
|
||||
# dependency injection 기준
|
||||
|
||||
## 목적
|
||||
|
||||
의존성 주입은 객체가 자신의 협력 객체를 직접 생성하거나 찾지 않게 하여,
|
||||
- 결합도를 낮추고
|
||||
- 테스트를 쉽게 하며
|
||||
- 초기화 상태를 더 명확하게 만드는 데 사용한다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- Spring DI는 객체가 의존성을 생성자 인자, factory method 인자, 또는 setter/config method로 선언하면 컨테이너가 주입하는 방식이다.
|
||||
- Spring은 constructor-based DI와 setter-based DI를 지원한다.
|
||||
- Spring 팀은 일반적으로 constructor injection을 권장한다.
|
||||
- constructor injection은 필수 의존성의 non-null 보장과 fully initialized state를 더 쉽게 만든다.
|
||||
- setter injection은 주로 optional dependency 또는 재설정 가능한 dependency에 적합하다.
|
||||
- field injection은 production code에서는 권장되지 않는다.
|
||||
- 생성자가 하나뿐인 경우 Spring은 `@Autowired` 없이도 그 생성자를 사용할 수 있다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 기본값은 constructor injection
|
||||
application code의 기본 DI 방식은 생성자 주입이다.
|
||||
|
||||
이유:
|
||||
- 필수 의존성이 명확하다
|
||||
- 객체가 생성 직후 완전한 상태가 된다
|
||||
- final field 사용이 가능하다
|
||||
- 테스트에서 plain constructor 호출이 쉽다
|
||||
|
||||
### 2. 필수 의존성은 생성자로만 받는다
|
||||
다음은 생성자로만 주입한다.
|
||||
|
||||
- business collaborator
|
||||
- repository / port
|
||||
- external client
|
||||
- policy / strategy
|
||||
- configuration object
|
||||
- mapper / validator / assembler 중 필수 협력 객체
|
||||
|
||||
필수 의존성을 setter/field로 받지 않는다.
|
||||
|
||||
### 3. 선택 의존성만 setter/config method injection 검토
|
||||
setter 또는 config method injection은 아래일 때만 검토한다.
|
||||
|
||||
- optional dependency
|
||||
- reasonable default가 있는 경우
|
||||
- 재설정/reconfiguration 가능성이 실제로 필요한 경우
|
||||
- legacy / third-party class 구조상 생성자 주입이 적합하지 않은 경우
|
||||
|
||||
기본값은 아니다.
|
||||
|
||||
### 4. production code field injection 금지
|
||||
production code에서는 field injection을 사용하지 않는다.
|
||||
|
||||
이유:
|
||||
- 의존성이 시그니처에 드러나지 않는다
|
||||
- plain unit test가 불편해진다
|
||||
- final field 사용이 어렵다
|
||||
- partially initialized state 위험을 키운다
|
||||
|
||||
예외:
|
||||
- 테스트 클래스
|
||||
- framework가 직접 관리하는 극히 제한적 특수 케이스
|
||||
|
||||
### 5. 단일 생성자면 `@Autowired` 생략 가능
|
||||
생성자가 하나뿐인 bean class는 `@Autowired`를 굳이 붙이지 않아도 된다.
|
||||
|
||||
기본:
|
||||
- single constructor -> annotation 생략 가능
|
||||
- 여러 생성자면 의도를 분명히 해야 한다
|
||||
|
||||
### 6. 의존성은 lookup하지 않는다
|
||||
bean은 자신의 dependency를 직접 찾지 않는다.
|
||||
|
||||
금지:
|
||||
- `applicationContext.getBean(...)`
|
||||
- service locator 패턴
|
||||
- static holder 통해 bean 가져오기
|
||||
|
||||
예외:
|
||||
- 아주 제한된 framework integration
|
||||
- truly dynamic lookup이 필요한 infrastructure 경계
|
||||
|
||||
기본은 constructor/setter 주입이다.
|
||||
|
||||
### 7. 생성자 인자가 많으면 DI 스타일이 아니라 책임 분해 문제를 먼저 본다
|
||||
Spring 공식 문서도 constructor parameter가 많으면 code smell로 본다.
|
||||
|
||||
기본 판단:
|
||||
- 5~7개 이상으로 커지면 책임 과다를 의심
|
||||
- 하위 collaborator 분리
|
||||
- policy object 분리
|
||||
- orchestration 분리
|
||||
- config object 묶기
|
||||
를 먼저 검토한다
|
||||
|
||||
“setter로 바꿔서 숨기기”로 해결하지 않는다.
|
||||
|
||||
### 8. final field 우선
|
||||
constructor injection을 쓴다면 의존성 필드는 가능한 한 final로 둔다.
|
||||
|
||||
이유:
|
||||
- 불변성 강화
|
||||
- 초기화 상태 명확화
|
||||
- 재주입/변경 여지 축소
|
||||
|
||||
### 9. optional dependency는 명시적으로 표현
|
||||
optional dependency는 다음 방식 중 하나를 명시적으로 선택한다.
|
||||
|
||||
- setter injection
|
||||
- `ObjectProvider<T>`
|
||||
- nullable/optional parameter를 가진 config method
|
||||
- reasonable default를 가진 생성자/팩토리 구성
|
||||
|
||||
필수와 선택을 섞어 모호하게 만들지 않는다.
|
||||
|
||||
### 10. 컬렉션/다중 구현 주입은 의도를 분명히
|
||||
여러 bean이 한 인터페이스를 구현할 때는:
|
||||
- `List<T>`
|
||||
- `Map<String, T>`
|
||||
- `@Qualifier`
|
||||
- `@Primary`
|
||||
등을 통해 의도를 분명히 한다.
|
||||
|
||||
“우연히 하나만 있으니까 된다”에 기대지 않는다.
|
||||
|
||||
### 11. configuration properties는 raw value보다 객체로 주입
|
||||
관련 설정값이 여러 개면 primitive/string 여러 개를 직접 주입하지 말고, configuration properties 객체로 묶어 주입하는 쪽을 우선 검토한다.
|
||||
|
||||
### 12. bean 간 순환 의존은 기본 금지
|
||||
Spring은 constructor circular dependency를 문제로 보고, setter injection으로 우회는 가능하지만 권장하지 않는다.
|
||||
|
||||
기본:
|
||||
- 순환 구조를 리팩터링으로 제거
|
||||
- 책임 재배치
|
||||
- 이벤트/포트/분리된 collaborator 도입 검토
|
||||
|
||||
setter로 억지 우회하지 않는다.
|
||||
|
||||
### 13. framework 관리 대상과 plain object를 구분
|
||||
모든 객체가 DI 대상은 아니다.
|
||||
|
||||
기본:
|
||||
- Spring bean 협력은 DI
|
||||
- value object / domain entity / DTO / 단순 계산 객체는 plain object 생성 유지
|
||||
|
||||
### 14. 테스트도 같은 원칙을 따르되, 테스트 클래스 field injection은 허용 가능
|
||||
Spring 공식 문서는 테스트에서는 field injection이 자연스러울 수 있다고 설명한다.
|
||||
|
||||
기본:
|
||||
- production code -> constructor injection
|
||||
- test class -> Spring test fixture에서는 field injection 허용 가능
|
||||
- 하지만 application code 자체는 계속 constructor injection 유지
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 기본 DI 방식은 constructor injection
|
||||
- 필수 의존성은 생성자
|
||||
- optional dependency만 setter/config method 검토
|
||||
- production code field injection 금지
|
||||
- single constructor면 `@Autowired` 생략 가능
|
||||
- dependency lookup 금지
|
||||
- 생성자 인자 과다는 책임 분해 신호
|
||||
- final field 우선
|
||||
- circular dependency 우회보다 구조 수정 우선
|
||||
@@ -0,0 +1,317 @@
|
||||
# Filter / Interceptor / Resolver / Advice 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 Spring MVC 기반 서버에서 요청/응답 경계의 공통 처리 로직을 어디에 둘지 정의한다.
|
||||
|
||||
대상은 다음 네 가지다.
|
||||
|
||||
- Servlet Filter
|
||||
- Spring MVC HandlerInterceptor
|
||||
- HandlerExceptionResolver
|
||||
- @ControllerAdvice / @RestControllerAdvice / @ExceptionHandler / ResponseBodyAdvice
|
||||
|
||||
목표는 다음과 같다.
|
||||
|
||||
- HTTP/Servlet 수준 관심사와 MVC/controller 수준 관심사를 분리한다.
|
||||
- 예외 처리와 응답 포맷 표준화를 한 곳에 모은다.
|
||||
- business rule, validation, transaction, domain mapping이 web infrastructure 훅 안으로 새어 들어가지 않게 한다.
|
||||
- 같은 문제를 filter / interceptor / advice 어디에든 중복 구현하는 일을 막는다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 제약 위에 일반적인 실무 운영 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 계층이 낮을수록 더 일반적인 HTTP 관심사만 둔다
|
||||
|
||||
기본 원칙:
|
||||
|
||||
- Filter: Servlet/HTTP 인프라 수준 공통 처리
|
||||
- Interceptor: handler/controller 실행 전후 공통 처리
|
||||
- @RestControllerAdvice: controller 계층의 예외 응답/공통 응답 규약 처리
|
||||
- HandlerExceptionResolver: 정말 낮은 수준의 resolver chain 커스터마이징이 필요할 때만 사용
|
||||
|
||||
Spring 공식 문서 기준으로 Filter는 filter chain과 target Servlet 전후에 interception-style logic을 적용하는 용도이고, HandlerInterceptor는 handler 실행 전후 callback이며, 예외는 DispatcherServlet이 HandlerExceptionResolver 체인으로 위임합니다. @ControllerAdvice / @RestControllerAdvice는 전역 @ExceptionHandler 적용 지점입니다.
|
||||
|
||||
### 3.2 더 위 레벨 도구로 해결 가능한 일은 아래 레벨로 내리지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller 예외 응답은 우선 @RestControllerAdvice
|
||||
- 응답 body 표준화는 우선 ResponseBodyAdvice
|
||||
- handler 관련 공통 전처리는 우선 HandlerInterceptor
|
||||
- Servlet container 전체에 걸친 공통 처리만 Filter
|
||||
|
||||
즉, 아래 레벨 훅을 “더 강력하니까” 먼저 선택하지 않는다.
|
||||
|
||||
### 3.3 이 프로젝트의 에러 응답 기본 포맷은 ApiResult다
|
||||
|
||||
Spring은 @ExceptionHandler 또는 @RequestMapping에서 ProblemDetail이나 ErrorResponse를 반환해 RFC 9457 응답을 렌더링할 수 있고, ResponseEntityExceptionHandler도 공식 제공한다. 하지만 이 프로젝트는 공식 확장 지점은 따르되, 응답 본문 포맷의 기본값은 custom ApiResult 로 둔다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 에러 응답의 프로젝트 기본 표준은 ProblemDetail이 아니라 ApiResult
|
||||
- 전역 예외 처리의 기본 위치는 @RestControllerAdvice
|
||||
- built-in MVC 예외와 business exception 모두 프로젝트 공통 ApiResult 규약으로 변환
|
||||
- 외부 표준 계약이나 특정 연동에서 RFC 9457이 명시적으로 필요할 때만 ProblemDetail 사용을 예외적으로 허용
|
||||
|
||||
이 규칙은 Official + Practice + Project Recommendation 이다.
|
||||
즉, 공식 문서가 제공하는 entry point는 사용하되, 실제 payload shape은 프로젝트 표준으로 통일한다.
|
||||
|
||||
## 4. Filter 표준
|
||||
|
||||
### 4.1 Filter의 책임
|
||||
|
||||
Filter는 Servlet 체인 수준의 공통 처리에 사용한다. Spring 공식 문서에서 Filter는 processing chain과 target Servlet 전후에 interception-style logic을 적용하는 용도다.
|
||||
|
||||
허용 예:
|
||||
|
||||
- request/response wrapping
|
||||
- forwarded header 처리
|
||||
- correlation id / trace id의 very-early binding
|
||||
- MDC 진입/해제
|
||||
- Spring Security filter chain에 통합되는 보안 전처리
|
||||
- controller mapping 이전에 처리되어야 하는 공통 HTTP concern
|
||||
|
||||
비허용 예:
|
||||
|
||||
- 도메인 검증
|
||||
- use case 오케스트레이션
|
||||
- transaction 시작/종료
|
||||
- repository/JPA 직접 호출을 전제로 한 핵심 업무 처리
|
||||
- DTO ↔ domain 변환
|
||||
- 공통 API 에러 응답 본문 구성의 주 책임
|
||||
|
||||
### 4.2 custom filter는 OncePerRequestFilter를 우선 검토한다
|
||||
|
||||
Spring 공식 문서 기준으로 OncePerRequestFilter는 request 시작 시 단일 호출을 지원하고, ASYNC/ERROR dispatch 관여 여부 제어를 제공한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 새 custom filter는 기본적으로 OncePerRequestFilter를 우선 사용한다.
|
||||
- shouldNotFilterAsyncDispatch, shouldNotFilterErrorDispatch 필요 여부를 명시적으로 검토한다.
|
||||
- “왜 filter여야 하는가?”를 설명할 수 없으면 interceptor 또는 advice로 올린다.
|
||||
|
||||
### 4.3 Filter에는 무거운 의존성을 직접 물지 않는다
|
||||
|
||||
Spring Boot 공식 문서는 filter bean이 application lifecycle 초기에 설치되므로 너무 많은 bean의 eager initialization을 유발하지 않게 주의하라고 하며, DataSource나 JPA configuration 의존은 좋지 않다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- filter에서 repository, entity manager, transaction-heavy service 직접 의존을 기본 금지한다.
|
||||
- filter가 복잡한 서비스 계층을 호출해야 한다면 설계를 다시 검토한다.
|
||||
- 인증/인가 체계는 개별 custom filter 남발보다 security/filter chain 구조에 맞춘다.
|
||||
|
||||
### 4.4 Filter 등록과 순서는 명시적 필요가 있을 때만 건드린다
|
||||
|
||||
Spring Boot는 Filter bean을 자동 등록하고, 필요하면 FilterRegistrationBean으로 매핑과 order를 제어할 수 있다. dispatcher type 미지정 시 기본은 REQUEST다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 순서 의존이 없으면 순서 지정 최소화
|
||||
- 순서가 필요하면 이유를 주석 또는 문서에 남긴다
|
||||
- request body를 읽거나 wrapping하는 filter는 더 이른 순서 배치를 신중히 검토한다
|
||||
|
||||
## 5. HandlerInterceptor 표준
|
||||
|
||||
### 5.1 Interceptor의 책임
|
||||
|
||||
HandlerInterceptor는 handler/controller 실행 전후의 가벼운 공통 처리에 사용한다. Spring 공식 문서도 interceptor를 handler 관련 fine-grained preprocessing 용도로 설명한다.
|
||||
|
||||
허용 예:
|
||||
|
||||
- 인증 완료 이후의 요청자 정보 추출
|
||||
- audit context 세팅/해제
|
||||
- handler 기반 lightweight access policy
|
||||
- locale/theme 같은 MVC handler 관련 전처리
|
||||
- controller 호출 전후의 가벼운 메타데이터 기록
|
||||
|
||||
비허용 예:
|
||||
|
||||
- 보안의 주 진입점
|
||||
- request/response wrapping
|
||||
- body 읽기/변형
|
||||
- transaction 제어
|
||||
- 핵심 비즈니스 검증
|
||||
- domain object 생성/조립
|
||||
|
||||
### 5.2 Interceptor를 보안 레이어의 중심으로 사용하지 않는다
|
||||
|
||||
Spring 공식 문서는 interceptor가 annotated controller path matching과 mismatch 가능성이 있어 security layer로는 이상적이지 않으며, 일반적으로 Spring Security 또는 Servlet filter chain에 통합된 접근을 더 이르게 적용하라고 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 인증/인가의 중심은 interceptor가 아니라 security/filter chain
|
||||
- interceptor는 보안 체계가 끝난 뒤 controller 실행에 가까운 공통 처리에만 사용
|
||||
|
||||
### 5.3 @ResponseBody / ResponseEntity 응답 변경 지점으로 쓰지 않는다
|
||||
|
||||
Spring 공식 문서 기준으로 @ResponseBody와 ResponseEntity 응답은 HandlerAdapter 내부에서 body가 쓰이고 커밋된 뒤 postHandle이 호출되므로, 이 시점은 응답 변경 지점으로 늦다. 이 경우 ResponseBodyAdvice를 사용해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON 응답 envelope 적용
|
||||
- 공통 body 필드 삽입
|
||||
- 에러 응답 본문 공통 보강
|
||||
- ApiResult 응답 래핑/정규화
|
||||
|
||||
이런 작업은 interceptor가 아니라 ResponseBodyAdvice 또는 @RestControllerAdvice에 둔다.
|
||||
|
||||
### 5.4 async controller가 있으면 interceptor lifecycle을 단순 가정하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- thread-local 정리 책임이 interceptor에 있다면 async request 존재 여부를 반드시 점검한다
|
||||
- async endpoint가 있다면 interceptor cleanup이 sync 요청과 동일하게 호출된다고 가정하지 않는다
|
||||
- async lifecycle이 중요하면 별도 async 처리 훅 설계를 검토한다
|
||||
|
||||
이 항목은 Spring MVC async 처리 모델을 반영한 Official + Practice 규칙이다.
|
||||
|
||||
## 6. HandlerExceptionResolver 표준
|
||||
|
||||
### 6.1 Resolver는 저수준 exception chain 확장 지점이다
|
||||
|
||||
Spring 공식 문서 기준으로 여러 HandlerExceptionResolver를 체인으로 둘 수 있고, order에 따라 순서가 정해지며, resolver는 ModelAndView, empty ModelAndView, 또는 null을 반환할 수 있다. null이면 다음 resolver가 계속 처리한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 애플리케이션 예외 응답의 기본 수단으로 custom HandlerExceptionResolver를 만들지 않는다
|
||||
- 기본 선택은 @RestControllerAdvice + @ExceptionHandler
|
||||
- custom resolver는 framework integration이나 아주 낮은 수준의 예외 변환이 필요할 때만 허용
|
||||
|
||||
### 6.2 resolver는 ApiResult 표준화의 주 도구가 아니다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- business exception → HTTP 응답 매핑은 @RestControllerAdvice
|
||||
- ApiResult.fail(...) 생성도 기본적으로 advice에서 수행
|
||||
- resolver는 “정말 advice보다 아래 레벨에서 처리해야 하는 상황”에만 사용
|
||||
|
||||
즉, ApiResult를 도입한다고 해서 resolver 쪽으로 내려가지 않는다.
|
||||
응답 포맷 표준화의 중심은 여전히 advice다.
|
||||
|
||||
## 7. @ControllerAdvice / @RestControllerAdvice 표준
|
||||
|
||||
### 7.1 전역 예외 처리는 @RestControllerAdvice를 기본으로 한다
|
||||
|
||||
Spring 공식 문서 기준으로 @RestControllerAdvice는 @ControllerAdvice + @ResponseBody의 shortcut이며, 전역 @ExceptionHandler는 local controller의 @ExceptionHandler 뒤에 적용된다. 기본적으로 모든 controller에 적용된다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- REST API 서버의 전역 예외 처리 기본값은 @RestControllerAdvice
|
||||
- controller별 local @ExceptionHandler 남발을 피한다
|
||||
- 공통 에러 정책은 소수의 advice에 집중시킨다
|
||||
|
||||
### 7.2 이 프로젝트의 전역 에러 응답은 ApiResult로 통일한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- business exception, validation exception, built-in MVC exception 모두 가능한 한 ApiResult 규약으로 변환한다
|
||||
- 에러 응답 구조는 프로젝트 전역에서 일관되게 유지한다
|
||||
- controller가 직접 에러 body를 조립하지 않는다
|
||||
- advice가 HTTP status와 ApiResult body를 함께 결정한다
|
||||
|
||||
권장 방향 예시:
|
||||
|
||||
- HTTP status는 표준 의미를 유지
|
||||
- body는 ApiResult의 실패 형식으로 통일
|
||||
- 내부 stack trace, framework class name, 구현 세부사항은 노출 금지
|
||||
- 외부 노출용 에러 코드와 메시지는 분리 가능하게 설계
|
||||
|
||||
이 규칙은 Official + Practice 에 가깝다.
|
||||
Spring은 ProblemDetail을 공식 지원하지만, 실제 프로젝트에서는 별도의 공통 envelope를 유지하는 경우가 많고, Spring의 공식 advice/exception handler 확장 지점은 그런 custom body에도 그대로 사용할 수 있다.
|
||||
|
||||
### 7.3 ProblemDetail은 기본이 아니라 예외적 옵션이다
|
||||
|
||||
Spring은 ProblemDetail, ErrorResponse, ResponseEntityExceptionHandler를 공식 지원한다. 또한 Spring Boot는 spring.mvc.problemdetails.enabled를 통해 built-in exception의 problem details 처리도 자동 구성할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 프로젝트 기본 에러 포맷은 ApiResult
|
||||
- ProblemDetail은 다음 경우에만 예외적으로 허용
|
||||
- 외부 표준 계약이 RFC 9457을 요구하는 경우
|
||||
- 특정 API만 공개 표준 준수가 더 중요한 경우
|
||||
- 타 시스템과의 호환성 때문에 application/problem+json이 필요한 경우
|
||||
- 프로젝트 내부/일반 REST API에는 ProblemDetail과 ApiResult를 혼용하지 않는다
|
||||
|
||||
### 7.4 built-in MVC 예외도 프로젝트 포맷으로 흡수한다
|
||||
|
||||
Spring 공식 문서 기준으로 ResponseEntityExceptionHandler는 Spring MVC 예외와 ErrorResponseException을 다루는 편의 base class다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- built-in MVC 예외가 많고 이를 일관된 ApiResult로 바꿔야 하면 ResponseEntityExceptionHandler 확장을 검토한다
|
||||
- built-in 예외를 ProblemDetail 그대로 노출하는 방향은 프로젝트 기본값이 아니다
|
||||
- business exception과 framework exception이 서로 다른 응답 형식을 가지지 않게 한다
|
||||
|
||||
## 8. ResponseBodyAdvice 표준
|
||||
|
||||
### 8.1 응답 body 공통 가공은 ResponseBodyAdvice를 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 ResponseBodyAdvice는 @ResponseBody 또는 ResponseEntity controller method 실행 후, HttpMessageConverter가 body를 쓰기 전에 응답을 커스터마이징하는 확장 지점이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 성공 응답의 공통 envelope 적용
|
||||
- ApiResult.success(...) 형태의 일관화
|
||||
- 에러 응답 body의 공통 필드 보강
|
||||
- trace id, timestamp, request id 같은 공통 값 삽입
|
||||
|
||||
이런 책임은 ResponseBodyAdvice에 둘 수 있다.
|
||||
|
||||
### 8.2 다만 전역 래핑은 “명확한 규약”이 있을 때만 사용한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 모든 성공 응답을 ApiResult로 래핑할지 여부는 프로젝트 API 계약에 맞춰 일관되게 정한다
|
||||
- file download, streaming, SSE, 이미 포맷이 고정된 응답에는 전역 wrapping을 피한다
|
||||
- supports(...) 조건을 좁게 잡아 surprise를 줄인다
|
||||
- controller가 이미 ApiResult를 반환하는 프로젝트라면 ResponseBodyAdvice로 이중 래핑하지 않는다
|
||||
|
||||
즉, ResponseBodyAdvice는 강력하지만 “마법처럼 몰래 바꾸는 곳”이 아니라 공식적인 응답 규약 적용 지점이어야 한다.
|
||||
|
||||
## 9. 위치별 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
Filter:
|
||||
|
||||
- transaction
|
||||
- repository/JPA 직접 호출
|
||||
- business validation
|
||||
- DTO/domain mapping
|
||||
- ApiResult 에러 본문 생성의 주 수단
|
||||
|
||||
Interceptor:
|
||||
|
||||
- 인증/인가의 주 구현
|
||||
- body wrapping
|
||||
- @ResponseBody 응답 변형
|
||||
- transaction
|
||||
- 핵심 use case 호출
|
||||
|
||||
HandlerExceptionResolver:
|
||||
|
||||
- 일반 business exception 처리의 기본 수단
|
||||
- 팀 공통 ApiResult 포맷의 주 진입점
|
||||
|
||||
ControllerAdvice / ResponseBodyAdvice:
|
||||
|
||||
- domain 규칙 실행
|
||||
- repository 접근
|
||||
- 핵심 오케스트레이션
|
||||
- endpoint별 business branching 누적
|
||||
|
||||
## 10. 선택 기준 요약
|
||||
|
||||
다음 질문으로 결정한다.
|
||||
|
||||
- request/response를 Servlet 수준에서 감싸거나 아주 이른 시점에 처리해야 하는가? Filter
|
||||
- 특정 handler/controller 실행 전후의 공통 처리인가? HandlerInterceptor
|
||||
- controller 예외를 HTTP 응답으로 바꾸는가? @RestControllerAdvice + @ExceptionHandler
|
||||
- 성공/실패 body를 프로젝트 공통 ApiResult 규약에 맞게 가공해야 하는가? ResponseBodyAdvice 또는 @RestControllerAdvice
|
||||
- 정말 resolver chain 자체를 커스터마이징해야 하는가? HandlerExceptionResolver
|
||||
@@ -0,0 +1,182 @@
|
||||
# @Transactional 위치 기준
|
||||
|
||||
## 목적
|
||||
|
||||
트랜잭션은 “아무 데나 붙이는 애노테이션”이 아니라,
|
||||
**하나의 비즈니스 작업 단위를 원자적으로 끝내야 하는 경계**에 둔다.
|
||||
|
||||
이 문서의 핵심은:
|
||||
- 트랜잭션을 어디에 둘지
|
||||
- 어디에 두면 안 되는지
|
||||
- 프록시 기반 동작 때문에 어떤 함정이 있는지
|
||||
를 명확히 하는 것이다.
|
||||
|
||||
## 공식 의미
|
||||
|
||||
- Spring의 선언적 트랜잭션은 기본적으로 AOP proxy 기반이다.
|
||||
- proxy mode에서는 프록시를 통해 들어오는 외부 메서드 호출만 interception 된다.
|
||||
- self-invocation은 기본적으로 transactional interception을 일으키지 않는다.
|
||||
- `@Transactional` 기본값은 `PROPAGATION_REQUIRED`, `ISOLATION_DEFAULT`, read-write, 기본 timeout, unchecked exception rollback이다.
|
||||
- Spring 팀은 인터페이스보다 구체 클래스의 메서드에 `@Transactional`을 두는 것을 권장한다.
|
||||
- `PROPAGATION_REQUIRED`는 같은 스레드에서 service facade가 여러 repository 호출을 묶는 일반적인 호출 구조에 적절한 기본값이다.
|
||||
- `REQUIRES_NEW`는 별도 물리 트랜잭션/자원을 더 잡으므로 커넥션 풀 고갈 위험이 있을 수 있다.
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### 1. 기본 위치는 application service / use case
|
||||
트랜잭션 경계의 기본 위치는 application 계층의 service/use case 메서드다.
|
||||
|
||||
이유:
|
||||
- 하나의 비즈니스 작업 단위를 가장 잘 표현한다
|
||||
- 여러 repository/adapter 호출을 하나의 경계로 묶기 쉽다
|
||||
- controller / repository / adapter에 흩어지는 것을 막는다
|
||||
|
||||
기본 예:
|
||||
- 회원 가입
|
||||
- 로그인 완료 처리
|
||||
- 사용자 상태 변경
|
||||
- 토큰 발급 + 저장
|
||||
- 주문 생성
|
||||
- 재시도 가능한 import 단위
|
||||
|
||||
### 2. controller / filter / interceptor / resolver에는 기본 금지
|
||||
web adapter는 HTTP 경계를 담당한다.
|
||||
트랜잭션 경계를 web layer에 두지 않는다.
|
||||
|
||||
금지 대상 기본값:
|
||||
- `@Controller`
|
||||
- `@RestController`
|
||||
- `Filter`
|
||||
- `HandlerInterceptor`
|
||||
- `HandlerMethodArgumentResolver`
|
||||
- exception handler
|
||||
|
||||
이유:
|
||||
- HTTP concern과 transaction concern이 섞인다
|
||||
- web boundary가 persistence 세부를 과도하게 끌어안게 된다
|
||||
- 요청 전체를 너무 넓은 transaction으로 감쌀 위험이 커진다
|
||||
|
||||
### 3. repository / adapter에는 기본 금지
|
||||
repository나 infrastructure adapter는 “트랜잭션 경계 소유자”가 아니라
|
||||
상위 경계 안에서 참여하는 collaborator를 기본값으로 한다.
|
||||
|
||||
기본 금지:
|
||||
- repository 메서드마다 습관적으로 `@Transactional`
|
||||
- external API client에 `@Transactional`
|
||||
- mapper/assembler/helper에 `@Transactional`
|
||||
|
||||
예외:
|
||||
- 그 컴포넌트가 독립적인 transaction boundary를 실제로 소유해야 하는 경우
|
||||
- 프레임워크/기술 통합상 별도 transaction semantics가 정말 필요한 경우
|
||||
|
||||
### 4. 읽기 전용 use case는 `readOnly = true` 검토
|
||||
순수 조회 유스케이스라면 `@Transactional(readOnly = true)`를 우선 검토한다.
|
||||
|
||||
적용 후보:
|
||||
- 상세 조회
|
||||
- 목록 조회
|
||||
- 검색
|
||||
- 통계용 읽기 작업
|
||||
|
||||
단, readOnly 안에서 실제 write가 섞이면 안 된다.
|
||||
|
||||
### 5. 쓰기 작업은 기본 read-write
|
||||
상태 변경, 저장, 삭제, 발급, 전이 같은 작업은 기본 read-write transaction으로 둔다.
|
||||
readOnly를 습관적으로 붙이지 않는다.
|
||||
|
||||
### 6. self-invocation을 믿지 않는다
|
||||
같은 클래스 안에서 `this.someTransactionalMethod()`처럼 호출하면
|
||||
proxy mode에서는 transactional interception이 일어나지 않는다.
|
||||
|
||||
기본 대응:
|
||||
- transaction boundary를 public entry method로 둔다
|
||||
- 필요하면 클래스를 분리한다
|
||||
- self-invocation을 전제로 설계하지 않는다
|
||||
|
||||
### 7. `@PostConstruct` / 초기화 코드에서 transaction에 기대지 않는다
|
||||
Spring 공식 문서상 proxy가 완전히 준비되기 전에는 기대한 동작이 보장되지 않는다.
|
||||
초기화 코드에서 transactional behavior를 전제로 하지 않는다.
|
||||
|
||||
### 8. 인터페이스보다 구체 클래스 메서드에 둔다
|
||||
Spring 팀 권장에 따라 `@Transactional`은 기본적으로 구체 클래스의 메서드에 둔다.
|
||||
|
||||
이유:
|
||||
- AspectJ weaving 등에서 interface annotation이 조용히 무시될 수 있는 함정을 줄인다
|
||||
- annotation 위치가 더 직접적이고 명확하다
|
||||
|
||||
### 9. 클래스 레벨보다 메서드 레벨 우선 검토
|
||||
클래스 전체가 거의 같은 transaction semantics를 가지면 클래스 레벨 선언을 허용할 수 있다.
|
||||
하지만 readOnly/read-write, propagation, timeout이 섞이면 메서드별로 명시한다.
|
||||
|
||||
기본:
|
||||
- 모든 public method가 같은 semantics면 class-level 가능
|
||||
- 그렇지 않으면 method-level로 명확히 분리
|
||||
|
||||
### 10. 트랜잭션은 가능한 한 짧게 유지
|
||||
트랜잭션은 DB 자원/잠금/연결을 붙잡을 수 있으므로 가능한 짧게 유지한다.
|
||||
|
||||
기본 금지:
|
||||
- 긴 계산
|
||||
- sleep
|
||||
- 재시도 루프
|
||||
- 사용자 입력 대기
|
||||
- 네트워크 왕복 다수 포함
|
||||
- 파일 업로드/다운로드 전체를 transaction 안에 유지
|
||||
|
||||
### 11. 외부 API 호출을 긴 DB transaction 안에 넣지 않는다
|
||||
공식 문서가 직접 “외부 API 호출 금지”라고 쓰지는 않지만, transaction 자원과 propagation semantics 설명을 보면
|
||||
DB transaction이 열려 있는 동안 원격 네트워크 호출까지 길게 포함시키는 것은 프로젝트 기본값으로 금지하는 것이 안전하다.
|
||||
|
||||
기본 방향:
|
||||
- DB update 전/후로 외부 호출을 분리
|
||||
- 정말 필요하면 outbox/event/후속 작업 구조 검토
|
||||
- 원격 호출 때문에 DB connection/lock을 오래 잡지 않는다
|
||||
|
||||
### 12. `REQUIRES_NEW`는 예외적으로만
|
||||
`REQUIRES_NEW`는 기본값이 아니다.
|
||||
|
||||
허용 후보:
|
||||
- 독립 감사 로그 저장
|
||||
- 실패해도 본 작업과 분리되어야 하는 후속 기록
|
||||
- 별도 확정 단위가 명확한 경우
|
||||
|
||||
주의:
|
||||
- 별도 물리 트랜잭션/자원을 사용한다
|
||||
- 커넥션 풀 크기와 deadlock 가능성을 고려해야 한다
|
||||
|
||||
### 13. propagation/isolation/timeout은 필요할 때만 명시
|
||||
기본값은 `REQUIRED`, `ISOLATION_DEFAULT`, 시스템 timeout이다.
|
||||
즉 의미가 명확할 때만 덮어쓴다.
|
||||
|
||||
금지:
|
||||
- 습관적으로 모든 메서드에 propagation/isolation 지정
|
||||
- 이유 없는 `REQUIRES_NEW`
|
||||
- 이유 없는 custom timeout
|
||||
|
||||
### 14. rollback 규칙은 예외 설계와 함께 본다
|
||||
기본적으로 unchecked exception만 rollback된다.
|
||||
checked exception도 rollback해야 하면 `rollbackFor` 등을 명시한다.
|
||||
|
||||
즉:
|
||||
- 예외 타입 설계
|
||||
- rollback 정책
|
||||
- business failure 의미
|
||||
를 같이 설계한다.
|
||||
|
||||
### 15. method visibility와 proxy 제약을 의식
|
||||
proxy mode에서는 인터페이스 기반 프록시의 transactional 메서드는 public이어야 한다.
|
||||
class-based proxy에서는 protected/package-visible 메서드도 가능하지만,
|
||||
프로젝트 기본값은 **외부 진입 public method를 transaction boundary로 두는 것**이다.
|
||||
|
||||
## 프로젝트 기준 요약
|
||||
|
||||
- 기본 위치는 application service / use case
|
||||
- controller / filter / resolver / interceptor에는 기본 금지
|
||||
- repository / external client / mapper에는 기본 금지
|
||||
- 순수 조회는 `readOnly = true` 검토
|
||||
- self-invocation 믿지 않음
|
||||
- `@Transactional`은 구체 클래스 메서드에 우선
|
||||
- transaction은 짧게 유지
|
||||
- 외부 API 호출을 긴 DB transaction 안에 넣지 않음
|
||||
- `REQUIRES_NEW`는 예외적으로만
|
||||
- rollback 규칙은 예외 타입과 함께 설계
|
||||
@@ -0,0 +1,246 @@
|
||||
# Validation Location 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 “무엇을 어디서 검증할 것인가”를 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- request shape 검증과 business rule 검증을 구분한다.
|
||||
- web validation, application validation, domain invariant enforcement의 위치를 명확히 한다.
|
||||
- Bean Validation 애노테이션을 어디에 써야 하고 어디에 의존하면 안 되는지 정한다.
|
||||
- validation이 filter, interceptor, advice 같은 잘못된 지점으로 새어 나가지 않게 한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 문서의 제약 위에 실무적으로 널리 쓰이는 구조를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 검증은 웹 계층 전용이 아니다
|
||||
|
||||
Spring은 validation이 웹 계층에 묶여 있어서는 안 되며, Spring Validator는 애플리케이션의 모든 계층에서 사용할 수 있다고 설명한다. 따라서 이 프로젝트에서도 validation을 “controller에서 끝나는 일”로 보지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- web validation은 입력 경계 검증
|
||||
- application validation은 use case 실행 전제 검증
|
||||
- domain validation은 도메인 불변식 보장
|
||||
- 외부 연동 validation은 외부 계약 준수 확인
|
||||
|
||||
으로 나눈다.
|
||||
|
||||
### 3.2 한 곳의 검증만 믿지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller validation은 잘못된 HTTP 입력을 빨리 거르는 역할이다.
|
||||
- 그러나 domain invariant를 controller validation에 의존하지 않는다.
|
||||
- 핵심 규칙은 domain 또는 application에서 다시 보장한다.
|
||||
|
||||
즉, “controller에서 @Valid 붙였으니 충분하다”를 금지한다.
|
||||
|
||||
### 3.3 request model과 domain model을 분리한다
|
||||
|
||||
Spring 공식 문서는 웹 데이터 바인딩에서 전용 model object를 사용하는 것이 좋고, JPA/Hibernate entity 같은 domain model을 직접 노출하지 말라고 권장한다. 또한 constructor binding 또는 allowedFields 같은 바인딩 통제를 검토하라고 안내한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request binding 대상은 전용 request DTO / form / command model이다.
|
||||
- entity, aggregate, domain object를 @RequestBody / @ModelAttribute 바인딩 대상으로 쓰지 않는다.
|
||||
- “웹 입력용 객체”와 “도메인 객체”는 분리한다.
|
||||
|
||||
## 4. 위치별 검증 규칙
|
||||
|
||||
### 4.1 Presentation(Web) 레이어
|
||||
|
||||
무엇을 여기서 검증하나
|
||||
|
||||
controller에서는 다음을 검증한다.
|
||||
|
||||
- 필수값 존재 여부
|
||||
- 문자열 길이, blank 여부, 숫자 범위
|
||||
- enum/format/basic pattern
|
||||
- request DTO의 필드 단위 구조 검증
|
||||
- 단일 요청 객체 안에서 해결 가능한 단순 cross-field 검증
|
||||
- path variable / request param / header의 기본 제약
|
||||
|
||||
Spring MVC는 @RequestBody, @ModelAttribute, @RequestPart에 @Valid 또는 @Validated를 붙여 개별 객체 검증을 수행할 수 있고, 메서드 파라미터에 직접 @NotBlank, @Min 같은 constraint를 두면 method validation이 적용된다. 상황에 따라 MethodArgumentNotValidException 또는 HandlerMethodValidationException이 발생할 수 있다.
|
||||
|
||||
무엇을 여기서 검증하지 않나
|
||||
|
||||
controller에서는 다음을 검증하지 않는다.
|
||||
|
||||
- DB 조회가 필요한 business rule
|
||||
- 인증 이후 사용자 상태 기반 정책
|
||||
- aggregate 간 일관성
|
||||
- 도메인 불변식의 최종 보장
|
||||
- 외부 시스템 현재 상태에 의존하는 검증
|
||||
|
||||
이런 검증은 application 또는 domain으로 올린다.
|
||||
|
||||
controller 규칙
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- @RequestBody DTO에는 Bean Validation을 적극 사용한다.
|
||||
- request param / path variable / request header의 단순 제약은 메서드 파라미터에 직접 건다.
|
||||
- controller는 validation 실패를 ApiResult 규약으로 변환만 하고, 검증 정책 자체를 많이 품지 않는다.
|
||||
- request DTO는 transport schema를 표현하는 객체이지 domain object가 아니다.
|
||||
|
||||
### 4.2 Application 레이어
|
||||
|
||||
무엇을 여기서 검증하나
|
||||
|
||||
application service / use case에서는 다음을 검증한다.
|
||||
|
||||
- use case 실행 전제조건
|
||||
- 여러 입력 조합에 대한 정책 검증
|
||||
- repository 조회나 외부 상태 조회가 필요한 검증
|
||||
- “이 요청을 지금 수행해도 되는가”에 대한 오케스트레이션 수준 판단
|
||||
|
||||
Spring은 validation이 웹 계층에만 속하지 않는다고 설명하므로, 이런 종류의 검증을 application 계층으로 올리는 것은 Spring 철학과도 맞다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- application validation은 보통 명시적 코드로 작성한다.
|
||||
- 복잡한 business rule을 Bean Validation 애노테이션으로 숨기지 않는다.
|
||||
- application validation 실패는 domain/application 예외로 표현하고, web layer는 이를 ApiResult로 번역한다.
|
||||
|
||||
@Validated on service 사용 기준
|
||||
|
||||
Spring은 Bean Validation의 method validation을 MethodValidationPostProcessor와 @Validated를 통해 Spring bean에 적용할 수 있으며, 이는 AOP proxy에 의존한다고 설명한다. 따라서 proxy 한계가 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- service의 @Validated는 재사용 가능한 bean 경계 검증에 한해 제한적으로 허용한다.
|
||||
- self-invocation, proxy bypass, 내부 private method 호출에 기대지 않는다.
|
||||
- 핵심 business rule enforcement를 service method validation 하나에만 맡기지 않는다.
|
||||
- 단순 null/size/precondition 보조 수단으로는 쓸 수 있지만, 복잡한 도메인 정책의 주 수단으로는 쓰지 않는다.
|
||||
|
||||
### 4.3 Domain 레이어
|
||||
|
||||
무엇을 여기서 검증하나
|
||||
|
||||
domain에서는 다음을 보장한다.
|
||||
|
||||
- value object 생성 조건
|
||||
- entity/aggregate invariant
|
||||
- 도메인 행위 수행 가능 조건
|
||||
- “이 상태의 도메인 객체가 존재해도 되는가”에 대한 규칙
|
||||
|
||||
이 항목은 프로젝트 아키텍처 규칙이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- domain invariant는 생성자, factory method, 도메인 메서드 안에서 보장한다.
|
||||
- “controller에서 이미 검증했으니 domain에서는 생략”을 금지한다.
|
||||
- domain rule 실패는 명시적 domain exception 또는 명시적 실패 모델로 표현한다.
|
||||
- domain 객체에 web concern(BindingResult, @RequestBody, @ModelAttribute)를 들이지 않는다.
|
||||
|
||||
Bean Validation 애노테이션 사용 기준
|
||||
|
||||
Spring은 Bean Validation이 domain model 속성에도 선언될 수 있다고 설명하지만, 이 프로젝트에서는 domain의 핵심 규칙을 애노테이션만으로 표현하는 방식을 기본값으로 두지 않는다. Bean Validation은 보조 표현일 수 있으나, 최종 규칙 enforcement는 도메인 코드가 담당한다. 이는 Official + Practice + Project Recommendation 이다.
|
||||
|
||||
### 4.4 Infrastructure 레이어
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API client, messaging adapter 등에서는 외부 계약에 필요한 검증을 할 수 있다.
|
||||
- 다만 infrastructure validation은 외부 포맷/프로토콜/계약 확인용이지, 핵심 business rule의 본진이 아니다.
|
||||
- 외부 요청 DTO와 내부 domain object 사이의 매핑 전에 필요한 최소 검증은 허용한다.
|
||||
- domain 규칙을 infrastructure adapter에 중복 구현하지 않는다.
|
||||
|
||||
## 5. Bean Validation / Spring Validator 사용 규칙
|
||||
|
||||
### 5.1 @Valid / @Validated in controller
|
||||
|
||||
Spring MVC는 @Valid / @Validated로 command object 검증을 수행할 수 있고, method parameter constraint가 있으면 method validation이 개별 parameter validation을 대체한다. 또한 @Valid 자체는 constraint annotation이 아니며, @NotNull 같은 constraint와 함께 있을 때 method validation으로 이어질 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO 내부 필드 검증에는 @Valid
|
||||
- validation group이 실제로 필요한 경우에만 @Validated
|
||||
- scalar parameter 제약은 파라미터에 직접 constraint 부여
|
||||
- controller 클래스 레벨 @Validated는 기본 금지
|
||||
|
||||
### 5.2 controller 클래스 레벨 @Validated 금지
|
||||
|
||||
Spring Framework 6.1+에서 MVC built-in method validation을 제대로 쓰려면 controller 클래스 레벨 @Validated를 제거해야 한다고 공식 문서가 명시한다. 클래스 레벨 @Validated를 두면 AOP proxy 방식이 적용된다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller 클래스에는 @Validated를 붙이지 않는다.
|
||||
- 필요한 경우 메서드 파라미터에 직접 constraint를 선언해 MVC built-in method validation을 사용한다.
|
||||
|
||||
### 5.3 Spring Validator와 @InitBinder
|
||||
|
||||
Spring은 global validator를 MVC config로, local validator를 @InitBinder를 통해 controller 또는 @ControllerAdvice에 등록할 수 있다고 설명한다. @InitBinder는 binder 초기화, 변환, 포맷팅, controller-local customization에 쓰인다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Bean Validation으로 충분하면 custom Spring Validator를 추가하지 않는다.
|
||||
- 특정 web input에만 필요한 로컬 검증은 @InitBinder + custom Validator를 검토할 수 있다.
|
||||
- @InitBinder는 web binding 전용 커스터마이징 지점으로 사용한다.
|
||||
- application/domain business rule을 @InitBinder validator에 넣지 않는다.
|
||||
|
||||
### 5.4 BindingResult 사용 기준
|
||||
|
||||
Spring MVC는 Errors / BindingResult를 method parameter 바로 뒤에 두면 일부 validation error를 controller 안에서 직접 다룰 수 있다고 설명한다. 그러나 다른 파라미터에 validation error가 있으면 HandlerMethodValidationException이 발생할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 REST API에서는 BindingResult를 광범위하게 사용하지 않는다.
|
||||
- 기본 경로는 예외 발생 → @RestControllerAdvice에서 ApiResult 변환이다.
|
||||
- HTML form 처리처럼 controller가 오류를 직접 조합해야 하는 경우에만 제한적으로 사용한다.
|
||||
|
||||
## 6. 이 프로젝트의 기본 배치
|
||||
|
||||
이 프로젝트의 기본 배치는 다음과 같다.
|
||||
|
||||
presentation:
|
||||
|
||||
- request DTO 구조 검증
|
||||
- request param/path/header 기본 제약
|
||||
- transport-level parsing/format validation
|
||||
|
||||
application:
|
||||
|
||||
- use case 전제조건
|
||||
- 조회/상태 의존 정책
|
||||
- 여러 입력의 조합 규칙
|
||||
|
||||
domain:
|
||||
|
||||
- value object/entity/aggregate invariant
|
||||
- 생성/변경 가능 조건
|
||||
- 핵심 business rule
|
||||
|
||||
infrastructure:
|
||||
|
||||
- 외부 시스템 계약 검증
|
||||
- protocol/adapter 수준 포맷 검증
|
||||
|
||||
## 7. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- filter / interceptor / resolver / advice 에 business validation 작성
|
||||
- entity를 @RequestBody / @ModelAttribute로 직접 바인딩
|
||||
- controller validation만 믿고 domain invariant 생략
|
||||
- service method validation proxy에 business rule enforcement를 전부 위임
|
||||
- ApiResult 에러 메시지 생성을 validator 안에서 직접 수행
|
||||
- validation group을 명확한 이유 없이 남발
|
||||
- 단순 request DTO 검증까지 repository 조회 기반 custom validator로 만드는 것
|
||||
|
||||
## 8. 체크리스트
|
||||
|
||||
다음 질문으로 위치를 결정한다.
|
||||
|
||||
- HTTP 입력 형식, nullability, 길이, 범위, 단순 필드 검증인가? presentation
|
||||
- 여러 입력 조합, 현재 상태, 조회 결과에 따라 use case 수행 가능 여부를 판단하는가? application
|
||||
- 이 객체가 존재하거나 이 행위를 수행할 수 있는지의 핵심 규칙인가? domain
|
||||
- 외부 API 계약이나 adapter 포맷 문제인가? infrastructure
|
||||
- 특정 request binding 방식, formatter, web-only validator가 필요한가? @InitBinder / local validator
|
||||
@@ -0,0 +1,164 @@
|
||||
# Fixture / Factory 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 테스트 코드에서 사용하는 fixture와 factory를 어떤 기준으로 만들고 사용할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 테스트 데이터 준비를 중복 없이 재사용 가능하게 만든다
|
||||
- fixture와 factory의 역할을 분리해 테스트 가독성을 높인다
|
||||
- mutable shared state로 인한 테스트 간 간섭을 줄인다
|
||||
- repository test, service test, `@SpringBootTest` 통합 테스트에서 테스트 데이터 준비 방식을 일관되게 만든다
|
||||
|
||||
JUnit Jupiter는 기본적으로 테스트 메서드마다 새로운 테스트 클래스 인스턴스를 만들어 테스트 간 상태 간섭을 줄이도록 설계되어 있고, Spring TestContext Framework는 테스트 인스턴스에 의존성을 주입해 fixture를 구성할 수 있게 한다. Spring Boot는 JPA 테스트에서 `TestEntityManager`를 보조 도구로 제공한다. 이 문서는 그런 공식 기능 위에, 프로젝트 차원의 fixture/factory 설계 규칙을 얹는 문서다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: JUnit / Spring Framework / Spring Boot 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 테스트 lifecycle, test fixture DI, JPA test helper 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
중요하게, fixture / factory라는 용어 자체를 JUnit이나 Spring이 프로젝트 표준으로 정의해 주지는 않는다. 공식 문서는 테스트 인스턴스 lifecycle, 테스트 fixture에 대한 DI, JPA test helper를 제공할 뿐이고, 이 문서의 fixture/factory 구분은 그 위에 얹는 실무적 설계 규칙이다. 또한 이 문서에서 말하는 factory는 JUnit의 동적 테스트용 `@TestFactory`가 아니라, 테스트 데이터 생성 helper/factory를 뜻한다. JUnit의 `@TestFactory`는 런타임에 동적 테스트를 생성하는 별도의 기능이다.
|
||||
|
||||
## 3. 용어 정의
|
||||
|
||||
### 3.1 fixture
|
||||
|
||||
이 문서에서 fixture는 테스트가 읽기 쉬운 형태로 준비된 데이터/상태를 뜻한다. 예를 들어 “활성 사용자”, “삭제된 사용자”, “주문이 2개 있는 고객”처럼 시나리오 의미가 드러나는 준비물이 fixture다. 이 개념은 공식 프레임워크 용어라기보다 테스트 설계 용어이지만, JUnit의 per-method lifecycle과 Spring의 test fixture DI는 이런 준비물을 테스트마다 독립적으로 구성하는 데 맞춰져 있다.
|
||||
|
||||
### 3.2 factory
|
||||
|
||||
이 문서에서 factory는 새 테스트 데이터를 생성하는 helper를 뜻한다. fixture가 “이 테스트에서 필요한 상태 이름”에 가깝다면, factory는 “그 상태를 만들 수 있는 생성 도구”에 가깝다. 따라서 factory는 보통 매 호출마다 새 객체를 반환하고, fixture는 그 factory를 사용해 특정 시나리오를 설명하는 더 얇은 레이어가 된다. 이 구분은 공식 문서의 직접 규정이 아니라, JUnit의 테스트 격리 모델과 Spring 테스트 지원 위에 얹는 프로젝트 권장안이다.
|
||||
|
||||
## 4. 기본 원칙
|
||||
|
||||
### 4.1 fixture와 factory는 테스트 지원 코드이지, 테스트 대상이 아니다
|
||||
|
||||
fixture와 factory의 목적은 테스트를 짧게 만드는 것이 아니라 의도를 더 잘 드러내게 하는 것이다. Spring 테스트 문서는 DI가 테스트와 통합 테스트를 더 쉽게 만든다고 설명하지만, 그 목적은 wiring 편의이지 테스트 관심사를 숨기는 것이 아니다. 프로젝트에서는 fixture/factory가 테스트의 핵심 조건과 기대를 감추지 않아야 한다.
|
||||
|
||||
### 4.2 테스트에서 중요한 값은 숨기지 않고 드러낸다
|
||||
|
||||
fixture/factory는 반복되는 노이즈를 줄이기 위해 존재하지만, 테스트 결과를 바꾸는 핵심 입력까지 숨기면 테스트가 읽기 어려워진다. 따라서 프로젝트에서는 “이 테스트가 왜 통과/실패해야 하는가”를 결정하는 값은 테스트 본문에 남기고, 나머지 반복 필드만 fixture/factory가 채우게 한다. 이 규칙은 JUnit이 기본적으로 테스트 메서드 격리와 명확한 lifecycle을 제공한다는 점 위에 얹는 실무 best practice다.
|
||||
|
||||
### 4.3 기본값은 유효한 객체여야 한다
|
||||
|
||||
factory가 만드는 기본 객체는 특별한 목적이 없는 한 도메인상 유효한 상태여야 한다. invalid 상태가 필요하면 `invalidEmailUser()`, `deletedUser()`처럼 의도가 드러나는 별도 fixture/factory entrypoint로 표현한다. 이렇게 해야 테스트가 “무엇을 깨려는지”를 코드만 봐도 이해할 수 있다. 이 규칙은 공식 API가 직접 강제하는 것은 아니지만, Spring이 단위 테스트와 통합 테스트에서 DI를 통해 테스트 준비를 쉽게 하라고 설명하는 취지와 맞는 프로젝트 권장안이다.
|
||||
|
||||
### 4.4 mutable shared fixture는 기본 금지다
|
||||
|
||||
JUnit Jupiter의 기본 lifecycle은 `PER_METHOD`이며, 각 테스트 메서드 전에 새로운 테스트 인스턴스를 만든다. JUnit은 이것이 mutable test instance state로 인한 예기치 않은 부작용을 줄이기 위한 기본 동작이라고 설명한다. 프로젝트에서도 이 철학을 따라, mutable 엔티티나 변경 가능한 컬렉션을 static/shared fixture로 재사용하는 것을 기본 금지한다. 매 호출마다 새로운 객체를 만들어야 한다.
|
||||
|
||||
### 4.5 PER_CLASS lifecycle은 fixture 최적화 수단이 아니라 예외적 선택지다
|
||||
|
||||
JUnit은 `@TestInstance(PER_CLASS)`를 쓰면 같은 테스트 인스턴스를 재사용하게 되고, 이 경우 instance field 상태를 `@BeforeEach`나 `@AfterEach`에서 직접 정리해야 할 수 있다고 설명한다. 또한 기본 lifecycle을 바꾸는 것은 일관되지 않게 적용되면 fragile build를 만들 수 있다고 경고한다. 프로젝트에서는 fixture/factory 편의 때문에 `PER_CLASS`를 기본값으로 바꾸지 않는다.
|
||||
|
||||
## 5. fixture 기준
|
||||
|
||||
### 5.1 fixture 이름은 시나리오를 설명해야 한다
|
||||
|
||||
fixture 이름은 `user1`, `sampleOrder`, `data()`처럼 모호하면 안 되고, `activeUser()`, `deletedUser()`, `orderPendingPayment()`처럼 현재 테스트가 필요로 하는 상태를 설명해야 한다. fixture는 테스트 서사의 일부이므로, 생성 방식보다 상태 의미가 이름에 드러나야 한다. 이 규칙은 프로젝트 best practice다.
|
||||
|
||||
### 5.2 fixture는 최소한의 차이만 담아야 한다
|
||||
|
||||
fixture가 너무 많은 상태를 한 번에 끌고 오면 테스트마다 어떤 값이 핵심인지 구분하기 어려워진다. 따라서 `activeAdminUserWithExpiredPasswordAndTwoOrders()` 같은 거대한 fixture보다, 기본 유효 factory + 필요한 시나리오 fixture를 조합하는 방식을 선호한다. 이 규칙은 JUnit의 테스트 격리 모델과 Spring 테스트의 fixture DI 취지를 코드 가독성 관점으로 확장한 프로젝트 권장안이다.
|
||||
|
||||
### 5.3 fixture는 공유 상태를 캐시하지 않는다
|
||||
|
||||
fixture helper 내부에서 static mutable 객체를 재사용하거나, 이전 테스트에서 만든 엔티티를 보관했다가 다시 넘기지 않는다. JUnit 기본 lifecycle이 테스트 격리를 지향하는 이유와 어긋나기 때문이다. fixture는 매번 fresh object를 생성하거나, 적어도 immutable 값만 공유해야 한다.
|
||||
|
||||
### 5.4 persisted fixture와 transient fixture를 구분한다
|
||||
|
||||
JPA 테스트에서는 아직 저장되지 않은 객체와, DB에 persist/flush까지 된 객체의 의미가 다르다. Spring Boot의 `TestEntityManager`도 `persist`, `persistAndFlush`, `persistFlushFind` 같은 helper를 제공한다. 프로젝트에서는 `user()` 같은 transient fixture와 `persistedUser()` 같은 persisted fixture를 이름부터 구분하는 것을 권장한다.
|
||||
|
||||
## 6. factory 기준
|
||||
|
||||
### 6.1 factory는 호출할 때마다 새 객체를 반환한다
|
||||
|
||||
factory는 기본적으로 새 인스턴스 생성기여야 한다. JUnit의 per-method isolation 철학상, 테스트가 서로 영향을 주지 않으려면 각 테스트가 독립적인 객체를 받아야 한다. 따라서 factory는 static mutable singleton을 반환하거나 같은 엔티티 인스턴스를 재사용하지 않는다.
|
||||
|
||||
### 6.2 factory는 기본값을 채우고, 테스트는 중요한 차이만 override한다
|
||||
|
||||
좋은 factory는 도메인상 유효한 기본값을 제공하고, 테스트는 필요한 필드만 덮어쓴다. 이렇게 해야 테스트 본문이 짧아지고, 동시에 핵심 입력은 눈에 남는다. 프로젝트에서는 factory가 모든 필드를 강제로 매개변수로 받는 형태보다, sane defaults + override 조합을 선호한다. 이 규칙은 공식 문서의 직접 규정은 아니지만, Spring이 테스트에서 DI로 준비 부담을 줄이게 해 주는 방향과 맞는 실무 기준이다.
|
||||
|
||||
### 6.3 boolean flag 나열형 factory는 기본 금지다
|
||||
|
||||
`user(true, false, true, false)`처럼 boolean 파라미터가 늘어나는 factory는 테스트 의도를 숨긴다. 프로젝트에서는 의도가 다른 상태라면 별도 fixture entrypoint나 명시적 builder step으로 나누는 것을 기본값으로 둔다. 이 규칙은 테스트 가독성과 시나리오 설명력을 높이기 위한 프로젝트 권장안이다.
|
||||
|
||||
### 6.4 invalid factory는 명시적으로 이름 붙인다
|
||||
|
||||
유효한 기본 factory와 달리, 제약 위반이나 validation failure를 검증하는 invalid 객체는 `userWithInvalidEmail()`, `orderWithoutCustomer()`처럼 왜 invalid인지 이름에 드러나야 한다. repository test에서는 이런 invalid fixture를 `flush()`까지 가서 검증하는 경우가 많기 때문에, 이름이 더 중요하다. `TestEntityManager`가 persist/flush/find helper를 제공하는 점도 이런 테스트를 명시적으로 작성하도록 돕는다.
|
||||
|
||||
## 7. Spring 컨텍스트 테스트 기준
|
||||
|
||||
### 7.1 Spring test fixture DI는 허용되지만, fixture 자체를 bean으로 만들지는 않는다
|
||||
|
||||
Spring Framework는 테스트 인스턴스에 field, setter, constructor injection을 사용할 수 있다고 설명하고, test code에서는 field injection도 자연스러울 수 있다고 설명한다. 하지만 프로젝트에서는 fixture/factory 자체를 애플리케이션 빈으로 등록하는 것을 기본값으로 두지 않는다. 대부분의 fixture/factory는 `src/test` 안의 일반 테스트 지원 코드로 충분하다. Spring bean으로 올리는 것은 repository, clock, encoder처럼 실제 인프라 의존이 있는 경우에만 제한적으로 허용한다.
|
||||
|
||||
### 7.2 Spring 컨텍스트를 쓰는 테스트에서도 fixture/factory는 테스트 소스에 둔다
|
||||
|
||||
test fixture DI는 테스트 클래스에 이미 만들어진 bean을 주입하는 수단이지, 테스트 데이터를 애플리케이션 production bean처럼 관리하라는 뜻은 아니다. 프로젝트에서는 fixture/factory를 production source set에 두지 않고, 기본적으로 `src/test/java` 또는 테스트 전용 support 패키지에 둔다. 이는 Spring test fixture DI 공식 기능 위에 얹는 프로젝트 경계 규칙이다.
|
||||
|
||||
## 8. JPA / repository test 기준
|
||||
|
||||
### 8.1 JPA fixture는 영속성 상태를 의식해야 한다
|
||||
|
||||
JPA 테스트에서 객체가 새 객체인지, managed 상태인지, DB에 flush되었는지에 따라 의미가 달라진다. `TestEntityManager`는 바로 이런 테스트를 위해 `persist`, `flush`, `find`, `persistFlushFind` 같은 helper를 제공한다. 프로젝트에서는 repository test용 fixture/factory가 이 차이를 무시하지 않도록, `newUser()`, `persistedUser()`, `persistedUserAndClear()` 같은 식으로 상태를 구분해 제공하는 것을 권장한다.
|
||||
|
||||
### 8.2 DB round-trip 의미가 중요한 테스트에서는 flush/clear를 factory가 완전히 숨기지 않는다
|
||||
|
||||
fixture/factory가 너무 많은 것을 숨기면 repository test에서 중요한 `flush()`/`clear()` 타이밍이 보이지 않게 된다. JPA 테스트의 핵심은 종종 “실제 DB와 동기화한 뒤 다시 읽었을 때 무엇이 보이는가”이므로, persisted helper를 제공하더라도 테스트 본문에서 flush/clear가 왜 필요한지 설명 가능해야 한다. `TestEntityManager`가 제공하는 helper는 보조 도구이지, 테스트 의미를 가리는 추상화가 되어서는 안 된다.
|
||||
|
||||
## 9. 프로젝트 권장안
|
||||
|
||||
### 9.1 기본 구조
|
||||
|
||||
프로젝트의 기본 권장 구조는 다음과 같다.
|
||||
|
||||
- fixture: 시나리오 이름이 드러나는 얇은 helper
|
||||
- factory: 기본 유효 객체를 생성하는 재사용 도구
|
||||
- persisted factory: repository / `TestEntityManager`를 써서 DB 상태까지 준비하는 helper
|
||||
- 테스트 본문: 핵심 override와 assertion을 직접 드러냄
|
||||
|
||||
이 구조는 JUnit의 per-method 격리, Spring test fixture DI, Spring Boot `TestEntityManager` 역할을 함께 고려한 프로젝트 표준이다.
|
||||
|
||||
### 9.2 파일/패키지 권장안
|
||||
|
||||
프로젝트에서는 fixture/factory를 기본적으로 테스트 소스에 두고, 필요하면 다음처럼 나눈다.
|
||||
|
||||
- `...testsupport.fixture`
|
||||
- `...testsupport.factory`
|
||||
- `...testsupport.builder`
|
||||
- `...testsupport.persisted`
|
||||
|
||||
이는 공식 프레임워크 규칙은 아니지만, 테스트 지원 코드를 production 코드와 분리하고 책임을 드러내기 위한 프로젝트 권장안이다.
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- mutable entity를 static/shared fixture로 재사용하는 것
|
||||
- fixture/factory가 테스트의 핵심 입력을 숨기는 것
|
||||
- boolean 나열형 factory로 상태 의미를 감추는 것
|
||||
- invalid 상태를 모호한 이름의 기본 fixture로 섞어 두는 것
|
||||
- persisted/transient 상태를 이름 없이 섞는 것
|
||||
- repository test에서 flush/clear 의미를 factory가 완전히 감춰 버리는 것
|
||||
- fixture/factory를 production source set에 두는 것
|
||||
- fixture/factory를 애플리케이션 bean으로 무분별하게 등록하는 것
|
||||
- `PER_CLASS` lifecycle을 fixture 편의 때문에 기본값처럼 사용하는 것
|
||||
|
||||
이 금지 규칙은 JUnit의 test instance lifecycle, Spring의 test fixture DI, Spring Boot의 JPA test helper 역할을 실무 규칙으로 압축한 것이다.
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- fixture 이름이 시나리오 의미를 설명하는가?
|
||||
- factory는 매번 새 객체를 반환하는가?
|
||||
- 기본 factory가 유효한 객체를 만드는가?
|
||||
- invalid 상태는 별도 이름으로 드러나는가?
|
||||
- persisted fixture와 transient fixture가 구분되는가?
|
||||
- repository test에서 DB round-trip 의미가 중요한 지점이 테스트에 드러나는가?
|
||||
- fixture/factory가 테스트 핵심 입력을 과하게 숨기지 않는가?
|
||||
- 테스트 지원 코드가 production source가 아니라 test source에 있는가?
|
||||
- `PER_CLASS`나 shared mutable state에 의존하지 않는가?
|
||||
@@ -0,0 +1,200 @@
|
||||
# Mock 사용 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 테스트에서 mock, spy, Spring 컨텍스트 bean override mock을 언제 사용하고 언제 사용하지 말아야 하는지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- mock을 협력 객체 경계에만 사용하도록 제한한다
|
||||
- 단위 테스트용 Mockito mock과 Spring 컨텍스트용 bean override mock을 구분한다
|
||||
- spy, static mock, 과도한 interaction verification 같은 취약한 패턴을 줄인다
|
||||
- 현재 Spring 기준에 맞게 `@MockBean`/`@SpyBean` 대신 `@MockitoBean`/`@MockitoSpyBean` 사용 원칙을 정한다
|
||||
|
||||
Spring Boot는 현재 Spring Framework의 `@MockitoBean`과 `@MockitoSpyBean`을 Spring 테스트 컨텍스트 안의 bean override 수단으로 안내하고 있고, Spring Boot의 기존 `@MockBean` / `@SpyBean`은 3.4.0부터 deprecated 되었으며 4.0.0에서 제거되었다고 설명한다. 본 프로젝트는 Spring Boot 4.0.3을 사용하므로 해당 어노테이션은 더 이상 컴파일되지 않는다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot / Mockito 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 Spring Framework의 `@MockitoBean` / `@MockitoSpyBean`, Spring Boot의 테스트 문서와 deprecation API, Mockito의 `MockitoExtension`, strict stubbing, spy, `verifyNoMoreInteractions()` 관련 문서를 기준으로 작성한다. Mockito는 `MockitoExtension`이 mocks를 초기화하고 strict stubbings를 처리한다고 설명하고, `STRICT_STUBS`를 highly recommended라고 설명한다. 또한 spy는 carefully and occasionally 사용해야 한다고 경고하고, `verifyNoMoreInteractions()`를 모든 테스트마다 쓰는 것은 권장하지 않는다고 설명한다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 mock은 “테스트하고 싶은 대상”이 아니라 “대상이 의존하는 외부 협력자”에만 사용한다
|
||||
|
||||
mock은 테스트 대상 자체를 대신하는 도구가 아니라, 대상이 호출하는 다른 경계를 제어하기 위한 도구다. Mockito가 제공하는 mock/spy/verification 도구는 협력 객체를 대체하거나 관찰하는 데 목적이 있고, Spring의 `@MockitoBean`도 `ApplicationContext` 안의 bean을 override 하는 기능으로 설명된다. 프로젝트에서는 mock을 “내가 검증하려는 클래스”가 아니라 “내가 검증하려는 클래스가 호출하는 외부 의존성”에만 사용한다.
|
||||
|
||||
### 3.2 mock은 단순하게, 적게 사용한다
|
||||
|
||||
Mockito는 non-standard mock settings는 too often 쓰지 말라고 설명하고, 테스트가 너무 많은 mock에 의존하면 코드를 단순하게 리팩터링하는 편이 낫다고 시사한다. 프로젝트에서도 mock 개수가 많아질수록 테스트 대상이 너무 많은 책임을 가진 신호로 본다. 기본 원칙은 적은 수의 단순한 mock이다.
|
||||
|
||||
### 3.3 strict stubbing을 기본값으로 본다
|
||||
|
||||
Mockito는 strict stubbing이 cleaner tests, reduced duplication, improved debuggability를 주며 `STRICT_STUBS`를 highly recommended라고 설명한다. 또한 `MockitoExtension`은 mocks 초기화와 strict stubbings 처리를 담당한다고 설명한다. 프로젝트 기본값은 “쓰이지 않는 stub을 허용하는 느슨한 테스트”가 아니라 strict stubbing 기준의 테스트다.
|
||||
|
||||
## 4. 언제 mock을 사용하는가
|
||||
|
||||
### 4.1 순수 단위 테스트에서 외부 협력자를 대체할 때 사용한다
|
||||
|
||||
DB, 메시지 브로커, 외부 API client, 메일 발송기, 파일 저장기처럼 테스트 대상이 호출하는 외부 협력자를 실제로 띄우고 싶지 않을 때 mock이 적합하다. 이때는 Spring 컨텍스트 없이 `MockitoExtension`과 `@Mock`을 사용하는 순수 단위 테스트가 기본이다. Mockito는 `MockitoExtension`이 JUnit Jupiter용 확장이라고 설명한다.
|
||||
|
||||
### 4.2 interaction 자체가 의미인 경계에서 사용한다
|
||||
|
||||
어떤 메서드가 “무엇을 반환했는가”보다 “외부 협력자에게 어떤 호출을 했는가”가 의미인 경우가 있다. 예를 들어 이벤트 발행, 알림 전송, 외부 client 호출, retry 없이 1회만 위임해야 하는 adapter 경계가 그렇다. 이런 경우 mock verification이 적합하다. 다만 Mockito도 `verifyNoMoreInteractions()`를 모든 테스트마다 쓰는 것은 권장하지 않는다고 하므로, 프로젝트에서는 의미 있는 interaction만 검증한다.
|
||||
|
||||
### 4.3 Spring 컨텍스트 테스트에서는 bean override가 정말 필요할 때만 사용한다
|
||||
|
||||
`@MockitoBean`과 `@MockitoSpyBean`은 테스트의 `ApplicationContext` 안에서 bean을 mock/spy로 override하는 기능이다. 따라서 `@SpringBootTest`, slice test 같은 컨텍스트 테스트에서 특정 bean만 대체해야 할 때 적합하다. 하지만 이것은 “컨텍스트를 띄운 상태”를 전제로 하므로, 순수 단위 테스트에서 기본값이 되어서는 안 된다.
|
||||
|
||||
## 5. 언제 mock을 사용하지 않는가
|
||||
|
||||
### 5.1 엔티티, 값 객체, DTO에는 기본적으로 mock을 쓰지 않는다
|
||||
|
||||
엔티티, 값 객체, DTO는 테스트 대상 도메인 모델이므로 실제 객체를 만들어 쓰는 편이 더 자연스럽다. Mockito의 partial mock/spy 관련 문서도 partial mock이 대체로 설계 냄새라고 설명한다. 프로젝트에서는 도메인 모델을 mock으로 대체하지 않고, 실제 fixture/factory로 만든다.
|
||||
|
||||
### 5.2 repository test에서는 repository 자체를 mock하지 않는다
|
||||
|
||||
repository test의 목적은 DB 의미, 매핑, query, 제약을 검증하는 것이다. 이 문맥에서 repository를 mock으로 바꾸면 영속성 경계를 검증하지 못한다. 따라서 repository test는 `@DataJpaTest`와 실제 repository/DB를 사용하고, repository를 mock하는 것은 service 단위 테스트에서만 허용한다. 이 기준은 앞서 정한 repository test 문서와 일관된 프로젝트 규칙이다.
|
||||
|
||||
### 5.3 @SpringBootTest가 필요한 이유가 없는 테스트에서는 Spring bean mock을 쓰지 않는다
|
||||
|
||||
Spring Boot는 slice test와 full application context test를 구분해 제공한다. 따라서 Spring 컨텍스트가 굳이 필요 없는 테스트에서 `@MockitoBean`까지 사용하면 테스트가 과도하게 무거워진다. 프로젝트에서는 Spring bean mock보다 plain Mockito mock을 먼저 검토한다.
|
||||
|
||||
## 6. 단위 테스트에서의 기본 사용 기준
|
||||
|
||||
### 6.1 기본 조합은 MockitoExtension + @Mock
|
||||
|
||||
JUnit Jupiter 기반 Mockito 테스트의 기본 조합은 `@ExtendWith(MockitoExtension.class)`와 `@Mock`이다. Mockito는 `MockitoExtension`이 mocks를 초기화하고 strict stubbings를 처리한다고 설명한다. 프로젝트에서도 순수 단위 테스트의 기본 시작점은 이 조합이다.
|
||||
|
||||
### 6.2 @InjectMocks는 편의 수단으로만 사용한다
|
||||
|
||||
Mockito는 `@InjectMocks`가 constructor injection → setter injection → field injection 순서로 mock을 주입하려고 시도한다고 설명한다. 프로젝트에서는 `@InjectMocks`를 금지하지는 않지만, 테스트 대상 생성이 중요한 테스트에서는 명시적 생성자 호출을 더 선호한다. 그래야 의존성이 바뀌었을 때 테스트 코드에서 더 분명하게 드러난다. `@InjectMocks`는 보일러플레이트를 줄이는 편의 수단으로만 사용한다.
|
||||
|
||||
### 6.3 mock은 필요한 호출만 stub한다
|
||||
|
||||
Mockito는 strict stubbing이 cleaner tests를 만든다고 설명한다. 프로젝트에서는 미래를 대비한 과잉 stub, “혹시 몰라서 미리 깔아 두는 stub”, 실제로 사용되지 않는 stub을 금지한다. 테스트는 현재 시나리오에 필요한 stub만 가져야 한다.
|
||||
|
||||
## 7. verification 기준
|
||||
|
||||
### 7.1 state verification이 충분하면 interaction verification을 남발하지 않는다
|
||||
|
||||
mock verification은 유용하지만, 모든 테스트를 “호출 횟수 검사” 중심으로 만들 필요는 없다. Mockito도 `verifyNoMoreInteractions()`를 모든 테스트마다 쓰는 것은 권장하지 않는다고 설명한다. 프로젝트에서는 반환값/상태 변화로 충분한 테스트라면 그쪽을 우선하고, interaction 검증은 외부 경계 의미가 분명할 때만 사용한다.
|
||||
|
||||
### 7.2 verifyNoMoreInteractions()는 기본 금지다
|
||||
|
||||
Mockito는 `verifyNoMoreInteractions()`를 every test method에 쓰는 것을 권장하지 않는다고 분명히 말한다. 프로젝트에서는 이 메서드를 기본 assertion처럼 붙이지 않는다. 정말로 “추가 호출이 있으면 안 된다”가 비즈니스 의미인 경우에만 제한적으로 사용한다.
|
||||
|
||||
### 7.3 ArgumentCaptor는 verification을 완성하는 용도로만 사용한다
|
||||
|
||||
Mockito는 `ArgumentCaptor`를 verification과 함께 쓰는 것을 권장하고, stubbing에 쓰면 가독성과 defect localization이 나빠질 수 있다고 설명한다. 프로젝트에서도 `ArgumentCaptor`는 호출된 인자의 값을 마지막에 확인하는 용도로만 사용하고, stub 조건을 억지로 만드는 데는 기본적으로 사용하지 않는다.
|
||||
|
||||
## 8. spy 기준
|
||||
|
||||
### 8.1 spy는 기본 선택지가 아니다
|
||||
|
||||
Mockito는 real spy를 carefully and occasionally 사용하라고 설명하고, partial mock은 대체로 code smell이며 새롭고 잘 설계된 코드에는 권하지 않는다고 말한다. 프로젝트에서도 spy는 기본 선택지가 아니라 레거시 코드, 3rd-party 인터페이스, 점진적 리팩터링 같은 예외 상황에서만 사용한다.
|
||||
|
||||
### 8.2 spy stubbing에는 when(...)보다 doReturn(...) 계열을 우선한다
|
||||
|
||||
Mockito는 spy에서 `when(spy.method())`가 실제 메서드를 호출해 부작용을 일으킬 수 있으므로, `doReturn` / `doThrow` / `doNothing` 같은 계열을 고려하라고 설명한다. 프로젝트에서는 spy를 써야 한다면 stub 방식도 `doReturn(...).when(spy)...` 기본값으로 둔다.
|
||||
|
||||
### 8.3 @MockitoSpyBean은 더 신중히 쓴다
|
||||
|
||||
Spring의 `@MockitoSpyBean`은 기존 bean 인스턴스를 감싸는 방식이고, scoped proxy에는 사용할 수 없으며, non-singleton bean을 spy해도 singleton처럼 취급될 수 있다. 따라서 프로젝트에서는 `@MockitoSpyBean`을 넓은 컨텍스트 테스트에서 기본값으로 두지 않는다. 정말로 실제 bean 동작 일부만 감시해야 할 때만 제한적으로 사용한다.
|
||||
|
||||
## 9. Spring 컨텍스트에서의 mock 기준
|
||||
|
||||
### 9.1 새 기준은 @MockitoBean, @MockitoSpyBean
|
||||
|
||||
Spring Framework는 `@MockitoBean`과 `@MockitoSpyBean`을 테스트의 `ApplicationContext` bean override 용도로 제공한다. Spring Boot 문서도 이 어노테이션들을 안내하고 있다. 프로젝트에서는 Spring 컨텍스트 테스트에서 bean override가 필요하면 이 둘을 기본값으로 사용한다.
|
||||
|
||||
### 9.2 @MockBean, @SpyBean은 신규 코드 기본값으로 쓰지 않는다
|
||||
|
||||
Spring Boot API 문서는 `@MockBean`과 관련 Boot Mockito 테스트 지원이 3.4.0부터 deprecated 되었고, 4.0.0에서 제거되었으며 `MockitoBean`/`MockitoSpyBean`으로 대체하라고 설명한다. 본 프로젝트는 Spring Boot 4.0.3 기반이므로 `@MockBean`/`@SpyBean`은 더 이상 사용 가능하지 않다. 신규 테스트는 `@MockitoBean`/`@MockitoSpyBean`을 사용하고, 기존 테스트도 동일하게 이전한다.
|
||||
|
||||
### 9.3 @MockitoBean은 bean override가 필요한 테스트에만 사용한다
|
||||
|
||||
`@MockitoBean`은 bean을 `REPLACE_OR_CREATE` 전략으로 override하고, `enforceOverride = true`를 주면 반드시 기존 bean이 있어야만 교체하도록 바꿀 수 있다. 프로젝트에서는 bean이 없으면 새 mock을 조용히 만들어 버리는 기본 동작이 테스트 의도를 흐릴 수 있으므로, “반드시 기존 bean을 대체해야 한다”는 테스트에는 `enforceOverride = true`를 검토한다.
|
||||
|
||||
### 9.4 같은 bean을 mock하는 테스트는 필드 이름과 qualifier를 일관되게 유지한다
|
||||
|
||||
Spring Framework는 field 이름이나 qualifier가 컨텍스트 분리에 영향을 줄 수 있고, 같은 bean을 여러 테스트에서 mock/spy할 때 필드 이름을 일관되게 유지하면 불필요한 새로운 `ApplicationContext` 생성을 줄일 수 있다고 설명한다. 프로젝트에서는 컨텍스트 캐시를 깨지 않기 위해 같은 bean mock 필드 이름을 가능하면 통일한다.
|
||||
|
||||
### 9.5 non-singleton bean mock/spy는 기본 금지다
|
||||
|
||||
Spring Framework는 `@MockitoBean`으로 non-singleton bean을 mock하면 singleton mock으로 대체되고, `@MockitoSpyBean`으로 non-singleton bean을 spy해도 singleton처럼 취급된다고 설명한다. 프로젝트에서는 prototype/scoped bean override mock을 기본 금지하고, 정말 필요하면 테스트 구조를 다시 설계하는 쪽을 우선한다.
|
||||
|
||||
## 10. static mock 기준
|
||||
|
||||
### 10.1 static mock은 예외적이고 짧게 사용한다
|
||||
|
||||
Mockito는 `MockedStatic`이 활성화된 정적 mock을 나타내며, 그 mock이 생성된 thread에만 영향을 주고, 다른 thread와 동시에 쓰는 것은 안전하지 않다고 설명한다. 프로젝트에서는 static mocking을 레거시나 외부 라이브러리 래핑 같은 예외 상황에서만 허용하고, try-with-resources로 scope를 매우 짧게 제한한다.
|
||||
|
||||
### 10.2 새 코드 설계에서는 static mock 대신 의존성 분리를 우선한다
|
||||
|
||||
static mock은 가능하더라도 thread-scoped이고 테스트를 더 취약하게 만들 수 있다. 프로젝트에서는 새 코드에서 시간, UUID, 외부 유틸 호출 같은 요소를 static method로 직접 부르기보다 bean/port로 분리해서 plain mock으로 대체 가능하게 만드는 것을 기본값으로 둔다. 이 부분은 Mockito의 static mock 제약 위에 얹는 프로젝트 권장안이다.
|
||||
|
||||
## 11. 프로젝트 권장안
|
||||
|
||||
### 11.1 기본 선택 순서
|
||||
|
||||
프로젝트의 기본 선택 순서는 다음과 같다.
|
||||
|
||||
- mock 없이 실제 객체로 테스트 가능하면 그렇게 한다
|
||||
- 외부 협력자만 plain Mockito mock으로 대체한다
|
||||
- Spring 컨텍스트가 정말 필요하면 `@MockitoBean`을 사용한다
|
||||
- spy는 예외적으로만 사용한다
|
||||
- static mock은 마지막 수단으로만 사용한다
|
||||
|
||||
이 순서는 Mockito가 spy/partial mock을 신중히 쓰라고 경고하는 점과, Spring이 bean override mock을 별도 기능으로 제공하는 점을 함께 반영한 프로젝트 규칙이다.
|
||||
|
||||
### 11.2 신규 Spring 테스트는 @MockitoBean 기준으로 작성한다
|
||||
|
||||
Spring Boot 3.4+ 기준에서는 기존 Boot `@MockBean` 계열보다 Spring Framework `@MockitoBean` 계열이 현재 공식 방향이다. 프로젝트의 신규 컨텍스트 테스트는 이 기준을 따른다.
|
||||
|
||||
### 11.3 단위 테스트는 strict, 명시적, 짧게 유지한다
|
||||
|
||||
프로젝트 단위 테스트 기본값은 다음과 같다.
|
||||
|
||||
- `MockitoExtension`
|
||||
- strict stubbing
|
||||
- 최소 stub
|
||||
- 의미 있는 verification만 수행
|
||||
- 가능하면 명시적 생성자 주입
|
||||
- spy / static mock / deep stub 회피
|
||||
|
||||
Mockito는 strict stubbing을 강하게 권장하고, deep stubs와 partial mocks를 regular clean code에서는 드물게만 써야 한다고 설명한다.
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 테스트 대상 자체를 mock하는 것
|
||||
- 엔티티, 값 객체, DTO를 기본적으로 mock하는 것
|
||||
- repository test에서 repository를 mock하는 것
|
||||
- 모든 테스트에 `verifyNoMoreInteractions()`를 습관적으로 붙이는 것
|
||||
- spy를 기본 선택지처럼 사용하는 것
|
||||
- spy에서 `when(spy.method())`로 실제 메서드 부작용을 일으키는 것
|
||||
- Spring 컨텍스트 테스트 신규 코드에 `@MockBean` / `@SpyBean`을 기본값으로 사용하는 것
|
||||
- prototype/scoped bean을 무심코 `@MockitoBean` / `@MockitoSpyBean`으로 override하는 것
|
||||
- static mock을 긴 scope로 유지하거나 병렬 테스트에서 안전하다고 가정하는 것
|
||||
|
||||
이 금지 규칙은 Mockito의 spy/verification/static mock 주의사항과 Spring의 bean override 문서를 실무 규칙으로 압축한 것이다.
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 mock은 테스트 대상이 아니라 외부 협력자를 대체하고 있는가?
|
||||
- plain unit test라면 Spring 컨텍스트 없이 `MockitoExtension`으로 충분한가?
|
||||
- stub은 현재 시나리오에 필요한 것만 있는가?
|
||||
- state verification으로 충분한데 interaction verification을 남발하고 있지 않은가?
|
||||
- `verifyNoMoreInteractions()`가 정말 필요한 의미를 가지는가?
|
||||
- spy를 쓰는 이유가 레거시/부분 대체 같은 예외 상황으로 설명되는가?
|
||||
- Spring 컨텍스트 mock이라면 `@MockitoBean` / `@MockitoSpyBean`을 사용하고 있는가?
|
||||
- 같은 bean mock의 field name/qualifier를 테스트 간 일관되게 유지하고 있는가?
|
||||
- non-singleton bean override나 scoped proxy spy 같은 위험한 경우를 피했는가?
|
||||
- static mock이 정말 마지막 수단인가?
|
||||
@@ -0,0 +1,193 @@
|
||||
# Repository Test 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 Spring Data JPA 기반 repository test를 어떤 범위까지 검증하고, 어떤 방식으로 실행할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- repository test의 관심사를 영속성 경계로 한정한다
|
||||
- 기본 실행 방식으로 `@DataJpaTest`를 사용하고, `@SpringBootTest`와 역할을 분리한다
|
||||
- 영속성 컨텍스트 1차 캐시 때문에 가짜로 통과하는 테스트를 줄인다
|
||||
- PostgreSQL 특화 query나 제약 검증이 필요한 경우, 실제 DB 계열과 맞는 환경에서 검증하게 만든다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 JPA components에만 초점을 맞춘 테스트이며, 기본적으로 `@Entity`와 Spring Data JPA repository를 스캔하고, 일반 `@Component` 빈은 로드하지 않는다고 설명한다. 또한 임베디드 DB가 classpath에 있으면 그것을 자동 구성하고, 기본적으로 transactional하게 실행된다고 설명한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Boot / Spring Data JPA / Spring Framework / Hibernate 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 `@DataJpaTest`, `TestEntityManager`, Spring 테스트 트랜잭션 rollback, Hibernate 1차 캐시/영속성 컨텍스트 문서를 기준으로 작성한다. Spring Data JPA는 repository query method의 transaction 설정 규칙도 별도로 설명하고 있다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 repository test는 repository 경계만 검증한다
|
||||
|
||||
repository test의 관심사는 서비스 유스케이스가 아니라 엔티티 매핑, repository method, JPQL/native query, flush 시점의 제약 위반, DB round-trip 이후의 조회 결과다. `@DataJpaTest`가 JPA components만 좁게 로드하도록 설계된 것도 이 경계를 전제로 한다. 프로젝트에서는 service orchestration, 외부 연동, 보안, MVC, application event 흐름은 repository test에 넣지 않는다.
|
||||
|
||||
### 3.2 repository test의 기본값은 @DataJpaTest
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 Data JPA 테스트에 필요한 auto-configuration만 켜고, 엔티티와 repository만 중심으로 로드한다고 설명한다. 따라서 repository test의 기본 어노테이션은 `@DataJpaTest`다. `@SpringBootTest`는 전체 애플리케이션 조립이 필요한 경우에만 예외적으로 사용하고, repository test의 기본값으로 두지 않는다.
|
||||
|
||||
### 3.3 repository test는 “메서드가 호출된다”가 아니라 “DB 의미가 맞다”를 검증해야 한다
|
||||
|
||||
JPA/Hibernate는 영속성 컨텍스트를 1차 캐시로 유지하며, Hibernate는 이를 generally “repeatable read” persistence context라고 설명한다. 따라서 같은 트랜잭션 안에서 entity를 다시 읽을 때, 실제 DB round-trip 없이 메모리 상태만 보게 될 수 있다. 프로젝트에서는 repository test가 정말 DB 의미를 검증해야 한다면, 필요 지점에서 `flush()`와 `clear()`를 사용해 영속성 컨텍스트 환상을 걷어낸 뒤 검증하는 것을 기본 원칙으로 둔다.
|
||||
|
||||
## 4. 언제 repository test를 작성하는가
|
||||
|
||||
### 4.1 다음은 repository test의 대표 대상이다
|
||||
|
||||
repository test의 대표 대상은 다음과 같다.
|
||||
|
||||
- 엔티티 매핑이 실제 스키마와 맞는지
|
||||
- derived query method가 기대한 조건으로 동작하는지
|
||||
- JPQL/native query가 기대한 결과를 반환하는지
|
||||
- unique/fk/check/not null 같은 DB 제약이 flush 시점에 올바르게 드러나는지
|
||||
- soft delete, partial index 전제 predicate, 정렬/페이지 query가 의도대로 동작하는지
|
||||
|
||||
이들은 모두 JPA repository/EntityManager 경계의 책임이며, `@DataJpaTest`가 좁게 검증하기에 적합한 주제다.
|
||||
|
||||
### 4.2 다음은 repository test의 기본 대상이 아니다
|
||||
|
||||
다음은 repository test의 기본 대상이 아니다.
|
||||
|
||||
- 서비스 유스케이스 전체 흐름
|
||||
- 여러 repository를 묶는 트랜잭션 정책
|
||||
- 보안 컨텍스트와 인증 인가
|
||||
- MVC 요청/응답 변환
|
||||
- 외부 API 연동과 메시징 흐름
|
||||
|
||||
이런 항목은 `@SpringBootTest`, slice test, 혹은 더 상위 통합 테스트의 관심사다. `@DataJpaTest`가 일반 `@Component`를 로드하지 않는다는 점도 이 구분과 맞는다.
|
||||
|
||||
## 5. 기본 실행 방식 기준
|
||||
|
||||
### 5.1 기본 어노테이션은 @DataJpaTest
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 JPA test에 초점을 맞추고, 엔티티와 repository를 스캔하며, 임베디드 DB가 있으면 그것을 자동 구성한다고 설명한다. 또한 기본적으로 transactional하게 실행되고, Spring 테스트 트랜잭션은 종료 시 기본 rollback된다. 프로젝트에서는 repository test 클래스의 기본 시작점을 `@DataJpaTest`로 둔다.
|
||||
|
||||
### 5.2 임베디드 DB 기본값을 무심코 신뢰하지 않는다
|
||||
|
||||
`@DataJpaTest`는 기본적으로 임베디드 DB를 구성할 수 있다. 하지만 repository가 PostgreSQL dialect, native query, JSONB, partial index, window function, locking clause, case sensitivity 차이 같은 DB 고유 동작에 의존한다면, 임베디드 DB만으로는 신뢰도가 부족할 수 있다. Spring Boot는 실제 DB를 선호하면 `@AutoConfigureTestDatabase`로 대체 전략을 제어할 수 있다고 설명한다. 프로젝트에서는 DB 특화 기능이 있는 repository test는 실제 운영 DB 계열로 검증하는 것을 기본 권장안으로 둔다.
|
||||
|
||||
### 5.3 @SpringBootTest는 repository test의 예외 경로다
|
||||
|
||||
repository 자체는 `@DataJpaTest`로 충분한 경우가 대부분이다. 다만 repository가 Boot auto-configuration, custom converter, listener, 여러 인프라 bean과 강하게 얽혀 있고 그 조합 자체를 검증해야 한다면 예외적으로 `@SpringBootTest`를 사용할 수 있다. 하지만 이 경우도 관심사는 여전히 repository 경계여야 하며, 단순히 편하다는 이유로 full context를 올리지는 않는다. 이 기준은 `@DataJpaTest`의 공식 역할 위에 얹는 프로젝트 best practice다.
|
||||
|
||||
## 6. 트랜잭션 기준
|
||||
|
||||
### 6.1 repository test는 기본적으로 rollback된다
|
||||
|
||||
Spring 테스트 문서는 transactional test가 기본적으로 종료 후 rollback된다고 설명한다. Spring Boot 문서도 `@DataJpaTest`가 기본적으로 transactional하게 동작한다고 설명한다. 따라서 repository test는 기본적으로 test isolation을 위해 rollback을 기대할 수 있다.
|
||||
|
||||
### 6.2 commit이 필요한 테스트만 예외적으로 @Commit 또는 @Rollback(false)를 사용한다
|
||||
|
||||
Spring은 `@Rollback(false)` 또는 `@Commit`으로 테스트 트랜잭션을 commit하도록 바꿀 수 있다고 설명한다. 프로젝트에서는 DB trigger, 외부 관측, 별도 세션에서만 보이는 결과, commit 이후 동작을 검증해야 할 때만 예외적으로 commit 테스트를 허용한다. 기본값은 rollback이다.
|
||||
|
||||
### 6.3 repository query method 자체의 transaction 설정을 혼동하지 않는다
|
||||
|
||||
Spring Data JPA는 declared query methods와 default methods에는 transaction configuration이 기본 적용되지 않으며, 필요하면 repository interface에 `@Transactional`을 명시해야 한다고 설명한다. 다만 `@DataJpaTest` 안에서는 테스트 메서드 자체가 트랜잭션 안에서 실행되므로, repository query method의 transaction 유무와 테스트 트랜잭션의 존재를 혼동하면 안 된다. 프로젝트에서는 repository method의 production transaction semantics를 검증하려는 테스트라면, 테스트 메서드 트랜잭션에 가려지지 않는지 먼저 확인한다.
|
||||
|
||||
## 7. 검증 방식 기준
|
||||
|
||||
### 7.1 저장 직후 검증이 아니라 flush() 이후 의미를 본다
|
||||
|
||||
영속성 컨텍스트 안에서 `save()`만 호출하고 바로 필드를 확인하면, 실제 SQL 실행이나 DB 제약 위반이 드러나지 않을 수 있다. Hibernate는 persistence context가 1차 캐시로 동작한다고 설명한다. 따라서 unique/fk/check/not null 위반, DB generated value, trigger, native query 결과를 보려면 `flush()`를 통해 DB와 동기화한 뒤 검증하는 것을 기본으로 한다.
|
||||
|
||||
### 7.2 조회 의미를 검증할 때는 필요하면 clear()까지 사용한다
|
||||
|
||||
같은 persistence context 안에서는 이미 읽은 엔티티가 다시 반환될 수 있다. 따라서 repository test가 정말 DB round-trip 이후의 조회 semantics를 보고 싶다면, `flush()` 후 `clear()`를 통해 1차 캐시를 비우고 다시 조회해야 한다. 프로젝트에서는 “쿼리가 실제로 원하는 row를 다시 읽어오는가”를 검증할 때 `clear()`를 적극적으로 사용한다.
|
||||
|
||||
### 7.3 예외 검증은 가능한 한 flush 시점까지 진행한다
|
||||
|
||||
제약 위반은 보통 DB에 SQL이 나가야 드러난다. 따라서 repository test에서 `assertThatThrownBy(() -> repository.save(entity))`처럼 save 호출만 감싸는 패턴은 충분하지 않을 수 있다. 프로젝트에서는 제약/매핑 오류 테스트를 `save` + `flush` 또는 `persistAndFlush` 수준까지 진행한 뒤 검증하는 것을 기본값으로 둔다. 이 규칙은 JPA flush semantics와 1차 캐시 특성을 근거로 한 best practice다.
|
||||
|
||||
## 8. TestEntityManager 기준
|
||||
|
||||
### 8.1 TestEntityManager는 repository test 보조 도구로 허용한다
|
||||
|
||||
Spring Boot는 `TestEntityManager`를 JPA 테스트용 대안 `EntityManager`로 제공하며, `persist`, `flush`, `find` 같은 testing helper를 제공한다고 설명한다. 프로젝트에서는 fixture seed, flush/clear, ID 확보, 영속성 컨텍스트 제어가 자주 필요할 때 `TestEntityManager` 사용을 허용한다.
|
||||
|
||||
### 8.2 다만 repository test의 중심은 여전히 repository여야 한다
|
||||
|
||||
`TestEntityManager`는 보조 도구이지 테스트 대상이 아니다. repository method를 검증하는 테스트가 `EntityManager` 호출로 가득 차면, 결국 repository를 우회한 테스트가 되기 쉽다. 프로젝트에서는 seed와 보조 검증 정도에만 쓰고, 핵심 assertion은 repository method 결과에 두는 것을 원칙으로 한다. 이 부분은 공식 API 역할 설명 위에 얹는 프로젝트 best practice다.
|
||||
|
||||
## 9. 데이터 준비 기준
|
||||
|
||||
### 9.1 repository test 데이터는 테스트 의도에 필요한 최소한만 준비한다
|
||||
|
||||
repository test는 SQL semantics를 검증하는 테스트이므로, 데이터가 많다고 좋은 것이 아니다. 정렬, 필터, unique, soft delete, join 조건을 드러내는 최소 사례 집합이 가장 좋다. 이 기준은 공식 문서가 직접 규정하는 항목은 아니지만, `@DataJpaTest`의 좁은 목적과 빠른 피드백에 맞는 실무 best practice다.
|
||||
|
||||
### 9.2 테스트마다 필요한 데이터는 독립적으로 준비한다
|
||||
|
||||
기본 rollback이 되더라도, 테스트끼리 순서 의존적인 데이터 준비를 하면 의도가 흐려진다. 프로젝트에서는 각 테스트가 자기 전제 데이터를 스스로 준비하게 하고, 외부 상태나 이전 테스트의 삽입 결과에 의존하지 않게 한다. 이는 Spring 테스트의 기본 rollback 모델과 맞는 프로젝트 규칙이다.
|
||||
|
||||
## 10. 무엇을 검증해야 하는가
|
||||
|
||||
### 10.1 repository test는 아래 항목을 우선 검증한다
|
||||
|
||||
프로젝트 기준으로 repository test가 특히 잘 검증해야 하는 것은 다음과 같다.
|
||||
|
||||
- 엔티티와 테이블/컬럼 매핑
|
||||
- 연관관계 매핑과 cascade로 인해 실제 SQL이 기대대로 나가는지
|
||||
- derived query method의 조건 해석
|
||||
- JPQL/native query의 결과 정확성
|
||||
- soft delete predicate, 정렬, pagination query
|
||||
- partial unique index, FK, check, not null 같은 DB 무결성 위반 드러남
|
||||
- flush 이후 다시 조회했을 때도 상태가 맞는지
|
||||
|
||||
이 항목들은 모두 영속성 경계의 책임이며, repository test에 가장 적합하다.
|
||||
|
||||
### 10.2 반대로 service policy는 repository test에서 검증하지 않는다
|
||||
|
||||
예외 변환, 유스케이스 조합, 외부 연동과 결합된 정책은 repository test에서 검증하지 않는다. 그런 항목은 상위 통합 테스트의 책임이다. repository test가 이 범위를 침범하면 `@DataJpaTest`의 좁은 장점이 사라진다.
|
||||
|
||||
## 11. 프로젝트 권장안
|
||||
|
||||
### 11.1 repository test의 기본 템플릿
|
||||
|
||||
프로젝트의 기본 repository test 템플릿은 다음과 같다.
|
||||
|
||||
- `@DataJpaTest`
|
||||
- 필요 시 실제 DB 계열 사용
|
||||
- fixture 준비
|
||||
- `repository.save(...)`
|
||||
- 필요 시 `flush()`, `clear()`
|
||||
- repository로 다시 조회
|
||||
- DB 의미 기준 assertion
|
||||
|
||||
이 흐름은 Spring Boot의 JPA slice test와 Hibernate 1차 캐시 특성을 함께 고려한 프로젝트 기본값이다.
|
||||
|
||||
### 11.2 PostgreSQL 의존 쿼리는 PostgreSQL로 검증한다
|
||||
|
||||
partial index 전제, JSONB, native query, locking clause, window function, case sensitivity, timestamp handling처럼 PostgreSQL 의미에 의존하는 repository test는 임베디드 대체 DB보다 실제 PostgreSQL 계열 환경에서 검증하는 것을 기본 권장안으로 둔다. Spring Boot가 실제 DB 사용을 위한 `@AutoConfigureTestDatabase` 제어를 제공하는 점과도 맞다.
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- repository test 기본값으로 `@SpringBootTest` 사용
|
||||
- 순수 서비스 정책 테스트를 repository test에 넣는 것
|
||||
- `save()` 직후 영속성 컨텍스트 상태만 보고 DB 검증이 끝났다고 생각하는 것
|
||||
- 제약 위반 테스트를 flush 없이 작성하는 것
|
||||
- 1차 캐시 때문에 다시 읽은 엔티티를 실제 DB 조회 결과로 오해하는 것
|
||||
- PostgreSQL 특화 query를 임베디드 DB만으로 신뢰하는 것
|
||||
- soft delete, 정렬, pagination query를 최소 데이터셋 없이 대충 검증하는 것
|
||||
- bulk data seed나 복잡한 service 조립을 repository test에 끌어오는 것
|
||||
- repository test에서 repository를 거의 쓰지 않고 `EntityManager`만 사용하는 것
|
||||
|
||||
이 금지 규칙은 Spring Boot의 `@DataJpaTest`, Spring 테스트 rollback, Hibernate persistence context 의미를 실무 규칙으로 압축한 것이다.
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 테스트는 repository 경계만 검증하고 있는가?
|
||||
- 기본 어노테이션이 `@DataJpaTest`인가?
|
||||
- 실제 운영 DB 의미가 중요하면 테스트 DB도 그에 맞췄는가?
|
||||
- DB 제약/trigger/generated value를 검증할 때 `flush()`를 사용했는가?
|
||||
- 실제 재조회 semantics를 검증할 때 `clear()`까지 고려했는가?
|
||||
- 테스트 데이터가 최소하지만 충분한가?
|
||||
- repository method 결과를 중심으로 assertion하고 있는가?
|
||||
- service 정책이나 웹 계층 검증이 섞이지 않았는가?
|
||||
@@ -0,0 +1,167 @@
|
||||
# @SpringBootTest 사용 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 Spring Boot 테스트에서 `@SpringBootTest`를 언제 사용하고, 언제 사용하지 말아야 하는지를 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- `@SpringBootTest`를 풀 애플리케이션 컨텍스트 통합 테스트 용도로 한정한다
|
||||
- slice test, repository test, 순수 단위 테스트와 역할을 구분한다
|
||||
- `webEnvironment`별 의미를 명확히 나눈다
|
||||
- 느리고 넓은 테스트를 기본값으로 삼지 않도록 기준을 만든다
|
||||
|
||||
Spring Boot는 `@SpringBootTest`가 `SpringApplication`을 통해 테스트용 `ApplicationContext`를 만들며, Boot 기능이 필요할 때 표준 `@ContextConfiguration`의 대안으로 사용할 수 있다고 설명한다. 또한 더 좁은 범위를 위한 여러 `@…Test` slice annotation도 함께 제공한다고 설명한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Boot / Spring Framework 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
이번 문서는 Spring Boot의 Testing Spring Boot Applications, `@SpringBootTest` API 문서, test slices 문서, Spring Framework의 transaction-in-test 및 context caching 문서를 기준으로 작성한다. `@SpringBootTest`의 컨텍스트 로딩 방식, `webEnvironment`, test slice, rollback 주의사항, 컨텍스트 캐시는 모두 공식 문서로 직접 확인 가능하다.
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 @SpringBootTest는 “전체 애플리케이션 조립이 정말 필요한 테스트”에만 사용한다
|
||||
|
||||
`@SpringBootTest`는 Spring Boot 기능이 적용된 실제 애플리케이션 컨텍스트를 띄운다. 따라서 이 어노테이션의 기본 의미는 “빈 하나만 검증”이 아니라 애플리케이션이 Boot 방식으로 조립되었을 때도 기대한 동작이 나오는지 확인하는 것이다. Boot 문서도 `@SpringBootTest`를 Boot features가 필요할 때 사용하는 어노테이션으로 설명하고, 더 구체적인 영역 테스트에는 slice annotation을 사용하라고 안내한다.
|
||||
|
||||
### 3.2 더 좁은 범위로 충분하면 @SpringBootTest를 쓰지 않는다
|
||||
|
||||
Spring Boot는 `@WebMvcTest`, `@JsonTest`, `@DataJpaTest` 같은 slice 테스트를 제공하고, 이들은 애플리케이션의 특정 부분만 auto-configuration 해 준다고 설명한다. 따라서 controller, JSON 직렬화, JPA repository처럼 대상 범위가 좁다면 먼저 slice test를 검토하고, 정말로 여러 레이어와 Boot auto-configuration을 함께 검증해야 할 때만 `@SpringBootTest`를 올린다.
|
||||
|
||||
### 3.3 @SpringBootTest는 느릴 수 있다는 전제를 갖고 쓴다
|
||||
|
||||
Spring TestContext Framework는 `ApplicationContext`를 static cache에 저장해 같은 컨텍스트는 재사용할 수 있게 해 주지만, 컨텍스트 로딩 자체는 여전히 비용이 크고, 컨텍스트 종류가 많아질수록 전체 테스트 시간이 늘어날 수 있다. 따라서 `@SpringBootTest`는 “편하니까 기본값”이 아니라, 컨텍스트 로딩 비용을 감수할 가치가 있는 테스트에만 써야 한다.
|
||||
|
||||
## 4. 언제 @SpringBootTest를 사용하는가
|
||||
|
||||
### 4.1 Boot auto-configuration과 실제 빈 조합을 함께 검증해야 할 때 사용한다
|
||||
|
||||
`@SpringBootTest`는 `SpringApplication`으로 컨텍스트를 만들고, 외부 설정, 로깅, Boot 기능을 기본적으로 적용한 상태를 재현한다. 따라서 실제 `@ConfigurationProperties`, auto-configuration, component scanning, AOP, security chain, application event wiring, 실제 빈 조합을 함께 검증해야 할 때 적합하다.
|
||||
|
||||
### 4.2 애플리케이션이 정상적으로 기동되는지 확인하는 smoke test에 사용한다
|
||||
|
||||
`@SpringBootTest`는 별도 설정이 없으면 `@SpringBootConfiguration`을 자동으로 찾고, Boot 방식으로 컨텍스트를 로딩한다. 따라서 `contextLoads()` 같은 smoke test는 `@SpringBootTest`의 대표적인 사용처다. 이는 “서비스가 실제 배포 구성을 기준으로 부팅 가능한가”를 빠르게 확인하는 최소 통합 테스트다.
|
||||
|
||||
### 4.3 여러 레이어를 한 번에 검증하는 use case 통합 테스트에 사용한다
|
||||
|
||||
컨트롤러-서비스-리포지토리-트랜잭션-AOP-설정 바인딩이 함께 맞물린 동작을 검증해야 한다면 slice test만으로는 부족할 수 있다. 이런 경우 `@SpringBootTest`가 적합하다. 다만 이때도 “한 서비스 메서드 통합”, “보안 포함 MVC 경로 통합”처럼 왜 전체 컨텍스트가 필요한지가 분명해야 한다. 이 해석은 Boot 문서의 full application context 성격과 slice 구분을 바탕으로 한 best practice다.
|
||||
|
||||
## 5. 언제 @SpringBootTest를 사용하지 않는가
|
||||
|
||||
### 5.1 순수 단위 테스트에는 사용하지 않는다
|
||||
|
||||
빈 하나의 로직만 검증하고 Spring 컨테이너가 필요 없다면 `@SpringBootTest`는 과도하다. Spring 문서는 테스트 지원이 통합 테스트에 강점을 가지지만, IoC 덕분에 단위 테스트도 쉽게 할 수 있다고 설명한다. 프로젝트에서는 순수 계산, 도메인 로직, 단일 서비스의 협력 객체 스텁/페이크 테스트에 `@SpringBootTest`를 기본 금지한다.
|
||||
|
||||
### 5.2 repository 전용 테스트에는 기본적으로 사용하지 않는다
|
||||
|
||||
Spring Boot는 `@DataJpaTest` 같은 데이터 접근 slice를 제공하고, JPA 테스트는 엔티티와 repository만 좁게 로딩하도록 설계되어 있다. 따라서 repository 전용 검증에 `@SpringBootTest`를 기본값으로 두면 범위가 과도하다. repository test 기준은 다음 문서에서 따로 상세히 다루지만, 이 문서 수준에서도 repository만 보려는 테스트에 full context는 기본 금지가 맞다.
|
||||
|
||||
### 5.3 MVC 계층만 검증하는 테스트에는 기본적으로 사용하지 않는다
|
||||
|
||||
Spring Boot는 Spring MVC controller 테스트에 `@WebMvcTest`를 제공한다고 설명한다. 따라서 controller request mapping, validation, status code, JSON binding, advice 같은 MVC 계층만 보려는 테스트에 `@SpringBootTest`를 기본값으로 쓰지 않는다. 전체 컨텍스트가 필요한 특별한 이유가 있을 때만 예외적으로 사용한다.
|
||||
|
||||
## 6. webEnvironment 기준
|
||||
|
||||
### 6.1 기본값은 목적에 맞는 가장 좁은 webEnvironment
|
||||
|
||||
Spring Boot는 `@SpringBootTest`의 `webEnvironment`로 `MOCK`, `RANDOM_PORT`, `DEFINED_PORT`, `NONE`을 제공한다고 설명한다. 프로젝트 기본 원칙은 가장 좁은 환경을 먼저 고르는 것이다. 웹 서버가 실제로 필요 없으면 서버를 띄우지 않는다.
|
||||
|
||||
### 6.2 NONE: non-web full context 테스트의 기본값
|
||||
|
||||
`NONE`은 `SpringApplication`으로 `ApplicationContext`를 로드하지만 웹 환경은 만들지 않는다. 따라서 웹 서버가 필요 없는 full-context 테스트, 예를 들어 서비스 통합 테스트나 단순 기동 테스트의 기본값으로 가장 적절하다.
|
||||
|
||||
### 6.3 MOCK: 실제 서버 없이 웹 애플리케이션을 통합 검증할 때 사용한다
|
||||
|
||||
`MOCK`은 기본값이며, web `ApplicationContext`를 로드하지만 내장 서버는 시작하지 않는다. Boot 문서는 이 모드가 `@AutoConfigureMockMvc` 또는 `@AutoConfigureWebTestClient`와 함께 mock 기반 웹 테스트에 적합하다고 설명한다. 프로젝트에서는 보안 필터, MVC 설정, Jackson, 예외 처리까지 포함하되 실제 포트는 필요 없는 경우 `MOCK`을 사용한다.
|
||||
|
||||
### 6.4 RANDOM_PORT: 실제 내장 서버와 실제 HTTP 왕복이 필요할 때 사용한다
|
||||
|
||||
`RANDOM_PORT`는 실제 `WebServerApplicationContext`를 만들고, 임의 포트에 내장 서버를 띄운다. 프로젝트에서는 실제 HTTP stack, 필터 체인, 포트 바인딩, serialization, client-server 상호작용 자체를 검증해야 할 때만 사용한다. 자동화 테스트에서는 `DEFINED_PORT`보다 충돌 위험이 적어 일반적으로 더 안전하다. 앞 문장은 Boot가 `RANDOM_PORT`와 `DEFINED_PORT`를 구분해 설명하는 점 위에 얹는 프로젝트 권장안이다.
|
||||
|
||||
### 6.5 DEFINED_PORT: 예외적 상황에서만 사용한다
|
||||
|
||||
`DEFINED_PORT`는 설정 파일 또는 기본 포트 8080으로 실제 서버를 띄운다. Boot 문서는 이 모드를 공식 지원하지만, 프로젝트에서는 고정 포트를 요구하는 외부 연동 테스트처럼 특별한 이유가 있을 때만 사용한다. 일반 테스트 스위트 기본값으로 두기에는 포트 충돌과 환경 의존성이 커질 수 있다. 이 판단은 Boot의 포트 모드 설명 위에 얹는 best practice다.
|
||||
|
||||
## 7. 트랜잭션 기준
|
||||
|
||||
### 7.1 같은 스레드 안에서 실행되는 @Transactional 테스트는 기본적으로 롤백된다
|
||||
|
||||
Spring TestContext Framework는 테스트 메서드에 `@Transactional`이 붙으면 테스트를 트랜잭션 안에서 실행하고, 기본적으로 종료 시 롤백한다고 설명한다. 따라서 same-thread 방식의 `@SpringBootTest`에서는 테스트 데이터 정리에 유용할 수 있다.
|
||||
|
||||
### 7.2 RANDOM_PORT / DEFINED_PORT에서는 테스트 메서드 롤백을 서버 처리까지 기대하지 않는다
|
||||
|
||||
Spring Boot는 `RANDOM_PORT`나 `DEFINED_PORT`에서는 실제 서버와 클라이언트가 별도 스레드에서 실행되므로, 테스트 메서드의 `@Transactional` 롤백이 서버 쪽 트랜잭션에는 적용되지 않는다고 명시한다. 프로젝트에서는 이 모드에서 “테스트 끝나면 DB가 자동 롤백될 것”이라는 기대를 금지한다.
|
||||
|
||||
## 8. slice 테스트와의 관계
|
||||
|
||||
### 8.1 slice가 가능하면 slice를 우선한다
|
||||
|
||||
Spring Boot는 test slices가 애플리케이션의 특정 부분만 테스트하도록 설계되었다고 설명한다. 프로젝트에서는 `@SpringBootTest`보다 좁은 slice가 정확히 요구사항을 만족하면 slice를 우선한다. full context는 필요 비용이 더 크기 때문이다.
|
||||
|
||||
### 8.2 여러 slice를 동시에 섞지 않는다
|
||||
|
||||
Spring Boot는 여러 `@…Test` slice annotation을 한 테스트에 함께 쓰는 것은 지원하지 않는다고 설명한다. 여러 조각이 동시에 필요하면 하나의 slice를 고르고, 다른 기능은 필요한 `@AutoConfigure…`를 수동으로 더하라고 안내한다. 프로젝트에서도 slice를 여러 개 겹치는 구조는 기본 금지다.
|
||||
|
||||
### 8.3 full context가 필요하지만 테스트 편의 기능도 원하면 @AutoConfigure…를 @SpringBootTest와 조합한다
|
||||
|
||||
Spring Boot는 `@AutoConfigure…` 계열 어노테이션을 `@SpringBootTest`와 조합할 수 있다고 설명한다. 따라서 전체 컨텍스트는 유지하되 `MockMvc`, `WebTestClient` 같은 테스트 편의 빈이 필요하면 이 조합을 사용한다.
|
||||
|
||||
## 9. 컨텍스트 캐시 기준
|
||||
|
||||
### 9.1 @SpringBootTest 변형을 최소화해 컨텍스트 캐시를 재사용한다
|
||||
|
||||
Spring TestContext Framework는 `ApplicationContext`를 static cache에 저장해 재사용한다고 설명한다. 따라서 properties, profiles, 임시 설정 클래스, 불필요한 커스텀 조합을 테스트마다 제각각 바꾸면 캐시 재사용이 줄고 전체 테스트가 느려질 수 있다. 프로젝트에서는 비슷한 목적의 `@SpringBootTest`는 같은 컨텍스트 구성을 공유하도록 정리한다.
|
||||
|
||||
## 10. 프로젝트 권장안
|
||||
|
||||
### 10.1 @SpringBootTest는 테스트 피라미드의 상단에 둔다
|
||||
|
||||
프로젝트 기본 전략은 다음과 같다.
|
||||
|
||||
- 순수 로직은 단위 테스트
|
||||
- 기술 경계별 검증은 slice test
|
||||
- 여러 레이어와 Boot 조립을 함께 확인해야 하는 경우에만 `@SpringBootTest`
|
||||
|
||||
이 규칙은 Spring Boot가 slices와 full-context test를 함께 제공하는 설계와 맞는 프로젝트 권장안이다.
|
||||
|
||||
### 10.2 non-web full-context 테스트의 기본값은 webEnvironment = NONE
|
||||
|
||||
실제 서버가 필요 없는데도 기본 `MOCK`이나 real server 모드를 쓰면 테스트 의도가 흐려질 수 있다. 프로젝트에서는 웹이 아닌 full-context 테스트는 `NONE`을 기본값으로 둔다. 이는 Boot가 `NONE`을 공식 지원한다는 점 위에 얹는 프로젝트 규칙이다.
|
||||
|
||||
### 10.3 웹 통합 테스트는 두 갈래로 나눈다
|
||||
|
||||
프로젝트 권장안은 다음과 같다.
|
||||
|
||||
- 실제 포트가 필요 없고 MVC/보안/직렬화 조합만 보면 된다 → `MOCK` + `@AutoConfigureMockMvc`
|
||||
- 실제 서버와 실제 HTTP round-trip이 필요하다 → `RANDOM_PORT`
|
||||
|
||||
이 구분은 Boot의 `webEnvironment` 설명을 실무적으로 정리한 프로젝트 권장안이다.
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 순수 단위 테스트에 `@SpringBootTest` 사용
|
||||
- repository 전용 테스트에 기본적으로 `@SpringBootTest` 사용
|
||||
- MVC 슬라이스로 충분한데 full context를 올리는 것
|
||||
- `RANDOM_PORT`/`DEFINED_PORT` 테스트에서 `@Transactional` 롤백을 서버 처리까지 기대하는 것
|
||||
- 여러 slice annotation을 한 테스트에 동시에 붙이는 것
|
||||
- 목적 없이 properties, profiles, 설정 클래스를 계속 바꿔 컨텍스트 캐시를 깨는 것
|
||||
- full context가 필요한 이유를 설명하지 못하는 `@SpringBootTest` 추가
|
||||
|
||||
이 금지 규칙은 Spring Boot의 full-context testing/slice 문서와 Spring TestContext의 트랜잭션·컨텍스트 캐시 문서를 바탕으로 한 best practice다.
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 테스트는 정말 full application context가 필요한가?
|
||||
- slice test로 줄일 수 없는가?
|
||||
- `webEnvironment`가 목적에 비해 과하지 않은가?
|
||||
- 정렬된 목적에 맞게 `NONE`, `MOCK`, `RANDOM_PORT`, `DEFINED_PORT`를 골랐는가?
|
||||
- `RANDOM_PORT`/`DEFINED_PORT`에서 rollback 기대를 잘못 두고 있지 않은가?
|
||||
- 컨텍스트 구성 변형을 최소화하고 있는가?
|
||||
- 같은 목적의 테스트들이 컨텍스트 캐시를 재사용할 수 있는가?
|
||||
@@ -0,0 +1,232 @@
|
||||
# API Controller 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 Spring MVC 기반 API controller의 책임, 위치, 작성 방식, 금지사항을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- controller를 HTTP boundary 역할에 집중시킨다.
|
||||
- request parsing / validation / authentication context extraction / response shaping의 위치를 명확히 한다.
|
||||
- business rule, transaction, persistence access, external integration이 controller로 새어 들어오지 않게 한다.
|
||||
- API controller가 프로젝트 전체에서 일관된 구조를 가지게 한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 문서의 확장 지점 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 API endpoint는 기본적으로 @RestController를 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 @RestController는 @Controller와 @ResponseBody를 결합한 annotation이며, @RequestMapping 메서드가 기본적으로 response body semantics를 가진다. JSON/HTTP body를 반환하는 API controller의 기본 선택지는 @RestController다. HTML view rendering이 목적일 때만 @Controller를 사용한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- REST API endpoint의 기본값은 @RestController
|
||||
- 서버사이드 템플릿 렌더링 등 view name 반환이 목적일 때만 @Controller
|
||||
- 같은 controller 안에 API 응답과 view 렌더링을 섞지 않는다
|
||||
|
||||
### 3.2 controller는 HTTP boundary translator다
|
||||
|
||||
Spring MVC는 controller 메서드에서 request mapping, request input, exception handling, return value rendering을 담당할 수 있게 해 준다. 그러나 이 프로젝트에서 controller의 핵심 책임은 “HTTP 요청을 애플리케이션 입력으로 번역하고, 애플리케이션 결과를 HTTP 응답으로 번역하는 것”이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 request를 해석한다
|
||||
- 필요한 인증 주체/식별자/입력을 추출한다
|
||||
- application service / use case를 호출한다
|
||||
- 응답 DTO 또는 ApiResult로 응답을 만든다
|
||||
|
||||
그 외의 핵심 business decision은 controller의 기본 책임이 아니다.
|
||||
|
||||
### 3.3 controller는 얇게 유지한다
|
||||
|
||||
이 항목은 Official + Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 가능한 한 짧고 예측 가능해야 한다
|
||||
- HTTP concern 외의 분기와 규칙은 application/domain으로 이동한다
|
||||
- controller 하나가 여러 repository, external client, transaction concern을 직접 조립하지 않는다
|
||||
- “request 파싱 → use case 호출 → response 반환” 흐름이 한눈에 보여야 한다
|
||||
|
||||
## 4. 매핑 표준
|
||||
|
||||
### 4.1 class-level base path + method-level endpoint를 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 @RequestMapping은 class level에서 shared mapping을, method level에서 구체 endpoint mapping을 표현할 수 있다. 또한 대부분의 controller method는 모든 HTTP method를 받는 일반 @RequestMapping보다 @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping 같은 method-specific shortcut을 쓰는 것이 자연스럽다. 같은 element에 여러 @RequestMapping 계열을 함께 두면 첫 번째만 사용되고 warning이 남는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller 클래스에는 resource base path를 둔다
|
||||
- endpoint 메서드에는 HTTP method-specific mapping을 사용한다
|
||||
- endpoint 메서드에 일반 @RequestMapping을 남발하지 않는다
|
||||
- 한 메서드에 중복 mapping annotation을 두지 않는다
|
||||
|
||||
### 4.2 매핑 조건은 필요한 만큼만 사용한다
|
||||
|
||||
Spring은 mapping에 URL, HTTP method, params, headers, media types를 모두 조건으로 줄 수 있다. 하지만 이 프로젝트에서는 매핑 조건을 너무 많이 걸어 endpoint를 읽기 어렵게 만드는 것을 지양한다. 꼭 필요한 경우에만 consumes, produces, params, headers를 사용한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본은 path + HTTP method
|
||||
- content-type이 중요한 endpoint만 consumes
|
||||
- response media type이 실제로 갈리는 endpoint만 produces
|
||||
- params/headers 조건은 routing necessity가 분명할 때만 사용
|
||||
|
||||
### 4.3 endpoint path는 리소스 중심으로 둔다
|
||||
|
||||
이 항목은 주로 Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- path는 동사보다 리소스 중심으로 설계한다
|
||||
- action semantics는 가능한 한 HTTP method로 표현한다
|
||||
- 도메인적으로 특별한 command endpoint가 필요하면 예외적으로 명시적 action path를 사용할 수 있다
|
||||
- controller 이름, class path, method path가 함께 읽혔을 때 자원이 자연스럽게 보이게 한다
|
||||
|
||||
## 5. 메서드 시그니처 표준
|
||||
|
||||
### 5.1 인자는 명시적으로 바인딩한다
|
||||
|
||||
Spring MVC controller method는 매우 다양한 인자를 지원한다. HttpServletRequest, HttpServletResponse, WebRequest, HttpSession, @PathVariable, @RequestParam, @RequestHeader, @ModelAttribute, @RequestBody 등 여러 타입과 annotation을 사용할 수 있다. 그러나 지원된다고 해서 아무 인자나 다 쓰는 방향을 기본값으로 두지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- path 값은 @PathVariable
|
||||
- query 값은 @RequestParam
|
||||
- header 값은 @RequestHeader
|
||||
- JSON body는 @RequestBody
|
||||
- form/query binding object는 @ModelAttribute
|
||||
- 각 입력의 출처가 메서드 시그니처에 드러나야 한다
|
||||
|
||||
### 5.2 request DTO를 명시적으로 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 @ModelAttribute는 request parameters, URI path variables, headers를 model object에 바인딩하며, 보안상 웹 바인딩 전용 객체를 쓰거나 constructor binding only를 고려하고, property binding이 필요하면 allowedFields를 제한하라고 권장한다. 즉, 웹 입력용 객체 설계는 신중해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON body는 전용 request DTO에 받는다
|
||||
- form/query binding도 가능하면 전용 request model로 받는다
|
||||
- entity/domain object를 직접 @RequestBody / @ModelAttribute 대상으로 쓰지 않는다
|
||||
- request DTO는 transport model이지 domain model이 아니다
|
||||
|
||||
### 5.3 Servlet API 의존은 정말 필요할 때만 허용한다
|
||||
|
||||
Spring MVC는 HttpServletRequest, HttpServletResponse, WebRequest 등 직접적인 request/response 접근을 지원한다. 하지만 API controller의 기본 시그니처는 annotation 기반 인자 바인딩으로 충분해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request body, path, query, header는 annotation 기반 인자 바인딩을 우선한다
|
||||
- HttpServletRequest / HttpServletResponse는 다음처럼 정말 필요한 경우에만 사용한다
|
||||
- request attribute 접근
|
||||
- low-level header 처리
|
||||
- cookie 직접 제어
|
||||
- file streaming / low-level response control
|
||||
- 단순 입력 추출 때문에 Servlet API를 들고 오지 않는다
|
||||
|
||||
### 5.4 변환과 포맷팅은 ad-hoc parsing보다 binder/converter를 우선 검토한다
|
||||
|
||||
Spring 공식 문서 기준으로 @InitBinder나 전역 MVC config를 통해 Converter, Formatter, PropertyEditor 등을 등록해 타입 변환과 formatting을 구성할 수 있다. controller 안에서 문자열 파싱 로직을 계속 반복하는 것보다 framework extension point를 쓰는 편이 낫다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 동일한 문자열 → 타입 변환이 반복되면 converter/formatter를 검토한다
|
||||
- web binding 전용 커스터마이징은 @InitBinder 또는 전역 conversion 설정으로 뺀다
|
||||
- controller 본문에 파싱 로직을 반복해서 쓰지 않는다
|
||||
|
||||
## 6. 반환값 표준
|
||||
|
||||
### 6.1 기본 반환은 body 중심이다
|
||||
|
||||
Spring MVC는 @ResponseBody, ResponseEntity, HttpHeaders, ProblemDetail, String view name 등 다양한 반환형을 지원한다. API controller에서는 body 중심 반환이 기본이다. @RestController는 class level @ResponseBody semantics를 제공한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 API 성공 응답은 ApiResult<ResponseDto> 또는 프로젝트 표준 response DTO를 반환한다
|
||||
- view name 반환은 API controller에서 사용하지 않는다
|
||||
- Map<String, Object> 같은 임시 응답은 승인 후보 코드에서 지양한다
|
||||
|
||||
### 6.2 ResponseEntity는 “정말 HTTP 응답을 제어해야 할 때” 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 ResponseEntity<B>는 전체 응답, 즉 HTTP status, headers, body를 함께 지정하는 반환형이다. 따라서 모든 endpoint에서 습관적으로 ResponseEntity를 쓸 필요는 없고, HTTP 응답 제어가 필요한 경우에 의미가 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 성공 200 응답이고 header 제어가 없다면 굳이 ResponseEntity를 강제하지 않는다
|
||||
- 다음 경우에는 ResponseEntity를 사용한다
|
||||
- 201 Created + Location
|
||||
- 204 No Content
|
||||
- header 제어
|
||||
- 캐시/조건부 응답
|
||||
- 파일 다운로드
|
||||
- endpoint별로 status가 의미 있게 달라지는 경우
|
||||
|
||||
### 6.3 응답 body 공통화는 controller보다 advice에서 해결할 수 있다
|
||||
|
||||
Spring의 ResponseBodyAdvice는 @ResponseBody 또는 ResponseEntity controller method의 body가 HttpMessageConverter로 쓰이기 직전에 응답을 커스터마이징할 수 있고, @ControllerAdvice로 등록하면 자동 적용된다. 따라서 전역 ApiResult 래핑이나 공통 필드 보강은 controller 메서드마다 반복하지 않고 advice에 둘 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 응답 envelope 공통화 정책이 있으면 controller 반복보다 advice를 우선 검토한다
|
||||
- 다만 controller가 이미 명시적으로 ApiResult를 반환하는 프로젝트라면 이중 래핑을 피한다
|
||||
|
||||
## 7. 예외, 검증, 인증 경계
|
||||
|
||||
### 7.1 controller는 예외를 직접 처리하는 기본 위치가 아니다
|
||||
|
||||
Spring 공식 문서 기준으로 @ExceptionHandler, @InitBinder, @ModelAttribute는 local controller에도 둘 수 있고, @ControllerAdvice / @RestControllerAdvice로 전역 적용도 가능하다. 전역 @ExceptionHandler는 local handler 뒤에 적용된다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반적인 API 예외 처리는 @RestControllerAdvice에 둔다
|
||||
- controller 메서드 안에서 try-catch로 공통 예외 응답을 반복해서 만들지 않는다
|
||||
- controller local @ExceptionHandler는 endpoint-local 정책이 정말 필요할 때만 사용한다
|
||||
|
||||
### 7.2 controller의 검증 책임은 request boundary까지다
|
||||
|
||||
이 항목은 앞서 정리한 validation 문서와 연결되는 Official + Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 request DTO / scalar input의 구조 검증을 트리거한다
|
||||
- business rule 검증, 상태 조회 기반 검증, 불변식 보장은 application/domain에서 담당한다
|
||||
- controller validation 통과를 domain correctness의 보장으로 간주하지 않는다
|
||||
|
||||
### 7.3 인증 주체 접근은 명시적이고 제한적으로 한다
|
||||
|
||||
Spring MVC는 다양한 method argument를 허용하므로 인증 객체도 여러 방식으로 전달될 수 있다. 이 프로젝트에서는 인증 객체 접근을 controller 시그니처에서 명시적으로 드러내되, security implementation 세부사항이 controller 전체에 번지지 않게 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 인증 주체는 가능한 한 전용 principal / auth object / argument resolver 결과로 받는다
|
||||
- controller가 security framework의 저수준 타입에 과도하게 묶이지 않게 한다
|
||||
- 인증 객체 접근 표준은 이후 Authentication Object Access Standard 문서에서 더 구체화한다
|
||||
|
||||
## 8. 의존성 및 금지 규칙
|
||||
|
||||
이 항목은 주로 Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 repository를 직접 호출하지 않는다
|
||||
- controller는 EntityManager, JDBC template, external client를 직접 조립하지 않는다
|
||||
- controller에 @Transactional을 기본 금지한다
|
||||
- controller는 domain entity를 외부 응답 모델로 직접 반환하지 않는다
|
||||
- controller에서 event 발행, retry, async orchestration을 직접 수행하지 않는다
|
||||
- controller 메서드가 “HTTP boundary 번역” 이상으로 커지기 시작하면 application service / mapper / advisor / resolver 분리를 검토한다
|
||||
|
||||
## 9. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 클래스는 REST API endpoint이므로 @RestController가 자연스러운가?
|
||||
- class-level path와 method-level HTTP mapping이 명확한가?
|
||||
- 입력 출처가 메서드 시그니처에서 드러나는가?
|
||||
- request DTO와 domain/entity가 분리되어 있는가?
|
||||
- controller가 repository/transaction/business rule을 직접 품고 있지 않은가?
|
||||
- 응답 형식이 프로젝트 표준(ApiResult 등)에 맞는가?
|
||||
- 예외 처리와 공통 응답 보강을 advice 쪽으로 밀어냈는가?
|
||||
@@ -0,0 +1,243 @@
|
||||
# API Versioning 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API version을 언제 도입하고, 어떤 위치에 두며, 어떤 경우에 올릴지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- API 변경을 안전하게 진화시킨다
|
||||
- 이미 배포된 클라이언트를 불필요하게 깨뜨리지 않는다
|
||||
- 버전 전략이 endpoint마다 제각각 달라지는 일을 막는다
|
||||
- 버전과 deprecation 정책을 문서화 가능하게 만든다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework, HTTP/REST 관련 공식 문서/가이드에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 제약 위에 일반적인 실무 API 운영 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 API versioning에는 표준 단일 방식이 없다
|
||||
|
||||
Spring 공식 문서는 API version을 지정하는 표준 방법은 없다고 설명하며, 버전을 header, query parameter, media type parameter, URL path 중 어디에서 읽을지 애플리케이션이 정해야 한다고 말한다. Spring MVC의 ApiVersionStrategy도 이 여러 전략을 지원한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- “업계 표준이라 무조건 이 방식”이라는 전제를 두지 않는다
|
||||
- 한 서비스 안에서는 반드시 하나의 기본 전략을 정한다
|
||||
- 예외 전략을 허용하더라도 기준과 이유를 남긴다
|
||||
|
||||
### 3.2 버전은 “breaking change 관리 수단”이다
|
||||
|
||||
Azure REST 가이드도 버저닝의 핵심 요구를 “기존 고객 workload가 깨지지 않아야 하고, 고객이 새 버전을 선택적으로 채택할 수 있어야 한다”는 점으로 설명한다. 즉, 버전은 새 기능 홍보 수단이 아니라 호환성 관리 수단이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- breaking change가 아니면 새 major version을 만들지 않는다
|
||||
- additive change, optional field 추가, 하위 호환 가능한 확장은 기존 major 안에서 처리한다
|
||||
- version은 endpoint 개수 늘리기 수단이 아니다
|
||||
|
||||
### 3.3 버전 전략보다 더 중요한 것은 일관성이다
|
||||
|
||||
Azure는 query parameter 전략을 강하게 권장하고 path versioning을 금지하지만, Stripe는 header 기반 version pinning을 사용한다. 업계의 실제 운영 방식이 서로 다르다는 뜻이다. 따라서 프로젝트에서 더 중요한 것은 “어느 방식이냐”보다 “같은 API 군에서 전략을 섞지 않느냐”다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 API product 안에서 path/header/query versioning을 혼용하지 않는다
|
||||
- version 협상 위치가 달라지면 문서, 테스트, 운영, client SDK가 모두 복잡해진다
|
||||
|
||||
## 4. 이 프로젝트의 기본 전략
|
||||
|
||||
### 4.1 기본값은 path major versioning이다
|
||||
|
||||
프로젝트 기본 전략:
|
||||
|
||||
```text
|
||||
/api/v1/...
|
||||
/api/v2/...
|
||||
```
|
||||
|
||||
형태의 path major versioning을 기본으로 한다.
|
||||
|
||||
이 규칙은 Spring이 path version resolver를 공식 지원한다는 사실 위에, 실무적으로 다음 장점 때문에 선택한 Project Recommendation 이다.
|
||||
|
||||
- URL만 봐도 버전이 드러난다
|
||||
- 로그, 게이트웨이, 캐시, 문서화에서 식별이 쉽다
|
||||
- client가 명시적으로 어떤 major를 호출하는지 드러난다
|
||||
- Spring 7 이전/이후 여부와 관계없이 구현이 단순하다
|
||||
|
||||
Spring은 path, header, query parameter, media type parameter 모두 지원한다. 따라서 path 전략은 framework 차원에서도 무리 없는 선택이다.
|
||||
|
||||
### 4.2 기본 path version은 major만 올린다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- URL에는 기본적으로 v1, v2처럼 major 만 노출한다
|
||||
- v1.1, v1.2, v1.2.3 같은 minor/patch를 path에 올리지 않는다
|
||||
- minor/patch 수준 진화는 같은 major 안에서 하위 호환으로 처리한다
|
||||
|
||||
Spring 7의 native API versioning은 semantic parser를 통해 major/minor/patch까지 다룰 수 있고, 1.2+ 같은 baseline version도 지원한다. 하지만 그것이 곧 공개 URL에 minor/patch를 그대로 드러내야 한다는 뜻은 아니다. 이 프로젝트는 공개 계약 단순성을 위해 path에는 major만 둔다.
|
||||
|
||||
### 4.3 header/query/media type versioning은 예외적으로만 사용한다
|
||||
|
||||
Spring은 header, query parameter, media type parameter도 공식 지원한다. Azure는 query parameter를, Stripe는 header를 대표적으로 사용한다. 그러나 이 프로젝트에서는 이를 기본값이 아닌 예외 전략으로 둔다.
|
||||
|
||||
허용 가능한 예외 예:
|
||||
|
||||
- 하나의 URL을 유지해야 하는 강한 외부 계약이 있을 때
|
||||
- API gateway/product 정책이 이미 header versioning을 강제할 때
|
||||
- 내부 SDK가 header pinning에 맞춰 설계되어 있을 때
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 예외 전략을 도입하면 해당 API 군 전체에서 일관되게 유지한다
|
||||
- path와 header versioning을 같은 리소스에 동시에 섞지 않는다
|
||||
|
||||
## 5. 언제 버전을 올리는가
|
||||
|
||||
### 5.1 major version을 올려야 하는 경우
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 breaking change 로 보고 major version을 올린다.
|
||||
|
||||
- 필수 request field 추가
|
||||
- 기존 field 의미 변경
|
||||
- response field 삭제
|
||||
- response field 타입 변경
|
||||
- status code 의미 변경
|
||||
- error code 계약 변경
|
||||
- 인증 방식/권한 요구의 비호환 변경
|
||||
- pagination/filter/sort 의미의 비호환 변경
|
||||
|
||||
이 항목은 Practice + Project Recommendation 이다. 버전은 기존 client를 깨뜨릴 수 있는 변경을 분리하기 위한 수단으로 쓴다. 이 원칙은 Azure의 “기존 workload는 깨지지 않아야 한다”는 요구와도 맞닿아 있다.
|
||||
|
||||
### 5.2 major version을 올리지 않아도 되는 경우
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 원칙적으로 같은 major 안에서 처리한다.
|
||||
|
||||
- optional response field 추가
|
||||
- optional request field 추가
|
||||
- backward compatible validation 완화
|
||||
- 새로운 endpoint 추가
|
||||
- 새로운 enum value 추가 가능성을 미리 허용한 경우
|
||||
- 기존 의미를 깨지 않는 내부 구현 변경
|
||||
|
||||
Azure 가이드는 버전 가능성을 위해 확장 가능한 계약 설계를 강조하고, 새 값이 생길 수 있음을 문서화하라고 권장한다. 이는 불필요한 버전 증가를 줄이는 방향과 맞는다.
|
||||
|
||||
## 6. Spring 사용 규칙
|
||||
|
||||
### 6.1 Spring 7+를 쓰는 경우 native API versioning을 활용할 수 있다
|
||||
|
||||
Spring MVC는 API versioning을 공식 지원하고, request에서 버전을 읽어 @RequestMapping 계열의 version 속성과 매핑할 수 있다. version 속성은 고정 버전("1.2"), baseline 버전("1.2+"), 또는 미지정(any version, lowest priority)을 지원한다. 지원되지 않는 버전이나 누락된 버전은 기본적으로 400으로 처리된다. deprecated version에 대해서는 Deprecation, Sunset, Link 헤더도 보낼 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Spring 7+라면 native version mapping은 사용 가능
|
||||
- 다만 이 프로젝트의 공개 API 기본 전략은 여전히 path major versioning
|
||||
- native versioning을 쓰더라도 외부 계약 복잡도를 늘리지 않게 사용한다
|
||||
|
||||
### 6.2 Spring 6.x 이하 또는 단순 운영이 목표라면 explicit path versioning을 선호한다
|
||||
|
||||
Spring 7 이전에는 지금처럼 통합된 first-class version mapping이 없었으므로, 실무에서는 path를 통해 명시적으로 controller를 나누는 방식이 운영상 단순했다. 이 프로젝트도 버전 전략 자체보다 명시성을 우선한다. Spring 7을 쓰지 않더라도 /api/v1/**, /api/v2/** 구조는 그대로 유효하다. 이 항목은 Project Recommendation 이다.
|
||||
|
||||
### 6.3 version 누락 정책은 프로젝트에서 명시한다
|
||||
|
||||
Spring은 versioning을 활성화하면 기본적으로 version이 필수이고, 누락되면 MissingApiVersionException으로 400이 된다. 다만 optional로 두고 가장 최신 버전을 쓰게 하거나, default version을 둘 수도 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- path major versioning을 쓰는 공개 API에서는 버전 명시를 필수로 한다
|
||||
- “버전 없으면 최신 버전 사용” 같은 암묵 규칙을 두지 않는다
|
||||
- 클라이언트가 어떤 계약을 호출하는지 URL에서 명확해야 한다
|
||||
|
||||
## 7. deprecation / sunset 규칙
|
||||
|
||||
### 7.1 deprecated version은 공지와 함께 단계적으로 종료한다
|
||||
|
||||
Spring의 built-in deprecation handler는 deprecated version에 대해 Deprecation, Sunset, Link 헤더를 보낼 수 있다. Azure 가이드도 deprecating behavior를 응답 헤더로 공지하라고 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- deprecated version은 문서, 릴리스 노트, 응답 헤더 중 최소 2개 이상으로 공지한다
|
||||
- sunset 일정은 명확한 날짜와 마이그레이션 경로를 함께 제공한다
|
||||
- 구버전을 숨겨서 갑자기 끊지 않는다
|
||||
|
||||
### 7.2 구버전과 신버전은 일정 기간 병행 운영할 수 있다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- breaking change가 있는 경우 신버전 출시 후 일정 기간 구버전을 병행 운영한다
|
||||
- 병행 운영 기간과 종료 시점은 문서화한다
|
||||
- 병행 중에는 error code, 인증, 주요 리소스 의미가 버전별로 뒤섞이지 않게 관리한다
|
||||
|
||||
## 8. URL / 버전 구조 규칙
|
||||
|
||||
### 8.1 권장 구조
|
||||
|
||||
프로젝트 권장 구조:
|
||||
|
||||
```text
|
||||
/api/v1/sessions
|
||||
/api/v1/users/{userId}
|
||||
/api/v2/users/{userId}
|
||||
```
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- api prefix 아래에 version segment를 둔다
|
||||
- version은 리소스 path 앞쪽에서 빠르게 식별 가능해야 한다
|
||||
- resource naming 규칙은 버전과 별도로 일관되게 유지한다
|
||||
|
||||
### 8.2 버전과 리소스 의미를 함께 바꾸지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- version을 올릴 때 path naming convention 자체까지 불필요하게 같이 바꾸지 않는다
|
||||
- 버전 차이는 “계약 변화”를 표현하는 데 집중한다
|
||||
- v1/users에서 v2/members처럼 naming까지 동시에 바꾸는 것은 진짜 의미 변화가 있을 때만 허용한다
|
||||
|
||||
## 9. 구현 규칙
|
||||
|
||||
### 9.1 controller/package 분리는 버전 경계를 드러내야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 버전별 controller는 package 또는 클래스 구조에서 분리한다
|
||||
- v1, v2 endpoint가 뒤섞여서 읽히지 않게 한다
|
||||
- shared application/domain 로직은 재사용하되, transport contract는 버전별로 분리한다
|
||||
|
||||
### 9.2 DTO와 응답 형식도 버전 경계를 존중한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- v1 request/response DTO와 v2 DTO는 필요하면 분리한다
|
||||
- 버전이 다르면 같은 이름의 DTO를 무리하게 재사용하지 않는다
|
||||
- ApiResult 같은 envelope는 major 간에도 최대한 유지하되, payload contract는 버전별로 독립적으로 관리한다
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 같은 API 군에서 path/header/query versioning 혼용
|
||||
- breaking change인데 version을 올리지 않음
|
||||
- minor/patch를 공개 URL에 무분별하게 노출
|
||||
- 버전 미지정 시 최신 버전으로 암묵 fallback
|
||||
- deprecated version 종료 일정 없이 장기 방치
|
||||
- 신버전 도입과 동시에 resource naming/convention까지 불필요하게 전면 변경
|
||||
- controller 하나에서 여러 major 계약을 뒤섞어 처리
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 변경은 정말 breaking change인가?
|
||||
- 새 major가 필요한 이유를 설명할 수 있는가?
|
||||
- version 전략이 이 API 군 전체에서 일관적인가?
|
||||
- 버전 위치가 client, gateway, 로그, 문서에서 쉽게 드러나는가?
|
||||
- deprecated/sunset 계획이 있는가?
|
||||
- DTO와 controller 구조가 버전 경계를 드러내는가?
|
||||
@@ -0,0 +1,255 @@
|
||||
# Authentication Object Access 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 인증된 사용자 정보, 주체(principal), 인증 컨텍스트를 어디서 어떻게 접근할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- controller에서 현재 사용자 접근 방식을 일관되게 만든다
|
||||
- Spring Security 저수준 타입이 application/domain으로 번지는 것을 막는다
|
||||
- 인증 객체 접근과 권한 검사 책임을 구분한다
|
||||
- SecurityContextHolder 직접 접근을 최소화한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Security 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 확장 지점 위에 일반적인 실무 구조를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 인증 객체 접근은 web boundary concern이다
|
||||
|
||||
Spring Security에서 현재 인증 정보는 SecurityContextHolder의 SecurityContext 안 Authentication으로 관리됩니다. Spring MVC는 Principal을 controller method argument로 지원하고, Spring Security는 @AuthenticationPrincipal과 @CurrentSecurityContext로 그 접근을 더 직접적으로 노출합니다. 이 프로젝트에서는 이를 web boundary concern 으로 본다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 현재 사용자 접근은 기본적으로 controller/web adapter 경계에서 끝낸다
|
||||
- application/domain은 “현재 인증 컨텍스트를 조회하는 법”을 몰라야 한다
|
||||
- 내부 로직에는 필요한 최소 actor 정보만 전달한다
|
||||
|
||||
### 3.2 기본 선호는 @AuthenticationPrincipal 기반 전용 현재 사용자 객체다
|
||||
|
||||
Spring Security 문서는 @AuthenticationPrincipal을 쓰면 MVC 레이어를 SecurityContextHolder 직접 접근에서 분리할 수 있다고 설명하고, 더 나아가 @CurrentUser 같은 메타 애노테이션으로 Spring Security 의존을 한 파일로 격리하는 방식을 권장 예시로 보여 줍니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller의 기본 인증 객체 접근 방식은 @AuthenticationPrincipal 또는 그 위에 올린 프로젝트 전용 애노테이션
|
||||
- 프로젝트 기본 애노테이션은 @CurrentUser 또는 이에 준하는 이름을 권장
|
||||
- controller가 매번 SecurityContextHolder를 직접 읽지 않는다
|
||||
|
||||
### 3.3 인가 규칙은 인증 객체 접근 방식과 별개로 다룬다
|
||||
|
||||
Spring Security 문서는 요청 매칭 기반 보안 규칙을 일찍 적용하고, 동시에 method security를 함께 두는 defense in depth 를 권장합니다. 따라서 인증 객체를 꺼내는 문제와 권한 검사를 어디서 할지는 분리해서 설계해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 인증 객체 접근은 “현재 사용자가 누구인가”의 문제
|
||||
- 인가 규칙은 “이 사용자가 이 동작을 할 수 있는가”의 문제
|
||||
- controller 안에서 if (role == ...) 식으로 인가를 기본 구현하지 않는다
|
||||
- 인가는 security config + method security + application/domain 정책으로 나눈다
|
||||
|
||||
## 4. 접근 방식별 규칙
|
||||
|
||||
### 4.1 Principal
|
||||
|
||||
Spring MVC는 java.security.Principal을 controller method argument로 지원하며, 현재 인증된 사용자를 나타냅니다. Spring Security 환경에서는 Authentication이 Principal이므로 HttpServletRequest#getUserPrincipal() 경유로 주입될 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순히 현재 사용자 이름/식별자 정도만 필요하면 Principal 사용 가능
|
||||
- 하지만 principal 구현 타입 캐스팅을 기대하는 기본 스타일로는 쓰지 않는다
|
||||
- Principal은 가장 단순한 읽기 전용 접근에만 쓴다
|
||||
|
||||
권장 예:
|
||||
|
||||
- /me 같은 endpoint에서 현재 username만 필요한 경우
|
||||
|
||||
### 4.2 Authentication
|
||||
|
||||
Spring Security의 Authentication은 현재 사용자와 권한 정보를 담는 핵심 타입입니다. @CurrentSecurityContext(expression = "authentication")로도 controller 인자로 받을 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Authentication은 예외적으로만 controller에서 직접 받는다
|
||||
- 권한 목록, credentials, details 같은 Security framework 세부정보가 정말 필요할 때만 허용
|
||||
- 일반 비즈니스 endpoint의 기본 시그니처로 사용하지 않는다
|
||||
|
||||
즉, Authentication은 가능하지만 기본값은 아니다.
|
||||
|
||||
### 4.3 @AuthenticationPrincipal
|
||||
|
||||
Spring Security는 AuthenticationPrincipalArgumentResolver를 제공하고, @EnableWebSecurity를 쓰면 이를 MVC에 자동 추가합니다. 이 애노테이션은 Authentication.getPrincipal()을 controller method argument로 직접 받게 해 줍니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 현재 사용자 객체 접근의 기본값은 @AuthenticationPrincipal
|
||||
- 단, controller 시그니처가 Spring Security 애노테이션에 직접 결합되는 것이 싫다면 메타 애노테이션으로 감싼다
|
||||
- controller는 principal 내부 구조를 깊게 탐색하기보다 필요한 전용 타입을 주입받는다
|
||||
|
||||
### 4.4 프로젝트 전용 @CurrentUser 메타 애노테이션
|
||||
|
||||
Spring Security 문서는 @AuthenticationPrincipal을 감싼 @CurrentUser 메타 애노테이션 예시를 직접 제공하고, 이렇게 하면 MVC 레이어의 Spring Security 의존을 한 파일로 격리할 수 있다고 설명합니다. 또한 expression을 통해 JWT claim 같은 값만 바로 꺼내는 방식도 예시로 보여 줍니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 프로젝트 기본 방식은 @CurrentUser
|
||||
- @CurrentUser는 @AuthenticationPrincipal의 메타 애노테이션으로 구현
|
||||
- 필요하면 expression 기반으로 userId, subject, claims['sub'] 같은 값만 주입하는 파생 애노테이션도 허용
|
||||
|
||||
권장 방향:
|
||||
|
||||
- @CurrentUser AuthenticatedUser currentUser
|
||||
- 또는 @CurrentUserId String userId
|
||||
|
||||
### 4.5 @CurrentSecurityContext
|
||||
|
||||
Spring Security는 @CurrentSecurityContext로 SecurityContext 또는 Authentication을 controller method argument로 직접 주입할 수 있게 지원합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- @CurrentSecurityContext는 예외적 escape hatch
|
||||
- 일반 endpoint의 기본 접근 방식으로 사용하지 않는다
|
||||
- security context 전체가 필요한 framework-adjacent endpoint에서만 제한적으로 허용한다
|
||||
|
||||
예:
|
||||
|
||||
- 디버그/진단 endpoint
|
||||
- 보안 관련 내부 운영 endpoint
|
||||
|
||||
## 5. 계층별 규칙
|
||||
|
||||
### 5.1 Controller
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 인증 객체를 전용 현재 사용자 타입 또는 최소 식별자로 받는다
|
||||
- controller가 SecurityContextHolder를 직접 조회하지 않는다
|
||||
- controller는 principal에서 필요한 최소 정보만 추출해 application command/use case에 전달한다
|
||||
- controller가 Authentication, SecurityContext, UserDetails를 그대로 내부로 넘기지 않는다
|
||||
|
||||
### 5.2 Application
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- application service/use case는 Spring Security 타입을 모른다
|
||||
- 입력으로는 actorId, actorRoleSet, tenantId 같은 의미 있는 값만 받는다
|
||||
- “현재 로그인 사용자 조회”를 application 내부에서 직접 하지 않는다
|
||||
|
||||
즉, application은 현재 사용자가 누구인지가 아니라, 호출 주체가 누구라고 전달받았는지만 다룬다.
|
||||
|
||||
### 5.3 Domain
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- domain은 Spring Security 의존을 가지지 않는다
|
||||
- domain 객체/도메인 서비스/값 객체가 Authentication, Principal, UserDetails를 참조하지 않는다
|
||||
- 도메인 규칙이 호출 주체를 필요로 하면 명시적 값(ActorId, ActorType)으로 전달한다
|
||||
|
||||
### 5.4 Infrastructure / Security Adapter
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Spring Security principal 구성, claim 해석, JWT → 현재 사용자 변환은 infrastructure/security adapter에서 담당한다
|
||||
- principal 구현체, converter, resolver, 인증 토큰 해석 로직은 이 계층에 모은다
|
||||
- web/business 계층이 JWT claim 구조를 직접 파싱하지 않는다
|
||||
|
||||
## 6. 현재 사용자 타입 규칙
|
||||
|
||||
### 6.1 AuthenticatedUser 같은 전용 타입을 둔다
|
||||
|
||||
Spring Security는 principal 타입을 자유롭게 둘 수 있고, @AuthenticationPrincipal은 그 principal을 그대로 주입할 수 있습니다. 이 프로젝트에서는 controller용 인증 객체를 프로젝트 전용 타입 으로 두는 방식을 기본 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 전용 타입 예: AuthenticatedUser
|
||||
- 최소 권장 필드 예:
|
||||
- userId
|
||||
- authorities 또는 역할 집합
|
||||
- tenantId(필요 시)
|
||||
- password, credentials, provider-specific raw claim map을 기본 공개 필드로 두지 않는다
|
||||
|
||||
### 6.2 전용 타입은 “비즈니스에 필요한 최소 정보”만 담는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 현재 사용자 타입은 보안 프레임워크 내부 표현이 아니다
|
||||
- JWT 전체 claims map, raw token string, authentication details를 무비판적으로 싣지 않는다
|
||||
- 컨트롤러/유스케이스가 자주 필요로 하는 값만 담는다
|
||||
|
||||
## 7. 전달 규칙
|
||||
|
||||
### 7.1 application에는 최소 actor 정보만 넘긴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- AuthenticatedUser 전체를 application에 넘기는 것도 기본적으로 지양
|
||||
- 더 선호하는 것은 command/query 생성 시 필요한 최소 값만 복사하는 방식
|
||||
|
||||
예:
|
||||
|
||||
- CreateSessionCommand(actorId, email, password)
|
||||
- ChangePasswordCommand(actorId, currentPassword, newPassword)
|
||||
|
||||
### 7.2 principal을 전역 static 접근으로 다시 조회하지 않는다
|
||||
|
||||
Spring Security에서 현재 인증은 SecurityContextHolder에 저장되지만, 그 저장소가 있다는 사실이 곧 아무 계층에서나 static 접근으로 꺼내 써도 된다는 뜻은 아닙니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- service/domain/util에서 SecurityContextHolder.getContext() 직접 호출 금지
|
||||
- “현재 사용자 필요”는 메서드 인자로 드러나야 한다
|
||||
- 숨겨진 전역 의존성을 만들지 않는다
|
||||
|
||||
## 8. 권한 검사 규칙
|
||||
|
||||
### 8.1 권한 검사는 security rule + method security를 우선한다
|
||||
|
||||
Spring Security는 요청 매칭 기반 보안과 method security를 함께 두는 defense in depth를 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 역할/권한 검사는 기본적으로 security config 또는 method security에서 처리
|
||||
- controller 안의 imperative role check를 기본 금지
|
||||
- application/domain에서 추가 business authorization이 필요하면 명시적 정책으로 구현한다
|
||||
|
||||
### 8.2 “현재 사용자와 리소스 소유자 비교”는 business rule일 수 있다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순 권한(ROLE_ADMIN 등)은 security rule에 두는 쪽을 우선
|
||||
- “현재 사용자 ID와 리소스 owner가 같은가” 같은 규칙은 application/domain 정책일 수 있다
|
||||
- 이 경우에도 현재 사용자 정보는 최소 actor 값으로 전달한다
|
||||
|
||||
## 9. 테스트 규칙
|
||||
|
||||
### 9.1 controller 테스트는 프로젝트 전용 현재 사용자 접근을 기준으로 짠다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 테스트도 @CurrentUser 또는 프로젝트 principal 타입 기준으로 작성한다
|
||||
- 테스트 때문에 production code가 raw Authentication에 과도하게 결합되지 않게 한다
|
||||
- 보안 프레임워크 타입보다 프로젝트의 현재 사용자 계약을 검증한다
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- controller에서 SecurityContextHolder 직접 조회
|
||||
- controller 시그니처에 raw Authentication 남발
|
||||
- application/domain/service에서 Spring Security 타입 직접 사용
|
||||
- service/util에서 전역 static 방식으로 현재 사용자 조회
|
||||
- JWT claims/raw token을 여러 계층에서 직접 파싱
|
||||
- controller 안에서 if (role == ...) 식 인가 로직 구현
|
||||
- principal 구현체를 persistence/domain 모델로 겸용 사용
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 현재 사용자 접근이 controller/web 경계에 머무르는가?
|
||||
- 기본 접근 방식이 @CurrentUser 또는 이에 준하는 전용 애노테이션인가?
|
||||
- Spring Security 타입이 application/domain으로 번지지 않는가?
|
||||
- 유스케이스에는 필요한 최소 actor 정보만 전달되는가?
|
||||
- 권한 검사가 controller imperative code가 아니라 보안 규칙/정책으로 표현되는가?
|
||||
@@ -0,0 +1,245 @@
|
||||
# Error Code / HTTP Status Separation 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API 실패 응답에서 HTTP status와 application error code의 역할을 분리하는 기준을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- HTTP status를 프로토콜 의미에 맞게 사용한다
|
||||
- business/domain/application 오류 식별은 별도의 ErrorCode로 관리한다
|
||||
- controller/advice에서 status와 code를 뒤섞어 쓰는 일을 막는다
|
||||
- 실패 응답이 운영, 클라이언트 처리, 로그 분석에서 일관되게 동작하게 한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: HTTP Semantics(RFC 9110), Spring Framework 공식 문서/Javadoc에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 의미 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 HTTP status는 프로토콜 수준 의미다
|
||||
|
||||
HTTP status code는 응답의 결과와 의미를 나타내는 표준 3자리 코드이며, 첫 번째 자리가 응답 클래스(1xx~5xx)를 결정합니다. 4xx는 요청 자체가 잘못되었거나 현재 요청을 이행할 수 없는 경우이고, 5xx는 서버가 유효해 보이는 요청을 수행하지 못한 경우입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- HTTP status는 HTTP 관점의 결과를 나타낸다
|
||||
- status는 transport/protocol 의미를 표현한다
|
||||
- status를 business/domain 세부 사유 식별자로 남용하지 않는다
|
||||
|
||||
### 3.2 ErrorCode는 애플리케이션 수준 의미다
|
||||
|
||||
ErrorCode는 HTTP 표준 개념이 아니라, 프로젝트가 정의하는 기계 판독용 애플리케이션 오류 식별자다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ErrorCode는 business/application/framework error를 구분하는 식별자다
|
||||
- 클라이언트의 세부 분기, 운영 로그 분류, 문서화, 모니터링에 사용한다
|
||||
- ErrorCode는 HTTP status를 대체하지 않는다
|
||||
|
||||
이 항목은 Official + Practice 이다. HTTP가 status의 의미를 정의하고, 세부 오류 분류는 애플리케이션이 별도로 설계하는 것이 자연스럽다.
|
||||
|
||||
### 3.3 status와 code는 서로 다른 질문에 답한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- HTTP status는 “HTTP 요청을 어떤 범주로 처리했는가?”에 답한다
|
||||
- ErrorCode는 “애플리케이션에서 정확히 어떤 종류의 실패인가?”에 답한다
|
||||
|
||||
예:
|
||||
|
||||
- 400 Bad Request + REQUEST_VALIDATION_FAILED
|
||||
- 409 Conflict + DUPLICATE_EMAIL
|
||||
- 401 Unauthorized + INVALID_ACCESS_TOKEN
|
||||
- 503 Service Unavailable + UPSTREAM_AUTH_SERVER_UNAVAILABLE
|
||||
|
||||
즉, 두 값은 중복이 아니라 서로 다른 층위의 정보다.
|
||||
|
||||
## 4. HTTP status 사용 규칙
|
||||
|
||||
### 4.1 유효한 HTTP status만 사용한다
|
||||
|
||||
HTTP status의 유효 범위는 100~599이며, 그 밖의 값은 HTTP status로는 유효하지 않습니다. RFC 9110도 600~999 같은 값은 내부 통신에서 비표준적으로 쓰일 수는 있어도 HTTP 응답 status로는 유효하지 않다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 6xx, 7xx 같은 custom status code 사용 금지
|
||||
- status는 표준 HTTP status만 사용
|
||||
- 세부 오류 분기는 status가 아니라 ErrorCode로 해결한다
|
||||
|
||||
### 4.2 status는 최대한 표준 의미에 가깝게 선택한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 입력 형식/검증 실패 → 400 계열
|
||||
- 인증 실패 → 401
|
||||
- 권한 부족 → 403
|
||||
- 리소스 없음 → 404
|
||||
- 상태 충돌/중복/현재 상태와의 모순 → 409
|
||||
- 의미적으로 처리 불가능한 요청을 별도로 구분할 합의가 있으면 422 검토 가능
|
||||
- 예상 못 한 서버 오류 → 500
|
||||
- 일시적 외부 의존성 실패 → 502/503/504 중 의미에 맞는 값 선택
|
||||
|
||||
이 항목은 Official + Practice 이다. RFC 9110이 status class 의미를 정의하고, 세부 매핑은 API 설계자가 해당 의미에 맞게 선택해야 한다.
|
||||
|
||||
### 4.3 같은 business family가 항상 같은 status일 필요는 없지만, 이유는 분명해야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 ErrorCode family라도 상황에 따라 status가 달라질 수 있다
|
||||
- 다만 같은 의미의 오류에 status가 들쭉날쭉하면 안 된다
|
||||
- status 선택 기준은 문서와 ErrorCode 정책에 남긴다
|
||||
|
||||
예:
|
||||
|
||||
- AUTHENTICATION_FAILED 류는 보통 401
|
||||
- AUTHORIZATION_DENIED 류는 보통 403
|
||||
- RESOURCE_CONFLICT 류는 보통 409
|
||||
|
||||
## 5. ErrorCode 사용 규칙
|
||||
|
||||
### 5.1 ErrorCode는 중앙 정책 타입으로 관리한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ErrorCode는 enum 또는 이에 준하는 중앙 정책 타입으로 관리한다
|
||||
- controller/advice/service 각 파일에 문자열 리터럴로 흩뿌리지 않는다
|
||||
- code, 기본 message, 기본 httpStatus를 함께 관리할 수 있다
|
||||
|
||||
이 규칙은 실무적으로 가장 흔한 안정화 방식이며, 앞선 응답 포맷 규약과도 맞물린다.
|
||||
|
||||
### 5.2 ErrorCode는 외부 계약이다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 한 번 공개된 ErrorCode는 API 계약으로 취급한다
|
||||
- 이름 변경, 삭제, 의미 변경은 호환성 영향이 있다
|
||||
- 로그용 내부 키와 외부 응답용 code를 필요하면 분리한다
|
||||
|
||||
### 5.3 message는 ErrorCode의 기본 외부 메시지로 관리할 수 있다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 외부 메시지는 ErrorCode가 가진다
|
||||
- advice는 예외를 적절한 ErrorCode로 매핑하는 책임에 집중한다
|
||||
- ex.getMessage()를 외부 응답 메시지 기본값으로 쓰지 않는다
|
||||
|
||||
## 6. status와 ErrorCode의 관계
|
||||
|
||||
### 6.1 하나의 status 아래 여러 ErrorCode가 올 수 있다
|
||||
|
||||
HTTP는 status class로 넓은 의미를 표현하므로, 하나의 400/409/500 아래에 여러 세부 application code가 오는 것이 자연스럽다. 이는 HTTP status가 세부 business 오류 식별용이 아니기 때문이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
400 아래:
|
||||
|
||||
- REQUEST_VALIDATION_FAILED
|
||||
- INVALID_QUERY_PARAMETER
|
||||
- MALFORMED_JSON_REQUEST
|
||||
|
||||
409 아래:
|
||||
|
||||
- DUPLICATE_EMAIL
|
||||
- SESSION_ALREADY_REVOKED
|
||||
- RESOURCE_VERSION_CONFLICT
|
||||
|
||||
처럼 관리할 수 있다.
|
||||
|
||||
### 6.2 하나의 ErrorCode는 기본 status를 가진다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 각 ErrorCode는 기본적으로 하나의 대표 HTTP status를 가진다
|
||||
- 기본 status는 중앙 정책 타입에서 관리한다
|
||||
- 예외적 override가 필요한 경우에만 advice에서 분기한다
|
||||
|
||||
### 6.3 “200 OK + success=false”를 기본 실패 전략으로 쓰지 않는다
|
||||
|
||||
HTTP status는 응답의 결과 의미를 담는 표준 필드이므로, 실패를 body의 success=false에만 넣고 status를 무조건 200으로 보내는 방식은 status 의미를 약화시킨다. RFC 9110은 status code가 요청 결과와 응답 의미를 나타낸다고 명확히 정의한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 실패 응답은 적절한 4xx/5xx status를 함께 사용한다
|
||||
- ApiResult.success=false는 body 규약 보강용이지, status 대체물이 아니다
|
||||
- “모든 응답은 200” 전략을 기본 금지한다
|
||||
|
||||
## 7. Spring 사용 규칙
|
||||
|
||||
### 7.1 일반 실패 응답은 @RestControllerAdvice + ResponseEntity를 기본으로 한다
|
||||
|
||||
Spring은 @ExceptionHandler에서 ResponseEntity를 반환해 status와 body를 함께 제어할 수 있게 하고, @ControllerAdvice/@RestControllerAdvice로 전역 적용할 수 있다. ResponseEntity는 status·headers·body를 함께 표현하는 타입이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공통 실패 응답은 @RestControllerAdvice에서 생성
|
||||
- body는 ApiResult.fail(ErrorCode...)
|
||||
- status는 ErrorCode가 가진 기본 status 또는 정책에 맞는 값 사용
|
||||
|
||||
### 7.2 예외 클래스에 @ResponseStatus를 기본 전략으로 두지 않는다
|
||||
|
||||
Spring의 ResponseStatusExceptionResolver는 @ResponseStatus와 ResponseStatusException을 status로 매핑한다. 하지만 @ResponseStatus Javadoc은 예외 클래스에 이 애노테이션을 붙이거나 reason을 주면 sendError가 사용되고, REST API에는 부적합할 수 있으므로 이런 경우 ResponseEntity를 선호하라고 명시합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- business exception 클래스에 @ResponseStatus를 기본적으로 붙이지 않는다
|
||||
- 특히 reason 사용 금지
|
||||
- 예외는 domain/application 의미를 표현하고, HTTP status 변환은 advice에서 수행한다
|
||||
|
||||
### 7.3 ResponseStatusException은 제한적으로 사용한다
|
||||
|
||||
Spring은 ResponseStatusException을 공식 지원하고 resolver가 이를 status로 처리한다. 다만 이것은 HTTP-aware 예외이므로 controller/web adapter 쪽에서는 유용할 수 있지만, application/domain 핵심 로직까지 전파되는 기본 모델로 두는 것은 바람직하지 않다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- web adapter/controller 레벨의 즉시 HTTP 실패 표현이 필요할 때 제한적으로 사용 가능
|
||||
- application/domain/service의 기본 예외 모델로 채택하지 않는다
|
||||
- 프로젝트 기본 경로는 여전히 “도메인/애플리케이션 예외 → advice에서 ErrorCode/status 매핑”이다
|
||||
|
||||
## 8. 설계 권장안
|
||||
|
||||
### 8.1 ErrorCode가 기본 status를 가진다
|
||||
|
||||
권장 구조:
|
||||
|
||||
- ErrorCode
|
||||
- httpStatus
|
||||
- code
|
||||
- message
|
||||
- ApiResult.fail(ErrorCode)
|
||||
- advice는 예외를 ErrorCode로 매핑
|
||||
|
||||
이 구조는 status와 code의 역할을 분리하면서도, 운영 시 일관된 실패 정책을 유지하기 쉽다.
|
||||
|
||||
### 8.2 advice는 문자열 조립보다 매핑에 집중한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- advice는 예외 → ErrorCode 선택
|
||||
- ApiResult는 ErrorCode에서 code/message를 읽어 body 생성
|
||||
- status는 ErrorCode.httpStatus() 또는 명시적 override로 결정
|
||||
|
||||
## 9. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 6xx/7xx 같은 custom HTTP status 사용
|
||||
- 실패를 무조건 200 OK로 응답하고 body에만 실패 표시
|
||||
- advice/controller에 "DUPLICATE_EMAIL" 같은 문자열 하드코딩
|
||||
- ex.getMessage()를 그대로 외부 응답 메시지로 사용
|
||||
- domain/application 예외 클래스에 @ResponseStatus(reason=...) 사용
|
||||
- HTTP status와 ErrorCode를 사실상 같은 값처럼 중복 설계
|
||||
- endpoint마다 같은 오류에 다른 status를 제멋대로 사용
|
||||
|
||||
## 10. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 status는 HTTP 의미로 설명 가능한가?
|
||||
- 이 세부 실패 식별은 ErrorCode로 따로 표현되는가?
|
||||
- 100~599 범위의 표준 status만 쓰고 있는가?
|
||||
- 실패인데도 200으로 보내고 있지 않은가?
|
||||
- ErrorCode가 중앙 정책 타입으로 관리되는가?
|
||||
- @ResponseStatus(reason=...) 대신 advice + ResponseEntity를 쓰고 있는가?
|
||||
@@ -0,0 +1,297 @@
|
||||
# Idempotency 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API에서 멱등성(idempotency)을 어떻게 정의하고, 어디에 적용하며, 어떤 방식으로 구현할지 정한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- HTTP 메서드 자체의 멱등성과 애플리케이션 수준 멱등성을 구분한다.
|
||||
- 네트워크 타임아웃, 응답 유실, 재시도 상황에서 중복 생성/중복 실행을 막는다.
|
||||
- Idempotency-Key 기반 중복 방지 정책을 프로젝트 단위로 통일한다.
|
||||
- controller, service, storage, 응답 규약에서 멱등성 책임을 분명히 한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: RFC 9110, IETF HTTPAPI draft, 공개 API 가이드/공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 의미 위에 Stripe/PayPal 같은 실무 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 HTTP 메서드의 멱등성과 애플리케이션 멱등성은 다르다
|
||||
|
||||
RFC 9110 기준으로 GET, HEAD, OPTIONS, TRACE는 safe 이고, PUT, DELETE, 그리고 safe 메서드들은 idempotent 입니다. 같은 요청을 여러 번 보내도 서버에 의도된 효과는 한 번과 같아야 합니다. 반면 POST는 기본적으로 idempotent가 아니고, 클라이언트는 특별한 근거가 없으면 자동 재시도하면 안 됩니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- HTTP 멱등성은 메서드 의미에 관한 규칙이다.
|
||||
- 애플리케이션 멱등성은 중복 요청 방지와 재시도 안전성에 관한 규칙이다.
|
||||
- PUT/DELETE가 HTTP 차원에서 idempotent라고 해서, 모든 business side effect까지 자동으로 안전하다고 가정하지 않는다.
|
||||
- POST/PATCH가 기본적으로 비멱등이므로, 재시도 안전성이 필요하면 별도 설계를 둔다.
|
||||
|
||||
### 3.2 이 프로젝트의 멱등성 기본 전략은 Idempotency-Key다
|
||||
|
||||
IETF 초안은 Idempotency-Key 요청 헤더를 사용해 POST·PATCH 같은 비멱등 메서드를 fault-tolerant 하게 만드는 방향을 제시하고 있고, 서버는 키의 유일성·만료 정책·중복 처리 방식을 문서화해야 한다고 설명합니다. Stripe와 PayPal도 같은 취지로 client-generated key/header를 사용합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 비멱등 command endpoint의 기본 멱등성 수단은 Idempotency-Key 요청 헤더
|
||||
- 공개 API에서 별도 사유가 없으면 proprietary header보다 Idempotency-Key를 우선 사용
|
||||
- 외부 third-party 연동에서 상대방이 다른 이름의 헤더를 요구하면 adapter에서 변환한다
|
||||
|
||||
### 3.3 멱등성의 목적은 “같은 의도”의 안전한 재시도다
|
||||
|
||||
IETF 초안은 같은 key가 같은 요청의 재시도를 식별하기 위한 것이라고 설명하고, Stripe도 동일 key에 대해 첫 결과를 재사용한다고 설명합니다. 즉, 멱등 키는 “대충 중복 방지용 문자열”이 아니라 같은 요청 의도에 대한 재시도 식별자입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 멱등 키는 “같은 요청을 다시 보내는 경우”에만 재사용한다
|
||||
- 요청 의도가 바뀌면 새 키를 생성한다
|
||||
- 멱등 키를 “세션 ID”나 “사용자 식별자”처럼 장기 재사용 식별자로 쓰지 않는다
|
||||
|
||||
## 4. 적용 대상
|
||||
|
||||
### 4.1 기본적으로 적용해야 하는 endpoint
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음처럼 중복 실행 위험이 큰 비멱등 요청에는 멱등성을 기본 검토 대상으로 둔다.
|
||||
|
||||
- 리소스 생성 POST
|
||||
- 상태 변경 command POST/PATCH
|
||||
- 외부 결제/인증/발급/전송과 연결된 요청
|
||||
- 타임아웃 후 client 재시도가 현실적으로 자주 일어날 수 있는 요청
|
||||
- “한 번만 수행돼야 하는” business command
|
||||
|
||||
예:
|
||||
|
||||
- 회원 가입
|
||||
- 세션/토큰 발급
|
||||
- 비밀번호 변경
|
||||
- 이메일 인증 발송
|
||||
- 환불/정산/결제 확정
|
||||
|
||||
### 4.2 기본적으로 적용하지 않는 endpoint
|
||||
|
||||
RFC 9110 기준으로 GET/HEAD/OPTIONS/TRACE는 safe이고, PUT/DELETE는 idempotent입니다. Stripe도 GET/DELETE에 idempotency key를 보내도 효과가 없다고 안내합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- GET/HEAD/OPTIONS/TRACE에는 Idempotency-Key를 기본적으로 사용하지 않는다
|
||||
- PUT/DELETE는 HTTP 의미상 이미 idempotent이므로, 별도 애플리케이션 멱등 키는 기본값이 아니다
|
||||
- 다만 PUT/DELETE가 추가 외부 side effect를 동반하는 특수 endpoint면 별도 검토할 수 있다
|
||||
|
||||
## 5. 키 규칙
|
||||
|
||||
### 5.1 키는 클라이언트가 생성한다
|
||||
|
||||
IETF 초안은 key를 client가 생성한 고유 값으로 설명하고, UUID 같은 random identifier 사용을 권장합니다. Stripe도 V4 UUID 또는 충분한 entropy를 가진 랜덤 문자열을 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Idempotency-Key는 클라이언트 생성
|
||||
- 서버가 멱등 키를 대신 생성해서 응답으로 내려주고 다음 요청에서 재사용하게 하는 방식을 기본으로 두지 않는다
|
||||
- 권장 형식은 UUID v4 또는 이에 준하는 고엔트로피 opaque string
|
||||
|
||||
### 5.2 키에는 민감정보를 넣지 않는다
|
||||
|
||||
Stripe는 idempotency key에 이메일 주소나 개인 식별자 같은 민감정보를 넣지 말라고 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 키에는 이메일, 전화번호, 주민번호, 사용자명 같은 의미 있는 개인정보를 넣지 않는다
|
||||
- 키는 opaque value 로 취급한다
|
||||
- 로그에도 원문 전체를 무분별하게 남기지 않는다
|
||||
|
||||
### 5.3 키의 유효 범위(scope)를 정의한다
|
||||
|
||||
IETF 초안은 key의 유일성 기준은 resource owner가 정의해야 한다고 설명합니다. 즉, “어디까지 같은 key로 보느냐”는 서버 정책입니다.
|
||||
|
||||
프로젝트 기본 규칙:
|
||||
|
||||
멱등 키 scope는 최소한 다음을 포함해 판단한다
|
||||
|
||||
- HTTP method
|
||||
- 정규화된 operation/resource
|
||||
- 호출 주체(actor/client)
|
||||
- idempotency key
|
||||
- 같은 key라도 다른 operation 이면 충돌로 보지 않는다
|
||||
- 같은 key라도 다른 사용자/클라이언트 면 같은 요청으로 취급하지 않는다
|
||||
|
||||
권장 예:
|
||||
|
||||
```text
|
||||
(actorId, operationName, idempotencyKey)
|
||||
```
|
||||
|
||||
## 6. fingerprint 규칙
|
||||
|
||||
### 6.1 키만 보지 말고 fingerprint도 비교한다
|
||||
|
||||
IETF 초안은 서버가 request payload로부터 idempotency fingerprint 를 생성할 수 있고, checksum·선택 필드 비교·request digest 등으로 요청 동일성을 판단할 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 서버는 key만 저장하지 말고 request fingerprint 도 함께 관리한다
|
||||
- fingerprint는 다음 요소를 기반으로 구성한다
|
||||
- method
|
||||
- operation/resource
|
||||
- actor/client
|
||||
- request body의 canonical form 또는 의미 필드
|
||||
- fingerprint 비교 없이 key만 믿고 중복 처리하지 않는다
|
||||
|
||||
### 6.2 fingerprint는 “의미적으로 같은 요청” 기준으로 만든다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순 raw JSON 문자열 비교보다 의미 필드 기준 비교를 우선 검토한다
|
||||
- 필드 순서 차이, 불필요한 공백 차이, 서버가 무시하는 필드 차이 때문에 다른 요청으로 오판하지 않게 한다
|
||||
- 반대로 실제 business 의미가 다른 요청은 반드시 다른 fingerprint가 되게 한다
|
||||
|
||||
## 7. 저장/처리 규칙
|
||||
|
||||
### 7.1 첫 완료 결과를 저장하고 같은 결과를 재생한다
|
||||
|
||||
IETF 초안은 중복 요청이 원래 요청 완료 후 재시도된 경우, 서버가 이전에 완료된 작업의 결과를 다시 응답해야 한다고 설명합니다. Stripe도 같은 key에 대해 첫 요청의 status code와 body를 재사용하고, 성공뿐 아니라 실패 결과도 재사용한다고 명시합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
같은 key + 같은 fingerprint + 이미 완료된 요청이면
|
||||
|
||||
- 같은 status
|
||||
- 같은 body
|
||||
- 필요하면 같은 핵심 header(Location 등)
|
||||
|
||||
를 재응답한다.
|
||||
중복 요청이라고 해서 새 business execution을 다시 시작하지 않는다.
|
||||
|
||||
### 7.2 요청이 아직 처리 중이면 409를 기본으로 한다
|
||||
|
||||
IETF 초안은 원 요청이 아직 처리 중인 상태에서 같은 key로 재시도되면 409 Conflict 를 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 key + 같은 fingerprint인데 원 요청이 in progress 면 기본 응답은 409 Conflict
|
||||
- body code는 예:
|
||||
- IDEMPOTENCY_REQUEST_IN_PROGRESS
|
||||
- 이 경우 client는 잠시 후 같은 key로 다시 재시도할 수 있다
|
||||
|
||||
### 7.3 같은 키를 다른 요청에 재사용하면 거절한다
|
||||
|
||||
IETF 초안은 같은 key를 다른 payload 로 재사용하면 422 Unprocessable Content 를 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 key + 다른 fingerprint는 기본적으로 422 Unprocessable Content
|
||||
- body code는 예:
|
||||
- IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST
|
||||
- 서버는 조용히 새 요청으로 처리하지 않는다
|
||||
|
||||
### 7.4 키가 필요한 endpoint에서 키가 없으면 400을 기본으로 한다
|
||||
|
||||
IETF 초안은 멱등 키가 문서상 필수인 operation에서 헤더가 없으면 400 Bad Request 를 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 멱등 키 필수 endpoint에서 헤더 누락 시 400 Bad Request
|
||||
- body code는 예:
|
||||
- IDEMPOTENCY_KEY_REQUIRED
|
||||
|
||||
## 8. 만료(TTL) 규칙
|
||||
|
||||
### 8.1 TTL은 반드시 문서화한다
|
||||
|
||||
IETF 초안은 서버가 key의 expiration policy를 문서화해야 한다고 설명합니다. Stripe는 key를 최소 24시간 이후 자동 제거 가능 하다고 말하고, PayPal은 일부 POST API에서 PayPal-Request-Id를 최대 45일 예시로 보여 줍니다. 즉, TTL에는 업계 단일 정답이 없습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 멱등 키 TTL은 endpoint 문서에 명시한다
|
||||
- 프로젝트 기본 최소 TTL 권장값은 24시간
|
||||
- 금융/정산/고비용 side effect는 더 긴 TTL을 검토한다
|
||||
- TTL이 지나면 같은 key는 새 요청으로 처리될 수 있음을 문서화한다
|
||||
|
||||
### 8.2 TTL은 business 위험에 따라 다르게 줄 수 있다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순 생성/변경: 24시간 전후
|
||||
- 고비용 외부 side effect: 더 긴 TTL 가능
|
||||
- 너무 긴 TTL은 key storage 비용과 오탐 가능성을 높이므로 무작정 늘리지 않는다
|
||||
|
||||
## 9. 구현 규칙
|
||||
|
||||
### 9.1 멱등성 저장소는 다중 인스턴스 환경에서도 일관돼야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- production에서는 프로세스 메모리만으로 멱등성 보장 금지
|
||||
- 다중 인스턴스에서 공유되는 저장소를 사용한다
|
||||
- RDB
|
||||
- Redis
|
||||
- 기타 내구성 있는 shared store
|
||||
- “한 서버에만 있는 ConcurrentHashMap” 으로 끝내지 않는다
|
||||
|
||||
### 9.2 business write와 멱등성 기록은 원자성 경계를 검토한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- “실제 side effect는 일어났는데 idempotency record는 안 남는” 상태를 최대한 줄인다
|
||||
- 가능하면 business state write와 idempotency completion 기록의 원자성/정합성을 맞춘다
|
||||
- 외부 시스템까지 걸친 완전 원자성은 어렵더라도, 적어도 중복 실행을 줄이는 방향 으로 설계한다
|
||||
|
||||
### 9.3 controller보다 application/service 경계에 두는 것을 기본으로 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 Idempotency-Key를 읽어 application command로 전달
|
||||
- 실제 중복 방지 판정, fingerprint 비교, 결과 재생은 application/service 전용 구성요소가 담당
|
||||
- controller에서 직접 storage를 만지며 멱등성 로직을 구현하지 않는다
|
||||
|
||||
## 10. 응답 규칙
|
||||
|
||||
### 10.1 멱등성 오류도 일반 실패 응답 규약을 따른다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 멱등성 관련 오류도 ApiResult.fail(ErrorCode...) 형식을 따른다
|
||||
|
||||
예:
|
||||
|
||||
- IDEMPOTENCY_KEY_REQUIRED
|
||||
- IDEMPOTENCY_REQUEST_IN_PROGRESS
|
||||
- IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST
|
||||
- 멱등성 오류라고 해서 별도 임시 JSON 구조를 만들지 않는다
|
||||
|
||||
### 10.2 재생된 응답임을 알려야 할지 여부는 API군 단위로 정한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 필요하면 X-Idempotent-Replay: true 같은 응답 헤더를 둘 수 있다
|
||||
- 하지만 body 계약을 바꿔서 “재생 응답” 전용 구조를 만들지는 않는다
|
||||
- 헤더 사용 여부는 API군 단위로 일관되게 정한다
|
||||
|
||||
## 11. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- GET/HEAD에 멱등 키를 기본 요구
|
||||
- 같은 key를 다른 요청 의도에 재사용
|
||||
- 민감정보를 key에 포함
|
||||
- controller 안에서 멱등성 저장/판정을 직접 구현
|
||||
- 다중 인스턴스 환경에서 로컬 메모리만으로 멱등성 보장
|
||||
- 같은 key + 다른 payload를 조용히 새 요청으로 처리
|
||||
- 실패 응답을 무조건 200 OK로 보내고 body만 실패로 표시
|
||||
- TTL/적용 대상/재시도 정책 문서 없이 운영
|
||||
|
||||
## 12. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 endpoint는 비멱등 요청이며 재시도 안전성이 필요한가?
|
||||
- Idempotency-Key 적용 여부가 문서화돼 있는가?
|
||||
- key scope와 fingerprint 기준이 정의돼 있는가?
|
||||
- 같은 key + 같은 fingerprint 재시도 시 같은 결과를 재생하는가?
|
||||
- 같은 key + 다른 fingerprint 재사용을 거절하는가?
|
||||
- in-flight duplicate를 409로 처리하는가?
|
||||
- TTL과 저장소 전략이 production 환경에 맞는가?
|
||||
@@ -0,0 +1,330 @@
|
||||
# Pagination / Sort / Filter 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 목록 조회 API의 pagination, sort, filter 규약을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 목록 조회 endpoint의 query contract를 일관되게 만든다
|
||||
- page 기반과 cursor 기반 pagination의 사용 기준을 구분한다
|
||||
- 정렬과 필터의 허용 범위를 명확히 한다
|
||||
- Spring Data의 편의 기능과 공개 API 계약을 분리한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Data / 공개 API 가이드에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 문서의 확장 지점 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 목록 조회의 입력은 query parameter를 기본으로 한다
|
||||
|
||||
Spring MVC에서 @RequestParam은 query parameter나 form data를 controller method argument에 바인딩하는 공식 방법이며, 같은 이름의 파라미터를 여러 번 보내는 경우 리스트나 배열로도 받을 수 있습니다. Azure 가이드도 pagination과 filtering을 query parameter로 제공하라고 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 목록 조회 입력은 기본적으로 query parameter로 받는다
|
||||
- pagination, sort, filter는 request body가 아니라 query contract로 노출한다
|
||||
- GET 목록 조회에 body filtering을 기본 전략으로 쓰지 않는다
|
||||
|
||||
### 3.2 Spring의 Pageable/Sort 지원은 “프레임워크 편의”이지 “공개 API 계약”은 아니다
|
||||
|
||||
Spring Data web support는 controller method argument로 Pageable과 Sort를 바로 받을 수 있게 해 주고, 기본 페이지 해석도 제공한다. 하지만 그것은 Spring 애플리케이션 내부 편의 기능이지, 외부 클라이언트에 그대로 노출해야 하는 계약이라는 뜻은 아닙니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공개 API controller는 raw Pageable/Sort를 기본 시그니처로 사용하지 않는다
|
||||
- 외부 계약은 명시적 request DTO 또는 명시적 query parameter 규약으로 드러낸다
|
||||
- repository/application 내부에서는 필요 시 Pageable/Sort를 사용할 수 있다
|
||||
|
||||
### 3.3 pagination, sort, filter는 함께 설계한다
|
||||
|
||||
Azure 가이드는 sorting이 filtering과 조합되어야 하며, paginated list에서는 모든 페이지에서 같은 filtering options와 sort order를 유지하라고 권장합니다. GitHub도 실제 paginated endpoint에서 다음 페이지 URL을 Link header로 주며, page/cursor 계열 query parameter를 계속 이어서 사용합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- pagination, sort, filter는 서로 독립 기능처럼 보이더라도 하나의 목록 조회 계약으로 설계한다
|
||||
- 다음 페이지를 조회할 때 filter/sort가 바뀌지 않게 설계한다
|
||||
- 페이지 이동 중 계약이 흔들리면 안 된다
|
||||
|
||||
## 4. Pagination 표준
|
||||
|
||||
### 4.1 기본값은 page-based pagination이다
|
||||
|
||||
Azure는 일반적인 데이터 조회에서 limit/offset 형태의 pagination을 예시로 제시하고, GitHub는 page 및 cursor 계열 파라미터를 실제로 사용합니다. Spring Data도 Pageable 기반 paging을 폭넓게 지원합니다.
|
||||
|
||||
프로젝트 기본 규칙:
|
||||
|
||||
- 일반 목록 조회 endpoint의 기본 pagination은 page-based
|
||||
- 외부 계약 기본 파라미터는 page, size
|
||||
- 공개 API에서는 외부 page는 1-based 로 둔다
|
||||
- 내부 Spring Data 변환 시 필요하면 0-based PageRequest로 변환한다
|
||||
|
||||
### 4.2 공개 API는 Spring 내부의 0-based 페이지 번호를 그대로 노출하지 않는다
|
||||
|
||||
Spring Data의 Pageable은 첫 페이지를 0으로 다루는 API를 제공하고, Pageable.ofSize(...)도 첫 페이지를 page number 0으로 생성합니다. 하지만 공개 API가 반드시 그 내부 표현을 따라야 하는 것은 아닙니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API는 page=1부터 시작한다
|
||||
- controller 또는 mapper에서 내부 0-based Pageable로 변환한다
|
||||
- Spring 내부 표현을 외부 계약에 그대로 새어 나오게 하지 않는다
|
||||
|
||||
### 4.3 page size는 기본값과 최대값을 반드시 둔다
|
||||
|
||||
Azure는 pagination 예시에서 limit와 offset에 의미 있는 기본값을 둘 것을 권장합니다. GitHub도 per_page를 통해 페이지 크기를 조절하지만, endpoint별 한도가 존재합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- size는 기본값과 최대값을 가진다
|
||||
- 기본값 예: 20
|
||||
- 최대값 예: 100
|
||||
- 최대값을 넘는 요청은 보정하거나 400으로 거절하는 정책을 API군 단위로 일관되게 정한다
|
||||
|
||||
### 4.4 Page가 항상 정답은 아니다
|
||||
|
||||
Spring Data에서 Page는 전체 개수와 전체 페이지 수를 알기 위해 추가 count query를 수행할 수 있고, 그 비용이 비쌀 수 있습니다. Slice는 다음 페이지 존재 여부만 알고, List는 count metadata를 만들지 않습니다. 또한 큰 offset 기반 조회는 비효율적일 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- totalCount가 반드시 필요한 목록만 Page 스타일 메타데이터를 제공한다
|
||||
- 다음 페이지 존재만 알면 충분한 목록은 Slice 스타일 응답을 선호한다
|
||||
- count query 비용이 큰 도메인에서는 무조건 total count를 주지 않는다
|
||||
|
||||
### 4.5 대용량/변동이 큰 목록은 cursor pagination을 우선 검토한다
|
||||
|
||||
Spring Data의 scrolling/keyset filtering은 stable sort order를 전제로 다음 구간을 더 효율적으로 가져올 수 있고, 큰 offset을 건너뛰는 비용을 줄이도록 설계되어 있습니다. GitHub도 실제 API에서 before/after/since 같은 cursor 계열 파라미터를 사용합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음 조건이면 cursor pagination을 우선 검토한다
|
||||
|
||||
- 데이터가 매우 크다
|
||||
- 최신순 피드/로그/이벤트처럼 계속 변한다
|
||||
- 큰 offset 페이지를 자주 조회한다
|
||||
- cursor는 opaque string 으로 노출한다
|
||||
- 내부 keyset 구조를 외부에 직접 노출하지 않는다
|
||||
|
||||
### 4.6 cursor pagination은 stable sort가 필수다
|
||||
|
||||
Spring Data keyset filtering은 stable sorting order를 전제로 하고, sort 필드와 primary key를 함께 사용해 다음 위치를 계산합니다. 또한 keyset 필드는 non-nullable이어야 하며, 적절한 인덱스가 있을 때 가장 잘 동작합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- cursor pagination에는 안정적인 정렬 기준이 반드시 있어야 한다
|
||||
- 기본 정렬 필드만으로 충돌 가능성이 있으면 tie-breaker로 id 같은 고유 키를 추가한다
|
||||
- nullable field를 cursor 핵심 정렬 키로 쓰는 것은 지양한다
|
||||
|
||||
## 5. Sort 표준
|
||||
|
||||
### 5.1 정렬은 명시적 허용 목록 기반으로 제공한다
|
||||
|
||||
Azure 가이드는 정렬에 사용할 수 없는 필드를 요청하면 에러를 반환하라고 권장하고, 값의 inherent order를 따르라고 안내합니다. Spring Data는 Sort와 Pageable로 정렬을 처리할 수 있지만, 어떤 필드를 정렬 가능하게 열 것인지는 애플리케이션이 결정해야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 정렬 가능 필드는 allowlist로 관리한다
|
||||
- 지원하지 않는 필드 정렬 요청은 무시하지 말고 400 으로 응답한다
|
||||
- DB 컬럼명이나 내부 경로를 그대로 외부 sort key로 노출하지 않는다
|
||||
|
||||
### 5.2 기본 정렬을 반드시 둔다
|
||||
|
||||
Spring Data keyset scrolling도 stable order를 전제로 하고, pagination이 있는 목록은 정렬 기준이 흐리면 페이지 이동 중 결과가 흔들릴 수 있습니다. Azure도 sorting과 pagination의 일관성을 강조합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 목록 endpoint는 기본 정렬을 가진다
|
||||
- 권장 기본 정렬 예:
|
||||
- 생성일 내림차순
|
||||
- 수정일 내림차순
|
||||
- 이름 오름차순
|
||||
- tie-breaker가 필요하면 id를 마지막 정렬 키로 고정한다
|
||||
|
||||
### 5.3 외부 정렬 파라미터 형식은 하나로 통일한다
|
||||
|
||||
Spring Data는 query parameter로 Sort를 해석하는 지원을 제공하지만, 공개 API는 더 읽기 쉬운 별도 규약을 가질 수 있습니다. Azure 예시는 orderby=name desc,hireDate 같은 문법도 보여 줍니다. 업계 관행은 다양하므로 프로젝트가 하나를 고정하는 것이 더 중요합니다.
|
||||
|
||||
프로젝트 기본 규칙:
|
||||
|
||||
- 기본 형식은 sortBy + direction
|
||||
- 다중 정렬이 정말 필요한 API군에서만 반복 sort 같은 확장 형식을 허용
|
||||
- 같은 API product 안에서 sortBy/direction, orderby, 반복 sort를 혼용하지 않는다
|
||||
|
||||
### 5.4 정렬은 의미 단위로 노출한다
|
||||
|
||||
Azure는 필드 타입의 inherent order를 따르라고 권장합니다. 즉, 날짜는 시간순, 숫자는 숫자순으로 정렬되어야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 날짜는 시간순 의미로 정렬한다
|
||||
- 문자열 표시명과 내부 저장 키가 다르면 외부 의미 기준의 정렬 키를 정의한다
|
||||
- “보여지는 값”과 “정렬되는 값”의 의미가 다르면 문서화한다
|
||||
|
||||
## 6. Filter 표준
|
||||
|
||||
### 6.1 필터는 명시적 query parameter를 기본으로 한다
|
||||
|
||||
Spring MVC @RequestParam은 query parameter 바인딩의 기본 도구이고, 반복 파라미터도 리스트로 받을 수 있습니다. Azure도 filtering을 query 기반으로 제공하라고 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
기본 필터는 명시적 named query parameter로 노출한다
|
||||
|
||||
예:
|
||||
|
||||
- status=ACTIVE
|
||||
- role=ADMIN
|
||||
- keyword=alice
|
||||
- createdFrom=...
|
||||
- createdTo=...
|
||||
- 외부 계약은 읽기 쉬운 이름을 사용한다
|
||||
|
||||
### 6.2 다중 값 필터는 반복 query parameter를 우선한다
|
||||
|
||||
Spring은 같은 이름의 request parameter를 여러 번 보내면 배열/리스트로 받을 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다중 선택 필터 기본 형식은 반복 parameter
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
status=ACTIVE&status=PENDING
|
||||
```
|
||||
|
||||
- comma-separated 형식은 API군 전체 합의가 있을 때만 허용
|
||||
- 같은 API군에서 두 방식을 혼용하지 않는다
|
||||
|
||||
### 6.3 범위 필터는 의미가 드러나는 이름을 쓴다
|
||||
|
||||
이 항목은 주로 Practice + Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 시간 범위: createdFrom, createdTo
|
||||
- 수치 범위: minPrice, maxPrice
|
||||
- 불리언 필터: includeInactive=true
|
||||
- 비교 연산자를 query string DSL로 억지로 숨기기보다 의미가 드러나는 파라미터명을 우선한다
|
||||
|
||||
### 6.4 generic filter DSL은 기본 금지다
|
||||
|
||||
Azure는 query-based filtering을 권장하지만, 모든 API가 OData 수준의 범용 filter 문법을 가져야 한다고 요구하지는 않습니다. 실제 실무에서도 공개 API는 명시적 필터 파라미터를 더 많이 사용합니다. GitHub 역시 endpoint별로 filter, state, sort 등 명시적 파라미터를 사용합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 공개 API에서는 범용 문자열 DSL filter를 도입하지 않는다
|
||||
- 정말 복잡한 검색이 필요하면 별도 search endpoint 또는 명시적 검색 모델을 설계한다
|
||||
- 단순 목록 API를 mini query language로 만들지 않는다
|
||||
|
||||
## 7. 응답 형식 규칙
|
||||
|
||||
### 7.1 page-based 응답은 커스텀 page DTO를 사용한다
|
||||
|
||||
Spring Data는 Page, Slice, Window 같은 내부 추상화를 제공하지만, 공개 API 응답 계약은 그것과 분리하는 편이 안정적입니다. 또한 Page는 count metadata 비용이 있을 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공개 응답은 raw Page<T>를 그대로 노출하지 않는다
|
||||
|
||||
기본 응답 형식 예:
|
||||
|
||||
```text
|
||||
ApiResult<PageResponse<T>>
|
||||
```
|
||||
|
||||
PageResponse<T> 권장 필드:
|
||||
|
||||
- items
|
||||
- page
|
||||
- size
|
||||
- hasNext
|
||||
- totalCount (필요 시만)
|
||||
|
||||
### 7.2 cursor 응답은 opaque cursor를 반환한다
|
||||
|
||||
GitHub는 paginated response에서 다음 페이지 URL 또는 cursor 계열 parameter를 계속 사용하게 하고, Spring Data scrolling은 ScrollPosition을 통해 다음 위치를 이어 갑니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
cursor 응답 기본 형식 예:
|
||||
|
||||
```text
|
||||
ApiResult<CursorPageResponse<T>>
|
||||
```
|
||||
|
||||
CursorPageResponse<T> 권장 필드:
|
||||
|
||||
- items
|
||||
- nextCursor
|
||||
- hasNext
|
||||
- cursor는 내부 정렬 키 원본을 그대로 노출하지 않고 인코딩/추상화한다
|
||||
|
||||
### 7.3 페이지 응답은 현재 조회 조건을 바꾸지 않게 설계한다
|
||||
|
||||
Azure는 모든 페이지에서 같은 filtering options와 sort order를 유지하라고 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 다음 페이지 요청은 같은 filter/sort를 유지해야 한다
|
||||
- cursor 기반이면 cursor 자체에 정렬 문맥이 포함되거나 서버가 이를 안전하게 검증해야 한다
|
||||
- page 기반이면 client가 같은 필터/정렬을 재전송하도록 문서화한다
|
||||
|
||||
## 8. Spring 사용 규칙
|
||||
|
||||
### 8.1 controller는 명시적 request DTO를 우선한다
|
||||
|
||||
Spring Data는 controller argument로 Pageable과 Sort를 직접 받을 수 있게 해 줍니다. 하지만 이 프로젝트는 공개 API 가독성과 계약 안정성을 위해 명시적 request DTO를 우선합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
공개 API controller:
|
||||
|
||||
- ListUsersRequest, SearchSessionsRequest 같은 명시적 query DTO 우선
|
||||
|
||||
내부 관리용/운영용 endpoint:
|
||||
|
||||
- 필요하면 Pageable/Sort 직접 사용 가능
|
||||
|
||||
외부 계약은 Spring Data parameter naming에 종속되지 않게 한다
|
||||
|
||||
### 8.2 repository/application 내부에서는 Pageable/Slice/Window를 사용할 수 있다
|
||||
|
||||
Spring Data는 Page, Slice, Sort, Pageable, Window/scrolling을 모두 지원합니다. 각각은 비용과 의미가 다릅니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
repository 레이어:
|
||||
|
||||
- 일반 paging: Pageable
|
||||
- count 불필요: Slice
|
||||
- 대규모 scrolling/keyset: Window/scroll position 검토
|
||||
- 공개 응답은 커스텀 DTO로 변환한다
|
||||
|
||||
## 9. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 공개 API controller에 raw Pageable/Sort를 무비판적으로 노출
|
||||
- page 기반 응답에서 무조건 totalCount 계산
|
||||
- 큰 목록에 깊은 offset paging을 기본 전략으로 고정
|
||||
- 지원하지 않는 sort field를 조용히 무시
|
||||
- 정렬 기준 없이 cursor pagination 구현
|
||||
- filter/sort가 페이지마다 달라질 수 있게 설계
|
||||
- generic filter DSL을 기본 공개 API에 도입
|
||||
- raw Page<Entity> 또는 Slice<Entity>를 외부 응답으로 직접 반환
|
||||
|
||||
## 10. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 목록 API에 page 방식과 cursor 방식 중 어떤 것이 더 맞는가?
|
||||
- 외부 query contract가 Spring 내부 타입에 종속되지 않는가?
|
||||
- page size 기본값과 최대값이 있는가?
|
||||
- 지원 가능한 sort field가 명시되어 있는가?
|
||||
- unsupported sort 요청을 400으로 처리하는가?
|
||||
- filter/sort가 페이지 이동 중에도 일관되게 유지되는가?
|
||||
- count query 비용이 큰데도 무조건 total count를 계산하고 있지 않은가?
|
||||
- cursor를 쓴다면 stable sort와 tie-breaker가 보장되는가?
|
||||
@@ -0,0 +1,226 @@
|
||||
# Request / Response DTO 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API 요청/응답에 사용하는 DTO의 역할, 위치, 설계 규칙을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- request DTO와 response DTO를 명확히 분리한다.
|
||||
- web transport model과 domain/application model을 섞지 않는다.
|
||||
- controller 바인딩 모델의 보안과 변경 가능성을 통제한다.
|
||||
- 응답 포맷을 domain/entity 구조가 아니라 API 계약 중심으로 설계한다.
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 문서의 확장 지점 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 DTO는 transport model이다
|
||||
|
||||
Spring MVC는 @RequestBody와 @ModelAttribute를 통해 웹 입력을 객체로 바인딩하고, @ResponseBody/ResponseEntity를 통해 객체를 응답으로 직렬화한다. 이 프로젝트에서 DTO는 그 바운더리에서만 쓰는 transport model로 정의한다. DTO는 HTTP 요청/응답 계약을 표현하는 객체이지, domain/entity/application command 자체가 아니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO는 HTTP 입력 계약을 표현한다
|
||||
- response DTO는 HTTP 출력 계약을 표현한다
|
||||
- DTO는 domain/entity/persistence model을 그대로 노출하는 수단이 아니다
|
||||
|
||||
### 3.2 request DTO와 response DTO를 분리한다
|
||||
|
||||
Spring 공식 문서는 @ModelAttribute 대상에 대해 웹 바인딩 전용 객체 사용을 권장한다. 이 원칙을 request/response 전체로 확장하면, 읽기 모델과 쓰기 모델을 분리하는 것이 안전하고 유지보수성이 높다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 하나의 DTO를 request와 response에 동시에 재사용하지 않는다
|
||||
- create/update 입력 DTO와 조회 응답 DTO를 분리한다
|
||||
- “필드가 비슷하니까 같은 DTO”를 기본값으로 두지 않는다
|
||||
|
||||
### 3.3 entity와 DTO를 섞지 않는다
|
||||
|
||||
Spring은 @ModelAttribute 바인딩 모델 설계 시 보안을 고려해 전용 모델 객체 또는 constructor binding only를 권장한다. 이는 웹 바인딩 대상을 domain/entity와 분리하라는 실무 방향과 맞닿아 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- entity를 @RequestBody 대상으로 쓰지 않는다
|
||||
- entity를 @ModelAttribute 대상으로 쓰지 않는다
|
||||
- entity를 API response body로 직접 반환하지 않는다
|
||||
- domain object를 외부 계약 모델로 직접 노출하지 않는다
|
||||
|
||||
## 4. Request DTO 표준
|
||||
|
||||
### 4.1 JSON body는 전용 request DTO로 받는다
|
||||
|
||||
Spring 공식 문서 기준으로 @RequestBody는 요청 본문을 HttpMessageConverter로 객체에 역직렬화한다. 따라서 JSON API 입력은 전용 request DTO에 받는 것이 기본이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON body는 전용 request DTO에 받는다
|
||||
- request DTO는 controller boundary에서만 사용한다
|
||||
- request DTO를 그대로 domain/service 내부에 전파하지 않는다
|
||||
- controller 또는 mapper에서 application/domain 입력으로 변환한다
|
||||
|
||||
### 4.2 form/query/path 기반 입력도 web 전용 모델로 받는다
|
||||
|
||||
Spring 공식 문서 기준으로 @ModelAttribute는 request parameters, path variables, headers를 모델 객체에 바인딩할 수 있다. Spring은 이 모델을 웹 바인딩 전용으로 설계하거나 constructor binding only를 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- query/form/multipart 조합 입력도 전용 request model 사용을 우선 검토한다
|
||||
- @ModelAttribute 대상은 web binding 전용 객체로 제한한다
|
||||
- setter/property binding을 허용해야 한다면 바인딩 가능한 필드를 의식적으로 통제한다
|
||||
|
||||
### 4.3 request DTO는 입력 계약만 표현한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO에는 HTTP 입력 필드만 둔다
|
||||
- 서버 내부 계산값, 조회 결과, 인증 결과, 저장 전용 필드를 넣지 않는다
|
||||
- “나중에 응답에도 쓸 수 있으니 미리 넣어두기”를 금지한다
|
||||
|
||||
이 규칙은 Practice + Project Recommendation 이다.
|
||||
|
||||
### 4.4 request DTO는 validation 경계를 드러낼 수 있어야 한다
|
||||
|
||||
Spring MVC는 @RequestBody/@ModelAttribute 입력에 @Valid/@Validated 검증을 적용할 수 있다. 따라서 request DTO는 request shape validation이 걸릴 자리라는 점이 명확해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO에는 입력 형식 검증에 필요한 constraint를 둘 수 있다
|
||||
- business rule을 request DTO constraint에 과도하게 숨기지 않는다
|
||||
- request DTO validation 통과를 domain correctness의 보장으로 간주하지 않는다
|
||||
|
||||
### 4.5 request DTO는 API 변경에 안전해야 한다
|
||||
|
||||
Spring이 전용 바인딩 객체와 constructor binding을 권장하는 이유는 바인딩 범위를 명시적으로 통제하기 위함이다. 이 프로젝트에서는 이를 API 변경 안전성 원칙으로 확장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO는 허용된 입력만 명시적으로 받는다
|
||||
- 무분별한 setter/property 확장을 지양한다
|
||||
- 클라이언트가 보내면 안 되는 필드가 우연히 열리지 않도록 설계한다
|
||||
|
||||
## 5. Response DTO 표준
|
||||
|
||||
### 5.1 응답은 response DTO 또는 표준 envelope로 반환한다
|
||||
|
||||
Spring MVC는 @ResponseBody와 ResponseEntity를 통해 객체를 응답으로 직렬화한다. 이 프로젝트에서는 응답 body를 domain/entity가 아니라 response DTO 또는 ApiResult<ResponseDto> 같은 표준 envelope로 반환한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 응답 본문은 response DTO 또는 ApiResult<ResponseDto>로 표현한다
|
||||
- entity, aggregate, persistence projection을 그대로 반환하지 않는다
|
||||
- Map<String, Object> 기반 임시 응답은 승인 후보 코드에서 지양한다
|
||||
|
||||
### 5.2 response DTO는 외부 계약 중심으로 설계한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- response DTO는 클라이언트가 실제로 필요한 필드만 포함한다
|
||||
- 내부 식별자, 상태값, 구현 세부사항을 무분별하게 노출하지 않는다
|
||||
- domain 모델 필드 구조를 그대로 따라가는 것을 기본값으로 두지 않는다
|
||||
|
||||
이 항목은 Practice + Project Recommendation 이다.
|
||||
|
||||
### 5.3 request DTO와 response DTO를 상호 재사용하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 생성 요청 DTO를 조회 응답 DTO로 재사용하지 않는다
|
||||
- 응답에서만 필요한 필드와 요청에서만 필요한 필드를 분리한다
|
||||
- “대칭 구조”를 위해 무의미한 필드를 추가하지 않는다
|
||||
|
||||
## 6. 매핑 규칙
|
||||
|
||||
### 6.1 DTO ↔ domain/application 변환은 명시적으로 한다
|
||||
|
||||
Spring의 바인딩/직렬화는 controller boundary까지의 편의를 제공하지만, DTO를 domain/application 모델로 자동 동일시하지는 않는다. 따라서 DTO ↔ 내부 모델 변환은 명시적으로 관리한다. 이 규칙은 Spring의 boundary-oriented programming model에 대한 Project Recommendation 이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO → application input 변환은 controller 또는 전용 mapper가 담당한다
|
||||
- domain/application result → response DTO 변환도 명시적으로 수행한다
|
||||
- mapper는 transport concern과 domain concern을 구분해서 작성한다
|
||||
|
||||
### 6.2 controller가 DTO를 내부 모델처럼 들고 다니지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- use case 메서드 시그니처에 request DTO를 그대로 넘기지 않는다
|
||||
- application/domain이 response DTO를 직접 생성하지 않는다
|
||||
- DTO는 presentation 경계 안에서 생성/소비를 끝내는 쪽을 기본값으로 둔다
|
||||
|
||||
## 7. 바인딩 보안 규칙
|
||||
|
||||
### 7.1 @ModelAttribute 대상은 특히 더 조심한다
|
||||
|
||||
Spring 공식 문서는 @ModelAttribute에서 전용 웹 바인딩 객체 또는 constructor binding only를 권장하고, property binding이 필요하면 allowedFields로 제한하라고 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- @ModelAttribute 대상은 전용 DTO로 둔다
|
||||
- mutable property binding을 열어야 한다면 바인딩 가능한 필드를 통제한다
|
||||
- 도메인 객체나 영속 객체를 @ModelAttribute로 받지 않는다
|
||||
|
||||
### 7.2 request DTO에 서버 소유 필드를 두지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- userId, role, status, createdAt, updatedAt 같은 서버 소유 값은 request DTO에 기본적으로 두지 않는다
|
||||
- 필요한 경우에도 클라이언트 입력값과 서버 결정값의 출처를 명확히 분리한다
|
||||
|
||||
이 항목은 Practice + Project Recommendation 이다.
|
||||
|
||||
## 8. 반환/응답 제어 규칙
|
||||
|
||||
### 8.1 ResponseEntity는 전체 응답 제어가 필요할 때 사용한다
|
||||
|
||||
Spring 공식 문서 기준으로 ResponseEntity는 status, headers, body를 함께 지정하는 전체 응답 표현이다. 따라서 단순 성공 200 응답에는 항상 필요하지 않다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 성공 응답은 DTO 또는 ApiResult 반환으로 충분하면 그렇게 한다
|
||||
- 다음 경우에는 ResponseEntity를 사용한다
|
||||
- 201 Created + Location
|
||||
- 204 No Content
|
||||
- custom header
|
||||
- 캐시/조건부 응답
|
||||
- 파일 다운로드/streaming 등 HTTP 제어가 중요한 경우
|
||||
|
||||
### 8.2 공통 envelope는 DTO 설계와 충돌하지 않게 한다
|
||||
|
||||
Spring의 ResponseBodyAdvice는 응답 body 공통 가공 지점이다. 따라서 ApiResult 같은 공통 응답 envelope를 쓴다면 response DTO 설계와 중복/충돌이 없도록 해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- response DTO는 business payload를 표현한다
|
||||
- ApiResult는 공통 wrapper 역할에 집중한다
|
||||
- response DTO 안에 다시 status/code/message 같은 공통 wrapper 성격 필드를 중복으로 넣지 않는다
|
||||
|
||||
## 9. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- entity를 request DTO로 사용
|
||||
- entity를 response DTO로 직접 노출
|
||||
- request/response DTO를 하나로 합치기
|
||||
- request DTO를 application/domain 메서드 시그니처에 그대로 넘기기
|
||||
- response DTO 생성을 domain/application 안에서 직접 수행하기
|
||||
- 임시 Map<String, Object> 응답 남발
|
||||
- @ModelAttribute 대상에 무분별한 property binding 열기
|
||||
- DTO에 서버 소유 필드를 무심코 포함시키기
|
||||
|
||||
## 10. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 객체는 request용인가, response용인가가 명확한가?
|
||||
- web transport model과 domain/entity가 분리되어 있는가?
|
||||
- JSON body라면 @RequestBody 전용 DTO인가?
|
||||
- form/query binding이라면 @ModelAttribute 전용 DTO인가?
|
||||
- response DTO가 외부 계약 중심으로 설계되어 있는가?
|
||||
- DTO가 서버 내부 정책/엔티티 구조를 그대로 노출하지 않는가?
|
||||
- DTO ↔ 내부 모델 변환 위치가 명확한가?
|
||||
@@ -0,0 +1,251 @@
|
||||
# Response Format 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API 응답 본문의 형식과 공통 규약을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 성공/실패 응답의 구조를 일관되게 만든다
|
||||
- controller마다 제각각인 응답 body 형식을 막는다
|
||||
- HTTP status와 응답 body의 역할을 구분한다
|
||||
- 공통 응답 envelope와 실제 business payload의 책임을 분리한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 문서의 확장 지점 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 이 프로젝트의 JSON API 기본 응답 형식은 ApiResult<T>다
|
||||
|
||||
Spring은 응답 body를 @ResponseBody/ResponseEntity로 직렬화하고, 필요하면 ResponseBodyAdvice로 body를 공통 가공할 수 있게 한다. 따라서 프로젝트는 Spring의 공식 응답 처리 지점을 그대로 사용하되, 실제 JSON 응답 본문 형식은 custom envelope인 ApiResult<T>로 표준화한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 JSON API 응답의 기본 형식은 ApiResult<T>
|
||||
- controller마다 서로 다른 임의 JSON 구조를 만들지 않는다
|
||||
- ApiResult는 transport-level envelope이고, 실제 payload는 T가 담당한다
|
||||
|
||||
### 3.2 응답 형식은 “공통 envelope”와 “실제 data”를 분리한다
|
||||
|
||||
Spring 공식 문서가 ResponseEntity와 body object를 분리해서 다루는 구조를 제공하는 것처럼, 이 프로젝트도 응답의 공통 필드와 business payload를 분리한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공통 응답 정보는 ApiResult가 담당
|
||||
- 실제 비즈니스 데이터는 data가 담당
|
||||
- business DTO 안에 다시 success, code, message를 중복으로 넣지 않는다
|
||||
|
||||
### 3.3 응답 형식은 전역 규약이어야 한다
|
||||
|
||||
Spring은 ResponseBodyAdvice를 통해 @ResponseBody나 ResponseEntity 응답을 전역적으로 가공할 수 있다. 즉, 응답 형식 통일은 controller 개별 구현이 아니라 프레임워크 확장 지점에서 중앙 관리할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 응답 형식 표준화는 controller마다 수동으로 맞추는 것보다 공통 규약으로 관리한다
|
||||
- 같은 API 군 안에서는 성공/실패 응답 형식이 일관되어야 한다
|
||||
- endpoint마다 envelope 유무가 달라지는 surprise를 만들지 않는다
|
||||
|
||||
## 4. 표준 응답 구조
|
||||
|
||||
### 4.1 성공 응답
|
||||
|
||||
프로젝트 기본 형식 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": "SUCCESS",
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"userId": "u_123",
|
||||
"email": "user@example.com"
|
||||
},
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 성공 응답은 success=true
|
||||
- 성공 응답의 표준 code는 기본적으로 SUCCESS
|
||||
- 실제 payload는 data
|
||||
- 부가 정보가 필요하면 meta 사용 가능
|
||||
- 단순 성공이더라도 응답 구조를 임의로 바꾸지 않는다
|
||||
|
||||
이 항목은 Project Recommendation 이다.
|
||||
|
||||
### 4.2 실패 응답
|
||||
|
||||
Spring은 예외를 HTTP 응답으로 렌더링하는 공식 지점으로 @ExceptionHandler, @ControllerAdvice, ResponseEntityExceptionHandler를 제공한다. 이 프로젝트는 그 지점을 사용해 실패 응답도 ApiResult 형식으로 통일한다.
|
||||
|
||||
프로젝트 기본 형식 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"code": "REQUEST_VALIDATION_FAILED",
|
||||
"message": "Request validation failed",
|
||||
"data": {
|
||||
"email": "must not be blank"
|
||||
},
|
||||
"meta": {
|
||||
"requestId": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 실패 응답은 success=false
|
||||
- 실패 원인 식별자는 반드시 code에 둔다
|
||||
- 외부 노출 메시지는 message
|
||||
- 상세 오류 정보가 필요하면 data 또는 별도 표준 필드에 둔다
|
||||
- 내부 예외 스택트레이스, 클래스명, 민감정보를 응답 body에 넣지 않는다
|
||||
|
||||
## 5. ApiResult 설계 규칙
|
||||
|
||||
### 5.1 envelope는 얇고 안정적이어야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ApiResult 필드는 최소한으로 유지한다
|
||||
- envelope 구조는 쉽게 자주 바꾸지 않는다
|
||||
- 응답 본문 규약은 business DTO보다 더 안정적인 계약으로 다룬다
|
||||
|
||||
권장 기본 필드:
|
||||
|
||||
- success
|
||||
- code
|
||||
- message
|
||||
- data
|
||||
- meta (선택)
|
||||
|
||||
### 5.2 공통 필드와 business 필드를 섞지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ApiResult 바깥과 data 안의 의미를 섞지 않는다
|
||||
- pagination, cursor, totalCount 같은 응답 보조 정보는 규칙적으로 meta 또는 명시적 pagination DTO에 둔다
|
||||
- business payload 안에 공통 상태 필드를 섞어 넣지 않는다
|
||||
|
||||
### 5.3 code는 문자열이지만 정책적으로 중앙 관리한다
|
||||
|
||||
이 문서는 응답 형식 문서이므로 code 체계 자체의 상세 규칙은 다음 문서인 Error Code/HTTP Status Separation Standard에서 다룬다. 다만 응답 형식 관점에서 code는 항상 존재하는 공통 식별자여야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 성공/실패 모두 code 필드를 가진다
|
||||
- code는 advice/controller에서 임의 문자열로 흩뿌리지 않는다
|
||||
- ErrorCode 같은 중앙 정책 타입을 통해 관리한다
|
||||
|
||||
## 6. ResponseEntity 사용 규칙
|
||||
|
||||
Spring 공식 문서 기준으로 ResponseEntity는 headers, body, status를 함께 지정하는 반환형이다. 따라서 전체 HTTP 응답을 제어할 필요가 있을 때 의미가 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순 200 JSON 응답이면 꼭 ResponseEntity를 강제하지 않는다
|
||||
- 다음 경우에는 ResponseEntity를 사용한다
|
||||
- 201 Created
|
||||
- 204 No Content
|
||||
- custom header
|
||||
- 캐시/조건부 응답
|
||||
- 다운로드/streaming
|
||||
- endpoint별 status 제어가 중요한 경우
|
||||
|
||||
### 6.1 body 표준화와 ResponseEntity는 충돌하지 않아야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ResponseEntity<ApiResult<T>>는 허용된다
|
||||
- 다만 ResponseEntity는 HTTP 제어용이고, ApiResult는 body 규약용이라는 역할 분리를 유지한다
|
||||
- controller가 HTTP 제어도 없는데 습관적으로 ResponseEntity<ApiResult<T>>를 남발하지 않는다
|
||||
|
||||
## 7. ResponseBodyAdvice 사용 규칙
|
||||
|
||||
Spring의 ResponseBodyAdvice는 @ResponseBody 또는 ResponseEntity controller method 실행 후, HttpMessageConverter가 body를 쓰기 전에 응답을 커스터마이징하는 확장 지점이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 전역 envelope 적용이 필요하면 ResponseBodyAdvice를 사용할 수 있다
|
||||
- 이미 ApiResult인 응답은 다시 감싸지 않는다
|
||||
- file response, streaming response, SSE, 이미 형식이 고정된 외부 계약 응답은 전역 래핑 대상에서 제외한다
|
||||
- supports(...) 조건은 넓게 열기보다 명시적으로 제어한다
|
||||
|
||||
### 7.1 전역 래핑은 “마법”이 아니라 명시적 규약이어야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 팀이 “모든 JSON 성공 응답을 자동으로 ApiResult.success(...)로 감싼다”는 규칙을 합의한 경우에만 전역 래핑을 쓴다
|
||||
- 그렇지 않으면 controller가 명시적으로 ApiResult를 반환하게 한다
|
||||
- 두 방식이 혼재되면 응답 규약 이해 비용이 커지므로 기본 전략 하나를 정한다
|
||||
|
||||
## 8. 예외 응답 형식 규칙
|
||||
|
||||
Spring은 @ControllerAdvice/@ExceptionHandler, ResponseEntityExceptionHandler, DefaultHandlerExceptionResolver 등을 통해 예외를 HTTP 응답으로 연결할 수 있다. 이 프로젝트는 그 공식 메커니즘 위에서 예외 응답도 ApiResult 형식으로 통일한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공통 예외 응답은 @RestControllerAdvice에서 생성한다
|
||||
- controller 안에서 실패 응답 body를 직접 조립하는 것을 기본 금지한다
|
||||
- framework 예외와 business 예외가 서로 다른 JSON 구조를 가지지 않게 한다
|
||||
|
||||
### 8.1 실패 응답의 message는 외부 노출용이어야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- message는 클라이언트에 보여줄 수 있는 수준으로 제한한다
|
||||
- ex.getMessage()를 그대로 외부에 노출하는 것을 기본값으로 두지 않는다
|
||||
- 내부 로그 메시지와 외부 응답 메시지를 분리한다
|
||||
|
||||
## 9. 예외적 응답 형식
|
||||
|
||||
### 9.1 envelope를 적용하지 않는 응답
|
||||
|
||||
Spring MVC는 body object뿐 아니라 HttpHeaders, file/streaming 관련 반환형 등도 지원한다. 모든 응답이 JSON envelope여야 하는 것은 아니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 ApiResult envelope 적용 대상에서 제외할 수 있다.
|
||||
|
||||
- 파일 다운로드
|
||||
- binary response
|
||||
- streaming/SSE
|
||||
- redirect
|
||||
- 204 No Content
|
||||
- 외부 표준 계약이 별도 형식을 강제하는 응답
|
||||
|
||||
### 9.2 HTML/view 응답과 JSON API 응답을 섞지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON API는 ApiResult 또는 명시적 API 응답 규약을 따른다
|
||||
- view rendering 응답은 별도 controller/경계로 분리한다
|
||||
- 한 controller 안에서 HTML 응답 규약과 JSON envelope 규약을 섞지 않는다
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- endpoint마다 제각각 다른 성공/실패 JSON 형식 사용
|
||||
- controller 안에서 임시 Map<String, Object>로 응답 구조 조립
|
||||
- ApiResult 바깥과 data 안에 공통 필드 중복
|
||||
- ResponseBodyAdvice에서 무조건 이중 래핑
|
||||
- 예외 메시지를 그대로 외부 응답에 노출
|
||||
- HTTP status와 응답 body code/message 역할을 뒤섞기
|
||||
- 파일/스트리밍 응답까지 무리하게 JSON envelope로 감싸기
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 응답은 프로젝트 표준 envelope(ApiResult)를 따르는가?
|
||||
- ApiResult와 business payload의 역할이 분리되어 있는가?
|
||||
- ResponseEntity를 쓰는 이유가 status/header 제어 때문인가?
|
||||
- 전역 응답 래핑이 있다면 이중 래핑을 막고 있는가?
|
||||
- 실패 응답도 성공 응답과 같은 큰 형식을 유지하는가?
|
||||
- envelope 예외 대상(파일, 스트리밍 등)을 따로 처리하고 있는가?
|
||||
@@ -0,0 +1,272 @@
|
||||
# Serialization / Jackson 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 API JSON 직렬화/역직렬화와 Jackson 사용 기준을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- JSON 계약을 DTO 중심으로 안정적으로 관리한다
|
||||
- Jackson 설정과 애노테이션 사용을 일관되게 만든다
|
||||
- domain/entity에 transport concern이 스며들지 않게 한다
|
||||
- 날짜/시간, null, unknown field, 민감 필드, 커스텀 serializer의 기준을 명확히 한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot / Jackson 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 확장 지점 위에 일반적인 실무 API 설계 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 직렬화는 web boundary concern이다
|
||||
|
||||
Spring MVC에서 JSON 직렬화/역직렬화는 HttpMessageConverter가 담당하고, Jackson converter는 typed bean이나 untyped map을 JSON으로 읽고 쓸 수 있습니다. 따라서 이 프로젝트에서 serialization/Jackson 규칙은 presentation/web boundary concern 으로 본다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON 계약은 controller boundary의 request/response DTO가 중심이다
|
||||
- domain/entity가 JSON 계약의 중심이 되지 않는다
|
||||
- Jackson 규칙 때문에 domain 모델을 뒤틀지 않는다
|
||||
|
||||
### 3.2 DTO 설계가 Jackson 애노테이션보다 우선한다
|
||||
|
||||
Spring과 Jackson은 애노테이션으로 매핑을 많이 바꿀 수 있게 해 주지만, 이 프로젝트의 기본값은 애노테이션으로 억지로 맞추기보다 DTO를 명시적으로 분리하는 것이다. Spring이 @JsonView, mixin, custom mapper를 지원하더라도, 그 지원 자체가 곧 그것을 기본 설계 수단으로 삼으라는 뜻은 아니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- request DTO / response DTO 분리를 기본값으로 둔다
|
||||
- Jackson 애노테이션은 보조 수단 이다
|
||||
- JSON shape를 맞추기 위해 entity/domain에 애노테이션을 덕지덕지 붙이지 않는다
|
||||
|
||||
### 3.3 전역 규칙은 mapper 설정으로, 예외는 DTO에서 처리한다
|
||||
|
||||
Spring Boot는 Jackson mapper를 자동 구성하고 다양한 spring.jackson.* 설정을 제공한다. 즉, naming, inclusion, timezone, visibility 같은 공통 규칙은 전역 mapper 정책으로 관리할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 공통 규칙은 전역 Jackson 설정으로 관리한다
|
||||
- 특정 계약 예외만 DTO/필드 레벨 애노테이션으로 처리한다
|
||||
- controller마다 new ObjectMapper()를 만들어 제각각 직렬화하지 않는다
|
||||
|
||||
## 4. DTO 우선 규칙
|
||||
|
||||
### 4.1 request/response DTO가 JSON 계약의 source of truth다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON 요청 형식은 request DTO가 정의한다
|
||||
- JSON 응답 형식은 response DTO가 정의한다
|
||||
- entity/domain/persistence model을 직렬화 계약의 source of truth로 두지 않는다
|
||||
|
||||
### 4.2 entity와 Jackson 애노테이션을 결합하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JPA entity를 직접 JSON 응답으로 내보내지 않는다
|
||||
- entity에 @JsonIgnore, @JsonManagedReference, @JsonBackReference 같은 애노테이션으로 API 문제를 해결하는 것을 기본 금지한다
|
||||
- 엔티티 순환 참조, lazy loading, 내부 식별자 노출 문제는 DTO 변환으로 해결한다
|
||||
|
||||
### 4.3 request DTO와 response DTO를 하나로 합치지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 하나의 DTO를 입력/출력 겸용으로 두는 것을 기본 금지한다
|
||||
- 비밀번호, 내부 상태, 서버 소유 필드 같은 방향성 차이는 DTO 분리로 해결한다
|
||||
- @JsonProperty(access = WRITE_ONLY/READ_ONLY)는 예외적 escape hatch일 뿐, 기본 설계 수단이 아니다
|
||||
|
||||
## 5. 필드 이름 규칙
|
||||
|
||||
### 5.1 기본 naming은 lowerCamelCase다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 내부 API JSON 기본 naming은 lowerCamelCase
|
||||
- 프로젝트 전체 기본 naming 전략은 하나로 유지한다
|
||||
- DTO마다 제각각 snake_case / kebab-case / camelCase를 섞지 않는다
|
||||
|
||||
### 5.2 외부 계약 이름 변경은 @JsonProperty로 국소화한다
|
||||
|
||||
Jackson의 @JsonProperty는 외부에 노출할 property name을 지정하는 데 사용할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 공급자 계약이나 레거시 호환 때문에 필드명이 다를 때만 @JsonProperty를 사용한다
|
||||
- 내부 표준 naming을 DTO 전체에 포기하지 않는다
|
||||
- naming mismatch를 해결하려고 domain 필드명을 외부 계약에 맞춰 바꾸지 않는다
|
||||
|
||||
## 6. 날짜/시간 규칙
|
||||
|
||||
### 6.1 public timestamp는 java.time + ISO-8601 문자열을 기본으로 한다
|
||||
|
||||
JavaTimeModule은 java.time 타입을 지원하며, WRITE_DATES_AS_TIMESTAMPS가 꺼져 있으면 대부분의 java.time 타입을 ISO-8601 문자열로 직렬화합니다. Spring Boot Actuator API도 timestamp 입력을 ISO 8601 offset date-time으로 요구합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- public timestamp 기본값은 ISO-8601 문자열
|
||||
- epoch number timestamp를 공개 API 기본값으로 두지 않는다
|
||||
- java.util.Date보다 java.time 타입을 우선한다
|
||||
|
||||
### 6.2 시점(timestamp)은 OffsetDateTime 또는 Instant를 우선한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 시스템과 교환하는 timestamp는 OffsetDateTime 또는 Instant를 우선 검토한다
|
||||
- 시간대 정보가 없는 LocalDateTime을 public timestamp 기본값으로 두지 않는다
|
||||
- 날짜만 필요하면 LocalDate
|
||||
- 시간만 의미가 있으면 정말 필요한 경우에만 LocalTime
|
||||
|
||||
### 6.3 전역 timezone/date-format은 명시적으로 관리한다
|
||||
|
||||
Spring Boot는 spring.jackson.date-format, spring.jackson.time-zone 같은 전역 설정 프로퍼티를 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 시간 직렬화 정책은 전역 설정 또는 공통 mapper 설정으로 명시한다
|
||||
- DTO별 임의 @JsonFormat 남발을 지양한다
|
||||
- 특정 필드만 예외 형식이 필요한 경우에만 필드 레벨 포맷을 둔다
|
||||
|
||||
## 7. null / absent 규칙
|
||||
|
||||
### 7.1 null omission은 계약 변경 효과가 있으므로 신중하게 쓴다
|
||||
|
||||
Jackson의 @JsonInclude와 Boot의 spring.jackson.default-property-inclusion은 null/empty 값을 응답에서 제외하도록 설정할 수 있습니다. 하지만 필드 omission은 단순 직렬화 최적화가 아니라 응답 계약 의미 변경 이 될 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 전역 NON_NULL을 무비판적으로 켜지 않는다
|
||||
- 필드 omission이 API 의미상 “존재하지 않음”을 뜻할 때만 선택적으로 쓴다
|
||||
- “null일 때 숨기면 보기 좋다”는 이유만으로 계약을 흔들지 않는다
|
||||
|
||||
### 7.2 envelope와 payload의 null 정책을 구분한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- ApiResult 같은 공통 envelope 필드는 가능한 안정적으로 유지한다
|
||||
- business payload 필드 omission 여부는 DTO 계약 단위에서 결정한다
|
||||
- data, meta, 상세 필드의 null/absent 정책을 뒤섞지 않는다
|
||||
|
||||
## 8. unknown property 규칙
|
||||
|
||||
### 8.1 Spring 기본 동작에 기대기보다 프로젝트 정책을 명시한다
|
||||
|
||||
Spring의 Jackson2ObjectMapperBuilder 기본값은 FAIL_ON_UNKNOWN_PROPERTIES를 끄고, Jackson의 @JsonIgnoreProperties(ignoreUnknown=true)도 unknown input field를 무시하는 용도로 쓸 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- unknown property 정책은 API군 단위의 명시적 정책 으로 둔다
|
||||
- DTO마다 제멋대로 strict / lenient를 섞지 않는다
|
||||
|
||||
### 8.2 first-party API request DTO는 기본적으로 strict를 권장한다
|
||||
|
||||
프로젝트 권장 규칙:
|
||||
|
||||
- 우리가 소유한 public/internal API request DTO는 기본적으로 unknown field를 실패 처리 하도록 권장
|
||||
- client 오타, 잘못된 계약 사용, 조용한 무시를 빨리 발견하는 쪽을 선호한다
|
||||
- 필요하면 Spring 기본값을 프로젝트 정책에 맞게 override한다
|
||||
|
||||
### 8.3 external webhook / third-party callback DTO는 lenient를 허용한다
|
||||
|
||||
Jackson의 @JsonIgnoreProperties(ignoreUnknown=true)는 deserialization 시 인식하지 못하는 필드를 무시하도록 하는 공식 수단입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 공급자 webhook, callback, third-party response DTO는 ignoreUnknown=true를 허용할 수 있다
|
||||
- 외부가 필드를 추가해도 우리 파싱이 깨지지 않아야 하는 integration DTO에 한해 사용한다
|
||||
- 이 경우에도 first-party API request DTO와 같은 기준으로 섞지 않는다
|
||||
|
||||
## 9. 애노테이션 사용 규칙
|
||||
|
||||
### 9.1 @JsonProperty(access = ...)는 예외적으로만 쓴다
|
||||
|
||||
Jackson은 property access를 read-only / write-only / read-write로 제어할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- WRITE_ONLY는 비밀번호처럼 입력만 받고 출력하면 안 되는 필드에 한해 제한적으로 사용
|
||||
- READ_ONLY는 서버 계산값처럼 응답에는 나가지만 입력받으면 안 되는 필드에 한해 제한적으로 사용
|
||||
- 기본 해결책은 여전히 request/response DTO 분리다
|
||||
|
||||
### 9.2 @JsonView는 public API 기본 설계 수단으로 쓰지 않는다
|
||||
|
||||
Spring MVC는 @JsonView를 controller method에서 지원하지만, 메서드당 직접 지정 가능한 view는 하나이며, 여러 shape를 장기 유지하는 public API 계약 관리에는 DTO 분리가 더 명확하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- @JsonView를 public API summary/detail/versioning의 기본 수단으로 사용하지 않는다
|
||||
- endpoint별 shape 차이는 별도 response DTO로 표현한다
|
||||
- @JsonView는 관리용/내부용 제한된 케이스에서만 예외적으로 검토한다
|
||||
|
||||
### 9.3 @JsonIgnore는 마지막 수단으로 쓴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 필드 숨김 문제를 @JsonIgnore로 즉석에서 막기보다 DTO 구조를 먼저 재검토한다
|
||||
- @JsonIgnore가 많아지면 DTO/entity 책임이 흐려졌다는 신호로 본다
|
||||
|
||||
## 10. 커스텀 serializer/deserializer 규칙
|
||||
|
||||
### 10.1 교차 절단(cross-cutting) 직렬화는 전역 구성요소로 등록한다
|
||||
|
||||
Spring MVC는 custom JsonMapper/builder를 converter에 주입할 수 있고, Spring Boot는 전역 mapper 설정과 커스터마이저, 모듈, mixin 등록 지점을 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 여러 DTO에서 반복되는 직렬화 규칙은 전역 Jackson 설정/모듈/커스터마이저로 올린다
|
||||
- controller 안에서 ad-hoc serializer를 만들지 않는다
|
||||
- DTO 한두 개만을 위한 국소 예외는 DTO 애노테이션으로 처리할 수 있다
|
||||
|
||||
### 10.2 third-party 타입 수정은 mixin을 우선 검토한다
|
||||
|
||||
Spring Boot는 @JacksonMixin을 스캔해 auto-configured mapper에 등록할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 직접 수정할 수 없는 third-party 타입의 직렬화 변경은 mixin을 우선 검토한다
|
||||
- 우리 코드의 DTO에까지 mixin을 남발하지 않는다
|
||||
- mixin은 “타입 소유권이 우리에게 없을 때”의 수단이다
|
||||
|
||||
## 11. ObjectMapper 사용 규칙
|
||||
|
||||
### 11.1 controller에서 new ObjectMapper()를 만들지 않는다
|
||||
|
||||
Spring MVC와 Boot는 이미 message converter와 전역 mapper 구성을 제공한다. controller가 직접 새 mapper를 만들면 전역 규칙, module, naming, inclusion, time 설정을 우회하기 쉽다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller/service에서 new ObjectMapper() 금지
|
||||
- 정말 수동 직렬화가 필요하면 주입된 공용 mapper 또는 전용 serializer 컴포넌트를 사용한다
|
||||
- “이 endpoint만 예외”를 위해 로컬 mapper를 만들지 않는다
|
||||
|
||||
### 11.2 테스트도 production mapper와 같은 규칙을 검증한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- serialization contract가 중요한 DTO는 직렬화/역직렬화 테스트를 둔다
|
||||
- production과 다른 임시 mapper 설정으로 테스트하지 않는다
|
||||
- 날짜, null, enum, unknown property, field name 같은 계약 포인트를 테스트한다
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- entity를 직접 JSON request/response 모델로 사용
|
||||
- controller에서 new ObjectMapper() 생성
|
||||
- @JsonView를 public API versioning/shape 관리 기본 수단으로 사용
|
||||
- 전역 NON_NULL 같은 omission 정책을 계약 검토 없이 켜기
|
||||
- unknown property strict/lenient 정책을 DTO마다 제각각 섞기
|
||||
- 외부 계약 이름 변경 문제를 domain/entity 필드명 변경으로 해결
|
||||
- 민감 필드 숨김을 @JsonIgnore만으로 땜질
|
||||
- 숫자 timestamp를 public API 기본값으로 사용
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 JSON 계약은 DTO가 정의하고 있는가?
|
||||
- entity/domain에 Jackson concern이 새어 나오지 않았는가?
|
||||
- 날짜/시간이 ISO-8601 + 적절한 java.time 타입으로 표현되는가?
|
||||
- null omission이 계약 의도를 반영하는가?
|
||||
- unknown property 정책이 API군 단위로 일관적인가?
|
||||
- @JsonProperty, @JsonIgnore, @JsonView 사용이 정말 필요한 예외인가?
|
||||
- 전역 mapper 규칙을 우회하는 로컬 ObjectMapper가 없는가?
|
||||
Reference in New Issue
Block a user