728x90

주말에 HN 올라온 글 하나 읽고 한참 멍하니 있었다. 2018년에 "장난삼아" 산 sondehub.org 도메인 하나가, 결국 미 국방부(Office of the Secretary of War)와 NTSB, FAA 관제탑, 그리고 우크라이나 심층타격팀까지 엮이는 인프라가 되어버린 이야기다.

기술적으로 대단한 스택이 나오는 글은 아니다. 그런데 "내가 만든 공개 API를 내가 통제할 수 없는 사람들이 쓰기 시작했을 때 무슨 일이 벌어지는가"에 대해서는, 내가 본 어떤 아키텍처 문서보다 생생하다. 5년차 인프라 하다 보면 사이드 프로젝트 하나쯤은 공개 엔드포인트로 열어두게 되는데, 그 엔드포인트가 어디까지 갈 수 있는지 감각을 잡아두는 게 좋다.

장난 도메인이 어쩌다 전략 자산이 됐나

흐름을 요약하면 이렇다.

  • 2018.05 — Habhub(아마추어 고고도 풍선 추적 사이트)가 기상 관측용 라디오존데를 기본 필터로 감췄다. 필터 해제 쿼리 파라미터를 매번 치기 귀찮아서 sondehub.org를 사면서 Habhub으로 리다이렉트만 걸었다. 진짜 그게 전부였다.
  • 2018.07 — 리다이렉트만으로는 안 되겠어서 데이터 수집을 프록시로 받기 시작. 자체 OpenSearch 클러스터에 쌓았지만 노출은 안 했다. 저자 본인 표현으로 "AWS 서비스 놀아보는 장난감" 수준.
  • 2019 — Habhub과 aprs.fi 서버가 버티질 못함. 자체 서비스로 전환 시작. 이때부터 정부기관 데이터 요청이 들어온다. (라디오존데가 말에 맞아서 말이 울타리 뚫고 도망간 보험 청구 건... 이런 게 온다)
  • 2021 — 역예측(reverse prediction) 기능 도입. 그리고 군에서 이메일이 온다.
  • 2023.02 — 중국 정찰풍선 사태 + 미군이 아마추어 무선 풍선을 AIM-9X로 격추. Washington Post에 링크되면서 트래픽 폭증.
  • 2024.12 — 예측 API에 알람 폭탄. 추적해보니 우크라이나 쪽 심층타격 작전이었다.

여기서 결정적 분기점이 역예측이다. 원래 예측기는 "지금 이 풍선이 어디로 갈까"를 바람 모델로 계산한다. 이걸 시간축을 뒤집어서 돌리면, 이미 관측된 풍선의 발사 지점이 역산된다. 문서화 안 된 발사장을 여럿 찾아냈고 — 문제는, 상층 바람 데이터는 기상 예보용만이 아니라 포병 사거리 계산에도 쓰인다는 것.

즉 이들이 무심코 만든 건 "미문서화 기상 관측소 지도"가 아니라 포병 진지 지도였다. 2021년에 군에서 "민감/군사 시설이니 지도에 명시적으로 표시하지 말아달라"는 메일을 받는다. 역예측 기능은 유지하되, 정당한 요청이 오면 해당 발사장을 삭제하는 방식으로 타협했다고 한다.

핵심 원리: 파생 데이터는 원본보다 위험하다

이 사례의 기술적 교훈은 딱 하나로 압축된다. 공개 데이터를 모아서 가공하면, 개별 데이터에는 없던 민감도가 생긴다. 인프라 쪽 용어로는 aggregation / inference 문제다.

비유하자면 이렇다. 사내 액세스 로그 한 줄은 아무 의미 없다. 근데 6개월치를 모아서 "이 IP대역이 새벽 3시에만 특정 엔드포인트를 친다"는 패턴을 뽑으면, 그건 배치 스케줄이자 곧 인프라 구조도다. 라디오존데도 똑같다. 개별 좌표 하나는 공개 기상 데이터인데, 수만 건을 역방향 물리 모델에 넣으면 발사 원점이 나온다.

