이번 글에서는 애플리케이션 안에서 동시성을 해결하는 방법을 정리해 보려고 합니다.
동시성 이슈
는 여러 작업이 동시에 공유 자원에 접근했을 때 발생합니다.
ex) 재고 감소, 조회수 증가, 선착순 시스템 등
동시성 이슈로 다음과 같은 문제가 발생할 수 있습니다.
경쟁 조건 (Race Condition)
교착 상태 (DeadLock)
병목 현상 (Bottleneck)
@Entity
public class Coupon {
@Id
private Long id;
private String name;
private int totalQuantity;
private int issuedQuantity;
public void issue() {
if (issuedQuantity >= totalQuantity) {
throw new RuntimeException("더 이상 쿠폰을 발급할 수 없습니다.");
}
issuedQuantity++;
}
}
@Entity
public class UserCoupon {
@Id
private Long id;
private Long couponId;
private Long userId;
}
@RequiredArgsConstructor
@Service
public class CouponService {
private final CouponRepository couponRepository;
private final UserCouponRepository userCouponRepository;
// 쿠폰 발급 로직
@Transactional
public void issue(Long couponId, Long userId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow();
coupon.issue();
userCouponRepository.save(
new UserCoupon(couponId, userId)
);
}
}
@DisplayName("동시에 여러명의 유저가 쿠폰을 발행한다")
@Test
void issueCouponByUsers() throws InterruptedException {
// given
long userId = 1L;
long couponId = 1L;
couponRepository.saveAndFlush(
new Coupon(couponId, "치킨 할인 쿠폰", 100, 0)
);
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// when
IntStream.range(0, 100)
.forEach(n -> executorService.execute(() -> {
try {
couponService.issue(couponId, userId);
} finally {
countDownLatch.countDown();
}
})
);
countDownLatch.await();
// then
Coupon coupon = couponRepository.findById(couponId).orElseThrow();
assertThat(coupon.getIssuedQuantity()).isEqualTo(100);
}
동시성 테스트와 테스트가 모두 끝날 때 까지 대기하기 위해 ExecutorService
, CountDownLatch
를 사용했습니다.
쿠폰 100개가 발급되길 기대했지만 경쟁 조건(Race Condition)
이 발생해서 기대했던 것보다 적게 쿠폰이 발급되었습니다.
public void issue(Long couponId, Long userId) {
synchronized (this) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow();
coupon.issue();
couponRepository.save(coupon);
}
userCouponRepository.save(
new UserCoupon(couponId, userId)
);
}
Java에서는 synchronized
를 사용해서 동시성 이슈를 해결할 수 있습니다.
synchronized
를 사용하면 데이터를 점유하고 있는 스레드를 제외하고, 나머지 스레드들은 데이터에 접근할 수 없습니다.
@Transactional
은 트랜잭션 종료 시점에 데이터베이스 업데이트를 하게 됩니다.
실제 데이터베이스가 업데이트 되기 전에 다른 스레드가 쿠폰 발급을 호출하면,
갱신되기 전에 값을 가져가서 경쟁 조건(Race Condition)
이 또 다시 발생하게 됩니다.
위에서 언급한 것처럼 synchronized
는 데이터를 점유하고 있는 스레드를 제외하고, 나머지 스레드들은 데이터에 접근할 수 없기 때문에 많은 요청이 들어오는 경우 빠르게 처리할 수 없습니다.
synchronnized
는 하나의 프로세스 안에서만 보장됩니다.
실사용 서비스는 대부분 다중 서버 환경이기 때문에 동시성 문제가 발생할 수 있어 synchronized
를 사용하지 않습니다.
Java에서 제공하는 Lock
을 사용하면 synchronized
와 같이 동시성 문제를 해결할 수 있습니다.
@Configuration
public class LockConfig {
// Lock을 공유하기 위해 빈 등록
@Bean
public Lock lock() {
return new ReentrantLock();
}
}
public void issue(Long couponId, Long userId) {
try {
lock.lock();
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow();
coupon.issue();
couponRepository.save(coupon);
} finally {
lock.unlock(); // 다른 스레드가 Lock을 걸 수 있도록 하기 위해
}
userCouponRepository.save(
new UserCoupon(couponId, userId)
);
}
하지만 Lock
도 마찬가지로 synchronized
와 같은 문제를 가지고 있어 잘 사용하지 않습니다.
대부분의 운영 중인 서비스는 다중 서버 환경이므로 동시성 이슈를 해결하기 위해서는 위에서 알아본 방법 대신 DB Lock
, Redis
와 같은 외부 기술을 사용해야 될 것 같습니다.
추가로 전에 DB Lock, Redis를 이용한 동시성 이슈를 해결하는 방법을 정리한 글을 참고하시면 도움이 될 것 같습니다!