
무료로 다중 GPU 추론 백엔드 구축하기: Colab × Cloudflare Tunnel × GAS 레지스트리
요약
Google Colab의 무료 GPU를 활용하여 다중 추론 백엔드를 구축하는 방법을 소개합니다. Cloudflare Tunnel로 외부 통로를 확보하고, Google Apps Script(GAS)를 레지스트리로 사용하여 가변적인 서버 URL을 효율적으로 관리하는 0원 규모의 아키텍처를 제안합니다.
핵심 포인트
- Colab의 무료 GPU를 활용한 비용 제로 추론 서버 구축
- ngrok의 제한을 극복하기 위해 Cloudflare Quick Tunnel 사용
- GAS와 스프레드시트를 활용한 서버 URL 레지스트리 구현
- 가변적인 서버 환경을 관리하기 위한 중앙 집중식 관리 메커니즘
어느 이벤트용(1일 한정)으로, 브라우저에서 음성 생성(TTS)을 호출할 수 있는 Web App을 만들고 있었습니다. 물론, 모델 추론에는 GPU가 필요합니다.
하지만,
- 상시 가동되는 GPU 서버를 빌리면, 어느 정도 비용이 발생한다.
- 동시에 여러 명이 사용한다. 1대만으로는 추론이 병목된다.
- 예산은 실질적으로 0원이다.
1일 한정으로 사용하는 간이 앱이었기에, "무료 GPU를 여러 장 모아서 묶는다"는 방향으로 과감히 결정했습니다.
무료 GPU라고 하면!! 맞습니다!! Google Colab입니다.
전체적인 모습은 다음과 같습니다.
Colab 상에서 FastAPI를 실행하면, 무료로 GPU가 포함된 추론 서버를 얻을 수 있습니다. 하지만, Colab 인스턴스에는 외부에서 도달할 수 있는 고정 IP도 도메인도 없습니다. 그래서 "터널(Tunnel)"을 통해 외부로 통로를 엽니다.
처음에는 ngrok를 사용했지만, 무료 플랜은 계정당 동시 1개 터널만 가능합니다. 5대를 동시에 띄우고 싶은 실전 상황에서는 사용할 수 없습니다. 그래서 실전에서는 Cloudflare Quick Tunnel로 전환했습니다.
| ngrok (무료) | Cloudflare Quick Tunnel |
|---|---|
| 동시 터널 수 | 1개 |
| ... | ... |
cloudflared 바이너리를 다운로드하여 tunnel --url http://localhost:8000을 실행하기만 하면, https://xxxx.trycloudflare.com이 즉시 발행됩니다. ngrok와 달리 키(Key)나 계정이 필요 없으므로, Colab에 입력해야 할 비밀 정보도 줄어듭니다.
# Cloudflare Quick Tunnel을 구축하는 부분
_cloudflared_proc = subprocess.Popen(
[bin_path, "tunnel", "--no-autoupdate", "--url", f"http://localhost:{PORT}"],
...
⚠️
cloudflared의 표준 출력(Standard Output)을 읽지 않고 방치하면, 파이프(Pipe)가 막혀 터널 전체가 멈출 수 있습니다. URL을 확보한 후에는 별도의 스레드에서 출력을 계속 drain(비우기) 해줄 필요가 있었습니다.
터널을 통해 공개는 할 수 있었습니다. 하지만 실전 운용에는 치명적인 두 가지 특성이 있습니다.
- 공개 URL이 실행할 때마다 바뀐다 (
trycloudflare.com의 서브도메인은 랜덤). - Colab은 언제든 끊길 수 있다 (무료 플랜의 유휴 상태 연결 끊김, 세션 제한 등).
즉, 프론트엔드에 "백엔드 URL"을 고정해서 심어둘 수 없습니다. 게다가 대수도 가변적입니다 (5대인 날도 있고 10대인 날도 있음).
여기서 필요한 것이 "현재 살아있는 서버의 URL 목록을 어딘가에서 집중 관리하는" 메커니즘 = 레지스트리(Registry)입니다. 보통이라면 작은 DB + API 서버를 구축하겠지만, 그 또한 비용과 운용 부담이 따릅니다.
그래서 Google Apps Script (GAS) + 스프레드시트를 레지스트리로 사용했습니다.
- 0원 · 운용 비용 제로 (Google 계정만 있으면 됨).
- GAS의 Web App은
https://script.google.com/macros/s/.../exec라는 고정 URL 1개를 제공합니다. 이것은 절대 바뀌지 않습니다. 프론트엔드에는 이 URL 하나만 심어두면 됩니다. - 스프레드시트가 그대로 관리 화면이 됩니다. 현재 어떤 서버가 살아있는지 눈으로 확인하고 직접 수정할 수 있습니다.
스프레드시트(servers 시트)는 다음과 같은 열 구성으로 되어 있습니다.
serverId | color | label | apiUrl | enabled | capacity | assignedCount | lastSeen
apiUrl… 터널의 공개 URL (매번 변경됨)lastSeen… 마지막으로 heartbeat가 도착한 시각 (생사 판정에 사용)enabled… 수동으로 Off 할 수 있는 플래그
GAS의 doGet / doPost를 통해, 레지스트리의 읽기/쓰기를 가벼운 HTTP API로 공개합니다.
Colab 측에서 호출 (쓰기 계열)
register… 실행 시 "내 URL은 이것이다"라고 등록heartbeat… 30초마다 "아직 살아있다"라고lastSeen을 업데이트
프론트엔드 측에서 호출 (읽기 + 업데이트 계열)
list
… 살아있는 서버 목록을 가져오는 -
presence
… 「이 단말기는 이 서버를 사용 중」이라고 재석을 알림 (후술)
register
와 heartbeat
의 구현은 이것뿐입니다.
function doPost(e) {
var lock = LockService.getScriptLock();
lock.waitLock(10000); // 동시 업데이트 방지 (여러 Colab이 동시에 register함)
...
포인트는 LockService입니다. 여러 Colab이 동시에 register/heartbeat를 수행하므로, 잠금(lock)이 없으면 스프레드시트의 읽기/쓰기가 경합하여 행이 손상됩니다. GAS의
LockService.getScriptLock()
으로 직렬화하고 있습니다. list 측은 단순히 현재 목록을 JSON으로 반환할 뿐입니다:
function doGet(e) {
if (e.parameter.action === 'list') {
var rows = readRows_().map(function (r) {
...
이로써 "계속 변하는 백엔드 URL 목록을, 고정된 URL 하나 뒤로 집약하는 것"이 가능해졌습니다.
레지스트리가 만들어졌으므로, 프론트엔드는 기동 시 다음을 수행합니다.
?action=list로 목록을 가져온다.- 살아있고·비어있는 서버를 선택한다.
- 해당 서버의
/health를 호출하여 정말로 응답하는지 확인한다. - 통과하면
localStorage에 저장하여, 이후 해당 단말기는 그 서버를 계속 사용한다. - 도중에 끊기면, 다른 서버로 자동으로 갈아탄다.
선정 로직의 중심은 rankServers입니다.
export function rankServers(servers: ServerInfo[]): ServerInfo[] {
const freshMs = getServerFreshSeconds() * 1000
const now = Date.now()
...
lastSeen으로 생사 여부를 판정하는 것이 포인트입니다. heartbeat가 일정 시간 동안 오지 않는 서버는, 설령 행이 남아있더라도 후보에서 제외됩니다. Colab이 조용히 종료되어도 프론트엔드는 자연스럽게 해당 서버를 피하게 됩니다.
그리고 실제 할당. 선택하기 전에 반드시 /health를 호출하여, 죽어있는 서버를 잡지 않도록 하고 있습니다.
export async function assignFreshServer(excludeId?: string): Promise<Assignment> {
const servers = await fetchServers()
const ranked = rankServers(servers).filter((s) => s.serverId !== excludeId)
...
excludeId를 통해 "방금 실패한 서버"를 제외할 수 있으므로, 연결 실패 시 이를 전달하여 다른 서버로 갈아탑니다.
기동 시의 진입점인 ensureAssignment는 "localStorage에 저장된 서버가 있고 헬스 체크를 통과하면 그것을 우선하고, 안 되면 다시 가져온다"는 흐름입니다:
export async function ensureAssignment(): Promise<EnsureResult> {
const saved = loadAssignment()
if (saved && (await checkHealth(saved.apiUrl))) {
...
처음에는 단순하게 "assign에서 assignedCount를 +1, 이탈 시 -1" 하는 카운터 방식으로 만들었습니다. 하지만 이는 금방 망가집니다.
- 단말기가 탭을 닫거나/새로고침하거나/전원이 꺼지면 "-1"이 전송되지 않습니다.
- 결과적으로
assignedCount가 실제와 어긋나 계속 증가하며, 모든 서버가 "만석"으로 보여 아무도 할당할 수 없게 됩니다.
그래서 TTL (재석) 방식으로 변경했습니다. 발상은 "카운트를 더하고 빼는 것"을 그만두고, "지금 이 순간, 살아있는 단말기를 다시 세는 것"으로 전환하는 것입니다.
- 프론트는 사용 중일 때, 정기적으로
presence(deviceId + serverId)를 계속 보냅니다. - GAS는 단말기마다 마지막
presence시각을 가집니다 (presence시트). list
매번 "최근 90초 이내에 presence가 들어온 단말기만"을 계산하여, 이를 각 서버의 라이브 부하(activeCount)로 반환합니다.
// TTL 이내의 재석 단말기만 서버별로 계산
var PRESENCE_TTL_MS = 90 * 1000;
function activeCounts_(now) {
...
이렇게 하면 단말기가 조용히 사라지더라도, 90초 후에는 자동으로 부하에서 제외됩니다. "감산 이벤트(subtraction event)가 도달하지 않는" 문제가 구조적으로 발생하지 않습니다. assignedCount 컬럼은 호환성을 위해 남겨두되, 프론트엔드는 activeCount(재석 수)를 우선하여 부하를 판정하도록 했습니다.
// 재석 수가 있으면 그것을, 없으면 assignedCount로 대체
function loadOf(s: ServerInfo): number {
return s.activeCount ?? s.assignedCount ?? 0
...
배운 점: 분산 환경의 "현재 부하"는 증감 이벤트를 쌓아 올리는(push) 것보다, 살아있는 것을 매번 다시 세는(poll + TTL) 방식이 더 견고합니다. Heartbeat로 서버의 생사 여부를 확인하는 것과 완전히 동일한 발상을 단말기 측에도 적용한 형태입니다.
실제 운영 시에는 사람이 Colab을 5~10개 정도 하나씩 실행합니다. 매번 수동으로 의존성을 설치하고 URL을 등록하는 방식은 사고의 원인이 될 수 있으므로, 마지막 셀 하나만 실행하면 모든 과정이 끝나도록 만들었습니다.
# Colab 마지막 셀
import os
os.environ['GAS_URL'] = userdata.get('GAS_URL') # 직접 쓰지 않음
...
colab_runner.py가 수행하는 작업은 5단계입니다:
def main():
install_dependencies() # [1] pip install (AI 의존성 설치가 실패해도 dummy로 실행 지속)
start_backend() # [2] FastAPI를 별도 스레드에서 실행 → /health 대기
...
register와 heartbeat는 앞서 만든 GAS에 대해 단순히 POST를 보낼 뿐입니다:
def register_to_gas(api_url):
requests.post(GAS_URL, params={"action": "register"},
json={"serverId": SERVER_ID, "color": SERVER_COLOR,
...
이로써 "Colab 실행 → 자동으로 레지스트리에 등록 → 프론트엔드에서 포착"이 성립됩니다. 서버를 늘리고 싶다면 Colab 셀을 하나 더 늘리기만 하면 됩니다. 대수는 어디에도 하드코딩되어 있지 않습니다.
실제로 운용하며 부딪힌 점과 이 구성의 솔직한 약점입니다.
GAS의 Web App은 OPTIONS (Preflight)에 응답하지 않습니다. 따라서 프론트엔드에서 커스텀 헤더를 붙이거나 Content-Type: application/json으로 POST를 보내면 Preflight 단계에서 차단됩니다. 이를 피하기 위해 프론트엔드에서 GAS로 보내는 요청은 "단순 요청(Simple Request)\
- 무료 GPU = Colab, 외부 노출은 Cloudflare Quick Tunnel (다중 서버 가능 · 키 불필요).
- URL이 매번 바뀌거나 끊기는 문제는 **GAS + Sheets를 이용한 간이 레지스트리 (Registry)**로 해결. 프론트엔드에는 고정된 URL 하나만 심어둠.
- 프론트엔드 로직은 list → 빈 서버 순서 → /health 체크 → localStorage 고정 → 실패 시 전환 방식.
- 부하 카운팅은 증감 카운터(Increment/Decrement Counter) 방식이 아닌 TTL 재석(TTL Presence) 방식을 채택하여 시스템이 망가지는 것을 방지함.
- 서버 증설은 Colab 셀을 하나 더 추가하기만 하면 됨. 서버 대수를 하드코딩하지 않음.
"개인 개발을 위해 GPU 추론 백엔드가 필요하지만, 비용은 들여 쓰고 싶지 않다"는 분들이 그대로 활용할 수 있는 구성이라고 생각합니다. (운영 환경(Production)에서 쓰기에는 너무 불안정해서 사용하기 어려울 수도 있겠지만..)
레지스트리 부분(GAS)은 TTS 이외의 용도, 즉 LLM 추론이나 이미지 생성에서도 그대로 재사용할 수 있을 것입니다.
무료로 GPU를 이용할 수 있는 환경을 제공해 주는 Google에 깊은 감사를 표합니다.
| 항목 | 채택 |
|---|---|
| 프론트엔드 | React + TypeScript (고정 URL로 호스팅) |
| ... |
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기