<?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>Wed, 08 Apr 2026 10:32:42 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[[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>
        <item>
            <title><![CDATA[[third tool] 기술 블로그 - Card 변경 이력 분리 ]]></title>
            <link>https://velog.io/@js-kim-arc/third-tool-%EA%B8%B0%EC%88%A0-%EB%B8%94%EB%A1%9C%EA%B7%B8-Card-%EB%B3%80%EA%B2%BD-%EC%9D%B4%EB%A0%A5-%EB%B6%84%EB%A6%AC</link>
            <guid>https://velog.io/@js-kim-arc/third-tool-%EA%B8%B0%EC%88%A0-%EB%B8%94%EB%A1%9C%EA%B7%B8-Card-%EB%B3%80%EA%B2%BD-%EC%9D%B4%EB%A0%A5-%EB%B6%84%EB%A6%AC</guid>
            <pubDate>Fri, 27 Mar 2026 10:55:44 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<ul>
<li><a href="#%EB%93%A4%EC%96%B4%EA%B0%80%EB%A9%B0">들어가며</a></li>
<li><a href="#%EA%B8%B0%EC%A1%B4-%EA%B5%AC%EC%A1%B0%EC%9D%98-%ED%95%9C%EA%B3%84">기존 구조의 한계</a></li>
<li><a href="#%EC%84%A4%EA%B3%84-%EA%B2%B0%EC%A0%95--card%EC%99%80-cardstatushistory%EC%9D%98-%EC%B1%85%EC%9E%84-%EB%B6%84%EB%A6%AC">설계 결정 — Card와 CardStatusHistory의 책임 분리</a></li>
<li><a href="#%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8--cardstatushistory">도메인 모델 — CardStatusHistory</a></li>
<li><a href="#cardstatushistoryappender--%EC%9D%B4%EB%A0%A5-%EA%B8%B0%EB%A1%9D-%EC%A0%84%EB%8B%B4-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4---%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%9D%98-%ED%99%9C%EC%9A%A9">CardStatusHistoryAppender — 이력 기록 전담 도메인 서비스 - 도메인 서비스의 활용</a></li>
<li><a href="#%EC%98%81%EC%86%8D%EC%84%B1-%EB%A0%88%EC%9D%B4%EC%96%B4--repository--adapter-%ED%8C%A8%ED%84%B4%ED%8F%AC%ED%8A%B8%EC%99%80-%EC%96%B4%EB%8C%91%ED%84%B0--%EC%9D%98%EC%A1%B4%EC%84%B1-%EA%B4%80%EB%A6%AC-%EC%B8%A1%EB%A9%B4">영속성 레이어 — Repository / Adapter 패턴(포트와 어댑터- 의존성 관리 측면)</a></li>
<li><a href="#%EB%B6%84%EB%A6%AC%EB%A1%9C-%EC%96%BB%EC%9D%80-%EA%B2%83%EB%93%A4">분리로 얻은 것들</a></li>
<li><a href="#%EB%A7%88%EC%B9%98%EB%A9%B0">마치며</a>
<img src="https://velog.velcdn.com/images/js-kim-arc/post/48da2e51-aec1-470d-96c8-e3aeab928a32/image.png" alt=""><h1 id="card-상태-이력을-분리-테이블로-관리하기--cardstatushistory-설계-결정기">Card 상태 이력을 분리 테이블로 관리하기 — CardStatusHistory 설계 결정기</h1>
</li>
</ul>
<h2 id="들어가며">들어가며</h2>
<p>Third Tool의 Card는 두 가지 운영 위치를 가집니다.</p>
<ul>
<li><strong>ON_FIELD</strong> — 지금 집중 반복이 필요한 전면 노출 구간</li>
<li><strong>ARCHIVE</strong> — 배경 지식으로 보관된 후방 대기 구간</li>
</ul>
<p>처음에는 Card 엔티티 안에 <code>status</code> 컬럼 하나만 두고 현재 상태를 덮어쓰는 방식으로 구현했습니다. 단순하고 빨랐지만, 이 구조에는 조용한 문제가 하나 있었습니다.</p>
<blockquote>
<p><strong>&quot;이 카드, 언제 ARCHIVE로 보냈더라? 몇 번이나 왔다 갔다 했지?&quot;</strong></p>
</blockquote>
<p>현재 상태만 알 수 있고, 그 상태가 어떻게 변해왔는지는 전혀 알 수 없었습니다.</p>
<hr>
<h2 id="기존-구조의-한계">기존 구조의 한계</h2>
<pre><code class="language-java">// 기존 Card — status를 그냥 덮어씀
public void archive() {
    this.status = CardStatus.ARCHIVE;  // 이전 상태? 모름. 언제? 모름.
}</code></pre>
<p>이 방식의 문제는 명확했습니다.</p>
<ul>
<li><strong>이력 추적 불가</strong>: ON_FIELD → ARCHIVE 전환이 몇 번 일어났는지 알 수 없습니다.</li>
<li><strong>시점 데이터 없음</strong>: 언제 상태가 바뀌었는지 <code>updatedDate</code>만으로는 원인을 특정할 수 없습니다. Card 수정과 상태 변경이 <code>updatedDate</code>를 공유하기 때문입니다.</li>
<li><strong>감사(Audit) 불가</strong>: &quot;지난달에 ARCHIVE로 보냈다가 다시 꺼낸 카드 목록&quot;같은 조회가 원천적으로 불가능합니다.</li>
<li><strong>RAG 서버 연동 고려</strong>: 추후 학습 패턴 분석이나 개인화 추천에 활용하려면 상태 변화 시계열 데이터가 반드시 필요합니다.</li>
</ul>
<hr>
<h2 id="설계-결정--card와-cardstatushistory의-책임-분리">설계 결정 — Card와 CardStatusHistory의 책임 분리</h2>
<p>리팩토링 후 두 객체의 책임을 명확하게 나눴습니다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Card</th>
<th>CardStatusHistory</th>
</tr>
</thead>
<tbody><tr>
<td>책임</td>
<td>현재 위치만 관리</td>
<td>전환 이력만 기록</td>
</tr>
<tr>
<td>변경 가능 여부</td>
<td>status 변경 가능</td>
<td>생성 후 불변</td>
</tr>
<tr>
<td>데이터</td>
<td>status (현재값)</td>
<td>fromStatus, toStatus, changedAt</td>
</tr>
</tbody></table>
<p>Card는 &quot;지금 어디 있는가&quot;만 알면 됩니다. 이력은 CardStatusHistory가 전담합니다.</p>
<hr>
<h2 id="도메인-모델--cardstatushistory">도메인 모델 — CardStatusHistory</h2>
<pre><code class="language-java">@Entity
@Table(name = &quot;card_status_history&quot;)
public class CardStatusHistory {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;card_status_history_id&quot;)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;card_id&quot;, nullable = false, updatable = false)
    private Card card;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;from_status&quot;, nullable = false, updatable = false, length = 20)
    private CardStatus fromStatus;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;to_status&quot;, nullable = false, updatable = false, length = 20)
    private CardStatus toStatus;

    @CreationTimestamp
    @Column(name = &quot;changed_at&quot;, nullable = false, updatable = false)
    private LocalDateTime changedAt;
}</code></pre>
<p>중요한 설계 포인트 세 가지입니다.</p>
<p><strong>1. 모든 컬럼에 <code>updatable = false</code></strong>
이력은 생성 후 절대 수정되지 않습니다. 이력을 수정하는 것 자체가 감사 데이터의 신뢰를 무너뜨립니다.</p>
<p><strong>2. 팩토리 메서드의 접근 제어</strong></p>
<pre><code class="language-java">// package-private — CardStatusHistoryAppender에서만 호출 가능
static CardStatusHistory of(Card card, CardStatus fromStatus, CardStatus toStatus) {
    if (fromStatus == toStatus) {
        throw CardDomainException.of(
            ErrorCode.INVALID_INPUT,
            &quot;fromStatus와 toStatus가 동일합니다.&quot;
        );
    }
    return new CardStatusHistory(card, fromStatus, toStatus);
}</code></pre>
<p><code>of()</code>를 package-private으로 선언해 외부에서 직접 생성할 수 없게 막았습니다. 이력 생성의 진입점은 <code>CardStatusHistoryAppender</code> 하나뿐입니다.</p>
<p><strong>3. 동일 상태 전환 방어</strong>
<code>fromStatus == toStatus</code>이면 예외를 던집니다. &quot;ON_FIELD → ON_FIELD&quot; 같은 의미 없는 이력이 쌓이는 것을 도메인 레벨에서 차단합니다.</p>
<hr>
<h2 id="cardstatushistoryappender--이력-기록-전담-도메인-서비스---도메인-서비스의-활용">CardStatusHistoryAppender — 이력 기록 전담 도메인 서비스 - 도메인 서비스의 활용</h2>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class CardStatusHistoryAppender {

    private final CardStatusHistoryRepository historyRepository;

    public void append(Card card, CardStatus fromStatus, CardStatus toStatus) {
        if (fromStatus == toStatus) {
            // 멱등성 처리 — 상태가 바뀌지 않으면 이력 생성 불필요
            return;
        }
        CardStatusHistory history = CardStatusHistory.of(card, fromStatus, toStatus);
        historyRepository.save(history);
    }
}</code></pre>
<p><code>CardStatusHistoryAppender</code>가 존재하는 이유는 이력 생성 규칙을 한 곳에 모으기 위해서입니다. Application Service에서 직접 <code>CardStatusHistory.of()</code>를 호출하면 이 검증 로직이 서비스 여기저기에 흩어집니다.</p>
<p>호출 흐름은 다음과 같습니다.</p>
<pre><code>CardCommandService
    └── card.archive()              ← Card 현재 상태 변경
    └── appender.append(            ← 이력 기록 *** 
            card,
            CardStatus.ON_FIELD,    ← fromStatus
            CardStatus.ARCHIVE      ← toStatus
        )</code></pre><p>Card는 자신의 상태만 바꾸고, 이력 기록은 Appender가 독립적으로 처리합니다.</p>
<hr>
<h2 id="영속성-레이어--repository--adapter-패턴포트와-어댑터--의존성-관리-측면">영속성 레이어 — Repository / Adapter 패턴(포트와 어댑터- 의존성 관리 측면)</h2>
<p>이전 글에서 다뤘던 포트와 어댑터 구조를 이력 관리에도 동일하게 적용했습니다.</p>
<pre><code class="language-java">// Outbound Port — 도메인이 선언하는 인터페이스
public interface CardStatusHistoryRepository {
    CardStatusHistory save(CardStatusHistory history);
}

// Adapter — JPA 구현체
@Repository
@RequiredArgsConstructor
public class CardStatusHistoryRepositoryAdapter
        implements CardStatusHistoryRepository {

    private final CardStatusHistoryJpaRepository jpaRepository;

    @Override
    public CardStatusHistory save(CardStatusHistory history) {
        return jpaRepository.save(history);
    }
}</code></pre>
<p><code>CardStatusHistoryAppender</code>는 <code>CardStatusHistoryRepository</code> 인터페이스만 알고 있습니다. JPA 구현체가 바뀌어도 도메인 서비스는 영향을 받지 않습니다.</p>
<hr>
<h2 id="분리로-얻은-것들">분리로 얻은 것들</h2>
<p><strong>추적 가능한 이력 데이터</strong></p>
<pre><code class="language-sql">-- 특정 카드의 상태 전환 이력 전체 조회
SELECT from_status, to_status, changed_at
FROM card_status_history
WHERE card_id = 42
ORDER BY changed_at;

-- 결과
-- ON_FIELD → ARCHIVE  2025-03-01 09:12
-- ARCHIVE  → ON_FIELD 2025-03-15 14:30
-- ON_FIELD → ARCHIVE  2025-03-27 11:05</code></pre>
<p>Card의 <code>updatedDate</code>만 봤을 때는 불가능했던 조회입니다.</p>
<p><strong>Card 엔티티의 단순함 유지</strong></p>
<p>Card는 현재 상태만 관리합니다. 이력 관련 필드나 로직이 Card 안으로 흘러들어오지 않습니다. 이력이 생기더라도 Card를 열어볼 이유가 없습니다.</p>
<p><strong>미래 기능의 토대</strong></p>
<p>현재는 단순 저장만 하지만, 이 테이블을 기반으로 다음 기능이 가능해집니다.</p>
<ul>
<li>학습 패턴 분석: &quot;이 사용자는 평균 며칠 만에 ARCHIVE로 보내는가&quot;</li>
<li>RAG 서버 연동: 상태 변화 시계열을 컨텍스트로 활용한 카드 추천</li>
<li>복습 알고리즘: ON_FIELD 복귀 빈도 기반 난이도 추정</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>상태를 단순히 덮어쓰는 것과 이력으로 남기는 것은 당장은 차이가 없어 보입니다. 하지만 서비스가 성장할수록 &quot;과거에 무슨 일이 있었는가&quot;를 묻는 순간이 반드시 옵니다.</p>
<p>Third Tool에서 CardStatusHistory를 분리한 핵심 이유는 단 하나입니다.</p>
<blockquote>
<p><strong>Card는 지금 어디 있는지만 알면 된다. 어떻게 여기까지 왔는지는 다른 객체가 기억한다.</strong></p>
</blockquote>
<p>책임을 명확히 나누면 각 객체가 단순해지고, 나중에 필요한 기능을 추가할 때 기존 코드를 건드리지 않아도 됩니다. 이번 리팩토링이 그 작은 증거입니다.</p>
<p>리팩토링 회고 끝!</p>
<blockquote>
<p>최종 수정일: 2026-03-27 </p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[third tool] Spring 이벤트, Third Tool에 녹여보기 — (non-blocking 도입)]]></title>
            <link>https://velog.io/@js-kim-arc/third-tool-Spring-%EC%9D%B4%EB%B2%A4%ED%8A%B8-Third-Tool%EC%97%90-%EB%85%B9%EC%97%AC%EB%B3%B4%EA%B8%B0-non-blocking-%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@js-kim-arc/third-tool-Spring-%EC%9D%B4%EB%B2%A4%ED%8A%B8-Third-Tool%EC%97%90-%EB%85%B9%EC%97%AC%EB%B3%B4%EA%B8%B0-non-blocking-%EB%8F%84%EC%9E%85</guid>
            <pubDate>Thu, 26 Mar 2026 11:12:48 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/cc830bdc-3023-41f6-a78b-665c598e708f/image.png" alt=""></p>
<h1 id="spring-이벤트-third-tool에-녹여보기--non-blocking-찍먹">Spring 이벤트, Third Tool에 녹여보기 — (non-blocking 찍먹)</h1>
<blockquote>
<p>이 글은 개인 프로젝트 <strong>Third Tool</strong>(Cornell Notes 기반 학습 카드 시스템)에 Spring 애플리케이션 이벤트를 도입하면서 겪은 고민의 흔적입니다.<br>&quot;왜 이벤트를 써야 하는가&quot;보다 <strong>&quot;이 프로젝트의 어느 지점에 이벤트가 필요했는가&quot;</strong> 에 집중했습니다.</p>
</blockquote>
<hr>
<h2 id="목차">목차</h2>
<ol>
<li><a href="#1-%EB%8F%84%EC%9E%85-%EC%A0%84--%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%A9%94%EC%84%9C%EB%93%9C%EA%B0%80-%EC%A0%90%EC%A0%90-%EB%9A%B1%EB%9A%B1%ED%95%B4%EC%A7%80%EB%8D%98-%EC%8B%9C%EC%A0%88">도입 전 — 서비스 메서드가 점점 뚱뚱해지던 시절</a></li>
<li><a href="#2-third-tool%EC%97%90%EC%84%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8%EA%B0%80-%EC%96%B4%EC%9A%B8%EB%A6%AC%EB%8A%94-%EC%A7%80%EC%A0%90">Third Tool에서 이벤트가 어울리는 지점</a></li>
<li><a href="#3-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%9D%98%EC%A1%B4-%EA%B4%80%EA%B3%84%EA%B0%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8B%AC%EB%9D%BC%EC%A7%80%EB%82%98">트랜잭션 의존 관계가 어떻게 달라지나</a></li>
<li><a href="#4-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%8E%B8%EC%9D%98%EC%84%B1--%EC%9D%B4%EA%B2%8C-%EC%83%9D%EA%B0%81%EB%B3%B4%EB%8B%A4-%ED%81%B0-%EC%9D%B4%EC%9C%A0%EC%98%80%EB%8B%A4">테스트 편의성 — 이게 생각보다 큰 이유였다</a></li>
<li><a href="#5-%EC%A0%95%EB%A6%AC">정리</a></li>
</ol>
<hr>
<h2 id="1-도입-전--서비스-메서드가-점점-뚱뚱해지던-시절">1. 도입 전 — 서비스 메서드가 점점 뚱뚱해지던 시절</h2>
<p>Third Tool의 핵심 도메인은 <code>Card</code>다.<br>Card 한 장엔 <strong>MainNote</strong>(학습 맥락), <strong>KeywordCue</strong>(회상 단서), <strong>Summary</strong>(핵심 압축) 세 파트가 있고,<br>사용자는 이 구조를 따라 <strong>학습 → 회상(Review) → 요약</strong> 의 순서로 카드를 소화한다.</p>
<p>Card Review가 완료되는 시점, 처음엔 이렇게 짰다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
@Transactional
public class ReviewService {

    private final CardRepository cardRepository;
    private final ReviewStatisticsRepository statsRepository;
    private final NotificationService notificationService;
    private final DeckProgressRepository deckProgressRepository;

    public void completeReview(Long cardId) {
        Card card = cardRepository.findById(cardId).orElseThrow();
        card.completeReview();
        cardRepository.save(card);

        // 여기서부터가 문제
        statsRepository.incrementReviewCount(cardId);                     // 통계
        notificationService.scheduleReminder(cardId, card.getNextReviewDate()); // 알림 예약
        deckProgressRepository.recalculate(card.getDeckId());             // 덱 진행률 갱신
        // 나중에 뱃지, 라이브러리, 상담 기능까지 너무 두꺼워지는데 ... 계속 늘어난다
    }
}</code></pre>
<p><code>ReviewService</code>가 <strong>통계 / 알림 / 덱 진행률</strong> 을 전부 알고 있어야 한다.<br>Card Review라는 핵심 행위가 끝났을 뿐인데, 이 메서드는 프로젝트 전체를 향해 손을 뻗고 있다.</p>
<p><strong>이벤트가 필요하다는 신호 세 가지:</strong></p>
<ul>
<li><code>ReviewService</code> 생성자 주입이 점점 늘어난다</li>
<li>새 기능을 추가할 때마다 이 메서드를 열어야 한다</li>
<li>테스트에서 목(Mock)이 너무 많아 무엇을 테스트하는지 불분명해진다</li>
</ul>
<hr>
<h2 id="2-third-tool에서-이벤트가-어울리는-지점">2. Third Tool에서 이벤트가 어울리는 지점</h2>
<p>Third Tool을 훑어보면 이벤트 패턴이 자연스럽게 맞아 들어가는 지점이 몇 군데 있다.</p>
<hr>
<h3 id="2-1-card-review-완료--가장-먼저-눈에-띈-곳">2-1. Card Review 완료 — 가장 먼저 눈에 띈 곳</h3>
<pre><code>CardReviewedEvent 발행 (여긴 바로 알 필요가 없을 것 같은데가 적격)
    │
    ├── 리뷰 통계 갱신 (ReviewStatisticsRepository)
    ├── 다음 회상일 알림 예약 (NotificationService)          @Async
    └── 덱 전체 진행률 재계산 (DeckProgressRepository)       @Async</code></pre><p><code>Card</code> 애그리거트에 <code>AbstractAggregateRoot</code>를 적용하면<br><code>save()</code> 시점에 Spring이 이벤트를 자동 발행해준다. 서비스는 전혀 신경 쓸 필요 없다.</p>
<pre><code class="language-java">@Entity
public class Card extends AbstractAggregateRoot&lt;Card&gt; {

    // ...

    public void completeReview() {
        this.lastReviewedAt = LocalDate.now();
        this.nextReviewDate = ReviewPolicy.calculateNext(this.reviewCount++);

        // save() 호출 시 Spring이 자동 발행
        registerDomainEvent(new CardReviewedEvent(this.id, this.nextReviewDate));
    }
}</code></pre>
<pre><code class="language-java">public record CardReviewedEvent(Long cardId, LocalDate nextReviewDate) {}</code></pre>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class CardReviewedEventHandler {

    private final ReviewStatisticsRepository statsRepository;
    private final NotificationService notificationService;
    private final DeckProgressRepository deckProgressRepository;

    @TransactionalEventListener(phase = AFTER_COMMIT)
    public void updateStats(CardReviewedEvent event) {
        statsRepository.incrementReviewCount(event.cardId());
    }

    @Async
    @TransactionalEventListener(phase = AFTER_COMMIT)
    public void scheduleReminder(CardReviewedEvent event) {
        notificationService.scheduleAt(event.cardId(), event.nextReviewDate());
    }

    @Async
    @TransactionalEventListener(phase = AFTER_COMMIT)
    public void recalculateDeckProgress(CardReviewedEvent event) {
        deckProgressRepository.recalculateByCard(event.cardId());
    }
}</code></pre>
<p><code>ReviewService</code>는 이렇게 남는다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
@Transactional
public class ReviewService {

    private final CardRepository cardRepository;

    public void completeReview(Long cardId) {
        Card card = cardRepository.findById(cardId).orElseThrow();
        card.completeReview();       // 내부에서 이벤트 등록
        cardRepository.save(card);   // Spring이 자동 발행
        // 끝. 이후에 무슨 일이 일어나는지 모른다 — 알 필요도 없다 깔끔~~
    }
}</code></pre>
<p>이렇게 의존성에 대해서 함축이 가능해져버린다. </p>
<hr>
<h3 id="2-2-추후deck-라이브러리-발행--바운디드-컨텍스트-경계선">2-2. (추후)Deck 라이브러리 발행 — 바운디드 컨텍스트 경계선</h3>
<p>현재 Library 기능이 계획 중인데, Deck을 공개 마켓에 올리는 시점이 좋은 이벤트 경계가 된다.</p>
<pre><code>DeckPublishedEvent 발행
    │
    ├── Library BC: 라이브러리 목록에 등록
    ├── Search BC: 검색 인덱스 갱신               @Async
    ├── Notification: 팔로워에게 알림              @Async
    └── Stats: 발행 카운트 집계</code></pre><p>핵심은 <strong>Deck 도메인이 Library, Search, Notification을 직접 알지 않아도 된다</strong>는 점이다.<br>각 BC는 이벤트를 구독해 자기 책임만 진다.</p>
<pre><code class="language-java">// Deck 도메인 — Library가 뭔지 모른다
public class Deck extends AbstractAggregateRoot&lt;Deck&gt; {

    public void publish() {
        this.status = DeckStatus.PUBLISHED;
        this.publishedAt = LocalDateTime.now();
        registerDomainEvent(new DeckPublishedEvent(this.id, this.ownerId, this.category));
    }
}</code></pre>
<pre><code class="language-java">public record DeckPublishedEvent(Long deckId, Long ownerId, Category category) {}</code></pre>
<pre><code class="language-java">// Library BC 핸들러 — Deck 내부를 직접 참조하지 않는다
@Component
@RequiredArgsConstructor
public class DeckPublishedEventHandler {

    private final LibraryRepository libraryRepository;
    private final SearchIndexService searchIndexService;
    private final FollowerNotificationService followerNotificationService;

    @TransactionalEventListener(phase = AFTER_COMMIT)
    public void registerToLibrary(DeckPublishedEvent event) {
        libraryRepository.register(event.deckId(), event.category());
    }

    @Async
    @TransactionalEventListener(phase = AFTER_COMMIT)
    public void indexToSearch(DeckPublishedEvent event) {
        searchIndexService.index(event.deckId());
    }

    @Async
    @TransactionalEventListener(phase = AFTER_COMMIT)
    public void notifyFollowers(DeckPublishedEvent event) {
        followerNotificationService.notifyAll(event.ownerId(), event.deckId());
    }
}</code></pre>
<p>나중에 Kafka 같은 메시지 브로커를 붙일 때도<br>발행부(<code>registerDomainEvent</code> → <code>kafkaTemplate.send</code>)만 교체하면 핸들러 로직은 재사용 가능하다.</p>
<hr>
<h2 id="3-트랜잭션-의존-관계가-어떻게-달라지나">3. 트랜잭션 의존 관계가 어떻게 달라지나</h2>
<p>이벤트 도입에서 트랜잭션 설계가 가장 실질적인 이유였다.</p>
<h3 id="문제-상황--커밋-전-외부-호출">문제 상황 — 커밋 전 외부 호출</h3>
<pre><code class="language-java">@Transactional
public void completeReview(Long cardId) {
    Card card = cardRepository.findById(cardId).orElseThrow();
    card.completeReview();
    cardRepository.save(card);

    // 여기서 알림을 예약해버리면?
    notificationService.scheduleAt(cardId, card.getNextReviewDate());

    // 이후 덱 진행률 재계산에서 예외 발생
    deckProgressRepository.recalculate(card.getDeckId()); // RuntimeException!

    // → 트랜잭션 롤백 → Card 저장 취소
    // → 근데 알림은 이미 예약됨 💀
}</code></pre>
<p>리뷰 자체는 실패했는데, 외부에 이미 신호가 나갔다.<br>이걸 보정하려면 알림 취소 로직까지 같이 짜야 한다.</p>
<h3 id="해결--after_commit으로-경계-확정">해결 — <code>AFTER_COMMIT</code>으로 경계 확정</h3>
<pre><code class="language-java">@TransactionalEventListener(phase = AFTER_COMMIT)
public void scheduleReminder(CardReviewedEvent event) {
    // DB 커밋이 확정된 이후에만 실행
    // 롤백되면 이 메서드 자체가 호출되지 않음
    notificationService.scheduleAt(event.cardId(), event.nextReviewDate());
}</code></pre>
<p><code>AFTER_COMMIT</code>은 트랜잭션이 성공적으로 커밋된 뒤에만 리스너를 호출한다.<br>롤백이 일어나면 리스너 호출이 없다. 외부 서비스에 잘못된 신호가 나갈 여지가 없다.</p>
<hr>
<h3 id="-비동기-분리로-응답-시간-확보">** 비동기 분리로 응답 시간 확보</h3>
<p>Third Tool의 Review API는 사용자 응답에 포함될 필요가 없는 작업이 많다.</p>
<pre><code>completeReview() 호출
    │
    ├── [동기] 카드 저장 + 커밋            → 응답에 영향
    │
    └── [AFTER_COMMIT]
            ├── @Async 통계 갱신           → 응답에 무관
            ├── @Async 알림 예약           → 응답에 무관
            └── @Async 덱 진행률 재계산    → 응답에 무관</code></pre><pre><code class="language-java">@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void recalculateDeckProgress(CardReviewedEvent event) {
    // 이게 1초 걸려도 Review API 응답은 이미 반환됨
    deckProgressRepository.recalculateByCard(event.cardId());
}</code></pre>
<p>스레드 풀 설정은 한 곳에서만 관리한다.</p>
<pre><code class="language-java">@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix(&quot;third-tool-async-&quot;);
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -&gt;
            log.error(&quot;[AsyncEvent] handler error: method={}&quot;, method.getName(), ex);
    }
}</code></pre>
<hr>
<h2 id="4-테스트-편의성--이게-생각보다-큰-이유였다">4. 테스트 편의성 — 이게 생각보다 큰 이유였다</h2>
<p>이벤트 도입 전, <code>ReviewService</code> 단위 테스트는 이렇게 생겼다.</p>
<pre><code class="language-java">// 이벤트 없을 때
@ExtendWith(MockitoExtension.class)
class ReviewServiceTest {

    @Mock CardRepository cardRepository;
    @Mock ReviewStatisticsRepository statsRepository;   // 목 1
    @Mock NotificationService notificationService;       // 목 2
    @Mock DeckProgressRepository deckProgressRepository; // 목 3

    @InjectMocks ReviewService reviewService;

    @Test
    void 리뷰_완료시_카드_상태가_갱신된다() {
        // given
        Card card = CardFixture.create();
        given(cardRepository.findById(any())).willReturn(Optional.of(card));

        // when
        reviewService.completeReview(1L);

        // then
        assertThat(card.getNextReviewDate()).isNotNull();

        // 그런데 이것도 검증해야 하나?
        verify(statsRepository).incrementReviewCount(any());   // 이게 ReviewService 테스트 관심사인가?
        verify(notificationService).scheduleAt(any(), any());  // 이것도?
    }
}</code></pre>
<p>목이 많아질수록 <strong>&quot;이 테스트가 ReviewService를 테스트하는 건지,<br>아니면 ReviewService가 다른 서비스들을 올바르게 호출하는지를 테스트하는 건지&quot;</strong> 경계가 흐려진다.</p>
<hr>
<h3 id="이벤트-도입-후--각자의-책임만-테스트">이벤트 도입 후 — 각자의 책임만 테스트</h3>
<p><strong>ReviewService는 이벤트 발행만 검증한다.</strong></p>
<pre><code class="language-java">@ExtendWith(MockitoExtension.class)
class ReviewServiceTest {

    @Mock CardRepository cardRepository;
    @Mock ApplicationEventPublisher eventPublisher; // 목은 이것 하나

    @InjectMocks ReviewService reviewService;

    @Test
    void 리뷰_완료시_CardReviewedEvent가_발행된다() {
        // given
        Card card = CardFixture.createWithId(1L);
        given(cardRepository.findById(1L)).willReturn(Optional.of(card));

        // when
        reviewService.completeReview(1L);

        // then — ReviewService의 책임: 이벤트를 발행했는가
        assertThat(card.getNextReviewDate()).isNotNull();
        // AbstractAggregateRoot 사용 시엔 domainEvents() 로 검증
        assertThat(card.domainEvents())
            .hasSize(1)
            .first()
            .isInstanceOf(CardReviewedEvent.class);
    }
}</code></pre>
<p><strong>핸들러는 핸들러 단독으로 테스트한다.</strong></p>
<pre><code class="language-java">@ExtendWith(MockitoExtension.class)
class CardReviewedEventHandlerTest {

    @Mock ReviewStatisticsRepository statsRepository;
    @Mock NotificationService notificationService;
    @Mock DeckProgressRepository deckProgressRepository;

    @InjectMocks CardReviewedEventHandler handler;

    @Test
    void 이벤트_수신시_리뷰_통계가_갱신된다() {
        // given
        CardReviewedEvent event = new CardReviewedEvent(1L, LocalDate.now().plusDays(3));

        // when
        handler.updateStats(event);

        // then
        verify(statsRepository).incrementReviewCount(1L);
    }

    @Test
    void 이벤트_수신시_다음_회상일_알림이_예약된다() {
        // given
        LocalDate nextDate = LocalDate.now().plusDays(3);
        CardReviewedEvent event = new CardReviewedEvent(1L, nextDate);

        // when
        handler.scheduleReminder(event);

        // then
        verify(notificationService).scheduleAt(1L, nextDate);
    }
}</code></pre>
<p><strong>통합 테스트에서 실제 이벤트 흐름을 검증한다.</strong></p>
<pre><code class="language-java">@SpringBootTest
@Transactional
class ReviewEventIntegrationTest {

    @Autowired ReviewService reviewService;
    @Autowired ReviewStatisticsRepository statsRepository;

    @Test
    void 리뷰_완료_후_통계가_실제로_갱신된다() {
        // given — 실제 Card 저장
        Card card = cardRepository.save(CardFixture.create());

        // when
        reviewService.completeReview(card.getId());

        // then — 리스너가 실제로 동작했는가
        long count = statsRepository.countByCardId(card.getId());
        assertThat(count).isEqualTo(1);
    }
}</code></pre>
<hr>
<h3 id="테스트-구조가-명확해지는-이유">테스트 구조가 명확해지는 이유</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>이벤트 도입 전</th>
<th>이벤트 도입 후</th>
</tr>
</thead>
<tbody><tr>
<td>ReviewService 단위 테스트 목(Mock) 수</td>
<td>3~5개</td>
<td>1개 (EventPublisher)</td>
</tr>
<tr>
<td>핸들러 테스트</td>
<td>ReviewService와 섞여 있음</td>
<td>독립적으로 존재</td>
</tr>
<tr>
<td>새 부수 효과 추가 시</td>
<td>ReviewService 테스트 수정</td>
<td>핸들러 테스트만 추가</td>
</tr>
<tr>
<td>테스트 실패 지점</td>
<td>어디서 깨졌는지 추적 어려움</td>
<td>발행 / 핸들러 중 어디인지 즉시 특정</td>
</tr>
</tbody></table>
<hr>
<h2 id="5-정리">5. 정리</h2>
<p>Third Tool에 이벤트를 도입하면서 얻은 것을 한 줄씩 정리하면 이렇다.</p>
<p><strong>의존성 측면:</strong><br><code>ReviewService</code>는 리뷰라는 행위 하나에만 집중한다.<br>통계, 알림, 덱 진행률은 각자의 핸들러가 책임진다. 새 기능이 생겨도 서비스는 안 건드린다.</p>
<p><strong>트랜잭션 측면:</strong><br><code>AFTER_COMMIT</code>으로 DB 커밋이 확정된 뒤에만 외부 효과가 발생한다.<br>롤백이 일어나면 리스너 자체가 실행되지 않아, 상태 불일치 가능성이 구조적으로 차단된다.</p>
<p><strong>테스트 측면:</strong><br>발행자는 &quot;이벤트를 올바르게 발행했는가&quot;만 검증한다.<br>리스너는 &quot;이벤트를 받았을 때 올바르게 처리했는가&quot;만 검증한다.<br>책임이 분리되니 테스트 범위도 명확해진다.</p>
<hr>
<blockquote>
<p>최종 수정일: 2026-03-26</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA 낙관적 락 vs 비관적 락 — 트레이드오프와 선택 기준 ]]></title>
            <link>https://velog.io/@js-kim-arc/JPA-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-vs-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84%EC%99%80-%EC%84%A0%ED%83%9D-%EA%B8%B0%EC%A4%80</link>
            <guid>https://velog.io/@js-kim-arc/JPA-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-vs-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84%EC%99%80-%EC%84%A0%ED%83%9D-%EA%B8%B0%EC%A4%80</guid>
            <pubDate>Wed, 25 Mar 2026 11:05:06 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/c0059a79-1477-4719-9031-c357422aaadc/image.png" alt=""></p>
