[루퍼스/루프백 2기] 이커머스 동시성 제어 및 낙관적 락과 비관적 락 선택 기준

2025. 11. 17. 18:40·외부활동/루퍼스 2기

TL;DR

  • 이커머스에서 동시성 문제가 일어나는 부분과 제어 기술에 대해 알아봅니다.
    • 재고 차감
    • 쿠폰 사용
    • 포인트 사용
    • 좋아요
  • 인터넷에 보편적으로 알려진 내용 말고도 비관적 락과 낙관적 락의 선택 기준을 여러 관점에서 판단합니다.
  • 락을 잡는것 보단 Lock-Free한 방식으로 해결할 수 없는지 고민하는 습관을 가집시다.

서론

 

제가 작성한 '주문 요청' 시퀀스 다이어그램 일부입니다.

주문 요청한 상품의 재고 수와 사용자의 포인트도 충분한지 검증합니다.

검증을 통과한 후 주문 요청 처리를 위한 작업을 시작하는데요.

빨강색 박스는 재고 차감 요청을, 파랑색 박스는 포인트 차감 요청을 처리합니다.

 

요청이 일정한 간격으로 한건씩 들어온다면 문제는 발생하지 않습니다. 하지만 무신사 블랙프라이데이 할인, 네고왕 이벤트와 같이 사용자가 몰리는 환경에선 문제가 발생할 확률이 기하급수적으로 증가합ㄴ디ㅏ.

 

상품 주문에 성공해서 결제까지 했는데 재고가 없다는 연락을 받으면 사용자들은 어떤 기분이 들까요?

상품을 구매할 수 있는 포인트를 가지고 있어, 주문 및 결제를 했는데 결제에 실패했다는 알림을 받으면

유저는 더 이상 해당 서비스를 사용하고 싶지 않을 겁니다.


동시성 문제 개념과 종류

동시성 문제

동시성 문제(Concurrency Problem)는 여러 스레드 또는 프로세스가 동시에 공유 자원(Shared Resource)에 접근하여 읽기/쓰기  작업을 수행할 때, 실행 순서나 타이밍에 따라 예상치 못한 결과가 발생하는 현상을 의미합니다.

 

공유 자원이이란?

공유 자원은 여러 트랜잭션이나 스레드가 동시에 접근할 수 있는 데이터를 말합니다. 

 

이커머스 시스템에서 대표적인 공유 자원의 예시:

  • 상품 재고 수량: 여러 사용자가 동시에 주문할 때
  • 사용자 포인트 잔액: 한 사용자가 여러 기기에서 동시에 결제할 때
  • 쿠폰 사용 여부: 동일한 쿠폰으로 여러 주문을 시도할 때

대표적인 동시성 문제 종류는 다음과 같습니다.

Race Condition 둘 이상의 실행 흐름이 공유 자원에 동시에 접근해서 결과가 뒤엉침
Deadlock (교착 상태) 둘 이상이 서로 자원을 점유한 채 상대방을 기다리다 멈춤
Livelock 서로 양보하느라 아무 일도 못 함 (움직이지만 진전 없음)
Starvation 특정 스레드가 계속 CPU 할당을 못 받아 작업을 못 함

 

이번 포스팅에서는 Race Condition과 Deadlock을 탐구해 보겠습니다.

 

Race Condition

여러 프로세스나 스레드가 공유 자원에 접근할 때, 실행 순서나 타이밍에 따라 결과가 달라지는 상황을 의미합니다

 

Race Condition은 "읽기-수정-쓰기" 혹은 "읽기-쓰기" 패턴에서 주로 발생합니다.

balance = balance - 100;

 

이 한줄은 컴퓨터 입장에서 보면 아래와 같은 3단계로 이루어 집니다.

1. balance 값을 메모리에서 읽고 (읽기)

2. 100을 빼고 (수정)

3. 다시 메모리에 쓴다 (쓰기)

 

위 3단계가 하나의 원자적 연산이 아니면 사이에 다른 스레드가 끼어 들어 문제가 발생합니다.

위와 같은 패턴이 보인다면 동시성 문제가 일어나지 않도록 제어 기술이 필요합니다.

 

lost udpate 문제

Lost Update는 동시성 문제의 대표적인 유형 중 하나로, 두 개 이상의 트랜잭션이 같은 데이터를 동시에 수정할 때, 한 트랜잭션의 변경 사항이 다른 트랜잭션에 의해 덮어씌워져 사라지는 현상입니다.

 

총 재고가 10개가 있습니다.

고객 A는 재고 3개를 주문요청을 했고, 고객 B는 재고 5개를 주문요청을 했습니다.

재고는 2개가 남아야 하지만, lost update 문제가 발생하여 최종 재고는 5개가 남았습니다.

고객 B의 주문이 고객 A의 주문을 덮어씌워 사라졌습니다.

 

 

 