SondeHub이 왜 기존 도구보다 데이터가 좋았냐면 — 지면에 닿을 때까지 끝까지 추적했기 때문이다. 당시 공식 소프트웨어는 그렇지 않았다. 그래서 보험 청구 조사까지 가능했던 거고, 그래서 역예측 정밀도도 나온 거다. 관측 커버리지를 늘렸더니 부작용으로 정보 가치가 폭발한 케이스.

본인 인프라에서 이걸 확인하고 싶다면, 로그에서 "단일 소스가 예측 API만 반복 호출"하는 패턴을 뽑아보는 게 첫 단계다. CloudFront/ALB 액세스 로그 기준 예시:

# ALB 액세스 로그에서 예측 엔드포인트 상위 호출자 뽑기
awk '$13 ~ /predict/ {split($4,a,":"); print a[1]}' alb.log \
  | sort | uniq -c | sort -rn | head -5

  48213 13.54.x.x
    902 203.0.113.9
    311 198.51.100.44
     87 192.0.2.17
     52 172.16.8.3

1등과 2등이 50배 차이 나면 그건 사람이 아니다. 원문에서도 로깅을 켜자마자 단일 IP에서 오고 있었다고 나온다. 처음엔 어떤 사설 업체가 백엔드를 무단으로 쓰는 줄 알고 항의 메일까지 보냈는데, 그쪽은 진범이 아니었다. LLM 스크래퍼 봇이겠거니 하고 예측 요청 좌표를 지도에 찍어봤더니 — 그림이 나왔다.

실무 관점: 공개 API 운영자가 실제로 마주치는 것들

1. 차단이 정답이 아닌 경우가 있다

보통 이런 트래픽 보면 WAF 룰 하나 박고 끝낸다. 근데 이 케이스에서 운영자가 AWS 서포트에 보낸 문장이 이거다.

"HTTP 요청 데이터가 배포되지 않는 것이 대단히 중요합니다. 또한 소스 AWS 계정이 차단·레이트리밋·해지되지 않는 것도 중요합니다 — 인명 손실이 발생할 수 있습니다."

서포트 티켓에 이런 문장을 쓸 일이 있으리라곤 본인도 상상 못 했다고 한다. 요구사항이 두 개인데 서로 충돌한다. (a) 서비스를 끊으면 안 된다 (b) 요청 로그가 새어나가면 발사 지점이 노출된다. 결국 AWS를 통해 "당신 람다가 스크래핑으로 플래그됐으니 운영자에게 연락하라"는 형태로 채널이 열렸고, 로컬 예측기 실행 문서를 넘겨주는 걸로 정리됐다.

2. 진짜 해법은 "떠나게 만드는 것"

여기서 제일 실무적인 판단이 나온다. 저자는 차단 대신 docker compose 파일을 급하게 만들어서 누구나 자기 예측기를 돌릴 수 있게 풀었다. 헤비 유저를 내쫓는 게 아니라 셀프호스팅 경로를 열어준 것.

이건 사내 공용 API 운영할 때도 그대로 적용된다. 특정 팀이 공용 계산 API를 초당 수백 번 때리면, 429 때리기 전에 "그 계산 라이브러리를 너희 파드 안에서 돌려라"를 먼저 제안하는 게 총 비용이 싸다. 사이드카/로컬 배포 경로가 있으면 대부분 알아서 옮겨간다.

# 헤비 유저에게 넘길 최소 셀프호스팅 스택 예시
cat > compose.yml <<'EOF'
services:
  predictor:
    image: ghcr.io/example/predictor:latest
    ports: ["8080:8080"]
    environment:
      - GFS_CACHE_DIR=/data/gfs
    volumes: ["./gfs:/data/gfs"]
EOF

docker compose up -d
curl -s localhost:8080/healthz

{"status":"ok","wind_model":"loaded"}

※ 위 이미지 경로는 예시다. SondeHub 실제 이미지 태그는 공식 저장소 확인 필요.

3. 흔한 함정: 레이트리밋 걸고 나서 벌어지는 일

API Gateway나 CloudFront 앞단에 usage plan 걸면 이런 게 뜬다.

HTTP/1.1 429 Too Many Requests
x-amzn-ErrorType: LimitExceededException
{"message":"Too Many Requests"}