<blockquote>
<p>Ref) youtube 쉬운코드 </p>
</blockquote>
<h1 id="참고-락-관련-개념-도입시-참고하려고-만든-til-입니다">참고) 락 관련 개념 도입시 참고하려고 만든 til 입니다!</h1>
<h2 id="목차">목차</h2>
<ul>
<li><a href="#jpa-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-vs-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD--%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84%EC%99%80-%EC%84%A0%ED%83%9D-%EA%B8%B0%EC%A4%80">JPA 낙관적 락 vs 비관적 락 — 트레이드오프와 선택 기준</a><ul>
<li><a href="#%EC%99%9C-%EB%9D%BD%EC%9D%B4-%ED%95%84%EC%9A%94%ED%95%9C%EA%B0%80--lost-update-%EB%AC%B8%EC%A0%9C">왜 락이 필요한가? — Lost Update 문제</a></li>
<li><a href="#%ED%95%9C%EB%88%88%EC%97%90-%EB%B9%84%EA%B5%90">한눈에 비교</a></li>
<li><a href="#%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-optimistic-lock">낙관적 락 (Optimistic Lock)</a><ul>
<li><a href="#%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC">동작 원리</a></li>
<li><a href="#%EA%B5%AC%ED%98%84%EC%8B%A4%EC%A0%9C-%EC%82%AC%EC%9A%A9-%EC%98%88%EC%8B%9C_-spring">구현(실제 사용 예시_ spring)</a></li>
<li><a href="#%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD%EC%9D%98-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84">낙관적 락의 트레이드오프</a><ul>
<li><a href="#%EC%9D%B4%EB%9F%B4-%EB%95%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EC%84%B8%EC%9A%94">이럴 때 사용하세요</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-pessimistic-lock">비관적 락 (Pessimistic Lock)</a><ul>
<li><a href="#%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC-1">동작 원리</a></li>
<li><a href="#%EA%B5%AC%ED%98%84">구현</a></li>
<li><a href="#%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EC%9D%98-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84">비관적 락의 트레이드오프</a><ul>
<li><a href="#%EC%9D%B4%EB%9F%B4-%EB%95%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EC%84%B8%EC%9A%94-1">이럴 때 사용하세요</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#lockmodetype-%EC%A0%84%EC%B2%B4-%EC%A0%95%EB%A6%AC">LockModeType 전체 정리</a></li>
<li><a href="#%EC%84%A0%ED%83%9D-%EA%B8%B0%EC%A4%80-%EC%A0%95%EB%A6%AC">선택 기준 정리</a></li>
<li><a href="#%EB%A7%88%EC%B9%98%EB%A9%B0">마치며</a></li>
</ul>
</li>
</ul>
<h1 id="jpa-낙관적-락-vs-비관적-락--트레이드오프와-선택-기준">JPA 낙관적 락 vs 비관적 락 — 트레이드오프와 선택 기준</h1>
<blockquote>
<p>두 방식 모두 &quot;데이터 정합성&quot;을 지키기 위한 수단이지만, 접근 방식이 정반대입니다.<br>어떤 상황에서 무엇을 써야 할지, 각각의 트레이드오프를 중심으로 정리해두겠습니다.</p>
</blockquote>
<hr>
<h2 id="왜-락이-필요한가--lost-update-문제">왜 락이 필요한가? — Lost Update 문제</h2>
<p>(생각보다 rdbms에서 관리가 확실하게하기가 귀찮은 것 같다... mysql locking read를 신경쓰면서 걸어줘야하는 것 같다.)</p>
<p>동시에 두 트랜잭션이 같은 행을 읽고, 각자 계산한 뒤 쓰면 어떻게 될까요?</p>
<pre><code>T1: SELECT stock = 100         T2: SELECT stock = 100
T1: 100 - 10 = 90              T2: 100 - 30 = 70
T1: UPDATE stock = 90  ✓
                               T2: UPDATE stock = 70  ← T1의 변경이 사라짐!

❌ 결과: stock = 70  (정상: 60이어야 함, 재고 10개 허공으로 사라짐)</code></pre><p>나중에 쓴 트랜잭션이 이전 변경을 덮어쓰는 <strong>Lost Update</strong> 문제입니다.<br>이걸 방지하는 두 가지 전략이 바로 낙관적 락과 비관적 락입니다.</p>
<hr>
<h2 id="한눈에-비교">한눈에 비교</h2>
<table>
<thead>
<tr>
<th>기준</th>
<th>낙관적 락</th>
<th>비관적 락</th>
</tr>
</thead>
<tbody><tr>
<td>핵심 메커니즘</td>
<td><code>@Version</code> 컬럼으로 충돌 감지</td>
<td><code>SELECT FOR UPDATE</code>로 행 잠금</td>
</tr>
<tr>
<td>충돌이 드물 때</td>
<td>✅ 유리</td>
<td>❌ 불필요한 대기</td>
</tr>
<tr>
<td>충돌이 잦을 때</td>
<td>❌ 재시도 폭발</td>
<td>✅ 유리</td>
</tr>
<tr>
<td>DB 커넥션 점유</td>
<td>없음</td>
<td>대기 동안 계속 점유</td>
</tr>
<tr>
<td>실패 처리</td>
<td><code>OptimisticLockException</code> → 재시도</td>
<td>대기 후 최신 데이터로 자동 처리</td>
</tr>
<tr>
<td>분산 환경</td>
<td>✅ 적합</td>
<td>⚠️ DB 레벨 의존, 주의 필요</td>
</tr>
<tr>
<td>대표 use case</td>
<td>게시글 수정, 사용자 프로필</td>
<td>재고 차감, 포인트/잔고</td>
</tr>
</tbody></table>
<hr>
<h2 id="낙관적-락-optimistic-lock">낙관적 락 (Optimistic Lock)</h2>
<h3 id="동작-원리">동작 원리</h3>
<p>&quot;충돌이 거의 없을 것&quot;이라고 <strong>낙관</strong>하고, 일단 자유롭게 읽고 씁니다.<br>커밋 직전에 <code>version</code> 컬럼으로 &quot;내가 읽었을 때와 지금이 같은가?&quot;를 검사합니다.</p>
<pre><code>T1: SELECT stock=100, version=1
T2: SELECT stock=100, version=1   ← 동시에 읽어도 OK

T1: UPDATE SET stock=90, version=2 WHERE id=1 AND version=1  → ✅ 성공
T2: UPDATE SET stock=70, version=2 WHERE id=1 AND version=1
    → 이미 version=2! → 영향 row=0 → OptimisticLockException!</code></pre><h3 id="구현실제-사용-예시_-spring">구현(실제 사용 예시_ spring)</h3>
<p><strong>1. 엔티티에 <code>@Version</code> 추가</strong></p>
<pre><code class="language-java">@Entity
public class Item {

    @Id @GeneratedValue
    private Long id;

    private String name;
    private int stock;

    @Version  // JPA가 UPDATE 시 자동으로 version 조건 추가, 성공마다 +1
    private Long version;

    public void decreaseStock(int quantity) {
        if (this.stock &lt; quantity) throw new IllegalStateException(&quot;재고 부족&quot;);
        this.stock -= quantity;
    }
}</code></pre>
<p><strong>2. 서비스 — 기본 사용</strong></p>
<p><code>@Version</code>만 붙이면 JPA가 알아서 처리합니다. 별도 쿼리 없이도 동작합니다.</p>
<pre><code class="language-java">@Transactional
public void purchase(Long itemId, int quantity) {
    Item item = itemRepository.findById(itemId).orElseThrow();
    item.decreaseStock(quantity);
    // 커밋 시점에 자동으로:
    // UPDATE item SET stock=?, version=2 WHERE id=? AND version=1
    // → 영향 row=0이면 OptimisticLockException 발생
}</code></pre>
<p><strong>3. <code>@Retryable</code>로 재시도 처리</strong></p>
<p>충돌 발생 시 예외를 잡아 재시도하는 게 핵심입니다.</p>
<pre><code class="language-java">@Transactional
@Retryable(
    retryFor = OptimisticLockingFailureException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 100, multiplier = 2)  // 100ms → 200ms → 400ms
)
public void purchaseWithRetry(Long itemId, int quantity) {
    Item item = itemRepository.findById(itemId).orElseThrow();
    item.decreaseStock(quantity);
}

@Recover
public void recover(OptimisticLockingFailureException e, Long itemId, int quantity) {
    log.error(&quot;재고 감소 최종 실패 itemId={}&quot;, itemId);
    throw new BusinessException(&quot;일시적 오류, 잠시 후 재시도해주세요&quot;);
}</code></pre>
<p><strong>4. Repository — 명시적 LockModeType</strong></p>
<pre><code class="language-java">public interface ItemRepository extends JpaRepository&lt;Item, Long&gt; {

    // OPTIMISTIC: 조회 시 version 확인 + 커밋 시 재확인 (기본값)
    @Lock(LockModeType.OPTIMISTIC)
    @Query(&quot;SELECT i FROM Item i WHERE i.id = :id&quot;)
    Optional&lt;Item&gt; findByIdOptimistic(@Param(&quot;id&quot;) Long id);

    // OPTIMISTIC_FORCE_INCREMENT: 읽기만 해도 version 증가
    // → 연관 엔티티 변경 시 부모 version도 올리고 싶을 때 사용
    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    @Query(&quot;SELECT i FROM Item i WHERE i.id = :id&quot;)
    Optional&lt;Item&gt; findByIdForUpdate(@Param(&quot;id&quot;) Long id);
}</code></pre>
<hr>
<h3 id="낙관적-락의-트레이드오프">낙관적 락의 트레이드오프</h3>
<h4 id="✅-장점">✅ 장점</h4>
<ul>
<li><strong>락 오버헤드 없음</strong> — DB 행을 잠그지 않으므로 읽기 성능이 뛰어납니다. (사실상 규칙으로 락을 만드는 느낌) </li>
<li><strong>분산 환경 친화적</strong> — DB 레벨 락에 의존하지 않아 멀티 인스턴스에서도 안전합니다</li>
<li><strong>DB 커넥션 효율</strong> — 대기 중 커넥션을 점유하지 않아 처리량이 높습니다</li>
</ul>
<h4 id="❌-단점">❌ 단점</h4>
<ul>
<li><strong>충돌 시 재시도 비용</strong> — 충돌이 잦으면 재시도가 폭발적으로 늘어납니다 (충돌이 많을지 판단이 상당히 중요할 것 같다.) </li>
<li><strong>재시도 로직 필요</strong> — 개발자가 직접 <code>@Retryable</code>이나 재시도 로직을 구현해야 합니다</li>
<li><strong>충돌 빈도 예측 필요</strong> — 트래픽 패턴을 모르면 잘못된 선택이 될 수 있습니다</li>
</ul>
<h4 id="👉-이럴-때-사용하세요">👉 이럴 때 사용하세요</h4>
<blockquote>
<p><strong>읽기가 많고 쓰기가 드문 경우</strong>, 혹은 <strong>같은 데이터를 동시에 수정할 가능성이 낮은 경우</strong></p>
</blockquote>
<ul>
<li>게시글/댓글 수정 (같은 글을 두 명이 동시에 편집하는 경우가 드뭄)</li>
<li>사용자 프로필 업데이트</li>
<li>설정 값 변경</li>
<li>분산 서버 환경에서 정합성이 필요한 모든 곳</li>
</ul>
<hr>
<h2 id="비관적-락-pessimistic-lock">비관적 락 (Pessimistic Lock)</h2>
<h3 id="동작-원리-1">동작 원리</h3>
<p>&quot;충돌이 생길 것&quot;이라고 <strong>비관</strong>하고, 처음부터 행(row)을 잠급니다.<br>다른 트랜잭션은 락이 해제될 때까지 대기하고, 해제 후 최신 데이터를 읽어 처리합니다.</p>
<pre><code>T1: SELECT stock FROM item FOR UPDATE  → 행 락 획득
T2: SELECT stock FROM item FOR UPDATE  → 🔒 블로킹! T1 완료까지 대기

T1: 100 - 10 = 90, UPDATE, COMMIT → 락 해제
T2: 최신 stock=90 읽기 → 90 - 30 = 60, UPDATE, COMMIT

✅ 결과: stock = 60  (정합성 완벽 보장!)</code></pre><h3 id="구현">구현</h3>
<p><strong>1. Repository — <code>PESSIMISTIC_WRITE</code> 설정</strong></p>
<pre><code class="language-java">public interface ItemRepository extends JpaRepository&lt;Item, Long&gt; {

    // PESSIMISTIC_WRITE: SELECT ... FOR UPDATE
    // → 다른 트랜잭션의 SELECT FOR UPDATE, UPDATE, DELETE 모두 차단
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(&quot;SELECT i FROM Item i WHERE i.id = :id&quot;)
    Optional&lt;Item&gt; findByIdWithPessimisticLock(@Param(&quot;id&quot;) Long id);

    // PESSIMISTIC_READ: SELECT ... FOR SHARE
    // → 쓰기만 차단, 일반 읽기는 허용
    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query(&quot;SELECT i FROM Item i WHERE i.id = :id&quot;)
    Optional&lt;Item&gt; findByIdForRead(@Param(&quot;id&quot;) Long id);
}</code></pre>
<p><strong>2. 서비스 — 기본 사용</strong></p>
<pre><code class="language-java">@Transactional
public void purchase(Long itemId, int quantity) {
    // SELECT ... FOR UPDATE 발행, 다른 트랜잭션은 여기서 대기
    Item item = itemRepository.findByIdWithPessimisticLock(itemId)
        .orElseThrow(() -&gt; new EntityNotFoundException(&quot;상품 없음&quot;));

    item.decreaseStock(quantity);
    // 커밋 시 락 해제 → 대기 중이던 트랜잭션이 최신 데이터로 진행
}</code></pre>
<p><strong>3. 락 타임아웃 설정 — 데드락 방지에 필수</strong></p>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
    @QueryHint(name = &quot;jakarta.persistence.lock.timeout&quot;, value = &quot;3000&quot;) // 3초
})
@Query(&quot;SELECT i FROM Item i WHERE i.id = :id&quot;)
Optional&lt;Item&gt; findByIdWithPessimisticLock(@Param(&quot;id&quot;) Long id);</code></pre>
<pre><code class="language-yaml"># application.yml — 전역 설정
spring:
  jpa:
    properties:
      jakarta.persistence.lock.timeout: 3000</code></pre>
<p><strong>4. 데드락 방지 — 락 순서 일관성 유지(데드락 처리 로직도 중요한것 같다.)</strong></p>
<p>여러 행을 동시에 락잡아야 한다면, 항상 같은 순서로 잠가야 데드락을 예방할 수 있습니다.</p>
<pre><code class="language-java">// ❌ BAD: T1이 A→B, T2가 B→A 순으로 락을 잡으면 데드락 발생!

// ✅ GOOD: 항상 id 오름차순으로 락
@Transactional
public void transfer(Long fromId, Long toId, int amount) {
    Long firstId  = Math.min(fromId, toId);  // 항상 작은 id 먼저
    Long secondId = Math.max(fromId, toId);

    Item first  = itemRepository.findByIdWithPessimisticLock(firstId).orElseThrow();
    Item second = itemRepository.findByIdWithPessimisticLock(secondId).orElseThrow();
    // ...
}</code></pre>
<hr>
<h3 id="비관적-락의-트레이드오프">비관적 락의 트레이드오프</h3>
<h4 id="✅-장점-1">✅ 장점</h4>
<ul>
<li><strong>강력한 정합성 보장</strong> — 충돌이 아예 발생하지 않고, 재시도 로직 불필요</li>
<li><strong>구현 단순</strong> — 예외 처리나 재시도 없이 <code>FOR UPDATE</code> 한 줄로 해결</li>
<li><strong>순서 보장</strong> — 요청이 들어온 순서대로 처리 (FIFO)</li>
</ul>
<h4 id="❌-단점-1">❌ 단점</h4>
<ul>
<li><strong>처리량 저하</strong> — 락 대기로 인해 동시 처리 능력이 떨어집니다</li>
<li><strong>DB 커넥션 점유</strong> — 대기 중에도 커넥션이 살아 있어, 커넥션 풀 고갈 위험</li>
<li><strong>데드락 가능성</strong> — 여러 행을 잠글 때 순서를 잘못 잡으면 데드락 발생</li>
<li><strong>분산 환경 주의</strong> — DB 레벨 락이므로 DB가 단일 장애점이 될 수 있음</li>
<li><strong>트랜잭션을 짧게 유지해야 함</strong> — 락 시간이 길수록 병목이 심해짐</li>
</ul>
<h4 id="👉-이럴-때-사용하세요-1">👉 이럴 때 사용하세요</h4>
<blockquote>
<p><strong>충돌이 빈번하고 데이터 정합성이 절대적으로 중요한 경우</strong></p>
</blockquote>
<ul>
<li>재고 차감 (한 상품을 여러 사람이 동시에 구매)</li>
<li>포인트/잔고 증감 (동시 결제, 동시 환불)</li>
<li>좌석 예약 (콘서트 티켓, 항공편 좌석)</li>
<li>쿠폰 발급 (선착순 N명 제한)</li>
</ul>
<hr>
<h2 id="lockmodetype-전체-정리">LockModeType 전체 정리</h2>
<table>
<thead>
<tr>
<th>LockModeType</th>
<th>SQL</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><code>OPTIMISTIC</code></td>
<td>(없음, version 체크만)</td>
<td>낙관적 기본값</td>
</tr>
<tr>
<td><code>OPTIMISTIC_FORCE_INCREMENT</code></td>
<td>(없음, version 강제 증가)</td>
<td>연관 엔티티 변경 시 부모 version 보호</td>
</tr>
<tr>
<td><code>PESSIMISTIC_READ</code></td>
<td><code>SELECT ... FOR SHARE</code></td>
<td>쓰기 차단, 읽기 허용</td>
</tr>
<tr>
<td><code>PESSIMISTIC_WRITE</code></td>
<td><code>SELECT ... FOR UPDATE</code></td>
<td>읽기/쓰기 모두 차단</td>
</tr>
<tr>
<td><code>PESSIMISTIC_FORCE_INCREMENT</code></td>
<td><code>SELECT ... FOR UPDATE</code> + version++</td>
<td>비관적 + version 증가 조합</td>
</tr>
</tbody></table>
<hr>
<h2 id="선택-기준-정리">선택 기준 정리</h2>
<p>사실 두 방식 중 하나가 항상 옳은 건 아닙니다.<br><strong>트래픽 패턴</strong>과 <strong>비즈니스의 민감도</strong>에 따라 달라집니다.</p>
<pre><code>충돌이 드문가?
  ├─ YES → 낙관적 락 (@Version + @Retryable)
  │         읽기 성능 우수, 분산 환경 적합
  │         단, 재시도 로직 구현 필수
  │
  └─ NO  → 충돌이 잦은가? (재고, 포인트, 잔고)
              └─ YES → 비관적 락 (PESSIMISTIC_WRITE)
                        강력한 정합성, 단순한 로직
                        단, 트랜잭션은 짧게 유지할 것!</code></pre><h2 id="마치며">마치며</h2>
<p>두 방식을 한 문장으로 요약하자면:</p>
<ul>
<li><strong>낙관적 락</strong> → &quot;일단 하고 나중에 충돌 확인&quot;</li>
<li><strong>비관적 락</strong> → &quot;충돌할 것 같으니 처음부터 잠금&quot;</li>
</ul>
<p>실무에서는 두 방식을 혼용하는 경우도 많습니다.<br>예를 들어, 주문 생성은 비관적 락으로 재고를 보호하고, 주문 상세 정보 수정은 낙관적 락으로 처리하는 식입니다.</p>
<p>무엇을 쓸지 고민된다면 우선 <strong>충돌 빈도</strong>를 먼저 따져보세요.<br>그게 선택의 출발점입니다.</p>
<blockquote>
<p>최종 수정일: 2026-03-25</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[트러블슈팅- 내일배움캠프 Calculator 미션 ]]></title>
            <link>https://velog.io/@js-kim-arc/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Calculator-%EB%AF%B8%EC%85%98</link>
            <guid>https://velog.io/@js-kim-arc/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Calculator-%EB%AF%B8%EC%85%98</guid>
            <pubDate>Tue, 24 Mar 2026 10:19:34 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/dd5b1e51-5e2f-4320-89f1-35c2b8a24827/image.png" alt=""></p>
<h1 id="트러블슈팅--내일배움캠프-calculator-미션">트러블슈팅- 내일배움캠프 Calculator 미션</h1>
<hr>
<h2 id="1-main에서-calculator를-객체로-뽑아내는-과정에서-app이-여전히-직접-연산하고-있었다-객체-extract-리팩토링의-숙련도-이슈">#1. main()에서 Calculator를 객체로 뽑아내는 과정에서 App이 여전히 직접 연산하고 있었다 (객체 Extract 리팩토링의 숙련도 이슈)</h2>
<h3 id="문제-상황">문제 상황</h3>
<p><code>Calculator</code> 클래스를 만들고 <code>App</code>에서 인스턴스를 생성했는데, 막상 <code>main()</code>을 보니 연산 로직이 <code>Calculator</code>가 아닌 <code>App</code>에 그대로 남아 있었다.
(연산의 책임은 Calculator의 객체가 지고 io의 책임은 App이 지고 있는 상황)</p>
<pre><code class="language-java">// App.java — Calculator를 만들었지만 연산은 여전히 App이 직접 하고 있음
Calculator calculator = new Calculator();  // 인스턴스는 만들었지만...

switch (operator) {                        // ❌ 연산은 App이 직접 수행
    case &#39;+&#39;: result = first + second; break;
    case &#39;-&#39;: result = first - second; break;
    // ...
}

calculator.getResults();  // 결과 조회만 Calculator에서 가져옴</code></pre>
<p><code>Calculator</code> 인스턴스를 생성하긴 했는데, <code>App</code>이 <code>switch</code>로 연산을 직접 수행하고 <code>calculator</code>는 결과를 꺼낼 때만 쓰이고 있었다. &quot;객체를 만들었다&quot;와 &quot;객체에게 책임을 위임했다&quot;가 다르다는 걸 코드를 실제로 보기 전까지 인식하지 못했다.</p>
<h3 id="원인-분석">원인 분석</h3>
<p>객체를 생성하는 것과 객체에게 일을 시키는 것은 다른 행동이다. 절차적으로 작성된 <code>main()</code>의 <code>switch</code> 블록을 그대로 둔 채 <code>Calculator</code>만 옆에 선언한 셈이었다. <code>App</code>이 연산 방법을 알고 있는 한, <code>Calculator</code>는 단순한 결과 저장소에 불과하다.</p>
<p>핵심은 <strong>&quot;App이 연산 방법을 알아야 하는가?&quot;</strong> 라는 질문이다. <code>App</code>이 <code>+</code>일 때 더하고 <code>-</code>일 때 빼는 방법을 직접 알고 있다면, <code>Calculator</code> 클래스가 생겨도 책임은 분리되지 않은 것이다.</p>
<h3 id="해결-과정">해결 과정</h3>
<p><code>switch</code> 블록 전체를 <code>Calculator.calculate()</code> 안으로 이동시켰다. <code>App</code>은 두 수와 연산자를 <code>calculate()</code>에 넘기고, 결과만 돌려받는다. (메시지 기반으로만 객체들이 상호작용할 수 있는 context를 만들자)</p>
<p><strong>이동 전 — App이 연산 방법을 알고 있음</strong></p>
<pre><code class="language-java">// App.java
int result = 0;
switch (operator) {
    case &#39;+&#39;: result = first + second; break;
    case &#39;-&#39;: result = first - second; break;
    case &#39;*&#39;: result = first * second; break;
    case &#39;/&#39;:
        if (second == 0) { ... }
        result = first / second; break;
}
calculator.results.add(result);  // 결과를 직접 저장소에 밀어넣음</code></pre>
<p><strong>이동 후 — App은 위임만 한다</strong></p>
<pre><code class="language-java">// App.java
int result = calculator.calculate(first, second, operator);  // ✅ 방법은 모른다, 결과만 받는다

// Calculator.java — 연산 방법은 여기서만 안다
public int calculate(int num1, int num2, OperatorType operator) {
    int result = (int) operator.apply(num1, num2);
    results.add(result);  // 저장도 내부에서 스스로
    return result;
}</code></pre>
<p>추가로 <code>results</code> 필드에 <code>private</code>을 붙이면서, <code>App</code>이 <code>calculator.results.add()</code>로 직접 저장소를 건드리던 길도 막았다. 외부에서 직접 접근하는 경로를 차단하자, 자연스럽게 <code>calculate()</code> 안에서 저장까지 책임지는 구조가 완성됐다.</p>
<h3 id="배운-것">배운 것</h3>
<blockquote>
<ul>
<li>객체를 생성하는 것과 객체에게 책임을 위임하는 것은 다르다.</li>
<li><code>new Calculator()</code>는 시작일 뿐이고, <code>App</code>이 연산 방법을 모르게 되는 순간이 진짜 분리가 완성된 시점이다.</li>
<li><code>private</code> 캡슐화는 &quot;외부 접근을 막는 규칙&quot;이 아니라, 외부가 내부 방법을 알아야 할 이유를 없애는 장치다.</li>
<li>결국 객체는 역할과 행동이다.</li>
</ul>
</blockquote>
<hr>
<h2 id="2-기능을-수정할-때-어디까지-건드려야-하는지-몰라-여러-파일을-동시에-뜯었다">#2. 기능을 수정할 때 어디까지 건드려야 하는지 몰라 여러 파일을 동시에 뜯었다</h2>
<h3 id="문제-상황-1">문제 상황</h3>
<p>제네릭을 적용하면서 <code>int → T</code>로 타입을 바꾸는 작업을 시작했는데, 어디서 시작해서 어디서 끝내야 하는지 감이 없었다.
<code>ArithmeticCalculator</code>를 수정했더니 <code>OperatorType</code>도 바꿔야 했고, <code>OperatorType</code>을 바꿨더니 <code>App</code>도 바꿔야 했다.
결국 세 파일을 동시에 열어놓고 이곳저곳을 동시에 수정하다가, 어느 시점에 무엇이 깨진 건지 파악이 안 되는 상황이 됐다.</p>
<pre><code>ArithmeticCalculator&lt;T&gt; 수정
    → OperatorType.apply() 시그니처 수정
        → App의 입력 처리 수정
            → 다시 ArithmeticCalculator로 돌아와서 추가 수정...</code></pre><p>변경이 변경을 낳고, 수정이 수정을 낳는 연쇄가 끝나지 않았다. </p>
<h3 id="원인-분석-1">원인 분석</h3>
<p>문제의 본질은 <strong>변경의 경계(Boundary)를 생각하지 않고 코드를 수정했기 때문</strong>이다.</p>
<p>클래스를 나눠놨다고 해서 자동으로 변경이 격리되는 게 아니다.
클래스 사이에 &quot;이 변경은 여기까지만 영향을 준다&quot;는 경계가 설계되어 있어야 한다.</p>
<p>이번 경우 경계가 무너진 지점은 <code>OperatorType.apply()</code>의 시그니처였다.
<code>apply(int, int)</code>였을 때 <code>ArithmeticCalculator</code>가 <code>int</code>로 넘기고 있었기 때문에,
<code>ArithmeticCalculator</code>가 <code>T</code>를 쓰게 되면 <code>apply()</code>도 같이 바꿔야 했다.
즉, <strong>두 클래스가 타입(<code>int</code>)을 공유하고 있었고, 그 타입이 변경의 파급 경로가 됐다.</strong></p>
<h3 id="해결-과정-1">해결 과정</h3>
<p>해결의 실마리는 <strong>&quot;누가 타입 변환 책임을 져야 하는가?&quot;</strong> 라는 질문이었다.</p>
<p><code>OperatorType</code>은 연산 행위만 담당하면 된다. 피연산자가 <code>Integer</code>인지 <code>Double</code>인지 알 필요가 없다.
반면 <code>ArithmeticCalculator</code>는 <code>T</code>를 받아서 처리하는 쪽이니, <code>T → double</code> 변환은 여기서 책임지는 게 맞다.</p>
<pre><code class="language-java">// ArithmeticCalculator — T를 double로 변환하는 책임을 여기서 진다
public double calculate(T num1, T num2, OperatorType operator) {
    double result = operator.apply(num1.doubleValue(), num2.doubleValue());
    results.add((T) /* 처리 */);
    return result;
}

// OperatorType — double만 알면 된다. T를 전혀 모른다
public abstract double apply(double num1, double num2);</code></pre>
<p><code>OperatorType</code>이 <code>double</code>만 받도록 고정하자, <code>ArithmeticCalculator</code>에서 아무리 <code>T</code>를 바꿔도 <code>OperatorType</code>은 건드릴 필요가 없어졌다.
<strong>변경이 경계 안에서 멈췄다.</strong>
이 과정에서 <strong>바운더리 컨텍스트(Boundary Context)</strong> 라는 개념이 자연스럽게 체감됐다.</p>
<h3 id="배운-것-1">배운 것</h3>
<blockquote>
<p>유지보수성은 &quot;클래스를 나누는 것&quot;이 아니라 &quot;변경이 어디서 멈추는가&quot;로 결정된다.
변경이 여러 파일로 번진다면, 클래스 사이 어딘가에 타입이나 로직이 공유되고 있다는 신호다.
&quot;이 수정이 몇 개의 파일에 영향을 주는가&quot;를 먼저 생각하면, 경계를 어디에 그어야 하는지 보이기 시작한다.</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[ [third tool] ADR-014 아키텍처 트레이드오프 -- 왜 Repository를 4개로 쪼갰는가 ]]></title>
            <link>https://velog.io/@js-kim-arc/third-tool-ADR-014-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84-%EC%99%9C-Repository%EB%A5%BC-4%EA%B0%9C%EB%A1%9C-%EC%AA%BC%EA%B0%B0%EB%8A%94%EA%B0%80</link>
            <guid>https://velog.io/@js-kim-arc/third-tool-ADR-014-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84-%EC%99%9C-Repository%EB%A5%BC-4%EA%B0%9C%EB%A1%9C-%EC%AA%BC%EA%B0%B0%EB%8A%94%EA%B0%80</guid>
            <pubDate>Mon, 23 Mar 2026 10:31:27 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/33896fdd-af21-490f-80f8-8f5b5fca0cb1/image.png" alt=""></p>