위 두 문제 모두 Race Condition이 발생하여 그 결과로 lost update 문제가 발생했습니다.

Race Condition이 발생

  • 두 요청(트랜잭션, 스레드)이 공유 자원(재고, 포인트)을 동시에 읽고 업데이트
  • 실행 순서에 따라 최종 결과가 달라짐

lost update 문제

  • 두 요청 중 하나가 사라짐. 즉, Update가 Lost 됨

lost update 현상이 발생한 이유는 "읽기-수정-쓰기" 패턴이고 비 원자적 연산이기 때문입니다.

 

💡 원자적 연산이란?
“더 이상 쪼갤 수 없는 연산”으로, 중간에 끼어들 수 없고, 실패 없이 전부 다 성공하거나 전혀 실행되지 않은 것처럼 보이는 연산입니다.
멀티스레드 환경에서 여러 스레드가 동시에 어떤 값을 읽고 수정하려고 할 때, 중간에 다른 스레드가 끼어들면 데이터가 꼬일 수 있습니다. 하지만 원자적 연산이라면, 그 연산이 진행되는 동안 다른 어떤 스레드도 끼어들 수 없습니다. 연산이 끝날 때까지 완전히 보호됩니다.

 

Deadlock

Deadlock(교착 상태)은 두 개 이상의 트랜잭션이 서로가 점유한 자원을 기다리면서 무한정 대기하는 상태를 의미합니다. 각 트랜잭션은 자신이 필요로 하는 자원을 다른 트랜잭션이 점유하고 있어 진행할 수 없고, 동시에 자신이 점유한 자원을 해제하지도 않아 서로 영원히 대기하게 됩니다.

실제 이커머스 사례: 주문 처리 중 Deadlock

시나리오: 두 고객이 서로 다른 순서로 동일한 상품들을 주문

 

위와 같이 서로 다른 트랜잭션이 순서가 다른 자원에 Lock 점유를 동시에 요청하게 되면 서로를 막게 되어 데드락이 발생합니다.

 

Deadlock 레스토랑 비유

두 명의 손님이 동시에 레스토랑에서 식사를 주문합니다. 이 레스토랑은 특이하게도 재료를 직접 손님 테이블에 가져다주고, 손님이 조리할 때까지 그 재료를 다른 손님에게 줄 수 없는 규칙이 있습니다.

 

둘 다 영원히 기다려, 누구도 요리를 완성하지 못합니다. 주방장(데이터베이스)이 개입해서 한 명의 주문을 취소시켜야 합니다.

MySQL의 경우 자동으로 데드락을 감지하고 하나의 트랜잭션을 롤백시킵니다.

Deadlock 예방 전략

자원 접근 순서 통일

항상 같은 순서로 락을 획득합니다. 

 

예시

주문 요의 일부인 재고 차감시 상품 ID를 먼저 정렬한 후에 재고를 조회 후 감소 시킵니다.

// ❌ Deadlock 위험
orderItems.forEach { item ->
    stock = stockRepository.findByIdWithLock(item.productId)
    stock.decrease(item.quantity)
}

// ✅ Deadlock 방지
orderItems
    .sortedBy { it.productId }  // ID 오름차순 정렬
    .forEach { item ->
        stock = stockRepository.findByIdWithLock(item.productId)
        stock.decrease(item.quantity)
    }

 

 

락 타임아웃 설정

락 타임 아웃을 설정하여 무한 대기를 방지하고 빠른 실패로 시스템 안정성을 확보합니다.

 

예시

spring-data-jpa의 @QueryHints를 사용하여 구현할 수 있습니다.

interface StockJpaRepository : JpaRepository<Stock, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
    @Query("SELECT s FROM Stock s WHERE s.productId IN :productIds ORDER BY s.productId ASC")
    fun findAllByProductIdIn(productIds: List<Long>): List<Stock>
}

 

 

락 범위 최소화

필요한 순간에만 짧게 락을 유지합니다. 외부 API와 같은 의존성은 트랜잭션 밖에서 처리하여 범위를 최소화 합니다.

 

예시

❌ 잘못된 예시: 넓은 락 범위

@Transactional
fun createOrder(userId: Long, items: List<OrderItem>): Order {
    // 🔴 트랜잭션 시작 - 여기서부터 락 시작
    
    // 재고 차감 (락 필요 ✅)
    items.forEach { stock.decrease(it.quantity) }
    
    // 포인트 차감 (락 필요 ✅)
    point.use(totalAmount)
    
    // 주문 저장 (락 필요 ✅)
    val order = orderRepository.save(order)
    
    // 외부 API 호출 (락 불필요 ❌) - 평균 1~2초 소요
    externalApi.send(order)
    
    // 이메일 발송 (락 불필요 ❌) - 평균 500ms 소요
    emailService.send(order)
    
    // 🔴 트랜잭션 종료 - 총 2~3초간 락 유지됨!
    return order
}

 

