반응형
- 문제 상황 : 동시에 여러 사용자가 쿠폰을 발급받을 때 발급 수량을 초과하면 안된다.
- 목표 : 설정된 발급 수량만큼만 정확히 발급되도록 보장해야 한다.
- 기술스택 : Spring Boot 3.x, Spring Data JPA, Postgresql16
문제 상황
쿠폰 발급 시나리오
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private int maxIssueCount;
@Column(nullable = false)
private int issuedCount = 0;
public Coupon(String name, int maxIssueCount) {
this.name = name;
this.maxIssueCount = maxIssueCount;
}
public boolean canIssue() {
return issuedCount < maxIssueCount;
}
public void increaseIssuedCount() {
if (!canIssue()) {
throw new IllegalStateException("쿠폰 발급 수량이 초과되었습니다.");
}
this.issuedCount++;
}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
class CouponIssue {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long couponId;
@Column(nullable = false)
private Long memberId;
@Column(nullable = false)
private ZonedDateTime issuedAt;
CouponIssue(Long couponId, Long memberId) {
this.couponId = couponId;
this.memberId = memberId;
this.issuedAt = ZonedDateTime.now();
}
}
- Coupon 엔티티의 issuedCount는 발급한 수량을 의마하며 maxIssueCount만큼만 쿠폰을 발급할 수 있다.
동시성 문제가 발생하는 상황
@Service
@Transactional
@RequiredArgsConstructor
public class CouponService {
public void issueCoupon(Long couponId, Long memberId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("쿠폰을 찾을 수 없습니다."));
// 동시에 여러 스레드가 접근할 수 있다.
coupon.issue();
couponIssueRepository.save(new CouponIssue(couponId, memberId));
}
}
여러 사용자가 동시에 쿠폰 발급을 요청할 경우 문제가 발생할 수 있다.
- 최대 발급 수량을 100개로 설정
- 99개가 이미 발급된 상황(issuedCount = 99)
- 두 명의 사용자가 동시에 쿠폰 발급을 요청할 경우
해결 방법들
synchronyzed 키워드
- Java에서 동시성 제어를 위해 지원하는 키워드인 synchronyzed를 사용
- 쿠폰 발급 메서드에 synchronyzed 키워드를 추가해 테스트했다.
// 메서드에 synchronized를 추가했다.
public synchronized void issueCoupon(Long couponId, Long memberId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("쿠폰을 찾을 수 없습니다."));
coupon.issue();
CouponIssue couponIssue = new CouponIssue(couponId, memberId);
couponIssueRepository.save(couponIssue);
}
하지만 테스트해보니 쿠폰 발급 수량이 초과하는 문제가 발생했다. 이 방법으로는 동시성 문제를 해결할 수 없었다.
원인 : 트랜잭션 범위와 락의 범위의 차이로 인해 발생한 문제였다.
- synchronized 락은 메서드 실행이 끝나면 즉시 해제된다.
- @Transactional은 메서드 실행 후에도 트랜잭션 커밋까지 시간이 걸린다.
- Thread2가 Thread1의 트랜잭션이 커밋되기 전에 같은 데이터를 읽을 수 있다.
@Service
public class CouponService {
public void issueCoupon(Long couponId, Long memberId) {
// synchronized 범위에 포함시킨다.
synchronized (this) {
issueCouponWithTransaction(couponId, memberId);
}
}
@Transactional
public void issueCouponWithTransaction(Long couponId, Long memberId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("쿠폰을 찾을 수 없습니다."));
coupon.issue();
CouponIssue couponIssue = new CouponIssue(couponId, memberId);
couponIssueRepository.save(couponIssue);
}
}
이 방법으로 동시성 문제를 해결할 수는 있지만 몇 가지 한계점이 있다.
- synchronized는 JVM 레벨에서만 동작하기 때문에 다중 서버 환경에서는 사용할 수 없다.
- 모든 쿠폰 발급이 하나씩만 처리되어 성능상 비효율적이다.
synchronized는 단일 인스턴스 환경에서는 동시성 문제를 해결할 수 있지만 한계가 명확하기 때문에 다른 방법이 필요했다.
데이터베이스 레벨 동시성 제어
- 데이터베이스에서 제공하는 동시성 제어 매커니즘을 활용하는 방법들
- 데이터베이스 락은 애플리케이션과 무관하게 동작하기 때문에 분산 환경에서도 적용 가능
Optimistic Lock(낙관적 락)
- 실제 데이터 수정 시점에서만 충돌을 검사하는 방식 - 충돌은 드물게 발생할 것이라는 낙관적인 가정
구현 방법
- JPA에서는 엔티티에 @Version 을 붙인 속성을 추가하면 간단히 낙관적 락을 사용할 수 있다.
class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private int maxIssueCount;
@Column(nullable = false)
private int issuedCount = 0;
@Version
private Long version; // 낙관적 잠금을 위한 속성
}
장점
- 데드락 위험이 없다.
- 데이터를 읽을 때 락을 걸지 않아 동시 읽기 성능이 비교적 좋다.
- 충돌이 적은 환경에서는 효율적이다.
한계
- 충돌이 빈번한 상황에서는 계속 재시도를 하기 때문에 성능이 저하된다.
- 재시도 로직을 직접 구현해야하기 때문에 복잡성이 높아진다.
쿠폰 발급처럼 동시 요청이 많은 시나리오에는 부적합한 방법
Persimistic Lock(비관적 락)
- 데이터를 읽는 시점부터 락을 걸어 다른 트랜잭션의 접근을 차단하는 방식 - 충돌이 자주 발생할 것이라는 비관적인 가정
구현방법
- 조회 시
interface CouponRepository extends JpaRepository<Coupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :id")
Optional<Coupon> findByIdWithPessimisticLock(@Param("id") Long id);
}
장점
- 동시성을 확실하게 보장한다. : 락을 먼저 획득한 트랜잭션이 가져가는 명확한 방식
- 재시도 로직이 필요 없다.
한계
- 여러 리소스에 대해 락 획득 시 데드락이 발생할 수 있다.
- 락 대기로 인해 성능 저하가 발생할 수 있다.
단일 SQL문
- 단일 SQL문으로 조건부 업데이트를 수행한다.
interface CouponRepository extends JpaRepository<Coupon, Long> {
@Modifying
@Query("""
UPDATE Coupon c
SET c.issuedCount = c.issuedCount + 1
WHERE c.id = :couponId AND c.issuedCount < c.maxIssueCount
""")
int decreaseStock(Long couponId);
}
장점
- 별도의 락이 필요없다. - 단일 쿼리로 처리되서 안전하다.
- 데이터베이스 레벨에서 원자적인 연산을 수행해 성능이 좋다.
한계
- 복잡한 비즈니스 로직에서는 사용하기 어렵고 SQL로 표현하기 때문에 확장성에 한계가 있다.
- 엔티티와 DB의 실제 값이 달라질 수 있다.
간단한 동시성 제어에는 최적이지만, 복잡한 비즈니스 요구사항에는 한계가 있는 방법이다.
분산락
- 네트워크상의 여러 애플리케이션 인스턴스가 동일한 락을 공유해 분산 환경에서도 동시성 제어가 가능하다.
장점
- redis같은 인메모리 DB를 사용할 경우 빠른 락 연산을 지원한다.
한계점
- 추가 인프라가 필요하며 리소스 및 운영 비용을 고려해야한다.
- 네트워크 의존성이 생겨 단일 지점 장애 문제가 발생할 수 있다.
- 락 타임아웃, 갱신 등 복잡성이 증가한다.
어떤 방법을 선택할 것인가
생각해본 기준
- 동시성 문제가 절대 발생하지 않아야 하는가 - 어느정도 무시해도 괜찮은가
- 복잡한 로직 없이 구현 가능한가
- 비즈니스 로직 변경에 유연하게 대응할 수 있는가
- 추가 인프라가 필요한가
일단 수량제한 쿠폰 발급기능에는 Pessimistic Lock을 사용할 것 같다.
- 데이터 일관성 완벽 보장이 필요하다.
- JPA 어노테이션만으로 구현할 수 있다.
- 재시도나 복잡한 로직 없이 처리할 수 있다.
- 추가 외부 인프라가 필요없다.
쿠폰 발급 수량 제한 기능을 구현하면서 다양한 동시성 제어 방법을 조사해보고, 최종적으로 Pessimistic Lock을 선택하기까지의 과정을 정리했다. 사실 처음에는 synchronyzed만 추가해도 잘 동작할 거라고 생각했다… 트랜잭션 범위와 락 범위에 대한 이해가 부족했는데 이번 기회에 공부할 수 있었다.
정확한 지식을 쌓으면서 적절한 선택을 하려면 더 공부가 필요할 것 같다.
반응형