문제는 이걸 받는 쪽 클라이언트가 대개 백오프를 구현 안 했다는 것이다. 429를 받으면 재시도를 더 빨리 돌려서 부하가 오히려 튄다. 원문에서도 "API를 올바르게 사용하지 않는 것 같다"는 의심이 나온다. 그리고 이런 상황에서 진짜 답답한 건, 연락할 방법이 없다는 점이다. User-Agent도 없고 API 키도 없으면 그냥 IP 하나뿐이다.

결국 이들은 우크라이나어로 밀리터리 채팅방에 메시지를 뿌려서 컨택을 잡았다. "파이썬 스크립트로 오픈소스 바람 예측 엔진 쓰는 심층타격팀 아시는 분 연락 주세요, 차단될 수 있으니 조치가 필요합니다" — 이게 실제 컨택 경로였다.

여기서 얻어갈 실무 룰:

  • 익명 접근을 허용하더라도 키 발급은 무료로라도 열어둬라. 차단이 목적이 아니라 연락 채널 확보가 목적이다.
  • 429 본문에 사람이 읽을 수 있는 컨택 정보와 셀프호스팅 링크를 반드시 넣어라. 봇 운영자도 결국 사람이 로그를 본다.
  • 레이트리밋은 계정 단위로, 익명은 IP 단위로. NAT 뒤에 있으면 IP 단위는 애먼 사람 잡는다.

4. 로그 자체가 기밀이 되는 순간

평소엔 액세스 로그를 S3에 던져놓고 라이프사이클 정책만 걸어둔다. 근데 이 케이스에선 요청 파라미터(위경도)가 곧 작전 정보였다. 클라우드 벤더 서포트에게 로그를 넘기는 순간 그게 유출이 된다.

지도에 찍을 때도 좌표 정밀도를 의도적으로 낮췄고, 데이터셋은 상당히 오래된 것만, 블로그 발행 자체를 "풍선 전쟁이 충분히 널리 알려질 때까지" 미뤘다고 한다. 이 판단 자체가 운영 프로세스의 일부다.

대응 원칙 정하고 가자:

  • 쿼리 파라미터를 액세스 로그에 통째로 남길지 여부를 설계 시점에 결정. 좌표·검색어·식별자는 기본적으로 마스킹 후보다.
  • 서드파티(벤더 서포트, APM SaaS)로 나가는 로그 필드를 화이트리스트로 관리. 블랙리스트는 언젠가 샌다.
  • 보존 기간 짧게. 없는 데이터는 유출도 안 되고 소환장에도 안 걸린다.

5. 예상 못 한 이해관계자들

운영하다 보면 상상 못 한 곳에서 메일이 온다. 원문에 나온 것만 봐도:

  • NTSB(2025.09) — "10/16/2025 1200~1300 UTC에 유타 지역 풍선 정보 있나요?" 데이터를 넘겼고, SondeHub 추적 풍선과는 안 맞았지만 Windborne 풍선이 근처에 있었다는 정보를 전달. 나중에 Windborne이 항공기 충돌 가능성을 확인하고 시스템을 수정했다.
  • FAA 관제탑 — "하늘에 항공기가 많은데 이 풍선들 가까이 가고 싶지 않다. 통제 주체와 직접 조율하거나 사전에 임무 정보를 받을 수 있나?" → 기상 풍선은 정기 스케줄로 올라가고 조종되지 않으며 FAA 규정 Part 101.D 범위라고 설명해야 했다. 저자 표현: "FAA에 기상 풍선의 존재를 설명하게 될 줄은 몰랐다."
  • 뺑소니 민원 — 아이오와 아나모사에서 누가 라디오존데 회수하다가 건물 들이받고 쪽지도 안 남기고 도망감. 건물주가 범인 찾아달라고 연락.
  • 미 국방부(2025) — 데이터 요청. 커뮤니티 상호 이익이 있으면 보통 무료로 가공해서 푸는데, 이건 그게 아니라 인보이스를 끊었다. 결과: 결제도 후속 연락도 없었다.