<h2 id="목차">목차</h2>
<ul>
<li><a href="#%EB%B0%B0%EA%B2%BD">배경</a></li>
<li><a href="#%EC%9D%B4%EC%A0%84-%EB%B0%A9%EC%8B%9D--service%EA%B0%80-jparepository%EB%A5%BC-%EC%A7%81%EC%A0%91-%EC%A3%BC%EC%9E%85">이전 방식 — Service가 JpaRepository를 직접 주입</a><ul>
<li><a href="#%EB%AC%B8%EC%A0%9C-1-service%EA%B0%80-jpa-%EA%B5%AC%ED%98%84-%EC%84%B8%EB%B6%80%EC%82%AC%ED%95%AD%EC%97%90-%EC%A7%81%EC%A0%91-%EC%9D%98%EC%A1%B4">문제 1. Service가 JPA 구현 세부사항에 직접 의존</a></li>
<li><a href="#%EB%AC%B8%EC%A0%9C-2-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-db%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%B4%EC%A7%90">문제 2. 단위 테스트에서 DB가 필요해짐</a></li>
<li><a href="#%EB%AC%B8%EC%A0%9C-3-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EC%A0%84%ED%99%98-%EC%8B%9C-jpa-%EC%9D%98%EC%A1%B4%EC%84%B1%EC%9D%B4-%EC%83%81%EC%9C%84-%EB%A0%88%EC%9D%B4%EC%96%B4%EB%A1%9C-%EC%B9%A8%ED%88%AC">문제 3. 멀티 모듈 전환 시 JPA 의존성이 상위 레이어로 침투</a></li>
</ul>
</li>
<li><a href="#%EC%9D%B4%ED%9B%84-%EB%B0%A9%EC%8B%9D--%ED%8F%AC%ED%8A%B8-%EC%96%B4%EB%8C%91%ED%84%B0-%EA%B5%AC%EC%A1%B0">이후 방식 — 포트-어댑터 구조</a><ul>
<li><a href="#%EC%A0%84%EC%B2%B4-%EC%97%B0%EA%B2%B0-%EA%B5%AC%EB%8F%84">전체 연결 구도</a></li>
<li><a href="#cardrepository--%ED%8F%AC%ED%8A%B8-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4">CardRepository — 포트 인터페이스</a></li>
<li><a href="#cardrepositoryadapter--%EC%96%B4%EB%8C%91%ED%84%B0">CardRepositoryAdapter — 어댑터</a></li>
<li><a href="#cardrepositorycustom--cardjparepositoryimpl--querydsl-%EC%97%B0%EA%B2%B0">CardRepositoryCustom + CardJpaRepositoryImpl — QueryDSL 연결</a></li>
</ul>
</li>
<li><a href="#%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84-%EC%A0%95%EB%A6%AC">트레이드오프 정리</a><ul>
<li><a href="#%EC%96%B8%EC%A0%9C-%ED%8F%AC%ED%8A%B8-%EC%96%B4%EB%8C%91%ED%84%B0%EA%B0%80-%EC%9C%A0%ED%9A%A8%ED%95%9C%EA%B0%80">언제 포트-어댑터가 유효한가</a></li>
<li><a href="#%EC%96%B8%EC%A0%9C-%EC%A7%81%EC%A0%91-%EC%A3%BC%EC%9E%85%EC%9D%B4-%EB%8D%94-%EB%82%98%EC%9D%80%EA%B0%80">언제 직접 주입이 더 나은가</a></li>
</ul>
</li>
<li><a href="#%EA%B2%B0%EB%A1%A0">결론</a></li>
</ul>
<h2 id="왜-repository를-4개로-쪼갰는가--adr-014-아키텍처-트레이드오프-회고">왜 Repository를 4개로 쪼갰는가 — ADR-014 아키텍처 트레이드오프 회고</h2>
<blockquote>
<p>ThirdTool 프로젝트에서 <code>CardRepository</code>, <code>CardRepositoryAdapter</code>, <code>CardRepositoryCustom</code>, <code>CardJpaRepositoryImpl</code> 4계층 구조를 채택한 배경과, 단순한 <code>JpaRepository</code> 직접 주입 방식과의 트레이드오프를 정리한 글입니다.</p>
</blockquote>
<hr>
<h2 id="배경">배경</h2>
<p>ThirdTool은 스페이스드 리피티션(Spaced Repetition) 학습 앱으로, 
Card 도메인이 서비스 핵심입니다. 초기에는 <code>CardJpaRepository</code>를 Service에서 직접 주입해서 사용하는 방식이 직관적이었습니다. 
그런데 QueryDSL 동적 쿼리가 들어오고, 멀티 모듈 전환 가능성이 생기면서 구조 안정성의 의문이 생겼습니다.</p>
<p>결론적으로 채택한 구조는 아래 4계층입니다.</p>
<pre><code>CardRepository          ← 의존성 역전용 포트 인터페이스
CardRepositoryAdapter   ← CardRepository 구현체, 실제 JPA 호출 담당
CardRepositoryCustom    ← QueryDSL 동적 쿼리 연결 지점
CardJpaRepositoryImpl   ← CardRepositoryCustom 구현체</code></pre><p>이 선택이 어떤 문제를 풀고, 어떤 비용을 지불하는지 트레이드오프 관점에서 돌아봅니다.</p>
<hr>
<h2 id="이전-방식--service가-jparepository를-직접-주입">이전 방식 — Service가 JpaRepository를 직접 주입</h2>
<pre><code class="language-java">@Service
public class CardQueryService {
    private final CardJpaRepository cardJpaRepository; // JPA 직접 주입

    public Card getActiveCard(Long cardId) {
        return cardJpaRepository.findByIdWithKeywords(cardId); // JPA 메서드 직접 호출
    }
}</code></pre>
<p>이 방식의 강점은 단순함입니다. (이게 처음엔 정석인줄 알았는데....ㅎ)</p>
<ul>
<li>파일이 적고, 연결 고리가 눈에 바로 보입니다.</li>
<li>Spring Data JPA의 자동 구현 덕에 <code>findByXxx()</code> 네이밍만으로 쿼리가 만들어집니다.</li>
<li>초기 개발 속도가 빠릅니다.</li>
</ul>
<p>그런데 프로젝트가 커지면서 다음 문제들이 수면 위로 올라왔습니다.</p>
<h3 id="문제-1-service가-jpa-구현-세부사항에-직접-의존">문제 1. Service가 JPA 구현 세부사항에 직접 의존</h3>
<p><code>findByIdWithKeywords()</code>는 <strong>페치 조인이라는 JPA 구현 세부사항</strong>입니다. 이 메서드 이름이 Service 코드에 박히면, 나중에 &quot;페치 조인 대신 별도 쿼리 2번으로 바꾸자&quot;고 결정했을 때 Service도 같이 수정해야 합니다. (유지보수 관점에서 자꾸만 애매하게 걸렸던게 컸다.)  </p>
<p>인터페이스(What)와 구현(How)이 Service 안에서 뒤섞이는 상황입니다.</p>
<h3 id="문제-2-단위-테스트에서-db가-필요해짐">문제 2. 단위 테스트에서 DB가 필요해짐</h3>
<p><code>CardJpaRepository</code>는 Spring Data JPA가 런타임에 프록시로 생성합니다. 이를 Mock으로 대체하려면 <code>@DataJpaTest</code>나 <code>@SpringBootTest</code>처럼 Spring Context를 띄워야 하고, 테스트 비용이 올라갑니다.</p>
<pre><code class="language-java">// CardJpaRepository를 직접 주입하면 단위 테스트에서 이렇게 해야 함
@DataJpaTest // Spring Context 필요 — 느리고 무겁다
class CardQueryServiceTest { ... }</code></pre>
<h3 id="문제-3-멀티-모듈-전환-시-jpa-의존성이-상위-레이어로-침투">문제 3. 멀티 모듈 전환 시 JPA 의존성이 상위 레이어로 침투</h3>
<p>도메인/애플리케이션 모듈과 인프라 모듈을 분리할 때, Service가 <code>CardJpaRepository</code>를 직접 알고 있으면 애플리케이션 모듈에 JPA 의존성이 올라옵니다. 이는 <strong>&quot;기술 의존성은 인프라 레이어에 가두자&quot;</strong>는 원칙을 깨뜨립니다.</p>
<hr>
<h2 id="이후-방식--포트-어댑터-구조">이후 방식 — 포트-어댑터 구조</h2>
<h3 id="전체-연결-구도">전체 연결 구도</h3>
<pre><code>[Service 레이어]
  CardQueryService
  CardCommandService
       │
       │ 의존 (인터페이스만 앎)
       ▼
[CardRepository] ← 포트 인터페이스
  save()
  findById()
  findAllByDeckIdAndDeletedFalse()
  searchCards()
       │
       │ 구현
       ▼
[CardRepositoryAdapter] ← @Repository 어댑터
  └── CardJpaRepository 주입 후 각 포트 메서드 → JPA 호출로 변환
        ├── findByIdWithKeywords()    ← JPQL 페치 조인
        ├── findAllByDeckId...()      ← JPA 네이밍 쿼리
        └── searchCards()             ← QueryDSL (Impl 위임)</code></pre><h3 id="cardrepository--포트-인터페이스">CardRepository — 포트 인터페이스</h3>
<pre><code class="language-java">public interface CardRepository {
    Card save(Card card);
    Optional&lt;Card&gt; findById(Long id);
    List&lt;Card&gt; findAllByDeckIdAndDeletedFalse(Long deckId);
    Page&lt;Card&gt; searchCards(CardSearchCondition condition, Pageable pageable);
}</code></pre>
<p>JPA도, QueryDSL도 이 인터페이스에는 등장하지 않습니다. Service가 의존하는 계약(Contract)만 정의합니다.</p>
<h3 id="cardrepositoryadapter--어댑터">CardRepositoryAdapter — 어댑터</h3>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
public class CardRepositoryAdapter implements CardRepository {

    private final CardJpaRepository cardJpaRepository;

    @Override
    public Optional&lt;Card&gt; findById(Long id) {
        return cardJpaRepository.findByIdWithKeywords(id); // 구현 세부사항은 여기서 결정
    }

    @Override
    public Page&lt;Card&gt; searchCards(CardSearchCondition condition, Pageable pageable) {
        return cardJpaRepository.searchCards(condition, pageable); // QueryDSL로 위임
    }
}</code></pre>
<p>Service는 <code>CardRepository</code>라는 인터페이스만 알고, Spring DI가 런타임에 <code>CardRepositoryAdapter</code>를 주입합니다. Service는 이 사실을 모르고, 알 필요도 없습니다.</p>
<h3 id="cardrepositorycustom--cardjparepositoryimpl--querydsl-연결">CardRepositoryCustom + CardJpaRepositoryImpl — QueryDSL 연결</h3>
<p>이 두 개는 의존성 역전이 목적이 아닙니다. <strong>Spring Data JPA의 기술적 제약을 해결하기 위한 구조</strong>입니다.</p>
<p><code>JpaRepository</code>는 기본 CRUD와 네이밍 쿼리만 자동 구현합니다. QueryDSL 동적 쿼리를 추가하려면 Custom 인터페이스 + <code>Impl</code> 네이밍 규칙을 따라야 Spring이 자동으로 감지해서 연결해줍니다.</p>
<pre><code class="language-java">// Spring이 &quot;CardJpaRepository명 + Impl&quot; 규칙으로 자동 감지
public class CardJpaRepositoryImpl implements CardRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page&lt;Card&gt; searchCards(CardSearchCondition condition, Pageable pageable) {
        // QueryDSL 동적 쿼리
        List&lt;Card&gt; content = queryFactory
            .selectFrom(card)
            .where(
                keywordContains(condition.getKeyword()),
                deckIdEq(condition.getDeckId())
            )
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
        // ...
    }
}</code></pre>
<hr>
<h2 id="트레이드오프-정리">트레이드오프 정리</h2>
<table>
<thead>
<tr>
<th>관점</th>
<th>직접 주입 방식</th>
<th>포트-어댑터 방식</th>
</tr>
</thead>
<tbody><tr>
<td><strong>코드 양</strong></td>
<td>적음 (파일 2개)</td>
<td>많음 (파일 4개)</td>
</tr>
<tr>
<td><strong>초기 개발 속도</strong></td>
<td>빠름</td>
<td>느림</td>
</tr>
<tr>
<td><strong>Service의 기술 의존</strong></td>
<td>JPA에 직접 결합</td>
<td>인터페이스에만 결합</td>
</tr>
<tr>
<td><strong>단위 테스트</strong></td>
<td>Spring Context 필요</td>
<td>Mock 주입으로 충분</td>
</tr>
<tr>
<td><strong>구현 교체 비용</strong></td>
<td>Service도 수정 필요</td>
<td>Adapter만 수정</td>
</tr>
<tr>
<td><strong>멀티 모듈 확장성</strong></td>
<td>JPA 의존이 상위 침투</td>
<td>인프라 모듈에 격리</td>
</tr>
<tr>
<td><strong>구조 이해 난이도</strong></td>
<td>낮음</td>
<td>높음 (패턴 이해 필요)</td>
</tr>
</tbody></table>
<h3 id="언제-포트-어댑터가-유효한가">언제 포트-어댑터가 유효한가</h3>
<ul>
<li><strong>QueryDSL 등 커스텀 쿼리가 많을 때</strong>: 기술 세부사항이 Service에 노출되는 면적이 커집니다.</li>
<li><strong>단위 테스트를 DB 없이 돌리고 싶을 때</strong>: Mock 교체가 인터페이스 덕에 자연스럽습니다.</li>
<li><strong>멀티 모듈 전환 가능성이 있을 때</strong>: 도메인/애플리케이션 모듈이 JPA를 몰라도 됩니다.</li>
<li><strong>구현 전략이 바뀔 가능성이 있을 때</strong>: 페치 조인 → 별도 쿼리, JPA → MyBatis 등 전환 비용을 줄입니다.</li>
</ul>
<h3 id="언제-직접-주입이-더-나은가">언제 직접 주입이 더 나은가</h3>
<ul>
<li>프로젝트 규모가 작고 팀이 혼자일 때</li>
<li>단위 테스트보다 통합 테스트 중심으로 운영할 때</li>
<li>레이어 분리보다 개발 속도가 우선일 때</li>
</ul>
<hr>
<h2 id="결론">결론</h2>
<p>포트-어댑터 구조는 <strong>&quot;지금 당장의 복잡함&quot;을 지불하고 &quot;미래의 변경 비용&quot;을 낮추는 선택</strong>입니다.</p>
<p>ThirdTool 기준으로는 QueryDSL 동적 쿼리가 이미 들어와 있고, 멀티 모듈 전환 가능성도 열어두고 싶었기 때문에 이 방향이 적합하다고 판단했습니다.</p>
<p>다만 솔직히 말하면, 1인 프로젝트 초기에 파일 4개를 만드는 게 오버엔지니어링처럼 느껴지는 순간도 있었습니다. 그 불편함 자체가 이 구조의 비용이고, 그 비용이 납득 가능한지 팀 상황에 맞게 따져봐야 한다는 게 제 결론입니다.</p>
<blockquote>
<p>ADR(Architecture Decision Record)로 이 결정을 문서화해두면, 나중에 &quot;왜 이렇게 만들었지?&quot;라는 질문에 빠르게 답할 수 있습니다. 의사결정의 맥락을 코드가 아닌 문서에 남기는 습관, 추천합니다.</p>
</blockquote>
<hr>
<p><em>ThirdTool ADR 시리즈는 계속됩니다.</em></p>
<blockquote>
<p>최종 수정일: 2026-03-23</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[third tool] flyway의 도입기 ]]></title>
            <link>https://velog.io/@js-kim-arc/third-tool-flyway%EC%9D%98-%EB%8F%84%EC%9E%85%EA%B8%B0</link>
            <guid>https://velog.io/@js-kim-arc/third-tool-flyway%EC%9D%98-%EB%8F%84%EC%9E%85%EA%B8%B0</guid>
            <pubDate>Fri, 20 Mar 2026 10:11:26 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/460296dd-91f1-4169-8c30-8dd690339c14/image.png" alt=""></p>
<h1 id="flyway를-도입할-수밖에-없었던-이유">Flyway를 도입할 수밖에 없었던 이유</h1>
<blockquote>
<p>&quot;코드는 Git으로 관리하면서, 왜 DB 스키마는 기억에 의존하고 있었을까?&quot;</p>
</blockquote>
<hr>
<h2 id="시작은-편함이었다">시작은 편함이었다</h2>
<p>Spring Boot 프로젝트를 처음 시작할 때, 대부분의 개발자는 <code>application.yml</code>에 이 한 줄을 아무 고민 없이 추가한다.</p>
<pre><code class="language-yaml">spring:
  jpa:
    hibernate:
      ddl-auto: create-drop</code></pre>
<p>토이 프로젝트 수준에서는 이게 사실 최고의 선택처럼 느껴진다. 엔티티 클래스를 수정하면 테이블이 알아서 반영되고, 애플리케이션을 재시작하면 깨끗하게 초기화된다. 마이그레이션 스크립트를 따로 작성할 필요도 없고, DDL을 직접 손댈 일도 없다.</p>
<p>문제는, 이 편함이 <strong>&quot;지금은 괜찮지만, 나중엔 반드시 터진다&quot;</strong> 는 종류의 편함이라는 점이다.</p>
<hr>
<h2 id="ddl-auto가-만들어내는-진짜-문제"><code>ddl-auto</code>가 만들어내는 진짜 문제</h2>
<h3 id="스키마-변경-이력이-없다">스키마 변경 이력이 없다</h3>
<ul>
<li>이 문제점 느끼는데 꽤 오랜 시간이 걸렸다.</li>
</ul>
<p>코드는 Git에 커밋이 쌓인다. 누가 언제 어떤 이유로 어떤 필드를 추가했는지, <code>git log</code>나 PR 히스토리를 보면 알 수 있다.</p>
<p>그런데 DB 스키마는? <code>ddl-auto: update</code>를 쓰고 있다면, 어느 시점에 어떤 컬럼이 생겼는지 알 방법이 없다. 테이블 구조 자체에는 아무런 기록이 남지 않는다.</p>
<p>이건 단순히 불편함의 문제가 아니다. <strong>&quot;왜 이 컬럼이 생겼는가&quot;</strong> 라는 질문에 대한 답이 코드베이스 어디에도 없다는 의미다.</p>
<h3 id="환경-간-스키마-일관성을-보장할-수-없다">환경 간 스키마 일관성을 보장할 수 없다</h3>
<p>로컬에서는 잘 돌아가는데, 서버에 올리면 이상하다. 팀원 A의 로컬에서는 되는데, 팀원 B의 로컬에서는 안 된다.</p>
<p><code>ddl-auto: update</code>는 현재 엔티티 상태를 기준으로 <strong>없는 컬럼만 추가</strong>한다. 컬럼을 지우거나, 타입을 바꾸거나, 제약조건을 변경하는 건 직접 처리해야 한다. 결국 &quot;내 로컬 DB엔 언제 어떻게 만들어졌는지 모를 컬럼들&quot;이 조용히 쌓여간다.</p>
<h3 id="create-drop은-운영에서-쓸-수-없다"><code>create-drop</code>은 운영에서 쓸 수 없다</h3>
<p>로컬에서 <code>create-drop</code>을 쓰다가, 운영 환경으로 가면? 당연히 쓸 수 없다. 배포할 때마다 테이블이 날아간다.</p>
<p>그러면 운영 환경에서는 <code>validate</code>나 <code>none</code>으로 설정하고, 스키마 변경은 직접 SQL을 손으로 실행하게 된다. 이 시점부터 스키마 변경은 완전히 <strong>수동 프로세스</strong>가 된다. 누군가 까먹으면, 배포가 터진다.</p>
<hr>
<h2 id="flyway는-스키마를-위한-git이다">Flyway는 &quot;스키마를 위한 Git&quot;이다</h2>
<p>Flyway의 핵심 개념은 단순하다.</p>
<blockquote>
<p><strong>모든 스키마 변경은 SQL 파일로 기록되고, 한 번 적용된 파일은 절대 수정하지 않는다.</strong></p>
</blockquote>
<p>프로젝트에 <code>src/main/resources/db/migration/</code> 디렉토리를 만들고, 다음과 같은 네이밍 컨벤션으로 파일을 추가하기만 하면 된다.</p>
<pre><code>V1__init_schema.sql
V2__add_card_table.sql
V3__add_summary_column.sql</code></pre><p>애플리케이션이 시작될 때 Flyway는 <code>flyway_schema_history</code> 테이블을 확인하고, 아직 적용되지 않은 마이그레이션 파일만 순서대로 실행한다.</p>
<p>이게 전부다. 그런데 이 단순한 구조가 앞서 말한 문제들을 모두 해결한다.</p>
<hr>
<h2 id="도입하면-생기는-것들">도입하면 생기는 것들</h2>
<h3 id="1-스키마-변경-이력이-생긴다">1. 스키마 변경 이력이 생긴다</h3>
<p><code>V3__add_summary_column.sql</code> 파일이 Git 히스토리에 남는다. 언제, 누가, 왜 이 컬럼을 추가했는지 커밋 메시지와 함께 추적할 수 있다. DB 변경사항이 코드 변경사항과 같은 단위로 관리되기 시작한다.</p>
<h3 id="2-환경-간-스키마가-동일함을-보장한다">2. 환경 간 스키마가 동일함을 보장한다</h3>
<p>새로운 팀원이 합류해서 프로젝트를 클론하면, 애플리케이션을 실행하는 것만으로 DB 스키마가 최신 상태로 자동 세팅된다. 로컬, 개발서버, 운영서버 모두 동일한 마이그레이션 파일을 순서대로 적용받는다.</p>
<h3 id="3-ddl-auto-validate를-운영에서-안심하고-쓸-수-있다">3. <code>ddl-auto: validate</code>를 운영에서 안심하고 쓸 수 있다</h3>
<p>Flyway를 도입하면 운영 환경 설정을 아래처럼 가져갈 수 있다.</p>
<pre><code class="language-yaml"># 운영 환경
spring:
  jpa:
    hibernate:
      ddl-auto: validate  # 엔티티와 스키마가 불일치하면 시작 자체를 막는다
  flyway:
    enabled: true</code></pre>
<p><code>validate</code> 옵션은 Hibernate가 현재 엔티티 클래스와 실제 DB 스키마가 일치하는지 검증하고, 맞지 않으면 애플리케이션 시작을 실패시킨다. Flyway와 함께 쓰면, 마이그레이션 파일을 빠뜨린 채 배포했을 때 조용히 넘어가지 않고 명시적으로 터진다. <strong>&quot;배포 후 런타임에 터지는 것&quot;보다 &quot;시작 시점에 터지는 것&quot;이 훨씬 낫다.</strong></p>
<h3 id="4-ddl을-직접-통제하게-된다">4. DDL을 직접 통제하게 된다</h3>
<p><code>ddl-auto</code>에 의존하는 동안은 사실 Hibernate가 DDL을 대신 만들어주는 것이다. 컬럼 타입, 인덱스, 제약조건... Hibernate가 엔티티를 보고 나름대로 판단해서 생성한다.</p>
<p>Flyway를 도입하면 SQL을 직접 작성하게 된다. 처음엔 번거롭게 느껴지지만, 덕분에 <strong>&quot;내 DB 스키마가 정확히 어떻게 생겼는지&quot;</strong> 를 명확히 알게 된다. <code>VARCHAR(255)</code>로 뭉뚱그려 생성되던 컬럼을 <code>VARCHAR(500) NOT NULL</code>로 정확히 지정할 수 있고, 복합 인덱스를 언제 어떤 이유로 추가했는지 SQL 파일과 커밋 메시지에 남길 수 있다.</p>
<hr>
<h2 id="환경별-설정-분리-전략">환경별 설정 분리 전략</h2>
<p>Flyway를 도입할 때 한 가지 짚어두어야 할 게 있다. 테스트 환경, 특히 <code>@DataJpaTest</code> 같은 슬라이스 테스트에서는 Flyway와 H2를 함께 쓰면 MySQL 전용 SQL 문법 충돌이 발생한다.</p>
<p>내가 택한 방식은 간단하다.</p>
<pre><code class="language-yaml"># 로컬/운영 (application.yml)
spring:
  jpa:
    hibernate:
      ddl-auto: validate
  flyway:
    enabled: true

# 테스트 (application-test.yml)
spring:
  jpa:
    hibernate:
      ddl-auto: create-drop  # H2가 엔티티 기반으로 스키마를 직접 생성
  flyway:
    enabled: false           # Flyway 비활성화로 MySQL 문법 충돌 방지</code></pre>
<p>테스트는 H2가 알아서 스키마를 만들게 두고, Flyway는 실제 MySQL을 쓰는 환경에서만 동작하게 분리한다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>Flyway 도입이 귀찮게 느껴지는 건, SQL 파일을 직접 작성해야 하기 때문이다. 하지만 그 귀찮음은 사실 <strong>&quot;지금까지 Hibernate에게 맡겨두고 신경 쓰지 않았던 것&quot;</strong> 을 직접 챙기기 시작하는 과정이다.</p>
<p>코드 변경은 커밋으로 남긴다. 그렇다면 스키마 변경도 마찬가지여야 한다.</p>
<p>결국 Flyway는 선택의 문제가 아니라, 프로젝트가 토이 수준을 넘어서는 순간 자연스럽게 도달하게 되는 결론이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third tool] 캐시 도입기: 로컬 캐시부터 Redis까지, 트레이드오프 중심으로(제발 알고 쓰자~ ) ]]></title>
            <link>https://velog.io/@js-kim-arc/Third-tool-%EC%BA%90%EC%8B%9C-%EB%8F%84%EC%9E%85%EA%B8%B0-%EB%A1%9C%EC%BB%AC-%EC%BA%90%EC%8B%9C%EB%B6%80%ED%84%B0-Redis%EA%B9%8C%EC%A7%80-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84-%EC%A4%91%EC%8B%AC%EC%9C%BC%EB%A1%9C%EC%A0%9C%EB%B0%9C-%EC%95%8C%EA%B3%A0-%EC%93%B0%EC%9E%90</link>
            <guid>https://velog.io/@js-kim-arc/Third-tool-%EC%BA%90%EC%8B%9C-%EB%8F%84%EC%9E%85%EA%B8%B0-%EB%A1%9C%EC%BB%AC-%EC%BA%90%EC%8B%9C%EB%B6%80%ED%84%B0-Redis%EA%B9%8C%EC%A7%80-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84-%EC%A4%91%EC%8B%AC%EC%9C%BC%EB%A1%9C%EC%A0%9C%EB%B0%9C-%EC%95%8C%EA%B3%A0-%EC%93%B0%EC%9E%90</guid>
            <pubDate>Thu, 19 Mar 2026 10:53:09 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/8e7c1eb1-922b-4b8a-b0fc-96c1ac4fac34/image.png" alt="">
<img src="https://velog.velcdn.com/images/js-kim-arc/post/decbd9c0-e294-4708-86cc-28c42118eae5/image.png" alt=""></p>
<h2 id="읽고-싶은-곳으로-이동하기">읽고 싶은 곳으로 이동하기</h2>
<ul>
<li><a href="#1-%EB%AC%B8%EC%A0%9C-%EC%9D%B8%EC%8B%9D-%EC%99%9C-%EC%BA%90%EC%8B%9C%EA%B0%80-%ED%95%84%EC%9A%94%ED%96%88%EB%82%98">1. 문제 인식: 왜 캐시가 필요했나</a><ul>
<li><a href="#%EC%86%8D%EB%8F%84-%EA%B2%A9%EC%B0%A8-%EB%AC%B8%EC%A0%9C-speed-gap">속도 격차 문제 (Speed Gap)</a></li>
<li><a href="#%EC%96%B4%EB%96%A4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EA%B0%80-%EB%AC%B8%EC%A0%9C%EC%98%80%EB%82%98">어떤 데이터가 문제였나</a></li>
</ul>
</li>
<li><a href="#2-%EC%B2%AB-%EB%B2%88%EC%A7%B8-%EC%84%A0%ED%83%9D-%EC%99%9C-redis%EA%B0%80-%EC%95%84%EB%8B%8C-caffeine%EC%9D%B4%EC%97%88%EB%82%98%EC%9D%B4%EA%B2%8C-%EC%A4%91%EC%9A%94%ED%95%98%EB%8B%A4">2. 첫 번째 선택: 왜 Redis가 아닌 Caffeine이었나(이게 중요하다.)</a><ul>
<li><a href="#%EB%B0%94%EB%A1%9C-redis%EB%A5%BC-%EB%8F%84%EC%9E%85%ED%95%98%EC%A7%80-%EC%95%8A%EC%9D%80-%EC%9D%B4%EC%9C%A0">바로 Redis를 도입하지 않은 이유</a></li>
<li><a href="#%EC%88%9C%EC%B0%A8%EC%A0%81-%EA%B2%80%EC%A6%9D%EC%9D%98-%EC%9B%90%EC%B9%99">순차적 검증의 원칙</a></li>
</ul>
</li>
<li><a href="#3-caffeine-%EC%BA%90%EC%8B%9C-%EC%86%8C%EA%B0%9C">3. Caffeine 캐시 소개</a><ul>
<li><a href="#%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%84%A4%EC%A0%95">의존성 설정</a></li>
<li><a href="#caffeine-%EC%84%A4%EC%A0%95">Caffeine 설정</a></li>
<li><a href="#ttl-%EA%B8%B0%EB%B0%98-%EC%BA%90%EC%8B%9C-%ED%99%9C%EC%9A%A9">TTL 기반 캐시 활용</a></li>
</ul>
</li>
<li><a href="#4-%EB%A1%9C%EC%BB%AC-%EC%BA%90%EC%8B%9Ccaffeine%EC%9D%98-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84">4. 로컬 캐시(Caffeine)의 트레이드오프</a><ul>
<li><a href="#%EC%9E%A5%EC%A0%90">장점</a></li>
<li><a href="#%ED%95%9C%EA%B3%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84">한계 (트레이드오프)</a></li>
</ul>
</li>
<li><a href="#5-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-%ED%99%95%EC%9D%B8">5. 성능 개선 확인</a></li>
<li><a href="#6-%EA%B7%B8%EB%9E%98%EC%84%9C-%EC%96%B8%EC%A0%9C-redis%EB%A1%9C-%EB%84%98%EC%96%B4%EA%B0%80%EC%95%BC-%ED%95%98%EB%82%98">6. 그래서 언제 Redis로 넘어가야 하나</a><ul>
<li><a href="#caffeine%EC%9C%BC%EB%A1%9C-%EC%B6%A9%EB%B6%84%ED%95%9C-%EA%B2%BD%EC%9A%B0">Caffeine으로 충분한 경우</a></li>
<li><a href="#redis%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%9C-%EA%B2%BD%EC%9A%B0">Redis가 필요한 경우</a></li>
</ul>
</li>
<li><a href="#7-redis-%EB%8F%84%EC%9E%85-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%EB%9D%BC%EC%A7%80%EB%82%98">7. Redis 도입: 무엇이 달라지나</a><ul>
<li><a href="#%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EB%B3%80%ED%99%94">아키텍처 변화</a></li>
<li><a href="#spring-cache--redis-%EC%84%A4%EC%A0%95">Spring Cache + Redis 설정</a></li>
<li><a href="#redis-%EB%8F%84%EC%9E%85-%EC%8B%9C-%EC%83%88%EB%A1%9C%EC%9A%B4-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84">Redis 도입 시 새로운 트레이드오프</a></li>
</ul>
</li>
<li><a href="#8-%EB%91%90-%EC%BA%90%EC%8B%9C%EB%A5%BC-%ED%95%A8%EA%BB%98-local--redis-two-level-cache">8. 두 캐시를 함께: Local + Redis (Two-Level Cache)</a></li>
<li><a href="#9-%EB%8F%8C%EC%95%84%EB%B3%B4%EB%A9%B0-%EC%88%9C%EC%B0%A8%EC%A0%81-%EA%B2%80%EC%A6%9D%EC%9D%98-%EA%B0%80%EC%B9%98---%EA%B2%B0%EA%B5%AD%EC%9D%80-%EB%AA%A8%EB%93%A0-%EA%B1%B4-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84%EB%8B%A4">9. 돌아보며: 순차적 검증의 가치 - 결국은 모든 건 트레이드오프다.</a></li>
<li><a href="#%EC%B0%B8%EA%B3%A0">참고</a></li>
</ul>
<h1 id="캐시-도입기-로컬-캐시부터-redis까지-트레이드오프-중심으로제발-알고-쓰자">캐시 도입기: 로컬 캐시부터 Redis까지, 트레이드오프 중심으로(제발 알고 쓰자~)</h1>
<blockquote>
<p>&quot;처음부터 Redis를 쓰면 되는 거 아닌가요...?&quot;
아마 비슷한 상황을 마주하면 많은 분들이 이런 생각을 할 것입니다. 저도 그랬습니다.
이 글은 그 질문에 답하기 위해, 순차적으로 검증하며 캐시를 도입한 과정을 기록한 글입니다.</p>
</blockquote>
<hr>
<h2 id="1-문제-인식-왜-캐시가-필요했나">1. 문제 인식: 왜 캐시가 필요했나</h2>
<h3 id="속도-격차-문제-speed-gap">속도 격차 문제 (Speed Gap)</h3>
<p>캐시의 본질은 <strong>속도 불균형에서 비롯된다는 점이다.</strong></p>
<p>CPU와 메모리, 메모리와 디스크, 애플리케이션과 데이터베이스. 이 계층들 사이의 접근 속도 차이는 어마어마합니다.</p>
<table>
<thead>
<tr>
<th>저장소</th>
<th>접근 속도</th>
</tr>
</thead>
<tbody><tr>
<td>CPU L1 캐시</td>
<td>~1 ns</td>
</tr>
<tr>
<td>RAM</td>
<td>~100 ns</td>
</tr>
<tr>
<td>SSD</td>
<td>~100 μs</td>
</tr>
<tr>
<td>HDD / Network DB</td>
<td>~10 ms 이상</td>
</tr>
</tbody></table>
<p>데이터베이스는 디스크 I/O와 네트워크 레이턴시를 동반합니다. 요청이 늘수록 이 비용은 선형, 혹은 그 이상으로 늘어납니다. <strong>캐시란 이 속도 격차를 메우는 가장 현실적인 수단입니다.</strong> 빠른 계층에 자주 쓰이는 데이터를 미리 올려두고, 느린 계층으로의 접근을 줄이는 것이 핵심 아이디어입니다.</p>
<h3 id="어떤-데이터가-문제였나">어떤 데이터가 문제였나</h3>
<p>서비스에는 <strong>조회 빈도가 높지만 변경 빈도는 낮은 데이터</strong>들이 있었습니다.</p>
<p>(내가 운영하는 Third tool 기준으로 간단히 생각해보자... ㅎ) </p>
<ul>
<li>카테고리·태그 목록 (변경 거의 없음, 매 요청마다 조회)</li>
<li>Deck 통계 (views, import count - 실시간 정확성보다 응답 속도가 중요)</li>
<li>공개된 Library Deck 목록 (자주 바뀌지 않음)</li>
</ul>
<p>매 요청마다 DB를 거치는 것은 명백한 낭비였습니다.(찾아보니 cpu나 메인메모리,disk보다 네트워크- 포트끼리의 통신은 거의 1초와 1년의 차이였다... ㄷㄷㄷ) </p>
<hr>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/b737086a-b88e-4a6f-89cd-30f94e3e3924/image.png" alt=""></p>
<h2 id="2-첫-번째-선택-왜-redis가-아닌-caffeine이었나이게-중요하다">2. 첫 번째 선택: 왜 Redis가 아닌 Caffeine이었나(이게 중요하다.)</h2>
<h3 id="바로-redis를-도입하지-않은-이유">바로 Redis를 도입하지 않은 이유</h3>
<p>Redis는 강력한 솔루션입니다. 그런데 저는 의도적으로 Redis를 첫 번째 선택지에서 제외했습니다.</p>
<p><strong>인프라 비용과 운영 복잡도</strong>가 가장 큰 이유였습니다. Redis는 별도 프로세스이고, 네트워크를 통해 접근합니다. 이 말은 곧 다음을 의미합니다.</p>
<ul>
<li>서버를 하나 더 띄워야 한다</li>
<li>Redis 자체의 장애 포인트가 생긴다</li>
<li>Redis 접근 레이턴시가 추가된다 (로컬보다 느림)</li>
<li>운영 환경(개발/스테이징/프로덕션) 마다 별도 설정이 필요하다</li>
<li>무엇보다 들었던 좋았던 비유 - 라면 1봉지 옆집으로 주는데 큰 박스 안에 엄청 큰 택배에 감싸서 보내는 것이랍니다..</li>
</ul>
<p><strong>&quot;지금 당장 분산 캐시가 필요한가?&quot;</strong> 이 질문이 핵심이었습니다. 서버 인스턴스가 하나라면, 로컬 캐시로도 충분합니다. 오히려 Redis를 먼저 붙이는 것은 Over-engineering입니다.</p>
<h3 id="순차적-검증의-원칙">순차적 검증의 원칙</h3>
<p>캐시 도입은 단계적으로 진행해야 합니다.</p>
<pre><code>로컬 캐시로 캐시 전략 검증
    → 효과 확인
    → 스케일아웃 필요 시점에 분산 캐시로 이전</code></pre><p>캐시 히트율, TTL 설정, 무효화 전략 등을 <strong>운영 복잡도 없이 먼저 학습</strong>하는 것이 로컬 캐시의 가장 큰 장점입니다.</p>
