<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://benyco-dev.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://benyco-dev.github.io/" rel="alternate" type="text/html" /><updated>2026-10-03T10:25:46+00:00</updated><id>https://benyco-dev.github.io/feed.xml</id><title type="html">Benyco’s Blog</title><subtitle>클라우드, 도커, 자동매매를 직접 만들어보고 남기는 개발 기록. GCP · Docker · Python · Claude Code.</subtitle><author><name>Benyco</name></author><entry><title type="html">클라우드 공부하려고 학습 사이트 26개를 만든 이야기 (IT 기초 · 쿠버네티스 · 구글 클라우드)</title><link href="https://benyco-dev.github.io/study-roadmap-sites/" rel="alternate" type="text/html" title="클라우드 공부하려고 학습 사이트 26개를 만든 이야기 (IT 기초 · 쿠버네티스 · 구글 클라우드)" /><published>2026-10-02T00:00:00+00:00</published><updated>2026-10-02T00:00:00+00:00</updated><id>https://benyco-dev.github.io/study-roadmap-sites</id><content type="html" xml:base="https://benyco-dev.github.io/study-roadmap-sites/"><![CDATA[<p>GKE, BigQuery, Vertex AI를 제대로 이해하고 싶었다.</p>

<p>공부 자료는 넘쳐났다. 문제는 <strong>“그래서 오늘 뭘 하지?”</strong> 였다. 공식 문서는 너무 넓고, 강의는 너무 길고, 블로그 글은 서로 순서가 달랐다.</p>

<p>그래서 기준을 하나 정했다. <strong>페이지를 열면 지금 할 일 하나가 바로 보일 것.</strong></p>

<h2 id="무엇을-만들었나">무엇을 만들었나</h2>

<table>
  <thead>
    <tr>
      <th>묶음</th>
      <th>과목</th>
      <th>기간(하루 1시간)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>IT 기초</td>
      <td>서버, 네트워크, DB, Python, 웹과 API, Git, 보안, 아키텍처</td>
      <td>약 32주</td>
    </tr>
    <tr>
      <td>쿠버네티스</td>
      <td>전체 로드맵 + 01 컨테이너 ~ 10 전문가</td>
      <td>약 32주</td>
    </tr>
    <tr>
      <td>구글 클라우드</td>
      <td>초급, 중급, 상급 + BigQuery, Vertex AI, Gemini Enterprise, Antigravity</td>
      <td>약 48주</td>
    </tr>
  </tbody>
</table>

<ul>
  <li><a href="https://storage.googleapis.com/study-it-basics-site/index.html">IT 기초</a></li>
  <li><a href="https://storage.googleapis.com/study-k8s-roadmap-site/index.html">쿠버네티스</a></li>
  <li><a href="https://storage.googleapis.com/study-gcp-roadmap-site/index.html">구글 클라우드</a></li>
</ul>

<h2 id="한-페이지에-들어-있는-것">한 페이지에 들어 있는 것</h2>

<p>과목 페이지는 모두 같은 틀이다.</p>

<ol>
  <li><strong>다음 할 일 카드</strong> — 체크 안 한 첫 과제를 맨 위에 띄운다. 스크롤해서 찾을 필요가 없다.</li>
  <li><strong>진행 체크</strong> — 과제마다 체크박스. 브라우저 <code class="language-plaintext highlighter-rouge">localStorage</code>에만 저장해서 로그인이 필요 없다.</li>
  <li><strong>주석 달린 명령어 + 예상 결과</strong> — 명령 옆에 <code class="language-plaintext highlighter-rouge"># 이게 무슨 뜻인지</code>를 붙이고, 실행하면 화면에 뭐가 나와야 정상인지 같이 보여 준다. 결과가 다르면 거기서 막힌 거다.</li>
  <li><strong>“쉽게 말하면”</strong> — 과제마다 비유 한 줄. 예: “정책 태그는 서류의 특정 칸에 붙인 대외비 스티커.”</li>
  <li><strong>용어 사전, 참고 링크, 맥북 실습 메모</strong></li>
</ol>

<p>묶음마다 메인 페이지가 있고, 각 과목의 진행률을 읽어서 <strong>“다음에 할 과목”</strong> 을 골라 준다.</p>

<h2 id="데이터와-틀을-나누기">데이터와 틀을 나누기</h2>

<p>26개 페이지를 손으로 만들면 디자인 하나 고칠 때 26번 고쳐야 한다. 그래서 둘로 나눴다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>과목 데이터 (JS 파일)        template.html
단계, 과제, 명령어, 용어      페이지 모양
          │                      │
          └──────────┬───────────┘
                     v
                 build.js
            틀에 데이터를 끼워 넣음
                     │
                     v
              정적 HTML 26개
                     │
                     v
            Cloud Storage 버킷
</code></pre></div></div>

<ul>
  <li><strong>내용 수정</strong> → 해당 과목 JS 파일 하나만</li>
  <li><strong>디자인 수정</strong> → 생성기 하나만 고치고 다시 생성</li>
</ul>

<p>데이터 파일은 <code class="language-plaintext highlighter-rouge">validate.js</code>로 먼저 검사한다. 과제 형식, 중복 ID 같은 기본 검사 말고도 실제로 겪은 실수를 규칙으로 넣었다.</p>

<ul>
  <li>줄 이음 <code class="language-plaintext highlighter-rouge">\</code> 뒤에 주석을 붙이면 셸 명령이 깨짐 → 잡아냄</li>
  <li>Dockerfile 줄 끝 주석은 오류 → 잡아냄</li>
  <li>heredoc 종료 <code class="language-plaintext highlighter-rouge">EOF</code> 줄에 주석 → 잡아냄</li>
</ul>

<p>의존성은 0개다. Node.js만 있으면 된다.</p>

<h2 id="호스팅-버킷-3개가-전부">호스팅: 버킷 3개가 전부</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>학습자 브라우저 ──HTTPS──&gt; storage.googleapis.com
      │                          │
      v                          v
 localStorage               버킷 3개
 (진행 체크)          (서울 리전, allUsers 읽기 전용)
</code></pre></div></div>

<ul>
  <li><strong>Cloud Storage</strong> — HTML 파일만 올리면 끝. 서버 관리가 없다.</li>
  <li><strong>IAM</strong> — <code class="language-plaintext highlighter-rouge">allUsers</code>에 <code class="language-plaintext highlighter-rouge">objectViewer</code>만. 누구나 읽지만 아무도 못 고친다.</li>
</ul>

<p>처음엔 설명을 생성해 주는 Cloud Run 서버도 붙였다가 지웠다. 정적 파일로 충분했고, 서버가 없으니 비용도, 보안 걱정도 같이 사라졌다. 버킷 호스팅 자체는 <a href="/gcs-static-hosting/">Cloud Storage 정적 호스팅 글</a>에서 해 본 패턴 그대로다.</p>

<p>업로드할 때 한 가지 주의할 점:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gcloud storage <span class="nb">cp </span>index.html gs://&lt;bucket&gt;/server/index.html <span class="nt">--content-type</span><span class="o">=</span><span class="s2">"text/html; charset=utf-8"</span> <span class="nt">--cache-control</span><span class="o">=</span><span class="s2">"no-cache"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">charset=utf-8</code>을 빼면 한글이 깨지고, <code class="language-plaintext highlighter-rouge">no-cache</code>를 빼면 고친 내용이 한동안 안 보인다.</p>

<h2 id="윈도우와-맥북-문제">윈도우와 맥북 문제</h2>

<p>실습 환경이 생각보다 큰 문제였다.</p>

<ul>
  <li><strong>Docker Desktop은 큰 회사에서 유료</strong> → WSL(윈도우) 또는 Ubuntu VM(맥) 안에 무료 Docker Engine</li>
  <li><strong>맥은 Multipass로 Ubuntu VM</strong> → 사이트의 리눅스 명령을 윈도우와 똑같이 씀</li>
  <li><strong>Apple Silicon</strong> → 다운로드 주소의 <code class="language-plaintext highlighter-rouge">amd64</code>를 <code class="language-plaintext highlighter-rouge">arm64</code>로</li>
</ul>

<p>과목 데이터를 하나하나 고치지 않고, 생성기가 과제 내용을 보고 맥 메모를 자동으로 붙이게 했다. <code class="language-plaintext highlighter-rouge">PowerShell</code>이 나오면 “VM에서 하세요”, <code class="language-plaintext highlighter-rouge">localhost:포트</code>가 나오면 “VM IP로 여세요”, <code class="language-plaintext highlighter-rouge">amd64</code>가 나오면 “arm64로 바꾸세요”.</p>

<h2 id="배운-것">배운 것</h2>

<ol>
  <li><strong>“다음 할 일” 하나가 목차 100개보다 낫다.</strong> 열자마자 할 일이 보이면 시작이 쉽다.</li>
  <li><strong>예상 결과를 같이 적으면 혼자 디버깅이 된다.</strong> “내 화면이 이것과 다르다”가 곧 질문이 된다.</li>
  <li><strong>정적 사이트면 충분한 경우가 많다.</strong> 서버를 붙였다가 지운 게 이번 프로젝트에서 제일 잘한 결정이다.</li>
  <li><strong>공개할 거라면 처음부터 안 넣는다.</strong> 개인 계정, 프로젝트 ID 같은 정보는 처음부터 넣지 않았다. 예시 이메일은 <code class="language-plaintext highlighter-rouge">example.com</code>, 예시 IP는 문서용 대역만 썼다.</li>
</ol>

<h2 id="아직-남은-것">아직 남은 것</h2>

<p>명령어 대부분은 아직 직접 다 실행해 보지 못했다. 따라 하다 에러가 나면 댓글로 알려 주면 고쳐서 다시 올리겠다.</p>

<p>지금은 IT 기초의 첫 과목, 서버부터 하고 있다.</p>]]></content><author><name>Benyco</name></author><category term="Google Cloud" /><category term="GCP" /><category term="Cloud Storage" /><category term="Kubernetes" /><category term="정적 호스팅" /><category term="학습 로드맵" /><summary type="html"><![CDATA[IT 기초에서 쿠버네티스, 구글 클라우드까지 과제 779개짜리 학습 사이트 26개를 만들었다. 서버는 0대, 버킷 3개가 전부다. 페이지를 열면 오늘 할 일 하나가 바로 보이게 하는 게 목표였다.]]></summary></entry><entry><title type="html">월 5천원으로 GCP에 커뮤니티 사이트 운영하기 (Cloud SQL·로드밸런서 없이)</title><link href="https://benyco-dev.github.io/gcp-5000won-architecture/" rel="alternate" type="text/html" title="월 5천원으로 GCP에 커뮤니티 사이트 운영하기 (Cloud SQL·로드밸런서 없이)" /><published>2026-09-25T00:00:00+00:00</published><updated>2026-09-25T00:00:00+00:00</updated><id>https://benyco-dev.github.io/gcp-5000won-architecture</id><content type="html" xml:base="https://benyco-dev.github.io/gcp-5000won-architecture/"><![CDATA[<p>프로젝트를 올리고 서로 구경하는 사이트 <a href="https://pro-v.co.kr">Pro-v</a>를 만들었다. 구글 로그인, 게시물·댓글·좋아요, 썸네일 업로드, 커스텀 도메인까지 있는 평범한 웹 서비스다.</p>

<p>평범한 서비스를 GCP 교과서대로 올리면 이렇게 된다. <strong>Cloud SQL</strong>에 DB를 두고, <strong>외부 HTTPS 로드밸런서</strong>로 도메인을 붙이고, <strong>Serverless VPC 커넥터</strong>로 둘을 잇는다. 트래픽이 거의 없는 개인 프로젝트인데도 이것만으로 월 5만원 이상이 나간다.</p>

<p>원칙은 하나였다. <strong>“저렴할수록 좋다.”</strong> 셋을 전부 빼고 무료 티어 안에서 대신할 방법을 찾았다. 지금 청구서는 한 달 5천원대다.</p>

<p>최종 구성은 이렇다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>사용자 ──HTTPS──&gt; Cloud Run (prov-web, 인스턴스 0~3)
                   │   └─ 커스텀 도메인: Cloud Run 도메인 매핑 (로드밸런서 없음)
                   │
                   └─ Direct VPC egress (커넥터 없음), 내부 IP:5432
                        │
                        v
                  e2-micro VM (무료 티어) ── PostgreSQL 15
                        ├─ 매일 pg_dump ──&gt; Cloud Storage (us-central1 단일 리전)
                        └─ 매일 디스크 스냅샷 (7일)
</code></pre></div></div>

<p>인프라는 전부 Terraform으로 만들었다. 아래 코드 조각은 실제 구성에서 필요한 부분만 뗀 것이다.</p>

<h2 id="1-비용부터-흔한-구성과-비교">1. 비용부터: 흔한 구성과 비교</h2>

<table>
  <thead>
    <tr>
      <th>역할</th>
      <th>흔한 선택</th>
      <th>월 비용(대략)</th>
      <th>이번 선택</th>
      <th>월 비용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>DB</td>
      <td>Cloud SQL (가장 작은 인스턴스)</td>
      <td>약 15,000원</td>
      <td>e2-micro VM + Postgres</td>
      <td>0원 (무료 티어)</td>
    </tr>
    <tr>
      <td>커스텀 도메인·HTTPS</td>
      <td>외부 HTTPS 로드밸런서</td>
      <td>약 30,000원</td>
      <td>Cloud Run 도메인 매핑</td>
      <td>0원</td>
    </tr>
    <tr>
      <td>Cloud Run → DB 연결</td>
      <td>Serverless VPC 커넥터</td>
      <td>약 10,000원</td>
      <td>Direct VPC egress</td>
      <td>0원</td>
    </tr>
    <tr>
      <td>합계</td>
      <td> </td>
      <td><strong>약 55,000원</strong></td>
      <td> </td>
      <td><strong>0원</strong></td>
    </tr>
  </tbody>
</table>

<p>남는 고정비는 두 개뿐이다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>월</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>VM의 외부 IPv4 1개 (패키지 업데이트용)</td>
      <td>약 5,000원</td>
    </tr>
    <tr>
      <td>Cloud DNS 존 1개</td>
      <td>약 300원</td>
    </tr>
    <tr>
      <td>Cloud Run, Cloud Storage, Artifact Registry, Logging, Pub/Sub</td>
      <td>0원 (무료 한도 안)</td>
    </tr>
  </tbody>
</table>

<p>도메인 등록비(연 2만원 내외)는 별도다.</p>

<h2 id="2-cloud-sql-대신-e2-micro-vm에-postgres">2. Cloud SQL 대신 e2-micro VM에 Postgres</h2>

<p>Compute Engine 무료 티어에는 <strong>e2-micro 1대 + 표준 디스크 30GB</strong>가 들어 있다. 조건이 두 개 있다.</p>

<ul>
  <li>리전이 <code class="language-plaintext highlighter-rouge">us-central1</code>, <code class="language-plaintext highlighter-rouge">us-west1</code>, <code class="language-plaintext highlighter-rouge">us-east1</code> 중 하나여야 한다.</li>
  <li><strong>결제 계정당 1대</strong>다. 다른 프로젝트에 e2-micro를 또 만들면 그 VM은 과금된다.</li>
</ul>