이게 왜 실무 얘기냐면, 공개 서비스를 운영한다는 건 지원 채널을 운영한다는 뜻이기 때문이다. 스펙에도 없고 SLA도 없는 요청이 계속 들어온다. 답변 템플릿과 에스컬레이션 기준, "이건 우리 소관 아님"이라고 말할 수 있는 문서화된 경계선이 없으면 운영자 한 명이 갈려나간다.

정리

한 줄 요약: 공개 API는 어느 순간 당신이 승인하지 않은 용도로 쓰이며, 그때는 차단보다 셀프호스팅 경로 제공과 연락 채널 확보가 더 나은 해법일 수 있다.

체크리스트로 남긴다.

  • 공개 엔드포인트를 열 거면 1일차에 로깅·식별·컨택 경로를 같이 설계해라. 사고 터진 뒤 켜는 로깅은 이미 늦다.
  • 원본 데이터가 공개라고 파생 데이터도 공개해도 되는 건 아니다. 집계·역산으로 새 정보가 생기는지 한 번은 따져봐라.
  • 헤비 유저는 적이 아니라 배포 형태를 잘못 고른 사용자다. 도커 컴포즈 하나가 WAF 룰 열 개보다 낫다.
  • 액세스 로그 필드는 유출 자산으로 취급하고, 서드파티로 나가는 항목은 화이트리스트로.
  • 취미 프로젝트라도 인보이스 끊을 각오는 해두되, 정부기관이 그걸 낼 거라는 기대는 접어라.

솔직히 나는 이 글에서 제일 인상 깊었던 게 아키텍처가 아니라 "AWS 서포트에게 이 계정을 차단하지 말아달라, 인명 손실이 발생할 수 있다"는 문장이었다. 우리가 짜는 레이트리밋 룰 한 줄이 어디까지 가닿을 수 있는지, 가끔은 생각해볼 만하다.

참고 자료

728x90
728x90

1. 6개월, 19건. "에러 없이 성공한 쓰기"가 사라졌다

Tailscale이 제어 평면(control plane) 데이터베이스에서 겪은 손상 사고 이야기가 화제다. 요약하면 이렇다. 2024년 8월 S3 백업을 읽는 파이프라인에서 오류가 났고, PRAGMA integrity_check를 돌려보니 진짜 손상이었다. 그 뒤 6개월 동안 서로 무관해 보이는 손상 사고가 19번 터졌다. 결론은 SQLite의 체크포인트와 쓰기 트랜잭션 사이에 존재하던 데이터 경쟁, SQLite 개발진이 WAL-Reset 버그라 이름 붙인, 최소 16년 묵은 결함이었다.

구성부터 보자. Tailscale의 제어 평면은 여러 샤드로 나뉘고, 각 샤드는 자기 SQLite 파일을 하나 갖는다. 접근하는 프로세스는 Go 프로세스 하나뿐이다. 단일 작성자 — SQLite가 가장 잘하는 형태다. 백업은 몇 분마다 전체 스냅샷을 떠서 S3에 올린다. 2023년 초부터 사고 없이 잘 돌던 구성이다.

운영 담당자 입장에서 이 사건이 무서운 건 증상이 아니라 증상의 성질이다. 정리하면:

  • 재현 조건이 없다. 특정 샤드·고객·기능·시간대·부하와 상관관계가 안 나왔다.
  • 발생 간격이 몇 시간에서 몇 주까지 제멋대로다. 10월~12월엔 6주간 조용하다가 크리스마스 즈음 다시 터졌다.
  • 가장 결정적인 단서: 백업 복구용으로 만든 트랜잭션 로그를 재생했더니 깨끗하게 적용되지 않았다. 어떤 트랜잭션이 기록하고 커밋한 데이터가, 뒤이은 트랜잭션에서는 안 보였다. 애플리케이션에는 에러가 하나도 안 올라왔는데 쓰기가 증발한 것이다.
  • 지표도 이상했다. WAL에 10페이지가 있는데 체크포인트는 20페이지를 복사했다고 집계했다.

