🚀 요약

SUMMARY

InnoDB 엔진에서 비인덱스 컬럼을 WHERE 조건으로 PESSIMISTIC_WRITE를 사용하면 레코드 락(Record Lock)뿐만 아니라 갭 락(Gap Lock)이 함께 동작할 수 있으며, 이는 의도치 않은 데드락을 유발하는 원인이 될 수 있다.

  • 공식문서는 REPEATABLE READ 격리 수준 이상에서 Gap Lock 이 발생하는 경우를 설명하지만 READ_COMMITTED 와 READ_UNCOMMITTED 격리 수준에서도 Gap Lock 이 발생했다.
  • 비교유(Non-Unique) 인덱스를 WHERE 조건으로 사용해도 동일하게 데드락이 발생할 것이라 생각했지만 의외로 데드락이 발생하지 않았다.
  • 데드락을 피하기 위해서는 상황에 따라 아래 방법 등을 고민해볼 수 있다.
    • 비유니크 인덱스 조건을 **WHERE PK IN (A, B)**와 같이 기본 키(PK)를 이용한 조건으로 변경
    • 비관적 락이 아닌 낙관적 락으로 로직 변경
    • 트랜젝션 오류 시 재시도 로직 추가

⚙️ 환경

  • MariaDB 10.8.3 (InnoDB)
  • Spring Boot 2.5.1
  • JDK 1.8

💬 이슈

사내 솔루션에서 비관적 락(PESSIMISTIC_WRITE)을 사용하는 로직에 트랜잭션 간 경합이 발생하며 간헐적으로 데드락이 발생하는 문제를 겪었다. 문제 상황을 재현하기 위해 아래와 같이 코드를 구성해보았다.

엔티티

@Entity  
@Table(name = "target_table", catalog = "test")  
@Data  
public class TargetTable {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.AUTO)  
    @Column(name = "id")  
    private Integer id;  
  
    @Column(name = "col1")  
    private Integer col1;  
  
}

오류 발생 지점

private List<TargetTable> findEntityListWithLock(int col1) {  
    return rdbService.getQueryFactory()  
            .selectFrom(QTargetTable.targetTable)  
            .where(QTargetTable.targetTable.col1.eq(col1))  
            .setLockMode(LockModeType.PESSIMISTIC_WRITE)  
            .fetch();  
}

오류 메시지

Caused by: javax.persistence.OptimisticLockException: org.hibernate.exception.LockAcquisitionException: could not extract ResultSet

❓ 의문점

일반적인 경합 상황이라면, 먼저 락을 획득한 트랜잭션이 끝날 때까지 innodb_lock_wait_timeout 설정값인 50초 동안 대기한 후 타임아웃 오류가 발생해야 한다고 예상했다. 하지만 실제로는 락 획득 시도 후 1초도 안 되어 데드락 예외가 발생했다.

게다가 예외 종류도 락 획득 실패 시 예상했던 PessimisticLockException이 아니라 OptimisticLockException이어서 의아했다.

이러한 점들 때문에 단순한 경합 문제가 아닌, 로직 상에서 의도치 않은 다른 원인이 있을 것이라 판단했고, 더 깊이 파고들어 보기로 했다.

🧗 해결

MariaDB 로그 확인

서비스 오류 로그만으로는 정확한 원인 파악이 어려워, MariaDB의 데드락 로그를 직접 확인해보기로 했다. (확인 방법은 MariaDB 데드락 로그 확인 참고)

2024-09-27 18:01:00 0x7f86936af700  
*** (1) TRANSACTION:  
TRANSACTION 28480029, ACTIVE 0 sec starting index read  
mysql tables in use 1, locked 1  
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)  
MariaDB thread id 901455, OS thread handle 140215975606016, query id 53815334 172.19.0.1 root Sending data  
select targettabl0_.id as id1_184_, targettabl0_.col1 as col2_184_ from test.target_table targettabl0_ where targettabl0_.col1=50 for update  
*** WAITING FOR THIS LOCK TO BE GRANTED:  
RECORD LOCKS space id 218104 page no 3 n bits 8 index PRIMARY of table `test`.`target_table` trx id 28480029 lock_mode X locks rec but not gap waiting  
...
*** (2) TRANSACTION:  
TRANSACTION 28480030, ACTIVE 0 sec fetching rows  
mysql tables in use 1, locked 1  
LOCK WAIT 3 lock struct(s), heap size 1128, 4 row lock(s)  
MariaDB thread id 901452, OS thread handle 140215980521216, query id 53815330 172.19.0.1 root Sending data  
select targettabl0_.id as id1_184_, targettabl0_.col1 as col2_184_ from test.target_table targettabl0_ where targettabl0_.col1=20 for update  
*** WAITING FOR THIS LOCK TO BE GRANTED:  
RECORD LOCKS space id 218104 page no 3 n bits 8 index PRIMARY of table `test`.`target_table` trx id 28480030 lock_mode X locks rec but not gap waiting  
...
*** WE ROLL BACK TRANSACTION (0)