<p>그래서 VM을 <code class="language-plaintext highlighter-rouge">us-central1</code>에 두고, Cloud Run·버킷·Artifact Registry도 전부 같은 리전에 맞췄다. 리전이 다르면 내부 통신에도 리전 간 전송 요금이 붙는다.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"google_compute_instance"</span> <span class="s2">"db"</span> <span class="p">{</span>
  <span class="nx">name</span>         <span class="p">=</span> <span class="s2">"prov-db"</span>
  <span class="nx">machine_type</span> <span class="p">=</span> <span class="s2">"e2-micro"</span>
  <span class="nx">zone</span>         <span class="p">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">zone</span> <span class="c1"># us-central1-a</span>

  <span class="nx">boot_disk</span> <span class="p">{</span>
    <span class="nx">initialize_params</span> <span class="p">{</span>
      <span class="nx">image</span> <span class="p">=</span> <span class="s2">"debian-cloud/debian-12"</span>
      <span class="nx">size</span>  <span class="p">=</span> <span class="mi">30</span>
      <span class="nx">type</span>  <span class="p">=</span> <span class="s2">"pd-standard"</span> <span class="c1"># 무료 티어는 표준 디스크 30GB까지</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nx">network_interface</span> <span class="p">{</span>
    <span class="nx">network</span>    <span class="p">=</span> <span class="nx">data</span><span class="err">.</span><span class="nx">google_compute_network</span><span class="err">.</span><span class="nx">default</span><span class="err">.</span><span class="nx">id</span>
    <span class="nx">subnetwork</span> <span class="p">=</span> <span class="nx">data</span><span class="err">.</span><span class="nx">google_compute_subnetwork</span><span class="err">.</span><span class="nx">default</span><span class="err">.</span><span class="nx">id</span>
    <span class="nx">access_config</span> <span class="p">{}</span> <span class="c1"># 패키지·보안 업데이트용 외부 IP (월 약 5천원)</span>
  <span class="p">}</span>

  <span class="nx">metadata_startup_script</span> <span class="p">=</span> <span class="nx">templatefile</span><span class="err">(</span><span class="s2">"${path.module}/scripts/vm-startup.sh"</span><span class="err">,</span> <span class="p">{</span> <span class="err">...</span> <span class="p">}</span><span class="err">)</span>

  <span class="nx">lifecycle</span> <span class="p">{</span>
    <span class="c1"># 부팅 스크립트가 바뀌어도 VM을 다시 만들지 않는다.</span>
    <span class="c1"># 다시 만들면 부팅 디스크와 함께 DB가 통째로 날아간다.</span>
    <span class="nx">ignore_changes</span> <span class="p">=</span> <span class="p">[</span><span class="nx">metadata_startup_script</span><span class="p">]</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Postgres 설치와 계정 생성은 부팅 스크립트가 한다. DB 비밀번호는 Secret Manager에서 읽는다.</p>

<h3 id="대가-단일-장애점">대가: 단일 장애점</h3>

<p>Cloud SQL이 해 주던 걸 이제 직접 챙겨야 한다. 가장 큰 건 <strong>VM이 죽으면 사이트도 멈춘다</strong>는 점이다. 이중화는 이 예산으로는 불가능하다. Cloud SQL HA만 해도 월 6만원부터다. 그래서 “빨리 알고, 빨리 되살리는” 쪽에 무료 도구를 붙였다.</p>

<table>
  <thead>
    <tr>
      <th>대비</th>
      <th>방법</th>
      <th>비용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>데이터 백업</td>
      <td>매일 03:10 <code class="language-plaintext highlighter-rouge">pg_dump</code> → Cloud Storage, 30일 뒤 자동 삭제</td>
      <td>0원 (5GB 무료)</td>
    </tr>
    <tr>
      <td>VM째 복구</td>
      <td>매일 04:00 디스크 스냅샷, 7일 보관</td>
      <td>월 수백 원</td>
    </tr>
    <tr>
      <td>다운 감지</td>
      <td>Cloud Monitoring이 5분마다 <code class="language-plaintext highlighter-rouge">/status</code>(DB까지 확인) 호출, 실패 시 메일</td>
      <td>0원</td>
    </tr>
  </tbody>
</table>

<p>가동 확인은 <a href="/cloud-monitoring-uptime/">지난 글</a>에서 만든 방식 그대로다. 주의할 점이 하나 있다. 확인 대상은 메인 페이지 <code class="language-plaintext highlighter-rouge">/</code>가 아니라 전용 <code class="language-plaintext highlighter-rouge">/status</code>여야 한다. 쿠키 없는 검사 요청이 매번 새 방문자로 집계돼서 방문자 수가 부풀었다.</p>

<h3 id="terraform에서-vm이-다시-만들어질-뻔했다">Terraform에서 VM이 다시 만들어질 뻔했다</h3>

<p>DB가 VM 디스크에 있으니 <strong>VM 재생성 = 데이터 삭제</strong>다. 두 번 걸렸다.</p>

<ol>
  <li>부팅 스크립트에 스키마 SQL을 넣었더니, 스키마를 고칠 때마다 plan에 VM 교체가 떴다. 위 코드의 <code class="language-plaintext highlighter-rouge">ignore_changes</code>로 막았다. 스키마 변경은 앱이 부팅할 때 하거나 SQL을 직접 실행한다.</li>
  <li>서브넷 데이터 소스에 <code class="language-plaintext highlighter-rouge">depends_on</code>으로 API 활성화를 걸었더니, API를 하나 추가할 때마다 서브넷 값이 “알 수 없음”이 되어 VM 교체가 떴다. <strong>데이터 소스에는 <code class="language-plaintext highlighter-rouge">depends_on</code>을 걸지 않는다.</strong></li>
</ol>

<p><code class="language-plaintext highlighter-rouge">terraform apply</code> 전에 plan에서 <code class="language-plaintext highlighter-rouge">prov-db</code>에 <code class="language-plaintext highlighter-rouge">-/+</code>(교체)가 있는지 꼭 보는 습관이 생겼다.</p>

<h2 id="3-로드밸런서-대신-cloud-run-도메인-매핑">3. 로드밸런서 대신 Cloud Run 도메인 매핑</h2>

<p>Cloud Run에 커스텀 도메인을 붙이는 공식 추천은 외부 HTTPS 로드밸런서다. 기능은 많지만 트래픽이 0이어도 월 3만원 정도가 나간다.</p>

<p><strong>Cloud Run 도메인 매핑</strong>을 쓰면 로드밸런서 없이 도메인을 붙이고 인증서도 무료로 자동 발급된다.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"google_cloud_run_domain_mapping"</span> <span class="s2">"web"</span> <span class="p">{</span>
  <span class="nx">name</span>     <span class="p">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">domain</span> <span class="c1"># pro-v.co.kr</span>
  <span class="nx">location</span> <span class="p">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">region</span>
  <span class="nx">metadata</span> <span class="p">{</span> <span class="nx">namespace</span> <span class="p">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">project_id</span> <span class="p">}</span>
  <span class="nx">spec</span> <span class="p">{</span> <span class="nx">route_name</span> <span class="p">=</span> <span class="nx">google_cloud_run_v2_service</span><span class="err">.</span><span class="nx">web</span><span class="err">.</span><span class="nx">name</span> <span class="p">}</span>
<span class="p">}</span>

<span class="nx">locals</span> <span class="p">{</span>
  <span class="c1"># 매핑이 요구하는 A/AAAA 레코드를 그대로 DNS에 옮긴다.</span>
  <span class="nx">mapping_records</span> <span class="p">=</span> <span class="nx">try</span><span class="err">(</span><span class="nx">google_cloud_run_domain_mapping</span><span class="err">.</span><span class="nx">web</span><span class="err">.</span><span class="nx">status</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="err">.</span><span class="nx">resource_records</span><span class="err">,</span> <span class="p">[]</span><span class="err">)</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"google_dns_record_set"</span> <span class="s2">"apex_a"</span> <span class="p">{</span>
  <span class="nx">name</span>         <span class="p">=</span> <span class="s2">"${var.domain}."</span>
  <span class="nx">managed_zone</span> <span class="p">=</span> <span class="nx">google_dns_managed_zone</span><span class="err">.</span><span class="nx">web</span><span class="err">.</span><span class="nx">name</span>
  <span class="nx">type</span>         <span class="p">=</span> <span class="s2">"A"</span>
  <span class="nx">ttl</span>          <span class="p">=</span> <span class="mi">3600</span>
  <span class="nx">rrdatas</span>      <span class="p">=</span> <span class="p">[</span><span class="nx">for</span> <span class="nx">r</span> <span class="nx">in</span> <span class="nx">local</span><span class="err">.</span><span class="nx">mapping_records</span> <span class="err">:</span> <span class="nx">r</span><span class="err">.</span><span class="nx">rrdata</span> <span class="nx">if</span> <span class="nx">r</span><span class="err">.</span><span class="nx">type</span> <span class="err">==</span> <span class="s2">"A"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">www</code>는 매핑을 하나 더 만들고 CNAME을 <code class="language-plaintext highlighter-rouge">ghs.googlehosted.com.</code>으로 걸면 된다. <code class="language-plaintext highlighter-rouge">www</code>와 <code class="language-plaintext highlighter-rouge">*.run.app</code> 주소로 들어온 요청은 앱이 <code class="language-plaintext highlighter-rouge">pro-v.co.kr</code>로 301 리디렉션한다. 대표 주소가 하나여야 로그인 콜백 주소와 검색 등록이 꼬이지 않는다.</p>

<p>순서는 두 단계다. 도메인을 사고 소유 확인을 끝내야 매핑이 만들어진다. 그래서 <code class="language-plaintext highlighter-rouge">enable_domain</code> 변수를 두고 처음엔 <code class="language-plaintext highlighter-rouge">false</code>로 apply한다. 도메인 구매·소유 확인이 끝나면 <code class="language-plaintext highlighter-rouge">true</code>로 바꿔 다시 apply한다.</p>

<h3 id="도메인-매핑으로-포기한-것">도메인 매핑으로 포기한 것</h3>

<ul>
  <li><strong>Cloud Armor를 못 쓴다.</strong> 로드밸런서에 붙는 기능이라서다. 특정 국가 IP 차단이 필요해서 앱 미들웨어에서 직접 막았다(다음 글에서 다룬다).</li>
  <li>문서상 도메인 매핑은 <strong>프리뷰</strong> 기능이고, 지원하는 리전이 정해져 있다. <code class="language-plaintext highlighter-rouge">us-central1</code>은 된다.</li>
  <li>301 리디렉션 때문에 걸린 곳이 있다. <strong>Cloud Scheduler는 리디렉션을 따라가지 않는다.</strong> 스케줄러가 <code class="language-plaintext highlighter-rouge">*.run.app</code> 주소를 부르면 매번 301에서 실패했다. 대상 주소를 <code class="language-plaintext highlighter-rouge">https://pro-v.co.kr/...</code>로 바꿔서 해결했다. 이 실패는 Cloud Run 앱 로그에 아무것도 안 남아서 원인을 찾는 데 오래 걸렸다.</li>
</ul>

<h2 id="4-vpc-커넥터-대신-direct-vpc-egress">4. VPC 커넥터 대신 Direct VPC egress</h2>

<p>Cloud Run은 기본적으로 VPC 밖에 있다. VM의 내부 IP로 붙으려면 VPC로 들어가는 길이 필요하다. 예전 방법인 <strong>Serverless VPC 커넥터</strong>는 커넥터용 인스턴스가 최소 2대 상시로 떠 있어서 월 1만원 정도가 든다.</p>

<p><strong>Direct VPC egress</strong>는 Cloud Run 인스턴스가 서브넷에 직접 붙는 방식이다. 추가 요금이 없다.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"google_cloud_run_v2_service"</span> <span class="s2">"web"</span> <span class="p">{</span>
  <span class="nx">name</span>     <span class="p">=</span> <span class="s2">"prov-web"</span>
  <span class="nx">location</span> <span class="p">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">region</span>

  <span class="nx">template</span> <span class="p">{</span>
    <span class="nx">scaling</span> <span class="p">{</span>
      <span class="nx">min_instance_count</span> <span class="p">=</span> <span class="mi">0</span> <span class="c1"># 요청 없으면 0원</span>
      <span class="nx">max_instance_count</span> <span class="p">=</span> <span class="mi">3</span> <span class="c1"># 트래픽이 튀어도 요금이 튀지 않게</span>
    <span class="p">}</span>

    <span class="nx">vpc_access</span> <span class="p">{</span>
      <span class="nx">network_interfaces</span> <span class="p">{</span>
        <span class="nx">network</span>    <span class="p">=</span> <span class="nx">data</span><span class="err">.</span><span class="nx">google_compute_network</span><span class="err">.</span><span class="nx">default</span><span class="err">.</span><span class="nx">name</span>
        <span class="nx">subnetwork</span> <span class="p">=</span> <span class="nx">data</span><span class="err">.</span><span class="nx">google_compute_subnetwork</span><span class="err">.</span><span class="nx">default</span><span class="err">.</span><span class="nx">name</span>
      <span class="p">}</span>
      <span class="nx">egress</span> <span class="p">=</span> <span class="s2">"PRIVATE_RANGES_ONLY"</span> <span class="c1"># 내부 IP로 가는 트래픽만 VPC로</span>
    <span class="p">}</span>
    <span class="err">...</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">PRIVATE_RANGES_ONLY</code>로 두면 DB로 가는 트래픽만 VPC를 타고, 구글 API 호출은 평소처럼 나간다.</p>

<p>VM 쪽 방화벽은 <strong>서브넷 내부에서 오는 5432만</strong> 연다. Postgres 포트는 외부에서 보이지 않는다.</p>

<p>한 가지 더 확인할 게 있다. default VPC에는 <code class="language-plaintext highlighter-rouge">default-allow-ssh</code>, <code class="language-plaintext highlighter-rouge">default-allow-rdp</code>(0.0.0.0/0) 규칙이 기본으로 있다. Terraform 밖에서 만들어진 규칙이라 plan에도 안 보인다. 나중에 확인해 보니 VM의 22번 포트가 실제로 열려 있었다. SSH를 안 쓰기로 해서 두 규칙을 지웠다.</p>

<h2 id="5-함정-cloud-storage-무료-티어는-단일-리전에만">5. 함정: Cloud Storage 무료 티어는 단일 리전에만</h2>

<p>썸네일과 DB 백업을 담을 버킷을 처음엔 <code class="language-plaintext highlighter-rouge">US</code> <strong>멀티리전</strong>으로 만들었다. “미국이면 무료 티어겠지” 싶었다.</p>

<p>Cloud Storage 무료 5GB는 <strong><code class="language-plaintext highlighter-rouge">us-central1</code>, <code class="language-plaintext highlighter-rouge">us-east1</code>, <code class="language-plaintext highlighter-rouge">us-west1</code> 단일 리전</strong>에만 적용된다. <code class="language-plaintext highlighter-rouge">US</code> 멀티리전은 무료 티어가 아니다. 용량이 작아 금액은 크지 않았지만 원칙에 어긋나서 옮겼다.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"google_storage_bucket"</span> <span class="s2">"thumbs"</span> <span class="p">{</span>
  <span class="nx">name</span>     <span class="p">=</span> <span class="s2">"${var.project_id}-thumbs"</span>
  <span class="nx">location</span> <span class="p">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">region</span> <span class="c1"># 무료 티어는 us-central1·us-east1·us-west1 단일 리전만</span>
  <span class="err">...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>버킷의 위치는 바꿀 수 없어서 <strong>지우고 같은 이름으로 다시 만들어야</strong> 했다. 옮기면서 두 번 더 걸렸다.</p>

<ol>
  <li>
    <p><strong>IAM 권한이 조용히 사라진다.</strong> 버킷을 교체해도 <code class="language-plaintext highlighter-rouge">google_storage_bucket_iam_member</code>는 plan에 안 뜬다. 버킷 이름 문자열이 같아서 바뀐 게 없다고 판단한다. 그대로 apply하면 새 버킷에 권한이 없는데 state에는 있는 상태가 된다. 권한 리소스도 같이 교체해야 한다.</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terraform apply <span class="se">\</span>
  <span class="nt">-replace</span><span class="o">=</span>google_storage_bucket.thumbs <span class="se">\</span>
  <span class="nt">-replace</span><span class="o">=</span>google_storage_bucket_iam_member.thumbs_public
</code></pre></div>    </div>
  </li>
  <li>
    <p><strong>같은 이름을 다른 위치로 다시 만들면 한동안 404가 난다.</strong> 최대 10분 걸렸고, 버킷 생성 자체도 5분 넘게 걸렸다. 사이트 썸네일이 그동안 깨지니 사람이 적은 시간에 옮기자.</p>
  </li>
</ol>

<p>버킷 이야기가 나온 김에 하나 더. 공개 이미지 버킷에 <code class="language-plaintext highlighter-rouge">roles/storage.objectViewer</code>를 <code class="language-plaintext highlighter-rouge">allUsers</code>로 주면 <strong>파일 목록(objects.list)까지 공개</strong>된다. 누구나 버킷의 파일 이름을 전부 훑어볼 수 있다. 파일 읽기만 필요하면 <code class="language-plaintext highlighter-rouge">roles/storage.legacyObjectReader</code>를 쓴다.</p>

<h2 id="6-나머지를-무료-한도-안에-묶어-두는-설정">6. 나머지를 무료 한도 안에 묶어 두는 설정</h2>

<table>
  <thead>
    <tr>
      <th>서비스</th>
      <th>설정</th>
      <th>이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cloud Run</td>
      <td><code class="language-plaintext highlighter-rouge">min_instance_count = 0</code>, <code class="language-plaintext highlighter-rouge">max_instance_count = 3</code></td>
      <td>유휴 비용 0, 트래픽이 몰려도 요금 상한</td>
    </tr>
    <tr>
      <td>Artifact Registry</td>
      <td>이미지 최근 5개만 보관</td>
      <td>0.5GB 무료 한도 유지</td>
    </tr>
    <tr>
      <td>Cloud Storage 백업</td>
      <td>30일 지나면 자동 삭제</td>
      <td>5GB 무료 한도 유지</td>
    </tr>
    <tr>
      <td>Cloud Logging</td>
      <td>보관 30일</td>
      <td>기본값 그대로, 개인정보처리방침 기간과 맞춤</td>
    </tr>
    <tr>
      <td>배포</td>
      <td>GitHub Actions + Workload Identity Federation</td>
      <td>서비스 계정 키 없이, Cloud Build 비용 없이</td>
    </tr>
  </tbody>
</table>

<h2 id="정리">정리</h2>

<ul>
  <li>트래픽이 적은 개인 서비스라면 <strong>Cloud SQL, 로드밸런서, VPC 커넥터 없이도</strong> GCP에서 충분히 운영할 수 있다. 셋만 빼도 월 약 5.5만원이 준다.</li>
  <li>DB는 <strong>e2-micro 무료 티어 VM</strong>에 둔다. 리전은 <code class="language-plaintext highlighter-rouge">us-central1</code>·<code class="language-plaintext highlighter-rouge">us-west1</code>·<code class="language-plaintext highlighter-rouge">us-east1</code>, 결제 계정당 1대다. 대가는 단일 장애점이니 백업·스냅샷·가동 확인을 무료로 붙인다.</li>
  <li>커스텀 도메인은 <strong>Cloud Run 도메인 매핑</strong>으로 붙인다. 대신 Cloud Armor는 못 쓰고, 301 리디렉션을 안 따라가는 호출자(Cloud Scheduler)를 조심한다.</li>
  <li>Cloud Run → VM 연결은 <strong>Direct VPC egress</strong>로 한다. 추가 요금이 없다.</li>
  <li><strong>Cloud Storage 무료 티어는 단일 리전 버킷만.</strong> <code class="language-plaintext highlighter-rouge">US</code> 멀티리전은 과금된다. 옮길 때는 IAM 권한을 같이 <code class="language-plaintext highlighter-rouge">-replace</code> 한다.</li>
  <li>Terraform으로 VM에 DB를 두면 <strong>plan에서 VM 교체(<code class="language-plaintext highlighter-rouge">-/+</code>)가 있는지</strong> 매번 확인한다.</li>
</ul>]]></content><author><name>Benyco</name></author><category term="Google Cloud" /><category term="Terraform" /><category term="GCP" /><category term="Cloud Run" /><category term="Compute Engine" /><category term="Cloud Storage" /><category term="비용 절감" /><category term="무료 티어" /><summary type="html"><![CDATA[구글 로그인, 게시판, 이미지 업로드, 커스텀 도메인까지 있는 사이트를 월 5천원에 돌린다. Cloud SQL, 로드밸런서, VPC 커넥터를 빼고 무엇으로 대신했는지, 무료 티어에서 어디서 걸렸는지 정리했다.]]></summary></entry><entry><title type="html">Cloud Monitoring으로 내 사이트 감시하기 (가동시간 체크 + 이메일 알림)</title><link href="https://benyco-dev.github.io/cloud-monitoring-uptime/" rel="alternate" type="text/html" title="Cloud Monitoring으로 내 사이트 감시하기 (가동시간 체크 + 이메일 알림)" /><published>2026-09-15T00:00:00+00:00</published><updated>2026-09-15T00:00:00+00:00</updated><id>https://benyco-dev.github.io/cloud-monitoring-uptime</id><content type="html" xml:base="https://benyco-dev.github.io/cloud-monitoring-uptime/"><![CDATA[<p>실습하면서 만든 사이트가 어느새 네 개가 됐다. <a href="/gcs-static-hosting/">로또 예상번호</a>, <a href="/lunch-box/">점심 메뉴</a>, BigQuery로 모으는 트렌드 아카이브, 그리고 이 블로그. 문제는 <strong>하나가 죽어도 내가 들어가 보기 전까지는 모른다</strong>는 거였다. 트렌드 아카이브는 더 조용하다. 예약 쿼리가 실패해도 사이트는 멀쩡히 뜨고, 데이터만 그날부터 멈춘다.</p>

<p>그래서 Cloud Monitoring으로 감시를 붙였다. 새로 띄운 서버, 컨테이너, 함수는 하나도 없다. Monitoring 설정만으로 끝난다.</p>

<p>최종 구성은 이렇다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[가동시간 체크]  15분마다
  Google 체크 서버 (asia-pacific / usa-oregon / europe)
     ├─ HTTPS GET ──&gt; 사이트 4곳
     └─ 결과 기록 ──&gt; 지표 check_passed ──&gt; 알림 정책 ──&gt; 이메일

[로그 기반 알림]
  BigQuery 예약 쿼리 ──&gt; Cloud Logging (ERROR) ──&gt; 알림 정책 ──&gt; 이메일
</code></pre></div></div>

<h2 id="1-비용부터-확인했다">1. 비용부터 확인했다</h2>

<p>“무료로” 하는 게 조건이라 <a href="https://cloud.google.com/stackdriver/pricing">가격표</a>부터 봤다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>무료 한도 / 가격</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>가동시간 체크 실행</td>
      <td><strong>프로젝트당 월 100만 회</strong> 무료, 넘으면 1,000회당 $0.30</td>
    </tr>
    <tr>
      <td>알림 정책</td>
      <td><strong>2027년 9월 1일 이전에는 과금 없음</strong></td>
    </tr>
    <tr>
      <td>가동시간 지표로 거는 알림</td>
      <td>과금이 시작돼도 무료</td>
    </tr>
    <tr>
      <td>로그 기반 알림</td>
      <td>과금 시작 뒤 조건당 월 $0.35</td>
    </tr>
  </tbody>
</table>

<p>가동시간 체크는 <strong>리전별로 한 번씩</strong> 센다. 3개 리전에서 검사하면 1회 검사가 3회 실행이다. 사이트 4곳을 15분마다 검사하면 이렇게 된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>4곳 × 3개 리전 × 하루 96회 × 30일 = 월 34,560회
</code></pre></div></div>

<p>한도의 3.5%라 한참 남는다.</p>

<h2 id="2-가동시간-체크--30분-주기는-없다">2. 가동시간 체크 — 30분 주기는 없다</h2>

<p>처음엔 30분마다 검사하려고 했다. 그런데 <strong>가동시간 체크 주기는 1분, 5분, 10분, 15분 넷 중 하나만</strong> 고를 수 있다. 30분이나 1시간은 API가 받지 않는다. 가장 긴 15분으로 정했다.</p>

<p>체크 리전도 제약이 있다. 리전을 직접 고르려면 <strong>최소 3개</strong>를 골라야 한다. 한 리전의 일시적인 네트워크 문제로 오탐이 나는 걸 막으려는 설계로 보인다.</p>

<p>REST API로 보낸 체크 설정은 이렇다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span>
    <span class="s">"displayName"</span><span class="p">:</span> <span class="s">"site-watch lottery"</span><span class="p">,</span>
    <span class="s">"monitoredResource"</span><span class="p">:</span> <span class="p">{</span><span class="s">"type"</span><span class="p">:</span> <span class="s">"uptime_url"</span><span class="p">,</span>
                          <span class="s">"labels"</span><span class="p">:</span> <span class="p">{</span><span class="s">"project_id"</span><span class="p">:</span> <span class="n">project</span><span class="p">,</span> <span class="s">"host"</span><span class="p">:</span> <span class="s">"storage.googleapis.com"</span><span class="p">}},</span>
    <span class="s">"httpCheck"</span><span class="p">:</span> <span class="p">{</span><span class="s">"path"</span><span class="p">:</span> <span class="s">"/&lt;bucket&gt;/index.html"</span><span class="p">,</span> <span class="s">"port"</span><span class="p">:</span> <span class="mi">443</span><span class="p">,</span> <span class="s">"useSsl"</span><span class="p">:</span> <span class="bp">True</span><span class="p">,</span> <span class="s">"validateSsl"</span><span class="p">:</span> <span class="bp">True</span><span class="p">,</span>
                  <span class="s">"acceptedResponseStatusCodes"</span><span class="p">:</span> <span class="p">[{</span><span class="s">"statusClass"</span><span class="p">:</span> <span class="s">"STATUS_CLASS_2XX"</span><span class="p">}]},</span>
    <span class="s">"period"</span><span class="p">:</span> <span class="s">"900s"</span><span class="p">,</span>
    <span class="s">"timeout"</span><span class="p">:</span> <span class="s">"10s"</span><span class="p">,</span>
    <span class="s">"selectedRegions"</span><span class="p">:</span> <span class="p">[</span><span class="s">"ASIA_PACIFIC"</span><span class="p">,</span> <span class="s">"USA_OREGON"</span><span class="p">,</span> <span class="s">"EUROPE"</span><span class="p">],</span>
<span class="p">}</span>
</code></pre></div></div>

<p>GCS 정적 사이트는 호스트가 <code class="language-plaintext highlighter-rouge">storage.googleapis.com</code> 이고 버킷 이름이 경로에 들어간다. <code class="language-plaintext highlighter-rouge">host</code> 와 <code class="language-plaintext highlighter-rouge">path</code> 를 나눠 넣어야 한다.</p>

<p>가동시간 체크는 <strong>리전이 없는 프로젝트 단위 리소스</strong>다. VPC도 서비스 계정도 필요 없다. 대상이 공개 URL이라 구글 체크 서버가 인터넷으로 접속하면 된다. 그래서 사이트가 어느 프로젝트에 있든 상관없이 감시용 프로젝트 하나에 몰아서 만들었다.</p>

<h2 id="3-알림-조건--2개-이상-리전에서-실패">3. 알림 조건 — “2개 이상 리전에서 실패”</h2>

<p>체크 결과는 <code class="language-plaintext highlighter-rouge">monitoring.googleapis.com/uptime_check/check_passed</code> 지표에 <strong>리전별 true/false</strong>로 쌓인다. 알림 정책은 이 지표를 이렇게 집계한다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">"conditionThreshold"</span><span class="p">:</span> <span class="p">{</span>
    <span class="s">"filter"</span><span class="p">:</span> <span class="s">'metric.type="monitoring.googleapis.com/uptime_check/check_passed" AND resource.type="uptime_url"'</span><span class="p">,</span>
    <span class="s">"aggregations"</span><span class="p">:</span> <span class="p">[{</span>
        <span class="s">"alignmentPeriod"</span><span class="p">:</span> <span class="s">"1800s"</span><span class="p">,</span>
        <span class="s">"perSeriesAligner"</span><span class="p">:</span> <span class="s">"ALIGN_NEXT_OLDER"</span><span class="p">,</span>      <span class="c1"># 리전별로 가장 최근 값 하나
</span>        <span class="s">"crossSeriesReducer"</span><span class="p">:</span> <span class="s">"REDUCE_COUNT_FALSE"</span><span class="p">,</span>  <span class="c1"># 그중 false 인 리전 수
</span>        <span class="s">"groupByFields"</span><span class="p">:</span> <span class="p">[</span><span class="s">"metric.label.check_id"</span><span class="p">],</span>  <span class="c1"># 사이트별로 따로
</span>    <span class="p">}],</span>
    <span class="s">"comparison"</span><span class="p">:</span> <span class="s">"COMPARISON_GT"</span><span class="p">,</span> <span class="s">"thresholdValue"</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>  <span class="c1"># 1보다 크면 = 2개 이상
</span>    <span class="s">"duration"</span><span class="p">:</span> <span class="s">"60s"</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">groupByFields</code> 를 체크 ID로 두면 <strong>정책 하나로 모든 사이트를 감시</strong>한다. 나중에 체크를 추가해도 정책은 손댈 필요가 없다.</p>

<p>같은 방식으로 <code class="language-plaintext highlighter-rouge">time_until_ssl_cert_expires</code> 지표에 “7일 미만” 조건을 걸어서 <strong>인증서 만료 임박</strong> 알림도 만들었다. 체크에 <code class="language-plaintext highlighter-rouge">validateSsl</code> 을 켜야 이 지표가 쌓인다.</p>

<h2 id="4-로그-기반-알림--예약-쿼리-실패-잡기">4. 로그 기반 알림 — 예약 쿼리 실패 잡기</h2>

<p>BigQuery 예약 쿼리는 실패해도 사이트는 멀쩡하다. 가동시간 체크로는 못 잡는다. 대신 실행할 때마다 Cloud Logging에 로그가 남는다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud logging <span class="nb">read</span> <span class="s1">'resource.type="bigquery_dts_config"'</span> <span class="nt">--project</span><span class="o">=</span>&lt;project&gt; <span class="nt">--limit</span><span class="o">=</span>3 <span class="se">\</span>
    <span class="nt">--format</span><span class="o">=</span><span class="s1">'value(severity,textPayload)'</span>
INFO    Summary: succeeded 1 <span class="nb">jobs</span>, failed 0 jobs.
INFO    Job scheduled_query_... completed successfully.
INFO    Job scheduled_query_... started.
</code></pre></div></div>

<p>그래서 로그 필터 <code class="language-plaintext highlighter-rouge">resource.type="bigquery_dts_config" AND severity&gt;=ERROR</code> 에 걸리는 로그가 생기면 알림을 보내게 했다.</p>

<p>여기서 두 가지를 알게 됐다.</p>

<p><strong>로그 기반 알림은 <code class="language-plaintext highlighter-rouge">notificationRateLimit</code> 이 필수다.</strong> 에러 로그가 수백 줄 쏟아지면 메일도 수백 통 올 수 있어서다. 1시간에 최대 1통으로 잡았다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">"conditions"</span><span class="p">:</span> <span class="p">[{</span><span class="s">"displayName"</span><span class="p">:</span> <span class="n">name</span><span class="p">,</span> <span class="s">"conditionMatchedLog"</span><span class="p">:</span> <span class="p">{</span><span class="s">"filter"</span><span class="p">:</span> <span class="n">log_filter</span><span class="p">}}],</span>
<span class="s">"alertStrategy"</span><span class="p">:</span> <span class="p">{</span><span class="s">"notificationRateLimit"</span><span class="p">:</span> <span class="p">{</span><span class="s">"period"</span><span class="p">:</span> <span class="s">"3600s"</span><span class="p">},</span> <span class="s">"autoClose"</span><span class="p">:</span> <span class="s">"1800s"</span><span class="p">},</span>
</code></pre></div></div>

<p><strong>알림 채널은 프로젝트에 속한다.</strong> 로그 기반 알림은 로그가 쌓이는 프로젝트에 만들어야 하고, 그 정책이 쓰는 이메일 채널도 같은 프로젝트에 있어야 한다. 감시용 프로젝트에 만든 채널을 가져다 쓸 수 없어서 채널을 하나 더 만들었다.</p>

<h2 id="5-한-번에-만드는-스크립트">5. 한 번에 만드는 스크립트</h2>

<p>콘솔에서 클릭으로 만들면 다음에 또 해야 할 때 기억에 의존하게 된다. 그래서 설정 파일 하나를 읽어서 전부 만드는 파이썬 스크립트로 짰다. 표준 라이브러리만 쓰고, 인증은 <code class="language-plaintext highlighter-rouge">gcloud auth print-access-token</code> 으로 받는다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"project"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
  </span><span class="nl">"email"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
  </span><span class="nl">"sites"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"lottery"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://storage.googleapis.com/&lt;bucket&gt;/index.html"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"blog"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://&lt;github-user&gt;.github.io/"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"log_alerts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"bigquery-scheduled-query-failed"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"project"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;project-with-scheduled-queries&gt;"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"filter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"resource.type=</span><span class="se">\"</span><span class="s2">bigquery_dts_config</span><span class="se">\"</span><span class="s2"> AND severity&gt;=ERROR"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">project</code> 와 <code class="language-plaintext highlighter-rouge">email</code> 을 비워두면 gcloud의 현재 프로젝트와 로그인 계정을 쓴다. 실제 URL과 주소가 들어간 설정 파일은 <code class="language-plaintext highlighter-rouge">.gitignore</code> 에 넣었다.</p>

<p>핵심은 <strong>이름으로 찾고, 없을 때만 만드는</strong> 함수 하나다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">ensure</span><span class="p">(</span><span class="n">project</span><span class="p">,</span> <span class="n">kind</span><span class="p">,</span> <span class="n">body</span><span class="p">):</span>
    <span class="s">"""kind = notificationChannels | uptimeCheckConfigs | alertPolicies"""</span>
    <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">call</span><span class="p">(</span><span class="s">"GET"</span><span class="p">,</span> <span class="sa">f</span><span class="s">"projects/</span><span class="si">{</span><span class="n">project</span><span class="si">}</span><span class="s">/</span><span class="si">{</span><span class="n">kind</span><span class="si">}</span><span class="s">?pageSize=1000"</span><span class="p">).</span><span class="n">get</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="p">[]):</span>
        <span class="k">if</span> <span class="n">item</span><span class="p">[</span><span class="s">"displayName"</span><span class="p">]</span> <span class="o">==</span> <span class="n">body</span><span class="p">[</span><span class="s">"displayName"</span><span class="p">]:</span>
            <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"있음  </span><span class="si">{</span><span class="n">kind</span><span class="si">:</span><span class="mi">20</span><span class="si">}</span><span class="s"> </span><span class="si">{</span><span class="n">body</span><span class="p">[</span><span class="s">'displayName'</span><span class="p">]</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">item</span><span class="p">[</span><span class="s">"name"</span><span class="p">]</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"생성  </span><span class="si">{</span><span class="n">kind</span><span class="si">:</span><span class="mi">20</span><span class="si">}</span><span class="s"> </span><span class="si">{</span><span class="n">body</span><span class="p">[</span><span class="s">'displayName'</span><span class="p">]</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">call</span><span class="p">(</span><span class="s">"POST"</span><span class="p">,</span> <span class="sa">f</span><span class="s">"projects/</span><span class="si">{</span><span class="n">project</span><span class="si">}</span><span class="s">/</span><span class="si">{</span><span class="n">kind</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="n">body</span><span class="p">)[</span><span class="s">"name"</span><span class="p">]</span>
</code></pre></div></div>

<p>세 가지 리소스 모두 목록 조회 응답의 키가 URL 경로와 같아서 함수 하나로 처리된다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>python3 watch.py
생성  uptimeCheckConfigs   site-watch lottery
생성  uptimeCheckConfigs   site-watch lunch-box
생성  uptimeCheckConfigs   site-watch trends-kr
생성  uptimeCheckConfigs   site-watch blog
생성  notificationChannels site-watch email
생성  alertPolicies        site-watch: 사이트 다운
생성  alertPolicies        site-watch: SSL 인증서 만료 임박
생성  notificationChannels site-watch email
생성  alertPolicies        site-watch: bigquery-scheduled-query-failed

<span class="nv">$ </span>python3 watch.py        <span class="c"># 다시 돌려도 중복 생성 없음</span>
있음  uptimeCheckConfigs   site-watch lottery
...
</code></pre></div></div>

<p>한계도 있다. 이름이 같으면 내용이 바뀌어도 건너뛴다. 설정을 고치려면 콘솔에서 지우고 다시 돌린다. 리소스가 몇 개 안 돼서 갱신 로직은 넣지 않았다.</p>

<h2 id="6-알림이-진짜-오는지-테스트">6. 알림이 진짜 오는지 테스트</h2>

<p>만들기만 하고 알림이 안 오면 의미가 없다. 사이트를 일부러 죽일 수는 없으니 <strong>실패하는 체크를 하나 더</strong> 만들었다. 존재하지 않는 경로를 1분 주기로 검사한다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud monitoring <span class="nb">uptime </span>create <span class="s2">"site-watch test-404"</span> <span class="se">\</span>
    <span class="nt">--resource-type</span><span class="o">=</span>uptime-url <span class="se">\</span>
    <span class="nt">--resource-labels</span><span class="o">=</span><span class="nv">host</span><span class="o">=</span>storage.googleapis.com,project_id<span class="o">=</span>&lt;project&gt; <span class="se">\</span>
    <span class="nt">--path</span><span class="o">=</span>/&lt;bucket&gt;/no-such-page.html <span class="nt">--protocol</span><span class="o">=</span>https <span class="se">\</span>
    <span class="nt">--period</span><span class="o">=</span>1 <span class="nt">--regions</span><span class="o">=</span>asia-pacific,usa-oregon,europe
</code></pre></div></div>

<p>2분 뒤 3개 리전 모두 실패로 기록됐고, <strong>만든 지 5분 만에 인시던트가 열리고 메일이 왔다.</strong> 확인 후 테스트 체크는 지웠다.</p>

<p>로그 기반 알림은 가짜 ERROR 로그를 한 줄 써서 테스트했다. <code class="language-plaintext highlighter-rouge">gcloud logging write</code> 는 기본 리소스 타입이 <code class="language-plaintext highlighter-rouge">global</code> 이라서, 필터에 걸리도록 <code class="language-plaintext highlighter-rouge">bigquery_dts_config</code> 타입을 직접 지정해야 한다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud logging write site-watch-test <span class="s2">"알림 시험용 가짜 에러"</span> <span class="nt">--project</span><span class="o">=</span>&lt;project&gt; <span class="se">\</span>
    <span class="nt">--severity</span><span class="o">=</span>ERROR <span class="nt">--monitored-resource-type</span><span class="o">=</span>bigquery_dts_config <span class="se">\</span>
    <span class="nt">--monitored-resource-labels</span><span class="o">=</span><span class="nv">project_id</span><span class="o">=</span>&lt;project&gt;,location<span class="o">=</span>us,config_id<span class="o">=</span><span class="nb">test</span>
</code></pre></div></div>

<p><strong>첫 시도는 알림이 안 왔다.</strong> 정책을 만들고 1분쯤 뒤에 로그를 썼는데, 로그는 분명히 기록됐는데도 인시던트가 열리지 않았다. 정책이 만들어진 직후라 아직 적용 전이었던 것으로 보인다. 6분 뒤 같은 로그를 다시 쓰니 <strong>2분 만에 인시던트가 열렸다.</strong> 로그 기반 알림을 테스트할 땐 정책을 만들고 몇 분 기다렸다가 로그를 쓰는 게 좋다.</p>

<p>테스트로 열린 인시던트는 조건이 사라지면 <code class="language-plaintext highlighter-rouge">autoClose</code> 로 30분 안에 닫히고, 그때 “해결” 메일이 한 통 더 온다.</p>

<h2 id="정리">정리</h2>

<ul>
  <li>정적 사이트 감시는 <strong>Cloud Monitoring 가동시간 체크 + 알림 정책</strong>이면 된다. 서버 0개, 개인 규모면 비용 0원이다.</li>
  <li>검사 주기는 <strong>최대 15분</strong>이다. 30분, 1시간은 없다. 리전은 최소 3개를 골라야 한다.</li>
  <li>알림 조건은 <code class="language-plaintext highlighter-rouge">REDUCE_COUNT_FALSE</code> 로 <strong>실패한 리전 수</strong>를 세서 2개 이상일 때만 울리게 하면 오탐이 준다.</li>
  <li>사이트는 멀쩡한데 데이터가 멈추는 문제는 가동시간 체크로 못 잡는다. <strong>실패 로그에 로그 기반 알림</strong>을 건다. <code class="language-plaintext highlighter-rouge">notificationRateLimit</code> 은 필수다.</li>
  <li>알림 채널은 프로젝트 소속이다. 로그 알림을 다른 프로젝트에 걸면 채널도 거기에 하나 더 필요하다.</li>
  <li>알림은 <strong>일부러 실패시켜서</strong> 확인하자. 로그 알림은 정책을 만든 직후에는 안 잡힐 수 있다.</li>
</ul>]]></content><author><name>Benyco</name></author><category term="Google Cloud" /><category term="GCP" /><category term="Cloud Monitoring" /><category term="Cloud Logging" /><category term="BigQuery" /><category term="알림" /><summary type="html"><![CDATA[운영 중인 정적 사이트가 죽거나 BigQuery 예약 쿼리가 실패하면 메일이 오게 만들었다. 서버 0개, 비용 0원. 검사 주기 제한과 로그 알림 테스트에서 한 번씩 걸렸다.]]></summary></entry><entry><title type="html">점심 메뉴 고르는 사이트 만들기 (카카오 로컬 API + Cloud Storage)</title><link href="https://benyco-dev.github.io/lunch-box/" rel="alternate" type="text/html" title="점심 메뉴 고르는 사이트 만들기 (카카오 로컬 API + Cloud Storage)" /><published>2026-09-11T00:00:00+00:00</published><updated>2026-09-11T00:00:00+00:00</updated><id>https://benyco-dev.github.io/lunch-box</id><content type="html" xml:base="https://benyco-dev.github.io/lunch-box/"><![CDATA[<p>점심시간마다 “뭐 먹지?”로 5분씩 쓰는 게 아까워서 사이트를 하나 만들었다. 회사 반경 500m 안의 식당만 모아서, 여럿이 하나씩 고른 뒤 그중에서 뽑거나 룰렛이나 월드컵으로 정한다.</p>

<p>구성은 <a href="/gcs-static-hosting/">Cloud Storage 정적 호스팅 글</a>에서 만든 패턴을 그대로 가져왔다. 버킷 하나, 키 파일 없는 GitHub Actions 배포. 이번 글은 그 위에서 <strong>새로 부딪힌 것들</strong>, 그러니까 식당 데이터를 어디서 가져오느냐와 GCP 결제 한도, 그리고 화면 버그 이야기다.</p>

<ul>
  <li>사이트: <a href="https://storage.googleapis.com/benyco-lunch-box/index.html">storage.googleapis.com/benyco-lunch-box/index.html</a></li>
  <li>코드: <a href="https://github.com/benyco-dev/lunch-box">github.com/benyco-dev/lunch-box</a></li>
</ul>

<p><img src="/images/uploads/2026-09-11-lunch-box/01-hero.webp" alt="점심 메뉴 사이트 첫 화면" /></p>

<p><em>오른쪽 사진 벽은 근처 식당 후기 글에서 가져온 대표 사진이다. 열마다 반대 방향으로 천천히 흐른다.</em></p>

<p>최종 구성은 이렇다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GitHub Actions (매월 1일)
  └─ 카카오 로컬 검색 ─ 반경 500m 음식점 ─┐
  └─ 카카오 블로그 검색 ─ 대표 사진 ──────┴→ restaurants.json → 되커밋
                                                    │
                        (Workload Identity Federation, 키 파일 없음)
                                                    ↓
                                          Cloud Storage 버킷 → 브라우저
브라우저 ─ Leaflet + OpenStreetMap 타일 (지도, API 키 없음)
</code></pre></div></div>

<h2 id="1-식당-데이터는-어디서-가져오나--세-번-바꿨다">1. 식당 데이터는 어디서 가져오나 — 세 번 바꿨다</h2>

<p>처음 계획은 <strong>네이버</strong>였다. 막상 붙이려고 보니 두 가지가 걸렸다.</p>

<ul>
  <li>지도를 띄우려면 NAVER Cloud Platform에서 Maps 앱을 따로 만들어야 하고, 식당 검색은 NAVER Developers에서 또 앱을 만들어야 한다. 키가 두 종류다.</li>
  <li>지역 검색 API는 <strong>쿼리당 최대 5건</strong>이고 <strong>반경 파라미터가 없다.</strong> “지식정보타운역 김치찌개”, “과천 갈현동 김치찌개”처럼 지역 키워드를 바꿔 여러 번 검색한 뒤, 좌표로 거리를 계산해서 500m 밖을 버리는 식으로 우회해야 했다.</li>
</ul>

<p>그래서 키가 필요 없는 <strong>OpenStreetMap</strong>부터 확인했다. Overpass API로 반경 500m 안 음식점을 조회해봤다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>curl <span class="nt">-s</span> <span class="nt">--data-urlencode</span> <span class="s1">'data=[out:json];nwr(around:500,&lt;위도&gt;,&lt;경도&gt;)[amenity~"^(restaurant|fast_food)$"];out center tags;'</span> <span class="se">\</span>
    https://overpass-api.de/api/interpreter | jq <span class="s1">'.elements | length'</span>
3
</code></pre></div></div>

<p><strong>3곳.</strong> 신도시 상가라 지도 데이터가 아직 안 채워진 동네였다. 데이터 소스로는 못 쓴다.</p>

<p>다음은 <strong>Google Places</strong>. 이미 쓰는 GCP 프로젝트에서 <code class="language-plaintext highlighter-rouge">gcloud services api-keys create</code> 로 키를 바로 만들 수 있어서 가입 절차가 없다는 게 장점이었다. 반경 검색 한 번에 20곳이 나왔다. 다만 국내 식당 정보가 카카오·네이버보다 적고, 나중에 평점·리뷰까지 붙이려고 보니 구글 데이터를 구글 지도가 아닌 지도와 함께 쓰면 약관에 걸린다.</p>

<p>최종 선택은 <strong>카카오 로컬 API</strong>였다.</p>

<table>
  <thead>
    <tr>
      <th>후보</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>네이버 지역 검색</td>
      <td>쿼리당 5건, 반경 파라미터 없음, 키 2종</td>
    </tr>
    <tr>
      <td>OpenStreetMap</td>
      <td>키 없음, 하지만 반경 안 식당 3곳</td>
    </tr>
    <tr>
      <td>Google Places</td>
      <td>한 번에 20곳, 국내 데이터 부족, 지도 약관</td>
    </tr>
    <tr>
      <td><strong>카카오 로컬</strong></td>
      <td><code class="language-plaintext highlighter-rouge">radius</code> + 음식점 카테고리 필터, 쿼리당 45건, 분류가 자세함</td>
    </tr>
  </tbody>
</table>

<p>카카오는 키워드 검색에 좌표와 반경, 카테고리 그룹 코드(<code class="language-plaintext highlighter-rouge">FD6</code> = 음식점)를 같이 넘길 수 있다. 메뉴 이름으로 검색하면 <strong>그 메뉴를 파는 반경 안 식당</strong>이 바로 나온다. 거리도 응답에 들어 있다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">body</span> <span class="o">=</span> <span class="n">get</span><span class="p">(</span><span class="n">LOCAL</span><span class="p">,</span> <span class="p">{</span>
    <span class="s">"query"</span><span class="p">:</span> <span class="n">menu</span><span class="p">,</span> <span class="s">"category_group_code"</span><span class="p">:</span> <span class="s">"FD6"</span><span class="p">,</span>
    <span class="s">"x"</span><span class="p">:</span> <span class="n">center</span><span class="p">[</span><span class="s">"lng"</span><span class="p">],</span> <span class="s">"y"</span><span class="p">:</span> <span class="n">center</span><span class="p">[</span><span class="s">"lat"</span><span class="p">],</span> <span class="s">"radius"</span><span class="p">:</span> <span class="mi">500</span><span class="p">,</span>
    <span class="s">"sort"</span><span class="p">:</span> <span class="s">"distance"</span><span class="p">,</span> <span class="s">"page"</span><span class="p">:</span> <span class="n">page</span><span class="p">,</span> <span class="s">"size"</span><span class="p">:</span> <span class="mi">15</span><span class="p">,</span>
<span class="p">},</span> <span class="n">key</span><span class="p">)</span>
</code></pre></div></div>

<p>분류도 <code class="language-plaintext highlighter-rouge">음식점 &gt; 한식 &gt; 찌개,전골</code> 처럼 자세하게 온다. 메뉴 92개로 돌렸더니 식당 79곳이 모였다. 같은 반경의 카카오 음식점 전체가 89곳이니, 메뉴 이름 검색만으로 90% 가까이 잡힌 셈이다.</p>

<p>지도는 <strong>Leaflet + OpenStreetMap 타일</strong>로 그렸다. 식당 데이터로 쓰기엔 비어 있던 OSM이지만, 길과 건물을 그리는 배경 지도로는 충분하다. 덕분에 지도용 키가 하나도 필요 없어졌다.</p>

<h2 id="2-엉뚱한-식당-사진--이미지-검색을-버렸다">2. 엉뚱한 식당 사진 — 이미지 검색을 버렸다</h2>

<p>룰렛을 원판 대신 <strong>식당 사진</strong>으로 돌리고 싶었다. 카카오 로컬 응답에는 사진이 없어서 같은 키로 쓸 수 있는 <strong>이미지 검색</strong>을 붙였다. “식당 이름 + 과천시”로 검색해서 첫 결과의 썸네일을 쓰는 방식이다.</p>

<p>첫 번째 제약은 크기였다. 썸네일 주소의 크기 부분만 바꾸면 더 큰 사진을 받을 수 있을까 싶었는데, <strong>130px 말고는 전부 404</strong>였다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> <span class="s2">"https://search4.kakaocdn.net/argon/130x130_85_c/&lt;id&gt;"</span>
200
<span class="nv">$ </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> <span class="s2">"https://search4.kakaocdn.net/argon/480x360_85_c/&lt;id&gt;"</span>
404
</code></pre></div></div>

<p>그래서 큰 사진 한 장 대신 <strong>작은 사진 여러 장이 흘러가는 릴</strong>로 디자인을 바꿨다.</p>

<p>두 번째가 진짜 문제였다. 배포하고 나서 “사진이 이상하다”는 말을 들었다. 중식당 칸에 <strong>키보드 사진</strong>이 떠 있었다. 이미지 검색 결과에는 제목도 본문도 없어서, 그 사진이 이 식당 사진인지 확인할 방법이 애초에 없었다.</p>

<p>해결은 <strong>블로그 검색</strong>이었다. 블로그 검색은 글 제목과 본문 일부를 같이 준다. 거기에 <strong>식당 이름과 지역 단어가 둘 다</strong> 들어 있는 글의 대표 썸네일만 쓰기로 했다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">pick_photo</span><span class="p">(</span><span class="n">docs</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="n">region_words</span><span class="p">,</span> <span class="n">exclude</span><span class="o">=</span><span class="p">()):</span>
    <span class="n">want</span> <span class="o">=</span> <span class="n">squash</span><span class="p">(</span><span class="n">base_name</span><span class="p">(</span><span class="n">name</span><span class="p">))</span>          <span class="c1"># '서울현방 과천점' → '서울현방'
</span>    <span class="k">for</span> <span class="n">d</span> <span class="ow">in</span> <span class="n">docs</span><span class="p">:</span>
        <span class="n">text</span> <span class="o">=</span> <span class="n">squash</span><span class="p">(</span><span class="n">d</span><span class="p">[</span><span class="s">"title"</span><span class="p">]</span> <span class="o">+</span> <span class="n">d</span><span class="p">[</span><span class="s">"contents"</span><span class="p">])</span>   <span class="c1"># 태그·엔티티·공백 제거
</span>        <span class="k">if</span> <span class="p">(</span><span class="n">d</span><span class="p">[</span><span class="s">"thumbnail"</span><span class="p">]</span> <span class="ow">and</span> <span class="n">d</span><span class="p">[</span><span class="s">"url"</span><span class="p">]</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">exclude</span>
                <span class="ow">and</span> <span class="n">want</span> <span class="ow">in</span> <span class="n">text</span> <span class="ow">and</span> <span class="nb">any</span><span class="p">(</span><span class="n">squash</span><span class="p">(</span><span class="n">w</span><span class="p">)</span> <span class="ow">in</span> <span class="n">text</span> <span class="k">for</span> <span class="n">w</span> <span class="ow">in</span> <span class="n">region_words</span><span class="p">)):</span>
            <span class="k">return</span> <span class="n">d</span><span class="p">[</span><span class="s">"thumbnail"</span><span class="p">],</span> <span class="n">d</span><span class="p">[</span><span class="s">"url"</span><span class="p">]</span>
    <span class="k">return</span> <span class="bp">None</span><span class="p">,</span> <span class="bp">None</span>                        <span class="c1"># 맞는 글이 없으면 사진 없음이 낫다
</span></code></pre></div></div>

<p>지점명(<code class="language-plaintext highlighter-rouge">과천점</code>)을 떼는 이유는 후기 글마다 표기가 제각각이라서다. 비교 전에 <code class="language-plaintext highlighter-rouge">&lt;b&gt;</code> 태그, <code class="language-plaintext highlighter-rouge">&amp;lt;</code> 같은 엔티티, 띄어쓰기를 전부 걷어낸다. “이화 향”, “&lt;이화향&gt;“도 같은 이름으로 잡힌다.</p>

<p>결과는 사진이 있는 식당이 71곳에서 <strong>77곳</strong>으로 오히려 늘었다. 79곳을 한 화면에 깔아서 눈으로 확인해보니 음식, 가게 외관, 메뉴판 사진이 나왔다.</p>

<p>하나 더 걸린 게 있었다. 한 식당은 후기 글 대표 사진에 <strong>블로그 작성자의 아이 얼굴</strong>이 나왔다. 사진 내용까지 코드로 판별할 수는 없어서, <code class="language-plaintext highlighter-rouge">menus.json</code> 에 제외할 글 주소 목록(<code class="language-plaintext highlighter-rouge">photoExclude</code>)을 두고 거기 넣은 글은 건너뛰게 했다.</p>

<h2 id="3-gcp에서-걸린-곳--프로젝트-id와-결제-한도">3. GCP에서 걸린 곳 — 프로젝트 ID와 결제 한도</h2>

<p>프로젝트 이름은 <code class="language-plaintext highlighter-rouge">lunch-box</code> 로 하고 싶었는데 이미 누가 쓰고 있었다. <strong>프로젝트 ID는 전 세계에서 고유</strong>해야 한다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud projects create lunch-box <span class="nt">--organization</span><span class="o">=</span>&lt;ORG_ID&gt;
ERROR: <span class="o">(</span>gcloud.projects.create<span class="o">)</span> Project creation failed.
The project ID you specified is already <span class="k">in </span>use by another project.
</code></pre></div></div>

<p>표시 이름은 <code class="language-plaintext highlighter-rouge">lunch-box</code> 로 두고 ID만 다르게 만들었다.</p>

<p>진짜 막힌 건 결제 연결이었다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud billing projects <span class="nb">link </span>my-project <span class="nt">--billing-account</span><span class="o">=</span>&lt;BILLING_ID&gt;
  - description: <span class="s1">'Cloud billing quota exceeded: https://support.google.com/code/contact/billing_quota_increase'</span>
</code></pre></div></div>

<p><strong>결제 계정 하나에 연결할 수 있는 프로젝트 수에 한도가 있다.</strong> 새로 만든 결제 계정이라 한도가 5개였고, 실습하느라 만든 프로젝트들로 이미 꽉 차 있었다. 이상하게도 버킷 생성은 결제 없이도 통과했는데, 파일을 올리는 순간 거부됐다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ERROR: (gcloud.storage.cp) HTTPError 403: The billing account for the owning project is disabled in state absent.
</code></pre></div></div>

<p>선택지는 두 개였다. 한도 증설을 요청하거나, 안 쓰는 프로젝트의 결제를 떼는 것. VM도 버킷도 없는 Terraform 실습 프로젝트가 있어서 그쪽 결제를 해제하고 새 프로젝트에 붙였다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud billing projects <span class="nb">unlink</span> &lt;안-쓰는-프로젝트&gt;
<span class="nv">$ </span>gcloud billing projects <span class="nb">link </span>my-project <span class="nt">--billing-account</span><span class="o">=</span>&lt;BILLING_ID&gt;
</code></pre></div></div>

<p>그리고 <strong>결제 연결 직후에 돌린 첫 rsync는 일부 파일만 올라갔다.</strong> 반영에 몇 초가 걸리는 것 같다. 한 번 더 돌리니 전부 올라갔다. 연결 직후라면 업로드 결과를 꼭 확인하는 게 좋다.</p>

<p>WIF(Workload Identity Federation) 구성과 <code class="language-plaintext highlighter-rouge">legacyBucketReader</code> 권한은 <a href="/gcs-static-hosting/">지난 글</a>에서 겪은 대로 처음부터 넣었더니 한 번에 통과했다.</p>

<h2 id="4-기능-결정은-먼저-연출은-나중에">4. 기능: 결정은 먼저, 연출은 나중에</h2>

<p><img src="/images/uploads/2026-09-11-lunch-box/02-group-result.webp" alt="함께 고르기 탭에서 4명이 식당을 고르고 뽑은 결과" /></p>

<p><em>PC 화면. 왼쪽에서 4명이 식당을 고르고 뽑으면 오른쪽에 당첨 식당과 지도가 뜬다.</em></p>

<p>탭은 세 개다.</p>

<ul>
  <li><strong>함께</strong>: 인원(2~10명)을 정하고 사람마다 <code class="language-plaintext highlighter-rouge">식당 이름 (메뉴)</code> 목록에서 고르거나 주사위로 채운다. 다 고르면 그중 하나를 뽑는다.</li>
  <li><strong>룰렛</strong>: 종목(한식, 중식 등)의 근처 식당 전체에서 하나를 뽑는다.</li>
  <li><strong>월드컵</strong>: 메뉴로 8강·16강·32강 토너먼트를 한다.</li>
</ul>

<p>처음 “함께” 탭은 메뉴를 고르게 만들었다. 써보니 <strong>“김치찌개”보다 “정가네 (김치찌개, 된장찌개)”를 보고 고르는 게 훨씬 빨리 결정된다</strong>는 피드백이 나와서 식당 단위로 바꿨다.</p>

<p>뽑기 확률은 <strong>한 사람이 한 표</strong>다. 4명 중 2명이 같은 식당을 고르면 그 식당은 표를 두 장 가진다. 따로 가중치 로직을 짤 필요 없이, 고른 목록에서 그냥 무작위로 하나를 집으면 그렇게 된다.</p>

<div style="display:flex; gap:12px; flex-wrap:wrap">
  <img src="/images/uploads/2026-09-11-lunch-box/03-worldcup-mobile.webp" alt="모바일 메뉴 월드컵 16강 대결 화면" style="width:48%; min-width:240px" />
  <img src="/images/uploads/2026-09-11-lunch-box/04-roulette-dark.webp" alt="다크 모드에서 사진 릴이 식당에 멈춘 화면" style="width:48%; min-width:240px" />
</div>

<p><em>왼쪽은 모바일 메뉴 월드컵, 오른쪽은 다크 모드에서 사진 릴이 멈춘 모습.</em></p>

<p>사진 릴에서 신경 쓴 건 <strong>당첨을 애니메이션이 정하지 않게</strong> 하는 것이었다. 흔한 구현은 릴을 무작위 거리만큼 돌리고 멈춘 칸을 당첨으로 읽는 건데, 그러면 칸 너비나 반올림에 따라 확률이 미묘하게 흔들린다.</p>

<p>그래서 순서를 뒤집었다. <strong>당첨을 먼저 뽑고</strong>, 릴에는 그 식당을 특정 칸에 박아둔 뒤 거기까지 미끄러지는 연출만 한다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 릴에 깔 칸 목록. 당첨은 target 칸에 두고 나머지는 흘러가는 연출용</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nx">reelStrip</span><span class="p">(</span><span class="nx">pool</span><span class="p">,</span> <span class="nx">winner</span><span class="p">,</span> <span class="p">{</span> <span class="nx">length</span> <span class="o">=</span> <span class="mi">60</span><span class="p">,</span> <span class="nx">tail</span> <span class="o">=</span> <span class="mi">6</span><span class="p">,</span> <span class="nx">rand</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">random</span> <span class="p">}</span> <span class="o">=</span> <span class="p">{})</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">target</span> <span class="o">=</span> <span class="nx">length</span> <span class="o">-</span> <span class="nx">tail</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
  <span class="k">return</span> <span class="p">{</span> <span class="nx">target</span><span class="p">,</span> <span class="na">items</span><span class="p">:</span> <span class="nb">Array</span><span class="p">.</span><span class="k">from</span><span class="p">({</span> <span class="nx">length</span> <span class="p">},</span> <span class="p">(</span><span class="nx">_</span><span class="p">,</span> <span class="nx">i</span><span class="p">)</span> <span class="o">=&gt;</span>
    <span class="nx">i</span> <span class="o">===</span> <span class="nx">target</span> <span class="p">?</span> <span class="nx">winner</span> <span class="p">:</span> <span class="nx">pool</span><span class="p">[</span><span class="nb">Math</span><span class="p">.</span><span class="nx">floor</span><span class="p">(</span><span class="nx">rand</span><span class="p">()</span> <span class="o">*</span> <span class="nx">pool</span><span class="p">.</span><span class="nx">length</span><span class="p">)])</span> <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>화면 쪽은 그 칸의 가운데가 표시선에 오도록 <code class="language-plaintext highlighter-rouge">translateX</code> 값을 계산해서 CSS transition으로 5.5초 동안 감속시킨다. 이 함수는 DOM을 몰라서 node 테스트에서 “당첨이 늘 target 칸에 있는지”를 그대로 검증한다.</p>

<h2 id="5-화면에서-걸린-곳">5. 화면에서 걸린 곳</h2>

<p><strong>사진 띠가 화면을 수천 px로 늘렸다.</strong> 모바일에서 갑자기 모든 글자가 사라지고 가로 스크롤이 생겼다. 원인은 릴의 사진 띠였다. 60칸짜리 띠에 <code class="language-plaintext highlighter-rouge">width: max-content</code> 를 줬는데, 이걸 담은 1열 그리드의 칸 너비가 기본값 <code class="language-plaintext highlighter-rouge">auto</code> 라서 <strong>자식의 최대 콘텐츠 너비만큼 늘어났다.</strong></p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* 전: grid-template-columns 미지정 → auto → 7000px */</span>
<span class="nc">.layout</span> <span class="p">{</span> <span class="nl">display</span><span class="p">:</span> <span class="n">grid</span><span class="p">;</span> <span class="py">grid-template-columns</span><span class="p">:</span> <span class="n">minmax</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">1</span><span class="n">fr</span><span class="p">);</span> <span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">minmax(0, 1fr)</code> 로 최솟값을 0으로 못 박으니 칸이 화면 너비에 고정되고 띠는 안에서 잘렸다.</p>

<p><strong><code class="language-plaintext highlighter-rouge">hidden</code> 속성이 안 먹었다.</strong> 결과가 나왔는데 “여기에 식당이 나와요” 안내가 그대로 남았다. 안내 요소에 <code class="language-plaintext highlighter-rouge">display: grid</code> 를 줬는데, 이게 브라우저 기본 스타일의 <code class="language-plaintext highlighter-rouge">[hidden] { display: none }</code> 보다 우선이었다. 한 줄로 해결했다.</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">[</span><span class="nt">hidden</span><span class="o">]</span> <span class="p">{</span> <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span> <span class="cp">!important</span><span class="p">;</span> <span class="p">}</span>
</code></pre></div></div>

<p><strong>같은 클래스 이름이 두 군데서 쓰였다.</strong> “함께” 탭에서 아직 안 고른 줄에 <code class="language-plaintext highlighter-rouge">.empty</code> 클래스를 붙였더니 그 줄만 높이 220px로 부풀었다. 결과 영역의 빈 상태 안내가 이미 <code class="language-plaintext highlighter-rouge">.empty</code> 였다. 이름을 <code class="language-plaintext highlighter-rouge">.unset</code> 으로 바꿨다.</p>

<p><strong>한글이 단어 중간에서 끊겼다.</strong> “과천지식정보타 / 운점”. <code class="language-plaintext highlighter-rouge">word-break: keep-all</code> 을 body에 줬다.</p>

<h2 id="6-비용">6. 비용</h2>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>규모</th>
      <th>월 비용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cloud Storage 저장</td>
      <td>약 150KB</td>
      <td>사실상 0</td>
    </tr>
    <tr>
      <td>네트워크 이그레스</td>
      <td>방문자 수에 비례. 사진은 카카오 CDN에서 받는다</td>
      <td>1GB당 약 $0.12</td>
    </tr>
    <tr>
      <td>카카오 API</td>
      <td>월 1회 수백 호출</td>
      <td>무료 한도 안</td>
    </tr>
  </tbody>
</table>

<p>사진과 지도 타일은 전부 외부 CDN에서 받아서 <strong>버킷이 내보내는 건 HTML·JS·JSON뿐</strong>이다.</p>

<h2 id="정리">정리</h2>

<ul>
  <li>식당 데이터는 <strong>카카오 로컬</strong>이 제일 편했다. 반경과 음식점 카테고리를 한 번에 걸 수 있다. 네이버는 쿼리당 5건, OSM은 동네에 따라 텅 비어 있다.</li>
  <li><strong>이미지 검색 첫 결과를 믿지 말자.</strong> 제목·본문이 있는 블로그 검색으로 이름과 지역을 대조해야 엉뚱한 사진이 안 섞인다.</li>
  <li><strong>결제 계정에는 연결 가능한 프로젝트 수 한도가 있다.</strong> 실습 프로젝트를 여러 개 만들었다면 먼저 확인하자. 버킷 생성은 통과하고 업로드에서 403이 난다.</li>
  <li>무작위 뽑기에 애니메이션을 붙일 땐 <strong>당첨을 먼저 정하고 연출은 거기에 맞춘다.</strong></li>
  <li>CSS 그리드 안에 긴 요소를 넣을 땐 <strong><code class="language-plaintext highlighter-rouge">minmax(0, 1fr)</code></strong>. <code class="language-plaintext highlighter-rouge">auto</code> 칸은 자식 따라 늘어난다.</li>
</ul>

<p>다음엔 이 인프라를 Terraform으로 옮겨볼 생각이다. 버킷, 서비스 계정, WIF까지 <code class="language-plaintext highlighter-rouge">gcloud</code> 명령이 꽤 길어졌다.</p>]]></content><author><name>Benyco</name></author><category term="Google Cloud" /><category term="GCP" /><category term="Cloud Storage" /><category term="GitHub Actions" /><category term="카카오 API" /><category term="정적 호스팅" /><category term="트러블슈팅" /><summary type="html"><![CDATA[회사 반경 500m 식당으로 함께 고르기, 사진 룰렛, 메뉴 월드컵을 만들었다. 데이터 소스를 세 번 바꿨고, 결제 한도와 엉뚱한 식당 사진에서 한 번씩 걸렸다.]]></summary></entry><entry><title type="html">Terraform으로 GCP에 VPC + VM 만들고 지워보기</title><link href="https://benyco-dev.github.io/terraform-gcp-vpc-vm/" rel="alternate" type="text/html" title="Terraform으로 GCP에 VPC + VM 만들고 지워보기" /><published>2026-09-11T00:00:00+00:00</published><updated>2026-09-11T00:00:00+00:00</updated><id>https://benyco-dev.github.io/terraform-gcp-vpc-vm</id><content type="html" xml:base="https://benyco-dev.github.io/terraform-gcp-vpc-vm/"><![CDATA[<p><a href="/gcp-docker-nginx/">Compute Engine에 Docker로 Nginx를 띄울 때</a>도, <a href="/gcs-static-hosting/">Cloud Storage로 정적 사이트를 호스팅할 때</a>도 리소스는 전부 콘솔 클릭이나 <code class="language-plaintext highlighter-rouge">gcloud</code> 명령으로 만들었다. 그러다 보니 한계가 느껴졌다. 뭘 만들었는지 기록이 남지 않고, 지울 때도 하나씩 찾아서 지워야 했다.</p>

<p>그래서 인프라를 코드로 관리하는 <strong>Terraform</strong>을 직접 써보기로 했다. 이번 실습의 목표는 간단하다.</p>

<blockquote>
  <p>코드 한 파일로 <strong>VPC → 서브넷 → VM → 방화벽</strong>을 만들고, 확인하고, 한 번에 지운다.</p>
</blockquote>

<h2 id="실습-환경">실습 환경</h2>

<ul>
  <li>Terraform v1.16.1</li>
  <li>Google Cloud SDK 583.0.0</li>
  <li>google provider <code class="language-plaintext highlighter-rouge">~&gt; 5.0</code></li>
  <li>리전: 서울(<code class="language-plaintext highlighter-rouge">asia-northeast3</code>)</li>
</ul>

<h2 id="작성한-코드">작성한 코드</h2>

<p><code class="language-plaintext highlighter-rouge">main.tf</code> 하나에 전부 넣었다. <code class="language-plaintext highlighter-rouge">my-project</code> 는 본인 프로젝트 ID로 바꿔 쓰면 된다.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">terraform</span> <span class="p">{</span>
  <span class="nx">required_version</span> <span class="p">=</span> <span class="s2">"&gt;= 1.5.0"</span>
  <span class="nx">required_providers</span> <span class="p">{</span>
    <span class="nx">google</span> <span class="p">=</span> <span class="p">{</span>
      <span class="nx">source</span>  <span class="p">=</span> <span class="s2">"hashicorp/google"</span>
      <span class="nx">version</span> <span class="p">=</span> <span class="s2">"~&gt; 5.0"</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">provider</span> <span class="s2">"google"</span> <span class="p">{</span>
  <span class="nx">project</span> <span class="p">=</span> <span class="s2">"my-project"</span>
  <span class="nx">region</span>  <span class="p">=</span> <span class="s2">"asia-northeast3"</span>
  <span class="nx">zone</span>    <span class="p">=</span> <span class="s2">"asia-northeast3-a"</span>
<span class="p">}</span>

<span class="c1"># 1. 커스텀 VPC</span>
<span class="nx">resource</span> <span class="s2">"google_compute_network"</span> <span class="s2">"custom_vpc"</span> <span class="p">{</span>
  <span class="nx">name</span>                    <span class="p">=</span> <span class="s2">"tf-custom-vpc"</span>
  <span class="nx">auto_create_subnetworks</span> <span class="p">=</span> <span class="kc">false</span>
<span class="p">}</span>

<span class="c1"># 2. 서브넷</span>
<span class="nx">resource</span> <span class="s2">"google_compute_subnetwork"</span> <span class="s2">"custom_subnet"</span> <span class="p">{</span>
  <span class="nx">name</span>          <span class="p">=</span> <span class="s2">"tf-subnet-seoul"</span>
  <span class="nx">ip_cidr_range</span> <span class="p">=</span> <span class="s2">"10.0.1.0/24"</span>
  <span class="nx">region</span>        <span class="p">=</span> <span class="s2">"asia-northeast3"</span>
  <span class="nx">network</span>       <span class="p">=</span> <span class="nx">google_compute_network</span><span class="err">.</span><span class="nx">custom_vpc</span><span class="err">.</span><span class="nx">id</span>
<span class="p">}</span>

<span class="c1"># 3. VM</span>
<span class="nx">resource</span> <span class="s2">"google_compute_instance"</span> <span class="s2">"vm_instance"</span> <span class="p">{</span>
  <span class="nx">name</span>         <span class="p">=</span> <span class="s2">"tf-demo-vm"</span>
  <span class="nx">machine_type</span> <span class="p">=</span> <span class="s2">"e2-micro"</span>
  <span class="nx">zone</span>         <span class="p">=</span> <span class="s2">"asia-northeast3-a"</span>

  <span class="nx">boot_disk</span> <span class="p">{</span>
    <span class="nx">initialize_params</span> <span class="p">{</span>
      <span class="nx">image</span> <span class="p">=</span> <span class="s2">"debian-cloud/debian-12"</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nx">network_interface</span> <span class="p">{</span>
    <span class="nx">subnetwork</span> <span class="p">=</span> <span class="nx">google_compute_subnetwork</span><span class="err">.</span><span class="nx">custom_subnet</span><span class="err">.</span><span class="nx">id</span>
    <span class="nx">access_config</span> <span class="p">{}</span> <span class="c1"># 외부 공인 IP 할당</span>
  <span class="p">}</span>

  <span class="nx">tags</span> <span class="p">=</span> <span class="p">[</span><span class="s2">"web-server"</span><span class="p">]</span>
<span class="p">}</span>

<span class="c1"># 4. 방화벽 (SSH 허용)</span>
<span class="nx">resource</span> <span class="s2">"google_compute_firewall"</span> <span class="s2">"allow_ssh"</span> <span class="p">{</span>
  <span class="nx">name</span>    <span class="p">=</span> <span class="s2">"tf-allow-ssh"</span>
  <span class="nx">network</span> <span class="p">=</span> <span class="nx">google_compute_network</span><span class="err">.</span><span class="nx">custom_vpc</span><span class="err">.</span><span class="nx">id</span>

  <span class="nx">allow</span> <span class="p">{</span>
    <span class="nx">protocol</span> <span class="p">=</span> <span class="s2">"tcp"</span>
    <span class="nx">ports</span>    <span class="p">=</span> <span class="p">[</span><span class="s2">"22"</span><span class="p">]</span>
  <span class="p">}</span>

  <span class="nx">source_ranges</span> <span class="p">=</span> <span class="p">[</span><span class="s2">"0.0.0.0/0"</span><span class="p">]</span>
  <span class="nx">target_tags</span>   <span class="p">=</span> <span class="p">[</span><span class="s2">"web-server"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>코드를 쓰면서 눈에 띈 점이 두 가지 있었다.</p>

<p><strong>리소스끼리 참조하면 순서가 자동으로 정해진다.</strong> 서브넷의 <code class="language-plaintext highlighter-rouge">network = google_compute_network.custom_vpc.id</code> 처럼 다른 리소스를 가리키기만 하면, Terraform이 VPC를 먼저 만들어야 한다는 걸 알아서 판단한다. <code class="language-plaintext highlighter-rouge">depends_on</code> 을 따로 쓸 필요가 없었다.</p>

<p><strong>방화벽과 VM은 태그로 연결된다.</strong> 방화벽의 <code class="language-plaintext highlighter-rouge">target_tags</code> 와 VM의 <code class="language-plaintext highlighter-rouge">tags</code> 가 <code class="language-plaintext highlighter-rouge">web-server</code> 로 같으면 규칙이 적용된다. 코드상 참조 관계가 없어서 Terraform은 둘을 병렬로 처리한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tf-custom-vpc
 ├── tf-subnet-seoul
 │    └── tf-demo-vm
 └── tf-allow-ssh ···(태그 web-server로 매칭)··· tf-demo-vm
</code></pre></div></div>

<h2 id="1-인증">1. 인증</h2>

<p>Terraform이 내 GCP 계정으로 API를 호출하려면 자격증명이 필요하다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud auth application-default login
</code></pre></div></div>

<p>브라우저로 로그인하면 로컬에 <strong>ADC(Application Default Credentials)</strong> 가 저장된다. Terraform google provider는 이 파일을 자동으로 읽는다. 서비스 계정 키 파일을 코드 옆에 둘 필요가 없어서 편했다.</p>

<p>로그인 후 <code class="language-plaintext highlighter-rouge">Quota project "..." was added to ADC</code> 라는 메시지가 떴다. gcloud 기본 프로젝트가 quota 프로젝트로 잡혔다는 뜻이다. 실습 프로젝트로 맞추고 싶다면 아래 명령을 쓰면 된다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud auth application-default set-quota-project my-project
</code></pre></div></div>

<h2 id="2-init과-plan">2. init과 plan</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>terraform init
<span class="nv">$ </span>terraform plan
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">init</code> 은 google provider 플러그인을 내려받는 단계다. <code class="language-plaintext highlighter-rouge">plan</code> 은 실제로 만들기 전에 무엇이 바뀌는지 미리 보여준다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Plan: 4 to add, 0 to change, 0 to destroy.
</code></pre></div></div>

<p>예상대로 리소스 4개가 추가된다고 나왔다. <code class="language-plaintext highlighter-rouge">id</code> 나 <code class="language-plaintext highlighter-rouge">self_link</code> 같은 값은 <code class="language-plaintext highlighter-rouge">(known after apply)</code> 로 표시됐다. GCP가 리소스를 실제로 만들어야 정해지는 값들이다.</p>

<h2 id="3-apply--첫-번째로-걸린-곳">3. apply — 첫 번째로 걸린 곳</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>terraform apply
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">yes</code> 를 입력하자 VPC 생성에서 바로 실패했다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Error: Error creating Network: googleapi: Error 403: Compute Engine API has not
been used in project my-project before or it is disabled.
reason: SERVICE_DISABLED
</code></pre></div></div>

<p>새로 만든 프로젝트라 <strong>Compute Engine API가 꺼져 있었던 것</strong>이다. 콘솔에서 VM을 처음 만들 때는 API를 켜라는 안내가 뜨지만, Terraform은 API를 대신 켜주지 않는다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud services <span class="nb">enable </span>compute.googleapis.com <span class="nt">--project</span><span class="o">=</span>my-project
Operation <span class="s2">"operations/..."</span> finished successfully.
</code></pre></div></div>

<p>API를 켠 직후에는 반영까지 몇 분 걸릴 수 있다고 해서 잠깐 기다렸다가 다시 apply했다.</p>

<h2 id="4-다시-apply해서-완료">4. 다시 apply해서 완료</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>google_compute_instance.vm_instance: Creating...
google_compute_instance.vm_instance: Still creating... [00m10s elapsed]
google_compute_instance.vm_instance: Creation complete after 13s

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
</code></pre></div></div>

<p>마지막 apply에서는 VM 하나만 추가됐다. 나머지 리소스는 앞선 시도에서 이미 만들어져 <strong>state 파일에 기록</strong>돼 있었기 때문이다.</p>

<p>여기서 Terraform의 장점을 확실히 느꼈다. apply가 중간에 실패해도 성공한 리소스는 state에 남는다. 원인을 고치고 다시 실행하면 <strong>빠진 것만 채운다.</strong> 처음부터 다시 만들거나 중복으로 생기지 않는다.</p>

<h2 id="5-콘솔에서-확인">5. 콘솔에서 확인</h2>

<p>Compute Engine → VM instances에 들어가 보니 <code class="language-plaintext highlighter-rouge">tf-demo-vm</code> 이 서울 존에서 실행 중이었다.</p>

<ul>
  <li><strong>내부 IP는 <code class="language-plaintext highlighter-rouge">10.0.1.2</code> 였다.</strong> <code class="language-plaintext highlighter-rouge">/24</code> 서브넷에서 <code class="language-plaintext highlighter-rouge">.0</code> 은 네트워크 주소, <code class="language-plaintext highlighter-rouge">.1</code> 은 게이트웨이로 GCP가 예약하기 때문에 첫 VM이 <code class="language-plaintext highlighter-rouge">.2</code> 를 받는다.</li>
  <li><strong>외부 IP도 붙어 있었다.</strong> <code class="language-plaintext highlighter-rouge">access_config {}</code> 를 비워두기만 해도 임시(ephemeral) 공인 IP가 할당된다.</li>
</ul>

<p>코드 몇십 줄로 네트워크부터 VM까지 한 번에 올라온 걸 보니 신기했다.</p>

<h2 id="6-destroy로-전부-정리">6. destroy로 전부 정리</h2>

<p>실습이 끝났으니 요금이 나가지 않게 바로 지웠다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>terraform destroy
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Plan: 0 to add, 0 to change, 4 to destroy.
</code></pre></div></div>

<p>삭제 순서가 흥미로웠다. <strong>만든 순서의 반대로</strong> 지워졌다.</p>

<table>
  <thead>
    <tr>
      <th>순서</th>
      <th>리소스</th>
      <th>소요 시간</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>방화벽 <code class="language-plaintext highlighter-rouge">tf-allow-ssh</code> (병렬)</td>
      <td>12초</td>
    </tr>
    <tr>
      <td>1</td>
      <td>VM <code class="language-plaintext highlighter-rouge">tf-demo-vm</code> (병렬)</td>
      <td>21초</td>
    </tr>
    <tr>
      <td>2</td>
      <td>서브넷 <code class="language-plaintext highlighter-rouge">tf-subnet-seoul</code></td>
      <td>21초</td>
    </tr>
    <tr>
      <td>3</td>
      <td>VPC <code class="language-plaintext highlighter-rouge">tf-custom-vpc</code></td>
      <td>22초</td>
    </tr>
  </tbody>
</table>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Destroy complete! Resources: 4 destroyed.
</code></pre></div></div>

<p>VM이 서브넷을 쓰고 있고, 서브넷과 방화벽은 VPC에 속해 있다. 그래서 바깥쪽부터 지워야 하는데, 이 순서도 Terraform이 알아서 계산했다. 콘솔에서 손으로 지웠다면 “사용 중인 리소스라 삭제할 수 없다”는 에러를 몇 번은 만났을 것이다.</p>

<h2 id="사용한-gcp-서비스">사용한 GCP 서비스</h2>

<table>
  <thead>
    <tr>
      <th>서비스</th>
      <th>용도</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Compute Engine</td>
      <td>VM 인스턴스 실행</td>
    </tr>
    <tr>
      <td>VPC Network</td>
      <td>커스텀 VPC와 서브넷으로 사설 네트워크 구성</td>
    </tr>
    <tr>
      <td>VPC 방화벽 규칙</td>
      <td>태그 기반으로 SSH 인바운드 허용</td>
    </tr>
    <tr>
      <td>Service Usage API</td>
      <td>Compute Engine API 활성화</td>
    </tr>
    <tr>
      <td>IAM / ADC</td>
      <td>Terraform이 사용할 자격증명 발급</td>
    </tr>
  </tbody>
</table>

<h2 id="정리">정리</h2>

<ul>
  <li><strong>선언형이라 편하다.</strong> “무엇이 있어야 하는가”만 쓰면 생성·삭제 순서는 Terraform이 참조 관계로 계산한다.</li>
  <li><strong>state가 기준이다.</strong> Terraform은 state 파일과 실제 클라우드를 비교해서 plan을 만든다. state에는 프로젝트 ID나 IP 같은 정보가 들어가므로 <strong><code class="language-plaintext highlighter-rouge">.gitignore</code> 에 꼭 넣자.</strong></li>
  <li><strong>실패해도 다시 돌리면 된다.</strong> 부분 실패 후 재실행하면 남은 것만 만든다.</li>
  <li><strong>새 프로젝트에선 API부터 켜자.</strong> Terraform은 꺼진 API를 대신 켜주지 않는다.</li>
  <li><strong>정리까지가 실습이다.</strong> <code class="language-plaintext highlighter-rouge">destroy</code> 한 줄로 흔적 없이 지울 수 있다. 콘솔에서 수동으로 지우면 state와 실제 상태가 어긋난다.</li>
</ul>

<p>다음엔 이번 코드를 조금 더 실전에 가깝게 다듬어볼 생각이다.</p>

<ul>
  <li>프로젝트 ID를 <code class="language-plaintext highlighter-rouge">variable</code> 로 빼고 <code class="language-plaintext highlighter-rouge">terraform.tfvars</code> 로 주입하기</li>
  <li>SSH 허용 범위를 <code class="language-plaintext highlighter-rouge">0.0.0.0/0</code> 에서 IAP 대역(<code class="language-plaintext highlighter-rouge">35.235.240.0/20</code>)으로 좁히고 <code class="language-plaintext highlighter-rouge">gcloud compute ssh --tunnel-through-iap</code> 로 접속하기</li>
  <li>API 활성화도 <code class="language-plaintext highlighter-rouge">google_project_service</code> 리소스로 코드화하기</li>
  <li>state를 GCS 버킷 backend에 저장하기</li>
</ul>]]></content><author><name>Benyco</name></author><category term="Google Cloud" /><category term="Terraform" /><category term="GCP" /><category term="Terraform" /><category term="Compute Engine" /><category term="VPC" /><category term="트러블슈팅" /><summary type="html"><![CDATA[코드 한 파일로 VPC, 서브넷, VM, 방화벽을 만들고 콘솔에서 확인한 뒤 destroy 한 줄로 전부 지웠다. 새 프로젝트라 Compute Engine API에서 한 번 걸렸다.]]></summary></entry><entry><title type="html">Claude Code와 토스증권 API로 자동매매 시작하기 1편 (환경 구성)</title><link href="https://benyco-dev.github.io/auto-trading-1-setup/" rel="alternate" type="text/html" title="Claude Code와 토스증권 API로 자동매매 시작하기 1편 (환경 구성)" /><published>2026-09-10T00:00:00+00:00</published><updated>2026-09-10T00:00:00+00:00</updated><id>https://benyco-dev.github.io/auto-trading-1-setup</id><content type="html" xml:base="https://benyco-dev.github.io/auto-trading-1-setup/"><![CDATA[<p>유튜브 자동매매 강의 시리즈(<a href="https://www.youtube.com/watch?v=7VG7ugLu6qs">1강</a>)를 참고해서 미국 주식 자동매매를 만들어봤다. 코드는 전부 공개해뒀다 — <a href="https://github.com/benyco-dev/project-auto-trading-scanner"><code class="language-plaintext highlighter-rouge">project-auto-trading-scanner</code></a>.</p>

<p>3부작으로 나눠 정리한다. 1편은 환경 구성과 전략 결정, 2편은 수수료, 3편은 백테스트를 어디까지 믿을 것인가.</p>

<p>먼저 어떻게 구성했는지부터 훑고, 후반부에 전략을 왜 바꿨는지로 넘어간다.</p>

<h2 id="구성-요약">구성 요약</h2>

<table>
  <thead>
    <tr>
      <th>영역</th>
      <th>사용한 것</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>코드 작성</td>
      <td>Claude Code (바이브 코딩)</td>
    </tr>
    <tr>
      <td>시세 데이터</td>
      <td><code class="language-plaintext highlighter-rouge">yfinance</code> (무료, 지연 시세)</td>
    </tr>
    <tr>
      <td>주문</td>
      <td>토스증권 Open API</td>
    </tr>
    <tr>
      <td>서버</td>
      <td>GCP Compute Engine (서울, <code class="language-plaintext highlighter-rouge">e2-small</code>)</td>
    </tr>
    <tr>
      <td>배포</td>
      <td>GitHub Actions → Docker Hub → VM <code class="language-plaintext highlighter-rouge">docker pull</code></td>
    </tr>
    <tr>
      <td>스케줄링</td>
      <td>VM cron 2개 (마감 후 스캔 / 개장 후 매매)</td>
    </tr>
  </tbody>
</table>

<p>하루 흐름은 이렇다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>06:30 KST (미국장 마감 후)
  cron → docker pull → S&amp;P 500 스캔 → signals_history.csv 누적

22:45 KST (미국장 개장 후)  청산 규칙 검사 → 매도
22:50 KST                  확보된 현금으로 매수
</code></pre></div></div>

<p>스캔은 그날 종가가 확정된 뒤에, 주문은 장이 열린 뒤에 나간다.</p>

<h2 id="claude-code로-바이브-코딩하기">Claude Code로 바이브 코딩하기</h2>

<p>코드는 처음부터 끝까지 Claude Code로 작성했다. 터미널에서 자연어로 요구사항을 말하면 파일을 직접 읽고 고치는 CLI 도구다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> @anthropic-ai/claude-code
<span class="nb">cd </span>auto-trading-scanner
claude
</code></pre></div></div>

<p>실제로 쓴 흐름은 이렇다.</p>

<p><strong>1. 프로젝트 규칙을 <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>에 적어둔다</strong></p>

<p>세션마다 자동으로 읽히는 파일이다. 매번 설명하기 싫은 것들을 여기 넣는다.</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">-</span> 로컬에서 docker build 하지 않는다. GitHub Actions에서 빌드/푸시.
<span class="p">-</span> 실주문 경로를 건드리는 변경은 항상 dry-run 기본값을 유지할 것.
<span class="p">-</span> 백테스트 결과는 커밋 메시지에 숫자로 남긴다.
</code></pre></div></div>

<p><strong>2. 한 번에 한 덩어리씩 요청한다</strong></p>

<p>“자동매매 만들어줘”가 아니라 이 정도 크기로 끊는다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RSI(2)가 5 이하이고 종가가 200일선 위일 때 진입하는 전략을
strategies.py에 추가해줘. 청산은 10일선 회복 / ATR 2.5배 손절 /
20거래일 시간청산.
</code></pre></div></div>

<p><strong>3. 돌려보고, 결과를 그대로 다시 던진다</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>백테스트 돌렸더니 트레이드당 평균이 +0.37%인데 왕복 수수료가 0.2%야.
이거 수수료 반영한 포트폴리오 시뮬레이션도 만들어줘.
</code></pre></div></div>

<p>2편의 발견이 실제로 이 대화에서 나왔다. 바이브 코딩이 잘 굴러가는 지점은 “코드를 대신 써주는 것”보다 <strong>결과를 붙여넣고 다음 질문으로 넘어가는 속도</strong>다.</p>

<p><strong>4. 검증은 코드로 남긴다</strong></p>

<p>돌려보고 “잘 되네” 하고 넘어가면 다음 수정에서 조용히 깨진다. 최소한의 assert 파일 하나를 만들게 해서 계속 갱신했다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python test_checks.py   <span class="c"># → all checks passed</span>
</code></pre></div></div>

<p><strong>5. 커밋 메시지에 실험 결과를 적게 한다</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hold longer (SMA10 / 20 days) so commission stops eating the edge

Round-trip commission is ~0.2% and fixed per trade, while the SMA5 exit
averaged only +0.41% per trade over 3.5 days — roughly half the gross
edge went to fees. Exiting on SMA10 with a 20-day cap averages +0.57%
over 5.9 days.
</code></pre></div></div>

<p>2주 뒤에 “이거 왜 SMA10이었지?” 하고 다시 안 돌려봐도 된다.</p>

<p><strong>6. 위험한 건 권한으로 막는다</strong></p>

<p>실주문이 나가는 스크립트는 자동 실행 대상에서 빼두고, 수정 제안만 받았다.</p>

<h2 id="토스증권-open-api-설정">토스증권 Open API 설정</h2>

<p>주문은 <a href="https://corp.tossinvest.com/ko/open-api">토스증권 Open API</a>로 나간다. 토스증권 계좌가 있어야 하고, WTS(웹 트레이딩 시스템) → 설정 → Open API에서 발급한다.</p>

<p>발급하면서 등록할 것이 두 가지다.</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">client_id</code> / <code class="language-plaintext highlighter-rouge">client_secret</code></strong> — <code class="language-plaintext highlighter-rouge">client_secret</code>은 발급 시 한 번만 보여준다.</li>
  <li><strong>허용 IP 목록</strong> — 여기 등록된 IP에서만 호출된다. 등록 안 된 IP는 403. 그래서 서버는 고정 IP를 잡아뒀다.</li>
</ol>

<p>인증은 OAuth2 client credentials다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST <span class="s1">'https://openapi.tossinvest.com/oauth2/token'</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Content-Type: application/x-www-form-urlencoded'</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'grant_type=client_credentials'</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'client_id=...'</span> <span class="nt">-d</span> <span class="s1">'client_secret=...'</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">access_token</code>은 3600초 만료라 클라이언트에서 자동 재발급하게 했다. 계좌·자산·주문 API는 <code class="language-plaintext highlighter-rouge">X-Tossinvest-Account</code> 헤더에 <code class="language-plaintext highlighter-rouge">accountSeq</code>가 필요하고, 이 값은 계좌 목록 조회로 확인한다.</p>

<p>자격증명은 GCP Secret Manager에 넣고 VM에서 꺼내 쓴다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="nt">-n</span> <span class="s2">"발급받은_client_secret"</span> | gcloud secrets create toss-client-secret <span class="nt">--data-file</span><span class="o">=</span>-
</code></pre></div></div>

<h2 id="gcp-구성">GCP 구성</h2>

<p>서버는 GCP Compute Engine에 만들었다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>리전 / 존</td>
      <td><code class="language-plaintext highlighter-rouge">asia-northeast3-a</code> (서울)</td>
    </tr>
    <tr>
      <td>머신 타입</td>
      <td><code class="language-plaintext highlighter-rouge">e2-small</code></td>
    </tr>
    <tr>
      <td>OS</td>
      <td>Debian 12</td>
    </tr>
    <tr>
      <td>디스크</td>
      <td>10GB</td>
    </tr>
    <tr>
      <td>외부 IP</td>
      <td>고정 IP 예약 (토스 API 허용목록 등록용)</td>
    </tr>
  </tbody>
</table>

<p>VM 생성은 <a href="https://github.com/benyco-dev/project-auto-trading-scanner/blob/main/cloud/deploy.sh"><code class="language-plaintext highlighter-rouge">cloud/deploy.sh</code></a> 한 번으로 끝난다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./cloud/deploy.sh
</code></pre></div></div>

<p>보안 설정은 <a href="https://github.com/benyco-dev/project-auto-trading-scanner/blob/main/cloud/security-hardening.sh"><code class="language-plaintext highlighter-rouge">cloud/security-hardening.sh</code></a>로 따로 적용했다. 주요 항목 세 가지.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>기본 상태</th>
      <th>적용한 것</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>IAP 전용 SSH</strong></td>
      <td><code class="language-plaintext highlighter-rouge">0.0.0.0/0</code>에서 22번 포트 허용</td>
      <td>공개 방화벽 규칙 삭제, IAP 터널로만 접속</td>
    </tr>
    <tr>
      <td><strong>최소 권한 서비스 계정</strong></td>
      <td>프로젝트 Editor 권한의 기본 계정</td>
      <td>전용 계정 생성 후 교체</td>
    </tr>
    <tr>
      <td><strong>Shielded VM</strong></td>
      <td>일반 부팅</td>
      <td>Secure Boot 활성화</td>
    </tr>
  </tbody>
</table>

<p>접속은 이렇게 한다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gcloud compute ssh auto-trading-scanner <span class="nt">--zone</span><span class="o">=</span>asia-northeast3-a <span class="nt">--tunnel-through-iap</span>
</code></pre></div></div>

<h2 id="배포">배포</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git push
  → GitHub Actions가 Dockerfile로 이미지 빌드
  → Docker Hub push (:latest 와 :커밋SHA 두 태그)
  → VM이 다음 cron 때 docker pull
</code></pre></div></div>

<p>CI 설정은 <a href="https://github.com/benyco-dev/project-auto-trading-scanner/blob/main/.github/workflows/docker-build.yml"><code class="language-plaintext highlighter-rouge">.github/workflows/docker-build.yml</code></a>이고, Docker Hub 자격증명은 저장소 Secrets에 넣어뒀다. 로컬에서는 <code class="language-plaintext highlighter-rouge">docker build</code>를 하지 않는다.</p>

<p>이미지는 non-root 사용자로 돌고, 상태 파일(<code class="language-plaintext highlighter-rouge">signals_history.csv</code> 등)은 <code class="language-plaintext highlighter-rouge">/data</code> 볼륨 마운트로 VM에 남긴다.</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">RUN </span>useradd <span class="nt">--create-home</span> <span class="nt">--uid</span> 1000 scanner <span class="se">\
</span>    <span class="o">&amp;&amp;</span> <span class="nb">mkdir</span> <span class="nt">-p</span> /data <span class="o">&amp;&amp;</span> <span class="nb">chown</span> <span class="nt">-R</span> scanner:scanner /app /data
<span class="k">USER</span><span class="s"> scanner</span>
<span class="k">WORKDIR</span><span class="s"> /data</span>
</code></pre></div></div>

<p>VM cron은 <a href="https://github.com/benyco-dev/project-auto-trading-scanner/blob/main/cloud/install-trading-cron.sh"><code class="language-plaintext highlighter-rouge">cloud/install-trading-cron.sh</code></a>로 등록한다.</p>

<p>여기까지가 환경이다. 아래부터는 그 위에 무엇을 올릴지 정하는 이야기다.</p>

<h2 id="여기까지가-강의를-그대로-따라간-부분">여기까지가 강의를 그대로 따라간 부분</h2>

<p>강의는 매매 로직을 정의하고 스캐너를 만드는 것까지를 다룬다. 시작하기에 딱 좋은 범위였고, 1강 로직을 그대로 옮겨서 돌려보는 것부터 했다.</p>

<ul>
  <li><strong>매수</strong>: MACD(5,25,9) 골든크로스 + 15일 이격도 85% 이하</li>
</ul>

<p>여기서 갈라지기 시작했다. 강의의 목표는 <strong>스캐너</strong>를 만드는 것이고, 내 목표는 <strong>사람이 안 보는 동안 도는 시스템</strong>이었다. 스캐너는 “지금 살 만한 종목”만 알려주면 되지만, 자동으로 돌아가는 시스템은 <strong>언제 팔지</strong>도 규칙으로 갖고 있어야 한다. 강의 범위 밖의 부분이라, 내가 채워야 했다.</p>

<p>사람이 지켜보는 매매라면 “적당히 오르면 판다”가 명시적 규칙이 아니어도 굴러간다. cron이 매일 주문을 넣는 구조에서는 그 “적당히”를 아무도 판단하지 않는다. 손절가가 정해지지 않은 포지션은 그냥 열려 있을 뿐이다.</p>

<p>그래서 첫 결정을 이렇게 잡았다. <strong>진입 규칙보다 청산 규칙을 먼저 고정한다.</strong></p>

<h2 id="내-방식으로-고른-전략-rsi2--atr">내 방식으로 고른 전략: RSI(2) + ATR</h2>

<p>전략을 새로 발명하지는 않았다. 개인이 백테스트 몇 번 돌려 찾은 규칙보다, 오래 검증되고 문서화된 규칙을 조합하는 쪽이 낫다고 봤다.</p>

<ul>
  <li><strong>추세 필터</strong>: 종가가 200일 이동평균 위일 때만 매수 고려</li>
  <li><strong>진입</strong>: RSI(2)가 5 이하 — Larry Connors &amp; Cesar Alvarez의 RSI(2) 평균회귀</li>
  <li><strong>청산</strong>: 10일 이동평균 회복 시 익절 / 진입가 대비 ATR(14) × 2.5 하락 시 손절 / 20거래일 시간 청산</li>
  <li><strong>사이징</strong>: <code class="language-plaintext highlighter-rouge">수량 = (계좌자산 × 리스크비율) / (진입가 − 손절가)</code> — Turtle Trading 방식</li>
</ul>

<p>핵심은 마지막 두 줄이다. <strong>손절가가 먼저 정해지고, 그 손절가로부터 수량이 역산된다.</strong> 손절폭이 넓은 종목은 자동으로 적게 사고, 좁은 종목은 많이 산다. 결과적으로 어떤 트레이드든 실패했을 때 잃는 금액이 계좌의 1%로 같아진다.</p>

<p>“얼마 살까”를 사람이 판단하지 않아도 되게 만드는 부분이라, 자동화에서는 진입 신호보다 중요했다.</p>

<h3 id="rsi-기준값-5냐-10이냐">RSI 기준값: 5냐 10이냐</h3>

<p>Connors 원본은 5다. 10으로 완화하면 트레이드가 두 배로 늘어난다. S&amp;P 500 무작위 100종목 × 3년으로 확인했다.</p>

<table>
  <thead>
    <tr>
      <th>RSI 기준</th>
      <th>트레이드 수</th>
      <th>Profit Factor</th>
      <th>최악의 트레이드</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>5 (원본)</td>
      <td>기준</td>
      <td><strong>1.47</strong></td>
      <td>−17.6%</td>
    </tr>
    <tr>
      <td>10 (완화)</td>
      <td>약 2배</td>
      <td>1.42</td>
      <td>−19.1%</td>
    </tr>
  </tbody>
</table>

<p>트레이드 수는 두 배인데 트레이드 품질은 떨어지고 꼬리 위험은 더 깊어진다. 원본값을 유지했다. <strong>“기회를 더 많이 잡는다”가 공짜가 아니라는 것</strong> — 2편에서 이 얘기가 훨씬 더 아프게 돌아온다.</p>

<h2 id="두-전략을-같이-돌려서-비교했다">두 전략을 같이 돌려서 비교했다</h2>

<p>강의 로직을 지운 게 아니라 <code class="language-plaintext highlighter-rouge">legacy</code>라는 이름으로 코드에 남겨뒀다. <code class="language-plaintext highlighter-rouge">--strategy legacy</code>로 언제든 비교할 수 있다. S&amp;P 500 무작위 100종목 × 5년:</p>

<table>
  <thead>
    <tr>
      <th>전략</th>
      <th>트레이드</th>
      <th>승률</th>
      <th>평균 수익률</th>
      <th>Profit Factor</th>
      <th>평균 보유일</th>
      <th>최악</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>legacy (MACD+이격도)</td>
      <td>338</td>
      <td>36.1%</td>
      <td>+1.54%</td>
      <td><strong>1.59</strong></td>
      <td>11.6일</td>
      <td>−21.0%</td>
    </tr>
    <tr>
      <td>rsi2_trend</td>
      <td>2799</td>
      <td><strong>67.8%</strong></td>
      <td>+0.37%</td>
      <td>1.37</td>
      <td>3.5일</td>
      <td>−15.9%</td>
    </tr>
  </tbody>
</table>

<p><strong>Profit Factor만 보면 강의 로직이 더 좋다.</strong> 가끔 크게 먹는 트레이드가 있어서 그렇다. 트레이드당 평균 수익률도 4배 이상 높다.</p>

<p>그런데도 <code class="language-plaintext highlighter-rouge">rsi2_trend</code>를 기본값으로 삼았다. 이유는 두 가지다.</p>

<ol>
  <li>저 1.59는 손절이 없는 상태로 측정된 숫자다. 이 백테스트는 종가 기준이라 갭 하락 같은 시나리오를 제대로 잡지 못한다. (비교를 위해 “MACD 데드크로스 또는 30거래일 시간청산”을 내가 임의로 붙여서 돌린 결과다 — 강의가 제시한 규칙이 아니다.)</li>
  <li>승률이 높고 보유 기간이 짧으면 자본 회전이 빠르다. 자본이 작을수록 이게 중요하다.</li>
</ol>

<p>숫자가 지는 쪽을 골랐다는 게 이상하게 들리는데, 여기서 실제로 고른 건 수익률이 아니라 <strong>“모든 트레이드에 리스크가 정의되어 있다”는 속성</strong>이었다. 목표가 자동화라서 그렇다.</p>

<h2 id="강의와-달라진-지점-정리">강의와 달라진 지점 정리</h2>

<table>
  <thead>
    <tr>
      <th>강의</th>
      <th>강의에서 다루는 것</th>
      <th>내가 대신 택한 것</th>
      <th>왜</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1강</td>
      <td>MACD+이격도 로직</td>
      <td><code class="language-plaintext highlighter-rouge">legacy</code>로 보존, 기본값은 <code class="language-plaintext highlighter-rouge">rsi2_trend</code></td>
      <td>무인 운영이라 청산 규칙이 필요했다</td>
    </tr>
    <tr>
      <td>2강</td>
      <td>GitHub Actions + 텔레그램 알림</td>
      <td>GCE VM cron + CSV 누적 로그</td>
      <td>토스 API의 고정 IP 요구</td>
    </tr>
    <tr>
      <td>3강</td>
      <td>Cloudflare Worker로 스케줄링</td>
      <td>이미 cron이 담당</td>
      <td>중복이라 불필요</td>
    </tr>
    <tr>
      <td>이후</td>
      <td>증권사 API 자동 주문</td>
      <td>구현하되 <strong>cron에 안 붙임</strong></td>
      <td>아래</td>
    </tr>
  </tbody>
</table>

<p>마지막 줄이 제일 의도적인 차이다. 시그널을 찾는 것과 실제로 돈을 움직이는 것은 분리된 결정이어야 한다고 봐서, 주문 도구는 사람이 직접 실행하는 수동 도구로 남겼다. 실주문은 <code class="language-plaintext highlighter-rouge">--live</code> 플래그와 <code class="language-plaintext highlighter-rouge">TOSS_LIVE_TRADING=CONFIRM</code> 환경변수를 <strong>둘 다</strong> 줘야 나간다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 미리보기 (자격증명 없어도 동작)</span>
python <span class="nt">-m</span> toss.place_orders <span class="nt">--account-seq</span> 1

<span class="c"># 실제 주문</span>
<span class="nv">TOSS_LIVE_TRADING</span><span class="o">=</span>CONFIRM python <span class="nt">-m</span> toss.place_orders <span class="nt">--account-seq</span> 1 <span class="nt">--live</span>
</code></pre></div></div>

<p>강의 시리즈가 없었으면 시작 자체를 안 했을 거다. 방향을 잡아준 지점과, 내 목표가 달라서 갈라진 지점이 명확해서 오히려 왜 그렇게 만들었는지를 계속 설명할 수 있게 됐다.</p>

<hr />

<p>다음 편: <a href="/auto-trading-2-commission/">수수료의 함정</a> — 백테스트는 22% 수익이라고 했는데, 수수료를 넣으니 4%가 됐다.</p>

<blockquote>
  <p>투자 조언이 아니다. 백테스트 결과는 참고용 통계일 뿐이고 실거래 결과를 보장하지 않는다.</p>
</blockquote>]]></content><author><name>Benyco</name></author><category term="Auto Trading" /><category term="자동매매" /><category term="Claude Code" /><category term="토스증권 API" /><category term="GCP" /><category term="Docker" /><summary type="html"><![CDATA[Claude Code로 바이브 코딩하고, 토스증권 Open API와 GCP로 무인 운영 환경을 만들었다. 강의를 참고해 시작한 뒤 전략은 내 목표에 맞게 바꿨다.]]></summary></entry><entry><title type="html">Claude Code와 토스증권 API로 자동매매 시작하기 2편 (수수료의 함정)</title><link href="https://benyco-dev.github.io/auto-trading-2-commission/" rel="alternate" type="text/html" title="Claude Code와 토스증권 API로 자동매매 시작하기 2편 (수수료의 함정)" /><published>2026-09-10T00:00:00+00:00</published><updated>2026-09-10T00:00:00+00:00</updated><id>https://benyco-dev.github.io/auto-trading-2-commission</id><content type="html" xml:base="https://benyco-dev.github.io/auto-trading-2-commission/"><![CDATA[<p><a href="/auto-trading-1-setup/">1편</a>에서 환경을 세우고 전략을 정한 뒤 백테스트를 돌렸다. 승률 67.8%, Profit Factor 1.37, 트레이드당 평균 +0.37%. 나쁘지 않아 보였다.</p>

<p>그런데 저 <code class="language-plaintext highlighter-rouge">+0.37%</code>라는 숫자를 한참 보다가 이상한 걸 깨달았다.</p>

<p><strong>왕복 수수료가 0.2%다.</strong></p>

<h2 id="백테스트가-대답하지-못하는-질문">백테스트가 대답하지 못하는 질문</h2>

<p>내가 쓰던 <code class="language-plaintext highlighter-rouge">backtest.py</code>는 트레이드 단위 통계를 낸다. 승률, 평균 수익률, Profit Factor. 이 방식에는 두 개의 조용한 가정이 깔려 있다.</p>

<ol>
  <li><strong>자본이 무한하다.</strong> 같은 날 시그널이 20개 뜨면 20개를 다 산다. 실제로는 현금이 모자란다.</li>
  <li><strong>거래 비용이 0이다.</strong> 진입가와 청산가의 차이가 곧 수익이다.</li>
</ol>

<p>두 가정 다 “약간 낙관적”인 수준이 아니었다. 답을 바꾸는 수준이었다.</p>

<p>특히 1번은 내가 답하고 싶었던 질문 자체를 막고 있었다. 나는 <strong>“200만원으로 이 전략을 굴릴 수 있나”</strong>를 알고 싶었는데, 자본 무한 가정 위에서는 그 질문이 성립하지 않는다.</p>

<p>그래서 <a href="https://github.com/benyco-dev/project-auto-trading-scanner/blob/main/portfolio_sim.py"><code class="language-plaintext highlighter-rouge">portfolio_sim.py</code></a>를 따로 만들었다. 백테스트를 대체하는 게 아니라 <strong>다른 질문에 답하는 도구</strong>다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">day</span> <span class="ow">in</span> <span class="n">pd</span><span class="p">.</span><span class="n">date_range</span><span class="p">(...):</span>
    <span class="c1"># 1) 먼저 청산해서 현금을 회수하고
</span>    <span class="c1"># 2) 그날 시그널을 손절폭 좁은 순으로 정렬한 다음
</span>    <span class="c1"># 3) 현금이 닿는 데까지만, --max-orders 개수까지만 담는다
</span>    <span class="k">if</span> <span class="n">cost</span> <span class="o">*</span> <span class="p">(</span><span class="mi">1</span> <span class="o">+</span> <span class="n">fee</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">cash</span><span class="p">:</span>
        <span class="n">skipped_cash</span> <span class="o">+=</span> <span class="mi">1</span>
        <span class="k">continue</span>
</code></pre></div></div>

<p>핵심은 <code class="language-plaintext highlighter-rouge">skipped_cash</code>다. <strong>못 산 신호를 세는 것.</strong> 트레이드 단위 백테스트에는 이 개념 자체가 없다.</p>

<h2 id="수수료가-총수익의-40를-먹고-있었다">수수료가 총수익의 40%를 먹고 있었다</h2>

<p>시뮬레이터에 수수료를 넣고(토스 미국주식 편도 0.1%) 돌렸더니, 2.2년 동안 약 500번 체결됐다.</p>

<ul>
  <li>수수료 총액 = <strong>자본의 16~19%</strong></li>
  <li>전략이 만들어낸 총 gross edge 대비 = <strong>약 40%</strong></li>
  <li>시뮬레이션 총수익률: <strong>약 22% → 약 4%</strong></li>
</ul>

<p>수익의 40%가 증권사로 갔다.</p>

<p>더 나쁜 건 이게 <strong>계좌 크기와 무관하게 같은 비율</strong>이라는 점이다. 포지션 사이징이 자산에 비례하니까 수수료도 자산에 비례한다. “돈이 좀 더 많으면 수수료 비중이 줄겠지”가 성립하지 않는다.</p>

<p>원인은 명확했다. 수수료는 <strong>트레이드당 고정 비율</strong>인데, 내 전략의 트레이드당 평균 수익은 +0.37%였다. 0.2%를 떼면 0.17%가 남는다. 승률 67.8%라는 좋아 보이던 숫자는, 사실 <strong>아주 작은 이익을 아주 자주 가져가는</strong> 구조였고, 그 구조가 수수료에 최악으로 취약했던 거다.</p>

<p>1편에서 “트레이드를 두 배로 늘리는 게 공짜가 아니다”라고 썼는데, 진짜 청구서는 여기서 왔다.</p>

<h2 id="해결-더-자주-사는-게-아니라-더-오래-들고-있기">해결: 더 자주 사는 게 아니라 더 오래 들고 있기</h2>

<p>수수료율은 내가 바꿀 수 없다. 바꿀 수 있는 건 <strong>같은 수수료를 얼마나 큰 움직임에 분산시키느냐</strong>다.</p>

<p>Connors 원본의 청산 규칙은 SMA5 회복이다. 이걸 SMA10 + 20거래일 상한으로 늘렸다.</p>

<table>
  <thead>
    <tr>
      <th>청산 규칙</th>
      <th>트레이드당 평균</th>
      <th>평균 보유일</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SMA5 (원본)</td>
      <td>+0.41%</td>
      <td>3.5일</td>
    </tr>
    <tr>
      <td><strong>SMA10 + 20일 상한</strong></td>
      <td><strong>+0.57%</strong></td>
      <td>5.9일</td>
    </tr>
  </tbody>
</table>

<p>트레이드당 +0.16%p. 별것 아닌 것 같지만, 고정 0.2% 수수료 앞에서는 <strong>남는 몫이 0.21%에서 0.37%로 거의 두 배</strong>가 된다.</p>

<p>포트폴리오 시뮬레이션으로 확인 (독립 100종목 표본 3개, $2,000, 리스크 1%, 수수료 반영):</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>평균 총수익률</th>
      <th>최대낙폭</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SMA5</td>
      <td>7.7%</td>
      <td>−9.1%</td>
    </tr>
    <tr>
      <td><strong>SMA10 + 20일</strong></td>
      <td><strong>22.9%</strong></td>
      <td>−9.7%</td>
    </tr>
  </tbody>
</table>

<p>낙폭은 거의 그대로인데 수익률이 3배가 됐다. 리스크를 더 진 게 아니라, <strong>증권사에 덜 준 것</strong>이다.</p>

<h2 id="못-산-신호를-어떻게-고를-것인가">못 산 신호를 어떻게 고를 것인가</h2>

<p>시뮬레이터를 만들면서 딸려 나온 문제가 하나 더 있었다. 현금이 모자라서 그날 시그널 중 일부만 살 수 있을 때, <strong>뭘 먼저 사야 하나?</strong></p>

<p>원래 코드는 티커 알파벳순이었다. 그럴 의도는 없었고, 그냥 정렬을 안 해서 그렇게 됐다.</p>

<p>리스크 기반 사이징을 쓰면 어떤 신호를 고르든 잃는 금액은 계좌의 1%로 같다. 그래서 의미 있는 기준은 <strong>손절폭</strong>이다. 손절폭이 좁으면 같은 1% 리스크로 더 많은 자본을 투입할 수 있다.</p>

<p>실제 시그널 46건, $2,000 / 리스크 1% / 하루 최대 3건 기준으로 측정:</p>

<table>
  <thead>
    <tr>
      <th>우선순위</th>
      <th>실제 투입된 자본</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>저가 순</td>
      <td>$383</td>
    </tr>
    <tr>
      <td>알파벳 순 (기존)</td>
      <td>$676</td>
    </tr>
    <tr>
      <td><strong>손절폭 좁은 순</strong></td>
      <td><strong>$1,186</strong></td>
    </tr>
  </tbody>
</table>

<p>같은 신호, 같은 리스크, 같은 계좌인데 투입 자본이 1.75배 차이 났다. 정렬 한 줄이다.</p>

<h2 id="여기서-배운-것">여기서 배운 것</h2>

<ol>
  <li><strong>백테스트와 포트폴리오 시뮬레이션은 다른 도구다.</strong> 전자는 “이 규칙이 통계적으로 우위가 있나”, 후자는 “내 돈으로 이걸 굴릴 수 있나”. 둘 중 하나만 있으면 반쪽이다.</li>
  <li><strong>거래 비용은 전략 파라미터다.</strong> “얼마나 자주 매매하는가”가 수수료 구조와 맞물려서 수익률을 결정한다. 비용을 넣지 않은 백테스트는 매매 빈도를 무료라고 착각하게 만든다.</li>
  <li><strong>작은 계좌에서는 “무엇을 못 샀는가”가 “무엇을 샀는가”만큼 중요하다.</strong></li>
</ol>

<hr />

<p>다음 편: <a href="/auto-trading-3-wrapup/">마무리</a> — 자본 규모는 수익률에 영향이 없었고, 표본 편차가 설정 차이보다 9배 컸다.</p>

<blockquote>
  <p>투자 조언이 아니다. 시뮬레이션은 배당·세금·환전을 반영하지 않았고, 현재 S&amp;P 500 구성종목 기준이라 생존편향이 있다.</p>
</blockquote>]]></content><author><name>Benyco</name></author><category term="Auto Trading" /><category term="자동매매" /><category term="백테스트" /><category term="수수료" /><category term="포트폴리오 시뮬레이션" /><summary type="html"><![CDATA[트레이드 단위 백테스트는 자본이 무한하고 수수료가 0이라고 가정한다. 그 두 가정을 걷어내니 수익의 40%가 수수료였다.]]></summary></entry><entry><title type="html">Claude Code와 토스증권 API로 자동매매 시작하기 3편 (마무리)</title><link href="https://benyco-dev.github.io/auto-trading-3-wrapup/" rel="alternate" type="text/html" title="Claude Code와 토스증권 API로 자동매매 시작하기 3편 (마무리)" /><published>2026-09-10T00:00:00+00:00</published><updated>2026-09-10T00:00:00+00:00</updated><id>https://benyco-dev.github.io/auto-trading-3-wrapup</id><content type="html" xml:base="https://benyco-dev.github.io/auto-trading-3-wrapup/"><![CDATA[<p><a href="/auto-trading-2-commission/">2편</a>에서 수수료 문제를 잡고 나니, 이제 남은 건 파라미터 튜닝처럼 보였다. 하루에 몇 종목까지 담을지, 한 종목에 자본을 몇 %까지 몰지.</p>

<p>튜닝을 하러 갔다가, <strong>백테스트 숫자를 어디까지 믿어도 되는지</strong>를 배우고 왔다.</p>

<h2 id="먼저-자본-규모는-거의-영향이-없었다">먼저: 자본 규모는 거의 영향이 없었다</h2>

<p>작은 계좌로 시작할 거라 이게 제일 궁금했다. <code class="language-plaintext highlighter-rouge">$2,000</code> / <code class="language-plaintext highlighter-rouge">$8,000</code> / <code class="language-plaintext highlighter-rouge">$20,000</code> 세 구간으로 같은 시그널 세트를 돌렸다.</p>

<p>예상은 “작은 계좌가 불리하다”였다. 이유도 명확해 보였다 — 손절폭이 넓은 종목은 1% 리스크로 1주도 못 사서 통째로 놓치고, 현금이 모자라서 신호를 건너뛴다.</p>

<p>실제로는 <strong>총수익률이 구간별로 거의 차이가 없었다.</strong></p>

<p>이유는 2편에서 이미 나왔다. 포지션 사이징이 자산에 비례하니까, 자본이 10배면 포지션도 10배고 수수료도 10배다. 수익률(%)로 보면 상쇄된다.</p>

<p>자본이 실제로 영향을 주는 지점은 <strong>수익률이 아니라 체결률</strong>이었다. 작은 계좌는 놓치는 신호가 많고, 그래서 자본 곡선이 더 거칠어진다. 기댓값이 아니라 분산의 문제다.</p>

<p>작은 계좌의 진짜 문제는 “수익률이 낮은 것”이 아니라 <strong>“운의 비중이 큰 것”</strong>이라는 결론.</p>

<p>여담으로, 실제 소액 계좌(수백 달러)로 dry-run을 돌렸을 때 20개 시그널에 대해 계좌 잔고의 10배가 넘는 주문이 만들어진 적이 있다. 모든 시그널이 각자 전체 자산 기준으로 독립적으로 사이징되니까 그렇게 된다. 매수 가능 금액을 한 번 조회해서 주문마다 차감해나가는 처리를 넣어서 막았다. 이런 건 백테스트에서 절대 안 보인다.</p>

<h2 id="하루-최대-매수-종목-수-3-vs-5">하루 최대 매수 종목 수: 3 vs 5</h2>

<p>한 종목당 리스크가 1%니까, 하루 5건이면 그날 새로 지는 리스크가 5%다. 무제한으로 두면 하루 20건 신호에 리스크가 20% 쌓인다. 상한이 필요한 건 분명했다. 숫자만 정하면 됐다.</p>

<p>독립적인 100종목 표본 5개, 자본 3구간으로 돌렸다.</p>

<ul>
  <li><strong>3건은 모든 자본 구간에서 최하위였다.</strong> 놓친 신호의 비용은 실재한다.</li>
  <li><strong>5건을 넘으면 곡선이 평평해진다.</strong> 6번째 신호는 값어치가 없다.</li>
</ul>

<p>그래서 5로 올렸다. 여기까지는 깔끔한 결론이다.</p>

<h2 id="그런데-표본을-바꾸면-19도-되고-55도-됐다">그런데 표본을 바꾸면 19%도 되고 55%도 됐다</h2>

<p>같은 설정, 같은 자본, 같은 기간. <strong>바꾼 건 어떤 100종목을 뽑았느냐 뿐</strong>이다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>폭</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>표본을 바꿨을 때 총수익률 범위 (한 설정 기준)</td>
      <td>19% ~ 55% → <strong>36%p</strong></td>
    </tr>
    <tr>
      <td>설정(3건 vs 5건)을 바꿨을 때 차이</td>
      <td><strong>약 4%p</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>9배다.</strong></p>

<p>내가 튜닝하겠다고 붙잡고 있던 신호는, 노이즈의 9분의 1 크기였다.</p>

<p>2편의 수수료 실험도 다시 보면 같다. SMA5 → SMA10으로 평균 7.7% → 22.9%였는데, 그 22.9%를 구성한 개별 표본은 7.9%부터 37.6%까지 흩어져 있었다. <strong>방향은 믿을 만하지만 수준은 못 믿는다.</strong></p>

<p>그래서 커밋 메시지에 이렇게 적었다.</p>

<blockquote>
  <p>Sample-to-sample spread (19-55% for one config) still dwarfs the gap between settings (~4pp), so this is a mild preference, not a tuned optimum.</p>

  <p>(표본 간 편차가 설정 간 차이를 압도한다. 이건 튜닝된 최적값이 아니라 약한 선호다.)</p>
</blockquote>

<h2 id="무엇을-믿고-무엇을-안-믿을-것인가">무엇을 믿고 무엇을 안 믿을 것인가</h2>

<p>이 프로젝트를 하면서 백테스트 숫자를 세 등급으로 나누게 됐다.</p>

<p><strong>믿는 것 — 부호와 방향</strong>
“수수료를 넣으면 수익이 준다”, “3건은 5건보다 나쁘다”. 여러 독립 표본에서 일관되게 같은 방향이면 신호로 취급한다.</p>

<p><strong>조건부로 믿는 것 — 구조적 결론</strong>
“수수료는 트레이드당 고정이라 회전율이 높을수록 불리하다”. 이건 백테스트가 발견해준 게 아니라, 백테스트가 가리킨 곳을 들여다보니 <strong>산수로 설명되는</strong> 것이었다. 데이터가 아니라 메커니즘이 근거다.</p>

<p><strong>안 믿는 것 — 수준과 소수점</strong>
“이 전략의 연 수익률은 22.9%”. 표본 하나 바꾸면 7.9%가 되는 숫자다. 이걸 근거로 파라미터를 소수점까지 맞추면 그건 최적화가 아니라 그 표본을 외우는 것이다.</p>

<p>실무적으로 하나 얻은 습관: <strong>파라미터를 바꾸기 전에, 아무것도 안 바꾸고 표본만 바꿔서 몇 번 돌려본다.</strong> 그 편차보다 작은 개선은 개선이 아니다.</p>

<h2 id="그래서-지금은">그래서 지금은</h2>

<ul>
  <li>스캔은 GCE VM에서 cron으로 매일 돈다. 미국장 마감 후 스캔해서 그날 종가로 시그널을 확정하고, 다음 장이 열린 뒤에 주문을 넣는다. (마감 후 주문하면 API가 거부한다 — 이건 그냥 맞아보고 알았다.)</li>
  <li>배포는 push → GitHub Actions가 Docker 이미지 빌드 → Docker Hub → VM이 cron 때 <code class="language-plaintext highlighter-rouge">docker pull</code>. 로컬에서 빌드하지 않는다.</li>
  <li><strong>실거래는 아직 cron에 붙이지 않았다.</strong> 시그널을 찾는 것과 돈을 움직이는 것은 분리된 결정이어야 한다고 봐서, 주문 도구는 사람이 직접 실행하는 수동 도구로 남겨뒀다. 지금은 자본 3구간으로 모의매매를 돌리면서 시뮬레이션 결과와 실제가 얼마나 벌어지는지 보는 중이다.</li>
</ul>

<p>3편에서 배운 걸 그대로 적용하면, 그 모의매매 결과도 <strong>한 번의 결과는 아무것도 증명하지 않는다.</strong> 방향이 여러 번 같게 나오는지를 봐야 한다.</p>

<h2 id="전체-코드">전체 코드</h2>

<p><a href="https://github.com/benyco-dev/project-auto-trading-scanner">github.com/benyco-dev/project-auto-trading-scanner</a></p>

<p>출발점이 된 유튜브 강의 시리즈는 <a href="https://www.youtube.com/watch?v=7VG7ugLu6qs">여기</a>. 방향을 잡아준 덕에 시작할 수 있었고, 거기서부터 내 목표에 맞게 하나씩 바꿔온 기록이 이 3부작이다.</p>

<ul>
  <li><a href="/auto-trading-1-setup/">1편: 환경 구성</a> — Claude Code + 토스증권 API + GCP로 환경을 세우고, 전략을 내 방식대로 바꾸기까지</li>
  <li><a href="/auto-trading-2-commission/">2편: 수수료의 함정</a> — 백테스트는 22%였는데 수수료를 넣으니 4%가 됐다</li>
</ul>

<blockquote>
  <p>투자 조언이 아니다. 모든 수치는 참고용 시뮬레이션 통계이며 실거래 결과를 보장하지 않는다.</p>
</blockquote>]]></content><author><name>Benyco</name></author><category term="Auto Trading" /><category term="자동매매" /><category term="백테스트" /><category term="통계" /><category term="파라미터 튜닝" /><summary type="html"><![CDATA[자본 $2k와 $20k의 수익률은 거의 같았다. 파라미터를 튜닝하려다, 백테스트 숫자를 얼마나 믿어도 되는지부터 배웠다.]]></summary></entry><entry><title type="html">Google Cloud Storage로 정적 웹사이트 호스팅하기 (+ 로또 예상번호 사이트)</title><link href="https://benyco-dev.github.io/gcs-static-hosting/" rel="alternate" type="text/html" title="Google Cloud Storage로 정적 웹사이트 호스팅하기 (+ 로또 예상번호 사이트)" /><published>2026-09-10T00:00:00+00:00</published><updated>2026-09-10T00:00:00+00:00</updated><id>https://benyco-dev.github.io/gcs-static-hosting</id><content type="html" xml:base="https://benyco-dev.github.io/gcs-static-hosting/"><![CDATA[<p><a href="/gcp-docker-nginx/">Compute Engine에 Docker로 Nginx를 띄워보고</a>, <a href="/cloud-run-docker-gui/">Cloud Run에 이미지를 올려보고</a> 나니 자연스럽게 다음 질문이 생겼다. <strong>애초에 서버가 필요 없는 사이트라면 뭘 써야 하나?</strong></p>

