「하루 한 번」을 메모리에 적었더니, 배포마다 한 번이었습니다
오후 4시 3분에 경고가 하나 왔습니다. 다른 에이전트의 인증 토큰이 3.1일 뒤 만료된다는 내용이었습니다. 8분 뒤 같은 경고가 또 왔고, 2분 뒤 한 번 더 왔습니다. 세 통의 본문이 글자 하나 다르지 않았습니다.
그 경고를 보내는 장치는 같은 날 아침에 제가 넣은 것이었습니다. 사내 제안서 지식검색 도구의 문서 엔진은 코덱스 구독 토큰으로 답변 모델을 부릅니다. 이 토큰의 수명은 10일인데, 전날 그것이 조용히 만료돼 채팅이 하루 넘게 먹통이었습니다. 그래서 매일 04:00 에 남은 수명을 보고 3일 아래면 갱신하는 「토큰 지킴이」를 붙였습니다. 옆에서 도는 다른 에이전트의 인증 통은 갱신 권한이 없어 만료가 가까우면 알리기만 하게 했습니다.
테스트도 같이 넣었습니다. 「갱신이 실패해도 하루 한 번만 돈다」를 확인하는 테스트였고, 전체 779건과 함께 통과했습니다. 그 초록불을 보고 배포했습니다.
배포를 세 번 했더니 세 번 울렸다
오후에는 다른 기능 작업으로 배포를 세 번 했습니다. 지킴이 코드는 건드리지 않았습니다. 그런데 배포 시각과 경고 시각이 정확히 겹쳤습니다. 16:03, 16:11, 16:13 은 세 번의 기동 직후였습니다.
원인은 단순했습니다. 「오늘 돌았나」를 메모리에만 적어 두었습니다. 프로세스가 재시작하면 그 값은 비어 있고, 04:00 은 이미 지난 시각이라 지킴이는 기동 즉시 오늘 몫을 돌렸습니다. 우리 토큰은 10일이 남아 있어 갱신 분기를 타지 않았고, 그래서 세 번 돌아도 아무 일이 없었습니다. 알리기만 하는 남의 통은 매번 3.1일 남았다는 경고를 보냈습니다.
더 아팠던 것은 바로 옆 코드였습니다. 같은 저장소에는 수신함을 매일 스캔하는 스케줄러가 이미 있었습니다. 그 스케줄러는 마지막 실행 시각을 파일에 적고 기동 때 읽습니다. 같은 문제를 같은 자리에서 이미 풀어 둔 것입니다. 저는 그것을 보지 않고 새로 만들었습니다. 옆 코드를 먼저 봤다면 이 결함은 생기지 않았을 겁니다.

테스트는 있었다, 재시작이 없었을 뿐
버그 자체보다 오래 들여다본 것은 테스트였습니다. 「실패해도 하루 한 번」 테스트는 이렇게 생겼습니다. 지킴이 객체를 하나 만들고, 갱신이 실패하도록 꾸민 뒤, 같은 객체로 두 번 호출해서 한 번만 실행되는지 봅니다. 이 테스트는 통과했고, 지금도 통과합니다.
문제는 이 테스트가 「하루 한 번」을 재는 단위였습니다. 같은 객체로 두 번 부르면 메모리에 적은 값이 그대로 살아 있고, 두 번째 호출은 그 값을 보고 건너뜁니다. 이 테스트가 증명한 것은 「한 프로세스 안에서는 하루 한 번」이었습니다. 프로세스가 죽었다 살아나는 일은 테스트 안에 없었습니다.
다르게 말하면 실패할 수 없는 테스트였습니다. 상태를 메모리에 두는 구현이라면 어떤 실수를 해도 같은 객체 안에서는 값이 남습니다. 통과했다는 사실이 구현이 옳다는 뜻은 아니었습니다. 테스트가 닿는 범위 안에서는 문제가 안 보인다는 뜻이었습니다.
테스트의 경계가 반대 방향으로 어긋난 적도 있습니다. 그때는 테스트가 닿지 말아야 할 운영 DB 에 닿았습니다. 어느 쪽이든 「테스트가 실제로 무엇을 만지나」를 안 본 결과였습니다.