로그의 핵심은 두 트랜잭션이 record lock은 획득했지만 gap lock을 기다리다 교착 상태에 빠졌고(lock_mode X locks rec but not gap waiting), 결국 InnoDB 엔진이 둘 중 하나를 희생양(victim)으로 선택해 롤백했다는 것이다.

NOTE

갭 락(Gap Lock)이란? 갭 락은 인덱스 레코드 사이의 간격(Gap)을 잠그는 기능이다. 즉, 실제 존재하는 레코드뿐만 아니라, 조건에 해당하지만 존재하지 않는 레코드의 범위까지 잠근다. 이로 인해 다른 트랜잭션이 그 간격 내에 새로운 데이터를 추가(INSERT)하는 것을 방지하여 Phantom Read 현상을 막는다.

여기서 새로운 의문이 생겼다. 두 트랜잭션은 서로 다른 레코드를 대상으로 락을 시도했는데 왜 교착 상태가 발생했으며, 생소한 Gap Lock이란 대체 무엇일까? 일반적으로 FOR UPDATE 쿼리는 레코드(Row) 단위로 락을 획득하므로, 서로 다른 레코드를 대상으로 할 때는 경합이 발생하지 않아야 한다고 생각했다. 하지만 여기에는 한 가지 중요한 조건이 숨어있었다.

오류 재연

정확한 원인을 파악하기 위해 데드락이 발생하는 상황을 직접 재현해보았다.

테이블 세팅

MariaDB [test]> CREATE TABLE target_table (
    -> id INT NOT NULL,
    -> col1 INT DEFAULT NULL,
    -> PRIMARY KEY (id)
    -> ) ENGINE=InnoDB;
 
MariaDB [test]> INSERT INTO target_table VALUES (1, 10), (2, 20), (3, 30), (4, 20), (5, 50), (6, 10), (7, 20), (8, 30), (9, 40), (10, 50);
 
MariaDB [test]> select * from target_table;
+----+------+
| id | col1 |
+----+------+
|  1 |   10 |
|  6 |   10 |
|  2 |   20 |
|  7 |   20 |
|  3 |   30 |
|  8 |   30 |
|  4 |   40 |
|  9 |   40 |
|  5 |   50 |
| 10 |   50 |
+----+------+

테스트 코드

@Slf4j  
@RequiredArgsConstructor  
@DisplayName("데드락 테스트")  
@Transactional  
public class DeadLockTest extends IntegrationTest {  
  
    private final DeadLockTestService deadLockTestService;  
    final int NUM_THREADS = 5;  
  
    @Test  
    @DisplayName("서로 다른 스레드(트랜젝션)가 동시에 락 획득 시도")  
    void test() {  
        ExecutorService executorService = new ThreadPoolExecutor(NUM_THREADS, NUM_THREADS, 0L, TimeUnit.MILLISECONDS,  
                new LinkedBlockingQueue<>());  
  
        List<Runnable> runnables = new ArrayList<>();  
  
        for (int i = 0; i < NUM_THREADS; i++) {  
            final int threadId = i + 1; // 스레드 번호  
            Runnable runnable = () -> {  
                deadLockTestService.process(threadId);  
            };  
            runnables.add(runnable);  
        }  
        // 스레드 작업 실행  
        runnables.forEach(executorService::execute);  
  
        // 스레드 풀 종료  
        executorService.shutdown();  
        try {  
            executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);  
        } catch (InterruptedException e) {  
            Thread.currentThread().interrupt();  
        }  
    }  
}

서비스 클래스

@Service  
@Slf4j  
@RequiredArgsConstructor  
public class DeadLockTestService {  
    private final RdbService rdbService;  
  
