코딩관계론

발급 가능한 쿠폰의 개수보다 사용자에게 전달한 쿠폰의 개수가 더 많다.... 본문

TroubleShooting

발급 가능한 쿠폰의 개수보다 사용자에게 전달한 쿠폰의 개수가 더 많다....

개발자_티모 2024. 7. 23. 01:22
반응형

타임딜 쿠폰 발급 서비스를 구현하는 도중에 발급 가능한 쿠폰의 개수보다 사용자에게 전달한 쿠폰의 개수가 더 많은 일이 발생했다.

 

아래가 원인이 된 코드인데 자세히 알아보자. 

  1.  발급 가능한 쿠폰의 개수와 사용자에게 전달된 쿠폰의 개수를 비교한다.
  2.  발급이 가능하다면, 새로운 쿠폰 엔티티를 생성하고, 사용자에게 쿠폰을 발급했다고 업데이트를 하게 된다.
    @Transactional
    public Coupon generateCouponToUser(TimeDealEvent timeDealEvent, Double discountRate){
        if(timeDealEvent.getPublishedCouponNum() <= timeDealEvent.getDeliveredCouponNum()){
            throw new IllegalStateException("더 이상 발급하지 못함");
        }

        Coupon coupon = new Coupon(discountRate, timeDealEvent);

        String saveId = couponRepository.save(coupon);
        log.debug("발급된 쿠폰 ID는 {}", saveId);

        timeDealEvent.updateDeliveredCouponNum();
        return coupon;
    }

 

만약 아래의 그림과 같이 사용자 두 명이 동시에 티켓 발급 요청이 들어오면 어떻게 될까? TimeDealEvent의 칼럼은 동시성 제약이 없기 때문이 사용자 둘 다 발급된 쿠폰이 없다고 나올 것이다.

 

이후 updateDeliveredCouponNum을 하더라도 Read 값이 잘못됐으므로 Write 값이 제대로 되지 않을 것이다. 이는 대표적인 동시성 처리 문제이다. 따라서 이를 해결하기 위해서 트랜잭션 격리 수준 전력과 DB락 전략에 대해서 알아보자

트랜잭션의 격리 수준

트랜잭션은 데이터베이스에서의 작업 단위를 의미하며, ACID 속성을 보장해야 합니다. 문제는 격리성인데, 격리성을 완벽히 보장하려면 트랜잭션을 거의 차례대로 실행해야 합니다. 이렇게 되면 트랜잭션을 거의 순서대로 처리해야 하기 때문에 동시성 성능이 매우 나빠지게 됩니다. 이를 해결하기 위해 ANSI 표준에서는 트랜잭션 간의 간섭을 얼마나 허용할지에 따라 네 가지 격리 수준을 정의했습니다. 이 격리 수준은 성능과 일관성 사이의 균형을 맞추기 위해 설계되었습니다.

1. Read Uncommitted

가장 낮은 격리 수준으로, 트랜잭션이 커밋하지 않은 데이터를 다른 트랜잭션이 읽을 수 있습니다. 이것을 **더티 리드(Dirty Read)**라고 합니다.

더티 리드 문제는 다음과 같은 상황에서 발생할 수 있습니다:

  1. 트랜잭션 1이 데이터 A를 읽고 작업을 시작합니다.
  2. 트랜잭션 2가 데이터 A를 수정하고 커밋되지 않은 상태에서 트랜잭션 1이 수정된 데이터를 읽습니다.
  3. 트랜잭션 2가 롤백됩니다.
  4. 트랜잭션 1이 커밋되지 않은 잘못된 데이터를 기반으로 작업을 마칩니다.

이 상황에서는 트랜잭션 1이 롤백된 데이터, 즉 잘못된 데이터를 사용하게 되어 데이터의 일관성에 문제가 발생합니다.

2. Read Committed

트랜잭션이 커밋한 데이터만 읽을 수 있습니다. 커밋한 데이터만 읽을 수 있음으로 더티 리드는 발생하지 않습니다. 하지만 논 리피터블 리드(Non-repeatable Read) 문제는 발생할 수 있습니다.

