두 번째 질문은 사라진 게 아니라 첫 번째 답에 섞였다
텔레그램으로 질문을 하나 보냈다. 바로 앞 질문이 아직 처리 중이어서 두 번째 질문은 대기할 줄 알았다. 몇 분 뒤 답이 왔지만 내용이 묘했다. 첫 질문과 두 번째 질문을 한꺼번에 답하고 있었다. 브릿지는 그 사실을 몰랐다. 두 번째 질문의 전용 답이 아직 안 왔다고 믿고 계속 기다렸다. 그리고 재시작 뒤, 전혀 다른 작업의 답을 두 번째 질문에 붙여버렸다.
처음 증상만 보면 흔한 메시지 유실처럼 보였다. 질문을 보냈는데 답이 늦었다. 화면에는 계속 입력 중 표시가 떴다. 브릿지 프로세스도 살아 있었다. 큐에도 메시지가 있었다. 다만 새 질문이 AI 화면에 들어가지 않는 것 같았다.
로그를 세 군데 맞춰보니 결론은 반대였다.
질문은 사라지지 않았다. 너무 일찍 들어갔다. 그래서 앞 질문과 같은 턴에 합쳐졌다.
5초가 만든 하나의 턴
사건은 밤 11시 33분 무렵이었다. 한 작업이 이미 시작된 지 5초 뒤에 새 질문이 도착했다. 브릿지는 AI가 바쁜 동안에도 다음 입력을 받을 수 있도록 새 문장을 입력창에 넣었다. 여기까지는 의도한 기능이었다. 긴 작업 중 다음 지시를 미리 적어둘 수 있게 만든 것이다.
문제는 AI 터미널이 그 문장을 “다음 턴 예약”이 아니라 “현재 턴의 추가 사용자 메시지”로 받아들였다는 점이었다.
대화 기록에는 첫 질문, 두 번째 질문, 그리고 두 질문을 모두 다룬 하나의 최종답변이 순서대로 남았다. AI는 두 번째 질문을 놓치지 않았다. 오히려 충실하게 함께 답했다.
사람 눈에는 답이 끝난 상태였다. 하지만 브릿지 내부 장부에는 다른 이야기가 적혔다. 두 번째 질문은 별도 식별자를 가지고 있었고, 브릿지는 그 질문이 독립된 턴으로 시작됐다고 승격했다. 이제 브릿지는 “이 식별자에 대응하는 새 최종답변”을 기다리기 시작했다.
그런 답은 영원히 올 수 없었다. 이미 앞 턴의 답 속에 포함돼 끝났기 때문이다.
시스템은 답이 없다고 생각했고, 사용자는 답이 섞여 왔다고 보았다. 같은 기록을 두고 사람과 브릿지가 서로 다른 완료 상태를 믿었다.
메시지 하나가 아니라 상태 다섯 개
예전에는 메시지를 단순하게 봤다.
보냈다. 받았다.
하지만 실제 브릿지 안에서는 최소한 다음 상태가 있었다.
- 텔레그램에서 수신했다.
- 로컬 큐에 넣었다.
- AI 입력창에 주입했다.
- 대화 기록에서 사용자 메시지로 확인했다.
- 그 사용자 메시지 뒤의 최종답변을 정확히 연결해 발송했다.
각 단계는 다음 단계를 보장하지 않는다. 큐에 들어갔다고 주입된 게 아니다. 입력창에 넣었다고 독립된 새 턴이 열린 게 아니다. 대화 기록에 보인다고 전용 답이 따로 생기는 것도 아니다. 최종답변이 하나 나왔다고 그 답이 지금 기다리는 질문의 답인 것도 아니다.
이번 사고는 3단계와 4단계 사이의 의미를 잘못 해석한 결과였다. 브릿지는 “주입됐다”를 “새 턴이 시작됐다”로 읽었다. 하지만 실제로는 기존 턴에 병합됐다.
분산 시스템 이야기를 읽으면 상태 기계라는 말이 자주 나온다. 나는 그 말을 거창한 서버 설계에서나 쓰는 줄 알았다. 텔레그램과 터미널 하나를 연결하는 작은 브릿지도 똑같았다. 한 줄의 텍스트가 어느 상태에 있는지 정확히 구분하지 않으면, 완료와 대기가 동시에 참이 된다.
답변 오귀속은 유실보다 위험했다
고착된 브릿지는 몇 분 뒤 워치독에 의해 재시작됐다. 새 프로세스가 떠서 큐와 대화 기록을 다시 읽었다. 여기서 두 번째 문제가 생겼다.
조금 뒤 다른 노드에서 무관한 작업 보고가 들어왔고, 그 보고가 새 AI 턴을 만들었다. 최종답변도 정상적으로 나왔다. 그런데 재시작된 브릿지는 과거에 남아 있던 두 번째 질문의 대기 식별자와 지금 막 나온 무관한 답변을 연결했다.
사용자는 자신이 물은 것과 관계없는 답을 받았다.
이건 단순 지연보다 심각하다. 답이 늦으면 사람은 다시 물을 수 있다. 답이 아예 없으면 장애를 의심한다. 하지만 그럴듯한 다른 답이 정확한 질문에 대한 답처럼 붙으면 사람은 내용을 믿고 다음 행동을 할 수 있다.
메시지 유실은 빈칸을 만든다. 오귀속은 거짓 연결을 만든다.
원인은 답변 쪽만 보고 연결했기 때문이다. “새 최종답변이 나왔다”는 사실은 확인했지만, 그 답변을 시작한 사용자 메시지가 기다리던 질문과 같은지는 확인하지 않았다. 마치 택배 상자가 하나 도착했다는 이유만으로, 가장 오래 기다린 주문서에 그 상자를 붙인 셈이다.
주문 번호가 맞아야 한다. 질문과 답도 마찬가지다.
시간순은 상관관계가 아니다
가장 오래 기다린 질문에 다음 답을 붙이는 방식은 요청이 하나씩 처리될 때는 맞는다. 하지만 재시작, 재시도, 병렬 작업, 병합 턴이 끼면 시간순은 무너진다. 뒤에 온 질문이 앞 턴에 섞이고, 과거 대기 상태와 현재 출력이 함께 남을 수 있다.
그때 필요한 건 “다음 것”이 아니라 “같은 것”이라는 증거다.
이번 수리에서는 대화 기록의 사용자 메시지 식별자와 그 뒤에 나온 assistant 최종답변의 관계를 확인하도록 했다. 기다리던 질문의 사용자 식별자가 실제 턴을 시작한 식별자와 일치할 때만 전용 답으로 연결한다. 일치하지 않으면 다른 흐름의 답으로 처리하고, 오래된 대기 상태는 해제한다.
두 번째 질문이 이미 완료된 앞 턴에 들어가 있고 그 뒤에 최종답변까지 존재한다면 별도 답을 기다리지 않고 완료로 닫는다. “독립 턴 하나가 더 생겨야 완료”라는 가정을 버린 것이다.
재현은 두 문장으로 시작했다
이 결함을 막는 회귀 테스트는 복잡한 부하 테스트가 아니었다. 핵심은 두 문장이었다.
첫 질문이 처리 중일 때 두 번째 질문을 보낸다. AI 기록에는 두 사용자 메시지와 하나의 최종답변이 남는다. 브릿지는 두 번째 질문을 별도 미완료 턴으로 붙들면 안 된다.
두 번째 테스트는 재시작 뒤 무관한 턴을 만든다. 과거 대기 식별자가 남아 있어도 새 답을 옛 질문에 붙이면 안 된다.
운영 사고를 테스트로 옮길 때 중요한 건 로그 전체가 아니라 잘못된 가정을 가장 짧게 재현하는 순서다. 이번에는 “연속 두 메시지”와 “재시작 뒤 무관한 답”이었다. 완료를 시간으로 추정하지 않고, 사용자 메시지와 답변의 관계를 확인하며, 병합을 가능한 상태로 인정하는 규칙이 테스트에 고정됐다.
작은 브릿지에서 배운 큰 원리
작업 큐, 결제 요청, 이메일 발송에도 같은 일이 생긴다. 요청 A가 끝나기 전에 B가 들어오고, 한 실행이 둘을 함께 처리하고, 재시작 뒤 C의 결과가 나온다. 시간순으로만 붙이면 B와 C가 섞인다.
식별자를 실어 보내는 것만으로는 부족하다. 결과를 받을 때 그 결과가 어떤 입력에서 시작됐는지 검증해야 한다. 또 질문 두 개가 답 하나로 처리될 수 있듯, 시스템이 예상한 결과 개수와 실제 완료 모양이 다를 수 있음을 인정해야 한다.
배운 것
두 번째 질문은 유실되지 않았다. 첫 번째 답 안에 이미 들어가 있었다. 브릿지는 자신이 예상한 “별도 턴”이 생기지 않았다는 이유로 완료를 인정하지 못했다.
그 뒤의 오귀속은 더 단순한 교훈을 남겼다. 다음에 나온 답이라고 내 질문의 답은 아니다. 시간순은 편리한 추정일 뿐, 관계의 증거가 아니다.
메시지 시스템에서 완료는 “답이 나왔다”가 아니다. 어느 질문에서 시작된 답인지 확인되고, 그 질문의 대기 상태가 정확히 닫히고, 다른 질문의 결과와 섞이지 않는 데까지다.
사람은 문맥으로 두 질문이 한 답에 묶였다는 걸 금방 알아본다. 시스템은 그 문맥을 식별자와 상태 전이로 배워야 한다. 그렇지 않으면 답이 와도 계속 기다리고, 기다리다 재시작한 뒤에는 남의 답을 내 답이라고 배달한다.
다음 답이 아니라, 같은 질문에서 시작된 답을 찾아야 한다.
— 강대종 / @ssamssae