Concurrency issue: 데드락을 제거하며 정합성 유지하기

문제: 운영에서 발생한 데드락 발생과 데이터 불일치

이번 트러블슈팅은 운영 로그를 확인하면서 시작되었습니다.

어느 날 슬랙으로 데드락 알림이 올라왔고, 모니터링을 확인해 보니 일기 작성 요청작성 재촉하기 요청에서 다음과 같은 에러 로그가 반복적으로 발생하고 있었습니다.

  • SQL Error 1213, SQLState 40001

  • Deadlock found when trying to get lock; try restarting transaction

두 요청은 서로 다른 기능(API)이지만 공통점이 있었습니다.

  • 둘 다 같은 일기장에 대해 동작하고

  • 해당 일기장에 대한 작성 재촉 횟수 카운터를 갱신합니다.

처음에는 “데드락이면 적절한 커스텀 예외로 변환해서 클라이언트에 알려주면 되는 것 아닌가?”라는 정도로 생각했습니다.

하지만 실제 로그에 기록되지 않는 정합성이 깨진 상태까지 직접 확인해 보니, 상황은 그렇게 단순하지 않았습니다.

운영에서 발생했던 데드락 로그들을 모두 모아 보니, 크게 세 가지 상황이 존재했습니다.

문제 분석: 데드락과 정합성 문제 발생 상황 조사

문제를 분석해 본 결과, 다음 세 가지 케이스에서 데드락과 정합성 문제가 관찰되었습니다.

1. 일기 작성 동시 요청 시 데드락 발생

이 경우 데드락이 발생한 요청은 롤백 되지만, 최종 데이터 정합성 자체는 문제가 없었습니다.

해당 기능의 비즈니스 규칙은 “일기 작성 차례에는 한 번만 작성 가능”이기 때문에, 동시 요청이 오더라도 실제 한 번만 성공해야 합니다.

데드락으로 한 요청이 버려지는 것은 깔끔한 처리가 아니지만, 결과적으로 같은 일기가 “두 번” 써지는 상황은 발생하지 않았습니다.

2. 작성 재촉하기 동시 요청 시 데드락 발생

작성 재촉하기 기능에는 중요한 비즈니스 규칙이 존재합니다.

  • 한 번 재촉 요청이 성공하면 8시간이 지난 후에야 해당 요청을 반영할 수 있습니다.

  • 재촉 횟수가 3회 달성하면 0으로 초기화하면서, 작성 차례를 다음 차례로 변경합니다.

컨트롤러 레벨에서 캐시를 사용하여 동시에 요청 보내는 것을 어느 정도 막고 있지만, 실제로 동시 요청이 발생했습니다. 다만 이 경우에도 두 요청이 모두 처리되는 상황은 관찰되지 않았고, 데드락이 발생하면 한 요청이 실패하면서 재촉 횟수 정합성에는 문제가 없었습니다.

3. 일기 작성과 작성 재촉하기 동시 요청

이 상황에서는 데드락과 정합성 문제가 모두 발생하는 핵심 케이스였습니다.

  1. 하나의 요청이 성공 → 데드락으로 하나의 요청이 버려짐

  • 데드락은 발생하지만 최종적으로 하나의 요청만 반영되므로, 정합성은 유지됩니다.

  1. 작성 재촉하기 요청 성공 → 일기 작성 요청 성공

  • 재촉 횟수가 증가한 뒤, 일기 작성이 성공하면 재촉 횟수를 N → 0으로 덮어씌우면서 초기화합니다.

  • 최종 결과는 재촉 횟수가 0이기 때문에 비즈니스적으로 문제가 없으며 허용 가능한 상태입니다.

  1. 일기 작성 요청 성공 → 작성 재촉하기 요청 성공

정합성이 문제가 되는 상황은 그 반대 순서, 즉 일기 작성 요청이 먼저 성공하고, 그다음에 재촉하기 요청이 성공하는 경우입니다.

  • 먼저 일기 작성 요청이 성공하면 해당 데이터는 DB에 저장되고, 이 시점에 재촉 횟수를 0으로 초기화합니다.

  • 이후 재촉하기 요청도 성공하면서, 재촉 횟수를 이전 값 기준 +1 계산하여 DB에 업데이트합니다.

  • 최종 재촉 횟수는 0이 아니라, 재촉하기가 이전에 읽은 값을 기준으로 계산한 값으로 덮어써 버립니다.

🤔 그럼 무엇이 문제가 되는 걸까요?

재촉하기는 “일기 작성 후 8시간 이후에 요청을 반영할 수 있다.”라는 비즈니스 규칙이 존재합니다. 하지만 위와 같은 상황에서는 이 규칙을 위반합니다.

즉, 사용자는 방금 작성된 일기지만 재촉 횟수가 0이 아닌 잘못된 결과를 보게 됩니다.

이는 업데이트 손실(Lost Update)가 발생했다고 해석할 수 있었습니다.

정리한 내용은 다음과 같습니다.

  • 데드락 자체는 대부분 케이스에서 최종 정합성을 깨뜨리지 않습니다.

  • 진짜 문제는 일부 케이스에서 Lost Update로 인해 정합성이 깨지고, 사용자가 잘못된 정보를 보게 되는 것이었습니다.

데드락 원인 분석

앞 내용에서, 문제는 세 가지 케이스 모두 데드락이 발생했습니다.

그렇다면 “왜” 데드락이 발생했는지 분석했으며, 가장 먼저 인덱스나 DB 제약조건 같은 것보다 락 획득 순서와 트랜잭션 범위에 집중했습니다.

일기 작성 요청과 작성 재촉하기 요청은 서로 다른 API이지만, DB 수준에서는 공통 자원을 공유합니다.

  • 특정 일기장 ROW

  • 일기장의 재촉 횟수 기록 ROW

  • 재촉 기록 ROW (누가 언제 재촉했는지를 저장하는 데이터)

이 자원들에 대해 두 요청이 서로 다른 순서로 락을 획득하여 잡고 있었습니다.

락 획득 시점 분석

먼저 각 API가 어떤 순서로 락을 획득하는지를 예시로 나타냈습니다.

일기 작성 요청 진행 순서

  1. 일기장과 현재 작성자 정보를 읽고, 작성 가능 여부를 검증합니다.

  2. 일기 데이터를 저장합니다.

  3. 일기장 메타데이터를 갱신합니다.

  4. 작성 재촉 횟수를 0으로 초기화합니다.

