
Unique Index는InnoDb에서Record Lock(X-Lock)기반의 락을 통해 Thread-safe 하게 동작한다.Unique Index자체에 메커니즘이 존재하는 것이 아닌, Insert 연산이Record Lock(X-Lock)기반으로 동작하고 insert 시점에 unique 제약조건이 존재하면 validation 로직을 실행하는 방식- 여러 쓰레드 경합 상태에서 첫번째 쓰레드가 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 가 어떻게 동작하는지 눈으로 확인해보고 테스트 할 것이다.
테스트 코드는 크게 다음의 두가지 로직으로 수행된다.
ThreadPoolExecutors + CountDownLatch를 통해 동시에UserService.create() 메서드를 호출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 된 레코드는 다음의 한개만 존재한다.

로그를 확인해보면 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'
위에서 실행한 로그를 보기 쉽게 그림으로 그리면 다음과 같다.

위의 실험에서 동시에 같은 Email 데이터를 가지고 10개의 쓰레드로 insert 했을때,
1개의 요청만 성공하고 나머지 9개의 요청은
- MySQL :
SQL Error: 1062, SQLState: 23000발생- Spring :
DataIntegrityViolationExceptionThrow
로 이어져 요청이 실패하였다.
그러면 MySQL 내부에서 어떤일이 일어나길래 1062 에러가 발생하여 동시성 요청을 처리하는 걸까?
이에 관해 MySQL 공식문서 - Locks Set by Different SQL Statements in InnoDB에는 다음과 같이 언급되어있다.

INSERT 문은 삽입된 행에 배타적 잠금을 설정합니다. 이 잠금은 인덱스 레코드 잠금이며, 다음 키 잠금(즉, 간격 잠금)이 아니므로 다른 세션이 삽입된 행 앞의 간격에 삽입하는 것을 막지 않습니다.
insert 연산은 다음과 같이 수행된다.
- insert 할 위치 B-Tree를 통한 탐색 (이때, Latch를 통해 Thread-safe 하게 동작)
- 탐색 후 unique 제약조건(unique-index 혹은 pk) 가 있다면 중복체크
- 중복 존재 여부 따라 다르게 동작
3-1. 중복 체크한 데이터가 없다면 -> X-Lock(배타적 락) 획득 후 insert 수행
3-2. insert 할 데이터가 있다면 -> S-Lock(공유 락) 획득 후 다른 트랜잭션이 완료될때까지 대기
및 다른 트랜잭션이 성공하면 실패, 다른 트랜잭션이 실패하면 X-Lock 획득 후 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 된다면 그 어떻게 동작할까?
이를 위해 다음과 같은 테스트를 작성하고 실행해보자
@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 사용시 데드락이 발생하는 경우를 재현해보기 위해 작성한 테스트로 다음의 로직으로 수행된다.
- 첫번째로 Insert 요청을 수행하는 트랜잭션을 열고 잠시 대기하다가 롤백하는 쓰레드
(2의 쓰레드들이 요청 처리될때까지 잠시 대기 후 롤백)ThreadPoolExecutors + CountDownLatch를 통해 동시에 UserService.create() 메서드를 호출(위 테스트와 동일)- 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에 성공했다.
로그를 확인해보면 첫번째 쓰레드가 먼저 실행되고 락을 점유하고 있다가 다른 쓰레드들이 insert 요청을 수행하여 락을 얻기위해 대기하게 된다.
위 실행 로그를 시퀀스 다이어그램으로 정리하면 다음과 같다.

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