MySQL InnoDB Unique Index는 어떻게 정합성을 보장할까?

jonghyun.log·2026년 1월 10일

MySQL

목록 보기
1/3
post-thumbnail

3줄 요약

  1. Unique IndexInnoDb에서 Record Lock(X-Lock) 기반의 락을 통해 Thread-safe 하게 동작한다.
  2. Unique Index 자체에 메커니즘이 존재하는 것이 아닌, Insert 연산이 Record Lock(X-Lock) 기반으로 동작하고 insert 시점에 unique 제약조건이 존재하면 validation 로직을 실행하는 방식
  3. 여러 쓰레드 경합 상태에서 첫번째 쓰레드가 insert 트랜잭션을 열고 X-Lock 획득 후 rollback 하게 되면 DeadLock이 발생할 수 있으니 주의하자.

MySQL은 Unique Index 제약조건을 통해 특정 테이블의 각 row들에 대해 유니크 함을 보장해준다. 그러면 어떻게 동작하길래 각 레코드가 유니크 하도록 데이터 정합성을 보장해주는 걸까?
직접 한번 먹어보면서 알아보도록 하자.

직접 먹어보기

실험 환경

  • MySQL 8.0 InnoDB
  • Spring Boot 3.5.3
  • Kotlin 1.9.25

위 환경에서 간단한 테이블을 만들고, 해당 테이블에 동시에 Insert하는 코드를 통해
어떻게 동작하는지 직접 한번 먹어보도록 하자.

테이블

