Project/주식 자동매매 프로젝트

[동시성] 동시 요청에서 ACTIVE 데이터는 어떻게 하나만 보장할까?

eunkonge 2026. 9. 4. 22:35 댓글 0

AI 위험 분석에 사용할 프롬프트를 DB에서 버전별로 관리하는 기능을 구현했다.

프롬프트를 새로 등록하면 기존 ACTIVE 프롬프트를 RETIRED 상태로 변경하고, 새로운 버전을 ACTIVE 상태로 저장하는 방식이다.

 

예를 들어 v1이 활성화된 상태에서 새로운 프롬프트를 등록하면 다음과 같이 변경된다.

등록 전
v1 ACTIVE

등록 후
v1 RETIRED
v2 ACTIVE

 

이 구조에서 지켜야 할 중요한 조건이 하나 있었다.

동일한 prompt_key에는 ACTIVE 상태의 프롬프트가 항상 하나만 존재해야 한다.

단일 요청에서는 문제가 없었지만, 동일한 프롬프트에 대한 등록 요청이 동시에 들어오면 이 조건이 깨질 가능성이 있었다.

1. 동시 요청에서 Race Condition 재현하기

기존 프롬프트 등록 로직은 크게 다음 순서로 동작했다.

1. 현재 ACTIVE 프롬프트 조회
2. 기존 ACTIVE → RETIRED 변경
3. 다음 버전 계산
4. 새로운 ACTIVE 프롬프트 저장

 

문제는 두 요청이 거의 동시에 들어오는 경우다.

Thread A                         Thread B
   │                                │
   ├─ ACTIVE v1 조회                ├─ ACTIVE v1 조회
   │                                │
   ├─ v1 → RETIRED                 ├─ v1 → RETIRED
   │                                │
   ├─ 신규 ACTIVE 저장             ├─ 신규 ACTIVE 저장
   │                                │
   ▼                                ▼
 v2 ACTIVE                        v3 ACTIVE

 

두 트랜잭션 모두 자신이 새로운 ACTIVE 프롬프트를 생성할 수 있다고 판단할 수 있다.

실제로 문제가 발생하는지 확인하기 위해 Testcontainers로 실제 PostgreSQL을 실행하고, 두 개의 스레드에서 동시에 프롬프트 등록 요청을 보내는 통합 테스트를 작성했다.

 

테스트의 핵심 검증 조건은 다음과 같다.

long activeCount = prompts.stream()
        .filter(p -> p.getPromptKey() == AiPromptKey.RISK_ANALYSIS)
        .filter(p -> p.getStatus() == AiPromptStatus.ACTIVE)
        .count();

assertThat(activeCount).isEqualTo(1);

 

테스트 결과 실제로 동시성 문제가 재현됐다.

version=v2, status=ACTIVE
version=v1, status=RETIRED
version=v3, status=ACTIVE

Expected: 1
Actual:   2

 

v2, v3 두 개의 프롬프트가 모두 ACTIVE가 되면서 우리가 정의한 불변식이 깨졌다.

애플리케이션에서 기존 ACTIVE 데이터를 조회한 후 변경하는 것만으로는 동시 요청을 제어할 수 없었다.

2. 비관적 락을 적용하면 해결할 수 있을까?

처음에는 기존 ACTIVE 프롬프트를 조회할 때 비관적 락을 걸면 해결할 수 있다고 생각했다.

비관적 락은 데이터를 조회하는 시점에 DB Lock을 획득하여 다른 트랜잭션이 해당 데이터에 동시에 접근하는 것을 제한하는 방식이다.

 

따라서 기존 ACTIVE 프롬프트를 조회하는 Repository에 PESSIMISTIC_WRITE를 적용했다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<AiPromptVersion> findByPromptKeyAndStatus(
        AiPromptKey promptKey,
        AiPromptStatus status
);

 

이렇게 하면 한 트랜잭션이 기존 ACTIVE 행을 처리하는 동안 다른 트랜잭션의 접근을 막을 수 있을 것으로 예상했다.

하지만 동일한 동시성 테스트를 실행한 결과 문제는 해결되지 않았다.

v1 RETIRED
v2 ACTIVE
v3 ACTIVE

Expected: 1
Actual:   2

 

여기서 문제를 다시 생각해보게 됐다.

 

내가 보장하고 싶은 것은 단순히

"v1이라는 특정 행을 동시에 수정하지 못하게 한다."

가 아니었다.

 

실제로 보장해야 하는 것은

"RISK_ANALYSIS라는 prompt_key에 대해 ACTIVE 상태의 행이 2개 이상 존재할 수 없다."

 

라는 데이터 자체의 제약이었다.

 

특히 최초 프롬프트 등록은 더 명확한 문제가 있다.

현재 ACTIVE 프롬프트 = 없음

 

이라면 비관적 락을 걸고 싶어도 조회되는 행 자체가 없다.

 

즉 이번 문제는 특정 기존 행에 대한 동시 접근을 제어하는 것보다, DB가 최종적으로 허용할 수 있는 데이터 상태를 제한하는 것이 더 적합했다.

3. PostgreSQL Partial Unique Index 적용

그래서 애플리케이션의 실행 순서를 제어하는 대신 DB에 다음 불변식을 직접 표현하기로 했다.

동일한 prompt_key에 ACTIVE 상태의 데이터는 하나만 존재할 수 있다.

 

