📘 SyncMon

1. 문제는 “느린 쿼리”가 아니라 “서로 다른 수정 경로”였다

운영 시스템에서 데드락은 종종 오해를 부릅니다.

대부분 처음에는 특정 쿼리가 느리거나, 트래픽이 순간적으로 몰렸거나, DB가 일시적으로 불안정해서 발생한 문제처럼 보입니다. 그런데 실제로는 그 반대인 경우가 많습니다.

이번 SyncMon 이슈도 그랬습니다.

겉으로 드러난 현상은 단순했습니다.

  • Link Y/N 설정 시 간헐적인 deadlock 발생
  • ALL Intensive, ALL Apply 같은 일괄 변경 기능 실행 시 deadlock 발생
  • 일부 화면에서 수정 작업이 겹치면 실패 후 재시도에서만 정상 처리됨

겉으로만 보면 “특정 화면에서 UPDATE가 충돌하네” 정도로 보일 수 있습니다.

하지만 실제 원인은 개별 쿼리 하나가 아니라, 같은 데이터 집합을 여러 기능이 서로 다른 순서와 범위로 수정하고 있었다는 점이었습니다.

운영 시스템에서 데드락은 보통 쿼리 자체가 복잡해서 생기기보다,

수정 경로가 늘어나면서 잠금 순서가 일관되지 않게 되는 순간부터 본격적으로 드러납니다.

이번 문제도 정확히 그 유형에 가까웠습니다.


2. 왜 이런 기능에서 데드락이 잘 발생했는가

데드락이 자주 발생하던 지점은 크게 두 부류였습니다.

하나는 설정 계열 기능이었습니다.

예를 들어 Link Y/N, Intensive Y/N, Apply Y/N처럼 특정 속성을 한 번에 바꾸는 작업입니다.

이런 기능은 UI에서는 단순 토글처럼 보입니다.

하지만 DB 입장에서는 전혀 단순하지 않습니다. 개별 행 1건이 아니라 여러 프로세스 row를 한 번에 갱신하는 bulk update이고, 종종 후속 검증 쿼리까지 같은 트랜잭션 안에서 이어집니다.

다른 하나는 배치/수집/초기화 계열 기능이었습니다.

실시간 모니터링 시스템 특성상 상태값 갱신, 성공 처리, 누락 데이터 정리, 초기화성 삭제 작업이 계속 돌아가고 있었습니다.

즉, 온라인 화면에서 설정을 바꾸는 경로와, 백그라운드에서 상태를 갱신하거나 정리하는 경로가 같은 테이블 혹은 연관된 row 집합을 서로 다른 순서로 건드리고 있었습니다.

개별 기능만 보면 모두 정상입니다.

하지만 이들이 동시에 실행되면 얘기가 달라집니다.

  • 화면 A는 Process -> Relation Map -> Job Cycle 순서로 잠금을 잡고
  • 화면 B는 Relation Map -> Process 순서로 잠금을 잡고
  • 배치 C는 Transaction Manage -> Process 또는 Error 관련 row를 먼저 건드리는 식이면

각자 입장에서는 짧은 수정이더라도, 운영에서는 아주 쉽게 lock cycle이 만들어집니다.

즉, 데드락의 본질은 “업데이트가 많다”가 아니라

잠금을 잡는 순서가 기능마다 제각각이었다는 데 있었습니다.


3. 데드락은 왜 운영 환경에서 더 잘 드러났는가

개발 환경에서는 잘 안 보이던 문제가 운영에서만 드러나는 이유도 명확했습니다.

첫째, 대상 row 수가 많았습니다.

ALL Intensive, ALL Apply 같은 기능은 이름 그대로 여러 프로세스를 한 번에 수정합니다. 단건 토글이 아니라 동일 성격의 UPDATE를 넓은 범위에 수행하는 작업이므로 잠금 범위 자체가 넓어집니다.

둘째, 동시 실행 경로가 많았습니다.

실시간 수집, 상태 정리, 에러 처리, 사용자 설정 변경, 초기화 로직이 서로 다른 타이밍에 실행되다 보니, 특정 테이블은 항상 “누군가가 만지고 있는 상태”가 되기 쉬웠습니다.

셋째, 일부 조회 조건은 잠금을 더 오래 잡게 만들 가능성이 있었습니다.

이런 형태는 조회 대상이 커질수록 인덱스 활용성이 떨어질 가능성이 있고, 결국 읽기와 쓰기 모두에서 체류 시간을 늘릴 수 있습니다.

데드락의 직접 원인이 항상 이런 조건식은 아니더라도, 잠금을 오래 쥐게 만드는 환경을 만드는 데는 충분히 기여합니다.

즉, 운영에서 보인 데드락은 단순히 “사용자가 많아서”가 아니라,

