6개의 AI 시스템을 라이브로 배포하며 겪은 실제 장애 사례
요약
로컬 환경에서 완벽하게 작동하던 AI 시스템이 클라우드 배포 시 발생하는 다양한 장애 사례를 다룹니다. 라이브러리 버전 불일치와 Git LFS 설정 문제 등 코드 외부 요인으로 인한 배포 실패 원인과 해결책을 제시합니다.
핵심 포인트
- 라이브러리 버전 고정(Pinning)을 통해 배포 환경의 의존성 문제를 방지해야 함
- 로컬 환경과 클린 설치 기반의 배포 환경 간의 차이를 인지해야 함
- Git LFS 사용 시 클라우드 배포 환경에서 실제 바이너리 파일이 포함되었는지 확인 필요
6개의 AI 시스템을 라이브로 배포하며 겪은 실제 장애 사례
몇 주 전, 저는 49개의 AI 시스템을 구축하며 60시간 이상의 시간을 허비하게 만든 5가지 버그에 대해 글을 썼습니다. 그 버그들은 모두 코드 자체 내에 존재했습니다. 잘못된 배열 레이아웃(array layout), 이름이 변경된 모델 클래스(model class), 직렬화 불일치(serialization mismatch) 같은 것들이었죠.
이 글은 그 이야기의 후반부이며, 저에게 더 불편한 사실 하나를 가르쳐 주었습니다. 당신의 로컬 머신에서 완벽하게 작동하는 코드가, 코드와는 전혀 상관없는 이유로 머신을 떠나는 순간 완전히 실패할 수 있다는 점입니다.
저는 제가 GitHub에 고정(pinned)해 둔 6개의 프로젝트를 가져와 Streamlit Cloud에 모두 라이브로 배포했습니다. 로컬에서는 6개 모두 단 하나의 오류 없이 작동했습니다. 하지만 배포를 진행하자 제가 이전에 한 번도 본 적 없는 5가지 실패 사례가 나타났으며, 그중 어느 것도 제 로직의 버그는 아니었습니다.
제가 마주친 순서대로 정리해 보겠습니다.
실패 1 — 어제는 존재했던 모듈이 오늘 사라짐
제 RAG 챗봇은 몇 주 동안 변경 없이 다음 임포트(import) 문을 사용해 왔습니다:
from langchain.chains import ConversationalRetrievalChain
로컬: 작동함. 배포: 즉시 충돌.
ModuleNotFoundError: No module named 'langchain.chains'
원인은 제 코드와 아무런 상관이 없었습니다. 제 로컬 환경에는 몇 달 전에 설치된 오래된 LangChain 캐시 버전이 설치되어 있었습니다. 배포 환경은 클린 설치(clean install)를 수행하며 그 순간의 최신 버전을 가져왔는데, 최근의 LangChain 릴리스(releases)에서 이와 같은 레거시 체인 클래스(legacy chain classes)들을 코어 패키지에서 완전히 제외해 버린 상태였습니다.
실제로 효과가 있었던 해결책: 배포 압박 속에서 새로운 API 패턴을 쫓기보다는, 해당 클래스를 여전히 포함하고 있는 정확한 버전을 고정(pin)하는 것이었습니다:
langchain==0.3.7
langchain-community==0.3.7
교훈: "내 컴퓨터에서는 잘 된다"라는 말은, 역설적으로 당신의 컴퓨터가 최근에 아무것도 재설치하지 않았기 때문에 참일 때가 많습니다. 클린 배포 환경은 그런 사치를 누릴 수 없습니다. 빌드되는 순간 가장 최신 것을 가져오게 됩니다. 새벽 1시에 이 문제를 디버깅해야 하는 상황이 오기 전에 버전을 고정하세요.
실패 2 — 존재하지만, 어느 순간 사라지는 파일
제가 구축한 RAG (Retrieval-Augmented Generation) 프로젝트는 디스크에서 미리 빌드된 FAISS 벡터 인덱스를 로드합니다.
vectorstore = FAISS.load_local("faiss_index", embedding, allow_dangerous_deserialization=True)
로컬 환경에서는 즉시 로드됩니다. 하지만 배포 환경에서는 FAISS의 C++ 바인딩(binding) 깊은 곳에서 원시 크래시(raw crash)가 발생하며, 깔끔한 Python 트레이스백(traceback)조차 남기지 않습니다. 구글링을 해도 아무런 정보를 얻을 수 없는 종류의 실패입니다.
실제 원인은 Git LFS였습니다. 제 인덱스 파일은 Git LFS를 통해 조용히 저장되어 있었는데, Git LFS는 실제 바이너리 대신 git 히스토리에 아주 작은 텍스트 포인터(pointer)만을 유지합니다. 로컬에서는 제 LFS 클라이언트가 해당 포인터를 실제 파일로 조용히 해결(resolve)해주었기 때문에 전혀 눈치채지 못했습니다. 하지만 클라우드 플랫폼의 git clone은 텍스트로 된 수백 바이트의 포인터 파일만을 가져왔고, 바이너리 인덱스를 기대하던 FAISS에 이를 전달했습니다.
해결 방법:
git lfs untrack "faiss_index/*"
git rm --cached faiss_index/index.faiss
git add faiss_index/index.faiss
...
교훈: Git LFS는 로컬 머신에서 올바르게 작동할 때 정확히 보이지 않습니다. 이를 사용하고 있었다는 사실을 깨닫게 되는 유일한 순간은, 이를 지원하지 않는 곳에 처음으로 배포할 때뿐입니다.
실패 3 — 동일한 에러 메시지를 사용하는 두 가지 서로 다른 크기 제한
GitHub 웹사이트를 통해 83MB 크기의 PyTorch 모델 체크포인트를 업로드했을 때 다음과 같은 메시지를 받았습니다:
Yowza, that's a big file. Try again with a file smaller than 25MB.
저는 단순히 GitHub가 그 정도 크기의 파일을 수용할 수 없다고 가정했습니다. 하지만 GitHub는 수용할 수 있습니다. 웹사이트의 드래그 앤 드롭(drag-and-drop) 방식에는 25MB라는 상한선이 있지만, 커맨드 라인(command line)에서의 git push에는 완전히 별개인 100MB 상한선이 존재합니다. 동일한 플랫폼임에도 어떤 문으로 들어가느냐에 따라 두 가지 서로 다른 제한이 적용되는 것입니다.
해결 방법:
git add model.pth
git commit -m "Add trained model checkpoint"
git config --global http.postBuffer 157286400
...
해당 postBuffer 설정은 특히 50~100MB 범위의 파일에 중요합니다. 이 설정이 없으면 느린 연결 환경에서 더 큰 푸시(push) 작업이 전송 도중 조용히 타임아웃(time out)될 수 있습니다.
교훈: 플랫폼의 문서화된 제한(documented limit)과 UI에서 강제하는 제한(enforced limit)이 항상 같은 숫자인 것은 아닙니다. 무언가 의심스러울 정도로 딱 떨어지는 임계값(threshold)에서 실패한다면, 실제 플랫폼 제한에 도달한 것인지 아니면 우연히 사용 중인 특정 인터페이스의 임의적인 제한에 걸린 것인지 확인하십시오.
장애 4 — 묻지도 않고 플랫폼이 바뀌어 버렸다
이번 배포 스프린트(deployment sprint) 중간에, 며칠 동안 잘 작동하던 앱이 갑자기 torchvision 누락과 transformers 내부 깊숙한 곳에서 연쇄적으로 발생하는 수십 개의 경고 메시지와 함께 임포트(import) 오류의 벽을 드러내며 깨져버렸습니다.
제 코드에는 아무런 변화가 없었습니다. 변한 것은 제 배포 플랫폼이 제가 고정(pin)해둔 것보다 더 최신 버전의 Python 버전을 조용히 선택했다는 점이었고, 제가 사용하는 여러 의존성(dependencies)들이 아직 그 버전에 호환되는 빌드(build)를 갖추지 못해 발생한 문제였습니다.
실제로 효과가 있었던 해결책: 고정(pin) 설정을 신뢰성 있게 준수하지 않는 플랫폼을 상대로 특정 Python 버전을 고정하려고 애쓰는 것을 중단했습니다. 대신, 엄격하게 필요하지 않은 모든 무거운 컴파일된 의존성(compiled dependency)을 제거했습니다. 지식 베이스(knowledge base)가 벡터 스토어(vector store) 대신 프롬프트(prompt)에 직접 담을 수 있을 정도로 충분히 작다면 torch, transformers, faiss 등이 필요 없습니다. 단 세 줄로 구성된 requirements 파일:
streamlit==1.40.0
requests==2.32.3
python-dotenv==1.0.1
이렇게 하면 플랫폼별로 컴파일된 휠(wheels) 파일이 포함된 것이 없으므로, 이런 방식으로 깨질 일이 없습니다.
교훈: 관리형 플랫폼(managed platform)이 런타임(runtime)을 제어할 때, 가장 회복 탄력성(resilient) 있는 전략은 모든 변수를 고정하려고 싸우는 것이 아니라, 애초에 의존하는 변수의 개수를 최소화하는 것입니다.
장애 5 — 성공했지만 당신이 찾는 곳에는 없는 푸시(push)
다섯 가지 장애 중 가장 당혹스러운 실패였습니다. git push는 파일이 작성되었고, 오류가 없으며, 깔끔하게 종료되었다고 성공을 보고했습니다. 하지만 그 이후 GitHub 어디에서도 해당 파일을 볼 수 없었습니다.
git branch -a
* master
remotes/origin/main
제 저장소는 GitHub에 이미 기본 main 브랜치가 생성된 상태로 만들어졌습니다. 저는 그동안 로컬에 존재하고 이제는 원격에도 존재하게 된 master 브랜치에 커밋하고 푸시(push)해 왔던 것입니다. 이 브랜치는 main과 병렬로 놓여 있었으며, 제가 확인하던 페이지에는 전혀 나타나지 않았습니다.
해결 방법:
git push origin master:main
또는, 앞으로는 GitHub가 기본적으로 보여주는 브랜치에 직접 커밋하면 됩니다.
교훈: 성공적인 푸시(push)는 당신의 노트북과 원격 저장소가 서로 일치함을 확인해 줄 뿐입니다. 그것이 목적지가 브라우저 탭에서 사람이 보고 있는 바로 그곳인지에 대해서는 아무것도 확인해 주지 않습니다.
다섯 가지 사례 전체에 걸친 패턴
이 중 그 어떤 것도 로직 버그(logic bug)가 아니었습니다. 제 코드는 모든 경우에 정확했습니다. 모든 실패는 제가 동일하다고 가정했지만 실제로는 그렇지 않았던 두 환경 사이의 간극에서 발생했습니다.
- 캐시된 의존성(cached dependencies) vs. 새로 설치된 의존성(fresh install)
- 로컬 LFS 해결(LFS resolution) vs. LFS를 건너뛰는 클론(clone)
- UI의 제한 vs. 프로토콜(protocol)의 제한
- 요청한 런타임(runtime) vs. 실제로 할당받은 런타임(runtime)
- 내가 입력 중인 브랜치 vs. 표시되고 있는 브랜치
"로컬에서는 잘 돌아간다"는 말은 하나의 특정 환경에 대한 주장일 뿐입니다. 배포(deployment)란 그 주장이 조용히 기반하고 있었던 모든 가정을 발견해 나가는 과정입니다.
다음을 위한 짧은 체크리스트
다시 무언가를 배포하기 전에, 저는 이제 다음 사항들을 확인합니다:
- 의존성 버전이 범위가 아닌 정확한 숫자로 고정(pinned)되어 있는가?
- 이 저장소의 무언가가 Git LFS에 의존하고 있는가 — 그리고 내 배포 대상이 이를 지원하는가?
- 커밋된 파일 중 플랫폼의 크기 제한에 근접한 것이 있는가? 그렇다면 어떤 제한인가 — UI인가 아니면 프로토콜인가?
- 무거운 컴파일된 의존성(compiled dependency)의 버전을 고정하려고 애쓰는 대신, 이를 제거할 수 있는가?
git branch -a를 실행했을 때, 내가 푸시하려는 브랜치가 정확히 하나만 나타나는가? 옆에 조용히 숨어 있는 두 번째 브랜치는 없는가?
배포 후에 발견하는 대신, 배포 전에 던지는 다섯 가지 질문과 30초의 시간입니다.
6개의 시스템 모두 라이브 상태이며 오픈 소스입니다:
🔗 github.com/Danish08654
만약 여러분이 실제 코드와는 전혀 상관없는 배포 실패를 경험한 적이 있다면, 그 이야기를 듣고 싶습니다. 댓글로 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기