<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://kimdohwan24.github.io/TIL/feed.xml" rel="self" type="application/atom+xml" /><link href="https://kimdohwan24.github.io/TIL/" rel="alternate" type="text/html" /><updated>2026-05-17T17:12:33+00:00</updated><id>https://kimdohwan24.github.io/TIL/feed.xml</id><title type="html">DoHwan’s Study Room</title><subtitle>Write an awesome description for your new site here. You can edit this line in _config.yml. It will appear in your document head meta (for Google search results) and in your feed.xml site description.</subtitle><author><name>KimDohwan24</name></author><entry><title type="html">주식 조회 서비스 성능 최적화</title><link href="https://kimdohwan24.github.io/TIL/til/2026/05/19/TIL-36.html" rel="alternate" type="text/html" title="주식 조회 서비스 성능 최적화" /><published>2026-05-19T00:00:00+00:00</published><updated>2026-05-19T00:00:00+00:00</updated><id>https://kimdohwan24.github.io/TIL/til/2026/05/19/TIL-36</id><content type="html" xml:base="https://kimdohwan24.github.io/TIL/til/2026/05/19/TIL-36.html"><![CDATA[<h2 id="개요">개요</h2>

<p>AI 뉴스 기반 주식 추천 서비스(StockAI)에서 <strong>조회 속도 저하</strong> 문제를 분석하고, BE(Spring Boot) + FE(Next.js) 전체 스택에 걸쳐 13개 최적화 항목을 도출. 순차적 병목 → 병렬화, 2왕복 → 1왕복, 중복 요청 → 캐시 재사용 등의 원칙으로 접근.</p>

<hr />

<h2 id="1-국내-주식-배치-가격-조회-순차--병렬">1. 국내 주식 배치 가격 조회: 순차 → 병렬</h2>

<h3 id="before">Before</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>StockPriceBatchService.processChunk()
├─ stockCode[0]: cache miss → KIS API → 125ms
├─ stockCode[1]: cache miss → KIS API → 125ms
├─ stockCode[2]: cache miss → KIS API → 125ms
├─ ... (50개 순차 대기)
└─ stockCode[49]: cache miss → KIS API → 125ms
총 소요: ~6.25초
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Semaphore(10)</code>, <code class="language-plaintext highlighter-rouge">RateLimiter(8.0/sec)</code>로 동시성 제어는 있으나 for문 안에서 순차 <code class="language-plaintext highlighter-rouge">acquire()</code> 후 동기 호출</li>
  <li>해외 주식(<code class="language-plaintext highlighter-rouge">OverseasStockPriceBatchService</code>)은 이미 <code class="language-plaintext highlighter-rouge">@Async</code> + <code class="language-plaintext highlighter-rouge">CompletableFuture</code> 병렬 패턴인데 국내만 누락</li>
</ul>

<h3 id="after">After</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>StockPriceBatchService.processChunk()
├─ 1차: 캐시 조회 (cache hit 즉시 반환)
└─ 2차: cache miss 항목만 CompletableFuture.allOf() 병렬 dispatch
    ├─ Future[0]: @Async → KIS API
    ├─ Future[1]: @Async → KIS API
    ├─ ... (Semaphore=10, 최대 10개 동시)
    └─ Future[49]: @Async → KIS API
총 소요: ~6.25초 (50개 ÷ 8 TPS), 하지만 스레드는 블록하지 않음
</code></pre></div></div>

<h3 id="핵심-인사이트">핵심 인사이트</h3>
<ul>
  <li><strong>동시성 제어 도구(Semaphore, RateLimiter)와 호출 방식(동기/비동기)은 독립적</strong> — 제어 도구가 있다고 병렬이 되는 건 아님. <code class="language-plaintext highlighter-rouge">@Async</code> + <code class="language-plaintext highlighter-rouge">CompletableFuture</code>로 스레드에 작업을 분산해야 Semaphore가 의미 있음</li>
  <li>RateLimiter 초당 8건 제약이 병목 → 50건 처리에 <strong>최소 50÷8 ≈ 6.25초</strong> 필요. Semaphore=10으로 10개 스레드가 동시에 acquire()해도 RateLimiter가 8 TPS로 직렬화하므로, 시간 단축은 RateLimiter 한도 완화 전제</li>
  <li>병렬화의 진짜 이점은 <strong>스레드 블록 해제</strong> — 순차 코드에서는 호출 스레드가 6.25초 내내 점유되지만, <code class="language-plaintext highlighter-rouge">@Async</code>에서는 Future를 반환 후 스레드가 즉시 해제됨</li>
</ul>

<h3 id="효과">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>50종목 배치 응답 시간</td>
      <td>~6.25초 (호출 스레드 점유)</td>
      <td>~6.25초 (RateLimiter 한도)</td>
    </tr>
    <tr>
      <td>KIS API 호출 방식</td>
      <td>순차 동기 block</td>
      <td>병렬 비동기 (스레드 논블록)</td>
    </tr>
    <tr>
      <td>호출 스레드 점유 시간</td>
      <td>~6.25초</td>
      <td>즉시 반환 (Future 위임)</td>
    </tr>
    <tr>
      <td>타임아웃 처리</td>
      <td>없음</td>
      <td><code class="language-plaintext highlighter-rouge">allOf().get(20s)</code> + cancel</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="2-oauth-webclient-매번-생성--싱글톤-재사용">2. OAuth WebClient: 매번 생성 → 싱글톤 재사용</h2>

<h3 id="before-1">Before</h3>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="nc">WebClient</span> <span class="nf">getOAuthClient</span><span class="o">()</span> <span class="o">{</span>
    <span class="k">return</span> <span class="nc">WebClient</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
            <span class="o">.</span><span class="na">baseUrl</span><span class="o">(</span><span class="n">kisConfig</span><span class="o">.</span><span class="na">getOauthUrl</span><span class="o">())</span>
            <span class="o">.</span><span class="na">build</span><span class="o">();</span>  <span class="c1">// 매 호출 시 새 인스턴스</span>
<span class="o">}</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">WebClient.builder().build()</code>는 내부적으로 <strong>새 Reactor Netty ConnectionProvider</strong> 생성</li>
  <li>커넥션 풀 생성 비용 + TCP handshake + SSL 협상이 매 토큰 갱신마다 발생</li>
  <li>KIS 토큰은 하루에도 수십 번 갱신 가능 (TTL 만료, 회수 등)</li>
</ul>

<h3 id="after-1">After</h3>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// WebClientConfig.java</span>
<span class="nd">@Bean</span><span class="o">(</span><span class="s">"kisOAuthWebClient"</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">WebClient</span> <span class="nf">kisOAuthWebClient</span><span class="o">(</span><span class="nc">KisConfig</span> <span class="n">kisConfig</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">HttpClient</span> <span class="n">httpClient</span> <span class="o">=</span> <span class="nc">HttpClient</span><span class="o">.</span><span class="na">create</span><span class="o">()</span>
            <span class="o">.</span><span class="na">option</span><span class="o">(</span><span class="nc">ChannelOption</span><span class="o">.</span><span class="na">CONNECT_TIMEOUT_MILLIS</span><span class="o">,</span> <span class="mi">5000</span><span class="o">)</span>
            <span class="o">.</span><span class="na">responseTimeout</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">5</span><span class="o">));</span>
    <span class="k">return</span> <span class="nc">WebClient</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
            <span class="o">.</span><span class="na">baseUrl</span><span class="o">(</span><span class="n">kisConfig</span><span class="o">.</span><span class="na">getOauthUrl</span><span class="o">())</span>
            <span class="o">.</span><span class="na">clientConnector</span><span class="o">(</span><span class="k">new</span> <span class="nc">ReactorClientHttpConnector</span><span class="o">(</span><span class="n">httpClient</span><span class="o">))</span>
            <span class="o">.</span><span class="na">build</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="핵심-인사이트-1">핵심 인사이트</h3>
<ul>
  <li><strong>WebClient는 무상태(stateless)이지만 내부 ConnectionProvider는 상태ful</strong> — <code class="language-plaintext highlighter-rouge">build()</code>마다 새 커넥션 풀이 생성되고, 기존 풀의 커넥션은 close되지 않은 채 방치됨 (메모리 leak 가능)</li>
  <li>Spring에서 HTTP 클라이언트 Bean은 <strong>반드시 싱글톤</strong>으로 관리해야 커넥션 재사용이 가능</li>
  <li>위 코드에서는 명시적 <code class="language-plaintext highlighter-rouge">ConnectionProvider</code> 없이 <code class="language-plaintext highlighter-rouge">HttpClient.create()</code>를 사용하므로 <strong>기본 풀(무제한 idle)이 적용됨</strong> — Section 3의 커넥션 풀 설정을 OAuth용 WebClient에도 동일하게 적용해야 stale connection 문제가 발생하지 않음</li>
</ul>

<h3 id="효과-1">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>토큰 발급 시 커넥션 생성</td>
      <td>매번 (TCP+SSL ~100ms)</td>
      <td>풀에서 재사용 (~1ms)</td>
    </tr>
    <tr>
      <td>메모리 누수 위험</td>
      <td>누적 ConnectionProvider</td>
      <td>단일 인스턴스</td>
    </tr>
    <tr>
      <td>stale connection 위험</td>
      <td>높음 (기본 풀 사용 시)</td>
      <td>낮음 (명시적 풀 설정 시)</td>
    </tr>
    <tr>
      <td>타임아웃 보장</td>
      <td>없음</td>
      <td>connect 5s, response 5s</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="3-webclient-커넥션-풀-설정">3. WebClient 커넥션 풀 설정</h2>

<h3 id="before-2">Before</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Reactor Netty 기본 ConnectionProvider:
- maxConnections: 500 (ConnectionProvider.pool 생성 시)
- maxIdleTime: 무한 (idle 커넥션 정리 없음)
- maxLifeTime: 무한
- evictInBackground: 없음
</code></pre></div></div>

<ul>
  <li>500개 커넥션이 필요 없는 서비스인데 기본값 사용</li>
  <li>idle 커넥션이 영구 생존 → DB/대상 서버에서 timeout이 발생해도 클라이언트는 모름 → stale connection 에러</li>
</ul>

<h3 id="after-2">After</h3>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">ConnectionProvider</span> <span class="n">provider</span> <span class="o">=</span> <span class="nc">ConnectionProvider</span><span class="o">.</span><span class="na">builder</span><span class="o">(</span><span class="s">"kis-pool"</span><span class="o">)</span>
    <span class="o">.</span><span class="na">maxConnections</span><span class="o">(</span><span class="mi">100</span><span class="o">)</span>
    <span class="o">.</span><span class="na">pendingAcquireMaxCount</span><span class="o">(</span><span class="mi">500</span><span class="o">)</span>
    <span class="o">.</span><span class="na">pendingAcquireTimeout</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">10</span><span class="o">))</span>
    <span class="o">.</span><span class="na">maxIdleTime</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">30</span><span class="o">))</span>
    <span class="o">.</span><span class="na">maxLifeTime</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">60</span><span class="o">))</span>
    <span class="o">.</span><span class="na">evictInBackground</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">30</span><span class="o">))</span>
    <span class="o">.</span><span class="na">build</span><span class="o">();</span>
</code></pre></div></div>

<h3 id="핵심-인사이트-2">핵심 인사이트</h3>
<ul>
  <li><strong>maxIdleTime과 maxLifeTime의 차이</strong> — idleTime은 마지막 사용 후 경과, lifeTime은 커넥션 생성 후 경과. 둘 다 설정해야 오래된 커넥션이 정리됨</li>
  <li><code class="language-plaintext highlighter-rouge">evictInBackground</code>는 별도 스레드에서 주기적으로 정리. 없으면 커넥션이 다음 요청 시점에야 close됨</li>
  <li>KIS API는 1초당 8건 제한이므로 100개 커넥션으로 충분. 과도한 풀 크기는 리소스 낭비</li>
</ul>

<h3 id="효과-2">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>maxConnections</td>
      <td>500</td>
      <td>100</td>
    </tr>
    <tr>
      <td>idle 커넥션 정리</td>
      <td>없음</td>
      <td>30초</td>
    </tr>
    <tr>
      <td>stale connection 위험</td>
      <td>높음</td>
      <td>낮음 (60초 lifeTime + evictInBackground)</td>
    </tr>
    <tr>
      <td>메모리 사용</td>
      <td>불필요한 커넥션 유지</td>
      <td>적정 수준 유지</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="4-카탈로그--가격-통합-2왕복--1왕복">4. 카탈로그 + 가격 통합: 2왕복 → 1왕복</h2>

<h3 id="before-3">Before</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FE (Server Component)
  ├─ 1st: GET /api/stocks?page=0 → 종목 목록 [{stockCode, name, sector...}]
  │         응답에 가격 없음 (또는 캐시에 없으면 withoutPrice)
  └─ 2nd: POST /api/stocks/prices → {005930: {price:...}, 000660: {price:...}}
            ↑ 1st 응답의 stockCode 목록이 필요 → 반드시 순차

왕복: 2회 (waterfall)
</code></pre></div></div>

<ul>
  <li>BE에 이미 <code class="language-plaintext highlighter-rouge">StockCatalogWithPriceResponse</code> DTO와 <code class="language-plaintext highlighter-rouge">enrichWithPrice()</code> 메서드가 존재</li>
  <li>But <strong>캐시에 없는 가격은 <code class="language-plaintext highlighter-rouge">withoutPrice</code>로 반환</strong> → FE가 별도 batch 호출 필수</li>
</ul>

<h3 id="after-3">After</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FE (Server Component)
  └─ 1st: GET /api/stocks?page=0 → {stockCode, name, sector, price, changeRate...}
           enrichWithPrice()에서 캐시 miss 시 StockPriceService로 실시간 조회
           (조회 결과는 @Cacheable에 의해 캐시에도 저장됨)

왕복: 1회
</code></pre></div></div>

<h3 id="핵심-인사이트-3">핵심 인사이트</h3>
<ul>
  <li><strong>Backend-Driven Enrichment 원칙</strong> — 클라이언트가 조합해야 할 데이터는 서버에서 미리 합쳐서 반환하는 것이 네트워크 왕복을 줄이는 근본적 해결책</li>
  <li><code class="language-plaintext highlighter-rouge">enrichWithPrice()</code>에서 “캐시에 없으면 빈 값 반환”은 <strong>안전하지만 비효율적</strong> — 실시간 조회로 채워도 <code class="language-plaintext highlighter-rouge">@Cacheable</code>에 의해 후속 요청은 캐시 hit가 됨</li>
  <li>다수의 cache miss가 한꺼번에 발생할 수 있으므로 <strong>배치 병렬화 선행 필수</strong></li>
</ul>

<h3 id="효과-3">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SSR 시 HTTP 왕복</td>
      <td>2회 (waterfall)</td>
      <td>1회</td>
    </tr>
    <tr>
      <td>FE→BE 요청 수</td>
      <td>2</td>
      <td>1</td>
    </tr>
    <tr>
      <td>TTFB (캐시 hit 시)</td>
      <td>~200ms</td>
      <td>~100ms</td>
    </tr>
    <tr>
      <td>TTFB (캐시 miss 시)</td>
      <td>~6.5초+</td>
      <td>~1.5초 (배치 병렬화 후)</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="5-hikaricp-커넥션-풀-튜닝">5. HikariCP 커넥션 풀 튜닝</h2>

<h3 id="before-4">Before</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Spring Boot 기본 HikariCP:
- maximumPoolSize: 10
- minimumIdle: 10
- connectionTimeout: 30000ms (30초)
- leakDetectionThreshold: 0 (비활성)
</code></pre></div></div>

<ul>
  <li>10개 풀로 고부하 시 커넥션 대기 발생 가능</li>
  <li>30초 타임아웃은 너무 김 → 장애 시 장시간 응답 지연</li>
  <li>커넥션 누수 감지 없음</li>
</ul>

<h3 id="after-4">After</h3>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spring.datasource.hikari</span><span class="pi">:</span>
  <span class="na">maximum-pool-size</span><span class="pi">:</span> <span class="m">20</span>
  <span class="na">minimum-idle</span><span class="pi">:</span> <span class="m">5</span>
  <span class="na">idle-timeout</span><span class="pi">:</span> <span class="m">300000</span>       <span class="c1"># 5분</span>
  <span class="na">max-lifetime</span><span class="pi">:</span> <span class="m">600000</span>       <span class="c1"># 10분</span>
  <span class="na">connection-timeout</span><span class="pi">:</span> <span class="m">5000</span>   <span class="c1"># 5초</span>
  <span class="na">leak-detection-threshold</span><span class="pi">:</span> <span class="m">10000</span>  <span class="c1"># 10초</span>
</code></pre></div></div>

<h3 id="핵심-인사이트-4">핵심 인사이트</h3>
<ul>
  <li><strong>maximumPoolSize는 “동시 연결 수”가 아니라 “풀의 최대 크기”</strong> — 10개가 다 사용 중이면 11번째 요청은 대기. 서비스의 동시 DB 쿼리 수(동시 가격 조회 + 카탈로그 + 인증 등)를 기준으로 설정해야 함</li>
  <li><code class="language-plaintext highlighter-rouge">connection-timeout=5000</code>은 “5초 내 커넥션 획득 실패 시 예외” → 30초 동안 스레드가 블록되는 것 방지</li>
  <li><code class="language-plaintext highlighter-rouge">leak-detection-threshold=10000</code>은 커넥션을 10초 이상 반납하지 않으면 WARN 로그 → 버그 조기 발견</li>
  <li><strong><code class="language-plaintext highlighter-rouge">max-lifetime=10분</code>은 MySQL <code class="language-plaintext highlighter-rouge">wait_timeout</code>(기본 28800초=8시간)보다 짧게 설정하는 원칙에는 맞으나, 지나치게 공격적</strong> — 풀 전체 커넥션이 10분마다 교체되어 불필요한 TCP 재연결 발생. 보통 5~30분 사이가 적절하며, MySQL의 <code class="language-plaintext highlighter-rouge">wait_timeout</code>과 <code class="language-plaintext highlighter-rouge">interactive_timeout</code>을 확인 후 그보다 2~3분 짧게 설정하는 것이 권장됨</li>
</ul>

<h3 id="효과-4">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>최대 동시 DB 커넥션</td>
      <td>10</td>
      <td>20</td>
    </tr>
    <tr>
      <td>장애 시 응답 지연</td>
      <td>최대 30초 블록</td>
      <td>5초 타임아웃</td>
    </tr>
    <tr>
      <td>커넥션 누수 감지</td>
      <td>없음</td>
      <td>10초 경고</td>
    </tr>
    <tr>
      <td>idle 최소 유지</td>
      <td>10 (불필요한 유지)</td>
      <td>5</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="6-balancesummary-중복-호출-통합">6. Balance/Summary 중복 호출 통합</h2>

<h3 id="before-5">Before</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /api/account/balance       → kisApiClient.getBalance() → output1
GET /api/account/balance/summary → kisApiClient.getBalance() → output2
                                                          ↑ 같은 KIS API 2회 호출
</code></pre></div></div>

<ul>
  <li>클라이언트가 두 API를 연속 호출하면 동일한 KIS API 요청이 2번 발생</li>
  <li>KIS API는 초당 호출 제한이 있어 중복 호출이 제한 소모</li>
</ul>

<h3 id="after-5">After</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>getBalanceFull() → kisApiClient.getBalance() 1회 호출
  ├─ getBalance():      캐시된 응답에서 output1 추출
  └─ getBalanceSummary(): 캐시된 응답에서 output2 추출
</code></pre></div></div>

<h3 id="핵심-인사이트-5">핵심 인사이트</h3>
<ul>
  <li><strong>같은 외부 API를 여러 서비스 메서드에서 호출할 때 캐시 또는 통합이 필요</strong> — 응답의 다른 부분(output1 vs output2)만 필요하더라도, 요청 자체는 동일하므로 1회만 호출</li>
  <li>KIS API의 <code class="language-plaintext highlighter-rouge">getBalance()</code>는 무거운 호출 (잔고 전체 조회). TTL 10초 캐시면 충분 (실시간성 요구 낮음)</li>
</ul>

<h3 id="효과-5">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>KIS API 호출</td>
      <td>2회/요청</td>
      <td>1회/요청</td>
    </tr>
    <tr>
      <td>초당 호출 제한 소모</td>
      <td>2</td>
      <td>1</td>
    </tr>
    <tr>
      <td>응답 시간 (2번째)</td>
      <td>~300ms</td>
      <td>캐시 hit &lt;5ms</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="7-db-인덱스-sector-단일-컬럼">7. DB 인덱스: sector 단일 컬럼</h2>

<h3 id="before-6">Before</h3>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 복합 인덱스만 존재</span>
<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_stocks_market_type_sector</span> <span class="k">ON</span> <span class="n">stocks</span> <span class="p">(</span><span class="n">market_type</span><span class="p">,</span> <span class="n">sector</span><span class="p">);</span>

<span class="c1">-- findBySector() 쿼리:</span>
<span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">stocks</span> <span class="k">WHERE</span> <span class="n">sector</span> <span class="o">=</span> <span class="s1">'반도체'</span>
<span class="c1">-- → 복합 인덱스의 선행 컬럼(market_type)이 조건에 없으므로 인덱스 미사용 → Seq Scan</span>
</code></pre></div></div>

<h3 id="after-6">After</h3>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_stocks_sector</span> <span class="k">ON</span> <span class="n">stocks</span> <span class="p">(</span><span class="n">sector</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="핵심-인사이트-6">핵심 인사이트</h3>
<ul>
  <li><strong>복합 인덱스의 최 좌선 원칙(Leftmost Prefix Rule)</strong> — <code class="language-plaintext highlighter-rouge">(A, B)</code> 인덱스는 <code class="language-plaintext highlighter-rouge">WHERE A=?</code>와 <code class="language-plaintext highlighter-rouge">WHERE A=? AND B=?</code>에만 사용 가능. <code class="language-plaintext highlighter-rouge">WHERE B=?</code>는 인덱스를 타지 않음</li>
  <li>단일 컬럼 인덱스 추가는 디스크 공간을 약간 차지하지만, 필터 전용 쿼리의 성능은 극적으로 개선</li>
</ul>

<h3 id="효과-6">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">findBySector()</code> 실행 계획</td>
      <td>Seq Scan</td>
      <td>Index Scan</td>
    </tr>
    <tr>
      <td>2000행 기준 조회 시간</td>
      <td>~15ms</td>
      <td>~1ms</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="8-circuit-breaker-fallback-장애-전파--graceful-degradation">8. Circuit Breaker Fallback: 장애 전파 → Graceful Degradation</h2>

<h3 id="before-7">Before</h3>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="nc">StockPriceResponse</span> <span class="nf">getStockPriceFallback</span><span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nf">RuntimeException</span><span class="o">(</span><span class="s">"Circuit open"</span><span class="o">);</span>  <span class="c1">// 호출부로 예외 전파</span>
<span class="o">}</span>
</code></pre></div></div>

<ul>
  <li>CB가 열리면 모든 가격 조회가 500 에러</li>
  <li>프론트엔드는 에러 표시 또는 빈 값</li>
</ul>

<h3 id="after-7">After</h3>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="nc">StockPriceResponse</span> <span class="n">lastGoodResponse</span><span class="o">;</span>  <span class="c1">// 마지막 정상 응답 캐시</span>

<span class="kd">private</span> <span class="nc">StockPriceResponse</span> <span class="nf">getStockPriceFallback</span><span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">StockPriceResponse</span> <span class="n">response</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">StockPriceResponse</span><span class="o">();</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">lastGoodResponse</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">response</span> <span class="o">=</span> <span class="n">lastGoodResponse</span><span class="o">;</span>           <span class="c1">// stale data라도 반환</span>
        <span class="n">response</span><span class="o">.</span><span class="na">setError</span><span class="o">(</span><span class="s">"CIRCUIT_OPEN_STALE"</span><span class="o">);</span>
    <span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
        <span class="n">response</span><span class="o">.</span><span class="na">setError</span><span class="o">(</span><span class="s">"CIRCUIT_OPEN"</span><span class="o">);</span>
    <span class="o">}</span>
    <span class="k">return</span> <span class="n">response</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="핵심-인사이트-7">핵심 인사이트</h3>