영향은 메타데이터 범위였다. 사용자 개인키나 트래픽은 DB에 없다. 다만 손상된 샤드를 복구·복원하는 동안 제어 평면 프로세스를 멈춰야 했고, 초기엔 1시간 넘는 중단이 났다. 이미 연결된 WireGuard 피어는 살아 있지만 새로 켠 장치는 피어 목록을 못 받아 연결을 못 만든다. 그리고 반복되는 상태 페이지 공지 자체가 신뢰를 갉아먹었다.

2. WAL과 체크포인트, 그리고 리셋 순간의 틈

복습부터. WAL 모드에서 쓰기는 메인 DB 파일에 바로 가지 않고 -wal 파일에 페이지 단위로 append된다. 읽기 트랜잭션은 자기 스냅샷 시점까지의 WAL 프레임과 메인 파일을 조합해 본다. WAL이 무한정 커질 수 없으니 주기적으로 체크포인트가 WAL 페이지를 메인 DB로 복사하고, 아무도 그 프레임을 안 읽고 있으면 WAL을 리셋(처음부터 다시 쓰기 시작)한다.

여기서 조율을 담당하는 게 -shm 파일에 mmap된 wal-index다. 일반 파일 잠금이 아니라 xShmLock 같은 공유 메모리 원시연산으로 조정한다. 그래서 이 경로는 연결이 둘 이상 열려 있어야만 밟힌다. Tailscale은 프로세스가 하나지만, 쓰기와 체크포인트를 서로 다른 스레드의 서로 다른 커넥션에서 수행했을 것으로 보인다(HN 토론의 해석). 실제로 SQLite 측 버그 설명도 다중 연결 조건을 든다.

버그의 모양은 이렇다. 체크포인트가 진행되는 특정 시점에 쓰기 트랜잭션이 끼어들어 WAL을 리셋하면, 체크포인트 로직은 실제로 메인 DB에 복사하지 않은 페이지를 복사했다고 착각한다. 복사 안 된 페이지는 메인 파일에 영원히 안 들어간다. 그런데 그 페이지를 참조하는 인덱스 등 다른 페이지는 정상적으로 기록된다. 결과는 두 가지가 한꺼번에 온다 — 데이터 소실 + 구조 불일치(= 손상).

비유하자면, 이삿짐 목록에 체크 표시를 하는 사람과 실제 박스를 옮기는 사람이 다른데, 중간에 누가 목록을 새 종이로 갈아끼웠고 체크 표시만 그대로 옮겨 적힌 상황이다. 목록상으론 다 옮겼는데 박스 몇 개가 옛날 집에 남아 있고, 그 박스를 가리키는 색인표는 새 집에 도착해 있다.

왜 16년이나 안 걸렸나. 이 코드 경로는 xShmMap/xShmLock/xShmBarrier가 실제로 동작하는 VFS에서만 밟힌다. 현실에서 쓰이는 구현은 Unix와 Windows 정도고, 나머지 대체 VFS(브라우저/WASM/OPFS 등)는 locking_mode=exclusive로 우회해 wal-index를 힙에 둔다. 즉 Unix에서, 체크포인트를 수동으로, 공격적으로 돌리는 누군가가 나타나야 발견되는 버그였다. Tailscale은 빠르고 일관된 백업을 위해 체크포인트를 직접 제어하고 있었고, 그래서 당첨됐다.

3. 재현 안 되는 버그를 어떻게 잡았나 — 관측을 프로덕션에 심는다

