Claude Code에서 작업 중에 보낸 프롬프트가 전달되는 시점

Claude Code가 작업 중일 때 /btw 없이 그냥 입력한 프롬프트도 잘 반영된다고 느껴서, 언제부터 그랬는지 릴리즈 노트에서 찾아봤습니다. /btw와 큐 메시지의 차이, 큐 메시지가 모델에 전달되는 시점, CHANGELOG에서 확인한 변경 이력과 확인하지 못한 부분을 정리합니다.

Claude Code가 작업하는 중에 무언가를 덧붙이고 싶을 때 한동안 저는 /btw를 썼습니다. 그냥 프롬프트를 입력하면 그 작업에 반영되지 않는다고 느꼈기 때문입니다. 그런데 어느 시점부터인가 /btw 없이 그냥 입력해도 작업 중간에 잘 반영되었습니다. 현시점에서의 /btw를 언제써야할지와 릴리즈 노트에서 관련 이력을 찾아봤습니다

/btw와 큐 메시지의 차이

Interactive mode 문서에 따르면 두 기능은 이렇게 다릅니다.

항목 /btw (side question) 작업 중 입력한 프롬프트 (queued message)

대화 이력 반영

반영되지 않음. 질문과 답변 모두 conversation history에 들어가지 않음

반영됨. 일반 메시지와 같은 자격으로 대화에 들어감

도구 사용

불가. 이미 컨텍스트에 있는 내용만으로 답함

가능. 파일 읽기, 명령 실행 모두 가능

진행 중인 작업에 미치는 영향

없음. 본 작업을 중단시키지도, 방향을 바꾸지도 않음

있음. 이후 작업의 방향이 바뀜

용도

이번 세션에서 이미 알고 있는 내용을 묻기

작업 방향을 바꾸거나 지시를 덧붙이기

문서는 /btw를 "subagent의 반대"라고 설명합니다. subagent는 대화 이력 없이 도구만 갖고 새로 알아내는 쪽이고, /btw는 대화 이력을 다 보지만 도구가 없는 쪽입니다.

그래서 /btw로 지시를 내리면 그 지시는 진행 중인 작업에 반영되지 않습니다. 제 프롬프트 이력에도 /btw로 지시를 내린 뒤 "아까 btw로 내린 명령을 수행해 줘"라고 다시 요청한 기록이 남아 있었습니다. 애초에 지시를 전달하는 용도로는 맞지 않는 명령을 썼던 것입니다.

/btw 자체가 언제 도입되었는지는 CHANGELOG에서 확인되지 않습니다. 도입을 알리는 항목이 없고, 가장 이른 언급은 v2.1.79(2026-03-18)의 버그 수정입니다.

Fixed `/btw` returning the main agent's output instead of answering
the side question when triggered during streaming

큐에 넣은 메시지가 모델에 전달되는 시점

작업 중에 프롬프트를 입력하고 Enter를 누르면 Claude Code는 턴을 중단하지 않고 큐에 넣습니다. 큐에 있는 항목이 모델에 언제 전달되는지는 문서에 이렇게 적혀 있습니다.

Messages: if you queue a message while Claude is running tool calls, Claude Code passes it to Claude as soon as those tool calls finish, within the same turn. When the turn ends, Claude Code sends the messages that are still queued as the next turn, each as a separate message

즉 도구 호출이 진행 중일 때 넣은 메시지는 그 도구 호출이 끝나는 시점에 같은 턴 안에서 전달됩니다. 턴이 이미 끝나 가고 있었다면 다음 턴의 첫 메시지가 됩니다. 슬래시 명령과 ! 셸 명령은 다르게 처리되어 턴이 끝난 뒤에 하나씩 실행됩니다.

바로 전달하고 싶으면 Esc로 턴을 중단하면 됩니다. 이때 큐에 있던 내용은 버려지지 않고 그대로 전달됩니다.

이 동작이 언제부터 이랬는지는 문서에 나와 있지 않습니다. 이 문서는 동작이 바뀐 항목에 "Before v2.1.186", "Before v2.1.212"처럼 이전 버전 주석을 붙이는데, 큐 관련 절에는 그런 주석이 없습니다.

CHANGELOG에서 확인한 steering 관련 변경 이력

작업 중 메시지 전달과 직접 관련된 항목은 세 개입니다.

버전 배포일 릴리즈 노트 항목

0.2.108

2025-05-13

You can now send messages to Claude while it works to steer Claude in real-time

1.0.84

2025-08-18

Fix Claude sometimes ignoring real-time steering when wrapping up a task

2.1.20

2026-01-27

Fixed agents sometimes ignoring user messages sent while actively working on a task

작업 중 메시지를 실시간으로 보내는 기능 자체는 2025년 5월에 도입되었습니다. "보낸 메시지를 무시한다"는 문제를 정면으로 고친 항목은 v2.1.20이 마지막입니다. 한 버전을 꼽으라면 이쪽입니다.

그 뒤로는 기능 변경이 아니라 유실이나 표시 관련 수정이 이어졌습니다.

  • v2.1.97(2026-04-08): 작업 중 입력한 메시지가 transcript에 기록되지 않던 문제

  • v2.1.105(2026-04-13): 큐에 넣은 메시지에 첨부한 이미지가 버려지던 문제

  • v2.1.178(2026-06-15): subagent가 턴을 마무리하는 중에 보낸 메시지가 유실되던 문제

  • v2.1.205(2026-07-08): --max-turns 한도에서 턴이 끝날 때 작업 중 보낸 메시지가 조용히 사라지던 문제

  • v2.1.234(2026-08-17): 큐에 남아 있는 메시지가 프롬프트 이력에 다시 나타나던 문제

  • v2.1.239(2026-08-21): 큐에 프롬프트가 있는 상태에서 Esc를 눌렀을 때 턴이 일찍 끝나던 경합 문제

참고 자료


이 포스트는 Claude Code와 정상혁이 함께 작성했습니다.

Cloud Run과 Turso로 개인 앱 배포하기

Go와 htmx로 만든 TIL 기록장을 애플리케이션 서버는 Google Cloud Run에, DB는 Turso로 배포한 과정입니다.

혼자 작성할 TIL(Today I Learned) 기록장을 만들어 til.benelog.net에 올렸습니다. 이 과정에서 애플리케이션 서버와 DB를 어떤 인프라 위에서 실행할지를 두고 여러 기술을 검토했습니다. 무료로 운영할 수 있어야 한다는 점을 가장 중요시했습니다. 무료 조건 내에서는 접속 속도도 상대적으로 빠르기를 바랬습니다. 또한 로컬 개발 환경과 운영 환경에서 최대한 같은 코드 경로를 사용해, 배포하기 전에 로컬에서 많은 동작을 검증할 수 있어야 한다는 조건도 두었습니다.

처음에는 카드 등록까지 피하려 했지만 이 조건을 모두 만족하는 배포처는 찾지 못했습니다. 그래서 카드가 등록된 결제 계정은 연결하되 각 서비스의 무료 한도 안에서 쓰는 것으로 기준을 낮췄고, Google Cloud Run과 Turso를 조합했습니다. 도메인은 처음에 Netlify 프록시로 붙였다가, 응답 시간을 측정한 뒤 Cloud Run의 직접 커스텀 도메인 매핑으로 옮겼습니다.

애플리케이션 서버의 소스는 benelog/til에 있습니다. 요금과 무료 한도, 지원 리전은 2026년 8월 22일에 확인한 값입니다. 바뀔 수 있으므로 이 문서를 참고하실 분은 실제로 기술을 선택하시기 전에 글 끝의 공식 문서를 다시 확인하시는 것을 추천드립니다.

1. Cloud Run과 Turso를 고른 이유

애플리케이션 서버는 Cloud Run에 배포합니다. 요청 기반 과금의 무료 한도는 Tier 1 단가 기준으로 월 2M 요청, 360,000 GiB-초, 180,000 vCPU-초입니다. 최소 인스턴스를 지정하지 않아 요청이 없을 때는 0개로 축소되기에 이 앱의 실제 트래픽은 무료 한도 안에 충분히 들어갈 것으로 예상했습니다.

Cloud Run 서비스 사용량 외에도 함께 청구되는 항목이 있고, 설정에 따라 과금 방식 자체가 달라집니다. 트래픽이 작다는 이유만으로 비용이 언제나 0원이라고 보장되지는 않습니다. 어떤 조건에서 금액이 붙는지는 뒤의 "Cloud Run 요금이 0원을 넘어가는 조건" 섹션에 정리했습니다.

서울 리전(asia-northeast3)을 쓸 수 있다는 점도 최초의 선택 이유였습니다. 최종 운영 리전은 도쿄(asia-northeast1)입니다. 서울과 도쿄의 응답 시간 차이보다 도메인 연결 방식의 차이가 컸기 때문인데, 근거는 뒤의 "응답 시간을 좌우한 리전과 프록시" 섹션에 있습니다.

데이터 저장소는 Turso(libSQL)입니다. Cloud Run 컨테이너의 쓰기 가능한 파일 시스템은 영속적으로 유지되지 않습니다. 로컬 SQLite 파일을 그 안에서 쓰면 서버 인스턴스가 종료될 때 데이터가 없어지고, 여러 인스턴스가 뜨면 서로 다른 파일을 보게 됩니다. 따라서 영속 데이터를 컨테이너 밖에 두어야 합니다.

Turso는 SQLite와 호환되는 원격 저장소이고, 무료 한도가 SQLite 파일 하나 분량의 데이터를 다루는 개인 앱에는 충분합니다. 무료 요금제에서는 데이터베이스 100개까지, 전체 저장 용량 5GB까지 쓸 수 있습니다. 드라이버(libsql-client-go)가 순수 Go라서 Go로 만든 앱을 CGO 없이 빌드하는 구성이 그대로 유지됩니다. 앱은 환경변수에 Turso URL이 있으면 libSQL 드라이버를, 없으면 로컬 SQLite 드라이버를 고릅니다. 드라이버와 접속 문자열만 달라지고, `database/sql`로 질의하는 저장소 코드는 두 환경에서 같습니다.

