동시성 문제(Concurrency Issue): 3편. 주문만 안전하면 끝일까? 시스템 전체를 관통하는 동시성 설계와 한계 회고

2026. 7. 24. 15:08·💭Retrospective

들어가며

1편에서는 동시성 테스트를 통해 재고 정합성이 깨지는 현상을 재현했고, 2편에서는 비관적 락(Pessimistic Lock)을 통해 정합성을 보장하는 과정을 다뤘습니다. 처음에는 비관적 락 적용으로 모든 문제가 해결되었다고 생각했습니다. 하지만 구현을 진행하고 서비스를 확장할수록 한 가지 중요한 사실을 깨달았습니다. 바로, "동시성은 주문 기능 하나만 안전하게 만든다고 해결되는 문제가 아니다."

재고 데이터를 변경하는 주체는 주문 로직만이 아닙니다. 관리자의 재고 수정, 사용자 취소로 인한 재고 복구, 취소/환불 도메인 등 동일한 데이터(Inventory)에 접근하는 모든 경로가 같은 동시성 규칙 하에 제어되지 않는다면 정합성은 언제든 다시 깨질 수 있습니다.

이번 3편에서는 특정 단일 기능을 넘어서 프로젝트 전체 관점에서 동시성을 설계한 과정과 비관적 락이 가지는 명확한 한계, 그리고 아키텍처적 확장 방향에 대해 다뤄보고자 합니다.

1. 재고를 변경하는 모든 도메인 흐름의 통일

비관적 락을 적용한 뒤 가장 먼저 진행한 작업은 프로젝트 내에서 Inventory 데이터를 변경하는 모든 코드를 전수 조사하는 것이었습니다.

  • [주문 생성]  --->  재고 차감 (Inventory -)
  • [주문 취소]  --->  재고 복구 (Inventory +)
  • [관리자 수정] --->  재고 수량 변경 (Inventory =)

1) 관리자의 재고 수정

운영 중 관리자가 특정 상품의 재고 수량을 직접 수정하는 상황을 가정해 보겠습니다.

만약 사용자의 타임세일 주문(재고 차감)과 관리자의 재고 수량이 동시에 업데이트된다면, 락이 적용되지 않은 관리자의 트랜잭션으로 인해 Lost Update(갱신 손실)가 다시 발생할 수 있습니다. 따라서 관리자 재고 수정 서비스 역시 동일한 `findWithPessimisticLock` 메서드를 통해 DB 쓰기 락을 획득하도록 일관되게 설계했습니다.

2) 주문 취소와 재고 복구

주문 취소 시에도 동일합니다. 결제 실패나 사용자 요청으로 주문이 취소되면 차감되었던 재고를 다시 증가(+)시켜야 합니다.

"차감(감소) 작업만 락을 잡으면 되겠지"라고 생각하기 쉽지만, 재고 증가 작업 역시 동일한 Row를 수정하는 Write 작업입니다. 주문 생성 트랜잭션과 주문 취소 트랜잭션이 충돌할 경우를 대비하여, 재고 증가 로직 역시 동일한 비관적 락 전략 안에서 격리되도록 통일했습니다.

2. 왜 끝까지 Atomic UPDATE 대신 비관적 락이었는가?

시스템 전체의 일관성을 맞추는 과정에서,
"일부 단일 재고 수정 로직은 Atomic UPDATE가 더 효율적이지 않을까?"라는 고민을 계속했습니다.

-- Atomic UPDATE 예시
UPDATE inventory 
SET quantity = quantity - :quantity 
WHERE product_id = :productId AND quantity >= :quantity;

조회와 수정을 하나의 SQL로 원자적으로 처리하는 Atomic UPDATE는 락 대기 시간을 최소화하고 DB 성능을 극대화할 수 있는 강력한 방법입니다. 하지만 본 프로젝트의 주문 도메인은 단순 숫자 차감 그 이상의 비즈니스 트랜잭션을 품고 있었습니다.

sequenceDiagram
    actor Client
    participant OrderService
    participant Inventory
    participant OrderRepository
    participant DB

    Client->>OrderService: 주문 요청

    Note over OrderService,DB: Transaction Begin

    rect rgb(245,245,245)
        OrderService->>Inventory: ① 재고 조회 + FOR UPDATE
        Inventory->>DB: SELECT ... FOR UPDATE
        DB-->>Inventory: Lock 획득

        OrderService->>OrderService: ② 판매 가능 상태 검증

        OrderService->>Inventory: ③ 재고 차감
        Inventory->>DB: UPDATE Inventory

        OrderService->>OrderRepository: ④ Order / OrderItem 생성
        OrderRepository->>DB: INSERT
    end

    Note over OrderService,DB: Commit

    OrderService-->>Client: 주문 완료

 

만약 3번 재고 차감만 Atomic UPDATE로 분리한다면, 이후 4번이나 5번 단계에서 예외가 발생했을 때 재고는 이미 차감되었으나 주문은 생성되지 않는 비즈니스적 불일치를 수동으로 롤백하거나 복잡한 보상 트랜잭션을 구현해야 합니다. 결국 여러 도메인의 상태 변화를 하나의 ACID 트랜잭션으로 보호해야 하는 구조였기에, 비관적 락으로 전체 흐름의 시작점부터 격리하는 방식이 가장 안전한 선택이었습니다.

