해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요

동시성 이슈 관련해서 혼자 이것 저것 테스트해보다가 데드락 에러를 발견했다.

Caused by: java.sql.SQLTransactionRollbackException: (conn=644) Deadlock found when trying to get lock; try restarting transaction
	at org.mariadb.jdbc.export.ExceptionFactory.createException(ExceptionFactory.java:303)
	at org.mariadb.jdbc.export.ExceptionFactory.create(ExceptionFactory.java:378)
	at org.mariadb.jdbc.message.ClientMessage.readPacket(ClientMessage.java:172)
	at org.mariadb.jdbc.client.impl.StandardClient.readPacket(StandardClient.java:915)
	at org.mariadb.jdbc.client.impl.StandardClient.readResults(StandardClient.java:854)
	at org.mariadb.jdbc.client.impl.StandardClient.readResponse(StandardClient.java:773)
	at org.mariadb.jdbc.client.impl.StandardClient.execute(StandardClient.java:697)
	at org.mariadb.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:93)
	at org.mariadb.jdbc.ClientPreparedStatement.executeLargeUpdate(ClientPreparedStatement.java:342)
	at org.mariadb.jdbc.ClientPreparedStatement.executeUpdate(ClientPreparedStatement.java:319)
	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)
	at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java)
	at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:194)
  • "SQLTransactionRollbackException 예외가 발생했고 Lock을 얻는 과정에서 Deadlock이 발생했다" 라는 메시지였다.
  • 테스트는 다음과 같다. 
  • 상품 응모 이벤트가 있고 정해진 수만큼만 응모가 가능하다.
  • 예를 들어, 선착순으로 100명까지 상품 응모가 가능한 이벤트가 있다고 가정하고 해당 이벤트에서 동시에 100번 응모했을 때 원하는 결과가 나오는지 테스트했다.
  • 엔티티는 EventProduct, ProductDrawEvent, ProductDrawEventHistory가 있고, 각각 이벤트 상품, 상품 응모 이벤트, 상품 응모 이벤트 이력 엔티티이다.
  •  특정 유저가 이벤트 응모에 참가하면 ProductDrawEvent의 drawQuantity가 한개 차감된다. drawQuantity가 0이 된 경우 상품 응모가 모두 소진되었다는 예외 메시지가 반환된다.

EventProductProductDrawEventProductDrawEventHistory 엔티티

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "event_product")
@Entity
public class EventProduct {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @Column(name = "name", nullable = false)
    private String name;

    public static EventProduct of(String name) {
        return new EventProduct(null, name);
    }
}

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "product_draw_event")
@Entity
public class ProductDrawEvent {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name = "event_product_id", nullable = false)
    private EventProduct eventProduct;

    @Column(name = "product_quantity", nullable = false)
    private Long productQuantity;

    @Column(name = "draw_quantity", nullable = false)
    private Long drawQuantity;

    public boolean hasEventProductDrawQuantities() {
        return this.drawQuantity > 0;
    }

    public ProductDrawEventHistory draw(Long userId) {
        if (hasEventProductDrawQuantities()) {
            drawQuantity--;
            return ProductDrawEventHistory.of(userId, this);
        }

        throw new IllegalStateException("상품 응모 개수가 모두 소진되었습니다.");
    }

    public static ProductDrawEvent of(EventProduct eventProduct, Long productQuantity, Long drawQuantity) {
        return new ProductDrawEvent(null, eventProduct, productQuantity, drawQuantity);
    }
}

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "product_draw_event_history",
    uniqueConstraints = {
        @UniqueConstraint(
            name = "uk_product_draw_event_history_product_draw_event_id_user_id",
            columnNames = {"product_draw_event_id", "user_id"}
        )
    }, indexes = @Index(name = "idx_product_draw_event_history_user_id", columnList = "user_id"))
@Entity
public class ProductDrawEventHistory {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name = "product_draw_event_id", nullable = false)
    private ProductDrawEvent productDrawEvent;

    @Column(name = "user_id", nullable = false)
    private Long userId;

    @Column(name = "is_winner")
    private boolean isWinner;

    @Column(name = "create_at")
    private LocalDateTime createAt;

    public static ProductDrawEventHistory of(Long userId, ProductDrawEvent productDrawEvent) {
        return new ProductDrawEventHistory(null, productDrawEvent, userId, false, LocalDateTime.now());
    }
}

ProductDrawEventEnterUseCase

  • 상품 응모 이벤트 참가 유스케이스
@RequiredArgsConstructor
@Service
public class ProductDrawEventEnterUseCase {
    private final ProductDrawEventRepository productDrawEventRepository;
    private final ProductDrawEventHistoryRepository productDrawEventHistoryRepository;

    @Transactional
    public Long draw(Long userId, Long productDrawEventId) {
        ProductDrawEvent productDrawEvent = productDrawEventRepository.findById(productDrawEventId)
            .orElseThrow(() -> new IllegalStateException("존재하지 않는 이벤트 입니다."));

        return productDrawEventHistoryRepository.save(productDrawEvent.draw(userId))
            .getId();
    }
}
  • draw 메소드 실행 시 상품 응모 이벤트를 조회하고 남은 응모 회수가 있으면 상품 응모 이벤트 내역을 생성한다.
  • 동시성 이슈가 발생하는지 확인하기 위해서 비관적 락을 걸지 않고 productDrawEvent를 조회하게 했다.
  •  그리고 아래와 같은 테스트 코드를 작성했다.
@SpringBootTest
class ProductDrawEventEnterUseCaseTest {

    @Autowired
    public EventProductRepository eventProductRepository;

    @Autowired
    public ProductDrawEventRepository productDrawEventRepository;

