TroubleShooting & Study/SpringBoot

[Concurrency] 비즈니스 특성으로 풀어보는 동시성 제어 방식의 선택 기준

DH_0518 2026. 3. 6. 14:54

TL;DR

처음에는 동시성 제어를 '낙관적 락 vs 비관적 락' 선택 문제로만 봤다. 그런데 실제로는 '애초에 발생 가능한 문제인지', 'UPDATE 문제인지', 'INSERT 중복 문제인지', '무엇을 보호해야 하는지'부터 나눠보는 편이 훨씬 설득력 있었다.

 

 

이커머스 기능을 개발하다 보면 동시성 문제를 자주 만나게 된다.

이번에도 주문, 쿠폰, 좋아요 도메인을 다듬는 과정에서 비슷해 보이지만 성격은 전혀 다른 문제들이 한꺼번에 튀어나왔다.

  • 여러 사용자가 동시에 같은 상품을 주문하는 경우 -> 정확한 재고 차감
  • 쿠폰 사용은 정말 동시성 제어가 필요한 문제인지 -> 과잉 방어 걷어내기
  • 같은 주문 요청이 재전송되는 경우 -> 주문 1회만 생성
  • 좋아요가 동시에 몰리는 경우 -> 정확한 카운트 반영
  • 같은 사용자가 같은 쿠폰을 다시 발급받는 경우 -> 중복 발급 차단

 

처음에는 '낙관적 락을 쓸까, 비관적 락을 쓸까' 정도의 문제라고 생각했다.

동시성 제어라고 하면 제일 먼저 떠오르는 것도 보통 그 두 가지였기 때문이다.

 

그런데 실제 테스트와 코드 흐름을 따라가 보니, 생각보다 그렇게 단순하지 않았다.

대표적으로 좋아요 수 증가 로직은 동시성 테스트에서 'expected: 10L but was: 7L'로 실패했다.

 

여기서 의문이 들었다.

애초에 이걸 락 문제로만 보는게 맞을까?

 

그때부터는 '낙관적 락이냐 비관적 락이냐'를 고르는 일보다 먼저, '이 유스케이스가 정말 락으로 풀어야 하는 문제인가'부터 다시 보기 시작했다.

이번 글에서는 그 과정을 바탕으로, 동시성 제어 방식을 기술 이름이 아니라 비즈니스 특성으로 다시 고른 이유를 정리해보려 한다.

 

그중에서도 가장 먼저 이상 신호를 준 건 좋아요 수 증가 로직이었다.

 

 


 

분석

좋아요 수 증가 로직의 구조는 단순했다.

  1. 상품을 읽는다.
  2. 도메인 모델에서 'likeCount++'를 수행한다.
  3. '@Version'으로 충돌을 검사하면서 저장한다.
  4. 충돌하면 '@Retryable'로 다시 시도한다.

 

문제는 10개 요청이 동시에 들어오면, 대부분의 스레드가 같은 버전을 읽는다는 점이다.

Thread 1~10: 모두 version=0 조회
Thread 1: UPDATE ... WHERE version=0 성공
Thread 2~10: OptimisticLockingFailureException

 

 

재시도를 걸어도 상황은 크게 달라지지 않는다.

2차 시도: 9개가 다시 같은 최신 버전을 읽음
그중 1개만 성공, 나머지는 또 충돌
...
maxAttempts 소진 후 일부 요청 실패

 

그래서 테스트 결과가 '10'이 아니라 '7'이 나왔다.

 

여기서 중요한 건 '낙관적 락이 고장났다'가 아니라, 검증도 계산도 거의 없는 단순 카운터에 'read-modify-write + 충돌 재시도' 구조를 얹은 것 자체가 과했다는 점이었다.

 


원인

원인은 기술 하나를 잘못 쓴 것이 아니라, 동시성 문제를 너무 빨리 락 문제로만 단정한 데 있었다.

 

처음에는 낙관적 락과 비관적 락 중 무엇을 쓸지부터 고민했다.

그런데 실제로는 그 전에 먼저 답해야 할 질문이 있었다.

 

정리해보면 먼저 갈라야 할 기준은 세 가지 정도였다.

  1. 애초에 이 '동시성 문제가 실제로 발생 가능한가?'
  2. 이 문제는 '기존 row를 수정하는 UPDATE 문제인가?'
  3. 아니면 '중복 INSERT를 막는 문제인가?'

