동시성 이슈 해결하기

2024. 12. 19. 23:00·개발
반응형
  • 문제 상황 : 동시에 여러 사용자가 쿠폰을 발급받을 때 발급 수량을 초과하면 안된다.
  • 목표 : 설정된 발급 수량만큼만 정확히 발급되도록 보장해야 한다.
  • 기술스택 : 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만 추가해도 잘 동작할 거라고 생각했다… 트랜잭션 범위와 락 범위에 대한 이해가 부족했는데 이번 기회에 공부할 수 있었다.

정확한 지식을 쌓으면서 적절한 선택을 하려면 더 공부가 필요할 것 같다.

반응형
저작자표시 비영리 변경금지 (새창열림)
'개발' 카테고리의 다른 글
  • 테스트 대역 용어
  • 로컬 개발환경에서 https 사용
덴마크초코우유
덴마크초코우유
IT, 알고리즘, 프로그래밍 언어, 자료구조 등 정리
    반응형
  • 덴마크초코우유
    이것저것끄적
    덴마크초코우유
  • 전체
    오늘
    어제
    • 분류 전체보기 (126)
      • Spring Framework (10)
        • Spring (5)
        • JPA (3)
        • Spring Security (0)
      • Language (52)
        • Java (12)
        • Python (10)
        • JavaScript (5)
        • NUXT (2)
        • C C++ (15)
        • PHP (8)
      • DB (16)
        • MySQL (10)
        • Reids (3)
        • Memcached (2)
      • 개발 (3)
      • 프로젝트 (3)
      • Book (2)
      • PS (15)
        • 기타 (2)
        • 백준 (2)
        • 프로그래머스 (10)
      • 딥러닝 (8)
        • CUDA (0)
        • Pytorch (0)
        • 모델 (0)
        • 컴퓨터 비전 (4)
        • OpenCV (1)
      • 기타 (16)
        • 디자인패턴 (2)
        • UnrealEngine (8)
        • ubuntu (1)
        • node.js (1)
        • 블로그 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 미디어로그
    • 위치로그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    mscoco
    pytorch
    PS
    redis
    게임 개발
    언리얼엔진4
    MySQL
    NUXT
    딥러닝
    Unreal
    JS
    select
    블루프린트
    CPP
    프로그래머스
    파이썬
    php
    memcached
    웹
    C++
    클래스
    Unreal Engine
    게임
    map
    JavaScript
    알고리즘
    자바
    FPS
    Python
    C
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
덴마크초코우유
동시성 이슈 해결하기
상단으로

티스토리툴바