작성 재촉하기 요청 진행 순서

  1. 일기장의 재촉 상태(재촉 가능 여부 = 쿨타임, 누적 횟수 등)를 확인합니다.

  2. 재촉 횟수를 +1 증가시킵니다.

  3. 재촉 기록을 저장합니다.

  4. 특정 조건(예: 재촉 3회 도달)에서는 재촉 횟수를 0으로 초기화하고 작성 차례를 변경합니다.

여기서 중요한 것은 락 순서입니다.

일기 작성은

  • 일기장 → 재촉 횟수 순서로 락을 잡는 반면,

재촉하기는

  • 재촉 횟수 → 일기장 순서로 락을 잡는 구조가 될 수 있습니다.

저는 MySQL InnoDB를 사용하고 있었고, InnoDB는 외래 키 제약을 보장하기 위해 데이터를 INSERT 할 때 참조 대상에 공유 락(S-lock)을 겁니다. 이 때문에 참조 대상 ROW는 여러 쿼리가 동시에 접근하는 병목 지점이 될 수 있습니다.

그 결과, 어떤 트랜잭션은 락을 쥐고, 다른 트랜잭션은 락을 기다리는 원형 대기(Circular Wait) 상태가 형성됩니다. 이것이 맨 처음 언급한 데드락(SQL 1213)의 직접적인 원인이 됩니다.

API 응답 속도 분석

락 획득 순서만으로도 데드락이 발생할 수 있는 구조였지만, 중간 외부 API 호출 구간에서 트랜잭션이 락을 오래 쥐고 있는지를 확인했습니다.

락 획득 순서만으로도 데드락이 발생할 수 있는 구조였지만, 중간 외부 API 호출 구간에서 트랜잭션이 락을 오래 쥐고 있는지를 확인했습니다.

응답 시간은 모니터링 기준으로:

  • 일기 작성 요청의 p95 응답 시간은 약 1.36s

  • 작성 재촉하기 요청의 p95 응답 시간은 약 848ms

당시 구현에서는 트랜잭션 안에서 FCM 알림을 동기 호출하고 있었습니다. 외부 API 호출은 보통 수백 ms 정도의 지연을 갖습니다.

따라서 이 응답 시간에는 네트워크, 서비스 로직, DB 작업, 트랜잭션 안에서 실행되던 FCM 알림 전송(외부 API 호출)까지 모두 포함되어 있었습니다. 이 시간이 그대로 트랜잭션 유지 시간 = 락 보유 시간으로 이어질 수 있으며, 락을 오래 점유하고 있을수록 더 많은 트랜잭션이 생성되며 데드락이 발생할 확률은 증가합니다.

조금 과장해서 얘기하면,

  • 일기 작성 하나가 p95 기준 1.36s 동안 락을 점유

  • 작성 재촉하기도 p95 기준 848ms 동안 락을 점유

이런 상황에서 같은 일기장에 대한 요청이 몰리면 서로 다른 트랜잭션이 긴 시간 동안 락을 기다리게 되고 데드락 발생 확률이 높아집니다.

따라서 트랜잭션 길이가 길수록 데드락의 발생 빈도도 함께 올라갔다고 볼 수 있습니다.

정리

위 상황을 모두 종합해 봤을 때, 데드락 발생은 다음과 같은 이유로 예상할 수 있습니다.

  • 락 순서가 서로 다른 트랜잭션이 대기하는 구조

  • 트랜잭션 안에서 FCM을 동기 호출하면서 락을 점유하는 시간이 불필요하게 길었던 점

1차 개선: 트랜잭션 범위 축소와 락 획득 순서 정렬 시도

데드락의 직접적인 원인을 파악했으니, 이제 개선할 차례입니다.

가장 먼저 시도한 것은 트랜잭션 범위 축소락 획득 순서 정렬이었습니다.

트랜잭션 범위 축소: FCM 알림 비동기 처리

앞서 분석에서 일기 작성의 p95 응답 시간은 약 1.36s, 재촉하기는 약 848ms로 측정되었습니다.

이 시간동안 트랜잭션이 락을 점유하고 있었고, 그 주요 원인은 트랜잭션 안에서 FCM 알림을 동기 호출하고 있었기 때문입니다. 외부 API 호출은 네트워크 지연으로 인해 수백ms의 지연이 발생할 수 있으며, 이는 락을 불필요하게 오래 점유하게 만들어 데드락 발생 확률을 높입니다.

따라서 FCM 알림 전송을 스프링 이벤트 기반의 비동기 I/O로 분리했습니다.

비동기 처리를 위한 스레드 풀 설정

비동기 처리를 도입할 때 적절한 스레드 풀 크기 설정이 필요하다고 생각했습니다. 처음에는 추상적인 안전 값 적용을 고민했습니다.

  • 너무 작으면 요청을 제때 처리하지 못하고

  • 너무 크면 불필요한 리소스를 낭비하게 됩니다.

하지만 구체적인 근거가 없어 적용하지 않고 실제 운영 트래픽 측정 결과를 기반으로 스레드 풀을 설계했습니다.

모니터링을 통한 운영환경 트래픽 측정 결과:

  • 피크 처리량: 448 rpm(분당 요청 수) = 약 7.47 rps

  • 피크 시간: 오후 11시~12시

  • 활동 시간대 평균: 50~150 rpm

시나리오 분석:

  1. 피크시간 (448 rpm = 7.47 rps)

    • Core 2개의 처리 능력 = 10 rps

    • 스레드 사용률: 7.47 ÷ 10 = 74.7%

    • 여유율: 25.3%

  2. FCM 지연 상황 (2배 지연으로 400ms)

    • Core 2개만으로는 2 ÷ 0.4 = 5 rps (부족)

    • MaxPoolSize 4개 적용 시: 4 ÷ 0.4 = 10 rps

    • 사용률: 74.7% (안정적으로 처리 가능)

  3. 큐 크기 계산

큐 크기는 애플리케이션 종료 시 알림 유실을 방지하기 위해 적절한 큐 크기가 필요하다고 판단하여, 애플리케이션 컨테이너 종료 시간을 기준으로 설정했습니다.

  • Docker 컨테이너 종료 시 default 10초의 graceful shutdown 시간을 제공

  • Core 2개의 스레드가 초당 10건 처리 (0.2s/건)

  • Queue 50개 처리 시간: 50 ÷ 10 = 5초

  • 결론: 50개는 10초 안에 여유롭게 처리 가능하므로 유실이 없을 것이라 판단

