<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>junyoung-choe.log</title>
        <link>https://velog.io/</link>
        <description>라곰</description>
        <lastBuildDate>Tue, 15 Sep 2026 07:23:59 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>junyoung-choe.log</title>
            <url>https://velog.velcdn.com/images/junyoung-choe/profile/e8d7ca03-cf72-4c3a-99db-c1670f633b74/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. junyoung-choe.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/junyoung-choe" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[급한 실서버 반영은 왔는데, develop엔 아직 못 나갈 코드가 있다면]]></title>
            <link>https://velog.io/@junyoung-choe/%EA%B8%89%ED%95%9C-%EC%8B%A4%EC%84%9C%EB%B2%84-%EB%B0%98%EC%98%81%EC%9D%80-%EC%99%94%EB%8A%94%EB%8D%B0-develop%EC%97%94-%EC%95%84%EC%A7%81-%EB%AA%BB-%EB%82%98%EA%B0%88-%EC%BD%94%EB%93%9C%EA%B0%80-%EC%9E%88%EB%8B%A4%EB%A9%B4</link>
            <guid>https://velog.io/@junyoung-choe/%EA%B8%89%ED%95%9C-%EC%8B%A4%EC%84%9C%EB%B2%84-%EB%B0%98%EC%98%81%EC%9D%80-%EC%99%94%EB%8A%94%EB%8D%B0-develop%EC%97%94-%EC%95%84%EC%A7%81-%EB%AA%BB-%EB%82%98%EA%B0%88-%EC%BD%94%EB%93%9C%EA%B0%80-%EC%9E%88%EB%8B%A4%EB%A9%B4</guid>
            <pubDate>Tue, 15 Sep 2026 07:23:59 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>브랜치 전략 얘기다. 팀에서 실제로 부딪힌 딜레마 하나를 어떻게 풀었는지,
