본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 27. 11:46

주식 스크리너를 '바이브 코딩(Vibe-Coding)'으로 배포했다가 2GB 서버가 OOM(Out of Memory)을 일으키고

요약

AI 에이전트를 활용한 '바이브 코딩'으로 주식 스크리너를 개발한 비전공자 개발자의 운영 실패 사례를 다룹니다. 2GB 서버 환경에서 발생한 OOM(Out of Memory) 문제와 이로 인해 Google SEO가 급락한 경험을 기술합니다.

핵심 포인트

  • AI 에이전트를 활용한 95% 수준의 코드 생성 가능성 확인
  • 용량 계획 및 메모리 관리 등 운영(Ops)의 중요성 강조
  • 서버 자원 부족(OOM)이 검색 엔진 최적화(SEO)에 미치는 치명적 영향
  • 인디 개발자를 위한 기술 스택(Next.js, FastAPI, SQLite) 구성

시리즈 서론. 저는 AI 에이전트를 활용한 거의 전적인 "바이브 코딩 (Vibe-coding)"을 통해 실제 서비스 중인 주식 스크리너를 구축하고 출시한 비전공자 1인 개발자입니다. 사이트는 작동합니다. 사용자들도 이용합니다. 그리고 제가 취했던 모든 지름길은 실제로 대가를 치르게 했습니다. 이 시리즈는 그 대가들을 솔직하게 기록합니다. 무엇이 망가졌는지, 왜 그랬는지, 이를 해결하기 위해 무엇을 배포했는지, 그리고 다시 한다면 무엇을 다르게 할 것인지에 대해 다룹니다. 파트 1은 여전히 쓰라린 기억입니다. Google이 막 저를 주목하기 시작했을 때, 서버의 OOM (Out of Memory) 발생으로 인해 SEO (검색 엔진 최적화)가 완전히 망가져 버린 사건입니다.

내가 만든 것

StockDigging은 한국 시장 (KOSPI + KOSDAQ)과 미국 시장 (NYSE + NASDAQ)을 아우르는 무료 주식 스크리너 및 순위 사이트로, 총 약 5,600개의 활성 티커 (Ticker)를 다룹니다. 모든 가치 평가 지표 (PER, PBR, 시가총액 등)는 당일 종가 × 최신 재무제표를 통해 매일 재계산됩니다. 오래된 스냅샷이나 중간 집계업체(Aggregator)는 없습니다.

기술 스택은 전형적인 인디 개발자의 구성입니다:

  • Frontend (프론트엔드): Next.js 16 (App Router, Turbopack), React 19, TypeScript
  • Backend (백엔드): Python 3.14 기반의 FastAPI
  • DB (데이터베이스): SQLite (디스크 용량 약 2.3 GB, 단일 파일)
  • Hosting (호스팅): 단일 2 GB Vultr VPS
  • Edge (엣지): 캐싱 및 DDoS 방지를 위한 Cloudflare

저는 코드를 직접 손으로 작성한 비중이 아마 5% 정도밖에 되지 않는 상태에서 첫 번째 공개 버전을 출시했습니다. 나머지 95%는 AI 에이전트와 함께 생성하고, 검토하고, 반복하며 만들어냈습니다. 그 부분은 실제로 효과가 있었습니다. AI는 끈기 있고 인내심 있는 페어 프로그래밍 파트너입니다. 하지만 제대로 작동하지 않은 부분은 바로 _운영 (Operations)_이었습니다. 구체적으로는 용량 계획 (Capacity planning), 메모리 관리 (Memory hygiene), 그리고 하루에 세 번씩 프로덕션에 푸시하고 싶은 충동을 참는 절제력이었습니다.

이 포스트는 그 실수가 불러온 최악의 단일 결과, 그리고 그만큼 중요하게 다룰 후속 조치에 관한 이야기입니다.

Google이 나를 잘못 인식한 날

5월 중순경, 제 Google Search Console 그래프가 모든 인디 개발자가 두려워하는 현상을 보였습니다. 꾸준히 상승하던 노출수(Impressions)가 낭떠러지 아래로 떨어졌습니다. 광범위한 쿼리에 걸쳐 평균 순위(Average position)가 하락했습니다. 1페이지에 나타나던 페이지들이 조용히 3페이지, 4페이지로 밀려났고, 나중에는 아예 나타나지 않게 되었습니다.

