grep

데드락을 해결하려다, 락을 줄이게 된 이야기

여기어때

2026년 8월 20일

원문에서 보기 ↗

글. 김민우(Kyle) / 유저혜택개발팀

들어가며

안녕하세요. 유저혜택개발팀의 카일입니다.

이번 글에서는 동시성 문제를 다루면서 직접 겪었던 고민과, 그 과정에서 배운 락과 데드락에 대한 제 생각을 공유해보고자 합니다.

신규 유저가 상품을 구매하고 이용을 완료하면 결제 금액의 일부를 포인트로 지급하는 이벤트를 개발하고 있었습니다.

문제는 한 유저에게 여러 이벤트가 동시에 들어올 수 있다는 점이었습니다. 예를 들어 한 유저가 주문한 직후 이용완료 이벤트가 들어오고, 비슷한 시점에 다른 주문의 취소 이벤트까지 들어올 수 있습니다.

한 유저의 상태를 여러 요청이 동시에 읽고 수정하는 상황을 피할 수 없었습니다.

처음에는 가장 익숙한 방법을 선택했습니다.

"같은 유저의 상태를 수정할 때 비관적 락을 걸어서 한 번에 하나씩 처리하면 되겠지."

신규 유저 리워드 이벤트에 비관적 락을 적용했고 단건 요청은 문제가 없었습니다.

실제로 단건 요청에서는 아무 문제가 없었습니다.

그런데 동시성 테스트를 돌려보니 상황이 달랐습니다.

Deadlock found when trying to get lock; try restarting transaction

같은 메시지가 반복해서 발생했습니다.

데드락 로그를 보고 어떤 문제가 있었는지 하나씩 따라가고, 최종적으로 왜 비관적 락을 줄이는 방향으로 해결했는지를 정리해보겠습니다.

먼저, 어떤 시스템이었을까?

개발 중이던 시스템은 신규 유저가 상품을 구매하고 이용을 완료하면 결제 금액의 일부를 포인트로 지급하는 이벤트 시스템이었습니다.

신규 유저가 상품을 구매하면 주문 이벤트가 발생하고, 이후 실제로 상품을 이용하면 이용완료 이벤트가 발생합니다. 이용이 완료되면 이벤트 조건에 따라 포인트를 지급하고, 주문이 취소되면 지급 대상에서 제외하거나 이미 처리된 내용을 되돌려야 합니다. 흐름을 단순하게 표현하면 다음과 같습니다.

이벤트는 Kafka를 통해 비동기로 처리되고 있었습니다.

여기서 두 가지 문제가 있었습니다.

첫째, Kafka의 At-Least-Once 특성 때문에 같은 이벤트가 중복 전달될 수 있었습니다.

둘째, 주문, 이용완료, 취소 이벤트가 서로 다른 토픽으로 발행되고 있었습니다. 이 이벤트들의 처리 순서가 항상 우리가 기대한 순서와 같다고 보장할 수 없었습니다.

Kafka 파티셔닝으로 순서를 보장할 수 없었던 이유

가장 먼저 떠올릴 수 있는 방법은 메시지 키를 user_id로 지정하는 것입니다.

같은 유저의 이벤트를 같은 파티션으로 보내면 해당 파티션 안에서는 순차적으로 처리할 수 있기 때문입니다.

하지만 이 시스템에서는 적용하기 어려웠습니다.

주문, 이용완료, 취소 이벤트가 각각 다른 토픽으로 분리되어 있었고, 각 토픽의 발행 주체도 다른 도메인 서비스였습니다.

Kafka가 보장하는 순서는 하나의 토픽 안에서 하나의 파티션을 기준으로 한 순서입니다.

따라서 모든 이벤트에 같은 user_id를 사용하더라도 서로 다른 토픽에 있는 이벤트 사이의 순서는 보장되지 않습니다.

또한 발행 측의 토픽 구조와 파티셔닝 전략을 우리가 임의로 변경할 수도 없었습니다.