<p>HTML, CSS, JS, JSON 몇 개를 그대로 내려주기만 하면 되는 사이트에 VM은 과하고, Cloud Run도 컨테이너 빌드와 레지스트리가 따라붙는다. 그래서 이번엔 <strong>Cloud Storage 버킷 하나로</strong> 사이트를 띄워봤다.</p>

<p>만든 건 로또 6/45 예상번호 사이트다. 주제는 가벼운데 구성은 실전에 가깝다 — 매주 데이터가 갱신돼야 하고, 그 갱신이 자동으로 배포까지 이어져야 한다.</p>

<ul>
  <li>사이트: <a href="https://storage.googleapis.com/benycolottery/index.html">storage.googleapis.com/benycolottery/index.html</a></li>
  <li>코드: <a href="https://github.com/benyco-dev/lottery">github.com/benyco-dev/lottery</a></li>
</ul>

<p><img src="/images/uploads/2026-09-10-gcs-static-hosting/01-site.webp" alt="로또 예상번호 사이트 메인 화면" /></p>

<p><em>버킷에서 그대로 내려온 정적 페이지. 서버 프로세스는 0개다.</em></p>

<p>최종 구성은 이렇다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GitHub Actions ──(Workload Identity Federation, 키 파일 없음)──&gt; GCS 버킷 ──&gt; 공개 URL
      │
      └─ 데이터 수집 → 검증 → 갱신 커밋 → rsync 배포