<ul>
  <li><strong>Circuit Breaker의 목적은 “장애 격리”</strong> — fallback에서 예외를 throw하면 격리가 아니라 형태만 바뀐 장애 전파</li>
  <li>호출부가 이미 <code class="language-plaintext highlighter-rouge">error</code> 필드를 체크하는 구조(<code class="language-plaintext highlighter-rouge">if (price.getError() == null)</code>)라면, fallback이 에러 DTO를 반환하는 것이 자연스러운 흐름</li>
  <li>사용자는 “일시적 오류” 메시지를 보는 대신 “가격 정보 없음”으로 부드럽게 저하</li>
  <li><strong>Stale Data 전략</strong> — CB가 열리기 직전의 마지막 정상 응답을 반환하면, 사용자는 0이나 빈 값 대신 “마지막으로 확인된 가격”을 볼 수 있어 UX가 크게 개선됨. 단, 데이터가 오래될 수 있으므로 FE에서 <code class="language-plaintext highlighter-rouge">error</code> 필드로 표시 필요</li>
</ul>

<h3 id="효과-7">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CB open 시 FE 응답</td>
      <td>500 에러 (화면 깨짐)</td>
      <td>정상 응답 + 에러 표시 (또는 stale data)</td>
    </tr>
    <tr>
      <td>연쇄 장애</td>
      <td>가능</td>
      <td>차단</td>
    </tr>
    <tr>
      <td>UX</td>
      <td>전체 페이지 에러</td>
      <td>개별 종목 가격 숨김 또는 마지막 가격 표시</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="9-fe-카탈로그-waterfall-제거">9. FE 카탈로그 Waterfall 제거</h2>

<h3 id="before-8">Before</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Server Component:
  const catalog = await getStocks(...)      // ~200ms
  const prices = await getBatchPrices(codes) // ~6s (전체 cache miss 시)
  // 총: ~6.2초 (순차)
</code></pre></div></div>

<h3 id="after-8">After</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Server Component:
  const catalog = await getStocks(...)  // 이미 가격 포함 (~200ms + 실시간 조회)
  const prices = catalog에서 추출       // 0ms (추출만)
  // 총: ~200ms (캐시 hit) or ~1.5s (miss)
</code></pre></div></div>

<h3 id="핵심-인사이트-8">핵심 인사이트</h3>
<ul>
  <li><strong>Server Component에서의 waterfall은 TTFB에 직접 영향</strong> — CSR에서는 병렬 useSWR이 가능하지만, SSR에서는 <code class="language-plaintext highlighter-rouge">await</code>의 순차 실행이 불가피</li>
  <li>해결책은 “FE에서 병렬화”가 아니라 <strong>“BE에서 합쳐서 보내기”</strong> — 근원적 해결</li>
  <li>후속 페이지(infinite scroll)의 delta 조회는 유지: SSR은 첫 페이지만 담당</li>
</ul>

<h3 id="효과-8">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SSR HTTP 왕복</td>
      <td>2</td>
      <td>1</td>
    </tr>
    <tr>
      <td>TTFB (cache hit)</td>
      <td>~400ms</td>
      <td>~200ms</td>
    </tr>
    <tr>
      <td>TTFB (cache miss)</td>
      <td>~6.5초</td>
      <td>~1.5초</td>
    </tr>
    <tr>
      <td>FE 복잡도</td>
      <td>서버에서 batch 호출 필요</td>
      <td>initialData에서 바로 추출</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="10-tradepanel-raw-fetch--swr-전환">10. TradePanel raw fetch → SWR 전환</h2>

<h3 id="before-9">Before</h3>
<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">getHoldings</span><span class="p">().</span><span class="nx">then</span><span class="p">(</span><span class="nx">setUserHolding</span><span class="p">).</span><span class="k">catch</span><span class="p">(...);</span>
<span class="p">},</span> <span class="p">[</span><span class="nx">isAuthenticated</span><span class="p">,</span> <span class="nx">stockCode</span><span class="p">]);</span>
<span class="c1">// 페이지 이동 시마다 재요청, dedup 없음</span>
</code></pre></div></div>

<h3 id="after-9">After</h3>
<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">{</span> <span class="na">data</span><span class="p">:</span> <span class="nx">holdings</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useSWR</span><span class="p">(</span>
  <span class="nx">isAuthenticated</span> <span class="p">?</span> <span class="dl">'</span><span class="s1">user-holdings</span><span class="dl">'</span> <span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
  <span class="nx">getHoldings</span><span class="p">,</span>
  <span class="p">{</span> <span class="na">dedupingInterval</span><span class="p">:</span> <span class="mi">5000</span> <span class="p">}</span>
<span class="p">);</span>
<span class="c1">// 동일 key면 dedup, Dashboard와 캐시 공유</span>
</code></pre></div></div>

<h3 id="핵심-인사이트-9">핵심 인사이트</h3>
<ul>
  <li><strong>raw fetch + useEffect는 SWR의 dedup, 캐시, 재검증 이점을 모두 포기하는 것</strong> — 동일한 API를 다른 컴포넌트에서 다른 전략으로 호출하면 중복 요청 발생</li>
  <li>SWR key를 통일하면 여러 컴포넌트가 같은 캐시를 공유 → 네트워크 요청 1회로 N개 컴포넌트 업데이트</li>
</ul>

<h3 id="효과-9">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>페이지 이동 시 holdings 요청</td>
      <td>매번</td>
      <td>dedup (10s 내 중복 제거)</td>
    </tr>
    <tr>
      <td>Dashboard ↔ 상세 페이지 캐시</td>
      <td>공유 안됨</td>
      <td>동일 key로 공유</td>
    </tr>
    <tr>
      <td>주문 후 갱신</td>
      <td>수동 fetch</td>
      <td><code class="language-plaintext highlighter-rouge">mutate('user-holdings')</code></td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="11-렌더-중-dispatch--useeffect">11. 렌더 중 dispatch → useEffect</h2>

<h3 id="before-10">Before</h3>
<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="nx">deltaPrices</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">dispatch</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">merge</span><span class="dl">'</span><span class="p">,</span> <span class="na">prices</span><span class="p">:</span> <span class="nx">deltaPrices</span> <span class="p">});</span>
<span class="p">}</span>
<span class="c1">// 렌더 함수 본문 내에서 state 변경 → React가 두 번 렌더링함</span>
</code></pre></div></div>

<ul>
  <li>SWR이 <code class="language-plaintext highlighter-rouge">deltaPrices</code>를 갱신 → 리렌더 1회 + dispatch로 reducer 상태 변경 → 리렌더 1회 = <strong>동일 데이터에 2회 렌더</strong></li>
</ul>

<h3 id="after-10">After</h3>
<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">deltaPrices</span><span class="p">)</span> <span class="nx">dispatch</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">merge</span><span class="dl">'</span><span class="p">,</span> <span class="na">prices</span><span class="p">:</span> <span class="nx">deltaPrices</span> <span class="p">});</span>
<span class="p">},</span> <span class="p">[</span><span class="nx">deltaPrices</span><span class="p">]);</span>
</code></pre></div></div>

<h3 id="핵심-인사이트-10">핵심 인사이트</h3>
<ul>
  <li><strong>React에서 렌더 중 state 변경은 “바로 다시 렌더”를 트리거</strong> — <code class="language-plaintext highlighter-rouge">useEffect</code>는 렌더 완료 후 실행되므로, SWR 갱신 → 리렌더(데이터 반영) → useEffect에서 dispatch → 리렌더(가격 반영)이 되지만, 첫 리렌더에서 이전 가격을 보여주므로 깜빡임이 줄어듦</li>
  <li>더 근본적으로는 <code class="language-plaintext highlighter-rouge">accumulatedPrices</code>를 <code class="language-plaintext highlighter-rouge">useMemo</code>로 계산하면 dispatch 없이 합성 가능:</li>
</ul>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">accumulatedPrices</span> <span class="o">=</span> <span class="nx">useMemo</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">merged</span> <span class="o">=</span> <span class="p">{</span> <span class="p">...</span><span class="nx">initialPrices</span> <span class="p">};</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">deltaPrices</span><span class="p">)</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">assign</span><span class="p">(</span><span class="nx">merged</span><span class="p">,</span> <span class="nx">deltaPrices</span><span class="p">);</span>
  <span class="k">return</span> <span class="nx">merged</span><span class="p">;</span>
<span class="p">},</span> <span class="p">[</span><span class="nx">initialPrices</span><span class="p">,</span> <span class="nx">deltaPrices</span><span class="p">]);</span>
</code></pre></div></div>

<p>이렇게 하면 reducer와 dispatch를 완전히 제거할 수 있으며, 리렌더도 SWR 갱신 시 1회만 발생함</p>

<h3 id="효과-10">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>배치 가격 도착 시 리렌더</td>
      <td>2회</td>
      <td>1회</td>
    </tr>
    <tr>
      <td>렌더 중 state 변경 경고</td>
      <td>가능</td>
      <td>없음</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="12-price-badge-retry-범위-축소">12. Price Badge retry 범위 축소</h2>

<h3 id="before-11">Before</h3>
<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">mutate</span><span class="p">(</span>
  <span class="p">(</span><span class="nx">key</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">key</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nx">key</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">batch-prices</span><span class="dl">'</span><span class="p">,</span>
  <span class="kc">undefined</span><span class="p">,</span>
  <span class="p">{</span> <span class="na">revalidate</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span>
<span class="p">);</span>
<span class="c1">// → batch-prices로 시작하는 모든 SWR key 재검증</span>
</code></pre></div></div>

<ul>
  <li>1개 종목 에러 → 전체 배치 재요청</li>
</ul>

<h3 id="after-11">After</h3>
<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 해당 종목 단건 API로 대체</span>
<span class="kd">const</span> <span class="nx">retryPrice</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getStockPrice</span><span class="p">(</span><span class="nx">stockCode</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="핵심-인사이트-11">핵심 인사이트</h3>
<ul>
  <li><strong>SWR mutate의 필터 매칭은 편리하지만 비용이 큼</strong> — prefix 매칭으로 수십 개 key를 재검증하면, 정상 가격까지 불필요하게 다시 fetch</li>
  <li>에러 발생 종목만 단건 API로 조회하는 것이 네트워크 비용 최소</li>
</ul>

<h3 id="효과-11">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>retry 시 재요청 범위</td>
      <td>전체 배치 (50종목)</td>
      <td>실패 1종목</td>
    </tr>
    <tr>
      <td>네트워크 비용</td>
      <td>POST + 50종목 응답</td>
      <td>GET + 1종목 응답</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="13-캔들-데이터-swr-전환">13. 캔들 데이터 SWR 전환</h2>

<h3 id="before-12">Before</h3>
<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">[</span><span class="nx">state</span><span class="p">,</span> <span class="nx">dispatch</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useReducer</span><span class="p">(</span><span class="nx">reducer</span><span class="p">,</span> <span class="nx">initialState</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">fetchCandleData</span> <span class="o">=</span> <span class="nx">useCallback</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">dispatch</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">FETCH_START</span><span class="dl">'</span> <span class="p">});</span>
  <span class="nx">getDailyCandles</span><span class="p">({...}).</span><span class="nx">then</span><span class="p">(</span><span class="nx">d</span> <span class="o">=&gt;</span> <span class="nx">dispatch</span><span class="p">({</span><span class="na">type</span><span class="p">:</span><span class="dl">'</span><span class="s1">FETCH_CANDLES_SUCCESS</span><span class="dl">'</span><span class="p">,</span> <span class="na">payload</span><span class="p">:</span> <span class="nx">d</span><span class="p">}));</span>
<span class="p">},</span> <span class="p">[...]);</span>

<span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="nx">fetchCandleData</span><span class="p">();</span> <span class="p">},</span> <span class="p">[</span><span class="nx">fetchCandleData</span><span class="p">]);</span>
</code></pre></div></div>