<hr>
<h2 id="3-caffeine-캐시-소개">3. Caffeine 캐시 소개</h2>
<p><a href="https://github.com/ben-manes/caffeine">Caffeine</a>은 Java용 고성능 로컬 캐시 라이브러리입니다.</p>
<p>Spring Boot 2.x 이후 <code>spring-boot-starter-cache</code>와의 연동이 공식 지원되어 <code>@Cacheable</code>, <code>@CacheEvict</code> 같은 어노테이션을 그대로 활용할 수 있습니다.</p>
<h3 id="의존성-설정">의존성 설정</h3>
<pre><code class="language-gradle">implementation &#39;org.springframework.boot:spring-boot-starter-cache&#39;
implementation &#39;com.github.ben-manes.caffeine:caffeine&#39;</code></pre>
<h3 id="caffeine-설정">Caffeine 설정</h3>
<pre><code class="language-java">@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(
            Caffeine.newBuilder()
                .maximumSize(1000)          // 최대 엔트리 수
                .expireAfterWrite(10, TimeUnit.MINUTES)  // TTL
                .recordStats()              // 히트율 모니터링
        );
        return manager;
    }
}</code></pre>
<h3 id="ttl-기반-캐시-활용">TTL 기반 캐시 활용</h3>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class CategoryQueryService {

    private final CategoryRepository categoryRepository;

    @Cacheable(cacheNames = &quot;categories&quot;, key = &quot;&#39;all&#39;&quot;)
    public List&lt;CategoryResponse&gt; findAll() {
        return categoryRepository.findAll()
            .stream()
            .map(CategoryResponse::from)
            .toList();
    }

    @CacheEvict(cacheNames = &quot;categories&quot;, allEntries = true)
    public void evictCache() {
        // 카테고리 변경 시 캐시 무효화
    }
}</code></pre>
<hr>
<h2 id="4-로컬-캐시caffeine의-트레이드오프">4. 로컬 캐시(Caffeine)의 트레이드오프</h2>
<h3 id="장점">장점</h3>
<p><strong>1. 제로 네트워크 오버헤드</strong>
메모리 직접 접근이므로 ns ~ μs 단위의 응답이 가능합니다. Redis 접근이 수십 μs ~ 수 ms인 것과 비교하면 압도적으로 빠릅니다.( 개인적으로 매우 큰 장점이라고 생각합니다.) </p>
<p><strong>2. 인프라 단순성</strong>
추가 서버가 없으므로 개발 환경 구성이 단순하고, 장애 포인트가 하나 줄어듭니다.</p>
<p><strong>3. 빠른 도입</strong>
의존성 추가 + 어노테이션 몇 줄이면 동작합니다. Redis 연결 설정, 직렬화 전략 등을 고민할 필요가 없습니다.</p>
<h3 id="한계-트레이드오프">한계 (트레이드오프)</h3>
<p><strong>1. 인스턴스 간 캐시 불일치 (Cache Inconsistency)</strong></p>
<p>이것이 가장 치명적인 단점입니다.</p>
<pre><code>[인스턴스 A] categories 캐시: [개발, 디자인, 마케팅]
[인스턴스 B] categories 캐시: [개발, 디자인]  ← 아직 무효화 안 됨

사용자 A → 인스턴스 A → &quot;마케팅&quot; 카테고리 보임
사용자 B → 인스턴스 B → &quot;마케팅&quot; 카테고리 안 보임</code></pre><p>서버 인스턴스가 여러 개라면 각자 독립된 캐시를 가집니다. 한 인스턴스에서 캐시를 무효화해도, 다른 인스턴스는 TTL이 만료될 때까지 stale data를 서빙합니다.</p>
<p><strong>2. 애플리케이션 재시작 시 캐시 소멸</strong>
배포할 때마다 캐시가 비워집니다. Cold Start 직후에는 DB 부하가 일시적으로 급증합니다.</p>
<p><strong>3. 캐시 모니터링의 어려움</strong>
인스턴스마다 따로 관리되므로, 전체 캐시 상태를 한눈에 보기 어렵습니다.</p>
<hr>
<h2 id="5-성능-개선-확인">5. 성능 개선 확인</h2>
<p>Caffeine 도입 후 변화를 측정했습니다.</p>
<table>
<thead>
<tr>
<th>시나리오</th>
<th>캐시 미적용</th>
<th>Caffeine 적용 후</th>
</tr>
</thead>
<tbody><tr>
<td>카테고리 목록 조회 (DB)</td>
<td>~35ms</td>
<td>~0.3ms (히트 시)</td>
</tr>
<tr>
<td>Library Deck 목록 (10페이지)</td>
<td>~120ms</td>
<td>~1ms (히트 시)</td>
</tr>
<tr>
<td>동시 요청 100건 DB QPS</td>
<td>100 QPS → DB</td>
<td>~2 QPS → DB</td>
</tr>
</tbody></table>
<p><strong>캐시 히트율</strong>은 <code>recordStats()</code>를 통해 확인할 수 있습니다.</p>
<pre><code class="language-java">@Bean
public ApplicationRunner cacheStatsLogger(CacheManager cacheManager) {
    return args -&gt; {
        if (cacheManager instanceof CaffeineCacheManager cm) {
            // Actuator 또는 스케줄러로 주기적으로 출력
        }
    };
}</code></pre>
<p>Spring Boot Actuator의 <code>/actuator/metrics/cache.gets</code> 엔드포인트를 활용하면 히트/미스 비율을 실시간으로 확인할 수 있습니다.</p>
<hr>
<h2 id="6-그래서-언제-redis로-넘어가야-하나">6. 그래서 언제 Redis로 넘어가야 하나</h2>
<p>Caffeine으로 충분한 상황과, Redis가 필요한 시점을 명확히 구분해야 합니다.</p>
<h3 id="caffeine으로-충분한-경우">Caffeine으로 충분한 경우</h3>
<ul>
<li>서버 인스턴스가 <strong>단일</strong> 또는 <strong>소수 (2대 이하)</strong></li>
<li>캐시 불일치가 허용되는 데이터 (TTL 내 stale 허용)</li>
<li>빠른 프로토타이핑, 초기 서비스</li>
</ul>
<h3 id="redis가-필요한-경우">Redis가 필요한 경우</h3>
<table>
<thead>
<tr>
<th>상황</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>수평 확장(Scale-out) 시</td>
<td>모든 인스턴스가 동일한 캐시 뷰를 공유해야 할 때</td>
</tr>
<tr>
<td>강한 일관성이 필요한 데이터</td>
<td>잔액, 재고처럼 stale data가 비즈니스 문제를 유발할 때</td>
</tr>
<tr>
<td>배포 간 캐시 유지 필요</td>
<td>워밍업 비용이 큰 캐시 (대용량 추천 결과 등)</td>
</tr>
<tr>
<td>세션 관리, 분산 락</td>
<td>Redis의 원자적 연산이 필요할 때</td>
</tr>
</tbody></table>
<hr>
<h2 id="7-redis-도입-무엇이-달라지나">7. Redis 도입: 무엇이 달라지나</h2>
<h3 id="아키텍처-변화">아키텍처 변화</h3>
<pre><code>[Before - Caffeine]
인스턴스 A ──── JVM 힙 캐시
인스턴스 B ──── JVM 힙 캐시  (서로 다른 상태)

[After - Redis]
인스턴스 A ──┐
              ├── Redis ──── 중앙 캐시 (동일한 상태)
인스턴스 B ──┘</code></pre><h3 id="spring-cache--redis-설정">Spring Cache + Redis 설정</h3>
<pre><code class="language-gradle">implementation &#39;org.springframework.boot:spring-boot-starter-data-redis&#39;</code></pre>
<pre><code class="language-java">@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer()
                )
            );

        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }
}</code></pre>
<p>어노테이션(<code>@Cacheable</code>, <code>@CacheEvict</code>)은 <strong>변경 없이</strong> 그대로 유지됩니다. Spring Cache 추상화의 가장 큰 장점입니다.</p>
<h3 id="redis-도입-시-새로운-트레이드오프">Redis 도입 시 새로운 트레이드오프</h3>
<p>Redis가 만능은 아닙니다. 아래 비용이 발생합니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>네트워크 레이턴시</td>
<td>로컬 메모리보다 느림 (보통 수십 μs ~ 수 ms)</td>
</tr>
<tr>
<td>직렬화/역직렬화 비용</td>
<td>객체를 JSON/Byte로 변환해야 함</td>
</tr>
<tr>
<td>Redis 장애 시 영향</td>
<td>Redis 다운 → 캐시 전체 불가 (Fallback 전략 필요)</td>
</tr>
<tr>
<td>운영 복잡도 증가</td>
<td>모니터링, 메모리 정책, Eviction 전략 관리</td>
</tr>
</tbody></table>
<hr>
<h2 id="8-두-캐시를-함께-local--redis-two-level-cache">8. 두 캐시를 함께: Local + Redis (Two-Level Cache)</h2>
<p>성능을 극대화하면서 일관성도 높이는 전략으로 <strong>Two-Level Cache</strong>가 있습니다.</p>
<pre><code>요청
 │
 ▼
[L1: Caffeine (로컬)] ── 히트 → 즉시 반환
 │ 미스
 ▼
[L2: Redis (중앙)] ── 히트 → 로컬 캐시 채우고 반환
 │ 미스
 ▼
[DB] → 결과를 L2, L1에 순서대로 채움</code></pre><p>이 구조는 캐시 히트율을 최대화하면서 인스턴스 간 불일치도 Redis 수준에서 통제할 수 있습니다. 단, 무효화(eviction) 전략이 복잡해지므로, <strong>단순함을 우선시한다면 처음부터 Two-Level을 도입하지 않는 것</strong>을 권장합니다.</p>
<hr>
<h2 id="9-돌아보며-순차적-검증의-가치---결국은-모든-건-트레이드오프다">9. 돌아보며: 순차적 검증의 가치 - 결국은 모든 건 트레이드오프다.</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>선택</th>
<th>얻은 것</th>
</tr>
</thead>
<tbody><tr>
<td>1단계</td>
<td>Caffeine</td>
<td>빠른 도입, 전략 검증, 성능 기준선 확보</td>
</tr>
<tr>
<td>2단계</td>
<td>Redis</td>
<td>수평 확장 지원, 캐시 일관성 확보</td>
</tr>
<tr>
<td>(선택)</td>
<td>Two-Level</td>
<td>최대 성능 + 일관성</td>
</tr>
</tbody></table>
<p>처음부터 Redis를 붙이는 것이 틀린 선택은 아닙니다. 하지만 그것이 <strong>지금 당장 필요한지</strong>를 먼저 물어야 합니다. Caffeine으로 캐시 전략을 검증하고, 한계가 명확해졌을 때 Redis로 이전하는 것이 더 나은 엔지니어링 판단이었다고 생각합니다.</p>
<p><strong>기술 선택은 현재의 문제를 푸는 것이지, 미래의 문제를 미리 가져오는 것이 아닙니다.</strong></p>
<hr>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://github.com/ben-manes/caffeine">Caffeine GitHub</a></li>
<li><a href="https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#cache">Spring Cache Abstraction</a></li>
<li><a href="https://dl.acm.org/doi/10.1145/3149873">W-TinyLFU Paper</a></li>
<li><a href="https://blog.yevgnenll.me/posts/spring-boot-with-caffeine-cache">Spring boot 에 caffeine 캐시를 적용해보자 - 어떻게하면 일을 안 할까?</a></li>
</ul>
<blockquote>
<p>최종 수정일 : 2026-03-19 
(캐시 관련 계속 업데이트하자~!!)</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[ThirdTool 팀의 성능 감시 체크리스트 — 우리가 실제로 시도해오고, 해올 것들(~ing)]]></title>
            <link>https://velog.io/@js-kim-arc/ThirdTool-%ED%8C%80%EC%9D%98-%EC%84%B1%EB%8A%A5-%EA%B0%90%EC%8B%9C-%EC%B2%B4%ED%81%AC%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%9A%B0%EB%A6%AC%EA%B0%80-%EC%8B%A4%EC%A0%9C%EB%A1%9C-%EC%8B%9C%EB%8F%84%ED%95%B4%EC%98%A4%EA%B3%A0-%ED%95%B4%EC%98%AC-%EA%B2%83%EB%93%A4ing</link>
            <guid>https://velog.io/@js-kim-arc/ThirdTool-%ED%8C%80%EC%9D%98-%EC%84%B1%EB%8A%A5-%EA%B0%90%EC%8B%9C-%EC%B2%B4%ED%81%AC%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%9A%B0%EB%A6%AC%EA%B0%80-%EC%8B%A4%EC%A0%9C%EB%A1%9C-%EC%8B%9C%EB%8F%84%ED%95%B4%EC%98%A4%EA%B3%A0-%ED%95%B4%EC%98%AC-%EA%B2%83%EB%93%A4ing</guid>
            <pubDate>Wed, 18 Mar 2026 10:59:58 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/d3619505-4484-4c85-81ec-80ce76c4312e/image.png" alt=""></p>
<h2 id="목차">목차</h2>
<h3 id="📦-application-레벨">📦 Application 레벨</h3>
<ol>
<li><a href="#1-n1-%EB%AC%B8%EC%A0%9C--%EA%B0%80%EC%9E%A5-%EB%A8%BC%EC%A0%80-%ED%99%95%EC%9D%B8%ED%95%98%EB%8A%94-%EA%B2%83%EC%83%9D%EA%B0%81%EB%B3%B4%EB%8B%A4-%EA%B3%84%EC%86%8D-%EC%A0%90%EA%B2%80%ED%95%B4%EC%A4%98%EC%95%BC%ED%95%A8">N+1 문제 — 가장 먼저 확인하는 것</a></li>
<li><a href="#2-pagination--%EC%A0%84%EC%B2%B4-%EC%A1%B0%ED%9A%8C-%EC%B0%A8%EB%8B%A8">Pagination — 전체 조회 차단</a></li>
<li><a href="#3-projection--%ED%95%84%EC%9A%94%ED%95%9C-%EC%BB%AC%EB%9F%BC%EB%A7%8C-%EC%A1%B0%ED%9A%8C">Projection — 필요한 컬럼만 조회</a></li>
<li><a href="#4-%EC%9D%BD%EA%B8%B0-%EC%A0%84%EC%9A%A9-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC">읽기 전용 트랜잭션 분리</a></li>
<li><a href="#5-bulk-insert--update">Bulk Insert / Update</a></li>
<li><a href="#6-%EC%BA%90%EC%8B%B1-%EA%B3%84%EC%B8%B5-%EB%8F%84%EC%9E%85-%EC%88%9C%EC%84%9C">캐싱 계층 도입 순서</a></li>
<li><a href="#7-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC--%ED%95%B5%EC%8B%AC-%EB%A1%9C%EC%A7%81%EA%B3%BC-%EB%B6%80%EA%B0%80-%EB%A1%9C%EC%A7%81-%EB%B6%84%EB%A6%AC">비동기 처리 — 핵심 로직과 부가 로직 분리</a></li>
<li><a href="#8-db-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%A0%84%EB%9E%B5--explain-%EB%A8%BC%EC%A0%80-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%82%98%EC%A4%91%EC%97%90">DB 인덱스 전략 — EXPLAIN 먼저, 부하 테스트 나중에</a></li>
<li><a href="#9-connection-pool-%ED%8A%9C%EB%8B%9D">Connection Pool 튜닝</a></li>
<li><a href="#10-%EC%9D%91%EB%8B%B5-%EC%95%95%EC%B6%95-gzip">응답 압축 (Gzip)</a></li>
<li><a href="#11-dto-%EC%A7%81%EB%A0%AC%ED%99%94-%EC%B5%9C%EC%A0%81%ED%99%94">DTO 직렬화 최적화</a></li>
</ol>
<h3 id="🏗️-infrastructure-레벨">🏗️ Infrastructure 레벨</h3>
<ol start="12">
<li><a href="#12-db-read-replica-%EB%B6%84%EB%A6%AC">DB Read Replica 분리</a></li>
<li><a href="#13-cdn--%EC%A0%95%EC%A0%81-%EB%A6%AC%EC%86%8C%EC%8A%A4-%EB%B6%84%EB%A6%AC">CDN — 정적 리소스 분리</a><h1 id="thirdtool-팀의-성능-감시-체크리스트--우리가-실제로-시도해오고-해올-것들ing">ThirdTool 팀의 성능 감시 체크리스트 — 우리가 실제로 시도해오고, 해올 것들(~ing)</h1>
</li>
</ol>
<blockquote>
<p>이 글은 ThirdTool 서비스를 운영하면서 UX 체감 성능을 개선하기 위해<br>팀 내부에서 정리한 <strong>성능 감시 체크리스트</strong>입니다.<br>처음부터 완벽하게 갖춘 팀은 없어요. 우리도 장애를 겪고, 느린 쿼리를 발견하고, 그때그때 고쳐가며 여기까지 왔어요.</p>
</blockquote>
<hr>
<h2 id="왜-체크리스트가-필요했냐">왜 체크리스트가 필요했냐</h2>
<p>ThirdTool은 간격 반복(Spaced Repetition) 기반의 학습 앱이에요.<br>사용자가 복습 세션에 들어가는 순간 — 오늘의 카드를 불러오고, 복습 결과를 저장하고, 다음 복습 일정을 업데이트하는 흐름이 연속으로 발생해요.</p>
<p>문제는 이 흐름이 느려지면 학습 집중이 끊긴다는 거예요. <strong>성능 = UX</strong> 인 서비스예요.</p>
<p>초반엔 장애가 나면 로그 뒤지고 핫픽스 배포하는 방식으로 버텼는데, 어느 순간부터 <strong>&quot;이게 언제 터질지 모른다&quot;는 불안감</strong> 이 팀 전체에 깔리기 시작했어요.<br>그때부터 팀이 공유할 수 있는 성능 감시 기준을 만들기 시작했습니다.진행하면서 </p>
<hr>
<h2 id="체크리스트-구성">체크리스트 구성</h2>
<p>크게 두 레이어로 나눠서 관리해요.</p>
<pre><code>📦 Application 레벨  →  코드와 쿼리에서 잡는 성능(관측 가능성)
🏗️ Infrastructure 레벨  →  서버와 네트워크에서 잡는 성능(가시성)</code></pre><hr>
<h2 id="✅-application-레벨-체크리스트">✅ Application 레벨 체크리스트</h2>
<h3 id="1-n1-문제--가장-먼저-확인하는-것생각보다-계속-점검해줘야함">1. N+1 문제 — 가장 먼저 확인하는 것(생각보다 계속 점검해줘야함)</h3>
<p>실무에서 슬로우 쿼리의 절반 이상은 N+1이에요. ThirdTool처럼 카드-키워드 관계가 있는 도메인은 특히 조심해야 해요.</p>
<pre><code class="language-java">// ❌ Before: 카드 100개 → 쿼리 101번 발생
cardRepository.findAll()
    .forEach(card -&gt; card.getKeywords().size());

// ✅ After: fetch join으로 쿼리 1번
@Query(&quot;SELECT c FROM Card c JOIN FETCH c.keywords WHERE c.userId = :userId&quot;)
List&lt;Card&gt; findAllWithKeywords(@Param(&quot;userId&quot;) Long userId);

// 또는 컬렉션 N+1엔 @BatchSize
@BatchSize(size = 100)
@OneToMany(mappedBy = &quot;card&quot;)
private List&lt;Keyword&gt; keywords;</code></pre>
<p><strong>확인 방법:</strong> <code>spring.jpa.show-sql=true</code> 로 쿼리 수 직접 세기, 또는 p6spy 라이브러리로 쿼리 로그 분석.</p>
<hr>
<h3 id="2-pagination--전체-조회-차단">2. Pagination — 전체 조회 차단</h3>
<p>카드가 10,000개인 사용자가 복습 목록을 열면 어떻게 될까요? 전부 메모리에 올라가요.</p>
<pre><code class="language-java">// ❌ Before
List&lt;Card&gt; all = cardRepository.findByUserId(userId);

// ✅ After: 무한스크롤엔 Slice (count 쿼리 없어서 더 빠름)
Slice&lt;Card&gt; cards = cardRepository.findByUserId(
    userId, PageRequest.of(page, 20)
);</code></pre>
<blockquote>
<p><strong>Slice vs Page 선택 기준</strong>  </p>
<ul>
<li>무한스크롤 → <code>Slice</code> (전체 개수 불필요)  </li>
<li>페이지 번호 UI → <code>Page</code> (count 쿼리 필요)</li>
</ul>
</blockquote>
<hr>
<h3 id="3-projection--필요한-컬럼만-조회">3. Projection — 필요한 컬럼만 조회</h3>
<p>복습 목록 화면에서 30개 컬럼짜리 Card 엔티티를 전부 꺼낼 필요는 없어요.</p>
<pre><code class="language-java">// ✅ 화면에 필요한 필드만 선언
public interface CardSummary {
    Long getId();
    String getMainNote();
    LocalDate getNextReviewDate();
}

List&lt;CardSummary&gt; cards = cardRepository.findProjectedByUserId(userId);</code></pre>
<p>엔티티 전체 로딩 대비 <strong>DB I/O, 메모리, 직렬화 비용</strong> 모두 줄어요.</p>
<hr>
<h3 id="4-읽기-전용-트랜잭션-분리">4. 읽기 전용 트랜잭션 분리</h3>
<p>조회 메서드에 <code>@Transactional(readOnly = true)</code> 하나만 붙여도 JPA dirty checking이 스킵돼요.</p>
<pre><code class="language-java">// ✅ 조회
@Transactional(readOnly = true)
public List&lt;CardSummary&gt; getTodayCards(Long userId) { ... }

// ✅ 쓰기 (기본값 readOnly=false)
@Transactional
public ReviewResult submitReview(Long cardId, int quality) { ... }</code></pre>
<p>나중에 Read Replica 도입 시 라우팅 기반으로 자연스럽게 연결되는 설계이기도 해요.</p>
<hr>
<h3 id="5-bulk-insert--update">5. Bulk Insert / Update</h3>
<p>복습 결과를 루프로 건별 저장하고 있다면 즉시 바꿔야 해요.</p>
<pre><code class="language-java">// ❌ Before: INSERT 100번
reviews.forEach(r -&gt; reviewRepository.save(r));

// ✅ After: batch로 한 번에
reviewRepository.saveAll(reviews);</code></pre>
<pre><code class="language-yaml"># application.yml — hibernate batch 설정 필수
spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 50
        order_inserts: true
        order_updates: true</code></pre>
<hr>
<h3 id="6-캐싱-계층-도입-순서">6. 캐싱 계층 도입 순서</h3>
<blockquote>
<p><strong>Caffeine (로컬 캐시) → Redis (분산 캐시)</strong> 순서로 도입해요.<br>처음부터 Redis 없어도 Caffeine만으로 조회성 API는 극적으로 개선돼요.</p>
</blockquote>
<pre><code class="language-java">// 오늘의 복습 카드는 자주 바뀌지 않음 → 캐시 적합
@Cacheable(value = &quot;todayCards&quot;, key = &quot;#userId&quot;)
public List&lt;Card&gt; getTodayCards(Long userId) { ... }</code></pre>
<p><strong>캐시 도입 전 체크 포인트:</strong></p>
<ul>
<li>이 데이터가 얼마나 자주 바뀌나?</li>
<li>캐시 무효화(invalidation) 시점이 명확한가?</li>
<li>읽기:쓰기 비율이 높은가?</li>
</ul>
<hr>
<h3 id="7-비동기-처리--핵심-로직과-부가-로직-분리">7. 비동기 처리 — 핵심 로직과 부가 로직 분리</h3>
<p>복습 저장 API가 느리다면, 동기로 묶인 부가 작업이 원인일 수 있어요.</p>
<pre><code class="language-java">// ❌ Before: 복습 저장 + 통계 갱신 + 알림이 한 트랜잭션
public ReviewResult submitReview(Long cardId, int quality) {
    saveReview(cardId, quality);       // 핵심
    updateUserStatistics(userId);      // 부가 → 비동기로 분리
    sendPushNotification(userId);      // 부가 → 비동기로 분리
}

// ✅ After
@Async
public void updateUserStatistics(Long userId) { ... }

@Async
public void sendPushNotification(Long userId) { ... }</code></pre>
<p>응답 시간 = 핵심 로직만의 시간으로 줄어들어요.</p>
<hr>
<h3 id="8-db-인덱스-전략--explain-먼저-부하-테스트-나중에">8. DB 인덱스 전략 — EXPLAIN 먼저, 부하 테스트 나중에</h3>
<blockquote>
<p>Prometheus에 슬로우 쿼리가 잡히기 시작하면 <strong>인덱스가 원인인 경우가 70% 이상</strong> 이에요.<br>부하 테스트 전에 EXPLAIN으로 실행 계획을 반드시 확인하는 습관을 들이세요.</p>
</blockquote>
<pre><code class="language-sql">-- next_review_date 기반 조회가 잦은데 인덱스 없으면 full scan
EXPLAIN SELECT * FROM card
WHERE user_id = ? AND next_review_date &lt;= NOW();

-- 복합 인덱스 추가
CREATE INDEX idx_card_user_review
ON card(user_id, next_review_date);</code></pre>
<p><strong>ThirdTool에서 인덱스 우선순위가 높은 쿼리들:</strong></p>
<ul>
<li><code>user_id + next_review_date</code> 조회 (복습 스케줄)</li>
<li><code>user_id + created_at</code> 조회 (카드 목록)</li>
</ul>
<hr>
<h3 id="9-connection-pool-튜닝">9. Connection Pool 튜닝</h3>
<p>HikariCP 기본값 그대로 쓰면 동시 요청이 몰릴 때 Pool 대기가 병목이 돼요.</p>
<pre><code class="language-yaml">spring:
  datasource:
    hikari:
      maximum-pool-size: 10      # 공식: CPU 코어 수 × 2 + 디스크 수
      minimum-idle: 5
      connection-timeout: 3000   # 3초 후 예외 → 무한 대기 방지
      idle-timeout: 600000</code></pre>
<p><strong>모니터링 신호:</strong> Prometheus에서 <code>hikaricp_connections_pending &gt; 0</code> 이 뜨기 시작하면 pool-size를 올려야 한다는 신호예요.</p>
<hr>
<h3 id="10-응답-압축-gzip">10. 응답 압축 (Gzip)</h3>
<p>설정 3줄로 JSON 응답 크기를 60~80% 줄일 수 있어요.</p>
<pre><code class="language-yaml">server:
  compression:
    enabled: true
    mime-types: application/json
    min-response-size: 1024   # 1KB 이상만 압축</code></pre>
<p>모바일 사용자가 많은 ThirdTool에서 체감 차이가 커요.</p>
<hr>
<h3 id="11-dto-직렬화-최적화">11. DTO 직렬화 최적화</h3>
<pre><code class="language-java">// null 필드를 응답에서 제외
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CardResponse {
    private Long id;
    private String mainNote;
    private String summary;      // null이면 응답에서 제외
    private String keywordCue;   // null이면 응답에서 제외
}</code></pre>
<hr>
<h2 id="✅-infrastructure-레벨-체크리스트">✅ Infrastructure 레벨 체크리스트</h2>
<h3 id="12-db-read-replica-분리">12. DB Read Replica 분리</h3>
<p>ThirdTool의 읽기:쓰기 비율은 대략 <strong>8:2</strong> 예요 (복습 조회가 압도적으로 많음).<br>이 비율이 7:3을 넘으면 Read Replica 분리 효과가 극적으로 나타나요.</p>
<pre><code>Write → Primary DB
Read  → Replica DB (복습 조회, 스케줄 조회, 카드 목록)</code></pre><pre><code class="language-java">// AbstractRoutingDataSource로 readOnly 여부에 따라 자동 분기
public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? &quot;replica&quot; : &quot;primary&quot;;
    }
}</code></pre>
<p>4번 체크리스트(<code>@Transactional(readOnly = true)</code>)와 자연스럽게 연결되는 이유가 여기 있어요.</p>
<hr>
<h3 id="13-cdn--정적-리소스-분리">13. CDN — 정적 리소스 분리</h3>
<pre><code>❌ Before: React 빌드 결과물 → Spring 서버 직접 서빙
✅ After:  React 빌드 결과물 → S3 + CloudFront → CDN 엣지 캐싱

Spring 서버는 API 처리만 담당</code></pre><p>정적 파일 처리 비용이 서버에서 완전히 빠지고, 사용자는 가장 가까운 엣지 서버에서 받아요.</p>
<hr>
<h2 id="팀의-성능-감시-파이프라인-단계별">팀의 성능 감시 파이프라인 (단계별)</h2>
<p>우리 팀이 실제로 밟아온 순서예요. 처음부터 다 갖출 필요는 없어요.</p>
<pre><code>1단계 (지금 당장)
  └─ EXPLAIN으로 슬로우 쿼리 인덱스 확인
  └─ @Transactional(readOnly = true) 적용
  └─ N+1 체크 (show-sql 로그로)

2단계 (사용자 증가 전)
  └─ Caffeine 로컬 캐시 도입
  └─ Pagination 강제 (전체 조회 제거)
  └─ Gzip 압축 활성화

3단계 (부하 테스트 단계)
  └─ Prometheus + Grafana 모니터링 구축
  └─ HikariCP 튜닝 (pending 지표 기반)
  └─ @Async로 비동기 분리

4단계 (스케일업)
  └─ Redis 캐시 도입
  └─ Read Replica 분리
  └─ CDN (S3 + CloudFront)</code></pre><hr>
<h2 id="마치며">마치며</h2>
<p>성능 개선은 &quot;한 번에 다 하는 것&quot;이 아니에요.</p>
<blockquote>
<p><strong>인덱스와 캐싱을 먼저 잡고 → 그다음 부하 테스트.</strong><br>그래야 &quot;인프라 문제 vs 코드 문제&quot;를 깔끔하게 구분할 수 있어요.</p>
</blockquote>
<p>ThirdTool 팀은 이 체크리스트를 PR 리뷰 기준 중 하나로 사용하고 있어요.<br>완벽한 시스템을 처음부터 만드는 것보다, <strong>지금 단계에 맞는 기준을 팀이 공유하는 것</strong> 이 더 중요하다고 생각해요.</p>
<p>다음 편에서는 Prometheus + Grafana 실제 구축 과정을 다뤄볼 예정이에요.</p>
<hr>
<p><em>Tags: <code>#Spring</code> <code>#성능최적화</code> <code>#JPA</code> <code>#ThirdTool</code> <code>#백엔드</code></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[third tool]Classist vs Mockist: Card 도메인 테스트에서 양쪽 다 해본 이야기]]></title>
            <link>https://velog.io/@js-kim-arc/third-toolClassist-vs-Mockist-Card-%EB%8F%84%EB%A9%94%EC%9D%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-%EC%96%91%EC%AA%BD-%EB%8B%A4-%ED%95%B4%EB%B3%B8-%EC%9D%B4%EC%95%BC%EA%B8%B0</link>
            <guid>https://velog.io/@js-kim-arc/third-toolClassist-vs-Mockist-Card-%EB%8F%84%EB%A9%94%EC%9D%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-%EC%96%91%EC%AA%BD-%EB%8B%A4-%ED%95%B4%EB%B3%B8-%EC%9D%B4%EC%95%BC%EA%B8%B0</guid>
            <pubDate>Tue, 17 Mar 2026 10:38:03 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/bee540fc-d2f6-49f3-8d37-a81b1bb2abfe/image.png" alt=""></p>
