<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>js-kim-arc.log</title>
        <link>https://velog.io/</link>
        <description>edit하는 개발자! story 있는 삶</description>
        <lastBuildDate>Tue, 16 Jun 2026 10:18:11 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>js-kim-arc.log</title>
            <url>https://velog.velcdn.com/images/js-kim-arc/profile/b2f64c8f-aba2-4e66-985b-93d1f8a77718/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. js-kim-arc.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/js-kim-arc" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[d]]></title>
            <link>https://velog.io/@js-kim-arc/d</link>
            <guid>https://velog.io/@js-kim-arc/d</guid>
            <pubDate>Tue, 16 Jun 2026 10:18:11 GMT</pubDate>
            <description><![CDATA[<p>d</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프] 클라우드 주차 트러블슈팅 TIL]]></title>
            <link>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EC%A3%BC%EC%B0%A8-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-TIL</link>
            <guid>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EC%A3%BC%EC%B0%A8-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-TIL</guid>
            <pubDate>Mon, 25 May 2026 23:45:51 GMT</pubDate>
            <description><![CDATA[<h1 id="prod-라고-부르는데-prod-가-아니었다--rds-도입-트러블슈팅과-바이브-코딩-회고">&quot;prod&quot; 라고 부르는데 prod 가 아니었다 — RDS 도입 트러블슈팅과 바이브 코딩 회고</h1>
<p>ThirdTool 에 <code>prod</code> 프로파일을 처음 도입하면서 배포를 EC2 + RDS 로 옮겼다. 그 과정에서 <strong>가장 오래 헤맨 버그 하나</strong>, <strong>&quot;만약 이게 진짜 운영 데이터가 들어 있는 stateful migration 이었다면&quot; 이라는 가상 시나리오</strong>, 그리고 <strong>AI 와 빠르게 작업하면서 생긴 바이브 코딩 회고</strong> 를 한 편으로 정리한다.</p>
<p>본 과제는 prod 프로파일 첫 도입이라 사실상 <em>빈 DB 마이그레이션</em> 이었다. 컨테이너를 교체해도 잃을 데이터가 없으니 <code>ddl-auto: update</code> 를 가벼운 마음으로 켤 수 있었다 — 바로 그 &#39;가벼운 마음&#39; 이 첫 번째 사고를 불렀다.</p>
<hr>
<h2 id="목차">목차</h2>
<blockquote>
<ul>
<li><a href="#1-%EA%B0%80%EC%9E%A5-%EC%98%A4%EB%9E%98-%ED%97%A4%EB%A7%A8-%EB%B2%84%EA%B7%B8-prod-%EB%9D%BC%EA%B3%A0-%EB%B6%80%EB%A5%B4%EB%8A%94%EB%8D%B0-prod-%EA%B0%80-%EC%95%84%EB%8B%88%EC%97%88%EB%8B%A4">1. 가장 오래 헤맨 버그, prod 라고 부르는데 prod 가 아니었다</a></li>
</ul>
</blockquote>
<ul>
<li><a href="#1-1-%EC%A6%9D%EC%83%81%EA%B3%BC-%EC%9B%90%EC%9D%B8">1-1. 증상과 원인</a></li>
<li><a href="#1-2-%EA%B5%90%ED%9B%88">1-2. 교훈</a><ul>
<li><a href="#2-%EB%A7%8C%EC%95%BD-rds-%EA%B0%80-private-subnet-%EC%9D%98-stateful-migration-%EC%9D%B4%EC%97%88%EB%8B%A4%EB%A9%B4">2. 만약 RDS 가 private subnet 의 stateful migration 이었다면</a></li>
</ul>
</li>
<li><a href="#2-1-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4-%EA%B0%80%EC%A0%95">2-1. 시나리오 가정</a></li>
<li><a href="#2-2-%EC%B2%AB-%EB%B2%88%EC%A7%B8-%ED%95%A8%EC%A0%95-ddl-auto-update-%EA%B0%80-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EA%B1%B4%EB%93%9C%EB%A6%B0%EB%8B%A4">2-2. 첫 번째 함정, ddl-auto: update 가 데이터를 건드린다</a></li>
<li><a href="#2-3-%EB%91%90-%EB%B2%88%EC%A7%B8-%ED%95%A8%EC%A0%95-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%B4%EA%B4%80%EC%9D%98-cutover">2-3. 두 번째 함정, 데이터 이관의 cutover</a></li>
<li><a href="#2-4-%EC%84%B8-%EB%B2%88%EC%A7%B8-%ED%95%A8%EC%A0%95-private-subnet-%EC%9D%98-%EB%94%94%EB%B2%84%EA%B9%85">2-4. 세 번째 함정, private subnet 의 디버깅</a></li>
<li><a href="#2-5-%EB%84%A4-%EB%B2%88%EC%A7%B8-%ED%95%A8%EC%A0%95-connection-pool-warm-up">2-5. 네 번째 함정, Connection pool warm-up</a><ul>
<li><a href="#3-%EB%B0%94%EC%9D%B4%EB%B8%8C-%EC%BD%94%EB%94%A9-%ED%9A%8C%EA%B3%A0-%EC%8A%A4%ED%8E%99%EA%B3%BC-%EC%A7%80%EC%8B%9D%EC%9D%84-%EC%96%B4%EB%94%94%EC%97%90-%EB%91%90%EB%8A%94%EA%B0%80">3. 바이브 코딩 회고, 스펙과 지식을 어디에 두는가</a></li>
</ul>
</li>
<li><a href="#3-1-%EC%BD%94%EB%93%9C%EA%B0%80-%EC%A7%84%EC%8B%A4-%EC%86%8C%EC%8A%A4-%EC%9B%90%EC%B9%99%EC%9D%98-%ED%95%9C%EA%B3%84">3-1. 코드가 진실 소스 원칙의 한계</a></li>
<li><a href="#3-2-%EB%A1%A4%EB%B0%B1%EC%9D%98-%EB%B9%84%EB%8C%80%EC%B9%AD%EC%84%B1">3-2. 롤백의 비대칭성</a></li>
<li><a href="#3-3-%EC%8A%A4%ED%8E%99%EC%9D%84-%EC%96%B4%EB%94%94%EC%97%90-%EB%91%90%EB%8A%94%EA%B0%80-adr-vs-runbook-vs-%EC%BD%94%EB%93%9C-%EC%A3%BC%EC%84%9D">3-3. 스펙을 어디에 두는가, ADR vs Runbook vs 코드 주석</a></li>
<li><a href="#3-4-%EC%A7%80%EC%8B%9D%EC%9D%98-%EC%9D%91%EC%A7%91-%EC%82%AC%EB%9E%8C%EC%9D%B4-%EA%B2%B0%EC%A0%95%ED%95%9C-%EC%9E%90%EB%A6%AC%EB%A7%8C-%EC%A0%81%EB%8A%94%EB%8B%A4">3-4. 지식의 응집, 사람이 결정한 자리만 적는다</a></li>
<li><a href="#3-5-%EB%B0%94%EC%9D%B4%EB%B8%8C-%EC%BD%94%EB%94%A9%EC%9D%98-%EC%95%88%EC%A0%84%EB%A7%9D-reviewer-5%EA%B4%80%EC%A0%90">3-5. 바이브 코딩의 안전망, Reviewer 5관점</a></li>
</ul>
<hr>
<h2 id="1-가장-오래-헤맨-버그-prod-라고-부르는데-prod-가-아니었다">1. 가장 오래 헤맨 버그, prod 라고 부르는데 prod 가 아니었다</h2>
<h3 id="1-1-증상과-원인">1-1. 증상과 원인</h3>
<p>Epic 1-3 (Docker Hub CD) 머지 직후 EC2 첫 배포가 <strong>성공</strong> 했고, <code>/actuator/health</code> 도 <code>UP</code> 이었고, 회원 가입 API 도 200 을 돌려줬다. 겉으로 보면 흠잡을 데가 없었다.</p>
<p>다만 이상한 점이 하나 있었다 — <strong>컨테이너를 한 번 교체하면 직전에 만든 회원이 사라졌다.</strong></p>
<p>원인은 단순했지만 발견까지 시간이 걸렸다. <code>application-prod.yml</code> 의 datasource 는 분명 MySQL 을 가리키고 있었다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:mysql://${DB_URL}
    driver-class-name: com.mysql.cj.jdbc.Driver</code></pre>
<p>그런데 EC2 의 <code>/home/ec2-user/.env</code> 안에는 <strong>과거에 임시로 넣어둔 줄</strong> 이 살아 있었다.</p>
<pre><code class="language-bash">SPRING_DATASOURCE_DRIVER_CLASS_NAME=org.h2.Driver
SPRING_JPA_HIBERNATE_DDL_AUTO=update</code></pre>
<p>Spring Boot 의 환경변수 binding 규칙(<code>SPRING_DATASOURCE_*</code> → <code>spring.datasource.*</code>)은 <strong>yml 보다 우선</strong> 한다. 즉 yml 은 MySQL 을 선언했고, env 는 H2 로 <strong>조용히 덮어쓰고</strong> 있었다. driver 가 H2 이므로 mysql JDBC URL 은 <em>해석조차 되지 않은 채</em> H2 in-memory 가 떴고, <code>ddl-auto: update</code> 로 매 부팅마다 schema 가 다시 만들어지면서 데이터가 새것으로 시작되었다.</p>
<p>문법적으론 정상, <strong>의미적으론 prod 가 아닌</strong> 상태가 한참 유지된 셈이다.</p>
<h3 id="1-2-교훈">1-2. 교훈</h3>
<ul>
<li><strong>fail-fast 의 부재가 가장 비싼 버그를 만든다.</strong> prod 프로파일이 H2 driver 를 받아들이지 않게 <em>명시적으로 거부</em> 해야 한다.(a매우 중요한 생각인듯) ADR-0008 의 &quot;환경변수 누락 시 부팅 실패&quot; 정책은 <em>값이 없는 경우</em> 만 다룬다 — <em>잘못된 값</em>(H2 driver 가 prod 에 와 있는 상황)은 잡지 못한다. 부팅 후 actuator 의 <code>/info</code> 또는 별도 startup probe 에서 <code>datasource.driver-class-name</code> 을 prod 일 때만 MySQL 류로 검증하는 안전망이 필요하다.</li>
<li><strong>환경별 override 가 가능한 키는 소수 정예로만 둔다.</strong> ADR-0014 의 <code>.env</code> 키 재설계(<code>DB_HOST</code>, <code>DB_PORT</code>, <code>DB_NAME</code>, <code>DB_USERNAME</code>, <code>DB_PASSWORD</code> 만)가 이 사고에 대한 응답이다. <code>SPRING_DATASOURCE_*</code> 같은 <em>Spring 전체 binding 키</em> 는 우회 통로가 되므로, .env 와 yml 사이에 <em>동등 계층의 두 진실 소스</em> 를 만들지 말 것.</li>
<li><strong>&quot;왜 작동하는지 모르지만 작동함&quot; 이 일주일 가면 부채로 굳는다.</strong> <code>actuator/health: UP</code> 만으로 정합성을 추론하지 말 것 — <code>DataSourceHealthIndicator</code> 는 <em>연결 가능성</em> 만 검증하지, <em>어느 DB 인지</em> 는 검증하지 않는다.</li>
</ul>
<hr>
<h2 id="2-만약-rds-가-private-subnet-의-stateful-migration-이었다면">2. 만약 RDS 가 private subnet 의 stateful migration 이었다면</h2>
<p>본 과제는 prod 프로파일 첫 도입이라 사실상 <strong>빈 DB 마이그레이션</strong> 이었다. 컨테이너 교체로 잃을 데이터가 없으니 <code>ddl-auto: update</code> 를 가벼운 마음으로 켤 수 있었다. 하지만 <em>실제 운영</em> 의 RDS 전환은 거의 항상 <strong>기존 데이터가 들어 있는 상태</strong> — <em>stateful migration</em> 이다. 이 시나리오를 가상으로 펴 보면 ADR-0014 가 <strong>어디서 부서지는지</strong> 가 잘 보인다.</p>
<h3 id="2-1-시나리오-가정">2-1. 시나리오 가정</h3>
<ul>
<li>운영 중인 H2 file-based DB 또는 <em>다른 region 의 MySQL</em> 에 <strong>수만 건의 회원 데이터</strong> 가 들어 있다.</li>
<li>목표: 이걸 <em>private subnet 의 RDS MySQL 8</em> 로 옮기고 <strong>순단을 최소화</strong> 한다.</li>
<li>추가 제약: 회원 가입 API 는 24/7 트래픽이 있다 — <strong>완전 중단 창구 없음</strong>.</li>
</ul>
<h3 id="2-2-첫-번째-함정-ddl-auto-update-가-데이터를-건드린다">2-2. 첫 번째 함정, ddl-auto: update 가 데이터를 건드린다</h3>
<p>ADR-0014 는 &quot;DROP 미발생, 데이터 손실 위험 0&quot; 이라 적었다. 그건 <em>현재 코드의 엔티티 정의</em> 가 <em>현재 DB schema</em> 와 <strong>일치할 때만</strong> 참이다. 마이그레이션 시점에는 이야기가 달라진다.</p>
<ul>
<li>H2 와 MySQL 의 <em>동일 컬럼 타입이 미세하게 다르다</em>. 예를 들어 H2 의 <code>VARCHAR(255)</code> 는 character semantics 인 반면, MySQL <code>utf8mb4</code> 의 <code>VARCHAR(255)</code> 는 <em>바이트가 아니라 character</em> — 같지만 다른 자리에서 깨진다.</li>
<li>Hibernate 가 <em>기존 컬럼이 자기 기대와 다르면</em> schema 를 <strong>자동으로 ALTER</strong> 한다. 데이터를 보존하면서 컬럼 타입을 바꾸는 ALTER 가 일어나는데, 이 시점에 <em>그 테이블 전체에 metadata lock</em> 이 잡힌다. <strong>대형 테이블일수록 metadata lock 이 수십 초에서 수 분 간 API 를 막는다.</strong></li>
</ul>
<p>해법은 <em>마이그레이션을 <code>ddl-auto</code> 에게 맡기지 않는 것</em> 이다. Flyway 또는 Liquibase 로 <strong>코드화된 schema</strong> 를 <em>명시적 ALTER</em> 로 적용하고, 그 ALTER 자체를 <code>ALGORITHM=INPLACE, LOCK=NONE</code> 옵션과 함께 <em>오프타임에 사전 적용</em> 한다. ADR-0014 가 Flyway 를 <em>연기</em> 한 것은 학습 단계의 단순화로 정당하지만, <strong>stateful migration 을 만나면 즉시 깨질 결정</strong> 이다.</p>
<h3 id="2-3-두-번째-함정-데이터-이관의-cutover">2-3. 두 번째 함정, 데이터 이관의 cutover</h3>
<p>H2 file → MySQL 이관은 <em>어떻게 끊는가</em> 의 문제다. 세 가지 길이 있다.</p>
<ol>
<li><strong>Stop the world</strong> — 회원 가입 API 를 막고, dump 로 풀 백업, RDS 에 import, env 변경, 재기동. <em>가장 단순</em> 하다. 다만 <em>수만 건이면 import 만 10분~1시간</em>. 그동안 API 다운.</li>
<li><strong>Dual write</strong> — 마이그레이션 윈도 동안 <em>두 DB 에 동시 write</em> 하도록 코드 분기. 읽기는 새 DB. <em>순단 0</em>. 다만 코드를 일시적으로 더럽힌다 — <code>if (migrationWindow) { writeBoth() }</code> 같은 줄이 들어가고, 끝난 뒤 <em>지우는 PR 을 따로 내야</em> 한다. 잘 지우지 않으면 부채로 남는다.</li>
<li><strong>Logical replication / CDC</strong> — Debezium / DMS 로 <em>변경 이벤트</em> 를 새 DB 에 비동기 복제. cutover 는 <em>replication lag 이 0 인 순간</em> 에 <em>DNS 또는 endpoint 만 전환</em>. 가장 순단이 짧다. 가장 인프라가 무겁다.</li>
</ol>
<p>본 과제 규모에서는 <strong>(1) stop the world</strong> 가 ROI 가 가장 크다. 다만 그러려면 <em>공지 → 트래픽 차단 → dump → restore → 검증 → 트래픽 복귀</em> 를 <strong>체크리스트로</strong> 미리 적어두어야 한다 — 이게 바로 <strong>Runbook</strong> 이다.</p>
<p>ADR 이 <em>결정</em> 을 적는 자리라면, Runbook 은 <em>어떻게 안전하게 실행하는지</em> 를 적는 자리다. 현재 repo 에는 <strong>Runbook 자리가 없다.</strong> 만약 이 과제가 stateful migration 을 포함했다면 <code>docs/runbook/</code> 디렉토리를 <em>ADR 보다 먼저</em> 만들었어야 한다.</p>
<h3 id="2-4-세-번째-함정-private-subnet-의-디버깅">2-4. 세 번째 함정, private subnet 의 디버깅</h3>
<p>public RDS 에선 로컬에서 <code>mysql -h &lt;endpoint&gt; -u ...</code> 로 <em>즉시 접속</em> 해 schema 확인이 가능하다. private 으로 옮기면 디버깅 자체가 어려워진다.</p>
<ul>
<li>로컬 PC → RDS <em>직접 접속 불가</em> (보안 그룹 + private subnet 라우팅).</li>
<li><em>bastion EC2</em> 또는 <em>SSM Session Manager Port Forwarding</em> 으로 우회.</li>
<li>둘 다 <strong>세팅이 작업의 절반</strong> — 사고가 났을 때 <em>3분 안에 schema 를 들여다보지 못한다</em>.</li>
</ul>
<p>이 비용을 <strong>마이그레이션 전에</strong> 미리 지불해 두어야 한다. ADR 형태로 풀어 쓰면 이렇게 된다.</p>
<blockquote>
<p>운영 RDS 는 private subnet 에 두되, 디버깅을 위해 <em>SSM Session Manager Port Forwarding</em> 을 항상 가능한 상태로 유지한다. bastion EC2 는 비용 0 (SSM 만 쓰면 됨). 운영자 PC 의 <code>~/.ssh/config</code> 또는 <code>.aws/config</code> 에 접속 단축어를 <em>팀 공유 README</em> 로 적는다.</p>
</blockquote>
<p><strong>마이그레이션 당일</strong> 에 이걸 처음 세팅하면 현장에서 30분이 사라진다. 평소에 <strong>늘 켜 두는 것</strong> 이 운영의 핵심이다.</p>
<hr>
<h2 id="3-바이브-코딩-회고-스펙과-지식을-어디에-두는가">3. 바이브 코딩 회고, 스펙과 지식을 어디에 두는가</h2>
<p>본 과제 전체를 <em>AI 와의 협업으로 빠르게 굴린</em> 결과 흥미로운 자리가 몇 군데 생겼다. <em>바이브 코딩</em> — 정의가 사람마다 다르지만, 본 글에선 <strong>대화로 의도를 빠르게 만들고, 검증과 정합은 사후에 추적하는 작업 방식</strong> 으로 둔다.</p>
<h3 id="3-1-코드가-진실-소스-원칙의-한계">3-1. 코드가 진실 소스 원칙의 한계</h3>
<p><code>CLAUDE.md</code> 가 명시적으로 <strong>&quot;코드가 진실 소스&quot;</strong> 라 적어 두었다. API · DB schema · 테스트 매트릭스 · 패키지 구조는 <em>별도 명세 없이</em> 코드 자체로 본다는 결정. 이게 대부분의 경우 옳다 — 문서와 코드의 <em>동기화 비용</em> 을 0 으로 만든다.</p>
<p>다만 RDS 마이그레이션처럼 <strong>코드가 코드 밖의 시스템 상태에 의존</strong> 하는 순간엔 무너진다.</p>
<ul>
<li><em>현재 RDS 의 schema</em> 는 코드 어디에도 없다. JPA 엔티티는 <em>기대</em> 일 뿐 <em>실재</em> 가 아니다.</li>
<li><em>현재 SSM Parameter Store 의 키 목록</em> 은 <code>user-data.sh</code> 의 <code>REQUIRED=</code> 줄에 <em>부분적으로만</em> 적혀 있다.</li>
<li><em>현재 RDS 의 maintenance window 시각, backup retention, multi-az 여부</em> 는 <em>Terraform 도 CloudFormation 도 없으므로</em> AWS Console 의 <em>클릭 상태</em> 가 유일한 진실 소스다.</li>
</ul>
<p>즉 <strong>코드 안에선 코드가 진실 소스이지만, 코드 밖 인프라의 진실 소스는 AWS Console 의 현재 상태</strong> 다. 이 두 진실 소스가 <em>동등하게 책임을 분담</em> 한다는 사실 자체를 어딘가에 적어두지 않으면, 6개월 뒤 누군가가 코드만 보고 &quot;RDS 도 PR 하면 되지 않나&quot; 하고 잘못 합의한다.</p>
<h3 id="3-2-롤백의-비대칭성">3-2. 롤백의 비대칭성</h3>
<p>CI/CD 가 잘 깔리면 <em>코드 롤백</em> 은 쉽다 — ADR-0013 의 SHA 태깅 전략 덕에 <code>./deploy.sh &lt;old_sha&gt;</code> 한 줄이면 끝. 하지만 <strong>DB schema 롤백</strong> 은 완전히 다른 차원의 비용이다. 컬럼 추가는 롤백 쉽지만, <em>데이터를 동반한 컬럼 삭제 / 타입 변경</em> 은 <strong>되돌릴 수 없는 행위</strong> 다. ADR 의 <em>T2: 되돌리기 비싼 결정</em> 트리거가 유독 DB 에서 강력하게 발동되는 이유다.</p>
<p>바이브 코딩의 <em>빠른 반복</em> 은 <em>코드만 다룰 때</em> 의 미덕이다. DB 가 끼는 순간 <strong>PR 한 번 = 영원</strong> 일 수 있다. 본 과제에선 <em>Flyway 를 연기</em> 한 결정(ADR-0014 Option D)이 바로 이 비대칭성에 대한 의식적 회피다 — <em>학습 단계라 데이터 손실의 비용이 0</em> 이기 때문에. 운영에선 이게 <strong>역방향</strong> 으로 작용한다. Flyway 없이 stateful migration 을 하면 <em>PR 의 결과를 되돌릴 길</em> 이 사실상 없다.</p>
<h3 id="3-3-스펙을-어디에-두는가-adr-vs-runbook-vs-코드-주석">3-3. 스펙을 어디에 두는가, ADR vs Runbook vs 코드 주석</h3>
<p>본 repo 의 ADR 14개를 다시 들여다보면 <em>결정</em> 과 <em>실행 절차</em> 가 섞여 있다.</p>
<ul>
<li><strong>결정형</strong> (ADR-0001, 0008, 0011, 0014): 왜 이걸 골랐는가 + 버려진 옵션 + 트레이드오프</li>
<li><strong>실행 절차형</strong> (ADR-0013 의 EC2 사전 준비 5단계, ADR-0014 의 <code>.env</code> 키 갱신): 어떻게 셋업하는가</li>
</ul>
<p>후자는 <strong>Runbook 의 영역</strong> 이다. ADR 안에 섞이면 두 가지가 깨진다.</p>
<ul>
<li><em>ADR 은 한 번 적고 다시 보지 않는 문서</em> 라는 통념 때문에 <strong>실행 절차가 stale 해진다</strong>. 예를 들어 ADR-0013 의 &quot;EC2 사전 준비&quot; 는 <code>user-data.sh</code> 도입 후 <em>이미 우회 가능</em> 해졌지만 ADR 본문은 그대로다.</li>
<li><em>결정의 흐름</em> 을 <em>현장 매뉴얼이 끊는다</em>. ADR 을 연대순으로 읽어 결정 흐름을 추적하려는 사람에게는 노이즈다.</li>
</ul>
<p>회고하자면 <strong>결정과 실행 절차는 같은 문서에 두지 말 것.</strong> ADR 은 <em>왜</em> 를, Runbook 은 <em>어떻게</em> 를. 본 repo 의 차기 PR 에서 <code>docs/runbook/</code> 분리를 시도할 자리다.</p>
<h3 id="3-4-지식의-응집-사람이-결정한-자리만-적는다">3-4. 지식의 응집, 사람이 결정한 자리만 적는다</h3>
<p>AI 와 빠르게 작업하면 <em>코드 안에 흔적이 남지 않는 결정</em> 이 폭발적으로 많아진다. 변수명, 메서드 시그니처, 패키지 위치, 어노테이션 선택, 로깅 한 줄. 이걸 <em>전부 ADR 에 적으면</em> 노이즈, <em>하나도 안 적으면</em> 6개월 뒤 &quot;왜 이렇게 했지&quot; 의 무덤이 된다.</p>
<p>본 repo 의 경계선은 <code>.claude/rules/adr.md</code> 의 7가지 트리거다. 다만 작업 중 체감한 추가 기준이 둘 있다.</p>
<ul>
<li><strong>AI 가 제안한 패턴을 사람이 의식적으로 거부한 순간</strong> — 그 <em>거부 자체</em> 가 결정이다. 예: &quot;Spring Cloud Config 쓰는 게 일반적입니다&quot; 라는 제안을 <em>학습 단계 단순화</em> 로 거부한 자리는 ADR-0001 → ADR-0008 로 흔적이 남았다.</li>
<li><strong>AI 가 제안하지 <em>않은</em> 옵션을 사람이 떠올려 비교한 순간</strong> — 비교가 기록되지 않으면 두 번 비교한다.</li>
</ul>
<p>후자가 특히 약하다. AI 는 <em>대안 비교</em> 를 잘 해 주지만, <em>제안하지 않은 옵션</em> 은 보이지 않는다. 사람이 &quot;Option E. 이거 안 됩니까?&quot; 라고 던지지 않으면 그 선택지는 <em>결정 트리에서 빠진 채로</em> 진행된다. ADR-0014 의 Option F(Secrets Manager)와 G(IAM 인증)는 <em>사용자가 제기해서</em> 비교에 들어간 자리다. 만약 그 제기가 없었다면 <em>기각된 적도 없는 옵션</em> 이 되어, 6개월 뒤 누군가 IAM 인증을 도입하며 &quot;왜 처음부터 안 했지&quot; 라고 묻는 자리를 만들었을 것이다.</p>
<h3 id="3-5-바이브-코딩의-안전망-reviewer-5관점">3-5. 바이브 코딩의 안전망, Reviewer 5관점</h3>
<p><code>.claude/rules/review.md</code> 의 5관점(Domain / Architecture / API·Exception / Test / Sceptical)은 <em>바이브 코딩의 빠른 사이클</em> 이 <em>놓치기 쉬운 자리</em> 에 대한 응답이다. 특히 <strong>Sceptical Reviewer</strong> — *&quot;정말 Story AC 를 충족하는가, 누락된 엣지 케이스(동시성, 부분 실패, 네트워크 실패, 타임존), 성급한 추상화, 변경 의도와 결과의 괴리&quot;* — 가 본 과제에서 <strong>가장 비싼 자리</strong> 를 막아주었다.</p>
<p>다만 솔직한 회고. 5관점 자체도 <strong>Story 단위로만 작동</strong> 한다. <em>Epic 횡단</em> 의 결정(Epic 1-3 의 CD 가 RDS 의 필요성을 <em>촉발</em> 한 자리)은 5관점이 잡지 못한다. <strong>&quot;왜 이 Epic 을 지금 하는가&quot;</strong> 는 <em>사람이 잡아야 할 자리</em> — AI 가 가장 약한 자리다.</p>
<h3 id="추후-spec-driven-아키텍처의-도입">추후) spec-driven 아키텍처의 도입</h3>
<p>최근에 자주 언급되는 kiro, spec-kit 등 spec-driven 아키텍처로 진행 방식에 대해서도 고민을 해봐야겠다. (여러 장단점을 고려한 끝에 speckit을 선택하기러 했다.)</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 RDS 도입에서 배운 건 결국 하나로 모인다. <strong>진실 소스가 코드 안에만 있지 않다는 것.</strong> 환경변수(<code>.env</code>)와 yml, JPA 엔티티와 실제 RDS schema, ADR 과 Console 의 클릭 상태 — 이 경계가 흐려지는 순간 가장 비싼 버그가 태어난다.</p>
<p>빈 DB 라서 가볍게 넘긴 <code>ddl-auto: update</code> 한 줄이, stateful migration 시나리오에선 metadata lock·되돌릴 수 없는 롤백·warm-up 비용으로 줄줄이 부서졌다. 그래서 다음 repo 의 To-Do 는 명확하다.</p>
<ol>
<li>prod 프로파일에 <strong>driver 검증 startup probe</strong> 추가 (fail-fast 강화)</li>
<li><code>docs/runbook/</code> 디렉토리 분리 — <em>결정(ADR)</em> 과 <em>실행 절차(Runbook)</em> 의 책임 분리</li>
<li>stateful migration 을 진짜로 만나기 전에 <strong>Flyway 도입 + SSM Port Forwarding 상시화</strong></li>
</ol>
<p>바이브 코딩의 속도는 <em>코드만 다룰 때</em> 의 미덕이다. DB 와 인프라가 끼는 순간, 속도보다 <strong>되돌릴 수 있는가</strong> 를 먼저 물어야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third tool] LLM 위키로 흩어지는 지식을 붙잡다 — 서브 에이전트 운영과 오케스트레이션에 대한 고민]]></title>
            <link>https://velog.io/@js-kim-arc/Third-tool-LLM-%EC%9C%84%ED%82%A4%EB%A1%9C-%ED%9D%A9%EC%96%B4%EC%A7%80%EB%8A%94-%EC%A7%80%EC%8B%9D%EC%9D%84-%EB%B6%99%EC%9E%A1%EB%8B%A4-%EC%84%9C%EB%B8%8C-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-%EC%9A%B4%EC%98%81%EA%B3%BC-%EC%98%A4%EC%BC%80%EC%8A%A4%ED%8A%B8%EB%A0%88%EC%9D%B4%EC%85%98%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EB%AF%BC</link>
            <guid>https://velog.io/@js-kim-arc/Third-tool-LLM-%EC%9C%84%ED%82%A4%EB%A1%9C-%ED%9D%A9%EC%96%B4%EC%A7%80%EB%8A%94-%EC%A7%80%EC%8B%9D%EC%9D%84-%EB%B6%99%EC%9E%A1%EB%8B%A4-%EC%84%9C%EB%B8%8C-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-%EC%9A%B4%EC%98%81%EA%B3%BC-%EC%98%A4%EC%BC%80%EC%8A%A4%ED%8A%B8%EB%A0%88%EC%9D%B4%EC%85%98%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EB%AF%BC</guid>
            <pubDate>Thu, 21 May 2026 10:33:22 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<blockquote>
<ul>
<li><a href="#%EB%AC%B8%EC%A0%9C-%EC%A0%95%EB%B3%B4%EB%8A%94-%ED%9D%98%EB%9F%AC%EA%B0%80%EA%B3%A0-%EA%B2%B0%EC%A0%95%EC%9D%98-%EB%A7%A5%EB%9D%BD%EC%9D%80-%EC%82%AC%EB%9D%BC%EC%A7%84%EB%8B%A4">문제: 정보는 흘러가고, 결정의 맥락은 사라진다</a></li>
</ul>
</blockquote>
<ul>
<li><a href="#%ED%95%B4%EA%B2%B0%EC%9D%98-%EC%B6%9C%EB%B0%9C%EC%A0%90-llm-%EC%9C%84%ED%82%A4-obsidian-%EB%B0%A9%EC%8B%9D">해결의 출발점: LLM 위키 (Obsidian 방식)</a></li>
<li><a href="#%EC%8B%9C%EB%8F%84-1-%EB%AA%A8%EB%93%A0-%EA%B1%B8-%ED%95%98%EB%82%98%EC%9D%98-claude%EB%A1%9C-%ED%86%B5%ED%95%A9%ED%95%98%EA%B8%B0-%EC%8B%A4%ED%8C%A8">시도 1: 모든 걸 하나의 Claude로 통합하기 (실패)</a></li>
<li><a href="#junction-%EC%9A%B4%EC%98%81-%EB%B0%A9%EC%8B%9D-%EB%8A%90%EC%8A%A8%ED%95%9C-%EA%B2%B0%ED%95%A9-%EA%B0%95%ED%95%9C-%EC%9D%91%EC%A7%91">Junction 운영 방식: 느슨한 결합, 강한 응집</a></li>
<li><a href="#%EC%8B%A4%EC%A0%9C%EB%A1%9C-%EC%9A%B4%EC%98%81-%EC%A4%91%EC%9D%B8-%EC%84%9C%EB%B8%8C-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8%EB%93%A4">실제로 운영 중인 서브 에이전트들</a><ul>
<li><a href="#1-ai-%ED%85%8C%ED%81%AC-%ED%86%A0%ED%94%BD-%ED%81%90%EB%A0%88%EC%9D%B4%EC%85%98-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8">1. AI 테크 토픽 큐레이션 에이전트</a></li>
<li><a href="#2-%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B0%80%EC%B9%98-%ED%95%B4%EC%84%9D-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8">2. 도메인 가치 해석 에이전트</a></li>
<li><a href="#3-%EC%97%85%EB%AC%B4-%EB%B6%84%ED%95%B4-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-product--epic--story">3. 업무 분해 에이전트 (Product → Epic → Story)</a></li>
</ul>
</li>
<li><a href="#%EC%9A%B4%EC%98%81%ED%95%98%EB%A9%B4%EC%84%9C-%EC%96%BB%EC%9D%80-%EA%B2%83">운영하면서 얻은 것</a></li>
<li><a href="#%EB%8B%A4%EC%9D%8C-%EA%B3%A0%EB%AF%BC-%EC%98%A4%EC%BC%80%EC%8A%A4%ED%8A%B8%EB%A0%88%EC%9D%B4%EC%85%98">다음 고민: 오케스트레이션</a></li>
<li><a href="#%EC%A0%95%EB%A6%AC">정리</a></li>
</ul>
<hr>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/bf1b0734-982f-4b9f-bbfc-3652467bbb8a/image.png" alt=""></p>
<h2 id="문제-정보는-흘러가고-결정의-맥락은-사라진다">문제: 정보는 흘러가고, 결정의 맥락은 사라진다</h2>
<p>매일 새로운 기술을 접하고, 수많은 선택을 하고, ADR을 쓴다. 그런데 며칠만 지나면 머릿속에 이런 질문이 떠오른다.</p>
<ul>
<li>&quot;내가 왜 이 구조를 선택했더라?&quot;</li>
<li>&quot;그때 트레이드오프 비교했던 그 자료가 어디에 있더라?&quot;</li>
<li>&quot;비슷한 문제, 분명히 전에 한 번 풀었던 것 같은데…&quot;</li>
</ul>
<p>정보는 흘러가고, 의사결정의 맥락도 함께 사라진다. 머릿속에 저장해 두려고 해도 한계가 있다. ThirdTool을 만들면서 이 문제는 점점 커졌다. JPA 내부 동작을 정리한 노트, Redisson 분산락 ADR, 키워드 카운트 비정규화 성능 실험 결과, Spring 트랜잭션 전파 패턴… 따로따로는 다 있는데, 한 달 후에 다시 보면 흩어져 있어서 한눈에 안 들어왔다.</p>
<p>거기에 또 하나의 문제가 겹쳤다. <strong>여러 에이전트를 동시에 굴리고 있다는 점.</strong></p>
<p>Claude Code로는 코드 생성과 테스트를 돌리고, Claude Desktop으로는 도메인 모델링과 문서화를 한다. 그 외에도 학습 토픽을 큐레이팅해 주는 흐름, 도메인 가치를 해석해 주는 흐름, 업무를 Product → Epic → Story로 쪼개주는 흐름이 각각 따로 돌고 있다. 산출물이 사방으로 흩어진다. <strong>이 모든 걸 한눈에 보면서, 동시에 각 에이전트의 고유한 역할은 망가뜨리지 않을 방법이 필요했다.</strong></p>
<h2 id="해결의-출발점-llm-위키-obsidian-방식">해결의 출발점: LLM 위키 (Obsidian 방식)</h2>
<p>답은 &quot;위키&quot;였다. 정확히는 Obsidian처럼, 작은 노트들이 양방향으로 연결되면서 자라나는 구조.</p>
<p>원칙은 간단하다.</p>
<ol>
<li>모든 결과물 — 학습 노트, ADR, 도메인 모델, 실험 결과 — 은 위키의 한 노드가 된다.</li>
<li>노드끼리는 링크로 연결된다.</li>
<li>각 에이전트는 자신의 영역에서 노드를 만들거나 갱신한다.</li>
<li>나는 한 군데에서 전체를 본다.</li>
</ol>
<p>말로는 쉬운데, 이걸 실제로 어떻게 구현할지가 진짜 문제였다.</p>
<h2 id="시도-1-모든-걸-하나의-claude로-통합하기-실패">시도 1: 모든 걸 하나의 Claude로 통합하기 (실패)</h2>
<p>처음에는 단순하게 갔다. <strong>Claude MCP로 모든 컨텍스트와 도구를 한 Claude에 다 연결해서, 그 Claude가 모든 걸 관리하게 하자.</strong></p>
<p>생각은 깔끔했다. 하나의 강한 에이전트가 모든 노드를 알고, 모든 서브 도메인의 작업을 처리한다. 통합 뷰가 자동으로 생긴다.</p>
<p>근데 실제로 굴려보니 문제가 터졌다.</p>
<p><strong>각 서브 에이전트의 고유한 컨셉이 무너졌다.</strong></p>
<p>원래 백엔드 학습 에이전트는 &quot;JPA, Spring, 동시성 같은 기술 토픽을 깊게 파는 사람&quot;이라는 컨셉을 가지고 있었다. 도메인 해석 에이전트는 &quot;소프트웨어 의사결정을 비즈니스 가치로 번역하는 사람&quot;이었다. 업무 분해 에이전트는 &quot;추상적인 요구사항을 실행 가능한 단위로 쪼개는 사람&quot;이었다.</p>
<p>각 에이전트는 자기만의 <code>CLAUDE.md</code>, 자기만의 룰, 자기만의 출력 포맷을 가지고 있었다. 그런데 이걸 다 합쳐버리니까, <strong>한 에이전트의 룰이 다른 에이전트의 룰을 흐릿하게 만들기 시작했다.</strong></p>
<p>기술 학습용으로 던진 질문에 갑자기 비즈니스 가치 해설이 섞여 나오고, 업무 분해 결과물에 도메인 모델링 ADR 톤이 묻어났다. 통합 트래킹이라는 이득보다 <strong>각 에이전트의 본래 역할이 흐려지는 비용</strong>이 컸다.</p>
<p>전체를 본다는 목적이 오히려 각 부분을 망친 것이다. <strong>응집도는 떨어지고, 결합도만 높아진 구조.</strong></p>
<h2 id="junction-운영-방식-느슨한-결합-강한-응집">Junction 운영 방식: 느슨한 결합, 강한 응집</h2>
<p>여기서 방향을 바꿨다. 각 에이전트는 <strong>자기 영역에 충실하게</strong> 두고, 결과물만 위키로 흘려보내는 구조.</p>
<p>DDD에서 말하는 Bounded Context와 비슷한 발상이었다. 각 컨텍스트는 자기 모델을 가진다. 컨텍스트 사이에는 명시적인 통합 지점(junction)만 둔다. 컨텍스트 안의 모델은 절대 바깥의 모델에 끌려가지 않는다.</p>
<p>이 원칙을 에이전트 운영에 그대로 옮겼다.</p>
<ul>
<li>각 서브 에이전트는 <strong>자기만의 컨셉, 룰, <code>CLAUDE.md</code>를 유지</strong>한다.</li>
<li>에이전트끼리 직접 영향을 주고받지 않는다.</li>
<li>모든 결과물은 <strong>Obsidian 노트라는 공통 포맷</strong>으로 출력된다.</li>
<li>Obsidian은 노드들을 링크로 연결해서 통합 뷰를 만든다.</li>
</ul>
<p>결과적으로 이런 그림이 됐다.</p>
<pre><code>[기술 학습 에이전트]    [도메인 해석 에이전트]    [업무 분해 에이전트]
       │                       │                        │
       ▼                       ▼                        ▼
              ─────── Obsidian (LLM 위키) ───────
                            ▲
                            │ 한 군데에서 본다
                           (나)</code></pre><p>핵심은 <strong>각 에이전트가 서로를 모른다는 점</strong>이다. 자기 일만 한다. 통합은 산출물 레이어에서 일어난다. 코드로 비유하면, 각 에이전트는 자기 인터페이스만 노출하고, 통합은 어댑터 계층에서 한다. 포트-어댑터 패턴이랑 모양이 거의 똑같다.