<h3 id="after-12">After</h3>
<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">{</span> <span class="na">data</span><span class="p">:</span> <span class="nx">candles</span><span class="p">,</span> <span class="na">isLoading</span><span class="p">:</span> <span class="nx">candlesLoading</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useSWR</span><span class="p">(</span>
  <span class="nx">stockCode</span> <span class="p">?</span> <span class="p">[</span><span class="dl">'</span><span class="s1">daily-candles</span><span class="dl">'</span><span class="p">,</span> <span class="nx">stockCode</span><span class="p">,</span> <span class="nx">period</span><span class="p">,</span> <span class="nx">startDate</span><span class="p">,</span> <span class="nx">endDate</span><span class="p">]</span> <span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
  <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">getDailyCandles</span><span class="p">({</span><span class="nx">stockCode</span><span class="p">,</span> <span class="nx">period</span><span class="p">,</span> <span class="nx">startDate</span><span class="p">,</span> <span class="nx">endDate</span><span class="p">}),</span>
  <span class="p">{</span> <span class="na">revalidateOnFocus</span><span class="p">:</span> <span class="kc">false</span> <span class="p">}</span>
<span class="p">);</span>

<span class="kd">const</span> <span class="p">{</span> <span class="na">data</span><span class="p">:</span> <span class="nx">minuteCandles</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useSWR</span><span class="p">(</span>
  <span class="nx">viewMode</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">minute</span><span class="dl">'</span> <span class="o">&amp;&amp;</span> <span class="nx">stockCode</span> <span class="p">?</span> <span class="p">[</span><span class="dl">'</span><span class="s1">minute-candles</span><span class="dl">'</span><span class="p">,</span> <span class="nx">stockCode</span><span class="p">]</span> <span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
  <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">getMinuteCandles</span><span class="p">(</span><span class="nx">stockCode</span><span class="p">),</span>
  <span class="p">{</span> <span class="na">revalidateOnFocus</span><span class="p">:</span> <span class="kc">false</span> <span class="p">}</span>
<span class="p">);</span>
</code></pre></div></div>

<h3 id="핵심-인사이트-12">핵심 인사이트</h3>
<ul>
  <li><strong>로딩/에러/데이터 상태 관리는 SWR이 이미 제공</strong> — <code class="language-plaintext highlighter-rouge">useReducer</code>로 직접 관리하면 코드 중복 + 버그 가능성 증가</li>
  <li>SWR key에 파라미터를 포함하면 파라미터 변경 시 자동 재요청 → <code class="language-plaintext highlighter-rouge">useCallback</code> 의존성 관리 불필요</li>
  <li><code class="language-plaintext highlighter-rouge">viewMode === 'minute'</code> 조건부 key(null이면 fetch 안 함)로 불필요한 API 호출 방지</li>
</ul>

<h3 id="효과-12">효과</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>상태 관리 코드</td>
      <td>~30행 (reducer + action types)</td>
      <td>제거</td>
    </tr>
    <tr>
      <td>캐시/dedup</td>
      <td>없음</td>
      <td>SWR 자동</td>
    </tr>
    <tr>
      <td>period 변경 시 재요청</td>
      <td>수동 useCallback 관리</td>
      <td>key 변경으로 자동</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="종합-효과-요약">종합 효과 요약</h2>

<table>
  <thead>
    <tr>
      <th>시나리오</th>
      <th>Before</th>
      <th>After</th>
      <th>개선율</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>카탈로그 첫 로드 (cache hit)</td>
      <td>2왕복 ~400ms</td>
      <td>1왕복 ~200ms</td>
      <td><strong>50%↓</strong></td>
    </tr>
    <tr>
      <td>카탈로그 첫 로드 (cache miss)</td>
      <td>2왕복 ~6.5초</td>
      <td>1왕복 ~6.25초 (RateLimiter 한도)</td>
      <td><strong>4%↓ + 요청 절반</strong></td>
    </tr>
    <tr>
      <td>국내 배치 가격 50종목</td>
      <td>~6.25초 (순차, 스레드 점유)</td>
      <td>~6.25초 (병렬, 스레드 논블록)</td>
      <td><strong>스레드 가용성↑</strong></td>
    </tr>
    <tr>
      <td>KIS 토큰 발급 커넥션 오버헤드</td>
      <td>~100ms/회</td>
      <td>~1ms/회</td>
      <td><strong>99%↓</strong></td>
    </tr>
    <tr>
      <td>Balance+Summary 동시 요청</td>
      <td>KIS API 2회</td>
      <td>1회+캐시</td>
      <td><strong>50%↓</strong></td>
    </tr>
    <tr>
      <td>CB open 시 사용자 경험</td>
      <td>500 에러 (전체 장애)</td>
      <td>stale data + 에러 표시</td>
      <td><strong>가용성↑</strong></td>
    </tr>
    <tr>
      <td>배치 가격 도착 시 리렌더</td>
      <td>2회</td>
      <td>1회</td>
      <td><strong>50%↓</strong></td>
    </tr>
    <tr>
      <td>종목 상세 페이지 이동</td>
      <td>holdings 중복 요청</td>
      <td>SWR dedup</td>
      <td><strong>중복 제거</strong></td>
    </tr>
    <tr>
      <td>badge retry 네트워크 비용</td>
      <td>50종목 재요청</td>
      <td>1종목 단건</td>
      <td><strong>98%↓</strong></td>
    </tr>
  </tbody>
</table>

<h3 id="핵심-교훈-3가지">핵심 교훈 3가지</h3>
<ol>
  <li><strong>서버에서 합쳐서 보내면 클라이언트 waterfall은 사라진다</strong> — FE 최적화보다 BE 응답 구조 개선이 근본적</li>
  <li><strong>동시성 제어 ≠ 병렬 처리</strong> — Semaphore와 RateLimiter는 제한일 뿐, <code class="language-plaintext highlighter-rouge">@Async</code>가 있어야 스레드가 분산됨</li>
  <li><strong>캐시 전략은 “miss 시 어떻게 채울 것인가”까지 설계해야 한다</strong> — hit만 최적화하면 miss 경로가 새 병목이 됨</li>
</ol>

<hr />

<h2 id="추가-고려-항목">추가 고려 항목</h2>

<h3 id="14-cache-stampede-방지">14. Cache Stampede 방지</h3>

<p>다수의 cache miss가 동시에 발생하면, 여러 스레드가 동일 키에 대해 외부 API를 중복 호출함.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// @Cacheable만 사용 시: 10개 스레드가 동시에 getStockPrice("005930") 호출 → KIS API 10회</span>
<span class="c1">// 해결: @Cacheable + CacheLoader 또는 stampede guard</span>
<span class="nd">@Cacheable</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="s">"stockPrice"</span><span class="o">,</span> <span class="n">key</span> <span class="o">=</span> <span class="s">"#stockCode"</span><span class="o">,</span> <span class="n">sync</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">StockPriceResponse</span> <span class="nf">getStockPrice</span><span class="o">(</span><span class="nc">String</span> <span class="n">stockCode</span><span class="o">)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>
</code></pre></div></div>

<ul>
  <li>Spring의 <code class="language-plaintext highlighter-rouge">sync = true</code>는 같은 키에 대해 <strong>1개 스레드만 로드</strong>하고 나머지는 대기 → 중복 API 호출 방지</li>
  <li>대규모 트래픽에서는 분산 락(Redis) 또는 <code class="language-plaintext highlighter-rouge">Caffeine</code>의 <code class="language-plaintext highlighter-rouge">RefreshAfterWrite</code>도 고려</li>
</ul>

<h3 id="15-fe-error-boundary-적용">15. FE Error Boundary 적용</h3>

<p>CB fallback이 정상 응답을 반환하더라도, FE 렌더링 중 예외(예: stale data의 null 필드 접근)가 발생하면 전체 페이지가 깨질 수 있음.</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">ErrorBoundary</span> <span class="na">fallback</span><span class="p">=</span><span class="si">{</span><span class="p">&lt;</span><span class="nc">StockCardSkeleton</span> <span class="p">/&gt;</span><span class="si">}</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nc">StockCard</span> <span class="na">stock</span><span class="p">=</span><span class="si">{</span><span class="nx">stock</span><span class="si">}</span> <span class="p">/&gt;</span>
<span class="p">&lt;/</span><span class="nc">ErrorBoundary</span><span class="p">&gt;</span>
</code></pre></div></div>

<ul>
  <li>개별 종목 카드 단위로 Error Boundary를 감싸면, 한 종목의 렌더 에러가 전체 목록에 영향을 주지 않음</li>
  <li>SSR에서는 Next.js의 <code class="language-plaintext highlighter-rouge">error.tsx</code>로 페이지 레벨 대응</li>
</ul>]]></content><author><name>KimDohwan24</name></author><category term="TIL" /><category term="Spring Boot" /><category term="Next.js" /><category term="Performance" /><category term="WebClient" /><category term="HikariCP" /><category term="SWR" /><category term="React" /><summary type="html"><![CDATA[개요]]></summary></entry><entry><title type="html">API 워터폴 제거로 전체조회 속도 개선하기</title><link href="https://kimdohwan24.github.io/TIL/til/2026/05/18/TIL-35.html" rel="alternate" type="text/html" title="API 워터폴 제거로 전체조회 속도 개선하기" /><published>2026-05-18T00:00:00+00:00</published><updated>2026-05-18T00:00:00+00:00</updated><id>https://kimdohwan24.github.io/TIL/til/2026/05/18/TIL-35</id><content type="html" xml:base="https://kimdohwan24.github.io/TIL/til/2026/05/18/TIL-35.html"><![CDATA[<h2 id="사전-개념">사전 개념</h2>

<p>이 글에서 반복해서 등장하는 세 가지 개념을 먼저 정리한다.</p>

<ul>
  <li><strong>SSR (Server-Side Rendering)</strong>: 서버에서 HTML을 완성해 브라우저에 보내는 방식. 클라이언트가 빈 화면을 보지 않고 곧바로 콘텐츠를 볼 수 있다. 단, 서버가 데이터를 가져와야 하므로 응답 자체가 느려질 수 있다.</li>
  <li><strong>ISR (Incremental Static Regeneration)</strong>: 정적 페이지를 빌드한 뒤 일정 시간(revalidate)이 지나면 백그라운드에서 다시 생성하는 방식. 매 요청마다 서버가 일할 필요 없으므로 응답이 빠르고, 캐시 만료 시에만 갱신된다.</li>
  <li><strong>Delta 조회</strong>: 이미 가지고 있는 데이터는 다시 요청하지 않고, 새로 필요한 데이터만(DELTA) 조회하는 방식. 전체 재조회를 피해 API 호출 횟수를 크게 줄일 수 있다.</li>
</ul>

<hr />

<h2 id="문제-상황">문제 상황</h2>

<p>주식 전체조회 페이지(<code class="language-plaintext highlighter-rouge">/stocks</code>, <code class="language-plaintext highlighter-rouge">/overseas-stocks</code>) 접속 시 체감 속도가 느렸다. 페이지 구성에 3개의 API가 호출되는데, 이 호출들이 <strong>직렬 워터폴</strong>로 동작하고 있었고, 스크롤로 추가 페이지를 로드할 때마다 <strong>이미 받은 데이터까지 전부 재조회</strong>하는 구조적 문제가 있었다.</p>

<hr />

<h2 id="원인-분석">원인 분석</h2>

<h3 id="1-ssr--client-hydration-직렬-워터폴">1. SSR → Client Hydration 직렬 워터폴</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SSR: getStocks(page=0)         ──→ HTML 전송 ──→ Hydration ──→ useEffect ──→ getBatchPrices()
     [200~500ms]                                    [100~300ms]              [200~500ms]
</code></pre></div></div>

<p>SSR에서 종목 목록만 가져오고, 시세는 클라이언트 마운트 후에야 요청된다. 시세 요청은 SSR 완료 → HTML 전송 → Hydration → useEffect 실행 이후에야 시작되므로 <strong>최소 1왕복 시간</strong>이 낭비된다.</p>

<h3 id="2-스크롤-시-전체-재조회-on-폭발">2. 스크롤 시 전체 재조회 (O(N²) 폭발)</h3>