<h2 id="목차">목차</h2>
<ul>
<li><a href="#%EB%A8%BC%EC%A0%80-%EC%A7%9A%EA%B3%A0-%EA%B0%80%EC%9E%90--test-double%EC%9D%98-%EC%A2%85%EB%A5%98">먼저 짚고 가자 — Test Double의 종류</a></li>
<li><a href="#classist%EC%99%80-mockist--%EB%91%90-%EC%B2%A0%ED%95%99%EC%9D%98-%EC%B0%A8%EC%9D%B4">Classist와 Mockist — 두 철학의 차이</a></li>
<li><a href="#case-a--%EC%88%9C%EC%88%98-%ED%81%B4%EB%9E%98%EC%8A%A4%EB%A1%9C-card-%EB%8F%84%EB%A9%94%EC%9D%B8%EC%9D%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%B4%EC%95%BC%EB%A7%8C-%ED%96%88%EB%8D%98-%EC%9D%B4%EC%9C%A0">Case A — 순수 클래스로 Card 도메인을 테스트해야만 했던 이유</a></li>
<li><a href="#case-b--mock%EC%9D%84-%EB%8F%84%EC%9E%85%ED%95%B4%EB%B4%A4%EB%8D%94%EB%8B%88-%EC%83%9D%EA%B8%B4-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84">Case B — Mock을 도입해봤더니 생긴 트레이드오프</a></li>
<li><a href="#%EB%91%90-%EC%A0%91%EA%B7%BC%EC%9D%98-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84-%EC%A0%95%EB%A6%AC">두 접근의 트레이드오프 정리</a></li>
<li><a href="#%EA%B7%B8%EB%9F%BC-%EC%96%B8%EC%A0%9C-mock%EC%9D%84-%EC%93%B8-%EA%B2%83%EC%9D%B8%EA%B0%80">그럼 언제 Mock을 쓸 것인가</a></li>
<li><a href="#card-%EB%8F%84%EB%A9%94%EC%9D%B8%EC%97%90-%EC%A0%81%EC%9A%A9%ED%95%9C-%EC%B5%9C%EC%A2%85-%EA%B5%AC%EC%A1%B0">Card 도메인에 적용한 최종 구조</a></li>
<li><a href="#%EB%A7%88%EC%B9%98%EB%A9%B0">마치며</a></li>
</ul>
<hr>
<h1 id="classist-vs-mockist-card-도메인-테스트에서-양쪽-다-해본-이야기">Classist vs Mockist: Card 도메인 테스트에서 양쪽 다 해본 이야기</h1>
<blockquote>
<p>태그: <code>Spring Boot</code> <code>Testing</code> <code>DDD</code> <code>JUnit5</code></p>
</blockquote>
<hr>
<p>테스트를 구성하다 보면 항상 같은 고민에 다다릅니다. <strong>실제 객체를 써야 할까, 아니면 Mock을 써야 할까?</strong></p>
<p> &quot;외부 의존성은 Mock하라&quot;고 많이 보지만, 실제 코드베이스 앞에 서면 경계가 생각보다 흐릿합니다. 저는 Cornell Notes에서 영감을 받은 학습 카드 시스템 — <code>Card</code>, <code>MainNote</code>, <code>KeywordCue</code>, <code>Summary</code>로 이루어진 도메인 — 을 설계하면서 이 고민에 대해서도 기록해보기러 했습니다.</p>
<p>이 글은 <strong>순수 클래스 기반 테스트(Classist)를 먼저 적용해보고, 이후 Mock 기반 테스트(Mockist)로 같은 레이어를 다시 짜본 경험</strong>을 바탕으로 씁니다. 둘 다 해봤기 때문에 말할 수 있는 것들이 있습니다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/a8c8e34a-0df8-4331-9217-3367489f3124/image.png" alt=""></p>
<h2 id="먼저-짚고-가자--test-double의-종류">먼저 짚고 가자 — Test Double의 종류</h2>
<p>Mock과 Stub은 종종 같은 의미로 쓰이지만, 엄밀히는 다른 역할을 합니다. Gerard Meszaros의 분류를 기준으로 정리하면:</p>
<table>
<thead>
<tr>
<th>종류</th>
<th>역할</th>
<th>검증 대상</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Dummy</strong></td>
<td>그냥 자리 채우기 — 실제로 쓰이지 않음</td>
<td>없음</td>
</tr>
<tr>
<td><strong>Stub</strong></td>
<td>호출 시 미리 정해진 값을 반환</td>
<td>상태(state)</td>
</tr>
<tr>
<td><strong>Spy</strong></td>
<td>실제 객체처럼 동작하되, 호출 기록을 남김</td>
<td>행동(behavior)</td>
</tr>
<tr>
<td><strong>Mock</strong></td>
<td>기대하는 호출이 실제로 일어났는지 검증</td>
<td>행동(behavior)</td>
</tr>
<tr>
<td><strong>Fake</strong></td>
<td>실제 동작하는 경량 대체 구현체</td>
<td>상태(state)</td>
</tr>
</tbody></table>
<blockquote>
<p>핵심 차이: <strong>Stub은 &quot;이 값을 반환하면 내 로직이 제대로 동작하는가&quot;를 묻고, Mock은 &quot;이 메서드가 이 인자로 호출되었는가&quot;를 묻습니다.</strong> 전자는 상태 검증, 후자는 행동 검증입니다.</p>
</blockquote>
<p>Mock과 stub도 햇갈릴 지점이 많았지만 ,</p>
<hr>
<h2 id="classist와-mockist--두-철학의-차이">Classist와 Mockist — 두 철학의 차이</h2>
<p>테스팅 세계에는 크게 두 관점이 있다는 말을 많이 들었습니다.</p>
<p><strong>Classist (Detroit School)</strong></p>
<ul>
<li>실제 객체를 최대한 사용</li>
<li>협력 객체도 실제로 생성</li>
<li>상태(state) 기반 검증</li>
<li>리팩터링에 강함</li>
<li>대표: Kent Beck, TDD 본류</li>
</ul>
<p><strong>Mockist (London School)</strong></p>
<ul>
<li>협력 객체는 전부 Mock으로 교체</li>
<li>한 번에 하나의 단위만 테스트</li>
<li>행동(behavior) 기반 검증</li>
<li>설계 피드백이 빠름</li>
<li>대표: Steve Freeman, GOOS 저자들</li>
</ul>
<p>어느 쪽이 옳은가에 대한 논쟁은 수십 년째 이어지고 있습니다. 실용적인 답은 의외로 단순합니다 — <strong>테스트 대상이 무엇이냐에 따라 달라집니다.</strong>(결국은 이것도 트레이드오프다!!!)
그렇다면, 결국 어떤 대상을 기준으로 장단점이 있는지 알아봐야합니다!!
(Third tool에서 사용해본 경험을 기반으로 진행하겠습니다!)</p>
<hr>
<h2 id="case-a--순수-클래스로-card-도메인을-테스트해야만-했던-이유">Case A — 순수 클래스로 Card 도메인을 테스트해야만 했던 이유</h2>
<p>Card 도메인의 구조는 다음과 같습니다.</p>
<pre><code class="language-java">// Card는 MainNote, KeywordCue, Summary를 직접 포함합니다.
// @Embeddable — 별도 테이블 없이 card 테이블에 컬럼으로 매핑됩니다.
@Entity
public class Card {
    private MainNote mainNote;           // @Embedded
    private Summary summary;             // @Embedded
    private List&lt;KeywordCue&gt; keywordCues; // @OneToMany orphanRemoval
}

@Embeddable
public class MainNote {
    private String content;
    private MainContentType contentType; // of()에서 자동 결정

    public static MainNote of(String content) {
        return new MainNote(content, MainContentType.resolve(content));
    }
}</code></pre>
<p>여기서 핵심을 짚어봐야 합니다. <code>MainNote</code>, <code>Summary</code>, <code>KeywordCue</code>는 모두 <strong>Value Object</strong>입니다. 별도의 Repository도 없고, 외부 시스템과 통신도 하지 않으며, 그 자체로 완결된 도메인 규칙을 담고 있습니다.(내부 규칙에 대해서도 당연히 테스트가 필요하다.) </p>
<p>이 구조에서 Mock을 도입하려 하면 이런 상황이 됩니다:</p>
<pre><code class="language-java">// ❌ Mock을 넣으려 하면 오히려 어색해집니다 (정말 굳이 필요 없음) 
@Test
void shouldAutoResolveContentType() {
    MainNote mockNote = mock(MainNote.class);
    when(mockNote.getContentType()).thenReturn(MainContentType.CODE);

    // 이 테스트는 무엇을 검증하고 있는 걸까요?
    // MainNote.of()의 resolve 로직? 아니면 Mockito의 when() 동작?
}</code></pre>
<p>Mock을 쓰는 순간 <strong>실제로 검증해야 할 &quot;contentType이 content를 기반으로 자동 결정된다&quot;는 도메인 규칙 자체가 검증 대상에서 빠져버립니다.</strong> Mock은 우리가 기대하는 반환값을 미리 주입하기 때문에, 내부 로직은 우회됩니다.</p>
<p>그래서 Card 도메인 테스트는 자연스럽게 Classist 방향으로 갔습니다:</p>
<pre><code class="language-java">// ✅ 실제 객체로 도메인 규칙을 검증합니다
@Nested
@DisplayName(&quot;MainNote 생성 시&quot;)
class MainNoteCreationTest {

    @Test
    @DisplayName(&quot;코드 패턴이 포함된 content는 CODE 타입으로 자동 결정된다&quot;)
    void resolveToCodeType() {
        MainNote note = MainNote.of(&quot;public class Foo { }&quot;);

        assertThat(note.getContentType())
            .isEqualTo(MainContentType.CODE);
    }

    @Test
    @DisplayName(&quot;일반 텍스트 content는 TEXT 타입으로 결정된다&quot;)
    void resolveToTextType() {
        MainNote note = MainNote.of(&quot;오늘 배운 내용을 정리하면...&quot;);

        assertThat(note.getContentType())
            .isEqualTo(MainContentType.TEXT);
    }
}</code></pre>
<p>이 테스트는 빠르고, 명확하고, 외부 의존성이 전혀 없습니다. <strong>Value Object는 Classist가 압도적으로 유리합니다.</strong> 의존성 자체가 없기 때문에 Mock을 써야 할 이유가 처음부터 없습니다.</p>
<blockquote>
<p><strong>Rule of Thumb</strong>
외부 의존성(네트워크, DB, 파일 시스템)이 없고, 생성 비용이 낮은 도메인 객체라면 → 무조건 실제 객체로 테스트하세요. Mock은 오히려 테스트를 복잡하게 만듭니다.</p>
</blockquote>
<hr>
<h2 id="case-b--mock을-도입해봤더니-생긴-트레이드오프">Case B — Mock을 도입해봤더니 생긴 트레이드오프</h2>
<p>도메인 레이어가 아닌 <strong>Application Service 레이어</strong>로 올라가면 상황이 달라집니다. <code>CardService</code>는 <code>CardRepository</code>에 의존합니다. Repository는 실제로 DB에 접근하고, JPA Proxy가 개입합니다.</p>
<p>처음에는 이 레이어도 실제 객체로 통합 테스트를 짰습니다:</p>
<pre><code class="language-java">// Case B-1: 실제 Repository를 사용한 슬라이스 테스트
@DataJpaTest
@Import(CardService.class)
class CardServiceClassistTest {

    @Autowired private CardService cardService;
    @Autowired private CardRepository cardRepository;

    @Test
    void createCard() {
        CreateCardCommand cmd = new CreateCardCommand(&quot;노트 내용&quot;, ...);
        Card saved = cardService.createCard(cmd);

        assertThat(saved.getId()).isNotNull();
        // DB까지 실제로 저장되었음을 검증
    }
}</code></pre>
<p>이 방식은 신뢰도가 높습니다. 하지만 테스트가 늘어나자 몇 가지 문제가 보이기 시작했습니다. 그래서 같은 서비스를 Mock 기반으로 다시 작성해봤습니다:</p>
<pre><code class="language-java">// Case B-2: Repository를 Mock한 단위 테스트
@ExtendWith(MockitoExtension.class)
class CardServiceMockistTest {

    @Mock
    private CardRepository cardRepository;

    @InjectMocks
    private CardService cardService;

    @Test
    @DisplayName(&quot;카드 생성 시 Repository의 save가 호출된다&quot;)
    void createCardCallsSave() {
        Card expected = Card.create(...);
        when(cardRepository.save(any())).thenReturn(expected);

        cardService.createCard(cmd);

        verify(cardRepository, times(1)).save(any(Card.class));
    }
}</code></pre>
<h3 id="그런데-이-테스트-뭘-검증하는-건가요">그런데 이 테스트, 뭘 검증하는 건가요?</h3>
<p>Mock 기반 서비스 테스트를 작성하면서 불편한 감각이 들었습니다. <code>verify(cardRepository, times(1)).save(any())</code>는 <strong>&quot;save가 한 번 호출됐는지&quot;를 검증합니다. 이건 구현 세부사항입니다.</strong></p>
<p>나중에 내부 구현을 바꿨습니다 — 예를 들어 <code>saveAndFlush()</code>로 변경하거나, 배치 저장을 위해 <code>saveAll()</code>로 리팩터링하면, <strong>비즈니스 로직은 전혀 바뀌지 않았는데 테스트가 빨간불로 바뀝니다.</strong></p>
<blockquote>
<p>⚠️ <strong>Mock 기반 행동 검증의 함정</strong>
Mock으로 내부 호출을 <code>verify()</code>하는 테스트는 구현 변경에 취약합니다. &quot;올바른 동작&quot;이 아닌 &quot;특정 메서드가 호출됨&quot;을 단언하기 때문입니다. 리팩터링 시 테스트 유지 비용이 급격히 올라갑니다.(리팩터링도 좋은 테스팅의 중요한 요소라고 생각이 든다...) </p>
</blockquote>
<p>반면 Classist 방식의 슬라이스 테스트는 <code>cardRepository.findById(id)</code>로 실제 DB에서 꺼내 상태를 검증했기 때문에, 내부 save 방식이 바뀌어도 테스트는 초록불을 유지했습니다.(Class를 직접 사용하는 것의 큰 장점은 굳이 유지 보수로 코드가 변하더라도 테스트 코드의 유지보수 비용이 비싸지 않다는 점) </p>
<hr>
<h2 id="두-접근의-트레이드오프-정리">두 접근의 트레이드오프 정리</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Classist (실제 객체)</th>
<th>Mockist (Mock 사용)</th>
</tr>
</thead>
<tbody><tr>
<td>테스트 속도</td>
<td>도메인 레이어: 빠름 / 슬라이스: 보통</td>
<td>항상 빠름</td>
</tr>
<tr>
<td>리팩터링 내성</td>
<td>강함 — 상태 기반 검증</td>
<td>약함 — 구현 세부사항에 종속</td>
</tr>
<tr>
<td>설계 피드백</td>
<td>늦게 드러남</td>
<td>빠름 — 의존성이 명시적으로 강제됨</td>
</tr>
<tr>
<td>외부 의존성 격리</td>
<td>복잡 (테스트 컨테이너 필요)</td>
<td>간단</td>
</tr>
<tr>
<td>Value Object 테스트</td>
<td>최적</td>
<td>의미 없음 (Mock하면 로직이 우회됨)</td>
</tr>
<tr>
<td>Repository 레이어</td>
<td>@DataJpaTest 슬라이스 권장</td>
<td>단위 테스트에 유용</td>
</tr>
<tr>
<td>외부 API / 네트워크</td>
<td>느리고 불안정</td>
<td>Mock이 명확한 정답</td>
</tr>
</tbody></table>
<hr>
<h2 id="그럼-언제-mock을-쓸-것인가">그럼 언제 Mock을 쓸 것인가</h2>
<p>두 방식을 모두 써보고 나서 제가 내린 결론은 이렇습니다.</p>
<h3 id="mock이-확실히-정답인-경우">Mock이 확실히 정답인 경우</h3>
<ul>
<li><strong>외부 인프라와의 통신</strong> — HTTP 클라이언트, 이메일 발송, 결제 API, S3 파일 업로드. 이런 건 실제로 호출하면 느리고 비용이 들고 사이드 이펙트가 있습니다.</li>
<li><strong>시간/난수처럼 비결정적인 값</strong> — <code>Clock</code>을 Mock하거나 인터페이스로 주입받아야 테스트가 반복 가능해집니다.</li>
<li><strong>에러 케이스 시뮬레이션</strong> — 네트워크 타임아웃, DB 연결 실패 등은 실제 환경에서 재현하기 어렵습니다.</li>
</ul>
<h3 id="실제-객체가-훨씬-나은-경우">실제 객체가 훨씬 나은 경우</h3>
<ul>
<li><strong>Value Object, Entity의 도메인 로직</strong> — 외부 의존성이 없으므로 Mock이 오히려 노이즈입니다.</li>
<li><strong>Repository 레이어</strong> — <code>@DataJpaTest</code>로 H2 + 실제 JPA를 쓰면 N+1 문제, dirty checking, cascade 동작까지 검증할 수 있습니다. Mock Repository는 이런 걸 검증하지 못합니다.</li>
<li><strong>도메인 서비스의 비즈니스 로직</strong> — 협력 객체가 모두 도메인 객체라면 실제 인스턴스를 사용하세요.</li>
</ul>
<blockquote>
<p>💡 <strong>실용적인 기준선</strong>
<strong>경계(boundary)</strong>를 기준으로 나눠보세요. 도메인 내부 — Value Object, Entity, Domain Service — 는 Classist로. 도메인 외부 — 외부 API, 이메일, 파일 스토리지 — 는 Mock으로. Repository는 <code>@DataJpaTest</code> 슬라이스가 둘의 중간 지점에서 균형을 잡아줍니다.</p>
</blockquote>
<hr>
<h2 id="card-도메인에-적용한-최종-구조">Card 도메인에 적용한 최종 구조</h2>
<p>결국 저는 이런 방식으로 레이어별 전략을 나눴습니다:</p>
<pre><code>// 1. 도메인 레이어 → 순수 클래스 테스트 (Classist)
//    Card, MainNote, KeywordCue, Summary — @Embeddable Value Objects
//    외부 의존성 없음 → Mock이 필요한 이유 자체가 없음
CardTest            // Card 생성, 수정, 불변 규칙
MainNoteTest        // content → contentType 자동 결정 로직
KeywordCueTest      // 키워드 최대 개수 등 도메인 규칙

// 2. Repository 레이어 → @DataJpaTest 슬라이스 (Classist + Spring Test)
//    Fetch Join, dirty checking, orphanRemoval, QueryDSL 동적 검색
CardRepositoryTest

// 3. Application Service → Mock (Mockist)
//    단, verify()는 최소화. 가능하면 반환값의 상태 검증으로 대체
CardServiceTest

// 4. 외부 인프라 (S3, 이메일) → Mock이 명확한 정답
ImageStorageServiceTest</code></pre><hr>
<h2 id="마치며">마치며</h2>
<p>Classist vs Mockist는 이분법이 아닙니다. <strong>&quot;이 테스트 대상이 외부와 어떻게 연결되어 있는가&quot;를 먼저 묻는 것</strong>이 출발점입니다.</p>
<p>Card 도메인에서 얻은 가장 큰 교훈은 이겁니다. Value Object는 Mock할 이유가 없습니다. 도메인의 핵심 규칙 — <code>contentType</code>이 어떻게 결정되는지, <code>KeywordCue</code>가 몇 개까지 허용되는지 — 은 실제 객체를 통해 검증해야만 의미가 있습니다. Mock을 넣는 순간 그 로직 자체가 테스트에서 빠져버립니다.</p>
<p>반면 외부 네트워크, 파일 스토리지, 시간처럼 비결정적이거나 비용이 따르는 것들은 Mock이 명확한 답입니다. 실제 S3를 테스트에서 호출하는 건 느리고, 비용이 들고, CI 환경에서 깨지기 쉽습니다.</p>
<p>Mock은 도구입니다. 경계를 알고 쓰면 테스트가 간결해지고, 모르고 남발하면 리팩터링마다 테스트를 다시 짜야 하는 악순환에 빠집니다. 두 방식 모두 직접 써보는 것 — 그게 가장 빠른 길이었습니다.</p>
<hr>
<p><em>참고: Martin Fowler — Mocks Aren&#39;t Stubs / Steve Freeman, Nat Pryce — Growing Object-Oriented Software, Guided by Tests</em></p>
<blockquote>
<p>최종 수정일: 2026-03-17</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third tool]개발자가 설계하는 정보 아키텍처: 읽기 좋은 코드를 만드는 원칙]]></title>
            <link>https://velog.io/@js-kim-arc/%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EC%84%A4%EA%B3%84%ED%95%98%EB%8A%94-%EC%A0%95%EB%B3%B4-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%9D%BD%EA%B8%B0-%EC%A2%8B%EC%9D%80-%EC%BD%94%EB%93%9C%EB%A5%BC-%EB%A7%8C%EB%93%9C%EB%8A%94-14%EA%B0%80%EC%A7%80-%EC%9B%90%EC%B9%99</link>
            <guid>https://velog.io/@js-kim-arc/%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EC%84%A4%EA%B3%84%ED%95%98%EB%8A%94-%EC%A0%95%EB%B3%B4-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%9D%BD%EA%B8%B0-%EC%A2%8B%EC%9D%80-%EC%BD%94%EB%93%9C%EB%A5%BC-%EB%A7%8C%EB%93%9C%EB%8A%94-14%EA%B0%80%EC%A7%80-%EC%9B%90%EC%B9%99</guid>
            <pubDate>Fri, 13 Mar 2026 10:37:46 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/1e39f9f1-f629-46df-875f-cb31ff745744/image.png" alt=""></p>
