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가 일어날 수 있다고 설명한다.
+88
View File
@@ -0,0 +1,88 @@
# common module 예시
## 좋은 예시 1: common 대신 owning module에 둠
```text
presentation/support/response/ApiResult.java
```
**왜 좋은가:**
- HTTP 응답 구조는 presentation 소유다
- 다른 레이어가 알 필요가 없다
- 공용으로 빼면 오히려 경계가 흐려진다
## 좋은 예시 2: common 대신 module API로 노출
```text
order/
OrderManagement.java
order/spi/
package-info.java (@NamedInterface("spi"))
OrderLookup.java
```
**왜 좋은가:**
- 필요한 범위만 공개한다
- 전체 common으로 빼지 않고 모듈 API를 좁게 노출한다
## 좋은 예시 3: 예외적으로 허용 가능한 작은 공용 타입
```text
common/types/NormalizedHost.java
```
**허용 조건:**
- 여러 모듈이 실제로 사용
- framework/business/persistence 의존 없음
- 값 기반 타입
- 변화 이유가 동일함
**왜 좋은가:**
- 진짜 공통 값 의미를 담는다
- owning module이 특정되기 어렵다
- 경계를 섞지 않는다
## 나쁜 예시 1: 잡동사니 common
```text
common/
StringUtils.java
DateUtils.java
ErrorUtils.java
ValidationUtils.java
AuthConstants.java
ApiResult.java
UserMapper.java
```
**문제:**
- 소유권이 불명확하다
- web/domain/infrastructure가 섞인다
- dump zone이 된다
## 나쁜 예시 2: 경계 회피용 common
```text
common/UserDto.java
```
**문제:**
- presentation DTO를 공용으로 올려 application/infrastructure도 기대게 만들 수 있다
- DTO/Domain/Entity 경계가 무너진다
## 나쁜 예시 3: premature abstraction common
```text
common/DeadlineHelper.java
```
**문제:**
- task와 payment가 지금은 비슷해 보여도 미래에 독립 진화할 수 있다
- owning module 안에 두는 편이 더 안전할 수 있다
@@ -0,0 +1,136 @@
# DTO / Domain / Entity separation 예시
## 좋은 예시 1: request DTO -> command -> domain
```java
public record CreateUserRequest(
String email,
String password,
String name
) {}
public record CreateUserCommand(
String email,
String password,
String name
) {}
public final class UserWebMapper {
public CreateUserCommand toCommand(CreateUserRequest request) {
return new CreateUserCommand(
request.email(),
request.password(),
request.name()
);
}
}
```
**왜 좋은가:**
- 웹 입력 모델과 application 입력 모델이 분리된다
- request binding과 business 의미 부여 경계가 생긴다
## 좋은 예시 2: entity -> domain 분리
```java
@Entity
@Table(name = "users")
public class UserJpaEntity {
@Id
private Long id;
private String email;
private String encodedPassword;
private String name;
protected UserJpaEntity() {}
}
public class User {
private final UserId id;
private final UserEmail email;
private final UserName name;
private final EncodedPassword password;
private User(...) { ... }
}
```
**왜 좋은가:**
- JPA 제약과 도메인 의미가 분리된다
- domain이 persistence annotation에 오염되지 않는다
## 좋은 예시 3: domain -> response DTO 분리
```java
public record UserResponse(
Long id,
String email,
String name
) {}
public final class UserResponseMapper {
public UserResponse toResponse(User user) {
return new UserResponse(
user.id().value(),
user.email().value(),
user.name().value()
);
}
}
```
**왜 좋은가:**
- 외부 응답 계약이 명시적이다
- domain 전체를 그대로 노출하지 않는다
## 나쁜 예시 1: entity를 바로 response로 반환
```java
@GetMapping("/{id}")
public UserJpaEntity getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
```
**문제:**
- persistence 구조가 외부 계약으로 새어 나간다
- 민감정보/지연로딩/관계 구조가 노출될 수 있다
- API와 persistence가 강하게 결합된다
## 나쁜 예시 2: request DTO를 그대로 domain으로 사용
```java
public User createUser(CreateUserRequest request) {
return userService.create(request);
}
```
**문제:**
- 웹 입력 모델이 business layer로 직접 흘러간다
- validation/binding shape가 domain/application 설계를 오염시킨다
## 나쁜 예시 3: domain에 JPA/JSON/validation annotation 혼합
```java
@Entity
public class User {
@Id
private Long id;
@JsonProperty("email")
@NotBlank
private String email;
}
```
**문제:**
- persistence / serialization / validation / business 의미가 한 타입에 섞인다
- 변경 이유가 서로 다른 관심사가 강결합된다
+141
View File
@@ -0,0 +1,141 @@
# interface 생성 예시
## 좋은 예시 1: application output port
```java
public interface UserReader {
Optional<User> findByEmail(UserEmail email);
Optional<User> findById(UserId userId);
}
@Repository
public class JpaUserReader implements UserReader {
...
}
```
**왜 좋은가:**
- application이 persistence 구현을 모른다
- 바깥 구현 교체와 테스트 대역 주입이 쉽다
- 레이어 경계가 분명하다
## 좋은 예시 2: 외부 시스템 client contract
```java
public interface VaultTransitClient {
String sign(String keyName, byte[] input);
PublicKey readPublicKey(String keyName);
}
```
**왜 좋은가:**
- 외부 연동 경계가 분명하다
- HttpClient/WebClient/Jackson 세부가 계약에 새지 않는다
- fake/stub 구현으로 테스트하기 쉽다
## 좋은 예시 3: 교체 가능한 정책 객체
```java
public interface PasswordHasher {
String hash(String rawPassword);
boolean matches(String rawPassword, String encodedPassword);
}
```
**왜 좋은가:**
- 알고리즘 교체 가능성이 실제로 있다
- application/domain이 구체 해시 구현을 모른다
## 좋은 예시 4: 인터페이스 없이 concrete class 유지
```java
@Component
public class LoginResponseAssembler {
public LoginResponse toResponse(User user, TokenPair tokenPair) {
...
}
}
```
**왜 좋은가:**
- 내부 presentation helper일 뿐 계약 경계가 아니다
- 구현체 1개, 교체 가치 낮음, 인터페이스 이득 작음
- 불필요한 LoginResponseAssemblerImpl을 만들지 않는다
## 나쁜 예시 1: 의미 없는 Service/Impl 쌍
```java
public interface UserService {
User create(CreateUserCommand command);
}
@Service
public class UserServiceImpl implements UserService {
...
}
```
**문제:**
- 실제 경계/교체/테스트 seam 의미가 약하다
- 타입만 늘고 추상화 이득이 거의 없다
- “관성적인 인터페이스”에 가깝다
## 나쁜 예시 2: 기술 세부를 계약에 노출
```java
public interface UserClient {
ResponseEntity<String> getUser(String id);
}
```
**문제:**
- Spring Web 타입이 계약에 박힌다
- 호출자가 구현 기술에 묶인다
**개선:**
- 도메인/애플리케이션에 더 맞는 결과 타입으로 계약 정의
## 나쁜 예시 3: 여러 책임을 한 인터페이스에 몰아넣기
```java
public interface UserManager {
User findUser(...);
User saveUser(...);
void sendEmail(...);
String issueToken(...);
}
```
**문제:**
- 하나의 역할이 아니다
- 호출자마다 일부만 필요할 가능성이 높다
- 응집도가 낮다
## 나쁜 예시 4: 조기 추상화
```java
public interface DeadlineService {
void setDeadline(...);
}
public class TaskDeadlineService implements DeadlineService { ... }
public class PaymentDeadlineService implements DeadlineService { ... }
```
**문제:**
- 지금은 비슷해 보여도 미래에 다르게 진화할 수 있다
- 아직 공통 계약이 자연스러운지 검증되지 않았다
**개선 방향:**
- 충분한 공통성/경계 필요가 생길 때까지 분리된 concrete class 유지
+152
View File
@@ -0,0 +1,152 @@
# mapper separation 예시
## 좋은 예시 1: web request -> command 매핑
```java
public final class UserWebMapper {
public CreateUserCommand toCommand(CreateUserRequest request) {
return new CreateUserCommand(
request.email(),
request.password(),
request.name()
);
}
}
```
**왜 좋은가:**
- HTTP request 구조를 application command 구조로만 번역한다
- 비즈니스 정책을 결정하지 않는다
## 좋은 예시 2: domain -> response DTO 매핑
```java
public final class UserResponseMapper {
public UserResponse toResponse(User user) {
return new UserResponse(
user.id().value(),
user.email().value(),
user.name().value()
);
}
}
```
**왜 좋은가:**
- 응답 계약만 만든다
- repository/service 호출이 없다
## 좋은 예시 3: persistence entity -> domain 매핑 분리
```java
public final class UserPersistenceMapper {
public User toDomain(UserJpaEntity entity) {
return User.restore(
entity.getId(),
entity.getEmail(),
entity.getName(),
entity.getEncodedPassword()
);
}
public UserJpaEntity toEntity(User user) {
return new UserJpaEntity(
user.id().value(),
user.email().value(),
user.name().value(),
user.password().encodedValue()
);
}
}
```
**왜 좋은가:**
- persistence 구조와 domain 구조를 별도 경계에서 번역한다
- JPA 세부가 domain으로 직접 새지 않는다
## 좋은 예시 4: update mapping을 명시적으로 분리
```java
public interface UserPersistenceMapper {
UserJpaEntity toNewEntity(User user);
void updateEntity(User user, UserJpaEntity target);
}
```
**왜 좋은가:**
- 생성과 수정의 계약이 다름을 드러낸다
- side effect가 있는 매핑을 명시한다
## 나쁜 예시 1: 매퍼에서 repository 호출
```java
public final class UserMapper {
private final RoleRepository roleRepository;
public User toDomain(UserRequest request) {
Role role = roleRepository.findByName(request.roleName()).orElseThrow();
return User.create(request.email(), role);
}
}
```
**문제:**
- 매퍼가 번역을 넘어 DB 조회까지 한다
- 테스트와 책임 분리가 어려워진다
**개선:**
- 호출자가 Role을 먼저 준비해서 전달한다
## 나쁜 예시 2: 매퍼에서 비즈니스 규칙 결정
```java
public UserStatus toStatus(UserRequest request) {
if (request.provider().equals("google")) {
return UserStatus.ACTIVE;
}
return UserStatus.PENDING;
}
```
**문제:**
- 상태 결정 정책이 매퍼에 숨어 있다
- 단순 구조 변환이 아니라 비즈니스 의미를 만든다
**개선:**
- status 결정은 application/domain 정책으로 이동
## 나쁜 예시 3: web + persistence + integration를 한 매퍼에 몰아넣기
```java
public final class UserMapper {
CreateUserCommand toCommand(CreateUserRequest request) { ... }
UserJpaEntity toEntity(User user) { ... }
ExternalUserPayload toPayload(User user) { ... }
}
```
**문제:**
- 경계가 섞인다
- 변경 이유가 달라 함께 진화하기 어렵다
**개선:**
- UserWebMapper
- UserPersistenceMapper
- UserExternalMapper
- 로 분리
+127
View File
@@ -0,0 +1,127 @@
# port abstraction 예시
## 좋은 예시 1: outbound port를 application이 소유
```java
public interface UserReader {
Optional<User> findByEmail(UserEmail email);
Optional<User> findById(UserId userId);
}
@Repository
public class JpaUserReader implements UserReader {
...
}
```
**왜 좋은가:**
- application이 persistence 기술을 모른다
- 코어가 필요한 조회 능력만 계약으로 드러난다
- adapter만 JPA를 안다
## 좋은 예시 2: inbound port를 use case 계약으로 사용
```java
public interface LoginUseCase {
LoginResult login(LoginCommand command);
}
@RestController
class LoginController {
private final LoginUseCase loginUseCase;
...
}
```
**왜 좋은가:**
- controller가 구현체보다 use case 계약에 의존한다
- HTTP 세부와 비즈니스 흐름이 분리된다
## 좋은 예시 3: external API 경계 포트
```java
public interface TokenSigner {
Signature sign(SigningRequest request);
}
public class VaultTokenSigner implements TokenSigner {
...
}
```
**왜 좋은가:**
- 포트는 “서명한다”는 능력만 표현한다
- HTTP, JSON, Vault path/header는 adapter 구현으로 숨긴다
## 좋은 예시 4: 하나의 포트에 여러 adapter 가능
```java
public interface RateRepository {
BigDecimal findDiscountRate(Money amount);
}
public class InMemoryRateRepository implements RateRepository { ... }
public class JdbcRateRepository implements RateRepository { ... }
```
**왜 좋은가:**
- 테스트와 운영 구현이 같은 계약을 공유한다
- 포트는 기술 수와 무관하게 같은 대화를 표현한다
## 나쁜 예시 1: 기술 타입이 새는 포트
```java
public interface UserApiPort {
ResponseEntity<String> getUser(String id);
}
```
**문제:**
- HTTP 세부가 코어 계약으로 올라온다
- 비즈니스 의미가 아니라 transport 형식이 중심이 된다
## 나쁜 예시 2: adapter 편의 중심 포트
```java
public interface DatabasePort {
String query(String sql);
}
```
**문제:**
- 코어가 SQL/DB 기술 세부를 알게 된다
- “무엇을 원하나”가 아니라 “어떻게 하냐”를 말한다
## 나쁜 예시 3: 너무 범용적인 outbound port
```java
public interface ExternalSystemPort {
Object execute(Object input);
}
```
**문제:**
- 역할이 불명확하다
- 타입 안정성과 계약 의미가 없다
- 여러 외부 시스템 책임을 한 곳에 섞기 쉽다
## 나쁜 예시 4: 내부 helper까지 포트화
```java
public interface EmailNormalizerPort {
String normalize(String raw);
}
```
**문제:**
- 외부 경계가 아니라 내부 로직 detail이다
- 포트 추상화 비용이 이득보다 크다
+136
View File
@@ -0,0 +1,136 @@
# value object 예시
## 좋은 예시 1: 이메일 Value Object
```java
public record UserEmail(String value) {
public UserEmail {
Objects.requireNonNull(value, "value must not be null");
value = value.trim().toLowerCase(Locale.ROOT);
if (value.isBlank()) {
throw new IllegalArgumentException("email must not be blank");
}
if (!EMAIL_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException("invalid email format");
}
}
public static UserEmail from(String raw) {
return new UserEmail(raw);
}
}
```
**왜 좋은가:**
- 문자열 의미를 타입으로 끌어올린다
- 정규화와 검증이 한 곳에 모인다
- 값 기반 equality가 자연스럽다
## 좋은 예시 2: 금액 Value Object
```java
public record Money(BigDecimal amount) {
public Money {
Objects.requireNonNull(amount, "amount must not be null");
amount = amount.setScale(2, RoundingMode.HALF_UP);
if (amount.signum() < 0) {
throw new IllegalArgumentException("amount must not be negative");
}
}
public Money add(Money other) {
return new Money(this.amount.add(other.amount));
}
}
```
**왜 좋은가:**
- 숫자 primitive를 그대로 흘리지 않는다
- scale/음수 금지 규칙이 타입에 들어간다
- 값 관련 행위가 같이 있다
## 좋은 예시 3: entity와 분리된 domain Value Object
```java
@Entity
@Table(name = "users")
public class UserJpaEntity {
private String email;
}
public record UserEmail(String value) { ... }
public final class UserPersistenceMapper {
public User toDomain(UserJpaEntity entity) {
return User.restore(
UserEmail.from(entity.getEmail())
);
}
}
```
**왜 좋은가:**
- persistence 문자열과 domain 의미 타입이 분리된다
- domain invariant를 mapper 경계에서 회복한다
## 나쁜 예시 1: identity를 가진 것을 Value Object처럼 사용
```java
public record User(Long id, String name) {}
```
**문제:**
- User는 identity가 본질인 entity일 가능성이 높다
- 값 객체로 만들면 의미가 흐려진다
## 나쁜 예시 2: mutable Value Object
```java
public class UserName {
private String value;
public void setValue(String value) {
this.value = value;
}
}
```
**문제:**
- 생성 후 불변이 아니다
- 검증/정규화 이후 상태가 깨질 수 있다
## 나쁜 예시 3: 의미 없는 래퍼
```java
public record NameString(String value) {}
```
**문제:**
- business meaning이 약하다
- 검증/정규화/행위가 전혀 없다
- 래퍼 비용만 생길 수 있다
## 나쁜 예시 4: Value Object에서 외부 의존
```java
public class UserEmail {
public boolean exists(UserRepository repository) {
return repository.existsByEmail(value);
}
}
```
**문제:**
- 값 객체가 외부 의존과 오케스트레이션을 떠안는다
- 순수한 값 의미 타입이 아니다
@@ -0,0 +1,216 @@
# External API Client Structure 예시
## 좋은 예시
### 예시 1. imperative 서비스에서 RestClient adapter를 infrastructure에 둔다
```java
public interface ExternalTokenPort {
ExternalTokenResult issueToken(ExternalTokenCommand command);
}
@Service
@RequiredArgsConstructor
class KeycloakTokenClient implements ExternalTokenPort {
private final RestClient restClient;
private final KeycloakTokenMapper keycloakTokenMapper;
KeycloakTokenClient(RestClient.Builder restClientBuilder,
KeycloakProperties properties,
KeycloakAuthHeaderCustomizer authHeaderCustomizer) {
this.restClient = restClientBuilder
.baseUrl(properties.baseUrl())
.defaultHeader("User-Agent", "project-auth-server")
.requestInterceptor(authHeaderCustomizer)
.build();
this.keycloakTokenMapper = new KeycloakTokenMapper();
}
@Override
public ExternalTokenResult issueToken(ExternalTokenCommand command) {
KeycloakTokenRequest request = keycloakTokenMapper.toRequest(command);
KeycloakTokenResponse response = restClient.post()
.uri("/protocol/openid-connect/token")
.body(request)
.retrieve()
.body(KeycloakTokenResponse.class);
return keycloakTokenMapper.toResult(response);
}
}
```
**좋은 이유:**
- 외부 호출이 infrastructure adapter에 있다
- RestClient.Builder를 주입받아 공통 구성과 관측을 따른다
- 외부 DTO와 내부 결과가 분리된다.
### 예시 2. reactive 경계에서는 WebClient를 사용한다
```java
@Service
class ExternalAuditClient {
private final WebClient webClient;
ExternalAuditClient(WebClient.Builder webClientBuilder, AuditProperties properties) {
this.webClient = webClientBuilder
.baseUrl(properties.baseUrl())
.build();
}
Mono<Void> send(AuditEventRequest request) {
return webClient.post()
.uri("/events")
.bodyValue(request)
.retrieve()
.bodyToMono(Void.class);
}
}
```
**좋은 이유:**
- non-blocking 흐름에 맞는 client를 쓴다
- Boot가 권장하는 WebClient.Builder 주입 방식을 따른다.
### 예시 3. HTTP Service Client를 group 기반으로 묶는다
```java
@HttpExchange
public interface KeycloakUserHttpClient {
@GetExchange("/admin/realms/{realm}/users/{id}")
KeycloakUserResponse getUser(@PathVariable String realm, @PathVariable String id);
}
@ImportHttpServices(group = "keycloak", types = KeycloakUserHttpClient.class)
@Configuration
class KeycloakHttpClientsConfiguration {
}
```
**좋은 이유:**
- 선언형 인터페이스로 계약이 분명하다
- group을 통해 URL, timeout, SSL, auth customization을 함께 묶을 수 있다.
### 예시 4. 외부 DTO와 내부 결과를 명시적으로 분리한다
```java
public record KeycloakUserResponse(
@JsonProperty("id") String id,
@JsonProperty("email") String email,
@JsonProperty("enabled") boolean enabled
) {
}
public record ExternalUserResult(
String externalUserId,
String email,
boolean active
) {
}
public class KeycloakUserMapper {
ExternalUserResult toResult(KeycloakUserResponse response) {
return new ExternalUserResult(
response.id(),
response.email(),
response.enabled()
);
}
}
```
**좋은 이유:**
- provider JSON 계약이 내부 모델로 그대로 번지지 않는다
- 필드명 mismatch와 provider 의미를 adapter 경계에 가둔다
## 나쁜 예시
### 예시 1. controller가 외부 API를 직접 호출한다
```java
@RestController
@RequiredArgsConstructor
class BadTokenController {
private final RestClient.Builder restClientBuilder;
@PostMapping("/api/v1/tokens")
ApiResult<?> create(@RequestBody CreateTokenRequest request) {
KeycloakTokenResponse response = restClientBuilder.build()
.post()
.uri("https://keycloak.example.com/token")
.body(request)
.retrieve()
.body(KeycloakTokenResponse.class);
return ApiResult.success(response);
}
}
```
**나쁜 이유:**
- controller가 외부 연동과 transport 변환을 직접 수행한다
- base URL이 하드코딩돼 있다
- 외부 DTO가 내부 API 응답으로 그대로 노출된다
### 예시 2. 외부 DTO를 application 시그니처에 그대로 넘긴다
```java
@Service
class BadIssueTokenService {
public void issue(KeycloakTokenRequest request) {
// ...
}
}
```
**나쁜 이유:**
- application이 provider 계약에 결합된다
- 외부 필드명/형식 변화가 내부 계층으로 번진다
### 예시 3. RestClient.create()를 직접 써서 공통 구성을 우회한다
```java
@Service
class BadExternalClient {
private final RestClient client = RestClient.create("https://example.org");
}
```
**나쁜 이유:**
- Boot auto-configuration, customizer, instrumentation 적용을 우회한다.
### 예시 4. provider-specific 예외를 그대로 내부로 던진다
```java
public ExternalUserResult getUser(String id) {
try {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(ExternalUserResult.class)
.block();
} catch (WebClientResponseException ex) {
throw ex;
}
}
```
**나쁜 이유:**
- application이 HTTP status와 client exception 타입에 직접 묶인다
- 예외 번역 책임이 adapter 밖으로 새어 나간다
+191
View File
@@ -0,0 +1,191 @@
# Fallback 예시
## 좋은 예시
### 예시 1. 외부 추천 실패 시 빈 추천 목록으로 degrade한다
```java
@Service
@RequiredArgsConstructor
public class RecommendationIntegrationService {
private final RecommendationClient recommendationClient;
public RecommendationResult getRecommendations(String userId) {
try {
return recommendationClient.getRecommendations(userId);
} catch (ExternalRecommendationTemporaryFailure ex) {
return RecommendationResult.degradedEmpty();
}
}
}
```
**좋은 이유:**
- 추천은 soft dependency로 다룰 수 있다
- 핵심 기능을 깨지 않고 degraded mode를 제공한다
- fallback 위치가 integration 경계에 있다
### 예시 2. 캐시된 공개키로 fallback한다
```java
@Service
@RequiredArgsConstructor
public class JwkIntegrationService {
private final JwkClient jwkClient;
private final JwkCache jwkCache;
public JwkSetResult getJwkSet() {
try {
JwkSetResult result = jwkClient.fetch();
jwkCache.put(result);
return result;
} catch (ExternalJwkTemporaryFailure ex) {
return jwkCache.get()
.orElseThrow(() -> ex);
}
}
}
```
**좋은 이유:**
- 조회성 데이터에 짧은 TTL 캐시 fallback을 적용할 수 있다
- fallback 가능성과 불가능성이 함께 표현된다
- 외부 실패를 무조건 숨기지 않는다
### 예시 3. CircuitBreaker fallback을 명시적으로 둔다
```java
@Service
@RequiredArgsConstructor
public class ExternalProfileService {
private final CircuitBreakerFactory<?, ?> circuitBreakerFactory;
private final ExternalProfileClient externalProfileClient;
public ProfileSupplementResult getSupplement(String userId) {
return circuitBreakerFactory.create("external-profile")
.run(
() -> externalProfileClient.getProfile(userId),
throwable -> ProfileSupplementResult.degradedUnavailable()
);
}
}
```
**좋은 이유:**
- Spring Cloud CircuitBreaker의 공식 fallback 모델을 따른다
- fallback 결과가 별도 degraded result로 표현된다.
### 예시 4. 이메일 발송은 비동기 접수로 degrade할 수 있다
```java
@Service
@RequiredArgsConstructor
public class MailIntegrationService {
private final MailClient mailClient;
private final MailOutboxRepository mailOutboxRepository;
public MailDispatchResult sendVerificationMail(MailCommand command) {
try {
mailClient.send(command);
return MailDispatchResult.sent();
} catch (ExternalMailTemporaryFailure ex) {
mailOutboxRepository.enqueue(command);
return MailDispatchResult.acceptedForRetry();
}
}
}
```
**좋은 이유:**
- 즉시 발송 실패를 비동기 재처리로 전환한다
- API 의미를 “즉시 완료”가 아니라 “접수됨”으로 명확히 바꿀 수 있다
- hard dependency를 soft dependency로 바꾸는 사례다.
## 나쁜 예시
### 예시 1. 결제 확정 실패를 성공처럼 fallback한다
```java
public PaymentCaptureResult capture(CaptureCommand command) {
try {
return paymentClient.capture(command);
} catch (Exception ex) {
return PaymentCaptureResult.success();
}
}
```
**나쁜 이유:**
- 실제 결제 확정 실패를 성공처럼 숨긴다
- 정합성과 감사 가능성을 깨뜨린다
- fallback을 쓰면 안 되는 대표 사례다
### 예시 2. 오래된 캐시를 무기한 사용한다
```java
public ExchangeRateResult getRate(String currency) {
try {
return exchangeRateClient.getRate(currency);
} catch (Exception ex) {
return foreverCache.get(currency);
}
}
```
**나쁜 이유:**
- stale budget이 없다
- 오래된 데이터를 최신 사실처럼 쓰게 된다
- 운영에서 품질 저하를 통제할 수 없다
### 예시 3. controller에서 fallback을 직접 구현한다
```java
@RestController
@RequiredArgsConstructor
public class UserController {
private final ExternalProfileClient externalProfileClient;
@GetMapping("/api/v1/users/{userId}")
public ApiResult<UserResponse> get(@PathVariable String userId) {
try {
ExternalProfileResponse response = externalProfileClient.getProfile(userId);
return ApiResult.success(UserResponse.from(response));
} catch (Exception ex) {
return ApiResult.success(UserResponse.withoutProfile());
}
}
}
```
**나쁜 이유:**
- fallback이 controller로 새어 나갔다
- provider-aware 로직이 presentation 경계에 있다
- 공통 observability와 정책 일관성이 깨진다
### 예시 4. fallback 발생을 전혀 기록하지 않는다
```java
try {
return recommendationClient.getRecommendations(userId);
} catch (Exception ex) {
return RecommendationResult.degradedEmpty();
}
```
**나쁜 이유:**
- degraded mode가 운영에서 보이지 않는다
- fallback rate를 추적할 수 없다
- upstream 장애가 숨어 버린다
+133
View File
@@ -0,0 +1,133 @@
# Integration Idempotency 예시
## 좋은 예시
### 예시 1. provider 공식 header를 adapter에서 설정한다
```java
@Service
@RequiredArgsConstructor
class StripePaymentClient {
private final RestClient restClient;
ChargeResult createCharge(CreateChargeCommand command) {
return restClient.post()
.uri("/v1/payment_intents")
.header("Idempotency-Key", command.providerIdempotencyKey())
.body(StripeCreateChargeRequest.from(command))
.retrieve()
.body(ChargeResult.class);
}
}
```
**좋은 이유:**
- provider 공식 idempotency header를 adapter 경계에서 설정한다
- application/domain이 HTTP 헤더 이름을 몰라도 된다
- 같은 command 재전송 시 같은 key를 쓸 수 있다.
### 예시 2. 내부 command id와 provider key를 분리해 관리한다
```java
public record OutboundCallKey(
String outboundCommandId,
String provider,
String operation,
String providerIdempotencyKey
) {
}
public record OutboundFingerprint(
String requestDigest
) {
}
```
**좋은 이유:**
- 내부 추적 키와 provider 전송 키가 분리된다
- provider별 operation scope 차이를 표현하기 쉽다
- fingerprint 충돌 검사를 붙이기 좋다
### 예시 3. timeout 후 같은 key로 재전송한다
```java
try {
return paypalCaptureClient.capture(command);
} catch (ExternalTimeoutException ex) {
return paypalCaptureClient.capture(command.withSameProviderIdempotencyKey());
}
```
**좋은 이유:**
- side effect 재시도 시 새 key를 만들지 않는다
- 같은 요청 의도에 같은 provider key를 재사용한다
- PayPal/Stripe 문서 취지와 맞는다.
### 예시 4. 같은 key 동시 송신을 막는다
```java
if (!outboundIdempotencyCoordinator.tryAcquire(command.provider(), command.operation(), command.providerIdempotencyKey())) {
throw new DuplicateOutboundCallInProgressException();
}
```
**좋은 이유:**
- 같은 key 두 번 동시 전송을 줄인다
- PayPal이 설명한 concurrent duplicate 문제를 완화할 수 있다.
## 나쁜 예시
### 예시 1. timeout 후 새 key로 다시 보낸다
```java
try {
return stripeClient.createCharge(command.withNewProviderIdempotencyKey());
} catch (ExternalTimeoutException ex) {
return stripeClient.createCharge(command.withNewProviderIdempotencyKey());
}
```
**나쁜 이유:**
- 같은 외부 side effect 요청이 새 요청으로 처리될 수 있다
- 중복 생성/중복 결제 위험이 커진다
### 예시 2. 같은 key를 다른 operation에 재사용한다
```java
String key = "7f6d...";
authorizePayment(key);
capturePayment(key);
```
**나쁜 이유:**
- provider마다 operation scope가 다를 수 있다
- PayPal은 API call type 단위 고유성을 요구한다.
### 예시 3. provider key에 이메일을 넣는다
```java
String providerIdempotencyKey = request.email() + ":" + request.orderId();
```
**나쁜 이유:**
- PII가 key에 섞인다
- Stripe도 민감정보를 key로 쓰지 말라고 권고한다.
### 예시 4. replay semantics를 무시하고 항상 “새 성공”으로 해석한다
```java
return new PaymentCapturedResult(true, true);
```
**나쁜 이유:**
- provider가 이전 결과 재생인지 최신 상태 조회인지 구분하지 못한다
- 내부 감사/운영 추적이 부정확해진다
+159
View File
@@ -0,0 +1,159 @@
# Retry 예시
## 좋은 예시
### 예시 1. retry 대상 예외와 backoff를 명시한다
```java
@Component
@RequiredArgsConstructor
public class ExternalTokenGateway {
private final ExternalTokenClient externalTokenClient;
@Retryable(
retryFor = {
SocketTimeoutException.class,
ConnectException.class,
ResourceAccessException.class
},
noRetryFor = {
IllegalArgumentException.class,
ExternalAuthenticationRejectedException.class
},
maxAttempts = 3,
backoff = @Backoff(delay = 300, maxDelay = 2_000, multiplier = 2.0, random = true)
)
public TokenResult issueToken(TokenCommand command) {
return externalTokenClient.issueToken(command);
}
}
```
**좋은 이유:**
- retry 대상을 좁혔다
- business rejection은 제외했다
- backoff + jitter 성격(random = true)을 명시했다.
### 예시 2. 최종 실패만 내부 예외로 번역한다
```java
@Component
@RequiredArgsConstructor
public class KeycloakTokenClient {
private final RestClient restClient;
public TokenResult issue(TokenCommand command) {
try {
return doIssue(command);
} catch (HttpServerErrorException | ResourceAccessException ex) {
throw new ExternalAuthTemporaryFailureException(ex);
} catch (HttpClientErrorException.Unauthorized ex) {
throw new ExternalAuthRejectedException(ex);
}
}
private TokenResult doIssue(TokenCommand command) {
return restClient.post()
.uri("/protocol/openid-connect/token")
.body(command)
.retrieve()
.body(TokenResult.class);
}
}
```
**좋은 이유:**
- provider-specific HTTP 오류를 내부 의미로 번역한다
- application이 raw HTTP client 예외를 직접 보지 않는다
### 예시 3. retry 후 성공은 WARN으로 남긴다
```java
log.warn("External auth request succeeded after retry. provider={} operation={} attempts={}",
"keycloak", "issue-token", attemptCount);
```
**좋은 이유:**
- 중간 장애 징후를 추적 가능하게 남긴다
- 최종 성공을 장애처럼 ERROR로 과장하지 않는다
### 예시 4. provider rate limit 신호를 존중한다
```java
if (response.getStatusCode().value() == 429) {
Duration retryAfter = parseRetryAfter(response.getHeaders());
throw new RetryableRateLimitedException(retryAfter);
}
```
**좋은 이유:**
- provider가 주는 throttling 신호를 반영할 수 있다
- 무작정 같은 간격으로 재시도하지 않는다
## 나쁜 예시
### 예시 1. 모든 예외를 그대로 retry한다
```java
@Retryable
public void callExternalApi() {
// ...
}
```
**나쁜 이유:**
- Spring 기본값은 모든 예외를 재시도할 수 있다
- deterministic failure와 business rejection까지 재시도될 수 있다.
### 예시 2. backoff 없이 즉시 재시도한다
```java
for (int i = 0; i < 3; i++) {
try {
return call();
} catch (Exception ignored) {
}
}
```
**나쁜 이유:**
- retry without backoff anti-pattern이다
- 순간 장애 시 부하를 더 키운다.
### 예시 3. side effect API를 idempotency 검토 없이 다시 호출한다
```java
try {
paymentClient.capture(request);
} catch (TimeoutException ex) {
paymentClient.capture(request);
}
```
**나쁜 이유:**
- timeout은 side effect 미발생을 보장하지 않는다
- non-idempotent retry anti-pattern에 가깝다.
### 예시 4. SDK retry와 adapter retry를 동시에 켠다
```java
public void send() {
sdkClient.send(); // SDK 내부 retry 있음
}
```
그리고 바깥에서 다시 @Retryable 적용
**나쁜 이유:**
- retry layering anti-pattern이다
- 실제 요청 수와 부하가 폭증할 수 있다.
@@ -0,0 +1,208 @@
# Integration Serialization / Deserialization 예시
## 좋은 예시
### 예시 1. 외부 response DTO만 관대하게 읽는다
```java
@JsonIgnoreProperties(ignoreUnknown = true)
public record KeycloakUserResponse(
@JsonProperty("id") String id,
@JsonProperty("email") String email,
@JsonProperty("enabled") boolean enabled
) {
}
```
**좋은 이유:**
- provider가 필드를 추가해도 파싱이 덜 깨진다
- 외부 필드명 mismatch를 DTO 경계에서 해결한다
- 내부 모델로 바로 새지 않는다.
### 예시 2. 성공 응답과 오류 응답 DTO를 분리한다
```java
@JsonIgnoreProperties(ignoreUnknown = true)
public record StripeChargeResponse(
@JsonProperty("id") String id,
@JsonProperty("status") String status
) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record StripeErrorResponse(
@JsonProperty("error") StripeErrorBody error
) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record StripeErrorBody(
@JsonProperty("type") String type,
@JsonProperty("code") String code,
@JsonProperty("message") String message
) {
}
```
**좋은 이유:**
- success/error shape를 억지로 하나의 DTO에 우겨 넣지 않는다
- adapter가 provider failure semantics를 더 명확하게 번역할 수 있다
### 예시 3. form-urlencoded 계약은 JSON으로 억지 변환하지 않는다
```java
@Service
@RequiredArgsConstructor
class KeycloakTokenClient {
private final RestClient restClient;
TokenResult issue(KeycloakTokenCommand command) {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("grant_type", "password");
form.add("client_id", command.clientId());
form.add("username", command.username());
form.add("password", command.password());
KeycloakTokenResponse response = restClient.post()
.uri("/protocol/openid-connect/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.body(KeycloakTokenResponse.class);
return new TokenResult(response.accessToken(), response.expiresIn());
}
}
```
**좋은 이유:**
- provider media type을 정확히 따른다
- form 계약을 JSON DTO로 왜곡하지 않는다
- Spring converter 지원과도 맞는다.
### 예시 4. provider-specific weird format은 adapter mapper에서 흡수한다
```java
public record ExternalPaymentResult(
String paymentId,
PaymentState state
) {
}
public class StripePaymentMapper {
ExternalPaymentResult toResult(StripeChargeResponse response) {
return new ExternalPaymentResult(
response.id(),
switch (response.status()) {
case "succeeded" -> PaymentState.SUCCEEDED;
case "processing" -> PaymentState.PROCESSING;
default -> PaymentState.UNKNOWN;
}
);
}
}
```
**좋은 이유:**
- provider string enum이 domain enum으로 직접 새지 않는다
- 새 값이 추가돼도 UNKNOWN으로 흡수할 수 있다
### 예시 5. 공통 builder를 주입받아 client를 만든다
```java
@Service
class ExternalUserClient {
private final RestClient restClient;
ExternalUserClient(RestClient.Builder builder, ExternalUserProperties properties) {
this.restClient = builder
.baseUrl(properties.baseUrl())
.build();
}
}
```
**좋은 이유:**
- 공통 HttpMessageConverters와 request factory를 따른다
- 로컬 ObjectMapper/client 생성을 줄인다.
## 나쁜 예시
### 예시 1. 내부 entity를 외부 request body로 직접 보낸다
```java
@Entity
public class User {
@Id
private Long id;
private String email;
private String password;
private String role;
}
restClient.post()
.uri("/users")
.body(user)
.retrieve();
```
**나쁜 이유:**
- 내부 모델이 외부 계약으로 새어 나간다
- provider에 보내면 안 되는 필드까지 함께 나갈 수 있다
- serialization concern이 domain/entity를 오염시킨다
### 예시 2. adapter 메서드 안에서 new ObjectMapper()를 만든다
```java
public ExternalUserResult getUser(String id) throws Exception {
String body = httpClient.get(...);
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.readValue(body, ExternalUserResult.class);
}
```
**나쁜 이유:**
- 공통 mapper/configuration을 우회한다
- client별 일관성이 깨진다
- message converter 경계를 스스로 무너뜨린다.
### 예시 3. external response를 raw Map으로 받아 business 로직에 넘긴다
```java
Map<String, Object> response = restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(Map.class);
return userService.handle(response);
```
**나쁜 이유:**
- payload reading 경계가 application으로 번진다
- contract drift가 여러 계층에 퍼진다
- tolerant reader가 아니라 “아무도 책임지지 않는 reader”가 된다
### 예시 4. 외부 오류 본문을 그대로 예외 메시지로 올린다
```java
catch (HttpClientErrorException ex) {
throw new RuntimeException(ex.getResponseBodyAsString());
}
```
**나쁜 이유:**
- provider raw payload가 내부 예외/로그로 새어 나간다
- 민감정보나 과도한 본문이 포함될 수 있다
- success/error parsing 규칙이 사라진다
+163
View File
@@ -0,0 +1,163 @@
# Timeout 예시
## 좋은 예시
### 예시 1. 전역 기본값은 공통 설정으로 둔다
```yaml
spring:
http:
clients:
connect-timeout: 500ms
read-timeout: 2s
```
**좋은 이유:**
- 서비스 전체 기본값이 한 곳에 있다
- 모든 client에 최소 timeout 정책이 적용된다.
### 예시 2. provider별 차이는 HTTP service group에서 override한다
```yaml
spring:
http:
clients:
connect-timeout: 500ms
serviceclient:
keycloak:
base-url: https://keycloak.example.com
read-timeout: 3s
payment:
base-url: https://payment.example.com
read-timeout: 5s
```
**좋은 이유:**
- 공통 기본값과 provider별 차이가 함께 보인다
- Spring Boot가 제공하는 group-level connect/read timeout 구조와 맞는다.
### 예시 3. RestClient는 주입된 builder를 사용한다
```java
@Service
class KeycloakTokenClient {
private final RestClient restClient;
KeycloakTokenClient(RestClient.Builder builder, KeycloakProperties properties) {
this.restClient = builder
.baseUrl(properties.baseUrl())
.build();
}
}
```
**좋은 이유:**
- Boot auto-configuration과 공통 timeout/customizer를 따른다
- RestClient.create()로 공통 구성을 우회하지 않는다.
### 예시 4. WebClient는 Reactor Netty timeout을 명시적으로 구성할 수 있다
```java
@Bean
WebClient paymentWebClient(WebClient.Builder builder) {
HttpClient httpClient = HttpClient.create()
.responseTimeout(Duration.ofSeconds(3))
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000);
return builder
.clientConnector(new ReactorClientHttpConnector(httpClient))
.baseUrl("https://payment.example.com")
.build();
}
```
**좋은 이유:**
- connect timeout과 response timeout을 분리한다
- Reactor Netty의 구체 timeout 지점을 활용한다.
### 예시 5. timeout 값은 operation별로 명시적 override만 허용한다
```java
Mono<ResponseDto> callLongRunningOperation(RequestDto request) {
return webClient.post()
.uri("/reports")
.bodyValue(request)
.retrieve()
.bodyToMono(ResponseDto.class)
.timeout(Duration.ofSeconds(8));
}
```
**좋은 이유:**
- “이 operation만 더 길다”는 의도가 코드에 드러난다
- 기본값과 다른 이유를 문서화하기 쉽다
**주의:**
- reactive 전체 timeout()은 최후 수단에 가깝고, 가능하면 client-specific timeout이 더 우선이다.
## 나쁜 예시
### 예시 1. 외부 호출에 timeout이 없다
```java
@Service
class BadExternalClient {
private final RestClient restClient = RestClient.create("https://example.com");
}
```
**나쁜 이유:**
- 공통 timeout/customizer/관측 구성을 우회한다
- 무제한 또는 의도 불명확한 대기에 빠질 수 있다.
### 예시 2. 너무 낮은 timeout을 근거 없이 하드코딩한다
```java
webClient.get()
.uri("/token")
.retrieve()
.bodyToMono(TokenResponse.class)
.timeout(Duration.ofMillis(20));
```
**나쁜 이유:**
- TLS handshake, 새 연결, DNS 비용을 고려하지 않은 값일 수 있다
- 배포 직후/콜드 커넥션에서 false timeout을 유발하기 쉽다.
### 예시 3. timeout 값을 서비스 전체에 하나의 숫자로 강제한다
```yaml
external:
timeout-ms: 1000
```
**나쁜 이유:**
- provider별 latency와 business 중요도가 다를 수 있다
- connect/read/response 구분도 사라진다
- operation별 차이를 담기 어렵다
### 예시 4. timeout 이후 side effect API를 무심코 재시도한다
```java
try {
paymentClient.capture(request);
} catch (TimeoutException ex) {
paymentClient.capture(request);
}
```
**나쁜 이유:**
- timeout이 side effect 미발생을 보장하지 않는다
- idempotency 검토 없이 중복 실행 위험이 생긴다.
@@ -0,0 +1,194 @@
# collections / immutability 예시
이 문서는 [collections / immutability 기준](../../standards/language/collections-immutability.md)을 코드 예시로 확인하기 위한 자료입니다.
핵심 기준은 컬렉션을 immutable-first로 다루고, 변경이 필요한 로컬 조립 단계가 끝나면 경계를 넘기기 전에 수정 불가 snapshot으로 고정하는 것입니다.
## 좋은 예시 1: 생성자에서 defensive copy
```java
public class RolePolicy {
private final List<String> allowedRoles;
public RolePolicy(List<String> allowedRoles) {
this.allowedRoles = List.copyOf(allowedRoles);
}
public List<String> allowedRoles() {
return allowedRoles;
}
}
```
왜 좋은가:
- 외부에서 넘긴 mutable list를 그대로 보관하지 않는다.
- 내부 필드를 안정된 snapshot으로 고정한다.
## 좋은 예시 2: 상수성 데이터는 of 사용
```java
private static final Set<String> PUBLIC_PATHS = Set.of(
"/",
"/login",
"/swagger-ui.html"
);
```
왜 좋은가:
- 상수 컬렉션 의도가 분명하다.
- `null`과 중복을 조기에 차단한다.
주의:
- 순서를 기대하면 `List.of`가 더 적합할 수 있다.
## 좋은 예시 3: stream 결과를 수정 불가로 고정
```java
List<String> activeEmails = users.stream()
.filter(User::isActive)
.map(User::getEmail)
.toList();
```
왜 좋은가:
- 결과가 읽기 전용이라는 의도가 분명하다.
- 후속 코드가 실수로 수정하지 못한다.
## 좋은 예시 4: mutable 조립 후 경계에서 snapshot
```java
List<String> buildScopes(User user) {
List<String> scopes = new ArrayList<>();
scopes.add("profile");
if (user.isAdmin()) {
scopes.add("admin");
}
return List.copyOf(scopes);
}
```
왜 좋은가:
- 로컬 조립 단계에서는 mutable 컬렉션을 실용적으로 사용한다.
- 반환 시점에는 안정된 snapshot으로 바꾼다.
## 좋은 예시 5: 구체 mutable 결과가 필요하면 명시
```java
List<UserDto> result = users.stream()
.map(UserMapper::toDto)
.collect(Collectors.toCollection(ArrayList::new));
```
왜 좋은가:
- mutable 결과가 필요하다는 점을 코드에 드러낸다.
- `Collectors.toList()`의 mutability를 가정하지 않는다.
## 나쁜 예시 1: 내부 mutable collection 그대로 노출
```java
public class UserGroup {
private final List<User> users = new ArrayList<>();
public List<User> getUsers() {
return users;
}
}
```
문제:
- 외부에서 내부 상태를 직접 수정할 수 있다.
- 캡슐화가 깨진다.
개선:
```java
public List<User> getUsers() {
return List.copyOf(users);
}
```
## 나쁜 예시 2: unmodifiable view를 immutable로 착각
```java
List<String> source = new ArrayList<>();
source.add("A");
List<String> readOnly = Collections.unmodifiableList(source);
source.add("B");
```
문제:
- `readOnly`는 immutable snapshot이 아니라 view다.
- `source`가 바뀌면 `readOnly`도 바뀐다.
개선:
```java
List<String> readOnly = List.copyOf(source);
```
## 나쁜 예시 3: null collection 반환
```java
public List<Role> findRoles(Long userId) {
if (userId == null) {
return null;
}
// ...
}
```
문제:
- 호출자마다 null-check를 강요한다.
- 컬렉션 결과의 계약이 흐려진다.
개선:
- empty list를 반환한다.
- 또는 입력 자체를 경계에서 검증한다.
## 나쁜 예시 4: 순서를 기대하면서 Set.of 사용
```java
Set<String> statuses = Set.of("NEW", "PROCESSING", "DONE");
String first = statuses.iterator().next();
```
문제:
- `Set.of` iteration order를 비즈니스 로직에 기대고 있다.
- JVM 실행마다 순서가 달라질 수 있다.
개선:
```java
List<String> statuses = List.of("NEW", "PROCESSING", "DONE");
String first = statuses.getFirst();
```
## 나쁜 예시 5: shallow immutability 오해
```java
List<UserProfile> profiles = List.copyOf(sourceProfiles);
profiles.get(0).changeNickname("new-name");
```
문제:
- 컬렉션은 수정 불가지만 원소는 mutable이라 상태가 바뀔 수 있다.
- 공유 상태 안정성을 보장하지 못한다.
개선 방향:
- immutable element를 사용한다.
- mutable element는 공유하지 않도록 복사하거나 변환한다.
+137
View File
@@ -0,0 +1,137 @@
# duplication 예시
## 좋은 예시 1: 같은 정책 중복은 private method로 추출
```java
private String normalizeEmail(String rawEmail) {
return rawEmail.trim().toLowerCase(Locale.ROOT);
}
public User register(String rawEmail) {
String email = normalizeEmail(rawEmail);
...
}
public User login(String rawEmail) {
String email = normalizeEmail(rawEmail);
...
}
```
왜 좋은가:
- 같은 정책이다
- 같은 이유로 바뀐다
- 한 곳에서 수정 가능하다
## 좋은 예시 2: 외부 API 예외 번역 중복 추출
```java
private InfrastructureException vaultFailure(String message, Exception cause) {
return new InfrastructureException(
InfrastructureErrorCode.VAULT_TRANSIT_FAILED,
message,
cause
);
}
```
왜 좋은가:
- 기술 실패 번역 정책이 한 곳에 모인다
- 누락/불일치 위험이 줄어든다
## 좋은 예시 3: 테스트는 중복을 일부 허용
```java
@Test
void registers_two_users() {
User user1 = new User("alice");
User user2 = new User("bob");
forum.register(user1);
forum.register(user2);
assertTrue(forum.hasRegisteredUser(user1));
assertTrue(forum.hasRegisteredUser(user2));
}
```
왜 좋은가:
- helper/loop보다 읽기 쉽다
- 테스트 의도가 바로 드러난다
## 좋은 예시 4: 3회 이상 반복되는 mapper 규칙 추출
```java
private ApiResult<Void> failureOf(ApplicationException exception) {
return ApiResult.failure(exception.getCode(), exception.getMessage());
}
```
왜 좋은가:
- 응답 실패 조립 규칙이 공통 정책이다
- presentation 전반에서 같은 이유로 바뀔 가능성이 높다
## 나쁜 예시 1: 우연한 유사성을 억지로 공통화
```java
public Object process(Object input, String mode, Map<String, Object> options) {
...
}
```
문제:
- 맥락이 다른 두세 개 흐름을 한 메서드로 억지로 합친다
- 이름이 모호해지고 분기만 늘어난다
- 이후 독립 진화가 어렵다
## 나쁜 예시 2: 레이어를 넘는 공통화
```java
public final class CommonValidationUtil {
public static void validateUser(User user, CreateUserRequest request, UserJpaEntity entity) {
...
}
}
```
문제:
- domain/presentation/infrastructure 경계를 한 곳에 섞는다
- 중복 제거보다 아키텍처 손상이 더 크다
## 나쁜 예시 3: 테스트를 너무 DRY하게 만들어 의미 숨김
```java
private void registerAll(List<User> users) { ... }
@Test
void registers_users() {
registerAll(defaultUsers());
assertAllRegistered(defaultUsers());
}
```
문제:
- 테스트 본문만 보면 실제 행위가 잘 드러나지 않는다
- helper를 따라가야 해서 검증이 어려워진다
## 나쁜 예시 4: common 모듈로 너무 빨리 이동
```text
common/
StringUtils.java
DateUtils.java
ErrorUtils.java
ValidationUtils.java
```
문제:
- “중복 제거” 명분으로 소유권 없는 잡동사니 모듈이 된다
- 진짜 공통인지, 그냥 아직 설계가 안 된 것인지 구분이 사라진다
+213
View File
@@ -0,0 +1,213 @@
# enum / constants 예시
이 문서는 [enum / constants 기준](../../standards/language/enum-constants.md)을 코드 예시로 확인하기 위한 자료입니다.
핵심 기준은 고정된 의미 집합을 enum으로 표현하고, external code / display label / internal name을 섞지 않는 것입니다.
## 좋은 예시 1: 문자열 상수 대신 enum
```java
public enum AuthProvider {
LOCAL,
GOOGLE,
KAKAO
}
if (user.getProvider() == AuthProvider.LOCAL) {
// local login flow
}
```
왜 좋은가:
- 고정된 값 집합을 타입으로 표현한다.
- 오타와 매직 스트링 분기를 줄인다.
## 좋은 예시 2: external code를 명시적 필드로 분리
```java
public enum AuthProvider {
LOCAL("local"),
GOOGLE("google"),
KAKAO("kakao");
private final String code;
AuthProvider(String code) {
this.code = code;
}
public String code() {
return code;
}
}
```
왜 좋은가:
- `name()`에 외부 계약을 맡기지 않는다.
- 내부 enum 이름 변경과 외부 계약을 분리할 수 있다.
## 좋은 예시 3: EnumSet 사용
```java
private static final EnumSet<AuthProvider> SOCIAL_PROVIDERS =
EnumSet.of(AuthProvider.GOOGLE, AuthProvider.KAKAO);
```
왜 좋은가:
- enum 집합이라는 의도가 직접 드러난다.
- 비트 플래그나 일반 `Set`보다 타입 안전하고 목적에 맞다.
## 좋은 예시 4: EnumMap 사용
```java
private final EnumMap<AuthProvider, OAuthClient> clients =
new EnumMap<>(AuthProvider.class);
```
왜 좋은가:
- enum key 전용 자료구조라는 점이 명확하다.
- 일반 `HashMap`보다 목적에 더 잘 맞는다.
## 좋은 예시 5: 진짜 상수만 상수로 둠
```java
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(30);
private static final List<String> PUBLIC_PATHS = List.of("/", "/login");
```
왜 좋은가:
- 값이 immutable이다.
- `static final`뿐 아니라 실제 의미도 안정적이다.
## 나쁜 예시 1: ordinal 저장/분기
```java
int providerCode = provider.ordinal();
```
문제:
- enum 순서 변경이나 값 추가에 취약하다.
- stable contract가 아니다.
개선:
```java
String providerCode = provider.code();
```
## 나쁜 예시 2: name/toString 문자열 비교
```java
if (provider.name().equals("GOOGLE")) {
// google login flow
}
```
문제:
- enum 의미 비교를 문자열 비교로 내린다.
- 타입 안전성이 사라진다.
개선:
```java
if (provider == AuthProvider.GOOGLE) {
// google login flow
}
```
## 나쁜 예시 3: 잡다한 constants class
```java
public final class AppConstants {
public static final String PROVIDER_LOCAL = "LOCAL";
public static final String PROVIDER_GOOGLE = "GOOGLE";
public static final String PROVIDER_KAKAO = "KAKAO";
private AppConstants() {
}
}
```
문제:
- 고정된 의미 집합을 타입으로 표현하지 않는다.
- 문자열 오타와 분기 누락에 취약하다.
개선:
```java
public enum AuthProvider {
LOCAL,
GOOGLE,
KAKAO
}
```
## 나쁜 예시 4: mutable collection을 상수처럼 사용
```java
private static final Set<String> PUBLIC_PATHS = new HashSet<>();
```
문제:
- `static final`이어도 내부 상태는 바뀔 수 있다.
- 진짜 상수라고 보기 어렵다.
개선:
```java
private static final Set<String> PUBLIC_PATHS = Set.of("/", "/login");
```
## 나쁜 예시 5: default로 enum 추가 누락 숨김
```java
return switch (provider) {
case LOCAL -> localHandler();
default -> socialHandler();
};
```
문제:
- 새 enum 값이 생겨도 의도치 않게 `default`에 흡수될 수 있다.
- 분기 누락이 컴파일 시점에 드러나기 어렵다.
개선:
```java
return switch (provider) {
case LOCAL -> localHandler();
case GOOGLE, KAKAO -> socialHandler();
};
```
## 나쁜 예시 6: null 회피용 UNKNOWN 남용
```java
public enum AuthProvider {
UNKNOWN,
LOCAL,
GOOGLE,
KAKAO
}
```
문제:
- `UNKNOWN`이 실제 비즈니스 상태가 아니라면 의미 없는 상태가 생긴다.
- 단순 null 회피가 enum 모델에 섞인다.
개선 방향:
- boundary 입력은 검증하거나 nullable로 명시한다.
- 조회 결과의 부재는 필요하면 `Optional<AuthProvider>`로 표현한다.
- 실제 비즈니스 상태일 때만 `UNKNOWN` 또는 `UNSPECIFIED`를 둔다.
+216
View File
@@ -0,0 +1,216 @@
# exceptions 예시
이 문서는 [exceptions 기준](../../standards/language/exceptions.md)을 코드 예시로 확인하기 위한 자료입니다.
핵심 기준은 예외를 정상 흐름 제어가 아니라 예외 상황 전달 수단으로 사용하고, catch는 번역 / 문맥 추가 / 복구 목적이 있을 때만 두는 것입니다.
## 좋은 예시 1: 기술 예외를 계층 예외로 번역하면서 cause 보존
```java
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return parse(response.body());
} catch (IOException exception) {
throw new InfrastructureException(
InfrastructureErrorCode.EXTERNAL_API_FAILED,
"Failed to call external API",
exception
);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new InfrastructureException(
InfrastructureErrorCode.EXTERNAL_API_FAILED,
"External API call was interrupted",
exception
);
}
```
왜 좋은가:
- broad catch가 아니다.
- `InterruptedException`을 별도로 처리한다.
- cause를 보존한다.
- 기술 실패를 infrastructure 의미로 번역한다.
## 좋은 예시 2: try-with-resources 사용
```java
try (InputStream in = Files.newInputStream(path)) {
return objectMapper.readValue(in, Payload.class);
}
```
왜 좋은가:
- 자원 해제를 자동화한다.
- close 중 예외가 발생해도 suppressed exception으로 보존될 수 있다.
## 좋은 예시 3: 상위 경계에서만 broad catch
```java
try {
return useCase.execute(command);
} catch (ApplicationException exception) {
return errorResponse(exception.getCode(), exception.getMessage());
} catch (Exception exception) {
log.error("Unhandled exception while processing request", exception);
return errorResponse("INTERNAL_SERVER_ERROR", "Unexpected server error");
}
```
왜 좋은가:
- 최상위 boundary에서 마지막 방어선으로만 broad catch를 쓴다.
- 내부 계층에서는 더 구체적인 예외 처리를 유지한다.
- 예상 가능한 application 예외와 예상하지 못한 실패를 구분한다.
## 좋은 예시 4: checked 예외 rollback 필요 시 명시
```java
@Transactional(rollbackFor = IOException.class)
public void importUsers(Path path) throws IOException {
// import users from file
}
```
왜 좋은가:
- Spring 기본 rollback 규칙을 명시적으로 보완한다.
- checked exception이 rollback 대상인지 계약으로 드러난다.
## 좋은 예시 5: 테스트는 assertThrows 우선
```java
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> service.createUser(command)
);
```
왜 좋은가:
- `try-catch + fail` 패턴보다 의도가 직접적이다.
- 예외 객체를 받아 메시지나 상태를 추가로 검증할 수 있다.
## 나쁜 예시 1: broad catch + 삼키기
```java
try {
saveUser(user);
} catch (Exception exception) {
}
```
문제:
- 예외가 사라진다.
- 디버깅이 어려워진다.
- interruption 같은 중요한 신호도 놓칠 수 있다.
개선 방향:
- 복구할 수 있는 구체 예외만 catch한다.
- 계층 예외로 번역하거나, 문맥을 붙여 다시 던진다.
- 정말 무시해야 한다면 이유를 남기고 logging / metrics / 상태 기록 중 하나를 수행한다.
## 나쁜 예시 2: printStackTrace 후 계속 진행
```java
try {
sync();
} catch (IOException exception) {
exception.printStackTrace();
}
```
문제:
- 운영 로그 정책을 깨뜨린다.
- 실패를 구조적으로 전달하지 못한다.
개선 방향:
- logging framework로 기록한다.
- 또는 계층 예외로 번역해 상위 boundary로 전달한다.
## 나쁜 예시 3: finally에서 return
```java
try {
return load();
} finally {
return fallback();
}
```
문제:
- try 블록 결과와 예외를 덮어쓴다.
- 실제 실패가 호출자에게 전달되지 않을 수 있다.
개선 방향:
- `finally`는 정리 작업만 수행한다.
- fallback이 필요하면 catch나 명시적 분기에서 처리한다.
## 나쁜 예시 4: InterruptedException 뭉개기
```java
try {
queue.take();
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
```
문제:
- interruption을 별도 의미로 처리하지 않는다.
- 스레드 인터럽트 상태를 잃을 수 있다.
개선:
```java
try {
queue.take();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting for queue item", exception);
}
```
## 나쁜 예시 5: 너무 넓은 일반 예외 던지기
```java
throw new RuntimeException("bad request");
```
문제:
- 의미가 너무 넓다.
- 호출자가 어떤 실패인지 이해하기 어렵다.
- 경계에서 일관된 에러 코드나 응답으로 번역하기 어렵다.
개선 방향:
- 계약 위반이면 `IllegalArgumentException` 같은 구체 예외를 사용한다.
- 계층 의미가 있으면 domain / application / infrastructure 예외로 표현한다.
## 나쁜 예시 6: 정상적인 결과 없음에 예외 사용
```java
public User findUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("user not found"));
}
```
문제:
- 결과 없음이 정상적인 조회 결과일 수 있는데 예외로만 표현한다.
- 호출자가 부재를 처리할 수 있는 선택지를 잃는다.
개선 방향:
- 단건 조회의 부재가 정상 흐름이면 `Optional<User>`를 반환한다.
- 유스케이스 계약상 반드시 있어야 하는 값이면 구체적인 application 예외로 번역한다.
+164
View File
@@ -0,0 +1,164 @@
# Javadoc 예시
## 좋은 예시 1: 반환 계약과 예외 조건이 드러나는 메서드
```java
/**
* Returns the active user for the given email.
*
* @param email normalized user email, never {@code null}
* @return the matching active user
* @throws UserNotFoundException if no user exists for the given email
* @throws InactiveUserException if the user exists but is inactive
*/
public User getActiveUserByEmail(String email) {
...
}
```
왜 좋은가:
- 호출자가 믿을 수 있는 계약이 보인다
- null 허용 여부와 실패 조건이 드러난다
- 구현 세부가 아니라 API 의미를 설명한다
## 좋은 예시 2: value object 생성 제약 문서화
```java
/**
* Value object representing a normalized email address.
*
* <p>The value is always lowercase and trimmed.
*/
public record UserEmail(String value) {
...
}
```
왜 좋은가:
- 타입의 핵심 invariant를 문서화한다
- typical reader가 놓치기 쉬운 제약을 설명한다
## 좋은 예시 3: override는 문서 상속 활용
```java
@Override
public String getName() {
return name;
}
```
왜 좋은가:
- 상위 계약이 충분하면 중복 문서를 쓰지 않는다
- 불필요한 복붙 Javadoc을 줄인다
## 좋은 예시 4: package/class 수준에서 구조 설명
```java
/**
* HTTP request/response contracts and exception translation for the auth API.
*
* <p>This package owns controllers, request/response DTOs, and client-facing
* error handling. It must not depend directly on infrastructure implementations.
*/
package com.project.auth.presentation;
```
왜 좋은가:
- package 책임과 금지사항이 드러난다
- architecture 문서와 연결되는 설명이다
## 나쁜 예시 1: 자명한 getter 설명
```java
/**
* Returns the user name.
*/
public String getUserName() {
return userName;
}
```
문제:
- 이름만 읽어도 알 수 있다
- 유지보수 시 stale 될 가능성만 늘어난다
개선:
- 생략하거나
- 정말 추가 계약이 있을 때만 적는다
## 나쁜 예시 2: 구현 설명만 적음
```java
/**
* Uses ArrayList internally and loops over all elements to find the user.
*/
public User findUser(String email) {
...
}
```
문제:
- 구현 세부에 과도하게 묶인다
- 리팩터링 시 쉽게 거짓 문서가 된다
개선:
- 호출 계약, 검색 조건, 실패 조건을 설명한다
## 나쁜 예시 3: 태그만 채우는 문서
```java
/**
* @param email the email
* @return the user
*/
public User findUser(String email) {
...
}
```
문제:
- 독자에게 새로운 정보가 없다
- 형식만 있고 계약이 없다
개선:
- summary와 제약/의미를 써라
- 아니면 생략하라
## 나쁜 예시 4: stale Javadoc 방치
```java
/**
* Returns a mutable list of authorities.
*/
public List<String> getAuthorities() {
return List.copyOf(authorities);
}
```
문제:
- 코드와 문서가 충돌한다
- 거짓 문서가 된다
개선:
```java
/**
* Returns an unmodifiable snapshot of authorities.
*/
public List<String> getAuthorities() {
return List.copyOf(authorities);
}
```
또는 Javadoc 삭제 후 더 적절한 형태로 재작성
+140
View File
@@ -0,0 +1,140 @@
# null 처리 예시
이 문서는 [null 처리 기준](../../standards/language/null.md)을 코드 예시로 확인하기 위한 자료입니다.
핵심 기준은 `null`을 경계에서만 제한적으로 받고, 내부 로직에 들어가기 전에 non-null 값이나 명시적 상태로 정리하는 것입니다.
## 좋은 예시 1: 생성자/경계에서 즉시 검증
```java
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = Objects.requireNonNull(userRepository, "userRepository must not be null");
this.passwordEncoder = Objects.requireNonNull(passwordEncoder, "passwordEncoder must not be null");
}
```
왜 좋은가:
- boundary에서 non-null 계약을 바로 강제한다.
- 내부 필드는 이후 non-null로 다룰 수 있다.
## 좋은 예시 2: 외부 입력은 DTO에서 받고 내부에서 정리
```java
public CreateUserCommand toCommand(CreateUserRequest request) {
return new CreateUserCommand(
UserEmail.from(request.email()),
request.nickname() == null ? null : request.nickname().trim()
);
}
```
더 좋은 경우:
- nullable `nickname`을 value object 또는 명시적 규칙으로 바로 정리한다.
왜 좋은가:
- nullable 입력이 boundary에 머문다.
- 내부 의미로 들어가기 전에 정리할 수 있다.
## 좋은 예시 3: 컬렉션은 null 대신 empty 반환
```java
public List<Role> findRoles(Long userId) {
List<Role> roles = roleRepository.findAllByUserId(userId);
return roles == null ? List.of() : roles;
}
```
더 좋은 경우:
- repository 계약 자체를 null이 아닌 empty 반환으로 고정한다.
왜 좋은가:
- 호출자가 불필요한 null-check를 하지 않아도 된다.
## 좋은 예시 4: persistence -> domain 변환에서 nullable 차단
```java
public User toDomain(UserJpaEntity entity) {
return User.restore(
Objects.requireNonNull(entity.getId(), "id must not be null"),
UserEmail.from(Objects.requireNonNull(entity.getEmail(), "email must not be null")),
Objects.requireNonNull(entity.getEncodedPassword(), "encodedPassword must not be null"),
UserName.from(Objects.requireNonNull(entity.getName(), "name must not be null"))
);
}
```
왜 좋은가:
- DB nullable/오염 상태를 domain으로 전파하지 않는다.
- invariant 경계가 분명하다.
## 나쁜 예시 1: Optional과 null 혼용
```java
public Optional<User> findByEmail(String email) {
if (email == null) {
return null;
}
// ...
}
```
문제:
- `Optional` 반환 계약을 깨뜨린다.
- 호출자는 `Optional``null`을 동시에 처리해야 한다.
개선:
- null 입력 자체를 검증한다.
- 또는 `Optional.empty()`를 반환한다.
- 또는 파라미터를 non-null로 강제한다.
## 나쁜 예시 2: null을 business 의미로 사용
```java
if (user.getProvider() == null) {
// local user
}
```
문제:
- business state가 `null`에 숨는다.
- 의미가 타입으로 드러나지 않는다.
개선:
```java
if (user.getProvider() == AuthProvider.LOCAL) {
// local user
}
```
또는 명시적 enum/state를 사용한다.
## 나쁜 예시 3: 여러 계층으로 nullable 전파
```java
public String handle(String nickname) {
return service.process(nickname);
}
public String process(String nickname) {
return repository.saveNickname(nickname);
}
```
문제:
- nullable 여부가 계약으로 명시되지 않는다.
- 모든 계층이 방어 책임을 떠넘긴다.
개선:
- boundary에서 검증/정규화한다.
- nullable이면 `Optional`, value object, 명시적 command로 변환한다.
+183
View File
@@ -0,0 +1,183 @@
# Optional 사용 예시
이 문서는 [Optional 사용 기준](../../standards/language/optional.md)을 코드 예시로 확인하기 위한 자료입니다.
핵심 기준은 `Optional`을 값의 부재가 가능한 단건 반환 타입에 쓰고, 필드/파라미터/직렬화 경계에는 기본적으로 쓰지 않는 것입니다.
## 좋은 예시 1: 단건 조회 결과 없음 표현
```java
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
```
왜 좋은가:
- 단건 조회 결과의 부재를 반환 타입에서 명시한다.
- 호출자에게 "없을 수 있음"을 강제한다.
## 좋은 예시 2: transform chain
```java
public Optional<String> findActiveUserEmail(Long userId) {
return userRepository.findById(userId)
.filter(User::isActive)
.map(User::getEmail);
}
```
왜 좋은가:
- `isPresent() + get()` 없이 선언적으로 표현한다.
- 값이 없으면 자연스럽게 empty로 전파된다.
## 좋은 예시 3: nested Optional 방지
```java
public Optional<Token> resolveToken(Long userId) {
return userRepository.findById(userId)
.flatMap(tokenService::findValidToken);
}
```
왜 좋은가:
- `flatMap`으로 `Optional<Optional<Token>>`를 만들지 않는다.
## 좋은 예시 4: expensive default는 orElseGet
```java
UserProfile profile = profileRepository.findByUserId(userId)
.orElseGet(() -> profileFactory.createDefault(userId));
```
왜 좋은가:
- 기본값 생성 비용이 있을 때 lazy supplier를 사용한다.
## 좋은 예시 5: 단건은 Optional, 다건은 빈 컬렉션
```java
public List<Role> findRoles(Long userId) {
return roleRepository.findAllByUserId(userId);
}
```
왜 좋은가:
- 다건 결과의 부재를 `Optional<List<Role>>`로 감싸지 않는다.
- 호출자는 빈 컬렉션으로 처리하면 된다.
## 나쁜 예시 1: Optional 반환인데 null 반환
```java
public Optional<User> findByEmail(String email) {
return null;
}
```
문제:
- `Optional` 자체가 `null`이 되어 의미가 깨진다.
- 호출자는 `Optional``null`을 둘 다 처리해야 한다.
개선:
```java
public Optional<User> findByEmail(String email) {
return Optional.empty();
}
```
## 나쁜 예시 2: 필드에 Optional 저장
```java
public class UserResponse {
private Optional<String> nickname;
}
```
문제:
- DTO 경계에서 표현이 복잡해진다.
- 직렬화/스키마/API 계약이 불명확해질 수 있다.
개선:
```java
public class UserResponse {
private String nickname;
}
```
또는 nullable 여부를 API 계약에서 명시한다.
## 나쁜 예시 3: 파라미터에 Optional 사용
```java
public User createUser(Optional<String> nickname) {
// ...
}
```
문제:
- 호출자가 `Optional.empty()``null` 실수를 섞기 쉽다.
- 오버로드/명시적 request object보다 의도가 약하다.
개선:
```java
public User createUser(String nickname) {
// ...
}
```
또는:
```java
public User createUser(CreateUserCommand command) {
// ...
}
```
## 나쁜 예시 4: isPresent + get
```java
if (userOpt.isPresent()) {
return userOpt.get().getEmail();
}
return "unknown";
```
문제:
- imperative null-check와 다를 바 없는 패턴이다.
- `get()` 의존이 생긴다.
개선:
```java
return userOpt.map(User::getEmail)
.orElse("unknown");
```
## 나쁜 예시 5: map 결과를 안 쓰고 side-effect
```java
userOpt.map(user -> {
audit(user.getId());
return user;
});
```
문제:
- `map`은 값 변환인데 반환값을 사용하지 않는다.
- side-effect 목적이면 `ifPresent`가 더 맞다.
개선:
```java
userOpt.ifPresent(user -> audit(user.getId()));
```
+150
View File
@@ -0,0 +1,150 @@
# Stream 사용 예시
이 문서는 [Stream 사용 기준](../../standards/language/stream.md)을 코드 예시로 확인하기 위한 자료입니다.
핵심 기준은 Stream을 집계, 변환, 검색 파이프라인에 쓰고, 외부 상태 변경이나 필수 부작용에는 쓰지 않는 것입니다.
## 좋은 예시 1: 조회 + 변환 + 불변 결과
```java
List<String> activeEmails = users.stream()
.filter(User::isActive)
.map(User::getEmail)
.toList();
```
왜 좋은가:
- 집계/변환 파이프라인이다.
- side-effect가 없다.
- 결과가 명확하다.
- 읽는 사람이 "무엇을 만들었는지" 바로 이해할 수 있다.
## 좋은 예시 2: 존재 여부 판단
```java
boolean hasExpiredToken = tokens.stream()
.anyMatch(Token::isExpired);
```
왜 좋은가:
- for 루프보다 의도가 직접적이다.
- short-circuit terminal operation이라 불필요한 순회를 줄일 수 있다.
## 좋은 예시 3: 그룹화
```java
Map<AuthProvider, List<User>> usersByProvider = users.stream()
.collect(Collectors.groupingBy(User::getProvider));
```
왜 좋은가:
- grouping이 핵심인 aggregate operation이다.
- 외부 mutable map을 직접 관리하지 않는다.
## 좋은 예시 4: 숫자 집계는 primitive stream 사용
```java
int totalQuantity = orderLines.stream()
.mapToInt(OrderLine::getQuantity)
.sum();
```
왜 좋은가:
- 숫자 집계 의도가 분명하다.
- boxed `Integer` stream보다 표현이 명확하다.
## 좋은 예시 5: 구체 컬렉션 타입이 필요할 때만 toCollection
```java
LinkedHashSet<String> roles = authorities.stream()
.map(Authority::getRole)
.collect(Collectors.toCollection(LinkedHashSet::new));
```
왜 좋은가:
- 결과 타입 요구사항이 있을 때만 명시적으로 선택한다.
- `Collectors.toList()`의 구현/가변성에 기대지 않는다.
## 좋은 예시 6: I/O 기반 stream은 닫기
```java
try (Stream<String> lines = Files.lines(path)) {
List<String> words = lines
.flatMap(line -> Stream.of(line.split("\\s+")))
.filter(word -> !word.isBlank())
.toList();
}
```
왜 좋은가:
- I/O 기반 stream을 명시적으로 닫는다.
- transform pipeline과 resource lifecycle이 분리되어 있다.
## 나쁜 예시 1: 외부 mutable accumulator 사용
```java
List<String> emails = new ArrayList<>();
users.stream()
.filter(User::isActive)
.map(User::getEmail)
.forEach(emails::add);
```
문제:
- 불필요한 side-effect가 있다.
- 병렬화나 리팩터링에 취약하다.
- reduction/collection으로 더 안전하게 표현할 수 있다.
개선:
```java
List<String> emails = users.stream()
.filter(User::isActive)
.map(User::getEmail)
.toList();
```
## 나쁜 예시 2: 상태를 가진 람다
```java
AtomicInteger seq = new AtomicInteger(0);
List<UserView> result = users.stream()
.map(user -> new UserView(seq.incrementAndGet(), user.getEmail()))
.toList();
```
문제:
- 람다가 외부 상태에 의존한다.
- 병렬 스트림이나 리팩터링 시 의미가 불안정하다.
개선 방향:
- 순번이 비즈니스적으로 필요하면 스트림 밖에서 명시적으로 설계한다.
- 단순 변환이면 순번 생성을 제거한다.
## 나쁜 예시 3: 비즈니스 로직에서 peek 사용
```java
List<User> result = users.stream()
.peek(user -> audit("USER_READ", user.getId()))
.filter(User::isActive)
.toList();
```
문제:
- `peek`를 필수 부작용 채널로 쓰고 있다.
- 최적화/재구성 시 기대가 깨질 수 있다.
개선 방향:
- audit가 필수라면 terminal boundary에서 명시적으로 처리한다.
- `peek`는 임시 디버깅 용도로만 제한한다.
+221
View File
@@ -0,0 +1,221 @@
# time 타입 / 포맷 예시
이 문서는 [time 타입 / 포맷 기준](../../standards/language/time.md)을 코드 예시로 확인하기 위한 자료입니다.
핵심 기준은 시간 값을 문자열이나 숫자로 들고 다니지 않고, 시점 / 날짜 / 시각 / 기간 / 시간대 의미에 맞는 `java.time` 타입으로 표현하는 것입니다.
## 좋은 예시 1: event timestamp는 Instant
```java
public record AuditEvent(
String action,
Long userId,
Instant occurredAt
) {
}
```
왜 좋은가:
- timeline 위 한 점을 명확하게 표현한다.
- 로깅, 저장, 비교에 적합하다.
## 좋은 예시 2: 사람 기준 날짜 의미는 LocalDate / YearMonth
```java
public record UserProfile(
LocalDate birthDate,
YearMonth cardExpiry
) {
}
```
왜 좋은가:
- 시간대와 무관한 사람 기준 날짜 의미를 타입으로 드러낸다.
- 생일과 카드 만료월처럼 서로 다른 날짜 의미를 구분한다.
## 좋은 예시 3: 현재 시각은 Clock 기반
```java
public class TokenIssuer {
private final Clock clock;
public TokenIssuer(Clock clock) {
this.clock = clock;
}
public Instant issueTime() {
return Instant.now(clock);
}
public Instant expiryTime(Duration ttl) {
return Instant.now(clock).plus(ttl);
}
}
```
왜 좋은가:
- 현재 시각을 고정해 테스트하기 쉽다.
- static `now()` 호출이 코드 곳곳에 흩어지지 않는다.
## 좋은 예시 4: 시간 간격은 Duration
```java
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(30);
```
왜 좋은가:
- `1800` 같은 매직 숫자보다 의미가 분명하다.
- 초, 밀리초, 분 단위 혼동이 줄어든다.
## 좋은 예시 5: 외부 응답 포맷은 경계에서 처리
```java
String value = DateTimeFormatter.ISO_INSTANT.format(event.occurredAt());
```
왜 좋은가:
- 내부 로직은 `Instant`를 유지한다.
- 문자열 포맷은 serialization, logging, external API adapter 같은 boundary에서만 수행한다.
## 좋은 예시 6: 실제 zone 계산이 필요할 때만 ZonedDateTime
```java
ZonedDateTime reservationTime = localReservationTime.atZone(ZoneId.of("Asia/Seoul"));
```
왜 좋은가:
- 서울 지역 wall-clock 시간이라는 의미가 필요할 때만 zone을 붙인다.
- 시간대 규칙이 필요한 계산임을 코드에 드러낸다.
## 나쁜 예시 1: createdAt을 LocalDateTime으로 저장
```java
private LocalDateTime createdAt;
```
문제:
- 절대 시점이 아니라 zone/offset 없는 wall-clock 값이 된다.
- 시스템 간 교환, 저장, 비교에서 의미가 흔들린다.
개선:
```java
private Instant createdAt;
```
## 나쁜 예시 2: business logic에서 기본 시스템 zone 의존
```java
LocalDate today = LocalDate.now();
```
문제:
- JVM 기본 time-zone에 암묵적으로 의존한다.
- 테스트와 운영 환경에 따라 결과가 달라질 수 있다.
개선:
```java
LocalDate today = LocalDate.now(clock);
```
또는:
```java
LocalDate today = LocalDate.now(zoneId);
```
## 나쁜 예시 3: 문자열로 시간 비교
```java
if (request.startTime().compareTo("09:00") >= 0) {
// open
}
```
문제:
- 타입 의미가 사라진다.
- 포맷 변화에 취약하다.
개선:
```java
if (!request.startTime().isBefore(LocalTime.of(9, 0))) {
// open
}
```
## 나쁜 예시 4: legacy API 사용
```java
Date now = new Date();
Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + 1_800_000);
```
문제:
- 새 코드 기준으로 `java.time`보다 의미가 덜 명확하다.
- 시간 단위와 시스템 clock 의존이 코드에 흩어진다.
개선:
```java
Instant now = Instant.now(clock);
Instant expiresAt = now.plus(Duration.ofMinutes(30));
```
## 나쁜 예시 5: wall-clock 의미인데 Instant 남용
```java
public record StoreHours(
Instant opensAt,
Instant closesAt
) {
}
```
문제:
- 영업 시작/종료는 보통 지역 wall-clock 의미다.
- 절대 시점 타입이 도메인 의미를 흐린다.
개선:
```java
public record StoreHours(
LocalTime opensAt,
LocalTime closesAt
) {
}
```
## 나쁜 예시 6: Instant를 DTO에서 문자열로 직접 조립
```java
public record TokenResponse(
String expiresAt
) {
public static TokenResponse from(Instant expiresAt) {
return new TokenResponse(expiresAt.toString());
}
}
```
문제:
- DTO 조립 코드가 시간 포맷 정책을 직접 가진다.
- 응답 포맷 변경이 여러 DTO 생성 코드로 퍼질 수 있다.
개선 방향:
- response serialization 설정이나 전용 formatter 경계에서 포맷한다.
- 내부 모델과 유스케이스 결과는 `Instant` 같은 typed value를 유지한다.
@@ -0,0 +1,148 @@
# Exception Log 예시
## 좋은 예시
### 예시 1. 대표 실패만 ERROR로 남긴다
```java
@RestControllerAdvice
public class ApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(ExternalAuthUnavailableException.class)
public ResponseEntity<ApiResult<Void>> handleExternalAuthUnavailable(
ExternalAuthUnavailableException ex,
HttpServletRequest request
) {
log.error("Failed request. requestPath={} method={} errorCode={} actorId={}",
request.getRequestURI(),
request.getMethod(),
ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE.code(),
request.getAttribute("actorId"),
ex);
ErrorCode errorCode = ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE;
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(errorCode));
}
}
```
**좋은 이유:**
- 대표 ERROR 로그가 한 곳에 모인다
- 메시지와 응답 코드가 분리된다
- 운영 키와 stack trace가 함께 남는다
### 예시 2. 재시도 중간 실패는 WARN 또는 DEBUG로만 남긴다
```java
try {
return externalAuthClient.issueToken(command);
} catch (SocketTimeoutException ex) {
log.warn("External auth attempt failed. provider={} actorId={} attempt={}",
"keycloak", command.actorId(), attemptNumber);
throw ex;
}
```
**좋은 이유:**
- 중간 실패를 곧바로 대표 장애처럼 기록하지 않는다
- 재시도 맥락이 드러난다
### 예시 3. validation 실패는 ERROR로 과장하지 않는다
```java
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
MethodArgumentNotValidException ex,
HttpServletRequest request
) {
log.info("Rejected invalid request. requestPath={} method={} actorId={}",
request.getRequestURI(),
request.getMethod(),
request.getAttribute("actorId"));
Map<String, String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toUnmodifiableMap(
FieldError::getField,
DefaultMessageSourceResolvable::getDefaultMessage,
(first, second) -> first
));
return ResponseEntity.badRequest()
.body(ApiResult.fail(ErrorCode.REQUEST_VALIDATION_FAILED, errors));
}
```
**좋은 이유:**
- 예상 가능한 4xx를 서버 장애처럼 기록하지 않는다
- 필요한 요청 맥락은 남긴다
### 예시 4. 메시지는 사건 설명 중심으로 쓴다
```java
log.error("Failed external auth request. provider={} requestPath={} actorId={} durationMs={}",
provider, requestPath, actorId, durationMs, ex);
```
**좋은 이유:**
- 무엇이 실패했는지 바로 보인다
- 예외 메시지 품질에 로그 제목이 종속되지 않는다
## 나쁜 예시
### 예시 1. 같은 예외를 여러 레이어에서 반복 ERROR로 찍는다
```java
log.error("Client failed", ex);
log.error("Service failed", ex);
log.error("Controller failed", ex);
```
**나쁜 이유:**
- 한 실패가 여러 번 기록된다
- 검색/알림/집계 품질이 나빠진다
### 예시 2. 예외 메시지를 그대로 제목으로 쓴다
```java
log.error(ex.getMessage(), ex);
```
**나쁜 이유:**
- 사건 맥락이 없다
- 민감정보가 메시지에 섞일 수 있다
- 운영 키가 없다
### 예시 3. 요청 본문 전체를 예외 로그에 남긴다
```java
log.error("Failed create user request. requestBody={}", requestBody, ex);
```
**나쁜 이유:**
- PII/비밀번호/토큰이 유출될 수 있다
- payload 전문 로그는 기본 금지다
### 예시 4. 예상 가능한 business rejection을 ERROR로 남긴다
```java
log.error("Duplicate email sign-up attempt. email={}", request.email());
```
**나쁜 이유:**
- 서버 장애처럼 과장된다
- 개인식별정보 전체값이 그대로 남는다
- 운영 신호를 오염시킨다
+82
View File
@@ -0,0 +1,82 @@
# Log Level 예시
## 좋은 예시
### 예시 1. 최종 실패만 ERROR로 남긴다
```java
try {
externalAuthClient.issueToken(command);
} catch (ExternalAuthException ex) {
log.error("Failed to issue external auth token. provider={}, actorId={}", "auth-provider", command.actorId(), ex);
throw ex;
}
```
**좋은 이유:**
- 최종 실패를 명확히 드러낸다
- 운영자가 바로 봐야 할 사건이다
### 예시 2. 재시도 후 성공은 WARN으로 남긴다
```java
log.warn("External auth request succeeded after retry. provider={}, actorId={}, attempts={}",
"auth-provider", actorId, attemptCount);
```
**좋은 이유:**
- 즉시 실패는 아니지만 이상 징후다
- 운영 추적 가치가 있다
### 예시 3. 상세 분기 정보는 DEBUG에 둔다
```java
log.debug("Mapped external auth response to internal token result. provider={}, tokenType={}",
"auth-provider", response.tokenType());
```
**좋은 이유:**
- 상세 흐름 파악용이다
- 기본 운영 레벨에서는 숨겨진다
## 나쁜 예시
### 예시 1. 예상 가능한 비즈니스 거절을 ERROR로 남긴다
```java
log.error("Duplicate email sign-up attempt. email={}", request.email());
```
**나쁜 이유:**
- 서버 장애처럼 과장된다
- 실제 운영 신호가 묻힌다
### 예시 2. 같은 예외를 여러 계층에서 모두 ERROR로 찍는다
```java
log.error("Repository failed", ex);
log.error("Service failed", ex);
log.error("Controller failed", ex);
```
**나쁜 이유:**
- 한 실패가 세 번 기록된다
- 검색/알림/분석 품질이 떨어진다
### 예시 3. production 상시 로그에 과도한 상세를 남긴다
```java
log.info("Request payload={}", requestBody);
log.info("Response payload={}", responseBody);
```
**나쁜 이유:**
- 노이즈가 많다
- 민감정보 노출 위험이 크다
- INFO 레벨 의미를 무너뜨린다
@@ -0,0 +1,102 @@
# Log Message Format 예시
## 좋은 예시
### 예시 1. 사건 설명 + key-value 필드를 함께 남긴다
```java
log.info("Created user. actorId={} userId={} requestPath={}",
actorId, userId, requestPath);
```
**좋은 이유:**
- 사건이 짧게 드러난다
- 검색 가능한 핵심 키가 있다
- free text만으로 끝나지 않는다
### 예시 2. 실패 메시지에 운영 키를 먼저 담고 예외를 붙인다
```java
log.error("Failed external auth request. provider={} actorId={} durationMs={}",
provider, actorId, durationMs, ex);
```
**좋은 이유:**
- 메시지 자체만 봐도 무엇이 실패했는지 알 수 있다
- stack trace는 추가 정보로 붙는다
- 운영 키가 빠지지 않는다
### 예시 3. 재시도/대체 경로도 명시적 사건으로 남긴다
```java
log.warn("Applied external auth fallback. provider={} actorId={} fallback={} durationMs={}",
provider, actorId, "cached-public-key", durationMs);
```
**좋은 이유:**
- fallback 발생 사실이 바로 드러난다
- 이후 검색과 집계가 쉽다
### 예시 4. structured logging 전환을 고려한 키 이름을 쓴다
```java
log.info("Completed session cleanup. job={} deletedCount={} durationMs={}",
"expired-session-cleanup", deletedCount, durationMs);
```
**좋은 이유:**
- 텍스트 로그에서도 구조가 보인다
- JSON 로그로 전환해도 의미가 유지된다
## 나쁜 예시
### 예시 1. 설명만 길고 검색 키가 없다
```java
log.info("The user registration process was completed successfully after all checks had been passed");
```
**나쁜 이유:**
- 누가, 어떤 요청에서, 어떤 리소스가 생성됐는지 알 수 없다
- 검색/집계가 어렵다
### 예시 2. 예외 메시지를 그대로 제목으로 쓴다
```java
log.error(ex.getMessage(), ex);
```
**나쁜 이유:**
- 사건 맥락이 없다
- 운영 키가 없다
- 예외 메시지 품질에 로그 제목이 종속된다
### 예시 3. 민감정보를 그대로 남긴다
```java
log.debug("Login request. email={} password={} accessToken={}",
request.email(), request.password(), accessToken);
```
**나쁜 이유:**
- 민감정보가 원문으로 노출된다
- 디버그 로그라도 허용되지 않는다
### 예시 4. 같은 의미를 제각각 다른 키 이름으로 쓴다
```java
log.info("Created user. uid={} path={} timeMs={}", userId, requestPath, durationMs);
log.info("Deleted user. userId={} requestUri={} duration={}", userId, requestPath, durationMs);
```
**나쁜 이유:**
- 같은 의미의 키 이름이 섞인다
- 검색/집계/알람 규칙이 복잡해진다
@@ -0,0 +1,150 @@
# Operation Indicator / Health Check 예시
## 좋은 예시
### 예시 1. 기본 actuator health와 probe 경로를 그대로 사용한다
```yaml
management:
endpoint:
health:
probes:
enabled: true
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8081
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8081
```
**좋은 이유:**
- Spring Boot 기본 probe group을 그대로 사용한다
- Kubernetes가 기대하는 liveness/readiness 의미와 맞다.
### 예시 2. management 포트 분리 시 main port에도 /livez, /readyz를 노출한다
```yaml
management:
server:
port: 8081
endpoint:
health:
probes:
add-additional-paths: true
```
**좋은 이유:**
- actuator 전용 포트만 살아 있고 실제 애플리케이션 포트는 문제인 상황을 줄일 수 있다
- Spring Boot도 이 구성을 좋은 아이디어로 안내한다.
### 예시 3. readiness에만 필수 내부 준비 상태를 추가한다
```yaml
management:
endpoint:
health:
group:
readiness:
include: "readinessState,customCheck"
```
**좋은 이유:**
- readiness에 필요한 추가 체크만 명시적으로 포함한다
- liveness와 readiness를 구분해서 설계한다.
### 예시 4. startup이 긴 서비스에는 startup probe를 둔다
```yaml
startupProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
failureThreshold: 30
periodSeconds: 10
```
**좋은 이유:**
- 느린 시작 중 liveness 오탐을 줄일 수 있다
- startup probe는 성공 전까지 liveness/readiness 실행을 지연시킨다.
## 나쁜 예시
### 예시 1. liveness에 DB 상태를 직접 넣는다
```java
@Component
public class BadDatabaseLivenessIndicator implements HealthIndicator {
@Override
public Health health() {
return databaseClient.ping() ? Health.up().build() : Health.down().build();
}
}
```
**나쁜 이유:**
- 외부 DB 장애가 모든 인스턴스 재시작으로 이어질 수 있다
- Spring Boot는 liveness를 외부 체크 기반으로 두지 말라고 권고한다.
### 예시 2. 모든 외부 시스템을 readiness에 무조건 포함한다
```yaml
management:
endpoint:
health:
group:
readiness:
include: "readinessState,db,redis,kafka,s3,externalApiA,externalApiB"
```
**나쁜 이유:**
- 공유 외부 시스템 장애 시 전체 인스턴스가 동시에 ready=false가 될 수 있다
- fallback 가능한 비필수 시스템도 서비스 제외 원인이 된다.
### 예시 3. probe 용 controller를 별도로 만든다
```java
@RestController
public class BadHealthController {
@GetMapping("/health")
public Map<String, Object> health() {
return Map.of("status", "UP");
}
}
```
**나쁜 이유:**
- actuator가 이미 제공하는 운영 계약과 분리된다
- liveness/readiness/group 정책과 연계되지 않는다
- health semantics를 임의 JSON으로 약화시킨다.
### 예시 4. health indicator에서 무거운 쿼리를 수행한다
```java
@Component
public class BadSlowHealthIndicator implements HealthIndicator {
@Override
public Health health() {
analyticsRepository.runExpensiveAggregation();
return Health.up().build();
}
}
```
**나쁜 이유:**
- health endpoint 자체가 느려진다
- Spring Boot도 느린 indicator를 warning 대상으로 본다.
+122
View File
@@ -0,0 +1,122 @@
# PII Masking 예시
## 좋은 예시
### 예시 1. 내부 식별자만 로그에 남긴다
```java
log.info("Completed password reset request. actorId={} requestPath={}",
actorId, requestPath);
```
**좋은 이유:**
- 누구의 요청인지는 추적 가능하다
- 이메일/전화번호/비밀번호는 남기지 않는다
### 예시 2. 토큰은 일부만 식별 가능하게 남긴다
```java
String maskedToken = TokenMasker.prefix(token);
log.warn("Rejected external callback due to invalid token. provider={} tokenPrefix={}",
provider, maskedToken);
```
**좋은 이유:**
- 토큰 전체 원문을 남기지 않는다
- 운영상 일부 식별은 가능하다
### 예시 3. 이메일은 부분 마스킹한다
```java
String maskedEmail = EmailMasker.mask(request.email());
log.info("Started email verification. actorId={} email={}",
actorId, maskedEmail);
```
**좋은 이유:**
- 메일 발송 대상 추적은 가능하다
- 개인 식별정보 전체값을 남기지 않는다
### 예시 4. structured logging에도 안전한 필드만 넣는다
```java
MDC.put("actorId", actorId);
MDC.put("requestPath", request.getRequestURI());
```
**좋은 이유:**
- 운영 상관관계 필드는 남긴다
- token/session/password 같은 값은 MDC에 올리지 않는다
### 예시 5. 외부 오류 메시지는 정제해서 남긴다
```java
log.error("Failed external auth request. provider={} status={} errorCode={}",
provider, status, "UPSTREAM_AUTH_SERVER_UNAVAILABLE", ex);
```
**좋은 이유:**
- 외부 시스템 에러 본문 원문을 그대로 노출하지 않는다
- 운영 키와 표준 에러 코드 중심으로 남긴다
## 나쁜 예시
### 예시 1. Authorization 헤더를 그대로 남긴다
```java
log.debug("Incoming request. authorization={}", request.getHeader("Authorization"));
```
**나쁜 이유:**
- access token 원문이 로그로 유출된다
- 디버그 로그라도 허용되지 않는다
### 예시 2. request body 전체를 남긴다
```java
log.info("Create user request body={}", requestBody);
```
**나쁜 이유:**
- 비밀번호, 이메일, 전화번호 등 민감값이 함께 들어갈 수 있다
- 운영 로그 노이즈도 크다
### 예시 3. 세션 ID를 그대로 남긴다
```java
log.warn("Invalid session. sessionId={}", sessionId);
```
**나쁜 이유:**
- 세션 식별값 원문이 노출된다
- OWASP도 세션 식별값은 직접 로그에 남기지 말라고 권고한다.
### 예시 4. 예외 메시지를 그대로 제목으로 쓴다
```java
log.error(ex.getMessage(), ex);
```
**나쁜 이유:**
- 예외 메시지 안 민감정보가 그대로 노출될 수 있다
- 사건 설명과 안전한 운영 키가 없다
### 예시 5. 외부 입력을 정제 없이 로그에 넣는다
```java
log.warn("Rejected request. keyword={}", request.getParameter("keyword"));
```
**나쁜 이유:**
- 줄바꿈/제어문자/악성 문자열이 로그 형식을 깨뜨릴 수 있다
- 민감 검색어가 그대로 남을 수 있다
@@ -0,0 +1,161 @@
# Trace / Principal / Path Recording 예시
## 좋은 예시
### 예시 1. controller는 현재 사용자 식별자를 명시적으로 받는다
```java
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal(expression = "userId")
public @interface CurrentUserId {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserQueryController {
private final UserQueryUseCase userQueryUseCase;
@GetMapping("/{userId}")
public ApiResult<UserResponse> getUser(
@CurrentUserId String actorId,
@PathVariable String userId
) {
UserResult result = userQueryUseCase.getUser(actorId, userId);
return ApiResult.success(new UserResponse(result.userId(), result.email()));
}
}
```
**좋은 이유:**
- principal 접근이 controller 시그니처에서 드러난다
- SecurityContextHolder 직접 접근이 없다
- application에는 최소 actor 정보만 전달한다
### 예시 2. 공통 요청 로그는 한 곳에서 남긴다
```java
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
long startNanos = System.nanoTime();
try {
filterChain.doFilter(request, response);
} finally {
long durationMs = Duration.ofNanos(System.nanoTime() - startNanos).toMillis();
log.info("Completed request. requestPath={} method={} status={} durationMs={}",
request.getRequestURI(),
request.getMethod(),
response.getStatus(),
durationMs);
}
}
}
```
**좋은 이유:**
- 대표 요청 로그가 공통 위치에 있다
- requestPath와 method/status/duration이 일관되게 남는다
- controller마다 요청 로그를 복붙하지 않는다
### 예시 3. principal은 내부 식별자만 남긴다
```java
log.warn("Rejected request. requestPath={} actorId={} errorCode={}",
requestPath, actorId, "ACCESS_DENIED");
```
**좋은 이유:**
- 이메일/토큰 같은 민감정보 대신 내부 식별자를 남긴다
- principal 검색 가능성과 개인정보 보호를 함께 고려한다
### 예시 4. route template를 별도 필드로 둘 수 있다
```java
log.info("Completed request. requestPath={} route={} method={} status={} durationMs={}",
"/api/v1/users/123",
"/api/v1/users/{userId}",
"GET",
200,
21);
```
**좋은 이유:**
- 실제 요청과 집계용 route를 분리할 수 있다
- 고카디널리티 문제를 운영에서 다루기 쉬워진다
## 나쁜 예시
### 예시 1. controller가 SecurityContextHolder를 직접 읽는다
```java
@GetMapping("/api/v1/me")
public ApiResult<String> me() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
log.info("Current request. path={} principal={}", request.getRequestURI(), principal);
return ApiResult.success(principal.getUserId());
}
```
**나쁜 이유:**
- principal 접근과 로깅 규칙이 controller에 퍼진다
- principal 전체 객체가 로그에 노출될 수 있다
- current user 접근 방식이 일관되지 않다
### 예시 2. query string 전체를 기본 로그에 남긴다
```java
log.info("Incoming request. requestPath={} query={}",
request.getRequestURI(),
request.getQueryString());
```
**나쁜 이유:**
- 검색어, 토큰, 식별자 등 민감정보가 섞일 수 있다
- 운영 로그에 노이즈가 많아진다
### 예시 3. 같은 의미를 여러 키 이름으로 섞는다
```java
log.info("Completed request. uri={} userId={}", requestPath, actorId);
log.info("Failed request. path={} principalId={}", requestPath, actorId);
```
**나쁜 이유:**
- uri/path/requestPath, userId/actorId/principalId가 혼용된다
- 검색/집계 규칙이 깨진다
### 예시 4. 요청 로그를 여러 레이어에서 반복한다
```java
log.info("Controller request. requestPath={}", requestPath);
log.info("Service request. requestPath={}", requestPath);
log.info("Client request. requestPath={}", requestPath);
```
**나쁜 이유:**
- 대표 요청 로그가 중복된다
- 실제 중요한 비즈니스/연동 로그가 묻힌다
+171
View File
@@ -0,0 +1,171 @@
# AOP 예시
## 좋은 예시 1: 실행 시간 측정
```java
@Aspect
@Component
public class TimingAspect {
@Pointcut("execution(public * com.project.auth.application..*(..))")
public void applicationOperation() {}
@Around("applicationOperation()")
public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
try {
return joinPoint.proceed();
} finally {
long elapsed = System.nanoTime() - start;
log.info("method={} elapsedNanos={}", joinPoint.getSignature(), elapsed);
}
}
}
```
**왜 좋은가:**
- 횡단 관심사인 timing만 다룬다
- 비즈니스 로직을 바꾸지 않는다
- pointcut이 이름 있는 작은 단위다
## 좋은 예시 2: 예외 기록
```java
@Aspect
@Component
public class ExceptionLoggingAspect {
@Pointcut("execution(public * com.project.auth.application..*(..))")
public void applicationOperation() {}
@AfterThrowing(pointcut = "applicationOperation()", throwing = "exception")
public void logFailure(Exception exception) {
log.error("application failure", exception);
}
}
```
**왜 좋은가:**
- 예외를 숨기지 않고 기록만 한다
- 비즈니스 의미를 변경하지 않는다
## 좋은 예시 3: annotation 기반 감사
```java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String action();
}
@Aspect
@Component
public class AuditAspect {
@Around("@annotation(audited)")
public Object audit(ProceedingJoinPoint joinPoint, Audited audited) throws Throwable {
Object result = joinPoint.proceed();
auditLog.record(audited.action(), joinPoint.getSignature().toShortString());
return result;
}
}
```
**왜 좋은가:**
- annotation으로 의도를 드러낸다
- 횡단 concern만 수행한다
- service 흐름을 숨기지 않는다
## 좋은 예시 4: named pointcut 조합
```java
@Aspect
@Component
public class CommonPointcuts {
@Pointcut("execution(public * *(..))")
public void publicMethod() {}
@Pointcut("within(com.project.auth.application..*)")
public void inApplicationLayer() {}
@Pointcut("publicMethod() && inApplicationLayer()")
public void applicationPublicOperation() {}
}
```
**왜 좋은가:**
- 작은 pointcut을 조합한다
- 범위를 읽고 설명하기 쉽다
## 나쁜 예시 1: 핵심 비즈니스 로직을 AOP로 이동
```java
@Around("execution(* ..LoginService.login(..))")
public Object issueTokenAndSaveAudit(ProceedingJoinPoint joinPoint) throws Throwable {
...
}
```
**문제:**
- 핵심 use case 흐름이 숨는다
- 코드 추적이 어려워진다
- 서비스가 해야 할 결정을 aspect가 가져간다
## 나쁜 예시 2: self-invocation 기대
```java
@Service
public class SampleService {
public void foo() {
this.bar(); // aspect 기대
}
public void bar() {
...
}
}
```
**문제:**
- proxy를 통과하지 않아 advice가 적용되지 않을 수 있다
## 나쁜 예시 3: 너무 넓은 pointcut
```java
@Before("execution(* *(..))")
public void logEverything() {
...
}
```
**문제:**
- 범위가 지나치게 넓다
- 성능/디버깅/예측 가능성 모두 나빠질 수 있다
- 어떤 코드가 영향을 받는지 설명하기 어렵다
## 나쁜 예시 4: @Around로 예외 숨김
```java
@Around("execution(* ..*(..))")
public Object swallow(ProceedingJoinPoint joinPoint) {
try {
return joinPoint.proceed();
} catch (Throwable ex) {
return null;
}
}
```
**문제:**
- 실패를 정상값처럼 숨긴다
- 디버깅과 계약을 깨뜨린다
+175
View File
@@ -0,0 +1,175 @@
# ApplicationEvent 예시
## 좋은 예시 1: 핵심 작업 후 후속 반응 분리
```java
public record UserRegisteredEvent(
Long userId,
String email,
Instant occurredAt
) {}
@Service
public class RegisterUserService {
private final ApplicationEventPublisher eventPublisher;
private final Clock clock;
public RegisterUserService(ApplicationEventPublisher eventPublisher, Clock clock) {
this.eventPublisher = eventPublisher;
this.clock = clock;
}
@Transactional
public Long register(CreateUserCommand command) {
User user = ...;
userRepository.save(user);
eventPublisher.publishEvent(
new UserRegisteredEvent(user.getId(), user.getEmail(), Instant.now(clock))
);
return user.getId();
}
}
```
**왜 좋은가:**
- 핵심 등록 작업과 후속 반응을 분리한다
- 이벤트 payload가 필요한 상태를 직접 담는다
- publisher가 listener 구현을 모른다
## 좋은 예시 2: commit 후에만 처리
```java
@Component
public class UserRegisteredAuditListener {
@TransactionalEventListener
public void handle(UserRegisteredEvent event) {
auditLog.record("USER_REGISTERED", event.userId(), event.occurredAt());
}
}
```
**왜 좋은가:**
- 기본 AFTER_COMMIT 의미를 활용한다
- rollback된 작업에 대해 잘못된 후속 기록을 남기지 않는다
## 좋은 예시 3: listener는 짧고 부가적
```java
@Component
public class WelcomeMetricListener {
@EventListener
public void handle(UserRegisteredEvent event) {
metrics.counter("user.registered").increment();
}
}
```
**왜 좋은가:**
- 짧고 효율적이다
- 핵심 비즈니스 흐름을 숨기지 않는다
## 좋은 예시 4: 테스트에서 이벤트 검증
```java
@RecordApplicationEvents
@SpringBootTest
class RegisterUserServiceTest {
@Test
void publishes_user_registered_event(ApplicationEvents events) {
service.register(command);
assertThat(events.stream(UserRegisteredEvent.class)).hasSize(1);
}
}
```
**왜 좋은가:**
- 이벤트 발행 사실을 테스트로 확인할 수 있다
- “어딘가에서 되겠지” 상태를 줄인다
## 나쁜 예시 1: 핵심 오케스트레이션을 이벤트에 숨김
```java
@Service
public class LoginService {
public LoginResponse login(LoginCommand command) {
eventPublisher.publishEvent(new LoginRequestedEvent(command));
return LoginResponse.pending();
}
}
```
**문제:**
- 핵심 로그인 흐름이 listener들 뒤로 숨어버린다
- 메인 결과가 이벤트 체인에 의존하게 된다
## 나쁜 예시 2: payload가 너무 빈약함
```java
public record UserRegisteredEvent(Long userId) {}
```
**문제:**
- 모든 listener가 다시 조회를 강요받을 수 있다
- 필요한 최소 상태가 누락되면 결합과 조회 비용이 커진다
**개선:**
- 정말 필요한 상태를 payload에 포함
## 나쁜 예시 3: 무거운 작업을 listener에 직접 넣음
```java
@Component
public class HeavyListener {
@EventListener
public void handle(UserRegisteredEvent event) {
externalApi.call(...);
fileExporter.export(...);
Thread.sleep(5000);
}
}
```
**문제:**
- listener가 너무 무겁고 느리다
- event hand-off의 장점을 해친다
- 장애 반경이 커진다
## 나쁜 예시 4: listener 순서에 핵심 의존
```java
@Component
class FirstListener {
@Order(1)
@EventListener
void handle(UserRegisteredEvent event) { ... }
}
@Component
class SecondListener {
@Order(2)
@EventListener
void handle(UserRegisteredEvent event) { ... } // 첫 번째가 반드시 먼저 돌 것을 기대
}
```
**문제:**
- 이벤트 기반 구조가 사실상 숨은 절차형 흐름이 된다
- 순서 의존이 커질수록 명시적 호출이 더 낫다
@@ -0,0 +1,199 @@
# async / scheduler / retry 예시
## 좋은 예시
### 예시 1. 스케줄 트리거는 얇게 두고, 재시도는 외부 경계에 둔다
```java
@Component
@RequiredArgsConstructor
public class ExpiredSessionCleanupJob {
private final ExpiredSessionCleanupUseCase expiredSessionCleanupUseCase;
@Scheduled(cron = "${auth.session.cleanup-cron}")
public void run() {
expiredSessionCleanupUseCase.cleanUpExpiredSessions();
}
}
@Service
@RequiredArgsConstructor
public class ExpiredSessionCleanupUseCase {
private final SessionRepository sessionRepository;
private final TokenRevocationGateway tokenRevocationGateway;
private final CleanupAuditAsyncPublisher cleanupAuditAsyncPublisher;
public void cleanUpExpiredSessions() {
List<ExpiredSession> expiredSessions = sessionRepository.findExpiredSessions();
for (ExpiredSession expiredSession : expiredSessions) {
tokenRevocationGateway.revoke(expiredSession.tokenId());
}
cleanupAuditAsyncPublisher.publish(expiredSessions.size());
}
}
@Component
public class TokenRevocationGateway {
@Retryable(
retryFor = {
ResourceAccessException.class,
SocketTimeoutException.class,
ConnectException.class
},
noRetryFor = {
IllegalArgumentException.class
},
maxAttempts = 3,
backoff = @Backoff(delay = 500, maxDelay = 2_000, multiplier = 2.0)
)
public void revoke(String tokenId) {
// 외부 인증/폐기 시스템 호출
}
@Recover
public void recover(Exception ex, String tokenId) {
throw new ExternalDependencyException("Token revocation failed after retries. tokenId=" + tokenId, ex);
}
}
@Component
@RequiredArgsConstructor
public class CleanupAuditAsyncPublisher {
@Async("auditAsyncExecutor")
public CompletableFuture<Void> publish(int cleanedCount) {
// 감사 로그/알림 전송
return CompletableFuture.completedFuture(null);
}
}
```
**좋은 이유:**
- scheduler는 트리거만 담당
- retry는 외부 호출 경계에만 존재
- async는 비핵심 후속 처리로 분리
- 각 책임이 bean 경계로 나뉘어 프록시 적용 여부가 명확함
### 예시 2. executor / scheduler를 명시적으로 분리한다
```java
@Configuration
@EnableAsync
@EnableScheduling
@EnableRetry
public class TaskExecutionConfig implements AsyncConfigurer {
@Bean(name = "auditAsyncExecutor")
public ThreadPoolTaskExecutor auditAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("audit-async-");
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.initialize();
return executor;
}
@Bean(name = "maintenanceTaskScheduler")
public ThreadPoolTaskScheduler maintenanceTaskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setThreadNamePrefix("maintenance-scheduler-");
scheduler.setPoolSize(2);
scheduler.initialize();
return scheduler;
}
@Override
public Executor getAsyncExecutor() {
return auditAsyncExecutor();
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("Async error in method={}, params={}", method.getName(), Arrays.toString(params), ex);
}
}
```
**좋은 이유:**
- async executor와 scheduler를 분리
- thread prefix로 운영 추적 가능
- void @Async 예외를 방치하지 않음
## 나쁜 예시
### 예시 1. 한 메서드에 스케줄/비동기/재시도를 다 겹친다
```java
@Service
public class BadCleanupService {
@Scheduled(fixedRate = 1000)
@Async
@Retryable
public void run() {
// 핵심 업무 + 외부 호출 + 후속 처리까지 한곳에 몰아넣음
}
}
```
**나쁜 이유:**
- 실행 경계가 불명확함
- 실패 전파/관측/재시도 범위가 애매함
- 어떤 책임 때문에 실패했는지 읽기 어려움
- 기본 retry 정책에 의존하기 쉬움
### 예시 2. self-invocation으로 @Async / @Retryable 효과를 기대한다
```java
@Service
@RequiredArgsConstructor
public class BadNotificationService {
public void sendAll(List<String> ids) {
for (String id : ids) {
this.sendOne(id); // 프록시를 거치지 않음
}
}
@Async
public void sendOne(String id) {
// ...
}
}
```
**나쁜 이유:**
- 같은 클래스 내부 호출이라 프록시 적용을 기대하면 안 됨
### 예시 3. retry를 결정적 실패에 건다
```java
@Component
public class BadMapper {
@Retryable(maxAttempts = 5)
public UserId map(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("raw must not be blank");
}
return new UserId(raw);
}
}
```
**나쁜 이유:**
- 입력 검증 실패는 재시도로 해결되지 않음
- retry 대상 예외를 좁히지 않음
- domain/value 생성 로직에 retry를 붙임
+138
View File
@@ -0,0 +1,138 @@
# bean registration 예시
## 좋은 예시 1: application service는 stereotype 등록
```java
@Service
public class RegisterUserService implements RegisterUserUseCase {
...
}
```
**왜 좋은가:**
- 애플리케이션 주 컴포넌트라는 역할이 드러난다
- scanning 기반 등록에 자연스럽다
## 좋은 예시 2: external client는 configuration + bean
```java
@Configuration
public class VaultClientConfiguration {
@Bean
public VaultTransitClient vaultTransitClient(
VaultProperties properties,
ObjectMapper objectMapper
) {
return new VaultTransitClient(
properties.address(),
properties.token(),
HttpClient.newHttpClient(),
objectMapper
);
}
}
```
**왜 좋은가:**
- 외부 라이브러리/인프라 객체 조립이 한 곳에 모인다
- 생성 로직이 명시적이다
## 좋은 예시 3: security/filter wiring은 configuration에 둠
```java
@Configuration
public class WebConfiguration {
@Bean
public FilterRegistrationBean<TraceIdFilter> traceIdFilter() {
FilterRegistrationBean<TraceIdFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new TraceIdFilter());
return registration;
}
}
```
**왜 좋은가:**
- framework wiring 성격이 분명하다
- business component와 분리된다
## 좋은 예시 4: domain object는 bean으로 등록하지 않음
```java
public record UserEmail(String value) {}
```
**왜 좋은가:**
- value object는 container 관리 대상이 아니다
- 생성/검증 책임은 domain에 남는다
## 나쁜 예시 1: domain entity를 bean으로 등록
```java
@Component
public class User {
...
}
```
**문제:**
- domain object 생명주기를 container가 소유하게 된다
- 의미가 맞지 않는다
## 나쁜 예시 2: @Component 안에 습관적 @Bean
```java
@Component
public class UserFactoryComponent {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
```
**문제:**
- full @Configuration 대신 lite mode가 된다
- configuration 역할과 component 역할이 섞인다
**개선:**
- 별도 @Configuration 클래스로 이동
## 나쁜 예시 3: 의미 없는 잡다한 config
```java
@Configuration
public class CommonConfig {
@Bean ...
@Bean ...
@Bean ...
}
```
**문제:**
- 어떤 조립을 담당하는지 이름만 보고 알기 어렵다
- 변경 이유가 다른 bean이 섞이기 쉽다
## 나쁜 예시 4: 단순 helper까지 bean으로 올림
```java
@Component
public class StringMaskingHelper {
public String mask(String input) { ... }
}
```
**문제:**
- lifecycle/DI 이득이 작다
- plain helper로 둘 수 있다면 굳이 bean일 필요가 없다
@@ -0,0 +1,150 @@
# @ConfigurationProperties 예시
## 좋은 예시 1: 의미 있는 설정 집합을 타입으로 묶음
```java
@ConfigurationProperties(prefix = "auth.jwt")
@Validated
public record JwtProperties(
@NotNull Duration accessTokenTtl,
@NotNull Duration refreshTokenTtl,
@NotBlank String issuer
) {}
```
**왜 좋은가:**
- 관련 설정이 하나의 계약으로 묶인다
- 타입 안전성과 검증이 있다
- scattered @Value를 줄인다
## 좋은 예시 2: configuration properties scanning 사용
```java
@SpringBootApplication
@ConfigurationPropertiesScan
public class AuthApplication {
}
```
**왜 좋은가:**
- 애플리케이션 내부 properties 타입을 명시적으로 스캔한다
- @Component에 기대지 않는다
## 좋은 예시 3: 조건부/명시 등록은 EnableConfigurationProperties
```java
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(VaultProperties.class)
public class VaultConfiguration {
}
```
**왜 좋은가:**
- 어떤 설정 타입을 활성화하는지 명확하다
- auto-configuration/조건부 wiring에 잘 맞는다
## 좋은 예시 4: third-party bean에 바인딩
```java
@Configuration(proxyBeanMethods = false)
public class ClientConfiguration {
@Bean
@ConfigurationProperties("app.http.client")
public HttpClientProperties httpClientProperties() {
return new HttpClientProperties();
}
}
```
**왜 좋은가:**
- 외부 타입/서드파티 설정을 명시적 config 안에 가둔다
- prefix와 등록 위치가 분명하다
## 나쁜 예시 1: 산발적 @Value 남발
```java
@Service
public class JwtIssuer {
@Value("${auth.jwt.access-token-ttl}")
private Duration accessTokenTtl;
@Value("${auth.jwt.refresh-token-ttl}")
private Duration refreshTokenTtl;
@Value("${auth.jwt.issuer}")
private String issuer;
}
```
**문제:**
- 관련 설정이 흩어진다
- 타입 집합과 검증이 약해진다
- 재사용/문서화가 어려워진다
**개선:**
- JwtProperties로 묶어서 주입
## 나쁜 예시 2: Optional 필드 사용
```java
@ConfigurationProperties("vault")
public record VaultProperties(
Optional<String> namespace
) {}
```
**문제:**
- Spring Boot 공식 문서가 권장하지 않는다
- 값이 없으면 empty Optional이 아니라 null이 바인딩될 수 있다
**개선:**
- nullable String
- 명시적 기본값
- 별도 default 처리
## 나쁜 예시 3: properties class에 business logic 포함
```java
@ConfigurationProperties("auth.jwt")
public class JwtProperties {
private Duration accessTokenTtl;
public String issueToken(User user) {
...
}
}
```
**문제:**
- 설정 계약과 비즈니스 로직이 섞인다
- 테스트/책임 분리가 흐려진다
## 나쁜 예시 4: CommonProperties dump zone
```java
@ConfigurationProperties("app")
public class AppProperties {
private String jwtIssuer;
private Duration retryDelay;
private String vaultAddress;
private String mailFrom;
}
```
**문제:**
- 소유 기능이 다 다르다
- prefix와 책임이 너무 넓다
- 기능별 변경이 서로 얽힌다
@@ -0,0 +1,165 @@
# dependency injection 예시
## 좋은 예시 1: 필수 의존성은 생성자 주입
```java
@Service
public class RegisterUserService implements RegisterUserUseCase {
private final UserReader userReader;
private final UserAppender userAppender;
private final PasswordHasher passwordHasher;
public RegisterUserService(
UserReader userReader,
UserAppender userAppender,
PasswordHasher passwordHasher
) {
this.userReader = userReader;
this.userAppender = userAppender;
this.passwordHasher = passwordHasher;
}
}
```
**왜 좋은가:**
- 필수 의존성이 시그니처에 드러난다
- final field를 사용할 수 있다
- 객체가 완전한 상태로 생성된다
## 좋은 예시 2: single constructor면 @Autowired 생략
```java
@Component
public class JwtTokenIssuer {
private final Clock clock;
public JwtTokenIssuer(Clock clock) {
this.clock = clock;
}
}
```
**왜 좋은가:**
- Spring은 단일 생성자를 자동으로 사용할 수 있다
- annotation noise를 줄인다
## 좋은 예시 3: 선택 의존성은 setter/config method 검토
```java
@Component
public class AuditClient {
private RetryTemplate retryTemplate = RetryTemplate.defaultInstance();
@Autowired(required = false)
public void setRetryTemplate(RetryTemplate retryTemplate) {
this.retryTemplate = retryTemplate;
}
}
```
**왜 좋은가:**
- 선택 의존성이라는 점이 드러난다
- reasonable default가 있다
## 좋은 예시 4: 다중 구현은 qualifier로 명시
```java
@Service
public class OAuthLoginService {
private final OAuthClient googleOAuthClient;
public OAuthLoginService(@Qualifier("googleOAuthClient") OAuthClient googleOAuthClient) {
this.googleOAuthClient = googleOAuthClient;
}
}
```
**왜 좋은가:**
- 여러 구현체 중 무엇을 주입받는지 명확하다
- 우연한 후보 선택에 기대지 않는다
## 나쁜 예시 1: production field injection
```java
@Service
public class RegisterUserService {
@Autowired
private UserReader userReader;
@Autowired
private UserAppender userAppender;
}
```
**문제:**
- 필수 의존성이 시그니처에 안 드러난다
- final field 사용이 어렵다
- plain unit test가 불편하다
## 나쁜 예시 2: service locator 사용
```java
@Service
public class RegisterUserService {
@Autowired
private ApplicationContext applicationContext;
public void register(...) {
UserAppender userAppender = applicationContext.getBean(UserAppender.class);
...
}
}
```
**문제:**
- DI가 아니라 lookup으로 퇴행한다
- 숨은 의존성이 생긴다
## 나쁜 예시 3: 생성자 인자 과다를 setter로 숨김
```java
@Service
public class ComplexService {
@Autowired
public void setA(A a) { ... }
@Autowired
public void setB(B b) { ... }
@Autowired
public void setC(C c) { ... }
@Autowired
public void setD(D d) { ... }
@Autowired
public void setE(E e) { ... }
@Autowired
public void setF(F f) { ... }
}
```
**문제:**
- 책임이 큰 문제를 주입 방식으로 숨긴다
- 객체의 필수/선택 의존성이 흐려진다
**개선:**
- collaborator 분리
- orchestration 재설계
- 설정 묶기 검토
@@ -0,0 +1,495 @@
# Filter / Interceptor / Resolver / Advice 예시
## 좋은 예시
### 예시 1. request/response 수준의 공통 처리만 filter에 둔다
```java
@Component
public class RequestIdFilter extends OncePerRequestFilter {
public static final String REQUEST_ID_ATTRIBUTE = "requestId";
public static final String REQUEST_ID_HEADER = "X-Request-Id";
@Override
protected boolean shouldNotFilterErrorDispatch() {
return false;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String requestId = request.getHeader(REQUEST_ID_HEADER);
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId);
response.setHeader(REQUEST_ID_HEADER, requestId);
filterChain.doFilter(request, response);
}
}
```
**좋은 이유:**
- HTTP request/response concern만 다룬다
- controller 이전에 처리되어도 자연스럽다
- business/service/repository에 의존하지 않는다
### 예시 2. handler 전후의 가벼운 공통 처리는 interceptor에 둔다
```java
@Component
@RequiredArgsConstructor
public class AuditActorInterceptor implements HandlerInterceptor {
private final AuditContextHolder auditContextHolder;
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) {
String actorId = (String) request.getAttribute(RequestAttributes.AUTHENTICATED_ACTOR_ID);
if (actorId != null) {
auditContextHolder.bind(actorId);
}
return true;
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) {
auditContextHolder.clear();
}
}
public final class RequestAttributes {
public static final String AUTHENTICATED_ACTOR_ID = "authenticatedActorId";
private RequestAttributes() {
}
}
```
**좋은 이유:**
- handler 실행 전후의 공통 처리라는 interceptor 책임에 맞는다
- 인증 자체를 구현하지 않고, 인증 이후 컨텍스트 연결만 수행한다
- 핵심 business logic을 수행하지 않는다
### 예시 3. resolver는 낮은 수준의 예외만 제한적으로 변환한다
```java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestBindingExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) throws IOException {
if (!(ex instanceof HttpMessageNotReadableException)) {
return null;
}
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{
"success": false,
"code": "MALFORMED_JSON_REQUEST",
"message": "Malformed request body"
}
""");
return new ModelAndView();
}
}
```
**좋은 이유:**
- resolver를 “전역 business exception 처리기”가 아니라 저수준 예외 처리 지점으로 제한한다
- null 반환으로 다른 예외는 다음 resolver/advice에 넘긴다
- resolver 사용 이유가 명확하다
**주의:**
- 실제 프로젝트에서는 이조차도 가능하면 advice/기본 처리로 흡수할 수 있는지 먼저 검토하는 편이 낫다
- 이 예시는 “resolver가 허용되는 좁은 자리”를 보여주기 위한 예시다
### 예시 4. 에러 정책은 ErrorCode로 중앙 관리한다
```java
public enum ErrorCode {
DOMAIN_RULE_VIOLATION(HttpStatus.CONFLICT, "DOMAIN_RULE_VIOLATION", "Domain rule violation"),
EXTERNAL_DEPENDENCY_FAILURE(HttpStatus.BAD_GATEWAY, "EXTERNAL_DEPENDENCY_FAILURE", "Temporary external dependency failure"),
REQUEST_VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "REQUEST_VALIDATION_FAILED", "Request validation failed");
private final HttpStatus httpStatus;
private final String code;
private final String message;
ErrorCode(HttpStatus httpStatus, String code, String message) {
this.httpStatus = httpStatus;
this.code = code;
this.message = message;
}
public HttpStatus httpStatus() {
return httpStatus;
}
public String code() {
return code;
}
public String message() {
return message;
}
}
```
**좋은 이유:**
- 에러 코드, 메시지, 상태값이 분산되지 않는다
- advice가 문자열 조립 대신 매핑 책임에 집중할 수 있다
### 예시 5. 전역 예외 응답은 @RestControllerAdvice에서 ApiResult로 통일한다
```java
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(DomainRuleViolationException.class)
public ResponseEntity<ApiResult<Void>> handleDomainRuleViolation(
HttpServletRequest request
) {
ErrorCode errorCode = ErrorCode.DOMAIN_RULE_VIOLATION;
Map<String, String> metadata = requestMetadata(request);
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(
errorCode,
null,
metadata
));
}
@ExceptionHandler(ExternalDependencyException.class)
public ResponseEntity<ApiResult<Void>> handleExternalDependency(
HttpServletRequest request
) {
ErrorCode errorCode = ErrorCode.EXTERNAL_DEPENDENCY_FAILURE;
Map<String, String> metadata = requestMetadata(request);
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(
errorCode,
null,
metadata
));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
MethodArgumentNotValidException ex,
HttpServletRequest request
) {
Map<String, String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toUnmodifiableMap(
FieldError::getField,
DefaultMessageSourceResolvable::getDefaultMessage,
(first, second) -> first
));
ErrorCode errorCode = ErrorCode.REQUEST_VALIDATION_FAILED;
Map<String, String> metadata = requestMetadata(request);
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(
errorCode,
errors,
metadata
));
}
private Map<String, String> requestMetadata(HttpServletRequest request) {
Object requestId = request.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE);
if (!(requestId instanceof String value) || value.isBlank()) {
return Map.of();
}
return Map.of("requestId", value);
}
}
```
**좋은 이유:**
- business exception, validation exception을 한곳에서 다룬다
- 응답 포맷이 ApiResult로 일관된다
- advice는 예외를 ErrorCode로 매핑하는 책임만 가진다
### 예시 6. 성공 응답 공통 래핑은 ResponseBodyAdvice에서 처리한다
```java
@RestControllerAdvice
public class ApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<?> converterType) {
Class<?> parameterType = returnType.getParameterType();
return !ApiResult.class.isAssignableFrom(parameterType)
&& !ResponseEntity.class.isAssignableFrom(parameterType)
&& !Resource.class.isAssignableFrom(parameterType);
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response
) {
if (body == null) {
return ApiResult.success(null);
}
if (body instanceof ApiResult<?>) {
return body;
}
return ApiResult.success(body);
}
}
```
**좋은 이유:**
- 성공 응답 공통화 위치가 명확하다
- controller가 반복해서 ApiResult.success(...)를 만들지 않아도 된다
- 이미 래핑된 응답은 다시 감싸지 않는다
### 예시 7. controller는 정상 흐름만 표현한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/sessions")
public class SessionQueryController {
private final SessionQueryUseCase sessionQueryUseCase;
@GetMapping("/{sessionId}")
public ApiResult<SessionResponse> getSession(@PathVariable String sessionId) {
SessionResponse response = sessionQueryUseCase.getSession(sessionId);
return ApiResult.success(response);
}
}
```
**좋은 이유:**
- controller가 예외 정책까지 떠안지 않는다
- 정상 흐름과 예외 흐름이 분리된다
## 나쁜 예시
### 예시 1. filter에서 business/service를 직접 호출한다
```java
@Component
@RequiredArgsConstructor
public class BadAuthenticationFilter extends OncePerRequestFilter {
private final UserRepository userRepository;
private final LoginPolicyService loginPolicyService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String userId = request.getHeader("X-User-Id");
User user = userRepository.findById(userId).orElseThrow();
loginPolicyService.validate(user);
filterChain.doFilter(request, response);
}
}
```
**나쁜 이유:**
- filter에 repository/business validation이 들어갔다
- HTTP concern과 business concern이 섞였다
### 예시 2. interceptor를 보안의 주 레이어로 사용한다
```java
@Component
public class BadAuthorizationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) throws Exception {
if (request.getHeader("Authorization") == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
return true;
}
}
```
**나쁜 이유:**
- 인증/인가의 중심을 interceptor에 두고 있다
- security/filter chain과 역할이 충돌한다
### 예시 3. resolver를 business exception 처리의 기본 수단으로 사용한다
```java
@Component
public class BadBusinessExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) throws IOException {
if (ex instanceof DomainRuleViolationException) {
response.setStatus(HttpStatus.CONFLICT.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{
"success": false,
"code": "DOMAIN_RULE_VIOLATION",
"message": "Domain rule violation"
}
""");
return new ModelAndView();
}
return null;
}
}
```
**나쁜 이유:**
- business exception 처리의 중심이 resolver로 내려갔다
- advice보다 의도가 덜 드러난다
- 응답 정책이 저수준 구현으로 흩어진다
### 예시 4. advice에서 에러 코드 문자열을 직접 하드코딩한다
```java
@RestControllerAdvice
public class BadApiExceptionHandler {
@ExceptionHandler(DomainRuleViolationException.class)
public ResponseEntity<ApiResult<Void>> handleDomainRuleViolation(
DomainRuleViolationException ex
) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResult.fail(
"DOMAIN_RULE_VIOLATION",
ex.getMessage()
));
}
}
```
**나쁜 이유:**
- 에러 코드 문자열이 advice에 박혀 있다
- 메시지 정책과 예외 메시지가 섞인다
- 코드/메시지/상태값 정책이 중앙화되지 않는다
### 예시 5. controller가 예외를 직접 잡아 ApiResult를 만든다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class BadUserController {
private final UserRegisterUseCase userRegisterUseCase;
@PostMapping
public ResponseEntity<ApiResult<Void>> register(@RequestBody RegisterUserRequest request) {
try {
userRegisterUseCase.register(request.email(), request.password());
return ResponseEntity.ok(ApiResult.success(null));
} catch (DuplicateEmailException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResult.fail(ErrorCode.DOMAIN_RULE_VIOLATION));
} catch (Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResult.fail(ErrorCode.EXTERNAL_DEPENDENCY_FAILURE));
}
}
}
```
**나쁜 이유:**
- controller마다 예외 처리 로직이 중복된다
- 전역 예외 처리 규약이 깨진다
### 예시 6. ResponseBodyAdvice에서 무조건 이중 래핑한다
```java
@RestControllerAdvice
public class BadApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<?> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response
) {
return ApiResult.success(body);
}
}
```
**나쁜 이유:**
- 이미 ApiResult인 응답도 다시 감싼다
- file response, streaming response 같은 예외 케이스를 고려하지 않았다
+171
View File
@@ -0,0 +1,171 @@
# @Transactional 위치 예시
## 좋은 예시 1: use case 경계에 transaction
```java
@Service
public class RegisterUserService implements RegisterUserUseCase {
private final UserReader userReader;
private final UserAppender userAppender;
private final PasswordHasher passwordHasher;
public RegisterUserService(
UserReader userReader,
UserAppender userAppender,
PasswordHasher passwordHasher
) {
this.userReader = userReader;
this.userAppender = userAppender;
this.passwordHasher = passwordHasher;
}
@Transactional
public UserId register(CreateUserCommand command) {
if (userReader.findByEmail(UserEmail.from(command.email())).isPresent()) {
throw new DuplicateUserException();
}
User user = User.create(
UserEmail.from(command.email()),
UserName.from(command.name()),
passwordHasher.hash(command.password())
);
return userAppender.append(user);
}
}
```
**왜 좋은가:**
- 비즈니스 작업 단위가 transaction 경계와 일치한다
- repository 호출들이 하나의 원자적 작업으로 묶인다
- controller나 repository에 흩어지지 않는다
## 좋은 예시 2: 조회 use case는 readOnly
```java
@Service
public class GetUserProfileService implements GetUserProfileUseCase {
private final UserReader userReader;
public GetUserProfileService(UserReader userReader) {
this.userReader = userReader;
}
@Transactional(readOnly = true)
public UserProfileResult get(UserId userId) {
User user = userReader.findById(userId).orElseThrow(UserNotFoundException::new);
return UserProfileResult.from(user);
}
}
```
**왜 좋은가:**
- 순수 조회라는 의도가 드러난다
- 쓰기 작업과 구분된다
## 좋은 예시 3: 별도 확정 단위가 필요한 경우만 REQUIRES_NEW
```java
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(LoginAuditCommand command) {
...
}
}
```
**왜 좋은가:**
- 본 작업과 독립된 commit 단위를 의도적으로 분리한다
- 예외적 사용이라는 점이 분명하다
## 나쁜 예시 1: controller에 transaction
```java
@RestController
public class UserController {
@Transactional
@PostMapping("/users")
public UserResponse create(@RequestBody CreateUserRequest request) {
...
}
}
```
**문제:**
- HTTP 경계와 transaction 경계가 섞인다
- web layer가 persistence 세부를 과도하게 끌어안는다
## 나쁜 예시 2: repository마다 습관적 transaction
```java
@Repository
public class JpaUserRepository {
@Transactional
public UserJpaEntity save(UserJpaEntity entity) {
...
}
}
```
**문제:**
- 상위 use case 경계가 아니라 하위 collaborator에 transaction이 흩어진다
- 작업 단위가 잘게 찢어진다
## 나쁜 예시 3: self-invocation 기대
```java
@Service
public class UserService {
public void doWork() {
this.saveAudit(); // transactional 기대
}
@Transactional
public void saveAudit() {
...
}
}
```
**문제:**
- proxy mode에서는 self-invocation이 interception 되지 않는다
- 기대한 transaction이 실제로 열리지 않을 수 있다
**개선:**
- 클래스를 분리하거나 public entry boundary를 다시 설계
## 나쁜 예시 4: 긴 외부 API 호출을 transaction 안에 유지
```java
@Transactional
public void completeLogin(LoginCommand command) {
userRepository.save(...);
externalOAuthClient.fetchProfile(...); // 긴 네트워크 호출
tokenRepository.save(...);
}
```
**문제:**
- DB 자원/잠금을 오래 붙잡을 수 있다
- 실패 반경과 지연 시간이 커진다
**개선 방향:**
- 외부 호출과 DB transaction 경계를 재설계
- 후속 작업/event/outbox 구조 검토
+311
View File
@@ -0,0 +1,311 @@
# Validation Location 예시
## 좋은 예시
### 예시 1. request DTO 구조 검증은 presentation에서 처리한다
```java
public record CreateSessionRequest(
@NotBlank String email,
@NotBlank String password,
@NotNull LoginType loginType
) {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/sessions")
public class SessionCommandController {
private final CreateSessionUseCase createSessionUseCase;
@PostMapping
public ApiResult<CreateSessionResponse> create(@Valid @RequestBody CreateSessionRequest request) {
CreateSessionResponse response = createSessionUseCase.create(
request.email(),
request.password(),
request.loginType()
);
return ApiResult.success(response);
}
}
```
**좋은 이유:**
- request shape 검증이 web boundary에 있다
- controller는 transport DTO를 domain object와 분리한다
- business rule 판단은 use case로 넘긴다
### 예시 2. path variable / request param 제약은 메서드 파라미터에 직접 둔다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserQueryController {
private final UserQueryUseCase userQueryUseCase;
@GetMapping("/{userId}")
public ApiResult<UserResponse> getUser(
@PathVariable @NotBlank String userId,
@RequestParam(defaultValue = "1") @Min(1) int page
) {
UserResponse response = userQueryUseCase.getUser(userId, page);
return ApiResult.success(response);
}
}
```
**좋은 이유:**
- scalar input 제약이 controller boundary에 명확히 드러난다
- request DTO가 필요 없는 단순 입력을 과하게 감싸지 않는다
### 예시 3. application은 조회가 필요한 정책 검증을 담당한다
```java
@Service
@RequiredArgsConstructor
public class CreateSessionUseCase {
private final UserRepository userRepository;
public CreateSessionResponse create(CreateSessionCommand command) {
if (!userRepository.existsActiveUserByEmail(command.email())) {
throw new UserNotFoundException(command.email());
}
if (command.loginType() == LoginType.PASSWORDLESS
&& command.credential() instanceof PasswordCredential) {
throw new InvalidLoginRequestException();
}
// 실제 세션 생성
return new CreateSessionResponse(...);
}
}
public record CreateSessionCommand(
String email,
LoginCredential credential,
LoginType loginType
) {
public CreateSessionCommand {
Objects.requireNonNull(email, "email must not be null");
Objects.requireNonNull(credential, "credential must not be null");
Objects.requireNonNull(loginType, "loginType must not be null");
}
}
public sealed interface LoginCredential permits PasswordCredential, PasswordlessCredential {
}
public record PasswordCredential(String value) implements LoginCredential {
public PasswordCredential {
Objects.requireNonNull(value, "value must not be null");
if (value.isBlank()) {
throw new InvalidLoginRequestException();
}
}
}
public record PasswordlessCredential() implements LoginCredential {
}
```
**좋은 이유:**
- DB 조회가 필요한 규칙을 controller validation에 두지 않았다
- use case 전제조건 검증이 application에 있다
- nullable password를 application 내부로 전파하지 않고 명시적 credential 타입으로 표현한다
### 예시 4. domain은 자기 불변식을 스스로 보장한다
```java
public final class Email {
private final String value;
private Email(String value) {
if (value == null || value.isBlank()) {
throw new DomainRuleViolationException("Email must not be blank");
}
if (!value.contains("@")) {
throw new DomainRuleViolationException("Email format is invalid");
}
this.value = value;
}
public static Email of(String value) {
return new Email(value);
}
public String value() {
return value;
}
}
```
**좋은 이유:**
- domain invariant를 controller에 의존하지 않는다
- 어디서 생성되더라도 유효한 상태만 허용한다
### 예시 5. web 전용 복잡한 입력 검증은 @InitBinder + custom Validator로 제한적으로 둔다
```java
public class ChangePasswordRequestValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return ChangePasswordRequest.class.equals(clazz);
}
@Override
public void validate(Object target, Errors errors) {
ChangePasswordRequest request = (ChangePasswordRequest) target;
if (request.newPassword() != null
&& request.newPasswordConfirm() != null
&& !request.newPassword().equals(request.newPasswordConfirm())) {
errors.rejectValue("newPasswordConfirm", "password.confirm.mismatch");
}
}
}
@RestController
@RequestMapping("/api/password")
public class PasswordController {
@InitBinder("changePasswordRequest")
void initBinder(WebDataBinder binder) {
binder.addValidators(new ChangePasswordRequestValidator());
}
@PostMapping("/change")
public ApiResult<Void> changePassword(
@Valid @RequestBody ChangePasswordRequest changePasswordRequest
) {
return ApiResult.success(null);
}
}
```
**좋은 이유:**
- request-object 내부의 web 입력 규칙만 binder validator에 둔다
- business rule 전체를 validator에 몰아넣지 않는다
## 나쁜 예시
### 예시 1. entity를 request binding 대상으로 직접 노출한다
```java
@Entity
public class User {
@Id
private Long id;
private String email;
private String role;
}
@PostMapping("/users")
public ApiResult<Void> create(@Valid @RequestBody User user) {
...
}
```
**나쁜 이유:**
- web input model과 domain/persistence model이 섞였다
- 바인딩 범위가 불필요하게 넓다
- request schema 변경이 domain/persistence 모델에 직접 번진다
### 예시 2. controller validation만 믿고 domain에서 아무 것도 보장하지 않는다
```java
public final class Email {
private final String value;
public Email(String value) {
this.value = value;
}
}
```
**나쁜 이유:**
- 다른 진입 경로에서 잘못된 값이 들어오면 막지 못한다
- domain이 자기 불변식을 보장하지 못한다
### 예시 3. controller 클래스에 @Validated를 붙여 구식 proxy 방식에 기대한다
```java
@Validated
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{userId}")
public ApiResult<UserResponse> get(@PathVariable @NotBlank String userId) {
...
}
}
```
**나쁜 이유:**
- Spring MVC 6.1+ built-in method validation 대신 class-level AOP proxy 경로로 흐를 수 있다
- 이 프로젝트의 controller 규칙과 맞지 않는다
### 예시 4. filter / interceptor에서 business validation을 수행한다
```java
@Component
@RequiredArgsConstructor
public class BadLoginValidationFilter extends OncePerRequestFilter {
private final LoginPolicyService loginPolicyService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
loginPolicyService.validateLoginWindow();
filterChain.doFilter(request, response);
}
}
```
**나쁜 이유:**
- business validation이 web infrastructure 훅으로 새어 나갔다
- 요청 바운더리 검증과 use case 규칙이 섞였다
### 예시 5. service method validation만 믿고 복잡한 정책을 숨긴다
```java
@Service
@Validated
public class BadCreateSessionService {
public void create(
@NotBlank String email,
@NotBlank String password,
@NotNull LoginType loginType
) {
// 복잡한 도메인 정책을 전부 메서드 시그니처 제약에 기대함
}
}
```
**나쁜 이유:**
- method validation은 보조 수단이지 핵심 정책 엔진이 아니다
- proxy 기반 동작 특성 때문에 경계가 흐려질 수 있다
- business rule이 시그니처 제약 뒤에 숨어 버린다
+270
View File
@@ -0,0 +1,270 @@
# Fixture / Factory 예시
## 좋은 예시
### 예시 1. factory는 새 유효 객체를 매번 반환한다
```java
public final class UserFactory {
private UserFactory() {
}
public static User user() {
return new User(
"user-" + UUID.randomUUID() + "@test.com",
"ACTIVE"
);
}
public static User user(UnaryOperator<UserBuilder> customizer) {
UserBuilder builder = UserBuilder.defaultUser();
return customizer.apply(builder).build();
}
}
```
**좋은 이유:**
- 매 호출마다 새 객체를 만든다
- 기본값은 유효한 상태다
- 테스트는 필요한 값만 override할 수 있다
JUnit은 기본적으로 테스트 메서드마다 새 테스트 인스턴스를 만들어 격리를 보장하려고 하므로, 테스트 데이터 helper도 같은 방향으로 fresh object를 주는 것이 자연스럽다.
### 예시 2. fixture 이름이 시나리오를 설명한다
```java
public final class UserFixture {
private UserFixture() {
}
public static User activeUser() {
return UserFactory.user();
}
public static User deletedUser() {
return UserFactory.user(builder -> builder.deletedAt(OffsetDateTime.now()));
}
public static User invalidEmailUser() {
return UserFactory.user(builder -> builder.email("not-an-email"));
}
}
```
**좋은 이유:**
- fixture 이름만 봐도 상태 의미가 드러난다
- invalid 상태도 명시적으로 분리된다
- factory와 fixture의 역할이 나뉜다
이런 분리는 공식 어노테이션이 강제하는 것은 아니지만, Spring 테스트 지원이 fixture 준비를 쉽게 해 주는 목적과 잘 맞는 실무 패턴이다.
### 예시 3. repository test에서는 persisted fixture를 분리한다
```java
@Component
@RequiredArgsConstructor
public class PersistedUserFactory {
private final EntityManager em;
public User persistedUser() {
User user = UserFactory.user();
em.persist(user);
em.flush();
return user;
}
public User persistedUserAndClear() {
User user = UserFactory.user();
em.persist(user);
em.flush();
em.clear();
return user;
}
}
```
**좋은 이유:**
- DB에 반영된 fixture와 메모리 상태 fixture를 구분한다
- repository test에서 영속성 상태를 더 명확히 다룰 수 있다
- 표준 `EntityManager`만 사용하므로 `@DataJpaTest``@SpringBootTest` 양쪽 컨텍스트에서 동일하게 동작한다
Spring Boot는 `@DataJpaTest` 슬라이스에서 `TestEntityManager``persist`/`flush`/`find` 같은 common testing task를 위한 대안 `EntityManager`로 제공한다고 설명한다. 다만 `TestEntityManager``@DataJpaTest` 컨텍스트에서만 자동 구성되므로, `@SpringBootTest`에서도 재사용할 helper에는 표준 `EntityManager`를 주입하는 것이 안전하다.
### 예시 4. 핵심 차이는 테스트 본문에 남긴다
```java
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void findByEmail_returnsMatchingUser() {
User saved = userRepository.save(
UserFactory.user(builder -> builder.email("target@test.com"))
);
Optional<User> result = userRepository.findByEmail("target@test.com");
assertThat(result).contains(saved);
}
}
```
**좋은 이유:**
- 반복 필드는 factory가 채우지만, 핵심 조건인 email은 테스트 본문에 드러난다
- 테스트를 읽는 사람이 왜 이 테스트가 중요한지 바로 이해할 수 있다
- fixture/factory가 assertion의 핵심을 숨기지 않는다
Spring 테스트 문서는 DI와 테스트 지원이 테스트를 더 쉽게 만들 수 있다고 설명하지만, 그 목적은 테스트 의미를 감추는 것이 아니다.
### 예시 5. Spring context 테스트에서도 fixture helper는 test support로 분리한다
```java
@SpringBootTest
class UserCommandServiceTest {
@Autowired
private UserCommandService userCommandService;
@Autowired
private PersistedUserFactory persistedUserFactory;
@Test
void deactivateUser_marksUserInactive() {
User user = persistedUserFactory.persistedUser();
userCommandService.deactivate(user.getId());
// assertion ...
}
}
```
**좋은 이유:**
- fixture 준비가 재사용 가능하다
- 그래도 테스트의 핵심 동작은 서비스 호출과 assertion에 남아 있다
- Spring DI는 helper 주입에만 쓰고, fixture 자체를 production 로직처럼 다루지 않는다
Spring Framework는 테스트 인스턴스에 field, setter, constructor injection을 지원한다고 설명한다.
## 나쁜 예시
### 예시 1. mutable shared fixture를 static으로 재사용한다
```java
public final class SharedFixtures {
public static final User USER = new User("a@test.com", "ACTIVE");
}
```
**나쁜 이유:**
- 한 테스트의 변경이 다른 테스트에 영향을 줄 수 있다
- JUnit의 기본 per-method 격리 철학과 맞지 않는다
- 테스트 순서 의존과 flaky test를 만들기 쉽다
JUnit은 기본 lifecycle이 테스트 간 mutable state 부작용을 피하기 위한 `PER_METHOD`라고 설명한다.
### 예시 2. boolean 나열형 factory로 의미를 숨긴다
```java
public static User user(boolean deleted, boolean admin, boolean locked, boolean invalidEmail) {
// ...
}
```
**나쁜 이유:**
- 호출부에서 각 boolean이 무엇을 뜻하는지 바로 알기 어렵다
- 상태 의미가 시나리오 이름으로 드러나지 않는다
- 잘못된 조합도 쉽게 생긴다
이런 형태는 fixture/factory가 테스트 가독성을 높여야 한다는 목적에 어긋난다. 프로젝트에서는 명시적 이름의 fixture나 builder override를 선호한다.
### 예시 3. invalid 상태를 기본 factory에 섞는다
```java
public static User user() {
return new User(null, "ACTIVE");
}
```
**나쁜 이유:**
- 기본 factory가 유효하지 않은 객체를 만든다
- 여러 테스트가 뜻하지 않게 invalid 상태를 끌고 들어온다
- invalid 검증 테스트와 정상 경로 테스트가 섞인다
factory 기본값은 특별한 이유가 없으면 유효한 객체여야 테스트 의도가 분명해진다. 이는 프로젝트 fixture/factory 기본 규칙이다.
### 예시 4. repository test에서 DB round-trip 의미를 helper가 완전히 숨긴다
```java
public User persistedUser() {
User user = UserFactory.user();
em.persist(user);
em.flush();
em.clear();
return em.find(User.class, user.getId());
}
```
**나쁜 이유:**
- 언제 `flush`/`clear`가 일어나는지 테스트 본문에서 보이지 않는다
- 어떤 테스트는 `flush`까지만 필요하고, 어떤 테스트는 `clear`가 핵심인데 모두 같은 helper 뒤에 숨는다
- repository semantics를 읽기 어렵게 만든다
`TestEntityManager`는 helper를 제공하지만, 그 목적은 테스트를 보조하는 것이지 중요한 JPA 의미를 완전히 숨기는 것이 아니다.
### 예시 5. fixture/factory를 production source에 넣는다
```java
src/main/java/com/example/user/UserFixture.java
```
**나쁜 이유:**
- 테스트 지원 코드가 production code와 경계를 잃는다
- 실제 애플리케이션 책임과 테스트 전용 책임이 섞인다
- 유지보수 시 production API처럼 오해되기 쉽다
Spring 테스트 문서는 테스트 인스턴스에 대한 DI를 지원하지만, 테스트 준비 코드를 production source에 두라고 요구하지는 않는다. 프로젝트에서는 test support를 test source에 두는 것이 기본이다.
### 예시 6. PER_CLASS lifecycle에 기대어 상태를 공유한다
```java
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserRepositoryTest {
private final List<User> users = new ArrayList<>();
@Test
void test1() {
users.add(UserFactory.user());
}
@Test
void test2() {
assertThat(users).hasSize(1);
}
}
```
**나쁜 이유:**
- 테스트 간 상태가 공유된다
- 순서와 실행 방식에 따라 쉽게 깨질 수 있다
- fixture 편의 때문에 lifecycle을 바꾼 나쁜 예다
JUnit은 `PER_CLASS`를 쓰면 instance state를 직접 reset해야 할 수 있고, 기본 lifecycle을 일관되지 않게 바꾸면 fragile build가 될 수 있다고 경고한다.
+316
View File
@@ -0,0 +1,316 @@
# Mock 사용 예시
## 좋은 예시
### 예시 1. 순수 단위 테스트에서는 MockitoExtension과 @Mock를 사용한다
```java
@ExtendWith(MockitoExtension.class)
class UserNotifierTest {
@Mock
private MailSender mailSender;
private UserNotifier userNotifier;
@BeforeEach
void setUp() {
userNotifier = new UserNotifier(mailSender);
}
@Test
void sendWelcomeMail_delegatesToMailSender() {
userNotifier.sendWelcomeMail("a@test.com");
verify(mailSender).send("a@test.com");
}
}
```
**좋은 이유:**
- Spring 컨텍스트 없이 외부 협력자만 대체한다
- 테스트 대상 생성이 명시적이라 의존성이 잘 드러난다
- interaction verification도 외부 경계에만 한정된다
Mockito는 `MockitoExtension`이 JUnit Jupiter용 확장이고 strict stubbings를 처리한다고 설명한다. `@InjectMocks`는 편의 기능이지만, 프로젝트 기본값은 명시적 생성자 조립을 우선한다.
### 예시 2. @InjectMocks는 보조 편의 수단으로 제한적으로 사용한다
```java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void findUser_returnsRepositoryResult() {
User user = new User(1L, "a@test.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
Optional<User> result = userService.findUser(1L);
assertThat(result).contains(user);
}
}
```
**좋은 이유:**
- 대상이 단순하고 의존성도 적을 때 보일러플레이트를 줄일 수 있다
- `@InjectMocks`를 “자동 wiring 마법”이 아니라 편의 기능으로만 사용한다
- 테스트의 핵심 stub과 assertion은 여전히 본문에 남아 있다
Mockito는 `@InjectMocks`가 constructor/property/setter injection 순서로 mock 주입을 시도한다고 설명한다.
### 예시 3. Spring 컨텍스트 테스트에서 bean 하나만 대체할 때는 @MockitoBean을 사용한다
```java
@SpringBootTest
class PaymentFacadeTest {
@MockitoBean
private PaymentGatewayClient paymentGatewayClient;
@Autowired
private PaymentFacade paymentFacade;
@Test
void approve_usesGatewayClient() {
when(paymentGatewayClient.approve(any())).thenReturn(new GatewayResult(true));
boolean result = paymentFacade.approve(1L);
assertThat(result).isTrue();
}
}
```
**좋은 이유:**
- full context가 필요한 테스트에서 특정 bean만 override한다
- 신규 기준에 맞는 `@MockitoBean`을 사용한다
- 외부 연동 경계만 mock으로 대체한다
Spring Framework는 `@MockitoBean`이 테스트 `ApplicationContext`의 bean을 Mockito mock으로 override한다고 설명하고, Spring Boot도 이를 공식 테스트 기능으로 안내한다.
### 예시 4. spy가 꼭 필요하면 doReturn(...).when(spy)...를 사용한다
```java
@ExtendWith(MockitoExtension.class)
class LegacyUserServiceTest {
@Test
void spy_stubsWithoutCallingRealMethod() {
LegacyUserService spy = spy(new LegacyUserService());
doReturn("stubbed").when(spy).loadExternalValue();
String result = spy.read();
assertThat(result).isEqualTo("stubbed");
}
}
```
**좋은 이유:**
- spy가 필요한 예외 상황에서도 실제 메서드 부작용을 피한다
- Mockito가 권장하는 spy stubbing 방식과 맞다
- partial mock을 최소 범위로 제한한다
Mockito는 spy를 carefully and occasionally 사용하라고 설명하고, spy stubbing에는 `doReturn` 계열을 고려하라고 설명한다.
### 예시 5. verifyNoMoreInteractions()는 정말 의미가 있을 때만 쓴다
```java
@ExtendWith(MockitoExtension.class)
class AuditPublisherTest {
@Mock
private EventBus eventBus;
@Test
void publishExactlyOneAuditEvent() {
AuditPublisher publisher = new AuditPublisher(eventBus);
publisher.publish("LOGIN");
verify(eventBus).publish("LOGIN");
verifyNoMoreInteractions(eventBus);
}
}
```
**좋은 이유:**
- “정확히 한 번만 발행되어야 한다”는 의미가 테스트 요구와 직접 연결된다
- 습관적 사용이 아니라 비즈니스 의미가 있을 때만 사용한다
- interaction assertion이 과도하지 않다
Mockito는 `verifyNoMoreInteractions()`를 every test method에 사용하는 것을 권장하지 않지만, interaction testing toolkit의 일부로는 유용하다고 설명한다.
## 나쁜 예시
### 예시 1. 테스트 대상 자체를 mock한다
```java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserService userService;
@Test
void findUser() {
when(userService.findUser(1L)).thenReturn(Optional.of(new User(1L, "a@test.com")));
}
}
```
**나쁜 이유:**
- 검증하려는 대상을 아예 가짜로 바꿔 버린다
- 테스트가 대상 로직을 전혀 실행하지 않는다
- mock은 협력자 경계에만 써야 한다
Mockito mock은 협력 객체를 대체하는 도구이지, 테스트 대상을 없애는 도구가 아니다. Spring의 `@MockitoBean`도 마찬가지로 bean override 용도다.
### 예시 2. repository test에서 repository를 mock한다
```java
@ExtendWith(MockitoExtension.class)
class UserRepositoryTest {
@Mock
private UserRepository userRepository;
@Test
void findByEmail() {
when(userRepository.findByEmail("a@test.com"))
.thenReturn(Optional.of(new User(1L, "a@test.com")));
}
}
```
**나쁜 이유:**
- repository 경계의 실제 DB 의미, query, 매핑을 전혀 검증하지 않는다
- 이런 테스트는 repository test가 아니라 service 단위 테스트의 협력자 stub에 가깝다
- repository 자체 검증은 실제 repository/DB로 해야 한다
repository test의 목적은 영속성 경계 검증이므로 mock repository는 목적과 맞지 않는다.
### 예시 3. 모든 테스트에 verifyNoMoreInteractions()를 습관적으로 붙인다
```java
@ExtendWith(MockitoExtension.class)
class UserNotifierTest {
@Mock
private MailSender mailSender;
@Test
void sendWelcomeMail() {
UserNotifier notifier = new UserNotifier(mailSender);
notifier.sendWelcomeMail("a@test.com");
verify(mailSender).send("a@test.com");
verifyNoMoreInteractions(mailSender);
}
}
```
**나쁜 이유:**
- 추가 상호작용 금지가 이 테스트의 핵심 의미가 아닐 수도 있다
- 테스트가 불필요하게 취약해진다
- Mockito도 이 메서드를 every test method에 쓰는 것은 권장하지 않는다
Mockito는 `verifyNoMoreInteractions()`를 모든 테스트마다 쓰는 것을 권장하지 않는다고 설명한다.
### 예시 4. Spring 신규 테스트에서 @MockBean을 기본값으로 쓴다
```java
@SpringBootTest
class PaymentFacadeTest {
@MockBean
private PaymentGatewayClient paymentGatewayClient;
}
```
**나쁜 이유:**
- 현재 Spring 기준에서는 신규 코드 기본값이 아니다
- Spring Boot는 `@MockBean`이 3.4.0부터 deprecated 되었고 `@MockitoBean`을 쓰라고 안내한다
- 장기적으로 제거 예정 API에 새 테스트를 얹는 셈이다
Spring Boot API 문서는 `@MockBean`이 3.4.0부터 4.0.0 제거 예정으로 deprecated 되었고 `MockitoBean`을 대안으로 제시한다.
### 예시 5. spy에서 when(spy.method())로 실제 메서드를 먼저 호출한다
```java
@ExtendWith(MockitoExtension.class)
class LegacyUserServiceTest {
@Test
void badSpyUsage() {
LegacyUserService spy = spy(new LegacyUserService());
when(spy.loadExternalValue()).thenReturn("stubbed");
}
}
```
**나쁜 이유:**
- stub 과정에서 실제 메서드가 호출될 수 있다
- 부작용이나 예외를 일으킬 수 있다
- spy에서는 `doReturn(...).when(spy)...`가 더 안전하다
Mockito는 spy stubbing에서 `when(...)`가 부적절할 수 있고, `doReturn` 계열을 고려하라고 설명한다.
### 예시 6. static mock을 길게 열어 두고 일반 테스트처럼 사용한다
```java
@Test
void badStaticMockUsage() {
MockedStatic<ClockUtil> mocked = mockStatic(ClockUtil.class);
mocked.when(ClockUtil::now).thenReturn(Instant.parse("2026-01-01T00:00:00Z"));
// 여러 로직 수행
}
```
**나쁜 이유:**
- scope가 길고 close가 명확하지 않다
- static mock은 생성된 thread에만 영향을 주고 동시 사용에도 안전하지 않다
- try-with-resources로 짧게 감싸는 편이 맞다
Mockito는 `MockedStatic`이 생성된 thread에만 영향을 주며 concurrent use에 안전하지 않다고 설명한다.
### 예시 7. non-singleton bean을 무심코 @MockitoBean으로 바꾼다
```java
@SpringBootTest
class ScopedBeanTest {
@MockitoBean
private RequestScopedClient requestScopedClient;
}
```
**나쁜 이유:**
- Spring은 non-singleton bean을 mock/spy하면 singleton처럼 취급될 수 있다고 설명한다
- scope 의미가 깨질 수 있다
- 이런 경우는 테스트 구조 자체를 다시 설계하는 편이 더 안전하다
Spring Framework는 non-singleton bean을 `@MockitoBean`/`@MockitoSpyBean`으로 override하면 singleton처럼 다뤄질 수 있다고 설명한다.
+297
View File
@@ -0,0 +1,297 @@
# Repository Test 예시
## 좋은 예시
### 예시 1. 기본 repository test는 @DataJpaTest로 시작한다
```java
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void findByEmail_returnsUser() {
// given
User user = new User("a@test.com", "active");
userRepository.save(user);
// when
Optional<User> result = userRepository.findByEmail("a@test.com");
// then
assertThat(result).isPresent();
}
}
```
**좋은 이유:**
- JPA slice만 좁게 로딩한다
- repository 자체를 테스트 대상으로 유지한다
- full application context를 불필요하게 띄우지 않는다
Spring Boot는 `@DataJpaTest`가 JPA components에 초점을 맞추고, 엔티티와 repository를 스캔한다고 설명한다.
### 예시 2. 제약 위반은 flush()까지 가서 검증한다
```java
@DataJpaTest
class UserRepositoryConstraintTest {
@Autowired
private UserRepository userRepository;
@Test
void save_duplicateEmail_throwsExceptionOnFlush() {
userRepository.save(new User("dup@test.com", "active"));
userRepository.save(new User("dup@test.com", "active"));
assertThatThrownBy(() -> userRepository.flush())
.isInstanceOf(Exception.class);
}
}
```
**좋은 이유:**
- unique constraint 위반이 실제 DB 동기화 시점에 드러난다는 점을 반영한다
- `save()`만 보고 통과한 테스트를 방지한다
- 영속성 경계의 실제 실패 시점을 검증한다
Hibernate의 영속성 컨텍스트는 1차 캐시로 동작하므로, DB 의미를 보려면 `flush`가 중요하다. `TestEntityManager``persistAndFlush` 같은 helper를 제공한다.
### 예시 3. 실제 재조회 의미를 검증할 때는 flush() 후 clear()를 사용한다
```java
@DataJpaTest
class UserRepositoryReloadTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager em;
@Test
void findActiveUsers_excludesDeletedRows() {
userRepository.save(new User("a@test.com", "active", null));
userRepository.save(new User("b@test.com", "active", OffsetDateTime.now()));
em.flush();
em.clear();
List<User> users = userRepository.findAllByDeletedAtIsNullOrderByIdDesc();
assertThat(users).extracting(User::getEmail)
.containsExactly("a@test.com");
}
}
```
**좋은 이유:**
- 1차 캐시가 아니라 실제 DB round-trip 이후 결과를 검증한다
- soft delete predicate 같은 조회 semantics를 더 신뢰도 높게 확인한다
- `TestEntityManager`를 보조 도구로만 사용한다
Hibernate는 persistence context가 transaction-scoped first-level cache로 동작한다고 설명하고, `TestEntityManager`는 test에서 `persist`/`flush`/`find` helper를 제공한다고 설명한다.
### 예시 4. PostgreSQL 특화 query는 실제 DB 계열에서 검증한다
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class PostgresUserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void findRecentlyCreatedUsers_worksWithPostgresSpecificQuery() {
// given / when / then
}
}
```
**좋은 이유:**
- 임베디드 DB 대체를 끄고 실제 DB 계열을 사용하도록 의도를 드러낸다
- PostgreSQL native query나 dialect 의존 쿼리를 더 신뢰도 높게 검증할 수 있다
- repository test의 범위는 유지하면서 DB 의미를 맞춘다
Spring Boot는 `@DataJpaTest`가 기본적으로 임베디드 DB를 구성할 수 있고, 실제 DB를 선호하면 `@AutoConfigureTestDatabase`로 제어할 수 있다고 설명한다.
### 예시 5. commit이 정말 필요한 경우에만 예외적으로 commit 테스트를 쓴다
```java
@DataJpaTest
@Commit
class UserRepositoryCommitTest {
@Autowired
private UserRepository userRepository;
@Test
void savesDataThatMustBeObservedAfterCommit() {
userRepository.save(new User("commit@test.com", "active"));
}
}
```
**좋은 이유:**
- 기본 rollback 규칙을 알고, 필요한 경우에만 예외를 사용한다
- commit 이후에만 보이는 DB 효과를 검증하는 목적이 분명하다
- rollback이 기본, commit은 예외라는 기준을 지킨다
Spring 테스트는 transactional test를 기본 rollback하고, `@Commit`/`@Rollback`으로 이를 바꿀 수 있다고 설명한다.
## 나쁜 예시
### 예시 1. repository만 보는데 @SpringBootTest를 기본값으로 쓴다
```java
@SpringBootTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
}
```
**나쁜 이유:**
- repository 경계만 볼 테스트에 full application context를 띄운다
- 테스트 범위와 비용이 과도하다
- `@DataJpaTest`가 더 적합한 기본값이다
Spring Boot는 `@DataJpaTest`를 JPA slice test로 제공하고, 일반 `@Component`는 로드하지 않는다고 설명한다.
### 예시 2. 제약 테스트를 flush 없이 작성한다
```java
@DataJpaTest
class UserRepositoryConstraintTest {
@Autowired
private UserRepository userRepository;
@Test
void save_duplicateEmail_throwsException() {
userRepository.save(new User("dup@test.com", "active"));
assertThatThrownBy(() ->
userRepository.save(new User("dup@test.com", "active"))
).isInstanceOf(Exception.class);
}
}
```
**나쁜 이유:**
- 실제 DB 제약 위반이 아직 드러나지 않을 수 있다
- 영속성 컨텍스트 안에서 테스트가 가짜로 통과하거나 실패 시점이 늦어질 수 있다
- 이런 검증은 보통 `flush` 시점까지 가야 한다
Hibernate의 persistence context는 1차 캐시로 동작하므로, DB 의미를 드러내려면 `flush`가 중요하다.
### 예시 3. 재조회 검증인데 clear() 없이 같은 엔티티만 본다
```java
@DataJpaTest
class UserRepositoryReloadTest {
@Autowired
private UserRepository userRepository;
@Test
void findById_readsUpdatedState() {
User user = userRepository.save(new User("a@test.com", "active"));
user.changeStatus("inactive");
User found = userRepository.findById(user.getId()).orElseThrow();
assertThat(found.getStatus()).isEqualTo("inactive");
}
}
```
**나쁜 이유:**
- 같은 persistence context 안의 같은 엔티티 인스턴스를 다시 본 것일 수 있다
- 실제 DB에서 다시 읽은 결과인지 보장되지 않는다
- 조회 semantics 검증으로는 신뢰도가 낮다
Hibernate는 `Session`/`EntityManager`가 first-level cache를 유지한다고 설명한다.
### 예시 4. PostgreSQL 특화 native query를 임베디드 DB만으로 신뢰한다
```java
@DataJpaTest
class NativePostgresQueryTest {
@Autowired
private UserRepository userRepository;
@Test
void works() {
userRepository.runPostgresSpecificQuery();
}
}
```
**나쁜 이유:**
- 테스트 DB가 운영 DB 의미를 충분히 재현하지 못할 수 있다
- dialect 차이, 함수, JSONB, partial index 전제, locking clause 같은 부분은 놓치기 쉽다
- DB 특화 query는 실제 DB 계열 검증이 더 적합하다
Spring Boot는 `@DataJpaTest`가 임베디드 DB를 기본 구성할 수 있고, 실제 DB를 쓰려면 `@AutoConfigureTestDatabase`로 제어할 수 있다고 설명한다.
### 예시 5. repository test 안에서 service 정책까지 같이 검증한다
```java
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private UserService userService;
}
```
**나쁜 이유:**
- 테스트 범위가 repository 경계를 벗어난다
- slice 목적과 맞지 않는다
- service 정책 검증은 상위 통합 테스트 책임이다
`@DataJpaTest`는 JPA components에만 초점을 맞추고 일반 컴포넌트를 로드하지 않는 slice다.
### 예시 6. repository test에서 repository 대신 EntityManager만 직접 사용한다
```java
@DataJpaTest
class UserRepositoryTest {
@PersistenceContext
private EntityManager em;
@Test
void test() {
em.persist(new User("a@test.com", "active"));
em.createQuery("select u from User u", User.class).getResultList();
}
}
```
**나쁜 이유:**
- repository를 검증하겠다면서 repository를 거의 사용하지 않는다
- 결국 repository contract가 아니라 JPA API 자체를 테스트하게 된다
- `EntityManager`는 보조 도구로만 쓰는 편이 적절하다
Spring Boot는 `TestEntityManager`를 repository/JPA test의 보조 도구로 제공한다고 설명한다. 중심은 repository여야 한다.
@@ -0,0 +1,254 @@
# @SpringBootTest 사용 예시
## 좋은 예시
### 예시 1. 애플리케이션 기동 smoke test는 NONE으로 충분하다
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class ApplicationContextSmokeTest {
@Test
void contextLoads() {
}
}
```
**좋은 이유:**
- 실제 서버가 필요 없는 full-context 기동 확인이다
- Boot 방식으로 애플리케이션이 정상 조립되는지 확인한다
- non-web full-context 테스트에 가장 좁은 환경을 선택했다
Spring Boot는 `@SpringBootTest``SpringApplication`으로 컨텍스트를 만들고, `NONE`은 웹 환경 없이 `ApplicationContext`만 로드한다고 설명한다.
### 예시 2. 여러 레이어가 함께 필요한 서비스 통합 테스트는 NONE을 사용한다
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class OrderCommandServiceIntegrationTest {
@Autowired
private OrderCommandService orderCommandService;
@Autowired
private OrderRepository orderRepository;
@Test
void confirmOrder_changesState() {
// given
// when
orderCommandService.confirm(1L);
// then
assertThat(orderRepository.findById(1L)).isPresent();
}
}
```
**좋은 이유:**
- 서비스, 리포지토리, 트랜잭션, 설정 조합까지 함께 검증한다
- 실제 웹 서버는 필요 없으므로 `NONE`으로 범위를 제한했다
- full context가 필요한 이유가 분명하다
`@SpringBootTest`는 Boot features가 필요한 full application context 테스트에 적합하고, `NONE`은 웹 환경을 만들지 않는다고 Boot 문서가 설명한다.
### 예시 3. 실제 서버는 필요 없지만 MVC/보안/직렬화까지 함께 보고 싶으면 MOCK + MockMvc를 쓴다
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class UserApiIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void getUser_returns200() throws Exception {
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk());
}
}
```
**좋은 이유:**
- 전체 컨텍스트는 유지하면서 실제 내장 서버는 띄우지 않는다
- MVC 설정, 보안 필터, Jackson, 예외 처리 등을 함께 볼 수 있다
- mock 기반 웹 통합 테스트 목적과 잘 맞는다
Spring Boot는 `MOCK`이 내장 서버를 시작하지 않는 mock web environment이고, `@AutoConfigureMockMvc`와 함께 사용할 수 있다고 설명한다.
### 예시 4. 실제 HTTP round-trip이 필요하면 RANDOM_PORT를 사용한다
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserHttpIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void getUser_returns200() {
var response = restTemplate.getForEntity("/users/1", String.class);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
}
}
```
**좋은 이유:**
- 실제 내장 서버와 실제 HTTP 경로를 검증한다
- 고정 포트 충돌 없이 자동화 테스트에 적합하다
- mock 환경으로는 검증하기 어려운 실제 서버 동작을 본다
Spring Boot는 `RANDOM_PORT`가 실제 `WebServerApplicationContext`를 만들고 임의 포트에 서버를 시작한다고 설명한다.
### 예시 5. full context가 필요하지만 테스트 편의 기능도 원하면 @AutoConfigure…를 조합한다
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class UserAdminFlowTest {
// full context + MockMvc 편의 빈 사용
}
```
**좋은 이유:**
- slice로 자를 수는 없지만 테스트 편의 빈은 활용한다
- `@SpringBootTest``@AutoConfigure…`의 역할이 분명하다
- Boot가 공식적으로 허용한 조합이다
Spring Boot는 `@AutoConfigure…` 계열을 `@SpringBootTest`와 함께 사용할 수 있다고 설명한다.
## 나쁜 예시
### 예시 1. 순수 단위 테스트에 @SpringBootTest를 붙인다
```java
@SpringBootTest
class MoneyCalculatorTest {
@Test
void add() {
MoneyCalculator calculator = new MoneyCalculator();
assertThat(calculator.add(1, 2)).isEqualTo(3);
}
}
```
**나쁜 이유:**
- Spring 컨테이너가 전혀 필요 없다
- full context 로딩 비용만 추가한다
- 이런 테스트는 순수 JUnit 단위 테스트가 맞다
Spring 문서는 IoC 덕분에 단위 테스트와 통합 테스트를 구분해서 설계할 수 있다고 설명하고, Boot는 `@SpringBootTest`를 Boot features가 필요할 때 쓰는 어노테이션으로 설명한다.
### 예시 2. repository만 검증하려고 @SpringBootTest를 기본값으로 쓴다
```java
@SpringBootTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void findByEmail() {
assertThat(userRepository.findByEmail("a@test.com")).isPresent();
}
}
```
**나쁜 이유:**
- JPA repository만 볼 테스트에 전체 애플리케이션을 띄운다
- 데이터 접근 slice로 충분한 범위를 과도하게 넓힌다
- 테스트 목적 대비 로딩 비용이 크다
Spring Boot는 `@DataJpaTest` 같은 데이터 slice를 별도로 제공한다고 설명한다.
### 예시 3. MVC 계층 검증인데 full context를 기본값으로 쓴다
```java
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {
// request mapping, validation, status code만 검증
}
```
**나쁜 이유:**
- 테스트 관심사가 MVC 계층에 머무르면 `@WebMvcTest`가 더 정확하다
- full context를 기본값으로 잡으면 테스트 범위와 책임이 흐려진다
- slice로 충분한 대상을 과도하게 넓힌다
Spring Boot는 Spring MVC controller 테스트에 `@WebMvcTest`를 제공한다고 설명한다.
### 예시 4. RANDOM_PORT 테스트에서 @Transactional이면 서버 변경도 롤백된다고 기대한다
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
class UserHttpRollbackTest {
@Test
void createUser() {
// HTTP 호출 후 테스트 종료되면 DB도 원복될 것이라고 기대
}
}
```
**나쁜 이유:**
- 실제 서버와 테스트 메서드는 별도 스레드/별도 트랜잭션이다
- 테스트 메서드 롤백이 서버 쪽 트랜잭션에는 적용되지 않는다
- 데이터 정리 전략을 별도로 가져가야 한다
Spring Boot는 `RANDOM_PORT`/`DEFINED_PORT`에서 서버와 클라이언트가 별도 스레드에서 실행되므로 서버 쪽 트랜잭션은 테스트 롤백으로 되돌아가지 않는다고 명시한다.
### 예시 5. 여러 slice annotation을 동시에 섞는다
```java
@WebMvcTest
@DataJpaTest
class MixedSliceTest {
}
```
**나쁜 이유:**
- Spring Boot가 지원하지 않는 조합이다
- 테스트 범위가 애매하고 자동 구성도 예측하기 어렵다
- slice가 여러 개 필요하면 하나를 고르고 나머지는 수동으로 추가해야 한다
Spring Boot는 여러 `@…Test` slice annotation을 한 테스트에 함께 사용하는 것은 지원하지 않는다고 설명한다.
### 예시 6. 작은 차이마다 다른 @SpringBootTest 구성을 만들어 컨텍스트 캐시를 깨뜨린다
```java
@SpringBootTest(properties = "feature.a=true")
class ATest {
}
@SpringBootTest(properties = "feature.a=false")
class BTest {
}
@SpringBootTest(properties = "feature.b=true")
class CTest {
}
```
**나쁜 이유:**
- 목적이 비슷한 테스트인데 서로 다른 컨텍스트를 계속 만든다
- static context cache 재사용이 줄어든다
- 전체 테스트 시간이 불필요하게 늘 수 있다
Spring TestContext Framework는 `ApplicationContext`를 static cache에 저장해 재사용한다고 설명한다. 컨텍스트 구성이 달라질수록 재사용 이점이 줄어든다.
+218
View File
@@ -0,0 +1,218 @@
# API Controller 예시
## 좋은 예시
### 예시 1. controller는 request DTO를 받아 use case를 호출하고 표준 응답을 반환한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/sessions")
public class SessionCommandController {
private final CreateSessionUseCase createSessionUseCase;
@PostMapping
public ApiResult<CreateSessionResponse> create(
@Valid @RequestBody CreateSessionRequest request
) {
CreateSessionResult result = createSessionUseCase.create(
request.email(),
request.password(),
request.loginType()
);
return ApiResult.success(CreateSessionResponse.from(result));
}
}
```
**좋은 이유:**
- @RestController가 API 용도와 맞다
- JSON body를 전용 request DTO로 받는다
- controller가 use case 호출과 응답 반환에 집중한다
### 예시 2. ResponseEntity는 HTTP 제어가 필요할 때만 사용한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserCommandController {
private final RegisterUserUseCase registerUserUseCase;
@PostMapping
public ResponseEntity<ApiResult<UserCreatedResponse>> register(
@Valid @RequestBody RegisterUserRequest request
) {
UserCreatedResult result = registerUserUseCase.register(request.email(), request.password());
UserCreatedResponse response = UserCreatedResponse.from(result);
URI location = URI.create("/api/users/" + response.userId());
return ResponseEntity.created(location)
.body(ApiResult.success(response));
}
}
```
**좋은 이유:**
- 201 Created와 Location 헤더가 필요한 경우에만 ResponseEntity를 사용한다
- 모든 endpoint를 습관적으로 ResponseEntity로 감싸지 않는다
### 예시 3. 입력 출처를 시그니처에 명시한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserQueryController {
private final UserQueryUseCase userQueryUseCase;
@GetMapping("/{userId}")
public ApiResult<UserResponse> getUser(
@PathVariable String userId,
@RequestParam(defaultValue = "false") boolean includeInactive
) {
UserResult result = userQueryUseCase.getUser(userId, includeInactive);
return ApiResult.success(UserResponse.from(result));
}
}
```
**좋은 이유:**
- path와 query 입력 출처가 시그니처에서 구분된다
- HttpServletRequest 전체를 들고 오지 않아도 되는 입력은 annotation으로 처리한다
- request id 같은 관측용 헤더는 use case 입력으로 섞지 않는다
### 예시 4. controller는 예외를 직접 잡지 않는다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/password")
public class PasswordController {
private final ChangePasswordUseCase changePasswordUseCase;
@PostMapping("/change")
public ApiResult<Void> changePassword(
@Valid @RequestBody ChangePasswordRequest request
) {
changePasswordUseCase.change(
request.userId(),
request.currentPassword(),
request.newPassword()
);
return ApiResult.success(null);
}
}
```
**좋은 이유:**
- 예외는 @RestControllerAdvice에서 통합 처리할 수 있다
- controller가 공통 에러 응답 정책을 직접 품지 않는다
## 나쁜 예시
### 예시 1. controller가 repository를 직접 호출한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class BadUserController {
private final UserRepository userRepository;
@GetMapping("/{userId}")
public ApiResult<User> getUser(@PathVariable Long userId) {
User user = userRepository.findById(userId).orElseThrow();
return ApiResult.success(user);
}
}
```
**나쁜 이유:**
- controller가 persistence access를 직접 수행한다
- domain/entity가 외부 응답 모델로 직접 노출된다
- application boundary가 사라진다
### 예시 2. entity를 request body로 직접 받는다
```java
@RestController
@RequestMapping("/api/users")
public class BadUserCommandController {
@PostMapping
public ApiResult<Void> create(@Valid @RequestBody User user) {
return ApiResult.success(null);
}
}
```
**나쁜 이유:**
- request model과 domain/persistence model이 섞인다
- 웹 입력 변경이 domain/entity 구조에 직접 번진다
### 예시 3. 모든 응답을 습관적으로 ResponseEntity로 감싼다
```java
@RestController
@RequestMapping("/api/health")
public class BadHealthController {
@GetMapping
public ResponseEntity<ApiResult<String>> health() {
return ResponseEntity.ok(ApiResult.success("ok"));
}
}
```
**나쁜 이유:**
- 추가로 제어할 status/header가 없다
- 불필요한 ceremony만 늘어난다
### 예시 4. controller 안에서 공통 예외를 직접 처리한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/sessions")
public class BadSessionController {
private final CreateSessionUseCase createSessionUseCase;
@PostMapping
public ResponseEntity<ApiResult<?>> create(@RequestBody CreateSessionRequest request) {
try {
return ResponseEntity.ok(ApiResult.success(
createSessionUseCase.create(request.email(), request.password(), request.loginType())
));
} catch (InvalidCredentialException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResult.fail(ErrorCode.INVALID_CREDENTIAL));
} catch (Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResult.fail(ErrorCode.INTERNAL_SERVER_ERROR));
}
}
}
```
**나쁜 이유:**
- controller마다 예외 정책이 중복된다
- 전역 advice 기준과 충돌한다
- 정상 흐름과 에러 흐름이 한 메서드에 뒤섞인다
+181
View File
@@ -0,0 +1,181 @@
# API Versioning 예시
## 좋은 예시
### 예시 1. path major versioning으로 계약을 명시한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserQueryV1Controller {
private final UserQueryUseCase userQueryUseCase;
@GetMapping("/{userId}")
public ApiResult<UserV1Response> getUser(@PathVariable String userId) {
UserResult result = userQueryUseCase.getUser(userId);
return ApiResult.success(new UserV1Response(
result.userId(),
result.email()
));
}
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v2/users")
public class UserQueryV2Controller {
private final UserQueryUseCase userQueryUseCase;
@GetMapping("/{userId}")
public ApiResult<UserV2Response> getUser(@PathVariable String userId) {
UserResult result = userQueryUseCase.getUser(userId);
return ApiResult.success(new UserV2Response(
result.userId(),
result.email(),
result.displayName()
));
}
}
```
**좋은 이유:**
- URL만 보고 major version이 드러난다
- 버전별 계약 차이가 controller와 DTO에서 명확하다
- 내부 use case는 공유하면서 외부 계약은 분리할 수 있다
### 예시 2. Spring 7+ native version mapping을 제한적으로 활용한다
```java
@RestController
@RequestMapping("/accounts/{id}")
public class AccountController {
@GetMapping
public ApiResult<AccountLegacyResponse> getDefault(@PathVariable String id) {
return ApiResult.success(...);
}
@GetMapping(version = "1.1")
public ApiResult<AccountV11Response> getV11(@PathVariable String id) {
return ApiResult.success(...);
}
@GetMapping(version = "1.2+")
public ApiResult<AccountV12Response> getV12Plus(@PathVariable String id) {
return ApiResult.success(...);
}
}
```
**좋은 이유:**
- Spring이 공식 지원하는 version mapping 규칙을 따른다
- fixed version과 baseline version의 의미가 분명하다
- 단, 이 방식은 Spring 7+에 맞는 선택지다.
### 예시 3. deprecated version에 sunset 공지를 준비한다
```java
@RestController
@RequestMapping("/api/v1/sessions")
public class SessionV1Controller {
// 구버전 유지
}
```
운영 정책 예:
- 문서에 v1 deprecation 공지
- 릴리스 노트에 종료 일정 공지
- 응답 헤더에 deprecation/sunset/link 추가
**좋은 이유:**
- 버전 종료가 갑작스럽지 않다
- 클라이언트가 마이그레이션할 시간을 가진다
- Spring도 deprecation 관련 응답 헤더 전송을 지원한다.
## 나쁜 예시
### 예시 1. 같은 API 군에서 path와 header versioning을 섞는다
```java
@RestController
@RequestMapping("/api/v1/users")
public class MixedVersionController {
@GetMapping
public ApiResult<List<UserResponse>> getUsers() {
return ApiResult.success(...);
}
@GetMapping(headers = "API-Version=2")
public ApiResult<List<UserResponse>> getUsersV2() {
return ApiResult.success(...);
}
}
```
**나쁜 이유:**
- 버전 협상 위치가 두 군데다
- client, gateway, 문서, 테스트가 모두 복잡해진다
- 한 API product 안의 일관성을 깨뜨린다
### 예시 2. breaking change인데 version을 올리지 않는다
```java
public record UserResponse(
String userId,
String email,
String displayName,
String role
) {
}
```
기존에 email만 응답하던 endpoint가 같은 /api/v1/users/{id} 에서
- 기존 필드 삭제
- 필수 필드 의미 변경
- 구조 변경
을 해 버리는 경우
**나쁜 이유:**
- 기존 client를 조용히 깨뜨린다
- versioning 목적 자체를 무력화한다
### 예시 3. minor/patch를 path에 과하게 노출한다
```java
@RequestMapping("/api/v1.0.3/users")
public class UserController {
}
```
**나쁜 이유:**
- 공개 URL이 불필요하게 복잡해진다
- minor/patch 수준 변화까지 client 계약에 노출된다
- 프로젝트의 major-only path 전략과 맞지 않는다
### 예시 4. 버전 누락 시 최신 버전으로 암묵 fallback한다
```java
@GetMapping("/api/users/{userId}")
public ApiResult<UserResponse> getUser(@PathVariable String userId) {
// 내부적으로 최신 버전 계약으로 응답
}
```
**나쁜 이유:**
- client가 어떤 계약을 호출하는지 불명확하다
- 시간이 지나며 응답 의미가 조용히 바뀔 수 있다
- 명시적 계약 원칙과 맞지 않는다
@@ -0,0 +1,214 @@
# Authentication Object Access 예시
## 좋은 예시
### 예시 1. 프로젝트 전용 @CurrentUser를 정의한다
```java
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal
public @interface CurrentUser {
}
```
**좋은 이유:**
- controller가 Spring Security 애노테이션에 직접 결합되지 않는다
- 현재 사용자 접근 규칙이 한 파일에 모인다
- Spring 공식 문서도 같은 메타 애노테이션 방식을 예시로 보여 준다.
### 예시 2. controller는 전용 현재 사용자 타입만 받는다
```java
public record AuthenticatedUser(
String userId,
Set<String> authorities
) {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/sessions")
public class SessionCommandController {
private final CreateSessionUseCase createSessionUseCase;
@PostMapping
public ApiResult<CreateSessionResponse> create(
@CurrentUser AuthenticatedUser currentUser,
@Valid @RequestBody CreateSessionRequest request
) {
CreateSessionResult result = createSessionUseCase.create(
new CreateSessionCommand(
currentUser.userId(),
request.email(),
request.password()
)
);
return ApiResult.success(new CreateSessionResponse(
result.sessionId(),
result.accessToken()
));
}
}
```
**좋은 이유:**
- controller가 현재 사용자 접근을 명시적으로 드러낸다
- application에는 필요한 값만 전달한다
- SecurityContextHolder 직접 접근이 없다
### 예시 3. ID만 필요하면 claim/field만 바로 주입한다
```java
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal(expression = "userId")
public @interface CurrentUserId {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/password")
public class PasswordController {
private final ChangePasswordUseCase changePasswordUseCase;
@PostMapping("/change")
public ApiResult<Void> changePassword(
@CurrentUserId String userId,
@Valid @RequestBody ChangePasswordRequest request
) {
changePasswordUseCase.change(
new ChangePasswordCommand(
userId,
request.currentPassword(),
request.newPassword()
)
);
return ApiResult.success(null);
}
}
```
**좋은 이유:**
- 필요한 최소 actor 정보만 유스케이스로 간다
- current user 타입 전체를 넘기지 않아도 된다
- 공식 문서의 expression 기반 메타 애노테이션 패턴과 맞는다.
### 예시 4. Principal은 단순 확인 endpoint에 제한적으로 쓴다
```java
@RestController
@RequestMapping("/api/v1/me")
public class MeController {
@GetMapping
public ApiResult<Map<String, String>> me(Principal principal) {
return ApiResult.success(Map.of("name", principal.getName()));
}
}
```
**좋은 이유:**
- 단순 identity 확인 수준에는 충분하다
- 복잡한 Security 타입을 노출하지 않는다
- Spring MVC가 공식 지원하는 기본 method argument다.
## 나쁜 예시
### 예시 1. controller가 SecurityContextHolder를 직접 읽는다
```java
@RestController
@RequestMapping("/api/v1/me")
public class BadMeController {
@GetMapping
public ApiResult<String> me() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
return ApiResult.success(principal.getUserId());
}
}
```
**나쁜 이유:**
- controller가 보안 저장소 접근과 캐스팅 책임까지 가진다
- 시그니처에서 현재 사용자 의존이 드러나지 않는다
- Spring 공식 문서도 이 패턴보다 @AuthenticationPrincipal 쪽을 권장 예시로 보여 준다.
### 예시 2. application이 Spring Security 타입을 직접 받는다
```java
@Service
public class BadChangePasswordService {
public void change(Authentication authentication, String currentPassword, String newPassword) {
String userId = ((CustomUserPrincipal) authentication.getPrincipal()).getUserId();
// ...
}
}
```
**나쁜 이유:**
- application이 Spring Security에 결합된다
- 유스케이스 입력이 보안 프레임워크 타입에 종속된다
- 테스트와 재사용성이 나빠진다
### 예시 3. controller가 role check로 인가를 직접 처리한다
```java
@RestController
@RequestMapping("/api/v1/admin")
public class BadAdminController {
@PostMapping("/users/{userId}/lock")
public ApiResult<Void> lock(
@CurrentUser AuthenticatedUser currentUser,
@PathVariable String userId
) {
if (!currentUser.authorities().contains("ROLE_ADMIN")) {
throw new AccessDeniedException("forbidden");
}
// ...
return ApiResult.success(null);
}
}
```
**나쁜 이유:**
- 인가 규칙이 controller imperative code로 새어 나갔다
- security rule/method security와 역할이 충돌한다
- defense in depth 구조가 흐려진다.
### 예시 4. JWT claim을 여러 계층에서 직접 파싱한다
```java
@Service
public class BadUserService {
public void doSomething(JwtAuthenticationToken authentication) {
String userId = authentication.getToken().getClaimAsString("sub");
// ...
}
}
```
**나쁜 이유:**
- application이 특정 인증 메커니즘(JWT claim 구조)에 결합된다
- principal 해석 책임이 security adapter에 모이지 않는다
- 토큰 구조 변경이 여러 계층으로 번진다
@@ -0,0 +1,185 @@
# Error Code / HTTP Status Separation 예시
## 좋은 예시
### 예시 1. ErrorCode가 status와 외부 메시지를 함께 관리한다
```java
public enum ErrorCode {
REQUEST_VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "REQUEST_VALIDATION_FAILED", "Request validation failed"),
MALFORMED_JSON_REQUEST(HttpStatus.BAD_REQUEST, "MALFORMED_JSON_REQUEST", "Malformed request body"),
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "DUPLICATE_EMAIL", "Email already exists"),
INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_ACCESS_TOKEN", "Invalid access token"),
ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "Access denied"),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "User not found"),
UPSTREAM_AUTH_SERVER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "UPSTREAM_AUTH_SERVER_UNAVAILABLE", "Authentication server is temporarily unavailable"),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Unexpected server error");
private final HttpStatus httpStatus;
private final String code;
private final String message;
ErrorCode(HttpStatus httpStatus, String code, String message) {
this.httpStatus = httpStatus;
this.code = code;
this.message = message;
}
public HttpStatus httpStatus() {
return httpStatus;
}
public String code() {
return code;
}
public String message() {
return message;
}
}
```
**좋은 이유:**
- HTTP status와 application code가 함께 정책화된다
- 문자열 하드코딩이 흩어지지 않는다
- 같은 code가 어디서든 같은 기본 status를 갖는다
### 예시 2. advice는 예외를 ErrorCode로 매핑하고, status와 body를 함께 만든다
```java
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail() {
ErrorCode errorCode = ErrorCode.DUPLICATE_EMAIL;
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(errorCode));
}
@ExceptionHandler(InvalidAccessTokenException.class)
public ResponseEntity<ApiResult<Void>> handleInvalidAccessToken() {
ErrorCode errorCode = ErrorCode.INVALID_ACCESS_TOKEN;
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(errorCode));
}
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ApiResult<Void>> handleUserNotFound() {
ErrorCode errorCode = ErrorCode.USER_NOT_FOUND;
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(errorCode));
}
}
```
**좋은 이유:**
- status와 body code가 같은 정책 타입에서 나온다
- controller가 실패 응답을 직접 만들지 않는다
- ErrorCode와 HTTP status 역할이 모두 드러난다
### 예시 3. 같은 400 계열 아래 여러 세부 ErrorCode를 둔다
```java
@RestControllerAdvice
public class RequestExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
MethodArgumentNotValidException ex
) {
Map<String, String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toUnmodifiableMap(
FieldError::getField,
DefaultMessageSourceResolvable::getDefaultMessage,
(first, second) -> first
));
ErrorCode errorCode = ErrorCode.REQUEST_VALIDATION_FAILED;
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(errorCode, errors));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResult<Void>> handleMalformedJson() {
ErrorCode errorCode = ErrorCode.MALFORMED_JSON_REQUEST;
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(errorCode));
}
}
```
**좋은 이유:**
- 둘 다 400이지만 세부 원인은 ErrorCode로 구분된다
- status는 넓은 범주, code는 세부 식별자라는 역할 분리가 분명하다
## 나쁜 예시
### 예시 1. 모든 실패를 200으로 응답한다
```java
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail() {
return ResponseEntity.ok(ApiResult.fail(ErrorCode.DUPLICATE_EMAIL));
}
```
**나쁜 이유:**
- body는 실패인데 HTTP status는 성공이다
- HTTP semantics와 application semantics가 충돌한다
### 예시 2. custom 6xx status를 사용한다
```java
@ExceptionHandler(UpstreamAuthServerUnavailableException.class)
public ResponseEntity<ApiResult<Void>> handleUpstreamFailure() {
return ResponseEntity.status(601)
.body(ApiResult.fail(ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE));
}
```
**나쁜 이유:**
- 601은 유효한 HTTP status가 아니다
- 세부 원인 구분은 ErrorCode로 해야 한다
### 예시 3. @ResponseStatus(reason=...)를 REST API 기본 실패 전략으로 사용한다
```java
@ResponseStatus(code = HttpStatus.CONFLICT, reason = "Email already exists")
public class DuplicateEmailException extends RuntimeException {
}
```
**나쁜 이유:**
- HTTP status와 REST body 정책을 예외 클래스에 고정해 버린다
- reason 기반 sendError는 REST API 응답 규약과 잘 맞지 않는다
- body envelope 통일과 충돌하기 쉽다
### 예시 4. advice에서 문자열 코드와 예외 메시지를 직접 하드코딩한다
```java
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail(DuplicateEmailException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResult.fail("DUPLICATE_EMAIL", ex.getMessage()));
}
```
**나쁜 이유:**
- status/code/message 정책이 중앙화되지 않는다
- 외부 메시지와 내부 예외 메시지가 섞인다
- 다른 파일에서도 같은 문자열이 반복되기 쉽다
+290
View File
@@ -0,0 +1,290 @@
# Idempotency 예시
## 좋은 예시
### 예시 1. 멱등 키가 필요한 POST endpoint는 헤더를 명시적으로 받는다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserCommandController {
private final RegisterUserUseCase registerUserUseCase;
@PostMapping
public ResponseEntity<ApiResult<CreateUserResponse>> register(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreateUserRequest request,
AuthenticatedUser authenticatedUser
) {
CreateUserCommand command = new CreateUserCommand(
authenticatedUser.userId(),
idempotencyKey,
request.email(),
request.password(),
request.displayName()
);
CreateUserResult result = registerUserUseCase.register(command);
return ResponseEntity.created(URI.create("/api/v1/users/" + result.userId()))
.body(ApiResult.success(new CreateUserResponse(
result.userId(),
result.email(),
result.displayName()
)));
}
}
```
**좋은 이유:**
- controller는 헤더를 읽고 command로 전달만 한다
- 멱등성 구현 책임이 controller에 머무르지 않는다
- POST 생성 endpoint에서 멱등 키 요구가 명확하다
### 예시 2. application/service에서 키 + fingerprint로 중복을 판정한다
```java
public record IdempotencyScope(
String actorId,
String operation
) {
}
public record StoredRegistrationResult(
String userId,
String email,
String displayName
) {
}
public interface IdempotencyStore {
Optional<StoredRegistrationResult> findCompleted(
IdempotencyScope scope,
String key,
String fingerprint
);
IdempotencyStartResult tryStart(
IdempotencyScope scope,
String key,
String fingerprint
);
void complete(
IdempotencyScope scope,
String key,
String fingerprint,
StoredRegistrationResult result
);
}
@Service
@RequiredArgsConstructor
public class RegisterUserUseCase {
private final IdempotencyStore idempotencyStore;
private final UserRegistrationService userRegistrationService;
public CreateUserResult register(CreateUserCommand command) {
IdempotencyScope scope = new IdempotencyScope(command.actorId(), "register-user");
String fingerprint = fingerprint(command);
idempotencyStore.findCompleted(scope, command.idempotencyKey(), fingerprint)
.ifPresent(storedResponse -> {
throw new IdempotentReplayException(storedResponse);
});
IdempotencyStartResult startResult = idempotencyStore.tryStart(
scope,
command.idempotencyKey(),
fingerprint
);
if (startResult == IdempotencyStartResult.IN_PROGRESS) {
throw new IdempotencyRequestInProgressException();
}
if (startResult == IdempotencyStartResult.KEY_REUSED_WITH_DIFFERENT_REQUEST) {
throw new IdempotencyKeyMismatchException();
}
CreateUserResult result = userRegistrationService.register(command);
StoredRegistrationResult storedResult = new StoredRegistrationResult(
result.userId(),
result.email(),
result.displayName()
);
idempotencyStore.complete(scope, command.idempotencyKey(), fingerprint, storedResult);
return result;
}
private String fingerprint(CreateUserCommand command) {
return DigestUtils.sha256Hex(
command.actorId() + "|" +
command.email() + "|" +
command.displayName()
);
}
}
```
**좋은 이유:**
- 멱등성 판정이 application 경계에 있다
- key뿐 아니라 fingerprint도 비교한다
- 완료 결과 재생, 진행 중 충돌, key 재사용 충돌을 분리한다
- application은 HTTP status, ApiResult, JSON 직렬화 세부를 알지 않는다
### 예시 3. 멱등성 오류도 공통 에러 응답 규약으로 처리한다
```java
@RestControllerAdvice
public class IdempotencyExceptionHandler {
@ExceptionHandler(IdempotencyKeyMissingException.class)
public ResponseEntity<ApiResult<Void>> handleMissingKey() {
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_KEY_REQUIRED;
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(errorCode));
}
@ExceptionHandler(IdempotencyRequestInProgressException.class)
public ResponseEntity<ApiResult<Void>> handleInProgress() {
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_REQUEST_IN_PROGRESS;
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(errorCode));
}
@ExceptionHandler(IdempotencyKeyMismatchException.class)
public ResponseEntity<ApiResult<Void>> handleMismatch() {
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST;
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(errorCode));
}
}
```
**좋은 이유:**
- 400/409/422 같은 HTTP status와 application error code를 함께 유지한다
- 멱등성 오류도 전체 API 에러 규약에 맞춰진다
### 예시 4. key는 opaque UUID를 사용한다
요청 예:
```http
POST /api/v1/users
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json
```
**좋은 이유:**
- 민감정보가 없다
- 재시도 시 같은 key를 다시 보낼 수 있다
- 운영/추적에도 적당한 opaque identifier다
## 나쁜 예시
### 예시 1. GET에 멱등 키를 요구한다
```java
@GetMapping("/{userId}")
public ApiResult<UserResponse> getUser(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@PathVariable String userId
) {
...
}
```
**나쁜 이유:**
- GET은 이미 safe/idempotent다
- 불필요한 계약 복잡도만 늘어난다
### 예시 2. 서버가 멱등 키를 생성한다
```java
@PostMapping
public ApiResult<CreateUserResponse> register(@RequestBody CreateUserRequest request) {
String idempotencyKey = UUID.randomUUID().toString();
...
}
```
**나쁜 이유:**
- client가 타임아웃 후 같은 요청을 재시도할 때 같은 key를 다시 보낼 수 없다
- 재시도 안전성이라는 목적을 달성하지 못한다
### 예시 3. 이메일을 멱등 키로 사용한다
```http
Idempotency-Key: donghyun@example.com
```
**나쁜 이유:**
- 개인정보가 key에 노출된다
- 요청 의도 식별자와 사용자 식별자가 뒤섞인다
- 같은 사용자의 다른 요청을 구분하기 어렵다
### 예시 4. controller의 로컬 메모리로만 중복을 막는다
```java
@RestController
@RequestMapping("/api/v1/users")
public class BadUserController {
private final Set<String> processedKeys = ConcurrentHashMap.newKeySet();
@PostMapping
public ApiResult<Void> register(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody CreateUserRequest request
) {
if (!processedKeys.add(idempotencyKey)) {
return ApiResult.success(null);
}
// 실제 생성 처리
return ApiResult.success(null);
}
}
```
**나쁜 이유:**
- 다중 인스턴스 환경에서 깨진다
- fingerprint 비교가 없다
- 애플리케이션 재기동 시 기록이 사라진다
- controller가 멱등성 구현 책임까지 떠안는다
### 예시 5. 같은 key를 다른 payload에 재사용해도 새 요청으로 처리한다
```java
public void handle(String key, CreateUserRequest request) {
if (store.contains(key)) {
process(request); // 그냥 다시 처리
return;
}
process(request);
}
```
**나쁜 이유:**
- key 재사용 오용을 막지 못한다
- 중복 생성/중복 실행 위험이 남는다
- “같은 요청의 재시도”와 “다른 요청”을 구분하지 못한다
+285
View File
@@ -0,0 +1,285 @@
# Pagination / Sort / Filter 예시
## 좋은 예시
### 예시 1. 일반 목록 조회는 명시적 query DTO + page 응답으로 표현한다
```java
public record UserListRequest(
@Min(1) int page,
@Min(1) @Max(100) int size,
String keyword,
UserStatus status,
String sortBy,
SortDirection direction
) {
}
public record PageResponse<T>(
List<T> items,
int page,
int size,
boolean hasNext,
Long totalCount
) {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserQueryController {
private final SearchUsersUseCase searchUsersUseCase;
@GetMapping
public ApiResult<PageResponse<UserResponse>> search(
@Valid @ModelAttribute UserListRequest request
) {
SearchUsersQuery query = SearchUsersQuery.of(
request.page(),
request.size(),
request.keyword(),
request.status(),
request.sortBy(),
request.direction()
);
UserPageResult result = searchUsersUseCase.search(query);
return ApiResult.success(new PageResponse<>(
result.items().stream()
.map(UserResponse::from)
.toList(),
result.page(),
result.size(),
result.hasNext(),
result.totalCount()
));
}
}
```
**좋은 이유:**
- 외부 계약이 명시적이다
- query input과 내부 paging 모델이 분리된다
- 응답도 raw Page가 아니라 API 전용 DTO다
### 예시 2. sort field는 allowlist로 받고, persistence adapter에서 내부 정렬로 변환한다
```java
public enum UserSortField {
CREATED_AT("createdAt"),
DISPLAY_NAME("displayName");
private final String externalName;
UserSortField(String externalName) {
this.externalName = externalName;
}
public static UserSortField from(String value) {
return Arrays.stream(values())
.filter(field -> field.externalName.equals(value))
.findFirst()
.orElseThrow(() -> new InvalidSortFieldException(value));
}
}
@Service
public class JpaUserSortMapper {
public Sort toSort(UserSortField sortField, SortDirection direction) {
return switch (sortField) {
case CREATED_AT -> Sort.by(direction.toSpring(), "createdAt", "id");
case DISPLAY_NAME -> Sort.by(direction.toSpring(), "displayName", "id");
};
}
}
```
**좋은 이유:**
- 외부 정렬 키와 내부 컬럼/프로퍼티가 분리된다
- unsupported sort field를 명시적으로 거절할 수 있다
- tie-breaker가 포함되어 정렬이 안정적이다
- Spring Data `Sort` 변환은 persistence adapter 경계에 머문다
### 예시 3. count가 필요 없으면 Slice 스타일 응답으로 줄인다
```java
public record SliceResponse<T>(
List<T> items,
int page,
int size,
boolean hasNext
) {
}
@Service
@RequiredArgsConstructor
public class SearchAuditLogUseCase {
private final AuditLogRepository auditLogRepository;
public AuditLogSliceResult search(SearchAuditLogQuery query) {
return auditLogRepository.findSliceByCondition(query);
}
}
```
**좋은 이유:**
- application은 count가 필요 없는 slice 결과를 typed result로 반환한다
- Spring Data `Slice` / `PageRequest`와 response DTO 변환은 바깥 adapter 책임으로 남긴다
### 예시 4. 대용량 피드는 cursor pagination을 쓴다
```java
public record CursorPageResponse<T>(
List<T> items,
String nextCursor,
boolean hasNext
) {
}
public record TimelineRequest(
String cursor,
@Min(1) @Max(100) int size
) {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/timeline")
public class TimelineController {
private final ReadTimelineUseCase readTimelineUseCase;
@GetMapping
public ApiResult<CursorPageResponse<TimelineItemResponse>> read(
@Valid @ModelAttribute TimelineRequest request
) {
TimelineWindowResult result = readTimelineUseCase.read(request.cursor(), request.size());
return ApiResult.success(new CursorPageResponse<>(
result.items(),
result.nextCursor(),
result.hasNext()
));
}
}
```
**좋은 이유:**
- 대용량/변동이 큰 목록에 더 적합하다
- 외부에는 opaque cursor만 노출한다
- page number 깊이에 따라 성능이 급격히 나빠지는 구조를 피할 수 있다
### 예시 5. 다중 값 필터는 반복 query parameter로 받는다
```java
public record UserSearchRequest(
List<UserStatus> status,
String keyword,
@Min(1) int page,
@Min(1) @Max(100) int size
) {
}
```
요청 예:
```http
GET /api/v1/users?status=ACTIVE&status=PENDING&keyword=kim&page=1&size=20
```
**좋은 이유:**
- 다중 값 필터가 명확하다
- query parameter 규약이 읽기 쉽다
- Spring 바인딩과도 자연스럽게 맞는다
## 나쁜 예시
### 예시 1. 공개 API controller에 raw Pageable을 그대로 노출한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class BadUserController {
private final UserRepository userRepository;
@GetMapping
public Page<User> getUsers(Pageable pageable) {
return userRepository.findAll(pageable);
}
}
```
**나쁜 이유:**
- 외부 계약이 Spring Data 내부 타입에 종속된다
- entity와 raw Page가 그대로 노출된다
- request/response 계약을 프로젝트가 통제하기 어렵다
### 예시 2. 지원하지 않는 sort field를 조용히 무시한다
```java
public Sort toSort(String sortBy, SortDirection direction) {
if ("createdAt".equals(sortBy)) {
return Sort.by(direction.toSpring(), "createdAt");
}
return Sort.unsorted();
}
```
**나쁜 이유:**
- 잘못된 요청을 성공처럼 처리한다
- client는 정렬이 적용된 줄 오해할 수 있다
- unsupported sort는 명시적으로 거절해야 한다
### 예시 3. 깊은 페이지까지 offset만 강제한다
```java
@GetMapping("/api/v1/events")
public ApiResult<PageResponse<EventResponse>> getEvents(
@RequestParam int page,
@RequestParam int size
) {
// 수백만 건 로그를 무조건 offset paging으로 조회
}
```
**나쁜 이유:**
- 큰 offset paging은 성능이 급격히 나빠질 수 있다
- 이벤트/로그/피드 계열에는 cursor 전략 검토가 필요하다
### 예시 4. 페이지마다 sort/filter가 달라질 수 있게 한다
```http
GET /api/v1/users?page=1&size=20&sortBy=createdAt&direction=desc
GET /api/v1/users?page=2&size=20&sortBy=displayName&direction=asc
```
**나쁜 이유:**
- 같은 목록의 다음 페이지라는 의미가 깨진다
- 중복/누락/순서 흔들림이 생길 수 있다
### 예시 5. 범용 filter DSL을 기본 공개 API에 도입한다
```http
GET /api/v1/users?filter=(status eq ACTIVE and (createdAt gt 2026-01-01)) or (role in [ADMIN,OWNER])
```
**나쁜 이유:**
- 단순 목록 API치고 계약이 과도하게 복잡하다
- 문서화, 검증, 운영 비용이 커진다
- 기본 공개 API로는 명시적 필터 파라미터가 더 낫다
+202
View File
@@ -0,0 +1,202 @@
# Request / Response DTO 예시
## 좋은 예시
### 예시 1. request와 response를 명확히 분리한다
```java
public record CreateUserRequest(
@NotBlank String email,
@NotBlank String password,
@NotBlank String displayName
) {
}
public record CreateUserResponse(
String userId,
String email,
String displayName
) {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserCommandController {
private final RegisterUserUseCase registerUserUseCase;
@PostMapping
public ApiResult<CreateUserResponse> register(@Valid @RequestBody CreateUserRequest request) {
RegisteredUser registeredUser = registerUserUseCase.register(
request.email(),
request.password(),
request.displayName()
);
return ApiResult.success(new CreateUserResponse(
registeredUser.userId(),
registeredUser.email(),
registeredUser.displayName()
));
}
}
```
**좋은 이유:**
- request와 response 역할이 분리된다
- request DTO가 그대로 내부 모델처럼 전파되지 않는다
- 응답이 entity 구조가 아니라 API 계약 중심으로 표현된다
### 예시 2. query/form 입력은 전용 @ModelAttribute DTO로 받는다
```java
public record UserSearchRequest(
@NotBlank String keyword,
@Min(1) int page,
@Min(1) @Max(100) int size
) {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserQueryController {
private final SearchUsersUseCase searchUsersUseCase;
@GetMapping
public ApiResult<UserSearchResponse> search(@Valid @ModelAttribute UserSearchRequest request) {
UserSearchResult result = searchUsersUseCase.search(
request.keyword(),
request.page(),
request.size()
);
return ApiResult.success(UserSearchResponse.from(result));
}
}
```
**좋은 이유:**
- query 입력도 전용 web model로 분리된다
- @ModelAttribute 대상이 domain/entity가 아니다
- 검색 조건과 응답 모델이 분리된다
### 예시 3. ResponseEntity는 HTTP 제어가 필요할 때만 사용한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserCommandController {
private final RegisterUserUseCase registerUserUseCase;
@PostMapping
public ResponseEntity<ApiResult<CreateUserResponse>> register(
@Valid @RequestBody CreateUserRequest request
) {
RegisteredUser registeredUser = registerUserUseCase.register(
request.email(),
request.password(),
request.displayName()
);
CreateUserResponse response = new CreateUserResponse(
registeredUser.userId(),
registeredUser.email(),
registeredUser.displayName()
);
return ResponseEntity.created(URI.create("/api/users/" + response.userId()))
.body(ApiResult.success(response));
}
}
```
**좋은 이유:**
- 201 Created와 Location 제어가 필요해 ResponseEntity 사용 이유가 분명하다
## 나쁜 예시
### 예시 1. entity를 request body로 직접 받는다
```java
@Entity
public class User {
@Id
private Long id;
private String email;
private String role;
}
@PostMapping("/api/users")
public ApiResult<Void> create(@Valid @RequestBody User user) {
...
}
```
**나쁜 이유:**
- web input model과 persistence/domain model이 섞인다
- 바인딩 범위가 불필요하게 넓다
- API 변경이 entity 구조에 직접 번진다
### 예시 2. request와 response를 하나의 DTO로 재사용한다
```java
public record UserDto(
String userId,
String email,
String password,
String displayName,
String role
) {
}
@PostMapping("/api/users")
public ApiResult<UserDto> create(@RequestBody UserDto request) {
...
}
```
**나쁜 이유:**
- 요청과 응답의 책임이 섞인다
- 응답에 불필요하거나 민감한 필드가 섞이기 쉽다
- write model과 read model이 분리되지 않는다
### 예시 3. DTO를 내부 모델처럼 그대로 넘긴다
```java
@PostMapping("/api/users")
public ApiResult<Void> create(@Valid @RequestBody CreateUserRequest request) {
registerUserUseCase.register(request);
return ApiResult.success(null);
}
```
**나쁜 이유:**
- request DTO가 presentation 경계를 넘어 application 시그니처로 새어 나간다
- 내부 use case가 transport model에 결합된다
### 예시 4. 응답으로 entity를 직접 반환한다
```java
@GetMapping("/api/users/{userId}")
public ApiResult<User> get(@PathVariable Long userId) {
User user = userRepository.findById(userId).orElseThrow();
return ApiResult.success(user);
}
```
**나쁜 이유:**
- persistence/domain 구조가 외부 계약이 된다
- 내부 필드가 의도치 않게 노출되기 쉽다
- controller가 repository와 entity에 직접 결합된다
+233
View File
@@ -0,0 +1,233 @@
# Response Format 예시
## 좋은 예시
### 예시 1. 일반 성공 응답은 ApiResult<T>로 반환한다
```java
public record UserResponse(
String userId,
String email,
String displayName
) {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserQueryController {
private final UserQueryUseCase userQueryUseCase;
@GetMapping("/{userId}")
public ApiResult<UserResponse> getUser(@PathVariable String userId) {
UserResult result = userQueryUseCase.getUser(userId);
return ApiResult.success(new UserResponse(
result.userId(),
result.email(),
result.displayName()
));
}
}
```
**좋은 이유:**
- 성공 응답 형식이 명확하다
- business payload와 공통 envelope가 분리된다
- controller가 임시 JSON을 조립하지 않는다
### 예시 2. HTTP 제어가 필요할 때만 ResponseEntity<ApiResult<T>>를 사용한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserCommandController {
private final RegisterUserUseCase registerUserUseCase;
@PostMapping
public ResponseEntity<ApiResult<CreateUserResponse>> register(
@Valid @RequestBody CreateUserRequest request
) {
RegisteredUser result = registerUserUseCase.register(
request.email(),
request.password(),
request.displayName()
);
CreateUserResponse response = new CreateUserResponse(
result.userId(),
result.email(),
result.displayName()
);
return ResponseEntity.created(URI.create("/api/users/" + response.userId()))
.body(ApiResult.success(response));
}
}
```
**좋은 이유:**
- ResponseEntity 사용 이유가 201 Created + Location으로 분명하다
- body 형식은 여전히 프로젝트 표준을 따른다
### 예시 3. 실패 응답은 advice에서 ApiResult로 통일한다
```java
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
MethodArgumentNotValidException ex
) {
Map<String, String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toUnmodifiableMap(
FieldError::getField,
DefaultMessageSourceResolvable::getDefaultMessage,
(first, second) -> first
));
return ResponseEntity.badRequest()
.body(ApiResult.fail(ErrorCode.REQUEST_VALIDATION_FAILED, errors));
}
}
```
**좋은 이유:**
- 예외 응답 형식이 중앙에서 통일된다
- controller가 실패 body를 직접 조립하지 않는다
- 상세 오류 정보가 규칙적으로 담긴다
### 예시 4. 전역 응답 래핑은 이중 래핑을 피한다
```java
@RestControllerAdvice
public class ApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<?> converterType) {
Class<?> parameterType = returnType.getParameterType();
return !ApiResult.class.isAssignableFrom(parameterType)
&& !ResponseEntity.class.isAssignableFrom(parameterType)
&& !Resource.class.isAssignableFrom(parameterType);
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response
) {
if (body == null) {
return ApiResult.success(null);
}
if (body instanceof ApiResult<?>) {
return body;
}
return ApiResult.success(body);
}
}
```
**좋은 이유:**
- 공통 envelope 적용 지점이 분명하다
- 이미 래핑된 응답을 다시 감싸지 않는다
- file/resource 응답을 무심코 건드리지 않는다
## 나쁜 예시
### 예시 1. controller마다 임시 응답 구조를 만든다
```java
@GetMapping("/api/users/{userId}")
public Map<String, Object> getUser(@PathVariable String userId) {
UserResult result = userQueryUseCase.getUser(userId);
Map<String, Object> response = new LinkedHashMap<>();
response.put("ok", true);
response.put("payload", result);
return response;
}
```
**나쁜 이유:**
- 프로젝트 공통 응답 형식을 깨뜨린다
- 다른 endpoint와 구조가 달라진다
- 임시 필드명이 계약이 되어 버린다
### 예시 2. ResponseEntity를 의미 없이 남발한다
```java
@GetMapping("/api/health")
public ResponseEntity<ApiResult<String>> health() {
return ResponseEntity.ok(ApiResult.success("ok"));
}
```
**나쁜 이유:**
- 별도 header/status 제어가 없다
- 불필요한 ceremony만 늘어난다
### 예시 3. 실패 응답에 내부 예외 메시지를 그대로 노출한다
```java
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResult<Void>> handle(Exception ex) {
return ResponseEntity.internalServerError()
.body(ApiResult.fail("INTERNAL_SERVER_ERROR", ex.getMessage()));
}
```
**나쁜 이유:**
- 내부 메시지가 외부 계약이 된다
- 민감한 구현 세부사항이 노출될 수 있다
- 외부 응답 메시지 정책이 없다
### 예시 4. ResponseBodyAdvice에서 무조건 감싼다
```java
@RestControllerAdvice
public class BadApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<?> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response
) {
return ApiResult.success(body);
}
}
```
**나쁜 이유:**
- 이미 ApiResult인 응답도 이중 래핑한다
- 파일/리소스/스트리밍 응답을 망가뜨릴 수 있다
- 규약 적용이 아니라 무차별 변환이 된다
+234
View File
@@ -0,0 +1,234 @@
# Serialization / Jackson 예시
## 좋은 예시
### 예시 1. request/response DTO를 분리하고 timestamp는 offset 기반으로 노출한다
```java
public record CreateSessionRequest(
@NotBlank String email,
@NotBlank String password
) {
}
public record CreateSessionResponse(
String sessionId,
String accessToken,
OffsetDateTime issuedAt,
OffsetDateTime expiresAt
) {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/sessions")
public class SessionCommandController {
private final CreateSessionUseCase createSessionUseCase;
@PostMapping
public ApiResult<CreateSessionResponse> create(
@Valid @RequestBody CreateSessionRequest request
) {
SessionResult result = createSessionUseCase.create(request.email(), request.password());
return ApiResult.success(new CreateSessionResponse(
result.sessionId(),
result.accessToken(),
result.issuedAt(),
result.expiresAt()
));
}
}
```
**좋은 이유:**
- request/response 계약이 분리된다
- password는 응답 DTO에 존재하지 않는다
- timestamp가 OffsetDateTime으로 명확하다
### 예시 2. 외부 공급자 webhook DTO만 lenient하게 받는다
```java
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExternalAuthWebhookRequest(
String eventId,
String eventType,
String subjectId
) {
}
```
**좋은 이유:**
- 외부 공급자가 필드를 추가해도 파싱이 덜 깨진다
- lenient 정책이 third-party integration DTO로 국소화된다
- first-party API request DTO와 기준이 분리된다
### 예시 3. 외부 계약 이름 mismatch만 @JsonProperty로 보정한다
```java
public record ExternalUserResponse(
@JsonProperty("user_id") String userId,
@JsonProperty("display_name") String displayName
) {
}
```
**좋은 이유:**
- 내부 표준 naming을 전체 프로젝트에 퍼뜨리지 않는다
- mismatch를 DTO 경계에서 해결한다
### 예시 4. write-only 필드는 예외적으로만 사용한다
```java
public record ResetPasswordCommandRequest(
@NotBlank String userId,
@NotBlank @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String newPassword
) {
}
```
**좋은 이유:**
- 입력만 받고 다시 내보내면 안 되는 필드를 제한적으로 막는다
- 그래도 request DTO 안에 국소화돼 있다
### 예시 5. 공통 직렬화 예외는 전역 컴포넌트로 등록한다
```java
@Configuration
public class JacksonConfig {
@Bean
Module userIdModule() {
SimpleModule module = new SimpleModule();
module.addSerializer(UserId.class, new JsonSerializer<>() {
@Override
public void serialize(UserId value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeString(value.value());
}
});
return module;
}
}
```
**좋은 이유:**
- 반복되는 값 객체 직렬화를 전역 정책으로 올린다
- controller나 DTO마다 같은 로직을 복붙하지 않는다
## 나쁜 예시
### 예시 1. entity를 그대로 응답으로 내보낸다
```java
@Entity
public class User {
@Id
private Long id;
private String email;
private String password;
@ManyToOne(fetch = FetchType.LAZY)
private Organization organization;
}
@GetMapping("/api/v1/users/{id}")
public ApiResult<User> getUser(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
return ApiResult.success(user);
}
```
**나쁜 이유:**
- persistence 모델이 외부 계약이 된다
- 민감 필드와 lazy relation 노출 위험이 있다
- API shape가 entity 구조에 끌려간다
### 예시 2. controller에서 로컬 ObjectMapper를 만든다
```java
@GetMapping("/api/v1/users/{id}")
public String getUser(@PathVariable Long id) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(userService.getUser(id));
}
```
**나쁜 이유:**
- 전역 Jackson 규칙을 우회한다
- converter, module, naming, time 설정이 끊어진다
- controller 책임이 과도해진다
### 예시 3. public API shape를 @JsonView로 관리한다
```java
public class UserViewModel {
@JsonView(Summary.class)
private String userId;
@JsonView(Summary.class)
private String displayName;
@JsonView(Detail.class)
private String email;
interface Summary {}
interface Detail extends Summary {}
}
@GetMapping("/api/v1/users/{id}")
@JsonView(UserViewModel.Summary.class)
public UserViewModel getUser(@PathVariable String id) {
...
}
```
**나쁜 이유:**
- summary/detail 계약이 DTO 분리 대신 view 규칙에 숨어든다
- public API contract evolution이 읽기 어려워진다
- versioning/응답 shape 관리 수단으로는 과도하게 간접적이다
### 예시 4. first-party request DTO에서 unknown field를 무비판적으로 무시한다
```java
@JsonIgnoreProperties(ignoreUnknown = true)
public record CreateUserRequest(
String email,
String password,
String displayName
) {
}
```
**나쁜 이유:**
- 클라이언트 오타나 잘못된 필드 전송을 조용히 숨길 수 있다
- 우리가 소유한 API 계약이 흐려진다
- strict 정책을 택한 API군과 충돌한다
### 예시 5. null omission을 보기 좋다는 이유만으로 남발한다
```java
@JsonInclude(JsonInclude.Include.NON_NULL)
public record UserResponse(
String userId,
String displayName,
String email,
String phoneNumber
) {
}
```
**나쁜 이유:**
- 필드 omission이 계약 의미를 바꾼다
- 클라이언트가 null과 absent를 구분해야 하는 경우 혼란이 생긴다
- 전역/DTO별 정책이 뒤섞이기 쉽다