Files
project-auth-server/docs/examples/db/concurrency.md
T

280 lines
9.2 KiB
Markdown

# 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"는 제약이 더 적절합니다.