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

9.2 KiB

DB Concurrency 예시

좋은 예시

예시 1. 동시에 수정될 수 있는 aggregate root에는 @Version을 둔다

@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 한 번으로 처리한다

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로 닫는다

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로 처리한다

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. 낙관적 락 실패는 유스케이스 실패로 올린다

@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 없이 마지막 커밋이 이기게 둔다

@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를 날린다

@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. 중복 생성 방지를 선조회로만 처리한다

@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을 잡아서 무시한다

@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에서 제외한다

@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을 무한 재시도한다

while (true) {
    try {
        userRepository.save(user);
        break;
    } catch (DataIntegrityViolationException e) {
        // 계속 재시도
    }
}

나쁜 이유:

  • unique violation은 transient concurrency일 수도 있지만, 영구적인 비즈니스 오류일 수도 있다
  • 원인을 구분하지 않으면 무한 루프나 불필요한 부하를 만든다
  • 재시도는 전체 유스케이스와 에러 코드 의미를 보고 제한적으로만 해야 한다

PostgreSQL은 23505가 때로는 concurrency 실패일 수 있지만, 항상 그런 것은 아니므로 더 신중해야 한다고 설명합니다.

예시 7. 단순 중복 생성 문제를 곧바로 FOR UPDATE로 푼다

SELECT *
FROM auth.users
WHERE email = :email
FOR UPDATE;

나쁜 이유:

  • 중복 생성 방지의 기본 문제는 uniqueness인데, row lock을 먼저 가져가면 설계가 과도하게 무거워진다
  • 아직 row가 없는 경우에는 lock으로도 해결되지 않는다
  • 이런 문제는 보통 unique constraint와 ON CONFLICT가 더 직접적이다

PostgreSQL은 FOR UPDATE가 기존 row를 잠그는 수단이라고 설명합니다. "존재하지 않는 row의 uniqueness"는 제약이 더 적절합니다.