<h1 id="readable-code를-위해-내가-더-붙이고-싶은-이야기">readable code를 위해 내가 더 붙이고 싶은 이야기</h1>
<h2 id="읽고-싶은-부분만-읽으세요">읽고 싶은 부분만 읽으세요~!!</h2>
<blockquote>
<ul>
<li><a href="#1-%EC%BD%94%EB%93%9C%EB%8A%94-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%82%AC%EB%9E%8C%EA%B0%80-%EC%9D%BD%EC%9C%BC%EB%9D%BC%EA%B3%A0-%EB%A7%8C%EB%93%A4%EC%96%B4%EC%A7%84-%EA%B2%83%EC%9D%B4%EB%8B%A4">1. 코드는 개발자(사람)가 읽으라고 만들어진 것이다.</a></li>
</ul>
</blockquote>
<ul>
<li><a href="#2-%ED%8C%A8%ED%82%A4%EC%A7%80-%EB%82%98%EB%88%84%EA%B8%B0--%EC%BD%94%EB%93%9C%EC%9D%98-%EC%A0%95%EB%B3%B4-%EC%A1%B0%EC%A7%81%ED%99%94%EC%A0%95%EB%B3%B4-%EC%A1%B0%EC%A7%81%ED%99%94%EC%B2%B4%EA%B3%84%EC%9D%98-%EC%8B%9C%EC%9E%91">2. 패키지 나누기 = 코드의 정보 조직화(정보 조직화체계의 시작)</a></li>
<li><a href="#3-%EC%82%AC%EA%B3%A0%EC%9D%98-depth-%EC%A4%84%EC%9D%B4%EA%B8%B0--%EB%A0%88%EC%9D%B4%EC%96%B4%EB%A5%BC-%EC%A4%84%EC%9D%B4%EB%8A%94-%EA%B2%83%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%B6%94%EB%A1%A0%EC%9D%98-%EB%8B%A8%EA%B3%84%EB%A5%BC-%EC%A4%84%EC%9D%B4%EB%8A%94-%EA%B2%83">3. 사고의 depth 줄이기 = 레이어를 줄이는 것이 아니라, 추론의 단계를 줄이는 것</a></li>
<li><a href="#4-%EB%B6%80%EC%A0%95%EC%96%B4-%EC%A4%84%EC%9D%B4%EA%B8%B0--%EB%87%8C%EA%B0%80-%ED%95%9C-%EB%B2%88-%EB%8D%94-%EB%92%A4%EC%A7%91%EC%A7%80-%EC%95%8A%EA%B2%8C-%ED%95%98%EA%B8%B0%EC%83%9D%EA%B0%81%EB%B3%B4%EB%8B%A4%EC%9D%98-%EA%BF%80%ED%8C%81">4. 부정어 줄이기 = 뇌가 한 번 더 뒤집지 않게 하기(생각보다의 꿀팁)</a></li>
<li><a href="#5-%EB%B9%84%EC%8A%B7%ED%95%9C-%EA%B2%83%EB%93%A4%EC%9D%80-%EA%B7%BC%EC%B2%98%EC%97%90-%EB%91%90%EA%B8%B0">5. 비슷한 것들은 근처에 두기</a></li>
<li><a href="#6-readable-code%EB%8A%94-%EA%B3%A7-information-architecture%EB%8B%A4">6. readable code는 곧 Information Architecture다</a></li>
<li><a href="#7-%EC%82%AC%EC%9A%A9%EC%9E%90-%ED%86%A0%EA%B8%80%EB%B0%95%EC%8A%A4%EC%B2%98%EB%9F%BC-%EC%BD%94%EB%93%9C%EB%8F%84-%ED%95%84%EC%9A%94%ED%95%9C-%EB%A7%8C%ED%81%BC%EB%A7%8C-%EB%B3%B4%EC%9D%B4%EA%B2%8C-%ED%95%98%EB%9D%BC-%EC%B5%9C%EC%8B%A0-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8">7. 사용자 토글박스처럼, 코드도 필요한 만큼만 보이게 하라 (최신 업데이트)</a><ul>
<li><a href="#7-1-%ED%86%A0%EA%B8%80%EB%B0%95%EC%8A%A4%EC%9D%98-%EC%9E%AC%EB%B0%9C%EA%B2%AC---%EC%A0%95%EB%B3%B4-%ED%94%BC%EB%A1%9C%EA%B0%90%EC%9D%98-%EA%B0%90%EC%86%8C-%ED%9A%A8%EA%B3%BC">7-1. 토글박스의 재발견 - 정보 피로감의 감소 효과</a></li>
</ul>
</li>
<li><a href="#8-intellij%EC%9D%98-%EC%A0%95%EB%B3%B4-%EC%A0%91%EA%B7%BC-%EC%84%A4%EA%B3%84-good">8. IntelliJ의 정보 접근 설계 (Good)</a></li>
<li><a href="#9-spring-test%EC%9D%98-nested%EB%8F%84-%EC%A0%95%EB%B3%B4-%EA%B5%AC%EC%A1%B0%ED%99%94-%EA%B4%80%EC%A0%90%EC%97%90%EC%84%9C-%EB%B3%BC-%EC%88%98-%EC%9E%88%EB%8B%A4-2026-03-13">9. Spring Test의 Nested도 정보 구조화 관점에서 볼 수 있다 (2026-03-13)</a><ul>
<li><a href="#9-1-%ED%86%A0%EA%B8%80%EB%B0%95%EC%8A%A4%EC%9D%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9C%A0%EC%82%AC%EC%84%B1">9-1. 토글박스의 테스트 유사성</a></li>
</ul>
</li>
<li><a href="#10-%EC%A0%95%EB%B3%B4-%EC%A0%91%EA%B7%BC%EC%9D%98-%EC%84%A4%EA%B3%84--%EC%97%B0%EA%B4%80%EB%90%9C-%EC%A0%95%EB%B3%B4%EB%93%A4%EC%9D%B4-%EB%B0%94%EB%A1%9C-%EC%9D%B4%EB%8F%99%ED%95%A0-%EC%88%98-%EC%9E%88%EA%B2%8C-%ED%95%98%EA%B8%B0">10. 정보 접근의 설계 = 연관된 정보들이 바로 이동할 수 있게 하기</a></li>
<li><a href="#11-%EC%9D%B4%EB%A6%84-%EC%A7%93%EA%B8%B0%EB%8F%84-%EA%B2%B0%EA%B5%AD-%EC%A0%95%EB%B3%B4-%EC%84%A4%EA%B3%84%EB%8B%A4">11. 이름 짓기도 결국 정보 설계다</a></li>
<li><a href="#12-%EC%A3%BC%EC%84%9D%EB%B3%B4%EB%8B%A4-%EA%B5%AC%EC%A1%B0%EA%B0%80-%EB%A8%BC%EC%A0%80%EB%8B%A4">12. 주석보다 구조가 먼저다</a></li>
<li><a href="#13-readable-code%EB%8A%94-%ED%98%91%EC%97%85-%EB%B9%84%EC%9A%A9%EC%9D%84-%EC%A4%84%EC%9D%B4%EB%8A%94-%EC%BD%94%EB%93%9C%EB%8B%A4">13. readable code는 협업 비용을 줄이는 코드다</a></li>
<li><a href="#14-%EB%82%B4%EA%B0%80-%EC%83%9D%EA%B0%81%ED%95%98%EB%8A%94-readable-code%EC%9D%98-%ED%95%B5%EC%8B%AC-%EC%9B%90%EC%B9%99">14. 내가 생각하는 readable code의 핵심 원칙</a></li>
<li><a href="#15-%EA%B2%B0%EB%A1%A0">15. 결론</a></li>
</ul>
<h1 id="readable-code를-위해-내가-더-붙이고-싶은-이야기-1">readable code를 위해 내가 더 붙이고 싶은 이야기</h1>
<p>코드는 결국 기계가 실행하지만, 먼저 사람의 머리에서 해석된다.<br>그래서 readable code의 본질은 “문법이 맞는 코드”가 아니라, <strong>읽는 사람이 빠르게 구조를 파악하고, 덜 헷갈리고, 덜 실수하게 만드는 코드</strong>에 있다.</p>
<p>나는 readable code를 단순히 “예쁘게 쓰는 것”으로 보지 않는다.<br>오히려 이것은 <strong>정보를 어떻게 배치하고, 어떤 순서로 드러내고, 어디까지 한 번에 보여줄 것인가를 설계하는 일</strong>에 가깝다.<br>즉, readable code는 코딩 스킬만의 문제가 아니라, 정보 설계와 인지 부하 관리의 문제다.(개인적으로 너무 중요하다고 생각)</p>
<hr>
<h2 id="1-코드는-개발자사람가-읽으라고-만들어진-것이다">1. 코드는 개발자(사람)가 읽으라고 만들어진 것이다.</h2>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/ffb3409e-0bf0-46ad-941b-e1d8a9fdf175/image.png" alt=""></p>
<p>컴퓨터는 변수명이 <code>a</code>든 <code>paymentRetryLimitExceededFlag</code>든 똑같이 실행한다.<br>하지만 사람은 다르다. 사람은 이름을 보고 맥락을 추측하고, 구조를 보고 책임을 유추하고, 흐름을 따라가며 의도를 이해한다.</p>
<p>그래서 코드를 작성할 때 가장 먼저 던져야 하는 질문은 이것이다.</p>
<ul>
<li>이 코드를 처음 보는 사람이 어디서부터 읽기 시작할까?</li>
<li>몇 번이나 다시 올라가서 확인해야 할까?</li>
<li>읽는 도중 “이건 왜 여기 있지?”라는 멈춤이 생기지 않을까?</li>
<li>수정하려는 사람이 안전하게 바꿀 수 있을까?</li>
</ul>
<p>readable code는 결국<br><strong>읽는 속도를 높이고</strong>,  
<strong>판단 실수를 줄이고</strong>,  
<strong>수정 비용을 낮추고</strong>,  
<strong>협업 과정에서 오해를 줄이는 코드</strong>다.(계속 유의해야하는 지점이라고 할 수 있다.)</p>
<hr>
<h2 id="2-패키지-나누기--코드의-정보-조직화정보-조직화체계의-시작">2. 패키지 나누기 = 코드의 정보 조직화(정보 조직화체계의 시작)</h2>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/9ec744f1-20f4-4c3a-9391-4208d9ef778a/image.png" alt=""></p>
<p>패키지를 나눈다는 것은 단순히 파일을 흩어놓는 일이 아니다.<br>그건 곧 <strong>어떤 관점으로 시스템을 이해하게 만들 것인가</strong>를 정하는 일이다.</p>
<p>패키징은 일종의 정보 아키텍처다.</p>
<p>예를 들어 패키지를 기술 중심으로 나누면 다음처럼 보일 수 있다.</p>
<ul>
<li>controller</li>
<li>service</li>
<li>repository</li>
<li>dto</li>
<li>entity</li>
</ul>
<p>이 방식은 익숙하고 시작하기 쉽다.<br>하지만 기능이 커질수록 한 기능을 따라가려면 여러 패키지를 계속 이동해야 한다.<br>즉, 읽는 사람은 “한 기능의 전체 이야기”를 머릿속에서 직접 조립해야 한다.</p>
<p>반대로 도메인이나 기능 중심으로 나누면 다음과 같이 바뀔 수 있다.</p>
<ul>
<li>card<ul>
<li>api</li>
<li>application</li>
<li>domain</li>
<li>infrastructure</li>
</ul>
</li>
<li>deck<ul>
<li>api</li>
<li>application</li>
<li>domain</li>
<li>infrastructure</li>
</ul>
</li>
</ul>
<p>이 구조는 관련된 것들을 가까이에 둔다.<br>즉, <strong>함께 바뀌는 것들</strong>, <strong>함께 이해해야 하는 것들</strong>, <strong>함께 테스트해야 하는 것들</strong>을 근처에 묶는다.</p>
<p>좋은 패키징은 이런 효과를 만든다.</p>
<ul>
<li>찾는 시간이 줄어든다</li>
<li>관련 맥락이 한 곳에 모인다</li>
<li>수정 시 영향 범위를 예측하기 쉬워진다</li>
<li>“이 책임이 여기 맞나?”를 더 잘 판단하게 된다</li>
</ul>
<p>결국 패키징은 폴더 정리가 아니라<br><strong>사람의 사고 경로를 설계하는 일</strong>이다. -&gt; 더 좋은 패키징 방식은 계속해서 고민해봐야할 것 같다.
(코드의 확장의 속도를 억제하고 적정한 코드가 들어갈 수 있는 컨셉을 기준으로) </p>
<hr>
<h2 id="3-사고의-depth-줄이기--레이어를-줄이는-것이-아니라-추론의-단계를-줄이는-것">3. 사고의 depth 줄이기 = 레이어를 줄이는 것이 아니라, 추론의 단계를 줄이는 것</h2>
<p>많은 사람이 readable code를 이야기할 때 “메서드를 짧게”, “클래스를 작게”만 말한다.<br>하지만 실제로 더 중요한 것은 <strong>읽는 사람이 몇 단계나 추론해야 하는가</strong>다.
-&gt; 사고의 depth가 깊어지는 순간 정보 피로감이 확 와버린다.</p>
<p>예를 들어 어떤 기능을 이해하려고 할 때</p>
<ul>
<li>Controller<ul>
<li>Facade</li>
</ul>
</li>
<li>Service<ul>
<li>DomainService<ul>
<li>Helper</li>
<li>Util</li>
<li>Strategy</li>
<li>Validator</li>
<li>Mapper</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>이렇게 계속 타고 들어가야 한다면, 각 클래스가 아무리 깔끔해도 읽는 사람은 지친다.<br>왜냐하면 코드 이해는 단순히 “한 줄 읽기”가 아니라, <strong>맥락을 메모리에 붙잡고 이동하는 일</strong>이기 때문이다.</p>
<p>레이어가 많아질수록 어려운 이유는 단순하다.</p>
<ul>
<li>현재 맥락을 기억해야 하고</li>
<li>이전 호출 지점을 떠올려야 하고</li>
<li>지금 보고 있는 로직이 진짜 핵심인지 보조인지 구분해야 하고</li>
<li>다시 원래 위치로 돌아와 흐름을 이어야 하기 때문이다</li>
</ul>
<p>그래서 readable code는 무조건 레이어를 줄이자는 말이 아니다.<br>핵심은 이것이다.</p>
<blockquote>
<p>추상화는 필요하지만, 읽는 사람이 한 번에 붙잡아야 하는 맥락의 수를 늘리면 안 된다.</p>
</blockquote>
<p>좋은 구조는 깊이가 아예 없는 구조가 아니라,<br><strong>들어가야 할 깊이가 자연스럽고, 들어간 만큼 얻는 정보가 분명한 구조</strong>다.</p>
<hr>
<h2 id="4-부정어-줄이기--뇌가-한-번-더-뒤집지-않게-하기생각보다의-꿀팁">4. 부정어 줄이기 = 뇌가 한 번 더 뒤집지 않게 하기(생각보다의 꿀팁)</h2>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/7891a9d5-5831-4264-a565-8ec09faee3b3/image.png" alt=""></p>
<p>부정어는 생각보다 피로하다.<br>사람은 긍정을 이해한 뒤, 그걸 뒤집어서 부정을 해석하는 경우가 많다.</p>
<p>예를 들어 아래 둘은 의미가 비슷하지만 읽는 감각은 다르다.</p>
<ul>
<li><code>if (!isNotAvailable())</code></li>
<li><code>if (isAvailable())</code></li>
</ul>
<p>첫 번째는 머릿속에서 두 번 뒤집어야 한다.<br>이 과정은 짧아 보여도, 반복되면 읽는 리듬을 깨뜨린다.</p>
<p>부정어가 특히 위험한 곳은 다음과 같다.</p>
<ul>
<li>boolean 변수명</li>
<li>조건문</li>
<li>early return</li>
<li>validation 실패 조건</li>
<li>예외 메시지와 정책 판단 로직</li>
</ul>
<p>예를 들면</p>
<ul>
<li><code>isNotValid</code></li>
<li><code>hasNoPermission</code></li>
<li><code>cannotCreate</code></li>
<li><code>isUnavailable</code></li>
<li><code>notFound == false</code></li>
</ul>
<p>이런 표현은 문법적으로 맞더라도 읽는 흐름을 자주 끊는다.</p>
<p>더 좋은 방향은 보통 아래와 같다.</p>
<ul>
<li><code>isValid</code></li>
<li><code>hasPermission</code></li>
<li><code>canCreate</code></li>
<li><code>isAvailable</code></li>
<li><code>exists</code></li>
</ul>
<p>그리고 분기문은 가능한 한 <strong>정상 흐름을 자연스럽게 읽히게</strong> 만드는 편이 좋다.</p>
<p>즉 readable code는 단순히 참/거짓을 표현하는 것이 아니라,<br><strong>사람이 판단을 뒤집지 않고 곧바로 이해하게 만드는 표현</strong>을 선택하는 것이다.</p>
<hr>
<h2 id="5-비슷한-것들은-근처에-두기">5. 비슷한 것들은 근처에 두기</h2>
<p>읽기 쉬운 구조의 핵심 중 하나는<br><strong>관련된 정보가 멀리 흩어져 있지 않다</strong>는 점이다.</p>
<p>이건 코드에서도 그대로 적용된다.</p>
<h3 id="가까이-두면-좋은-것들">가까이 두면 좋은 것들</h3>
<ul>
<li>함께 바뀌는 필드와 메서드</li>
<li>같은 규칙을 검증하는 코드들</li>
<li>하나의 유즈케이스를 완성하는 객체들</li>
<li>같은 테스트 맥락에 속한 테스트들</li>
<li>같은 도메인 언어를 사용하는 클래스들</li>
</ul>
<p>멀리 떨어져 있으면 어떤 일이 생기냐면,<br>사람은 계속 “이거랑 관련된 게 어디 있었지?”를 떠올려야 한다.<br>이건 집중력을 코드 자체가 아니라 탐색에 소비하게 만든다.</p>
<p>그래서 readable code는 정리의 감각이 중요하다.</p>
<ul>
<li>생성 관련 로직은 생성 주변에</li>
<li>상태 변경 규칙은 상태 변경 메서드 주변에</li>
<li>validation은 validation끼리</li>
<li>mapping은 mapping끼리</li>
<li>테스트 fixture는 테스트가 쓰는 곳 가까이에</li>
</ul>
<p>즉, “비슷한 것들을 근처에 둔다”는 것은 미적 감각의 문제가 아니라<br><strong>검색 비용과 맥락 전환 비용을 줄이는 전략</strong>이다.</p>
<hr>
<h2 id="6-readable-code는-곧-information-architecture다">6. readable code는 곧 Information Architecture다</h2>
<p>나는 readable code를 설명할 때 종종 웹이나 앱의 정보 구조와 비슷하다고 본다.</p>
<p>좋은 서비스는 첫 화면에서 모든 정보를 한 번에 때려 넣지 않는다.<br>대신 가장 중요한 것만 먼저 보여주고, 필요한 사람만 더 깊이 들어가게 만든다.</p>
<p>코드도 마찬가지다.</p>
<h3 id="코드에서의-정보-아키텍처란">코드에서의 정보 아키텍처란</h3>
<ul>
<li>가장 중요한 흐름이 위에 보이게 하기</li>
<li>세부 구현은 아래나 내부로 밀어두기</li>
<li>public API는 단순하게 유지하기</li>
<li>내부 구현 세부사항은 감추기</li>
<li>이름만 읽어도 역할과 방향이 드러나게 하기</li>
</ul>
<p>즉, 좋은 코드는<br><strong>모든 것을 숨기는 코드</strong>가 아니라,<br><strong>읽는 순서에 맞게 계층적으로 드러내는 코드</strong>다.</p>
<hr>
<h2 id="7-사용자-토글박스처럼-코드도-필요한-만큼만-보이게-하라-최신-업데이트">7. 사용자 토글박스처럼, 코드도 필요한 만큼만 보이게 하라 (최신 업데이트)</h2>
<h3 id="토글박스의-재발견---정보-피로감의-감소-효과">토글박스의 재발견 - 정보 피로감의 감소 효과</h3>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/92a1f4be-4fbb-4ffc-91f1-4290d60cd598/image.png" alt=""></p>
<p>UI에서 토글박스나 아코디언은 왜 좋을까?<br>처음에는 핵심만 보여주고, 궁금한 사람만 펼쳐보게 만들기 때문이다.
그릭고 더 중요한 것은 핵심들만 보고 사용자가 원하는 글을 선택해서 볼 수 있게 해준다는 점이다.</p>
<p>코드도 이 원리가 그대로 적용된다.</p>
<p>예를 들어 좋은 메서드는 종종 이런 느낌을 가진다.</p>
<ul>
<li>메서드 이름만 봐도 큰 흐름이 보인다</li>
<li>내부로 들어가면 세부 구현이 있다</li>
<li>더 들어가면 예외 처리나 기술 상세가 있다</li>
</ul>
<p>이건 마치 문서를 접어두고 펼치는 것과 비슷하다.<br>즉, 코드는 한 번에 다 보여주는 것이 아니라 <strong>단계적으로 열리게</strong> 만드는 편이 좋다.</p>
<p>이 관점에서 좋은 코드의 특징은 다음과 같다.</p>
<ul>
<li>상위 메서드는 “무엇을 하는지” 중심으로 쓴다</li>
<li>하위 메서드는 “어떻게 하는지”를 담는다</li>
<li>구현 디테일은 핵심 흐름을 가리지 않게 분리한다</li>
<li>하지만 너무 잘게 쪼개서 오히려 흐름이 안 보이지 않게 한다</li>
</ul>
<p>핵심은 숨김이 아니라 <strong>점진적 노출</strong>이다.</p>
<hr>
<h2 id="8-intellij의-정보-접근-설계-good">8. IntelliJ의 정보 접근 설계 (Good)</h2>
<p>우리가 IntelliJ를 편하게 느끼는 이유는 단순히 자동완성 때문만이 아니다.<br>진짜 강점은 <strong>연관된 정보를 빠르게 이동하게 해준다</strong>는 점에 있다.</p>
<p>예를 들면</p>
<ul>
<li>선언부로 바로 이동</li>
<li>구현체 찾기</li>
<li>사용처 찾기</li>
<li>테스트와 구현 코드 왕복</li>
<li>구조 보기</li>
<li>파일/클래스/심볼 빠른 검색</li>
<li>리팩토링 시 참조 일관성 유지</li>
</ul>
<p>이건 다 정보 접근성의 문제다.</p>
<p>좋은 코드도 비슷해야 한다.</p>
<ul>
<li>이름만 봐도 다음 위치가 예측되어야 하고</li>
<li>관련 코드가 비슷한 위치에 있어야 하며</li>
<li>찾았을 때 구조가 납득 가능해야 한다</li>
<li>이동했을 때 “왜 여기에 있지?”가 적어야 한다</li>
</ul>
<p>즉, readable code는 단지 “문장을 예쁘게 쓰는 일”이 아니라<br><strong>탐색 가능한 구조를 만드는 일</strong>이다.</p>
<hr>
<h2 id="9-spring-test의-nested도-정보-구조화-관점에서-볼-수-있다-2026-03-13">9. Spring Test의 Nested도 정보 구조화 관점에서 볼 수 있다 (2026-03-13)</h2>
<h3 id="토글박스의-테스트-유사성">토글박스의 테스트 유사성</h3>
<p><code>@Nested</code> 테스트가 읽기 좋은 이유도 문법적 예쁨 때문만은 아니다.<br>그것은 테스트를 <strong>맥락별로 접어서 보여주기 때문</strong>이다.</p>
<p>예를 들어 카드 도메인을 테스트한다고 하자.</p>
<ul>
<li>생성한다</li>
<li>수정한다</li>
<li>삭제한다</li>
<li>검증 실패한다</li>
<li>특정 정책에서 동작한다</li>
</ul>
<p>이걸 한 평면에 전부 나열해두면 테스트 수가 늘수록 읽기가 급격히 어려워진다.<br>하지만 <code>@Nested</code>를 사용하면 읽는 사람은 맥락 단위로 이해할 수 있다.</p>
<p>예시 감각은 이런 식이다.</p>
<ul>
<li><code>create</code><ul>
<li>정상 생성</li>
<li>필수값 누락</li>
<li>규칙 위반</li>
</ul>
</li>
<li><code>changeSummary</code><ul>
<li>정상 변경</li>
<li>길이 초과</li>
</ul>
</li>
<li><code>replaceKeywords</code><ul>
<li>전체 교체</li>
<li>빈 값 포함</li>
<li>중복 포함</li>
</ul>
</li>
</ul>
<p>이렇게 되면 테스트는 단순 검증 코드가 아니라<br><strong>도메인 규칙의 문서</strong>처럼 읽히기 시작한다.</p>
<p>즉, <code>@Nested</code>는 테스트를 꾸미는 기능이 아니라<br><strong>테스트 정보의 계층을 설계하는 도구</strong>다.</p>
<hr>
<h2 id="10-정보-접근의-설계--연관된-정보들이-바로-이동할-수-있게-하기">10. 정보 접근의 설계 = 연관된 정보들이 바로 이동할 수 있게 하기</h2>
<p>readable code의 중요한 기준 중 하나는<br>“읽다가 필요한 정보를 얼마나 빨리 찾을 수 있는가”다.</p>
<p>이건 단순히 검색 기능이 있다는 뜻이 아니다.<br>코드 자체가 연관성을 자연스럽게 드러내야 한다는 뜻이다.</p>
<p>예를 들면 아래 연결들이 부드러워야 한다.</p>
<ul>
<li>API 요청 → 서비스 흐름 → 도메인 변경</li>
<li>도메인 규칙 → 테스트 케이스</li>
<li>예외 정책 → 예외 클래스 → 에러 응답</li>
<li>엔티티 → 리포지토리 → 조회 정책</li>
<li>유즈케이스 → 커맨드/DTO → 검증 로직</li>
</ul>
<p>좋은 구조는 이런 이동이 자연스럽다.<br>반대로 안 좋은 구조는 계속 점프해야 한다.</p>
<ul>
<li>이름이 일관되지 않아 검색이 잘 안 되고</li>
<li>관련 파일이 흩어져 있고</li>
<li>규칙은 주석에 있고 실제 코드는 다른 행동을 하고</li>
<li>테스트 이름은 포괄적이고 실패 원인은 숨겨져 있다</li>
</ul>
<p>그래서 readable code는 “예쁘게 쓰는 코드”보다<br><strong>이동 가능한 코드</strong>, <strong>추적 가능한 코드</strong>, <strong>회복 가능한 코드</strong>에 더 가깝다.</p>
<hr>
<h2 id="11-이름-짓기도-결국-정보-설계다">11. 이름 짓기도 결국 정보 설계다</h2>
<p>가독성에서 이름은 너무 중요하다.<br>이름은 코드를 읽는 사람이 가장 먼저 만나는 인터페이스다.</p>
<p>좋은 이름은 단순히 영어를 잘 쓰는 문제가 아니다.<br>그 이름이 <strong>행동을 드러내는지</strong>, <strong>경계를 드러내는지</strong>, <strong>책임을 줄여주는지</strong>가 더 중요하다.</p>
<p>예를 들어</p>
<ul>
<li><code>process()</code> 보다 <code>publishCard()</code></li>
<li><code>handle()</code> 보다 <code>validatePublishRequest()</code></li>
<li><code>data</code> 보다 <code>cardSummary</code></li>
<li><code>manager</code> 보다 <code>CardPublishPolicy</code></li>
</ul>
<p>이런 이름은 읽는 사람이 추측해야 할 양을 줄여준다.</p>
<p>특히 클래스명과 메서드명은 아래 기준으로 보는 것이 좋다.</p>
<h3 id="클래스명">클래스명</h3>
<ul>
<li>무엇을 들고 있는가보다 무엇을 책임지는가</li>
<li>기술명보다 역할명</li>
<li>범용명보다 경계가 드러나는 이름</li>
</ul>
<h3 id="메서드명">메서드명</h3>
<ul>
<li>동사로 시작해서 행동이 읽히게</li>
<li>내부 구현이 아니라 외부에서 보이는 의미를 드러내게</li>
<li><code>do</code>, <code>process</code>, <code>handle</code>, <code>execute</code> 같은 범용어는 신중하게</li>
</ul>
<p>좋은 이름은 주석을 줄인다.<br>나쁜 이름은 주석을 늘린다.</p>
<hr>
<h2 id="12-주석보다-구조가-먼저다">12. 주석보다 구조가 먼저다</h2>
<p>readable code를 말할 때 주석을 많이 달면 된다고 오해하는 경우가 있다.<br>하지만 많은 경우 주석은 구조가 불명확하다는 신호일 수도 있다.</p>
<p>예를 들면</p>
<ul>
<li>왜 이런 순서인지</li>
<li>왜 이렇게 분기하는지</li>
<li>왜 예외를 던지는지</li>
<li>왜 이 메서드가 이 책임을 가지는지</li>
</ul>
<p>이런 설명이 매번 길게 필요하다면,<br>코드 자체의 구조나 이름이 충분히 의미를 전달하지 못하고 있을 가능성이 있다.</p>
<p>좋은 순서는 대체로 이렇다.</p>
<ol>
<li>구조를 먼저 정리한다</li>
<li>이름을 통해 의도를 드러낸다</li>
<li>그래도 코드만으로 설명되지 않는 “이유”만 주석으로 남긴다</li>
</ol>
<p>즉 주석은 “무엇을 하는지”보다<br><strong>왜 이렇게 했는지</strong>, <strong>다른 선택지와의 차이</strong>, <strong>주의해야 할 맥락</strong>을 설명할 때 더 가치가 있다.</p>
<hr>
<h2 id="13-readable-code는-협업-비용을-줄이는-코드다">13. readable code는 협업 비용을 줄이는 코드다</h2>
<p>혼자 짠 코드는 내가 기억으로 메꿀 수 있다.<br>하지만 팀 코드에서는 기억이 아니라 구조가 말해줘야 한다.</p>
<p>그래서 readable code는 개인 취향의 문제가 아니라 팀 생산성의 문제다.</p>
<p>좋은 가독성은 이런 곳에서 힘을 발휘한다.</p>
<ul>
<li>코드 리뷰 속도가 빨라진다</li>
<li>버그 지점이 더 빨리 보인다</li>
<li>신규 인원이 온보딩하기 쉬워진다</li>
<li>테스트가 문서처럼 동작한다</li>
<li>리팩토링할 때 덜 무섭다</li>
</ul>
<p>결국 읽기 좋은 코드는 팀의 커뮤니케이션 비용을 줄인다.<br>코드가 설명을 대신해주기 때문이다.</p>
<hr>
<h2 id="14-내가-생각하는-readable-code의-핵심-원칙">14. 내가 생각하는 readable code의 핵심 원칙</h2>
<p>정리하면 readable code는 아래 방향으로 수렴한다.</p>
<h3 id="1-가까이-둘-것">1) 가까이 둘 것</h3>
<p>함께 이해해야 하는 것, 함께 바뀌는 것, 함께 검증해야 하는 것을 근처에 둔다.</p>
<h3 id="2-덜-뒤집게-할-것">2) 덜 뒤집게 할 것</h3>
<p>부정어, 중첩 조건, 역방향 이름을 줄여서 사고를 덜 비틀게 만든다.</p>
<h3 id="3-한-번에-다-보여주지-말-것">3) 한 번에 다 보여주지 말 것</h3>
<p>핵심 흐름부터 보이게 하고, 세부사항은 필요한 만큼만 열리게 만든다.</p>
<h3 id="4-이동이-쉬울-것">4) 이동이 쉬울 것</h3>
<p>관련 코드, 테스트, 정책, 예외를 빠르게 찾고 따라갈 수 있게 한다.</p>
<h3 id="5-이름이-설명하게-할-것">5) 이름이 설명하게 할 것</h3>
<p>주석으로 해설하기 전에 이름과 구조가 먼저 의도를 드러내게 한다.</p>
<h3 id="6-추론-단계를-줄일-것">6) 추론 단계를 줄일 것</h3>
<p>코드를 이해하기 위해 머릿속에서 여러 조각을 오래 붙잡지 않게 한다.</p>
<hr>
<h2 id="15-결론">15. 결론</h2>
<p>readable code는 단순히 “클린하게 보이는 코드”가 아니다.<br>그것은 <strong>사람이 덜 헤매고, 덜 오해하고, 덜 지치게 만드는 정보 설계</strong>다.</p>
<p>패키지를 나누는 방식도,<br>레이어를 쌓는 방식도,<br>부정어를 줄이는 것도,<br>비슷한 것들을 묶는 것도,<br>Nested 테스트를 사용하는 것도,<br>IDE에서 빠르게 이동 가능한 구조를 만드는 것도<br>전부 하나의 방향을 가리킨다.</p>
<blockquote>
<p>사람의 사고 비용을 줄이는 것.</p>
</blockquote>
<p>결국 좋은 코드는 많이 아는 사람이 쓴 코드가 아니라,<br><strong>처음 보는 사람도 덜 힘들게 읽을 수 있도록 배려한 코드</strong>다.</p>
<p>그리고 그 배려가 쌓일수록<br>코드는 단순한 구현물이 아니라<br>팀이 신뢰할 수 있는 작업 환경이 된다.</p>
<blockquote>
<p>Ref) </p>
</blockquote>
<ul>
<li>readable code 책 </li>
</ul>
<blockquote>
<p>최종 수정일: 2026-03-13</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Third tool] BusinessException 하나면 충분할까? 도메인 예외 분리의 실전 트레이드오프]]></title>
            <link>https://velog.io/@js-kim-arc/Third-tool-BusinessException-%ED%95%98%EB%82%98%EB%A9%B4-%EC%B6%A9%EB%B6%84%ED%95%A0%EA%B9%8C-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%98%88%EC%99%B8-%EB%B6%84%EB%A6%AC%EC%9D%98-%EC%8B%A4%EC%A0%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84</link>
            <guid>https://velog.io/@js-kim-arc/Third-tool-BusinessException-%ED%95%98%EB%82%98%EB%A9%B4-%EC%B6%A9%EB%B6%84%ED%95%A0%EA%B9%8C-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%98%88%EC%99%B8-%EB%B6%84%EB%A6%AC%EC%9D%98-%EC%8B%A4%EC%A0%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84</guid>
            <pubDate>Thu, 12 Mar 2026 10:31:53 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/f0fdd437-c52b-410a-a1a7-0f2c8ebb597b/image.png" alt=""></p>
<h1 id="트레이드오프-businessexception--errorcode로-통일할까-도메인별-예외로-나눌까">[트레이드오프] BusinessException + ErrorCode로 통일할까, 도메인별 예외로 나눌까</h1>
<h2 id="원하는-부분으로-가서-읽으세요">원하는 부분으로 가서 읽으세요!</h2>
<blockquote>
<ul>
<li><a href="#1-%EC%99%9C-%EC%9D%B4-%EB%B9%84%EA%B5%90%EB%A5%BC-%ED%95%98%EA%B2%8C-%EB%90%98%EC%97%88%EB%8A%94%EA%B0%80">1. 왜 이 비교를 하게 되었는가</a></li>
</ul>
</blockquote>
<ul>
<li><a href="#2-a-%EB%B0%A9%EC%8B%9D-businessexception-%ED%95%98%EB%82%98%EB%A1%9C-%ED%86%B5%EC%9D%BC%ED%95%98%EB%8A%94-%EB%B0%A9%EC%8B%9D">2. A 방식: BusinessException 하나로 통일하는 방식</a><ul>
<li><a href="#a%EC%9D%98-%ED%8A%B9%EC%A7%95">A의 특징</a></li>
<li><a href="#a%EC%9D%98-%EC%9E%A5%EC%A0%90">A의 장점</a></li>
<li><a href="#a%EC%9D%98-%EB%8B%A8%EC%A0%90">A의 단점</a></li>
<li><a href="#a%EA%B0%80-%EC%9E%98-%EB%A7%9E%EB%8A%94-%EC%83%81%ED%99%A9">A가 잘 맞는 상황</a></li>
</ul>
</li>
<li><a href="#3-b-%EB%B0%A9%EC%8B%9D-%EB%8F%84%EB%A9%94%EC%9D%B8%EB%B3%84-%EC%98%88%EC%99%B8-%ED%81%B4%EB%9E%98%EC%8A%A4%EB%A1%9C-%EB%B6%84%EB%A6%AC%ED%95%98%EB%8A%94-%EB%B0%A9%EC%8B%9D">3. B 방식: 도메인별 예외 클래스로 분리하는 방식</a><ul>
<li><a href="#b%EC%9D%98-%ED%8A%B9%EC%A7%95">B의 특징</a></li>
<li><a href="#b%EC%9D%98-%EC%9E%A5%EC%A0%90">B의 장점</a></li>
<li><a href="#b%EC%9D%98-%EB%8B%A8%EC%A0%90">B의 단점</a></li>
<li><a href="#b%EA%B0%80-%EC%9E%98-%EB%A7%9E%EB%8A%94-%EC%83%81%ED%99%A9">B가 잘 맞는 상황</a></li>
</ul>
</li>
<li><a href="#4-%EC%A7%81%EC%A0%91-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EB%A9%B0-%EB%8A%90%EB%82%80-%EC%B0%A8%EC%9D%B4">4. 직접 적용해보며 느낀 차이</a></li>
<li><a href="#5-%EB%A1%9C%EA%B7%B8-%EA%B4%80%EC%A0%90%EC%97%90%EC%84%9C%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EA%B4%80%EB%A6%AC%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C">5. 로그 관점에서는 어떻게 관리해야 할까</a></li>
<li><a href="#6-%EA%B7%B8%EB%9E%98%EC%84%9C-%EB%AC%B4%EC%97%87%EC%9D%84-%EC%84%A0%ED%83%9D%ED%96%88%EB%8A%94%EA%B0%80">6. 그래서 무엇을 선택했는가</a></li>
<li><a href="#7-%EC%98%88%EC%8B%9C%EB%A1%9C-%EB%B3%B4%EB%A9%B4-%EC%9D%B4%EB%9F%B0-%EA%B5%AC%EC%A1%B0%EA%B0%80-%EA%B4%9C%EC%B0%AE%EC%95%98%EB%8B%A4">7. 예시로 보면 이런 구조가 괜찮았다</a></li>
<li><a href="#8-%EC%A0%95%EB%A6%AC">8. 정리</a></li>
</ul>
<blockquote>
<p>&quot;예외를 정말 하나로만 관리해도 괜찮을까?&quot;
&quot;Card, Deck, Member처럼 도메인별로 나누면 더 명확해지는 것 아닐까?&quot;
&quot;그런데 또 너무 잘게 나누면 오히려 복잡해지는 것 아닐까?&quot;</p>
</blockquote>
<p>이번 글은 바로 이 고민에 대한 test와 기록이다.
정답을 단정하려는 글이 아니라, 직접 둘 다 써보며 어떤 차이를 느꼈는지, 그리고 왜 지금은 특정 방향을 더 선호하게 되었는지를 정리해보려 한다. - 추후에 Exception 핸들링 관련 트레이드 오프는 여기에 추가 업로드하면서 생각을 키워나갈 예정입니다.</p>
<hr>
<h2 id="1-왜-이-비교를-하게-되었는가">1. 왜 이 비교를 하게 되었는가</h2>
<p>예외 처리는 단순히 에러를 던지는 기술 문제가 아니다.
시스템에서 실패를 어떻게 해석하고, 어떻게 전달하고, 어떻게 운영에서 읽을지를 결정하는 설계에 가깝다.</p>
<p>처음에는 BusinessException 하나로 통일하는 방식이 더 좋아 보였다.</p>
<p>이유는 단순했다.</p>
<ul>
<li>예외 클래스가 적다 (이는 곧 팀에서 관리하기가 매우 쉽다는 뜻이라고 느낌, 당장의 트레이드오프에서는 예외처리보다 기능 구현이 우선시 되는 상황에서 가장 편한 방법이였다.)</li>
<li>전역 예외 처리기가 단순하다</li>
<li>ErrorCode만 잘 설계하면 일관성을 맞추기 쉽다</li>
<li>작은 프로젝트나 초기 프로젝트에서는 속도가 빠르다</li>
</ul>
<p>실제로 이 방식은 시작할 때 굉장히 매력적이다.
예외를 새로 정의하는 비용보다 기능을 빨리 구현하는 게 더 중요할 때가 많기 때문이다.</p>
<p>그런데 프로젝트가 점점 커지면서 고민이 생겼다.</p>
<ul>
<li>이 실패가 카드 규칙 위반인지</li>
<li>덱 조회 실패인지</li>
<li>인증 문제인지</li>
<li>외부 시스템 장애인지</li>
</ul>
<p>이런 것들이 로그나 코드에서 한 번에 잘 드러나지 않는 순간이 생기기 시작했다.</p>
<p>즉, 처음에는 &quot;단순해서 좋다&quot;였는데, 나중에는 &quot;너무 한데 모여 있어서 의미가 흐려진다&quot;는 감각이 생겼다. (리팩토링 타이밍으로 느꼈다...ㅎ)
그래서 BusinessException + ErrorCode 방식과, CardDomainException, DeckDomainException처럼 도메인별 예외를 분리하는 방식을 비교하게 되었다. 비교 후 장단점을 비교하고 이 기록을 토대로 필요한 타이밍에 골라 쓸 수 있게 할 예정이다.</p>
<hr>
<h2 id="2-a-방식-businessexception-하나로-통일하는-방식기존의-방식---추가적인-체계화가-될까">2. A 방식: BusinessException 하나로 통일하는 방식(기존의 방식) - 추가적인 체계화가 될까...?</h2>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/81fa0d95-d434-4f6b-ae3f-63e2b02d98bd/image.png" alt=""></p>
<p>예를 들면 이런 느낌이다.</p>
<pre><code class="language-java">Deck deck = deckRepository.findById(deckId)
        .orElseThrow(() -&gt; new BusinessException(ErrorCode.DECK_NOT_FOUND));</code></pre>