    private List<TargetTable> findEntityListWithLock(int col1) {  
        return rdbService.getQueryFactory()  
                .selectFrom(QTargetTable.targetTable)  
                .where(QTargetTable.targetTable.col1.eq(col1))  
                .setLockMode(LockModeType.PESSIMISTIC_WRITE)
                .fetch();  
    }  
  
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void process(int threadId) {  
        // 현재 시간  
        LocalTime now = LocalTime.now();  
        // 다음 분의 00초 000밀리초까지 남은 시간 계산  
        LocalTime nextMinute = now.plusMinutes(1).truncatedTo(ChronoUnit.MINUTES);  
        long millisUntilNextMinute = ChronoUnit.MILLIS.between(now, nextMinute);   
        try {  
            // 다음 00초 000밀리초까지 대기  
            Thread.sleep(millisUntilNextMinute);  
  
            log.info("비관적 락 시도 - 조건 : col1={}", threadId * 10);  
            List<TargetTable> list = findEntityListWithLock(threadId * 10);  
            log.info("비관적 락 획득 완료 - {}", list.size());  
            list = findEntityListWithLock(threadId * 10);  
  
            Thread.sleep(10000);  
        } catch (InterruptedException e) {  
            throw new RuntimeException(e);  
        } catch (OptimisticLockException e) {  
            e.printStackTrace();  
            log.info("잡았다!");  
        } catch (PessimisticLockException e) {  
            e.printStackTrace();  
            log.info("정상적인 경우");  
        }    }  
}

테스트 진행

위 코드를 베이스로 다음과 같은 케이스로 테스트를 진행했고, 아래 결과를 얻었다.

케이스데드락 발생 여부
WHERE 조건에 PK 를 사용하는 경우발생 안함
격리수준을 REPEATABLE_READSERIALIZABLE 로 설정하는 경우발생 안함
한 트랜젝션에 FOR UPDATE 쿼리를 1번씩만 호출하는 경우발생 안함
비고유(Non-unique)인덱스로 등록한 컬럼을 WHERE 조건으로 사용하는 경우발생 안함
setHint("javax.persistence.lock.timeout", 5000) 로 설정하는 경우무관하게 발생

IMPORTANT

비고유(Non-unique) 인덱스를 WHERE 조건으로 사용해도 동일하게 데드락이 발생할 것이라 생각했지만 의외로 데드락이 발생하지 않았다. 이 부분에 대해서는 추가적으로 파악해보지 못했다.

데드락 발생 조건 분석

여러 테스트를 통해 데드락이 발생하는 특정 조건을 종합해볼 수 있었다.

  • 서로 다른 트랜젝션이 거의 동시에 락을 획득하려 했다.
  • 격리 레벨은 READ_COMMITTED 와 READ_UNCOMMITTED 일 경우에만 발생했다.
  • 인덱스가 아닌 컬럼을 조건으로 사용했다.
  • 1개 트랜젝션에서 for update 를 두 번 호출했다.

종합하면 READ_COMMITTED 또는 READ_UNCOMMITTED 격리 수준에서, 인덱스가 없는 컬럼WHERE 조건으로 사용하여, 하나의 트랜잭션에서 FOR UPDATE를 두 번 이상 호출하며, 여러 트랜잭션이 거의 동시에 락을 획득하려 할 때 발생했다.

IMPORTANT

공식 문서에서는 REPEATABLE READ 격리 수준 이상에서 Gap Lock이 발생한다고 설명하지만, 진행한 테스트에서는 READ_COMMITTEDREAD_UNCOMMITTED 격리 수준에서도 Gap Lock으로 인한 데드락이 발생했다. MariaDB 버전의 이슈나 특정 상황에 따라 동작이 다를 수 있는지에 대해서는 파악하지 못했다.

해결 방안

Gap locking is not needed for statements that lock rows using a unique index to search for a unique row. - MariaDB 공식 문서

원인이 Gap Lock에 있다는 것을 파악한 후, 서비스 로직을 수정하기로 했다. 따라서 기존 FOR UPDATE 쿼리의 WHERE 절에서 비 인덱스 컬럼 조건을 사용하는 대신, WHERE id IN (...)과 같이 PK 기반으로 레코드를 조회하도록 로직을 변경하여 문제를 해결했다.

추가적으로, 데드락이 아니더라도 발생할 수 있는 타임아웃(PessimisticLockException)에 대비하여 트랜잭션 오류 시 비즈니스 로직을 재시도하는 로직을 더한다면 더욱 안정적인 서비스를 만들 수 있을 것이라 생각한다. 이는 트랜잭션 외부에서 처리하거나 별도의 스케줄링 로직으로 구현하는 것이 어떨까 싶다.

🔗 참고