<img src="https://velog.velcdn.com/images/js-kim-arc/post/3f57de21-5a32-471d-8c53-574418282cf5/image.png" alt=""></p>
<h2 id="실제로-운영-중인-서브-에이전트들">실제로 운영 중인 서브 에이전트들</h2>
<h3 id="1-ai-테크-토픽-큐레이션-에이전트">1. AI 테크 토픽 큐레이션 에이전트</h3>
<p>매일 쏟아지는 백엔드/AI 분야 토픽 중에서 &quot;지금 내 학습 로드맵에 맞는 것&quot;을 골라서 정리해 주는 에이전트.</p>
<ul>
<li><strong>입력</strong>: 내 학습 로테이션(L1~L5 우선순위), 최근에 본 토픽</li>
<li><strong>출력</strong>: 오늘 볼 토픽 1~3개, 핵심 개념 요약, 깊이 파볼 만한 질문</li>
<li><strong>위키 노드 형식</strong>: <code>topics/{domain}/{topic}.md</code></li>
</ul>
<p>이 에이전트는 &quot;정보의 흐름&quot;을 다룬다. 트렌드와 기초 사이의 균형, <strong>70% 시대불문 원칙 + 30% 현재 실용 지식</strong>이라는 내 학습 철학이 룰로 박혀 있다. 그래서 &quot;오늘 핫한 토픽&quot;만 던지지 않고, 내 로테이션상 비어 있는 영역을 채우는 방향으로 큐레이션을 한다.</p>
<h3 id="2-도메인-가치-해석-에이전트">2. 도메인 가치 해석 에이전트</h3>
<p>내가 ThirdTool에서 내리는 기술적 결정 — 비정규화, 분산락, CQRS-lite 분리 같은 것들 — 을 <strong>&quot;이게 비즈니스적으로 어떤 가치를 가지는가&quot;</strong> 관점으로 다시 써 주는 에이전트.</p>
<p>예를 들어 <code>keyword_count</code> 비정규화로 160 TPS → 1,190 TPS 만든 작업. 이걸 단순히 &quot;쿼리 최적화 잘했음&quot;이 아니라, <strong>&quot;학습 콘텐츠 탐색이라는 사용자 핵심 흐름의 응답 속도를 7배 개선한 결정&quot;</strong> 으로 번역해 준다.</p>
<ul>
<li><strong>입력</strong>: ADR, 기술 선택 메모</li>
<li><strong>출력</strong>: 비즈니스 가치 문장, 트레이드오프 요약, 면접에서 말할 수 있는 한 문단</li>
<li><strong>위키 노드 형식</strong>: <code>decisions/{adr-id}.md</code>에 <code>## value</code> 섹션 추가</li>
</ul>
<p>면접 준비에 그대로 쓰이는 부분이라 효용이 크다. 45초/75초 답변 연습할 때 이 노드들이 기본 재료가 된다.</p>
<h3 id="3-업무-분해-에이전트-product-→-epic-→-story">3. 업무 분해 에이전트 (Product → Epic → Story)</h3>
<p>Claude Code로 굴리는 에이전트. 큰 요구사항을 받아서 Agile 단위로 쪼개준다.</p>
<ul>
<li><strong>입력</strong>: &quot;관리자 서버에 리뷰 통계 집계 기능 추가&quot;</li>
<li><strong>출력</strong>: Product 정의 → Epic 분할 → Story 단위, 각 Story마다 수용 기준(AC) 포함</li>
<li><strong>위키 노드 형식</strong>: <code>backlog/{product}/{epic}/{story}.md</code></li>
</ul>
<p>내가 직접 짤 때 자주 빠뜨리는 &quot;수용 기준을 먼저 명시&quot;하는 단계를 강제로 거치게 룰을 박았다. Claude Code니까 그대로 코드 생성/테스트 단계로 이어진다.</p>
<h2 id="운영하면서-얻은-것">운영하면서 얻은 것</h2>
<p>세 가지를 확실히 얻었다.</p>
<p><strong>1. 각 에이전트가 자기다워졌다.</strong> 기술 학습 에이전트는 더 깊게 파고, 도메인 해석 에이전트는 더 비즈니스적으로 말한다. 컨셉이 섞이지 않으니까 출력 품질이 올라갔다.</p>
<p><strong>2. 위키가 자라기 시작했다.</strong> 노드끼리 링크가 생기면서, 한 ADR을 클릭하면 관련 토픽, 관련 Story, 관련 가치 해석이 줄줄이 따라온다. <strong>흩어지지 않는다.</strong></p>
<p><strong>3. 메타 인지가 가능해졌다.</strong> 내가 한 달간 어떤 도메인에 시간을 썼는지, 어떤 결정의 근거가 약한지, 위키 구조만 봐도 보인다. 학습 로테이션을 어디서 보정해야 할지 데이터로 결정할 수 있게 됐다.</p>
<h2 id="다음-고민-오케스트레이션">다음 고민: 오케스트레이션</h2>
<p>여기까지 와서 새로운 문제가 보였다.</p>
<p><strong>지금은 내가 오케스트레이터다.</strong></p>
<p>어떤 에이전트를 언제 호출할지, 어떤 결과물을 어디에 묶을지, 어떤 노드가 stale 되었는지 — 다 내가 판단한다. 에이전트 수가 늘어날수록 이 부담이 커진다.</p>
<p>다음 단계는 <strong>오케스트레이션 레이어</strong>다. 위에 한 단계를 더 얹는 것.</p>
<ul>
<li>들어오는 작업이 어느 에이전트의 영역인지 라우팅</li>
<li>여러 에이전트의 출력이 필요한 작업이면 워크플로우 정의</li>
<li>위키 노드 사이의 정합성 체크 (예: ADR이 바뀌었는데 가치 해석 노드가 안 갱신된 경우)</li>
<li>학습 로테이션 같은 주기적 트리거</li>
</ul>
<p>여기서 조심해야 하는 게, <strong>시도 1의 실수를 반복하면 안 된다는 점</strong>이다. 오케스트레이터가 각 서브 에이전트의 컨셉을 침범하면 또 무너진다. <strong>오케스트레이터는 &quot;무엇을, 언제, 누구에게&quot;만 결정하고, &quot;어떻게&quot;는 절대 건드리지 않아야 한다.</strong> Bounded Context를 깨지 않는 Application Service에 가까운 역할.</p>
<p>머릿속 모델은 이렇다.</p>
<pre><code>                [오케스트레이터 / 메타 에이전트]
                          │
            ┌─────────────┼─────────────┐
            ▼             ▼             ▼
       [기술 학습]   [도메인 해석]   [업무 분해]
            │             │             │
            └──────► Obsidian (LLM 위키) ◄──────</code></pre><p>오케스트레이터는 위에 있고, 산출물은 아래에 있다. 헥사고날 아키텍처에서 Application 계층이 도메인을 조정하면서도 도메인 내부 룰은 안 건드리는 것과 같은 구조다.</p>
<h2 id="정리">정리</h2>
<p>흩어지는 정보를 붙잡는 문제에서 출발했지만, 결국 도착한 곳은 <strong>&quot;여러 지능을 어떻게 조합해서 운영할 것인가&quot;</strong> 라는 질문이었다.</p>
<ul>
<li>통합하면 무너진다 → 각 에이전트의 컨셉을 지켜라.</li>
<li>분리하면 흩어진다 → 산출물 레이어에서 통합하라.</li>
<li>사람이 다 하면 한계가 온다 → 오케스트레이션을 얹어라.</li>
<li>오케스트레이터가 욕심내면 다시 무너진다 → 조정만 하고 침범하지 마라.</li>
</ul>
<p>DDD에서 배운 원칙이 LLM 에이전트 운영에 이렇게 그대로 적용된다는 게 흥미로웠다. 결국 좋은 아키텍처는 코드든 사람이든 에이전트든 비슷한 모양으로 수렴하는 것 같다.</p>
<p>다음 글에서는 오케스트레이션을 실제로 어떻게 구현해 봤는지, 어떤 게 잘 됐고 어떤 게 안 됐는지를 풀어볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Thirdtool] 오케스트레이터가 풀어주는 5가지 문제 — Docker만으로 부족한 순간]]></title>
            <link>https://velog.io/@js-kim-arc/Thirdtool-%EC%98%A4%EC%BC%80%EC%8A%A4%ED%8A%B8%EB%A0%88%EC%9D%B4%ED%84%B0%EA%B0%80-%ED%92%80%EC%96%B4%EC%A3%BC%EB%8A%94-5%EA%B0%80%EC%A7%80-%EB%AC%B8%EC%A0%9C-Docker%EB%A7%8C%EC%9C%BC%EB%A1%9C-%EB%B6%80%EC%A1%B1%ED%95%9C-%EC%88%9C%EA%B0%84</link>
            <guid>https://velog.io/@js-kim-arc/Thirdtool-%EC%98%A4%EC%BC%80%EC%8A%A4%ED%8A%B8%EB%A0%88%EC%9D%B4%ED%84%B0%EA%B0%80-%ED%92%80%EC%96%B4%EC%A3%BC%EB%8A%94-5%EA%B0%80%EC%A7%80-%EB%AC%B8%EC%A0%9C-Docker%EB%A7%8C%EC%9C%BC%EB%A1%9C-%EB%B6%80%EC%A1%B1%ED%95%9C-%EC%88%9C%EA%B0%84</guid>
            <pubDate>Fri, 15 May 2026 11:05:48 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>컨테이너를 잘 다루는 것과, 컨테이너로 만든 시스템을 잘 운영하는 것은 완전히 다른 이야기다.</p>
</blockquote>
<hr>
<h2 id="들어가며">들어가며</h2>
<p><code>docker run</code>은 쉽다. <code>docker-compose up</code>도 쉽다. 그런데 운영 환경에 올리는 순간 <strong>사람이 손으로는 도저히 감당이 안 되는 문제들</strong>이 갑자기 쏟아진다.</p>
<p>이 글은 <strong>그 문제들이 정확히 뭐고, 오케스트레이터(Docker Swarm, Kubernetes)가 그걸 어떻게 풀어주는지</strong> 를 다룬다. 도구의 기능 나열이 아니라, <em>왜 이게 없으면 운영을 못하는가</em> 를 짚는 게 목적이다.- why 오케스트레이터</p>
<hr>
<h2 id="가정할-상황">가정할 상황</h2>
<pre><code>서버 5대, 서비스 10개, 컨테이너 30개 정도.
오케스트레이터 없이 docker 명령어만으로 운영한다고 상상해보자.</code></pre><p>이게 어떻게 망가지는지 하나씩 본다.</p>
<hr>
<h2 id="문제-1-이거-어느-서버에-띄우지">문제 1. &quot;이거 어느 서버에 띄우지?&quot;</h2>
<p>새 컨테이너를 띄울 때마다 결정해야 한다.</p>
<blockquote>
<p>Server-1은 메모리 여유가 8GB 있고, Server-2는 4GB. Server-3은 CPU가 놀고 있고, Server-4는 이미 다른 서비스가 빡빡하게 돌고 있다... 이번에 띄울 컨테이너는 메모리가 좀 필요하니까 Server-1에 띄울까?</p>
</blockquote>
<p>이걸 <strong>매번 사람이 판단한다.</strong> 서버 수가 5대 정도면 어떻게든 되지만, 20대, 50대가 되면 머리에 안 들어온다. 결과적으로:</p>
<ul>
<li>어떤 서버는 과부하로 OOM, 어떤 서버는 절반이 놀고 있음</li>
<li>누가 어디에 뭘 띄웠는지 추적이 안 됨</li>
</ul>
<h3 id="오케스트레이터가-해주는-일">오케스트레이터가 해주는 일</h3>
<pre><code class="language-bash"># Swarm
docker service create --name web --replicas 3 nginx

# Kubernetes
kubectl apply -f web-deployment.yaml</code></pre>
<p>띄우라고 말만 한다. <strong>어디에 띄울지는 오케스트레이터가 자원 상황을 보고 결정한다.</strong> 메모리·CPU 여유, 라벨 제약(<code>SSD 노드에만</code>, <code>같은 앱끼리는 다른 노드에</code>)까지 고려해서. 사람은 &quot;5개 띄워줘&quot;만 신경 쓰면 된다.</p>
<p>이게 <strong>스케줄링(scheduling)</strong> 이다.</p>
<hr>
<h2 id="문제-2-새벽-3시에-컨테이너-죽음-알림이-왔다">문제 2. &quot;새벽 3시에 컨테이너 죽음 알림이 왔다&quot;</h2>
<p>운영 중인 컨테이너가 죽는 이유는 많다. OOM, 코드 버그, 외부 의존성 일시 장애, 호스트 하드웨어 문제...</p>
<p>오케스트레이터 없이는 이 흐름이다:</p>
<pre><code>[알림 울림]
   ↓
SSH로 서버 들어감
   ↓
어느 컨테이너가 죽었는지 확인
   ↓
로그 보고 원인 파악
   ↓
docker run으로 다시 띄움 (정확한 옵션 기억해서)
   ↓
다시 잠</code></pre><p>그리고 <strong>호스트 자체가 죽으면 더 끔찍하다.</strong> 그 호스트에 있던 모든 컨테이너를 다른 호스트로 옮겨야 한다. 어디로? 어떻게?</p>
<h3 id="오케스트레이터가-해주는-일-1">오케스트레이터가 해주는 일</h3>
<p>선언만 해두면 된다 — *&quot;이 앱은 항상 3개가 떠 있어야 한다.&quot;*</p>
<pre><code class="language-yaml">replicas: 3</code></pre>
<p>그러면 시스템이 끊임없이 <em>현재 상태</em> 와 <em>원하는 상태</em> 를 비교한다.</p>
<pre><code>Desired: 3개          Actual: 3개  ✓ OK

──── 컨테이너 1개 죽음 ────

Desired: 3개          Actual: 2개  ✗
                        ↓
                   &quot;1개 더 띄워야 함&quot;
                        ↓
                  자동으로 새 컨테이너 생성
                        ↓
Desired: 3개          Actual: 3개  ✓ OK

──── 호스트 1대 죽음, 거기 있던 2개 사라짐 ────

Desired: 3개          Actual: 1개  ✗
                        ↓
                  살아있는 다른 노드에
                  2개 자동 재배치
                        ↓
Desired: 3개          Actual: 3개  ✓</code></pre><p>새벽 3시 알림이 안 온다. 시스템이 알아서 회복한다. 이게 <strong>자가 치유(self-healing)</strong> 다.</p>
<hr>
<h2 id="문제-3-order-service가-지금-어디에-있지">문제 3. &quot;order-service가 지금 어디에 있지?&quot;</h2>
<p>마이크로서비스라면 서비스끼리 서로를 호출한다. <code>payment-service</code>가 <code>order-service</code>를 호출하려고 한다. 그런데 <code>order-service</code>는...</p>
<ul>
<li>Server-3에 있다가, 죽어서 Server-7로 이동했고</li>
<li>트래픽이 늘어서 Server-2에도 추가로 떴고</li>
<li>잠시 후엔 Server-5로 또 이동할 예정이다</li>
</ul>
<p><strong>Pod의 IP는 계속 바뀐다.</strong> 그런데 <code>payment-service</code>는 매번 어디로 요청을 보내야 하는지 어떻게 아는가?</p>
<p>오케스트레이터 없이 풀려면:</p>
<ul>
<li>설정 파일에 IP 박아두기 → 바뀔 때마다 재배포 </li>
<li>DNS를 직접 관리 → 갱신 지연</li>
</ul>
<h3 id="오케스트레이터가-해주는-일-2">오케스트레이터가 해주는 일</h3>
<p>오케스트레이터에 <strong>&quot;서비스(Service)&quot; 라는 추상화</strong>가 내장돼 있다.</p>
<pre><code class="language-yaml"># K8s 예시
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector: { app: order-service }
  ports: [{port: 80}]</code></pre>
<p>이제 <code>payment-service</code>는 <strong><code>order-service</code>라는 이름으로만 부르면 된다.</strong></p>
<pre><code class="language-java">// payment-service 코드
restTemplate.getForObject(&quot;http://order-service/orders/123&quot;, Order.class);</code></pre>
<p>내부에서 무슨 일이 일어나나:</p>
<ul>
<li>오케스트레이터가 &quot;order-service라는 라벨이 붙은 Pod 목록&quot;을 항상 추적</li>
<li>Pod이 죽으면 목록에서 빼고, 새로 뜨면 추가</li>
<li>클라이언트는 안정적인 가상 IP / DNS 이름으로만 접근</li>
<li>트래픽은 살아있는 Pod에만 자동 분배</li>
</ul>
<p><strong>Pod 위치가 바뀌어도 클라이언트 코드는 한 줄도 안 바뀐다.</strong> 이게 <strong>서비스 디스커버리 + 로드밸런싱</strong> 이다.</p>
<hr>
<h2 id="문제-4-이거-어떻게-다운타임-없이-배포하지">문제 4. &quot;이거 어떻게 다운타임 없이 배포하지?&quot;</h2>
<p>새 버전을 배포해야 한다. 옵션은 두 가지.</p>
<p><strong>옵션 A: 한 번에 다 바꾸기</strong></p>
<ul>
<li>모든 컨테이너 중지 → 새 버전으로 띄움 → 시작 시간 동안 다운타임 → 새 버전 버그라면 다 같이 죽음</li>
</ul>
<p><strong>옵션 B: 한 대씩 직접 바꾸기</strong></p>
<ul>
<li>1번 인스턴스 중지 → 새 버전 띄움 → 헬스 체크 → OK → 2번 인스턴스 중지 → ...</li>
<li>사람이 30분 동안 SSH 창 들여다보며 헬스 체크 함</li>
<li>중간에 문제 생기면 어디까지 진행됐는지 추적해서 롤백</li>
<li>컨테이너 30개면? 노예 노동</li>
</ul>
<h3 id="오케스트레이터가-해주는-일-3">오케스트레이터가 해주는 일</h3>
<p>선언만 해둔다 — *&quot;한 번에 1개씩 교체하고, 새 거 ready 되면 다음 거 교체해.&quot;*</p>
<pre><code class="language-yaml">strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 0</code></pre>
<p>흐름:</p>
<pre><code>v1 v1 v1 v1 v1          (배포 시작)
   ↓
v1 v1 v1 v1 v1 v2       (v2 1개 추가)
   ↓ (v2 ready 됨)
v1 v1 v1 v1 v2          (v1 1개 제거)
   ↓
v1 v1 v1 v2 v2          ... 반복 ...
   ↓
v2 v2 v2 v2 v2          (완료, 다운타임 0)</code></pre><p>그리고 <strong>새 버전이 ready 못 받으면 자동으로 멈춘다.</strong> 롤백 명령 한 줄로 이전 버전으로 돌아갈 수 있다.</p>
<pre><code class="language-bash">kubectl rollout undo deployment/order-service</code></pre>
<p>새벽 SSH 노동이 한 줄짜리 선언으로 바뀐다. 이게 <strong>롤링 업데이트(rolling update)</strong> 다.</p>
<hr>
<h2 id="문제-5-갑자기-트래픽이-10배-뛰면-어떡하지">문제 5. &quot;갑자기 트래픽이 10배 뛰면 어떡하지?&quot;</h2>
<p>평소 3개 인스턴스로 충분한 서비스가 있다. 그런데 이벤트가 시작되면 10배 트래픽이 몰린다.</p>
<p>선택지는?</p>
<p><strong>A. 평소에도 많이 띄워두기</strong></p>
<ul>
<li>자원 낭비. 99%의 시간 동안 안 쓰는 인스턴스에 비용 지불.</li>
</ul>
<p><strong>B. 이벤트 직전에 사람이 늘리기</strong></p>
<ul>
<li>트래픽 시작 시점 예측 못하면 장애.</li>
<li>끝나면 또 줄여야 함. 사람이 매번 챙겨야 함.</li>
</ul>
<p><strong>C. 트래픽 보고 자동으로 늘리고 줄이기</strong></p>
<ul>
<li>메트릭 수집, 임계치 판단, 인스턴스 생성/제거... 직접 구현하면 그 자체가 또 하나의 시스템.</li>
</ul>
<h3 id="오케스트레이터가-해주는-일-4">오케스트레이터가 해주는 일</h3>
<p>CPU 사용률, 메모리, 커스텀 메트릭(요청 수, 큐 길이 등) 기반으로 자동 스케일링이 내장돼 있다.</p>
<pre><code class="language-yaml"># K8s HPA 예시
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  minReplicas: 3
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }</code></pre>
<p>CPU 평균이 70% 넘으면 자동으로 Pod을 늘리고, 떨어지면 줄인다. <strong>이벤트가 끝나면 다시 3개로 줄어든다.</strong> 사람이 안 끼어들어도 된다.</p>
<p>이게 <strong>오토스케일링</strong> 이다.</p>
<hr>
<h2 id="정리-다섯-문제의-공통점">정리: 다섯 문제의 공통점</h2>
<p>각 문제를 다시 보면 패턴이 보인다.</p>
<table>
<thead>
<tr>
<th>문제</th>
<th>사람이 풀어야 하던 것</th>
<th>오케스트레이터의 해결</th>
</tr>
</thead>
<tbody><tr>
<td>어디에 띄울지</td>
<td>매번 사람이 결정</td>
<td>스케줄러가 자동 배치</td>
</tr>
<tr>
<td>죽으면 살리기</td>
<td>SSH로 재시작</td>
<td>자동 재시작/재배치</td>
</tr>
<tr>
<td>서비스 위치 추적</td>
<td>설정 파일에 IP 박기</td>
<td>서비스 디스커버리</td>
</tr>
<tr>
<td>무중단 배포</td>
<td>한 대씩 수동 교체</td>
<td>자동 롤링 업데이트</td>
</tr>
<tr>
<td>트래픽 변동</td>
<td>미리 띄우거나 장애</td>
<td>자동 스케일링</td>
</tr>
</tbody></table>
<p>공통점은 하나다.</p>
<blockquote>
<p><strong>&quot;원하는 상태&quot;만 선언하면, 시스템이 끊임없이 현재 상태와 비교하면서 자동으로 맞춰준다.</strong></p>
</blockquote>
<p>사람이 매번 명령을 내리는 게 아니라, &quot;이렇게 되어 있어야 한다&quot;고 한 번 선언하면 끝이다. 그 뒤로는 시스템이 알아서 desired state를 유지한다.</p>
<p>이게 오케스트레이터의 본질이다.</p>
<hr>
<h2 id="docker-swarm과-kubernetes의-차이">Docker Swarm과 Kubernetes의 차이</h2>
<p>위 5가지 문제를 <strong>둘 다 풀어준다.</strong> 그럼 뭐가 다른가?</p>
<h3 id="docker-swarm">Docker Swarm</h3>
<p>Docker가 직접 만든 오케스트레이터. <strong>Docker 명령어를 그대로 확장한 형태</strong>다.</p>
<pre><code class="language-bash">docker service create --name web --replicas 3 nginx
docker service scale web=5
docker service update --image nginx:1.25 web</code></pre>
<p><code>docker run</code>을 알면 학습 곡선이 거의 없다. 작은 클러스터, 작은 팀에 잘 맞는다. 위 5가지 문제는 이걸로 충분히 풀린다.</p>
<h3 id="kubernetes">Kubernetes</h3>
<p>Google이 내부 시스템(Borg)의 경험을 일반화한 플랫폼. Swarm과 결정적으로 다른 두 가지:</p>
<p><strong>(1) 추상화의 풍부함</strong></p>
<ul>
<li>Swarm의 <code>service</code> 하나로 표현 어려운 워크로드가 많다 — <em>노드마다 하나씩 떠야 하는 로그 수집기</em>, <em>일회성 배치 작업</em>, <em>순서 보장이 필요한 stateful 시스템</em>, <em>주기적으로 도는 크론 작업</em>...</li>
<li>K8s는 이걸 각각 <code>DaemonSet</code>, <code>Job</code>, <code>StatefulSet</code>, <code>CronJob</code> 같은 별도 리소스로 가진다.</li>
</ul>
<p><strong>(2) 확장 가능한 플랫폼</strong></p>
<ul>
<li>Swarm은 Docker가 만든 닫힌 시스템이다.</li>
<li>K8s는 <strong>누구나 새 리소스 타입(CRD)과 새 컨트롤러를 만들어 얹을 수 있다.</strong></li>
<li>이 차이가 만들어낸 결과 — Helm, ArgoCD, Istio, Prometheus, Cert-Manager, 각종 DB Operator... <strong>모든 운영 도구의 생태계가 K8s 위에 만들어졌다.</strong></li>
</ul>
<p>Swarm은 단순함을 무기로 했지만, 결국 이 생태계 격차를 따라잡지 못했다. CNCF 표준이 되고 AWS/GCP/Azure가 모두 매니지드 K8s를 제공하면서 <strong>사실상 표준의 자리</strong>는 K8s가 가져갔다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이 글에서 하고 싶었던 건 하나다.</p>
<blockquote>
<p>컨테이너를 잘 띄울 줄 안다고 끝이 아니다.
<strong>여러 컨테이너가 여러 호스트에서 동시에 살아 움직이는 시스템</strong>을 만들려면, 사람이 매번 결정하던 일들을 시스템이 자동으로 처리하게 만들어야 한다.</p>
<p>오케스트레이터가 푸는 건 결국 그 모든 자동화의 묶음이다.</p>
</blockquote>
<p>처음 K8s를 봤을 때 <em>왜 이렇게 복잡한 게 표준이 됐지?</em> 싶었는데, 위 5가지 문제를 하나씩 직접 만나보고 나서야 <em>아, 이걸 자동으로 풀어주는 시스템이 없으면 정말 안 되겠구나</em> 가 느껴졌다. 도구가 복잡한 게 아니라, <strong>풀어야 하는 문제 자체가 복잡한 거였다.</strong></p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third tool] 컨테이너 시대에도 VM이 살아있는 이유]]></title>
            <link>https://velog.io/@js-kim-arc/Third-tool-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%8B%9C%EB%8C%80%EC%97%90%EB%8F%84-VM%EC%9D%B4-%EC%82%B4%EC%95%84%EC%9E%88%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@js-kim-arc/Third-tool-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%8B%9C%EB%8C%80%EC%97%90%EB%8F%84-VM%EC%9D%B4-%EC%82%B4%EC%95%84%EC%9E%88%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Wed, 13 May 2026 11:13:56 GMT</pubDate>
            <description><![CDATA[<h1 id="컨테이너-시대에도-vm이-살아있는-이유">컨테이너 시대에도 VM이 살아있는 이유</h1>
<blockquote>
<p>&quot;도커 쓰면 되는 거 아닌가요?&quot;</p>
<p>Spring Boot 프로젝트를 인프라에 올리면서 자연스럽게 들었던 질문이다. 그런데 성원튜터님 강의를 들으며 자연스러운 인사이트를 얻게되었다.. <strong>EC2(가상머신) 위에 ECS(컨테이너)를 띄운다</strong>. 컨테이너가 그렇게 좋다면 왜 굳이 VM 위에 또 컨테이너를?</p>
</blockquote>
<p>이 질문에서 출발해서 VM과 컨테이너의 본질적 차이, 그리고 <strong>언제 무엇을 선택해야 하는지</strong> 정리해봤다.</p>
<h2 id="1-본질적-차이-어디까지-가상화하느냐">1. 본질적 차이: 어디까지 가상화하느냐</h2>
<p>가장 흔한 오해부터 풀고 가자. &quot;VM은 무겁고 컨테이너는 가볍다&quot;는 결과일 뿐, 원인이 아니다. 진짜 차이는 <strong>가상화의 계층</strong>에 있다.</p>
<h3 id="vm의-구조">VM의 구조</h3>
<pre><code>┌──────────────────────────────────┐
│        VM 1          │   VM 2    │
│  ┌────────────────┐  │ ┌───────┐ │
│  │  App           │  │ │ App   │ │
│  ├────────────────┤  │ ├───────┤ │
│  │  Libs/Runtime  │  │ │ Libs  │ │
│  ├────────────────┤  │ ├───────┤ │
│  │  Guest OS      │  │ │ Guest │ │  ← 각자 OS 풀세트
│  │  (Linux 풀)    │  │ │ OS    │ │     커널 포함
│  └────────────────┘  │ └───────┘ │
├──────────────────────────────────┤
│      Hypervisor (가상화 계층)    │
├──────────────────────────────────┤
│         Host OS                  │
├──────────────────────────────────┤
│         Hardware                 │
└──────────────────────────────────┘</code></pre><p>VM은 <strong>하드웨어 수준을 가상화</strong>한다. 각 VM이 자기 OS 커널을 완전히 가진 채로 돌아간다. Windows 위에 Linux VM, Linux 위에 Windows VM도 가능한 이유.</p>
<h3 id="컨테이너의-구조">컨테이너의 구조</h3>
<pre><code>┌──────────────────────────────────┐
│  Container 1  │  Container 2     │
│  ┌─────────┐  │  ┌─────────┐    │
│  │  App    │  │  │  App    │    │
│  ├─────────┤  │  ├─────────┤    │
│  │  Libs   │  │  │  Libs   │    │  ← OS 없음
│  └─────────┘  │  └─────────┘    │     앱 + 의존성만
├──────────────────────────────────┤
│  Container Runtime (Docker)      │
├──────────────────────────────────┤
│         Host OS Kernel           │  ← 호스트 커널 공유
├──────────────────────────────────┤
│         Hardware                 │
└──────────────────────────────────┘</code></pre><p>컨테이너는 <strong>OS 프로세스 수준에서 격리</strong>한다. 자기 OS가 없고 호스트의 커널을 공유한다. 본질적으로 &quot;잘 격리된 프로세스&quot; 수준의 가벼운 가상화.</p>
<blockquote>
<p>💡 <strong>그래서 Docker Desktop은 Mac에서 어떻게 Linux 컨테이너를 돌리지?</strong></p>
<p>답: macOS 위에 경량 Linux VM 한 대를 띄우고, 그 VM 안에서 Docker를 돌린다. 결국 <strong>컨테이너 ⊂ VM</strong>이라는 구조. 컨테이너는 호스트 커널을 공유하니까, Linux 커널이 필요하면 VM으로 한 번 감싸야 한다.</p>
</blockquote>
<h2 id="2-비교표">2. 비교표</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>VM</th>
<th>Container</th>
</tr>
</thead>
<tbody><tr>
<td>가상화 수준</td>
<td>하드웨어</td>
<td>OS 프로세스</td>
</tr>
<tr>
<td>부팅 시간</td>
<td>1~3분</td>
<td><strong>1~3초</strong></td>
</tr>
<tr>
<td>이미지 크기</td>
<td>수 GB</td>
<td>수십 MB~수백 MB</td>
</tr>
<tr>
<td>리소스 오버헤드</td>
<td>10~30%</td>
<td><strong>1~5%</strong></td>
</tr>
<tr>
<td>격리 수준</td>
<td><strong>강함</strong></td>
<td>중간</td>
</tr>
<tr>
<td>보안 경계</td>
<td><strong>강함</strong></td>
<td>약함</td>
</tr>
<tr>
<td>OS 자유도</td>
<td>뭐든 가능</td>
<td>호스트 커널과 호환</td>
</tr>
<tr>
<td>상태 유지</td>
<td>영구 (기본)</td>
<td>휘발성 (기본)</td>
</tr>
<tr>
<td>적합한 수명</td>
<td>길게 (월~년)</td>
<td>짧게 (분~일)</td>
</tr>
</tbody></table>
<p>부팅이 100배 빠르고 이미지가 10분의 1이라는 건 단순한 효율 문제가 아니다. <strong>이게 가능한 아키텍처와 불가능한 아키텍처를 가른다</strong>. 오토스케일링, 마이크로서비스, CI/CD 같은 게 다 이 차이에서 나온다.</p>
<h2 id="3-그래서-vm은-언제-쓰는가">3. 그래서 VM은 언제 쓰는가</h2>
<p>컨테이너 시대에도 VM이 사라지지 않는 데는 명확한 이유가 있다.</p>
<h3 id="3-1-강한-격리가-필요할-때-⭐">3-1. 강한 격리가 필요할 때 ⭐</h3>
<p>이게 진짜 핵심이다. 컨테이너는 커널을 공유한다. 즉 <strong>커널 익스플로잇 하나면 같은 호스트의 모든 컨테이너가 뚫린다</strong>. 이를 <em>container escape</em> 문제라고 한다.</p>
<p>그래서 다음과 같은 상황에선 VM이 답이다:</p>
<ul>
<li><strong>멀티테넌트 클라우드</strong>: AWS EC2가 왜 VM 기반인가? 고객 A의 인스턴스와 고객 B의 인스턴스가 같은 물리 서버에 있어도 <strong>절대 침범할 수 없음을 보장</strong>해야 하니까. 컨테이너만으로는 이 신뢰가 안 잡힌다.(확고한 보안이 필요할 때)</li>
<li><strong>신뢰 안 가는 코드 실행</strong>: 코딩 테스트 채점 서버, 보안 연구용 샌드박스 등.</li>
<li><strong>규제 산업</strong>: 금융, 의료, 정부. 강한 격리가 법적 요구사항.</li>
</ul>
<h3 id="3-2-os-다양성이-필요할-때">3-2. OS 다양성이 필요할 때</h3>
<p>컨테이너는 호스트 커널에 묶인다. Linux 호스트에선 Linux 컨테이너만 돈다. 하지만 VM은 자기 OS를 통째로 가지니까 자유롭다.</p>
<ul>
<li>같은 서버에 Windows VM + Linux VM 동시 운영</li>
<li>레거시 Windows Server 2008 같은 골동품 OS 살리기</li>
<li>특정 커널 버전이 필요한 워크로드</li>
</ul>
<h3 id="3-3-무거운-stateful-워크로드">3-3. 무거운 stateful 워크로드</h3>
<p>컨테이너의 철학은 *&quot;휘발성&quot;<em>이다. 컨테이너가 죽으면 그 안의 데이터도 같이 사라지는 게 기본. 볼륨 마운트, 영구 볼륨(PV) 같은 추가 작업으로 데이터를 살릴 순 있지만, 본질적으로 *</em>stateful 워크로드와 궁합이 안 좋다**.</p>
<p>그래서 다음은 여전히 VM 기반이 많다:</p>
<ul>
<li>대용량 DB 메인 인스턴스</li>
<li>ERP 같은 모놀리식 엔터프라이즈 앱</li>
<li>장기간 떠 있어야 하는 시스템</li>
</ul>
<blockquote>
<p>그래서 AWS RDS도 결국 내부적으론 VM 기반이다. *&quot;관리형 서비스&quot;*라는 추상화로 가려져 있을 뿐.</p>
</blockquote>
<h3 id="3-4-하드웨어에-가까운-작업">3-4. 하드웨어에 가까운 작업</h3>
<ul>
<li>커널 모듈 로딩이 필요한 작업</li>
<li>특정 디바이스 드라이버 직접 접근</li>
<li>실시간성이 극도로 중요한 워크로드</li>
</ul>
<h2 id="4-컨테이너가-빛나는-순간">4. 컨테이너가 빛나는 순간</h2>
<p>반대로 컨테이너가 명확하게 답인 경우들.</p>
<h3 id="4-1-무상태-웹-서비스">4-1. 무상태 웹 서비스</h3>
<p>Spring Boot REST API, Next.js 앱처럼 *&quot;요청 받고 응답 주고 끝&quot;* 인 서비스. 상태가 없으니 죽어도 새로 띄우면 그만이다.</p>
<h3 id="4-2-마이크로서비스">4-2. 마이크로서비스</h3>
<p>서비스가 수십~수백 개 있을 때 VM 수백 개를 띄우면 OS 수백 개분 자원이 통째로 날아간다. 컨테이너는 OS 한 개를 공유하니까 자원 효율이 차원이 다르다.</p>
<h3 id="4-3-오토스케일링">4-3. 오토스케일링</h3>
<p>트래픽 몰릴 때 1분 안에 인스턴스 100개로 늘리려면 컨테이너 아니면 불가능하다. VM 부팅 1~3분 × 100개 = 답이 없다.</p>
<h3 id="4-4-환경-일관성-내-컴퓨터에선-되는데요-박멸">4-4. 환경 일관성 (&quot;내 컴퓨터에선 되는데요&quot; 박멸)</h3>
<p><code>Dockerfile</code>에 OS 라이브러리, 런타임 버전, 환경 변수까지 다 박힌다. 개발자 노트북 = 스테이징 = 프로덕션 환경이 비트 단위로 동일해진다. 이거 하나만으로도 컨테이너의 가치는 충분하다.</p>
<h3 id="4-5-cicd-자동화">4-5. CI/CD 자동화</h3>
<p>일회성 빌드 환경, 테스트 격리, 배포 자동화 — 컨테이너의 빠른 시작과 격리가 빛난다.</p>
<h2 id="5-현실-둘이-같이-산다">5. 현실: 둘이 같이 산다</h2>
<p>이게 글의 핵심이다. <strong>VM vs 컨테이너 = 양자택일이 아니다.</strong></p>
<h3 id="표준-패턴-vm-위에-컨테이너">표준 패턴: VM 위에 컨테이너</h3>
<p>AWS의 사실상 표준 구조:</p>
<pre><code>[ EC2 (VM) ]
    └── [ ECS / EKS 노드 ]
            └── [ Docker 컨테이너 N개 (Spring Boot, Redis, ...) ]</code></pre><ul>
<li><strong>외부 레이어(VM)</strong>: AWS가 고객 간 강한 격리 보장</li>
<li><strong>내부 레이어(컨테이너)</strong>: 고객이 빠른 배포와 스케일링 활용</li>
</ul>
<p>양쪽 장점을 다 가져가는 구조다.</p>
<h2 id="5선택">5.선택</h2>
<p>내가 만드는 시스템에 무엇을 써야 할지 결정하는 기준.</p>
<p><strong>컨테이너가 답인 경우</strong></p>
<ul>
<li>✅ 무상태 웹 서비스</li>
<li>✅ 마이크로서비스 아키텍처</li>
<li>✅ 빠른 배포가 중요 (하루 여러 번 배포)</li>
<li>✅ 오토스케일링 필수</li>
<li>✅ 개발 환경 통일 (Spring Boot + MySQL + Redis)</li>
</ul>
<p><strong>VM이 답인 경우</strong></p>
<ul>
<li>✅ 멀티테넌트 — 고객 간 강한 격리</li>
<li>✅ 신뢰 안 가는 코드 실행</li>
<li>✅ Windows 워크로드, 레거시 OS</li>
<li>✅ 규제 산업</li>
<li>✅ 장기간 떠 있어야 하는 stateful 시스템</li>
</ul>
<p><strong>둘 다 쓰는 경우 (대부분 현실)</strong></p>
<ul>
<li>✅ 클라우드 표준 — VM이 토대, 컨테이너가 앱</li>
</ul>
<h2 id="6-정리하며">6. 정리하며</h2>
<p>처음 가졌던 질문 — *&quot;컨테이너 쓰면 되는 거 아닌가?&quot;* — 에 답해본다.</p>
<p><strong>컨테이너의 시대에 VM이 죽은 게 아니라, VM이 컨테이너의 든든한 토대가 되어주는 시대다.</strong></p>
<p>겉으로 보이는 건 컨테이너 오케스트레이션이지만, 그 아래엔 VM이 깔려 있다. 컨테이너가 빠른 배포와 효율을, VM이 강한 격리와 안정성을 책임진다. <strong>각자가 잘하는 영역을 맡고 있는 것</strong>일 뿐.</p>
<p>도구를 선택할 때 *&quot;요즘 트렌드&quot;* 가 아니라 <strong>내 워크로드가 무엇을 요구하는가</strong>로 출발해야 하는 이유다. 무상태 마이크로서비스라면 컨테이너, 강한 격리가 필요하면 VM, 둘 다 필요하면 같이 쓰면 된다.</p>
<p>기술 선택의 첫 질문은 항상 같다:</p>
<blockquote>
<p>&quot;내가 풀려는 문제는 무엇이고, 그 문제는 어떤 제약을 요구하는가?&quot;</p>
</blockquote>
<h2 id="참고하면-좋은-자료">참고하면 좋은 자료</h2>
<ul>
<li><a href="https://www.docker.com/resources/what-container/">Docker 공식 — Container vs VM</a></li>
<li><a href="https://aws.amazon.com/">AWS — EC2 vs ECS vs Lambda</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프] 리팩토링 과제 트러블슈팅]]></title>
            <link>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EA%B3%BC%EC%A0%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EA%B3%BC%EC%A0%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Sun, 10 May 2026 23:50:45 GMT</pubDate>
            <description><![CDATA[<h1 id="테스트가-실패할-때-무엇을-고쳐야-하는가--4개의-실패-테스트가-알려준-것">테스트가 실패할 때, 무엇을 고쳐야 하는가 — 4개의 실패 테스트가 알려준 것</h1>
<blockquote>
<p>리팩토링 과제 마지막 단계에서 만난 4개의 실패 테스트. 그중 3개는 테스트 코드를, 1개는 서비스 로직을 고쳐야 했습니다. 그 구분이 어떻게 결정되는지 정리했습니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>Expert 프로젝트 리팩토링의 마지막 단계는 &quot;테스트 정합성 확보&quot;였다. 깨져 있는 테스트 4개를 정상 상태로 만들어야 했다.</p>
<p>처음엔 단순한 작업으로 보였다. 테스트가 실패하면 테스트를 고치면 되니까. 그런데 케이스를 하나씩 들여다보면서, <strong>&quot;테스트가 실패한다&quot;는 같은 증상이 네 가지 서로 다른 원인을 갖고 있다</strong>는 걸 알게 됐다.</p>
<p>그중 한 케이스는 &quot;테스트 코드가 맞고, <strong>서비스 로직을 고쳐야 하는</strong>&quot; 경우였다.</p>
<p>이 글은 그 네 가지를 어떻게 구분했는지에 대한 기록이다.</p>
<h2 id="케이스-1-인자-순서가-뒤바뀐-단순-오류">케이스 1: 인자 순서가 뒤바뀐 단순 오류</h2>
<pre><code class="language-java">@Test
void matches_메서드가_정상적으로_동작한다() {
    String rawPassword = &quot;raw&quot;;
    String encodedPassword = passwordEncoder.encode(rawPassword);

    boolean result = passwordEncoder.matches(encodedPassword, rawPassword);
    //                                       ^^^^^^^^^^^^^^^  ^^^^^^^^^^^
    //                                       인자 순서가 반대

    assertTrue(result);
}</code></pre>
<p><code>PasswordEncoder.matches(rawPassword, encodedPassword)</code> 시그니처에서 인자 순서가 뒤바뀌어 있었다.</p>
<p>테스트가 검증하려는 의도(&quot;encode 후 matches로 비교하면 true&quot;)는 명확하고 그 의도는 옳다. <strong>잘못된 건 그 의도를 구현한 코드 한 줄</strong>이다.</p>
<p>→ <strong>테스트 코드 수정</strong>.</p>
<p>가장 단순한 케이스. 의사결정할 게 거의 없다.</p>
<h2 id="케이스-2-메서드명과-실제-검증이-어긋난-경우">케이스 2: 메서드명과 실제 검증이 어긋난 경우</h2>
<pre><code class="language-java">@Test
void manager_목록_조회_시_Todo가_없다면_NPE_에러를_던진다() {
    // ...
    assertThrows(InvalidRequestException.class, () -&gt;
        managerService.getManagers(invalidTodoId)
    );
}</code></pre>
<p>메서드명은 <code>NPE_에러를_던진다</code>인데 실제 검증 대상은 <code>InvalidRequestException</code>이다. 그런데 어느 쪽이 맞는가?</p>
<p>서비스 코드를 봤다. Todo를 찾지 못하면 <code>InvalidRequestException</code>을 던지고 있었다. <strong>실제 동작과 메서드명이 어긋난 상태</strong>.</p>
<p>여기서 한 번 더 결정해야 한다.</p>
<ul>
<li><strong>A) 서비스를 NPE로 던지도록 바꾼다.</strong> 도메인 예외를 죽이는 나쁜 선택.</li>
<li><strong>B) 메서드명을 실제 검증과 일치하도록 바꾼다.</strong> 올바른 선택.</li>
</ul>
<p>→ <strong>테스트 코드(메서드명) 수정</strong>.</p>
<pre><code class="language-java">@Test
void manager_목록_조회_시_Todo가_없다면_InvalidRequestException을_던진다() {
    // ...
}</code></pre>
<p>여기서 배운 것: <strong>메서드명은 테스트의 의도를 설명하는 문서다.</strong> 메서드명과 검증 내용이 다르면, 둘 중 하나가 거짓말을 하고 있는 거다. 보통은 메서드명이 옛 의도의 잔재로 남아 있는 경우가 많다 — 처음엔 정말 NPE를 던지는 코드였다가, 도메인 예외로 개선되는 과정에서 테스트 이름이 따라오지 못한 거다.</p>
<h2 id="케이스-3-mock-setup이-실제-흐름과-어긋난-경우">케이스 3: mock setup이 실제 흐름과 어긋난 경우</h2>
<pre><code class="language-java">@Test
void comment_등록_중_할일을_찾지_못해_에러가_발생한다() {
    given(todoRepository.findById(any())).willReturn(Optional.empty());
    // ...
}</code></pre>
<p>실제 서비스 코드는 <code>todoRepository.findByIdWithUser()</code> 같은 다른 메서드를 호출하고 있다면? <code>findById</code>에 대한 stubbing은 매칭되지 않는다. 결과적으로 mock이 기본값(빈 <code>Optional</code> 또는 null)을 반환하지만, <strong>테스트가 의도한 시나리오와 다른 경로</strong>로 흘러간다.</p>
<p>이 경우 무엇이 맞는가?</p>
<p>서비스 로직이 의도적으로 <code>findByIdWithUser</code>를 쓰고 있다면, 그건 N+1 회피나 다른 이유로 선택된 합리적인 설계다. 테스트가 mock stubbing을 실제 호출에 맞춰 갱신해야 한다.</p>
<p>→ <strong>테스트 코드 수정</strong>.</p>
<pre><code class="language-java">given(todoRepository.findByIdWithUser(any())).willReturn(Optional.empty());</code></pre>
<p>이 케이스에서 한 번 더 곱씹어둘 만한 건: <strong>mock 기반 테스트는 &quot;서비스가 어떤 협력자에게 어떤 메시지를 보낸다&quot;는 가정을 그대로 박제한다는 것.</strong> 서비스 내부가 바뀌면 그 가정도 함께 깨진다. 테스트가 깨졌다는 건 &quot;내부가 바뀌었다&quot;는 신호이기도 하다.</p>
<h2 id="케이스-4-테스트는-맞고-서비스-로직이-가정을-깬-경우">케이스 4: 테스트는 맞고, 서비스 로직이 가정을 깬 경우</h2>
<p>여기가 핵심이다.</p>
<pre><code class="language-java">@Test
void todo의_user가_null인_경우_예외가_발생한다() {
    Todo todo = new Todo(...);
    ReflectionTestUtils.setField(todo, &quot;user&quot;, null);
    given(todoRepository.findById(any())).willReturn(Optional.of(todo));

    assertThrows(InvalidRequestException.class, () -&gt;
        managerService.saveManager(authUser, todoId, request)
    );
}</code></pre>
<p>이 테스트는 <strong>&quot;Todo의 user가 null이면 도메인 예외를 던진다&quot;</strong>를 검증한다. 의미 있는 시나리오다. Todo가 사용자 없이 존재하는 비정상 상태를 명시적으로 다루겠다는 의도.</p>
<p>서비스 코드를 봤다.</p>
<pre><code class="language-java">public ManagerResponse saveManager(AuthUser authUser, long todoId, ...) {
    Todo todo = todoRepository.findById(todoId)
        .orElseThrow(...);

    if (!ObjectUtils.nullSafeEquals(todo.getUser().getId(), authUser.getId())) {
        // ^ todo.getUser()가 null이면 여기서 NPE 발생
        throw new InvalidRequestException(&quot;담당자 권한이 없습니다.&quot;);
    }
    // ...
}</code></pre>
<p>팀원이 <code>getUser()</code> 결과에 대한 null 체크 없이 권한 검증을 작성했다. 테스트는 이 상황을 예상하고 도메인 예외를 기대하지만, 실제로는 NPE가 발생한다.</p>
<p>여기서 무엇을 고칠 것인가?</p>
<ul>
<li><strong>A) 테스트를 NPE 검증으로 바꾼다.</strong> 의미 있는 도메인 예외를 NPE로 후퇴시키는 나쁜 선택.</li>
<li><strong>B) 서비스에 null 체크를 추가한다.</strong> 테스트가 표현한 의도를 코드가 따라가도록.</li>
</ul>
<pre><code class="language-java">public ManagerResponse saveManager(AuthUser authUser, long todoId, ...) {
    Todo todo = todoRepository.findById(todoId)
        .orElseThrow(...);

    if (todo.getUser() == null) {
        throw new InvalidRequestException(&quot;Todo의 작성자가 존재하지 않습니다.&quot;);
    }

    if (!ObjectUtils.nullSafeEquals(todo.getUser().getId(), authUser.getId())) {
        throw new InvalidRequestException(&quot;담당자 권한이 없습니다.&quot;);
    }
    // ...
}</code></pre>
<p>→ <strong>서비스 로직 수정</strong>.</p>
<p>이 PR은 다른 테스트 수정 PR들과 섞으면 안 된다. 다른 케이스는 모두 &quot;테스트만 고치는&quot; 변경이지만, 이건 <strong>프로덕션 코드의 동작을 바꾸는 변경</strong>이다. 리뷰어가 봐야 할 시선이 완전히 다르다.</p>
<h2 id="그래서-어떻게-구분했는가">그래서 어떻게 구분했는가</h2>
<p>네 케이스를 모두 본 다음, 이런 질문 순서로 정리할 수 있었다.</p>
<ol>
<li><p><strong>테스트의 의도가 무엇인가?</strong>
메서드명, 검증 내용, given/when/then을 읽고 &quot;이 테스트는 무엇이 옳다고 주장하는가&quot;를 먼저 파악한다.</p>
</li>
<li><p><strong>그 의도가 옳은가?</strong>
도메인 관점에서 합리적인 시나리오인지 본다.</p>
</li>
<li><p><strong>의도가 옳다면, 실패의 원인은 무엇인가?</strong></p>
<ul>
<li>의도를 잘못 구현한 테스트 코드 → <strong>테스트 수정</strong> (케이스 1)</li>
<li>메서드명이 옛 의도의 잔재 → <strong>테스트 수정</strong> (케이스 2)</li>
<li>mock setup이 서비스의 실제 협력 관계와 어긋남 → <strong>테스트 수정</strong> (케이스 3)</li>
<li>서비스 로직이 의도된 시나리오를 처리하지 않음 → <strong>서비스 수정</strong> (케이스 4)</li>
</ul>
</li>
</ol>
<p>핵심은 <strong>&quot;테스트가 실패하면 테스트를 고친다&quot;는 반사적 반응을 멈추는 것</strong>이었다. 테스트가 가진 의도를 먼저 읽고, 그 의도가 옳은지 판단한 다음에 어느 쪽을 고칠지 결정한다.</p>
<h2 id="회고">회고</h2>
<p>테스트의 가치는 회귀 방지에만 있는 게 아니라, <strong>&quot;이 시스템이 어떤 시나리오를 명시적으로 다루는가&quot;를 문서화하는 데</strong>도 있다. 케이스 4의 테스트가 없었다면 &quot;Todo의 user가 null인 비정상 상황&quot;이 어떻게 처리되는지 아무도 신경 쓰지 않았을 것이다. NPE는 운영 환경에서 조용히 5xx로 돌아왔을 거고, 누가 그 원인을 추적할 때쯤이면 이미 한참 뒤였을 것이다.</p>
<p>테스트가 실패할 때 그 의도를 먼저 읽는 습관은, 결국 도메인 시나리오를 더 신중하게 다루는 습관과 같다. &quot;왜 이 테스트가 처음에 작성됐을까&quot;를 묻는 것은 &quot;이 시스템은 무엇을 보장해야 하는가&quot;를 묻는 것과 같다.</p>
<p>다음에 깨진 테스트를 만나면, 빨간 줄을 없애는 가장 빠른 방법을 찾기 전에 의도부터 한 번 읽으려 한다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third tool] AI 시대의 Doc-Driven Workflow: Living Docs로 하네스 잡기, 그리고 사람의 영역]]></title>
            <link>https://velog.io/@js-kim-arc/Third-tool-AI-%EC%8B%9C%EB%8C%80%EC%9D%98-Doc-Driven-Workflow-Living-Docs%EB%A1%9C-%ED%95%98%EB%84%A4%EC%8A%A4-%EC%9E%A1%EA%B8%B0-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%82%AC%EB%9E%8C%EC%9D%98-%EC%98%81%EC%97%AD</link>
            <guid>https://velog.io/@js-kim-arc/Third-tool-AI-%EC%8B%9C%EB%8C%80%EC%9D%98-Doc-Driven-Workflow-Living-Docs%EB%A1%9C-%ED%95%98%EB%84%A4%EC%8A%A4-%EC%9E%A1%EA%B8%B0-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%82%AC%EB%9E%8C%EC%9D%98-%EC%98%81%EC%97%AD</guid>
            <pubDate>Fri, 08 May 2026 11:38:25 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/add6b656-957d-4b7b-8fe6-471d9ddf14b8/image.png" alt=""></p>