논 리피터블 리드 문제는 다음과 같은 상황에서 발생할 수 있습니다:

  1. 트랜잭션 1이 유저 A의 아이디를 조회합니다.
    • 이 시점에서 유저 A의 아이디는 'user123'입니다.
  2. 트랜잭션 2가 유저 A의 아이디를 'user456'으로 수정하고 커밋합니다.
  3. 트랜잭션 1이 다시 유저 A의 아이디를 조회합니다.
    • 이번에는 유저 A의 아이디가 'user456'으로 변경된 것을 봅니다.

이 경우, 트랜잭션 1은 처음과 두 번째 조회에서 서로 다른 결과를 얻게 됩니다. 이는 데이터 일관성에 영향을 미칠 수 있습니다.

3. Repeatable Read

트랜잭션 동안 같은 데이터를 여러 번 읽더라도 동일한 결과를 보장합니다. 하지만 **팬텀 리드(Phantom Read)**라는 문제점이 발생할 수 있습니다.

팬텀 리드는 다음과 같은 상황에서 발생할 수 있습니다:

  1. 트랜잭션 1이 고객 테이블에서 나이가 30 이하인 모든 고객을 조회합니다.
    • 첫 번째 조회 결과: Alice (28), Bob (25)
  2. 트랜잭션 2가 나이가 30 이하인 새로운 고객 Charlie (27)을 추가하고 커밋합니다.
  3. 트랜잭션 1이 같은 범위(나이가 30 이하인 모든 고객)를 다시 조회합니다.
    • 두 번째 조회 결과: Alice (28), Bob (25), Charlie (27)

이 경우, 트랜잭션 1은 처음 조회한 결과와 두 번째 조회한 결과가 달라집니다. 이는 범위 쿼리의 결과가 트랜잭션 도중 변하는 현상을 의미합니다.

4. Serializable

가장 높은 격리 수준으로, 위에서 언급한 모든 문제를 방지할 수 있지만 동시성 처리 성능이 급격하게 저하될 수 있습니다. 트랜잭션이 순차적으로 실행되는 것처럼 보장하여 모든 격리성 문제를 해결합니다

발생할 수 있는 문제들

그렇다면 앞서 발생한 문제를 트랜잭션 격리 수준으로 해결할 수 있을까?

용자가 같은 칼럼의 데이터를 동시에 읽어서 값을 변경할 때, 결과적으로 예상보다 적은 값으로 업데이트되는 문제는 경쟁 조건(Race Condition) 문제입니다. 이 문제는 트랜잭션 격리 수준만으로는 완벽하게 해결할 수 없습니다.

따라서 우리는 DB락 전략에 대해서 공부해야만 합니다.

 

DB 락 전략

1. Pessimistic Locking (비관적 락)

비관적 락(Pessimistic Locking)은 데이터베이스 관리 시스템(DBMS)에서 동시성을 제어하는 방법 중 하나로, 트랜잭션이 데이터에 접근하는 동안 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 하는 방식입니다. 비관적 락은 데이터 충돌이 자주 발생할 가능성이 높다고 예상되는 경우에 사용됩니다.

동작 방식

특징

  1. 데이터 일관성 보장: 비관적 락은 데이터의 일관성을 보장하기 위해 다른 트랜잭션이 동일한 데이터에 접근하지 못하도록 합니다. 이는 데이터 무결성을 중시하는 시스템에서 유용합니다.
  2. 성능 저하 가능성: 트랜잭션이 데이터를 잠그고 있는 동안 다른 트랜잭션이 대기해야 하기 때문에 성능이 저하될 수 있습니다.
  3. 락의 범위: 비관적 락은 읽기 락(Shared Lock)과 쓰기 락(Exclusive Lock)으로 나눌 수 있습니다.
    • 읽기 락: 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 수정할 수 없게 합니다.
    • 쓰기 락: 데이터를 수정하는 동안 다른 트랜잭션이 해당 데이터를 읽거나 수정할 수 없게 합니다.(보통은 이 락을 비관적 락이라고 말합니다)

단점

  1. 비관적 락을 사용하면 락을 획득할 때까지 트랜잭션이 대기한다. 무한정 기다릴 수도 있게 됨으로 타임아웃 시간 설정이 필요하다.

먼저 경쟁조건이 생기는 부분에 쓰기 락을 걸었다. JPA에서는 아래의 코드로 간단하게 락을 걸 수가 있다.

