[KB IT's Your Life TIL] 오늘의 프로젝트 요약 : 티켓 예매 시스템에서 발생하는 동시성 이슈 스터디

JUN·2024년 9월 19일
0

KB IT's your life

목록 보기
11/16

서론

어느덧 KB IT's Your Life 교육과정도 최종 프로젝트에 들어갔다. 이번 프로젝트는 티켓 예매 시스템을 구현하는 것으로, 특히 티켓 예매의 핵심 로직인 티켓 예매 로직을 구현하기 위해 스터디를 진행했다. 스터디 중 가장 중요한 주제는 바로 레이스 컨디션(Race Condition) 문제였다.

레이스 컨디션이란?

레이스 컨디션이란, 여러 스레드가 동시에 공유 자원에 접근할 때 발생하는 문제이다. 각 스레드가 자원에 접근하는 순서나 타이밍에 따라 의도한 로직과 다른 결과가 발생할 수 있다. 이로 인해 예상치 못한 동작이나 데이터 무결성 문제가 발생할 수 있다.

레이스 컨디션의 발생 실습: 쿠폰 발급 시스템

쿠폰 발급 로직의 기본 구조

먼저, 사용자에게 쿠폰을 발급하는 로직을 간단히 구현한 코드 구조를 살펴보자.

@Service
public class ApplyService {

  private final CouponRepository couponRepository;
  private final CouponCountRepository couponCountRepository;

  public ApplyService(CouponRepository couponRepository,
                      CouponCountRepository couponCountRepository) {
    this.couponRepository = couponRepository;
    this.couponCountRepository = couponCountRepository;
  }

  public void apply(Long userId) {
    Long count = couponCountRepository.increment();

    if (count > 100) {
      return;  // 이미 100개의 쿠폰이 발급되었다면 더 이상 발급하지 않음
    }
    couponRepository.save(new Coupon(userId));  // 쿠폰 발급
  }
}

위 코드에서 중요한 부분은 쿠폰 발급 수를 관리하는 부분이다. couponCountRepository.increment()를 통해 현재 발급된 쿠폰 수를 확인하고, 100개 이상일 경우 추가 발급을 막는다는 로직이 핵심이다. 그러나 이 구조에는 동시성 문제를 해결하지 못하는 치명적인 약점이 있다.

문제점: 동시성 문제

만약 여러 사용자가 동시에 쿠폰을 신청하게 되면 어떻게 될까? 각 사용자가 동일한 시점에 쿠폰을 요청할 경우, couponCountRepository.increment() 호출이 중첩되어 예상보다 더 많은 쿠폰이 발급될 수 있다. 예를 들어, 100개의 쿠폰만 발급해야 하는데도 101개 이상의 쿠폰이 발급되는 상황이 발생할 수 있다.

이를 확인하기 위해 테스트 코드를 작성해 보자.

테스트 코드

@SpringBootTest
class ApplyServiceTest {

  @Autowired
  private ApplyService applyService;

  @Autowired
  private CouponRepository couponRepository;

  @Test
  public void 여러명응모() throws InterruptedException {
    int threadCount = 1000;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
      long userId = i;
      executorService.submit(() -> {
        try {
          applyService.apply(userId);  // 1000명의 사용자가 동시에 쿠폰을 신청함
        } finally {
          countDownLatch.countDown();
        }
      });
    }

    countDownLatch.await();  // 모든 작업이 완료될 때까지 기다림

    long count = couponRepository.count();  // 최종 발급된 쿠폰 수 확인
    assertThat(count).isEqualTo(100);  // 100개의 쿠폰만 발급되었는지 확인
  }
}

해당 코드는 여러 명이 동시에 쿠폰을 신청할 때 발생할 수 있는 문제를 테스트하는 코드이다.

1000명의 사용자가 동시에 쿠폰을 신청하는 상황을 만들고 자바에서 제공하는 스레드풀을 이용해 기본 32개의 스레드를 사용해서 이 상황을 시뮬레이션 하였다.

각 사용자마다 applyService.apply(userId)를 호출해서 쿠폰을 신청하고 실제 발급한 쿠폰의 수를 확인한다.

만약 제대로 쿠폰이 발급되었다면 100개의 쿠폰이 count에 저장되었을 것이다.

결과를 확인해보자.