수정 범위가 넓고, 수정 경로가 많고, 일부 경로는 잠금 보유 시간이 길어질 여지도 있었기 때문에 발생한 것이었습니다.


4. 이번에 본질적으로 손댄 것은 세 가지였다

이 문제를 해결하면서 가장 먼저 버린 생각은 “문제된 쿼리 하나만 빠르게 만들면 된다”는 접근이었습니다.

데드락은 성능 이슈와 닮아 보여도, 실은 트랜잭션 설계 이슈에 더 가깝습니다.

정리하면 개선 방향은 세 가지였습니다.

4.1 잠금 순서를 기능마다 다르게 가져가지 않도록 정리했다

가장 중요한 건 이 부분입니다.

같은 테이블과 같은 row 집합을 건드리는 작업이라면, 기능이 달라도 항상 같은 순서로 잠금을 잡아야 합니다.

예를 들어 Process 관련 설정을 바꾸는 경로라면

  • 대상 process 목록 확정
  • process master 갱신
  • relation/map 또는 부가 설정 갱신
  • 후속 상태/이력 정리

처럼 흐름을 정하고, 어떤 API든 이 순서를 크게 벗어나지 않게 정리해야 합니다.

운영에서 데드락을 줄이는 가장 강한 방법은 락을 없애는 게 아니라,

락을 예측 가능한 순서로 잡게 만드는 것입니다.

이 원칙을 무시하면, 개별 UPDATE는 멀쩡해 보여도 기능이 늘어날수록 데드락은 다시 돌아옵니다.

4.2 트랜잭션 안에서 오래 머무르는 작업을 줄였다

설정 변경 계열 기능은 자칫하면 “수정 -> 검증 -> 후처리”가 한 트랜잭션 안에서 길게 이어지기 쉽습니다.

예를 들어 intensiveAllY()는 업데이트 이후 다시 개수를 세는 검증이 들어갑니다.

이런 로직은 기능적으로는 자연스럽지만, 동시성 관점에서는 주의가 필요합니다.

수정 후 검증이 같은 트랜잭션 안에 묶이면, 수정 대상 row에 대한 잠금을 쥔 상태로 추가 쿼리까지 수행하게 되기 때문입니다.

그래서 데드락을 줄이려면 단순히 @Transactional을 붙이거나 빼는 문제가 아니라,

  • 어디까지를 한 트랜잭션으로 볼 것인지
  • 검증은 사전 검증으로 뺄 수 있는지
  • 사후 검증이 필요하다면 잠금 범위를 최소화할 수 있는지
  • bulk update 이후 불필요한 후속 조회가 붙지 않는지

를 같이 봐야 합니다.

핵심은 “트랜잭션이 있어야 안전하다”가 아니라,

트랜잭션이 너무 많은 row를 너무 오래 잡고 있지 않게 만드는 것입니다.

4.3 온라인 경로와 배치/정리 경로가 같은 시점에 충돌하지 않게 정리했다

운영 시스템에서는 온라인 요청보다 백그라운드 작업이 더 위험할 때가 많습니다.

사용자는 단건 수정이라고 생각하지만, 뒤에서는 정리 job이나 초기화 job이 훨씬 넓은 범위를 만지고 있기 때문입니다.

예를 들면 아래 같은 초기화성 delete는 온라인 수정과 같은 시간대에 섞이면 잠금 증폭을 만들기 쉽습니다.

이런 작업은 기능 자체가 잘못된 것이 아니라,

언제 돌고 무엇과 겹치느냐가 중요합니다.

그래서 이번 정리에서는 온라인 설정 변경과 정리/초기화/상태 보정 계열 작업이 가능한 한 같은 잠금 구간에서 맞부딪히지 않도록 경계를 명확하게 잡는 쪽이 중요했습니다.

데드락은 보통 SQL 한 줄이 아니라,

서로 다른 종류의 작업이 같은 row 집합을 동시에 오래 붙잡을 때 터집니다.


5. 이 문제에서 배운 것은 “벌크 업데이트는 UI보다 훨씬 무겁다”는 점이다

ALL Intensive, ALL Apply, Link Y/N 같은 기능은 화면에서는 단순한 설정처럼 보입니다.

하지만 DB에서는 전혀 그렇지 않습니다.

이런 기능은 보통 다음 네 가지를 동시에 가집니다.

  • 수정 대상 row 수가 많다
  • 동시에 여러 사용자가 실행할 수 있다
  • 배치/상태 갱신 job과 충돌할 수 있다
  • 후속 검증이나 상태 갱신이 붙기 쉽다

즉, UI는 토글이지만 DB에는 작은 배치 작업에 가깝습니다.

운영 시스템에서 bulk update를 다룰 때 가장 위험한 실수는,

이걸 단건 수정과 같은 감각으로 취급하는 것입니다.