도메인은 Cloud Run의 커스텀 도메인 매핑으로 직접 붙였습니다. 처음에는 서울 서비스 앞에 기존 Netlify 사이트를 프록시로 두었습니다. Cloud Run의 직접 매핑이 서울 리전을 지원하지 않기 때문입니다. 측정해 보니 이 프록시가 응답 시간의 상당 부분을 차지해서, 직접 매핑을 지원하는 도쿄로 서비스를 옮기고 프록시를 걷어냈습니다. 그 과정과 측정값은 뒤의 "응답 시간을 좌우한 리전과 프록시" 섹션에서 다룹니다. benelog.net의 DNS zone은 여전히 Netlify가 관리합니다.

2. 앱 구성과 배포 설정 절차

앱은 Go 표준 net/http와 htmx로 만들었습니다. 웹 프레임워크는 쓰지 않았고, 화면 갱신은 htmx가 HTML 조각을 갈아 끼우는 방식입니다. 의존성이 전부 순수 Go라 CGO 없이 정적 바이너리로 빌드됩니다. 런타임 이미지로 gcr.io/distroless/static-debian12를 쓰고 그 위에 바이너리 하나만 올리므로, 언어 런타임 레이어가 아예 없습니다. Docker 이미지의 크기는 28MB입니다. 같은 앱을 Python이나 Node.js로 만들었다면 런타임을 담은 베이스 이미지 때문에 전체 크기가 훨씬 커집니다.

런타임 구성 베이스 이미지 압축 크기 앱까지 포함한 크기(압축 해제)

distroless static + Go 정적 바이너리

약 8MB

28MB (실측)

python:3.13-alpine

16.1MB

90~130MB (추정)

python:3.13-slim

41.0MB

170~210MB (추정)

node:22-alpine

55.1MB

190~230MB (추정)

node:22-slim

76.2MB

250~290MB (추정)

베이스 이미지 크기는 Docker Hub에서 조회한 amd64 값이고, 앱까지 포함한 크기는 웹 프레임워크·마크다운·HTML sanitizer·libSQL 클라이언트를 더했다고 가정해 역산한 추정치입니다. 실제로 빌드해 본 값은 Go 구성뿐입니다. 이미지 크기가 Cloud Run 요금에 직접 반영되지는 않지만, 뒤에서 설명할 Artifact Registry 무료 한도와 콜드 스타트에는 영향을 줍니다.

이 앱에서 목록과 각 기록을 읽는 요청은 모든 접속자에게 공개하고, 작성·수정·삭제 요청은 단일 비밀번호로 로그인한 세션만 허용합니다. 따라서 뒤에서 쓰는 --allow-unauthenticated는 Cloud Run의 IAM 인증을 요구하지 않는다는 뜻이지, 쓰기 기능까지 인증 없이 공개한다는 뜻은 아닙니다.

TIL 기록 목록 화면
기록 편집 화면. 마크다운 입력과 미리보기

배포를 위한 설정 과정은 다음 순서로 따라할 수 있습니다. Turso와 Google Cloud 가입, turso CLIgcloud CLI 설치, 각 CLI의 로그인은 마쳤다고 전제합니다.

  1. Turso에서 도쿄 리전 그룹과 데이터베이스를 만들고 URL과 토큰을 확인합니다. Turso가 제공하는 리전에 서울은 없어서, 가장 가까운 도쿄를 골랐습니다.

    turso group create tokyo --location aws-ap-northeast-1
    turso db create til --group tokyo
    turso db show til --url
    turso db tokens create til
  2. 사용할 GCP 프로젝트를 선택하고 결제 계정을 연결한 뒤 API 세 개를 켭니다. 결제 계정 연결은 무료 한도만 쓸 때도 필요합니다.

    gcloud config set project <project>
    gcloud billing projects link <project> --billing-account=<ACCOUNT_ID>
    gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com
  3. 기본 컴퓨트 서비스 계정에 빌드 권한을 부여합니다. 이 단계를 건너뛰면 첫 배포가 실패합니다.

    gcloud projects add-iam-policy-binding <project> \
      --member=serviceAccount:<projectNumber>-compute@developer.gserviceaccount.com \
      --role=roles/cloudbuild.builds.builder
  4. 저장소 루트에서 환경변수 파일을 만들고 배포합니다. --source .가 Cloud Build로 Dockerfile을 빌드합니다.

    /tmp/run-env.yaml
    TURSO_DATABASE_URL: "libsql://<database-host>"
    TURSO_AUTH_TOKEN: "<turso-db-token>"
    TIL_PASSWORD: "<write-password>"
    TZ: "Asia/Seoul"

    이 파일에는 DB 쓰기 권한이 있는 토큰과 로그인 비밀번호가 평문으로 들어갑니다. 저장소 안에 만들거나 커밋하지 않고, 파일 권한을 제한한 임시 경로에 둡니다.

    gcloud run deploy til-tokyo --source . --region asia-northeast1 \
      --allow-unauthenticated --env-vars-file /tmp/run-env.yaml
  5. Google Search Console에서 benelog.net의 소유권을 도메인 방식으로 확인합니다. Cloud Run은 검증된 도메인에만 매핑을 만들 수 있습니다.

  6. 서비스에 커스텀 도메인을 매핑합니다. 이 기능은 도쿄처럼 지원하는 리전에서만 쓸 수 있습니다. 매핑 명령은 beta 컴포넌트에 들어 있어서 없으면 먼저 설치해야 합니다. 이름이 비슷한 gcloud run domain-mappings는 Cloud Run for Anthos용이라 관리형 Cloud Run에는 쓸 수 없습니다.

    gcloud components install beta
    
    gcloud beta run domain-mappings create \
      --domain=til.benelog.net --service=til-tokyo \
      --region=asia-northeast1 --project=<project>
  7. 매핑이 안내한 레코드를 DNS에 넣습니다. 저는 benelog.net zone을 Netlify에서 관리하고 있어서 Netlify의 DNS 화면에서 추가했습니다.

    til  CNAME  ghs.googlehosted.com.

    CNAME은 같은 이름의 다른 레코드와 공존할 수 없습니다. 그 이름에 이미 다른 레코드가 있으면 먼저 지워야 합니다. 관리형 인증서는 DNS가 ghs.googlehosted.com.을 가리킨 뒤에야 발급되기 시작합니다. 그 사이에 그 호스트로 접속하면 TLS 오류가 납니다.

환경변수를 파일로 넘기면 토큰 값 자체는 셸 명령 이력에 남지 않습니다. Cloud Run 서비스 설정에는 환경변수가 저장되므로 접근 권한이 있는 사용자는 값을 볼 수 있습니다. 더 엄격하게 관리해야 하는 서비스라면 Secret Manager를 연결해야 합니다. 한 번 넣은 값은 다음 리비전에도 이어지므로, 값을 바꾸지 않는 다음 배포부터는 --env-vars-file을 생략할 수 있습니다.

3. Cloud Run 배포의 빌드 권한과 /healthz 문제

처음 배포할 때는 다음의 두 가지 문제를 만났습니다.

3.1. 기본 서비스 계정의 빌드 권한 부족

gcloud run deploy --source .의 첫 실행이 실패했습니다.

ERROR: (gcloud.run.deploy) PERMISSION_DENIED: Build failed because the default
service account is missing required IAM permissions.
... IAM permission denied for service account
788537242173-compute@developer.gserviceaccount.com

Google이 기본 컴퓨트 서비스 계정의 빌드 권한을 축소한 뒤 새 프로젝트에서 나타나는 증상입니다. roles/cloudbuild.builds.builder를 한 번 부여하면 해결됩니다. 앞의 배포 설정 절차 3단계가 이것입니다.

3.2. Google Frontend가 가로채는 /healthz 경로

배포 후 Cloud Run이 발급한 run.app 주소에서 헬스 체크 경로를 확인하니 앱이 아니라 Google의 404 페이지가 돌아왔습니다. 앱은 정상이어서 /도 기록 목록도 잘 나오는데 /healthz만 그랬습니다. 경로를 바꿔 가며 확인한 결과입니다.

경로 응답 누가 응답했나

/healthz

404

Google의 404 HTML. 컨테이너까지 도달하지 않습니다.

/healthz/

404

404 page not found. Go의 ServeMux가 응답했습니다.

/health, /readyz

404

404 page not found. 역시 앱까지 도달합니다.

/entries/2

200

앱이 정상 응답합니다.

이 서비스에서 확인한 경로 중에는 정확히 /healthz 하나만 앞단에서 가로챘습니다. 슬래시를 붙인 /healthz/는 그대로 통과하므로, 앱의 라우팅 문제가 아닙니다.

/healthz는 Cloud Run이 요구한 경로가 아니라, 제가 관례를 따라 앱에 정한 경로입니다. 이 관례는 Google 내부의 z-pages(/healthz, /varz, /statusz)에서 나왔습니다. 실제 애플리케이션 경로와 충돌하지 않도록 z를 붙인 이름인데, Kubernetes의 liveness probe 예제와 여러 헬스 체크 라이브러리를 거치며 업계 표준처럼 퍼졌습니다. Cloud Run의 공식 "Known issues"에도 /eventlog, /_ah/로 시작하는 경로와 일부 z로 끝나는 경로를 쓸 수 없다고 나옵니다. 즉, /healthz 하나만의 특수 규칙으로 일반화하기보다 Cloud Run에서는 z로 끝나는 진단 경로를 피하는 편이 안전합니다. 기본 헬스 경로가 /healthz인 Streamlit도 GCP에서 같은 문제를 겪고 경로를 바꾸는 옵션을 추가했습니다.

Cloud Run 자체가 이 경로를 요구하지는 않습니다. 다만 외부 모니터나 Cloud Run의 HTTP startup/liveness probe가 /healthz를 호출하도록 설정돼 있다면 컨테이너가 정상이어도 계속 404로 판단합니다. 그래서 같은 핸들러를 /health에도 등록했습니다.

main.go
// Cloud Run 앞단의 Google Frontend 가 정확히 /healthz 경로를 가로채 자체 404 를
// 돌려주므로(다른 경로는 그대로 통과) 헬스 경로를 /health 로도 열어둔다.
health := func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("ok"))
}
mux.HandleFunc("GET /healthz", health)
mux.HandleFunc("GET /health", health)

4. Cloud Run 요금이 0원을 넘어가는 조건

Cloud Run에서 금액이 붙기 시작하는 조건을 나눠서 정리합니다.

4.1. 무료 한도가 적용되는 방식