이 사건의 진짜 볼거리는 디버깅 방법론이다. 합성 환경 재현을 포기하고 프로덕션에 수동적 포렌식 텔레메트리를 심는 쪽으로 방향을 틀었다.

  1. 사고 대응 시간을 먼저 줄였다. 손상 감지 시 샤드를 즉시 강제 중지, 백업마다 PRAGMA integrity_check를 돌리는 자동 백업 모니터 배포, 런북과 온콜 교육 개선. 이걸로 대응을 1시간 미만으로 낮췄다. 원인 못 찾는 동안 서비스는 돌려야 하니까.
  2. 트랜잭션 로그 파이프라인. DB를 바꾸는 모든 SQL 문을 별도 로그로 스트리밍. 단일 작성자 + 직렬화 가능 트랜잭션이라 기록이 선형적·결정적이다(멀티 라이터인 Postgres/MySQL에선 이 성질이 안 성립한다). 마지막 정상 백업에 로그를 재생해 손상 파일을 우회하고 최신 상태를 복원했다. 그리고 이 재생이 실패한 두 번의 사고가 결정적 단서가 됐다.
  3. tmstmpvfs shim. SQLite 개발진이 기존 VFS를 감싸 DB 변경 추적 로그를 남기는 shim을 만들었고, Tailscale이 이걸 프로덕션에 배포해 다음 사고의 로그를 확보했다. 여기서 경쟁 상태가 잡혔다.
  4. 수정 검증도 관측으로. "6주간 조용했던 전례"가 있었으니 재발 없음만으로 해결 선언을 안 했다. 대신 쓰기 트랜잭션과 WAL 리셋이 겹치면 경고를 남기도록 드라이버를 고쳤다. 배포 두 달 뒤 실제로 그 경고가 떴고 — DB는 멀쩡했다. 조건은 성립하는데 손상은 안 난다, 즉 패치가 사고를 막고 있다는 능동적 증거다.

여기서 훔쳐올 만한 원칙 하나: "안 터졌다"는 수정의 증거가 아니다. 위험 조건이 발생했다는 사실 자체를 계측해서, 조건 발생 + 무사고를 함께 봐야 한다.

4. 실무 점검: 지금 우리 SQLite는 안전한가

SQLite는 국내에서도 엣지 노드, 모니터링 에이전트, 사이드카 캐시, 온디바이스 저장소에 넘치게 쓰인다. 당장 확인할 것부터.

4-1. 저널 모드와 체크포인트 운용 확인

$ sqlite3 /var/lib/agent/state.db \
  "PRAGMA journal_mode; PRAGMA wal_autocheckpoint; PRAGMA synchronous;"
wal
1000
2

# 수동 체크포인트를 돌린다면 반환값을 반드시 확인
$ sqlite3 /var/lib/agent/state.db "PRAGMA wal_checkpoint(TRUNCATE);"
0|10|10

세 컬럼은 순서대로 busy, WAL 프레임 수, 메인 DB로 복사된 프레임 수다. 첫 값이 1이면 체크포인트가 완료되지 못했다는 뜻인데, 이걸 무시하고 "백업 떴다"고 넘어가는 스크립트가 현장에 정말 많다. 그리고 이번 사건에서 2번째와 3번째 값이 안 맞는 것이 이상 신호였다는 점을 기억하자. 백업 잡에서 이 세 값을 로그·메트릭으로 남겨두면 공짜 조기경보다.

수동 체크포인트를 고빈도로 돌리고 있다면 그게 곧 비표준 운용이다. 문서화되고 지원되는 기능이라도, 일반 배포 경로보다 검증 범위가 좁다는 사실을 Tailscale이 비싸게 증명했다.

4-2. 백업은 파일 복사가 아니라 검증까지

#!/bin/bash
set -euo pipefail
DB=/var/lib/agent/state.db
OUT=/backup/state-$(date +%Y%m%d%H%M).db

# 실행 중 DB를 cp/tar로 긁는 것은 금물. 일관 스냅샷 API를 쓴다.
sqlite3 "$DB" "VACUUM INTO '$OUT';"       # 3.27+ 지원

# 업로드 전에 반드시 검증
if ! sqlite3 "$OUT" "PRAGMA integrity_check;" | grep -qx "ok"; then
  echo "CORRUPT backup: $OUT" >&2
  exit 1
fi
aws s3 cp "$OUT" s3://backups/agent/ --only-show-errors

핵심은 마지막 두 단계다. 검증하지 않은 백업은 백업이 아니다. Tailscale도 손상을 처음 발견한 게 DB 자체가 아니라 S3 백업을 읽던 파이프라인이었다. 백업 모니터가 없었다면 19건이 아니라 훨씬 뒤에, 훨씬 크게 터졌을 것이다.

4-3. 흔한 함정과 실제로 보게 될 에러

손상이 나면 애플리케이션 로그에 이런 게 뜬다. 검색해서 여기 온 사람이 있다면 반갑다.

Error: database disk image is malformed (11)
# Go 드라이버 기준
sql: transaction: SQLITE_CORRUPT: database disk image is malformed

