[Spring Boot] 이커머스 프로젝트로 알아보는 동시성 제어

곽태민·2025년 11월 20일

TIL

목록 보기
72/72

왜 동시성 제어인가?

이커머스 서비스를 개발하다 보면 가장 신경 쓰이는 부분은 바로 데이터 정합성이다. 혼자 테스트를 통해서 실행했던 기능들이 수천 명이 동시에 "결제"나 "선착순 쿠폰 발급"을 받으려고 할 때 순식간에 무너질 수 있다.

그래서 공부하면서 진행한 이커머스 프로젝트에서 발생한 동시성 이슈를 해결하기 위해 비관적 락(Perssimistic Lock), 낙관적 락(Optimistic Lock), 그리고 분산 락(Distributed Lock) 을 어떻게 적용했는지, 그리고 분산 락 적용 시 겪었던 트랜잭션 범위 문제를 어떻게 해결했는지 정리하려고 한다.


동시성 제저 기법 비교 (개념편)

프로젝트에 적용하기 앞서, 세 가지 락의 특징을 간단히 짚고 넘어가자면

비관적 락 (Perssimistic Lock)

  • 개념: 충돌이 발생할 수 있다는 상황을 비관적으로 가정하면서 데이터를 읽을 때부터 DB Lock을 걸어버리는 방식이다. (SELECT ... FOR UPDATE)
  • 장점: 데이터 정합성을 강력하게 보장한다.
  • 단점: 락을 잡고 있는 동안 다른 트랜잭션이 대기를 해야하기 때문에 성능 저하가 발생할 수 있고, Deadlcok 위험이 있다.
  • 적용처: 주문, 결제 (데이터의 절대적인 무결성이 속도보다 중요하다고 판단)

낙관적 락 (Optimistic Lock)

  • 개념: 여러 서버(또는 인스턴스)가 공통으로 사용하는 저장소(주로 Redis)를 이용해서 락을 제어.
  • 장점: DB 부하를 줄일 수 있고, 분산 환경에서 정합성을 보장.
  • 단점: 구현이 복잡하면서 별도의 인프라(Redis 등)가 필요
  • 적용처: 선착순 쿠폰 발급 (짧은 시간에 트래픽이 몰리는 구간).

시나리오별 적용 전략

현재 이 프로젝트에서는 각 비즈니스 로직 특성에 맞추어 다른 전략으로 진행했다.

Case 1 - 주문 및 결제 (비관적 락)

주문과 결제는 포인트 차감, 재고 감소 등 데이터의 정확성이 최우선이다. 트래픽이 쿠폰만큼 순간적으로 폭발할 가능성은 적지만, 동시에 같은 주문 건을 처리하는 것을 막기 위해 비관적 락을 사용했다.

// Repository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Payment p where p.id = :id")
fun findByIdForUpdate(id: Long): Payment?

Case 2 - 선착순 쿠폰 발급 (분산 락)

선착순 이벤트는 DB 락으로만 제어를 한다면 수많은 대기열이 DB 커넥션을 점유하면서 전체 서비스 장애로 이어질 수 있다. 따라서 Redis를 이용한 분산 락을 도입했다.

하지만, 여기서 "분산 락과 DB 트랜잭션의 범위" 문제가 있었다.


문제 상황 - 분산 락을 썼는데 데이터 정합성 ❌

처음에는 아래와 같은 구조로 코드를 짰다. (데이터 정합성을 위해서 DB 락까지 이중으로 건 상황)

  • 기존 로직 (문제점)
    1. @Transactional 시작
    1. 분산 락 획득 (Redis)
    2. 불안해서 DB 비관적 락도 획득
    3. 쿠폰 발급 로직 수행
    4. 분산 락 해제
    5. @Transactional 커밋

이렇게 분산 락 + DB 비관적 락 을 혼용하게 되면 데드락 위험이 커지면서 성능도 떨어진다. 목표는 "DB 락 없이 분산락만으로 깔끔하게 처리하는 것" 이었다.

하지만 단순히 DB 락을 빼고 분산 락만 사용한다면 동시성 이슈가 다시 발생했다. 이유는 트랜잭션 커밋 시점과 락 해제 시점이 불일치했다.

  1. Thread A가 로직을 다 수행하고 락을 해제함.
  2. 하지만 Thread A의 DB 트랜잭션은 아직 커밋되지 않음 (DB 반영전)
  3. 그 사이 Thread B가 락을 획득하고 데이터를 읽음. -> Thread A의 변경 상항을 못 보고 과거 데이터를 읽음
  4. 결국 정합성 깨짐.

해결 Facade 패턴으로 락 범위 제어하기

이 문제를 해결하기 위해서는 "락의 범위가 트랜잭션의 범위보다 커야 한다." 즉, 트랜잭션이 완전히 커밋된 후에 락을 풀어야 한다.

이를 위해서 Facade 패턴을 적용하여 비즈니스 로직(트랜잭션)을 락으로 감싸는 구조로 리팩토링했다.

  1. 사용자가 쿠폰 발급 시 Redisson Lock을 획득하고 획득 성공 여부 판단
  2. 쿠폰 발급 트랜잭션을 시작하고 쿠폰을 발급하는 로직 실행.
  3. 트랜잭션 커밋 -> DB 반영
  4. 획득한 Redisson Lock 해제

코드 예시 (Kotlin)

CouponService (트랜잭션 담당) 순수한 비즈니스 로직만 담당하며, @Transactional을 가진다.

@Service
class CouponService(
	private val couponRepository: CouponRepository
) {
	@Transactional // 트랜잭션은 여기서만 동작.
    fun issueCoupon(couponId: Long, userId: Long) {
    	val coupon = couponRepository.findById(couponId)
        	?: throw IllegalArgumentException("쿠폰이 없습니다.")
            
        coupon.decreaseQuantity() // 수량 감소

		// 발급 로직
    }
}

CoupnFacade (락 제어 담당) 트랜잭션 없이 락의 획득 및 해제만 담당하면서, 실제 로직은 Service에 위임한다.

@Component
class CouponFacade(
	private val redissonClient: RedissonClient,
    private val couponSerivce: CouponService
) {
	fun issueCouponWithLock(userId: Long, couponId: Long) {
    	val lock = redissonClient.getLock("coupon_lock:$couponId")
        
        try {
        	// 락 획득 시도
			val available = lock.tryLock(10, 1, TimeUnit.SECONDS)
            
            if (!available) {
            	return
            }
            
            couponService.issueCoupon(couponId, userId)
        } catch (e: InterruptedException) {
        	throw RuntimeException(e)
        } finally {
        	if(lock.isLocked && lock.isHeldByCurrentThread) {
                lock.unlock()
            }
        }
    }
}

결론

이번 프로젝트를 통해서 상황에 맞는 동시성 제어 방식이 무엇인지 깊이 고민할 수 있었다.

  1. 비관적 락: 데이터 정합성이 중요한 결제/주문 로직에 적합하다고 판단.
  2. 분산 락: 트래픽이 몰리는 선착순 이벤트에 적합하며, DB 부하를 줄인다.
  3. 트랜잭션과 락의 범위: 분산 락 사용 시 반드시 "Lock 획득 -> Transaction 시작/종료 -> Lock 해제" 순서를 지켜야 데이터 정합성이 깨지지 않는다.

단순히 "락을 걸었다"에서 끝나는게 아니라, DB의 격리 수준과 트랜잭션의 생명주기기까지 고려해야 완벽한 동시성 제어가 가능하단걸 알게되었다.

profile
Node.js 백엔드 개발자입니다!

0개의 댓글