[TIL-260722] 검증 재시도 API를 만들다 발견한 동시성 문제와 비관적 락으로 해결한 과정

2026. 7. 22. 23:51·Backend/DB

우리 서비스는 콘텐츠를 등록하면 백그라운드 비동기 워커가 AI 검증을 수행하고, 프론트엔드는 폴링(Polling)으로 검증 상태를 주기적으로 조회한다.

 

검증이 실패한 경우에는 사용자가 '재시도(Retry)' 버튼을 눌러 다시 검증을 요청할 수 있도록 API를 추가했다.

 

처음엔 단순한 API라고 생각했다. 기존 검증이 진행 중인지 확인하고, 아니라면 새로운 PENDING 이력을 만들고 검증 이벤트를 발행하면 끝이라고 생각했다. 그런데 구현을 마치고 다시 코드를 보니, 사용자가 버튼을 여러 번 누르는 순간 동시성 문제가 생길 수 있다는 걸 발견했다.

1. 처음 구현은 이렇게 생겼다

재시도 API의 역할은 단순하다.

POST /api/contents/{contentId}/retry
  1. 현재 검증이 진행 중인지 확인한다.
  2. 진행 중이 아니라면 새로운 PENDING 이력을 생성한다.
  3. AI 검증 이벤트를 발행한다.

처음 구현도 그대로였다.

ContentValidation validation = getLatestValidation(contentId);

if (validation.getStatus() == ValidationStatus.PENDING) {
    throw new CustomException(ContentErrorCode.VALIDATION_ALREADY_RUNNING);
}

ContentValidation pendingValidation =
        ContentValidation.builder()
                .status(ValidationStatus.PENDING)
                .build();

contentValidationRepository.save(pendingValidation);

eventPublisher.publishEvent(
        new ContentCreatedEvent(contentId, memberUuid));

처음 보면 특별히 문제 없어 보인다. 이미 검증이 진행 중이라면 예외를 던지고, 아니라면 새로운 검증을 시작한다. 나도 처음에는 여기서 끝이라고 생각했다.

2. 광클 하나가 Race Condition을 만들었다

문제는 여러 요청이 동시에 들어오는 경우였다.

 

예를 들어 사용자가 버튼을 0.1초 만에 10번 연속 누른다고 가정해 보자. 그 순간 10개의 스레드가 거의 동시에 아래 코드를 실행하게 된다.

ContentValidation validation = getLatestValidation(contentId);

그런데 아직 어느 스레드도 PENDING을 저장하지 않았으므로,

모든 스레드가

status != PENDING

이라고 판단하게 된다.

 

결국 10개의 스레드가 모두 아래 코드까지 실행한다.

contentValidationRepository.save(...PENDING...);

그 결과

  • PENDING 이력이 여러 개 생성되고
  • AI 검증 이벤트도 여러 번 발행되며
  • Claude API도 여러 번 호출된다.

사용자는 버튼을 한 번 누른다고 생각했는데, 서버에서는 동일한 검증을 여러 번 수행하게 되는 것이다.

 

이건 전형적인 Race Condition이었다.

3. 해결 방법은 "한 명씩만 들어오게 만들기"

이 문제를 해결하려면 동시에 여러 스레드가 같은 콘텐츠를 처리하지 못하게 해야 했다.

 

그래서 선택한 방법이 비관적 락(Pessimistic Lock)이다. 비관적 락은 "동시에 수정이 일어날 가능성이 높다고 가정하고, 먼저 데이터를 잠근 뒤 작업을 수행하는 방식"이다. 여러 트랜잭션이 같은 데이터를 수정하려고 경쟁하는 상황이라면, 먼저 락을 획득한 트랜잭션만 작업을 수행하고 나머지는 기다리게 만들어 Race Condition을 방지한다.

 

JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE)를 사용하면 Hibernate가 일반 조회가 아니라 DB의 쓰기 락을 획득하는 SQL을 생성한다. MySQL에서는 내부적으로 SELECT ... FOR UPDATE 형태로 실행되어 조회와 동시에 해당 Row에 쓰기 락을 건다.

 

먼저 Repository에 락을 거는 조회 메서드를 추가했다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
    SELECT c
    FROM Content c
    WHERE c.id = :contentId