Feature Flag를 왜 안 골랐는지, 대신 뭘 골랐는지의 기록.
결론부터 말하면 hotfix 브랜치를 stage 서버에 임시로 스왑하는 방식으로 갔다.</p>
</blockquote>
<h2 id="그날-아침-들어온-요청">그날 아침 들어온 요청</h2>
<p>기획쪽에서 카톡이 왔다.</p>
<blockquote>
<p>&quot;실서버 화면 하나가 잘못 나가고 있어요. 오늘 안에 반영해주세요.&quot;</p>
</blockquote>
<p>익숙한 요청이다. 평소 흐름은 이렇다.</p>
<pre><code>feat/* ──▶ develop ──▶ stage 검증 ──▶ main ──▶ prod</code></pre><p>작은 수정이니 30분이면 끝나겠다 싶어 develop 상태를 확인했다.
여기서 브레이크가 걸렸다.</p>
<p><strong>develop 위엔 아직 실서버에 나가면 안 되는 미공개 기능들이 잔뜩 머지돼 있었다.</strong>
공모전 어드민 같은, 기획팀이 아직 오픈 시점을 안 정한 페이지들.
develop을 그대로 흘려보내면 stage에서 검증 중인 미공개 기능이 prod에 딸려 나간다.</p>
<h2 id="문제를-한-줄로-다시-쓰면">문제를 한 줄로 다시 쓰면</h2>
<p>곰곰이 정리하니 이렇게 되더라.</p>
<blockquote>
<p><strong><code>main</code> 은 언제든 배포 가능해야 하는데, <code>main</code> 을 앞으로 끌고 갈 유일한 소스인
<code>develop</code> 이 배포 불가능한 코드를 담고 있어서 prod 반영 경로가 막혔다.</strong></p>
</blockquote>
<p>원인은 develop 브랜치가 <strong>두 역할을 겸용</strong>하고 있다는 것.</p>
<ol>
<li>Stage 통합 테스트 무대 (WIP 검증 포함)</li>
<li>다음 prod 릴리즈 후보 대기실</li>
</ol>
<p>평소엔 이 둘이 문제없이 굴러갔다. 릴리즈 사이클이 짧아서 &quot;WIP 있는 상태&quot;가 오래 지속되지 않았기 때문이다. 이번엔 오래 붙어 있었다.</p>
<p>이런 상황이 앞으로도 반복될 수 있다는 걸 인정했다. 그러니 이번 한 번을 넘기는 것보다, <strong>어떤 방식으로 이걸 상시 해결할지</strong>를 정하는 게 맞다.</p>
<h2 id="첫-번째-후보-feature-flag">첫 번째 후보: Feature Flag</h2>
<p>가장 먼저 떠오른 건 Feature Flag였다. 미공개 기능을 코드에는 두되 flag로 꺼두는 방식.</p>
<ul>
<li>BE: <code>@ConditionalOnProperty(name = &quot;feature.contest.admin.enabled&quot;, havingValue = &quot;true&quot;)</code> 로 컨트롤러를 게이팅. Doppler로 환경별 flag 관리.</li>
<li>FE: <code>import.meta.env.VITE_FEATURE_CONTEST === &#39;true&#39;</code> + Amplify env.</li>
</ul>
<p>이렇게 하면 develop은 언제나 배포 가능하다. 미공개 기능이 있어도 prod에선 flag가 꺼져 있으니 흔적이 없다. 구조적으로 예쁜 해결책이다.</p>
<p>그런데 팀 규모를 생각하니 손이 멈췄다.</p>
<ul>
<li>BE 1인 + FE 1인 규모다.</li>
<li>Flag의 진짜 비용은 <strong>정리(cleanup)</strong> 다. 기능이 오픈된 뒤 flag를 제때 걷어내지 않으면 dead flag가 코드에 쌓인다. 두세 명 규모에서 이 규율을 지키는 건 현실적으로 어렵다.</li>
<li>매번 BE/FE 사이 flag 이름·타이밍을 조율해야 한다. 오버헤드.</li>
<li>Flag가 늘어날수록 조합 테스트 부담이 2^N으로 커진다.</li>
<li>무엇보다 이번이 사실상 첫 사례다. 발생 빈도가 아직 낮은데 상시 인프라를 짜는 건 과잉.</li>
</ul>
<p>Feature Flag는 팀이 3~5명을 넘어가고, 릴리즈 트레인이 상시 돌아가는 단계에서 쓰는 도구다. 지금은 아니다. <strong>기각.</strong></p>
<h2 id="두-번째-후보-3환경-브랜치-dev--ready--prod">두 번째 후보: 3환경 브랜치 (dev / ready / prod)</h2>
<p>이전 회사에서 이 방식을 썼다. 환경마다 브랜치를 두고 승인 게이트로 흘렸다. 대기업 SI에서 자주 보이는 그림이다.</p>
<p>이 팀에는 확실히 오버킬이다. 배포 절차만 무거워지고 얻는 안전마진이 지금 필요한 만큼보다 크다. <strong>기각.</strong></p>
<h2 id="채택한-방식-hotfix-브랜치--stage-스왑">채택한 방식: Hotfix 브랜치 + Stage 스왑</h2>
<p>정리하고 나서 남은 게 이거였다.</p>
<p><strong>아이디어 한 줄</strong>:</p>
<blockquote>
<p>급한 수정만 담은 <code>hotfix/*</code> 브랜치를 <strong>main에서 분기</strong>해서, <strong>stage 서버를 잠깐 이 브랜치로 갈아치우고</strong> 검증한다. 검증 끝나면 main으로 흘려 prod 반영, develop에 역머지, stage는 다시 develop로 복귀.</p>
</blockquote>
<p>정통 git-flow의 hotfix 브랜치를 살짝 확장한 것에 가깝다. 새로 발명한 게 아니라 실무에서 이름만 안 붙어 있을 뿐 널리 쓰이는 방식이다.</p>
<p>핵심을 한 줄로 압축하면:</p>
<blockquote>
<p><strong>&quot;마지막에 stage로 push된 브랜치가 stage 서버의 현재 상태를 이긴다.&quot;</strong></p>
</blockquote>
<p>이 규칙 하나가 전부다.</p>
<ul>
<li>평소: develop을 push → stage가 develop 반영</li>
<li>Hotfix 중: hotfix/* 를 push → stage가 hotfix 반영</li>
<li>Hotfix 완료 후 develop을 다시 push → stage가 다시 develop 반영</li>
</ul>
<h2 id="그림으로">그림으로</h2>
<pre><code>┌────────────┐   ┌────────────┐   ┌────────────┐
│  develop   │   │    main    │   │  hotfix/*  │  ← 필요할 때만 등장
└─────┬──────┘   └─────┬──────┘   └─────┬──────┘
      │                │                │
      │ 자동배포       │ 자동배포       │ 자동배포 (규칙 추가)
      ▼                ▼                ▼
┌────────────┐   ┌────────────┐   ┌────────────┐
│   stage    │   │   prod     │   │   stage    │ ← 같은 stage 서버
└────────────┘   └────────────┘   └────────────┘</code></pre><h2 id="하루가-어떻게-흘러가는지">하루가 어떻게 흘러가는지</h2>
<p><strong>10:00 — 요청 도착.</strong></p>
<p><strong>10:05 — main에서 hotfix 분기.</strong></p>
<pre><code>main    ────●────●
                 │
                 └── hotfix/20260915-poster-fix</code></pre><p>⚠️ <strong>반드시 main에서.</strong> develop에서 분기하면 WIP가 딸려오므로 목적이 파탄난다.</p>
<p><strong>10:30 — 수정 커밋, push.</strong></p>
<p>Push 순간 stage 자동배포가 도는데, 이때 stage가 hotfix/* 로 갈아치워진다. WIP는 stage에서 사라지고, hotfix만 반영된 깨끗한 상태가 된다.</p>
<p><strong>이 순간 stage는 사실상 &quot;prod 미리보기&quot;다.</strong> WIP가 없는 상태니까.</p>
<p><strong>10:45 — QA·프런트 검증.</strong></p>
<p>방해 없이 hotfix만 검증할 수 있다. 이게 이 방식의 진짜 가치다. <strong>&quot;검증 시점의 stage가 곧 prod가 될 코드&quot;</strong> 라는 신뢰가 생긴다.</p>
<p><strong>11:00 — main 머지.</strong></p>
<pre><code>main    ────●────●───────────────●   ← push 순간 prod 자동배포
                 │              ▲
                 └── hotfix ────┘</code></pre><p><strong>11:10 — develop에 역머지.</strong> (⚠️ 잊기 쉬움)</p>
<pre><code>develop  ───●──WIP──WIP──────────────●
                                      ▲
             hotfix ────────────────┘</code></pre><p>이걸 잊으면 다음번에 develop → main 릴리즈할 때 hotfix 수정이 사라져 회귀버그가 재발한다. 절대 잊으면 안 되는 스텝.</p>
<p><strong>11:15 — develop을 다시 push해서 stage 복귀.</strong>
<strong>11:20 — hotfix 브랜치 삭제.</strong></p>
<p>끝. 문제 요청부터 정리까지 1시간 20분.</p>
<h2 id="여기서-진짜-무서운-것들">여기서 진짜 무서운 것들</h2>
<p>이 방식을 실제로 쓴다면 조심해야 할 지점이 몇 개 있다. 개념은 단순한데 규율이 흐트러지면 실수 여지가 크다.</p>
<p><strong>첫째, hotfix 진행 중 다른 사람이 develop에 push하면 stage가 develop로 스왑된다.</strong>
검증 진행 중이던 QA는 갑자기 WIP가 나타나는 경험을 하게 된다. → 슬랙에 &quot;hotfix 시작/종료&quot; 공지가 필수. hotfix 활성 중 develop push 금지가 팀 규칙.</p>
<p><strong>둘째, hotfix를 develop에 역머지하는 걸 잊는 건 진짜 흔한 실수다.</strong>
prod에 반영되고 나면 다들 만족해서 브랜치를 지워버린다. 2주 뒤 릴리즈에서 회귀버그가 다시 튀어나온다. → PR 템플릿에 <code>[ ] develop 역머지 완료</code> 체크박스 필수, 역머지 전 브랜치 삭제 금지가 규칙.</p>
<p><strong>셋째, stage 서버가 어떤 브랜치를 반영 중인지 밖에서 안 보인다.</strong>
&quot;왜 내 코드가 없지?&quot; 삽질로 이어진다. → Swagger 상단, 어드민 footer에 배포 브랜치 + 커밋 SHA + 배포 시각을 노출. Spring Boot의 <code>build-info</code> + Actuator로 뽑을 수 있다.</p>
<p><strong>넷째, hotfix 브랜치엔 DB 마이그레이션을 넣지 말자.</strong>
hotfix는 main 기준이라 develop에서 새로 생긴 V29를 모른다. Stage DB는 이미 V29 적용됨. 대개 Flyway가 skip해서 문제없지만, 스키마 상태를 꼬이게 만들 여지가 있다. <strong>hotfix는 순수 코드 변경만.</strong></p>
<p><strong>다섯째, hotfix가 30분~1시간 넘게 걸리면 방식을 잘못 고른 거다.</strong>
Stage 스왑 방식은 짧은 hotfix용 도구다. 길어지면 정식 feat 브랜치로 전환하는 게 맞다.</p>
<h2 id="왜-이-방식이-지금-팀에-맞았나">왜 이 방식이 지금 팀에 맞았나</h2>
<p>세 가지가 딱 맞아떨어졌다.</p>
<p><strong>1. 코드에 조건부 로직이 0이다.</strong> flag 지옥이 없다. dead flag가 쌓일 걱정 없음.</p>
<p><strong>2. 학습곡선이 낮다.</strong> git-flow의 hotfix 개념을 아는 개발자면 5분 설명이면 이해한다.</p>
<p><strong>3. 인프라 변경이 최소다.</strong> GitHub Actions workflow YAML에 <code>hotfix/**</code> 트리거 한 줄 추가로 끝. Terraform·Amplify 콘솔 조작 불필요.</p>
<pre><code class="language-yaml">on:
  push:
    branches:
      - develop
      - &#39;hotfix/**&#39;    # ← 신규 추가</code></pre>
<h2 id="언제-이-결정을-재평가해야-하나">언제 이 결정을 재평가해야 하나</h2>
<p>이 방식이 영원히 맞는 건 아니다. 아래 상황 중 하나라도 발생하면 재평가한다.</p>
<ul>
<li>Hotfix 케이스가 <strong>월 3회 이상</strong> 반복적으로 발생 → Feature Flag 정식 도입 검토</li>
<li><strong>동시 hotfix</strong> 요청이 실제로 발생 → 병렬 stage 슬롯 신설 검토</li>
<li>팀 규모 <strong>3인 이상</strong> 으로 확대 → 자동화 강화 or Trunk-Based + Feature Flag 재검토</li>
<li>이 절차로 인한 <strong>회귀버그가 2회 이상</strong> 발생 → 자동화 우선순위 상향</li>
</ul>
<h2 id="배운-것-세-가지">배운 것 세 가지</h2>
<p><strong>1. &quot;정답인 도구&quot;보다 &quot;지금 팀에 맞는 도구&quot;가 우선이다.</strong>
Feature Flag는 구조적으로 우수한 해결책이지만, 팀 규모 2인에서 flag 청소 규율을 유지할 수 있느냐가 진짜 질문이었다. 도구의 이론적 우수함이 아니라 <strong>운영 비용을 감당할 수 있는지</strong>로 판단해야 한다.</p>
<p><strong>2. 브랜치 전략의 본질은 &quot;환경과 브랜치의 매핑&quot;이다.</strong>
develop이 왜 안 됐는지 파고들었더니 결국 &quot;develop이라는 브랜치 하나에 stage 무대 역할과 릴리즈 대기실 역할을 겹쳐 담았다&quot;는 문제였다. Hotfix + Stage 스왑은 <strong>잠깐 이 매핑을 바꿔치기</strong>하는 방식이다. 매핑을 이해하면 왜 이 방식이 작동하는지가 자명하다.</p>
<p><strong>3. 절차 문서엔 &quot;왜&quot;를 남겨야 한다.</strong>
&quot;어떻게&quot;만 남긴 절차는 상황이 바뀌면 그냥 낡은 문서가 된다. &quot;왜 Feature Flag가 아니라 이걸 골랐나&quot;의 논리가 함께 남아 있어야, 팀 규모가 바뀌었을 때 이 결정을 재평가할 근거가 된다. 그래서 이 결정에 대해선 절차 문서(<code>docs/guide/hotfix-workflow.md</code>)와 회고 문서(이 글)를 따로 남겼다.</p>
<hr>
<p>절차 자체는 프로젝트 안에 살아있는 문서로 정리해뒀다.
실제로 돌려보면서 또 배울 게 나올 것이다. 그때 이 글도 이어 붙일 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백엔드 개발 일지 (5·완) — application.yml의 숫자엔 전부 이유가 있다]]></title>
            <link>https://velog.io/@junyoung-choe/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80-5%EC%99%84-application.yml%EC%9D%98-%EC%88%AB%EC%9E%90%EC%97%94-%EC%A0%84%EB%B6%80-%EC%9D%B4%EC%9C%A0%EA%B0%80-%EC%9E%88%EB%8B%A4</link>
            <guid>https://velog.io/@junyoung-choe/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80-5%EC%99%84-application.yml%EC%9D%98-%EC%88%AB%EC%9E%90%EC%97%94-%EC%A0%84%EB%B6%80-%EC%9D%B4%EC%9C%A0%EA%B0%80-%EC%9E%88%EB%8B%A4</guid>
            <pubDate>Tue, 15 Sep 2026 07:23:10 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>백엔드 일지 마지막 편이다. 화려한 기능 대신 설정 파일을 꺼냈다.
application.yml에 박힌 숫자들은 대부분 &quot;기본값을 왜 바꿨는가&quot;의 기록이기 때문이다.</p>
</blockquote>
<h2 id="기본값은-우리-사정을-모른다">기본값은 우리 사정을 모른다</h2>
<p>프레임워크의 기본값은 &quot;대부분의 상황에서 무난한&quot; 값이지,
&quot;우리 상황에서 최적인&quot; 값이 아니다. 운영하면서 기본값을 하나씩
의심했고, 바꾼 값에는 주석으로 이유를 박아뒀다. 그 이유들을 풀어본다.</p>
<h2 id="3초의-철학-빨리-실패하기">3초의 철학: 빨리 실패하기</h2>
<p>이 설정 파일에서 가장 자주 나오는 숫자는 3초다. 두 군데에 있다.</p>
<pre><code class="language-yaml">datasource:
  hikari:
    connection-timeout: 3000   # 기본 30s → 3s
data:
  redis:
    timeout: 3s                # Lettuce 기본 60s → 3s</code></pre>
<p><strong>HikariCP 30초의 문제.</strong> 커넥션 풀이 고갈됐다고 하자. 기본값이면
요청 스레드가 커넥션을 기다리며 30초씩 매달린다. 그동안 톰캣 스레드는
점유된 채고, 새 요청은 계속 쌓인다. DB가 아픈 게 앱 전체의 스레드 고갈로
<strong>번져나간다.</strong> 3초면 빨리 실패하고 스레드를 돌려준다. 사용자는 에러를
보지만, 서비스 전체가 마비되는 것보단 낫다.</p>
<p><strong>Redis 60초는 더 심각했다.</strong> 우리 인증 필터는 요청마다 Redis에서
토큰 블랙리스트를 조회한다. Redis가 순단되면 기본값 기준 <strong>모든 인증 요청이
60초씩 행</strong>에 걸린다. 1편에서 &quot;Redis 죽어도 서비스는 산다&quot;를 설계해놨는데,
타임아웃이 60초면 그 설계가 무의미하다 — 죽진 않지만 60초 걸리는 서비스는
죽은 거나 다름없으니까.</p>
<p>두 값의 공통 철학: <strong>의존성 장애는 &quot;빨리 실패&quot;로 격리한다.</strong>
느린 성공보다 빠른 실패가 시스템 전체엔 이롭다.</p>
<h2 id="20초의-산수-graceful-shutdown-예산">20초의 산수: graceful shutdown 예산</h2>
<pre><code class="language-yaml">server:
  shutdown: graceful
lifecycle:
  timeout-per-shutdown-phase: 20s</code></pre>
<p>배포하면 구버전 태스크는 SIGTERM을 받는다. <code>graceful</code>은 진행 중인 요청을
마저 처리하고 죽겠다는 선언인데, 문제는 <strong>얼마나 기다릴 거냐</strong>다.</p>
<p>이 20초는 임의의 숫자가 아니라 산수의 결과다.</p>
<pre><code>ECS stop_timeout           = 30초   ← 이 안에 안 죽으면 SIGKILL (강제종료)
─────────────────────────────────
진행 중 HTTP 요청 마무리    ≈ 수 초
viewCountExecutor 큐 소진   ≤ 10초   (4편의 그 10초)
컨텍스트 정리 여유          = 나머지
─────────────────────────────────
합계                        = 20초  &lt; 30초  ✓</code></pre><p>20초를 30초보다 크게 잡으면? 앱이 &quot;아직 정리 중&quot;인데 ECS가 SIGKILL을
날린다. 진행 중이던 요청은 5xx, 큐에 있던 조회수는 유실.
<strong>graceful shutdown은 앱 설정과 인프라 설정(stop_timeout)의 합의</strong>다.
한쪽만 보고 정하면 안 된다.</p>
<h2 id="10의-근거-커넥션-풀은-곱셈이다">10의 근거: 커넥션 풀은 곱셈이다</h2>
<pre><code class="language-yaml">hikari:
  maximum-pool-size: 10   # 기본값과 같지만 명시 고정</code></pre>
<p>기본값이랑 같은데 왜 적어놨냐면, 이 10이 <strong>다른 계산의 입력</strong>이기 때문이다.
인프라 일지 5편의 그 계산이다.</p>
<pre><code>태스크당 풀 10 × 최대 태스크 4 = 40 커넥션 ≪ RDS 한계 ≈ 85</code></pre><p>누군가 &quot;풀이 작네?&quot; 하고 30으로 올리면, 오토스케일 max 4대 기준
120 커넥션 — RDS가 죽는다. 이 값은 앱 혼자 정하는 게 아니라
<code>RDS 한계 ÷ 최대 태스크 수</code>로 역산되는 값이라는 걸 주석으로 박아뒀다.
<strong>바꾸면 안 되는 값이 아니라, 바꿀 때 계산이 필요한 값</strong>이다.</p>
<h2 id="나머지-한-줄짜리-결정들">나머지 한 줄짜리 결정들</h2>
<p><strong><code>open-in-view: false</code></strong> — 기본값 true는 HTTP 응답이 끝날 때까지
DB 커넥션을 물고 있는다. 뷰 렌더링에서 LAZY 로딩을 허용하기 위한
낡은 편의인데, API 서버엔 이득이 없고 커넥션 점유 시간만 는다.
10개뿐인 귀한 풀이라 더더욱. 대신 LAZY 접근은 서비스 레이어에서
fetch join과 batch size(3편)로 끝내야 한다 — false는 그 규율의 강제장치다.</p>
<p><strong><code>default_batch_fetch_size: 100</code></strong> — 3편에서 다뤘다. N+1의 안전망.</p>
<p><strong><code>ddl-auto: validate</code></strong> — 스키마 변경은 Flyway만 한다(사건편 참고).
Hibernate에겐 &quot;만지지 말고 검증만 해라&quot;. 엔티티와 스키마가 어긋나면
런타임 어딘가에서 터지는 대신 <strong>부팅 시점에</strong> 죽는다. 빨리 실패의 또 다른 얼굴.</p>
<p><strong><code>management.health.redis.enabled: false</code></strong> — 1편에서 다룬 헬스체크와
장애 설계의 정합. Redis는 &quot;없어도 되는 의존성&quot;으로 설계했으니
헬스체크도 그렇게 답해야 한다.</p>
<p><strong><code>compression: min-response-size: 1024</code></strong> — 1KB 미만 응답은 압축 안 한다.
압축에도 CPU가 들고, 작은 응답은 압축해봐야 몇 바이트라 배보다 배꼽이 크다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 설정값은 코드보다 감사(audit)받지 않는다.</strong>
코드 리뷰는 다들 열심히 하는데 yml의 숫자는 그냥 지나친다.
기본값 하나(Redis 60s)가 장애 시나리오 전체를 바꾸는데도.</p>
<p><strong>2. 숫자엔 주석이 필요하다.</strong>
&quot;왜 3초인가&quot;를 주석으로 남기지 않으면, 미래의 누군가(대부분 나)가
근거 없이 되돌린다. 계산으로 나온 값은 계산식을 함께 적는다.</p>
<p><strong>3. 앱 설정은 인프라 설정과 짝이 있다.</strong>
graceful 20s ↔ ECS stop_timeout 30s, 풀 10 ↔ RDS 85 ÷ 태스크 4,
헬스체크 ↔ ALB 판정. 한쪽만 고치면 짝이 깨진다. 설정 파일은
독립 문서가 아니라 인프라와의 <strong>계약서</strong>였다.</p>
<hr>
<p>백엔드 일지는 여기까지. 인프라 8편 + 백엔드 5편, 돌아보면 결론은 하나로
수렴한다. <strong>기본값과 초록불을 믿지 말고, 이유와 실측만 믿기.</strong>
다음 글은 또 사건이 터지면 돌아오겠다. (안 터지는 게 최선이지만.)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백엔드 개발 일지 (4) — 조회수 하나 올리는 데 이렇게까지]]></title>
            <link>https://velog.io/@junyoung-choe/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80-4-%EC%A1%B0%ED%9A%8C%EC%88%98-%ED%95%98%EB%82%98-%EC%98%AC%EB%A6%AC%EB%8A%94-%EB%8D%B0-%EC%9D%B4%EB%A0%87%EA%B2%8C%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@junyoung-choe/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80-4-%EC%A1%B0%ED%9A%8C%EC%88%98-%ED%95%98%EB%82%98-%EC%98%AC%EB%A6%AC%EB%8A%94-%EB%8D%B0-%EC%9D%B4%EB%A0%87%EA%B2%8C%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 15 Sep 2026 07:22:28 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이번 편은 &quot;조회수 +1&quot;이라는, 요구사항 한 줄짜리 기능이다.
그런데 이 한 줄이 트랜잭션·비동기·백프레셔를 전부 소환했다. 그 과정을 남긴다.</p>
</blockquote>
<h2 id="순진한-버전에서-시작">순진한 버전에서 시작</h2>
<p>첫 구현은 누구나 떠올리는 그대로였다. 상세 조회 트랜잭션 안에서 UPDATE.</p>
<pre><code>상세 조회 트랜잭션 {
    콘텐츠 SELECT
    viewCount UPDATE +1   ← 여기
}</code></pre><p>동작한다. 그런데 곱씹을수록 이상했다.</p>
<p><strong>첫째, 조회 API에 쓰기가 섞인다.</strong> 읽기 트랜잭션이 쓰기 락을 잡는다.
같은 콘텐츠를 여럿이 동시에 열면 같은 행의 UPDATE 락을 두고 줄을 선다.
인기 콘텐츠일수록 상세 조회가 느려지는, 이상한 역설이 생긴다.</p>
<p><strong>둘째, 주객이 전도된다.</strong> 조회수 UPDATE가 실패하면 상세 조회까지 실패한다.
사용자 입장에선 &quot;화면이 안 뜨는&quot; 대형 문제가, 고작 카운트 때문에 생기는 거다.</p>
<p>조회수의 본질을 정리하면 이렇다.
<strong>정확성보다 응답 속도가 중요하고, 하나쯤 유실돼도 아무도 안 죽는 데이터.</strong>
그렇다면 설계도 그 본질을 따라가야 한다.</p>
<h2 id="분리-1단계-이벤트로-떼어내기">분리 1단계: 이벤트로 떼어내기</h2>
<p>메인 로직에서 카운트를 떼어내는 도구로 Spring의 이벤트를 썼다.
그냥 <code>@EventListener</code>가 아니라 <strong><code>@TransactionalEventListener</code></strong>다.</p>
<pre><code class="language-java">@Async(&quot;viewCountExecutor&quot;)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(ContentViewedEvent event) {
    viewCountService.increment(event.contentType(), event.contentId());
}</code></pre>
<p><code>AFTER_COMMIT</code>이 핵심이다. 메인 트랜잭션이 <strong>커밋된 뒤에만</strong> 리스너가 돈다.</p>
<ul>
<li>메인이 롤백되면? → 이벤트도 안 돈다. 실패한 조회에 카운트가 오르는 정합성 깨짐 방지.</li>
<li>그냥 <code>@EventListener</code>였다면? → 메인 트랜잭션 안에서 동기 실행. 떼어낸 의미가 없다.</li>
</ul>
<h2 id="분리-2단계-스레드까지-떼어내기">분리 2단계: 스레드까지 떼어내기</h2>
<p><code>AFTER_COMMIT</code>만으론 부족하다. 커밋 후라도 같은 스레드에서 돌면
카운트 UPDATE가 끝나야 응답이 나간다. 그래서 <code>@Async</code>를 얹었다.</p>
<p>여기서 결정 포인트 — <strong>전용 Executor를 만들었다.</strong></p>
<pre><code class="language-java">@Bean(name = &quot;viewCountExecutor&quot;)
public Executor viewCountExecutor() {
    executor.setCorePoolSize(2);       // 카운트 작업은 짧고 가볍다
    executor.setMaxPoolSize(5);
    executor.setQueueCapacity(500);
    executor.setRejectedExecutionHandler(new CallerRunsPolicy());
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(10);
}</code></pre>
<p>숫자마다 이유를 붙이면:</p>
<p><strong>core 2 / max 5</strong> — 카운트는 UPDATE 한 방짜리 초경량 작업이다. 큰 풀은 낭비고,
무엇보다 이 스레드들도 결국 <strong>DB 커넥션을 쓴다.</strong> 인프라 일지 5편에서 계산한
태스크당 커넥션 10개 예산 안에서, 비동기 풀이 커넥션을 얼마나 점유할 수 있는지가
상한을 정한다. 비동기 풀 크기는 사실 DB 커넥션 계산의 연장선이다.</p>
<p><strong>queue 500 + CallerRunsPolicy</strong> — 큐가 가득 차면 어떻게 할 것인가.
기본 정책(AbortPolicy)은 예외를 던진다 → 카운트 유실.
그 대신 CallerRunsPolicy는 <strong>요청 스레드가 직접 그 작업을 실행</strong>한다.
순간적으로 응답이 조금 느려지는 대가로, 유실 없이 밀린 속도를 따라잡는다.
생산 속도를 소비 속도에 맞춰 끌어내리는 자연스러운 <strong>백프레셔</strong>다.</p>
<p><strong>shutdown 대기 10초</strong> — 배포로 SIGTERM을 받았을 때 큐에 남은 카운트를
버리지 않고 최대 10초 기다린다. 이 10초는 다음 편에서 다룰
graceful shutdown 예산(20초) 안에 들어가도록 맞춘 숫자다.</p>
<h2 id="조용히-죽는-예외-잡기">조용히 죽는 예외 잡기</h2>
<p><code>@Async</code> + void 반환의 함정이 하나 있다. <strong>예외가 조용히 사라진다.</strong>
호출자는 이미 응답하고 떠났으니 예외를 받을 사람이 없다.</p>
<p>이중 안전망을 깔았다.</p>
<pre><code class="language-java">// 1차: 리스너 안에서 직접 catch — 개별 실패는 로그만 남기고 흡수
catch (Exception e) {
    log.error(&quot;viewCount increment failed contentType={} contentId={}&quot;, ...);
}

// 2차: AsyncUncaughtExceptionHandler — 1차를 새는 예외의 최종 안전망</code></pre>
<p>카운트 하나의 실패는 흡수하되, <strong>로그는 반드시 남긴다.</strong>
&quot;유실돼도 되는 데이터&quot;와 &quot;유실을 몰라도 되는 데이터&quot;는 다르다.
로그가 있으면 단발 유실인지, 뭔가 구조적으로 깨진 건지 구분할 수 있다.</p>
<h2 id="남은-조각-집계는-스케줄러로">남은 조각: 집계는 스케줄러로</h2>
<p>원본 카운트가 쌓이면 일간 인기 목록 같은 집계가 필요해진다.
집계는 요청 시점이 아니라 스케줄러가 주기적으로 계산해 Redis에 얹는다.</p>
<p>여기서 인프라 일지 5편의 복선이 회수된다. 태스크가 2~4대로 늘어나는
환경에서 스케줄러가 대마다 돌면 집계가 중복 실행된다.
<strong>ShedLock</strong>(Redis 분산락)으로 &quot;한 시점에 한 대만&quot;을 보장했다.
스케줄 코드에 애너테이션 하나지만, 이게 없으면 오토스케일링이
스케줄러의 버그 트리거가 된다.</p>
<p>그리고 집계 조회엔 DB fallback을 뒀다. Redis에 집계가 없으면(만료·장애)
그 자리에서 DB로 계산해 응답하고 다시 캐싱한다. 1편의
&quot;캐시 실패는 miss로 강등&quot; 원칙이 여기서도 반복된다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 데이터의 본질이 설계를 정한다.</strong>
조회수는 &quot;빠르고 대충&quot;이 맞는 데이터다. 본질이 그런데 구현이
&quot;느리고 정확&quot;하면 설계가 틀린 거다.</p>
<p><strong>2. AFTER_COMMIT과 @Async는 분리의 축이 다르다.</strong>
전자는 트랜잭션 결과와의 분리(롤백 시 미실행), 후자는 응답 시간과의 분리.
하나만 쓰면 반쪽짜리 분리가 된다.</p>
<p><strong>3. 비동기 풀의 거부 정책은 &quot;실패 모드 선택&quot;이다.</strong>
Abort는 유실, CallerRuns는 지연. 어느 쪽이 덜 아픈지는 데이터마다 다르다.
조회수는 &quot;잠깐 느려도 유실 없음&quot;을 골랐다.</p>
<hr>
<p>다음 편이 백엔드 일지 마지막이다. application.yml에 박힌 숫자들 —
3초, 20초, 100, 10 — 하나하나에 붙은 이유를 전부 푼다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백엔드 개발 일지 (3) — offset 페이징을 버린 이유]]></title>
            <link>https://velog.io/@junyoung-choe/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80-3-offset-%ED%8E%98%EC%9D%B4%EC%A7%95%EC%9D%84-%EB%B2%84%EB%A6%B0-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@junyoung-choe/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80-3-offset-%ED%8E%98%EC%9D%B4%EC%A7%95%EC%9D%84-%EB%B2%84%EB%A6%B0-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Mon, 07 Sep 2026 07:49:56 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>2편에서 검색 인덱스를 정리했다. 이번엔 그 검색 결과를 &quot;어떻게 나눠 주느냐&quot; —
페이징 이야기다. 교과서의 OFFSET 방식을 버리고 커서로 간 기록.</p>
</blockquote>
<h2 id="offset의-조용한-비용">OFFSET의 조용한 비용</h2>
<p>페이징의 기본형은 다들 이렇게 배운다.</p>
<pre><code class="language-sql">SELECT * FROM content ORDER BY id DESC LIMIT 20 OFFSET 10000;</code></pre>
<p>이 쿼리의 함정은 OFFSET의 동작 방식에 있다.
<strong>DB는 10,020행을 읽어서 10,000행을 버린다.</strong> 건너뛰는 게 아니라 읽고 버린다.</p>
<ul>
<li>1페이지: 20행 읽음</li>
<li>500페이지: 10,020행 읽고 10,000행 폐기</li>
</ul>
<p>뒤 페이지로 갈수록 느려지는 구조적 비용이고, 무한 스크롤 UI에서는
사용자가 스크롤할수록 서버가 무거워진다는 뜻이 된다.</p>
<p>하나 더 있다. <strong>행이 밀리는 문제.</strong> 사용자가 2페이지를 보는 사이 새 콘텐츠가
등록되면 전체 행이 한 칸씩 밀린다. 3페이지를 요청하면 2페이지 마지막 항목이
중복돼 나온다. 무한 스크롤에서 같은 카드가 두 번 보이는 버그의 고전적 원인이다.</p>
<h2 id="커서-몇-번째부터가-아니라-어디-다음부터">커서: &quot;몇 번째부터&quot;가 아니라 &quot;어디 다음부터&quot;</h2>
<p>커서(keyset) 방식은 질문을 바꾼다.</p>
<pre><code class="language-sql">-- OFFSET: &quot;10,000번째부터 20개 줘&quot;
-- 커서:   &quot;id 12345보다 작은 것부터 20개 줘&quot;
WHERE (:cursor IS NULL OR id &lt; :cursor)
ORDER BY id DESC
LIMIT :size</code></pre>
<p><code>id &lt; :cursor</code>는 인덱스(PK)를 타고 <strong>시작점으로 점프</strong>한다. 읽고 버리는 게 없다.
1페이지든 500페이지든 비용이 같고, 중간에 행이 끼어들어도
&quot;이 id 다음&quot;이라는 기준은 안 밀리니 중복도 없다.</p>
<p>첫 요청은 커서 없이(<code>:cursor IS NULL</code>) 오고, 응답의 마지막 항목 id가
다음 요청의 커서가 된다. 클라이언트는 &quot;마지막으로 본 것&quot;만 기억하면 된다.</p>
<h2 id="진짜-어려운-건-정렬-기준이-id가-아닐-때">진짜 어려운 건 정렬 기준이 id가 아닐 때</h2>
<p>최신순은 id 커서로 끝난다. 문제는 <strong>좋아요순</strong> 정렬이었다.</p>
<p>좋아요 수는 유일하지 않다. 좋아요 10개짜리가 서른 개면,
<code>likeCount &lt; 10</code>으로 끊는 순간 같은 10개짜리 나머지가 전부 건너뛰어진다.</p>
<p>해법은 <strong>복합 커서</strong>다. 정렬 기준 + 유일한 타이브레이커(id)를 쌍으로 쓴다.</p>
<pre><code class="language-sql">WHERE (:lastLikeCount IS NULL
       OR likeCount &lt; :lastLikeCount
       OR (likeCount = :lastLikeCount AND id &lt; :lastId))
ORDER BY likeCount DESC, id DESC</code></pre>
<p>풀어 읽으면: &quot;좋아요가 더 적거나, <strong>좋아요가 같다면 id가 더 작은</strong> 것부터&quot;.
정렬 순서(<code>likeCount DESC, id DESC</code>)와 커서 조건이 정확히 같은 순서를
표현해야 한다. 이 둘이 어긋나면 중복이나 누락이 조용히 생긴다.</p>
<p>커서 방식의 대가도 여기서 드러난다. <strong>&quot;몇 페이지로 점프&quot;가 안 된다.</strong>
항상 &quot;다음&quot;만 있다. 무한 스크롤에는 완벽하지만, 페이지 번호 UI가 필요했다면
OFFSET과의 하이브리드를 고민했을 거다. 우리는 스크롤 UI라 고민이 없었다.</p>
<h2 id="페이징을-고쳐도-n1이면-도루묵">페이징을 고쳐도 N+1이면 도루묵</h2>
<p>커서로 20행을 가볍게 가져와도, 각 행의 연관 엔티티(작성자·장르)를
LAZY 로딩으로 하나씩 긁으면 쿼리가 1 + 20 + 20개가 된다. 악명 높은 N+1.</p>
<p>1차 방어는 <strong>fetch join</strong>이다. 리스트 쿼리에서 항상 쓰는 연관은 한 방에 가져온다.</p>
<pre><code class="language-sql">SELECT c FROM Content c
LEFT JOIN FETCH c.member
LEFT JOIN FETCH c.genre
WHERE ...</code></pre>
<p>2차 방어는 설정 한 줄이다.</p>
<pre><code class="language-yaml">hibernate:
  jdbc:
    default_batch_fetch_size: 100</code></pre>
<p>fetch join이 못 덮는 경로(다른 서비스에서 재사용될 때, 컬렉션 연관 등)에서
LAZY 로딩이 발생하면, 프록시를 하나씩 조회하는 대신 <strong>IN (100개) 쿼리로 묶는다.</strong>
N+1이 N/100+1이 되는 안전망이다.</p>
<p>역할을 나누면: fetch join은 &quot;설계된 경로&quot;의 최적화, batch size는
&quot;설계 밖 경로&quot;의 보험. 전자만 믿으면 언젠가 새 코드가 N+1을 다시 만들고,
후자만 믿으면 최적 경로도 쿼리가 2개가 된다. 둘 다 필요하다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. OFFSET은 &quot;읽고 버리는&quot; 비용이다.</strong>
페이지가 뒤로 갈수록, 데이터가 쌓일수록 선형으로 느려진다.
무한 스크롤이면 커서가 구조적으로 맞다.</p>
<p><strong>2. 커서는 정렬 기준의 유일성이 생명이다.</strong>
유일하지 않은 컬럼으로 정렬하면 반드시 타이브레이커(id)를 커서에 포함하고,
WHERE 조건이 ORDER BY와 같은 순서를 표현하는지 검증해야 한다.</p>
<p><strong>3. 페이징 최적화와 N+1 방어는 세트다.</strong>
행을 가져오는 비용을 줄여도 행당 추가 쿼리가 남으면 의미가 없다.
fetch join(설계된 경로) + batch fetch size(안전망)의 이중 구조로 막았다.</p>
<hr>
<p>다음 편은 조회수다. &quot;카운트 1 올리기&quot;라는 사소해 보이는 요구사항에
트랜잭션 이벤트, 비동기 풀, 백프레셔까지 동원하게 된 이유.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백엔드 개발 일지 (2) — LIKE '%검색어%'는 인덱스를 못 탄다던데]]></title>
            <link>https://velog.io/@junyoung-choe/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80-2-LIKE-%EA%B2%80%EC%83%89%EC%96%B4%EB%8A%94-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%EB%AA%BB-%ED%83%84%EB%8B%A4%EB%8D%98%EB%8D%B0</link>
            <guid>https://velog.io/@junyoung-choe/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80-2-LIKE-%EA%B2%80%EC%83%89%EC%96%B4%EB%8A%94-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%EB%AA%BB-%ED%83%84%EB%8B%A4%EB%8D%98%EB%8D%B0</guid>
            <pubDate>Mon, 07 Sep 2026 07:49:35 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>1편은 캐시였고, 이번엔 검색이다. 인프라 일지 5편의 부하 테스트에서
&quot;가장 무거운 API = 검색&quot;이었던 이유와, 그걸 어떻게 인덱스로 풀었는지.</p>
</blockquote>
<h2 id="문제의-쿼리">문제의 쿼리</h2>
<p>검색 기능의 핵심 조건은 이거다. 대소문자 무시 + 부분 일치.</p>
<pre><code class="language-sql">WHERE LOWER(title) LIKE LOWER(&#39;%검색어%&#39;)</code></pre>
<p>동작은 잘 한다. 문제는 실행 계획이다. <strong>sequential scan</strong> — 테이블 전체를 훑는다.</p>
<p>흔히 아는 규칙대로다. B-tree 인덱스는 정렬된 자료구조라서
<code>LIKE &#39;검색어%&#39;</code>(전방 일치)는 타지만, <code>&#39;%검색어%&#39;</code>(중간 일치)는 못 탄다.
&quot;어디서부터 찾을지&quot;를 정할 수 없기 때문이다.</p>
<p>부하 테스트에서 검색이 CPU 바운드 1순위였던 이유가 여기 있다.
데이터가 적을 땐 풀스캔도 빠르다. 그런데 풀스캔의 비용은 데이터에 <strong>비례해서</strong> 자란다.
지금 안 터졌다고 두면, 언젠가 반드시 터지는 유형의 부채다.</p>
<h2 id="반은-틀린-말-pg_trgm">반은 틀린 말: pg_trgm</h2>
<p>&quot;&#39;%검색어%&#39;는 인덱스 못 탄다&quot;는 B-tree 한정의 이야기다.
PostgreSQL에는 <strong>pg_trgm</strong>이라는 확장이 있다.</p>
<p>원리는 단순하다. 문자열을 3글자 단위 조각(trigram)으로 쪼개서 색인한다.</p>
<pre><code>&quot;backend&quot; → &quot;  b&quot;, &quot; ba&quot;, &quot;bac&quot;, &quot;ack&quot;, &quot;cke&quot;, &quot;ken&quot;, &quot;end&quot;, &quot;nd &quot;</code></pre><p>검색어도 같은 방식으로 쪼갠 뒤, <strong>조각이 겹치는 행만</strong> 후보로 추린다.
중간 일치라도 조각 단위로는 &quot;어디서 찾을지&quot;가 정의되는 거다.
이걸 GIN(역색인) 인덱스에 얹으면 <code>LIKE &#39;%검색어%&#39;</code>가 인덱스를 탄다.</p>
<pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS pg_trgm;</code></pre>
<p>참고로 RDS에서는 이 <code>CREATE EXTENSION</code>에 rds_superuser 권한이 필요해서,
마이그레이션이 실행되는 계정 권한을 미리 확인해야 한다. (여기서 한 번 막혔다.)</p>
<h2 id="함정-하나-lower가-인덱스를-무력화한다">함정 하나: LOWER()가 인덱스를 무력화한다</h2>
<p>그냥 컬럼에 GIN 인덱스를 걸면 될 것 같지만, 우리 쿼리는 <code>LOWER(title)</code>이다.
인덱스는 <code>title</code>에 걸려 있는데 쿼리는 <code>LOWER(title)</code>을 찾으니 <strong>매치가 안 된다.</strong></p>
<p>인덱스는 컬럼이 아니라 <strong>표현식</strong>에도 걸 수 있다. 쿼리에 쓴 표현식 그대로.</p>
<pre><code class="language-sql">CREATE INDEX idx_content_title_trgm
    ON content USING GIN (LOWER(title) gin_trgm_ops)
    WHERE deleted_at IS NULL;</code></pre>
<p>쿼리의 <code>LOWER(title)</code>과 인덱스의 <code>LOWER(title)</code>이 글자 그대로 일치해야
플래너가 인덱스를 집어든다. <strong>인덱스는 쿼리를 따라가야지, 그 반대가 아니다.</strong></p>
<h2 id="함정-둘-지운-데이터까지-색인할-필요는-없다">함정 둘: 지운 데이터까지 색인할 필요는 없다</h2>
<p>위 DDL의 마지막 줄, <code>WHERE deleted_at IS NULL</code> — <strong>partial index</strong>다.</p>
<p>우리 서비스는 soft delete를 쓴다. 지워진 행도 테이블엔 남는다.
그런데 검색 쿼리는 항상 활성 행만 대상으로 한다. 그럼 인덱스도
활성 행만 색인하면 된다. 삭제된 행 몫의 인덱스 크기와 갱신 비용이 통째로 빠진다.</p>
<p>이 partial index는 다른 곳에서 더 재밌게 쓰였다. <strong>UNIQUE 제약</strong>이다.</p>
<pre><code class="language-sql">-- 문제: 탈퇴(soft delete)한 회원의 소셜 ID·닉네임이 UNIQUE에 계속 잡혀서
--       같은 계정으로 재가입하면 UNIQUE violation → 500
CREATE UNIQUE INDEX uk_member_provider_active
    ON member (provider, provider_id) WHERE deleted_at IS NULL;</code></pre>
<p>일반 UNIQUE 제약은 지워진 행까지 포함해 유일성을 강제한다.
탈퇴한 회원이 재가입을 못 하는 버그의 정체가 이거였다.
partial unique index로 바꾸면 &quot;<strong>활성 행끼리만</strong> 유일&quot;이 된다.
탈퇴 행은 유일성 검사 대상에서 빠지니 재가입이 자연스럽게 풀린다.</p>
<p>soft delete를 쓰는 순간, 모든 UNIQUE 제약은
&quot;전체 행 기준인가, 활성 행 기준인가&quot;를 다시 물어야 한다.</p>
<h2 id="적용-범위-전부-걸지-않았다">적용 범위: 전부 걸지 않았다</h2>
<p>trigram 인덱스를 검색 가능한 모든 컬럼에 걸진 않았다.</p>
<pre><code>사용자 검색 (고빈도):  콘텐츠 3종의 title       → 즉시 적용
어드민 검색 (저빈도):  닉네임, 댓글 본문        → 풀스캔 회피 목적으로만</code></pre><p>GIN 인덱스는 공짜가 아니다. 쓰기마다 trigram 분해·색인 갱신 비용이 붙는다.
고빈도 검색엔 확실한 이득이고, 저빈도 검색엔 &quot;최악(풀스캔) 방지&quot;만 노렸다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. &quot;인덱스 못 탄다&quot;는 대부분 &quot;B-tree로는&quot;의 줄임말이다.</strong>
인덱스 종류(GIN, GiST, BRIN...)마다 잘하는 질문이 다르다.
쿼리 패턴에 맞는 인덱스를 고르는 게 튜닝의 시작이다.</p>
<p><strong>2. 인덱스는 쿼리와 글자 단위로 맞아야 한다.</strong>
<code>LOWER()</code> 하나로 인덱스가 무력화된다. 표현식 인덱스는 쿼리에 쓴
표현식을 그대로 복사해 만든다.</p>
<p><strong>3. soft delete는 인덱스 전략을 바꾼다.</strong>
검색 인덱스는 partial로 작게, UNIQUE는 partial로 &quot;활성 행만 유일&quot;로.
<code>deleted_at IS NULL</code>이 붙은 인덱스가 늘어나는 건 자연스러운 결과다.</p>
<hr>
<p>다음 편은 페이징이다. <code>OFFSET 10000</code>이 왜 뒤로 갈수록 느려지는지,
그리고 커서 방식으로 갈아탄 이유.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백엔드 개발 일지 (1) — 캐시 서버가 죽으면 서비스도 죽어야 할까?]]></title>
            <link>https://velog.io/@junyoung-choe/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80-1-%EC%BA%90%EC%8B%9C-%EC%84%9C%EB%B2%84%EA%B0%80-%EC%A3%BD%EC%9C%BC%EB%A9%B4-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%8F%84-%EC%A3%BD%EC%96%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@junyoung-choe/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80-1-%EC%BA%90%EC%8B%9C-%EC%84%9C%EB%B2%84%EA%B0%80-%EC%A3%BD%EC%9C%BC%EB%A9%B4-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%8F%84-%EC%A3%BD%EC%96%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Mon, 07 Sep 2026 07:48:56 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>인프라 구축 일지는 끝났고, 이번엔 그 위에서 돌아가는 애플리케이션 이야기다.
첫 주제는 캐시. Redis를 붙이는 건 쉬운데, &quot;Redis가 아플 때&quot;를 설계하는 게 진짜 일이었다.</p>
</blockquote>
<h2 id="캐시를-붙인-이유부터">캐시를 붙인 이유부터</h2>
<p>메인 화면은 조회가 압도적으로 많고, 내용은 자주 안 바뀐다.
리스트 첫 페이지, 추천 목록, 배너 — 매 요청마다 DB를 때릴 이유가 없다.
Spring Cache + Redis 조합으로 <code>@Cacheable</code>을 붙였다. 여기까진 교과서다.</p>
<p>문제는 그 다음부터였다.</p>
<h2 id="ttl은-하나가-아니다">TTL은 하나가 아니다</h2>
<p>처음엔 전부 1시간 TTL이었다. 그런데 성격이 다른 데이터가 섞여 있었다.</p>
<pre><code>기본 TTL:        1시간   (추천·배너 — 어드민이 바꿀 때만 변함)
리스트 캐시:     3분     (사용자가 등록하면 바로 보여야 함)</code></pre><p>리스트에 1시간 TTL을 주면, 사용자가 콘텐츠를 등록하고 나서
&quot;내 것이 목록에 없는데요?&quot;가 최대 1시간 지속된다.
반대로 추천·배너에 3분을 주면 히트율만 떨어진다.</p>
<p><strong>TTL은 &quot;이 데이터가 얼마나 오래 틀려도 되는가&quot;에 대한 답</strong>이다.
데이터마다 답이 다르면 TTL도 달라야 한다.</p>
<h2 id="ttl만-믿을-수-없는-경우-삭제">TTL만 믿을 수 없는 경우: 삭제</h2>
<p>3분 TTL이라도 못 기다리는 경우가 있다. <strong>콘텐츠 삭제·제재</strong>다.
내려간 콘텐츠가 리스트에 3분, 추천·배너에 1시간 떠 있는 건 곤란하다.</p>
<p>그래서 삭제 흐름에 캐시 무효화를 명시적으로 넣었다.</p>
<pre><code class="language-java">public void evict(ContentType type) {
    clear(cacheName(type));      // 해당 타입 리스트 캐시
    clear(RECOMMENDATIONS);      // 추천 (삭제 cascade에 걸릴 수 있음)
    clear(BANNERS);              // 배너 (동일)
}</code></pre>
<p>포인트는 <code>@CacheEvict</code> 애너테이션이 아니라 프로그래매틱 방식을 쓴 것.
회원 탈퇴 cascade에선 삭제되는 콘텐츠 타입이 <strong>런타임에 결정</strong>되기 때문에
애너테이션으로는 못 박을 수가 없었다.</p>
<p>그리고 추천 캐시는 키별로 골라 지우지 않고 통째로 clear 한다.
키 매칭 로직을 정교하게 만드는 비용 대비, 재계산 비용이 무시할 수준이라서다.
<strong>무효화는 정밀함보다 확실함이 우선이다.</strong></p>
<h2 id="본론-redis가-죽으면-어떻게-되나">본론: Redis가 죽으면 어떻게 되나</h2>
<p>여기서부터가 이 글을 쓴 이유다. 어느 날 자문해봤다.
&quot;Redis가 내려가면 우리 서비스는 어떻게 되지?&quot;</p>
<p>기본 동작을 확인해보니 끔찍했다. <code>@Cacheable</code>은 메서드 실행 <strong>전에</strong> 캐시를
조회하는데, 이 조회가 예외를 던지면 그대로 500이 된다.
<strong>캐시는 최적화하려고 붙인 건데, 캐시 장애가 서비스 장애가 되는 구조</strong>였다.</p>
<p>더 음험한 경우도 겪었다. 응답 DTO에 등록일(<code>LocalDateTime</code>) 필드를 추가했는데,
직렬화기에 JavaTimeModule이 빠져 있어서 캐시 저장이 깨졌다.
코드를 고쳐 배포해도 — <strong>깨진 엔트리가 Redis에 남아 있는 한</strong>,
<code>@Cacheable</code>이 메서드 실행 전에 그 엔트리를 읽다가 계속 500을 냈다.
새 코드가 실행될 기회 자체가 없는 거다.</p>
<h2 id="해법-실패를-cache-miss로-강등">해법: 실패를 cache miss로 강등</h2>
<p>Spring Cache에는 <code>CacheErrorHandler</code>라는 확장점이 있다. 여기에 원칙 하나를 심었다.</p>
<blockquote>
<p>캐시는 어디까지나 최적화 레이어다. <strong>캐시 실패가 응답을 막으면 안 된다.</strong></p>
</blockquote>
<pre><code class="language-java">public void handleCacheGetError(RuntimeException e, Cache cache, Object key) {
    log.warn(&quot;캐시 GET 실패 → miss 강등 + 깨진 엔트리 evict. ...&quot;);
    try {
        cache.evict(key);   // 깨진 엔트리 제거 → 다음 요청이 정상 값 재기록
    } catch (RuntimeException evictException) {
        log.warn(&quot;evict도 실패 (TTL 만료 대기). ...&quot;);
    }
}</code></pre>
<p>동작을 풀면:</p>
<pre><code>GET 실패  → miss 취급 + 깨진 키 evict → DB 조회 → 정상 값 재기록 (자가 치유)
PUT 실패  → 캐싱 없이 응답만 진행 (다음 요청이 재시도)
EVICT 실패 → 이건 error 로그 — stale 데이터가 TTL 동안 남는 실질 위험</code></pre><p>GET/PUT 실패는 warn, EVICT 실패만 error로 로그 레벨을 갈랐다.
GET 실패는 성능 저하일 뿐이지만, EVICT 실패는 <strong>틀린 데이터가 노출되는</strong> 문제라
사람이 봐야 할 수도 있기 때문이다. 같은 &quot;캐시 실패&quot;라도 결과의 무게가 다르다.</p>
<p>특히 GET 실패 시 evict를 같이 하는 게 핵심이다.
위의 LocalDateTime 사건처럼 깨진 엔트리가 박혀 있으면, miss 강등만으론
매 요청이 DB로 가는 상태가 TTL 내내 지속된다. 깨진 키를 지워버리면
다음 요청이 정상 값을 다시 써넣으면서 <strong>캐시가 스스로 낫는다.</strong></p>
<h2 id="마지막-퍼즐-헬스체크">마지막 퍼즐: 헬스체크</h2>
<p>Redis 장애 대응이 앱 레벨에서 끝나도, 인프라가 배신할 수 있다.</p>
<p>기본 설정의 <code>/actuator/health</code>는 Redis 상태를 포함한다. Redis가 죽으면
헬스체크가 DOWN → ALB가 태스크를 비정상으로 판정 → 태스크 교체 →
새 태스크도 Redis에 못 붙으니 또 DOWN → <strong>교체 무한 루프.</strong>
앱은 캐시 없이 버틸 수 있게 만들어놨는데, ALB가 멀쩡한 앱을 계속 죽이는 그림이다.</p>
<pre><code class="language-yaml">management:
  health:
    redis:
      enabled: false   # 헬스체크에서 Redis 제외</code></pre>
<p>&quot;Redis 없이도 응답할 수 있다&quot;고 설계했으면,
헬스체크의 정의도 그 설계에 맞춰야 한다. <strong>헬스체크는 &quot;필수 의존성&quot;만 물어야 한다.</strong></p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 캐시 설계의 절반은 &quot;장애 시나리오&quot;다.</strong>
붙이는 건 애너테이션 하나. 진짜 설계는 GET/PUT/EVICT 각각이 실패했을 때
서비스가 어떻게 되는지 정의하는 일이었다.</p>
<p><strong>2. 깨진 캐시 엔트리는 배포로 안 고쳐진다.</strong>
코드를 고쳐도 캐시에 남은 과거의 산물이 계속 터진다.
자가 치유(evict on error)를 넣거나, 최소한 캐시 키에 버전을 태워야 한다.</p>
<p><strong>3. 앱의 장애 설계와 인프라의 장애 판정을 맞춰야 한다.</strong>
앱은 &quot;Redis 없이 버틴다&quot;인데 헬스체크는 &quot;Redis 없으면 죽음&quot;이면,
더 정교한 쪽 설계가 무력화된다.</p>
<hr>
<p>다음 편은 검색이다. <code>LIKE &#39;%검색어%&#39;</code>는 인덱스를 못 탄다는 말,
반은 맞고 반은 틀리다 — PostgreSQL pg_trgm 이야기.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프라 구축 일지 (8·완) — 오픈 전 마지막 일주일에 한 일들]]></title>
            <link>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-8%EC%99%84-%EC%98%A4%ED%94%88-%EC%A0%84-%EB%A7%88%EC%A7%80%EB%A7%89-%EC%9D%BC%EC%A3%BC%EC%9D%BC%EC%97%90-%ED%95%9C-%EC%9D%BC%EB%93%A4</link>
            <guid>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-8%EC%99%84-%EC%98%A4%ED%94%88-%EC%A0%84-%EB%A7%88%EC%A7%80%EB%A7%89-%EC%9D%BC%EC%A3%BC%EC%9D%BC%EC%97%90-%ED%95%9C-%EC%9D%BC%EB%93%A4</guid>
            <pubDate>Fri, 04 Sep 2026 07:43:37 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>구축 일지 마지막 편이다. 인프라는 다 만들어져 있었다.
문제는 &quot;이대로 열어도 되나?&quot;에 답하는 일이었다.</p>
</blockquote>
<h2 id="다-됐다와-열어도-된다의-간극">&quot;다 됐다&quot;와 &quot;열어도 된다&quot;의 간극</h2>
<p>기능 개발이 끝나고 오픈 일정이 잡히면, 이상하게 그때부터 불안해진다.
개발 환경에선 편했던 것들이 운영에선 구멍이 되기 때문이다.
오픈 전 마지막 주에 한 것들을 영역별로 훑어보며 정리한다.</p>
<h2 id="1-문-잠그기-운영-swagger-차단">1. 문 잠그기: 운영 Swagger 차단</h2>
<p>개발 내내 잘 쓰던 Swagger UI. API 명세를 브라우저로 보고 바로 호출해볼 수 있어 편하다.
그런데 그 &quot;편함&quot;이 운영에서는 그대로 <strong>공격자의 편함</strong>이 된다.
전체 API 목록, 파라미터 구조, 응답 스키마가 인증도 없이 공개되는 셈이니까.</p>
<p>방법이 고민이었다. 조건에 따라 코드를 바꾸는 건 싫었다.
같은 코드가 환경마다 다르게 동작하면 &quot;스테이징에선 됐는데&quot; 류의 문제가 생긴다.</p>
<p>결론은 <strong>환경변수 스위치</strong>였다. terraform이 운영 태스크에만 비활성 플래그를 주입한다.</p>
<pre><code>코드:        모든 환경에서 동일 (springdoc enabled 여부만 env로)
스테이징:    Swagger 살아있음 (개발 편의 유지)
운영:        terraform이 SPRINGDOC_*_ENABLED=false 주입</code></pre><p>적용하고 끝이 아니라 <strong>실측</strong>했다. Swagger 관련 4개 경로
(<code>/swagger-ui.html</code>, <code>/swagger-ui/**</code>, <code>/v3/api-docs</code> 등)를 운영 도메인에 직접 요청해서
전부 404인 걸 확인했다. &quot;설정했으니 됐겠지&quot;는 5편의 부하 테스트에서 이미 버린 습관이다.</p>
<h2 id="2-판-갈기-db-리셋">2. 판 갈기: DB 리셋</h2>
<p>베타 기간 동안 운영 DB엔 테스트 데이터가 쌓여 있었다. 실험 계정, 더미 데이터, 시행착오 흔적들.
정식 오픈은 깨끗한 판에서 시작하기로 했다.</p>
<pre><code>① 스키마 전체 DROP
② 앱 재기동 → Flyway가 V1부터 순서대로 마이그레이션 재실행
③ 어드민 초기 계정은 시크릿 저장소 값으로 자동 재생성</code></pre><p>포인트는 ②다. 백업을 복원하는 게 아니라 <strong>마이그레이션 이력으로 스키마를 처음부터 재구성</strong>했다.
이게 되려면 V1부터 최신까지의 마이그레이션이 빈 DB에서 한 번에 완주할 수 있어야 하는데,
실제로 완주했다. &quot;우리 DB 스키마는 코드(마이그레이션 파일)만으로 재현 가능하다&quot;는 게
증명된 순간이라 꽤 뿌듯했다.</p>
<p>③도 같은 맥락이다. 어드민 계정을 수동으로 만들지 않고, 앱이 기동하며
시크릿 저장소의 초기값으로 생성한다. 리셋 후 사람 손이 갈 일이 없었다.</p>
<p>그런데 — 시리즈 첫 글을 읽었다면 눈치챘겠지만, 이 리셋이 나중에 <strong>Flyway 사건의 복선</strong>이 된다.
운영은 리셋된 빈 DB, 스테이징은 리셋 안 한 DB. 이 상태 차이가 얼마 뒤
&quot;운영은 통과하고 스테이징만 실패하는 마이그레이션&quot;을 만들었다.
그때는 몰랐다. 환경 간 DB 상태를 갈라놓는 결정은 이렇게 시한부 부채가 되기도 한다.</p>
<h2 id="3-마지막-감사-전-영역-훑기">3. 마지막 감사: 전 영역 훑기</h2>
<p>오픈 결정 전에 영역별로 최종 점검을 돌았다. 요약하면 이런 체크리스트였다.</p>
<p><strong>네트워크·접근 통제</strong></p>
<ul>
<li><input checked="" disabled="" type="checkbox"> 외부 진입점이 ALB 하나뿐인가 (1편 구조 그대로인지 재확인)</li>
<li><input checked="" disabled="" type="checkbox"> DB 서브넷에 인터넷 라우트가 없는가</li>
<li><input checked="" disabled="" type="checkbox"> 어드민 API는 IP allowlist로 제한되는가</li>
</ul>
<p><strong>시크릿·노출면</strong></p>
<ul>
<li><input checked="" disabled="" type="checkbox"> 코드·이미지·리포지토리에 평문 시크릿이 없는가 (3편)</li>
<li><input checked="" disabled="" type="checkbox"> 운영 Swagger·API Docs 차단 (위 1번)</li>
<li><input checked="" disabled="" type="checkbox"> 프런트 운영 빌드에서 소스맵 제외</li>
</ul>
<p><strong>확장·가용성</strong></p>
<ul>
<li><input checked="" disabled="" type="checkbox"> 오토스케일링 실증 완료 — 포화점 실측 + 증설 동작 확인 (5편)</li>
<li><input checked="" disabled="" type="checkbox"> 다중 인스턴스 안전성 — 스케줄러 분산락, JWT 무상태, DB 커넥션 계산</li>
<li><input checked="" disabled="" type="checkbox"> 배포 실패 시 자동 롤백(서킷 브레이커) 동작</li>
</ul>
<p><strong>관측</strong></p>
<ul>
<li><input checked="" disabled="" type="checkbox"> 알람 6종(CPU·메모리·5xx·RDS) + SNS 이메일 수신 확인 — 실제로 테스트 메일까지 받아봄</li>
<li><input checked="" disabled="" type="checkbox"> ALB 액세스 로그 S3 90일 보존 (사고 시 포렌식용)</li>
</ul>
<p>여기서도 원칙은 같았다. 항목마다 &quot;설정돼 있다&quot;가 아니라 <strong>&quot;확인했다&quot;</strong>로 체크했다.
알람은 SNS 구독 확인 메일을 실제로 받았고, Swagger는 404를 실제로 봤고,
오토스케일링은 증설 이벤트 로그를 실제로 읽었다.</p>
<h2 id="4-안-한-것들도-기록해뒀다">4. 안 한 것들도 기록해뒀다</h2>
<p>전부 다 하고 연 건 아니다. 의도적으로 미룬 것들이 있다.</p>
<ul>
<li><strong>RDS Multi-AZ</strong>: 비용 대비 현재 트래픽 규모에선 과하다고 판단. 장애 시 복구 절차만 문서화</li>
<li><strong>NAT 게이트웨이 이중화</strong>: 같은 이유로 단일 구성 유지</li>
<li><strong>WAF 패턴 규칙 일괄 BLOCK</strong>: 오탐 관측을 거쳐 규칙별로 순차 승격하기로 (6편)</li>
</ul>
<p>중요한 건 &quot;몰라서 안 한 것&quot;과 &quot;알고 미룬 것&quot;을 구분해 적어두는 거였다.
후자는 트래픽이 늘거나 요구사항이 바뀌면 꺼내 쓸 수 있는 대기 목록이 된다.</p>
<h2 id="시리즈를-마치며">시리즈를 마치며</h2>
<p>혼자 인프라를 구축하면서 가장 크게 남은 건 기술 지식보다 태도에 가깝다.</p>
<p><strong>&quot;설정했다&quot;를 믿지 말고 &quot;확인했다&quot;만 믿기.</strong>
부하 테스트와 증설 이벤트 로그(5편), Swagger 404 실측과 알람 수신 확인(이번 편) —
전부 같은 원칙의 반복이었다. 그리고 이 원칙이 무너진 지점에서
정확히 사고가 났다. stage 배포가 5주간 조용히 롤백되던 Flyway 사건은
&quot;초록불 = 반영됨&quot;을 확인 없이 믿은 대가였다.</p>
<p>인프라는 만드는 것보다 의심하는 게 일이다. 이 시리즈가 누군가의
&quot;이대로 열어도 되나?&quot;에 체크리스트 하나라도 보태면 충분하다.</p>
<hr>
<p>구축 일지는 여기까지. 운영하면서 생기는 사건들은
(첫 글의 Flyway 삽질기처럼) 사건이 생길 때마다 따로 남길 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프라 구축 일지 (7) — 매일 아침 5분, 서버 점검 루틴 만들기]]></title>
            <link>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-7-%EB%A7%A4%EC%9D%BC-%EC%95%84%EC%B9%A8-5%EB%B6%84-%EC%84%9C%EB%B2%84-%EC%A0%90%EA%B2%80-%EB%A3%A8%ED%8B%B4-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-7-%EB%A7%A4%EC%9D%BC-%EC%95%84%EC%B9%A8-5%EB%B6%84-%EC%84%9C%EB%B2%84-%EC%A0%90%EA%B2%80-%EB%A3%A8%ED%8B%B4-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Fri, 04 Sep 2026 07:41:06 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>구축이 끝나면 운영이 시작된다. 오픈 이후 매일 아침 반복하는
4종 점검 루틴과, 로그를 &quot;읽는 기준&quot;을 정한 이야기다.</p>
</blockquote>
<h2 id="대시보드를-열지-않기로-했다">대시보드를 열지 않기로 했다</h2>
<p>오픈 직후엔 불안해서 CloudWatch 콘솔을 하루에도 몇 번씩 열었다.
그런데 콘솔을 돌아다니는 건 시간도 걸리고, 볼 때마다 보는 범위가 달랐다.
어제는 알람을 봤는데 오늘은 로그만 봤다든가.</p>
<p>그래서 점검을 <strong>고정된 질문 4개</strong>로 정형화했다. 매일 같은 명령, 같은 범위, 같은 기준.
CLI로 병렬 실행하면 5분 안에 끝난다.</p>
<h2 id="4종-점검">4종 점검</h2>
<p><strong>① 발령 중인 알람이 있나</strong></p>
<pre><code class="language-bash">aws cloudwatch describe-alarms --state-value ALARM</code></pre>
<p>CPU·메모리·5xx·RDS 커넥션 등 걸어둔 알람 중 지금 울리는 게 있는지.
정상이면 빈 배열. 뭐라도 나오면 그날 점검은 여기서 방향이 바뀐다.</p>
<p><strong>② 지난 24시간 ERROR/WARN 로그</strong></p>
<pre><code class="language-bash">aws logs filter-log-events \
  --log-group-name &quot;/ecs/&lt;백엔드 로그그룹&gt;&quot; \
  --filter-pattern &quot;?ERROR ?WARN&quot; \
  --start-time &lt;24시간 전 epoch&gt;</code></pre>
<p>전체 로그를 다 보는 게 아니라 ERROR와 WARN만 거른다. (해석 기준은 아래에서.)</p>
<p><strong>③ ALB 5xx 카운트</strong></p>
<pre><code class="language-bash">aws cloudwatch get-metric-statistics \
  --namespace AWS/ApplicationELB \
  --metric-name HTTPCode_Target_5XX_Count ...</code></pre>
<p>앱이 5xx를 뱉은 게 한 건이라도 있는지. 목표는 매일 <strong>0건</strong>, 지금까지 0건 유지 중이다.</p>
<p><strong>④ ECS 서비스 상태</strong></p>
<pre><code class="language-bash">aws ecs describe-services --cluster &lt;클러스터&gt; --services &lt;서비스&gt;</code></pre>
<p>running == desired인지, 이벤트에 재시작·롤백 흔적이 없는지.</p>
<h2 id="진짜-어려운-건-읽는-기준">진짜 어려운 건 &quot;읽는 기준&quot;</h2>
<p>명령 4개는 금방 만든다. 어려운 건 결과를 보고 <strong>무엇을 무시할지</strong> 정하는 거였다.</p>
<p>②번의 WARN 로그를 예로 들면, 매일 이런 게 몇 건씩 나온다.</p>
<pre><code>WARN ... BusinessException: INVALID_REFRESH_TOKEN
WARN ... BusinessException: ALREADY_REGISTERED
WARN ... BusinessException: INVALID_URL</code></pre><p>처음엔 WARN이라는 글자만 보고 긴장했는데, 뜯어보면 이건 <strong>정상 동작이다.</strong>
만료된 토큰으로 갱신을 시도했다거나, 이미 등록된 콘텐츠를 또 등록하려 했다거나 —
전부 예상된 사용자 시나리오고, 전역 예외 핸들러가 잡아서 4xx로 응답한 흔적이다.
앱이 아프다는 신호가 아니라 앱이 일하고 있다는 신호다.</p>
<p>그래서 기준을 이렇게 세웠다.</p>
<table>
<thead>
<tr>
<th>로그</th>
<th>판정</th>
</tr>
</thead>
<tbody><tr>
<td>BusinessException 계열 WARN (핸들러 처리분)</td>
<td>무해 — 개수 추이만 본다</td>
</tr>
<tr>
<td>처리 안 된 스택트레이스</td>
<td><strong>즉시 조사</strong></td>
</tr>
<tr>
<td>5xx &gt; 0</td>
<td><strong>즉시 조사</strong></td>
</tr>
<tr>
<td>같은 예외가 짧은 시간에 반복</td>
<td>UX 문제 신호로 기록</td>
</tr>
</tbody></table>
<h2 id="무해한-로그가-알려준-것">무해한 로그가 알려준 것</h2>
<p>마지막 줄의 &quot;짧은 시간 반복&quot;이 실제로 걸린 적이 있다.
어느 날 <code>INVALID_URL</code>이 <strong>21초 동안 7건</strong> 찍혔다.</p>
<p>서버 입장에선 전부 정상 처리된 무해 로그다. 그런데 상상해보면 —
한 사용자가 URL을 붙여넣고, 거부당하고, 다시 시도하고, 또 거부당하고...
를 21초간 7번 반복한 그림이다. 서버는 멀쩡한데 <strong>사용자는 고통받고 있었다.</strong></p>
<p>이 관찰이 계기가 되어 파고들었더니, <code>https://</code>를 빼고 URL을 입력하면
무조건 거부되는 파싱 버그가 나왔다. (공교롭게도 오픈 전 준비 기간에
비슷한 URL 파싱 수정을 stage에서 확인하려다 더 큰 사건을 만난 적이 있다 —
시리즈 첫 글의 Flyway 삽질기다.)</p>
<p>에러 로그가 아니라 <strong>무해한 로그의 패턴</strong>이 버그를 찾아준 셈이다.</p>
<h2 id="이-루틴이-못-잡는-것">이 루틴이 못 잡는 것</h2>
<p>정직하게 적어두면, 이 4종 점검엔 사각지대가 있다.</p>
<p>오픈 전에 겪은 Flyway 사건이 증명했듯 — stage 배포가 5주간 조용히
롤백되는 유형의 실패는 이런 점검으로 못 잡는다. 알람 없음, 5xx 없음, ECS 정상, 에러 로그 없음.
<strong>이 점검은 전부 &quot;서비스가 살아있는가&quot;를 묻는 질문</strong>이고,
&quot;새 버전이 반영됐는가&quot;는 묻지 않기 때문이다.</p>
<p>그래서 지금은 배포 직후엔 별도로 확인한다: 배포 이벤트 이력과
실행 중인 태스크의 이미지가 방금 푸시한 커밋인지.
점검 루틴은 만능이 아니라, &quot;무엇을 묻는 질문들의 집합인지&quot;를 알고 써야 한다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 점검은 정형화해야 지속된다.</strong>
&quot;틈날 때 콘솔 보기&quot;는 3일이면 무너진다. 같은 질문 4개를 매일 던지니
평소의 모습(baseline)이 몸에 익고, 이상이 눈에 띄기 시작했다.</p>
<p><strong>2. 로그 읽기의 핵심은 무시할 것을 정하는 것이다.</strong>
모든 WARN에 반응하면 진짜 신호를 놓친다. &quot;무해 목록&quot;을 명시적으로
정해두니 나머지에 집중할 수 있었다.</p>
<p><strong>3. 무해한 로그도 모이면 신호다.</strong>
개별로는 정상인 이벤트의 반복 패턴이 UX 버그를 찾아줬다.
서버 관점의 &quot;정상&quot;과 사용자 관점의 &quot;정상&quot;은 다르다.</p>
<p><strong>4. 점검 루틴의 한계를 아는 것도 점검의 일부다.</strong>
초록불 4개가 &quot;전부 정상&quot;이 아니라 &quot;이 4가지 질문에 한해 정상&quot;이라는 걸
잊으면 안 된다.</p>
<hr>
<p>다음 편이 구축 일지의 마지막이다. 서비스 오픈 직전에 했던 점검들 —
Swagger 차단, DB 리셋, 그리고 런칭 체크리스트.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프라 구축 일지 (6) — 오픈하니까 진짜로 공격이 왔다: WAF 실전 기록]]></title>
            <link>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-6-%EC%98%A4%ED%94%88%ED%95%98%EB%8B%88%EA%B9%8C-%EC%A7%84%EC%A7%9C%EB%A1%9C-%EA%B3%B5%EA%B2%A9%EC%9D%B4-%EC%99%94%EB%8B%A4-WAF-%EC%8B%A4%EC%A0%84-%EA%B8%B0%EB%A1%9D-0zkvkgth</link>
            <guid>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-6-%EC%98%A4%ED%94%88%ED%95%98%EB%8B%88%EA%B9%8C-%EC%A7%84%EC%A7%9C%EB%A1%9C-%EA%B3%B5%EA%B2%A9%EC%9D%B4-%EC%99%94%EB%8B%A4-WAF-%EC%8B%A4%EC%A0%84-%EA%B8%B0%EB%A1%9D-0zkvkgth</guid>
            <pubDate>Fri, 04 Sep 2026 07:38:43 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>5편까지는 &quot;만든&quot; 이야기였다. 이번엔 서비스를 오픈하고 나서
실제로 들어온 공격과, 그걸 막은 기록이다.</p>
</blockquote>
<h2 id="오픈하면-스캐너부터-온다">오픈하면 스캐너부터 온다</h2>
<p>서비스를 인터넷에 열면 사람보다 봇이 먼저 온다는 말이 있는데, 진짜였다.
오픈 직후부터 ALB 액세스 로그에 이런 요청들이 찍히기 시작했다.</p>
<pre><code>GET /.env
GET /wp-login.php
GET /phpinfo.php
GET /config.json
...</code></pre><p>우리는 Spring Boot 서비스다. 워드프레스도 아니고 PHP도 없다.
이건 특정 대상을 노린 게 아니라, 인터넷 전체를 훑으며
&quot;아무 데나 걸려라&quot; 식으로 알려진 취약점을 자동으로 찔러보는 <strong>스캐너</strong>다.</p>
<p>발신지를 추려보니 반복 소스가 둘로 좁혀졌다.</p>
<ul>
<li>싱가포르의 VPS 한 대 (단일 IP)</li>
<li>프랑스 호스팅 업체의 <strong>대역 하나</strong> (여러 IP를 바꿔가며 시도)</li>
</ul>
<h2 id="미리-깔아둔-그물-waf-규칙-구성">미리 깔아둔 그물: WAF 규칙 구성</h2>
<p>다행히 WAF는 오픈 전에 ALB 앞에 걸어둔 상태였다. 규칙은 우선순위 순으로 이렇다.</p>
<table>
<thead>
<tr>
<th align="center">우선순위</th>
<th>규칙</th>
<th align="center">동작</th>
</tr>
</thead>
<tbody><tr>
<td align="center">0</td>
<td>Rate limit — IP당 5분에 수천 건 초과</td>
<td align="center"><strong>BLOCK</strong></td>
</tr>
<tr>
<td align="center">1</td>
<td>AWS IP 평판 리스트 (알려진 악성 IP)</td>
<td align="center"><strong>BLOCK</strong></td>
</tr>
<tr>
<td align="center">2</td>
<td>Common Rule Set (XSS 등 일반 공격 패턴)</td>
<td align="center">COUNT</td>
</tr>
<tr>
<td align="center">3</td>
<td>Known Bad Inputs (Log4Shell 류)</td>
<td align="center">COUNT</td>
</tr>
<tr>
<td align="center">4</td>
<td>SQLi 탐지</td>
<td align="center">COUNT</td>
</tr>
</tbody></table>
<p>BLOCK과 COUNT가 섞여 있는 게 포인트다. 기준은 <strong>오탐 위험</strong>이다.</p>
<ul>
<li>IP 기반 규칙(rate limit, 평판)은 오탐이 거의 없다 → 즉시 BLOCK</li>
<li>패턴 탐지형(SQLi, XSS)은 정상 요청을 오인할 수 있다 → 일단 COUNT로 관측만</li>
</ul>
<p>COUNT 규칙은 지표만 쌓고 요청은 통과시킨다. 오탐이 없는 걸 확인한 규칙부터
순차적으로 BLOCK 승격하는 전략이다. 처음부터 다 막았다가 정상 사용자가 차단되면 그게 더 큰 사고다.
(위 표는 <strong>도입 초기 구성</strong>이다. 승격은 관측 결과에 따라 계속 진행되므로,
글을 읽는 시점의 구성과는 다르다.)</p>
<p>&quot;그럼 SQLi가 COUNT 동안은 뚫리는 거 아냐?&quot;라는 질문엔 — 아니다.
<strong>SQLi의 1차 방어는 WAF가 아니라 앱 코드다.</strong> JPA 파라미터 바인딩이라
쿼리와 데이터가 구조적으로 분리돼 있다. WAF는 그 위에 얹는 2차 그물이다.
심층 방어에서 각 겹의 역할을 알고 있으면 COUNT 기간이 무섭지 않다.</p>
<h2 id="반복-스캐너는-수동-차단">반복 스캐너는 수동 차단</h2>
<p>스캐너 요청들은 어차피 존재하지 않는 경로라 404로 끝나긴 한다.
하지만 매일 로그에 소음을 만들고, 언젠가 진짜 취약점이 생겼을 때 걸릴 수도 있다.
반복 소스가 명확했기 때문에 <strong>IP set 규칙(BLOCK)</strong>을 추가해 영구 차단했다.</p>
<p>여기서 프랑스 쪽은 단일 IP가 아니라 <code>/24</code> 대역 전체를 막았다.</p>
<pre><code>x.x.x.0/24  =  x.x.x.0 ~ x.x.x.255, 256개 IP

/24의 뜻: IP 32비트 중 앞 24비트가 네트워크 주소
→ 나머지 8비트가 호스트 = 2^8 = 256개</code></pre><p>왜 대역으로 막았나? 로그를 보니 <strong>같은 대역 안에서 IP를 바꿔가며</strong> 들어오고 있었다.
한 IP를 막으면 옆 IP로 오는 패턴. 이럴 땐 개별 IP가 아니라
그 호스팅 대역 자체를 막는 게 맞다.</p>
<h2 id="차단-후-효과가-있긴-한-걸까">차단 후: 효과가 있긴 한 걸까?</h2>
<p>차단하고 며칠 뒤 WAF 지표를 확인했는데, 재밌는 상황이 나왔다.</p>
<p><strong>차단 규칙의 BlockedRequests가 0이었다.</strong></p>
<p>처음엔 &quot;규칙이 안 먹나?&quot; 싶었다. 그런데 이 지표의 의미를 뜯어보면 반대다.
BlockedRequests는 규칙이 요청을 막을 때마다 올라가는 카운터다.
이게 0이라는 건 = <strong>그 IP들에서 요청 자체가 한 건도 안 왔다</strong>는 뜻이다.</p>
<p>솔직하게 결론 내리면: 차단이 공격을 &quot;막아내는 중&quot;인 게 아니라,
<strong>공격자가 떠났다.</strong> (차단 전에도 계속 404를 받으면서도 오던 애들이라, 시점이 겹친 건 우연에 가깝다.)
그럼 규칙이 무의미하냐면 아니고 — 같은 소스가 다시 오면 이번엔 로그에 닿기도 전에
차단되는 <strong>안전망</strong>으로 남는다. 효과 평가는 이렇게 지표의 의미까지 따져서 해야
&quot;막았다&quot;는 착각을 안 하게 된다.</p>
<h2 id="진짜-물량전-어느-밤의-플러딩">진짜 물량전: 어느 밤의 플러딩</h2>
<p>스캐너와 별개로, 어느 날 밤 자정 무렵 45분간 물량 공격이 들어왔다.</p>
<pre><code>평상시 트래픽:    5분당 수십 건
공격 피크:       5분당 3,609건 차단 (평시의 수백 배)
공격 총량:       rate limit 차단 약 27,800건 + IP 평판 차단 약 6,000건</code></pre><p>이때 일한 게 우선순위 0번(rate limit)과 1번(IP 평판)이다.
그리고 결과 지표가 이 구조의 존재 이유를 증명했다.</p>
<pre><code>같은 시간대 ALB → 앱으로 넘어간 요청:  5분당 1~34건 (평소 수준)
앱 5xx 에러:                        0건</code></pre><p>3만 건이 넘는 공격이 왔는데 <strong>앱은 공격이 있었는지도 모른다.</strong>
전부 ALB 앞 WAF 층에서 흡수됐다. 다음 날 아침 지표를 보고 나서야 공격이 있었음을 알았다.
&quot;장애 대응&quot;이 아니라 &quot;사후 확인&quot;으로 끝난 것 — 방어가 잘 동작하면 이렇게 심심하다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 오픈 = 공격 개시다. 방어는 오픈 전에 깔려 있어야 한다.</strong>
스캐너는 서비스 인지도와 무관하게 온다. IP를 가진 모든 서버가 대상이다.</p>
<p><strong>2. BLOCK/COUNT를 가르는 기준은 오탐 위험이다.</strong>
탐지 정확도가 높은 규칙만 즉시 차단, 나머지는 관측 후 승격.
보안 규칙도 배포처럼 점진적으로 켜는 거였다.</p>
<p><strong>3. 지표는 &quot;숫자&quot;가 아니라 &quot;의미&quot;로 읽어야 한다.</strong>
차단 카운트 0 = 규칙 고장이 아니라 유입 자체가 없음.
효과를 과대평가하지 않는 해석이 다음 판단을 정확하게 만든다.</p>
<p><strong>4. 계층 방어의 성공은 &quot;안쪽 계층이 심심한 것&quot;으로 측정된다.</strong>
3만 건 공격에 앱 계층 변화 0. 이게 WAF를 앞단에 두는 이유의 전부다.</p>
<hr>
<p>다음 편은 운영 루틴이다. 매일 아침 서버 상태를 확인하는
4종 점검 루틴을 어떻게 정형화했는지.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프라 구축 일지 (6) — 오픈하니까 진짜로 공격이 왔다: WAF 실전 기록]]></title>
            <link>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-6-%EC%98%A4%ED%94%88%ED%95%98%EB%8B%88%EA%B9%8C-%EC%A7%84%EC%A7%9C%EB%A1%9C-%EA%B3%B5%EA%B2%A9%EC%9D%B4-%EC%99%94%EB%8B%A4-WAF-%EC%8B%A4%EC%A0%84-%EA%B8%B0%EB%A1%9D-cbzte506</link>
            <guid>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-6-%EC%98%A4%ED%94%88%ED%95%98%EB%8B%88%EA%B9%8C-%EC%A7%84%EC%A7%9C%EB%A1%9C-%EA%B3%B5%EA%B2%A9%EC%9D%B4-%EC%99%94%EB%8B%A4-WAF-%EC%8B%A4%EC%A0%84-%EA%B8%B0%EB%A1%9D-cbzte506</guid>
            <pubDate>Fri, 04 Sep 2026 07:38:20 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>5편까지는 &quot;만든&quot; 이야기였다. 이번엔 서비스를 오픈하고 나서
실제로 들어온 공격과, 그걸 막은 기록이다.</p>
</blockquote>
<h2 id="오픈하면-스캐너부터-온다">오픈하면 스캐너부터 온다</h2>
<p>서비스를 인터넷에 열면 사람보다 봇이 먼저 온다는 말이 있는데, 진짜였다.
오픈 직후부터 ALB 액세스 로그에 이런 요청들이 찍히기 시작했다.</p>
<pre><code>GET /.env
GET /wp-login.php
GET /phpinfo.php
GET /config.json
...</code></pre><p>우리는 Spring Boot 서비스다. 워드프레스도 아니고 PHP도 없다.
이건 특정 대상을 노린 게 아니라, 인터넷 전체를 훑으며
&quot;아무 데나 걸려라&quot; 식으로 알려진 취약점을 자동으로 찔러보는 <strong>스캐너</strong>다.</p>
<p>발신지를 추려보니 반복 소스가 둘로 좁혀졌다.</p>
<ul>
<li>싱가포르의 VPS 한 대 (단일 IP)</li>
<li>프랑스 호스팅 업체의 <strong>대역 하나</strong> (여러 IP를 바꿔가며 시도)</li>
</ul>
<h2 id="미리-깔아둔-그물-waf-규칙-구성">미리 깔아둔 그물: WAF 규칙 구성</h2>
<p>다행히 WAF는 오픈 전에 ALB 앞에 걸어둔 상태였다. 규칙은 우선순위 순으로 이렇다.</p>
<table>
<thead>
<tr>
<th align="center">우선순위</th>
<th>규칙</th>
<th align="center">동작</th>
</tr>
</thead>
<tbody><tr>
<td align="center">0</td>
<td>Rate limit — IP당 5분에 수천 건 초과</td>
<td align="center"><strong>BLOCK</strong></td>
</tr>
<tr>
<td align="center">1</td>
<td>AWS IP 평판 리스트 (알려진 악성 IP)</td>
<td align="center"><strong>BLOCK</strong></td>
</tr>
<tr>
<td align="center">2</td>
<td>Common Rule Set (XSS 등 일반 공격 패턴)</td>
<td align="center">COUNT</td>
</tr>
<tr>
<td align="center">3</td>
<td>Known Bad Inputs (Log4Shell 류)</td>
<td align="center">COUNT</td>
</tr>
<tr>
<td align="center">4</td>
<td>SQLi 탐지</td>
<td align="center">COUNT</td>
</tr>
</tbody></table>
<p>BLOCK과 COUNT가 섞여 있는 게 포인트다. 기준은 <strong>오탐 위험</strong>이다.</p>
<ul>
<li>IP 기반 규칙(rate limit, 평판)은 오탐이 거의 없다 → 즉시 BLOCK</li>
<li>패턴 탐지형(SQLi, XSS)은 정상 요청을 오인할 수 있다 → 일단 COUNT로 관측만</li>
</ul>
<p>COUNT 규칙은 지표만 쌓고 요청은 통과시킨다. 오탐이 없는 걸 확인한 규칙부터
순차적으로 BLOCK 승격하는 전략이다. 처음부터 다 막았다가 정상 사용자가 차단되면 그게 더 큰 사고다.
(위 표는 <strong>도입 초기 구성</strong>이다. 승격은 관측 결과에 따라 계속 진행되므로,
글을 읽는 시점의 구성과는 다르다.)</p>
<p>&quot;그럼 SQLi가 COUNT 동안은 뚫리는 거 아냐?&quot;라는 질문엔 — 아니다.
<strong>SQLi의 1차 방어는 WAF가 아니라 앱 코드다.</strong> JPA 파라미터 바인딩이라
쿼리와 데이터가 구조적으로 분리돼 있다. WAF는 그 위에 얹는 2차 그물이다.
심층 방어에서 각 겹의 역할을 알고 있으면 COUNT 기간이 무섭지 않다.</p>
<h2 id="반복-스캐너는-수동-차단">반복 스캐너는 수동 차단</h2>
<p>스캐너 요청들은 어차피 존재하지 않는 경로라 404로 끝나긴 한다.
하지만 매일 로그에 소음을 만들고, 언젠가 진짜 취약점이 생겼을 때 걸릴 수도 있다.
반복 소스가 명확했기 때문에 <strong>IP set 규칙(BLOCK)</strong>을 추가해 영구 차단했다.</p>
<p>여기서 프랑스 쪽은 단일 IP가 아니라 <code>/24</code> 대역 전체를 막았다.</p>
<pre><code>x.x.x.0/24  =  x.x.x.0 ~ x.x.x.255, 256개 IP

/24의 뜻: IP 32비트 중 앞 24비트가 네트워크 주소
→ 나머지 8비트가 호스트 = 2^8 = 256개</code></pre><p>왜 대역으로 막았나? 로그를 보니 <strong>같은 대역 안에서 IP를 바꿔가며</strong> 들어오고 있었다.
한 IP를 막으면 옆 IP로 오는 패턴. 이럴 땐 개별 IP가 아니라
그 호스팅 대역 자체를 막는 게 맞다.</p>
<h2 id="차단-후-효과가-있긴-한-걸까">차단 후: 효과가 있긴 한 걸까?</h2>
<p>차단하고 며칠 뒤 WAF 지표를 확인했는데, 재밌는 상황이 나왔다.</p>
<p><strong>차단 규칙의 BlockedRequests가 0이었다.</strong></p>
<p>처음엔 &quot;규칙이 안 먹나?&quot; 싶었다. 그런데 이 지표의 의미를 뜯어보면 반대다.
BlockedRequests는 규칙이 요청을 막을 때마다 올라가는 카운터다.
이게 0이라는 건 = <strong>그 IP들에서 요청 자체가 한 건도 안 왔다</strong>는 뜻이다.</p>
<p>솔직하게 결론 내리면: 차단이 공격을 &quot;막아내는 중&quot;인 게 아니라,
<strong>공격자가 떠났다.</strong> (차단 전에도 계속 404를 받으면서도 오던 애들이라, 시점이 겹친 건 우연에 가깝다.)
그럼 규칙이 무의미하냐면 아니고 — 같은 소스가 다시 오면 이번엔 로그에 닿기도 전에
차단되는 <strong>안전망</strong>으로 남는다. 효과 평가는 이렇게 지표의 의미까지 따져서 해야
&quot;막았다&quot;는 착각을 안 하게 된다.</p>
<h2 id="진짜-물량전-어느-밤의-플러딩">진짜 물량전: 어느 밤의 플러딩</h2>
<p>스캐너와 별개로, 어느 날 밤 자정 무렵 45분간 물량 공격이 들어왔다.</p>
<pre><code>평상시 트래픽:    5분당 수십 건
공격 피크:       5분당 3,609건 차단 (평시의 수백 배)
공격 총량:       rate limit 차단 약 27,800건 + IP 평판 차단 약 6,000건</code></pre><p>이때 일한 게 우선순위 0번(rate limit)과 1번(IP 평판)이다.
그리고 결과 지표가 이 구조의 존재 이유를 증명했다.</p>
<pre><code>같은 시간대 ALB → 앱으로 넘어간 요청:  5분당 1~34건 (평소 수준)
앱 5xx 에러:                        0건</code></pre><p>3만 건이 넘는 공격이 왔는데 <strong>앱은 공격이 있었는지도 모른다.</strong>
전부 ALB 앞 WAF 층에서 흡수됐다. 다음 날 아침 지표를 보고 나서야 공격이 있었음을 알았다.
&quot;장애 대응&quot;이 아니라 &quot;사후 확인&quot;으로 끝난 것 — 방어가 잘 동작하면 이렇게 심심하다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 오픈 = 공격 개시다. 방어는 오픈 전에 깔려 있어야 한다.</strong>
스캐너는 서비스 인지도와 무관하게 온다. IP를 가진 모든 서버가 대상이다.</p>
<p><strong>2. BLOCK/COUNT를 가르는 기준은 오탐 위험이다.</strong>
탐지 정확도가 높은 규칙만 즉시 차단, 나머지는 관측 후 승격.
보안 규칙도 배포처럼 점진적으로 켜는 거였다.</p>
<p><strong>3. 지표는 &quot;숫자&quot;가 아니라 &quot;의미&quot;로 읽어야 한다.</strong>
차단 카운트 0 = 규칙 고장이 아니라 유입 자체가 없음.
효과를 과대평가하지 않는 해석이 다음 판단을 정확하게 만든다.</p>
<p><strong>4. 계층 방어의 성공은 &quot;안쪽 계층이 심심한 것&quot;으로 측정된다.</strong>
3만 건 공격에 앱 계층 변화 0. 이게 WAF를 앞단에 두는 이유의 전부다.</p>
<hr>
<p>다음 편은 운영 루틴이다. 매일 아침 서버 상태를 확인하는
4종 점검 루틴을 어떻게 정형화했는지.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프라 구축 일지 (5) — 오토스케일링, 설정만 하면 끝일까?]]></title>
            <link>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-5-%EC%98%A4%ED%86%A0%EC%8A%A4%EC%BC%80%EC%9D%BC%EB%A7%81-%EC%84%A4%EC%A0%95%EB%A7%8C-%ED%95%98%EB%A9%B4-%EB%81%9D%EC%9D%BC%EA%B9%8C</link>
            <guid>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-5-%EC%98%A4%ED%86%A0%EC%8A%A4%EC%BC%80%EC%9D%BC%EB%A7%81-%EC%84%A4%EC%A0%95%EB%A7%8C-%ED%95%98%EB%A9%B4-%EB%81%9D%EC%9D%BC%EA%B9%8C</guid>
            <pubDate>Fri, 04 Sep 2026 07:36:32 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>3·4편에서 비밀값과 배포 인증을 정리했다. 이번엔 트래픽이 몰리면 알아서 늘어나는 구조,
그리고 그게 진짜 동작하는지 부하를 걸어 실측한 이야기다.</p>
</blockquote>
<h2 id="설정-자체는-금방-끝난다">설정 자체는 금방 끝난다</h2>
<p>ECS 오토스케일링은 Target Tracking 방식으로 걸었다. terraform으로 몇 줄이면 된다.</p>
<pre><code>대상:   ECS 서비스의 태스크 수 (min 2 ~ max 4)
지표:   서비스 평균 CPU 사용률
목표:   70% 유지</code></pre><p>CPU가 70%를 넘게 유지되면 태스크를 늘리고, 내려가면 줄인다.
CloudWatch 알람 생성·관리는 AWS가 자동으로 해준다. 설정만 보면 10분 컷.</p>
<p>그런데 이 숫자들 하나하나에 이유가 있어야 했다. 그냥 넣은 값은 없다.</p>
<h2 id="숫자마다-이유-붙이기">숫자마다 이유 붙이기</h2>
<p><strong>왜 min 2인가</strong> — 가용성 하한. AZ 2개에 하나씩은 있어야 한 쪽 데이터센터가 죽어도 산다.</p>
<p><strong>왜 max 4인가</strong> — 이게 제일 중요했다. 상한은 &quot;돈 아끼려고&quot; 정하는 게 아니라
<strong>DB 커넥션 한계</strong>로 정해진다.</p>
<pre><code>태스크당 HikariCP 커넥션 풀 = 10
최대 4대 × 10 = 40 커넥션  ≪  db.t3.micro 한계 ≈ 85</code></pre><p>max를 무턱대고 10으로 올리면? 10 × 10 = 100 커넥션. DB 한계를 넘어서
<strong>스케일아웃이 오히려 DB를 죽이는</strong> 역설이 생긴다.
앱을 늘릴 땐 뒤에 있는 DB가 받아줄 수 있는지부터 계산해야 한다.</p>
<p><strong>왜 CPU만 추적하고 메모리는 안 하나</strong> — JVM 때문이다.
JVM은 한번 할당받은 힙을 OS에 잘 반환하지 않아서, 메모리 사용률이 항상 높게 유지된다.
메모리 기반 스케일링을 걸면 부하가 없는데도 증설되는 오탐이 난다. JVM 앱은 CPU가 정직하다.</p>
<p><strong>쿨다운 두 개의 비대칭</strong> —</p>
<pre><code>scale_out_cooldown = 60초    (증설은 빠르게)
scale_in_cooldown  = 900초   (감축은 신중하게)</code></pre><p>증설 쿨다운 60초엔 숨은 이유가 있다. 새로 뜬 태스크는 JVM 기동 때문에
CPU가 일시적으로 100%를 친다. 이걸 &quot;부하&quot;로 오인해 연쇄 증설되는 걸 막는 최소한의 대기다.
감축 15분은 트래픽 출렁임에 태스크가 들락날락하는 걸 막는다.
<strong>늘릴 땐 민감하게, 줄일 땐 둔감하게.</strong></p>
<h2 id="늘어나도-괜찮은가-무상태-점검">늘어나도 괜찮은가: 무상태 점검</h2>
<p>태스크가 2대에서 4대가 되는 순간, &quot;여러 대라서 생기는 문제&quot;가 없는지 먼저 점검했다.</p>
<table>
<thead>
<tr>
<th>걱정</th>
<th>결론</th>
</tr>
</thead>
<tbody><tr>
<td>스케줄러가 태스크마다 중복 실행?</td>
<td>ShedLock(Redis 분산락)으로 한 대만 실행</td>
</tr>
<tr>
<td>세션이 특정 서버에 붙어 있나?</td>
<td>JWT 무상태라 어느 태스크가 받아도 동일</td>
</tr>
<tr>
<td>DB 커넥션 폭증?</td>
<td>위의 4 × 10 = 40 계산으로 한계 내 확인</td>
</tr>
</tbody></table>
<p>이게 되니까 &quot;태스크 수 조절&quot;이 안전한 확장 수단이 된다.
반대로 말하면, 앱이 무상태가 아니면 오토스케일링 설정은 시한폭탄이다.</p>
<h2 id="설정했다와-동작한다는-다르다">&quot;설정했다&quot;와 &quot;동작한다&quot;는 다르다</h2>
<p>여기서 멈출 수도 있었다. terraform apply 됐고, 콘솔에 정책 보이고. 끝?</p>
<p>찜찜했다. <strong>알람이 진짜 발화하는지, 태스크가 진짜 늘어나는지 본 적이 없잖아.</strong>
장애 상황에서 처음 검증하고 싶지는 않았다. 그래서 부하 테스트를 했다.</p>
<p>도구는 autocannon. 가장 무거운 API(검색 — CPU 바운드)를 골라 때렸다.</p>
<p><strong>1차: 한계 찾기.</strong> 동시성을 올려가며 처리량이 꺾이는 지점을 찾았다.</p>
<pre><code>2대 기준 포화점 ≈ 460 req/s</code></pre><p>포화점 근처에서 병목을 확인해보니 CPU보다 먼저 <strong>태스크당 DB 커넥션 풀(10개)</strong>에서
대기가 걸렸다. 즉 이 병목은 태스크를 늘리면(=풀이 늘어나면) 풀리는 구조다.
스케일아웃이 유효한 처방이라는 게 확인됐다.</p>
<p><strong>2차: 증설 실증.</strong> CPU를 90% 이상으로 3분간 몰아붙였다.</p>
<pre><code>① CloudWatch 알람(AlarmHigh) 발화
② Application Auto Scaling이 desired 2 → 3 변경
③ 새 태스크 기동 → 헬스체크 통과 → ALB 타겟 등록
④ 서비스 평균 CPU 하락</code></pre><p>부하 감지부터 새 태스크가 트래픽을 받기까지 <strong>약 5분.</strong>
이벤트 로그로 전 과정을 눈으로 확인했다. 이제 &quot;오토스케일링 됩니다&quot;를
설정 화면이 아니라 실측 로그로 말할 수 있다.</p>
<h2 id="알람과-스케일링의-역할-분담">알람과 스케일링의 역할 분담</h2>
<p>스케일링용 임계값(70%)과 별개로 사람 호출용 알람은 80%에 걸어놨다.</p>
<pre><code>CPU 70% → 기계가 대응 (자동 증설)
CPU 80% → 사람을 부름 (증설로도 못 버티는 상황 = max 도달 등)</code></pre><p>80% 알람이 울린다는 건 &quot;스케일링이 일하는 중&quot;이 아니라
&quot;스케일링으로도 안 되는 중&quot;이라는 뜻이 되도록 설계했다.
알람은 사람이 행동해야 할 때만 울려야 한다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 오토스케일링의 진짜 설계는 max 값에 있다.</strong>
min은 가용성, max는 뒷단(DB)의 수용량 계산. 상한 없는 확장은 병목을 뒤로 전가할 뿐이다.</p>
<p><strong>2. &quot;설정했다&quot;는 검증이 아니다.</strong>
부하 테스트 전까지 오토스케일링은 가설이었다. 포화점(460 req/s)과
증설 실증(2→3)을 얻고 나서야 용량 계획을 숫자로 말할 수 있게 됐다.</p>
<p><strong>3. 병목의 위치가 처방을 결정한다.</strong>
병목이 커넥션 풀(태스크당 자원)이라 스케일아웃이 답이 됐다.
병목이 DB 자체였다면 태스크를 아무리 늘려도 소용없었을 거다.</p>
<hr>
<p>다음 편은 보안 실전편이다. 오픈하고 나니 진짜로 공격이 들어왔다 —
취약점 스캐너와 트래픽 플러딩을 WAF로 막은 기록.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프라 구축 일지 (4) — 배포 키를 없앴다: GitHub Actions OIDC 전환기]]></title>
            <link>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-4-%EB%B0%B0%ED%8F%AC-%ED%82%A4%EB%A5%BC-%EC%97%86%EC%95%B4%EB%8B%A4-GitHub-Actions-OIDC-%EC%A0%84%ED%99%98%EA%B8%B0</link>
            <guid>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-4-%EB%B0%B0%ED%8F%AC-%ED%82%A4%EB%A5%BC-%EC%97%86%EC%95%B4%EB%8B%A4-GitHub-Actions-OIDC-%EC%A0%84%ED%99%98%EA%B8%B0</guid>
            <pubDate>Thu, 03 Sep 2026 02:24:43 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>3편에서 &quot;비밀은 한 군데에만 산다&quot;를 만들었다. 그런데 정리하고 보니
아직 한 군데가 남아 있었다. GitHub Secrets에 들어 있는 배포용 AWS 액세스 키.</p>
</blockquote>
<h2 id="남아-있던-마지막-장기-키">남아 있던 마지막 장기 키</h2>
<p>우리 배포는 GitHub Actions가 한다. develop에 push하면 스테이징, main이면 운영.
그러려면 Actions가 AWS에 접근해야 하고, 처음엔 정석대로(?) 했다.</p>
<ol>
<li>배포용 IAM 사용자 생성</li>
<li>액세스 키 발급</li>
<li>GitHub Secrets에 <code>AWS_ACCESS_KEY_ID</code> / <code>AWS_SECRET_ACCESS_KEY</code> 등록</li>
</ol>
<p>동작엔 문제가 없다. 문제는 이 키의 성질이다.</p>
<ul>
<li><strong>만료가 없다.</strong> 한 번 유출되면 폐기 전까지 영원히 유효하다.</li>
<li><strong>어디서 쓰든 통한다.</strong> GitHub에서 쓰라고 만든 키지만, 훔친 사람의 노트북에서도 똑같이 동작한다.</li>
<li><strong>정기 로테이션은 사람이 해야 한다.</strong> 그리고 사람은 까먹는다.</li>
</ul>
<p>3편에서 내 로컬 키를 aws-vault로 치웠는데, CI에는 장기 키가 그대로 있는 셈이었다.
이걸 없애는 방법이 OIDC다.</p>
<h2 id="oidc-방식-키-대신-신원-증명">OIDC 방식: 키 대신 신원 증명</h2>
<p>발상의 전환은 이거다. <strong>키를 잘 보관하는 게 아니라, 키 자체를 안 만든다.</strong></p>
<pre><code>[기존] GitHub Secrets에 영구 키 저장 → 워크플로가 키로 인증

[OIDC] 워크플로 실행 → GitHub이 &quot;이 워크플로는 이 repo의 이 브랜치다&quot;라고
       서명한 토큰 발급 → AWS가 서명 진위 확인 → 조건 맞으면
       1시간짜리 임시 자격증명 발급 → 배포 → 자동 소멸</code></pre><p>GitHub Secrets에 저장되는 영구 키: <strong>0개.</strong></p>
<p>인증의 근거가 &quot;키를 알고 있다&quot;에서 <strong>&quot;GitHub이 서명으로 보증하는 신원&quot;</strong>으로 바뀐다.
훔칠 키가 없고, 임시 자격증명은 1시간이면 죽는다.</p>
<h2 id="핵심은-신뢰-정책의-sub-조건">핵심은 신뢰 정책의 sub 조건</h2>
<p>AWS 쪽엔 배포용 IAM Role을 하나 만들고, 신뢰 정책에 이렇게 건다.</p>
<pre><code class="language-json">{
  &quot;Effect&quot;: &quot;Allow&quot;,
  &quot;Principal&quot;: { &quot;Federated&quot;: &quot;arn:aws:iam::&lt;계정&gt;:oidc-provider/token.actions.githubusercontent.com&quot; },
  &quot;Action&quot;: &quot;sts:AssumeRoleWithWebIdentity&quot;,
  &quot;Condition&quot;: {
    &quot;StringEquals&quot;: { &quot;token.actions.githubusercontent.com:aud&quot;: &quot;sts.amazonaws.com&quot; },
    &quot;StringLike&quot;: {
      &quot;token.actions.githubusercontent.com:sub&quot;: [
        &quot;repo:&lt;org&gt;/&lt;repo&gt;:ref:refs/heads/main&quot;,
        &quot;repo:&lt;org&gt;/&lt;repo&gt;:ref:refs/heads/develop&quot;
      ]
    }
  }
}</code></pre>
<p>이 <code>sub</code> 조건이 OIDC 보안의 전부라고 해도 된다.</p>
<ul>
<li>우리 repo의 <strong>main·develop 브랜치에서 실행된 워크플로만</strong> 이 Role을 쓸 수 있다</li>
<li>다른 repo? 차단. 포크? 차단. PR 워크플로? 차단. feature 브랜치? 차단.</li>
</ul>
<p>키 방식에선 &quot;키를 가진 자 = 아무나&quot;였는데,
OIDC에선 &quot;허용된 repo의 허용된 브랜치&quot;로 주체가 좁혀진다.</p>
<h2 id="권한도-최소로">권한도 최소로</h2>
<p>Role에 붙인 권한은 워크플로가 실제로 호출하는 액션만 골랐다.</p>
<table>
<thead>
<tr>
<th>권한 덩어리</th>
<th>내용</th>
<th>스코프</th>
</tr>
</thead>
<tbody><tr>
<td>EcrAuth</td>
<td>ECR 로그인 토큰</td>
<td>(리소스 지정 불가라 *)</td>
</tr>
<tr>
<td>EcrPush</td>
<td>이미지 push</td>
<td>백엔드 리포지토리만</td>
</tr>
<tr>
<td>EcsDeploy</td>
<td><code>force-new-deployment</code></td>
<td>UpdateService/DescribeServices만</td>
</tr>
<tr>
<td>Amplify</td>
<td>프런트 빌드 트리거</td>
<td>지정한 앱 2개의 job만</td>
</tr>
</tbody></table>
<p>배포 Role이 탈취돼도 할 수 있는 건 &quot;이미지 올리고 재배포&quot; 정도다.
인프라를 바꾸거나 시크릿을 읽는 권한은 없다.</p>
<p>백엔드(ECR/ECS)와 프런트(Amplify) 워크플로 4개가 이 Role <strong>하나</strong>를 같이 쓴다.
sub 조건이 브랜치 기준이라 4개를 전부 커버하고, Role이 하나면 감사도 한 곳만 보면 된다.</p>
<h2 id="워크플로-쪽-변경은-의외로-작다">워크플로 쪽 변경은 의외로 작다</h2>
<pre><code class="language-yaml">permissions:
  id-token: write   # OIDC 토큰 발급 허용 — 이거 빼먹으면 안 됨
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::&lt;계정&gt;:role/&lt;배포롤&gt;
      aws-region: ap-northeast-2</code></pre>
<p><code>aws-access-key-id</code>/<code>aws-secret-access-key</code> 줄이 사라지고 <code>role-to-assume</code> 한 줄로 바뀐다.
<code>permissions.id-token: write</code>가 OIDC 토큰을 요청할 수 있게 하는 스위치다.</p>
<h2 id="전환은-한-번에-하지-않았다">전환은 한 번에 하지 않았다</h2>
<p>기존 키를 바로 지우고 싶은 유혹이 있었지만, 순서를 지켰다.</p>
<ol>
<li>OIDC Role 생성 + 스테이징 워크플로부터 전환</li>
<li>스테이징 배포가 OIDC로 정상 동작하는 것 확인</li>
<li>운영 워크플로 전환 → 운영 배포 정상 확인</li>
<li><strong>그 다음에야</strong> 기존 액세스 키 폐기</li>
</ol>
<p>새 인증이 초록불인 걸 확인하기 전에 옛 인증을 지우면,
배포가 막힌 상태에서 되돌릴 수단도 없는 최악의 상황이 나온다.
탈출구는 마지막에 닫는다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 최고의 키 관리는 키를 안 만드는 것이다.</strong>
로테이션 자동화, 유출 탐지, 금고 암호화... 전부 &quot;키가 존재한다&quot;는 전제의 비용이다.
OIDC는 그 전제를 지운다.</p>
<p><strong>2. 신원 기반 인증은 조건을 정밀하게 걸 수 있다.</strong>
키는 all-or-nothing인데, OIDC sub 조건은 repo·브랜치 단위로 좁힌다.
1편의 SG 체이닝(&quot;IP가 아니라 신분&quot;)과 같은 철학이 인증에도 적용된 셈이다.</p>
<p><strong>3. 마이그레이션은 병행 기간을 두고, 옛길은 새 길 검증 후에 닫는다.</strong></p>
<hr>
<p>다음 편은 오토스케일링이다. &quot;설정했다&quot;로 끝내지 않고
부하 테스트로 실제 증설이 일어나는 것까지 실측한 이야기.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프라 구축 일지 (3) — AWS 키를 평문으로 두지 않기로 했다]]></title>
            <link>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-3-AWS-%ED%82%A4%EB%A5%BC-%ED%8F%89%EB%AC%B8%EC%9C%BC%EB%A1%9C-%EB%91%90%EC%A7%80-%EC%95%8A%EA%B8%B0%EB%A1%9C-%ED%96%88%EB%8B%A4</link>
            <guid>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-3-AWS-%ED%82%A4%EB%A5%BC-%ED%8F%89%EB%AC%B8%EC%9C%BC%EB%A1%9C-%EB%91%90%EC%A7%80-%EC%95%8A%EA%B8%B0%EB%A1%9C-%ED%96%88%EB%8B%A4</guid>
            <pubDate>Thu, 03 Sep 2026 02:16:44 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>1편은 네트워크, 2편은 컴퓨팅이었다. 이번엔 눈에 안 보이는 것 — 비밀값 이야기다.
DB 비밀번호, JWT 키, OAuth 시크릿, 그리고 AWS 액세스 키 자체까지.</p>
</blockquote>
<h2 id="시작은-찜찜함이었다">시작은 찜찜함이었다</h2>
<p>인프라를 구축하다 보면 비밀값이 계속 늘어난다.
DB 비밀번호, JWT 서명 키, 소셜 로그인 클라이언트 시크릿, 어드민 초기 계정...</p>
<p>처음엔 다들 하듯이 <code>.env</code> 파일과 <code>~/.aws/credentials</code>에 넣고 시작했다. 동작은 한다.
그런데 어느 날 문득 세어보니, 내 노트북 디스크에 <strong>평문으로 굴러다니는 비밀이 열 개가 넘었다.</strong></p>
<ul>
<li><code>~/.aws/credentials</code> — AWS 액세스 키가 평문 텍스트로</li>
<li><code>.env</code>, <code>*.tfvars</code> — 실수로 커밋하면 그대로 유출</li>
<li><code>terraform.tfstate</code> — 여기엔 비밀값이 평문으로 박힌다 (이건 나중에 알고 식겁했다)</li>
</ul>
<p>하나씩 정리하기로 했다. 목표는 한 문장이었다. <strong>&quot;비밀은 한 군데에만 산다.&quot;</strong></p>
<h2 id="역할-분담-doppler--aws--git">역할 분담: Doppler / AWS / Git</h2>
<p>비밀값을 다루는 시스템이 셋인데, 셋이 같은 비밀을 나눠 갖는 게 아니라 <strong>역할이 다르다.</strong></p>
<table>
<thead>
<tr>
<th>시스템</th>
<th>역할</th>
<th>실제로 들고 있는 것</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Doppler</strong></td>
<td>값의 단일 원천 (무엇을)</td>
<td>평문 시크릿 값. 환경별 config(prd/stg) 분리</td>
</tr>
<tr>
<td><strong>AWS Secrets Manager</strong></td>
<td>암호화 저장 + 런타임 주입 (어디에)</td>
<td>KMS로 암호화된 시크릿. ECS는 값이 아니라 <strong>ARN만 참조</strong></td>
</tr>
<tr>
<td><strong>Git</strong></td>
<td>형상·구조 (어떻게)</td>
<td>코드와 <code>${ENV}</code> placeholder만. <strong>실제 값은 0개</strong></td>
</tr>
</tbody></table>
<p>값이 흐르는 방향은 한 방향이다.</p>
<pre><code>Doppler (평문 값의 유일한 거처)
   │  terraform apply 시 주입
   ▼
AWS Secrets Manager (KMS 암호화 저장)
   │  ECS task definition: valueFrom = secret의 ARN   ← 값이 아니라 주소
   ▼
Spring Boot 컨테이너 (기동 시점에만 읽어서 ${ENV}에 바인딩)</code></pre><p>포인트 몇 개.</p>
<p><strong>앱은 Doppler를 모른다.</strong> 컨테이너는 시작할 때 AWS에서만 값을 읽는다.
그래서 Doppler에서 값만 바꾸고 apply를 안 하면 아무 일도 안 일어난다. (한 번 당해보고 배웠다.)</p>
<p><strong>task definition엔 값이 없다.</strong> <code>valueFrom</code>에 들어가는 건 Secrets Manager ARN,
즉 &quot;값이 어디 있는지&quot;다. task definition이 노출돼도 값은 안 나온다.</p>
<p><strong>db/url 같은 건 사람이 안 적는다.</strong> RDS 엔드포인트는 인프라를 깔아야 정해지는 값이라,
Doppler가 아니라 terraform이 직접 조합해서 넣는다. &quot;사람이 아는 비밀&quot;과
&quot;인프라가 만드는 값&quot;을 구분하니 관리가 명확해졌다.</p>
<h2 id="git이-절대-못-보는-것">Git이 절대 못 보는 것</h2>
<p><code>.gitignore</code>로 원천 차단한 것들.</p>
<table>
<thead>
<tr>
<th>Git에서 제외 (평문 위험)</th>
<th>Git에 커밋 (값 없음)</th>
</tr>
</thead>
<tbody><tr>
<td><code>.env</code>, 로컬 export 파일</td>
<td><code>env.example</code> (키 이름만, 값은 빈칸)</td>
</tr>
<tr>
<td><code>*.tfvars</code></td>
<td><code>application.yml</code> (전부 <code>${ENV}</code> placeholder)</td>
</tr>
<tr>
<td><code>*.tfstate</code></td>
<td>terraform <code>.tf</code> 파일 (시크릿의 구조만)</td>
</tr>
</tbody></table>
<p>그리고 tfstate. <strong>terraform state 파일엔 시크릿 값이 평문으로 들어간다.</strong>
이걸 로컬에 두면 그동안의 노력이 무의미해진다.
그래서 state는 S3 backend(암호화 + 잠금)로 옮겼다. 로컬 디스크엔 state가 없다.</p>
<p>이 구조의 결론이 마음에 든다.</p>
<blockquote>
<p>리포지토리가 통째로 유출돼도, <strong>꺼낼 비밀이 애초에 없다.</strong></p>
</blockquote>
<h2 id="마지막-남은-평문-내-aws-키">마지막 남은 평문: 내 AWS 키</h2>
<p>앱 비밀은 정리됐는데, 정작 그걸 관리하는 <strong>내 AWS 액세스 키</strong>가
<code>~/.aws/credentials</code>에 평문으로 남아 있었다. 등잔 밑이 어둡다.</p>
<p>이건 <strong>aws-vault</strong>로 해결했다. 키를 OS 자격증명 저장소(Windows Credential Manager)에
암호화해서 넣고, 평문 credentials 파일은 삭제했다.</p>
<pre><code class="language-bash"># 저장 (이후 평문 파일 삭제)
aws-vault add &lt;profile&gt;

# 사용 — 임시 자격증명을 발급받아 명령 하나에만 주입
aws-vault exec &lt;profile&gt; -- aws s3 ls</code></pre>
<p>aws-vault의 좋은 점은 단순 암호화 저장이 아니라, 실행할 때마다 STS로
<strong>수명이 있는 임시 자격증명</strong>을 만들어 그 프로세스에만 넘긴다는 것.
장기 키가 환경변수로 셸에 떠다니지 않는다.</p>
<h2 id="전부-합치면-terraform-실행-한-줄">전부 합치면: terraform 실행 한 줄</h2>
<p>이 모든 게 합쳐진 결과물이 이 명령이다. 인프라를 변경할 때 항상 이렇게 실행한다.</p>
<pre><code class="language-bash">aws-vault exec &lt;profile&gt; -- \
  doppler run -p backend -c prd --name-transformer tf-var -- \
  terraform apply</code></pre>
<p>풀어보면:</p>
<ol>
<li><code>aws-vault exec</code> — 암호화 저장소에서 AWS 임시 자격증명 발급</li>
<li><code>doppler run ... --name-transformer tf-var</code> — Doppler의 시크릿을 <code>TF_VAR_*</code> 환경변수로 변환해 주입</li>
<li><code>terraform apply</code> — 그 값들로 Secrets Manager 갱신</li>
</ol>
<p>이 한 줄이 실행되는 순간에만, 이 프로세스 안에서만 비밀이 존재한다.
끝나면 사라진다. 디스크 어디에도 평문이 안 남는다.</p>
<p>환경 격리도 이 구조에 얹혀 있다. prod는 Doppler <code>prd</code> config + state key <code>prod/</code>,
스테이징은 <code>stg</code> + <code>stg/</code>. 운영과 스테이징 값이 섞일 구조적 여지가 없다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 비밀 관리의 핵심은 암호화가 아니라 &quot;사본 줄이기&quot;다.</strong>
비밀이 다섯 군데 있으면 다섯 군데를 다 지켜야 한다.
단일 원천 + 한 방향 흐름으로 만들면 지킬 곳이 하나로 준다.</p>
<p><strong>2. tfstate를 조심하라.</strong>
<code>.tfvars</code>는 다들 gitignore하는데, state에 값이 평문으로 남는 건 놓치기 쉽다.
remote backend + 암호화는 선택이 아니라 필수다.</p>
<p><strong>3. 앱 비밀보다 &quot;관리자인 나의 키&quot;가 더 위험할 수 있다.</strong>
내 AWS 키는 인프라 전체를 만질 수 있는 키다. 앱 시크릿을 금고에 넣어놓고
금고 열쇠를 책상 위에 두면 의미가 없다.</p>
<hr>
<p>다음 편은 이 흐름의 연장선이다. GitHub Actions에 저장돼 있던 배포용 AWS 키를
<strong>아예 없애버린</strong> 이야기 — OIDC 전환기.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프라 구축 일지 (2) — EC2 대신 Fargate를 고른 이유, 그리고 "백신 안 깔았어요?"]]></title>
            <link>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-2-EC2-%EB%8C%80%EC%8B%A0-Fargate%EB%A5%BC-%EA%B3%A0%EB%A5%B8-%EC%9D%B4%EC%9C%A0-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%B0%B1%EC%8B%A0-%EC%95%88-%EA%B9%94%EC%95%98%EC%96%B4%EC%9A%94</link>
            <guid>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-2-EC2-%EB%8C%80%EC%8B%A0-Fargate%EB%A5%BC-%EA%B3%A0%EB%A5%B8-%EC%9D%B4%EC%9C%A0-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%B0%B1%EC%8B%A0-%EC%95%88-%EA%B9%94%EC%95%98%EC%96%B4%EC%9A%94</guid>
            <pubDate>Thu, 03 Sep 2026 02:12:53 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>1편에서 네트워크를 3층으로 나눴다. 이번엔 그 2층(앱 층)에 뭘 올릴지 정하는 이야기다.</p>
</blockquote>
<h2 id="ec2냐-fargate냐">EC2냐 Fargate냐</h2>
<p>Spring Boot 앱을 AWS에서 돌리는 방법은 여러 가지다. 후보를 두 개로 좁혔다.</p>
<ul>
<li><strong>EC2</strong>: 가상 서버를 직접 빌려서 그 위에 도커/앱을 올린다</li>
<li><strong>ECS Fargate</strong>: 컨테이너 이미지만 주면 AWS가 알아서 굴려준다</li>
</ul>
<p>EC2가 더 익숙한 길이다. 자료도 많고, SSH로 들어가서 뭐든 만질 수 있다.
그런데 그 &quot;뭐든 만질 수 있다&quot;가 바로 문제라고 생각했다.</p>
<p><strong>EC2를 고르면 관리해야 하는 것들:</strong></p>
<ul>
<li>호스트 OS 보안 패치 (안 하면 그대로 취약점)</li>
<li>SSH 키 관리, 접속 통제</li>
<li>오토스케일링 하려면 AMI + Launch Template + ASG 직접 배선</li>
<li>서버 안에 쌓이는 상태 (로그, 임시 파일, 언젠가 누가 손대서 바뀐 설정...)</li>
</ul>
<p><strong>Fargate를 고르면:</strong></p>
<ul>
<li>관리할 호스트 OS 자체가 없다</li>
<li>SSH 접속 경로 자체가 없다</li>
<li>스케일링은 &quot;태스크 수&quot; 숫자만 조절</li>
<li>컨테이너는 배포 때마다 이미지에서 새로 뜬다</li>
</ul>
<p>혼자 백엔드와 인프라를 다 맡는 상황에서, 운영 부담이 적은 쪽이 답이었다.
Fargate로 갔다.</p>
<h2 id="불변-인프라라는-사고방식">불변 인프라라는 사고방식</h2>
<p>Fargate를 쓰면서 자연스럽게 따라온 개념이 <strong>불변(immutable) 인프라</strong>다.</p>
<p>전통적 서버 운영은 &quot;고쳐 쓰기&quot;다. 서버에 들어가서 패치하고, 설정을 바꾸고, 재시작한다.
그러다 보면 서버마다 상태가 조금씩 달라지고, &quot;그 서버에서만 되는&quot; 미스터리가 생긴다.</p>
<p>불변 인프라는 &quot;갈아 끼우기&quot;다. 컨테이너를 수정하지 않는다.
바꿀 게 있으면 새 이미지를 구워서 통째로 교체한다.</p>
<pre><code>전통 방식:  서버 접속 → 수정 → 재시작   (서버마다 상태가 갈라짐)
불변 방식:  새 이미지 빌드 → 교체       (모든 컨테이너가 항상 이미지와 동일)</code></pre><p>배포마다 깨끗한 상태에서 새로 뜨니까, 설령 뭔가 이상한 게 침투해도
다음 배포에서 사라진다. <strong>감염이 지속될 곳이 없다.</strong></p>
<h2 id="서버에-백신-안-깔았어요">&quot;서버에 백신 안 깔았어요?&quot;</h2>
<p>보안 점검 과정에서 실제로 받은 질문이다. 온프레미스 서버 기준으론 당연한 질문이다.
그런데 Fargate에선 대답이 좀 다르다. 정리하면 이렇다.</p>
<p><strong>1. 사람이 악성코드를 심을 경로가 없다.</strong>
SSH가 없다. 접속 자체가 안 되니 누가 들어가서 뭘 깔 수가 없다.</p>
<p><strong>2. 호스트 OS 보안은 AWS 책임이다.</strong>
책임 공유 모델. 하이퍼바이저와 호스트 OS 패치는 AWS가 하고,
우리는 앱과 데이터를 책임진다. 백신을 깔 호스트에 우리는 접근조차 못 한다.</p>
<p><strong>3. 그 대신 다른 방어를 쓴다.</strong></p>
<ul>
<li><strong>ECR 이미지 스캔</strong>: 이미지를 push할 때마다 알려진 취약점(CVE) 검사</li>
<li><strong>WAF</strong>: 앱으로 들어오는 요청 내용(L7)을 검사 — 이건 별도 편에서</li>
<li><strong>최소권한 IAM</strong>: 컨테이너가 가진 권한 자체를 최소화</li>
</ul>
<p>전통적 호스트 백신은 &quot;서버가 오래 살고, 사람이 드나드는&quot; 환경의 도구다.
불변·단명 컨테이너 환경에선 실행 모델이 달라서, 방어 수단도 달라진다.</p>
<h2 id="os가-없다면서-이미지엔-리눅스가-있는데요">&quot;OS가 없다&quot;면서 이미지엔 리눅스가 있는데요?</h2>
<p>이것도 헷갈렸던 지점이다. Fargate는 &quot;서버리스&quot;라며? 근데 Dockerfile엔 이렇게 쓰여 있다.</p>
<pre><code class="language-dockerfile">FROM eclipse-temurin:21-jre-alpine</code></pre>
<p>Alpine <strong>Linux</strong>다. OS가 없다더니 리눅스가 들어있다. 모순 아닌가?</p>
<p>모순이 아니다. 층을 나눠서 보면 된다.</p>
<pre><code>[이미지 안]  Alpine 유저스페이스 (libc, 셸, 패키지) + JRE + 우리 jar
[이미지 밖]  리눅스 커널 — 호스트와 공유, AWS가 관리</code></pre><p>컨테이너 이미지엔 <strong>커널이 없다.</strong> 유저스페이스만 있다.
커널은 호스트 것을 빌려 쓴다. 그게 컨테이너가 VM보다 가벼운 이유이기도 하다.</p>
<p>&quot;OS가 없다&quot;의 진짜 뜻은 &quot;리눅스가 없다&quot;가 아니라
<strong>&quot;패치하고 SSH로 들어가서 관리하는 전통적 관리 대상 OS가 없다&quot;</strong>는 거다.</p>
<p>Alpine을 고른 이유도 같은 맥락이다. 5MB짜리 초경량 배포판이라
이미지에 들어가는 게 적을수록 공격 표면도 작아진다. 없는 건 뚫릴 수도 없다.</p>
<h2 id="dockerfile에서-챙긴-두-가지">Dockerfile에서 챙긴 두 가지</h2>
<pre><code class="language-dockerfile"># 멀티스테이지: 빌드 도구는 최종 이미지에 안 들어간다
FROM gradle:... AS build
...
FROM eclipse-temurin:21-jre-alpine
COPY --from=build /app/build/libs/*.jar app.jar

# 루트로 돌리지 않는다
USER spring</code></pre>
<ul>
<li><strong>멀티스테이지 빌드</strong>: Gradle, JDK 같은 빌드 도구는 빌드 단계에서만 쓰고 버린다. 최종 이미지엔 JRE와 jar만. 이미지도 작아지고 공격 표면도 준다.</li>
<li><strong>non-root 실행</strong>: 컨테이너가 뚫려도 컨테이너 안에서 루트가 아니다. 피해 범위를 한 겹 더 줄인다.</li>
</ul>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 기술 선택의 기준은 &quot;내가 감당할 수 있는 운영 부담&quot;이었다.</strong>
EC2가 나쁜 게 아니다. 호스트를 직접 통제해야 하는 요구사항이 있으면 EC2가 맞다.
우리는 그 요구가 없었고, 관리 표면을 줄이는 게 이득이었다.</p>
<p><strong>2. 보안 질문에는 그 환경의 언어로 다시 답해야 한다.</strong>
&quot;백신 깔았냐&quot;는 질문의 본질은 &quot;악성코드 대책이 있냐&quot;다.
도구(백신)가 아니라 목적(악성코드 방어)에 답하면, 불변 인프라 + 이미지 스캔이라는
다른 형태의 답이 성립한다.</p>
<p><strong>3. &quot;서버리스&quot;라는 말은 마케팅 용어라서, 실제 구조로 한 번 번역해봐야 한다.</strong>
커널 공유 구조를 이해하고 나니 컨테이너, VM, Fargate의 차이가 명확해졌다.</p>
<hr>
<p>다음 편은 비밀값 이야기다. DB 비밀번호와 API 키를 코드에 넣지 않기 위해
Doppler와 aws-vault로 만든 &quot;비밀이 한 군데만 사는&quot; 구조.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프라 구축 일지 (1) — VPC를 3개의 방으로 나눈 이유]]></title>
            <link>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-1-VPC%EB%A5%BC-3%EA%B0%9C%EC%9D%98-%EB%B0%A9%EC%9C%BC%EB%A1%9C-%EB%82%98%EB%88%88-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@junyoung-choe/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95-%EC%9D%BC%EC%A7%80-1-VPC%EB%A5%BC-3%EA%B0%9C%EC%9D%98-%EB%B0%A9%EC%9C%BC%EB%A1%9C-%EB%82%98%EB%88%88-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Thu, 03 Sep 2026 02:05:06 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>백엔드 개발자가 AWS 인프라를 처음부터 직접 구축한 기록이다.
지난 글(Flyway 삽질기)에서 예고했던 구축 일지, 그 첫 번째. 네트워크부터 시작한다.</p>
</blockquote>
<h2 id="집을-짓기-전에-땅부터">집을 짓기 전에 땅부터</h2>
<p>인프라를 구축하면서 가장 먼저 한 일은 서버를 띄우는 게 아니었다. <strong>네트워크 도면 그리기</strong>였다.</p>
<p>AWS에서 네트워크의 시작은 VPC다. 우리만 쓰는 격리된 사설 네트워크.
여기에 <code>10.1.0.0/16</code>이라는 대역을 잡았다. IP로 치면 6.5만 개짜리 땅이다.</p>
<p>문제는 이 땅을 어떻게 나누느냐였다. 다 한 방에 몰아넣을 수도 있다.
ALB도, 앱 서버도, DB도 전부 같은 서브넷에. 동작은 한다. 하지만 그렇게 안 했다.</p>
<h2 id="3개의-층으로-나눴다">3개의 층으로 나눴다</h2>
<pre><code>1층 퍼블릭   → ALB(입구) · NAT(출구)     ← 인터넷이 닿는 유일한 층
2층 앱       → ECS Fargate 컨테이너      ← 나가는 것만 가능 (NAT 경유)
3층 데이터   → RDS(PostgreSQL) · Redis   ← 인터넷으로 가는 길 자체가 없음</code></pre><p>각 층은 가용영역(AZ) 2개에 걸쳐 있어서 실제 서브넷은 6개다.</p>
<table>
<thead>
<tr>
<th>층</th>
<th>서브넷 크기</th>
<th>왜 이 크기인가</th>
</tr>
</thead>
<tbody><tr>
<td>퍼블릭</td>
<td>/26 (64개)</td>
<td>ALB가 AZ마다 IP를 여러 개 쓴다</td>
</tr>
<tr>
<td>앱</td>
<td>/27 (32개)</td>
<td>컨테이너 최대 4대 + 여유</td>
</tr>
<tr>
<td>데이터</td>
<td>/27 (32개)</td>
<td>DB는 몇 대 안 된다</td>
</tr>
</tbody></table>
<p>한 문장으로 요약하면: <strong>안으로 갈수록 인터넷에서 멀어진다.</strong></p>
<h2 id="퍼블릭-서브넷이라는-건-사실-없다">&quot;퍼블릭 서브넷&quot;이라는 건 사실 없다</h2>
<p>여기서 처음 제대로 이해하게 된 것.
퍼블릭 서브넷과 프라이빗 서브넷은 서브넷 자체의 속성이 아니다.</p>
<p><strong>차이는 오직 라우팅 테이블 한 줄이다.</strong></p>
<ul>
<li>라우팅 테이블에 <code>0.0.0.0/0 → IGW(인터넷 게이트웨이)</code> 경로가 있으면 → 퍼블릭</li>
<li>없으면 → 프라이빗</li>
</ul>
<p>그래서 우리 구조는 이렇게 된다.</p>
<pre><code>퍼블릭 층 라우팅:  0.0.0.0/0 → IGW        (인터넷 양방향)
앱 층 라우팅:     0.0.0.0/0 → NAT GW     (나가기만 가능)
데이터 층 라우팅:  0.0.0.0/0 경로 없음     (인터넷과 단절)</code></pre><p>앱 층의 NAT 게이트웨이는 &quot;나가기만 하는 출구&quot;다.
컨테이너가 외부 API를 호출하거나 이미지를 pull할 땐 나갈 수 있지만,
외부에서 컨테이너로 직접 들어오는 건 불가능하다.</p>
<p>그리고 데이터 층. 여기가 이 설계의 핵심인데, <strong><code>0.0.0.0/0</code> 라우트를 일부러 안 만들었다.</strong></p>
<blockquote>
<p>DB 비밀번호가 통째로 유출돼도, 인터넷에서 DB에 접근할 <strong>물리적 경로 자체가 없다.</strong>
방화벽이 막는 게 아니라, 길이 없는 거다.</p>
</blockquote>
<h2 id="방화벽은-ip가-아니라-신분으로">방화벽은 IP가 아니라 &quot;신분&quot;으로</h2>
<p>층을 나눴으면 층 사이의 문도 통제해야 한다. AWS에선 보안 그룹(SG)이 그 역할인데,
여기서 한 가지 결정을 했다. <strong>IP 기반이 아니라 SG 체이닝으로 연결한다.</strong></p>
<pre><code>인터넷 → [ALB SG] → :8080 → [ECS SG] → :5432 → [RDS SG]
                                     → :6379 → [Redis SG]</code></pre><p>각 화살표의 규칙이 &quot;특정 IP를 허용&quot;이 아니라 <strong>&quot;앞 단계 SG를 가진 리소스만 허용&quot;</strong>이다.</p>
<ul>
<li>ECS SG의 8080 인바운드: <code>ALB SG를 가진 것만</code></li>
<li>RDS SG의 5432 인바운드: <code>ECS SG를 가진 것만</code></li>
</ul>
<p>왜 IP가 아니라 신분인가? <strong>컨테이너 IP는 계속 바뀌기 때문이다.</strong>
오토스케일링으로 2대가 4대가 되면 새 컨테이너는 새 IP를 받는다.
IP로 규칙을 짰다면 스케일아웃할 때마다 방화벽이 깨진다.
&quot;ECS SG 멤버&quot;라는 신분으로 열어두면 몇 대로 늘든 규칙이 자동으로 맞는다.</p>
<h2 id="요청-하나가-흐르는-길">요청 하나가 흐르는 길</h2>
<p>사용자가 API를 호출하면 이렇게 흐른다.</p>
<pre><code>① 사용자 → https://api.도메인 (DNS가 ALB로 안내)
② ALB(1층)가 TLS 종료 — HTTPS를 풀고
③ ALB → ECS 컨테이너(2층)로 8080 평문 전달   ← 사설망 내부라 평문 OK
④ 앱 → RDS/Redis(3층) 조회
⑤ 응답이 역순으로 나감</code></pre><p>외부에서 들어오는 문은 ALB 하나뿐이다. TLS 암호화 부하는 ALB가 흡수하고,
내부 통신은 사설망이라 평문으로 빠르게 처리한다.</p>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. 네트워크 격리는 &quot;설정&quot;이 아니라 &quot;구조&quot;다.</strong>
방화벽 규칙은 실수로 열 수 있다. 하지만 라우트가 없는 서브넷은 실수로도 못 연다.
가장 강한 방어는 규칙이 아니라 경로의 부재였다.</p>
<p><strong>2. 퍼블릭/프라이빗은 라우팅 테이블 한 줄 차이다.</strong>
이걸 이해하고 나니 AWS 네트워크 문서들이 갑자기 읽히기 시작했다.</p>
<p><strong>3. 방화벽 규칙은 변하는 것(IP)이 아니라 변하지 않는 것(신분)에 걸어야 한다.</strong>
오토스케일링 시대에 IP 기반 규칙은 유지보수 폭탄이다.</p>
<hr>
<p>다음 편은 컴퓨팅 이야기다. EC2 대신 Fargate를 고른 이유,
그리고 &quot;서버에 백신 안 깔았어요?&quot;라는 질문에 어떻게 답했는지.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[배포했는데 반영이 안 된다? — 5주간 조용히 롤백된 Flyway 삽질기]]></title>
            <link>https://velog.io/@junyoung-choe/%EB%B0%B0%ED%8F%AC%ED%96%88%EB%8A%94%EB%8D%B0-%EB%B0%98%EC%98%81%EC%9D%B4-%EC%95%88-%EB%90%9C%EB%8B%A4-5%EC%A3%BC%EA%B0%84-%EC%A1%B0%EC%9A%A9%ED%9E%88-%EB%A1%A4%EB%B0%B1%EB%90%9C-Flyway-%EC%82%BD%EC%A7%88%EA%B8%B0-z956nrk1</link>
            <guid>https://velog.io/@junyoung-choe/%EB%B0%B0%ED%8F%AC%ED%96%88%EB%8A%94%EB%8D%B0-%EB%B0%98%EC%98%81%EC%9D%B4-%EC%95%88-%EB%90%9C%EB%8B%A4-5%EC%A3%BC%EA%B0%84-%EC%A1%B0%EC%9A%A9%ED%9E%88-%EB%A1%A4%EB%B0%B1%EB%90%9C-Flyway-%EC%82%BD%EC%A7%88%EA%B8%B0-z956nrk1</guid>
            <pubDate>Thu, 03 Sep 2026 01:16:29 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>시리즈 예고: 지금 운영 중인 서비스의 백엔드 인프라(AWS ECS Fargate + Terraform)를 직접 구축했다.
구축 과정은 따로 연재로 풀 예정이고, 오늘은 <strong>정식 오픈 전 준비 기간</strong>에 겪은 사건 하나를 먼저 꺼낸다.
실사용자가 없던 시기라 피해는 없었지만, 개인적으로 올해 겪은 삽질 중 가장 교훈이 많았던 건이다.</p>
</blockquote>
<h2 id="버그-하나-고쳤을-뿐인데">버그 하나 고쳤을 뿐인데</h2>
<p>시작은 사소했다. URL 파싱 버그를 고쳤다.
<code>https://</code> 없이 <code>www.도메인/...</code>처럼 스킴을 빼고 입력하면 등록이 거부되는 문제였다.</p>
<p>수정하고, 테스트 통과 확인하고, develop에 머지했다.
CI가 돌고 stage 배포 워크플로가 초록불로 끝났다. 여기까진 평화로웠다.</p>
<p>그런데 stage에서 확인해보니 —</p>
<ul>
<li><code>https://www.도메인/...</code> → 등록됨</li>
<li><code>www.도메인/...</code> → <strong>여전히 실패</strong></li>
</ul>
<p>고친 코드가 반영이 안 된 거다. 방금 배포 성공했다고 초록불 떴는데?</p>
<h2 id="파이프라인은-초록불">파이프라인은 초록불</h2>
<p>처음엔 당연히 코드나 브랜치를 의심했다.</p>
<ul>
<li>머지가 제대로 됐나? → develop에 커밋 있음</li>
<li>워크플로가 안 돌았나? → 트리거 정상, 테스트 통과, 이미지 빌드·푸시 정상</li>
<li>ECR에 새 이미지가 올라갔나? → 최신 커밋 SHA 태그로 올라가 있음</li>
</ul>
<p>전부 정상이다. 빌드도 됐고 이미지도 있고 배포 명령도 나갔다.
그런데 서버는 옛날 코드로 응답하고 있다. 증상이 앞뒤가 안 맞았다.</p>
<h2 id="ecs-이벤트에서-찾은-단서">ECS 이벤트에서 찾은 단서</h2>
<p>파이프라인이 아니라면 그 다음 단계, ECS를 봐야 한다.
서비스 이벤트 로그를 열었더니 이런 문장이 있었다.</p>
<pre><code>(service ...-stg-back) deployment failed: tasks failed to start.
(service ...-stg-back) rolling back to deployment ...</code></pre><p><strong>새 태스크가 부팅에 실패해서, ECS가 알아서 구버전으로 롤백하고 있었다.</strong></p>
<p>ECS에는 deployment circuit breaker라는 기능이 있다.
새 버전 컨테이너가 계속 뜨지 못하면 배포를 실패로 판정하고
마지막으로 성공했던 버전으로 자동 롤백한다. 서비스가 죽는 것보단 훨씬 낫다.</p>
<p>문제는 이게 <strong>너무 조용하다</strong>는 거다.
롤백된 구버전이 멀쩡히 트래픽을 받으니까 서비스는 초록불이고, 알람도 안 울리고, 5xx도 없다.
겉으로 보면 &quot;배포 성공 + 서비스 정상&quot;인데 실제로는 &quot;배포 실패 + 구버전 유지&quot;다.</p>
<h2 id="진짜-범인-flyway-마이그레이션">진짜 범인: Flyway 마이그레이션</h2>
<p>그럼 새 태스크는 왜 부팅에 실패했을까. 컨테이너 로그를 열어보니 답이 바로 나왔다.</p>
<pre><code>Migration V28__remove_placeholder_master_seed.sql failed
ERROR: update or delete on table &quot;genre&quot; violates
foreign key constraint &quot;..._fkey&quot; on table &quot;content&quot;</code></pre><p>Spring Boot는 부팅 시점에 Flyway로 DB 마이그레이션을 돌린다.
V28이라는 마이그레이션이 FK 제약 위반으로 실패했고, 마이그레이션이 실패하면 앱이 안 뜬다.
앱이 안 뜨니 태스크 실패, 태스크 실패니 circuit breaker 롤백. 인과가 전부 연결됐다.</p>
<p>V28의 내용은 단순하다. 초기 세팅 때 넣어둔 placeholder 시드 데이터(장르 목록 등)를 지우는 거다.</p>
<pre><code class="language-sql">DELETE FROM genre;      -- 마스터 시드 3종 (테이블명은 일반화했다)
DELETE FROM tag;
DELETE FROM category;</code></pre>
<p>파일 주석에는 이렇게 적혀 있었다.
&quot;DB 초기화 직후라 콘텐츠가 아직 이 시드를 참조하지 않는 시점. FK 충돌 없음.&quot;</p>
<p>이 가정이 문제였다.</p>
<h2 id="왜-prod는-되고-stage만-안-되나">왜 prod는 되고 stage만 안 되나</h2>
<p>여기가 이 사건의 핵심이다. <strong>같은 코드, 같은 V28인데 prod는 멀쩡했다.</strong></p>
<p>이유는 두 환경의 DB가 걸어온 역사가 달랐기 때문이다.</p>
<ul>
<li><strong>prod</strong>: 오픈 준비 과정에서 DB를 전체 리셋했다. V28이 돌 때 테이블이 비어 있었고,
시드를 참조하는 데이터가 없으니 DELETE가 그냥 통과했다. Flyway 이력에 <code>V28 = success</code>가 기록됐다.</li>
<li><strong>stage</strong>: 리셋한 적이 없다. 테스트하면서 등록한 콘텐츠들이 시드 데이터를 FK로 참조하고 있었다.
그 상태에서 시드를 지우려니 FK 위반. <code>V28 = success</code> 기록이 영영 안 생긴다.</li>
</ul>
<p>Flyway는 어떤 마이그레이션을 적용했는지를 DB마다 <code>flyway_schema_history</code> 테이블에 기록한다.
그래서 prod는 부팅 때마다 &quot;V28? 이미 했네&quot; 하고 넘어가고,
stage는 부팅 때마다 &quot;V28 해야지&quot; → FK 위반 → 실패를 반복한 거다.</p>
<p><strong>코드는 하나인데 DB 상태가 갈라져 있으면, 같은 마이그레이션이 환경마다 다르게 동작한다.</strong>
Flyway는 스키마 버전만 추적하지, 그 안에 어떤 데이터가 쌓였는지는 모른다.</p>
<h2 id="더-소름-5주-전부터였다">더 소름: 5주 전부터였다</h2>
<p>&quot;근데 왜 갑자기 안 되지?&quot;라고 생각하며 배포 이력을 거슬러 올라갔다.</p>
<p>갑자기가 아니었다. <strong>V28이 들어간 이후 5주 동안, stage 배포는 전부 실패하고 있었다.</strong></p>
<p>stage에서 돌고 있는 이미지를 확인해보니 5주 전 커밋이었다. V28이 생기기 직전 버전.
그 사이에 있었던 배포들 전부 — V28 FK 실패 → circuit breaker 롤백 → 5주 전 이미지로 복귀.</p>
<p>그동안 아무도 몰랐다. stage 서비스는 계속 초록불이었으니까.
이번에 버그 수정을 stage에서 확인하려다가 우연히 수면 위로 올라온 거다.</p>
<p>그나마 다행인 건 시점이었다. <strong>아직 정식 오픈 전이라 실사용자에게 간 영향은 없었다.</strong>
오픈 준비 중의 stage에서 미리 밟은 지뢰인 셈인데, 이 함정 자체는
오픈 후에도 언제든 똑같이 터질 수 있는 구조였다. 그래서 기록해둔다.</p>
<p>돌이켜보면 무서운 지점이다. 모니터링은 &quot;서비스가 살아있는가&quot;를 본다.
그런데 이 사건의 실패 신호는 &quot;서비스가 죽었다&quot;가 아니라 &quot;<strong>새 버전이 반영되지 않았다</strong>&quot;였다.
살아있는지만 보는 모니터링에는 절대 안 잡힌다.</p>
<h2 id="파일을-고치면-되는-거-아냐">파일을 고치면 되는 거 아냐?</h2>
<p>가장 먼저 드는 생각: V28에서 DELETE 앞에 참조 데이터 정리를 추가하면 되지 않나?</p>
<p><strong>안 된다.</strong> Flyway는 마이그레이션 파일의 체크섬을 이력에 함께 저장하고,
부팅 때마다 &quot;적용된 파일이 그대로인지&quot; 검증한다(validate).</p>
<p>V28은 이미 prod에 적용됐고 체크섬이 박제됐다.
지금 파일을 수정하면 <strong>다음 prod 배포에서 체크섬 불일치로 validate가 깨진다.</strong>
stage 고치려다 prod를 망가뜨리는 그림이다.</p>
<blockquote>
<p>한번 적용된 마이그레이션 파일은 불변(immutable)이다.
고치고 싶으면 파일이 아니라 다른 곳을 손대야 한다.</p>
</blockquote>
<h2 id="해결-파일이-아니라-이력을-만졌다">해결: 파일이 아니라 이력을 만졌다</h2>
<p>선택지는 두 개였다.</p>
<ul>
<li><strong>A. stage 이력에 V28을 &quot;적용됨&quot;으로 수동 마킹</strong> — 시드 데이터는 남지만, stage에선 실제로 쓰이는 데이터라 남아도 무해. 데이터 무손실.</li>
<li><strong>B. 시드를 참조하는 stage 데이터를 정리하고 V28을 정상 실행</strong> — 스키마 상태는 prod와 완전히 일치하지만 테스트 데이터가 날아간다.</li>
</ul>
<p>A를 택했다. stage의 테스트 데이터를 지킬 수 있고, 조치가 DB 이력 한 줄로 끝난다.</p>
<p><code>flyway_schema_history</code>에 V28 행을 직접 INSERT 했다.
주의할 건 체크섬 — Flyway가 다음 부팅 때 파일과 대조하므로 실제 파일의 체크섬 값과 일치해야 한다.
(Flyway의 체크섬은 파일을 줄 단위로 읽어 CRC32를 누적한 값이다. 줄바꿈 문자는 계산에서 빠지기 때문에 CRLF/LF가 달라도 같은 값이 나온다.)</p>
<pre><code class="language-sql">INSERT INTO flyway_schema_history
  (installed_rank, version, description, type, script, checksum,
   installed_by, installed_on, execution_time, success)
VALUES
  ((SELECT COALESCE(MAX(installed_rank),0)+1 FROM flyway_schema_history),
   &#39;28&#39;, &#39;remove placeholder master seed&#39;, &#39;SQL&#39;,
   &#39;V28__remove_placeholder_master_seed.sql&#39;, &lt;파일_체크섬&gt;,
   &#39;manual-skip&#39;, now(), 0, true);</code></pre>
<p>그리고 재배포. 이번엔 부팅 로그가 달랐다.</p>
<pre><code>Successfully validated 28 migrations
Current version of schema &quot;public&quot;: 28
Schema &quot;public&quot; is up to date. No migration necessary.
...
Started Application in 49.6 seconds</code></pre><p>V28을 &quot;이미 적용됨&quot;으로 스킵하고 앱이 떴다. 새 태스크가 헬스체크를 통과하고,
이번엔 <strong>구버전 쪽이 드레이닝됐다.</strong> 5주 만에 처음으로 롤백이 반대로 일어난 순간이었다.</p>
<p>밀려 있던 5주치 변경사항이 한 번에 반영됐고, 처음 고치려던 URL 파싱 버그도 stage에서 정상 동작했다.</p>
<h2 id="배운-것-세-가지">배운 것 세 가지</h2>
<p><strong>1. 데이터에 의존하는 마이그레이션은 환경마다 다르게 터진다.</strong>
Flyway가 보장하는 건 &quot;스키마 버전의 순서&quot;뿐이다. DELETE처럼 데이터 상태를 가정하는
마이그레이션은 &quot;모든 환경에서 그 가정이 참인가&quot;를 물어야 한다.
&quot;지금 이 DB에서 되니까&quot;는 다른 환경에서의 성공을 보장하지 않는다.</p>
<p><strong>2. 자동 롤백은 서비스를 지키지만, 실패를 숨긴다.</strong>
circuit breaker 덕분에 장애는 없었다. 대신 실패가 5주간 은폐됐다.
초록불은 &quot;서비스가 살아있다&quot;는 뜻이지 &quot;최신 버전이 떠 있다&quot;는 뜻이 아니다.
배포가 반영 안 되는 것 같으면 에러 로그가 아니라 <strong>배포 이벤트와 실행 중인 이미지 버전</strong>을 먼저 봐야 한다.</p>
<p><strong>3. 적용된 마이그레이션은 불변이다. 고칠 땐 파일이 아니라 이력을.</strong>
체크섬이 박제된 파일을 수정하면 그 파일이 적용된 모든 환경이 깨진다.
환경 하나의 이력이 꼬였다면 그 환경의 <code>flyway_schema_history</code>를 수술하는 게 정석이다.
(Flyway에 <code>repair</code> 명령이 있는 것도 같은 맥락이다.)</p>
<hr>
<p>다음 글부터는 이 인프라를 어떻게 구축했는지 처음부터 풀어볼 예정이다.
VPC를 왜 3층으로 나눴는지, EC2 대신 Fargate를 왜 골랐는지 — 구축 일지로 이어진다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ Qwen 모델 구축, 삽질의 연속]]></title>
            <link>https://velog.io/@junyoung-choe/Qwen-%EB%AA%A8%EB%8D%B8-%EA%B5%AC%EC%B6%95-%EC%82%BD%EC%A7%88%EC%9D%98-%EC%97%B0%EC%86%8D</link>
            <guid>https://velog.io/@junyoung-choe/Qwen-%EB%AA%A8%EB%8D%B8-%EA%B5%AC%EC%B6%95-%EC%82%BD%EC%A7%88%EC%9D%98-%EC%97%B0%EC%86%8D</guid>
            <pubDate>Mon, 30 Mar 2026 01:10:20 GMT</pubDate>
            <description><![CDATA[<p>3편에서 AWS 환경 세팅을 마쳤다.</p>
<p>이제 Qwen-Image-Edit-2511 모델을 실제로 올려보자.</p>
<p>생각보다 순탄하지 않았다.</p>
<hr>
<h2 id="모델-다운로드">모델 다운로드</h2>
<p>작업 폴더를 만들고 모델을 내려받는다.</p>
<p>모델 크기가 약 40GB라 시간이 좀 걸린다.</p>
<pre><code class="language-bash">mkdir -p ~/qwen-project
cd ~/qwen-project</code></pre>
<pre><code class="language-python">from huggingface_hub import snapshot_download

snapshot_download(
    repo_id=&quot;Qwen/Qwen-Image-Edit-2511&quot;,
    local_dir=&quot;./model&quot;,
    local_dir_use_symlinks=False,
    resume_download=True
)</code></pre>
<p><code>resume_download=True</code> 덕분에 중간에 끊겨도 이어받을 수 있다 !</p>
<hr>
<h2 id="1차-시도--device_mapcuda">1차 시도 — device_map=&quot;cuda&quot;</h2>
<p>모델을 GPU에 전부 올리는 방식으로 시작했다.</p>
<pre><code class="language-python">pipeline = QwenImageEditPlusPipeline.from_pretrained(
    &quot;Qwen/Qwen-Image-Edit-2511&quot;,
    torch_dtype=torch.float16,
    device_map=&quot;cuda&quot;,
    low_cpu_mem_usage=True
)</code></pre>
<p>바로 에러가 떴다.</p>
<pre><code>torch.OutOfMemoryError: CUDA out of memory.
Tried to allocate 72.00 MiB.
GPU 0 has a total capacity of 21.99 GiB of which 69.38 MiB is free.
Of the allocated memory 21.48 GiB is allocated by PyTorch</code></pre><p>VRAM 상태를 보면 이렇다.</p>
<pre><code>Total VRAM : 21.99 GiB
사용 중    : 21.91 GiB (99.6%)
 ├─ PyTorch : 21.48 GiB
 └─ 기타    : 0.43 GiB
남은 공간  : 0.069 GiB (69MB)</code></pre><p>72MB가 필요한데 69MB밖에 없었다.</p>
<p>모델이 VRAM을 꽉 채워버린 것이다.</p>
<hr>
<h2 id="2차-시도--device_mapbalanced">2차 시도 — device_map=&quot;balanced&quot;</h2>
<p>GPU 메모리가 부족하면 자동으로 CPU로 offload하는 방식으로 바꿨다.</p>
<pre><code class="language-python">pipeline = QwenImageEditPlusPipeline.from_pretrained(
    &quot;Qwen/Qwen-Image-Edit-2511&quot;,
    torch_dtype=torch.float16,
    device_map=&quot;balanced&quot;,
    low_cpu_mem_usage=True
)</code></pre>
<p>실행은 됐다.</p>
<p>근데 문제가 생겼다.</p>
<pre><code>40%|████████| 12/30 [4:09:33&lt;6:09:14, 1230.82s/it]</code></pre><p>30 스텝 중 12번째(40%)에 4시간 9분이 걸렸다.</p>
<p>스텝당 약 20분, 총 예상 시간이 10시간이 넘었다.</p>
<p>GPU와 CPU를 계속 왔다갔다하면서 속도가 처참하게 느려진 것이다.</p>
<p>결국 <code>KeyboardInterrupt</code> 로 중단했다.</p>
<hr>
<h2 id="3차-시도--fp-조절">3차 시도 — FP 조절</h2>
<p>VRAM 사용량을 줄이려면 데이터 타입을 낮추면 된다.</p>
<pre><code class="language-python"># 기존
torch_dtype=torch.float16

# 변경 시도
torch_dtype=torch.float8</code></pre>
<p>바로 막혔다.</p>
<p>PyTorch에는 <code>torch.float8</code> 이 없다 !</p>
<pre><code>AttributeError: module &#39;torch&#39; has no attribute &#39;float8&#39;</code></pre><p>float16보다 더 낮은 타입을 쓰려면 다른 방법이 필요했다.</p>
<hr>
<h2 id="해결--서버-교체--sequential-offload--bfloat16-조합">해결 — 서버 교체 + sequential offload + bfloat16 조합</h2>
<p>원래 쓰던 서버는 VRAM 8GB였다.</p>
<p>모델 자체가 너무 커서 8GB로는 아예 올라가지도 않았다.</p>
<p>서버를 교체했지만 모델 전체를 다 올릴 수 있는 사양으로 바꾼 건 아니었다.</p>
<p>그래서 두 가지를 함께 적용했다.</p>
<p><strong>sequential CPU offload</strong> — 레이어 단위로 필요할 때만 GPU에 올리고 나머지는 CPU에 둔다.</p>
<p>model 방식보다 느리지만 메모리를 훨씬 적게 쓴다.</p>
<pre><code class="language-python">pipeline.enable_sequential_cpu_offload()</code></pre>
<p><strong>bfloat16으로 타입 변경</strong> — float16 대비 수치 안정성이 높고 메모리 사용량은 동일하다.</p>
<pre><code class="language-python">torch_dtype=torch.bfloat16</code></pre>
<p>이 두 가지 조합으로 정상적으로 동작했다 !</p>
<hr>
<h2 id="결과">결과</h2>
<p>서버를 바꾼 뒤 정상적으로 동작했다.</p>
<p>GPU 상태도 안정적이었다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/08f92c5e-0e0d-4efd-9b51-88f54c1aa879/image.png" alt=""></p>
<pre><code>GPU 사용률 : 89%
온도       : 73°C
전력       : 58W / 72W
메모리 사용 : 890 MiB / 23034 MiB (약 3.9%)
여유 메모리 : 약 21.6 GB</code></pre><h3 id="강아지-사진-테스트">강아지 사진 테스트</h3>
<p>원본 사진에 다양한 각도와 스타일 변환을 적용해봤다.</p>
<ul>
<li><p>원본 강아지 사진
<img src="https://velog.velcdn.com/images/junyoung-choe/post/c1c808d7-f7e4-4816-9e12-c1d60efb84f9/image.png" alt=""></p>
</li>
<li><p>right side view high-angle shot close-up
<img src="https://velog.velcdn.com/images/junyoung-choe/post/e8a71c1a-e955-424f-8e57-579a7b79acff/image.webp" alt=""></p>
</li>
<li><p>back side view high-angle shot close-up
<img src="https://velog.velcdn.com/images/junyoung-choe/post/1be9ccce-22bd-4cf2-af18-8e41d5e0fbc4/image.webp" alt=""></p>
</li>
<li><p>left side view high-angle shot close-up</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/b4b57297-c9d5-484b-8c9d-4844b73641e1/image.webp" alt=""></p>
<ul>
<li>지브리 스타일 변환 결과
<img src="https://velog.velcdn.com/images/junyoung-choe/post/38d027b5-0bfb-45ed-a435-6447c0512468/image.webp" alt=""></li>
</ul>
<p>지브리 스타일 변환에 사용한 프롬프트다.</p>
<pre><code>Transform the actual photo provided into an anime-style landscape scene.
Keep the core composition and elements from the original photo,
but render everything in vibrant anime aesthetics with exaggerated colors,
dynamic lighting, soft gradients, and cel-shaded outlines typical of
Studio Ghibli-inspired landscapes.</code></pre><h3 id="사람-사진-테스트">사람 사진 테스트</h3>
<ul>
<li>원본 사람 사진</li>
</ul>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/afd795ca-dfb9-4cd3-9337-83836daf374e/image.png" alt=""></p>
<ul>
<li>right side view high-angle shot close-up 결과</li>
</ul>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/aae40d0e-a7d6-4624-a312-e28d7f24ddf4/image.png" alt=""></p>
<hr>
<h2 id="lora--gradio-서버">LoRA + Gradio 서버</h2>
<p>LoRA까지 붙이고 외부에서 접근할 수 있는 Gradio 서버를 구축했다.
<img src="https://velog.velcdn.com/images/junyoung-choe/post/aa650fd4-db0c-41f3-8323-afafc8449f80/image.png" alt="">
단일 편집과 배치 편집(다중 각도 한 번에 생성) 두 가지 탭으로 구성했다.</p>
<pre><code class="language-python">import gradio as gr
import torch
from PIL import Image
from diffusers import QwenImageEditPlusPipeline
import gc
import os

os.environ[&#39;PYTORCH_CUDA_ALLOC_CONF&#39;] = &#39;expandable_segments:True&#39;

pipeline = QwenImageEditPlusPipeline.from_pretrained(
    &quot;./model&quot;,
    torch_dtype=torch.bfloat16
)
pipeline.enable_sequential_cpu_offload()

pipeline.load_lora_weights(
    &quot;fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA&quot;,
    adapter_name=&quot;multi_angle&quot;
)
pipeline.set_adapters([&quot;multi_angle&quot;], adapter_weights=[0.9])</code></pre>
<p>서버는 <code>0.0.0.0:7860</code> 으로 띄운다.</p>
<pre><code class="language-python">demo.launch(
    server_name=&quot;0.0.0.0&quot;,
    server_port=7860,
    share=False,
    debug=True
)</code></pre>
<ul>
<li>결과화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/d7e357a5-bdbe-434d-8035-3c8fa2e184de/image.png" alt=""></li>
</ul>
<hr>
<h2 id="qwen-모델-핵심-파라미터">Qwen 모델 핵심 파라미터</h2>
<p>일반적인 Diffusion 모델과 파라미터가 다르다.</p>
<p>공식 문서 기준으로 아래 값을 써야 제대로 동작한다.</p>
<pre><code class="language-python">output = pipeline(
    image=[img],           # 리스트로 전달
    prompt=prompt,
    negative_prompt=&quot; &quot;,   # 빈 문자열이 아닌 공백 하나
    true_cfg_scale=4.0,    # Qwen 필수 파라미터
    guidance_scale=1.0,    # Qwen 기본값
    num_inference_steps=20,
)</code></pre>
<p><code>negative_prompt=&quot; &quot;</code> 에서 <code>&quot;&quot;</code> 이 아니라 <code>&quot; &quot;</code> (공백 하나)를 써야 한다.</p>
<p>처음엔 이걸 몰라서 한참 헤맸다 !</p>
<hr>
<h2 id="메모리-최적화-정리">메모리 최적화 정리</h2>
<p>T4에서 삽질하면서 배운 최적화 포인트 4가지다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>변경 전</th>
<th>변경 후</th>
<th>효과</th>
</tr>
</thead>
<tbody><tr>
<td>CPU Offload</td>
<td><code>model</code></td>
<td><code>sequential</code></td>
<td>메모리 절약</td>
</tr>
<tr>
<td>환경 변수</td>
<td>없음</td>
<td><code>expandable_segments</code></td>
<td>단편화 방지</td>
</tr>
<tr>
<td>이미지 크기</td>
<td>원본 그대로</td>
<td>512px</td>
<td>메모리 1/4 감소</td>
</tr>
<tr>
<td>Steps</td>
<td>40</td>
<td>20</td>
<td>속도 2배 향상</td>
</tr>
</tbody></table>
<p>1024x1024 → 512x512로 줄이는 것만으로 메모리 사용량이 1/4로 줄어든다 !</p>
<hr>
<p>처음엔 단순히 모델을 올리는 거라 쉽게 생각했는데, VRAM 용량과 속도 사이에서 꽤 고생했다.</p>
<p>이번에 배운 건 하나다.</p>
<p>VRAM이 모델 크기를 전부 수용할 수 있으면 제일 좋다.</p>
<p>하지만 그렇지 않더라도 방법이 있다.</p>
<ul>
<li><strong>sequential CPU offload</strong> — 레이어 단위로 쪼개서 필요한 부분만 GPU에 올린다</li>
<li><strong>bfloat16 / float16</strong> — 데이터 타입을 낮춰서 메모리 사용량을 줄인다</li>
<li><strong>이미지 크기 축소</strong> — 입력 해상도를 낮추면 메모리가 확 줄어든다</li>
</ul>
<p>서버 사양이 부족하다고 바로 포기할 필요가 없다는 걸 직접 경험했다.</p>
<p>물론 속도 트레이드오프는 있다. 하지만 테스트 목적이라면 충분히 쓸 만하다 !</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS 환경 세팅, Conda부터 PyTorch까지]]></title>
            <link>https://velog.io/@junyoung-choe/AWS-%ED%99%98%EA%B2%BD-%EC%84%B8%ED%8C%85-Conda%EB%B6%80%ED%84%B0-PyTorch%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@junyoung-choe/AWS-%ED%99%98%EA%B2%BD-%EC%84%B8%ED%8C%85-Conda%EB%B6%80%ED%84%B0-PyTorch%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Mon, 30 Mar 2026 00:49:43 GMT</pubDate>
            <description><![CDATA[<p>앞서 실 서비스들을 직접 써보면서 Out Painting이 어떤 건지 감을 잡았다.</p>
<p>이제 직접 모델을 구축해서 테스트해볼 차례다.</p>
<hr>
<h2 id="왜-aws인가">왜 AWS인가</h2>
<p>회사에서 실제로 사용 중인 GPU 서버에 바로 올리기엔 부담이 있었다.</p>
<p>검증되지 않은 모델을 프로덕션 서버에 올리는 건 리스크가 크니까.</p>
<p>AWS에서 먼저 테스트하고, 결과가 괜찮으면 실 서버에 올리는 방식으로 접근했다.</p>
<hr>
<h2 id="인스턴스-선택">인스턴스 선택</h2>
<p>테스트 목적이니 비용 대비 효율을 따졌다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>인스턴스</td>
<td>g4dn.xlarge</td>
</tr>
<tr>
<td>GPU</td>
<td>NVIDIA T4</td>
</tr>
<tr>
<td>VRAM</td>
<td>16GB</td>
</tr>
</tbody></table>
<p>T4는 추론 최적화 GPU라 딥러닝 모델 테스트 용도로 충분하다.</p>
<hr>
<h2 id="환경-세팅">환경 세팅</h2>
<p>모델을 올리기 전에 환경부터 제대로 잡아야 한다.</p>
<p>GPU 인스턴스 세팅부터 Python 환경 구축까지 순서대로 정리한다.</p>
<hr>
<h2 id="ami-선택">AMI 선택</h2>
<p>AWS에서 인스턴스를 생성할 때 AMI 선택이 중요하다.</p>
<p>직접 드라이버, CUDA, PyTorch를 하나씩 설치하면 버전 충돌로 삽질할 가능성이 크다.</p>
<p>아래 AMI를 선택하면 전부 세팅된 상태로 시작할 수 있다.</p>
<pre><code>Deep Learning OSS Nvidia Driver AMI GPU PyTorch 2.9 (Ubuntu 24.04)</code></pre><p>이 AMI 하나로 끝난다.</p>
<ul>
<li>NVIDIA 드라이버 ✅</li>
<li>CUDA Toolkit ✅</li>
<li>cuDNN ✅</li>
<li>PyTorch 2.9 ✅</li>
<li>기타 딥러닝 라이브러리들 ✅</li>
</ul>
<hr>
<h2 id="1-시스템-확인-및-업데이트">1. 시스템 확인 및 업데이트</h2>
<p>인스턴스에 접속하면 가장 먼저 시스템 상태와 GPU를 확인한다.</p>
<pre><code class="language-bash"># 시스템 정보
cat /etc/os-release | grep PRETTY_NAME
uname -r

# GPU 확인
nvidia-smi

# 업데이트
sudo apt update &amp;&amp; sudo apt upgrade -y

# 기본 도구 설치
sudo apt install -y build-essential wget curl git vim htop</code></pre>
<p><code>nvidia-smi</code> 에서 GPU 정보가 정상적으로 출력되면 드라이버는 잡힌 거다.</p>
<hr>
<h2 id="2-nvidia-드라이버-설치-ami-미사용-시">2. NVIDIA 드라이버 설치 (AMI 미사용 시)</h2>
<p>딥러닝 AMI를 쓰지 않는 경우라면 드라이버를 직접 설치해야 한다.</p>
<pre><code class="language-bash">sudo apt update &amp;&amp; sudo apt upgrade -y

# ubuntu-drivers 설치
sudo apt install -y ubuntu-drivers-common

# 추천 드라이버 확인
sudo ubuntu-drivers devices

# 자동 설치
sudo ubuntu-drivers autoinstall

# 재부팅
sudo reboot</code></pre>
<hr>
<h2 id="3-conda-설치">3. Conda 설치</h2>
<p>패키지 버전 관리를 위해 Miniconda를 설치한다.</p>
<pre><code class="language-bash"># Miniconda 다운로드
cd ~
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh

# 설치
bash Miniconda3-latest-Linux-x86_64.sh -b -p $HOME/miniconda3

# Conda 초기화
$HOME/miniconda3/bin/conda init bash

# 설정 적용
source ~/.bashrc

# 확인 — (base) 표시가 나와야 함
conda --version</code></pre>
<hr>
<h2 id="4-python-환경-생성">4. Python 환경 생성</h2>
<p>모델별로 환경을 분리해두는 게 좋다.</p>
<pre><code class="language-bash"># Python 3.10 환경 생성
conda create -n qwen python=3.10 -y -c conda-forge

# 환경 활성화
conda activate qwen

# 확인 — (qwen) 표시가 나와야 함
python --version</code></pre>
<p>에러가 발생하면 <code>conda update -n base -c defaults conda</code> 로 먼저 conda 자체를 업데이트해보자.</p>
<hr>
<h2 id="5-pytorch-설치">5. PyTorch 설치</h2>
<p>환경 안에서 CUDA 호환 PyTorch를 설치한다.</p>
<pre><code class="language-bash">pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121</code></pre>
<p>설치 후 GPU 연결 여부를 확인한다.</p>
<pre><code class="language-python">import torch

print(f&quot;PyTorch 버전: {torch.__version__}&quot;)
print(f&quot;CUDA 사용 가능: {torch.cuda.is_available()}&quot;)
print(f&quot;CUDA 버전: {torch.version.cuda}&quot;)
print(f&quot;GPU 이름: {torch.cuda.get_device_name(0)}&quot;)
print(f&quot;GPU 메모리: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB&quot;)</code></pre>
<p><code>CUDA 사용 가능: True</code> 가 나오면 성공이다 !</p>
<hr>
<h2 id="6-diffusers-및-의존성-설치">6. Diffusers 및 의존성 설치</h2>
<p>Qwen 모델을 쓰려면 HuggingFace 생태계 라이브러리들이 필요하다.</p>
<pre><code class="language-bash">pip install diffusers transformers accelerate safetensors pillow huggingface_hub</code></pre>
<hr>
<h2 id="7-huggingface-로그인">7. HuggingFace 로그인</h2>
<p>모델을 다운로드하려면 HuggingFace 계정 인증이 필요하다.</p>
<pre><code class="language-bash">huggingface-cli login</code></pre>
<p>토큰을 입력하면 인증이 완료된다.</p>
<hr>
<h2 id="gpu-실행-옵션-정리">GPU 실행 옵션 정리</h2>
<p>모델 로드 시 <code>device_map</code> 옵션에 따라 동작 방식이 달라진다.</p>
<table>
<thead>
<tr>
<th>옵션</th>
<th>의미</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td><code>&quot;cuda&quot;</code></td>
<td>GPU에 전체 로드</td>
<td>추천 (VRAM 충분할 때)</td>
</tr>
<tr>
<td><code>&quot;balanced&quot;</code></td>
<td>부족하면 CPU 사용</td>
<td>VRAM 부족 시 대안</td>
</tr>
<tr>
<td><code>&quot;auto&quot;</code></td>
<td>자동 선택</td>
<td>Qwen에서 지원 안 됨</td>
</tr>
</tbody></table>
<p>VRAM이 충분하다면 <code>&quot;cuda&quot;</code> 로 전체 로드하는 게 속도 면에서 가장 좋다.</p>
<hr>
<p>이제 환경이 다 갖춰졌다.</p>
<p>다음 편에서는 Qwen 모델을 실제로 내려받고 Gradio 서버까지 구축한 과정을 정리한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Out Painting 개념 + 상용 서비스 직접 써봤다]]></title>
            <link>https://velog.io/@junyoung-choe/Out-Painting-%EA%B0%9C%EB%85%90-%EC%83%81%EC%9A%A9-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%A7%81%EC%A0%91-%EC%8D%A8%EB%B4%A4%EB%8B%A4</link>
            <guid>https://velog.io/@junyoung-choe/Out-Painting-%EA%B0%9C%EB%85%90-%EC%83%81%EC%9A%A9-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%A7%81%EC%A0%91-%EC%8D%A8%EB%B4%A4%EB%8B%A4</guid>
            <pubDate>Mon, 30 Mar 2026 00:33:16 GMT</pubDate>
            <description><![CDATA[<p>Out Painting이라는 개념을 처음 접했을 때 꽤 흥미로웠다.</p>
<p>이미지를 잘라내거나 비율을 바꿔야 할 때 배경을 AI가 자연스럽게 채워준다는 거니까.</p>
<p>직접 써보기 전에 개념부터 잡고, 상용 서비스들을 하나씩 테스트해봤다.</p>
<hr>
<h2 id="out-painting이란">Out Painting이란</h2>
<p>원본 이미지의 사이즈를 변경해야 할 때, 없는 배경을 자연스럽게 생성해주는 기술이다.</p>
<p>예를 들어 16:9 이미지를 9:16으로 바꿔야 할 때, 원본 바깥 영역을 AI가 추론해서 채워준다.</p>
<hr>
<h2 id="직접-테스트해봤다">직접 테스트해봤다</h2>
<p>원본 사진에서 일부를 잘라낸 뒤, 잘린 이미지로 원본을 복원할 수 있는지 여러 서비스에서 테스트했다.</p>
<ul>
<li>원본 사진
<img src="https://velog.velcdn.com/images/junyoung-choe/post/26ecf708-d56b-49ee-8bc4-750cd7ec25c5/image.png" alt=""></li>
</ul>
<ul>
<li>원본 사진 일부 추출 (캡처)
<img src="https://velog.velcdn.com/images/junyoung-choe/post/77c97e68-138b-48f7-b674-b7893a42de0d/image.png" alt=""></li>
</ul>
<hr>
<h3 id="promeai">PromeAI</h3>
<p><a href="https://www.promeai.pro/ko">PromeAI 바로가기</a></p>
<ul>
<li>PromeAI 결과 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/8a2b2d47-9ada-4ba9-ac1f-776a17ddb5b1/image.png" alt=""></li>
</ul>
<hr>
<h3 id="pixelcut">Pixelcut</h3>
<p><a href="https://www.pixelcut.ai/uncrop/ai-outpainting">Pixelcut 바로가기</a></p>
<ul>
<li>Pixelcut 결과 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/f9d51282-674f-4319-b1b7-20e1ff982ac0/image.png" alt=""></li>
</ul>
<hr>
<h3 id="capcut">CapCut</h3>
<p><a href="https://dreamina.capcut.com/ko-kr/resource/ai-outpainting">CapCut 바로가기</a></p>
<ul>
<li>CapCut 프롬프트 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/53cc05fe-1f01-490a-ae42-7c05b82ab543/image.png" alt=""></li>
</ul>
<ul>
<li>CapCut 프롬프트 입력 후 결과 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/dafba751-50b7-4d49-9eb5-2db4f2bae246/image.png" alt=""></li>
</ul>
<p>세 서비스 모두 사진이 완벽하게 복구되진 않았고, 퀄리티가 조금씩 아쉬웠다.</p>
<p>그 중 CapCut은 프롬프트를 함께 입력할 수 있어서 다른 서비스보다 조금 더 나은 결과를 받을 수 있었다.</p>
<hr>
<h3 id="gemini">Gemini</h3>
<ul>
<li><p>Gemini Out Painting 프롬프트 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/e46a5030-bef9-4605-bb1e-6c3412cee98a/image.png" alt=""></p>
</li>
<li><p>Gemini 생성 이미지
<img src="https://velog.velcdn.com/images/junyoung-choe/post/c52375e9-b94f-4c3c-8252-ad3a07cfebbc/image.png" alt=""></p>
</li>
</ul>
<hr>
<h2 id="stable-diffusion으로도-해봤다">Stable Diffusion으로도 해봤다</h2>
<p>상용 서비스 외에 Stable Diffusion으로도 직접 돌려봤다.</p>
<ul>
<li><p>원본 산 풍경 사진
<img src="https://velog.velcdn.com/images/junyoung-choe/post/31f676bd-dc3e-46cc-a0fd-cdb35c31e05f/image.jpg" alt=""></p>
</li>
<li><p>Stable Diffusion Out Painting 설정 화면
<img src="https://velog.velcdn.com/images/junyoung-choe/post/95873f7c-2c8c-4756-897b-a2d3851980b1/image.png" alt=""></p>
</li>
<li><p>Stable Diffusion Out Painting 결과 
<img src="https://velog.velcdn.com/images/junyoung-choe/post/3a91ab5c-45a1-4c8c-8430-100a8d6e577c/image.png" alt=""></p>
</li>
</ul>
<p>완벽하게 모방하진 못하지만 어느 정도의 자연스러움은 유지하면서 확장됐다.</p>
<hr>
<h2 id="문제는-속도였다">문제는 속도였다</h2>
<p>이미지 한 장 확장에 CPU 기준으로 약 3분이 걸렸다.</p>
<p>영상으로 확장하면 어떻게 될까 계산해봤다.</p>
<table>
<thead>
<tr>
<th>fps</th>
<th>1분 영상 프레임 수</th>
</tr>
</thead>
<tbody><tr>
<td>24fps (영화)</td>
<td>1,440개</td>
</tr>
<tr>
<td>30fps (일반 영상)</td>
<td>1,800개</td>
</tr>
<tr>
<td>60fps (고화질)</td>
<td>3,600개</td>
</tr>
</tbody></table>
<p>24fps 1분짜리 클립을 Out Painting 한다고 하면:</p>
<pre><code>1,440 프레임 × 3분 = 4,320분</code></pre><p>72시간이 넘는다 !</p>
<p>GPU로 시도해봐야 한다는 결론이 나왔다.</p>
<p>과연 영상으로 이 과정을 처리할 수 있을까 ?</p>
<hr>
<p>다음 편에서는 Video Out Painting을 다룬 논문을 분석한다.</p>
<p>세로 영상을 가로로 변환하는 문제를 어떻게 접근했는지, 핵심 아이디어가 뭔지 정리해볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GPU 기초, 드라이버부터 PyTorch까지]]></title>
            <link>https://velog.io/@junyoung-choe/GPU-%EA%B8%B0%EC%B4%88-%EB%93%9C%EB%9D%BC%EC%9D%B4%EB%B2%84%EB%B6%80%ED%84%B0-PyTorch%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@junyoung-choe/GPU-%EA%B8%B0%EC%B4%88-%EB%93%9C%EB%9D%BC%EC%9D%B4%EB%B2%84%EB%B6%80%ED%84%B0-PyTorch%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Mon, 30 Mar 2026 00:24:15 GMT</pubDate>
            <description><![CDATA[<p>어느 날 팀원 중 한 명이 다음과 같은 말을 했다.</p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/84d8c32d-92cc-469b-b52c-9083bfe59625/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/cdd2f0c4-375d-418c-87fb-30c0affa9fda/image.png" alt=""></p>
<p><a href="https://www.threads.com/@choi.openai/post/DSnK8HRjy8f?hl=ko">choi.openai 님 Threads 포스트 (2025-12-24)</a></p>
<p>4개월 만에 상용 모델을 따라잡는 오픈소스라니, 직접 돌려보고 싶다는 생각이 바로 들었다.</p>
<p>그냥 API 쓰는 게 아니라, 모델이 내부적으로 어떻게 돌아가는지 직접 부딪혀보기로 했다.</p>
<p>시작하기 전에 GPU 관련 개념부터 제대로 정리해두기로 했다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/junyoung-choe/post/0d115428-0d34-4e85-95fe-71d47d306568/image.png" alt=""></p>
<h2 id="레이어-구조">레이어 구조</h2>
<p>GPU로 AI를 돌리려면 세 가지 계층이 맞춰져 있어야 한다.</p>
<pre><code>PyTorch
   ↑
CUDA
   ↑
NVIDIA 드라이버
   ↑
GPU 하드웨어</code></pre><p>위로 올라갈수록 추상화가 높아진다.</p>
<p>아래부터 하나씩 보자.</p>
<hr>
<h2 id="nvidia-드라이버">NVIDIA 드라이버</h2>
<p>&quot;운영체제 ↔ GPU 하드웨어를 이어주는 필수 소프트웨어&quot;</p>
<p>OS와 애플리케이션이 GPU에 명령을 보내고 결과를 받게 해주는 가장 낮은 레벨의 소프트웨어 계층이다.</p>
<p>OS 입장에서 이게 없으면 GPU라는 하드웨어 자체를 인식하지 못한다.</p>
<p>드라이버 = GPU 장치 드라이버 (Device Driver)</p>
<hr>
<h2 id="cuda">CUDA</h2>
<p>드라이버 위에서 돌아가는 개발용 플랫폼이다.</p>
<p>&quot;GPU를 써서 연산을 짜고 돌리게 해주는 것&quot;</p>
<p>CUDA = GPU 프로그래밍용 SDK + 런타임</p>
<p>개발자 입장에서 이게 없으면 C/Python 코드로 행렬연산이나 딥러닝 같은 걸 GPU에 던질 수 없다.</p>
<hr>
<h2 id="pytorch">PyTorch</h2>
<p>CUDA 위에서 돌아가는 고수준 프레임워크다.</p>
<p>CPU/GPU에서 텐서 연산, 자동 미분, 딥러닝 모델 구현과 학습을 담당한다.</p>
<p>GPU 가속이 필요할 때 내부적으로 CUDA를 사용한다.</p>
<p>개발자는 <code>torch.cuda</code> 모듈을 통해 CUDA를 직접 다루지 않고도 GPU를 쓸 수 있다.</p>
<hr>
<h2 id="레이어-순서-정리">레이어 순서 정리</h2>
<table>
<thead>
<tr>
<th>레이어</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>NVIDIA 드라이버</td>
<td>GPU 인식·초기화, 커널 모드 명령 처리 (가장 아래층)</td>
</tr>
<tr>
<td>CUDA 런타임 / 드라이버 API</td>
<td>드라이버 위에서 커널 실행, 메모리 할당/복사 등 GPU 연산 제어</td>
</tr>
<tr>
<td>PyTorch</td>
<td>텐서 연산·NN 모듈·옵티마이저 제공, <code>torch.cuda</code>로 CUDA 추상화</td>
</tr>
</tbody></table>
<hr>
<h2 id="서버-ami-설정">서버 AMI 설정</h2>
<p>이번에 AWS에서 아래 AMI를 선택했다.</p>
<pre><code>Deep Learning OSS Nvidia Driver AMI GPU PyTorch 2.9 (Ubuntu 24.04)</code></pre><p>이 AMI 하나로 아래가 전부 세팅된 상태로 시작한다.</p>
<ul>
<li>NVIDIA 드라이버 ✅</li>
<li>CUDA Toolkit ✅</li>
<li>cuDNN ✅</li>
<li>PyTorch 2.9 ✅</li>
<li>기타 딥러닝 라이브러리들 ✅</li>
</ul>
<p>직접 하나씩 설치하면 버전 충돌로 삽질할 가능성이 크다.</p>
<p>딥러닝 AMI를 쓰면 이 과정을 통째로 건너뛸 수 있다 !</p>
<hr>
<p>다음 편에서는 Out Painting이 뭔지, 상용 서비스들을 직접 써보면서 비교한 과정을 정리한다.</p>
]]></description>
        </item>
    </channel>
</rss>