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 문제와 분리해서 생각하고 있는가?
|
||||
Reference in New Issue
Block a user