실제로는 훨씬 더 조심해야 합니다.

  • 대상 row를 어떤 순서로 잠글 것인지
  • 가능한 한 같은 순서로만 갱신하게 만들 수 있는지
  • 한 번에 수정할 범위를 줄일 수 있는지
  • 후속 검증을 같은 트랜잭션에 꼭 묶어야 하는지
  • 다른 배치와 시간대 충돌이 없는지

이걸 먼저 봐야 합니다.

이번 이슈는 그걸 다시 상기시켜 준 사례였습니다.

데드락은 DB가 약해서가 아니라, 애플리케이션이 잠금 순서를 설계하지 않은 대가인 경우가 많습니다.


6. 성능 문제가 아니라 운영 모델의 문제로 봐야 풀린다

데드락이 반복될 때 흔히 하는 대응은 두 가지입니다.

하나는 인덱스를 추가하는 것이고,

다른 하나는 재시도 로직을 넣는 것입니다.

둘 다 필요할 수는 있습니다.

하지만 이 문제의 핵심은 아니었습니다.

인덱스는 잠금 대기 시간을 줄이는 데 도움을 줄 수 있고, 재시도는 장애 표면을 완화할 수 있습니다.

그런데 잠금을 잡는 순서 자체가 뒤죽박죽이면, 인덱스를 잘 태워도 데드락은 다시 납니다.

재시도 역시 증상을 늦출 뿐, 구조를 해결하지는 못합니다.

이번에 중요했던 건 개별 SQL이 아니라 수정 경로 전체를 운영 모델 관점에서 다시 보는 것이었습니다.

  • 어떤 기능이 어떤 테이블을 만지는지
  • 그 순서가 기능마다 일관적인지
  • 같은 데이터 집합을 온라인/배치/초기화 경로가 동시에 수정하고 있지는 않은지
  • 읽기 쿼리조차 체류 시간을 키우고 있지는 않은지

이걸 정리한 뒤에야 데드락이 “희한하게 가끔 나는 문제”가 아니라,

충분히 재현 가능하고 설명 가능한 구조적 문제로 보이기 시작했습니다.

문제를 설명할 수 있어야 해결도 됩니다.


7. 결과적으로 무엇이 달라졌는가

이번 작업의 성과는 단순히 deadlock exception 개수가 줄었다는 데 있지 않았습니다.

더 큰 변화는 운영자가 시스템을 보는 방식이 바뀌었다는 점입니다.

예전에는 deadlock이 나면 “운이 나빴다”, “조금 뒤에 다시 하면 된다” 수준으로 넘어가기 쉬웠습니다.

그런데 이건 운영 시스템에서 가장 위험한 태도입니다. 일단 재시도로 넘어가기 시작하면, 언젠가는 더 큰 범위의 정합성 문제나 배치 지연으로 번집니다.

이번 정리 이후에는 최소한 아래는 분명해졌습니다.

  • 어떤 기능이 잠금 충돌을 만들기 쉬운지
  • bulk update가 왜 단건 수정보다 훨씬 위험한지
  • 온라인 수정과 배치/정리 작업을 왜 같은 감각으로 두면 안 되는지
  • deadlock은 SQL 문법 문제가 아니라 트랜잭션 경로 설계 문제라는 점

즉, 예외를 지운 것이 아니라

운영 중 동시성 문제를 바라보는 기준을 바꾼 것에 더 가까웠습니다.


8. 정리

이번 데드락 이슈는 특정 쿼리 하나의 실패가 아니었습니다.

Link Y/N, ALL Intensive, ALL Apply 같은 설정 기능과, 상태 갱신/정리/초기화 계열 작업이 같은 데이터 집합을 서로 다른 순서로 건드리면서 만들어진 전형적인 운영형 동시성 문제였습니다.

해결도 마찬가지였습니다.

쿼리 한두 줄을 바꾸는 것으로는 부족했고, 결국 다음을 같이 정리해야 했습니다.

  • 잠금 순서를 기능마다 일관되게 맞추고
  • 트랜잭션 안에서 오래 머무는 작업을 줄이고
  • 온라인 경로와 배치/정리 경로의 충돌을 줄이고
  • 읽기 쿼리까지 포함해 체류 시간을 키우는 요소를 같이 점검하는 것

운영 시스템에서 데드락은 대개 우연처럼 보입니다.

하지만 대부분 우연이 아닙니다. 수정 경로가 늘어나도 잠금 순서를 설계하지 않았을 때, 결국 한 번은 반드시 드러나는 문제에 가깝습니다.

이번 작업은 deadlock을 “예외 처리할 문제”로 보지 않고,

트랜잭션과 수정 경로를 다시 설계해야 할 신호로 받아들였기 때문에 풀 수 있었던 사례였습니다.