Project/phonebid

계약하기 버튼, 두 번 누르면 어떻게 될까 ㅡ 비관적 락 적용기

쉬지마 이굥진 2026. 4. 29. 22:45

필자는 현재 스마트폰 역경매 플랫폼 bidr(비더)를 개발 중이다. (5월 런칭 목표)

 

bidr에서 계약이 만들어지는 흐름은 이렇다. 구매자가 견적을 올리면 여러 판매자가 입찰을 넣고, 구매자가 마음에 드는 입찰을 선택하면 계약이 체결된다. 간단한 구조다.

 

문제 인식 — 이 패턴, 어디서 많이 봤는데

과거에 이커머스 프로젝트 개발 중 재고 차감 기능을 구현한 적이 있다. 여러 사용자가 동시에 같은 상품을 주문할 때 재고가 0 아래로 내려가는 걸 막아야 하는 상황이었다. 그때 낙관적 락과 Redis 분산락을 직접 써보면서 동시성 문제를 어떻게 다루는지 익히게 되었다.

 

그 경험이 있어서인지, bidr에서 계약 생성 기능을 구현하다가 코드를 보는데 뭔가 익숙하고 찝찝한(?) 느낌이 드는것이었다..!

 

'구매자가 계약하기 버튼을 두 번 누르면 어떻게 되지?' (일명 따닥)

재고 문제랑 구조가 똑같았다. SELECT 하고 검증하고 UPDATE 하는 사이에 다른 요청이 끼어드는 패턴. 재고 문제에서 이미 겪어본 그것.

계약은 반드시 단 한 번만 생성되어야 한다. 같은 견적에 계약이 두 개 생기면 어떤 입찰이 선택된 건지 알 수 없고, 금전 거래가 통째로 꼬여버린다. 비즈니스 적으로 보면 돈이 걸려있기에 재고 문제보다 더 철저해야한다.

 

코드를 다시 봤다.

// 개선 전 — @Transactional만 있고 락이 없다
@Transactional
public Contract createContract(UUID quoteId, UUID bidId, User user) {
    Quote quote = quoteRepository.findById(quoteId)...  // ← 일반 조회, 락 없음
    Bid bid = bidRepository.findById(bidId)...

    bid.select();
    quote.markContracted();

    return contractRepository.save(contract);  // ← DB 제약조건만 믿는 구조
}

 

@Transactional이 있으면 다 해결되는 거 아닌가? 그렇지 않다. @Transactional은 하나의 트랜잭션이 원자적으로 실행되는 것을 보장할 뿐, 동시에 들어온 두 요청이 같은 데이터를 동시에 읽는 것까지 막아주지는 않는다. (동시성 문제를 이미 다뤄보신 분이라면 잘 아실 것)

 

위 코드를 시간 순서로 보면 이렇다.

더블 클릭 시나리오 (락이 없을 때)

시간    요청 1 (첫 번째 클릭)          요청 2 (두 번째 클릭)
───────────────────────────────────────────────────────────────
T1      Quote 조회 → 상태: OPEN        Quote 조회 → 상태: OPEN
T2      권한 확인 통과                  권한 확인 통과
T3      Bid 조회 → 상태: ACTIVE        Bid 조회 → 상태: ACTIVE
T4      bid.select() 실행              bid.select() 실행
T5      quote.markContracted()         quote.markContracted() 시도
T6      계약 생성 → 성공 ✅             (상황에 따라)
                                     - quote 상태 체크에서 400으로 실패하거나
                                     - DB 유니크 제약 위반 예외가 나서 500으로 떨어질 수 있음
───────────────────────────────────────────────────────────────

 

여기서 중요한 건 “DB 유니크 제약이 중복 계약 저장 자체는 막아줄 수 있지만”, 그 예외가 사용자 친화적인 에러로 변환되지 않으면 사용자는 500 같은 의미 없는 에러를 받기 쉽다는 점이다.
금전 거래가 엮이는 기능에서 이 UX는 좋지 않다.

 