일반적인 Unique Constraint를 다음처럼 적용할 수는 없었다.

UNIQUE(prompt_key, status)

 

이렇게 하면 RETIRED 데이터 역시 prompt_key별로 하나밖에 저장할 수 없기 때문이다.

 

프롬프트 버전은 다음처럼 여러 개의 RETIRED 데이터를 보관해야 한다.

v1 RETIRED
v2 RETIRED
v3 RETIRED
v4 ACTIVE

 

그래서 PostgreSQL의 Partial Unique Index를 사용했다.

CREATE UNIQUE INDEX uq_ai_prompt_active
ON p_ai_prompt_versions (prompt_key)
WHERE status = 'ACTIVE';

 

이 인덱스는 모든 행에 Unique 조건을 적용하지 않는다.

RETIRED → Unique Index 대상 X
ACTIVE  → Unique Index 대상 O

 

따라서 다음 상태는 허용된다.

RISK_ANALYSIS / v1 / RETIRED
RISK_ANALYSIS / v2 / RETIRED
RISK_ANALYSIS / v3 / ACTIVE

 

하지만 다음 상태는 DB가 허용하지 않는다.

RISK_ANALYSIS / v2 / ACTIVE
RISK_ANALYSIS / v3 / ACTIVE
                       ↑
                  Unique 위반

동일한 동시성 테스트에 Partial Unique Index를 적용한 결과는 달라졌다.

Key (prompt_key)=(RISK_ANALYSIS) already exists.

version=v1, status=RETIRED
version=v2, status=ACTIVE

exception=BusinessException:
프롬프트 등록 중 충돌이 발생했습니다.

 

두 요청이 동시에 새로운 ACTIVE 프롬프트를 저장하려 하더라도 DB가 중복 ACTIVE 생성을 차단했다.

 

최종적으로는:

v1 RETIRED
v2 ACTIVE

 

만 남으면서 ACTIVE = 1이라는 불변식이 유지됐다.

4. DB 예외를 비즈니스 예외로 변환하기

Partial Unique Index를 적용하면서 새로운 문제도 생겼다.

DB가 중복 데이터를 막아주는 것은 좋지만, Unique Index 위반을 그대로 외부로 노출하는 것은 적절하지 않았다.

 

그래서 saveAndFlush() 시점에서 충돌을 감지해 비즈니스 예외로 변환했다.

try {
    AiPromptVersion saved =
            promptVersionCommandRepository.saveAndFlush(promptVersion);

    return AiPromptVersionCreateResponse.from(saved);
} catch (DataIntegrityViolationException e) {
    if (isPromptVersionConflict(e)) {
        throw new BusinessException(
                AiRiskErrorCode.AI_PROMPT_VERSION_CONFLICT
        );
    }

    throw e;
}

 

여기서 save()가 아니라 saveAndFlush()를 사용한 이유도 있다.

 

JPA의 save()는 실제 INSERT가 flush 또는 transaction commit 시점까지 지연될 수 있다. 그렇게 되면 DB 제약 조건 위반이 try-catch 범위 이후에 발생할 수 있다.

 

saveAndFlush()를 사용하면 해당 지점에서 SQL을 DB에 반영하기 때문에 Unique Index 위반을 서비스 계층에서 확인할 수 있다.

 

처음에는 모든 DataIntegrityViolationException을 동일한 프롬프트 충돌 예외로 변환하려 했다. 하지만 NOT NULL 위반 등 전혀 다른 데이터 무결성 문제가 발생해도 같은 비즈니스 오류로 처리될 수 있다는 문제가 있었다.

 

따라서 Hibernate의 ConstraintViolationException에서 constraint name을 확인하여 우리가 의도한 제약 조건의 충돌만 변환하도록 수정했다.

private boolean isPromptVersionConflict(
        DataIntegrityViolationException exception
) {
    Throwable cause = exception;

    while (cause != null) {
        if (cause instanceof ConstraintViolationException constraintException) {
            String constraintName = constraintException.getConstraintName();

            return ACTIVE_PROMPT_UNIQUE_INDEX.equals(constraintName)
                    || PROMPT_VERSION_UNIQUE_CONSTRAINT.equals(constraintName);
        }

        cause = cause.getCause();
    }

    return false;
}

 

즉 예외 처리의 기준도 단순히 "DB 저장 중 오류가 발생했는가?"가 아니라 "우리가 예상하고 있는 프롬프트 버전 충돌인가?"로 좁혔다.

정리

이번 문제를 해결하면서 처음에는 "동시 요청이니까 Lock을 걸어야 한다"고 접근했다.

하지만 실제로 필요한 것은 특정 행에 대한 접근 제어가 아니라 다음 데이터 불변식을 보장하는 것이었다.

동일한 prompt_key에는 ACTIVE 프롬프트가 최대 하나만 존재한다.

 

비관적 락을 적용해보고 실제 PostgreSQL 환경에서 동시성 테스트를 진행하면서, 이 조건은 애플리케이션의 실행 순서보다 DB 제약 조건으로 보장하는 것이 더 적합하다고 판단했다.

 

최종적으로 PostgreSQL Partial Unique Index를 적용하여 ACTIVE 데이터의 유일성을 DB 레벨에서 보장했고, 충돌은 애플리케이션의 비즈니스 예외로 변환했다.

많이 읽은 글