<blockquote>
<p>AI에게 문서를 맡길 때 무너지는 건 정확성이 아니라 <strong>소유권(ownership)</strong> 과 <strong>결정의 날카로움</strong>이다.</p>
</blockquote>
<h2 id="들어가며--두-번-다른-길을-걸어봤다">들어가며 — 두 번 다른 길을 걸어봤다</h2>
<p>Doc-driven workflow를 ThirdTool에 적용하면서, 같은 도메인 모델링 문서를 두 가지 방식으로 작성해봤다.</p>
<pre><code>[방식 A] Top-down
  목차/프롬프트 먼저 → 검토 후 빈칸 채워가며 문서화
  → epic/story 그 위에서 진행

[방식 B] Living
  최소한의 모델링 doc(속성/행동/규칙)만 두고 시작
  → epic 진행하며 학습된 것이 문서로 흘러들어옴
  → 살아서 리팩토링되는 문서</code></pre><p>처음엔 &quot;어느 쪽이 더 나은가&quot; 식으로 답을 찾으려 했는데, 한참 굴려보고 나서 그게 잘못된 질문이었다는 걸 알았다. 두 방식은 <strong>다른 trade-off curve를 가지고 있고</strong>, 어느 쪽이 우월한 게 아니라 <strong>상황별로 적합한 영역이 다르다.</strong></p>
<p>다만 내 작업 성격(도메인 모델링 + 학습 + 1인 진화 서비스 + Claude Code 하네스 운영)에서는 B쪽이 더 잘 맞았다. 그 이유와 한계를 정리하면서, 결국 두 방식을 어떻게 layered로 섞어 쓰는지까지 가본다.</p>
<hr>
<h2 id="1-두-방식의-trade-off-매트릭스">1. 두 방식의 Trade-off 매트릭스</h2>
<h3 id="top-down-방식">Top-down 방식</h3>
<pre><code>〔장점〕
- 구조적 완결성       → 누락을 구조적으로 잡아줌
- 큰 그림 가시성       → 전체 지도를 한눈에
- 표준화 / 공유 용이   → 팀에서 같은 형식 강제 가능
- 신참 onboarding     → 입사자가 한 번에 따라잡기 좋음
- 안 변하는 영역에 강함 → 외부 계약, 컴플라이언스, 감사 대비

〔단점〕
- 정답 모를 때 정답 구조부터 만드는 모순 - 이 모순이 컸다.
- 빈칸 강제 → AI가 균형 맞추려 hallucination
- sunk cost → 잘못 잡힌 목차가 작업을 끌고 감
- 의도 정렬 &lt; 형식 충실도 → 칸 채우기 게임이 됨
- 학습 반영 어려움 → &quot;완성품&quot;으로 인식되어 수정 진입 장벽</code></pre><p><img src="https://velog.velcdn.com/images/js-kim-arc/post/346170a5-cf27-4357-9759-8fe2e8b64480/image.png" alt=""></p>
<h3 id="living-방식">Living 방식</h3>
<pre><code>〔장점〕
- 진짜 필요한 것만 자람 → lean
- 학습 동기화           → 이해 ≈ 문서 상태
- 진입 비용 낮음        → 첫 작업까지 빠름
- AI에 좋은 컨텍스트     → 항상 &quot;현재 진실&quot;만 제공
- 도메인 모델링/탐색에 본질적으로 정합
- 하네스 운영 친화적     → (다음 섹션에서 자세히)

〔단점〕
- 큰 그림 누락 위험     → 중복/사각지대 (확실히 피드백이 많이 필요할 것 같음) 
- 정합성 침식           → 시간 지나면 문서 간 충돌
- 결정 history 흐릿     → &quot;왜 이렇게 됐지?&quot; 추적 어려움
- onboarding 약함       → 새 사람은 &quot;현재&quot;만 보고 &quot;맥락&quot; 못 봄
- 후반부 refactoring 비용 누적</code></pre><p>만족도가 갈렸던 건 절대적 우위가 아니라, <strong>내 작업 컨텍스트에서 living의 장점이 더 크게 작동하고 단점은 덜 아팠기 때문</strong>이었다.</p>
<hr>
<h2 id="2-내-컨텍스트에서-living이-잘-맞았던-이유">2. 내 컨텍스트에서 Living이 잘 맞았던 이유</h2>
<p>세 가지가 동시에 정렬됐다.</p>
<pre><code>[도메인 모델링의 본질]
  - 발견적(emergent) 작업
  - 속성/행동/규칙은 코드를 짜며 드러남
  - &quot;이 필드 있어야 하나?&quot; 는 use case 만나야 답이 나옴
  → 미리 채울 수 있는 정보가 적음
  → top-down은 빈칸을 강제로 채우게 됨

[학습 곡선과의 정합]
  - 사람의 이해는 점진적으로 깊어짐
  - top-down 문서는 내 이해 수준과 mismatch
    (이해 안 된 채로 문서에 적힌 항목들이 떠다님)
  - living은 이해 곡선과 동기화됨

[AI의 약점 회피]
  - top-down에서 빈칸이 많으면 → AI가 균형 맞추려고 hallucination
  - &quot;Card는 7개 속성이 자연스러워 보이니 채우자&quot; 식의 합성
  - living은 &quot;이번에 진짜 필요한 것&quot;만 다루므로 합성 여지가 적음</code></pre><p>ThirdTool에서 <code>AxisAction → AxisTopic</code>으로 마이그레이션할 때가 좋은 예다. 처음에 AxisAction이라고 명명한 이유는 &quot;사용자 학습 행동 단위&quot;라는 직관에서 출발했는데, ReviewLog의 user action과 의미 충돌이 나면서 학습 흐름을 표현하기에 부적절해진 거다.</p>
<p>이걸 top-down 문서에 미리 넣을 수 있었을까? 못한다. <strong>돌아갔기 때문에 알게 된 거다.</strong> Living docs는 이런 학습을 받아낼 수 있었고, top-down docs였다면 잘못된 사전 합리화를 박제했을 거다.</p>
<hr>
<h2 id="3-living이-하네스-운영에-좋았던-추가-이유">3. Living이 하네스 운영에 좋았던 추가 이유</h2>
<p>이건 따로 빼서 쓸 만한 가치가 있다. 단순히 &quot;도메인 모델링에 좋다&quot;를 넘어, <strong>Claude Code 하네스 운영 관점에서 구조적 우위</strong>가 있었다.</p>
<h3 id="3-1-모듈성--발췌가-자연스럽다">3-1. 모듈성 — 발췌가 자연스럽다</h3>
<pre><code>[top-down]
  - 한 덩어리 종합 문서
  - 매 story마다 &quot;이 부분만 떼어서&quot; 가 부자연스러움
  - 결국 통째로 넘기거나, 발췌하는 데 별도 노력 필요

[living]
  - 작은 단위 doc이 분산되어 있음
  - story-local harness에 그대로 발췌 가능
  - &quot;이번 작업에 관련된 living doc 3개&quot; 같은 단위가 자연스러움</code></pre><p>작업 단위와 문서 단위가 일치하니까 발췌 비용이 거의 없다.</p>
<h3 id="3-2-컨텍스트-가벼움--claude-code에-던지기-좋다">3-2. 컨텍스트 가벼움 — Claude Code에 던지기 좋다</h3>
<p>Claude Code 세션에 컨텍스트를 넘길 때 분량과 정확도가 직접 품질에 영향을 미친다.</p>
<pre><code>- 큰 종합 문서 통째로 넘기면 → 무관한 정보가 노이즈
- AI가 노이즈에서 잘못된 패턴을 짚어낼 위험
- token도 비싸짐

→ living은 작업과 관련된 좁은 범위만 넘길 수 있어 hit rate↑</code></pre><p>특히 200줄 제한 둔 CLAUDE.md/rules와 결합하면, <strong>&quot;규칙은 늘 로드, 디테일은 on-demand&quot;</strong> 의 분리가 자연스럽게 나온다.</p>
<h3 id="3-3-속도-동기화--story와-docs의-진화-속도가-같다">3-3. 속도 동기화 — story와 docs의 진화 속도가 같다</h3>
<pre><code>[top-down]
  - docs는 사전에 freeze, story는 작업하며 진화
  - 시간 지날수록 docs ↔ 코드 갭 누적
  - 갭 인식 → 큰 docs 수정 마찰 → 갭 방치

[living]
  - story 진행 = doc 갱신
  - 갭이 누적되지 않음
  - 오히려 docs가 작업의 by-product로 자연 생산</code></pre><p>진행 중인 story의 학습이 즉시 다음 story의 컨텍스트가 된다.</p>
<h3 id="3-4-수정-비용-낮음--작업-중-발견한-모순을-즉시-반영">3-4. 수정 비용 낮음 — 작업 중 발견한 모순을 즉시 반영</h3>
<pre><code>[top-down]
  - &quot;이미 잘 만들어진 doc&quot;을 수정하는 심리적 마찰
  - 다른 부분과의 정합성 다시 점검 부담
  - 결과: 모순을 발견해도 사이드 메모로 남기고 미룸

[living]
  - 원래 진화하는 문서로 설계됨
  - 수정에 대한 마찰이 낮음
  - 모순이 발견되는 즉시 반영 → 정합성 유지 비용↓</code></pre><h3 id="3-5-실험-친화적--새-패턴-시도가-쉽다">3-5. 실험 친화적 — 새 패턴 시도가 쉽다</h3>
<pre><code>- 새 라이브러리, 새 패턴 도입할 때
- top-down은 &quot;큰 그림 미리 그리기&quot; 가 시작 장벽
- living은 작은 doc 1개로 실험 시작
- 안정화되면 승격 (Layer 1 ADR로)
- 실패하면 그냥 폐기</code></pre><p>유사한 멘탈모델</p>
<pre><code class="language-markdown">이 사이클은 배포 전략으로 치면 **canary 배포**와 구조가 똑같다. 새 버전을 작은 트래픽 비율로 먼저 흘리고, 메트릭 보면서 문제 없으면 점진적으로 비중 늘리고, 안정화되면 100%로 전환하는 흐름. Living docs의 실험 → 승격 사이클도 같은 모양이다.
</code></pre>
<p>[Canary 배포]
  새 버전 5% → 25% → 50% → 100% 트래픽</p>
<p>[Living docs canary]
  작은 living doc → 한 BC에서 검증 → 2~3 BC로 확대 → ADR + CLAUDE.md 규칙으로 강제 (해당 프로젝트에 대한 트레이드오프를 새로운 testing에 대해 고민해볼 수 있다.)</p>
<pre><code>
핵심은 **점진적 확대 + 안정화 후 표준화**다. 단계별로 풀면:
</code></pre><p>〔1. Canary〕  작은 living doc으로 한 BC에 시범 적용
〔2. Observe〕 다음 story에서도 자연스럽게 들어맞는지 관찰
〔3. Expand〕  2~3 BC에서 일관되게 안정화
〔4. Promote〕 ADR로 결정 기록 + CLAUDE.md 규칙으로 강제</p>
<pre><code>
ThirdTool에서 새 패턴(분산락, Outbox, soft delete cascade 같은 것)을 도입할 때 이 사이클이 잘 먹혔다. 안정화 못 된 패턴은 living doc 단계에서 그냥 폐기되니까 롤백 비용도 작다. 반대로 처음부터 ADR/규칙으로 박아놓고 시작했으면, 실패가 드러났을 때 이미 코드베이스 곳곳에 퍼져 있어서 떼어내는 비용이 컸을 거다.

&gt; 즉, **Layer 2 living doc은 Layer 1으로 가기 위한 canary 단계**다. 한 번에 규칙으로 승격시키지 말고, &quot;이 패턴을 모든 BC에 강제해도 되겠다&quot; 는 확신이 누적되면 그때 promote.</code></pre><hr>
<p>ThirdTool에서 Redisson 분산락을 처음 도입할 때 이 패턴이 잘 먹혔다. 작은 living doc으로 실험 → 안정화된 패턴은 ADR과 CLAUDE.md 규칙으로 승격.</p>
<h3 id="3-6-ai-합성-노이즈-감소--빈칸이-적다">3-6. AI 합성 노이즈 감소 — 빈칸이 적다</h3>
<pre><code>- top-down은 구조적으로 빈칸이 많음 (목차에 비해 내용 부족)
- AI는 빈칸을 견디지 못하고 &quot;그럴듯한&quot; 내용으로 채움
- 이번 작업에 무관한 합성 결과가 다음 작업의 컨텍스트로 흘러감
- 가장 크게 느낀 건 어느 순간 내가 원하는 테스팅의 방향성으로 가지 않고 점점 기성품에 가까운 소프트웨어가 되가는 느낌이였다. 

- living은 &quot;이번에 필요한 것&quot;만 다룸
- 빈칸 자체가 없거나 명시적 ⚠️ TBD로 표시
- AI 합성 여지가 줄어듦</code></pre><p>이 효과는 누적된다. 한 번 잘못 합성된 내용이 다음 doc 생성의 입력이 되면 오류가 증폭된다. Living은 이 누적 경로 자체를 짧게 유지한다.</p>
<hr>
<h2 id="4-그럼에도--living만으로는-무너지는-지점">4. 그럼에도 — Living만으로는 무너지는 지점</h2>
<p>위 장점들이 매력적이라 한 번은 미끄러질 뻔했다. <strong>&quot;Living이 좋으니 모든 docs를 living으로 운영하자&quot;</strong> 는 결론으로 가려고 했는데, scale이 올라가면 다른 힘이 작동한다.</p>
<pre><code>〔P0: 정합성 침식〕
  - living docs N개가 시간 따라 미세하게 어긋남
  - 같은 사실이 doc A에선 X, doc B에선 X&#39; 로 적혀있음
  - Claude Code가 다른 doc 보고 다른 답을 냄
  → N이 커질수록 비선형으로 커짐

〔P0: 결정 history 손실〕
  - &quot;왜 AxisAction → AxisTopic 이었지?&quot; 추적 불가
  - living은 &quot;현재&quot;만 보여주고 &quot;왜&quot;는 못 보여줌
  - 인터뷰에서 의사결정 이유 못 답하면 치명적

〔P1: 외부 계약 깨짐〕
  - API 스펙이 living이면 클라이언트 입장에서 신뢰 못함
  - 결제/Toss 같은 외부 통합은 freeze가 본질
  - &quot;어제는 됐는데 오늘은 안 되네&quot;

〔P1: 잘못된 합성의 누적 경로〕
  - 모호한 표현이 끼어들면 Claude Code가 &quot;합리적으로 해석&quot;
  - 합성된 코드가 다음 living doc에 반영되며 누적
  - 위 3-6 장점의 정확히 반대편 위험</code></pre><p>위 trade-off 매트릭스의 단점들이 실제로 모습을 드러내는 지점이다. 즉 <strong>하네스 운영의 강점은 작은~중간 scale에서 가장 크고, 큰 scale에선 정합성 비용이 따라온다.</strong></p>
<hr>
<h2 id="5-해법--layered-living-docs-3-layer">5. 해법 — Layered Living Docs (3-Layer)</h2>
<p>그래서 도착한 답은 <strong>하네스를 단일 계층으로 보지 말고 3-Layer로 분리</strong>하는 거다. 각 layer가 다른 lifecycle을 가진다.</p>
<pre><code>[Layer 1: Frozen Core]  〔P0: top-down, freeze 후 ADR로만 변경〕
  - 도메인 용어집 (Ubiquitous Language)
  - ADR 모음 → 결정 + 이유 + alternatives
  - 외부 API 계약 (OpenAPI 스펙)
  - DB 마이그레이션 history
  → 변경 시 별도 PR + 이유 기록 강제

[Layer 2: Living Working Set]  〔P1: 만족도 높았던 방식 그대로〕
  - 도메인 모델링 doc (속성/행동/규칙)
  - 진행 중 학습 노트
  - 테스트 모델링 doc
  - 작업 중 발견된 제약사항
  → story 진행하며 자유롭게 수정

[Layer 3: Story-local Harness]  〔P2: 일회성〕
  - 이번 story의 작업 지시서
  - 이번 story에 관련된 Layer 1, 2 발췌/링크
  - 완료 후 폐기 또는 git history로만 보존
  → Claude Code 세션 컨텍스트</code></pre><p>매 story마다 Layer 3를 새로 짜고, 그 안에서 Layer 1은 참조만, Layer 2는 갱신 가능, 이런 식이다. <strong>Living의 하네스 운영 강점은 Layer 2~3에서 그대로 살리고, 정합성 침식은 Layer 1으로 가둔다.</strong></p>
<h3 id="living-→-frozen-승격-패턴">Living → Frozen 승격 패턴</h3>
<p>진화하던 사실이 안정화되는 순간이 있다. 이때 Layer 2 → Layer 1으로 승격시키면 정합성이 유지된다.</p>
<pre><code>- 같은 도메인 규칙이 3 story 연속 안 바뀜  → 용어집으로 승격
- 의사결정이 두 번 이상 흔들리지 않음      → ADR 작성
- API가 외부에 노출됨                      → 스펙 freeze
- 테스트 패턴이 정착됨                     → 컨벤션 문서로</code></pre><p>ThirdTool에 적용하면:</p>
<ul>
<li><code>AxisAction → AxisTopic</code> 같은 마이그레이션 결정 → ADR (Layer 1)</li>
<li><code>LearningFacade</code>의 메서드 시그니처가 진화 중이면 → Layer 2</li>
<li>Card AR의 embedded VO 구조가 안정화됐으면 → Layer 1</li>
</ul>
<hr>
<h2 id="6-사람이-쥐고-있어야-할-5가지-판단-축">6. 사람이 쥐고 있어야 할 5가지 판단 축</h2>
<p>Layer 구조를 만든다고 끝이 아니다. <strong>각 layer의 어디까지 AI에게 맡기고, 어디부터 사람이 쥘 것인가</strong>가 다음 문제다.</p>
<p>문서를 AI에게 맡길 때 무너지는 건 정확성이 아니라 <strong>소유권</strong>과 <strong>결정의 날카로움</strong>이다. AI는 그럴듯한 문장을 너무 잘 써서, 어딘가 합리적으로 보이는 문서가 자동으로 만들어진다. 다시 읽었을 때 <em>진짜 내 결정인지 사후 합리화인지 구분이 사라진다.</em></p>
<p>문서 항목 단위로 다음 다섯 질문을 던진다. <strong>하나라도 사람 쪽이면 사람이 쥔다.</strong></p>
<pre><code>〔A1〕 Reversibility    바꾸려면 비용이 큰가?           (irreversible → 사람)
〔A2〕 Ownership        내 색깔/취향/철학이 드러나는가?  (yes → 사람)
〔A3〕 Truth grounding  AI가 거짓말해도 알아챌 수 있나?  (no → 사람 검증 필수)
〔A4〕 Context depth    팀/프로젝트 맥락이 깊이 필요한가? (deep → 사람)
〔A5〕 Why vs What      의도(Why)인가 형태(What)인가?    (Why → 사람)</code></pre><p>이 5축을 기준으로 문서 종류별 매트릭스를 정리해본다.</p>
<hr>
<h2 id="7-문서-종류별-분리-매트릭스">7. 문서 종류별 분리 매트릭스</h2>
<h3 id="7-1-domain-modeling-문서">7-1. Domain Modeling 문서</h3>
<pre><code>〔사람〕
- BC 경계 결정              → 한번 그으면 코드 구조 전체가 따라옴 (A1, A2)
- Aggregate Root 선택       → &quot;어떤 트랜잭션 일관성을 지킬 것인가&quot; 결정 (A1, A5)
- VO/Entity 분류 기준       → 식별성 vs 값 동등성 모델링 철학 (A2)
- Ubiquitous Language 핵심어 → &quot;AxisTopic이라 부른다&quot;는 결정은 도메인 정체성 (A2, A4)
- Invariant 정의            → 깨지면 안 되는 규칙은 비즈니스 본질 (A5)

〔AI〕
- 필드 후보 나열, 타입 시그니처 초안
- 메서드 시그니처 일관성 점검
- 기존 BC 형식에 맞춰 신규 BC 골격 생성
- ASCII 다이어그램
- equals/hashCode 등 보일러플레이트 명세</code></pre><p>핵심: AI는 &quot;AxisAction이 더 동작 중심이고 AxisTopic이 더 주제 중심&quot;이라는 식의 <strong>사후 합리화는 무한히 잘 만든다.</strong> 진짜 이유 — &quot;Action이라는 단어가 ReviewLog의 user action과 의미 충돌해서 학습 흐름을 표현하기에 부적절했다&quot; — 같은 본인 문맥은 모르고, 모르는 채로 그럴듯한 다른 이유를 채워 넣는다. 이걸 그대로 두면 6개월 뒤에 본인도 흔들린다.</p>
<h3 id="7-2-adr-architecture-decision-record">7-2. ADR (Architecture Decision Record)</h3>
<p>ADR은 형식이 &quot;Context / Decision / Consequences / Alternatives&quot;라 AI에게 통째로 맡기기 가장 위험한 문서다.</p>
<pre><code>〔사람〕
〔P0〕 Decision 한 줄         → ADR의 본체. 양도 불가 (A1, A2, A5)
〔P0〕 진짜 Why               → &quot;왜 이걸 골랐는가&quot;의 실제 동기 (A2, A5)
〔P0〕 Trade-off 인정 부분    → 무엇을 포기했는지 의식적으로 (A2)
〔P1〕 Consequences 중 위험   → 운영 가서 무너질 가능성에 대한 직감 (A4)