<p>기존에는 SWR 키를 전체 종목코드를 join한 문자열로 생성했다:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">stableKey</span> <span class="o">=</span> <span class="nx">stockCodes</span><span class="p">.</span><span class="nx">slice</span><span class="p">().</span><span class="nx">sort</span><span class="p">().</span><span class="nx">join</span><span class="p">(</span><span class="dl">'</span><span class="s1">,</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">useSWR</span><span class="p">([</span><span class="dl">'</span><span class="s1">batch-prices</span><span class="dl">'</span><span class="p">,</span> <span class="nx">stableKey</span><span class="p">],</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">getBatchPrices</span><span class="p">(</span><span class="nx">stockCodes</span><span class="p">));</span>
</code></pre></div></div>

<p>페이지가 추가될 때마다 <code class="language-plaintext highlighter-rouge">stableKey</code>가 변경 → SWR이 <strong>새 요청으로 인식</strong> → 이미 받은 종목까지 전부 재조회:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">스크롤 페이지</th>
      <th style="text-align: center">누적 종목</th>
      <th style="text-align: center">API 호출 횟수</th>
      <th style="text-align: center">실제 필요 횟수</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">1</td>
      <td style="text-align: center">20</td>
      <td style="text-align: center">1</td>
      <td style="text-align: center">1</td>
    </tr>
    <tr>
      <td style="text-align: center">2</td>
      <td style="text-align: center">40</td>
      <td style="text-align: center">2</td>
      <td style="text-align: center">1</td>
    </tr>
    <tr>
      <td style="text-align: center">3</td>
      <td style="text-align: center">60</td>
      <td style="text-align: center">3</td>
      <td style="text-align: center">1</td>
    </tr>
    <tr>
      <td style="text-align: center">5</td>
      <td style="text-align: center">100</td>
      <td style="text-align: center">15</td>
      <td style="text-align: center">5</td>
    </tr>
  </tbody>
</table>

<p>5페이지까지 로드하면 <strong>15번</strong> chunk 요청이 발생하지만, 실제로는 5번이면 충분하다. <code class="language-plaintext highlighter-rouge">accumulatedPrices</code> 상태가 있었지만 <code class="language-plaintext highlighter-rouge">batchPrices ?? accumulatedPrices</code>에서 <code class="language-plaintext highlighter-rouge">batchPrices</code>가 우선하여 재조회를 막지 못했다.</p>

<h3 id="3-batch_chunk_size--20이-너무-작음">3. BATCH_CHUNK_SIZE = 20이 너무 작음</h3>

<p>100개 종목이면 5번의 POST 요청이 동시 발생. 백엔드 부하 시 429 에러 → 지수 백오프 재시도(최대 10.5초)가 연쇄 발생.</p>

<h3 id="4-force-dynamic으로-매-요청마다-백엔드-히트">4. force-dynamic으로 매 요청마다 백엔드 히트</h3>

<p><code class="language-plaintext highlighter-rouge">export const dynamic = 'force-dynamic'</code>으로 ISR 캐시 없이 매번 백엔드 호출.</p>

<h3 id="5-fetcher-재시도-3회--403-포함">5. fetcher 재시도 3회 + 403 포함</h3>

<p>재시도 상태코드에 403이 포함되어 있었고, 최대 3회 재시도로 10.5초까지 대기 가능.</p>

<hr />

<h2 id="적용한-개선">적용한 개선</h2>

<h3 id="p0-delta-시세-조회">P0: Delta 시세 조회</h3>

<p><strong>핵심 변경</strong>: 이미 가진 시세는 재조회하지 않고, 신규 종목만 API 호출.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before: 전체 종목을 매번 조회</span>
<span class="nx">useSWR</span><span class="p">([</span><span class="dl">'</span><span class="s1">batch-prices</span><span class="dl">'</span><span class="p">,</span> <span class="nx">stableKey</span><span class="p">],</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">getBatchPrices</span><span class="p">(</span><span class="nx">stockCodes</span><span class="p">));</span>

<span class="c1">// After: accumulatedPrices에 없는 종목만 delta 조회</span>
<span class="kd">const</span> <span class="nx">newCodes</span> <span class="o">=</span> <span class="nx">stockCodes</span><span class="p">.</span><span class="nx">filter</span><span class="p">((</span><span class="nx">code</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="nx">accumulatedPrices</span><span class="p">[</span><span class="nx">code</span><span class="p">]);</span>
<span class="nx">useSWR</span><span class="p">([</span><span class="dl">'</span><span class="s1">batch-prices-delta</span><span class="dl">'</span><span class="p">,</span> <span class="nx">deltaKey</span><span class="p">],</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">getBatchPrices</span><span class="p">(</span><span class="nx">newCodes</span><span class="p">));</span>
</code></pre></div></div>

<p>상태 관리는 <code class="language-plaintext highlighter-rouge">useReducer</code>로 변경하여 filter 변경 시 reset, delta 데이터 도착 시 merge:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">type</span> <span class="nx">PriceAction</span> <span class="o">=</span>
  <span class="o">|</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">reset</span><span class="dl">'</span><span class="p">;</span> <span class="nl">prices</span><span class="p">:</span> <span class="nb">Record</span><span class="o">&lt;</span><span class="kr">string</span><span class="p">,</span> <span class="nx">MappedStockPrice</span><span class="o">&gt;</span> <span class="p">}</span>
  <span class="o">|</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">merge</span><span class="dl">'</span><span class="p">;</span> <span class="nl">prices</span><span class="p">:</span> <span class="nb">Record</span><span class="o">&lt;</span><span class="kr">string</span><span class="p">,</span> <span class="nx">MappedStockPrice</span><span class="o">&gt;</span> <span class="p">};</span>

<span class="kd">function</span> <span class="nx">priceReducer</span><span class="p">(</span><span class="nx">state</span><span class="p">,</span> <span class="nx">action</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">switch</span> <span class="p">(</span><span class="nx">action</span><span class="p">.</span><span class="kd">type</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">case</span> <span class="dl">'</span><span class="s1">reset</span><span class="dl">'</span><span class="p">:</span> <span class="k">return</span> <span class="nx">action</span><span class="p">.</span><span class="nx">prices</span><span class="p">;</span>
    <span class="k">case</span> <span class="dl">'</span><span class="s1">merge</span><span class="dl">'</span><span class="p">:</span>  <span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">state</span><span class="p">,</span> <span class="p">...</span><span class="nx">action</span><span class="p">.</span><span class="nx">prices</span> <span class="p">};</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>효과</strong>: 스크롤 시 API 호출이 페이지당 1회로 고정. 5페이지 기준 15회 → 5회 (<strong>67% 감소</strong>).</p>

<h3 id="p0-ssr에서-시세-함께-프리페치">P0: SSR에서 시세 함께 프리페치</h3>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before: SSR에서 카탈로그만 fetch</span>
<span class="nx">initialData</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getStocks</span><span class="p">({</span> <span class="na">page</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="na">size</span><span class="p">:</span> <span class="mi">20</span><span class="p">,</span> <span class="p">...</span> <span class="p">});</span>

<span class="c1">// After: 카탈로그 + 시세를 SSR에서 연속 fetch 후 prop 전달</span>
<span class="nx">initialData</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getStocks</span><span class="p">({</span> <span class="na">page</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="na">size</span><span class="p">:</span> <span class="mi">20</span><span class="p">,</span> <span class="p">...</span> <span class="p">});</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">initialData</span><span class="p">.</span><span class="nx">content</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">codes</span> <span class="o">=</span> <span class="nx">initialData</span><span class="p">.</span><span class="nx">content</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">item</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">item</span><span class="p">.</span><span class="nx">stockCode</span><span class="p">);</span>
  <span class="nx">initialPrices</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getBatchPrices</span><span class="p">(</span><span class="nx">codes</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">initialPrices</code> prop을 클라이언트 컴포넌트에 전달하여, 첫 화면 로드 시 Hydration 대기 없이 시세 표시.</p>

<p><strong>효과</strong>: 클라이언트 워터폴 1단계 제거, 첫 화면 시세 로딩 시간 약 <strong>500ms~1s 단축</strong>.</p>

<h3 id="p1-batch_chunk_size-20--50">P1: BATCH_CHUNK_SIZE 20 → 50</h3>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before</span>
<span class="kd">const</span> <span class="nx">BATCH_CHUNK_SIZE</span> <span class="o">=</span> <span class="mi">20</span><span class="p">;</span>
<span class="c1">// After</span>
<span class="kd">const</span> <span class="nx">BATCH_CHUNK_SIZE</span> <span class="o">=</span> <span class="mi">50</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>효과</strong>: 100개 종목 기준 5회 → 2회 POST 요청 (<strong>60% 감소</strong>).</p>

<h3 id="p1-isr-도입">P1: ISR 도입</h3>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">dynamic</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">force-dynamic</span><span class="dl">'</span><span class="p">;</span>
<span class="c1">// After</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">revalidate</span> <span class="o">=</span> <span class="mi">30</span><span class="p">;</span>
</code></pre></div></div>

<p>30초간 캐시된 페이지를 서브. 종목 목록은 자주 변하지 않으므로 30초면 충분.</p>

<p><strong>효과</strong>: 대부분의 사용자가 캐시된 SSR 페이지를 받아 백엔드 호출 생략.</p>

<h3 id="p2-fetcher-재시도-축소">P2: fetcher 재시도 축소</h3>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before: retries = 3, retryable = [403, 429, 502, 503]</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">fetcher</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">init</span><span class="p">,</span> <span class="nx">retries</span> <span class="o">=</span> <span class="mi">3</span><span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
<span class="kd">const</span> <span class="nx">retryable</span> <span class="o">=</span> <span class="p">[</span><span class="mi">403</span><span class="p">,</span> <span class="mi">429</span><span class="p">,</span> <span class="mi">502</span><span class="p">,</span> <span class="mi">503</span><span class="p">];</span>

<span class="c1">// After: retries = 1, retryable = [429, 502, 503]</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">fetcher</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">init</span><span class="p">,</span> <span class="nx">retries</span> <span class="o">=</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
<span class="kd">const</span> <span class="nx">retryable</span> <span class="o">=</span> <span class="p">[</span><span class="mi">429</span><span class="p">,</span> <span class="mi">502</span><span class="p">,</span> <span class="mi">503</span><span class="p">];</span>
</code></pre></div></div>

<p>403은 권한 문제이므로 재시도 무의미. 최대 대기 10.5초 → 1.5초로 단축.</p>

<h3 id="p2-context-리렌더-최적화">P2: Context 리렌더 최적화</h3>

<p><code class="language-plaintext highlighter-rouge">BatchPriceProvider</code>의 value와 hook 반환값에 <code class="language-plaintext highlighter-rouge">useMemo</code> 적용:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Provider value 메모이제이션</span>
<span class="kd">const</span> <span class="nx">value</span> <span class="o">=</span> <span class="nx">useMemo</span><span class="p">(</span>
  <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="nx">domestic</span><span class="p">,</span> <span class="nx">overseas</span><span class="p">,</span> <span class="nx">loadingCodes</span><span class="p">,</span> <span class="nx">errorCodes</span> <span class="p">}),</span>
  <span class="p">[</span><span class="nx">domestic</span><span class="p">,</span> <span class="nx">overseas</span><span class="p">,</span> <span class="nx">loadingCodes</span><span class="p">,</span> <span class="nx">errorCodes</span><span class="p">]</span>
<span class="p">);</span>

<span class="c1">// 개별 hook 반환값 메모이제이션</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nx">useBatchDomesticPrice</span><span class="p">(</span><span class="nx">stockCode</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">ctx</span> <span class="o">=</span> <span class="nx">useContext</span><span class="p">(</span><span class="nx">BatchPriceContext</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="nx">ctx</span><span class="p">.</span><span class="nx">domestic</span><span class="p">[</span><span class="nx">stockCode</span><span class="p">];</span>
  <span class="kd">const</span> <span class="nx">isLoading</span> <span class="o">=</span> <span class="nx">ctx</span><span class="p">.</span><span class="nx">loadingCodes</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">stockCode</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">error</span> <span class="o">=</span> <span class="nx">ctx</span><span class="p">.</span><span class="nx">errorCodes</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">stockCode</span><span class="p">);</span>
  <span class="k">return</span> <span class="nx">useMemo</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="nx">data</span><span class="p">,</span> <span class="nx">isLoading</span><span class="p">,</span> <span class="nx">error</span> <span class="p">}),</span> <span class="p">[</span><span class="nx">data</span><span class="p">,</span> <span class="nx">isLoading</span><span class="p">,</span> <span class="nx">error</span><span class="p">]);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>효과</strong>: 다른 종목의 시세 업데이트 시 관련 없는 카드 컴포넌트의 불필요한 리렌더 방지.</p>

<hr />

<h2 id="lint-트러블슈팅">Lint 트러블슈팅</h2>

<p>초기에 <code class="language-plaintext highlighter-rouge">useRef</code>로 accumulated prices를 관리하려 했으나, React 19의 <code class="language-plaintext highlighter-rouge">react-hooks/refs</code> 린트 규칙에 의해 “Cannot access refs during render” 에러 발생. 렌더 중 ref 읽기/쓰기를 금지하는 규칙.</p>

<p><code class="language-plaintext highlighter-rouge">useState</code> + <code class="language-plaintext highlighter-rouge">useEffect</code> 조합도 <code class="language-plaintext highlighter-rouge">react-hooks/set-state-in-effect</code> 규칙에 걸림.</p>

<p>최종적으로 <strong><code class="language-plaintext highlighter-rouge">useReducer</code></strong> 를 사용하여:</p>
<ul>
  <li>filter 변경 시 → <code class="language-plaintext highlighter-rouge">dispatch({ type: 'reset' })</code></li>
  <li>delta 데이터 도착 시 → <code class="language-plaintext highlighter-rouge">dispatch({ type: 'merge' })</code></li>
</ul>

<p>state 업데이트가 reducer에서 이루어지므로 린트 에러 없이 안전하게 동작.</p>

<hr />

<h2 id="be-개선이-추가로-필요한-부분">BE 개선이 추가로 필요한 부분</h2>

<p>FE 개선만으로 상당한 효과를 얻었지만, 근본적 해결을 위해서는 BE 개선도 필요:</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>카탈로그 + 시세 통합 응답</td>
      <td><code class="language-plaintext highlighter-rouge">GET /api/stocks</code> 응답에 시세 포함 → FE 시세 별도 조회 불필요 (3회 → 1회)</td>
    </tr>
    <tr>
      <td>배치 최대 수 증가</td>
      <td>POST <code class="language-plaintext highlighter-rouge">/api/stocks/prices</code>에서 50~100개 코드 한번에 수용</td>
    </tr>
    <tr>
      <td>CORS 헤더 추가</td>
      <td>FE에서 백엔드 직접 호출 가능하게 → Next.js 프록시 hop 제거</td>
    </tr>
    <tr>
      <td>Redis 시세 캐싱</td>
      <td>동일 종목 반복 조회 방지, TTL 5~10초</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="핵심-교훈">핵심 교훈</h2>

<ol>
  <li><strong>SWR 키 설계가 성능을 결정한다</strong>: 전체 데이터를 join한 키는 추가 로드마다 전체 재조회를 유발. Delta 기반 키 설계가 필수.</li>
  <li><strong>SSR 프리페치는 체감 속도에 결정적</strong>: 클라이언트에서만 fetch하면 Hydration 대기시간이 반드시 발생.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">useReducer</code>가 렌더 중 상태 업데이트의 안전한 대안</strong>: <code class="language-plaintext highlighter-rouge">useRef</code>는 렌더 중 접근 불가, <code class="language-plaintext highlighter-rouge">useState</code> + <code class="language-plaintext highlighter-rouge">useEffect</code>는 린트 에러. <code class="language-plaintext highlighter-rouge">useReducer</code>의 dispatch는 안전.</li>
  <li><strong>ISR로 SSR 부하 감소</strong>: <code class="language-plaintext highlighter-rouge">force-dynamic</code>은 매 요청마다 백엔드 호출. 실시간성이 덜 중요한 목록 페이지는 <code class="language-plaintext highlighter-rouge">revalidate</code>로 충분.</li>
</ol>]]></content><author><name>KimDohwan24</name></author><category term="TIL" /><category term="React" /><category term="Next.js" /><category term="Performance" /><category term="SWR" /><category term="SSR" /><summary type="html"><![CDATA[사전 개념]]></summary></entry><entry><title type="html">Harness, CLAUDE.md, AGENTS.md의 역할과 차이</title><link href="https://kimdohwan24.github.io/TIL/til/2026/04/27/TIL-34.html" rel="alternate" type="text/html" title="Harness, CLAUDE.md, AGENTS.md의 역할과 차이" /><published>2026-04-27T00:00:00+00:00</published><updated>2026-04-27T00:00:00+00:00</updated><id>https://kimdohwan24.github.io/TIL/til/2026/04/27/TIL-34</id><content type="html" xml:base="https://kimdohwan24.github.io/TIL/til/2026/04/27/TIL-34.html"><![CDATA[<h2 id="들어가며">들어가며</h2>

<p>오늘은 <code class="language-plaintext highlighter-rouge">Harness</code>, <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>, <code class="language-plaintext highlighter-rouge">AGENTS.md</code>가 각각 어떤 역할을 하는지 정리해봤다.</p>

<p>처음 이름만 보면 셋 다 작업을 더 효율적으로 도와주는 무언가처럼 느껴진다. 하지만 실제로는 다루는 대상도 다르고, 사용되는 맥락도 분명히 다르다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Harness</code>는 소프트웨어를 안전하게 배포하기 위한 운영 플랫폼이다.</li>
  <li><code class="language-plaintext highlighter-rouge">CLAUDE.md</code>는 AI에게 프로젝트 규칙을 알려주는 문서다.</li>
  <li><code class="language-plaintext highlighter-rouge">AGENTS.md</code>는 AI의 역할 분담과 작업 흐름을 정의하는 문서다.</li>
</ul>

<p>즉, <code class="language-plaintext highlighter-rouge">Harness</code>는 실제 서비스 전달 과정을 다루는 도구이고, <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>와 <code class="language-plaintext highlighter-rouge">AGENTS.md</code>는 AI가 프로젝트 안에서 더 정확하고 일관되게 작업하도록 돕는 가이드 문서라고 이해하면 된다.</p>

<h2 id="harness란">Harness란</h2>

<p><code class="language-plaintext highlighter-rouge">Harness</code>는 빌드, 테스트, 배포, 검증, 롤백 같은 과정을 자동화하고 관리하는 플랫폼이다.</p>

<p>개발에서는 코드를 작성하는 것만으로 일이 끝나지 않는다. 작성한 코드가 실제 운영 환경까지 안전하게 전달되어야 하고, 그 과정에서 문제가 생기면 빠르게 감지하고 되돌릴 수 있어야 한다. 이때 필요한 것이 바로 배포 흐름의 자동화다.</p>

<p>예전에는 사람이 직접 서버에 접속해서 명령어를 실행하거나, 배포 순서를 기억해서 수동으로 진행하는 경우가 많았다. 하지만 서비스 규모가 커질수록 이런 방식은 실수 가능성이 높고, 같은 작업을 반복하더라도 품질을 일정하게 유지하기 어렵다. Harness는 이 과정을 파이프라인으로 정의해 누가 실행하더라도 같은 절차와 기준으로 배포가 이루어지게 돕는다.</p>

<h3 id="왜-필요한가">왜 필요한가</h3>

<p>서비스를 배포할 때는 보통 다음과 같은 흐름이 필요하다.</p>

<ol>
  <li>빌드</li>
  <li>테스트</li>
  <li>배포</li>
  <li>배포 후 검증</li>
  <li>장애 발생 시 롤백</li>
</ol>

<p>이 과정을 전부 사람이 수동으로 처리하면 몇 가지 문제가 생긴다.</p>

<ul>
  <li>실수가 발생하기 쉽다.</li>
  <li>배포 속도가 느려진다.</li>
  <li>팀원마다 방식이 달라 일관성이 깨질 수 있다.</li>
  <li>문제가 발생했을 때 원인 추적과 복구가 어려워진다.</li>
</ul>

<p>결국 배포는 단순히 애플리케이션을 서버에 올리는 작업이 아니라, 서비스 품질을 유지하기 위한 운영 절차의 일부라고 볼 수 있다. Harness는 바로 이 절차를 자동화하고 표준화하는 데 초점이 맞춰져 있다.</p>

<h3 id="핵심-특징">핵심 특징</h3>

<ol>
  <li>자동화
    <ul>
      <li>코드 변경 이후 빌드, 테스트, 배포를 자동으로 실행할 수 있다.</li>
    </ul>
  </li>
  <li>안정성
    <ul>
      <li>배포 중 이상이 감지되면 중단하거나 이전 버전으로 롤백할 수 있다.</li>
    </ul>
  </li>
  <li>표준화
    <ul>
      <li>팀 전체가 동일한 배포 절차를 공유할 수 있다.</li>
    </ul>
  </li>
  <li>가시성
    <ul>
      <li>현재 배포가 어느 단계에 있는지, 어디서 실패했는지 확인하기 쉽다.</li>
    </ul>
  </li>
</ol>

<h3 id="예시로-보면">예시로 보면</h3>

<p>예를 들어 쇼핑몰 서비스의 결제 기능을 수정했다고 가정해보자. 개발자가 코드를 <code class="language-plaintext highlighter-rouge">main</code> 브랜치에 머지하면 아래와 같은 흐름이 자동으로 실행될 수 있다.</p>

<ol>
  <li>애플리케이션 빌드</li>
  <li>자동 테스트 실행</li>
  <li>스테이징 서버 배포</li>
  <li>정상 동작 여부 확인</li>
  <li>운영 서버 배포</li>
  <li>오류 발생 시 자동 롤백</li>
</ol>

<p>이런 흐름이 없다면 배포 이후 문제가 생겼을 때 누가 어떤 순서로 무엇을 해야 하는지 혼란이 생기기 쉽다. 반면 Harness를 사용하면 배포 과정 자체를 하나의 관리 가능한 시스템으로 다룰 수 있다.</p>

<h3 id="장점과-한계">장점과 한계</h3>

<p>Harness의 가장 큰 장점은 사람이 하던 반복 작업을 줄이고, 배포를 더 안정적으로 만들 수 있다는 점이다. 배포 이력을 추적하기 쉽고, 승인 단계나 검증 단계도 체계적으로 넣을 수 있다.</p>

<p>다만 자동화가 많아질수록 파이프라인 설계가 복잡해질 수 있다. 그래서 단순히 도구를 도입하는 것보다, 어떤 기준으로 배포를 운영할 것인지 먼저 정리하는 것이 중요하다.</p>

<h2 id="claudemd란">CLAUDE.md란</h2>

<p><code class="language-plaintext highlighter-rouge">CLAUDE.md</code>는 AI가 프로젝트 안에서 작업할 때 참고하는 규칙 문서다.</p>

<p>이름만 보면 <code class="language-plaintext highlighter-rouge">Claude</code> 전용 문서처럼 느껴질 수 있지만, 실제로 담기는 내용은 특정 AI 하나에만 해당되는 경우가 많지 않다. 프로젝트 구조, 실행 방법, 코딩 규칙, 수정하면 안 되는 부분 같은 정보는 대부분의 AI 코딩 도구가 공통으로 필요로 하는 문맥이다.</p>

<p>사람이 새로운 프로젝트에 참여할 때도 먼저 프로젝트 구조, 실행 방법, 코딩 규칙, 수정하면 안 되는 부분 같은 내용을 전달받는다. AI도 마찬가지다. 이런 정보가 정리되어 있지 않으면 AI는 코드만 보고 맥락을 추측해야 하고, 그 과정에서 잘못된 가정을 할 가능성이 커진다.</p>

<p><code class="language-plaintext highlighter-rouge">CLAUDE.md</code>는 이런 문제를 줄이기 위해 AI에게 프로젝트 문맥을 빠르게 전달하는 역할을 한다. 다만 중요한 것은 파일 이름 자체보다 그 안에 어떤 규칙과 기준이 정리되어 있는가이다.</p>

<h3 id="보통-들어가는-내용">보통 들어가는 내용</h3>

<ul>
  <li>프로젝트 개요</li>
  <li>실행 명령어</li>
  <li>테스트 방법</li>
  <li>코드 스타일 규칙</li>
  <li>수정하면 안 되는 영역</li>
  <li>작업 우선순위</li>
  <li>커밋 또는 리뷰 원칙</li>