✅ 올바른 예시: 좁은 락 범위

// OrderFacade.kt
fun createOrder(userId: Long, items: List<OrderItem>): Order {
    // 1. 트랜잭션 안: 핵심 작업만 (약 100~200ms)
    val order = orderService.saveOrderTransaction(userId, items)
    
    // 2. 트랜잭션 밖: 외부 연동 작업
    externalApi.send(order)
    emailService.send(order)
    
    return order
}

// OrderService.kt
@Transactional
fun saveOrderTransaction(userId: Long, items: List<OrderItem>): Order {
    // 🟢 트랜잭션 시작 - 필수 작업만!
    items.forEach { stock.decrease(it.quantity) }
    point.use(totalAmount)
    val order = orderRepository.save(order)
    // 🟢 트랜잭션 종료 - 락 즉시 해제!
    return order
}

 


동시성 제어 기술

낙관적 락과 비관적 락은 데이터베이스 트랜잭션에서 동시성 제어를 위한 주요 기법입니다. 데이터 무결성을 유지하면서 여러 트랜잭션이 동시에 데이터에 접근할 때 발생할 수 있는 충돌을 해결할 때 사용됩니다.

낙관적 락(optimistic Lock)

 

낙관적 락(Optimistic Lock)은 데이터 충돌이 적을 것으로 가정하고, 데이터를 읽을 때 락을 설정하지 않고 트랜잭션이 데이터를 수정할 때(commit 시점) 충돌이 발생하지 않았는지 확인하는 방식입니다. 보통 version과 같은 별도의 구분 컬럼을 사용해서 데이터가 변경되었는지 확인하며, 충돌이 발생하면 데이터베이스가 아닌 애플리케이션에서 직접 롤백하거나 재시도 처리를 해야 합니다.

재시도 처리는 순서 보장이 안되며, 자칫 잘못하면 디비 부하를 줄 수 있습니다.

💡 version 말고 update 시간으로는 안되나요?
극한의 상황을 가정했을 때는 문제점이 생길 수 있습니다. 시간 값은 최대 밀리세컨드까지 표현 가능한데, 우연찮게 값이 같아 버리면 업데이트가 여러건 될 수 있습니다. version값은 mysql의 auto-increment같이 순차적으로 증가하고 각 값마다 다른게 보장이 됩니다. 이러한 특성만 만족 한다면 반드시 version값이 아니여도 됩니다.

 

또한, 다른 트랜잭션에서 버전을 증가시켜서 수정을 못하고 OptimisticLockException 예외를 던집니다. OptimisticLockException은 RuntimeException이기 때문에 @Transactional로 감싼 메서드에서 이전 수정사항들이 롤백이 발생합니다.

 

낙관적 락을 걸더라도 트랜잭션의 범위를 최소화 시키는게 중요합니다.

JPA의 버전에 대한 업데이트는 커밋시점에 날라 갑니다. 어차피 실패할 사항인데, 이후 로직이 많이 걸리면 불필요한 커넥션을 물고 있기 때문입니다.

 

 

낙관적 락 재시도 패턴

핵심 문제: 영속성 컨텍스트

문제 상황

@Transactional
fun retryInsideTransaction() {
    repeat(3) { attempt ->
        try {
            val stock = stockRepository.findById(1L)  // 첫 조회: Stock(quantity=10, version=1)
            stock.decrease(3)
            stockRepository.save(stock)  // OptimisticLockException 발생!
        } catch (e: OptimisticLockException) {
            // ⚠️ 문제: 다시 조회해도 영속성 컨텍스트가 같은 엔티티를 반환!
            // stockRepository.findById(1L) → 여전히 Stock(quantity=10, version=1)
            // 실제 DB는 version=2로 바뀌었는데도!
        }
    }
}

낙관적 락 예외 발생시 재시도를 같은 트랜잭션 내에서 하게 되면 DB의 최신 상태를 가져오지 못합니다.

 

이런 일이 발생하는 이유는같은 트랜잭션 내에서는 1차 캐시(영속성 컨텍스트)가 유지됩니다.

findById(1L)을 다시 호출해도 캐시된 엔티티가 반환 되어 실제 DB의 최신 상태를 가져오지 못하게 됩니다.

 

따라서, 트랜잭션 바깥에서 재시도를 시도 해야 합니다.