💥Race Condition이 발생할 수 있는 현실적인 상황들

  • 더블 클릭 : 버튼을 빠르게 두 번 누름 (가장 흔한 케이스)
  • 네트워크 재시도 : 응답이 늦어지자 재요청
  • 크로스 디바이스 : (흔하진 않지만) 스마트폰과 PC에서 동시에 같은 견적 처리

bidr는 모바일 사용자가 많을 것으로 예상된다. 지하철처럼 네트워크가 불안정한 환경에서 응답이 늦으면 버튼을 한 번 더 누르는 건 지극히 자연스러운 행동이다. 이미 비슷한 문제를 한 번 겪어봤으니, 이번엔 미리 잡기로 했다.

 

락 전략 선택

이번 동시성 문제를 해결하기 위해 생각했던 방법들은 크게 세 가지다. 낙관적 락, 비관적 락, Redis 분산락.

전략 방식 이 상황에서의 문제
낙관적 락 충돌 발생 후 예외 → 재시도 계약은 재시도가 불가능 (이미 다른 입찰이 선택됐을 수 있음)
Redis 분산락 외부 저장소로 락 관리 현재 단일 서버 구성에서 인프라 복잡도만 늘어남
비관적 락 요청 자체를 원천 차단 ✅ 계약처럼 한 번만 생성되어야 하는 리소스에 적합

 

전에 재고 동시성 문제 잡을 때 썼던 Redis 분산락부터 제외했다. 현재 비더는 단일 서버 구성이다. 외부 인프라를 추가하는 복잡도 대비 얻는 이점이 압도적이지 않다고 생각했기 때문이다.

 

그 다음은 낙관적 락을 생각했는데, 낙관적 락이 성능은 좋지만 이 상황엔 맞지 않는다. 낙관적 락은 충돌이 발생하면 재시도를 하는 구조인데 계약에서 재시도는 의미가 없다. '먼저 온 요청이 이미 특정 입찰을 선택해버렸다면, 두 번째 요청은 어떤 입찰을 선택해야 하나?' 라는 질문에 답이 없다.

 

결국 비관적 락이 가장 적합하다고 보고 선택하기로 했다. 비관적 락은 요청 자체를 DB 수준에서 직렬화해서 동시 접근을 원천 차단한다. 계약처럼 충돌이 나면 되돌릴 방법이 없는 리소스에는 비관적 락으로 동시성 보장을 확실하게 하는게 맞다고 생각했다.

구분 비관적 락 (PESSIMISTIC_WRITE) 낙관적 락 (@Version)
동시성 보장 확실 충돌 발생 시 예외
성능 낙관적 락에 비해 느림 (일반적으로) 빠름
사용자 경험 명확한 처리 결과 수신 (안정적) 즉시 실패 후 재시도 유도
구현 복잡도 간단 재시도 로직 구현 필요
데드락 위험 (확률은 낮지만) 있음 없음

 

구현 — 배타적 락 + 이중 체크

락을 걸어서 동시 접근을 막고, 락을 획득한 뒤에도 한 번 더 상태를 확인하는 로직으로 구현을 진행했다!

 

1. 배타적 락으로 조회

PESSIMISTIC_WRITESELECT ... FOR UPDATE 쿼리를 통해 행을 선점한다. 한 트랜잭션이 락을 획득하면, 다른 트랜잭션은 데이터를 수정하거나 같이 락을 걸고 조회하는 작업을 수행하기 위해 앞선 트랜잭션이 종료 (커밋/롤백) 될 때까지 기다려야 한다.

(락을 사용하지 않는 일반적인 SELECT는 MVCC 덕분에 대기 없이 조회가 가능하다)

💡MVCC(Multi-Version Concurrency Control) 기술
대부분의 RDBMS(MySQL InnoDB, PostgreSQL 등)는 MVCC 기술을 사용한다.

