신뢰성을 가르쳐준 9가지 버그: 제로 비용 하이브리드 AI 시스템 사후 분석
요약
본 글은 $0 비용과 프로덕션 서비스 중단 금지라는 두 가지 엄격한 제약 조건 하에 로컬 및 클라우드 AI 에이전트를 구축하고 운영하는 경험을 다룹니다. 특히, 시스템의 '행복 경로'가 아닌 9가지 실제 버그 사례를 분석하며, 개발 과정에서 발생하는 신뢰성 문제와 세부적인 구현 노하우를 공유합니다.
핵심 포인트
- AI 에이전트 구축 시 $0 비용 제약 조건과 프로덕션 무중단 운영 원칙을 지키는 것이 중요합니다.
- 로컬 VM과 클라우드 항상 무료(always-free) VM을 활용한 하이브리드 토폴로지 설계가 핵심입니다.
- 시스템의 신뢰성은 복잡한 아키텍처보다 '지루한 세부 사항' 처리 능력에 달려있음을 보여줍니다.
- 자격 증명 관리와 같은 기본적인 배포 과정에서 발생하는 버그를 통해 실질적인 학습을 얻었습니다.
canonical_url: https://arielchangdev.github.io/angelina-finance-agent/
두 가지 엄격한 규칙 하에 로컬+클라우드 AI 시스템을 구축하는 과정 — 비용 $0, 그리고 프로덕션 서비스 중단 금지. 흥미로웠던 부분은 순조로운 경로(happy path)가 아니라, 9개의 프로덕션 버그와 각각의 버그가 신뢰성이 실제로 어디에 존재하는지에 대해 무엇을 가르쳐 주었는지였습니다.
전투 경험담 형식의 글입니다. 시스템을 설계하는 것은 재미있었습니다. 흥미로운 부분은 그것이 조용히 고장 난 9가지 방식과, 각각의 방식이 신뢰성이 실제로 어디에 존재하는지에 대해 무엇을 가르쳐 주었는지였습니다.
저는 두 가지 변하지 않는 규칙 하에 작은 AI 에이전트를 구축하고 운영하는 데 몇 달을 보냈습니다:
- 비용은 정확히 $0이어야 합니다. 모든 구성 요소는 제공업체의 항상 무료(always-free) 등급 내에서 작동하며, 위기 경보 장치 역할을 하는 $1 예산 알림이 설정되어 있습니다.
- 실행 중인 프로덕션 서비스에 결코 퇴보해서는 안 됩니다. 실제 사용자가 의존하는 라이브 일일 작업(daily job)이 존재합니다. 모든 변경 사항은 이미 작동하고 있는 것을 깨뜨리지 않고 배포되어야 했습니다.
그 두 가지 제약 조건이 이야기 전체였습니다. 돈이나 스테이징 클러스터를 문제에 투입할 수 없을 때, 지루한 세부 사항들을 제대로 처리하도록 강요받습니다. 이 게시물은 바로 그 지루한 세부 사항들에 관한 것입니다 — 구체적으로, 제가 싸워야 했던 9가지 프로덕션 버그를 정리하여, 독자들이 제가 다루지 않은 부분은 건너뛸 수 있도록 작성했습니다.
전체 아키텍처와 트레이드오프(trade-off)에 대한 글을 원한다면, 별도의 케이스 스터디에서 확인할 수 있습니다 (하단 링크 참조). 여기서는 시스템의 맥락을 간략하게 유지하고 단어들을 실패 사례에 할애할 것입니다.
시스템 개요를 한 문단으로
시스템 개요를 한 문단으로
이것은 자체 호스팅(self-hosted) AI 에이전트입니다 (Python + FastAPI, 무료 티어의 호스팅된 LLM, 검색을 위한 벡터 DB, 상태 관리를 위한 SQLite). 크론 작업(cron job)이 예약된 일일 작업을 실행하고, 그 결과를 공유 스프레드시트에 기록하며, 작은 지식 기반(knowledge base)을 동기화합니다. 세 번의 반복을 거치면서, 이 시스템은 하나의 홈 랩 VM에서 하이브리드 토폴로지로 확장되었습니다: 로컬 VM이 주된 역할을 하며 모든 실제 작업을 수행하고; 클라우드 항상 무료(always-free) VM은 수동적(passive)으로 대기하며 로컬 노드가 작동하지 않을 때만 인계받습니다. 조정은 공유 시트에 기록되는 하트비트(heartbeat)를 통해 이루어지며, 데이터는 토큰 게이트가 적용된 HTTPS 동기화 엔드포인트를 거쳐 이동합니다. 그게 전부입니다. 아래 내용은 이 두 노드, 제로 예산 설정을 올바르게 유지하는 것에 관한 것입니다.
일반적인 하루의 흐름은 대략 다음과 같습니다:
local VM ──(일일 작업 + 하트비트 스탬프)──▶ 공유 시트 ◀──(23:00 읽기)── cloud VM (수동)
└──────────────── 토큰 게이트 HTTPS 동기화 (last-write-wins) ────────────────┘
이제 재미있는 부분입니다.
버그 1 — 자체 자격 증명(credentials)을 망가뜨린 배포
증상. 저는 정리된,
버그 1 — 자체 자격 증명(credentials)을 망가뜨린 배포
시사점. 설정 파일은 코드가 아니다. 설정을 건드리지 않고도 코드를 배포할 수 있게 되는 순간, '최신 버전으로 배포'는 위험한 무기가 아니라 안전하고 지루한 작업이 된다. 이 단일 분리가 미래의 전체 범주에 걸친 사고를 예방했다.
버그 2 — Cron은 실행되었지만 내 환경 변수는 전혀 없었다
증상. 앱은 내가 수동으로 실행했을 때는 완벽하게 작동했다. 하지만 cron으로 트리거된 실행은 마치 설정이 존재하지 않는 것처럼 행동했다.
조사 결과. 전형적인 '내 셸에서는 되지만, cron에서는 안 되는' 경우였다. 나는 cron 프로세스가 실제로 본 환경 변수를 덤프해 보았다. 거의 비어 있었는데, 내가 .env에서 의존하는 변수들은 전혀 없었다.
근본 원인. cron 항목이 .env를 먼저 불러오지 않고 python을 직접 호출했다. cron은 최소한의, 로그인하지 않은 셸(non-login shell)에서 실행된다: 네가 사용하는 .bashrc, 프로필 파일, 내보낸 변수들 — 그 어떤 것도 함께 오지 않는다.
해결책. 배포를 시작하기 전에 cron 명령어 내부에서 환경을 불러와야 한다:
0 23 * * * cd /opt/app && . /opt/app/.env && /opt/app/venv/bin/python -m app. daily >> /var/log/app.log 2>&1
시사점. cron은 네가 사용하는 셸이 아니다. 작업이 환경에 의존한다면, 그 작업을 명시적으로 해당 환경을 로드하도록 만들어야 한다. 대화형 세션에서 아무것도 상속된다고 가정하지 마라.
버그 3 — '내 컴퓨터에서는 되는데'에 버전 번호가 붙다
증상. 로컬에서 깨끗하게 실행되던 코드가 클라우드 VM에서 SyntaxError를 발생시켰다. 이는 어떤 로직이 실행되기 전, import 시점에 발생했다.
조사 결과. 동일한 소스 코드에 대한 SyntaxError는 파서(parser)가 다르다는 것을 의미하고, 이는 인터프리터 버전이 다르다는 것을 의미한다. 로컬은 Python 3.12였고, 클라우드 VM은 3.11이었다.
근본 원인. 백슬래시를 포함하는 f-string 때문이었다. Python 3.12는 f-string 문법을 완화하여 이를 허용하지만, 3.11은 그렇지 않다. 같은 문자지만 다른 판결을 받았다.
해결책. 더 오래된 인터프리터에서도 유효하도록 표현식을 다시 작성해야 한다 — 백슬래시를 f-string에서 이름이 지정된 변수로 빼내어 — 모든 곳에서 파싱되게 만든다.
# 3.11에서는 구문 분석에 실패함
msg = f
⚠️ `[IMG:N]` 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
**핵심 요약.** '내 컴퓨터에서는 되는데'라는 말에는 항상 버전 번호가 붙어 있습니다. 환경 간 런타임(runtime)을 일치시키거나, 지원할 수 있는 가장 낮은 버전으로 작성해야 합니다. 런타임 패리티(Runtime parity)는 세부 사항이 아니라 계약(contract)의 일부입니다.
## 버그 4 — SQLite가 인덱스가 부분적(partial)이라서 upsert를 거부함
**증상.** 동기화 기록에 대한 `ON CONFLICT` upsert 작업이 완전히 실패했습니다. 오류 메시지는 충돌 대상 지점을 가리켰습니다.
**조사.** `sync_id` 컬럼에는 고유 인덱스(unique index)가 있었으므로, upsert는 이를 기준으로 삼을 중재자(arbiter)를 가져야 했습니다. SQLite 문서를 더 주의 깊게 읽어보니: upsert의 충돌 대상은 특정 종류의 인덱스와 매핑되어야 합니다.
**근본 원인.** 해당 고유 인덱스는 **부분적(partial)** 인덱스였습니다 (즉, `WHERE` 절을 가지고 있었습니다). SQLite는 부분적 인덱스를 upsert 충돌 중재자로 사용하지 않습니다. 이는 제 SQL의 버그라기보다는 '고유 인덱스'와 'upsert 중재자'가 같은 것이라는 저의 가정에 대한 오류였습니다.
**해결책.** 해당 인덱스를 **전체(full)** 고유 인덱스로 변환하고, 시작 시 기존 부분적 인덱스를 감지하여 기존 데이터베이스를 제자리에서 복구하는 자체 치유 마이그레이션(self-healing migration)을 추가했습니다.
-- upsert 중재자로 사용되지 않음
CREATE UNIQUE INDEX ix_sync ON records(sync_id) WHERE sync_id IS NOT NULL;
...
**핵심 요약.** upsert 중재자는 정밀한 요구 사항을 가지며, 엔진별로 다릅니다. 데이터베이스가 '되어야' 할 것을 거부할 때는 임시 방편(workaround)을 찾기 전에 정확한 제약 조건(constraint)을 읽어야 합니다.
## 버그 5 — 영원히 커지는 동기화 테이블 (네임스페이스 바운스)
**증상.** 매일 동기화할 때마다 대화 테이블의 크기가 커졌습니다. 새로운 활동 때문이 아니라, _같은_ 기록들이 증식했기 때문입니다.
**조사.** 저는 여러 번의 동기화 라운드를 거치며 개별 기록들을 추적했습니다. 로컬에서 시작된 기록은 클라우드에 나타났고, 다시 _로_ 로컬로 돌아왔으며, 또다시 클라우드로 나갔습니다. 이 모든 이동(hop)마다 약간씩 다른 ID를 가졌습니다. 시스템은 각 이동을 완전히 새로운 기록으로 간주하고 삽입했습니다.
**근본 원인(Root cause).** 내보낼 때마다 기록의 네임스페이스가 재설정되었습니다 (`local: N` → `cloud: M` → `local: P` → …). 신원이 매 왕복 주기마다 바뀌었기 때문에, 어떤 것도
이것이 제가 가장 좋아하는 사례입니다. 왜냐하면 버그가 미묘했고 증상이 이상했기 때문입니다.
**증상.** 장기간 로컬 시스템 장애가 발생했을 때, 클라우드 백업이 일일 작업을 전달했지만 격일로만 이루어졌습니다. 1일차: 전달됨. 2일차: 건너뜀. 3일차: 전달됨. 마치 메트로놈처럼요.
**조사.** 페일오버 로직은 각 성공적인 실행 후 로컬 노드가 찍는 하트비트를 읽습니다. 만약 로컬이 약 25시간 동안 침묵했다면, 클라우드는 이를 오프라인으로 선언하고 작업을 인계받습니다. 그럼 왜 진동(oscillation)이 발생했을까요? 저는 누가 하트비트를 작성하는지 살펴보았습니다. 클라우드가 페일오버를 할 때도 '로컬 존재' 하트비트를 찍고 있었던 것입니다.
**근본 원인. ⚒⚒** 페일오버 푸시 역시 로컬 존재 신호를 찍었습니다. 그래서 클라우드가 1일차를 커버한 후, 하트비트는 '로컬이 살아있다!'라는 신선한 상태가 되었고, 2일차에 클라우드는 충실히 건너뛰었습니다. 하지만 실제로는 로컬이 여전히 다운되어 있었기 때문에, 3일차에는 하트비트가 다시 오래된 것이었고, 클라우드는 또다시 페일오버를 했습니다. 존재 신호는 잘못된 주체가 작성했기 때문에 거짓말을 하고 있던 것입니다.
**수정.** 로컬 역할만이 로컬 존재 하트비트를 찍어야 합니다. 페일오버 푸시는 자신의 역할을 수행할 뿐, 로컬 하트비트는 건드리지 않아야 합니다. 이제 장기간의 장애는 지속적인 장애로 간주되며, 로컬이 돌아올 때까지 클라우드가 매일을 커버합니다.
**교훈.** 존재 신호는 반드시 그 존재를 나타내는 주체에 의해서만 작성되어야 합니다. 두 번째 주체가 다른 사람을 대신해 '나 여기 있어'라고 쓸 수 있게 되는 순간, 여러분의 생존 감지(liveness detection)는 오염됩니다. 이는 페일오버를 넘어 일반화되므로, '마지막으로 본 시간(last seen)' 필드가 있는 곳이라면 어디든 누가 그것을 건드릴 수 있는지 경계해야 합니다.
## 버그 8 — 일시적인 503 오류로 하루 전체가 손실되었습니다
**증상.** 어느 날은 출력이 전혀 없었습니다. 상위 모델 제공업체에서 작업이 실행되는 바로 그 순간에 짧은 문제가 발생했습니다.
**조사.** 로그에는 일일 주기 동안 `503` 에러가 짧게 터진 기록이 있었습니다. 제 재시도 로직이 너무 적은 시도로 인해 포기했고, 몇 분간 지속된 일시적인 장애가 하루 전체를 놓치는 결과를 초래했습니다.
**근본 원인(Root cause). "★ ★" 얇은 재시도(Thin retries).** 최소한의 백오프(backoff)를 가진 단일 예약 시도는 짧은 5xx 버스트(burst)조차 견딜 수 없으며, 하루에 한 번 실행되는 작업은 두 번째 기회 자체가 없습니다.
**수정 사항(Fix).** 재시도/백오프 로직을 강화했습니다: 최대 백오프를 적용한 5번의 시도(대략 20/40/60/90/120초)로, `500/502/503/504/429` 및 네트워크 오류를 처리합니다.
try 1 → 503 → 20초 대기
try 2 → 503 → 40초 대기
try 3 → 200 ✓ (제시간에 전달됨)
**시사점(Takeaway).** 간헐적으로 실행되는 중요한 작업의 경우, 재시도 로직은 선택적인 개선 사항이 아니라
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기