// Facade Layer
@Service
class StockFacade(
    private val stockService: StockService
) {
    // 트랜잭션 없음 - 재시도 제어만
    fun decreaseStockWithRetry(productId: Long, quantity: Int) {
        var retryCount = 0
        val maxRetries = 3
        var success = false
        
        while (!success && retryCount < maxRetries) {
            try {
                // 🔑 핵심: 매번 새로운 트랜잭션 시작
                stockService.decreaseStock(productId, quantity)
                success = true
                
            } catch (e: OptimisticLockException) {
                retryCount++
                
                if (retryCount >= maxRetries) {
                    throw StockUpdateFailedException("재고 차감 실패: 최대 재시도 초과")
                }
                
                // 재시도 전 대기
                Thread.sleep(50L * retryCount)
            }
        }
    }
}

 

// Service Layer
@Service
class StockService(
    private val stockRepository: StockRepository
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun decreaseStock(productId: Long, quantity: Int) {
        // 🔑 새로운 트랜잭션이므로 매번 새로운 영속성 컨텍스트
        val stock = stockRepository.findById(productId)
            .orElseThrow { ProductNotFoundException() }
        
        stock.decrease(quantity)
        stockRepository.save(stock)
        
        // 트랜잭션 종료 시 영속성 컨텍스트도 종료
    }
}

 

 

비관적 락(Pessimistic Lock)

비관적 락(Pessimistic Lock) 은 데이터 충돌이 많을 것으로 가정하고, 트랜잭션이 시작될 때 공유락(Shared Lock, S-Lock) 또는 베타락(Exclusive Lock, X-Lock)을 설정하여 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 하는 방식입니다.

 

💡 S-Lock과 X-Lock

S-Lock: 다른 트랜잭션에서 읽기는 가능하지만 쓰기는 불가능합니다.
X-Lock: 다른 트랜잭션에서 읽기, 쓰기 모두 불가능합니다.
    cf. MySQL은 일관된 읽기(Consistent Nonlocking Reads)를 지원하여 X-Lock이 걸려있어도 단순 SELECT로 읽을 수 있습니다

 

 

일관된 읽기(Consistent Nonlocking Reads)

https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html

 

MySQL :: MySQL 8.0 Reference Manual :: 17.7.2.3 Consistent Nonlocking Reads

17.7.2.3 Consistent Nonlocking Reads A consistent read means that InnoDB uses multi-versioning to present to a query a snapshot of the database at a point in time. The query sees the changes made by transactions that committed before that point in time, a

dev.mysql.com

 

낙관적  락과 비관적 락의 차이점

첫 번째는 충돌 가능성입니다. 낙관적 락은 충돌이 자주 발생하지 않을 것이라고 가정하고, 비관적 락은 충돌이 자주 발생할 것이라고 가정합니다.

 

두 번째는 데이터베이스 락 사용 여부입니다. 낙관적 락은 락을 사용하지 않고, 비관적 락은 트랜잭션이 시작될 때 락을 설정합니다.

 

세 번째는 성능입니다. 낙관적 락은 락을 설정하지 않기 때문에 성능이 더 좋을 수 있습니다. 하지만 충돌이 발생할 경우 롤백하거나 재시도 처리를 해야 하기 때문에 성능이 떨어질 수 있습니다. 비관적 락은 락을 설정하기 때문에 다른 트랜잭션이 대기해야 하며, 이로 인해 성능이 저하될 수 있습니다.

 

결론적으로 낙관적 락은 충돌이 발생하면 해결하는 방식이고, 비관적 락은 애초에 충돌을 방지하는 방식입니다.

 

낙관적  락과 비관적 락 선택 기준

단순히 "충돌이 많으냐 적으냐"가 아니라, 비즈니스 관점에서 어떤 결과를 원하는가가 핵심입니다.

1. 공정성 (Fairness): 순서가 중요한가?

비관적 락: 순서 보장 필요

  • 먼저 요청한 사람이 먼저 처리됨 (FIFO)
  • 대기하더라도 순서대로 처리
  • 사용자 입장에서 "공정하다"고 느낌

예시: 이커머스 재고 차감

10:00:00에 주문한 A
10:00:01에 주문한 B
10:00:02에 주문한 C
→ A, B, C 순서대로 처리되어야 함
→ 순서가 중요하므로 비관적 락

낙관적 락: 순서 무관

  • 먼저 커밋하는 사람이 성공
  • 늦게 요청했어도 빨리 커밋하면 이김
  • 순서 보장 안 됨

예시: 좌석 예약

A, B, C가 동시에 같은 좌석 예약 시도

누가 먼저 요청했는지는 중요하지 않음
한 명만 성공하면 됨 (나머지는 다른 좌석 선택)
→ 순서가 중요하지 않으므로 낙관적 락

 

2. 다음 트랜잭션을 성공시켜야 하는가?

비관적 락: 모든 요청을 성공시켜야 함

  • 대기하더라도 순차적으로 모두 처리
  • "재고가 있으면 모두 주문 성공해야 함"
  • 3초 정도 대기는 사용자가 수용 가능

예시: 재고 차감