무료 한도는 프로젝트가 아니라 결제 계정 단위로 합산되고 매월 초기화됩니다. 요청 기반 과금(request-based billing)의 무료 한도는 CPU 180,000 vCPU-초, 메모리 360,000 GiB-초, 요청 2,000,000건입니다.

여기서 놓치기 쉬운 조건이 두 가지 있습니다.

첫째, 무료 한도는 사용량을 빼 주는 것이 아니라 Tier 1 단가에 해당하는 금액을 할인하는 방식입니다. Tier 2 리전에서 같은 양을 쓰면 단가가 더 비싸므로 할인으로 덮이는 사용량이 그만큼 줄어듭니다. 서울(asia-northeast3)은 Tier 2, 도쿄(asia-northeast1)는 Tier 1입니다. 응답 시간 때문에 도쿄로 옮긴 것이지만, 결과적으로 무료 한도를 온전히 쓰는 리전이 되기도 했습니다.

둘째, 과금 시간은 100밀리초 단위로 올림됩니다. 요청 기반 과금에서는 요청을 처리하는 동안(billable instance time)에만 CPU와 메모리가 계산되고, 컨테이너에 도달하지 못한 요청은 과금되지 않습니다. IAM 정책에서 거부된 요청도 마찬가지입니다.

4.2. 설정 하나로 과금 방식이 바뀌는 항목

설정 기본값에서 달라지는 것 이 앱의 선택

최소 인스턴스

1개 이상으로 올리면 요청이 없는 동안에도 idle 단가로 과금됩니다. 최소 인스턴스가 아닌 유휴 인스턴스는 과금되지 않습니다.

지정하지 않았습니다. 대신 첫 요청에 콜드 스타트가 포함됩니다.

인스턴스 기반 과금

opt-in하면 요청 처리 여부와 무관하게 인스턴스가 살아 있는 전체 수명 동안, 최소 1분 단위로 과금됩니다. 무료 한도도 CPU 240,000 vCPU-초 / 메모리 450,000 GiB-초로 달라지고 요청 수 무료 한도는 없습니다.

기본값인 요청 기반 과금을 씁니다.

동시 실행 수

낮추면 같은 요청 수를 처리하는 데 인스턴스가 더 많이 필요하고, 그만큼 인스턴스 시간이 늘어납니다.

기본값을 씁니다.

CPU·메모리 크기

vCPU-초와 GiB-초 단가에 그대로 곱해집니다. 무료 한도에 닿는 속도도 같은 배수로 빨라집니다.

기본값을 씁니다.

4.3. Cloud Run 청구서 밖에서 늘어나는 항목

gcloud run deploy --source . 한 줄로 배포해도 Cloud Run 외의 서비스가 함께 쓰이고, 각각 별도의 요금 체계를 가집니다.

항목 무료 한도 이 앱의 사용량

Cloud Build

결제 계정당 월 2,500 build-분(기본 풀의 e2-standard-2 기준. promotional이라 바뀔 수 있음)

배포할 때만 실행되고 한 번에 몇 분입니다.

Artifact Registry

결제 계정당 0.5 GiB-월. 초과분은 GiB-시간당 $0.000136986

cloud-run-source-deploy 저장소가 18.9MB입니다. 리비전을 배포할 때마다 이미지가 쌓입니다.

Cloud Storage

소스 배포는 run-sources-<프로젝트>-<리전> 버킷에 소스 압축본을 보관합니다. 리전 버킷이라 아시아 리전에는 상시 무료 한도가 없습니다.

배포마다 수십 KB 수준입니다.

Cloud Logging

프로젝트당 월 50 GiB. 초과분은 GiB당 $0.50

요청 로그만 남습니다.

외부 데이터 전송

목적지에 따라 다릅니다. 아래에 따로 정리했습니다.

한국으로 나가는 응답에는 무료 구간이 없습니다.

이 중 Cloud Storage와 외부 데이터 전송에는 이 구성에서 기대할 수 있는 상시 무료 한도가 없습니다. 개인 앱의 사용량에서는 반올림돼 0원으로 남지만, "무료 한도 안에서 쓰고 있다"고 말할 수 있는 항목은 아닙니다.

4.4. 목적지에 따라 달라지는 외부 전송 요금

Cloud Run에서 나가는 인터넷 전송은 Premium Tier로 Google Cloud 네트워킹 요금이 적용됩니다. 들어오는 트래픽은 무료이고, 나가는 트래픽만 GiB 단위로 계산합니다. 중요한 점은 단가가 출발 리전이 아니라 목적지 기준이라는 것입니다.

트래픽이 도착하는 곳(목적지) 무료 구간 1GiB ~ 1,024GiB 단가

아시아(한국·인도네시아 제외). 일본 포함

0 ~ 1GiB

$0.12 / GiB

북미

0 ~ 1GiB

$0.12 / GiB

유럽

0 ~ 1GiB

$0.12 / GiB

한국, 호주, 인도네시아, 남미, 사우디아라비아

없음

$0.19 / GiB (0GiB부터)

앱을 어느 리전에 두든 이 구분은 달라지지 않습니다. 서울에서 한국 사용자에게 보내도 같은 $0.19/GiB이고, 서울에서 도쿄의 Turso로 보내도 같은 $0.12/GiB입니다. Turso는 AWS에 있어서 Google 밖의 주소이므로, 도쿄에서 같은 도쿄로 나가더라도 사설 구간이 아니라 인터넷 전송으로 계산됩니다.

이 앱에서 나가는 트래픽은 두 갈래입니다.

  • Turso로 보내는 질의: 목적지가 도쿄(일본)라서 아시아 단가가 적용됩니다. 다만 나가는 것은 SQL 질의문이라 매우 작고, 결과 행은 들어오는 방향이라 무료입니다.

  • 사용자 브라우저로 보내는 응답: 독자가 대부분 한국에 있으므로 무료 구간 없이 $0.19/GiB 구간에 들어갑니다. 실제로는 이쪽이 전송량의 대부분입니다.

4.5. Netlify 프록시를 걷어내면서 나빠진 전송 조건

앞의 표를 이번 전환에 대입해 보면, 프록시를 걷어낸 결정이 응답 시간에는 이득이지만 전송 요금에는 손해입니다. 두 가지가 함께 바뀌었습니다.

목적지가 싱가포르에서 한국으로 바뀌었습니다. 전환 전 til.benelog.net의 A 레코드는 13.215.239.219였고, RDAP로 조회하면 등록명이 AMAZON-SIN입니다. 즉 Cloud Run이 응답을 건네던 상대는 한국의 사용자가 아니라 싱가포르에 있는 Netlify의 엣지였습니다. GCP 입장의 목적지가 싱가포르였으므로 아시아 단가 $0.12/GiB에 0~1GiB 무료 구간이 적용됐고, 싱가포르에서 한국까지의 마지막 구간은 Netlify의 대역폭이었습니다. 직접 매핑으로 바꾼 지금은 목적지가 한국이므로 무료 구간 없이 $0.19/GiB입니다.

엣지 위치를 우리가 고를 수 있는 것도 아닙니다. Netlify는 PoP 목록을 공식적으로 공개하지 않고, 지원 포럼에서 안내한 일반 CDN 지역에는 동아시아가 없습니다. PoP가 더 많은 High-Performance Edge는 Enterprise 플랜 기능이고, 리전을 지정하는 설정은 Functions에만 있습니다(Pro 이상). 앞의 측정에서 Netlify 경유 경로의 TCP 완료가 71.7ms로 길었던 것도 이 위치와 무관하지 않아 보입니다.

응답 압축이 사라졌습니다. 이쪽이 단가 변화보다 영향이 컸습니다. 전환 직후 두 경로의 헤더를 비교해 보니 이렇게 나왔습니다.

# 전환 직후: Cloud Run 직접 매핑
$ curl -H 'Accept-Encoding: gzip, br' -D - https://til.benelog.net/static/htmx.min.js
server: Google Frontend
content-length: 50917          # content-encoding 헤더가 없다

# 전환 전 경로: Netlify
$ curl -H 'Accept-Encoding: gzip, br' -D - https://til-benelog.netlify.app/static/htmx.min.js
content-encoding: br
server: Netlify

Netlify는 Brotli를 자동으로 적용해 줬는데, 이 서비스에서는 Google Frontend가 Cloud Run의 응답을 대신 압축해 주지 않았습니다. 메인 HTML도 Accept-Encoding을 붙이든 붙이지 않든 같은 크기로 돌아왔습니다. HTML·CSS·JavaScript는 보통 3~5배까지 줄어드는 자산이므로, 단가가 1.58배 오른 것과 별개로 실제로 나가는 바이트 자체가 늘어난 상태였습니다.

4.6. 앱에서 직접 응답을 압축하도록 수정

프록시가 대신 해 주던 일이므로 앱이 직접 하도록 미들웨어를 넣었습니다. 표준 라이브러리 compress/gzip만 써서 순수 Go 구성은 그대로 유지했습니다.

compress.go
// 이미 압축된 응답이나 본문이 없는 응답은 그대로 통과시킨다.
alreadyEncoded := h.Get("Content-Encoding") != ""
noBody := w.status == http.StatusNoContent || w.status == http.StatusNotModified

if alreadyEncoded || noBody || len(w.buf) < minCompressSize || !compressible(ct) {
	w.writeHeaderNow()
	...
}

