
안전해 보였지만 아무것도 지키지 못했던 잠금(Lock)
요약
AI DM 봇 운영 중 발생하는 병렬 실행 및 컨텍스트 침범 버그와 그 해결 과정을 다룹니다. 데이터베이스 어드바이저리 락과 디바운스 기법의 한계를 분석하며, 동시성 제어의 중요성을 설명합니다.
핵심 포인트
- 빠른 메시지 입력 시 발생하는 병렬 실행 및 컨텍스트 오염 문제
- 트랜잭션 범위 제한으로 인한 데이터베이스 어드바이저리 락의 한계
- 디바운스(Debounce) 기법이 LLM 생성 시간 동안의 동시성을 막지 못하는 이유
- 상태 저장 방식의 오류로 발생하는 '손실 업데이트(lost update)' 문제
우리의 AI DM 봇에는 사용자가 매우 빠를 때만 나타나는 버그가 있었습니다. 메시지를 하나 보내고, 잠시 기다렸다가 다른 메시지를 보내면, 가끔 동일한 확인 메시지가 토씨 하나 틀리지 않고 세 번 또는 네 번씩 돌아오곤 했습니다. 느리게 타이핑하는 사람들은 이를 결코 보지 못했습니다. 빠른 사람들은 거의 항상 경험했습니다. 연속해서 빠르게 메시지를 보내는 경우는 전체적으로 드물었기 때문에, 누군가 이 점들을 연결하기 전까지 운영 환경(production)에서 한동안 조용히 발생하고 있었습니다.
버그
전형적인 트리거는 다음과 같습니다: 사용자가 전화번호를 보내고, 몇 초 후 봇이 첫 번째 메시지에 답장하기도 전에 이메일 주소를 보내는 경우입니다.
각 인바운드 메시지는 AI 실행을 시작(또는 재개)하는 함수를 다시 트리거했습니다. 만약 해당 대화에 대해 이미 실행 중인 프로세스가 있다면, 두 번째 메시지는 이를 기다리지 않았습니다. 두 번째 메시지는 두 번째 병렬 실행을 시작하거나, 첫 번째 실행의 입력을 새로운 메시지로 덮어씌웠습니다. 따라서 두 개의 메시지가 연달아 들어오면 대개 두 실행이 서로의 컨텍스트(context)를 침범하는 상황이 발생했습니다.
이를 막을 수 있는 유일한 수단은 단 한 번의 호출로 획득하는 데이터베이스 어드바이저리 락(database advisory lock)이었습니다. 문제는 이 종류의 락은 이를 획득하는 트랜잭션(transaction)에 범위가 제한되며, 호출 자체가 밀리초(milliseconds) 단위로 완료(및 락 해제)된다는 점이었습니다. 실제 작업(대화 로드, LLM 호출, 답장 전송)이 시작되기도 훨씬 전에 실행이 끝나버렸습니다. 따라서 코드상으로는 상호 배제(mutual exclusion)가 이루어지는 것처럼 보였지만, 실제로 중요한 시점이 되었을 때는 이미 락이 사라진 상태였습니다. 그것은 아무것도 지키지 못했습니다.
결과: 동일한 실패의 두 가지 형태가 나타났습니다. 때로는 두 실행 모두 정확히 동일한 답장을 생성하여 바이트 단위로 일치하는 중복 답장을 두 번 모두 보냈습니다. 때로는 반쯤 직렬화(semi-serialized)되기는 했지만, 각각 동일한 질문에 대해 자신만의 답을 생성했기 때문에 봇이 같은 내용을 약간씩 다른 단어로 두세 번 반복해서 대답했습니다.
왜 "디바운스(debounce)를 추가하는 것"만으로는 완전히 해결되지 않는가
명백한 해결책은 다음과 같습니다: 추가 메시지가 올 경우를 대비해 마지막 메시지 이후 몇 초간 기다렸다가 작업을 수행하는 것입니다.
그것이 도움이 되지만, 생성(generation)이 시작되기 _전_에 도착하는 메시지에만 해당됩니다. 실제 생성 과정 자체(LLM 호출, 도구 실행, 답변 전송)는 몇 초가 걸리며, 때로는 10초에 가까울 수 있습니다. 디바운스(debounce)는 이 시간이 흐른 후에는 아무런 효과를 발휘하지 못합니다. 만약 생성 중간에 세 번째 메시지가 도착한다면, 원래의 문제로 돌아간 것과 같습니다. 다만 몇 초만 지연되었을 뿐입니다. 이때는 두 가지 중 하나가 발생합니다. 첫째, 병렬로 두 번째 실행이 시작되어 (또다시 중복 답변) 합니다. 둘째, 재시도할 깔끔한 방법 없이 차단되는데, 이것이 대화 흐름을 막히게 만듭니다. 봇은 처음 두 메시지에는 답하지만, 사용자가 다른 말을 할 때까지 세 번째 메시지를 인지하지 못합니다.
또한 디바운스 타이머를 저장하는 위치에도 더 미묘한 함정이 있습니다. 저희의 첫 시도는 이 타이머를 대화 상태(conversation state) 덩어리 안에 저장했는데, 이 상태는 매 실행이 끝날 때마다 완전히 재작성됩니다. 한 번의 실행에 몇 초가 걸리기 때문에, 그 상태 덩어리가 재작성될 무렵에는 타이머 자체의 기록 관리(bookkeeping)까지 망가지는 '전형적인 손실 업데이트(lost update)' 문제가 발생했습니다. 실제 증상은 거의 우스꽝스러울 정도였습니다. 봇이
1. 버전 카운터(version counter)와 "답변 완료(answered up to)" 워터마크.
모든 실제 인바운드 메시지는 원자적(atomically)으로 카운터를 증가시킵니다. 이와 별개로, 두 번째 숫자는 실제로 답변이 전송된 마지막 카운터 값을 기록합니다. "지금 답변되지 않은 것이 있는가?"라는 질문은 타임스탬프에 대한 모호한 질문이 아니라, 단순한 정수 뺄셈인 '카운터 빼기 워터마크'가 됩니다. 결과가 0이면 모든 것에 답변이 완료된 것이고, 0이 아니라면 아직 답변해야 할 것이 남아 있는 것입니다. 이는 "이 메시지가 언제 도착했는가"와 "저 메시지에 언제 답변했는가"를 비교하려 할 때 발생할 수 있는 거의 모든 레이스 컨디션(race condition)을 우회합니다.
2. 별도의 전용 공간에 저장되는 디바운스 윈도우(debounce window) (매 실행마다 다시 쓰여지는 상태 블롭(state blob) 내부가 아닌 곳에 저장하며, 위 내용을 참조하세요). 모든 새로운 메시지는 윈도우를 몇 초간 연장하지만, 윈도우가 처음 열린 시점으로부터 설정된 하드 캡(hard cap)을 넘어서지는 않습니다. 따라서 끊임없이 타이핑을 하는 사용자가 답변을 영원히 지연시킬 수는 없습니다. 윈도우가 열려 있는 동안 시스템은 윈도우를 연장하는 작업 외에는 아무것도 하지 않고 즉시 반환합니다. 이 단계에서는 명시적으로 생성을 시작하지 않습니다. 이 빠르고 아무것도 하지 않는 반환(return)이 매우 중요한 역할을 한다는 것이 밝혀졌습니다. 이는 다른 어떤 일이 일어나기 전에 실제 버스트(burst)의 나머지 부분이 실제로 기록될 수 있게 해줍니다.
3. 만료 시간이 있는 세션 범위의 리스(session-scoped lease). 이것이 실제로 고장 난 권고 잠금(advisory lock)을 대체하는 요소입니다. 일회성 RPC 대신, 이는 비교 및 교체(compare-and-swap) 업데이트 방식입니다: "현재 아무도 리스를 보유하고 있지 않거나(또는 그들의 리스가 만료되었다면) 이 리스를 내 ID로 설정하라." 이 업데이트에서 승리한 사람만이 답변을 생성할 수 있는 유일한 권한을 가지며, 패배한 사람은 LLM을 건드리지 않고 즉시 종료합니다. 만료 시간이 있기 때문에, 실행 도중 충돌(crash)이 발생하더라도 대화가 멈춘 상태로 남지 않습니다. 리스는 타임아웃 후에 스스로 해제되며 다른 사람이 이를 가져갈 수 있습니다.
버스트가 실제로 흐르는 방식
- 메시지가 도착하면 카운터(counter)를 올리고, 디바운스 윈도우(debounce window)를 열거나 확장하며, 아직 생성을 시작하지 않은 상태로 즉시 반환합니다.
- 윈도우가 마침내 닫히면, 리스(lease)를 획득하려고 시도합니다.
- 획득에 실패했나요? 다른 무언가가 이미 이 대화를 처리하고 있습니다. 물러나서 그것이 완료되도록 둡니다.
- 획득에 성공했나요? 카운터와 워터마크(watermark)를 비교합니다. 응답되지 않은 것이 없나요? 리스를 해제하고 중단하며, 답장을 보낼 필요가 없습니다.
- 응답되지 않은 것이 있나요? 마지막 응답 이후의 모든 메시지를 모아서 하나의 프롬프트("여기에 아직 처리하지 않은 모든 내용이 있습니다")로 통합하고, 이를 모두 아우르는 단일 응답을 생성합니다.
- 전송 직전, 마지막 확인을 수행합니다: 이것이 생성되는 동안 새로운 것이 도착했나요? 만약 그렇다면, 전송을 완전히 건너뛰고 워터마크를 진행시키지 마세요. 다음 사이클이 이미 오래된(stale) 응답을 보내는 대신, 이번 실행의 기여분을 포함한 모든 것을 한 번의 깔끔한 패스로 처리할 것입니다.
- 어떤 경우든, 리스를 해제합니다.
짚고 넘어갈 만한 예외 케이스 (Edge cases)
- 생성 도중 실행(run)이 충돌하여 리스를 보유하고 있는 경우. 리스의 만료(expiry)가 자동으로 이를 회수하므로, 아무도 이를 인지하거나 수동으로 개입할 필요가 없습니다.
- 타임아웃된 실행으로부터 오는 느리고 늦은 정리(cleanup) 호출. 리스는 실제로 여전히 소유하고 있는 리스만 해제할 수 있으므로, 타임아웃 이후 상황을 이어받은 누군가로부터 실수로 리스를 가로챌 수 없습니다.
- 메시지가 디바운스 윈도우의 경계에 딱 맞춰 도착하는 경우. 윈도우의 상한(cap)은 처음 열린 시점에 고정되며, 윈도우를 확장하는 것과 동일한 원자적 단계(atomic step)에서 계산됩니다. 따라서 늦게 도착한 메시지가 윈도우를 확장할 수는 있지만, 하드 캡(hard cap) 자체를 더 뒤로 밀어낼 수는 없습니다.
- 모든 것에 이미 응답한 후 오래된 깨우기(wake-up)가 발생하는 경우. 카운터와 워터마크의 비교 체크가 이를 포착하며, 불필요한 응답 대신 조용히 아무 작업도 수행하지 않는 상태(no-op)로 처리됩니다.
- 생성 도중에 더 새로운 메시지가 도착하는 경우. 위에서 다루었습니다. 전송 전 확인 단계에서 이를 포착하여, 이미 구식이 된 내용을 보내는 대신 다음 사이클로 미룹니다.
이와 유사하게 "중요한 시점에 잠금(Lock)이 이미 해제되어 버리는" 버그를 겪어본 적이 있으신가요? 여러분이 이를 어떻게 찾아냈는지 진심으로 듣고 싶습니다. 이러한 버그는 코드가 마치 올바르게 작성된 것처럼 읽히기 때문에, 배포하기 쉽고 코드 리뷰(Code Review) 과정에서 놓치기도 매우 쉽습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기