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

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

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 마다 다름)
  • 혹여나 알 수 없는 데드락 증상이 발생한다면 해당 이슈를 참고해보자.

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

목차

1. Lock의 필요성
2. Lock의 종류
3. Lock의 동작 원리
4. Lock의 문제점과 해결책
5. 결론


개발자들은 종종 특정 기능을 개발할 때 동시성 이슈를 고려하지 않는 경우가 있다.

대부분의 상황에서는 동시성 이슈를 고민할 필요가 없지만, 포인트 차감이나 재고 관리 기능 등 특정 행을 동시에 수정하는 경우 동시성 이슈가 발생할 수 있다.

이때 별도의 락을 사용하지 않으면 동시성 이슈로 인해 의도하지 않은 수정이 발생할 수 있다.
따라서 데이터의 일관성과 무결성을 보장하기 위해 DB 락을 거는 작업은 매우 중요하다.
물론 모든 작업에 대해서 락을 거는 것은 효율성 측면에서 굉장히 비효율적이다.

그래서 무결성, 일관성이 보장되어야 하는 작업인 경우에 적절하게 락을 활용하는 것이 좋다.

1. Lock의 필요성

  • 데이터베이스 락은 다수의 트랜잭션이 동시에 데이터베이스의 동일한 자원을 수정하려고 할 때 발생할 수 있는 문제를 방지하기 위해 사용된다.
  • 락을 통해 데이터 무결성을 유지하고 데이터 일관성을 보장할 수 있다.

2. Lock의 종류

Shared Lock (공유 락)

  • 공유 락은 여러 트랜잭션이 동시에 데이터를 읽을 수 있도록 허용하지만, 데이터의 쓰기는 허용하지 않는 락이다.
  • 공유 락의 주요 목적은 데이터 일관성을 유지하면서도 여러 트랜잭션이 동일한 데이터에 접근할 수 있도록 하는 것이다.
  • 즉, 공유 락끼리는 같은 자원을 점유할 수 있지만 베타 락 (데이터 쓰기)의 점유는 허용하지 않는다.
  • select 구문 뒤에 LOCK IN SHARE MODEFOR SHARE 을 붙여서 공유락을 걸 수 있다.
  • 두 구문은 데이터베이스 시스템에 따라서 다르다.
  • SELECT ... LOCK IN SHARE MODE는 MySQL에서 사용
  • SELECT ... FOR SHARE는 SQL 표준에 따라 PostgreSQL, Oracle에서 사용
  • SELECT ... FOR SHARE는 SQL 표준이라서 조금 더 널리 사용되는 것 같다.

예시

테이블 생성 및 데이터

create table user_point
(
    id      bigint auto_increment primary key,
    points  bigint not null,
    user_id bigint not null
);
# 위와 같이 회원 포인트를 저장하는 테이블이 있다고 가정한다
INSERT INTO user_point (id, points, user_id) VALUES (1, 1000, 1);

트랜잭션1 - user_id 1에 공유 락 걸기

# 트랜잭션 시작 후 공유 락을 건다
START TRANSACTION;
select * from user_point where user_id = 1 FOR SHARE;

# 커밋 하지 않고 대기한다

트랜잭션2 - user_id 1에 공유 락 걸기

# 트랜잭션 시작 후 공유 락을 건다
# 트랜잭션 1에서 공유락을 걸었지만 같은 공유락끼리는 자원을 공유할 수 있다.
START TRANSACTION;
select * from user_point where user_id = 1 FOR SHARE;
commit;

트랜잭션3 - 포인트 차감

# update문을 실행하면 기본적으로 행수준에서 베타락을 건다.
START TRANSACTION;
update user_point
set points = points -1
where user_id = 1;
commit;
  • user_id 1 자원에 공유락이 걸려있기 때문에 트랜잭션 1,2가 commit 되면 트랜잭션 3의 작업인 포인트 차감을 수행할 수 있다.

Exclusive Lock (베타 락)

  • 특정 자원을 수정할 때 일관성, 무결성을 보장하기 위해 사용하는 락이다.
  • 트랜잭션 내에서 특정 자원에 베타 락이 걸리면 커밋되기 전까지 조회를 제외하고 해당 자원에 어떠한 락도 걸 수 없다.