이 셋은 비슷해 보일 수 있어도, 실제로는 판단 기준이 꽤 다르다.

 

UPDATE에서의 선택 기준

UPDATE 전략을 고르기 전에, 먼저 한 번 더 멈춰서 보게 된 질문이 있었다.

  • 정말 같은 자원에 대한 동시 수정이 현실적으로 발생하는가?
  • 아니면 "혹시 그럴 수도 있지 않을까?"라는 가정 위에 방어를 과하게 올리고 있는가?

 

쿠폰 사용은 여기에서 다시 보게 된 케이스였다.

같은 row를 여러 요청이 수정할 때는 세 가지 방식이 후보가 된다.

방식 맞는 상황 핵심 포인트
원자적 카운터 단순 증감, 검증 없음 DB가 단일 SQL로 처리
낙관적 락 경합 낮음, 상태 변경 로직 복잡함 충돌 시 재시도
비관적 락 경합 높음, 정확성 절대적 읽는 시점부터 잠금

 

 

여기서 가장 중요했던 질문은 의외로 기술 질문이 아니었다.

  • 이 변경은 '단순히 +1/-1' 하면 끝나는가?
  • 사용자는 '빠른 응답'을 기대하는가?
  • 아니면 '조금 기다려도 정확한 결과'를 기대하는가?

 

이 시점에서 UPDATE 쪽 기준은 대략 이랬다.

Q0. 애초에 동시성 경합이 현실적으로 발생하는가?
  No  -> 락 불필요 (과잉 방어 제거)
  Yes -> Q1

Q1. 단순 증감이고, 복잡한 검증이 없는가?
  Yes -> 원자적 카운터
  No  -> Q2

Q2. 경합이 낮고, 빠른 응답이 더 중요한가?
  Yes -> 낙관적 락
  No  -> 비관적 락

 

돌아보면 UPDATE에서 계속 보게 된 건 '락의 종류' 자체보다 비즈니스 로직의 복잡도와 경합 수준, 그리고 UX 기대치였다.

 

INSERT에서의 선택 기준

INSERT는 다른 질문으로 봐야 했다.

여기서 중요한 건 '중복이 왜 발생했는가'다.

 

방식 해결하려는 문제 중복 발생 시 처리
UNIQUE constraint 비즈니스 규칙 위반 에러
Idempotency key 네트워크 재전송 기존 결과 반환

 

이 차이를 구분하지 않으면 이름도 꼬이고 설계도 꼬인다.

  • 쿠폰을 두 번 발급받는 것은 비즈니스 규칙 위반이다.
  • 같은 주문 요청이 네트워크 문제로 두 번 도착한 것은 재전송이다.

둘 다 겉으로는 "중복"이지만, 의미가 다르다.

그리고 의미가 다르면 방어 방식도 달라져야 한다.

Q1. 중복은 비즈니스 규칙 위반인가?
  Yes -> UNIQUE constraint
  No  -> Idempotency key

 

 

여기서 결국 남는 질문은 하나였다.

중복이 에러여야 하는가, 아니면 같은 요청의 재도착으로 봐야 하는가?

 

 


해결

이제 각 유스케이스를 다시 보면 선택 기준이 꽤 선명해진다.

 

좋아요 수는 원자적 카운터로 정리했다

좋아요 수 증가는 사실상 이 한 줄이 전부다.

this.likeCount++;

 

여기에 재고처럼 '부족하면 예외', 쿠폰처럼 '상태 전이 + 만료 검증' 같은 로직이 없다.

그렇다면 도메인 모델 전체를 읽고, 버전 충돌을 감수하고, 재시도 루프를 돌릴 이유도 약하다.

게다가 좋아요는 인기 상품일수록 경합이 높고, 사용자는 버튼을 누른 뒤 즉각적인 반응을 기대한다.

 

그래서 실제로는 아래처럼 단일 SQL로 처리하도록 바꿨다.

UPDATE products
SET like_count = like_count + 1
WHERE id = ?;

 

좋아요 수는 '도메인 로직을 풍부하게 태워야 하는 상태 변경'이라기보다, DB가 가장 잘하는 단순 원자 연산으로 보는 편이 더 자연스러웠다.

실제로도 도메인 모델을 읽고 다시 저장하는 흐름 대신, 단일 UPDATE로 좋아요 수를 증감하는 방식으로 정리했다.

 