</ul>

<h3 id="왜-필요한가-1">왜 필요한가</h3>

<p>AI는 프로젝트의 암묵적 규칙을 자동으로 완벽하게 이해하지 못한다. 예를 들어 어떤 디렉터리는 건드리면 안 된다거나, 특정 파일명 규칙을 따라야 한다거나, 테스트를 반드시 돌려야 한다는 기준은 문서로 명확히 주어져야 한다.</p>

<p>이런 규칙이 <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>에 잘 정리되어 있으면 AI는 더 적은 시행착오로 원하는 방향의 결과를 낼 수 있다. 반대로 문서가 오래되어 실제 프로젝트와 맞지 않으면 오히려 잘못된 기준을 따르게 될 위험도 있다.</p>

<p>정리하면 <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>는 이름상으로는 Claude 계열 도구를 떠올리게 하지만, 실제 내용은 다른 AI 코딩 도구에도 충분히 적용될 수 있는 프로젝트 작업 설명서에 가깝다.</p>

<h2 id="agentsmd란">AGENTS.md란</h2>

<p><code class="language-plaintext highlighter-rouge">AGENTS.md</code>는 AI의 역할 분담과 작업 흐름을 정의하는 문서다.</p>

<p><code class="language-plaintext highlighter-rouge">CLAUDE.md</code>가 프로젝트 전체에서 공통으로 지켜야 할 규칙에 가깝다면, <code class="language-plaintext highlighter-rouge">AGENTS.md</code>는 그 안에서 어떤 역할이 무엇을 담당하는지 더 구체적으로 나누는 문서라고 볼 수 있다.</p>

<p>예를 들어 하나의 AI가 모든 작업을 한 번에 처리하는 대신, 초안 작성, 기술 검토, 최종 점검처럼 역할을 나눠서 작업한다면 <code class="language-plaintext highlighter-rouge">AGENTS.md</code>가 유용해진다.</p>

<h3 id="예시-역할">예시 역할</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">writer</code>: 초안 작성</li>
  <li><code class="language-plaintext highlighter-rouge">reviewer</code>: 기술 정확성과 문장 품질 검토</li>
  <li><code class="language-plaintext highlighter-rouge">publisher</code>: 발행 전 형식 점검</li>
</ul>

<h3 id="왜-필요한가-2">왜 필요한가</h3>

<p>역할이 나뉘면 책임도 명확해진다. 누가 초안을 작성하고, 누가 정확성을 검토하고, 누가 최종 형식을 확인하는지 정해져 있으면 작업 흐름이 훨씬 선명해진다.</p>

<p>특히 문서 작성, 코드 리뷰, 발행 점검처럼 단계가 분리되는 작업에서는 이런 역할 정의가 품질 관리에 도움이 된다. 다만 작은 프로젝트에서는 역할을 지나치게 세분화할 경우 오히려 관리 비용만 늘어날 수 있다.</p>

<p>결국 <code class="language-plaintext highlighter-rouge">AGENTS.md</code>는 AI 협업 워크플로우를 정리하는 문서라고 볼 수 있다.</p>

<h2 id="세-가지의-차이">세 가지의 차이</h2>

<p>세 가지는 모두 작업 효율을 높이기 위한 수단이라는 공통점이 있지만, 집중하는 대상은 서로 다르다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>용도</th>
      <th>장점</th>
      <th>단점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Harness</code></td>
      <td>빌드, 테스트, 배포, 검증, 롤백 등 소프트웨어 전달 과정을 자동화하는 플랫폼</td>
      <td>배포 표준화, 휴먼 에러 감소, 배포 상태 추적, 승인과 롤백 흐름 관리 가능</td>
      <td>초기 설정이 복잡할 수 있고, 파이프라인 설계가 잘못되면 운영이 오히려 어려워질 수 있다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CLAUDE.md</code></td>
      <td>AI에게 프로젝트 규칙과 작업 기준을 알려주는 문서</td>
      <td>프로젝트 문맥 전달이 빠르고, AI 작업 품질의 일관성을 높일 수 있다</td>
      <td>문서가 오래되면 잘못된 가이드가 될 수 있고, 너무 길면 핵심 규칙이 묻힐 수 있다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AGENTS.md</code></td>
      <td>AI 역할 분담과 작업 흐름을 정의하는 문서</td>
      <td>역할과 책임이 명확해지고, 초안-검토-점검 흐름을 체계화하기 좋다</td>
      <td>작은 프로젝트에서는 과설계가 될 수 있고, 역할이 지나치게 많아지면 관리 비용이 커질 수 있다</td>
    </tr>
  </tbody>
</table>

<p>이 표를 보면 <code class="language-plaintext highlighter-rouge">Harness</code>는 실제 운영 자동화에 가까운 개념이고, <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>와 <code class="language-plaintext highlighter-rouge">AGENTS.md</code>는 AI가 프로젝트 안에서 더 잘 일하기 위한 문서라는 차이가 분명해진다. 그중에서도 <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>는 공통 규칙에, <code class="language-plaintext highlighter-rouge">AGENTS.md</code>는 역할 분담과 흐름에 더 초점이 맞춰져 있다.</p>

<h2 id="마무리">마무리</h2>

<p>이번에 정리하면서 느낀 점은, 셋 다 결국 일을 더 잘하기 위한 도구이지만 해결하려는 문제가 서로 다르다는 것이다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Harness</code>는 코드가 운영 환경까지 안전하게 전달되도록 돕는다.</li>
  <li><code class="language-plaintext highlighter-rouge">CLAUDE.md</code>는 AI가 프로젝트 규칙을 정확히 이해하도록 돕는다.</li>
  <li><code class="language-plaintext highlighter-rouge">AGENTS.md</code>는 AI가 역할에 따라 체계적으로 협업하도록 돕는다.</li>
</ul>

<p>즉, <code class="language-plaintext highlighter-rouge">Harness</code>는 개발과 운영을 연결하는 플랫폼이고, <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>와 <code class="language-plaintext highlighter-rouge">AGENTS.md</code>는 AI와 프로젝트를 연결하는 문서라고 정리할 수 있다. 특히 <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>는 특정 AI만을 위한 절대적인 규칙서라기보다, 여러 AI 도구에도 활용 가능한 공통 작업 가이드라는 관점으로 이해하는 것이 더 정확하다.</p>

<p>앞으로는 새로운 도구나 문서를 볼 때, 단순히 “무엇을 하는가”만 보는 것이 아니라 “어떤 문제를 해결하기 위해 존재하는가”까지 함께 생각해보면 더 이해가 잘 될 것 같다.</p>]]></content><author><name>KimDohwan24</name></author><category term="TIL" /><category term="DevOps" /><category term="CI/CD" /><category term="Harness" /><category term="AI" /><category term="Documentation" /><summary type="html"><![CDATA[들어가며]]></summary></entry><entry><title type="html">Harness란</title><link href="https://kimdohwan24.github.io/TIL/til/2026/04/25/TIL-33.html" rel="alternate" type="text/html" title="Harness란" /><published>2026-04-25T00:00:00+00:00</published><updated>2026-04-25T00:00:00+00:00</updated><id>https://kimdohwan24.github.io/TIL/til/2026/04/25/TIL-33</id><content type="html" xml:base="https://kimdohwan24.github.io/TIL/til/2026/04/25/TIL-33.html"><![CDATA[<h2 id="harness">Harness</h2>
<ul>
  <li>Harness는 소프트웨어를 개발한 뒤 실제 운영 환경까지 안전하게 전달하기 위한 빌드, 테스트, 배포, 검증 과정을 자동화하고 관리하는 플랫폼이다.</li>
  <li>단순히 코드를 작성하는 것에서 끝나는 것이 아니라, 그 코드가 어떤 흐름으로 배포되고 운영되는지까지 설계하는 개념에 가깝다.</li>
  <li>즉, 개발과 운영 사이를 연결하는 DevOps 관점의 엔지니어링이라고 볼 수 있다.</li>
  <li>예전에는 배포를 사람이 직접 서버에 접속해 명령어를 실행하거나, 순서를 기억해가며 수동으로 진행하는 경우가 많았다.</li>
  <li>하지만 서비스 규모가 커질수록 이런 방식은 실수 가능성이 높고, 같은 작업을 반복할 때마다 품질이 달라질 수 있다.</li>
  <li>Harness는 이런 배포 과정을 파이프라인으로 정의해서, 누가 실행하더라도 같은 절차와 기준으로 배포가 이루어지도록 돕는다.</li>
</ul>

<h3 id="왜-필요한가">왜 필요한가</h3>
<p>서비스 개발에서는 기능 구현 이후에도 여러 단계가 필요하다.</p>

<ol>
  <li>빌드</li>
  <li>테스트</li>
  <li>배포</li>
  <li>배포 후 검증</li>
  <li>장애 발생 시 롤백</li>
</ol>

<ul>
  <li>이 과정을 사람이 수동으로 처리하면 실수가 발생하기 쉽다.</li>
  <li>배포 속도가 느려질 수 있다.</li>
  <li>팀원마다 방식이 달라 일관성이 깨질 수 있다.</li>
  <li>문제가 발생했을 때 원인을 추적하거나 이전 상태로 되돌리기 어려울 수 있다.</li>
  <li>특히 실무에서는 배포 전에 어떤 테스트를 통과해야 하는지, 누가 승인해야 하는지, 배포 후 어떤 지표를 확인해야 하는지까지 함께 관리해야 한다.</li>
  <li>
    <p>결국 배포는 단순히 애플리케이션을 올리는 작업이 아니라, 서비스 품질을 유지하기 위한 운영 절차의 일부라고 볼 수 있다.</p>
  </li>
  <li>Harness는 이런 문제를 줄이기 위해 배포 흐름을 자동화된 파이프라인으로 구성하도록 도와준다.</li>
</ul>

<h3 id="harness의-핵심">Harness의 핵심</h3>
<ol>
  <li>자동화
    <ul>
      <li>코드가 변경되면 빌드, 테스트, 배포가 자동으로 실행되도록 구성한다.</li>
      <li>반복적인 작업을 사람이 직접 하지 않아도 되기 때문에 작업 속도를 높이고 휴먼 에러를 줄일 수 있다.</li>
    </ul>
  </li>
  <li>안정성
    <ul>
      <li>배포 중 문제가 발생하면 중단하거나 이전 버전으로 롤백할 수 있어야 한다.</li>
      <li>운영 환경에 바로 전체 배포하는 것이 아니라 단계적으로 배포해 위험을 줄이는 방식과도 잘 연결된다.</li>
    </ul>
  </li>
  <li>표준화
    <ul>
      <li>팀마다 다른 배포 방식 대신 공통된 파이프라인을 사용해 일관성을 유지한다.</li>
      <li>새로운 팀원이 들어와도 배포 절차를 문서만 읽는 것이 아니라, 이미 정리된 흐름 안에서 따라갈 수 있다는 장점이 있다.</li>
    </ul>
  </li>
  <li>가시성
    <ul>
      <li>현재 배포가 어느 단계인지, 어떤 단계에서 실패했는지 쉽게 확인할 수 있어야 한다.</li>
      <li>배포 이력과 실패 지점을 빠르게 확인할 수 있어 장애 대응이나 원인 분석에도 도움이 된다.</li>
    </ul>
  </li>
</ol>

<h3 id="핵심-기능-예시">핵심 기능 예시</h3>
<ul>
  <li>Harness에서 중요하게 볼 수 있는 기능은 다음과 같다.</li>
</ul>

<ol>
  <li>파이프라인 구성
    <ul>
      <li>빌드, 테스트, 배포, 승인, 검증 단계를 순서대로 정의할 수 있다.</li>
    </ul>
  </li>
  <li>승인 단계 추가
    <ul>
      <li>운영 배포 전에 특정 담당자의 승인을 거치도록 설정할 수 있다.</li>
    </ul>
  </li>
  <li>배포 후 검증
    <ul>
      <li>배포 직후 애플리케이션 상태나 주요 지표를 확인해 이상 여부를 판단할 수 있다.</li>
    </ul>
  </li>
  <li>롤백
    <ul>
      <li>배포 후 문제가 발생하면 이전 안정 버전으로 되돌릴 수 있다.</li>
    </ul>
  </li>
  <li>환경 분리
    <ul>
      <li>개발, 스테이징, 운영 환경을 나누어 같은 파이프라인을 환경별로 다르게 적용할 수 있다.</li>
    </ul>
  </li>
</ol>

<h3 id="예시로-이해하기">예시로 이해하기</h3>
<ul>
  <li>예를 들어 쇼핑몰 서비스에서 결제 기능을 수정했다고 가정한다.</li>
  <li>개발자가 코드를 수정하고 <code class="language-plaintext highlighter-rouge">main</code> 브랜치에 머지하면 다음과 같은 흐름이 자동으로 실행될 수 있다.</li>
</ul>

<ol>
  <li>애플리케이션 빌드</li>
  <li>자동 테스트 실행</li>
  <li>스테이징 서버 배포</li>
  <li>정상 동작 여부 확인</li>
  <li>운영 서버 배포</li>
  <li>오류 발생 시 자동 롤백</li>
</ol>

<ul>
  <li>이처럼 Harness는 사람이 직접 하나씩 처리하던 작업을 자동화하고, 배포를 더 안전하게 만드는 역할을 한다.</li>
  <li>만약 운영 배포 이후 결제 실패율이 갑자기 높아졌다면, 배포를 즉시 중단하거나 이전 버전으로 되돌리는 식으로 대응할 수 있다.</li>
  <li>이런 흐름이 정리되어 있지 않으면 장애가 발생했을 때 누가 무엇을 해야 하는지 혼란이 생길 수 있지만, Harness는 이 과정을 더 명확하게 만들어준다.</li>
</ul>

<h3 id="cicd와의-관계">CI/CD와의 관계</h3>
<ul>
  <li>Harness는 CI/CD를 구현하고 운영하는 데 자주 사용되는 플랫폼이다.</li>
  <li>CI는 코드 변경 사항을 지속적으로 통합하고 빌드/테스트하는 과정이다.</li>
  <li>CD는 검증된 결과물을 실제 환경에 지속적으로 배포하는 과정이다.</li>
  <li>Harness를 활용하는 과정은 결국 이 CI/CD 흐름을 더 효율적이고 안정적으로 설계하는 일이라고 이해할 수 있다.</li>
  <li>즉, CI/CD가 개념이라면 Harness는 그 개념을 실제 운영 흐름 속에서 구현하고 관리하도록 돕는 도구라고 정리할 수 있다.</li>
</ul>

<h3 id="devops와의-관계">DevOps와의 관계</h3>
<ul>
  <li>DevOps는 개발과 운영이 분리되지 않고 협업하면서 빠르고 안정적으로 서비스를 운영하려는 문화와 방식이다.</li>
  <li>Harness는 이 DevOps 문화를 실제 배포 프로세스에서 실행 가능하게 만들어주는 플랫폼 중 하나다.</li>
  <li>따라서 Harness를 이해하려면 단순한 배포 툴로 보기보다, 개발부터 운영까지 이어지는 흐름을 관리하는 도구로 보는 것이 더 적절하다.</li>
</ul>

<h3 id="장점">장점</h3>
<ul>
  <li>반복적인 배포 작업을 줄일 수 있다.</li>
  <li>사람의 실수를 줄일 수 있다.</li>
  <li>배포 속도를 높일 수 있다.</li>
  <li>문제 발생 시 빠르게 원인을 파악할 수 있다.</li>
  <li>운영 안정성을 높일 수 있다.</li>
  <li>팀 전체가 동일한 배포 기준을 공유할 수 있다.</li>
</ul>

<h3 id="주의할-점">주의할 점</h3>
<ol>
  <li>자동화가 많아질수록 파이프라인 설계가 복잡해질 수 있다.</li>
  <li>툴을 도입하는 것보다 어떤 기준으로 배포를 관리할 것인지가 더 중요하다.</li>
  <li>무조건 빠른 배포보다 안전한 배포를 우선해야 한다.</li>
  <li>도구만 도입한다고 DevOps가 완성되는 것은 아니며, 팀의 운영 방식과 함께 정리되어야 한다.</li>
</ol>

<h2 id="요약">요약</h2>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Harness</td>
      <td>소프트웨어 전달 과정을 자동화하고 관리하는 플랫폼</td>
    </tr>
    <tr>
      <td>핵심 목적</td>
      <td>빠르고 안전한 배포</td>
    </tr>
    <tr>
      <td>주요 요소</td>
      <td>빌드, 테스트, 배포, 검증, 롤백</td>
    </tr>
    <tr>
      <td>연결 개념</td>
      <td>DevOps, CI/CD, 배포 자동화</td>
    </tr>
    <tr>
      <td>기대 효과</td>
      <td>안정성 향상, 배포 속도 향상, 실수 감소</td>
    </tr>
  </tbody>
</table>

<h2 id="최종-결론">최종 결론</h2>
<p>Harness는 단순히 배포를 수행하는 도구가 아니라, 배포 과정을 시스템으로 만들고 자동화하는 플랫폼이다.</p>

<p>결국 중요한 것은 코드를 잘 작성하는 것뿐만 아니라, 그 코드가 운영 환경까지 안전하게 전달되도록 만드는 것이다.</p>