트랜잭션1 - user_id 1에 베타 락 걸기

# 트랜잭션 시작 후 베타 락을 건다
START TRANSACTION;
select * from user_point where user_id = 1 FOR UPDATE;

# 포인트 차감
update user_point
set points = points -1
where user_id = 1;

# 커밋 하지 않고 대기한다
  • user_id 1에 베타락이 걸려 있기 때문에 다른 어떠한 락도 해당 자원을 점유할 수 없다.

트랜잭션2 - user_id 1에 공유 락 걸기

START TRANSACTION;
select * from user_point where user_id = 1 FOR SHARE;
commit;
  • 트랜잭션1에서 user_id 1에 베타락을 걸었기 때문에 대기한다.

트랜잭션3 - user_id 1에 베타 락 걸기

START TRANSACTION;
select * from user_point where user_id = 1 FOR UPDATE;
commit;
  • 트랜잭션1에서 user_id 1에 베타락을 걸었기 때문에 대기한다

트랜잭션4 - user_id 1 조회

START TRANSACTION;
select * from user_point where user_id = 1;
commit;
  • 락을 거는 것이 아닌 단순 조회 쿼리이기 때문에 조회가 가능하다.
  • 조회되는 값은 트랜잭션 격리 수준에 따라서 달라진다.

트랜잭션1 - commit

  • 트랜잭션 1이 커밋되는 순간 락 대기 큐에 저장된 값에 따라서 user_id 1 자원을 선점하게 된다.

Lock의 동작 원리

Lock 획득

  • 트랜잭션이 시작되면 수행하려는 작업(읽기, 쓰기)에 따라 공유 락 또는 베타 락을 걸 수 있다.
  • 예를 들어, 회원의 포인트를 차감하려고 할 때 베타 락을 걸어 먼저 해당 자원을 점유할 수 있다. 
  • 만약 이미 해당 자원이 선점되어 있다면, 현재 트랜잭션은 락을 획득할 수 있을 때까지 대기 큐에 추가되어 자원이 해제될 때까지 기다린다.
  • 하지만 락 대기 시간이 일정 시간을 초과하면 타임아웃이 발생하고 트랜잭션이 롤백된다.

Lock 해제

  • 락 해제는 일반적으로 트랜잭션이 커밋(commit)되거나 롤백(rollback)될 때 발생한다.

Lock의 문제점과 해결책

데드락 (Dead Lock)

  • 두 개 이상의 트랜잭션이 서로가 소유한 락을 기다리며 무한 대기 상태에 빠지는 현상이다.
  • 이는 개발 시 하나의 트랜잭션 크기와 지속시간이 길거나 여러 테이블에 락을 거는 경우 의도지 않게 발생하는 문제이다.  
  • 이를 해결하려면 락을 거는 경우 트랜잭션에 지속시간을 줄이고, 락의 범위를 줄여서 다른 트랜잭션에 영향을 끼치지 않게 해야한다.
  • 트랜잭션의 지속시간을 줄이려면 락을 걸고 수정하는 부분만 짧게 실행하고 트랜잭션을 종료하여 락을 반환하면 된다.

분산 DB 환경일 경우 문제점

  • 고가용성을 위해서 마스터 DB(write DB)가 여럿 존재할 경우 Lock을 거는 것이 힘들어 질 수 있다. 
  • 그 이유는 마스터 DB 끼리 Lock을 공유하려면 그에 대한 복잡성과 비용이 크기 때문인데 이를 해결하기 위해 나온 것이 분산락이다. 
  • 분산락은 Redis, Mysql, Zookeeper 등을 활용하여 구현할 수 있다.

결론

  • DB Lock은 데이터베이스의 무결성과 일관성을 유지하는 데 필수적인 메커니즘이다.
  • 락을 사용하면 동시성이 증가하기 때문에 처리속도 측면에서 안좋은 영향을 끼칠 수 있다.
  • 그래서 적절하게 전략을 세워서 무결성, 일관성이 꼭 보장되어야 하는 부분에 락을 사용하는 것이 중요하다.

+ Recent posts