최근 회사에서의 비효율을 개선하기 위한 개인 프로젝트를 개발했습니다. 법인카드 경비 신청서 도우미 서비스입니다.
저희 회사는 개인마다 법인카드를 제공합니다. 야근 식대나, 택시비에 사용할 수 있습니다. 월 초에는 이전 월의 한 달 사용 내역을 조회해 재무회계팀의 양식에 맞춰 넣고, 관련 결제 내역과 영수증을 편집해서 처리해야 합니다. 내역의 구분과 영수증의 처리 과정은 상당히 번거롭습니다. 그래서 만들게 되었죠.
대부분의 기능들은 금방 해결이 되었지만, 이미지의 편집 및 정해진 셀의 위치를 계산하여 중앙 정렬하는 로직을 구현하는 과정에서 사용한 라이브러리가 문제가 되었습니다. 이 라이브러리의 사용을 위해 .xlsx 확장자의 결과물 엑셀 파일로 추출되어야 하는데, 회사에서 제공하는 엑셀 양식은 .xls 확장자거든요.
제 서비스를 사용한다면 .xls가 아니라 .xlsx의 확장자 파일로 올리기 때문에 재무회계팀의 관행과 달라 문제가 될 수도 있었습니다.
보통 로컬머신에서 사용하는 '다른 이름으로 저장' > '확장자 변경'으로는 깨짐 없이 .xls로 바뀌지만, 이게 생각보다 코드적으로는 어려웠습니다. 결국 LibreOffice 같은 컨버터를 거쳐야 했어요.
근데 이게 또 문제를 만들었습니다. LibreOffice는 300~500MB짜리 헤비급 의존성이라 Vercel 서버리스 함수의 250MB 한계를 한참 넘습니다. 결국 Docker 환경이 필요한데, 그걸 어디서 돌리지? 가 이 글의 출발점입니다.
블로그에서는 처음 공개하지만, 개인 DB서버 용도로 사용하던 AWS EC2의 비용을 개선하고자 세팅해둔 홈서버가 있습니다. 이걸 쓰면 어떨까? 생각했습니다. 변환 한 번에 4초 정도 쓰는 가벼운 워크로드라 충분해 보였거든요. 그런데 생각해보니 부담이 꽤 있었습니다.
- 보안: 아직 홈서버의 네트워킹 보안, 포트포워딩이나 리버스 프록시, 방화벽 등의 설정을 깊게 하진 않았습니다. fail2ban 정도의 최우선 안전장치만 뒀어요.
- 안정성: 정전, 인터넷 장애 한 번이면 서비스가 죽습니다. 지인용 사이드 프로젝트라 해도 이제부턴 슬슬 조심스럽더라고요.
"Docker 컨테이너만 빌려주는 클라우드를 쓰자"고 AI 친구가 추천해줬고, 그렇게 Cloud Run을 만나게 되었습니다.
IaaS / PaaS / CaaS / FaaS
클라우드 서비스는 어디까지를 빌리느냐로 단계가 나뉩니다.
정보처리기사를 취득할 때 살짝 공부했던 개념이 있어 조금 정리하고 가겠습니다.
https://cloud.google.com/learn/paas-vs-iaas-vs-saas?hl=ko한눈에 그림으로 보면 이렇습니다.
제 케이스, LibreOffice 같은 헤비 바이너리 + 짧은 변환 작업에는 CaaS가 정확히 맞아떨어졌어요. 컨테이너로 패키징해서 통째로 올리고, 요청이 올 때만 깨어나면 되니까요.
다만 같은 CaaS 안에서도 과금·실행 모델은 서비스마다 많이 다르겠죠. 이걸 좀 비교해보겠습니다.
비교한 건 네 군데입니다. 최종 선택한 Cloud Run만 직접 운영해봤고, Fly.io · Render · Railway는 후보군에 두었지만 사용하지 않아서 공식 문서·요금표 기준으로 정리했습니다.
요청 기반 + 진짜 scale-to-zero
GCP가 만든 컨테이너 서버리스입니다. 모델이 단순합니다. 요청이 들어올 때만 컨테이너를 깨우고, 그 시간만큼 vCPU·메모리·요청 수로 청구해요. 트래픽이 없으면 인스턴스도 0, 청구도 0인 효율적인 모델입니다.
Always-Free 한도가 정말 후합니다. (Pricing)
사이드 프로젝트 트래픽이라면 이 안에서 벗어나기가 힘듭니다.
콜드스타트는 언어와 이미지 크기에 따라 천차만별입니다. ahmetb의 비공식 Cloud Run FAQ나 GCP 공식 가이드를 보면, Go·Rust 같은 정적 바이너리는 200ms 미만, Node·Python은 0.5~2초, Java/Spring Boot는 3~10초까지 갑니다.
저는 LibreOffice 컨테이너에서 7초쯤 걸렸는데, Docker 이미지가 500MB에 육박해 부팅 자체가 무거워서였어요.
머신 기반 + 글로벌 엣지
Fly.io는 결이 다릅니다. "컨테이너를 35개 이상의 리전에 분산 배치해 사용자에게 가까운 곳에서 실행한다"는 것이 핵심 컨셉입니다. 머신(Machine)이라는 micro-VM을 켜고, 사용한 만큼 시간 단위로 과금됩니다. Cloud Run의 "요청당" 모델과는 결이 달라요.
기본은 머신을 켜둔 상태이지만 auto_stop_machines 옵션으로 idle 시 정지시킬 수 있고, 다음 요청에 자동으로 다시 켜집니다(scale-to-zero 비슷한 효과).
하지만 Fly.io는 영구 free tier가 없습니다.
2024년 10월의 큰 변화가 있었어요. Fly.io는 그동안 운영하던 Hobby/Launch/Scale 플랜을 모두 폐지하고 순수 pay-as-you-go로 전환했습니다. 이전의 "shared-cpu VM 3대 + 160GB 대역폭 무료" 같은 영구 free tier가 사라졌어요. (Pricing) 신규 가입 시 2 VM-hour 또는 7일짜리 free trial이 전부예요. 가장 작은 머신을 always-on으로 굴리면 월 약 $1.94이 청구됩니다.
큰 비용은 아니지만 영구 free tier가 없다는 점이 사이드 프로젝트 사용 입장에서는 진입장벽으로 느껴졌습니다. 만약 나중에 글로벌 분산이 진짜 중요한 워크로드(엣지 API 등)에는 비용을 지불하고 사용하게 될 수도 있겠지만요.
옛 Heroku 갬성, Free 플랜은 sleep
Render는 깔끔한 UI와 Heroku-스러운 워크플로우가 특징입니다. Git Repo 연결하고 런타임 고르면 끝나는 식의 매끄러운 PaaS 경험을 제공합니다.
다만 Free 플랜 web service는 15분 idle 시 sleep에 들어가고, 다음 요청에 30~60초 콜드스타트가 추가됩니다. (공식 커뮤니티 답변) 30~60초의 지연은 말도 안 되죠. UX가 말도 안 되게 망가집니다. cron으로 ping해서 깨우는 우회법이 커뮤니티에서 자주 공유되지만, 결국은 유료 플랜으로 가야 깔끔합니다.
사용량 기반, 영구 free tier는 없음
Railway는 개발자 경험 평이 좋은 신생 서비스. 모니터링·로그·환경변수 관리가 매끄럽게 통합돼 있어요.
가입 시 $5 크레딧을 30일 안에 쓰는 free trial이 있고, 그 이후엔 Hobby($5/월) 또는 Pro($20/월) 구독이 필요합니다. (Pricing) 영구 무료는 없어요. trial 종료 후 free 플랜은 월 $1 크레딧만 제공해서 사실상 무의미하죠.
제 케이스(저트래픽 + 콜드스타트 감내 + 비용 0원 + 한국 리전)에서는 Cloud Run이 가장 깔끔한 답이었습니다. 서울 리전(asia-northeast3)이 있다는 것도 컸어요.
후보군의 수집, 최종 선택까지 Hacker News·Reddit 분위기를 살펴봤는데, 인상적인 부분이 몇 가지 있었어요.
Cloud Run은 사이드 프로젝트 호스팅의 사실상 표준 추천처럼 굳어진 분위기입니다. 2020년에 올라와 지금까지 회자되는 "Deploy your side-projects at scale for basically nothing - Google Cloud Run"가 그 시작점이고, 이후로도 "AWS가 몇 년 전에 만들었어야 할 서비스" 류의 코멘트가 꾸준히 달려요. 요청당 과금 + scale-to-zero라는 조합이 사이드 프로젝트 엔지니어들의 가려운 곳을 정확히 긁어준다는 평이 다수예요.
다만 함정 사례도 있습니다. 최근 화제가 됐던 "Google Cloud Run cost me $4,676 in 6 weeks with zero traffic" 글에서, 한 사용자가 트래픽 거의 없는 상태에서 600만 원에 가까운 청구서를 받은 사례가 보고됐어요.
원인은 봇/스캐너에 의한 무한 호출과 max-instances 미설정 콤보였습니다. 요청당 과금이 양날의 검이라는 거예요.
Cloud Run은 max-instances를 지정하지 않으면 기본값이 100입니다. 봇이 endpoint를 두드리기 시작하면 인스턴스가 100개까지 부풀고 vCPU-초·메모리-초 청구가 폭발해요. 사이드 프로젝트라면 --max-instances=3 같은 보호장치를 무조건 거는 게 안전합니다.
Fly.io 쪽은 2024년 가격 정책 변경 이후로 "예전만큼 부담 없이 권하기 어렵다"는 톤이 늘었어요. 글로벌 분산이 진짜 필요한 워크로드가 아니면 Cloud Run으로 옮겨갔다는 후기가 디스코드·HN에서 종종 보입니다.
반대로 엣지 게임 서버, 멀티 리전 챗 서비스처럼 지리적 분산이 진짜 본질인 케이스에서는 여전히 Fly.io가 압도적이라는 평이 많았습니다.
Cloud Run에 컨테이너를 배포하는 건 어떻게 할 수 있을까요?
핵심은 브라우저가 Cloud Run을 직접 호출하지 않는다는 점이에요. Cloud Run URL과 공유 토큰은 Vercel 서버에서만 다루고, 클라이언트는 사용중인 도메인의 app의 특정 api를 호출합니다.
저는 Next.js를 사용하고 있으니 API Route를 사용했습니다. (/api/convert-xls) 이렇게 하면 URL 노출과 한도 악용을 동시에 막을 수 있어요.
gcloud CLI를 사용하면 AI로 프로젝트 생성, 아티팩트 생성, 이미지 빌드/푸시/배포 등의 작업이 쉬워집니다.
# 1) 한 번만 — 프로젝트와 Artifact Registry 준비 gcloud projects create huns-cloud-run --set-as-default gcloud services enable run.googleapis.com artifactregistry.googleapis.com cloudbuild.googleapis.com gcloud artifacts repositories create xls-converter \ --repository-format=docker --location=asia-northeast3 gcloud auth configure-docker asia-northeast3-docker.pkg.dev # 2) 이미지 빌드·푸시 (M1/M2 맥은 --platform 명시 필수) docker build --platform linux/amd64 \ -t asia-northeast3-docker.pkg.dev/huns-cloud-run/xls-converter/xls-converter:v1 . docker push asia-northeast3-docker.pkg.dev/huns-cloud-run/xls-converter/xls-converter:v1 # 3) 배포 gcloud run deploy xls-converter \ --image=asia-northeast3-docker.pkg.dev/huns-cloud-run/xls-converter/xls-converter:v1 \ --region=asia-northeast3 --platform=managed \ --concurrency=1 --min-instances=0 --max-instances=3 \ --memory=1Gi --cpu=1 --timeout=60 \ --set-env-vars="XLS_CONVERT_SHARED_TOKEN=<32자 랜덤>"
옵션 풀이를 짚어볼게요.
--concurrency=1. 한 인스턴스가 동시에 처리할 요청 수. LibreOffice는 단일 변환에도 메모리·CPU를 꽤 쓰므로 1로 직렬화.--min-instances=0. 요청이 없으면 0으로 — 비용 0원. (콜드스타트 감수)--max-instances=3. 앞서 본 함정의 안전장치. 트래픽 폭주든 봇 공격이든 인스턴스가 3개를 넘지 않아요.--memory=1Gi --cpu=1. LibreOffice 한 번 띄울 최소치.--timeout=60. 60초 안에 응답 못 하면 끊음.
M1/M2 맥에서 docker build를 그냥 돌리면 기본이 arm64라 Cloud Run에서 "no match for platform in manifest" 에러로 실행이 안 됩니다. 빌드 시점에 --platform linux/amd64를 반드시 명시하세요. 첫 배포에서 5분쯤 헤맸습니다. (Docker 공식 multi-platform 가이드)
이를 통해 이런 화면까지 다다를 수 있습니다.
Next.js API Route에서는 이렇게 프록시 패턴 코드를 구성했습니다.
export async function POST(req: Request) { const formData = await req.formData(); const url = process.env.CLOUD_RUN_CONVERT_URL!; const token = process.env.XLS_CONVERT_SHARED_TOKEN!; const upstream = await fetch(`${url}/convert`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData, }); if (!upstream.ok) { return new Response(formData.get('file') as Blob, { headers: { 'X-Convert-Status': 'fallback-xlsx', 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }, }); } return new Response(await upstream.arrayBuffer(), { headers: { 'X-Convert-Status': 'converted', 'Content-Type': 'application/vnd.ms-excel', }, }); }
X-Convert-Status 헤더 하나로 클라이언트는 결과를 구분합니다. converted면 .xls, fallback-xlsx면 .xlsx로 저장하면 돼요. 변환이 실패해도 사용자는 어쨌든 양식을 받아갑니다 — 가용성이 망가지지 않는 fallback이 핵심이에요.
- Always-Free 한도로 충분히 굴러갑니다. 변환 한 번에 약 4초 + 동시성 1이라, 한 달에 수만 번을 변환해도 한도 안에 들어와요.
- scale-to-zero가 진짜 0원입니다. 안 쓰는 시간엔 인스턴스도 없고 청구도 0원. 사이드 프로젝트엔 최고의 모델이에요.
gcloud run deploy한 줄로 끝납니다. nginx 설정, systemd 등록, 인증서 갱신 같은 게 다 사라지는 경험은 꽤 짜릿했습니다.
- 콜드스타트 7초. LibreOffice 이미지가 무거운 탓이에요. UX 차원에서 "변환 중..." 인디케이터를 넣어줘야 했습니다.
--max-instances보호장치. HN $4,676 사례를 본 뒤로 신경 쓰게 됐어요. 사이드 프로젝트는 트래픽 없는 게 정상이라, 한도가 폭발하지 않게 위에서 막아두는 게 안전합니다.--platform linux/amd64. 위의 Warning에 적은 그 함정입니다.
이번에는 Cloud Run으로 충분한 토이 프로젝트였기 때문에 Cloud Run을 가볍게 파봤지만, 나중에는 용도에 따라 Fly.io의 글로벌 분산 배포나 Cloudflare Workers 같은 다른 패러다임도 직접 만져볼 생각이에요. 그동안 추상적인 단어로만 알던 "인프라"에 한발짝 가까워진 기분입니다.
고작 .xlsx 확장자를 .xls로 변환하기 위한 과정이었지만, 즐거운 배움과 경험의 과정이었습니다.
감사합니다.