락 획득 순서 정렬

비동기 처리로 트랜잭션 시간을 단축했지만, 이것만으로는 데드락을 완전히 해결할 수 없었습니다. 근본적인 문제인 락 획득 순서 불일치를 함께 해결해야 했습니다.

기존 락 획득 순서

일기 작성 요청:

작성 재촉하기 요청:

락 획득 순서가 달라 원형 대기(Circular Wait) 발생:

애플리케이션 레벨에서 락 순서 정렬 시도

모든 트랜잭션이 동일한 순서로 데이터에 접근하도록 비즈니스 로직의 순서를 재구성했습니다.

의도한 데이터 접근 순서:

일기 작성과 재촉하기 모두 일기장 테이블을 먼저 조회한 후, 재촉 횟수 테이블에 접근하도록 코드를 수정했습니다.

하지만 실제 JPA가 실행하는 쿼리 순서는 의도와 다르게 동작할 수 있었으며, 이는 다음에 발견한 정합성 문제와 함께 근본적으로 해결해야 했습니다.

문제 발견: JPA는 코드 순서대로 쿼리를 실행하지 않는다.

하지만 실제 JPA가 실행하는 쿼리 순서는 의도와 달랐습니다.

JPA Hibernate는 쓰기 지연(Write-Behind) 방식으로 동작합니다:

  1. SELECT 쿼리는 즉시 실행

  2. INSERT/UPDATE/DELETE는 트랜잭션 커밋 직전(flush)에 일괄 실행

  3. 영속성 컨텍스트 내부 순서에 따라 쿼리가 실행됨

1차 개선의 한계

락 순서를 재정렬하려는 시도는 다음과 같은 이유로 실패했습니다:

  1. JPA flush 시점 제어의 어려움: 코드 작성 순서 ≠ 실제 쿼리 실행 순서

  2. FK 제약조건의 암묵적 락: INSERT 시 자동으로 참조 테이블 S-lock 획득

  3. 비즈니스 로직의 복잡성: 각 트랜잭션마다 다른 엔티티 조합을 다루므로 일관된 순서 강제가 어려움

비동기 처리로 데드락 발생 확률은 줄였지만, 근본적인 해결책은 아니었습니다.

정리

이 개선으로 데드락 빈도는 줄었지만, 앞서 정리한 Lost Update로 인한 정합성 문제는 여전히 남아 있었습니다.

락 순서를 맞추더라도, JPA의 flush 시점이나 쿼리 실행 순서가 의도와 다르게 동작할 수 있기 때문입니다.

정합성 원인 분석

이전에 재촉 횟수가 맞지 않는다는 CS 문의가 있었지만, 로그에 남은 것도 없어 원인을 특정하지 못하고 넘어갔던 적이 있었습니다. 이번 데드락 트러블슈팅 중 개발 환경에서 테스트를 반복하면서 데이터 정합성이 쉽게 깨지는 상황을 재현할 수 있었고, 심각한 문제로 인식해 본격적으로 진행했습니다.

Read-Modify-Write와 Check-Then-Act 패턴의 위험성

현재 구조에는 두 가지 동시성 문제 패턴이 존재했습니다.

Read-Modify-Write 패턴

Check-Then-Act 패턴

문제 유형

패턴

원인

결과

해결 지점

Lost Update

RMW(Read-Modify-Write) 패턴

같은 값을 여러 트랜잭션이 동시에 읽음

데이터 손실 또는 덮어쓰기

데이터 갱신(Write 시점)

Race Condition

CTA(Check-Then-Act) 패턴

체크 시점과 사용 시점이 다름

규칙 위반, 잘못된 조건 검증

조건 검증(Act 시점)

  • Lost Update

    • 두 트랜잭션이 동시에 같은 데이터를 읽고 수정할 때, 한 트랜잭션의 변경사항이 다른 트랜잭션의 변경사항으로 덮어씌워지는 현상입니다.

  • Check-Then-Act

    • "조건 확인 → 동작" 사이에 상태가 바뀌는 문제입니다.

    • 체크 시점에는 조건이 맞았지만, 실제 동작 시점에는 조건이 깨진 상태입니다.

문제 발생 상황

처음 문제 분석에서 언급했듯, Lost Update가 발생하는 핵심 상황은 "일기 작성과 재촉하기 동시 요청"에 있었습니다.

Lost Update 상황: 일기 작성 커밋 → 재촉하기 커밋

문제점:

  • 일기 작성이 완료되어 사용자는 재촉 횟수 = 0을 봐야 하지만, 일기 작성 이후에도 증가한 카운트를 보게 되는 문제가 발생합니다.

비즈니스 영향:

  • 다음 작성 유저는 재촉하기 한 번만 발생해도 작성 차례가 스킵됨

  • “일기 작성 후 8시간 동안 재촉 불가"라는 비즈니스 규칙 위반

Check-Then-Act 상황: 재촉 횟수 = 2인 상태에서 재촉하기 커밋 → 일기 작성 커밋

= 작성자 검증 시점과 실제 작성 시점의 불일치

기대하는 시나리오:

  1. 재촉 횟수(=2) 상태에서 재촉하기 요청 발생

  2. 트랜잭션이 커밋되어 재촉 횟수 = 3이 되어 UserA(작성자) 턴 스킵 → 현재 작성자 = UserB

  3. 동시 실행중인 일기 작성 트랜잭션에서 A가 일기 작성 시도 → 현재 작성자가 아닌 경우 예외 발생

실제 발생한 시나리오:

  1. UserA가 “내 차례”를 확인 (현재 작성자 = A)

  2. 재촉하기 요청 발생 → A 턴이 스킵되어 작성자가 UserB로 변경

  3. A는 일기 작성 계속 진행

  4. 트랜잭션이 커밋되어 A의 일기 저장 성공 (문제)

문제점:

  • Check 시점: 현재 작성자 = A (검증 통과)

  • Act 시점: 현재 작성자 = B (이미 변경됨)

  • UserA의 턴이 스킵되었는데도 A가 일기를 작성함

  • 트랜잭션 시작 시점의 데이터로 검증하고, 커밋 시점의 변경사항을 인지하지 못함

분석:

이 케이스는 엄밀히 말하면 전통적인 Lost Update와는 약간 다릅니다.

  • 재촉 횟수는 정상적으로 0으로 초기화됨 (덮어쓰지 않음)

  • DB 최종 상태(재촉 횟수= 0, 현재 작성자 = B)는 비즈니스 결과와 일치합니다.

