Quizit에는 사용자가 퀴즈를 북마크하는 기능이 있습니다. 해당 기능은 이미 북마크한 퀴즈를 다시 북마크하면 자동으로 북마크를 해제하는 토글 방식인데요. 같은 북마크에 요청이 겹칠 때 간헐적으로 500 응답이 발생했습니다.

문제 현상

같은 사용자와 같은 퀴즈에 북마크 요청 100개를 동시에 보내 문제를 재현했습니다.

항목값
총 요청 수100
성공 응답87
실패 응답13

실패한 일부 요청에는 다음과 같이 데드락 로그가 남아 있었습니다.

org.springframework.dao.DeadlockLoserDataAccessException:
jOOQ; SQL [insert ignore into `quiz_bookmark` (`id`, `quiz_id`, `user_id`) values (?, ?, ?)];
Deadlock found when trying to get lock; try restarting transaction

문제 원인

기존 북마크 처리 흐름은 다음과 같습니다.

QuizService.kt
1fun markQuiz(
2    userId: UUID,
3    command: MarkQuizCommand
4) {
5    transaction {
6        if (!quizRepository.existsById(command.quizId)) {
7            throw QuizNotFoundException()
8        }
9
10        if (quizBookmarkRepository.existsByQuizIdAndUserId(command.quizId, userId)) {
11            quizBookmarkRepository.deleteByQuizIdAndUserId(command.quizId, userId)
12        } else {
13            quizBookmarkRepository.saveIgnore(
14                QuizBookmark(
15                    quizId = command.quizId,
16                    userId = userId
17                )
18            )
19        }
20    }
21}

먼저 북마크 존재 여부를 조회하고, 조회 결과에 따라 삭제하거나 추가합니다. 여러 요청이 같은 상태를 조회하면 동시에 같은 분기로 진입할 수 있습니다.

1CREATE TABLE quiz_bookmark (
2    id BINARY(16) PRIMARY KEY,
3    quiz_id BINARY(16) NOT NULL,
4    user_id BINARY(16) NOT NULL,
5    UNIQUE INDEX uk_quiz_bookmark_quiz_user (quiz_id, user_id)
6);

또한 북마크 테이블에는 중복 북마크 문제를 해결하기 위해 (quiz_id, user_id)에 대한 유니크 인덱스가 존재합니다.

이제 문제의 데드락 원인을 분석하기 위해 InnoDB의 데드락 로그를 조회했습니다.

SHOW ENGINE INNODB STATUS
LATEST DETECTED DEADLOCK
------------------------
2026-08-14 20:20:48 281473010351872
*** (1) TRANSACTION:
TRANSACTION 124053, ACTIVE 0 sec inserting

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 81 page no 5 n bits 80 index uk_quiz_bookmark_quiz_user of table `quizit`.`quiz_bookmark` trx id 124053 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 4 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 5 PHYSICAL RECORD: n_fields 3; compact format; info bits 32

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 81 page no 5 n bits 80 index uk_quiz_bookmark_quiz_user of table `quizit`.`quiz_bookmark` trx id 124053 lock_mode X insert intention waiting
Record lock, heap no 1

*** (2) TRANSACTION:
TRANSACTION 124054, ACTIVE 0 sec inserting

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 81 page no 5 n bits 80 index uk_quiz_bookmark_quiz_user of table `quizit`.`quiz_bookmark` trx id 124054 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 4 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 5 PHYSICAL RECORD: n_fields 3; compact format; info bits 32

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 81 page no 5 n bits 80 index uk_quiz_bookmark_quiz_user of table `quizit`.`quiz_bookmark` trx id 124054 lock_mode X insert intention waiting
Record lock, heap no 1
*** WE ROLL BACK TRANSACTION (2)

