0. 쿠폰발급 이벤트.

  쿠폰 발급 이벤트 도메인을 개발을 하게되었다. SpringBoot + JPA +Mysql 환경이고, 유저에게 정해진 수량 내에서 쿠폰을 발급을 해주고, 쿠폰 재고가 다 떨어지면 발급을 막는 간단한 요구사항이었다. 개발 과정에서 큰 어려움은 없었고 테스트도 순조로웠는데, 여러명에 동시에 쿠폰을 발급받는 상황을 가정한 멀티쓰레드 테스트에서 문제가 발생하였다.

 

1. 테스트코드

우선 작성한 테스트코드는 다음과 같다.

package event.coupon.service;


@Profile("test")
@SpringBootTest
@Transactional
class CouponServiceTest {

    @Autowired CouponRepository repository;
    @Autowired CouponStockRepository stockRepository;
    @Autowired private RedisTemplate<String, String> redisTemplate;
    @Autowired private CouponService couponService;
    long couponId = 2L;
    
    @BeforeEach
    void setup() {
        // 테스트 쿠폰 생성
        CouponRequest testCoupon = CouponRequest.builder()
                .couponName("커밋 쿠폰222")
                .planedCount(10L)
                .discountPercent(20)
                .limitDiscountAmount(BigDecimal.valueOf(20_000))
                .build();
        GeneratedCoupon generatedCoupon = couponService.generateCoupon(testCoupon);
        System.out.println("settingDB : " + generatedCoupon);
        System.out.println("redisStock : " + redisTemplate.opsForValue().get("coupon:stock:" + generatedCoupon.getCouponId()));
    }
    