- 일반 SELECT: 락이 걸려 있어도 Undo Log 등을 통해 이전 버전의 데이터를 즉시 읽어옴
- SELECT ... FOR UPDATE: "나 이거 고칠 거니까 아무도 건드리지 마"라고 선언하는 조회. 이때는 먼저 락을 잡은 트랜잭션이 끝날 때까지 대기해야 함
// QuoteRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT q FROM Quote q WHERE q.id = :id")
Optional<Quote> findByIdWithLock(@Param("id") UUID id);

// BidRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT b FROM Bid b WHERE b.id = :id")
Optional<Bid> findByIdWithLock(@Param("id") UUID id);

 

생성되는 SQL은 개념적으로 이런 형태다.

SELECT * FROM quotes WHERE id = ? FOR UPDATE;
-- 이 행에 대해 다른 트랜잭션이 락을 걸거나 수정하려 하면 대기

 

2. 락 획득 후 이중 체크

락을 획득했다고 끝이 아니다. 두 요청이 거의 동시에 들어왔을 때, 첫 번째 요청이 락을 쥐고 계약을 생성한 뒤 락을 해제하면 두 번째 요청이 락을 획득한다. 이 시점에서 아무 체크 없이 진행하면 두 번째 요청도 계약 생성을 시도하게 된다.

(아래의 existsByQuoteId() 메서드)

@Transactional
public Contract createContract(UUID quoteId, UUID bidId, User user) {
    // 1. 비관적 락으로 견적 조회 (배타적 락 획득)
    Quote quote = quoteRepository.findByIdWithLock(quoteId)
            .orElseThrow(() -> new CustomException(AuctionErrorCode.QUOTE_NOT_FOUND));

    // 2. 권한 확인
    if (!quote.getUser().getId().equals(user.getId())) {
        throw new CustomException(AuctionErrorCode.QUOTE_NOT_OWNED_BY_USER);
    }

    // 3. 이중 체크 — 락 획득 후에도 계약 존재 여부 재확인
    if (contractRepository.existsByQuoteId(quoteId)) {
        throw new CustomException(TradeErrorCode.CONTRACT_ALREADY_EXISTS);
    }

    // 4. 견적 상태 확인
    if (!quote.getStatus().isOpen()) {
        throw new CustomException(AuctionErrorCode.INVALID_QUOTE_STATUS);
    }

    // 5. 비관적 락으로 입찰 조회
    Bid bid = bidRepository.findByIdWithLock(bidId)
            .orElseThrow(() -> new CustomException(AuctionErrorCode.BID_NOT_FOUND));

    // 6. 입찰이 해당 견적에 속하는지 확인
    if (!bid.getQuote().getId().equals(quoteId)) {
        throw new CustomException(TradeErrorCode.INVALID_BID_FOR_QUOTE);
    }

    // 7. 입찰 상태 변경 → 견적 상태 변경 → 계약 생성 (순서 중요)
    bid.select();
    bidRepository.save(bid); // JPA의 변경 감지 사용해도 되지만 명확한 의도를 위해 save 호출

    quote.markContracted();
    quoteRepository.save(quote);
    // 부모 리소스인 Quote의 상태를 마지막에 변경하여 데이터 정합성을 맞춤

    Contract contract = Contract.builder()
            .quote(quote)
            .selectedBid(bid)
            .build();

    return contractRepository.save(contract);
}

 

물론 이 경우에도 quote.markContracted() 내부에서 상태 체크가 막아준다. Quote 상태가 이미 CONTRACTED로 바뀌어 있기 때문이다.

그런데 이 경우 사용자에게 돌아가는 에러INVALID_QUOTE_STATUS("견적 상태가 올바르지 않습니다")다. 두 번째 요청을 보낸 사람 입장에서는 무슨 상황인지 알기 어렵다.

 

그래서 락을 획득한 직후, 계약이 이미 존재하는지 한 번 더 확인한다. (existsByQuoteId 메서드) 이미 계약이 있으면 직접 정의한 커스텀 에러를 던진다.