결국 이 시스템에서는 다음을 전제로 설계해야 했습니다.

같은 유저의 이벤트가 동시에 처리될 수 있고, 같은 이벤트가 중복으로 들어올 수 있으며, 이벤트 처리 순서도 보장되지 않는다.

그렇다면 순서를 Kafka에 맡기는 대신, 애플리케이션과 DB에서 같은 유저의 상태를 안전하게 처리할 방법이 필요했습니다.

유저 상태는 어떻게 관리하고 있었을까?

CREATE TABLE rookie_reward (
    reward_seq          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id             BIGINT UNSIGNED NOT NULL,
    reward_total        INT UNSIGNED DEFAULT 0 NOT NULL,
    ... (생략)
    CONSTRAINT udx_user_id UNIQUE (user_id)
);
CREATE TABLE rookie_reward_step (
    step_seq     BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id      BIGINT UNSIGNED NOT NULL,
    order_id     VARCHAR(20) NOT NULL,
    ... (생략)
    CONSTRAINT udx_user_event_order UNIQUE (user_id, order_id)
);

유저별 전체 이벤트 상태는 rookie_reward에 저장하고, 개별 주문의 처리 상태는 rookie_reward_step에 저장했습니다.

두 테이블의 역할은 조금 다릅니다.

rookie_reward는 유저당 하나의 row만 존재하면서 전체 진행 상태를 관리합니다.

반면 rookie_reward_step은 주문마다 하나씩 row가 쌓이는 구조입니다.

이 구조에서 자연스럽게 한 가지 생각이 들었습니다.

"여러 이벤트가 같은 유저의 상태를 동시에 수정할 수 있으니, 유저당 하나뿐인 rookie_reward를 잠그면 되지 않을까?"

먼저 비관적 락을 적용했다

처음 구현은 대략 다음과 같았습니다.

@Transactional
public RookieReward getOrCreateWithLock(long userId) {
    if (!rookieRewardRepository.existsByUserId(userId)) {
        // INSERT ON DUPLICATE KEY UPDATE
        rookieRewardRepository.insertDuplicate(userId, ...);
    }
    // SELECT ... FOR UPDATE (비관적 락 조회)
    return rookieRewardRepository.findWithLockByUserId(userId)
        .orElseThrow();
}

유저의 rookie_reward가 없다면 먼저 생성하고, 그다음 SELECT ... FOR UPDATE로 row를 잠그는 방식입니다.

왜 굳이 생성한 뒤 다시 락을 걸었을까요?

비관적 락은 기본적으로 존재하는 row를 대상으로 거는 것이 가장 명확하기 때문입니다.

row가 아직 없다면 FOR UPDATE를 실행해도 잠글 대상이 없습니다.

그래서 먼저 락을 걸 대상을 만들어놓고, 그 row를 유저 단위의 뮤텍스처럼 사용했습니다.

그런데 여기에는 한 가지 문제가 있었습니다.

Kafka 이벤트가 중복될 수 있기 때문에, rookie_reward를 생성하는 과정도 멱등해야 했습니다.

중복 생성을 막기 위해 무엇을 사용할까?

가장 단순하게 생각하면 다음과 같습니다.

하지만 동시 요청에서는 문제가 생깁니다.

두 요청이 동시에 조회하면 둘 다 없음을 확인하고 동시에 INSERT할 수 있기 때문입니다.

그래서 생성 여부를 애플리케이션에서 판단하지 않고 DB의 UNIQUE KEY에 맡기기로 했습니다.

첫 구현은 ON DUPLICATE KEY UPDATE였습니다.

없으면 생성, 있으면 갱신을 DB가 원자적으로 보장해 주기 때문입니다.

후보는 세 가지였습니다.

REPLACE INTO

중복되면 기존 row를 삭제하고 새로 INSERT합니다.

하지만 rookie_reward에는 유저의 누적 상태가 저장되어 있습니다. 기존 row를 삭제하고 다시 만들면 누적 데이터가 초기화되고 PK도 다시 발급될 수 있습니다.