CREATE TABLE IF NOT EXISTS user (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '유저 ID',
    email VARCHAR(255) NOT NULL UNIQUE COMMENT '이메일',
    name VARCHAR(100) NOT NULL COMMENT '이름',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='유저';

-- 인덱스
CREATE UNIQUE INDEX uk_email ON user(email);

실험을 위해 간단한 유저 테이블을 만들고 email에 unique index를 걸어 어떻게 동작하는지 확인해본다.

실험을 위한 코드

// 엔티티 코드
@Entity
@Table(name = "user")
class UserEntity(
    @Column(nullable = false, unique = true, length = 255)
    val email: String,

    @Column(nullable = false, length = 100)
    val name: String,
) : BaseEntity()
// 서비스 코드
@Service
class UserService(
    private val userRepository: UserRepository,
) {
    @Transactional(readOnly = true)
    fun getByEmail(email: String): User {
        val user = userRepository.findByEmail(email)
            ?: throw CoreException(ErrorType.USER_NOT_FOUND)
        return User.from(user)
    }

    fun create(email: String, name: String): User {
        val user = User(email = email, name = name)
        userRepository.save(user.toEntity())
        return user
    }
}
// 테스트 코드
class UserServiceConcurrencyTest(
    private val userService: UserService,
    private val userRepository: UserRepository,
    private val dataSource: DataSource,
) : IntegrationTest() {

    companion object {
        val logger: Logger = LoggerFactory.getLogger(UserServiceConcurrencyTest::class.java)
    }


    @Test
    fun `동시에 같은 이메일로 유저 생성 시 하나만 성공해야 한다`() {
        // given
        val threadCount = 10
        val executor = Executors.newFixedThreadPool(threadCount)
        val latch = CountDownLatch(threadCount)
        val successCount = AtomicInteger(0)
        val failCount = AtomicInteger(0)
        val email = "concurrent-test@example.com"

        val stopped = AtomicBoolean(false)

        val monitorThread = Thread {
            while (!stopped.get()) {
                val locks = queryLockInfo() // SELECT * FROM performance_schema.data_locks
                logger.info(locks)
                Thread.sleep(1) // 1ms마다 폴링
            }
        }
        monitorThread.start()


        // when: 10개의 스레드가 동시에 같은 이메일로 유저 생성 시도
        repeat(threadCount) { index ->
            executor.submit {
                try {
                    latch.countDown()
                    latch.await() // 모든 스레드가 준비될 때까지 대기 (동시 실행을 위해)

                    userService.create(
                        email = email,
                        name = "Test User $index"
                    )
                    successCount.incrementAndGet()
                } catch (e: DataIntegrityViolationException) {
                    // DB의 unique 제약 조건 위반
                    failCount.incrementAndGet()
                } 
            }
        }

        executor.shutdown()
        val terminated = executor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)
        assertTrue(terminated, "모든 스레드가 10초 내에 완료되어야 합니다")

        // 모니터링 스레드 종료
        stopped.set(true)
        monitorThread.join()

        // then
        logger.info("성공: ${successCount.get()}, 실패: ${failCount.get()}, 총: $threadCount")
        logger.info("DB 레코드 수: ${userRepository.count()}")

        assertEquals(1, successCount.get(), "정확히 하나의 요청만 성공해야 합니다")
        assertEquals(threadCount - 1, failCount.get(), "나머지 ${threadCount - 1}개 요청은 실패해야 합니다")

        // 저장된 유저 확인
        val savedUser = userRepository.findByEmail(email)
        assertEquals(email, savedUser?.email)
    }

    /**
     * performance_schema.data_locks 테이블을 조회하여 현재 락 정보를 반환
     * 별도의 JDBC 커넥션을 사용하여 테스트 트랜잭션과 독립적으로 조회
     */
    private fun queryLockInfo(): String {
        return try {
            dataSource.connection.use { connection ->
                val sql = """
                    SELECT
                        ENGINE,
                        ENGINE_LOCK_ID,
                        ENGINE_TRANSACTION_ID,
                        THREAD_ID,
                        OBJECT_SCHEMA,
                        OBJECT_NAME,
                        INDEX_NAME,
                        LOCK_TYPE,
                        LOCK_MODE,
                        LOCK_STATUS,
                        LOCK_DATA
                    FROM performance_schema.data_locks
                    WHERE OBJECT_SCHEMA = 'commerce'
                    ORDER BY ENGINE_TRANSACTION_ID, LOCK_TYPE
                """.trimIndent()   // 쿼리 실행시점의 모든 락 조회 (사용하는 db의 락만)

                val statement = connection.createStatement()
                val resultSet = statement.executeQuery(sql)

                val locks = mutableListOf<String>()
                while (resultSet.next()) {
                    val lockInfo = buildString {
                        append("┌─────────────────────────────────────\n")
                        append("│ TRANSACTION: ${resultSet.getLong("ENGINE_TRANSACTION_ID")}\n")
                        append("│ THREAD_ID: ${resultSet.getLong("THREAD_ID")}\n")
                        append("│ TABLE: ${resultSet.getString("OBJECT_SCHEMA")}.${resultSet.getString("OBJECT_NAME")}\n")
                        append("│ INDEX: ${resultSet.getString("INDEX_NAME") ?: "N/A"}\n")
                        append("│ LOCK_TYPE: ${resultSet.getString("LOCK_TYPE")}\n")
                        append("│ LOCK_MODE: ${resultSet.getString("LOCK_MODE")}\n")
                        append("│ LOCK_STATUS: ${resultSet.getString("LOCK_STATUS")}\n")
                        append("│ LOCK_DATA: ${resultSet.getString("LOCK_DATA") ?: "N/A"}\n")
                        append("└─────────────────────────────────────")
                    }
                    locks.add(lockInfo)
                }

                if (locks.isEmpty()) {
                    "[${System.currentTimeMillis()}] No locks found"
                } else {
                    "\n[${System.currentTimeMillis()}] Found ${locks.size} lock(s):\n" + locks.joinToString("\n")
                }
            }
        } catch (e: Exception) {
            "[ERROR] Failed to query lock info: ${e.message}"
        }
    }
}

위와 같이 간단한 엔티티, 서비스 코드 그리고 테스트 코드를 작성하여 직접 MySQL InnoDB 에서 Unique Index 가 어떻게 동작하는지 눈으로 확인해보고 테스트 할 것이다.

테스트 코드 설명

테스트 코드는 크게 다음의 두가지 로직으로 수행된다.

  1. ThreadPoolExecutors + CountDownLatch 를 통해 동시에UserService.create() 메서드를 호출
  2. DataSource 를 통한 별도 커넥션 객체로 1의 커넥션들이 Insert Transaction을 수행되는 동안 User 테이블에 Lock 정보가 어떻게 처리되는지 로깅
    (performance_schema.data_locks 테이블 정보 사용)

1 에서 User 테이블에 Unique Index를 통한 Insert에 실패하면 DataIntegrityViolationException 이 발생할 것이므로 발생한 경우에 fail count를 증가시켜서 성공/실패 건수를 체크한다.

테스트 결과

테스트 실행 결과 로그

