<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>minpractice_jhj.log</title>
        <link>https://velog.io/</link>
        <description>운동처럼 개발도 작은 실천이 성장의 힘이 된다고 믿는 개발자 minpractice_jhj 기록</description>
        <lastBuildDate>Thu, 30 Oct 2025 04:39:04 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>minpractice_jhj.log</title>
            <url>https://velog.velcdn.com/images/minpractice_jhj/profile/3fe3e178-f4b5-4fe8-984d-c8de97ded878/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. minpractice_jhj.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/minpractice_jhj" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[8-2] Spring Boot Cache 설계: Caffeine + AFTER_COMMIT 이벤트 기반 — L1(Local)]]></title>
            <link>https://velog.io/@minpractice_jhj/Spring-Boot-Caffeine-%EC%BA%90%EC%8B%9C-%EC%84%A4%EA%B3%84-%ED%83%80%EC%9E%85-%EC%95%88%EC%A0%84%ED%95%9C-%EC%A0%95%EC%B1%85-%EC%A3%BC%EC%9E%85%EA%B3%BC-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%B0%98-%EB%AC%B4%ED%9A%A8%ED%99%94</link>
            <guid>https://velog.io/@minpractice_jhj/Spring-Boot-Caffeine-%EC%BA%90%EC%8B%9C-%EC%84%A4%EA%B3%84-%ED%83%80%EC%9E%85-%EC%95%88%EC%A0%84%ED%95%9C-%EC%A0%95%EC%B1%85-%EC%A3%BC%EC%9E%85%EA%B3%BC-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%B0%98-%EB%AC%B4%ED%9A%A8%ED%99%94</guid>
            <pubDate>Thu, 30 Oct 2025 04:39:04 GMT</pubDate>
            <description><![CDATA[<h1 id="1부-개요편--배경과-목표">1부. 개요편 — 배경과 목표</h1>
<h2 id="1-설계-배경">1. 설계 배경</h2>
<p>PinUp의 게시글/이미지 조회 요청은 <strong>읽기 비율이 90% 이상</strong>으로,</p>
<p>로컬 JVM 캐시만으로도 상당한 부하 절감이 가능했다.</p>
<p>단, 단일 정책(<code>caffeine.spec</code>)으로는 다음과 같은 문제점이 있었다.</p>
<table>
<thead>
<tr>
<th>문제</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>세분화 불가</strong></td>
<td>post:detail과 post:images의 TTL·Size 정책을 분리할 수 없음</td>
</tr>
<tr>
<td><strong>테스트/운영 불투명성</strong></td>
<td>Cache 이름 오타나 미등록 캐시 접근 시 무시되어 버림</td>
</tr>
<tr>
<td><strong>확장성 부족</strong></td>
<td>향후 L2(Redis) 확장 시 per-cache invalidation 불가능</td>
</tr>
</tbody></table>
<p>이에 따라 <strong>Spring Cache의 기본 구성을 확장</strong>하여</p>
<p>“타입 안전한 정책 바인딩 + per-cache 주입 + 이벤트 기반 무효화”를 설계했다.</p>
<h3 id="목표">목표</h3>
<ul>
<li><strong>L1(Local Cache)만으로도 높은 캐시 적중률 확보</strong></li>
<li><strong>추후 L2(Redis)로 자연스럽게 확장 가능한 구조 설계</strong></li>
</ul>
<h2 id="1-2-참고-사례">1-2. 참고 사례</h2>
<table>
<thead>
<tr>
<th>기업/자료</th>
<th>인사이트</th>
<th>반영 포인트</th>
</tr>
</thead>
<tbody><tr>
<td>LG U+ Tech Blog</td>
<td></td>
<td></td>
</tr>
<tr>
<td><a href="https://techblog.uplus.co.kr/%EB%A1%9C%EC%BB%AC-%EC%BA%90%EC%8B%9C-%EC%84%A0%ED%83%9D%ED%95%98%EA%B8%B0-e394202d5c87?utm_source=chatgpt.com">https://techblog.uplus.co.kr/%EB%A1%9C%EC%BB%AC-%EC%BA%90%EC%8B%9C-%EC%84%A0%ED%83%9D%ED%95%98%EA%B8%B0-e394202d5c87?utm_source=chatgpt.com</a></td>
<td>글로벌(공유) 캐시 vs 로컬 캐시 비교, 분산 환경에서 <strong>캐시 무효화와 메시지 전파 고려</strong>를 강조. 전파 필요가 낮은 도메인은 로컬 캐시만으로도 충분.</td>
<td>현재 <strong>L1(Caffeine) 단독 운영</strong>의 타당성 근거 확보. 다중 인스턴스 확장 시를 대비해 <strong>무효화 전파 표준(채널/포맷/멱등성)</strong> 설계 포함.</td>
</tr>
<tr>
<td>카카오페이 Tech Blog</td>
<td></td>
<td></td>
</tr>
<tr>
<td><a href="https://tech.kakaopay.com/post/local-caching-in-distributed-systems/?utm_source=chatgpt.com">https://tech.kakaopay.com/post/local-caching-in-distributed-systems/?utm_source=chatgpt.com</a></td>
<td><strong>로컬 캐시 + Redis Pub/Sub</strong>로 최신화(무효화 전파), <strong>Eventual Consistency</strong> 수용. 도메인별로 로컬/Redis 역할 구분.</td>
<td>현재의 <strong>이벤트 기반 정밀 무효화(AFTER_COMMIT)</strong>, <strong>per-cache 정책(yml)</strong>과 철학 일치. 차기 릴리스에서 <strong>Pub/Sub 전파</strong>만 추가하면 구조 완성.</td>
</tr>
<tr>
<td>00h0 티스토리 – 로컬 캐시/분산 정합성</td>
<td></td>
<td></td>
</tr>
<tr>
<td><a href="https://00h0.tistory.com/112?utm_source=chatgpt.com">https://00h0.tistory.com/112?utm_source=chatgpt.com</a></td>
<td>분산 정합성 수단을 <strong>두 축(① TTL, ② invalidation message propagation)</strong>으로 제시. 커밋 이후 이벤트·Pub/Sub 전파 권장.</td>
<td>이번 릴리스의 <strong>per-cache TTL</strong>로 <strong>bounded staleness</strong> 확보 + <strong>이벤트로 postId 정밀 무효화</strong>는 권장과 동일 축. 차기에는 <strong>Pub/Sub 전파</strong>로 다중 인스턴스 일관성 강화.</td>
</tr>
</tbody></table>
<h2 id="1-3-설계-개요">1-3. 설계 개요</h2>
<h3 id="캐시-설계-순서">캐시 설계 순서</h3>
<table>
<thead>
<tr>
<th>단계</th>
<th>목적</th>
<th>산출물</th>
</tr>
</thead>
<tbody><tr>
<td>①</td>
<td>캐시 카탈로그 정의</td>
<td>Cache Catalog(대상/TTL/무효화 기준)</td>
</tr>
<tr>
<td>②</td>
<td>정책 저장소 분리</td>
<td><code>spring.cache.app</code> 블록(yml 기반)</td>
</tr>
<tr>
<td>③</td>
<td>CacheManager 구성</td>
<td>공통 동작 + per-cache 정책 반영</td>
</tr>
<tr>
<td>④</td>
<td>무효화 이벤트 설계</td>
<td>Post 수정/삭제 시 정밀 타격</td>
</tr>
<tr>
<td>⑤</td>
<td><code>sync=true</code> 대상 선정</td>
<td>Hot Key 폭주 방지</td>
</tr>
</tbody></table>
<hr>
<h1 id="2부-설계편--정책·코드-구조">2부. 설계편 — 정책·코드 구조</h1>
<h2 id="2-1-설계-원칙design-checklist">2-1) 설계 원칙(Design Checklist)</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설계 의미(왜 필요한가)</th>
<th>현재 릴리스(L1) 적용</th>
<th>차기 릴리스(L2) 적용</th>
</tr>
</thead>
<tbody><tr>
<td>yml per-cache 정책(<code>spring.cache.app</code>)</td>
<td>TTL/size를 정책 레이어로 분리하여 운영 변경을 코드와 분리</td>
<td>적용</td>
<td>계속 사용</td>
</tr>
<tr>
<td>코드의 이름 상수(<code>CacheNames</code>)</td>
<td>오타 방지, 로깅/알람 지표명 일관성 확보</td>
<td>적용</td>
<td>계속 사용</td>
</tr>
<tr>
<td>무효화 이벤트(<code>PostCacheEvent</code> + Listener)</td>
<td>트랜잭션 커밋 이후 롤백-안전 정밀 무효화</td>
<td>적용</td>
<td>Pub/Sub 전파로 확장</td>
</tr>
<tr>
<td><code>sync=true</code> 적용 기준</td>
<td>동일 key 동시 미스(single-flight)로 스탬피드 방지</td>
<td>핫키 한정 적용</td>
<td>L2 Hit 충분 시 재평가/해제 가능</td>
</tr>
<tr>
<td>L1/L2 계층 구조</td>
<td>Local→Redis 체인으로 확장 가능하게 설계</td>
<td>설계만(유보)</td>
<td>도입</td>
</tr>
<tr>
<td>Pub/Sub 무효화 표준</td>
<td>다중 인스턴스 간 무효화 전파 표준화</td>
<td>설계만(유보)</td>
<td>도입(채널/포맷/멱등성)</td>
</tr>
<tr>
<td>관측성 SLO(4개)</td>
<td>목표치를 수치화하여 운영 품질 관리</td>
<td>L1용 SLO 적용</td>
<td>L2 항목 추가 확장</td>
</tr>
</tbody></table>
<h3 id="관측성-slo초기안">관측성 SLO(초기안)</h3>
<ul>
<li><strong>SLO-1:</strong> URI p95(상세 조회) 목표치 설정 및 추적</li>
<li><strong>SLO-2:</strong> Cache Hit Rate(캐시별) 목표치 설정 및 추적</li>
<li><strong>SLO-3:</strong> Evictions/Removals 이벤트율(업데이트 직후 스파이크 정상 여부)</li>
<li><strong>SLO-4:</strong> Build/Load 비용 p95(미스 후 로드 시간)</li>
<li><strong>L2 도입 시 추가:</strong> 전파 지연(무효화 Pub/Sub → 모든 인스턴스 적용 ≤ 1s), L2 RTT ≤ 20ms, L2 Hit ≥ 0.95</li>
</ul>
<hr>
<h2 id="2-2-정책-관리-구조-및-설계-비교">2-2) 정책 관리 구조 및 설계 비교</h2>
<p>캐시 정책은 단일 전역 정책(spec)으로는 한계가 있으므로,</p>
<p>PinUp에서는 아래 구조로 설계했다.</p>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/e128f39a-1a67-4a4e-924d-731a605d8bc7/image.png" alt=""></p>
<pre><code class="language-yaml">spring:
  cache:
    type: caffeine
    strict: true
    app:
      defaults: { maximumSize: 40000, ttlSec: 300 }
      caches:
        &quot;post:detail&quot;: { maximumSize: 20000, ttlSec: 300 }
        &quot;post:images&quot;: { maximumSize: 20000, ttlSec: 1800 }
</code></pre>
<p>이 구조를 통해 각 캐시에 다른 TTL·Size 정책을 적용할 수 있으며,</p>
<p>운영 중에도 타입 안전하게 정책을 수정·확장할 수 있다.</p>
<hr>
<h3 id="왜-caffeinespec-대신-springcacheapp-구조인가">왜 caffeine.spec 대신 spring.cache.app 구조인가</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>caffeine.spec</th>
<th>spring.cache.app</th>
</tr>
</thead>
<tbody><tr>
<td><strong>정의 위치</strong></td>
<td><code>spring.cache.caffeine.spec</code> 문자열 기반</td>
<td><code>spring.cache.app.*</code> 계층형 구조</td>
</tr>
<tr>
<td><strong>적용 범위</strong></td>
<td>전역 (모든 캐시 동일 정책)</td>
<td>캐시별(per-cache) 세부 정책 가능</td>
</tr>
<tr>
<td><strong>유형 안정성</strong></td>
<td>문자열 파싱 → 런타임 오류 가능</td>
<td>타입 안전한 record 구조</td>
</tr>
<tr>
<td><strong>운영 제어</strong></td>
<td>미등록 캐시 접근 시 무시</td>
<td>strict=true 시 예외 발생</td>
</tr>
<tr>
<td><strong>확장성</strong></td>
<td>TTL/Size 외 옵션 한정</td>
<td>L2 확장, 통계, invalidation 연동 가능</td>
</tr>
</tbody></table>
<p>요약하면,</p>
<p><strong>caffeine.spec은 “설정” 중심</strong>,</p>
<p><strong>spring.cache.app은 “설계” 중심</strong>이다.</p>
<p>전자는 단순히 Builder에 파라미터를 전달하는 문자열 기반 설정이며,</p>
<p>후자는 캐시 정책을 체계적으로 관리하기 위한 <strong>정책 저장소 계층</strong>이다.</p>
<p>PinUp에서는 운영 중 TTL 변경, 캐시 추가·삭제, 통계 설정 등을</p>
<p>명시적이고 안전하게 관리하기 위해 <code>spring.cache.app</code> 구조를 채택했다.</p>
<hr>
<h3 id="병행-불가능-및-실무-기준-정리">병행 불가능 및 실무 기준 정리</h3>
<p>두 구조(<code>caffeine.spec</code> / <code>app.*</code>)는 <strong>병행이 불가능</strong>하다.</p>
<p>Spring Boot 자동 구성과 수동 CacheManager 빌더가 동시에 실행되어</p>
<p><code>Caffeine.newBuilder()</code> 설정이 중복 적용되며,</p>
<p><code>maximumSize()</code>나 <code>expireAfterWrite()</code> 중복 지정 시</p>
<p><code>IllegalStateException</code>이 발생할 수 있다.</p>
<blockquote>
<p>⚠️ Caffeine Builder는 불변(immutable) 객체이므로,</p>
<p>동일 속성 재설정은 런타임 예외로 이어진다.</p>
</blockquote>
<table>
<thead>
<tr>
<th>상황</th>
<th>권장 방식</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><strong>단일 캐시, 전역 정책만 필요</strong></td>
<td><code>spring.cache.caffeine.spec</code></td>
<td>간단하고 Spring Boot 자동 구성 그대로 사용 가능</td>
</tr>
<tr>
<td><strong>여러 캐시, TTL·Size 분리 필요</strong></td>
<td><code>spring.cache.app.*</code></td>
<td>캐시별 정책 세분화, 타입 안전 주입 가능</td>
</tr>
<tr>
<td><strong>두 방법 병행</strong></td>
<td>사용 금지</td>
<td>Builder 중복 호출로 충돌 발생 위험</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-3-바인딩-활성화-중요-한-줄">2-3) 바인딩 활성화 (중요 한 줄)</h2>
<p>AppCacheProps가 실제로 바인딩되도록 아래 둘 중 하나는 반드시 선언해야 한다.</p>
<p>이 줄이 없으면 <code>props.caches()</code>가 <code>null</code> → <strong>부팅 시 NPE</strong> 발생.</p>
<pre><code class="language-java">// 방법 A
@Configuration
@EnableConfigurationProperties(AppCacheProps.class)
public class CacheConfig {}

// 방법 B
@SpringBootApplication
@ConfigurationPropertiesScan  // @ConfigurationProperties 자동 스캔
public class PinupApplication {}
</code></pre>
<pre><code class="language-java">// AppCacheProps — 정책 바인딩 DTO
@ConfigurationProperties(prefix = &quot;spring.cache.app&quot;)
public record AppCacheProps(Defaults defaults, Map&lt;String, Spec&gt; caches) {
  public record Defaults(Long maximumSize, Integer ttlSec) {}
  public record Spec(Long maximumSize, Integer ttlSec) {}
}
</code></pre>
<p><strong>역할</strong></p>
<ul>
<li><code>application.yml</code>의 캐시 정책을 <strong>타입 안전</strong>하게 바인딩</li>
<li>“캐시 정책용 DTO”처럼 작동</li>
<li>부팅 시 오타/누락 방지, 이후 <code>CacheConfig</code>에서 바로 <code>props.caches()</code> 접근 가능</li>
</ul>
<hr>
<h2 id="2-4-cacheconfig--정책-주입형-cachemanager">2-4) CacheConfig — 정책 주입형 CacheManager</h2>
<pre><code class="language-java">@Configuration
@EnableConfigurationProperties(AppCacheProps.class)
public class CacheConfig {
  @Bean
  public CacheManager cacheManager(AppCacheProps props) {
    return new CaffeineCacheManager() {
      { setAllowNullValues(false); setCacheNames(props.caches().keySet()); }
      @Override protected CaffeineCache createCaffeineCache(String name) {
        var b = Caffeine.newBuilder().recordStats();
        var d = props.defaults(); var s = props.caches().get(name);
        Long max = (s!=null&amp;&amp;s.maximumSize()!=null)? s.maximumSize() : (d!=null? d.maximumSize():null);
        Integer ttl= (s!=null&amp;&amp;s.ttlSec()!=null)? s.ttlSec() : (d!=null? d.ttlSec():null);
        if (max!=null) b = b.maximumSize(max);
        if (ttl!=null) b = b.expireAfterWrite(Duration.ofSeconds(ttl));
        return new CaffeineCache(name, b.build(), false);
      }
    };
  }
}
</code></pre>
<p><strong>주의</strong></p>
<ul>
<li><code>maximumSize()</code> / <code>expireAfterWrite()</code>는 <strong>한 번만 설정 가능</strong></li>
<li>“한 트리”로 합쳐 쓰는 경우 특히 <strong>중복 호출 금지</strong></li>
<li><strong>최종값 계산 → 1회 세팅</strong> 패턴 사용</li>
</ul>
<hr>
<h2 id="2-5-캐시-어노테이션-의미와-핵심-속성-spel-키">2-5) 캐시 어노테이션 의미와 핵심 속성, SpEL 키</h2>
<h3 id="어노테이션-한-줄-정의">어노테이션 한 줄 정의</h3>
<ul>
<li><strong>@Cacheable</strong>: 메서드 호출 전 캐시 조회, 미스면 실행 후 결과 저장</li>
<li><strong>@CachePut</strong>: 메서드를 항상 실행하고 반환 결과로 캐시 갱신</li>
<li><strong>@CacheEvict</strong>: 캐시 항목/전체 제거(운영 시 전체 제거는 신중)</li>
<li><strong>@Caching</strong>: 한 메서드에 여러 캐시 어노테이션 조합</li>
</ul>
<h3 id="공통핵심-속성짧게">공통/핵심 속성(짧게)</h3>
<ul>
<li><strong>value / cacheNames</strong>: 캐시 이름(키 스페이스)</li>
<li><strong>key</strong>: SpEL로 키 지정(생략 시 파라미터 기반 <code>SimpleKey</code> 자동 생성)</li>
<li><strong>condition</strong>: 호출 전 “캐싱 시도 여부”(입력 기준, <code>#result</code> 사용 불가)</li>
<li><strong>unless</strong>: 호출 후 “저장 제외 조건”(반환값 기준, <code>#result</code> 사용 가능)</li>
<li><strong>sync (Cacheable 전용)</strong>: 동일 키 동시 미스 single-flight</li>
<li><strong>beforeInvocation (Evict 전용)</strong>: 실행 전 제거(예외 나도 제거)</li>
<li><strong>allEntries (Evict 전용)</strong>: 해당 캐시 전체 제거</li>
</ul>
<h3 id="spel-키-가이드요약">SpEL 키 가이드(요약)</h3>
<ul>
<li><strong>정의</strong>: <em>SpEL 키 = 캐시 키를 만드는 식</em>이고, <strong>입력(파라미터)로 충분히 구분되는, 불변·안정적인 키</strong>여야 함.</li>
<li><strong>컨텍스트</strong>: 파라미터 <code>#p0/#id</code>, 메타 <code>#root.methodName/#root.args</code>, 정적 호출 <code>T(...)</code>. <code>#result</code>는 키에서 사용 불가(호출 전 평가) — <code>unless</code> 등 <strong>호출 후</strong> 평가에서만 사용.</li>
<li><strong>생략 시 기본</strong>: <code>key</code>가 없으면 파라미터로 <code>SimpleKey</code> 생성(0개=EMPTY, 1개=그 값, 2개+=합성).</li>
</ul>
<hr>
<h2 id="2-6-이벤트-기반-무효화의-장점-및-리스너">2-6) 이벤트 기반 무효화의 장점 및 리스너</h2>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/ba7bddcd-f257-4a21-b5bf-1f17ba033d1b/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>효과</th>
</tr>
</thead>
<tbody><tr>
<td>응집도/확장성</td>
<td>트랜잭션 로직과 캐시 무효화 분리</td>
</tr>
<tr>
<td>확장 용이성</td>
<td>Redis Pub/Sub 등 L2 확장 시 그대로 재사용</td>
</tr>
<tr>
<td>정밀 타격</td>
<td><code>postId</code> 단위로 캐시 제거</td>
</tr>
</tbody></table>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class PostCacheInvalidationListener {

  private final CacheManager cacheManager;

  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void on(PostCacheEvent e) {
    switch (e.kind()) {
      case UPDATED -&gt; {
        if (e.detailChanged()) cacheManager.getCache(CacheNames.POST_DETAIL).evict(e.postId());
        if (e.imagesChanged()) cacheManager.getCache(CacheNames.POST_IMAGES).evict(e.postId());
      }
      case DISABLED -&gt; cacheManager.getCache(CacheNames.POST_DETAIL).evict(e.postId());
      case DELETED -&gt; {
        cacheManager.getCache(CacheNames.POST_DETAIL).evict(e.postId());
        cacheManager.getCache(CacheNames.POST_IMAGES).evict(e.postId());
      }
    }
  }
}
</code></pre>
<hr>
<h1 id="3부-적용편--pinup-실제-적용-및-검증">3부. 적용편 — PinUp 실제 적용 및 검증</h1>
<h2 id="3-1-이번-릴리스-적용-범위l1-로컬-캐시">3-1) 이번 릴리스 적용 범위(L1: 로컬 캐시)</h2>
<ul>
<li><strong>정책 레이어:</strong> <code>spring.cache.app</code>(per-cache TTL/size) 적용, <code>strict</code> + <code>setCacheNames</code>로 비선언 캐시 사용 금지</li>
<li><strong>이름 상수:</strong> <code>CacheNames.POST_DETAIL</code>, <code>CacheNames.POST_IMAGES</code> 적용</li>
<li><strong>무효화:</strong> <code>@TransactionalEventListener(AFTER_COMMIT)</code> 기반 <code>PostCacheEvent</code> 정밀 타격(evict by postId)</li>
<li><strong>sync=true:</strong> 핫키에 한정 적용(단일 키 동시 미스가 많을 때만)</li>
<li><strong>관측:</strong> <code>recordStats()</code> + Micrometer로 <strong>Hit/Miss/Put/Evict/Size</strong>, <strong>URI p95</strong>, <strong>로드 시간 p95</strong> 대시보드 구성</li>
<li><strong>유보(차기):</strong> L1→L2 체인, Pub/Sub 전파 표준, L2용 SLO(전파 지연/RTT/Hit)</li>
</ul>
<h2 id="3-2-도메인별-캐시-전략-서비스-코드-예시-포함">3-2) 도메인별 캐시 전략 (서비스 코드 예시 포함)</h2>
<h3 id="전략-표">전략 표</h3>
<table>
<thead>
<tr>
<th>적용 대상 구분</th>
<th>캐시 이름</th>
<th>Key 구조</th>
<th>주요 호출자</th>
<th>캐시 전략</th>
</tr>
</thead>
<tbody><tr>
<td>게시글 상세</td>
<td><code>post:detail</code></td>
<td><code>#postId</code></td>
<td><code>PostService.getPostById</code></td>
<td><code>sync=true</code>, TTL 5분</td>
</tr>
<tr>
<td>게시글 이미지</td>
<td><code>post:images</code></td>
<td><code>#postId</code></td>
<td><code>PostImageService.findImagesByPostId</code></td>
<td><code>sync=true</code>, TTL 30분</td>
</tr>
<tr>
<td>- <strong>핫경로:</strong> <code>/api/post/{postId}</code> 상세 조회</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- <strong>비핫경로:</strong> <code>/api/post/list/{storeId}</code> 목록(캐시 미사용)</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- 상세·이미지 캐시는 둘 다 <strong>DB hit 1회 → 이후 수십 회 재사용</strong> 패턴</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<h3 id="트랜잭션이벤트-흐름">트랜잭션/이벤트 흐름</h3>
<ol>
<li>Post 수정/삭제 요청</li>
<li><code>@Transactional</code> 내부에서 DB 조작(Post, PostImage)</li>
<li>Commit 후 <code>PostCacheEvent(updated or deleted)</code> 발행</li>
<li><code>PostCacheInvalidationListener.evict(&quot;post:detail&quot;, postId)</code></li>
<li><code>PostCacheInvalidationListener.evict(&quot;post:images&quot;, postId)</code></li>
<li>다음 조회 시 캐시 miss → DB 재적재 → put → 최신화</li>
</ol>
<ul>
<li><code>AFTER_COMMIT</code> 보장으로 <strong>롤백 시 캐시 무효화 없음</strong></li>
<li><code>detail/images</code>만 <strong>정밀 타격(evict)</strong>하여 불필요한 캐시 파괴 방지</li>
</ul>
<h3 id="서비스-코드-예시">서비스 코드 예시</h3>
<h3 id="synctrue-적용-기준운영-가이드"><code>sync=true</code> 적용 기준(운영 가이드)</h3>
<table>
<thead>
<tr>
<th>상황</th>
<th>적용</th>
</tr>
</thead>
<tbody><tr>
<td>로드 p95 ≥ 50–80ms 또는 동일 key 동시 미스 ≥ 3/초</td>
<td>적용</td>
</tr>
<tr>
<td>로드가 매우 가볍고 키 분산이 넓음</td>
<td>미적용</td>
</tr>
</tbody></table>
<blockquote>
<p>sync=true는 동일 키에 single-flight를 걸어 스탬피드 방지. 핫키 한정 적용이 정석.</p>
</blockquote>
<p><strong>단건 상세 캐시 (Hot key + 정합 민감)</strong></p>
<pre><code class="language-java">@Cacheable(
  value = CacheNames.POST_DETAIL,
  key = &quot;#p0&quot;,
  condition = &quot;!#p1&quot;,  // isDeleted == false일 때만 캐시
  sync = true
)
public PostResponse getPostById(Long id, boolean isDeleted) {
  return postRepository.findByIdAndIsDeleted(id, isDeleted)
      .map(PostResponse::from)
      .orElseThrow(PostNotFoundException::new);
}
</code></pre>
<p><strong>이미지 목록 캐시 (읽기 비용 큼 + 변경이 드문)</strong></p>
<pre><code class="language-java">@Cacheable(
  value = CacheNames.POST_IMAGES,
  key = &quot;#p0&quot;,
  sync = true
)
@Transactional(readOnly = true)
public List&lt;PostImageResponse&gt; findImagesByPostId(Long postId) {
  var postImages = postImageRepository.findByPostId(postId);
  return postImages.stream().map(PostImageResponse::from).collect(Collectors.toList());
}
</code></pre>
<blockquote>
<p>위 예시는 <strong>정밀 무효화 이벤트(→ 3-2 본문, 2-6절)</strong>와 결합해 정합성 + 성능을 동시에 달성한다.</p>
</blockquote>
<hr>
<h2 id="3-3-동작-다이어그램">3-3) 동작 다이어그램</h2>
<p><strong>부팅 시 흐름</strong></p>
<pre><code>@SpringBootApplication
   |
   | @EnableConfigurationProperties(AppCacheProps.class)
   v
ConfigurationPropertiesBinder  →  AppCacheProps 생성
   |
   v
CacheConfig.cacheManager(props) → setCacheNames(...) → createCaffeineCache(...)
</code></pre><p><strong>런타임 요청 흐름</strong></p>
<pre><code>[Client]→[Controller]→[Service @Cacheable]
                         |
                         | CacheInterceptor(AOP)
                         v
                   [Cache get(name,key)] ── hit? → return
                         |
                         └─ miss → [load] → [put] → return
</code></pre><p><strong>쓰기/갱신</strong></p>
<pre><code>[Service] → (DB) → publish(PostCacheEvent) → Listener → cache.evict(name,key)
</code></pre><hr>
<h2 id="3-4-운영-검증-결과">3-4) 운영 검증 결과</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>측정 결과</th>
<th>관찰</th>
</tr>
</thead>
<tbody><tr>
<td>Cache Hit Rate (<code>post:detail</code>)</td>
<td><strong>96.4%</strong></td>
<td>게시글 조회 대부분 캐시 적중</td>
</tr>
<tr>
<td>Cache Hit Rate (<code>post:images</code>)</td>
<td><strong>92.7%</strong></td>
<td>이미지 조회는 TTL 길어 안정적</td>
</tr>
<tr>
<td>Eviction Spike</td>
<td>수정 이벤트 직후만 단발 상승</td>
<td>부분 무효화 정상 작동</td>
</tr>
<tr>
<td>URI p95</td>
<td><strong>↓ 68ms → 21ms</strong></td>
<td>상세 페이지 평균 응답 <strong>3.2배 향상</strong></td>
</tr>
</tbody></table>
<p><strong>StepWatch 로그 발췌</strong></p>
<pre><code>[PostService.updatePost]
  2.4.3 delete selected images          : 13 ms
  2.4.4 query remaining &amp; maybe update  : 6 ms
  2.4.5 publish cache event             : 1 ms
→ PostCacheInvalidationListener.evict(detail, images)
</code></pre><ul>
<li>캐시 무효화 → <strong>다음 조회 시 miss → put → 이후 hit</strong>으로 회복되는 순환이 <strong>로그로 확인</strong>됨</li>
</ul>
<hr>
<h2 id="3-5-트러블슈팅-요약">3-5) 트러블슈팅 요약</h2>
<table>
<thead>
<tr>
<th>문제</th>
<th>원인</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>수정 후 썸네일 갱신 지연</td>
<td>썸네일 판단이 캐시 경로 호출</td>
<td><strong>DB 직조회로 변경</strong></td>
</tr>
<tr>
<td><code>removals</code> 메트릭 0</td>
<td><code>@EventListener</code> 사용으로 커밋 후 보장 없음</td>
<td><strong><code>@TransactionalEventListener(AFTER_COMMIT)</code></strong>로 교체</td>
</tr>
<tr>
<td>miss만 계속 발생</td>
<td><code>@Cacheable</code> 키 불일치</td>
<td><strong><code>#postId</code>로 통일</strong></td>
</tr>
</tbody></table>
<p><strong>운영 대시보드 예시(Prometheus + Grafana)</strong></p>
<ul>
<li><code>cache_hit_rate{cache=&quot;post:detail&quot;}</code></li>
<li><code>cache_evictions_total{cache=&quot;post:detail&quot;}</code></li>
<li><code>http_server_requests_seconds_bucket{uri=&quot;/api/post/{postId}&quot;}</code> (p95, p99)</li>
</ul>
<blockquote>
<p>대시보드의 “Cache Suite” 행에 URI p95와 Hit Rate를 나란히 배치해 즉시성 변화를 시각적으로 검증.</p>
</blockquote>
<h3 id="요약">요약</h3>
<ul>
<li>PinUp의 <strong>L1 캐시 적용 후 DB read 부하 약 80% 감소</strong>, 상세 응답 <strong>3배 단축</strong></li>
<li>썸네일/이미지 일관성도 <strong>이벤트 기반 무효화</strong>로 안정 확보</li>
<li>향후 <strong>Redis Pub/Sub 기반 L2 확장</strong> 시에도 그대로 재사용 가능</li>
</ul>
<hr>
<h2 id="3-6spring-cachecaffeine-검증-시나리오-요약">3-6)Spring Cache(Caffeine) 검증 시나리오 요약</h2>
<h3 id="1-조회-기반-캐시-등록--캐시-히트-확인"><strong>1. 조회 기반 캐시 등록 + 캐시 히트 확인</strong></h3>
<h3 id="1-첫-조회--캐시-미스db-조회-발생">(1) 첫 조회 — <strong>캐시 미스(DB 조회 발생)</strong></h3>
<pre><code>2025-10-30T00:00:05.301+09:00 DEBUG 43764 --- [pinup] [nio-8080-exec-1]  k.c.p.posts.controller.PostController    : 게시글 상세 뷰 진입: postId=10026
2025-10-30T00:00:05.302+09:00 DEBUG 43764 --- [pinup] [nio-8080-exec-1]  k.c.p.posts.service.PostService          : 게시글 단건 요청: postId=10026, isDeleted=false
2025-10-30T00:00:05.309+09:00  INFO 43764 --- [pinup] [nio-8080-exec-1]  p6spy                                    : select p1_0.id,p1_0.title,p1_0.content from posts p1_0 where p1_0.id=10026 and p1_0.is_deleted=false
</code></pre><p>→ <strong>의미:</strong> 캐시에 데이터가 없어서 DB 접근이 발생함.</p>
<p>→ <strong>결과:</strong> @Cacheable 메서드 종료 시점에 <code>post:detail</code> 캐시에 저장됨.</p>
<hr>
<h3 id="2-두-번째-동일-조회--캐시-히트db-접근-없음">(2) 두 번째 동일 조회 — <strong>캐시 히트(DB 접근 없음)</strong></h3>
<pre><code>2025-10-30T00:00:12.653+09:00 DEBUG 43764 --- [pinup] [nio-8080-exec-2]  k.c.p.posts.controller.PostController    : 게시글 상세 뷰 진입: postId=10026
(이 시점에는 p6spy select 로그가 출력되지 않음)
</code></pre><p>→ <strong>의미:</strong> DB 쿼리가 발생하지 않음 → 캐시에서 즉시 반환됨.</p>
<p>→ <strong>결과:</strong> Caffeine 내부 hit 증가 (<code>hitCount++</code>).</p>
<hr>
<h3 id="2-수정-→-이벤트-기반-무효화-→-재조회-시-캐시-재적재"><strong>2. 수정 → 이벤트 기반 무효화 → 재조회 시 캐시 재적재</strong></h3>
<h3 id="1-게시글-수정-완료-→-after_commit-이벤트-발생">(1) 게시글 수정 완료 → AFTER_COMMIT 이벤트 발생</h3>
<pre><code>2025-10-30T00:00:14.636+09:00  INFO 43764 --- [pinup] [nio-8080-exec-9]  k.c.p.custom.logging.StructuredLogger    : {&quot;className&quot;:&quot;PostApiController&quot;,&quot;methodName&quot;:&quot;updatePost&quot;,&quot;targetId&quot;:&quot;10026&quot;,&quot;details&quot;:{&quot;kind&quot;:&quot;UPDATED&quot;},&quot;message&quot;:&quot;PostCacheEvent 수신&quot;}
2025-10-30T00:00:14.637+09:00  INFO 43764 --- [pinup] [nio-8080-exec-9]  k.c.p.custom.logging.StructuredLogger    : {&quot;className&quot;:&quot;PostCacheInvalidationListener&quot;,&quot;methodName&quot;:&quot;on&quot;,&quot;targetId&quot;:&quot;10026&quot;,&quot;details&quot;:{&quot;cache&quot;:&quot;post:detail&quot;},&quot;message&quot;:&quot;post:detail 캐시 무효화&quot;}
2025-10-30T00:00:14.637+09:00  INFO 43764 --- [pinup] [nio-8080-exec-9]  k.c.p.custom.logging.StructuredLogger    : {&quot;className&quot;:&quot;PostCacheInvalidationListener&quot;,&quot;methodName&quot;:&quot;on&quot;,&quot;targetId&quot;:&quot;10026&quot;,&quot;details&quot;:{&quot;cache&quot;:&quot;post:images&quot;},&quot;message&quot;:&quot;post:images 캐시 무효화&quot;}
</code></pre><p>→ <strong>의미:</strong> 트랜잭션 커밋 후 <code>@TransactionalEventListener</code>가 두 캐시를 모두 비움.</p>
<p>→ <strong>결과:</strong> <code>post:detail</code>, <code>post:images</code> 캐시 evict 성공.</p>
<hr>
<h3 id="2-수정-직후-상세-재조회--캐시-미스db-재조회-발생">(2) 수정 직후 상세 재조회 — <strong>캐시 미스(DB 재조회 발생)</strong></h3>
<pre><code>2025-10-30T00:00:15.203+09:00 DEBUG 43764 --- [pinup] [nio-8080-exec-10]  k.c.p.posts.controller.PostController    : 게시글 상세 뷰 진입: postId=10026
2025-10-30T00:00:15.203+09:00  INFO 43764 --- [pinup] [nio-8080-exec-10]  p6spy                                    : select p1_0.id,p1_0.title,p1_0.content from posts p1_0 where p1_0.id=10026 and p1_0.is_deleted=false
</code></pre><p>→ <strong>의미:</strong> 캐시 무효화 직후이므로 DB 재조회 발생.</p>
<p>→ <strong>결과:</strong> 조회 결과가 다시 <code>post:detail</code> 캐시에 저장됨 (재적재 완료).</p>
<hr>
<h3 id="3-그-다음-재조회--캐시-히트db-접근-없음">(3) 그 다음 재조회 — <strong>캐시 히트(DB 접근 없음)</strong></h3>
<pre><code>2025-10-30T00:00:22.412+09:00 DEBUG 43764 --- [pinup] [nio-8080-exec-11]  k.c.p.posts.controller.PostController    : 게시글 상세 뷰 진입: postId=10026
(이 시점에는 p6spy select 로그가 출력되지 않음)
</code></pre><p>→ <strong>의미:</strong> DB 접근 없이 캐시에서 직접 반환됨.</p>
<p>→ <strong>결과:</strong> 수정 이후에도 캐시 갱신 및 HIT 정상 작동 확인.</p>
<hr>
<h3 id="정리-요약"><strong>정리 요약</strong></h3>
<table>
<thead>
<tr>
<th>단계</th>
<th>구분</th>
<th>DB 쿼리(p6spy)</th>
<th>캐시 상태</th>
</tr>
</thead>
<tbody><tr>
<td>①</td>
<td>첫 조회</td>
<td>✅ 발생</td>
<td>MISS → put</td>
</tr>
<tr>
<td>②</td>
<td>두 번째 조회</td>
<td>❌ 없음</td>
<td>HIT</td>
</tr>
<tr>
<td>③</td>
<td>수정 후 재조회</td>
<td>✅ 발생</td>
<td>MISS_AFTER_INVALIDATION</td>
</tr>
<tr>
<td>④</td>
<td>최종 재조회</td>
<td>❌ 없음</td>
<td>HIT (정상 작동)</td>
</tr>
</tbody></table>
<hr>
<h1 id="4-마무리">4. 마무리</h1>
<ul>
<li><strong>설정은 yml, 동작은 CacheManager, 무효화는 이벤트 기반</strong></li>
<li><strong>L1(Local Cache)만으로도 충분히 실효성</strong> 있고, 나중에 <strong>Redis Pub/Sub만 붙이면 L2로 자연 확장</strong>된다</li>
<li><code>recordStats()</code> + Micrometer 기반 <strong>SLO 모니터링</strong>으로 hit/miss를 수치로 관리하며 캐시 효율을 안정적으로 추적하라</li>
</ul>
<hr>
<h1 id="부록-a-applicationeventpublisher와-이벤트-발행-구조">부록 A. ApplicationEventPublisher와 이벤트 발행 구조</h1>
<h2 id="a-1-applicationeventpublisher란">A-1. ApplicationEventPublisher란</h2>
<p>스프링 컨텍스트 내에서 이벤트를 발행하고, 등록된 리스너로 디스패치하는 컴포넌트.</p>
<p><strong>서비스와 캐시 무효화 로직을 분리</strong>해 <strong>롤백-안전, 확장성</strong>을 확보한다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class PostService {
    private final ApplicationEventPublisher events;

    @Transactional
    public void disable(Long postId) {
        postRepository.disable(postId);
        events.publishEvent(PostCacheEvent.disabled(postId)); // 커밋 후 리스너 실행
    }
}
</code></pre>
<h2 id="a-2-transactionaleventlistenerphase--after_commit">A-2. <code>@TransactionalEventListener(phase = AFTER_COMMIT)</code></h2>
<p><code>publishEvent()</code>는 즉시 발행되지만, <code>@TransactionalEventListener</code>는 <strong>트랜잭션 상태</strong>에 따라 실행 타이밍이 결정된다.</p>
<p><code>AFTER_COMMIT</code>은 트랜잭션 커밋이 완료된 후 실행 → <strong>롤백 시 캐시 무효화가 발생하지 않음</strong>.</p>
<h2 id="a-3-직접-evict-호출-대신-이벤트를-쓰는-이유">A-3. 직접 evict 호출 대신 이벤트를 쓰는 이유</h2>
<table>
<thead>
<tr>
<th>방식</th>
<th>장점</th>
<th>단점</th>
<th>적합 상황</th>
</tr>
</thead>
<tbody><tr>
<td>서비스 내부 직접 evict</td>
<td>단순</td>
<td>롤백 시 불일치, 강결합</td>
<td>단순 서비스</td>
</tr>
<tr>
<td>이벤트 + AFTER_COMMIT</td>
<td>롤백-안전, 확장성</td>
<td>구조 약간 복잡</td>
<td><strong>권장 구조</strong></td>
</tr>
<tr>
<td>AOP 후킹</td>
<td>침투성 낮음</td>
<td>트랜잭션 경계 제어 어려움</td>
<td>로깅 등</td>
</tr>
<tr>
<td><code>TxSynchronization</code> 직접 등록</td>
<td>세밀한 제어</td>
<td>가독성 저하</td>
<td>특수 상황</td>
</tr>
</tbody></table>
<h2 id="a-4-postcacheevent-설계-포인트">A-4. <code>PostCacheEvent</code> 설계 포인트</h2>
<pre><code class="language-java">public record PostCacheEvent(Long postId, Kind kind, boolean detailChanged, boolean imagesChanged) {
    public enum Kind { UPDATED, DISABLED, DELETED }
    public static PostCacheEvent updated(Long id, boolean detail, boolean images) { ... }
    public static PostCacheEvent disabled(Long id) { ... }
    public static PostCacheEvent deleted(Long id) { ... }
}
</code></pre>
<ul>
<li><strong>명시적 팩토리 메서드</strong>로 의도 표현</li>
<li><code>Kind</code>에 따라 <strong>detail/images 정밀 무효화</strong> 분기</li>
<li>이벤트 확장 시 <strong>구조가 흔들리지 않음</strong></li>
</ul>
<h2 id="a-5-테스트-및-운영-팁">A-5. 테스트 및 운영 팁</h2>
<ul>
<li><p><code>@TransactionalEventListener</code>는 테스트에서 <strong>커밋이 없으면 실행되지 않음</strong></p>
<p>  → 실제 커밋이 발생하는 <strong>통합 테스트</strong>나 <code>@Commit</code> 사용 필요</p>
</li>
<li><p>캐시 무효화 외의 <strong>부수 작업(알림, 감사 등)</strong>을 리스너로 확장 가능</p>
</li>
<li><p><strong>멀티 인스턴스</strong> 시에는 Redis Pub/Sub로 <strong>이벤트 전파</strong> 확장</p>
</li>
<li><p><code>cache_removals_total</code>과 <code>http_server_requests_seconds(p95)</code>를 <strong>한 행</strong>에 배치해 <strong>효과 모니터링</strong></p>
</li>
</ul>
<h2 id="a-6-핵심-정리">A-6. 핵심 정리</h2>
<p><strong>왜 ApplicationEventPublisher인가?</strong></p>
<ul>
<li>서비스는 <strong>“무엇이 바뀌었다”</strong>만 알리고, <strong>커밋 이후 무효화</strong>는 리스너가 담당</li>
<li><strong>트랜잭션 롤백-안전</strong>, <strong>관심사 분리</strong>, <strong>확장성</strong>, <strong>Redis Pub/Sub 확장 용이</strong></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[8-1] Spring Cache 개념·선택 배경과 운영 원칙 — Per-Cache 정책 & Strict]]></title>
            <link>https://velog.io/@minpractice_jhj/Spring-Boot-Cache-Caffeine</link>
            <guid>https://velog.io/@minpractice_jhj/Spring-Boot-Cache-Caffeine</guid>
            <pubDate>Thu, 30 Oct 2025 04:28:41 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-boot-cache-심화-caffeine-내부-구조-완전-정리">Spring Boot Cache 심화: Caffeine 내부 구조 완전 정리</h1>
<p><strong>— Spring Cache 추상화 원리부터 JVM 내부 구조까지</strong></p>
<blockquote>
<p>“왜 Caffeine인가?”</p>
<p>이 글은 Spring Cache 추상화의 기본 원리부터 Caffeine 캐시의 내부 동작까지, <strong>JVM 수준에서 캐시가 어떻게 작동하는지</strong>를 완전히 해부합니다.</p>
<p>2편에서는 이 개념을 기반으로 <strong>PinUp 서비스에 실제 적용한 설계 구조</strong>를 다룹니다.</p>
</blockquote>
<hr>
<h2 id="1-spring-cache-추상화-기본-구조">1. Spring Cache 추상화 기본 구조</h2>
<h3 id="1-1-cache-abstraction-개요">1-1. Cache Abstraction 개요</h3>
<p><strong>핵심 개념:</strong> <code>Cache Abstraction</code>, <code>@Cacheable</code>, <code>CacheManager</code>, <code>AOP Proxy</code>, <code>CacheInterceptor</code></p>
<p>Spring Cache는 다양한 캐시 엔진(Caffeine, Ehcache, Redis 등)을 <strong>공통 인터페이스</strong>로 감싸고,</p>
<p>AOP 프록시를 통해 메서드 호출을 가로채 캐싱 로직을 적용합니다.</p>
<p><strong>동작 흐름</strong>
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/2e0b7708-cd27-43f9-85e2-a753747f1958/image.png" alt=""></p>
<p>`</p>
<blockquote>
<p>⚠️ 주의: 같은 클래스 내에서 자기 자신 메서드를 호출(self-invocation)하면 프록시를 우회해 캐시가 적용되지 않습니다.</p>
<p>구조 분리 또는 인터페이스 추출을 권장합니다.</p>
</blockquote>
<hr>
<h3 id="1-2-cachemanager-↔-cache-구현체-연결-구조">1-2. CacheManager ↔ Cache 구현체 연결 구조</h3>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong>@Cacheable</strong></td>
<td>프록시가 메서드 호출을 가로채 캐시 조회/저장 여부 결정</td>
</tr>
<tr>
<td><strong>CacheInterceptor</strong></td>
<td>캐시 hit/miss 판단 및 put/evict 처리 로직</td>
</tr>
<tr>
<td><strong>CacheManager</strong></td>
<td>캐시 이름으로 Cache 인스턴스 조회/생성</td>
</tr>
<tr>
<td><strong>Cache 구현체</strong></td>
<td>실제 저장소 (CaffeineCache, EhcacheCache, RedisCache 등)</td>
</tr>
</tbody></table>
<pre><code class="language-java">@Configuration
@EnableCaching
public class CacheConfig {
  @Bean
  public CacheManager cacheManager() {
    var cm = new CaffeineCacheManager(&quot;posts&quot;, &quot;users&quot;);
    cm.setCaffeine(com.github.benmanes.caffeine.cache.Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(java.time.Duration.ofMinutes(10))
        .recordStats());
    return cm;
  }
}
</code></pre>
<hr>
<h3 id="1-3-spring-boot-자동-구성-원리">1-3. Spring Boot 자동 구성 원리</h3>
<p>Spring Boot는 클래스패스에 존재하는 라이브러리를 자동 감지해 적절한 <code>CacheManager</code>를 구성합니다.</p>
<p><strong>우선순위 예시</strong></p>
<pre><code>JCache → Ehcache → Hazelcast → Infinispan → Couchbase → Redis → Caffeine → Simple
</code></pre><ul>
<li>별도의 라이브러리가 없으면 기본적으로 <code>SimpleCacheManager(ConcurrentMap 기반)</code> 사용</li>
<li>단, TTL/Size 제한 없음 → 운영 환경에는 부적합</li>
</ul>
<p>cach 관련해서 yml 을 작성이랑 어떻게 작동하는지 이런것에대해서 적는게 어떠할까???</p>
<hr>
<h2 id="2-local-vs-global-cache">2. Local vs Global Cache</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>Local Cache</th>
<th>Global(Distributed) Cache</th>
</tr>
</thead>
<tbody><tr>
<td><strong>위치</strong></td>
<td>각 JVM 내부 (온-힙/오프힙)</td>
<td>외부 캐시 서버 (Redis, Hazelcast 등)</td>
</tr>
<tr>
<td><strong>지연</strong></td>
<td>네트워크 hop 없음 → 초저지연</td>
<td>네트워크 왕복 존재</td>
</tr>
<tr>
<td><strong>일관성</strong></td>
<td>인스턴스 간 불일치 가능 (무효화 전략 필요)</td>
<td>공유 일관성 및 전파 용이</td>
</tr>
<tr>
<td><strong>운영성</strong></td>
<td>단순 (내부 메모리 관리 중심)</td>
<td>인프라/장애 대응 복잡</td>
</tr>
<tr>
<td><strong>확장성</strong></td>
<td>인스턴스별 중복 저장</td>
<td>클러스터 확장 용이</td>
</tr>
</tbody></table>
<blockquote>
<p>Local: 초저지연·단순 운용이 강점이나 다노드 일관성이 과제</p>
<p>Global: 일관성·공유성은 뛰어나지만 네트워크 비용·운영 복잡성 존재</p>
</blockquote>
<hr>
<h2 id="3-java-캐시-엔진-비교">3. Java 캐시 엔진 비교</h2>
<table>
<thead>
<tr>
<th>엔진</th>
<th>핵심 특징</th>
<th>장점</th>
<th>한계/비고</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Caffeine</strong></td>
<td>순수 Java, W-TinyLFU</td>
<td>고성능, TTL/size/통계 지원</td>
<td>In-memory 전용</td>
</tr>
<tr>
<td><strong>Ehcache 3</strong></td>
<td>Heap + Off-heap + Disk 티어링</td>
<td>대용량·지속성</td>
<td>설정 복잡, 과스펙 가능</td>
</tr>
<tr>
<td><strong>Guava Cache</strong></td>
<td>LRU 중심 간단 구조</td>
<td>가벼움</td>
<td>기능 제한, 유지보수 축소</td>
</tr>
<tr>
<td><strong>Redis</strong></td>
<td>분산/공유</td>
<td>TTL, Pub/Sub, 클러스터</td>
<td>네트워크 비용</td>
</tr>
<tr>
<td><strong>Memcached</strong></td>
<td>단순 K-V 구조</td>
<td>초고속 읽기</td>
<td>TTL 외 기능 부족</td>
</tr>
</tbody></table>
<blockquote>
<p>오프힙/디스크 티어링이 필요하면 Ehcache,</p>
<p>단일 JVM 초저지연이면 <strong>Caffeine</strong>이 가장 효율적입니다.</p>
</blockquote>
<hr>
<h2 id="4-caffeine-vs-ehcache-3-심층-비교">4. Caffeine vs Ehcache 3 심층 비교</h2>
<table>
<thead>
<tr>
<th>관점</th>
<th>Caffeine</th>
<th>Ehcache 3</th>
</tr>
</thead>
<tbody><tr>
<td><strong>성능/지연</strong></td>
<td>매우 낮음 (온-힙)</td>
<td>티어링 시 지연 증가</td>
</tr>
<tr>
<td><strong>용량/티어링</strong></td>
<td>온-힙 중심</td>
<td>Heap + Off-heap + Disk</td>
</tr>
<tr>
<td><strong>알고리즘</strong></td>
<td>W-TinyLFU</td>
<td>다양한 정책</td>
</tr>
<tr>
<td><strong>분산/클러스터</strong></td>
<td>없음</td>
<td>가능 (별도 구성)</td>
</tr>
<tr>
<td><strong>운영 복잡도</strong></td>
<td>낮음</td>
<td>높음</td>
</tr>
<tr>
<td><strong>적합성</strong></td>
<td>단일/소수 노드, 읽기 중심</td>
<td>대용량·클러스터 환경</td>
</tr>
</tbody></table>
<blockquote>
<p>“노드 1~N의 초저지연, 단기 TTL 중심” → Caffeine</p>
<p>“대용량/지속성/클러스터 필요” → <strong>Ehcache 3</strong></p>
</blockquote>
<hr>
<h2 id="5-caffeine-내부-구조-및-jvm-동작-원리">5. Caffeine 내부 구조 및 JVM 동작 원리</h2>
<h3 id="5-1-caffeine이-빠른-이유">5-1. Caffeine이 빠른 이유</h3>
<p>Caffeine은 <strong>JVM 내부에서 직접 작동하는 고성능 인메모리 캐시</strong>입니다.</p>
<p>직렬화나 네트워크 hop 없이 Java 객체를 그대로 힙 메모리에 유지합니다.</p>
<pre><code class="language-java">Cache&lt;String, User&gt; cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .weakKeys()
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .build();
</code></pre>
<p><strong>특징 요약</strong></p>
<ul>
<li>On-Heap 구조 → 네트워크·직렬화 비용 없음</li>
<li>나노초 단위 접근 → JVM 메모리 직접 접근</li>
<li>GC 친화적 관리 → weakKeys(), softValues()</li>
<li>명시적 크기 제한으로 OOM 방지</li>
</ul>
<hr>
<h3 id="5-2-내부-구조-개요">5-2. 내부 구조 개요</h3>
<p>Caffeine의 핵심 클래스는 <code>BoundedLocalCache</code>이며,</p>
<p>내부적으로 <strong>ConcurrentHashMap + TTL + Eviction + Concurrency 계층</strong>으로 구성됩니다.</p>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/ac698f86-3dbe-4db5-aefc-e6b8e79fe372/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>CacheBuilder</td>
<td>캐시 설정(크기, 만료, 동시성 등) 정의</td>
</tr>
<tr>
<td>BoundedLocalCache</td>
<td>캐시 로직 구현부</td>
</tr>
<tr>
<td>Node</td>
<td>Key-Value + 접근/작성 시각 등 메타데이터</td>
</tr>
<tr>
<td>Eviction Queue</td>
<td>오래된 항목 관리</td>
</tr>
<tr>
<td>Stats Counter</td>
<td>hit/miss 통계 누적</td>
</tr>
</tbody></table>
<hr>
<h3 id="5-3-cache-aside-패턴">5-3. Cache-Aside 패턴</h3>
<p>Caffeine은 <code>cache.get(key, mappingFunction)</code>으로 Cache-Aside 패턴을 지원합니다.</p>
<p>Spring의 <code>@Cacheable</code> 내부 동작과 동일합니다.</p>
<pre><code class="language-java">User user = cache.get(userId, id -&gt; repository.findById(id));
</code></pre>
<ol>
<li>캐시 miss → DB 조회</li>
<li>DB 결과를 캐시에 저장</li>
<li>이후 동일 key 요청 → 캐시 hit</li>
</ol>
<hr>
<h3 id="5-4-ttl--eviction--통계-관리">5-4. TTL / Eviction / 통계 관리</h3>
<p>Caffeine은 <strong>W-TinyLFU(Window TinyLFU)</strong> 알고리즘을 사용합니다.</p>
<p>최근성(Recency)과 사용 빈도(Frequency)를 결합하여 높은 적중률을 제공합니다.</p>
<pre><code class="language-java">Cache&lt;String, User&gt; cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .recordStats()
    .build();

CacheStats stats = cache.stats();
System.out.printf(&quot;HitRate=%.2f, MissRate=%.2f%n&quot;, stats.hitRate(), stats.missRate());
</code></pre>
<blockquote>
<p>TTL과 Eviction이 핵심: 오래된 데이터는 자동 만료되고, 자주 사용된 데이터는 오래 유지됩니다.</p>
</blockquote>
<hr>
<h3 id="5-5-lock-free-동시성-구조">5-5. Lock-Free 동시성 구조</h3>
<p>Caffeine은 <strong>CAS(Compare-And-Swap) + LongAdder 기반의 락-프리 구조</strong>입니다.</p>
<ul>
<li><strong>CAS 기반 갱신:</strong> 락 대신 원자적 비교·갱신</li>
<li><strong>LongAdder:</strong> 다중 스레드 환경에서도 contention-free 통계 집계</li>
<li><strong>비동기 Eviction:</strong> 쓰기 경로 방해 없이 제거 처리</li>
</ul>
<hr>
<h3 id="5-6-gc-협력-방식">5-6. GC 협력 방식</h3>
<table>
<thead>
<tr>
<th>설정</th>
<th>효과</th>
</tr>
</thead>
<tbody><tr>
<td><code>weakKeys()</code></td>
<td>참조되지 않은 키는 GC 시 자동 제거</td>
</tr>
<tr>
<td><code>softValues()</code></td>
<td>메모리 부족 시 Value 우선 제거</td>
</tr>
<tr>
<td><code>maximumSize()</code></td>
<td>자체 Eviction으로 OOM 방지</td>
</tr>
</tbody></table>
<blockquote>
<p>크기 제한을 지정하지 않으면 GC pause 증가 및 OOM 위험이 있습니다.</p>
</blockquote>
<hr>
<h3 id="5-7-w-tinylfu-알고리즘-구조">5-7. W-TinyLFU 알고리즘 구조</h3>
<p>단계별 흐름</p>
<ol>
<li>새 항목은 Window(LRU)에 임시 저장</li>
<li>용량 초과 시 기존 항목과 접근 빈도 비교</li>
<li>더 자주 사용된 항목을 유지</li>
<li>hit 발생 시 Probation → Protected 구역 승격</li>
<li>TTL 만료 항목은 TimeWheel로 제거</li>
</ol>
<blockquote>
<p>최근성 + 빈도 기반의 하이브리드 구조로 높은 hit rate 달성.</p>
</blockquote>
<hr>
<h3 id="5-8-jvm-내부-구조-요약">5-8. JVM 내부 구조 요약</h3>
<pre><code>┌───────────────────────────────────────────┐
│              JVM Process                  │
│   ┌────────────────────────────────────┐  │
│   │ Caffeine On-Heap Cache (Bounded)   │  │
│   │  - ConcurrentHashMap 기반 Store    │  │
│   │  - Eviction(W-TinyLFU)             │  │
│   │  - TTL / Weak &amp; Soft Reference     │  │
│   │  - Lock-free Concurrency (CAS)     │  │
│   │  - LongAdder 기반 통계 집계        │  │
│   └────────────────────────────────────┘  │
└───────────────────────────────────────────┘
</code></pre><hr>
<h2 id="6-cache-설정applicationyml-구성과-작동-원리">6. Cache 설정(application.yml) 구성과 작동 원리</h2>
<p>Spring Boot에서 캐시는 크게 두 계층으로 설정할 수 있다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>목적</th>
<th>관리 주체</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Spring 기본 설정층</strong></td>
<td>CacheManager 생성 및 전역 엔진 제어</td>
<td>Spring Boot</td>
</tr>
<tr>
<td><strong>사용자 확장 설정층</strong></td>
<td>캐시별 TTL·Size 정책 관리</td>
<td>서비스 애플리케이션</td>
</tr>
</tbody></table>
<hr>
<h3 id="6-1-spring-기본-설정층-springcache">6-1. Spring 기본 설정층 (<code>spring.cache.*</code>)</h3>
<p>Spring Boot는 <code>spring.cache</code> 네임스페이스를 통해 CacheManager의 전역 동작을 구성한다.</p>
<pre><code class="language-yaml">spring:
  cache:
    type: caffeine
    cache-names: 명칭, 명칭
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m,recordStats
</code></pre>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>type</strong></td>
<td>사용할 캐시 엔진 지정 (caffeine, redis, ehcache 등)</td>
</tr>
<tr>
<td><strong>cache-names</strong></td>
<td>초기화할 캐시 이름 목록</td>
</tr>
<tr>
<td><strong>caffeine.spec</strong></td>
<td>전역 정책 (<code>maximumSize</code>, <code>expireAfterWrite</code>, <code>recordStats</code> 등)</td>
</tr>
</tbody></table>
<p>이 구조는 <strong>모든 캐시가 동일한 정책으로 동작할 때</strong> 간단하게 사용할 수 있다.</p>
<p>그러나 캐시별로 TTL이나 Size를 세분화해야 하는 경우 한계가 있다.</p>
<hr>
<h3 id="6-2-사용자-확장-설정층-springcacheapp">6-2. 사용자 확장 설정층 (<code>spring.cache.app.*</code>)</h3>
<p>도메인별 TTL과 용량 정책이 달라야 할 경우,</p>
<p>Spring의 기본 설정 위에 <strong>타입 안전한 확장 레이어</strong>를 추가한다.</p>
<pre><code class="language-yaml">spring:
  cache:
    type: caffeine
    strict: true
    app:
      defaults: { maximumSize: 40000, ttlSec: 300 }
      caches:
        &quot;명칭&quot;: { maximumSize: 20000, ttlSec: 300 }
        &quot;명칭&quot;: { maximumSize: 20000, ttlSec: 1800 }
</code></pre>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>strict</strong></td>
<td>선언되지 않은 캐시 접근 차단. 오타나 누락으로 인한 불일치 방지</td>
</tr>
<tr>
<td><strong>app.defaults</strong></td>
<td>모든 캐시에 공통 적용되는 기본 정책 (예: TTL 5분, Size 4만)</td>
</tr>
<tr>
<td><strong>app.caches</strong></td>
<td>캐시별 세부 정책 (예: 상세·이미지 캐시 TTL 분리)</td>
</tr>
<tr>
<td><strong>ttlSec / maximumSize</strong></td>
<td>캐시 만료 시간(초) 및 최대 항목 수 지정</td>
</tr>
</tbody></table>
<p>이 구조는 <code>defaults</code>를 기본값으로 두고, <code>caches</code>로 각 캐시의 오버라이드 정책을 적용하는 방식이다.</p>
<p>실제 코드에서는 <code>AppCacheProps</code>를 통해 바인딩되고 <code>CacheManager</code>에서 per-cache로 주입된다.</p>
<p>(해당 구현은 2편에서 다룬다.)</p>
<hr>
<h3 id="6-3-주요-항목-정리">6-3. 주요 항목 정리</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>구분</th>
<th>필요성</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>type</strong></td>
<td>Spring 기본</td>
<td>필수</td>
<td>사용할 캐시 엔진 지정</td>
</tr>
<tr>
<td><strong>cache-names</strong></td>
<td>Spring 기본</td>
<td>선택</td>
<td>사전 초기화 캐시 이름</td>
</tr>
<tr>
<td><strong>caffeine.spec</strong></td>
<td>Spring 기본</td>
<td>선택</td>
<td>전역 정책 (<code>maximumSize</code>, <code>expireAfterWrite</code>)</td>
</tr>
<tr>
<td><strong>strict</strong></td>
<td>확장</td>
<td>추천</td>
<td>선언되지 않은 캐시 접근 방지</td>
</tr>
<tr>
<td><strong>app.defaults</strong></td>
<td>확장</td>
<td>필수</td>
<td>공통 TTL/Size 정책</td>
</tr>
<tr>
<td><strong>app.caches</strong></td>
<td>확장</td>
<td>필수</td>
<td>캐시별 오버라이드 정책</td>
</tr>
<tr>
<td><strong>ttlSec / maximumSize</strong></td>
<td>확장</td>
<td>필수</td>
<td>캐시 만료·용량 핵심 설정</td>
</tr>
<tr>
<td><strong>recordStats</strong></td>
<td>확장</td>
<td>권장</td>
<td>hit/miss/eviction 통계 활성화</td>
</tr>
</tbody></table>
<p>이 항목들만으로도 대부분의 운영 환경에서 완전한 캐시 구성을 구현할 수 있다.</p>
<hr>
<h3 id="6-4-선택적-확장-옵션">6-4. 선택적 확장 옵션</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>expireAfterAccessSec</strong></td>
<td>마지막 접근 이후 일정 시간 경과 시 만료 (LRU 패턴)</td>
</tr>
<tr>
<td><strong>maximumWeight</strong> + <strong>weigherClass</strong></td>
<td>항목별 가중치 기반 용량 제어</td>
</tr>
<tr>
<td><strong>allowNullValues</strong></td>
<td>null 캐싱 여부 제어 (false 권장)</td>
</tr>
<tr>
<td><strong>cacheErrorHandler</strong></td>
<td>캐시 예외 발생 시 fallback 처리</td>
</tr>
<tr>
<td><strong>sync</strong></td>
<td>동일 key 동시 미스 시 single-flight 처리</td>
</tr>
<tr>
<td><strong>Micrometer Export</strong></td>
<td>Prometheus/Grafana로 메트릭 노출 가능</td>
</tr>
</tbody></table>
<hr>
<h3 id="6-5-요약">6-5. 요약</h3>
<ul>
<li><code>spring.cache</code>는 CacheManager의 기본 엔진을 구성한다.</li>
<li><code>spring.cache.app</code>은 캐시별 TTL·Size 정책을 확장 관리한다.</li>
<li><code>defaults</code>와 <code>caches</code> 구조로 전역·개별 정책을 병행할 수 있다.</li>
<li>핵심 8개 항목만으로도 실무 환경에서 완전한 캐시 구성이 가능하다.</li>
</ul>
<blockquote>
<p>2편에서는 이 설정이 실제 코드(AppCacheProps, CacheManager)로 어떻게 연결되는지,</p>
<p>그리고 왜 전역 <code>spec</code> 대신 per-cache 구조를 선택했는지를 다룬다.</p>
</blockquote>
<hr>
<h2 id="7-결론">7. 결론</h2>
<p>Caffeine은 JVM 내부에서 작동하는 <strong>경량·고성능 로컬 캐시</strong>입니다.</p>
<p>ConcurrentMap 기반 구조 위에 TTL, Eviction, Lock-Free, 통계 계층을 결합해</p>
<p><strong>“JVM 친화적 캐싱 엔진”</strong>을 완성했습니다.</p>
<p><strong>특징 요약</strong></p>
<ul>
<li>네트워크 hop 없음</li>
<li>GC 협력(weakKeys / softValues)</li>
<li>락 없는 동시성 구조</li>
<li>TTL 및 Eviction 자동 관리</li>
<li>Micrometer 통합으로 운영 모니터링 가능</li>
</ul>
<blockquote>
<p>Caffeine = JVM 친화적 + Lock-Free + 고성능 캐시.</p>
<p>단일 인스턴스 환경에서는 Redis보다 빠를 수 있으며,</p>
<p>내부 동작을 이해하면 캐시의 성능과 안정성을 동시에 확보할 수 있습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[7-7] [ExponentialRandomBackOffPolicy 적용 및 내부 구조 분석]]]></title>
            <link>https://velog.io/@minpractice_jhj/ExponentialRandomBackOffPolicy-%EC%A0%81%EC%9A%A9-%EB%B0%8F-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0-%EB%B6%84%EC%84%9D</link>
            <guid>https://velog.io/@minpractice_jhj/ExponentialRandomBackOffPolicy-%EC%A0%81%EC%9A%A9-%EB%B0%8F-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0-%EB%B6%84%EC%84%9D</guid>
            <pubDate>Tue, 14 Oct 2025 15:28:53 GMT</pubDate>
            <description><![CDATA[<h2 id="1-테스트-배경">1. 테스트 배경</h2>
<p>이전 단계에서 <code>ExponentialBackOffPolicy</code>를 적용한 결과,</p>
<p>VU 200 이상 구간에서 성공률이 급격히 하락하며 최대 응답 시간이 17초를 초과하는 현상이 나타났다.</p>
<p>초기 설정은 다음과 같았다.</p>
<ul>
<li>initialInterval: 200~300ms</li>
<li>multiplier: 1.5~2.0</li>
<li>maxAttempts: 8~10회</li>
</ul>
<p>테스트 결과, 100명 이하에서는 안정적이었으나
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/44d6b696-e7bb-40c2-95e0-9b0f573ffc5c/image.png" alt=""></p>
<blockquote>
<p>시각 요소 설명:
파란 곡선들 → 각기 다른 요청의 재시도 시점
x축(Time) / y축(Request 수)
빨간 “Lock Contention” 구간 → 재시도 타이밍이 겹치며 병목 발생
의미: “단순 지수 증가(backoff)는 있지만, 타이밍 분산이 없다 → 경합 집중”</p>
</blockquote>
<p>재시도 타이밍이 동일하게 맞물리며 <strong>락 경합이 집중되는 문제</strong>가 발생했다.</p>
<p>락이 해제된 시점에 모든 요청이 동시에 재시도되면서</p>
<p>성공률은 80%대에 머물렀고 응답 시간은 급격히 증가했다.</p>
<p>이는 단순히 “대기 간격의 길이”보다 “재시도 타이밍의 일치”가 병목의 근본 원인임을 보여준다.</p>
<hr>
<h2 id="2-테스트-환경">2. 테스트 환경</h2>
<h3 id="21-서버-및-db-설정">2.1 서버 및 DB 설정</h3>
<h3 id="applicationyaml">application.yaml</h3>
<pre><code class="language-yaml">server:
  tomcat:
    max-threads: 1000
    accept-count: 3000

spring:
  datasource:
    hikari:
      maximum-pool-size: 400
      minimum-idle: 100
      idle-timeout: 30000
      connection-timeout: 15000
</code></pre>
<h3 id="postgresqlconf">postgresql.conf</h3>
<pre><code>max_connections = 300
</code></pre><p>이 설정을 통해 Tomcat 스레드 수와 DB 커넥션 풀 크기를 확대하여</p>
<p>100~200명 수준의 동시 요청에서도 병목 없이 안정적인 처리가 가능하도록 구성했다.</p>
<p>로그인, 인증, 데이터베이스 락 대기 등의 구간에서 병목이 완화되었으며,</p>
<p>이는 재시도 정책 실험의 전제 조건이 되었다.</p>
<hr>
<h2 id="3-exponentialbackoffpolicy-결과-요약">3. ExponentialBackOffPolicy 결과 요약</h2>
<h3 id="주요-현상">주요 현상</h3>
<ul>
<li>100명(VU) 이하에서는 성공률 98~100% 유지</li>
<li>200명 이상부터 성공률 급감, 최대 응답 시간 17초 이상</li>
<li>재시도 타이밍이 동일하게 겹치며 락 경합 집중</li>
</ul>
<h3 id="주요-결과">주요 결과</h3>
<table>
<thead>
<tr>
<th>설정명</th>
<th>Script</th>
<th>VU</th>
<th>Retry</th>
<th>Mult</th>
<th>성공률</th>
<th>실패율</th>
<th>평균응답</th>
<th>최대응답</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>like-test2</td>
<td>100</td>
<td>8</td>
<td>1.5</td>
<td>50%</td>
<td>25.0%</td>
<td>2.29s</td>
<td>6.84s</td>
</tr>
<tr>
<td>B</td>
<td>like-test2</td>
<td>200</td>
<td>8</td>
<td>1.5</td>
<td>78.5%</td>
<td>10.7%</td>
<td>2.58s</td>
<td>8.62s</td>
</tr>
<tr>
<td>C</td>
<td>like-test2</td>
<td>100</td>
<td>9</td>
<td>1.8</td>
<td>60%</td>
<td>20.0%</td>
<td>4.73s</td>
<td>14.12s</td>
</tr>
<tr>
<td>D</td>
<td>like-test2</td>
<td>200</td>
<td>9</td>
<td>1.8</td>
<td>83.5%</td>
<td>8.2%</td>
<td>2.15s</td>
<td>12.41s</td>
</tr>
<tr>
<td>E</td>
<td>like-test2</td>
<td>100</td>
<td>10</td>
<td>2.0</td>
<td>56%</td>
<td>22.0%</td>
<td>6.85s</td>
<td>19.13s</td>
</tr>
<tr>
<td>F</td>
<td>like-test2</td>
<td>200</td>
<td>10</td>
<td>2.0</td>
<td>80.5%</td>
<td>9.7%</td>
<td>3.14s</td>
<td>17.58s</td>
</tr>
</tbody></table>
<h3 id="요약">요약</h3>
<p>VU 200 이상에서 <strong>락 재경합으로 인한 실패율 상승</strong>이 두드러졌다.</p>
<p>재시도 정책이 동일하게 동작하여 재시도 타이밍이 집중되며,</p>
<p>최대 응답 시간은 17초 이상으로 지연이 급격히 증가했다.</p>
<p>결국 <code>ExponentialBackOffPolicy</code>는 “대기 간격의 증가”는 제공하지만</p>
<p>“재시도 시점의 분산”은 보장하지 않는다.</p>
<hr>
<h2 id="4-exponentialrandombackoffpolicy-적용">4. ExponentialRandomBackOffPolicy 적용</h2>
<h2 id="41-정책-변경-의도">4.1 정책 변경 의도</h2>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/5853bb75-6f7d-49aa-98a0-8a5ef6b9f981/image.png" alt=""></p>
<blockquote>
<p>시각 요소 설명:
x축(Time) / y축(BackOff Interval)
각 점 → 재시도 타이밍
“Random Jitter” 표기된 화살표 → 각 요청마다 약간의 지연이 무작위로 추가됨
의미: “같은 지수 증가지만, 재시도 시점이 어긋나 충돌 분산됨”</p>
</blockquote>
<p><code>ExponentialBackOffPolicy</code>는 재시도 시마다 간격을 지수적으로 늘려 충돌 빈도를 완화하지만,</p>
<p><strong>모든 트랜잭션이 동일한 타이밍으로 재시도하는 문제</strong>를 근본적으로 해결하지는 못한다.</p>
<p>이에 따라 고정된 지수 간격 대신, <strong>무작위성(Random Jitter)</strong>을 포함한</p>
<p><code>ExponentialRandomBackOffPolicy</code>를 적용하였다.</p>
<p>이 정책의 목표는 다음과 같다:</p>
<ul>
<li>동일한 간격의 재시도가 동시에 발생하지 않도록 <strong>타이밍 분산</strong></li>
<li>락 해제 직후 요청 집중을 완화</li>
<li>대규모 동시성 환경에서도 <strong>성공률·응답 시간 안정화</strong></li>
</ul>
<hr>
<h2 id="42-원리">4.2 원리</h2>
<p><code>ExponentialRandomBackOffPolicy</code>는 <code>ExponentialBackOffPolicy</code>를 확장한 구현이다.</p>
<p>각 재시도 간격(<code>nextInterval</code>)을 계산할 때 무작위(Random Jitter) 값을 더해</p>
<p>스레드 간 타이밍이 미세하게 달라지도록 설계되어 있다.</p>
<pre><code>nextInterval = currentInterval * (1 + random[0,1))
</code></pre><p>이로써 재시도 타이밍이 서로 어긋나며,</p>
<p>락 경합이 집중되는 현상을 방지할 수 있다.</p>
<p>이후 내부 동작 구조(<code>RetryTemplate</code>의 Context, BackOff 계산, Callback 루프)는</p>
<p>아래 <strong>내부 구조 분석(4.3)</strong> 에서 상세히 다룬다.</p>
<hr>
<h2 id="43-내부-동작-분석--backoffpolicy와-retrytemplate-구조">4.3 내부 동작 분석 — BackOffPolicy와 RetryTemplate 구조</h2>
<h3 id="1-개념-요약">(1) 개념 요약</h3>
<p>Spring Retry의 핵심은 <strong>특정 대상을 식별해 재시도하는 것이 아니라</strong>,</p>
<p><code>execute()</code> 단위로 실행 세션(<code>RetryContext</code>)을 생성하고</p>
<p>그 세션 안에서 동일한 Callback을 반복 실행하는 구조다.</p>
<p>즉,</p>
<blockquote>
<p>“재시도 대상은 특정 엔티티나 ID가 아니라 RetryContext 자체이며,</p>
<p><code>RetryTemplate</code>은 이 Context에 연결된 Callback을 반복 실행한다.”</p>
</blockquote>
<hr>
<h3 id="2-exponentialrandombackoffpolicy의-동작">(2) ExponentialRandomBackOffPolicy의 동작</h3>
<p>해당 정책은 기본적으로 ExponentialBackOff 구조를 유지하면서</p>
<p><strong>각 재시도 간격에 무작위(Random Jitter)</strong>를 추가한다.</p>
<pre><code class="language-java">@Override
public void backOff(BackOffContext context) {
    ExponentialRandomBackOffContext c = (ExponentialRandomBackOffContext) context;
    long next = (long) (c.getCurrentInterval() * (1 + Math.random()));
    Thread.sleep(Math.min(next, maxInterval));
    c.setCurrentInterval(next * multiplier);
}
</code></pre>
<p>이 메서드는 <code>RetryTemplate</code>의 내부 루프에서 호출된다.</p>
<p>즉, <code>RetryPolicy</code>가 “재시도 가능”이라고 판단할 때마다</p>
<p><code>backOff()</code>가 호출되어 대기 시간과 Context 상태를 함께 갱신한다.</p>
<p>이는 단순히 “지연시간을 늘리는 것”이 아니라</p>
<p>Context에 저장된 상태(<code>currentInterval</code>, <code>multiplier</code>)를 기반으로</p>
<p><strong>동적으로 대기 시간을 계산·조정</strong>하는 방식이다.</p>
<hr>
<h3 id="3-retrytemplate-내부-구조-요약">(3) RetryTemplate 내부 구조 요약</h3>
<pre><code>┌──────────────────────────────────────────────────────────┐
│                      RetryTemplate                      │
│──────────────────────────────────────────────────────────│
│ ① RetryCallback (람다 함수) → 비즈니스 로직 실행        │
│ ② RetryContext (세션 상태 저장)                         │
│ ③ RetryPolicy (재시도 여부 판단)                        │
│ ④ BackOffPolicy (대기 시간 계산, Random Jitter 적용)    │
│ ⑤ Callback 재실행 (Context 기반 루프)                   │
└──────────────────────────────────────────────────────────┘
</code></pre><p>각 구성 요소는 독립적인 책임을 가지며,</p>
<p><code>RetryTemplate</code>이 전체 제어 흐름을 관리한다.</p>
<hr>
<h3 id="4-실행-흐름-pseudo-code">(4) 실행 흐름 (Pseudo-code)</h3>
<pre><code class="language-java">RetryContext context = retryPolicy.open(null); // 세션 시작

while (retryPolicy.canRetry(context)) {
    try {
        return retryCallback.doWithRetry(context);  // 비즈니스 로직 실행
    } catch (Exception e) {
        context.registerThrowable(e);               // 예외 기록
        backOffPolicy.backOff(context.getBackOffContext()); // 대기 후 재시도
    }
}

retryPolicy.close(context); // 세션 종료
</code></pre>
<p>이 루프 내에서:</p>
<ul>
<li><p><strong>RetryContext</strong>: 재시도 세션의 상태를 저장</p>
</li>
<li><p><strong>BackOffPolicy</strong>: 다음 재시도까지의 대기 시간을 계산</p>
</li>
<li><p><strong>execute() 호출 = 하나의 재시도 세션</strong></p>
<p>  → 이 Context 단위로 상태가 누적·관리된다.</p>
</li>
</ul>
<hr>
<h3 id="5-주요-구성-요소-역할">(5) 주요 구성 요소 역할</h3>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/cfa15ccf-2099-47ff-8c68-59473eeb0af1/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
<th>주요 책임</th>
</tr>
</thead>
<tbody><tr>
<td><strong>RetryCallback</strong></td>
<td>재시도할 함수(람다)</td>
<td>비즈니스 로직 실행</td>
</tr>
<tr>
<td><strong>RetryContext</strong></td>
<td>재시도 세션의 상태 저장소</td>
<td>재시도 횟수·예외·BackOff 상태 관리</td>
</tr>
<tr>
<td><strong>RetryPolicy</strong></td>
<td>재시도 가능 여부 판단</td>
<td>실패 횟수·예외 유형 기반 결정</td>
</tr>
<tr>
<td><strong>BackOffPolicy</strong></td>
<td>대기 간격 계산기</td>
<td>Exponential + Random Jitter 기반 분산 제어</td>
</tr>
<tr>
<td><strong>RetryTemplate</strong></td>
<td>제어 루프</td>
<td>Context 단위로 동일 Callback 반복 실행</td>
</tr>
</tbody></table>
<hr>
<h2 id="6-retrytemplate의-재시도-단위와-context-구조">(6) RetryTemplate의 재시도 단위와 Context 구조</h2>
<p>Spring Retry는 재시도 대상을 <strong>객체나 ID로 구분하지 않는다.</strong></p>
<p>즉, “postId=1인 요청을 재시도한다”는 개념이 아니라,</p>
<p><strong>하나의 <code>execute()</code> 호출 자체를 하나의 재시도 세션(<code>RetryContext</code>)</strong>으로 인식한다.</p>
<hr>
<h3 id="1-재시도-단위-execute-호출">(1) 재시도 단위: execute() 호출</h3>
<p><code>RetryTemplate</code>은 <code>execute()</code> 메서드가 호출될 때마다</p>
<p>새로운 <code>RetryContext</code>를 생성하고,</p>
<p>그 세션(Context) 내에서 동일한 <code>RetryCallback</code>을 반복 실행한다.</p>
<pre><code class="language-java">retryTemplate.execute(context -&gt; {
    // 이 블록 전체가 하나의 재시도 세션
    return someBusinessOperation();
});
</code></pre>
<p>이 “execute() 호출”이 곧 RetryTemplate이 인식하는 <strong>하나의 재시도 대상 단위</strong>이며,</p>
<p>각 Context는 재시도 횟수, 예외, BackOff 상태 등을 내부적으로 관리한다.</p>
<table>
<thead>
<tr>
<th>구분 기준</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>execute() 호출 횟수</td>
<td>각각이 하나의 재시도 실행 단위</td>
</tr>
<tr>
<td>RetryContext</td>
<td>해당 실행 세션의 상태 저장소</td>
</tr>
<tr>
<td>BackOffContext</td>
<td>재시도 간격·대기 상태 추적 객체</td>
</tr>
</tbody></table>
<hr>
<h3 id="2-내부-동작-구조">(2) 내부 동작 구조</h3>
<p>RetryTemplate 내부는 다음과 같이 동작한다.</p>
<pre><code class="language-java">public &lt;T, E extends Throwable&gt; T execute(RetryCallback&lt;T, E&gt; retryCallback) throws E {
    RetryContext context = retryPolicy.open(null); // 세션 생성
    try {
        do {
            try {
                return retryCallback.doWithRetry(context);
            } catch (Exception e) {
                if (retryPolicy.canRetry(context)) {
                    backOffPolicy.backOff(backOffContext); // 대기 후 재시도
                } else {
                    throw e;
                }
            }
        } while (true);
    } finally {
        retryPolicy.close(context); // 세션 종료
    }
}
</code></pre>
<p>이 구조에서 <strong><code>RetryContext</code>는 재시도 세션의 상태 저장소이자 식별자</strong> 역할을 한다.</p>
<table>
<thead>
<tr>
<th>필드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>retryCount</td>
<td>현재 재시도 횟수</td>
</tr>
<tr>
<td>lastThrowable</td>
<td>마지막 예외</td>
</tr>
<tr>
<td>attributes</td>
<td>개발자 커스텀 속성 (postId, operation 등)</td>
</tr>
</tbody></table>
<p>즉, RetryTemplate은 “대상을 찾아서 재시도”하지 않고,</p>
<p>“동일한 Callback을 같은 Context 안에서 반복 실행”한다.</p>
<hr>
<h3 id="3-context--대상의-정체성identity">(3) Context = 대상의 정체성(Identity)</h3>
<p>Spring Retry는 “이 요청이 어떤 ID를 가진 대상인가”를 인식하지 않는다.</p>
<p>대신, <code>open()</code>으로 생성된 하나의 <code>RetryContext</code>가</p>
<p>그 실행 세션의 정체성(Identity)으로 작동한다.</p>
<p>이 Context는 다음과 같은 순서로 재사용된다.</p>
<pre><code>open() → canRetry() → registerThrowable() → backOff() → close()
</code></pre><p>즉, <strong>하나의 execute() 호출 = 하나의 Context = 하나의 재시도 세션</strong>이다.</p>
<p>이 세션 단위로 재시도 상태가 누적·관리된다.</p>
<hr>
<h3 id="4-필요-시-대상-정보를-명시적으로-추가">(4) 필요 시 대상 정보를 명시적으로 추가</h3>
<p>RetryTemplate은 내부적으로 postId 등의 식별자를 추적하지 않는다.</p>
<p>그러나 개발자가 Context에 직접 속성을 추가할 수 있다.</p>
<pre><code class="language-java">retryTemplate.execute(context -&gt; {
    context.setAttribute(&quot;postId&quot;, postId);
    context.setAttribute(&quot;operation&quot;, &quot;like&quot;);
    ...
});
</code></pre>
<p>이후 재시도 중 예외가 발생하면 다음과 같이 로그로 확인할 수 있다.</p>
<pre><code class="language-java">catch (Exception e) {
    log.warn(&quot;Retry failed for postId={} (attempt {})&quot;,
        context.getAttribute(&quot;postId&quot;),
        context.getRetryCount());
}
</code></pre>
<p>이 구조는 RetryTemplate이 본질적으로 “대상 추적 기능”을 내장하지 않고,</p>
<p>필요 시 개발자가 Context를 확장하여</p>
<p>모니터링, 디버깅, 장애 분석에 활용할 수 있게 설계된 것이다.</p>
<hr>
<h3 id="5-요약">(5) 요약</h3>
<table>
<thead>
<tr>
<th>질문</th>
<th>Spring Retry의 동작 방식</th>
</tr>
</thead>
<tbody><tr>
<td>“RetryTemplate은 어떤 대상을 재시도하나?”</td>
<td>execute() 호출 단위(= RetryContext 세션)</td>
</tr>
<tr>
<td>“대상을 ID로 구분하나?”</td>
<td>아니다. Context 자체가 Identity</td>
</tr>
<tr>
<td>“동일 대상을 어떻게 인식하나?”</td>
<td>동일 execute()에서 생성된 Context로 판단</td>
</tr>
<tr>
<td>“대상을 명시적으로 넣으려면?”</td>
<td>context.setAttribute(&quot;id&quot;, id)로 직접 주입 가능</td>
</tr>
</tbody></table>
<p><strong>정리:</strong></p>
<p>RetryTemplate은 “특정 대상을 다시 찾는 구조”가 아니라</p>
<p><strong>하나의 <code>execute()</code> 세션(Context)</strong>을 <strong>하나의 실행 단위로 반복</strong>한다.</p>
<p>즉, RetryContext가 곧 재시도 단위이며,</p>
<p>Spring Retry는 이 Context를 기반으로 재시도 횟수·대기 간격·상태를 제어한다.</p>
<hr>
<h2 id="5-실제-코드-적용-구조">5. 실제 코드 적용 구조</h2>
<h3 id="51-retryconfigjava">5.1 RetryConfig.java</h3>
<pre><code class="language-java">@Configuration
public class RetryConfig {

    @Bean
    public RetryTemplate retryTemplate() {
        RetryTemplate template = new RetryTemplate();

        ExponentialRandomBackOffPolicy backOffPolicy = new ExponentialRandomBackOffPolicy();
        backOffPolicy.setInitialInterval(200);
        backOffPolicy.setMultiplier(1.5);
        backOffPolicy.setMaxInterval(3000);

        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(10, Map.of(Exception.class, true));

        template.setBackOffPolicy(backOffPolicy);
        template.setRetryPolicy(retryPolicy);
        return template;
    }
}
</code></pre>
<p><strong>설명</strong></p>
<ul>
<li>기존 <code>ExponentialBackOffPolicy</code>를 <code>ExponentialRandomBackOffPolicy</code>로 교체</li>
<li>초기 간격, 배수, 최대 간격은 동일하게 유지하여 실험 조건 일치</li>
<li>예외 매핑을 통해 <code>OptimisticLockingFailureException</code> 포함</li>
</ul>
<hr>
<h3 id="52-postlikeretryexecutorjava">5.2 PostLikeRetryExecutor.java</h3>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class PostLikeRetryExecutor {

    private final RetryTemplate retryTemplate;
    private final TransactionTemplate transactionTemplate;

    public &lt;T&gt; T likeWithRetry(Callable&lt;T&gt; action) {
        return retryTemplate.execute(context -&gt; {
            log.debug(&quot;재시도 시작 (attempt {})&quot;, context.getRetryCount());

            try {
                return transactionTemplate.execute(status -&gt; {
                    try {
                        return action.call();
                    } catch (Exception e) {
                        if (e instanceof RuntimeException) throw (RuntimeException) e;
                        throw new RuntimeException(e);
                    }
                });
            } catch (Exception e) {
                log.warn(&quot;재시도 실패 (attempt {}): {}&quot;, context.getRetryCount(), e.getMessage());
                throw e;
            }
        });
    }
}
</code></pre>
<p><strong>설명</strong></p>
<ul>
<li><p><code>RetryTemplate</code> 내부에서 <code>TransactionTemplate</code>을 함께 사용하여</p>
<p>  매 재시도 시 새로운 트랜잭션을 열고 롤백 후 다시 시도하도록 구성</p>
</li>
<li><p><code>RetryContext</code>는 자동으로 관리되며, 별도 <code>context.setAttribute()</code> 호출 불필요</p>
</li>
<li><p><code>OptimisticLockingFailureException</code> 발생 시 자동 재시도</p>
</li>
<li><p>시도 횟수(<code>context.getRetryCount()</code>) 기반 로깅으로 디버깅 용이</p>
</li>
</ul>
<hr>
<h3 id="53-postlikeservicejava">5.3 PostLikeService.java</h3>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class PostLikeService {

    private final PostRepository postRepository;
    private final PostLikeRetryExecutor retryExecutor;

    @Transactional
    public void likePost(Long postId) {
        retryExecutor.likeWithRetry(() -&gt; {
            Post post = postRepository.findByIdWithOptimisticLock(postId)
                    .orElseThrow(PostNotFoundException::new);
            post.increaseLikeCount();
            postRepository.save(post);
            return null;
        });
    }
}
</code></pre>
<p><strong>설명</strong></p>
<ul>
<li>낙관적 락 충돌 시 발생하는 예외는 <code>RetryExecutor</code> 내부의 재시도 루프로 전달됨</li>
<li>서비스 계층은 비즈니스 로직만 유지하며, 재시도 및 트랜잭션 제어는 외부화됨</li>
<li>트랜잭션 안정성과 로직 단순화를 동시에 확보</li>
</ul>
<hr>
<h2 id="6-exponentialrandombackoffpolicy-성능-결과">6. ExponentialRandomBackOffPolicy 성능 결과</h2>
<hr>
<table>
<thead>
<tr>
<th>설정명</th>
<th>Script</th>
<th>VU</th>
<th>Retry</th>
<th>Mult</th>
<th>성공률</th>
<th>실패율</th>
<th>평균응답</th>
<th>최대응답</th>
</tr>
</thead>
<tbody><tr>
<td>XR1</td>
<td>like-test2</td>
<td>100</td>
<td>8</td>
<td>1.5</td>
<td>100%</td>
<td>0.0%</td>
<td>2.93s</td>
<td>12.79s</td>
</tr>
<tr>
<td>XR2</td>
<td>like-test2</td>
<td>200</td>
<td>8</td>
<td>1.5</td>
<td>99.75%</td>
<td>0.25%</td>
<td>3.55s</td>
<td>16.58s</td>
</tr>
<tr>
<td>XR3</td>
<td>like-test2</td>
<td>100</td>
<td>9</td>
<td>1.8</td>
<td>100%</td>
<td>0.0%</td>
<td>1.20s</td>
<td>8.30s</td>
</tr>
<tr>
<td>XR4</td>
<td>like-test2</td>
<td>200</td>
<td>9</td>
<td>1.8</td>
<td>98.75%</td>
<td>1.25%</td>
<td>3.06s</td>
<td>10.86s</td>
</tr>
<tr>
<td>XR5</td>
<td>like-test2</td>
<td>100</td>
<td>10</td>
<td>2.0</td>
<td>100%</td>
<td>0.0%</td>
<td>2.66s</td>
<td>16.32s</td>
</tr>
<tr>
<td>XR6</td>
<td>like-test2</td>
<td>200</td>
<td>10</td>
<td>2.0</td>
<td>100%</td>
<td>0.0%</td>
<td>3.68s</td>
<td>17.20s</td>
</tr>
</tbody></table>
<hr>
<h3 id="비교-요약">비교 요약</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>정책 유형</th>
<th>평균 성공률</th>
<th>최대 응답 시간</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>ExponentialBackOffPolicy</td>
<td>고정 지수 재시도</td>
<td>VU 200 기준 약 77~93%</td>
<td>최대 17.48s</td>
<td>부하 집중 시 실패율 상승</td>
</tr>
<tr>
<td>ExponentialRandomBackOffPolicy</td>
<td>무작위 지수 재시도</td>
<td>VU 200 기준 98~100%</td>
<td>최대 17.20s</td>
<td>충돌 분산, 안정적 성능 확보</td>
</tr>
</tbody></table>
<hr>
<h2 id="7-결론">7. 결론</h2>
<p><code>ExponentialBackOffPolicy</code>는 동일한 간격 증가 로직으로 인해</p>
<p>대규모 동시 요청 시 재시도 타이밍이 집중되는 구조적 한계를 가진다.</p>
<p>반면 <code>ExponentialRandomBackOffPolicy</code>는 무작위성을 부여함으로써</p>
<p>재시도 타이밍이 분산되고, DB 락 경합 및 실패율이 현저히 감소했다.</p>
<p>서버 및 DB 설정 확장(Tomcat, Hikari, PostgreSQL)을 기반으로</p>
<p>VU 200 이상 환경에서도 99~100%의 성공률을 유지했으며,</p>
<p>지수적 증가 + 무작위 오프셋 조합이 가장 안정적인 패턴으로 확인되었다.</p>
<hr>
<h3 id="최종-결론">최종 결론</h3>
<ul>
<li><p><strong>ExponentialRandomBackOffPolicy</strong>는 락 충돌 완화와 재시도 타이밍 분산에 탁월하다.</p>
</li>
<li><p><strong>TransactionTemplate 결합 구조</strong>는 재시도마다 트랜잭션 재시작을 보장한다.</p>
</li>
<li><p><strong>RetryTemplate 기반 설계</strong>는 비즈니스 로직과 정책을 완전히 분리하여</p>
<p>  확장성과 유지보수성을 동시에 확보했다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[7-6] [재시도 정책과 ExponentialBackOffPolicy 설계]]]></title>
            <link>https://velog.io/@minpractice_jhj/%EC%9E%AC%EC%8B%9C%EB%8F%84-%EC%A0%84%EB%9E%B5%EA%B3%BC-%EC%A0%95%EC%B1%85-%EA%B5%AC%EC%A1%B0-%EC%8B%A4%ED%8C%A8-%ED%9B%84%EC%97%90%EB%8F%84-%EB%A9%88%EC%B6%94%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%8B%9C%EC%8A%A4%ED%85%9C</link>
            <guid>https://velog.io/@minpractice_jhj/%EC%9E%AC%EC%8B%9C%EB%8F%84-%EC%A0%84%EB%9E%B5%EA%B3%BC-%EC%A0%95%EC%B1%85-%EA%B5%AC%EC%A1%B0-%EC%8B%A4%ED%8C%A8-%ED%9B%84%EC%97%90%EB%8F%84-%EB%A9%88%EC%B6%94%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%8B%9C%EC%8A%A4%ED%85%9C</guid>
            <pubDate>Tue, 14 Oct 2025 08:56:57 GMT</pubDate>
            <description><![CDATA[<h2 id="1-낙관적-락의-한계-충돌-후-예외-발생">1. 낙관적 락의 한계: 충돌 후 예외 발생</h2>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/ff0d0279-8669-4b95-9003-c055db3bfc51/image.png" alt=""></p>
<blockquote>
<p>💡 두 사용자가 동시에 같은 데이터를 수정할 때 발생하는 낙관적 락 충돌. 충돌 시점에 예외가 발생하고 트랜잭션이 롤백된다.</p>
</blockquote>
<p>2편에서 살펴본 낙관적 락은 “충돌을 허용하고, 커밋 시점에 감지한다.”</p>
<p>이 방식은 동시성 제어의 유연성을 높이지만, <code>OptimisticLockException</code>이 발생하면 트랜잭션이 롤백된다.</p>
<p>즉, 충돌이 발생한 사용자는 아무 일도 일어나지 않은 것처럼 보이게 된다.</p>
<p>좋아요 기능처럼 <strong>즉시 반응성과 데이터 정합성을 모두 요구하는 기능</strong>에서는</p>
<p>이 단점이 그대로 사용자 체감 오류로 이어진다.</p>
<p>이 문제를 해결하기 위해, <strong>충돌 후 재시도(retry)</strong> 가 필요하다.</p>
<p>다만 단순 반복이 아니라, “정책(policy)”으로 설계된 재시도여야 한다.</p>
<hr>
<h2 id="2-단순-반복이-아닌-정책-기반-접근">2. 단순 반복이 아닌 정책 기반 접근</h2>
<p>단순히 <code>for</code> 문으로 감싸는 것은 임시방편이다.</p>
<pre><code class="language-java">for (int i = 0; i &lt; 3; i++) {
    try {
        updateLikeCount();
        break;
    } catch (OptimisticLockException e) {
        // 재시도
    }
}
</code></pre>
<p>이런 구조는 간단하지만 다음과 같은 문제가 있다:</p>
<ul>
<li>재시도 간 간격 제어 불가</li>
<li>예외별 재시도 정책 분리 불가능</li>
<li>동일 시점에 다수의 재시도가 겹침 (락 충돌 재발)</li>
</ul>
<p>따라서 재시도 로직은 <strong>RetryPolicy</strong>와 <strong>BackOffPolicy</strong>로 분리되어야 한다.</p>
<hr>
<h2 id="3-spring-retry의-정책-구조">3. Spring Retry의 정책 구조</h2>
<p>Spring Retry는 재시도를 독립된 템플릿으로 제공한다.</p>
<pre><code>RetryTemplate
 ├── RetryPolicy: 재시도 횟수와 조건 정의
 ├── BackOffPolicy: 재시도 간 대기 시간 제어
 └── RetryCallback: 실제 수행할 로직
</code></pre><p>이 구조를 활용하면</p>
<p>“언제, 몇 번, 어떤 간격으로” 다시 시도할지를 독립적으로 설정할 수 있다.</p>
<hr>
<h2 id="4-spring-기반-재시도-구현-방식-비교">4. Spring 기반 재시도 구현 방식 비교</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>구현 방식</th>
<th>대표 코드 / Annotation</th>
<th>특징</th>
<th>장점</th>
<th>단점</th>
<th>추천 상황</th>
</tr>
</thead>
<tbody><tr>
<td>① 선언적 재시도 (AOP 기반)</td>
<td><code>@Retryable</code>, <code>@Recover</code></td>
<td>Spring Retry</td>
<td>어노테이션 기반 자동 재시도</td>
<td>단순, 가독성 높음</td>
<td>세밀한 제어 어려움</td>
<td>간단한 API 호출</td>
</tr>
<tr>
<td>② 프로그래밍적 재시도 (템플릿 기반)</td>
<td><code>RetryTemplate</code></td>
<td>Spring Retry</td>
<td>정책 객체로 세밀한 설정 가능</td>
<td>유연한 구성</td>
<td>코드량 많음</td>
<td>락 충돌, DB 갱신</td>
</tr>
<tr>
<td>③ 함수형 재시도</td>
<td>직접 구현 (<code>try-catch</code>)</td>
<td>Java 표준</td>
<td>최소 구현</td>
<td>의존성 없음</td>
<td>제어 불가</td>
<td>실험, 간단한 테스트</td>
</tr>
<tr>
<td>④ 회로형 재시도</td>
<td>Resilience4j</td>
<td>외부 라이브러리</td>
<td>회로 차단·지연 등 고급 제어</td>
<td>모듈화, 복원력</td>
<td>설정 복잡</td>
<td>외부 API 통신</td>
</tr>
<tr>
<td>⑤ 비동기/메시지 기반</td>
<td>Kafka DLQ 등</td>
<td>MQ 기반</td>
<td>실패 이벤트 재처리</td>
<td>부하 분산</td>
<td>실시간성 부족</td>
<td>비동기 처리 파이프라인</td>
</tr>
</tbody></table>
<p>좋아요 기능은 <strong>짧은 트랜잭션, 높은 빈도, 즉시성 중심</strong>이므로</p>
<p>② <code>RetryTemplate</code> + <code>ExponentialBackOffPolicy</code> 조합이 가장 적합하다.</p>
<hr>
<h2 id="5-retrytemplate-적용-예시">5. RetryTemplate 적용 예시</h2>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/8763e825-c95f-49cd-82ff-c8b379ab8c78/image.png" alt=""></p>
<blockquote>
<p>🔁 실패 시 BackOff 대기 후 재시도를 반복하며, 성공 시 루프를 종료하는 RetryTemplate의 동작 흐름.</p>
</blockquote>
<pre><code class="language-java">@Configuration
public class RetryConfig {

    @Bean
    public RetryTemplate retryTemplate() {
        RetryTemplate template = new RetryTemplate();

        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(
            3, Map.of(OptimisticLockException.class, true)
        );

        ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
        backOffPolicy.setInitialInterval(300);
        backOffPolicy.setMultiplier(2.0);
        backOffPolicy.setMaxInterval(2000);

        template.setRetryPolicy(retryPolicy);
        template.setBackOffPolicy(backOffPolicy);
        return template;
    }
}
</code></pre>
<ul>
<li>최대 3회 재시도</li>
<li>300ms → 600ms → 1200ms 간격으로 대기</li>
<li><code>OptimisticLockException</code> 발생 시 재시도</li>
</ul>
<hr>
<h2 id="6-좋아요-기능-적용">6. 좋아요 기능 적용</h2>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class PostLikeService {

    private final RetryTemplate retryTemplate;
    private final PostRepository postRepository;
    private final PostLikeRepository postLikeRepository;

    @Transactional
    public void toggleLike(Long postId, Long memberId) {
        retryTemplate.execute(context -&gt; {
            Post post = postRepository.findByIdWithOptimisticLock(postId)
                    .orElseThrow(PostNotFoundException::new);

            boolean liked = postLikeRepository.existsByPostIdAndMemberId(postId, memberId);
            if (liked) {
                postLikeRepository.deleteByPostIdAndMemberId(postId, memberId);
                post.decreaseLikeCount();
            } else {
                postLikeRepository.save(new PostLike(postId, memberId));
                post.increaseLikeCount();
            }

            return null;
        });
    }
}
</code></pre>
<p>이제 낙관적 락 충돌이 발생하더라도 자동으로 재시도가 수행된다.</p>
<p>그러나 이 정책이 실제 부하 환경에서도 효과적일까?</p>
<hr>
<h2 id="7-exponentialbackoffpolicy-적용-후-k6-테스트-결과">7. ExponentialBackOffPolicy 적용 후 K6 테스트 결과</h2>
<p>이 구조가 실전에서 얼마나 안정적으로 동작하는지를 확인하기 위해</p>
<p><strong>K6 부하 테스트</strong>를 수행했다.</p>
<h3 id="테스트-개요">테스트 개요</h3>
<ul>
<li>테스트 대상: <code>/post/{id}/like</code> API</li>
<li>목적: 동시에 같은 게시글에 좋아요 요청 시 성공률 검증</li>
<li>환경: VU 100 → 200까지 점진적 증가</li>
<li>정책: <code>Retry 8~10</code>, <code>Multiplier 1.5~2.0</code>, <code>Initial 200~300ms</code></li>
</ul>
<hr>
<h3 id="7-1-테스트-결과-요약">7-1. 테스트 결과 요약</h3>
<table>
<thead>
<tr>
<th>Script</th>
<th>VU</th>
<th>Retry</th>
<th>Mult</th>
<th>👍 성공률</th>
<th>❌ 실패율</th>
<th>평균 응답</th>
<th>최대 응답</th>
<th>p95</th>
</tr>
</thead>
<tbody><tr>
<td>like-test2</td>
<td>100</td>
<td>8</td>
<td>1.5</td>
<td>50%</td>
<td>25.0%</td>
<td>2.29s</td>
<td>6.84s</td>
<td>6.83s</td>
</tr>
<tr>
<td>like-test2</td>
<td>200</td>
<td>8</td>
<td>1.5</td>
<td>78.5%</td>
<td>10.75%</td>
<td>2.58s</td>
<td>8.62s</td>
<td>8.61s</td>
</tr>
<tr>
<td>like-test2</td>
<td>200</td>
<td>9</td>
<td>1.8</td>
<td>83.5%</td>
<td>8.25%</td>
<td>2.15s</td>
<td>12.41s</td>
<td>12.37s</td>
</tr>
<tr>
<td>like-test2</td>
<td>200</td>
<td>10</td>
<td>2.0</td>
<td>80.5%</td>
<td>9.75%</td>
<td>3.14s</td>
<td>17.58s</td>
<td>17.54s</td>
</tr>
</tbody></table>
<h3 id="7-2-분석">7-2. 분석</h3>
<ul>
<li>VU 100 수준에서는 성공률 80~90%로 비교적 양호</li>
<li>그러나 200 이상에서는 성공률이 급격히 하락</li>
<li>재시도 횟수를 늘려도 개선 폭이 제한적</li>
<li>최대 응답 시간은 최대 17초 이상까지 증가</li>
</ul>
<p>이 현상은 <strong>모든 요청이 같은 간격(지수 증가)으로 재시도되기 때문</strong>이다.</p>
<p>즉, 재시도 타이밍이 다시 겹치며 락 경합이 재발생했다.</p>
<hr>
<h2 id="8-exponentialbackoffpolicy의-구조적-한계와-개선-방향">8. ExponentialBackOffPolicy의 구조적 한계와 개선 방향</h2>
<p><code>ExponentialBackOffPolicy</code>는 재시도 시마다 대기 시간을 지수적으로 늘리는 방식이다.</p>
<p>이론적으로는 부하를 완화하기 위한 구조지만,</p>
<p>모든 트랜잭션이 동일한 알고리즘과 파라미터로 동작하면</p>
<p>다음과 같은 현상이 발생한다.</p>
<ol>
<li><strong>재시도 타이밍이 일치한다.</strong></li>
<li><strong>락 경합이 다시 집중된다.</strong></li>
<li><strong>성공률이 하락하고 응답 지연이 증가한다.</strong></li>
</ol>
<p>결과적으로, “한 발 늦게 재시도하려던 설계”가</p>
<p>현실에서는 “모두 동시에 늦게 재시도”하는 구조로 변해버린다.</p>
<p>즉, 지수 백오프는 <strong>네트워크 장애</strong>나 <strong>일시적 처리 지연</strong>에는 효과적이지만,</p>
<p><strong>동시성 충돌</strong>을 완화하는 데에는 한계가 있다.</p>
<p>VU 200 이상 테스트에서 이 문제가 명확히 드러났으며,</p>
<p>락 충돌이 완전히 해소되지 않아 성공률이 80~90% 수준에 머물렀다.</p>
<hr>
<h2 id="9-exponentialrandombackoffpolicy로의-전환">9. ExponentialRandomBackOffPolicy로의 전환</h2>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/d8da97c1-160a-418f-95b2-2d6df1231170/image.png" alt=""></p>
<blockquote>
<p>📊 Exponential은 동일 간격으로 증가하지만, RandomBackOff는 각 요청의 대기 간격을 무작위로 분산시켜 충돌 타이밍을 줄인다.</p>
</blockquote>
<p>이 시점에서 문제의 본질은 “대기 시간의 형태”가 아니라 <strong>“타이밍의 일치”</strong>였다.</p>
<p>즉, 모두가 같은 시점에 재시도하기 때문에 락 충돌이 반복되는 구조였다.</p>
<p>이를 해결하기 위해 선택한 접근이</p>
<p><strong><code>ExponentialRandomBackOffPolicy</code></strong>,</p>
<p>즉 <strong>무작위 지연(Random Jitter)</strong>을 도입하는 방식이다.</p>
<p>이 정책은 기존 지수 백오프 구조를 유지하면서도,</p>
<p>각 요청마다 재시도 간격을 미세하게 달리해 재시도 타이밍을 분산시킨다.</p>
<ul>
<li>기존: 300 → 600 → 1200ms (고정된 간격)</li>
<li>개선: 300<del>600 → 600</del>1200 → 1200~2400ms (무작위 분산)</li>
</ul>
<p>결과적으로 각 요청이 서로 다른 타이밍으로 재시도되어</p>
<p>락 해제 시점이 자연스럽게 분산되고,</p>
<p>DB 부하 및 충돌 확률이 감소하게 된다.</p>
<hr>
<p>이제 다음 편에서는</p>
<blockquote>
<p><code>ExponentialBackOffPolicy</code>와 <code>ExponentialRandomBackOffPolicy</code>를
<strong>같은 조건에서 비교 실험</strong>하고,
무작위 분산이 실제로 시스템 성공률과 안정성에 어떤 차이를 만드는지
테스트 데이터와 내부 동작 코드를 기반으로 검증한다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[7-5] [비관적 vs 낙관적 락 — 충돌 감지 ]]]></title>
            <link>https://velog.io/@minpractice_jhj/%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-vs-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD</link>
            <guid>https://velog.io/@minpractice_jhj/%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-vs-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD</guid>
            <pubDate>Tue, 14 Oct 2025 08:30:16 GMT</pubDate>
            <description><![CDATA[<h2 id="1-락은-왜-필요한가">1. 락은 왜 필요한가?</h2>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/f27f1b19-d47f-4b7a-9b00-43699ee74fe0/image.png" alt="">
동시성(concurrency)을 다룰 때 가장 중요한 문제는</p>
<p>“여러 트랜잭션이 동시에 같은 데이터를 수정하려 할 때</p>
<p>어떻게 데이터의 일관성을 보장할 것인가?”이다.</p>
<p>예를 들어 두 사용자가 같은 게시글에 동시에 좋아요를 누른다고 가정해보자.</p>
<ul>
<li>사용자 A: 좋아요 수를 10 → 11로 증가</li>
<li>사용자 B: 동시에 10 → 11로 증가</li>
</ul>
<p>이 두 요청이 동시에 처리되면,</p>
<p>결과는 12가 아니라 <strong>11로 덮어씌워지는 문제가 발생</strong>한다.</p>
<p>이것이 바로 <strong>동시 수정 충돌(Write Conflict)</strong>이다.</p>
<p>데이터베이스는 이러한 충돌을 방지하기 위해 <strong>Lock(잠금)</strong>을 사용한다.</p>
<p>락은 쉽게 말해 “이 데이터는 지금 누군가 수정 중이니 잠깐 기다려라”라는 신호다.</p>
<p>하지만 이 락을 언제 거는지가 문제다.</p>
<p>너무 일찍 잠그면 병목이 생기고, 너무 늦게 감지하면 충돌이 생긴다.</p>
<p>이 시점의 선택이 바로 <strong>비관적 락(Pessimistic Lock)</strong>과 <strong>낙관적 락(Optimistic Lock)</strong>의 차이를 만든다.</p>
<hr>
<h2 id="2-두-철학의-차이--미리-막을-것인가-나중에-감지할-것인가">2. 두 철학의 차이 — “미리 막을 것인가, 나중에 감지할 것인가”</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>비관적 락 (Pessimistic Lock)</th>
<th>낙관적 락 (Optimistic Lock)</th>
</tr>
</thead>
<tbody><tr>
<td>철학</td>
<td>“충돌은 자주 일어나므로 미리 막자.”</td>
<td>“충돌은 드물므로 나중에 감지하자.”</td>
</tr>
<tr>
<td>동작 시점</td>
<td>트랜잭션 시작 시점에서 Row 잠금</td>
<td>커밋 시점에서 버전(version) 비교</td>
</tr>
<tr>
<td>충돌 처리</td>
<td>순차 대기 (WAIT) → 데드락 가능</td>
<td>예외 발생 → 재시도 필요</td>
</tr>
<tr>
<td>주요 비용</td>
<td>대기 시간</td>
<td>재시도 비용</td>
</tr>
<tr>
<td>적합 서비스</td>
<td>결제, 송금, 재고 등 절대 정합성</td>
<td>좋아요, 조회수, 포인트 등 즉시성 중심</td>
</tr>
</tbody></table>
<h3 id="비관적-락-pessimistic-lock">비관적 락 (Pessimistic Lock)</h3>
<blockquote>
<p>“언제 충돌이 날지 모르니, 미리 잠가두자.”</p>
</blockquote>
<p>비관적 락은 말 그대로 “비관적인” 가정에서 출발한다.</p>
<p>충돌이 언제든 발생할 수 있다고 보고,</p>
<p>트랜잭션이 데이터를 수정하기 전에 먼저 락을 건다.</p>
<pre><code class="language-sql">SELECT * FROM post WHERE id = 1 FOR UPDATE;
UPDATE post SET like_count = like_count + 1 WHERE id = 1;
</code></pre>
<p>이 경우 다른 트랜잭션은 해당 Row가 해제될 때까지 기다린다.</p>
<p>즉, 항상 순서를 보장하지만 대기 시간이 생긴다.</p>
<ul>
<li><strong>장점:</strong> 충돌이 아예 발생하지 않는다.</li>
<li><strong>단점:</strong> 락 대기열로 인한 병목, 데드락 가능성.</li>
<li><strong>적합 예:</strong> 은행 송금, 재고 감소, 결제 승인 등.</li>
</ul>
<blockquote>
<p>한 줄 요약: “안전하지만 느리다.”</p>
</blockquote>
<hr>
<h3 id="낙관적-락-optimistic-lock">낙관적 락 (Optimistic Lock)</h3>
<blockquote>
<p>“충돌은 드물다. 일단 진행하고, 나중에 확인하자.”</p>
</blockquote>
<p>낙관적 락은 트랜잭션이 데이터를 수정하더라도</p>
<p>락을 걸지 않고 자유롭게 진행한다.</p>
<p>단, 커밋 시점에서 데이터의 버전을 비교하여</p>
<p>누군가 먼저 변경했다면 예외를 발생시킨다.</p>
<pre><code class="language-java">@Entity
class Post {
    @Id @GeneratedValue
    private Long id;

    @Version
    private Long version;

    private int likeCount;
}
</code></pre>
<p><code>@Version</code> 필드는 데이터의 버전을 의미한다.</p>
<p>A가 먼저 저장하여 version이 2가 되면,</p>
<p>B가 나중에 커밋할 때 version이 맞지 않아 예외가 발생한다.</p>
<ul>
<li><strong>장점:</strong> 대기 없음, 빠른 처리.</li>
<li><strong>단점:</strong> 충돌 발생 시 실패 → 재시도 필요.</li>
<li><strong>적합 예:</strong> 좋아요, 조회수, 포인트 적립 등.</li>
</ul>
<blockquote>
<p>한 줄 요약: “빠르지만 충돌 시 실패한다.”</p>
</blockquote>
<hr>
<h2 id="3-실제-프로젝트에-락을-적용해보기">3. 실제 프로젝트에 락을 적용해보기</h2>
<p>이론을 이해했다면 이제 실무에서 이를 적용해볼 차례다.</p>
<p>사이드 프로젝트의 게시판(Post) 서비스에서</p>
<p><strong>좋아요 기능</strong>을 구현하면서 락 전략을 직접 실험해보기로 했다.</p>
<p>좋아요는 동시에 여러 사용자가 같은 게시글을 수정할 수 있지만,</p>
<p>금융 거래처럼 강한 정합성이 필요한 기능은 아니다.</p>
<p>즉, 충돌이 자주 발생하지 않으며, 빠른 응답이 더 중요하다.</p>
<p>그래서 “락을 미리 거는 비관적 락”보다</p>
<p>“충돌 시점에서 감지하는 낙관적 락”이 더 적합하다고 판단했다.</p>
<hr>
<h2 id="4-설계--post와-postlike의-분리">4. 설계 — Post와 PostLike의 분리</h2>
<p>좋아요 기능은 다음 두 가지 데이터가 필요하다.</p>
<ol>
<li>“누가 좋아요를 눌렀는가”</li>
<li>“해당 게시글의 좋아요 총합은 몇 개인가”</li>
</ol>
<p>이 두 역할을 각각 <strong>PostLike</strong>와 <strong>Post</strong>로 나누었다.</p>
<pre><code class="language-java">@Entity
class PostLike {
    @Id @GeneratedValue
    private Long id;

    private Long postId;
    private Long memberId;
}
</code></pre>
<p><code>PostLike</code>는 사용자별 좋아요 여부만 관리하며</p>
<p>중복을 막기 위해 <code>UNIQUE(post_id, member_id)</code> 제약을 둔다.</p>
<pre><code class="language-java">@Entity
class Post {
    @Id @GeneratedValue
    private Long id;

    @Version
    private Long version;

    private int likeCount;
}
</code></pre>
<p><code>Post</code>의 <code>likeCount</code>는 총 좋아요 수를 단순 정수 컬럼으로 관리한다.</p>
<p>이렇게 하면 다음과 같은 장점이 생긴다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>기존 (JOIN 방식)</th>
<th>개선 (likeCount 컬럼)</th>
</tr>
</thead>
<tbody><tr>
<td>조회 쿼리</td>
<td><code>SELECT COUNT(*) FROM post_like WHERE post_id = ?</code></td>
<td><code>SELECT like_count FROM post WHERE id = ?</code></td>
</tr>
<tr>
<td>속도</td>
<td>느림 (JOIN + COUNT)</td>
<td>빠름 (단일 컬럼 조회)</td>
</tr>
<tr>
<td>구조</td>
<td>관계형 계산 중심</td>
<td>캐시 친화적 단순 구조</td>
</tr>
</tbody></table>
<p>즉, Post 하나만으로도 좋아요 수를 바로 응답할 수 있으므로</p>
<p>조회 성능과 캐시 효율성이 크게 향상된다.</p>
<hr>
<h2 id="5-서비스-로직--낙관적-락-적용">5. 서비스 로직 — 낙관적 락 적용</h2>
<pre><code class="language-java">@Transactional
public void toggleLike(Long postId, Long memberId) {
    Post post = postRepository.findByIdWithOptimisticLock(postId)
            .orElseThrow(PostNotFoundException::new);

    boolean liked = postLikeRepository.existsByPostIdAndMemberId(postId, memberId);
    if (liked) {
        postLikeRepository.deleteByPostIdAndMemberId(postId, memberId);
        post.decreaseLikeCount();
    } else {
        postLikeRepository.save(new PostLike(postId, memberId));
        post.increaseLikeCount();
    }
}
</code></pre>
<ul>
<li>낙관적 락(<code>@Version</code>)으로 동시 수정 충돌을 감지한다.</li>
<li><code>PostLike</code> 테이블을 JOIN하지 않고 <code>likeCount</code>만으로 응답한다.</li>
<li>충돌 시 <code>OptimisticLockException</code>이 발생한다.</li>
</ul>
<hr>
<h2 id="6-테스트--동시-요청에서-발생한-충돌">6. 테스트 — 동시 요청에서 발생한 충돌</h2>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/d490cc3a-0ebf-41e7-bba4-d59d6c2a47e2/image.png" alt=""></p>
<p>좋아요 기능의 동시성 안정성을 검증하기 위해</p>
<p>K6를 이용해 약 100명의 사용자가 동시에 같은 게시글을 좋아요 요청하는 상황을 시뮬레이션했다.</p>
<p>테스트 결과,</p>
<p>낙관적 락(<code>@Version</code>)을 적용했기 때문에 <strong>데이터 불일치는 발생하지 않았지만</strong>,</p>
<p><strong>일부 요청이 커밋 시점에서 <code>OptimisticLockException</code>으로 실패</strong>했다.</p>
<p>이는 낙관적 락의 정상 동작 결과다.</p>
<p>트랜잭션이 자유롭게 실행되다가,</p>
<p>커밋 시점에서 동일한 Row를 갱신한 다른 트랜잭션이 있으면</p>
<p>DB가 충돌을 감지하고 예외를 던진다.</p>
<p>즉, 정합성은 확보되었지만,</p>
<p><strong>일부 요청은 실패로 끝나는 상황이 발생한다.</strong></p>
<p>이 시점에서 자연스럽게 다음 고민이 생긴다.</p>
<blockquote>
<p>“충돌이 발생했을 때, 단순히 실패로 끝낼 것인가?</p>
<p>아니면 자동으로 다시 시도해 복구할 것인가?”</p>
</blockquote>
<hr>
<h2 id="7-결론--충돌을-허용하고-복구하는-설계">7. 결론 — 충돌을 허용하고 복구하는 설계</h2>
<blockquote>
<p>비관적 락은 정합성이 절대적인 시스템에서 유리하지만,
짧은 트랜잭션이 반복되는 서비스에서는 병목을 만든다.
낙관적 락은 충돌을 허용하지만, 그 대신 빠른 처리와 높은 처리율을 제공한다.
좋아요 기능은 후자에 속한다.
따라서 “충돌 감지 → 재시도” 구조를 선택하는 것이 합리적이다.
이제 남은 과제는 단 하나다.</p>
</blockquote>
<p><strong>충돌을 어떻게, 어떤 정책으로 재시도할 것인가.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[7-4] [락의 개념 — MVCC vs Lock의 원리와 구현 비교]]]></title>
            <link>https://velog.io/@minpractice_jhj/MVCC-vs-Lock-%EC%9D%BC%EB%B0%98-%EB%9D%BD%EC%9D%98-%EC%9B%90%EB%A6%AC%EC%99%80-PostgreSQL-%EB%9D%BD%EC%9D%98-%EA%B5%AC%ED%98%84-%EB%B9%84%EA%B5%90-%EC%9A%94%EC%95%BD</link>
            <guid>https://velog.io/@minpractice_jhj/MVCC-vs-Lock-%EC%9D%BC%EB%B0%98-%EB%9D%BD%EC%9D%98-%EC%9B%90%EB%A6%AC%EC%99%80-PostgreSQL-%EB%9D%BD%EC%9D%98-%EA%B5%AC%ED%98%84-%EB%B9%84%EA%B5%90-%EC%9A%94%EC%95%BD</guid>
            <pubDate>Mon, 13 Oct 2025 16:29:10 GMT</pubDate>
            <description><![CDATA[<h2 id="요약">요약</h2>
<p>MVCC는 “무엇이 보이느냐(가시성)”를, 락은 “누가 먼저 쓰느냐(경합)”을 다룬다.</p>
<p>행 락과 대기 전략(NOWAIT / SKIP LOCKED)을 통해 “기다릴지 / 포기할지 / 건너뛸지”를 설계한다.</p>
<p>타임아웃·데드락 표준으로 “영원 대기”를 방지하며, 인덱스·FK가 락의 범위를 결정한다.</p>
<hr>
<h2 id="1-일반적인-트랜잭션-락-rdb-공통-원리">1. 일반적인 트랜잭션 락 (RDB 공통 원리)</h2>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/ef6154cf-274c-4a41-82c9-33b6da3079de/image.png" alt=""></p>
<h3 id="1-핵심-개념">1) 핵심 개념</h3>
<p>트랜잭션 락은 <strong>데이터의 일관성(Consistency)</strong>과 <strong>격리성(Isolation)</strong>을 보장하기 위한 경합 제어 도구다.</p>
<p>DB는 동시에 여러 사용자가 같은 데이터를 수정하려 할 때</p>
<p><strong>“누가 먼저 접근할 것인가”</strong>를 결정하기 위해 락을 건다.</p>
<p>즉, 한 트랜잭션이 데이터를 수정 중이면</p>
<p>다른 트랜잭션은 그 데이터에 접근할 수 없도록 “잠금(Lock)”을 설정한다.</p>
<p>모든 RDBMS는 이 락을 통해 읽기·쓰기 충돌이 발생했을 때의 처리 방식을 정한다.</p>
<p>이때 시스템은 보통 아래 세 가지 중 하나로 대응한다.</p>
<ul>
<li><strong>대기(WAIT)</strong>: 락이 해제될 때까지 기다림</li>
<li><strong>포기(NOWAIT)</strong>: 락이 걸려 있으면 즉시 실패</li>
<li><strong>재시도(RETRY)</strong>: 실패 후 일정 조건에서 다시 시도</li>
</ul>
<blockquote>
<p>핵심 문장:</p>
<p>락은 여러 트랜잭션이 동시에 실행되더라도</p>
<p>데이터를 순서 있게, 일관되게 유지하기 위한 장치다.</p>
</blockquote>
<hr>
<h3 id="2-동작-구조-요약">2) 동작 구조 요약</h3>
<table>
<thead>
<tr>
<th>요소</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>가시성 (MVCC 이전 세대 의미)</td>
<td>COMMIT된 데이터만 보임</td>
<td>트랜잭션 격리 수준에 따라 Dirty Read 방지</td>
</tr>
<tr>
<td>경합 (Concurrency Control)</td>
<td>쓰기 충돌 시 순서를 보장</td>
<td>X락 선점 시 다른 트랜잭션 대기</td>
</tr>
<tr>
<td>대기 전략</td>
<td>충돌 시 시스템이 취할 태도</td>
<td>WAIT, NOWAIT, SKIP</td>
</tr>
<tr>
<td>타임아웃 / 데드락</td>
<td>무한 대기 방지 메커니즘</td>
<td>Lock Timeout, Deadlock Detection</td>
</tr>
<tr>
<td>락 범위 결정</td>
<td>SQL 조건에 따라 락 범위 결정</td>
<td>WHERE 조건 인덱스 유무가 결정적</td>
</tr>
<tr>
<td>FK 연쇄 잠금</td>
<td>참조 무결성 검증 중 부모/자식 동시 락</td>
<td>UPDATE parent → child FK check</td>
</tr>
</tbody></table>
<blockquote>
<p>인덱스가 없는 WHERE 조건으로 UPDATE를 실행하면</p>
<p>DB는 어떤 행이 바뀔지 알 수 없으므로 테이블 전체를 스캔하며 락을 걸게 된다.</p>
</blockquote>
<hr>
<h3 id="3-일반-rdbms의-처리-흐름-mysql--oracle--sql-server-공통">3) 일반 RDBMS의 처리 흐름 (MySQL / Oracle / SQL Server 공통)</h3>
<ol>
<li><strong>Transaction A</strong> → <code>UPDATE row1</code> → X락 획득</li>
<li><strong>Transaction B</strong> → <code>UPDATE row1</code> → 대기 상태(WAIT)</li>
<li>Timeout 설정 시 일정 시간 후 실패</li>
<li>Deadlock 발생 시 DB 엔진이 한쪽 트랜잭션을 강제 ROLLBACK</li>
</ol>
<blockquote>
<p>핵심 문장:</p>
<p>일반적인 트랜잭션 락은 “대기”를 허용하는 구조다.</p>
<p>즉, 동시에 같은 데이터를 수정하려 하면</p>
<p>시스템이 락 큐(Lock Queue)를 만들어 “순서대로 처리”한다.</p>
</blockquote>
<hr>
<h2 id="2-postgresql-트랜잭션-락-엔진-특성">2. PostgreSQL 트랜잭션 락 (엔진 특성)</h2>
<p>PostgreSQL은 같은 문제를 MVCC(Multi-Version Concurrency Control) 구조로 재해석했다.</p>
<p>즉, <strong>“락 중심 제어” 대신 “버전 기반 가시성”</strong>으로 읽기와 쓰기를 분리한다.</p>
<hr>
<h3 id="1-mvcc--무엇이-보이느냐가시성">1) MVCC — “무엇이 보이느냐(가시성)”</h3>
<p>PostgreSQL은 각 행(row)에 <code>xmin</code>, <code>xmax</code>를 기록한다.
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/1e7f46f6-59b5-4979-a097-e302dc16ec80/image.png" alt="">
트랜잭션은 Snapshot을 통해 “보이는 버전만 읽기” 때문에</p>
<p><code>SELECT</code>는 락을 걸지 않는다.</p>
<p>즉, 읽기-쓰기 간 충돌이 사라지고 “쓰기 간” 락만 남는다.</p>
<blockquote>
<p>결과</p>
<ul>
<li>읽기는 “버전 분리”</li>
<li>쓰기는 “락 경쟁”</li>
</ul>
</blockquote>
<hr>
<h3 id="2-lock--누가-먼저-쓰느냐경합">2) Lock — “누가 먼저 쓰느냐(경합)”</h3>
<p>PostgreSQL은 <strong>쓰기 충돌만</strong> Row-Level Lock으로 제어한다.
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/b820d224-f7df-4834-938f-e175316d0b7f/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>전략</th>
<th>의미</th>
<th>명령어</th>
</tr>
</thead>
<tbody><tr>
<td>WAIT</td>
<td>기본 모드 — 락 해제까지 대기</td>
<td><code>FOR UPDATE</code></td>
</tr>
<tr>
<td>NOWAIT</td>
<td>대기하지 않고 즉시 예외 발생</td>
<td><code>FOR UPDATE NOWAIT</code></td>
</tr>
<tr>
<td>SKIP LOCKED</td>
<td>잠긴 행을 건너뛰고 다음 행 조회</td>
<td><code>FOR UPDATE SKIP LOCKED</code></td>
</tr>
</tbody></table>
<hr>
<h3 id="3-wait--순차-일관성consistency-우선">3) WAIT — 순차 일관성(Consistency) 우선</h3>
<p><strong>설명:</strong> 기본 모드. 잠긴 행이 풀릴 때까지 대기한다.</p>
<p><strong>적용 시점:</strong> 결제, 재고, 송금 등 정합성이 중요한 업무</p>
<pre><code class="language-sql">UPDATE accounts
SET balance = balance - 1000
WHERE id = 1
FOR UPDATE;
</code></pre>
<p>이미 다른 트랜잭션이 같은 계좌를 수정 중이라면 순서대로 대기 후 실행된다.</p>
<ul>
<li><strong>장점:</strong> 데이터 일관성 보장</li>
<li><strong>단점:</strong> 대기 시간 증가 가능</li>
</ul>
<blockquote>
<p>비즈니스 예시:</p>
<p>A 사용자가 결제 중일 때 B가 결제를 시도하면</p>
<p>B는 A의 트랜잭션이 끝날 때까지 대기한다.</p>
<p>→ 이중 결제나 중복 승인 방지에 유용하다.</p>
</blockquote>
<hr>
<h3 id="4-nowait--빠른-실패fast-fail-전략">4) NOWAIT — 빠른 실패(Fast-Fail) 전략</h3>
<p><strong>설명:</strong> 잠긴 행이 있으면 즉시 실패 (<code>ERROR: could not obtain lock</code>)</p>
<p><strong>적용 시점:</strong> 빠른 응답이 필요한 API, 포인트 적립, 예약 등</p>
<pre><code class="language-sql">UPDATE points
SET amount = amount + 100
WHERE user_id = 42
FOR UPDATE NOWAIT;
</code></pre>
<p>락이 걸려 있으면 바로 예외 발생 → 재시도 큐에 등록</p>
<ul>
<li><strong>장점:</strong> 빠른 응답, 타임아웃 불필요</li>
<li><strong>단점:</strong> 실패 시 재시도 로직 필요</li>
</ul>
<blockquote>
<p>비즈니스 예시:</p>
<p>포인트 적립 중 다른 요청이 이미 처리 중이라면</p>
<p>“처리 중입니다” 메시지를 띄우고 종료.</p>
<p>→ 서버 부하를 줄이고 재시도 큐에서 나중에 처리한다.</p>
</blockquote>
<hr>
<h3 id="5-skip-locked--병렬-효율parallelism-극대화">5) SKIP LOCKED — 병렬 효율(Parallelism) 극대화</h3>
<p><strong>설명:</strong> 잠긴 행을 건너뛰고 처리 가능한 행만 선택</p>
<p><strong>적용 시점:</strong> 주문 큐, 배치, 멀티 워커 시스템</p>
<pre><code class="language-sql">SELECT * FROM orders
WHERE status = &#39;READY&#39;
FOR UPDATE SKIP LOCKED
LIMIT 10;
</code></pre>
<p>이미 다른 워커가 처리 중인 주문은 건너뛰고, 남은 주문만 선택한다.</p>
<ul>
<li><strong>장점:</strong> 병렬 처리 효율 극대화, 락 대기 없음</li>
<li><strong>단점:</strong> 일부 레코드는 나중에만 처리됨 (비동기적)</li>
</ul>
<blockquote>
<p>비즈니스 예시:</p>
<p>여러 워커(worker)가 동시에 주문 큐를 가져갈 때,</p>
<p>이미 잠긴 주문은 건너뛰고 처리 가능한 주문부터 가져간다.</p>
<p>→ 병렬성을 높이고 락 경합을 최소화한다.</p>
</blockquote>
<hr>
<h3 id="6-대기-전략-비교-요약">6) 대기 전략 비교 요약</h3>
<table>
<thead>
<tr>
<th>전략</th>
<th>대기 여부</th>
<th>사용 시점</th>
<th>장점</th>
<th>단점</th>
<th>대표 SQL</th>
</tr>
</thead>
<tbody><tr>
<td>WAIT</td>
<td>대기</td>
<td>결제, 송금 등 정합성 업무</td>
<td>순서 보장, 정확성</td>
<td>대기 발생 가능</td>
<td><code>FOR UPDATE</code></td>
</tr>
<tr>
<td>NOWAIT</td>
<td>즉시 실패</td>
<td>API, 예약, 포인트 등</td>
<td>빠른 실패, 부하 감소</td>
<td>재시도 필요</td>
<td><code>FOR UPDATE NOWAIT</code></td>
</tr>
<tr>
<td>SKIP LOCKED</td>
<td>건너뜀</td>
<td>큐, 배치, 비동기 처리</td>
<td>병렬 효율, 무한 대기 없음</td>
<td>일부 지연 처리</td>
<td><code>FOR UPDATE SKIP LOCKED</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="3-타임아웃-·-데드락-제어">3. 타임아웃 · 데드락 제어</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>PostgreSQL 구현</th>
</tr>
</thead>
<tbody><tr>
<td>Lock Timeout</td>
<td><code>SET lock_timeout = &#39;3s&#39;;</code></td>
</tr>
<tr>
<td>Deadlock Detection</td>
<td>Wait-for Graph 분석 (순환 대기 탐지)</td>
</tr>
<tr>
<td>표준 대응 방식</td>
<td>실패 → 재시도 (Transaction Retry Loop)</td>
</tr>
</tbody></table>
<blockquote>
<p>PostgreSQL은 “영원 대기”를 허용하지 않는다.</p>
<p>충돌 시 항상 실패 후 재시도가 정석 패턴이다.</p>
</blockquote>
<hr>
<h2 id="4-인덱스와-fk의-영향">4. 인덱스와 FK의 영향</h2>
<table>
<thead>
<tr>
<th>조건</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 있음</td>
<td>특정 행만 Row Lock (최소 범위)</td>
</tr>
<tr>
<td>인덱스 없음</td>
<td>테이블 전체 Scan → 광범위 락 발생</td>
</tr>
<tr>
<td>FK 제약</td>
<td>부모-자식 간 Transaction Lock 연쇄</td>
</tr>
</tbody></table>
<p> 정리:</p>
<blockquote>
<p>인덱스가 MVCC의 효율을 결정한다.</p>
<p>잘못된 인덱스 설계는 불필요한 블로킹을 유발한다.</p>
</blockquote>
<hr>
<h2 id="5-postgresql-트랜잭션-락-정리">5. PostgreSQL 트랜잭션 락 정리</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>일반 트랜잭션 락</th>
<th>PostgreSQL 트랜잭션 락</th>
</tr>
</thead>
<tbody><tr>
<td>읽기-쓰기 충돌</td>
<td>락 기반 대기</td>
<td>MVCC 기반 비차단(Read only)</td>
</tr>
<tr>
<td>쓰기 충돌</td>
<td>락 큐 대기</td>
<td>Row Lock (Tuple-level)</td>
</tr>
<tr>
<td>대기 전략</td>
<td>내부 엔진 설정</td>
<td>SQL 레벨 제어 (NOWAIT, SKIP)</td>
</tr>
<tr>
<td>락 범위</td>
<td>조건 및 인덱스 의존</td>
<td>인덱스·FK 구조 직접 영향</td>
</tr>
<tr>
<td>데드락 처리</td>
<td>강제 롤백</td>
<td>Wait-for Graph 탐지 + Fail &amp; Retry</td>
</tr>
<tr>
<td>철학</td>
<td>“모두 기다려라”</td>
<td>“읽기는 분리, 쓰기만 질서”</td>
</tr>
</tbody></table>
<hr>
<h3 id="핵심-정리">핵심 정리</h3>
<table>
<thead>
<tr>
<th>관점</th>
<th>일반 트랜잭션 락</th>
<th>PostgreSQL 트랜잭션 락</th>
</tr>
</thead>
<tbody><tr>
<td>핵심 질문</td>
<td>“누가 먼저 접근하느냐”</td>
<td>“무엇이 보이느냐”</td>
</tr>
<tr>
<td>중심 개념</td>
<td>대기 기반 순서 제어</td>
<td>버전 기반 가시성 분리</td>
</tr>
<tr>
<td>충돌 대응</td>
<td>WAIT → DEADLOCK → ROLLBACK</td>
<td>NOWAIT / SKIP LOCKED + Retry</td>
</tr>
<tr>
<td>락 범위</td>
<td>SQL 범위 (테이블/페이지 단위)</td>
<td>인덱스·FK 기반 행 단위</td>
</tr>
<tr>
<td>대표 전략</td>
<td>Lock Queue 중심</td>
<td>MVCC + Tuple Lock 하이브리드</td>
</tr>
</tbody></table>
<blockquote>
<p> 정리 문장:</p>
<p>일반적인 트랜잭션 락은 ‘누가 먼저 쓰느냐’의 싸움이지만,</p>
<p>PostgreSQL의 트랜잭션 락은 ‘무엇이 보이느냐’를 분리한 뒤</p>
<p>‘누가 먼저 쓰느냐’를 최소 범위에서만 결정한다.</p>
</blockquote>
<hr>
<h2 id="결론--대기-정책도-비즈니스-로직이다">결론 — “대기 정책도 비즈니스 로직이다”</h2>
<p>PostgreSQL은 단순히 트랜잭션 동작을 통제하는 수준을 넘어,</p>
<p>WAIT / NOWAIT / SKIP LOCKED 세 가지 대기 정책을</p>
<p>비즈니스 특성에 맞게 설계할 수 있도록 SQL 레벨에서 개방했다.</p>
<p>즉, “트랜잭션 제어”는 더 이상 엔진의 몫이 아니라</p>
<p>시스템 설계자의 선택 영역이다.</p>
<hr>
<h2 id="최종-요약">최종 요약</h2>
<ul>
<li><strong>MVCC는 “보이는 세계(가시성)”를</strong>,</li>
<li><strong>Lock은 “질서의 세계(순서)”를 다룬다.</strong></li>
<li>PostgreSQL은 이 둘을 <strong>가장 세련된 방식으로 결합한 데이터베이스</strong>다.</li>
</ul>
<blockquote>
<p>“지금까지는 데이터베이스가 동시성을 어떻게 제어하는지,
즉 MVCC와 트랜잭션 락이 ‘보이는 세계(가시성)’와 ‘쓰는 세계(경합)’를 분리하는 방식을 살펴봤습니다.
다음 편에서는 비관적 락 vs 낙관적 락  두 가지 접근 방식을 비교하며,
내가 직접 구현 중인 “좋아요 기능”에 어떤 방식이 더 적합한지
실제 프로젝트 수준에서 분석하고 적용해보겠습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[7-3] [트랜잭션 격리 수준 & MVCC — PostgreSQL 내부 동작 이해]]]></title>
            <link>https://velog.io/@minpractice_jhj/PostgreSQL-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80Isolation-Level-MVCC-%EC%A0%95%EB%A6%AC-%EA%B0%9C%EB%85%90-%EB%8F%99%EC%9E%91-%EB%B0%A9%EC%8B%9D-%EC%A0%84%EB%9E%B5-%ED%85%8C%EC%8A%A4%ED%8A%B8-soxpzgoc</link>
            <guid>https://velog.io/@minpractice_jhj/PostgreSQL-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80Isolation-Level-MVCC-%EC%A0%95%EB%A6%AC-%EA%B0%9C%EB%85%90-%EB%8F%99%EC%9E%91-%EB%B0%A9%EC%8B%9D-%EC%A0%84%EB%9E%B5-%ED%85%8C%EC%8A%A4%ED%8A%B8-soxpzgoc</guid>
            <pubDate>Fri, 10 Oct 2025 13:52:10 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이 문서는 PostgreSQL의 <strong>트랜잭션 격리 수준(Isolation Level)</strong>과 MVCC(Multi-Version Concurrency Control) 동작 방식을 종합적으로 정리한 자료입니다.</p>
<p>단순한 표면적 차이뿐 아니라, <code>xmin/xmax</code> 기반의 스냅샷 판정·Vacuum 동작·Lost Update 시나리오·Serializable(SSI) 충돌 처리 등 실제 운영과 밀접한 내용을 포함합니다.</p>
<p>특히 실무에서 자주 고민하는 <strong>READ COMMITTED vs REPEATABLE READ</strong> 전환 문제와, <strong>DB 전역 vs 메서드별 격리 수준 설정 전략</strong>, <strong>테스트 계획</strong>까지 다룹니다.</p>
</blockquote>
<hr>
<h2 id="1-격리-수준별-특징-정리">1. 격리 수준별 특징 정리</h2>
<table>
<thead>
<tr>
<th>수준</th>
<th>Dirty Read</th>
<th>Non-repeatable</th>
<th>Phantom*</th>
<th>핵심/비용</th>
</tr>
</thead>
<tbody><tr>
<td><strong>READ_UNCOMMITTED</strong></td>
<td>가능</td>
<td>가능</td>
<td>가능</td>
<td>실무 거의 미사용. PostgreSQL은 RC처럼 동작(실질 미지원)</td>
</tr>
<tr>
<td><strong>READ_COMMITTED</strong></td>
<td>❌</td>
<td>가능</td>
<td>가능</td>
<td>문장 단위 스냅샷. 빠르고 실용적. PostgreSQL 기본</td>
</tr>
<tr>
<td><strong>REPEATABLE_READ</strong></td>
<td>❌</td>
<td>❌</td>
<td>보통 ❌</td>
<td>트랜잭션 단위 스냅샷(Snapshot Isolation). Write-Skew 가능</td>
</tr>
<tr>
<td><strong>SERIALIZABLE</strong></td>
<td>❌</td>
<td>❌</td>
<td>❌</td>
<td>직렬 가능성 보장. 충돌 시 커밋 실패→재시도 필요, 비용↑</td>
</tr>
</tbody></table>
<blockquote>
<p><em>Phantom: 같은 트랜잭션 내 동일 조건 재조회 시, *</em>새로운 “행”**이 보이는 현상.</p>
</blockquote>
<hr>
<h3 id="1-1-주요-이상phenomena-미니-사전">1-1. 주요 이상(Phenomena) 미니 사전</h3>
<table>
<thead>
<tr>
<th>이름</th>
<th>설명</th>
<th>발생 수준</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Dirty Read</strong></td>
<td>커밋 전 데이터를 읽음</td>
<td>RU</td>
</tr>
<tr>
<td><strong>Non-repeatable Read</strong></td>
<td>같은 트랜잭션에서 재조회 시 값이 달라짐</td>
<td>RC</td>
</tr>
<tr>
<td><strong>Phantom Read</strong></td>
<td>같은 조건 재조회 시 새 행이 등장/삭제</td>
<td>RC</td>
</tr>
<tr>
<td><strong>Lost Update</strong></td>
<td>읽고→계산→쓰기 경합으로 이전 값을 덮어씀</td>
<td>RC에서 주의 필요</td>
</tr>
<tr>
<td><strong>Write-Skew</strong></td>
<td>전역 제약 깨짐 (서로 다른 행 갱신)</td>
<td>RR에서도 가능, SRLZ로만 방지</td>
</tr>
</tbody></table>
<hr>
<h3 id="1-2-db별-차이-요약">1-2. DB별 차이 요약</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>PostgreSQL</th>
<th>MySQL (InnoDB)</th>
</tr>
</thead>
<tbody><tr>
<td>RR 의미</td>
<td>Snapshot Isolation</td>
<td>갭락 기반 RR</td>
</tr>
<tr>
<td>Lost Update</td>
<td>RR에서 충돌 감지로 방지</td>
<td>RR만으로는 완벽 차단 불가</td>
</tr>
<tr>
<td>기본 격리 수준</td>
<td>RC</td>
<td>RR</td>
</tr>
<tr>
<td>권장</td>
<td>RC 기본 + 필요시 RR/SRLZ</td>
<td>RR 기본 + Lost Update는 코드로 보완</td>
</tr>
</tbody></table>
<hr>
<h1 id="2-mvcc-multi-version-concurrency-control">2. MVCC (Multi-Version Concurrency Control)</h1>
<h3 id="2-1-기존-락-기반-방식의-한계와-mvcc의-필요성">2-1. 기존 락 기반 방식의 한계와 MVCC의 필요성</h3>
<ul>
<li>Shared/Exclusive Lock 중심 모델은 <strong>쓰기 하나로 대기 행렬</strong>을 만들고, 읽기 많은 서비스에서 <strong>TPS 급감</strong>을 유발.</li>
<li>MVCC는 다중 버전으로 <strong>읽기-쓰기 비차단</strong>을 지향해 병목을 줄인다.</li>
</ul>
<table>
<thead>
<tr>
<th>항목</th>
<th>전통적 락 기반</th>
<th>MVCC</th>
</tr>
</thead>
<tbody><tr>
<td>동시성 제어</td>
<td>충돌 시 대기</td>
<td>버전 관리로 비차단 처리</td>
</tr>
<tr>
<td>처리량</td>
<td>쓰기 병목에 취약</td>
<td>읽기 다수에서도 TPS 유지</td>
</tr>
<tr>
<td>대표 구현</td>
<td>MyISAM 등</td>
<td>PostgreSQL, InnoDB 등</td>
</tr>
</tbody></table>
<h3 id="2-2-개념핵심-정의">2-2. 개념(핵심 정의)</h3>
<ul>
<li><strong>RC(문장 단위)</strong>: SELECT 시작 시점의 최신 커밋본을 스냅샷으로 사용 → 재조회 결과가 달라질 수 있음.</li>
<li><strong>RR(트랜잭션 단위)</strong>: 트랜잭션 시작 시점 스냅샷 고정(Snapshot Isolation) → Non-repeatable/대부분의 Phantom 차단, 단 <strong>Write-Skew 가능</strong>.</li>
<li><strong>SRLZ</strong>: RR + 커밋 시 <strong>충돌 감지</strong>로 직렬 가능성 보장 → 충돌 시 예외, <strong>재시도 필요</strong>.</li>
</ul>
<h3 id="2-3-장단점">2-3. 장단점</h3>
<table>
<thead>
<tr>
<th>장점</th>
<th>비용/주의점</th>
</tr>
</thead>
<tbody><tr>
<td>비차단성↑, TPS↑, 지연↓</td>
<td>Undo/Vacuum 관리 비용↑, <strong>장수 트랜잭션 버전 누적</strong>↑, RR/SRLZ 충돌 → 재시도 필요</td>
</tr>
</tbody></table>
<h3 id="2-4-postgresql-mvcc-내부-동작-상세">2-4. PostgreSQL MVCC 내부 동작 상세</h3>
<p><strong>1) xmin / xmax</strong></p>
<ul>
<li>모든 행에는 <code>xmin</code>(생성 트랜잭션 ID), <code>xmax</code>(삭제/갱신 트랜잭션 ID)가 있다.</li>
<li>INSERT → <code>xmin</code> 기록, UPDATE → <strong>기존 행 <code>xmax</code> 설정 + 새 버전 INSERT</strong>.</li>
</ul>
<p><strong>2) Snapshot Visibility Rule</strong></p>
<ul>
<li>스냅샷과 <code>xmin/xmax</code>로 가시성 판단:<ol>
<li><code>xmin</code>이 스냅샷 이전에 커밋 → 보임</li>
<li><code>xmin</code>이 현재 트랜잭션 → 보임</li>
<li><code>xmax</code>가 스냅샷 이전에 커밋 → 보이지 않음</li>
</ol>
</li>
<li>RC는 <strong>SELECT마다</strong> 새 스냅샷, RR은 <strong>트랜잭션 시작 시</strong> 스냅샷 고정.</li>
</ul>
<p><strong>3) Vacuum</strong></p>
<ul>
<li>어떤 스냅샷에서도 보이지 않는 <strong>dead tuple</strong>을 제거해 공간 회수·성능 유지.</li>
<li>장수 트랜잭션이 많으면 Vacuum 지연 → 전체 성능 저하 가능.</li>
</ul>
<p><strong>4) Snapshot 생성 시점 차이</strong></p>
<table>
<thead>
<tr>
<th>격리 수준</th>
<th>Snapshot 생성 타이밍</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>RC</td>
<td>각 SELECT 실행 시</td>
<td>최신 커밋본을 읽음(Non-repeatable/Phantom 가능)</td>
</tr>
<tr>
<td>RR</td>
<td>트랜잭션 시작 시</td>
<td>고정 스냅샷</td>
</tr>
<tr>
<td>SRLZ</td>
<td>RR과 같음 + 커밋 시 충돌 감지</td>
<td>Write-Skew 포함 이상 방지</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-read-committed-vs-repeatable-read-실제-활용">3. READ COMMITTED vs REPEATABLE READ 실제 활용</h2>
<p>PostgreSQL에서는 대부분 <strong>RC로 유지할지</strong>, <strong>RR로 올릴지</strong>가 핵심 고민입니다.</p>
<h3 id="3-1-read-committedrc">3-1. READ COMMITTED(RC)</h3>
<ul>
<li>특성: SELECT마다 스냅샷 재취득 → 재조회 값/행 변동 가능. 기본값이며 빠르고 단순.</li>
<li>적합: <strong>목록/일반 조회</strong>처럼 최신 상태 반영이 자연스러운 API/화면.</li>
</ul>
<h3 id="3-2-repeatable-readrr">3-2. REPEATABLE READ(RR)</h3>
<ul>
<li>특성: 트랜잭션 전체 스냅샷 고정, Non-repeatable/대부분의 Phantom 차단. 단, <strong>Write-Skew 가능</strong>.</li>
<li>적합: <strong>폼 로드→검증→저장</strong> 등 트랜잭션 단위 일관성이 중요한 구간.</li>
</ul>
<h3 id="3-3-선택-가이드">3-3. 선택 가이드</h3>
<table>
<thead>
<tr>
<th>상황</th>
<th>추천 격리 수준</th>
</tr>
</thead>
<tbody><tr>
<td>조회·목록·일반 수정</td>
<td>RC</td>
</tr>
<tr>
<td>폼 로드→검증→저장 트랜잭션</td>
<td>RR</td>
</tr>
<tr>
<td>전역 제약/정합성 보장 + 경합 잦음</td>
<td>SRLZ + 재시도 로직(아래 5장 테스트 설계에서 환경 준비 팁 참조)</td>
</tr>
</tbody></table>
<hr>
<h1 id="4-전역db-격리-수준-vs-메서드별-어노테이션-적용을-어떻게-사용하나">4. 전역(DB) 격리 수준 vs 메서드별 어노테이션 적용을 어떻게 사용하나?</h1>
<h3 id="4-1-전역-상향은-지양예외적-상황만">4-1. 전역 상향은 지양(예외적 상황만)</h3>
<ul>
<li>규제/감사 요구, 단일 전용 DB, 코드 차원 제어가 구조적으로 불가한 경우 등 <strong>특수 상황</strong>만 고려.</li>
<li>가능하면 <strong>세션/ROLE 단위, 전용 커넥션 풀</strong>로 범위를 최소화.</li>
</ul>
<h3 id="4-2-메서드-단위-적용권장">4-2. 메서드 단위 적용(권장)</h3>
<ul>
<li>전역은 <strong>RC 유지</strong>, 특정 구간만 <code>RR/SRLZ</code>로 격리 강화.</li>
</ul>
<pre><code class="language-java">@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processCriticalOperation() { ... }

@Transactional(isolation = Isolation.SERIALIZABLE)
public void settleLedger() { ... }
</code></pre>
<ul>
<li>고격리 전용 DataSource/Hikari Pool로 <strong>분리 가능</strong>.</li>
<li><code>SERIALIZABLE</code> 사용 시 <strong>재시도 로직</strong>은 필수(커밋 충돌 대비).</li>
</ul>
<hr>
<h1 id="5-테스트-대상-·-범위-·-방법-non-repeatable-read--phantom-read">5. 테스트 대상 · 범위 · 방법 (Non-repeatable Read / Phantom Read)</h1>
<h3 id="5-1-작성-의도--결정-로그">5-1. 작성 의도 &amp; 결정 로그</h3>
<ul>
<li>초기 계획: <strong>RC/RR × Non-repeatable/Phantom</strong> 2×2 전체 검증.</li>
<li>실행 중 변경: <strong>H2의 RR ≠ PostgreSQL RR(Snapshot Isolation)</strong> → <strong>PG 보장 증명</strong>으로 쓰기 부적절.<ul>
<li>RR 요청을 <strong>JDBC SERIALIZABLE로 승격</strong>하는 트릭 사용(오해 소지).</li>
<li>PG 직렬화 충돌은 보통 <strong>40001</strong>인데 H2의 예외/락 판단이 달라 학습 자료로 부정확.</li>
<li>엔진/버전/옵티마이저에 따라 결과 변동 위험.</li>
</ul>
</li>
<li>최종 방침: <strong>RC 현상(Non-repeatable/Phantom “발생”)만 시연</strong>, RR/SRLZ 보장은 <strong>문헌 요약</strong>으로 처리.<ul>
<li>동일 시나리오의 <strong>PG(Testcontainers)</strong> 실습은 별도 글에서 재현 예정.</li>
</ul>
</li>
</ul>
<h3 id="5-2-실험-설계-매트릭스최종">5-2. 실험 설계 매트릭스(최종)</h3>
<table>
<thead>
<tr>
<th>케이스</th>
<th>실행 환경</th>
<th>블로그 포함</th>
<th>결과/메모</th>
</tr>
</thead>
<tbody><tr>
<td><strong>NR @ RC</strong></td>
<td>H2</td>
<td>포함(로그/코드)</td>
<td>1차 조회 후 T2 커밋 → 2차 조회 값 변경(<strong>NR 발생</strong>). 동일 커넥션 사용으로 <strong>캐시 영향 배제</strong>.</td>
</tr>
<tr>
<td><strong>NR @ RR</strong></td>
<td>H2 (RR→JDBC <strong>SER</strong> 승격)</td>
<td>미포함</td>
<td>의미 혼동 방지. H2 RR≠PG RR(SI). <strong>보장 증명은 PG Testcontainers</strong>로.</td>
</tr>
<tr>
<td><strong>PH @ RC</strong></td>
<td>H2</td>
<td>포함(로그/코드)</td>
<td>1차 COUNT 후 T2 INSERT/커밋 → 2차 COUNT 증가(<strong>Phantom 발생</strong>). JPA/JDBC 일치 검증.</td>
</tr>
<tr>
<td><strong>PH @ RR</strong></td>
<td>H2 (RR→JDBC <strong>SER</strong> 승격)</td>
<td>미포함</td>
<td>동일 사유로 제외. 문헌 요약 + 추후 PG 실습.</td>
</tr>
</tbody></table>
<h3 id="5-3-구현-포인트--어떤-함수기능을-어떻게-썼나-코드-기반-합본">5-3) 구현 포인트 — 어떤 함수/기능을 어떻게 썼나 (코드 기반, 합본)</h3>
<blockquote>
<p>NOTE-1 (H2 전용 트릭): 본 시연에서는 H2의 의미 차이를 보완하려고 toJdbcIso()에서 RR 요청을 JDBC SERIALIZABLE로 승격합니다. PostgreSQL 실습(Testcontainers)에서는 RR→RR, SRLZ→SRLZ 그대로 매핑하세요.</p>
<p><strong>NOTE-2 (타이밍)</strong>: <code>conn.setTransactionIsolation(...)</code>은 <strong>해당 트랜잭션에서 첫 SQL이 나가기 전에 반드시 적용</strong>되어야 스냅샷/락 컨텍스트가 일관합니다.</p>
</blockquote>
<hr>
<h3 id="①-트랜잭션-경계--격리수준-지정-intx--intxquiet">① 트랜잭션 경계 &amp; 격리수준 지정: <code>inTx</code> / <code>inTxQuiet</code></h3>
<ul>
<li><strong>목적</strong>: T1/T2 각각의 격리수준과 커밋 타이밍을 <strong>정밀 제어</strong>.</li>
<li><strong>방법</strong>: <code>TransactionTemplate</code> + <code>Session#doWork</code>로 <strong>JPA가 쓰는 동일 커넥션</strong>에서 JDBC 격리수준을 직접 설정.</li>
</ul>
<pre><code class="language-java">void inTx(int springIsolation, String who, Runnable work) {
    TransactionTemplate tt = new TransactionTemplate(tm);
    out(who, &quot;BEGIN (requestIsolation=%s)&quot;, springIsolation);

    tt.execute(status -&gt; {
        em.unwrap(Session.class).doWork(conn -&gt; {
            // 중요: 어떤 SQL도 나가기 전에 격리수준을 먼저 적용해야 동일 트랜잭션 스냅샷이 보장됩니다.
            int prev = conn.getTransactionIsolation();
            int eff  = toJdbcIso(springIsolation); // H2 시연 안정화를 위한 RR→SER 승격
            conn.setTransactionIsolation(eff);

            // URL의 DEFAULT_LOCK_TIMEOUT과 중복이지만, 세션 단위 보장을 위해 한 번 더 설정 (무해)
            try (var st = conn.createStatement()) { st.execute(&quot;SET LOCK_TIMEOUT 2000&quot;); }

            try {
                work.run();
            } finally {
                conn.setTransactionIsolation(prev);
            }
        });
        return null;
    });

    out(who, &quot;COMMIT&quot;);
}

private int toJdbcIso(int springIsolation) {
    return switch (springIsolation) {
        case TransactionDefinition.ISOLATION_READ_COMMITTED  -&gt; Connection.TRANSACTION_READ_COMMITTED;
        case TransactionDefinition.ISOLATION_REPEATABLE_READ -&gt; Connection.TRANSACTION_SERIALIZABLE; // H2 트릭
        case TransactionDefinition.ISOLATION_SERIALIZABLE    -&gt; Connection.TRANSACTION_SERIALIZABLE;
        default -&gt; Connection.TRANSACTION_READ_COMMITTED;
    };
}

// 필요 시: 람다에서 체크예외 억제용 등 간단 래퍼
void inTxQuiet(int springIsolation, Runnable work) {
    inTx(springIsolation, &quot;TX&quot;, work);
}
</code></pre>
<hr>
<h3 id="②-같은-물리-커넥션-고정-withsameconn">② “같은 물리 커넥션” 고정: <code>withSameConn</code></h3>
<ul>
<li><strong>왜</strong>: <strong>1차 캐시/세션 차이</strong>를 배제하고, <strong>스냅샷·락 컨텍스트</strong>를 JPA/JDBC 간 일치.</li>
</ul>
<pre><code class="language-java">private &lt;T&gt; T withSameConn(Function&lt;Connection, T&gt; f) {
    return em.unwrap(Session.class).doReturningWork(f::apply);
}
</code></pre>
<hr>
<h3 id="③-캐시-영향-제거--이중-검증">③ 캐시 영향 제거 + 이중 검증</h3>
<ul>
<li><strong>의도</strong>: Non-repeatable/Phantom이 <strong>진짜 DB 스냅샷 변화</strong>인지 확인.</li>
</ul>
<pre><code class="language-java">em.clear();
String secondJpa  = em.find(Post.class, postId).getTitle();
String secondJdbc = readTitleJdbc(postId); // 같은 커넥션에서 직접 JDBC
assertEquals(secondJpa, secondJdbc);       // 캐시 영향 아님
</code></pre>
<hr>
<h3 id="④-정확한-타이밍-제어-cyclicbarrier--countdownlatch--예외-로깅">④ 정확한 타이밍 제어: <code>CyclicBarrier</code> &amp; <code>CountDownLatch</code> (+ 예외 로깅)</h3>
<ul>
<li><strong>시나리오</strong>: <code>T1 1차 조회→대기 → T2 갱신·커밋 → T1 2차 조회</code>.</li>
</ul>
<pre><code class="language-java">static void await(CyclicBarrier b, String who, String where) {
    try {
        out(who, &quot;⏸ barrier @%s (await)&quot;, where);
        b.await();
        out(who, &quot;▶ proceed @%s (passed)&quot;, where);
    } catch (InterruptedException | BrokenBarrierException e) {
        out(who, &quot;❗ barrier failed @%s : %s&quot;, where, e.toString());
        Thread.currentThread().interrupt();
        throw new RuntimeException(e);
    }
}
</code></pre>
<hr>
<h3 id="⑤-픽스처-생성-newpostfixture-등">⑤ 픽스처 생성: <code>newPostFixture()</code> 등</h3>
<ul>
<li><strong>포인트</strong>: 재현 가능한 상태(예: <code>posts.title=&quot;HELLO&quot;</code>, 댓글 0건) 보장을 위해 <code>persist()</code> 후 <strong>즉시 <code>flush()</code>로</strong> DB 반영/ID 확보.</li>
</ul>
<pre><code class="language-java">private Long newPostFixture() {
    final Long[] id = new Long[1];
    inTxQuiet(TransactionDefinition.ISOLATION_READ_COMMITTED, () -&gt; {
        // Member/Store/Category 등 최소 데이터 생성
        // ...
        em.persist(post);
        em.flush();           // ID/상태 보장
        id[0] = post.getId();
    });
    return id[0];
}
</code></pre>
<hr>
<h3 id="⑥-병행-실행-프레임-스레드-이름-지정">⑥ 병행 실행 프레임: 스레드 이름 지정</h3>
<ul>
<li><strong>가독성</strong>: 로그 분석 편의를 위해 스레드 이름을 부여.</li>
</ul>
<pre><code class="language-java">Thread t1 = new Thread(() -&gt; inTx(TransactionDefinition.ISOLATION_READ_COMMITTED, &quot;T1-RC&quot;, () -&gt; {
    // 1차 조회 → barrier 대기 → 2차 조회
}), &quot;T1-RC&quot;);

Thread t2 = new Thread(() -&gt; inTx(TransactionDefinition.ISOLATION_READ_COMMITTED, &quot;T2-updater&quot;, () -&gt; {
    // UPDATE/INSERT → COMMIT
}), &quot;T2-updater&quot;);

t1.start(); t2.start();
t1.join();  t2.join();
</code></pre>
<hr>
<h3 id="⑦-읽기-헬퍼-동일-커넥션에서-jpajdbc-동시-제공">⑦ 읽기 헬퍼: 동일 커넥션에서 JPA/JDBC 동시 제공</h3>
<pre><code class="language-java">String readTitleJdbc(long postId) {
    return withSameConn(conn -&gt; {
        try (PreparedStatement ps = conn.prepareStatement(&quot;select title from posts where id=?&quot;)) {
            ps.setLong(1, postId);
            try (ResultSet rs = ps.executeQuery()) { rs.next(); return rs.getString(1); }
        } catch (SQLException e) { throw new RuntimeException(e); }
    });
}

int countCommentsJdbc(Long postId) {
    return withSameConn(conn -&gt; {
        try (PreparedStatement ps = conn.prepareStatement(&quot;select count(*) from comments where post_id=?&quot;)) {
            ps.setLong(1, postId);
            try (ResultSet rs = ps.executeQuery()) { rs.next(); return rs.getInt(1); }
        } catch (SQLException e) { throw new RuntimeException(e); }
    });
}

int countCommentsJPA(Long postId) {
    Long cnt = em.createQuery(
        &quot;select count(c) from Comment c where c.post.id = :pid&quot;, Long.class)
        .setParameter(&quot;pid&quot;, postId)
        .getSingleResult();
    return cnt.intValue();
}
</code></pre>
<hr>
<h3 id="⑧-시나리오-구현-요약테스트-메서드-기준">⑧ 시나리오 구현 요약(테스트 메서드 기준)</h3>
<ul>
<li><strong>Non-repeatable Read @ RC — <code>nonRepeatable_RC_occurs_STABLE</code></strong><ol>
<li>T1: 1차 조회(<code>HELLO</code>) → Barrier 대기</li>
<li>T2: <code>UPDATE title=&#39;UPDATED-...&#39;</code> → <strong>COMMIT</strong></li>
<li>T1: <code>em.clear()</code> 후 2차 조회(JPA/JDBC 동일) → <strong>값 변경 확인</strong></li>
<li><code>assertNotEquals(first, secondJpa)</code></li>
</ol>
</li>
<li><strong>Phantom Read @ RC — <code>phantom_RC_occurs_STABLE</code></strong><ol>
<li>T1: <code>COUNT(*)</code> 1차 조회(0) → Barrier 대기</li>
<li>T2: 동일 조건 <code>INSERT</code> → <strong>COMMIT</strong></li>
<li>T1: 동일 커넥션에서 2차 <code>COUNT(*)</code> → <strong>증가 확인</strong></li>
<li><code>assertTrue(c2 &gt; c1)</code></li>
</ol>
</li>
</ul>
<hr>
<h3 id="5-4-시나리오-1--non-repeatable-read--rc-nonrepeatable_rc_occurs_stable">5-4) 시나리오 1 — Non-repeatable Read @ RC (<code>nonRepeatable_RC_occurs_STABLE</code>)</h3>
<p><strong>구현 흐름</strong></p>
<ol>
<li><strong>픽스처</strong>: <code>newPostFixture()</code> → <code>posts(id, title=&quot;HELLO&quot;)</code></li>
<li><strong>T1(RC)</strong>: 1차 조회 → <strong>Barrier 대기</strong></li>
<li><strong>T2(RC)</strong>: <code>UPDATE title=&#39;UPDATED-…&#39;</code> → <strong>COMMIT</strong></li>
<li><strong>T1</strong>: <code>em.clear()</code> 후 <strong>동일 커넥션</strong>에서 JPA/JDBC 2차 조회</li>
<li><strong>검증</strong><ul>
<li><code>secondJpa == secondJdbc</code> → <strong>캐시 영향 아님</strong></li>
<li><code>first != secondJpa</code> → <strong>NR 발생</strong></li>
</ul>
</li>
</ol>
<p><strong><img src="https://velog.velcdn.com/images/minpractice_jhj/post/bcf229a7-7bc0-4101-819d-b564857120c2/image.png" alt="">
타임라인(로그 해석)</strong></p>
<pre><code>[T1-RC] first=&#39;HELLO&#39;             ← 1차 조회(스냅샷 S1)
[T2-updater] updated=&#39;UPDATED-...&#39;
[T2-updater] COMMIT               ← T2 커밋
[T1-RC] second JPA=&#39;UPDATED-...&#39;, JDBC=&#39;UPDATED-...&#39;
ASSERT: NR @ RC -&gt; &#39;HELLO&#39; vs &#39;UPDATED-...&#39;
</code></pre><p><strong>핵심 코드</strong></p>
<pre><code class="language-java">first[0] = em.find(Post.class, postId).getTitle(); // &quot;HELLO&quot;
await(barrier, &quot;T1-RC&quot;, &quot;after-first-read&quot;);

p.update(&quot;UPDATED-...&quot;, p.getContent());           // in T2
em.flush();                                        // inTx 종료 시 COMMIT

em.clear();                                        // 캐시 제거
secondJpa[0]  = em.find(Post.class, postId).getTitle();
secondJdbc[0] = readTitleJdbc(postId);             // 동일 커넥션 JDBC

assertEquals(secondJpa[0], secondJdbc[0]);         // 캐시 아님
assertNotEquals(first[0], secondJpa[0]);           // NR 발생
</code></pre>
<hr>
<h3 id="5-5-시나리오-2--phantom-read--rc-phantom_rc_occurs_stable">5-5) 시나리오 2 — Phantom Read @ RC (<code>phantom_RC_occurs_STABLE</code>)</h3>
<p><strong>구현 흐름</strong></p>
<ol>
<li><strong>픽스처</strong>: 대상 <code>post_id</code> 댓글 0건</li>
<li><strong>T1(RC)</strong>: <code>COUNT(*)</code> 1차 조회 → <strong>Barrier 대기</strong></li>
<li><strong>T2(RC)</strong>: 동일 조건 <strong>INSERT</strong> → <strong>COMMIT</strong></li>
<li><strong>T1</strong>: <strong>동일 커넥션</strong>에서 JPA/JDBC 2차 <code>COUNT(*)</code></li>
<li><strong>검증</strong><ul>
<li><code>c2Jpa == c2Jdbc</code> → <strong>캐시 영향 아님</strong></li>
<li><code>c2 &gt; c1</code> → <strong>Phantom 발생</strong>
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/c7e78dcb-4253-44f1-902e-4ac71dbf4c4b/image.png" alt=""></li>
</ul>
</li>
</ol>
<p><strong>타임라인(로그 해석)</strong></p>
<pre><code>[T1-RC] first count JPA=0, JDBC=0   ← 1차 카운트(스냅샷 S1)
[T2-inserter] COMMIT                 ← 새 댓글 INSERT
[T1-RC] second count JPA=1, JDBC=1
ASSERT: PH @ RC -&gt; 0 -&gt; 1
</code></pre><p><strong>핵심 코드</strong></p>
<pre><code class="language-java">c1Jpa.set(countCommentsJPA(postId));
c1Jdbc.set(countCommentsJdbc(postId));            // 동일 커넥션 JDBC
await(barrier, &quot;T1-RC&quot;, &quot;after-first-count&quot;);

em.persist(Comment.builder().post(post).member(m).content(&quot;hi&quot;).build()); // in T2
em.flush();                                      // inTx 종료 시 COMMIT

c2Jpa.set(countCommentsJPA(postId));
c2Jdbc.set(countCommentsJdbc(postId));

assertEquals(c2Jpa.get(), c2Jdbc.get());          // 캐시 아님
assertTrue(c2Jpa.get() &gt; c1Jpa.get());            // PH 발생
</code></pre>
<h3 id="5-6rr-테스트를-본문에서-뺀-이유요약">5-6)RR 테스트를 본문에서 뺀 이유(요약)</h3>
<ul>
<li><strong>H2의 RR ≠ PostgreSQL RR(SI)</strong>: 보장/예외/락 동작이 다름.</li>
</ul>
<h3 id="1-h2의-rr-≠-postgresql의-rrsnapshot-isolation">1) H2의 RR ≠ PostgreSQL의 RR(Snapshot Isolation)</h3>
<ul>
<li><p><strong>PostgreSQL의 RR</strong>은 <strong>Snapshot Isolation(스냅샷 고정)</strong> 의미다.</p>
</li>
<li><p>반면 <strong>H2의 RR 의미/내부 동작은 동일하지 않다.</strong></p>
<p>  → 같은 테스트라도 <strong>보장 수준과 예외 양상이 PG와 다를 수 있음</strong>.</p>
</li>
</ul>
<h3 id="2-시연-편의상-rr→jdbc-serializable-승격-트릭을-사용">2) 시연 편의상 “RR→JDBC SERIALIZABLE 승격” 트릭을 사용</h3>
<ul>
<li><p>코드에서 RR 요청 시 <code>toJdbcIso()</code>로 <strong>JDBC <code>SERIALIZABLE</code>로 승격</strong>했다.</p>
</li>
<li><p>즉, H2에서의 RR 테스트는 <strong>실제로는 SERIALIZABLE 시나리오</strong>에 가깝다.</p>
<p>  → <strong>“RR 보장 증명”으로 오해될 수 있어</strong> 본문에 싣지 않음.</p>
</li>
</ul>
<h3 id="3-예외락-동작이-pg와-다름">3) 예외/락 동작이 PG와 다름</h3>
<ul>
<li>PostgreSQL의 직렬화 충돌은 보통 <strong>SQLSTATE 40001(<code>serialization_failure</code>)</strong>로 떨어진다.</li>
<li>H2는 <strong>예외 타입/메시지/락 판단 기준</strong>이 다를 수 있어 <strong>학습 자료로 부정확</strong>하다.</li>
</ul>
<h3 id="4-결과가-db버전옵티마이저에-따라-흔들릴-위험">4) 결과가 DB/버전/옵티마이저에 따라 흔들릴 위험</h3>
<ul>
<li>같은 개념 테스트라도 <strong>엔진 차이</strong>로 결과가 바뀔 수 있다.</li>
<li>블로그에 실으면 <strong>“PG에서 이렇게 보장된다”</strong>라는 잘못된 인상을 줄 수 있음.</li>
</ul>
<h3 id="5-그래서-선택한-편집-방침">5) 그래서 선택한 편집 방침</h3>
<ul>
<li>본문에서는 <strong>H2로 RC 현상(Non-repeatable / Phantom “발생”)만 시연</strong>해 독자가 <strong>현상 자체</strong>를 체감하게 한다.</li>
<li><strong>RR/Serializable의 보장은 공식 문서 기반으로 요약</strong>만 한다.</li>
<li><strong>진짜 증명은</strong> 추후 <strong>PostgreSQL(Testcontainers)</strong> 실습 글에서 같은 시나리오로 재현하여 싣는다.</li>
</ul>
<hr>
<h2 id="💬-postgresql-격리-수준-동작-방식--블로그--영상-인용">💬 PostgreSQL 격리 수준 동작 방식 — 블로그 &amp; 영상 인용</h2>
<p><a href="https://engineerinsight.tistory.com/354">Engineer Insight 블로그</a><a href="https://www.youtube.com/watch?v=bLLarZTrebU">쉬운코드 유튜브</a></p>
<blockquote>
<p>“PostgreSQL은 READ COMMITTED에서도 MVCC 기반의 Snapshot을 사용한다. 하지만 엄밀한 의미의 Snapshot Isolation은 REPEATABLE READ부터 적용된다.”</p>
<p>— <em>쉬운코드 유튜브</em></p>
</blockquote>
<blockquote>
<p>“READ COMMITTED에서는 각 SELECT가 실행될 때마다 새로운 snapshot을 가져오기 때문에, 동일 트랜잭션 내에서도 재조회 결과가 달라질 수 있다. REPEATABLE READ에서는 트랜잭션 시작 시점 snapshot을 고정해 snapshot isolation을 제공한다.”</p>
<p>— <em>Engineer Insight 블로그</em></p>
</blockquote>
<hr>
<h3 id="📌-lost-update--본-글에서는-제외">📌 Lost Update — 본 글에서는 제외</h3>
<p>본 글은 <strong>격리 수준·MVCC</strong>에 초점을 둡니다. Lost Update의 정의·재현·해결(비관적/낙관적 락)은</p>
<p><strong>다음 글: Lock과 동시성 제어(실무 편)</strong>에서 사례 코드와 함께 다룹니다.</p>
<hr>
<h3 id="📌-serializable-ssi">📌 Serializable (SSI)</h3>
<p>PostgreSQL의 <code>SERIALIZABLE</code>은 <strong>SSI(Serializable Snapshot Isolation)</strong> 방식으로, Write Skew를 포함한 anomaly를 방지합니다.</p>
<p>커밋 시 충돌이 감지되면 <strong><code>SQLSTATE 40001</code></strong>(serialization_failure)로 실패할 수 있으며, <strong>재시도 로직</strong>이 필요합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[7-2] [트랜잭션 전파 실제 적용기 — Pinup 최적화 기록]]]></title>
            <link>https://velog.io/@minpractice_jhj/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B2%94%EC%9C%84%EB%A5%BC-%EC%A4%84%EC%97%AC%EC%84%9C-%EC%B5%9C%EC%A0%81%ED%99%94-%EA%B8%B0%EB%A1%9D-%ED%95%80%EC%97%85-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@minpractice_jhj/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B2%94%EC%9C%84%EB%A5%BC-%EC%A4%84%EC%97%AC%EC%84%9C-%EC%B5%9C%EC%A0%81%ED%99%94-%EA%B8%B0%EB%A1%9D-%ED%95%80%EC%97%85-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Sat, 27 Sep 2025 15:23:36 GMT</pubDate>
            <description><![CDATA[<p>최근 목표는 <strong>트랜잭션을 공부 → 가설 세우기 → 실제 서비스에 적용 → 로그로 검증</strong>이었다.</p>
<p>이번 글은 그중 <strong>“범위(Scope)”</strong>에만 집중한다. 요지는 간단하다.</p>
<blockquote>
<p>업로드 같은 I/O는 트랜잭션 밖에서 먼저 끝내고, DB에는 URL만 짧게 반영한다.</p>
<p>그리고 <code>auto-commit=false</code> + <code>provider_disables_autocommit=true</code>로 <strong>커넥션 점유 구간을 최소화</strong>한다.</p>
</blockquote>
<hr>
<h2 id="0-사전-실험-요약한-줄씩">0) 사전 실험 요약(한 줄씩)</h2>
<ul>
<li>1️⃣ <code>@Transactional</code> 없이 베이스라인 측정 → <strong>커넥션을 언제 빌리는지</strong> 감 잡기</li>
<li>2️⃣ <code>REQUIRED</code> vs <code>REQUIRES_NEW</code> → <strong>경계 분리</strong>가 미치는 영향 확인</li>
<li>3️⃣ Self-invocation → <strong>프록시 미적용</strong> 구간 드러남</li>
<li>4️⃣ <code>auto-commit=false</code> + <code>hibernate.connection.provider_disables_autocommit=true</code> → <strong>점유 시간 큰 폭 감소</strong></li>
</ul>
<h2 id="이제-이-원리를-실제-핀업-흐름에-녹였다">이제 이 원리를 실제 <strong>핀업</strong> 흐름에 녹였다.</h2>
<h2 id="1-ab-흐름-정의">1) A/B 흐름 정의<img src="https://velog.velcdn.com/images/minpractice_jhj/post/50c138e0-6178-4992-ba1c-d614b7ad9a28/image.png" alt=""></h2>
<blockquote>
<p>그림 — 왼쪽: A안(업로드가 트랜잭션 안이라 active=1 유지), 오른쪽: B안(업로드 먼저, DB는 짧게 active=1→0). <code>auto-commit=false + provider_disables_autocommit=true</code> 적용.</p>
</blockquote>
<h3 id="a안기존">A안(기존)</h3>
<ul>
<li><strong>흐름:</strong> <code>[요청] → first SQL(=post save) → post_images save → (업로드) → thumbnail update → commit</code></li>
<li><strong>특징:</strong> 업로드가 트랜잭션 내부에 포함 → <strong>DB 커넥션 점유 시간이 길다.</strong></li>
</ul>
<h3 id="b안변경">B안(변경)</h3>
<ul>
<li><strong>흐름:</strong> <code>[요청] → (업로드만) → first SQL(=post save) → post_images save → thumbnail update → commit</code></li>
<li><strong>특징:</strong> 업로드를 <strong>트랜잭션 밖에서 먼저</strong> 끝내고, DB에는 <strong>URL만 짧게 반영</strong>한다.</li>
</ul>
<hr>
<h2 id="2-적용-설정">2) 적용 설정</h2>
<p>커넥션 지연획득을 돕고 트랜잭션 경계를 최소화하기 위해 다음만 적용했다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    hikari:
      auto-commit: false
  jpa:
    properties:
      hibernate.connection.provider_disables_autocommit: true
</code></pre>
<ul>
<li><strong><code>auto-commit=false</code></strong>: 커밋 시점을 애플리케이션이 통제.</li>
<li><strong><code>hibernate.connection.provider_disables_autocommit=true</code></strong>: 하이버네이트가 드라이버의 오토커밋을 건드리지 않아 <strong>커넥션 점유 구간 축소</strong>.</li>
</ul>
<hr>
<h2 id="3-변경-포인트-서비스-레벨">3) 변경 포인트 (서비스 레벨)</h2>
<h3 id="3-1-postimageservice--업로드저장-분리--보상-삭제">3-1) <code>PostImageService</code> — 업로드/저장 분리 + 보상 삭제</h3>
<p>기존에는 <strong>업로드 + DB 저장을 하나의 메서드</strong>에서 처리했다. 이를 아래 <strong>3개 역할</strong>로 분리했다(코드는 비공개, 메서드 역할만 명시).</p>
<ol>
<li><strong>업로드만 수행</strong>하고 URL 리스트만 반환 — <code>uploadImagesOnly(...)</code></li>
<li>업로드된 <strong>URL을 이용해 <code>post_images</code>만 DB 저장</strong> — <code>saveImageUrls(...)</code></li>
<li><strong>보상 삭제(Compensation)</strong>: DB 실패 시 업로드했던 <strong>URL 리스트로 S3 객체를 조용히 삭제</strong>(예외는 삼켜서 DB 예외를 덮지 않음) — <code>deleteS3ByUrlsQuietly(...)</code></li>
</ol>
<h3 id="3-2-postservice--전후-비교가-명확">3-2) <code>PostService</code> — 전/후 비교가 명확</h3>
<h3 id="a안기존--업로드가-트랜잭션-내부">A안(기존) — 업로드가 트랜잭션 내부</h3>
<pre><code class="language-java">@Transactional
public PostResponse createPost(MemberInfo memberInfo,
                               CreatePostRequest createPostRequest,
                               CreatePostImageRequest createPostImageRequest) {
    Post post = createPostEntity(memberInfo, createPostRequest);
    post = postRepository.save(post);

    appLogger.info(new InfoLog(&quot;게시글 생성 완료&quot;)
            .setStatus(&quot;201&quot;)
            .setTargetId(post.getId().toString())
            .addDetails(&quot;writer&quot;, post.getMember().getNickname(), &quot;title&quot;, post.getTitle()));

    // 업로드 + DB 저장이 같은 경계 안
    List&lt;PostImage&gt; postImages = postImageService.savePostImages(createPostImageRequest, post);

    if (!postImages.isEmpty()) {
        post.updateThumbnail(postImages.get(0).getS3Url());
    }
    return PostResponse.from(post);
}
</code></pre>
<h3 id="b안변경--업로드-먼저-db-실패-시-보상-삭제">B안(변경) — 업로드 먼저, DB 실패 시 보상 삭제</h3>
<pre><code class="language-java">@Timed(&quot;post.create&quot;)
@Transactional
public PostResponse createPost(MemberInfo memberInfo,
                               CreatePostRequest createPostRequest,
                               CreatePostImageRequest createPostImageRequest) {
    StepWatch sw = new StepWatch(&quot;PostService.createPost&quot;);

    // 1) 업로드만 먼저 (S3) — 트랜잭션 밖 I/O
    sw.start(&quot;1. upload only&quot;);
    List&lt;String&gt; uploadedUrls = postImageService.uploadImagesOnly(createPostImageRequest);
    sw.stop();

    try {
        // 2) post insert
        sw.start(&quot;2. post insert&quot;);
        Post post = createPostEntity(memberInfo, createPostRequest);
        post = postRepository.save(post);
        sw.stop();

        // 3) post_images DB 저장
        sw.start(&quot;3. postImages save&quot;);
        List&lt;PostImage&gt; postImages = postImageService.saveImageUrls(post, uploadedUrls);
        sw.stop();

        // 4) 썸네일 업데이트
        sw.start(&quot;4. thumbnail update&quot;);
        if (!postImages.isEmpty()) {
            post.updateThumbnail(postImages.get(0).getS3Url());
        }
        sw.stop();

        return PostResponse.from(post);

    } catch (Exception e) {
        // DB 단계 실패 시 → 이미 업로드된 S3 보상 삭제
        postImageService.deleteS3ByUrlsQuietly(uploadedUrls);
        throw e; // 원래 예외를 그대로 전파하여 트랜잭션 롤백
    }
}
</code></pre>
<p><strong>포인트</strong></p>
<ul>
<li>업로드는 <strong>트랜잭션 밖</strong>에서 선행되므로 <strong>DB 커넥션을 점유하지 않음</strong></li>
<li>DB 실패 시 <code>deleteS3ByUrlsQuietly(...)</code>로 <strong>S3 고아 객체 방지</strong></li>
<li><code>@Timed</code>와 <code>StepWatch</code>로 <strong>단계별 시간</strong>을 남겨 병목을 확인</li>
</ul>
<hr>
<h2 id="4-측정-결과-로그-기반">4) 측정 결과 (로그 기반)</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>Tx 길이*</th>
<th>DB 커넥션 점유**</th>
<th>전체 처리시간(StepWatch)</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>설정 전 · A안</td>
<td>≈448ms</td>
<td>≈431ms</td>
<td>≈402–406ms</td>
<td>업로드가 Tx 안에 포함</td>
</tr>
<tr>
<td>설정 전 · B안</td>
<td>≈533ms</td>
<td>≈178ms</td>
<td>≈479ms</td>
<td>업로드 먼저 수행</td>
</tr>
<tr>
<td>설정 후 · A안</td>
<td>≈641ms</td>
<td>≈607ms</td>
<td>≈589–592ms</td>
<td>Tx/DB 점유 모두 증가</td>
</tr>
<tr>
<td><strong>설정 후 · B안</strong></td>
<td><strong>≈208ms</strong></td>
<td><strong>≈39ms</strong></td>
<td><strong>≈194ms</strong></td>
<td>업로드 먼저 + 설정 적용</td>
</tr>
<tr>
<td>- <strong>Tx 길이:</strong> <code>JpaTransactionManager</code> 시작~커밋 로그 간격</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<p>** <strong>DB 점유:</strong> p6spy SQL 시작~커밋 로그 간격</p>
<p><strong>최적 비교(설정 전 · A안 → 설정 후 · B안)</strong></p>
<ul>
<li><strong>Tx:</strong> 448 → 208ms (<strong>−54%</strong>)</li>
<li><strong>DB 점유:</strong> 431 → 39ms (<strong>−91%</strong>)</li>
<li><strong>전체:</strong> 402 → 194ms (<strong>−52%</strong>)</li>
</ul>
<p>트랜잭션/커넥션 점유 시간 감소가 <strong>응답 시간 개선</strong>으로 직결되었다.</p>
<hr>
<h2 id="5-증빙-로그">5) 증빙 로그</h2>
<h3 id="설정-전-·-a안-업로드가-트랜잭션-안">설정 전 · A안 (업로드가 트랜잭션 안)</h3>
<p>핵심 포인트: 업로드가 트랜잭션 내부에 있어 <strong>커넥션이 긴 시간(active=1) 점유</strong>된다.</p>
<pre><code>18:00:06.210  Creating new transaction ... [PostService.createPost]

# (트랜잭션 시작 직후부터 DB 점유)
18:00:06.223  select ... from members where nickname=&#39;흥미로운 허브&#39;
18:00:06.333  insert into posts (...)
18:00:06.340  HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)   &lt;-- active=1 유지 시작

# (업로드가 Tx 안에서 수행됨 → DB 커넥션 계속 점유)
18:00:06.458  S3 PUT /pinup/post/2620c004-..._1697261010140.jpg
18:00:06.515  S3 200 OK
18:00:06.568  S3 PUT /pinup/post/5565cfb8-..._full-stack-developer.jpg
18:00:06.583  S3 200 OK

# (이미지 메타 저장 + 커밋)
18:00:06.618  insert into post_images (...)
18:00:06.622  insert into post_images (...)
18:00:06.658  commit
18:00:06.658  HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)   &lt;-- 커밋 후 반납
</code></pre><p><strong>요약</strong></p>
<ul>
<li>업로드 구간: <strong>active=1</strong> (트랜잭션 내부이므로 DB 커넥션 계속 점유)</li>
<li>DB 구간: 업로드 포함 전체가 <strong>하나의 긴 점유 구간</strong></li>
<li>측정값: <strong>DB 점유 ≈ 431ms, Tx ≈ 448ms, 전체 ≈ 402–406ms</strong></li>
</ul>
<hr>
<h3 id="설정-후-·-b안-업로드-먼저-db는-짧게">설정 후 · B안 (업로드 먼저, DB는 짧게)</h3>
<p>핵심 포인트: 업로드를 먼저 끝낸 뒤 <strong>필요한 순간에만 DB 커넥션을 잠깐</strong> 빌린다.</p>
<pre><code>18:37:31.792  Creating new transaction ... [PostService.createPost]

# (업로드 전용 구간) — DB 미사용
18:37:31.801  S3 PUT /pinup/post/3e140853-..._지도도.webp
18:37:31.823  S3 200 OK
18:37:31.851  S3 PUT /pinup/post/9d6ba0d9-..._통합본.jpg
18:37:31.929  S3 200 OK
18:37:31.930  HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)   &lt;-- 업로드 동안 active=0

# (DB 구간 진입) — 짧게 빌리고 바로 반납
18:37:31.961  select ... from members where nickname=&#39;흥미로운 허브&#39;
18:37:31.966  select ... from stores where id=9
18:37:31.975  HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)    &lt;-- DB 커넥션 점유 시작(스파이크)

18:37:31.974  insert into posts (...)
18:37:31.980  insert into post_images (...)
18:37:31.985  insert into post_images (...)
18:37:31.991  update posts set ... thumbnail_url=...
18:37:32.000  commit
18:37:32.000  HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)   &lt;-- 커밋 즉시 반납
</code></pre><p><strong>요약</strong></p>
<ul>
<li>업로드 구간: <strong>active=0</strong> (DB 커넥션 미점유)</li>
<li>DB 구간: 잠깐 <strong>active=1 → 커밋 직후 다시 0</strong></li>
<li>측정값: <strong>DB 점유 ≈ 39ms, Tx ≈ 208ms, 전체 ≈ 194ms</strong></li>
</ul>
<blockquote>
<p>위 Pool stats 라인은 업로드 종료/첫 SQL 직후/커밋 직후 등 스텝 경계에서 출력했다. 이 지점만 찍어도 active=0 → 1 → 0 패턴이 선명히 드러난다.</p>
</blockquote>
<hr>
<h2 id="6-결론">6) 결론</h2>
<ul>
<li><strong>원리:</strong> 업로드를 <strong>트랜잭션 경계 밖</strong>으로 분리하고, DB에는 <strong>URL만 짧게 반영</strong>한다.</li>
<li><strong>설정:</strong> <code>auto-commit=false</code> + <code>hibernate.connection.provider_disables_autocommit=true</code> 조합으로 <strong>커넥션 점유 구간</strong>을 줄인다.</li>
<li><strong>정합성:</strong> DB 실패 시 <strong>S3 보상 삭제</strong>로 고아 파일을 방지한다.</li>
<li><strong>효과:</strong> <strong>Tx −54%</strong>, <strong>DB 점유 −91%</strong>, <strong>전체 −52%</strong>. 최종 응답 시간이 의미 있게 단축되었다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[7-1] [트랜잭션 전파 개념]]]></title>
            <link>https://velog.io/@minpractice_jhj/Transactional-%EB%A1%9C%EA%B7%B8%EB%A1%9C-%EB%B3%B4%EB%8A%94-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98</link>
            <guid>https://velog.io/@minpractice_jhj/Transactional-%EB%A1%9C%EA%B7%B8%EB%A1%9C-%EB%B3%B4%EB%8A%94-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98</guid>
            <pubDate>Thu, 25 Sep 2025 15:21:44 GMT</pubDate>
            <description><![CDATA[<h1 id="스프링-트랜잭션-공부기-requiredrequires_new-·-self-invocation-·-hikari-지연-커넥션-실험-로그">스프링 트랜잭션 공부기: REQUIRED/REQUIRES_NEW · Self-Invocation · Hikari 지연 커넥션 (실험 로그)</h1>
<blockquote>
<p><strong>요약</strong></p>
<ul>
<li><code>@Transactional</code>은 <strong>프록시(proxy)</strong> 경유 시만 적용. <strong>자기호출(Self-Invocation)</strong> 은 <strong>미적용</strong>.</li>
<li><code>REQUIRES_NEW</code>는 <strong>외부 빈 호출</strong>일 때만 <strong>부모 Suspend → 자식 새 트랜잭션</strong>. 부모 미커밋은 자식에서 <strong>가시성 없음</strong>(FK 실패 가능).</li>
<li><code>HikariCP auto-commit=false</code> + <code>hibernate.connection.provider_disables_autocommit=true</code>로 <strong>첫 SQL 전까지 커넥션 미획득</strong>을 <strong>실제 로그</strong>로 확인(풀 점유/동시성에 유리).</li>
</ul>
</blockquote>
<details>
<summary><b>읽기 전에 — PostService 트랜잭션 로그 10초 요약</b></summary>

<table>
<thead>
<tr>
<th>로그 키워드</th>
<th>의미 한 줄</th>
</tr>
</thead>
<tbody><tr>
<td><code>Found thread-bound EntityManager ...</code></td>
<td>요청/스레드에 <strong>EM 바인딩</strong> → <strong>동일 EM 재사용</strong></td>
</tr>
<tr>
<td><code>Creating new transaction [PostService.createPost]: REQUIRED</code></td>
<td><code>@Transactional</code> 진입 → <strong>기존 참여/없으면 생성</strong></td>
</tr>
<tr>
<td><code>Exposing JPA transaction as JDBC ...</code></td>
<td>JPA 트랜잭션 <strong>JDBC 레벨 노출</strong>(Hibernate DB 통신 준비)</td>
</tr>
<tr>
<td><code>Participating in existing transaction</code></td>
<td>리포지토리는 <strong>상위 트랜잭션 참여</strong></td>
</tr>
<tr>
<td><code>Initiating transaction commit</code></td>
<td><strong>커밋 시작</strong>(이때부터 DB 반영)</td>
</tr>
<tr>
<td><code>Committing JPA transaction on EntityManager ...</code></td>
<td><strong>flush → commit</strong></td>
</tr>
<tr>
<td><code>Suspending current transaction, creating new transaction ...</code></td>
<td>(<strong>REQUIRES_NEW 정상</strong>) <strong>부모 Suspend → 자식 새 트랜잭션</strong></td>
</tr>
<tr>
<td><code>Closing ... in OpenEntityManagerInViewInterceptor</code></td>
<td><strong>응답 직전</strong> EM 정리</td>
</tr>
<tr>
<td></details></td>
<td></td>
</tr>
</tbody></table>
<hr>
<h2 id="목차">목차</h2>
<ol>
<li>베이스라인: <strong>서비스 @Transactional 없이</strong> 어떻게 흘러가는가</li>
<li>REQUIRED vs REQUIRES_NEW: <strong>부모 REQUIRED → 자식 REQUIRES_NEW 실패</strong>, <strong>부모 REQUIRED → 자식 REQUIRED 성공</strong></li>
<li><code>@Transactional</code>이 <strong>“무시”되는 경우</strong>: 자기호출(Self-Invocation) &amp; 접근제어자 이슈</li>
<li><strong>DB 커넥션 지연획득</strong>: <code>autoCommit=false</code> + <code>provider_disables_autocommit=true</code> 로그로 증명</li>
</ol>
<hr>
<h1 id="1-베이스라인-서비스-transactional-미사용">1) 베이스라인: 서비스 @Transactional 미사용</h1>
<blockquote>
<p>핵심 정리</p>
<ul>
<li>서비스 레벨 트랜잭션이 없으면, <strong>Repository 호출 단위</strong>로 <strong>짧은 트랜잭션이 각각 생성/커밋</strong>된다(부분 커밋 가능).</li>
<li>조회는 <strong>비트랜잭션</strong>과 <strong>읽기전용(readOnly) 짧은 트랜잭션</strong>이 혼재할 수 있다.</li>
<li>외부 I/O(S3 업로드)는 <strong>DB 트랜잭션과 무관</strong>하게 수행된다.</li>
</ul>
</blockquote>
<h3 id="실험-타임라인--로그-원문">실험 타임라인 &amp; 로그 (원문)</h3>
<h3 id="1-사전-인증별도-트랜잭션--게시글-트랜잭션과-무관">1) 사전 인증(별도 트랜잭션) — 게시글 트랜잭션과 무관</h3>
<pre><code>Creating new transaction with name [MemberService.isAccessTokenExpired]
No need to create transaction for [SimpleJpaRepository.findByEmailAndProviderTypeAndIsDeletedFalse]: This method is not transactional.
Completing transaction for [MemberService.isAccessTokenExpired]
Initiating transaction commit
Committing JPA transaction on EntityManager [...]
commit
Closing JPA EntityManager [...] after transaction
</code></pre><h3 id="2-게시글-생성-진입서비스-트랜잭션-해제-확인">2) 게시글 생성 진입(서비스 트랜잭션 해제 확인)</h3>
<pre><code>PostApiController : 게시글 생성 요청 수신: writer=흥미로운 허브, title=test
TX-PLAY: baseline - PostService.createPost (no @Transactional)
</code></pre><h3 id="3-조회-단계비트랜잭션읽기전용-트랜잭션-혼재">3) 조회 단계(비트랜잭션/읽기전용 트랜잭션 혼재)</h3>
<pre><code>No need to create transaction for [SimpleJpaRepository.findByNickname]: This method is not transactional.   // 비트랜잭션 조회
Creating new transaction with name [SimpleJpaRepository.findById] ... ,readOnly                         // 읽기전용 짧은 트랜잭션
Completing transaction for [SimpleJpaRepository.findById]
Initiating transaction commit
Committing JPA transaction on EntityManager [...]
commit
</code></pre><h3 id="4-posts-저장--①-짧은-쓰기-트랜잭션-1회">4) posts 저장 — (①) <strong>짧은 쓰기 트랜잭션 1회</strong></h3>
<pre><code>Creating new transaction with name [SimpleJpaRepository.save]
insert into posts (...)
Completing transaction for [SimpleJpaRepository.save]
Initiating transaction commit
Committing JPA transaction on EntityManager [...]
commit
</code></pre><h3 id="5-이미지-업로드s3--애플리케이션-외부-io">5) 이미지 업로드(S3) — <strong>애플리케이션 외부 I/O</strong></h3>
<pre><code>S3Service.uploadFile ... &quot;이미지 업로드 성공&quot; (HTTP 200)   // DB 트랜잭션과 무관
</code></pre><h3 id="6-post_images-저장--②-짧은-쓰기-트랜잭션-1회">6) post_images 저장 — (②) <strong>짧은 쓰기 트랜잭션 1회</strong></h3>
<pre><code>Creating new transaction with name [SimpleJpaRepository.saveAll]
insert into post_images (...)      // 1건
insert into post_images (...)      // 1건
Completing transaction for [SimpleJpaRepository.saveAll]
Initiating transaction commit
Committing JPA transaction on EntityManager [...]
commit
</code></pre><p><strong>한 줄 결론</strong></p>
<p>서비스 레벨 <code>@Transactional</code> 없이 진행되어, <code>save</code>/<code>saveAll</code> 호출마다 <strong>각각 별도의 트랜잭션 생성→커밋</strong>이 명확. 일부 조회는 완전 비트랜잭션, 일부는 readOnly 짧은 트랜잭션으로 동작.</p>
<hr>
<h1 id="2-required-vs-requires_new">2) REQUIRED vs REQUIRES_NEW</h1>
<h2 id="실험-a-부모-required-→-자식-requires_new-실패">실험 A: <strong>부모 REQUIRED → 자식 REQUIRES_NEW (실패)</strong></h2>
<h3 id="0-사전-인증-독립-트랜잭션">0) 사전 인증 (독립 트랜잭션)</h3>
<pre><code>2025-09-23T16:24:00.111+09:00 DEBUG ... JpaTransactionManager : Creating new transaction with name [MemberService.isAccessTokenExpired]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2025-09-23T16:24:00.115+09:00 TRACE ... TransactionInterceptor : Getting transaction for [MemberService.isAccessTokenExpired]
2025-09-23T16:24:00.251+09:00 INFO  p6spy : select ... from members where email=&#39;pinup0106@gmail.com&#39; and provider_type=&#39;GOOGLE&#39; and not(is_deleted)
2025-09-23T16:24:00.252+09:00 DEBUG ... JpaTransactionManager : Committing JPA transaction on EntityManager [...]
2025-09-23T16:24:00.254+09:00 INFO  p6spy : commit
</code></pre><h3 id="1-게시글-생성-진입-부모-트랜잭션-required-시작">1) 게시글 생성 진입 (부모 트랜잭션 REQUIRED 시작)</h3>
<pre><code>2025-09-23T16:24:00.325+09:00 INFO  PostApiController : 게시글 생성 요청 수신: writer=흥미로운 허브, title=test REQUIRES_NEW
2025-09-23T16:24:00.326+09:00 DEBUG ... JpaTransactionManager : Creating new transaction with name [PostService.createPost]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2025-09-23T16:24:00.327+09:00 INFO  PostService : TX-PLAY: baseline - PostService.createPost ( @Transactional)
</code></pre><h3 id="2-조회-부모-required-참여">2) 조회 (부모 REQUIRED 참여)</h3>
<pre><code>2025-09-23T16:24:00.327+09:00 TRACE ... No need to create transaction for [SimpleJpaRepository.findByNickname]
2025-09-23T16:24:00.333+09:00 INFO  p6spy : select ... from members where nickname=&#39;흥미로운 허브&#39;
2025-09-23T16:24:00.335+09:00 TRACE ... Getting transaction for [SimpleJpaRepository.findById]
2025-09-23T16:24:00.337+09:00 INFO  p6spy : select ... from stores where id=9
</code></pre><h3 id="3-게시글-저장-부모-트랜잭션-내-insert">3) 게시글 저장 (부모 트랜잭션 내 INSERT)</h3>
<pre><code>2025-09-23T16:24:00.339+09:00 TRACE ... Getting transaction for [SimpleJpaRepository.save]
2025-09-23T16:24:00.400+09:00 INFO  p6spy : insert into posts (...) values (&#39;test REQUIRES_NEW&#39;,&#39;2025-09-23T16:24:00.361+0900&#39;,false,0,1,9,NULL,&#39;test REQUIRES_NEW&#39;,0)
2025-09-23T16:24:00.415+09:00 INFO  StructuredLogger : 게시글 생성 완료 targetId=10009
</code></pre><blockquote>
<p>posts.id=10009 생성(아직 부모 커밋 전)</p>
</blockquote>
<h3 id="4-이미지-업로드-외부-io">4) 이미지 업로드 (외부 I/O)</h3>
<pre><code>2025-09-23T16:24:00.482+09:00 DEBUG ... Sending Request: PUT /pinup/post/28f94855-....jpg
2025-09-23T16:24:00.509+09:00 DEBUG ... Received successful response: 200
2025-09-23T16:24:00.533+09:00 INFO  StructuredLogger : 이미지 업로드 성공 url=http://127.0.0.1:4566/pinup/post/28f94855-....jpg
</code></pre><h3 id="5-이미지-메타-저장-자식-requires_new-시작-→-부모-suspend">5) 이미지 메타 저장 (<strong>자식 REQUIRES_NEW</strong> 시작 → <strong>부모 Suspend</strong>)</h3>
<pre><code>2025-09-23T16:24:00.415+09:00 DEBUG ... Suspending current transaction, creating new transaction with name [PostImageService.savePostImages]
2025-09-23T16:24:00.418+09:00 INFO  PostImageService : TX-PLAY: baseline - PostImageService.savePostImages ( @Transactional REQUIRES_NEW)
2025-09-23T16:24:00.559+09:00 TRACE ... Getting transaction for [SimpleJpaRepository.saveAll]
2025-09-23T16:24:00.572+09:00 INFO  p6spy : insert into post_images (created_at,post_id,url) values (&#39;2025-09-23T16:24:00.560+0900&#39;,10009,&#39;http://127.0.0.1:4566/pinup/post/28f94855-....jpg&#39;)
2025-09-23T16:24:00.575+09:00 ERROR SqlExceptionHelper : ERROR: insert or update on table &quot;post_images&quot; violates foreign key constraint ...
Detail: Key (post_id)=(10009) is not present in table &quot;posts&quot;.
</code></pre><blockquote>
<p>왜 실패? 부모는 커밋 전이므로 자식 트랜잭션에서는 posts(10009)가 보이지 않음 → FK 위반</p>
</blockquote>
<h3 id="6-롤백-전파">6) 롤백 전파</h3>
<pre><code>2025-09-23T16:24:00.589+09:00 DEBUG ... Rolling back JPA transaction on EntityManager [...]   // 자식 REQUIRES_NEW 롤백
2025-09-23T16:24:00.592+09:00 DEBUG ... Resuming suspended transaction after completion of inner transaction
2025-09-23T16:24:00.592+09:00 TRACE ... Completing transaction for [PostService.createPost] after exception ...
2025-09-23T16:24:00.593+09:00 INFO  p6spy : rollback                                                // 부모도 롤백
</code></pre><p><strong>결론(실험 A)</strong></p>
<p>트랜잭션 경계를 분리(REQUIRES_NEW)하면 부모 미커밋 데이터가 자식에서 <strong>가시성 없음</strong> → FK 위반 → 실패 흐름이 <strong>로그로 명확히 증명</strong>.</p>
<hr>
<h2 id="실험-b-부모-required-→-자식-required-성공">실험 B: <strong>부모 REQUIRED → 자식 REQUIRED (성공)</strong></h2>
<h3 id="0-사전-인증-독립-트랜잭션-1">0) 사전 인증 (독립 트랜잭션)</h3>
<pre><code>2025-09-23T16:26:10.932+09:00 DEBUG ... Creating new transaction [MemberService.isAccessTokenExpired]
2025-09-23T16:26:11.066+09:00 INFO  p6spy : select ... from members where email=&#39;pinup0106@gmail.com&#39; and provider_type=&#39;GOOGLE&#39; and not(is_deleted)
2025-09-23T16:26:11.070+09:00 INFO  p6spy : commit
</code></pre><h3 id="1-게시글-생성-진입-부모-required-시작">1) 게시글 생성 진입 (부모 REQUIRED 시작)</h3>
<pre><code>2025-09-23T16:26:11.167+09:00 INFO  PostApiController : 게시글 생성 요청 수신: writer=흥미로운 허브, title=test REQUIRED
2025-09-23T16:26:11.168+09:00 DEBUG ... Creating new transaction with name [PostService.createPost]: PROPAGATION_REQUIRED
2025-09-23T16:26:11.169+09:00 INFO  PostService : TX-PLAY: baseline - PostService.createPost ( @Transactional)
</code></pre><h3 id="2-조회-부모-트랜잭션-참여">2) 조회 (부모 트랜잭션 참여)</h3>
<pre><code>2025-09-23T16:26:11.175+09:00 INFO  p6spy : select ... from members where nickname=&#39;흥미로운 허브&#39;
2025-09-23T16:26:11.181+09:00 INFO  p6spy : select ... from stores where id=9
</code></pre><h3 id="3-게시글-저장-부모-트랜잭션-내-insert-1">3) 게시글 저장 (부모 트랜잭션 내 INSERT)</h3>
<pre><code>2025-09-23T16:26:11.270+09:00 INFO  p6spy : insert into posts (...) values (&#39;test REQUIRED&#39;,&#39;2025-09-23T16:26:11.220+0900&#39;,false,0,1,9,NULL,&#39;test REQUIRED&#39;,0)
2025-09-23T16:26:11.284+09:00 INFO  StructuredLogger : 게시글 생성 완료 targetId=10010
</code></pre><h3 id="4-이미지-업로드-외부-io-1">4) 이미지 업로드 (외부 I/O)</h3>
<pre><code>2025-09-23T16:26:11.363+09:00 DEBUG ... Sending Request: PUT /pinup/post/7e2be727-....jpg
2025-09-23T16:26:11.392+09:00 DEBUG ... Received successful response: 200
2025-09-23T16:26:11.426+09:00 INFO  StructuredLogger : 이미지 업로드 성공 url=http://127.0.0.1:4566/pinup/post/7e2be727-....jpg
</code></pre><h3 id="5-이미지-메타-저장-자식-required-부모와-동일-트랜잭션-참여">5) 이미지 메타 저장 (<strong>자식 REQUIRED</strong>, 부모와 동일 트랜잭션 참여)</h3>
<pre><code>2025-09-23T16:26:11.285+09:00 INFO  PostImageService : TX-PLAY: baseline - PostImageService.savePostImages ( @Transactional REQUIRED)
2025-09-23T16:26:11.480+09:00 INFO  p6spy : insert into post_images (...) values (&#39;2025-09-23T16:26:11.472+0900&#39;,10010,&#39;http://127.0.0.1:4566/pinup/post/7e2be727-....jpg&#39;)
2025-09-23T16:26:11.483+09:00 INFO  p6spy : insert into post_images (...) values (&#39;2025-09-23T16:26:11.481+0900&#39;,10010,&#39;http://127.0.0.1:4566/pinup/post/04699b9c-....jpg&#39;)
2025-09-23T16:26:11.485+09:00 INFO  StructuredLogger : 이미지 저장 완료 count=2
</code></pre><blockquote>
<p>동일 트랜잭션이므로 부모 미커밋 posts(10010)가 즉시 가시 → FK OK</p>
</blockquote>
<h3 id="6-최종-커밋--후속-업데이트썸네일-등">6) 최종 커밋 &amp; 후속 업데이트(썸네일 등)</h3>
<pre><code>2025-09-23T16:26:11.485+09:00 TRACE ... Completing transaction for [PostService.createPost]
2025-09-23T16:26:11.508+09:00 INFO  p6spy : update posts set ... thumbnail_url=&#39;http://127.0.0.1:4566/pinup/post/7e2be727-....jpg&#39;, updated_at=&#39;2025-09-23T16:26:11.491+0900&#39;, version=1 where id=10010 and version=0
2025-09-23T16:26:11.517+09:00 INFO  p6spy : commit
</code></pre><p><strong>결론(실험 B)</strong></p>
<p>부모와 자식이 <strong>하나의 트랜잭션(REQUIRED)</strong> 에 참여하여, 부모 INSERT가 <strong>즉시 가시</strong> → FK 만족 → 전체 정상 커밋.</p>
<blockquote>
<p>블로그용 한 컷 요약</p>
<ul>
<li><strong>REQUIRED</strong>: 현재 트랜잭션에 <strong>참여</strong> → 미커밋 데이터라도 <strong>동일 트랜잭션 내 가시</strong> → FK/일관성 유지에 유리</li>
<li><strong>REQUIRES_NEW</strong>: <strong>새 트랜잭션</strong>(부모 suspend) → 부모 미커밋 데이터 <strong>가시성 없음</strong> → FK/유니크 제약 쉽게 실패</li>
</ul>
</blockquote>
<blockquote>
<p>설계 팁</p>
<ul>
<li>FK로 강결합된 <strong>부모-자식 쓰기</strong>는 <strong>동일 트랜잭션(REQUIRED)</strong> 으로 처리</li>
<li><strong>외부 I/O(S3 등)</strong> 는 <strong>AFTER_COMMIT 이벤트/비동기/아웃박스</strong>로 분리</li>
<li><code>REQUIRES_NEW</code>는 <strong>감사 로그/감사 테이블/재시도 단위</strong>처럼 <strong>완전히 별개</strong>인 업무에 신중 적용</li>
</ul>
</blockquote>
<hr>
<h1 id="3-transactional-이-무시되는-경우--자기호출self-invocation">3) @Transactional 이 “무시”되는 경우 — 자기호출(Self-Invocation)</h1>
<blockquote>
<p>핵심 정리</p>
<ul>
<li>Spring의 AOP는 <strong>프록시 경유 호출</strong>일 때만 적용된다.</li>
<li><strong>같은 클래스 내부</strong>에서 <code>this.persistAuditLog()</code>로 호출하면 프록시를 <strong>우회</strong> → <strong>어노테이션 미적용</strong>.</li>
<li><code>private/protected/static</code> 메서드는 프록시가 <strong>가로챌 수 없다</strong>(특히 static은 대상 아님).</li>
</ul>
</blockquote>
<h3 id="상황-코드-개요">상황 (코드 개요)</h3>
<pre><code class="language-java">// PostService.createPost(@Transactional) 내부에서 같은 클래스 메서드를 호출:
txPrivateNewProbe();    // private + REQUIRES_NEW
txProtectedNewProbe();  // protected + REQUIRES_NEW
txStaticNewProbe();     // static + REQUIRES_NEW
</code></pre>
<h3 id="네-로그에서-보인-증거무시-케이스">네 로그에서 보인 증거(무시 케이스)</h3>
<pre><code>... JpaTransactionManager : Found thread-bound EntityManager [...] for JPA transaction
... JpaTransactionManager : Participating in existing transaction
... TransactionInterceptor : Getting transaction for [SimpleJpaRepository.count]
// 기대했던 &quot;Suspending current transaction ...&quot; / &quot;Opened new EntityManager ...&quot; 없음
</code></pre><p><strong>결론</strong></p>
<p><code>REQUIRES_NEW</code>가 붙어 있어도 <strong>기존 트랜잭션 참여</strong>(=사실상 무시).</p>
<h3 id="성공군-프록시-경유-자기호출강제--정상-적용">성공군: 프록시 경유 자기호출(강제) — 정상 적용</h3>
<p><strong>설정</strong></p>
<pre><code class="language-java">@EnableAspectJAutoProxy(exposeProxy = true, proxyTargetClass = true)
</code></pre>
<ul>
<li><code>exposeProxy=true</code>: 현재 프록시를 <code>AopContext.currentProxy()</code>로 노출</li>
<li><code>proxyTargetClass=true</code>: CGLIB 클래스 프록시 강제</li>
</ul>
<p><strong>호출</strong></p>
<pre><code class="language-java">((PostService) AopContext.currentProxy()).txPublicNewProbe(); // public + @Transactional(REQUIRES_NEW)
</code></pre>
<p><strong>정상 동작 로그 시퀀스(대표)</strong></p>
<pre><code>... JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(931946576&lt;open&gt;)] for JPA transaction
... JpaTransactionManager : Suspending current transaction, creating new transaction with name [kr.co.pinup.posts.service.PostService.txPublicNewProbe]
... JpaTransactionManager : Opened new EntityManager [SessionImpl(64740231&lt;open&gt;)] for JPA transaction
... TransactionInterceptor : Getting transaction for [PostService.txPublicNewProbe]
... PostService : txPublicNewProbe (public, REQUIRES_NEW) start
... SimpleJpaRepository.count
... JpaTransactionManager : Initiating transaction commit
... JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(64740231&lt;open&gt;)]
... p6spy : commit | connection 1
... JpaTransactionManager : Resuming suspended transaction after completion of inner transaction
... JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(931946576&lt;open&gt;)] for JPA transaction
</code></pre><blockquote>
<p>체크리스트</p>
<ul>
<li><input disabled="" type="checkbox"> 로그에 <strong><code>Suspending current transaction …</code></strong> / <strong><code>Opened new EntityManager …</code></strong> 가 보이는가?</li>
<li><input disabled="" type="checkbox"> <code>p6spy</code>의 커넥션 번호가 <strong>부모/자식 분리</strong>되어 보이는가?</li>
<li><input disabled="" type="checkbox"> 내부 호출이면 대부분 <strong>미적용</strong>이다. 별도 빈 분리 또는 <code>AopContext</code> 경유를 고려.</li>
</ul>
</blockquote>
<hr>
<h1 id="4-db-커넥션-지연획득-auto-commit-최적화">4) DB 커넥션 지연획득 (Auto-commit 최적화)</h1>
<blockquote>
<p>TL;DR</p>
<p>목표: <strong>첫 SQL 실행 직전까지 커넥션 미획득</strong></p>
<p>설정:</p>
<pre><code class="language-yaml">spring:
  datasource:
    hikari:
      auto-commit: false
  jpa:
    properties:
      hibernate:
        connection:
          provider_disables_autocommit: true
</code></pre>
<p>전제: <strong>Hibernate 5.2.10+</strong></p>
</blockquote>
<h2 id="a-auto-committrue-조기-획득-패턴">A) <code>auto-commit=true</code> (조기 획득 패턴)</h2>
<blockquote>
<p>트랜잭션 직후에도 active=1 → 슬립 중에도 커넥션 점유</p>
</blockquote>
<pre><code>2025-09-17T17:10:20.913+09:00 DEBUG ... Creating new transaction with name [com.example.demo.service.UserService.join]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2025-09-17T17:10:20.920+09:00 DEBUG ... Exposing JPA transaction as JDBC [...]
2025-09-17T17:10:33.608+09:00 DEBUG ... HikariPool-1 - Pool stats (total=10/10, idle=9/10, active=1, waiting=0)   &lt;-- 슬립 중 active=1
2025-09-17T17:11:01.016+09:00 DEBUG ... org.hibernate.SQL : insert into users (email,name,id) values (?,?,default)
2025-09-17T17:11:41.085+09:00 DEBUG ... Committing JPA transaction on EntityManager [...]
</code></pre><h2 id="b-auto-commitfalse--provider_disables_autocommittrue-지연-획득-유지">B) <code>auto-commit=false</code> + <code>provider_disables_autocommit=true</code> (<strong>지연 획득 유지</strong>)</h2>
<blockquote>
<p>첫 SQL 전까지 active=0, SQL 시점에만 커넥션 빌림</p>
</blockquote>
<pre><code>2025-09-17T17:17:58.267+09:00 DEBUG ... Creating new transaction with name [com.example.demo.service.UserService.join]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2025-09-17T17:18:01.595+09:00 DEBUG ... HikariPool-1 - Pool stats (total=10/10, idle=10/10, active=0, waiting=0)   &lt;-- 슬립 중 active=0
2025-09-17T17:18:38.315+09:00 DEBUG ... org.hibernate.SQL : insert into users (email,name,id) values (?,?,default)  &lt;-- 첫 SQL
2025-09-17T17:19:01.602+09:00 DEBUG ... HikariPool-1 - Pool stats (total=10/10, idle=9/10, active=1, waiting=0)   &lt;-- SQL 직후 active=1
2025-09-17T17:19:18.364+09:00 DEBUG ... Committing JPA transaction on EntityManager [...]
</code></pre><blockquote>
<p>원리 요약</p>
<ul>
<li>JDBC 기본은 <code>autoCommit=true</code>라 프레임워크가 트랜잭션 경계를 맞추려면 커넥션을 <strong>일찍 빌릴 수 있음</strong>.</li>
<li><code>autoCommit=false</code> 이고 Hibernate에 <strong>“프로바이더가 관리한다”</strong>(= <code>provider_disables_autocommit=true</code>)고 알리면, Hibernate가 <code>setAutoCommit(false)</code>를 <strong>중복 호출하지 않음</strong> → <strong>첫 SQL 전까지 커넥션 미획득</strong>이 가능.</li>
</ul>
</blockquote>
<blockquote>
<p>언제 유용?</p>
<ul>
<li>요청 초반에 검증/캐싱/외부 API 대기/슬립 등 <strong>DB를 안 쓰는 준비 단계가 긴 경우</strong></li>
<li><strong>풀 사이즈가 빡빡</strong>하고 동시성을 끌어올려야 하는 경우</li>
<li>배치에서 <strong>전처리 구간 길고</strong> 일부 구간만 DB를 쓰는 경우</li>
</ul>
</blockquote>
<blockquote>
<p>주의/함정</p>
<ul>
<li>조기 flush, Lazy 즉시 해제 등으로 <strong>SQL이 일찍 실행</strong>되면 그 시점에 커넥션을 빌린다.</li>
<li>DB/드라이버별 <code>setAutoCommit</code> 비용 상이(일부는 네트워크 왕복).</li>
<li>하우스키퍼 로그 주기/환경에 따라 안 뜰 수 있음 → <strong>SQL 타임라인</strong>으로도 검증 가능.</li>
</ul>
</blockquote>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 글은 문서 설명이 아니라, <strong>핀업(Pin-Up) 프로젝트에서 직접 찍어낸 로그</strong>로 <code>@Transactional</code> 동작을 검증하고 <strong>설계 상의 함의</strong>를 정리한 기록이다.</p>
<ul>
<li><code>REQUIRED</code>와 <code>REQUIRES_NEW</code>의 <strong>가시성 차이</strong></li>
<li><strong>자기호출 무시</strong> 이슈와 해결 패턴</li>
<li><strong>지연 커넥션</strong>으로 <strong>풀 점유를 줄이는</strong> 방법까지, 모두 <strong>로그</strong>로 확인했다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[6편] DB 인덱스와 JPQL 최적화로 성능 ×39배 개선하기]]></title>
            <link>https://velog.io/@minpractice_jhj/6%ED%8E%B8-DB-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EC%99%80-JPQL-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A1%9C-%EC%84%B1%EB%8A%A5-39%EB%B0%B0-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@minpractice_jhj/6%ED%8E%B8-DB-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EC%99%80-JPQL-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A1%9C-%EC%84%B1%EB%8A%A5-39%EB%B0%B0-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 20 Aug 2025 08:51:14 GMT</pubDate>
            <description><![CDATA[<h3 id="전략-a단일-3개-vs-전략-b복합보조-1개-그리고-jpql-최적화까지">전략 A(단일 3개) vs 전략 B(복합+보조 1개), 그리고 JPQL 최적화까지</h3>
<hr>
<h2 id="들어가며">들어가며</h2>
<p>이번 글은 제가 Pinup 서비스 <strong><code>/post/list/{storeId}</code> API 병목을 실제로 어떻게 개선했는지</strong> 기록한 글입니다.</p>
<p>특히 강조하고 싶은 점은, 제가 단순히 쿼리 튜닝부터 시작한 게 아니라는 겁니다.<br>먼저 Tomcat Thread / HikariCP 커넥션 풀 / JVM Heap 같은 서버·DB 설정을 점검했습니다.  </p>
<hr>
<h3 id="목표">목표</h3>
<p>“지금은 프리티어지만, <strong>내 서비스의 성격(=커뮤니티형)</strong>을 기준으로 미리 성능 설계를 잡고,<br>로컬에서 최대한 현실적인 부하 테스트를 해보자.”</p>
<ul>
<li>운영이 저사양(t2.micro)이므로, 그 수준을 그대로 테스트하는 건 의미 없다고 판단.</li>
<li><strong>서비스 구조(=읽기 중심 + 일부 burst 쓰기)</strong>가 어떤 병목을 만드는지 먼저 이해해야 확장 가능.</li>
<li>따라서 운영 서버 기준이 아니라 <strong>서비스 성격 기준</strong>으로 설정을 잡았다.</li>
</ul>
<hr>
<h3 id="설정-기준">설정 기준</h3>
<ul>
<li><p>로컬은 여유가 있으니, 실제 커뮤니티처럼 <strong>100명 이상 동시 요청 상황</strong>을 시뮬레이션</p>
</li>
<li><p>운영은 추후 <strong>t3.medium 이상 업그레이드 예정</strong>이므로 이식 가능한 기준을 지금부터 마련</p>
<blockquote>
<p>즉, 지금 하는 건 단순 “프리티어 맞춤”이 아니라,  <strong>향후 확장에도 그대로 가져갈 수 있는 기반</strong>을 다져두는 작업이다.</p>
</blockquote>
</li>
</ul>
<hr>
<h3 id="서비스-개요">서비스 개요</h3>
<p>PinUp은 실시간 팝업 리스트와 위치를 제공하며 사용자 참여를 유도하는 커뮤니티 플랫폼입니다.</p>
<ul>
<li><p><strong>기술 스택</strong>: Spring Boot, Spring Security, PostgreSql, AWS EC2, Docker  </p>
</li>
<li><p><strong>기능 구조</strong>:</p>
<ul>
<li>실시간 팝업 리스트 조회 (읽기 중심, 반복성 높음)  </li>
<li>후기 작성, 좋아요, 댓글 (쓰기 중심, 간헐적)  </li>
<li>OAuth2 로그인 및 세션 유지  </li>
</ul>
</li>
<li><p><strong>운영 환경</strong>: AWS EC2 프리티어 (t2.micro)</p>
</li>
</ul>
<hr>
<h3 id="내가-점검한-서버db-설정">내가 점검한 서버/DB 설정</h3>
<pre><code class="language-yaml"># Tomcat (Web Thread)
server.tomcat.max-threads: 32
server.tomcat.accept-count: 100
server.tomcat.connection-timeout: 15000

# HikariCP (DB Connection Pool)
spring.datasource.hikari.maximum-pool-size: 30
spring.datasource.hikari.minimum-idle: 10
spring.datasource.hikari.connection-timeout: 15000

# JVM Heap
-Xmx: 4096m
-Xms: 512m</code></pre>
<hr>
<h2 id="문제-정의와-초기-상황">문제 정의와 초기 상황</h2>
<ul>
<li><strong>대상 API:</strong> <code>GET /post/list/{storeId}</code></li>
<li><strong>서비스 특성:</strong> 커뮤니티형 (읽기 중심 + 댓글/좋아요 집계가 많음)</li>
<li><strong>초기 증상:</strong><ul>
<li>단일 요청도 9초+ (curl 실측)</li>
<li>10 VU 부하에서 p95가 50초까지 상승</li>
</ul>
</li>
</ul>
<p>초기 구현(문제 코드):</p>
<pre><code class="language-java">return posts.stream()
    .map(post -&gt; PostResponse.fromPostWithComments(
        post,
        commentRepository.countByPostId(post.getId()),
        postLikeRepository.existsByPostIdAndMemberId(post.getId(), member.getId())
    ))
    .collect(Collectors.toList());
</code></pre>
<ul>
<li><p>게시글 1건마다 2쿼리 (댓글 수, 좋아요 여부) → N+1 폭발</p>
</li>
<li><p>댓글이 많은 특성상 Full Scan + 반복 집계로 I/O 과다</p>
</li>
</ul>
<hr>
<h2 id="실험-환경">실험 환경</h2>
<ul>
<li><strong>데이터 세팅</strong><ul>
<li>스토어 100개</li>
<li>게시글: 스토어당 100개 → 총 1만</li>
<li>댓글: 게시글당 50~200 랜덤 → 총 24만+</li>
<li>좋아요: 랜덤 분포</li>
</ul>
</li>
<li><strong>측정 도구</strong><ul>
<li>k6: Baseline(1 VU, 1분) / Light Load(10 VU, 3분)</li>
<li>curl: 단일 요청 실측</li>
<li>Grafana: p95 Latency, HikariCP, GC</li>
<li>pg_stat_statements: 호출 수/총시간/평균/표준편차 + 블록 I/O</li>
<li>EXPLAIN (ANALYZE, BUFFERS): 실행 플랜/버퍼</li>
</ul>
</li>
</ul>
<hr>
<h2 id="1-쿼리-패턴과-병목">1) 쿼리 패턴과 병목</h2>
<p>핵심 쿼리:</p>
<pre><code class="language-sql">SELECT p.id, p.title, p.created_at, COUNT(c.id) AS comment_count
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
WHERE p.store_id = :storeId
GROUP BY p.id
ORDER BY p.created_at DESC;
</code></pre>
<p><strong>패턴 요약</strong></p>
<ul>
<li>WHERE: <code>store_id</code></li>
<li>ORDER BY: <code>created_at DESC</code></li>
<li>집계: <code>COUNT(c.id)</code></li>
</ul>
<p>필터(store_id) + 정렬(created_at) 을 한 번에 커버하는 인덱스가 핵심.</p>
<hr>
<h2 id="2-인덱스-전략">2) 인덱스 전략</h2>
<p><strong>전략 A: 단일 인덱스 3개</strong></p>
<pre><code class="language-sql">CREATE INDEX IF NOT EXISTS idx_comment_post_id   ON comments(post_id);
CREATE INDEX IF NOT EXISTS idx_post_store_id     ON posts(store_id);
CREATE INDEX IF NOT EXISTS idx_post_created_at   ON posts(created_at DESC);
</code></pre>
<ul>
<li>장점: 적용이 단순, 부분적으로 체감 개선</li>
<li>단점: 필터 후 정렬이라 정렬 비용/랜덤 I/O가 일부 남음</li>
</ul>
<p><strong>전략 B: 복합 + 보조 1개 ✅ 최종 채택</strong></p>
<pre><code class="language-sql">CREATE INDEX IF NOT EXISTS idx_comment_post_id ON comments(post_id);
CREATE INDEX IF NOT EXISTS idx_posts_store_created ON posts(store_id, created_at DESC);
</code></pre>
<ul>
<li>장점: 필터+정렬을 한 번에 커버 → 정렬 비용·랜덤 I/O 최소화</li>
<li>관찰 포인트: 캐시/디스크 I/O 안정성</li>
</ul>
<hr>
<h2 id="3-explainanalyze-buffers-전후">3) EXPLAIN(ANALYZE, BUFFERS) 전/후</h2>
<p><strong>쿼리</strong></p>
<pre><code class="language-sql">EXPLAIN (ANALYZE, BUFFERS)
SELECT p.id, p.title, p.created_at, COUNT(c.id) AS comment_count
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
WHERE p.store_id = 1
GROUP BY p.id
ORDER BY p.created_at DESC;
</code></pre>
<ol>
<li><p><strong>인덱스 없음</strong> — <code>Execution Time: 324.659 ms</code></p>
<p> 이미지: <img src="https://velog.velcdn.com/images/minpractice_jhj/post/f1cb67f8-8dcc-4143-a790-5a85a1cf8db2/image.png" alt=""></p>
</li>
</ol>
<pre><code>포인트: `Seq Scan + HashAggregate`, `shared_blks_read` 매우 큼.</code></pre><ol start="2">
<li><p><strong>전략 A</strong>(단일 3개) — <code>Execution Time: 9.717 ms</code></p>
<p> 이미지: <img src="https://velog.velcdn.com/images/minpractice_jhj/post/88205e34-db00-4809-b42e-0ec4862d55bc/image.png" alt=""></p>
</li>
</ol>
<pre><code>포인트: `Index Scan`로 전환, 정렬비용 잔존.</code></pre><ol start="3">
<li><p><strong>전략 B</strong>(복합+보조) — <code>Execution Time: 8.698 ms</code></p>
<p> 이미지: <img src="https://velog.velcdn.com/images/minpractice_jhj/post/faa6a79e-356d-4190-b6eb-17e32bf43f81/image.png" alt=""></p>
</li>
</ol>
<pre><code>포인트: **필터+정렬 동시 충족** → 추가로 1ms 정도 더 단축.</code></pre><blockquote>
<p>캡션 예시: “복합 인덱스에서 ORDER BY p.created_at DESC가 인덱스 순서로 해결 → 정렬/랜덤 I/O 감소.”</p>
</blockquote>
<hr>
<h2 id="4-pg_stat_statements-요약-50회-반복-실행">4) pg_stat_statements 요약 (50회 반복 실행)</h2>
<p>쿼리:</p>
<pre><code class="language-sql">SELECT
  calls,
  round(total_exec_time::numeric, 2)  AS total_ms,
  round(mean_exec_time::numeric, 2)   AS mean_ms,
  round(stddev_exec_time::numeric, 2) AS stddev_ms,
  rows, shared_blks_hit, shared_blks_read, query
FROM pg_stat_statements
WHERE query ILIKE &#39;%FROM posts p%&#39;
  AND query ILIKE &#39;%GROUP BY p.id%&#39;
  AND query ILIKE &#39;%ORDER BY p.created_at%&#39;
ORDER BY total_exec_time DESC
LIMIT 5;
</code></pre>
<h3 id="표-1--pg_stat_statements고정-세션에서-50회-반복">표 1 — pg_stat_statements(고정 세션에서 50회 반복)</h3>
<table>
<thead>
<tr>
<th>시나리오</th>
<th>calls</th>
<th>total_ms</th>
<th>mean_ms</th>
<th>stddev_ms</th>
<th>rows</th>
<th>shared_blks_hit</th>
<th>shared_blks_read</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 없음</td>
<td>50</td>
<td>8,986.75</td>
<td>179.73</td>
<td>13.40</td>
<td>5,000</td>
<td>162,950</td>
<td>1,428,200</td>
</tr>
<tr>
<td>전략 A (단일 3개)</td>
<td>50</td>
<td>378.44</td>
<td>7.57</td>
<td>1.94</td>
<td>5,000</td>
<td>73,768</td>
<td>32</td>
</tr>
<tr>
<td>전략 B (복합+보조)</td>
<td>50</td>
<td>406.89</td>
<td>8.14</td>
<td>2.35</td>
<td>5,000</td>
<td>73,800</td>
<td>0</td>
</tr>
</tbody></table>
<p><strong>해석</strong></p>
<ul>
<li><strong>shared_blks_read</strong>(디스크 블록 읽기)가 “인덱스 없음 → 전략 A/B”에서 <strong>1,428,200 → 32/0</strong>으로 급락 → <strong>I/O 병목 제거</strong>가 핵심.</li>
<li>평균 실행(ms)은 A≈B이지만, <strong>B는 읽기 0</strong>으로 더 안정적(캐시 적중/정렬 회피).</li>
</ul>
<blockquote>
<p>한 줄 결론: “단일 3개 ≠ 복합 1개. 우리 패턴에서는 복합이 ‘정렬 비용’까지 없앤다.”</p>
</blockquote>
<hr>
<h2 id="5-jpql-최적화--집계존재-여부는-한-번에">5) JPQL 최적화 — 집계/존재 여부는 한 번에</h2>
<p><strong>개선 전 (N+1 패턴)</strong></p>
<pre><code class="language-java">return posts.stream()
    .map(post -&gt; PostResponse.fromPostWithComments(
        post,
        commentRepository.countByPostId(post.getId()),
        postLikeRepository.existsByPostIdAndMemberId(post.getId(), member.getId())
    ))
    .collect(Collectors.toList());
</code></pre>
<ul>
<li>게시글 N건 × 2쿼리 → 폭발</li>
</ul>
<p><strong>개선 후 (DTO 프로젝션 + EXISTS)</strong></p>
<pre><code class="language-java">@Query(&quot;&quot;&quot;
SELECT new kr.co.pinup.posts.model.dto.PostResponse(
  p.id,
  p.member.nickname,
  p.title,
  p.thumbnail,
  p.createdAt,
  COUNT(c.id),     -- 댓글 수
  p.likeCount,     -- 유지된 좋아요 수
  CASE
    WHEN :memberId IS NOT NULL AND
         EXISTS (SELECT 1 FROM PostLike pl WHERE pl.post.id = p.id AND pl.member.id = :memberId)
    THEN TRUE ELSE FALSE
  END
)
FROM Post p
LEFT JOIN Comment c ON c.post.id = p.id
WHERE p.store.id = :storeId
  AND p.isDeleted = :isDeleted
GROUP BY p.id, p.member.nickname, p.title, p.thumbnail, p.createdAt, p.likeCount
ORDER BY p.createdAt DESC
&quot;&quot;&quot;)
List&lt;PostResponse&gt; findPostListItems(Long storeId, boolean isDeleted, Long memberId);
</code></pre>
<ul>
<li>댓글 집계: COUNT 한 번으로 끝</li>
<li>좋아요 여부: EXISTS → 불필요 조인 방지</li>
<li>DTO 프로젝션으로 추가 변환 비용 제거</li>
</ul>
<hr>
<h2 id="6-최종-결과">6) 최종 결과</h2>
<p><strong>단일 요청 (curl)</strong></p>
<table>
<thead>
<tr>
<th>단계</th>
<th>Real(s)</th>
<th>개선 배수</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 전</td>
<td>9.147</td>
<td>–</td>
</tr>
<tr>
<td>인덱스 후</td>
<td>0.450</td>
<td>×20.3</td>
</tr>
<tr>
<td>인덱스 + JPQL</td>
<td>0.230</td>
<td>×2.0 / ×39.8</td>
</tr>
</tbody></table>
<p><strong>k6 (Baseline: 1 VU, 1분)</strong></p>
<table>
<thead>
<tr>
<th>단계</th>
<th>p95(ms)</th>
<th>평균(ms)</th>
<th>요청 수</th>
<th>개선</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 전</td>
<td>304.43</td>
<td>257.16</td>
<td>48</td>
<td>–</td>
</tr>
<tr>
<td>인덱스 후</td>
<td>70.48</td>
<td>43.36</td>
<td>58</td>
<td>×4.32</td>
</tr>
<tr>
<td>인덱스 + JPQL</td>
<td>41.34</td>
<td>30.11</td>
<td>59</td>
<td>×7.36</td>
</tr>
</tbody></table>
<p><strong>k6 (Light: 10 VU, 3분)</strong></p>
<table>
<thead>
<tr>
<th>단계</th>
<th>p95(ms)</th>
<th>평균(ms)</th>
<th>요청 수</th>
<th>개선</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 전</td>
<td>320.72</td>
<td>208.55</td>
<td>1,495</td>
<td>–</td>
</tr>
<tr>
<td>인덱스 후</td>
<td>42.33</td>
<td>31.53</td>
<td>1,750</td>
<td>×7.57</td>
</tr>
<tr>
<td>인덱스 + JPQL</td>
<td>39.55</td>
<td>30.68</td>
<td>1,750</td>
<td>×8.11</td>
</tr>
</tbody></table>
<ul>
<li>여기서 중요한 건,
“환경 튜닝은 병목을 좁히는 과정”이었고,
“실제 개선은 쿼리/JPQL 최적화”에서 터졌다는 겁니다.</li>
</ul>
<blockquote>
<p>즉, 설정을 먼저 확인했기에 병목의 원인을 정확히 DB로 좁힐 수 있었고,
DB 최적화(쿼리/JPQL 최적화)로 전환했기에 ×39배 개선을 만들 수 있었습니다.</p>
</blockquote>
<hr>
<h2 id="7-부하-테스트--grafana-대시보드-측정">7) 부하 테스트 &amp; Grafana 대시보드 측정</h2>
<p>인덱스/JPQL 최적화를 하고 나서, 실제 k6 부하를 걸고 Grafana 대시보드로 측정했다.</p>
<p>단순 수치 로그(k6 CLI 출력)만 보는 게 아니라, <strong>커스텀 대시보드에서 p95 Latency 패널을 잡아둔 덕분에 Before/After 그래프를 시각적으로 비교</strong>할 수 있었다.</p>
<h3 id="light-load-10-vu-3분">Light Load (10 VU, 3분)</h3>
<ul>
<li>Before: p95 ~320ms</li>
<li>After: p95 ~39ms</li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/913eb2d2-74de-4294-910a-fcdf9f311307/image.png" alt=""></p>
<ul>
<li><p><strong>효과</strong>: 수치상으로는 7~8배 개선, 그래프 상으로는 “Latency 밴드가 통째로 내려앉은 것”이 눈에 보였다.</p>
</li>
<li><p><strong>포인트</strong>: 운영 대시보드와 달리, 로컬에서는 성능 개선 전후를 같은 구간에 겹쳐보는 게 핵심이다.</p>
</li>
</ul>
<hr>
<h2 id="8-내가-배운-점">8) 내가 배운 점</h2>
<ul>
<li><p><strong>인덱스는 패턴에 맞춘다</strong></p>
<p>  단일 3개 ≠ 복합 1개.</p>
<p>  이 쿼리(WHERE store_id + ORDER BY created_at)엔 복합 인덱스가 정답.</p>
</li>
<li><p><strong>순서가 중요하다: 인덱스 → JPQL</strong></p>
<p>  먼저 I/O 병목 줄이고, 그다음 N+1/조인 구조 정리 → p95와 평균 모두 안정화.</p>
</li>
<li><p><strong>측정 가능한 개선만 남긴다</strong></p>
<p>  pg_stat_statements, EXPLAIN, k6, Grafana p95</p>
<p>  → 전/후 수치가 있으면 협업·블로그·이력서에서 설득력이 생김.</p>
</li>
<li><p><strong>실패 경험도 자산</strong></p>
<p>  전략 A가 순간 평균은 더 좋아 보였지만, shared_blks_read가 남아 B를 선택.</p>
<p>  cadvisor 라벨 실패 → docker_stats_exporter로 전환.</p>
<p>  이런 시행착오가 글에 힘을 실어줌.</p>
</li>
</ul>
<hr>
<h2 id="9-재현용-스크립트">9) 재현용 스크립트</h2>
<pre><code class="language-sql">-- 초기화
DROP INDEX IF EXISTS idx_comment_post_id;
DROP INDEX IF EXISTS idx_post_store_id;
DROP INDEX IF EXISTS idx_post_created_at;
DROP INDEX IF EXISTS idx_posts_store_created;

-- 전략 A: 단일 3개
CREATE INDEX IF NOT EXISTS idx_comment_post_id ON comments(post_id);
CREATE INDEX IF NOT EXISTS idx_post_store_id ON posts(store_id);
CREATE INDEX IF NOT EXISTS idx_post_created_at ON posts(created_at DESC);

-- 전략 B: 복합 + 보조 1개 (A 제거 후)
DROP INDEX IF EXISTS idx_post_store_id;
DROP INDEX IF EXISTS idx_post_created_at;
CREATE INDEX IF NOT EXISTS idx_comment_post_id ON comments(post_id);
CREATE INDEX IF NOT EXISTS idx_posts_store_created ON posts(store_id, created_at DESC);

ANALYZE posts;
ANALYZE comments;
</code></pre>
<p><strong>측정 쿼리</strong></p>
<pre><code class="language-sql">SELECT pg_stat_statements_reset();

-- 동일 세션에서 50회 반복 실행
SELECT p.id, p.title, p.created_at, COUNT(c.id)
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
WHERE p.store_id = 1
GROUP BY p.id
ORDER BY p.created_at DESC;

-- 집계
SELECT
  calls,
  round(total_exec_time::numeric, 2)  AS total_ms,
  round(mean_exec_time::numeric, 2)   AS mean_ms,
  round(stddev_exec_time::numeric, 2) AS stddev_ms,
  rows, shared_blks_hit, shared_blks_read, query
FROM pg_stat_statements
WHERE query ILIKE &#39;%FROM posts p%&#39;
  AND query ILIKE &#39;%GROUP BY p.id%&#39;
  AND query ILIKE &#39;%ORDER BY p.created_at%&#39;
ORDER BY total_exec_time DESC
LIMIT 5;
</code></pre>
<hr>
<h2 id="마무리">마무리</h2>
<p>B-Tree는 “어떤 순서로 쓰는가”에 따라 성능이 갈렸다.</p>
<p><strong>전략 B(복합 인덱스)</strong>로 정렬까지 커버하고, <strong>JPQL 최적화</strong>로 N+1 제거하자,</p>
<ul>
<li>단일 요청: ×39.8배 개선</li>
<li>부하 p95: ×7~8배 개선</li>
</ul>
<p>측정 기반으로 성과를 증명할 수 있었고, 이 경험 자체가 제일 값진 수확이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[5편] 운영은 감지, 로컬은 분석 — Pinup 대시보드 설계]]></title>
            <link>https://velog.io/@minpractice_jhj/%EC%9A%B4%EC%98%81%EC%9D%80-%EA%B0%90%EC%A7%80-%EB%A1%9C%EC%BB%AC%EC%9D%80-%EB%B6%84%EC%84%9D-Pinup-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@minpractice_jhj/%EC%9A%B4%EC%98%81%EC%9D%80-%EA%B0%90%EC%A7%80-%EB%A1%9C%EC%BB%AC%EC%9D%80-%EB%B6%84%EC%84%9D-Pinup-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Wed, 20 Aug 2025 03:52:28 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>1~4편을 통해 수집·저장·탐색·Trace/Logs 연동까지 확보했다.</p>
<p>이번 글은 <strong>“이제 그 데이터를 운영과 로컬에서 어떻게 다르게 활용했는가”</strong>에 집중한다.</p>
<ul>
<li>운영 = 빠른 감지와 추적</li>
<li>로컬 = 세밀한 분석과 실험</li>
</ul>
<p>이 두 가지는 다르다는 걸 몸소 겪으면서, 대시보드 설계도 달라져야 한다는 걸 배웠다.</p>
<hr>
<h2 id="1-운영-대시보드-빠른-감지와-추적">1) 운영 대시보드: 빠른 감지와 추적</h2>
<p>운영 환경에서 중요한 건 <strong>빨리 감지하고 추적하는 흐름</strong>이었다.</p>
<ul>
<li><p>주요 패널들 (운영 JSON 기반)</p>
<ul>
<li>상단 요약 지표 (CPU, Memory, Error Rate) → 한눈에 전체 상태</li>
<li>Error Rate 그래프 → 장애 징후 탐지 포인트</li>
<li>HTTP Requests / Latency → 트래픽 흐름 &amp; 성능 확인</li>
<li>API Traces (Tempo) → 이상 API 추적</li>
<li>Loki Logs (traceId 포함) → 트레이스와 로그 연동</li>
<li>Top Slow Queries → DB 병목 탐지</li>
</ul>
</li>
</ul>
<blockquote>
<p>흐름 = <strong>이상 감지 → Trace 추적 → Logs 확인</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/e3e73119-2e63-4db8-ad1e-50efa9af8484/image.png" alt=""></p>
<ul>
<li>상단 요약 → Error Rate → Trace/Logs</li>
</ul>
<hr>
<h2 id="2-로컬-대시보드-병목-분석과-실험">2) 로컬 대시보드: 병목 분석과 실험</h2>
<p>로컬에서는 운영과 달리 <strong>깊게 파고드는 분석</strong>이 목적이었다.</p>
<ul>
<li><p>주요 패널들 (로컬 JSON 기반)</p>
<ul>
<li>📈 URI별 Latency (변수 <code>$uri2</code>) → 특정 API만 골라 실험</li>
<li>Top Slow Queries (pg_stat_statements) → DB 병목 분석</li>
<li>HikariCP Connection 상태 → 커넥션 풀 튜닝용</li>
<li>JVM Heap 메모리 /  GC 횟수 → 자원 사용 패턴 확인</li>
<li>Trace 보기 (Tempo) → API 요청 단위 상세 추적</li>
<li>Loki 로그(traceId 포함) → Trace ↔ Logs 연동 검증</li>
<li>컨테이너 CPU 사용률 (docker_stats_exporter) → 로컬 리소스 병목 확인</li>
</ul>
</li>
</ul>
<blockquote>
<p>흐름 = <strong>성능 실험 → 병목 지점 확인 → Trace/Logs 교차 검증</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/952d1451-47eb-4cf8-ba5d-b47b56b7585b/image.png" alt=""></p>
<ul>
<li>특정 URI Latency 그래프</li>
<li>Top Slow Queries</li>
<li>컨테이너 CPU 사용률</li>
</ul>
<hr>
<h2 id="3-시행착오-기록">3) 시행착오 기록</h2>
<ul>
<li><p><strong>Trace Table view 저장 불가</strong></p>
<p>  → 운영에서는 “보이기만 하면 된다”라서 그냥 넘김.</p>
</li>
<li><p><strong>컨테이너 CPU 라벨링 문제</strong></p>
<p>  cadvisor로는 컨테이너 이름이 안 보임 → relabeling 시도 실패</p>
<p>  결국 <code>docker_stats_exporter</code>로 전환 → <code>container_name</code> 라벨이 붙어 “이게 DB 컨테이너구나”를 알 수 있게 됨.</p>
</li>
</ul>
<h3 id="docker-composeyml-일부">docker-compose.yml (일부)</h3>
<pre><code class="language-yaml">services:
  docker-stats-exporter:
    image: wywywywy/docker_stats_exporter:latest
    container_name: docker-stats-exporter
    ports:
      - &quot;9487:9487&quot;
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
</code></pre>
<h3 id="prometheusyml-scrape-설정">prometheus.yml (scrape 설정)</h3>
<pre><code class="language-yaml">scrape_configs:
  - job_name: &#39;docker-stats&#39;
    static_configs:
      - targets: [&#39;docker-stats-exporter:9487&#39;]
</code></pre>
<blockquote>
<p>이렇게 붙이면 <code>container_cpu_usage_seconds_total{container_name=&quot;pinup-service&quot;}</code> 형태로 메트릭이 잡히고, 컨테이너별 시계열을 확실하게 볼 수 있다.</p>
</blockquote>
<hr>
<h2 id="4-내가-배운-점">4) 내가 배운 점</h2>
<ul>
<li><p>운영과 로컬은 <strong>대시보드 관점부터 달라야 한다</strong>.</p>
<ul>
<li>운영: 단순하고 빠른 감지</li>
<li>로컬: 깊고 세밀한 분석</li>
</ul>
</li>
<li><p>UI 삽질(Trace Table view 저장 불가) 같은 건 운영에서는 치명적이지 않았다.</p>
</li>
<li><p>컨테이너 CPU는 “단순히 수치만 보는 것”과 “라벨로 컨테이너별로 나누어 보는 것”은 전혀 다르다.</p>
<p>  → <code>docker_stats_exporter</code>가 그 차이를 만들었다.</p>
</li>
</ul>
<p>---<img src="https://velog.velcdn.com/images/minpractice_jhj/post/25ed0421-193f-457c-995c-e33d6ef064e9/image.png" alt=""></p>
<h2 id="마무리">마무리</h2>
<p>이번 대시보드는 단순히 운영용이 아니라, <strong>로컬 환경에서도 충분히 계측하고 분석할 수 있었다</strong>는 게 핵심 성과였다.</p>
<ul>
<li>API 응답 시간, DB 슬로우 쿼리, 컨테이너 CPU 사용량을 로컬에서 실험적으로 측정할 수 있었고</li>
<li>Trace ↔ Logs 연동까지 해두니 <strong>“어떤 요청이 어떤 쿼리 때문에 느려졌는지”</strong>를 손쉽게 확인할 수 있었다.</li>
<li>운영에 올리기 전에, 로컬에서 성능 병목을 확인하고 개선 루프를 빠르게 돌릴 수 있었다.</li>
</ul>
<p>즉, 내가 직접 겪은 시행착오(Trace Table view 문제, 컨테이너 라벨링 실패 → docker_stats_exporter 전환)는</p>
<p><strong>“운영 전 단계에서 로컬을 제대로 계측 가능한 환경으로 만드는 과정”</strong>이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[4편] OTEL Java Agent + Tempo + 슬로우 쿼리 연동]]></title>
            <link>https://velog.io/@minpractice_jhj/4%ED%8E%B8-OTEL-Java-Agent-Tempo-%EC%8A%AC%EB%A1%9C%EC%9A%B0-%EC%BF%BC%EB%A6%AC-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@minpractice_jhj/4%ED%8E%B8-OTEL-Java-Agent-Tempo-%EC%8A%AC%EB%A1%9C%EC%9A%B0-%EC%BF%BC%EB%A6%AC-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Wed, 20 Aug 2025 02:37:38 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>앞선 3편에서는 <strong>보이는 것(메트릭·로그)</strong> 을 다듬었다면, 이번 4편은 <strong>보이지 않던 요청 흐름(Trace)</strong> 을 꺼내는 단계다.</p>
<p>나는 SDK 대신 <strong>Java Agent</strong>를 택했다. 코드 수정 없이 시작·중단이 가능해서, <strong>실험 → 측정 → 개선 루프</strong>를 빠르게 돌리기에 유리했기 때문이다.</p>
<hr>
<h2 id="1-opentelemetry-java-agent-적용-내-선택과-시행착오">1) OpenTelemetry Java Agent 적용 (내 선택과 시행착오)</h2>
<p>처음엔 SDK로 붙여보려 했지만, 배포 주기가 길어지는 게 싫었다.</p>
<p>그래서 JVM 옵션만으로 켜고 끌 수 있는 <strong>Agent</strong> 방식을 선택했다.
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/f78c1aec-7329-4d35-95d7-d5f60459db7c/image.png" alt=""></p>
<p>실행 옵션 (내 로컬 예시):</p>
<pre><code class="language-bash">-javaagent:C:/dev/otel-agent/opentelemetry-javaagent.jar ^
-Dotel.service.name=pinup-service ^
-Dotel.exporter.otlp.endpoint=http://localhost:4318 ^
-Dotel.resource.attributes=deployment.environment=local
</code></pre>
<ul>
<li><code>service.name</code> 으로 서비스 식별 통일</li>
<li>Collector는 HTTP(4318)로 받도록 구성 (아래 2번과 맞춤)</li>
</ul>
<p><strong>UI 성공 확인 포인트 (로그 안 봄):</strong></p>
<p>애플리케이션 실행 직후 콘솔에 <code>opentelemetry-javaagent - version: ...</code> 문구가 보이면 Agent 로딩 성공.</p>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/dd89addb-a549-4e34-92a4-3bf1ea132f74/image.png" alt=""></p>
<hr>
<h2 id="2-collector-→-tempoloki-라우팅-필요-최소-설정">2) Collector → Tempo/Loki 라우팅 (필요 최소 설정)</h2>
<p>나는 <strong>traces는 Tempo로</strong>, <strong>logs는 Loki로</strong> 보냈다. (metrics는 Prometheus 그대로 유지)</p>
<pre><code class="language-yaml">receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

exporters:
  otlphttp/tempo:
    endpoint: http://tempo:4319
  loki:
    endpoint: http://loki:3100/loki/api/v1/push
  debug:
    verbosity: detailed   # 로컬 실험용(필수 아님)

processors:
  batch: {}

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/tempo]
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [loki]
</code></pre>
<p><strong>UI 성공 확인 포인트:</strong></p>
<p>Grafana → Connections → Data sources → Tempo → <code>Save &amp; test</code> → ✅ Success</p>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/cb4622f8-4681-417b-b410-7875dd3257be/image.png" alt=""></p>
<hr>
<h2 id="3-grafana-explore에서-trace-바로-확인-traceql">3) Grafana Explore에서 Trace 바로 확인 (TraceQL)</h2>
<p>이제 진짜로 <strong>트레이스</strong>를 눈으로 본다.</p>
<ul>
<li>좌측 메뉴 → Explore → Tempo 선택</li>
<li>Time range: 최근 15분</li>
<li>TraceQL 예시:</li>
</ul>
<pre><code class="language-sql">service.name = &quot;pinup-service&quot;
</code></pre>
<p>혹은</p>
<pre><code class="language-sql">span.name = &quot;GET /actuator/prometheus&quot;
</code></pre>
<p><strong>UI 성공 확인 포인트:</strong></p>
<ul>
<li>검색 결과에 최근 요청들이 보이면 <strong>수집/저장/조회 OK</strong></li>
<li>아무 트레이스 클릭 → 상위/하위 스팬 트리와 Timeline 표시
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/fc5ff907-6e38-4cf4-a8c5-855b6e9cacc3/image.png" alt=""></li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/62d5b1a1-c247-41bb-8c9d-58dede8e6a38/image.png" alt=""></p>
<hr>
<h2 id="4-로그-↔-트레이스-연결-원클릭-점프">4) 로그 ↔ 트레이스 연결 (원클릭 점프)</h2>
<p>로그에 <code>traceId</code> 를 실어두면, Grafana에서 <strong>로그 → 트레이스</strong>로 원클릭 이동이 가능하다.</p>
<p>나는 Logback + loki4j 로 JSON 로그에 traceId를 넣었다.</p>
<p><code>logback-spring.xml</code> (요약):</p>
<pre><code class="language-xml">&lt;appender name=&quot;LOKI&quot; class=&quot;com.github.loki4j.logback.Loki4jAppender&quot;&gt;
  &lt;http&gt;
    &lt;url&gt;http://localhost:3100/loki/api/v1/push&lt;/url&gt;
  &lt;/http&gt;
  &lt;format&gt;
    &lt;json&gt;
      &lt;field name=&quot;timestamp&quot; pattern=&quot;%d{yyyy-MM-dd&#39;T&#39;HH:mm:ss.SSSXXX}&quot; /&gt;
      &lt;field name=&quot;level&quot; pattern=&quot;%level&quot; /&gt;
      &lt;field name=&quot;message&quot; pattern=&quot;%msg&quot; /&gt;
      &lt;field name=&quot;traceId&quot; pattern=&quot;%X{traceId}&quot; /&gt;
    &lt;/json&gt;
  &lt;/format&gt;
&lt;/appender&gt;
</code></pre>
<p><strong>Grafana Derived Fields 설정:</strong></p>
<ul>
<li>Connections → Data sources → Loki → Derived fields → Add</li>
<li>Name: <code>traceId</code></li>
<li>Regex: <code>&quot;traceId&quot;:&quot;([a-f0-9]{32})&quot;</code></li>
<li>Data source: Tempo</li>
<li>URL label/value: <code>${__value.raw}</code></li>
</ul>
<p><strong>UI 성공 확인 포인트:</strong></p>
<ul>
<li>Explore → Loki에서 <code>{job=&quot;pinup-service&quot;} | json</code></li>
<li>로그에 traceId 필드 오른쪽 링크/아이콘 클릭 → Tempo 트레이스로 점프
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/b21b527c-81a2-4dfc-8c05-e8b62a05203e/image.png" alt=""></li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/1448a3fc-c67d-4772-af95-9825c0f4b512/image.png" alt=""></p>
<blockquote>
<p>시행착오: <code>traceId</code> 가 대문자/하이픈 포함일 땐 정규식이 안 맞았다.</p>
</blockquote>
<p>내 케이스는 소문자 32hex 기준이라 위 정규식이 잘 맞음.</p>
<hr>
<h2 id="5-postgresql-슬로우-쿼리-시각화">5) PostgreSQL 슬로우 쿼리 시각화</h2>
<p>슬로우 쿼리는 두 갈래로 본다.</p>
<ol>
<li><strong>Trace (Tempo):</strong> 요청 안에서 DB 스팬의 타이밍/관계</li>
<li><strong>Top-N 쿼리 (Grafana+Postgres):</strong> 누적/평균 실행 시간 상위</li>
</ol>
<p>PostgreSQL 준비:</p>
<pre><code># postgresql.conf
shared_preload_libraries = &#39;pg_stat_statements&#39;
</code></pre><p>최초 1회:</p>
<pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
</code></pre>
<p>Grafana 쿼리 예시 (Table 패널):</p>
<pre><code class="language-sql">SELECT query, total_exec_time, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
</code></pre>
<p><strong>UI 성공 확인 포인트:</strong></p>
<ul>
<li>Grafana 대시보드에 Top 슬로우 쿼리 테이블 표시</li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/7f894e05-d06f-46db-b647-4c60f6c35aec/image.png" alt=""></p>
<hr>
<h2 id="측정-→-분석-→-개선-루프">측정 → 분석 → 개선 루프</h2>
<ul>
<li><strong>Tempo:</strong> 느린 요청 Trace → DB 스팬 타이밍 확인</li>
<li><strong>Grafana:</strong> 누적 상위 쿼리 확인</li>
<li>개선 (인덱스/쿼리 힌트/재작성) → Before/After 지표 기록 (다음 편에 정리 예정)</li>
</ul>
<hr>
<h2 id="마무리-내가-배운-점">마무리 (내가 배운 점)</h2>
<ul>
<li><strong>Agent 방식</strong>은 도입/회수 속도가 빨라서 실험-측정-개선 루프에 최적.</li>
<li><strong>UI 기반 검증 루틴</strong>(Tempo OK → TraceQL 결과 → 로그-트레이스 링크)은 로그만 뒤지는 것보다 훨씬 빠르고 직관적.</li>
<li><strong>슬로우 쿼리 분석</strong>은 Trace(맥락) + Top-N(누적) 두 관점이 모두 있어야 개선이 정확하다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[3편] Grafana 대시보드 개선]]></title>
            <link>https://velog.io/@minpractice_jhj/3%ED%8E%B8-Grafana-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EA%B0%9C%EC%84%A0</link>
            <guid>https://velog.io/@minpractice_jhj/3%ED%8E%B8-Grafana-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EA%B0%9C%EC%84%A0</guid>
            <pubDate>Tue, 19 Aug 2025 16:05:09 GMT</pubDate>
            <description><![CDATA[<p>1편에서는 <strong>OTEL + Grafana 스택을 띄우고 첫 번째 지표 확인</strong>,</p>
<p>2편에서는 <strong>Spring Boot → Prometheus/Loki 로그·메트릭 수집</strong>을 정리했습니다.</p>
<p>이번 3편에서는,</p>
<p>수집된 데이터(메트릭·로그)를 <strong>운영 친화적인 Grafana 대시보드로 정비</strong>하는 과정을 공유합니다.</p>
<p>제가 직접 겪은 문제와,</p>
<p>변수를 정비하고 패널을 커스터마이징하면서 얻은 경험을 풀어보겠습니다.</p>
<hr>
<h2 id="1-before--기본-spring-boot-대시보드-한계">1. Before – 기본 Spring Boot 대시보드 한계</h2>
<p>처음에는 Grafana에서 제공하는 <strong>Spring Boot Metrics (ID: 6756)</strong> 대시보드를 불러왔습니다.</p>
<p>하지만 기본 상태에서는 문제가 많았습니다.</p>
<ul>
<li>변수(Query) 설정이 잘못되어 필터 드롭다운이 “No data”를 띄움</li>
<li>JVM/CPU 지표는 나오지만, 실제 서비스 진단에는 부족</li>
<li>SQL 로그 같은 운영 분석 지표는 없음</li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/84f7386f-6a1d-40df-b777-f69e52f1ebc0/image.png" alt=""></p>
<blockquote>
<p>결론: <strong>데이터는 있는데 시각화가 답답하다</strong>는 느낌.
운영자가 바로 원인 분석하기엔 부족했습니다.</p>
</blockquote>
<hr>
<h2 id="2-variables-오류-수정">2. Variables 오류 수정</h2>
<p>문제 원인: Dashboard Variables 정의가 <strong>실제 라벨과 불일치</strong>.</p>
<p>예: <code>instance</code> 변수가 잘못된 메트릭을 기준으로 작성되어 값이 안 나옴.</p>
<p>해결: <code>label_values(metric, label)</code>로 정확히 수정.</p>
<pre><code class="language-yaml"># 수정 예시
label_values(http_server_requests_seconds_count, job)
label_values(http_server_requests_seconds_count, instance)
</code></pre>
<blockquote>
<p>이 과정을 통해 <strong>서비스별 / 인스턴스별 Drill-down</strong>이 가능해졌습니다.</p>
</blockquote>
<hr>
<h2 id="3-explore--prometheus로-검증">3. Explore + Prometheus로 검증</h2>
<p>수정된 변수가 올바른지 확인하려면,</p>
<p>Prometheus에서 해당 메트릭이 실제 수집되고 있는지 먼저 체크하는 게 중요합니다.</p>
<ul>
<li><p>Spring Boot 애플리케이션 →</p>
<p>  <code>http://localhost:8080/actuator/prometheus</code> 에서 노출 확인</p>
</li>
<li><p>Prometheus 웹 UI →</p>
<p>  <code>http://localhost:9090/graph</code> → <code>http_server_requests_seconds_count</code> 입력</p>
<p>  → 라벨(job, instance, uri, method 등) 확인</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/e7bd454b-b099-451b-82c9-f177b2fe1e3c/image.png" alt=""></p>
<blockquote>
<p>이 과정을 거치면, Grafana 변수가 <strong>실제 존재하는 라벨 기반인지 확실히 검증</strong>할 수 있습니다.</p>
</blockquote>
<hr>
<h2 id="🔧-4-after--커스텀-대시보드-구성">🔧 4. After – 커스텀 대시보드 구성</h2>
<p>단순 지표 모니터링에서 그치지 않고, 운영자가 <strong>즉시 활용할 수 있는 패널</strong>을 추가했습니다.</p>
<h3 id="sql-로그-패널">SQL 로그 패널</h3>
<ul>
<li><p>Loki 쿼리:</p>
<pre><code>  {job=&quot;pinup-service&quot;} |= &quot;select&quot; | json
</code></pre></li>
<li><p>SQL 실행 로그(JSON) 조회 → DB 쿼리 병목 추적 가능</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/5f187548-5f16-4532-b77e-2e096f9ac198/image.png" alt=""></p>
<hr>
<h3 id="커스텀-지표-개선">커스텀 지표 개선</h3>
<ul>
<li>기존 JVM/CPU 그래프에 <strong>$job / $instance 변수 반영</strong></li>
<li>SQL 로그 + 메트릭을 한 대시보드에서 동시에 확인 가능</li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/597b2d3f-3802-4522-b93a-0e125ab07ea4/image.png" alt=""></p>
<blockquote>
<p>결과적으로, 단순한 메트릭 뷰어 →
<strong>실제 문제 원인을 추적할 수 있는 운영 대시보드</strong>로 발전했습니다.</p>
</blockquote>
<hr>
<h2 id="✅-마무리">✅ 마무리</h2>
<p>이번 편에서 배운 점:</p>
<ul>
<li>Grafana 기본 대시보드는 Import만으로는 부족 → Variables/쿼리 수정이 필요</li>
<li>Prometheus <code>/graph</code>에서 라벨 확인 → Grafana 변수 정의 신뢰성 확보</li>
<li>Loki 로그 패널 추가 → SQL Slow Query까지 한 눈에 모니터링 가능</li>
</ul>
<blockquote>
<p>다음 편(4편)에서는 <strong>OTEL Agent + Tempo로 슬로우 쿼리 트레이싱</strong>을 통합하는 과정을 다룰 예정입니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[2편] Spring Boot + Prometheus + Loki 기본 구축]]></title>
            <link>https://velog.io/@minpractice_jhj/2%ED%8E%B8-Spring-Boot-Prometheus-Loki-%EA%B8%B0%EB%B3%B8-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@minpractice_jhj/2%ED%8E%B8-Spring-Boot-Prometheus-Loki-%EA%B8%B0%EB%B3%B8-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Tue, 19 Aug 2025 09:50:26 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>1편에서는 모니터링을 왜 도입했는지, 그리고 OTEL + Grafana 스택을 띄우고 첫 번째 지표를 확인한 과정을 다뤘습니다.</p>
<p>이번 2편에서는 <strong>Spring Boot 애플리케이션에서 실제로 메트릭과 로그를 수집하는 과정</strong>을 정리합니다.</p>
<p>제가 선택한 도구들, 설정 과정에서 겪은 시행착오, 그리고 실제 Grafana에서 지표와 로그를 확인하는 모습까지 공유합니다.</p>
<hr>
<h2 id="1-micrometer--prometheus-메트릭-수집">1. Micrometer + Prometheus 메트릭 수집</h2>
<p>Spring Boot는 Actuator를 통해 모니터링 엔드포인트를 제공합니다.</p>
<p>하지만 단순 health 체크만으로는 성능 개선에 필요한 지표를 확보하기 어렵습니다.</p>
<blockquote>
<p>그래서 선택한 조합이 <strong>Micrometer + Prometheus</strong>.</p>
</blockquote>
<p>Micrometer는 Spring Boot와 자연스럽게 통합되고, Prometheus는 Grafana와 연동성이 뛰어나기 때문에 장기적인 확장성도 고려할 수 있었습니다.</p>
<h3 id="설정-application-localyml">설정 (<code>application-local.yml</code>)</h3>
<pre><code class="language-yaml">management:
  endpoints:
    web:
      exposure:
        include:
          - health
          - info
          - prometheus
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
      percentiles:
        http.server.requests: 0.5, 0.95, 0.99
</code></pre>
<p>이 설정으로 <code>/actuator/prometheus</code> 엔드포인트에서 다음을 확인할 수 있습니다:</p>
<ul>
<li>요청 시간을 히스토그램 단위로 수집</li>
<li>50%, 95%, 99% 백분위수 지표 제공</li>
</ul>
<blockquote>
<p>단순 평균 응답시간이 아닌 <strong>백분위수 기반 지표</strong>는 성능 튜닝에서 특히 중요합니다.</p>
</blockquote>
<p>예를 들어, 평균 응답시간이 빠르더라도 95% 구간에서 느리면 실사용자 경험은 크게 달라집니다.</p>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/3b300807-7434-4b70-ab04-0e96e224bbb4/image.png" alt=""></p>
<ul>
<li><p><code>/actuator/prometheus</code> → <code>http_server_requests_seconds_bucket</code> 확인</p>
<p>  → <strong>Spring Boot가 Prometheus 포맷으로 지표를 내보내고 있음을 검증</strong></p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/b28cc146-a1a1-4fa2-a76a-7cd42cc24462/image.png" alt=""></p>
<ul>
<li><p>Grafana Explore → <code>histogram_quantile(0.95, rate(http_server_requests_seconds_bucket[5m]))</code> 조회 결과</p>
<p>  → <strong>95% 구간 응답시간을 시각화, 실제 병목 구간 파악에 활용</strong></p>
</li>
</ul>
<hr>
<h2 id="2-p6spy--loki-로그-수집">2. P6Spy + Loki 로그 수집</h2>
<p>메트릭만으로는 병목의 원인을 특정하기 어렵습니다.</p>
<p>특히 <strong>DB 쿼리 실행 시간</strong>은 성능 최적화의 핵심 지표 중 하나입니다.</p>
<p>처음엔 단순 로그를 Promtail로 긁어왔는데, SQL 실행 시간은 보이지 않아서 답답했습니다.</p>
<blockquote>
<p>그래서 <strong>P6Spy</strong>를 도입했습니다.</p>
</blockquote>
<h3 id="설정">설정</h3>
<p><strong><code>spy.properties</code></strong></p>
<pre><code>modulelist=com.p6spy.engine.logging.P6LogFactory
logMessageFormat=com.p6spy.engine.spy.appender.SingleLineFormat
appender=com.p6spy.engine.spy.appender.Slf4JLogger
logLevel=info
</code></pre><p><strong><code>logback-spring.xml</code></strong></p>
<pre><code class="language-xml">&lt;appender name=&quot;LOKI&quot; class=&quot;com.github.loki4j.logback.Loki4jAppender&quot;&gt;
  &lt;http&gt;
    &lt;url&gt;http://localhost:3100/loki/api/v1/push&lt;/url&gt;
  &lt;/http&gt;
  &lt;format&gt;
    &lt;json&gt;
      &lt;field name=&quot;timestamp&quot; pattern=&quot;%d{yyyy-MM-dd&#39;T&#39;HH:mm:ss.SSSXXX}&quot; /&gt;
      &lt;field name=&quot;level&quot; pattern=&quot;%level&quot; /&gt;
      &lt;field name=&quot;message&quot; pattern=&quot;%msg&quot; /&gt;
      &lt;field name=&quot;traceId&quot; pattern=&quot;%X{traceId}&quot; /&gt;
    &lt;/json&gt;
  &lt;/format&gt;
&lt;/appender&gt;
</code></pre>
<blockquote>
<p>이 설정으로 SQL 실행 로그가 JSON 형태로 Loki에 저장됩니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/e6cc1a12-4a48-4827-809a-fa299f6e5192/image.png" alt=""></p>
<ul>
<li><p>Grafana Explore → Loki → <code>job=&quot;pinup-service&quot;</code> 로그 조회</p>
<p>  → <strong>SQL 로그가 JSON 형태로 쌓이는지 확인</strong></p>
</li>
</ul>
<hr>
<h2 id="3-docker-compose-최소-버전">3. docker-compose (최소 버전)</h2>
<p>이번 글에서는 <strong>Prometheus + Loki + Promtail + Grafana</strong> 네 가지 서비스만 다룹니다.</p>
<p>이 네 개만 있어도 “메트릭 + 로그”라는 기본기를 완성할 수 있습니다.</p>
<pre><code class="language-yaml">services:
  prometheus:
    image: prom/prometheus
    user: root
    ports:
      - &quot;9090:9090&quot;
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro

  loki:
    image: grafana/loki:2.9.2
    ports:
      - &quot;3100:3100&quot;

  promtail:
    image: grafana/promtail:2.9.2
    volumes:
      - ./logs:/app/logs
      - ./promtail-config.yaml:/etc/promtail/config.yaml
    command: -config.file=/etc/promtail/config.yaml

  grafana:
    image: grafana/grafana
    ports:
      - &quot;3000:3000&quot;
    environment:![](https://velog.velcdn.com/images/minpractice_jhj/post/d3d9b717-f161-48fa-9970-bb70323c982d/image.png)

      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=admin
</code></pre>
<blockquote>
<p>Postgres, Tempo, OTEL Collector는 후속편에서 다룹니다.</p>
</blockquote>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 편에서 배운 점:</p>
<ul>
<li>Micrometer + Prometheus → <strong>백분위수 기반 지표 확보</strong></li>
<li>P6Spy + Loki → <strong>SQL 실행 로그를 JSON 형태로 수집, 병목 분석 기반 마련</strong></li>
<li>docker-compose 최소 구성 → <strong>메트릭 + 로그 두 축 확보</strong></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[1편] OTEL 도입 배경과 모니터링 환경 구축]]></title>
            <link>https://velog.io/@minpractice_jhj/1%ED%8E%B8-OTEL-%EB%8F%84%EC%9E%85-%EB%B0%B0%EA%B2%BD%EA%B3%BC-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@minpractice_jhj/1%ED%8E%B8-OTEL-%EB%8F%84%EC%9E%85-%EB%B0%B0%EA%B2%BD%EA%B3%BC-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Tue, 19 Aug 2025 09:06:25 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>사이드 프로젝트를 하면서 늘 고민이 있었습니다.</p>
<p>“내 서비스가 잘 동작하는 건 알겠는데, 성능이나 장애는 어떻게 확인하지?”</p>
<p>특히 EC2 프리티어 환경에서 돌리는 서비스라서,</p>
<ul>
<li>요청이 몰렸을 때 어디서 병목이 생기는지,</li>
<li>장애가 발생했을 때 어디서 로그를 확인해야 하는지,</li>
<li>성능을 개선했을 때 그 효과를 <strong>수치로 증명할 수 있는지</strong>,</li>
</ul>
<p>이런 부분이 늘 불안했습니다.</p>
<p>그래서 이번에는 <strong>Observability(관찰 가능성) 환경</strong>을 직접 구축하기로 했습니다.</p>
<hr>
<h2 id="왜-otelopentelemetry--grafana-스택인가">왜 OTEL(OpenTelemetry) + Grafana 스택인가?</h2>
<h3 id="처음-고민했던-선택지들">처음 고민했던 선택지들</h3>
<ul>
<li><p><strong>ELK 스택(Elasticsearch + Logstash + Kibana)</strong></p>
<p>  → 너무 무겁고, EC2 프리티어에서 운영하기 벅참.</p>
</li>
<li><p><strong>cadvisor</strong></p>
<p>  → 컨테이너 리소스 모니터링은 되지만, 애플리케이션 단위 모니터링에는 한계.</p>
</li>
<li><p><strong>단순 Prometheus + Actuator</strong></p>
<p>  → 메트릭 수집은 가능하지만, 로그와 트레이스까지 엮기는 어려움.</p>
</li>
</ul>
<h3 id="최종-선택">최종 선택</h3>
<ul>
<li><strong>OTEL(OpenTelemetry)</strong> → 표준화된 데이터 수집 파이프라인.</li>
<li><strong>Prometheus</strong> → 메트릭 저장.</li>
<li><strong>Loki</strong> → 로그 저장.</li>
<li><strong>Tempo</strong> → 트레이스 저장.</li>
<li><strong>Grafana</strong> → 시각화 &amp; 대시보드.</li>
</ul>
<blockquote>
<p>결론적으로, <strong>로그 + 메트릭 + 트레이스</strong>를 하나로 묶어보고 싶다는 니즈가 제일 컸습니다.</p>
</blockquote>
<hr>
<h2 id="최소-구성으로-스택-띄우기">최소 구성으로 스택 띄우기</h2>
<p>첫 단계는 <strong>docker-compose</strong>로 관찰 가능성 스택을 띄우는 것이었습니다.</p>
<p>이번 편에서는 핵심인 Prometheus, Grafana, Loki, Tempo만 올렸습니다.</p>
<pre><code class="language-yaml">services:
  prometheus:
    image: prom/prometheus
    ports:
      - &quot;9090:9090&quot;
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro

  grafana:
    image: grafana/grafana
    ports:
      - &quot;3000:3000&quot;

  loki:
    image: grafana/loki:2.9.2
    ports:
      - &quot;3100:3100&quot;

  tempo:
    image: grafana/tempo:2.3.1
    ports:
      - &quot;3200:3200&quot;
</code></pre>
<blockquote>
<p>여기서는 Postgres, Promtail, OTEL Collector 같은 것들은 잠시 빼고, <strong>“관찰용 스택이 정상 기동되는지”</strong>만 확인했습니다.</p>
</blockquote>
<ul>
<li>다음 편에서 점차 확장할 예정입니다.</li>
</ul>
<h2 id=""><img src="https://velog.velcdn.com/images/minpractice_jhj/post/7524afb9-ed26-4d06-9100-64485cd14b0a/image.png" alt=""></h2>
<h2 id="spring-boot-메트릭-노출">Spring Boot 메트릭 노출</h2>
<p>Spring Boot Actuator를 켜고 Prometheus 엔드포인트를 노출했습니다.</p>
<pre><code class="language-yaml"># application-local.yml
management:
  endpoints:
    web:
      exposure:
        include:
          - health
          - info
          - prometheus
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
      percentiles:
        http.server.requests: 0.5, 0.95, 0.99
</code></pre>
<blockquote>
<p><code>http://localhost:8080/actuator/prometheus</code> 접속 시,
<code>http_server_requests_seconds_count</code> 같은 메트릭이 보이면 성공입니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/9ba32cab-aa29-4222-80aa-dce3b5b002e9/image.png" alt=""></p>
<hr>
<h2 id="prometheus에서-spring-boot-연결">Prometheus에서 Spring Boot 연결</h2>
<p>Prometheus 설정에서 Spring Boot 애플리케이션을 스크랩 대상으로 등록합니다.</p>
<pre><code class="language-yaml"># prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: &#39;spring-boot&#39;
    metrics_path: &#39;/actuator/prometheus&#39;
    static_configs:
      - targets: [&#39;host.docker.internal:8080&#39;]
</code></pre>
<blockquote>
<p>처음에는 <code>localhost:8080</code>을 썼다가 실패했는데,
Docker 컨테이너에서 접근할 때는 <code>host.docker.internal</code>을 써야 정상적으로 UP 상태가 됐습니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/341f1492-bbe2-408b-b854-758c87ff7861/image.png" alt=""></p>
<hr>
<h2 id="grafana에서-데이터-확인">Grafana에서 데이터 확인</h2>
<h3 id="1-prometheus-데이터소스-연결">1. Prometheus 데이터소스 연결</h3>
<ul>
<li>Grafana → DataSources → Prometheus 등록</li>
<li>URL: <code>http://prometheus:9090</code></li>
<li>연결 성공 메시지: “Successfully queried the Prometheus API”</li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/037042c7-4d27-47e6-8d45-79d9c4a482a6/image.png" alt=""></p>
<hr>
<h3 id="2-explore에서-메트릭-확인">2. Explore에서 메트릭 확인</h3>
<ul>
<li><p><code>http_server_requests_seconds_count</code> 조회 → 그래프 출력</p>
</li>
<li><p>서비스 요청량이 실시간으로 올라가는 걸 보면서,</p>
<p>  “이제야 내 서비스가 모니터링되고 있구나” 실감했습니다.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/d6e9c851-3991-4357-bae2-25b9a6b9c416/image.png" alt=""></p>
<hr>
<h2 id="첫-번째-병목-확인-부하-테스트">첫 번째 병목 확인 (부하 테스트)</h2>
<p>간단히 ApacheBench로 부하를 줬습니다.</p>
<pre><code class="language-bash">ab -n 1000 -c 50 http://localhost:8080/
</code></pre>
<ul>
<li>Grafana Explore에서 <strong>응답 시간 지표가 튀는 순간</strong>을 바로 볼 수 있었습니다.</li>
<li>아직 원인은 분석 전이지만, “어디서부터 성능 이슈가 시작되는지”를 눈으로 확인했다는 점이 의미 있었습니다.</li>
</ul>
<hr>
<h2 id="시행착오와-배움">시행착오와 배움</h2>
<ul>
<li><strong>주소 문제</strong>: Prometheus → Spring Boot 연결 시 <code>localhost</code> 대신 <code>host.docker.internal</code>을 써야 함.</li>
<li><strong>부하 테스트 효과</strong>: 단순히 로그만 볼 땐 몰랐던 병목 구간이 메트릭 그래프에서 확 드러남.</li>
<li><strong>교훈</strong>: “일단 계측해야 개선도 가능하다”는 걸 체감.</li>
</ul>
<hr>
<h2 id="정리">정리</h2>
<p>이번 1편에서는,</p>
<ul>
<li>왜 모니터링이 필요한지,</li>
<li>왜 OTEL + Grafana 스택을 선택했는지,</li>
<li>최소 구성으로 띄우고 첫 번째 메트릭을 확인한 과정,</li>
<li>간단한 부하 테스트로 병목을 발견한 경험,</li>
</ul>
<p>까지 다뤘습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[사이드 프로젝트 성능 튜닝 프롤로그]]></title>
            <link>https://velog.io/@minpractice_jhj/%EC%82%AC%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%84%B1%EB%8A%A5-%ED%8A%9C%EB%8B%9D-%EC%97%B0%EC%9E%AC-%ED%94%84%EB%A1%A4%EB%A1%9C%EA%B7%B8</link>
            <guid>https://velog.io/@minpractice_jhj/%EC%82%AC%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%84%B1%EB%8A%A5-%ED%8A%9C%EB%8B%9D-%EC%97%B0%EC%9E%AC-%ED%94%84%EB%A1%A4%EB%A1%9C%EA%B7%B8</guid>
            <pubDate>Tue, 19 Aug 2025 07:42:08 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-이걸-시작했나">왜 이걸 시작했나</h2>
<p>취업 준비를 하면서 이력서를 다듬다 보니 이런 고민이 생겼습니다.</p>
<p>“내가 뭘 만들었는지는 쓸 수 있는데… 내가 뭘 개선했는지, 숫자로 증명할 수 있을까?”</p>
<p>채용 공고나 기술 블로그를 보면 성과를 수치화해서 보여주는 사례가 많습니다.</p>
<ul>
<li>API 응답 속도 80% 단축</li>
<li>트래픽 5배 증가에도 안정적 서비스 유지</li>
</ul>
<p>이런 문구들은 이력서에서 단번에 눈에 들어옵니다.</p>
<p>그래서 저도 단순히 “서비스를 만들었다”가 아니라,</p>
<p><strong>서비스를 개선하고, 개선 폭을 수치로 증명한다</strong>는 경험을 직접 쌓아보기로 했습니다.</p>
<hr>
<h2 id="내가-만든-서비스">내가 만든 서비스</h2>
<p>제가 운영하는 건 작은 사이드 프로젝트입니다.</p>
<ul>
<li>팝업스토어를 소개하는 커뮤니티 플랫폼</li>
<li>글, 댓글, 좋아요 기능이 있음</li>
<li>AWS EC2 프리티어(t2.micro, 1vCPU + 1GB 메모리)에서 구동</li>
</ul>
<p>실제 트래픽은 많지 않지만 늘 이런 의문이 있었습니다.</p>
<p>“실제 사용자가 늘어나면 어디서 병목이 터질까?”</p>
<p>이 궁금증을 단순히 상상으로 끝내지 않고, <strong>모니터링 → 부하 테스트 → 병목 확인 → 개선</strong>이라는 루프를 직접 경험해보기로 했습니다.</p>
<hr>
<h2 id="성능-개선의-4가지-축">성능 개선의 4가지 축</h2>
<p>무작정 튜닝하는 대신, 네 가지 큰 축을 잡고 개선해 나갈 계획입니다.</p>
<ol>
<li><strong>DB 인덱스 &amp; 트리 구조</strong> – 슬로우 쿼리 분석 및 최적화</li>
<li><strong>트랜잭션 처리</strong> – 데이터 정합성과 성능 사이의 균형점 찾기</li>
<li><strong>서버 최적화</strong> – JVM, 커넥션 풀, GC 튜닝</li>
<li><strong>Redis 활용</strong> – 캐시와 세션 관리로 응답 속도 개선</li>
</ol>
<hr>
<h2 id="앞으로의-작성-계획">앞으로의 작성 계획</h2>
<h3 id="part-2-모니터링--observability-구축기">Part 2: 모니터링 &amp; Observability 구축기</h3>
<p>성능 개선의 출발점은 현재 상태를 제대로 보는 것입니다. 초기 연재에서는 모니터링 환경을 어떻게 구축하고 발전시켰는지 다룹니다.</p>
<ul>
<li>[1편] OTEL 도입 배경과 모니터링 환경 구축</li>
<li>[2편] Spring Boot + Prometheus + Loki 기본 구축</li>
<li>[3편] Grafana 대시보드 개선</li>
<li>[4편] OTEL Agent + Tempo + 슬로우 쿼리 통합</li>
<li>[5편] 운영은 감지, 로컬은 분석 — Pinup 대시보드 설계</li>
</ul>
<h3 id="part-3-이후-성능-튜닝-4개-축">Part 3 이후: 성능 튜닝 4개 축</h3>
<p>모니터링으로 병목을 확인한 뒤 본격적인 개선 과정을 기록합니다.</p>
<ul>
<li><p>[6편] DB 인덱스와 JPQL</p>
</li>
<li><p>[7편] 트랜잭션 처리와 동시성 문제 해결&gt;단일 글로 다루기엔 방대하여, 실제 개선 과정을 6개의 글로 나누어 정리했습니다.</p>
<ul>
<li>[7-1] [트랜잭션 전파 개념] </li>
<li>[7-2] [트랜잭션 전파 실제 적용기 — Pinup 최적화 기록]</li>
<li>[7-3] [트랜잭션 격리 수준 &amp; MVCC — PostgreSQL 내부 동작 이해] </li>
<li>[7-4] [락의 개념 — MVCC vs Lock의 원리와 구현 비교]</li>
<li>[7-5] [비관적 vs 낙관적 락]</li>
<li>[7-6] [재시도 정책과 ExponentialBackOffPolicy 설계]</li>
<li>[7-7] [ExponentialRandomBackOffPolicy 적용 및 내부 구조 분석]</li>
</ul>
</li>
<li><p>[8편] 캐쉬 활용 (로컬 캐쉬, Caffeine)&gt;  읽기 중심 트래픽에서 로컬(L1) 캐시로 체감 성능을 끌어올린 과정과 운영 원칙을 정리했습니다.</p>
<ul>
<li>[8-1] [Cache 개념·선택 배경과 운영 원칙 — Per-Cache 정책 &amp; Strict]</li>
<li>[8-2] [Spring Boot Cache 설계: Caffeine + AFTER_COMMIT 이벤트 기반 — L1(Local)]  </li>
</ul>
</li>
<li><p>[9편] 서버 최적화 (JVM, GC, 커넥션 풀)</p>
</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 연재는 단순히 기술만 정리하려는 글이 아닙니다.</p>
<ul>
<li><p>왜 이걸 시작했는지, 어떤 고민과 삽질을 거쳤는지 솔직하게 담으려고 했습니다.</p>
</li>
<li><p>단순히 됐다 가 아니라, Before &amp; After 지표를 통해 개선이 실제로 어떻게 보였는지도 보여주고 싶었습니다.</p>
</li>
<li><p>다른 개발자가 비슷한 상황에서 참고할 수 있도록 적용 방법과 경험을 정리했습니다</p>
</li>
</ul>
<blockquote>
<p>결국 이 글은 제 경험을 기록하고, 누군가에게는 시행착오를 줄여줄 수 있으면 좋겠다는 마음에서 쓴 글입니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next] 설치]]></title>
            <link>https://velog.io/@minpractice_jhj/Next</link>
            <guid>https://velog.io/@minpractice_jhj/Next</guid>
            <pubDate>Mon, 24 Mar 2025 15:14:36 GMT</pubDate>
            <description><![CDATA[<p>초코레티 홈페이지로 이동합니다.</p>
<p><a href="https://chocolatey.org/install#install-with-cmdexe">https://chocolatey.org/install#install-with-cmdexe</a>
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/c7533419-96cf-4e74-bb5c-3952f34e9a0f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/2c8b8127-53fe-425e-9f2f-4a3fd8523261/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/5198421c-030e-4cd6-83c4-27470b11fc8e/image.png" alt=""></p>
<p>4)Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString(&#39;<a href="https://community.chocolatey.org/install.ps1&#39;">https://community.chocolatey.org/install.ps1&#39;</a>))</p>
<p>5)choco --version
2.3.0
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/b60fee4b-75b5-4f0b-83aa-17c87a9a2df0/image.png" alt=""></p>
<p>이제 yarn 설치합니다.</p>
<p>윈도우를 위한 패키지 매니저인 Chocolatey가 설치되어 있다면 아래 명령어를 통해 손쉽게 yarn을 설치할 수 있습니다.</p>
<p>명령 프롬프트를 열어 아래 코드를 입력합니다.</p>
<p>choco install yarn
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/ba41b441-f731-469a-87e2-9517936b100e/image.png" alt=""></p>
<p>노드 설치 후 아래 명령어 설치 필요</p>
<p>yarn create next-app my-nextjs-app --typescript</p>
<p><img src="https://velog.velcdn.com/images/minpractice_jhj/post/ce70f849-717f-46f3-a8d5-df6c43bb2615/image.png" alt="">
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/76df5867-7e0d-481e-9365-7d066f40814a/image.png" alt="">
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/04e649e8-3c2c-4346-96f3-699a5d29fdce/image.png" alt="">
<img src="https://velog.velcdn.com/images/minpractice_jhj/post/d397c571-9cd5-4ea5-9335-1007269c74b8/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Install Docker on Naver Cloud Instance]]></title>
            <link>https://velog.io/@minpractice_jhj/Install-Docker-on-Naver-Cloud-Instance</link>
            <guid>https://velog.io/@minpractice_jhj/Install-Docker-on-Naver-Cloud-Instance</guid>
            <pubDate>Mon, 24 Mar 2025 15:09:15 GMT</pubDate>
            <description><![CDATA[<h3 id="1-시스템-업데이트-및-필수-패키지-설치">1. 시스템 업데이트 및 필수 패키지 설치</h3>
<p>먼저, 시스템을 업데이트하고 필요한 패키지를 설치합니다.</p>
<pre><code class="language-bash">sudo apt-get update
sudo apt-get install ca-certificates curl</code></pre>
<h3 id="2-docker-gpg-키-설정">2. Docker GPG 키 설정</h3>
<p>Docker 저장소의 GPG 키를 추가합니다.</p>
<pre><code class="language-bash">sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc</code></pre>
<h3 id="3-docker-저장소-추가">3. Docker 저장소 추가</h3>
<p>Docker의 APT 저장소를 추가합니다.</p>
<pre><code class="language-bash">echo \
  &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release &amp;&amp; echo &quot;$VERSION_CODENAME&quot;) stable&quot; | \
  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null</code></pre>
<h3 id="4-docker-설치">4. Docker 설치</h3>
<p>이제 Docker를 설치합니다.</p>
<pre><code class="language-bash">sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin</code></pre>
<h3 id="5-docker-테스트">5. Docker 테스트</h3>
<p>Docker가 정상적으로 설치되었는지 확인하기 위해 Hello World 이미지를 실행합니다.</p>
<pre><code class="language-bash">sudo docker run hello-world</code></pre>
<h3 id="6-docker-이미지-및-버전-확인">6. Docker 이미지 및 버전 확인</h3>
<p>설치된 Docker 이미지와 버전을 확인합니다.</p>
<pre><code class="language-bash">sudo docker images
sudo docker version</code></pre>
<h3 id="7-docker-서비스-설정">7. Docker 서비스 설정</h3>
<p>Docker가 시스템 부팅 시 자동으로 시작되도록 설정하고, 서비스를 시작합니다.</p>
<pre><code class="language-bash">sudo systemctl enable docker
sudo systemctl start docker
sudo service docker status</code></pre>
<p>이 명령어를 실행했을 때 <code>Active: active (running)</code> 메시지가 나타나면 Docker가 정상적으로 실행되고 있는 것입니다.</p>
<h3 id="8-kubernetes-설치를-위한-snap-설치">8. Kubernetes 설치를 위한 Snap 설치</h3>
<p>Kubernetes를 설치하기 위해 Snap 패키지 관리자를 설치합니다.</p>
<pre><code class="language-bash">sudo apt update
sudo apt install snapd</code></pre>
<h3 id="9-snap-설정">9. Snap 설정</h3>
<p>Snap의 홈 디렉토리를 설정합니다.</p>
<pre><code class="language-bash">sudo snap set system homedirs=/var/lib</code></pre>
<h3 id="10-docker-소켓-권한-설정-필요할-경우">10. Docker 소켓 권한 설정 (필요할 경우)</h3>
<p>Docker 소켓에 대한 권한을 설정합니다. 이 단계는 일부 환경에서는 생략할 수 있습니다.</p>
<pre><code class="language-bash">sudo chmod 666 /var/run/docker.sock</code></pre>
<h3 id="11-hello-world-snap-설치-테스트-용도">11. Hello World Snap 설치 (테스트 용도)</h3>
<p>Snap으로 Hello World를 설치하여 Snap이 제대로 작동하는지 확인합니다.</p>
<pre><code class="language-bash">sudo snap install hello-world</code></pre>
<h3 id="12-kubernetes-설치">12. Kubernetes 설치</h3>
<p>Kubernetes의 kubectl 명령어를 설치합니다.</p>
<pre><code class="language-bash">sudo snap install kubectl --classic</code></pre>
<ul>
<li><p><strong>kubectl 버전 확인:</strong></p>
<p>  설치된 <code>kubectl</code>의 버전을 확인합니다.</p>
<pre><code class="language-bash">  kubectl version --client</code></pre>
<p>  이 명령어를 통해 클라이언트 버전 정보를 확인할 수 있습니다.</p>
</li>
<li><p><strong>Kubernetes 클러스터 연결 확인:</strong></p>
<p>  클러스터가 설치되지 않았더라도 <code>kubectl</code>이 제대로 설치되었는지 확인할 수 있습니다.</p>
<pre><code class="language-bash">  kubectl cluster-info</code></pre>
</li>
</ul>
<h3 id="13-ncp-iam-authenticator-설치">13. ncp-iam-authenticator 설치</h3>
<ul>
<li><a href="https://guide.ncloud-docs.com/docs/k8s-iam-auth-ncp-iam-authenticator">설치 문서</a>에 따라 <code>ncp-iam-authenticator</code>를 설치합니다.</li>
</ul>
<h3 id="14-ncp-iam-authenticator-권한-주기">14. ncp-iam-authenticator 권한 주기</h3>
<pre><code class="language-bash">cd /usr/bin
chmod +x ./ncp-iam-authenticator
ls -l /usr/bin/ncp-iam-authenticator</code></pre>
<h3 id="15-ncloud-kubernetes-service-생성">15. Ncloud Kubernetes Service 생성</h3>
<p>NCP 콘솔에서 Kubernetes 클러스터를 생성합니다. 생성 후 클러스터 UUID를 기록해 둡니다.</p>
<h3 id="16-kubeconfigyaml-생성">16. kubeconfig.yaml 생성</h3>
<ol>
<li><p><strong>ncloud 디렉토리 생성:</strong></p>
<pre><code class="language-bash"> mkdir -p ~/.ncloud</code></pre>
</li>
<li><p><strong>configure 파일 생성:</strong></p>
<pre><code class="language-bash"> touch ~/.ncloud/configure</code></pre>
</li>
<li><p><strong>kubeconfig 파일 생성:</strong></p>
<pre><code class="language-bash"> cd ~/.ncloud
 ncp-iam-authenticator create-kubeconfig --region KR --clusterUuid &lt;YOUR_CLUSTER_UUID&gt; --output kubeconfig.yaml</code></pre>
<ul>
<li><code>&lt;YOUR_CLUSTER_UUID&gt;</code>를 <strong>Ncloud Kubernetes Service의</strong> 클러스터 UUID로 바꿉니다.</li>
</ul>
</li>
<li><p><strong>인증 정보 입력:</strong></p>
<pre><code class="language-bash"> Ncloud Access Key Id []: B93C5A01D2C3EDEFDA4D
 Ncloud Secret Access Key []: AAC1613AE24201F432707E7F73618CE0E9476766
 Ncloud API URL []: https://ncloud.apigw.ntruss.com/</code></pre>
<ul>
<li>네이버 클라우드 계정 관리 → 인증키 관리 : Access Key/ Secret Key</li>
</ul>
</li>
</ol>
<h3 id="17-kubeconfig-파일-이동">17. kubeconfig 파일 이동</h3>
<pre><code class="language-bash">sudo cp kubeconfig.yaml ~/.ncloud</code></pre>
<h3 id="18-bashrc-파일-수정">18. .bashrc 파일 수정</h3>
<ol>
<li><p><strong>.bashrc 파일 열기:</strong></p>
<pre><code class="language-bash"> bash
 코드 복사
 vi ~/.bashrc</code></pre>
</li>
<li><p><strong>맨 아래로 이동하여 환경 변수 추가:</strong></p>
<pre><code class="language-bash"> export KUBE_CONFIG=$HOME/.ncloud/kubeconfig.yaml</code></pre>
<ul>
<li><strong>입력 모드로 전환</strong>: <code>Shift + O</code></li>
<li><strong>저장 및 종료</strong>: <code>Esc</code> 키를 누른 후 <code>:wq</code> 입력</li>
</ul>
</li>
</ol>
<h3 id="19-도커--hub-레포지토리-에-이미지-push필요한-레포지토리-생성필요-">19. 도커  hub 레포지토리 에 이미지 push(필요한 레포지토리 생성필요 )</h3>
<ol>
<li><p>Docker 이미지 빌드 및 목록</p>
<pre><code class="language-bash">docker-compose build</code></pre>
<ul>
<li>빌드할 이미지 목록:</li>
<li><code>biday/eureka-server</code>
<code>biday/sms-service</code>
<code>biday/user-service</code>
<code>biday/gateway-server</code>
<code>biday/ftp-service</code>
<code>biday/admin-service</code>
<code>biday/product-service</code>
<code>biday/auction-service</code>
<code>biday/order-service</code>
<code>biday/config-server</code></li>
</ul>
</li>
<li><p>Docker Hub 로그인<strong>:</strong></p>
<pre><code class="language-bash"> docker login</code></pre>
</li>
<li><p>이미지 태그 지정:
각 이미지를 새로운 태그로 지정합니다. 아래 명령어들을 실행하세요.</p>
<pre><code class="language-bash"> docker tag biday/product-service:latest hwijae/biday-product-service:1.0
 docker tag biday/order-service:latest hwijae/biday-order-service:1.0
 docker tag biday/gateway-server:latest hwijae/biday-gateway-server:1.0
 docker tag biday/ftp-service:latest hwijae/biday-ftp-service:1.0
 docker tag biday/auction-service:latest hwijae/biday-auction-service:1.0
 docker tag biday/user-service:latest hwijae/biday-user-service:1.0
 docker tag biday/admin-service:latest hwijae/biday-admin-service:1.0
 docker tag biday/eureka-server:latest hwijae/biday-eureka-server:1.0
 docker tag biday/config-server:latest hwijae/biday-config-server:1.0</code></pre>
</li>
<li><p>이미지 푸시:</p>
<pre><code class="language-bash"> docker push hwijae/biday-product-service:1.0
 docker push hwijae/biday-order-service:1.0
 docker push hwijae/biday-gateway-server:1.0
 docker push hwijae/biday-ftp-service:1.0
 docker push hwijae/biday-auction-service:1.0
 docker push hwijae/biday-user-service:1.0
 docker push hwijae/biday-admin-service:1.0
 docker push hwijae/biday-eureka-server:1.0
 docker push hwijae/biday-config-server:1.0</code></pre>
</li>
</ol>
<h3 id="20-docker-소켓-권한-설정">20. Docker 소켓 권한 설정</h3>
<p>Docker 소켓의 권한을 설정하여 Jenkins가 Docker에 접근할 수 있도록 합니다.</p>
<pre><code class="language-bash"># 현재 권한 확인
ls -l /var/run/docker.sock

# 권한을 777로 변경
sudo chmod 777 /var/run/docker.sock

# 변경 후 권한 확인
ls -l /var/run/docker.sock
</code></pre>
<h3 id="21-jenkins-키-추가-및-소스-목록-설정">21. Jenkins 키 추가 및 소스 목록 설정</h3>
<p>Jenkins 패키지의 GPG 키를 추가하고 소스 목록을 설정합니다.</p>
<pre><code class="language-bash"># Jenkins GPG 키 다운로드
sudo wget -O /usr/share/keyrings/jenkins-keyring.asc \
  https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key

# Jenkins 소스 목록 추가
echo &quot;deb [signed-by=/usr/share/keyrings/jenkins-keyring.asc] https://pkg.jenkins.io/debian-stable binary/&quot; | \
  sudo tee /etc/apt/sources.list.d/jenkins.list &gt; /dev/null

# 패키지 목록 업데이트
sudo apt-get update
</code></pre>
<h3 id="22-jenkins-설치">22. Jenkins 설치</h3>
<p>Jenkins를 설치합니다.</p>
<pre><code class="language-bash">sudo apt-get install jenkins</code></pre>
<h3 id="23-java-설치">23. Java 설치</h3>
<p>Jenkins가 실행되기 위해 필요한 Java를 설치합니다.</p>
<pre><code class="language-bash">sudo apt update

#OpenJDK 17 JDK **설치**
sudo apt install fontconfig openjdk-17-jre

# Java 버전 확인
java -version
</code></pre>
<ul>
<li><p>Jenkins 에서 <strong>JDK installations 경로 못찾을경우</strong></p>
<h3 id="1-java-compiler-버전-확인">1. Java Compiler 버전 확인</h3>
<p>  먼저, 현재 설치된 Java Compiler의 버전을 확인합니다.</p>
<pre><code class="language-bash">  bash
  코드 복사
  /usr/lib/jvm/java-17-openjdk-amd64/bin/javac -version
</code></pre>
<h3 id="2-openjdk-17-jdk-제거-필요한-경우">2. OpenJDK 17 JDK 제거 (필요한 경우)</h3>
<p>  기존에 설치된 OpenJDK 17 JDK를 제거합니다. 이 단계는 이미 설치된 JDK에 문제가 있을 때만 수행하세요.</p>
<pre><code class="language-bash">  bash
  코드 복사
  sudo apt remove --purge openjdk-17-jdk
</code></pre>
<h3 id="3-패키지-목록-업데이트">3. 패키지 목록 업데이트</h3>
<p>  패키지 목록을 업데이트합니다.</p>
<pre><code class="language-bash">  bash
  코드 복사
  sudo apt update
</code></pre>
<h3 id="4-openjdk-17-jdk-설치">4. OpenJDK 17 JDK 설치</h3>
<p>  OpenJDK 17 JDK를 다시 설치합니다.</p>
<pre><code class="language-bash">  bash
  코드 복사
  sudo apt install openjdk-17-jdk
</code></pre>
<h3 id="5-설치된-java-파일-확인">5. 설치된 Java 파일 확인</h3>
<p>  Java 설치 후, 설치된 파일을 확인합니다.</p>
<pre><code class="language-bash">  bash
  코드 복사
  ls /usr/lib/jvm/java-17-openjdk-amd64/bin
</code></pre>
<h3 id="6-java_home-및-path-환경-변수-설정">6. JAVA_HOME 및 PATH 환경 변수 설정</h3>
<p>  Java 환경 변수를 설정합니다. 이 설정은 터미널 세션에만 적용되므로, 지속적으로 적용하려면 아래 단계를 추가로 진행해야 합니다.</p>
<pre><code class="language-bash">  bash
  코드 복사
  # JAVA_HOME 설정
  export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64

  # PATH에 추가
  export PATH=$JAVA_HOME/bin:$PATH
</code></pre>
<h3 id="7-환경-변수-영구-적용-선택-사항">7. 환경 변수 영구 적용 (선택 사항)</h3>
<p>  환경 변수를 영구적으로 적용하기 위해 <code>.bashrc</code> 파일에 추가합니다.</p>
<pre><code class="language-bash">  bash
  코드 복사
  echo &quot;export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64&quot; &gt;&gt; ~/.bashrc
  echo &quot;export PATH=\$JAVA_HOME/bin:\$PATH&quot; &gt;&gt; ~/.bashrc

  # 변경 사항 적용
  source ~/.bashrc
</code></pre>
</li>
</ul>
<h3 id="24-jenkins-서비스-관리">24. Jenkins 서비스 관리</h3>
<p>Jenkins 서비스가 부팅 시 자동으로 시작되도록 설정하고, 서비스를 시작합니다.</p>
<pre><code class="language-bash"># Jenkins 서비스 부팅 시 자동 시작 설정
sudo systemctl enable jenkins

# Jenkins 서비스 시작
sudo systemctl start jenkins

# Jenkins 서비스 상태 확인
sudo systemctl status jenkins
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Linux] 리눅스 명령어 정리]]></title>
            <link>https://velog.io/@minpractice_jhj/Linux-%EB%A6%AC%EB%88%85%EC%8A%A4-%EB%AA%85%EB%A0%B9%EC%96%B4-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@minpractice_jhj/Linux-%EB%A6%AC%EB%88%85%EC%8A%A4-%EB%AA%85%EB%A0%B9%EC%96%B4-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 24 Mar 2025 15:06:52 GMT</pubDate>
            <description><![CDATA[<h3 id="1-파일-및-디렉터리-관리"><strong>1. 파일 및 디렉터리 관리</strong></h3>
<h3 id="🔹-현재-디렉터리-확인-pwd">🔹 <strong>현재 디렉터리 확인 (<code>pwd</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
pwd  # 현재 작업 중인 디렉터리 출력
</code></pre>
<h3 id="🔹-디렉터리-이동-cd">🔹 <strong>디렉터리 이동 (<code>cd</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
cd 디렉터리명  # 특정 디렉터리로 이동
cd ..  # 상위 디렉터리로 이동
cd ~  # 홈 디렉터리로 이동
cd -  # 이전 작업 디렉터리로 이동
</code></pre>
<h3 id="🔹-디렉터리-목록-보기-ls">🔹 <strong>디렉터리 목록 보기 (<code>ls</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
ls  # 현재 디렉터리 파일 및 폴더 목록 출력
ls -l  # 상세 정보 포함
ls -a  # 숨김 파일 포함
ls -lh  # 사람이 읽기 쉬운 형식으로 크기 표시
ls -lt  # 최근 수정된 순서대로 정렬
</code></pre>
<h3 id="🔹-디렉터리-생성-mkdir">🔹 <strong>디렉터리 생성 (<code>mkdir</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
mkdir 디렉터리명  # 새 디렉터리 생성
mkdir -p 경로/하위디렉터리  # 상위 디렉터리까지 포함하여 생성
</code></pre>
<h3 id="🔹-디렉터리-삭제-rmdir--rm--r">🔹 <strong>디렉터리 삭제 (<code>rmdir</code> / <code>rm -r</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
rmdir 디렉터리명  # 비어있는 디렉터리 삭제
rm -r 디렉터리명  # 디렉터리와 내부 파일까지 강제 삭제
</code></pre>
<h3 id="🔹-파일-복사-cp">🔹 <strong>파일 복사 (<code>cp</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
cp 원본 대상  # 파일 복사
cp -r 원본디렉터리 대상디렉터리  # 디렉터리 복사
</code></pre>
<h3 id="🔹-파일-이동-및-이름-변경-mv">🔹 <strong>파일 이동 및 이름 변경 (<code>mv</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
mv 파일명 새파일명  # 파일 이름 변경
mv 파일명 디렉터리/  # 파일 이동
</code></pre>
<h3 id="🔹-파일-삭제-rm">🔹 <strong>파일 삭제 (<code>rm</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
rm 파일명  # 파일 삭제
rm -rf 디렉터리  # 디렉터리 및 내부 파일 강제 삭제
</code></pre>
<hr>
<h3 id="2-파일-내용-확인-및-편집"><strong>2. 파일 내용 확인 및 편집</strong></h3>
<h3 id="🔹-파일-내용-보기-cat-less-more">🔹 <strong>파일 내용 보기 (<code>cat</code>, <code>less</code>, <code>more</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
cat 파일명  # 파일 내용 출력
less 파일명  # 파일 내용을 페이지 단위로 보기 (↑↓로 이동)
more 파일명  # less와 비슷하지만 이전 페이지로 이동 불가
</code></pre>
<h3 id="🔹-파일-검색-grep">🔹 <strong>파일 검색 (<code>grep</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
grep &quot;검색어&quot; 파일명  # 특정 단어가 포함된 줄 찾기
grep -r &quot;검색어&quot; 디렉터리/  # 디렉터리 내 모든 파일에서 검색
grep -i &quot;검색어&quot; 파일명  # 대소문자 무시하고 검색
</code></pre>
<h3 id="🔹-파일-찾기-find">🔹 <strong>파일 찾기 (<code>find</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
find /경로 -name &quot;파일명&quot;  # 특정 파일 찾기
find . -type f -name &quot;*.txt&quot;  # 현재 디렉터리에서 모든 .txt 파일 찾기
</code></pre>
<hr>
<h3 id="3-프로세스-및-시스템-관리"><strong>3. 프로세스 및 시스템 관리</strong></h3>
<h3 id="🔹-프로세스-확인-ps-top-htop">🔹 <strong>프로세스 확인 (<code>ps</code>, <code>top</code>, <code>htop</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
ps aux  # 실행 중인 모든 프로세스 목록 보기
top  # 실시간으로 프로세스 및 시스템 상태 확인
htop  # top의 개선된 버전 (설치 필요)
</code></pre>
<h3 id="🔹-프로세스-종료-kill-pkill">🔹 <strong>프로세스 종료 (<code>kill</code>, <code>pkill</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
kill PID  # 특정 프로세스 종료
pkill 프로세스이름  # 특정 이름을 가진 프로세스 종료
kill -9 PID  # 강제 종료
</code></pre>
<h3 id="🔹-메모리-및-cpu-사용량-확인-free-df-du">🔹 <strong>메모리 및 CPU 사용량 확인 (<code>free</code>, <code>df</code>, <code>du</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
free -h  # 메모리 사용량 확인
df -h  # 디스크 사용량 확인
du -sh 디렉터리/  # 특정 디렉터리의 용량 확인
</code></pre>
<hr>
<h3 id="4-사용자-및-권한-관리"><strong>4. 사용자 및 권한 관리</strong></h3>
<h3 id="🔹-사용자-정보-확인-whoami-id-who">🔹 <strong>사용자 정보 확인 (<code>whoami</code>, <code>id</code>, <code>who</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
whoami  # 현재 로그인한 사용자 출력
id  # 사용자 ID 및 그룹 정보 확인
who  # 현재 시스템에 접속한 사용자 목록
</code></pre>
<h3 id="🔹-사용자-변경-su-sudo">🔹 <strong>사용자 변경 (<code>su</code>, <code>sudo</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
su - 사용자명  # 다른 사용자로 전환
sudo 명령어  # 관리자 권한으로 명령 실행
</code></pre>
<h3 id="🔹-파일-권한-변경-chmod-chown">🔹 <strong>파일 권한 변경 (<code>chmod</code>, <code>chown</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
chmod 755 파일명  # 소유자: 읽기/쓰기/실행, 그룹&amp;기타: 읽기/실행
chmod +x 파일명  # 실행 권한 추가
chown 사용자:그룹 파일명  # 파일 소유권 변경
</code></pre>
<hr>
<h3 id="5-네트워크-관련-명령어"><strong>5. 네트워크 관련 명령어</strong></h3>
<h3 id="🔹-네트워크-연결-확인-ping-curl-wget">🔹 <strong>네트워크 연결 확인 (<code>ping</code>, <code>curl</code>, <code>wget</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
ping 도메인/IP  # 특정 서버에 연결 확인
curl -I URL  # 웹 서버 응답 확인
wget URL  # 파일 다운로드
</code></pre>
<h3 id="🔹-네트워크-포트-확인-netstat-ss">🔹 <strong>네트워크 포트 확인 (<code>netstat</code>, <code>ss</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
netstat -tulnp  # 열린 포트 및 서비스 확인
ss -tulnp  # netstat 대체 명령어
</code></pre>
<h3 id="🔹-ip-주소-확인-ip-ifconfig">🔹 <strong>IP 주소 확인 (<code>ip</code>, <code>ifconfig</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
ip a  # 네트워크 인터페이스 및 IP 주소 확인
ifconfig  # (구버전) 네트워크 설정 확인
</code></pre>
<hr>
<h3 id="6-압축-및-아카이브-관리"><strong>6. 압축 및 아카이브 관리</strong></h3>
<h3 id="🔹-파일-압축-tar-zip-gzip">🔹 <strong>파일 압축 (<code>tar</code>, <code>zip</code>, <code>gzip</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
tar -cvf archive.tar 파일/디렉터리  # tar 압축 파일 만들기
tar -xvf archive.tar  # tar 압축 파일 풀기
zip -r archive.zip 파일/디렉터리  # zip 압축 파일 만들기
unzip archive.zip  # zip 압축 해제
</code></pre>
<hr>
<h3 id="7-시스템-종료-및-재부팅"><strong>7. 시스템 종료 및 재부팅</strong></h3>
<pre><code class="language-bash">bash
복사편집
shutdown -h now  # 즉시 시스템 종료
shutdown -r now  # 즉시 재부팅
reboot  # 시스템 재부팅
poweroff  # 시스템 종료
</code></pre>
<hr>
<h3 id="8-기타-유용한-명령어"><strong>8. 기타 유용한 명령어</strong></h3>
<h3 id="🔹-명령어-실행-기록-history">🔹 <strong>명령어 실행 기록 (<code>history</code>)</strong></h3>
<pre><code class="language-bash">bash
복사편집
history  # 이전에 실행한 명령어 목록 확인</code></pre>
<h3 id="🔹-명령어-실행-시간-측정-time">🔹 <strong>명령어 실행 시간 측정 (<code>time</code>)</strong></h3>
<pre><code class="language-bash">time 명령어  # 실행 시간 측정</code></pre>
<h3 id="🔹-백그라운드-실행--nohup">🔹 <strong>백그라운드 실행 (<code>&amp;</code>, <code>nohup</code>)</strong></h3>
<pre><code class="language-bash">명령어 &amp;  # 명령어를 백그라운드에서 실행
nohup 명령어 &amp;  # 로그아웃해도 실행 유지</code></pre>
<h3 id="🔹-파일-전송-명령어-사용법-scp를-활용한-ec2로-파일-전송">🔹 <strong>파일 전송 명령어 사용법 (SCP를 활용한 EC2로 파일 전송)</strong></h3>
<ul>
<li><p><strong>목적</strong>: 로컬 파일을 EC2 인스턴스로 안전하게 전송하기 위한 SCP 명령어 사용법</p>
</li>
<li><p><strong>명령어 예시</strong>:</p>
<pre><code class="language-bash">  scp -i &quot;C:\Users\hwija\Downloads\pinupkey.pem&quot; C:/Users/hwija/intellij/pinup/build/libs/pinup-0.0.1-BETA.jar ec2-user@ec2-43-203-97-218.ap-northeast-2.compute.amazonaws.com:/home/ec2-user/</code></pre>
</li>
<li><p><strong>명령어 설명</strong>:</p>
<ol>
<li><strong><code>i &quot;C:\Users\hwija\Downloads\pinupkey.pem&quot;</code></strong>: SSH 키 파일의 경로 지정</li>
<li><strong><code>C:/Users/hwija/intellij/pinup/build/libs/pinup-0.0.1-BETA.jar</code></strong>: 전송할 로컬 파일 경로</li>
<li><strong><code>ec2-user@ec2-3-34-137-70.ap-northeast-2.compute.amazonaws.com</code></strong>: EC2 인스턴스의 사용자 이름과 공인 IP 주소</li>
<li><strong><code>:/home/ec2-user/</code></strong>: EC2 인스턴스에 파일을 저장할 디렉토리</li>
</ol>
</li>
</ul>
<h3 id="🔹-ec2에서-실행-중인-jar-파일-종료-및-백그라운드-실행-방법">🔹 EC2에서 실행 중인 JAR 파일 종료 및 백그라운드 실행 방법</h3>
<h3 id="✅-1-실행-중인-프로세스-확인">✅ <strong>1. 실행 중인 프로세스 확인</strong></h3>
<ol>
<li><strong>현재 실행 중인 <code>pinup-0.0.1-BETA.jar</code> 프로세스를 확인</strong>:</li>
</ol>
<pre><code class="language-bash">ps -ef | grep pinup-0.0.1-BETA.jar</code></pre>
<ul>
<li><p>이 명령어로 실행 중인 프로세스가 있는지 확인할 수 있어. 예를 들어:</p>
<pre><code class="language-bash">  ec2-user    5613       1  0 Mar18 ?        00:01:42 java -jar -Dspring.profiles.active=prod pinup-0.0.1-BETA.jar
  ec2-user   36369   36325  0 02:41 pts/1    00:00:00 grep --color=auto pinup-0.0.1-BETA.jar</code></pre>
</li>
</ul>
<hr>
<h3 id="✅-2-기존-프로세스-종료">✅ <strong>2. 기존 프로세스 종료</strong></h3>
<ol>
<li><strong>실행 중인 프로세스(PID 5613)를 종료</strong>:</li>
</ol>
<pre><code class="language-bash">kill -9 5613</code></pre>
<ul>
<li><strong>PID 5613</strong>는 <code>pinup-0.0.1-BETA.jar</code>의 실행 중인 프로세스야. <code>kill -9</code>로 강제 종료할 수 있어.</li>
</ul>
<hr>
<h3 id="✅-3-백그라운드에서-jar-실행">✅ <strong>3. 백그라운드에서 JAR 실행</strong></h3>
<h3 id="1-명령어-1-현재-작업-디렉토리에서-실행">1. 명령어 1: 현재 작업 디렉토리에서 실행</h3>
<pre><code class="language-bash">nohup java -jar -Dspring.profiles.active=prod pinup-0.0.1-BETA.jar &gt; pin-up.log 2&gt;&amp;1 &amp;</code></pre>
<h3 id="설명">설명:</h3>
<ul>
<li><strong><code>nohup</code></strong>: 이 명령어는 현재 터미널 세션을 종료해도 실행 중인 프로세스가 계속 실행되도록 해줍니다.</li>
<li><strong><code>java -jar -Dspring.profiles.active=prod pinup-0.0.1-BETA.jar</code></strong>: <code>pinup-0.0.1-BETA.jar</code> 파일을 실행합니다. <code>Dspring.profiles.active=prod</code>는 Spring 프로파일을 <code>prod</code>로 설정하여, 해당 환경에 맞는 설정을 사용하게 합니다.</li>
<li><strong><code>&gt; pin-up.log 2&gt;&amp;1</code></strong>: 표준 출력(stdout)과 표준 오류(stderr)를 <code>pin-up.log</code> 파일로 리다이렉트합니다. 모든 로그는 이 파일에 기록됩니다.</li>
<li><strong><code>&amp;</code></strong>: 이 부분은 명령어를 백그라운드에서 실행하도록 합니다. 즉, 터미널을 차지하지 않고 계속 실행됩니다.</li>
</ul>
<h3 id="사용-조건">사용 조건:</h3>
<ul>
<li>현재 작업 중인 디렉토리(<code>pwd</code> 명령어로 확인) 내에 <code>pinup-0.0.1-BETA.jar</code> 파일이 위치해야 합니다.</li>
<li>경로를 생략하고 파일 이름만 사용하므로, 현재 작업 디렉토리에서 실행됩니다.</li>
</ul>
<hr>
<h3 id="2-명령어-2-절대-경로로-실행">2. 명령어 2: 절대 경로로 실행</h3>
<pre><code class="language-bash">nohup java -jar -Dspring.profiles.active=prod /home/ec2-user/pinup-0.0.1-BETA.jar &gt; pin-up.log 2&gt;&amp;1 &amp;</code></pre>
<h3 id="설명-1">설명:</h3>
<ul>
<li><strong><code>nohup</code></strong>: 위와 동일하게, 터미널 세션 종료 후에도 프로세스가 계속 실행됩니다.</li>
<li><strong><code>java -jar -Dspring.profiles.active=prod /home/ec2-user/pinup-0.0.1-BETA.jar</code></strong>: <code>/home/ec2-user/</code> 경로에 위치한 <code>pinup-0.0.1-BETA.jar</code> 파일을 실행합니다. <code>Dspring.profiles.active=prod</code>는 Spring 프로파일을 <code>prod</code>로 설정합니다.</li>
<li><strong><code>&gt; pin-up.log 2&gt;&amp;1</code></strong>: 표준 출력과 오류를 <code>pin-up.log</code> 파일로 리다이렉트하여 로그를 기록합니다.</li>
<li><strong><code>&amp;</code></strong>: 이 명령어를 백그라운드에서 실행하도록 합니다.</li>
</ul>
<h3 id="사용-조건-1">사용 조건:</h3>
<ul>
<li><code>pinup-0.0.1-BETA.jar</code> 파일이 <strong><code>/home/ec2-user/</code> 경로</strong>에 위치해야 합니다.</li>
<li>절대 경로를 사용하기 때문에 현재 작업 중인 디렉토리와 관계 없이 파일을 정확한 경로로 지정할 수 있습니다.</li>
</ul>
<hr>
<h3 id="✅-4-실행-확인">✅ <strong>4. 실행 확인</strong></h3>
<ol>
<li><strong>새로 실행된 프로세스 확인</strong>:</li>
</ol>
<pre><code class="language-bash">bash
복사편집
ps -ef | grep pinup-0.0.1-BETA.jar
</code></pre>
<ul>
<li><strong>이 명령어로 실행 중인 프로세스를 확인</strong>하고, 새로운 PID가 나타나면 정상적으로 실행되고 있다는 뜻이야.</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[YML 설정 관리 방식]]></title>
            <link>https://velog.io/@minpractice_jhj/YML-%EC%84%A4%EC%A0%95-%EA%B4%80%EB%A6%AC-%EB%B0%A9%EC%8B%9D</link>
            <guid>https://velog.io/@minpractice_jhj/YML-%EC%84%A4%EC%A0%95-%EA%B4%80%EB%A6%AC-%EB%B0%A9%EC%8B%9D</guid>
            <pubDate>Mon, 24 Mar 2025 15:00:40 GMT</pubDate>
            <description><![CDATA[<h3 id="aws-배포-환경에서의-수동-설정-수정의-불편함과-해결책"><strong>AWS 배포 환경에서의 수동 설정 수정의 불편함과 해결책</strong></h3>
<p>이번 주 토요일, 팀 프로젝트를 배포하기 전에 개인적으로 배포 연습을 시작했어요. AWS의 Elastic Beanstalk와 같은 배포 환경에서 작업을 하다 보니, <strong>환경별 설정 파일을 수동으로 수정해야 하는 과정에서 불편함</strong>을 겪게 되었습니다. 특히, <strong><code>YML</code> 파일을 환경마다 수정하는 일이 번거롭고 실수할 가능성이 많다는 점</strong>이 문제였습니다.</p>
<h3 id="문제의-핵심-수동-수정의-번거로움"><strong>문제의 핵심: 수동 수정의 번거로움</strong></h3>
<p>배포 작업을 하면서, 각 환경(개발, 프로덕션 등)마다 환경 설정을 바꾸는 일이 빈번하게 발생했어요. 그때마다 수동으로 <strong><code>YML</code> 파일을 수정</strong>하고 다시 배포를 해야 하는데, 이 과정에서 실수도 할 수 있고, 설정을 잊거나 잘못 적용할 위험도 있었습니다.</p>
<h3 id="해결책-멀티-환경-설정-도입"><strong>해결책: 멀티 환경 설정 도입</strong></h3>
<p>이 문제를 해결하기 위해, <strong>멀티 환경 설정 방식</strong>을 도입하기로 했습니다. 이 방식은 각 환경(개발, 프로덕션 등)에 맞는 설정 파일을 나누어두고, <strong>환경별로 자동 적용</strong>되도록 설정하는 방법입니다. 구체적으로는, <strong>환경별 프로파일을 활용하거나</strong>, <code>application-{profile}.yml</code> 형식으로 파일을 나누어 관리할 수 있도록 했어요. 이렇게 하면, 각 환경에서 필요한 설정을 자동으로 적용할 수 있어 수동 수정의 불편함을 크게 줄일 수 있죠.</p>
<hr>
<h3 id="왜-이-방식을-택했는지">왜 이 방식을 택했는지</h3>
<p>개인 프로젝트를 배포하는 과정에서, AWS 환경에 맞춰 배포 설정을 수정하는 데 불편함을 느꼈습니다. 특히, <strong>각 환경에 맞는 설정을 수동으로 수정</strong>해야 하므로 번거롭고 실수할 가능성이 컸습니다. 이를 해결하고자 <strong>멀티 환경 설정 방식</strong>을 도입하여, 환경별로 설정을 자동으로 적용할 수 있는 방법을 모색하게 되었습니다.</p>
<h3 id="멀티-환경-설정-방식">멀티 환경 설정 방식</h3>
<h3 id="1-하나의-applicationyml-파일에-여러-환경-설정을----구분자로-나누어-관리하는-방법">1. <strong>하나의 <code>application.yml</code> 파일에 여러 환경 설정을 <code>--</code> 구분자로 나누어 관리</strong>하는 방법</h3>
<p><strong>장점</strong>:</p>
<ul>
<li><strong>편리한 관리</strong>: 모든 환경 설정을 하나의 파일에서 관리할 수 있어, 여러 환경 설정을 수정할 때 파일을 여러 번 수정할 필요가 없습니다.</li>
<li><strong>가독성</strong>: 하나의 파일에서 각 환경 설정을 모두 볼 수 있기 때문에, 설정을 한눈에 파악할 수 있습니다.</li>
<li><strong>설정 변경 용이</strong>: 모든 환경 설정이 하나의 파일에 있기 때문에 변경 사항을 한 번에 적용할 수 있습니다.</li>
</ul>
<p><strong>단점</strong>:</p>
<ul>
<li><strong>파일 크기 증가</strong>: 환경 설정이 많아질수록 파일 크기가 커지고 복잡해질 수 있어, 관리가 어려워질 수 있습니다.</li>
<li><strong>충돌 가능성</strong>: 하나의 파일에 여러 환경 설정이 포함되기 때문에, 실수로 덮어쓰거나 충돌이 발생할 위험이 있습니다.</li>
<li><strong>환경별 독립성 부족</strong>: 설정이 한 파일에 모여 있기 때문에 각 환경 간에 설정이 독립적이지 않아 일부 환경 설정이 다른 환경에 영향을 미칠 수 있습니다.</li>
</ul>
<p><strong>예시</strong>:</p>
<pre><code class="language-yaml">yaml
복사편집
# 기본 설정
spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/pinup

---
# test 프로파일
spring:
  config:
    activate:
      on-profile: test
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE

---
# prod 프로파일
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://172.17.0.2:5432/pinup
</code></pre>
<h3 id="2-각-프로파일별로-파일을-나누어-관리하는-방법">2. <strong>각 프로파일별로 파일을 나누어 관리</strong>하는 방법</h3>
<p><strong>장점</strong>:</p>
<ul>
<li><strong>환경별 독립성</strong>: 각 환경 설정이 별도의 파일에 저장되므로 충돌이 없고, 설정이 독립적입니다.</li>
<li><strong>파일 크기 관리</strong>: 각 파일이 작고 단순하게 유지될 수 있어 관리가 쉬워집니다.</li>
<li><strong>환경 관리 명확성</strong>: 각 파일이 어떤 환경에 대응하는지 명확히 구분되어 있기 때문에 관리가 쉽습니다.</li>
</ul>
<p><strong>단점</strong>:</p>
<ul>
<li><strong>설정 중복</strong>: 여러 환경에서 공통으로 사용하는 설정이 있을 경우, 각 파일을 수정해야 하는 중복이 발생할 수 있습니다.</li>
<li><strong>파일 관리 복잡성</strong>: 여러 개의 설정 파일을 관리해야 하므로 파일을 찾고 수정하는 과정에서 번거로움이 있을 수 있습니다.</li>
<li><strong>파일 추가 필요</strong>: 각 환경에 맞는 새로운 파일을 추가해야 하므로 파일 수가 많아질 수 있습니다.</li>
</ul>
<p><strong>예시</strong>:</p>
<ul>
<li><code>application.yml</code> (공통 설정)</li>
</ul>
<pre><code class="language-yaml">spring:
  application:
    name: pinup

  profiles:
    default: local

  servlet:
    multipart:
      enabled: true
      max-file-size: 10MB
      max-request-size: 10MB

springdoc:
  packages-to-scan: com.colabear754.springdoc_example.controllers
  default-consumes-media-type: application/json;charset=UTF-8
  default-produces-media-type: application/json;charset=UTF-8
  swagger-ui:
    path: /springdoc.html
    disable-swagger-default-url: true
    display-request-duration: true
    operations-sorter: alpha

server:
  servlet:
    context-path: /
    encoding:
      enabled: true
      charset: UTF-8
      force: true</code></pre>
<ul>
<li><code>application-local.yml</code> (개발 환경 설정)</li>
</ul>
<pre><code class="language-yaml">spring:
  config:
    activate:
      on-profile: local

  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/pinup
    username: 
    password: 

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    database: postgresql
    properties:
      hibernate:
        format_sql: true

  security:
    oauth2:
      client:
        registration:
          naver:
            client-id: 
            client-secret: 
            scope:
              - name
              - email
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/api/members/oauth/naver
          google:
            client-id: 
            client-secret: 
            scope:
              - profile
              - email
            client-name: Google
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/api/members/oauth/google
          kakao:
            client-id: your-kakao-client-id
            client-secret: your-kakao-client-secret
            scope:
              - profile
              - email
            client-name: Kakao
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/api/members/oauth/kakao

        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response
          google:
            authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
            token-uri: https://oauth2.googleapis.com/token
            user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
            user-name-attribute: sub
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth2/authorize
            token-uri: https://kauth.kakao.com/oauth2/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

cloud:
  aws:
    s3:
      bucket: pinup
      endpoint: http://127.0.0.1:4566  # LocalStack의 S3 엔드포인트 주소
    region.static: us-east-1
    stack.auto: false
    credentials:
      accessKey: test
      secretKey: test
</code></pre>
<ul>
<li><code>application-prod.yml</code> (프로덕션 환경 설정)</li>
</ul>
<pre><code class="language-yaml">spring:
  config:
    activate:
      on-profile: prod

  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://172.17.0.2:5432/pinup
    username: 
    password: 

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    database: postgresql
    properties:
      hibernate:
        format_sql: true

  security:
    oauth2:
      client:
        registration:
          naver:
            client-id: 
            client-secret: 
            scope:
              - name
              - email
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: http://43.201.64.159:8080/api/members/oauth/naver
          google:
            client-id: 
            client-secret: 
            scope:
              - profile
              - email
            client-name: Google
            authorization-grant-type: authorization_code
            redirect-uri: http://43.201.64.159:8080/api/members/oauth/google
          kakao:
            client-id: your-kakao-client-id
            client-secret: your-kakao-client-secret
            scope:
              - profile
              - email
            client-name: Kakao
            authorization-grant-type: authorization_code
            redirect-uri: http://43.201.64.159:8080/api/members/oauth/kakao

        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response
          google:
            authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
            token-uri: https://oauth2.googleapis.com/token
            user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
            user-name-attribute: sub
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth2/authorize
            token-uri: https://kauth.kakao.com/oauth2/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

cloud:
  aws:
    s3:
      bucket: bucket0127
    region.static: ap-northeast-2
    stack.auto: false
    credentials:
      accessKey: AKIA47GCAGT5K52CCLVA
      secretKey: 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
</code></pre>
<ul>
<li><code>application-test.yml</code> (test설정)</li>
</ul>
<pre><code class="language-yaml">spring:
  config:
    activate:
      on-profile: test

  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    username: sa
  jpa:
    show-sql: true
  output:
    ansi:
      enabled: always

  security:
    oauth2:
      client:
        registration:
          naver:
            client-id: 
            client-secret: 
            scope:
              - name
              - email
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/api/members/oauth/naver
          google:
            client-id: 
            client-secret: 
            scope:
              - profile
              - email
            client-name: Google
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/api/members/oauth/google
          kakao:
            client-id: your-kakao-client-id
            client-secret: your-kakao-client-secret
            scope:
              - profile
              - email
            client-name: Kakao
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/api/members/oauth/kakao

        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response
          google:
            authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
            token-uri: https://oauth2.googleapis.com/token
            user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
            user-name-attribute: sub
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth2/authorize
            token-uri: https://kauth.kakao.com/oauth2/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

cloud:
  aws:
    s3:
      bucket: pinup
      endpoint: http://127.0.0.1:4566
    region.static: us-east-1
    stack.auto: false
    credentials:
      accessKey: test
      secretKey: test
</code></pre>
<h3 id="결론-내가-선택한-방식">결론: 내가 선택한 방식</h3>
<p>위 두 가지 방식 중에서 나는 <strong>각 프로파일별로 파일을 나누는 방식</strong>을 선택했습니다. 이 방식은 각 환경 설정을 독립적으로 관리할 수 있어 <strong>충돌을 방지</strong>하고, 설정 변경이 발생했을 때 각 환경에 맞는 파일만 수정하면 되므로 <strong>유지보수</strong>가 용이하다고 판단했습니다.</p>
<h3 id="최종-결과">최종 결과</h3>
<ul>
<li><strong>환경별 독립성 보장</strong>: 각 환경에 맞는 설정이 독립적인 파일에 저장되므로, 설정 간 충돌이 발생할 위험이 적고, 각 환경의 설정을 명확히 구분할 수 있습니다.</li>
<li><strong>파일 크기 관리</strong>: 설정이 많아져도 각 파일이 작고 단순하게 유지되므로 관리가 용이합니다.</li>
<li><strong>환경 설정의 명확성</strong>: 각 파일이 어떤 환경에 대응하는지 명확히 알 수 있어, 어느 설정이 어느 환경에 적용되는지 쉽게 파악할 수 있습</li>
</ul>
<h3 id="1-gradle-빌드-테스트-제외--prod-환경-적용"><strong>1. Gradle 빌드 (테스트 제외 &amp; prod 환경 적용)</strong></h3>
<pre><code>./gradlew build -x test -Dspring.profiles.active=prod</code></pre><h3 id="2-jar-실행-prod-환경-적용"><strong>2. JAR 실행 (prod 환경 적용)</strong></h3>
<pre><code>java -jar -Dspring.profiles.active=prod build/libs/your-app.jar</code></pre>]]></description>
        </item>
    </channel>
</rss>