1. 증상: 보냈다는 보고 뒤에 아무 일도 없었다
팀에 일을 나눠 주던 지휘 담당자가 지시를 보냈다. 성공했다는 답이 돌아왔고, 운영 책임자에게도 일을 맡겼다고 보고했다. 그런데 40분 동안 작업은 시작되지 않았다. 일을 받을 사람은 계속 구체적인 지시를 기다리고 있었다. 잘못 보낸 사람은 지휘 담당자였다.
각 참가자는 자기 작업 화면을 하나씩 갖는다. 이 시스템에서는 그 화면을 ‘좌석’이라고 부른다. 화면마다 붙은 번호가 surface다. 지시를 화면에 넣는 데 쓴 도구는 cys다. 여러 에이전트의 작업 화면을 관리하고 메시지를 보내는 내부 도구다. 도구가 돌려준 OK는 성공했다는 응답이었다.
집필 담당자의 상태 기록은 그동안 정직했다. 아래 기록은 일을 진행 중이라는 뜻이 아니라, 구체적인 지시를 기다린다는 뜻이다.
state=waiting
task=구체적인 작업 지시 대기
지휘 담당자는 같은 방식으로 감시 담당자에게도 지시를 보냈다. 그러다 출력이 달라졌다. 사람이 입력 중이어서 메시지를 잠시 보관한다는 알림이었다. 이렇게 배달할 메시지를 보관하는 대기열을 ‘큐’라고 한다. 아래 출력의 QUEUED depth 1은 메시지가 대기열에 들어갔고, 그 깊이가 1이라는 표시다.
[send] 사람 입력 감지 — 본문을 큐로 전환(QUEUED depth 1) surface=surface:18
이때까지도 지휘 담당자는 지시를 보냈다고 믿었다. 운영 책임자에게 한 보고도 “티켓 발부 완료”였다. 티켓은 여기서 작업 지시서를 뜻한다. 실제 착수와 보고 사이에 40분의 빈틈이 생겼다.
2. 오진: 상대가 바빠서 잠시 늦는다고 생각했다
처음에는 받는 사람이 다른 일을 하고 있어 전달이 잠시 늦어진다고 생각했다. 기다리면 해결될 일로 보았다. 그렇게 판단하자 어디로 보냈는지 확인하는 일도 뒤로 밀렸다. 이번 오진의 대가는 엉뚱한 조치를 한 것이 아니라, 확인하지 않고 기다린 것이었다.
판단에 아무 근거도 없었던 것은 아니다. 이 시스템에는 사람이 글을 입력할 때 다른 메시지가 화면을 덮지 않도록 대기열로 돌리는 안전장치가 실제로 있다. “사람 입력 감지”라는 문구 자체는 사실이었다. 하지만 그 문구만으로는 누구의 화면에서 입력을 감지했는지 알 수 없었다. 우리는 그것을 상대 화면의 사정으로 읽었다.
일상에 비유하면 편지를 우체통에 넣고 접수 완료 도장을 받은 셈이다. 그런데 받는 사람 주소를 쓰지 않아 편지가 내 집으로 돌아왔다. 도장은 거짓이 아니었고 접수는 실제로 됐다. 실제 사고에서도 성공 응답을 지어낸 것이 문제는 아니었다. 메시지를 넣은 곳이 우리가 의도한 상대였는지가 문제였다. 그 사실은 비유가 아니라 다음 두 확인에서 드러났다.
3. 반증: 기다리는 메시지의 받는 사람이 나였다
판단을 뒤집은 것은 기다리는 시간을 더 늘리는 일이 아니었다. 아직 배달되지 않은 메시지의 받는 사람을 직접 확인했다. 두 건 모두 상대가 아니라 발신자 자신을 향하고 있었다. 상대의 주소라고 적은 글자는 메시지 내용의 맨 앞에 들어가 있었다. 이어 사용법을 읽으니 왜 그렇게 됐는지가 설명됐다.
첫 번째 확인: 미배달 두 건의 대상과 본문을 함께 봤다
아직 배달되지 않은 항목을 열어 보면 실제 받는 사람을 확인할 수 있다.
그 목록을 보는 명령이 cys queue list다. 목록에서 두 가지가 동시에 보였다. 미배달 항목 두 건의 대상은 surface:18이었다. 이 번호는 발신자 자신의 좌석이었다. 두 항목의 본문 첫 단어는 surface:14였다. 대상으로 적었다고 생각한 문자열이 내용 안에 들어가 있었다.
대기 시간도 짧지 않았다. 각각 1109초와 940초가 지났다. 분 단위로 보면 이미 약 18분과 15분을 기다린 상태였다. ‘곧 상대에게 배달될 것’이라는 예상과 달리, 메시지는 자기 자신을 대상으로 남아 있었다. 오래 기다렸다는 사실보다 더 결정적인 반증은 대상이 틀렸다는 사실이었다.
여기서 확인 범위를 분명히 갈라야 한다. 목록으로 미배달이 증명된 것은 두 건이다. 집필 담당자가 40분 동안 지시를 기다렸다는 상태 기록도 있다. 그러나 앞선 발신 일부에는 감시 담당자가 내용을 정확히 짚어 응답했다. 그 내용이 어떤 경로로 도달했는지는 끝내 규명하지 못했다. 따라서 네 번의 발신이 전부 미도달이었다고 쓸 수는 없다. 확인된 두 건과 경로를 모르는 구간은 같은 결론으로 묶지 않는다.
두 번째 확인: 주소를 적는 자리라고 생각한 곳이 본문 자리였다
실제 받는 사람이 달랐다는 것을 확인한 뒤에는, 우리가 쓴 명령을 도구가 어떻게 읽는지 알아야 했다.
cys send --help로 사용법을 열었다. 아래는 받는 사람을 별도 옵션으로 지정해야 한다는 사용법이다. OPTIONS는 명령의 설정 항목이고, TEXT는 보낼 본문을 뜻한다.
Usage: cys.exe send [OPTIONS] <TEXT>...
Options:
--surface <SURFACE>
--to <TO> Address by role name instead of surface ref
--surface는 받는 사람의 좌석 번호를 지정하는 옵션이다. --to는 번호 대신 역할 이름으로 받는 사람을 지정한다. 이 표시 없이 명령 뒤에 놓은 글자는 전부 본문으로 읽힌다. 명령에서 놓인 순서로 의미를 정하는 값을 ‘위치인자’라고 부른다. 이 명령의 위치인자는 주소가 아니라 내용이었다.
지휘 담당자는 아래 형태로 입력했다. 이 한 줄은 주소를 지정한 실제 실수의 형태를 보여 준다. <본문>은 메시지 내용이 들어간 자리를 나타낸다.
cys send surface:14 '<본문>'
사람은 surface:14를 상대 주소로 읽었다. 도구는 사용법에 따라 그것을 본문의 첫 단어로 읽었다. 받는 사람은 별도로 지정되지 않았다. 그래서 지정이 없을 때 쓰는 기본값, 즉 자기 자신이 대상이 됐다. 대기열에서 확인한 대상과 본문이 이 해석에 정확히 맞았다.
이제 OK를 다시 읽을 수 있었다. 도구는 메시지를 화면에 넣는 데 성공했다. 이렇게 입력을 넣는 동작을 ‘주입’이라고 한다. 성공 응답은 그 동작에 대한 참말이었다. 상대에게 도달했는지를 확인한 답은 아니었다. 어디에 넣었는지 묻지 않은 쪽은 우리였다.
함께 드러난 결함: 글을 넣는 것과 제출하는 것도 달랐다
받는 사람을 고쳐도 확인할 일이 하나 더 남았다. 입력창에 글만 들어가고 제출되지 않으면 상대의 처리가 시작되지 않는다.
같은 사용법의 첫 줄에 그 차이가 적혀 있었다. 아래 문장은 본문을 넣을 때 줄바꿈을 붙이지 않으므로, 이어서 Return 키를 보내라는 설명이다.
Inject text into a surface's stdin (no trailing newline; follow with send-key Return)
여기서 stdin은 프로그램이 입력을 받는 통로다. cys send는 본문만 넣는다. 따라서 직접 전송한 뒤에는 제출 키도 보내야 했다. 다음 명령은 지정한 좌석에 제출 키인 Return을 보내는 형태다. <ref> 자리에는 받는 사람의 좌석을 지정한다.
cys send-key --surface <ref> Return
대상을 바로잡아 재전송한 뒤, 두 좌석 모두 idle=0이 관측됐다. 아무 활동 없이 지난 시간을 나타내는 값이 0으로 바뀐 것이다. 당시에는 이 값으로 처리 착수를 확인했다. 앞선 성공 응답 하나만 보고 기다리던 때와 달리, 전송 뒤의 상태를 확인한 기록이 남았다.