em.find(TimeDealEvent.class, id, LockModeType.PESSIMISTIC_WRITE);
//public <T> T find(Class<T> entityClass, Object primaryKey,LockModeType lockMode);

 

 

앞서 봤던 동작 방식을 코드로 검증하기 위헤서 아래와 같이 몇 가지 로그를 추가하였고, 실제 결과를 확인해 봤다.

@Transactional(isolation = Isolation.READ_COMMITTED)
public Coupon generateCouponToUser(Long eventId, Double discountRate){
    System.out.println("=========== Thread 시작 " + Thread.currentThread().getName() + " =============");
    TimeDealEvent timeDealEvent = timeDealEventRepository.findById(eventId);
    log.debug("사용자에게 전달된 쿠폰의 개수는 = {} ", timeDealEvent.getDeliveredCouponNum());
    System.out.println("timeDealEvent = " + timeDealEvent.getDeliveredCouponNum());

    if(timeDealEvent.getPublishedCouponNum() <= timeDealEvent.getDeliveredCouponNum()){
        throw new IllegalStateException("더 이상 발급하지 못함");
    }

    timeDealEvent.updateDeliveredCouponNum();

    Coupon coupon = new Coupon(discountRate, timeDealEvent);
    String saveId = couponRepository.save(coupon);
    log.debug("발급된 쿠폰 ID는 {}", saveId);

    System.out.println("========================DB에 결과 반영 finish " + Thread.currentThread().getName() + "=================");
    return coupon;
}
//timeDealEventRepository.findById
@Override
    public TimeDealEvent findById(Long id) {
        System.out.println("============ 비관적 락 진입 시도" + Thread.currentThread().getName() + "============");
        TimeDealEvent timeDealEvent = em.find(TimeDealEvent.class, id, LockModeType.PESSIMISTIC_WRITE);
        System.out.println("=============SELECT 조회 완료 "+  Thread.currentThread().getName() +"=============");
        return timeDealEvent;
    }

 

아래의 접은 글을 클릭하면 로그를 확인할 수 있습니다. PostgreSQL을 사용하여 FOR NO KEY UPDATE를 이용해 락을 거는 모습을 확인할 수 있었고, userA의 트랜잭션이 커밋하기 전까지는 userB에서 쿼리 결과를 얻지 못하는 로그를 확인할 수 있습니다.

더보기

=========== Thread 시작 User B =============
=========== Thread 시작 User A =============
============ 비관적 락 진입 시도User A============
============ 비관적 락 진입 시도User B============
2024-07-23T13:38:45.976+09:00 DEBUG 32149 --- [         User B] org.hibernate.SQL                        : 
    select
        tde1_0.time_deal_event_id,
        tde1_0.delivered_coupon_num,
        tde1_0.published_coupon_num 
    from
        time_deal_event tde1_0 
    where
        tde1_0.time_deal_event_id=? for no key update
2024-07-23T13:38:45.976+09:00 DEBUG 32149 --- [         User A] org.hibernate.SQL                        : 
    select
        tde1_0.time_deal_event_id,
        tde1_0.delivered_coupon_num,
        tde1_0.published_coupon_num 
    from
        time_deal_event tde1_0 
    where
        tde1_0.time_deal_event_id=? for no key update
=============SELECT 조회 완료 User A=============
timeDealEvent = 0
========================DB에 결과 반영 finis User A=================
2024-07-23T13:38:45.980+09:00 DEBUG 32149 --- [         User A] org.hibernate.SQL                        : 
    insert 
    into
        coupon
        (coupon_number, coupon_rate, time_deal_event_id, published_date, status, id) 
    values
        (?, ?, ?, ?, ?, ?)
2024-07-23T13:38:45.983+09:00 DEBUG 32149 --- [         User A] org.hibernate.SQL                        : 
    update
        time_deal_event 
    set
        delivered_coupon_num=?,
        published_coupon_num=? 
    where
        time_deal_event_id=?
=============SELECT 조회 완료 User B=============
timeDealEvent = 1
========================DB에 결과 반영 finis User B=================
2024-07-23T13:38:45.986+09:00 DEBUG 32149 --- [         User B] org.hibernate.SQL                        : 
    insert 
    into
        coupon
        (coupon_number, coupon_rate, time_deal_event_id, published_date, status, id) 
    values
        (?, ?, ?, ?, ?, ?)