하지만 작성 순서 규칙 위반이라는 문제가 있습니다.

  • UserA의 턴이 스킵되었는데도 A가 일기를 작성합니다.

  • "현재 작성자만 일기를 쓸 수 있다.”는 비즈니스 규칙을 위반합니다.

  • 트랜잭션 시작 시점의 데이터로 검증하고, 커밋 시점의 변경사항을 인지하지 못한 상태로 커밋합니다.

이는 Check-Then-Act 패턴의 전형적인 Race Condition입니다.

이때 Check는 트랜잭션 시작 시점에 현재 작성자를 조회해 검증하는 단계이고, Act은 그 검증 결과가 여전히 유효하다고 가정하고 일기 INSERT를 커밋하는 단계에 해당합니다.

앞서 언급한 Read-Modify-Write의 Lost Update와는 별개의 동시성 문제입니다.

정합성을 보장하면서 데드락을 해결할 수 있는 방안 검토

데드락을 해결함과 동시에 정합성도 챙길 수 있는 방법을 도출하기 위해 여러 동시성 제어 기법을 검토했습니다.

해결 방안 후보:

  • Redis 분산 락

  • 애플리케이션 락 (ReentrantLock 등)

  • 낙관적 락 (Optimistic Lock - @Version)

  • 비관적 락 (Pessimistic Lock - SELECT FOR UPDATE)

  • 조건부 업데이트 (Conditional Update)

1. Redis

먼저 Redis를 해결 방안으로 채택하지 않은 이유는 명확합니다.

Redis 기반 락은 “여러 인스턴스에 걸쳐 공통된 키를 기준으로 락을 잡고 싶다.”는 문제에 대한 좋은 해답이라고 생각합니다. 하지만 현재 서비스 상황에서 고려했을 때,

  • 현재 운영 인프라에서는 Redis를 사용하지 않습니다.

  • 단지 동시성 이슈 하나 때문에 인프라 확장, 운영 포인트, 장애 포인트가 모두 늘어납니다.

  • 게다가 락 만료나 연장, 네트워크 파티션 등 불확신한 엣지 케이스를 다루기 위해서는 설계가 복잡해집니다.

즉, 지금 문제의 크기에 비해 너무 과도한 비용이라고 판단했습니다. DB 내부 동작과 애플리케이션 레벨 설계만으로도 충분히 해결 가능한 구조들이 열려 있었기 때문에 Redis 도입은 우선순위에서 제외했습니다.

2. 애플리케이션 수준의 락

문제 1. 트랜잭션 범위와 락 범위의 불일치

Spring의 @Transactional은 AOP 기반으로 동작합니다. 메서드가 종료될 때 커밋이 일어나는데, 이는 애플리케이션 락과 타이밍이 맞지 않습니다. (try-finally 블록에서 해제)

즉, 락은 메서드 종료 시점에 해제하고 DB 커밋은 그 이후 AOP 프록시 단계에서 수행됩니다.

여기서 발생하는 문제는 락은 반납했지만 DB 트랜잭션은 아직 커밋 되지 않은 순간에 다른 스레드가 변경 전 데이터를 읽어버리는 문제가 발생합니다.

  • Thread A:

    • 락 획득 (lock.lock())

    • DB 변경 수행

    • 락 해제 (lock.unlock())

  • Thread B:

    • 락 획득 (lock.lock())

    • 아직 커밋 되지 않은 DB 상태 기준으로 조회

    • 잘못된 판단 수행

즉, 락은 반납했지만 DB 상태는 과거의 상태를 읽고 처리하게 되어 정합성 보장이 불가능하게 됩니다.

문제 2. 다중 서버(분산, 스케일 아웃) 환경에서의 한계

서버 인스턴스가 2대 이상이면, 각 JVM 안의 ReentrantLock은 서버별로 존재합니다.

A 서버에서 id=1에 대한 락을 잡았다고 해서, B 서버가 그걸 알 방법은 없습니다.

현재 서비스는 충분히 스케일 아웃 가능성을 전제하고 있기 때문에 JVM 메모리 기반 락에 전체 동시성을 맡기는 것은 구조적으로 맞지 않았습니다.

  • JVM 메모리 락은 서버 간 공유되지 않아서 공유 시 별도의 공유 메커니즘이 필요합니다.

  • 이는 서버 1대일 경우에만 유효한 해법입니다.

3. 낙관적 락 (Optimistic Lock)

낙관적 락은 이론적으로 Lost Update를 해결하는 정석적인 방법입니다. 엔티티에 @Version 필드를 두고 커밋 시점에 버전이 바뀌었는지 확인해 충돌을 감지합니다.

이번 이슈에서도 한 번쯤은 “그냥 @Version을 추가하면 간단하게 해결되지 않을까?”를 고민했습니다.

하지만 실제 서비스 트래픽과 도메인 특성을 놓고 비교해 보니, “이 구조에 굳이 낙관락을 도입할 이유가 크지 않다.”라는 결론이 나왔습니다.

크게는 두 가지가 걸렸습니다.

문제 1. 도입 범위, 복잡도에 비해 얻는 이득이 적다.

먼저 전제를 다시 세웠습니다.

  • 이 서비스는 트래픽이 많은 시스템은 아닙니다.

  • “같은 일기장에 대해 동시에 재촉을 보내는 상황”이 가능은 하지만, 항상 발생하는 패턴은 아닙니다.

    • 주로 네트워크 지연이나 일부 사용자의 연속 요청

즉, 낙관적 락이 전제로 삼는 “가끔 충돌이 날 수 있다.”는 상황 자체는 맞습니다.

다만 그렇다고 해서,

  • 관련 엔티티(일기장, 일기, 재촉 횟수, 재촉 기록 등)에 일괄적으로 @Version을 도입하고

  • OptimisticLockException을 잡아서 재시도/도메인 에러로 변환하는 공통 로직을 만들고

    • 무조건 재시도할 것인가?

    • 아니면 늦게 들어온 요청은 실패하는 게 정상이라고 보고 4xx로 돌릴 것인가?

    • 예외를 어느 계층에서 잡고, 어떤 도메인 에러 코드로 변환할 것인가?

  • 테스트 코드와 운영 관측 포인트를 전부 새로 정비하는 것

까지 해야 하는 문제인가? 고민했습니다.