테스트 결과 100개만 생성되어야 했을 쿠폰이 116개나 생성된 것을 알 수 있다.

이유는 해당 표와 같이 쿠폰 발급을 시도하는 과정에서 동시에 같은 카운트 값을 읽으면서 중복된 발급이 발생했기 때문이다.

동시성 문제 해결 방안

쿠폰 발급 시스템에서 발생할 수 있는 레이스 컨디션 문제를 해결하기 위해 다양한 방법을 사용할 수 있다. Redis 외에도 여러 가지 동시성 제어 방법이 존재하며, 상황에 맞게 적절한 방식을 선택하는 것이 중요하다. 다음은 동시성 문제 해결을 위한 몇 가지 대표적인 방법들이다.

1. 동기화(Synchronization)

동기화는 Java에서 제공하는 기본적인 동시성 제어 기법이다. synchronized 키워드를 사용하여 메서드나 특정 코드 블록을 동기화할 수 있다. 이를 통해 한 번에 하나의 스레드만 해당 로직에 접근할 수 있게 만들어 레이스 컨디션을 방지할 수 있다.

public class ApplyService {
    private final CouponRepository couponRepository;
    private final CouponCountRepository couponCountRepository;

    public synchronized void apply(Long userId) {
        Long count = couponCountRepository.increment();

        if (count > 100) {
            return;  // 이미 100개의 쿠폰이 발급되었다면 더 이상 발급하지 않음
        }
        couponRepository.save(new Coupon(userId));  // 쿠폰 발급
    }
}

장점: 구현이 간단하며 단일 서버에서 동시성 문제를 효과적으로 해결할 수 있다.

단점: 여러 서버에서 운영되는 시스템에서는 사용하기 어렵고, 동기화로 인해 성능이 저하될 수 있다.

2. 트랜잭션 처리

트랜잭션을 통해 여러 작업을 하나의 단위로 묶어 처리할 수 있다. @Transactional 어노테이션을 사용하여 트랜잭션을 관리하면, 데이터베이스의 무결성을 보장하며, 여러 스레드가 동시에 같은 자원에 접근하는 것을 방지할 수 있다.

@Service
public class ApplyService {
    private final CouponRepository couponRepository;
    private final CouponCountRepository couponCountRepository;

    @Transactional
    public void apply(Long userId) {
        Long count = couponCountRepository.incrementAndGet();

        if (count > 100) {
            throw new IllegalStateException("쿠폰이 모두 소진되었습니다.");
        }
        couponRepository.save(new Coupon(userId));
    }
}

장점: 데이터베이스 트랜잭션을 활용하여 데이터의 무결성을 보장할 수 있다.

단점: 트랜잭션을 사용하면 동시성 처리가 어려워지고, 성능이 저하될 수 있다.

3. 분산 락(Distributed Lock)

다중 서버 환경에서 동시성 문제를 해결하기 위해 분산 락을 사용할 수 있다. Redisson과 같은 라이브러리를 활용하여 Redis 기반의 분산 락을 구현하면, 여러 서버에서 동시에 같은 자원에 접근하지 못하게 할 수 있다.

@Service
public class ApplyService {
    private final RedissonClient redissonClient;
    private final CouponRepository couponRepository;
    private final CouponCountRepository couponCountRepository;

    public void apply(Long userId) {
        RLock lock = redissonClient.getLock("coupon_lock");

        try {
            boolean isLocked = lock.tryLock(5, 1, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new IllegalStateException("락 획득 실패");
            }

            Long count = couponCountRepository.increment();

            if (count > 100) {
                return;
            }
            couponRepository.save(new Coupon(userId));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();
        }
    }
}

장점: 다중 서버 환경에서 효과적으로 동시성 문제를 해결할 수 있다.

단점: Redis와 같은 외부 시스템에 의존하게 되며, 락 획득 및 해제의 비용이 발생한다.

4. 비관적 락(Pessimistic Locking)

비관적 락은 데이터베이스에서 락을 사용하여 자원에 대한 접근을 제어하는 방식이다. 이 방식은 자원을 사용 중일 때 다른 트랜잭션이 해당 자원에 접근하지 못하도록 한다.

@Service
public class ApplyService {
    private final CouponRepository couponRepository;

