포스트

[업무일지] 0건을 지우는 DELETE가 잡은 갭 락 데드락

선정산 조회 결과를 스냅샷으로 남기는 적재에서 3주 동안 데드락이 나고 있었고, 그때마다 스냅샷이 통째로 사라졌는데 아무도 몰랐습니다. 호출부가 예외를 삼키고 있었거든요. 원인은 새로 만든 부모의 자식 행을 지우는 DELETE였습니다. 지울 행이 0건이어도 REPEATABLE READ에서는 인덱스 끝에 갭 락을 잡고, 동시에 들어온 두 적재가 서로의 INSERT를 막았습니다. 격리 수준을 낮추지 않고 신규 부모일 때는 DELETE를 건너뛰었습니다. 재시도 대상에 데드락을 넣고, 삼켜지던 실패에는 알림을 따로 달았습니다.

Java 21, Spring Boot 3.3.5, Spring Data JPA, MariaDB(InnoDB, 기본 격리 수준 REPEATABLE READ) 환경입니다. 셀러용 API 서버에서 있었던 일입니다. 설명을 위해 테이블명과 클래스명은 가명으로 씁니다. 실제 구조와 이름만 다릅니다.

  • query_results: 선정산 조회 한 건의 결과 스냅샷 (부모)
  • query_result_reasons: 그 조회에서 신청이 안 되는 사유 (자식). (result_id, reason_code) 에 유니크 인덱스가 있습니다.

3주 동안 스냅샷이 사라지고 있었다

8월 말에 팀에서 선정산 조회 결과를 DB에 남기는 기능을 넣었습니다. 셀러가 조회할 때마다 결과를 부모 한 행으로, 신청 불가 사유를 자식 여러 행으로 적재합니다. 그전에는 조회 결과가 캐시 TTL이 지나면 사라져서 나중에 분석할 방법이 없었거든요.

9월 초부터 이 적재 경로에서 Deadlock found 로그가 드문드문 찍혔습니다. 9월 말 조회량이 네 배쯤 늘면서 하루 3~4건이 됐습니다. 그 사이 코드는 바뀌지 않았고, 최근 7일 기준으로 조회 문서의 0.04% 정도입니다.

셀러 화면에는 영향이 없었습니다. 적재는 부가 기능이라 실패해도 조회를 실패시키지 않도록, 호출부가 예외를 잡아 로그만 남기게 설계돼 있었거든요.

대신 데드락으로 롤백된 조회는 스냅샷이 통째로 남지 않았습니다. 재시도는 유니크 제약 충돌만 다시 시도해서 데드락은 복구되지 않았고, 예외를 삼키니 전역 예외 알림도 닿지 않았습니다. 그렇게 3주를 보냈습니다.

적재는 부모 upsert 뒤에 자식을 전량 교체했다

같은 조회 문서를 10분 안에 다시 처리하는 경로가 있어서, 부모는 있으면 덮어쓰고 자식은 지우고 다시 넣는 구조였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional
public void write(String crawlObjectId, QueryResultSnapshot snapshot, List<Reason> reasons) {
    QueryResult saved = queryResultRepository.findByCrawlObjectId(crawlObjectId)
            .map(existing -> {
                existing.overwrite(snapshot);
                return existing;
            })
            .orElseGet(() -> queryResultRepository.save(QueryResult.from(snapshot)));

    // 재사용 조회(10분 TTL)는 같은 문서를 다시 처리하므로 자식을 전량 교체한다
    reasonRepository.deleteAllByResultId(saved.getId());
    reasonRepository.saveAll(toEntities(saved.getId(), reasons));
}

흔한 패턴이죠. 처음 조회하는 문서라면 부모가 방금 INSERT됐고 자식은 당연히 없습니다. 이때 DELETE는 0건을 지웁니다.

0건을 지우는 DELETE가 무슨 일을 하겠나 싶었는데, 데드락 로그가 찍힌 경로와 코드를 맞춰 보니 의심이 가는 자리가 이 DELETE와 그 뒤의 INSERT였습니다.

0건 DELETE가 인덱스 끝에 갭 락을 잡았다

