어느덧 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 외에도 여러 가지 동시성 제어 방법이 존재하며, 상황에 맞게 적절한 방식을 선택하는 것이 중요하다. 다음은 동시성 문제 해결을 위한 몇 가지 대표적인 방법들이다.
동기화는 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)); // 쿠폰 발급
}
}
장점: 구현이 간단하며 단일 서버에서 동시성 문제를 효과적으로 해결할 수 있다.
단점: 여러 서버에서 운영되는 시스템에서는 사용하기 어렵고, 동기화로 인해 성능이 저하될 수 있다.
트랜잭션을 통해 여러 작업을 하나의 단위로 묶어 처리할 수 있다. @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));
}
}
장점: 데이터베이스 트랜잭션을 활용하여 데이터의 무결성을 보장할 수 있다.
단점: 트랜잭션을 사용하면 동시성 처리가 어려워지고, 성능이 저하될 수 있다.
다중 서버 환경에서 동시성 문제를 해결하기 위해 분산 락을 사용할 수 있다. 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와 같은 외부 시스템에 의존하게 되며, 락 획득 및 해제의 비용이 발생한다.
비관적 락은 데이터베이스에서 락을 사용하여 자원에 대한 접근을 제어하는 방식이다. 이 방식은 자원을 사용 중일 때 다른 트랜잭션이 해당 자원에 접근하지 못하도록 한다.
@Service
public class ApplyService {
private final CouponRepository couponRepository;
@Transactional
public void apply(Long userId) {
Coupon coupon = couponRepository.findByIdForUpdate(userId); // 비관적 락 획득
couponRepository.save(new Coupon(userId));
}
}
장점: 동시성 문제를 확실하게 방지할 수 있으며, 데이터베이스 레벨에서 락을 걸기 때문에 신뢰성이 높다.
단점: 락으로 인해 성능 저하가 발생할 수 있으며, 자원을 락 상태로 오래 유지하면 경합이 심해질 수 있다.
낙관적 락은 데이터 충돌이 자주 발생하지 않을 것이라고 가정하는 방식으로, 충돌이 발생할 때만 예외를 발생시킨다. 이를 위해 엔티티에 버전 정보를 추가하여 데이터가 갱신될 때 버전 정보를 확인한다.
@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); // 낙관적 락 적용
}
}
장점: 데이터 충돌이 자주 발생하지 않는 경우 성능을 향상시킬 수 있다.
단점: 충돌이 자주 발생하면 예외가 많이 발생하여 성능이 저하될 수 있다.
메시지 큐 시스템을 도입하여 비동기 처리를 통해 동시성을 제어할 수 있다. RabbitMQ나 Kafka와 같은 시스템을 사용하여 요청을 큐에 쌓고 하나씩 처리함으로써, 동시성 문제를 해결할 수 있다.
@Service
public class ApplyService {
private final MessageQueueService messageQueueService;
public void apply(Long userId) {
messageQueueService.sendMessage(new ApplyMessage(userId)); // 비동기적으로 티켓 발급 처리
}
}
장점: 대규모 트래픽을 처리하는 데 적합하며, 요청을 큐에 쌓아 처리하므로 동시성 문제를 완화할 수 있다.
단점: 메시지 큐를 관리하는 추가적인 인프라가 필요하며, 처리 지연이 발생할 수 있다.
데이터베이스 레벨에서 직접 제약 조건을 설정하여 쿠폰 발급 수를 제한할 수 있다. 이를 통해 동시성 문제를 해결할 수 있으며, 데이터베이스에서 자동으로 제한을 걸어준다.
CREATE TABLE coupon (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
CONSTRAINT chk_coupon_count CHECK ((SELECT COUNT(*) FROM coupon) <= 100) -- 최대 100개까지만 발급
);
장점: 데이터베이스 차원에서 강력한 제약을 걸 수 있다.
단점: 성능 저하가 발생할 수 있으며, 대규모 시스템에서는 비효율적일 수 있다.
synchronized
키워드나 트랜잭션 처리만으로도 충분한 동시성 제어가 가능하다.티켓 예매 시스템과 같은 서비스에서 동시성 제어는 핵심적인 요소다. 이는 시스템의 안정성과 신뢰성을 보장하는 데 중요한 역할을 한다.
프로젝트 도중에 만난 핵심 로직에 대한 기술적 이해를 혼자 구현하는 것이 아니라 KB IT’s Your Life에서 만난 좋은 팀원들과 함께 스터디를 통해 쌓아갈 수 있다는 것이 색다른 경험이었다.
이러한 협업 과정을 통해 지식 공유뿐만 아니라 하나의 도메인 로직에 대한 이해를 단단히 하여 좋은 프로젝트 마무리까지 이어질 수 있었으면 좋겠다.
좋은 정보 감사합니다!