재고 차감은 비관적 락을 유지했다

반대로 재고 차감은 단순 카운터가 아니다.

  • 'stock >= quantity' 검증이 필요하다.
  • 플래시 세일처럼 높은 경합이 실제로 발생한다.
  • 잘못 차감되면 초과 판매로 바로 이어진다.

이 경우 사용자는 "결제 버튼을 눌렀는데 조금 기다렸으니 틀려도 된다"라고 생각하지 않는다.

조금 느려도 정확한 결과를 기대한다.

 

그래서 재고 차감은 'SELECT ... FOR UPDATE' 기반 비관적 락을 그대로 유지했다.

이 유스케이스는 조금 기다리더라도 정확해야 하는 문제에 더 가깝기 때문이다.

 

쿠폰 사용은 과잉 방어를 걷어냈다

쿠폰 사용은 또 다르다.

  • 1장의 쿠폰은 1명의 사용자가 가진다.
  • 한 사람이 같은 쿠폰을 동시에 두 번 사용할 이유는 거의 없다.
  • 즉, "정말 여기서 동시성 문제가 발생하는가?"부터 다시 물어봐야 했다.

처음에는 상태 변경, 만료 검증, 할인 계산이 함께 묶여 있으니 동시성 제어를 붙여야 하나 싶었다.

하지만 다시 생각해보니, 그 판단 자체가 "복잡한 상태 변경이 있으니 일단 락을 걸자"에 가까웠다.

 

더 근본적인 질문은 따로 있었다.

애초에 발생하지 않는 문제를 막기 위해 복잡한 방어를 올리고 있었던 건 아닐까?

 

그래서 쿠폰 사용은 락 전략을 더 다듬는 방향보다, 과잉 방어를 걷어내는 쪽으로 정리했다.

  • 버전 필드 제거
  • 재시도와 충돌 복구 로직 제거
  • 동시 사용 충돌을 전제로 한 예외 제거

이번 케이스에서는 "복잡한 상태 변경 + 낮은 경합 = 낙관적 락"이라는 공식보다, 그 경합이 실제로 성립하는지부터 확인하는 편이 더 중요했다.

 

쿠폰 발급과 좋아요 행은 UNIQUE constraint로 정리했다

쿠폰 발급에서 가장 어색했던 부분은 'idempotencyKey'라는 이름이었다.

 

'userId + couponTemplateId' 조합으로 중복 발급을 막고 있었다면, 그건 사실 멱등성 키라기보다 복합 유니크 제약으로 표현해야 하는 비즈니스 규칙에 가깝다.

 

왜냐하면 쿠폰 발급 중복은 "같은 요청이 다시 왔다"가 아니라, "같은 사용자가 같은 쿠폰을 두 번 가지면 안 된다"는 규칙의 문제이기 때문이다.

 

그래서 실제로는 다음처럼 정리했다.

  • 처음에 열어뒀던 '사용자당 동일 쿠폰 N개 발급' 설계를 걷어내고, 요구사항을 1인 1쿠폰으로 정리
  • '(user_id, coupon_template_id)' 복합 유니크 제약으로 1인 1쿠폰 정책 보장
  • 요구사항 변경에 맞춰 'maxIssuePerUser' 필드와 count 기반 검증 제거
  • 로컬 캐시가 대부분의 중복 요청을 먼저 차단
  • DB 복합 유니크 제약이 최종적으로 데이터 무결성을 보장

 

좋아요 행도 같은 맥락이다.

기존처럼 아래 흐름으로 가면 동시에 두 요청이 "없다"는 결과를 보고 둘 다 INSERT할 수 있다.

findLike()
if empty -> createLike()

 

그래서 사전 조회는 유지하되, 최종 방어선은 애플리케이션 'if' 문이 아니라 DB 제약 쪽에 두도록 정리했다.

  • '(user_id, target_type, target_id)' 복합 유니크 제약 추가
  • 사전 조회('findLike')로 대부분의 중복 요청은 멱등하게 반환
  • DB 유니크 제약이 최종적으로 데이터 무결성을 보장

즉, 쿠폰 발급과 좋아요 행은 둘 다 INSERT 중복 방지 문제였고, 사전 조회나 캐시는 유지하되 최종 방어선은 DB 유니크 제약에 두는 구조가 더 설득력 있었다.

 