<p>따라서 Harness는 개발과 운영을 연결하는 실전적인 DevOps 도구라고 정리할 수 있다.</p>]]></content><author><name>KimDohwan24</name></author><category term="TIL" /><category term="DevOps" /><category term="CI/CD" /><category term="Deployment" /><summary type="html"><![CDATA[Harness Harness는 소프트웨어를 개발한 뒤 실제 운영 환경까지 안전하게 전달하기 위한 빌드, 테스트, 배포, 검증 과정을 자동화하고 관리하는 플랫폼이다. 단순히 코드를 작성하는 것에서 끝나는 것이 아니라, 그 코드가 어떤 흐름으로 배포되고 운영되는지까지 설계하는 개념에 가깝다. 즉, 개발과 운영 사이를 연결하는 DevOps 관점의 엔지니어링이라고 볼 수 있다. 예전에는 배포를 사람이 직접 서버에 접속해 명령어를 실행하거나, 순서를 기억해가며 수동으로 진행하는 경우가 많았다. 하지만 서비스 규모가 커질수록 이런 방식은 실수 가능성이 높고, 같은 작업을 반복할 때마다 품질이 달라질 수 있다. Harness는 이런 배포 과정을 파이프라인으로 정의해서, 누가 실행하더라도 같은 절차와 기준으로 배포가 이루어지도록 돕는다.]]></summary></entry><entry><title type="html">Refresh Token 구현 방식 고도화</title><link href="https://kimdohwan24.github.io/TIL/til/2026/04/23/TIL-32.html" rel="alternate" type="text/html" title="Refresh Token 구현 방식 고도화" /><published>2026-04-23T00:00:00+00:00</published><updated>2026-04-23T00:00:00+00:00</updated><id>https://kimdohwan24.github.io/TIL/til/2026/04/23/TIL-32</id><content type="html" xml:base="https://kimdohwan24.github.io/TIL/til/2026/04/23/TIL-32.html"><![CDATA[<h2 id="요약">요약</h2>
<ul>
  <li>기존: Random Hex String 방식</li>
  <li>
    <p>변경: JWT + Metadata JSON 방식</p>
  </li>
  <li>이전에는 특정 토큰을 찾기 위해 Redis를 순회해야 할 수 있어 조회 비용이 <code class="language-plaintext highlighter-rouge">O(N)</code>에 가까워질 수 있었습니다.</li>
  <li>현재는 JWT 내부의 사용자 정보와 디바이스 정보를 활용해 <code class="language-plaintext highlighter-rouge">O(1)</code> 수준의 직접 조회가 가능해졌습니다.</li>
  <li>성능뿐 아니라 운영 편의성과 보안 검증 수준도 함께 개선되었습니다.</li>
</ul>

<h2 id="버전별-상세-비교">버전별 상세 비교</h2>

<table>
  <thead>
    <tr>
      <th style="text-align: left">항목</th>
      <th style="text-align: left">이전 버전 (Legacy)</th>
      <th style="text-align: left">현재 버전 (Modern)</th>
      <th style="text-align: left">비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">토큰 형태</td>
      <td style="text-align: left">64 bytes Random Hex</td>
      <td style="text-align: left">JWT (JSON Web Token)</td>
      <td style="text-align: left">정보 포함형</td>
    </tr>
    <tr>
      <td style="text-align: left">조회 성능</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">O(N)</code> (Redis Full Scan)</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">O(1)</code> (Direct Access)</td>
      <td style="text-align: left">90% 이상 향상</td>
    </tr>
    <tr>
      <td style="text-align: left">저장 구조</td>
      <td style="text-align: left">String (토큰만 저장)</td>
      <td style="text-align: left">JSON (메타데이터 포함)</td>
      <td style="text-align: left">가시성 확보</td>
    </tr>
    <tr>
      <td style="text-align: left">보안 체계</td>
      <td style="text-align: left">Redis TTL 의존</td>
      <td style="text-align: left">서명 + 만료 + 타입 검증</td>
      <td style="text-align: left">다중 보안 계층</td>
    </tr>
    <tr>
      <td style="text-align: left">디버깅</td>
      <td style="text-align: left">불가능에 가까움</td>
      <td style="text-align: left">매우 용이</td>
      <td style="text-align: left">사용자 정보 포함</td>
    </tr>
  </tbody>
</table>

<h2 id="주요-개선-사항">주요 개선 사항</h2>

<h3 id="성능-최적화-on---o1">성능 최적화: <code class="language-plaintext highlighter-rouge">O(N)</code> -&gt; <code class="language-plaintext highlighter-rouge">O(1)</code></h3>
<ul>
  <li>이전에는 특정 디바이스 토큰을 찾기 위해 <code class="language-plaintext highlighter-rouge">KEYS</code> 혹은 <code class="language-plaintext highlighter-rouge">SCAN</code>으로 Redis 전체를 탐색해야 했습니다.</li>
  <li>현재는 JWT 내부 <code class="language-plaintext highlighter-rouge">sub(userId)</code>를 즉시 추출한 뒤 <code class="language-plaintext highlighter-rouge">refresh_token:{userId}:{deviceId}</code> 키로 직접 접근합니다.</li>
  <li>Full Scan을 제거하면서 조회 비용을 단건 조회 수준으로 줄일 수 있었습니다.</li>
</ul>

<h3 id="운영-편의성-및-데이터-가시성">운영 편의성 및 데이터 가시성</h3>
<ul>
  <li>이전에는 Redis에 토큰 문자열만 저장되어 있어 어떤 사용자의 어떤 디바이스 토큰인지 바로 파악하기 어려웠습니다.</li>
  <li>현재는 메타데이터 JSON을 함께 저장해 Redis GUI에서도 사용자 이메일, 생성일, 디바이스 정보를 즉시 확인할 수 있습니다.</li>
</ul>

<p>저장 예시는 다음과 같습니다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"token"</span><span class="p">:</span><span class="w"> </span><span class="s2">"eyJhbGci..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"userId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"user-123"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"email"</span><span class="p">:</span><span class="w"> </span><span class="s2">"user@example.com"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"deviceId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"device-456"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"createdAt"</span><span class="p">:</span><span class="w"> </span><span class="mi">1709123456789</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="보안-로직-고도화">보안 로직 고도화</h3>
<ul>
  <li>서명 검증: 위변조된 토큰은 Redis를 조회하기도 전에 서버에서 차단합니다.</li>
  <li>만료 검증: JWT의 만료 정보를 기준으로 유효성을 검사합니다.</li>
  <li>타입 검증: Payload 내 <code class="language-plaintext highlighter-rouge">type: "refresh"</code>를 확인해 Access Token 오용을 방지합니다.</li>
</ul>

<h2 id="예상-성능-지표">예상 성능 지표</h2>
<ul>
  <li>검증 속도: 기존 약 <code class="language-plaintext highlighter-rouge">100ms</code> -&gt; 변경 후 <code class="language-plaintext highlighter-rouge">5ms</code> 미만</li>
  <li>Redis 부하: Full Scan 제거로 CPU 사용량 감소</li>
  <li>트레이드오프: 저장 공간은 약간 늘어나지만 성능 이득이 더 큼</li>
</ul>

<h2 id="핵심-코드-변경-포인트">핵심 코드 변경 포인트</h2>
<ul>
  <li>생성: <code class="language-plaintext highlighter-rouge">randomBytes(64)</code> 대신 <code class="language-plaintext highlighter-rouge">jwtService.sign(payload)</code> 사용</li>
  <li>검증: <code class="language-plaintext highlighter-rouge">keys()</code> 패턴 매칭 루프 제거</li>
  <li>검증 흐름: <code class="language-plaintext highlighter-rouge">jwtService.verify()</code> 후 <code class="language-plaintext highlighter-rouge">redis.get()</code> 단건 처리</li>
</ul>

<h2 id="정리">정리</h2>
<ul>
  <li>이번 변경의 핵심은 단순히 Refresh Token의 포맷만 바꾼 것이 아닙니다.</li>
  <li>
    <p>찾기 어려운 토큰을 검증 가능하고 식별 가능한 토큰으로 바꾼 것이 핵심입니다.</p>
  </li>
  <li>기존: Random Hex String + 단순 저장</li>
  <li>변경: JWT + Metadata JSON</li>
  <li>효과: <code class="language-plaintext highlighter-rouge">O(N)</code> 탐색 제거, <code class="language-plaintext highlighter-rouge">O(1)</code> 조회 구조 확보</li>
  <li>추가 이점: 보안 검증 강화, 운영 가시성 향상, 디버깅 편의성 개선</li>
</ul>]]></content><author><name>KimDohwan24</name></author><category term="TIL" /><category term="JWT" /><category term="RefreshToken" /><category term="Redis" /><category term="Authentication" /><category term="Security" /><summary type="html"><![CDATA[요약 기존: Random Hex String 방식 변경: JWT + Metadata JSON 방식]]></summary></entry><entry><title type="html">Token stateless</title><link href="https://kimdohwan24.github.io/TIL/til/2025/06/19/TIL-31.html" rel="alternate" type="text/html" title="Token stateless" /><published>2025-06-19T00:00:00+00:00</published><updated>2025-06-19T00:00:00+00:00</updated><id>https://kimdohwan24.github.io/TIL/til/2025/06/19/TIL-31</id><content type="html" xml:base="https://kimdohwan24.github.io/TIL/til/2025/06/19/TIL-31.html"><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<ul>
  <li>JWT 기반 인증 시스템을 구현하면서 로그아웃 기능을 추가하려 했습니다.</li>
  <li>하지만 JWT는 기본적으로 서버가 상태를 저장하지 않는 무상태(stateless) 구조이기 때문에, 사용자가 로그아웃을 하더라도 서버는 해당 토큰이 유효한지 아닌지를 판단할 수 없습니다.</li>
  <li>처음에는 이 문제를 해결하기 위해 Redis를 도입해 사용자의 토큰을 저장하고, 블랙리스트 방식으로 로그아웃 시 해당 토큰을 무효화하려고 했습니다.</li>
</ul>

<h3 id="고민한-점">고민한 점</h3>
<ul>
  <li>Redis에 토큰을 저장하면 로그아웃 처리를 쉽게 할 수 있고, 토큰 유효성 검사 시 블랙리스트에 있는지 체크하면 되므로 보안 측면에서 유리합니다.</li>
  <li>하지만 이 방식은 결국 토큰 상태를 서버가 관리하게 되므로 JWT의 핵심인 무상태성(stateless)을 깨는 문제가 생깁니다.</li>
  <li>또한 Redis 관리 부담, 토큰 만료 처리 로직, 블랙리스트 갱신 등 추가적인 복잡도가 생깁니다.</li>
</ul>

<h3 id="선택한-해결-방향">선택한 해결 방향</h3>

<ul>
  <li>
    <p>고민 끝에 다음과 같은 기준을 세웠습니다:</p>
  </li>
  <li>무상태성을 유지하는 방향을 우선시한다면:
    <ol>
      <li>Access Token의 만료 시간을 짧게 설정</li>
      <li>Refresh Token을 사용해 토큰 재발급 API 제공</li>
      <li>로그아웃 시 클라이언트가 로컬 토큰을 삭제하는 방식</li>
    </ol>
  </li>
  <li>보안이 더 중요한 상황이라면:
    <ol>
      <li>Redis를 사용해 블랙리스트 방식으로 로그아웃된 토큰을 저장하고 인증 필터에서 차단</li>
    </ol>
  </li>
  <li>저는 이번 프로젝트에서는 무상태성을 유지하는 방향을 택하고, 보안을 강화할 필요가 있을 때 블랙리스트 방식을 보완적으로 도입하는 쪽으로 결정했습니다.</li>
</ul>

<h2 id="redis-블랙리스트를-활용한-jwt-로그아웃-처리-사례">Redis 블랙리스트를 활용한 JWT 로그아웃 처리 사례</h2>
<ul>
  <li>배경 : JWT는 서버가 토큰을 저장하지 않기 때문에 무상태(stateless) 인증 방식입니다. 하지만 이로 인해 로그아웃 시 토큰을 서버 측에서 강제로 무효화할 수 없는 단점이 있습니다.
이를 해결하기 위해 Redis를 활용한 블랙리스트 방식을 적용했습니다.</li>
</ul>

<h3 id="구현-방식">구현 방식</h3>
<ol>
  <li>로그아웃 시 토큰을 Redis에 저장</li>
</ol>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 로그아웃 시 호출되는 서비스</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">logout</span><span class="o">(</span><span class="nc">String</span> <span class="n">accessToken</span><span class="o">)</span> <span class="o">{</span>
    <span class="kt">long</span> <span class="n">expiration</span> <span class="o">=</span> <span class="n">jwtUtil</span><span class="o">.</span><span class="na">getExpiration</span><span class="o">(</span><span class="n">accessToken</span><span class="o">);</span> <span class="c1">// 토큰의 남은 만료 시간 계산</span>
    <span class="n">redisTemplate</span><span class="o">.</span><span class="na">opsForValue</span><span class="o">().</span><span class="na">set</span><span class="o">(</span><span class="s">"BL:"</span> <span class="o">+</span> <span class="n">accessToken</span><span class="o">,</span> <span class="s">"logout"</span><span class="o">,</span> <span class="n">expiration</span><span class="o">,</span> <span class="nc">TimeUnit</span><span class="o">.</span><span class="na">MILLISECONDS</span><span class="o">);</span>
<span class="o">}</span>
<span class="n">토큰</span> <span class="n">키</span> <span class="n">앞에</span> <span class="s">"BL:"</span><span class="n">를</span> <span class="n">붙여</span> <span class="n">블랙리스트로</span> <span class="n">식별</span>

<span class="n">토큰의</span> <span class="n">남은</span> <span class="n">유효시간만큼</span> <span class="nc">Redis에</span> <span class="n">저장</span> <span class="err">→</span> <span class="n">메모리</span> <span class="n">낭비</span> <span class="n">방지</span>
</code></pre></div></div>

<ol>
  <li>JWT 필터에서 요청 토큰 확인</li>
</ol>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="nc">String</span> <span class="n">token</span> <span class="o">=</span> <span class="n">jwtUtil</span><span class="o">.</span><span class="na">resolveToken</span><span class="o">(</span><span class="n">request</span><span class="o">);</span>