    @Autowired
    public ProductDrawEventHistoryRepository productDrawEventHistoryRepository;

    @Autowired
    public ProductDrawEventEnterUseCase productDrawEventEnterUseCase;

    @BeforeEach
    public void setup() {
        productDrawEventHistoryRepository.deleteAll();
        productDrawEventRepository.deleteAll();
        eventProductRepository.deleteAll();
    }

    @Test
    public void 동시에_1000번_이벤트상품_응모_요청_성공_테스트() throws InterruptedException {
        final String eventProductName = "Apple 맥북 프로 14 M2";
        final long productQuantity = 10L;
        final long drawQuantity = 1000L;

        final int nThreads = 20;

        final EventProduct eventProduct = EventProduct.of(eventProductName);

        // 미리 이벤트 상품과 상품 응모 이벤트를 생성한다.
        eventProductRepository.save(eventProduct);
        ProductDrawEvent productDrawEvent = productDrawEventRepository.save(ProductDrawEvent.of(eventProduct, productQuantity, drawQuantity));

        List<Callable<Long>> tasks = new ArrayList<>();

        // task 목록을 생성한다
        for (long index = 0; index < drawQuantity; index++) {
            final long userId = index;
            tasks.add(() -> productDrawEventEnterUseCase.draw(userId, productDrawEvent.getId()));
        }

        // 스레드 개수롤 고정적으로 nThreads 만큼 생성
        ExecutorService executorService = Executors.newFixedThreadPool(nThreads);

        // 멀티 스레드로 동시에 상품 응모를 진행한다.
        List<Future<Long>> futures = executorService.invokeAll(tasks);

        // 상품 응모 결과 확인
        for (Future<Long> future : futures) {
            // 예외가 발생하지 않았는지 확인
            assertThatCode(future::get)
                .doesNotThrowAnyException();
        }

        // 작업 종료
        executorService.shutdown();

        ProductDrawEvent resultProductDrawEvent = productDrawEventRepository.findById(eventProduct.getId()).get();
        Assertions.assertThat(resultProductDrawEvent.getDrawQuantity()).isEqualTo(0L);
    }
}
  • 위 테스트를 실행했을 때 레이스 컨디션 증상이 발생해서 동시성 이슈가 발생할 것으로 예상했는데 데드락 예외가 발생하는 것이다.
  • ProductDrawEventEnterUseCase를 살펴보았을 때 별도로 Lock을 거는 부분이 없었는데 데드락 예외가 발생한 것이다..... (이게 무슨일이지)
  • 그래서 찾아보았는데 충격적인 사실을 알았다.
테이블에 데이터를 insert 할 때 외래키 제약조건이 걸린 외부 테이블의 id를 저장하는 경우 해당 외부 테이블 행에 공유락을 건다. 

즉 자식 테이블에서 데이터 insert 시 실행 시 부모 테이블 행에 공유락이 걸리는 것이다. 
  • ProductDrawEventEnterUseCase 코드를 보면 productDrawEventHistoryRepository.save(productDrawEvent.draw(userId)) 부분이 있는데 productDrawEventHistory를 insert하고 productDrawEvent의 drawQuantity를 한개 차감한다. 
  • 단일 스레드로 한개씩 수행된다면 문제 없지만 동시에 여러 스레드가 해당 메소드를 실행하면 deadlock 문제가 발생하는 것이다.
  • productDrawEventHistory insert 시 productDrawEvent의 id (외래키)를 저장하고 이때 해당 행에 공유락이 걸린다.
순서 트랜잭션 1 트랜잭션 2
1
insert into product_draw_event_history (user_id, product_draw_event_id) values (1, 1);

id 1번 product_draw_event에 공유락 선점
 
2   insert into product_draw_event_history (user_id, product_draw_event_id) values (2, 1);

id 1번 product_draw_event에 공유락 선점
3 update product_draw_event 
set draw_quantity = draw_quantity - 1 
where id = 1;
id 1번 product_draw_event에 쓰기락 대기
 
4   update product_draw_event 
set draw_quantity = draw_quantity - 1 
where id = 1;
id 1번 product_draw_event에 쓰기락 대기
  • id 1번  product_draw_event 행에 공유 락이 걸린 상태에서 트랜잭션 1, 2 모두 id 1번  product_draw_event 에 쓰기 락을 걸려고 하기 때문에 데드락이 발생한다.
  • 더 정확하게 확인해보기 위해 db client에서 테스트해보았다.
# tx1
start transaction;

insert into product_draw_event_history (create_at, is_winner, user_id, product_draw_event_id)
values (now(), false, 1, 1);
  • 트랜잭션 생성 후 1번 상품 응모 이벤트에 대해서 응모 이력을 생성한다. 
  • 커밋하지 않으면 1번 상품 응모 이벤트에 공유락이 걸린 상태로 대기한다.
# tx2
start transaction;

update product_draw_event
set draw_quantity = draw_quantity - 1
where id = 1;
  • 새로운 트랜잭션에서 1번 상품 응모 이벤트의 남은 응모 회수를 1 차감하는 update 문을 실행한다.
  • update 문 실행 시 쓰기락을 걸기 때문에 1번 상품 응모 이벤트에 공유락이 걸려 있으면 공유락이 해제 될 때까지 대기한다. 

  •  1번 트랜잭션을 커밋하기 전까지 2번 트랜잭션의 update 작업은 대기하게 된다.

 

결론

  • insert 시 foreign key가 걸린 id가 있다면 해당 부모 테이블에 공유락이 걸린다. (dbms 마다 다름)
  • 혹여나 알 수 없는 데드락 증상이 발생한다면 해당 이슈를 참고해보자.

+ Recent posts