    @Test
    @DisplayName("다중 쓰레드를 이용하여 쿠폰발급 동시성 테스트.")
    void multiThreadCouponTest() throws InterruptedException {
        //given
        ExecutorService executor = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(32);

        //when
        for (long userId = 1; userId <= 32; userId++) {
            final long uid = userId;
            executor.submit(() -> {
                try {
                    couponService.issueCoupon(couponId, uid);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await(); // 모든 스레드 종료 대기

        //then
        // DB 상태도 확인
        em.flush(); // 변경사항 DB에 반영
        em.clear(); // 1차 캐시 비우고 진짜 DB 조회

        // Redis 대기열에 들어간 유저 수 == 발급된 수
        System.out.println(RedisKeyPrefix.STOCK_KEY.of(couponId));
        String remain = redisTemplate.opsForValue().get(RedisKeyPrefix.STOCK_KEY.of(couponId));
        assertThat(remain).isEqualTo("0");


        CouponStock stock = stockRepository.findByCouponId(couponId).orElseThrow();
        System.out.println(stock);
        assertThat(stock.getIssuedCount()).isEqualTo(10);

    }
}

 

테스트 시나리오는 다음과 같다.

@BeforEach로 미리 테스트 데이터로 만들어둔 10장의 쿠폰을 32명의  사용자가 동시에 발급을 했을때 레디스에 올려둔 재고가 정상적으로 감소하는지? 트래잭션이 정상적으로 동작하여 토탈 발급한 수량이 DB에 10장으로 나오는지를 테스트하고자 하였다.

 

 

2. 발생하는 문제.

 

처음엔 상단의 @Transactional이 없는 상태로 테스트를 하였는데, 데이터를 정리하는과정이 번거로워 자동 롤백기능을 사용하고자  @Transactional을 달았는데 그때부터 테스트가 깨지기 시작하였는데,  DB에 기록된 쿠폰 발급수량은 10장이 아니라 0장으로 나오는 현상이 발생하였다.

 

디버깅을 해보았는데 여기서 에러가 발생하였다.

@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class CouponService {

    private final CouponRedisService couponRedisService;
    private final CouponStockRepository couponStockRepository;

    /* 사용자가 쿠폰을 발급받음*/
    public CouponResponse issueCoupon(Long id, Long userId) {

        TryAcquireStatus tryAcquireStatus = couponRedisService.tryAcquire(id, userId);

        if (tryAcquireStatus.equals(TryAcquireStatus.REMAIN)) {

            CouponStock couponStock = couponStockRepository.findByCouponIdForUpdate(id)
                    .orElseThrow(() -> new NotValidCouponException(id));

            couponStock.issueCoupon();
            couponStockRepository.save(couponStock);
            return new CouponResponse(couponStock.getCoupon());
        } else{
            throw new ExceededCouponException();
        }

    }
}

 

 

CouponService의 issueCoupon()을 실행도중 couponStocRepository.findByCouponIdForupdate(id)에서  커스텀 에러인NotValidCouponException이 발생하였다.  해당 쿠폰 자체를 조회를 못한다는건데 BeforeEach()로 넣어준 데이터에 접근을 못한다는 소리다.

@Transactional 하나 넣어준것 뿐인데 테스트가 깨지는게 이해가 잘 안된다.

 

3. 발생하는 원인 설명.

우선 원인을 이해하려면 mysql의 트랜잭션 격리 레벨에 대해서 알아야 한다.
https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html

 

MySQL :: MySQL 8.4 Reference Manual :: 17.7.2.1 Transaction Isolation Levels

17.7.2.1 Transaction Isolation Levels Transaction isolation is one of the foundations of database processing. Isolation is the I in the acronym ACID; the isolation level is the setting that fine-tunes the balance between performance and reliability, consi

dev.mysql.com

해당 Docs를 잘 읽어보면 Mysql의 기본엔진인 InnoDB의 default isolation에 대해서 설명이 자세하게 나와있다.

 

Mysql InnoDB의 기본 격리레벨은 Repeatable Read로 다음과 같이 설명하고 있다.

 

This is the default isolation level for InnoDB. Consistent reads within the same transaction read the snapshot established by the first read. This means that if you issue several plain (nonlocking) SELECT statements within the same transaction, these SELECT statements are consistent also with respect to each other. See Section 17.7.2.3, “Consistent Nonlocking Reads”.

 

-> 지속된 읽기는 같은 트랜잭션 내에서 처음 읽어온 스냅샷을 불러온다고 한다. 

 

그렇다면 지속된 읽기는 또 무엇이란 말인가? 우선 링크된  Section 17.7.2.3, “Consistent Nonlocking Reads”  를 읽어보자.

 

17.7.2.3 Consistent Nonlocking Reads

A consistent read means that InnoDB uses multi-versioning to present to a query a snapshot of the database at a point in time. The query sees the changes made by transactions that committed before that point in time, and no changes made by later or uncommitted transactions. The exception to this rule is that the query sees the changes made by earlier statements within the same transaction. This exception causes the following anomaly: If you update some rows in a table, a SELECT sees the latest version of the updated rows, but it might also see older versions of any rows. If other sessions simultaneously update the same table, the anomaly means that you might see the table in a state that never existed in the database.

 

이 구절을 자세히 읽어보면, 다중 버전 관리를 사용하여 특정 시점의 데이터베이스 스냅샷을 쿼리에 제공하는것을 의미한다.

쿼리는 해당 시점 이전에 커밋된 트랜잭션에 의해 변경된 내용만 읽고 나중에 혹은 커밋되지않은 트랜잭션에 의해 변경된 내용은 보지않는다고 나와있다.

 

 최초로 불러온 내용에 대해서만 제공하고 커밋되지 않은 테이블 내용에 대해서는 불러오지 않고 스냅샷을 보여준다는 의미이다.

 

그림으로 표현하면 다음과 같다.

 

Tx1이라는 트랜잭션이 시작되기 전 마지막으로 커밋된 내용이 스냅샷에 담기고, Tx1 내부에서 SELECT 요청이 들어오게 되면 Tx1 내부에서 변경이 일어나는 데이터들을 볼 수 있지만,  Tx1 내부가 아닌 외부에서 DB에 자원에 대한 SELECT 요청한 경우엔 스냅샷에 담긴 내용을 반환하게 된다.

 

 

데이터베이스에서 발생하는 일을 알게되었으니 이제 테스트 코드에서 발생하는 현상을 알아보자.

 

@Transactional을 테스트코드 클래스 레벨에 선언하게 되면 다음 그림과 같이 테스트가 동작하게 된다.

 

 일반적인 테스트 환경에선 Tx1 트랜잭션 내에서 setTestData()로 데이터를 넣어주면 해당 데이터는 동일 트랜잭션에서 넣어준 데이터이기에  test()에서 조회가 가능하고 Tx1이 종료가 되면 넣어준 데이터는 롤백이 된다. 해당 시퀀스가  Tx2, Tx3에서 반복이 된다.

 

 Tx2에선 Tx1에서 데이터가 insert되고 update되는 그런 내용들을 일절 알 수가 없다.

 

이제 테스트 코드 구조를 그림으로 그려보면 다음과 같다.

그림 tx total

Spring의 트랜잭션은 ThreadLocal 기반으로 관리가 되기때문에 Tx main 트랜잭션에서 generateCoupon()으로 쿠폰데이터를 생성을 하였지만 ExecutorService로  새로운 쓰레드를 생성하게 되고 해당 쓰레드에서 새로운 트랜잭션들이 생겨나게 된다. Tx main과 Tx sub1, Tx sub2, Tx Sub3는 각각 다른 트랜잭션으로 갈라지게 된다. Tx sub 1 내에서 findByCouponIdForUpdate()를 진행하게 되면 Tx main과의 컨택스트가 끊어지게 되어서 generateCoupon()으로 만들어진 쿠폰 데이터를 조회하는게 불가능하다. 실제 DB에서 스냅샷을 찾게 되는데, DB에는 넣어준적 없는 데이터를 찾으라고 하니 예외가 발생하는것이다. 

 

결국 멀티스레드 테스트 -> 트랜잭션의 분리 -> 스냅샷이 없는 데이터를 조회하게 되어 쿠폰 데이터 select에 실패 -> 쿠폰 발급량 업데이트에 실패를 하게 되는것이다.

 

 

4. 해결방법.

 

    4.1.1

    Service레이어에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 붙여주는 방법이 있다.  genereateCoupon()이 실행되고 나서 바로 커밋이 되어서 Tx sub 1에서도 findByCouponForUpdate()를 하여도 조회가 된다.  하지만 트랜잭션이 중첩되며 커낵션이 과잉으로 사용되어 성능저하의 원인이 되거나,  원래 트랜잭션과 커밋 타이밍이 달라져서 부모트랜잭션이 실패했는데 자식 트랜잭션은 커밋되어버리는 상황도 발생할 수 있게 되어서 운영에서 무지성으로 사용할 경우 장애의 원인이 될 수 있으니 사용에 주의해야 한다.

 

4.1.2

 테스트코드에서 @Transactional을 삭제하는것도 방법일 수 있겠다만.. 자동 롤백이 편리성을 포기하긴 쉽지않다.

 

4.1.3 괜찮은 해결법

@BeforeEach에서 바로 커밋을 해버리는 방법을 선택했다.

 

@BeforeEach
void setup() {
    // 테스트 쿠폰 생성
    CouponRequest testCoupon = CouponRequest.builder()
            .couponName("커밋 쿠폰222")
            .planedCount(10L)
            .discountPercent(20)
            .limitDiscountAmount(BigDecimal.valueOf(20_000))
            .build();
    GeneratedCoupon generatedCoupon = couponService.generateCoupon(testCoupon);
    System.out.println("settingDB : " + generatedCoupon);
    System.out.println("redisStock : " + redisTemplate.opsForValue().get("coupon:stock:" + generatedCoupon.getCouponId()));

    TestTransaction.flagForCommit();
    TestTransaction.end();     // 커밋!
    TestTransaction.start();   // 새로운 트랜잭션 시작
}

 

쿠폰을 발급받고 난 뒤 지금 트랜잭션에서 커밋후 종료시켜버린뒤 다른 트랜잭션을 시작하게 하여서 @BeforeEach와 @Test간 트랜잭션을 분리하는 방법을 선택해서 @Test에서 쿠폰 조회시에 커밋된 데이터를 읽게 하여서 테스트가 깨지지 않게 하였다.

 

문제없이 발급된 쿠폰 id를 조회하였고  해당 쿠폰의 제한된 발급수량 만큼 쿠폰을 발급하는데 성공하였다.

 

 

5. 프롤로그. 테스트 데이터 롤백이 안됐는데요?

 

테스트가 끝나고 DB를 조회해봤는데 롤백이 되지않은 모양이다.

 

테스트의 롤백은  클래스 레벨에서 돌아가는 트랜잭션에 포함된 데이터만 롤백이 된다.

 

 

그림 tx total

3절 마지막 tx total 그림을 다시 보면  Tx main은 새로운 스레드 생성으로 인해서 Tx sub1과 분리가 되어버리기 때문에 롤백의 대상이 아니라 그냥 그 자체로 트랜잭션이 따로 실행 된 후 커밋이 되는 순서를 가진다. 

'Java > 테스트코드' 카테고리의 다른 글

AssertJ에서 anyMatch() vs anySatisfy()  (1) 2025.03.19

테스트를 짜던 도중 리스트를 테스트 해야할 일이 있어서 assertThat.any를 치고 무엇이 나오나 봤는데 anyMatch()와 anySatisfy()가 두개 떴다

대충 봤을땐 두 메서드는 똑같은 기능을 하는거 같아보이는데 어떻게 두 메서드가 다른지 알아보았다.

우선 예제 클래스를 만들어준다.

public static class Pizza {
        private String topping;
        private String cheese;
        private String size;
 
        public Pizza(String topping, String cheese, String size) {
            this.topping = topping;
            this.cheese = cheese;
            this.size = size;
        }
    }

 

1. AnyMatch()

 @Test
   @DisplayName("anyMatch는 단일조건에 대해서 일치하는지 확인하는 매서드")
   void PizzaAnyMatchTest(){
       //given
       List<Pizza> pizzaList = new ArrayList<>();
       Pizza p1 = new Pizza("페퍼로니","모짜렐라", "large");
       Pizza p2 = new Pizza("불고기","까망베르", "small");
       Pizza p3 = new Pizza("스테이크","리코타", "medium");

       //when
       pizzaList.add(p1);
       pizzaList.add(p2);
       pizzaList.add(p3);

       //then
       assertThat(pizzaList).anyMatch(pizza -> pizza.getCheese().equals("모짜렐라"));
       assertThat(pizzaList).anyMatch(pizza -> pizza.getCheese().equals("리코타"));
   }
 

AnyMatch()의 경우 리스트 내의 객체중 하나라도 해당 조건을 가지고 있다면 ture가 된다.

p1의 치즈는 모짜렐라이고, p3의 치즈는 리코타이기 때문에 이 테스트는 통과한다.

2. AnySatisfy()

 

   @Test
   @DisplayName("AnySatisfy는 다중조건에 대해서 일치하는지 확인하는 메서드")
   void PizzaAnySatisfy(){
       List<Pizza> pizzaList = new ArrayList<>();
       Pizza p1 = new Pizza("페퍼로니","모짜렐라", "large");
       Pizza p2 = new Pizza("불고기","까망베르", "small");
       Pizza p3 = new Pizza("스테이크","리코타", "medium");

       //when
       pizzaList.add(p1);
       pizzaList.add(p2);
       pizzaList.add(p3);

       //then
       assertThat(pizzaList).anySatisfy(pizza -> {
           assertThat(pizza.getCheese()).isEqualTo("모짜렐라");
           assertThat(pizza.getTopping()).isEqualTo("페퍼로니");
       });
   }

보다싶이 anySatisfy()는 다중조건을 만족하는 객체를 검증하는 방법이다.

리스트 내에 치즈는 모짜렐라 'AND' 토핑은 페퍼로니인 피자가 있는지 검증한다.

p1이 해당 조건을 만족하기 때문에 본 테스트는 파란불이 뜬다.

반대로

 
 @Test
   @DisplayName("AnySatisfy는 다중조건에 대해서 일치하는지 확인하는 메서드")
   void PizzaAnySatisfy(){
       List<Pizza> pizzaList = new ArrayList<>();
       Pizza p1 = new Pizza("페퍼로니","모짜렐라", "large");
       Pizza p2 = new Pizza("불고기","까망베르", "small");
       Pizza p3 = new Pizza("스테이크","리코타", "medium");

       //when
       pizzaList.add(p1);
       pizzaList.add(p2);
       pizzaList.add(p3);

       //then
       assertThat(pizzaList).anySatisfy(pizza -> {
           assertThat(pizza.getCheese()).isEqualTo("모짜렐라");
           assertThat(pizza.getTopping()).isEqualTo("새우");
       });
   }

anyMatch()의 사용법처럼 치즈는 모짜렐라 'OR' 토핑은 새우인 피자가 리스트에 있는지 찾아보려는 의도로 검증을 돌려보면

 
java.lang.AssertionError: 
Expecting any element of:
  [CusmServiceTest.Pizza(topping=페퍼로니, cheese=모짜렐라, size=large),
    CusmServiceTest.Pizza(topping=불고기, cheese=까망베르, size=small),
    CusmServiceTest.Pizza(topping=스테이크, cheese=리코타, size=medium)]
to satisfy the given assertions requirements but none did:

CusmServiceTest.Pizza(topping=페퍼로니, cheese=모짜렐라, size=large)
error: 
expected: "새우"
 but was: "페퍼로니"

CusmServiceTest.Pizza(topping=불고기, cheese=까망베르, size=small)
error: 
expected: "모짜렐라"
 but was: "까망베르"

CusmServiceTest.Pizza(topping=스테이크, cheese=리코타, size=medium)
error: 
expected: "모짜렐라"
 but was: "리코타"

위와같이 리스트 내의 피자 객체중 모짜렐라 치즈와 새우토핑이 들어간, 두가지 조건을 동시에 만족하는 피자객체가 없기때문에 테스트는 실패하게 된다.

참고로

anyMatch()에서는 pizzaList -> { asserThat... }으로 진행하면 컴파일 에러가 발생한다.

요약

anyMatch() : 리스트내 요소에 대해 단일조건 검증

anySatisfy() : 리스트내 요소에 대해 다중조건 검증

 

+ Recent posts