2024-07-23T13:38:45.987+09:00 DEBUG 32149 --- [         User B] org.hibernate.SQL                        : 
    update
        time_deal_event 
    set
        delivered_coupon_num=?,
        published_coupon_num=? 
    where
        time_deal_event_id=?

2. Optimistic Locking (낙관적 락)

낙관적 락(Optimistic Locking)은 동시성 제어 기법 중 하나로, 데이터에 대한 충돌이 드물다고 가정하고, 데이터 접근 시점에는 락을 걸지 않고 데이터 커밋 시점에만 충돌 검사를 수행합니다. 이를 통해 데이터베이스 성능을 높이면서도 데이터의 일관성을 유지할 수 있습니다.

동작 방법

 

장점

  • 높은 성능: 락을 사용하지 않으므로, 데이터 읽기 성능이 높습니다.
  • 낮은 대기 시간: 데이터 읽기 시 다른 트랜잭션에 의해 대기하지 않습니다.
  • 단순성: 락 관리가 필요 없으며, 애플리케이션 레벨에서 간단하게 구현할 수 있습니다.
  • 최초 커밋 인장: 두 번의 갱신 분실 문제를 예방한다.

단점

  • 충돌 처리 부담: 충돌이 발생하면 트랜잭션을 롤백하고 재시도해야 하므로, 애플리케이션 로직이 복잡해질 수 있습니다.
  • 낮은 충돌 빈도 전제: 데이터 충돌이 빈번하게 발생하는 환경에서는 적합하지 않습니다.

JPA에서는 version을 이용해 낙관적 락을 관리하기 때문에 엔티티에 아래의 필드가 추가되어야 합니다.

@Version
private Integer version;

 

낙관적 락(Optimistic Locking)에는 여러 모드가 있으며, 대표적으로 None 모드와 Optimistic 모드가 있습니다. 각 모드는 데이터베이스 동시성 제어를 다루는 방법에서 차이를 보입니다.

1. None 모드

JPA에서는 @Version 정보를 통해 자동으로 낙관적 락이 적용됩니다.

용도

  • 조회 시점부터 수정 시점까지 데이터의 일관성을 보장합니다.

동작

  • 엔티티를 수정할 때, 버전 정보를 체크하면서 버전을 증가시킵니다.
  • 업데이트 쿼리에서 현재 버전이 일치하지 않으면 예외가 발생합니다.

이점

  • 두 번의 갱신 분신 문제 예방: 여러 트랜잭션이 동시에 같은 데이터를 수정할 때, 첫 번째 커밋만 인정되어 충돌을 방지합니다.

2. Optimistic 모드

Optimistic 모드는 엔티티를 조회만 해도 @Version 정보를 체크합니다. 이는 트랜잭션이 종료될 때까지 해당 엔티티가 다른 트랜잭션에 의해 변경되지 않음을 보장합니다.

용도

  • 조회한 엔티티가 다른 트랜잭션에 의해 변경되지 않음을 보장해야 할 때 사용합니다.

동작

  • 트랜잭션을 커밋할 때 버전 정보를 조회하여 현재 엔티티의 버전과 일치하는지 검증합니다.
  • 버전 정보가 일치하지 않으면 충돌로 간주하여 트랜잭션을 롤백합니다.

이점

  • Dirty Read와 Non-repeatable Read 방지: 트랜잭션 동안 데이터가 다른 트랜잭션에 의해 변경되지 않음을 보장하여 데이터 일관성을 유지합니다.

아래와 같이 낙관적 락을 사용하면 필드가 업데이트 됐다는 오류를 전달받을 수 있고, 사용자의 추가 저리가 필요합니다.

Exception in thread "User B" org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.bjcareer.stockservice.timeDeal.domain.TimeDealEvent#1]

결론

동시성 문제를 해결하기 위해서는 트랜잭션 격리 수준과 DB 락 전략을 적절히 활용해야 합니다. 빈번한 충돌이 예상되는 경우에는 비관적 락을 사용하고, 충돌이 드문 경우에는 낙관적 락을 사용하는 것이 좋습니다. 

 

타임딜 서비스의 경우에는 다수의 사용자가 동시에 접속해서 특정 테이블을 수정하는 빈번한 충돌이 예상되는 경우임으로 비관적 락을 사용합니다.

반응형