신규 조회의 result_id 는 방금 만든 부모의 id라서 항상 테이블에서 가장 큰 값입니다. DELETE ... WHERE result_id = ? 는 유니크 인덱스를 타고 그 값을 찾는데, 해당 행이 없으니 인덱스의 마지막 레코드 뒤, 끝(supremum)까지의 구간을 보게 됩니다.

REPEATABLE READ에서 InnoDB는 이렇게 없는 범위를 찾은 잠금 읽기에 갭 락을 겁니다. 나중에 누가 그 구간에 행을 넣어 결과가 바뀌는 걸 막으려는 장치입니다. 지운 행이 0건이어도 락은 잡힙니다. 링크는 MySQL 문서지만, 아래 재현은 MariaDB의 InnoDB에서 했고 같은 동작이 나왔습니다.

여기서 두 가지가 맞물립니다. 갭 락끼리는 서로 충돌하지 않습니다. 같은 구간에 두 트랜잭션이 동시에 갭 락을 가질 수 있어요. 반면 그 구간에 INSERT하려면 insert intention 락이 필요한데, 이건 다른 트랜잭션이 같은 구간에 쥔 갭 락에 막힙니다.

순서트랜잭션 A (조회 1)트랜잭션 B (조회 2)
1부모 INSERT (id 101)부모 INSERT (id 102)
2자식 DELETE result_id = 101, 0건. 끝 구간 갭 락 획득 
3 자식 DELETE result_id = 102, 0건. 같은 끝 구간 갭 락 획득 (서로 호환)
4자식 INSERT. B의 갭 락에 막혀 대기 
5 자식 INSERT. A의 갭 락에 막혀 대기. 순환 대기
6InnoDB가 한쪽을 롤백 

두 신규 조회가 거의 같은 순간에 적재되면 둘 다 인덱스 끝 구간에 갭 락을 쥔 채로 상대가 놓기를 기다립니다. 조회량이 네 배가 되면서 이 겹침이 잦아진 거고요.

이슈를 쓸 때까지는 코드와 로그 패턴으로 추론한 “가장 유력한 경로” 였습니다. 확정은 PR에서 했습니다. 로컬 MariaDB에서 두 트랜잭션을 동시에 돌려 기존 코드는 10번 중 10번 데드락이 났고, DELETE를 건너뛰면 0번이었습니다. 같은 트랜잭션을 READ COMMITTED로 돌려도 데드락이 나지 않았습니다. READ COMMITTED는 이런 갭 락을 잡지 않으니, 원인이 갭 락이라는 것도 이 결과로 확인했습니다.

격리 수준을 낮추는 대신 DELETE를 건너뛰었다

고치는 방법은 둘이 보였습니다.

하나는 이 트랜잭션만 READ COMMITTED로 돌리는 겁니다. 재현에서 데드락이 사라지는 것도 봤고, 한 줄이면 됩니다. 그런데 서비스 전체가 DB 기본값인 REPEATABLE READ로 도는데 이 메서드 하나만 다른 격리 수준을 쓰게 됩니다. 나중에 이 메서드를 고치는 사람이 그 사실을 알고 있어야 하고, 같은 트랜잭션에 다른 쿼리가 붙으면 그 쿼리도 조용히 다른 규칙으로 돕니다. 데드락 하나를 막으려고 예외 경로를 하나 만드는 셈이라 고르지 않았습니다.

다른 하나는 신규 부모일 때 DELETE를 아예 하지 않는 겁니다. 방금 만든 부모에 자식이 있을 리 없으니, 그 DELETE는 원래부터 지울 게 없는 쿼리였습니다. 하는 일은 갭 락을 잡는 것뿐이었죠. 이쪽으로 정했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Transactional
public void write(String crawlObjectId, QueryResultSnapshot snapshot, List<Reason> reasons) {
    Optional<QueryResult> existing = queryResultRepository.findByCrawlObjectId(crawlObjectId);
    QueryResult saved = existing
            .map(row -> {
                row.overwrite(snapshot);
                return row;
            })
            .orElseGet(() -> queryResultRepository.save(QueryResult.from(snapshot)));

    // 신규 부모의 DELETE 는 0건이어도 인덱스 끝 구간에 갭 락을 잡아 동시 신규 적재와 데드락이 난다
    if (existing.isPresent()) {
        reasonRepository.deleteAllByResultId(saved.getId());
    }
    reasonRepository.saveAll(toEntities(saved.getId(), reasons));
}