상황: 재고 10개, 주문 10건 (각 1개씩)
기대: 10건 모두 성공해야 함
→ 비관적 락으로 순차 처리
→ 늦게 온 요청도 대기 후 성공
 

낙관적 락: 한 명만 성공하면 됨

  • 실패한 사람은 다른 선택지로 이동
  • "한 자리는 한 명만" - 나머지는 실패해도 OK
  • 실패한 사람에게 대안 제공 (다른 좌석)

예시: 좌석 예약

상황: A석 1개, 예약 시도 10명
기대: 1명만 성공, 나머지 9명은 다른 좌석 선택
→ 낙관적 락
→ 실패한 사람은 B석, C석 등 선택
→ 다음 트랜잭션을 성공시킬 필요 없음
 

3. 비즈니스 중요도

비관적 락: 핵심 비즈니스, 정합성 최우선

  • 절대 실패하면 안 되는 작업
  • 데이터 정합성이 매우 중요
  • 약간의 성능 희생은 감수

이커머스 핵심 영역:

  • 포인트/결제: 돈 관련은 절대 틀리면 안 됨
  • 재고 차감: 판매 가능한 재고는 모두 팔아야 함
  • 쿠폰 사용: 한 번만 사용해야 함 (중복 사용 절대 불가)

낙관적 락: 부가 기능, 실패 허용

  • 실패해도 비즈니스에 큰 영향 없음
  • 사용자 경험은 중요하지만 필수는 아님

부가 기능 영역:

  • 좋아요: 실패해도 다시 시도 가능
  • 조회수: 정확하지 않아도 큰 문제 없음
  • 댓글 작성: 재시도하면 됨

 

4. 사용자 대기 시간 수용도

사용자는 3초 정도는 기다린다

  • 결제, 주문 같은 중요한 작업은 3초 대기 OK
  • "처리 중입니다..." 메시지만 잘 보여주면 됨
  • → 비관적 락의 대기 시간은 수용 가능

이커머스 동시성 발생 지점 제어 기술 채택

재고 차감 (비관적 락)

재고 차감은 비관적 락을 선택했습니다.

  • 이커머스에서 재고는 핵심 비즈니스 입니다.
  • 공정성이 있어야 하며, 모든 요청을 성공시켜야 합니다.
interface StockJpaRepository : JpaRepository<Stock, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
    @Query("SELECT s FROM Stock s WHERE s.productId IN :productIds ORDER BY s.productId ASC")
    fun findAllByProductIdIn(productIds: List<Long>): List<Stock>
}

 

재고 조회시 @Lock(LockModeType.PESSIMISTIC_WRITE)를 사용해 비관적 락을 구현 하였습니다.

@QueryHints를 사용하여 락 대기 타임아웃을 부여했습니다.

 

포인트 사용 (비관적 락)

포인트 사용은 비관적 락을 선택했습니다.

  • 이커머스에서 포인트는 돈과 관련있기 때문에 정합성이 매우 중요합니다.
  • 공정성이 있어야 하며, 모든 요청을 성공시켜야 합니다.
interface PointJpaRepository : JpaRepository<Point, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
    fun findByUserId(userId: Long): Point?
}

 

마찬가지로 포인트 조회시 @Lock(LockModeType.PESSIMISTIC_WRITE)를 사용해 비관적 락을 구현 하였습니다.

@QueryHints를 사용하여 락 대기 타임아웃을 부여했습니다.

 

쿠폰 사용 (낙관적 락)

쿠폰 사용은 낙관적 락을 선택했습니다.

  • 쿠폰은 어떤 요청이 성공하든 상관 없이 단 1번만 사용하면 됩니다. 
  • 누가 먼저 성공하든 상관없으므로 공정성이 없어도 됩니다.
  • 1번만 사용할 수 있기 때문에, 나머지 요청은 빠르게 실패 시켜줘야 합니다. 재고 처럼 대기할 필요가 없습니다.
  • 동일한 사용자가 가지고 있는 쿠폰을 쓰는 특성상 충돌은 매우 드물기 때문에(중복 클릭, 네트워크 지연으로 인한 재시도, 여러 기기에서 동시 주문) 낙관적 락을 선택하였습니다.
하지만, 이커머스에서 쿠폰은 돈과 관련있기 때문에 정합성이 중요해서 비관적 락을 선택할 수도 있습니다

 

CouponIssue.kt

// 생략
import jakarta.persistence.Version
// 생략

@Entity
@Table(
    name = "coupon_issue",
    indexes = [
        Index(name = "idx_coupon_issue_user_id", columnList = "user_id"),
        Index(name = "idx_coupon_issue_coupon_id", columnList = "coupon_id"),
    ],
)
class CouponIssue(
    val couponId: Long,

    val userId: Long,

    @Enumerated(EnumType.STRING)
    var status: CouponStatus = CouponStatus.ISSUED,

    var usedAt: ZonedDateTime? = null,

    val issuedAt: ZonedDateTime = ZonedDateTime.now(),

    @Version // 추가 
    var version: Long = 0 // 추가

)