</code></pre></div></div>

<h2 id="1-버킷-만들기">1. 버킷 만들기</h2>

<p>프로젝트를 새로 파고 결제를 연결한 다음 버킷을 만들었다. 서울 리전(<code class="language-plaintext highlighter-rouge">asia-northeast3</code>)으로 잡았다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud storage buckets create gs://my-project <span class="se">\</span>
    <span class="nt">--project</span><span class="o">=</span>my-project <span class="nt">--location</span><span class="o">=</span>asia-northeast3 <span class="nt">--uniform-bucket-level-access</span>
Creating gs://my-project/...
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--uniform-bucket-level-access</code> 를 켜면 객체마다 ACL을 따로 두지 않고 <strong>버킷 IAM 하나로만</strong> 접근을 통제한다. 정적 사이트처럼 “전부 공개”인 경우엔 이게 훨씬 단순하고, 파일을 새로 올릴 때마다 권한이 제각각이 되는 사고를 막아준다.</p>

<h2 id="2-공개-읽기-권한">2. 공개 읽기 권한</h2>

<p>버킷을 만들었다고 바로 보이는 건 아니다. <code class="language-plaintext highlighter-rouge">allUsers</code> 에게 읽기 권한을 줘야 한다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud storage buckets add-iam-policy-binding gs://my-project <span class="se">\</span>
    <span class="nt">--member</span><span class="o">=</span>allUsers <span class="nt">--role</span><span class="o">=</span>roles/storage.objectViewer <span class="nt">--project</span><span class="o">=</span>my-project
  - allUsers
  role: roles/storage.objectViewer