〔AI〕
〔P1〕 Context 사실 정리       → &quot;현재 시스템은 X 상황이다&quot;
〔P1〕 Alternatives 후보 나열  → &quot;고려 가능한 옵션은 A/B/C/D&quot;
〔P1〕 각 Alternative의 일반적 장단점
〔P2〕 ADR 형식 통일, 번호 부여, 인덱스 갱신</code></pre><p>가장 위험한 failure mode — AI에게 &quot;이 결정에 대한 ADR 써줘&quot;라고 하면 <strong>결정에 맞춰 Why를 만들어낸다.</strong> 진짜 Why는 &quot;그날 X 라이브러리 문서 보다가 이게 맞겠다 싶어서&quot;일 수 있는데, AI는 그걸 &quot;확장성, 유지보수성, 팀 학습 곡선을 종합 고려하여…&quot; 같은 <em>결정 후 정당화</em>로 포장한다. 면접에서 ADR 기반 깊은 질문에 본인이 무너지는 이유가 여기 있다.</p>
<p>→ <strong>권장 운영</strong>: ADR은 사람이 손글씨처럼 Decision + Why 한 단락 먼저, AI는 그 뒤에 Context/Alternatives만 살을 붙임. <strong>순서 중요.</strong></p>
<h3 id="7-3-api-spec">7-3. API Spec</h3>
<pre><code>〔사람〕
- 엔드포인트 URL 디자인         → 리소스 모델링 자체가 도메인 결정 (A1, A2)
- 에러 코드 체계 / 도메인 prefix → 시스템 전체에 영향 (A1, A4)
- Validation 규칙의 비즈니스 의미 → &quot;왜 이 필드가 필수인지&quot; (A5)
- 멱등성/락/동시성 정책          → 결제·재시도 영역 (A1, A4)
- Public API의 호환성 약속       → 한번 내보내면 회수 어려움 (A1)

〔AI〕
- Request/Response 스키마 초안
- Example payload (정상/에러)
- OpenAPI / Swagger 변환
- DTO 클래스 코드 생성
- HTTP status code 매핑 일관성 체크</code></pre><p>ThirdTool 예시 — Card 생성 API에서 &quot;MainNote, Summary는 embedded VO이고 KeywordCue는 child entity&quot;라는 결정은 사람의 도메인 모델링 결과물이다. 이걸 AI한테 그냥 &quot;Card 생성 API 명세 짜줘&quot; 하면, AI는 자기가 자주 본 패턴대로 <em>KeywordCue를 단순 String 배열로</em> 평탄화하기 쉽다. 그러면 복습 로직과의 정합성이 무너진다.</p>
<h3 id="7-4-test-modeling-문서">7-4. Test Modeling 문서</h3>
<pre><code>〔사람〕
- 테스트해야 할 핵심 invariant 식별 → &quot;이게 깨지면 도메인이 망한다&quot; (A4, A5)
- Edge case 우선순위               → 어떤 깨짐이 가장 비싼지 (A2, A4)
- Slice vs Unit vs E2E 분리 정책   → 아키텍처 이해 기반 (A2, A4)
- Mock 경계 결정                   → port-adapter에서 어디를 자르는지 (A2)
- 의도적으로 안 쓸 테스트 결정      → &quot;비용 대비 가치 낮아서 뺀다&quot; (A2)

〔AI〕
- 시나리오 매트릭스 (입력 조합)
- Given-When-Then 풀어내기
- 테스트 데이터 픽스처
- 동등 클래스/경계값 분석
- 테스트 코드 스켈레톤
- 누락 케이스 cross-check (사람이 지정한 invariant 기준)</code></pre><p>LearningFacade에 unit/slice test 만들 때, AI는 <em>조합 폭발을 잘 만든다.</em> 입력 5개 × 상태 4개 = 20개 케이스를 기계적으로 생성한다. 그런데 그중 진짜 중요한 5개 — &quot;ReviewLog가 Card보다 늦은 시각이면 안 된다&quot;, &quot;soft delete된 Card는 ReviewSession에 들어오면 안 된다&quot; 같은 — 를 골라내는 건 도메인 이해다. AI에게 맡기면 <em>모든 케이스를 비슷한 비중으로</em> 다뤄서 진짜 중요한 게 묻힌다.</p>
<p>→ <strong>권장 워크플로우</strong>:</p>
<ol>
<li>사람이 invariant 5~10개를 한 줄씩 적음</li>
<li>AI에게 &quot;이 invariant 각각에 대해 테스트 시나리오 풀어줘&quot;</li>
<li>사람이 우선순위 ★★★ 표시</li>
<li>AI가 코드화</li>
</ol>
<h3 id="7-5-pr-description--commit-message">7-5. PR Description / Commit Message</h3>
<pre><code>〔사람〕
- Why (의도, 배경)            → 리뷰어가 가장 보고 싶은 부분 (A5)
- 의도적으로 안 한 일         → &quot;여기 리팩토링 안 한 이유&quot; (A2)
- Trade-off 인정              → &quot;지금은 이렇게, 다음엔 이렇게&quot; (A2)
- 리뷰어에게 묻고 싶은 지점   → &quot;이 부분 의견 듣고 싶음&quot; (A2)

〔AI〕
- What (변경 파일/함수 요약)
- How (기술적 구현 요약)
- Diff 기반 changelog
- Conventional Commit 포맷
- Migration/Breaking change 체크리스트</code></pre><p>가장 흔한 실수 지점이다. Claude Code로 작업한 PR에 AI 자동 생성 description을 그대로 두면, <em>Why가 비어 있고 What만 잔뜩 있는</em> PR이 된다. 본인은 &quot;도메인 정합성 위해 이름 바꿨다&quot;가 진짜 이유인데, AI 생성문은 &quot;Renamed AxisAction to AxisTopic across 17 files&quot;만 나열한다. 리뷰어 눈엔 &quot;기계적 변경&quot;으로만 보인다.</p>
<h3 id="7-6-claudemd--rules-파일">7-6. CLAUDE.md / Rules 파일</h3>
<pre><code>〔사람〕
〔P0〕 절대 위반 금지 규칙       → &quot;이 파일은 AI가 못 건드린다&quot; (A1, A2)
〔P0〕 아키텍처 불변식           → &quot;Repository는 4-layer port-adapter 유지&quot; (A1)
〔P0〕 도메인 용어 사전          → Ubiquitous Language 못 박기 (A2, A4)
〔P1〕 우선순위 (어느 컨벤션이 더 강한가)

〔AI〕
〔P1〕 코드 스타일 일관성 검증
〔P2〕 형식 정돈, 200줄 제한 맞추기
〔P2〕 docs/와 rules/의 위치 정합성 점검</code></pre><p>CLAUDE.md는 AI에게 <em>내 의지를 전달하는 문서</em>라서, AI가 자기 스스로 갱신하게 두면 점진적으로 약해진다. AI는 본능적으로 &quot;유연한, 케이스별 판단&quot; 같은 표현을 좋아하는데, 이게 절대 규칙을 무력화한다.</p>
<hr>
<h2 id="8-ai에게-맡겼을-때의-실패-모드-6가지">8. AI에게 맡겼을 때의 실패 모드 6가지</h2>
<p>작업하면서 여러 번 데였다. 지금까지 발견한 패턴.</p>
<pre><code>[F1] 사후 합리화 (post-hoc rationalization)
  → 결정이 있고 나면 AI는 무한히 그럴듯한 이유를 만들어냄
  → 진짜 Why가 묻힘
  → 대응: Decision/Why를 사람이 먼저, AI는 그 뒤만 채움

[F2] Plausible-sounding 단어 오염
  → AI는 &quot;확장성/유연성/유지보수성&quot; 같은 부드러운 단어를 좋아함
  → 결정의 날카로움이 무뎌짐
  → 대응: 사람이 한 줄 결론을 강한 동사로 미리 박음

[F3] 일관성 흉내 (잘못된 패턴 복제)
  → 기존 문서를 본떠 새 문서 만들 때, 잘못된 패턴까지 복제
  → 대응: 신규 BC 추가 시 사람이 &quot;이 부분은 다르게 가자&quot; 명시

[F4] 빈 칸 채우기 강박
  → 모르는 부분도 비워 두지 못하고 채워 넣음
  → &quot;TBD&quot;가 사라짐
  → 대응: 명시적으로 &quot;모르는 건 ⚠️ 표시하고 비워 둬&quot;라고 지시

[F5] Balanced 문서 만들기
  → AI는 양쪽 의견을 균형 있게 제시하려는 경향
  → 결정 문서인데 결정이 안 보임
  → 대응: ADR은 &quot;Decision-first&quot; 포맷 강제

[F6] 컨텍스트 깊이 부족 노출
  → 팀 히스토리, 과거 사고, 사내 정치 등 모름
  → 그걸 모르는 채로 추천을 내놓음
  → 대응: 사람의 맥락 주입 없이 ADR 생성 금지</code></pre><hr>
<h2 id="09-실전-운영-5원칙">09. 실전 운영 5원칙</h2>
<pre><code>〔원칙 1〕 Decision-first, Expansion-second
   → 사람이 한 단락 손글씨처럼 결정+이유 적음
   → AI는 그 뒤 Context/Alternatives/형식만 부풂
   → 적용: ADR, PR description, 도메인 모델링

〔원칙 2〕 AI는 형식, 사람은 내용
   → 같은 정보의 markdown→OpenAPI 변환은 AI okay
   → 정보 자체의 Truth는 사람이 grounding
   → 적용: API Spec, 테스트 코드 생성

〔원칙 3〕 Invariant 5~10개는 사람이 직접
   → BC마다 깨지면 안 되는 규칙 5~10개는 사람 손으로
   → 나머지는 그 위에 쌓는 것
   → 적용: Domain Doc, Test Modeling, CLAUDE.md

〔원칙 4〕 AI 출력에 ⚠️ 마커 강제
   → AI가 추측한 부분은 ⚠️ 표시하라고 시스템 프롬프트로 박음
   → 사람이 검증해야 할 곳을 시각적으로 식별
   → 적용: 모든 doc 생성 시

〔원칙 5〕 Why를 묻는 자기 점검
   → 문서 작성 후 본인이 &quot;왜 이렇게 정했지?&quot;에 한 줄 답하기
   → 답이 안 나오면 그 부분은 사후 합리화일 가능성
   → 적용: ADR, Roadmap 우선순위</code></pre><hr>