직접 사이트를 확인했을 때는 멀쩡해 보였기 때문에 즉시 알아차리지 못했습니다. 그저 제 눈에만 멀쩡해 보였을 뿐입니다. 크롤러(Crawler)는 전혀 다른 경험을 하고 있었습니다.

이달 초, FastAPI 백엔드가 메모리 부족 현상을 겪었습니다. 아주 심각하게 말이죠. 제가 작성한 여러 인메모리 캐시(In-memory caches) — 순위, 통계, 인덱스를 위한 TTL(Time-To-Live) 키 기반의 딕셔너리(dict)들 — 가 제한이 없었습니다(unbounded). 모든 고유한 쿼리 조합이 항목을 추가했습니다. 항목들이 기술적으로는 만료되긴 했지만, 만료 사이에 이를 제거(evict)하는 장치가 없었습니다. 딕셔너리는 계속 커졌습니다. 상주 메모리(Resident memory)가 2GB VPS가 단일 Python 프로세스에 할당할 수 있는 수준을 넘어섰고, OOM 킬러(OOM killer)가 작동했으며, systemd가 서비스를 재시작했습니다.

제 대시보드에서는 이것이 짧은 일시적 현상(blip)처럼 보였습니다. 하지만 Googlebot의 관점에서는

지나고 보니, 이 중 그 어떤 것도 AI의 예측이 필요하지 않았습니다. 이 모든 것은 시스템 설계 (Systems Design)의 정석에 들어있는 내용입니다. 제가 '바이브 코딩 (Vibe-coding)'을 하는 동안 그 정석의 해당 부분을 읽지 않았을 뿐입니다.

1. 경계가 없는 인메모리 캐시 (Unbounded in-memory caches). 총 6개가 있었습니다. 각각은 처음에는 "이 값비싼 쿼리를 5분 동안 메모이제이션 (Memoize) 하자"라는 합리적인 생각으로 시작되었으나, 쿼리 파라미터를 추가함에 따라 몇 달에 걸쳐 커졌습니다. 캐시 키 (Cache key)는 더 넓어졌고, 엔트리 (Entry) 수는 더 많아졌으며, 크기를 제한하는 장치는 전혀 없었습니다. 최대 크기가 지정된 LRU (Least Recently Used)를 사용했다면 캐시당 코드 한 줄만 더 추가하면 되었을 것입니다.

2. 실제 워크로드를 감당하기에 2GB VPS는 너무 작음. Python + SQLite + Next.js + 상당한 양의 프로세스 내 상태 (In-process state) 조합은 2GB 규모의 워크로드가 아닙니다. 한가한 날에도 2GB에 간신히 들어맞는 수준입니다. 캐시 누수 (Cache leak), 긴 배치 작업 (Batch job), 갑작스러운 트래픽 급증 등 무언가 잘못되는 순간, 여유 공간 (Headroom)은 전혀 없습니다. 저는 첫날부터 이 사실을 알고 있었지만, 월 6달러는 월 6달러였기에 그냥 배포했습니다.

3. 메모리 모니터링의 부재. 로그는 있었습니다. 요청 메트릭 (Request metrics)도 있었습니다. 하지만 시간에 따른 RSS (Resident Set Size)를 보여주는 차트는 단 하나도 없었습니다. 만약 그 차트 하나만 보고 있었더라도, OOM (Out of Memory) 임계값에 도달하기 몇 주 전부터 수치가 상승하는 것을 보았을 것입니다.

4. 크롤러 활동 시간에 배포. 제 배포 스크립트는 롤백 (Rollback) 기능이 포함된 원자적 교체 (Atomic swap) 방식을 사용하여 사용자에게는 "무중단 (Zero downtime)"입니다. 하지만 크롤러에게는 배포 중 발생하는 단 몇 초간의 캐시 제거 (Cache eviction)와 CSR (Client-Side Rendering) 경로의 청크 단위 재빌드 (Chunked rebuild)만으로도 저하된 경험으로 기록되기에 충분합니다. 저는 하루에 두세 번씩 배포를 진행했고, 종종 Googlebot이 활발하게 활동하는 시간에 배포하곤 했습니다.

