해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요
동시성 이슈 관련해서 혼자 이것 저것 테스트해보다가 데드락 에러를 발견했다.
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이 된 경우 상품 응모가 모두 소진되었다는 예외 메시지가 반환된다.
EventProduct, ProductDrawEvent, ProductDrawEventHistory 엔티티
@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 마다 다름)
- 혹여나 알 수 없는 데드락 증상이 발생한다면 해당 이슈를 참고해보자.
'IT > 백엔드 필수 교양' 카테고리의 다른 글
[AWS] SQS (Simple Queue Service)와 Spring 연동하기 (0) | 2024.08.14 |
---|---|
[Java] 스프링에서 자주 사용하는 디자인 패턴 - 전략 패턴 (0) | 2024.08.05 |
[JAVA] 비동기 요청으로 인한 동시성 테스트 하기 (0) | 2024.07.31 |
[k6] k6를 활용해서 Spring Boot API 부하 테스트 하기 (0) | 2024.07.30 |
[DB] 트랜잭션 격리성 수준 (Transaction Isolation) (2) | 2024.07.29 |