규칙은 네 가지입니다.

  • Accept-Encoding에 gzip이 있을 때만 압축합니다. 압축 여부와 무관하게 Vary: Accept-Encoding은 항상 붙입니다.

  • 512바이트 미만은 그대로 보냅니다. htmx가 갈아 끼우는 작은 조각은 압축하면 오히려 커집니다.

  • text/*와 JSON·JavaScript·SVG만 압축하고 PNG처럼 이미 압축된 형식은 제외합니다.

  • 이미 Content-Encoding이 붙어 있으면 손대지 않습니다.

마지막 규칙 덕분에 나중에 Netlify 프록시를 다시 앞에 두더라도 이중 압축이 되지 않습니다. HTTP 압축은 홉마다 Accept-EncodingContent-Encoding으로 협상하는 구조라, 프록시는 이미 인코딩된 응답을 다시 압축하지 않고 그대로 전달합니다.

배포한 뒤 운영 주소에서 잰 값입니다.

응답 압축 전 gzip 적용 후 비율

GET /

2,918 B

1,220 B

2.4배

/static/style.css

4,606 B

1,610 B

2.9배

/static/htmx.min.js

50,917 B

16,435 B

3.1배

압축을 해제한 결과가 원본과 바이트 단위로 같은지, PNG는 그대로 나가는지도 함께 확인했습니다.

압축은 이렇게 되찾았지만, 목적지가 한국이라 단가가 $0.19/GiB이고 무료 구간이 없다는 조건은 그대로 남습니다. Netlify 프록시로 되돌리면 그 두 가지는 다시 유리해지므로 선택지로 열어 두고 있습니다. Turso로 오가는 양이 예상보다 크면 리전 구성을 다시 조정하는 것도 함께 검토할 생각입니다. 다만 지금은 청구서에 잡힌 값이 없어서 판단할 근거가 없고, 직접 매핑을 실제로 운영해 보는 것 자체가 목적이기도 해서 당분간은 이 구성을 그대로 두겠습니다.

Note

전송 요금에서 단가와 무료 한도는 기준이 서로 다릅니다. 단가는 위 표처럼 목적지가 기준인데, 무료 1GB는 출발지에 붙습니다. Google Cloud Free Program 문서의 Cloud Run 항목은 "1 GB of outbound data transfer from North America per month"라고 적고 있습니다. 북미에서 나가는 전송에 주어지는 할당이므로, 도쿄에서 출발하는 이 앱에는 적용되지 않습니다. Cloud Run 요금 문서는 같은 내용을 "within North America"로 적어 두어 표현이 조금 다르고, 네트워킹 요금표에는 목적지별로 0~1GiB 무료 구간이 표시됩니다. 문서마다 표현이 갈리는 영역이라 실제 적용 여부는 청구서로 확인하는 편이 안전하고, 한국 목적지에는 무료 구간이 없다는 쪽만 확실한 조건으로 잡았습니다.

이 1GB를 받으려고 앱과 데이터베이스를 북미로 옮기는 선택은 하지 않았습니다. 월 1GB는 $0.19어치이고 초과분 단가는 그대로인데, 앞의 Turso 리전 실험에서 보듯 태평양을 한 번 건널 때마다 160ms 안팎이 붙습니다.

또 하나 주의할 점은 이 무료 한도들이 대부분 결제 계정 단위라는 것입니다. 같은 결제 계정에 다른 프로젝트를 붙여 쓰고 있다면 그쪽 사용량이 이 앱의 무료 한도를 함께 소모합니다. 그래서 금액이 0원이더라도 예산 알림은 걸어 두는 편이 안전합니다. 현재 상태는 Google Cloud 콘솔의 결제 → 보고서에서 서비스별로 확인할 수 있고, 결제 → 예산 및 알림에서 임계값을 설정합니다.

5. 응답 시간을 좌우한 리전과 프록시

Cloud Run에서 배포할 리전을 서울로 골라도 데이터베이스와 프록시의 위치가 멀어지면 그 이득이 사라집니다. 두 구간을 실제 측정한 결과를 정리합니다. 아래 값은 정밀한 벤치마크의 백분위가 아니라, 한국의 같은 클라이언트에서 조건을 바꾸어 반복 호출했을 때 관찰한 범위입니다. Cloud Run의 콜드 스타트, 인터넷 경로 변화, 연결 재사용을 모두 분리하지는 않았으므로 절대값보다 전후 차이의 방향을 보는 용도입니다.

5.1. 오하이오에서 도쿄로 옮긴 Turso 리전

처음에는 데이터베이스를 aws-us-east-2(오하이오)에 두고 있었습니다. 앱을 서울 리전에 올려도 이 상태면 요청마다 태평양을 건너게 됩니다. 이 앱은 페이지 한 장을 그릴 때 질의를 한 번 보냅니다. 그룹 위치 외의 조건을 그대로 두고 응답 시간이 크게 달라졌으므로 원격 데이터베이스까지의 왕복 경로가 주요 요인이라고 추정했습니다. 서버 내부 구간별 시간을 따로 기록하지는 않았으므로 데이터베이스가 차지한 비율까지 계산할 수는 없습니다.

그룹을 도쿄로 옮기고 같은 조건에서 다시 측정했습니다. 관찰한 범위에서는 응답 시간이 5배 이상 차이가 났습니다. 이렇듯 애플리케이션 리전과 데이터베이스의 리전을 가까운 곳으로 지정하는 이득이 큽니다.

Turso 그룹 위치 한국에서 GET /

aws-us-east-2 (오하이오)

197 ~ 220ms

aws-ap-northeast-1 (도쿄)

38 ~ 45ms

Note

그룹은 데이터베이스들이 primary 리전과 서버를 공유하는 배치 단위라서, 무료 요금제의 데이터베이스는 모두 같은 리전 구성에 놓입니다. 스키마는 데이터베이스마다 독립이므로 용도가 다른 데이터베이스를 한 그룹에 함께 둘 수 있습니다. 그룹의 primary 위치는 나중에 바꿀 수 없어서, 다른 위치의 그룹으로 데이터를 옮겨야 합니다. 공식 문서상 무료(starter) 요금제는 그룹이 하나로 제한되고, 그룹을 둘 이상 만드는 기능은 유료 요금제 대상입니다. 따라서 기존 그룹과 새 그룹을 동시에 두는 이전 절차가 무료 계정에서 항상 가능하다고 가정해서는 안 됩니다.

제가 옮길 때는 사용량 표시가 groups 0/1이었는데도 두 번째 그룹이 생성됐습니다. 이 동작은 제한 적용 지연이나 계정 상태에 따른 예외일 수 있고 재현을 보장할 수 없습니다. 데이터를 보존한 채 확실하게 옮겨야 한다면 두 그룹을 동시에 쓸 수 있는 요금제로 잠시 올리거나 Turso 지원 채널에 이전 방법을 먼저 확인하는 편이 안전합니다. 기존 데이터베이스는 새 데이터베이스의 행 수와 주요 데이터를 검증하기 전에는 삭제하지 않습니다.

옮기는 과정에서 확인된 사실을 적어 둡니다.

  • turso db create <새 이름> --group <새 그룹> --from-db <기존 DB>는 그룹이 다르면 record not found로 실패합니다.

  • --from-dump는 데이터베이스만 만들어지고 덤프가 적재되지 않은 채 종료된 경우가 있었습니다. 데이터베이스가 생성됐다는 사실만 믿지 않고 select count(*) from entries처럼 테이블별 행 수를 확인해야 합니다.

  • 이 이전에서는 turso db shell <새 DB> < dump.sql로 직접 넣는 방법이 성공했습니다. sqlite_sequence와 인덱스도 함께 들어갔습니다.

  • 새 데이터베이스에는 토큰을 새로 발급해야 합니다.

5.2. Netlify 프록시와 Cloud Run 직접 도메인 매핑

Cloud Run 서비스는 서울에 있지만 Cloud Run의 직접 커스텀 도메인 매핑은 asia-northeast3을 지원하지 않습니다. Google이 권장하는 대안은 외부 Application Load Balancer이고 Firebase Hosting도 선택할 수 있지만, 둘 다 별도의 구성과 비용 조건을 검토해야 합니다. 이미 benelog.net의 DNS와 TLS 인증서를 Netlify에서 관리하고 있었기 때문에, til.benelog.net도 Netlify에 두고 모든 요청을 백엔드로 넘기는 rewrite 한 줄로 붙였습니다.

proxy/_redirects
/*  https://til-788537242173.asia-northeast3.run.app/:splat  200

상태 코드 200은 리다이렉트가 아니라 rewrite라서 주소창은 til.benelog.net으로 유지됩니다. DNS와 TLS 인증서는 Netlify가 관리하므로 백엔드를 어디로 옮겨도 이 한 줄만 고치면 됩니다.

이 프록시가 더하는 시간을 직접 확인하려고 커스텀 도메인 매핑을 지원하는 도쿄(asia-northeast1)에 같은 이미지와 환경변수로 til-tokyo 서비스를 만들었습니다. Google Search Console에서 benelog.net 소유권을 확인하고 til-tokyo.benelog.net의 CNAME을 ghs.googlehosted.com.으로 지정했습니다. Cloud Run 관리형 인증서는 도메인 매핑을 만든 뒤 약 57분 만에 준비됐습니다. 인증서 SAN에 til-tokyo.benelog.net이 포함됐고, //health는 모두 200으로 응답했습니다. 앞에서 설명한 /healthz는 커스텀 도메인에서도 Google Frontend의 404를 반환했습니다.

2026년 8월 22일 19시 42분(KST), 아직 운영 경로가 Netlify 프록시이던 시점에 한국의 같은 머신에서 네 주소를 측정했습니다. 각 주소를 한 번 워밍업한 뒤 20회씩 호출했고, 한 주소를 몰아서 재지 않고 매 반복마다 선두 주소를 바꿨습니다. 요청마다 새 curl 프로세스를 사용했으므로 TCP와 TLS 연결도 매번 새로 맺었습니다. 모든 요청의 상태 코드는 200이었습니다. DNS·TCP·TLS·TTFB는 요청을 시작한 시점부터 해당 단계까지 걸린 누적 시간이고, p95는 20개 중 19번째 값입니다.

경로 DNS 중앙값 TCP 중앙값 TLS 중앙값 TTFB 중앙값 전체 중앙값 전체 최소 / p95 / 최대

서울 run.app 직접 호출

1.7ms

6.1ms

48.6ms

99.1ms

99.7ms

94.7 / 108.3 / 123.6ms

도쿄 run.app 직접 호출

1.8ms

6.0ms

47.7ms

97.8ms

98.8ms

93.1 / 104.2 / 114.7ms

도쿄 서비스 + Cloud Run 도메인 매핑
(CNAME → ghs.googlehosted.com.)

1.5ms

62.9ms

148.4ms

281.9ms

284.1ms

265.2 / 323.0 / 339.0ms

서울 서비스 + Netlify 프록시
(당시 운영 경로)

1.3ms

71.7ms

153.9ms

347.0ms

347.1ms

335.1 / 403.1 / 413.9ms

서울과 도쿄 run.app의 전체 중앙값 차이는 0.9ms이고 두 표본의 변동 폭보다 작습니다. 이 결과로 어느 리전이 더 빠르다고 단정할 수 없습니다. 도메인 매핑을 거친 경로는 같은 도쿄 서비스의 run.app보다 전체 중앙값이 185.3ms 길었습니다. Cloud Run의 커스텀 도메인 진입 경로가 더한 비용을 같은 원본 서비스끼리 비교한 값입니다.

도메인 매핑 경로는 Netlify 프록시 경로보다 63.0ms 빨랐지만 run.app 수준으로 짧아지지는 않았습니다. Netlify 경유 주소의 TCP 완료가 서울 run.app보다 약 66ms 늦고, 원본 서버의 응답을 다시 전달받는 구간까지 TTFB에 포함됩니다. 이 값만으로 Netlify 접속 지점의 물리적 위치를 확정할 수는 없습니다.

앞의 Turso 리전 실험과 이 프록시 실험은 기초 응답 시간이 서로 다릅니다. 따라서 두 실험에서 구한 차이를 직접 더해 서로 상쇄됐다고 표현할 수는 없습니다. 다만 두 실험 모두 앱과 DB를 가까이 두는 것만큼 사용자가 접속하는 프록시 경로도 중요하다는 방향은 일치합니다.

커스텀 도메인을 직접 붙일 수 없는 배포처를 쓸 때는 유용한 방법이지만, 지연이 중요하면 배포처가 해당 리전에서 제공하는 도메인 연결 방법을 먼저 확인하는 편이 낫습니다. Cloud Run 직접 도메인 매핑은 Preview 기능이고 Google도 프로덕션 사용을 권장하지 않습니다. 운영 도메인을 옮기려면 새 매핑의 인증서가 발급되는 동안 TLS 오류가 날 수 있다는 점도 고려해야 합니다.

5.3. 운영 도메인 전환 절차와 중단 시간

측정 결과를 바탕으로 도쿄 Cloud Run과 직접 도메인 매핑으로 운영 경로를 옮겼습니다. 서울과 도쿄의 직접 응답은 사실상 같았지만, 직접 매핑은 Netlify 경로보다 63ms 짧았습니다. 개인 앱에서 지연을 줄이는 쪽을 우선해 Preview 제약을 감수한 선택입니다.

전환에서 가장 신경 쓴 부분은 중단 시간입니다. 관리형 인증서는 그 호스트의 DNS가 ghs.googlehosted.com.을 가리킨 뒤에 발급되기 시작합니다. 같은 이름의 인증서를 미리 만들어 둘 방법이 없으므로, DNS를 바꾼 시점부터 인증서가 준비될 때까지는 그 주소로 접속한 사용자가 TLS 오류를 봅니다. 그래서 순서를 이렇게 잡았습니다.

  1. 도쿄 서비스와 기존 운영 주소가 모두 정상인지 먼저 확인합니다.

  2. Netlify DNS에서 til.benelog.net과 이름이 정확히 같은 레코드의 Type/Name/Value/TTL을 기록합니다. 이 값이 롤백 원본입니다.

  3. DNS를 바꾸기 전에 Cloud Run 매핑을 먼저 만듭니다.

  4. 기존 레코드를 지우고 곧바로 til CNAME 하나를 추가합니다.

  5. 매핑 상태와 HTTPS를 함께 관찰합니다.

실제로 걸린 시간은 다음과 같습니다.

시각 (KST) 내용

19:57:31

Cloud Run에 til.benelog.nettil-tokyo 매핑 생성. 이 시점에는 DNS가 아직 Netlify를 가리켜 CertificatePending입니다.

20:02:30

Netlify DNS에서 til.benelog.netNETLIFY, NETLIFYv6 레코드 삭제

20:06:57

til CNAME ghs.googlehosted.com. 추가. 권한 네임서버와 8.8.8.8에서 즉시 새 값이 조회됐습니다.

20:14:45 이전

매핑이 Ready=True, CertificateProvisioned=True로 전환

20:22:59

https://til.benelog.net/이 처음으로 200 응답

레코드를 지운 20:02:30부터 HTTPS가 정상화된 20:22:59까지 약 20분 30초 동안 이 주소는 정상 동작하지 않았습니다. 그중 약 4분 30초는 레코드를 지운 뒤 CNAME을 추가하기 전까지 이름 자체가 응답하지 않은 구간이고, 나머지는 TLS 오류 구간입니다. 기존 레코드의 TTL이 120초였으므로 삭제 직후 잠시 옛 주소로 접속한 사용자도 있었을 수 있습니다.

인증서 자체는 CNAME을 넣은 지 8분 이내에 준비됐습니다. 앞서 테스트 호스트 til-tokyo.benelog.net에서는 같은 단계에 약 57분이 걸렸으므로, 이 시간은 호스트마다 다르다고 봐야 합니다. Google 안내상 최대 24시간이 걸릴 수 있습니다. CertificateProvisioned=True가 된 뒤에도 8분 정도 TLS 오류가 이어졌습니다. 매핑 상태가 준비됐다는 것과 Google Frontend가 그 인증서를 실제로 제공하기 시작하는 것은 별개의 시점입니다.

전환 뒤 발급된 인증서는 subject가 CN = til.benelog.net, issuer가 Google Trust Services, CN = WR3이고 유효 기간은 2026년 8월 22일부터 11월 20일까지입니다. SAN에는 til.benelog.net 하나만 들어 있습니다.

전환 직후 같은 방식으로 다시 측정한 값입니다.

경로 DNS 중앙값 TCP 중앙값 TLS 중앙값 TTFB 중앙값 전체 중앙값 전체 최소 / p95 / 최대

도쿄 run.app 직접 호출

1.3ms

5.7ms

46.7ms

97.9ms

98.5ms

92.4 / 104.6 / 141.8ms

도쿄 서비스 + Cloud Run 도메인 매핑
(전환 후 운영 경로)

1.3ms

54.2ms

116.0ms

223.2ms

224.2ms

210.4 / 240.8 / 255.3ms

전환 전 Netlify 프록시 경로의 347.1ms와 비교하면 전체 중앙값이 122.9ms 짧아졌습니다. 다만 이 값은 전환 약 20분 뒤에 잰 것이라 앞의 측정과 시간대가 다릅니다. 같은 시점에 잰 run.app이 98.5ms이므로, 커스텀 도메인 진입 경로가 더하는 비용은 여전히 125.7ms입니다.

서울 서비스 til, Netlify 사이트의 proxy/_redirects, 기존 DNS 레코드의 값은 지우지 않고 롤백 경로로 남겼습니다. DNS를 원래 레코드로 되돌리면 프록시 경로가 그대로 다시 동작합니다.

6. 선택하지 않은 배포처

Cloud Run으로 정하기 전에 처음 세운 조건은 "카드 등록 없이 상시 가동"이었습니다. 여기서 상시 가동은 프로세스가 항상 메모리에 떠 있다는 뜻이 아니라, 사용자가 요청하면 같은 URL로 다시 접근할 수 있다는 뜻입니다. 다만 유휴 상태에서 깨어나는 데 수십 초가 걸리면 개인 도구로도 불편하다고 판단했습니다. 그 조건으로 찾아본 후보와, 각각을 접은 이유를 정리합니다.

후보 무료 조건 걸린 점

Back4app Containers

컨테이너 1개, 256MB RAM / 0.25 CPU

카드가 필요 없습니다. 다만 제가 확인한 배포에서는 무료 주소가 만료될 때 배포도 함께 삭제됐습니다.

Render

Free web service, workspace당 월 750시간

15분 동안 요청이 없으면 컨테이너가 중지되고, 복귀에 30초에서 1분이 걸립니다.

Hugging Face Spaces

CPU Basic, 공개 Space 한정

유휴 시 중지됩니다. 데이터베이스 이름과 화면이 공개됩니다.

Northflank

프로젝트 1개, 서비스 2개

리소스를 생성할 때 카드 등록을 요구합니다. 무료 리전도 us-central과 europe-west뿐입니다.

Fly.io, Railway, Koyeb, Heroku

상시 운영 가능한 무료 컨테이너 요금제 없음

카드 등록 여부를 더 비교하기 전에 무료 상시 운영 조건에서 제외했습니다.

Northflank는 카드 요구로 먼저 제외했고, 다음으로 Back4app Containers를 골랐습니다. 카드 없이 GitHub 저장소를 연결하면 Kaniko가 Dockerfile을 빌드해 배포하고, 컨테이너가 중지되지 않아 콜드스타트도 관측되지 않았습니다. 여기까지는 조건에 맞았습니다.

6.1. Back4app Containers의 임시 URL 만료

배포한 지 30분이 되지 않아 til.benelog.net이 응답하지 않았습니다. 대시보드의 배포 로그에 원인이 그대로 남아 있었습니다.

Back4app Containers의 배포 로그 (UTC)
2026-08-21T23:58:26Z  SYSTEM  LAUNCHING CONTAINER...
2026-08-21T23:59:19Z  SYSTEM  CHECKING HEALTH...
2026-08-21T23:59:20Z  SYSTEM  DEPLOYMENT READY
2026-08-22T00:27:12Z  SYSTEM  The Back4app custom domain has expired for free plan
2026-08-22T00:27:12Z  SYSTEM  COOLING DOWN...
2026-08-22T00:28:12Z  SYSTEM  FINISHING CONTAINER...
2026-08-22T00:28:12Z  SYSTEM  DEPLOYMENT DESTROYED

컨테이너가 고장난 것이 아니라 무료 요금제의 임시 주소가 만료되면서 배포 자체가 삭제됐습니다. 그 사이 서비스는 정상이었고, 앱 로그에 GET / 요청이 200으로 남아 있습니다. 대시보드에는 "URL is temporary and will be live for 60 minutes"라는 안내가 있는데, 공식 문서에는 설명이 없습니다.

만료 시점은 재현하지 못했습니다. 위 사례는 DEPLOYMENT READY 이후 약 28분이고, 다른 때는 65분이 지나도 같은 주소가 살아 있었습니다. 따라서 안내문의 60분을 정확한 만료 시점이나 최소 보장 시간으로 해석할 수 없었습니다. 무료 주소의 수명을 운영 계약처럼 믿을 수 없다는 점만 결론으로 삼았습니다.

진단이 어려웠던 이유는 따로 있습니다. 만료된 주소가 한 번도 존재한 적 없는 서브도메인과 똑같은 응답을 돌려줍니다.

curl -sI https://til-nsh62xv6.b4a.run/healthz   # 실제로 만들었던 앱
curl -sI https://til-zzzzzzzz.b4a.run/healthz   # 존재한 적 없는 이름
# 둘 다: HTTP/2 404, "not found", x-cache: Error from cloudfront

컨테이너 장애 때 502나 503이 흔하지만, 플랫폼이 라우팅을 먼저 제거하면 404를 돌려줄 수도 있습니다. 이 404와 헤더만으로는 앱 장애인지 주소 회수인지 구분할 수 없었습니다. 대시보드의 배포 로그를 봐야 알 수 있습니다.

여기에 재배포하면 주소 접미사가 바뀌는 문제가 더해집니다. 실제로 되살릴 때마다 til-nsh62xv6에서 til-xu9uffq7로 바뀌었고, 그때마다 Netlify의 _redirects를 고쳐야 했습니다. 잠깐 띄워 보여 주는 용도라면 쓸 만하지만, 주소가 항상 살아 있어야 한다는 조건에는 맞지 않았습니다. 영구 주소는 월 5달러 요금제부터입니다.

그래서 "카드 등록 없이"라는 조건을 포기하고 Cloud Run으로 옮겼습니다. 카드를 등록해야 한다는 조건이 같다면, 무료 리전이 us-central과 europe-west뿐인 Northflank보다 서울 리전에서 요청이 없을 때 0개로 축소되는 Cloud Run이 이 앱의 사용 방식에 더 잘 맞았습니다. GitHub 연동으로 푸시할 때마다 자동 배포되던 점은 Back4app 쪽이 편했습니다.

7. 이 구성의 제약

같은 방식을 검토하실 분을 위해 제약과 단점을 정리합니다.

  • Cloud Run은 무료 한도만 써도 결제 계정 연결이 필수입니다. 카드를 등록하지 않겠다는 조건이면 이 선택지는 빠집니다.

  • Cloud Run 서비스 사용량 외의 비용 항목이 있습니다. 특히 한국으로 나가는 응답에는 무료 구간이 없습니다. 항목별 조건은 앞의 "Cloud Run 요금이 0원을 넘어가는 조건" 섹션에 정리했습니다.

  • 요청이 없으면 인스턴스가 0개로 줄어듭니다. URL은 유지되지만 다음 첫 요청에는 콜드 스타트가 포함될 수 있습니다.

  • 자동 배포는 따로 설정해야 합니다. gcloud run deploy를 직접 실행하는 방식이라, 푸시할 때마다 배포되게 하려면 Cloud Build 트리거를 붙여야 합니다.

  • 운영 도메인이 Preview 기능에 의존합니다. Cloud Run의 직접 커스텀 도메인 매핑은 Preview이고 Google도 프로덕션 사용을 권장하지 않습니다. 서울 리전에서는 아예 지원하지 않아 서비스를 도쿄로 옮겨야 했습니다. 직접 매핑과 Netlify 프록시가 응답 시간에 더한 비용은 앞의 "응답 시간을 좌우한 리전과 프록시" 섹션에서 측정했습니다.

  • 도메인 연결 방식을 바꾸는 동안 서비스가 중단됩니다. 같은 호스트 이름의 관리형 인증서를 미리 준비해 둘 수 없기 때문인데, 실제 전환에서 관찰한 중단 시간은 앞의 "운영 도메인 전환 절차와 중단 시간" 섹션에 적었습니다.

  • Turso 무료 요금제의 그룹 위치는 나중에 바꿀 수 없고, 공식적으로 그룹 하나만 쓸 수 있습니다. 처음 만들 때 앱이 뜰 리전에 맞추는 편이 좋습니다. 다른 리전으로 옮기는 절차의 제약은 앞의 "오하이오에서 도쿄로 옮긴 Turso 리전" 섹션의 참고 블록에 있습니다.

배포처를 옮기는 동안 저장소 접근 코드와 Dockerfile은 한 줄도 바꾸지 않았습니다. Turso가 SQLite와 호환되고 이미지가 표준 컨테이너라서 애플리케이션 코드의 이식 비용은 환경변수와 배포 설정을 바꾸는 정도에 그쳤습니다. 계정 생성, IAM 설정, 데이터 이전, 도메인 전환 같은 운영 작업까지 몇 줄로 끝났다는 뜻은 아닙니다. 무료 요금제의 조건이 자주 바뀌는 영역에서는 이렇게 옮기기 쉬운 구성이 선택 자체보다 중요하다고 생각합니다.

8. 참고 자료


이 포스트는 Claude Code, Codex와 정상혁이 함께 작성했습니다.

DoltHub에 데이터를 저장하는 애플리케이션 개발

습관 추적기의 데이터를 서버가 아니라 사용자의 DoltHub 저장소에 두는 구성입니다. 서버에 토큰을 저장하지 않고 기본 DB도 두지 않는 설계, htmx와 Cloudflare Pages 조합, DoltHub API로 스키마를 자동 생성한 방법과 그 과정에서 확인한 제약을 정리합니다.

"don’t break the chain" 방식의 습관 추적기를 만들었습니다. 웹 페이지의 달력에서 하루 칸을 클릭하면 사슬이 이어지고, 하루를 건너뛰면 사슬이 끊어지는 단순한 기능의 앱입니다. 이런 도구는 이미 많지만, 데이터를 서비스 운영자의 DB가 아니라 사용자가 소유하고 통째로 가져갈 수 있는 곳에 저장하면서 무료로 운영하고 싶었습니다. 그래서 사용자가 직접 DB를 생성할 수 있는 DoltHub를 데이터 저장소로 사용했습니다. 이 글에서는 그 구성의 선택지와 제약, 구현하면서 확인한 사실을 정리합니다.

소스는 benelog/habit-chain에 있습니다.

1. 습관 추적기의 화면과 연결 절차

화면은 습관마다 카드 하나입니다. 카드 안에는 최근 5주를 요일별로 늘어놓은 격자가 있고, 체크한 날끼리는 서로 이어져 하나의 사슬로 보입니다.

습관 카드 하나에 최근 5주 격자가 있고 7월 22일부터 25일까지 네 칸이 이어져 있다

사용자는 DoltHub에 가입하고 DB를 생성해야 합니다. 공개 DB는 이름만으로 읽을 수 있고, 비공개 DB는 읽기에도 토큰이 필요합니다. 앱은 설정된 토큰을 읽기 요청에도 전달하므로 둘 다 지원합니다.

  1. DoltHub에서 빈 데이터베이스를 하나 만듭니다. 테이블은 만들지 않아도 됩니다.

  2. DoltHub의 Settings → Tokens에서 API 토큰을 발급합니다. 토큰은 가능한 한 이 DB와 필요한 권한으로만 제한합니다.

  3. 앱의 설정 화면에 데이터베이스 이름(owner/name)과 토큰을 넣고 저장합니다. 저장하는 시점에 앱이 DoltHub에 토큰이 유효한지 물어보므로, 잘못 붙여 넣은 토큰은 그 자리에서 드러납니다.

  4. 목록 자리에 나타나는 DB 준비하기 버튼을 누릅니다. 앱이 사용자의 토큰으로 테이블을 만듭니다.

4단계가 끝나면 바로 습관을 추가할 수 있습니다. 스키마 SQL을 손으로 옮겨 붙이는 단계는 없습니다.

2. 데이터 저장소 후보 비교와 DoltHub 선택

데이터 저장소는 아래의 조건을 고려해서 선택했습니다.

  • 제가 서버 비용과 백업 책임을 지지 않아야 함

  • 사용자가 자기 데이터를 언제든 통째로 가져갈 수 있어야 함

  • SQL로 데이터 조회/입력 가능

선택지 장점 걸린 점

Cloudflare D1

Worker와 같은 계정에서 바로 쓰고 지연이 짧음

데이터가 제 계정에 쌓입니다. 사용자별로 격리하려면 인증과 테넌트 구분을 직접 만들어야 합니다.

브라우저 localStorage

서버가 아예 필요 없음

기기를 바꾸면 기록이 사라집니다. 브라우저 데이터를 지워도 사라집니다.

GitHub 저장소에 JSON 커밋

사용자 소유이고 이력이 남음

체크 한 번에 파일 전체를 다시 쓰게 됩니다. 조건 검색도 직접 구현해야 합니다.

DoltHub

사용자 소유, SQL 질의, 커밋 이력, 웹 관리 화면

쓰기 한 번에 1.5초에서 2초가 걸립니다. 무료 요금제의 데이터베이스는 공개됩니다.

DoltHub는 Git처럼 커밋과 브랜치를 갖는 SQL 데이터베이스인 Dolt의 호스팅 서비스입니다. 사용자가 자기 계정에 데이터베이스를 만들고, 앱은 사용자가 발급한 토큰으로 그 데이터베이스에 접근합니다. 체크를 한 번 할 때마다 커밋이 하나 쌓이므로, 언제 무엇을 고쳤는지가 DoltHub의 커밋 로그에 그대로 남습니다.

DoltHub의 Commit Log 화면. 체크 추가·삭제와 습관 수정이 SQL 구문 단위의 커밋으로 나열되어 있다

이력은 Git처럼 쌓이지만 조회는 SQL로 합니다. Dolt가 커밋 로그와 행 단위 변경 이력을 시스템 테이블로 노출하므로, 별도 명령을 배우지 않아도 익숙한 SQL로 이력을 들여다볼 수 있습니다.

-- 커밋 이력 조회
SELECT commit_hash, committer, date, message FROM dolt_log LIMIT 3;

-- 3개 커밋 전 시점의 체크 기록 조회
SELECT * FROM checks AS OF 'HEAD~3';

-- checks 테이블의 행 단위 변경 이력 조회
SELECT check_date, commit_date FROM dolt_history_checks ORDER BY commit_date DESC;

웹 화면의 데이터 관리 기능도 풍부합니다. DoltHub에서 테이블을 직접 편집하고, 커밋별 diff를 확인하고, SQL 콘솔에서 질의를 실행할 수 있습니다. 데이터베이스를 fork하고 pull request로 변경을 제안하는 흐름도 Git 호스팅과 같습니다. 덕분에 앱에 관리 화면을 따로 만들지 않아도 DoltHub 웹 화면이 그 역할을 합니다.

DoltHub의 Database 화면. 왼쪽에 테이블 목록과 스키마가 있고

2.1. 데이터 반출과 백업의 경계

앱의 설정 화면에는 현재 앱이 읽는 상태를 JSON으로 내려받는 기능이 있습니다. 이 내려받기는 일반 링크여서 URL에 비밀을 싣지 않으려고 토큰을 전달하지 않으므로, 공개 DB에서만 동작합니다. 스키마와 커밋 이력까지 포함한 DB 전체를 가져오려면 Dolt CLI로 저장소를 clone할 수 있습니다.

dolt clone owner/name

따라서 앱 운영자인 제가 사용자 데이터의 백업과 반출 기능을 별도로 만들 필요는 없습니다. 다만 DoltHub의 커밋 이력은 DoltHub 계정 삭제나 서비스 장애까지 막아 주는 별도의 백업은 아닙니다. 기록이 중요하다면 사용자가 주기적으로 clone해서 다른 곳에 사본을 두어야 합니다.

3. 토큰을 저장하지 않고 기본 DB도 두지 않는 애플리케이션 서버 구성

이 구성에서 서버가 하는 일은 화면을 그리고 DoltHub API를 대신 호출하는 것뿐입니다. 서버는 다음 두 가지를 갖지 않습니다.

  • 영속적으로 저장한 DB 접근 토큰: 사용자가 설정에 넣은 값이 요청마다 X-Dolt-Token 헤더로 옵니다. Worker는 요청을 처리하는 동안 토큰을 취급하지만 DB나 환경 변수에 저장하지 않습니다.

  • 기본 데이터베이스: 설정이 비어 있는 사용자에게 보여 줄 데이터베이스가 없습니다.

공개 DB의 읽기는 토큰 없이 누구나 할 수 있고, 비공개 DB의 읽기와 모든 쓰기는 DoltHub가 토큰의 권한으로 통제합니다. Worker는 사용자 계정이나 세션을 구현하지 않고 권한 검사를 DoltHub에 맡깁니다. 사용 편의성을 위해 DB 이름과 토큰은 브라우저의 localStorage에 저장합니다. 공용 PC에는 저장하지 말 것을 안내하고, 저장한 값을 지우는 버튼도 두었습니다.

첫 화면의 shell은 고정된 HTML이지만, 습관 목록은 DB의 현재 내용과 브라우저가 보낸 오늘 날짜로 Worker가 동적으로 렌더링합니다. 서버에 세션이나 사용자별 저장 공간이 없다는 뜻에서 상태가 없을 뿐, 정적 페이지는 아닙니다.

DB 이름과 날짜, 토큰을 매 요청에 싣는 구현은 htmx에서 했습니다. htmx의 hx-headers는 상속되는 속성이라, body에 한 번만 선언하면 모든 요청에 추가됩니다.

worker/src/render.ts가 만드는 shell
<body hx-headers='js:{"X-Local-Date": habitChain.today(), "X-Dolt-DB": habitChain.db(), "X-Dolt-Token": habitChain.token()}'>

js: 접두사를 붙이면 요청 시점마다 표현식을 평가합니다. 그래서 설정을 바꾸면 다음 요청부터 새 값이 실립니다. 이 구조에서는 읽기 요청에도 토큰이 실리고, Worker는 설정된 토큰을 DoltHub 읽기 호출에 전달합니다. 비공개 DB를 읽는 경로가 이것입니다. 부수 효과도 하나 있습니다. 2026년 8월에 확인했을 때 DoltHub의 읽기 엔드포인트는 공개 DB의 조회라도 잘못된 토큰이 붙어 있으면 400으로 거부했습니다. 그래서 저장한 뒤에 폐기된 토큰도 다음 읽기에서 드러나며, 앱은 이 오류 메시지를 알아보고 설정 화면을 가리키는 안내를 띄웁니다.

토큰이 앱의 여러 경로를 흐르므로 Worker 코드와 배포 경로, 같은 출처에서 실행되는 JavaScript를 신뢰해야 합니다. 특히 localStorage를 읽을 수 있는 XSS가 생기면 토큰이 앱 밖으로 유출될 수 있으므로, 토큰의 대상 DB와 권한을 최소화해야 합니다.

3.1. 저장 시점의 토큰 검증

잘못 붙여 넣은 토큰은 검증이 없으면 첫 쓰기에서야 오류로 드러납니다. 그래서 설정의 저장 버튼을 누르는 시점에 Worker가 DoltHub의 v2 API인 GET /api/v2/user로 토큰이 유효한지 확인합니다. `v1alpha1`에는 DB와 무관하게 토큰만 확인하는 문서화된 엔드포인트가 없어서, 이 요청 하나만 v2를 씁니다.

판정은 유효와 무효의 두 가지가 아니라 세 가지로 나눴습니다. DoltHub가 명확히 거부(401, 403)한 토큰만 저장을 막습니다. DoltHub 장애나 네트워크 오류처럼 판정할 수 없는 경우에는 확인하지 못했다는 안내와 함께 저장을 진행합니다. 저장소 쪽 장애를 "토큰이 틀렸다"로 보여 주면 사용자가 멀쩡한 토큰을 의심하게 되기 때문입니다.

4. htmx 서버 렌더링과 Cloudflare Pages 배포

화면 렌더링은 전부 서버에서 합니다. 브라우저는 htmx가 HTML 조각을 갈아 끼우는 일만 합니다. 브라우저에서 DoltHub를 직접 호출하지 않으므로 CORS 설정에 기댈 일이 없습니다. 2026년 8월에 확인했을 때 DoltHub의 write 엔드포인트는 브라우저의 preflight 요청에 쓰기를 허용하지 않았으므로, Worker의 API 중계 역할은 서버 렌더링과 별개로도 필요했습니다.

이름과 설명을 고치는 폼은 카드마다 접힌 채로 함께 내려보냅니다. 수정 버튼을 누르면 클래스 하나만 바꿔서 펼치므로 서버를 다시 부르지 않습니다. 읽기 한 번에 1초 가까이 걸리는 앱이라, 폼을 그때 받아 오면 수정 버튼과 취소 버튼이 매번 멈춥니다.

카드 안에서 이름과 설명 입력란이 펼쳐지고 아래에는 사슬 격자가 그대로 남아 있다

취소는 form.reset()으로 처리합니다. 서버가 내려보낸 값이 곧 폼의 기본값이라서 따로 되돌릴 값을 들고 있지 않아도 됩니다. 저장에 성공하면 서버가 카드를 통째로 새로 내려주므로 펼침 상태도 함께 사라집니다.

서버 실행 환경으로 Cloudflare를 고른 이유는 Workers입니다. 관리할 서버 없이 요청 단위로 코드가 실행되고, 무료 요금제로 하루 10만 요청까지 처리할 수 있습니다. 개인용 습관 추적기의 트래픽은 그 안에 넉넉히 들어갑니다.

배포는 Cloudflare Pages의 advanced mode를 씁니다. 빌드 산출물의 _worker.js 하나가 모든 요청을 먼저 받고, 자기가 모르는 경로만 정적 자산으로 넘깁니다. 이 _worker.js도 같은 Workers 런타임에서 실행되므로, Pages를 써도 실행 모델은 그대로입니다.

그런데도 Workers 직접 배포가 아니라 Pages를 고른 이유는 도메인입니다. 이 앱의 도메인 DNS는 Netlify에 있는데, Workers의 커스텀 도메인은 Cloudflare 존을 요구합니다. Pages는 서브도메인이면 외부 DNS에 CNAME 한 줄로 붙습니다.

4.1. X-Local-Date 헤더로 받는 오늘 날짜

서버에는 사용자의 시간대가 없습니다. UTC를 기준으로 삼으면 한국 사용자는 오전 9시 전에 체크한 기록이 어제 칸에 들어갑니다. 습관 추적기에서 그것은 사슬이 끊어진 것으로 보입니다.

그래서 브라우저가 로컬 날짜를 X-Local-Date 헤더로 보내고, 서버는 그 값을 씁니다. 헤더가 없을 때만 UTC로 계산합니다. 클라이언트가 보낸 값이므로 형식은 검사합니다.

체크 시각을 UTC로 저장하고 조회할 때 사용자의 시간대로 변환하는 방법도 검토했지만, 이 앱에는 맞지 않다고 판단했습니다. check_date는 시각이 아니라 "그 날 했다"는 날짜이기 때문입니다.

  • checks 테이블의 PK인 (habit_id, check_date)가 하루에 체크 하나라는 규칙을 스키마에 담고 있습니다. 현재 스키마에서 UTC 시각만 저장하면 하루의 경계가 조회하는 시간대에 따라 달라지므로, 이 규칙을 같은 방식으로 표현할 수 없습니다. 체크 해제도 지금은 날짜 단위 DELETE 구문 하나인데, 시각 저장이면 범위 질의가 됩니다.

  • 시간대를 옮겨서 조회하면 과거 기록이 다른 날로 재해석됩니다. 밤 11시의 체크가 다음 날로 넘어가면, 이미 이어졌던 사슬이 나중에 끊어져 보입니다.

  • 렌더링이 서버에서 일어나므로, 조회할 때 변환하려면 결국 브라우저가 시간대를 헤더로 보내야 합니다. X-Local-Date와 같은 클라이언트 의존이 그대로 남고 변환 지점만 늘어납니다.

  • DoltHub 웹 화면에서 2026-07-25라는 날짜는 그대로 읽히지만, UTC 시각은 시간대를 계산해야 맞는 날이 보입니다. 관리 화면을 DoltHub에 맡긴 구성과 어긋납니다.

시각인 데이터는 UTC로 저장합니다. 습관의 생성 시점인 created_at 칼럼이 그렇습니다. 남는 트레이드오프는 브라우저가 보낸 날짜를 형식만 검사하고 신뢰한다는 점입니다. 조작된 날짜를 막는 인증 경계가 아니라, 사용자가 자기 DB에 기록하는 개인 도구이므로 받아들였습니다.

5. DoltHub API를 사용한 스키마 자동 생성

이 앱은 SQL 읽기와 쓰기에 DoltHub의 v1alpha1 API를 사용합니다. 2026년 7월에 명시적인 OpenAPI 계약과 일관된 오류 형식을 갖춘 v2 API가 출시되었고, 공식 문서도 새 연동에는 v2를 권장합니다. 그래서 전체 이전을 검토했지만, 2026년 8월에 확인한 아래 사실 때문에 보류했습니다.

  • v2의 /api/v2/databases/{owner}/{db} 계열 엔드포인트가, `v1alpha1`로는 정상으로 읽히는 살아 있는 저장소 다수에 404(no such repository)를 반환했습니다. 제가 실제로 쓰는 DB와 v2 출시 이후에 만든 DB도 마찬가지였고, v2 문서의 예시 응답에 등장하는 dolthub/us-jails조차 라이브 v2에서는 404였습니다. 이 격차는 마이그레이션 가이드에 언급이 없습니다.

  • v1alpha1`은 폐기 예정이 아닙니다. v2 발표 글이 지원 지속을 명시하고, SQL API의 본 문서도 여전히 `v1alpha1 기준입니다. /api/v1 같은 정식 v1은 존재하지 않아서, 이름과 달리 `v1alpha1`이 현행 주력 API입니다.

  • v2 전용 토큰은 따로 없습니다. 같은 API 토큰을 `v1alpha1`은 authorization: token 스킴으로, v2는 Authorization: Bearer 스킴으로 받습니다.

커버리지 문제가 풀린 뒤에 v2로 옮기더라도 확인할 것이 남습니다. 응답 형식이 다르고(행이 칼럼명을 가진 객체가 아니라 값의 배열입니다), 아래에서 설명할 문서에 없는 write 동작이 v2의 sql-writes 엔드포인트에서도 유지되는지 다시 확인해야 합니다. 따라서 아래 내용은 DoltHub API 일반의 보장이 아니라 2026년 8월에 확인한 `v1alpha1`의 동작입니다.

DoltHub의 DDL 실행 경로는 아래 3가지입니다.

경로 DDL 확인한 내용

웹의 SQL 조회 콘솔

거부

ALTER TABLE을 보내면 Unsupported SQL statement로 거부합니다.

웹의 테이블 편집 화면

허용

테이블 목록에서 연필 아이콘을 눌러 들어가는 SQL Query 화면입니다. 임시 workspace에 반영한 뒤 커밋합니다.

REST write 엔드포인트

허용

앱이 쓰기에 쓰는 POST /api/v1alpha1/{owner}/{db}/write/{branch}/{branch}입니다.

REST write 엔드포인트가 DDL을 받는다는 내용은 공식 문서에 없습니다. 2026년 8월에 빈 데이터베이스로 직접 확인한 동작이므로, 보장된 계약으로 보지 않고 실패 경로를 남겨 두었습니다.

그래서 스키마 준비를 다음과 같이 구현했습니다.

  1. 목록 읽기가 실패하면 그때만 SHOW TABLESSHOW COLUMNS로 데이터베이스의 상태를 확인합니다.

  2. 스키마 상태가 충분하지 않으면 화면에서 DB 준비하기 버튼을 보여 줍니다.

  3. 버튼을 누르면 서버가 상태를 다시 확인하고, 앱이 알고 있는 마이그레이션의 사후 조건을 만족시키는 DDL을 실행합니다.

테이블이 없는 데이터베이스를 연결하면 DB 준비하기 버튼과 스키마 SQL 보기 링크가 나온다

3단계에서 DB의 스키마 상태를 다시 확인하는 이유는 앞선 시도가 일부만 적용되었거나 완료 응답을 받지 못한 경우에 ALTER TABLE …​ ADD COLUMN 등을 무조건 다시 실행하지 않기 위해서입니다.

5.1. 순번이 있는 마이그레이션 목록

실행할 DDL은 순번이 있는 목록으로 관리합니다. 목록은 뒤에 붙기만 하고, 순번이 의존 순서를 정합니다. 실행 시점에는 적용되지 않은 SQL 구문만 순서대로 골라냅니다.

worker/src/dolt.ts
export const MIGRATIONS: Migration[] = [
  { id: 1, name: "create-habits", statement: CREATE_HABITS, applied: (s) => s.habits },
  { id: 2, name: "create-checks", statement: CREATE_CHECKS, applied: (s) => s.checks },
  {
    id: 3,
    name: "habits-description",
    statement: ADD_DESCRIPTION,
    // Covered by create-habits on a fresh DB: the snapshot already has it.
    applied: (s) => !s.habits || s.description,
  },
];

export function migrations(shape: Shape): string[] {
  return MIGRATIONS.filter((m) => !m.applied(shape)).map((m) => m.statement);
}

이 형태는 Flyway나 golang-migrate 같은 마이그레이션 도구에서 가져왔습니다. 다만 도구를 그대로 붙일 수는 없었습니다. 이런 도구는 JDBC 같은 DB 연결을 전제하는데 DoltHub는 REST API만 노출합니다. 그리고 배포자가 자격증명을 갖고 배포 시점에 실행하는 모델인데, 이 앱에서는 사용자의 DB가 배포 이후에 생기고 마이그레이션은 그 사용자의 토큰으로 요청 시점에 돕니다. 그래서 순번 목록이라는 형태만 가져오고 두 가지를 바꿨습니다.

  • 버전 테이블을 두지 않습니다. 마이그레이션 도구는 schema_migrations 같은 테이블에 적용된 버전을 기록합니다. 이 구성에서는 사용자가 DoltHub 웹 화면에서 스키마를 직접 만질 수 있고, 여러 write 요청을 하나로 묶는 원자적 트랜잭션도 없습니다. DDL은 성공하고 버전 기록은 실패하면 둘이 어긋날 수 있습니다. 그래서 적용 여부는 마이그레이션마다 실제 스키마를 조사한 결과(applied)로 판정합니다. 실제 스키마가 유일한 기록입니다. 다만 현재 조사는 테이블과 일부 칼럼의 존재처럼 각 마이그레이션이 정한 조건만 확인합니다. 칼럼 타입, 기본값, PK까지 전체 스키마가 정확한지 검증하는 도구는 아닙니다.

  • CREATE 구문은 현재 스키마의 스냅숏입니다. 도구는 새 DB에도 과거 ALTER를 순서대로 재생하지만, 여기서는 SQL 구문 하나가 커밋 하나이고 1.5초에서 2초가 걸려서 재생할수록 첫 준비가 느려집니다. 새 테이블은 최신 모양으로 바로 만들고, 그 마이그레이션이 뒤의 ALTER까지 덮은 것으로 판정합니다. 3번의 applied가 "habits 테이블이 없으면 적용된 것"으로 읽히는 이유입니다.

자동으로 실행하는 내림(down) 마이그레이션도 만들지 않았습니다. 문제가 생기면 Dolt의 커밋 이력을 보고 수동으로 복구합니다. 스키마와 데이터가 함께 변한 커밋을 되돌리는 일은 별도의 호환성 판단이 필요하므로, 커밋 이력이 down 마이그레이션과 같은 것은 아닙니다.

5.2. 새 데이터베이스의 branch not found 오류

새 데이터베이스를 연결한 사용자가 query error: branch not found를 만났습니다.

원인은 DDL 실패가 아니었습니다. DoltHub에서 방금 만든 데이터베이스는 커밋이 하나도 없어서 main 브랜치 자체가 없습니다. 목록을 읽는 첫 질의가 거기서 실패한 것이었습니다.

curl "https://www.dolthub.com/api/v1alpha1/benelog/chain2/main?q=SHOW%20TABLES"
# {"query_execution_status":"Error","query_execution_message":"query error: branch not found"}

이 응답을 오류 화면에 그대로 띄우면 사용자가 할 수 있는 일이 없습니다. 지금은 이 메시지를 알아보고 준비 안내로 보냅니다. 다른 오류는 그대로 오류 화면으로 갑니다. 존재하지 않는 데이터베이스 이름까지 준비 안내로 덮으면 진짜 고장을 감추게 됩니다.

브랜치가 없는 데이터베이스에 write 엔드포인트로 CREATE TABLE을 보내면 테이블과 함께 브랜치도 생깁니다. 이것도 문서에 없는 동작입니다.

6. DoltHub 저장 구성의 제약

이 방식을 그대로 따라 하실 분을 위해 걸리는 점을 적습니다.

  • 공개 데이터베이스는 이름을 아는 누구나 읽을 수 있습니다. 토큰 없이 API로 현재 테이블과 커밋 이력을 읽을 수 있으므로, 습관 이름과 날짜뿐 아니라 설명, 생성 시각, note 칼럼과 수정·삭제 전 값도 공개됩니다. 민감한 값을 나중에 지우거나 고쳐도 과거 커밋에서는 사라지지 않습니다. 기록을 감추려면 비공개 DB를 써야 합니다. DoltHub Pro가 비공개 DB를 지원하며 2026년 8월 기준으로 비공개 데이터 100MB까지 무료이고, 초과하면 월 5달러입니다. 앱은 읽기 요청에도 토큰을 전달하므로 비공개 DB도 지원하지만, JSON 내려받기는 토큰을 싣지 않는 일반 링크라 공개 DB에서만 동작합니다.

  • 쓰기가 1.5초에서 2초 걸립니다. write 엔드포인트가 비동기라서 작업을 걸고 완료까지 폴링합니다. 체크 버튼을 누르고 바로 반응하는 화면을 원한다면 맞지 않습니다.

  • SQL 구문 하나에 커밋 하나가 쌓입니다. 영향받은 행이 0건이어도 커밋이 생깁니다. 체크를 자주 하면 커밋 로그가 길어집니다.

  • write 엔드포인트는 한 요청에 SQL 구문 하나만 받습니다. 세미콜론으로 이어 보내면 Error parsing SQL로 거부합니다. 습관 삭제는 `checks`와 `habits`를 차례로 지우는 두 요청입니다. 첫 요청 뒤에 두 번째 요청이 실패하면 습관은 남고 체크 기록만 지워진 상태가 되며, 앱은 이를 자동으로 보상하지 않습니다. 이런 부분 실패는 DoltHub의 커밋 이력을 보고 수동으로 복구해야 합니다.

  • ALTER TABLEAFTER절을 Dolt 파서가 거부합니다. 칼럼 위치는 지정하지 않았습니다.

7. 참고 자료


이 포스트는 Claude Code, Codex와 정상혁이 함께 작성했습니다.