이번 이슈에서 실제로 문제가 된 지점은 범위가 크지 않습니다.

  • Lost Update: 재촉 횟수 +1, 일기 작성 시 횟수 0 초기화가 정확히 부딪히는 한 필드

  • Check-Then-Act: 현재 작성자 필드와 그걸 기반으로 하는 작성 가능 여부 검증

이 두 군데만 제대로 잡으면 되는 문제였습니다.

이걸 위해 도메인 전체에 범용 낙관적 락 도입은 배보다 배꼽이 큰 느낌에 가까웠습니다.

문제 2. FK로 인한 락 구조와 데드락에는 낙관적 락이 개입할 수 없다.

두 번째 이유는, 낙관적 락을 도입해도 이번에 겪었던 데드락 구조에는 영향을 줄 수 없습니다.

이번 데드락의 발생 원인에는 MySQL InnoDB의 외래 키 동작도 포함되며, 낙관적 락은 애플리케이션 레벨(JPA)에서 버전을 체크하는 것일 뿐, DB 내부에서 FK 제약조건 때문에 발생하는 락을 막을 수 없습니다.

데드락 발생 상황:

  1. 재촉 기록을 INSERT 할 때, 외래 키인 일기장 테이블에 대해 공유 락(S-lock)이 걸림

  2. 동시에 다른 트랜잭션이 일기장 테이블을 업데이트하면서 배타 락(X-lock)을 기다림

  3. 일기 작성/재촉하기가 서로 다른 순서로 이 락들을 잡으면서 원형 대기가 발생

낙관적 락은 어디까지나 애플리케이션 레벨에서 version 컬럼을 보고 커밋 시점에 다른 트랜잭션이 먼저 이 행을 바꿨는지를 검사하는 메커니즘입니다.

따라서 낙관적 락은 다음 현상들을 해결할 수 없습니다.

  • InnoDB 내부에 걸리는 S/X-lock

  • FK로 인한 락 전파

  • 락 획득 순서 꼬임

낙관적 락을 도입하면 Lost Update를 해결할 수 있겠지만, FK + 락 순서 교차로 인한 데드락 구조는 그대로 남습니다. 실제 데드락을 줄인 것은 트랜잭션 범위를 축소하고 락 순서를 맞춘 것이었습니다.

4. 비관적 락 (Pessmistic Lock)

비관적 락은 “동시에 수정하려는 스레드가 분명 있을 것이다.”라고 가정하고, 아예 먼저 잠가 버리는 방식입니다.

한 번 락을 잡으면 트랜잭션이 끝날 때까지 해당 행에 대한 다른 쓰기 요청을 막을 수 있기 때문에, 동시성 문제가 의심될 때 많이 떠오르는 선택지이기도 합니다. 이 방식은 특히 “절대 동시에 두 개가 성공하면 안 되는 규칙”을 지키기에 좋습니다.

덕지는 다음과 같은 비즈니스 규칙이 있습니다.

  • 현재 작성자만 일기를 작성할 수 있습니다.

  • 재촉하기 3번으로 턴이 스킵 되면 해당 차례에 일기를 작성할 수 없습니다.

이번에 발생한 문제들도 “일기장에 비관락을 건다.”라는 개념만 도입하면, 정합성 측면에서는 대부분 해결 가능합니다. 그래서 정합성만 놓고 보면 가장 간단하면서도 직관적인 해결책이었습니다.

그럼에도 도입하지 않은 이유는 정합성은 쉽게 해결되지만, 그만큼 비용을 치르게 되기 때문입니다.

문제 1. 정합성은 쉬운데, 성능과 확장성이 비싸다

비관적 락을 도입했을 때 가장 큰 장점은 정합성을 얻기 쉽다는 점입니다.

예를들어, 일기장에 적용하면:

이 흐름에서는 같은 일기장에 대한 다른 일기 작성/재촉하기 요청이 오면 모두 1번에서 락을 얻기 위해 대기합니다.

결국 “한 번에 하나의 트랜잭션만” 해당 일기장에 대해 쓰기를 수행하게 되므로, 동시에 두 요청이 모두 성공하는 상황은 사실상 없어집니다. 문제는, 이 구조가 정합성은 쉽게 얻을 수 있지만 성능을 상당히 희생한다는 점입니다.

현재 서비스에는 일기 작성/재촉하기 외에도 일기장 참여, 일기장 설정 변경 등 일기장과 연관된 여러 쓰기 기능들이 존재합니다.

이 상태에서 일기장에 비관적 락을 도입하면:

  • 한 사용자의 요청이 일기장 락을 쥐고 있는 1초 동안, 같은 일기장에 대한 나머지 쓰기 요청은 모두 대기 상태가 됩니다.

  • 실제 전체 트래픽이 많지 않더라도, 특정 일기장 ROW에 요청이 몰리는 패턴은 충분히 발생할 수 있습니다.

  • 해당 일기장에 대한 쓰기는 사실상 한 번에 한 요청만 처리하는 직렬 파이프라인이 되고, 뒤에 오는 요청들의 응답 시간은 순차적으로 늘어납니다.

덕지는 아직 트래픽이 많은 서비스는 아니지만, 특정 시간대와 특정 일기장에 트래픽이 집중되는 패턴은 명확히 존재합니다. 이 상황에서 비관적 락으로 일기장 단위 직렬화를 걸어버리면, 정합성은 얻는 대신 해당 일기장에 대한 UX(데드락 관련 에러, 타임아웃, 응답 지연 체감 등)는 쉽게 망가질 수 있습니다.

문제 2. 이미 트랜잭션을 짧게 줄이는 방향으로 설계

앞에서 1차 개선에서는 트랜잭션 내부의 FCM 알림 호출을 비동기로 전환함으로써, 트랜잭션 유지 시간(= 락 보유 시간) 을 줄이는 방향으로 재설계했습니다.

즉, 이번 트러블슈팅의 방향성은 처음부터 응답 성능 최적화와 동시에 락을 최대한 짧고 가볍게 만들자 였습니다.

여기서 다시 비관락을 도입한다는 것은,

  • 트랜잭션 시작 시점부터 강한 락을 잡고

  • 트랜잭션이 끝날 때까지 그 락을 유지하겠다는 뜻입니다.

따라서 락을 오래 점유하여 데드락이 발생했던 구조를 락 수명을 줄여서 데드락 경합을 줄여온 상황에서, 다시 한번 락 점유 시간을 늘리는 것은 방향성 선택의 일관성 측면에서도 아쉬웠습니다.

문제 3. 비즈니스 규칙이 요구하는 것보다 과하게 보장