<h3 id="a의-특징">A의 특징</h3>
<p>이 방식의 핵심은 단순하다.</p>
<ul>
<li>예외 타입은 거의 하나로 통일한다</li>
<li>세부 의미는 ErrorCode가 담당한다</li>
<li>전역 예외 처리도 BusinessException 중심으로 잡는다</li>
</ul>
<p>즉, 타입은 단순하게 유지하고, 의미는 코드값에 몰아주는 구조다.</p>
<h3 id="a의-장점">A의 장점</h3>
<p><strong>1) 가장 빠르게 정리된다</strong></p>
<p>이 방식의 가장 큰 장점은 역시 속도다.</p>
<p>도메인별 예외 클래스를 계속 만들 필요 없이, 새로운 실패 상황이 생기면 ErrorCode만 추가하면 된다.
팀 초반에는 &quot;예외 구조를 얼마나 멋지게 짜느냐&quot;보다 &quot;빨리 기능을 안정적으로 붙이느냐&quot;가 더 중요할 수 있는데, 그런 상황에서 꽤 강하다.</p>
<p><strong>2) 전역 예외 처리가 단순하다</strong></p>
<pre><code class="language-java">@ExceptionHandler(BusinessException.class)
public ResponseEntity&lt;ErrorResponse&gt; handleBusinessException(BusinessException e) {
    ...
}</code></pre>
<p>핸들러가 단순하다.
응답 포맷도 통일하기 쉽다.
프론트와 계약을 맞출 때도 일관된 구조를 제공하기 좋다.</p>
<p><strong>3) 팀 규칙을 표준화하기 쉽다</strong></p>
<blockquote>
<p>&quot;비즈니스 예외는 무조건 BusinessException으로 던지고, 세부 상황은 ErrorCode로 구분한다.&quot;</p>
</blockquote>
<p>이 규칙은 생각보다 강력하다.
누가 코드를 짜더라도 예외의 큰 틀은 같아진다.
특히 팀 내 경험치가 다를 때, 복잡한 구조보다 이런 단일 규칙이 오히려 안정적으로 작동하기도 한다.</p>
<h3 id="a의-단점">A의 단점</h3>
<p><strong>1) 예외의 출처가 흐려진다</strong></p>
<p>BusinessException만 보면 이게 어디서 온 예외인지 타입만으로는 잘 드러나지 않는다.</p>
<ul>
<li>카드 규칙 위반인지</li>
<li>덱 상태 문제인지</li>
<li>회원 권한 문제인지</li>
<li>애플리케이션 흐름 실패인지</li>
</ul>
<p>결국 코드를 더 읽어야 하고, 로그에서는 ErrorCode를 직접 파봐야 한다.
즉, 실패의 문맥이 타입에서 바로 읽히지 않는다.</p>
<p><strong>2) ErrorCode가 비대해진다</strong>
<img src="https://velog.velcdn.com/images/js-kim-arc/post/68383acd-cb19-4e0b-820a-3d02128c1ffa/image.png" alt=""></p>
<p>처음엔 편하다. 그런데 시간이 지나면 ErrorCode가 점점 이런 식으로 늘어난다.</p>
<ul>
<li>CARD_NOT_FOUND</li>
<li>CARD_SUMMARY_TOO_LONG</li>
<li>CARD_KEYWORD_EMPTY</li>
<li>DECK_NOT_FOUND</li>
<li>DECK_CLOSED</li>
<li>MEMBER_FORBIDDEN</li>
<li>COMMENT_ALREADY_DELETED</li>
</ul>
<p>문제는 여기에 모든 의미가 몰린다는 점이다.
예외 타입은 단 하나인데, 실제 의미는 전부 enum에 들어가다 보니 ErrorCode 자체가 또 다른 거대한 분류 시스템이 된다.</p>
<p><strong>3) 계층이 섞이기 쉬워진다</strong></p>
<p>이 방식이 가장 위험해지는 지점은 여기에 있다.</p>
<p>원래는 비즈니스 실패를 표현하려고 만든 예외인데, 어느 순간</p>
<ul>
<li>도메인 규칙 위반</li>
<li>서비스 흐름 실패</li>
<li>인프라 장애</li>
<li>외부 API 호출 문제</li>
</ul>
<p>이런 것까지 전부 BusinessException으로 감싸기 시작한다.</p>
<p>그러면 나중에는 &quot;이게 진짜 비즈니스 예외인가?&quot;라는 질문이 생긴다.
특히 DB timeout이나 외부 시스템 장애까지 같은 결로 묶이기 시작하면, 운영 관점에서 실패의 종류를 분리해서 읽기가 어려워진다.</p>
<p><strong>4) 도메인 언어가 예외에 잘 드러나지 않는다</strong></p>
<p>DDD 관점에서는 성공하는 행위만 모델이 아니다. 실패하는 방식도 모델의 일부다.</p>
<p>그런데 예외를 전부 BusinessException 하나로 묶으면, 도메인이 가진 언어가 예외 구조에 잘 드러나지 않는다.
즉, 기능은 돌아가지만 도메인의 의미가 코드에 약하게 남는다.</p>
<h3 id="a가-잘-맞는-상황">A가 잘 맞는 상황</h3>
<ul>
<li>프로젝트 초반이라 빠른 정리가 중요할 때</li>
<li>도메인 규모가 아직 작을 때</li>
<li>팀 내 예외 처리 규칙을 강하게 단순화하고 싶을 때</li>
<li>운영 복잡도가 아직 높지 않을 때</li>
<li>응답 포맷 표준화가 우선일 때</li>
</ul>
<p>요약하면, 초기 속도와 단순함이 중요한 단계에서 꽤 좋은 선택이다.</p>
<hr>
<h2 id="3-b-방식-도메인별-예외-클래스로-분리하는-방식">3. B 방식: 도메인별 예외 클래스로 분리하는 방식</h2>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/9922e87e-df87-4a4b-8440-380eebffbc78/image.png" alt=""></p>
<p>예를 들면 이런 느낌이다.</p>
<pre><code class="language-java">public class CardDomainException extends BusinessException {

    private CardDomainException(ErrorCode errorCode) {
        super(errorCode);
    }

    public static CardDomainException of(ErrorCode errorCode) {
        return new CardDomainException(errorCode);
    }
}</code></pre>
<p>사용은 이렇게 한다.</p>
<pre><code class="language-java">throw CardDomainException.of(ErrorCode.CARD_SUMMARY_TOO_LONG);</code></pre>
<h3 id="b의-특징">B의 특징</h3>
<p>이 방식은 공통 부모를 유지하되, 예외의 타입 자체에 도메인 경계를 반영한다.</p>
<ul>
<li>BusinessException은 공통 부모로 둔다</li>
<li>CardDomainException, DeckDomainException, MemberDomainException처럼 도메인별로 나눈다</li>
<li>ErrorCode도 함께 사용하되, 타입이 1차 분류 역할을 한다</li>
</ul>
<p>즉, 에러코드만으로 의미를 표현하는 것이 아니라, 예외 타입 자체도 의미를 갖게 만드는 방식이다.</p>
<h3 id="b의-장점">B의 장점</h3>
<p><strong>1) 실패의 출처가 더 분명해진다</strong>
<img src="https://velog.velcdn.com/images/js-kim-arc/post/1404de65-c673-4d9f-a9e7-d915882e73d5/image.png" alt=""></p>
<p>CardDomainException이라는 이름만 봐도 어느 경계에서 실패가 발생했는지 감이 온다.</p>
<ul>
<li>카드 도메인에서</li>
<li>규칙 검증 중</li>
<li>비즈니스 실패가 났다</li>
</ul>
<p>이 흐름이 타입에서 바로 드러난다. 즉, 실패를 해석하는 비용이 줄어든다.</p>
<p><strong>2) 도메인 언어를 코드에 녹이기 좋다</strong></p>
<p>도메인 중심으로 모델링을 하고 있다면, 실패도 그 언어로 표현되는 편이 자연스럽다.</p>
<ul>
<li>CardDomainException</li>
<li>DeckDomainException</li>
<li>EnrollmentDomainException</li>
</ul>
<p>이런 식으로 구성하면, 성공 흐름뿐 아니라 실패 흐름도 도메인 경계 안에서 읽힌다.
이건 코드의 정교함 이전에, 모델을 읽는 감각 자체를 바꿔준다.</p>
<p><strong>3) 로그와 운영 분류가 쉬워진다</strong></p>
<p>운영에서는 &quot;에러가 났다&quot;보다 &quot;어떤 종류의 에러가 얼마나 자주 나는가&quot;가 더 중요하다.</p>
<p>도메인별 예외가 있으면 로그에서 다음처럼 분류하기 쉬워진다.</p>
<ul>
<li>카드 관련 실패 비율</li>
<li>덱 관련 실패 비율</li>
<li>멤버 관련 실패 비율</li>
<li>외부 시스템 관련 실패 비율</li>
</ul>
<p>즉, 예외는 단순히 throw를 위한 도구가 아니라, 운영 데이터의 분류 기준이 된다.</p>
<p>특히 로그 집계나 모니터링에서 예외 타입이 잘 잡히면, 장애 추적과 이슈 해석이 훨씬 수월해진다.</p>
<p><strong>4) 테스트의 의도가 선명해진다</strong></p>
<pre><code class="language-java">assertThatThrownBy(() -&gt; card.changeSummary(longText))
        .isInstanceOf(CardDomainException.class);</code></pre>
<p>이 테스트는 단순히 &quot;예외가 난다&quot;를 넘어서, &quot;카드 도메인의 규칙 위반이 난다&quot;는 걸 검증한다.
즉, 테스트가 더 구체적인 언어를 가지게 된다.</p>
<p><strong>5) 나중에 도메인별 공통 정책을 붙이기 좋다</strong></p>
<p>지금은 모두 같은 방식으로 처리하더라도, 나중에는 도메인마다 다르게 관리하고 싶어질 수 있다.</p>
<ul>
<li>카드 관련 예외는 warn 로그만 남긴다</li>
<li>인증 관련 예외는 보안 태그를 붙인다</li>
<li>외부 API 예외는 알림 시스템과 연결한다</li>
</ul>
<p>이런 확장이 가능해진다.
처음엔 차이가 작아 보여도, 규모가 커질수록 이 유연성이 꽤 큰 차이를 만든다.</p>
<h3 id="b의-단점">B의 단점</h3>
<p><strong>1) 클래스 수가 늘어난다</strong></p>
<p>작은 프로젝트에서는 과해 보일 수 있다.</p>
<ul>
<li>CardDomainException</li>
<li>DeckDomainException</li>
<li>MemberDomainException</li>
<li>ArchiveDomainException</li>
</ul>
<p>이런 식으로 계속 늘어나면 &quot;이거 결국 포장지만 다른 거 아닌가?&quot;라는 생각이 들 수 있다.
실제로 규모가 작은 코드베이스에서는 유지비가 오히려 더 크게 느껴질 수도 있다.</p>
<p><strong>2) 형식만 남을 위험이 있다</strong></p>
<p>이게 가장 조심해야 할 지점이다.</p>
<p>이름만 다르고 실제 차이는 없는 구조가 될 수 있다.</p>
<ul>
<li>전부 같은 status</li>
<li>전부 같은 응답 포맷</li>
<li>전부 같은 처리기</li>
<li>전부 같은 생성 메서드</li>
</ul>
<p>이러면 타입만 세분화됐을 뿐, 실질적인 의미 차이는 없다.
그렇다면 오히려 설계 비용만 늘어난 셈이 된다.</p>
<p><strong>3) 팀 합의가 없으면 더 혼란스러워진다</strong></p>
<p>어떤 개발자는 BusinessException을 던지고,
어떤 개발자는 CardDomainException을 던지고,
어떤 개발자는 IllegalArgumentException을 던지면 구조는 금방 무너진다.</p>
<p>즉, 이 방식은 A보다 더 정교해 보이지만, 그만큼 규칙이 없으면 더 쉽게 흔들린다.</p>
<p><strong>4) 성능을 이유로 과하게 걱정할 필요는 없지만, 남발은 경계해야 한다</strong></p>
<p>예외 처리 이야기를 하면 &quot;예외는 무거우니까 성능에 안 좋은 것 아닌가요?&quot;라는 질문이 자주 나온다.</p>
<p>이건 절반은 맞고, 절반은 오해다.</p>
<p>예외는 stack trace를 생성하는 비용이 있기 때문에, 정상 흐름에서 반복적으로 예외를 발생시키는 구조는 좋지 않다.
예를 들어 검증 로직을 예외 중심으로 남발하거나, 루프 안에서 자주 던지는 구조는 분명 피해야 한다.</p>
<p>다만 여기서 중요한 건 BusinessException 하나냐, CardDomainException이냐의 차이가 성능을 크게 좌우하는 것은 아니라는 점이다.</p>
<p>진짜 성능 이슈는 보통 이런 데서 생긴다.</p>
<ul>
<li>예외를 정상 제어 흐름처럼 사용하는 경우</li>
<li>대량 요청에서 stack trace가 과도하게 쌓이는 경우</li>
<li>불필요한 에러 로그를 너무 많이 남기는 경우</li>
</ul>
<p>즉, 예외 타입의 세분화 자체보다, 예외를 얼마나 자주 던지게 설계했는가가 더 중요하다.</p>
<h3 id="b가-잘-맞는-상황">B가 잘 맞는 상황</h3>
<ul>
<li>도메인 경계가 점점 명확해지고 있을 때</li>
<li>운영과 로깅의 중요도가 커질 때</li>
<li>테스트에서 실패의 의도까지 분명히 드러내고 싶을 때</li>
<li>팀이 예외 처리 규칙을 문서화하고 일관되게 지킬 수 있을 때</li>
<li>장기적으로 도메인별 정책 분리를 고려할 때</li>
</ul>
<p>즉, 규모와 맥락이 생긴 서비스에서 더 빛난다.</p>
<hr>
<h2 id="4-직접-적용해보며-느낀-차이">4. 직접 적용해보며 느낀 차이</h2>
<p><strong>1) 개발 경험의 차이</strong></p>
<p>BusinessException + ErrorCode 방식은 확실히 빠르다.
생각을 덜 해도 된다.
실패가 발생하면 그냥 적절한 에러코드 하나 붙여서 던지면 된다.</p>
<p>반면 도메인별 예외는 처음에는 조금 더 손이 간다.
&quot;이 예외는 어디에 속하는가?&quot;를 한 번 더 생각해야 한다.</p>
<p>그런데 기능이 늘어날수록 후자가 더 편해지는 순간이 온다.
이유는 실패를 던질 때도 도메인 경계를 다시 의식하게 되기 때문이다.</p>
<p>즉, 전자는 빠르게 쓰기 쉽고, 후자는 점점 읽기 쉬워진다.</p>
<p><strong>2) 유지보수 관점의 차이</strong></p>
<p>유지보수에서는 읽는 비용이 중요하다.</p>
<p>몇 달 뒤에 로그를 보거나 코드를 볼 때,
<code>BusinessException(ErrorCode.CARD_SUMMARY_TOO_LONG)</code>보다
<code>CardDomainException.of(ErrorCode.CARD_SUMMARY_TOO_LONG)</code>가 더 빨리 이해되는 경우가 많았다.</p>
<p>큰 차이 없어 보일 수 있지만, 이런 작은 해석 비용이 쌓이면 유지보수 피로도에 차이가 난다.</p>
<p><strong>3) 협업 관점의 차이</strong></p>
<p>프론트와 협업할 때 예외는 결국 API 응답 계약으로 번역된다.
프론트는 보통 이런 걸 알고 싶어 한다.</p>
<ul>
<li>사용자 입력 수정으로 해결 가능한가</li>
<li>다시 시도하면 되는가</li>
<li>로그인 화면으로 보내야 하는가</li>
<li>특정 화면에서만 처리하면 되는가</li>
</ul>
<p>이때 ErrorCode 중심 구조는 응답 표준화에는 좋다.
반면 도메인별 예외는 백엔드 내부에서 실패를 정리하는 힘이 더 강하다.</p>
<p>즉, 협업 관점에서는 사실 둘 중 하나만 필요한 게 아니라
<strong>외부 계약은 ErrorCode로 맞추고, 내부 의미는 도메인 예외로 강화하는 조합</strong>이 꽤 괜찮았다.</p>
<p><strong>4) 확장성 관점의 차이</strong></p>
<p>프로젝트가 작을 때는 A 방식이 더 경제적이다.
하지만 도메인이 늘고 운영이 중요해지면 B 방식이 점점 유리해진다.</p>
<p>특히 다음 같은 요구가 붙는 순간 차이가 커진다.</p>
<ul>
<li>특정 도메인 예외만 별도 태깅하고 싶다</li>
<li>특정 예외군만 알림을 보내고 싶다</li>
<li>로그 레벨을 다르게 가져가고 싶다</li>
<li>모니터링 지표를 도메인별로 집계하고 싶다</li>
</ul>
<p>이런 상황에서는 도메인별 예외가 확실히 더 확장 가능성이 좋다.</p>
<hr>
<h2 id="5-로그-관점에서는-어떻게-관리해야-할까">5. 로그 관점에서는 어떻게 관리해야 할까</h2>
<p>예외 설계는 결국 로그 설계와도 연결된다.</p>
<p>예외를 잘 나눠도 로그가 전부 같은 방식으로 찍히면 운영에서 얻는 정보가 줄어든다.
반대로 예외 구조가 조금 단순해도 로그를 잘 남기면 운영 대응력이 올라간다.</p>
<p>내가 중요하다고 느낀 포인트는 이렇다.</p>
<p><strong>1) 모든 예외를 error로 찍지 말 것</strong></p>
<p>사용자 입력 실수나 도메인 규칙 위반까지 전부 error 로그로 남기면 로그가 금방 오염된다.</p>
<ul>
<li>summary 글자 수 초과</li>
<li>필수값 누락</li>
<li>허용되지 않은 상태 전이</li>
</ul>
<p>이런 건 예상 가능한 실패일 수 있다.
이런 것까지 전부 error로 찍으면 진짜 장애 신호가 묻힌다.</p>
<p>보통은 이렇게 나누는 편이 운영상 더 좋다.</p>
<ul>
<li><code>warn</code>: 예상 가능한 비즈니스 실패</li>
<li><code>error</code>: 시스템 장애, 외부 연동 실패, 복구가 필요한 문제</li>
</ul>
<p><strong>2) 로그에는 예외 메시지보다 분류 정보가 더 중요하다</strong></p>
<p>운영에서 보고 싶은 건 감성적인 에러 문장이 아니라 분류 가능성이다.</p>
<p>예를 들면 이런 값들이 중요하다.</p>
<ul>
<li>에러 코드</li>
<li>예외 타입</li>
<li>도메인 이름</li>
<li>요청 경로</li>
<li>사용자 식별자 또는 추적 ID</li>
<li>상관관계 ID (traceId)</li>
</ul>
<p>즉, 로그는 읽기 좋게 쓰는 것도 중요하지만, <strong>집계 가능하게 남기는 것이 더 중요하다.</strong></p>
<p><strong>3) stack trace는 정말 필요할 때만 강하게 남길 것</strong></p>
<p>비즈니스 예외까지 전부 stack trace를 길게 찍어버리면 로그 저장 비용도 커지고, 진짜 문제를 찾기도 어려워진다.</p>
<p>예상 가능한 도메인 실패는 핵심 정보만 남기고,
시스템 장애나 원인 분석이 필요한 예외는 stack trace를 충분히 남기는 식의 구분이 필요하다.</p>
<p><strong>4) 예외는 운영 데이터의 분류 기준이 될 수 있어야 한다</strong></p>
<p>결국 좋은 예외 구조는 운영에서 이런 질문에 답할 수 있어야 한다.</p>
<ul>
<li>최근 카드 관련 실패가 왜 늘었는가</li>
<li>어떤 화면에서 가장 많이 실패하는가</li>
<li>특정 에러코드가 갑자기 급증했는가</li>
<li>시스템 장애와 사용자 실수가 로그에서 잘 구분되는가</li>
</ul>
<p>이 기준으로 보면, 도메인별 예외 분리는 로그 분석과 꽤 잘 맞는다.</p>
<hr>
<h2 id="6-그래서-무엇을-선택했는가">6. 그래서 무엇을 선택했는가</h2>
<p>현재 내 기준에서는 <strong>공통 부모 BusinessException은 유지하되, 도메인별 예외 클래스를 얇게 두는 방식</strong>이 가장 괜찮다고 느꼈다.</p>
<p>즉, 완전히 둘 중 하나만 선택하기보다 이런 구조다.</p>
<ul>
<li>공통 응답 계약은 ErrorCode 중심으로 유지한다</li>
<li>내부에서는 CardDomainException, DeckDomainException처럼 도메인별로 분리한다</li>
<li>인프라 장애나 시스템 예외는 비즈니스 예외와 섞지 않는다</li>
<li>로그 레벨과 로깅 방식도 예외 성격에 따라 다르게 가져간다</li>
</ul>
<p>왜 이렇게 판단했냐면, 실제로 서비스가 조금만 커져도 실패의 종류를 나눠서 읽어야 할 일이 많아지기 때문이다.</p>
<p>특히 내 기준에서는 아래 이유가 컸다.</p>
<p><strong>1) 도메인 맥락이 더 잘 드러난다</strong></p>
<p>예외도 결국 모델의 일부라고 보면, 도메인별 예외는 꽤 자연스럽다.</p>
<p><strong>2) 운영과 로그 분류가 쉬워진다</strong></p>
<p>나중에 장애를 추적하거나 통계를 볼 때 훨씬 유리하다.</p>
<p><strong>3) 외부 계약과 내부 의미를 분리할 수 있다</strong></p>
<p>프론트에는 ErrorCode와 표준 응답을 주고,
백엔드 내부에서는 도메인 타입으로 더 정교하게 읽을 수 있다.</p>
<p>다만 중요한 건, 무조건 클래스부터 많이 만드는 게 아니라는 점이다.
실질적인 의미 차이가 없는데 도메인별 예외만 늘리면 오히려 과설계가 된다.</p>
<p>그래서 지금은 이렇게 생각한다.</p>
<blockquote>
<p>작을 때는 단순하게 시작하되, 도메인이 보이기 시작하면 예외도 그 경계를 따라가게 하자.</p>
</blockquote>
<hr>
<h2 id="7-예시로-보면-이런-구조가-괜찮았다">7. 예시로 보면 이런 구조가 괜찮았다</h2>
<pre><code class="language-java">public abstract class BusinessException extends RuntimeException {

    private final ErrorCode errorCode;

    protected BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}</code></pre>