    @Transactional
    public void apply(Long userId) {
        Coupon coupon = couponRepository.findByIdForUpdate(userId); // 비관적 락 획득
        couponRepository.save(new Coupon(userId));
    }
}

장점: 동시성 문제를 확실하게 방지할 수 있으며, 데이터베이스 레벨에서 락을 걸기 때문에 신뢰성이 높다.

단점: 락으로 인해 성능 저하가 발생할 수 있으며, 자원을 락 상태로 오래 유지하면 경합이 심해질 수 있다.

5. 낙관적 락(Optimistic Locking)

낙관적 락은 데이터 충돌이 자주 발생하지 않을 것이라고 가정하는 방식으로, 충돌이 발생할 때만 예외를 발생시킨다. 이를 위해 엔티티에 버전 정보를 추가하여 데이터가 갱신될 때 버전 정보를 확인한다.

@Entity
public class Coupon {

    @Version
    private Long version;  // 낙관적 락을 위한 버전 필드

    // 기타 필드 및 메서드 생략
}
@Service
public class ApplyService {
    @Transactional
    public void apply(Long userId) {
        Coupon coupon = couponRepository.findById(userId).orElseThrow();
        couponRepository.save(coupon);  // 낙관적 락 적용
    }
}

장점: 데이터 충돌이 자주 발생하지 않는 경우 성능을 향상시킬 수 있다.

단점: 충돌이 자주 발생하면 예외가 많이 발생하여 성능이 저하될 수 있다.

6. 메시지 큐(Message Queue)

메시지 큐 시스템을 도입하여 비동기 처리를 통해 동시성을 제어할 수 있다. RabbitMQKafka와 같은 시스템을 사용하여 요청을 큐에 쌓고 하나씩 처리함으로써, 동시성 문제를 해결할 수 있다.

@Service
public class ApplyService {
    private final MessageQueueService messageQueueService;

    public void apply(Long userId) {
        messageQueueService.sendMessage(new ApplyMessage(userId));  // 비동기적으로 티켓 발급 처리
    }
}

장점: 대규모 트래픽을 처리하는 데 적합하며, 요청을 큐에 쌓아 처리하므로 동시성 문제를 완화할 수 있다.

단점: 메시지 큐를 관리하는 추가적인 인프라가 필요하며, 처리 지연이 발생할 수 있다.

7. 데이터베이스 제약 조건

데이터베이스 레벨에서 직접 제약 조건을 설정하여 쿠폰 발급 수를 제한할 수 있다. 이를 통해 동시성 문제를 해결할 수 있으며, 데이터베이스에서 자동으로 제한을 걸어준다.

CREATE TABLE coupon (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT,
    CONSTRAINT chk_coupon_count CHECK ((SELECT COUNT(*) FROM coupon) <= 100)  -- 최대 100개까지만 발급
);

장점: 데이터베이스 차원에서 강력한 제약을 걸 수 있다.

단점: 성능 저하가 발생할 수 있으며, 대규모 시스템에서는 비효율적일 수 있다.

요약

  • 단일 서버 환경에서는 synchronized 키워드나 트랜잭션 처리만으로도 충분한 동시성 제어가 가능하다.
  • 다중 서버 환경에서는 Redis 기반의 분산 락이나 메시지 큐를 사용하여 안정적인 처리가 필요하다.
  • 데이터베이스를 통한 제약 설정도 일부 동시성 문제를 해결할 수 있으며, 상황에 따라 비관적 락이나 낙관적 락을 사용할 수 있다.

결론

티켓 예매 시스템과 같은 서비스에서 동시성 제어는 핵심적인 요소다. 이는 시스템의 안정성과 신뢰성을 보장하는 데 중요한 역할을 한다.

프로젝트 도중에 만난 핵심 로직에 대한 기술적 이해를 혼자 구현하는 것이 아니라 KB IT’s Your Life에서 만난 좋은 팀원들과 함께 스터디를 통해 쌓아갈 수 있다는 것이 색다른 경험이었다.

이러한 협업 과정을 통해 지식 공유뿐만 아니라 하나의 도메인 로직에 대한 이해를 단단히 하여 좋은 프로젝트 마무리까지 이어질 수 있었으면 좋겠다.

profile
순간은 기록하고 반복은 단순화하자 🚀

1개의 댓글

comment-user-thumbnail
2024년 10월 20일

좋은 정보 감사합니다!

답글 달기