우리에게는 맞지 않았습니다.

INSERT IGNORE

중복 키가 발생하면 해당 에러를 무시하고 넘어갑니다.

다만 INSERT IGNORE는 중복 키 외에도 일부 데이터 오류를 경고로 처리할 수 있습니다.

처음에는 이 점이 마음에 걸렸습니다.

INSERT ... ON DUPLICATE KEY UPDATE

중복 키가 발생하면 UPDATE로 전환합니다.

없으면 INSERT하고, 있으면 UPDATE하므로 의도 자체는 가장 명확했습니다.

그래서 처음에는 이 방법을 사용했습니다.

INSERT INTO rookie_reward (...) VALUES (...) ON DUPLICATE KEY
UPDATE updated_at = NOW();

기능적으로는 문제가 없었습니다.

중복 요청이 들어와도 row는 하나만 존재했고, 동시성 테스트에서도 데드락은 발생하지 않았습니다.

그런데 테스트를 하면서 조금 이상한 점이 눈에 들어왔습니다. 이미 존재하는 유저인데 왜 UPDATE를 하고 있을까?

실제로 바뀌는 값은 없었습니다.

updated_at을 변경하기 위해 UPDATE를 수행하고 있을 뿐이었습니다.

그러면 중복 메시지가 들어올 때마다 불필요한 쓰기가 발생합니다.

DB 입장에서는 UPDATE이기 때문에 락도 필요하고, 변경 내용을 기록하기 위한 비용도 발생합니다.

더 중요한 건 이 row가 이후 다른 이벤트에서도 사용되는 유저 단위의 Hot Row라는 점이었습니다.

한 유저에게 이벤트가 몰리면 모든 이벤트가 이 row 앞에서 줄을 서게 됩니다.

그래서 생각을 바꿨습니다. 중복이면 정말 아무것도 하지 않으면 안 될까?

그렇게 INSERT IGNORE로 변경했습니다.

INSERT IGNORE로 바꾸자 데드락이 발생했다.

변경한 코드는 단순했습니다.

@Transactional
public RookieReward getOrCreateWithLock(long userId) {
    rookieRewardRepository.insertIgnore(userId);
    return rookieRewardRepository.findWithLockByUserId(userId)
        .orElseThrow();
}

중복이면 아무것도 하지 않고, 이후 FOR UPDATE로 필요한 row만 잠그는 구조였습니다.

그런데 동시성 테스트를 다시 돌려보니 예상하지 못한 결과가 나왔습니다.

Deadlock found when trying to get lock; try restarting transaction

이전에는 발생하지 않던 데드락이 INSERT IGNORE로 변경한 뒤 발생하기 시작했습니다.

여기서 처음으로 InnoDB 데드락 로그를 자세히 확인했습니다.

첫 번째 데드락, INSERT IGNORE와 FOR UPDATE

SHOW ENGINE INNODB STATUS의 데드락 로그를 확인하니 다음과 같은 패턴이 보였습니다.

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS ... index udx_user_id of table rookie_reward
lock mode S locks rec but not gap         ← INSERT IGNORE가 중복 시 잡은 S 레코드 락
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
lock_mode X locks rec but not gap waiting ← FOR UPDATE가 요청한 X (승격 대기)

*** (2) HOLDS THE LOCK(S):
lock mode S locks rec but not gap         ← 같은 행에 다른 트랜잭션도 S를 보유
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
lock_mode X locks rec but not gap waiting ← 마찬가지로 X 승격 대기 → 순환

두 트랜잭션 모두 같은 row에 S-Lock을 가지고 있었고, 이후 X-Lock을 얻으려고 기다리고 있었습니다.

처음에는 조금 의아했습니다.

INSERT IGNORE는 데이터를 변경하지 않았는데 왜 락을 잡고 있을까?

여기서 INSERT IGNORE의 동작을 다시 확인했습니다.

중복 키를 확인하는 과정에서 InnoDB는 해당 레코드에 대해 S-Lock을 잡습니다.