<pre><code class="language-java">public class CardDomainException extends BusinessException {

    private CardDomainException(ErrorCode errorCode) {
        super(errorCode);
    }

    public static CardDomainException of(ErrorCode errorCode) {
        return new CardDomainException(errorCode);
    }
}</code></pre>
<pre><code class="language-java">public class DeckDomainException extends BusinessException {

    private DeckDomainException(ErrorCode errorCode) {
        super(errorCode);
    }

    public static DeckDomainException of(ErrorCode errorCode) {
        return new DeckDomainException(errorCode);
    }
}</code></pre>
<pre><code class="language-java">public enum ErrorCode {
    CARD_NOT_FOUND,
    CARD_SUMMARY_TOO_LONG,
    DECK_NOT_FOUND,
    DECK_CLOSED
}</code></pre>
<p>이 구조의 장점은 극단적으로 복잡하지 않다는 점이다.</p>
<ul>
<li>공통 부모는 하나</li>
<li>응답 규약도 유지 가능</li>
<li>도메인 타입도 드러남</li>
<li>필요 이상으로 과한 구조는 아님</li>
</ul>
<p>즉, 표준화와 의미 부여 사이에서 적당한 균형점이 된다.</p>
<hr>
<h2 id="8-정리">8. 정리</h2>
<p>예외 처리는 결국 &quot;무엇을 던질까?&quot;의 문제가 아니라
<strong>실패를 어떻게 분류하고, 어떻게 읽고, 어떻게 운영할까</strong>의 문제에 더 가깝다.</p>
<p>BusinessException + ErrorCode 방식은 분명 강점이 있다.</p>
<ul>
<li>빠르다</li>
<li>단순하다</li>
<li>초반 생산성이 좋다</li>
<li>응답 표준화가 쉽다</li>
</ul>
<p>반면 도메인별 예외 분리도 분명한 장점이 있다.</p>
<ul>
<li>실패의 출처가 더 잘 보인다</li>
<li>도메인 언어가 코드에 남는다</li>
<li>로그와 운영 분류가 쉬워진다</li>
<li>테스트의 의도가 선명해진다</li>
</ul>
<p>결국 이건 정답의 문제가 아니라 상황의 문제다.</p>
<p>프로젝트가 작고 빠르게 가야 한다면 단일 BusinessException도 충분히 좋은 선택일 수 있다.
반대로 도메인이 자라고 운영 복잡도가 올라간다면, 예외도 그만큼 의미를 품는 방향으로 가는 편이 더 낫다.</p>
<p>내가 지금 더 선호하는 방향은 이렇다.</p>
<blockquote>
<p>예외는 하나로 시작할 수 있지만, 계속 하나로만 남겨둘 필요는 없다.
서비스가 자라면 실패도 더 정교하게 분류되어야 한다.</p>
</blockquote>
<p>그리고 그 분류는 단지 코드 스타일의 문제가 아니라,
유지보수와 협업, 운영을 훨씬 편하게 만들어주는 설계의 일부라고 느꼈다.</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-%EB%AF%B8%EC%85%98-%ED%9A%8C%EA%B3%A0%EB%A1%9D-%EB%A7%9B%EC%A7%91-%EB%AF%B8%EC%85%98</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%AF%B8%EC%85%98-%ED%9A%8C%EA%B3%A0%EB%A1%9D-%EB%A7%9B%EC%A7%91-%EB%AF%B8%EC%85%98</guid>
            <pubDate>Wed, 11 Mar 2026 10:51:27 GMT</pubDate>
            <description><![CDATA[<h1 id="내일배움캠프-미션-002-회고록">내일배움캠프 미션 002 회고록</h1>
<h2 id="맛집-리스트를-만들며-배운-것">맛집 리스트를 만들며 배운 것</h2>
<blockquote>
<p><strong>단순한 CRUD를 넘어, 그날 먹고 싶은 마음에 가까워지는 방향으로</strong></p>
</blockquote>
<hr>
<h2 id="읽고-싶은것만-읽으세요">읽고 싶은것만 읽으세요~!!</h2>
<blockquote>
<ul>
<li><a href="#%EB%93%A4%EC%96%B4%EA%B0%80%EB%A9%B0">들어가며</a>  </li>
<li><a href="#1-%EC%B4%88%EA%B8%B0-%EB%AF%B8%EC%85%98%EC%9D%98-%EC%8B%9C%EC%9E%91-%EB%A7%9B%EC%A7%91-%EB%A6%AC%EC%8A%A4%ED%8A%B8-crud-%EA%B5%AC%ED%98%84">1. 초기 미션의 시작: 맛집 리스트 CRUD 구현</a>  </li>
<li><a href="#2-%EC%B2%98%EC%9D%8C-%EB%A7%9E%EB%8B%A5%EB%9C%A8%EB%A6%B0-%EC%96%B4%EB%A0%A4%EC%9B%80-react%EC%99%80-javascript%EC%97%90-%EB%8C%80%ED%95%9C-%EB%82%AF%EC%84%A6">2. 처음 맞닥뜨린 어려움: React와 JavaScript에 대한 낯섦</a>  <ul>
<li><a href="#2-1-%EC%83%88%EB%A1%9C%EC%9A%B4-%EB%8F%84%EA%B5%AC%EC%9D%98-%ED%99%9C%EC%9A%A9-mock-api%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80">[2-1] 새로운 도구의 활용: Mock API란 무엇인가</a>  </li>
<li><a href="#2-2-%EC%88%9C%EC%88%98-htmlcssjavascript%EC%99%80-react%EC%9D%98-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84">[2-2] 순수 HTML/CSS/JavaScript와 React의 트레이드오프</a>  </li>
</ul>
</li>
<li><a href="#3-%EB%8B%A8%EC%88%9C%ED%95%9C-%EB%A7%9B%EC%A7%91-%EB%A6%AC%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-%ED%95%9C-%EA%B1%B8%EC%9D%8C-%EB%8D%94">3. 단순한 맛집 리스트에서 한 걸음 더</a>  </li>
<li><a href="#4-ux-%EA%B8%B0%ED%9A%8D-%EA%B4%80%EC%A0%90%EC%97%90%EC%84%9C-%EB%B3%B8-%ED%99%95%EC%9E%A5-%EB%B0%A9%ED%96%A5">4. UX 기획 관점에서 본 확장 방향</a>  </li>
<li><a href="#5-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B4%80%EC%A0%90%EC%97%90%EC%84%9C-%EB%B3%B8-%ED%98%84%EC%9E%AC-%EA%B5%AC%EC%A1%B0%EC%99%80-%EC%9D%B4%ED%9B%84-%ED%99%95%EC%9E%A5">5. 백엔드 관점에서 본 현재 구조와 이후 확장</a>  </li>
<li><a href="#6-%EC%9D%B4%EB%B2%88-%EB%AF%B8%EC%85%98%EC%97%90%EC%84%9C-%EC%96%BB%EC%9D%80-%EA%B0%80%EC%9E%A5-%ED%81%B0-%EB%B0%B0%EC%9B%80">6. 이번 미션에서 얻은 가장 큰 배움</a>  </li>
<li><a href="#%EB%A7%88%EB%AC%B4%EB%A6%AC">마무리</a></li>
</ul>
</blockquote>
<hr>
<blockquote>
<p>참고용 github: <a href="https://github.com/js-kim-arc/hidden-gem">https://github.com/js-kim-arc/hidden-gem</a></p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>내일배움캠프 초반 미션 중 하나로 <strong>맛집 리스트 서비스</strong>를 구현해보는 과제가 있었다.</p>
<p>처음에는 맛집 정보를 등록하고, 조회하고, 수정하고, 삭제하는 비교적 단순한 기능이라고 생각했다.<br>하지만 막상 시작해보니 프론트엔드 구조를 이해하고, 상태를 다루고, 데이터 흐름을 설계하고, 사용자 경험까지 함께 고민해야 했다.<br>생각보다 훨씬 많은 요소가 들어 있는 미션이었다.</p>
<p>이번 글에서는 이 미션을 진행하면서 겪었던 초기 어려움과,<br>단순한 리스트 기능에서 한 걸음 더 나아가 <strong>‘그날 먹고 싶은 것을 떠올리게 돕는 서비스’</strong> 로 확장해보고 싶었던 기획 방향까지 함께 정리해보려고 한다.</p>
<hr>
<h2 id="1-초기-미션의-시작-맛집-리스트-crud-구현">1. 초기 미션의 시작: 맛집 리스트 CRUD 구현</h2>
<p>초기 미션의 핵심은 비교적 명확했다.</p>
<p>기본적으로는 <strong>맛집 정보를 등록하고, 조회하고, 수정하고, 삭제하는 CRUD 로직</strong>을 구현하는 것이었다.</p>
<p>여기에 더해 단순히 데이터만 다루는 것이 아니라,<br>직접 화면을 만지고 바꾸어 보면서 <strong>프론트엔드가 데이터를 어떻게 보여주고, 어떤 흐름으로 조작하는지</strong> 경험해보는 것도 중요한 목표였다.</p>
<p>특히 이번 미션에서는 실제 서버를 바로 붙이기보다는,<br><strong>Mock API 서버</strong>를 기반으로 임시 데이터를 다루는 방식으로 진행했다.</p>
<p>즉, 완성된 백엔드가 없는 상황에서도 프론트엔드에서 요청을 보내고 응답을 받아보며,<br>하나의 작은 서비스가 어떻게 움직이는지를 먼저 체감하는 과정에 가까웠다.</p>
<p>이 방식은 개인적으로 꽤 의미 있었다.</p>
<p>나는 백엔드 개발을 중심으로 공부해왔지만, 실제 서비스는 결국<br><strong>화면에서 입력하고, 상태가 바뀌고, 다시 사용자에게 보이는 흐름 전체</strong>로 작동한다.</p>
<p>그렇기 때문에 초기 단계에서라도 프론트엔드를 직접 다뤄보는 경험은<br>단순한 “화면 구현” 이상의 의미가 있었다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/3b3491a3-ab65-447c-8cf9-ae275ae84bcb/image.png" alt=""></p>
<h2 id="2-처음-맞닥뜨린-어려움-react와-javascript에-대한-낯섦">2. 처음 맞닥뜨린 어려움: React와 JavaScript에 대한 낯섦</h2>
<p>이번 미션에서 가장 먼저 부딪힌 어려움은 기능 자체보다도<br><strong>React.js와 JavaScript 생태계에 대한 익숙하지 않음</strong>이었다.</p>
<p>기존에는 Java와 Spring 중심으로 사고하는 시간이 많았기 때문에,<br>프론트엔드에서 흔히 사용하는 구조들이 처음에는 꽤 낯설게 느껴졌다.</p>
<p>초반에 특히 어렵게 다가왔던 부분은 다음과 같았다.</p>
<ul>
<li><strong>컴포넌트를 어떻게 나누어야 하는지</strong></li>
<li><strong>JSX 문법이 HTML과 어떻게 다르고 왜 이렇게 섞여 보이는지</strong></li>
<li><strong>Node.js 환경에서 패키지를 어떻게 관리하고 분리하는지</strong></li>
<li><strong>데이터를 받아와 화면에 반영하는 흐름이 어떤 방식으로 움직이는지</strong></li>
</ul>
<p>처음에는 기능을 빨리 만들고 싶은 마음이 앞섰다.<br>하지만 그럴수록 구조를 제대로 이해하지 못한 채 억지로 붙이게 될 것 같았다.</p>
<p>그래서 먼저 React 컴포넌트 구조, JSX 문법, Node 기반 패키지 관리 방식과 관련된 문서들을 읽으면서<br>전체 흐름을 이해하려고 했다.</p>
<p>이 과정에서 느낀 점은 분명했다.</p>
<p>프론트엔드는 단순히 “화면을 예쁘게 만드는 영역”이 아니었다.<br>오히려 <strong>상태를 나누고, 책임을 분리하고, 사용자의 행동에 따라 즉각적으로 반응하는 흐름을 설계하는 영역</strong>에 더 가까웠다.</p>
<p>그리고 이 부분은 백엔드에서 도메인과 책임을 나누는 감각과도 생각보다 닮아 있었다.<br>그래서 비교적 빠르게 이해해나갈 수 있었던 것 같다.</p>
<hr>
<h2 id="2-1-새로운-도구의-활용-mock-api란-무엇인가">[2-1] 새로운 도구의 활용: Mock API란 무엇인가</h2>
<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/5aaca416-32ae-4560-a9bd-d7b30cd280aa/image.png" alt=""></p>
<h4 id="실사용-사진">실사용 사진</h4>
<p>이번 미션에서 사용한 Mock API는<br><strong>실제 백엔드를 붙이기 전에, API처럼 데이터를 주고받는 흐름을 먼저 만들어볼 수 있는 도구</strong>다.</p>
<p>백엔드 개발자 관점에서 보면, 프론트엔드가 서버 없이도<br><strong>조회, 생성, 수정, 삭제 같은 기본 요청 흐름을 미리 맞춰볼 수 있게 해주는 임시 인터페이스</strong>라고 볼 수 있다.</p>
<p>쉽게 말하면 아직 Spring 서버나 DB를 본격적으로 구성하지 않았더라도,<br>프론트엔드는 <code>GET</code>, <code>POST</code>, <code>PUT</code>, <code>DELETE</code> 같은 요청을 보내고<br>그에 맞는 응답을 받아보면서 화면과 데이터 흐름을 먼저 검증할 수 있다.</p>
<p>즉, <strong>“나중에 실제 서버가 들어올 자리”를 임시로 흉내 내주는 역할</strong>에 가깝다.</p>
<h3 id="mock-api의-장점">Mock API의 장점</h3>
<p>Mock API의 장점은 분명하다.</p>
<p>가장 큰 장점은 <strong>빠르게 붙여볼 수 있다</strong>는 점이다.<br>초기 기획 단계나 화면 개발 단계에서 백엔드가 완성되기를 기다리지 않고도<br>리스트를 불러오고, 데이터를 추가하고, 수정하고, 삭제하는 기본 동작을 바로 실험할 수 있다.</p>
<p>특히 프론트엔드와 협업할 때는 API 명세의 큰 틀을 먼저 맞춰보고,<br>데이터 구조나 요청/응답 형태를 가볍게 검증해보기에 좋다.</p>
<h3 id="mock-api의-한계">Mock API의 한계</h3>
<p>다만 한계도 분명하다.</p>
<p>Mock API는 어디까지나 <strong>실제 비즈니스 로직이 들어간 서버는 아니다.</strong></p>
<p>복잡한 유효성 검증, 인증과 인가, 트랜잭션 처리, 예외 상황 제어,<br>연관관계가 있는 데이터 관리, 실제 DB 설계 같은 부분은 결국 실제 백엔드에서 다시 다뤄야 한다.</p>
<p>그래서 Mock API는 운영용 서버라기보다는,<br><strong>초기 개발에서 흐름을 맞추고 인터페이스를 확인하기 위한 가벼운 실험 도구</strong>에 더 가깝다.</p>
<h3 id="정리">정리</h3>
<p>정리하면 Mock API는 백엔드가 완성되기 전 단계에서<br><strong>API의 형태를 먼저 흉내 내고, 화면과 데이터 흐름을 빠르게 검증할 수 있게 해주는 임시 서버 도구</strong>다.</p>
<p>실제 서버를 대체하는 것은 아니지만,<br>서비스를 처음 만들 때 프론트엔드와 백엔드 사이의 연결 감각을 익히기에는 꽤 유용한 출발점이라고 느꼈다.</p>
<hr>
<h2 id="2-2-순수-htmlcssjavascript와-react의-트레이드오프">[2-2] 순수 HTML/CSS/JavaScript와 React의 트레이드오프</h2>
<p>이번 미션을 진행하면서 자연스럽게 들었던 생각 중 하나는<br><strong>“이걸 그냥 순수 HTML, CSS, JavaScript로 만들 수도 있었을 텐데 왜 React를 쓰는 걸까?”</strong> 였다.</p>
<p>작은 기능만 놓고 보면 순수 JavaScript로도 충분히 구현할 수 있다.<br>실제로 버튼을 누르면 데이터를 불러오고, 목록을 그리고, 수정하고, 삭제하는 기본 흐름 자체는<br>굳이 React가 아니어도 만들 수 있다.</p>
<p>하지만 화면이 조금만 복잡해지기 시작하면 차이가 점점 드러난다.</p>
<h3 id="순수-javascript로-구현할-때">순수 JavaScript로 구현할 때</h3>
<p>순수 HTML, CSS, JavaScript 방식의 장점은 분명하다.</p>
<p>가장 큰 장점은 <strong>구조가 단순하고 직관적</strong>이라는 점이다.<br>브라우저가 어떻게 동작하는지, DOM이 어떻게 바뀌는지, 이벤트가 어떻게 연결되는지를<br>비교적 직접적으로 볼 수 있다.</p>
<p>즉, 화면을 직접 선택하고(<code>querySelector</code>),<br>필요한 부분의 내용을 바꾸고(<code>innerHTML</code>, <code>appendChild</code>),<br>이벤트를 붙이는 흐름이 눈에 바로 보인다.</p>
<p>이 방식은 작은 프로젝트나 간단한 페이지에서는 오히려 빠르고 가볍다.<br>불필요한 설정이 적고, 번들링이나 컴포넌트 구조를 크게 고민하지 않아도 되기 때문이다.</p>
<p>다만 단점도 분명하다.</p>
<p>기능이 늘어나고 화면이 복잡해질수록<br><strong>어느 DOM을 언제 바꿔야 하는지 직접 관리해야 하는 부담</strong>이 커진다.</p>
<p>예를 들어 리스트 하나를 수정할 때도<br>단순히 데이터만 바꾸는 것이 아니라,<br>그 변경이 화면 어디에 반영되어야 하는지 직접 찾아서 다시 그려야 한다.</p>
<p>즉, 상태와 화면이 커질수록<br>코드가 점점 <strong>DOM 조작 중심</strong>으로 흘러가기 쉽고,<br>이벤트와 렌더링 로직이 뒤섞이면서 유지보수가 어려워질 수 있다.</p>
<h3 id="react로-구현할-때">React로 구현할 때</h3>
<p>React의 큰 장점 중 하나는<br><strong>DOM을 직접 하나하나 조작하는 부담을 줄여준다</strong>는 점이다.</p>
<p>React에서는 개발자가 화면의 최종 상태를 중심으로 생각한다.<br>즉, “데이터가 이 상태면 화면은 이렇게 보여야 한다”를 선언적으로 작성하면,<br>실제 DOM 반영은 React가 처리해준다.</p>
<p>이때 자주 이야기되는 것이 <strong>가상 DOM(Virtual DOM)</strong> 이다.</p>
<p>React는 상태가 바뀌었을 때<br>실제 DOM을 무작정 전부 다시 건드리는 것이 아니라,<br>가상 DOM을 통해 이전 상태와 새로운 상태를 비교하고<br>바뀐 부분만 효율적으로 반영하려고 한다.</p>
<p>그래서 개발자는 순수 JavaScript처럼<br>“이 요소를 찾고, 지우고, 다시 넣고, 이벤트를 다시 걸고…”<br>이런 식으로 매번 직접 DOM을 관리하지 않아도 된다.</p>
<p>특히 리스트, 폼, 조건부 렌더링, 재사용되는 UI 조각이 많아질수록<br>React의 장점은 더 커진다.</p>
<ul>
<li>컴포넌트 단위로 나눌 수 있다.</li>
<li>상태 변화에 따라 UI를 자연스럽게 다시 그릴 수 있다.</li>
<li>재사용과 유지보수가 상대적으로 쉬워진다.</li>
<li>화면이 복잡해질수록 코드 구조를 정리하기 좋다.</li>
</ul>
<h3 id="그렇다고-react가-무조건-더-좋은-것은-아니다">그렇다고 React가 무조건 더 좋은 것은 아니다</h3>
<p>다만 React가 항상 정답인 것은 아니다.</p>
<p>React도 결국 <strong>학습 비용</strong>이 있다.</p>
<p>처음 접하면 다음과 같은 개념들이 꽤 낯설다.</p>
<ul>
<li>컴포넌트</li>
<li>props</li>
<li>state</li>
<li>이벤트 처리 방식</li>
<li>JSX</li>
<li>훅(Hooks)</li>
<li>렌더링 흐름</li>
</ul>
<p>즉, 순수 JavaScript보다<br>처음에는 더 많은 개념을 이해해야 하고,<br>프로젝트 설정이나 구조도 조금 더 무겁게 느껴질 수 있다.</p>
<p>또한 아주 단순한 정적 페이지나 작은 기능만 구현한다면<br>React의 구조가 오히려 과하게 느껴질 수도 있다.<br>이럴 때는 순수 JavaScript가 더 빠르고 단순할 수 있다.</p>
<h3 id="결국-핵심은-규모와-복잡도에-따른-선택이다">결국 핵심은 규모와 복잡도에 따른 선택이다</h3>
<p>정리하면 순수 JavaScript와 React는<br>누가 절대적으로 더 좋다기보다 <strong>어떤 상황에 더 적합한가의 차이</strong>에 가깝다.</p>
<h4 id="순수-javascript가-더-잘-맞는-경우">순수 JavaScript가 더 잘 맞는 경우</h4>
<ul>
<li>화면 구조가 단순할 때</li>
<li>기능 수가 많지 않을 때</li>
<li>DOM 동작 원리를 직접 익히고 싶을 때</li>
<li>빠르게 작게 만들어볼 때</li>
</ul>
<h4 id="react가-더-잘-맞는-경우">React가 더 잘 맞는 경우</h4>
<ul>
<li>상태 변화가 많은 화면일 때</li>
<li>리스트, 폼, 조건부 렌더링이 많을 때</li>
<li>컴포넌트 재사용이 중요할 때</li>
<li>프로젝트가 점점 커질 가능성이 있을 때</li>
<li>유지보수성과 확장성을 고려해야 할 때</li>
</ul>
<h3 id="내가-느낀-트레이드오프">내가 느낀 트레이드오프</h3>
<p>내가 느낀 가장 큰 차이는 이것이었다.</p>
<p>순수 JavaScript는<br><strong>“DOM을 직접 움직이며 화면을 만든다”</strong> 는 느낌에 가깝고,</p>
<p>React는<br><strong>“상태를 관리하면 화면은 React가 맞춰준다”</strong> 는 느낌에 가까웠다.</p>
<p>즉, 순수 JavaScript는 제어권이 직접 손에 있는 대신<br>규모가 커질수록 관리 비용이 커지고,</p>
<p>React는 처음 배울 것은 더 많지만<br>복잡한 화면에서는 그 관리 부담을 꽤 줄여준다.</p>
<p>특히 이번처럼 리스트를 보여주고, 수정하고, 삭제하고, 다시 반영하는 흐름에서는<br>React가 왜 많이 쓰이는지 조금 이해할 수 있었다.<br>결국 핵심은 성능 하나만이 아니라,<br><strong>상태와 화면을 연결하는 복잡성을 줄여주는 구조적 장점</strong>이 크다고 느꼈다.</p>
<h3 id="한-줄-정리">한 줄 정리</h3>
<p>순수 JavaScript는 <strong>작고 단순한 화면에 강하고</strong>,  
React는 <strong>상태 변화가 많은 복잡한 화면을 구조적으로 관리하는 데 강하다.</strong></p>
<p>그래서 둘의 차이는 단순히 “무엇이 더 빠른가”보다<br><strong>무엇을 더 쉽게 만들고, 더 오래 유지할 수 있는가</strong>의 트레이드오프로 보는 것이 더 적절하다고 생각한다.</p>
<hr>
<h2 id="3-단순한-맛집-리스트에서-한-걸음-더">3. 단순한 맛집 리스트에서 한 걸음 더</h2>
<h3 id="왜-그날-먹고-싶은지를-다루고-싶었는가">왜 ‘그날 먹고 싶은지’를 다루고 싶었는가</h3>
<p>기본 미션을 수행하면서 점점 이런 생각이 들었다.</p>
<p>사람들은 항상 “오늘 정확히 무엇을 먹고 싶은지”를 명확하게 알고 있지는 않다.<br>오히려 대부분은 메뉴 이름보다 먼저 <strong>느낌</strong>을 떠올린다.</p>
<p>예를 들면 이런 식이다.</p>
<ul>
<li>오늘은 뭔가 따뜻한 게 먹고 싶다.</li>
<li>비가 오니까 국물 있는 게 당긴다.</li>
<li>봄이라 가볍고 산뜻한 걸 먹고 싶다.</li>
<li>이상하게 오늘은 매콤한 게 끌린다.</li>
</ul>
<p>즉, 사용자는 음식 이름을 먼저 떠올리기보다<br><strong>날씨, 계절, 컨디션, 분위기 같은 감각적인 단서</strong>를 먼저 느끼는 경우가 많다.</p>
<p>그래서 단순히 “맛집을 저장하는 리스트”를 넘어서,<br><strong>계절감과 날씨감을 기준으로 그날의 음식점을 떠올리게 돕는 방향</strong>으로 확장해보면 재밌겠다고 생각했다.</p>
<p>여기서 중요한 점은 “정답 같은 추천”이 아니었다.</p>
<p>무조건 많은 사람에게 통하는 표준화된 추천보다,<br><strong>내가 평소에 저장해둔 맛집들 중에서 오늘의 분위기에 맞는 곳을 다시 꺼내주는 흐름</strong>이 더 매력적으로 느껴졌다.</p>
<p>이 관점에서 보면 서비스의 역할은 크게 달라진다.</p>
<p>단순 맛집 저장 서비스는 정보를 쌓아두는 역할에 가깝다.<br>반면, 날씨나 계절을 기준으로 다시 보여주는 구조는<br>사용자의 기억 속에서 애매하게 떠다니는 취향을 <strong>그날의 맥락 안에서 다시 호출하는 역할</strong>을 하게 된다.</p>
<p>나는 이 차이가 UX적으로 꽤 중요하다고 느꼈다.<br>사용자는 늘 새로운 정보를 원한다기보다,<br>사실은 <strong>이미 알고 있는 것 중에서 오늘 나에게 맞는 것을 빨리 찾고 싶어하는 경우</strong>가 많기 때문이다.</p>
<hr>
<h2 id="4-ux-기획-관점에서-본-확장-방향">4. UX 기획 관점에서 본 확장 방향</h2>
<h3 id="저장보다-떠올리게-하기">저장보다 떠올리게 하기</h3>
<p>이 미션을 하면서 점점 더 선명해진 것은<br>맛집 서비스의 본질이 단순히 “등록한다”에 있지 않다는 점이었다.</p>
<p>오히려 중요한 것은 <strong>언제, 어떤 맥락에서 다시 꺼내 보여주느냐</strong>였다.</p>
<p>UX 관점에서 보면 사용자는 리스트를 길게 쌓아두는 순간부터 피로해진다.<br>저장할 때는 만족스럽지만, 나중에 다시 보려고 하면 항목이 많아져서 오히려 고르기 어려워진다.<br>결국 저장은 했지만 활용은 잘 안 되는 상태가 된다.</p>
<p>그래서 이후에는 이런 방향을 생각하게 되었다.</p>
<ul>
<li>계절별로 어울리는 음식 분위기를 나누기</li>
<li>날씨 API를 받아와 오늘의 환경과 연결하기</li>
<li>저장해둔 맛집에 <code>비 오는 날</code>, <code>쌀쌀할 때</code>, <code>봄 저녁</code>, <code>혼밥</code>, <code>기분 전환</code> 같은 태그를 붙이기</li>
<li>오늘의 날씨나 분위기에 따라 기존 리스트 중 일부를 다시 보여주기</li>
</ul>
<p>이때 핵심은 거창한 AI 추천 시스템이 아니다.<br>오히려 초반에는 훨씬 단순해도 된다.</p>
<p>중요한 것은 완벽한 추천 정확도가 아니라,<br>사용자가 <strong>“맞아, 오늘은 이런 게 먹고 싶었어”</strong> 라고 느끼게 만드는 작은 계기를 제공하는 것이다.</p>
<p>이런 점에서 이 서비스는 단순한 맛집 보관함보다<br><strong>취향의 회상 장치</strong>에 더 가깝게 설계할 수 있다고 느꼈다.</p>
<hr>
<h2 id="5-백엔드-관점에서-본-현재-구조와-이후-확장">5. 백엔드 관점에서 본 현재 구조와 이후 확장</h2>
<p>초기 단계에서는 데이터 저장과 API 흐름을 빠르게 실험해보기 위해<br><strong>Mock API 기반으로 임시 운용</strong>하는 방식이 적절했다.</p>
<p>실제 DB를 설계하고 서버를 정교하게 구성하기 전에,<br>우선은 화면과 데이터 흐름이 자연스럽게 이어지는지 빠르게 확인할 수 있기 때문이다.</p>
<p>하지만 이 구조는 어디까지나 시작점이다.<br>이후에는 자연스럽게 <strong>Spring 백엔드로 연결</strong>해볼 계획을 가지고 있다.</p>
<p>백엔드 관점에서 보면 다음 단계에서 다뤄볼 수 있는 요소들도 꽤 분명하다.</p>
<ul>
<li>맛집 엔티티 구조 설계</li>
<li>태그, 계절, 날씨 조건에 대한 메타데이터 분리</li>
<li>사용자별 저장 리스트 관리</li>
<li>외부 날씨 API 연동</li>
<li>조건 기반 추천 로직 분기</li>
<li>이후 개인화 정도를 높이기 위한 조회 로그, 선택 로그 수집</li>
</ul>
<p>특히 흥미로운 부분은 단순한 맛집 CRUD가<br>나중에는 <strong>상황 기반 추천 시스템의 아주 작은 출발점</strong>이 될 수 있다는 점이다.</p>
<p>예를 들어 지금은 단순히<br><strong>“오늘 비가 오면 국물류 태그가 붙은 음식점을 먼저 보여준다”</strong><br>정도의 규칙 기반 로직으로 시작할 수 있다.</p>
<p>하지만 이후에는 사용자의 선택 이력을 바탕으로<br><strong>“비 오는 날에도 이 사용자는 전보다 매운 음식을 더 자주 선택한다”</strong><br>같은 식으로 점점 개인화된 흐름으로 발전시킬 수도 있다.</p>
<p>즉, 지금 단계의 임시 구조는 단순한 미완성이 아니라<br><strong>빠르게 가설을 검증하기 위한 실험용 토대</strong>라고 볼 수 있다.</p>
<hr>
<h2 id="6-이번-미션에서-얻은-가장-큰-배움">6. 이번 미션에서 얻은 가장 큰 배움</h2>
<p>이번 맛집 리스트 미션은 겉으로 보면 작은 CRUD 프로젝트처럼 보일 수 있다.<br>하지만 나에게는 그보다 훨씬 더 큰 의미가 있었다.</p>
<h3 id="1-익숙하지-않은-환경을-이해해가는-경험">1) 익숙하지 않은 환경을 이해해가는 경험</h3>
<p>익숙하지 않은 프론트엔드 환경 안에서도<br>문서를 읽고 구조를 이해하면서 문제를 하나씩 풀어가는 경험을 했다.</p>
<h3 id="2-기능-구현을-넘어-사용-이유를-고민한-경험">2) 기능 구현을 넘어 사용 이유를 고민한 경험</h3>
<p>기능을 구현하는 것을 넘어서<br><strong>“사용자가 왜 이 기능을 쓰고 싶어할까?”</strong> 를 생각해보게 되었다.</p>
<h3 id="3-가볍게-시작하고-점진적으로-확장하는-감각">3) 가볍게 시작하고 점진적으로 확장하는 감각</h3>
<p>백엔드 입장에서도 처음부터 무거운 구조를 올리기보다<br>가볍게 Mock 환경으로 시작해서 흐름을 검증하고,<br>이후 Spring 기반 구조로 확장하는 접근이 꽤 현실적이라는 점을 다시 느꼈다.</p>
<p>무엇보다도 가장 인상 깊었던 것은,<br>작은 미션 하나도 단순히 주어진 기능만 구현하는 데서 끝나지 않고<br><strong>사용자의 애매한 욕구를 어떻게 더 잘 다룰 수 있을지 상상하는 순간</strong><br>훨씬 더 살아 있는 서비스 기획으로 바뀐다는 점이었다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 미션은 맛집을 등록하고 관리하는 간단한 기능에서 출발했다.</p>
<p>하지만 그 안에서 나는 단순한 리스트보다 더 중요한 것은<br><strong>그날의 맥락 속에서 사용자가 원하는 선택지를 다시 떠올리게 만드는 경험</strong>이라는 생각을 하게 되었다.</p>
<p>아직은 Mock API 기반의 작은 시작점에 가깝고,<br>실제로는 기본적인 맛집 리스트 기능을 먼저 안정적으로 만드는 것이 우선이다.</p>
<p>하지만 그 다음 단계에서는 날씨, 계절, 분위기 같은 요소를 붙여<br>조금 더 개인의 감각에 가까운 방식으로 확장해보고 싶다.</p>
<p>결국 서비스는 데이터를 저장하는 것에서 끝나지 않는다.<br><strong>사용자가 필요할 때, 필요한 형태로 다시 꺼내 쓸 수 있어야 한다.</strong></p>
<p>이번 미션은 그 아주 작은 시작을 경험해본 과정이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[팀문화 회고록 ~ing]]></title>
            <link>https://velog.io/@js-kim-arc/%ED%8C%80%EB%AC%B8%ED%99%94-%ED%9A%8C%EA%B3%A0%EB%A1%9D-ing</link>
            <guid>https://velog.io/@js-kim-arc/%ED%8C%80%EB%AC%B8%ED%99%94-%ED%9A%8C%EA%B3%A0%EB%A1%9D-ing</guid>
            <pubDate>Tue, 10 Mar 2026 11:05:05 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js-kim-arc/post/95afa7be-29d6-4d9e-ad97-9b42d22b3980/image.png" alt=""></p>
<h2 id="읽고-부분만-읽으세요">읽고 부분만 읽으세요~!!</h2>
<blockquote>
<ul>
<li><a href="#%EB%AC%B8%EC%A0%9C%EB%8A%94-%EB%B0%A9%EC%8B%9D%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-%EB%B0%9B%EC%95%84%EB%93%A4%EC%9D%B4%EB%8A%94-%EC%82%AC%EB%9E%8C%EC%9D%98-%EC%86%8D%EB%8F%84%EC%98%80%EB%8B%A4">문제는 방식이 아니라, 받아들이는 사람의 속도였다</a></li>
</ul>
</blockquote>
<ul>
<li><a href="#%ED%8C%80%EC%9B%90%EC%9D%80-%EA%B8%B0%EB%8A%A5%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-%EA%B4%80%EC%84%B1%EC%9D%84-%EA%B0%80%EC%A7%84-%EC%82%AC%EB%9E%8C%EC%9D%B4%EB%8B%A4">팀원은 기능이 아니라, 관성을 가진 사람이다</a></li>
<li><a href="#%EC%A4%91%EC%9A%94%ED%95%9C-%EA%B2%83%EC%9D%80-%EC%83%88%EB%A1%9C%EC%9B%80%EC%9D%B4-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%9D%B5%EC%88%99%ED%95%9C-%EC%83%88%EB%A1%9C%EC%9B%80%EC%9D%B4%EC%97%88%EB%8B%A4">중요한 것은 ‘새로움’이 아니라 ‘익숙한 새로움’이었다</a></li>
<li><a href="#%EC%95%9E%EC%9C%BC%EB%A1%9C%EB%8A%94-%EC%9D%B4%EB%A0%87%EA%B2%8C-%ED%95%B4%EB%B3%B4%EA%B3%A0-%EC%8B%B6%EB%8B%A4">앞으로는 이렇게 해보고 싶다</a></li>
<li><a href="#%EB%A7%88%EB%AC%B4%EB%A6%AC">마무리</a></li>
</ul>
<h2 id="팀문화의-좋은-점들은-계속-업데이트-하겠습니다">(팀문화의 좋은 점들은 계속 업데이트 하겠습니다)</h2>
<h1 id="팀-문화에-대한-회고">팀 문화에 대한 회고</h1>
<h2 id="새로운-것을-자꾸-들이밀수록-팀은-더-좋아질까">새로운 것을 자꾸 들이밀수록, 팀은 더 좋아질까?</h2>
<p>팀 프로젝트, 도메인 팀의 경험을 거치면서 점점 강하게 느끼게 된 것이 있다.<br>팀을 더 잘 굴러가게 만들고 싶다는 마음이 항상 좋은 결과로 이어지지는 않는다는 점이다.</p>
<p>나는 종종 이런 생각을 했다.<br>이전 프로젝트에서 아쉬웠던 점이 있었다면, 이번에는 더 나은 방식으로 바꾸면 되지 않을까?</p>
<ul>
<li>성능이 부족했다면 새로운 기술들을 도입해보고 </li>
<li>문서 정리가 부족했다면 문서 가이드를 만들고</li>
<li>회의가 비효율적이었다면 새로운 회의 방식을 도입하고</li>
<li>아이디어가 답답하게 나온다면 더 체계적인 발상법을 가져오면 되지 않을까</li>
</ul>
<p>실제로 그렇게 해본 적도 많았다.<br>책을 읽고 참고한 방식들을 바탕으로 가이드라인을 정리해서 공유하기도 했고,<br>이전 팀 활동에서 느꼈던 실수를 반복하지 않기 위해 새로운 규칙과 흐름을 제안하기도 했다. (Ex) 아이디어 생산이 생각보다 잘 되지 않아 아이디어 생산법에 대한 여러 방법과 문서들을 만들어서 시도를 해보았다... 생각보다 잘 되지는 않았지만,</p>
<p>당시에는 분명 그것이 더 좋은 방향이라고 믿었다.<br>더 좋은 기술, 더 좋은 문서 방식, 더 좋은 아이디어 생산법이라면 도입하는 것이 맞다고 생각했다.</p>
<p>그런데 여러 번 부딪히고 나서야 보인 것이 있다.</p>
<hr>
<h2 id="문제는-방식이-아니라-받아들이는-사람의-속도였다">문제는 방식이 아니라, 받아들이는 사람의 속도였다</h2>
<p>결정적으로 놓치고 있던 것은 <strong>사람마다 이미 익숙한 작업 방식이 있다는 사실</strong>이었다.</p>
<p>각자는 각자의 규칙이 있고,<br>정보를 정리하는 습관이 있고,<br>문제를 풀어가는 리듬이 있고,<br>학습해 온 맥락이 있다.</p>
<p>나는 종종 이런 생각을 했다.</p>
<blockquote>
<p>“이게 더 좋은 방법인데 왜 바로 적용되지 않을까?”</p>
</blockquote>
<p>예를 들면 이런 식이다.</p>
<ul>
<li>더 좋은 AI 도구가 있으면 바꾸면 되지 않을까</li>
<li>더 체계적인 문서 방식이 있으면 바로 쓰면 되지 않을까</li>
<li>더 효율적인 회의법이 있으면 팀 전체가 바로 적용하면 되지 않을까</li>
</ul>
<p>하지만 지금 돌아보면, 그런 생각은 꽤 오만했다.</p>
<p>좋은 방법이라는 것과<br><strong>지금 이 팀이 자연스럽게 사용할 수 있는 방법이라는 것은 전혀 다른 문제였다.</strong></p>
<p>어떤 방식이 객관적으로 더 좋아 보여도,<br>그것이 팀원들의 기존 흐름을 크게 끊어버린다면 오히려 생산성을 떨어뜨릴 수도 있다.<br>특히 협업에서는 “더 좋은 방식”보다 먼저<br><strong>지금 이 사람들이 무리 없이 움직일 수 있는 방식인가</strong>를 봐야 했다.</p>
<hr>
<h2 id="팀원은-기능이-아니라-관성을-가진-사람이다">팀원은 기능이 아니라, 관성을 가진 사람이다</h2>
<p>이 회고에서 가장 크게 남은 문장은 아마 이것이다.</p>
<blockquote>
<p><strong>팀원은 새로운 규칙을 바로 흡수하는 존재가 아니라, 각자의 관성을 가진 사람이다.</strong></p>
</blockquote>
<p>개발을 하다 보면 자꾸 시스템처럼 생각하게 된다.<br>문제가 있으면 개선안을 만들고,<br>개선안이 합리적이면 적용하면 된다고 믿는다.<br>하지만 사람은 그렇게 단순하게 움직이지 않는다.</p>
<ul>
<li>누군가는 익숙한 툴에서 생각이 잘 정리되고</li>
<li>누군가는 말로 먼저 풀어야 아이디어가 나오고</li>
<li>누군가는 문서를 길게 읽는 것보다 짧은 태스크 단위가 더 편하고</li>
<li>누군가는 갑작스럽게 기준이 바뀌면 오히려 몰입이 깨진다</li>
</ul>
<p>이런 상태에서 새로운 방식이 한꺼번에 들어오면<br>그것은 혁신이 아니라 <strong>피로</strong>가 되기도 한다.</p>
<p>특히 내가 자주 놓쳤던 것은 <strong>정보 피로감</strong>이었다.</p>
<p>나는 팀을 위해 정리해서 공유했다고 생각했지만,<br>받는 사람 입장에서는 갑자기 익숙하지 않은 규칙과 문서와 방식이 한 번에 쏟아지는 경험이었을 수 있다.<br>게다가 그 방향이 자신이 원래 생각하던 작업 흐름이나 리팩토링 방향과 다를 경우,<br>그 피로는 단순한 불편함이 아니라 일종의 이탈감으로 이어질 수 있다.</p>
<blockquote>
<p>“내가 생각하던 방향이 있었는데, 갑자기 다른 방식으로 움직여야 한다.”</p>
</blockquote>
<p>이 감각은 생각보다 크다.</p>
<p>팀 문화는 단순히 좋은 제도를 만드는 일이 아니라,<br><strong>함께 움직일 수 있는 리듬을 만드는 일</strong>에 더 가깝다는 것을 조금씩 배우고 있다.</p>
<hr>
<h2 id="중요한-것은-새로움이-아니라-익숙한-새로움이었다">중요한 것은 ‘새로움’이 아니라 ‘익숙한 새로움’이었다</h2>
<p>그래서 요즘은 팀에 무언가를 도입할 때 기준이 조금 바뀌었다.</p>
<p>예전에는</p>
<blockquote>
<p>“이게 더 좋은가?”</p>
</blockquote>
<p>를 먼저 봤다면,</p>
<p>이제는</p>
<blockquote>
<p>“이게 지금 팀이 받아들일 수 있는가?”</p>
</blockquote>
<p>를 더 먼저 보게 된다. - (우리 팀의 리소스(사람, 시간, 돈)을 고려했을 때 받아들일 수 있는가)</p>
<p>완전히 새로운 회의법을 들고 오는 것보다<br>기존 회의 흐름 안에서 한 가지 질문만 바꾸는 것이 더 낫고,</p>
<p>새로운 문서 체계를 통째로 요구하는 것보다<br>기존 문서에 한 줄짜리 결정 이유만 남기게 하는 것이 더 낫고,</p>
<p>새로운 툴을 모두에게 강하게 요구하는 것보다<br>필요한 사람부터 작게 써보게 하는 것이 더 낫다.</p>
<p>결국 팀에 필요한 것은 낯선 혁신이 아니라<br><strong>익숙한 자리에서 조금씩 이동할 수 있게 만드는 변화</strong>였다.</p>
<p>나는 이것을 <strong>“익숙한 새로움”</strong>이라고 느꼈다.</p>
<ul>
<li>새롭되 거부감이 크지 않아야 하고</li>
<li>바뀌되 기존의 리듬을 완전히 무너뜨리지 않아야 하고</li>
<li>좋아 보이는 방식보다 실제로 팀 안에 정착할 수 있는 방식이어야 한다</li>
</ul>
<p>팀 문화는 정답을 배포한다고 생기는 것이 아니라,<br>사람들이 덜 아프게 움직일 수 있을 때 조금씩 쌓이는 것 같다.</p>
<hr>
<h2 id="앞으로는-이렇게-해보고-싶다">앞으로는 이렇게 해보고 싶다</h2>
<p>이 회고 이후로는 새로운 것을 도입할 때 몇 가지를 더 의식해보려 한다.</p>
<h3 id="1-한-번에-많이-바꾸지-않기">1. 한 번에 많이 바꾸지 않기</h3>
<p>좋은 방법이 여러 개 보여도 동시에 다 넣지 않는다.<br>한 번에 하나만, 그것도 가장 마찰이 적은 것부터 시도해야 한다.</p>
<h3 id="2-팀원들의-기존-흐름을-먼저-이해하기">2. 팀원들의 기존 흐름을 먼저 이해하기</h3>
<p>무엇이 불편한지 묻기 전에,<br>각자가 지금 어떤 방식으로 움직이고 있는지부터 봐야 한다.<br>개선은 빈 공간에 넣는 것이 아니라, 이미 굴러가고 있는 흐름 위에 얹는 것이기 때문이다.</p>
<h3 id="3-가이드보다-체감-이점을-먼저-만들기">3. 가이드보다 체감 이점을 먼저 만들기</h3>
<p>사람은 설명보다 경험으로 납득한다.<br>“이렇게 합시다”보다<br><strong>“이걸 하니까 회의가 10분 줄었다”</strong>가 더 강하다.</p>
<h3 id="4-도입보다-정착을-더-중요하게-보기">4. 도입보다 정착을 더 중요하게 보기</h3>
<p>새로운 규칙을 만드는 것보다,<br>기존에 정한 작은 규칙 하나가 계속 지켜지는 것이 팀에는 더 큰 힘이 된다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>예전에는 팀을 더 잘되게 만들고 싶다는 마음으로<br>새로운 기술, 새로운 문서 방식, 새로운 생산법을 자주 들고 왔다.<br>지금도 그 마음 자체가 틀렸다고 생각하지는 않는다.</p>
<p>다만 이제는 안다.</p>
<p>팀에서 중요한 것은<br>무엇이 더 좋아 보이느냐만이 아니라,<br><strong>무엇이 함께 지속될 수 있느냐</strong>라는 것을.</p>
<p>더 나은 방법을 찾는 일도 중요하지만,<br>그보다 먼저 팀원들의 리듬을 존중해야 한다.<br>변화는 밀어 넣는다고 정착되지 않는다.<br>사람이 받아들일 수 있는 속도로,<br>익숙함을 해치지 않는 선에서,<br>조금씩 스며들어야 비로소 문화가 된다.</p>
<p>이번 회고를 통해 배운 것은 분명하다.</p>
<blockquote>
<p><strong>팀 문화는 새로운 것을 많이 가져오는 사람이 만드는 것이 아니라,<br>사람들의 관성을 이해하고 그 위에서 천천히 움직이게 만드는 사람이 만든다.</strong></p>
</blockquote>
<blockquote>
<p>최종 수정일:2026-03-10</p>
</blockquote>
]]></description>
        </item>
    </channel>
</rss>