</code></pre></div></div>

<p>조직에 <code class="language-plaintext highlighter-rouge">constraints/storage.publicAccessPrevention</code> 정책이 걸려 있으면 이 명령이 거부된다. 그럴 땐 조직 정책에서 해당 프로젝트를 예외로 두거나, 로드밸런서를 앞에 세우는 구성으로 가야 한다.</p>

<h2 id="3-업로드--첫-번째로-걸린-곳">3. 업로드 — 첫 번째로 걸린 곳</h2>

<p><code class="language-plaintext highlighter-rouge">gcloud storage rsync</code> 로 폴더를 통째로 올린다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud storage rsync site gs://my-project <span class="nt">--recursive</span> <span class="se">\</span>
    <span class="nt">--delete-unmatched-destination-objects</span> <span class="se">\</span>
    <span class="nt">--cache-control</span><span class="o">=</span><span class="s2">"public, max-age=300"</span> <span class="se">\</span>
    <span class="nt">--project</span><span class="o">=</span>my-project
</code></pre></div></div>

<p>두 옵션이 중요하다.</p>

<p><code class="language-plaintext highlighter-rouge">--delete-unmatched-destination-objects</code> 는 로컬에서 지운 파일을 버킷에서도 지운다. 이걸 빼면 예전 파일이 계속 남아 서빙된다. 실제로 중간에 쓰다 만 <code class="language-plaintext highlighter-rouge">independence.json</code> 을 지웠는데, 이 옵션 덕분에 버킷에서도 같이 사라져서 404가 떴다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> <span class="se">\</span>
    https://storage.googleapis.com/my-project/data/independence.json