$ sqlite3 state.db "PRAGMA integrity_check;"
*** in database main ***
Page 4213: btreeInitPage() returns error code 11
row 90210 missing from index idx_nodes_tailnet
wrong # of entries in index sqlite_autoindex_devices_1

그리고 함정 하나 더. 위 출력에서 row N missing from index만 잔뜩 나오고 페이지 에러는 없다면, 진짜 손상이 아닐 수 있다. Tailscale은 3.52.0 배포 직후 13개 DB에서 손상 경고를 받았는데 실제 손상이 아니었다. 원인은 오래된 표현식 인덱스였다. 계산값 위에 인덱스를 만들어놓고 나중에 계산 방식이 바뀌면 인덱스 값과 실제 값이 어긋나고, integrity_check는 이걸 손상으로 판정한다. 이들은 고정밀 타임스탬프를 텍스트로 저장하고 VIRTUAL 생성 열에서 부동소수점으로 변환하고 있었는데, 3.52.0의 최적화가 텍스트→부동소수점 반올림 동작을 미세하게 바꿔버렸다. 카나리 샤드엔 해당 타임스탬프가 없어서 단계적 배포로도 못 걸렀다.

SQLite 개발진은 이 거짓 경고 때문에 3.52.0을 철회하고, WAL-Reset 수정만 담은 3.51.3을 냈다. 3.53.0에는 오래된 표현식 인덱스를 막는 자동 자체 복구 기능이 들어갔다. Tailscale은 타임스탬프 정밀도를 정수 초로 낮춰 모호하지 않은 텍스트-정수 변환으로 바꿨다.

여기서 실무 교훈 두 개. 첫째, 생성 열/표현식 인덱스는 "계산 방식이 언젠가 바뀔 수 있는가"를 기준으로 설계하라. 부동소수점 변환, 로케일·콜레이션 의존, 커스텀 함수 결과 위에 인덱스를 얹는 건 시한폭탄이다. 둘째, "온 김에" 다른 변경을 한 배포에 끼워 넣지 마라. 이번엔 크리티컬 버그 픽스와 최적화가 같은 릴리스에 있었고, 그 결과 픽스를 검증하려던 순간에 무관한 알람이 쏟아졌다.

대안도 짚어두자. 멀티 라이터가 필요하거나 쓰기 QPS가 계속 오른다면 애초에 SQLite가 답이 아니다(Postgres/MySQL). 반대로 SQLite를 유지하되 복제·백업을 자동화하고 싶다면 Litestream/rqlite/LiteFS 같은 선택지가 있는데, 이들도 결국 WAL과 체크포인트를 어떤 방식으로 다루는지가 안정성의 핵심이니 도입 전 체크포인트 운용 방식이 표준 경로인지 공식 문서로 확인하는 게 좋다.

5. 정리

한 줄 요약: SQLite에서 체크포인트를 직접, 공격적으로 제어하고 있다면 16년 된 WAL-Reset 레이스에 걸릴 수 있으니 3.51.3 이상으로 올리고, 백업마다 integrity_check를 돌려라.

누가 지금 확인해야 하나. (1) WAL 모드 + 수동 wal_checkpoint를 짧은 주기로 돌리는 서비스, (2) 한 프로세스 안에서 여러 커넥션을 열어 쓰기와 체크포인트를 나눠 처리하는 구조, (3) 백업을 뜨기만 하고 무결성 검증은 안 하는 파이프라인. 셋 다 해당한다면 오늘 안에 위 명령 두 개는 돌려보길 권한다.

마지막으로 개인적으로 가장 남는 대목. Tailscale은 오픈소스 유지보수자에게 공짜 수정을 요구하지 않고 SQLite 유료 지원 계약을 샀고, 그 과정에서 나온 tmstmpvfs shim은 오픈소스로 남아 다음 사람이 비슷한 버그를 잡는 데 쓰인다. "검증된 기술도 비표준으로 굴리면 위험하다"는 교훈과 함께, 문제 해결에 돈과 시간을 제대로 쓰는 방식의 좋은 사례다.

참고 자료

728x90

+ Recent posts