예외를 던지지 않는 실패들
에러 로그를 남기지 않는 결함 여섯 개를 찾았다. 빈 배열 하나로 "없음"과 "못 가져옴"을 함께 표현한 게 공통점이었고, 넷은 배포 전에 잡았다.
버그 리포트는 이렇게 왔다.
아침에 처음 말 걸면 에이전트가 내 취향을 하나도 모른다. 두 번째부터는 멀쩡하다.
에러 로그도 5xx도 없었다. 응답은 200이었고, 기억 목록만 빈 배열로 왔다. 재현하려고 낮에 아무리 호출해도 잘 나왔다.
원인을 찾고 나서, 같은 성질의 결함이 다섯 개 더 있다는 걸 알았다.
밤새 유휴, 그리고 첫 요청
벡터 저장소 커넥션 풀에 대여 전 검사가 없었다. 몇 시간 유휴 상태로 두면 서버 쪽에서 커넥션이 끊기는데, 풀은 그걸 모른 채 죽은 커넥션을 내준다. 첫 요청이 그걸 물고 실패한다.
01아침 첫 턴. 어젯밤 이후로 이 경로에는 아무 요청도 없었다.
기억을 못 불러왔다고 대화를 끊을 수는 없으니, 실패를 삼키는 설계 자체는 맞았다. 문제는 삼킨 실패가 어디에도 보고되지 않아서 빈 배열과 "기억이 없는 사용자"가 구분되지 않았다는 것이다.
고친 것은 두 줄이다. 풀에 대여 전 검사를 걸고, 삼킨 실패를 빈 결과와 다른 신호로 남겼다.
필드가 없는 행은 조건에 걸리지 않는다
검색에서 특정 대화를 제외하려고 $not 조건을 걸었다. 그런데 모델이 직접 저장한 기억이 검색에 영영 나오지 않았다.
SQL에서 NOT NULL의 결과는 NULL이다. 그런데 이 조건은 에러를 내지 않아서, 쿼리는 성공하고 결과만 조용히 줄어든다.
COALESCE(NOT (…), TRUE)로 감쌌다. 그리고 애초에 필드가 비지 않도록, 자동 적재분에도 분류를 반드시 붙이게 했다.
새벽에 나눈 대화는 어제 일이 된다
기억에는 날짜가 붙는다. "다음 주 화요일에 반차"라는 문장을 저장하려면 기준일이 필요하다.
벤더 코드는 기준일을 UTC로 계산하고 있었다.
파드 타임존을 Asia/Seoul로 맞추면 되지 않느냐는 이야기가 나왔다. 그렇게 하지 않았다. 그 설정이 되어 있는지를 배포마다 확인해야 하고, 어딘가에서 빠뜨리면 같은 버그가 조용히 돌아오기 때문이다.
대신 기준일을 UTC+9로 코드에 고정했다. 한국은 1988년 이후 서머타임이 없어서 이 값은 언제나 정확하다.
나머지 셋
같은 성격의 결함이 세 개 더 있었고, 전부 배포 전에 잡았다.
정렬 없는 LIMIT 이 특히 나쁘다. 페이지를 넘길 때마다 어떤 항목은 두 번 나오고 어떤 항목은 영영 안 나온다. 그런데 한 번만 조회하면 아무 이상이 없어 보인다.
공통점
여섯 개를 늘어놓고 보니 하나로 묶였다.
전부 예외를 던지지 않는다. 죽은 커넥션은 삼켜진 실패가 됐고, NOT NULL은 조용히 행을 줄였고, UTC 기준일은 그럴듯한 날짜를 만들었고, 풀스캔은 그냥 느렸고, 문자열 시각은 정렬만 어긋났고, 정렬 없는 LIMIT은 매번 다른 답을 줬다. 그러면서 전부 200과 그럴듯한 값을 돌려줬다.
배포 전에 잡은 값
여섯 중 넷은 아직 배포되지 않은 상태에서, 장애가 나기 전에 코드를 읽다가 잡았다.
배포 후였다면 각각 데이터 마이그레이션이 필요했을 것이다. 문자열 시각은 이미 쌓인 원장을 변환해야 하고, UTC 기준일은 이미 저장된 상대 날짜를 되돌릴 방법이 없다. 「내일」이 하루 밀린 채 저장된 기억은 나중에 타임존을 고쳐도 복구되지 않는다.
여섯 개 중 넷은 빈 배열 하나로 "없음"과 "못 가져옴"을 함께 표현하고 있었다. 코드에 이런 자리가 더 있을 것 같은데, 빈 배열을 돌려주는 곳을 하나씩 찾아서 두 경우의 타입을 갈라 주는 게 제대로 된 수정일 것 같다.
끝