그리고 트랜잭션이 이어서 SELECT ... FOR UPDATE를 실행하면 이번에는 같은 row에 X-Lock이 필요합니다.

문제는 S-Lock끼리는 서로 호환된다는 것입니다.

따라서 두 트랜잭션이 동시에 들어오면 이런 상황이 가능합니다.

둘 다 상대방의 S-Lock이 풀리기를 기다립니다.

하지만 둘 다 트랜잭션이 끝나야 S-Lock을 내려놓을 수 있습니다.

결국 서로가 서로를 기다리는 순환 대기가 만들어집니다.

데드락 이었습니다.

InnoDB 행 락의 세 가지 형태

여기서 락이 무엇을 잠그는지를 기준으로 InnoDB 행 락의 세 가지 형태를 짚고 가겠습니다.

이 글에 등장하는 쿼리가 잡는 락을 이 기준으로 정리하면 다음과 같습니다.

왜 ON DUPLICATE KEY UPDATE에서는 괜찮았을까?

두 방식을 비교해보면 차이가 더 명확했습니다.

반면 INSERT IGNORE는 중복을 발견해도 데이터를 변경하지 않기 때문에 S-Lock을 사용합니다.

처음에는 UPDATE를 없애서 쓰기를 줄이자고 생각했는데, 오히려 뒤에서 FOR UPDATE를 사용하는 구조와 결합되면서 데드락을 만들어낸 것입니다.

여기서 한 가지를 배웠습니다.

DB에서 불필요한 쓰기를 줄이는 것만 생각할 게 아니라, 그 문장이 어떤 락을 남기는지까지 같이 봐야 한다.

두 번째 데드락, 락을 잡는 순서가 달랐다

첫 번째 데드락을 분석하고 나서 코드를 더 따라가 봤습니다.

그런데 또 다른 문제가 있었습니다.

대부분의 이벤트는 다음 순서로 락을 잡고 있었습니다.

그런데 레저티켓 체크아웃 경로만 달랐습니다.

각각의 메서드만 보면 크게 이상해 보이지 않았습니다.

문제는 두 요청이 동시에 실행될 때였습니다.

이번에는 락의 종류가 문제가 아니었습니다.

**같은 락을 서로 다른 순서로 잡고 있었던 것이 문제였습니다.**전형적인 ABBA 패턴이었습니다.

이 문제는 해결 방법도 비교적 명확했습니다.락을 잡는 순서를 하나로 통일한다.

팀 내에서 rookie_reward → rookie_reward_step 순서로 락을 획득하도록 정리했습니다.

이렇게 하면 모든 트랜잭션이 같은 방향으로 락을 잡기 때문에 서로 반대 방향으로 기다리는 순환 대기를 만들 수 없습니다.

다만 레저티켓 경로에는 한 가지 특이한 점이 있었습니다.

step을 먼저 조회해야 userId를 알 수 있는 구조였기 때문입니다.

그래서 단순히 step → reward 순서로 락을 잡는 대신,

처럼 조회와 락 획득을 분리했습니다.

그런데 여기서 또 하나의 문제가 나왔습니다.

step을 먼저 조회했기 때문에 해당 엔티티가 이미 JPA 1차 캐시에 들어와 있었습니다.

이 상태에서 같은 엔티티를 PESSIMISTIC_WRITE로 다시 조회하면 DB에는 FOR UPDATE가 나가더라도 애플리케이션에서는 이미 가지고 있던 엔티티를 사용할 수 있습니다.

즉, DB에는 최신 row를 잠갔는데, 애플리케이션 객체는 이전 값을 보고 있는 상황이 생길 수 있었습니다.

그래서 락을 획득할 때 EntityManager.refresh를 사용해 DB의 최신 값을 다시 읽도록 했습니다.

entityManager.refresh(step, LockModeType.PESSIMISTIC_WRITE);

이렇게 두 번째 데드락의 원인이었던 락 순서와 영속성 컨텍스트 문제도 정리했습니다.