동시성을 보호할 엔티티에 version 필드, @Version 어노테이션을 추가합니다.

 

CouponService.kt

@Service
class CouponService(
    private val couponRepository: CouponRepository,
    private val couponIssueRepository: CouponIssueRepository,
) {

    @Transactional
    fun applyCoupon(userId: Long, couponId: Long?, totalAmount: Long): Long {
        if (couponId == null) {
            return 0
        }

        val coupon = couponRepository.findById(couponId)
            ?: throw CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다: $userId")
        val couponIssue = couponIssueRepository.findBy(userId, couponId)
            ?: throw CoreException(ErrorType.NOT_FOUND, "사용자가 발급 받은 적 없는 쿠폰입니다. userId: $userId, couponId: $couponId")

        couponIssue.use()
        return coupon.calculateDiscount(totalAmount)
    }
    // commit 시점에 version 비교
}

coupon과 couponIssue를 조회하고 사요처리 하는 로직은 그대로 냅두면 됩니다.

applyCoupon 메서드가 종료 후 @Transacitonal 훅이 걸린 aop의 내부 동작에서 commit 시점에 version을 비교해서합니다.

따라서 couponIssue.use()에 try-catch를 걸어도 catch문을 타지 않습니다.

의도한 예외를 던지려면 applyCoupon 메서드를 호출하는 상위 계층에서 try-catch로 감싸야 합니다.

 

@Component
class OrderFacade(
    private val couponService: CouponService,
    private val userService: UserService,
) {

    private val log = LoggerFactory.getLogger(OrderFacade::class.java)

    @Transactional
    fun placeOrder(userId: String, couponId: Long?, items: List<OrderDetailCommand>) {
        val user = userService.getMyInfo(userId)
		 
        // 생략
        
        // 쿠폰 할인 금액 계산 (쿠폰 사용 중복 발생시 exception을 catch하여 예외를 던짐)
        val discountAmount = try {
            couponService.applyCoupon(user.id, couponId, totalAmount)
        } catch (e: ObjectOptimisticLockingFailureException) {
            log.debug("쿠폰 중복 사용 시도 무시: user=${user.id}, couponId=${couponId}")
            throw CoreException(ErrorType.COUPON_ALREADY_USED, "이미 사용된 쿠폰입니다")
        }

		// 생략
    }
}

 

중복 쿠폰이 사용되면 빠르게 실패 처리를 하기 위해 ObjectOptimisticLockingFailureException 예외를 잡아서 의도한 예외를 던집니다.

 

좋아요 등록 (DB Unique)