마지막으로, 비즈니스 규칙이 요구하는 보장을 다시 생각해 보면 필요 이상으로 강한 도구라는 점도 있습니다.

  • 이미 누군가 먼저 일기를 작성했다면 → 뒤늦게 도착한 재촉 요청은 실패하는 것이 자연스럽고

  • 이미 누군가 먼저 재촉 3회를 채워 턴을 넘겼다면 → 이전 작성자가 그 턴의 일기를 더 이상 작성 못 하게 되는 것이 자연스럽습니다.

즉, 여기서는

  • 들어온 요청을 모두 직렬화해서 순차적으로 결국 다 성공시키는 것이 아니라,

  • 먼저 온 요청만 성공시키고, 나머지는 도메인 규칙에 따라 실패 시키는 것

이것이 제가 의도한 진짜로 원하는 동작입니다. 비관적 락은 “모두 순서대로 처리해 정합성을 맞추는” 데 최적화된 도구라면, 이번 문제는 트랜잭션이 먼저 커밋한 쪽만 성공, 경합하는 요청은 실패해도 되는 규칙입니다.

이 관점에서 보면, 비관락은 이 도메인이 필요로 하는 것보다 무거운 선택이라고 판단했습니다.

5. 조건부 업데이트 (Conditional Update)

여러 대안을 검토한 끝에, 이번 문제를 해결하기 위해 최종적으로 선택한 방법은 조건부 업데이트입니다.

핵심 아이디어는 단순합니다.

“처음에 읽었던 이전 상태가 아직 그대로일 때만 값을 바꾸고, 그 사이에 상태가 바뀌었으면 업데이트하지 않고 비즈니스 규칙을 따릅니다.”

장점: RMW를 한 번의 UPDATE로 수렴

기존 재촉 횟수 갱신은 전형적인 Read-Modify-Write 패턴이었습니다.

이 구조에서는 두 트랜잭션이 같은 값을 읽고 각자 수정할 수 있기 때문에, 앞에서 본 것처럼 Lost Update가 발생했습니다.

조건부 업데이트는 이 과정을 하나의 UPDATE 쿼리로 바꿉니다.

  • WHERE 절에 이전에 읽어온 상태(수정 시각, 작성 여부, 현재 작성자 등)를 추가합니다.

  • 최신 상태가 변경되지 않았을 경우에만 UPDATE를 수행합니다.

논리적인 진행 상황은 다음과 같은 형태입니다. (실제 동작은 다릅니다.)

이렇게 하면:

  • 일기 작성이 먼저 성공해 재촉 횟수를 0으로 만들고, 수정 시각과 현재 작성자를 바꿔 두면

  • 이후에 도착한 재촉하기 요청은 WHERE 조건이 맞지 않아 0행 갱신(rowCount = 0) 으로 끝납니다.

  • 더 이상 0 초기화가 뒤늦게 온 +1 연산에 덮어씌워지는 일이 없습니다.

장점: 도메인에 맞는 얇은 낙관적 락

조건부 업데이트의 장점은 이번 도메인과 거의 정교하게 맞아떨어졌습니다.

  1. RMW 패턴을 구조적으로 막을 수 있습니다.

    • 전체 RMW를 애플리케이션 레벨이 아니라 일부만 DB 쿼리로 처리하기 때문에 두 트랜잭션이 같은 값을 읽고 서로의 결과를 덮어쓰는 Lost Update를 차단할 수 있습니다.

  2. 충돌을 예외가 아닌 “도메인 실패”로 다룰 수 있습니다.

    • rowCount = 1이면 갱신 성공

    • rowCount = 0이면 그 사이에 상태가 바뀐 것(= 늦게 온 요청)입니다.

      • 이 0을 그대로 이미 일기가 작성되었거나 이미 턴이 넘어갔거나 이미 다른 재촉 요청이 먼저 반영되었기 때문에 실패하는 것이 정상이라는 **도메인 에러(4xx)**로 매핑할 수 있습니다.

      • 동시성 충돌을 500 서버 에러가 아니라, “이 상황에서는 원래 실패해야 하는 요청”으로 표현할 수 있다는 점이 중요했습니다.

  3. 적은 범위에 적용 가능합니다.

    • Redis나 전역 @Version 낙관적 락처럼 도메인 전체에 공통 정책을 강제할 필요가 없습니다.

    • 이번 문제는 재촉 횟수 + 현재 상태라는 아주 좁은 지점에서 발생했기 때문에, 해당 지점에만 조건부 업데이트를 적용하는 것으로 충분했습니다.

  4. 비관적 락보다 가볍습니다.

    • 비관적 락처럼 트랜잭션 전체 구간에서 락을 잡아두지 않고, UPDATE 실행 순간에만 짧게 락을 잡습니다.

    • 같은 일기장에 대한 요청이 몰려도 “일기장 단위 직렬화” 수준의 병목을 만들지 않습니다.

    • 락 수명은 짧게, 충돌은 도메인 실패라는 이번 설계 방향과 잘 맞았습니다.

단점: WHERE 조건의 설계와 확장성

  1. WHERE 조건 설계가 어렵습니다.

    • 어떤 필드를 이전에 읽어온 상태로 삼을지 설계해야 합니다.

    • 단순히 재촉 횟수만 비교할지, 여러 컬럼을 도메인 상태를 함께 비교할지 결정해야 합니다.

    • 잘못 설계하면 불필요하게 충돌을 많이 만들어 내거나, 반대로 검출해야 할 충돌을 놓칠 수 있습니다.

    • 또한 요구사항과 규칙이 추가/변경될 경우 기존 설계를 변경해야 할 확률이 높습니다.

  2. 서버 ↔ DB 사이의 네트워크 왕복 비용이 늘어날 수 있습니다.

    • 비관적 락의 경우 한 번의 네트워크 비용으로 문제를 해결할 수 있지만, 지금 구조의 경우 상태를 변경하고 다시 조회하여 검증하게 될 경우 추가적인 네트워크 비용이 발생합니다.

  3. 실패 케이스를 도메인 관점에서 정의해야 합니다.

    • rowCount = 0을 무조건 서버 에러로 보면 안 됩니다.

    • “이 경우에는 원래 실패해야 하는 요청인가?”를 도메인 관점에서 해석해 에러 코드와 메시지를 명확히 정의해야 합니다.

  4. 직관성이 다소 떨어질 수 있습니다

    • 단순한 RMW 코드(읽고 → +1 수정 → 저장)보다, Conditional Update Query는 한눈에 읽기 어렵습니다.

    • 팀 단위로 협업을 진행하는 경우 해당 내용에 대한 공유가 필요합니다.

  5. JPA를 사용할 때 객체지향 설계를 지키기 어려워질 수 있습니다.

    • 여러 곳에서 직접 UPDATE 쿼리 작성이 빈번해질 수 있습니다.

    • 이는 엔티티를 통해 상태를 바꾸고 그 안에서 규칙을 지키기보다는 비즈니스 규칙을 쿼리의 Where 절에 의존하게 됩니다.

    • 즉, 비즈니스 규칙이나 도메인 규칙이 해당 엔티티 내부가 아니라 SQL의 Where 조건으로 분산되기 때문에 상태 전이에 대한 책임이 객체에 남지 않게 됩니다.