그런데 여기서 끝내기에는 뭔가 찜찜했습니다.

그런데 왜 모든 이벤트가 rookie_reward를 잠그고 있을까?

데드락을 하나씩 없애면서 전체 코드를 다시 봤습니다.

그러다 문득 이런 생각이 들었습니다. 애초에 왜 모든 이벤트가 rookie_reward를 잠가야 하지?

코드를 따라가 보니 이유는 의외로 단순했습니다.

rookie_reward에 여러 카운터가 있었기 때문입니다.

private int orderCounter;
private int checkoutCounter;
private int rewardCounter;

주문 이벤트가 들어오면 orderCounter를 증가시키고,

체크아웃 이벤트가 들어오면 checkoutCounter를 증가시키고,

리워드가 지급되면 rewardCounter를 증가시키고 있었습니다.

결국 이벤트의 종류와 상관없이 마지막에는 같은 유저의 rookie_reward row를 UPDATE해야 했습니다.

그러다 보니 rookie_reward는 자연스럽게 Hot Row가 됐습니다.

한 유저에게 이벤트가 몰리면 모든 트랜잭션이 같은 row 앞에서 기다립니다.

이 자체가 데드락은 아닙니다.

하지만 락을 기다리는 시간이 길어지고, 트랜잭션이 여러 row를 잡고 있는 시간도 늘어납니다.

그만큼 서로 다른 락이 얽힐 가능성도 커집니다.

그렇다면 여기서 질문을 바꿔야 했습니다. 이 row를 어떻게 잘 잠글까? 가 아니라, 정말 이 row를 수정해야 할까?

카운터를 없애기

카운터를 다시 살펴보니 전부 rookie_reward_step을 조회하면 계산할 수 있는 값이었습니다.

예를 들어 특정 상태의 step 개수를 세면 카운터를 따로 저장하지 않아도 됩니다.

물론 매번 계산하는 비용이 너무 크다면 이야기가 달라집니다.

하지만 이 이벤트에서 한 유저에게 쌓이는 step은 현실적으로 수백 건을 넘지 않는 수준이었고, 카운터를 유지하기 위해 매 이벤트마다 row를 UPDATE하는 비용과 비교하면 충분히 감당할 수 있다고 판단했습니다.

결국 카운터를 제거했습니다.

// AS-IS
userInfo.incrementCheckoutCounter();

// TO-BE
stepRepository.findByUserIdAndStatusIn(...);

이 변경은 단순히 컬럼 몇 개를 삭제한 것이 아니었습니다. 락을 잡아야 할 이유 자체를 없앤 것이었습니다.

카운터를 없애니 대부분의 이벤트에서 rookie_reward를 UPDATE할 필요가 없어졌습니다.

그리고 UPDATE할 필요가 없어지니, 단순히 카운터를 증가시키기 위해 FOR UPDATE를 걸 필요도 없어졌습니다.

결국 기존의 구조를 Insert 후, 필요한 경우에만 상태를 변경하는 처리로 변경했습니다.

FOR UPDATE를 제거하고, 생성도 짧게 가져가기

카운터를 제거한 뒤 유저 상태 생성 메서드도 다시 구성했습니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public Optional<RookieReward> getOrCreateUserInfo(...) {
    rookieRewardRepository.insertNewUser(...);

    return rookieRewardRepository.findByUserId(userId);
}

여기서 핵심적인 변화는 두 가지입니다.

첫째, FOR UPDATE를 제거했습니다.

이제 단순히 유저 상태가 존재하는지만 확인하면 되기 때문에 굳이 row를 잠글 이유가 없습니다.

둘째, 생성 작업을 REQUIRES_NEW로 분리했습니다.

왜 별도 트랜잭션까지 필요했을까요?

INSERT IGNORE가 중복을 만났을 때 잡는 S-Lock은 SQL 문장이 끝났다고 바로 사라지는 것이 아니라 트랜잭션이 끝날 때까지 유지될 수 있습니다.