5. AI가 이를 지적해 줄 것이라는 믿음. 이 부분이 제가 가장 솔직해지고 싶은 지점입니다. 저는 코딩을 잘하는 에이전트라면 "이 딕셔너리 (Dict)에는 상한선이 없다"와 같은 아키텍처상의 악취 (Architectural smells)도 잡아낼 것이라고 가정했습니다. 하지만 기본적으로 AI는 그렇지 않습니다. AI는 당신이 요청한 대로 코드를 작성합니다. 만약 당신이 "1년 치 트래픽을 견딜 때 이 구조가 도달할 수 있는 최대 크기는 얼마인가?"라고 묻지 않았다면, 그 답을 얻을 수 없습니다.

그 이후로 제가 실제로 배포한 것들 (진짜 작업)

여기서 저는 구체적으로 말씀드리고 싶습니다. 대부분의 사후 분석(Postmortem)은 근본 원인(Root cause)에서 멈추고, 정작 일정의 대부분을 차지하는 부분인—인내심을 요하고 화려하지 않은—수정 작업 단계는 건너뛰기 때문입니다. 제가 실제로 배포한 것들은 대략 다음과 같은 순서로 진행되었습니다.

1. systemd의 4시간 RuntimeMaxSec 하한선 설정. 수정 사항을 말씀드리기 전에 고백하자면, 모든 메모리 누수(Leak) 지점을 파악하기도 전에 백엔드를 4시간마다 강제로 재시작하는 systemd 지시어(Directive)를 추가했습니다. 이것은 근본적인 해결책은 아닙니다. 아직 찾아내지 못한 누수로 인해 발생할 수 있는 피해를 제한하는 천장(Ceiling)일 뿐입니다. 또한 비용이 전혀 들지 않으며 20분 만에 완료되었습니다. 만약 여러분이 이러한 설정 없이 작은 VPS(가상 사설 서버)에서 상태 유지(Stateful)가 필요한 서비스를 운영 중이라면, 오늘 밤 바로 추가하십시오.

2. 일일 배치 작업(Daily batch jobs)을 위한 와치독(Watchdog) 크론(Cron). 저의 데이터 파이프라인은 매일 밤 가격과 재무 데이터를 가져옵니다. OOM(Out of Memory) 이벤트 이후, 백엔드가 배치 작업의 잠금(Lock) 시간 동안 재시작되면서 해당 배치들이 조용히 건너뛰어지는 현상이 발생했습니다. 저는 누락된 배치를 감지하고 더 가벼운 코드 경로(Code path)로 재실행하는 와치독 크론을 추가했습니다. 그러다 다음 주에 바로 와치독을 수정해야 했습니다. 와치독의 첫 예정된 실행(Tick)이 메인 배치보다 5분 먼저 실행되어 잠금을 가로채는 바람에, 안전망이 오히려 문제의 원인이 되어버렸기 때문입니다. 이 이야기는 별도의 포스트로 다루겠습니다.

3. 모든 핫 리드 경로(Hot read path)를 위한 정적 JSON. 이것은 단일 아키텍처 변경 사항 중 가장 컸으며, 유사한 스택을 운영하는 누구에게나 추천하고 싶은 방식입니다. 홈페이지와 랭킹 페이지가 매 요청마다 API를 호출하는 대신, 이제 야간 배치 작업이 해당 뷰(View)들을 data/rankings/{market}_{sort}.json 파일로 미리 계산(Precompute)해 둡니다. Next.js 서버는 SSR(서버 사이드 렌더링) 중에 이 JSON을 직접 읽습니다. 가장 트래픽이 많은 페이지들에 대해서는 API에 전혀 접근하지 않게 됩니다.

flowchart LR
    subgraph Before["Before — 모든 페이지 = DB 히트"]
      U1[User / Googlebot] --> CF1[Cloudflare]
...

Google 크롤링 도중에 백엔드에서 OOM(Out of Memory)이 발생하더라도, Google이 중요하게 여기는 페이지들은 여전히 JSON 파일로부터 정확한 데이터를 제공합니다. "백엔드가 불안정하다"는 문제의 영향 범위(Blast radius)가 "사이트 전체의 성능 저하"에서 "인기가 적은 롱테일(Long tail) 상세 페이지의 성능 저하"로 축소되었습니다. 이는 임시방편이 아닌, 진정한 아키텍처 측면의 승리입니다.