<span class="k">if</span> <span class="o">(</span><span class="n">token</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="n">jwtUtil</span><span class="o">.</span><span class="na">validateToken</span><span class="o">(</span><span class="n">token</span><span class="o">))</span> <span class="o">{</span>
    <span class="nc">Boolean</span> <span class="n">isBlacklisted</span> <span class="o">=</span> <span class="n">redisTemplate</span><span class="o">.</span><span class="na">hasKey</span><span class="o">(</span><span class="s">"BL:"</span> <span class="o">+</span> <span class="n">token</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="nc">Boolean</span><span class="o">.</span><span class="na">TRUE</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">isBlacklisted</span><span class="o">))</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">JwtException</span><span class="o">(</span><span class="s">"로그아웃된 토큰입니다."</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
<span class="n">요청이</span> <span class="n">들어올</span> <span class="n">때</span> <span class="nc">Redis에서</span> <span class="n">블랙리스트</span> <span class="n">토큰인지</span> <span class="n">검사</span>

<span class="n">로그아웃된</span> <span class="n">토큰이라면</span> <span class="n">인증</span> <span class="n">실패</span> <span class="n">처리</span>
</code></pre></div></div>

<h3 id="장점">장점</h3>
<ul>
  <li>서버가 토큰의 상태를 알 수 있어 로그아웃 구현이 가능</li>
  <li>RefreshToken 탈취 등 보안 문제 발생 시 즉시 차단 가능</li>
  <li>Redis의 TTL을 활용해 자동 만료 관리</li>
</ul>

<h3 id="단점">단점</h3>
<ul>
  <li>서버가 일부 상태를 저장하게 되어 무상태성(stateless) 손상</li>
  <li>Redis 의존성 증가 → 장애 시 인증 전체 영향</li>
  <li>토큰마다 Redis 조회 필요 → 성능 부담 고려 필요</li>
</ul>

<h3 id="요약">요약</h3>

<table>
  <thead>
    <tr>
      <th>기준</th>
      <th>무상태 방식</th>
      <th>Redis 블랙리스트</th>
      <th>토큰 버전 방식</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>무상태성 유지</td>
      <td>✅ 매우 좋음</td>
      <td>❌ 손상됨</td>
      <td>❌ 손상됨</td>
    </tr>
    <tr>
      <td>로그아웃 즉시 반영</td>
      <td>❌ 불가능</td>
      <td>✅ 가능</td>
      <td>✅ 가능</td>
    </tr>
    <tr>
      <td>구현 난이도</td>
      <td>✅ 쉬움</td>
      <td>⚠️ 중간</td>
      <td>⚠️ 중간</td>
    </tr>
    <tr>
      <td>보안 수준</td>
      <td>⚠️ 보통</td>
      <td>✅ 높음</td>
      <td>✅ 높음</td>
    </tr>
  </tbody>
</table>

<h2 id="최종-결론">최종 결론</h2>
<p>처음엔 Redis로 토큰을 관리하려 했지만, 이는 JWT의 무상태성을 해치기 때문에 Access Token은 짧게, Refresh Token으로 재발급 구조를 선택했습니다.
보안이 특히 중요한 서비스에서는 블랙리스트 방식도 함께 고려할 수 있습니다.</p>]]></content><author><name>KimDohwan24</name></author><category term="TIL" /><category term="JWT" /><category term="Security" /><category term="Redis" /><summary type="html"><![CDATA[문제 상황 JWT 기반 인증 시스템을 구현하면서 로그아웃 기능을 추가하려 했습니다. 하지만 JWT는 기본적으로 서버가 상태를 저장하지 않는 무상태(stateless) 구조이기 때문에, 사용자가 로그아웃을 하더라도 서버는 해당 토큰이 유효한지 아닌지를 판단할 수 없습니다. 처음에는 이 문제를 해결하기 위해 Redis를 도입해 사용자의 토큰을 저장하고, 블랙리스트 방식으로 로그아웃 시 해당 토큰을 무효화하려고 했습니다.]]></summary></entry><entry><title type="html">JPQL</title><link href="https://kimdohwan24.github.io/TIL/til/2025/06/13/TIL-30.html" rel="alternate" type="text/html" title="JPQL" /><published>2025-06-13T00:00:00+00:00</published><updated>2025-06-13T00:00:00+00:00</updated><id>https://kimdohwan24.github.io/TIL/til/2025/06/13/TIL-30</id><content type="html" xml:base="https://kimdohwan24.github.io/TIL/til/2025/06/13/TIL-30.html"><![CDATA[<h2 id="jpql">JPQL</h2>
<ul>
  <li>JPA는 SQL을 추상화한 JPQL이라는 개체 지향 쿼리 언어를 제공한다</li>
  <li>테이블을 대상으로 쿼리 하는 것이 아닌 엔티티 객체를 대항으로 쿼리한다</li>
  <li>JPQL은 SQL을 추상화했기 때문에 특정 데이터베이스 SQL에 의존하지 않는 장점이 있다</li>
  <li>JPQL은 SQL과 문법이 유사하며, SELECT, FROM, WHERE, GROUP BY, HAVING, JOIN을 지원한다</li>
  <li><code class="language-plaintext highlighter-rouge">JPQL은 결국 SQL로 변환된다</code></li>
</ul>

<h3 id="jpql-예제">JPQL 예제</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="c1">// 예: Member 엔티티에서 이름이 '홍길동'인 멤버 조회</span>
<span class="nd">@Query</span><span class="o">(</span><span class="s">"SELECT m FROM Member m WHERE m.name = :name"</span><span class="o">)</span>
<span class="nc">List</span><span class="o">&lt;</span><span class="nc">Member</span><span class="o">&gt;</span> <span class="nf">findByName</span><span class="o">(</span><span class="nd">@Param</span><span class="o">(</span><span class="s">"name"</span><span class="o">)</span> <span class="nc">String</span> <span class="n">name</span><span class="o">);</span>

</code></pre></div></div>

<ul>
  <li>위 쿼리에서 <code class="language-plaintext highlighter-rouge">Member</code>는 자바 엔티티 클래스이고, <code class="language-plaintext highlighter-rouge">name</code>은 필드명입니다.</li>
  <li>SQL에서는 <code class="language-plaintext highlighter-rouge">SELECT * FROM member WHERE name = '홍길동'</code>이지만, JPQL에서는 객체 중심으로 표현한다.</li>
</ul>

<h3 id="jpql-사용-방식">JPQL 사용 방식</h3>

<ol>
  <li><code class="language-plaintext highlighter-rouge">@Query</code> 어노테이션 사용</li>
</ol>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="nd">@Query</span><span class="o">(</span><span class="s">"SELECT m FROM Member m WHERE m.name = :name"</span><span class="o">)</span>
<span class="nc">List</span><span class="o">&lt;</span><span class="nc">Member</span><span class="o">&gt;</span> <span class="nf">findByName</span><span class="o">(</span><span class="nd">@Param</span><span class="o">(</span><span class="s">"name"</span><span class="o">)</span> <span class="nc">String</span> <span class="n">name</span><span class="o">);</span>

</code></pre></div></div>

<ol>
  <li><code class="language-plaintext highlighter-rouge">EntityManager</code> 직접 사용</li>
</ol>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="nc">TypedQuery</span><span class="o">&lt;</span><span class="nc">Member</span><span class="o">&gt;</span> <span class="n">query</span> <span class="o">=</span> <span class="n">em</span><span class="o">.</span><span class="na">createQuery</span><span class="o">(</span><span class="s">"SELECT m FROM Member m"</span><span class="o">,</span> <span class="nc">Member</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
<span class="nc">List</span><span class="o">&lt;</span><span class="nc">Member</span><span class="o">&gt;</span> <span class="n">resultList</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="na">getResultList</span><span class="o">();</span>

</code></pre></div></div>

<h3 id="typedquery-query">TypedQuery, Query</h3>
<ul>
  <li>JPQL을 실행하려면 쿼리 객체를 만들어야한다. 쿼리 객체로는 TypedQuery와 Query가 있는데,  <br />
반환할 타입을 명확하게 지정할 수 있으면 TypedQuery와 객체를, 명확하게 지정할 수 없으면 Query 객체를 사용한다</li>
</ul>

<h4 id="typedquery">TypedQuery</h4>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kd">public</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">typedQuery</span><span class="o">(</span><span class="nc">EntityManager</span> <span class="n">em</span><span class="o">)</span> <span class="o">{</span>    
    <span class="nc">String</span> <span class="n">jpql</span> <span class="o">=</span> <span class="s">"select m from Member m"</span><span class="o">;</span>	
    <span class="nc">TypedQuery</span><span class="o">&lt;</span><span class="nc">Member</span><span class="o">&gt;</span> <span class="n">query</span> <span class="o">=</span> <span class="n">em</span><span class="o">.</span><span class="na">createQuery</span><span class="o">(</span><span class="n">jpql</span><span class="o">,</span> <span class="nc">Member</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>		
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Member</span><span class="o">&gt;</span> <span class="n">list</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="na">getResultList</span><span class="o">();</span>	
    <span class="k">for</span><span class="o">(</span> <span class="nc">Member</span> <span class="n">member</span> <span class="o">:</span> <span class="n">list</span><span class="o">)</span> <span class="o">{</span>		
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"Member : "</span> <span class="o">+</span> <span class="n">member</span><span class="o">);</span>	
        <span class="o">}</span>
    <span class="o">}</span>

</code></pre></div></div>
<ul>
  <li>EntityManager 객체에서 createQuery() 메소드를 호출하면 쿼리가 생성된다.</li>
  <li>em.createQuery 메소드를 호출할 때 두 번째 인자로 엔티티 클래스를 넘겨준다.</li>
</ul>

<h4 id="query">Query</h4>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">Query</span><span class="o">(</span><span class="nc">EntityManager</span> <span class="n">em</span><span class="o">)</span> <span class="o">{</span>   
    <span class="nc">String</span> <span class="n">jpql</span> <span class="o">=</span> <span class="s">"select m.name, m.age from Member m"</span><span class="o">;</span>	
    <span class="nc">Query</span> <span class="n">query</span> <span class="o">=</span> <span class="n">em</span><span class="o">.</span><span class="na">createQuery</span><span class="o">(</span><span class="n">jpql</span><span class="o">);</span>		
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Object</span><span class="o">&gt;</span> <span class="n">list</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="na">getResultList</span><span class="o">();</span>	   
    <span class="k">for</span><span class="o">(</span> <span class="nc">Object</span> <span class="n">object</span> <span class="o">:</span> <span class="n">list</span> <span class="o">)</span> <span class="o">{</span>
        	<span class="nc">Object</span><span class="o">[]</span> <span class="n">results</span> <span class="o">=</span> <span class="o">(</span><span class="nc">Object</span><span class="o">[])</span> <span class="n">object</span><span class="o">;</span>	      	      
                <span class="k">for</span><span class="o">(</span> <span class="nc">Object</span> <span class="n">result</span> <span class="o">:</span> <span class="n">results</span> <span class="o">)</span> <span class="o">{</span>	          
                    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">print</span> <span class="o">(</span> <span class="n">result</span> <span class="o">);</span>	     
                <span class="o">}</span>	     
            <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">();</span>	  
        <span class="o">}</span>
    <span class="o">}</span>
</code></pre></div></div>
<ul>
  <li>Query 타입은 데이터 검색 결과의 타입을 명시하지 않는다.</li>
</ul>

<h3 id="파라미터-바인딩">파라미터 바인딩</h3>
<ul>
  <li>파라미터 바인딩에는 이름 기준 파라미터와 위치 기준 파라미터가 있다.</li>
  <li>위치 기준 파라미터 보다 이름 기준 파라미터가 더 명확하다.</li>
</ul>

<h4 id="이름-기준-파라미터">이름 기준 파라미터</h4>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">namedParameter</span><span class="o">(</span><span class="nc">EntityManager</span> <span class="n">em</span><span class="o">,</span> <span class="nc">String</span> <span class="n">param</span><span class="o">)</span> <span class="o">{</span>    
    <span class="nc">String</span> <span class="n">jpql</span> <span class="o">=</span> <span class="s">"select m from Member m where m.name = :name"</span><span class="o">;</span>	
    <span class="nc">TypedQuery</span><span class="o">&lt;</span><span class="nc">Member</span><span class="o">&gt;</span> <span class="n">query</span> <span class="o">=</span> <span class="n">em</span><span class="o">.</span><span class="na">createQuery</span><span class="o">(</span><span class="n">jpql</span><span class="o">,</span> <span class="nc">Book</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>	
    <span class="n">query</span><span class="o">.</span><span class="na">setParameter</span><span class="o">(</span><span class="s">"name"</span><span class="o">,</span> <span class="n">param</span><span class="o">);</span>		
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Member</span><span class="o">&gt;</span> <span class="n">list</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="na">getResultList</span><span class="o">();</span>
    <span class="o">}</span>
</code></pre></div></div>
<ul>
  <li>이름을 기준으로 파라미터를 바인딩 한다. 콜론( : ) 을 사용해 데이터가 추가될 곳을 지정하고,
query.setParameter() 메소드를 호출해 데이터를 동적으로 바인딩 한다.</li>
</ul>

<h4 id="위치-기준-파라미터">위치 기준 파라미터</h4>
<ul>
  <li>엔티티를 대상을 조회하려면 편리하겠지만, 꼭 필요한 데이터들만 선택해서 조회해야 할 때도 있다.</li>
  <li>이럴때 MemberDto 처럼 의미있는 객체로 변환해서 사용한다.</li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="nd">@AllArgsConstructor</span>
<span class="nd">@NoArgsConstructor</span>
<span class="nd">@Getter</span> 
<span class="nd">@Setter</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">MemberDto</span> <span class="o">{</span>
    	<span class="kd">private</span> <span class="nc">String</span> <span class="n">name</span><span class="o">;</span>	<span class="kd">private</span> <span class="kt">int</span> <span class="n">age</span><span class="o">;</span>
    <span class="o">}</span>

</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kd">public</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">useDto</span> <span class="o">(</span><span class="nc">EntityManager</span> <span class="n">em</span><span class="o">)</span> <span class="o">{</span> 
    <span class="c1">// Dto 사용 ( new 명령어 )	</span>
    <span class="nc">String</span> <span class="n">jpql</span> <span class="o">=</span> <span class="s">"select new com.coco.example.MemberDto(m.name, m.age) from Member m"</span><span class="o">;</span>	
    <span class="nc">TypedQuery</span><span class="o">&lt;</span><span class="nc">MemberDto</span><span class="o">&gt;</span> <span class="n">query</span> <span class="o">=</span> <span class="n">em</span><span class="o">.</span><span class="na">createQuery</span><span class="o">(</span><span class="n">jpql</span><span class="o">,</span> <span class="nc">MemberDto</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>		
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MemberDto</span><span class="o">&gt;</span> <span class="n">list</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="na">getResultList</span><span class="o">();</span>	
    <span class="k">for</span><span class="o">(</span> <span class="nc">BookDTO</span> <span class="n">dto</span> <span class="o">:</span> <span class="n">list</span><span class="o">)</span> <span class="o">{</span>		
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"dto : "</span> <span class="o">+</span> <span class="n">dto</span><span class="o">);</span>	
        <span class="o">}</span>
    <span class="o">}</span>

</code></pre></div></div>

<ul>
  <li>select 와 from 사이에 new 라는 키워드 뒤에 Dto의 패키지명까지 작성해야 한다.</li>
  <li>이 때 new는 객체를 생성하라는 의미가 아니라 JPQL에서 지원하는 new 키워드이다.</li>
</ul>

<h3 id="jpql-vs-sql">JPQL vs SQL</h3>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>JPQL</th>
      <th>SQL</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>대상</td>
      <td>엔티티(Entity), 필드명</td>
      <td>테이블, 컬럼명</td>
    </tr>
    <tr>
      <td>반환 타입</td>
      <td>객체(Entity), 필드, DTO</td>
      <td>Row 데이터</td>
    </tr>
    <tr>
      <td>실행 대상</td>
      <td>JPA (영속성 컨텍스트)</td>
      <td>데이터베이스</td>
    </tr>
    <tr>
      <td>목적</td>
      <td>객체 중심의 비즈니스 로직 처리</td>
      <td>DB 중심의 데이터 처리</td>
    </tr>
    <tr>
      <td>JOIN</td>
      <td>객체 필드로 조인</td>
      <td>외래 키를 기준으로 조인</td>
    </tr>
  </tbody>
</table>

<h3 id="jpql-주의사항">JPQL 주의사항</h3>
<ol>
  <li>테이블명 대신 클래스명, 컬럼명 대신 필드명을 사용</li>
  <li>SELECT 필드명은 항상 엔티티 기준</li>
  <li>엔티티가 아닌 테이블을 직접 조회할 수는 없음</li>
  <li>엔티티 매핑이 잘못돼 있으면 쿼리가 실패</li>
</ol>]]></content><author><name>KimDohwan24</name></author><category term="TIL" /><category term="JPA" /><category term="JPQL" /><category term="Database" /><summary type="html"><![CDATA[JPQL JPA는 SQL을 추상화한 JPQL이라는 개체 지향 쿼리 언어를 제공한다 테이블을 대상으로 쿼리 하는 것이 아닌 엔티티 객체를 대항으로 쿼리한다 JPQL은 SQL을 추상화했기 때문에 특정 데이터베이스 SQL에 의존하지 않는 장점이 있다 JPQL은 SQL과 문법이 유사하며, SELECT, FROM, WHERE, GROUP BY, HAVING, JOIN을 지원한다 JPQL은 결국 SQL로 변환된다]]></summary></entry><entry><title type="html">AOP</title><link href="https://kimdohwan24.github.io/TIL/til/2025/06/11/TIL-29.html" rel="alternate" type="text/html" title="AOP" /><published>2025-06-11T00:00:00+00:00</published><updated>2025-06-11T00:00:00+00:00</updated><id>https://kimdohwan24.github.io/TIL/til/2025/06/11/TIL-29</id><content type="html" xml:base="https://kimdohwan24.github.io/TIL/til/2025/06/11/TIL-29.html"><![CDATA[<h2 id="aop--관점-지향-프로그래밍-">AOP ( 관점 지향 프로그래밍 )</h2>
<ul>
  <li>AOP는 OOP( Object Oriented Programming )를 돕는 보조적인 기술로, 관심사의 분리( 기능의 분리 )의 문제를 해결하기 위해 만들어진 프로그래밍 패러다임이다.</li>
  <li>AOP는 기능을 핵심 관심 사항( Core Concern )과 공통 관심 사항( Cross - Cutting Concern )으로 분리시키고 각각을 모듈화 하는것을 의미한다.</li>
</ul>

<h3 id="aop의-핵심-개념">AOP의 핵심 개념</h3>

<ol>
  <li>관심사 ( Concern )
    <ul>
      <li>애플리케이션에서 어떤 기능이나 책임을 말한다</li>
      <li>예 : 비즈니스 로직, 보안, 로깅 등..</li>
    </ul>
  </li>
  <li>횡단 관심사 ( Cross - Cutting Concern )
    <ul>
      <li>여러 모듈에서 공통적으로 사용되는 기능</li>
      <li>예 : 로그출력, 트랜잭션 처리, 인증/인가 등..</li>
    </ul>
  </li>
  <li>Aspect
    <ul>
      <li>횡단 관심사를 모듈화한 단위</li>
      <li>예 : 로깅 기능을 <code class="language-plaintext highlighter-rouge">LoggingAspect</code>라는 클래스로 작성</li>
    </ul>
  </li>
</ol>

<h3 id="aop의-주요-용어">AOP의 주요 용어</h3>

<table>
  <thead>
    <tr>
      <th>용어</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Join Point</td>
      <td>Advice가 적용될 수 있는 지점. 메서드 실행, 예외 발생 등.</td>
    </tr>
    <tr>
      <td>Advice</td>
      <td>실제로 수행할 작업(로직). 예: 메서드 실행 전/후에 로그 찍기.</td>
    </tr>
    <tr>
      <td>Pointcut</td>
      <td>Advice를 적용할 Join Point를 결정하는 표현식.</td>
    </tr>
    <tr>
      <td>Aspect</td>
      <td>Advice + Pointcut을 합쳐서 정의한 클래스.</td>
    </tr>
    <tr>
      <td>Weaving</td>
      <td>Aspect를 실제 코드에 적용하는 과정. (컴파일 시, 클래스 로딩 시, 런타임 등)</td>
    </tr>
  </tbody>
</table>