부모를 찾는 조회를 메서드 앞으로 끌어올려서 “기존 부모였는가” 를 아래에서 쓸 수 있게 한 게 변경의 전부입니다.

회귀 테스트는 실제 DB로 짰습니다. 두 트랜잭션이 각자 첫 적재를 마치고 커밋하지 않은 상태에서 CyclicBarrier 로 만나 동시에 두 번째 적재를 시작합니다. 수정 전 코드로 되돌리면 운영과 같은 예외로 실패하고, 수정본은 통과합니다.

재시도는 확인된 예외 하나만 늘렸다

DELETE를 건너뛰어도 갱신 경로에는 DELETE가 남습니다. 그래서 적재를 감싼 재시도에 데드락도 넣었습니다.

1
2
3
4
5
6
7
8
9
10
// 이 클래스는 트랜잭션을 열지 않는다. 쓰기는 자체 @Transactional 을 가진 Writer 가 맡는다
public void record(String crawlObjectId, QueryResultSnapshot snapshot, List<Reason> reasons) {
    try {
        writer.write(crawlObjectId, snapshot, reasons);
    } catch (DataIntegrityViolationException | CannotAcquireLockException e) {
        // 실패한 트랜잭션은 이미 롤백됐다. 새 트랜잭션에서 한 번 더 시도한다
        log.info("Retrying query result recording after {}", e.getClass().getSimpleName(), e);
        writer.write(crawlObjectId, snapshot, reasons);
    }
}

재시도가 트랜잭션 밖에 있어야 한다는 게 이 구조의 전제입니다. 데드락 희생자가 된 트랜잭션은 이미 롤백됐으니 같은 트랜잭션 안에서는 다시 시도할 수 없거든요.

설계에서는 PessimisticLockingFailureException 계열 전체를 잡자고 했는데 CannotAcquireLockException 하나로 좁혔습니다. 운영 로그에 찍힌 데드락이 이 타입으로 올라오고 있었습니다. 상위 타입까지 잡으면 락 대기 타임아웃 같은 다른 실패도 재시도 대상이 될 수 있고, 그러면 셀러의 조회 요청 스레드가 타임아웃만큼 한 번 더 기다립니다. 확인된 것만 재시도하기로 했습니다.

예외를 삼킨 호출부에는 알림을 따로 달았다

이슈에 없던 것을 하나 더 넣었습니다. 3주 동안 몰랐던 이유를 막지 않으면 다음 적재 실패도 똑같이 묻힐 테니까요.

예외를 삼키는 판단 자체는 맞다고 봅니다. 다만 삼킬 거면 그 자리에서 직접 알려야 했습니다. 그래서 재시도까지 실패한 건을 그 자리에서 Slack으로 보내게 했습니다. 장애 때 조회마다 울리지 않게 Redis 카운터로 예외 유형별 10분에 한 번만 보내고, 알림 전송이 실패해도 조회에는 영향이 없게 감쌌습니다.

남겨 둔 것

갱신 경로의 DELETE는 그대로입니다. 자식이 없던 부모에 새 사유가 생기는 갱신 두 건이 동시에 돌면 이론상 같은 데드락이 날 수 있습니다. 10분 안에 다시 들어온 재사용 조회 두 건이 같은 순간에 겹쳐야 하는 경우라 드물고, 재시도 한 번이 흡수할 거라고 봤습니다. 이게 실제로 보이면 자식을 지우고 다시 넣는 대신 바뀐 것만 반영하는 방식으로 바꾸거나, 그때 가서 이 트랜잭션의 격리 수준을 다시 검토합니다.

알림은 최대 한 번(at-most-once)이라, 첫 전송이 실패하면 같은 유형 알림이 최대 10분 늦습니다. 셀러에게 영향이 없는 기록이라 받아들였습니다.

배포 뒤 관측은 아직 끝나지 않았습니다. 데드락 재시도 로그가 꾸준히 찍힌다면 갱신 경로의 DELETE가 생각보다 자주 겹친다는 뜻이고, 그때는 위의 대안을 앞당깁니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.