1. 쿠폰 발급 로직과 데드락 문제 발생
팀 프로젝트가 끝난 후 아쉬움이 남아, 쿠폰 기능을 따로 추출해 혼자 개발해보기로 했다.
MySQL을 데이터베이스로 사용하고, 별다른 락을 걸지 않은 상태에서 쿠폰 발급 로직을 구현했다.
1-1. 쿠폰 발급 로직
쿠폰 발급 로직은 다음과 같은 방식으로 작성했다.
@Transactional
public Long issue(Long couponId, Long userId) {
final Coupon coupon = validateCoupon(couponId);
coupon.issue(QUANTITY_TO_ISSUE_COUPON);
// TODO User 검증, 중복 검사 필요
final IssuedCoupon issuedCoupon = IssuedCoupon.of(userId, coupon);
final IssuedCoupon savedIssuedCoupon = issuedCouponRepository.save(issuedCoupon);
return savedIssuedCoupon.getId();
}
쿠폰 발급 로직은 다음 순서로 구성되어 있다.
- 발급할 쿠폰의 수량을 검증한 뒤, 수량을 1개 감소시킨다.
- 발급 이력인 IssuedCoupon 객체를 생성한 뒤 저장한다
현재는 User 관련 코드를 작성하지 않아, User 검증 및 중복 검사를 `TODO`로 표시하고 생략한 상태다.
1-2. 동시성 테스트 작성
동시성 문제를 확인하기 위해 멀티스레드 환경에서 동시성 테스트를 작성해보았다.
@Test
@DisplayName("100명의 사용자가 동시에 쿠폰을 발급할 때, 쿠폰 수량이 정상적으로 감소하고 100개의 쿠폰이 발급된다.")
void issueCouponWith100Users() throws InterruptedException {
// given
final int threadCount = 100;
final Long couponId = couponRepository.save(CouponFixture.createCoupon()).getId();
final Long userId = 1L;
final int expectedCouponQuantity = 0;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
couponService.issue(couponId, userId);
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
}
latch.await();
executor.shutdown();
// then
final Coupon coupon = couponRepository.findById(couponId).orElseThrow();
assertEquals(expectedCouponQuantity, coupon.getQuantity());
}
테스트는 100명의 사용자가 동시에 쿠폰을 발급받는 상황을 가정하여 작성했다.
- 조건: 초기 쿠폰 수량은 100으로 설정했다.
- 목표: 발급된 쿠폰이 100개 생성되고, 쿠폰 수량이 정확히 감소하는지 확인하려 했다.
1-3. 테스트 결과
테스트를 실행한 결과, 예상과는 달리 데드락(Deadlock) 문제가 발생했다.
SQL Error 1213(Deadlock) 오류로 인해 일부 트랜잭션이 롤백되었고, 쿠폰 발급에 실패했다. 결과적으로 발급된 쿠폰이 100개 생성되지 않았다.
사실 내가 예상한 결과는 동시성 문제가 발생하는 것이었다.. ㅠㅠ 그런데 예상치 못한 데드락 문제 또한 발생한 것!
2. 데드락 발생 원인
2-1. 주요 원인
문제를 분석한 결과, 데드락의 주요 원인은 다음과 같았다.
- MySQL의 잠금 메커니즘
- MySQL의 트랜잭션 격리 수준(`REPEATABLE READ`)에 따라, 레코드에 자동으로 락이 설정된다.
- 발급 쿠폰 INSERT 시: 발급 쿠폰 PK 인덱스 엔트리에 대해 `Exclusive Lock`이 설정된다.
- 또한 부모 테이블인 쿠폰 레코드에서 대해 `Shared Lock`이 설정된다.
- 락의 타이밍과 순서가 다른 트랜잭션과 충돌하면서 데드락이 발생했다.
- JPA의 쓰기 지연(Write-Behind)
- JPA는 쓰기 지연 메커니즘으로 인해 트랜잭션 커밋 시점에 쿼리를 실행한다.
- 쿼리 실행 순서와 실제 DB 반영 순서가 다를 수 있어, 락 충돌이 발생했다.
- UPDATE 쿼리보다 INSERT 쿼리가 먼저 발생하였다.
JPA 쓰기 지연의 쿼리 우선순위는 다음과 같다..
- INSERT
- UPDATE
- DELETE of Collection Elements (자식 엔티티 삭제)
- INSERT of Collection Elements (자식 엔티티 삽입)
- ...
등등!
아무튼 나는 UPDATE 쿼리를 먼저 실행했지만
`JPA의 쓰기 지연`으로 인해 INSERT 쿼리가 먼저 나감으로써 데드락이 발생한 것이다.
그림으로 나타내면 다음과 같은 상황이다.
이렇게 INSERT -> UPDATE 순으로 쿼리가 실행되어
`Issued Coupon`에 대한 X락이 걸리고 부모 테이블인 `Coupon`에 대해서 서로 S LOCK이 걸리게 된다.
그 결과 데드락이 발생했다.
2-2. MySQL InnoDB의 SELECT와 S LOCK
`InnoDB`는 MySQL 5.5 버전부터 기본 저장 엔진으로 채택되었다.
MySQL의 InnoDB에서는 기본적으로 SELECT 쿼리를 실행할 때 S LOCK을 자동으로 걸지 않는다.
즉, 단순 조회를 할 때 락을 걸지 않아서 예상과 다르게 동작할 수 있었다.
S LOCK은 데이터의 읽기를 위해 걸리는 락이다. InnoDB는 SELECT 쿼리가 실행될 때 S LOCK을 걸지 않아서, 여러 트랜잭션에서 동시에 데이터를 조회할 수 있다. 이로 인해 예상했던 S LOCK이 없어서, 데드락이 발생하는 상황을 이해하는데 어려움을 겪을 수 있었다.
그렇다면 SELECT 쿼리에서 락이 걸리지 않는데, 왜 FK 제약조건을 가진 레코드에서만 S LOCK이 걸리는지 궁금할 수 있다.
사실 FK 제약조건이 있는 레코드에서는 참조 무결성을 보장하기 위해 S LOCK이 자동으로 걸리기 때문에 다르게 동작하는 것이다.
따라서 결과적으로 INSERT 시 부모 테이블인 쿠폰 레코드에 대해서 S LOCK이 걸려 데드락이 발생했다.
3. 해결 방안
이를 해결하기 위해 여러 방법들을 검색해보았다.
3-1. flush()
먼저 `flush()`를 통해 해결하는 방법이 있었다.
`flush()`란 영속성 컨텍스트의 변경 내용을 DB에 반영하는 것을 말한다.
현재 `JPA 쓰기 지연` 메커니즘으로 인해 내가 작성한 로직과는 다른 순서로 DB에 반영되는 것이 데드락의 원인이었다.
따라서 `flush()`를 통해 `UPDATE 쿼리`를 먼저 반영하는 방식으로 해결할 수 있었다.
@Transactional
public Long issue(Long couponId, Long userId) {
final Coupon coupon = validateCoupon(couponId);
coupon.issue(QUANTITY_TO_ISSUE_COUPON);
couponRepository.flush();
//TODO User 검증 필요
final IssuedCoupon issuedCoupon = IssuedCoupon.of(userId, coupon);
final IssuedCoupon savedIssuedCoupon = issuedCouponRepository.save(issuedCoupon);
return savedIssuedCoupon.getId();
}
그런데 비즈니스 로직 중간에 JPA 메서드 `flush()`를 호출하는 방식은 다소 어색하다는 생각이 들었다.
3-2. AggregateRoot와 Domain Event
다음은 JPA domain event 발행을 통해 트랜잭션의 순서를 제어하는 방법이다.
`Aggregate Root`는 도메인 주도 설계의 개념에서 나온 용어로, 비즈니스 로직을 중심으로 구성된 객체의 진입점 역할을 한다.
Aggregate Root는 해당 Aggregate의 상태를 관리하고 데이터의 일관성을 보장한다.
Aggregate 내부의 상태를 변경할 때, 변경된 상태를 외부에 알릴 필요가 있을 수 있다.
이 때 `Domain Event`가 등장한다.
`Domain Event`는 도메인 모델에서 발생한 중요한 비즈니스 이벤트를 표현하는 객체이다.
보통 Aggregate Root가 자신의 상태를 변경한 후, 변경 사실을 외부에 알리기 위해 `Domain Event`를 발행한다.
따라서, 쿠폰이 사용자에게 발급될 때, 그 정보를 외부로 전달하는 이벤트를 생성할 수 있다.
이벤트는 Domain 내에서 생성된 후, Service 계층에서 발행될 수 있다.
이벤트 리스너는 기본적으로 기존에 이벤트를 호출한 메서드의 트랜잭션이 커밋된 후 실행되기 때문에
트랜잭션 순서를 제어할 수 있다.
즉, 이벤트 리스너의 로직은 기존 메서드 트랜잭션과 다른 트랜잭션 컨텍스트에서 실행된다.
구현 예시는 다음과 같다.
import java.util.ArrayList;
import java.util.List;
public abstract class AggregateRoot {
private final List<Object> domainEvents = new ArrayList<>();
// 도메인 이벤트 등록
protected void addDomainEvent(Object event) {
domainEvents.add(event);
}
// 도메인 이벤트 반환
public List<Object> getDomainEvents() {
return new ArrayList<>(domainEvents); // Immutable list를 반환
}
// 도메인 이벤트 초기화
public void clearDomainEvents() {
domainEvents.clear();
}
}
`AggregateRoot`를 상속받으면 도메인 이벤트를 관리하는 작업이 더 간편해진다.
`Coupon` 클래스가 이를 상속 받으면 이벤트 관리 메서드를 직접 구현할 필요 없이 기본 메서드를 바로 사용할 수 있다.
또한 `AggreagateRoot`를 사용하면 모든 도메인 이벤트가 일정한 흐름으로 처리되므로 일관성 있는 이벤트 관리가 가능하다.
- Aggregate Domain에서 이벤트를 등록 (addDomainEvent)
- Repository를 통해 변경된 상태 저장
- 저장 후 이벤트를 읽고 발행
@Entity
public class Coupon extends AggregateRoot {
...
public void issue(int quantity, Long userId) {
...
// 이벤트 등록 (AggregateRoot에서 제공된 메서드 사용)
addDomainEvent(new IssuedCouponEvent(this.id, userId));
}
}
@Component
public class IssuedCouponEventListener {
private final IssuedCouponRepository issuedCouponRepository;
public IssuedCouponEventListener(IssuedCouponRepository issuedCouponRepository) {
this.issuedCouponRepository = issuedCouponRepository;
}
@TransactionalEventListener
public void handleIssuedCouponEvent(IssuedCouponEvent event) {
IssuedCoupon issuedCoupon = IssuedCoupon.of(event.getUserId(), event.getCouponId());
issuedCouponRepository.save(issuedCoupon);
System.out.println("Issued coupon saved: " + issuedCoupon.getId());
}
}
이벤트 리스너는 `@TransactionalEventListener`를 사용하여, 발행된 이벤트를 처리한다.
이벤트가 발행되면, 해당 이벤트에 대해 리스너가 실행된다. 리스너는 이벤트 객체를 받아서 처리하는 로직을 수행한다.
실행 흐름을 요약하면 다음과 같다.
- 쿠폰 발급 시 Coupon 엔티티가 IssuedCouponEvent를 발행한다.
- 이벤트가 발행되면 `@TransactionalEventListener`가 이를 감지하여 해당 리스너 메서드가 실행된다.
- 리스너에서 이벤트를 처리하여 IssuedCoupon을 생성하여 DB에 저장한다.
이렇게 하면, 트랜잭션 순서를 제어할 수 있을 뿐만 아니라, 도메인 이벤트를 통해 이벤트의 발행과 처리를 체계적으로 관리할 수 있게 된다.
3-3. 비관적 락
마지막으로 JPA의 Pessimistic Lock을 이용한 방법이 있다.
하지만 이 방식은 데드락뿐만 아니라 동시성 문제까지 해결하는 방식이므로, 이에 대해서는 다음 글에서 다뤄보도록 하겠다.
어우 데드락 이거 완전 구제불능 문제다!!! 원인 찾느라 이틀을 내리 썼다.
게다가 처음에 이해한 원인은 완전 잘못 생각한 거였다,,,
그래도 이렇게 찐 원인을 찾고 나니 속이 뻥 뚫린 느낌!
다음 글은 동시성 문제를 해결하는 과정을 작성해보려고 한다.
'공부 > Project' 카테고리의 다른 글
Springboot 쿠폰 발급 동시성 문제 해결 (feat. JPA 비관적 락) (0) | 2024.12.26 |
---|---|
작업 환경 전환 시 Git 을 활용한 임시 커밋 처리 (0) | 2024.11.19 |
[Circular dependency between the following tasks] 순환 종속성 문제 해결과 Mapper 활용 (0) | 2024.11.07 |
웹 프로젝트 서버 배포 흐름도 (0) | 2024.09.25 |
Spring Cloud FeignClient N+1 호출 문제 해결하기 (0) | 2024.09.23 |