3. 해결책이었던 비관적 락의 명확한 한계와 비용

재고 정합성은 확보했지만, 비관적 락은 정합성을 얻는 대신 성능을 희생하는 전략입니다.
실제 부하를 가해보며 체감했던 비관적 락의 명확한 한계 4가지는 다음과 같습니다.

1. 대기 시간(Latency)의 선형적 증가

비관적 락은 선행 트랜잭션이 커밋/롤백될 때까지 후행 트랜잭션을 줄 세웁니다(Wait).
동시 요청이 100건, 1000건으로 늘어날수록 뒤쪽 요청들의 대기 시간은 선형적으로 폭증합니다.

2. 락 점유 시간 동안의 병목

락을 획득한 상태에서 외부 API를 호출하거나 불필요한 DB 조회를 수행하면, 뒤에서 대기하는 모든 요청의 응답 시간이 그만큼 지연됩니다. 이를 해결하기 위해 락 획득 후 트랜잭션 내부에서 실행되는 코드 범위를 최소한으로 다이어트해야 했습니다.

3. Connection Pool 고갈 위험 (핵심 병목)

가장 경계해야 할 부분입니다. 락을 기다리는 스레드는 아무 작업을 안 하는 것처럼 보이지만, 실제로는 DB Connection을 잡은 채 블로킹(Blocking)된 상태입니다.

flowchart TD
    A["① 동시 주문 요청 30개 유입"] --> B["② 각 요청이 SELECT ... FOR UPDATE 실행"]
    B --> C["③ 락을 기다리면서 DB Connection 점유"]
    C --> D["④ HikariCP Connection 30개 전부 사용"]
    D --> E["유휴 Connection 0개"]

    F["⑤ 31번째 일반 상품 조회 요청"] --> G["Connection 획득 요청"]
    E --> G
    G --> H["Connection 획득 대기"]
    H --> I["Connection Timeout"]
    I --> J["일반 상품 조회 실패"]

    J --> K["무관한 기능까지 장애 확산"]
    K --> L["Cascading Failure"]

    N["HikariCP Maximum Pool Size = 30"] -.-> D

 

락 경쟁이 심해지면 단순히 특정 기능이 느려지는 것을 넘어, 시스템 전체의 DB 커넥션 풀이 고갈되어 다른 무관한 서비스까지 마비되는 쇄도(Cascading Failure) 현상이 발생할 수 있습니다.

4. `LockTimeoutException` 예외 처리 설계의 필요성

무한 대기를 막기 위해 `LockTimeoutException`을 설정하지만, 이는 시간이 초과된 요청은 실패한다는 의미입니다. 비관적 락이 완벽한 성공을 보장하지는 않으므로, 백엔드에서는 다음과 같은 에러 핸들링 대책을 세워야만 했습니다.

  • 사용자 안내 ("현재 주문 요청이 몰리고 있습니다. 잠시 후 다시 시도해 주세요.")
  • 지능적 재시도(Backoff Retry) 정책 도입 여부 판단

4. 또 다른 아키텍처적 대안: 메시지 큐(MQ)를 통한 요청 직렬화

비관적 락의 한계를 체감하며 "애초에 DB 수정을 동시에 두드리지 않게 만들 수는 없을까?"라는 아키텍처적 질문에 도달했습니다.

동시성 제어가 'DB 수준에서 동시 접근을 막는 방식'이라면, 메시지 큐(Kafka, RabbitMQ)를 이용한 비동기 처리는 '동시 접근 자체를 없애는 방식'입니다.

sequenceDiagram
    actor Client as 사용자
    participant API as Order API
    participant Kafka as Kafka Topic
    participant Consumer as Order Consumer
    participant DB as Database
    participant Result as 주문 상태 저장소

    Client->>API: ① 주문 요청
    API->>Kafka: ② 주문 메시지 발행
    Kafka-->>API: 메시지 적재 완료
    API-->>Client: ③ 주문 접수 응답<br/>requestId 반환

    Note over Kafka,Consumer: 동일 상품 메시지는 동일 Partition에서 순서 보장

    Kafka->>Consumer: ④ 주문 메시지 순차 전달

    Consumer->>DB: ⑤ 재고 차감
    DB-->>Consumer: 차감 결과

    Consumer->>DB: ⑥ Order / OrderItem 생성
    DB-->>Consumer: 주문 생성 완료

    Consumer->>Result: ⑦ 처리 결과 저장

    Client->>API: ⑧ 주문 상태 조회
    API->>Result: requestId로 결과 조회
    Result-->>API: 처리 상태 반환
    API-->>Client: 주문 완료 / 실패 응답

 