이번 문제에서는 이 단점들이 Redis, 낙관/비관적 락을 도입했을 때의 비용에 비해 훨씬 적다고 판단했고, 그 결과 조건부 업데이트를 최종 해법으로 선택하게 되었습니다.

2차 개선: 조건부 업데이트 + FK 제거 + 락 순서 정렬

조건부 업데이트와 외래 키 제거를 적용하고 실제 코드와 트랜잭션 레벨에서 무엇이 어떻게 달라졌는지 확인하기 위해 앞에서 문제를 야기했던 시나리오를 다시 가져와 적용 전/후 흐름을 비교해 보았습니다.

Foregin Key 제거 (데드락 원인 일부 제거)

DB의 외래 키는 보통 참조 무결성을 보장하기 위해 존재하지만, 지금 구조에서는 쓰기 과정에서 데드락을 발생시키는 쪽으로 작용하고 있습니다.

같은 일기장에 대해 여러 요청이 동시에 들어올 때:

  • 요청 A는 일기장의 상태 정보를 갱신하기 위해 ROW에 락을 획득하고, 요청 B도 일기장을 참조하는 하위 테이블에 쓰기를 시도합니다.

  • 두 요청이 서로 다른 순서로 락을 획득하려고 하면 상호 대기 상태가 되어 데드락이 발생합니다.

이때 실제 데드락 발생 원인은 비즈니스 로직이 아니라, 하위 테이블 INSERT 시 외래 키를 검사하면서 참조 대상 행에 추가로 락이 걸리는 구조였습니다. 따라서 외래 키를 제거하면 순환 대기 구조를 제거할 수 있었습니다.

✅ 외래 키를 제거해도 괜찮은 이유

FK를 제거하면 DB가 자동으로 참조 무결성을 막아주지 않지만, 현재 시스템 구조에서는 이미 비즈니스 로직 + 조건부 업데이트를 통해 외래 키 검증의 역할을 대체하고 있습니다.

조회 후 검증 + 조건부 업데이트 (이중 검증)

  • 비즈니스 로직에서 먼저 참조 테이블을 조회하고 상태를 검증합니다. (S-lock이 걸리는 테이블 조회)

  • 이후 UPDATE 실행 시 조건부 WHERE 절로 일부 비즈니스 규칙을 검증합니다.

  • 두 계층의 검증을 모두 통과해야만 쓰기가 가능하므로 고아 레코드 생성을 막을 수 있습니다.

장점: 개발자가 락 순서 제어 가능

외래 키를 사용하면 하위 테이블의 쓰기 작업에서 락이 전파되어 어떤 순서로 락이 잡히는지 예측하기 어렵습니다.

  • FK를 제거하면 코드 레벨에서 락 순서를 어느 정도 제어할 수 있습니다.

  • 이는 락 획득/반납 시점을 통일하여 데드락이 발생하는 구조를 회피할 수 있습니다.

Lost Update 케이스: 재촉 횟수가 일치하지 않는 상황

일기 작성 ↔ 재촉하기 동시 요청 상황에서 발생하는 문제이며, 일기 작성 후 재촉 횟수가 0이 되지 않는 상황입니다.

  • "일기 작성 이후에는 재촉 횟수가 0이며, 작성 이후 8시간이 지나야 재촉할 수 있다."는 비즈니스 규칙을 깨뜨린 상황

⏸️ 적용 전(RMW + Lost Update)

결과:

  • Tx1이 먼저 재촉 횟수를 0으로 초기화하고 커밋 했지만

  • Tx2는 이전에 읽어 둔 값(1)을 기준으로 2로 증가시켜 Tx1의 변경을 덮어써 버립니다.

  • 일기 작성 후 8시간 동안 재촉이 불가해야 하는 비즈니스 규칙이 깨집니다.

Read-Modify-Write 패턴의 Lost Update입니다.

▶️ 적용 후 (조건부 Update + 도메인 실패)

결과:

  • 재촉하기는 조건부 증가 + 일기 작성 후 8시간 이후만 허용 규칙 검증

    • 조건부 증가는 DB 수준에서의 검증을

    • 일기 작성 후 8시간 이후 허용 규칙은 애플리케이션에서 검증합니다.

  • 규칙을 만족하지 못하면 예외를 발생시키며 전체 트랜잭션을 롤백합니다

변경된 구조는 “누가 먼저 커밋 했느냐”가 아니라 “상태가 아직 유효한가?”를 판단하도록 변경하여 Lost Update를 구조적으로 막았습니다.

Check-Then-Act 케이스: 작성 차례가 아닌 사용자가 작성하는 상황

일기 작성 ↔ 재촉하기 동시 요청 상황에서 발생하는 문제이며, 턴이 스킵된 사용자가 일기 작성을 성공하는 상황입니다.

  • "현재 작성자만 일기를 쓸 수 있다."는 비즈니스 규칙을 깨뜨린 Check-Then-Act 케이스입니다.

⏸️ 적용 전(검증 시점과 커밋 시점 사이의 갭)

결과:

  • A의 턴이 재촉 3회로 이미 스킵 되었는데도, A가 “내 차례인 줄 알고” 일기 쓰기가 성공합니다.

  • “현재 작성자만 일기를 쓸 수 있다”는 비즈니스 규칙 위반

  • 원인: 검증 시점(트랜잭션 시작 시점)과 커밋 시점 사이에 상태가 바뀌어 버린, Check-Then-Act 경쟁 조건

    • Check: 트랜잭션 시작 시점에 현재 작성자 = A

    • Act: 트랜잭션 끝에서 일기를 저장할 때는 이미 현재 작성자 = B로 바뀐 상태

▶️ 적용 후 (조건부 업데이트로 현재 작성자 보장)