두 트랜잭션 모두 uk_quiz_bookmark_quiz_user 인덱스에 공유 락을 보유하면서, 동시에 배타 락(Insert Intention Lock)을 기다려서 데드락이 발생한 것을 확인했습니다. INSERT의 동작 과정을 따라가면서 이러한 상황이 발생한 이유를 천천히 살펴보겠습니다.

중복 검사에서의 락

INSERT에서는 보조 인덱스 엔트리를 삽입하기 전에 유니크 제약조건이 있는 경우, 중복 후보를 스캔하는 과정을 거치는데요.

row0ins.cc
1static dberr_t row_ins_scan_sec_index_for_duplicate(...) {
2  allow_duplicates = row_allow_duplicates(thr);
3
4  do {
5    if (allow_duplicates) {
6      err = row_ins_set_rec_lock(LOCK_X, lock_type, block, rec, index, offsets, thr);
7    } else {
8      if (skip_gap_locks) {
9        if (is_supremum) {
10          continue;
11        }
12        if (is_next) {
13          goto end_scan;
14        }
15        lock_type = LOCK_REC_NOT_GAP;
16      } else if (is_supremum) {
17        lock_type = LOCK_ORDINARY;
18      } else if (is_next) {
19        lock_type = LOCK_GAP;
20      } else {
21        lock_type = LOCK_ORDINARY;
22      }
23
24      err = row_ins_set_rec_lock(LOCK_S, lock_type, block, rec, index, offsets, thr);
25    }
26
27    if (!is_next && !index->allow_duplicates) {
28      if (row_ins_dupl_error_with_rec(rec, entry, index, offsets)) {
29        err = DB_DUPLICATE_KEY;
30
31        goto end_scan;
32      }
33    }
34  } while (pcur.move_to_next(mtr));
35}

중복 스캔은 중복 후보 레코드들에 하나씩 락을 걸고 실제 중복 여부를 확인하는 과정으로 수행됩니다. 이때 사용하는 락은 공유 락 + 넥스트 키 락(Next-key Lock)인데요. 결국 로그에서 봤던 레코드들은 전부 중복 후보였기에 해당 락이 걸렸다는 것을 알 수 있었습니다.

Delete-marked Record

중복 후보에 락을 거는 이유는 이해했으나, 유니크 인덱스에서 이러한 중복 후보가 여러 개 존재하는 것은 이해하기 어려웠는데요. 이에 대한 정답은 info bits에서 찾을 수 있었습니다.

info bits는 헤더의 상태 비트 영역을 읽어 십진수로 출력한 값입니다.

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 81 page no 5 n bits 80 index uk_quiz_bookmark_quiz_user of table `quizit`.`quiz_bookmark` trx id 124053 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 4 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 5 PHYSICAL RECORD: n_fields 3; compact format; info bits 32

락이 잡힌 레코드들은 대부분 info bits가 32였는데요.

rec.h
1constexpr uint32_t REC_INFO_MIN_REC_FLAG = 0x10UL;
2constexpr uint32_t REC_INFO_DELETED_FLAG = 0x20UL;
3constexpr uint32_t REC_INFO_VERSION_FLAG = 0x40UL;
4constexpr uint32_t REC_INFO_INSTANT_FLAG = 0x80UL;

이때 info bits 32는 REC_INFO_DELETED_FLAG를 의미합니다. 즉, 삭제 표시된 레코드(Delete-marked Record)입니다. 이러한 레코드들이 존재하는 이유는 DELETE의 내부 동작 때문인데요.

InnoDB does not physically remove a row from the database immediately when you delete it with an SQL statement. A row and its index records are only physically removed when InnoDB discards the undo log record written for the deletion.
- MySQL 공식 문서

InnoDB의 DELETE는 레코드를 즉시 물리적으로 삭제하지 않습니다.

ha_innodb.cc
1int ha_innobase::delete_row(...) {
2  m_prebuilt->upd_node->is_delete = true;
3
4  if (error == DB_SUCCESS) {
5    error = row_update_for_mysql((byte *)record, m_prebuilt);
6  }
7}