2026-01-09T20:07:46.163+09:00  INFO 3864 --- [Thread-3] i.j.c.c.d.u.UserServiceConcurrencyTest   : [1767956866163] No locks found
2026-01-09T20:07:46.166+09:00  INFO 3864 --- [Thread-3] i.j.c.c.d.u.UserServiceConcurrencyTest   : [1767956866166] No locks found
2026-01-09T20:07:46.169+09:00  INFO 3864 --- [Thread-3] i.j.c.c.d.u.UserServiceConcurrencyTest   : [1767956866169] No locks found
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
2026-01-09T20:07:46.477+09:00  WARN 3864 --- [pool-2-thread-4] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-09T20:07:46.477+09:00  WARN 3864 --- [pool-2-thread-5] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-09T20:07:46.477+09:00  WARN 3864 --- [pool-2-thread-8] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-09T20:07:46.478+09:00 ERROR 3864 --- [pool-2-thread-4] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'concurrent-test@example.com' for key 'user.email'
2026-01-09T20:07:46.478+09:00  WARN 3864 --- [pool-2-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-09T20:07:46.478+09:00 ERROR 3864 --- [pool-2-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'concurrent-test@example.com' for key 'user.email'
2026-01-09T20:07:46.478+09:00 ERROR 3864 --- [pool-2-thread-5] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'concurrent-test@example.com' for key 'user.email'
2026-01-09T20:07:46.478+09:00 ERROR 3864 --- [pool-2-thread-8] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'concurrent-test@example.com' for key 'user.email'
2026-01-09T20:07:46.482+09:00  WARN 3864 --- [pool-2-thread-6] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-09T20:07:46.482+09:00 ERROR 3864 --- [pool-2-thread-6] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'concurrent-test@example.com' for key 'user.email'
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
2026-01-09T20:07:46.486+09:00  INFO 3864 --- [Thread-3] i.j.c.c.d.u.UserServiceConcurrencyTest   : [1767956866486] No locks found
2026-01-09T20:07:46.487+09:00  WARN 3864 --- [pool-2-thread-9] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-09T20:07:46.487+09:00  WARN 3864 --- [pool-2-thread-10] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-09T20:07:46.487+09:00  WARN 3864 --- [pool-2-thread-7] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-09T20:07:46.487+09:00 ERROR 3864 --- [pool-2-thread-9] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'concurrent-test@example.com' for key 'user.email'
2026-01-09T20:07:46.487+09:00 ERROR 3864 --- [ool-2-thread-10] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'concurrent-test@example.com' for key 'user.email'
2026-01-09T20:07:46.487+09:00 ERROR 3864 --- [pool-2-thread-7] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'concurrent-test@example.com' for key 'user.email'
2026-01-09T20:07:46.488+09:00  WARN 3864 --- [pool-2-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-09T20:07:46.488+09:00 ERROR 3864 --- [pool-2-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'concurrent-test@example.com' for key 'user.email'
2026-01-09T20:07:46.491+09:00  INFO 3864 --- [Thread-3] i.j.c.c.d.u.UserServiceConcurrencyTest   : 
[1767956866489] Found 4 lock(s):
┌─────────────────────────────────────
│ TRANSACTION: 2275
│ THREAD_ID: 302
│ TABLE: commerce.user
│ INDEX: email
│ LOCK_TYPE: RECORD
│ LOCK_MODE: S
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: 'concurrent-test@example.com', 418
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 2275
│ THREAD_ID: 302
│ TABLE: commerce.user
│ INDEX: PRIMARY
│ LOCK_TYPE: RECORD
│ LOCK_MODE: X
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: supremum pseudo-record
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 2275
│ THREAD_ID: 302
│ TABLE: commerce.user
│ INDEX: PRIMARY
│ LOCK_TYPE: RECORD
│ LOCK_MODE: X,INSERT_INTENTION
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: supremum pseudo-record
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 2275
│ THREAD_ID: 302
│ TABLE: commerce.user
│ INDEX: N/A
│ LOCK_TYPE: TABLE
│ LOCK_MODE: IX
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: N/A
└─────────────────────────────────────
2026-01-09T20:07:46.493+09:00  INFO 3864 --- [main] i.j.c.c.d.u.UserServiceConcurrencyTest   : 성공: 1, 실패: 9, 총: 10
Hibernate: 
    select
        count(*) 
    from
        user ue1_0
2026-01-09T20:07:46.700+09:00  INFO 3864 --- [main] i.j.c.c.d.u.UserServiceConcurrencyTest   : DB 레코드 수: 1
Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.email,
        ue1_0.name,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.email=?
2026-01-09T20:07:46.749+09:00  INFO 3864 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2026-01-09T20:07:46.750+09:00  INFO 3864 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : core-db-pool - Shutdown initiated...
2026-01-09T20:07:46.755+09:00  INFO 3864 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : core-db-pool - Shutdown completed.

종료 코드 0(으)로 완료된 프로세스

Users 테이블에 최종 insert 된 레코드는 다음의 한개만 존재한다.

db에 인서트한 결과

테스트 실행 결과 해석

로그를 확인해보면 insert 쿼리가 Create() 메서드 함수 호출의 갯수와 동일한 10번 호출된 것을 확인해볼 수 있다. 각 쓰레드가 거의 동시에 create() 함수 호출을 시작하여 Hibernate 단에서 insert 쿼리 로그가 호출되어 DB에 쿼리가 전송되었고,

// 이런 로그가 10번 찍혀있음
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)

제일 먼저 도착한 [pool-2-thread-1] 만 insert 요청에 성공했다.
(해당 쓰레드만 아래와 같이 실패 로그가 없음)

SQL Error: 1062, SQLState: 23000
Duplicate entry 'concurrent-test@example.com' for key 'user.email'

위에서 실행한 로그를 보기 쉽게 그림으로 그리면 다음과 같다.

테스트 실행 결과 시각화

Q : Unique Index는 어떻게 Unique 한 데이터를 보장할까?

위의 실험에서 동시에 같은 Email 데이터를 가지고 10개의 쓰레드로 insert 했을때,
1개의 요청만 성공하고 나머지 9개의 요청은

  1. MySQL : SQL Error: 1062, SQLState: 23000 발생
  2. Spring : DataIntegrityViolationException Throw

로 이어져 요청이 실패하였다.
그러면 MySQL 내부에서 어떤일이 일어나길래 1062 에러가 발생하여 동시성 요청을 처리하는 걸까?

A : insert 연산의 X-Lock을 통해 정합성 보장(비관적락 방식)

이에 관해 MySQL 공식문서 - Locks Set by Different SQL Statements in InnoDB에는 다음과 같이 언급되어있다.

INSERT 문은 삽입된 행에 배타적 잠금을 설정합니다. 이 잠금은 인덱스 레코드 잠금이며, 다음 키 잠금(즉, 간격 잠금)이 아니므로 다른 세션이 삽입된 행 앞의 간격에 삽입하는 것을 막지 않습니다.

insert 연산은 다음과 같이 수행된다.

  1. insert 할 위치 B-Tree를 통한 탐색 (이때, Latch를 통해 Thread-safe 하게 동작)
  2. 탐색 후 unique 제약조건(unique-index 혹은 pk) 가 있다면 중복체크
  3. 중복 존재 여부 따라 다르게 동작
    3-1. 중복 체크한 데이터가 없다면 -> X-Lock(배타적 락) 획득 후 insert 수행
    3-2. insert 할 데이터가 있다면 -> S-Lock(공유 락) 획득 후 다른 트랜잭션이 완료될때까지 대기
    및 다른 트랜잭션이 성공하면 실패, 다른 트랜잭션이 실패하면 X-Lock 획득 후 insert 수행

MySQL InnoDB에서 Insert 문의 수행 동작은 아래 그림과 같다.

MySQL InnoDB Insert 수행과정

좀 더 자세한 내용이 궁금하다면 해당 코드를 통해 확인해 볼 수 있다.

즉, 기본적으로 insert 연산에 X-Lock을 얻는 동작이 포함 되어있으며,
위의 실험 환경에서는 해당 X-Lock을 얻는 과정에서 1번 쓰레드를 제외한 다른 쓰레드들이 대기하고 1번 쓰레드들이 처리한 이후에 X-Lock을 얻게 되니 중복 처리 로직에 의해 에러가 발생하게 되는것이다.

또한, B-Tree 탐색 연산 자체를 Latch 라는 메커니즘을 통해 Thread-safe하게 동작하기 때문에 락을 얻기 이전에 Race Condition이 발생하지 않는다.

// row_ins_scan_sec_index_for_duplicate() 함수 내부
// Insert 연산 수행시 인덱스에 포함된 Latch 여부 검증 로직
ut_ad(s_latch == rw_lock_own_flagged(&index->lock, RW_LOCK_FLAG_S | RW_LOCK_FLAG_SX));

(자세한 내용은 해당 코드 참고)

자체적으로 Unique Index에 다르게 동작하는 로직이 포함된 것이 아닌,
insert 연산 자체에 Unique 제약조건(PK 제약조건 포함)이 존재한다면 Lock 을 획득하고 정합성을 보장하는 로직이 포함되어있다!

위의 실험 결과에서 1번 쓰레드가 제일 먼저 수행되고 commit 했기 때문에 다른 요청들이 성공하였다. 그렇다면, 만약 1번 쓰레드가 제일 먼저 X-Lock을 얻고 연산을 수행하고 Rollback 된다면 그 어떻게 동작할까?

Unique Index 가 걸려있을때 Dead Lock이 발생하는 경우

이를 위해 다음과 같은 테스트를 작성하고 실행해보자

테스트 코드

	@Test
    fun `동시에 여러 요청이 있을때 첫번째 요청이 insert 후 rollback하면 DeadLock이 발생한다`() {
        // given
        val threadCount = 10
        val executor = Executors.newFixedThreadPool(threadCount)
        val latch = CountDownLatch(threadCount)
        val successCount = AtomicInteger(0)
        val failCount = AtomicInteger(0)
        val deadLockCount = AtomicInteger(0)
        val firstInsertDoneLatch = CountDownLatch(1)

        val email = "rollback-test@example.com"

        val stopped = AtomicBoolean(false)

        val monitorThread = Thread {
            while (!stopped.get()) {
                val locks = queryLockInfo() // SELECT * FROM performance_schema.data_locks
                logger.info(locks)
                Thread.sleep(1) // 1ms마다 폴링
            }
        }

        val transactionTemplate = TransactionTemplate(transactionManager)
        val firstAndRollbackThread = Thread {
            transactionTemplate.execute { status ->
                userService.create(
                    email = email,
                    name = "Test First user",
                )
                logger.info("=== 첫 번째 INSERT 완료, 다른 스레드들 시작 허용 ===")
                firstInsertDoneLatch.countDown()  // 다른 스레드들 시작 신호

                Thread.sleep(200)  // 다른 스레드들이 락 대기 상태 진입할 시간

                logger.info("=== 롤백 실행 ===")
                status.setRollbackOnly()
            }
        }

        monitorThread.start()
        firstAndRollbackThread.start()

        // when: 10개의 스레드가 동시에 같은 이메일로 유저 생성 시도
        repeat(threadCount) { index ->
            executor.submit {
                try {
                    latch.countDown()
                    latch.await() // 모든 스레드가 준비될 때까지 대기 (동시 실행을 위해)

                    firstInsertDoneLatch.await() // 첫번째 쓰레드 실행될때까지 대기

                    // 여기서 예외 발생할 것!
                    userService.create(
                        email = email,
                        name = "Test User $index",
                    )

                    successCount.incrementAndGet()
                } catch (e: PessimisticLockingFailureException) {
                    // DeadlockLoserDataAccessException 클래스가 Deprecated 되어 PessimisticLockingFailureException 사용
                    // 데드락 패배자 (트랜잭션이 롤백됨)
                    logger.warn("데드락 발생 - Thread $index: ${e.message}")
                    deadLockCount.incrementAndGet()
                } catch (_: DataIntegrityViolationException) {
                    // DB의 unique 제약 조건 위반
                    failCount.incrementAndGet()
                } catch (e: Exception) {
                    if (e.cause is DataIntegrityViolationException) {
                        failCount.incrementAndGet()
                    } else {
                        throw e
                    }
                }
            }
        }

        // .. then 편의상 생략
    }

테스트 코드 설명

위의 테스트 코드는 Unique Index 사용시 데드락이 발생하는 경우를 재현해보기 위해 작성한 테스트로 다음의 로직으로 수행된다.

  1. 첫번째로 Insert 요청을 수행하는 트랜잭션을 열고 잠시 대기하다가 롤백하는 쓰레드
    (2의 쓰레드들이 요청 처리될때까지 잠시 대기 후 롤백)
  2. ThreadPoolExecutors + CountDownLatch 를 통해 동시에 UserService.create() 메서드를 호출 (위 테스트와 동일)
  3. DataSource 를 통한 별도 커넥션 객체로 1의 커넥션들이 Insert Transaction을 수행되는 동안 User 테이블에 Lock 정보가 어떻게 처리되는지 로깅 (위 테스트와 동일)
    (performance_schema.data_locks 테이블 정보 사용)

데드락 테스트 실행 결과

2026-01-13T16:05:07.528+09:00  INFO 40419 --- [core-api-test] [       Thread-3] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : [1768287907528] No locks or lock waits found
2026-01-13T16:05:07.532+09:00  INFO 40419 --- [core-api-test] [       Thread-3] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : [1768287907532] No locks or lock waits found
2026-01-13T16:05:07.535+09:00  INFO 40419 --- [core-api-test] [       Thread-3] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : [1768287907535] No locks or lock waits found
2026-01-13T16:05:07.539+09:00  INFO 40419 --- [core-api-test] [       Thread-3] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : [1768287907539] No locks or lock waits found
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
2026-01-13T16:05:07.542+09:00  INFO 40419 --- [core-api-test] [       Thread-3] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : [1768287907542] No locks or lock waits found
2026-01-13T16:05:07.546+09:00  INFO 40419 --- [core-api-test] [       Thread-3] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : [1768287907546] No locks or lock waits found
2026-01-13T16:05:07.550+09:00  INFO 40419 --- [core-api-test] [       Thread-3] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : 
[1768287907549] Found 1 lock(s):

═══ ALL LOCKS (모든 락 정보) ═══
┌─────────────────────────────────────
│ TRANSACTION: 4473
│ THREAD_ID: 253
│ TABLE: commerce.user
│ INDEX: N/A
│ LOCK_TYPE: TABLE
│ LOCK_MODE: IX
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: N/A
└─────────────────────────────────────
2026-01-13T16:05:07.553+09:00  INFO 40419 --- [core-api-test] [       Thread-3] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : 
[1768287907553] Found 1 lock(s):

═══ ALL LOCKS (모든 락 정보) ═══
┌─────────────────────────────────────
│ TRANSACTION: 4473
│ THREAD_ID: 253
│ TABLE: commerce.user
│ INDEX: N/A
│ LOCK_TYPE: TABLE
│ LOCK_MODE: IX
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: N/A
└─────────────────────────────────────
2026-01-13T16:05:07.557+09:00  INFO 40419 --- [core-api-test] [       Thread-3] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : 
[1768287907557] Found 1 lock(s):

═══ ALL LOCKS (모든 락 정보) ═══
┌─────────────────────────────────────
│ TRANSACTION: 4473
│ THREAD_ID: 253
│ TABLE: commerce.user
│ INDEX: N/A
│ LOCK_TYPE: TABLE
│ LOCK_MODE: IX
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: N/A
└─────────────────────────────────────
2026-01-13T16:05:07.557+09:00  INFO 40419 --- [core-api-test] [       Thread-4] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : === 첫 번째 INSERT 완료, 다른 스레드들 시작 허용 ===
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
2026-01-13T16:05:07.760+09:00  INFO 40419 --- [core-api-test] [       Thread-4] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : === 롤백 실행 ===
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
2026-01-13T16:05:08.049+09:00  WARN 40419 --- [core-api-test] [pool-2-thread-8] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-13T16:05:08.050+09:00 ERROR 40419 --- [core-api-test] [pool-2-thread-8] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'rollback-test@example.com' for key 'user.email'
2026-01-13T16:05:08.049+09:00  WARN 40419 --- [core-api-test] [ool-2-thread-10] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1213, SQLState: 40001
2026-01-13T16:05:08.050+09:00 ERROR 40419 --- [core-api-test] [ool-2-thread-10] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : Deadlock found when trying to get lock; try restarting transaction
2026-01-13T16:05:08.050+09:00  WARN 40419 --- [core-api-test] [pool-2-thread-1] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1213, SQLState: 40001
2026-01-13T16:05:08.050+09:00 ERROR 40419 --- [core-api-test] [pool-2-thread-1] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : Deadlock found when trying to get lock; try restarting transaction
2026-01-13T16:05:08.050+09:00  WARN 40419 --- [core-api-test] [pool-2-thread-2] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1213, SQLState: 40001
2026-01-13T16:05:08.050+09:00 ERROR 40419 --- [core-api-test] [pool-2-thread-2] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : Deadlock found when trying to get lock; try restarting transaction
2026-01-13T16:05:08.054+09:00  WARN 40419 --- [core-api-test] [pool-2-thread-4] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-13T16:05:08.054+09:00 ERROR 40419 --- [core-api-test] [pool-2-thread-4] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'rollback-test@example.com' for key 'user.email'
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
2026-01-13T16:05:08.056+09:00  WARN 40419 --- [core-api-test] [ool-2-thread-10] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : 데드락 발생 - Thread 9: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [insert into user (created_at,email,name,updated_at) values (?,?,?,?)]; SQL [insert into user (created_at,email,name,updated_at) values (?,?,?,?)]
2026-01-13T16:05:08.056+09:00  WARN 40419 --- [core-api-test] [pool-2-thread-2] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : 데드락 발생 - Thread 1: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [insert into user (created_at,email,name,updated_at) values (?,?,?,?)]; SQL [insert into user (created_at,email,name,updated_at) values (?,?,?,?)]
2026-01-13T16:05:08.056+09:00  WARN 40419 --- [core-api-test] [pool-2-thread-1] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : 데드락 발생 - Thread 0: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [insert into user (created_at,email,name,updated_at) values (?,?,?,?)]; SQL [insert into user (created_at,email,name,updated_at) values (?,?,?,?)]
Hibernate: 
    insert 
    into
        user
        (created_at, email, name, updated_at) 
    values
        (?, ?, ?, ?)
2026-01-13T16:05:08.057+09:00  WARN 40419 --- [core-api-test] [pool-2-thread-6] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-13T16:05:08.057+09:00  WARN 40419 --- [core-api-test] [pool-2-thread-5] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-13T16:05:08.057+09:00 ERROR 40419 --- [core-api-test] [pool-2-thread-6] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'rollback-test@example.com' for key 'user.email'
2026-01-13T16:05:08.057+09:00 ERROR 40419 --- [core-api-test] [pool-2-thread-5] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'rollback-test@example.com' for key 'user.email'
2026-01-13T16:05:08.058+09:00  WARN 40419 --- [core-api-test] [pool-2-thread-9] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-13T16:05:08.058+09:00 ERROR 40419 --- [core-api-test] [pool-2-thread-9] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'rollback-test@example.com' for key 'user.email'
2026-01-13T16:05:08.058+09:00  WARN 40419 --- [core-api-test] [pool-2-thread-7] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2026-01-13T16:05:08.058+09:00 ERROR 40419 --- [core-api-test] [pool-2-thread-7] [                                                 ] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'rollback-test@example.com' for key 'user.email'
2026-01-13T16:05:08.063+09:00  INFO 40419 --- [core-api-test] [       Thread-3] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : 
[1768287908063] Found 10 lock(s):

═══ ALL LOCKS (모든 락 정보) ═══
┌─────────────────────────────────────
│ TRANSACTION: 4484
│ THREAD_ID: 254
│ TABLE: commerce.user
│ INDEX: email
│ LOCK_TYPE: RECORD
│ LOCK_MODE: S
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: 'rollback-test@example.com', 4
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 4484
│ THREAD_ID: 256
│ TABLE: commerce.user
│ INDEX: PRIMARY
│ LOCK_TYPE: RECORD
│ LOCK_MODE: X
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: supremum pseudo-record
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 4484
│ THREAD_ID: 254
│ TABLE: commerce.user
│ INDEX: N/A
│ LOCK_TYPE: TABLE
│ LOCK_MODE: IX
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: N/A
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 4485
│ THREAD_ID: 256
│ TABLE: commerce.user
│ INDEX: email
│ LOCK_TYPE: RECORD
│ LOCK_MODE: S
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: 'rollback-test@example.com', 4
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 4485
│ THREAD_ID: 256
│ TABLE: commerce.user
│ INDEX: PRIMARY
│ LOCK_TYPE: RECORD
│ LOCK_MODE: X
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: supremum pseudo-record
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 4485
│ THREAD_ID: 256
│ TABLE: commerce.user
│ INDEX: N/A
│ LOCK_TYPE: TABLE
│ LOCK_MODE: IX
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: N/A
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 4486
│ THREAD_ID: 255
│ TABLE: commerce.user
│ INDEX: PRIMARY
│ LOCK_TYPE: RECORD
│ LOCK_MODE: X,INSERT_INTENTION
│ LOCK_STATUS: WAITING
│ LOCK_DATA: supremum pseudo-record
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 4486
│ THREAD_ID: 255
│ TABLE: commerce.user
│ INDEX: N/A
│ LOCK_TYPE: TABLE
│ LOCK_MODE: IX
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: N/A
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 4487
│ THREAD_ID: 253
│ TABLE: commerce.user
│ INDEX: PRIMARY
│ LOCK_TYPE: RECORD
│ LOCK_MODE: X,INSERT_INTENTION
│ LOCK_STATUS: WAITING
│ LOCK_DATA: supremum pseudo-record
└─────────────────────────────────────
┌─────────────────────────────────────
│ TRANSACTION: 4487
│ THREAD_ID: 253
│ TABLE: commerce.user
│ INDEX: N/A
│ LOCK_TYPE: TABLE
│ LOCK_MODE: IX
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: N/A
└─────────────────────────────────────
2026-01-13T16:05:08.065+09:00  INFO 40419 --- [core-api-test] [           main] [                                                 ] i.j.c.c.d.u.UserServiceConcurrencyTest   : 성공: 1, UK위반: 6, 데드락: 3
2026-01-13T16:05:08.075+09:00  INFO 40419 --- [core-api-test] [ionShutdownHook] [                                                 ] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2026-01-13T16:05:08.076+09:00  INFO 40419 --- [core-api-test] [ionShutdownHook] [                                                 ] com.zaxxer.hikari.HikariDataSource       : core-db-pool - Shutdown initiated...
2026-01-13T16:05:08.081+09:00  INFO 40419 --- [core-api-test] [ionShutdownHook] [                                                 ] com.zaxxer.hikari.HikariDataSource       : core-db-pool - Shutdown completed.

종료 코드 0(으)로 완료된 프로세스

1번 쓰레드 이후 가장 먼저 Lock 획득에 성공한 Thread 3(id=2)이 insert에 성공했다.

DeadLock 테스트 실행 결과 해석

로그를 확인해보면 첫번째 쓰레드가 먼저 실행되고 락을 점유하고 있다가 다른 쓰레드들이 insert 요청을 수행하여 락을 얻기위해 대기하게 된다.

위 실행 로그를 시퀀스 다이어그램으로 정리하면 다음과 같다.

  1. 첫번째 쓰레드가 insert 연산을 위해 트랜잭션을 열고 해당 레코드에 대한 X-Lock 획득
  2. 이후 다른 쓰레드들이 동시에 insert 연산을 시작, 1의 쓰레드가 X-Lock을 획득하고 있으니 Blocked 됨
  3. 1의 쓰레드가 insert 연산을 Rollback하고 X-Lock을 반환
  4. 2의 쓰레드들이 일제히 S-Lock을 획득하고 해당 락을 X-Lock으로 획득하기 위해 업그레이드 요청 수행
  5. 4에서 모두가 S-Lock을 획득하고 있어 X-Lock을 획득하지 못해 데드락에 걸림
  6. 4~5의 쓰레드들이 실패처리 후 운좋게 Thread 3(id=2)S-Lock -> X-Lock 과정을 마치고 insert 에 성공
  7. 이후 쓰레드들은 DataIntegrityViolationException 발생후 실패처리 됨

(X-Lock : Exclusive Lock 배타적 락, S-Lock : Shared Lock 공유 락)

왜 이런일이 일어난걸까?

답은 위의 Insert 연산시 수행되는 로직의 그림을 참조해보면 알수있다.

insert 연산 수행시 S-Lock 먼저 획득 -> X-Lock으로 해당 락을 업그레이드 하고 insert 연산을 수행하게 되는데 이 과정에서 데드락이 걸리게 된 것이다.

위의 실행 로그에서도 동시에 여러 쓰레드가 S-Lock을 획득한 내용을 찾아볼 수 있다.

2026-01-13T16:05:08.063 ... Found 10 lock(s):

... (중략) ...

┌─────────────────────────────────────
│ TRANSACTION: 4484                 <-- 트랜잭션 A
│ THREAD_ID: 254
│ TABLE: commerce.user
│ INDEX: email
│ LOCK_TYPE: RECORD
│ LOCK_MODE: S                      <-- S-Lock 획득 성공 (GRANTED)
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: 'rollback-test@example.com', 4
└─────────────────────────────────────

... (중략) ...

┌─────────────────────────────────────
│ TRANSACTION: 4485                 <-- 트랜잭션 B
│ THREAD_ID: 256
│ TABLE: commerce.user
│ INDEX: email
│ LOCK_TYPE: RECORD
│ LOCK_MODE: S                      <-- S-Lock 획득 성공 (GRANTED)
│ LOCK_STATUS: GRANTED
│ LOCK_DATA: 'rollback-test@example.com', 4
└─────────────────────────────────────

4484 트랜잭션과 4485 트랜잭션이 S-Lock 획득에 성공하였고 그 이후 X-Lock(배타적 락) 업그레이드 과정에서 데드락이 걸리게 된다.
(S-Lock은 여러 트랜잭션이 공유해서 가질수 있지만, X-Lock(배타적 락) 은 말 그대로 하나의 트랜잭션만 가질수 있기 떄문)

데드락에 걸린 쓰레드는 MySQL InnoDB에서 자동으로 감지하여 실패처리하게 된다.

레슨런

Unique Index는 평소에도 많이 사용했던 제약조건인데 실제로 어떻게 동작하는지 알아보는 과정에서 Two-Phase Locking(2PL) 관련 개념부터 MySQL Index, Lock 관련 개념을 눈으로 보면서 접해볼 수 있었습니다.

다음에는 MySQL에서 다른 락 기법은 어떤게 있는지 먹어보고 데드락을 피할 수 있는 방법에 대해 다뤄보겠습니다.

0개의 댓글