@Entity
@Table(
    name = "product_like",
    uniqueConstraints = [
        UniqueConstraint(
            name = "uk_product_user",
            columnNames = ["ref_product_id", "ref_user_id"],
        ),
    ],
)
class ProductLike(

    @Column(name = "ref_product_id", nullable = false)
    val productId: Long,

    @Column(name = "ref_user_id", nullable = false)
    val userId: Long,

    ) : BaseEntity() {

 

좋아요 정보를 저장하는 ProductLike에서 (productId, userId) 인덱스를 부여하였습니다. 사용자는 한 상품당 한 개의 좋아요 등록/취소가 가능합니다.

 

// 동시에 100명이 같은 상품에 좋아요를 눌러도
// DB가 최종 방어선 역할
ProductLike(productId = 1, userId = 1) // ✅ 성공
ProductLike(productId = 1, userId = 1) // ❌ UniqueConstraintViolation
ProductLike(productId = 1, userId = 1) // ❌ UniqueConstraintViolation
// ...
ProductLike(productId = 1, userId = 1) // ❌ 100번째도 실패

 

unique constraint를 사용하면 DB 레벨에서 데이터 정합성을 보장해줍니다.

락이 필요없기 때문에 코드가 간결하고 비관적 락에 비해 성능이 우수합니다.

 

좋아요는 "한 명만 성공"이 아니라 "중복만 방지"하면 됩니다.

따라서, 락으로 순차 처리할 필요가 없습니다.

 

애플리케이션 레벨에서 select 하여 멱등성 검증

ProductLikeService.kt

@Transactional
fun like(product: Product, user: User) {
    // 1. 먼저 일반 SELECT로 존재 여부 확인 (Lock 없이)
    if (productLikeRepository.existsBy(product.id, user.id)) {
        return // 멱등성 보장
    }
    productLikeRepository.save(ProductLike.create(product.id, user.id))
}

멱등성 보장을 위해애플리케이션 레벨에서 select를 날려 존재 여부를 확인합니다

 

ProductLikeFacade.kt

@Transactional
    fun like(productId: Long, userId: String) {
        val user = userService.getMyInfo(userId)

        val product =
            productService.getProduct(productId) ?: throw CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다: $productId")

        try {
            productLikeService.like(product, user)
        } catch (e: DataIntegrityViolationException) {
            // 이미 좋아요가 존재하는 경우 - 무시 (멱등성)
            // 예외를 상위로 전파하지 않고 조용히 처리
            log.debug("중복 좋아요 시도 무시: productId=${product.id}, userId=${user.id}")
        }
    }

 

ProductLikeFacade에선 DataIntegrityViolationException 예외를 catch하여 처리(warn 로그)하면, 사용자 입장에서도 멱등성이 보장됩니다.

 

upsert(insert ignore)

exist와 같은 select 쿼리를 줄이고 싶으면 upsert 방식을 사용하면 됩니다.

interface ProductLikeRepository : JpaRepository<ProductLike, Long> {
    
    @Modifying
    @Query(value = """
        INSERT IGNORE INTO product_likes 
            (product_id, user_id, created_at, updated_at, deleted_at)
        VALUES 
            (:productId, :userId, NOW(), NOW(), NULL)
    """, nativeQuery = true)
    fun insertIgnore(
        @Param("productId") productId: Long,
        @Param("userId") userId: Long
    )
}

 

일반 INSERT를 했을 경우 unique 제약조건에 위반도면 에러가 발생합니다.

INSERT IGNORE 구문은 unique 중복이 발생할 경우, 에러를 발생시키지 않을뿐더러 INSERT를 하지 않고 넘어갑니다.

상위 계층에서 try-catch를 하지 않아도 됩니다.

따라서, 중복 항목을 방지하고 데이터의 무결성을 유지할 수 있습니다.

 

Mysql 유니크 제약 조건에서 DeadLock이 발생한다

mysql 8.0 공식 문서에 따르면 여러 트랜잭션이 동시에 같은 값을 INSERT하려다 중복 키 오류가 발생하면, 공유 잠금(Shared Lock)경쟁으로 인해 데드락이 발생할 수 있습니다. 

즉,  "중복 키 오류가 발생하면, MySQL은 해당 레코드에 Shared Lock을 설정합니다."

 

 

Step 1: Session 1이 성공적으로 INSERT

-- Session 1
START TRANSACTION;
INSERT INTO t1 VALUES(1);  -- ✅ Exclusive Lock 획득
  • Session 1이 행 i=1에 대한 배타적 잠금(Exclusive Lock) 을 획득
  • 아직 커밋하지 않은 상태

 

Step 2: Session 2, 3이 중복 INSERT 시도

-- Session 2
START TRANSACTION;
INSERT INTO t1 VALUES(1);  -- ❌ Duplicate Key Error → Shared Lock 대기

-- Session 3
START TRANSACTION;
INSERT INTO t1 VALUES(1);  -- ❌ Duplicate Key Error → Shared Lock 대기
  • 중복 키 오류 발생
  • MySQL은 중복된 인덱스 레코드에 공유 잠금(Shared Lock) 을 요청
  • Session 1의 Exclusive Lock이 풀릴 때까지 대기

Step 3: Session 1 롤백 → 데드락!

-- Session 1
ROLLBACK;  -- Exclusive Lock 해제

 

문제 발생:

  1. Session 1의 Exclusive Lock이 해제됨
  2. Session 2, 3 모두 Shared Lock을 획득함
  3. 이제 둘 다 INSERT를 완료하려면 Exclusive Lock이 필요
  4. 서로가 가진 Shared Lock 때문에 Exclusive Lock 획득 불가
  5. 💥 데드락!

 

해결책은 직전 글의 내용 처럼 애플리케이션 레벨에서 존재 여부 확인 or INSERT IGNOR 방법을 적용하면 됩니다.

 

lock-Free한 방식

동시성 문제는 Lock을 잡지 않은 방식으로 해결할 수 있으면 Best 입니다. 이를 Lock-Free한 방식이라 부르기도 합니다.

락을 잡는 것 자체가 성능 비용이고, DB마다 Lock 메커니즘이 다르기 때문에 가능하면 Lock 없이 해결하는 방법을 먼저 고민해보는 것이 좋습니다.

  • 수정 없이 DB에 insert만 하는 방식
  • CAS(Compare And Swap)
  • mysql의 skip locked, insert ignore
  • 메시지 큐
  • Batch

동시성 제어를 디비 없이 메모리 단에서 할수도 있습니다. 리소스(s3, 동영상 등)에서 락을 제공해주지 않지만 동시 접근을 통제해야 할때 인메모리에서 CAS 개념이 적용된 라이브러리(ConcurrentHashMap)를 사용할 수 있습니다.


마무리: 학습 후 느낀 점

막연하게 알고 있던 동시성 문제 개념과 종류, 낙관적 락 비관적 락의 동작 방식, 선택 기준을 알 수 있었습니다.

동시성 문제는 많은 개발자가 고민하고 관심있는 주제입니다. 이번 기회에 확실히 알아가기 위해 글을 정리하였습니다.

 

제가 생각하는 가장 좋은 방식은 Lock을 잡지 않는 Lock-Free한 방식이 좋은 것 같습니다. 락을 잡는것 자체가 큰 비용이고, DB 마다 Lock 메커니즘이 다르기 때문입니다. 동시성 제어 기술을 선택할 때 단편적으로 성능 관점만 보는 것이 아닌 비즈니스 상황과 요구사항을 분석해서 상황에 맞게 적절한 선택이 중요하다는 것을 깨달았습니다.

 

 

루프백 3기 오픈 

https://www.loopers.im/education

 

교육 과정 | Loop:Pak

Loop:Pak 부트캠프 교육 과정 상세 정보. NextNode Backend Edition을 포함한 전문 개발자 양성 커리큘럼과 현업 멘토링 프로그램을 만나보세요.

www.loopers.im

 

루퍼스 3기가 오픈되었습니다.

 

추천인 코드를 입력하면 할인가로 수강할 수 있습니다. 다만 결코 작은 금액은 아니기에, 신청 전 적지 않은 고민을 했는데요. AI 기술의 빠른 발전과 함께 연차가 쌓이면서, 스스로의 실력에 대한 의구심이 들던 시기였습니다.

 

며칠간 고민한 끝에 이번 기회를 마지막이라 생각하고 수강을 결정했습니다. 지금 돌이켜보면, 후회 없는 선택이었다고 자신 있게 말할 수 있습니다.

 

만약 저와 비슷한 고민을 하고 있거나, 루퍼스 과정에 대해 궁금한 점이 있다면 언제든지 편하게 연락 주셔도 좋습니다.

LinkedIn 또는 jikimee64@gmail.com으로 언제든 문의 부탁 드립니다. 감사합니다.

 

추천인 코드

TZFI0

 

 

 

저작자표시 (새창열림)

'외부활동 > 루퍼스 2기' 카테고리의 다른 글

[루퍼스/루프백 2기] 조회 성능을 높이는 방법을 설명할 수 있나요?  (0) 2025.11.22
[Loop:PAK] 4주차 WIL  (0) 2025.11.17
[Loop:PAK] 3주차 WIL  (0) 2025.11.14
[루퍼스/루프백 2기] 도메인을 보호하고 객체간 협력을 지키는 구현 방법  (0) 2025.11.12
[Loop:PAK] 2주차 WIL  (0) 2025.11.06
'외부활동/루퍼스 2기' 카테고리의 다른 글
  • [루퍼스/루프백 2기] 조회 성능을 높이는 방법을 설명할 수 있나요?
  • [Loop:PAK] 4주차 WIL
  • [Loop:PAK] 3주차 WIL
  • [루퍼스/루프백 2기] 도메인을 보호하고 객체간 협력을 지키는 구현 방법
소프
소프
  • 소프
    기회는 찬스
    소프
  • 전체
    오늘
    어제
    • 분류 전체보기 (138)
      • Language (20)
        • Java (19)
        • Design Pattern (1)
      • Tech (27)
        • Spring (19)
        • JPA (3)
        • QueryDSL (1)
        • Gradle (4)
      • 개발 생각 (1)
      • IT Book (0)
        • 자바_스프링 개발자를 위한 실용주의 프로그래밍 (0)
      • Database (4)
        • Mysql (4)
        • Redis (0)
      • 네트워크 (1)
      • 운영체제 (2)
      • IDE (12)
        • Intellij (9)
        • vscode (2)
        • datagrip (1)
      • 인프라 (11)
        • Docker (2)
        • Jenkins (7)
        • Github Actions (1)
        • GCP (1)
      • 형상관리 (2)
        • gitlab (2)
        • git (0)
      • 외부활동 (44)
        • CS 면접 끝장내기 - 데이터베이스 1기 (5)
        • CS 면접 끝장내기 - 컴퓨터 네트워크 2기 (5)
        • CS 면접 끝장내기 - 자바 2기 (0)
        • TDD, 클린 코드 with Java 17기 (7)
        • ATDD, 클린 코드 with Spring 8기 (6)
        • 루퍼스 2기 (21)
      • 프로젝트 (0)
      • 회고 (6)
      • 기타 (1)
        • 제미니의 개발 실무 (0)
  • 블로그 메뉴

    • 홈
    • Github
    • LinkedIn
    • 방명록
  • 인기 글

  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.6
소프
[루퍼스/루프백 2기] 이커머스 동시성 제어 및 낙관적 락과 비관적 락 선택 기준
상단으로

티스토리툴바