적용 후에는, 일기 작성 쪽에서도 해당 사용자의 턴인지를 락 순서를 정렬해 확인하도록 바꾸었습니다.

결과:

  • 재촉 트랜잭션이 먼저 현재 작성자 = B, 재촉 횟수 = 0 상태를 만들고 커밋 합니다.

  • 그 이후에 커밋을 시도하는 일기 작성 트랜잭션은 “이 사용자의 차례가 아니다.”라는 비즈니스 규칙 검증을 통해 전체 트랜잭션을 롤백 합니다.

  • 따라서 턴이 스킵 된 사용자는 더 이상 해당 턴에서 일기를 쓸 수 없다는 규칙이 커밋 직전까지 보장됩니다.

Summary

이번 작업의 목표는 두 가지였습니다.

  1. 일기 작성/재촉하기 동시 요청에서 발생하던 데드락 제거

  2. Lost Update로 인한 도메인 정합성 문제를 구조적으로 막기

운영 환경에서 관측되던 데드락과 정합성 문제는 서로 다른 API가 동일한 상태를 Read-Modify-Write 패턴으로 공유하고 있는 구조적 문제가 핵심 원인이었습니다. 이 과정에서 Lost Update뿐만 아니라 Check-Then-Act 경쟁 조건도 함께 발생했습니다.

시도한 것

  1. 트랜잭션 안에서 실행되던 FCM 외부 호출을 비동기로 분리하여 트랜잭션 범위를 축소하고 락 점유 시간을 최소화했습니다.

  2. 데드락의 원인이 되던 일부 기록성 테이블의 외래 키를 제거하여 INSERT 시 발생하던 불필요한 락 전파를 차단했습니다. (순환 대기 가능성 제거 및 락 순서 정렬 가능)

  3. 정합성 문제가 발생하던 지점에 Conditional Update(조건부 업데이트)를 적용하여 도메인 상태 검증과 변경을 하나의 SQL로 묶어 상태 전이를 DB 레벨에서 원자적으로 처리하도록 변경했습니다.

결과 및 성과

배포 이후 약 한 달 동안 운영 환경에서 데드락 로그가 더 이상 관측되지 않았습니다.

이전에 재현되었던 턴 스킵, 재촉 횟수 꼬임 등 정합성 문제도 동일한 시나리오 테스트로도 재현되지 않았습니다.

또한 트랜잭션 범위 축소로 API 성능도 함께 개선되었습니다.

  • 일기 저장 API p95: 약 1.36s → 291ms

  • 작성 재촉하기 API p95: 약 848ms → 83.5ms

결과적으로 데드락과 정합성 문제를 동시에 해결하면서 응답 시간은 단축시킬 수 있었습니다.

Trade-off와 한계

문제를 해결하는 여러 방법 중 조건부 업데이트를 선택하여 구조를 변경하면서 다음과 같은 비용과 제약이 생겼습니다.

  1. Where 조건 설계 난이도 증가

  • 어떤 필드를 통해 이전에 읽어온 상태로 볼지, 여러 컬럼을 함께 비교할지에 대한 설계가 필요합니다.

  • 잘못 설계하면 불필요한 충돌을 만들어 내거나, 잡아야 할 충돌을 놓칠 수 있습니다.

  • 요구사항/비즈니스 규칙이 변경될 때마다 Where 조건을 함께 고민해야 할 확률이 높아집니다.

  1. 네트워크 왕복 비용 증가

  • 비관적 락처럼 한 번의 쿼리(조회 + 잠금) 호출을 통한 방식에 비해, 상태를 변경한 뒤 다시 조회해 검증해야 하는 케이스에서는 서버 ↔ DB의 왕복 비용이 늘어날 수 있습니다.

  1. 실패 케이스를 도메인 관점에서 해석

  • Update 실패를 단순 SQL 실패로 보는 것이 아닌, 어떤 조건이 안 맞아서 실패하는 요청인지 비즈니스 규칙 기준으로 해석하여 이에 맞는 에러 코드와 메시지를 명확히 정의해야 합니다.

  1. JPA를 사용하며 객체지향 설계를 지키기 어려움

  • 비즈니스 규칙을 객체 내부가 아닌 조건부 업데이트의 WHERE 절에 의존하기 시작하면, “객체에게 시킨다.” 기보다 “데이터를 꺼내 와서 쿼리로 처리하는” 방향으로 설계되기 쉽습니다.

  • 이 경우 절차적인 코드가 늘어나고, 객체지향 원칙을 코드에서 일관되게 적용하기가 어려워집니다.

  • 단순히 “읽고, 값을 변경하고, 변경된 값을 저장하는” 코드에 비해 조건부 업데이트 쿼리를 수행하는 코드는 한눈에 의도를 파악하기 어렵습니다.

적용 전/후 비교 (요약)

이번 문제에서는 Redis, 낙관적 락, 비관적 락 등 도입 기술들의 복잡도와 운영 부담을 함께 고려했습니다.

비관적 락은 정합성을 쉽게 보장할 수 있었지만, 긴 트랜잭션과 쓰기 잠금 점유로 인해 데드락 가능성과 성능 저하를 구조적으로 병행하고 있었습니다. 반면 조건부 업데이트는 트랜잭션을 짧게 유지하면서도 Lost Update와 Check-Then-Act 경쟁 문제를 구조적으로 차단할 수 있는 방법이었습니다.

물론 이 방식은 비즈니스 규칙의 일부를 엔티티 내부가 아닌 SQL의 WHERE로 이동시키며, 객체가 상태 전이에 대한 책임을 온전히 가지기 어렵다는 설계적 단점을 동반합니다. 하지만 이번 문제에서는 기존 도메인 모델과 API 구조를 크게 변경하지 않으면서도, 문제를 동시에 해결할 수 있다는 점에서 조건부 업데이트 + 부분적인 FK 제거는 비용 대비 가장 효과적인 선택이었다고 판단했습니다.

항목
적용 전
적용 후

데드락

간헐적으로 발생 (SQL 1213)

지난 1개월간 미발생

정합성 문제

Lost Update, Check-Then-Act 존재

구조적으로 차단

일기 저장 p95

(기록 시점 기준)

약 291 ms

재촉하기 p95

(기록 시점 기준)

약 83.5 ms

FK

존재, 데드락 원인 중 하나

제거, 락 순서 단순화

설계 복잡도

단순 RMW 중심

WHERE 조건/실패 케이스 설계 필요

Last updated