if (contractRepository.existsByQuoteId(quoteId)) {
    throw new CustomException(TradeErrorCode.CONTRACT_ALREADY_EXISTS);
    // → 409 "이미 해당 견적에 대한 계약이 존재합니다."
}

 

정리해보면, 동시성 안전성 자체는 비관적 락 + 상태 체크 + DB 유니크 제약이 담당한다. existsByQuoteId 이중 체크는 거기에 더해 두 번째 요청을 보낸 사람에게 '이미 계약이 완료됐다'는 명확한 메시지를 돌려주기 위한 사용자 친화적 장치인 것!

에러 비교
──────────────────────────────────────────────────────
이중 체크 없을 때  → INVALID_QUOTE_STATUS (400)
                    "견적 상태가 올바르지 않습니다" — 맥락 파악 어려움
이중 체크 있을 때  → CONTRACT_ALREADY_EXISTS (409)
                    "이미 해당 견적에 대한 계약이 존재합니다" — 명확 ✅
──────────────────────────────────────────────────────

 

여기까지 개선 후 아까와 동일한 시나리오가 어떻게 처리되는지 보면?!

개선 후 — 더블 클릭 시나리오
───────────────────────────────────────────────────────────────
시간    요청 1 (첫 번째 클릭)          요청 2 (두 번째 클릭)
───────────────────────────────────────────────────────────────
T1      Quote 락 획득 ✅               Quote 락 대기 중...
T2      이중 체크 통과 (계약 없음)
T3      상태 변경 + 계약 생성 완료 ✅
T4      트랜잭션 커밋 → 락 해제         Quote 락 획득 ✅
T5                                     이중 체크 → 이미 계약 존재
T6                                     → 409 에러 반환 ✅
───────────────────────────────────────────────────────────────

 

 

 

3. 락 획득 순서 고정 — 데드락 방지

비관적 락의 약점은 데드락이다. 확률은 크지 않지만 요청 A가 Quote 락을 쥐고 Bid 락을 기다리는 동안, 요청 B가 Bid 락을 쥐고 Quote 락을 기다리면 서로 영원히 기다리는 상황(=deadlock)이 생길 수 있다.

 

이를 방지하기 위해 항상 같은 순서로 락을 획득하도록 고정했다. Quote를 먼저, Bid를 나중에. 두 요청 모두 이 순서를 따르면 서로를 기다리는 상황이 생기지 않는다.

Quote quote = quoteRepository.findByIdWithLock(quoteId);  // ← 항상 1순위
Bid bid = bidRepository.findByIdWithLock(bidId);          // ← 항상 2순위

 

3중 방어 매커니즘

최종적으로 중복 계약을 막는 레이어는 세 개다.

계약 중복 방지 3중 방어
───────────────────────────────────────────────────────────────
1차 방어    비관적 락 (DB 레벨 직렬화)
            → 동시 요청을 순서대로 처리
               ↓
2차 방어    existsByQuoteId() 이중 체크 (애플리케이션 레벨)
            → 락 획득 후에도 계약 존재 여부 재확인, 두 번째 요청에 대해 409로 명확한 ux 제공
               ↓
3차 방어    DB 유니크 제약조건 (quote_id UNIQUE)
            → 혹시 위 두 개를 뚫더라도 DB가 최종 차단
───────────────────────────────────────────────────────────────

 

1, 2차 방어가 있음에도 3차를 남겨둔 이유는 코드 레벨에서 놓치는 엣지 케이스가 항상 존재하기 때문이다. DB 제약조건은 비용이 거의 없는 최후의 보루라고 생각하면 될 것 같다.

 

사용자 경험 변화

상황 개선 전 개선 후
더블 클릭 DB 에러 (500) '이미 계약이 존재합니다' (409)
네트워크 재시도 불안정한 동작 안전하게 직렬화 처리
크로스 디바이스 한쪽은 성공, 한쪽은 500 에러 먼저 온 요청만 성공, 나머지는 명확한 메시지

 