대신 내부적으로 UPDATE를 통해 레코드에 삭제 표시를 해두는데요.

Purge runs on a periodic schedule. It parses and processes undo log pages from the history list, which is a list of undo log pages for committed transactions that is maintained by the InnoDB transaction system. Purge frees the undo log pages from the history list after processing them.
- MySQL 공식 문서

이러한 Delete-marked 레코드들은 주기적으로 돌아가는 퍼지 스레드(Purge Thread)에 의해 물리적으로 삭제됩니다. 결국 락이 걸린 중복 후보들은 아직 퍼지 스레드에 의해 처리되지 못한 Delete-marked 레코드들이었던 것이었습니다.

Supremum Record

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 81 page no 5 n bits 80 index uk_quiz_bookmark_quiz_user of table `quizit`.`quiz_bookmark` trx id 124053 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 4 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
Record lock, heap no 5 PHYSICAL RECORD: n_fields 3; compact format; info bits 32

그렇다면 info bits 0인 첫 번째 레코드는 무엇일까요? 해당 레코드는 info bits보다 heap no으로부터 정답을 찾을 수 있었습니다.

InnoDB에서는 페이지마다 시작과 끝을 의미하는 가상 레코드(Pseudo Record)가 존재하는데요. 이때 시작은 Infimum, 끝은 Supremum이라고 합니다.

page0page.cc
1page_t *page_create_low(...) {
2  if (comp) {
3    memcpy(page + PAGE_DATA, infimum_supremum_compact, sizeof infimum_supremum_compact);
4    memset(page + PAGE_NEW_SUPREMUM_END, 0, UNIV_PAGE_SIZE - PAGE_DIR - PAGE_NEW_SUPREMUM_END);
5  }
6
7  return (page);
8}

실제로 페이지 생성 시에 가상 레코드 템플릿(infimum_supremum_compact)을 미리 삽입해두는 것을 확인할 수 있습니다.

page0page.cc
1static const byte infimum_supremum_compact[] = {
2    /* the infimum record */
3    0x01, 0x00, 0x02 /* heap_no = 0, REC_STATUS_INFIMUM */, 0x00, 0x0d /* pointer to supremum */, 'i', 'n', 'f', 'i', 'm', 'u', 'm', 0,
4    /* the supremum record */
5    0x01, 0x00, 0x0b /* heap_no = 1, REC_STATUS_SUPREMUM */, 0x00, 0x00 /* end of record list */, 's', 'u', 'p', 'r', 'e', 'm', 'u', 'm'
6};

해당 템플릿은 Infimum 및 Supremum 레코드를 바이트 배열로 가지고 있는데요. 주석에서도 알 수 있듯이 Supremum 레코드는 heap_no를 고정적으로 1로 가지고 있습니다. 즉, 앞서 봤었던 레코드는 Supremum 레코드였던 것이었습니다.

이제 해당 레코드에 락이 걸린 이유는 INSERT의 중복 후보 스캔과 연관지어 설명할 수 있는데요.

