init: 클린 기반 auth 서버 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:30:18 +09:00
parent 471db0203d
commit 8a1ac1e769
3642 changed files with 275893 additions and 1 deletions
+254
View File
@@ -0,0 +1,254 @@
# Audit Columns 예시
## 좋은 예시
### 예시 1. 최소 감사 컬럼은 created_at, updated_at을 공통으로 둔다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
status text NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT current_timestamp,
updated_at timestamp with time zone NOT NULL DEFAULT current_timestamp
);
```
**좋은 이유:**
- 생성/수정 시각을 공통 규칙으로 표준화한다
- 두 컬럼 모두 절대 시점 타입을 사용한다
- insert 시점 기본값을 DB가 일관되게 채운다
PostgreSQL은 `CURRENT_TIMESTAMP`를 timestamp column default의 대표 예시로 설명하고, default expression은 row 삽입 시 평가된다고 설명한다. `CURRENT_TIMESTAMP`/`now()`는 transaction start time 의미를 갖는다.
### 예시 2. updated_at은 DB trigger 하나만 source of truth로 둔다
```sql
CREATE OR REPLACE FUNCTION common_set_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at := current_timestamp;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_set_updated_at
BEFORE UPDATE ON auth.users
FOR EACH ROW
WHEN (OLD.* IS DISTINCT FROM NEW.*)
EXECUTE FUNCTION common_set_updated_at();
```
**좋은 이유:**
- `updated_at` 갱신 책임이 DB에 명확하게 모인다
- BEFORE ROW trigger가 NEW를 수정해 반환하는 PostgreSQL 공식 모델과 맞다
- `WHEN (OLD.* IS DISTINCT FROM NEW.*)`로 실제 변경이 있을 때만 갱신하게 만들 수 있다
PostgreSQL은 BEFORE row trigger가 NEW를 수정하고 반환할 수 있다고 설명하고, `WHEN (OLD.* IS DISTINCT FROM NEW.*)` 예시도 공식 문서에 제공한다.
### 예시 3. 주체 컬럼은 애플리케이션 principal 식별자로 둔다
```java
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "users", schema = "auth")
public class User {
@Id
private Long id;
@CreatedBy
@Column(name = "created_by")
private Long createdBy;
@LastModifiedBy
@Column(name = "updated_by")
private Long updatedBy;
}
```
**좋은 이유:**
- `created_by`, `updated_by`를 DB 연결 계정이 아니라 애플리케이션 actor 식별자로 다룬다
- Spring Data JPA의 표준 auditing metadata를 사용한다
- 시간 컬럼과 주체 컬럼의 source를 분리할 수 있다
Spring Data JPA는 `@CreatedBy`, `@LastModifiedBy`를 제공하고, `AuditorAware<T>`가 현재 애플리케이션 principal을 제공하도록 설명한다.
### 예시 4. 시간과 주체를 필요한 만큼만 선택적으로 둔다
```java
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "login_failures", schema = "auth")
public class LoginFailure {
@Id
private Long id;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
}
```
**좋은 이유:**
- 시간 정보만 필요하면 날짜 감사 컬럼만 써도 된다
- Spring Data JPA는 시간 추적만 하는 경우 `AuditorAware`가 필수가 아니라고 설명한다
- 모든 테이블에 주체 컬럼을 기계적으로 강제하지 않는다
Spring Data JPA는 생성/수정 날짜만 추적하는 경우 `AuditorAware`가 필요 없다고 설명한다.
### 예시 5. Hibernate timestamp를 쓴다면 source를 명시적으로 선택한다
```java
@Entity
@Table(name = "sessions", schema = "auth")
public class Session {
@Id
private Long id;
@CreationTimestamp(source = SourceType.DB)
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@UpdateTimestamp(source = SourceType.DB)
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
}
```
**좋은 이유:**
- Hibernate의 기본 VM source 대신 DB source를 명시적으로 선택한다
- 다중 인스턴스 환경에서 시간 source를 DB로 맞추고 싶다는 의도가 드러난다
- 같은 컬럼을 또 다른 trigger가 동시에 갱신하지만 않는다면 일관성이 높다
Hibernate는 `@CreationTimestamp`, `@UpdateTimestamp`가 기본적으로 VM(in memory)에서 생성되지만, `source()`로 변경할 수 있고 `SourceType.DB`는 DB가 값을 생성함을 뜻한다고 설명한다.
## 나쁜 예시
### 예시 1. 감사 컬럼 없이 테이블마다 임의 이름을 쓴다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
reg_dt timestamp,
mod_ymd text
);
```
**나쁜 이유:**
- 공통 규칙이 없어 해석과 조회가 어려워진다
- 수정 시각을 문자열로 저장해 타입 의미가 무너진다
- 생성/수정 메타데이터가 표준화되지 않는다
PostgreSQL은 timestamp/date/time 타입을 제공하고, `CURRENT_TIMESTAMP` 같은 기본 시간 함수도 제공한다. 감사 컬럼은 이런 표준 타입 위에서 일관되게 두는 편이 안전하다.
### 예시 2. default에 TIMESTAMP 'now' literal을 쓴다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_at timestamp with time zone NOT NULL DEFAULT TIMESTAMP 'now'
);
```
**나쁜 이유:**
- PostgreSQL이 default 절에서는 이 형태를 쓰지 말라고 명시한다
- row 삽입 시점이 아니라 table creation 시점으로 고정될 수 있다
- `CURRENT_TIMESTAMP` 또는 `now()` 같은 함수형 표현이 맞다
PostgreSQL은 later evaluation이 필요한 DEFAULT 절에서 `TIMESTAMP 'now'`를 사용하지 말라고 설명한다.
### 예시 3. 애플리케이션 사용자 식별자를 current_user에 맡긴다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_by text NOT NULL DEFAULT current_user,
updated_by text NOT NULL DEFAULT current_user
);
```
**나쁜 이유:**
- `current_user`는 DB 권한 검사에 쓰이는 사용자다
- connection pool, `SET ROLE`, `SECURITY DEFINER` 환경에서는 최종 애플리케이션 사용자와 다를 수 있다
- 일반 웹 애플리케이션의 actor 추적 컬럼으로는 부적절하다
PostgreSQL은 `current_user`가 권한 검사에 쓰이는 사용자이고, `SET ROLE`이나 `SECURITY DEFINER`에 의해 바뀔 수 있다고 설명한다. Spring Data JPA는 이런 경우 현재 principal을 `AuditorAware<T>`로 제공하도록 설계한다.
### 예시 4. 같은 updated_at을 DB trigger와 ORM이 동시에 관리한다
```java
@Entity
@Table(name = "users", schema = "auth")
public class User {
@UpdateTimestamp
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
}
```
```sql
CREATE TRIGGER trg_users_set_updated_at
BEFORE UPDATE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION common_set_updated_at();
```
**나쁜 이유:**
- 같은 컬럼에 두 개의 writer가 생긴다
- VM source와 DB source가 섞일 수 있다
- 어떤 값이 권위 있는지 흐려지고 디버깅이 어려워진다
Hibernate는 `@UpdateTimestamp`의 기본 source가 VM이라고 설명하고, PostgreSQL trigger는 NEW row를 수정해 저장값을 바꿀 수 있다고 설명한다. 둘을 같은 컬럼에 동시에 쓰면 source of truth가 모호해진다.
### 예시 5. created_at을 business code가 직접 덮어쓴다
```java
user.setCreatedAt(OffsetDateTime.now());
user.setUpdatedAt(OffsetDateTime.now());
```
**나쁜 이유:**
- 생성 감사 컬럼이 도메인 로직에서 임의로 바뀔 수 있다
- DB default나 프레임워크 auditing 의미를 깨뜨린다
- 생성 메타데이터의 불변성이 사라진다
Hibernate는 `@CreationTimestamp` 필드는 애플리케이션이 직접 설정할 수 없다고 설명한다. 프로젝트도 같은 철학으로, 생성 감사 컬럼을 business code가 임의로 다루지 않게 한다.
### 예시 6. deleted_at과 version을 기본 감사 컬럼처럼 섞어 둔다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
deleted_at timestamp with time zone,
version bigint NOT NULL
);
```
**나쁜 이유:**
- `deleted_at`은 soft delete 정책의 일부이고, `version`은 optimistic locking 메타데이터다
- 현재 row 감사 컬럼과 다른 목적의 컬럼을 한 범주로 섞는다
- 문서 경계와 코드 책임이 흐려진다
현재 문서 체계에서도 soft delete와 concurrency/version은 별도 주제로 분리되어 있다.
+114
View File
@@ -0,0 +1,114 @@
# Column Types 예시
## 좋은 예시
### 예시 1. 문자열 기본값은 text, 실제 길이 규칙은 varchar(n)
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email varchar(320) NOT NULL,
display_name text NOT NULL,
bio text,
created_at timestamp with time zone NOT NULL
);
```
**좋은 이유:**
- 이메일은 실제 길이 제한을 반영했다
- 자유 텍스트는 text
- 시점은 timestamptz
- 생성 키는 identity를 사용한다
### 예시 2. 금액은 numeric, 공개 식별자는 uuid
```sql
CREATE TABLE billing.payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id uuid NOT NULL,
amount numeric(19,4) NOT NULL,
currency varchar(3) NOT NULL,
paid_at timestamp with time zone
);
```
**좋은 이유:**
- 정밀도가 필요한 금액을 numeric 으로 저장한다
- UUID를 문자열로 저장하지 않는다
- 시간은 시점 의미로 저장한다
### 예시 3. 외부 payload 보관은 jsonb
```sql
CREATE TABLE integration.webhook_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
provider varchar(50) NOT NULL,
payload jsonb NOT NULL,
received_at timestamp with time zone NOT NULL
);
```
**좋은 이유:**
- 반정형 외부 payload 저장 용도에 맞다
- 나중에 검색/인덱싱 여지도 있다
- 원문 텍스트 보존이 핵심이 아니라면 jsonb 가 더 실용적이다
## 나쁜 예시
### 예시 1. 모든 문자열을 varchar(255) 로 통일한다
```sql
CREATE TABLE auth.users (
email varchar(255) NOT NULL,
display_name varchar(255) NOT NULL,
bio varchar(255)
);
```
**나쁜 이유:**
- 실제 길이 규칙을 표현하지 못한다
- 자유 텍스트까지 임의 길이로 잘라 버린다
- PostgreSQL은 text 와 varchar 사이에 일반 성능 이점이 없다고 설명한다
### 예시 2. 금액을 부동소수점으로 저장한다
```sql
CREATE TABLE billing.payments (
amount double precision NOT NULL
);
```
**나쁜 이유:**
- double precision 은 부정확한 floating-point 타입이다
- 금액/정산에 부적절하다
### 예시 3. UUID를 문자열에 저장한다
```sql
CREATE TABLE auth.sessions (
session_id varchar(36) NOT NULL
);
```
**나쁜 이유:**
- PostgreSQL이 네이티브 uuid 타입을 제공하는데 활용하지 않는다
- 문자열 유효성/연산/저장 의미가 흐려진다
### 예시 4. 시점을 로컬 datetime처럼 저장한다
```sql
CREATE TABLE auth.audit_logs (
created_at timestamp NOT NULL
);
```
**나쁜 이유:**
- 절대 시점인지 로컬 시각인지 의미가 불명확하다
- 일반적인 생성/수정 시각은 timestamp with time zone 이 더 안전하다
+279
View File
@@ -0,0 +1,279 @@
# DB Concurrency 예시
## 좋은 예시
### 예시 1. 동시에 수정될 수 있는 aggregate root에는 @Version을 둔다
```java
@Entity
@Table(name = "users", schema = "auth")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Version
private long version;
@Column(name = "display_name", nullable = false)
private String displayName;
public void changeDisplayName(String displayName) {
this.displayName = displayName;
}
}
```
**좋은 이유:**
- 같은 사용자를 여러 요청이 동시에 수정할 때 conflicting update를 감지할 수 있다
- JPA 표준 메커니즘이라 provider portability가 높다
- 숫자형 version은 timestamp보다 기본값으로 더 신뢰하기 쉽다
Jakarta Persistence는 version 필드가 있는 엔티티에 대해 provider가 자동 optimistic locking을 수행해야 한다고 설명하고, Hibernate는 `@Version`이 lost update를 막는 기본 메커니즘이라고 설명합니다.
### 예시 2. 상태 전이는 조건부 UPDATE 한 번으로 처리한다
```sql
UPDATE billing.payments
SET status = 'CONFIRMED',
confirmed_at = now()
WHERE id = :paymentId
AND status = 'PENDING'
RETURNING id, status, confirmed_at;
```
**좋은 이유:**
- PENDING일 때만 확정된다
- 선조회와 후행 update를 분리하지 않아 경쟁 상태를 줄인다
- 반환 row가 없으면 이미 다른 트랜잭션이 상태를 바꿨다고 해석할 수 있다
PostgreSQL은 concurrent update 뒤 WHERE 조건을 다시 평가하고, `UPDATE ... RETURNING`으로 실제 반영된 row를 바로 받을 수 있다고 설명합니다.
### 예시 3. 중복 생성은 UNIQUE + ON CONFLICT로 닫는다
```sql
CREATE TABLE integration.webhook_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
provider text NOT NULL,
provider_event_id text NOT NULL,
payload jsonb NOT NULL,
CONSTRAINT uq_webhook_events__provider_event_id
UNIQUE (provider, provider_event_id)
);
INSERT INTO integration.webhook_events (provider, provider_event_id, payload)
VALUES (:provider, :eventId, CAST(:payload AS jsonb))
ON CONFLICT (provider, provider_event_id) DO NOTHING
RETURNING id;
```
**좋은 이유:**
- 중복 webhook 반영을 애플리케이션 선조회가 아니라 DB 제약으로 닫는다
- high concurrency에서도 중복 row가 생기지 않는다
- 성공 여부를 RETURNING 결과 존재로 판정할 수 있다
PostgreSQL은 제약 위반 시 저장을 막고, `ON CONFLICT`가 high concurrency에서도 atomic outcome을 제공한다고 설명합니다.
### 예시 4. create-or-update는 UPSERT로 처리한다
```sql
INSERT INTO auth.login_failures (user_id, fail_count, last_failed_at)
VALUES (:userId, 1, now())
ON CONFLICT (user_id)
DO UPDATE
SET fail_count = auth.login_failures.fail_count + 1,
last_failed_at = EXCLUDED.last_failed_at
RETURNING user_id, fail_count, last_failed_at;
```
**좋은 이유:**
- "없으면 생성, 있으면 누적 갱신"을 한 문장으로 처리한다
- 경쟁 상태에서 insert/update 사이가 찢어지지 않는다
- 원자적 upsert semantics를 그대로 활용한다
PostgreSQL은 `ON CONFLICT DO UPDATE`가 atomic한 insert-or-update outcome을 보장한다고 설명합니다.
### 예시 5. 낙관적 락 실패는 유스케이스 실패로 올린다
```java
@Transactional
public void changeDisplayName(Long userId, String newName) {
User user = userRepository.findById(userId)
.orElseThrow(UserNotFoundException::new);
user.changeDisplayName(newName);
entityManager.flush();
}
```
**좋은 이유:**
- optimistic lock 충돌을 트랜잭션 후반 commit 시점이 아니라, 서비스 내부에서 더 빨리 드러나게 할 수 있다
- 실패를 조기에 감지하고 응답 정책을 결정하기 쉽다
- 예외를 숨기지 않고 유스케이스 실패로 처리한다
Jakarta Persistence는 optimistic lock check가 commit 시점까지 지연될 수 있고, 더 일찍 처리해야 하면 `flush()`를 사용할 수 있다고 설명합니다.
## 나쁜 예시
### 예시 1. version 없이 마지막 커밋이 이기게 둔다
```java
@Entity
@Table(name = "users", schema = "auth")
public class User {
@Id
private Long id;
@Column(name = "display_name", nullable = false)
private String displayName;
}
```
**나쁜 이유:**
- 두 요청이 같은 사용자를 수정하면 나중에 commit한 요청이 앞선 변경을 덮어쓸 수 있다
- detached merge나 동시 수정에서 lost update 위험이 크다
- 표준 optimistic locking 보호가 없다
Jakarta Persistence는 version이 없는 엔티티는 애플리케이션이 직접 일관성을 책임져야 하고, version을 쓰지 않으면 lost update와 inconsistent state를 초래할 수 있다고 설명합니다.
### 예시 2. 먼저 읽고, 자바에서 검사한 뒤, 별도 UPDATE를 날린다
```java
@Transactional
public void confirmPayment(Long paymentId) {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(PaymentNotFoundException::new);
if (payment.getStatus() != PaymentStatus.PENDING) {
throw new IllegalStateException("not pending");
}
payment.confirm();
}
```
**나쁜 이유:**
- 같은 PENDING row를 두 트랜잭션이 동시에 읽고 둘 다 confirm하려고 할 수 있다
- 버전 락이나 조건부 update가 없으면 경쟁 상태를 막기 어렵다
- 상태 전이 조건이 DB 최종 판정이 아니라 애플리케이션 선조회에만 의존한다
PostgreSQL은 concurrent update 시 row를 다시 적용하고 WHERE를 재평가한다고 설명하므로, 이런 read-then-act 패턴보다 단일 조건부 mutation이 더 안전합니다.
### 예시 3. 중복 생성 방지를 선조회로만 처리한다
```java
@Transactional
public void registerWebhookEvent(String provider, String eventId, String payload) {
boolean exists = webhookEventRepository.existsByProviderAndProviderEventId(provider, eventId);
if (exists) {
return;
}
webhookEventRepository.save(new WebhookEvent(provider, eventId, payload));
}
```
**나쁜 이유:**
- 두 트랜잭션이 동시에 exists = false를 볼 수 있다
- UNIQUE 제약이 없으면 중복 row가 실제로 저장될 수 있다
- 선조회는 보조일 수 있어도, 최종 정합성 보장 수단이 아니다
PostgreSQL은 제약이 저장 시점의 위반을 막는다고 설명합니다. 이런 문제는 UNIQUE 없이 안전하지 않습니다.
### 예시 4. OptimisticLockException을 잡아서 무시한다
```java
@Transactional
public void changeDisplayName(Long userId, String newName) {
try {
User user = userRepository.findById(userId)
.orElseThrow(UserNotFoundException::new);
user.changeDisplayName(newName);
entityManager.flush();
} catch (OptimisticLockException ignored) {
// 무시
}
}
```
**나쁜 이유:**
- 현재 트랜잭션은 이미 rollback 대상으로 표시될 수 있다
- 유스케이스 실패를 숨기고 잘못된 성공처럼 보이게 만든다
- 재시도 여부는 상위 application boundary에서 다시 판단해야 한다
Jakarta Persistence는 optimistic lock failure 시 `OptimisticLockException`을 던지고 현재 트랜잭션을 rollback 대상으로 표시해야 한다고 규정합니다.
### 예시 5. 핵심 필드를 optimistic lock에서 제외한다
```java
@Entity
public class Payment {
@Id
private Long id;
@OptimisticLock(excluded = true)
private BigDecimal amount;
@Version
private Long version;
}
```
**나쁜 이유:**
- 금액처럼 핵심 비즈니스 필드를 제외하면 concurrent overwrite를 받아들인다는 뜻이 된다
- 이런 필드는 lost update를 허용하면 안 된다
- `excluded=true`는 부수적 카운터 같은 제한된 경우에만 의미가 있다
Hibernate는 `excluded` 속성이 version 증가를 막아 lost update를 수용할 수 있는 필드에만 써야 함을 예시로 설명합니다.
### 예시 6. unique violation을 무한 재시도한다
```java
while (true) {
try {
userRepository.save(user);
break;
} catch (DataIntegrityViolationException e) {
// 계속 재시도
}
}
```
**나쁜 이유:**
- unique violation은 transient concurrency일 수도 있지만, 영구적인 비즈니스 오류일 수도 있다
- 원인을 구분하지 않으면 무한 루프나 불필요한 부하를 만든다
- 재시도는 전체 유스케이스와 에러 코드 의미를 보고 제한적으로만 해야 한다
PostgreSQL은 23505가 때로는 concurrency 실패일 수 있지만, 항상 그런 것은 아니므로 더 신중해야 한다고 설명합니다.
### 예시 7. 단순 중복 생성 문제를 곧바로 FOR UPDATE로 푼다
```sql
SELECT *
FROM auth.users
WHERE email = :email
FOR UPDATE;
```
**나쁜 이유:**
- 중복 생성 방지의 기본 문제는 uniqueness인데, row lock을 먼저 가져가면 설계가 과도하게 무거워진다
- 아직 row가 없는 경우에는 lock으로도 해결되지 않는다
- 이런 문제는 보통 unique constraint와 `ON CONFLICT`가 더 직접적이다
PostgreSQL은 `FOR UPDATE`가 기존 row를 잠그는 수단이라고 설명합니다. "존재하지 않는 row의 uniqueness"는 제약이 더 적절합니다.
+286
View File
@@ -0,0 +1,286 @@
# Index 예시
## 좋은 예시
### 예시 1. 자주 조회되는 FK에 단일 컬럼 B-tree 인덱스를 둔다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY,
CONSTRAINT pk_users PRIMARY KEY (id)
);
CREATE TABLE auth.sessions (
id bigint GENERATED ALWAYS AS IDENTITY,
user_id bigint NOT NULL,
expires_at timestamp with time zone NOT NULL,
CONSTRAINT pk_sessions PRIMARY KEY (id),
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
REFERENCES auth.users (id)
ON DELETE CASCADE
);
CREATE INDEX ix_sessions__user_id ON auth.sessions (user_id);
```
**좋은 이유:**
- FK는 참조 무결성을 보장하지만, 참조하는 쪽 인덱스는 자동 생성되지 않는다
- 세션을 사용자 기준으로 자주 조회하거나, 사용자 삭제 시 자식 세션을 찾는 경로에 도움이 된다
- 단순하고 신뢰도 높은 기본 패턴이다
이 예시는 PostgreSQL의 FK 동작과 B-tree 기본 사용 패턴에 맞는 안전한 best practice다.
### 예시 2. 복합 검색 패턴에는 왼쪽 컬럼 순서를 고려한 멀티 컬럼 인덱스를 둔다
```sql
CREATE TABLE ordering.orders (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL,
status text NOT NULL,
created_at timestamp with time zone NOT NULL,
CONSTRAINT pk_orders PRIMARY KEY (id)
);
CREATE INDEX ix_orders__tenant_status_created_at
ON ordering.orders (tenant_id, status, created_at DESC);
```
**좋은 이유:**
- tenant_id = ? AND status = ? AND created_at < ? ORDER BY created_at DESC 같은 패턴에 잘 맞는다
- equality 필터를 왼쪽에 두고, range/정렬 컬럼을 뒤에 둔 전형적인 B-tree 설계다
- 멀티 컬럼 인덱스가 실제 쿼리 패턴에 직접 대응한다
PostgreSQL은 B-tree 멀티 컬럼 인덱스가 leading columns 제약에 가장 효율적이라고 설명한다.
### 예시 3. soft delete 활성 행만 자주 조회되면 partial index를 사용한다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL,
email text NOT NULL,
deleted_at timestamp with time zone,
CONSTRAINT pk_users PRIMARY KEY (id)
);
CREATE INDEX ix_users__tenant_id_email__active
ON auth.users (tenant_id, email)
WHERE deleted_at IS NULL;
```
**좋은 이유:**
- 전체 사용자보다 “삭제되지 않은 사용자”만 자주 조회되는 경우에 맞는 설계다
- hot subset만 인덱싱하므로 전체 인덱스보다 더 작고 유지 비용도 낮을 수 있다
- predicate가 단순하고 쿼리와 맞추기 쉽다
PostgreSQL 공식 문서도 partial index를 이런 subset 최적화에 쓰도록 설명한다.
### 예시 4. 대소문자 무시 검색은 expression index로 맞춘다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY,
email text NOT NULL,
CONSTRAINT pk_users PRIMARY KEY (id)
);
CREATE UNIQUE INDEX uq_users__lower_email
ON auth.users (lower(email));
```
**좋은 이유:**
- 검색과 고유성 규칙이 모두 lower(email) 의미에 맞춰져 있다
- 애플리케이션에서 임시 변환만 하는 것보다 DB 규칙이 더 명확하다
- 표현식 인덱스의 대표적인 안전 사용 사례다
PostgreSQL은 lower(col) 같은 expression index를 공식 지원하고, unique expression index로 단순 unique constraint로 표현하기 어려운 규칙도 강제할 수 있다고 설명한다.
### 예시 5. 목록 조회 최적화가 확실하면 INCLUDE를 보수적으로 사용한다
```sql
CREATE TABLE ordering.orders (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL,
status text NOT NULL,
created_at timestamp with time zone NOT NULL,
total_amount numeric(19,4) NOT NULL,
CONSTRAINT pk_orders PRIMARY KEY (id)
);
CREATE INDEX ix_orders__tenant_status_created_at
ON ordering.orders (tenant_id, status, created_at DESC)
INCLUDE (total_amount);
```
**좋은 이유:**
- 자주 실행되는 목록 조회가 tenant_id, status, created_at 기준으로 필터/정렬되고, 응답에는 total_amount가 필요할 때 유효하다
- total_amount는 non-key payload라 uniqueness/탐색 키 의미를 어지럽히지 않는다
- 작은 payload 컬럼만 추가한 보수적 covering index다
PostgreSQL은 INCLUDE가 index-only scan을 돕지만, 인덱스 크기를 키우므로 보수적으로 사용해야 한다고 설명한다.
### 예시 6. jsonb 전체 containment 검색은 GIN을 검토한다
```sql
CREATE TABLE integration.webhook_events (
id bigint GENERATED ALWAYS AS IDENTITY,
payload jsonb NOT NULL,
received_at timestamp with time zone NOT NULL,
CONSTRAINT pk_webhook_events PRIMARY KEY (id)
);
CREATE INDEX ix_webhook_events__payload_gin
ON integration.webhook_events
USING GIN (payload);
```
**좋은 이유:**
- payload @> ..., key existence, jsonpath 검색 같은 jsonb 검색 패턴에 맞는다
- B-tree로 해결할 수 없는 composite value 내부 검색을 GIN으로 처리한다
- jsonb를 유지해야 하는 상황에서 가장 전형적인 공식 패턴이다
PostgreSQL은 jsonb 검색에 GIN을 사용할 수 있고, key/key-value search 및 containment에 적합하다고 설명한다.
### 예시 7. 대형 append-only 로그는 BRIN을 검토한다
```sql
CREATE TABLE audit.audit_logs (
id bigint GENERATED ALWAYS AS IDENTITY,
occurred_at timestamp with time zone NOT NULL,
actor_id bigint,
action text NOT NULL,
CONSTRAINT pk_audit_logs PRIMARY KEY (id)
);
CREATE INDEX ix_audit_logs__occurred_at_brin
ON audit.audit_logs
USING BRIN (occurred_at);
```
**좋은 이유:**
- 아주 큰 로그 테이블에서 occurred_at이 물리 저장 순서와 자연스럽게 상관될 가능성이 높다
- BRIN은 작은 인덱스로 큰 범위를 건너뛸 수 있다
- append-only 성격이 강한 로그/이력성 테이블과 잘 맞는다
PostgreSQL은 BRIN이 매우 큰 테이블과 물리 순서 상관성이 있는 컬럼에 적합하다고 설명한다.
## 나쁜 예시
### 예시 1. PK가 이미 만든 인덱스를 다시 만든다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY,
email text NOT NULL,
CONSTRAINT pk_users PRIMARY KEY (id)
);
CREATE INDEX ix_users__id ON auth.users (id);
```
**나쁜 이유:**
- PK가 이미 unique B-tree 인덱스를 자동 생성한다
- 같은 의미의 중복 인덱스라 쓰기 비용과 저장 비용만 늘린다
- 운영상 관리 포인트만 증가한다
PostgreSQL 공식 문서는 PK/UNIQUE 제약이 자동으로 인덱스를 만든다고 설명한다.
### 예시 2. 실제 쿼리와 무관하게 긴 멀티 컬럼 인덱스를 만든다
```sql
CREATE INDEX ix_orders__tenant_status_type_created_at_updated_at
ON ordering.orders (tenant_id, status, type, created_at, updated_at);
```
**나쁜 이유:**
- 어떤 쿼리를 위한 인덱스인지 설명하기 어렵다
- 멀티 컬럼이 길어질수록 유지 비용이 커지고 활용 범위도 애매해진다
- 대부분의 경우 단일/짧은 복합 인덱스 조합이 더 낫다
PostgreSQL도 멀티 컬럼 인덱스는 신중히 사용해야 하고, 3개를 넘는 경우는 드물게만 유효하다고 설명한다.
### 예시 3. 단일 컬럼 DESC 인덱스를 습관적으로 만든다
```sql
CREATE INDEX ix_orders__created_at_desc
ON ordering.orders (created_at DESC);
```
**나쁜 이유:**
- 단일 컬럼 B-tree는 backward scan이 가능하므로 대개 별도 DESC 인덱스 이점이 없다
- mixed ordering이 아닌데도 특수 정렬을 도입해 관리 복잡도만 올린다
- 실제 쿼리 근거가 부족하다
PostgreSQL은 ordered scan을 forward/backward 모두 지원하고, 단일 컬럼에서는 별도 DESC 인덱스가 대체로 유용하지 않다고 설명한다.
### 예시 4. partial index predicate를 쿼리와 다르게 만든다
```sql
CREATE INDEX ix_users__active_recent
ON auth.users (tenant_id, email)
WHERE deleted_at IS NULL AND last_login_at > now() - interval '30 days';
```
**나쁜 이유:**
- now() 같은 시간 의존 조건은 안정적인 partial index predicate로 부적절하다
- 쿼리와 predicate가 정확히 맞지 않으면 planner가 인덱스를 잘 쓰지 못한다
- 데이터 분포와 시간 경계가 계속 바뀌므로 유지 신뢰도가 낮다
PostgreSQL은 partial index predicate가 planner가 인식 가능한 형태여야 한다고 설명한다.
### 예시 5. 큰 payload를 INCLUDE에 넣는다
```sql
CREATE INDEX ix_posts__author_created_at
ON board.posts (author_id, created_at DESC)
INCLUDE (content, metadata_json);
```
**나쁜 이유:**
- 큰 text/json payload는 인덱스를 크게 부풀린다
- 인덱스 tuple 크기 제한에 걸릴 수 있고, 검색 자체도 느려질 수 있다
- INCLUDE는 작은 응답용 컬럼에만 보수적으로 써야 한다
PostgreSQL은 wide non-key column을 INCLUDE에 넣는 것을 보수적으로 하라고 명시한다.
### 예시 6. 운영 중 대형 테이블에 일반 CREATE INDEX를 바로 실행한다
```sql
CREATE INDEX ix_events__occurred_at
ON audit.audit_logs (occurred_at);
```
**나쁜 이유:**
- 일반 CREATE INDEX는 빌드 동안 writes를 막는다
- 운영 중 대형 테이블에서는 서비스 영향이 매우 클 수 있다
- 이런 경우는 CREATE INDEX CONCURRENTLY 여부를 먼저 검토해야 한다
PostgreSQL 공식 문서도 일반 index build는 writes를 block하고, 운영 환경에서는 종종 unacceptable하다고 설명한다.
### 예시 7. jsonb 키 검색에 B-tree를 건다
```sql
CREATE INDEX ix_webhook_events__payload
ON integration.webhook_events (payload);
```
**나쁜 이유:**
- jsonb @>, ?, @?, @@ 같은 검색은 B-tree 기본 패턴과 맞지 않는다
- composite value 내부 검색은 GIN 같은 전용 접근 방식이 더 적합하다
- 타입/연산자 특성을 무시한 설계다
PostgreSQL은 jsonb key/key-value 검색에 GIN을 공식적으로 권장 가능한 접근 방식으로 설명한다.
+244
View File
@@ -0,0 +1,244 @@
# Transaction Isolation 예시
## 좋은 예시
### 예시 1. 일반적인 서비스 로직은 기본값을 따른다
```java
@Service
@RequiredArgsConstructor
public class UserCommandService {
private final UserRepository userRepository;
@Transactional
public void changeDisplayName(Long userId, String newName) {
User user = userRepository.findById(userId)
.orElseThrow(UserNotFoundException::new);
user.changeDisplayName(newName);
}
}
```
**좋은 이유:**
- Spring 기본값인 `Isolation.DEFAULT`를 사용한다
- PostgreSQL에서는 보통 `READ COMMITTED`가 적용된다
- 일반적인 단건 수정 유스케이스에 과도한 isolation을 강제하지 않는다
Spring은 `@Transactional` 기본 isolation이 `ISOLATION_DEFAULT`라고 설명하고, PostgreSQL은 기본 isolation이 보통 `READ COMMITTED`라고 설명한다.
### 예시 2. 같은 트랜잭션 안에서 stable snapshot이 필요하면 REPEATABLE_READ를 검토한다
```java
@Service
@RequiredArgsConstructor
public class SettlementPreviewService {
private final SettlementRepository settlementRepository;
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public SettlementPreview preview(Long merchantId, LocalDate from, LocalDate to) {
List<SettlementLine> lines = settlementRepository.findLines(merchantId, from, to);
BigDecimal fee = settlementRepository.sumFee(merchantId, from, to);
return SettlementPreview.of(lines, fee);
}
}
```
**좋은 이유:**
- 여러 query가 같은 snapshot을 기준으로 계산되길 원할 때 의미가 있다
- PostgreSQL의 `REPEATABLE READ`는 트랜잭션 시작 시점 snapshot을 유지한다
- 단, 이 설계는 여전히 serialization failure 재시도 필요성을 함께 고려해야 한다
PostgreSQL은 `REPEATABLE READ`에서 successive SELECT가 같은 snapshot을 보고, serialization failure에 대비해야 한다고 설명한다.
### 예시 3. cross-row invariant가 중요하면 SERIALIZABLE과 재시도를 함께 둔다
```java
@Service
@RequiredArgsConstructor
public class SeatAllocationService {
private final SeatRepository seatRepository;
@Transactional(isolation = Isolation.SERIALIZABLE)
public void allocateSeat(Long eventId, Long userId) {
if (seatRepository.countAllocated(eventId) >= seatRepository.capacityOf(eventId)) {
throw new NoSeatLeftException();
}
seatRepository.insertAllocation(eventId, userId);
}
}
```
**좋은 이유:**
- 집합 단위 정합성이 중요한 유스케이스를 명시적으로 serial semantics로 올린다
- 단순 snapshot 안정성이 아니라 serialization anomaly 방지가 목적이다
- 이 경우 40001 전체 재시도 정책이 같이 있어야 설계가 완성된다
PostgreSQL은 `SERIALIZABLE`이 serial execution과 같은 효과를 보장하지만, serialization failure가 발생할 수 있으므로 재시도가 필요하다고 설명한다.
### 예시 4. stronger isolation보다 더 직접적인 수단이 있으면 그쪽을 먼저 쓴다
```sql
UPDATE billing.payments
SET status = 'CONFIRMED',
confirmed_at = now()
WHERE id = :paymentId
AND status = 'PENDING';
```
**좋은 이유:**
- 단순 상태 전이는 stronger isolation보다 조건부 UPDATE가 더 직접적이다
- `READ COMMITTED`에서도 원자적으로 성공 여부를 판단할 수 있다
- isolation level을 과도하게 올리지 않아도 된다
PostgreSQL은 `READ COMMITTED`에서 concurrent update 시 WHERE 조건이 재평가될 수 있고, 조건부 mutation이 유용하게 동작한다고 설명한다.
### 예시 5. inner method isolation override를 기대하지 않고 outer boundary에서 선언한다
```java
@Service
@RequiredArgsConstructor
public class ReportFacade {
private final ReportQueryService reportQueryService;
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public ReportResponse generate(Long reportId) {
return reportQueryService.generate(reportId);
}
}
```
**좋은 이유:**
- isolation을 outer use case boundary에서 선언한다
- inner service가 기존 트랜잭션에 참여하면서 의미가 흐려지는 것을 피한다
- Spring의 isolation 적용 규칙과 맞다
Spring은 isolation setting이 새로 시작된 트랜잭션에만 적용되고, 기존 트랜잭션에 참여하는 inner scope의 local isolation은 기본적으로 무시된다고 설명한다.
## 나쁜 예시
### 예시 1. PostgreSQL에서 READ_UNCOMMITTED를 dirty read 용도로 기대한다
```java
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public User findUser(Long id) {
return userRepository.findById(id).orElseThrow();
}
```
**나쁜 이유:**
- PostgreSQL에서는 `READ UNCOMMITTED`가 별도 dirty-read 모드로 동작하지 않는다
- 실제로는 `READ COMMITTED`처럼 동작한다
- 성능이나 동작 의미가 달라질 것이라 기대하면 틀린 설계가 된다
PostgreSQL은 `READ UNCOMMITTED`가 내부적으로 `READ COMMITTED`처럼 동작한다고 명시한다.
### 예시 2. READ_COMMITTED에서 같은 트랜잭션 안의 두 조회가 반드시 같을 것이라 가정한다
```java
@Transactional
public boolean canStillShip(Long orderId) {
Order order1 = orderRepository.findById(orderId).orElseThrow();
// 중간에 다른 트랜잭션이 상태를 바꿀 수 있음
Order order2 = orderRepository.findById(orderId).orElseThrow();
return order1.getStatus() == order2.getStatus();
}
```
**나쁜 이유:**
- PostgreSQL `READ COMMITTED`에서는 각 query가 자기 시작 시점 snapshot을 본다
- 따라서 같은 트랜잭션 안에서도 두 조회 결과가 달라질 수 있다
- stable snapshot이 필요한 로직이라면 isolation 또는 더 직접적인 동시성 제어를 다시 설계해야 한다
PostgreSQL은 `READ COMMITTED`에서 subsequent commands in the same transaction이 committed concurrent transaction의 효과를 보게 된다고 설명한다.
### 예시 3. REPEATABLE_READ를 쓰면서 serialization failure 재시도를 준비하지 않는다
```java
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void runComplexSettlement(Long merchantId) {
// 복잡한 다단계 조회/갱신
}
```
**나쁜 이유:**
- PostgreSQL의 `REPEATABLE READ`도 serialization anomaly를 막기 위해 실패할 수 있다
- stronger isolation만 올리고 재시도 정책을 설계하지 않으면 운영 시 장애로 이어질 수 있다
- "repeatable read니까 실패는 없을 것"이라는 가정이 틀리다
PostgreSQL은 `REPEATABLE READ`에서도 애플리케이션이 serialization failure 재시도를 준비해야 한다고 설명한다.
### 예시 4. SERIALIZABLE을 전역 기본값처럼 남발한다
```java
@Transactional(isolation = Isolation.SERIALIZABLE)
public void doAnything() {
// 일반 CRUD, 단순 조회, 목록 조회까지 전부 같은 정책
}
```
**나쁜 이유:**
- serial semantics가 필요하지 않은 경로에도 monitoring overhead와 retry 부담을 강제한다
- 더 직접적인 수단으로 닫을 수 있는 문제까지 모두 isolation으로 해결하려 든다
- stronger isolation은 증명 가능한 요구가 있을 때만 써야 한다
PostgreSQL은 `SERIALIZABLE`이 monitoring overhead를 가지며, serialization failure를 일으킬 수 있다고 설명한다.
### 예시 5. inner method에서 isolation을 바꾸면 outer transaction을 override할 수 있다고 기대한다
```java
@Service
public class OuterService {
@Transactional
public void outer() {
inner();
}
@Transactional(isolation = Isolation.SERIALIZABLE)
public void inner() {
// 여기서 isolation이 바뀔 것이라고 기대
}
}
```
**나쁜 이유:**
- 기존 트랜잭션에 참여하면 inner isolation 선언은 기본적으로 무시될 수 있다
- 게다가 self-invocation 구조라 transaction advice 자체가 적용되지 않을 수도 있다
- isolation override 의도가 코드에 반영되지 않는다
Spring은 isolation setting이 새 트랜잭션에만 적용되고, 기존 트랜잭션 참여 시 local isolation은 기본적으로 무시된다고 설명한다.
### 예시 6. sequence 값이 rollback되리라 기대한다
```sql
BEGIN;
INSERT INTO auth.users DEFAULT VALUES;
ROLLBACK;
```
**나쁜 이유:**
- sequence/serial 계열 값은 다른 트랜잭션에 즉시 visible할 수 있고 rollback되지 않는다
- gap 없는 연속 번호를 기대하면 안 된다
- 이는 isolation 문제가 아니라 PostgreSQL sequence 동작 특성이다
PostgreSQL은 sequence 변경이 즉시 visible하고 transaction abort 시에도 rollback되지 않는다고 설명한다.
+269
View File
@@ -0,0 +1,269 @@
# JPA Fetch Strategy 예시
## 좋은 예시
### 예시 1. to-one 기본값도 명시적으로 LAZY로 바꾼다
```java
@Entity
@Table(name = "sessions", schema = "auth")
public class Session {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;
}
```
**좋은 이유:**
- JPA 기본값인 @ManyToOne EAGER에 기대지 않는다
- 세션 조회에서 항상 사용자 전체를 강제로 가져오지 않는다
- 필요한 경우 query 또는 entity graph에서만 user를 함께 가져오게 만들 수 있다
Jakarta Persistence는 @ManyToOne 기본 fetch가 EAGER라고 정의하고, Hibernate는 to-one EAGER 기본값을 그대로 쓰기보다 모든 association을 LAZY로 두는 편을 권장한다.
### 예시 2. 상세 조회의 to-one 연관은 join fetch로 가져온다
```java
@Query("""
select s
from Session s
join fetch s.user u
where s.id = :id
""")
Optional<Session> findDetailById(@Param("id") Long id);
```
**좋은 이유:**
- 기본 매핑은 LAZY로 두고, 이 상세 조회에서만 user를 함께 가져온다
- to-one 연관 fetch join은 Hibernate가 권장하는 대표 패턴이다
- use case 단위로 eager 요구를 제어한다
Hibernate는 join fetch가 laziness를 override하여 같은 SQL join으로 데이터를 가져오게 하며, to-one association에 특히 적합하다고 설명한다.
### 예시 3. repository 메서드 단위 eager 요구는 EntityGraph로 선언한다
```java
@Entity
@NamedEntityGraph(
name = "User.withRoles",
attributeNodes = {
@NamedAttributeNode("roles")
}
)
@Table(name = "users", schema = "auth")
public class User {
// ...
}
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(value = "User.withRoles", type = EntityGraph.EntityGraphType.FETCH)
Optional<User> findByEmail(String email);
}
```
**좋은 이유:**
- 정적 매핑을 EAGER로 바꾸지 않고, 특정 repository 메서드에서만 roles를 가져온다
- FETCH graph로 명시한 속성만 eager 범위에 넣는다
- entity graph를 fetch plan template로 사용하는 정석적인 방식이다
Jakarta Persistence는 fetchgraph와 loadgraph를 공식적으로 지원하고, Spring Data JPA는 repository method에서 @EntityGraph와 동적 attributePaths()를 지원한다.
### 예시 4. 여러 부모의 같은 LAZY 컬렉션 접근에는 BatchSize를 보조적으로 사용한다
```java
@Entity
@Table(name = "users", schema = "auth")
public class User {
@Id
private Long id;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 50)
private List<UserRole> roles = new ArrayList<>();
}
```
**좋은 이유:**
- 여러 사용자의 roles 컬렉션을 순차적으로 초기화할 때 round trip 수를 줄일 수 있다
- join fetch가 항상 적절하지 않은 경우의 보조 최적화다
- 기본 전략을 EAGER로 바꾸지 않는다
Hibernate는 @BatchSize가 여러 uninitialized association을 한 번에 가져오도록 도와주며, N+1보다 낫지만 대개 JOIN FETCH나 DTO projection이 더 좋다고 설명한다.
### 예시 5. 조회 전용 목록은 DTO projection으로 닫는다
```java
public record UserSummary(Long id, String email, String status) {}
@Query("""
select new com.example.auth.user.UserSummary(u.id, u.email, u.status)
from User u
where u.deletedAt is null
order by u.id desc
""")
List<UserSummary> findActiveUserSummaries();
```
**좋은 이유:**
- 수정 목적이 아닌 조회에서 엔티티 그래프 전체를 관리하지 않는다
- 필요한 컬럼만 선택한다
- persistence context 부담을 줄이고 fetch 전략 고민도 단순화한다
Hibernate는 read-only transaction에는 DTO projection이 더 적합하고, 필요한 컬럼만 조회할 수 있어 persistence context 부담을 줄인다고 설명한다.
## 나쁜 예시
### 예시 1. to-one 기본값을 그대로 두어 암묵적 EAGER를 만든다
```java
@Entity
@Table(name = "sessions", schema = "auth")
public class Session {
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;
}
```
**나쁜 이유:**
- @ManyToOne 기본값은 EAGER라서, 의도하지 않아도 user를 항상 가져오게 된다
- query별 fetch 정책을 세밀하게 제어하기 어려워진다
- JPQL에서 fetch join을 빠뜨리면 secondary select로 이어질 수 있다
Jakarta Persistence는 @ManyToOne 기본 fetch를 EAGER로 정의하고, Hibernate는 이런 EAGER 기본값을 피하라고 권장한다.
### 예시 2. 여러 컬렉션을 한 query에서 동시에 fetch join한다
```java
@Query("""
select u
from User u
left join fetch u.roles
left join fetch u.sessions
left join fetch u.loginHistories
where u.id = :id
""")
Optional<User> findEverything(@Param("id") Long id);
```
**나쁜 이유:**
- 여러 to-many를 병렬 fetch join하면 Cartesian product가 발생할 수 있다
- row 폭발과 중복으로 성능이 매우 나빠질 수 있다
- “한 번에 다 가져오자”는 발상이 오히려 가장 위험하다
Hibernate는 여러 컬렉션/to-many를 병렬 fetch join하면 Cartesian product가 생겨 매우 나쁜 성능을 낼 수 있다고 명시한다.
### 예시 3. pagination query에 컬렉션 fetch join을 사용한다
```java
@Query("""
select u
from User u
left join fetch u.roles
order by u.id desc
""")
Page<User> findAllWithRoles(Pageable pageable);
```
**나쁜 이유:**
- 컬렉션 fetch join과 pagination은 충돌하기 쉽다
- page boundary가 불안정해지고 row duplication 문제가 생길 수 있다
- 목록/페이지 조회는 다른 방식으로 설계해야 한다
Hibernate는 fetch join을 제한/페이징 query에서 보통 피해야 한다고 설명한다.
### 예시 4. 지연 로딩 문제를 presentation 계층에서 우연히 해결되길 기대한다
```java
@Transactional(readOnly = true)
public User getUser(Long id) {
return userRepository.findById(id).orElseThrow();
}
// controller
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
User user = userService.getUser(id);
return new UserResponse(
user.getId(),
user.getEmail(),
user.getRoles().stream().map(UserRole::getName).toList()
);
}
```
**나쁜 이유:**
- service 트랜잭션이 끝난 뒤 controller에서 lazy 컬렉션 접근을 시도한다
- 환경에 따라 LazyInitializationException 또는 우연한 추가 SQL에 의존하게 된다
- fetch plan 책임이 service/application 경계 밖으로 새어 나간다
Hibernate는 필요한 연관은 persistence context가 닫히기 전에 fetch해야 하며, 그렇지 않으면 LazyInitializationException이 발생한다고 설명한다.
### 예시 5. read-only 목록인데도 엔티티 전체를 억지로 로딩한다
```java
@Query("""
select u
from User u
left join fetch u.department
left join fetch u.roles
where u.deletedAt is null
order by u.id desc
""")
List<User> findAllForAdminList();
```
**나쁜 이유:**
- 목록 화면에 필요한 필드보다 훨씬 많은 엔티티 상태를 가져오기 쉽다
- 관리 대상 엔티티 수와 SQL row 수가 불필요하게 커진다
- 이런 경우는 DTO projection이 더 적합할 가능성이 높다
Hibernate는 read-only transaction에서는 DTO projection이 더 적절하고, 필요한 컬럼만 선택할 수 있다고 설명한다.
### 예시 6. @BatchSize로 구조 문제를 덮는다
```java
@Entity
public class User {
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 1000)
private List<UserRole> roles;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 1000)
private List<Session> sessions;
}
```
**나쁜 이유:**
- 왜 secondary select 구조가 필요한지 설명 없이 숫자만 크게 올린다
- batch fetching은 보조 최적화이지 1차 설계 수단이 아니다
- query 설계, DTO projection, entity graph 같은 더 직접적인 해법을 가릴 수 있다
Hibernate도 @BatchSize는 N+1보다 낫지만 대개 JOIN FETCH나 DTO projection이 더 좋은 선택이라고 설명한다.
+232
View File
@@ -0,0 +1,232 @@
# Lock 예시
## 좋은 예시
### 예시 1. 현재 row를 선점한 뒤 곧바로 상태를 바꾸는 작업은 NOWAIT로 fail-fast 한다
```sql
SELECT id, status
FROM billing.payments
WHERE id = :paymentId
FOR UPDATE NOWAIT;
```
**좋은 이유:**
- 같은 결제를 동시에 하나만 처리해야 할 때 즉시 충돌을 드러낸다
- 기다림보다 빠른 실패가 더 맞는 사용자 요청에 적합하다
- 가장 강한 row lock이 필요한 상황을 명확하게 표현한다
PostgreSQL은 `NOWAIT`가 락을 즉시 못 잡으면 기다리지 않고 오류를 반환한다고 설명한다. `FOR UPDATE`는 해당 row에 대한 다른 수정과 row lock을 막는다.
### 예시 2. queue claim은 SKIP LOCKED를 작업 큐에만 제한해서 사용한다
```sql
WITH picked AS (
SELECT id
FROM integration.outbox_events
WHERE status = 'READY'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE integration.outbox_events e
SET status = 'IN_PROGRESS',
claimed_at = now()
FROM picked
WHERE e.id = picked.id
RETURNING e.id;
```
**좋은 이유:**
- 여러 worker가 같은 큐를 경쟁 소비할 때 이미 잠긴 row를 건너뛸 수 있다
- queue-like workload에 맞는 전형적인 사용법이다
- `ORDER BY``LIMIT`를 함께 두어 claim 범위를 결정적으로 만든다
PostgreSQL은 `SKIP LOCKED`가 inconsistent view를 만들기 때문에 general-purpose work에는 부적합하지만 queue-like table에는 사용할 수 있다고 설명한다. 또한 locking clause와 `LIMIT`를 함께 쓸 수 있고, `ORDER BY` 없는 제한 조회는 예측 가능한 subset을 보장하지 않는다고 설명한다.
### 예시 3. join query에서는 OF로 실제 lock 대상만 잠근다
```sql
SELECT o.id, o.status
FROM ordering.orders o
JOIN auth.users u ON u.id = o.user_id
WHERE o.id = :orderId
FOR UPDATE OF o NOWAIT;
```
**좋은 이유:**
- 주문 row만 잠그고, join에 참여한 사용자 row까지 불필요하게 잠그지 않는다
- join이 있다고 해서 전체 테이블 범위를 넓게 잠그지 않는다
- lock 범위가 SQL만 봐도 명확하다
PostgreSQL은 locking clause에 table list를 지정하면 해당 테이블에서 나온 row만 잠그고, list를 생략하면 statement에 사용된 모든 테이블에 적용될 수 있다고 설명한다.
### 예시 4. JPA에서는 특정 단건 처리 메서드에만 PESSIMISTIC_WRITE를 붙인다
```java
public interface PaymentRepository extends JpaRepository<Payment, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Payment p where p.id = :id")
Optional<Payment> findForUpdate(@Param("id") Long id);
}
```
**좋은 이유:**
- 일반 조회 메서드와 락 조회 메서드를 분리한다
- lock intent가 repository 메서드에 명시적으로 드러난다
- 단건 처리 유스케이스에서만 비관적 락을 올린다
Spring Data JPA는 query method에 `@Lock`으로 `LockModeType`을 지정할 수 있다고 설명한다. Jakarta Persistence는 `PESSIMISTIC_WRITE`가 즉시 장기 DB 락을 얻는 pessimistic lock mode라고 설명한다.
### 예시 5. non-key 상태 수정만 예정되어 있으면 SQL 레벨에서 FOR NO KEY UPDATE를 검토한다
```sql
SELECT id, status
FROM billing.payments
WHERE id = :paymentId
FOR NO KEY UPDATE;
```
**좋은 이유:**
- 이후 status 같은 non-key 컬럼만 바꿀 예정이라면 `FOR UPDATE`보다 약한 락으로 충분할 수 있다
- 필요 이상으로 강한 락을 쓰지 않는다
- ordinary update semantics와 더 잘 맞는다
PostgreSQL은 `FOR NO KEY UPDATE``FOR UPDATE`보다 약하고, key를 바꾸지 않는 일반 UPDATE가 이 수준의 락을 획득한다고 설명한다.
## 나쁜 예시
### 예시 1. 일반 목록 조회에 PESSIMISTIC_WRITE를 건다
```java
public interface UserRepository extends JpaRepository<User, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Page<User> findAll(Pageable pageable);
}
```
**나쁜 이유:**
- 단건 선점이 아니라 넓은 목록 조회에 비관적 락을 건다
- 대기, contention, deadlock 가능성을 크게 올린다
- locking use case와 plain listing use case가 섞여 있다
Spring Data JPA는 CRUD/query method에 `@Lock`을 적용할 수 있지만, PostgreSQL row lock은 같은 row에 대한 writer/locker를 막고 대기를 만들 수 있다. 이런 넓은 조회에 쓰면 비용이 과도하다.
### 예시 2. 일반 사용자 목록 API에 SKIP LOCKED를 사용한다
```sql
SELECT id, email
FROM auth.users
WHERE deleted_at IS NULL
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 20;
```
**나쁜 이유:**
- 일반 업무 조회에서 잠긴 row를 조용히 건너뛰면 결과 집합 의미가 깨진다
- 운영자와 사용자 모두 "왜 어떤 데이터가 안 보였는지" 설명하기 어렵다
- `SKIP LOCKED`는 queue-like workload가 아닌 경우 기본적으로 부적절하다
PostgreSQL은 `SKIP LOCKED`가 inconsistent view를 만들기 때문에 general-purpose work에는 적합하지 않다고 직접 경고한다.
### 예시 3. queue claim query에 OFFSET을 넣는다
```sql
SELECT id
FROM integration.outbox_events
WHERE status = 'READY'
ORDER BY id
OFFSET 100
LIMIT 10
FOR UPDATE SKIP LOCKED;
```
**나쁜 이유:**
- `OFFSET`으로 건너뛴 row도 잠길 수 있다
- claim 범위와 실제 lock 범위가 어긋날 수 있다
- queue 소비 문맥에서는 특히 예측이 어려워진다
PostgreSQL은 locking clause와 함께 `LIMIT`를 쓸 때 필요한 row까지만 잠그지만, `OFFSET`으로 건너뛴 row도 잠길 수 있다고 설명한다.
### 예시 4. ordinary row 처리에 LOCK TABLE을 사용한다
```sql
LOCK TABLE billing.payments IN ACCESS EXCLUSIVE MODE;
```
**나쁜 이유:**
- plain SELECT까지 막을 수 있는 매우 강한 table-level lock이다
- 특정 row 경쟁을 해결하려는 문제에 비해 범위가 과도하다
- 일반 비즈니스 처리에 쓰기에는 영향 범위가 너무 크다
PostgreSQL은 `ACCESS EXCLUSIVE`가 모든 lock mode와 충돌하고, plain SELECT를 막는 유일한 lock mode라고 설명한다.
### 예시 5. 락을 잡은 뒤 외부 호출을 오래 수행한다
```java
@Transactional
public void approve(Long paymentId) {
Payment payment = paymentRepository.findForUpdate(paymentId)
.orElseThrow();
externalGatewayClient.call(payment); // 오래 걸리는 네트워크 호출
payment.approve();
}
```
**나쁜 이유:**
- row lock을 쥔 채 외부 대기 시간을 모두 끌고 간다
- 다른 트랜잭션이 같은 row를 오래 기다리게 만든다
- deadlock과 lock wait 문제를 악화시킨다
PostgreSQL은 lock 요청이 deadlock이 아니면 오래 기다릴 수 있고, 애플리케이션이 긴 시간 트랜잭션을 열어 두는 것은 나쁜 생각이라고 설명한다. idle in transaction도 오래 유지되면 문제가 된다고 설명한다.
### 예시 6. join query에서 lock 대상을 좁히지 않는다
```sql
SELECT o.id, u.id
FROM ordering.orders o
JOIN auth.users u ON u.id = o.user_id
WHERE o.id = :orderId
FOR UPDATE;
```
**나쁜 이유:**
- 실제로는 order row만 선점하면 되는데 join 참여 row까지 넓게 잠글 수 있다
- lock 범위가 과도하고, 예상보다 큰 contention을 만들 수 있다
- 이런 query는 `FOR UPDATE OF o` 같은 형태로 범위를 좁히는 편이 안전하다
PostgreSQL은 locking clause에서 table list를 생략하면 statement에 사용된 모든 테이블에 영향을 줄 수 있다고 설명한다.
### 예시 7. PessimisticLockException과 LockTimeoutException을 같은 것으로 처리한다
```java
try {
repository.findForUpdate(id);
} catch (PersistenceException e) {
return;
}
```
**나쁜 이유:**
- transaction-level rollback이 난 경우와 statement-level timeout만 난 경우를 구분하지 못한다
- 후속 처리와 재시도 정책이 흐려진다
- 잠금 실패 의미를 application boundary에서 잃어버린다
Jakarta Persistence는 pessimistic lock failure가 transaction rollback이면 `PessimisticLockException`, statement rollback이면 `LockTimeoutException`이라고 구분한다. 두 예외는 현재 트랜잭션 상태도 다를 수 있다.
+222
View File
@@ -0,0 +1,222 @@
# Migration 예시
## 좋은 예시
### 예시 1. 새 nullable 컬럼 추가 → backfill → default 설정으로 나눈다
```sql
ALTER TABLE auth.users
ADD COLUMN status text;
-- 배치/스크립트로 backfill 수행
-- UPDATE auth.users SET status = 'ACTIVE' WHERE status IS NULL;
ALTER TABLE auth.users
ALTER COLUMN status SET DEFAULT 'ACTIVE';
```
**좋은 이유:**
- 컬럼 추가를 빠른 additive change로 분리했다
- 과거 데이터 보정과 future default를 분리했다
- `SET DEFAULT`가 기존 row를 바꾸지 않는다는 점을 전제로 설계했다
PostgreSQL은 컬럼 추가가 가능하고, default 변경은 이후 insert/update에만 영향을 주며 기존 row를 바꾸지 않는다고 설명한다.
### 예시 2. 큰 테이블의 CHECK/FK는 NOT VALID 후 나중에 검증한다
```sql
ALTER TABLE ordering.orders
ADD CONSTRAINT ck_orders__amount_positive
CHECK (amount > 0) NOT VALID;
ALTER TABLE ordering.orders
VALIDATE CONSTRAINT ck_orders__amount_positive;
```
**좋은 이유:**
- 제약 추가 시점의 긴 full scan 영향을 줄인다
- 새로 들어오는/갱신되는 row에는 바로 제약이 적용된다
- 기존 데이터 검증은 별도 단계로 분리한다
PostgreSQL은 `NOT VALID` 제약이 기존 row scan을 건너뛰고, 이후 `VALIDATE CONSTRAINT`로 검증할 수 있으며 validation은 `SHARE UPDATE EXCLUSIVE` lock으로 수행된다고 설명한다.
### 예시 3. NOT NULL은 backfill과 증명 단계를 거쳐 올린다
```sql
ALTER TABLE auth.users
ADD CONSTRAINT ck_users__status_not_null
CHECK (status IS NOT NULL) NOT VALID;
-- backfill 수행
-- UPDATE auth.users SET status = 'ACTIVE' WHERE status IS NULL;
ALTER TABLE auth.users
VALIDATE CONSTRAINT ck_users__status_not_null;
ALTER TABLE auth.users
ALTER COLUMN status SET NOT NULL;
ALTER TABLE auth.users
DROP CONSTRAINT ck_users__status_not_null;
```
**좋은 이유:**
- null 방지 강화를 한 번에 몰아넣지 않았다
- PostgreSQL이 valid CHECK로 null 불가능함을 증명하면 `SET NOT NULL` scan을 건너뛸 수 있는 점과 잘 맞는다
- 운영 중 영향도를 줄이기 좋은 패턴이다
PostgreSQL은 `SET NOT NULL`이 보통 table scan을 하지만, valid CHECK가 null 불가능함을 증명하면 scan을 생략할 수 있다고 설명한다.
### 예시 4. 운영 인덱스는 CONCURRENTLY로 만들고, 필요하면 제약으로 승격한다
```sql
CREATE UNIQUE INDEX CONCURRENTLY uq_users__email_idx
ON auth.users (email);
ALTER TABLE auth.users
ADD CONSTRAINT uq_users__email
UNIQUE USING INDEX uq_users__email_idx;
```
**좋은 이유:**
- 인덱스 build 중 write block을 줄인다
- 기존 인덱스를 활용해 빠르게 UNIQUE 제약으로 승격한다
- 큰 테이블의 unique 추가에 적합한 공식 경로다
PostgreSQL은 `CREATE INDEX CONCURRENTLY`가 production environment에 유용하고, 기존 unique index를 UNIQUE/PRIMARY KEY 제약으로 전환할 수 있다고 설명한다.
### 예시 5. PostgreSQL 11+에서는 상수 default 컬럼 추가를 안전한 후보로 본다
```sql
ALTER TABLE auth.sessions
ADD COLUMN source text DEFAULT 'LOCAL';
```
**좋은 이유:**
- 상수 default라면 PostgreSQL 11+에서 빠른 metadata 기반 처리 경로에 들어갈 수 있다
- 운영 중 큰 rewrite를 피할 가능성이 높다
- 상수 default라는 점이 분명하다
PostgreSQL 11 release notes와 현재 ALTER TABLE 문서는 non-volatile/default constant 컬럼 추가가 table rewrite를 피할 수 있다고 설명한다.
## 나쁜 예시
### 예시 1. 큰 테이블에 volatile default를 바로 추가한다
```sql
ALTER TABLE audit.audit_logs
ADD COLUMN created_bucket timestamp with time zone DEFAULT clock_timestamp();
```
**나쁜 이유:**
- `clock_timestamp()`는 volatile default다
- PostgreSQL은 이런 경우 전체 테이블과 인덱스 rewrite가 일어날 수 있다고 설명한다
- 운영 테이블에서는 매우 위험할 수 있다
공식 문서는 volatile default 컬럼 추가가 rewrite를 유발한다고 설명한다.
### 예시 2. 운영 인덱스를 일반 CREATE INDEX로 바로 만든다
```sql
CREATE INDEX ix_orders__created_at
ON ordering.orders (created_at);
```
**나쁜 이유:**
- write를 막을 수 있는 일반 index build를 사용한다
- 운영 중 대형 테이블에는 영향도가 과도할 수 있다
- 이런 경우는 `CREATE INDEX CONCURRENTLY`를 먼저 검토해야 한다
PostgreSQL은 `CREATE INDEX CONCURRENTLY`가 write를 막지 않고 production environment에 유용하다고 설명한다. 그 반대 의미로, 일반 build는 운영 중 더 보수적으로 다뤄야 한다.
### 예시 3. CREATE INDEX CONCURRENTLY를 트랜잭션 블록 안에 넣는다
```sql
BEGIN;
CREATE INDEX CONCURRENTLY ix_users__email
ON auth.users (email);
COMMIT;
```
**나쁜 이유:**
- PostgreSQL은 `CREATE INDEX CONCURRENTLY`가 transaction block 안에서 실행될 수 없다고 명시한다
- migration 도구가 이 구분을 지원하지 않으면 배포 시 실패한다
- non-transactional step으로 분리해야 한다
공식 문서는 regular `CREATE INDEX`는 transaction block 안에서 가능하지만, `CREATE INDEX CONCURRENTLY`는 불가능하다고 설명한다.
### 예시 4. backfill 없이 바로 NOT NULL을 건다
```sql
ALTER TABLE auth.users
ADD COLUMN status text;
ALTER TABLE auth.users
ALTER COLUMN status SET NOT NULL;
```
**나쁜 이유:**
- 기존 row가 null일 가능성을 무시한다
- `SET NOT NULL`은 보통 전체 테이블 scan을 요구한다
- 데이터 보정 없이 곧바로 강한 제약을 올리는 구조다
PostgreSQL은 `SET NOT NULL`이 기존 row에 null이 없어야 하고, 보통 이를 확인하기 위해 전체 테이블을 스캔한다고 설명한다.
### 예시 5. 타입 변경을 무심코 직접 수행한다
```sql
ALTER TABLE billing.payments
ALTER COLUMN amount TYPE numeric(19,4);
```
**나쁜 이유:**
- 타입 변경은 기본적으로 rewrite/rebuild 후보다
- 큰 테이블에서는 시간과 디스크 사용량이 매우 커질 수 있다
- 더 안전한 staged migration이 필요한지 먼저 검토해야 한다
PostgreSQL은 타입 변경이 보통 테이블과 인덱스를 rewrite/rebuild하고, 상당한 시간과 최대 거의 두 배 디스크를 일시적으로 요구할 수 있다고 설명한다.
### 예시 6. 앱 호환성 검증 전에 rename/drop부터 수행한다
```sql
ALTER TABLE auth.users
RENAME COLUMN email TO login_id;
ALTER TABLE auth.users
DROP COLUMN username;
```
**나쁜 이유:**
- 기술적으로는 가능한 DDL이지만, 애플리케이션/쿼리/배치/운영 스크립트와의 호환성을 즉시 깨뜨릴 수 있다
- backward-compatible rollout 경로가 없다
- 이런 변경은 마지막 cleanup 단계에서만 허용하는 것이 안전하다
PostgreSQL은 rename/drop을 지원하지만, 프로젝트 운영 기준에서는 destructive/비호환 변경으로 본다. 특히 `DROP COLUMN`은 빠르지만 디스크 공간도 즉시 줄지 않는다.
### 예시 7. failed concurrent build 뒤 INVALID 인덱스를 방치한다
```sql
-- 실패한 CREATE INDEX CONCURRENTLY 이후
-- 아무 조치 없이 배포 종료
```
**나쁜 이유:**
- PostgreSQL은 실패한 concurrent build가 INVALID 인덱스를 남길 수 있다고 설명한다
- 이 인덱스는 query에는 안 쓰여도 update overhead는 계속 발생한다
- drop 후 재시도나 `REINDEX INDEX CONCURRENTLY` 같은 정리 절차가 필요하다
공식 문서는 invalid index가 남을 수 있고, 권장 복구 방법은 drop 후 다시 시도하거나 concurrent reindex라고 설명한다.
+267
View File
@@ -0,0 +1,267 @@
# N+1 예시
## 좋은 예시
### 예시 1. 상세 조회의 to-one 연관은 join fetch로 한 번에 가져온다
```java
@Query("""
select s
from Session s
join fetch s.user
where s.id = :id
""")
Optional<Session> findDetailById(@Param("id") Long id);
```
**좋은 이유:**
- Session 상세 조회에서 필요한 user를 같은 query로 가져온다
- to-one 연관에 대한 N+1 위험을 가장 직접적으로 제거한다
- 기본 매핑은 LAZY로 유지하면서, 이 use case에서만 eager 요구를 적용한다
Hibernate는 JOIN FETCH가 @ManyToOne / @OneToOne에 좋고, 필요한 association은 persistence context가 닫히기 전에 fetch하는 것이 가장 좋은 대응이라고 설명한다.
### 예시 2. query 단위 fetch plan은 EntityGraph로 선언한다
```java
@Entity
@NamedEntityGraph(
name = "User.withRoles",
attributeNodes = @NamedAttributeNode("roles")
)
@Table(name = "users", schema = "auth")
public class User {
// ...
}
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(value = "User.withRoles", type = EntityGraph.EntityGraphType.FETCH)
Optional<User> findByEmail(String email);
}
```
**좋은 이유:**
- 정적 매핑을 EAGER로 바꾸지 않고, 특정 repository method에서만 roles를 함께 가져온다
- fetch plan이 use case 단위로 분리된다
- named graph를 재사용할 수 있다
Jakarta Persistence는 fetchgraph와 loadgraph를 표준으로 정의하고, Spring Data JPA는 repository method에 @EntityGraph를 붙여 fetch/load graph를 적용할 수 있다고 설명한다.
### 예시 3. read-only 목록은 DTO projection으로 닫는다
```java
public record UserSummary(Long id, String email, String status) {}
@Query("""
select new com.example.auth.user.UserSummary(u.id, u.email, u.status)
from User u
where u.deletedAt is null
order by u.id desc
""")
List<UserSummary> findActiveUserSummaries();
```
**좋은 이유:**
- 목록 조회에서 엔티티와 연관 그래프 전체를 관리하지 않는다
- 필요한 컬럼만 가져오므로 N+1 설계 자체를 피한다
- 가장 단순하고 예측 가능한 목록 조회 구조다
Hibernate는 @BatchSize보다 DTO projection이나 JOIN FETCH가 더 좋은 대안인 경우가 많다고 설명한다. 특히 read-only 목록에서는 DTO projection이 더 신뢰도 높은 선택이다.
### 예시 4. 여러 부모의 같은 컬렉션을 뒤이어 접근한다면 BatchSize를 보조적으로 사용한다
```java
@Entity
@Table(name = "users", schema = "auth")
public class User {
@Id
private Long id;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 50)
private List<UserRole> roles = new ArrayList<>();
}
```
**좋은 이유:**
- 여러 User의 roles 컬렉션을 순차적으로 초기화할 때 secondary select를 묶어 줄 수 있다
- N+1을 완화하지만, 기본 fetch를 EAGER로 바꾸지 않는다
- 보조 최적화로서 의미가 분명하다
Hibernate는 @BatchSize가 여러 연관을 single database round trip 또는 적은 수의 round trip으로 묶을 수 있다고 설명한다. 다만 DTO projection이나 JOIN FETCH가 더 좋은 대안인 경우가 많다고도 함께 설명한다.
### 예시 5. 페이징 목록은 ID 페이지 조회와 후속 query를 분리한다
```java
@Query("""
select u.id
from User u
where u.deletedAt is null
order by u.createdAt desc, u.id desc
""")
Page<Long> findUserIds(Pageable pageable);
@Query("""
select distinct u
from User u
left join fetch u.department
where u.id in :ids
""")
List<User> findUsersWithDepartment(@Param("ids") Collection<Long> ids);
```
**좋은 이유:**
- page boundary는 root ID query에서 안정적으로 결정한다
- 후속 query는 page 범위 안에서만 필요한 연관을 가져온다
- 컬렉션 fetch join + pagination 충돌을 피하면서 N+1도 막는다
Hibernate는 paged query에서 fetch join, 특히 many-valued association에 대한 fetch join을 피하라고 설명한다. 이 분리 전략은 해당 제약을 피해 가는 실무 best practice다.
## 나쁜 예시
### 예시 1. N+1을 막겠다며 to-one을 EAGER로 선언한다
```java
@Entity
@Table(name = "sessions", schema = "auth")
public class Session {
@Id
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
}
```
**나쁜 이유:**
- 이 설정은 모든 query에서 user 로딩 비용을 고정한다
- JPQL query에서 fetch join을 빠뜨리면 오히려 secondary select가 association마다 발생해 N+1로 이어질 수 있다
- 문제를 매핑에 숨기고 use case 단위 제어를 잃는다
Hibernate는 EAGER fetching is almost always a bad choice라고 설명하고, EAGER association을 query에서 join fetch하지 않으면 secondary select로 N+1이 생길 수 있다고 명시한다.
### 예시 2. 루트 목록을 가져온 뒤 컬렉션을 루프에서 접근한다
```java
List<User> users = entityManager.createQuery("""
select u
from User u
where u.deletedAt is null
order by u.id desc
""", User.class).getResultList();
for (User user : users) {
user.getRoles().size();
}
```
**나쁜 이유:**
- root query 1번 뒤에, user 수만큼 roles 초기화 SQL이 반복될 수 있다
- 가장 전형적인 N+1 구조다
- 목록 조회라면 DTO projection, join fetch, batch fetching 등으로 구조를 바꿔야 한다
Hibernate는 collection을 first access 시 secondary select로 초기화하는 FetchMode.SELECT가 N+1을 일으킬 수 있다고 설명한다.
### 예시 3. 여러 컬렉션을 한 query에서 병렬 fetch join한다
```java
@Query("""
select u
from User u
left join fetch u.roles
left join fetch u.sessions
left join fetch u.loginHistories
where u.id = :id
""")
Optional<User> findEverything(@Param("id") Long id);
```
**나쁜 이유:**
- 여러 to-many/collection을 동시에 fetch join하면 Cartesian product가 발생할 수 있다
- 중복 row가 폭증하고 성능이 매우 나빠질 수 있다
- “한 번에 다 가져오기”가 오히려 더 위험하다
Hibernate는 multiple collections or to-many associations in parallel fetch join results in a Cartesian product and might exhibit very poor performance라고 명시한다.
### 예시 4. pagination query에 컬렉션 fetch join을 사용한다
```java
@Query("""
select u
from User u
left join fetch u.roles
order by u.id desc
""")
Page<User> findAllWithRoles(Pageable pageable);
```
**나쁜 이유:**
- many-valued association fetch join과 pagination이 충돌한다
- limit가 DB가 아니라 메모리에서 적용될 수 있다
- page boundary와 성능이 모두 불안정해진다
Hibernate는 fetch joins should usually be avoided in limited or paged queries라고 설명하고, 컬렉션 fetch join과 pagination 조합은 terrible performance characteristics를 만들 수 있다고 경고한다.
### 예시 5. 트랜잭션 밖에서 lazy 연관이 알아서 초기화되기를 기대한다
```java
@Transactional(readOnly = true)
public User getUser(Long id) {
return userRepository.findById(id).orElseThrow();
}
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
User user = userService.getUser(id);
return new UserResponse(
user.getId(),
user.getEmail(),
user.getRoles().stream().map(UserRole::getName).toList()
);
}
```
**나쁜 이유:**
- service 트랜잭션 종료 후 controller에서 lazy collection 접근을 시도한다
- 환경에 따라 LazyInitializationException이 발생하거나, 우연한 추가 query에 의존할 수 있다
- fetch plan 책임이 presentation 계층으로 새어 나간다
Hibernate는 필요한 association은 persistence context가 닫히기 전에 fetch하는 것이 가장 좋은 대응이라고 설명한다.
### 예시 6. BatchSize만 크게 올려 구조 문제를 덮는다
```java
@Entity
public class User {
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 1000)
private List<UserRole> roles;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 1000)
private List<Session> sessions;
}
```
**나쁜 이유:**
- 왜 secondary select 구조를 유지하는지 설명 없이 숫자만 키운다
- batch fetching은 보조 완화 수단이지 1차 설계 수단이 아니다
- DTO projection, join fetch, query 분리 같은 더 직접적인 해결책을 가릴 수 있다
Hibernate도 @BatchSize는 N+1보다 낫지만, 대부분의 경우 DTO projection이나 JOIN FETCH가 더 좋은 대안이라고 설명한다.
+223
View File
@@ -0,0 +1,223 @@
# Pagination Query 예시
## 좋은 예시
### 예시 1. 얕은 관리자 목록은 결정적 ORDER BY + LIMIT/OFFSET으로 처리한다
```sql
SELECT id, email, created_at
FROM auth.users
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT :limit
OFFSET :offset;
```
**좋은 이유:**
- `LIMIT/OFFSET`을 쓰면서 결정적 `ORDER BY`를 함께 둔다
- `created_at` 동률을 `id`로 해소한다
- active-row predicate가 soft delete 계약과 맞는다
PostgreSQL은 `LIMIT/OFFSET``ORDER BY`가 없으면 예측 불가능한 subset을 얻게 된다고 설명하고, 큰 `OFFSET`은 비효율적일 수 있다고 설명한다.
### 예시 2. 무한 스크롤은 keyset/cursor 방식으로 설계한다
```sql
SELECT id, created_at, title
FROM board.posts
WHERE deleted_at IS NULL
AND (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT :limit;
```
**좋은 이유:**
- 깊은 페이지에서도 큰 `OFFSET`을 피할 수 있다
- 정렬 기준과 seek 조건이 같은 의미를 가진다
- `(created_at, id)`가 tie-breaker까지 포함한 cursor 역할을 한다
PostgreSQL은 row constructor comparison의 `<`, `>`가 좌→우 비교로 동작한다고 설명하고, `ORDER BY ... LIMIT n`에서 B-tree ordered scan이 유리할 수 있다고 설명한다. 이 예시는 그 두 기능을 pagination에 적용한 전형적 패턴이다.
### 예시 3. 필터와 정렬이 함께 있는 페이지 query는 인덱스와 같이 설계한다
```sql
SELECT id, tenant_id, status, created_at
FROM ordering.orders
WHERE tenant_id = :tenantId
AND status = 'READY'
ORDER BY created_at DESC, id DESC
LIMIT :limit;
```
```sql
CREATE INDEX ix_orders__tenant_status_created_at_id
ON ordering.orders (tenant_id, status, created_at DESC, id DESC);
```
**좋은 이유:**
- leading equality filter 뒤에 정렬 키를 배치한다
- pagination query와 인덱스가 같은 access pattern을 공유한다
- 적은 수의 앞 row를 직접 가져오기에 유리하다
PostgreSQL은 multicolumn B-tree가 leading equality와 그 다음 inequality/정렬 문맥에서 가장 효율적이라고 설명하고, `ORDER BY ... LIMIT n`에서 정렬을 만족하는 인덱스가 특히 유용하다고 설명한다.
### 예시 4. total count가 꼭 필요할 때만 별도 query로 분리한다
```sql
SELECT id, email, created_at
FROM auth.users
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT :limit
OFFSET :offset;
```
```sql
SELECT count(*)
FROM auth.users
WHERE deleted_at IS NULL;
```
**좋은 이유:**
- 페이지 조회와 총건수 계산을 명시적으로 분리한다
- 목록 일부 조회와 전체 집계의 비용을 섞지 않는다
- count가 필요 없는 API에서는 두 번째 query를 생략할 수 있다
PostgreSQL은 `count(*)`가 입력 row 수를 계산하는 aggregate라고 설명하고, planner statistics인 `reltuples`는 근사치라고 설명한다.
### 예시 5. 다음 페이지 존재 여부만 필요하면 한 건 더 가져온다
```sql
SELECT id, created_at, title
FROM board.posts
WHERE deleted_at IS NULL
AND (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT :limitPlusOne;
```
**좋은 이유:**
- exact total count 없이도 next page 존재 여부를 계산할 수 있다
- 큰 목록에서 count 비용을 매번 강제하지 않는다
- keyset/cursor 방식과 잘 맞는다
PostgreSQL 공식 문서는 `LIMIT`이 결과 일부를 가져오는 기본 도구임을 설명하고, 큰 `OFFSET`이 비효율적일 수 있음을 설명한다. 이 예시는 total count를 피하는 실무 패턴이다.
## 나쁜 예시
### 예시 1. LIMIT/OFFSET을 정렬 없이 사용한다
```sql
SELECT id, email
FROM auth.users
LIMIT :limit
OFFSET :offset;
```
**나쁜 이유:**
- 어떤 row 집합을 잘라 오는지 정의되지 않는다
- `LIMIT/OFFSET` 값에 따라 plan과 결과 subset이 달라질 수 있다
- 페이지 계약이 성립하지 않는다
PostgreSQL은 `ORDER BY` 없이 `LIMIT`을 쓰면 예측 불가능한 subset을 얻게 된다고 명시한다.
### 예시 2. tie-breaker 없는 정렬로 페이지를 자른다
```sql
SELECT id, created_at, title
FROM board.posts
ORDER BY created_at DESC
LIMIT :limit
OFFSET :offset;
```
**나쁜 이유:**
- 같은 `created_at`을 가진 row의 상대 순서가 API 계약상 명시되지 않는다
- 페이지 경계가 흔들리거나 중복/누락처럼 보일 수 있다
- `id` 같은 유니크 tie-breaker가 필요하다
PostgreSQL은 `LIMIT`과 함께 사용할 때 `ORDER BY`가 결과를 unique order로 제약하는 것이 중요하다고 설명한다.
### 예시 3. 깊은 페이지를 큰 OFFSET으로 계속 읽는다
```sql
SELECT id, created_at, title
FROM board.posts
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 20
OFFSET 200000;
```
**나쁜 이유:**
- 건너뛴 200,000 row도 서버 내부에서 계산해야 한다
- 깊은 페이지로 갈수록 비용이 커질 수 있다
- 이런 요구는 keyset/cursor 방식이 더 적합하다
PostgreSQL은 `OFFSET`으로 건너뛴 row도 내부에서 계산되어야 하므로 큰 `OFFSET`이 비효율적일 수 있다고 설명한다.
### 예시 4. keyset인데 seek 조건과 정렬이 맞지 않는다
```sql
SELECT id, created_at, title
FROM board.posts
WHERE id < :lastId
ORDER BY created_at DESC, id DESC
LIMIT :limit;
```
**나쁜 이유:**
- `ORDER BY``(created_at, id)` 의미인데 seek 조건은 `id`만 본다
- 페이지 경계가 정렬 의미와 어긋난다
- keyset/cursor는 정렬 기준과 동일한 키 의미를 써야 한다
PostgreSQL의 row comparison은 여러 정렬 키를 좌→우로 비교할 수 있으므로, 이런 경우 `(created_at, id)` 형태가 더 자연스럽다.
### 예시 5. soft delete 테이블인데 active-row predicate 없이 페이지를 자른다
```sql
SELECT id, email, created_at
FROM auth.users
ORDER BY created_at DESC, id DESC
LIMIT :limit;
```
**나쁜 이유:**
- 삭제 row가 일반 목록에 섞일 수 있다
- active-row partial index와도 잘 맞지 않는다
- soft delete 기본 조회 계약을 깨뜨린다
PostgreSQL partial index는 query의 WHERE가 predicate를 함의할 때 가장 자연스럽게 사용되므로, soft delete 테이블은 active-row predicate를 일관되게 포함해야 한다.
### 예시 6. 페이지 query와 exact count를 항상 묶는다
```sql
SELECT id, email, created_at
FROM auth.users
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT :limit
OFFSET :offset;
SELECT count(*)
FROM auth.users
WHERE deleted_at IS NULL;
```
**나쁜 이유:**
- 총건수가 항상 필요한지 검토하지 않는다
- 목록 일부 조회와 전체 집계를 무조건 함께 수행한다
- 큰 목록에서는 불필요한 비용이 될 수 있다
PostgreSQL은 `count(*)`가 입력 row 수를 계산하는 aggregate이고, planner statistics는 근사치라고 설명한다. exact total count는 별도 비용을 가진다고 보고 설계해야 한다.
+261
View File
@@ -0,0 +1,261 @@
# PK / FK / UNIQUE / CHECK 예시
## 좋은 예시
### 예시 1. 대표 식별자는 PK, 비즈니스 고유성은 UNIQUE로 분리한다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY,
email text NOT NULL,
username text NOT NULL,
created_at timestamp with time zone NOT NULL,
CONSTRAINT pk_users PRIMARY KEY (id),
CONSTRAINT uq_users__email UNIQUE (email),
CONSTRAINT uq_users__username UNIQUE (username)
);
```
**좋은 이유:**
- 대표 식별자 id를 PK로 둔다
- 이메일과 username은 business unique로 분리한다
- identity에만 기대지 않고 PK가 유일성을 보장한다
PostgreSQL은 PK가 대표 식별자이며 unique B-tree 인덱스를 자동 생성한다고 설명하고, identity는 자동 생성일 뿐 uniqueness를 보장하지 않는다고 명시한다.
### 예시 2. 필수 관계는 FK + NOT NULL, 삭제 정책은 관계 의미에 맞춘다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY,
CONSTRAINT pk_users PRIMARY KEY (id)
);
CREATE TABLE auth.sessions (
id bigint GENERATED ALWAYS AS IDENTITY,
user_id bigint NOT NULL,
expires_at timestamp with time zone NOT NULL,
CONSTRAINT pk_sessions PRIMARY KEY (id),
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
REFERENCES auth.users (id)
ON DELETE CASCADE
);
CREATE INDEX ix_sessions__user_id ON auth.sessions (user_id);
```
**좋은 이유:**
- 세션은 사용자에 종속된 구성요소이므로 ON DELETE CASCADE가 자연스럽다
- 필수 관계를 NOT NULL로 닫았다
- FK 컬럼 인덱스를 별도로 생성했다
PostgreSQL 공식 문서도 구성요소 관계에는 CASCADE가 적절할 수 있고, FK는 참조하는 쪽 인덱스를 자동 생성하지 않는다고 설명한다.
### 예시 3. soft delete 환경의 조건부 고유성은 partial unique index로 표현한다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL,
email text NOT NULL,
deleted_at timestamp with time zone,
CONSTRAINT pk_users PRIMARY KEY (id)
);
CREATE UNIQUE INDEX uq_users__tenant_email_active
ON auth.users (tenant_id, email)
WHERE deleted_at IS NULL;
```
**좋은 이유:**
- “삭제되지 않은 사용자만 tenant 내 email 유일”이라는 조건부 고유성을 정확히 표현한다
- 일반 UNIQUE constraint로는 일부 행에만 적용되는 uniqueness를 표현할 수 없다
PostgreSQL 공식 문서는 일부 행에만 적용되는 uniqueness restriction은 unique constraint가 아니라 unique partial index로 표현해야 한다고 설명한다.
### 예시 4. CHECK는 같은 행 안의 불변식에만 사용한다
```sql
CREATE TABLE billing.payments (
id bigint GENERATED ALWAYS AS IDENTITY,
amount numeric(19,4) NOT NULL,
refunded_amount numeric(19,4) NOT NULL DEFAULT 0,
status text NOT NULL,
CONSTRAINT pk_payments PRIMARY KEY (id),
CONSTRAINT ck_payments__amount_positive CHECK (amount > 0),
CONSTRAINT ck_payments__refunded_amount_range
CHECK (refunded_amount >= 0 AND refunded_amount <= amount),
CONSTRAINT ck_payments__status
CHECK (status IN ('PENDING', 'PAID', 'CANCELLED', 'REFUNDED'))
);
```
**좋은 이유:**
- 모두 같은 행 안에서 평가 가능한 규칙이다
- null 금지는 NOT NULL로, 값 범위는 CHECK로 역할을 분리했다
- CHECK에 cross-table 의존이 없다
PostgreSQL은 CHECK가 row-local invariant에 적합하고, null 금지는 NOT NULL로 표현하는 것이 맞다고 설명한다.
### 예시 5. 조인 테이블은 복합 PK를 예외적으로 사용할 수 있다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY,
CONSTRAINT pk_users PRIMARY KEY (id)
);
CREATE TABLE auth.roles (
id bigint GENERATED ALWAYS AS IDENTITY,
CONSTRAINT pk_roles PRIMARY KEY (id)
);
CREATE TABLE auth.user_roles (
user_id bigint NOT NULL,
role_id bigint NOT NULL,
granted_at timestamp with time zone NOT NULL,
CONSTRAINT pk_user_roles PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles__users FOREIGN KEY (user_id)
REFERENCES auth.users (id)
ON DELETE CASCADE,
CONSTRAINT fk_user_roles__roles FOREIGN KEY (role_id)
REFERENCES auth.roles (id)
ON DELETE RESTRICT
);
```
**좋은 이유:**
- 조인 테이블에서는 (user_id, role_id) 조합 자체가 자연스러운 정체성이다
- 복합 PK가 FK와 겹쳐도 의미가 분명하다
- 삭제 정책도 관계 의미에 따라 다르게 선택했다
PostgreSQL은 복합 PK와 FK를 모두 지원하며, 문서 예시에서도 many-to-many 구조에서 이런 형태를 보여준다. 다만 JPA에서는 복합 PK가 별도 키 클래스를 요구하므로 예외적으로 사용하는 편이 안전하다.
## 나쁜 예시
### 예시 1. PK 없이 UNIQUE만으로 테이블을 운영한다
```sql
CREATE TABLE auth.users (
email text NOT NULL UNIQUE,
username text NOT NULL UNIQUE
);
```
**나쁜 이유:**
- 대표 식별자가 없다
- FK 기본 참조 대상과 ORM 식별 의미가 불명확하다
- “고유한 컬럼 몇 개”와 “대표 PK”의 역할이 섞인다
PostgreSQL도 모든 테이블에 PK를 강제하지는 않지만, 일반적으로 두는 것이 가장 좋다고 설명한다.
### 예시 2. nullable UNIQUE를 두고 NULL도 하나만 허용된다고 착각한다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
external_subject text UNIQUE
);
```
**나쁜 이유:**
- PostgreSQL 기본 동작에서는 NULL이 서로 다른 값으로 취급된다
- 따라서 external_subject가 NULL인 행이 여러 개 들어갈 수 있다
- “값이 없으면 하나만 허용” 의미라면 현재 설계는 틀렸다
이 경우는 NOT NULL, UNIQUE NULLS NOT DISTINCT, 또는 모델 재설계가 필요하다.
### 예시 3. 필수 관계인데 nullable FK로 열어 둔다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);
CREATE TABLE auth.sessions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint,
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
REFERENCES auth.users (id)
);
```
**나쁜 이유:**
- 세션이 반드시 사용자에 속해야 한다면 user_id는 NOT NULL이어야 한다
- FK만으로는 NULL을 막지 못한다
- 필수 관계를 스키마가 보장하지 못한다
PostgreSQL은 참조 컬럼에 null이 있으면 FK를 회피할 수 있다고 설명한다.
### 예시 4. 독립 객체 관계에 무분별하게 CASCADE를 건다
```sql
CREATE TABLE catalog.products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);
CREATE TABLE ordering.order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL,
CONSTRAINT fk_order_items__products FOREIGN KEY (product_id)
REFERENCES catalog.products (id)
ON DELETE CASCADE
);
```
**나쁜 이유:**
- product와 order item 관계는 도메인에 따라 독립 객체일 수 있다
- 상품 삭제가 주문 이력 일부를 연쇄 삭제하면 운영상 매우 위험할 수 있다
- 이 경우는 RESTRICT 또는 NO ACTION이 더 자연스러운 경우가 많다
PostgreSQL 공식 문서도 독립 객체 관계라면 RESTRICT 또는 NO ACTION이 더 적절하다고 설명한다.
### 예시 5. null 금지를 CHECK로 우회한다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text,
CONSTRAINT ck_users__email_not_null CHECK (email IS NOT NULL)
);
```
**나쁜 이유:**
- null 금지는 NOT NULL이 더 직접적이고 효율적이다
- 역할이 다른 제약을 섞고 있다
- 팀 규칙 해석도 흐려진다
PostgreSQL 공식 문서도 CHECK (col IS NOT NULL)보다 explicit NOT NULL이 더 효율적이라고 설명한다.
### 예시 6. cross-row 규칙을 CHECK로 해결하려 한다
```sql
CREATE TABLE auth.user_roles (
user_id bigint NOT NULL,
role_id bigint NOT NULL,
is_primary boolean NOT NULL,
CONSTRAINT ck_user_roles__only_one_primary
CHECK (
NOT is_primary
OR role_id IS NOT NULL
)
);
```
**나쁜 이유:**
- “사용자당 primary role은 하나만” 같은 규칙은 이런 CHECK로 보장되지 않는다
- CHECK는 다른 행을 기준으로 유일성을 유지하는 수단이 아니다
- 이런 요구는 (user_id) 조건부 unique index 같은 방식으로 풀어야 한다
PostgreSQL은 cross-row / cross-table 규칙에 CHECK를 쓰지 말고 UNIQUE, FK, EXCLUDE, trigger를 검토하라고 안내한다.
+310
View File
@@ -0,0 +1,310 @@
# Query 예시
## 좋은 예시
### 예시 1. LIMIT에는 결정적 ORDER BY를 함께 둔다
```sql
SELECT id, user_id, created_at
FROM auth.sessions
WHERE user_id = :userId
ORDER BY created_at DESC, id DESC
LIMIT 20;
```
**좋은 이유:**
- LIMIT 결과가 어떤 20건인지 결정적으로 정의된다
- created_at 동률에서도 id가 tie-breaker가 된다
- 같은 query를 반복 실행하거나 페이지를 넘겨도 의미가 흔들리지 않는다
PostgreSQL은 LIMIT 사용 시 ORDER BY가 없으면 예측 불가능한 부분집합을 얻게 된다고 설명한다.
### 예시 2. LEFT JOIN의 매칭 조건은 ON에 둔다
```sql
SELECT u.id, u.email, s.id AS active_session_id
FROM auth.users u
LEFT JOIN auth.sessions s
ON s.user_id = u.id
AND s.revoked_at IS NULL
WHERE u.deleted_at IS NULL;
```
**좋은 이유:**
- “사용자 전체를 유지하면서 revoked 되지 않은 세션만 매칭”이라는 의미가 정확하다
- 오른쪽 조건을 ON에 두어 outer join 의미를 유지한다
- 사용자 필터와 join 매칭 조건이 분리되어 읽기 쉽다
PostgreSQL은 outer join에서 ON과 WHERE의 위치가 결과를 다르게 만든다고 명시한다.
### 예시 3. 존재 여부 확인은 EXISTS로 처리한다
```sql
SELECT u.id, u.email
FROM auth.users u
WHERE EXISTS (
SELECT 1
FROM auth.user_roles ur
WHERE ur.user_id = u.id
AND ur.role_name = 'ADMIN'
);
```
**좋은 이유:**
- “ADMIN role이 하나라도 있으면 됨”이라는 의미가 직접적이다
- role이 여러 개여도 바깥 사용자 row가 증폭되지 않는다
- COUNT(*) > 0보다 존재 여부 의도를 더 잘 드러낸다
PostgreSQL은 EXISTS가 행 존재 여부만 판단하며, 일반적으로 전부 끝까지 수행하지 않는다고 설명한다.
### 예시 4. anti-join은 NOT EXISTS를 사용한다
```sql
SELECT u.id, u.email
FROM auth.users u
WHERE NOT EXISTS (
SELECT 1
FROM auth.sessions s
WHERE s.user_id = u.id
AND s.revoked_at IS NULL
);
```
**좋은 이유:**
- null semantics 함정 없이 “활성 세션이 없는 사용자”를 표현한다
- NOT IN보다 더 안전한 기본값이다
- anti-join 의도가 분명하다
PostgreSQL은 NOT IN에 null이 섞이면 결과가 true가 아니라 null이 될 수 있다고 설명한다.
### 예시 5. row filter는 WHERE, 조건부 집계는 FILTER를 사용한다
```sql
SELECT
user_id,
count(*) FILTER (WHERE revoked_at IS NULL) AS active_count,
count(*) FILTER (WHERE revoked_at IS NOT NULL) AS revoked_count
FROM auth.sessions
WHERE created_at >= :from
GROUP BY user_id;
```
**좋은 이유:**
- 기간 제한은 row filter이므로 WHERE
- 집계별 조건은 FILTER
- grouped query의 의미가 분명하다
PostgreSQL은 WHERE와 HAVING의 역할이 다르고, aggregate input을 FILTER로 제한할 수 있다고 설명한다.
### 예시 6. one-row-per-group에는 DISTINCT ON을 의도적으로 사용한다
```sql
SELECT DISTINCT ON (user_id)
user_id,
id,
created_at
FROM auth.sessions
WHERE revoked_at IS NULL
ORDER BY user_id, created_at DESC, id DESC;
```
**좋은 이유:**
- 사용자별 최신 활성 세션 1건이라는 의미가 분명하다
- DISTINCT ON (user_id)와 ORDER BY user_id, ...가 맞춰져 있다
- 어떤 row를 남길지 예측 가능하다
PostgreSQL은 DISTINCT ON의 first row는 ORDER BY 없이는 예측 불가능하며, DISTINCT ON 식은 ORDER BY의 leftmost expressions와 맞아야 한다고 설명한다.
### 예시 7. deduplication이 불필요하면 UNION ALL을 사용한다
```sql
SELECT user_id, created_at, 'LOGIN' AS event_type
FROM auth.login_events
WHERE created_at >= :from
UNION ALL
SELECT user_id, created_at, 'LOGOUT' AS event_type
FROM auth.logout_events
WHERE created_at >= :from;
```
**좋은 이유:**
- 두 이벤트 집합을 단순 병합하는 요구다
- 중복 제거가 필요하지 않다
- UNION보다 의미와 비용이 더 적절하다
PostgreSQL은 UNION ALL이 중복 제거를 하지 않으므로 보통 더 빠르다고 설명한다.
### 예시 8. CTE는 단계 분해에 쓰되, 필요하면 NOT MATERIALIZED를 명시한다
```sql
WITH recent_sessions AS NOT MATERIALIZED (
SELECT user_id, created_at
FROM auth.sessions
WHERE created_at >= :from
)
SELECT u.id, rs.created_at
FROM auth.users u
JOIN recent_sessions rs ON rs.user_id = u.id
WHERE u.deleted_at IS NULL;
```
**좋은 이유:**
- CTE로 query 단계를 읽기 쉽게 분해했다
- predicate pushdown 이점이 중요한 경우 NOT MATERIALIZED 의도를 드러낸다
- CTE를 성능 힌트처럼 무의식적으로 쓰지 않는다
PostgreSQL은 side-effect-free CTE의 folding/materialization 규칙과 NOT MATERIALIZED의 trade-off를 설명한다.
## 나쁜 예시
### 예시 1. LIMIT만 두고 정렬을 생략한다
```sql
SELECT id, user_id, created_at
FROM auth.sessions
WHERE user_id = :userId
LIMIT 20;
```
**나쁜 이유:**
- 어떤 20건인지 정의되지 않는다
- 실행 계획과 실행 시점에 따라 다른 부분집합이 나올 수 있다
- API와 배치 결과의 재현성이 깨진다
PostgreSQL은 LIMIT을 ORDER BY 없이 쓰면 예측 불가능한 subset을 얻는다고 설명한다.
### 예시 2. LEFT JOIN인데 오른쪽 조건을 WHERE에 내려 의미를 바꾼다
```sql
SELECT u.id, u.email, s.id AS active_session_id
FROM auth.users u
LEFT JOIN auth.sessions s
ON s.user_id = u.id
WHERE s.revoked_at IS NULL;
```
**나쁜 이유:**
- session이 없는 사용자도 남겨야 하는데 결과에서 사라질 수 있다
- 사실상 inner join처럼 동작할 수 있다
- outer join의 핵심 의미를 망가뜨린다
PostgreSQL은 outer join에서 ON과 WHERE가 동등하지 않다고 설명한다.
### 예시 3. NATURAL JOIN을 사용한다
```sql
SELECT *
FROM auth.users
NATURAL JOIN auth.user_profiles;
```
**나쁜 이유:**
- 동일 이름 컬럼이 추가되면 join 의미가 바뀔 수 있다
- 스키마 변경에 매우 취약하다
- 장기 유지보수 신뢰도가 낮다
PostgreSQL은 NATURAL이 USING보다 훨씬 위험하다고 명시한다.
### 예시 4. 존재 여부 확인을 COUNT(*)로 처리한다
```sql
SELECT CASE
WHEN count(*) > 0 THEN true
ELSE false
END
FROM auth.user_roles
WHERE user_id = :userId
AND role_name = 'ADMIN';
```
**나쁜 이유:**
- 존재 여부만 필요해도 집계를 수행한다
- COUNT(*)는 공짜가 아니며, 전체 집계는 테이블 크기에 비례하는 비용이 들 수 있다
- EXISTS가 더 직접적인 표현이다
PostgreSQL은 전체 count(*)가 테이블 또는 전체 인덱스를 스캔해야 할 수 있다고 설명한다.
### 예시 5. NOT IN에 null 가능성을 남겨 둔다
```sql
SELECT u.id, u.email
FROM auth.users u
WHERE u.id NOT IN (
SELECT s.user_id
FROM auth.sessions s
WHERE s.revoked_at IS NULL
);
```
**나쁜 이유:**
- subquery 결과에 null이 섞이면 NOT IN 결과가 true가 아니라 null이 될 수 있다
- null semantics를 이해하지 못하면 버그를 만들기 쉽다
- 이런 anti-join은 보통 NOT EXISTS가 더 안전하다
PostgreSQL은 NOT IN의 null semantics를 명시적으로 경고한다.
### 예시 6. row filter를 HAVING으로 올린다
```sql
SELECT user_id, count(*)
FROM auth.sessions
GROUP BY user_id
HAVING max(created_at) >= :from
AND user_id = :userId;
```
**나쁜 이유:**
- user_id = :userId는 grouping 전에 걸러도 되는 row filter다
- 불필요하게 더 많은 행을 grouping하게 만든다
- WHERE와 HAVING의 역할이 섞여 있다
PostgreSQL 튜토리얼은 aggregate가 필요 없는 제한은 WHERE가 더 효율적이라고 설명한다.
### 예시 7. 잘못된 join을 DISTINCT로 덮는다
```sql
SELECT DISTINCT u.id, u.email
FROM auth.users u
JOIN auth.user_roles ur ON ur.user_id = u.id
WHERE ur.role_name IN ('ADMIN', 'MANAGER');
```
**나쁜 이유:**
- row multiplication 원인을 해결하지 않고 중복만 제거한다
- 결과 의미가 DISTINCT에 의존하게 된다
- 존재 여부가 목적이라면 EXISTS가 더 직접적이다
PostgreSQL에서 DISTINCT는 실제로 duplicate row를 제거하는 의미 연산이다. 따라서 이 패턴은 보통 구조 문제를 가리는 나쁜 신호다.
### 예시 8. DISTINCT ON을 ORDER BY 없이 사용한다
```sql
SELECT DISTINCT ON (user_id)
user_id, id, created_at
FROM auth.sessions;
```
**나쁜 이유:**
- 사용자별 어떤 세션이 남는지 예측할 수 없다
- first row 선택 기준이 정의되지 않는다
- 운영 결과가 비결정적이다
PostgreSQL은 DISTINCT ON의 first row는 ORDER BY가 없으면 예측 불가능하다고 설명한다.
+102
View File
@@ -0,0 +1,102 @@
# Schema Structure 예시
## 좋은 예시
### 예시 1. 애플리케이션 전용 schema를 migration으로 만든다
```sql
CREATE SCHEMA IF NOT EXISTS auth;
CREATE TABLE auth.users (
id BIGINT PRIMARY KEY,
email VARCHAR(320) NOT NULL
);
```
**좋은 이유:**
- 애플리케이션 객체가 public 과 분리된다
- schema 생성과 테이블 생성 위치가 명확하다
- PostgreSQL의 CREATE SCHEMA 와 qualified name 사용 방식에 맞다.
### 예시 2. ORM 기본 schema를 한 곳에서 맞춘다
```java
@Entity
@Table(name = "users")
public class User {
@Id
private Long id;
}
```
**좋은 이유:**
- 기본 schema를 연결/ORM 설정에서 맞춘다는 전제를 따른다
- 모든 엔티티에 같은 schema 문자열을 반복하지 않는다
- Hibernate는 schema를 따로 지정하지 않으면 현재 연결의 기본 schema를 사용한다.
### 예시 3. 정말 필요한 경우에만 특정 엔티티에 schema를 명시한다
```java
@Entity
@Table(schema = "auth_audit", name = "login_audit")
public class LoginAudit {
@Id
private Long id;
}
```
**좋은 이유:**
- 예외적 별도 schema 의도를 코드에서 드러낸다
- Hibernate가 지원하는 공식 매핑 방식이다.
## 나쁜 예시
### 예시 1. 기본 public 에 그냥 테이블을 만든다
```sql
CREATE TABLE users (
id BIGINT PRIMARY KEY
);
```
**나쁜 이유:**
- 애플리케이션 schema 의도가 없다
- search_path 와 public 기본값에 기대게 된다
- PostgreSQL 기본 동작상 unqualified create는 현재 스키마, 기본적으로는 public 에 들어갈 수 있다.
### 예시 2. 같은 schema를 모든 엔티티에 반복해서 박아 둔다
```java
@Entity
@Table(schema = "auth", name = "users")
public class User { ... }
@Entity
@Table(schema = "auth", name = "sessions")
public class Session { ... }
@Entity
@Table(schema = "auth", name = "login_histories")
public class LoginHistory { ... }
```
**나쁜 이유:**
- 기본 schema가 하나인데 코드 중복만 늘어난다
- schema 변경 시 수정 범위가 불필요하게 커진다
- Hibernate는 기본 schema 연결을 사용할 수 있다.
### 예시 3. search_path에 기대어 운영마다 다른 schema를 본다
```sql
SET search_path TO auth, public;
SELECT * FROM users;
```
**나쁜 이유:**
- 환경별 search_path 차이에 취약하다
- PostgreSQL 공식 문서도 search_path 는 해석 결과와 신뢰 경계를 바꾸므로 주의하라고 설명한다
+221
View File
@@ -0,0 +1,221 @@
# Soft Delete 예시
## 좋은 예시
### 예시 1. 기본 soft delete 컬럼은 deleted_at으로 둔다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
display_name text NOT NULL,
deleted_at timestamp with time zone
);
```
**좋은 이유:**
- soft delete를 삭제 시점으로 표현한다
- active row 조건이 `deleted_at IS NULL`로 단순해진다
- boolean-only보다 운영 추적성이 좋다
Hibernate는 TIMESTAMP soft delete 전략이 삭제된 시점을 추적한다고 설명한다. 프로젝트 기본값을 `deleted_at`으로 두는 것은 그 전략과 잘 맞는다.
### 예시 2. active row uniqueness는 partial unique index로 강제한다
```sql
CREATE UNIQUE INDEX uq_users__email__active
ON auth.users (email)
WHERE deleted_at IS NULL;
```
**좋은 이유:**
- active row 사이에서만 이메일 중복을 막는다
- soft-deleted row는 uniqueness 대상에서 제외할 수 있다
- PostgreSQL이 공식적으로 지원하는 부분집합 uniqueness 패턴이다
PostgreSQL은 일부 row에만 적용되는 uniqueness restriction은 unique constraint가 아니라 unique partial index로 표현해야 한다고 설명한다.
### 예시 3. soft delete는 DELETE가 아니라 UPDATE로 수행한다
```sql
UPDATE auth.users
SET deleted_at = current_timestamp
WHERE id = :userId
AND deleted_at IS NULL;
```
**좋은 이유:**
- soft delete를 상태 전이로 표현한다
- 이미 삭제된 row에 대한 중복 처리도 막기 쉽다
- 일반 삭제와 물리 삭제를 분리하기 좋다
Hibernate는 soft delete를 실제 삭제 대신 indicator column update로 설명한다.
### 예시 4. Hibernate를 쓴다면 TIMESTAMP soft delete를 공식 기능으로 쓸 수 있다
```java
@Entity
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
@Table(name = "users", schema = "auth")
public class User {
@Id
private Long id;
}
```
**좋은 이유:**
- Hibernate의 공식 soft delete 기능을 사용한다
- TIMESTAMP 전략이 삭제 시점을 추적한다
- 컬럼 이름을 프로젝트 표준인 `deleted_at`으로 맞출 수 있다
Hibernate는 `@SoftDelete`가 TIMESTAMP 전략을 지원하고, indicator column 이름은 `columnName`으로 정의할 수 있다고 설명한다.
### 예시 5. @ManyToMany / @ElementCollection join table에는 soft delete를 제한적으로 적용할 수 있다
```java
@ManyToMany
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
private Set<Role> roles;
```
**좋은 이유:**
- Hibernate 공식 지원 범위 안에서 join table row를 soft delete할 수 있다
- 관계 row의 논리 삭제가 필요한 경우에만 제한적으로 쓸 수 있다
- 엔티티 자체 soft delete와 컬렉션 테이블 soft delete를 구분한다
Hibernate는 `@SoftDelete``@ElementCollection``@ManyToMany` collection table에 적용할 수 있다고 설명한다.
### 예시 6. soft delete와 bulk purge를 분리한다
```java
public interface UserRepository extends JpaRepository<User, Long> {
@Modifying
@Query("""
delete from User u
where u.deletedAt < :cutoff
""")
int purgeDeletedBefore(@Param("cutoff") OffsetDateTime cutoff);
}
```
**좋은 이유:**
- 일반 삭제는 soft delete 경로로 두고, 오래된 삭제 row 정리는 purge 전용 경로로 분리한다
- bulk delete가 direct database delete임을 명시적으로 드러낸다
- soft delete와 physical purge를 같은 경로로 섞지 않는다
Spring Data JPA는 JPQL/Criteria bulk delete가 DB 직접 delete로 매핑되고 persistence context를 동기화하지 않는다고 설명한다. 이런 방식은 purge 전용 경로에서만 명시적으로 쓰는 편이 안전하다.
## 나쁜 예시
### 예시 1. soft delete 컬럼은 있지만 기본 조회에서 빼지 않는다
```sql
SELECT id, email
FROM auth.users
ORDER BY id DESC;
```
**나쁜 이유:**
- 삭제 row가 일반 조회에 섞인다
- soft delete가 조회 계약으로 완성되지 않는다
- active-row partial index와도 잘 맞지 않는다
PostgreSQL은 partial index가 query WHERE 조건이 predicate를 함의할 때만 사용될 수 있다고 설명한다. active-row 조회는 predicate를 명시적으로 포함해야 한다.
### 예시 2. active-only uniqueness를 일반 UNIQUE로 둔다
```sql
CREATE TABLE auth.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
deleted_at timestamp with time zone
);
```
**나쁜 이유:**
- soft-deleted row까지 포함해 테이블 전체에서 email 중복을 막아 버린다
- "삭제된 이메일은 다시 쓸 수 있다"는 정책을 표현할 수 없다
- active subset uniqueness가 아니라 전체-table uniqueness다
PostgreSQL은 일부 row에만 적용되는 uniqueness restriction은 unique constraint로 쓸 수 없고, partial unique index로 표현해야 한다고 설명한다.
### 예시 3. partial unique index natural key를 FK target으로 사용하려고 한다
```sql
CREATE UNIQUE INDEX uq_users__email__active
ON auth.users (email)
WHERE deleted_at IS NULL;
CREATE TABLE auth.login_audit (
user_email text REFERENCES auth.users(email)
);
```
**나쁜 이유:**
- FK 대상은 non-partial unique index 또는 PK/UNIQUE 제약이어야 한다
- active-row partial unique index는 FK target 자격이 없다
- soft delete와 natural key 참조를 섞으면 설계가 불안정해진다
PostgreSQL은 FK 참조 대상이 non-deferrable unique/primary key 제약 또는 non-partial unique index여야 한다고 설명한다.
### 예시 4. @OneToMany 컬렉션에 @SoftDelete를 붙인다
```java
@OneToMany(mappedBy = "user")
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
private List<Session> sessions;
```
**나쁜 이유:**
- Hibernate 공식 지원 범위가 아니다
- `@OneToMany``@SoftDelete`를 붙이면 예외가 난다
- 자식 엔티티 자체를 soft delete해야 한다
Hibernate는 `@OneToMany` association에 `@SoftDelete`를 붙이면 예외를 던진다고 설명한다.
### 예시 5. soft-deletable 엔티티를 batch delete로 지운다
```java
userRepository.deleteAllInBatch(users);
```
**나쁜 이유:**
- 단일 query physical delete가 일어난다
- persistence context가 DB와 동기화되지 않을 수 있다
- JPA cascade semantics와 lifecycle events도 존중되지 않는다
- soft delete semantics를 우회할 위험이 크다
Spring Data JPA는 `deleteAllInBatch(Iterable)`가 단일 query를 만들고, first level cache와 DB를 out of sync 상태로 만들 수 있으며, JPA cascade와 lifecycle event를 존중하지 않는다고 설명한다.
### 예시 6. restore를 uniqueness 검증 없이 수행한다
```sql
UPDATE auth.users
SET deleted_at = NULL
WHERE id = :userId;
```
**나쁜 이유:**
- 같은 이메일을 가진 다른 active row가 이미 있으면 active subset uniqueness를 깨뜨릴 수 있다
- restore는 단순 null 복원이 아니라 active set에 다시 들어가는 상태 전이다
- partial unique index와 충돌 가능성을 고려해야 한다
PostgreSQL unique partial index는 predicate를 만족하는 row subset 안에서 uniqueness를 강제한다. restore는 그 subset으로 다시 들어가는 행위다.
+317
View File
@@ -0,0 +1,317 @@
# DB Transaction 예시
## 좋은 예시
### 예시 1. 쓰기 유스케이스 경계는 application service에서 잡는다
```java
@Service
@RequiredArgsConstructor
public class UserRoleCommandService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
@Transactional
public void assignRole(Long userId, Long roleId) {
User user = userRepository.getById(userId);
Role role = roleRepository.getById(roleId);
user.assign(role);
}
}
```
**좋은 이유:**
- 유스케이스 전체를 하나의 트랜잭션으로 묶는다
- 여러 repository 호출을 outer service boundary가 소유한다
- controller나 repository가 아니라 application service가 일관성 경계를 대표한다
Spring Data JPA는 여러 repository 호출을 묶는 facade/service가 transactional boundary를 정의한다고 설명한다.
### 예시 2. 조회 유스케이스는 readOnly=true를 최적화 힌트로 사용한다
```java
@Service
@RequiredArgsConstructor
public class UserQueryService {
private final UserRepository userRepository;
@Transactional(readOnly = true)
public UserDetailResponse getUserDetail(Long userId) {
User user = userRepository.getDetailById(userId)
.orElseThrow(UserNotFoundException::new);
return new UserDetailResponse(
user.getId(),
user.getEmail(),
user.getStatus()
);
}
}
```
**좋은 이유:**
- 읽기 유스케이스임을 트랜잭션 속성으로 명확히 표현한다
- readOnly=true를 최적화 힌트로 사용하고, write 시도를 섞지 않는다
- 필요한 조회와 response mapping을 service 경계 안에서 끝낸다
Spring은 readOnly를 최적화 힌트로 설명하고, Spring Data JPA는 Hibernate 사용 시 flush mode를 NEVER로 두어 dirty check를 건너뛸 수 있다고 설명한다.
### 예시 3. checked exception도 롤백해야 하면 좁게 지정한다
```java
@Service
@RequiredArgsConstructor
public class UserImportService {
private final UserRepository userRepository;
@Transactional(rollbackFor = InvalidUserImportException.class)
public void importUsers(List<UserImportRow> rows) throws InvalidUserImportException {
for (UserImportRow row : rows) {
if (!row.isValid()) {
throw new InvalidUserImportException("invalid row");
}
userRepository.save(User.from(row));
}
}
}
```
**좋은 이유:**
- checked exception이 유스케이스 실패를 의미한다는 점을 transaction 설정에 반영한다
- rollbackFor = Exception.class처럼 과도하게 넓히지 않는다
- rollback 규칙이 예외 의미와 맞는다
Spring은 기본적으로 checked exception에서 rollback하지 않으며, rollback rules로 필요한 예외만 지정할 수 있다고 설명한다.
### 예시 4. commit 이후에만 실행돼야 하는 후속 작업은 after-commit에 연결한다
```java
@Component
public class UserCreatedEventHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(UserCreatedEvent event) {
// 메일 발송, 후속 발행, 외부 통지 등
}
}
```
**좋은 이유:**
- DB commit 성공 이후에만 실행돼야 하는 후속 작업을 분리한다
- 트랜잭션 내부 write와 외부 side effect를 같은 시점에 섞지 않는다
- “저장 실패인데 메일은 발송됨” 같은 불일치를 줄일 수 있다
Spring은 @TransactionalEventListener가 AFTER_COMMIT 같은 phase를 지원한다고 설명한다.
### 예시 5. outer transaction이 있어야 하는 일반 호출 체인은 REQUIRED에 맡긴다
```java
@Service
@RequiredArgsConstructor
public class OrderCommandService {
private final PaymentService paymentService;
private final OrderRepository orderRepository;
@Transactional
public void confirmOrder(Long orderId) {
Order order = orderRepository.getById(orderId);
paymentService.validatePayment(order.getPaymentId());
order.confirm();
}
}
@Service
public class PaymentService {
@Transactional
public void validatePayment(Long paymentId) {
// 같은 physical transaction에 참여
}
}
```
**좋은 이유:**
- 같은 유스케이스 안에서는 기본 propagation인 REQUIRED로 충분하다
- 불필요하게 REQUIRES_NEW를 쓰지 않는다
- 하나의 물리 트랜잭션 안에서 일관성을 유지한다
Spring은 PROPAGATION_REQUIRED가 common call stack arrangement에서 좋은 기본값이라고 설명한다.
## 나쁜 예시
### 예시 1. controller에 트랜잭션을 둔다
```java
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserRoleCommandService userRoleCommandService;
@PostMapping("/users/{id}/roles/{roleId}")
@Transactional
public void assignRole(@PathVariable Long id, @PathVariable Long roleId) {
userRoleCommandService.assignRole(id, roleId);
}
}
```
**나쁜 이유:**
- request handling 범위 전체가 DB 트랜잭션에 포함되기 쉽다
- controller는 입출력 경계이고, 일관성 경계를 소유하는 계층이 아니다
- transaction scope를 불필요하게 길게 만든다
Spring Data JPA는 facade/service가 transactional boundary를 정의한다고 설명하고, Hibernate는 물리 트랜잭션은 가능한 짧아야 한다고 설명한다.
### 예시 2. self-invocation에 @Transactional을 기대한다
```java
@Service
public class UserService {
public void createUser(CreateUserRequest request) {
validate(request);
saveUser(request);
}
@Transactional
void saveUser(CreateUserRequest request) {
// 저장
}
}
```
**나쁜 이유:**
- 같은 클래스 내부 호출이라 proxy를 통과하지 않는다
- saveUser()의 @Transactional이 실제로 적용되지 않을 수 있다
- 동작하는 것처럼 보여도 rollback 시나리오에서 깨지기 쉽다
Spring은 proxy mode에서 external method call만 interception 대상이고, self-invocation은 실제 트랜잭션을 만들지 않는다고 설명한다.
### 예시 3. readOnly=true에서 엔티티를 수정한다
```java
@Service
@RequiredArgsConstructor
public class UserQueryService {
private final UserRepository userRepository;
@Transactional(readOnly = true)
public void touchLastViewedAt(Long userId) {
User user = userRepository.getById(userId);
user.updateLastViewedAt();
}
}
```
**나쁜 이유:**
- readOnly=true는 쓰기 차단 장치가 아니다
- 코드 의미와 트랜잭션 의미가 서로 충돌한다
- Hibernate 최적화와 코드 의도가 어긋난다
Spring과 Spring Data JPA는 readOnly를 최적화 힌트로 설명하며, write attempt 자체를 반드시 막지 않는다고 명시한다.
### 예시 4. 외부 호출을 길게 물고 있는 long transaction
```java
@Service
@RequiredArgsConstructor
public class PaymentCommandService {
private final PaymentRepository paymentRepository;
private final ExternalGatewayClient externalGatewayClient;
@Transactional
public void approve(Long paymentId) {
Payment payment = paymentRepository.getById(paymentId);
externalGatewayClient.call(payment); // 오래 걸리는 외부 호출
payment.approve();
}
}
```
**나쁜 이유:**
- 네트워크 대기 시간 동안 DB 트랜잭션이 열린 채로 유지될 수 있다
- lock contention과 확장성 문제가 커진다
- 외부 호출 실패와 DB 일관성 경계를 분리해서 설계해야 할 가능성이 높다
Hibernate는 DB 트랜잭션은 가능한 짧아야 하고, 긴 트랜잭션은 확장성을 해친다고 설명한다. Spring도 transaction context가 remote call로 전파되지 않는다고 설명한다.
### 예시 5. 무심코 rollbackFor = Exception.class를 붙인다
```java
@Service
public class UserService {
@Transactional(rollbackFor = Exception.class)
public void doSomething() throws Exception {
// ...
}
}
```
**나쁜 이유:**
- 모든 checked exception을 일괄 rollback 대상으로 만들어 예외 의미 구분을 흐린다
- 복구 가능한 checked exception까지 전부 트랜잭션 실패로 취급할 수 있다
- rollback 규칙이 너무 넓다
Spring은 기본 rollback 규칙이 unchecked exception 중심이며, rollback rules는 필요한 예외에 맞춰 세밀하게 설정할 수 있다고 설명한다. 따라서 광범위한 기본 확대는 신중해야 한다.
### 예시 6. 일반 helper에 REQUIRES_NEW를 붙여 부분 커밋을 만든다
```java
@Service
public class AuditHelper {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAudit(AuditLog log) {
// 저장
}
}
```
**나쁜 이유:**
- outer transaction과 무관하게 commit될 수 있다
- partial commit을 만들기 때문에 business semantics가 달라진다
- “트랜잭션 충돌 회피용”으로 쓰면 의도치 않은 데이터 잔존을 만든다
Spring은 REQUIRES_NEW가 항상 독립 물리 트랜잭션을 사용하고, outer rollback과 독립적으로 commit/rollback 된다고 설명한다.
### 예시 7. flush와 commit을 같은 것으로 가정한다
```java
@Transactional
public void updateUser(Long id) {
User user = userRepository.getById(id);
user.changeName("new-name");
userRepository.findAll(); // "아직 commit 전이니까 DB에 영향 없겠지"라고 가정
}
```
**나쁜 이유:**
- Hibernate는 겹치는 query 실행 전 flush를 일으킬 수 있다
- commit 전에도 SQL이 먼저 나갈 수 있다
- flush timing을 잘못 이해하면 query ordering과 side effect를 오판하게 된다
Hibernate는 기본 AUTO flush 모드에서 commit 전뿐 아니라, 겹치는 JPQL/HQL query 전과 native query 전에도 flush가 일어날 수 있다고 설명한다.