기존처럼 메시지 전체를 하나의 긴 트랜잭션으로 처리한다면, 생성 과정에서 잡힌 락도 그만큼 오래 유지될 수 있습니다.

그래서 생성에 필요한 작업만 별도 트랜잭션으로 분리했습니다.

이렇게 하면 생성 과정에서 필요한 락의 수명이 메시지 전체 처리 시간이 아니라 생성 작업 자체의 시간으로 줄어듭니다.

즉 REQUIRES_NEW를 도입한 이유는 특별한 트랜잭션 패턴을 만들기 위해서가 아니라, 생성할 때 잠깐 필요한 락을 오래 가지고 있지 않기 위해서였습니다.

락 없는 조회인데 괜찮을까?

여기서 한 가지 걱정이 생길 수 있습니다.

INSERT IGNORE를 실행한 다음 FOR UPDATE 없이 바로 조회하면, 방금 생성된 row를 제대로 볼 수 있을까?

이 부분도 확인했습니다.

내가 직접 INSERT한 경우에는 자신의 변경 내용을 볼 수 있습니다.

다른 트랜잭션이 먼저 생성한 경우에는 INSERT IGNORE가 중복 키를 확인하는 과정에서 해당 트랜잭션의 처리가 끝날 때까지 기다린 후 다음 문장으로 넘어갑니다.

그리고 이미 존재하는 row라면 애초에 오래전에 커밋된 row입니다.

문제는 긴 바깥 트랜잭션 안에서 이 조회를 수행할 경우였습니다.

InnoDB의 기본 격리 수준인 REPEATABLE READ에서는 트랜잭션의 Read View가 첫 조회 시점에 만들어질 수 있습니다.

만약 바깥 트랜잭션에서 이미 조회가 발생했다면,

같은 상황에서 최신 상태를 바로 보지 못할 가능성을 고려해야 합니다.

그래서 생성 과정을 REQUIRES_NEW로 분리했습니다.

새 트랜잭션에서 생성과 조회를 수행하면 조회 시점에 새로운 스냅샷을 만들 수 있고, 앞선 트랜잭션의 커밋 결과를 확인할 수 있습니다.

결과적으로 REQUIRES_NEW는 두 가지 역할을 하게 됐습니다.

물론 트레이드오프도 있었습니다.

REQUIRES_NEW는 기존 트랜잭션의 커넥션을 유지한 채 새로운 커넥션을 추가로 사용합니다.

따라서 컨슈머의 동시 처리량을 기준으로 커넥션 풀을 확인했고, 내부 트랜잭션은 두 문장만 수행한 뒤 바로 커밋하도록 구성했습니다.

또한 내부 트랜잭션에서 반환된 엔티티는 바깥 영속성 컨텍스트에 속하지 않는 준영속 상태가 됩니다.

따라서 이 메서드에서 반환하는 엔티티는 이후 상태 변경에 사용하는 것이 아니라, 존재 여부와 식별 정보를 확인하는 용도로만 사용했습니다.

왜 Redis 분산 락이나 낙관적 락을 사용하지 않았을까?

이 문제를 해결하면서 다른 선택지도 함께 고민했습니다.

Redis 분산 락

Redis를 이용해 유저 단위 분산 락을 걸 수도 있습니다.

하지만 이 문제에서 보호하려는 대상은 결국 DB row이고, 정합성을 보장해야 하는 범위도 DB 트랜잭션과 맞닿아 있습니다.

외부 분산 락을 사용하면 Redis의 락 해제 시점과 DB 커밋 시점이 서로 어긋날수 있고, 장애가 발생하는 상황도 고려해야 합니다.

반면 DB row lock은 트랜잭션의 커밋과 락 해제가 DB 안에서 함께 관리됩니다. 이미 DB에 존재하는 row를 보호하는 문제였기 때문에 굳이 외부에 락 시스템을 하나 더 추가할 필요가 없다고 판단했습니다.

낙관적 락

@Version을 이용한 낙관적 락도 선택지였습니다.