메시지 큐 방식의 장단점

  • 장점: 동시 요청이 메시지 큐에 순차적으로 적재되므로 DB 스레드 간 락 경쟁이 발생하지 않습니다. DB 커넥션 풀 고갈 위험이 사라지고, Peak 트래픽을 큐가 완충해 줍니다.
  • 단점: 응답이 비동기로 처리되므로 사용자 화면에서 폴링(Polling)이나 Webhook/SSE 구조가 필요합니다. 메시지 중복 발행 방지(Idempotency), Consumer 장애 시 재처리 등 아키텍처적 복잡도가 상승합니다.

트래픽이 폭증하는 대규모 이커머스/티켓팅 시스템이라면 DB 비관적 락만으로 버티기보다는, MQ를 도입하여 락 경쟁 발생 자체를 차단하는 아키텍처 전환이 필수적이라는 점을 배울 수 있었습니다.

마치며: 기술 적용을 넘어 엔지니어링 의사결정으로

이번 동시성 시리즈를 진행하며 거친 흐름은 다음과 같았습니다.

  1. 1편: 동시성 테스트 코드를 통해 재고 정합성이 깨지는 문제를 재현하고 원인을 분석했습니다.
  2. 2편: JPA 비관적 락을 적용해 문제를 해결하고, 락 획득 순서 정렬을 통해 데드락(Deadlock) 위험을 제거했습니다.
  3. 3편: 주문 외 모든 도메인의 재고 제어 흐름을 통일하고, 비관적 락의 한계(커넥션 풀 고갈)와 MQ 기반 아키텍처라는 다음 단계까지 시야를 넓혔습니다.

돌이켜보면 이번 프로젝트의 가장 큰 수확은 단순히 @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션의 사용법을 익힌 것이 아니었습니다. "동시성 해결은 은총탄(Silver Bullet) 같은 특정 기술을 적용하는 것이 아니라, 비즈니스 요구사항 속에서 '정합성'과 '성능(처리량)' 사이의 균형점을 찾는 엔지니어링 의사결정 과정이다."

 

비관적 락은 저희 프로젝트의 현재 규모와 비즈니스 트랜잭션 복잡도 안에서 가장 합리적인 균형점이었습니다. 향후 트래픽이 커진다면 메시지 큐를 활용한 비동기 아키텍처나 Redis 기반의 분산 락 전략으로 전환하겠지만, 어떤 선택을 하든 그 뒤에 존재하는 트레이드오프를 명확히 이해하고 설계할 수 있는 시각을 갖게 된 뜻깊은 경험이었습니다.

'💭Retrospective' 카테고리의 다른 글

누구나 이해하는 클라우드 인프라 지식 사전: 백엔드는 개발해봤는데, 서비스는 어떻게 안정적으로 운영하는 사고력 키우기  (0) 2026.07.26
동시성 문제(Concurrency Issue): 2편. 비관적 락(Pessimistic Lock)으로 재고 정합성 해결하기  (0) 2026.07.24
동시성 문제(Concurrency Issue): 1편. 문제 도출! 주문 재고 동시성 문제는 어떻게 발견했는가?  (0) 2026.07.24
상품 상세 조회 API 성능 개선기: DB 부하 줄이고 데이터 정합성 지키기 (Redis, k6, Cache)  (0) 2026.07.23
클린 아키텍처 with 파이썬: 높은 자유도를 보장하는 파이썬에서 클린 아키텍처는 어떻게 구현할까?  (0) 2026.05.24
'💭Retrospective' 카테고리의 다른 글
  • 누구나 이해하는 클라우드 인프라 지식 사전: 백엔드는 개발해봤는데, 서비스는 어떻게 안정적으로 운영하는 사고력 키우기
  • 동시성 문제(Concurrency Issue): 2편. 비관적 락(Pessimistic Lock)으로 재고 정합성 해결하기
  • 동시성 문제(Concurrency Issue): 1편. 문제 도출! 주문 재고 동시성 문제는 어떻게 발견했는가?
  • 상품 상세 조회 API 성능 개선기: DB 부하 줄이고 데이터 정합성 지키기 (Redis, k6, Cache)
limdaeil
limdaeil
limdaeil 님의 블로그 입니다.
  • limdaeil
    limdaeil
    limdaeil
  • 전체
    오늘
    어제
    • 분류 전체보기 (82) N
      • 💭Retrospective (21)
      • 🥕FrontEnd (0)
      • 🐬MySQL (1)
      • 🐍Python (5)
      • 🍃SpringBoot (33) N
      • ☕Java (1)
      • ♾️Devops (1)
      • 🌎Network (2)
      • 📚Read & 👨‍🏫Course (10)
      • Programmers (1)
      • 🧪Test (7)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

    Concurrency
    redis
    Mockito
    optimistic rock
    IoC
    distributed lock
    서평단
    Python
    MySQL
    jwt
    한빛미디어
    Spring
    나는리뷰어다
    한빛아카데미
    책
    spring boot
    gradle
    DI
    회고
    junit
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
limdaeil
동시성 문제(Concurrency Issue): 3편. 주문만 안전하면 끝일까? 시스템 전체를 관통하는 동시성 설계와 한계 회고
상단으로

티스토리툴바