404
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--cache-control</code> 이 두 번째다. <strong>이걸 안 주면 GCS가 기본값 <code class="language-plaintext highlighter-rouge">max-age=3600</code> 을 붙인다.</strong> 파일을 새로 올려도 브라우저와 중간 캐시가 한 시간 동안 옛날 걸 보여준다. 배포했는데 왜 안 바뀌지 하고 한참 헤맬 수 있는 지점이다. 이 사이트는 주 1회 갱신이라 5분으로 뒀다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>curl <span class="nt">-sI</span> https://storage.googleapis.com/my-project/index.html | <span class="nb">grep</span> <span class="nt">-i</span> cache
cache-control: public, max-age<span class="o">=</span>300
</code></pre></div></div>

<p>Content-Type은 확장자로 자동 판별된다. ES 모듈로 쓴 <code class="language-plaintext highlighter-rouge">.js</code> 파일도 <code class="language-plaintext highlighter-rouge">text/javascript</code> 로 정상적으로 내려왔다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s1">'%{http_code} %{content_type}\n'</span> <span class="se">\</span>
    https://storage.googleapis.com/my-project/app.js
200 text/javascript
</code></pre></div></div>

<h2 id="4-github-actions에서-키-없이-배포하기">4. GitHub Actions에서 키 없이 배포하기</h2>