주문 생성의 멱등성은 Order가 직접 소유하도록 바꿨다

주문은 쿠폰 발급과 반대다.

 

주문 요청이 두 번 들어오는 이유는 종종 비즈니스 규칙 위반이 아니라 네트워크 재전송이다.

응답을 받지 못한 클라이언트가 같은 요청을 다시 보내는 경우가 있기 때문이다.

 

이 경우에는 중복을 에러로 처리하기보다, 같은 요청이면 같은 결과를 돌려주는 멱등성이 필요하다.

다만 그렇다고 해서 멱등성 키 전용 도메인과 전용 테이블을 별도로 둘 필요까지 있는지는 별개 문제다.

 

이번 케이스에서는 주문 생성에서만 쓰는 값이었고, TTL 기반 범용 프레임워크로 확장할 계획도 없었다.

그래서 요청 ID를 'Order'가 직접 소유하게 만드는 쪽으로 바꿨다.

  • 'Order'에 요청 ID 필드 추가
  • '(user_id, request_id)' 유니크 제약 추가
  • 기존 멱등성 키 전용 테이블/리포지토리/매퍼/테스트 제거
  • 주문 생성에서만 쓰이던 멱등성 전용 파일 약 14개 정리

 

이렇게 바꾸면 "주문의 멱등성"이 더 이상 바깥 구조물에 흩어지지 않는다.

적어도 이번 구조에서는, 주문이 자기 요청 식별자를 스스로 가지는 편이 더 단순하고 이해하기 쉬웠다.

 


검증

이번 글이 기술 비교로만 끝나지 않으려면, 바꾼 뒤 무엇을 확인했는지도 함께 적어야 했다.

 

이미 확인한 실패는 분명했다.

  • 좋아요 수에 낙관적 락 + 재시도를 적용했을 때, 동시성 테스트에서 '10 -> 7'로 실패했다.

 

변경 후에는 아래 항목들을 실제로 확인했다.

항목 반영/확인 내용
좋아요 수 증가 원자적 카운터로 변경해, 증가/감소가 단일 UPDATE를 타도록 정리했다.
재고 차감 동시 차감 테스트에서 정확한 재고 차감과 품절 처리 흐름을 확인했다.
쿠폰 사용 과잉 방어를 제거한 뒤에도 쿠폰 적용과 상태 전이가 정상적으로 동작하는 것을 확인했다.
쿠폰 발급 로컬 캐시 중복 차단과 '(user_id, coupon_template_id)' 복합 유니크 제약 반영을 확인했다.
좋아요 행 생성 likes 테이블에 '(user_id, target_type, target_id)' 복합 유니크 제약을 반영했다.
주문 생성 동일 요청 ID로 재요청했을 때 기존 주문을 반환하는 E2E 흐름을 확인했다.

 

중요했던 건 "락을 걸었다"가 아니라, 그 락 또는 제약이 해당 유스케이스의 비즈니스 요구를 실제로 만족하는지 테스트로 확인하는 것이었다.

 


정리

이번에 가장 크게 배운 건 동시성 제어를 기술 이름으로 외우면 자꾸 헷갈린다는 점이었다.

  • UPDATE는 '단순 증감인가', '복잡한 검증이 있는가', '경합과 UX 기대치가 어떤가'로 봐야 한다.
  • INSERT는 '비즈니스 규칙 위반인가', '네트워크 재전송인가'로 나눠야 한다.

기술과 비즈니스 특성이 어긋나면 테스트가 실패하고, 구조는 불필요하게 복잡해지고, 심지어 이름까지 어색해진다.

 

결론은 다음과 같다.

  • 좋아요 수처럼 단순 증감은 원자적 카운터
  • 재고처럼 정확성이 절대적인 변경은 비관적 락
  • 쿠폰 사용처럼 실제 경합이 성립하지 않는 경우는 과잉 방어부터 걷어내기
  • 쿠폰 발급, 좋아요 행처럼 비즈니스 중복 방지는 UNIQUE constraint
  • 주문 재전송 방지는 Idempotency key

돌아보면 동시성 제어는 '어떤 락이 더 좋아 보이는가'보다, '이 요청이 무엇을 보호해야 하는가'에서 출발하는 편이 훨씬 덜 흔들렸다.

이번 변경도 결국 그 기준으로 정리되었다.