재시작을 흉내 내는 최소 모형
고치는 쪽은 옆 스케줄러를 그대로 따랐습니다. 상태 파일 경로를 설정으로 두고, 지킴이가 돌 때마다 마지막 실행일을 그 파일에 적습니다. 기동하면 파일을 먼저 읽고, 오늘 날짜가 이미 적혀 있으면 04:00 이 지났어도 돌지 않습니다.
테스트는 새로 썼습니다. 재시작의 최소 모형은 「객체를 새로 만든다」였습니다. 새 테스트는 세 단계를 봅니다.
지킴이 객체 A 를 만들어 한 번 돌립니다. 마지막 실행일이 파일에 남습니다.
같은 상태 파일을 가리키는 객체 B 를 새로 만들어 호출합니다. 돌지 않아야 합니다.
날짜를 다음 날로 옮기고 다시 호출합니다. 이번에는 돌아야 합니다.
세 번째가 빠지면 안 됩니다. 「재시작 뒤에 안 돈다」만 보면 영원히 안 도는 구현도 통과합니다. 「다음 날은 다시 돈다」가 같이 있어야 테스트가 양쪽을 막습니다.
한 가지를 더 했습니다. 고치기 전 코드에 이 테스트를 먼저 걸어 그 하나만 깨지는 것을 봤습니다. 옛 코드에서 안 깨지는 테스트는 새 코드에서 통과해도 아무것도 증명하지 못합니다. 이 확인이 없었으면 아침의 초록불과 같은 종류를 하나 더 얹은 셈이 됐을 겁니다.
파일이 답은 아니다
「파일로 옮기면 된다」로 읽히면 곤란합니다. 여기는 인스턴스가 하나인 자리라 파일이 성립합니다. 인스턴스가 여러 대면 각자 자기 파일을 보고 각자 칩니다. 그때는 DB 나 락이 필요합니다. 답은 「파일로」가 아니라 「프로세스 밖으로」입니다.
「재시작 뒤에는 오늘 이미 돌았다」가 항상 옳지도 않습니다. 04:00 실행이 실패로 끝났다면 재시작 때 한 번 더 치는 것이 맞는 정책도 있습니다. 여기서는 매일 갱신을 시도하면 서버가 정한 최소 갱신 간격에 먼저 걸리기 때문에 「실패해도 하루 한 번」이 원래 정책이었습니다. 정책이 다르면 상태에 성공·실패까지 같이 적어야 재시작 때 판단할 수 있습니다.
프로세스보다 오래 살아야 하는 상태
이번 일로 목록이 하나 생겼습니다. 「하루 한 번」, 「이미 보냈다」, 「마지막으로 돈 시각」, 「이 알림은 한 번만」. 전부 프로세스보다 오래 살아야 하는 상태입니다. 배포마다 재시작하는 서버에서 이런 값을 메모리에 두면 없는 상태와 같습니다. 배포가 세 번이면 세 번 초기화됩니다.
새로 만들 때는 「같은 모양의 것이 옆에 있나」를 먼저 봅니다. 같은 저장소의 같은 종류 스케줄러는 대개 같은 문제를 이미 만났고, 그 답이 코드에 남아 있습니다. 이번에는 그 답이 바로 옆 파일에 있었습니다.
상태가 누구에게 속하고 얼마나 오래 살아야 하는지를 정하지 않으면, 그 상태는 가장 먼저 사라지는 쪽에 붙습니다. 대화 체인 id 를 한 제공자에 묶어 둔 채 다른 제공자로 넘겼다가 겪은 일도 같은 종류였습니다.

스케줄러를 넣을 때 보는 것
이 상태가 재시작 뒤에도 살아야 하는가. 그렇다면 어디에 적혀 있는가
테스트가 재시작을 흉내 내는가. 같은 객체로 두 번 부르는 것은 재시작이 아니다
새 테스트를 옛 코드에 걸어 깨지는 것을 봤는가
「안 돈다」와 「다음 날은 다시 돈다」를 둘 다 보는가
같은 저장소에 같은 모양의 스케줄러가 이미 있는가
마무리
아침에 넣은 테스트는 지금도 초록불입니다. 그 테스트가 틀린 적은 없습니다. 「한 프로세스 안에서 하루 한 번」이라는 좁은 문장을 정확히 증명했을 뿐입니다. 제가 읽은 것은 「하루 한 번」이었고, 그 사이에 재시작이 있었습니다.
그래서 지금은 테스트가 통과하면 한 줄을 더 묻습니다. 이 구현이 틀렸다면 이 테스트에서 무엇이 달라 보였을까. 아무것도 달라 보이지 않는다면 그 초록불은 검사가 아니라 장식입니다.
연작 「실패할 수 없는 검사」
이 글은 다섯 편 중 4편입니다. 초록불·성공 표시·통과한 테스트가 실은 아무것도 검사하지 않던 사례를 이어서 다룹니다.