<p>여기가 이번 실습에서 제일 배운 게 많은 부분이다.</p>

<p>보통은 서비스 계정 JSON 키를 만들어 저장소 시크릿에 넣는다. 그런데 이 키는 <strong>만료가 없다.</strong> 한 번 새면 계속 쓸 수 있다. 그래서 <strong>Workload Identity Federation</strong>(WIF)을 썼다. GitHub Actions가 발급한 OIDC 토큰을 GCP가 직접 검증하는 방식이라, 저장할 키 파일 자체가 없다.</p>

<p>먼저 배포 전용 서비스 계정을 만들고 버킷 권한을 준다. 아래 <code class="language-plaintext highlighter-rouge">&lt;서비스계정&gt;</code> 은 원하는 이름으로 바꿔 쓰면 된다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud iam service-accounts create &lt;서비스계정&gt; <span class="se">\</span>
    <span class="nt">--project</span><span class="o">=</span>my-project <span class="nt">--display-name</span><span class="o">=</span><span class="s2">"GitHub Actions deploy"</span>
Service account email: &lt;서비스계정&gt;@my-project.iam.gserviceaccount.com

<span class="nv">$ </span>gcloud storage buckets add-iam-policy-binding gs://my-project <span class="se">\</span>
    <span class="nt">--member</span><span class="o">=</span><span class="s2">"serviceAccount:&lt;서비스계정&gt;@my-project.iam.gserviceaccount.com"</span> <span class="se">\</span>
    <span class="nt">--role</span><span class="o">=</span>roles/storage.objectAdmin <span class="nt">--project</span><span class="o">=</span>my-project
</code></pre></div></div>

<p>그다음 워크로드 아이덴티티 풀과 프로바이더를 만든다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud iam workload-identity-pools create github <span class="se">\</span>
    <span class="nt">--location</span><span class="o">=</span>global <span class="nt">--project</span><span class="o">=</span>my-project
Created workload identity pool <span class="o">[</span>github].

<span class="nv">$ </span>gcloud iam workload-identity-pools providers create-oidc github-actions <span class="se">\</span>
    <span class="nt">--location</span><span class="o">=</span>global <span class="nt">--workload-identity-pool</span><span class="o">=</span>github <span class="nt">--project</span><span class="o">=</span>my-project <span class="se">\</span>
    <span class="nt">--issuer-uri</span><span class="o">=</span><span class="s2">"https://token.actions.githubusercontent.com"</span> <span class="se">\</span>
    <span class="nt">--attribute-mapping</span><span class="o">=</span><span class="s2">"google.subject=assertion.sub,attribute.repository=assertion.repository"</span> <span class="se">\</span>
    <span class="nt">--attribute-condition</span><span class="o">=</span><span class="s2">"assertion.repository=='benyco-dev/lottery'"</span>
Created workload identity pool provider <span class="o">[</span>github-actions].
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--attribute-condition</code> 은 반드시 넣는다. 이걸 빼면 GitHub의 <strong>어떤 저장소든</strong> 이 서비스 계정을 빌려 쓸 수 있게 된다.</p>

<p>마지막으로 이 저장소에서만 서비스 계정을 위임받을 수 있도록 묶어준다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ PN</span><span class="o">=</span><span class="si">$(</span>gcloud projects describe my-project <span class="nt">--format</span><span class="o">=</span><span class="s1">'value(projectNumber)'</span><span class="si">)</span>
<span class="nv">$ </span>gcloud iam service-accounts add-iam-policy-binding <span class="se">\</span>
    &lt;서비스계정&gt;@my-project.iam.gserviceaccount.com <span class="nt">--project</span><span class="o">=</span>my-project <span class="se">\</span>
    <span class="nt">--role</span><span class="o">=</span>roles/iam.workloadIdentityUser <span class="se">\</span>
    <span class="nt">--member</span><span class="o">=</span><span class="s2">"principalSet://iam.googleapis.com/projects/</span><span class="nv">$PN</span><span class="s2">/locations/global/workloadIdentityPools/github/attribute.repository/benyco-dev/lottery"</span>
</code></pre></div></div>

<p>워크플로에서는 <code class="language-plaintext highlighter-rouge">id-token: write</code> 권한이 있어야 OIDC 토큰이 발급된다. 이게 없으면 인증 단계에서 그냥 실패한다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">permissions</span><span class="pi">:</span>
  <span class="na">contents</span><span class="pi">:</span> <span class="s">write</span>   <span class="c1"># 갱신된 데이터를 되커밋</span>
  <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>   <span class="c1"># WIF용 OIDC 토큰. 없으면 인증 실패</span>

<span class="na">steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">google-github-actions/auth@v2</span>
    <span class="na">with</span><span class="pi">:</span>
      <span class="na">workload_identity_provider</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">service_account</span><span class="pi">:</span> <span class="s">$</span>
  <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">google-github-actions/setup-gcloud@v2</span>
  <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">gcloud storage rsync site "gs://$" \</span>
        <span class="s">--recursive --delete-unmatched-destination-objects \</span>
        <span class="s">--cache-control="public, max-age=300"</span>
</code></pre></div></div>

<p>시크릿은 세 개만 등록했다. 프로바이더 경로, 서비스 계정 이메일, 버킷 이름. <strong>키 파일은 없다.</strong></p>

<h2 id="5-두-번째로-걸린-곳--objectadmin만으론-rsync가-안-된다">5. 두 번째로 걸린 곳 — objectAdmin만으론 rsync가 안 된다</h2>

<p>권한을 <code class="language-plaintext highlighter-rouge">roles/storage.objectAdmin</code> 만 주고 돌렸더니 배포가 실패했다. 객체를 만들고 지울 권한은 있는데, <strong>버킷 자체의 메타데이터를 읽을 권한이 없어서</strong>다. <code class="language-plaintext highlighter-rouge">rsync</code> 는 동기화 전에 버킷 상태를 조회한다.</p>

<p><code class="language-plaintext highlighter-rouge">roles/storage.legacyBucketReader</code> 를 같이 줘야 통과한다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud storage buckets add-iam-policy-binding gs://my-project <span class="se">\</span>
    <span class="nt">--member</span><span class="o">=</span><span class="s2">"serviceAccount:&lt;서비스계정&gt;@my-project.iam.gserviceaccount.com"</span> <span class="se">\</span>
    <span class="nt">--role</span><span class="o">=</span>roles/storage.legacyBucketReader <span class="nt">--project</span><span class="o">=</span>my-project
</code></pre></div></div>

<p>이름에 <code class="language-plaintext highlighter-rouge">legacy</code> 가 붙어 있어서 쓰면 안 되는 것처럼 보이는데, uniform bucket-level access 환경에서도 버킷 조회 권한을 주는 정식 역할이다.</p>

<p>최종 권한은 이렇게 정리됐다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud storage buckets get-iam-policy gs://my-project <span class="nt">--format</span><span class="o">=</span>json
  roles/storage.objectViewer        -&gt; allUsers
  roles/storage.objectAdmin         -&gt; serviceAccount:&lt;서비스계정&gt;@...
  roles/storage.legacyBucketReader  -&gt; serviceAccount:&lt;서비스계정&gt;@...
</code></pre></div></div>

<h2 id="6-세-번째로-걸린-곳--ci에서-수집이-타임아웃">6. 세 번째로 걸린 곳 — CI에서 수집이 타임아웃</h2>

<p>첫 워크플로 실행은 배포까지 가지도 못하고 데이터 수집 단계에서 죽었다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>urllib.error.URLError: &lt;urlopen error timed out&gt;
##[error]Process completed with exit code 1.
</code></pre></div></div>

<p>로컬에서는 잘 되던 게 러너에서만 실패했다. 원인은 단순했다. 매 실행마다 1회차부터 1,240회차까지 <strong>124페이지를 연속으로 요청</strong>하고 있었다. 로컬에서는 통과하지만 러너에서는 중간에 끊긴다.</p>

<p>고치는 방향은 재시도가 아니라 <strong>애초에 덜 요청하는 것</strong>이었다. 이미 받아둔 회차는 다시 긁지 않고 새 회차만 가져오게 바꿨다. 주 1회 실행이니 매번 1페이지면 끝난다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">draws</span> <span class="o">=</span> <span class="n">load_existing</span><span class="p">()</span>
<span class="n">have</span> <span class="o">=</span> <span class="nb">max</span><span class="p">(</span><span class="n">draws</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>
<span class="k">if</span> <span class="n">have</span> <span class="o">&gt;=</span> <span class="n">latest</span><span class="p">:</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"이미 최신 (1~</span><span class="si">{</span><span class="n">latest</span><span class="si">}</span><span class="s">회). 받을 회차 없음."</span><span class="p">)</span>
    <span class="k">return</span>
</code></pre></div></div>