4. 공개 API를 호출하는 배치 후 검증 (Post-batch validation). 제가 인정해야 했던 별도의 실패 사례가 있습니다. 제 배치 작업(Batch jobs)들은 생성된 데이터가 잘못되었을 때도(한 섹터에서 약 10^9개의 주식에 대한 지표가 조용히 누락되었을 때) 즐겁게 "성공"을 보고했습니다. 이제는 모든 배치가 완료된 후, 검증 스크립트가 각 시장 × 정렬(Sort) 조합에 대해 사용자가 접속하는 것과 동일한 엔드포인트로 실제 HTTP 호출을 수행합니다. 만약 특정 조합이 0개의 행을 반환하거나, 30일 기준치(Baseline)보다 눈에 띄게 적은 수를 반환하면, 행 수(Row counts)가 무엇을 말하든 상관없이 해당 배치는 실패로 표시되고 저에게 이메일이 발송됩니다. 이 검증기는 첫 달에 두 번의 실제 회귀(Regression)를 잡아냈습니다.

5. 매일 밤 실행되며 이상 징후가 보이면 이메일을 보내는 "데이터 상태(Data health)" 체크. 동일한 데이터를 노출하는 내부 관리자 페이지를 운영하고 있지만, 더 중요한 부분은 그 뒤에 있는 크론 잡(Cron job)입니다. 즉, 매일 밤 배치 작업이 끝난 후 실행되어 시장별로 수십 개의 특정 불변량(Invariants)을 확인하는 스크립트입니다. 실패 시에는 저에게 이메일이 오고, 경고(Warning)는 나중에 검토할 수 있도록 로그로 기록됩니다. 전형적인 밤의 실행 모습은 다음과 같습니다:

$ python -m scripts.automation.data_health_check
data_health_check — 2026-05-26 22:00 KST
─────────────────────────────────────────────────────────────
...

각 줄은 제가 실제로 저질렀거나 목격한 실수에 해당합니다. financial_margin_impossible이 존재하는 이유는 한 섹터의 매출 항목이 잘못 분류되어 증권사의 영업이익률(operating margin)이 잠시 73%로 표시되었기 때문입니다. override_staleness가 존재하는 이유는 금융 섹터 매출에 대해 제가 직접 관리하는 오버라이드(override) 파일이 있고, 이를 1년에 한 번씩 업데이트하지 않으면 잊어버리기 때문입니다. top50_mcap_match가 존재하는 이유는 한때 사이트에서 가장 방문자가 많은 페이지의 시가총액(market cap)을 조용히 망가뜨리는 배포(deploy)를 진행한 적이 있기 때문입니다. 각 체크 항목은 하나의 흉터입니다.

6. 비상 재패치(repatch) 스크립트 — 단 한 번의 명령으로 완전 복구. 공개 사이트에서 무언가 잘못되었을 때, 기존의 복구 방식은 다음과 같았습니다: 백엔드 중지, 패치 실행, 재시작, JSON 재생성, 통계(stats) 재생성, 주식 상세 JSON 재생성, 에지 캐시(edge cache) 퍼지, 검증. 대략 10단계에 달하며, 하나를 빼먹기 쉽고, 주말 밤 11시에 작업하기에는 매우 오류가 발생하기 쉬운 방식이었습니다. 저는 이를 모든 단계를 순서대로 실행하고, 실패 시 즉시 중단(fail fast)하며

8. 페이지 1 경계(edge-of-page-1) 쿼리에 대한 Title/meta 마이크로 튜닝. 사이트 자체가 안정화됨에 따라, SEO(검색 엔진 최적화) 복구는 이제 단순히 기다리는 것이 아니라 능동적인 프로젝트가 되었습니다. 저는 Google Search Console 데이터를 추출하여, 제 페이지들이 순위 5~15위(작은 문구 변경만으로도 순위를 올릴 수 있는 "페이지 1의 경계" 구역)에 머물고 있는 쿼리들을 식별했고, 해당 특정 페이지들의 제목(Title)과 메타 설명(Meta description)을 다시 작성했습니다. 저는 각 변경 사항을 최적화 로그(Optimization log)에 기록하고 매주 순위를 확인합니다. 화려하지는 않습니다. 천천히 진행 중입니다.

출시 전에 했어야 했던 일들

실제 SEO 목표를 가지고 자신이 '바이브 코딩(Vibe-coding)'으로 만든 첫 결과물을 실제 도메인에 배포하려는 분들을 위해, 제가 모니터에 붙여두었더라면 좋았을 목록을 정리했습니다:

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0