init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
# Audit Columns 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 최소 감사 컬럼은 created_at, updated_at을 공통으로 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT current_timestamp,
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT current_timestamp
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 생성/수정 시각을 공통 규칙으로 표준화한다
|
||||
- 두 컬럼 모두 절대 시점 타입을 사용한다
|
||||
- insert 시점 기본값을 DB가 일관되게 채운다
|
||||
|
||||
PostgreSQL은 `CURRENT_TIMESTAMP`를 timestamp column default의 대표 예시로 설명하고, default expression은 row 삽입 시 평가된다고 설명한다. `CURRENT_TIMESTAMP`/`now()`는 transaction start time 의미를 갖는다.
|
||||
|
||||
### 예시 2. updated_at은 DB trigger 하나만 source of truth로 둔다
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION common_set_updated_at()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := current_timestamp;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_users_set_updated_at
|
||||
BEFORE UPDATE ON auth.users
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.* IS DISTINCT FROM NEW.*)
|
||||
EXECUTE FUNCTION common_set_updated_at();
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- `updated_at` 갱신 책임이 DB에 명확하게 모인다
|
||||
- BEFORE ROW trigger가 NEW를 수정해 반환하는 PostgreSQL 공식 모델과 맞다
|
||||
- `WHEN (OLD.* IS DISTINCT FROM NEW.*)`로 실제 변경이 있을 때만 갱신하게 만들 수 있다
|
||||
|
||||
PostgreSQL은 BEFORE row trigger가 NEW를 수정하고 반환할 수 있다고 설명하고, `WHEN (OLD.* IS DISTINCT FROM NEW.*)` 예시도 공식 문서에 제공한다.
|
||||
|
||||
### 예시 3. 주체 컬럼은 애플리케이션 principal 식별자로 둔다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@CreatedBy
|
||||
@Column(name = "created_by")
|
||||
private Long createdBy;
|
||||
|
||||
@LastModifiedBy
|
||||
@Column(name = "updated_by")
|
||||
private Long updatedBy;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- `created_by`, `updated_by`를 DB 연결 계정이 아니라 애플리케이션 actor 식별자로 다룬다
|
||||
- Spring Data JPA의 표준 auditing metadata를 사용한다
|
||||
- 시간 컬럼과 주체 컬럼의 source를 분리할 수 있다
|
||||
|
||||
Spring Data JPA는 `@CreatedBy`, `@LastModifiedBy`를 제공하고, `AuditorAware<T>`가 현재 애플리케이션 principal을 제공하도록 설명한다.
|
||||
|
||||
### 예시 4. 시간과 주체를 필요한 만큼만 선택적으로 둔다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
@Table(name = "login_failures", schema = "auth")
|
||||
public class LoginFailure {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 시간 정보만 필요하면 날짜 감사 컬럼만 써도 된다
|
||||
- Spring Data JPA는 시간 추적만 하는 경우 `AuditorAware`가 필수가 아니라고 설명한다
|
||||
- 모든 테이블에 주체 컬럼을 기계적으로 강제하지 않는다
|
||||
|
||||
Spring Data JPA는 생성/수정 날짜만 추적하는 경우 `AuditorAware`가 필요 없다고 설명한다.
|
||||
|
||||
### 예시 5. Hibernate timestamp를 쓴다면 source를 명시적으로 선택한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "sessions", schema = "auth")
|
||||
public class Session {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@CreationTimestamp(source = SourceType.DB)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp(source = SourceType.DB)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Hibernate의 기본 VM source 대신 DB source를 명시적으로 선택한다
|
||||
- 다중 인스턴스 환경에서 시간 source를 DB로 맞추고 싶다는 의도가 드러난다
|
||||
- 같은 컬럼을 또 다른 trigger가 동시에 갱신하지만 않는다면 일관성이 높다
|
||||
|
||||
Hibernate는 `@CreationTimestamp`, `@UpdateTimestamp`가 기본적으로 VM(in memory)에서 생성되지만, `source()`로 변경할 수 있고 `SourceType.DB`는 DB가 값을 생성함을 뜻한다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 감사 컬럼 없이 테이블마다 임의 이름을 쓴다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
reg_dt timestamp,
|
||||
mod_ymd text
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공통 규칙이 없어 해석과 조회가 어려워진다
|
||||
- 수정 시각을 문자열로 저장해 타입 의미가 무너진다
|
||||
- 생성/수정 메타데이터가 표준화되지 않는다
|
||||
|
||||
PostgreSQL은 timestamp/date/time 타입을 제공하고, `CURRENT_TIMESTAMP` 같은 기본 시간 함수도 제공한다. 감사 컬럼은 이런 표준 타입 위에서 일관되게 두는 편이 안전하다.
|
||||
|
||||
### 예시 2. default에 TIMESTAMP 'now' literal을 쓴다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT TIMESTAMP 'now'
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL이 default 절에서는 이 형태를 쓰지 말라고 명시한다
|
||||
- row 삽입 시점이 아니라 table creation 시점으로 고정될 수 있다
|
||||
- `CURRENT_TIMESTAMP` 또는 `now()` 같은 함수형 표현이 맞다
|
||||
|
||||
PostgreSQL은 later evaluation이 필요한 DEFAULT 절에서 `TIMESTAMP 'now'`를 사용하지 말라고 설명한다.
|
||||
|
||||
### 예시 3. 애플리케이션 사용자 식별자를 current_user에 맡긴다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_by text NOT NULL DEFAULT current_user,
|
||||
updated_by text NOT NULL DEFAULT current_user
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- `current_user`는 DB 권한 검사에 쓰이는 사용자다
|
||||
- connection pool, `SET ROLE`, `SECURITY DEFINER` 환경에서는 최종 애플리케이션 사용자와 다를 수 있다
|
||||
- 일반 웹 애플리케이션의 actor 추적 컬럼으로는 부적절하다
|
||||
|
||||
PostgreSQL은 `current_user`가 권한 검사에 쓰이는 사용자이고, `SET ROLE`이나 `SECURITY DEFINER`에 의해 바뀔 수 있다고 설명한다. Spring Data JPA는 이런 경우 현재 principal을 `AuditorAware<T>`로 제공하도록 설계한다.
|
||||
|
||||
### 예시 4. 같은 updated_at을 DB trigger와 ORM이 동시에 관리한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TRIGGER trg_users_set_updated_at
|
||||
BEFORE UPDATE ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION common_set_updated_at();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 컬럼에 두 개의 writer가 생긴다
|
||||
- VM source와 DB source가 섞일 수 있다
|
||||
- 어떤 값이 권위 있는지 흐려지고 디버깅이 어려워진다
|
||||
|
||||
Hibernate는 `@UpdateTimestamp`의 기본 source가 VM이라고 설명하고, PostgreSQL trigger는 NEW row를 수정해 저장값을 바꿀 수 있다고 설명한다. 둘을 같은 컬럼에 동시에 쓰면 source of truth가 모호해진다.
|
||||
|
||||
### 예시 5. created_at을 business code가 직접 덮어쓴다
|
||||
|
||||
```java
|
||||
user.setCreatedAt(OffsetDateTime.now());
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 생성 감사 컬럼이 도메인 로직에서 임의로 바뀔 수 있다
|
||||
- DB default나 프레임워크 auditing 의미를 깨뜨린다
|
||||
- 생성 메타데이터의 불변성이 사라진다
|
||||
|
||||
Hibernate는 `@CreationTimestamp` 필드는 애플리케이션이 직접 설정할 수 없다고 설명한다. 프로젝트도 같은 철학으로, 생성 감사 컬럼을 business code가 임의로 다루지 않게 한다.
|
||||
|
||||
### 예시 6. deleted_at과 version을 기본 감사 컬럼처럼 섞어 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
version bigint NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- `deleted_at`은 soft delete 정책의 일부이고, `version`은 optimistic locking 메타데이터다
|
||||
- 현재 row 감사 컬럼과 다른 목적의 컬럼을 한 범주로 섞는다
|
||||
- 문서 경계와 코드 책임이 흐려진다
|
||||
|
||||
현재 문서 체계에서도 soft delete와 concurrency/version은 별도 주제로 분리되어 있다.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Column Types 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 문자열 기본값은 text, 실제 길이 규칙은 varchar(n)
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email varchar(320) NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
bio text,
|
||||
created_at timestamp with time zone NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 이메일은 실제 길이 제한을 반영했다
|
||||
- 자유 텍스트는 text
|
||||
- 시점은 timestamptz
|
||||
- 생성 키는 identity를 사용한다
|
||||
|
||||
### 예시 2. 금액은 numeric, 공개 식별자는 uuid
|
||||
|
||||
```sql
|
||||
CREATE TABLE billing.payments (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
public_id uuid NOT NULL,
|
||||
amount numeric(19,4) NOT NULL,
|
||||
currency varchar(3) NOT NULL,
|
||||
paid_at timestamp with time zone
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 정밀도가 필요한 금액을 numeric 으로 저장한다
|
||||
- UUID를 문자열로 저장하지 않는다
|
||||
- 시간은 시점 의미로 저장한다
|
||||
|
||||
### 예시 3. 외부 payload 보관은 jsonb
|
||||
|
||||
```sql
|
||||
CREATE TABLE integration.webhook_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
provider varchar(50) NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
received_at timestamp with time zone NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 반정형 외부 payload 저장 용도에 맞다
|
||||
- 나중에 검색/인덱싱 여지도 있다
|
||||
- 원문 텍스트 보존이 핵심이 아니라면 jsonb 가 더 실용적이다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 모든 문자열을 varchar(255) 로 통일한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
email varchar(255) NOT NULL,
|
||||
display_name varchar(255) NOT NULL,
|
||||
bio varchar(255)
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 길이 규칙을 표현하지 못한다
|
||||
- 자유 텍스트까지 임의 길이로 잘라 버린다
|
||||
- PostgreSQL은 text 와 varchar 사이에 일반 성능 이점이 없다고 설명한다
|
||||
|
||||
### 예시 2. 금액을 부동소수점으로 저장한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE billing.payments (
|
||||
amount double precision NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- double precision 은 부정확한 floating-point 타입이다
|
||||
- 금액/정산에 부적절하다
|
||||
|
||||
### 예시 3. UUID를 문자열에 저장한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.sessions (
|
||||
session_id varchar(36) NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL이 네이티브 uuid 타입을 제공하는데 활용하지 않는다
|
||||
- 문자열 유효성/연산/저장 의미가 흐려진다
|
||||
|
||||
### 예시 4. 시점을 로컬 datetime처럼 저장한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.audit_logs (
|
||||
created_at timestamp NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 절대 시점인지 로컬 시각인지 의미가 불명확하다
|
||||
- 일반적인 생성/수정 시각은 timestamp with time zone 이 더 안전하다
|
||||
@@ -0,0 +1,279 @@
|
||||
# DB Concurrency 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 동시에 수정될 수 있는 aggregate root에는 @Version을 둔다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
@Column(name = "display_name", nullable = false)
|
||||
private String displayName;
|
||||
|
||||
public void changeDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 같은 사용자를 여러 요청이 동시에 수정할 때 conflicting update를 감지할 수 있다
|
||||
- JPA 표준 메커니즘이라 provider portability가 높다
|
||||
- 숫자형 version은 timestamp보다 기본값으로 더 신뢰하기 쉽다
|
||||
|
||||
Jakarta Persistence는 version 필드가 있는 엔티티에 대해 provider가 자동 optimistic locking을 수행해야 한다고 설명하고, Hibernate는 `@Version`이 lost update를 막는 기본 메커니즘이라고 설명합니다.
|
||||
|
||||
### 예시 2. 상태 전이는 조건부 UPDATE 한 번으로 처리한다
|
||||
|
||||
```sql
|
||||
UPDATE billing.payments
|
||||
SET status = 'CONFIRMED',
|
||||
confirmed_at = now()
|
||||
WHERE id = :paymentId
|
||||
AND status = 'PENDING'
|
||||
RETURNING id, status, confirmed_at;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- PENDING일 때만 확정된다
|
||||
- 선조회와 후행 update를 분리하지 않아 경쟁 상태를 줄인다
|
||||
- 반환 row가 없으면 이미 다른 트랜잭션이 상태를 바꿨다고 해석할 수 있다
|
||||
|
||||
PostgreSQL은 concurrent update 뒤 WHERE 조건을 다시 평가하고, `UPDATE ... RETURNING`으로 실제 반영된 row를 바로 받을 수 있다고 설명합니다.
|
||||
|
||||
### 예시 3. 중복 생성은 UNIQUE + ON CONFLICT로 닫는다
|
||||
|
||||
```sql
|
||||
CREATE TABLE integration.webhook_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
provider text NOT NULL,
|
||||
provider_event_id text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
CONSTRAINT uq_webhook_events__provider_event_id
|
||||
UNIQUE (provider, provider_event_id)
|
||||
);
|
||||
|
||||
INSERT INTO integration.webhook_events (provider, provider_event_id, payload)
|
||||
VALUES (:provider, :eventId, CAST(:payload AS jsonb))
|
||||
ON CONFLICT (provider, provider_event_id) DO NOTHING
|
||||
RETURNING id;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 중복 webhook 반영을 애플리케이션 선조회가 아니라 DB 제약으로 닫는다
|
||||
- high concurrency에서도 중복 row가 생기지 않는다
|
||||
- 성공 여부를 RETURNING 결과 존재로 판정할 수 있다
|
||||
|
||||
PostgreSQL은 제약 위반 시 저장을 막고, `ON CONFLICT`가 high concurrency에서도 atomic outcome을 제공한다고 설명합니다.
|
||||
|
||||
### 예시 4. create-or-update는 UPSERT로 처리한다
|
||||
|
||||
```sql
|
||||
INSERT INTO auth.login_failures (user_id, fail_count, last_failed_at)
|
||||
VALUES (:userId, 1, now())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE
|
||||
SET fail_count = auth.login_failures.fail_count + 1,
|
||||
last_failed_at = EXCLUDED.last_failed_at
|
||||
RETURNING user_id, fail_count, last_failed_at;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- "없으면 생성, 있으면 누적 갱신"을 한 문장으로 처리한다
|
||||
- 경쟁 상태에서 insert/update 사이가 찢어지지 않는다
|
||||
- 원자적 upsert semantics를 그대로 활용한다
|
||||
|
||||
PostgreSQL은 `ON CONFLICT DO UPDATE`가 atomic한 insert-or-update outcome을 보장한다고 설명합니다.
|
||||
|
||||
### 예시 5. 낙관적 락 실패는 유스케이스 실패로 올린다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void changeDisplayName(Long userId, String newName) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(UserNotFoundException::new);
|
||||
|
||||
user.changeDisplayName(newName);
|
||||
|
||||
entityManager.flush();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- optimistic lock 충돌을 트랜잭션 후반 commit 시점이 아니라, 서비스 내부에서 더 빨리 드러나게 할 수 있다
|
||||
- 실패를 조기에 감지하고 응답 정책을 결정하기 쉽다
|
||||
- 예외를 숨기지 않고 유스케이스 실패로 처리한다
|
||||
|
||||
Jakarta Persistence는 optimistic lock check가 commit 시점까지 지연될 수 있고, 더 일찍 처리해야 하면 `flush()`를 사용할 수 있다고 설명합니다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. version 없이 마지막 커밋이 이기게 둔다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Column(name = "display_name", nullable = false)
|
||||
private String displayName;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 두 요청이 같은 사용자를 수정하면 나중에 commit한 요청이 앞선 변경을 덮어쓸 수 있다
|
||||
- detached merge나 동시 수정에서 lost update 위험이 크다
|
||||
- 표준 optimistic locking 보호가 없다
|
||||
|
||||
Jakarta Persistence는 version이 없는 엔티티는 애플리케이션이 직접 일관성을 책임져야 하고, version을 쓰지 않으면 lost update와 inconsistent state를 초래할 수 있다고 설명합니다.
|
||||
|
||||
### 예시 2. 먼저 읽고, 자바에서 검사한 뒤, 별도 UPDATE를 날린다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void confirmPayment(Long paymentId) {
|
||||
Payment payment = paymentRepository.findById(paymentId)
|
||||
.orElseThrow(PaymentNotFoundException::new);
|
||||
|
||||
if (payment.getStatus() != PaymentStatus.PENDING) {
|
||||
throw new IllegalStateException("not pending");
|
||||
}
|
||||
|
||||
payment.confirm();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 PENDING row를 두 트랜잭션이 동시에 읽고 둘 다 confirm하려고 할 수 있다
|
||||
- 버전 락이나 조건부 update가 없으면 경쟁 상태를 막기 어렵다
|
||||
- 상태 전이 조건이 DB 최종 판정이 아니라 애플리케이션 선조회에만 의존한다
|
||||
|
||||
PostgreSQL은 concurrent update 시 row를 다시 적용하고 WHERE를 재평가한다고 설명하므로, 이런 read-then-act 패턴보다 단일 조건부 mutation이 더 안전합니다.
|
||||
|
||||
### 예시 3. 중복 생성 방지를 선조회로만 처리한다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void registerWebhookEvent(String provider, String eventId, String payload) {
|
||||
boolean exists = webhookEventRepository.existsByProviderAndProviderEventId(provider, eventId);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
webhookEventRepository.save(new WebhookEvent(provider, eventId, payload));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 두 트랜잭션이 동시에 exists = false를 볼 수 있다
|
||||
- UNIQUE 제약이 없으면 중복 row가 실제로 저장될 수 있다
|
||||
- 선조회는 보조일 수 있어도, 최종 정합성 보장 수단이 아니다
|
||||
|
||||
PostgreSQL은 제약이 저장 시점의 위반을 막는다고 설명합니다. 이런 문제는 UNIQUE 없이 안전하지 않습니다.
|
||||
|
||||
### 예시 4. OptimisticLockException을 잡아서 무시한다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void changeDisplayName(Long userId, String newName) {
|
||||
try {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(UserNotFoundException::new);
|
||||
user.changeDisplayName(newName);
|
||||
entityManager.flush();
|
||||
} catch (OptimisticLockException ignored) {
|
||||
// 무시
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 현재 트랜잭션은 이미 rollback 대상으로 표시될 수 있다
|
||||
- 유스케이스 실패를 숨기고 잘못된 성공처럼 보이게 만든다
|
||||
- 재시도 여부는 상위 application boundary에서 다시 판단해야 한다
|
||||
|
||||
Jakarta Persistence는 optimistic lock failure 시 `OptimisticLockException`을 던지고 현재 트랜잭션을 rollback 대상으로 표시해야 한다고 규정합니다.
|
||||
|
||||
### 예시 5. 핵심 필드를 optimistic lock에서 제외한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class Payment {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@OptimisticLock(excluded = true)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Version
|
||||
private Long version;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 금액처럼 핵심 비즈니스 필드를 제외하면 concurrent overwrite를 받아들인다는 뜻이 된다
|
||||
- 이런 필드는 lost update를 허용하면 안 된다
|
||||
- `excluded=true`는 부수적 카운터 같은 제한된 경우에만 의미가 있다
|
||||
|
||||
Hibernate는 `excluded` 속성이 version 증가를 막아 lost update를 수용할 수 있는 필드에만 써야 함을 예시로 설명합니다.
|
||||
|
||||
### 예시 6. unique violation을 무한 재시도한다
|
||||
|
||||
```java
|
||||
while (true) {
|
||||
try {
|
||||
userRepository.save(user);
|
||||
break;
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// 계속 재시도
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- unique violation은 transient concurrency일 수도 있지만, 영구적인 비즈니스 오류일 수도 있다
|
||||
- 원인을 구분하지 않으면 무한 루프나 불필요한 부하를 만든다
|
||||
- 재시도는 전체 유스케이스와 에러 코드 의미를 보고 제한적으로만 해야 한다
|
||||
|
||||
PostgreSQL은 23505가 때로는 concurrency 실패일 수 있지만, 항상 그런 것은 아니므로 더 신중해야 한다고 설명합니다.
|
||||
|
||||
### 예시 7. 단순 중복 생성 문제를 곧바로 FOR UPDATE로 푼다
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM auth.users
|
||||
WHERE email = :email
|
||||
FOR UPDATE;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 중복 생성 방지의 기본 문제는 uniqueness인데, row lock을 먼저 가져가면 설계가 과도하게 무거워진다
|
||||
- 아직 row가 없는 경우에는 lock으로도 해결되지 않는다
|
||||
- 이런 문제는 보통 unique constraint와 `ON CONFLICT`가 더 직접적이다
|
||||
|
||||
PostgreSQL은 `FOR UPDATE`가 기존 row를 잠그는 수단이라고 설명합니다. "존재하지 않는 row의 uniqueness"는 제약이 더 적절합니다.
|
||||
@@ -0,0 +1,286 @@
|
||||
# Index 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 자주 조회되는 FK에 단일 컬럼 B-tree 인덱스를 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE auth.sessions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
user_id bigint NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_sessions PRIMARY KEY (id),
|
||||
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
|
||||
REFERENCES auth.users (id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX ix_sessions__user_id ON auth.sessions (user_id);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- FK는 참조 무결성을 보장하지만, 참조하는 쪽 인덱스는 자동 생성되지 않는다
|
||||
- 세션을 사용자 기준으로 자주 조회하거나, 사용자 삭제 시 자식 세션을 찾는 경로에 도움이 된다
|
||||
- 단순하고 신뢰도 높은 기본 패턴이다
|
||||
|
||||
이 예시는 PostgreSQL의 FK 동작과 B-tree 기본 사용 패턴에 맞는 안전한 best practice다.
|
||||
|
||||
### 예시 2. 복합 검색 패턴에는 왼쪽 컬럼 순서를 고려한 멀티 컬럼 인덱스를 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE ordering.orders (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
tenant_id bigint NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_orders PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_orders__tenant_status_created_at
|
||||
ON ordering.orders (tenant_id, status, created_at DESC);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- tenant_id = ? AND status = ? AND created_at < ? ORDER BY created_at DESC 같은 패턴에 잘 맞는다
|
||||
- equality 필터를 왼쪽에 두고, range/정렬 컬럼을 뒤에 둔 전형적인 B-tree 설계다
|
||||
- 멀티 컬럼 인덱스가 실제 쿼리 패턴에 직접 대응한다
|
||||
|
||||
PostgreSQL은 B-tree 멀티 컬럼 인덱스가 leading columns 제약에 가장 효율적이라고 설명한다.
|
||||
|
||||
### 예시 3. soft delete 활성 행만 자주 조회되면 partial index를 사용한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
tenant_id bigint NOT NULL,
|
||||
email text NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_users__tenant_id_email__active
|
||||
ON auth.users (tenant_id, email)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 전체 사용자보다 “삭제되지 않은 사용자”만 자주 조회되는 경우에 맞는 설계다
|
||||
- hot subset만 인덱싱하므로 전체 인덱스보다 더 작고 유지 비용도 낮을 수 있다
|
||||
- predicate가 단순하고 쿼리와 맞추기 쉽다
|
||||
|
||||
PostgreSQL 공식 문서도 partial index를 이런 subset 최적화에 쓰도록 설명한다.
|
||||
|
||||
### 예시 4. 대소문자 무시 검색은 expression index로 맞춘다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
email text NOT NULL,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_users__lower_email
|
||||
ON auth.users (lower(email));
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 검색과 고유성 규칙이 모두 lower(email) 의미에 맞춰져 있다
|
||||
- 애플리케이션에서 임시 변환만 하는 것보다 DB 규칙이 더 명확하다
|
||||
- 표현식 인덱스의 대표적인 안전 사용 사례다
|
||||
|
||||
PostgreSQL은 lower(col) 같은 expression index를 공식 지원하고, unique expression index로 단순 unique constraint로 표현하기 어려운 규칙도 강제할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. 목록 조회 최적화가 확실하면 INCLUDE를 보수적으로 사용한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE ordering.orders (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
tenant_id bigint NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
total_amount numeric(19,4) NOT NULL,
|
||||
CONSTRAINT pk_orders PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_orders__tenant_status_created_at
|
||||
ON ordering.orders (tenant_id, status, created_at DESC)
|
||||
INCLUDE (total_amount);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 자주 실행되는 목록 조회가 tenant_id, status, created_at 기준으로 필터/정렬되고, 응답에는 total_amount가 필요할 때 유효하다
|
||||
- total_amount는 non-key payload라 uniqueness/탐색 키 의미를 어지럽히지 않는다
|
||||
- 작은 payload 컬럼만 추가한 보수적 covering index다
|
||||
|
||||
PostgreSQL은 INCLUDE가 index-only scan을 돕지만, 인덱스 크기를 키우므로 보수적으로 사용해야 한다고 설명한다.
|
||||
|
||||
### 예시 6. jsonb 전체 containment 검색은 GIN을 검토한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE integration.webhook_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
payload jsonb NOT NULL,
|
||||
received_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_webhook_events PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_webhook_events__payload_gin
|
||||
ON integration.webhook_events
|
||||
USING GIN (payload);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- payload @> ..., key existence, jsonpath 검색 같은 jsonb 검색 패턴에 맞는다
|
||||
- B-tree로 해결할 수 없는 composite value 내부 검색을 GIN으로 처리한다
|
||||
- jsonb를 유지해야 하는 상황에서 가장 전형적인 공식 패턴이다
|
||||
|
||||
PostgreSQL은 jsonb 검색에 GIN을 사용할 수 있고, key/key-value search 및 containment에 적합하다고 설명한다.
|
||||
|
||||
### 예시 7. 대형 append-only 로그는 BRIN을 검토한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit.audit_logs (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
occurred_at timestamp with time zone NOT NULL,
|
||||
actor_id bigint,
|
||||
action text NOT NULL,
|
||||
CONSTRAINT pk_audit_logs PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_audit_logs__occurred_at_brin
|
||||
ON audit.audit_logs
|
||||
USING BRIN (occurred_at);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 아주 큰 로그 테이블에서 occurred_at이 물리 저장 순서와 자연스럽게 상관될 가능성이 높다
|
||||
- BRIN은 작은 인덱스로 큰 범위를 건너뛸 수 있다
|
||||
- append-only 성격이 강한 로그/이력성 테이블과 잘 맞는다
|
||||
|
||||
PostgreSQL은 BRIN이 매우 큰 테이블과 물리 순서 상관성이 있는 컬럼에 적합하다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. PK가 이미 만든 인덱스를 다시 만든다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
email text NOT NULL,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_users__id ON auth.users (id);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PK가 이미 unique B-tree 인덱스를 자동 생성한다
|
||||
- 같은 의미의 중복 인덱스라 쓰기 비용과 저장 비용만 늘린다
|
||||
- 운영상 관리 포인트만 증가한다
|
||||
|
||||
PostgreSQL 공식 문서는 PK/UNIQUE 제약이 자동으로 인덱스를 만든다고 설명한다.
|
||||
|
||||
### 예시 2. 실제 쿼리와 무관하게 긴 멀티 컬럼 인덱스를 만든다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_orders__tenant_status_type_created_at_updated_at
|
||||
ON ordering.orders (tenant_id, status, type, created_at, updated_at);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 어떤 쿼리를 위한 인덱스인지 설명하기 어렵다
|
||||
- 멀티 컬럼이 길어질수록 유지 비용이 커지고 활용 범위도 애매해진다
|
||||
- 대부분의 경우 단일/짧은 복합 인덱스 조합이 더 낫다
|
||||
|
||||
PostgreSQL도 멀티 컬럼 인덱스는 신중히 사용해야 하고, 3개를 넘는 경우는 드물게만 유효하다고 설명한다.
|
||||
|
||||
### 예시 3. 단일 컬럼 DESC 인덱스를 습관적으로 만든다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_orders__created_at_desc
|
||||
ON ordering.orders (created_at DESC);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 단일 컬럼 B-tree는 backward scan이 가능하므로 대개 별도 DESC 인덱스 이점이 없다
|
||||
- mixed ordering이 아닌데도 특수 정렬을 도입해 관리 복잡도만 올린다
|
||||
- 실제 쿼리 근거가 부족하다
|
||||
|
||||
PostgreSQL은 ordered scan을 forward/backward 모두 지원하고, 단일 컬럼에서는 별도 DESC 인덱스가 대체로 유용하지 않다고 설명한다.
|
||||
|
||||
### 예시 4. partial index predicate를 쿼리와 다르게 만든다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_users__active_recent
|
||||
ON auth.users (tenant_id, email)
|
||||
WHERE deleted_at IS NULL AND last_login_at > now() - interval '30 days';
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- now() 같은 시간 의존 조건은 안정적인 partial index predicate로 부적절하다
|
||||
- 쿼리와 predicate가 정확히 맞지 않으면 planner가 인덱스를 잘 쓰지 못한다
|
||||
- 데이터 분포와 시간 경계가 계속 바뀌므로 유지 신뢰도가 낮다
|
||||
|
||||
PostgreSQL은 partial index predicate가 planner가 인식 가능한 형태여야 한다고 설명한다.
|
||||
|
||||
### 예시 5. 큰 payload를 INCLUDE에 넣는다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_posts__author_created_at
|
||||
ON board.posts (author_id, created_at DESC)
|
||||
INCLUDE (content, metadata_json);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 큰 text/json payload는 인덱스를 크게 부풀린다
|
||||
- 인덱스 tuple 크기 제한에 걸릴 수 있고, 검색 자체도 느려질 수 있다
|
||||
- INCLUDE는 작은 응답용 컬럼에만 보수적으로 써야 한다
|
||||
|
||||
PostgreSQL은 wide non-key column을 INCLUDE에 넣는 것을 보수적으로 하라고 명시한다.
|
||||
|
||||
### 예시 6. 운영 중 대형 테이블에 일반 CREATE INDEX를 바로 실행한다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_events__occurred_at
|
||||
ON audit.audit_logs (occurred_at);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 일반 CREATE INDEX는 빌드 동안 writes를 막는다
|
||||
- 운영 중 대형 테이블에서는 서비스 영향이 매우 클 수 있다
|
||||
- 이런 경우는 CREATE INDEX CONCURRENTLY 여부를 먼저 검토해야 한다
|
||||
|
||||
PostgreSQL 공식 문서도 일반 index build는 writes를 block하고, 운영 환경에서는 종종 unacceptable하다고 설명한다.
|
||||
|
||||
### 예시 7. jsonb 키 검색에 B-tree를 건다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_webhook_events__payload
|
||||
ON integration.webhook_events (payload);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- jsonb @>, ?, @?, @@ 같은 검색은 B-tree 기본 패턴과 맞지 않는다
|
||||
- composite value 내부 검색은 GIN 같은 전용 접근 방식이 더 적합하다
|
||||
- 타입/연산자 특성을 무시한 설계다
|
||||
|
||||
PostgreSQL은 jsonb key/key-value 검색에 GIN을 공식적으로 권장 가능한 접근 방식으로 설명한다.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Transaction Isolation 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 일반적인 서비스 로직은 기본값을 따른다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserCommandService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional
|
||||
public void changeDisplayName(Long userId, String newName) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(UserNotFoundException::new);
|
||||
|
||||
user.changeDisplayName(newName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring 기본값인 `Isolation.DEFAULT`를 사용한다
|
||||
- PostgreSQL에서는 보통 `READ COMMITTED`가 적용된다
|
||||
- 일반적인 단건 수정 유스케이스에 과도한 isolation을 강제하지 않는다
|
||||
|
||||
Spring은 `@Transactional` 기본 isolation이 `ISOLATION_DEFAULT`라고 설명하고, PostgreSQL은 기본 isolation이 보통 `READ COMMITTED`라고 설명한다.
|
||||
|
||||
### 예시 2. 같은 트랜잭션 안에서 stable snapshot이 필요하면 REPEATABLE_READ를 검토한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SettlementPreviewService {
|
||||
|
||||
private final SettlementRepository settlementRepository;
|
||||
|
||||
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
|
||||
public SettlementPreview preview(Long merchantId, LocalDate from, LocalDate to) {
|
||||
List<SettlementLine> lines = settlementRepository.findLines(merchantId, from, to);
|
||||
BigDecimal fee = settlementRepository.sumFee(merchantId, from, to);
|
||||
|
||||
return SettlementPreview.of(lines, fee);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 여러 query가 같은 snapshot을 기준으로 계산되길 원할 때 의미가 있다
|
||||
- PostgreSQL의 `REPEATABLE READ`는 트랜잭션 시작 시점 snapshot을 유지한다
|
||||
- 단, 이 설계는 여전히 serialization failure 재시도 필요성을 함께 고려해야 한다
|
||||
|
||||
PostgreSQL은 `REPEATABLE READ`에서 successive SELECT가 같은 snapshot을 보고, serialization failure에 대비해야 한다고 설명한다.
|
||||
|
||||
### 예시 3. cross-row invariant가 중요하면 SERIALIZABLE과 재시도를 함께 둔다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SeatAllocationService {
|
||||
|
||||
private final SeatRepository seatRepository;
|
||||
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public void allocateSeat(Long eventId, Long userId) {
|
||||
if (seatRepository.countAllocated(eventId) >= seatRepository.capacityOf(eventId)) {
|
||||
throw new NoSeatLeftException();
|
||||
}
|
||||
|
||||
seatRepository.insertAllocation(eventId, userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 집합 단위 정합성이 중요한 유스케이스를 명시적으로 serial semantics로 올린다
|
||||
- 단순 snapshot 안정성이 아니라 serialization anomaly 방지가 목적이다
|
||||
- 이 경우 40001 전체 재시도 정책이 같이 있어야 설계가 완성된다
|
||||
|
||||
PostgreSQL은 `SERIALIZABLE`이 serial execution과 같은 효과를 보장하지만, serialization failure가 발생할 수 있으므로 재시도가 필요하다고 설명한다.
|
||||
|
||||
### 예시 4. stronger isolation보다 더 직접적인 수단이 있으면 그쪽을 먼저 쓴다
|
||||
|
||||
```sql
|
||||
UPDATE billing.payments
|
||||
SET status = 'CONFIRMED',
|
||||
confirmed_at = now()
|
||||
WHERE id = :paymentId
|
||||
AND status = 'PENDING';
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 단순 상태 전이는 stronger isolation보다 조건부 UPDATE가 더 직접적이다
|
||||
- `READ COMMITTED`에서도 원자적으로 성공 여부를 판단할 수 있다
|
||||
- isolation level을 과도하게 올리지 않아도 된다
|
||||
|
||||
PostgreSQL은 `READ COMMITTED`에서 concurrent update 시 WHERE 조건이 재평가될 수 있고, 조건부 mutation이 유용하게 동작한다고 설명한다.
|
||||
|
||||
### 예시 5. inner method isolation override를 기대하지 않고 outer boundary에서 선언한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportFacade {
|
||||
|
||||
private final ReportQueryService reportQueryService;
|
||||
|
||||
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
|
||||
public ReportResponse generate(Long reportId) {
|
||||
return reportQueryService.generate(reportId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- isolation을 outer use case boundary에서 선언한다
|
||||
- inner service가 기존 트랜잭션에 참여하면서 의미가 흐려지는 것을 피한다
|
||||
- Spring의 isolation 적용 규칙과 맞다
|
||||
|
||||
Spring은 isolation setting이 새로 시작된 트랜잭션에만 적용되고, 기존 트랜잭션에 참여하는 inner scope의 local isolation은 기본적으로 무시된다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. PostgreSQL에서 READ_UNCOMMITTED를 dirty read 용도로 기대한다
|
||||
|
||||
```java
|
||||
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
|
||||
public User findUser(Long id) {
|
||||
return userRepository.findById(id).orElseThrow();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL에서는 `READ UNCOMMITTED`가 별도 dirty-read 모드로 동작하지 않는다
|
||||
- 실제로는 `READ COMMITTED`처럼 동작한다
|
||||
- 성능이나 동작 의미가 달라질 것이라 기대하면 틀린 설계가 된다
|
||||
|
||||
PostgreSQL은 `READ UNCOMMITTED`가 내부적으로 `READ COMMITTED`처럼 동작한다고 명시한다.
|
||||
|
||||
### 예시 2. READ_COMMITTED에서 같은 트랜잭션 안의 두 조회가 반드시 같을 것이라 가정한다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public boolean canStillShip(Long orderId) {
|
||||
Order order1 = orderRepository.findById(orderId).orElseThrow();
|
||||
|
||||
// 중간에 다른 트랜잭션이 상태를 바꿀 수 있음
|
||||
|
||||
Order order2 = orderRepository.findById(orderId).orElseThrow();
|
||||
return order1.getStatus() == order2.getStatus();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL `READ COMMITTED`에서는 각 query가 자기 시작 시점 snapshot을 본다
|
||||
- 따라서 같은 트랜잭션 안에서도 두 조회 결과가 달라질 수 있다
|
||||
- stable snapshot이 필요한 로직이라면 isolation 또는 더 직접적인 동시성 제어를 다시 설계해야 한다
|
||||
|
||||
PostgreSQL은 `READ COMMITTED`에서 subsequent commands in the same transaction이 committed concurrent transaction의 효과를 보게 된다고 설명한다.
|
||||
|
||||
### 예시 3. REPEATABLE_READ를 쓰면서 serialization failure 재시도를 준비하지 않는다
|
||||
|
||||
```java
|
||||
@Transactional(isolation = Isolation.REPEATABLE_READ)
|
||||
public void runComplexSettlement(Long merchantId) {
|
||||
// 복잡한 다단계 조회/갱신
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL의 `REPEATABLE READ`도 serialization anomaly를 막기 위해 실패할 수 있다
|
||||
- stronger isolation만 올리고 재시도 정책을 설계하지 않으면 운영 시 장애로 이어질 수 있다
|
||||
- "repeatable read니까 실패는 없을 것"이라는 가정이 틀리다
|
||||
|
||||
PostgreSQL은 `REPEATABLE READ`에서도 애플리케이션이 serialization failure 재시도를 준비해야 한다고 설명한다.
|
||||
|
||||
### 예시 4. SERIALIZABLE을 전역 기본값처럼 남발한다
|
||||
|
||||
```java
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public void doAnything() {
|
||||
// 일반 CRUD, 단순 조회, 목록 조회까지 전부 같은 정책
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- serial semantics가 필요하지 않은 경로에도 monitoring overhead와 retry 부담을 강제한다
|
||||
- 더 직접적인 수단으로 닫을 수 있는 문제까지 모두 isolation으로 해결하려 든다
|
||||
- stronger isolation은 증명 가능한 요구가 있을 때만 써야 한다
|
||||
|
||||
PostgreSQL은 `SERIALIZABLE`이 monitoring overhead를 가지며, serialization failure를 일으킬 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. inner method에서 isolation을 바꾸면 outer transaction을 override할 수 있다고 기대한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class OuterService {
|
||||
|
||||
@Transactional
|
||||
public void outer() {
|
||||
inner();
|
||||
}
|
||||
|
||||
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||
public void inner() {
|
||||
// 여기서 isolation이 바뀔 것이라고 기대
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기존 트랜잭션에 참여하면 inner isolation 선언은 기본적으로 무시될 수 있다
|
||||
- 게다가 self-invocation 구조라 transaction advice 자체가 적용되지 않을 수도 있다
|
||||
- isolation override 의도가 코드에 반영되지 않는다
|
||||
|
||||
Spring은 isolation setting이 새 트랜잭션에만 적용되고, 기존 트랜잭션 참여 시 local isolation은 기본적으로 무시된다고 설명한다.
|
||||
|
||||
### 예시 6. sequence 값이 rollback되리라 기대한다
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
INSERT INTO auth.users DEFAULT VALUES;
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- sequence/serial 계열 값은 다른 트랜잭션에 즉시 visible할 수 있고 rollback되지 않는다
|
||||
- gap 없는 연속 번호를 기대하면 안 된다
|
||||
- 이는 isolation 문제가 아니라 PostgreSQL sequence 동작 특성이다
|
||||
|
||||
PostgreSQL은 sequence 변경이 즉시 visible하고 transaction abort 시에도 rollback되지 않는다고 설명한다.
|
||||
@@ -0,0 +1,269 @@
|
||||
# JPA Fetch Strategy 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. to-one 기본값도 명시적으로 LAZY로 바꾼다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "sessions", schema = "auth")
|
||||
public class Session {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private OffsetDateTime expiresAt;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- JPA 기본값인 @ManyToOne EAGER에 기대지 않는다
|
||||
- 세션 조회에서 항상 사용자 전체를 강제로 가져오지 않는다
|
||||
- 필요한 경우 query 또는 entity graph에서만 user를 함께 가져오게 만들 수 있다
|
||||
|
||||
Jakarta Persistence는 @ManyToOne 기본 fetch가 EAGER라고 정의하고, Hibernate는 to-one EAGER 기본값을 그대로 쓰기보다 모든 association을 LAZY로 두는 편을 권장한다.
|
||||
|
||||
### 예시 2. 상세 조회의 to-one 연관은 join fetch로 가져온다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select s
|
||||
from Session s
|
||||
join fetch s.user u
|
||||
where s.id = :id
|
||||
""")
|
||||
Optional<Session> findDetailById(@Param("id") Long id);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 기본 매핑은 LAZY로 두고, 이 상세 조회에서만 user를 함께 가져온다
|
||||
- to-one 연관 fetch join은 Hibernate가 권장하는 대표 패턴이다
|
||||
- use case 단위로 eager 요구를 제어한다
|
||||
|
||||
Hibernate는 join fetch가 laziness를 override하여 같은 SQL join으로 데이터를 가져오게 하며, to-one association에 특히 적합하다고 설명한다.
|
||||
|
||||
### 예시 3. repository 메서드 단위 eager 요구는 EntityGraph로 선언한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@NamedEntityGraph(
|
||||
name = "User.withRoles",
|
||||
attributeNodes = {
|
||||
@NamedAttributeNode("roles")
|
||||
}
|
||||
)
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
// ...
|
||||
}
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
@EntityGraph(value = "User.withRoles", type = EntityGraph.EntityGraphType.FETCH)
|
||||
Optional<User> findByEmail(String email);
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 정적 매핑을 EAGER로 바꾸지 않고, 특정 repository 메서드에서만 roles를 가져온다
|
||||
- FETCH graph로 명시한 속성만 eager 범위에 넣는다
|
||||
- entity graph를 fetch plan template로 사용하는 정석적인 방식이다
|
||||
|
||||
Jakarta Persistence는 fetchgraph와 loadgraph를 공식적으로 지원하고, Spring Data JPA는 repository method에서 @EntityGraph와 동적 attributePaths()를 지원한다.
|
||||
|
||||
### 예시 4. 여러 부모의 같은 LAZY 컬렉션 접근에는 BatchSize를 보조적으로 사용한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 50)
|
||||
private List<UserRole> roles = new ArrayList<>();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 여러 사용자의 roles 컬렉션을 순차적으로 초기화할 때 round trip 수를 줄일 수 있다
|
||||
- join fetch가 항상 적절하지 않은 경우의 보조 최적화다
|
||||
- 기본 전략을 EAGER로 바꾸지 않는다
|
||||
|
||||
Hibernate는 @BatchSize가 여러 uninitialized association을 한 번에 가져오도록 도와주며, N+1보다 낫지만 대개 JOIN FETCH나 DTO projection이 더 좋다고 설명한다.
|
||||
|
||||
### 예시 5. 조회 전용 목록은 DTO projection으로 닫는다
|
||||
|
||||
```java
|
||||
public record UserSummary(Long id, String email, String status) {}
|
||||
|
||||
@Query("""
|
||||
select new com.example.auth.user.UserSummary(u.id, u.email, u.status)
|
||||
from User u
|
||||
where u.deletedAt is null
|
||||
order by u.id desc
|
||||
""")
|
||||
List<UserSummary> findActiveUserSummaries();
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 수정 목적이 아닌 조회에서 엔티티 그래프 전체를 관리하지 않는다
|
||||
- 필요한 컬럼만 선택한다
|
||||
- persistence context 부담을 줄이고 fetch 전략 고민도 단순화한다
|
||||
|
||||
Hibernate는 read-only transaction에는 DTO projection이 더 적합하고, 필요한 컬럼만 조회할 수 있어 persistence context 부담을 줄인다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. to-one 기본값을 그대로 두어 암묵적 EAGER를 만든다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "sessions", schema = "auth")
|
||||
public class Session {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- @ManyToOne 기본값은 EAGER라서, 의도하지 않아도 user를 항상 가져오게 된다
|
||||
- query별 fetch 정책을 세밀하게 제어하기 어려워진다
|
||||
- JPQL에서 fetch join을 빠뜨리면 secondary select로 이어질 수 있다
|
||||
|
||||
Jakarta Persistence는 @ManyToOne 기본 fetch를 EAGER로 정의하고, Hibernate는 이런 EAGER 기본값을 피하라고 권장한다.
|
||||
|
||||
### 예시 2. 여러 컬렉션을 한 query에서 동시에 fetch join한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u
|
||||
from User u
|
||||
left join fetch u.roles
|
||||
left join fetch u.sessions
|
||||
left join fetch u.loginHistories
|
||||
where u.id = :id
|
||||
""")
|
||||
Optional<User> findEverything(@Param("id") Long id);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 여러 to-many를 병렬 fetch join하면 Cartesian product가 발생할 수 있다
|
||||
- row 폭발과 중복으로 성능이 매우 나빠질 수 있다
|
||||
- “한 번에 다 가져오자”는 발상이 오히려 가장 위험하다
|
||||
|
||||
Hibernate는 여러 컬렉션/to-many를 병렬 fetch join하면 Cartesian product가 생겨 매우 나쁜 성능을 낼 수 있다고 명시한다.
|
||||
|
||||
### 예시 3. pagination query에 컬렉션 fetch join을 사용한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u
|
||||
from User u
|
||||
left join fetch u.roles
|
||||
order by u.id desc
|
||||
""")
|
||||
Page<User> findAllWithRoles(Pageable pageable);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 컬렉션 fetch join과 pagination은 충돌하기 쉽다
|
||||
- page boundary가 불안정해지고 row duplication 문제가 생길 수 있다
|
||||
- 목록/페이지 조회는 다른 방식으로 설계해야 한다
|
||||
|
||||
Hibernate는 fetch join을 제한/페이징 query에서 보통 피해야 한다고 설명한다.
|
||||
|
||||
### 예시 4. 지연 로딩 문제를 presentation 계층에서 우연히 해결되길 기대한다
|
||||
|
||||
```java
|
||||
@Transactional(readOnly = true)
|
||||
public User getUser(Long id) {
|
||||
return userRepository.findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
// controller
|
||||
@GetMapping("/users/{id}")
|
||||
public UserResponse getUser(@PathVariable Long id) {
|
||||
User user = userService.getUser(id);
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getRoles().stream().map(UserRole::getName).toList()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- service 트랜잭션이 끝난 뒤 controller에서 lazy 컬렉션 접근을 시도한다
|
||||
- 환경에 따라 LazyInitializationException 또는 우연한 추가 SQL에 의존하게 된다
|
||||
- fetch plan 책임이 service/application 경계 밖으로 새어 나간다
|
||||
|
||||
Hibernate는 필요한 연관은 persistence context가 닫히기 전에 fetch해야 하며, 그렇지 않으면 LazyInitializationException이 발생한다고 설명한다.
|
||||
|
||||
### 예시 5. read-only 목록인데도 엔티티 전체를 억지로 로딩한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u
|
||||
from User u
|
||||
left join fetch u.department
|
||||
left join fetch u.roles
|
||||
where u.deletedAt is null
|
||||
order by u.id desc
|
||||
""")
|
||||
List<User> findAllForAdminList();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 목록 화면에 필요한 필드보다 훨씬 많은 엔티티 상태를 가져오기 쉽다
|
||||
- 관리 대상 엔티티 수와 SQL row 수가 불필요하게 커진다
|
||||
- 이런 경우는 DTO projection이 더 적합할 가능성이 높다
|
||||
|
||||
Hibernate는 read-only transaction에서는 DTO projection이 더 적절하고, 필요한 컬럼만 선택할 수 있다고 설명한다.
|
||||
|
||||
### 예시 6. @BatchSize로 구조 문제를 덮는다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 1000)
|
||||
private List<UserRole> roles;
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 1000)
|
||||
private List<Session> sessions;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 왜 secondary select 구조가 필요한지 설명 없이 숫자만 크게 올린다
|
||||
- batch fetching은 보조 최적화이지 1차 설계 수단이 아니다
|
||||
- query 설계, DTO projection, entity graph 같은 더 직접적인 해법을 가릴 수 있다
|
||||
|
||||
Hibernate도 @BatchSize는 N+1보다 낫지만 대개 JOIN FETCH나 DTO projection이 더 좋은 선택이라고 설명한다.
|
||||
@@ -0,0 +1,232 @@
|
||||
# Lock 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 현재 row를 선점한 뒤 곧바로 상태를 바꾸는 작업은 NOWAIT로 fail-fast 한다
|
||||
|
||||
```sql
|
||||
SELECT id, status
|
||||
FROM billing.payments
|
||||
WHERE id = :paymentId
|
||||
FOR UPDATE NOWAIT;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 같은 결제를 동시에 하나만 처리해야 할 때 즉시 충돌을 드러낸다
|
||||
- 기다림보다 빠른 실패가 더 맞는 사용자 요청에 적합하다
|
||||
- 가장 강한 row lock이 필요한 상황을 명확하게 표현한다
|
||||
|
||||
PostgreSQL은 `NOWAIT`가 락을 즉시 못 잡으면 기다리지 않고 오류를 반환한다고 설명한다. `FOR UPDATE`는 해당 row에 대한 다른 수정과 row lock을 막는다.
|
||||
|
||||
### 예시 2. queue claim은 SKIP LOCKED를 작업 큐에만 제한해서 사용한다
|
||||
|
||||
```sql
|
||||
WITH picked AS (
|
||||
SELECT id
|
||||
FROM integration.outbox_events
|
||||
WHERE status = 'READY'
|
||||
ORDER BY id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 10
|
||||
)
|
||||
UPDATE integration.outbox_events e
|
||||
SET status = 'IN_PROGRESS',
|
||||
claimed_at = now()
|
||||
FROM picked
|
||||
WHERE e.id = picked.id
|
||||
RETURNING e.id;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 여러 worker가 같은 큐를 경쟁 소비할 때 이미 잠긴 row를 건너뛸 수 있다
|
||||
- queue-like workload에 맞는 전형적인 사용법이다
|
||||
- `ORDER BY`와 `LIMIT`를 함께 두어 claim 범위를 결정적으로 만든다
|
||||
|
||||
PostgreSQL은 `SKIP LOCKED`가 inconsistent view를 만들기 때문에 general-purpose work에는 부적합하지만 queue-like table에는 사용할 수 있다고 설명한다. 또한 locking clause와 `LIMIT`를 함께 쓸 수 있고, `ORDER BY` 없는 제한 조회는 예측 가능한 subset을 보장하지 않는다고 설명한다.
|
||||
|
||||
### 예시 3. join query에서는 OF로 실제 lock 대상만 잠근다
|
||||
|
||||
```sql
|
||||
SELECT o.id, o.status
|
||||
FROM ordering.orders o
|
||||
JOIN auth.users u ON u.id = o.user_id
|
||||
WHERE o.id = :orderId
|
||||
FOR UPDATE OF o NOWAIT;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 주문 row만 잠그고, join에 참여한 사용자 row까지 불필요하게 잠그지 않는다
|
||||
- join이 있다고 해서 전체 테이블 범위를 넓게 잠그지 않는다
|
||||
- lock 범위가 SQL만 봐도 명확하다
|
||||
|
||||
PostgreSQL은 locking clause에 table list를 지정하면 해당 테이블에서 나온 row만 잠그고, list를 생략하면 statement에 사용된 모든 테이블에 적용될 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. JPA에서는 특정 단건 처리 메서드에만 PESSIMISTIC_WRITE를 붙인다
|
||||
|
||||
```java
|
||||
public interface PaymentRepository extends JpaRepository<Payment, Long> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select p from Payment p where p.id = :id")
|
||||
Optional<Payment> findForUpdate(@Param("id") Long id);
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 일반 조회 메서드와 락 조회 메서드를 분리한다
|
||||
- lock intent가 repository 메서드에 명시적으로 드러난다
|
||||
- 단건 처리 유스케이스에서만 비관적 락을 올린다
|
||||
|
||||
Spring Data JPA는 query method에 `@Lock`으로 `LockModeType`을 지정할 수 있다고 설명한다. Jakarta Persistence는 `PESSIMISTIC_WRITE`가 즉시 장기 DB 락을 얻는 pessimistic lock mode라고 설명한다.
|
||||
|
||||
### 예시 5. non-key 상태 수정만 예정되어 있으면 SQL 레벨에서 FOR NO KEY UPDATE를 검토한다
|
||||
|
||||
```sql
|
||||
SELECT id, status
|
||||
FROM billing.payments
|
||||
WHERE id = :paymentId
|
||||
FOR NO KEY UPDATE;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 이후 status 같은 non-key 컬럼만 바꿀 예정이라면 `FOR UPDATE`보다 약한 락으로 충분할 수 있다
|
||||
- 필요 이상으로 강한 락을 쓰지 않는다
|
||||
- ordinary update semantics와 더 잘 맞는다
|
||||
|
||||
PostgreSQL은 `FOR NO KEY UPDATE`가 `FOR UPDATE`보다 약하고, key를 바꾸지 않는 일반 UPDATE가 이 수준의 락을 획득한다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 일반 목록 조회에 PESSIMISTIC_WRITE를 건다
|
||||
|
||||
```java
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
Page<User> findAll(Pageable pageable);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 단건 선점이 아니라 넓은 목록 조회에 비관적 락을 건다
|
||||
- 대기, contention, deadlock 가능성을 크게 올린다
|
||||
- locking use case와 plain listing use case가 섞여 있다
|
||||
|
||||
Spring Data JPA는 CRUD/query method에 `@Lock`을 적용할 수 있지만, PostgreSQL row lock은 같은 row에 대한 writer/locker를 막고 대기를 만들 수 있다. 이런 넓은 조회에 쓰면 비용이 과도하다.
|
||||
|
||||
### 예시 2. 일반 사용자 목록 API에 SKIP LOCKED를 사용한다
|
||||
|
||||
```sql
|
||||
SELECT id, email
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 일반 업무 조회에서 잠긴 row를 조용히 건너뛰면 결과 집합 의미가 깨진다
|
||||
- 운영자와 사용자 모두 "왜 어떤 데이터가 안 보였는지" 설명하기 어렵다
|
||||
- `SKIP LOCKED`는 queue-like workload가 아닌 경우 기본적으로 부적절하다
|
||||
|
||||
PostgreSQL은 `SKIP LOCKED`가 inconsistent view를 만들기 때문에 general-purpose work에는 적합하지 않다고 직접 경고한다.
|
||||
|
||||
### 예시 3. queue claim query에 OFFSET을 넣는다
|
||||
|
||||
```sql
|
||||
SELECT id
|
||||
FROM integration.outbox_events
|
||||
WHERE status = 'READY'
|
||||
ORDER BY id
|
||||
OFFSET 100
|
||||
LIMIT 10
|
||||
FOR UPDATE SKIP LOCKED;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- `OFFSET`으로 건너뛴 row도 잠길 수 있다
|
||||
- claim 범위와 실제 lock 범위가 어긋날 수 있다
|
||||
- queue 소비 문맥에서는 특히 예측이 어려워진다
|
||||
|
||||
PostgreSQL은 locking clause와 함께 `LIMIT`를 쓸 때 필요한 row까지만 잠그지만, `OFFSET`으로 건너뛴 row도 잠길 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. ordinary row 처리에 LOCK TABLE을 사용한다
|
||||
|
||||
```sql
|
||||
LOCK TABLE billing.payments IN ACCESS EXCLUSIVE MODE;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- plain SELECT까지 막을 수 있는 매우 강한 table-level lock이다
|
||||
- 특정 row 경쟁을 해결하려는 문제에 비해 범위가 과도하다
|
||||
- 일반 비즈니스 처리에 쓰기에는 영향 범위가 너무 크다
|
||||
|
||||
PostgreSQL은 `ACCESS EXCLUSIVE`가 모든 lock mode와 충돌하고, plain SELECT를 막는 유일한 lock mode라고 설명한다.
|
||||
|
||||
### 예시 5. 락을 잡은 뒤 외부 호출을 오래 수행한다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void approve(Long paymentId) {
|
||||
Payment payment = paymentRepository.findForUpdate(paymentId)
|
||||
.orElseThrow();
|
||||
|
||||
externalGatewayClient.call(payment); // 오래 걸리는 네트워크 호출
|
||||
|
||||
payment.approve();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- row lock을 쥔 채 외부 대기 시간을 모두 끌고 간다
|
||||
- 다른 트랜잭션이 같은 row를 오래 기다리게 만든다
|
||||
- deadlock과 lock wait 문제를 악화시킨다
|
||||
|
||||
PostgreSQL은 lock 요청이 deadlock이 아니면 오래 기다릴 수 있고, 애플리케이션이 긴 시간 트랜잭션을 열어 두는 것은 나쁜 생각이라고 설명한다. idle in transaction도 오래 유지되면 문제가 된다고 설명한다.
|
||||
|
||||
### 예시 6. join query에서 lock 대상을 좁히지 않는다
|
||||
|
||||
```sql
|
||||
SELECT o.id, u.id
|
||||
FROM ordering.orders o
|
||||
JOIN auth.users u ON u.id = o.user_id
|
||||
WHERE o.id = :orderId
|
||||
FOR UPDATE;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제로는 order row만 선점하면 되는데 join 참여 row까지 넓게 잠글 수 있다
|
||||
- lock 범위가 과도하고, 예상보다 큰 contention을 만들 수 있다
|
||||
- 이런 query는 `FOR UPDATE OF o` 같은 형태로 범위를 좁히는 편이 안전하다
|
||||
|
||||
PostgreSQL은 locking clause에서 table list를 생략하면 statement에 사용된 모든 테이블에 영향을 줄 수 있다고 설명한다.
|
||||
|
||||
### 예시 7. PessimisticLockException과 LockTimeoutException을 같은 것으로 처리한다
|
||||
|
||||
```java
|
||||
try {
|
||||
repository.findForUpdate(id);
|
||||
} catch (PersistenceException e) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- transaction-level rollback이 난 경우와 statement-level timeout만 난 경우를 구분하지 못한다
|
||||
- 후속 처리와 재시도 정책이 흐려진다
|
||||
- 잠금 실패 의미를 application boundary에서 잃어버린다
|
||||
|
||||
Jakarta Persistence는 pessimistic lock failure가 transaction rollback이면 `PessimisticLockException`, statement rollback이면 `LockTimeoutException`이라고 구분한다. 두 예외는 현재 트랜잭션 상태도 다를 수 있다.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Migration 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 새 nullable 컬럼 추가 → backfill → default 설정으로 나눈다
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN status text;
|
||||
|
||||
-- 배치/스크립트로 backfill 수행
|
||||
-- UPDATE auth.users SET status = 'ACTIVE' WHERE status IS NULL;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ALTER COLUMN status SET DEFAULT 'ACTIVE';
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 컬럼 추가를 빠른 additive change로 분리했다
|
||||
- 과거 데이터 보정과 future default를 분리했다
|
||||
- `SET DEFAULT`가 기존 row를 바꾸지 않는다는 점을 전제로 설계했다
|
||||
|
||||
PostgreSQL은 컬럼 추가가 가능하고, default 변경은 이후 insert/update에만 영향을 주며 기존 row를 바꾸지 않는다고 설명한다.
|
||||
|
||||
### 예시 2. 큰 테이블의 CHECK/FK는 NOT VALID 후 나중에 검증한다
|
||||
|
||||
```sql
|
||||
ALTER TABLE ordering.orders
|
||||
ADD CONSTRAINT ck_orders__amount_positive
|
||||
CHECK (amount > 0) NOT VALID;
|
||||
|
||||
ALTER TABLE ordering.orders
|
||||
VALIDATE CONSTRAINT ck_orders__amount_positive;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 제약 추가 시점의 긴 full scan 영향을 줄인다
|
||||
- 새로 들어오는/갱신되는 row에는 바로 제약이 적용된다
|
||||
- 기존 데이터 검증은 별도 단계로 분리한다
|
||||
|
||||
PostgreSQL은 `NOT VALID` 제약이 기존 row scan을 건너뛰고, 이후 `VALIDATE CONSTRAINT`로 검증할 수 있으며 validation은 `SHARE UPDATE EXCLUSIVE` lock으로 수행된다고 설명한다.
|
||||
|
||||
### 예시 3. NOT NULL은 backfill과 증명 단계를 거쳐 올린다
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.users
|
||||
ADD CONSTRAINT ck_users__status_not_null
|
||||
CHECK (status IS NOT NULL) NOT VALID;
|
||||
|
||||
-- backfill 수행
|
||||
-- UPDATE auth.users SET status = 'ACTIVE' WHERE status IS NULL;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
VALIDATE CONSTRAINT ck_users__status_not_null;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ALTER COLUMN status SET NOT NULL;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
DROP CONSTRAINT ck_users__status_not_null;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- null 방지 강화를 한 번에 몰아넣지 않았다
|
||||
- PostgreSQL이 valid CHECK로 null 불가능함을 증명하면 `SET NOT NULL` scan을 건너뛸 수 있는 점과 잘 맞는다
|
||||
- 운영 중 영향도를 줄이기 좋은 패턴이다
|
||||
|
||||
PostgreSQL은 `SET NOT NULL`이 보통 table scan을 하지만, valid CHECK가 null 불가능함을 증명하면 scan을 생략할 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. 운영 인덱스는 CONCURRENTLY로 만들고, 필요하면 제약으로 승격한다
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX CONCURRENTLY uq_users__email_idx
|
||||
ON auth.users (email);
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ADD CONSTRAINT uq_users__email
|
||||
UNIQUE USING INDEX uq_users__email_idx;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 인덱스 build 중 write block을 줄인다
|
||||
- 기존 인덱스를 활용해 빠르게 UNIQUE 제약으로 승격한다
|
||||
- 큰 테이블의 unique 추가에 적합한 공식 경로다
|
||||
|
||||
PostgreSQL은 `CREATE INDEX CONCURRENTLY`가 production environment에 유용하고, 기존 unique index를 UNIQUE/PRIMARY KEY 제약으로 전환할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. PostgreSQL 11+에서는 상수 default 컬럼 추가를 안전한 후보로 본다
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.sessions
|
||||
ADD COLUMN source text DEFAULT 'LOCAL';
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 상수 default라면 PostgreSQL 11+에서 빠른 metadata 기반 처리 경로에 들어갈 수 있다
|
||||
- 운영 중 큰 rewrite를 피할 가능성이 높다
|
||||
- 상수 default라는 점이 분명하다
|
||||
|
||||
PostgreSQL 11 release notes와 현재 ALTER TABLE 문서는 non-volatile/default constant 컬럼 추가가 table rewrite를 피할 수 있다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 큰 테이블에 volatile default를 바로 추가한다
|
||||
|
||||
```sql
|
||||
ALTER TABLE audit.audit_logs
|
||||
ADD COLUMN created_bucket timestamp with time zone DEFAULT clock_timestamp();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- `clock_timestamp()`는 volatile default다
|
||||
- PostgreSQL은 이런 경우 전체 테이블과 인덱스 rewrite가 일어날 수 있다고 설명한다
|
||||
- 운영 테이블에서는 매우 위험할 수 있다
|
||||
|
||||
공식 문서는 volatile default 컬럼 추가가 rewrite를 유발한다고 설명한다.
|
||||
|
||||
### 예시 2. 운영 인덱스를 일반 CREATE INDEX로 바로 만든다
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_orders__created_at
|
||||
ON ordering.orders (created_at);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- write를 막을 수 있는 일반 index build를 사용한다
|
||||
- 운영 중 대형 테이블에는 영향도가 과도할 수 있다
|
||||
- 이런 경우는 `CREATE INDEX CONCURRENTLY`를 먼저 검토해야 한다
|
||||
|
||||
PostgreSQL은 `CREATE INDEX CONCURRENTLY`가 write를 막지 않고 production environment에 유용하다고 설명한다. 그 반대 의미로, 일반 build는 운영 중 더 보수적으로 다뤄야 한다.
|
||||
|
||||
### 예시 3. CREATE INDEX CONCURRENTLY를 트랜잭션 블록 안에 넣는다
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
|
||||
CREATE INDEX CONCURRENTLY ix_users__email
|
||||
ON auth.users (email);
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL은 `CREATE INDEX CONCURRENTLY`가 transaction block 안에서 실행될 수 없다고 명시한다
|
||||
- migration 도구가 이 구분을 지원하지 않으면 배포 시 실패한다
|
||||
- non-transactional step으로 분리해야 한다
|
||||
|
||||
공식 문서는 regular `CREATE INDEX`는 transaction block 안에서 가능하지만, `CREATE INDEX CONCURRENTLY`는 불가능하다고 설명한다.
|
||||
|
||||
### 예시 4. backfill 없이 바로 NOT NULL을 건다
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN status text;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ALTER COLUMN status SET NOT NULL;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기존 row가 null일 가능성을 무시한다
|
||||
- `SET NOT NULL`은 보통 전체 테이블 scan을 요구한다
|
||||
- 데이터 보정 없이 곧바로 강한 제약을 올리는 구조다
|
||||
|
||||
PostgreSQL은 `SET NOT NULL`이 기존 row에 null이 없어야 하고, 보통 이를 확인하기 위해 전체 테이블을 스캔한다고 설명한다.
|
||||
|
||||
### 예시 5. 타입 변경을 무심코 직접 수행한다
|
||||
|
||||
```sql
|
||||
ALTER TABLE billing.payments
|
||||
ALTER COLUMN amount TYPE numeric(19,4);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 타입 변경은 기본적으로 rewrite/rebuild 후보다
|
||||
- 큰 테이블에서는 시간과 디스크 사용량이 매우 커질 수 있다
|
||||
- 더 안전한 staged migration이 필요한지 먼저 검토해야 한다
|
||||
|
||||
PostgreSQL은 타입 변경이 보통 테이블과 인덱스를 rewrite/rebuild하고, 상당한 시간과 최대 거의 두 배 디스크를 일시적으로 요구할 수 있다고 설명한다.
|
||||
|
||||
### 예시 6. 앱 호환성 검증 전에 rename/drop부터 수행한다
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.users
|
||||
RENAME COLUMN email TO login_id;
|
||||
|
||||
ALTER TABLE auth.users
|
||||
DROP COLUMN username;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기술적으로는 가능한 DDL이지만, 애플리케이션/쿼리/배치/운영 스크립트와의 호환성을 즉시 깨뜨릴 수 있다
|
||||
- backward-compatible rollout 경로가 없다
|
||||
- 이런 변경은 마지막 cleanup 단계에서만 허용하는 것이 안전하다
|
||||
|
||||
PostgreSQL은 rename/drop을 지원하지만, 프로젝트 운영 기준에서는 destructive/비호환 변경으로 본다. 특히 `DROP COLUMN`은 빠르지만 디스크 공간도 즉시 줄지 않는다.
|
||||
|
||||
### 예시 7. failed concurrent build 뒤 INVALID 인덱스를 방치한다
|
||||
|
||||
```sql
|
||||
-- 실패한 CREATE INDEX CONCURRENTLY 이후
|
||||
-- 아무 조치 없이 배포 종료
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL은 실패한 concurrent build가 INVALID 인덱스를 남길 수 있다고 설명한다
|
||||
- 이 인덱스는 query에는 안 쓰여도 update overhead는 계속 발생한다
|
||||
- drop 후 재시도나 `REINDEX INDEX CONCURRENTLY` 같은 정리 절차가 필요하다
|
||||
|
||||
공식 문서는 invalid index가 남을 수 있고, 권장 복구 방법은 drop 후 다시 시도하거나 concurrent reindex라고 설명한다.
|
||||
@@ -0,0 +1,267 @@
|
||||
# N+1 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 상세 조회의 to-one 연관은 join fetch로 한 번에 가져온다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select s
|
||||
from Session s
|
||||
join fetch s.user
|
||||
where s.id = :id
|
||||
""")
|
||||
Optional<Session> findDetailById(@Param("id") Long id);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Session 상세 조회에서 필요한 user를 같은 query로 가져온다
|
||||
- to-one 연관에 대한 N+1 위험을 가장 직접적으로 제거한다
|
||||
- 기본 매핑은 LAZY로 유지하면서, 이 use case에서만 eager 요구를 적용한다
|
||||
|
||||
Hibernate는 JOIN FETCH가 @ManyToOne / @OneToOne에 좋고, 필요한 association은 persistence context가 닫히기 전에 fetch하는 것이 가장 좋은 대응이라고 설명한다.
|
||||
|
||||
### 예시 2. query 단위 fetch plan은 EntityGraph로 선언한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@NamedEntityGraph(
|
||||
name = "User.withRoles",
|
||||
attributeNodes = @NamedAttributeNode("roles")
|
||||
)
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
// ...
|
||||
}
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
@EntityGraph(value = "User.withRoles", type = EntityGraph.EntityGraphType.FETCH)
|
||||
Optional<User> findByEmail(String email);
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 정적 매핑을 EAGER로 바꾸지 않고, 특정 repository method에서만 roles를 함께 가져온다
|
||||
- fetch plan이 use case 단위로 분리된다
|
||||
- named graph를 재사용할 수 있다
|
||||
|
||||
Jakarta Persistence는 fetchgraph와 loadgraph를 표준으로 정의하고, Spring Data JPA는 repository method에 @EntityGraph를 붙여 fetch/load graph를 적용할 수 있다고 설명한다.
|
||||
|
||||
### 예시 3. read-only 목록은 DTO projection으로 닫는다
|
||||
|
||||
```java
|
||||
public record UserSummary(Long id, String email, String status) {}
|
||||
|
||||
@Query("""
|
||||
select new com.example.auth.user.UserSummary(u.id, u.email, u.status)
|
||||
from User u
|
||||
where u.deletedAt is null
|
||||
order by u.id desc
|
||||
""")
|
||||
List<UserSummary> findActiveUserSummaries();
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 목록 조회에서 엔티티와 연관 그래프 전체를 관리하지 않는다
|
||||
- 필요한 컬럼만 가져오므로 N+1 설계 자체를 피한다
|
||||
- 가장 단순하고 예측 가능한 목록 조회 구조다
|
||||
|
||||
Hibernate는 @BatchSize보다 DTO projection이나 JOIN FETCH가 더 좋은 대안인 경우가 많다고 설명한다. 특히 read-only 목록에서는 DTO projection이 더 신뢰도 높은 선택이다.
|
||||
|
||||
### 예시 4. 여러 부모의 같은 컬렉션을 뒤이어 접근한다면 BatchSize를 보조적으로 사용한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 50)
|
||||
private List<UserRole> roles = new ArrayList<>();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 여러 User의 roles 컬렉션을 순차적으로 초기화할 때 secondary select를 묶어 줄 수 있다
|
||||
- N+1을 완화하지만, 기본 fetch를 EAGER로 바꾸지 않는다
|
||||
- 보조 최적화로서 의미가 분명하다
|
||||
|
||||
Hibernate는 @BatchSize가 여러 연관을 single database round trip 또는 적은 수의 round trip으로 묶을 수 있다고 설명한다. 다만 DTO projection이나 JOIN FETCH가 더 좋은 대안인 경우가 많다고도 함께 설명한다.
|
||||
|
||||
### 예시 5. 페이징 목록은 ID 페이지 조회와 후속 query를 분리한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u.id
|
||||
from User u
|
||||
where u.deletedAt is null
|
||||
order by u.createdAt desc, u.id desc
|
||||
""")
|
||||
Page<Long> findUserIds(Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
select distinct u
|
||||
from User u
|
||||
left join fetch u.department
|
||||
where u.id in :ids
|
||||
""")
|
||||
List<User> findUsersWithDepartment(@Param("ids") Collection<Long> ids);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- page boundary는 root ID query에서 안정적으로 결정한다
|
||||
- 후속 query는 page 범위 안에서만 필요한 연관을 가져온다
|
||||
- 컬렉션 fetch join + pagination 충돌을 피하면서 N+1도 막는다
|
||||
|
||||
Hibernate는 paged query에서 fetch join, 특히 many-valued association에 대한 fetch join을 피하라고 설명한다. 이 분리 전략은 해당 제약을 피해 가는 실무 best practice다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. N+1을 막겠다며 to-one을 EAGER로 선언한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "sessions", schema = "auth")
|
||||
public class Session {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 이 설정은 모든 query에서 user 로딩 비용을 고정한다
|
||||
- JPQL query에서 fetch join을 빠뜨리면 오히려 secondary select가 association마다 발생해 N+1로 이어질 수 있다
|
||||
- 문제를 매핑에 숨기고 use case 단위 제어를 잃는다
|
||||
|
||||
Hibernate는 EAGER fetching is almost always a bad choice라고 설명하고, EAGER association을 query에서 join fetch하지 않으면 secondary select로 N+1이 생길 수 있다고 명시한다.
|
||||
|
||||
### 예시 2. 루트 목록을 가져온 뒤 컬렉션을 루프에서 접근한다
|
||||
|
||||
```java
|
||||
List<User> users = entityManager.createQuery("""
|
||||
select u
|
||||
from User u
|
||||
where u.deletedAt is null
|
||||
order by u.id desc
|
||||
""", User.class).getResultList();
|
||||
|
||||
for (User user : users) {
|
||||
user.getRoles().size();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- root query 1번 뒤에, user 수만큼 roles 초기화 SQL이 반복될 수 있다
|
||||
- 가장 전형적인 N+1 구조다
|
||||
- 목록 조회라면 DTO projection, join fetch, batch fetching 등으로 구조를 바꿔야 한다
|
||||
|
||||
Hibernate는 collection을 first access 시 secondary select로 초기화하는 FetchMode.SELECT가 N+1을 일으킬 수 있다고 설명한다.
|
||||
|
||||
### 예시 3. 여러 컬렉션을 한 query에서 병렬 fetch join한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u
|
||||
from User u
|
||||
left join fetch u.roles
|
||||
left join fetch u.sessions
|
||||
left join fetch u.loginHistories
|
||||
where u.id = :id
|
||||
""")
|
||||
Optional<User> findEverything(@Param("id") Long id);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 여러 to-many/collection을 동시에 fetch join하면 Cartesian product가 발생할 수 있다
|
||||
- 중복 row가 폭증하고 성능이 매우 나빠질 수 있다
|
||||
- “한 번에 다 가져오기”가 오히려 더 위험하다
|
||||
|
||||
Hibernate는 multiple collections or to-many associations in parallel fetch join results in a Cartesian product and might exhibit very poor performance라고 명시한다.
|
||||
|
||||
### 예시 4. pagination query에 컬렉션 fetch join을 사용한다
|
||||
|
||||
```java
|
||||
@Query("""
|
||||
select u
|
||||
from User u
|
||||
left join fetch u.roles
|
||||
order by u.id desc
|
||||
""")
|
||||
Page<User> findAllWithRoles(Pageable pageable);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- many-valued association fetch join과 pagination이 충돌한다
|
||||
- limit가 DB가 아니라 메모리에서 적용될 수 있다
|
||||
- page boundary와 성능이 모두 불안정해진다
|
||||
|
||||
Hibernate는 fetch joins should usually be avoided in limited or paged queries라고 설명하고, 컬렉션 fetch join과 pagination 조합은 terrible performance characteristics를 만들 수 있다고 경고한다.
|
||||
|
||||
### 예시 5. 트랜잭션 밖에서 lazy 연관이 알아서 초기화되기를 기대한다
|
||||
|
||||
```java
|
||||
@Transactional(readOnly = true)
|
||||
public User getUser(Long id) {
|
||||
return userRepository.findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
@GetMapping("/users/{id}")
|
||||
public UserResponse getUser(@PathVariable Long id) {
|
||||
User user = userService.getUser(id);
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getRoles().stream().map(UserRole::getName).toList()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- service 트랜잭션 종료 후 controller에서 lazy collection 접근을 시도한다
|
||||
- 환경에 따라 LazyInitializationException이 발생하거나, 우연한 추가 query에 의존할 수 있다
|
||||
- fetch plan 책임이 presentation 계층으로 새어 나간다
|
||||
|
||||
Hibernate는 필요한 association은 persistence context가 닫히기 전에 fetch하는 것이 가장 좋은 대응이라고 설명한다.
|
||||
|
||||
### 예시 6. BatchSize만 크게 올려 구조 문제를 덮는다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 1000)
|
||||
private List<UserRole> roles;
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
|
||||
@BatchSize(size = 1000)
|
||||
private List<Session> sessions;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 왜 secondary select 구조를 유지하는지 설명 없이 숫자만 키운다
|
||||
- batch fetching은 보조 완화 수단이지 1차 설계 수단이 아니다
|
||||
- DTO projection, join fetch, query 분리 같은 더 직접적인 해결책을 가릴 수 있다
|
||||
|
||||
Hibernate도 @BatchSize는 N+1보다 낫지만, 대부분의 경우 DTO projection이나 JOIN FETCH가 더 좋은 대안이라고 설명한다.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Pagination Query 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 얕은 관리자 목록은 결정적 ORDER BY + LIMIT/OFFSET으로 처리한다
|
||||
|
||||
```sql
|
||||
SELECT id, email, created_at
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- `LIMIT/OFFSET`을 쓰면서 결정적 `ORDER BY`를 함께 둔다
|
||||
- `created_at` 동률을 `id`로 해소한다
|
||||
- active-row predicate가 soft delete 계약과 맞는다
|
||||
|
||||
PostgreSQL은 `LIMIT/OFFSET`에 `ORDER BY`가 없으면 예측 불가능한 subset을 얻게 된다고 설명하고, 큰 `OFFSET`은 비효율적일 수 있다고 설명한다.
|
||||
|
||||
### 예시 2. 무한 스크롤은 keyset/cursor 방식으로 설계한다
|
||||
|
||||
```sql
|
||||
SELECT id, created_at, title
|
||||
FROM board.posts
|
||||
WHERE deleted_at IS NULL
|
||||
AND (created_at, id) < (:lastCreatedAt, :lastId)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 깊은 페이지에서도 큰 `OFFSET`을 피할 수 있다
|
||||
- 정렬 기준과 seek 조건이 같은 의미를 가진다
|
||||
- `(created_at, id)`가 tie-breaker까지 포함한 cursor 역할을 한다
|
||||
|
||||
PostgreSQL은 row constructor comparison의 `<`, `>`가 좌→우 비교로 동작한다고 설명하고, `ORDER BY ... LIMIT n`에서 B-tree ordered scan이 유리할 수 있다고 설명한다. 이 예시는 그 두 기능을 pagination에 적용한 전형적 패턴이다.
|
||||
|
||||
### 예시 3. 필터와 정렬이 함께 있는 페이지 query는 인덱스와 같이 설계한다
|
||||
|
||||
```sql
|
||||
SELECT id, tenant_id, status, created_at
|
||||
FROM ordering.orders
|
||||
WHERE tenant_id = :tenantId
|
||||
AND status = 'READY'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit;
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_orders__tenant_status_created_at_id
|
||||
ON ordering.orders (tenant_id, status, created_at DESC, id DESC);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- leading equality filter 뒤에 정렬 키를 배치한다
|
||||
- pagination query와 인덱스가 같은 access pattern을 공유한다
|
||||
- 적은 수의 앞 row를 직접 가져오기에 유리하다
|
||||
|
||||
PostgreSQL은 multicolumn B-tree가 leading equality와 그 다음 inequality/정렬 문맥에서 가장 효율적이라고 설명하고, `ORDER BY ... LIMIT n`에서 정렬을 만족하는 인덱스가 특히 유용하다고 설명한다.
|
||||
|
||||
### 예시 4. total count가 꼭 필요할 때만 별도 query로 분리한다
|
||||
|
||||
```sql
|
||||
SELECT id, email, created_at
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT count(*)
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 페이지 조회와 총건수 계산을 명시적으로 분리한다
|
||||
- 목록 일부 조회와 전체 집계의 비용을 섞지 않는다
|
||||
- count가 필요 없는 API에서는 두 번째 query를 생략할 수 있다
|
||||
|
||||
PostgreSQL은 `count(*)`가 입력 row 수를 계산하는 aggregate라고 설명하고, planner statistics인 `reltuples`는 근사치라고 설명한다.
|
||||
|
||||
### 예시 5. 다음 페이지 존재 여부만 필요하면 한 건 더 가져온다
|
||||
|
||||
```sql
|
||||
SELECT id, created_at, title
|
||||
FROM board.posts
|
||||
WHERE deleted_at IS NULL
|
||||
AND (created_at, id) < (:lastCreatedAt, :lastId)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limitPlusOne;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- exact total count 없이도 next page 존재 여부를 계산할 수 있다
|
||||
- 큰 목록에서 count 비용을 매번 강제하지 않는다
|
||||
- keyset/cursor 방식과 잘 맞는다
|
||||
|
||||
PostgreSQL 공식 문서는 `LIMIT`이 결과 일부를 가져오는 기본 도구임을 설명하고, 큰 `OFFSET`이 비효율적일 수 있음을 설명한다. 이 예시는 total count를 피하는 실무 패턴이다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. LIMIT/OFFSET을 정렬 없이 사용한다
|
||||
|
||||
```sql
|
||||
SELECT id, email
|
||||
FROM auth.users
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 어떤 row 집합을 잘라 오는지 정의되지 않는다
|
||||
- `LIMIT/OFFSET` 값에 따라 plan과 결과 subset이 달라질 수 있다
|
||||
- 페이지 계약이 성립하지 않는다
|
||||
|
||||
PostgreSQL은 `ORDER BY` 없이 `LIMIT`을 쓰면 예측 불가능한 subset을 얻게 된다고 명시한다.
|
||||
|
||||
### 예시 2. tie-breaker 없는 정렬로 페이지를 자른다
|
||||
|
||||
```sql
|
||||
SELECT id, created_at, title
|
||||
FROM board.posts
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 `created_at`을 가진 row의 상대 순서가 API 계약상 명시되지 않는다
|
||||
- 페이지 경계가 흔들리거나 중복/누락처럼 보일 수 있다
|
||||
- `id` 같은 유니크 tie-breaker가 필요하다
|
||||
|
||||
PostgreSQL은 `LIMIT`과 함께 사용할 때 `ORDER BY`가 결과를 unique order로 제약하는 것이 중요하다고 설명한다.
|
||||
|
||||
### 예시 3. 깊은 페이지를 큰 OFFSET으로 계속 읽는다
|
||||
|
||||
```sql
|
||||
SELECT id, created_at, title
|
||||
FROM board.posts
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 20
|
||||
OFFSET 200000;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 건너뛴 200,000 row도 서버 내부에서 계산해야 한다
|
||||
- 깊은 페이지로 갈수록 비용이 커질 수 있다
|
||||
- 이런 요구는 keyset/cursor 방식이 더 적합하다
|
||||
|
||||
PostgreSQL은 `OFFSET`으로 건너뛴 row도 내부에서 계산되어야 하므로 큰 `OFFSET`이 비효율적일 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. keyset인데 seek 조건과 정렬이 맞지 않는다
|
||||
|
||||
```sql
|
||||
SELECT id, created_at, title
|
||||
FROM board.posts
|
||||
WHERE id < :lastId
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- `ORDER BY`는 `(created_at, id)` 의미인데 seek 조건은 `id`만 본다
|
||||
- 페이지 경계가 정렬 의미와 어긋난다
|
||||
- keyset/cursor는 정렬 기준과 동일한 키 의미를 써야 한다
|
||||
|
||||
PostgreSQL의 row comparison은 여러 정렬 키를 좌→우로 비교할 수 있으므로, 이런 경우 `(created_at, id)` 형태가 더 자연스럽다.
|
||||
|
||||
### 예시 5. soft delete 테이블인데 active-row predicate 없이 페이지를 자른다
|
||||
|
||||
```sql
|
||||
SELECT id, email, created_at
|
||||
FROM auth.users
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 삭제 row가 일반 목록에 섞일 수 있다
|
||||
- active-row partial index와도 잘 맞지 않는다
|
||||
- soft delete 기본 조회 계약을 깨뜨린다
|
||||
|
||||
PostgreSQL partial index는 query의 WHERE가 predicate를 함의할 때 가장 자연스럽게 사용되므로, soft delete 테이블은 active-row predicate를 일관되게 포함해야 한다.
|
||||
|
||||
### 예시 6. 페이지 query와 exact count를 항상 묶는다
|
||||
|
||||
```sql
|
||||
SELECT id, email, created_at
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
|
||||
SELECT count(*)
|
||||
FROM auth.users
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 총건수가 항상 필요한지 검토하지 않는다
|
||||
- 목록 일부 조회와 전체 집계를 무조건 함께 수행한다
|
||||
- 큰 목록에서는 불필요한 비용이 될 수 있다
|
||||
|
||||
PostgreSQL은 `count(*)`가 입력 row 수를 계산하는 aggregate이고, planner statistics는 근사치라고 설명한다. exact total count는 별도 비용을 가진다고 보고 설계해야 한다.
|
||||
@@ -0,0 +1,261 @@
|
||||
# PK / FK / UNIQUE / CHECK 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 대표 식별자는 PK, 비즈니스 고유성은 UNIQUE로 분리한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
email text NOT NULL,
|
||||
username text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id),
|
||||
CONSTRAINT uq_users__email UNIQUE (email),
|
||||
CONSTRAINT uq_users__username UNIQUE (username)
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대표 식별자 id를 PK로 둔다
|
||||
- 이메일과 username은 business unique로 분리한다
|
||||
- identity에만 기대지 않고 PK가 유일성을 보장한다
|
||||
|
||||
PostgreSQL은 PK가 대표 식별자이며 unique B-tree 인덱스를 자동 생성한다고 설명하고, identity는 자동 생성일 뿐 uniqueness를 보장하지 않는다고 명시한다.
|
||||
|
||||
### 예시 2. 필수 관계는 FK + NOT NULL, 삭제 정책은 관계 의미에 맞춘다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE auth.sessions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
user_id bigint NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_sessions PRIMARY KEY (id),
|
||||
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
|
||||
REFERENCES auth.users (id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX ix_sessions__user_id ON auth.sessions (user_id);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 세션은 사용자에 종속된 구성요소이므로 ON DELETE CASCADE가 자연스럽다
|
||||
- 필수 관계를 NOT NULL로 닫았다
|
||||
- FK 컬럼 인덱스를 별도로 생성했다
|
||||
|
||||
PostgreSQL 공식 문서도 구성요소 관계에는 CASCADE가 적절할 수 있고, FK는 참조하는 쪽 인덱스를 자동 생성하지 않는다고 설명한다.
|
||||
|
||||
### 예시 3. soft delete 환경의 조건부 고유성은 partial unique index로 표현한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
tenant_id bigint NOT NULL,
|
||||
email text NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_users__tenant_email_active
|
||||
ON auth.users (tenant_id, email)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “삭제되지 않은 사용자만 tenant 내 email 유일”이라는 조건부 고유성을 정확히 표현한다
|
||||
- 일반 UNIQUE constraint로는 일부 행에만 적용되는 uniqueness를 표현할 수 없다
|
||||
|
||||
PostgreSQL 공식 문서는 일부 행에만 적용되는 uniqueness restriction은 unique constraint가 아니라 unique partial index로 표현해야 한다고 설명한다.
|
||||
|
||||
### 예시 4. CHECK는 같은 행 안의 불변식에만 사용한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE billing.payments (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
amount numeric(19,4) NOT NULL,
|
||||
refunded_amount numeric(19,4) NOT NULL DEFAULT 0,
|
||||
status text NOT NULL,
|
||||
CONSTRAINT pk_payments PRIMARY KEY (id),
|
||||
CONSTRAINT ck_payments__amount_positive CHECK (amount > 0),
|
||||
CONSTRAINT ck_payments__refunded_amount_range
|
||||
CHECK (refunded_amount >= 0 AND refunded_amount <= amount),
|
||||
CONSTRAINT ck_payments__status
|
||||
CHECK (status IN ('PENDING', 'PAID', 'CANCELLED', 'REFUNDED'))
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 모두 같은 행 안에서 평가 가능한 규칙이다
|
||||
- null 금지는 NOT NULL로, 값 범위는 CHECK로 역할을 분리했다
|
||||
- CHECK에 cross-table 의존이 없다
|
||||
|
||||
PostgreSQL은 CHECK가 row-local invariant에 적합하고, null 금지는 NOT NULL로 표현하는 것이 맞다고 설명한다.
|
||||
|
||||
### 예시 5. 조인 테이블은 복합 PK를 예외적으로 사용할 수 있다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE auth.roles (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY,
|
||||
CONSTRAINT pk_roles PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE auth.user_roles (
|
||||
user_id bigint NOT NULL,
|
||||
role_id bigint NOT NULL,
|
||||
granted_at timestamp with time zone NOT NULL,
|
||||
CONSTRAINT pk_user_roles PRIMARY KEY (user_id, role_id),
|
||||
CONSTRAINT fk_user_roles__users FOREIGN KEY (user_id)
|
||||
REFERENCES auth.users (id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_roles__roles FOREIGN KEY (role_id)
|
||||
REFERENCES auth.roles (id)
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 조인 테이블에서는 (user_id, role_id) 조합 자체가 자연스러운 정체성이다
|
||||
- 복합 PK가 FK와 겹쳐도 의미가 분명하다
|
||||
- 삭제 정책도 관계 의미에 따라 다르게 선택했다
|
||||
|
||||
PostgreSQL은 복합 PK와 FK를 모두 지원하며, 문서 예시에서도 many-to-many 구조에서 이런 형태를 보여준다. 다만 JPA에서는 복합 PK가 별도 키 클래스를 요구하므로 예외적으로 사용하는 편이 안전하다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. PK 없이 UNIQUE만으로 테이블을 운영한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
email text NOT NULL UNIQUE,
|
||||
username text NOT NULL UNIQUE
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 대표 식별자가 없다
|
||||
- FK 기본 참조 대상과 ORM 식별 의미가 불명확하다
|
||||
- “고유한 컬럼 몇 개”와 “대표 PK”의 역할이 섞인다
|
||||
|
||||
PostgreSQL도 모든 테이블에 PK를 강제하지는 않지만, 일반적으로 두는 것이 가장 좋다고 설명한다.
|
||||
|
||||
### 예시 2. nullable UNIQUE를 두고 NULL도 하나만 허용된다고 착각한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
external_subject text UNIQUE
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PostgreSQL 기본 동작에서는 NULL이 서로 다른 값으로 취급된다
|
||||
- 따라서 external_subject가 NULL인 행이 여러 개 들어갈 수 있다
|
||||
- “값이 없으면 하나만 허용” 의미라면 현재 설계는 틀렸다
|
||||
|
||||
이 경우는 NOT NULL, UNIQUE NULLS NOT DISTINCT, 또는 모델 재설계가 필요하다.
|
||||
|
||||
### 예시 3. 필수 관계인데 nullable FK로 열어 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE auth.sessions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id bigint,
|
||||
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
|
||||
REFERENCES auth.users (id)
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 세션이 반드시 사용자에 속해야 한다면 user_id는 NOT NULL이어야 한다
|
||||
- FK만으로는 NULL을 막지 못한다
|
||||
- 필수 관계를 스키마가 보장하지 못한다
|
||||
|
||||
PostgreSQL은 참조 컬럼에 null이 있으면 FK를 회피할 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. 독립 객체 관계에 무분별하게 CASCADE를 건다
|
||||
|
||||
```sql
|
||||
CREATE TABLE catalog.products (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE ordering.order_items (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
product_id bigint NOT NULL,
|
||||
CONSTRAINT fk_order_items__products FOREIGN KEY (product_id)
|
||||
REFERENCES catalog.products (id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- product와 order item 관계는 도메인에 따라 독립 객체일 수 있다
|
||||
- 상품 삭제가 주문 이력 일부를 연쇄 삭제하면 운영상 매우 위험할 수 있다
|
||||
- 이 경우는 RESTRICT 또는 NO ACTION이 더 자연스러운 경우가 많다
|
||||
|
||||
PostgreSQL 공식 문서도 독립 객체 관계라면 RESTRICT 또는 NO ACTION이 더 적절하다고 설명한다.
|
||||
|
||||
### 예시 5. null 금지를 CHECK로 우회한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email text,
|
||||
CONSTRAINT ck_users__email_not_null CHECK (email IS NOT NULL)
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- null 금지는 NOT NULL이 더 직접적이고 효율적이다
|
||||
- 역할이 다른 제약을 섞고 있다
|
||||
- 팀 규칙 해석도 흐려진다
|
||||
|
||||
PostgreSQL 공식 문서도 CHECK (col IS NOT NULL)보다 explicit NOT NULL이 더 효율적이라고 설명한다.
|
||||
|
||||
### 예시 6. cross-row 규칙을 CHECK로 해결하려 한다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.user_roles (
|
||||
user_id bigint NOT NULL,
|
||||
role_id bigint NOT NULL,
|
||||
is_primary boolean NOT NULL,
|
||||
CONSTRAINT ck_user_roles__only_one_primary
|
||||
CHECK (
|
||||
NOT is_primary
|
||||
OR role_id IS NOT NULL
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- “사용자당 primary role은 하나만” 같은 규칙은 이런 CHECK로 보장되지 않는다
|
||||
- CHECK는 다른 행을 기준으로 유일성을 유지하는 수단이 아니다
|
||||
- 이런 요구는 (user_id) 조건부 unique index 같은 방식으로 풀어야 한다
|
||||
|
||||
PostgreSQL은 cross-row / cross-table 규칙에 CHECK를 쓰지 말고 UNIQUE, FK, EXCLUDE, trigger를 검토하라고 안내한다.
|
||||
@@ -0,0 +1,310 @@
|
||||
# Query 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. LIMIT에는 결정적 ORDER BY를 함께 둔다
|
||||
|
||||
```sql
|
||||
SELECT id, user_id, created_at
|
||||
FROM auth.sessions
|
||||
WHERE user_id = :userId
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- LIMIT 결과가 어떤 20건인지 결정적으로 정의된다
|
||||
- created_at 동률에서도 id가 tie-breaker가 된다
|
||||
- 같은 query를 반복 실행하거나 페이지를 넘겨도 의미가 흔들리지 않는다
|
||||
|
||||
PostgreSQL은 LIMIT 사용 시 ORDER BY가 없으면 예측 불가능한 부분집합을 얻게 된다고 설명한다.
|
||||
|
||||
### 예시 2. LEFT JOIN의 매칭 조건은 ON에 둔다
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.email, s.id AS active_session_id
|
||||
FROM auth.users u
|
||||
LEFT JOIN auth.sessions s
|
||||
ON s.user_id = u.id
|
||||
AND s.revoked_at IS NULL
|
||||
WHERE u.deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “사용자 전체를 유지하면서 revoked 되지 않은 세션만 매칭”이라는 의미가 정확하다
|
||||
- 오른쪽 조건을 ON에 두어 outer join 의미를 유지한다
|
||||
- 사용자 필터와 join 매칭 조건이 분리되어 읽기 쉽다
|
||||
|
||||
PostgreSQL은 outer join에서 ON과 WHERE의 위치가 결과를 다르게 만든다고 명시한다.
|
||||
|
||||
### 예시 3. 존재 여부 확인은 EXISTS로 처리한다
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.email
|
||||
FROM auth.users u
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM auth.user_roles ur
|
||||
WHERE ur.user_id = u.id
|
||||
AND ur.role_name = 'ADMIN'
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “ADMIN role이 하나라도 있으면 됨”이라는 의미가 직접적이다
|
||||
- role이 여러 개여도 바깥 사용자 row가 증폭되지 않는다
|
||||
- COUNT(*) > 0보다 존재 여부 의도를 더 잘 드러낸다
|
||||
|
||||
PostgreSQL은 EXISTS가 행 존재 여부만 판단하며, 일반적으로 전부 끝까지 수행하지 않는다고 설명한다.
|
||||
|
||||
### 예시 4. anti-join은 NOT EXISTS를 사용한다
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.email
|
||||
FROM auth.users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM auth.sessions s
|
||||
WHERE s.user_id = u.id
|
||||
AND s.revoked_at IS NULL
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- null semantics 함정 없이 “활성 세션이 없는 사용자”를 표현한다
|
||||
- NOT IN보다 더 안전한 기본값이다
|
||||
- anti-join 의도가 분명하다
|
||||
|
||||
PostgreSQL은 NOT IN에 null이 섞이면 결과가 true가 아니라 null이 될 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. row filter는 WHERE, 조건부 집계는 FILTER를 사용한다
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
user_id,
|
||||
count(*) FILTER (WHERE revoked_at IS NULL) AS active_count,
|
||||
count(*) FILTER (WHERE revoked_at IS NOT NULL) AS revoked_count
|
||||
FROM auth.sessions
|
||||
WHERE created_at >= :from
|
||||
GROUP BY user_id;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 기간 제한은 row filter이므로 WHERE
|
||||
- 집계별 조건은 FILTER
|
||||
- grouped query의 의미가 분명하다
|
||||
|
||||
PostgreSQL은 WHERE와 HAVING의 역할이 다르고, aggregate input을 FILTER로 제한할 수 있다고 설명한다.
|
||||
|
||||
### 예시 6. one-row-per-group에는 DISTINCT ON을 의도적으로 사용한다
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT ON (user_id)
|
||||
user_id,
|
||||
id,
|
||||
created_at
|
||||
FROM auth.sessions
|
||||
WHERE revoked_at IS NULL
|
||||
ORDER BY user_id, created_at DESC, id DESC;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 사용자별 최신 활성 세션 1건이라는 의미가 분명하다
|
||||
- DISTINCT ON (user_id)와 ORDER BY user_id, ...가 맞춰져 있다
|
||||
- 어떤 row를 남길지 예측 가능하다
|
||||
|
||||
PostgreSQL은 DISTINCT ON의 first row는 ORDER BY 없이는 예측 불가능하며, DISTINCT ON 식은 ORDER BY의 leftmost expressions와 맞아야 한다고 설명한다.
|
||||
|
||||
### 예시 7. deduplication이 불필요하면 UNION ALL을 사용한다
|
||||
|
||||
```sql
|
||||
SELECT user_id, created_at, 'LOGIN' AS event_type
|
||||
FROM auth.login_events
|
||||
WHERE created_at >= :from
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT user_id, created_at, 'LOGOUT' AS event_type
|
||||
FROM auth.logout_events
|
||||
WHERE created_at >= :from;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 두 이벤트 집합을 단순 병합하는 요구다
|
||||
- 중복 제거가 필요하지 않다
|
||||
- UNION보다 의미와 비용이 더 적절하다
|
||||
|
||||
PostgreSQL은 UNION ALL이 중복 제거를 하지 않으므로 보통 더 빠르다고 설명한다.
|
||||
|
||||
### 예시 8. CTE는 단계 분해에 쓰되, 필요하면 NOT MATERIALIZED를 명시한다
|
||||
|
||||
```sql
|
||||
WITH recent_sessions AS NOT MATERIALIZED (
|
||||
SELECT user_id, created_at
|
||||
FROM auth.sessions
|
||||
WHERE created_at >= :from
|
||||
)
|
||||
SELECT u.id, rs.created_at
|
||||
FROM auth.users u
|
||||
JOIN recent_sessions rs ON rs.user_id = u.id
|
||||
WHERE u.deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- CTE로 query 단계를 읽기 쉽게 분해했다
|
||||
- predicate pushdown 이점이 중요한 경우 NOT MATERIALIZED 의도를 드러낸다
|
||||
- CTE를 성능 힌트처럼 무의식적으로 쓰지 않는다
|
||||
|
||||
PostgreSQL은 side-effect-free CTE의 folding/materialization 규칙과 NOT MATERIALIZED의 trade-off를 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. LIMIT만 두고 정렬을 생략한다
|
||||
|
||||
```sql
|
||||
SELECT id, user_id, created_at
|
||||
FROM auth.sessions
|
||||
WHERE user_id = :userId
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 어떤 20건인지 정의되지 않는다
|
||||
- 실행 계획과 실행 시점에 따라 다른 부분집합이 나올 수 있다
|
||||
- API와 배치 결과의 재현성이 깨진다
|
||||
|
||||
PostgreSQL은 LIMIT을 ORDER BY 없이 쓰면 예측 불가능한 subset을 얻는다고 설명한다.
|
||||
|
||||
### 예시 2. LEFT JOIN인데 오른쪽 조건을 WHERE에 내려 의미를 바꾼다
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.email, s.id AS active_session_id
|
||||
FROM auth.users u
|
||||
LEFT JOIN auth.sessions s
|
||||
ON s.user_id = u.id
|
||||
WHERE s.revoked_at IS NULL;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- session이 없는 사용자도 남겨야 하는데 결과에서 사라질 수 있다
|
||||
- 사실상 inner join처럼 동작할 수 있다
|
||||
- outer join의 핵심 의미를 망가뜨린다
|
||||
|
||||
PostgreSQL은 outer join에서 ON과 WHERE가 동등하지 않다고 설명한다.
|
||||
|
||||
### 예시 3. NATURAL JOIN을 사용한다
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM auth.users
|
||||
NATURAL JOIN auth.user_profiles;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 동일 이름 컬럼이 추가되면 join 의미가 바뀔 수 있다
|
||||
- 스키마 변경에 매우 취약하다
|
||||
- 장기 유지보수 신뢰도가 낮다
|
||||
|
||||
PostgreSQL은 NATURAL이 USING보다 훨씬 위험하다고 명시한다.
|
||||
|
||||
### 예시 4. 존재 여부 확인을 COUNT(*)로 처리한다
|
||||
|
||||
```sql
|
||||
SELECT CASE
|
||||
WHEN count(*) > 0 THEN true
|
||||
ELSE false
|
||||
END
|
||||
FROM auth.user_roles
|
||||
WHERE user_id = :userId
|
||||
AND role_name = 'ADMIN';
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 존재 여부만 필요해도 집계를 수행한다
|
||||
- COUNT(*)는 공짜가 아니며, 전체 집계는 테이블 크기에 비례하는 비용이 들 수 있다
|
||||
- EXISTS가 더 직접적인 표현이다
|
||||
|
||||
PostgreSQL은 전체 count(*)가 테이블 또는 전체 인덱스를 스캔해야 할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. NOT IN에 null 가능성을 남겨 둔다
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.email
|
||||
FROM auth.users u
|
||||
WHERE u.id NOT IN (
|
||||
SELECT s.user_id
|
||||
FROM auth.sessions s
|
||||
WHERE s.revoked_at IS NULL
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- subquery 결과에 null이 섞이면 NOT IN 결과가 true가 아니라 null이 될 수 있다
|
||||
- null semantics를 이해하지 못하면 버그를 만들기 쉽다
|
||||
- 이런 anti-join은 보통 NOT EXISTS가 더 안전하다
|
||||
|
||||
PostgreSQL은 NOT IN의 null semantics를 명시적으로 경고한다.
|
||||
|
||||
### 예시 6. row filter를 HAVING으로 올린다
|
||||
|
||||
```sql
|
||||
SELECT user_id, count(*)
|
||||
FROM auth.sessions
|
||||
GROUP BY user_id
|
||||
HAVING max(created_at) >= :from
|
||||
AND user_id = :userId;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- user_id = :userId는 grouping 전에 걸러도 되는 row filter다
|
||||
- 불필요하게 더 많은 행을 grouping하게 만든다
|
||||
- WHERE와 HAVING의 역할이 섞여 있다
|
||||
|
||||
PostgreSQL 튜토리얼은 aggregate가 필요 없는 제한은 WHERE가 더 효율적이라고 설명한다.
|
||||
|
||||
### 예시 7. 잘못된 join을 DISTINCT로 덮는다
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT u.id, u.email
|
||||
FROM auth.users u
|
||||
JOIN auth.user_roles ur ON ur.user_id = u.id
|
||||
WHERE ur.role_name IN ('ADMIN', 'MANAGER');
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- row multiplication 원인을 해결하지 않고 중복만 제거한다
|
||||
- 결과 의미가 DISTINCT에 의존하게 된다
|
||||
- 존재 여부가 목적이라면 EXISTS가 더 직접적이다
|
||||
|
||||
PostgreSQL에서 DISTINCT는 실제로 duplicate row를 제거하는 의미 연산이다. 따라서 이 패턴은 보통 구조 문제를 가리는 나쁜 신호다.
|
||||
|
||||
### 예시 8. DISTINCT ON을 ORDER BY 없이 사용한다
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT ON (user_id)
|
||||
user_id, id, created_at
|
||||
FROM auth.sessions;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 사용자별 어떤 세션이 남는지 예측할 수 없다
|
||||
- first row 선택 기준이 정의되지 않는다
|
||||
- 운영 결과가 비결정적이다
|
||||
|
||||
PostgreSQL은 DISTINCT ON의 first row는 ORDER BY가 없으면 예측 불가능하다고 설명한다.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Schema Structure 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 애플리케이션 전용 schema를 migration으로 만든다
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
CREATE TABLE auth.users (
|
||||
id BIGINT PRIMARY KEY,
|
||||
email VARCHAR(320) NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 애플리케이션 객체가 public 과 분리된다
|
||||
- schema 생성과 테이블 생성 위치가 명확하다
|
||||
- PostgreSQL의 CREATE SCHEMA 와 qualified name 사용 방식에 맞다.
|
||||
|
||||
### 예시 2. ORM 기본 schema를 한 곳에서 맞춘다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 기본 schema를 연결/ORM 설정에서 맞춘다는 전제를 따른다
|
||||
- 모든 엔티티에 같은 schema 문자열을 반복하지 않는다
|
||||
- Hibernate는 schema를 따로 지정하지 않으면 현재 연결의 기본 schema를 사용한다.
|
||||
|
||||
### 예시 3. 정말 필요한 경우에만 특정 엔티티에 schema를 명시한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(schema = "auth_audit", name = "login_audit")
|
||||
public class LoginAudit {
|
||||
@Id
|
||||
private Long id;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예외적 별도 schema 의도를 코드에서 드러낸다
|
||||
- Hibernate가 지원하는 공식 매핑 방식이다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 기본 public 에 그냥 테이블을 만든다
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id BIGINT PRIMARY KEY
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 애플리케이션 schema 의도가 없다
|
||||
- search_path 와 public 기본값에 기대게 된다
|
||||
- PostgreSQL 기본 동작상 unqualified create는 현재 스키마, 기본적으로는 public 에 들어갈 수 있다.
|
||||
|
||||
### 예시 2. 같은 schema를 모든 엔티티에 반복해서 박아 둔다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(schema = "auth", name = "users")
|
||||
public class User { ... }
|
||||
|
||||
@Entity
|
||||
@Table(schema = "auth", name = "sessions")
|
||||
public class Session { ... }
|
||||
|
||||
@Entity
|
||||
@Table(schema = "auth", name = "login_histories")
|
||||
public class LoginHistory { ... }
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기본 schema가 하나인데 코드 중복만 늘어난다
|
||||
- schema 변경 시 수정 범위가 불필요하게 커진다
|
||||
- Hibernate는 기본 schema 연결을 사용할 수 있다.
|
||||
|
||||
### 예시 3. search_path에 기대어 운영마다 다른 schema를 본다
|
||||
|
||||
```sql
|
||||
SET search_path TO auth, public;
|
||||
SELECT * FROM users;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 환경별 search_path 차이에 취약하다
|
||||
- PostgreSQL 공식 문서도 search_path 는 해석 결과와 신뢰 경계를 바꾸므로 주의하라고 설명한다
|
||||
@@ -0,0 +1,221 @@
|
||||
# Soft Delete 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 기본 soft delete 컬럼은 deleted_at으로 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
deleted_at timestamp with time zone
|
||||
);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- soft delete를 삭제 시점으로 표현한다
|
||||
- active row 조건이 `deleted_at IS NULL`로 단순해진다
|
||||
- boolean-only보다 운영 추적성이 좋다
|
||||
|
||||
Hibernate는 TIMESTAMP soft delete 전략이 삭제된 시점을 추적한다고 설명한다. 프로젝트 기본값을 `deleted_at`으로 두는 것은 그 전략과 잘 맞는다.
|
||||
|
||||
### 예시 2. active row uniqueness는 partial unique index로 강제한다
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_users__email__active
|
||||
ON auth.users (email)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- active row 사이에서만 이메일 중복을 막는다
|
||||
- soft-deleted row는 uniqueness 대상에서 제외할 수 있다
|
||||
- PostgreSQL이 공식적으로 지원하는 부분집합 uniqueness 패턴이다
|
||||
|
||||
PostgreSQL은 일부 row에만 적용되는 uniqueness restriction은 unique constraint가 아니라 unique partial index로 표현해야 한다고 설명한다.
|
||||
|
||||
### 예시 3. soft delete는 DELETE가 아니라 UPDATE로 수행한다
|
||||
|
||||
```sql
|
||||
UPDATE auth.users
|
||||
SET deleted_at = current_timestamp
|
||||
WHERE id = :userId
|
||||
AND deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- soft delete를 상태 전이로 표현한다
|
||||
- 이미 삭제된 row에 대한 중복 처리도 막기 쉽다
|
||||
- 일반 삭제와 물리 삭제를 분리하기 좋다
|
||||
|
||||
Hibernate는 soft delete를 실제 삭제 대신 indicator column update로 설명한다.
|
||||
|
||||
### 예시 4. Hibernate를 쓴다면 TIMESTAMP soft delete를 공식 기능으로 쓸 수 있다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Hibernate의 공식 soft delete 기능을 사용한다
|
||||
- TIMESTAMP 전략이 삭제 시점을 추적한다
|
||||
- 컬럼 이름을 프로젝트 표준인 `deleted_at`으로 맞출 수 있다
|
||||
|
||||
Hibernate는 `@SoftDelete`가 TIMESTAMP 전략을 지원하고, indicator column 이름은 `columnName`으로 정의할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. @ManyToMany / @ElementCollection join table에는 soft delete를 제한적으로 적용할 수 있다
|
||||
|
||||
```java
|
||||
@ManyToMany
|
||||
@JoinTable(
|
||||
name = "user_roles",
|
||||
joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "role_id")
|
||||
)
|
||||
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
|
||||
private Set<Role> roles;
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Hibernate 공식 지원 범위 안에서 join table row를 soft delete할 수 있다
|
||||
- 관계 row의 논리 삭제가 필요한 경우에만 제한적으로 쓸 수 있다
|
||||
- 엔티티 자체 soft delete와 컬렉션 테이블 soft delete를 구분한다
|
||||
|
||||
Hibernate는 `@SoftDelete`를 `@ElementCollection`과 `@ManyToMany` collection table에 적용할 수 있다고 설명한다.
|
||||
|
||||
### 예시 6. soft delete와 bulk purge를 분리한다
|
||||
|
||||
```java
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
delete from User u
|
||||
where u.deletedAt < :cutoff
|
||||
""")
|
||||
int purgeDeletedBefore(@Param("cutoff") OffsetDateTime cutoff);
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 일반 삭제는 soft delete 경로로 두고, 오래된 삭제 row 정리는 purge 전용 경로로 분리한다
|
||||
- bulk delete가 direct database delete임을 명시적으로 드러낸다
|
||||
- soft delete와 physical purge를 같은 경로로 섞지 않는다
|
||||
|
||||
Spring Data JPA는 JPQL/Criteria bulk delete가 DB 직접 delete로 매핑되고 persistence context를 동기화하지 않는다고 설명한다. 이런 방식은 purge 전용 경로에서만 명시적으로 쓰는 편이 안전하다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. soft delete 컬럼은 있지만 기본 조회에서 빼지 않는다
|
||||
|
||||
```sql
|
||||
SELECT id, email
|
||||
FROM auth.users
|
||||
ORDER BY id DESC;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 삭제 row가 일반 조회에 섞인다
|
||||
- soft delete가 조회 계약으로 완성되지 않는다
|
||||
- active-row partial index와도 잘 맞지 않는다
|
||||
|
||||
PostgreSQL은 partial index가 query WHERE 조건이 predicate를 함의할 때만 사용될 수 있다고 설명한다. active-row 조회는 predicate를 명시적으로 포함해야 한다.
|
||||
|
||||
### 예시 2. active-only uniqueness를 일반 UNIQUE로 둔다
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
deleted_at timestamp with time zone
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- soft-deleted row까지 포함해 테이블 전체에서 email 중복을 막아 버린다
|
||||
- "삭제된 이메일은 다시 쓸 수 있다"는 정책을 표현할 수 없다
|
||||
- active subset uniqueness가 아니라 전체-table uniqueness다
|
||||
|
||||
PostgreSQL은 일부 row에만 적용되는 uniqueness restriction은 unique constraint로 쓸 수 없고, partial unique index로 표현해야 한다고 설명한다.
|
||||
|
||||
### 예시 3. partial unique index natural key를 FK target으로 사용하려고 한다
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_users__email__active
|
||||
ON auth.users (email)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE auth.login_audit (
|
||||
user_email text REFERENCES auth.users(email)
|
||||
);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- FK 대상은 non-partial unique index 또는 PK/UNIQUE 제약이어야 한다
|
||||
- active-row partial unique index는 FK target 자격이 없다
|
||||
- soft delete와 natural key 참조를 섞으면 설계가 불안정해진다
|
||||
|
||||
PostgreSQL은 FK 참조 대상이 non-deferrable unique/primary key 제약 또는 non-partial unique index여야 한다고 설명한다.
|
||||
|
||||
### 예시 4. @OneToMany 컬렉션에 @SoftDelete를 붙인다
|
||||
|
||||
```java
|
||||
@OneToMany(mappedBy = "user")
|
||||
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
|
||||
private List<Session> sessions;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Hibernate 공식 지원 범위가 아니다
|
||||
- `@OneToMany`에 `@SoftDelete`를 붙이면 예외가 난다
|
||||
- 자식 엔티티 자체를 soft delete해야 한다
|
||||
|
||||
Hibernate는 `@OneToMany` association에 `@SoftDelete`를 붙이면 예외를 던진다고 설명한다.
|
||||
|
||||
### 예시 5. soft-deletable 엔티티를 batch delete로 지운다
|
||||
|
||||
```java
|
||||
userRepository.deleteAllInBatch(users);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 단일 query physical delete가 일어난다
|
||||
- persistence context가 DB와 동기화되지 않을 수 있다
|
||||
- JPA cascade semantics와 lifecycle events도 존중되지 않는다
|
||||
- soft delete semantics를 우회할 위험이 크다
|
||||
|
||||
Spring Data JPA는 `deleteAllInBatch(Iterable)`가 단일 query를 만들고, first level cache와 DB를 out of sync 상태로 만들 수 있으며, JPA cascade와 lifecycle event를 존중하지 않는다고 설명한다.
|
||||
|
||||
### 예시 6. restore를 uniqueness 검증 없이 수행한다
|
||||
|
||||
```sql
|
||||
UPDATE auth.users
|
||||
SET deleted_at = NULL
|
||||
WHERE id = :userId;
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 이메일을 가진 다른 active row가 이미 있으면 active subset uniqueness를 깨뜨릴 수 있다
|
||||
- restore는 단순 null 복원이 아니라 active set에 다시 들어가는 상태 전이다
|
||||
- partial unique index와 충돌 가능성을 고려해야 한다
|
||||
|
||||
PostgreSQL unique partial index는 predicate를 만족하는 row subset 안에서 uniqueness를 강제한다. restore는 그 subset으로 다시 들어가는 행위다.
|
||||
@@ -0,0 +1,317 @@
|
||||
# DB Transaction 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 쓰기 유스케이스 경계는 application service에서 잡는다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserRoleCommandService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
|
||||
@Transactional
|
||||
public void assignRole(Long userId, Long roleId) {
|
||||
User user = userRepository.getById(userId);
|
||||
Role role = roleRepository.getById(roleId);
|
||||
|
||||
user.assign(role);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 유스케이스 전체를 하나의 트랜잭션으로 묶는다
|
||||
- 여러 repository 호출을 outer service boundary가 소유한다
|
||||
- controller나 repository가 아니라 application service가 일관성 경계를 대표한다
|
||||
|
||||
Spring Data JPA는 여러 repository 호출을 묶는 facade/service가 transactional boundary를 정의한다고 설명한다.
|
||||
|
||||
### 예시 2. 조회 유스케이스는 readOnly=true를 최적화 힌트로 사용한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserQueryService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserDetailResponse getUserDetail(Long userId) {
|
||||
User user = userRepository.getDetailById(userId)
|
||||
.orElseThrow(UserNotFoundException::new);
|
||||
|
||||
return new UserDetailResponse(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getStatus()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 읽기 유스케이스임을 트랜잭션 속성으로 명확히 표현한다
|
||||
- readOnly=true를 최적화 힌트로 사용하고, write 시도를 섞지 않는다
|
||||
- 필요한 조회와 response mapping을 service 경계 안에서 끝낸다
|
||||
|
||||
Spring은 readOnly를 최적화 힌트로 설명하고, Spring Data JPA는 Hibernate 사용 시 flush mode를 NEVER로 두어 dirty check를 건너뛸 수 있다고 설명한다.
|
||||
|
||||
### 예시 3. checked exception도 롤백해야 하면 좁게 지정한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserImportService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional(rollbackFor = InvalidUserImportException.class)
|
||||
public void importUsers(List<UserImportRow> rows) throws InvalidUserImportException {
|
||||
for (UserImportRow row : rows) {
|
||||
if (!row.isValid()) {
|
||||
throw new InvalidUserImportException("invalid row");
|
||||
}
|
||||
userRepository.save(User.from(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- checked exception이 유스케이스 실패를 의미한다는 점을 transaction 설정에 반영한다
|
||||
- rollbackFor = Exception.class처럼 과도하게 넓히지 않는다
|
||||
- rollback 규칙이 예외 의미와 맞는다
|
||||
|
||||
Spring은 기본적으로 checked exception에서 rollback하지 않으며, rollback rules로 필요한 예외만 지정할 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. commit 이후에만 실행돼야 하는 후속 작업은 after-commit에 연결한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class UserCreatedEventHandler {
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void handle(UserCreatedEvent event) {
|
||||
// 메일 발송, 후속 발행, 외부 통지 등
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- DB commit 성공 이후에만 실행돼야 하는 후속 작업을 분리한다
|
||||
- 트랜잭션 내부 write와 외부 side effect를 같은 시점에 섞지 않는다
|
||||
- “저장 실패인데 메일은 발송됨” 같은 불일치를 줄일 수 있다
|
||||
|
||||
Spring은 @TransactionalEventListener가 AFTER_COMMIT 같은 phase를 지원한다고 설명한다.
|
||||
|
||||
### 예시 5. outer transaction이 있어야 하는 일반 호출 체인은 REQUIRED에 맡긴다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderCommandService {
|
||||
|
||||
private final PaymentService paymentService;
|
||||
private final OrderRepository orderRepository;
|
||||
|
||||
@Transactional
|
||||
public void confirmOrder(Long orderId) {
|
||||
Order order = orderRepository.getById(orderId);
|
||||
paymentService.validatePayment(order.getPaymentId());
|
||||
order.confirm();
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
public class PaymentService {
|
||||
|
||||
@Transactional
|
||||
public void validatePayment(Long paymentId) {
|
||||
// 같은 physical transaction에 참여
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 같은 유스케이스 안에서는 기본 propagation인 REQUIRED로 충분하다
|
||||
- 불필요하게 REQUIRES_NEW를 쓰지 않는다
|
||||
- 하나의 물리 트랜잭션 안에서 일관성을 유지한다
|
||||
|
||||
Spring은 PROPAGATION_REQUIRED가 common call stack arrangement에서 좋은 기본값이라고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller에 트랜잭션을 둔다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final UserRoleCommandService userRoleCommandService;
|
||||
|
||||
@PostMapping("/users/{id}/roles/{roleId}")
|
||||
@Transactional
|
||||
public void assignRole(@PathVariable Long id, @PathVariable Long roleId) {
|
||||
userRoleCommandService.assignRole(id, roleId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- request handling 범위 전체가 DB 트랜잭션에 포함되기 쉽다
|
||||
- controller는 입출력 경계이고, 일관성 경계를 소유하는 계층이 아니다
|
||||
- transaction scope를 불필요하게 길게 만든다
|
||||
|
||||
Spring Data JPA는 facade/service가 transactional boundary를 정의한다고 설명하고, Hibernate는 물리 트랜잭션은 가능한 짧아야 한다고 설명한다.
|
||||
|
||||
### 예시 2. self-invocation에 @Transactional을 기대한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
public void createUser(CreateUserRequest request) {
|
||||
validate(request);
|
||||
saveUser(request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
void saveUser(CreateUserRequest request) {
|
||||
// 저장
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 클래스 내부 호출이라 proxy를 통과하지 않는다
|
||||
- saveUser()의 @Transactional이 실제로 적용되지 않을 수 있다
|
||||
- 동작하는 것처럼 보여도 rollback 시나리오에서 깨지기 쉽다
|
||||
|
||||
Spring은 proxy mode에서 external method call만 interception 대상이고, self-invocation은 실제 트랜잭션을 만들지 않는다고 설명한다.
|
||||
|
||||
### 예시 3. readOnly=true에서 엔티티를 수정한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserQueryService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public void touchLastViewedAt(Long userId) {
|
||||
User user = userRepository.getById(userId);
|
||||
user.updateLastViewedAt();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- readOnly=true는 쓰기 차단 장치가 아니다
|
||||
- 코드 의미와 트랜잭션 의미가 서로 충돌한다
|
||||
- Hibernate 최적화와 코드 의도가 어긋난다
|
||||
|
||||
Spring과 Spring Data JPA는 readOnly를 최적화 힌트로 설명하며, write attempt 자체를 반드시 막지 않는다고 명시한다.
|
||||
|
||||
### 예시 4. 외부 호출을 길게 물고 있는 long transaction
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentCommandService {
|
||||
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final ExternalGatewayClient externalGatewayClient;
|
||||
|
||||
@Transactional
|
||||
public void approve(Long paymentId) {
|
||||
Payment payment = paymentRepository.getById(paymentId);
|
||||
|
||||
externalGatewayClient.call(payment); // 오래 걸리는 외부 호출
|
||||
|
||||
payment.approve();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 네트워크 대기 시간 동안 DB 트랜잭션이 열린 채로 유지될 수 있다
|
||||
- lock contention과 확장성 문제가 커진다
|
||||
- 외부 호출 실패와 DB 일관성 경계를 분리해서 설계해야 할 가능성이 높다
|
||||
|
||||
Hibernate는 DB 트랜잭션은 가능한 짧아야 하고, 긴 트랜잭션은 확장성을 해친다고 설명한다. Spring도 transaction context가 remote call로 전파되지 않는다고 설명한다.
|
||||
|
||||
### 예시 5. 무심코 rollbackFor = Exception.class를 붙인다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void doSomething() throws Exception {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 모든 checked exception을 일괄 rollback 대상으로 만들어 예외 의미 구분을 흐린다
|
||||
- 복구 가능한 checked exception까지 전부 트랜잭션 실패로 취급할 수 있다
|
||||
- rollback 규칙이 너무 넓다
|
||||
|
||||
Spring은 기본 rollback 규칙이 unchecked exception 중심이며, rollback rules는 필요한 예외에 맞춰 세밀하게 설정할 수 있다고 설명한다. 따라서 광범위한 기본 확대는 신중해야 한다.
|
||||
|
||||
### 예시 6. 일반 helper에 REQUIRES_NEW를 붙여 부분 커밋을 만든다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class AuditHelper {
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void saveAudit(AuditLog log) {
|
||||
// 저장
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- outer transaction과 무관하게 commit될 수 있다
|
||||
- partial commit을 만들기 때문에 business semantics가 달라진다
|
||||
- “트랜잭션 충돌 회피용”으로 쓰면 의도치 않은 데이터 잔존을 만든다
|
||||
|
||||
Spring은 REQUIRES_NEW가 항상 독립 물리 트랜잭션을 사용하고, outer rollback과 독립적으로 commit/rollback 된다고 설명한다.
|
||||
|
||||
### 예시 7. flush와 commit을 같은 것으로 가정한다
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void updateUser(Long id) {
|
||||
User user = userRepository.getById(id);
|
||||
user.changeName("new-name");
|
||||
|
||||
userRepository.findAll(); // "아직 commit 전이니까 DB에 영향 없겠지"라고 가정
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Hibernate는 겹치는 query 실행 전 flush를 일으킬 수 있다
|
||||
- commit 전에도 SQL이 먼저 나갈 수 있다
|
||||
- flush timing을 잘못 이해하면 query ordering과 side effect를 오판하게 된다
|
||||
|
||||
Hibernate는 기본 AUTO flush 모드에서 commit 전뿐 아니라, 겹치는 JPQL/HQL query 전과 native query 전에도 flush가 일어날 수 있다고 설명한다.
|
||||
@@ -0,0 +1,88 @@
|
||||
# common module 예시
|
||||
|
||||
## 좋은 예시 1: common 대신 owning module에 둠
|
||||
|
||||
```text
|
||||
presentation/support/response/ApiResult.java
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- HTTP 응답 구조는 presentation 소유다
|
||||
- 다른 레이어가 알 필요가 없다
|
||||
- 공용으로 빼면 오히려 경계가 흐려진다
|
||||
|
||||
## 좋은 예시 2: common 대신 module API로 노출
|
||||
|
||||
```text
|
||||
order/
|
||||
OrderManagement.java
|
||||
order/spi/
|
||||
package-info.java (@NamedInterface("spi"))
|
||||
OrderLookup.java
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 필요한 범위만 공개한다
|
||||
- 전체 common으로 빼지 않고 모듈 API를 좁게 노출한다
|
||||
|
||||
## 좋은 예시 3: 예외적으로 허용 가능한 작은 공용 타입
|
||||
|
||||
```text
|
||||
common/types/NormalizedHost.java
|
||||
```
|
||||
|
||||
**허용 조건:**
|
||||
|
||||
- 여러 모듈이 실제로 사용
|
||||
- framework/business/persistence 의존 없음
|
||||
- 값 기반 타입
|
||||
- 변화 이유가 동일함
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 진짜 공통 값 의미를 담는다
|
||||
- owning module이 특정되기 어렵다
|
||||
- 경계를 섞지 않는다
|
||||
|
||||
## 나쁜 예시 1: 잡동사니 common
|
||||
|
||||
```text
|
||||
common/
|
||||
StringUtils.java
|
||||
DateUtils.java
|
||||
ErrorUtils.java
|
||||
ValidationUtils.java
|
||||
AuthConstants.java
|
||||
ApiResult.java
|
||||
UserMapper.java
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 소유권이 불명확하다
|
||||
- web/domain/infrastructure가 섞인다
|
||||
- dump zone이 된다
|
||||
|
||||
## 나쁜 예시 2: 경계 회피용 common
|
||||
|
||||
```text
|
||||
common/UserDto.java
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- presentation DTO를 공용으로 올려 application/infrastructure도 기대게 만들 수 있다
|
||||
- DTO/Domain/Entity 경계가 무너진다
|
||||
|
||||
## 나쁜 예시 3: premature abstraction common
|
||||
|
||||
```text
|
||||
common/DeadlineHelper.java
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- task와 payment가 지금은 비슷해 보여도 미래에 독립 진화할 수 있다
|
||||
- owning module 안에 두는 편이 더 안전할 수 있다
|
||||
@@ -0,0 +1,136 @@
|
||||
# DTO / Domain / Entity separation 예시
|
||||
|
||||
## 좋은 예시 1: request DTO -> command -> domain
|
||||
|
||||
```java
|
||||
public record CreateUserRequest(
|
||||
String email,
|
||||
String password,
|
||||
String name
|
||||
) {}
|
||||
|
||||
public record CreateUserCommand(
|
||||
String email,
|
||||
String password,
|
||||
String name
|
||||
) {}
|
||||
|
||||
public final class UserWebMapper {
|
||||
|
||||
public CreateUserCommand toCommand(CreateUserRequest request) {
|
||||
return new CreateUserCommand(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 웹 입력 모델과 application 입력 모델이 분리된다
|
||||
- request binding과 business 의미 부여 경계가 생긴다
|
||||
|
||||
## 좋은 예시 2: entity -> domain 분리
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class UserJpaEntity {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String encodedPassword;
|
||||
private String name;
|
||||
protected UserJpaEntity() {}
|
||||
}
|
||||
|
||||
public class User {
|
||||
private final UserId id;
|
||||
private final UserEmail email;
|
||||
private final UserName name;
|
||||
private final EncodedPassword password;
|
||||
|
||||
private User(...) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- JPA 제약과 도메인 의미가 분리된다
|
||||
- domain이 persistence annotation에 오염되지 않는다
|
||||
|
||||
## 좋은 예시 3: domain -> response DTO 분리
|
||||
|
||||
```java
|
||||
public record UserResponse(
|
||||
Long id,
|
||||
String email,
|
||||
String name
|
||||
) {}
|
||||
|
||||
public final class UserResponseMapper {
|
||||
|
||||
public UserResponse toResponse(User user) {
|
||||
return new UserResponse(
|
||||
user.id().value(),
|
||||
user.email().value(),
|
||||
user.name().value()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 외부 응답 계약이 명시적이다
|
||||
- domain 전체를 그대로 노출하지 않는다
|
||||
|
||||
## 나쁜 예시 1: entity를 바로 response로 반환
|
||||
|
||||
```java
|
||||
@GetMapping("/{id}")
|
||||
public UserJpaEntity getUser(@PathVariable Long id) {
|
||||
return userRepository.findById(id).orElseThrow();
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- persistence 구조가 외부 계약으로 새어 나간다
|
||||
- 민감정보/지연로딩/관계 구조가 노출될 수 있다
|
||||
- API와 persistence가 강하게 결합된다
|
||||
|
||||
## 나쁜 예시 2: request DTO를 그대로 domain으로 사용
|
||||
|
||||
```java
|
||||
public User createUser(CreateUserRequest request) {
|
||||
return userService.create(request);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 웹 입력 모델이 business layer로 직접 흘러간다
|
||||
- validation/binding shape가 domain/application 설계를 오염시킨다
|
||||
|
||||
## 나쁜 예시 3: domain에 JPA/JSON/validation annotation 혼합
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@JsonProperty("email")
|
||||
@NotBlank
|
||||
private String email;
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- persistence / serialization / validation / business 의미가 한 타입에 섞인다
|
||||
- 변경 이유가 서로 다른 관심사가 강결합된다
|
||||
@@ -0,0 +1,141 @@
|
||||
# interface 생성 예시
|
||||
|
||||
## 좋은 예시 1: application output port
|
||||
|
||||
```java
|
||||
public interface UserReader {
|
||||
Optional<User> findByEmail(UserEmail email);
|
||||
Optional<User> findById(UserId userId);
|
||||
}
|
||||
|
||||
@Repository
|
||||
public class JpaUserReader implements UserReader {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- application이 persistence 구현을 모른다
|
||||
- 바깥 구현 교체와 테스트 대역 주입이 쉽다
|
||||
- 레이어 경계가 분명하다
|
||||
|
||||
## 좋은 예시 2: 외부 시스템 client contract
|
||||
|
||||
```java
|
||||
public interface VaultTransitClient {
|
||||
String sign(String keyName, byte[] input);
|
||||
PublicKey readPublicKey(String keyName);
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 외부 연동 경계가 분명하다
|
||||
- HttpClient/WebClient/Jackson 세부가 계약에 새지 않는다
|
||||
- fake/stub 구현으로 테스트하기 쉽다
|
||||
|
||||
## 좋은 예시 3: 교체 가능한 정책 객체
|
||||
|
||||
```java
|
||||
public interface PasswordHasher {
|
||||
String hash(String rawPassword);
|
||||
boolean matches(String rawPassword, String encodedPassword);
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 알고리즘 교체 가능성이 실제로 있다
|
||||
- application/domain이 구체 해시 구현을 모른다
|
||||
|
||||
## 좋은 예시 4: 인터페이스 없이 concrete class 유지
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class LoginResponseAssembler {
|
||||
public LoginResponse toResponse(User user, TokenPair tokenPair) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 내부 presentation helper일 뿐 계약 경계가 아니다
|
||||
- 구현체 1개, 교체 가치 낮음, 인터페이스 이득 작음
|
||||
- 불필요한 LoginResponseAssemblerImpl을 만들지 않는다
|
||||
|
||||
## 나쁜 예시 1: 의미 없는 Service/Impl 쌍
|
||||
|
||||
```java
|
||||
public interface UserService {
|
||||
User create(CreateUserCommand command);
|
||||
}
|
||||
|
||||
@Service
|
||||
public class UserServiceImpl implements UserService {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 실제 경계/교체/테스트 seam 의미가 약하다
|
||||
- 타입만 늘고 추상화 이득이 거의 없다
|
||||
- “관성적인 인터페이스”에 가깝다
|
||||
|
||||
## 나쁜 예시 2: 기술 세부를 계약에 노출
|
||||
|
||||
```java
|
||||
public interface UserClient {
|
||||
ResponseEntity<String> getUser(String id);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- Spring Web 타입이 계약에 박힌다
|
||||
- 호출자가 구현 기술에 묶인다
|
||||
|
||||
**개선:**
|
||||
|
||||
- 도메인/애플리케이션에 더 맞는 결과 타입으로 계약 정의
|
||||
|
||||
## 나쁜 예시 3: 여러 책임을 한 인터페이스에 몰아넣기
|
||||
|
||||
```java
|
||||
public interface UserManager {
|
||||
User findUser(...);
|
||||
User saveUser(...);
|
||||
void sendEmail(...);
|
||||
String issueToken(...);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 하나의 역할이 아니다
|
||||
- 호출자마다 일부만 필요할 가능성이 높다
|
||||
- 응집도가 낮다
|
||||
|
||||
## 나쁜 예시 4: 조기 추상화
|
||||
|
||||
```java
|
||||
public interface DeadlineService {
|
||||
void setDeadline(...);
|
||||
}
|
||||
|
||||
public class TaskDeadlineService implements DeadlineService { ... }
|
||||
|
||||
public class PaymentDeadlineService implements DeadlineService { ... }
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 지금은 비슷해 보여도 미래에 다르게 진화할 수 있다
|
||||
- 아직 공통 계약이 자연스러운지 검증되지 않았다
|
||||
|
||||
**개선 방향:**
|
||||
|
||||
- 충분한 공통성/경계 필요가 생길 때까지 분리된 concrete class 유지
|
||||
@@ -0,0 +1,152 @@
|
||||
# mapper separation 예시
|
||||
|
||||
## 좋은 예시 1: web request -> command 매핑
|
||||
|
||||
```java
|
||||
public final class UserWebMapper {
|
||||
|
||||
public CreateUserCommand toCommand(CreateUserRequest request) {
|
||||
return new CreateUserCommand(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- HTTP request 구조를 application command 구조로만 번역한다
|
||||
- 비즈니스 정책을 결정하지 않는다
|
||||
|
||||
## 좋은 예시 2: domain -> response DTO 매핑
|
||||
|
||||
```java
|
||||
public final class UserResponseMapper {
|
||||
|
||||
public UserResponse toResponse(User user) {
|
||||
return new UserResponse(
|
||||
user.id().value(),
|
||||
user.email().value(),
|
||||
user.name().value()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 응답 계약만 만든다
|
||||
- repository/service 호출이 없다
|
||||
|
||||
## 좋은 예시 3: persistence entity -> domain 매핑 분리
|
||||
|
||||
```java
|
||||
public final class UserPersistenceMapper {
|
||||
|
||||
public User toDomain(UserJpaEntity entity) {
|
||||
return User.restore(
|
||||
entity.getId(),
|
||||
entity.getEmail(),
|
||||
entity.getName(),
|
||||
entity.getEncodedPassword()
|
||||
);
|
||||
}
|
||||
|
||||
public UserJpaEntity toEntity(User user) {
|
||||
return new UserJpaEntity(
|
||||
user.id().value(),
|
||||
user.email().value(),
|
||||
user.name().value(),
|
||||
user.password().encodedValue()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- persistence 구조와 domain 구조를 별도 경계에서 번역한다
|
||||
- JPA 세부가 domain으로 직접 새지 않는다
|
||||
|
||||
## 좋은 예시 4: update mapping을 명시적으로 분리
|
||||
|
||||
```java
|
||||
public interface UserPersistenceMapper {
|
||||
|
||||
UserJpaEntity toNewEntity(User user);
|
||||
|
||||
void updateEntity(User user, UserJpaEntity target);
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 생성과 수정의 계약이 다름을 드러낸다
|
||||
- side effect가 있는 매핑을 명시한다
|
||||
|
||||
## 나쁜 예시 1: 매퍼에서 repository 호출
|
||||
|
||||
```java
|
||||
public final class UserMapper {
|
||||
|
||||
private final RoleRepository roleRepository;
|
||||
|
||||
public User toDomain(UserRequest request) {
|
||||
Role role = roleRepository.findByName(request.roleName()).orElseThrow();
|
||||
return User.create(request.email(), role);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 매퍼가 번역을 넘어 DB 조회까지 한다
|
||||
- 테스트와 책임 분리가 어려워진다
|
||||
|
||||
**개선:**
|
||||
|
||||
- 호출자가 Role을 먼저 준비해서 전달한다
|
||||
|
||||
## 나쁜 예시 2: 매퍼에서 비즈니스 규칙 결정
|
||||
|
||||
```java
|
||||
public UserStatus toStatus(UserRequest request) {
|
||||
if (request.provider().equals("google")) {
|
||||
return UserStatus.ACTIVE;
|
||||
}
|
||||
return UserStatus.PENDING;
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 상태 결정 정책이 매퍼에 숨어 있다
|
||||
- 단순 구조 변환이 아니라 비즈니스 의미를 만든다
|
||||
|
||||
**개선:**
|
||||
|
||||
- status 결정은 application/domain 정책으로 이동
|
||||
|
||||
## 나쁜 예시 3: web + persistence + integration를 한 매퍼에 몰아넣기
|
||||
|
||||
```java
|
||||
public final class UserMapper {
|
||||
CreateUserCommand toCommand(CreateUserRequest request) { ... }
|
||||
UserJpaEntity toEntity(User user) { ... }
|
||||
ExternalUserPayload toPayload(User user) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 경계가 섞인다
|
||||
- 변경 이유가 달라 함께 진화하기 어렵다
|
||||
|
||||
**개선:**
|
||||
|
||||
- UserWebMapper
|
||||
- UserPersistenceMapper
|
||||
- UserExternalMapper
|
||||
- 로 분리
|
||||
@@ -0,0 +1,127 @@
|
||||
# port abstraction 예시
|
||||
|
||||
## 좋은 예시 1: outbound port를 application이 소유
|
||||
|
||||
```java
|
||||
public interface UserReader {
|
||||
Optional<User> findByEmail(UserEmail email);
|
||||
Optional<User> findById(UserId userId);
|
||||
}
|
||||
|
||||
@Repository
|
||||
public class JpaUserReader implements UserReader {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- application이 persistence 기술을 모른다
|
||||
- 코어가 필요한 조회 능력만 계약으로 드러난다
|
||||
- adapter만 JPA를 안다
|
||||
|
||||
## 좋은 예시 2: inbound port를 use case 계약으로 사용
|
||||
|
||||
```java
|
||||
public interface LoginUseCase {
|
||||
LoginResult login(LoginCommand command);
|
||||
}
|
||||
|
||||
@RestController
|
||||
class LoginController {
|
||||
private final LoginUseCase loginUseCase;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- controller가 구현체보다 use case 계약에 의존한다
|
||||
- HTTP 세부와 비즈니스 흐름이 분리된다
|
||||
|
||||
## 좋은 예시 3: external API 경계 포트
|
||||
|
||||
```java
|
||||
public interface TokenSigner {
|
||||
Signature sign(SigningRequest request);
|
||||
}
|
||||
|
||||
public class VaultTokenSigner implements TokenSigner {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 포트는 “서명한다”는 능력만 표현한다
|
||||
- HTTP, JSON, Vault path/header는 adapter 구현으로 숨긴다
|
||||
|
||||
## 좋은 예시 4: 하나의 포트에 여러 adapter 가능
|
||||
|
||||
```java
|
||||
public interface RateRepository {
|
||||
BigDecimal findDiscountRate(Money amount);
|
||||
}
|
||||
|
||||
public class InMemoryRateRepository implements RateRepository { ... }
|
||||
|
||||
public class JdbcRateRepository implements RateRepository { ... }
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 테스트와 운영 구현이 같은 계약을 공유한다
|
||||
- 포트는 기술 수와 무관하게 같은 대화를 표현한다
|
||||
|
||||
## 나쁜 예시 1: 기술 타입이 새는 포트
|
||||
|
||||
```java
|
||||
public interface UserApiPort {
|
||||
ResponseEntity<String> getUser(String id);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- HTTP 세부가 코어 계약으로 올라온다
|
||||
- 비즈니스 의미가 아니라 transport 형식이 중심이 된다
|
||||
|
||||
## 나쁜 예시 2: adapter 편의 중심 포트
|
||||
|
||||
```java
|
||||
public interface DatabasePort {
|
||||
String query(String sql);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 코어가 SQL/DB 기술 세부를 알게 된다
|
||||
- “무엇을 원하나”가 아니라 “어떻게 하냐”를 말한다
|
||||
|
||||
## 나쁜 예시 3: 너무 범용적인 outbound port
|
||||
|
||||
```java
|
||||
public interface ExternalSystemPort {
|
||||
Object execute(Object input);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 역할이 불명확하다
|
||||
- 타입 안정성과 계약 의미가 없다
|
||||
- 여러 외부 시스템 책임을 한 곳에 섞기 쉽다
|
||||
|
||||
## 나쁜 예시 4: 내부 helper까지 포트화
|
||||
|
||||
```java
|
||||
public interface EmailNormalizerPort {
|
||||
String normalize(String raw);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 외부 경계가 아니라 내부 로직 detail이다
|
||||
- 포트 추상화 비용이 이득보다 크다
|
||||
@@ -0,0 +1,136 @@
|
||||
# value object 예시
|
||||
|
||||
## 좋은 예시 1: 이메일 Value Object
|
||||
|
||||
```java
|
||||
public record UserEmail(String value) {
|
||||
|
||||
public UserEmail {
|
||||
Objects.requireNonNull(value, "value must not be null");
|
||||
value = value.trim().toLowerCase(Locale.ROOT);
|
||||
|
||||
if (value.isBlank()) {
|
||||
throw new IllegalArgumentException("email must not be blank");
|
||||
}
|
||||
if (!EMAIL_PATTERN.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("invalid email format");
|
||||
}
|
||||
}
|
||||
|
||||
public static UserEmail from(String raw) {
|
||||
return new UserEmail(raw);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 문자열 의미를 타입으로 끌어올린다
|
||||
- 정규화와 검증이 한 곳에 모인다
|
||||
- 값 기반 equality가 자연스럽다
|
||||
|
||||
## 좋은 예시 2: 금액 Value Object
|
||||
|
||||
```java
|
||||
public record Money(BigDecimal amount) {
|
||||
|
||||
public Money {
|
||||
Objects.requireNonNull(amount, "amount must not be null");
|
||||
amount = amount.setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
if (amount.signum() < 0) {
|
||||
throw new IllegalArgumentException("amount must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
public Money add(Money other) {
|
||||
return new Money(this.amount.add(other.amount));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 숫자 primitive를 그대로 흘리지 않는다
|
||||
- scale/음수 금지 규칙이 타입에 들어간다
|
||||
- 값 관련 행위가 같이 있다
|
||||
|
||||
## 좋은 예시 3: entity와 분리된 domain Value Object
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class UserJpaEntity {
|
||||
private String email;
|
||||
}
|
||||
|
||||
public record UserEmail(String value) { ... }
|
||||
|
||||
public final class UserPersistenceMapper {
|
||||
public User toDomain(UserJpaEntity entity) {
|
||||
return User.restore(
|
||||
UserEmail.from(entity.getEmail())
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- persistence 문자열과 domain 의미 타입이 분리된다
|
||||
- domain invariant를 mapper 경계에서 회복한다
|
||||
|
||||
## 나쁜 예시 1: identity를 가진 것을 Value Object처럼 사용
|
||||
|
||||
```java
|
||||
public record User(Long id, String name) {}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- User는 identity가 본질인 entity일 가능성이 높다
|
||||
- 값 객체로 만들면 의미가 흐려진다
|
||||
|
||||
## 나쁜 예시 2: mutable Value Object
|
||||
|
||||
```java
|
||||
public class UserName {
|
||||
private String value;
|
||||
|
||||
public void setValue(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 생성 후 불변이 아니다
|
||||
- 검증/정규화 이후 상태가 깨질 수 있다
|
||||
|
||||
## 나쁜 예시 3: 의미 없는 래퍼
|
||||
|
||||
```java
|
||||
public record NameString(String value) {}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- business meaning이 약하다
|
||||
- 검증/정규화/행위가 전혀 없다
|
||||
- 래퍼 비용만 생길 수 있다
|
||||
|
||||
## 나쁜 예시 4: Value Object에서 외부 의존
|
||||
|
||||
```java
|
||||
public class UserEmail {
|
||||
public boolean exists(UserRepository repository) {
|
||||
return repository.existsByEmail(value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 값 객체가 외부 의존과 오케스트레이션을 떠안는다
|
||||
- 순수한 값 의미 타입이 아니다
|
||||
@@ -0,0 +1,216 @@
|
||||
# External API Client Structure 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. imperative 서비스에서 RestClient adapter를 infrastructure에 둔다
|
||||
|
||||
```java
|
||||
public interface ExternalTokenPort {
|
||||
ExternalTokenResult issueToken(ExternalTokenCommand command);
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
class KeycloakTokenClient implements ExternalTokenPort {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final KeycloakTokenMapper keycloakTokenMapper;
|
||||
|
||||
KeycloakTokenClient(RestClient.Builder restClientBuilder,
|
||||
KeycloakProperties properties,
|
||||
KeycloakAuthHeaderCustomizer authHeaderCustomizer) {
|
||||
this.restClient = restClientBuilder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.defaultHeader("User-Agent", "project-auth-server")
|
||||
.requestInterceptor(authHeaderCustomizer)
|
||||
.build();
|
||||
this.keycloakTokenMapper = new KeycloakTokenMapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExternalTokenResult issueToken(ExternalTokenCommand command) {
|
||||
KeycloakTokenRequest request = keycloakTokenMapper.toRequest(command);
|
||||
|
||||
KeycloakTokenResponse response = restClient.post()
|
||||
.uri("/protocol/openid-connect/token")
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.body(KeycloakTokenResponse.class);
|
||||
|
||||
return keycloakTokenMapper.toResult(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 호출이 infrastructure adapter에 있다
|
||||
- RestClient.Builder를 주입받아 공통 구성과 관측을 따른다
|
||||
- 외부 DTO와 내부 결과가 분리된다.
|
||||
|
||||
### 예시 2. reactive 경계에서는 WebClient를 사용한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class ExternalAuditClient {
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
ExternalAuditClient(WebClient.Builder webClientBuilder, AuditProperties properties) {
|
||||
this.webClient = webClientBuilder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.build();
|
||||
}
|
||||
|
||||
Mono<Void> send(AuditEventRequest request) {
|
||||
return webClient.post()
|
||||
.uri("/events")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- non-blocking 흐름에 맞는 client를 쓴다
|
||||
- Boot가 권장하는 WebClient.Builder 주입 방식을 따른다.
|
||||
|
||||
### 예시 3. HTTP Service Client를 group 기반으로 묶는다
|
||||
|
||||
```java
|
||||
@HttpExchange
|
||||
public interface KeycloakUserHttpClient {
|
||||
|
||||
@GetExchange("/admin/realms/{realm}/users/{id}")
|
||||
KeycloakUserResponse getUser(@PathVariable String realm, @PathVariable String id);
|
||||
}
|
||||
|
||||
@ImportHttpServices(group = "keycloak", types = KeycloakUserHttpClient.class)
|
||||
@Configuration
|
||||
class KeycloakHttpClientsConfiguration {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 선언형 인터페이스로 계약이 분명하다
|
||||
- group을 통해 URL, timeout, SSL, auth customization을 함께 묶을 수 있다.
|
||||
|
||||
### 예시 4. 외부 DTO와 내부 결과를 명시적으로 분리한다
|
||||
|
||||
```java
|
||||
public record KeycloakUserResponse(
|
||||
@JsonProperty("id") String id,
|
||||
@JsonProperty("email") String email,
|
||||
@JsonProperty("enabled") boolean enabled
|
||||
) {
|
||||
}
|
||||
|
||||
public record ExternalUserResult(
|
||||
String externalUserId,
|
||||
String email,
|
||||
boolean active
|
||||
) {
|
||||
}
|
||||
|
||||
public class KeycloakUserMapper {
|
||||
|
||||
ExternalUserResult toResult(KeycloakUserResponse response) {
|
||||
return new ExternalUserResult(
|
||||
response.id(),
|
||||
response.email(),
|
||||
response.enabled()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider JSON 계약이 내부 모델로 그대로 번지지 않는다
|
||||
- 필드명 mismatch와 provider 의미를 adapter 경계에 가둔다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 외부 API를 직접 호출한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
class BadTokenController {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
@PostMapping("/api/v1/tokens")
|
||||
ApiResult<?> create(@RequestBody CreateTokenRequest request) {
|
||||
KeycloakTokenResponse response = restClientBuilder.build()
|
||||
.post()
|
||||
.uri("https://keycloak.example.com/token")
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.body(KeycloakTokenResponse.class);
|
||||
|
||||
return ApiResult.success(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller가 외부 연동과 transport 변환을 직접 수행한다
|
||||
- base URL이 하드코딩돼 있다
|
||||
- 외부 DTO가 내부 API 응답으로 그대로 노출된다
|
||||
|
||||
### 예시 2. 외부 DTO를 application 시그니처에 그대로 넘긴다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class BadIssueTokenService {
|
||||
|
||||
public void issue(KeycloakTokenRequest request) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 provider 계약에 결합된다
|
||||
- 외부 필드명/형식 변화가 내부 계층으로 번진다
|
||||
|
||||
### 예시 3. RestClient.create()를 직접 써서 공통 구성을 우회한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class BadExternalClient {
|
||||
|
||||
private final RestClient client = RestClient.create("https://example.org");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Boot auto-configuration, customizer, instrumentation 적용을 우회한다.
|
||||
|
||||
### 예시 4. provider-specific 예외를 그대로 내부로 던진다
|
||||
|
||||
```java
|
||||
public ExternalUserResult getUser(String id) {
|
||||
try {
|
||||
return webClient.get()
|
||||
.uri("/users/{id}", id)
|
||||
.retrieve()
|
||||
.bodyToMono(ExternalUserResult.class)
|
||||
.block();
|
||||
} catch (WebClientResponseException ex) {
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 HTTP status와 client exception 타입에 직접 묶인다
|
||||
- 예외 번역 책임이 adapter 밖으로 새어 나간다
|
||||
@@ -0,0 +1,191 @@
|
||||
# Fallback 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 외부 추천 실패 시 빈 추천 목록으로 degrade한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecommendationIntegrationService {
|
||||
|
||||
private final RecommendationClient recommendationClient;
|
||||
|
||||
public RecommendationResult getRecommendations(String userId) {
|
||||
try {
|
||||
return recommendationClient.getRecommendations(userId);
|
||||
} catch (ExternalRecommendationTemporaryFailure ex) {
|
||||
return RecommendationResult.degradedEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 추천은 soft dependency로 다룰 수 있다
|
||||
- 핵심 기능을 깨지 않고 degraded mode를 제공한다
|
||||
- fallback 위치가 integration 경계에 있다
|
||||
|
||||
### 예시 2. 캐시된 공개키로 fallback한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class JwkIntegrationService {
|
||||
|
||||
private final JwkClient jwkClient;
|
||||
private final JwkCache jwkCache;
|
||||
|
||||
public JwkSetResult getJwkSet() {
|
||||
try {
|
||||
JwkSetResult result = jwkClient.fetch();
|
||||
jwkCache.put(result);
|
||||
return result;
|
||||
} catch (ExternalJwkTemporaryFailure ex) {
|
||||
return jwkCache.get()
|
||||
.orElseThrow(() -> ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 조회성 데이터에 짧은 TTL 캐시 fallback을 적용할 수 있다
|
||||
- fallback 가능성과 불가능성이 함께 표현된다
|
||||
- 외부 실패를 무조건 숨기지 않는다
|
||||
|
||||
### 예시 3. CircuitBreaker fallback을 명시적으로 둔다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalProfileService {
|
||||
|
||||
private final CircuitBreakerFactory<?, ?> circuitBreakerFactory;
|
||||
private final ExternalProfileClient externalProfileClient;
|
||||
|
||||
public ProfileSupplementResult getSupplement(String userId) {
|
||||
return circuitBreakerFactory.create("external-profile")
|
||||
.run(
|
||||
() -> externalProfileClient.getProfile(userId),
|
||||
throwable -> ProfileSupplementResult.degradedUnavailable()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring Cloud CircuitBreaker의 공식 fallback 모델을 따른다
|
||||
- fallback 결과가 별도 degraded result로 표현된다.
|
||||
|
||||
### 예시 4. 이메일 발송은 비동기 접수로 degrade할 수 있다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MailIntegrationService {
|
||||
|
||||
private final MailClient mailClient;
|
||||
private final MailOutboxRepository mailOutboxRepository;
|
||||
|
||||
public MailDispatchResult sendVerificationMail(MailCommand command) {
|
||||
try {
|
||||
mailClient.send(command);
|
||||
return MailDispatchResult.sent();
|
||||
} catch (ExternalMailTemporaryFailure ex) {
|
||||
mailOutboxRepository.enqueue(command);
|
||||
return MailDispatchResult.acceptedForRetry();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 즉시 발송 실패를 비동기 재처리로 전환한다
|
||||
- API 의미를 “즉시 완료”가 아니라 “접수됨”으로 명확히 바꿀 수 있다
|
||||
- hard dependency를 soft dependency로 바꾸는 사례다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 결제 확정 실패를 성공처럼 fallback한다
|
||||
|
||||
```java
|
||||
public PaymentCaptureResult capture(CaptureCommand command) {
|
||||
try {
|
||||
return paymentClient.capture(command);
|
||||
} catch (Exception ex) {
|
||||
return PaymentCaptureResult.success();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 결제 확정 실패를 성공처럼 숨긴다
|
||||
- 정합성과 감사 가능성을 깨뜨린다
|
||||
- fallback을 쓰면 안 되는 대표 사례다
|
||||
|
||||
### 예시 2. 오래된 캐시를 무기한 사용한다
|
||||
|
||||
```java
|
||||
public ExchangeRateResult getRate(String currency) {
|
||||
try {
|
||||
return exchangeRateClient.getRate(currency);
|
||||
} catch (Exception ex) {
|
||||
return foreverCache.get(currency);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- stale budget이 없다
|
||||
- 오래된 데이터를 최신 사실처럼 쓰게 된다
|
||||
- 운영에서 품질 저하를 통제할 수 없다
|
||||
|
||||
### 예시 3. controller에서 fallback을 직접 구현한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final ExternalProfileClient externalProfileClient;
|
||||
|
||||
@GetMapping("/api/v1/users/{userId}")
|
||||
public ApiResult<UserResponse> get(@PathVariable String userId) {
|
||||
try {
|
||||
ExternalProfileResponse response = externalProfileClient.getProfile(userId);
|
||||
return ApiResult.success(UserResponse.from(response));
|
||||
} catch (Exception ex) {
|
||||
return ApiResult.success(UserResponse.withoutProfile());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- fallback이 controller로 새어 나갔다
|
||||
- provider-aware 로직이 presentation 경계에 있다
|
||||
- 공통 observability와 정책 일관성이 깨진다
|
||||
|
||||
### 예시 4. fallback 발생을 전혀 기록하지 않는다
|
||||
|
||||
```java
|
||||
try {
|
||||
return recommendationClient.getRecommendations(userId);
|
||||
} catch (Exception ex) {
|
||||
return RecommendationResult.degradedEmpty();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- degraded mode가 운영에서 보이지 않는다
|
||||
- fallback rate를 추적할 수 없다
|
||||
- upstream 장애가 숨어 버린다
|
||||
@@ -0,0 +1,133 @@
|
||||
# Integration Idempotency 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. provider 공식 header를 adapter에서 설정한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
class StripePaymentClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
ChargeResult createCharge(CreateChargeCommand command) {
|
||||
return restClient.post()
|
||||
.uri("/v1/payment_intents")
|
||||
.header("Idempotency-Key", command.providerIdempotencyKey())
|
||||
.body(StripeCreateChargeRequest.from(command))
|
||||
.retrieve()
|
||||
.body(ChargeResult.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider 공식 idempotency header를 adapter 경계에서 설정한다
|
||||
- application/domain이 HTTP 헤더 이름을 몰라도 된다
|
||||
- 같은 command 재전송 시 같은 key를 쓸 수 있다.
|
||||
|
||||
### 예시 2. 내부 command id와 provider key를 분리해 관리한다
|
||||
|
||||
```java
|
||||
public record OutboundCallKey(
|
||||
String outboundCommandId,
|
||||
String provider,
|
||||
String operation,
|
||||
String providerIdempotencyKey
|
||||
) {
|
||||
}
|
||||
|
||||
public record OutboundFingerprint(
|
||||
String requestDigest
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 내부 추적 키와 provider 전송 키가 분리된다
|
||||
- provider별 operation scope 차이를 표현하기 쉽다
|
||||
- fingerprint 충돌 검사를 붙이기 좋다
|
||||
|
||||
### 예시 3. timeout 후 같은 key로 재전송한다
|
||||
|
||||
```java
|
||||
try {
|
||||
return paypalCaptureClient.capture(command);
|
||||
} catch (ExternalTimeoutException ex) {
|
||||
return paypalCaptureClient.capture(command.withSameProviderIdempotencyKey());
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- side effect 재시도 시 새 key를 만들지 않는다
|
||||
- 같은 요청 의도에 같은 provider key를 재사용한다
|
||||
- PayPal/Stripe 문서 취지와 맞는다.
|
||||
|
||||
### 예시 4. 같은 key 동시 송신을 막는다
|
||||
|
||||
```java
|
||||
if (!outboundIdempotencyCoordinator.tryAcquire(command.provider(), command.operation(), command.providerIdempotencyKey())) {
|
||||
throw new DuplicateOutboundCallInProgressException();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 같은 key 두 번 동시 전송을 줄인다
|
||||
- PayPal이 설명한 concurrent duplicate 문제를 완화할 수 있다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. timeout 후 새 key로 다시 보낸다
|
||||
|
||||
```java
|
||||
try {
|
||||
return stripeClient.createCharge(command.withNewProviderIdempotencyKey());
|
||||
} catch (ExternalTimeoutException ex) {
|
||||
return stripeClient.createCharge(command.withNewProviderIdempotencyKey());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 외부 side effect 요청이 새 요청으로 처리될 수 있다
|
||||
- 중복 생성/중복 결제 위험이 커진다
|
||||
|
||||
### 예시 2. 같은 key를 다른 operation에 재사용한다
|
||||
|
||||
```java
|
||||
String key = "7f6d...";
|
||||
authorizePayment(key);
|
||||
capturePayment(key);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider마다 operation scope가 다를 수 있다
|
||||
- PayPal은 API call type 단위 고유성을 요구한다.
|
||||
|
||||
### 예시 3. provider key에 이메일을 넣는다
|
||||
|
||||
```java
|
||||
String providerIdempotencyKey = request.email() + ":" + request.orderId();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PII가 key에 섞인다
|
||||
- Stripe도 민감정보를 key로 쓰지 말라고 권고한다.
|
||||
|
||||
### 예시 4. replay semantics를 무시하고 항상 “새 성공”으로 해석한다
|
||||
|
||||
```java
|
||||
return new PaymentCapturedResult(true, true);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider가 이전 결과 재생인지 최신 상태 조회인지 구분하지 못한다
|
||||
- 내부 감사/운영 추적이 부정확해진다
|
||||
@@ -0,0 +1,159 @@
|
||||
# Retry 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. retry 대상 예외와 backoff를 명시한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalTokenGateway {
|
||||
|
||||
private final ExternalTokenClient externalTokenClient;
|
||||
|
||||
@Retryable(
|
||||
retryFor = {
|
||||
SocketTimeoutException.class,
|
||||
ConnectException.class,
|
||||
ResourceAccessException.class
|
||||
},
|
||||
noRetryFor = {
|
||||
IllegalArgumentException.class,
|
||||
ExternalAuthenticationRejectedException.class
|
||||
},
|
||||
maxAttempts = 3,
|
||||
backoff = @Backoff(delay = 300, maxDelay = 2_000, multiplier = 2.0, random = true)
|
||||
)
|
||||
public TokenResult issueToken(TokenCommand command) {
|
||||
return externalTokenClient.issueToken(command);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- retry 대상을 좁혔다
|
||||
- business rejection은 제외했다
|
||||
- backoff + jitter 성격(random = true)을 명시했다.
|
||||
|
||||
### 예시 2. 최종 실패만 내부 예외로 번역한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class KeycloakTokenClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
public TokenResult issue(TokenCommand command) {
|
||||
try {
|
||||
return doIssue(command);
|
||||
} catch (HttpServerErrorException | ResourceAccessException ex) {
|
||||
throw new ExternalAuthTemporaryFailureException(ex);
|
||||
} catch (HttpClientErrorException.Unauthorized ex) {
|
||||
throw new ExternalAuthRejectedException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private TokenResult doIssue(TokenCommand command) {
|
||||
return restClient.post()
|
||||
.uri("/protocol/openid-connect/token")
|
||||
.body(command)
|
||||
.retrieve()
|
||||
.body(TokenResult.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider-specific HTTP 오류를 내부 의미로 번역한다
|
||||
- application이 raw HTTP client 예외를 직접 보지 않는다
|
||||
|
||||
### 예시 3. retry 후 성공은 WARN으로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("External auth request succeeded after retry. provider={} operation={} attempts={}",
|
||||
"keycloak", "issue-token", attemptCount);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 중간 장애 징후를 추적 가능하게 남긴다
|
||||
- 최종 성공을 장애처럼 ERROR로 과장하지 않는다
|
||||
|
||||
### 예시 4. provider rate limit 신호를 존중한다
|
||||
|
||||
```java
|
||||
if (response.getStatusCode().value() == 429) {
|
||||
Duration retryAfter = parseRetryAfter(response.getHeaders());
|
||||
throw new RetryableRateLimitedException(retryAfter);
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider가 주는 throttling 신호를 반영할 수 있다
|
||||
- 무작정 같은 간격으로 재시도하지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 모든 예외를 그대로 retry한다
|
||||
|
||||
```java
|
||||
@Retryable
|
||||
public void callExternalApi() {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring 기본값은 모든 예외를 재시도할 수 있다
|
||||
- deterministic failure와 business rejection까지 재시도될 수 있다.
|
||||
|
||||
### 예시 2. backoff 없이 즉시 재시도한다
|
||||
|
||||
```java
|
||||
for (int i = 0; i < 3; i++) {
|
||||
try {
|
||||
return call();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- retry without backoff anti-pattern이다
|
||||
- 순간 장애 시 부하를 더 키운다.
|
||||
|
||||
### 예시 3. side effect API를 idempotency 검토 없이 다시 호출한다
|
||||
|
||||
```java
|
||||
try {
|
||||
paymentClient.capture(request);
|
||||
} catch (TimeoutException ex) {
|
||||
paymentClient.capture(request);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- timeout은 side effect 미발생을 보장하지 않는다
|
||||
- non-idempotent retry anti-pattern에 가깝다.
|
||||
|
||||
### 예시 4. SDK retry와 adapter retry를 동시에 켠다
|
||||
|
||||
```java
|
||||
public void send() {
|
||||
sdkClient.send(); // SDK 내부 retry 있음
|
||||
}
|
||||
```
|
||||
|
||||
그리고 바깥에서 다시 @Retryable 적용
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- retry layering anti-pattern이다
|
||||
- 실제 요청 수와 부하가 폭증할 수 있다.
|
||||
@@ -0,0 +1,208 @@
|
||||
# Integration Serialization / Deserialization 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 외부 response DTO만 관대하게 읽는다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record KeycloakUserResponse(
|
||||
@JsonProperty("id") String id,
|
||||
@JsonProperty("email") String email,
|
||||
@JsonProperty("enabled") boolean enabled
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider가 필드를 추가해도 파싱이 덜 깨진다
|
||||
- 외부 필드명 mismatch를 DTO 경계에서 해결한다
|
||||
- 내부 모델로 바로 새지 않는다.
|
||||
|
||||
### 예시 2. 성공 응답과 오류 응답 DTO를 분리한다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record StripeChargeResponse(
|
||||
@JsonProperty("id") String id,
|
||||
@JsonProperty("status") String status
|
||||
) {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record StripeErrorResponse(
|
||||
@JsonProperty("error") StripeErrorBody error
|
||||
) {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record StripeErrorBody(
|
||||
@JsonProperty("type") String type,
|
||||
@JsonProperty("code") String code,
|
||||
@JsonProperty("message") String message
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- success/error shape를 억지로 하나의 DTO에 우겨 넣지 않는다
|
||||
- adapter가 provider failure semantics를 더 명확하게 번역할 수 있다
|
||||
|
||||
### 예시 3. form-urlencoded 계약은 JSON으로 억지 변환하지 않는다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
class KeycloakTokenClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
TokenResult issue(KeycloakTokenCommand command) {
|
||||
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||
form.add("grant_type", "password");
|
||||
form.add("client_id", command.clientId());
|
||||
form.add("username", command.username());
|
||||
form.add("password", command.password());
|
||||
|
||||
KeycloakTokenResponse response = restClient.post()
|
||||
.uri("/protocol/openid-connect/token")
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(form)
|
||||
.retrieve()
|
||||
.body(KeycloakTokenResponse.class);
|
||||
|
||||
return new TokenResult(response.accessToken(), response.expiresIn());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider media type을 정확히 따른다
|
||||
- form 계약을 JSON DTO로 왜곡하지 않는다
|
||||
- Spring converter 지원과도 맞는다.
|
||||
|
||||
### 예시 4. provider-specific weird format은 adapter mapper에서 흡수한다
|
||||
|
||||
```java
|
||||
public record ExternalPaymentResult(
|
||||
String paymentId,
|
||||
PaymentState state
|
||||
) {
|
||||
}
|
||||
|
||||
public class StripePaymentMapper {
|
||||
|
||||
ExternalPaymentResult toResult(StripeChargeResponse response) {
|
||||
return new ExternalPaymentResult(
|
||||
response.id(),
|
||||
switch (response.status()) {
|
||||
case "succeeded" -> PaymentState.SUCCEEDED;
|
||||
case "processing" -> PaymentState.PROCESSING;
|
||||
default -> PaymentState.UNKNOWN;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider string enum이 domain enum으로 직접 새지 않는다
|
||||
- 새 값이 추가돼도 UNKNOWN으로 흡수할 수 있다
|
||||
|
||||
### 예시 5. 공통 builder를 주입받아 client를 만든다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class ExternalUserClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
ExternalUserClient(RestClient.Builder builder, ExternalUserProperties properties) {
|
||||
this.restClient = builder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 공통 HttpMessageConverters와 request factory를 따른다
|
||||
- 로컬 ObjectMapper/client 생성을 줄인다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 내부 entity를 외부 request body로 직접 보낸다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String password;
|
||||
private String role;
|
||||
}
|
||||
|
||||
restClient.post()
|
||||
.uri("/users")
|
||||
.body(user)
|
||||
.retrieve();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 내부 모델이 외부 계약으로 새어 나간다
|
||||
- provider에 보내면 안 되는 필드까지 함께 나갈 수 있다
|
||||
- serialization concern이 domain/entity를 오염시킨다
|
||||
|
||||
### 예시 2. adapter 메서드 안에서 new ObjectMapper()를 만든다
|
||||
|
||||
```java
|
||||
public ExternalUserResult getUser(String id) throws Exception {
|
||||
String body = httpClient.get(...);
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
return objectMapper.readValue(body, ExternalUserResult.class);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공통 mapper/configuration을 우회한다
|
||||
- client별 일관성이 깨진다
|
||||
- message converter 경계를 스스로 무너뜨린다.
|
||||
|
||||
### 예시 3. external response를 raw Map으로 받아 business 로직에 넘긴다
|
||||
|
||||
```java
|
||||
Map<String, Object> response = restClient.get()
|
||||
.uri("/users/{id}", id)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
|
||||
return userService.handle(response);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- payload reading 경계가 application으로 번진다
|
||||
- contract drift가 여러 계층에 퍼진다
|
||||
- tolerant reader가 아니라 “아무도 책임지지 않는 reader”가 된다
|
||||
|
||||
### 예시 4. 외부 오류 본문을 그대로 예외 메시지로 올린다
|
||||
|
||||
```java
|
||||
catch (HttpClientErrorException ex) {
|
||||
throw new RuntimeException(ex.getResponseBodyAsString());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider raw payload가 내부 예외/로그로 새어 나간다
|
||||
- 민감정보나 과도한 본문이 포함될 수 있다
|
||||
- success/error parsing 규칙이 사라진다
|
||||
@@ -0,0 +1,163 @@
|
||||
# Timeout 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 전역 기본값은 공통 설정으로 둔다
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
http:
|
||||
clients:
|
||||
connect-timeout: 500ms
|
||||
read-timeout: 2s
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 서비스 전체 기본값이 한 곳에 있다
|
||||
- 모든 client에 최소 timeout 정책이 적용된다.
|
||||
|
||||
### 예시 2. provider별 차이는 HTTP service group에서 override한다
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
http:
|
||||
clients:
|
||||
connect-timeout: 500ms
|
||||
serviceclient:
|
||||
keycloak:
|
||||
base-url: https://keycloak.example.com
|
||||
read-timeout: 3s
|
||||
payment:
|
||||
base-url: https://payment.example.com
|
||||
read-timeout: 5s
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 공통 기본값과 provider별 차이가 함께 보인다
|
||||
- Spring Boot가 제공하는 group-level connect/read timeout 구조와 맞는다.
|
||||
|
||||
### 예시 3. RestClient는 주입된 builder를 사용한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class KeycloakTokenClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
KeycloakTokenClient(RestClient.Builder builder, KeycloakProperties properties) {
|
||||
this.restClient = builder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Boot auto-configuration과 공통 timeout/customizer를 따른다
|
||||
- RestClient.create()로 공통 구성을 우회하지 않는다.
|
||||
|
||||
### 예시 4. WebClient는 Reactor Netty timeout을 명시적으로 구성할 수 있다
|
||||
|
||||
```java
|
||||
@Bean
|
||||
WebClient paymentWebClient(WebClient.Builder builder) {
|
||||
HttpClient httpClient = HttpClient.create()
|
||||
.responseTimeout(Duration.ofSeconds(3))
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000);
|
||||
|
||||
return builder
|
||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||
.baseUrl("https://payment.example.com")
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- connect timeout과 response timeout을 분리한다
|
||||
- Reactor Netty의 구체 timeout 지점을 활용한다.
|
||||
|
||||
### 예시 5. timeout 값은 operation별로 명시적 override만 허용한다
|
||||
|
||||
```java
|
||||
Mono<ResponseDto> callLongRunningOperation(RequestDto request) {
|
||||
return webClient.post()
|
||||
.uri("/reports")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(ResponseDto.class)
|
||||
.timeout(Duration.ofSeconds(8));
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “이 operation만 더 길다”는 의도가 코드에 드러난다
|
||||
- 기본값과 다른 이유를 문서화하기 쉽다
|
||||
|
||||
**주의:**
|
||||
|
||||
- reactive 전체 timeout()은 최후 수단에 가깝고, 가능하면 client-specific timeout이 더 우선이다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 외부 호출에 timeout이 없다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class BadExternalClient {
|
||||
|
||||
private final RestClient restClient = RestClient.create("https://example.com");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공통 timeout/customizer/관측 구성을 우회한다
|
||||
- 무제한 또는 의도 불명확한 대기에 빠질 수 있다.
|
||||
|
||||
### 예시 2. 너무 낮은 timeout을 근거 없이 하드코딩한다
|
||||
|
||||
```java
|
||||
webClient.get()
|
||||
.uri("/token")
|
||||
.retrieve()
|
||||
.bodyToMono(TokenResponse.class)
|
||||
.timeout(Duration.ofMillis(20));
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- TLS handshake, 새 연결, DNS 비용을 고려하지 않은 값일 수 있다
|
||||
- 배포 직후/콜드 커넥션에서 false timeout을 유발하기 쉽다.
|
||||
|
||||
### 예시 3. timeout 값을 서비스 전체에 하나의 숫자로 강제한다
|
||||
|
||||
```yaml
|
||||
external:
|
||||
timeout-ms: 1000
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider별 latency와 business 중요도가 다를 수 있다
|
||||
- connect/read/response 구분도 사라진다
|
||||
- operation별 차이를 담기 어렵다
|
||||
|
||||
### 예시 4. timeout 이후 side effect API를 무심코 재시도한다
|
||||
|
||||
```java
|
||||
try {
|
||||
paymentClient.capture(request);
|
||||
} catch (TimeoutException ex) {
|
||||
paymentClient.capture(request);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- timeout이 side effect 미발생을 보장하지 않는다
|
||||
- idempotency 검토 없이 중복 실행 위험이 생긴다.
|
||||
@@ -0,0 +1,194 @@
|
||||
# collections / immutability 예시
|
||||
|
||||
이 문서는 [collections / immutability 기준](../../standards/language/collections-immutability.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 컬렉션을 immutable-first로 다루고, 변경이 필요한 로컬 조립 단계가 끝나면 경계를 넘기기 전에 수정 불가 snapshot으로 고정하는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 생성자에서 defensive copy
|
||||
|
||||
```java
|
||||
public class RolePolicy {
|
||||
|
||||
private final List<String> allowedRoles;
|
||||
|
||||
public RolePolicy(List<String> allowedRoles) {
|
||||
this.allowedRoles = List.copyOf(allowedRoles);
|
||||
}
|
||||
|
||||
public List<String> allowedRoles() {
|
||||
return allowedRoles;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 외부에서 넘긴 mutable list를 그대로 보관하지 않는다.
|
||||
- 내부 필드를 안정된 snapshot으로 고정한다.
|
||||
|
||||
## 좋은 예시 2: 상수성 데이터는 of 사용
|
||||
|
||||
```java
|
||||
private static final Set<String> PUBLIC_PATHS = Set.of(
|
||||
"/",
|
||||
"/login",
|
||||
"/swagger-ui.html"
|
||||
);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 상수 컬렉션 의도가 분명하다.
|
||||
- `null`과 중복을 조기에 차단한다.
|
||||
|
||||
주의:
|
||||
|
||||
- 순서를 기대하면 `List.of`가 더 적합할 수 있다.
|
||||
|
||||
## 좋은 예시 3: stream 결과를 수정 불가로 고정
|
||||
|
||||
```java
|
||||
List<String> activeEmails = users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.toList();
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 결과가 읽기 전용이라는 의도가 분명하다.
|
||||
- 후속 코드가 실수로 수정하지 못한다.
|
||||
|
||||
## 좋은 예시 4: mutable 조립 후 경계에서 snapshot
|
||||
|
||||
```java
|
||||
List<String> buildScopes(User user) {
|
||||
List<String> scopes = new ArrayList<>();
|
||||
scopes.add("profile");
|
||||
if (user.isAdmin()) {
|
||||
scopes.add("admin");
|
||||
}
|
||||
return List.copyOf(scopes);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 로컬 조립 단계에서는 mutable 컬렉션을 실용적으로 사용한다.
|
||||
- 반환 시점에는 안정된 snapshot으로 바꾼다.
|
||||
|
||||
## 좋은 예시 5: 구체 mutable 결과가 필요하면 명시
|
||||
|
||||
```java
|
||||
List<UserDto> result = users.stream()
|
||||
.map(UserMapper::toDto)
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- mutable 결과가 필요하다는 점을 코드에 드러낸다.
|
||||
- `Collectors.toList()`의 mutability를 가정하지 않는다.
|
||||
|
||||
## 나쁜 예시 1: 내부 mutable collection 그대로 노출
|
||||
|
||||
```java
|
||||
public class UserGroup {
|
||||
|
||||
private final List<User> users = new ArrayList<>();
|
||||
|
||||
public List<User> getUsers() {
|
||||
return users;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 외부에서 내부 상태를 직접 수정할 수 있다.
|
||||
- 캡슐화가 깨진다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public List<User> getUsers() {
|
||||
return List.copyOf(users);
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: unmodifiable view를 immutable로 착각
|
||||
|
||||
```java
|
||||
List<String> source = new ArrayList<>();
|
||||
source.add("A");
|
||||
|
||||
List<String> readOnly = Collections.unmodifiableList(source);
|
||||
source.add("B");
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `readOnly`는 immutable snapshot이 아니라 view다.
|
||||
- `source`가 바뀌면 `readOnly`도 바뀐다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
List<String> readOnly = List.copyOf(source);
|
||||
```
|
||||
|
||||
## 나쁜 예시 3: null collection 반환
|
||||
|
||||
```java
|
||||
public List<Role> findRoles(Long userId) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 호출자마다 null-check를 강요한다.
|
||||
- 컬렉션 결과의 계약이 흐려진다.
|
||||
|
||||
개선:
|
||||
|
||||
- empty list를 반환한다.
|
||||
- 또는 입력 자체를 경계에서 검증한다.
|
||||
|
||||
## 나쁜 예시 4: 순서를 기대하면서 Set.of 사용
|
||||
|
||||
```java
|
||||
Set<String> statuses = Set.of("NEW", "PROCESSING", "DONE");
|
||||
String first = statuses.iterator().next();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `Set.of` iteration order를 비즈니스 로직에 기대고 있다.
|
||||
- JVM 실행마다 순서가 달라질 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
List<String> statuses = List.of("NEW", "PROCESSING", "DONE");
|
||||
String first = statuses.getFirst();
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: shallow immutability 오해
|
||||
|
||||
```java
|
||||
List<UserProfile> profiles = List.copyOf(sourceProfiles);
|
||||
profiles.get(0).changeNickname("new-name");
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 컬렉션은 수정 불가지만 원소는 mutable이라 상태가 바뀔 수 있다.
|
||||
- 공유 상태 안정성을 보장하지 못한다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- immutable element를 사용한다.
|
||||
- mutable element는 공유하지 않도록 복사하거나 변환한다.
|
||||
@@ -0,0 +1,137 @@
|
||||
# duplication 예시
|
||||
|
||||
## 좋은 예시 1: 같은 정책 중복은 private method로 추출
|
||||
|
||||
```java
|
||||
private String normalizeEmail(String rawEmail) {
|
||||
return rawEmail.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public User register(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
...
|
||||
}
|
||||
|
||||
public User login(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 같은 정책이다
|
||||
- 같은 이유로 바뀐다
|
||||
- 한 곳에서 수정 가능하다
|
||||
|
||||
## 좋은 예시 2: 외부 API 예외 번역 중복 추출
|
||||
|
||||
```java
|
||||
private InfrastructureException vaultFailure(String message, Exception cause) {
|
||||
return new InfrastructureException(
|
||||
InfrastructureErrorCode.VAULT_TRANSIT_FAILED,
|
||||
message,
|
||||
cause
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 기술 실패 번역 정책이 한 곳에 모인다
|
||||
- 누락/불일치 위험이 줄어든다
|
||||
|
||||
## 좋은 예시 3: 테스트는 중복을 일부 허용
|
||||
|
||||
```java
|
||||
@Test
|
||||
void registers_two_users() {
|
||||
User user1 = new User("alice");
|
||||
User user2 = new User("bob");
|
||||
|
||||
forum.register(user1);
|
||||
forum.register(user2);
|
||||
|
||||
assertTrue(forum.hasRegisteredUser(user1));
|
||||
assertTrue(forum.hasRegisteredUser(user2));
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- helper/loop보다 읽기 쉽다
|
||||
- 테스트 의도가 바로 드러난다
|
||||
|
||||
## 좋은 예시 4: 3회 이상 반복되는 mapper 규칙 추출
|
||||
|
||||
```java
|
||||
private ApiResult<Void> failureOf(ApplicationException exception) {
|
||||
return ApiResult.failure(exception.getCode(), exception.getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 응답 실패 조립 규칙이 공통 정책이다
|
||||
- presentation 전반에서 같은 이유로 바뀔 가능성이 높다
|
||||
|
||||
## 나쁜 예시 1: 우연한 유사성을 억지로 공통화
|
||||
|
||||
```java
|
||||
public Object process(Object input, String mode, Map<String, Object> options) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 맥락이 다른 두세 개 흐름을 한 메서드로 억지로 합친다
|
||||
- 이름이 모호해지고 분기만 늘어난다
|
||||
- 이후 독립 진화가 어렵다
|
||||
|
||||
## 나쁜 예시 2: 레이어를 넘는 공통화
|
||||
|
||||
```java
|
||||
public final class CommonValidationUtil {
|
||||
public static void validateUser(User user, CreateUserRequest request, UserJpaEntity entity) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- domain/presentation/infrastructure 경계를 한 곳에 섞는다
|
||||
- 중복 제거보다 아키텍처 손상이 더 크다
|
||||
|
||||
## 나쁜 예시 3: 테스트를 너무 DRY하게 만들어 의미 숨김
|
||||
|
||||
```java
|
||||
private void registerAll(List<User> users) { ... }
|
||||
|
||||
@Test
|
||||
void registers_users() {
|
||||
registerAll(defaultUsers());
|
||||
assertAllRegistered(defaultUsers());
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 테스트 본문만 보면 실제 행위가 잘 드러나지 않는다
|
||||
- helper를 따라가야 해서 검증이 어려워진다
|
||||
|
||||
## 나쁜 예시 4: common 모듈로 너무 빨리 이동
|
||||
|
||||
```text
|
||||
common/
|
||||
StringUtils.java
|
||||
DateUtils.java
|
||||
ErrorUtils.java
|
||||
ValidationUtils.java
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- “중복 제거” 명분으로 소유권 없는 잡동사니 모듈이 된다
|
||||
- 진짜 공통인지, 그냥 아직 설계가 안 된 것인지 구분이 사라진다
|
||||
@@ -0,0 +1,213 @@
|
||||
# enum / constants 예시
|
||||
|
||||
이 문서는 [enum / constants 기준](../../standards/language/enum-constants.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 고정된 의미 집합을 enum으로 표현하고, external code / display label / internal name을 섞지 않는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 문자열 상수 대신 enum
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
LOCAL,
|
||||
GOOGLE,
|
||||
KAKAO
|
||||
}
|
||||
|
||||
if (user.getProvider() == AuthProvider.LOCAL) {
|
||||
// local login flow
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 고정된 값 집합을 타입으로 표현한다.
|
||||
- 오타와 매직 스트링 분기를 줄인다.
|
||||
|
||||
## 좋은 예시 2: external code를 명시적 필드로 분리
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
LOCAL("local"),
|
||||
GOOGLE("google"),
|
||||
KAKAO("kakao");
|
||||
|
||||
private final String code;
|
||||
|
||||
AuthProvider(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `name()`에 외부 계약을 맡기지 않는다.
|
||||
- 내부 enum 이름 변경과 외부 계약을 분리할 수 있다.
|
||||
|
||||
## 좋은 예시 3: EnumSet 사용
|
||||
|
||||
```java
|
||||
private static final EnumSet<AuthProvider> SOCIAL_PROVIDERS =
|
||||
EnumSet.of(AuthProvider.GOOGLE, AuthProvider.KAKAO);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- enum 집합이라는 의도가 직접 드러난다.
|
||||
- 비트 플래그나 일반 `Set`보다 타입 안전하고 목적에 맞다.
|
||||
|
||||
## 좋은 예시 4: EnumMap 사용
|
||||
|
||||
```java
|
||||
private final EnumMap<AuthProvider, OAuthClient> clients =
|
||||
new EnumMap<>(AuthProvider.class);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- enum key 전용 자료구조라는 점이 명확하다.
|
||||
- 일반 `HashMap`보다 목적에 더 잘 맞는다.
|
||||
|
||||
## 좋은 예시 5: 진짜 상수만 상수로 둠
|
||||
|
||||
```java
|
||||
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(30);
|
||||
private static final List<String> PUBLIC_PATHS = List.of("/", "/login");
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 값이 immutable이다.
|
||||
- `static final`뿐 아니라 실제 의미도 안정적이다.
|
||||
|
||||
## 나쁜 예시 1: ordinal 저장/분기
|
||||
|
||||
```java
|
||||
int providerCode = provider.ordinal();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- enum 순서 변경이나 값 추가에 취약하다.
|
||||
- stable contract가 아니다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
String providerCode = provider.code();
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: name/toString 문자열 비교
|
||||
|
||||
```java
|
||||
if (provider.name().equals("GOOGLE")) {
|
||||
// google login flow
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- enum 의미 비교를 문자열 비교로 내린다.
|
||||
- 타입 안전성이 사라진다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
if (provider == AuthProvider.GOOGLE) {
|
||||
// google login flow
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 3: 잡다한 constants class
|
||||
|
||||
```java
|
||||
public final class AppConstants {
|
||||
|
||||
public static final String PROVIDER_LOCAL = "LOCAL";
|
||||
public static final String PROVIDER_GOOGLE = "GOOGLE";
|
||||
public static final String PROVIDER_KAKAO = "KAKAO";
|
||||
|
||||
private AppConstants() {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 고정된 의미 집합을 타입으로 표현하지 않는다.
|
||||
- 문자열 오타와 분기 누락에 취약하다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
LOCAL,
|
||||
GOOGLE,
|
||||
KAKAO
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 4: mutable collection을 상수처럼 사용
|
||||
|
||||
```java
|
||||
private static final Set<String> PUBLIC_PATHS = new HashSet<>();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `static final`이어도 내부 상태는 바뀔 수 있다.
|
||||
- 진짜 상수라고 보기 어렵다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
private static final Set<String> PUBLIC_PATHS = Set.of("/", "/login");
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: default로 enum 추가 누락 숨김
|
||||
|
||||
```java
|
||||
return switch (provider) {
|
||||
case LOCAL -> localHandler();
|
||||
default -> socialHandler();
|
||||
};
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 새 enum 값이 생겨도 의도치 않게 `default`에 흡수될 수 있다.
|
||||
- 분기 누락이 컴파일 시점에 드러나기 어렵다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
return switch (provider) {
|
||||
case LOCAL -> localHandler();
|
||||
case GOOGLE, KAKAO -> socialHandler();
|
||||
};
|
||||
```
|
||||
|
||||
## 나쁜 예시 6: null 회피용 UNKNOWN 남용
|
||||
|
||||
```java
|
||||
public enum AuthProvider {
|
||||
UNKNOWN,
|
||||
LOCAL,
|
||||
GOOGLE,
|
||||
KAKAO
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `UNKNOWN`이 실제 비즈니스 상태가 아니라면 의미 없는 상태가 생긴다.
|
||||
- 단순 null 회피가 enum 모델에 섞인다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- boundary 입력은 검증하거나 nullable로 명시한다.
|
||||
- 조회 결과의 부재는 필요하면 `Optional<AuthProvider>`로 표현한다.
|
||||
- 실제 비즈니스 상태일 때만 `UNKNOWN` 또는 `UNSPECIFIED`를 둔다.
|
||||
@@ -0,0 +1,216 @@
|
||||
# exceptions 예시
|
||||
|
||||
이 문서는 [exceptions 기준](../../standards/language/exceptions.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 예외를 정상 흐름 제어가 아니라 예외 상황 전달 수단으로 사용하고, catch는 번역 / 문맥 추가 / 복구 목적이 있을 때만 두는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 기술 예외를 계층 예외로 번역하면서 cause 보존
|
||||
|
||||
```java
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
return parse(response.body());
|
||||
} catch (IOException exception) {
|
||||
throw new InfrastructureException(
|
||||
InfrastructureErrorCode.EXTERNAL_API_FAILED,
|
||||
"Failed to call external API",
|
||||
exception
|
||||
);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new InfrastructureException(
|
||||
InfrastructureErrorCode.EXTERNAL_API_FAILED,
|
||||
"External API call was interrupted",
|
||||
exception
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- broad catch가 아니다.
|
||||
- `InterruptedException`을 별도로 처리한다.
|
||||
- cause를 보존한다.
|
||||
- 기술 실패를 infrastructure 의미로 번역한다.
|
||||
|
||||
## 좋은 예시 2: try-with-resources 사용
|
||||
|
||||
```java
|
||||
try (InputStream in = Files.newInputStream(path)) {
|
||||
return objectMapper.readValue(in, Payload.class);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 자원 해제를 자동화한다.
|
||||
- close 중 예외가 발생해도 suppressed exception으로 보존될 수 있다.
|
||||
|
||||
## 좋은 예시 3: 상위 경계에서만 broad catch
|
||||
|
||||
```java
|
||||
try {
|
||||
return useCase.execute(command);
|
||||
} catch (ApplicationException exception) {
|
||||
return errorResponse(exception.getCode(), exception.getMessage());
|
||||
} catch (Exception exception) {
|
||||
log.error("Unhandled exception while processing request", exception);
|
||||
return errorResponse("INTERNAL_SERVER_ERROR", "Unexpected server error");
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 최상위 boundary에서 마지막 방어선으로만 broad catch를 쓴다.
|
||||
- 내부 계층에서는 더 구체적인 예외 처리를 유지한다.
|
||||
- 예상 가능한 application 예외와 예상하지 못한 실패를 구분한다.
|
||||
|
||||
## 좋은 예시 4: checked 예외 rollback 필요 시 명시
|
||||
|
||||
```java
|
||||
@Transactional(rollbackFor = IOException.class)
|
||||
public void importUsers(Path path) throws IOException {
|
||||
// import users from file
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- Spring 기본 rollback 규칙을 명시적으로 보완한다.
|
||||
- checked exception이 rollback 대상인지 계약으로 드러난다.
|
||||
|
||||
## 좋은 예시 5: 테스트는 assertThrows 우선
|
||||
|
||||
```java
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.createUser(command)
|
||||
);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `try-catch + fail` 패턴보다 의도가 직접적이다.
|
||||
- 예외 객체를 받아 메시지나 상태를 추가로 검증할 수 있다.
|
||||
|
||||
## 나쁜 예시 1: broad catch + 삼키기
|
||||
|
||||
```java
|
||||
try {
|
||||
saveUser(user);
|
||||
} catch (Exception exception) {
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 예외가 사라진다.
|
||||
- 디버깅이 어려워진다.
|
||||
- interruption 같은 중요한 신호도 놓칠 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 복구할 수 있는 구체 예외만 catch한다.
|
||||
- 계층 예외로 번역하거나, 문맥을 붙여 다시 던진다.
|
||||
- 정말 무시해야 한다면 이유를 남기고 logging / metrics / 상태 기록 중 하나를 수행한다.
|
||||
|
||||
## 나쁜 예시 2: printStackTrace 후 계속 진행
|
||||
|
||||
```java
|
||||
try {
|
||||
sync();
|
||||
} catch (IOException exception) {
|
||||
exception.printStackTrace();
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 운영 로그 정책을 깨뜨린다.
|
||||
- 실패를 구조적으로 전달하지 못한다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- logging framework로 기록한다.
|
||||
- 또는 계층 예외로 번역해 상위 boundary로 전달한다.
|
||||
|
||||
## 나쁜 예시 3: finally에서 return
|
||||
|
||||
```java
|
||||
try {
|
||||
return load();
|
||||
} finally {
|
||||
return fallback();
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- try 블록 결과와 예외를 덮어쓴다.
|
||||
- 실제 실패가 호출자에게 전달되지 않을 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- `finally`는 정리 작업만 수행한다.
|
||||
- fallback이 필요하면 catch나 명시적 분기에서 처리한다.
|
||||
|
||||
## 나쁜 예시 4: InterruptedException 뭉개기
|
||||
|
||||
```java
|
||||
try {
|
||||
queue.take();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- interruption을 별도 의미로 처리하지 않는다.
|
||||
- 스레드 인터럽트 상태를 잃을 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
try {
|
||||
queue.take();
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while waiting for queue item", exception);
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: 너무 넓은 일반 예외 던지기
|
||||
|
||||
```java
|
||||
throw new RuntimeException("bad request");
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 의미가 너무 넓다.
|
||||
- 호출자가 어떤 실패인지 이해하기 어렵다.
|
||||
- 경계에서 일관된 에러 코드나 응답으로 번역하기 어렵다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 계약 위반이면 `IllegalArgumentException` 같은 구체 예외를 사용한다.
|
||||
- 계층 의미가 있으면 domain / application / infrastructure 예외로 표현한다.
|
||||
|
||||
## 나쁜 예시 6: 정상적인 결과 없음에 예외 사용
|
||||
|
||||
```java
|
||||
public User findUser(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("user not found"));
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 결과 없음이 정상적인 조회 결과일 수 있는데 예외로만 표현한다.
|
||||
- 호출자가 부재를 처리할 수 있는 선택지를 잃는다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 단건 조회의 부재가 정상 흐름이면 `Optional<User>`를 반환한다.
|
||||
- 유스케이스 계약상 반드시 있어야 하는 값이면 구체적인 application 예외로 번역한다.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Javadoc 예시
|
||||
|
||||
## 좋은 예시 1: 반환 계약과 예외 조건이 드러나는 메서드
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns the active user for the given email.
|
||||
*
|
||||
* @param email normalized user email, never {@code null}
|
||||
* @return the matching active user
|
||||
* @throws UserNotFoundException if no user exists for the given email
|
||||
* @throws InactiveUserException if the user exists but is inactive
|
||||
*/
|
||||
public User getActiveUserByEmail(String email) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 호출자가 믿을 수 있는 계약이 보인다
|
||||
- null 허용 여부와 실패 조건이 드러난다
|
||||
- 구현 세부가 아니라 API 의미를 설명한다
|
||||
|
||||
## 좋은 예시 2: value object 생성 제약 문서화
|
||||
|
||||
```java
|
||||
/**
|
||||
* Value object representing a normalized email address.
|
||||
*
|
||||
* <p>The value is always lowercase and trimmed.
|
||||
*/
|
||||
public record UserEmail(String value) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 타입의 핵심 invariant를 문서화한다
|
||||
- typical reader가 놓치기 쉬운 제약을 설명한다
|
||||
|
||||
## 좋은 예시 3: override는 문서 상속 활용
|
||||
|
||||
```java
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 상위 계약이 충분하면 중복 문서를 쓰지 않는다
|
||||
- 불필요한 복붙 Javadoc을 줄인다
|
||||
|
||||
## 좋은 예시 4: package/class 수준에서 구조 설명
|
||||
|
||||
```java
|
||||
/**
|
||||
* HTTP request/response contracts and exception translation for the auth API.
|
||||
*
|
||||
* <p>This package owns controllers, request/response DTOs, and client-facing
|
||||
* error handling. It must not depend directly on infrastructure implementations.
|
||||
*/
|
||||
package com.project.auth.presentation;
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- package 책임과 금지사항이 드러난다
|
||||
- architecture 문서와 연결되는 설명이다
|
||||
|
||||
## 나쁜 예시 1: 자명한 getter 설명
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns the user name.
|
||||
*/
|
||||
public String getUserName() {
|
||||
return userName;
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 이름만 읽어도 알 수 있다
|
||||
- 유지보수 시 stale 될 가능성만 늘어난다
|
||||
|
||||
개선:
|
||||
|
||||
- 생략하거나
|
||||
- 정말 추가 계약이 있을 때만 적는다
|
||||
|
||||
## 나쁜 예시 2: 구현 설명만 적음
|
||||
|
||||
```java
|
||||
/**
|
||||
* Uses ArrayList internally and loops over all elements to find the user.
|
||||
*/
|
||||
public User findUser(String email) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 구현 세부에 과도하게 묶인다
|
||||
- 리팩터링 시 쉽게 거짓 문서가 된다
|
||||
|
||||
개선:
|
||||
|
||||
- 호출 계약, 검색 조건, 실패 조건을 설명한다
|
||||
|
||||
## 나쁜 예시 3: 태그만 채우는 문서
|
||||
|
||||
```java
|
||||
/**
|
||||
* @param email the email
|
||||
* @return the user
|
||||
*/
|
||||
public User findUser(String email) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 독자에게 새로운 정보가 없다
|
||||
- 형식만 있고 계약이 없다
|
||||
|
||||
개선:
|
||||
|
||||
- summary와 제약/의미를 써라
|
||||
- 아니면 생략하라
|
||||
|
||||
## 나쁜 예시 4: stale Javadoc 방치
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns a mutable list of authorities.
|
||||
*/
|
||||
public List<String> getAuthorities() {
|
||||
return List.copyOf(authorities);
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 코드와 문서가 충돌한다
|
||||
- 거짓 문서가 된다
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Returns an unmodifiable snapshot of authorities.
|
||||
*/
|
||||
public List<String> getAuthorities() {
|
||||
return List.copyOf(authorities);
|
||||
}
|
||||
```
|
||||
|
||||
또는 Javadoc 삭제 후 더 적절한 형태로 재작성
|
||||
@@ -0,0 +1,140 @@
|
||||
# null 처리 예시
|
||||
|
||||
이 문서는 [null 처리 기준](../../standards/language/null.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 `null`을 경계에서만 제한적으로 받고, 내부 로직에 들어가기 전에 non-null 값이나 명시적 상태로 정리하는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 생성자/경계에서 즉시 검증
|
||||
|
||||
```java
|
||||
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = Objects.requireNonNull(userRepository, "userRepository must not be null");
|
||||
this.passwordEncoder = Objects.requireNonNull(passwordEncoder, "passwordEncoder must not be null");
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- boundary에서 non-null 계약을 바로 강제한다.
|
||||
- 내부 필드는 이후 non-null로 다룰 수 있다.
|
||||
|
||||
## 좋은 예시 2: 외부 입력은 DTO에서 받고 내부에서 정리
|
||||
|
||||
```java
|
||||
public CreateUserCommand toCommand(CreateUserRequest request) {
|
||||
return new CreateUserCommand(
|
||||
UserEmail.from(request.email()),
|
||||
request.nickname() == null ? null : request.nickname().trim()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
더 좋은 경우:
|
||||
|
||||
- nullable `nickname`을 value object 또는 명시적 규칙으로 바로 정리한다.
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- nullable 입력이 boundary에 머문다.
|
||||
- 내부 의미로 들어가기 전에 정리할 수 있다.
|
||||
|
||||
## 좋은 예시 3: 컬렉션은 null 대신 empty 반환
|
||||
|
||||
```java
|
||||
public List<Role> findRoles(Long userId) {
|
||||
List<Role> roles = roleRepository.findAllByUserId(userId);
|
||||
return roles == null ? List.of() : roles;
|
||||
}
|
||||
```
|
||||
|
||||
더 좋은 경우:
|
||||
|
||||
- repository 계약 자체를 null이 아닌 empty 반환으로 고정한다.
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 호출자가 불필요한 null-check를 하지 않아도 된다.
|
||||
|
||||
## 좋은 예시 4: persistence -> domain 변환에서 nullable 차단
|
||||
|
||||
```java
|
||||
public User toDomain(UserJpaEntity entity) {
|
||||
return User.restore(
|
||||
Objects.requireNonNull(entity.getId(), "id must not be null"),
|
||||
UserEmail.from(Objects.requireNonNull(entity.getEmail(), "email must not be null")),
|
||||
Objects.requireNonNull(entity.getEncodedPassword(), "encodedPassword must not be null"),
|
||||
UserName.from(Objects.requireNonNull(entity.getName(), "name must not be null"))
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- DB nullable/오염 상태를 domain으로 전파하지 않는다.
|
||||
- invariant 경계가 분명하다.
|
||||
|
||||
## 나쁜 예시 1: Optional과 null 혼용
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
if (email == null) {
|
||||
return null;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `Optional` 반환 계약을 깨뜨린다.
|
||||
- 호출자는 `Optional`과 `null`을 동시에 처리해야 한다.
|
||||
|
||||
개선:
|
||||
|
||||
- null 입력 자체를 검증한다.
|
||||
- 또는 `Optional.empty()`를 반환한다.
|
||||
- 또는 파라미터를 non-null로 강제한다.
|
||||
|
||||
## 나쁜 예시 2: null을 business 의미로 사용
|
||||
|
||||
```java
|
||||
if (user.getProvider() == null) {
|
||||
// local user
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- business state가 `null`에 숨는다.
|
||||
- 의미가 타입으로 드러나지 않는다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
if (user.getProvider() == AuthProvider.LOCAL) {
|
||||
// local user
|
||||
}
|
||||
```
|
||||
|
||||
또는 명시적 enum/state를 사용한다.
|
||||
|
||||
## 나쁜 예시 3: 여러 계층으로 nullable 전파
|
||||
|
||||
```java
|
||||
public String handle(String nickname) {
|
||||
return service.process(nickname);
|
||||
}
|
||||
|
||||
public String process(String nickname) {
|
||||
return repository.saveNickname(nickname);
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- nullable 여부가 계약으로 명시되지 않는다.
|
||||
- 모든 계층이 방어 책임을 떠넘긴다.
|
||||
|
||||
개선:
|
||||
|
||||
- boundary에서 검증/정규화한다.
|
||||
- nullable이면 `Optional`, value object, 명시적 command로 변환한다.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Optional 사용 예시
|
||||
|
||||
이 문서는 [Optional 사용 기준](../../standards/language/optional.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 `Optional`을 값의 부재가 가능한 단건 반환 타입에 쓰고, 필드/파라미터/직렬화 경계에는 기본적으로 쓰지 않는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 단건 조회 결과 없음 표현
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return userRepository.findByEmail(email);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 단건 조회 결과의 부재를 반환 타입에서 명시한다.
|
||||
- 호출자에게 "없을 수 있음"을 강제한다.
|
||||
|
||||
## 좋은 예시 2: transform chain
|
||||
|
||||
```java
|
||||
public Optional<String> findActiveUserEmail(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `isPresent() + get()` 없이 선언적으로 표현한다.
|
||||
- 값이 없으면 자연스럽게 empty로 전파된다.
|
||||
|
||||
## 좋은 예시 3: nested Optional 방지
|
||||
|
||||
```java
|
||||
public Optional<Token> resolveToken(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.flatMap(tokenService::findValidToken);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `flatMap`으로 `Optional<Optional<Token>>`를 만들지 않는다.
|
||||
|
||||
## 좋은 예시 4: expensive default는 orElseGet
|
||||
|
||||
```java
|
||||
UserProfile profile = profileRepository.findByUserId(userId)
|
||||
.orElseGet(() -> profileFactory.createDefault(userId));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 기본값 생성 비용이 있을 때 lazy supplier를 사용한다.
|
||||
|
||||
## 좋은 예시 5: 단건은 Optional, 다건은 빈 컬렉션
|
||||
|
||||
```java
|
||||
public List<Role> findRoles(Long userId) {
|
||||
return roleRepository.findAllByUserId(userId);
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 다건 결과의 부재를 `Optional<List<Role>>`로 감싸지 않는다.
|
||||
- 호출자는 빈 컬렉션으로 처리하면 된다.
|
||||
|
||||
## 나쁜 예시 1: Optional 반환인데 null 반환
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `Optional` 자체가 `null`이 되어 의미가 깨진다.
|
||||
- 호출자는 `Optional`과 `null`을 둘 다 처리해야 한다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return Optional.empty();
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: 필드에 Optional 저장
|
||||
|
||||
```java
|
||||
public class UserResponse {
|
||||
private Optional<String> nickname;
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- DTO 경계에서 표현이 복잡해진다.
|
||||
- 직렬화/스키마/API 계약이 불명확해질 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public class UserResponse {
|
||||
private String nickname;
|
||||
}
|
||||
```
|
||||
|
||||
또는 nullable 여부를 API 계약에서 명시한다.
|
||||
|
||||
## 나쁜 예시 3: 파라미터에 Optional 사용
|
||||
|
||||
```java
|
||||
public User createUser(Optional<String> nickname) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 호출자가 `Optional.empty()`와 `null` 실수를 섞기 쉽다.
|
||||
- 오버로드/명시적 request object보다 의도가 약하다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public User createUser(String nickname) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
또는:
|
||||
|
||||
```java
|
||||
public User createUser(CreateUserCommand command) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 4: isPresent + get
|
||||
|
||||
```java
|
||||
if (userOpt.isPresent()) {
|
||||
return userOpt.get().getEmail();
|
||||
}
|
||||
return "unknown";
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- imperative null-check와 다를 바 없는 패턴이다.
|
||||
- `get()` 의존이 생긴다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
return userOpt.map(User::getEmail)
|
||||
.orElse("unknown");
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: map 결과를 안 쓰고 side-effect
|
||||
|
||||
```java
|
||||
userOpt.map(user -> {
|
||||
audit(user.getId());
|
||||
return user;
|
||||
});
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `map`은 값 변환인데 반환값을 사용하지 않는다.
|
||||
- side-effect 목적이면 `ifPresent`가 더 맞다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
userOpt.ifPresent(user -> audit(user.getId()));
|
||||
```
|
||||
@@ -0,0 +1,150 @@
|
||||
# Stream 사용 예시
|
||||
|
||||
이 문서는 [Stream 사용 기준](../../standards/language/stream.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 Stream을 집계, 변환, 검색 파이프라인에 쓰고, 외부 상태 변경이나 필수 부작용에는 쓰지 않는 것입니다.
|
||||
|
||||
## 좋은 예시 1: 조회 + 변환 + 불변 결과
|
||||
|
||||
```java
|
||||
List<String> activeEmails = users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.toList();
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 집계/변환 파이프라인이다.
|
||||
- side-effect가 없다.
|
||||
- 결과가 명확하다.
|
||||
- 읽는 사람이 "무엇을 만들었는지" 바로 이해할 수 있다.
|
||||
|
||||
## 좋은 예시 2: 존재 여부 판단
|
||||
|
||||
```java
|
||||
boolean hasExpiredToken = tokens.stream()
|
||||
.anyMatch(Token::isExpired);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- for 루프보다 의도가 직접적이다.
|
||||
- short-circuit terminal operation이라 불필요한 순회를 줄일 수 있다.
|
||||
|
||||
## 좋은 예시 3: 그룹화
|
||||
|
||||
```java
|
||||
Map<AuthProvider, List<User>> usersByProvider = users.stream()
|
||||
.collect(Collectors.groupingBy(User::getProvider));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- grouping이 핵심인 aggregate operation이다.
|
||||
- 외부 mutable map을 직접 관리하지 않는다.
|
||||
|
||||
## 좋은 예시 4: 숫자 집계는 primitive stream 사용
|
||||
|
||||
```java
|
||||
int totalQuantity = orderLines.stream()
|
||||
.mapToInt(OrderLine::getQuantity)
|
||||
.sum();
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 숫자 집계 의도가 분명하다.
|
||||
- boxed `Integer` stream보다 표현이 명확하다.
|
||||
|
||||
## 좋은 예시 5: 구체 컬렉션 타입이 필요할 때만 toCollection
|
||||
|
||||
```java
|
||||
LinkedHashSet<String> roles = authorities.stream()
|
||||
.map(Authority::getRole)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 결과 타입 요구사항이 있을 때만 명시적으로 선택한다.
|
||||
- `Collectors.toList()`의 구현/가변성에 기대지 않는다.
|
||||
|
||||
## 좋은 예시 6: I/O 기반 stream은 닫기
|
||||
|
||||
```java
|
||||
try (Stream<String> lines = Files.lines(path)) {
|
||||
List<String> words = lines
|
||||
.flatMap(line -> Stream.of(line.split("\\s+")))
|
||||
.filter(word -> !word.isBlank())
|
||||
.toList();
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- I/O 기반 stream을 명시적으로 닫는다.
|
||||
- transform pipeline과 resource lifecycle이 분리되어 있다.
|
||||
|
||||
## 나쁜 예시 1: 외부 mutable accumulator 사용
|
||||
|
||||
```java
|
||||
List<String> emails = new ArrayList<>();
|
||||
users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.forEach(emails::add);
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 불필요한 side-effect가 있다.
|
||||
- 병렬화나 리팩터링에 취약하다.
|
||||
- reduction/collection으로 더 안전하게 표현할 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
List<String> emails = users.stream()
|
||||
.filter(User::isActive)
|
||||
.map(User::getEmail)
|
||||
.toList();
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: 상태를 가진 람다
|
||||
|
||||
```java
|
||||
AtomicInteger seq = new AtomicInteger(0);
|
||||
|
||||
List<UserView> result = users.stream()
|
||||
.map(user -> new UserView(seq.incrementAndGet(), user.getEmail()))
|
||||
.toList();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 람다가 외부 상태에 의존한다.
|
||||
- 병렬 스트림이나 리팩터링 시 의미가 불안정하다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- 순번이 비즈니스적으로 필요하면 스트림 밖에서 명시적으로 설계한다.
|
||||
- 단순 변환이면 순번 생성을 제거한다.
|
||||
|
||||
## 나쁜 예시 3: 비즈니스 로직에서 peek 사용
|
||||
|
||||
```java
|
||||
List<User> result = users.stream()
|
||||
.peek(user -> audit("USER_READ", user.getId()))
|
||||
.filter(User::isActive)
|
||||
.toList();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- `peek`를 필수 부작용 채널로 쓰고 있다.
|
||||
- 최적화/재구성 시 기대가 깨질 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- audit가 필수라면 terminal boundary에서 명시적으로 처리한다.
|
||||
- `peek`는 임시 디버깅 용도로만 제한한다.
|
||||
@@ -0,0 +1,221 @@
|
||||
# time 타입 / 포맷 예시
|
||||
|
||||
이 문서는 [time 타입 / 포맷 기준](../../standards/language/time.md)을 코드 예시로 확인하기 위한 자료입니다.
|
||||
핵심 기준은 시간 값을 문자열이나 숫자로 들고 다니지 않고, 시점 / 날짜 / 시각 / 기간 / 시간대 의미에 맞는 `java.time` 타입으로 표현하는 것입니다.
|
||||
|
||||
## 좋은 예시 1: event timestamp는 Instant
|
||||
|
||||
```java
|
||||
public record AuditEvent(
|
||||
String action,
|
||||
Long userId,
|
||||
Instant occurredAt
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- timeline 위 한 점을 명확하게 표현한다.
|
||||
- 로깅, 저장, 비교에 적합하다.
|
||||
|
||||
## 좋은 예시 2: 사람 기준 날짜 의미는 LocalDate / YearMonth
|
||||
|
||||
```java
|
||||
public record UserProfile(
|
||||
LocalDate birthDate,
|
||||
YearMonth cardExpiry
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 시간대와 무관한 사람 기준 날짜 의미를 타입으로 드러낸다.
|
||||
- 생일과 카드 만료월처럼 서로 다른 날짜 의미를 구분한다.
|
||||
|
||||
## 좋은 예시 3: 현재 시각은 Clock 기반
|
||||
|
||||
```java
|
||||
public class TokenIssuer {
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
public TokenIssuer(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public Instant issueTime() {
|
||||
return Instant.now(clock);
|
||||
}
|
||||
|
||||
public Instant expiryTime(Duration ttl) {
|
||||
return Instant.now(clock).plus(ttl);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 현재 시각을 고정해 테스트하기 쉽다.
|
||||
- static `now()` 호출이 코드 곳곳에 흩어지지 않는다.
|
||||
|
||||
## 좋은 예시 4: 시간 간격은 Duration
|
||||
|
||||
```java
|
||||
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(30);
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- `1800` 같은 매직 숫자보다 의미가 분명하다.
|
||||
- 초, 밀리초, 분 단위 혼동이 줄어든다.
|
||||
|
||||
## 좋은 예시 5: 외부 응답 포맷은 경계에서 처리
|
||||
|
||||
```java
|
||||
String value = DateTimeFormatter.ISO_INSTANT.format(event.occurredAt());
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 내부 로직은 `Instant`를 유지한다.
|
||||
- 문자열 포맷은 serialization, logging, external API adapter 같은 boundary에서만 수행한다.
|
||||
|
||||
## 좋은 예시 6: 실제 zone 계산이 필요할 때만 ZonedDateTime
|
||||
|
||||
```java
|
||||
ZonedDateTime reservationTime = localReservationTime.atZone(ZoneId.of("Asia/Seoul"));
|
||||
```
|
||||
|
||||
왜 좋은가:
|
||||
|
||||
- 서울 지역 wall-clock 시간이라는 의미가 필요할 때만 zone을 붙인다.
|
||||
- 시간대 규칙이 필요한 계산임을 코드에 드러낸다.
|
||||
|
||||
## 나쁜 예시 1: createdAt을 LocalDateTime으로 저장
|
||||
|
||||
```java
|
||||
private LocalDateTime createdAt;
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 절대 시점이 아니라 zone/offset 없는 wall-clock 값이 된다.
|
||||
- 시스템 간 교환, 저장, 비교에서 의미가 흔들린다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
private Instant createdAt;
|
||||
```
|
||||
|
||||
## 나쁜 예시 2: business logic에서 기본 시스템 zone 의존
|
||||
|
||||
```java
|
||||
LocalDate today = LocalDate.now();
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- JVM 기본 time-zone에 암묵적으로 의존한다.
|
||||
- 테스트와 운영 환경에 따라 결과가 달라질 수 있다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
```
|
||||
|
||||
또는:
|
||||
|
||||
```java
|
||||
LocalDate today = LocalDate.now(zoneId);
|
||||
```
|
||||
|
||||
## 나쁜 예시 3: 문자열로 시간 비교
|
||||
|
||||
```java
|
||||
if (request.startTime().compareTo("09:00") >= 0) {
|
||||
// open
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 타입 의미가 사라진다.
|
||||
- 포맷 변화에 취약하다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
if (!request.startTime().isBefore(LocalTime.of(9, 0))) {
|
||||
// open
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 4: legacy API 사용
|
||||
|
||||
```java
|
||||
Date now = new Date();
|
||||
Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + 1_800_000);
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 새 코드 기준으로 `java.time`보다 의미가 덜 명확하다.
|
||||
- 시간 단위와 시스템 clock 의존이 코드에 흩어진다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
Instant now = Instant.now(clock);
|
||||
Instant expiresAt = now.plus(Duration.ofMinutes(30));
|
||||
```
|
||||
|
||||
## 나쁜 예시 5: wall-clock 의미인데 Instant 남용
|
||||
|
||||
```java
|
||||
public record StoreHours(
|
||||
Instant opensAt,
|
||||
Instant closesAt
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- 영업 시작/종료는 보통 지역 wall-clock 의미다.
|
||||
- 절대 시점 타입이 도메인 의미를 흐린다.
|
||||
|
||||
개선:
|
||||
|
||||
```java
|
||||
public record StoreHours(
|
||||
LocalTime opensAt,
|
||||
LocalTime closesAt
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
## 나쁜 예시 6: Instant를 DTO에서 문자열로 직접 조립
|
||||
|
||||
```java
|
||||
public record TokenResponse(
|
||||
String expiresAt
|
||||
) {
|
||||
public static TokenResponse from(Instant expiresAt) {
|
||||
return new TokenResponse(expiresAt.toString());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문제:
|
||||
|
||||
- DTO 조립 코드가 시간 포맷 정책을 직접 가진다.
|
||||
- 응답 포맷 변경이 여러 DTO 생성 코드로 퍼질 수 있다.
|
||||
|
||||
개선 방향:
|
||||
|
||||
- response serialization 설정이나 전용 formatter 경계에서 포맷한다.
|
||||
- 내부 모델과 유스케이스 결과는 `Instant` 같은 typed value를 유지한다.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Exception Log 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 대표 실패만 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(ExternalAuthUnavailableException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleExternalAuthUnavailable(
|
||||
ExternalAuthUnavailableException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
log.error("Failed request. requestPath={} method={} errorCode={} actorId={}",
|
||||
request.getRequestURI(),
|
||||
request.getMethod(),
|
||||
ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE.code(),
|
||||
request.getAttribute("actorId"),
|
||||
ex);
|
||||
|
||||
ErrorCode errorCode = ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대표 ERROR 로그가 한 곳에 모인다
|
||||
- 메시지와 응답 코드가 분리된다
|
||||
- 운영 키와 stack trace가 함께 남는다
|
||||
|
||||
### 예시 2. 재시도 중간 실패는 WARN 또는 DEBUG로만 남긴다
|
||||
|
||||
```java
|
||||
try {
|
||||
return externalAuthClient.issueToken(command);
|
||||
} catch (SocketTimeoutException ex) {
|
||||
log.warn("External auth attempt failed. provider={} actorId={} attempt={}",
|
||||
"keycloak", command.actorId(), attemptNumber);
|
||||
throw ex;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 중간 실패를 곧바로 대표 장애처럼 기록하지 않는다
|
||||
- 재시도 맥락이 드러난다
|
||||
|
||||
### 예시 3. validation 실패는 ERROR로 과장하지 않는다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
log.info("Rejected invalid request. requestPath={} method={} actorId={}",
|
||||
request.getRequestURI(),
|
||||
request.getMethod(),
|
||||
request.getAttribute("actorId"));
|
||||
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResult.fail(ErrorCode.REQUEST_VALIDATION_FAILED, errors));
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예상 가능한 4xx를 서버 장애처럼 기록하지 않는다
|
||||
- 필요한 요청 맥락은 남긴다
|
||||
|
||||
### 예시 4. 메시지는 사건 설명 중심으로 쓴다
|
||||
|
||||
```java
|
||||
log.error("Failed external auth request. provider={} requestPath={} actorId={} durationMs={}",
|
||||
provider, requestPath, actorId, durationMs, ex);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 무엇이 실패했는지 바로 보인다
|
||||
- 예외 메시지 품질에 로그 제목이 종속되지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 같은 예외를 여러 레이어에서 반복 ERROR로 찍는다
|
||||
|
||||
```java
|
||||
log.error("Client failed", ex);
|
||||
log.error("Service failed", ex);
|
||||
log.error("Controller failed", ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 한 실패가 여러 번 기록된다
|
||||
- 검색/알림/집계 품질이 나빠진다
|
||||
|
||||
### 예시 2. 예외 메시지를 그대로 제목으로 쓴다
|
||||
|
||||
```java
|
||||
log.error(ex.getMessage(), ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 사건 맥락이 없다
|
||||
- 민감정보가 메시지에 섞일 수 있다
|
||||
- 운영 키가 없다
|
||||
|
||||
### 예시 3. 요청 본문 전체를 예외 로그에 남긴다
|
||||
|
||||
```java
|
||||
log.error("Failed create user request. requestBody={}", requestBody, ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PII/비밀번호/토큰이 유출될 수 있다
|
||||
- payload 전문 로그는 기본 금지다
|
||||
|
||||
### 예시 4. 예상 가능한 business rejection을 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
log.error("Duplicate email sign-up attempt. email={}", request.email());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 서버 장애처럼 과장된다
|
||||
- 개인식별정보 전체값이 그대로 남는다
|
||||
- 운영 신호를 오염시킨다
|
||||
@@ -0,0 +1,82 @@
|
||||
# Log Level 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 최종 실패만 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
try {
|
||||
externalAuthClient.issueToken(command);
|
||||
} catch (ExternalAuthException ex) {
|
||||
log.error("Failed to issue external auth token. provider={}, actorId={}", "auth-provider", command.actorId(), ex);
|
||||
throw ex;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 최종 실패를 명확히 드러낸다
|
||||
- 운영자가 바로 봐야 할 사건이다
|
||||
|
||||
### 예시 2. 재시도 후 성공은 WARN으로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("External auth request succeeded after retry. provider={}, actorId={}, attempts={}",
|
||||
"auth-provider", actorId, attemptCount);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 즉시 실패는 아니지만 이상 징후다
|
||||
- 운영 추적 가치가 있다
|
||||
|
||||
### 예시 3. 상세 분기 정보는 DEBUG에 둔다
|
||||
|
||||
```java
|
||||
log.debug("Mapped external auth response to internal token result. provider={}, tokenType={}",
|
||||
"auth-provider", response.tokenType());
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 상세 흐름 파악용이다
|
||||
- 기본 운영 레벨에서는 숨겨진다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 예상 가능한 비즈니스 거절을 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
log.error("Duplicate email sign-up attempt. email={}", request.email());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 서버 장애처럼 과장된다
|
||||
- 실제 운영 신호가 묻힌다
|
||||
|
||||
### 예시 2. 같은 예외를 여러 계층에서 모두 ERROR로 찍는다
|
||||
|
||||
```java
|
||||
log.error("Repository failed", ex);
|
||||
log.error("Service failed", ex);
|
||||
log.error("Controller failed", ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 한 실패가 세 번 기록된다
|
||||
- 검색/알림/분석 품질이 떨어진다
|
||||
|
||||
### 예시 3. production 상시 로그에 과도한 상세를 남긴다
|
||||
|
||||
```java
|
||||
log.info("Request payload={}", requestBody);
|
||||
log.info("Response payload={}", responseBody);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 노이즈가 많다
|
||||
- 민감정보 노출 위험이 크다
|
||||
- INFO 레벨 의미를 무너뜨린다
|
||||
@@ -0,0 +1,102 @@
|
||||
# Log Message Format 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 사건 설명 + key-value 필드를 함께 남긴다
|
||||
|
||||
```java
|
||||
log.info("Created user. actorId={} userId={} requestPath={}",
|
||||
actorId, userId, requestPath);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 사건이 짧게 드러난다
|
||||
- 검색 가능한 핵심 키가 있다
|
||||
- free text만으로 끝나지 않는다
|
||||
|
||||
### 예시 2. 실패 메시지에 운영 키를 먼저 담고 예외를 붙인다
|
||||
|
||||
```java
|
||||
log.error("Failed external auth request. provider={} actorId={} durationMs={}",
|
||||
provider, actorId, durationMs, ex);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 메시지 자체만 봐도 무엇이 실패했는지 알 수 있다
|
||||
- stack trace는 추가 정보로 붙는다
|
||||
- 운영 키가 빠지지 않는다
|
||||
|
||||
### 예시 3. 재시도/대체 경로도 명시적 사건으로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("Applied external auth fallback. provider={} actorId={} fallback={} durationMs={}",
|
||||
provider, actorId, "cached-public-key", durationMs);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- fallback 발생 사실이 바로 드러난다
|
||||
- 이후 검색과 집계가 쉽다
|
||||
|
||||
### 예시 4. structured logging 전환을 고려한 키 이름을 쓴다
|
||||
|
||||
```java
|
||||
log.info("Completed session cleanup. job={} deletedCount={} durationMs={}",
|
||||
"expired-session-cleanup", deletedCount, durationMs);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 텍스트 로그에서도 구조가 보인다
|
||||
- JSON 로그로 전환해도 의미가 유지된다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 설명만 길고 검색 키가 없다
|
||||
|
||||
```java
|
||||
log.info("The user registration process was completed successfully after all checks had been passed");
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 누가, 어떤 요청에서, 어떤 리소스가 생성됐는지 알 수 없다
|
||||
- 검색/집계가 어렵다
|
||||
|
||||
### 예시 2. 예외 메시지를 그대로 제목으로 쓴다
|
||||
|
||||
```java
|
||||
log.error(ex.getMessage(), ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 사건 맥락이 없다
|
||||
- 운영 키가 없다
|
||||
- 예외 메시지 품질에 로그 제목이 종속된다
|
||||
|
||||
### 예시 3. 민감정보를 그대로 남긴다
|
||||
|
||||
```java
|
||||
log.debug("Login request. email={} password={} accessToken={}",
|
||||
request.email(), request.password(), accessToken);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 민감정보가 원문으로 노출된다
|
||||
- 디버그 로그라도 허용되지 않는다
|
||||
|
||||
### 예시 4. 같은 의미를 제각각 다른 키 이름으로 쓴다
|
||||
|
||||
```java
|
||||
log.info("Created user. uid={} path={} timeMs={}", userId, requestPath, durationMs);
|
||||
log.info("Deleted user. userId={} requestUri={} duration={}", userId, requestPath, durationMs);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 의미의 키 이름이 섞인다
|
||||
- 검색/집계/알람 규칙이 복잡해진다
|
||||
@@ -0,0 +1,150 @@
|
||||
# Operation Indicator / Health Check 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 기본 actuator health와 probe 경로를 그대로 사용한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: 8081
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: 8081
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring Boot 기본 probe group을 그대로 사용한다
|
||||
- Kubernetes가 기대하는 liveness/readiness 의미와 맞다.
|
||||
|
||||
### 예시 2. management 포트 분리 시 main port에도 /livez, /readyz를 노출한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
server:
|
||||
port: 8081
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
add-additional-paths: true
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- actuator 전용 포트만 살아 있고 실제 애플리케이션 포트는 문제인 상황을 줄일 수 있다
|
||||
- Spring Boot도 이 구성을 좋은 아이디어로 안내한다.
|
||||
|
||||
### 예시 3. readiness에만 필수 내부 준비 상태를 추가한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
group:
|
||||
readiness:
|
||||
include: "readinessState,customCheck"
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- readiness에 필요한 추가 체크만 명시적으로 포함한다
|
||||
- liveness와 readiness를 구분해서 설계한다.
|
||||
|
||||
### 예시 4. startup이 긴 서비스에는 startup probe를 둔다
|
||||
|
||||
```yaml
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: 8080
|
||||
failureThreshold: 30
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 느린 시작 중 liveness 오탐을 줄일 수 있다
|
||||
- startup probe는 성공 전까지 liveness/readiness 실행을 지연시킨다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. liveness에 DB 상태를 직접 넣는다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadDatabaseLivenessIndicator implements HealthIndicator {
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
return databaseClient.ping() ? Health.up().build() : Health.down().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 외부 DB 장애가 모든 인스턴스 재시작으로 이어질 수 있다
|
||||
- Spring Boot는 liveness를 외부 체크 기반으로 두지 말라고 권고한다.
|
||||
|
||||
### 예시 2. 모든 외부 시스템을 readiness에 무조건 포함한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
group:
|
||||
readiness:
|
||||
include: "readinessState,db,redis,kafka,s3,externalApiA,externalApiB"
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공유 외부 시스템 장애 시 전체 인스턴스가 동시에 ready=false가 될 수 있다
|
||||
- fallback 가능한 비필수 시스템도 서비스 제외 원인이 된다.
|
||||
|
||||
### 예시 3. probe 용 controller를 별도로 만든다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
public class BadHealthController {
|
||||
|
||||
@GetMapping("/health")
|
||||
public Map<String, Object> health() {
|
||||
return Map.of("status", "UP");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- actuator가 이미 제공하는 운영 계약과 분리된다
|
||||
- liveness/readiness/group 정책과 연계되지 않는다
|
||||
- health semantics를 임의 JSON으로 약화시킨다.
|
||||
|
||||
### 예시 4. health indicator에서 무거운 쿼리를 수행한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadSlowHealthIndicator implements HealthIndicator {
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
analyticsRepository.runExpensiveAggregation();
|
||||
return Health.up().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- health endpoint 자체가 느려진다
|
||||
- Spring Boot도 느린 indicator를 warning 대상으로 본다.
|
||||
@@ -0,0 +1,122 @@
|
||||
# PII Masking 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 내부 식별자만 로그에 남긴다
|
||||
|
||||
```java
|
||||
log.info("Completed password reset request. actorId={} requestPath={}",
|
||||
actorId, requestPath);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 누구의 요청인지는 추적 가능하다
|
||||
- 이메일/전화번호/비밀번호는 남기지 않는다
|
||||
|
||||
### 예시 2. 토큰은 일부만 식별 가능하게 남긴다
|
||||
|
||||
```java
|
||||
String maskedToken = TokenMasker.prefix(token);
|
||||
log.warn("Rejected external callback due to invalid token. provider={} tokenPrefix={}",
|
||||
provider, maskedToken);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 토큰 전체 원문을 남기지 않는다
|
||||
- 운영상 일부 식별은 가능하다
|
||||
|
||||
### 예시 3. 이메일은 부분 마스킹한다
|
||||
|
||||
```java
|
||||
String maskedEmail = EmailMasker.mask(request.email());
|
||||
log.info("Started email verification. actorId={} email={}",
|
||||
actorId, maskedEmail);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 메일 발송 대상 추적은 가능하다
|
||||
- 개인 식별정보 전체값을 남기지 않는다
|
||||
|
||||
### 예시 4. structured logging에도 안전한 필드만 넣는다
|
||||
|
||||
```java
|
||||
MDC.put("actorId", actorId);
|
||||
MDC.put("requestPath", request.getRequestURI());
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 운영 상관관계 필드는 남긴다
|
||||
- token/session/password 같은 값은 MDC에 올리지 않는다
|
||||
|
||||
### 예시 5. 외부 오류 메시지는 정제해서 남긴다
|
||||
|
||||
```java
|
||||
log.error("Failed external auth request. provider={} status={} errorCode={}",
|
||||
provider, status, "UPSTREAM_AUTH_SERVER_UNAVAILABLE", ex);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 시스템 에러 본문 원문을 그대로 노출하지 않는다
|
||||
- 운영 키와 표준 에러 코드 중심으로 남긴다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. Authorization 헤더를 그대로 남긴다
|
||||
|
||||
```java
|
||||
log.debug("Incoming request. authorization={}", request.getHeader("Authorization"));
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- access token 원문이 로그로 유출된다
|
||||
- 디버그 로그라도 허용되지 않는다
|
||||
|
||||
### 예시 2. request body 전체를 남긴다
|
||||
|
||||
```java
|
||||
log.info("Create user request body={}", requestBody);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 비밀번호, 이메일, 전화번호 등 민감값이 함께 들어갈 수 있다
|
||||
- 운영 로그 노이즈도 크다
|
||||
|
||||
### 예시 3. 세션 ID를 그대로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("Invalid session. sessionId={}", sessionId);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 세션 식별값 원문이 노출된다
|
||||
- OWASP도 세션 식별값은 직접 로그에 남기지 말라고 권고한다.
|
||||
|
||||
### 예시 4. 예외 메시지를 그대로 제목으로 쓴다
|
||||
|
||||
```java
|
||||
log.error(ex.getMessage(), ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 예외 메시지 안 민감정보가 그대로 노출될 수 있다
|
||||
- 사건 설명과 안전한 운영 키가 없다
|
||||
|
||||
### 예시 5. 외부 입력을 정제 없이 로그에 넣는다
|
||||
|
||||
```java
|
||||
log.warn("Rejected request. keyword={}", request.getParameter("keyword"));
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 줄바꿈/제어문자/악성 문자열이 로그 형식을 깨뜨릴 수 있다
|
||||
- 민감 검색어가 그대로 남을 수 있다
|
||||
@@ -0,0 +1,161 @@
|
||||
# Trace / Principal / Path Recording 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. controller는 현재 사용자 식별자를 명시적으로 받는다
|
||||
|
||||
```java
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@AuthenticationPrincipal(expression = "userId")
|
||||
public @interface CurrentUserId {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@CurrentUserId String actorId,
|
||||
@PathVariable String userId
|
||||
) {
|
||||
UserResult result = userQueryUseCase.getUser(actorId, userId);
|
||||
return ApiResult.success(new UserResponse(result.userId(), result.email()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- principal 접근이 controller 시그니처에서 드러난다
|
||||
- SecurityContextHolder 직접 접근이 없다
|
||||
- application에는 최소 actor 정보만 전달한다
|
||||
|
||||
### 예시 2. 공통 요청 로그는 한 곳에서 남긴다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class RequestLoggingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
long startNanos = System.nanoTime();
|
||||
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
long durationMs = Duration.ofNanos(System.nanoTime() - startNanos).toMillis();
|
||||
|
||||
log.info("Completed request. requestPath={} method={} status={} durationMs={}",
|
||||
request.getRequestURI(),
|
||||
request.getMethod(),
|
||||
response.getStatus(),
|
||||
durationMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대표 요청 로그가 공통 위치에 있다
|
||||
- requestPath와 method/status/duration이 일관되게 남는다
|
||||
- controller마다 요청 로그를 복붙하지 않는다
|
||||
|
||||
### 예시 3. principal은 내부 식별자만 남긴다
|
||||
|
||||
```java
|
||||
log.warn("Rejected request. requestPath={} actorId={} errorCode={}",
|
||||
requestPath, actorId, "ACCESS_DENIED");
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 이메일/토큰 같은 민감정보 대신 내부 식별자를 남긴다
|
||||
- principal 검색 가능성과 개인정보 보호를 함께 고려한다
|
||||
|
||||
### 예시 4. route template를 별도 필드로 둘 수 있다
|
||||
|
||||
```java
|
||||
log.info("Completed request. requestPath={} route={} method={} status={} durationMs={}",
|
||||
"/api/v1/users/123",
|
||||
"/api/v1/users/{userId}",
|
||||
"GET",
|
||||
200,
|
||||
21);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 실제 요청과 집계용 route를 분리할 수 있다
|
||||
- 고카디널리티 문제를 운영에서 다루기 쉬워진다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 SecurityContextHolder를 직접 읽는다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/v1/me")
|
||||
public ApiResult<String> me() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
|
||||
|
||||
log.info("Current request. path={} principal={}", request.getRequestURI(), principal);
|
||||
return ApiResult.success(principal.getUserId());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- principal 접근과 로깅 규칙이 controller에 퍼진다
|
||||
- principal 전체 객체가 로그에 노출될 수 있다
|
||||
- current user 접근 방식이 일관되지 않다
|
||||
|
||||
### 예시 2. query string 전체를 기본 로그에 남긴다
|
||||
|
||||
```java
|
||||
log.info("Incoming request. requestPath={} query={}",
|
||||
request.getRequestURI(),
|
||||
request.getQueryString());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 검색어, 토큰, 식별자 등 민감정보가 섞일 수 있다
|
||||
- 운영 로그에 노이즈가 많아진다
|
||||
|
||||
### 예시 3. 같은 의미를 여러 키 이름으로 섞는다
|
||||
|
||||
```java
|
||||
log.info("Completed request. uri={} userId={}", requestPath, actorId);
|
||||
log.info("Failed request. path={} principalId={}", requestPath, actorId);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- uri/path/requestPath, userId/actorId/principalId가 혼용된다
|
||||
- 검색/집계 규칙이 깨진다
|
||||
|
||||
### 예시 4. 요청 로그를 여러 레이어에서 반복한다
|
||||
|
||||
```java
|
||||
log.info("Controller request. requestPath={}", requestPath);
|
||||
log.info("Service request. requestPath={}", requestPath);
|
||||
log.info("Client request. requestPath={}", requestPath);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 대표 요청 로그가 중복된다
|
||||
- 실제 중요한 비즈니스/연동 로그가 묻힌다
|
||||
@@ -0,0 +1,171 @@
|
||||
# AOP 예시
|
||||
|
||||
## 좋은 예시 1: 실행 시간 측정
|
||||
|
||||
```java
|
||||
@Aspect
|
||||
@Component
|
||||
public class TimingAspect {
|
||||
|
||||
@Pointcut("execution(public * com.project.auth.application..*(..))")
|
||||
public void applicationOperation() {}
|
||||
|
||||
@Around("applicationOperation()")
|
||||
public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
return joinPoint.proceed();
|
||||
} finally {
|
||||
long elapsed = System.nanoTime() - start;
|
||||
log.info("method={} elapsedNanos={}", joinPoint.getSignature(), elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 횡단 관심사인 timing만 다룬다
|
||||
- 비즈니스 로직을 바꾸지 않는다
|
||||
- pointcut이 이름 있는 작은 단위다
|
||||
|
||||
## 좋은 예시 2: 예외 기록
|
||||
|
||||
```java
|
||||
@Aspect
|
||||
@Component
|
||||
public class ExceptionLoggingAspect {
|
||||
|
||||
@Pointcut("execution(public * com.project.auth.application..*(..))")
|
||||
public void applicationOperation() {}
|
||||
|
||||
@AfterThrowing(pointcut = "applicationOperation()", throwing = "exception")
|
||||
public void logFailure(Exception exception) {
|
||||
log.error("application failure", exception);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 예외를 숨기지 않고 기록만 한다
|
||||
- 비즈니스 의미를 변경하지 않는다
|
||||
|
||||
## 좋은 예시 3: annotation 기반 감사
|
||||
|
||||
```java
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface Audited {
|
||||
String action();
|
||||
}
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
public class AuditAspect {
|
||||
|
||||
@Around("@annotation(audited)")
|
||||
public Object audit(ProceedingJoinPoint joinPoint, Audited audited) throws Throwable {
|
||||
Object result = joinPoint.proceed();
|
||||
auditLog.record(audited.action(), joinPoint.getSignature().toShortString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- annotation으로 의도를 드러낸다
|
||||
- 횡단 concern만 수행한다
|
||||
- service 흐름을 숨기지 않는다
|
||||
|
||||
## 좋은 예시 4: named pointcut 조합
|
||||
|
||||
```java
|
||||
@Aspect
|
||||
@Component
|
||||
public class CommonPointcuts {
|
||||
|
||||
@Pointcut("execution(public * *(..))")
|
||||
public void publicMethod() {}
|
||||
|
||||
@Pointcut("within(com.project.auth.application..*)")
|
||||
public void inApplicationLayer() {}
|
||||
|
||||
@Pointcut("publicMethod() && inApplicationLayer()")
|
||||
public void applicationPublicOperation() {}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 작은 pointcut을 조합한다
|
||||
- 범위를 읽고 설명하기 쉽다
|
||||
|
||||
## 나쁜 예시 1: 핵심 비즈니스 로직을 AOP로 이동
|
||||
|
||||
```java
|
||||
@Around("execution(* ..LoginService.login(..))")
|
||||
public Object issueTokenAndSaveAudit(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 핵심 use case 흐름이 숨는다
|
||||
- 코드 추적이 어려워진다
|
||||
- 서비스가 해야 할 결정을 aspect가 가져간다
|
||||
|
||||
## 나쁜 예시 2: self-invocation 기대
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class SampleService {
|
||||
|
||||
public void foo() {
|
||||
this.bar(); // aspect 기대
|
||||
}
|
||||
|
||||
public void bar() {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- proxy를 통과하지 않아 advice가 적용되지 않을 수 있다
|
||||
|
||||
## 나쁜 예시 3: 너무 넓은 pointcut
|
||||
|
||||
```java
|
||||
@Before("execution(* *(..))")
|
||||
public void logEverything() {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 범위가 지나치게 넓다
|
||||
- 성능/디버깅/예측 가능성 모두 나빠질 수 있다
|
||||
- 어떤 코드가 영향을 받는지 설명하기 어렵다
|
||||
|
||||
## 나쁜 예시 4: @Around로 예외 숨김
|
||||
|
||||
```java
|
||||
@Around("execution(* ..*(..))")
|
||||
public Object swallow(ProceedingJoinPoint joinPoint) {
|
||||
try {
|
||||
return joinPoint.proceed();
|
||||
} catch (Throwable ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 실패를 정상값처럼 숨긴다
|
||||
- 디버깅과 계약을 깨뜨린다
|
||||
@@ -0,0 +1,175 @@
|
||||
# ApplicationEvent 예시
|
||||
|
||||
## 좋은 예시 1: 핵심 작업 후 후속 반응 분리
|
||||
|
||||
```java
|
||||
public record UserRegisteredEvent(
|
||||
Long userId,
|
||||
String email,
|
||||
Instant occurredAt
|
||||
) {}
|
||||
|
||||
@Service
|
||||
public class RegisterUserService {
|
||||
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final Clock clock;
|
||||
|
||||
public RegisterUserService(ApplicationEventPublisher eventPublisher, Clock clock) {
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Long register(CreateUserCommand command) {
|
||||
User user = ...;
|
||||
userRepository.save(user);
|
||||
|
||||
eventPublisher.publishEvent(
|
||||
new UserRegisteredEvent(user.getId(), user.getEmail(), Instant.now(clock))
|
||||
);
|
||||
|
||||
return user.getId();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 핵심 등록 작업과 후속 반응을 분리한다
|
||||
- 이벤트 payload가 필요한 상태를 직접 담는다
|
||||
- publisher가 listener 구현을 모른다
|
||||
|
||||
## 좋은 예시 2: commit 후에만 처리
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class UserRegisteredAuditListener {
|
||||
|
||||
@TransactionalEventListener
|
||||
public void handle(UserRegisteredEvent event) {
|
||||
auditLog.record("USER_REGISTERED", event.userId(), event.occurredAt());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 기본 AFTER_COMMIT 의미를 활용한다
|
||||
- rollback된 작업에 대해 잘못된 후속 기록을 남기지 않는다
|
||||
|
||||
## 좋은 예시 3: listener는 짧고 부가적
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class WelcomeMetricListener {
|
||||
|
||||
@EventListener
|
||||
public void handle(UserRegisteredEvent event) {
|
||||
metrics.counter("user.registered").increment();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 짧고 효율적이다
|
||||
- 핵심 비즈니스 흐름을 숨기지 않는다
|
||||
|
||||
## 좋은 예시 4: 테스트에서 이벤트 검증
|
||||
|
||||
```java
|
||||
@RecordApplicationEvents
|
||||
@SpringBootTest
|
||||
class RegisterUserServiceTest {
|
||||
|
||||
@Test
|
||||
void publishes_user_registered_event(ApplicationEvents events) {
|
||||
service.register(command);
|
||||
|
||||
assertThat(events.stream(UserRegisteredEvent.class)).hasSize(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 이벤트 발행 사실을 테스트로 확인할 수 있다
|
||||
- “어딘가에서 되겠지” 상태를 줄인다
|
||||
|
||||
## 나쁜 예시 1: 핵심 오케스트레이션을 이벤트에 숨김
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class LoginService {
|
||||
|
||||
public LoginResponse login(LoginCommand command) {
|
||||
eventPublisher.publishEvent(new LoginRequestedEvent(command));
|
||||
return LoginResponse.pending();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 핵심 로그인 흐름이 listener들 뒤로 숨어버린다
|
||||
- 메인 결과가 이벤트 체인에 의존하게 된다
|
||||
|
||||
## 나쁜 예시 2: payload가 너무 빈약함
|
||||
|
||||
```java
|
||||
public record UserRegisteredEvent(Long userId) {}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 모든 listener가 다시 조회를 강요받을 수 있다
|
||||
- 필요한 최소 상태가 누락되면 결합과 조회 비용이 커진다
|
||||
|
||||
**개선:**
|
||||
|
||||
- 정말 필요한 상태를 payload에 포함
|
||||
|
||||
## 나쁜 예시 3: 무거운 작업을 listener에 직접 넣음
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class HeavyListener {
|
||||
|
||||
@EventListener
|
||||
public void handle(UserRegisteredEvent event) {
|
||||
externalApi.call(...);
|
||||
fileExporter.export(...);
|
||||
Thread.sleep(5000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- listener가 너무 무겁고 느리다
|
||||
- event hand-off의 장점을 해친다
|
||||
- 장애 반경이 커진다
|
||||
|
||||
## 나쁜 예시 4: listener 순서에 핵심 의존
|
||||
|
||||
```java
|
||||
@Component
|
||||
class FirstListener {
|
||||
@Order(1)
|
||||
@EventListener
|
||||
void handle(UserRegisteredEvent event) { ... }
|
||||
}
|
||||
|
||||
@Component
|
||||
class SecondListener {
|
||||
@Order(2)
|
||||
@EventListener
|
||||
void handle(UserRegisteredEvent event) { ... } // 첫 번째가 반드시 먼저 돌 것을 기대
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 이벤트 기반 구조가 사실상 숨은 절차형 흐름이 된다
|
||||
- 순서 의존이 커질수록 명시적 호출이 더 낫다
|
||||
@@ -0,0 +1,199 @@
|
||||
# async / scheduler / retry 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 스케줄 트리거는 얇게 두고, 재시도는 외부 경계에 둔다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ExpiredSessionCleanupJob {
|
||||
|
||||
private final ExpiredSessionCleanupUseCase expiredSessionCleanupUseCase;
|
||||
|
||||
@Scheduled(cron = "${auth.session.cleanup-cron}")
|
||||
public void run() {
|
||||
expiredSessionCleanupUseCase.cleanUpExpiredSessions();
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExpiredSessionCleanupUseCase {
|
||||
|
||||
private final SessionRepository sessionRepository;
|
||||
private final TokenRevocationGateway tokenRevocationGateway;
|
||||
private final CleanupAuditAsyncPublisher cleanupAuditAsyncPublisher;
|
||||
|
||||
public void cleanUpExpiredSessions() {
|
||||
List<ExpiredSession> expiredSessions = sessionRepository.findExpiredSessions();
|
||||
|
||||
for (ExpiredSession expiredSession : expiredSessions) {
|
||||
tokenRevocationGateway.revoke(expiredSession.tokenId());
|
||||
}
|
||||
|
||||
cleanupAuditAsyncPublisher.publish(expiredSessions.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
public class TokenRevocationGateway {
|
||||
|
||||
@Retryable(
|
||||
retryFor = {
|
||||
ResourceAccessException.class,
|
||||
SocketTimeoutException.class,
|
||||
ConnectException.class
|
||||
},
|
||||
noRetryFor = {
|
||||
IllegalArgumentException.class
|
||||
},
|
||||
maxAttempts = 3,
|
||||
backoff = @Backoff(delay = 500, maxDelay = 2_000, multiplier = 2.0)
|
||||
)
|
||||
public void revoke(String tokenId) {
|
||||
// 외부 인증/폐기 시스템 호출
|
||||
}
|
||||
|
||||
@Recover
|
||||
public void recover(Exception ex, String tokenId) {
|
||||
throw new ExternalDependencyException("Token revocation failed after retries. tokenId=" + tokenId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CleanupAuditAsyncPublisher {
|
||||
|
||||
@Async("auditAsyncExecutor")
|
||||
public CompletableFuture<Void> publish(int cleanedCount) {
|
||||
// 감사 로그/알림 전송
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- scheduler는 트리거만 담당
|
||||
- retry는 외부 호출 경계에만 존재
|
||||
- async는 비핵심 후속 처리로 분리
|
||||
- 각 책임이 bean 경계로 나뉘어 프록시 적용 여부가 명확함
|
||||
|
||||
### 예시 2. executor / scheduler를 명시적으로 분리한다
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
@EnableRetry
|
||||
public class TaskExecutionConfig implements AsyncConfigurer {
|
||||
|
||||
@Bean(name = "auditAsyncExecutor")
|
||||
public ThreadPoolTaskExecutor auditAsyncExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setThreadNamePrefix("audit-async-");
|
||||
executor.setCorePoolSize(4);
|
||||
executor.setMaxPoolSize(8);
|
||||
executor.setQueueCapacity(200);
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Bean(name = "maintenanceTaskScheduler")
|
||||
public ThreadPoolTaskScheduler maintenanceTaskScheduler() {
|
||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||
scheduler.setThreadNamePrefix("maintenance-scheduler-");
|
||||
scheduler.setPoolSize(2);
|
||||
scheduler.initialize();
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Executor getAsyncExecutor() {
|
||||
return auditAsyncExecutor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
||||
return (ex, method, params) ->
|
||||
log.error("Async error in method={}, params={}", method.getName(), Arrays.toString(params), ex);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- async executor와 scheduler를 분리
|
||||
- thread prefix로 운영 추적 가능
|
||||
- void @Async 예외를 방치하지 않음
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 한 메서드에 스케줄/비동기/재시도를 다 겹친다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class BadCleanupService {
|
||||
|
||||
@Scheduled(fixedRate = 1000)
|
||||
@Async
|
||||
@Retryable
|
||||
public void run() {
|
||||
// 핵심 업무 + 외부 호출 + 후속 처리까지 한곳에 몰아넣음
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실행 경계가 불명확함
|
||||
- 실패 전파/관측/재시도 범위가 애매함
|
||||
- 어떤 책임 때문에 실패했는지 읽기 어려움
|
||||
- 기본 retry 정책에 의존하기 쉬움
|
||||
|
||||
### 예시 2. self-invocation으로 @Async / @Retryable 효과를 기대한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BadNotificationService {
|
||||
|
||||
public void sendAll(List<String> ids) {
|
||||
for (String id : ids) {
|
||||
this.sendOne(id); // 프록시를 거치지 않음
|
||||
}
|
||||
}
|
||||
|
||||
@Async
|
||||
public void sendOne(String id) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 클래스 내부 호출이라 프록시 적용을 기대하면 안 됨
|
||||
|
||||
### 예시 3. retry를 결정적 실패에 건다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadMapper {
|
||||
|
||||
@Retryable(maxAttempts = 5)
|
||||
public UserId map(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException("raw must not be blank");
|
||||
}
|
||||
return new UserId(raw);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 입력 검증 실패는 재시도로 해결되지 않음
|
||||
- retry 대상 예외를 좁히지 않음
|
||||
- domain/value 생성 로직에 retry를 붙임
|
||||
@@ -0,0 +1,138 @@
|
||||
# bean registration 예시
|
||||
|
||||
## 좋은 예시 1: application service는 stereotype 등록
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class RegisterUserService implements RegisterUserUseCase {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 애플리케이션 주 컴포넌트라는 역할이 드러난다
|
||||
- scanning 기반 등록에 자연스럽다
|
||||
|
||||
## 좋은 예시 2: external client는 configuration + bean
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class VaultClientConfiguration {
|
||||
|
||||
@Bean
|
||||
public VaultTransitClient vaultTransitClient(
|
||||
VaultProperties properties,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
return new VaultTransitClient(
|
||||
properties.address(),
|
||||
properties.token(),
|
||||
HttpClient.newHttpClient(),
|
||||
objectMapper
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 외부 라이브러리/인프라 객체 조립이 한 곳에 모인다
|
||||
- 생성 로직이 명시적이다
|
||||
|
||||
## 좋은 예시 3: security/filter wiring은 configuration에 둠
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class WebConfiguration {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<TraceIdFilter> traceIdFilter() {
|
||||
FilterRegistrationBean<TraceIdFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(new TraceIdFilter());
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- framework wiring 성격이 분명하다
|
||||
- business component와 분리된다
|
||||
|
||||
## 좋은 예시 4: domain object는 bean으로 등록하지 않음
|
||||
|
||||
```java
|
||||
public record UserEmail(String value) {}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- value object는 container 관리 대상이 아니다
|
||||
- 생성/검증 책임은 domain에 남는다
|
||||
|
||||
## 나쁜 예시 1: domain entity를 bean으로 등록
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class User {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- domain object 생명주기를 container가 소유하게 된다
|
||||
- 의미가 맞지 않는다
|
||||
|
||||
## 나쁜 예시 2: @Component 안에 습관적 @Bean
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class UserFactoryComponent {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- full @Configuration 대신 lite mode가 된다
|
||||
- configuration 역할과 component 역할이 섞인다
|
||||
|
||||
**개선:**
|
||||
|
||||
- 별도 @Configuration 클래스로 이동
|
||||
|
||||
## 나쁜 예시 3: 의미 없는 잡다한 config
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class CommonConfig {
|
||||
@Bean ...
|
||||
@Bean ...
|
||||
@Bean ...
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 어떤 조립을 담당하는지 이름만 보고 알기 어렵다
|
||||
- 변경 이유가 다른 bean이 섞이기 쉽다
|
||||
|
||||
## 나쁜 예시 4: 단순 helper까지 bean으로 올림
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class StringMaskingHelper {
|
||||
public String mask(String input) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- lifecycle/DI 이득이 작다
|
||||
- plain helper로 둘 수 있다면 굳이 bean일 필요가 없다
|
||||
@@ -0,0 +1,150 @@
|
||||
# @ConfigurationProperties 예시
|
||||
|
||||
## 좋은 예시 1: 의미 있는 설정 집합을 타입으로 묶음
|
||||
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "auth.jwt")
|
||||
@Validated
|
||||
public record JwtProperties(
|
||||
@NotNull Duration accessTokenTtl,
|
||||
@NotNull Duration refreshTokenTtl,
|
||||
@NotBlank String issuer
|
||||
) {}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 관련 설정이 하나의 계약으로 묶인다
|
||||
- 타입 안전성과 검증이 있다
|
||||
- scattered @Value를 줄인다
|
||||
|
||||
## 좋은 예시 2: configuration properties scanning 사용
|
||||
|
||||
```java
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class AuthApplication {
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 애플리케이션 내부 properties 타입을 명시적으로 스캔한다
|
||||
- @Component에 기대지 않는다
|
||||
|
||||
## 좋은 예시 3: 조건부/명시 등록은 EnableConfigurationProperties
|
||||
|
||||
```java
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(VaultProperties.class)
|
||||
public class VaultConfiguration {
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 어떤 설정 타입을 활성화하는지 명확하다
|
||||
- auto-configuration/조건부 wiring에 잘 맞는다
|
||||
|
||||
## 좋은 예시 4: third-party bean에 바인딩
|
||||
|
||||
```java
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class ClientConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConfigurationProperties("app.http.client")
|
||||
public HttpClientProperties httpClientProperties() {
|
||||
return new HttpClientProperties();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 외부 타입/서드파티 설정을 명시적 config 안에 가둔다
|
||||
- prefix와 등록 위치가 분명하다
|
||||
|
||||
## 나쁜 예시 1: 산발적 @Value 남발
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class JwtIssuer {
|
||||
|
||||
@Value("${auth.jwt.access-token-ttl}")
|
||||
private Duration accessTokenTtl;
|
||||
|
||||
@Value("${auth.jwt.refresh-token-ttl}")
|
||||
private Duration refreshTokenTtl;
|
||||
|
||||
@Value("${auth.jwt.issuer}")
|
||||
private String issuer;
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 관련 설정이 흩어진다
|
||||
- 타입 집합과 검증이 약해진다
|
||||
- 재사용/문서화가 어려워진다
|
||||
|
||||
**개선:**
|
||||
|
||||
- JwtProperties로 묶어서 주입
|
||||
|
||||
## 나쁜 예시 2: Optional 필드 사용
|
||||
|
||||
```java
|
||||
@ConfigurationProperties("vault")
|
||||
public record VaultProperties(
|
||||
Optional<String> namespace
|
||||
) {}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- Spring Boot 공식 문서가 권장하지 않는다
|
||||
- 값이 없으면 empty Optional이 아니라 null이 바인딩될 수 있다
|
||||
|
||||
**개선:**
|
||||
|
||||
- nullable String
|
||||
- 명시적 기본값
|
||||
- 별도 default 처리
|
||||
|
||||
## 나쁜 예시 3: properties class에 business logic 포함
|
||||
|
||||
```java
|
||||
@ConfigurationProperties("auth.jwt")
|
||||
public class JwtProperties {
|
||||
|
||||
private Duration accessTokenTtl;
|
||||
|
||||
public String issueToken(User user) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 설정 계약과 비즈니스 로직이 섞인다
|
||||
- 테스트/책임 분리가 흐려진다
|
||||
|
||||
## 나쁜 예시 4: CommonProperties dump zone
|
||||
|
||||
```java
|
||||
@ConfigurationProperties("app")
|
||||
public class AppProperties {
|
||||
private String jwtIssuer;
|
||||
private Duration retryDelay;
|
||||
private String vaultAddress;
|
||||
private String mailFrom;
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 소유 기능이 다 다르다
|
||||
- prefix와 책임이 너무 넓다
|
||||
- 기능별 변경이 서로 얽힌다
|
||||
@@ -0,0 +1,165 @@
|
||||
# dependency injection 예시
|
||||
|
||||
## 좋은 예시 1: 필수 의존성은 생성자 주입
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class RegisterUserService implements RegisterUserUseCase {
|
||||
|
||||
private final UserReader userReader;
|
||||
private final UserAppender userAppender;
|
||||
private final PasswordHasher passwordHasher;
|
||||
|
||||
public RegisterUserService(
|
||||
UserReader userReader,
|
||||
UserAppender userAppender,
|
||||
PasswordHasher passwordHasher
|
||||
) {
|
||||
this.userReader = userReader;
|
||||
this.userAppender = userAppender;
|
||||
this.passwordHasher = passwordHasher;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 필수 의존성이 시그니처에 드러난다
|
||||
- final field를 사용할 수 있다
|
||||
- 객체가 완전한 상태로 생성된다
|
||||
|
||||
## 좋은 예시 2: single constructor면 @Autowired 생략
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class JwtTokenIssuer {
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
public JwtTokenIssuer(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- Spring은 단일 생성자를 자동으로 사용할 수 있다
|
||||
- annotation noise를 줄인다
|
||||
|
||||
## 좋은 예시 3: 선택 의존성은 setter/config method 검토
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class AuditClient {
|
||||
|
||||
private RetryTemplate retryTemplate = RetryTemplate.defaultInstance();
|
||||
|
||||
@Autowired(required = false)
|
||||
public void setRetryTemplate(RetryTemplate retryTemplate) {
|
||||
this.retryTemplate = retryTemplate;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 선택 의존성이라는 점이 드러난다
|
||||
- reasonable default가 있다
|
||||
|
||||
## 좋은 예시 4: 다중 구현은 qualifier로 명시
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class OAuthLoginService {
|
||||
|
||||
private final OAuthClient googleOAuthClient;
|
||||
|
||||
public OAuthLoginService(@Qualifier("googleOAuthClient") OAuthClient googleOAuthClient) {
|
||||
this.googleOAuthClient = googleOAuthClient;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 여러 구현체 중 무엇을 주입받는지 명확하다
|
||||
- 우연한 후보 선택에 기대지 않는다
|
||||
|
||||
## 나쁜 예시 1: production field injection
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class RegisterUserService {
|
||||
|
||||
@Autowired
|
||||
private UserReader userReader;
|
||||
|
||||
@Autowired
|
||||
private UserAppender userAppender;
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 필수 의존성이 시그니처에 안 드러난다
|
||||
- final field 사용이 어렵다
|
||||
- plain unit test가 불편하다
|
||||
|
||||
## 나쁜 예시 2: service locator 사용
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class RegisterUserService {
|
||||
|
||||
@Autowired
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
public void register(...) {
|
||||
UserAppender userAppender = applicationContext.getBean(UserAppender.class);
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- DI가 아니라 lookup으로 퇴행한다
|
||||
- 숨은 의존성이 생긴다
|
||||
|
||||
## 나쁜 예시 3: 생성자 인자 과다를 setter로 숨김
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class ComplexService {
|
||||
|
||||
@Autowired
|
||||
public void setA(A a) { ... }
|
||||
|
||||
@Autowired
|
||||
public void setB(B b) { ... }
|
||||
|
||||
@Autowired
|
||||
public void setC(C c) { ... }
|
||||
|
||||
@Autowired
|
||||
public void setD(D d) { ... }
|
||||
|
||||
@Autowired
|
||||
public void setE(E e) { ... }
|
||||
|
||||
@Autowired
|
||||
public void setF(F f) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 책임이 큰 문제를 주입 방식으로 숨긴다
|
||||
- 객체의 필수/선택 의존성이 흐려진다
|
||||
|
||||
**개선:**
|
||||
|
||||
- collaborator 분리
|
||||
- orchestration 재설계
|
||||
- 설정 묶기 검토
|
||||
@@ -0,0 +1,495 @@
|
||||
# Filter / Interceptor / Resolver / Advice 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. request/response 수준의 공통 처리만 filter에 둔다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class RequestIdFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String REQUEST_ID_ATTRIBUTE = "requestId";
|
||||
public static final String REQUEST_ID_HEADER = "X-Request-Id";
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilterErrorDispatch() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
String requestId = request.getHeader(REQUEST_ID_HEADER);
|
||||
if (requestId == null || requestId.isBlank()) {
|
||||
requestId = UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId);
|
||||
response.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- HTTP request/response concern만 다룬다
|
||||
- controller 이전에 처리되어도 자연스럽다
|
||||
- business/service/repository에 의존하지 않는다
|
||||
|
||||
### 예시 2. handler 전후의 가벼운 공통 처리는 interceptor에 둔다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuditActorInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final AuditContextHolder auditContextHolder;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler
|
||||
) {
|
||||
String actorId = (String) request.getAttribute(RequestAttributes.AUTHENTICATED_ACTOR_ID);
|
||||
if (actorId != null) {
|
||||
auditContextHolder.bind(actorId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex
|
||||
) {
|
||||
auditContextHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public final class RequestAttributes {
|
||||
|
||||
public static final String AUTHENTICATED_ACTOR_ID = "authenticatedActorId";
|
||||
|
||||
private RequestAttributes() {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- handler 실행 전후의 공통 처리라는 interceptor 책임에 맞는다
|
||||
- 인증 자체를 구현하지 않고, 인증 이후 컨텍스트 연결만 수행한다
|
||||
- 핵심 business logic을 수행하지 않는다
|
||||
|
||||
### 예시 3. resolver는 낮은 수준의 예외만 제한적으로 변환한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class RequestBindingExceptionResolver implements HandlerExceptionResolver {
|
||||
|
||||
@Override
|
||||
public ModelAndView resolveException(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex
|
||||
) throws IOException {
|
||||
if (!(ex instanceof HttpMessageNotReadableException)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write("""
|
||||
{
|
||||
"success": false,
|
||||
"code": "MALFORMED_JSON_REQUEST",
|
||||
"message": "Malformed request body"
|
||||
}
|
||||
""");
|
||||
|
||||
return new ModelAndView();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- resolver를 “전역 business exception 처리기”가 아니라 저수준 예외 처리 지점으로 제한한다
|
||||
- null 반환으로 다른 예외는 다음 resolver/advice에 넘긴다
|
||||
- resolver 사용 이유가 명확하다
|
||||
|
||||
**주의:**
|
||||
|
||||
- 실제 프로젝트에서는 이조차도 가능하면 advice/기본 처리로 흡수할 수 있는지 먼저 검토하는 편이 낫다
|
||||
- 이 예시는 “resolver가 허용되는 좁은 자리”를 보여주기 위한 예시다
|
||||
|
||||
### 예시 4. 에러 정책은 ErrorCode로 중앙 관리한다
|
||||
|
||||
```java
|
||||
public enum ErrorCode {
|
||||
DOMAIN_RULE_VIOLATION(HttpStatus.CONFLICT, "DOMAIN_RULE_VIOLATION", "Domain rule violation"),
|
||||
EXTERNAL_DEPENDENCY_FAILURE(HttpStatus.BAD_GATEWAY, "EXTERNAL_DEPENDENCY_FAILURE", "Temporary external dependency failure"),
|
||||
REQUEST_VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "REQUEST_VALIDATION_FAILED", "Request validation failed");
|
||||
|
||||
private final HttpStatus httpStatus;
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
ErrorCode(HttpStatus httpStatus, String code, String message) {
|
||||
this.httpStatus = httpStatus;
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public HttpStatus httpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 에러 코드, 메시지, 상태값이 분산되지 않는다
|
||||
- advice가 문자열 조립 대신 매핑 책임에 집중할 수 있다
|
||||
|
||||
### 예시 5. 전역 예외 응답은 @RestControllerAdvice에서 ApiResult로 통일한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DomainRuleViolationException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDomainRuleViolation(
|
||||
HttpServletRequest request
|
||||
) {
|
||||
ErrorCode errorCode = ErrorCode.DOMAIN_RULE_VIOLATION;
|
||||
Map<String, String> metadata = requestMetadata(request);
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(
|
||||
errorCode,
|
||||
null,
|
||||
metadata
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ExternalDependencyException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleExternalDependency(
|
||||
HttpServletRequest request
|
||||
) {
|
||||
ErrorCode errorCode = ErrorCode.EXTERNAL_DEPENDENCY_FAILURE;
|
||||
Map<String, String> metadata = requestMetadata(request);
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(
|
||||
errorCode,
|
||||
null,
|
||||
metadata
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
ErrorCode errorCode = ErrorCode.REQUEST_VALIDATION_FAILED;
|
||||
Map<String, String> metadata = requestMetadata(request);
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(
|
||||
errorCode,
|
||||
errors,
|
||||
metadata
|
||||
));
|
||||
}
|
||||
|
||||
private Map<String, String> requestMetadata(HttpServletRequest request) {
|
||||
Object requestId = request.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE);
|
||||
if (!(requestId instanceof String value) || value.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
return Map.of("requestId", value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- business exception, validation exception을 한곳에서 다룬다
|
||||
- 응답 포맷이 ApiResult로 일관된다
|
||||
- advice는 예외를 ErrorCode로 매핑하는 책임만 가진다
|
||||
|
||||
### 예시 6. 성공 응답 공통 래핑은 ResponseBodyAdvice에서 처리한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class<?> converterType) {
|
||||
Class<?> parameterType = returnType.getParameterType();
|
||||
|
||||
return !ApiResult.class.isAssignableFrom(parameterType)
|
||||
&& !ResponseEntity.class.isAssignableFrom(parameterType)
|
||||
&& !Resource.class.isAssignableFrom(parameterType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response
|
||||
) {
|
||||
if (body == null) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
|
||||
if (body instanceof ApiResult<?>) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return ApiResult.success(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 성공 응답 공통화 위치가 명확하다
|
||||
- controller가 반복해서 ApiResult.success(...)를 만들지 않아도 된다
|
||||
- 이미 래핑된 응답은 다시 감싸지 않는다
|
||||
|
||||
### 예시 7. controller는 정상 흐름만 표현한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/sessions")
|
||||
public class SessionQueryController {
|
||||
|
||||
private final SessionQueryUseCase sessionQueryUseCase;
|
||||
|
||||
@GetMapping("/{sessionId}")
|
||||
public ApiResult<SessionResponse> getSession(@PathVariable String sessionId) {
|
||||
SessionResponse response = sessionQueryUseCase.getSession(sessionId);
|
||||
return ApiResult.success(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller가 예외 정책까지 떠안지 않는다
|
||||
- 정상 흐름과 예외 흐름이 분리된다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. filter에서 business/service를 직접 호출한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class BadAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final LoginPolicyService loginPolicyService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
String userId = request.getHeader("X-User-Id");
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
loginPolicyService.validate(user);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- filter에 repository/business validation이 들어갔다
|
||||
- HTTP concern과 business concern이 섞였다
|
||||
|
||||
### 예시 2. interceptor를 보안의 주 레이어로 사용한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadAuthorizationInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler
|
||||
) throws Exception {
|
||||
if (request.getHeader("Authorization") == null) {
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 인증/인가의 중심을 interceptor에 두고 있다
|
||||
- security/filter chain과 역할이 충돌한다
|
||||
|
||||
### 예시 3. resolver를 business exception 처리의 기본 수단으로 사용한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadBusinessExceptionResolver implements HandlerExceptionResolver {
|
||||
|
||||
@Override
|
||||
public ModelAndView resolveException(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex
|
||||
) throws IOException {
|
||||
if (ex instanceof DomainRuleViolationException) {
|
||||
response.setStatus(HttpStatus.CONFLICT.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write("""
|
||||
{
|
||||
"success": false,
|
||||
"code": "DOMAIN_RULE_VIOLATION",
|
||||
"message": "Domain rule violation"
|
||||
}
|
||||
""");
|
||||
return new ModelAndView();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- business exception 처리의 중심이 resolver로 내려갔다
|
||||
- advice보다 의도가 덜 드러난다
|
||||
- 응답 정책이 저수준 구현으로 흩어진다
|
||||
|
||||
### 예시 4. advice에서 에러 코드 문자열을 직접 하드코딩한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class BadApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DomainRuleViolationException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDomainRuleViolation(
|
||||
DomainRuleViolationException ex
|
||||
) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResult.fail(
|
||||
"DOMAIN_RULE_VIOLATION",
|
||||
ex.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 에러 코드 문자열이 advice에 박혀 있다
|
||||
- 메시지 정책과 예외 메시지가 섞인다
|
||||
- 코드/메시지/상태값 정책이 중앙화되지 않는다
|
||||
|
||||
### 예시 5. controller가 예외를 직접 잡아 ApiResult를 만든다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final UserRegisterUseCase userRegisterUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<Void>> register(@RequestBody RegisterUserRequest request) {
|
||||
try {
|
||||
userRegisterUseCase.register(request.email(), request.password());
|
||||
return ResponseEntity.ok(ApiResult.success(null));
|
||||
} catch (DuplicateEmailException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResult.fail(ErrorCode.DOMAIN_RULE_VIOLATION));
|
||||
} catch (Exception ex) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResult.fail(ErrorCode.EXTERNAL_DEPENDENCY_FAILURE));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller마다 예외 처리 로직이 중복된다
|
||||
- 전역 예외 처리 규약이 깨진다
|
||||
|
||||
### 예시 6. ResponseBodyAdvice에서 무조건 이중 래핑한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class BadApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class<?> converterType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response
|
||||
) {
|
||||
return ApiResult.success(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 이미 ApiResult인 응답도 다시 감싼다
|
||||
- file response, streaming response 같은 예외 케이스를 고려하지 않았다
|
||||
@@ -0,0 +1,171 @@
|
||||
# @Transactional 위치 예시
|
||||
|
||||
## 좋은 예시 1: use case 경계에 transaction
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class RegisterUserService implements RegisterUserUseCase {
|
||||
|
||||
private final UserReader userReader;
|
||||
private final UserAppender userAppender;
|
||||
private final PasswordHasher passwordHasher;
|
||||
|
||||
public RegisterUserService(
|
||||
UserReader userReader,
|
||||
UserAppender userAppender,
|
||||
PasswordHasher passwordHasher
|
||||
) {
|
||||
this.userReader = userReader;
|
||||
this.userAppender = userAppender;
|
||||
this.passwordHasher = passwordHasher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserId register(CreateUserCommand command) {
|
||||
if (userReader.findByEmail(UserEmail.from(command.email())).isPresent()) {
|
||||
throw new DuplicateUserException();
|
||||
}
|
||||
|
||||
User user = User.create(
|
||||
UserEmail.from(command.email()),
|
||||
UserName.from(command.name()),
|
||||
passwordHasher.hash(command.password())
|
||||
);
|
||||
|
||||
return userAppender.append(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 비즈니스 작업 단위가 transaction 경계와 일치한다
|
||||
- repository 호출들이 하나의 원자적 작업으로 묶인다
|
||||
- controller나 repository에 흩어지지 않는다
|
||||
|
||||
## 좋은 예시 2: 조회 use case는 readOnly
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class GetUserProfileService implements GetUserProfileUseCase {
|
||||
|
||||
private final UserReader userReader;
|
||||
|
||||
public GetUserProfileService(UserReader userReader) {
|
||||
this.userReader = userReader;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserProfileResult get(UserId userId) {
|
||||
User user = userReader.findById(userId).orElseThrow(UserNotFoundException::new);
|
||||
return UserProfileResult.from(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 순수 조회라는 의도가 드러난다
|
||||
- 쓰기 작업과 구분된다
|
||||
|
||||
## 좋은 예시 3: 별도 확정 단위가 필요한 경우만 REQUIRES_NEW
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class AuditLogService {
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void record(LoginAuditCommand command) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**왜 좋은가:**
|
||||
|
||||
- 본 작업과 독립된 commit 단위를 의도적으로 분리한다
|
||||
- 예외적 사용이라는 점이 분명하다
|
||||
|
||||
## 나쁜 예시 1: controller에 transaction
|
||||
|
||||
```java
|
||||
@RestController
|
||||
public class UserController {
|
||||
|
||||
@Transactional
|
||||
@PostMapping("/users")
|
||||
public UserResponse create(@RequestBody CreateUserRequest request) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- HTTP 경계와 transaction 경계가 섞인다
|
||||
- web layer가 persistence 세부를 과도하게 끌어안는다
|
||||
|
||||
## 나쁜 예시 2: repository마다 습관적 transaction
|
||||
|
||||
```java
|
||||
@Repository
|
||||
public class JpaUserRepository {
|
||||
|
||||
@Transactional
|
||||
public UserJpaEntity save(UserJpaEntity entity) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 상위 use case 경계가 아니라 하위 collaborator에 transaction이 흩어진다
|
||||
- 작업 단위가 잘게 찢어진다
|
||||
|
||||
## 나쁜 예시 3: self-invocation 기대
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
public void doWork() {
|
||||
this.saveAudit(); // transactional 기대
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void saveAudit() {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- proxy mode에서는 self-invocation이 interception 되지 않는다
|
||||
- 기대한 transaction이 실제로 열리지 않을 수 있다
|
||||
|
||||
**개선:**
|
||||
|
||||
- 클래스를 분리하거나 public entry boundary를 다시 설계
|
||||
|
||||
## 나쁜 예시 4: 긴 외부 API 호출을 transaction 안에 유지
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void completeLogin(LoginCommand command) {
|
||||
userRepository.save(...);
|
||||
externalOAuthClient.fetchProfile(...); // 긴 네트워크 호출
|
||||
tokenRepository.save(...);
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- DB 자원/잠금을 오래 붙잡을 수 있다
|
||||
- 실패 반경과 지연 시간이 커진다
|
||||
|
||||
**개선 방향:**
|
||||
|
||||
- 외부 호출과 DB transaction 경계를 재설계
|
||||
- 후속 작업/event/outbox 구조 검토
|
||||
@@ -0,0 +1,311 @@
|
||||
# Validation Location 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. request DTO 구조 검증은 presentation에서 처리한다
|
||||
|
||||
```java
|
||||
public record CreateSessionRequest(
|
||||
@NotBlank String email,
|
||||
@NotBlank String password,
|
||||
@NotNull LoginType loginType
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(@Valid @RequestBody CreateSessionRequest request) {
|
||||
CreateSessionResponse response = createSessionUseCase.create(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.loginType()
|
||||
);
|
||||
return ApiResult.success(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- request shape 검증이 web boundary에 있다
|
||||
- controller는 transport DTO를 domain object와 분리한다
|
||||
- business rule 판단은 use case로 넘긴다
|
||||
|
||||
### 예시 2. path variable / request param 제약은 메서드 파라미터에 직접 둔다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@PathVariable @NotBlank String userId,
|
||||
@RequestParam(defaultValue = "1") @Min(1) int page
|
||||
) {
|
||||
UserResponse response = userQueryUseCase.getUser(userId, page);
|
||||
return ApiResult.success(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- scalar input 제약이 controller boundary에 명확히 드러난다
|
||||
- request DTO가 필요 없는 단순 입력을 과하게 감싸지 않는다
|
||||
|
||||
### 예시 3. application은 조회가 필요한 정책 검증을 담당한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CreateSessionUseCase {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public CreateSessionResponse create(CreateSessionCommand command) {
|
||||
if (!userRepository.existsActiveUserByEmail(command.email())) {
|
||||
throw new UserNotFoundException(command.email());
|
||||
}
|
||||
|
||||
if (command.loginType() == LoginType.PASSWORDLESS
|
||||
&& command.credential() instanceof PasswordCredential) {
|
||||
throw new InvalidLoginRequestException();
|
||||
}
|
||||
|
||||
// 실제 세션 생성
|
||||
return new CreateSessionResponse(...);
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateSessionCommand(
|
||||
String email,
|
||||
LoginCredential credential,
|
||||
LoginType loginType
|
||||
) {
|
||||
public CreateSessionCommand {
|
||||
Objects.requireNonNull(email, "email must not be null");
|
||||
Objects.requireNonNull(credential, "credential must not be null");
|
||||
Objects.requireNonNull(loginType, "loginType must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed interface LoginCredential permits PasswordCredential, PasswordlessCredential {
|
||||
}
|
||||
|
||||
public record PasswordCredential(String value) implements LoginCredential {
|
||||
public PasswordCredential {
|
||||
Objects.requireNonNull(value, "value must not be null");
|
||||
if (value.isBlank()) {
|
||||
throw new InvalidLoginRequestException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record PasswordlessCredential() implements LoginCredential {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- DB 조회가 필요한 규칙을 controller validation에 두지 않았다
|
||||
- use case 전제조건 검증이 application에 있다
|
||||
- nullable password를 application 내부로 전파하지 않고 명시적 credential 타입으로 표현한다
|
||||
|
||||
### 예시 4. domain은 자기 불변식을 스스로 보장한다
|
||||
|
||||
```java
|
||||
public final class Email {
|
||||
|
||||
private final String value;
|
||||
|
||||
private Email(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new DomainRuleViolationException("Email must not be blank");
|
||||
}
|
||||
if (!value.contains("@")) {
|
||||
throw new DomainRuleViolationException("Email format is invalid");
|
||||
}
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public static Email of(String value) {
|
||||
return new Email(value);
|
||||
}
|
||||
|
||||
public String value() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- domain invariant를 controller에 의존하지 않는다
|
||||
- 어디서 생성되더라도 유효한 상태만 허용한다
|
||||
|
||||
### 예시 5. web 전용 복잡한 입력 검증은 @InitBinder + custom Validator로 제한적으로 둔다
|
||||
|
||||
```java
|
||||
public class ChangePasswordRequestValidator implements Validator {
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> clazz) {
|
||||
return ChangePasswordRequest.class.equals(clazz);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Object target, Errors errors) {
|
||||
ChangePasswordRequest request = (ChangePasswordRequest) target;
|
||||
|
||||
if (request.newPassword() != null
|
||||
&& request.newPasswordConfirm() != null
|
||||
&& !request.newPassword().equals(request.newPasswordConfirm())) {
|
||||
errors.rejectValue("newPasswordConfirm", "password.confirm.mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/password")
|
||||
public class PasswordController {
|
||||
|
||||
@InitBinder("changePasswordRequest")
|
||||
void initBinder(WebDataBinder binder) {
|
||||
binder.addValidators(new ChangePasswordRequestValidator());
|
||||
}
|
||||
|
||||
@PostMapping("/change")
|
||||
public ApiResult<Void> changePassword(
|
||||
@Valid @RequestBody ChangePasswordRequest changePasswordRequest
|
||||
) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- request-object 내부의 web 입력 규칙만 binder validator에 둔다
|
||||
- business rule 전체를 validator에 몰아넣지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. entity를 request binding 대상으로 직접 노출한다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String role;
|
||||
}
|
||||
|
||||
@PostMapping("/users")
|
||||
public ApiResult<Void> create(@Valid @RequestBody User user) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- web input model과 domain/persistence model이 섞였다
|
||||
- 바인딩 범위가 불필요하게 넓다
|
||||
- request schema 변경이 domain/persistence 모델에 직접 번진다
|
||||
|
||||
### 예시 2. controller validation만 믿고 domain에서 아무 것도 보장하지 않는다
|
||||
|
||||
```java
|
||||
public final class Email {
|
||||
|
||||
private final String value;
|
||||
|
||||
public Email(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 다른 진입 경로에서 잘못된 값이 들어오면 막지 못한다
|
||||
- domain이 자기 불변식을 보장하지 못한다
|
||||
|
||||
### 예시 3. controller 클래스에 @Validated를 붙여 구식 proxy 방식에 기대한다
|
||||
|
||||
```java
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> get(@PathVariable @NotBlank String userId) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring MVC 6.1+ built-in method validation 대신 class-level AOP proxy 경로로 흐를 수 있다
|
||||
- 이 프로젝트의 controller 규칙과 맞지 않는다
|
||||
|
||||
### 예시 4. filter / interceptor에서 business validation을 수행한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class BadLoginValidationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final LoginPolicyService loginPolicyService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
loginPolicyService.validateLoginWindow();
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- business validation이 web infrastructure 훅으로 새어 나갔다
|
||||
- 요청 바운더리 검증과 use case 규칙이 섞였다
|
||||
|
||||
### 예시 5. service method validation만 믿고 복잡한 정책을 숨긴다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@Validated
|
||||
public class BadCreateSessionService {
|
||||
|
||||
public void create(
|
||||
@NotBlank String email,
|
||||
@NotBlank String password,
|
||||
@NotNull LoginType loginType
|
||||
) {
|
||||
// 복잡한 도메인 정책을 전부 메서드 시그니처 제약에 기대함
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- method validation은 보조 수단이지 핵심 정책 엔진이 아니다
|
||||
- proxy 기반 동작 특성 때문에 경계가 흐려질 수 있다
|
||||
- business rule이 시그니처 제약 뒤에 숨어 버린다
|
||||
@@ -0,0 +1,270 @@
|
||||
# Fixture / Factory 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. factory는 새 유효 객체를 매번 반환한다
|
||||
|
||||
```java
|
||||
public final class UserFactory {
|
||||
|
||||
private UserFactory() {
|
||||
}
|
||||
|
||||
public static User user() {
|
||||
return new User(
|
||||
"user-" + UUID.randomUUID() + "@test.com",
|
||||
"ACTIVE"
|
||||
);
|
||||
}
|
||||
|
||||
public static User user(UnaryOperator<UserBuilder> customizer) {
|
||||
UserBuilder builder = UserBuilder.defaultUser();
|
||||
return customizer.apply(builder).build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 매 호출마다 새 객체를 만든다
|
||||
- 기본값은 유효한 상태다
|
||||
- 테스트는 필요한 값만 override할 수 있다
|
||||
|
||||
JUnit은 기본적으로 테스트 메서드마다 새 테스트 인스턴스를 만들어 격리를 보장하려고 하므로, 테스트 데이터 helper도 같은 방향으로 fresh object를 주는 것이 자연스럽다.
|
||||
|
||||
### 예시 2. fixture 이름이 시나리오를 설명한다
|
||||
|
||||
```java
|
||||
public final class UserFixture {
|
||||
|
||||
private UserFixture() {
|
||||
}
|
||||
|
||||
public static User activeUser() {
|
||||
return UserFactory.user();
|
||||
}
|
||||
|
||||
public static User deletedUser() {
|
||||
return UserFactory.user(builder -> builder.deletedAt(OffsetDateTime.now()));
|
||||
}
|
||||
|
||||
public static User invalidEmailUser() {
|
||||
return UserFactory.user(builder -> builder.email("not-an-email"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- fixture 이름만 봐도 상태 의미가 드러난다
|
||||
- invalid 상태도 명시적으로 분리된다
|
||||
- factory와 fixture의 역할이 나뉜다
|
||||
|
||||
이런 분리는 공식 어노테이션이 강제하는 것은 아니지만, Spring 테스트 지원이 fixture 준비를 쉽게 해 주는 목적과 잘 맞는 실무 패턴이다.
|
||||
|
||||
### 예시 3. repository test에서는 persisted fixture를 분리한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PersistedUserFactory {
|
||||
|
||||
private final EntityManager em;
|
||||
|
||||
public User persistedUser() {
|
||||
User user = UserFactory.user();
|
||||
em.persist(user);
|
||||
em.flush();
|
||||
return user;
|
||||
}
|
||||
|
||||
public User persistedUserAndClear() {
|
||||
User user = UserFactory.user();
|
||||
em.persist(user);
|
||||
em.flush();
|
||||
em.clear();
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- DB에 반영된 fixture와 메모리 상태 fixture를 구분한다
|
||||
- repository test에서 영속성 상태를 더 명확히 다룰 수 있다
|
||||
- 표준 `EntityManager`만 사용하므로 `@DataJpaTest`와 `@SpringBootTest` 양쪽 컨텍스트에서 동일하게 동작한다
|
||||
|
||||
Spring Boot는 `@DataJpaTest` 슬라이스에서 `TestEntityManager`를 `persist`/`flush`/`find` 같은 common testing task를 위한 대안 `EntityManager`로 제공한다고 설명한다. 다만 `TestEntityManager`는 `@DataJpaTest` 컨텍스트에서만 자동 구성되므로, `@SpringBootTest`에서도 재사용할 helper에는 표준 `EntityManager`를 주입하는 것이 안전하다.
|
||||
|
||||
### 예시 4. 핵심 차이는 테스트 본문에 남긴다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail_returnsMatchingUser() {
|
||||
User saved = userRepository.save(
|
||||
UserFactory.user(builder -> builder.email("target@test.com"))
|
||||
);
|
||||
|
||||
Optional<User> result = userRepository.findByEmail("target@test.com");
|
||||
|
||||
assertThat(result).contains(saved);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 반복 필드는 factory가 채우지만, 핵심 조건인 email은 테스트 본문에 드러난다
|
||||
- 테스트를 읽는 사람이 왜 이 테스트가 중요한지 바로 이해할 수 있다
|
||||
- fixture/factory가 assertion의 핵심을 숨기지 않는다
|
||||
|
||||
Spring 테스트 문서는 DI와 테스트 지원이 테스트를 더 쉽게 만들 수 있다고 설명하지만, 그 목적은 테스트 의미를 감추는 것이 아니다.
|
||||
|
||||
### 예시 5. Spring context 테스트에서도 fixture helper는 test support로 분리한다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class UserCommandServiceTest {
|
||||
|
||||
@Autowired
|
||||
private UserCommandService userCommandService;
|
||||
|
||||
@Autowired
|
||||
private PersistedUserFactory persistedUserFactory;
|
||||
|
||||
@Test
|
||||
void deactivateUser_marksUserInactive() {
|
||||
User user = persistedUserFactory.persistedUser();
|
||||
|
||||
userCommandService.deactivate(user.getId());
|
||||
|
||||
// assertion ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- fixture 준비가 재사용 가능하다
|
||||
- 그래도 테스트의 핵심 동작은 서비스 호출과 assertion에 남아 있다
|
||||
- Spring DI는 helper 주입에만 쓰고, fixture 자체를 production 로직처럼 다루지 않는다
|
||||
|
||||
Spring Framework는 테스트 인스턴스에 field, setter, constructor injection을 지원한다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. mutable shared fixture를 static으로 재사용한다
|
||||
|
||||
```java
|
||||
public final class SharedFixtures {
|
||||
public static final User USER = new User("a@test.com", "ACTIVE");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 한 테스트의 변경이 다른 테스트에 영향을 줄 수 있다
|
||||
- JUnit의 기본 per-method 격리 철학과 맞지 않는다
|
||||
- 테스트 순서 의존과 flaky test를 만들기 쉽다
|
||||
|
||||
JUnit은 기본 lifecycle이 테스트 간 mutable state 부작용을 피하기 위한 `PER_METHOD`라고 설명한다.
|
||||
|
||||
### 예시 2. boolean 나열형 factory로 의미를 숨긴다
|
||||
|
||||
```java
|
||||
public static User user(boolean deleted, boolean admin, boolean locked, boolean invalidEmail) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 호출부에서 각 boolean이 무엇을 뜻하는지 바로 알기 어렵다
|
||||
- 상태 의미가 시나리오 이름으로 드러나지 않는다
|
||||
- 잘못된 조합도 쉽게 생긴다
|
||||
|
||||
이런 형태는 fixture/factory가 테스트 가독성을 높여야 한다는 목적에 어긋난다. 프로젝트에서는 명시적 이름의 fixture나 builder override를 선호한다.
|
||||
|
||||
### 예시 3. invalid 상태를 기본 factory에 섞는다
|
||||
|
||||
```java
|
||||
public static User user() {
|
||||
return new User(null, "ACTIVE");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기본 factory가 유효하지 않은 객체를 만든다
|
||||
- 여러 테스트가 뜻하지 않게 invalid 상태를 끌고 들어온다
|
||||
- invalid 검증 테스트와 정상 경로 테스트가 섞인다
|
||||
|
||||
factory 기본값은 특별한 이유가 없으면 유효한 객체여야 테스트 의도가 분명해진다. 이는 프로젝트 fixture/factory 기본 규칙이다.
|
||||
|
||||
### 예시 4. repository test에서 DB round-trip 의미를 helper가 완전히 숨긴다
|
||||
|
||||
```java
|
||||
public User persistedUser() {
|
||||
User user = UserFactory.user();
|
||||
em.persist(user);
|
||||
em.flush();
|
||||
em.clear();
|
||||
return em.find(User.class, user.getId());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 언제 `flush`/`clear`가 일어나는지 테스트 본문에서 보이지 않는다
|
||||
- 어떤 테스트는 `flush`까지만 필요하고, 어떤 테스트는 `clear`가 핵심인데 모두 같은 helper 뒤에 숨는다
|
||||
- repository semantics를 읽기 어렵게 만든다
|
||||
|
||||
`TestEntityManager`는 helper를 제공하지만, 그 목적은 테스트를 보조하는 것이지 중요한 JPA 의미를 완전히 숨기는 것이 아니다.
|
||||
|
||||
### 예시 5. fixture/factory를 production source에 넣는다
|
||||
|
||||
```java
|
||||
src/main/java/com/example/user/UserFixture.java
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 지원 코드가 production code와 경계를 잃는다
|
||||
- 실제 애플리케이션 책임과 테스트 전용 책임이 섞인다
|
||||
- 유지보수 시 production API처럼 오해되기 쉽다
|
||||
|
||||
Spring 테스트 문서는 테스트 인스턴스에 대한 DI를 지원하지만, 테스트 준비 코드를 production source에 두라고 요구하지는 않는다. 프로젝트에서는 test support를 test source에 두는 것이 기본이다.
|
||||
|
||||
### 예시 6. PER_CLASS lifecycle에 기대어 상태를 공유한다
|
||||
|
||||
```java
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class UserRepositoryTest {
|
||||
|
||||
private final List<User> users = new ArrayList<>();
|
||||
|
||||
@Test
|
||||
void test1() {
|
||||
users.add(UserFactory.user());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test2() {
|
||||
assertThat(users).hasSize(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 간 상태가 공유된다
|
||||
- 순서와 실행 방식에 따라 쉽게 깨질 수 있다
|
||||
- fixture 편의 때문에 lifecycle을 바꾼 나쁜 예다
|
||||
|
||||
JUnit은 `PER_CLASS`를 쓰면 instance state를 직접 reset해야 할 수 있고, 기본 lifecycle을 일관되지 않게 바꾸면 fragile build가 될 수 있다고 경고한다.
|
||||
@@ -0,0 +1,316 @@
|
||||
# Mock 사용 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 순수 단위 테스트에서는 MockitoExtension과 @Mock를 사용한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserNotifierTest {
|
||||
|
||||
@Mock
|
||||
private MailSender mailSender;
|
||||
|
||||
private UserNotifier userNotifier;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userNotifier = new UserNotifier(mailSender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendWelcomeMail_delegatesToMailSender() {
|
||||
userNotifier.sendWelcomeMail("a@test.com");
|
||||
|
||||
verify(mailSender).send("a@test.com");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring 컨텍스트 없이 외부 협력자만 대체한다
|
||||
- 테스트 대상 생성이 명시적이라 의존성이 잘 드러난다
|
||||
- interaction verification도 외부 경계에만 한정된다
|
||||
|
||||
Mockito는 `MockitoExtension`이 JUnit Jupiter용 확장이고 strict stubbings를 처리한다고 설명한다. `@InjectMocks`는 편의 기능이지만, 프로젝트 기본값은 명시적 생성자 조립을 우선한다.
|
||||
|
||||
### 예시 2. @InjectMocks는 보조 편의 수단으로 제한적으로 사용한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void findUser_returnsRepositoryResult() {
|
||||
User user = new User(1L, "a@test.com");
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
|
||||
Optional<User> result = userService.findUser(1L);
|
||||
|
||||
assertThat(result).contains(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대상이 단순하고 의존성도 적을 때 보일러플레이트를 줄일 수 있다
|
||||
- `@InjectMocks`를 “자동 wiring 마법”이 아니라 편의 기능으로만 사용한다
|
||||
- 테스트의 핵심 stub과 assertion은 여전히 본문에 남아 있다
|
||||
|
||||
Mockito는 `@InjectMocks`가 constructor/property/setter injection 순서로 mock 주입을 시도한다고 설명한다.
|
||||
|
||||
### 예시 3. Spring 컨텍스트 테스트에서 bean 하나만 대체할 때는 @MockitoBean을 사용한다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class PaymentFacadeTest {
|
||||
|
||||
@MockitoBean
|
||||
private PaymentGatewayClient paymentGatewayClient;
|
||||
|
||||
@Autowired
|
||||
private PaymentFacade paymentFacade;
|
||||
|
||||
@Test
|
||||
void approve_usesGatewayClient() {
|
||||
when(paymentGatewayClient.approve(any())).thenReturn(new GatewayResult(true));
|
||||
|
||||
boolean result = paymentFacade.approve(1L);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- full context가 필요한 테스트에서 특정 bean만 override한다
|
||||
- 신규 기준에 맞는 `@MockitoBean`을 사용한다
|
||||
- 외부 연동 경계만 mock으로 대체한다
|
||||
|
||||
Spring Framework는 `@MockitoBean`이 테스트 `ApplicationContext`의 bean을 Mockito mock으로 override한다고 설명하고, Spring Boot도 이를 공식 테스트 기능으로 안내한다.
|
||||
|
||||
### 예시 4. spy가 꼭 필요하면 doReturn(...).when(spy)...를 사용한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LegacyUserServiceTest {
|
||||
|
||||
@Test
|
||||
void spy_stubsWithoutCallingRealMethod() {
|
||||
LegacyUserService spy = spy(new LegacyUserService());
|
||||
|
||||
doReturn("stubbed").when(spy).loadExternalValue();
|
||||
|
||||
String result = spy.read();
|
||||
|
||||
assertThat(result).isEqualTo("stubbed");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- spy가 필요한 예외 상황에서도 실제 메서드 부작용을 피한다
|
||||
- Mockito가 권장하는 spy stubbing 방식과 맞다
|
||||
- partial mock을 최소 범위로 제한한다
|
||||
|
||||
Mockito는 spy를 carefully and occasionally 사용하라고 설명하고, spy stubbing에는 `doReturn` 계열을 고려하라고 설명한다.
|
||||
|
||||
### 예시 5. verifyNoMoreInteractions()는 정말 의미가 있을 때만 쓴다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuditPublisherTest {
|
||||
|
||||
@Mock
|
||||
private EventBus eventBus;
|
||||
|
||||
@Test
|
||||
void publishExactlyOneAuditEvent() {
|
||||
AuditPublisher publisher = new AuditPublisher(eventBus);
|
||||
|
||||
publisher.publish("LOGIN");
|
||||
|
||||
verify(eventBus).publish("LOGIN");
|
||||
verifyNoMoreInteractions(eventBus);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “정확히 한 번만 발행되어야 한다”는 의미가 테스트 요구와 직접 연결된다
|
||||
- 습관적 사용이 아니라 비즈니스 의미가 있을 때만 사용한다
|
||||
- interaction assertion이 과도하지 않다
|
||||
|
||||
Mockito는 `verifyNoMoreInteractions()`를 every test method에 사용하는 것을 권장하지 않지만, interaction testing toolkit의 일부로는 유용하다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 테스트 대상 자체를 mock한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void findUser() {
|
||||
when(userService.findUser(1L)).thenReturn(Optional.of(new User(1L, "a@test.com")));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 검증하려는 대상을 아예 가짜로 바꿔 버린다
|
||||
- 테스트가 대상 로직을 전혀 실행하지 않는다
|
||||
- mock은 협력자 경계에만 써야 한다
|
||||
|
||||
Mockito mock은 협력 객체를 대체하는 도구이지, 테스트 대상을 없애는 도구가 아니다. Spring의 `@MockitoBean`도 마찬가지로 bean override 용도다.
|
||||
|
||||
### 예시 2. repository test에서 repository를 mock한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail() {
|
||||
when(userRepository.findByEmail("a@test.com"))
|
||||
.thenReturn(Optional.of(new User(1L, "a@test.com")));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- repository 경계의 실제 DB 의미, query, 매핑을 전혀 검증하지 않는다
|
||||
- 이런 테스트는 repository test가 아니라 service 단위 테스트의 협력자 stub에 가깝다
|
||||
- repository 자체 검증은 실제 repository/DB로 해야 한다
|
||||
|
||||
repository test의 목적은 영속성 경계 검증이므로 mock repository는 목적과 맞지 않는다.
|
||||
|
||||
### 예시 3. 모든 테스트에 verifyNoMoreInteractions()를 습관적으로 붙인다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserNotifierTest {
|
||||
|
||||
@Mock
|
||||
private MailSender mailSender;
|
||||
|
||||
@Test
|
||||
void sendWelcomeMail() {
|
||||
UserNotifier notifier = new UserNotifier(mailSender);
|
||||
|
||||
notifier.sendWelcomeMail("a@test.com");
|
||||
|
||||
verify(mailSender).send("a@test.com");
|
||||
verifyNoMoreInteractions(mailSender);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 추가 상호작용 금지가 이 테스트의 핵심 의미가 아닐 수도 있다
|
||||
- 테스트가 불필요하게 취약해진다
|
||||
- Mockito도 이 메서드를 every test method에 쓰는 것은 권장하지 않는다
|
||||
|
||||
Mockito는 `verifyNoMoreInteractions()`를 모든 테스트마다 쓰는 것을 권장하지 않는다고 설명한다.
|
||||
|
||||
### 예시 4. Spring 신규 테스트에서 @MockBean을 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class PaymentFacadeTest {
|
||||
|
||||
@MockBean
|
||||
private PaymentGatewayClient paymentGatewayClient;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 현재 Spring 기준에서는 신규 코드 기본값이 아니다
|
||||
- Spring Boot는 `@MockBean`이 3.4.0부터 deprecated 되었고 `@MockitoBean`을 쓰라고 안내한다
|
||||
- 장기적으로 제거 예정 API에 새 테스트를 얹는 셈이다
|
||||
|
||||
Spring Boot API 문서는 `@MockBean`이 3.4.0부터 4.0.0 제거 예정으로 deprecated 되었고 `MockitoBean`을 대안으로 제시한다.
|
||||
|
||||
### 예시 5. spy에서 when(spy.method())로 실제 메서드를 먼저 호출한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LegacyUserServiceTest {
|
||||
|
||||
@Test
|
||||
void badSpyUsage() {
|
||||
LegacyUserService spy = spy(new LegacyUserService());
|
||||
|
||||
when(spy.loadExternalValue()).thenReturn("stubbed");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- stub 과정에서 실제 메서드가 호출될 수 있다
|
||||
- 부작용이나 예외를 일으킬 수 있다
|
||||
- spy에서는 `doReturn(...).when(spy)...`가 더 안전하다
|
||||
|
||||
Mockito는 spy stubbing에서 `when(...)`가 부적절할 수 있고, `doReturn` 계열을 고려하라고 설명한다.
|
||||
|
||||
### 예시 6. static mock을 길게 열어 두고 일반 테스트처럼 사용한다
|
||||
|
||||
```java
|
||||
@Test
|
||||
void badStaticMockUsage() {
|
||||
MockedStatic<ClockUtil> mocked = mockStatic(ClockUtil.class);
|
||||
mocked.when(ClockUtil::now).thenReturn(Instant.parse("2026-01-01T00:00:00Z"));
|
||||
|
||||
// 여러 로직 수행
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- scope가 길고 close가 명확하지 않다
|
||||
- static mock은 생성된 thread에만 영향을 주고 동시 사용에도 안전하지 않다
|
||||
- try-with-resources로 짧게 감싸는 편이 맞다
|
||||
|
||||
Mockito는 `MockedStatic`이 생성된 thread에만 영향을 주며 concurrent use에 안전하지 않다고 설명한다.
|
||||
|
||||
### 예시 7. non-singleton bean을 무심코 @MockitoBean으로 바꾼다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class ScopedBeanTest {
|
||||
|
||||
@MockitoBean
|
||||
private RequestScopedClient requestScopedClient;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring은 non-singleton bean을 mock/spy하면 singleton처럼 취급될 수 있다고 설명한다
|
||||
- scope 의미가 깨질 수 있다
|
||||
- 이런 경우는 테스트 구조 자체를 다시 설계하는 편이 더 안전하다
|
||||
|
||||
Spring Framework는 non-singleton bean을 `@MockitoBean`/`@MockitoSpyBean`으로 override하면 singleton처럼 다뤄질 수 있다고 설명한다.
|
||||
@@ -0,0 +1,297 @@
|
||||
# Repository Test 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 기본 repository test는 @DataJpaTest로 시작한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail_returnsUser() {
|
||||
// given
|
||||
User user = new User("a@test.com", "active");
|
||||
userRepository.save(user);
|
||||
|
||||
// when
|
||||
Optional<User> result = userRepository.findByEmail("a@test.com");
|
||||
|
||||
// then
|
||||
assertThat(result).isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- JPA slice만 좁게 로딩한다
|
||||
- repository 자체를 테스트 대상으로 유지한다
|
||||
- full application context를 불필요하게 띄우지 않는다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 JPA components에 초점을 맞추고, 엔티티와 repository를 스캔한다고 설명한다.
|
||||
|
||||
### 예시 2. 제약 위반은 flush()까지 가서 검증한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryConstraintTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void save_duplicateEmail_throwsExceptionOnFlush() {
|
||||
userRepository.save(new User("dup@test.com", "active"));
|
||||
userRepository.save(new User("dup@test.com", "active"));
|
||||
|
||||
assertThatThrownBy(() -> userRepository.flush())
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- unique constraint 위반이 실제 DB 동기화 시점에 드러난다는 점을 반영한다
|
||||
- `save()`만 보고 통과한 테스트를 방지한다
|
||||
- 영속성 경계의 실제 실패 시점을 검증한다
|
||||
|
||||
Hibernate의 영속성 컨텍스트는 1차 캐시로 동작하므로, DB 의미를 보려면 `flush`가 중요하다. `TestEntityManager`도 `persistAndFlush` 같은 helper를 제공한다.
|
||||
|
||||
### 예시 3. 실제 재조회 의미를 검증할 때는 flush() 후 clear()를 사용한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryReloadTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private TestEntityManager em;
|
||||
|
||||
@Test
|
||||
void findActiveUsers_excludesDeletedRows() {
|
||||
userRepository.save(new User("a@test.com", "active", null));
|
||||
userRepository.save(new User("b@test.com", "active", OffsetDateTime.now()));
|
||||
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<User> users = userRepository.findAllByDeletedAtIsNullOrderByIdDesc();
|
||||
|
||||
assertThat(users).extracting(User::getEmail)
|
||||
.containsExactly("a@test.com");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 1차 캐시가 아니라 실제 DB round-trip 이후 결과를 검증한다
|
||||
- soft delete predicate 같은 조회 semantics를 더 신뢰도 높게 확인한다
|
||||
- `TestEntityManager`를 보조 도구로만 사용한다
|
||||
|
||||
Hibernate는 persistence context가 transaction-scoped first-level cache로 동작한다고 설명하고, `TestEntityManager`는 test에서 `persist`/`flush`/`find` helper를 제공한다고 설명한다.
|
||||
|
||||
### 예시 4. PostgreSQL 특화 query는 실제 DB 계열에서 검증한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
class PostgresUserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findRecentlyCreatedUsers_worksWithPostgresSpecificQuery() {
|
||||
// given / when / then
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 임베디드 DB 대체를 끄고 실제 DB 계열을 사용하도록 의도를 드러낸다
|
||||
- PostgreSQL native query나 dialect 의존 쿼리를 더 신뢰도 높게 검증할 수 있다
|
||||
- repository test의 범위는 유지하면서 DB 의미를 맞춘다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 기본적으로 임베디드 DB를 구성할 수 있고, 실제 DB를 선호하면 `@AutoConfigureTestDatabase`로 제어할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. commit이 정말 필요한 경우에만 예외적으로 commit 테스트를 쓴다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
@Commit
|
||||
class UserRepositoryCommitTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void savesDataThatMustBeObservedAfterCommit() {
|
||||
userRepository.save(new User("commit@test.com", "active"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 기본 rollback 규칙을 알고, 필요한 경우에만 예외를 사용한다
|
||||
- commit 이후에만 보이는 DB 효과를 검증하는 목적이 분명하다
|
||||
- rollback이 기본, commit은 예외라는 기준을 지킨다
|
||||
|
||||
Spring 테스트는 transactional test를 기본 rollback하고, `@Commit`/`@Rollback`으로 이를 바꿀 수 있다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. repository만 보는데 @SpringBootTest를 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- repository 경계만 볼 테스트에 full application context를 띄운다
|
||||
- 테스트 범위와 비용이 과도하다
|
||||
- `@DataJpaTest`가 더 적합한 기본값이다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`를 JPA slice test로 제공하고, 일반 `@Component`는 로드하지 않는다고 설명한다.
|
||||
|
||||
### 예시 2. 제약 테스트를 flush 없이 작성한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryConstraintTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void save_duplicateEmail_throwsException() {
|
||||
userRepository.save(new User("dup@test.com", "active"));
|
||||
assertThatThrownBy(() ->
|
||||
userRepository.save(new User("dup@test.com", "active"))
|
||||
).isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 DB 제약 위반이 아직 드러나지 않을 수 있다
|
||||
- 영속성 컨텍스트 안에서 테스트가 가짜로 통과하거나 실패 시점이 늦어질 수 있다
|
||||
- 이런 검증은 보통 `flush` 시점까지 가야 한다
|
||||
|
||||
Hibernate의 persistence context는 1차 캐시로 동작하므로, DB 의미를 드러내려면 `flush`가 중요하다.
|
||||
|
||||
### 예시 3. 재조회 검증인데 clear() 없이 같은 엔티티만 본다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryReloadTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findById_readsUpdatedState() {
|
||||
User user = userRepository.save(new User("a@test.com", "active"));
|
||||
user.changeStatus("inactive");
|
||||
|
||||
User found = userRepository.findById(user.getId()).orElseThrow();
|
||||
|
||||
assertThat(found.getStatus()).isEqualTo("inactive");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 persistence context 안의 같은 엔티티 인스턴스를 다시 본 것일 수 있다
|
||||
- 실제 DB에서 다시 읽은 결과인지 보장되지 않는다
|
||||
- 조회 semantics 검증으로는 신뢰도가 낮다
|
||||
|
||||
Hibernate는 `Session`/`EntityManager`가 first-level cache를 유지한다고 설명한다.
|
||||
|
||||
### 예시 4. PostgreSQL 특화 native query를 임베디드 DB만으로 신뢰한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class NativePostgresQueryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void works() {
|
||||
userRepository.runPostgresSpecificQuery();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 DB가 운영 DB 의미를 충분히 재현하지 못할 수 있다
|
||||
- dialect 차이, 함수, JSONB, partial index 전제, locking clause 같은 부분은 놓치기 쉽다
|
||||
- DB 특화 query는 실제 DB 계열 검증이 더 적합하다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 임베디드 DB를 기본 구성할 수 있고, 실제 DB를 쓰려면 `@AutoConfigureTestDatabase`로 제어할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. repository test 안에서 service 정책까지 같이 검증한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 범위가 repository 경계를 벗어난다
|
||||
- slice 목적과 맞지 않는다
|
||||
- service 정책 검증은 상위 통합 테스트 책임이다
|
||||
|
||||
`@DataJpaTest`는 JPA components에만 초점을 맞추고 일반 컴포넌트를 로드하지 않는 slice다.
|
||||
|
||||
### 예시 6. repository test에서 repository 대신 EntityManager만 직접 사용한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager em;
|
||||
|
||||
@Test
|
||||
void test() {
|
||||
em.persist(new User("a@test.com", "active"));
|
||||
em.createQuery("select u from User u", User.class).getResultList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- repository를 검증하겠다면서 repository를 거의 사용하지 않는다
|
||||
- 결국 repository contract가 아니라 JPA API 자체를 테스트하게 된다
|
||||
- `EntityManager`는 보조 도구로만 쓰는 편이 적절하다
|
||||
|
||||
Spring Boot는 `TestEntityManager`를 repository/JPA test의 보조 도구로 제공한다고 설명한다. 중심은 repository여야 한다.
|
||||
@@ -0,0 +1,254 @@
|
||||
# @SpringBootTest 사용 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 애플리케이션 기동 smoke test는 NONE으로 충분하다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
class ApplicationContextSmokeTest {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 실제 서버가 필요 없는 full-context 기동 확인이다
|
||||
- Boot 방식으로 애플리케이션이 정상 조립되는지 확인한다
|
||||
- non-web full-context 테스트에 가장 좁은 환경을 선택했다
|
||||
|
||||
Spring Boot는 `@SpringBootTest`가 `SpringApplication`으로 컨텍스트를 만들고, `NONE`은 웹 환경 없이 `ApplicationContext`만 로드한다고 설명한다.
|
||||
|
||||
### 예시 2. 여러 레이어가 함께 필요한 서비스 통합 테스트는 NONE을 사용한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
class OrderCommandServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private OrderCommandService orderCommandService;
|
||||
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Test
|
||||
void confirmOrder_changesState() {
|
||||
// given
|
||||
|
||||
// when
|
||||
orderCommandService.confirm(1L);
|
||||
|
||||
// then
|
||||
assertThat(orderRepository.findById(1L)).isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 서비스, 리포지토리, 트랜잭션, 설정 조합까지 함께 검증한다
|
||||
- 실제 웹 서버는 필요 없으므로 `NONE`으로 범위를 제한했다
|
||||
- full context가 필요한 이유가 분명하다
|
||||
|
||||
`@SpringBootTest`는 Boot features가 필요한 full application context 테스트에 적합하고, `NONE`은 웹 환경을 만들지 않는다고 Boot 문서가 설명한다.
|
||||
|
||||
### 예시 3. 실제 서버는 필요 없지만 MVC/보안/직렬화까지 함께 보고 싶으면 MOCK + MockMvc를 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@AutoConfigureMockMvc
|
||||
class UserApiIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void getUser_returns200() throws Exception {
|
||||
mockMvc.perform(get("/users/1"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 전체 컨텍스트는 유지하면서 실제 내장 서버는 띄우지 않는다
|
||||
- MVC 설정, 보안 필터, Jackson, 예외 처리 등을 함께 볼 수 있다
|
||||
- mock 기반 웹 통합 테스트 목적과 잘 맞는다
|
||||
|
||||
Spring Boot는 `MOCK`이 내장 서버를 시작하지 않는 mock web environment이고, `@AutoConfigureMockMvc`와 함께 사용할 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. 실제 HTTP round-trip이 필요하면 RANDOM_PORT를 사용한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class UserHttpIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Test
|
||||
void getUser_returns200() {
|
||||
var response = restTemplate.getForEntity("/users/1", String.class);
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 실제 내장 서버와 실제 HTTP 경로를 검증한다
|
||||
- 고정 포트 충돌 없이 자동화 테스트에 적합하다
|
||||
- mock 환경으로는 검증하기 어려운 실제 서버 동작을 본다
|
||||
|
||||
Spring Boot는 `RANDOM_PORT`가 실제 `WebServerApplicationContext`를 만들고 임의 포트에 서버를 시작한다고 설명한다.
|
||||
|
||||
### 예시 5. full context가 필요하지만 테스트 편의 기능도 원하면 @AutoConfigure…를 조합한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@AutoConfigureMockMvc
|
||||
class UserAdminFlowTest {
|
||||
// full context + MockMvc 편의 빈 사용
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- slice로 자를 수는 없지만 테스트 편의 빈은 활용한다
|
||||
- `@SpringBootTest`와 `@AutoConfigure…`의 역할이 분명하다
|
||||
- Boot가 공식적으로 허용한 조합이다
|
||||
|
||||
Spring Boot는 `@AutoConfigure…` 계열을 `@SpringBootTest`와 함께 사용할 수 있다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 순수 단위 테스트에 @SpringBootTest를 붙인다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class MoneyCalculatorTest {
|
||||
|
||||
@Test
|
||||
void add() {
|
||||
MoneyCalculator calculator = new MoneyCalculator();
|
||||
assertThat(calculator.add(1, 2)).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring 컨테이너가 전혀 필요 없다
|
||||
- full context 로딩 비용만 추가한다
|
||||
- 이런 테스트는 순수 JUnit 단위 테스트가 맞다
|
||||
|
||||
Spring 문서는 IoC 덕분에 단위 테스트와 통합 테스트를 구분해서 설계할 수 있다고 설명하고, Boot는 `@SpringBootTest`를 Boot features가 필요할 때 쓰는 어노테이션으로 설명한다.
|
||||
|
||||
### 예시 2. repository만 검증하려고 @SpringBootTest를 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail() {
|
||||
assertThat(userRepository.findByEmail("a@test.com")).isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- JPA repository만 볼 테스트에 전체 애플리케이션을 띄운다
|
||||
- 데이터 접근 slice로 충분한 범위를 과도하게 넓힌다
|
||||
- 테스트 목적 대비 로딩 비용이 크다
|
||||
|
||||
Spring Boot는 `@DataJpaTest` 같은 데이터 slice를 별도로 제공한다고 설명한다.
|
||||
|
||||
### 예시 3. MVC 계층 검증인데 full context를 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class UserControllerTest {
|
||||
// request mapping, validation, status code만 검증
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 관심사가 MVC 계층에 머무르면 `@WebMvcTest`가 더 정확하다
|
||||
- full context를 기본값으로 잡으면 테스트 범위와 책임이 흐려진다
|
||||
- slice로 충분한 대상을 과도하게 넓힌다
|
||||
|
||||
Spring Boot는 Spring MVC controller 테스트에 `@WebMvcTest`를 제공한다고 설명한다.
|
||||
|
||||
### 예시 4. RANDOM_PORT 테스트에서 @Transactional이면 서버 변경도 롤백된다고 기대한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@Transactional
|
||||
class UserHttpRollbackTest {
|
||||
|
||||
@Test
|
||||
void createUser() {
|
||||
// HTTP 호출 후 테스트 종료되면 DB도 원복될 것이라고 기대
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 서버와 테스트 메서드는 별도 스레드/별도 트랜잭션이다
|
||||
- 테스트 메서드 롤백이 서버 쪽 트랜잭션에는 적용되지 않는다
|
||||
- 데이터 정리 전략을 별도로 가져가야 한다
|
||||
|
||||
Spring Boot는 `RANDOM_PORT`/`DEFINED_PORT`에서 서버와 클라이언트가 별도 스레드에서 실행되므로 서버 쪽 트랜잭션은 테스트 롤백으로 되돌아가지 않는다고 명시한다.
|
||||
|
||||
### 예시 5. 여러 slice annotation을 동시에 섞는다
|
||||
|
||||
```java
|
||||
@WebMvcTest
|
||||
@DataJpaTest
|
||||
class MixedSliceTest {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring Boot가 지원하지 않는 조합이다
|
||||
- 테스트 범위가 애매하고 자동 구성도 예측하기 어렵다
|
||||
- slice가 여러 개 필요하면 하나를 고르고 나머지는 수동으로 추가해야 한다
|
||||
|
||||
Spring Boot는 여러 `@…Test` slice annotation을 한 테스트에 함께 사용하는 것은 지원하지 않는다고 설명한다.
|
||||
|
||||
### 예시 6. 작은 차이마다 다른 @SpringBootTest 구성을 만들어 컨텍스트 캐시를 깨뜨린다
|
||||
|
||||
```java
|
||||
@SpringBootTest(properties = "feature.a=true")
|
||||
class ATest {
|
||||
}
|
||||
|
||||
@SpringBootTest(properties = "feature.a=false")
|
||||
class BTest {
|
||||
}
|
||||
|
||||
@SpringBootTest(properties = "feature.b=true")
|
||||
class CTest {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 목적이 비슷한 테스트인데 서로 다른 컨텍스트를 계속 만든다
|
||||
- static context cache 재사용이 줄어든다
|
||||
- 전체 테스트 시간이 불필요하게 늘 수 있다
|
||||
|
||||
Spring TestContext Framework는 `ApplicationContext`를 static cache에 저장해 재사용한다고 설명한다. 컨텍스트 구성이 달라질수록 재사용 이점이 줄어든다.
|
||||
@@ -0,0 +1,218 @@
|
||||
# API Controller 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. controller는 request DTO를 받아 use case를 호출하고 표준 응답을 반환한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(
|
||||
@Valid @RequestBody CreateSessionRequest request
|
||||
) {
|
||||
CreateSessionResult result = createSessionUseCase.create(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.loginType()
|
||||
);
|
||||
|
||||
return ApiResult.success(CreateSessionResponse.from(result));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- @RestController가 API 용도와 맞다
|
||||
- JSON body를 전용 request DTO로 받는다
|
||||
- controller가 use case 호출과 응답 반환에 집중한다
|
||||
|
||||
### 예시 2. ResponseEntity는 HTTP 제어가 필요할 때만 사용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<UserCreatedResponse>> register(
|
||||
@Valid @RequestBody RegisterUserRequest request
|
||||
) {
|
||||
UserCreatedResult result = registerUserUseCase.register(request.email(), request.password());
|
||||
UserCreatedResponse response = UserCreatedResponse.from(result);
|
||||
|
||||
URI location = URI.create("/api/users/" + response.userId());
|
||||
|
||||
return ResponseEntity.created(location)
|
||||
.body(ApiResult.success(response));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 201 Created와 Location 헤더가 필요한 경우에만 ResponseEntity를 사용한다
|
||||
- 모든 endpoint를 습관적으로 ResponseEntity로 감싸지 않는다
|
||||
|
||||
### 예시 3. 입력 출처를 시그니처에 명시한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@PathVariable String userId,
|
||||
@RequestParam(defaultValue = "false") boolean includeInactive
|
||||
) {
|
||||
UserResult result = userQueryUseCase.getUser(userId, includeInactive);
|
||||
return ApiResult.success(UserResponse.from(result));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- path와 query 입력 출처가 시그니처에서 구분된다
|
||||
- HttpServletRequest 전체를 들고 오지 않아도 되는 입력은 annotation으로 처리한다
|
||||
- request id 같은 관측용 헤더는 use case 입력으로 섞지 않는다
|
||||
|
||||
### 예시 4. controller는 예외를 직접 잡지 않는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/password")
|
||||
public class PasswordController {
|
||||
|
||||
private final ChangePasswordUseCase changePasswordUseCase;
|
||||
|
||||
@PostMapping("/change")
|
||||
public ApiResult<Void> changePassword(
|
||||
@Valid @RequestBody ChangePasswordRequest request
|
||||
) {
|
||||
changePasswordUseCase.change(
|
||||
request.userId(),
|
||||
request.currentPassword(),
|
||||
request.newPassword()
|
||||
);
|
||||
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예외는 @RestControllerAdvice에서 통합 처리할 수 있다
|
||||
- controller가 공통 에러 응답 정책을 직접 품지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 repository를 직접 호출한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<User> getUser(@PathVariable Long userId) {
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
return ApiResult.success(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller가 persistence access를 직접 수행한다
|
||||
- domain/entity가 외부 응답 모델로 직접 노출된다
|
||||
- application boundary가 사라진다
|
||||
|
||||
### 예시 2. entity를 request body로 직접 받는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class BadUserCommandController {
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<Void> create(@Valid @RequestBody User user) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- request model과 domain/persistence model이 섞인다
|
||||
- 웹 입력 변경이 domain/entity 구조에 직접 번진다
|
||||
|
||||
### 예시 3. 모든 응답을 습관적으로 ResponseEntity로 감싼다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/health")
|
||||
public class BadHealthController {
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResult<String>> health() {
|
||||
return ResponseEntity.ok(ApiResult.success("ok"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 추가로 제어할 status/header가 없다
|
||||
- 불필요한 ceremony만 늘어난다
|
||||
|
||||
### 예시 4. controller 안에서 공통 예외를 직접 처리한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/sessions")
|
||||
public class BadSessionController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<?>> create(@RequestBody CreateSessionRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResult.success(
|
||||
createSessionUseCase.create(request.email(), request.password(), request.loginType())
|
||||
));
|
||||
} catch (InvalidCredentialException ex) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResult.fail(ErrorCode.INVALID_CREDENTIAL));
|
||||
} catch (Exception ex) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResult.fail(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller마다 예외 정책이 중복된다
|
||||
- 전역 advice 기준과 충돌한다
|
||||
- 정상 흐름과 에러 흐름이 한 메서드에 뒤섞인다
|
||||
@@ -0,0 +1,181 @@
|
||||
# API Versioning 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. path major versioning으로 계약을 명시한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserQueryV1Controller {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserV1Response> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
return ApiResult.success(new UserV1Response(
|
||||
result.userId(),
|
||||
result.email()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v2/users")
|
||||
public class UserQueryV2Controller {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserV2Response> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
return ApiResult.success(new UserV2Response(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- URL만 보고 major version이 드러난다
|
||||
- 버전별 계약 차이가 controller와 DTO에서 명확하다
|
||||
- 내부 use case는 공유하면서 외부 계약은 분리할 수 있다
|
||||
|
||||
### 예시 2. Spring 7+ native version mapping을 제한적으로 활용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/accounts/{id}")
|
||||
public class AccountController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<AccountLegacyResponse> getDefault(@PathVariable String id) {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
|
||||
@GetMapping(version = "1.1")
|
||||
public ApiResult<AccountV11Response> getV11(@PathVariable String id) {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
|
||||
@GetMapping(version = "1.2+")
|
||||
public ApiResult<AccountV12Response> getV12Plus(@PathVariable String id) {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring이 공식 지원하는 version mapping 규칙을 따른다
|
||||
- fixed version과 baseline version의 의미가 분명하다
|
||||
- 단, 이 방식은 Spring 7+에 맞는 선택지다.
|
||||
|
||||
### 예시 3. deprecated version에 sunset 공지를 준비한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/sessions")
|
||||
public class SessionV1Controller {
|
||||
// 구버전 유지
|
||||
}
|
||||
```
|
||||
|
||||
운영 정책 예:
|
||||
|
||||
- 문서에 v1 deprecation 공지
|
||||
- 릴리스 노트에 종료 일정 공지
|
||||
- 응답 헤더에 deprecation/sunset/link 추가
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 버전 종료가 갑작스럽지 않다
|
||||
- 클라이언트가 마이그레이션할 시간을 가진다
|
||||
- Spring도 deprecation 관련 응답 헤더 전송을 지원한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 같은 API 군에서 path와 header versioning을 섞는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class MixedVersionController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<List<UserResponse>> getUsers() {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
|
||||
@GetMapping(headers = "API-Version=2")
|
||||
public ApiResult<List<UserResponse>> getUsersV2() {
|
||||
return ApiResult.success(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 버전 협상 위치가 두 군데다
|
||||
- client, gateway, 문서, 테스트가 모두 복잡해진다
|
||||
- 한 API product 안의 일관성을 깨뜨린다
|
||||
|
||||
### 예시 2. breaking change인데 version을 올리지 않는다
|
||||
|
||||
```java
|
||||
public record UserResponse(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName,
|
||||
String role
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
기존에 email만 응답하던 endpoint가 같은 /api/v1/users/{id} 에서
|
||||
|
||||
- 기존 필드 삭제
|
||||
- 필수 필드 의미 변경
|
||||
- 구조 변경
|
||||
|
||||
을 해 버리는 경우
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기존 client를 조용히 깨뜨린다
|
||||
- versioning 목적 자체를 무력화한다
|
||||
|
||||
### 예시 3. minor/patch를 path에 과하게 노출한다
|
||||
|
||||
```java
|
||||
@RequestMapping("/api/v1.0.3/users")
|
||||
public class UserController {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공개 URL이 불필요하게 복잡해진다
|
||||
- minor/patch 수준 변화까지 client 계약에 노출된다
|
||||
- 프로젝트의 major-only path 전략과 맞지 않는다
|
||||
|
||||
### 예시 4. 버전 누락 시 최신 버전으로 암묵 fallback한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/users/{userId}")
|
||||
public ApiResult<UserResponse> getUser(@PathVariable String userId) {
|
||||
// 내부적으로 최신 버전 계약으로 응답
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- client가 어떤 계약을 호출하는지 불명확하다
|
||||
- 시간이 지나며 응답 의미가 조용히 바뀔 수 있다
|
||||
- 명시적 계약 원칙과 맞지 않는다
|
||||
@@ -0,0 +1,214 @@
|
||||
# Authentication Object Access 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 프로젝트 전용 @CurrentUser를 정의한다
|
||||
|
||||
```java
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@AuthenticationPrincipal
|
||||
public @interface CurrentUser {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller가 Spring Security 애노테이션에 직접 결합되지 않는다
|
||||
- 현재 사용자 접근 규칙이 한 파일에 모인다
|
||||
- Spring 공식 문서도 같은 메타 애노테이션 방식을 예시로 보여 준다.
|
||||
|
||||
### 예시 2. controller는 전용 현재 사용자 타입만 받는다
|
||||
|
||||
```java
|
||||
public record AuthenticatedUser(
|
||||
String userId,
|
||||
Set<String> authorities
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(
|
||||
@CurrentUser AuthenticatedUser currentUser,
|
||||
@Valid @RequestBody CreateSessionRequest request
|
||||
) {
|
||||
CreateSessionResult result = createSessionUseCase.create(
|
||||
new CreateSessionCommand(
|
||||
currentUser.userId(),
|
||||
request.email(),
|
||||
request.password()
|
||||
)
|
||||
);
|
||||
|
||||
return ApiResult.success(new CreateSessionResponse(
|
||||
result.sessionId(),
|
||||
result.accessToken()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller가 현재 사용자 접근을 명시적으로 드러낸다
|
||||
- application에는 필요한 값만 전달한다
|
||||
- SecurityContextHolder 직접 접근이 없다
|
||||
|
||||
### 예시 3. ID만 필요하면 claim/field만 바로 주입한다
|
||||
|
||||
```java
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@AuthenticationPrincipal(expression = "userId")
|
||||
public @interface CurrentUserId {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/password")
|
||||
public class PasswordController {
|
||||
|
||||
private final ChangePasswordUseCase changePasswordUseCase;
|
||||
|
||||
@PostMapping("/change")
|
||||
public ApiResult<Void> changePassword(
|
||||
@CurrentUserId String userId,
|
||||
@Valid @RequestBody ChangePasswordRequest request
|
||||
) {
|
||||
changePasswordUseCase.change(
|
||||
new ChangePasswordCommand(
|
||||
userId,
|
||||
request.currentPassword(),
|
||||
request.newPassword()
|
||||
)
|
||||
);
|
||||
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 필요한 최소 actor 정보만 유스케이스로 간다
|
||||
- current user 타입 전체를 넘기지 않아도 된다
|
||||
- 공식 문서의 expression 기반 메타 애노테이션 패턴과 맞는다.
|
||||
|
||||
### 예시 4. Principal은 단순 확인 endpoint에 제한적으로 쓴다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me")
|
||||
public class MeController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<Map<String, String>> me(Principal principal) {
|
||||
return ApiResult.success(Map.of("name", principal.getName()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 단순 identity 확인 수준에는 충분하다
|
||||
- 복잡한 Security 타입을 노출하지 않는다
|
||||
- Spring MVC가 공식 지원하는 기본 method argument다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 SecurityContextHolder를 직접 읽는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me")
|
||||
public class BadMeController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<String> me() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
|
||||
return ApiResult.success(principal.getUserId());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller가 보안 저장소 접근과 캐스팅 책임까지 가진다
|
||||
- 시그니처에서 현재 사용자 의존이 드러나지 않는다
|
||||
- Spring 공식 문서도 이 패턴보다 @AuthenticationPrincipal 쪽을 권장 예시로 보여 준다.
|
||||
|
||||
### 예시 2. application이 Spring Security 타입을 직접 받는다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class BadChangePasswordService {
|
||||
|
||||
public void change(Authentication authentication, String currentPassword, String newPassword) {
|
||||
String userId = ((CustomUserPrincipal) authentication.getPrincipal()).getUserId();
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 Spring Security에 결합된다
|
||||
- 유스케이스 입력이 보안 프레임워크 타입에 종속된다
|
||||
- 테스트와 재사용성이 나빠진다
|
||||
|
||||
### 예시 3. controller가 role check로 인가를 직접 처리한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin")
|
||||
public class BadAdminController {
|
||||
|
||||
@PostMapping("/users/{userId}/lock")
|
||||
public ApiResult<Void> lock(
|
||||
@CurrentUser AuthenticatedUser currentUser,
|
||||
@PathVariable String userId
|
||||
) {
|
||||
if (!currentUser.authorities().contains("ROLE_ADMIN")) {
|
||||
throw new AccessDeniedException("forbidden");
|
||||
}
|
||||
|
||||
// ...
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 인가 규칙이 controller imperative code로 새어 나갔다
|
||||
- security rule/method security와 역할이 충돌한다
|
||||
- defense in depth 구조가 흐려진다.
|
||||
|
||||
### 예시 4. JWT claim을 여러 계층에서 직접 파싱한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class BadUserService {
|
||||
|
||||
public void doSomething(JwtAuthenticationToken authentication) {
|
||||
String userId = authentication.getToken().getClaimAsString("sub");
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 특정 인증 메커니즘(JWT claim 구조)에 결합된다
|
||||
- principal 해석 책임이 security adapter에 모이지 않는다
|
||||
- 토큰 구조 변경이 여러 계층으로 번진다
|
||||
@@ -0,0 +1,185 @@
|
||||
# Error Code / HTTP Status Separation 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. ErrorCode가 status와 외부 메시지를 함께 관리한다
|
||||
|
||||
```java
|
||||
public enum ErrorCode {
|
||||
REQUEST_VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "REQUEST_VALIDATION_FAILED", "Request validation failed"),
|
||||
MALFORMED_JSON_REQUEST(HttpStatus.BAD_REQUEST, "MALFORMED_JSON_REQUEST", "Malformed request body"),
|
||||
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "DUPLICATE_EMAIL", "Email already exists"),
|
||||
INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_ACCESS_TOKEN", "Invalid access token"),
|
||||
ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "Access denied"),
|
||||
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "User not found"),
|
||||
UPSTREAM_AUTH_SERVER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "UPSTREAM_AUTH_SERVER_UNAVAILABLE", "Authentication server is temporarily unavailable"),
|
||||
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Unexpected server error");
|
||||
|
||||
private final HttpStatus httpStatus;
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
ErrorCode(HttpStatus httpStatus, String code, String message) {
|
||||
this.httpStatus = httpStatus;
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public HttpStatus httpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- HTTP status와 application code가 함께 정책화된다
|
||||
- 문자열 하드코딩이 흩어지지 않는다
|
||||
- 같은 code가 어디서든 같은 기본 status를 갖는다
|
||||
|
||||
### 예시 2. advice는 예외를 ErrorCode로 매핑하고, status와 body를 함께 만든다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DuplicateEmailException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail() {
|
||||
ErrorCode errorCode = ErrorCode.DUPLICATE_EMAIL;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidAccessTokenException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleInvalidAccessToken() {
|
||||
ErrorCode errorCode = ErrorCode.INVALID_ACCESS_TOKEN;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(UserNotFoundException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleUserNotFound() {
|
||||
ErrorCode errorCode = ErrorCode.USER_NOT_FOUND;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- status와 body code가 같은 정책 타입에서 나온다
|
||||
- controller가 실패 응답을 직접 만들지 않는다
|
||||
- ErrorCode와 HTTP status 역할이 모두 드러난다
|
||||
|
||||
### 예시 3. 같은 400 계열 아래 여러 세부 ErrorCode를 둔다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class RequestExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex
|
||||
) {
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
ErrorCode errorCode = ErrorCode.REQUEST_VALIDATION_FAILED;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode, errors));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleMalformedJson() {
|
||||
ErrorCode errorCode = ErrorCode.MALFORMED_JSON_REQUEST;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 둘 다 400이지만 세부 원인은 ErrorCode로 구분된다
|
||||
- status는 넓은 범주, code는 세부 식별자라는 역할 분리가 분명하다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 모든 실패를 200으로 응답한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(DuplicateEmailException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail() {
|
||||
return ResponseEntity.ok(ApiResult.fail(ErrorCode.DUPLICATE_EMAIL));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- body는 실패인데 HTTP status는 성공이다
|
||||
- HTTP semantics와 application semantics가 충돌한다
|
||||
|
||||
### 예시 2. custom 6xx status를 사용한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(UpstreamAuthServerUnavailableException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleUpstreamFailure() {
|
||||
return ResponseEntity.status(601)
|
||||
.body(ApiResult.fail(ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 601은 유효한 HTTP status가 아니다
|
||||
- 세부 원인 구분은 ErrorCode로 해야 한다
|
||||
|
||||
### 예시 3. @ResponseStatus(reason=...)를 REST API 기본 실패 전략으로 사용한다
|
||||
|
||||
```java
|
||||
@ResponseStatus(code = HttpStatus.CONFLICT, reason = "Email already exists")
|
||||
public class DuplicateEmailException extends RuntimeException {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- HTTP status와 REST body 정책을 예외 클래스에 고정해 버린다
|
||||
- reason 기반 sendError는 REST API 응답 규약과 잘 맞지 않는다
|
||||
- body envelope 통일과 충돌하기 쉽다
|
||||
|
||||
### 예시 4. advice에서 문자열 코드와 예외 메시지를 직접 하드코딩한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(DuplicateEmailException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail(DuplicateEmailException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResult.fail("DUPLICATE_EMAIL", ex.getMessage()));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- status/code/message 정책이 중앙화되지 않는다
|
||||
- 외부 메시지와 내부 예외 메시지가 섞인다
|
||||
- 다른 파일에서도 같은 문자열이 반복되기 쉽다
|
||||
@@ -0,0 +1,290 @@
|
||||
# Idempotency 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 멱등 키가 필요한 POST endpoint는 헤더를 명시적으로 받는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<CreateUserResponse>> register(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody CreateUserRequest request,
|
||||
AuthenticatedUser authenticatedUser
|
||||
) {
|
||||
CreateUserCommand command = new CreateUserCommand(
|
||||
authenticatedUser.userId(),
|
||||
idempotencyKey,
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
CreateUserResult result = registerUserUseCase.register(command);
|
||||
|
||||
return ResponseEntity.created(URI.create("/api/v1/users/" + result.userId()))
|
||||
.body(ApiResult.success(new CreateUserResponse(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
)));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- controller는 헤더를 읽고 command로 전달만 한다
|
||||
- 멱등성 구현 책임이 controller에 머무르지 않는다
|
||||
- POST 생성 endpoint에서 멱등 키 요구가 명확하다
|
||||
|
||||
### 예시 2. application/service에서 키 + fingerprint로 중복을 판정한다
|
||||
|
||||
```java
|
||||
public record IdempotencyScope(
|
||||
String actorId,
|
||||
String operation
|
||||
) {
|
||||
}
|
||||
|
||||
public record StoredRegistrationResult(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
public interface IdempotencyStore {
|
||||
Optional<StoredRegistrationResult> findCompleted(
|
||||
IdempotencyScope scope,
|
||||
String key,
|
||||
String fingerprint
|
||||
);
|
||||
|
||||
IdempotencyStartResult tryStart(
|
||||
IdempotencyScope scope,
|
||||
String key,
|
||||
String fingerprint
|
||||
);
|
||||
|
||||
void complete(
|
||||
IdempotencyScope scope,
|
||||
String key,
|
||||
String fingerprint,
|
||||
StoredRegistrationResult result
|
||||
);
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RegisterUserUseCase {
|
||||
|
||||
private final IdempotencyStore idempotencyStore;
|
||||
private final UserRegistrationService userRegistrationService;
|
||||
|
||||
public CreateUserResult register(CreateUserCommand command) {
|
||||
IdempotencyScope scope = new IdempotencyScope(command.actorId(), "register-user");
|
||||
String fingerprint = fingerprint(command);
|
||||
|
||||
idempotencyStore.findCompleted(scope, command.idempotencyKey(), fingerprint)
|
||||
.ifPresent(storedResponse -> {
|
||||
throw new IdempotentReplayException(storedResponse);
|
||||
});
|
||||
|
||||
IdempotencyStartResult startResult = idempotencyStore.tryStart(
|
||||
scope,
|
||||
command.idempotencyKey(),
|
||||
fingerprint
|
||||
);
|
||||
|
||||
if (startResult == IdempotencyStartResult.IN_PROGRESS) {
|
||||
throw new IdempotencyRequestInProgressException();
|
||||
}
|
||||
|
||||
if (startResult == IdempotencyStartResult.KEY_REUSED_WITH_DIFFERENT_REQUEST) {
|
||||
throw new IdempotencyKeyMismatchException();
|
||||
}
|
||||
|
||||
CreateUserResult result = userRegistrationService.register(command);
|
||||
|
||||
StoredRegistrationResult storedResult = new StoredRegistrationResult(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
);
|
||||
|
||||
idempotencyStore.complete(scope, command.idempotencyKey(), fingerprint, storedResult);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private String fingerprint(CreateUserCommand command) {
|
||||
return DigestUtils.sha256Hex(
|
||||
command.actorId() + "|" +
|
||||
command.email() + "|" +
|
||||
command.displayName()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 멱등성 판정이 application 경계에 있다
|
||||
- key뿐 아니라 fingerprint도 비교한다
|
||||
- 완료 결과 재생, 진행 중 충돌, key 재사용 충돌을 분리한다
|
||||
- application은 HTTP status, ApiResult, JSON 직렬화 세부를 알지 않는다
|
||||
|
||||
### 예시 3. 멱등성 오류도 공통 에러 응답 규약으로 처리한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class IdempotencyExceptionHandler {
|
||||
|
||||
@ExceptionHandler(IdempotencyKeyMissingException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleMissingKey() {
|
||||
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_KEY_REQUIRED;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IdempotencyRequestInProgressException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleInProgress() {
|
||||
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_REQUEST_IN_PROGRESS;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IdempotencyKeyMismatchException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleMismatch() {
|
||||
ErrorCode errorCode = ErrorCode.IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 400/409/422 같은 HTTP status와 application error code를 함께 유지한다
|
||||
- 멱등성 오류도 전체 API 에러 규약에 맞춰진다
|
||||
|
||||
### 예시 4. key는 opaque UUID를 사용한다
|
||||
|
||||
요청 예:
|
||||
|
||||
```http
|
||||
POST /api/v1/users
|
||||
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 민감정보가 없다
|
||||
- 재시도 시 같은 key를 다시 보낼 수 있다
|
||||
- 운영/추적에도 적당한 opaque identifier다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. GET에 멱등 키를 요구한다
|
||||
|
||||
```java
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@PathVariable String userId
|
||||
) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- GET은 이미 safe/idempotent다
|
||||
- 불필요한 계약 복잡도만 늘어난다
|
||||
|
||||
### 예시 2. 서버가 멱등 키를 생성한다
|
||||
|
||||
```java
|
||||
@PostMapping
|
||||
public ApiResult<CreateUserResponse> register(@RequestBody CreateUserRequest request) {
|
||||
String idempotencyKey = UUID.randomUUID().toString();
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- client가 타임아웃 후 같은 요청을 재시도할 때 같은 key를 다시 보낼 수 없다
|
||||
- 재시도 안전성이라는 목적을 달성하지 못한다
|
||||
|
||||
### 예시 3. 이메일을 멱등 키로 사용한다
|
||||
|
||||
```http
|
||||
Idempotency-Key: donghyun@example.com
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 개인정보가 key에 노출된다
|
||||
- 요청 의도 식별자와 사용자 식별자가 뒤섞인다
|
||||
- 같은 사용자의 다른 요청을 구분하기 어렵다
|
||||
|
||||
### 예시 4. controller의 로컬 메모리로만 중복을 막는다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final Set<String> processedKeys = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<Void> register(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestBody CreateUserRequest request
|
||||
) {
|
||||
if (!processedKeys.add(idempotencyKey)) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
|
||||
// 실제 생성 처리
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 다중 인스턴스 환경에서 깨진다
|
||||
- fingerprint 비교가 없다
|
||||
- 애플리케이션 재기동 시 기록이 사라진다
|
||||
- controller가 멱등성 구현 책임까지 떠안는다
|
||||
|
||||
### 예시 5. 같은 key를 다른 payload에 재사용해도 새 요청으로 처리한다
|
||||
|
||||
```java
|
||||
public void handle(String key, CreateUserRequest request) {
|
||||
if (store.contains(key)) {
|
||||
process(request); // 그냥 다시 처리
|
||||
return;
|
||||
}
|
||||
process(request);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- key 재사용 오용을 막지 못한다
|
||||
- 중복 생성/중복 실행 위험이 남는다
|
||||
- “같은 요청의 재시도”와 “다른 요청”을 구분하지 못한다
|
||||
@@ -0,0 +1,285 @@
|
||||
# Pagination / Sort / Filter 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 일반 목록 조회는 명시적 query DTO + page 응답으로 표현한다
|
||||
|
||||
```java
|
||||
public record UserListRequest(
|
||||
@Min(1) int page,
|
||||
@Min(1) @Max(100) int size,
|
||||
String keyword,
|
||||
UserStatus status,
|
||||
String sortBy,
|
||||
SortDirection direction
|
||||
) {
|
||||
}
|
||||
|
||||
public record PageResponse<T>(
|
||||
List<T> items,
|
||||
int page,
|
||||
int size,
|
||||
boolean hasNext,
|
||||
Long totalCount
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final SearchUsersUseCase searchUsersUseCase;
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<PageResponse<UserResponse>> search(
|
||||
@Valid @ModelAttribute UserListRequest request
|
||||
) {
|
||||
SearchUsersQuery query = SearchUsersQuery.of(
|
||||
request.page(),
|
||||
request.size(),
|
||||
request.keyword(),
|
||||
request.status(),
|
||||
request.sortBy(),
|
||||
request.direction()
|
||||
);
|
||||
|
||||
UserPageResult result = searchUsersUseCase.search(query);
|
||||
|
||||
return ApiResult.success(new PageResponse<>(
|
||||
result.items().stream()
|
||||
.map(UserResponse::from)
|
||||
.toList(),
|
||||
result.page(),
|
||||
result.size(),
|
||||
result.hasNext(),
|
||||
result.totalCount()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 계약이 명시적이다
|
||||
- query input과 내부 paging 모델이 분리된다
|
||||
- 응답도 raw Page가 아니라 API 전용 DTO다
|
||||
|
||||
### 예시 2. sort field는 allowlist로 받고, persistence adapter에서 내부 정렬로 변환한다
|
||||
|
||||
```java
|
||||
public enum UserSortField {
|
||||
CREATED_AT("createdAt"),
|
||||
DISPLAY_NAME("displayName");
|
||||
|
||||
private final String externalName;
|
||||
|
||||
UserSortField(String externalName) {
|
||||
this.externalName = externalName;
|
||||
}
|
||||
|
||||
public static UserSortField from(String value) {
|
||||
return Arrays.stream(values())
|
||||
.filter(field -> field.externalName.equals(value))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new InvalidSortFieldException(value));
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
public class JpaUserSortMapper {
|
||||
|
||||
public Sort toSort(UserSortField sortField, SortDirection direction) {
|
||||
return switch (sortField) {
|
||||
case CREATED_AT -> Sort.by(direction.toSpring(), "createdAt", "id");
|
||||
case DISPLAY_NAME -> Sort.by(direction.toSpring(), "displayName", "id");
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 정렬 키와 내부 컬럼/프로퍼티가 분리된다
|
||||
- unsupported sort field를 명시적으로 거절할 수 있다
|
||||
- tie-breaker가 포함되어 정렬이 안정적이다
|
||||
- Spring Data `Sort` 변환은 persistence adapter 경계에 머문다
|
||||
|
||||
### 예시 3. count가 필요 없으면 Slice 스타일 응답으로 줄인다
|
||||
|
||||
```java
|
||||
public record SliceResponse<T>(
|
||||
List<T> items,
|
||||
int page,
|
||||
int size,
|
||||
boolean hasNext
|
||||
) {
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SearchAuditLogUseCase {
|
||||
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
|
||||
public AuditLogSliceResult search(SearchAuditLogQuery query) {
|
||||
return auditLogRepository.findSliceByCondition(query);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- application은 count가 필요 없는 slice 결과를 typed result로 반환한다
|
||||
- Spring Data `Slice` / `PageRequest`와 response DTO 변환은 바깥 adapter 책임으로 남긴다
|
||||
|
||||
### 예시 4. 대용량 피드는 cursor pagination을 쓴다
|
||||
|
||||
```java
|
||||
public record CursorPageResponse<T>(
|
||||
List<T> items,
|
||||
String nextCursor,
|
||||
boolean hasNext
|
||||
) {
|
||||
}
|
||||
|
||||
public record TimelineRequest(
|
||||
String cursor,
|
||||
@Min(1) @Max(100) int size
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/timeline")
|
||||
public class TimelineController {
|
||||
|
||||
private final ReadTimelineUseCase readTimelineUseCase;
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<CursorPageResponse<TimelineItemResponse>> read(
|
||||
@Valid @ModelAttribute TimelineRequest request
|
||||
) {
|
||||
TimelineWindowResult result = readTimelineUseCase.read(request.cursor(), request.size());
|
||||
|
||||
return ApiResult.success(new CursorPageResponse<>(
|
||||
result.items(),
|
||||
result.nextCursor(),
|
||||
result.hasNext()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대용량/변동이 큰 목록에 더 적합하다
|
||||
- 외부에는 opaque cursor만 노출한다
|
||||
- page number 깊이에 따라 성능이 급격히 나빠지는 구조를 피할 수 있다
|
||||
|
||||
### 예시 5. 다중 값 필터는 반복 query parameter로 받는다
|
||||
|
||||
```java
|
||||
public record UserSearchRequest(
|
||||
List<UserStatus> status,
|
||||
String keyword,
|
||||
@Min(1) int page,
|
||||
@Min(1) @Max(100) int size
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
요청 예:
|
||||
|
||||
```http
|
||||
GET /api/v1/users?status=ACTIVE&status=PENDING&keyword=kim&page=1&size=20
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 다중 값 필터가 명확하다
|
||||
- query parameter 규약이 읽기 쉽다
|
||||
- Spring 바인딩과도 자연스럽게 맞는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 공개 API controller에 raw Pageable을 그대로 노출한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class BadUserController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping
|
||||
public Page<User> getUsers(Pageable pageable) {
|
||||
return userRepository.findAll(pageable);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 외부 계약이 Spring Data 내부 타입에 종속된다
|
||||
- entity와 raw Page가 그대로 노출된다
|
||||
- request/response 계약을 프로젝트가 통제하기 어렵다
|
||||
|
||||
### 예시 2. 지원하지 않는 sort field를 조용히 무시한다
|
||||
|
||||
```java
|
||||
public Sort toSort(String sortBy, SortDirection direction) {
|
||||
if ("createdAt".equals(sortBy)) {
|
||||
return Sort.by(direction.toSpring(), "createdAt");
|
||||
}
|
||||
return Sort.unsorted();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 잘못된 요청을 성공처럼 처리한다
|
||||
- client는 정렬이 적용된 줄 오해할 수 있다
|
||||
- unsupported sort는 명시적으로 거절해야 한다
|
||||
|
||||
### 예시 3. 깊은 페이지까지 offset만 강제한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/v1/events")
|
||||
public ApiResult<PageResponse<EventResponse>> getEvents(
|
||||
@RequestParam int page,
|
||||
@RequestParam int size
|
||||
) {
|
||||
// 수백만 건 로그를 무조건 offset paging으로 조회
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 큰 offset paging은 성능이 급격히 나빠질 수 있다
|
||||
- 이벤트/로그/피드 계열에는 cursor 전략 검토가 필요하다
|
||||
|
||||
### 예시 4. 페이지마다 sort/filter가 달라질 수 있게 한다
|
||||
|
||||
```http
|
||||
GET /api/v1/users?page=1&size=20&sortBy=createdAt&direction=desc
|
||||
GET /api/v1/users?page=2&size=20&sortBy=displayName&direction=asc
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 목록의 다음 페이지라는 의미가 깨진다
|
||||
- 중복/누락/순서 흔들림이 생길 수 있다
|
||||
|
||||
### 예시 5. 범용 filter DSL을 기본 공개 API에 도입한다
|
||||
|
||||
```http
|
||||
GET /api/v1/users?filter=(status eq ACTIVE and (createdAt gt 2026-01-01)) or (role in [ADMIN,OWNER])
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 단순 목록 API치고 계약이 과도하게 복잡하다
|
||||
- 문서화, 검증, 운영 비용이 커진다
|
||||
- 기본 공개 API로는 명시적 필터 파라미터가 더 낫다
|
||||
@@ -0,0 +1,202 @@
|
||||
# Request / Response DTO 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. request와 response를 명확히 분리한다
|
||||
|
||||
```java
|
||||
public record CreateUserRequest(
|
||||
@NotBlank String email,
|
||||
@NotBlank String password,
|
||||
@NotBlank String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
public record CreateUserResponse(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateUserResponse> register(@Valid @RequestBody CreateUserRequest request) {
|
||||
RegisteredUser registeredUser = registerUserUseCase.register(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
return ApiResult.success(new CreateUserResponse(
|
||||
registeredUser.userId(),
|
||||
registeredUser.email(),
|
||||
registeredUser.displayName()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- request와 response 역할이 분리된다
|
||||
- request DTO가 그대로 내부 모델처럼 전파되지 않는다
|
||||
- 응답이 entity 구조가 아니라 API 계약 중심으로 표현된다
|
||||
|
||||
### 예시 2. query/form 입력은 전용 @ModelAttribute DTO로 받는다
|
||||
|
||||
```java
|
||||
public record UserSearchRequest(
|
||||
@NotBlank String keyword,
|
||||
@Min(1) int page,
|
||||
@Min(1) @Max(100) int size
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final SearchUsersUseCase searchUsersUseCase;
|
||||
|
||||
@GetMapping
|
||||
public ApiResult<UserSearchResponse> search(@Valid @ModelAttribute UserSearchRequest request) {
|
||||
UserSearchResult result = searchUsersUseCase.search(
|
||||
request.keyword(),
|
||||
request.page(),
|
||||
request.size()
|
||||
);
|
||||
|
||||
return ApiResult.success(UserSearchResponse.from(result));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- query 입력도 전용 web model로 분리된다
|
||||
- @ModelAttribute 대상이 domain/entity가 아니다
|
||||
- 검색 조건과 응답 모델이 분리된다
|
||||
|
||||
### 예시 3. ResponseEntity는 HTTP 제어가 필요할 때만 사용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<CreateUserResponse>> register(
|
||||
@Valid @RequestBody CreateUserRequest request
|
||||
) {
|
||||
RegisteredUser registeredUser = registerUserUseCase.register(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
CreateUserResponse response = new CreateUserResponse(
|
||||
registeredUser.userId(),
|
||||
registeredUser.email(),
|
||||
registeredUser.displayName()
|
||||
);
|
||||
|
||||
return ResponseEntity.created(URI.create("/api/users/" + response.userId()))
|
||||
.body(ApiResult.success(response));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 201 Created와 Location 제어가 필요해 ResponseEntity 사용 이유가 분명하다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. entity를 request body로 직접 받는다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String role;
|
||||
}
|
||||
|
||||
@PostMapping("/api/users")
|
||||
public ApiResult<Void> create(@Valid @RequestBody User user) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- web input model과 persistence/domain model이 섞인다
|
||||
- 바인딩 범위가 불필요하게 넓다
|
||||
- API 변경이 entity 구조에 직접 번진다
|
||||
|
||||
### 예시 2. request와 response를 하나의 DTO로 재사용한다
|
||||
|
||||
```java
|
||||
public record UserDto(
|
||||
String userId,
|
||||
String email,
|
||||
String password,
|
||||
String displayName,
|
||||
String role
|
||||
) {
|
||||
}
|
||||
|
||||
@PostMapping("/api/users")
|
||||
public ApiResult<UserDto> create(@RequestBody UserDto request) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 요청과 응답의 책임이 섞인다
|
||||
- 응답에 불필요하거나 민감한 필드가 섞이기 쉽다
|
||||
- write model과 read model이 분리되지 않는다
|
||||
|
||||
### 예시 3. DTO를 내부 모델처럼 그대로 넘긴다
|
||||
|
||||
```java
|
||||
@PostMapping("/api/users")
|
||||
public ApiResult<Void> create(@Valid @RequestBody CreateUserRequest request) {
|
||||
registerUserUseCase.register(request);
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- request DTO가 presentation 경계를 넘어 application 시그니처로 새어 나간다
|
||||
- 내부 use case가 transport model에 결합된다
|
||||
|
||||
### 예시 4. 응답으로 entity를 직접 반환한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/users/{userId}")
|
||||
public ApiResult<User> get(@PathVariable Long userId) {
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
return ApiResult.success(user);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- persistence/domain 구조가 외부 계약이 된다
|
||||
- 내부 필드가 의도치 않게 노출되기 쉽다
|
||||
- controller가 repository와 entity에 직접 결합된다
|
||||
@@ -0,0 +1,233 @@
|
||||
# Response Format 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 일반 성공 응답은 ApiResult<T>로 반환한다
|
||||
|
||||
```java
|
||||
public record UserResponse(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
|
||||
return ApiResult.success(new UserResponse(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 성공 응답 형식이 명확하다
|
||||
- business payload와 공통 envelope가 분리된다
|
||||
- controller가 임시 JSON을 조립하지 않는다
|
||||
|
||||
### 예시 2. HTTP 제어가 필요할 때만 ResponseEntity<ApiResult<T>>를 사용한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/users")
|
||||
public class UserCommandController {
|
||||
|
||||
private final RegisterUserUseCase registerUserUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResult<CreateUserResponse>> register(
|
||||
@Valid @RequestBody CreateUserRequest request
|
||||
) {
|
||||
RegisteredUser result = registerUserUseCase.register(
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.displayName()
|
||||
);
|
||||
|
||||
CreateUserResponse response = new CreateUserResponse(
|
||||
result.userId(),
|
||||
result.email(),
|
||||
result.displayName()
|
||||
);
|
||||
|
||||
return ResponseEntity.created(URI.create("/api/users/" + response.userId()))
|
||||
.body(ApiResult.success(response));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- ResponseEntity 사용 이유가 201 Created + Location으로 분명하다
|
||||
- body 형식은 여전히 프로젝트 표준을 따른다
|
||||
|
||||
### 예시 3. 실패 응답은 advice에서 ApiResult로 통일한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex
|
||||
) {
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResult.fail(ErrorCode.REQUEST_VALIDATION_FAILED, errors));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예외 응답 형식이 중앙에서 통일된다
|
||||
- controller가 실패 body를 직접 조립하지 않는다
|
||||
- 상세 오류 정보가 규칙적으로 담긴다
|
||||
|
||||
### 예시 4. 전역 응답 래핑은 이중 래핑을 피한다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class<?> converterType) {
|
||||
Class<?> parameterType = returnType.getParameterType();
|
||||
|
||||
return !ApiResult.class.isAssignableFrom(parameterType)
|
||||
&& !ResponseEntity.class.isAssignableFrom(parameterType)
|
||||
&& !Resource.class.isAssignableFrom(parameterType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response
|
||||
) {
|
||||
if (body == null) {
|
||||
return ApiResult.success(null);
|
||||
}
|
||||
|
||||
if (body instanceof ApiResult<?>) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return ApiResult.success(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 공통 envelope 적용 지점이 분명하다
|
||||
- 이미 래핑된 응답을 다시 감싸지 않는다
|
||||
- file/resource 응답을 무심코 건드리지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller마다 임시 응답 구조를 만든다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/users/{userId}")
|
||||
public Map<String, Object> getUser(@PathVariable String userId) {
|
||||
UserResult result = userQueryUseCase.getUser(userId);
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("ok", true);
|
||||
response.put("payload", result);
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 프로젝트 공통 응답 형식을 깨뜨린다
|
||||
- 다른 endpoint와 구조가 달라진다
|
||||
- 임시 필드명이 계약이 되어 버린다
|
||||
|
||||
### 예시 2. ResponseEntity를 의미 없이 남발한다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/health")
|
||||
public ResponseEntity<ApiResult<String>> health() {
|
||||
return ResponseEntity.ok(ApiResult.success("ok"));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 별도 header/status 제어가 없다
|
||||
- 불필요한 ceremony만 늘어난다
|
||||
|
||||
### 예시 3. 실패 응답에 내부 예외 메시지를 그대로 노출한다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResult<Void>> handle(Exception ex) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(ApiResult.fail("INTERNAL_SERVER_ERROR", ex.getMessage()));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 내부 메시지가 외부 계약이 된다
|
||||
- 민감한 구현 세부사항이 노출될 수 있다
|
||||
- 외부 응답 메시지 정책이 없다
|
||||
|
||||
### 예시 4. ResponseBodyAdvice에서 무조건 감싼다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class BadApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class<?> converterType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response
|
||||
) {
|
||||
return ApiResult.success(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 이미 ApiResult인 응답도 이중 래핑한다
|
||||
- 파일/리소스/스트리밍 응답을 망가뜨릴 수 있다
|
||||
- 규약 적용이 아니라 무차별 변환이 된다
|
||||
@@ -0,0 +1,234 @@
|
||||
# Serialization / Jackson 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. request/response DTO를 분리하고 timestamp는 offset 기반으로 노출한다
|
||||
|
||||
```java
|
||||
public record CreateSessionRequest(
|
||||
@NotBlank String email,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
|
||||
public record CreateSessionResponse(
|
||||
String sessionId,
|
||||
String accessToken,
|
||||
OffsetDateTime issuedAt,
|
||||
OffsetDateTime expiresAt
|
||||
) {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/sessions")
|
||||
public class SessionCommandController {
|
||||
|
||||
private final CreateSessionUseCase createSessionUseCase;
|
||||
|
||||
@PostMapping
|
||||
public ApiResult<CreateSessionResponse> create(
|
||||
@Valid @RequestBody CreateSessionRequest request
|
||||
) {
|
||||
SessionResult result = createSessionUseCase.create(request.email(), request.password());
|
||||
|
||||
return ApiResult.success(new CreateSessionResponse(
|
||||
result.sessionId(),
|
||||
result.accessToken(),
|
||||
result.issuedAt(),
|
||||
result.expiresAt()
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- request/response 계약이 분리된다
|
||||
- password는 응답 DTO에 존재하지 않는다
|
||||
- timestamp가 OffsetDateTime으로 명확하다
|
||||
|
||||
### 예시 2. 외부 공급자 webhook DTO만 lenient하게 받는다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ExternalAuthWebhookRequest(
|
||||
String eventId,
|
||||
String eventType,
|
||||
String subjectId
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 공급자가 필드를 추가해도 파싱이 덜 깨진다
|
||||
- lenient 정책이 third-party integration DTO로 국소화된다
|
||||
- first-party API request DTO와 기준이 분리된다
|
||||
|
||||
### 예시 3. 외부 계약 이름 mismatch만 @JsonProperty로 보정한다
|
||||
|
||||
```java
|
||||
public record ExternalUserResponse(
|
||||
@JsonProperty("user_id") String userId,
|
||||
@JsonProperty("display_name") String displayName
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 내부 표준 naming을 전체 프로젝트에 퍼뜨리지 않는다
|
||||
- mismatch를 DTO 경계에서 해결한다
|
||||
|
||||
### 예시 4. write-only 필드는 예외적으로만 사용한다
|
||||
|
||||
```java
|
||||
public record ResetPasswordCommandRequest(
|
||||
@NotBlank String userId,
|
||||
@NotBlank @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String newPassword
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 입력만 받고 다시 내보내면 안 되는 필드를 제한적으로 막는다
|
||||
- 그래도 request DTO 안에 국소화돼 있다
|
||||
|
||||
### 예시 5. 공통 직렬화 예외는 전역 컴포넌트로 등록한다
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
Module userIdModule() {
|
||||
SimpleModule module = new SimpleModule();
|
||||
module.addSerializer(UserId.class, new JsonSerializer<>() {
|
||||
@Override
|
||||
public void serialize(UserId value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
gen.writeString(value.value());
|
||||
}
|
||||
});
|
||||
return module;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 반복되는 값 객체 직렬화를 전역 정책으로 올린다
|
||||
- controller나 DTO마다 같은 로직을 복붙하지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. entity를 그대로 응답으로 내보낸다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String password;
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
private Organization organization;
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/users/{id}")
|
||||
public ApiResult<User> getUser(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElseThrow();
|
||||
return ApiResult.success(user);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- persistence 모델이 외부 계약이 된다
|
||||
- 민감 필드와 lazy relation 노출 위험이 있다
|
||||
- API shape가 entity 구조에 끌려간다
|
||||
|
||||
### 예시 2. controller에서 로컬 ObjectMapper를 만든다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/v1/users/{id}")
|
||||
public String getUser(@PathVariable Long id) throws JsonProcessingException {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.writeValueAsString(userService.getUser(id));
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 전역 Jackson 규칙을 우회한다
|
||||
- converter, module, naming, time 설정이 끊어진다
|
||||
- controller 책임이 과도해진다
|
||||
|
||||
### 예시 3. public API shape를 @JsonView로 관리한다
|
||||
|
||||
```java
|
||||
public class UserViewModel {
|
||||
|
||||
@JsonView(Summary.class)
|
||||
private String userId;
|
||||
|
||||
@JsonView(Summary.class)
|
||||
private String displayName;
|
||||
|
||||
@JsonView(Detail.class)
|
||||
private String email;
|
||||
|
||||
interface Summary {}
|
||||
interface Detail extends Summary {}
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/users/{id}")
|
||||
@JsonView(UserViewModel.Summary.class)
|
||||
public UserViewModel getUser(@PathVariable String id) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- summary/detail 계약이 DTO 분리 대신 view 규칙에 숨어든다
|
||||
- public API contract evolution이 읽기 어려워진다
|
||||
- versioning/응답 shape 관리 수단으로는 과도하게 간접적이다
|
||||
|
||||
### 예시 4. first-party request DTO에서 unknown field를 무비판적으로 무시한다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record CreateUserRequest(
|
||||
String email,
|
||||
String password,
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 클라이언트 오타나 잘못된 필드 전송을 조용히 숨길 수 있다
|
||||
- 우리가 소유한 API 계약이 흐려진다
|
||||
- strict 정책을 택한 API군과 충돌한다
|
||||
|
||||
### 예시 5. null omission을 보기 좋다는 이유만으로 남발한다
|
||||
|
||||
```java
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record UserResponse(
|
||||
String userId,
|
||||
String displayName,
|
||||
String email,
|
||||
String phoneNumber
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 필드 omission이 계약 의미를 바꾼다
|
||||
- 클라이언트가 null과 absent를 구분해야 하는 경우 혼란이 생긴다
|
||||
- 전역/DTO별 정책이 뒤섞이기 쉽다
|
||||
Reference in New Issue
Block a user