본문 바로가기

Backend

동시성 문제 해결 (Kotlin, Spring Boot)

반응형

안녕하세요!
오늘은 정말 중요한 동시성 문제 해결 방식을 이야기해보려고 합니다.
최근 이 부분에 대해서 관심이 생기면서 한번 정리하면 좋을까 했습니다.

예를들면 네이버 쇼핑, 인터파크 같은 수많은 사용자가 동시에 몰려드는 서비스에서는 단순히 락만 걸어서는 한계가 있어요.
그래서 어떤 구조를 쓰면 좋을지, 그리고 실제로 Kotlin + Spring Boot 예제 코드로 어떻게 구현할 수 있는지도 같이 알아볼게요.

왜 동시성 문제가 발생할까?

주문/예약 시스템에서는 재고(상품/좌석/티켓)가 제한적이죠.
예를 들어 100개의 좌석을 동시에 1,000명이 예약하려고 하면, 재고는 순식간에 소진될 거예요.
만약 여러 요청이 동시에 재고를 확인하고 감소시키면, 아래처럼 꼬일 수 있어요.

1️⃣ 스레드 A: 재고 읽음 → 1
2️⃣ 스레드 B: 재고 읽음 → 1
3️⃣ A, B 둘 다 재고를 1 감소 → 최종 재고 -1 (말도 안 되는 결과!)

동시성 처리의 핵심 개념

이 문제를 해결하려면 아래 개념들을 꼭 알아야 해요.
- 임계 구역(Critical Section): 동시에 접근하면 안 되는 부분
- 락(Lock): 한 번에 하나만 접근할 수 있게 문을 잠그는 역할
원자성(Atomicity): 중간에 실패 없이 한 번에 실행돼야 함
- DB 트랜잭션: 데이터 정합성을 보장 (작업 단위로 실행)

 대규모 예약 시스템의 실제 전략

1. DB 트랜잭션 + 비관적 락

가장 기본적인 방법은 DB row-level 락을 거는 방식이에요.

BEGIN;
SELECT stock FROM tickets WHERE id = 1 FOR UPDATE;
UPDATE tickets SET stock = stock - 1 WHERE id = 1 AND stock > 0;
COMMIT;

이렇게 하면 같은 좌석을 두 명이 동시에 가져가려 해도, DB가 락을 걸어 순서를 보장해줍니다.

2. 낙관적 락 (Optimistic Lock)

충돌이 잘 안 날 때는 낙관적 락이 좋아요.
버전 번호를 사용해서 중복 업데이트를 막습니다.

예를 들어, JPA에서는 아래처럼 처리할 수 있어요.

@Entity
class Ticket(
    @Id val id: Long,
    var stock: Int,
    
    @Version
    val version: Int = 0
)
  • @Version 컬럼 덕분에, 누군가 먼저 업데이트하면 다른 트랜잭션은 실패해요.
  • 충돌나면 재시도 로직으로 해결!

3. Redis 캐시 & 메시지 큐

DB 락만으로는 한계가 있어요. 그래서 보통은 Redis + 메시지 큐 구조를 같이 씁니다.

Redis 캐시

  • 재고를 Redis에 캐싱해두고, 예약 요청은 Redis에서 먼저 처리
  • 속도가 훨씬 빨라져요.

메시지 큐

  • Kafka나 RabbitMQ로 요청을 순차적으로 처리
  • 갑작스런 트래픽 폭주를 방지해줘요.

💻 실제 Kotlin + Spring Boot 예제 코드

아래는 Redis 캐시를 사용해 동시성 문제를 방지하는 간단한 예제입니다.

@Service
class ReservationService(
    private val redisTemplate: StringRedisTemplate
) {
    fun reserve(ticketId: Long): Boolean {
        val lockKey = "lock:ticket:$ticketId"
        val stockKey = "stock:ticket:$ticketId"

        val lock = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5))
        if (lock == false) {
            println("다른 사용자가 예약 중입니다. 다시 시도해주세요.")
            return false
        }

        try {
            val stock = redisTemplate.opsForValue().get(stockKey)?.toIntOrNull() ?: 0
            if (stock > 0) {
                redisTemplate.opsForValue().decrement(stockKey)
                println("예약 성공! 남은 재고: ${stock - 1}")
                // 여기서 DB에도 예약 확정 로직 (트랜잭션 처리!)을 실행
                return true
            } else {
                println("예약 실패! 재고가 없습니다.")
                return false
            }
        } finally {
            // 락 해제
            redisTemplate.delete(lockKey)
        }
    }
}

이상적인 처리 흐름 정리

정리하면, 사용자가 많은 예약 시스템에서는 보통 아래처럼 처리해요.

1. 사용자 예약 요청
2. Redis에서 재고 확인 및 감소 (속도 & 중복 방지)
3. 예약 요청을 메시지 큐로 전송
4. Consumer가 메시지 큐를 받아 DB 트랜잭션으로 최종 확정
5. 사용자에게 예약 성공/실패 응답

 

이렇게 하면 재고가 음수가 되지 않고 DB에 불필요한 락 경합도 줄일 수 있어요.

🔥 결론

예약 시스템의 핵심은 "속도와 정확성 둘 다 잡는 것"
Redis, 메시지 큐, 트랜잭션을 적절히 조합하면,
수십만 명이 몰려도 안정적으로 예약을 처리할 수 있습니다.

반응형