""")
Optional<Content> findByIdWithPessimisticLock(Long contentId);

언뜻 보면 단순한 조회 메서드처럼 보이지만, 메서드가 직접 락을 거는 것은 아니다. @Lock(PESSIMISTIC_WRITE)를 붙이면 Hibernate가 이 JPQL을 실행할 때 데이터베이스가 이해할 수 있는 락 쿼리로 변환해 준다.

 

실제로는 내부적으로 다음과 비슷한 SQL이 실행된다.

SELECT *
FROM content
WHERE id = ?
FOR UPDATE;

여기서 핵심은 마지막의 FOR UPDATE다.


이 쿼리가 실행되는 순간 DB는 해당 content Row에 쓰기 락(Exclusive Lock)을 획득한다. 그 상태에서는 다른 트랜잭션도 같은 Row에 SELECT ... FOR UPDATE를 수행할 수는 있지만, 기존 락이 해제될 때까지 결과를 반환받지 못하고 대기하게 된다.

예를 들어 10개의 요청이 동시에 들어오면 순서는 이렇게 된다.

Thread1
↓
SELECT ... FOR UPDATE
↓
락 획득
↓
비즈니스 로직 수행
↓
Commit
↓
락 해제
────────────────────────

Thread2 ~ Thread10
↓
SELECT ... FOR UPDATE
↓
락이 풀릴 때까지 대기

Thread1이 먼저 PENDING 상태를 저장하고 이벤트를 발행한 뒤 Commit을 수행하면 락이 해제된다. 그동안 다른 요청들은 락이 풀릴 때까지 대기하므로, 동일한 콘텐츠에 대한 비즈니스 로직은 순차적으로 실행된다. 락이 해제된 뒤에야 Thread2가 실행되는데, 이미 DB에는 PENDING 상태가 저장되어 있으므로 중복 요청으로 판단하고 예외를 반환하게 된다.

 

즉 락이 직접 중복 실행을 막는 것이 아니라, 한 번에 하나의 트랜잭션만 비즈니스 로직을 수행하도록 만들어 주는 것이다.

 

여기서 락을 ContentValidation이 아니라 Content에 건 이유도 있다. 우리가 막고 싶은 것은 같은 콘텐츠에 대한 재시도가 동시에 실행되는 상황이지, 특정 검증 이력(Row) 하나를 보호하는 것이 아니었다. Content는 콘텐츠당 하나의 Row만 존재하므로, 이 Row를 잠그면 같은 콘텐츠에 대한 모든 재시도 요청을 자연스럽게 직렬화할 수 있다.

 

그리고 서비스 로직도 가장 처음에 락을 획득하도록 수정했다.

@Transactional
public ContentValidationResponse retryValidation(...) {

    // 가장 먼저 Content Row를 잠근다.
    Content content =
            getOwnedContentWithLock(contentId, memberUuid);

    ContentValidation validation =
            getLatestValidation(contentId);

    if (validation.getStatus() == ValidationStatus.PENDING) {
        throw new CustomException(
                ContentErrorCode.VALIDATION_ALREADY_RUNNING);
    }

    ...
}

4. 이제는 정말 한 요청씩만 처리된다

앞에서 본 것처럼 SELECT ... FOR UPDATE는 같은 Content Row에 대한 접근을 직렬화한다.

 

예를 들어 10개의 요청이 동시에 들어오면

1번 요청 → 락 획득
2~10번 요청 → 대기

가 된다.

 

1번 요청이

  • PENDING 저장
  • 이벤트 발행
  • Commit

까지 끝내고 나서야 락이 풀린다.

그제야 두 번째 요청이 실행되는데, 이미 DB에는 PENDING이 저장되어 있으므로

if (validation.getStatus() == PENDING)

조건에 걸려 바로 예외를 반환한다.

 

결국

1개 성공
9개 실패

가 된다.

 

처음 의도했던 동작을 비로소 보장할 수 있게 됐다.

5. 정말 그럴까? 동시성 테스트로 확인했다

락은 눈으로 확인하기 어렵다.

 

그래서 실제로 10개의 스레드가 동시에 요청하도록 테스트 코드를 작성했다.

스레드가 순차적으로 실행되는 걸 막기 위해 CountDownLatch를 사용했다.

CountDownLatch startLatch = new CountDownLatch(1);

for (int i = 0; i < threadCount; i++) {
    executor.submit(() -> {

        startLatch.await();

        contentService.retryValidation(...);

    });
}

startLatch.countDown();

startLatch.countDown()이 호출되는 순간, 10개의 스레드가 거의 동시에 API를 호출하게 된다.

 

테스트에서는

assertThat(successCount.get()).isEqualTo(1);
assertThat(blockedCount.get()).isEqualTo(9);

를 검증했다.

 

실행 결과도 정확히 예상대로 나왔다.

  • 성공 : 1건
  • VALIDATION_ALREADY_RUNNING : 9건

비관적 락이 의도대로 동작하고 있다는 걸 테스트 코드로도 확인할 수 있었다.

6. 프론트엔드에서 막는 것만으로는 부족했다

처음에는 프론트엔드에서 버튼을 비활성화하면 충분하다고 생각했다.

 

하지만 조금만 생각해 보면

  • 사용자의 빠른 연속 클릭
  • 네트워크 지연
  • 동일한 API를 직접 여러 번 호출하는 경우

처럼 프론트엔드만으로는 막을 수 없는 상황이 얼마든지 존재한다.

 

결국 프론트엔드는 사용자 경험(UX)을 개선할 수는 있지만, 동시성으로부터 데이터의 정합성을 보장하는 책임은 백엔드에 있다.

이번처럼 동일한 리소스를 동시에 수정하는 API라면, 프론트엔드의 UX 개선과 별개로 서버에서도 반드시 동시성을 고려해야 한다는 걸 다시 한번 느꼈다.

7. 배운 것: 락은 "사용했다"보다 "언제 획득하느냐"가 더 중요했다

이번에 가장 크게 배운 건 비관적 락을 사용했다는 사실보다, 언제 락을 획득하느냐가 더 중요하다는 점이었다.

 

처음에는 PENDING 여부만 확인하면 충분하다고 생각했다. 하지만 여러 요청이 동시에 들어오면, 모두가 같은 상태를 읽은 뒤 각각 새로운 PENDING을 저장할 수 있다는 걸 뒤늦게 발견했다.

 

핵심은 상태를 확인하기 전에 먼저 같은 리소스에 대한 접근을 직렬화하는 것이었다. 트랜잭션이 시작되자마자 SELECT ... FOR UPDATE로 Content Row를 잠그니, 이후의 상태 조회와 저장이 모두 하나의 순서 안에서 수행되면서 Race Condition이 사라졌다.

 

이번 일을 계기로 "정상적인 요청 하나"만 생각해서 코드를 작성하면 쉽게 동시성 문제를 놓칠 수 있다는 걸 배웠다. 앞으로는 API를 구현할 때 기능이 동작하는지만 확인하는 데서 끝내지 않고, 여러 요청이 동시에 들어와도 동일한 결과를 보장하는지까지 함께 검증하는 습관을 가져야겠다고 느꼈다.

'Backend > DB' 카테고리의 다른 글

[TIL-260305] DB 기초: 주요 함수, 조인, 서브 쿼리  (0) 2026.03.05
[TIL-260304] DB 기초: SET, 그룹 함수, 주요 함수  (0) 2026.03.04
[TIL-260303] DB 기초: 제약 조건, SELECT, WHERE 조건  (0) 2026.03.04
[TIL-260227] DB 기초: SQL 정의하기  (0) 2026.02.27
[TIL-260226] DB 기초: 데이터 모델링, 정규화, SQL 분류  (0) 2026.02.27
'Backend/DB' 카테고리의 다른 글
  • [TIL-260305] DB 기초: 주요 함수, 조인, 서브 쿼리
  • [TIL-260304] DB 기초: SET, 그룹 함수, 주요 함수
  • [TIL-260303] DB 기초: 제약 조건, SELECT, WHERE 조건
  • [TIL-260227] DB 기초: SQL 정의하기
hee-on
hee-on
작은 기록을 모아 꾸준히 성장해 나가는 개발 기록 공간입니다💻
  • hee-on
    희온의 dev log
    hee-on
  • 전체
    오늘
    어제
    • 전체 글 (52) N
      • About (2)
      • Backend (33) N
        • Java (15)
        • Spring (6)
        • DB (8) N
        • Architecture & Design (4) N
      • Frontend (6)
      • CS (6)
        • Algorithm (2)
        • Do it 알고리즘 코딩테스트 (자바편) (4)
        • Network (0)
        • Operating System (0)
      • DevOps (1)
        • Git (1)
        • CI-CD (0)
        • Docker (0)
        • AWS (0)
      • AI (1)
      • 일상 || 잡담 (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    til
    백준
    SQL
    JSP
    ClaudeAPI
    코테
    Spring
    react
    취준
    백엔드
    Servlet
    JavaScript
    블로그
    db
    Java
    SpringBoot
    알고리즘
    MVC
    소개
    개발자
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
hee-on
[TIL-260722] 검증 재시도 API를 만들다 발견한 동시성 문제와 비관적 락으로 해결한 과정
상단으로

티스토리툴바