<p>여기에 함정이 하나 더 있다. 기존 파일에 <strong>구멍이 있는데도 그대로 재사용하면 누락된 회차가 영구히 남는다.</strong> 그래서 1회차부터 연속으로 이어질 때만 재사용하고, 아니면 전량 다시 받도록 했다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">load_existing</span><span class="p">():</span>
    <span class="s">"""1회부터 연속으로 이어지지 않으면 증분을 포기하고 전량 수집한다."""</span>
    <span class="p">...</span>
    <span class="k">if</span> <span class="p">[</span><span class="n">d</span><span class="p">[</span><span class="s">"e"</span><span class="p">]</span> <span class="k">for</span> <span class="n">d</span> <span class="ow">in</span> <span class="n">draws</span><span class="p">]</span> <span class="o">!=</span> <span class="nb">list</span><span class="p">(</span><span class="nb">range</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">draws</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)):</span>
        <span class="k">return</span> <span class="p">{}</span>
    <span class="k">return</span> <span class="p">{</span><span class="n">d</span><span class="p">[</span><span class="s">"e"</span><span class="p">]:</span> <span class="n">d</span> <span class="k">for</span> <span class="n">d</span> <span class="ow">in</span> <span class="n">draws</span><span class="p">}</span>
</code></pre></div></div>

<p>이 조건은 눈으로 확인하기 어려워서 테스트로 박아뒀다. 중간이 빈 파일, 1회차부터 시작하지 않는 파일, 형식이 깨진 파일을 넣고 전부 <code class="language-plaintext highlighter-rouge">{}</code> 가 나오는지 본다.</p>

<p>고치고 다시 돌리니 수집부터 배포까지 전부 통과했다.</p>

<p><img src="/images/uploads/2026-09-10-gcs-static-hosting/03-actions.webp" alt="GitHub Actions 워크플로 성공 화면" /></p>

<p><em>수집 → 검증 → 채점 → 커밋 → WIF 인증 → GCS 배포까지 38초.</em></p>

<h2 id="7-비용">7. 비용</h2>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>규모</th>
      <th>월 비용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>스토리지</td>
      <td>약 200KB</td>
      <td>사실상 0</td>
    </tr>
    <tr>
      <td>클래스 A 작업(업로드)</td>
      <td>주 10여 건</td>
      <td>사실상 0</td>
    </tr>
    <tr>
      <td>네트워크 이그레스</td>
      <td>방문자 수에 비례</td>
      <td>1GB당 약 $0.12</td>
    </tr>
  </tbody>
</table>

<p>Cloud Run도 요청이 없으면 0으로 스케일되지만, 컨테이너 빌드와 레지스트리 보관, 콜드스타트가 따라온다. <strong>내려줄 게 정적 파일뿐이라면 버킷이 더 단순하고 싸다.</strong></p>

<h2 id="그래서-사이트는-뭘-하나">그래서 사이트는 뭘 하나</h2>

<p>로또 6/45 1회차부터 1,240회차까지 1등 당첨번호를 모아, 번호들 사이의 연관성 네 가지를 뽑아서 다음 회차 예상번호 6자리 5세트를 만든다.</p>

<p><img src="/images/uploads/2026-09-10-gcs-static-hosting/02-model.webp" alt="모델 구성과 번호쌍 연관성 화면" /></p>

<p><em>동반출현과 전이 리프트. 리프트 1.0이 기준선이고, ×1.81이면 기대보다 1.81배 자주 같이 나왔다는 뜻이다.</em></p>

<table>
  <thead>
    <tr>
      <th>요소</th>
      <th>내용</th>
      <th>가중</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>동반출현</td>
      <td>이미 고른 번호와 같은 회차에 나온 빈도</td>
      <td>1.4</td>
    </tr>
    <tr>
      <td>최근 빈도</td>
      <td>반감기 300회로 가중한 출현</td>
      <td>1.0</td>
    </tr>
    <tr>
      <td>직전 회차 전이</td>
      <td>직전 회차 번호 다음에 나온 번호</td>
      <td>0.8</td>
    </tr>
    <tr>
      <td>미출현 기간</td>
      <td>마지막 출현 이후 회차 ÷ 기대 간격 7.5</td>
      <td>0.6</td>
    </tr>
  </tbody>
</table>

<p>네 점수를 z점수로 맞춰 더한 뒤 가중 추출한다. 재미있는 부분은 <strong>번호를 하나씩 여섯 번 뽑으면서 매번 이미 고른 번호와의 동반출현을 다시 계산</strong>한다는 점이다. 그래서 한 세트 안의 여섯 개가 서로 엮인 조합이 된다.</p>

<p>예상번호는 추첨 <strong>전에</strong> 저장소에 커밋되고, 추첨이 끝나면 다음 실행에서 자동 채점된다. 시드가 회차번호에 묶여 있어서 같은 코드로 누구나 같은 결과를 재현할 수 있고, 나중에 성적을 고쳐 쓸 수도 없다.</p>

<p>물론 <strong>로또는 매 회차 독립시행이라 어떤 조합이든 1등 확률은 8,145,060분의 1로 같다.</strong> 백테스트로 4만 세트를 돌려봤는데 5등 적중률이 이론값과 구별되지 않았다. 예상번호는 재미로 보는 것이고, 이 프로젝트의 실제 목적은 그 아래 깔린 호스팅 구성이다.</p>

<h2 id="정리">정리</h2>

<ul>
  <li>정적 파일만 내려주는 사이트에 VM이나 컨테이너는 과하다. <strong>버킷 하나로 충분하다.</strong></li>
  <li><strong><code class="language-plaintext highlighter-rouge">--cache-control</code> 을 직접 주자.</strong> 안 주면 기본 1시간이 붙어서 배포가 반영 안 된 것처럼 보인다.</li>
  <li><code class="language-plaintext highlighter-rouge">rsync</code> 를 CI에서 돌리려면 <strong><code class="language-plaintext highlighter-rouge">objectAdmin</code> 에 <code class="language-plaintext highlighter-rouge">legacyBucketReader</code> 를 더해야</strong> 한다.</li>
  <li>서비스 계정 키 파일 대신 <strong>WIF</strong>를 쓰면 저장할 비밀이 없어진다. <code class="language-plaintext highlighter-rouge">attribute-condition</code> 으로 저장소를 반드시 못 박자.</li>
  <li>CI에서 외부 API를 긁는다면 <strong>매번 전부 받지 말고 증분으로.</strong> 재시도보다 요청을 줄이는 쪽이 근본 해결이었다.</li>
</ul>

<p>다음엔 커스텀 도메인을 붙여보거나, 이 구성을 Terraform으로 옮겨볼 생각이다.</p>]]></content><author><name>Benyco</name></author><category term="Google Cloud" /><category term="GCP" /><category term="Cloud Storage" /><category term="GitHub Actions" /><category term="정적 호스팅" /><category term="트러블슈팅" /><summary type="html"><![CDATA[서버도 컨테이너도 없이 버킷 하나로 웹사이트를 띄우고, GitHub Actions가 키 파일 없이 배포하게 만들었다. 권한과 캐시에서 한 번씩 걸렸다.]]></summary></entry><entry><title type="html">Cloud Run에 Docker 이미지 배포하기</title><link href="https://benyco-dev.github.io/cloud-run-docker-gui/" rel="alternate" type="text/html" title="Cloud Run에 Docker 이미지 배포하기" /><published>2026-09-07T00:00:00+00:00</published><updated>2026-09-07T00:00:00+00:00</updated><id>https://benyco-dev.github.io/cloud-run-docker-gui</id><content type="html" xml:base="https://benyco-dev.github.io/cloud-run-docker-gui/"><![CDATA[<p>Docker 이미지를 만들어 <strong>Cloud Run</strong>에 올리는 가장 기본적인 흐름을 처음부터 끝까지 해봤다. <code class="language-plaintext highlighter-rouge">gcloud run deploy</code> 한 줄로도 끝나는 작업이지만, 이번에는 <strong>콘솔 GUI에서 클릭으로</strong> 배포하는 과정을 정리했다. 각 설정이 화면 어디에 있는지 눈으로 익혀두면 나중에 CLI 옵션을 볼 때도 훨씬 빨리 이해된다.</p>

<p>전체 흐름은 이렇다.</p>

<ol>
  <li>앱과 Dockerfile 작성 → 이미지 빌드 → 로컬에서 동작 확인</li>
  <li>Artifact Registry 저장소를 만들고 이미지 푸시</li>
  <li>Cloud Run 콘솔에서 그 이미지를 골라 서비스 생성</li>
  <li>발급된 URL로 접속 확인</li>
</ol>

<blockquote>
  <p>아래 스크린샷의 프로젝트 ID는 <code class="language-plaintext highlighter-rouge">my-project</code>, 프로젝트 번호는 <code class="language-plaintext highlighter-rouge">XXXXXXXXXXXX</code>로 가렸다.</p>
</blockquote>

<h2 id="1-컨테이너에-올릴-앱-준비">1. 컨테이너에 올릴 앱 준비</h2>

<p>Cloud Run은 <strong>컨테이너가 <code class="language-plaintext highlighter-rouge">$PORT</code>(기본 8080)로 들어오는 HTTP 요청을 받아주기만 하면</strong> 언어나 프레임워크를 가리지 않는다. 그래서 의존성 없는 Node.js 기본 http 서버로 최소 구성을 만들었다.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app.js</span>
<span class="kd">const</span> <span class="nx">http</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">http</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">port</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">PORT</span> <span class="o">||</span> <span class="mi">8080</span><span class="p">;</span>

<span class="nx">http</span><span class="p">.</span><span class="nx">createServer</span><span class="p">((</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">res</span><span class="p">.</span><span class="nx">writeHead</span><span class="p">(</span><span class="mi">200</span><span class="p">,</span> <span class="p">{</span> <span class="dl">'</span><span class="s1">Content-Type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">text/plain</span><span class="dl">'</span> <span class="p">});</span>
  <span class="nx">res</span><span class="p">.</span><span class="nx">end</span><span class="p">(</span><span class="dl">'</span><span class="s1">Hello from Docker on Cloud Run!</span><span class="se">\n</span><span class="dl">'</span><span class="p">);</span>
<span class="p">}).</span><span class="nx">listen</span><span class="p">(</span><span class="nx">port</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`Server listening on port </span><span class="p">${</span><span class="nx">port</span><span class="p">}</span><span class="s2">`</span><span class="p">));</span>
</code></pre></div></div>

<p>포트를 하드코딩하지 않고 <code class="language-plaintext highlighter-rouge">process.env.PORT</code>를 먼저 읽는 게 핵심이다. Cloud Run은 컨테이너를 띄울 때 <code class="language-plaintext highlighter-rouge">PORT</code> 환경변수를 주입하고, 그 포트로 헬스 체크를 한다. 여기서 어긋나면 배포는 성공한 것처럼 보이다가 리비전이 준비되지 않아 실패한다.</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Dockerfile</span>
<span class="k">FROM</span><span class="s"> node:18-slim</span>
<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> package.json .</span>
<span class="k">COPY</span><span class="s"> app.js .</span>
<span class="k">EXPOSE</span><span class="s"> 8080</span>
<span class="k">CMD</span><span class="s"> ["node", "app.js"]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">EXPOSE</code>는 문서화 목적이라 Cloud Run 동작에 직접 영향을 주진 않는다. 실제 포트 매핑은 Cloud Run이 컨테이너 설정의 <strong>Container port</strong> 값으로 처리한다.</p>

<h2 id="2-이미지-빌드-후-로컬에서-먼저-확인">2. 이미지 빌드 후 로컬에서 먼저 확인</h2>

<p>Cloud Shell에서 바로 빌드했다. 로컬 Docker가 없어도 브라우저만으로 되니 실습에는 이 편이 편하다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>docker build <span class="nt">-t</span> docker-cloudrun-demo <span class="nb">.</span>
<span class="o">[</span>+] Building 26.3s <span class="o">(</span>9/9<span class="o">)</span> FINISHED
 <span class="o">=&gt;</span> <span class="o">[</span>1/4] FROM docker.io/library/node:18-slim
 <span class="o">=&gt;</span> <span class="o">[</span>2/4] WORKDIR /app
 <span class="o">=&gt;</span> <span class="o">[</span>3/4] COPY package.json <span class="nb">.</span>
 <span class="o">=&gt;</span> <span class="o">[</span>4/4] COPY app.js <span class="nb">.</span>
 <span class="o">=&gt;</span> exporting to image
</code></pre></div></div>

<p>바로 배포하지 않고 <strong>로컬에서 한 번 띄워보는 습관</strong>이 시간을 아껴준다. 컨테이너가 안 뜨는 문제를 클라우드 로그로 디버깅하는 것보다 훨씬 빠르다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>docker run <span class="nt">-d</span> <span class="nt">-p</span> 8080:8080 <span class="nt">--name</span> demo docker-cloudrun-demo
<span class="nv">$ </span>curl <span class="nt">-s</span> localhost:8080
Hello from Docker on Cloud Run!

<span class="nv">$ </span>docker ps
CONTAINER ID   IMAGE                  STATUS         PORTS
3688356b6355   docker-cloudrun-demo   Up 2 seconds   0.0.0.0:8080-&gt;8080/tcp
</code></pre></div></div>

<p>응답이 확인되면 컨테이너를 정리한다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>docker stop demo <span class="o">&amp;&amp;</span> docker <span class="nb">rm </span>demo
</code></pre></div></div>

<h2 id="3-artifact-registry에-이미지-푸시">3. Artifact Registry에 이미지 푸시</h2>

<p>Cloud Run은 <strong>레지스트리에 올라간 이미지</strong>만 배포할 수 있다. 로컬에만 있는 이미지는 고를 수 없으니 먼저 저장소를 만들고 푸시한다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">export </span><span class="nv">REGION</span><span class="o">=</span>asia-northeast3
<span class="nv">$ </span><span class="nb">export </span><span class="nv">PROJECT_ID</span><span class="o">=</span><span class="si">$(</span>gcloud config get-value project<span class="si">)</span>

<span class="nv">$ </span>gcloud artifacts repositories create docker-repo <span class="se">\</span>
    <span class="nt">--repository-format</span><span class="o">=</span>docker <span class="se">\</span>
    <span class="nt">--location</span><span class="o">=</span><span class="nv">$REGION</span> <span class="se">\</span>
    <span class="nt">--description</span><span class="o">=</span><span class="s1">'docker basics repo'</span>
Created repository <span class="o">[</span>docker-repo].

<span class="nv">$ </span>gcloud auth configure-docker <span class="nv">$REGION</span><span class="nt">-docker</span>.pkg.dev
Adding credentials <span class="k">for</span>: asia-northeast3-docker.pkg.dev

<span class="nv">$ </span>docker tag docker-cloudrun-demo <span class="se">\</span>
    <span class="nv">$REGION</span><span class="nt">-docker</span>.pkg.dev/<span class="nv">$PROJECT_ID</span>/docker-repo/docker-cloudrun-demo:v1
<span class="nv">$ </span>docker push <span class="se">\</span>
    <span class="nv">$REGION</span><span class="nt">-docker</span>.pkg.dev/<span class="nv">$PROJECT_ID</span>/docker-repo/docker-cloudrun-demo:v1
v1: digest: sha256:1002cc002b8199a84ef27698baf1bb19d6c0efa474e0c812691e6e337689248c
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">gcloud auth configure-docker</code>를 빼먹으면 푸시할 때 인증 오류가 난다. Docker에게 “이 호스트로 갈 땐 gcloud 자격 증명을 써라”라고 알려주는 단계다. 저장소 리전(<code class="language-plaintext highlighter-rouge">asia-northeast3</code>)은 이미지 주소의 접두사가 되므로 나중에 Cloud Run 리전과 맞춰두는 편이 지연 시간 면에서 유리하다.</p>

<h2 id="4-콘솔에서-cloud-run-서비스-만들기">4. 콘솔에서 Cloud Run 서비스 만들기</h2>

<p>여기서부터가 GUI 작업이다. 콘솔에서 <strong>Cloud Run</strong> 으로 이동하면 개요 화면이 나온다. 하단의 <strong>Deploy container</strong> 카드로 시작한다.</p>

<p><img src="/images/uploads/2026-09-07-cloud-run-docker/01-cloud-run-overview.webp" alt="Cloud Run 개요 화면과 Deploy container 카드" /></p>

<p><em>Cloud Run 개요. 기존 서비스 목록과 함께 배포 진입점이 카드로 제공된다.</em></p>

<p>서비스 생성 화면은 배포 소스를 먼저 고르게 되어 있다. 선택지는 세 가지다.</p>

<ul>
  <li><strong>기존 컨테이너 이미지에서 리비전 1개 배포</strong> — 이미 만들어 둔 이미지를 그대로 올린다 (이번 실습)</li>
  <li><strong>저장소에서 지속적 배포</strong> — GitHub 등을 연결해 푸시할 때마다 Cloud Build로 빌드</li>
  <li><strong>인라인 편집기로 함수 작성</strong> — 소스만 붙여넣어 배포</li>
</ul>

<p><img src="/images/uploads/2026-09-07-cloud-run-docker/02-create-service-form.webp" alt="Create service 화면의 배포 소스 선택 영역" /></p>

<p><em>Create service 초기 화면. Container image URL은 비어 있고, 리전 기본값은 <code class="language-plaintext highlighter-rouge">europe-west1</code>이다.</em></p>

<p><strong>Select</strong>를 누르면 Artifact Registry 브라우저가 열린다. 아까 만든 저장소가 트리로 보인다.</p>

<p><img src="/images/uploads/2026-09-07-cloud-run-docker/03-select-image-panel.webp" alt="Artifact Registry에서 이미지를 선택하는 패널" /></p>

<p><em>프로젝트에 있는 저장소 목록. 앞서 만든 <code class="language-plaintext highlighter-rouge">docker-repo</code>가 보인다.</em></p>

<p>저장소를 펼치면 이미지 이름이, 그 아래에 다이제스트와 태그가 나온다. <code class="language-plaintext highlighter-rouge">v1</code> 태그가 붙은 항목을 고르고 <strong>Select</strong>를 누른다.</p>

<p><img src="/images/uploads/2026-09-07-cloud-run-docker/04-select-image-tag.webp" alt="docker-cloudrun-demo 이미지의 v1 태그를 선택한 화면" /></p>

<p><em>태그(<code class="language-plaintext highlighter-rouge">v1</code>)와 다이제스트가 함께 표시된다. 푸시한 지 9분 된 이미지가 정상적으로 올라와 있다.</em></p>

<p>여기서 <strong>처음 걸렸던 부분</strong>이 두 가지 있다.</p>

<p><strong>첫째, 서비스 이름이 자동으로 채워진다.</strong> 이미지를 선택하면 이미지 이름(<code class="language-plaintext highlighter-rouge">docker-cloudrun-demo</code>)이 Service name에 자동 입력된다. 모르고 그냥 타이핑하면 기존 값 뒤에 이어 붙어 <code class="language-plaintext highlighter-rouge">docker-cloudrun-demodocker-gui-demo</code> 같은 이름이 된다. 필드를 전체 선택 후 덮어써야 한다.</p>

<p><strong>둘째, 리전 기본값이 <code class="language-plaintext highlighter-rouge">europe-west1</code>(벨기에)이다.</strong> 서울에서 쓸 서비스라면 반드시 <code class="language-plaintext highlighter-rouge">asia-northeast3</code>로 바꿔야 한다. 서비스 이름과 리전은 <strong>생성 후 변경할 수 없다.</strong></p>

<p>인증(Authentication)에서는 <strong>Allow public access</strong>를 선택했다. 로그인 없이 URL로 바로 접근하게 하는 옵션으로, 내부적으로는 <code class="language-plaintext highlighter-rouge">allUsers</code>에게 <code class="language-plaintext highlighter-rouge">run.invoker</code> 권한을 주는 것과 같다. 실습이 아니라면 기본값인 <strong>Require authentication</strong>을 유지하는 편이 안전하다.</p>

<p><img src="/images/uploads/2026-09-07-cloud-run-docker/05-configure-service.webp" alt="인증, 결제, 스케일링 설정 영역" /></p>

<p><em>Billing은 Request-based(요청 처리 중에만 과금), 최소 인스턴스는 0이 기본값이다.</em></p>

<p>최소 인스턴스가 0이면 트래픽이 없을 때 인스턴스가 완전히 내려가 비용이 들지 않는 대신, 다음 요청에서 <strong>콜드 스타트</strong>가 생긴다. 실습·토이 프로젝트라면 0이 맞다.</p>

<p>배포 전 설정을 다시 확인한다. 이미지 주소, 서비스 이름, 리전, 공개 액세스까지 네 가지만 보면 된다.</p>

<p><img src="/images/uploads/2026-09-07-cloud-run-docker/06-final-settings-before-create.webp" alt="배포 직전 최종 설정 화면" /></p>

<p><em>이미지는 Artifact Registry 경로, 리전은 <code class="language-plaintext highlighter-rouge">asia-northeast3 (Seoul)</code>로 수정된 상태.</em></p>

<h2 id="5-배포-결과-확인">5. 배포 결과 확인</h2>

<p><strong>Create</strong>를 누르면 서비스 생성 → 리비전 생성 → 트래픽 라우팅 순으로 진행된다. 세 단계가 모두 Completed가 되면 끝이다. 이미지가 이미 빌드되어 있어 20초 정도밖에 걸리지 않았다.</p>

<p><img src="/images/uploads/2026-09-07-cloud-run-docker/07-deployment-success.webp" alt="배포 완료된 서비스 상세 화면" /></p>

<p><em>리비전 <code class="language-plaintext highlighter-rouge">docker-gui-demo-00001</code>이 트래픽 100%를 받고 있다. Port 8080, CPU 1, 메모리 512MiB가 기본값이다.</em></p>

<p>상세 화면에서 눈여겨볼 값들이다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>값</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Port</td>
      <td>8080</td>
      <td>컨테이너가 리스닝해야 하는 포트</td>
    </tr>
    <tr>
      <td>Concurrency</td>
      <td>80</td>
      <td>인스턴스 1개가 동시에 처리하는 요청 수</td>
    </tr>
    <tr>
      <td>Request timeout</td>
      <td>300초</td>
      <td>이 시간을 넘기면 요청이 끊긴다</td>
    </tr>
    <tr>
      <td>Memory / CPU</td>
      <td>512MiB / 1</td>
      <td>리비전 단위로 조정 가능</td>
    </tr>
  </tbody>
</table>

<p>Concurrency 80은 다른 서버리스 제품과 구분되는 지점이다. 요청마다 인스턴스를 하나씩 쓰는 게 아니라, <strong>인스턴스 하나가 최대 80개 요청을 동시에 처리</strong>하기 때문에 I/O 위주 앱에서는 비용이 크게 절약된다.</p>

<p>발급된 URL로 접속하면 컨테이너 안의 앱이 그대로 응답한다.</p>

<p><img src="/images/uploads/2026-09-07-cloud-run-docker/08-running-service-browser.webp" alt="배포된 서비스 URL 접속 결과" /></p>

<p><em><code class="language-plaintext highlighter-rouge">https://docker-gui-demo-XXXXXXXXXXXX.asia-northeast3.run.app</code> 접속 결과.</em></p>

<h2 id="정리">정리</h2>

<p>GUI로 해보니 CLI 명령어의 옵션들이 화면의 어떤 항목에 대응하는지 명확해졌다. 결국 아래 한 줄과 같은 일을 한 셈이다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gcloud run deploy docker-gui-demo <span class="se">\</span>
    <span class="nt">--image</span><span class="o">=</span>asia-northeast3-docker.pkg.dev/my-project/docker-repo/docker-cloudrun-demo:v1 <span class="se">\</span>
    <span class="nt">--region</span><span class="o">=</span>asia-northeast3 <span class="se">\</span>
    <span class="nt">--allow-unauthenticated</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">--image</code> → Container image URL (Select 버튼)</li>
  <li><code class="language-plaintext highlighter-rouge">--region</code> → Region 드롭다운 (<strong>기본값이 유럽이므로 반드시 확인</strong>)</li>
  <li><code class="language-plaintext highlighter-rouge">--allow-unauthenticated</code> → Authentication의 Allow public access</li>
</ul>

<p>이번에 얻은 것을 정리하면,</p>

<ul>
  <li>컨테이너는 반드시 <code class="language-plaintext highlighter-rouge">$PORT</code>(기본 8080)를 읽어서 리스닝해야 한다</li>
  <li>Cloud Run은 레지스트리의 이미지만 배포하므로 Artifact Registry 푸시가 선행되어야 한다</li>
  <li>서비스 이름과 리전은 생성 후 변경할 수 없으니 Create 전에 확인한다</li>
  <li>이미지를 선택하면 서비스 이름이 자동으로 채워지므로 덮어쓸 때 주의한다</li>
</ul>

<p>반복 작업이라면 결국 CLI가 빠르지만, 처음 한 번은 GUI로 훑어보는 게 각 설정의 의미를 이해하는 데 도움이 됐다.</p>]]></content><author><name>Benyco</name></author><category term="Google Cloud" /><category term="Docker" /><category term="GCP" /><category term="Docker" /><category term="Cloud Run" /><category term="Artifact Registry" /><summary type="html"><![CDATA[직접 만든 Docker 이미지를 Artifact Registry에 올리고, Google Cloud 콘솔 GUI만으로 Cloud Run 서비스를 배포해봤다. 클릭 몇 번으로 끝나지만 리전과 서비스 이름에서 한 번씩 걸린다.]]></summary></entry></feed>