500 에러와 409 에러는 다르다. 500은 '서버가 뭔가 잘못됐다'는 신호고, 409는 '이미 처리된 요청입니다'라는 의미 있는 응답이다. 사용자 입장에서, 그리고 프론트엔드에서 에러를 처리하는 입장에서도 훨씬 나을 것이다 😊

 

성능 영향은 얼마나 될까

비관적 락을 쓰면 성능이 나빠지는 거 아닌가? 맞다. 락 대기 시간이 추가된다. 하지만 이 케이스에서는 문제가 되지 않는다.

 

계약 생성은 자주 발생하는 작업이 아니다. 초기 프로젝트 특성 상 하루 수십~수백 건 수준이 될 것이라고 판단했고, 동시에 같은 견적에 요청이 몰리는 확률은 매우 낮다. 실제로 락 경합이 발생하는 케이스 자체가 드물기 때문에 성능 영향은 허용 가능한 수준이라고 판단했다.

 

금전 거래의 정확성이 성능보다 우선이라고 보고 이 선택을 했다.

 

추후 구현 사항

현재 구현에서 한 가지 더 고려해볼 수 있는 게 있다.

락 타임아웃 설정이다. 지금은 락 대기에 타임아웃이 설정되어 있지 않다. 트래픽이 갑자기 몰려서 락 대기가 길어지면 요청이 무한정 대기하는 상황이 생길 수 있다.

// 추후 적용 예정
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
    @QueryHint(name = "javax.persistence.lock.timeout", value = "3000") // 3초 타임아웃
})
Optional<Quote> findByIdWithLock(@Param("id") UUID id);

 

3초 안에 락을 획득하지 못하면 타임아웃 예외를 던지고 사용자에게 '잠시 후 다시 시도해주세요'를 반환하는 방식이다. 현재 트래픽 수준에서는 당장 필요하지 않지만 런칭 후 트래픽이 늘어나면 챙겨야 할 부분이다.

 

마무리

재고 차감 기능에서 동시성 문제를 한 번 겪어봤기 때문에, 계약 생성 설계할 때부터 비슷한 문제 있겠는데 하고 바로 인식할 수 있었다. 평소 개발 스터디로 여러 서적이나 개념 공부하면서 익혀둔 것도 확실히 도움이 됐다.

 

이번에 직접 시나리오를 그려보면서 다시 한번 확인한 것들이 있다.

 

@Transactional은 하나의 트랜잭션이 원자적으로 실행되는 것을 보장할 뿐, 동시에 들어온 여러 트랜잭션이 같은 데이터를 읽는 것까지 막지 못한다. 그 간극을 채우는 게 락이다.

 

낙관적 락이냐 비관적 락이냐는 "충돌이 났을 때 재시도할 수 있는 작업인가" 로 판단하면 선택이 빨라진다.

 

재고 차감은 충돌이 나도 재시도가 가능하다. 재고가 1개 남은 상품을 두 명이 동시에 담았을 때, 한 명에게 "다시 시도해주세요"를 돌려줘도 크게 문제없으니 → 낙관적 락.

계약은 다르다. 기술적으로 트랜잭션 롤백은 가능하지만, 비즈니스적으론 재시도가 불가능하다. 첫 번째 요청이 이미 특정 입찰을 선택해버렸다면, 두 번째 요청은 어떤 입찰을 선택해야 하는가? 이미 선택된 입찰을 또 선택할 수도없고, 다른 입찰을 고르자니 사용자 의도와 달라져 답이 없으니 → 요청 자체를 원천 차단하는 비관적 락!

 

같은 동시성 문제라도 리소스의 성격에 따라 락 전략이 달라진다. 이번에 두 케이스를 직접 비교해보면서 그 판단 기준이 좀 더 선명해진 것 같다 😊