<h3 id="aop-클래스-정의-하는-방법">AOp 클래스 정의 하는 방법</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="c1">// 밑의 두개의 어노테이션을 작성하면 된다.</span>
<span class="nd">@Aspect</span> <span class="c1">// AOP 클래스임을 명시하는 어노테이션</span>
<span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">LoggingAspect</span> <span class="o">{</span>

    <span class="nd">@Before</span><span class="o">(</span><span class="s">"execution(* com.example.service.*.*(..))"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">beforeMethod</span><span class="o">(</span><span class="nc">JoinPoint</span> <span class="n">joinPoint</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"Before: "</span> <span class="o">+</span> <span class="n">joinPoint</span><span class="o">.</span><span class="na">getSignature</span><span class="o">().</span><span class="na">getName</span><span class="o">());</span>
    <span class="o">}</span>

    <span class="nd">@AfterReturning</span><span class="o">(</span><span class="n">pointcut</span> <span class="o">=</span> <span class="s">"execution(* com.example.service.*.*(..))"</span><span class="o">,</span> <span class="n">returning</span> <span class="o">=</span> <span class="s">"result"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">afterMethod</span><span class="o">(</span><span class="nc">JoinPoint</span> <span class="n">joinPoint</span><span class="o">,</span> <span class="nc">Object</span> <span class="n">result</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"After: "</span> <span class="o">+</span> <span class="n">joinPoint</span><span class="o">.</span><span class="na">getSignature</span><span class="o">().</span><span class="na">getName</span><span class="o">()</span> <span class="o">+</span> <span class="s">" Result: "</span> <span class="o">+</span> <span class="n">result</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>


</code></pre></div></div>

<h3 id="advice-종류-요약">Advice 종류 요약</h3>

<table>
  <thead>
    <tr>
      <th>종류</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@Before</code></td>
      <td>메서드 실행 전에 실행</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@AfterReturning</code></td>
      <td>메서드가 정상적으로 종료된 후 실행</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@AfterThrowing</code></td>
      <td>예외 발생 후 실행</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@After</code></td>
      <td>정상 종료든 예외든 무조건 실행 (finally 역할)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@Around</code></td>
      <td>메서드 실행 전후를 모두 제어 (가장 강력하고 유연함)</td>
    </tr>
  </tbody>
</table>

<h3 id="aop의-장점과-주의점">AOP의 장점과 주의점</h3>

<ul>
  <li>장점
    <ol>
      <li>코드 중복 제거</li>
      <li>모듈화 개선</li>
      <li>유지보수 용이</li>
      <li>핵심 로직과 부가 로직 분리 가능 ( 재사용성을 높임 )</li>
    </ol>
  </li>
  <li>주의점
    <ol>
      <li>너무 남용하면 로직 추적이 어려워짐</li>
      <li>Spring AOP는 기본적으로 프록시 기반이므로 인터페이스 기반 또는 클래스 기반 프록시임을 이해하고 사용해야함</li>
      <li>private 매서드에는 적용 X ( Spring AOP 기준 )</li>
    </ol>
  </li>
</ul>

<h2 id="프록시proxy">프록시(Proxy)</h2>
<ul>
  <li>다른 객체에 대한 접근을 제어하는 대리자이다.</li>
  <li>즉, 클라이언트는 진짜 객체 대신 프록시 객체를 사용하고, 프록시가 진짜 객체에 요청을 전달하면서 추가기능 ( 로깅, 보안, 트랜잭션 등 )을 수행할 수 있다.</li>
</ul>]]></content><author><name>KimDohwan24</name></author><category term="TIL" /><category term="Spring" /><category term="AOP" /><category term="Aspect" /><summary type="html"><![CDATA[AOP ( 관점 지향 프로그래밍 ) AOP는 OOP( Object Oriented Programming )를 돕는 보조적인 기술로, 관심사의 분리( 기능의 분리 )의 문제를 해결하기 위해 만들어진 프로그래밍 패러다임이다. AOP는 기능을 핵심 관심 사항( Core Concern )과 공통 관심 사항( Cross - Cutting Concern )으로 분리시키고 각각을 모듈화 하는것을 의미한다.]]></summary></entry><entry><title type="html">Test Code</title><link href="https://kimdohwan24.github.io/TIL/til/2025/06/09/TIL-28.html" rel="alternate" type="text/html" title="Test Code" /><published>2025-06-09T00:00:00+00:00</published><updated>2025-06-09T00:00:00+00:00</updated><id>https://kimdohwan24.github.io/TIL/til/2025/06/09/TIL-28</id><content type="html" xml:base="https://kimdohwan24.github.io/TIL/til/2025/06/09/TIL-28.html"><![CDATA[<h2 id="테스트-코드">테스트 코드</h2>
<ul>
  <li>소프트웨어 개발 후 기능과 동작을 테스트하는 데 사용되는 코드이다.</li>
  <li>개발자가 예상한대로 프로그램이 실행하는지 확인하는 역할을 한다.</li>
  <li>어떤 기능을 테스트할 것인지에 대해 각각 테스트 케이스를 분류하고, 다양한 라이브러리와 프레임워크를 이용해 작성할 수 있다.</li>
</ul>

<h3 id="테스트-코드의-종류">테스트 코드의 종류</h3>

<ol>
  <li>단위 테스트 ( Unit Test )
    <ul>
      <li>하나의 메서드나 클래스 등 작은 단위를 독립적으로 테스트하는 방법이다.</li>
      <li>외부 의존성( DB, 네트워크 등 )은 mock이 처리</li>
      <li>예 : <code class="language-plaintext highlighter-rouge">회원가입 시 이메일 형식을 검사하는 함수</code>만 따로 테스트</li>
    </ul>
  </li>
  <li>통합 테스트 ( Integration Test )
    <ul>
      <li>여러 컴포넌트( 예 : controller, Service, Repository )가 함께 동작하는지 확인</li>
      <li>실제 데이터베이스나 서버와 연결되는 경우가 많음</li>
      <li>예 : <code class="language-plaintext highlighter-rouge">회원가입 요청 -&gt; DB저장까지</code> 전체 흐름 테스트</li>
    </ul>
  </li>
  <li>엔드 투 엔드 테스트 ( E2E, End-to-End )
    <ul>
      <li>사용자 관점에서 전체 시스템을 테스트 (브라우저 자동화 등 포함)</li>
      <li>UI -&gt; 백앤드 -&gt; DB 등 실제 사용 흐름 전체 검증</li>
    </ul>
  </li>
</ol>

<h3 id="그래서-테스트-코드를-사용하는-이유">그래서 테스트 코드를 사용하는 이유?</h3>

<ol>
  <li>버그 예방 및 조기 발견
    <ul>
      <li>개발 중 실수나 논리 오류를 빠르게 발견할 수 있다.</li>
      <li>코드가 예상대로 동작하는지 확인할 수 있어, 배포 전에 문제를 줄일 수 있다.</li>
    </ul>
  </li>
  <li>리팩토링의 안정성 확보
    <ul>
      <li>기존 기능을 망가뜨리지 않고 코드를 개선할 수 있다.</li>
      <li>테스트 코드가 변경 전/후의 동작을 비교해주는 보호막 역할을 한다.</li>
    </ul>
  </li>
  <li>자동화된 검증으로 생산성 향상
    <ul>
      <li>수동 테스트 대신 자동으로 실행되므로 반복 작업을 줄여준다.</li>
      <li>CI/DI 파이프라인에서 테스트 자동 실행으로 배포 안정성 확보가 가능하다.</li>
    </ul>
  </li>
  <li>문서화 역학
    <ul>
      <li>테스트 코드를 보면 해당 기능이 어떤 입력과 출력을 갖는지 알 수 있어, 일종의 실행 가능한 명세서 역할을 한다.</li>
    </ul>
  </li>
  <li>협업과 유지보수에 유리
    <ul>
      <li>다른 개발자가 코드를 변경할 때 테스트가 실패하면 영향을 인지할 수 있다.</li>
      <li>코드 변경 시 의도치 않은 부작용을 방지할 수 있어 유지보수에 매우 유리하다.</li>
    </ul>
  </li>
  <li>디자인 품질 향상
    <ul>
      <li>테스트 가능한 구조를 만들기 위해 결합도를 낮추고, 의존성을 분리하게 되어 설계 품질이 좋아진다.</li>
    </ul>
  </li>
</ol>

<h3 id="테스트-코드-작성-예시">테스트 코드 작성 예시</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@SpringBootTest</span>
<span class="kd">class</span> <span class="nc">UserServiceTest</span> <span class="o">{</span>

    <span class="nd">@Autowired</span>
    <span class="kd">private</span> <span class="nc">UserService</span> <span class="n">userService</span><span class="o">;</span>

    <span class="nd">@Test</span>
    <span class="kt">void</span> <span class="n">회원가입_성공</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// given</span>
        <span class="nc">UserDto</span> <span class="n">userDto</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">UserDto</span><span class="o">(</span><span class="s">"test@example.com"</span><span class="o">,</span> <span class="s">"password123"</span><span class="o">);</span>

        <span class="c1">// when</span>
        <span class="nc">User</span> <span class="n">user</span> <span class="o">=</span> <span class="n">userService</span><span class="o">.</span><span class="na">register</span><span class="o">(</span><span class="n">userDto</span><span class="o">);</span>

        <span class="c1">// then</span>
        <span class="n">assertEquals</span><span class="o">(</span><span class="s">"test@example.com"</span><span class="o">,</span> <span class="n">user</span><span class="o">.</span><span class="na">getEmail</span><span class="o">());</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="테스트-코드의-구성-요소">테스트 코드의 구성 요소</h3>

<table>
  <thead>
    <tr>
      <th>구성 요소</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@Test</code></td>
      <td>해당 메서드가 테스트임을 표시</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">assertEquals()</code>, <code class="language-plaintext highlighter-rouge">assertTrue()</code> 등</td>
      <td>테스트 결과가 기대값과 같은지 검증</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@BeforeEach</code>, <code class="language-plaintext highlighter-rouge">@AfterEach</code></td>
      <td>각 테스트 실행 전/후 초기화</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@Mock</code>, <code class="language-plaintext highlighter-rouge">@InjectMocks</code></td>
      <td>외부 의존 객체를 가짜(Mock)로 생성</td>
    </tr>
  </tbody>
</table>

<h3 id="테스트-코드의-장점">테스트 코드의 장점</h3>

<table>
  <thead>
    <tr>
      <th>장점</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>빠른 피드백</td>
      <td>코드 변경 후 바로 영향 확인 가능</td>
    </tr>
    <tr>
      <td>리팩토링 안전</td>
      <td>테스트 코드가 보호막 역할</td>
    </tr>
    <tr>
      <td>협업 용이</td>
      <td>다른 개발자도 코드의 의도를 쉽게 이해</td>
    </tr>
    <tr>
      <td>유지보수 용이</td>
      <td>시간이 지나도 기능 보장이 가능</td>
    </tr>
  </tbody>
</table>

<h3 id="테스트-코드-작성-시-유의사항">테스트 코드 작성 시 유의사항</h3>
<ul>
  <li>작고 명확한 테스트: 한 번에 하나의 기능만 테스트</li>
  <li>독립적 테스트: 테스트 간 의존성이 없어야 함</li>
  <li>실행 시간 고려: 너무 오래 걸리면 테스트를 자주 안 하게 됨</li>
  <li>실제 데이터 조심: 테스트 DB 또는 Mock 사용 권장</li>
</ul>

<h3 id="tdd--test-driven-development-">TDD ( Test-Driven Development )</h3>
<ul>
  <li>
    <p>TDD는 테스트 코드를 먼저 작성한 후, 해당 테스트를 통과하도록 실제 코드를 구현하는 개발 방식입니다.</p>
  </li>
  <li>순서:
    <ol>
      <li>실패하는 테스트 작성 (Red)</li>
      <li>테스트 통과하는 최소한의 코드 작성 (Green)</li>
      <li>중복 제거, 리팩토링 (Refactor)</li>
    </ol>
  </li>
  <li>이 방식은 설계 품질과 안정성을 동시에 확보할 수 있도록 도와줍니다.</li>
</ul>]]></content><author><name>KimDohwan24</name></author><category term="TIL" /><category term="Test" /><category term="Spring" /><category term="Quality" /><summary type="html"><![CDATA[테스트 코드 소프트웨어 개발 후 기능과 동작을 테스트하는 데 사용되는 코드이다. 개발자가 예상한대로 프로그램이 실행하는지 확인하는 역할을 한다. 어떤 기능을 테스트할 것인지에 대해 각각 테스트 케이스를 분류하고, 다양한 라이브러리와 프레임워크를 이용해 작성할 수 있다.]]></summary></entry><entry><title type="html">Filter</title><link href="https://kimdohwan24.github.io/TIL/til/2025/06/05/TIL-27.html" rel="alternate" type="text/html" title="Filter" /><published>2025-06-05T00:00:00+00:00</published><updated>2025-06-05T00:00:00+00:00</updated><id>https://kimdohwan24.github.io/TIL/til/2025/06/05/TIL-27</id><content type="html" xml:base="https://kimdohwan24.github.io/TIL/til/2025/06/05/TIL-27.html"><![CDATA[<h2 id="filter">Filter</h2>
<ul>
  <li>Servlet Filter는 서블릿 스펙의 일부로, 웹 애플리케이션의 요정(Request) 또는 응답(Response)을 가로채고 가공할 수 있는 재사용 가능한 컴포넌트이다.</li>
  <li>Spring에서도 이를 활용해 인증/인가 필터, 로깅 필터, CORS 필터 등을 만들 수 있다.</li>
</ul>

<h3 id="filter-동작-순서">Filter 동작 순서</h3>
<ol>
  <li>클라이언트의 요청이 먼저 필터를 통과함</li>
  <li>각 필터는 doFilter() 메서드를 통해 요청을 가로채고 필요 시 후속 필터 또는 서블릿으로 넘김</li>
  <li>서블릿/컨트롤러가 응답을 생성한 후 필터로 다시 돌아옴</li>
  <li>필터가 응답을 후처리하고 클라이언트로 전달</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[HTTP 요청]
   ↓
[FilterChain: 여러 필터]
   ↓
[DispatcherServlet 또는 서블릿]
   ↓
[Controller 등 비즈니스 로직]
   ↓
[응답 생성]
   ↑
[Filter: 응답 후처리]
   ↑
[클라이언트]

</code></pre></div></div>

<h3 id="filter-사용-예시">Filter 사용 예시</h3>
<ul>
  <li>로그인 검증</li>
  <li>JWT 토큰 검사</li>
  <li>CORS 처리</li>
  <li>요청/응답 로깅</li>
  <li>응답 캐싱</li>
</ul>

<h3 id="filter-구현-예제">Filter 구현 예제</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@WebFilter</span><span class="o">(</span><span class="n">urlPatterns</span> <span class="o">=</span> <span class="s">"/*"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">LoggingFilter</span> <span class="kd">implements</span> <span class="nc">Filter</span> <span class="o">{</span>
    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">doFilter</span><span class="o">(</span><span class="nc">ServletRequest</span> <span class="n">request</span><span class="o">,</span> <span class="nc">ServletResponse</span> <span class="n">response</span><span class="o">,</span>
                         <span class="nc">FilterChain</span> <span class="n">chain</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span><span class="o">,</span> <span class="nc">ServletException</span> <span class="o">{</span>
        <span class="nc">HttpServletRequest</span> <span class="n">req</span> <span class="o">=</span> <span class="o">(</span><span class="nc">HttpServletRequest</span><span class="o">)</span> <span class="n">request</span><span class="o">;</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"Request URI: "</span> <span class="o">+</span> <span class="n">req</span><span class="o">.</span><span class="na">getRequestURI</span><span class="o">());</span>

        <span class="n">chain</span><span class="o">.</span><span class="na">doFilter</span><span class="o">(</span><span class="n">request</span><span class="o">,</span> <span class="n">response</span><span class="o">);</span> <span class="c1">// 다음 필터 또는 서블릿으로 전달</span>

        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"Response processed"</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<ul>
  <li>Spring에서 필터 등록 ( Spring boot )</li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">FilterConfig</span> <span class="o">{</span>
    <span class="nd">@Bean</span>
    <span class="kd">public</span> <span class="nc">FilterRegistrationBean</span><span class="o">&lt;</span><span class="nc">LoggingFilter</span><span class="o">&gt;</span> <span class="nf">loggingFilter</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">FilterRegistrationBean</span><span class="o">&lt;</span><span class="nc">LoggingFilter</span><span class="o">&gt;</span> <span class="n">registration</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">FilterRegistrationBean</span><span class="o">&lt;&gt;();</span>
        <span class="n">registration</span><span class="o">.</span><span class="na">setFilter</span><span class="o">(</span><span class="k">new</span> <span class="nc">LoggingFilter</span><span class="o">());</span>
        <span class="n">registration</span><span class="o">.</span><span class="na">addUrlPatterns</span><span class="o">(</span><span class="s">"/*"</span><span class="o">);</span>
        <span class="k">return</span> <span class="n">registration</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="filter-주요-사용-예">Filter 주요 사용 예</h3>

<table>
  <thead>
    <tr>
      <th>목적</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>인증 처리</td>
      <td>로그인 여부 확인 후 인증되지 않은 사용자는 접근 제한</td>
    </tr>
    <tr>
      <td>CORS 처리</td>
      <td>응답에 CORS 헤더를 추가</td>
    </tr>
    <tr>
      <td>로깅</td>
      <td>요청 URI, 처리 시간, 사용자 정보 등 기록</td>
    </tr>
    <tr>
      <td>인코딩</td>
      <td>UTF-8 등의 문자 인코딩 설정</td>
    </tr>
    <tr>
      <td>보안 검사</td>
      <td>요청 헤더, 파라미터에 대한 악성코드 검사 등</td>
    </tr>
  </tbody>
</table>

<h3 id="filter의-장단점">Filter의 장단점</h3>

<ul>
  <li>장점
    <ol>
      <li>요청/응답 흐름 제어 가능 (컨트롤러 밖에서 동작)</li>
      <li>관심사 분리 (예: 인증, 로깅 등을 독립적으로 관리)</li>
      <li>다양한 URL에 공통 처리 적용 가능</li>
    </ol>
  </li>
  <li>단점
    <ol>
      <li>순서를 잘못 구성하거나 무분별하게 사용하면 디버깅이 어려움</li>
      <li>비즈니스 로직과 동떨어져 있어 테스트가 어려울 수 있음</li>
      <li>Spring Security 필터와 충돌 우려가 있음 (주의 필요)</li>
    </ol>
  </li>
</ul>

<h3 id="관련-개념">관련 개념</h3>

<table>
  <thead>
    <tr>
      <th>개념</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Interceptor</td>
      <td>Spring MVC 전용. Handler 실행 전/후 동작 가능. 컨트롤러 수준에서 동작함.</td>
    </tr>
    <tr>
      <td>AOP</td>
      <td>메서드 단위의 공통 관심사를 처리. 트랜잭션, 로깅 등에서 사용됨.</td>
    </tr>
    <tr>
      <td>Filter vs Interceptor</td>
      <td>Filter는 서블릿 단위(DispatcherServlet 이전), Interceptor는 컨트롤러 단위(DispatcherServlet 이후).</td>
    </tr>
  </tbody>
</table>

<h3 id="spring-환경에서의-필터-순서">Spring 환경에서의 필터 순서</h3>

<ul>
  <li>Spring Boot에서는 다음과 같은 순서로 필터가 실행된다:
    <ol>
      <li>Servlet Filter (javax.servlet.Filter)</li>
      <li>Spring Security Filter (내부에서 여러 FilterChain 구성됨)</li>
      <li>DispatcherServlet</li>
      <li>Interceptor</li>
      <li>Controller</li>
    </ol>
  </li>
  <li>필터 순서 조정이 필요하다면 <code class="language-plaintext highlighter-rouge">FilterRegistrationBean#setOrder()</code>를 통해 설정 가능하다.</li>
</ul>]]></content><author><name>KimDohwan24</name></author><category term="TIL" /><category term="Spring" /><category term="Servlet" /><category term="Filter" /><summary type="html"><![CDATA[Filter Servlet Filter는 서블릿 스펙의 일부로, 웹 애플리케이션의 요정(Request) 또는 응답(Response)을 가로채고 가공할 수 있는 재사용 가능한 컴포넌트이다. Spring에서도 이를 활용해 인증/인가 필터, 로깅 필터, CORS 필터 등을 만들 수 있다.]]></summary></entry></feed>