<h2 id="마무리">마무리</h2>
<p>처음 질문 — <strong>&quot;docs를 living 방식으로 운영해도 괜찮은가?&quot;</strong> — 에 대한 답을 정리하면 이렇다.</p>
<pre><code>- Top-down과 Living은 다른 trade-off curve를 가짐
- 절대 우위는 없음, 작업 성격에 따라 적합한 영역이 다름
- 도메인 모델링 + 학습 + 하네스 운영 → Living이 유리
- 외부 계약 + 결정 history + onboarding → Top-down이 유리
- 그래서 답은 &quot;둘을 layered로 섞어 쓴다&quot;
- Frozen Core (Layer 1) + Living Working Set (Layer 2) + Story-local Harness (Layer 3)</code></pre><p>그리고 어느 layer에 있든 <strong>사람이 쥐어야 할 부분과 AI에게 맡길 부분</strong>을 명확히 분리해야 한다. 5축 기준으로:</p>
<ul>
<li><strong>Why, Decision, Invariant, BC 경계, 외부 계약</strong> → 사람</li>
<li><strong>What, How, 보일러플레이트, 형식 변환, 시나리오 expansion</strong> → AI</li>
</ul>
<p>한 줄로 줄이면:</p>
<blockquote>
<p><strong>AI는 <em>내가 무엇을 결정했는지</em>를 잘 표현하지만, <em>내가 왜 결정했는지</em>는 만들어내지 못한다. Doc-driven workflow의 Doc은 결국 그 Why를 보존하기 위해 있다.</strong></p>
</blockquote>
<p>Living docs가 하네스 운영에 잘 맞았던 이유는 <strong>모듈성·컨텍스트 가벼움·속도 동기화·수정 비용·실험 친화성·합성 노이즈 감소</strong>가 한꺼번에 정렬됐기 때문이다. 다만 이 강점은 작은~중간 scale에서 가장 크고, 큰 scale에선 정합성 비용이 따라온다. Layered 구조는 그 강점을 살리면서 비용을 가두는 구조적 답이다.</p>
<p>ThirdTool에 적용하면서 계속 다듬는 중이다. 여기 적은 trade-off, 5축, 실패 모드, 5원칙은 지금 시점의 결론이고, 이 글 자체도 layered 구조로 보면 Layer 2 — 진화 중인 living working note — 에 가깝다. 안정화되면 다음 글에서 다시 정리해볼 생각이다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프] 협업 트러블슈팅 - 코드리뷰의 효과]]></title>
            <link>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%EC%9D%BC%EB%8B%B9%EB%B0%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%BD%94%EB%93%9C%EB%A6%AC%EB%B7%B0%EC%9D%98-%ED%9A%A8%EA%B3%BC</link>
            <guid>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%EC%9D%BC%EB%8B%B9%EB%B0%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%BD%94%EB%93%9C%EB%A6%AC%EB%B7%B0%EC%9D%98-%ED%9A%A8%EA%B3%BC</guid>
            <pubDate>Wed, 29 Apr 2026 12:00:52 GMT</pubDate>
            <description><![CDATA[<h1 id="트러블슈팅01-1000줄짜리-pr에서-200500줄-task-단위로--코드-리뷰의-진짜-가치를-알게-된-순간">[트러블슈팅01] 1000줄짜리 PR에서 200~500줄 Task 단위로 — 코드 리뷰의 진짜 가치를 알게 된 순간</h1>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/1b093aa9-5850-4f30-bb2f-b14ed51fc381/image.png" alt=""></p>
<blockquote>
<p>일당백 백오피스 프로젝트(<code>com.ildang100.backoffice</code>) 회고 시리즈의 첫 글.
도메인: admin / customer / product / order / review (Bounded Context별 분담)
담당: product 도메인 + 재고 자동화 플로우, Review</p>
</blockquote>
<hr>
<h2 id="1-배경--코드-리뷰는-형식이지-라고-생각하던-시절">1. 배경 — &quot;코드 리뷰는 형식이지&quot; 라고 생각하던 시절</h2>
<p>이전 프로젝트들에서 나에게 코드 리뷰는 솔직히 <strong>&quot;머지 전 참고사항&quot;</strong> 이였다.</p>
<ul>
<li>개인 토이 프로젝트 → 리뷰어 자체가 1명이거나 없음</li>
<li>정돈되지 않은 팀 활동 → 일정에 쫓겨 LGTM 도장만 찍힘</li>
<li>PR 한 개에 LoC 1,000줄 이상이 기본 - 생산성이 훨씬 중요했다. </li>
</ul>
<p>당시 우선순위는 <strong>&quot;얼마나 빠르게 기능을 동작시키느냐&quot;</strong> 였고, 리뷰는 그 흐름을 막는 행정 절차에 가까웠다. 코드 품질은 &quot;내가 잘 짜면 되는 것&quot;이라고 믿었다.</p>
<p>이번 일당백 백오피스 프로젝트에서 도메인이 admin / customer / product / order / review로 나뉘고, 각자 BC(Bounded Context)를 책임지는 구조로 시작하면서 이 믿음이 깨졌다.</p>
<hr>
<h2 id="2-문제-상황--pr-첫-머지에서-드러난-것들코드-리뷰가-제대로-진행되기-시작했을-때">2. 문제 상황 — PR 첫 머지에서 드러난 것들(코드 리뷰가 제대로 진행되기 시작했을 때)</h2>
<h3 id="2-1-1200줄-pr을-올렸더니-리뷰가-멈췄다">2-1. 1,200줄 PR을 올렸더니 리뷰가 멈췄다</h3>
<p>product 도메인 1차 작업으로 다음을 한 PR에 몰아넣었다.</p>
<ul>
<li><code>Product</code> 엔티티 + <code>BaseEntity</code> 상속, static factory</li>
<li><code>ProductRepository</code> (JPQL 동적 검색 포함)</li>
<li><code>ProductService</code> (조회/등록/수정/재고 변경)</li>
<li><code>ProductController</code> + DTO (Request/Response/ListResponse)</li>
<li><code>ServiceException</code> + <code>ErrorCode</code> 추가</li>
<li>단위 테스트</li>
</ul>
<p>Loc가 1000줄이 넘어가려고 하니 바로 문제 생성</p>
<ul>
<li>어디서부터 읽어야 할지 모르겠음 - 그냥 읽기 싫다는 평이 많았다. </li>
<li>컨벤션 위반과 도메인 로직 이슈가 한 PR 안에 섞여 있어 코멘트 우선순위가 안 잡힘</li>
<li>결국 &quot;전체적으로 좋아 보입니다 👍&quot;</li>
</ul>
<h3 id="2-2-컨벤션이-굳기-전에-코드가-굳었다">2-2. 컨벤션이 굳기 전에 코드가 굳었다</h3>
<p>팀에서 합의한 컨벤션 — <code>BaseEntity</code> 상속, <code>private 생성자 + static factory create()</code>, <code>@Setter</code> 금지, <code>ServiceException(ErrorCode.XXX)</code>, <code>CommonApiResponse&lt;T&gt;</code> 직접 반환, 페이징은 1부터 시작 — 들이 한 번에 1,200줄짜리 PR로 들어가면 작은 위반이 묻힌다.</p>
<p>예를 들어 내 PR에는 이런 게 섞여 있었다.</p>
<ul>
<li>DTO 일부 필드가 <code>final</code>이 아님</li>
<li>페이징에서 <code>page-1</code> 변환을 컨트롤러에서 처리 (Service에서 해야 함)</li>
<li>일부 예외를 <code>IllegalArgumentException</code>으로 던짐 (<code>ServiceException</code>이어야 함)</li>
</ul>
<p>이게 머지된 뒤에 발견하면 도메인 전체를 다시 들쑤셔야 한다.</p>
<h3 id="2-3-다른-bc의-코드를-알지-못한-채-지나갔다">2-3. 다른 BC의 코드를 알지 못한 채 지나갔다</h3>
<p>customer / order PR을 슬쩍 훑고 approve를 누른 뒤, 정작 product에서 <code>OrderStatus</code> enum을 참조해야 할 때 *&quot;이게 어디 패키지에 있었지?&quot;* 부터 다시 찾고 있었다. <strong>리뷰를 안 한 거나 다름없었다.</strong></p>
<hr>
<h2 id="3-원인-분석--문제는-pr의-입자-크기였다">3. 원인 분석 — 문제는 &quot;PR의 입자 크기&quot;였다</h2>
<p>도구도 컨벤션도 룰북도 아니었다. 본질은 <strong>리뷰 단위(granularity)</strong> 였다. </p>
<table>
<thead>
<tr>
<th>단위</th>
<th>예시</th>
<th>적정 LoC</th>
</tr>
</thead>
<tbody><tr>
<td>Epic</td>
<td>&quot;재고 자동화 플로우&quot;</td>
<td>—</td>
</tr>
<tr>
<td>Story</td>
<td>&quot;주문 생성 시 재고 차감&quot;, &quot;재고 0 도달 시 <code>OUT_OF_STOCK</code> 전환&quot;, &quot;재고 보충 시 <code>ON_SALE</code> 복귀&quot;</td>
<td>—</td>
</tr>
<tr>
<td>Task (= 1 PR)</td>
<td>&quot;Product 엔티티 + 기본 CRUD&quot;, &quot;재고 차감 도메인 메서드 <code>changeStock</code>&quot;</td>
<td>200~500줄</td>
</tr>
</tbody></table>
<p>이전에 내가 만들던 1,000줄짜리 PR은 사실 <strong>Epic 통째로 PR</strong>이었다. Task 단위로 쪼개진 적이 없었다. 코드 리뷰가 작동하려면 그 전 단계인 <strong>Epic → Story → Task 분해</strong>가 선행되어야 했다.</p>
<hr>
<h2 id="4-해결-과정--task--pr-200500줄-룰">4. 해결 과정 — Task = PR, 200~500줄 룰</h2>
<h3 id="4-1-pr-단위-강제">4-1. PR 단위 강제</h3>
<p>product 도메인 2차 작업부터 다음과 같이 쪼갰다.</p>
<ol>
<li><strong>PR-1</strong>: <code>Product</code> 엔티티 + <code>ProductRepository</code> 기본 CRUD (≈ 280줄)</li>
<li><strong>PR-2</strong>: <code>ProductService</code> 검색 API + JPQL 동적 검색 (≈ 320줄)</li>
<li><strong>PR-3</strong>: <code>ProductStatus</code> 전환 도메인 메서드 (<code>changeStock</code>, 상태 자동 전이) (≈ 240줄)</li>
<li><strong>PR-4</strong>: 주문 이벤트 → 재고 차감 연결 (cross-BC) (≈ 410줄)</li>
<li><strong>PR-5</strong>: 재고 자동화 통합 테스트 (≈ 360줄)</li>
</ol>
<p>각 PR이 200~500줄에 떨어지니 리뷰어가 30분 안에 끝까지 본다.</p>
<h3 id="4-2-pr을-어떻게-쪼갤-것인가--수직vertical-vs-수평horizontal">4-2. PR을 &quot;어떻게&quot; 쪼갤 것인가 — 수직(Vertical) vs 수평(Horizontal)</h3>
<p>크기를 정했다고 끝이 아니었다. 같은 500줄 PR이라도 <strong>어떻게 쪼개느냐</strong>에 따라 리뷰 효율과 통합 비용이 크게 달라졌다. 두 방식을 다 시도해보고 차이를 체감했다.</p>
<h4 id="수평-분할-horizontal-slicing--레이어-단위로-자르기">수평 분할 (Horizontal slicing) — 레이어 단위로 자르기</h4>
<p>한 도메인을 기술 레이어 기준으로 자른다.</p>
<ul>
<li>PR-1: Product 도메인 <strong>Entity 전체</strong> (모든 필드, 모든 도메인 메서드)</li>
<li>PR-2: Product 도메인 <strong>Repository 전체</strong> (<code>findById</code>, <code>searchProducts</code> JPQL, ...)</li>
<li>PR-3: Product 도메인 <strong>Service + Controller 전체</strong>
특징:</li>
<li>한 PR이 같은 종류의 코드(엔티티 컨벤션, JPQL 패턴 등)만 다룸 → 리뷰어가 <strong>하나의 패턴만 검증</strong>하면 됨</li>
<li>컨벤션 일관성 잡기 유리 (<code>BaseEntity</code> 상속, static factory 같은 패턴이 한 PR 안에서 통일)</li>
<li>하지만 <strong>PR 단독으로 동작하지 않음</strong> — Entity만 머지되어도 사용자/Postman으로 검증할 게 없음</li>
<li>마지막 PR이 머지되기 전까지 <strong>end-to-end 검증 불가</strong><h4 id="수직-분할-vertical-slicing--기능-단위로-자르기">수직 분할 (Vertical slicing) — 기능 단위로 자르기</h4>
</li>
</ul>
<p>한 사용자 기능(Story)을 Entity ~ Controller까지 한 슬라이스로 자른다.</p>
<ul>
<li>PR-1: <strong>상품 단건 조회</strong> (필요한 Entity 필드 + <code>Repository.findById</code> + <code>Service.getProduct</code> + <code>Controller GET /products/{id}</code>)</li>
<li>PR-2: <strong>상품 검색</strong> (검색 관련 필드/JPQL + Service + Controller + DTO)</li>
<li>PR-3: <strong>재고 차감</strong> (<code>Product.changeStock</code> + <code>ProductStatus</code> 자동 전이 + 호출 지점)
특징:</li>
<li>각 PR이 <strong>독립적으로 동작 + Postman으로 즉시 검증 가능</strong></li>
<li>end-to-end 흐름이 한 PR 안에 있어 리뷰어가 <strong>&quot;이 변경이 어떤 사용자 행동을 가능하게 하는가&quot;</strong> 를 검증할 수 있음</li>
<li>같은 파일(Entity, Repository)을 여러 PR에서 건드림 → 머지 충돌 위험 ↑</li>
<li>컨벤션이 PR마다 미세하게 어긋날 위험 (Service 검증 패턴이 PR마다 조금씩 다른 식)<h4 id="실제로는-이렇게-됐다">실제로는 이렇게 됐다</h4>
</li>
</ul>
<p><strong>처음 시도: 수평 분할.</strong> product 도메인 1차 작업을 수평으로 쪼갰다. PR-1로 Entity 전체를 올리고 시작.</p>
<p>망한 지점:</p>
<ol>
<li>PR-1 리뷰에서 *&quot;이 필드/도메인 메서드가 어디서 쓰일지 맥락이 없어요&quot;* 라는 코멘트가 달림 — Service/Controller PR이 아직 없으니 검증 기준이 모호</li>
<li>PR-3(Service+Controller)까지 가서야 비즈니스 흐름이 드러남 → 그 시점에 <strong>PR-1의 엔티티 설계 이슈</strong>(예: <code>changeStock</code>이 음수 입력 검증을 안 함)가 발견됨 → PR-1 영역을 다시 수정</li>
<li>PR-1, PR-2가 머지되어도 <strong>Postman/QA로 검증할 게 없음</strong> → 리뷰가 &quot;코드 자체&quot;에만 의존, 사용자 가치 관점 검증이 불가능</li>
</ol>
<p><strong>중간부터 수직 분할로 전환.</strong> product 도메인 2차 작업부터는 Story 단위로 잘랐다.</p>
<ul>
<li>PR-1: 상품 단건 조회 (Entity 최소 필드 + Repository + Service + Controller, end-to-end)</li>
<li>PR-2: 상품 검색 (필요한 필드 추가 + JPQL + DTO + Controller)</li>
<li>PR-3: 재고 차감 (<code>changeStock</code> + 상태 전이 + 호출 지점)
바뀐 점:</li>
<li>각 PR마다 <strong>Postman으로 즉시 검증</strong> → 리뷰어가 코드뿐 아니라 동작도 본다</li>
<li>도메인 설계 이슈를 PR-1에서 발견하면 PR-2/3에서 점진적으로 보완 (수평이었다면 PR-1로 되돌아가 깨야 했음)</li>
<li>비즈니스 흐름이 PR 단위로 명확해서 <strong>PM/QA가 진척도를 PR 머지 수로 추적 가능</strong><h4 id="그렇다고-수평이-무조건-나쁜-건-아니다">그렇다고 수평이 무조건 나쁜 건 아니다</h4>
</li>
</ul>
<p><strong>부트스트래핑(초기 공통 인프라) 단계는 수평이 맞다.</strong> 모든 BC가 의존하는 토대는 한 번에 깔아두는 게 합리적이다.</p>
<p>이 프로젝트도 PR-0 격으로 다음을 한 번에 깔았다:</p>
<ul>
<li><code>BaseEntity</code> (createdAt/updatedAt + JPA Auditing)</li>
<li><code>ServiceException</code> + <code>ErrorCode</code> 골격</li>
<li><code>CommonApiResponse&lt;T&gt;</code> + <code>@JsonInclude(NON_NULL)</code></li>
<li><code>GlobalExceptionHandler</code> (validation, enum mismatch, ServiceException, Exception 매핑)</li>
<li><code>SessionConst</code> + <code>SessionUtils</code> + <code>LoginAdminDto</code></li>
<li>공통 enum (<code>AdminRole</code>, <code>AdminStatus</code>, <code>CustomerStatus</code>, <code>OrderStatus</code>, <code>ProductStatus</code>)
이 인프라가 깔린 다음부터 각 도메인이 수직 슬라이스로 들어갔다. <strong>수평으로 토대 → 수직으로 기능</strong> 의 2단 흐름.</li>
</ul>
<h4 id="휴리스틱-이번-프로젝트에서-정리한-규칙">휴리스틱 (이번 프로젝트에서 정리한 규칙)</h4>
<table>
<thead>
<tr>
<th>상황</th>
<th>권장 분할</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>공통 인프라 / 컨벤션 토대</td>
<td><strong>수평</strong></td>
<td>모든 BC가 의존, 한 번에 깔아야 함</td>
</tr>
<tr>
<td>새 도메인 첫 진입</td>
<td>얇은 수평 + 첫 수직 슬라이스</td>
<td>Entity 뼈대는 수평, 첫 기능부터 수직</td>
</tr>
<tr>
<td>기능 추가/확장</td>
<td><strong>수직</strong></td>
<td>사용자 가치 단위로 검증 가능</td>
</tr>
<tr>
<td>한 레이어 패턴 리팩터링 (예: 모든 Service에 <code>@Transactional</code> 정책 통일)</td>
<td><strong>수평</strong></td>
<td>같은 변경 패턴을 한 번에</td>
</tr>
<tr>
<td>Cross-BC 통합 (재고 자동화 흐름)</td>
<td><strong>수직</strong></td>
<td>한 흐름 = 한 PR, 트랜잭션 경계를 한 곳에서 검증</td>
</tr>
</tbody></table>
<h4 id="한-줄-정리">한 줄 정리</h4>
<p><strong>크기는 PR을 <em>리뷰 가능</em>하게 만들고, 형태는 PR을 <em>검증 가능</em>하게 만든다.</strong></p>
<ul>
<li>수평으로만 쪼개면 → 리뷰는 되지만 &quot;이게 왜 필요한지&quot;가 마지막 PR까지 안 보임</li>
<li>수직으로만 쪼개면 → 검증은 되지만 같은 파일 충돌과 컨벤션 흔들림이 늘어남</li>
<li>둘을 단계별로 섞는다: <strong>인프라는 수평, 기능은 수직.</strong></li>
</ul>
<h3 id="4-3-리뷰-코멘트가-질문으로-바뀜">4-3. 리뷰 코멘트가 &quot;질문&quot;으로 바뀜</h3>
<p>이전:</p>
<blockquote>
<p>LGTM 👍</p>
</blockquote>
<p>이후:</p>
<blockquote>
<p><code>ProductService.changeStock</code>이 <code>@Transactional(readOnly = true)</code> 안의 다른 메서드에서 호출되면 어떻게 되나요? readOnly 트랜잭션 안에서 상태 변경 일어나면 flush 시점에 터질 텐데요.</p>
</blockquote>
<blockquote>
<p><code>ProductStatus.OUT_OF_STOCK</code> 전환을 도메인 메서드에서 처리하는 건 좋은데, 이걸 <code>OrderService</code>에서 직접 호출하면 order → product 의존이 강해지지 않나요? 도메인 이벤트 발행 쪽이 깔끔할 것 같은데 어떻게 생각하세요?</p>
</blockquote>
<p><strong>질문이 들어오기 시작하면 그게 진짜 코드 리뷰다.</strong> 형식 절차에서 의사결정 회의로 바뀌었다.</p>
<hr>
<h2 id="5-협업--코드-리뷰의-장점-실제로-체감한-것">5. 협업 + 코드 리뷰의 장점 (실제로 체감한 것)</h2>
<h3 id="5-1-다른-bc에서-새로운-관점을-얻는다">5-1. 다른 BC에서 <strong>새로운 관점</strong>을 얻는다</h3>
<p>customer 도메인 담당자가 페이징 응답을 처리하는 방식을 보고 product를 갈아엎었다.</p>
<p><strong>Before (내 코드, 컨트롤러에서 직접 매핑):</strong></p>
<pre><code class="language-java">public CommonApiResponse&lt;Page&lt;ProductResponse&gt;&gt; search(...) {
    Page&lt;Product&gt; products = productService.search(...);
    Page&lt;ProductResponse&gt; response = products.map(ProductResponse::from);
    return CommonApiResponse.success(HttpStatus.OK, &quot;조회 성공&quot;, response);
}</code></pre>
<p><strong>After (customer 패턴 차용, ListResponse가 변환 책임):</strong></p>
<pre><code class="language-java">public CommonApiResponse&lt;ProductListResponse&gt; search(...) {
    Page&lt;Product&gt; products = productService.search(...);
    return CommonApiResponse.success(HttpStatus.OK, &quot;조회 성공&quot;,
        ProductListResponse.from(products));
}</code></pre>
<p>DTO 변환 책임이 <code>ProductListResponse.from(Page&lt;Product&gt;)</code> 한 곳으로 모이고, 페이징 메타데이터(totalPages, currentPage 등)도 거기서 한 번에 처리하게 됐다. <strong>컨트롤러가 가벼워졌다.</strong></p>
<p>내가 단독으로 작업했다면 이 패턴을 도입하기까지 많은 시간이 필요했을 것이다. </p>
<h3 id="5-2-팀의-업무-습관이-보인다">5-2. <strong>팀의 업무 습관</strong>이 보인다</h3>
<p>코드만 보면 안 보이는 것들이 PR 흐름에서 드러난다.</p>
<ul>
<li>누구는 <strong>테스트를 먼저 짜고</strong> 구현 PR을 따로 올린다</li>
<li>누구는 <strong>커밋을 의미 단위로 분리</strong>해서 PR 안에서도 따라가기 쉽게 만든다</li>
<li>누구는 <strong>PR 본문에 동기/접근/대안</strong>을 항상 적는다 (왜 이 방식을 택했고 어떤 대안을 버렸는지)</li>
<li>누구는 <strong>방어적 검증</strong>(화이트리스트, null 체크)을 자연스럽게 깔아둔다 — 페이징 sortBy 검증을 화이트리스트로 처리하는 패턴은 이렇게 배웠다</li>
</ul>
<p>이건 한 줄짜리 코드 컨벤션이 아니다. 한 사람의 <strong>&quot;일하는 결&quot;</strong> 이다. 한두 주만 같이 리뷰해보면 그 결이 보이고, 자연스럽게 좋은 부분을 흡수하게 된다. <strong>혼자 하던 시절에는 절대 얻을 수 없는 것이었다.</strong></p>
<hr>
<h2 id="6-트레이드오프--단점도-솔직하게">6. 트레이드오프 — 단점도 솔직하게</h2>
<p>코드 리뷰가 무조건 좋다고 말하면 거짓말이다. 분명히 비용이 있었다.</p>
<h3 id="6-1-단기-진행-속도-저하">6-1. 단기 진행 속도 저하</h3>
<ul>
<li><strong>1,000줄 단일 PR</strong> 시절: 한 도메인 머지에 하루</li>
<li><strong>200~500줄 Task 단위 PR</strong> 이후: 같은 도메인을 4<del>5개 PR로 나누고, 각 PR마다 리뷰 대기 → 2</del>3일 소요</li>
</ul>
<p>순수 &quot;LoC / 시간&quot; 기준으로는 생산성이 분명히 떨어진다. 마감이 빠듯한 상황에서는 이 비용이 뼈아프다.</p>
<h3 id="6-2-리뷰어의-컨텍스트-비용">6-2. 리뷰어의 컨텍스트 비용</h3>
<p>PR이 작아도 리뷰어가 <strong>그 도메인의 맥락</strong>을 알아야 한다. order 담당자가 product PR을 받으면, 적어도 <code>Product</code>, <code>ProductStatus</code>, <code>ProductService</code>의 책임 범위는 알고 있어야 의미 있는 리뷰가 된다.</p>
<p>모든 사람이 모든 BC를 다 알 수는 없으니, 결과적으로 <strong>&quot;주 리뷰어 + 보조 리뷰어&quot;</strong> 형태가 자연스럽게 자리잡았다. 주 리뷰어는 도메인 컨텍스트를, 보조 리뷰어는 컨벤션·전반적 가독성을 보는 식.</p>
<h3 id="6-3-pr-쪼개기-자체가-스킬">6-3. PR 쪼개기 자체가 스킬</h3>
<p>&quot;이 기능을 어떻게 5개 Task로 쪼갤까&quot;가 처음에는 어렵다.</p>
<ul>
<li>너무 잘게 쪼개면: PR 1개가 그 자체로 의미 없는 단위가 됨 (엔티티 필드만 추가, 같은)</li>
<li>너무 크게 쪼개면: 다시 1,000줄 문제로 회귀</li>
</ul>
<p><strong>Story 단위에서 &quot;그 자체로 동작하거나 검증 가능한 최소 단위(MVP slice)&quot;를 찾는 감각</strong>이 필요하다. 이건 시간이 들어야 늘었다.</p>
<h3 id="6-4-컨벤션-합의-비용">6-4. 컨벤션 합의 비용</h3>
<p>팀 컨벤션이 굳기 전(<code>BaseEntity</code>, <code>ServiceException</code>, <code>CommonApiResponse</code>, 페이징 1-base 등)에는 리뷰 코멘트의 절반이 *&quot;이거 컨벤션 어떻게 갈까요?&quot;* 가 됐다. 초반에는 코드 작성보다 합의에 시간이 더 든다.</p>
<p>다만 이 비용은 <strong>일회성</strong>이다. 한 번 잡히면 그 뒤로는 자동.</p>
<hr>
<h2 id="7-트레이드오프-정리">7. 트레이드오프 정리</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>1,000줄 단일 PR</th>
<th>200~500줄 Task 단위 PR</th>
</tr>
</thead>
<tbody><tr>
<td><strong>단기 진행 속도</strong></td>
<td>✅ 빠름</td>
<td>❌ 느림</td>
</tr>
<tr>
<td><strong>코드 리뷰 품질</strong></td>
<td>❌ 형식적</td>
<td>✅ 실질적</td>
</tr>
<tr>
<td><strong>컨벤션 정합성</strong></td>
<td>❌ 깨지기 쉬움</td>
<td>✅ 초기에 잡힘</td>
</tr>
<tr>
<td><strong>다른 BC 학습</strong></td>
<td>❌ 거의 없음</td>
<td>✅ 자연스럽게 발생</td>
</tr>
<tr>
<td><strong>리팩터링 비용</strong></td>
<td>❌ 후반에 폭발</td>
<td>✅ 분산됨</td>
</tr>
<tr>
<td><strong>신규 합류자 온보딩</strong></td>
<td>❌ 어려움</td>
<td>✅ PR 히스토리로 추적</td>
</tr>
<tr>
<td><strong>버그 발견 시점</strong></td>
<td>❌ 머지 후</td>
<td>✅ 머지 전</td>
</tr>
<tr>
<td><strong>개인 컨텍스트 부담</strong></td>
<td>✅ 낮음</td>
<td>❌ 높음 (작업 분할 필요)</td>
</tr>
</tbody></table>
<p>장기적으로는 거의 모든 항목에서 200~500줄 단위가 우위였다. <strong>단기 속도를 포기하고 장기 품질을 얻는 트레이드오프</strong>가 명확했고, 이번 프로젝트 기간(약 한 달) 안에서도 후반부에 이미 회수가 시작됐다 (재고 자동화 cross-BC 작업에서 다른 도메인 코드를 빠르게 읽고 수정할 수 있었음).</p>
<hr>
<h2 id="8-배운-것-요약">8. 배운 것 (요약)</h2>
<ol>
<li><strong>코드 리뷰의 작동 여부는 PR 크기가 결정한다.</strong> 도구·룰북·컨벤션보다 *&quot;PR 200~500줄&quot;* 한 줄이 가장 큰 영향을 줬다.</li>
<li><strong>Epic → Story → Task 분해는 PM 작업이 아니라 코드 리뷰의 인프라다.</strong> 이게 안 되면 리뷰 자체가 작동하지 않는다.</li>
<li><strong>개인 생산성과 팀 품질은 트레이드오프다.</strong> 그리고 거의 항상 후자가 이긴다 — 적어도 한 달 이상의 프로젝트에서는.</li>
<li><strong>다른 사람의 코드를 읽는 시간이 가장 빠른 학습 시간이다.</strong> 라이브러리 문서를 읽는 것보다, 팀원이 그 라이브러리를 어떻게 실전에서 쓰는지 보는 게 압도적으로 빠르다.</li>
<li><strong>혼자 잘하는 것과 팀에서 잘하는 것은 다른 스킬이다.</strong> 이전에 1,000줄 PR을 잘 짜던 능력은 팀에서는 오히려 마이너스가 될 수 있다는 걸 인정해야 했다.</li>
</ol>
<hr>
<blockquote>
<p>최종 수정일 : 2026-04-29</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프] 스케쥴관리 2차 트러블슈팅]]></title>
            <link>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%EC%8A%A4%EC%BC%80%EC%A5%B4%EA%B4%80%EB%A6%AC-2%EC%B0%A8-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%EC%8A%A4%EC%BC%80%EC%A5%B4%EA%B4%80%EB%A6%AC-2%EC%B0%A8-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Tue, 21 Apr 2026 13:04:59 GMT</pubDate>
            <description><![CDATA[<p>예비군 일정과 겹쳐서 우선 임시 페이지로 제출하겠습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third Tool] 도메인 서비스 도입하기]]></title>
            <link>https://velog.io/@js-kim-arc/Third-Tool-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%8F%84%EC%9E%85%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@js-kim-arc/Third-Tool-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%8F%84%EC%9E%85%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 20 Apr 2026 11:40:27 GMT</pubDate>
            <description><![CDATA[<h1 id="third-tool에-도메인-서비스-심화-생각하기---coveragecalculationservice-분리-기록">Third Tool에 도메인 서비스 심화 생각하기  — CoverageCalculationService 분리 기록</h1>
<h2 id="배경">배경</h2>
<p>LearningFacade BC에서 <code>AxisAction</code>의 <code>coverage_status</code>는 연결된 <code>ActionMaterial</code>의 상태에 따라 결정된다.</p>
<ul>
<li><code>NO_MATERIAL</code> — 자료 없음</li>
<li><code>PARTIAL</code> (학습중) — 자료는 있으나 일부만 완료</li>
<li><code>COVERED</code> (마스터) — 자료가 있고 전부 완료</li>
</ul>
<p><code>coverage_status</code>는 <strong>DB 컬럼으로 저장</strong>하기로 결정했기 때문에 (runtime 계산이 아닌 snapshot), 값을 갱신하는 주체와 계산 로직이 어디에 살아야 하는지가 문제가 됐다.</p>
<hr>
<h2 id="before--어느-엔티티에-넣어도-어색했던-순간">Before — 어느 엔티티에 넣어도 어색했던 순간</h2>
<h3 id="시도-1-axisaction에-계산-로직-넣기">시도 1. <code>AxisAction</code>에 계산 로직 넣기</h3>
<pre><code class="language-java">public class AxisAction {
    public void recalculateCoverage(List&lt;ActionMaterial&gt; materials) {
        // AxisAction이 자기 컬렉션도 아닌 목록을 외부에서 주입받음
        // ActionMaterial은 @ManyToMany가 아닌 explicit join entity로 분리된 상태
        // → AxisAction aggregate가 ActionMaterial 전체를 알아야 하는 구조가 됨
    }
}</code></pre>
<p>AxisAction은 ActionMaterial을 <strong>연관관계로 들고 있지 않다</strong>. <code>countByActionId()</code> 같은 query 기반 조회를 위해 일부러 explicit entity로 분리했기 때문이다. 그런 AxisAction이 <code>List&lt;ActionMaterial&gt;</code>을 파라미터로 받는 건 aggregate 경계를 어기는 냄새가 난다.</p>
<h3 id="시도-2-actionmaterial에-static-method">시도 2. <code>ActionMaterial</code>에 static method</h3>
<pre><code class="language-java">public class ActionMaterial {
    public static CoverageStatus calculate(List&lt;ActionMaterial&gt; materials) {
        // ActionMaterial이 AxisAction의 상태를 결정하는 주체?
        // 책임이 반대방향으로 흐른다
    }
}</code></pre>
<p>ActionMaterial은 자기 완료 여부만 알면 된다. 다른 ActionMaterial들을 훑어서 <strong>AxisAction의 상태를 정하는 역할</strong>은 ActionMaterial의 책임이 아니다.</p>
<p>→ 두 엔티티 모두 어색. 전형적인 <strong>도메인 서비스 시그널</strong>.</p>
<hr>
<h2 id="after---도메인-서비스로-분리">After  — 도메인 서비스로 분리</h2>
<pre><code class="language-java">public class CoverageCalculationService {

    public CoverageStatus calculate(AxisAction action, List&lt;ActionMaterial&gt; materials) {
        if (materials.isEmpty()) {
            return CoverageStatus.NO_MATERIAL;
        }

        boolean allCompleted = materials.stream()
            .allMatch(ActionMaterial::isCompleted);

        return allCompleted
            ? CoverageStatus.COVERED
            : CoverageStatus.PARTIAL;
    }
}</code></pre>
<p>지킨 원칙 두 가지(도메인 서비스에서 중요한 원칙):</p>
<ul>
<li><strong>상태 없음</strong> — 필드 없이 파라미터로만 동작. 싱글톤에서 안전.</li>
<li><strong>인프라 의존 없음</strong> — Repository, Redis, 외부 API 주입 없음. 순수 계산.</li>
</ul>
<hr>
<h2 id="호출-흐름--application-service가-조회-domain-service가-계산-entity가-상태-변경">호출 흐름 — Application Service가 조회, Domain Service가 계산, Entity가 상태 변경</h2>
<pre><code class="language-java">@Service
@Transactional
@RequiredArgsConstructor
public class UpdateActionMaterialService implements UpdateActionMaterialUseCase {

    private final LoadAxisActionPort loadAction;
    private final LoadActionMaterialPort loadMaterials;
    private final SaveAxisActionPort saveAction;

    private final CoverageCalculationService coverageCalculator
        = new CoverageCalculationService();

    @Override
    public void handle(UpdateActionMaterialCommand command) {

        // 1. 조회 — Application Service 책임 (Port 경유)
        AxisAction action = loadAction.loadById(command.actionId());
        List&lt;ActionMaterial&gt; materials
            = loadMaterials.loadByActionId(command.actionId());

        // 2. 계산 — Domain Service 책임
        CoverageStatus newStatus = coverageCalculator.calculate(action, materials);

        // 3. 상태 변경 — Entity 자신의 책임
        action.updateCoverageStatus(newStatus);

        // 4. 저장
        saveAction.save(action);
    }
}</code></pre>
<p>역할 분리가 선명해진다.</p>
<table>
<thead>
<tr>
<th>레이어</th>
<th>책임</th>
</tr>
</thead>
<tbody><tr>
<td>Application Service</td>
<td>Port 경유 데이터 조회/저장, 트랜잭션</td>
</tr>
<tr>
<td>Domain Service</td>
<td>여러 엔티티 협력이 필요한 계산</td>
</tr>
<tr>
<td>Entity</td>
<td>자기 상태 변경</td>
</tr>
</tbody></table>
<hr>
<h2 id="테스트--도메인-서비스의-진짜-이득">테스트 — 도메인 서비스의 진짜 이득</h2>
<pre><code class="language-java">class CoverageCalculationServiceTest {

    private final CoverageCalculationService sut = new CoverageCalculationService();

    @Test
    void calculate_whenNoMaterial_returnsNoMaterial() {
        // given
        AxisAction action = DomainFixture.axisAction();
        List&lt;ActionMaterial&gt; materials = List.of();

        // when
        CoverageStatus result = sut.calculate(action, materials);

        // then
        assertThat(result).isEqualTo(CoverageStatus.NO_MATERIAL);
    }

    @Test
    void calculate_whenAllCompleted_returnsCovered() {
        // given
        AxisAction action = DomainFixture.axisAction();
        List&lt;ActionMaterial&gt; materials = List.of(
            DomainFixture.completedMaterial(),
            DomainFixture.completedMaterial()
        );

        // when
        CoverageStatus result = sut.calculate(action, materials);

        // then
        assertThat(result).isEqualTo(CoverageStatus.COVERED);
    }

    @Test
    void calculate_whenPartiallyCompleted_returnsPartial() {
        // given
        AxisAction action = DomainFixture.axisAction();
        List&lt;ActionMaterial&gt; materials = List.of(
            DomainFixture.completedMaterial(),
            DomainFixture.uncompletedMaterial()
        );

        // when
        CoverageStatus result = sut.calculate(action, materials);

        // then
        assertThat(result).isEqualTo(CoverageStatus.PARTIAL);
    }
}</code></pre>
<p><code>@SpringBootTest</code>, <code>@DataJpaTest</code> 전부 불필요. 순수 Java 객체 생성만으로 끝난다. 실행 속도는 체감상 즉시.</p>
<hr>
<h2 id="판단-기준--나에게-체화시킨-3-question">판단 기준 — 나에게 체화시킨 3 Question</h2>
<pre><code>Q1. 단일 엔티티 데이터만으로 로직이 완성되는가?
    YES → 엔티티 메서드
    NO  → 다음 질문

Q2. 두 엔티티 이상의 데이터가 필요한가?
    YES → 도메인 서비스 후보

Q3. 엔티티에 넣으면 엔티티 간 결합도가 높아지는가?
    YES → 도메인 서비스로 분리</code></pre><p>Third Tool에서 이 기준을 적용해 뽑힌 도메인 서비스 후보:</p>
<ul>
<li><code>CoverageCalculationService</code> — AxisAction + ActionMaterial 목록 기반 coverage 계산</li>
<li><code>OnFieldPromotionPolicy</code> (예정) — UserScheduleConfig의 budget + 현재 ON_FIELD Card 집계 기반 승격 허용 여부 판정</li>
<li><code>ReviewSoftScheduleAdvancer</code> (예정) — Card의 현재 SoftScheduleState + 방금 생성된 ReviewHistory 기반 다음 state 전이</li>
</ul>
<p>반대로 <strong>도메인 서비스로 뽑지 않은 것</strong> 도 기록해 둔다.</p>
<ul>
<li><code>Card.submitReview(grade)</code> — Card 자기 상태만 바꿈 → 엔티티 메서드</li>
<li><code>DifficultyScore.adjust(delta)</code> — 값 객체 내부 계산 → VO 메서드</li>
<li><code>AxisActionDescription</code> 단일 동사 검증 — 값 객체 불변식 → VO 생성자</li>
</ul>
<hr>
<h2 id="회고">회고</h2>
<p>도메인 서비스는 <strong>&quot;엔티티에 넣자니 어색한데, 값 객체는 아닌&quot;</strong> 로직을 위한 피난처가 아니다. 반대로, <strong>여러 엔티티가 협력해야 성립하는 정책</strong>을 명시적으로 드러내기 위한 장치다.</p>
<p>처음엔 <code>AxisAction.recalculateCoverage(materials)</code>로 넣어도 돌아가긴 한다. 그런데 그 순간 AxisAction은 <strong>자기 aggregate 밖의 컬렉션을 파라미터로 받는 애매한 책임</strong>을 짊어지게 되고, 훗날 coverage 정책이 바뀔 때 — 예를 들어 &quot;자료 3개 이상일 때만 COVERED 인정&quot; 같은 규칙이 생길 때 — AxisAction이 수정 대상에 포함된다.</p>
<p>도메인 서비스로 분리해 두면, 정책 변경은 <code>CoverageCalculationService</code> 한 클래스에서 끝난다. Aggregate는 자기 상태 변경에만 집중하고, 정책은 정책대로 격리된다. 이게 DDD에서 도메인 서비스를 별도 레이어로 둘 만한 이유라고 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프] 캐시, 언제 도입해야 할까? ]]></title>
            <link>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%EC%BA%90%EC%8B%9C-%EC%96%B8%EC%A0%9C-%EB%8F%84%EC%9E%85%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%EC%BA%90%EC%8B%9C-%EC%96%B8%EC%A0%9C-%EB%8F%84%EC%9E%85%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Wed, 15 Apr 2026 11:34:20 GMT</pubDate>
            <description><![CDATA[<h1 id="캐시-언제-도입해야-할까--판단-기준">캐시, 언제 도입해야 할까? — 판단 기준</h1>
<p>캐시가 답이 되는 상황은 분명히 존재합니다. 하지만 그게 언제인지 알려면 명확한 기준이 필요합니다.
이 글에서는 캐시 도입을 결정할 때 쓰는 4가지 조건, 4가지 부적합 케이스, 그리고 실전 도입 순서를 정리해보겠습니다.</p>
<hr>
<h2 id="캐시가-적합한-4가지-조건">캐시가 적합한 4가지 조건</h2>
<h3 id="조건-1--읽기-빈도--쓰기-빈도-가장-흔한-시그널">조건 1 — 읽기 빈도 &gt;&gt; 쓰기 빈도 (가장 흔한 시그널)</h3>
<p><strong>핵심 질문: &quot;이 데이터는 얼마나 자주 읽히고, 얼마나 자주 바뀌는가?&quot;</strong></p>
<p>캐시의 가치는 <strong>같은 데이터를 반복해서 읽을 때</strong> 발생합니다.</p>
<pre><code>[적합한 예시]

쇼핑몰 — 상품 상세 정보
  읽기: 상품 페이지 조회 (초당 수백 회)
  쓰기: 상품 정보 수정 (하루 1~2회)
  → 읽기:쓰기 = 수만:1 → 캐시 적합

서비스 공통 — 카테고리 목록, 공지사항
  읽기: 모든 페이지 진입마다 조회
  쓰기: 관리자가 한 달에 몇 번 수정
  → 캐시 적합

[부적합한 예시]

쇼핑몰 — 실시간 재고 수량
  읽기: 상품 페이지 조회마다
  쓰기: 주문 발생마다 차감
  → 읽기:쓰기 비율이 비슷 → 캐시 부적합</code></pre><p>읽기/쓰기 비율을 수치로 확인하고 싶다면:</p>
<pre><code class="language-sql">SELECT
    table_name,
    rows_read,
    rows_inserted + rows_updated + rows_deleted AS rows_written,
    rows_read / NULLIF(rows_inserted + rows_updated + rows_deleted, 0) AS read_write_ratio
FROM information_schema.table_statistics
WHERE table_schema = &#39;your_database&#39;
ORDER BY read_write_ratio DESC;

-- read_write_ratio &gt; 10 → 캐시 효과 있음
-- read_write_ratio &lt; 3  → 캐시 효과 미미</code></pre>
<hr>
<h3 id="조건-2--db-조회-비용이-높다">조건 2 — DB 조회 비용이 높다</h3>
<p><strong>핵심 질문: &quot;이 쿼리를 매번 실행하면 얼마나 비싼가?&quot;</strong></p>
<pre><code>[비용이 높은 케이스 — 캐시 적합]

케이스 1: 복잡한 집계 쿼리
  SELECT category_id,
         COUNT(p.id) AS product_count,
         AVG(p.price) AS avg_price,
         SUM(CASE WHEN p.status = &#39;SOLD_OUT&#39; THEN 1 ELSE 0 END) AS sold_out_count
  FROM product p
  GROUP BY category_id;

  실행시간: 200~500ms (상품 수만 건 기준)
  호출 빈도: 메인 화면 진입마다
  → 캐시 없으면 메인 화면이 느림 → 캐시 적합

케이스 2: 다중 테이블 JOIN
  SELECT u.name, COUNT(o.id) AS order_count, SUM(o.amount) AS total_amount
  FROM orders o
  JOIN users u ON o.user_id = u.id
  JOIN order_items oi ON o.id = oi.order_id
  WHERE o.created_at &gt;= DATE_SUB(NOW(), INTERVAL 7 DAY)
  GROUP BY u.id;

  실행시간: 1~3초
  → 매번 실행하면 사용자 경험 최악 → 캐시 적합

케이스 3: 외부 API 응답
  결제 수단 목록, 환율 정보 등 외부 API
  응답시간: 평균 300ms / 최대 2초
  변경 주기: 거의 없음
  → 캐시 없으면 매번 외부 API 왕복 발생 → 캐시 적합

[비용이 낮은 케이스 — 캐시 불필요]

PK 단건 조회
  SELECT * FROM product WHERE id = ?
  실행시간: 1~5ms (PK 인덱스 활용)
  → 캐시 도입 비용 &gt; 절약 효과 → 인덱스면 충분</code></pre><p>비용이 높은 쿼리는 슬로우 쿼리 로그로 먼저 확인합니다.</p>
<pre><code class="language-sql">SET GLOBAL slow_query_log = &#39;ON&#39;;
SET GLOBAL long_query_time = 0.1;  -- 100ms 이상 쿼리 수집

SELECT * FROM mysql.slow_log
ORDER BY query_time DESC
LIMIT 20;</code></pre>
<hr>
<h3 id="조건-3--결과가-자주-바뀌지-않는다-ttl-안에-변경-가능성-낮음">조건 3 — 결과가 자주 바뀌지 않는다 (TTL 안에 변경 가능성 낮음)</h3>
<p><strong>핵심 질문: &quot;TTL이 만료되기 전에 이 데이터가 바뀔 확률은?&quot;</strong></p>
<p>데이터의 변경 주기에 따라 TTL을 설계해야 합니다.</p>
<table>
<thead>
<tr>
<th>데이터 유형</th>
<th>적절한 TTL</th>
</tr>
</thead>
<tbody><tr>
<td>설정값, 코드성 데이터 (카테고리, 결제 수단 종류)</td>
<td>1시간 ~ 1일</td>
</tr>
<tr>
<td>사용자 프로필 기본 정보</td>
<td>10~30분</td>
</tr>
<tr>
<td>콘텐츠 메타 정보</td>
<td>5~10분</td>
</tr>
<tr>
<td>요약/집계 통계</td>
<td>1~5분</td>
</tr>
<tr>
<td>실시간 데이터 (재고, 잔액)</td>
<td>TTL 적용 자체가 부적합</td>
</tr>
</tbody></table>
<pre><code>[변경 주기 vs TTL 판단]

변경 주기가 1주일인 데이터에 TTL 10분 설정
  → 변경 거의 없는데 10분마다 캐시 미스 + DB 조회 반복
  → TTL을 더 길게 (1시간 이상)

변경 주기가 1분인 데이터에 TTL 10분 설정
  → 최대 10분간 stale 데이터 노출
  → TTL을 짧게 (30초 이하) or 캐시 포기</code></pre><hr>
<h3 id="조건-4--약간의-지연을-허용할-수-있다-eventual-consistency">조건 4 — 약간의 지연을 허용할 수 있다 (Eventual Consistency)</h3>
<p><strong>핵심 질문: &quot;이 데이터가 최신값이 아니어도 비즈니스에 문제가 없는가?&quot;</strong></p>
<pre><code>[허용 — 캐시 적합]

인기 상품 랭킹
  실제: 5분 전 데이터
  사용자 인식: &quot;인기 상품 목록&quot;이 정확히 실시간일 필요 없음
  → TTL 5분 캐시 적합

상품 리뷰 수
  실제: 1분 전 데이터
  사용자 인식: 리뷰 수가 1개 오차여도 무방
  → TTL 1분 캐시 적합

[불허 — 캐시 부적합]

계좌 잔액    → &quot;방금 입금했는데 잔액이 안 바뀜&quot; → 심각한 사용자 불신
쿠폰 사용 여부 → 이미 사용한 쿠폰을 &quot;미사용&quot;으로 읽음 → 중복 사용
재고 수량    → 재고 0인데 캐시에서 1개로 읽음 → 주문 오류</code></pre><hr>
<h2 id="캐시가-부적합한-4가지-케이스">캐시가 부적합한 4가지 케이스</h2>
<h3 id="케이스-1--실시간-정합성이-필수인-데이터">케이스 1 — 실시간 정합성이 필수인 데이터</h3>
<pre><code>[잔액]
사용자 A: 잔액 10,000원 → 캐시(TTL 5분) → 9,000원 사용
캐시에는 여전히 10,000원 → 다시 9,000원 결제 시도
→ 실제 잔액 1,000원인데 통과 → 심각한 오류
→ 해결: 잔액 조회는 항상 DB Primary에서 직접 조회

[재고]
재고 1개 남음 → 캐시에 1개 → 동시 주문 2건
둘 다 캐시에서 1개 읽음 → 둘 다 주문 성공처리 → 재고 -1
→ 해결: 재고 확인은 DB 비관적 락으로 처리. 캐시 사용 안 함.</code></pre><h3 id="케이스-2--쓰기-빈도가-높다">케이스 2 — 쓰기 빈도가 높다</h3>
<p>캐시는 무효화(invalidation) 비용이 존재합니다. 쓰기가 잦으면 캐시 히트율이 낮아져 의미가 없습니다.</p>
<pre><code>히트율 = (읽기 빈도 - 무효화 횟수) / 읽기 빈도

읽기 100, 쓰기(무효화) 80 → 히트율 = 20/100 = 20% → 캐시 불필요
읽기 100, 쓰기(무효화) 5  → 히트율 = 95/100 = 95% → 캐시 효과적</code></pre><h3 id="케이스-3--데이터-다양성이-높다">케이스 3 — 데이터 다양성이 높다</h3>
<pre><code>[개인화된 추천 목록]
사용자마다 추천이 다름 → 캐시 키 10만 개 × 1KB = 100MB (과도)
히트율: 사용자 재방문율에 따라 낮음
→ 추천 결과를 사전 계산해서 DB에 저장하는 것이 낫다

[동적 쿼리 파라미터 조합]
카테고리 × 가격대 × 정렬 × 페이지 → 조합 수: 50 × 10 × 5 × 100 = 250,000가지
대부분의 조합은 1회 조회 후 다시 안 씀 → 히트율 낮음
→ 인기 검색 조합 TOP 100만 선별 캐시하는 것이 효율적</code></pre><h3 id="케이스-4--캐시-관리-비용--성능-이득">케이스 4 — 캐시 관리 비용 &gt; 성능 이득</h3>
<p>인덱스 하나로 해결되는 문제에 캐시를 붙이는 것이 대표적인 케이스입니다.</p>
<pre><code>Before: 인덱스 없는 쿼리 → 500ms
        → 캐시 도입 고려

After:  인덱스 추가 → 2ms
        → 캐시 불필요

캐시 도입 시 추가 비용:
  Redis 서버 관리 + 무효화 로직 + 정합성 모니터링
  → 이 비용이 2ms 절약 효과보다 크다</code></pre><hr>
<h2 id="캐시-도입-순서-원칙">캐시 도입 순서 원칙</h2>
<h3 id="단계-1--측정-먼저">단계 1 — 측정 먼저</h3>
<pre><code>측정 없이 캐시 도입하면:

개발자: &quot;이 쿼리 느릴 것 같아서 캐시 달았어요&quot;
실제:   이 쿼리 실행시간 5ms / 캐시 Redis 왕복 10ms
결과:   캐시가 오히려 더 느림 + 운영 복잡도만 증가</code></pre><p>APM에서 P95 응답시간이 느린 API를 먼저 찾고, 슬로우 쿼리 로그로 병목 쿼리를 확인한 뒤 캐시 대상을 선정합니다.</p>
<h3 id="단계-2--인덱스-튜닝-→-쿼리-최적화-→-캐시-순서">단계 2 — 인덱스 튜닝 → 쿼리 최적화 → 캐시 순서</h3>
<p>캐시는 마지막 수단입니다.</p>
<pre><code>문제 쿼리 (1.2초)

Step 1: EXPLAIN으로 실행계획 확인 → type: ALL (풀스캔) 발견
Step 2: 인덱스 추가 → 1.2초 → 150ms
Step 3: 쿼리 리팩토링 → 150ms → 80ms
Step 4: 80ms도 여전히 느리고 자주 호출됨 → 캐시 도입 → 2ms</code></pre><p>인덱스만으로 1.2초 → 80ms, 캐시는 80ms → 2ms.
<strong>인덱스 튜닝의 임팩트가 훨씬 컸습니다.</strong> 순서를 지키지 않으면 인덱스 문제를 영원히 모르고 지나칩니다.</p>
<h3 id="단계-3--히트율-목표-설정-80-이상">단계 3 — 히트율 목표 설정 (80% 이상)</h3>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/457a5d82-c8a8-4822-bdc5-d683fdd99d9c/image.png" alt=""></p>
<pre><code class="language-bash">redis-cli INFO stats | grep hit

keyspace_hits:   85000
keyspace_misses: 15000
# 히트율 = 85000 / (85000 + 15000) = 85% → 적정 수준</code></pre>
<p>히트율이 낮다면 원인을 먼저 진단합니다.</p>
<table>
<thead>
<tr>
<th>원인</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>TTL이 너무 짧음</td>
<td>TTL 늘리기</td>
</tr>
<tr>
<td>캐시 키 설계 문제</td>
<td>같은 데이터를 다른 키로 중복 저장하는지 확인</td>
</tr>
<tr>
<td>데이터 다양성 너무 높음</td>
<td>캐시 대상 재검토</td>
</tr>
<tr>
<td>배포 직후 Cold Start</td>
<td>캐시 워밍 도입</td>
</tr>
</tbody></table>
<h3 id="단계-4--트레이드오프를-명시하고-결정한다">단계 4 — 트레이드오프를 명시하고 결정한다</h3>
<p>캐시 도입은 항상 <strong>읽기 성능 vs 정합성 지연</strong>의 트레이드오프입니다. 결정한 내용을 코드 주석이나 ADR로 남겨야 합니다.</p>
<pre><code class="language-java">// 카테고리별 통계는 최대 5분 지연될 수 있음 (TTL 기반 캐시)
// 실시간 정확도가 필요하면 캐시 무효화 이벤트 추가 필요
@Cacheable(value = &quot;category-stats&quot;, key = &quot;#categoryId&quot;)
public CategoryStatsResponse getCategoryStats(Long categoryId) {
    return calculateStats(categoryId);
}</code></pre>
<hr>
<h2 id="데이터별-캐시-적합성-판단표">데이터별 캐시 적합성 판단표</h2>
<pre><code>┌──────────────────────┬──────┬──────┬──────────┬──────┬─────────────┐
│ 데이터               │읽기  │쓰기  │지연 허용 │비용  │ 결정        │
├──────────────────────┼──────┼──────┼──────────┼──────┼─────────────┤
│ 상품 상세 정보       │ 높음 │ 낮음 │    O     │ 중   │ 캐시 적합   │
│ 카테고리 목록        │ 높음 │ 낮음 │    O     │ 낮   │ 캐시 적합   │
│ 집계/통계 요약       │ 높음 │ 중간 │    O     │ 높   │ 캐시 적합   │
│ 재고 수량            │ 높음 │ 높음 │    X     │ 중   │ 캐시 불가   │
│ 결제/주문 상태       │ 중간 │ 중간 │    X     │ 낮   │ 캐시 불가   │
│ 사용자 인증 정보     │ 높음 │ 낮음 │    O     │ 낮   │ 세션 캐시   │
│ 개인화 추천 목록     │ 중간 │ 낮음 │    O     │ 높   │ DB 저장 권장│
└──────────────────────┴──────┴──────┴──────────┴──────┴─────────────┘</code></pre><hr>
<h2 id="캐시-계층-구조--l1과-l2를-함께-쓰는-이유">캐시 계층 구조 — L1과 L2를 함께 쓰는 이유</h2>
<p>캐시를 도입하기로 결정했다면, 다음 질문은 <strong>&quot;어느 계층에 둘 것인가&quot;</strong> 입니다.</p>
<h3 id="l1--로컬-캐시-in-process">L1 — 로컬 캐시 (In-Process)</h3>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/009b70f6-367a-48ff-9ff4-d9c784522840/image.png" alt=""></p>
<p>애플리케이션 프로세스 안(JVM 힙)에 데이터를 저장합니다. 네트워크 왕복이 없어서 나노초 단위로 응답합니다.</p>
<pre><code>장점: 극한의 속도 (~100ns), 직렬화 비용 없음
단점: 서버 재시작 시 소멸 / 인스턴스 간 공유 불가
도구: Caffeine, ConcurrentHashMap
적합: 카테고리 목록, 코드성 데이터처럼 변경이 거의 없는 것</code></pre><h4 id="한계-①--서버-재시작-시-캐시-소멸-cache-cold-start">한계 ① — 서버 재시작 시 캐시 소멸 (Cache Cold Start)</h4>
<p>JVM 프로세스가 종료되면 힙이 초기화되므로 캐시도 함께 사라집니다.</p>
<pre><code>배포 발생 → JVM 재시작 → 캐시 전부 소멸
→ 모든 요청이 캐시 MISS
→ DB 요청 폭증 → Connection Pool 고갈 → 타임아웃</code></pre><p>평소에는 L1 히트율 95%로 DB 요청이 50건/초였다면,
재시작 직후에는 히트율 0%로 1,000건/초가 DB로 쏟아집니다.</p>
<p><strong>대응 ① 캐시 워밍업</strong> — <code>ApplicationReadyEvent</code>는 Spring Boot가 완전히 기동되고 트래픽을 받기 직전에 발행됩니다. 이 시점에 주요 데이터를 미리 적재해두면 첫 요청부터 히트가 가능합니다.</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class CacheWarmupRunner {

    private final CategoryService categoryService;

    @EventListener(ApplicationReadyEvent.class)
    public void warmup() {
        try {
            categoryService.findAllCategories();
        } catch (Exception e) {
            log.warn(&quot;[CacheWarmup] 실패 - 계속 진행&quot;, e);
        }
    }
}</code></pre>
<p><strong>대응 ② 롤링 / 카나리 배포</strong> — 서버를 한 번에 전부 재시작하지 않고 순서대로 한 대씩 재시작합니다. 항상 Warm 인스턴스가 대부분의 트래픽을 소화하므로 DB 부하 폭증을 막을 수 있습니다.</p>
<h4 id="한계-②--멀티-인스턴스-데이터-불일치">한계 ② — 멀티 인스턴스 데이터 불일치</h4>
<pre><code>인스턴스 A 캐시: 가격 = 1,000원  ← 갱신됐지만 아직 모름
인스턴스 B 캐시: 가격 = 1,200원  ← 정상
→ 사용자 요청이 A로 라우팅 → 틀린 가격 응답</code></pre><p>L2 공유 캐시를 두거나, Redis Pub/Sub로 무효화 이벤트를 전파해 해결합니다.</p>
<hr>
<h3 id="l2--분산-캐시-out-of-process">L2 — 분산 캐시 (Out-of-Process)</h3>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/0f71743f-f951-4c47-8bdd-914a11671c85/image.png" alt=""></p>
<p>Redis처럼 별도 서버에 캐시를 둡니다. 모든 인스턴스가 같은 캐시를 바라보므로 일관성이 보장됩니다.</p>
<pre><code>장점: 인스턴스 간 공유 / 서버 재시작 후에도 유지 가능
단점: 직렬화 비용 + 네트워크 왕복 (~1ms)
도구: Redis Standalone / Sentinel / Cluster
적합: 세션, 목록 조회, 집계 결과</code></pre><p>DB 대비 10~100배 빠르지만 L1보다는 느립니다. 이 차이는 네트워크가 아니라 <strong>Redis는 key를 받으면 바로 반환하는 반면, DB는 SQL 파싱 → 실행 계획 → 인덱스 탐색까지 거쳐야 하기 때문</strong>입니다.</p>
<p>Redis는 RDB(스냅샷)와 AOF(명령 로그) 두 가지 영속성 옵션을 제공하지만, 캐시 용도라면 영속성을 끄는 경우도 많습니다. 재시작 후 자연스럽게 Cache Miss → DB 조회 → 캐시 재적재가 되기 때문입니다.</p>
<hr>
<h3 id="caffeine을-따로-쓰는-이유">Caffeine을 따로 쓰는 이유</h3>
<p>Redis만 쓰면 안 될까요? 단순히 무겁다거나 빠르다는 이유 때문만은 아닙니다.</p>
<pre><code>Caffeine  → 힙 메모리 참조 한 번 → ~100ns / 직렬화 없음
Redis     → 직렬화 + TCP 송신 + 처리 + TCP 수신 + 역직렬화 → ~1ms</code></pre><p>초당 수만 건의 읽기 요청에서 1ms가 쌓이면 <strong>Redis 자체가 병목</strong>이 됩니다. Caffeine이 앞단에서 대부분을 처리해주면 Redis 부하를 크게 줄일 수 있고, Redis 장애 시 Caffeine이 버퍼 역할도 합니다.</p>
<pre><code>요청
 ↓
L1 (Caffeine) — 히트 시 즉시 반환 (~100ns)
 ↓ 미스
L2 (Redis)    — 히트 시 반환 + L1에도 적재 (~1ms)
 ↓ 미스
DB            — 조회 후 L2 저장 → L1 저장 (~10ms)</code></pre><p>단, 데이터 변경 시 L1과 L2를 모두 무효화해야 하고, 멀티 인스턴스 환경의 L1 불일치는 Pub/Sub 브로드캐스트나 짧은 TTL로 제어합니다.</p>
<blockquote>
<p>L1은 &quot;빠르게&quot;, L2는 &quot;공유하며&quot; — 두 계층을 목적에 맞게 조합하는 것이 실무 캐시 설계의 핵심입니다.</p>
</blockquote>
<hr>
<h2 id="마무리">마무리</h2>
<p>캐시 도입 기준을 한 줄로 정리하면:</p>
<blockquote>
<p><strong>&quot;자주 읽히고, DB 조회 비용이 크고, 잘 안 바뀌고, 약간 오래된 값이어도 괜찮은&quot;</strong> 데이터에만 씁니다.</p>
</blockquote>
<p>도입 전에는 반드시 측정부터, 인덱스와 쿼리 최적화를 먼저 시도한 뒤, 여전히 느릴 때 마지막 수단으로 사용합니다.</p>
<p>캐시는 성능 문제의 최종 해결사가 아니라, <strong>진단을 마친 뒤 선택하는 도구</strong>입니다.</p>
<hr>
<p><em>읽어주셔서 감사합니다. 틀린 내용이나 보완할 부분이 있으면 댓글로 알려주세요 🙏</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프] Schedule 과제 트러블슈팅]]></title>
            <link>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Schedule-%EA%B3%BC%EC%A0%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Schedule-%EA%B3%BC%EC%A0%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Tue, 14 Apr 2026 02:10:26 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/008a4d46-f755-4fa2-8dac-e7c637f4382b/image.png" alt=""></p>
<h1 id="bean-validation-없이-서비스-레이어에서-검증-관리하기--엣지케이스-정리와-메서드-분리">Bean Validation 없이 서비스 레이어에서 검증 관리하기 — 엣지케이스 정리와 메서드 분리</h1>
<blockquote>
<p>스파르타 코딩클럽 일정 관리 과제를 진행하면서,<br>Bean Validation을 사용하지 않는 조건 하에 Schedule·Comment 서비스의 검증 로직을 어떻게 설계할지 고민했던 과정을 정리한다.</p>
</blockquote>
<hr>
<h2 id="배경">배경</h2>
<p>과제 제약 조건은 두 가지였다.</p>
<ul>
<li><strong>Bean Validation 사용 금지</strong> (<code>@NotBlank</code>, <code>@Valid</code> 등)</li>
<li><strong>JPA 연관관계 매핑 사용 금지</strong> (<code>@ManyToOne</code> 등)</li>
</ul>
<p>익숙한 도구 없이 시작하자 검증 코드가 서비스 메서드 안에 뒤섞이기 시작했다.
처음에는 별 생각 없이 각 서비스 메서드 안에 직접 <code>if</code> 블록을 썼는데,
<code>create()</code> 하나만 해도 이런 모습이 됐다.</p>
<pre><code class="language-java">@Transactional
public ScheduleResponse create(ScheduleRequest.Create request) {
    if (request.title() == null || request.title().isBlank())
        throw new IllegalArgumentException(&quot;제목은 필수입니다.&quot;);
    if (request.content() == null || request.content().isBlank())
        throw new IllegalArgumentException(&quot;내용은 필수입니다.&quot;);
    if (request.author() == null || request.author().isBlank())
        throw new IllegalArgumentException(&quot;작성자는 필수입니다.&quot;);
    if (request.password() == null || request.password().isBlank())
        throw new IllegalArgumentException(&quot;비밀번호는 필수입니다.&quot;);

    // ... 실제 생성 로직
}</code></pre>
<p><code>update()</code>, <code>delete()</code>에도 비슷한 블록이 반복되자 두 가지 문제가 눈에 띄었다.</p>
<ol>
<li><strong>가독성</strong>: 서비스 메서드의 핵심 흐름이 검증 코드에 묻혔다.</li>
<li><strong>일관성</strong>: 예외 타입이 <code>IllegalArgumentException</code>이라 에러 응답 포맷이 다른 예외와 달랐다.</li>
</ol>
<hr>
<h2 id="문제-1--검증-코드가-비즈니스-로직과-뒤섞인다">문제 1 — 검증 코드가 비즈니스 로직과 뒤섞인다</h2>
<h3 id="상황">상황</h3>
<p><code>create()</code> 메서드는 두 가지 일을 동시에 하고 있었다.</p>
<ul>
<li>입력값이 유효한지 확인한다 (검증)</li>
<li>유효하다면 일정을 만들어 저장한다 (비즈니스 로직)</li>
</ul>
<p>단일 책임 원칙(SRP)을 생각하면 검증과 핵심 흐름은 분리되어야 한다.</p>
<h3 id="해결--validatexxx-메서드-추출">해결 — <code>validateXxx()</code> 메서드 추출</h3>
<pre><code class="language-java">@Transactional
public ScheduleResponse create(ScheduleRequest.Create request) {
    validateCreate(request);   // 검증은 여기서 끝난다

    Schedule schedule = Schedule.of(
            request.title(),
            request.content(),
            request.author(),
            request.password()
    );
    return ScheduleResponse.from(scheduleRepository.save(schedule));
}

private void validateCreate(ScheduleRequest.Create request) {
    if (isBlank(request.title()))    throw ScheduleException.of(ErrorCode.INVALID_INPUT, &quot;제목은 필수입니다.&quot;);
    if (isBlank(request.content()))  throw ScheduleException.of(ErrorCode.INVALID_INPUT, &quot;내용은 필수입니다.&quot;);
    if (isBlank(request.author()))   throw ScheduleException.of(ErrorCode.INVALID_INPUT, &quot;작성자는 필수입니다.&quot;);
    if (isBlank(request.password())) throw ScheduleException.of(ErrorCode.INVALID_INPUT, &quot;비밀번호는 필수입니다.&quot;);
}

private boolean isBlank(String value) {
    return value == null || value.isBlank();
}</code></pre>
<p><code>create()</code> 메서드는 이제 검증이 통과됐다고 가정하고 핵심 흐름만 담당한다.
<code>validateCreate()</code>가 실패하면 예외가 던져지고 이후 코드는 실행되지 않는다.</p>
<hr>
<h2 id="문제-2--예외-타입이-제각각이라-에러-응답-포맷이-달라진다">문제 2 — 예외 타입이 제각각이라 에러 응답 포맷이 달라진다</h2>
<h3 id="상황-1">상황</h3>
<p><code>IllegalArgumentException</code>을 그대로 쓰면 <code>GlobalExceptionHandler</code>의
<code>handleUnexpected()</code> 핸들러에 잡혀 <code>500</code> 응답이 반환된다.
검증 실패는 <code>400</code>이어야 하는데 응답 코드가 틀렸다.</p>
<pre><code class="language-java">// 의도: 400 Bad Request
// 실제: 500 Internal Server Error (handleUnexpected에 잡힘)
throw new IllegalArgumentException(&quot;제목은 필수입니다.&quot;);</code></pre>
<h3 id="해결--errorcode-열거형--businessexception-계층">해결 — <code>ErrorCode</code> 열거형 + <code>BusinessException</code> 계층</h3>
<pre><code class="language-java">// ErrorCode.java
INVALID_INPUT(400, &quot;입력값이 올바르지 않습니다.&quot;),

// ScheduleException.of()로 던지면 GlobalExceptionHandler가 400으로 처리
throw ScheduleException.of(ErrorCode.INVALID_INPUT, &quot;제목은 필수입니다.&quot;);</code></pre>
<p><code>BusinessException</code>을 상속한 <code>ScheduleException</code>, <code>CommentException</code>을 쓰면
<code>GlobalExceptionHandler</code>의 <code>handleBusiness()</code> 핸들러 하나에서
상태 코드와 메시지를 일관되게 처리할 수 있다.</p>
<pre><code class="language-java">@ExceptionHandler(BusinessException.class)
public ResponseEntity&lt;Map&lt;String, String&gt;&gt; handleBusiness(BusinessException e) {
    ErrorCode code = e.getErrorCode();
    Map&lt;String, String&gt; body = new HashMap&lt;&gt;();
    body.put(&quot;message&quot;, code.getMessage());
    if (e.getDetail() != null) body.put(&quot;detail&quot;, e.getDetail());
    return ResponseEntity.status(code.getStatus()).body(body);
}</code></pre>
<hr>
<h2 id="문제-3--comment에서-검증-대상이-schedule과-comment-두-계층에-걸친다">문제 3 — Comment에서 검증 대상이 Schedule과 Comment 두 계층에 걸친다</h2>
<h3 id="상황-2">상황</h3>
<p>Comment는 Schedule보다 검증해야 할 항목이 많았다.</p>
<table>
<thead>
<tr>
<th>검증 항목</th>
<th>성격</th>
</tr>
</thead>
<tbody><tr>
<td><code>content</code>, <code>author</code>, <code>password</code> 누락</td>
<td>입력값 검증</td>
</tr>
<tr>
<td>존재하지 않는 <code>scheduleId</code></td>
<td>비즈니스 규칙 (외부 리소스 확인)</td>
</tr>
<tr>
<td>해당 일정 댓글 수 10개 초과</td>
<td>비즈니스 규칙 (집계 조건)</td>
</tr>
<tr>
<td><code>commentId</code>가 해당 <code>scheduleId</code> 소속인지</td>
<td>보안 검증</td>
</tr>
</tbody></table>
<p>이것들을 <code>create()</code> 하나에 모두 쓰면 메서드 길이가 급격히 늘어난다.</p>
<h3 id="해결--성격별로-메서드를-분리하고-이름에-의도를-담는다">해결 — 성격별로 메서드를 분리하고 이름에 의도를 담는다</h3>
<pre><code class="language-java">@Transactional
public CommentResponse create(Long scheduleId, CommentRequest.Create request) {
    validateCreate(request);          // 1. 입력값 검증
    verifyScheduleExists(scheduleId); // 2. 일정 존재 여부
    verifyCommentLimit(scheduleId);   // 3. 10개 제한

    Comment comment = Comment.of(scheduleId, request.content(),
                                 request.author(), request.password());
    return CommentResponse.from(commentRepository.save(comment));
}</code></pre>
<p><code>update()</code>와 <code>delete()</code>에는 한 가지 검증이 더 붙는다.</p>
<pre><code class="language-java">@Transactional
public CommentResponse update(Long scheduleId, Long commentId,
                              CommentRequest.Update request) {
    validateUpdate(request);
    verifyScheduleExists(scheduleId);

    Comment comment = getCommentOrThrow(commentId);
    verifyCommentBelongsToSchedule(comment, scheduleId); // 4. 소속 검증
    comment.update(request.content(), request.author(), request.password());
    return CommentResponse.from(comment);
}</code></pre>
<p>각 메서드의 이름이 의도를 설명하므로 <code>create()</code> 흐름을 읽을 때
내부 구현을 볼 필요가 없다.</p>
<hr>
<h2 id="문제-4--verifycommentbelongstoschedule를-빠뜨리면-생기는-보안-구멍">문제 4 — <code>verifyCommentBelongsToSchedule</code>를 빠뜨리면 생기는 보안 구멍</h2>
<h3 id="상황-3">상황</h3>
<p>URL은 <code>/api/schedules/{scheduleId}/comments/{commentId}</code> 구조다.
commentId만 확인하고 scheduleId 소속 여부를 검증하지 않으면
아래 요청이 통과된다.</p>
<pre><code>PATCH /api/schedules/1/comments/99</code></pre><p><code>scheduleId=1</code>의 댓글이 아닌 <code>commentId=99</code>(다른 일정 소속)를
경로에 조합해도 수정이 성공한다.</p>
<h3 id="해결--소속-검증-메서드-추가">해결 — 소속 검증 메서드 추가</h3>
<pre><code class="language-java">private void verifyCommentBelongsToSchedule(Comment comment, Long scheduleId) {
    if (!comment.getScheduleId().equals(scheduleId)) {
        throw CommentException.of(ErrorCode.COMMENT_NOT_FOUND);
    }
}</code></pre>
<p>존재하는 댓글이지만 해당 일정 소속이 아닐 때 <code>404</code>를 반환한다.
<code>403</code>이나 별도 에러 코드를 쓰지 않은 이유는,
소속이 다른 댓글의 존재 자체를 클라이언트에게 알릴 필요가 없기 때문이다.</p>
<hr>
<h2 id="최종-구조-요약">최종 구조 요약</h2>
<pre><code>서비스 메서드 (create / update / delete)
├── validateXxx()             → 입력값 누락·형식 검증 (400)
├── verifyScheduleExists()    → 일정 존재 확인 (404)
├── verifyCommentLimit()      → 댓글 10개 제한 (400)  [생성 전용]
├── getCommentOrThrow()       → 댓글 존재 확인 (404)  [수정·삭제]
└── verifyCommentBelongsToSchedule()  → 소속 검증 (404)  [수정·삭제]</code></pre><p>서비스 메서드 자체는 이 흐름을 읽는 목차 역할만 하고,
각 검증의 구체적인 조건은 분리된 메서드 안에 캡슐화된다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>Bean Validation을 쓸 수 없는 상황에서 검증을 서비스에서 직접 관리할 때
어렵게 느껴졌던 부분은 <strong>검증의 성격이 다양하다</strong>는 점이었다.
단순 null 체크부터 DB 조회가 필요한 비즈니스 규칙, 보안 목적의 소속 검증까지
한 메서드에 섞이면 의도를 읽기 어렵다.</p>
<p>해결 방향은 단순했다.</p>
<blockquote>
<p><strong>성격이 다른 검증은 이름이 다른 메서드로 분리한다.</strong></p>
</blockquote>
<p>메서드 추출 자체는 간단하지만, 이름에 의도를 담는 것이
코드를 읽는 사람(미래의 나 포함)에게 가장 큰 도움이 됐다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA를 쓴다면 꼭 알아야 할 것들]]></title>
            <link>https://velog.io/@js-kim-arc/JPA%EB%A5%BC-%EC%93%B4%EB%8B%A4%EB%A9%B4-%EA%BC%AD-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%A0-%EA%B2%83%EB%93%A4</link>
            <guid>https://velog.io/@js-kim-arc/JPA%EB%A5%BC-%EC%93%B4%EB%8B%A4%EB%A9%B4-%EA%BC%AD-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%A0-%EA%B2%83%EB%93%A4</guid>
            <pubDate>Mon, 13 Apr 2026 00:56:43 GMT</pubDate>
            <description><![CDATA[<h1 id="jpa를-쓴다면-꼭-알아야-할-것들--1차-캐시-프록시-영속성-컨텍스트">JPA를 쓴다면 꼭 알아야 할 것들 — 1차 캐시, 프록시, 영속성 컨텍스트</h1>
<blockquote>
<p>&quot;JPA가 알아서 해준다&quot;는 말을 믿기 전에, 무엇을 어떻게 해주는지는 알아야 한다.</p>
</blockquote>
<hr>
<h2 id="영속성-컨텍스트란">영속성 컨텍스트란?</h2>
<p>JPA에서 엔티티를 관리하는 <strong>논리적인 저장 공간</strong>이다.<br><code>EntityManager</code>가 이 컨텍스트를 통해 엔티티의 생명주기를 제어하며,<br>Spring Data JPA 환경에서는 <code>@Transactional</code> 범위가 곧 영속성 컨텍스트의 범위다.</p>
<p>엔티티는 4가지 상태를 가진다.</p>
<p>비영속 (new)       → new Member()
영속   (managed)   → em.persist() / repository.findById()
준영속 (detached)  → 트랜잭션 종료 후
삭제   (removed)   → em.remove()</p>
<p>이 생명주기를 이해하는 것이 JPA를 안전하게 쓰는 출발점이다.</p>
<hr>
<h2 id="이점-1--1차-캐시-first-level-cache">이점 1 — 1차 캐시 (First-Level Cache)</h2>
<h3 id="동작-방식">동작 방식</h3>
<p>같은 트랜잭션 안에서 동일한 ID로 조회하면, 영속성 컨텍스트 내부의 Map에서 먼저 찾는다.<br>있으면 DB를 거치지 않고 바로 반환한다.</p>
<p>findById(1L) 호출
→ 영속성 컨텍스트에 있는가?
YES → 캐시에서 반환 (SELECT 없음)
NO  → DB SELECT → 컨텍스트에 저장 → 반환</p>
<pre><code class="language-java">@Transactional
public void example() {
    Member m1 = memberRepository.findById(1L).get(); // SELECT 발생
    Member m2 = memberRepository.findById(1L).get(); // 캐시 반환, 쿼리 없음

    System.out.println(m1 == m2); // true — 동일 인스턴스 보장
}</code></pre>
<p>동일 인스턴스를 반환하기 때문에 <strong>동일성(identity) 보장</strong>도 함께 따라온다.<br>같은 트랜잭션 안에서 동일 ID의 엔티티는 항상 <code>==</code> 비교가 <code>true</code>다.</p>
<h3 id="⚠️-주의-1--트랜잭션-범위-밖에선-캐시가-없다">⚠️ 주의 1 — 트랜잭션 범위 밖에선 캐시가 없다</h3>
<p>1차 캐시는 트랜잭션마다 독립적으로 생성되고, 종료되면 사라진다.<br>서로 다른 요청 간에는 절대 공유되지 않는다.<br>애플리케이션 전역 캐시(Redis, Caffeine)와 혼동하면 안 된다. (중요한 참고사항입니다.) </p>
<pre><code class="language-java">// 요청 A — 트랜잭션 1
memberRepository.findById(1L); // SELECT 발생

// 요청 B — 트랜잭션 2 (완전히 독립)
memberRepository.findById(1L); // 다시 SELECT 발생 — 공유 없음</code></pre>
<h3 id="⚠️-주의-2--jpql--native-query는-캐시를-우회한다">⚠️ 주의 2 — JPQL / Native Query는 캐시를 우회한다</h3>
<p><code>findById</code>는 1차 캐시를 먼저 확인하지만,<br>JPQL이나 <code>@Query</code>로 작성한 쿼리는 항상 DB를 직접 조회한다.</p>
<pre><code class="language-java">@Transactional
public void example() {
    Member m1 = memberRepository.findById(1L).get(); // 캐시에 저장

    // JPQL — DB를 직접 조회 후 컨텍스트에 병합
    Member m2 = em.createQuery(&quot;SELECT m FROM Member m WHERE m.id = 1&quot;, Member.class)
                  .getSingleResult();

    System.out.println(m1 == m2); // true — 병합 후 동일 인스턴스 반환
}</code></pre>
<p>결과는 같은 인스턴스지만, <strong>DB 쿼리는 반드시 나간다.</strong><br>캐시 덕에 쿼리를 아낄 거라고 기대하면 안 된다. -&gt; 이게 결론입니다. </p>
<hr>
<h2 id="이점-2--변경-감지-dirty-checking">이점 2 — 변경 감지 (Dirty Checking)</h2>
<h3 id="동작-방식-1">동작 방식</h3>
<p>영속성 컨텍스트는 엔티티를 저장할 때 <strong>스냅샷(초기 상태 복사본)</strong>도 함께 저장한다.<br>트랜잭션 커밋 시점(<code>flush</code>)에 현재 엔티티와 스냅샷을 비교해서,<br>변경된 필드가 있으면 UPDATE 쿼리를 자동으로 생성한다.</p>
<p>[엔티티 조회] → 스냅샷 저장
↓
[엔티티 필드 변경]
↓
[트랜잭션 커밋 → flush]
↓
[현재 상태 vs 스냅샷 비교]
↓
[변경 감지 → UPDATE 쿼리 실행]</p>
<pre><code class="language-java">@Transactional
public void updateName(Long id, String newName) {
    Member member = memberRepository.findById(id).get();
    member.changeName(newName); // save() 호출 없음
    // 커밋 시 자동으로 UPDATE member SET name = ? WHERE id = ?
}</code></pre>
<hr>
<h2 id="이점-3--프록시와-지연-로딩-proxy--lazy-loading">이점 3 — 프록시와 지연 로딩 (Proxy &amp; Lazy Loading)</h2>
<h3 id="동작-방식-2">동작 방식</h3>
<p><code>FetchType.LAZY</code>로 설정된 연관 엔티티는 실제 객체 대신 <strong>프록시 객체</strong>로 주입된다.<br>프록시는 실제 클래스를 상속한 껍데기로, 처음 필드에 접근하는 순간 DB를 조회한다.</p>
<pre><code class="language-java">@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    private Member member; // 처음엔 프록시 객체
}

@Transactional
public void printOrderInfo(Long orderId) {
    Order order = orderRepository.findById(orderId).get();
    // member는 아직 프록시 — SELECT 없음

    String name = order.getMember().getName();
    // getName() 호출 시점에 SELECT member WHERE id = ? 발생
}</code></pre>
<p>필요할 때만 조회하기 때문에 <strong>불필요한 JOIN을 줄이고 첫 조회 성능을 높인다.</strong></p>
<h3 id="⚠️-주의-1--lazyinitializationexception">⚠️ 주의 1 — LazyInitializationException</h3>
<p>트랜잭션이 종료된 후 프록시를 초기화하면 예외가 발생한다.</p>
<pre><code class="language-java">// 서비스 — 트랜잭션 없음
public Order getOrder(Long id) {
    return orderRepository.findById(id).get(); // 영속성 컨텍스트 없이 반환
}

// 컨트롤러
Order order = service.getOrder(1L);
order.getMember().getName(); // ❌ LazyInitializationException</code></pre>
<p><strong>해결책 1</strong> — 트랜잭션 안에서 DTO로 변환해서 반환한다. (가장 권장)</p>
<pre><code class="language-java">@Transactional(readOnly = true)
public OrderResponse getOrder(Long id) {
    Order order = orderRepository.findById(id).get();
    return new OrderResponse(order.getId(), order.getMember().getName()); // 컨텍스트 안에서 초기화
}</code></pre>
<p><strong>해결책 2</strong> — fetch join으로 한 번에 가져온다.</p>
<pre><code class="language-java">@Query(&quot;SELECT o FROM Order o JOIN FETCH o.member WHERE o.id = :id&quot;)
Optional&lt;Order&gt; findWithMember(@Param(&quot;id&quot;) Long id);</code></pre>
<h3 id="⚠️-주의-2--n1-문제">⚠️ 주의 2 — N+1 문제</h3>
<p>리스트 조회 후 각 엔티티의 연관 객체를 순회하면, 쿼리가 N번 추가로 나간다.</p>
<pre><code class="language-java">List&lt;Order&gt; orders = orderRepository.findAll(); // 쿼리 1번

for (Order o : orders) {
    System.out.println(o.getMember().getName());
    // orders 개수만큼 추가 SELECT 발생 → 100건이면 101번 쿼리
}</code></pre>
<p><strong>해결책 — fetch join 또는 @EntityGraph</strong></p>
<pre><code class="language-java">// fetch join
@Query(&quot;SELECT o FROM Order o JOIN FETCH o.member&quot;)
List&lt;Order&gt; findAllWithMember();

// @EntityGraph
@EntityGraph(attributePaths = {&quot;member&quot;})
List&lt;Order&gt; findAll();</code></pre>
<p>단, 컬렉션(<code>@OneToMany</code>)에 fetch join을 쓰면 <strong>중복 데이터</strong>가 생길 수 있다.<br>이 경우 <code>DISTINCT</code>를 붙이거나 <code>@BatchSize</code>를 고려한다.</p>
<pre><code class="language-java">// @OneToMany + fetch join 시 중복 방지
@Query(&quot;SELECT DISTINCT o FROM Order o JOIN FETCH o.items&quot;)
List&lt;Order&gt; findAllWithItems();</code></pre>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>기능</th>
<th>이점</th>
<th>핵심 주의 사항</th>
</tr>
</thead>
<tbody><tr>
<td>1차 캐시</td>
<td>동일 트랜잭션 내 반복 조회 제거, 동일성 보장</td>
<td>JPQL은 캐시 우회 / 트랜잭션 간 공유 없음</td>
</tr>
<tr>
<td>변경 감지</td>
<td><code>save()</code> 없이 자동 UPDATE</td>
<td>준영속 상태에선 무효 / 기본은 전체 필드 UPDATE</td>
</tr>
<tr>
<td>지연 로딩</td>
<td>불필요한 JOIN 제거, 첫 로딩 성능 향상</td>
<td>LazyInitializationException / N+1</td>
</tr>
</tbody></table>
<blockquote>
<p>JPA의 편리함은 영속성 컨텍스트의 생명주기와 경계를 이해했을 때 비로소 안전하게 쓸 수 있다.<br><strong>&quot;트랜잭션 밖은 위험하다&quot;</strong> — 이 하나만 항상 기억해도 절반은 맞다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third tool] 테이블을 설계하고 나서 끝이 아니었다 — 데이터 전략 문서를 쓰기 시작한 이유]]></title>
            <link>https://velog.io/@js-kim-arc/Third-tool-%ED%85%8C%EC%9D%B4%EB%B8%94%EC%9D%84-%EC%84%A4%EA%B3%84%ED%95%98%EA%B3%A0-%EB%82%98%EC%84%9C-%EB%81%9D%EC%9D%B4-%EC%95%84%EB%8B%88%EC%97%88%EB%8B%A4-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%84%EB%9E%B5-%EB%AC%B8%EC%84%9C%EB%A5%BC-%EC%93%B0%EA%B8%B0-%EC%8B%9C%EC%9E%91%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@js-kim-arc/Third-tool-%ED%85%8C%EC%9D%B4%EB%B8%94%EC%9D%84-%EC%84%A4%EA%B3%84%ED%95%98%EA%B3%A0-%EB%82%98%EC%84%9C-%EB%81%9D%EC%9D%B4-%EC%95%84%EB%8B%88%EC%97%88%EB%8B%A4-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%84%EB%9E%B5-%EB%AC%B8%EC%84%9C%EB%A5%BC-%EC%93%B0%EA%B8%B0-%EC%8B%9C%EC%9E%91%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Wed, 08 Apr 2026 10:32:42 GMT</pubDate>
            <description><![CDATA[<hr>
<h2 id="목차">목차</h2>
<ul>
<li><a href="#%EC%B2%98%EC%9D%8C%EC%97%94-%EA%B7%B8%EB%83%A5-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4">처음엔 그냥 만들었다</a></li>
<li><a href="#%ED%9D%94%EB%93%A4%EB%A6%AC%EA%B8%B0-%EC%8B%9C%EC%9E%91%ED%95%9C-%EC%88%9C%EA%B0%84%EB%93%A4">흔들리기 시작한 순간들</a></li>
<li><a href="#%EC%9E%90%EA%B0%81%EC%9D%98-%EA%B3%BC%EC%A0%95--4%EC%B6%95%EC%9D%B4%EB%9D%BC%EB%8A%94-%EC%96%B8%EC%96%B4%EB%A5%BC-%EC%96%BB%EB%8B%A4">자각의 과정 — 4축이라는 언어를 얻다</a></li>
<li><a href="#%EA%B7%B8%EB%9E%98%EC%84%9C-%EB%AC%B8%EC%84%9C%EB%A5%BC-%EC%93%B0%EA%B8%B0-%EC%8B%9C%EC%9E%91%ED%96%88%EB%8B%A4">그래서 문서를 쓰기 시작했다</a></li>
<li><a href="#%EB%AC%B8%EC%84%9C%EB%A5%BC-%EA%B4%80%EB%A6%AC%ED%95%98%EB%A9%B4%EC%84%9C-%EB%8B%AC%EB%9D%BC%EC%A7%84-%EA%B2%83%EB%93%A4">문서를 관리하면서 달라진 것들</a></li>
<li><a href="#%EC%95%84%EC%A7%81-%EB%82%A8%EC%9D%80-%EA%B2%83%EB%93%A4">아직 남은 것들</a></li>
<li><a href="#%EB%A7%88%EC%B9%98%EB%A9%B0">마치며</a></li>
</ul>
<hr>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/77f3f46e-6aba-42d1-bc51-a7530a2b4795/image.png" alt=""></p>
<h1 id="테이블을-설계하고-나서-끝이-아니었다--데이터-전략-문서를-쓰기-시작한-이유">테이블을 설계하고 나서 끝이 아니었다 — 데이터 전략 문서를 쓰기 시작한 이유</h1>
<blockquote>
<p>ThirdTool을 개발하면서 처음으로 테이블이 &quot;살아있는 것&quot;처럼 느껴졌다. 아무것도 몰랐던 시절의 이야기다.</p>
</blockquote>
<hr>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/888ba7ac-97a2-42f8-8c65-67184d11acd6/image.png" alt=""></p>
<h2 id="처음엔-그냥-만들었다">처음엔 그냥 만들었다</h2>
<p>백엔드 개발을 시작하고 꽤 오랫동안, 나에게 테이블 설계란 &quot;지금 필요한 컬럼을 적는 것&quot;이었다.</p>
<p>카드가 필요하면 <code>card</code> 테이블을 만들었다. 상태가 필요하면 <code>status</code> 컬럼을 추가했다. 삭제가 필요하면 <code>deleted_at</code>을 달았다. ERD를 그리면 설계가 끝난 것이라고 생각했다.</p>
<p>그런데 ThirdTool을 개발하면서 이 생각이 흔들리기 시작했다.</p>
<hr>
<h2 id="결심을-하기-시작한-순간들">결심을 하기 시작한 순간들</h2>
<p>처음 균열이 생긴 건 <code>card_status_history</code> 테이블을 설계할 때였다.</p>
<p>카드가 ON_FIELD에서 ARCHIVE로 이동할 때마다 이력 한 건이 쌓인다. 지금은 테스트 데이터 몇 십 건이지만, 유저가 늘어나면 어떻게 될까? <strong>유저 수 × 카드 수 × 전환 횟수</strong>. 어느 시점에는 분명히 테이블이 터진다.</p>
<p>그때 처음으로 &quot;이 테이블이 얼마나 빨리 자라는지&quot;를 의식했다. </p>
<p>비슷한 일이 또 있었다. <code>card</code> 테이블에 <code>view_count</code> 컬럼을 추가하면서였다. 리뷰 세션 도중 <code>recordView()</code>가 호출될 때마다 UPDATE가 발생한다. 빈번한 쓰기다. 그런데 카드의 <code>archive()</code> 상태 전환은 반드시 정합하게 처리해야 한다. 같은 테이블 안에서 <strong>&quot;오차가 허용되는 쓰기&quot;와 &quot;절대 틀리면 안 되는 쓰기&quot;가 공존</strong>한다는 걸 깨달았다.</p>
<p>그 다음은 <code>tag</code> 테이블이었다. <code>findByValue()</code>는 카드에 태그를 붙일 때마다 호출된다. 지금은 DB 직접 조회로 충분하지만, 태그가 쌓이고 호출 빈도가 높아지면 어느 순간 캐시가 필요해진다. 언제? 어떤 기준으로?</p>
<p>문제는 <strong>이것들을 그때그때 머릿속으로만 생각하고 지나쳤다는 것</strong>이다. 코드에 반영하고 나면 왜 그런 결정을 했는지 기억이 흐려졌다. 나중에 다시 보면 &quot;왜 여기 인덱스가 있지?&quot; 싶을 때가 생겼다.</p>
<hr>
<h2 id="자각의-과정--4축이라는-언어를-얻다">자각의 과정 — 4축이라는 언어를 얻다</h2>
<p>개발을 이어가면서 테이블마다 비슷한 질문이 반복된다는 걸 느꼈다.
()</p>
<ul>
<li>이 테이블의 데이터는 수정이 많은가, 한 번 쓰고 끝인가?</li>
<li>얼마나 빨리 행이 쌓이는가?</li>
<li>주로 읽는가, 주로 쓰는가?</li>
<li>정합성이 얼마나 중요한가?</li>
</ul>
<p>이 질문들을 <strong>4가지 축</strong>으로 정리했다.</p>
<table>
<thead>
<tr>
<th>축</th>
<th>의미</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Lifecycle</strong></td>
<td>데이터가 어떻게 바뀌는가</td>
<td>Mutable / Immutable / Soft Delete</td>
</tr>
<tr>
<td><strong>Growth Rate</strong></td>
<td>얼마나 빨리 쌓이는가</td>
<td>Slow-growing / Fast-growing</td>
</tr>
<tr>
<td><strong>Access Pattern</strong></td>
<td>읽기 위주인가, 쓰기 위주인가</td>
<td>Read-heavy / Write-heavy / 혼합</td>
</tr>
<tr>
<td><strong>Consistency</strong></td>
<td>정합성이 얼마나 중요한가</td>
<td>Strong / Eventual</td>
</tr>
</tbody></table>
<p>예를 들어, <code>card_status_history</code>는 이렇게 분류된다.</p>
<pre><code>Lifecycle     → Immutable (한 번 기록하면 절대 수정하지 않는다)
Growth Rate   → Medium → Fast-growing (유저가 늘수록 급격히 증가)
Access Pattern → Write-heavy + Sparse Read (매 전환마다 INSERT, 조회는 분석 용도)
Consistency   → Eventual (이력 기록 실패가 전환 자체를 롤백하지 않는다)</code></pre><p>분류 결과에서 전략이 나온다. Immutable이니까 <code>updated_at</code>은 없어도 된다. Fast-growing이니까 1000만 건을 넘으면 파티셔닝을 고려한다.(진짜 나중 이야기일지도 모르지만 지금부터 조금씩 관리하는 연습을 위해서) Write-heavy니까 분석 쿼리가 서비스 DB에 영향을 주기 시작하면 Read Replica를 분리한다.</p>
<p>이 언어가 생기고 나서야, 테이블 하나하나를 새로운 요구사항, 분석에 따라 바뀌게 될 때도 바꾸는 전략에 대해서 좀 더 유동적으로 생각할 수 있게 되었다. </p>
<hr>
<h2 id="관리-문서의-updqte---table-전략-문서">관리 문서의 updqte - table 전략 문서</h2>
<p>4축으로 분류만 하고 코드에 묻어두면 결국 또 잊는다. 그래서 각 테이블마다 정형화된 문서를 쓰기 시작했다.</p>
<p>문서의 구조는 이렇다.</p>
<pre><code>테이블 이름
├── 4축 분류 (표)
├── 현재 전략 (선택 이유 포함)
├── 현재 스키마 핵심 (SQL)
├── 모니터링 기준 &amp; 전환 트리거 (표)
└── 미결 설계 과제 (체크리스트)</code></pre><p>여기서 핵심은 <strong>&quot;모니터링 기준 &amp; 전환 트리거&quot;</strong> 섹션이다.</p>
<p>예를 들어 <code>card</code> 테이블의 경우 이런 식이다.</p>
<table>
<thead>
<tr>
<th>지표</th>
<th>현재 전략</th>
<th>전환 기준</th>
<th>전환 전략</th>
</tr>
</thead>
<tbody><tr>
<td>전체 row 수</td>
<td>파티셔닝 없음</td>
<td>500만 건 초과</td>
<td>RANGE(created_date) 파티셔닝</td>
</tr>
<tr>
<td><code>deleted=true</code> 비율</td>
<td>보관 정책 없음</td>
<td>전체의 30% 초과</td>
<td>아카이브 테이블 이동 배치</td>
</tr>
<tr>
<td><code>viewCount</code> 동시 증가</td>
<td>단순 UPDATE</td>
<td>동시 세션 충돌 발생</td>
<td>낙관적 락 (version 컬럼)</td>
</tr>
</tbody></table>
<p>지금 당장 낙관적 락을 걸지 않는다. 단일 유저 단일 세션 구조에서 동시 수정 빈도가 낮기 때문이다. <strong>하지만 언제, 어떤 신호가 오면 전환할지는 미리 써둔다.</strong></p>
<p>이 한 줄이 있는 것과 없는 것의 차이가 크다. 나중에 &quot;낙관적 락 써야 하나?&quot;를 처음부터 다시 고민하지 않아도 된다. 이미 근거가 있고, 트리거가 있다. </p>
<hr>
<h2 id="문서를-관리하면서-달라진-것들">문서를 관리하면서 달라진 것들</h2>
<h3 id="1-설계-결정이-코드-밖에서도-살아남는다">1. 설계 결정이 코드 밖에서도 살아남는다</h3>
<p><code>card</code> 테이블에 <code>max_view</code>, <code>max_duration</code> 컬럼을 넣지 않기로 했다. budget은 <code>user_schedule_config</code>에서 런타임에 파생한다. 이 결정의 트레이드오프를 문서에 이렇게 적었다.</p>
<pre><code>트레이드오프:
  Card 조회 시 budget 조인 불필요 / budget 변경 시 card 행 재기록 없음.
  반면 CardExpiryPolicy 실행 시 configRepository 조회 필수.</code></pre><p>코드만 보면 &quot;왜 card에 컬럼이 없지?&quot;라는 의문이 생긴다. 문서가 있으면 의도가 보인다.</p>
<h3 id="2-성장-시나리오가-구체화된다">2. 성장 시나리오가 구체화된다</h3>
<p><code>card_status_history</code>는 지금은 별 문제 없다. 하지만 문서를 쓰면서 &quot;1000만 건이 넘으면 파티셔닝&quot;이라는 숫자를 적는 순간, 그게 언제 올지를 진지하게 생각하게 된다. 유저 100명이 카드 100장씩 가지고 하루 5번 전환하면 하루 5만 건. 200일이면 1000만 건이다. 생각보다 빨리 온다.</p>
<p>이 숫자가 있고 없고의 차이는, 나중에 당황하느냐 미리 준비하느냐의 차이다.</p>
<h3 id="3-지금은-안-해도-된다는-결정이-명확해진다">3. &quot;지금은 안 해도 된다&quot;는 결정이 명확해진다</h3>
<p>소규모 서비스에서 모든 것을 처음부터 최적화하면 오버엔지니어링이다. 하지만 아무 기준 없이 미루는 것과, <strong>명확한 트리거를 설정하고 의식적으로 미루는 것</strong>은 다르다.</p>
<p><code>tag</code> 테이블의 <code>findByValue()</code> 캐싱 전략을 예로 들면:</p>
<pre><code>현재 전략: DB 직접 조회
전환 기준: 초당 100회 이상 호출 시
전환 전략: Redis 캐시 (TTL 1시간 + CacheEvict)</code></pre><p>지금은 캐시가 없다. 하지만 언제 달아야 하는지는 안다. 이것만으로도 충분히 다르다.</p>
<hr>
<h2 id="아직-남은-것들">아직 남은 것들</h2>
<p>솔직히 이 문서가 완전하다고 생각하지 않는다. 몇 가지 미결 과제가 있다.(앞으로 진행을 하면서 좋은 것은 남기고, 아쉬운 것은 계속 지우면서 문서 규칙도 업데이트 해야할 것 같다.)</p>
<p>첫째, <strong>모니터링 기준의 수치가 근거 없이 직관적</strong>이다. &quot;500만 건이면 파티셔닝&quot;이 맞는 수치인지, 실제 쿼리 계획을 보고 검증한 건 아니다. k6 부하 테스트나 EXPLAIN ANALYZE를 통해 실측 기반으로 보강해야 한다.</p>
<p>둘째, <strong>문서와 코드 사이의 드리프트</strong>다. 문서를 업데이트하지 않으면 코드는 바뀌는데 문서는 과거에 머문다. ADR처럼 변경 이력을 날짜와 함께 관리하는 습관이 필요하다.</p>
<p>셋째, <strong>아직 <code>UserScheduleConfig BC</code> 문서가 미완성</strong>이다. Card BC와 짝을 이루는 데이터 전략 문서인데, 이쪽도 같은 형식으로 써야 한다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>테이블 설계는 ERD를 그리는 순간에 끝나지 않는다. 데이터가 어떻게 자라고, 어떻게 바뀌고, 언제 전략을 바꿔야 하는지를 의식적으로 추적하는 일이 함께 따라온다.</p>
<p>4축 분류와 전환 트리거라는 언어를 얻고 나서, 처음으로 테이블 하나하나를 <strong>다르게</strong> 볼 수 있게 됐다. <code>card_status_history</code>는 더 이상 그냥 이력 테이블이 아니다. Immutable하고, 유저가 늘수록 빠르게 자라고, 1000만 건이 넘으면 파티셔닝을 검토해야 하는 테이블이다.</p>
<p>아직 부족한 게 많지만, 이 문서를 유지하면서 계속 업데이트할 생각이다. Live 문서라고 적어둔 이유가 거기 있다.</p>
<hr>
<p><em>ThirdTool — 간격 반복 학습 플랫폼 개발 중. 관련 ADR과 데이터 전략 문서는 지속적으로 업데이트 중.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third tool] Deck 삭제했는데 Card가 살아있다 — Soft Delete 연쇄 누락 트러블슈팅]]></title>
            <link>https://velog.io/@js-kim-arc/Third-tool-Deck-%EC%82%AD%EC%A0%9C%ED%96%88%EB%8A%94%EB%8D%B0-Card%EA%B0%80-%EC%82%B4%EC%95%84%EC%9E%88%EB%8B%A4-Soft-Delete-%EC%97%B0%EC%87%84-%EB%88%84%EB%9D%BD-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@js-kim-arc/Third-tool-Deck-%EC%82%AD%EC%A0%9C%ED%96%88%EB%8A%94%EB%8D%B0-Card%EA%B0%80-%EC%82%B4%EC%95%84%EC%9E%88%EB%8B%A4-Soft-Delete-%EC%97%B0%EC%87%84-%EB%88%84%EB%9D%BD-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Tue, 07 Apr 2026 10:57:23 GMT</pubDate>
            <description><![CDATA[<h1 id="cascadetyperemove를-없앤-이유-그리고-놓친-것">CascadeType.REMOVE를 없앤 이유, 그리고 놓친 것</h1>
<h2 id="들어가며">들어가며</h2>
<p>ThirdTool을 개발하면서내린 설계 결정 중 하나가
<code>CascadeType.REMOVE</code>와 <code>orphanRemoval</code>을 제거하는 것이었다.</p>
<p>이유는 명확했다. Soft Delete 구조에서 JPA의 물리 삭제 캐스케이드는
오히려 독이 된다. <code>orphanRemoval = true</code>인 상태에서 부모 엔티티의
컬렉션에서 자식을 분리하면 DELETE 쿼리가 나간다.
Soft Delete를 쓰는 시스템에서는 절대 원하지 않는 동작이다.</p>
<p>ADR-003에 이 결정을 기록했고, 당시에는 올바른 판단이라고 생각했다.</p>
<p>그런데 며칠 후, QA 중에 이상한 현상을 발견했다.</p>
<hr>
<h2 id="증상">증상</h2>
<p>Deck을 논리 삭제했는데, 하위 Card들의 <code>deleted</code> 필드가 <code>false</code> 그대로
남아 있었다.</p>
<pre><code class="language-sql">-- Deck은 삭제됨
SELECT deleted FROM decks WHERE id = 42;
-- → true

-- Card는 살아있음
SELECT deleted FROM cards WHERE deck_id = 42;
-- → false, false, false ...</code></pre>
<p><code>CardQueryService</code>에서 <code>deck_id</code>로 카드를 조회하면 이미 삭제된 덱의
카드들이 버젓이 반환되고 있었다.</p>
<p>처음엔 쿼리 조건 문제인 줄 알았다. WHERE 절에 <code>deleted = false</code> 필터가
빠진 건 아닌지 한참 뒤졌다. 문제는 다른 곳에 있었다.</p>
<hr>
<h2 id="원인-파악">원인 파악</h2>
<p><code>DeckCommandService</code>의 삭제 로직을 열어봤다.</p>
<pre><code class="language-java">// ❌ 문제가 된 코드
public void softDelete(Long deckId) {
    Deck deck = deckRepository.findById(deckId).orElseThrow();
    deck.softDelete();
    deckRepository.save(deck);
    // Card에 대한 처리가 아무것도 없다
}</code></pre>
<p>원인은 단순했다. <code>CascadeType.REMOVE</code>를 제거하면서 JPA가 자동으로
Card를 처리하지 않게 됐는데, Application Service에서 카드를 직접
순회하며 <code>softDelete()</code>를 호출하는 코드를 빠뜨린 것이다.</p>
<p>JPA Cascade가 있을 때는 Deck을 삭제하면 Card도 따라서 삭제됐다.
물리 삭제지만 어쨌든 &quot;연쇄&quot;는 동작했다. Cascade를 걷어내는 순간,
그 연쇄의 책임이 JPA에서 Application Service로 이동한다.</p>
<p>그 책임 이동을 코드로 구현하지 않았다.</p>
<pre><code>CascadeType.REMOVE 있을 때:
  Deck.delete() → JPA가 Card DELETE 쿼리 자동 발행

CascadeType.REMOVE 제거 후:
  Deck.softDelete() → Deck만 처리됨
  Card.softDelete() → Application Service가 명시적으로 호출해야 함</code></pre><hr>
<h2 id="수정">수정</h2>
<pre><code class="language-java">// ✅ 수정된 코드
public void softDelete(Long deckId) {
    Deck deck = deckRepository.findById(deckId).orElseThrow();

    deck.softDelete();

    // Card 연쇄 처리 — Application Service가 직접 책임짐
    List&lt;Card&gt; cards = cardRepository.findAllByDeckIdAndDeletedFalse(deck.getId());
    cards.forEach(Card::softDelete);
    cardRepository.saveAll(cards);

    deckRepository.save(deck);
}</code></pre>
<p>여기서 <code>findAllByDeckIdAndDeletedFalse</code>로 조회하는 게 중요하다.
이미 개별적으로 삭제된 카드는 건드리지 않는다. Deck 삭제 시점에
살아있던 카드만 함께 삭제해야 한다.</p>
<hr>
<h2 id="복구-로직에서-한-번-더-고민">복구 로직에서 한 번 더 고민</h2>
<p>수정하고 나서 복구 로직도 다시 들여다봤다.
Deck을 복구할 때 하위 Card를 어디까지 살려야 하는가?</p>
<p>처음 생각은 단순했다. &quot;Deck 복구 → 전체 Card 복구&quot;. 그런데 이건 틀렸다.</p>
<pre><code>타임라인:
  t=1: Card A 개별 삭제 (사용자가 직접)
  t=2: Card B, C 살아있음
  t=3: Deck 삭제 → Card B, C 함께 삭제
  t=4: Deck 복구

이때 무엇을 복구해야 하는가?
  - Card B, C → 복구해야 함 (Deck과 함께 삭제됐으니까)
  - Card A    → 복구하면 안 됨 (사용자가 의도적으로 삭제했으니까)</code></pre><p>전체 복구는 사용자가 의도적으로 삭제한 카드까지 살려버린다.
<code>deletedAt</code> 타임스탬프를 기준으로 Deck이 삭제된 시점과 함께 삭제된
카드만 골라내야 한다.</p>
<pre><code class="language-java">// ✅ deletedAt 기준 — 덱 삭제와 동시에 삭제된 카드만 복구
public void restore(Long deckId) {
    Deck deck = deckRepository.findById(deckId).orElseThrow();
    LocalDateTime deckDeletedAt = deck.getDeletedAt();

    List&lt;Card&gt; cards = cardRepository.findAllByDeckId(deckId);

    cards.stream()
         .filter(card -&gt;
             card.getDeletedAt() != null &amp;&amp;
             card.getDeletedAt().isAfter(deckDeletedAt.minusSeconds(5))
         )
         .forEach(Card::restore);

    cardRepository.saveAll(cards);
    deck.restore();
    deckRepository.save(deck);
}</code></pre>
<p><code>minusSeconds(5)</code>는 트랜잭션 내에서 Deck과 Card의 <code>deletedAt</code>이
정확히 같은 밀리초가 아닐 수 있어서 넣은 여유값이다.
운영 환경에서 배치 처리나 네트워크 지연으로 미세하게 차이가 날 수 있다.</p>
<p>더 엄밀하게는 삭제 트랜잭션 ID나 별도 <code>deletion_batch_id</code>를 관리하는
방법도 있지만, 현재 규모에서는 5초 윈도우가 충분한 판단이었다.</p>
<hr>
<h2 id="왜-놓쳤나">왜 놓쳤나</h2>
<p>돌아보면 이유가 명확하다.</p>
<p><code>CascadeType.REMOVE</code>를 제거하는 ADR을 작성할 때,
<strong>&quot;무엇을 없앴는가&quot;만 기록했고 &quot;그래서 누가 대신 해야 하는가&quot;를 명시하지 않았다.</strong></p>
<p>JPA Cascade가 암묵적으로 처리해주던 것들이 있다.
그걸 의식적으로 제거하는 순간, 그 책임은 어딘가로 이동한다.
이동한 책임이 코드로 구현됐는지 확인하는 과정이 빠졌다.</p>
<hr>
<h2 id="재발-방지">재발 방지</h2>
<p><strong>통합 테스트에 연쇄 검증 추가</strong></p>
<pre><code class="language-java">@Test
void Deck을_삭제하면_하위_Card도_논리_삭제된다() {
    // given
    Deck deck = createDeckWithCards(3);

    // when
    deckCommandService.softDelete(deck.getId());

    // then
    List&lt;Card&gt; cards = cardRepository.findAllByDeckId(deck.getId());
    assertThat(cards).allMatch(Card::isDeleted);
    // ← 이 한 줄이 없었다면 버그가 QA 전에 잡히지 않았을 것
}</code></pre>
<p><strong>코드 리뷰 체크리스트 추가</strong></p>
<p><code>DeckCommandService</code>의 삭제/복구 관련 PR을 리뷰할 때
Card 연쇄 처리 여부를 명시적으로 확인하는 항목을 추가했다.
JPA Cascade가 없으면 연쇄는 눈에 보이지 않는다. 명시적으로 확인해야 한다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>Soft Delete 시스템에서 JPA의 물리 삭제 캐스케이드는 위험하다.</p>
<p>다만 그 결정에는 트레이드오프가 따른다.
편의성(JPA 자동 처리)을 포기하고 명시성(Application Service 직접 처리)을 택한 것이다.
그 명시성을 끝까지 책임지는 코드와 테스트가 함께 있어야 한다.</p>
<p>아키텍처 결정은 문서로 남기는 것만으로 끝나지 않는다.
그 결정이 낳는 책임을 코드에서 표현하고, 테스트로 검증해야 완성이다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third tool] ReviewSession의 분리]]></title>
            <link>https://velog.io/@js-kim-arc/Third-tool-ReviewSession%EC%9D%98-%EB%B6%84%EB%A6%AC</link>
            <guid>https://velog.io/@js-kim-arc/Third-tool-ReviewSession%EC%9D%98-%EB%B6%84%EB%A6%AC</guid>
            <pubDate>Fri, 03 Apr 2026 11:22:40 GMT</pubDate>
            <description><![CDATA[<h1 id="reviewsession을-도메인으로-분리했을-때--설계-판단과-트레이드오프">ReviewSession을 도메인으로 분리했을 때 — 설계 판단과 트레이드오프</h1>
<blockquote>
<p>Card와 ReviewSession을 왜 나눴는가, 그리고 그 대가는 무엇인가</p>
</blockquote>
<hr>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/324b293e-8faf-4349-9c5f-339b16a34e88/image.jpg" alt=""></p>
<h2 id="📚-목차">📚 목차</h2>
<ul>
<li><a href="#%EB%8F%84%EB%A9%94%EC%9D%B8-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84-%EA%B8%B0%EB%A1%9D">도메인 트레이드오프 기록</a></li>
<li><a href="#%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B5%AC%EC%A1%B0-%ED%95%9C%EB%88%88%EC%97%90-%EB%B3%B4%EA%B8%B0">도메인 구조 한눈에 보기</a></li>
<li><a href="#%EC%9E%A5%EC%A0%90">장점</a><ul>
<li><a href="#1-%EA%B4%80%EC%8B%AC%EC%82%AC%EA%B0%80-%EB%AA%85%ED%99%95%ED%95%98%EA%B2%8C-%EB%B6%84%EB%A6%AC%EB%90%9C%EB%8B%A4">1. 관심사가 명확하게 분리된다</a></li>
<li><a href="#2-%EA%B0%99%EC%9D%80-card%EB%A5%BC-%EC%97%AC%EB%9F%AC-%EC%84%B8%EC%85%98%EC%97%90%EC%84%9C-%EB%8F%85%EB%A6%BD%EC%A0%81%EC%9C%BC%EB%A1%9C-%EB%8B%A4%EB%A3%B0-%EC%88%98-%EC%9E%88%EB%8B%A4">2. 같은 Card를 여러 세션에서 독립적으로 다룰 수 있다</a></li>
<li><a href="#3-cardreview%EA%B0%80-%ED%86%B5%EA%B3%84-%ED%99%95%EC%9E%A5%EC%9D%98-%EA%B7%BC%EA%B1%B0%EA%B0%80-%EB%90%9C%EB%8B%A4">3. CardReview가 통계 확장의 근거가 된다</a></li>
<li><a href="#4-finished-%ED%95%84%EB%93%9C%EA%B0%80-%EC%BB%AC%EB%A0%89%EC%85%98-%EB%A1%9C%EB%94%A9-%EC%97%86%EC%9D%B4-%EC%99%84%EB%A3%8C%EB%A5%BC-%ED%8C%90%EB%8B%A8%ED%95%9C%EB%8B%A4">4. <code>finished</code> 필드가 컬렉션 로딩 없이 완료를 판단한다</a></li>
</ul>
</li>
<li><a href="#%EB%8B%A8%EC%A0%90">단점</a><ul>
<li><a href="#1-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%BD%EA%B3%84%EA%B0%80-%EB%B3%B5%EC%9E%A1%ED%95%B4%EC%A7%84%EB%8B%A4">1. 트랜잭션 경계가 복잡해진다</a></li>
<li><a href="#2-islastview-%EA%B0%92%EC%9D%84-%EC%84%B8%EC%85%98-%EB%A0%88%EC%9D%B4%EC%96%B4%EC%97%90%EC%84%9C-%EA%B4%80%EB%A6%AC%ED%95%B4%EC%95%BC-%ED%95%9C%EB%8B%A4">2. <code>isLastView</code> 값을 세션 레이어에서 관리해야 한다</a></li>
<li><a href="#3-cardreview-%ED%96%89%EC%9D%B4-%EA%B3%84%EC%86%8D-%EC%8C%93%EC%9D%B8%EB%8B%A4">3. CardReview 행이 계속 쌓인다</a></li>
</ul>
</li>
<li><a href="#%EC%84%A4%EA%B3%84-%ED%8C%90%EB%8B%A8%EC%9D%98-%ED%95%B5%EC%8B%AC">설계 판단의 핵심</a></li>
<li><a href="#%EB%A7%88%EC%B9%98%EB%A9%B0">마치며</a></li>
</ul>
<h2 id="도메인-트레이드오프-기록">도메인 트레이드오프 기록</h2>
<p>ThirdTool을 개발하면서 학습 흐름을 설계할 때 가장 오래 고민한 지점이 있다.</p>
<p>&quot;리뷰 진행 상태를 Card 안에 둘 것인가, 아니면 ReviewSession/CardReview로 분리할 것인가.&quot;</p>
<p>결론부터 말하면 <strong>분리</strong>를 선택했다. 그 판단의 근거와 실제로 맞닥뜨린 단점을 함께 기록해둔다.</p>
<hr>
<h2 id="도메인-구조-한눈에-보기">도메인 구조 한눈에 보기</h2>
<pre><code>ReviewSession
 └── CardReview (N)
       └── Card (참조)</code></pre><ul>
<li><strong>Card</strong> : 학습 단위. <code>status</code>, <code>viewCount</code>, <code>lastViewedAt</code>만 관리한다.</li>
<li><strong>CardReview</strong> : 특정 세션 안에서 카드 한 장의 진행 상태. <code>reviewStep</code>, <code>comparingStartedAt</code> 등을 갖는다.</li>
<li><strong>ReviewSession</strong> : 한 번의 학습 세션. CardReview들의 흐름을 조율한다.</li>
</ul>
<hr>
<h2 id="장점">장점</h2>
<h3 id="1-관심사가-명확하게-분리된다">1. 관심사가 명확하게 분리된다</h3>
<p>Card는 &quot;이 카드를 몇 번 봤는가&quot;, &quot;마지막으로 언제 봤는가&quot;라는 <strong>영속적 이력</strong>에만 책임진다.</p>
<p>&quot;지금 이 카드가 회상(RECALLING) 단계인지 비교(COMPARING) 단계인지&quot;는 Card가 알 필요가 없다. 이건 <strong>세션 안에서만 유효한 일시적 상태</strong>이기 때문이다.</p>
<p>ReviewSession/CardReview가 없었다면 Card 안에 이런 필드가 들어가야 했을 것이다.</p>
<pre><code class="language-java">// 분리하지 않았을 때의 Card
public class Card {
    private ReviewStep reviewStep;       // 회상? 비교?
    private LocalDateTime comparingStartedAt; // 비교 시작 시각
    // ... 이미 Card가 너무 많은 걸 알고 있다
}</code></pre>
<p>Card가 &quot;학습 단위&quot;이면서 동시에 &quot;리뷰 진행 상태&quot;까지 책임지는 구조는 SRP(단일 책임 원칙) 위반이다.</p>
<hr>
<h3 id="2-같은-card를-여러-세션에서-독립적으로-다룰-수-있다">2. 같은 Card를 여러 세션에서 독립적으로 다룰 수 있다</h3>
<p>CardReview는 ReviewSession에 종속된다. 덕분에 이런 상황이 자연스럽게 해결된다.</p>
<pre><code>세션 A → 카드 #3 : COMPARING 단계
세션 B → 카드 #3 : RECALLING 단계  ← 완전히 독립적</code></pre><p>Card 자체에 <code>reviewStep</code>을 뒀다면 두 세션이 <strong>같은 필드를 공유</strong>하므로 상태 충돌이 발생한다.
현재 구조에서는 이 문제가 구조적으로 불가능하다.</p>
<hr>
<h3 id="3-cardreview가-통계-확장의-근거가-된다">3. CardReview가 통계 확장의 근거가 된다</h3>
<p><code>comparingStartedAt</code>이 CardReview에 있기 때문에, 나중에 이런 분석이 가능해진다.</p>
<ul>
<li>&quot;이 카드를 회상부터 비교까지 몇 초 걸렸는가&quot;</li>
<li>&quot;사용자별 평균 회상 소요 시간 추이&quot;</li>
</ul>
<p>Card에 이 필드를 뒀다면 세션마다 덮어쓰여서 이전 기록이 사라진다. CardReview는 세션별로 각각 한 행이 생기므로 <strong>시계열로 쌓이는 로그</strong>처럼 동작한다.</p>
<hr>
<h3 id="4-finished-필드가-컬렉션-로딩-없이-완료를-판단한다">4. <code>finished</code> 필드가 컬렉션 로딩 없이 완료를 판단한다</h3>
<pre><code class="language-java">// 분리하지 않았을 때 — 컬렉션 초기화 필요
boolean isFinished = currentIndex &gt;= cardReviews.size(); // Lazy 로딩 발생

// 현재 구조 — 컬럼 하나로 판단
boolean isFinished = this.finished; // SELECT 없음</code></pre>
<p>세션 목록 조회처럼 &quot;완료 여부만 필요한 상황&quot;에서 불필요한 JOIN을 피할 수 있다. 이건 성능 측면에서도, JPA Lazy 로딩 함정을 피한다는 측면에서도 유효하다.</p>
<hr>
<h2 id="단점">단점</h2>
<h3 id="1-트랜잭션-경계가-복잡해진다">1. 트랜잭션 경계가 복잡해진다</h3>
<p><code>PATCH /reviews/{sessionId}/next</code>(다음 학습 카드 요청 api) 요청 하나에서 실제로 일어나는 일을 추적하면 이렇다.</p>
<pre><code>ReviewSession.moveToNext()
ReviewSession.recordCurrentCardView()
  └── CardReview.recordView()
        └── Card.recordView()        ← card 업데이트 (viewCount, lastViewedAt)
archiveCard()                        ← card 업데이트 (status)
                                     ← CardStatusHistory 저장
cardRepository.save(card)</code></pre><p>한 요청에서 <strong>ReviewSession, Card, CardStatusHistory</strong> 세 Aggregate를 건드린다.</p>
<p>지금은 단일 트랜잭션으로 처리하고 있어서 일관성은 보장된다. 하지만 Card BC와 Review BC를 물리적으로 분리하는 순간, 이 부분이 가장 먼저 문제가 된다. Eventually Consistent 방식으로 전환하려면 Domain Event 기반 처리가 필요해진다.(한 트랜잭션에서 여러 애그리거트를 조절 이슈)</p>
<hr>
<h3 id="2-islastview-값을-세션-레이어에서-관리해야-한다">2. <code>isLastView</code> 값을 세션 레이어에서 관리해야 한다</h3>
<p>Card는 자신의 <code>viewCount</code>만 알고 있다. &quot;내가 마지막 노출이다&quot;라는 사실은 ReviewSession 레이어에서 계산해서 Application Service로 올려줘야 한다.</p>
<pre><code class="language-java">// ReviewSession
public boolean recordCurrentCardView() {
    CardReview current = getCurrentCardReview();
    boolean isLastView = current.recordView(); // CardReview → Card 위임
    return isLastView; // 세션 레이어가 들고 있어야 함
}</code></pre>
<p><code>startComparing()</code> 응답에도 <code>isLastView</code>가 필요한데, 이 시점에는 RECALLING에서 결정된 값을 다시 읽어야 해서 <code>resolveIsLastView()</code>를 따로 만들었다. Card에 이 상태를 직접 저장하지 않는 이상 이 흐름은 다소 번거롭다.</p>
<hr>
<h3 id="3-cardreview-행이-계속-쌓인다">3. CardReview 행이 계속 쌓인다</h3>
<p>세션마다 카드 수만큼 CardReview 행이 생성된다. 유저가 세션을 자주 시작할수록 <code>card_review</code> 테이블이 빠르게 커진다.</p>
<pre><code>유저 1명 × 카드 50장 × 세션 30회 = CardReview 1,500행</code></pre><p>지금은 세션 삭제 정책이 없어서 이 데이터가 무한정 쌓인다. 추후 <strong>세션 만료 정책</strong>이나 <strong>아카이빙 전략</strong>이 필요하다.</p>
<hr>
<h2 id="설계-판단의-핵심">설계 판단의 핵심</h2>
<p>이 설계의 핵심 전제는 하나다.</p>
<blockquote>
<p><strong>&quot;리뷰 흐름 상태는 세션에 종속된다.&quot;</strong></p>
</blockquote>
<p>Card는 학습 단위로서의 영속적인 상태(몇 번 봤는가, 어디 있는가)만 가진다.
CardReview는 특정 세션 안에서의 일시적인 진행 상태를 가진다.</p>
<p>이 분리가 Card BC와 Review BC의 경계를 만들어 주고, 통계 확장 가능성을 열어준다.</p>
<p>단점인 트랜잭션 복잡도와 데이터 누적은 지금 단계에서는 감수할 수 있는 수준이다. 하지만 이 트레이드오프를 모르고 선택한 것과, 알고 선택한 것은 완전히 다른 이야기다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>도메인을 분리하면 코드가 깔끔해진다. 하지만 공짜가 아니다.</p>
<p>트랜잭션 경계, 데이터 누적, 상태를 어느 레이어에서 들고 있을지의 문제가 따라온다. 중요한 건 이 비용을 인식한 상태에서 선택했는가, 아닌가다.</p>
<p>ThirdTool은 지금 단일 트랜잭션 + 단일 DB 구조로 운영 중이다. Eventually Consistent가 필요해지는 시점이 오면, 이 설계가 그 전환의 기반이 된다.</p>
<hr>
<p><em>ThirdTool — 간격 반복 학습 플랫폼 개발기</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프] 커머스 시스템 트러블슈팅]]></title>
            <link>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%EC%BB%A4%EB%A8%B8%EC%8A%A4-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@js-kim-arc/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-%EC%BB%A4%EB%A8%B8%EC%8A%A4-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Thu, 02 Apr 2026 02:26:56 GMT</pubDate>
            <description><![CDATA[<h1 id="구현하면서-배운-것들---흐름-제어와-예외-처리">구현하면서 배운 것들 - 흐름 제어와 예외 처리</h1>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/91d654b7-9705-4e43-926e-8ac13fea72c7/image.jpg" alt=""></p>
<h2 id="들어가며">들어가며</h2>
<p>커머스 플랫폼에 관리자 모드를 추가하면서문제점을 만났다.
 <strong>예외 처리를 어디서, 어떻게 해야 하는가</strong> 였다.</p>
<p>코드는 결국 동작했지만, 그 과정에서 생각보다 많은 것을 고민했다.(항상 Exception , 에러코드들을 관리하는 것은 복잡한 것 같다.)</p>
<hr>
<h2 id="문제-1-3회-실패하면-메인으로">문제 1. &quot;3회 실패하면 메인으로&quot;</h2>
<h3 id="처음에-생각한-구조">처음에 생각한 구조</h3>
<p>요구사항을 처음 읽었을 때는 단순해 보였다.</p>
<pre><code>비밀번호 입력 → 틀리면 카운트 증가 → 3회 되면 메인으로</code></pre><p>그래서 처음에는 이렇게 짰다.</p>
<pre><code class="language-java">private void showAdminLogin(Scanner scanner) {
    int failCount = 0;

    while (failCount &lt; 3) {
        System.out.print(&quot;비밀번호: &quot;);
        String input = scanner.next();

        if (input.equals(&quot;admin123&quot;)) {
            showAdminMenu(scanner);
            return;
        }
        failCount++;
        System.out.println(&quot;틀렸습니다. (&quot; + failCount + &quot;회 실패)&quot;);
    }
    System.out.println(&quot;초과했습니다.&quot;);
}</code></pre>
<p>동작은 했다. 그런데 문제가 생겼다.</p>
<h3 id="문제-관리자-메뉴에서-나왔다가-다시-6번을-누르면">문제: 관리자 메뉴에서 나왔다가 다시 6번을 누르면?</h3>
<p><code>failCount</code>가 메서드 로컬 변수라 매번 0으로 초기화됐다.
즉, 3회 실패해서 메인으로 돌아간 다음 6번을 다시 누르면 카운트가 리셋됐다.</p>
<p>의도한 동작인지 아닌지가 불명확했는데, 요구사항을 다시 읽어보니
<strong>&quot;메인 메뉴로 돌아가기&quot;</strong> 가 포인트였다. 복귀 후 재시도는 허용하는 게 자연스럽다고 판단했다.</p>
<p>그렇다면 <code>resetFailCount()</code>는 <strong>메인으로 복귀하는 시점에</strong> 호출해야 했다.</p>
<pre><code class="language-java">private void showAdminLogin(Scanner scanner) {
    admin.resetFailCount(); // 진입 시점에 초기화

    while (!admin.isLocked()) {
        System.out.print(&quot;비밀번호: &quot;);
        String input = scanner.next();

        if (admin.authenticate(input)) {
            showAdminMenu(scanner);
            return;
        }

        if (admin.isLocked()) {
            System.out.println(&quot;초과했습니다. 메인으로 돌아갑니다.&quot;);
        } else {
            System.out.printf(&quot;틀렸습니다. (%d회 실패)%n&quot;, admin.getFailCount());
        }
    }
}</code></pre>
<h3 id="배운-것">배운 것</h3>
<p>흐름 제어에서 <strong>&quot;언제 초기화하는가&quot;</strong> 는 생각보다 중요한 설계 결정이다.
로컬 변수로 처리하면 간단해 보이지만, 상태가 객체에 있어야 할 때는 명시적으로 초기화 시점을 정해줘야 한다.</p>
<hr>
<h2 id="문제-2-예외-처리를-어디서-해야-하는가">문제 2. 예외 처리를 어디서 해야 하는가</h2>
<h3 id="처음-접근-그냥-systemoutprintln으로-막기">처음 접근: 그냥 <code>System.out.println</code>으로 막기</h3>
<p>장바구니에 재고 없는 상품을 담으려 할 때, 처음에는 <code>Cart.addItem()</code> 안에서 이렇게 처리했다.</p>
<pre><code class="language-java">public void addItem(Product product) {
    if (product.getStock() == 0) {
        System.out.println(&quot;재고가 없어 추가할 수 없습니다.&quot;); // 그냥 출력하고 끝
        return;
    }
    // ...
}</code></pre>
<p>동작은 했다. 그런데 관리자 모드에서 음수 가격 입력 처리를 짜다가 의문이 생겼다.</p>
<pre><code class="language-java">// CommerceSystem 안에서
while (true) {
    System.out.print(&quot;가격 입력: &quot;);
    price = scanner.nextInt();
    if (price &lt; 0) {
        System.out.println(&quot;0 이상이어야 합니다.&quot;);
    } else {
        break;
    }
}</code></pre>
<p><code>Product.setPrice()</code>에도 같은 검증이 있다.</p>
<pre><code class="language-java">public void setPrice(int price) {
    if (price &lt; 0) {
        System.out.println(&quot;가격은 0 이상이어야 합니다.&quot;);
        return;
    }
    this.price = price;
}</code></pre>
<p><strong>검증이 두 곳에 있다.</strong> 어디서 막아야 하는 걸까?</p>
<h3 id="고민한-지점">고민한 지점</h3>
<table>
<thead>
<tr>
<th>위치</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>도메인 객체 내부 (<code>setPrice</code>)</td>
<td>어디서 호출해도 안전</td>
<td>출력 메시지가 도메인 안에 있는 게 어색함</td>
</tr>
<tr>
<td>흐름 제어 레이어 (<code>CommerceSystem</code>)</td>
<td>입력 단계에서 차단, UX 제어 가능</td>
<td>도메인 객체를 믿고 쓸 수 없음</td>
</tr>
</tbody></table>
<p>결론적으로 현재 구조에서는 <strong>둘 다 유지</strong>했다.</p>
<p>이유는 간단하다. 지금은 <code>Exception</code>을 던지지 않고 <code>System.out.println</code>으로 처리하고 있는데, 도메인 객체 내부 검증이 없으면 잘못된 값이 그냥 들어가버린다. 흐름 제어 레이어의 while 루프는 UX를 위한 것이고, 도메인 내부 검증은 <strong>방어선</strong>이다.</p>
<h3 id="exception을-제대로-던지지-못한-이유">Exception을 제대로 던지지 못한 이유</h3>
<p>사실 이상적인 구조는 이렇다.</p>
<pre><code class="language-java">// 도메인에서 예외를 던지고
public void setPrice(int price) {
    if (price &lt; 0) throw new IllegalArgumentException(&quot;가격은 0 이상이어야 합니다.&quot;);
    this.price = price;
}

// 호출부에서 잡아서 처리
try {
    product.setPrice(newPrice);
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage());
}</code></pre>
<p>그런데 지금 프로젝트는 아직 <code>Exception</code> 설계를 제대로 도입하지 않았다.
주석에 <code>// Exception 처리 - System.out.print로 대체</code> 라고 적어두었는데,
이게 <strong>임시 처리임을 명시</strong>한 것이다.</p>
<p>현 단계에서 <code>Exception</code>을 도입하면 try-catch 구조가 전체로 퍼지는데,
그 설계를 아직 잡지 않은 상태에서 섣불리 도입하면 오히려 코드가 더 복잡해질 수 있다고 판단했다.</p>
<h3 id="배운-것-1">배운 것</h3>
<p>예외 처리는 <strong>&quot;어디서 막는가&quot;</strong> 의 문제가 아니라 <strong>&quot;누가 책임지는가&quot;</strong> 의 문제다.
도메인 객체는 자신의 규칙을 스스로 지켜야 하고,
흐름 제어 레이어는 사용자 경험을 책임진다.
지금은 두 역할이 섞여 있지만, 다음 단계에서는 <code>Exception</code>을 제대로 설계해서 분리할 예정이다.</p>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>문제</th>
<th>원인</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>실패 카운트 초기화 시점</td>
<td>로컬 변수 vs 객체 상태 혼동</td>
<td><code>Admin</code> 객체가 상태를 소유, 진입 시점에 <code>reset()</code> 호출</td>
</tr>
<tr>
<td>예외 처리 이중화</td>
<td>도메인·흐름 레이어 책임 불분명</td>
<td>현 단계에서 <code>System.out.println</code> 임시 처리, Exception 설계는 다음 단계로</td>
</tr>
</tbody></table>
<p>콘솔 커머스 프로젝트지만 이런 설계 결정들이 실제 서버 개발에서도 그대로 나온다는 걸 느꼈다.
다음에는 <code>Exception</code> 계층을 제대로 설계해서 도메인 규칙 위반을 명시적으로 처리해보려 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third tool] DTO 파일이 30개를 넘자 보인 것들: 중첩 record로 다시 묶은 이유]]></title>
            <link>https://velog.io/@js-kim-arc/Third-tool-DTO-%ED%8C%8C%EC%9D%BC%EC%9D%B4-30%EA%B0%9C%EB%A5%BC-%EB%84%98%EC%9E%90-%EB%B3%B4%EC%9D%B8-%EA%B2%83%EB%93%A4-%EC%A4%91%EC%B2%A9-record%EB%A1%9C-%EB%8B%A4%EC%8B%9C-%EB%AC%B6%EC%9D%80-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@js-kim-arc/Third-tool-DTO-%ED%8C%8C%EC%9D%BC%EC%9D%B4-30%EA%B0%9C%EB%A5%BC-%EB%84%98%EC%9E%90-%EB%B3%B4%EC%9D%B8-%EA%B2%83%EB%93%A4-%EC%A4%91%EC%B2%A9-record%EB%A1%9C-%EB%8B%A4%EC%8B%9C-%EB%AC%B6%EC%9D%80-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Wed, 01 Apr 2026 11:23:57 GMT</pubDate>
            <description><![CDATA[<h1 id="dto-파일이-30개를-넘어가면서-생긴-일">DTO 파일이 30개를 넘어가면서 생긴 일</h1>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/2175e6f5-eced-4781-b95b-76dbfac7400f/image.jpg" alt=""></p>
<h2 id="목차">목차</h2>
<ul>
<li><a href="#%EC%8B%9C%EC%9E%91%EC%9D%80-%EB%8B%A8%EC%88%9C%ED%96%88%EB%8B%A4">시작은 단순했다</a></li>
<li><a href="#deck%EC%9D%84-%EC%B6%94%EA%B0%80%ED%96%88%EC%9D%84-%EB%95%8C">Deck을 추가했을 때</a></li>
<li><a href="#review%EA%B0%80-%EB%B6%99%EC%9C%BC%EB%A9%B4%EC%84%9C-%ED%99%95%EC%8B%A0%EC%9D%B4-%EC%83%9D%EA%B2%BC%EB%8B%A4">Review가 붙으면서 확신이 생겼다</a></li>
<li><a href="#%EB%AC%B8%EC%A0%9C%EB%A5%BC-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A1%9C-%ED%99%95%EC%9D%B8%ED%95%B4%EB%B4%A4%EB%8B%A4">문제를 테스트로 확인해봤다</a></li>
<li><a href="#%EC%A4%91%EC%B2%A9-%ED%81%B4%EB%9E%98%EC%8A%A4%EB%A1%9C-%EB%B0%94%EA%BF%94%EB%B4%A4%EB%8B%A4">중첩 클래스로 바꿔봤다</a></li>
<li><a href="#%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EB%8B%A4%EC%8B%9C-%EC%9E%91%EC%84%B1%ED%96%88%EB%8B%A4">테스트를 다시 작성했다</a></li>
<li><a href="#%ED%8C%8C%EC%9D%BC-%ED%81%AC%EA%B8%B0-%EB%AC%B8%EC%A0%9C%EB%8A%94-%EB%B6%84%EB%A6%AC-%EA%B8%B0%EC%A4%80%EC%9C%BC%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%96%88%EB%8B%A4">파일 크기 문제는 분리 기준으로 해결했다</a></li>
<li><a href="#%EB%B0%94%EA%BE%B8%EA%B3%A0-%EB%82%98%EC%84%9C-%EB%8B%AC%EB%9D%BC%EC%A7%84-%EA%B2%83">바꾸고 나서 달라진 것</a></li>
<li><a href="#%EC%A0%95%EB%A6%AC">정리</a></li>
</ul>
<hr>
<h2 id="시작은-단순했다">시작은 단순했다</h2>
<p>Third Tool 초기에 Card 도메인 API를 작성할 때 DTO 구조는 아무 생각 없이 이렇게 시작했다.</p>
<pre><code>presentation/
  dto/
    CreateCardRequest.java
    CardDetailResponse.java
    CardSummaryResponse.java</code></pre><p>세 개. 아무 문제 없어 보였다.</p>
<hr>
<h2 id="deck을-추가했을-때">Deck을 추가했을 때</h2>
<p>Deck API가 붙으면서 파일이 빠르게 늘었다.</p>
<pre><code>presentation/
  dto/
    CreateCardRequest.java
    CardDetailResponse.java
    CardSummaryResponse.java
    CreateDeckRequest.java
    DeckDetailResponse.java
    DeckSummaryResponse.java
    UpdateDeckNameRequest.java
    ChangeDeckParentRequest.java</code></pre><p>여기서 처음으로 이상한 걸 느꼈다.
Card는 <code>CreateCardRequest</code>인데 Deck은 <code>CreateDeckRequest</code>다.
순서가 다르다. 혼자 개발하는데 이미 컨벤션이 흔들리고 있었다.</p>
<p><code>UpdateDeckNameRequest</code>인지 <code>DeckUpdateNameRequest</code>인지
파일을 새로 만들 때마다 잠깐 고민하게 됐다.</p>
<hr>
<h2 id="review가-붙으면서-확신이-생겼다">Review가 붙으면서 확신이 생겼다</h2>
<p>Review 도메인 API가 5개 붙으면서 파일이 20개를 넘었다.
IDE에서 <code>Ctrl + N</code>으로 파일을 찾으려고 <code>Card</code>를 치면 이런 목록이 나왔다.</p>
<pre><code>CardDetailResponse
CardSummaryResponse
CardRelatedResponse
CreateCardRequest
UpdateCardMainNoteRequest
UpdateCardSummaryRequest
AddCardKeywordRequest
ReplaceCardKeywordsRequest
AddCardTagRequest
ReplaceCardTagsRequest</code></pre><p>Card 관련 파일만 10개다.
&quot;이번에 추가한 태그 추가 요청 DTO가 뭐였더라&quot; 하고 찾는 데 시간이 걸리기 시작했다.</p>
<hr>
<h2 id="문제를-테스트로-확인해봤다">문제를 테스트로 확인해봤다</h2>
<p>코드를 바꾸기 전에 &quot;정말 이게 문제인가&quot;를 검증하고 싶었다.
방법은 단순했다. <strong>컨트롤러 테스트를 작성하면서 DTO import 상태를 보는 것.</strong></p>
<pre><code class="language-java">@WebMvcTest(CardController.class)
class CardControllerTest {

    @Test
    void 카드_생성_성공() throws Exception {
        // import가 어떻게 생겼는지 보자
    }
}</code></pre>
<p>테스트 파일 상단 import를 보니 이랬다.</p>
<pre><code class="language-java">import com.example.thirdtool.Card.presentation.dto.CreateCardRequest;
import com.example.thirdtool.Card.presentation.dto.CardDetailResponse;
import com.example.thirdtool.Card.presentation.dto.CardSummaryResponse;
import com.example.thirdtool.Card.presentation.dto.AddCardTagRequest;
import com.example.thirdtool.Card.presentation.dto.ReplaceCardTagsRequest;
import com.example.thirdtool.Card.presentation.dto.UpdateCardMainNoteRequest;
import com.example.thirdtool.Card.presentation.dto.UpdateCardSummaryRequest;</code></pre>
<p>컨트롤러 하나를 테스트하는 파일에 import가 7줄이다.
그리고 <code>AddCardTagRequest</code>인지 <code>CardAddTagRequest</code>인지
테스트를 작성하는 중에도 IDE 자동완성에 의존하고 있었다.</p>
<p><strong>테스트가 불편하다는 건 구조가 잘못됐다는 신호라고 생각했다.</strong></p>
<p>두 번째 검증은 신규 팀원이 코드를 처음 봤을 때를 가정한 시뮬레이션이었다.
<code>UpdateCardMainNoteRequest</code>와 <code>AddCardTagRequest</code> 중 어느 게 더 최근에 추가된 것인지,
파일만 보고는 전혀 알 수 없었다. 도메인 구조가 파일 이름에서 드러나지 않았다.</p>
<hr>
<h2 id="중첩-클래스로-바꿔봤다">중첩 클래스로 바꿔봤다</h2>
<p>구조를 바꾸기로 하고 먼저 Card 도메인에만 적용해봤다.</p>
<pre><code class="language-java">public class CardRequest {

    public record Create(
            MainNoteDto mainNote,
            @NotEmpty List&lt;String&gt; keywords,
            @NotBlank String summary,
            List&lt;String&gt; tags
    ) {}

    public record UpdateMainNote(
            String textContent,
            String imageUrl
    ) {}

    public record AddTag(
            @NotBlank String value
    ) {}

    public record ReplaceTags(
            @NotEmpty List&lt;String&gt; tags
    ) {}

    public record MainNoteDto(
            String textContent,
            String imageUrl
    ) {}
}</code></pre>
<pre><code class="language-java">public class CardResponse {

    public record Create(Long cardId, Long deckId, ...) {
        public static Create of(Card card) { ... }
    }

    public record Detail(Long cardId, Long deckId, ...) {
        public static Detail of(Card card) { ... }
    }

    public record Tags(Long cardId, List&lt;TagDto&gt; tags) {
        public static Tags of(Card card) { ... }
    }
}</code></pre>
<hr>
<h2 id="테스트를-다시-작성했다">테스트를 다시 작성했다</h2>
<p>같은 컨트롤러 테스트를 중첩 클래스 구조로 다시 작성했다.</p>
<pre><code class="language-java">import com.example.thirdtool.Card.presentation.dto.CardRequest;
import com.example.thirdtool.Card.presentation.dto.CardResponse;</code></pre>
<p>import가 2줄이 됐다.</p>
<p>그리고 테스트 본문이 이렇게 바뀌었다.</p>
<pre><code class="language-java">// 변경 전
CreateCardRequest request = new CreateCardRequest(...);

// 변경 후
CardRequest.Create request = new CardRequest.Create(...);</code></pre>
<p><code>CardRequest.Create</code>. 읽기만 해도 &quot;Card 도메인의 생성 요청&quot;임을 알 수 있다.
테스트 코드가 문서처럼 읽히기 시작했다.</p>
<p><strong>두 번째로 확인한 것은 <code>@Valid</code> 작동 여부였다.</strong></p>
<pre><code class="language-java">@Test
void 키워드가_없으면_400을_반환한다() throws Exception {
    String json = &quot;&quot;&quot;
            {
              &quot;mainNote&quot;: { &quot;textContent&quot;: &quot;텍스트&quot;, &quot;imageUrl&quot;: null },
              &quot;keywords&quot;: [],
              &quot;summary&quot;: &quot;요약이다.&quot;
            }
            &quot;&quot;&quot;;

    mockMvc.perform(post(&quot;/api/v1/decks/1/cards&quot;)
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(json))
           .andExpect(status().isBadRequest());
}</code></pre>
<p>중첩 record 안의 <code>@NotEmpty</code>도 정상적으로 Bean Validation이 동작하는 것을 확인했다.</p>
<hr>
<h2 id="파일-크기-문제는-분리-기준으로-해결했다">파일 크기 문제는 분리 기준으로 해결했다</h2>
<p>구조를 바꾸고 나서 한 가지 우려가 생겼다.
Card API가 13개라 <code>CardResponse.java</code>가 점점 길어지는 것이었다.</p>
<pre><code>CardResponse.java — 현재 180줄</code></pre><p>파일이 길어지는 것 자체는 문제가 아니지만,
한 파일에서 너무 많은 것을 찾아야 하면 중첩 구조의 장점이 희석된다.</p>
<p>그래서 분리 기준을 명시해뒀다.</p>
<ul>
<li><strong>200줄 초과</strong> → 개별 파일로 분리</li>
<li><strong>중첩 record가 다른 중첩 record를 참조하기 시작</strong> → 의존성이 복잡해진다는 신호. 분리 검토</li>
</ul>
<p>지금 180줄이라 아직 임계점 아래다.
200줄이 넘는 시점에 <code>CardCreateResponse.java</code>처럼 분리하면 된다.
기준이 있으면 &quot;언제 분리해야 하지?&quot;를 고민하는 시간이 없어진다.</p>
<hr>
<h2 id="바꾸고-나서-달라진-것">바꾸고 나서 달라진 것</h2>
<p><strong>전체 파일 수</strong></p>
<table>
<thead>
<tr>
<th>상태</th>
<th>DTO 파일 수 (Card + Deck + Review)</th>
</tr>
</thead>
<tbody><tr>
<td>변경 전</td>
<td>28개</td>
</tr>
<tr>
<td>변경 후</td>
<td>6개 (도메인당 Request/Response 2개)</td>
</tr>
</tbody></table>
<p><strong>컨트롤러 테스트 import</strong></p>
<table>
<thead>
<tr>
<th>상태</th>
<th>import 수</th>
</tr>
</thead>
<tbody><tr>
<td>변경 전</td>
<td>7줄</td>
</tr>
<tr>
<td>변경 후</td>
<td>2줄</td>
</tr>
</tbody></table>
<p><strong>신규 DTO 추가</strong></p>
<p>변경 전에는 파일 새로 만들고 네이밍 고민하고 위치 잡는 데 시간이 걸렸다.
변경 후에는 <code>CardRequest.java</code>를 열고 record 하나 추가하면 끝이다.
&quot;어느 파일에 넣어야 하지?&quot;라는 고민이 없어졌다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>처음에는 DTO 파일이 많아지는 게 자연스러운 성장 비용이라고 생각했다.
테스트를 작성하면서 import가 쌓이고, 파일 이름을 찾는 데 시간이 걸리기 시작하면서
&quot;이건 구조 문제다&quot;라는 걸 확인할 수 있었다.</p>
<p>중첩 record로 바꾸고 세 가지를 테스트로 검증했다.
역직렬화, <code>@Valid</code> 동작, 컨트롤러 테스트 가독성.
모두 통과하고 나서야 전체 도메인에 적용했다.</p>
<p>테스트가 불편하다는 감각이 설계를 바꾸는 신호가 됐다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[개발자 insight - 개인적인 문서화의 자세]]></title>
            <link>https://velog.io/@js-kim-arc/%EA%B0%9C%EB%B0%9C%EC%9E%90-insight-%EA%B0%9C%EC%9D%B8%EC%A0%81%EC%9D%B8-%EB%AC%B8%EC%84%9C%ED%99%94%EC%9D%98-%EC%9E%90%EC%84%B8</link>
            <guid>https://velog.io/@js-kim-arc/%EA%B0%9C%EB%B0%9C%EC%9E%90-insight-%EA%B0%9C%EC%9D%B8%EC%A0%81%EC%9D%B8-%EB%AC%B8%EC%84%9C%ED%99%94%EC%9D%98-%EC%9E%90%EC%84%B8</guid>
            <pubDate>Tue, 31 Mar 2026 11:34:02 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/83de3663-ab85-4d39-8993-62615fbd47c0/image.png" alt=""></p>
<h1 id="ai-시대-백엔드-개발자가-문서를-대하는-방법">AI 시대, 백엔드 개발자가 문서를 대하는 방법</h1>
<blockquote>
<p>개인 문서화 project~~!</p>
</blockquote>
<hr>
<h2 id="들어가며">들어가며</h2>
<p>AI가 코드를 짜주고, 아키텍처를 제안하고, 심지어 PR 리뷰까지 해주는 시대가 왔다.
그렇다면 개발자에게 남는 핵심 역량은 무엇일까?</p>
<p>나는 그게 <strong>&quot;읽고, 구조화하고, 판단하고, 책임지는 것&quot;</strong> 이라고 생각한다.
그리고 이 모든 것의 가장 기본 단위는 <strong>개인 관리 문서</strong>다.</p>
<hr>
<h2 id="ai-시대일수록-문서-능력이-중요해진다">AI 시대일수록 문서 능력이 중요해진다</h2>
<blockquote>
<p>개인 문서는 개인 pr의 시작점이다. </p>
</blockquote>
<p>AI의 생산성이 높아질수록, 새로운 기술·도메인·문제 해결 방식이 쏟아지는 속도도 함께 빨라진다.</p>
<p>지금도 매주 새로운 프레임워크가 나오고, 매달 패러다임이 바뀐다.
이 속도를 머릿속으로만 따라가려 하면 반드시 한계가 온다.</p>
<p>결국 <strong>&quot;내가 아는 것을 얼마나 잘 관리 할 수 있는가&quot;</strong> 가 개발자의 실질적인 역량 차이를 만든다.</p>
<p>문서는 단순한 기록이 아니다.
내 사고의 구조를 밖으로 꺼내는 행위다.</p>
<hr>
<h2 id="문서화의-시작-로드맵-만들기">문서화의 시작: 로드맵 만들기</h2>
<p>아무 문서나 쓴다고 좋은 게 아니다.
먼저 <strong>&quot;무엇을 알아야 하는가&quot;</strong> 를 지도처럼 그려야 한다. (전체를 바라보는 능력은 상당히 중요한 것 같다. - 우선순위화의 시작) </p>
<p>로드맵은 두 가지 역할을 한다.</p>
<ol>
<li><strong>전체 지형을 보여준다</strong> — 지금 내가 어디에 있고, 어디로 가야 하는지</li>
<li><strong>우선순위를 강제한다</strong> — 모든 걸 다 할 수 없으니, 순서를 정해야 한다</li>
</ol>
<p>로드맵 없이 문서를 쌓으면 결국 <strong>정리되지 않은 메모 더미</strong>가 된다.
반대로 로드맵이 있으면, 새로운 기술을 배울 때마다 &quot;이건 어디에 붙는 개념인가&quot;를 즉시 판단할 수 있다.</p>
<hr>
<h2 id="우선순위를-기준으로-문서-관리-방식을-결정한다">우선순위를 기준으로 문서 관리 방식을 결정한다</h2>
<p>로드맵에서 우선순위가 정해지면, 자연스럽게 <strong>문서의 깊이와 관리 방식</strong>도 달라진다.</p>
<table>
<thead>
<tr>
<th>우선순위</th>
<th>문서 형태</th>
<th>관리 방식</th>
</tr>
</thead>
<tbody><tr>
<td>높음</td>
<td>상세 ADR, 설계 문서, 트러블슈팅 기록</td>
<td>주기적으로 리뷰 &amp; 업데이트</td>
</tr>
<tr>
<td>중간</td>
<td>개념 정리, 비교 분석</td>
<td>참고용으로 유지</td>
</tr>
<tr>
<td>낮음</td>
<td>간단한 메모, 링크 모음</td>
<td>필요할 때 보는 수준</td>
</tr>
</tbody></table>
<p>중요한 건 <strong>우선순위가 바뀌면 문서 관리 방식도 바뀌어야 한다</strong>는 것이다.
문서는 한 번 쓰고 끝나는 게 아니다.</p>
<hr>
<h2 id="결국-핵심은-abstraction이다">결국 핵심은 Abstraction이다</h2>
<p>여기서부터가 진짜 이야기다.</p>
<p>문서를 많이 쌓다 보면 어느 순간 이런 상황이 온다.</p>
<blockquote>
<p>&quot;내가 쓴 ADR이 10개가 넘는데... 다 다른 형식이다.&quot;
&quot;카테고리 구조가 처음이랑 완전히 달라졌다.&quot;
&quot;태그가 너무 많아서 오히려 찾기 어렵다.&quot;</p>
</blockquote>
<p>이건 실패가 아니다. <strong>리팩토링이 필요한 시점</strong>이 온 것이다.</p>
<p>코드에서 중복을 추상화하듯, 문서도 마찬가지다.
여러 문서를 쌓다 보면 <strong>공통된 패턴과 구조</strong>가 보이기 시작한다.
그 공통점을 뽑아내서 <strong>템플릿(틀)</strong> 으로 만들고, 분류 체계(카테고리·태그)를 재정비하는 것.
이게 문서의 리팩토링이다.</p>
<p>그리고 한 발 더 나아가면 — 그 추상화된 틀을 <strong>프롬프트화</strong>할 수 있어야 한다.</p>
<p>&quot;이 구조로 문서 초안 잡아줘.&quot;
&quot;이 내용을 이 템플릿 형식에 맞게 정리해줘.&quot;</p>
<p>AI를 문서 작업의 페어 프로그래머로 쓰는 것이다.
이 단계에 오면, 문서 생산 속도가 비약적으로 빨라진다.</p>
<hr>
<h2 id="live-문서를-분리하라">Live 문서를 분리하라</h2>
<p>모든 문서가 같은 성격을 가지지는 않는다.</p>
<p>특히 <strong>작업 흐름에 따라 지속적으로 변하는 문서</strong>가 있다.
나는 이걸 <strong>Live 문서</strong>라고 부른다.</p>
<p>예를 들어:</p>
<ul>
<li>진행 중인 기능의 설계 문서</li>
<li>현재 발생하고 있는 장애/이슈 트래킹 문서</li>
<li>스프린트 회고나 회의록</li>
<li>아직 결론이 나지 않은 기술 선택지 비교</li>
</ul>
<p>이런 문서들은 &quot;쓰고 끝&quot;이 아니라 <strong>살아 있는 상태를 유지</strong>해야 한다.
작업이 완료되거나 결정이 내려지면, 그때 Archived 형태의 정적 문서로 전환한다.</p>
<p>Live 문서와 정적 문서를 섞어두면 나중에 어떤 내용이 최신인지 알 수 없게 된다.
이 두 가지를 <strong>의식적으로 분리</strong>하는 것만으로도 문서 관리가 훨씬 깔끔해진다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>정리하면 이렇다.</p>
<ol>
<li><strong>로드맵</strong>으로 전체 지형을 그린다</li>
<li><strong>우선순위</strong>를 기준으로 문서의 깊이와 관리 방식을 결정한다</li>
<li>문서가 쌓이면 <strong>공통 구조를 추상화</strong>하고, 틀을 프롬프트화한다</li>
<li><strong>Live 문서</strong>와 정적 문서를 분리해서 관리한다</li>
</ol>
<p>AI가 점점 더 많은 것을 대신해주는 시대일수록,
<strong>&quot;무엇을, 어떤 구조로, 어떤 순서로 알아야 하는가&quot;</strong> 를 판단하는 능력이 더 중요해진다.</p>
<p>문서는 그 판단을 외부화하는 가장 강력한 도구다.</p>
<hr>
<p>*이 글은 개인적인 개발 철학을 정리한 시리즈 중 하나입니다. 문서화 관련해서 꾸준히 업로드 예정 *</p>
<blockquote>
<p>최종 수정일: 2026-03-31</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[코딩 테스트 - 연산의 패턴화 ]]></title>
            <link>https://velog.io/@js-kim-arc/%EC%BD%94%EB%94%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%97%B0%EC%82%B0%EC%9D%98-%ED%8C%A8%ED%84%B4%ED%99%94</link>
            <guid>https://velog.io/@js-kim-arc/%EC%BD%94%EB%94%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%97%B0%EC%82%B0%EC%9D%98-%ED%8C%A8%ED%84%B4%ED%99%94</guid>
            <pubDate>Mon, 30 Mar 2026 11:03:47 GMT</pubDate>
            <description><![CDATA[<h1 id="코딩테스트-자료구조가-아니라-연산으로-반응하라---연산-abstraction">코딩테스트, &quot;자료구조&quot;가 아니라 &quot;연산&quot;으로 반응하라 - 연산 Abstraction</h1>
<blockquote>
<p>자료구조를 기준으로 function을 찾을 때는 문제
&quot;스택은 이럴 때, 큐는 저럴 때...&quot; 
무엇보다 공통점, 단조로움에 대해서 처음에 관통이 어려움</p>
<p><strong>문제는 연산을 요구한다.</strong></p>
<p>&quot;어떤 자료구조를 쓰지?&quot; 가 아니라
&quot;어떤 연산이 필요하지?&quot; 로 먼저 반응하는 순간
패턴이 보이기 시작한다. - 연산으로부터 패턴의 정형화 
연산들을 토대로 공통점 분류 작업 project </p>
</blockquote>
<hr>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/0cd7f18b-f378-45e8-9872-6d941c62f53d/image.png" alt=""></p>
<h2 id="목차">목차</h2>
<ol>
<li><a href="#1-%ED%83%90%EC%83%89-search">탐색 (Search)</a></li>
<li><a href="#2-%EA%B5%AC%EA%B0%84-range">구간 (Range)</a></li>
<li><a href="#3-%EC%9A%B0%EC%84%A0%EC%88%9C%EC%9C%84-priority">우선순위 (Priority)</a></li>
<li><a href="#4-%EB%B9%88%EB%8F%84--%ED%95%B4%EC%8B%9C-frequency--hash">빈도 / 해시 (Frequency / Hash)</a></li>
<li><a href="#5-%EC%97%B0%EA%B2%B0--%EC%A7%91%ED%95%A9-graph--union">연결 / 집합 (Graph / Union)</a></li>
<li><a href="#6-%EA%B7%B8%EB%9E%98%ED%94%84-%ED%83%90%EC%83%89-%EC%9D%91%EC%9A%A9-graph-traversal">그래프 탐색 응용 (Graph Traversal)</a></li>
<li><a href="#7-dp-dynamic-programming">DP (Dynamic Programming)</a></li>
<li><a href="#13-%EB%B3%B5%EC%9E%A1%EB%8F%84-%ED%95%9C%EA%B3%84-%EA%B8%B0%EC%A4%80">복잡도 한계 기준</a></li>
</ol>
<hr>
<h2 id="1-탐색-search">1. 탐색 (Search)</h2>
<table>
<thead>
<tr>
<th>시그널</th>
<th>패턴</th>
<th>복잡도</th>
<th>전환 조건</th>
</tr>
</thead>
<tbody><tr>
<td>정렬된 배열에서 값/위치/개수</td>
<td>bisect</td>
<td>O(log N)</td>
<td>정렬 안 됐으면 불가</td>
</tr>
<tr>
<td>&quot;최솟값의 최대화&quot; / &quot;최댓값의 최소화&quot;</td>
<td>파라메트릭 서치</td>
<td>O(N log N)</td>
<td>check 함수가 O(N) 이하여야 의미 있음</td>
</tr>
<tr>
<td>연속 구간 + 조건 + 크기 가변</td>
<td>투 포인터</td>
<td>O(N)</td>
<td>음수 포함이면 prefix sum + hashmap</td>
</tr>
<tr>
<td>연속 구간 + 조건 + 크기 고정</td>
<td>슬라이딩 윈도우</td>
<td>O(N)</td>
<td>최솟값/최댓값 필요하면 deque</td>
</tr>
<tr>
<td>문자열에서 패턴 위치/횟수</td>
<td>KMP</td>
<td>O(N+M)</td>
<td>N×M &lt; 10⁷ 이면 브루트포스 가능</td>
</tr>
<tr>
<td>공통 접두사 / 자동완성</td>
<td>트라이</td>
<td>O(L)</td>
<td>문자열 수 × 길이가 메모리 초과 주의</td>
</tr>
</tbody></table>
<p><strong>반응 기준</strong></p>
<pre><code>&quot;~이상/~이하 중 최적값&quot;           → 파라메트릭 서치
&quot;합이 K인 연속 구간&quot;              → 양수면 투 포인터 / 음수 포함이면 prefix+hash
&quot;길이 K인 구간의 최대/최소&quot;       → 슬라이딩 윈도우
&quot;정렬된 배열에서 몇 번째 / 몇 개&quot; → bisect
&quot;문자열 안에서 패턴&quot;              → KMP</code></pre><h3 id="핵심-원리">핵심 원리</h3>
<p><strong>bisect</strong>
정렬된 배열에서 선형 탐색 O(N) 대신,
단조성을 이용해 절반씩 제거하며 O(log N)에 위치를 찾는다.</p>
<pre><code class="language-python">import bisect

arr = sorted([4, 1, 7, 2, 9])  # [1, 2, 4, 7, 9]

bisect.bisect_left(arr, 5)    # 3 → 5 이상인 첫 번째 인덱스
bisect.bisect_right(arr, 5)   # 3 → 5 초과인 첫 번째 인덱스

# 범위 [lo, hi] 안의 원소 개수
def count_range(arr, lo, hi):
    return bisect.bisect_right(arr, hi) - bisect.bisect_left(arr, lo)</code></pre>
<p><strong>투 포인터</strong>
right를 늘릴수록 조건에 가까워지고, left를 당길수록 조건이 완화되는
단조성을 이용해 O(N²) → O(N)으로 줄인다.</p>
<pre><code class="language-python">left = 0
total = 0
answer = float(&#39;inf&#39;)

for right in range(n):
    total += arr[right]

    while total &gt;= s:              # 조건 만족하는 동안 최단 탐색
        answer = min(answer, right - left + 1)
        total -= arr[left]
        left += 1</code></pre>
<p><strong>슬라이딩 윈도우</strong>
크기 K인 구간을 매번 처음부터 계산하지 않고,
이전 결과에서 1개 빼고 1개 더하는 재사용으로 O(N×K) → O(N).</p>
<pre><code class="language-python">window_sum = sum(arr[:k])
answer = window_sum

for i in range(k, n):
    window_sum += arr[i]        # 오른쪽 추가
    window_sum -= arr[i - k]    # 왼쪽 제거
    answer = max(answer, window_sum)</code></pre>
<p><strong>파라메트릭 서치</strong>
정답 후보 범위에 단조성이 있을 때,
&quot;이 값이 답이 될 수 있는가&quot;를 O(N) check 함수로 판별하며
범위를 절반씩 줄여 O(N log N)에 최적값을 찾는다.</p>
<pre><code class="language-python">def check(mid):
    return sum(max(tree - mid, 0) for tree in trees) &gt;= m

left, right = 0, max(trees)
answer = 0

while left &lt;= right:
    mid = (left + right) // 2
    if check(mid):
        answer = mid
        left = mid + 1
    else:
        right = mid - 1</code></pre>
<hr>
<h2 id="2-구간-range">2. 구간 (Range)</h2>
<table>
<thead>
<tr>
<th>시그널</th>
<th>패턴</th>
<th>복잡도</th>
<th>전환 조건</th>
</tr>
</thead>
<tbody><tr>
<td>구간 합 쿼리 (배열 불변)</td>
<td>누적합 / 2D 누적합</td>
<td>O(1) / 쿼리</td>
<td>배열이 바뀌면 BIT로</td>
</tr>
<tr>
<td>구간 전체에 값 일괄 추가</td>
<td>차이 배열</td>
<td>O(1) / 업데이트</td>
<td>점 업데이트는 불가</td>
</tr>
<tr>
<td>점 업데이트 + 구간 합</td>
<td>BIT (Fenwick Tree)</td>
<td>O(log N)</td>
<td>최솟값/최댓값은 세그트리로</td>
</tr>
<tr>
<td>점 업데이트 + 구간 최솟값·최댓값</td>
<td>세그먼트 트리</td>
<td>O(log N)</td>
<td>구현 복잡 — BIT로 안 될 때만</td>
</tr>
<tr>
<td>오른쪽/왼쪽으로 다음 크거나 작은 값</td>
<td>단조 스택</td>
<td>O(N)</td>
<td>구간 최솟값이 아니라 &quot;다음 값&quot;</td>
</tr>
<tr>
<td>고정 구간 내 최솟값/최댓값 반복 쿼리</td>
<td>스파스 테이블</td>
<td>O(1) / 쿼리</td>
<td>배열 불변일 때만 / 전처리 O(N log N)</td>
</tr>
</tbody></table>
<p><strong>반응 기준</strong></p>
<pre><code>배열이 안 바뀜 + 구간 합         → 누적합
배열이 안 바뀜 + 구간 최솟값     → 스파스 테이블
배열이 바뀜 + 구간 합            → BIT
배열이 바뀜 + 구간 최솟값·최댓값 → 세그먼트 트리
&quot;다음으로 큰 수&quot; / 히스토그램    → 단조 스택
구간에 일괄 더하기만             → 차이 배열</code></pre><h3 id="핵심-원리-1">핵심 원리</h3>
<p><strong>누적합</strong>
prefix[i] = arr[0] + ... + arr[i-1] 을 전처리해두면
구간 [l, r] 합을 prefix[r+1] - prefix[l] 로 O(1)에 계산.</p>
<pre><code class="language-python">prefix = [0] * (n + 1)
for i in range(n):
    prefix[i + 1] = prefix[i] + arr[i]

# 구간 [l, r] 합
query = prefix[r + 1] - prefix[l]</code></pre>
<p><strong>단조 스택</strong>
스택에 &quot;아직 다음으로 큰 값을 못 찾은 인덱스&quot;를 유지.
새 원소가 스택 top보다 크면 → top의 &quot;다음으로 큰 값&quot;이 확정.</p>
<pre><code class="language-python">stack = []
result = [-1] * n

for i in range(n):
    while stack and arr[stack[-1]] &lt; arr[i]:
        result[stack.pop()] = arr[i]    # 다음으로 큰 값 확정
    stack.append(i)</code></pre>
<hr>
<h2 id="3-우선순위-priority">3. 우선순위 (Priority)</h2>
<table>
<thead>
<tr>
<th>시그널</th>
<th>패턴</th>
<th>복잡도</th>
<th>전환 조건</th>
</tr>
</thead>
<tbody><tr>
<td>최솟값 / 최댓값 반복 추출</td>
<td>heapq</td>
<td>O(log N)</td>
<td>정적 데이터면 정렬 한 번이 더 빠름</td>
</tr>
<tr>
<td>실시간 K번째 값 유지</td>
<td>크기 K 고정 힙</td>
<td>O(N log K)</td>
<td>K가 고정일 때만</td>
</tr>
<tr>
<td>실시간 중앙값 유지</td>
<td>두 힙 조합</td>
<td>O(log N)</td>
<td>삽입/삭제 동시에 필요할 때</td>
</tr>
<tr>
<td>특정 원소 삭제 + 최솟값</td>
<td>지연 삭제 + heapq</td>
<td>O(log N)</td>
<td>힙에서 직접 삭제는 O(N)</td>
</tr>
</tbody></table>
<p><strong>반응 기준</strong></p>
<pre><code>&quot;매 순간 가장 작은/큰 것&quot;    → heapq
&quot;다익스트라&quot;                 → heapq 필수
&quot;최댓값이 필요&quot;              → -val 음수 트릭
&quot;중앙값을 실시간으로&quot;        → 두 힙 조합
&quot;K번째로 큰 값 유지&quot;         → 크기 K 최소힙</code></pre><h3 id="핵심-원리-2">핵심 원리</h3>
<p><strong>heapq (다익스트라)</strong>
매 단계마다 가장 비용이 작은 노드를 O(log V)에 꺼내서 처리.
힙 없이 매번 전체 스캔하면 O(V²).</p>
<pre><code class="language-python">import heapq

def dijkstra(graph, start, n):
    dist = [float(&#39;inf&#39;)] * (n + 1)
    dist[start] = 0
    heap = [(0, start)]

    while heap:
        cost, u = heapq.heappop(heap)
        if cost &gt; dist[u]:       # 이미 처리된 노드 스킵
            continue
        for v, w in graph[u]:
            if dist[u] + w &lt; dist[v]:
                dist[v] = dist[u] + w
                heapq.heappush(heap, (dist[v], v))
    return dist</code></pre>
<p><strong>두 힙으로 중앙값 유지</strong>
작은 절반 → 최대힙 / 큰 절반 → 최소힙으로 나누어
두 힙의 top에서 중앙값을 O(1)에 계산.</p>
<pre><code class="language-python">import heapq

lower = []    # 최대힙 (부호 반전)
upper = []    # 최소힙

def add(num):
    heapq.heappush(lower, -num)
    if upper and -lower[0] &gt; upper[0]:
        heapq.heappush(upper, -heapq.heappop(lower))
    if len(lower) &lt; len(upper):
        heapq.heappush(lower, -heapq.heappop(upper))
    elif len(lower) &gt; len(upper) + 1:
        heapq.heappush(upper, -heapq.heappop(lower))

def median():
    if len(lower) &gt; len(upper):
        return -lower[0]
    return (-lower[0] + upper[0]) / 2</code></pre>
<hr>
<h2 id="4-빈도--해시-frequency--hash">4. 빈도 / 해시 (Frequency / Hash)</h2>
<table>
<thead>
<tr>
<th>시그널</th>
<th>패턴</th>
<th>복잡도</th>
<th>전환 조건</th>
</tr>
</thead>
<tbody><tr>
<td>값 등장 횟수</td>
<td>Counter / defaultdict(int)</td>
<td>O(N)</td>
<td>-</td>
</tr>
<tr>
<td>합이 K인 두 수 쌍</td>
<td>해시맵 + 보수 탐색</td>
<td>O(N)</td>
<td>정렬 배열이면 bisect도 가능</td>
</tr>
<tr>
<td>합이 K인 부분 배열 개수</td>
<td>누적합 + 해시맵</td>
<td>O(N)</td>
<td>양수만이면 투 포인터도 가능</td>
</tr>
<tr>
<td>아나그램 / 동일 구조 그룹핑</td>
<td>정규화 키 + 해시맵</td>
<td>O(N)</td>
<td>-</td>
</tr>
<tr>
<td>값 범위가 너무 클 때 인덱스 재매핑</td>
<td>좌표 압축</td>
<td>O(N log N)</td>
<td>값이 아닌 순서만 필요할 때</td>
</tr>
<tr>
<td>삽입·삭제 중 K번째 원소</td>
<td>좌표압축 + BIT</td>
<td>O(N log N)</td>
<td>-</td>
</tr>
</tbody></table>
<p><strong>반응 기준</strong></p>
<pre><code>&quot;몇 번 등장?&quot;                        → Counter
&quot;두 수 합이 K?&quot;                      → dict에 보수 저장
&quot;부분 배열 합이 K?&quot;                  → prefix_sum + dict
&quot;같은 패턴으로 묶어라&quot;               → sorted(word) or tuple 정규화 키
&quot;값이 10⁹인데 배열 인덱스로 쓰고 싶다&quot; → 좌표 압축</code></pre><h3 id="핵심-원리-3">핵심 원리</h3>
<p><strong>누적합 + 해시맵 (음수 포함 구간합)</strong>
prefix[j] - prefix[i] = k  →  prefix[j] - k = prefix[i]
&quot;지금까지 본 prefix 합 중 (현재 합 - k)가 있었는가&quot;를 dict로 O(1) 체크.</p>
<pre><code class="language-python">from collections import defaultdict

def count_subarrays(arr, k):
    prefix_count = defaultdict(int)
    prefix_count[0] = 1    # arr[0]부터 시작하는 구간 처리
    total = 0
    answer = 0

    for x in arr:
        total += x
        answer += prefix_count[total - k]
        prefix_count[total] += 1

    return answer</code></pre>
<p><strong>좌표 압축</strong>
값이 10⁹까지 있어도 실제로 사용하는 값이 N개뿐이면
0~N-1로 재매핑해서 배열 인덱스로 활용.</p>
<pre><code class="language-python">sorted_unique = sorted(set(arr))
compress = {v: i for i, v in enumerate(sorted_unique)}
compressed = [compress[x] for x in arr]</code></pre>
<hr>
<h2 id="5-연결--집합-graph--union">5. 연결 / 집합 (Graph / Union)</h2>
<table>
<thead>
<tr>
<th>시그널</th>
<th>패턴</th>
<th>복잡도</th>
<th>전환 조건</th>
</tr>
</thead>
<tbody><tr>
<td>같은 그룹인지 / 사이클 감지</td>
<td>Union-Find</td>
<td>O(α)</td>
<td>간선 삭제가 필요하면 사용 불가</td>
</tr>
<tr>
<td>최소 비용으로 모든 노드 연결</td>
<td>크루스칼 (정렬 + Union-Find)</td>
<td>O(E log E)</td>
<td>밀집 그래프면 프림이 유리</td>
</tr>
<tr>
<td>가중치 없는 최단 경로</td>
<td>BFS</td>
<td>O(V+E)</td>
<td>가중치 있으면 다익스트라로</td>
</tr>
<tr>
<td>양수 가중치 최단 경로</td>
<td>다익스트라 + heapq</td>
<td>O(E log V)</td>
<td>음수 가중치 있으면 벨만-포드</td>
</tr>
<tr>
<td>음수 가중치 / 전체 쌍 최단 경로</td>
<td>플로이드-워셜</td>
<td>O(V³)</td>
<td>V &gt; 500 이면 시간 초과</td>
</tr>
<tr>
<td>선행 조건 / 의존성 순서</td>
<td>위상 정렬</td>
<td>O(V+E)</td>
<td>사이클 있으면 위상 정렬 불가</td>
</tr>
<tr>
<td>가중치가 0 또는 1</td>
<td>0-1 BFS (deque)</td>
<td>O(V+E)</td>
<td>다익스트라보다 빠름</td>
</tr>
</tbody></table>
<p><strong>반응 기준</strong></p>
<pre><code>&quot;연결되어 있냐?&quot;        → Union-Find or BFS/DFS
&quot;최소 비용 연결&quot;        → 크루스칼
&quot;최단 거리&quot;             → 무가중 BFS / 양수 다익스트라 / 음수 벨만포드
&quot;순서가 있는 의존성&quot;    → 위상 정렬
&quot;0 또는 1 가중치&quot;       → 0-1 BFS</code></pre><h3 id="핵심-원리-4">핵심 원리</h3>
<p><strong>Union-Find</strong>
두 노드가 같은 집합인지를 O(α) ≈ O(1)에 판별.
경로 압축 + 랭크 병합으로 트리가 납작하게 유지됨.</p>
<pre><code class="language-python">parent = list(range(n + 1))
rank = [0] * (n + 1)

def find(x):
    if parent[x] != x:
        parent[x] = find(parent[x])    # 경로 압축
    return parent[x]

def union(x, y):
    px, py = find(x), find(y)
    if px == py:
        return False    # 이미 같은 집합 → 사이클
    if rank[px] &lt; rank[py]:
        px, py = py, px
    parent[py] = px
    if rank[px] == rank[py]:
        rank[px] += 1
    return True</code></pre>
<p><strong>위상 정렬</strong>
진입차수가 0인 노드부터 처리하며 의존성 순서를 결정.
처리된 노드 수가 전체보다 적으면 사이클 존재.</p>
<pre><code class="language-python">from collections import deque

indegree = [0] * (n + 1)
for u, v in edges:
    indegree[v] += 1

q = deque([i for i in range(1, n+1) if indegree[i] == 0])
order = []

while q:
    node = q.popleft()
    order.append(node)
    for next_node in graph[node]:
        indegree[next_node] -= 1
        if indegree[next_node] == 0:
            q.append(next_node)

if len(order) != n:
    print(&quot;사이클 존재&quot;)</code></pre>
<hr>
<h2 id="6-그래프-탐색-응용-graph-traversal">6. 그래프 탐색 응용 (Graph Traversal)</h2>
<table>
<thead>
<tr>
<th>시그널</th>
<th>패턴</th>
<th>복잡도</th>
<th>전환 조건</th>
</tr>
</thead>
<tbody><tr>
<td>영역 크기 / 연결 컴포넌트</td>
<td>DFS / BFS</td>
<td>O(V+E)</td>
<td>-</td>
</tr>
<tr>
<td>조건 붙은 상태 공간 탐색</td>
<td>BFS + 상태 튜플</td>
<td>O(상태 수)</td>
<td>상태가 너무 많으면 DP로</td>
</tr>
<tr>
<td>사이클 탐지 / 무한루프 판별</td>
<td>DFS + 색칠 (white/gray/black)</td>
<td>O(V+E)</td>
<td>-</td>
</tr>
<tr>
<td>트리에서 두 노드 최단 거리</td>
<td>LCA (최소 공통 조상)</td>
<td>O(log N)</td>
<td>일반 BFS로 안 되는 쿼리가 많을 때</td>
</tr>
</tbody></table>
<p><strong>반응 기준</strong></p>
<pre><code>&quot;섬의 개수&quot; / &quot;영역 넓이&quot;     → DFS/BFS 컴포넌트
&quot;문 열쇠 / 조건부 이동&quot;       → BFS + (위치, 상태) 튜플
&quot;이 노드에서 탈출 가능?&quot;      → DFS 색칠
&quot;트리에서 두 노드 거리 쿼리&quot;  → LCA</code></pre><h3 id="핵심-원리-5">핵심 원리</h3>
<p><strong>BFS + 상태 튜플</strong>
단순 위치만으로는 방문 체크가 안 되는 경우,
(위치, 조건 상태)를 튜플로 묶어서 visited에 저장.</p>
<pre><code class="language-python">from collections import deque

visited = set()
q = deque([(start_r, start_c, initial_state)])
visited.add((start_r, start_c, initial_state))

while q:
    r, c, state = q.popleft()
    for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
        nr, nc = r + dr, c + dc
        new_state = update(state, nr, nc)
        if (nr, nc, new_state) not in visited:
            visited.add((nr, nc, new_state))
            q.append((nr, nc, new_state))</code></pre>
<hr>
<h2 id="7-dp-dynamic-programming">7. DP (Dynamic Programming)</h2>
<table>
<thead>
<tr>
<th>시그널</th>
<th>패턴</th>
<th>복잡도</th>
<th>전환 조건</th>
</tr>
</thead>
<tbody><tr>
<td>순서대로 최적값 / 경우의 수 누적</td>
<td>1D / 2D DP</td>
<td>O(N), O(N²)</td>
<td>그리디 교환 논증 안 되면 DP</td>
</tr>
<tr>
<td>구간을 분할해 최적화</td>
<td>구간 DP</td>
<td>O(N³)</td>
<td>N &gt; 500 이면 시간 초과</td>
</tr>
<tr>
<td>트리에서 자식 → 부모 집계</td>
<td>트리 DP</td>
<td>O(N)</td>
<td>-</td>
</tr>
<tr>
<td>부분집합 선택 / 순열 상태 압축</td>
<td>비트마스크 DP</td>
<td>O(2^N × N)</td>
<td>N ≤ 20 이하일 때만</td>
</tr>
<tr>
<td>DAG 위상 순서 기반 점화식</td>
<td>위상정렬 DP</td>
<td>O(V+E)</td>
<td>-</td>
</tr>
</tbody></table>
<p><strong>반응 기준</strong></p>
<pre><code>&quot;최대/최소/경우의 수&quot; + 앞 결과가 뒤에 영향  → DP
구간 [i, j] 최적값                           → 구간 DP
N ≤ 20이고 모든 선택 조합                    → 비트마스크 DP
트리에서 서브트리 집계                        → 트리 DP
그리디 교환 논증이 안 됨                      → DP로 전환</code></pre><h3 id="핵심-원리-6">핵심 원리</h3>
<p>DP의 본질은 <strong>&quot;이미 계산한 부분 문제의 결과를 재사용해서 중복 계산을 없애는 것&quot;</strong>.
그리디와의 구분 기준은 <strong>교환 논증</strong>이다.
&quot;지금 최적 선택이 나중에도 최적을 보장하는가&quot; → YES면 그리디, NO면 DP.</p>
<pre><code class="language-python"># 배낭 문제 — 전형적인 2D DP
dp = [[0] * (capacity + 1) for _ in range(n + 1)]

for i in range(1, n + 1):
    weight, value = items[i - 1]
    for w in range(capacity + 1):
        dp[i][w] = dp[i - 1][w]    # 안 담는 경우
        if w &gt;= weight:
            dp[i][w] = max(dp[i][w], dp[i-1][w-weight] + value)</code></pre>
<hr>
<h2 id="8-복잡도-한계-기준">8. 복잡도 한계 기준</h2>
<p>코딩테스트 현장에서 시간 초과를 피하는 기준표.
<strong>10⁸ 연산 / 초</strong> 기준으로 역산한다.</p>
<pre><code>N = 10⁶   → O(N) or O(N log N) 까지
N = 10⁵   → O(N log N) or O(N log² N) 까지
N = 10⁴   → O(N²) 까지
N = 10³   → O(N² log N) 까지
N = 500   → O(N³) 까지          → 플로이드-워셜, 구간 DP
N = 20    → O(2^N × N) 까지     → 비트마스크 DP
N = 10    → O(N!) 까지          → 완전탐색 / 백트래킹</code></pre><hr>
<h2 id="마치며">마치며</h2>
<blockquote>
<p>패턴은 외우는 것이 아니라 <strong>원리로 이해하는 것</strong>이다.</p>
<p>&quot;왜 이 연산이 이 복잡도인가&quot;를 이해하면
처음 보는 문제에서도 패턴이 보인다.</p>
<p>모든 패턴의 공통점은 하나다.
<strong>&quot;어차피 볼 필요 없는 것을 건너뛰는 방법을 찾는 것&quot;</strong></p>
</blockquote>
]]></description>
        </item>
    </channel>
</rss>