row0ins.cc
1static dberr_t row_ins_scan_sec_index_for_duplicate(...) {
2  do {
3    const bool is_next = !is_supremum && (cmp_dtuple_rec(entry, rec, index, offsets) < 0);
4
5    if (allow_duplicates) {...}
6    else {
7      if (skip_gap_locks) {
8        if (is_supremum) {
9          continue;
10        }
11        if (is_next) {
12          goto end_scan;
13        }
14        lock_type = LOCK_REC_NOT_GAP;
15      } else if (is_supremum) {
16        lock_type = LOCK_ORDINARY;
17      } else if (is_next) {
18        lock_type = LOCK_GAP;
19      } else {
20        lock_type = LOCK_ORDINARY;
21      }
22    }
23}

앞서 설명한 중복 후보를 스캔하며 락을 거는 과정에는 마지막 Supremum 레코드도 포함됩니다. 실제로 중복 후보를 끝내기 위한 플래그인 is_next는 Supremum 레코드인 경우 활성화되지 않도록 되어 있는데요. 그렇기에 Supremum 레코드까지 공유 락 + 넥스트 키 락이 걸리게 된 것입니다.

삽입 시도에서의 락

지금까지 공유 락이 어디에, 어떻게 걸리게 된 것인지 알아봤는데요. 이제 데드락의 직접적인 원인인 배타 락에 대해 살펴보겠습니다.

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 81 page no 5 n bits 80 index uk_quiz_bookmark_quiz_user of table `quizit`.`quiz_bookmark` trx id 124053 lock_mode X insert intention waiting
Record lock, heap no 1

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 81 page no 5 n bits 80 index uk_quiz_bookmark_quiz_user of table `quizit`.`quiz_bookmark` trx id 124054 lock_mode X insert intention waiting
Record lock, heap no 1
*** WE ROLL BACK TRANSACTION (2)

앞서 배타 락은 Supremum 레코드에 걸린 것을 확인했었습니다. 해당 배타 락은 Insert Intention Lock입니다.

An insert intention lock is a type of gap lock set by`INSERT operations prior to row insertion. This lock signals the intent to insert in such a way that multiple transactions inserting into the same index gap need not wait for each other if they are not inserting at the same position within the gap.
- MySQL 공식 문서

Insert Intention Lock은 INSERT에서 실제 삽입 전에 걸리는 갭 락(Gap Lock)의 일종인데요.

lock0lock.cc
1static inline Conflict rec_lock_check_conflict(...) {
2  if ((lock_is_on_supremum || (type_mode & LOCK_GAP)) && !(type_mode & LOCK_INSERT_INTENTION)) {
3    return Conflict::NO_CONFLICT;
4  }
5
6  if (lock_rec_get_insert_intention(lock2)) {
7    return Conflict::NO_CONFLICT;
8  }
9
10  return Conflict::HAS_TO_WAIT;
11}

같은 갭 락(Insert Intention Lock)끼리는 호환이 되지만 다른 갭 락과는 충돌이 된다는 특징을 가지고 있습니다.

결국 해당 락이 걸렸다는 것은 INSERT의 중복 검사를 통과해 실제 삽입 단계까지 진행되었다는 것을 의미하는데요. 즉, 중복 후보로서 락이 걸린 Delete-marked 레코드들이 전부 중복 검사를 통과했다는 것으로 이해할 수 있습니다.

row0ins.cc
1static bool row_ins_dupl_error_with_rec(...) {
2  n_unique = dict_index_get_n_unique(index);
3  matched_fields = 0;
4
5  entry->compare(rec, index, offsets, &matched_fields);
6
7  if (matched_fields < n_unique) {
8    return false;
9  }
10
11  return rec_get_deleted_flag(rec, rec_offs_comp(offsets)) == 0;
12}
rem0rec.ic
1static inline bool rec_get_deleted_flag(const rec_t *rec, bool comp) {
2  if (comp) {
3    return (rec_get_bit_field_1(rec, REC_NEW_INFO_BITS, REC_INFO_DELETED_FLAG, REC_INFO_BITS_SHIFT));
4  }
5}

실제로 중복 검사를 하는 부분에서 Delete-mark 레코드를 예외적으로 허용하는 것을 확인할 수 있었습니다.

결국 여러 요청이 동시에 Delete-mark 레코드를 중복 후보로 스캔해 락을 잡았는데, 바로 삽입 단계로 넘어가 배타 락을 요청하는 것이 데드락의 원인이었습니다.

공식 문서에서의 예시

사실 이러한 데드락은 공식 문서에서도 잘 알려진 시나리오였는데요. 공식 문서에서는 다음과 같은 INSERT 데드락 예시를 설명합니다.

Session 3Session 2InnoDBSession 1Session 3Session 2InnoDBSession 1par[Session 2][Session 3]par[Session 2][Session 3]데드락 발생START TRANSACTIONINSERT배타 락 획득START TRANSACTIONINSERT공유 락 요청 및 대기START TRANSACTIONINSERT공유 락 요청 및 대기ROLLBACK배타 락 해제공유 락 획득공유 락 획득배타 락 요청 및 대기배타 락 요청 및 대기

저희가 겪은 데드락과의 공통점은 공유 락을 서로 동시에 획득한 상태에서 실제 삽입 단계까지 진행되어 배타 락을 요청한다는 점입니다. 위 예시에서는 INSERT를 수행한 트랜잭션이 롤백(Rollback)됨에 따라 원래 존재하던 중복 후보가 사라져 최종적으로 중복 검사를 통과한 경우이고, 저희가 겪은 데드락은 Delete-marked 레코드가 중복 후보로 인식되지만 예외적으로 중복 검사를 통과한 경우입니다.

문제 해결

해당 문제를 해결하기 위해 같은 요청을 락으로 직렬화하는 방법과, 데드락이 발생한 트랜잭션을 재시도하는 방법을 생각했는데요. 락을 사용하는 방법은 락 획득과 해제, 타임아웃과 장애 복구까지 관리해야 한다는 단점이 있습니다. 이에 비해 북마크 처리 자체는 조회와 추가 또는 삭제로 끝나는 짧은 트랜잭션이라, 실패한 작업을 다시 실행하는 비용이 적어 재시도 방법이 좀 더 효율적이라 판단했습니다.

트랜잭션 전체 재시도

재시도할 때는 북마크 존재 여부 조회부터 다시 실행해야 합니다. 데드락으로 기존 트랜잭션이 롤백되었고 그동안 다른 요청이 북마크를 추가하거나 삭제했을 수 있기 때문입니다. 처음 조회 결과를 그대로 사용해 INSERT만 반복하면 현재 상태와 맞지 않는 분기를 실행할 수 있습니다.

기존에 사용하던 transaction() 확장 함수에 재시도 옵션을 추가했습니다.

TransactionExtensions.kt
1fun <T> transaction(
2    readOnly: Boolean = false,
3    propagation: Propagation = Propagation.REQUIRED,
4    retry: TransactionRetry? = null,
5    func: () -> T
6): T {
7    if (retry == null) {
8        return TransactionWrapper(
9            readOnly = readOnly,
10            propagation = propagation,
11            func = func
12        )
13    }
14
15    var count = 0
16
17    while (true) {
18        try {
19            return TransactionWrapper(
20                readOnly = readOnly,
21                propagation = propagation,
22                func = func
23            )
24        } catch (exception: Throwable) {
25            if (!retry.retryOn(exception)) {
26                throw exception
27            }
28
29            count += 1
30
31            if (count >= retry.count) {
32                throw exception
33            }
34
35            Thread.sleep(retry.backoff.inWholeMilliseconds)
36        }
37    }
38}

count는 첫 번째 실행을 포함한 최대 시도 횟수입니다. 예외가 재시도 대상이 아니거나 시도 횟수를 모두 사용했으면 호출자에게 다시 전달합니다.

검증

같은 북마크에 100개 요청을 동시에 보내고, 재시도 유무만 바꿔 각각 300회 반복했습니다.

지표재시도 미적용재시도 적용
동시 요청 수100개100개
누적 요청 수30,000건30,000건
실패 요청139건0건
평균 응답 시간26.32ms28.25ms
P95 응답 시간49.38ms49.70ms
P99 응답 시간75.07ms147.13ms
요청당 평균 재시도 횟수0회0.0281회

전체 요청 기준으로 평균 및 P95 응답 시간은 크게 변하지 않았으며 P99 응답 시간은 약 2배 늘었습니다. 데드락 자체를 없애지는 못했지만, 일부 요청의 지연을 감수하고 실패 응답을 복구할 수 있도록 한 결과입니다.