하지만 이 시스템은 동일 유저에게 이벤트가 몰릴 수 있는 구조였습니다.

충돌이 발생하면 애플리케이션에서 재시도해야 하고, Kafka 메시지 재소비까지 이어질 수도 있습니다.

충돌이 드물다고 보기 어려운 상황에서 계속 충돌을 감지하고 재시도하는 것보다, 애초에 DB에서 순차적으로 처리하는 비관적 락이 더 단순하다고 판단했습니다.

그래서 실제로 어떻게 바뀌었을까?

처음 구조는 대략 이랬습니다.

여기에는 불필요한 쓰기와 긴 락 보유가 있었습니다.

중간에는 이를 줄여보려고 이렇게 바꿨습니다.

그런데 이 과정에서 INSERT IGNORE의 S-Lock와 FOR UPDATE의 X-Lock의 첫 번째 데드락이 발생했습니다.

또 일부 경로에서는 락 순서 차이로 두 번째 데드락이 발생했습니다.

최종적으로는 다음과 같이 바뀌었습니다.

그리고 카운터를 제거하면서 대부분의 이벤트에서 rookie_reward를 UPDATE할 필요 자체가 없어졌습니다.

동시성 테스트로 다시 확인했다

마지막으로 데드락을 재현했던 동일한 동시성 테스트를 다시 실행했습니다.

같은 스레드 수와 반복 조건으로 테스트했고, 수정 이후에는 데드락이 더 이상 발생하지 않았습니다.

이번 해결은 "어떤 상황에서도 데드락이 절대 발생하지 않는다"는 것을 증명한 것은 아닙니다.

다만 기존에 실제로 발생했던 데드락의 조건을 하나씩 제거했습니다.

그리고 만약 예상하지 못한 데드락이 발생하더라도 InnoDB는 한쪽 트랜잭션을 롤백합니다.

Kafka 컨슈머는 해당 메시지의 offset을 정상적으로 커밋하지 못하기 때문에 메시지가 다시 소비될 수 있습니다.

따라서 생성은 INSERT IGNORE로, 상태 변경은 현재 상태를 기준으로 멱등하게 처리하도록 구성해 재소비되어도 동일한 결과를 만들 수 있도록 했습니다.

마치며

이번 문제를 처음 만났을 때는 단순하게 생각했습니다. "동시에 들어오는 요청이 있으니까 비관적 락을 걸자."

그런데 실제로 데드락을 따라가 보니 문제는 락 하나로 설명되지 않았습니다.

처음에는 INSERT IGNORE와 FOR UPDATE의 조합에서 락 승격이 발생했고,

그다음에는 이벤트마다 락을 잡는 순서가 달랐고,

더 깊이 들어가 보니 애초에 모든 이벤트가 같은 row를 잠그게 만든 카운터가 있었습니다.

결국 마지막에 한 일은 락을 더 추가하는 것이 아니었습니다. 오히려 하나씩 없앴습니다.

불필요한 UPDATE를 없애고, 카운터를 없애고, 필요하지 않은 FOR UPDATE를 없애고, 남은 락의 순서를 통일했습니다.

처음에는 어떻게 하면 이 락을 안전하게 걸 수 있을까? 를 고민했다면, 마지막에는 **정말 이 락이 필요한가?**를 먼저 고민하게 됐습니다.

동시성 문제를 만났을 때 락부터 추가하는 것이 항상 정답은 아닌 것 같습니다.

락이 왜 필요한지, 얼마나 오래 가지고 있는지, 어떤 순서로 잡고 있는지, 그리고 애초에 그 락을 필요하게 만든 데이터가 정말 필요한지까지 함께 봐야 합니다.

이번 경험을 통해 데드락을 해결하는 가장 좋은 방법이 락을 더 잘 거는 것이 아니라, 락이 필요했던 이유를 없애는 것일 수도 있다​는 것을 배웠습니다.

긴 글 읽어주셔서 감사합니다.


데드락을 해결하려다, 락을 줄이게 된 이야기 was originally published in 여기어때 기술블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.