4. 진짜 원인: 성공했다는 답에 우리가 원한 뜻을 더했다
직접적인 실수는 받는 사람을 잘못 적은 것이었다. 하지만 40분 동안 몰랐던 이유는 따로 있다. 우리는 도구가 자기 일을 끝냈다는 답을, 우리가 원한 일이 끝났다는 답으로 받아들였다. 확인할 수단이 없었던 것이 아니다. 성공이라는 답을 받은 뒤에는 확인할 이유가 없다고 여긴 것이 문제였다.
우리가 근거로 쓴 신호는 OK 하나였다. 그 신호가 보증한 것은 주입 성공까지였다. 우리가 판정하려던 것은 상대 도달이었다. 그 사이에는 대상 지정이라는 단계가 있었다. 뒤이어 입력을 제출하는 단계도 따로 확인해야 했다.
미배달 목록과 사용법은 처음부터 열어 볼 수 있었다. 없던 것은 도구가 아니라 도구의 성공 선언을 의심할 이유였다. 지휘 담당자가 먼저 “발부 완료”라고 보고한 것도 같은 오판의 일부다. 보고 전에 상대가 실제로 일을 받았는지 확인하지 않았다.
도구의 성공 선언은 도구가 한 일에 대한 보고이지 내가 원한 일에 대한 보고가 아니다. 이 차이는 메시지 전송에서만 생기지 않는다. 어떤 자동화든 성공 표시가 정확히 어느 단계까지를 뜻하는지 먼저 확인해야 한다.
5. 재발 방지: 주의하자는 말 대신 잘못 보내는 경로를 없앴다
이번에는 다음부터 조심하자는 제안으로 끝내지 않았다. 받는 사람을 내용 맨 앞에 적는 잘못된 방식으로는 보낼 수 없게 고쳤다. 보내기 전후에 대상을 비교하는 검사도 적용했다. 다만 확인할 수 있는 범위를 넓힌 뒤에도 남은 빈틈이 있었다. 이 글을 쓰는 일 자체가 그 빈틈 때문에 다시 멈췄다.
감시 담당자는 발신 검증기 v2를 실제 전송 과정에 연결했다. v2는 두 번째 버전이라는 뜻이다. 그 수정 보고 자체가 첫 실사용이었다. 위치인자 경로를 없앤 곳은 이 검증기의 코드다. 경고문을 붙이는 데 그치지 않고 잘못 보내는 경로를 제거했다.
실제 대상과 비교할 기준은 따로 확보한다. cys list로 좌석 목록에서 보내려는 역할의 번호를 얻는다. 발신 후에는 대기열 접수 기록인 queue.enqueued를 확인한다. 그 기록의 실제 대상 번호인 surface_id와 앞서 얻은 번호를 대조한다.
본문 첫 단어가 surface:숫자 또는 역할 이름의 형태이면 발신 전에 멈춘다. 이때 중단을 나타내는 종료 코드 3을 반환한다. 잘못된 주소가 본문에 섞인 흔적을 실제로 보내기 전에 잡는다.
판정은 네 가지다. MATCH는 본문 무손실과 대상 일치, MISMATCH는 불일치다. UNVERIFIED는 사건 기록을 회수하지 못해 확인 불가라는 뜻이다. PREFLIGHT WARN은 발신 전 검사로 아예 보내지 않았다는 뜻이다. 모르는 결과를 성공에 섞지 않았다.
그다음 버전인 v3에는 제출 확인을 추가했다. 접수 기록과 배달 기록인 queue.delivered를 같은 항목 번호로 짝지어 본다. 배달 기록이 없으면 제출 미확인으로 판정하고 엔터를 다시 보낸다. 화면 글자는 다시 그려질 때 판정이 깨질 수 있어 기준으로 삼지 않았다. 앞서 착수 판단에 쓴 ‘한가한 상태인가’라는 값에도 오판 이력이 있었다. 시스템이 한 일을 남긴 기록을 기준으로 삼은 이유다.
그 검사도 처음에는 다른 메시지를 자기 것으로 착각했다. 같은 초에 감시 담당자의 보고 1391바이트와 지휘 담당자의 다른 발신 3344바이트가 접수됐다. 바이트는 글의 데이터 크기를 세는 단위다. v3가 가장 최근 접수 기록을 집으면서 대상 불일치 오경보를 냈다. v3.2에서는 바이트 수로 자기 항목을 식별하고 남의 발신은 건너뛰게 고쳤다. 측정한 것은 ‘가장 최근 사건’인데 판정하려던 것은 ‘내 사건’이었다는 사고도 주석에 남겼다.
그래도 배달 기록은 마지막 단계를 증명하지 못했다. 시스템이 엔터를 보냈다는 증거이지 상대 프로그램이 그것을 받아들였다는 증거는 아니었다. 같은 날 세 번, 글자는 화면에 들어갔지만 미제출 상태로 남았다. 사람이 엔터를 다시 눌러야 작업이 시작됐다. 그중 한 번은 이 글을 쓰는 작업 자체를 약 40분 멈춰 세웠다. 처음의 잘못된 대상 사고와는 별개로, 수정을 적용한 뒤에도 다음 단계에서 다시 걸렸다.
검증 장치 자체의 오경보도 두 번 있었다. 하나는 개수를 세는 부분의 버그로 정상을 고장으로 판정한 것이고, 다른 하나는 위의 최근 항목 오인이었다. 검증 장치가 검증 대상보다 복잡해지면 장치가 오판의 원천이 된다.
그렇다고 모든 경보를 같은 방식으로 줄일 수는 없다. 위 검증기 오경보는 확인에 3분이 들었다. 진짜 오배달의 대가보다 쌌다. 확인 비용이 싸고 간헐적이며 직접 고칠 수 있는 경보라면 검사를 끄기보다 정확도를 높인다. 반대로 확인 비용이 비싸고 영구히 켜진 경보라면 반응 방식을 바꿔야 한다. 앞선 글의 ‘누락 1건’처럼 0이 될 수 없는 지표는 이상을 구별하는 신호가 되지 못한다.
이 글의 그림 설명글 자체가 같은 실수로 깨져 있었다. 그림은 정상이었고, 설명글을 코드에 넣는 과정에서 한글이 물음표로 바뀌었다. 앞선 글에서 이미 기록해 둔 원인이었는데도 그랬다. 기록해 두는 것과 다음 작업에서 걸리지 않는 것은 다른 일이다.
같은 계열의 사례는 그날 아홉까지 늘었다. 앞선 글의 표를 반복하는 대신 이번에 더해진 두 사례만 남긴다. 아래 표는 도구가 실제로 확인한 일과 우리가 확인했다고 믿은 일을 나란히 놓은 것이다.
| 확인 절차 | 실제로 잰 것 | 판정하려던 것 | 오판 방향 |
|---|---|---|---|
| 발신(이번 건) | 주입에 성공했는지 | 상대에게 도달했는지 | 실패를 성공으로 |
| 좌석 목록 | 좌석 껍데기가 존재하는지 | 에이전트가 살아 있는지 | 실패를 성공으로 |
같은 날, 목록에 남아 있던 좌석 하나에서는 실제로 에이전트가 죽어 있었다. 에이전트의 생존 여부를 따로 나타내는 agent_alive=false를 보지 않으면 목록만으로는 알 수 없었다.
자기 작업에서도 성공 표시가 확인한 일과 내가 끝났다고 판단하려는 일을 나눠 보면 빠진 단계를 찾을 수 있다. 이번 수정은 적용을 마쳤지만 완전히 해결했다는 뜻은 아니다. 보냈는가에서 상대에게 갔는가로 확인 범위를 높였다. 그리고 상대가 받아들였는가라는 단계가 남았음을 실제 사고로 확인했다.