<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Rockernun.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Tue, 20 Oct 2026 06:55:08 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Rockernun.log</title>
            <url>https://velog.velcdn.com/images/rocker_nun/profile/f0c0bd50-74e7-4ccf-a1cf-9f8278565deb/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Rockernun.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/rocker_nun" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Spring AOP 개념]]></title>
            <link>https://velog.io/@rocker_nun/Spring-AOP-%EA%B0%9C%EB%85%90</link>
            <guid>https://velog.io/@rocker_nun/Spring-AOP-%EA%B0%9C%EB%85%90</guid>
            <pubDate>Tue, 20 Oct 2026 06:55:08 GMT</pubDate>
            <description><![CDATA[<h1 id="🧩-핵심-기능과-부가-기능">🧩 핵심 기능과 부가 기능</h1>
<p>애플리케이션 로직은 크게 두 가지로 나눌 수 있다.</p>
<ol>
<li><p><strong>핵심 기능:</strong> 해당 객체가 제공하는 고유의 기능이다. 예를 들어, <code>OrderService</code>의 핵심 기능은 주문 로직이다. 이 객체가 존재하는 이유 그 자체다.</p>
</li>
<li><p><strong>부가 기능:</strong> 핵심 기능을 보조하기 위한 기능이다. 로그 추적, 트랜잭션, 실행 시간 측정 같은 것들이 여기에 속한다. 부가 기능은 단독으로 쓰이지 않는다. 로그 추적은 어떤 핵심 기능이 호출되었는지를 남기기 위해 존재하고, 트랜잭션은 핵심 기능을 하나의 작업 단위로 묶기 위해 존재한다. 이름 그대로 핵심 기능을 보조하기 위해 있는 것이다.</p>
</li>
</ol>
<p>&nbsp;</p>
<p>문제는 주문 로직을 실행하기 직전과 직후에 로그를 남겨야 한다면, 두 기능이 하나의 객체 안에 섞여 들어간다는 점이다. <code>OrderService</code> 코드를 보자.</p>
<pre><code class="language-java">public class OrderService {

    private final OrderRepository orderRepository;
    private final LogTrace trace;

    public void orderItem(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin(&quot;OrderService.orderItem()&quot;);  // 부가 기능

            orderRepository.save(itemId);  // 핵심 기능 (단 한 줄!)

            trace.end(status);  // 부가 기능
        } catch (Exception e) {
            trace.exception(status, e);  // 부가 기능
            throw e;
        }
    }
}</code></pre>
<p>핵심 기능은 <code>orderRepository.save(itemId)</code> 단 한 줄인데, 그걸 감싸는 부가 기능 코드가 훨씬 길다. 이 메서드만 봐서는 <strong>*&quot;주문을 처리한다&quot;*</strong> 는 본래 의도가 잘 보이지 않는다.</p>
<p>&nbsp;</p>
<h1 id="🕸️-횡단-관심사-cross-cutting-concerns">🕸️ 횡단 관심사 (Cross-Cutting Concerns)</h1>
<p>더 큰 문제는 <strong>부가 기능이 보통 한 곳에만 필요한 게 아니라는 점</strong>이다. 만약 *&quot;모든 애플리케이션 호출을 로깅해 달라&quot;* 는 요구사항이 들어왔다고 해보자. 그러면 로그 추적 로직은 <code>OrderController</code>, <code>OrderService</code>, <code>OrderRepository</code> 전부에 들어가야 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a24343c2-1521-4b93-9c57-6fc968e9dc9c/image.png" alt=""></p>
<p>이렇게 하나의 부가 기능이 여러 클래스에 걸쳐 동일하게 사용되는 것을 <strong>횡단 관심사(Cross-Cutting Concerns)</strong>라고 한다. </p>
<p>하지만 부가 기능을 적용해야 할 클래스가 100개, 1000개, 그 이상이라면 어떻게 될까? </p>
<p>&nbsp;</p>
<p><strong>1. 아주 많은 반복이 필요:</strong> 100개 클래스에 동일한 코드를 100번 추가해야 한다.</p>
<p><strong>2. 유틸리티 클래스로 빼도 해결되지 않음:</strong> *&quot;그럼 로그 기능을 별도 클래스로 만들면 되지 않나?&quot;* 싶지만, 그 유틸리티를 호출하는 코드 역시 100군데 남을 것이다. 게다가 위 예제처럼 <code>try ~ catch ~ finally</code> 구조가 필요한 부가 기능이라면, 단순한 메서드 호출 한 줄로도 끝나지 않는다. 실행 시간 측정 같은 기능이 대표적이다.</p>
<p><strong>3. 수정 지옥:</strong> 로그 형식을 바꿔야 한다면? 100개 클래스를 하나씩 찾아가며 고쳐야 한다.</p>
<p><strong>4. 적용 대상을 바꾸는 것도 힘듦:</strong>  처음엔 컨트롤러 / 서비스 / 레포지토리 전부에 로그를 남겼는데, 로그가 너무 많아서 서비스 계층에만 남기는 것으로 바뀌었다면? 이번엔 컨트롤러와 리포지토리에서 해당 코드를 전부 걷어내야 한다.</p>
<p>&nbsp;</p>
<table>
<thead>
<tr>
<th>문제</th>
<th>원인</th>
</tr>
</thead>
<tbody><tr>
<td>아주 많은 반복</td>
<td>적용 대상마다 같은 코드를 넣어야 함</td>
</tr>
<tr>
<td>중복 코드 확산</td>
<td>부가 기능이 여러 클래스에 흩어짐</td>
</tr>
<tr>
<td>변경 시 대규모 수정</td>
<td>중복된 모든 지점을 고쳐야 함</td>
</tr>
<tr>
<td>적용 대상 변경 시 대규모 수정</td>
<td>*&quot;어디에 적용할지&quot;* 가 코드 곳곳에 박혀 있음</td>
</tr>
</tbody></table>
<p>소프트웨어 개발에서 <strong>변경 지점은 하나로 모듈화</strong>되어야 한다는 원칙이 있다. 그런데 부가 기능처럼 특정 로직을 애플리케이션 전반에 적용하는 문제는 일반적인 OOP 방식만으로는 깔끔하게 해결하기 어렵다. </p>
<p>&nbsp;</p>
<h1 id="💡-애스펙트aspect의-등장">💡 애스펙트(Aspect)의 등장</h1>
<p>위와 같은 문제를 해결하기 위해 나온 아이디어는 단순하다.</p>
<blockquote>
<p><strong><em>“부가 기능을 핵심 기능에서 분리하고, 한 곳에서 관리하자. 그리고 그 부가 기능을 어디에 적용할지 선택하는 기능도 함께 만들자.”</em></strong></p>
</blockquote>
<p>이렇게 <strong>부가 기능</strong>과 <strong>그 부가 기능을 어디에 적용할지 정의한 것</strong>을 합쳐서 하나의 모듈로 만든 것이 바로 <strong>애스펙트(Aspect)</strong> 다. 예를 들면 *&quot;로그 출력 기능을, 모든 컨트롤러에 적용하라&quot;* 가 하나의 애스펙트다. 뭔가 익숙한 느낌이다. 이전에 직접 만들어본 <code>LogTraceAspect</code>가 바로 이것이다.</p>
<pre><code class="language-java">@Aspect
public class LogTraceAspect {

    @Around(&quot;execution(* com.example.advanced_spring.proxy..*(..))&quot;)  // 어디에 적용할지
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        // 무엇을 적용할지 (로그 추적 로직)
    }
}</code></pre>
<p>그리고 스프링의 <strong>어드바이저(Advisor)</strong>도 어드바이스(부가 기능)와 포인트컷(적용 대상)을 가지고 있으니, 개념상 하나의 애스펙트다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7c9f4879-db17-4acb-81ad-98da91663ddf/image.png" alt=""></p>
<p>이제 핵심 기능 코드에는 로그 관련 코드가 한 줄도 없다. <code>OrderService</code>는 다시 주문 로직만 남는다.</p>
<pre><code class="language-java">public void orderItem(String itemId) {
    orderRepository.save(itemId);   // 핵심 기능만!
}</code></pre>
<p>로그 형식을 바꾸고 싶으면 <code>LogTraceAspect</code> 하나만 고치면 되고, 적용 대상을 바꾸고 싶으면 포인트컷 표현식 하나만 고치면 된다. 앞서 본 네 가지 문제가 한 번에 해결된다.</p>
<p>&nbsp;</p>
<h2 id="🔭-관점-지향-프로그래밍-aop">🔭 관점 지향 프로그래밍 (AOP)</h2>
<p>애스펙트(Aspect)는 우리말로 <strong>관점</strong>이다. 애플리케이션을 하나하나의 기능 단위로만 보던 시선에서, <strong>횡단 관심사라는 관점</strong>으로 달리 보는 것이다. 이렇게 애스펙트를 사용한 프로그래밍 방식을 <strong>관점 지향 프로그래밍, AOP(Aspect-Oriented Programming)</strong>라고 한다. </p>
<p>여기서 꼭 짚고 넘어가야 할 점은, AOP는 OOP를 대체하기 위한 것이 아니라는 점이다. 횡단 관심사를 깔끔하게 처리하기 어려운 OOP의 부족한 부분을 보조하기 위해 개발되었다. 핵심 기능은 여전히 객체지향으로 설계한다. AOP는 그 위에서 여러 객체를 가로지르는 관심사만 따로 떼어내 관리하도록 돕는 도구라고 생각하면 된다.</p>
<p>&nbsp;</p>
<h2 id="🛠️-aspectj-프레임워크와-스프링-aop">🛠️ AspectJ 프레임워크와 스프링 AOP</h2>
<p>AOP의 대표적인 구현체로 <strong>AspectJ 프레임워크</strong>가 있다. AspectJ는 스스로를 이렇게 소개한다.</p>
<ul>
<li>자바 프로그래밍 언어에 대한 <strong>완벽한 관점 지향 확장</strong></li>
<li>횡단 관심사의 깔끔한 모듈화<ul>
<li>오류 검사 및 처리</li>
<li>동기화</li>
<li>성능 최적화(캐싱)</li>
<li>모니터링 및 로깅</li>
</ul>
</li>
</ul>
<p>스프링도 AOP를 지원하지만, 대부분 <strong>AspectJ의 문법을 차용</strong>하고 AspectJ가 제공하는 기능의 일부만 제공한다. 지금까지 써온 <code>@Aspect</code>, <code>@Around</code>, <code>execution(...)</code> 같은 표현식이 전부 AspectJ에서 온 것이다. 그렇다면 둘은 무엇이 다를까? 그 차이는 <strong>부가 기능을 언제, 어떻게 실제 코드에 붙이느냐</strong>에서 갈린다.</p>
<p>&nbsp;</p>
<h1 id="⏱️-aop-적용-방식-언제-부가-기능을-붙일-것인가">⏱️ AOP 적용 방식: 언제 부가 기능을 붙일 것인가</h1>
<p>AOP를 쓰면 핵심 기능과 부가 기능이 코드상 완전히 분리된다. 그런데 실행할 때는 결국 둘이 합쳐져야 로그가 찍힌다. 그렇다면 분리된 부가 기능은 어느 시점에 실제 로직에 추가될까?</p>
<p>크게 세 가지 방법이 있다.</p>
<ol>
<li>컴파일 시점</li>
<li>클래스 로딩 시점</li>
<li>런타임 시점(프록시)</li>
</ol>
<p>그 전에 용어 하나만 짚고 가자. 원본 로직에 부가 기능 로직이 추가되는 것을 <strong>위빙(Weaving)</strong>이라고 한다. *&quot;옷감을 짜다, 직조하다&quot;* 라는 뜻으로, 애스펙트와 실제 코드를 엮어서 붙인다는 의미다.</p>
<p>&nbsp;</p>
<h2 id="1️⃣-컴파일-시점---컴파일-타임-위빙">1️⃣ 컴파일 시점 - 컴파일 타임 위빙</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/004c8a55-ca30-4dd0-961c-e4597db016d7/image.png" alt=""></p>
<p><code>.java</code> 소스 코드를 컴파일러로 <code>.class</code>로 만드는 시점에 부가 기능 로직을 추가하는 방식이다. 이때는 일반 <code>javac</code>가 아니라 <strong>AspectJ가 제공하는 특별한 컴파일러</strong>를 써야 한다.</p>
<p>AspectJ 컴파일러는 애스펙트를 확인해서 해당 클래스가 적용 대상인지 먼저 판단하고, 대상이라면 부가 기능 로직을 끼워 넣는다. 그래서 컴파일된 <code>.class</code>를 디컴파일해 보면 애스펙트 관련 호출 코드가 실제로 들어가 있다. 쉽게 말해 부가 기능 코드가 핵심 기능 코드 주변에 물리적으로 붙어 버리는 것이다.</p>
<p><strong>단점</strong>: 특별한 컴파일러가 필요하고, 빌드 과정이 복잡해진다.</p>
<p>&nbsp;</p>
<h2 id="2️⃣-클래스-로딩-시점---로드-타임-위빙">2️⃣ 클래스 로딩 시점 - 로드 타임 위빙</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3c06b187-146e-437d-b240-7670a0f72134/image.png" alt=""></p>
<p>자바를 실행하면 <code>.class</code> 파일은 JVM 내부의 클래스 로더에 보관된다. 자바는 <code>.class</code>를 <strong>JVM에 올리기 직전에 조작할 수 있는 기능</strong>을 제공하는데(<code>java.lang.instrument</code>, Java Instrumentation), 이 틈을 이용해 바이트코드를 조작한 뒤 JVM에 올리는 방식이다. 이 시점에 애스펙트를 적용하는 것을 <strong>로드 타임 위빙</strong>이라 한다.</p>
<p>수많은 APM, 모니터링 툴이 바로 이 방식을 사용한다. 애플리케이션 코드를 한 줄도 건드리지 않고 메서드 실행 시간을 측정할 수 있는 비결이 여기에 있다.</p>
<p><strong>단점</strong>: 자바를 실행할 때 <code>java -javaagent</code> 같은 특별한 옵션으로 클래스 로더 조작기를 지정해야 한다. 이 부분이 번거롭고 운영하기 어렵다.</p>
<p>&nbsp;</p>
<h2 id="3️⃣-런타임-시점---프록시">3️⃣ 런타임 시점 - 프록시</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/5b5b314b-14a8-459e-9521-ff2c65429879/image.png" alt=""></p>
<p>굉장히 익숙한 그림이다. <strong>이전에 다룬 빈 후처리기의 동작 과정 그대로다.</strong></p>
<p>런타임 시점은 컴파일도 다 끝나고, 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 다음(<code>main</code> 메서드가 실행된 이후)을 말한다. 바이트코드를 건드릴 수 없으니, 자바 언어가 허용하는 범위 안에서 부가 기능을 적용해야 한다.</p>
<p>그래서 스프링 같은 컨테이너의 도움을 받아 프록시, DI, 빈 포스트 프로세서 같은 개념을 총동원한다. 원본 객체 대신 부가 기능이 담긴 프록시를 스프링 빈으로 등록해 버리는 것이다. 그렇다. <strong>지금까지 학습한 것이 바로 프록시 방식의 AOP</strong>였다.</p>
<p><strong>장점</strong>: 특별한 컴파일러도, 복잡한 실행 옵션도, 클래스 로더 조작기도 필요 없다. 스프링만 있으면 된다.
<strong>단점</strong>: 프록시를 사용하기 때문에 AOP 기능에 일부 제약이 있다. </p>
<p>&nbsp;</p>
<h3 id="👀-세-가지-방식-한눈에-비교">👀 세 가지 방식 한눈에 비교</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>컴파일 시점</th>
<th>클래스 로딩 시점</th>
<th>런타임 시점</th>
</tr>
</thead>
<tbody><tr>
<td>위빙 이름</td>
<td>컴파일 타임 위빙</td>
<td>로드 타임 위빙</td>
<td>런타임 위빙</td>
</tr>
<tr>
<td>방식</td>
<td>AspectJ 컴파일러가 <code>.class</code>에 코드 삽입</td>
<td>클래스 로더 조작기가 바이트코드 조작</td>
<td>프록시를 빈으로 등록</td>
</tr>
<tr>
<td>실제 대상 코드</td>
<td>부가 기능 호출 코드가 <strong>포함됨</strong></td>
<td>부가 기능 호출 코드가 <strong>포함됨</strong></td>
<td><strong>그대로 유지됨</strong></td>
</tr>
<tr>
<td>필요한 것</td>
<td>AspectJ 직접 사용</td>
<td>AspectJ 직접 사용 + <code>-javaagent</code></td>
<td>스프링 컨테이너</td>
</tr>
<tr>
<td>사용하는 쪽</td>
<td>AspectJ</td>
<td>AspectJ, 모니터링 툴</td>
<td><strong>스프링 AOP</strong></td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h1 id="📍-aop-적용-위치-조인-포인트와-스프링-aop의-제약">📍 AOP 적용 위치: 조인 포인트와 스프링 AOP의 제약</h1>
<p>AOP는 지금까지 다룬 메서드 실행 말고도 다양한 위치에 적용할 수 있다.</p>
<ul>
<li>생성자 호출</li>
<li>필드 값 접근</li>
<li>static 메서드 접근</li>
<li>메서드 실행</li>
</ul>
<p>이렇게 AOP를 적용할 수 있는 지점을 <strong>조인 포인트(Join Point)</strong>라고 한다.</p>
<p>AspectJ를 써서 컴파일 시점이나 클래스 로딩 시점에 적용하는 AOP는 바이트코드를 실제로 조작하기 때문에, 위의 모든 지점에 다 적용할 수 있다.</p>
<p>반면 프록시 방식을 쓰는 스프링 AOP는 <strong>메서드 실행 지점에만</strong> AOP를 적용할 수 있다. 왜일까?</p>
<p>프록시는 <strong>메서드 오버라이딩 개념으로 동작</strong>하기 때문이다. JDK 동적 프록시든 CGLIB이든, 결국 클라이언트가 호출한 <strong>메서드를 가로채서</strong> 부가 기능을 실행한 뒤 원본 메서드를 호출하는 구조다. 생성자나 static 메서드, 필드 값 접근은 오버라이딩으로 가로챌 방법이 없으니 프록시 개념이 적용될 수 없다.</p>
<p>추가로, 프록시를 만들고 바꿔치기하는 주체가 스프링 컨테이너(빈 후처리기)이므로, <strong>스프링 컨테이너가 관리하는 스프링 빈에만</strong> AOP를 적용할 수 있다. <code>new</code>로 직접 만든 객체에는 적용되지 않는다. 꼭 기억하도록 하자.</p>
<blockquote>
<p><strong>스프링 AOP의 두 가지 제약</strong></p>
<ol>
<li>조인 포인트는 <strong>메서드 실행 지점</strong>으로 제한된다.</li>
<li><strong>스프링 빈</strong>에만 적용할 수 있다.</li>
</ol>
</blockquote>
<p>참고로 이 제약은 실무에서 꽤 자주 마주친다. 대표적인 것이 같은 객체 안에서 자기 메서드를 호출하는 경우(<code>this.method()</code>)인데, 이때는 프록시를 거치지 않고 원본 객체의 메서드를 직접 부르기 때문에 부가 기능이 적용되지 않는다. <code>@Transactional</code>이나 <code>@Async</code>를 붙는데 동작하지 않는 대표적인 원인이기도 하다.</p>
<blockquote>
<p>관련 PR: <a href="https://github.com/kb-final/nagasseum-cheongnyeon_backend/pull/44">https://github.com/kb-final/nagasseum-cheongnyeon_backend/pull/44</a></p>
</blockquote>
<p>&nbsp;</p>
<h2 id="🤔-그럼-기능이-더-많은-aspectj를-직접-쓰는-게-낫지-않을까">🤔 그럼 기능이 더 많은 AspectJ를 직접 쓰는 게 낫지 않을까?</h2>
<p>AspectJ를 쓰려면 공부할 내용도 많고, 특별한 컴파일러, AspectJ 전용 문법, 자바 실행 옵션 등 설정도 복잡하다.</p>
<p>반면 스프링 AOP는 <strong>별도의 추가 자바 설정 없이</strong> 스프링만 있으면 편리하게 AOP를 사용할 수 있다. 그리고 실무에서 필요한 부가 기능(로깅, 트랜잭션, 실행 시간 측정 등)은 대부분 스프링 빈의 메서드 실행 지점만으로 충분하다. 스프링이 제공하는 AOP 기능만으로도 대부분의 문제를 해결할 수 있으니, 스프링 AOP에 집중하는 것이 효율적이다.</p>
<p>참고로 스프링은 <strong>AspectJ의 문법을 차용</strong>하고, 실제 적용은 <strong>프록시 방식</strong>으로 한다. <code>@Aspect</code>를 쓴다고 해서 AspectJ 컴파일러가 동작하는 게 아니다. 이전에도 살펴본 것처럼, 자동 프록시 생성기가 <code>@Aspect</code>를 읽어 어드바이저로 변환한 뒤 프록시를 만드는 것이다.</p>
<p>&nbsp;</p>
<h1 id="📚-aop-용어-정리">📚 AOP 용어 정리</h1>
<p>AOP 용어는 서로 얽혀 있어서 하나씩 외우면 금방 헷갈린다. 각 용어의 <strong>역할</strong>과 <strong>관계</strong>를 중심으로, 이전 글의 <code>LogTraceAspect</code> 코드에 대입하며 정리해 보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3f31dca8-7e48-4905-88c3-ab97340dab37/image.png" alt=""></p>
<h2 id="🍯-조인-포인트-join-point">🍯 조인 포인트 (Join Point)</h2>
<ul>
<li>어드바이스가 적용될 수 있는 위치. 메서드 실행, 생성자 호출, 필드 값 접근, static 메서드 접근 같은 프로그램 실행 중의 지점이다.</li>
<li>추상적인 개념으로, <strong>AOP를 적용할 수 있는 모든 지점</strong>이라고 생각하면 된다.</li>
<li><strong>스프링 AOP는 프록시 방식이므로 조인 포인트는 항상 메서드 실행 지점으로 제한된다.</strong></li>
<li>코드에서는 <code>ProceedingJoinPoint joinPoint</code> 파라미터가 바로 현재 실행 중인 조인 포인트의 정보(호출 대상, 인자, 메서드 시그니처)를 담고 있다.</li>
</ul>
<p>&nbsp;</p>
<h2 id="🔍-포인트컷-pointcut">🔍 포인트컷 (Pointcut)</h2>
<ul>
<li>조인 포인트 중에서 <strong>어드바이스가 실제로 적용될 위치를 선별하는 기능</strong>이다.</li>
<li>주로 AspectJ 표현식으로 지정한다. 코드에서는 <code>@Around(&quot;execution(* ...proxy..*(..))&quot;)</code>의 표현식 부분이다.</li>
<li><strong>프록시를 쓰는 스프링 AOP는 메서드 실행 지점만 선별할 수 있다.</strong></li>
</ul>
<p>&nbsp;</p>
<h2 id="🎯-타겟-target">🎯 타겟 (Target)</h2>
<ul>
<li>어드바이스를 받는 객체, 즉 <strong>부가 기능이 적용되는 원본 객체</strong>다.</li>
<li>포인트컷에 의해 결정된다. 위 그림에서 <code>orderService</code>, <code>orderRepository</code>가 타겟이다.</li>
</ul>
<p>&nbsp;</p>
<h2 id="🛠️-어드바이스-advice">🛠️ 어드바이스 (Advice)</h2>
<ul>
<li><strong>부가 기능 그 자체다.</strong> 특정 조인 포인트에서 애스펙트에 의해 취해지는 조치다.</li>
<li><code>Around</code>(주변), <code>Before</code>(전), <code>After</code>(후) 등 다양한 종류가 있다.</li>
<li>코드에서는 <code>@Around</code>가 붙은 <code>execute()</code> 메서드 안의 로그 추적 로직이다.</li>
</ul>
<p>&nbsp;</p>
<h2 id="🍱-애스펙트-aspect">🍱 애스펙트 (Aspect)</h2>
<ul>
<li><strong>어드바이스 + 포인트컷을 모듈화한 것이다.</strong></li>
<li><code>@Aspect</code>를 떠올리면 된다. 하나의 애스펙트 안에 여러 어드바이스와 포인트컷이 함께 존재할 수 있다.</li>
</ul>
<p>&nbsp;</p>
<h2 id="🎏-어드바이저-advisor">🎏 어드바이저 (Advisor)</h2>
<ul>
<li><strong>하나의 어드바이스와 하나의 포인트컷</strong>으로 구성된다.</li>
<li><strong>스프링 AOP에서만 쓰이는 특별한 용어다.</strong> 이전에 살펴본 <code>DefaultPointcutAdvisor</code>가 바로 이것이다.</li>
<li>애스펙트가 여러 쌍을 담을 수 있는 큰 모듈이라면, 어드바이저는 딱 한 쌍만 담은 가장 작은 단위의 애스펙트라고 보면 된다. 그래서 자동 프록시 생성기가 <code>@Aspect</code>를 읽으면, 그 안의 어드바이스, 포인트컷 쌍마다 어드바이저를 하나씩 만들어 내는 것이다.</li>
</ul>
<p>&nbsp;</p>
<h2 id="🧶-위빙-weaving">🧶 위빙 (Weaving)</h2>
<ul>
<li><strong>포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용하는 것</strong>이다.</li>
<li>위빙 덕분에 핵심 기능 코드에 영향을 주지 않고 부가 기능을 추가할 수 있다.</li>
<li>앞서 본 세 시점(컴파일 타임 / 로드 타임 / 런타임)에 수행할 수 있고, <strong>스프링 AOP는 런타임에 프록시 방식으로 위빙한다.</strong></li>
</ul>
<p>&nbsp;</p>
<h2 id="🤖-aop-프록시">🤖 AOP 프록시</h2>
<ul>
<li><strong>AOP 기능을 구현하기 위해 만든 프록시 객체다.</strong></li>
<li>스프링에서 AOP 프록시는 <strong>JDK 동적 프록시</strong> 또는 <strong>CGLIB 프록시</strong>다. 이전에 실행 로그에서 <code>jdk.proxy2.$Proxy54</code>, <code>...$$SpringCGLIB$$0</code>으로 찍혔던 그 객체들이다.</li>
</ul>
<p>&nbsp;</p>
<pre><code class="language-java">@Aspect  // 애스펙트
public class LogTraceAspect {

    @Around(&quot;execution(* com.example.advanced_spring.proxy..*(..))&quot;)  // 포인트컷
    public Object execute(ProceedingJoinPoint joinPoint)  // 조인 포인트 정보
            throws Throwable {
        TraceStatus status = null;
        try {                                                 // ┐
            status = logTrace.begin(                          // │
                joinPoint.getSignature().toShortString());    // │ 어드바이스
            Object result = joinPoint.proceed();  //  타겟 호출 │ (부가 기능)
            logTrace.end(status);                             // │
            return result;                                    // │
        } catch (Exception e) {                               // │
            logTrace.exception(status, e);                    // │
            throw e;                                          // ┘
        }
    }
}</code></pre>
<p><code>joinPoint.proceed()</code>가 호출하는 실제 객체가 <strong>타겟</strong>이고, 이 모든 것을 엮어 프록시로 만들어 빈으로 등록하는 과정이 <strong>위빙</strong>, 그 결과물이 <strong>AOP 프록시</strong>다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[빈 후처리기]]></title>
            <link>https://velog.io/@rocker_nun/%EB%B9%88-%ED%9B%84%EC%B2%98%EB%A6%AC%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/%EB%B9%88-%ED%9B%84%EC%B2%98%EB%A6%AC%EA%B8%B0</guid>
            <pubDate>Mon, 19 Oct 2026 04:12:55 GMT</pubDate>
            <description><![CDATA[<h1 id="🤔-빈-후처리기란">🤔 빈 후처리기란?</h1>
<p><code>@Bean</code> 애노테이션이나 컴포넌트 스캔을 이용해서 스프링 빈을 등록하면, 스프링은 객체들을 생성해서 스프링 컨테이너에 등록했다. 특정 객체가 필요한 경우에 그 빈을 조회해서 사용하는 것이 일반적인 스프링 빈의 사용법이었다. </p>
<p>하지만 스프링 컨테이너에 스프링 빈을 등록하기 전에 어떠한 조작을 하고 싶다면 <strong>빈 후처리기(BeanPostProcessor)</strong>를 사용할 수 있다. 빈 후처리기는 객체를 조작할 수도 있고, 완전히 다른 객체로 바꿔치기 하는 것도 가능하다. 아래의 간단한 처리과정을 보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e19fbf1a-ea64-4bd5-8a73-ad270bcc84a4/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/1e8c50c5-1860-41a4-bfe3-7c0aef0526b6/image.png" alt=""></p>
<ol>
<li><p>객체 생성: <code>@Bean</code> 혹은 컴포넌트 스캔을 통해 등록된 스프링 빈의 대상이 되는 객체를 생성한다. </p>
</li>
<li><p>빈 후처리기에 전달: 생성된 객체를 스프링 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.</p>
</li>
<li><p>후 처리 작업: 빈 후처리기는 전달된 스프링 빈 객체를 조작하거나 다른 객체로 바뀌치기 할 수 있다.</p>
</li>
<li><p>등록: 빈 후처리기는 빈을 반환한다. 전달 된 빈을 그대로 반환하면 해당 빈이 등록되고, 바꿔치기 하면 다른 객체가 빈 저장소에 등록된다.</p>
</li>
</ol>
<p>&nbsp;</p>
<h2 id="📝-빈-후처리기---예제-코드">📝 빈 후처리기 - 예제 코드</h2>
<p>먼저 스프링 빈으로 등록된 A 객체, 스프링 빈으로 등록되지 않은 B 객체를 생성하고 테스트해보자.</p>
<pre><code class="language-java">public class BasicTest {

    @Test
    void basicConfig() {
        ApplicationContext annotationConfigApplicationContext = new AnnotationConfigApplicationContext(BasicConfig.class);

        // A라는 객체는 현재 빈으로 등록되어 있음
        A beanA = annotationConfigApplicationContext.getBean(&quot;beanA&quot;, A.class);
        beanA.helloA();

        // B라는 객체는 현재 빈으로 등록되어 있지 않음
        Assertions.assertThrows(NoSuchBeanDefinitionException.class,
                () -&gt; annotationConfigApplicationContext.getBean(B.class));
    }

    @Slf4j
    @Configuration
    static class BasicConfig {
        @Bean(name = &quot;beanA&quot;)
        public A a() {
            return new A();
        }
    }

    @Slf4j
    static class A {
        public void helloA() {
            log.info(&quot;Hello A&quot;);
        }
    }

    @Slf4j
    static class B {
        public void helloB() {
            log.info(&quot;Hello B&quot;);
        }
    }
}</code></pre>
<p>당연하게도 B라는 객체는 스프링 빈으로 등록되어 있지 않기 때문에 <code>NoSuchBeanDefinitionException</code> 예외가 발생한다. 근데 아까 빈 후처리기를 사용해서 등록할 객체를 바꿔치기 할 수 있다고 했다. </p>
<p>이제 A 객체를 스프링 빈으로 등록할 때 B 객체로 바꿔치기 해볼 텐데, 그 전에 스프링이 제공하는 <code>BeanPostProcessor</code> 인터페이스를 살펴보자.</p>
<pre><code class="language-java">package org.springframework.beans.factory.config;

import org.springframework.beans.BeansException;
import org.springframework.lang.Nullable;

public interface BeanPostProcessor {
    @Nullable
    default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }

    @Nullable
    default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }
}</code></pre>
<p>보다시피 이 인터페이스는 2개의 메서드를 갖고 있다. 객체가 생성하고 초기화를 진행하기 전에 호출하느냐, 초기화를 진행한 이후에 호출하느냐 차이가 있다. </p>
<p>빈 후처리기를 사용하기 위해서는 이 인터페이스를 구현하고 스프링 빈으로 등록해주기만 하면 된다.</p>
<pre><code class="language-java">public class BeanPostProcessorTest {

    @Test
    void basicConfig() {
        ApplicationContext annotationConfigApplicationContext = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class);

        // 이제 더 이상 A 객체 타입의 스프링 빈은 조회되지 않음
        Assertions.assertThrows(NoSuchBeanDefinitionException.class,
                () -&gt; annotationConfigApplicationContext.getBean(A.class));

        // 빈 후처리기에 의해 이름은 beanA이지만, B 객체가 스프링 빈으로 등록되어 있음
        B beanA1 = annotationConfigApplicationContext.getBean(&quot;beanA&quot;, B.class);
        beanA1.helloB();
    }

    @Slf4j
    @Configuration
    static class BeanPostProcessorConfig {
        @Bean(name = &quot;beanA&quot;)
        public A a() {
            return new A();
        }

        @Bean
        public AToBPostProcessor helloPostProcessor() {
            return new AToBPostProcessor();
        }
    }

    @Slf4j
    static class A {
        public void helloA() {
            log.info(&quot;Hello A&quot;);
        }
    }

    @Slf4j
    static class B {
        public void helloB() {
            log.info(&quot;Hello B&quot;);
        }
    }

    @Slf4j
    static class AToBPostProcessor implements BeanPostProcessor {

        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
            log.info(&quot;beanName = {}, bean = {}&quot;, beanName, bean);
            if (bean instanceof A) {
                return new B();  // A 객체 타입이면 B 객체를 스프링 컨테이너에 등록
            }
            return bean;
        }
    }
}</code></pre>
<p>이제 빈 후처리기는 빈을 조작 및 변경할 수 있고 심지어 다른 객체로 바꿔치기 할 수도 있다는 것을 느꼈을 것이다. 일반적으로 컴포넌트 스캔의 대상이 되는 빈들은 중간에 조작할 방법이 없는데, 빈 후처리기를 사용하면 개발자가 등록하는 모든 빈을 중간에 조작할 수 있다. 이 말은 빈 객체를 프록시로 교체하는 것도 가능하다는 뜻이다.</p>
<p>&nbsp;</p>
<h2 id="🔨-빈-후처리기---적용">🔨 빈 후처리기 - 적용</h2>
<p>이전까지 <code>ProxyFactoryConfigV1</code>, <code>ProxyFactoryConfigV2</code> 처럼 설정 파일에서 빈 하나하나마다 프록시를 수동으로 만들어 등록했다. 이 방식은 두 가지 한계가 있다.</p>
<ul>
<li><p><strong>설정이 너무 많다</strong>: 빈이 100개면 프록시 생성 코드도 100개다. 컴포넌트 스캔으로 빈 등록은 자동화했는데, 프록시 적용 코드를 또 수동으로 작성해야 하는 모순이 발생한다.</p>
</li>
<li><p><strong>컴포넌트 스캔으로 등록된 빈에는 적용할 수 없다</strong>: 컴포넌트 스캔은 원본 객체를 이미 빈으로 등록해 버린다. 프록시를 적용하려면 원본 대신 프록시를 빈으로 등록해야 하는데, 이미 끝난 상태라 끼어들 틈이 없다.</p>
</li>
</ul>
<p>위와 같은 문제를 실제 빈 후처리기를 이용하여 프록시로 바꿔치기 해야 할 대상이면 프록시로 만들어서 반환하는 <code>PackageLogTracePostProcessor</code>를 구현해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/da077b6a-1133-474f-b7b1-cfacc9aeb60f/image.png" alt=""></p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.config.v4_postprocessor.postprocessor;

import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.Advisor;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;

@Slf4j
public class PackageLogTracePostProcessor implements BeanPostProcessor {

    private final String basePackage;
    private final Advisor advisor;

    public PackageLogTracePostProcessor(String basePackage, Advisor advisor) {
        this.basePackage = basePackage;
        this.advisor = advisor;
    }

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        log.info(&quot;param beanName: {}&quot;, beanName);
        log.info(&quot;param beanClass: {}&quot;, bean.getClass().getName());

        // 프록시 적용 대상 여부 체크
        // 프록시 적용 대상이 아니라면 원본을 그대로 진행
        String packageName = bean.getClass().getPackageName();
        if (!packageName.startsWith(basePackage)) return bean;

        // 프록시 대상이면 프록시를 만들어서 반환
        ProxyFactory proxyFactory = new ProxyFactory(bean);
        proxyFactory.addAdvisor(advisor);

        Object proxy = proxyFactory.getProxy();
        log.info(&quot;create proxy: target={}, proxy={}&quot;, bean.getClass(), proxy.getClass());
        return proxy;
    }
}</code></pre>
<p>&nbsp;</p>
<p>이제 빈 후처리기를 등록해보자. </p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.config.v4_postprocessor;

import com.example.advanced_spring.proxy.config.AppV1Config;
import com.example.advanced_spring.proxy.config.v3_proxyfactory.advice.LogTraceAdvice;
import com.example.advanced_spring.proxy.config.v4_postprocessor.postprocessor.PackageLogTracePostProcessor;
import com.example.advanced_spring.proxy.config.AppV2Config;
import com.example.advanced_spring.trace.logtrace.LogTrace;
import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.Advisor;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@Slf4j
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class BeanPostProcessorConfig {

    @Bean
    public PackageLogTracePostProcessor packageLogTracePostProcessor(LogTrace logTrace) {
        return new PackageLogTracePostProcessor(&quot;com.example.advanced_spring.proxy.v&quot;, getAdvisor(logTrace));
    }

    private Advisor getAdvisor(LogTrace logTrace) {
        // pointcut
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames(&quot;request*&quot;, &quot;order*&quot;, &quot;save*&quot;);

        // advice
        LogTraceAdvice logTraceAdvice = new LogTraceAdvice(logTrace);
        return new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
    }
}</code></pre>
<p>빈 후처리기는 스프링 빈으로 등록만 하면 자동으로 동작한다. 패키지 접두어와 어드바이저를 주입해 반환한다. 이로써 설정 파일에서 프록시 생성 코드가 완전히 사라졌다. 더 이상 *&quot;A 서비스 프록시 만들고, B 리포지토리 프록시 만들고…&quot;* 하는 반복은 없다. 이제 빈 후처리기 하나가 전부 처리한다.</p>
<p>&nbsp;</p>
<p>실제 실행 로그를 확인해보면 아래와 같이 찍힌다.</p>
<pre><code>create proxy: target=...v1.ProxyOrderRepositoryV1Impl, proxy=class jdk.proxy2.$Proxy54
create proxy: target=...v1.ProxyOrderServiceV1Impl,    proxy=class jdk.proxy2.$Proxy56
create proxy: target=...v1.ProxyOrderControllerV1Impl, proxy=class jdk.proxy2.$Proxy57
create proxy: target=...v2.ProxyOrderRepositoryV2, proxy=class ...v2.ProxyOrderRepositoryV2$$SpringCGLIB$$0
create proxy: target=...v2.ProxyOrderServiceV2,    proxy=class ...v2.ProxyOrderServiceV2$$SpringCGLIB$$0
create proxy: target=...v2.ProxyOrderControllerV2, proxy=class ...v2.ProxyOrderControllerV2$$SpringCGLIB$$0
create proxy: target=...v3.ProxyOrderRepositoryV3, proxy=class ...v3.ProxyOrderRepositoryV3$$SpringCGLIB$$0
create proxy: target=...v3.ProxyOrderServiceV3,    proxy=class ...v3.ProxyOrderServiceV3$$SpringCGLIB$$0
create proxy: target=...v3.ProxyOrderControllerV3, proxy=class ...v3.ProxyOrderControllerV3$$SpringCGLIB$$0</code></pre><p>보다시피 v1은 JDK 동적 프록시, v2와 v3는 CGLIB 프록시가 정상적으로 등록된 것을 볼 수 있다. 여기까지도 만족스럽지만, 잘 생각해보면 위와 같이 패키지를 기준으로 프록시 적용 대상을 확인하지 않고, 포인트컷을 활용한다면 한층 더 편리하고 세밀하게 프록시 적용 대상 여부를 확인할 수 있지 않을까?</p>
<p>&nbsp;</p>
<h2 id="🌈-스프링이-제공하는-빈-후처리기">🌈 스프링이 제공하는 빈 후처리기</h2>
<p>다행스럽게도 스프링은 이런 빈 후처리기를 이미 만들어서 제공한다. 바로 <code>AnnotationAwareAspectJAutoProxyCreator</code>다. 이름 그대로 자동으로 프록시를 생성해주는 빈 후처리기 이고, 컨테이너에 등록된 모든 어드바이저를 찾아 알아서 프록시를 적용해 준다.</p>
<h3 id="🤖-자동-프록시-생성기">🤖 자동 프록시 생성기</h3>
<p>자동 프록시 생성기 <code>AnnotationAwareAspectJAutoProxyCreator</code>의 작동 과정은 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/4bc95509-1685-4bae-8578-cdaffe8f797b/image.png" alt=""></p>
<ol>
<li><p>생성: 스프링이 빈 대상 객체를 생성한다. (@Bean · 컴포넌트 스캔 공통)</p>
</li>
<li><p>전달: 빈 저장소에 넣기 직전, 빈 후처리기로 넘긴다.</p>
</li>
<li><p>조회: 자동 프록시 생성기가 컨테이너의 모든 어드바이저를 조회한다.</p>
</li>
<li><p>매칭: 어드바이저의 포인트컷으로 해당 객체가 프록시 대상인지 판단한다.</p>
<ul>
<li>클래스 + 모든 메서드를 하나씩 포인트컷에 맞춰봄</li>
<li>하나라도 매칭되면 프록시 대상 확정</li>
</ul>
</li>
<li><p>생성: 대상이면 <code>ProxyFactory</code>로 프록시를 만들어 반환하고, 대상이 아니면 원본 객체 그대로 반환한다.</p>
</li>
<li><p>등록: 반환된 객체(프록시 또는 원본)가 스프링 빈으로 등록된다.</p>
</li>
</ol>
<p>4번 과정이 핵심이다. 클래스의 메서드 10개 중 하나만 포인트컷에 걸려도 그 클래스는 프록시로 감싸진다. 반대로 매칭되는 메서드가 하나도 없으면 프록시를 만들지 않고 그냥 지나간다. 꼭 필요한 곳에만 프록시가 생기는 셈이다. 생성된 프록시는 내부에 어드바이저와 실제 타깃 객체 둘 다 갖고 있다.</p>
<p>&nbsp;</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.config.v5_autoproxy;

import com.example.advanced_spring.proxy.config.AppV1Config;
import com.example.advanced_spring.proxy.config.AppV2Config;
import com.example.advanced_spring.proxy.config.v3_proxyfactory.advice.LogTraceAdvice;
import com.example.advanced_spring.trace.logtrace.LogTrace;
import org.springframework.aop.Advisor;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {

    @Bean
    public Advisor advisor1(LogTrace logTrace) {
        // pointcut
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames(&quot;request*&quot;, &quot;order*&quot;, &quot;save*&quot;);

        // advice
        LogTraceAdvice logTraceAdvice = new LogTraceAdvice(logTrace);
        return new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
    }
}
</code></pre>
<p>보다시피 이전의 <code>BeanPostProcessorConfig</code>와 비교하면 빈 후처리기 등록 코드가 통째로 사라졌다. 어드바이저 하나만 선언하면 끝이고, 나머지는 스프링이 자동 등록한 <code>AnnotationAwareAspectJAutoProxyCreator</code>가 처리한다.</p>
<p><strong>반드시 명심하고 넘어가야 할 점은 포인트컷이 두 곳에서 쓰인다는 점이다.</strong> 하나의 포인트컷이 *&quot;이 빈을 프록시로 만들까?&quot;* 와 *&quot;이 메서드에 부가 기능을 적용할까?&quot;* 두 질문에 모두 답을 주는 셈이다.</p>
<h4 id="1️⃣-프록시-적용-여부-판단-생성-단계">1️⃣ 프록시 적용 여부 판단 (생성 단계)</h4>
<p>빈 후처리기가 빈을 받으면, 포인트컷으로 클래스 + 모든 메서드를 매칭해 본다. 하나라도 걸리면 프록시 생성, 아니면 생성하지 않는다고 했다.</p>
<h4 id="2️⃣-어드바이스-적용-여부-판단-사용-단계">2️⃣ 어드바이스 적용 여부 판단 (사용 단계)</h4>
<p>이미 생성된 프록시 안에서, 특정 메서드가 호출됐을 때 포인트컷을 다시 본다. 매칭되면 어드바이스를 호출하고 타깃을 호출한다, 매칭이 되지 않으면 타깃만 호출한다.</p>
<p>&nbsp;</p>
<p>근데 애플리케이션 서버를 실행하고 로그를 확인해보면 스프링이 초기화되면서 기대하지 않은 로그들도 보인다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/84c8d7a2-62f0-4abb-b9e8-ae3d054b388e/image.png" alt=""></p>
<p>그 이유는 현재 포인트컷은 단순히 메서드 이름에 특정 키워드만 포함된다면 매칭된다고 판단하고 있기 때문이다.</p>
<pre><code class="language-java">@Bean
public Advisor advisor1(LogTrace logTrace) {
    // pointcut
    NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
    pointcut.setMappedNames(&quot;request*&quot;, &quot;order*&quot;, &quot;save*&quot;);

    // advice
    LogTraceAdvice logTraceAdvice = new LogTraceAdvice(logTrace);
    return new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
}</code></pre>
<p>결국 스프링이 내부에서 사용하는 빈에도 메서드 이름에 <em>“request”</em> 라는 단어만 들어가 있으면 프록시가 만들어지고 되고, 어드바이스도 적용되는 것이다. 이래서 패키지에 메서드 이름까지 함께 지정할 수 있는 매우 정밀한 포인트컷이 필요하다는 것이다.</p>
<p>&nbsp;</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.config.v5_autoproxy;

import com.example.advanced_spring.proxy.config.AppV1Config;
import com.example.advanced_spring.proxy.config.AppV2Config;
import com.example.advanced_spring.proxy.config.v3_proxyfactory.advice.LogTraceAdvice;
import com.example.advanced_spring.trace.logtrace.LogTrace;
import org.springframework.aop.Advisor;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {

    // @Bean
    public Advisor advisor1(LogTrace logTrace) {
        // pointcut
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames(&quot;request*&quot;, &quot;order*&quot;, &quot;save*&quot;);

        // advice
        LogTraceAdvice logTraceAdvice = new LogTraceAdvice(logTrace);
        return new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
    }

    // @Bean
    public Advisor advisor2(LogTrace logTrace) {
        // pointcut
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression(&quot;execution(* com.example.advanced_spring.proxy.v.*.*(..))&quot;);

        // advice
        LogTraceAdvice logTraceAdvice = new LogTraceAdvice(logTrace);
        return new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
    }

    @Bean
    public Advisor advisor3(LogTrace logTrace) {
        // pointcut
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression(&quot;execution(* com.example.advanced_spring.proxy.v.*.*(..)) &amp;&amp; !execution(* com.example.advanced_spring.proxy..noLog(..))&quot;);

        // advice
        LogTraceAdvice logTraceAdvice = new LogTraceAdvice(logTrace);
        return new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
    }
}
</code></pre>
<p>표현식을 작성하는 방법은 아래와 같다.</p>
<pre><code>execution( * com.example.advanced_spring.proxy..*(..) )
           ─ ──────────────────────────────── ──  ──
           │               │                   │   └─ 파라미터: 아무거나
           │               │                   └──── 메서드 이름: 아무거나
           │               └──────────────────────── 패키지: proxy 및 하위 모든 패키지
           └──────────────────────────────────────── 반환 타입: 아무거나
</code></pre><p>핵심은 두 번째 <code>... ..</code>가 패키지 자리에 오면 *&quot;그 패키지와 그 아래 모든 서브패키지&quot;* 를 뜻한다. 그리고 <code>..</code>가 파라미터 자리에 오면 *&quot;파라미터 몇 개든 상관없음&quot;* 을 뜻한다. 똑같은 두 점짜리 기호가 자리에 따라 의미가 달라지는 게 처음엔 헷갈린다. 지금은 그냥 이런 게 있다는 것만 알아두자.</p>
<p>&nbsp;</p>
<h3 id="🔍-왜-포인트컷을-두-곳에서-사용할까">🔍 왜 포인트컷을 두 곳에서 사용할까?</h3>
<p>모든 빈에 프록시를 걸면 생성 비용, 호출 시 간접 호출 오버헤드, 메모리까지 그 자체가 비용이다. 그래서 자동 프록시 생성기는 <strong>*&quot;어드바이스가 적용될 가능성이 있는 빈&quot;*</strong> 에만 프록시를 생성한다. (1단계 포인트컷 필터)</p>
<p>그리고 프록시가 생성된 빈이라고 해도, 모든 메서드가 부가 기능 대상인 건 아니다. 특정 메서드 호출에서만 어드바이스를 끼워 넣고 나머지는 그대로 두는 게 자연스럽다. (2단계 포인트컷 체크)</p>
<p>이 두 단계 필터 덕분에 설정은 어드바이저 하나로 끝나면서도 프록시가 꼭 필요한 곳에만 생성되고, 부가 기능도 꼭 필요한 메서드에만 적용된다.</p>
<p>&nbsp;</p>
<h2 id="⛓️-하나의-프록시-여러-advisor-적용">⛓️ 하나의 프록시, 여러 Advisor 적용</h2>
<p>지금까지 어드바이저를 하나만 활성화해서 다뤘다. 그런데 자연스럽게 궁금한 지점이 생긴다.</p>
<blockquote>
<p><strong>*&quot;하나의 빈이 <code>advisor1</code>, <code>advisor3</code> 두 개의 포인트컷에 모두 걸리면 프록시는 몇 개 생길까?&quot;*</strong></p>
</blockquote>
<p>직관적으로는 어드바이저가 N개면 프록시도 N개가 생길 것 같지만 자동 프록시 생성기는 프록시를 하나만 생성한다. 그리고 그 하나의 프록시 안에 매칭된 어드바이저들을 모두 담는다. 생각해보면 당연하다. 이전 프록시 팩토리에서 다뤘듯이 <code>ProxyFactory</code>는 하나의 프록시에 여러 어드바이저를 담을 수 있는 구조이기 때문이다.</p>
<p>자동 프록시 생성기도 똑같이 동작한다. 매칭된 어드바이저가 N개라고 해서 프록시를 N개 만들 이유가 없다. 하나만 만들고 그 안에 어드바이저를 체인으로 쌓는 게 비용 측면에서도, 호출 측면에서도 자연스럽다.</p>
<p>자동 프록시 생성기가 특정 빈을 두고 결정하는 방식을 정리하면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/248420c8-4a83-44c2-a5b5-40abf6af5b79/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>매칭 조건</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td><code>advisor1</code>의 포인트컷만 만족</td>
<td>프록시 1개 생성, 내부에 <code>advisor1</code>만 포함</td>
</tr>
<tr>
<td><code>advisor1</code>, <code>advisor3</code>의 포인트컷을 모두 만족</td>
<td>프록시 1개 생성, 내부에 <code>advisor1</code>, <code>advisor3</code> 모두 포함</td>
</tr>
<tr>
<td>어떤 포인트컷에도 매칭되지 않음</td>
<td>프록시 생성하지 않음, 원본 객체 그대로 등록</td>
</tr>
</tbody></table>
<p>프록시 생성은 딱 한 번, 어드바이저는 매칭된 만큼 쌓인다. 아주 깔끔한 설계다.</p>
<p>&nbsp;</p>
<p>자동 프록시 생성기 <code>AnnotationAwareAspectJAutoProxyCreator</code> 덕분에 개발자는 매우 편리하게 프록시를 적용할 수 있다. 이제 더 이상 설정 파일에 프록시 생성 코드를 반복해서 쓸 필요도 없고, 컴포넌트 스캔으로 등록되는 빈까지도 자연스럽게 커버된다. 어드바이저만 스프링 빈으로 등록하면 끝이다.</p>
<p>지금까지의 흐름을 되짚어보면 아래와 같은 공식 하나로 요약된다.</p>
<p>▎ <strong><em>Advisor = Pointcut + Advice</em></strong></p>
<p>&nbsp;</p>
<p>그리고 여기에 한 가지가 더 추가됐다.</p>
<p>▎ <strong><em>하나의 프록시 안에는 여러 개의 Advisor가 들어갈 수 있다.</em></strong></p>
<p>&nbsp;</p>
<h2 id="🦾-aspect-적용">🦾 @Aspect 적용</h2>
<p>지금까지 어드바이저를 만들려면 <code>Pointcut</code>, <code>Advice</code>, <code>DefaultPointcutAdvisor</code>를 손수 조립해서 <code>@Bean</code>으로 등록해야 했다. 스프링은 이 과정을 더 편리하게 만들어주는 애노테이션을 제공한다. 바로 <code>@Aspect</code>다. <code>LogTraceAspect</code>를 작성해보자.</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.config.v6_aop.aspect;

import com.example.advanced_spring.trace.TraceStatus;
import com.example.advanced_spring.trace.logtrace.LogTrace;
import java.lang.reflect.Method;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;

@Slf4j
@Aspect
public class LogTraceAspect {

    private final LogTrace logTrace;

    public LogTraceAspect(LogTrace logTrace) {
        this.logTrace = logTrace;
    }

    @Around(&quot;execution(* com.example.advanced_spring.proxy..*(..)) &amp;&amp; !within(com.example.advanced_spring.proxy.config..*)&quot;)
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        TraceStatus status = null;

        try {
            String message = joinPoint.getSignature().toShortString();
            status = logTrace.begin(message);

            // 로직 호출
            Object result = joinPoint.proceed();

            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}</code></pre>
<ul>
<li><p><code>@Aspect</code>: 애노테이션 기반 프록시를 적용할 때 필요하다. 이 애노테이션이 붙은 클래스는 뒤에서 설명할 자동 프록시 생성기가 어드바이저로 변환해준다.</p>
</li>
<li><p><code>@Around(&quot;...&quot;)</code>: 괄호 안에 포인트컷 표현식을 넣는다. 표현식은 AspectJ 표현식을 그대로 사용한다. 이 애노테이션이 붙은 메서드가 바로 어드바이스가 된다.</p>
</li>
<li><p><code>ProceedingJoinPoint joinPoint</code>: 이전에 다뤘던 <code>MethodInvocation invocation</code>과 유사한 역할을 한다. 내부에 실제 호출 대상, 전달 인자, 호출된 메서드의 시그니처 등 조인 포인트에 관한 모든 정보가 담겨 있다.</p>
</li>
<li><p><code>joinPoint.proceed()</code>: 실제 호출 대상(target)을 호출한다. 이걸 호출하지 않으면 원본 메서드는 실행되지 않는다.</p>
</li>
</ul>
<p>&nbsp;</p>
<p>이제 <code>AopConfig</code>를 작성해서 <code>LogTraceAspect</code>를 스프링 빈으로 등록하자.</p>
<pre><code class="language-java">package com.example.advanced_spring.proxy.config.v6_aop;

import com.example.advanced_spring.proxy.config.AppV1Config;
import com.example.advanced_spring.proxy.config.AppV2Config;
import com.example.advanced_spring.proxy.config.v6_aop.aspect.LogTraceAspect;
import com.example.advanced_spring.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AopConfig {

    @Bean
    public LogTraceAspect logTraceAspect(LogTrace logTrace) {
        return new LogTraceAspect(logTrace);
    }
}</code></pre>
<p>중요한 포인트는 <code>@Aspect</code>가 붙어 있더라도 스프링 빈으로 등록해줘야 동작한다는 점이다. <code>@Bean</code>으로 등록하든, <code>@Component</code>를 붙여서 컴포넌트 스캔으로 등록하든 상관없다. 하지만 어떻게든 스프링 컨테이너 안에 들어가 있어야 한다. 애노테이션만 붙이고 등록하지 않으면 아무 일도 일어나지 않는다.</p>
<p>이전에는 어드바이저를 만들기 위해 포인트컷과 어드바이스를 조립하는 설정 코드를 썼지만, 이제는 <code>@Aspect</code>가 붙은 클래스 하나로 끝난다. 메서드에 <code>@Around</code>를 붙이면 거기에 포인트컷과 어드바이스 로직이 함께 담긴다. 설정이 훨씬 간결해졌다.</p>
<p>그런데 궁금한 점이 있다. <strong>*&quot;@Aspect는 어드바이저가 아닌데 어떻게 자동 프록시 생성기가 이걸 찾아서 적용하는가?”*</strong></p>
<p>앞서 자동 프록시 생성기(<code>AnnotationAwareAspectJAutoProxyCreator</code>)는 어드바이저를 자동으로 찾아와서 필요한 곳에 프록시를 생성하고 적용해준다고 했다. 자동 프록시 생성기는 여기에 추가로 하나의 역할을 더 수행한다. <strong>바로 <code>@Aspect</code>를 찾아서 이것을 어드바이저로 변환해주는 역할</strong>이다. 그래서 이름 앞에 <code>AnnotationAware</code>(애노테이션을 인식하는)가 붙어 있는 거다.</p>
<p>&nbsp;</p>
<h2 id="✌🏻-자동-프록시-생성기의-역할-2가지">✌🏻 자동 프록시 생성기의 역할 2가지</h2>
<h3 id="1️⃣-aspect를-보고-advisor로-변환해서-저장">1️⃣ @Aspect를 보고 Advisor로 변환해서 저장</h3>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3364193c-5ea7-40ee-a636-6ab403bee585/image.png" alt=""></p>
<ol>
<li><p>실행: 스프링 애플리케이션 로딩 시점에 자동 프록시 생성기를 호출한다.</p>
</li>
<li><p>모든 <code>@Aspect</code> 빈 조회: 자동 프록시 생성기는 스프링 컨테이너에서 <code>@Aspect</code> 애노테이션이 붙은 스프링 빈을 모두 조회한다.</p>
</li>
<li><p>어드바이저 생성: <code>@Aspect</code> 어드바이저 빌더를 통해 <code>@Aspect</code> 애노테이션 정보를 기반으로 어드바이저를 생성한다. (포인트컷은 <code>@Around</code>의 표현식에서, 어드바이스는 <code>@Around</code>가 붙은 메서드에서 추출된다.)</p>
</li>
<li><p><code>@Aspect</code> 기반 어드바이저 저장: 생성한 어드바이저를 <code>@Aspect</code> 어드바이저 빌더 내부에 저장한다.</p>
</li>
</ol>
<p>여기서 등장한 <code>@Aspect</code> 어드바이저 빌더는 <code>BeanFactoryAspectJAdvisorsBuilder</code> 클래스이다. <code>@Aspect</code>의 정보를 기반으로 포인트컷, 어드바이스, 어드바이저를 생성하고 내부 저장소에 캐시하는 역할을 한다. 캐시에 어드바이저가 이미 만들어져 있는 경우에는 캐시에 저장된 어드바이저를 그대로 반환한다.</p>
<p>&nbsp;</p>
<h3 id="2️⃣-advisor를-기반으로-프록시를-생성">2️⃣ Advisor를 기반으로 프록시를 생성</h3>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7ecf1fe4-662a-447e-bd93-db0c8e914103/image.png" alt=""></p>
<p>이 과정은 각 빈이 생성될 때마다 일어난다. 앞서 다룬 자동 프록시 생성기의 작동 과정과 거의 동일하지만, 3번 과정(조회 과정)이 두 단계로 쪼개진다는 점에서 약간의 차이가 있다.</p>
<ol>
<li><p>생성: 스프링 빈 대상이 되는 객체를 생성한다. (<code>@Bean</code>, 컴포넌트 스캔 모두 포함)</p>
</li>
<li><p>전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.</p>
</li>
<li><p>스프링 컨테이너에서 <code>Advisor</code> 빈을 모두 조회한다. -&gt; <code>@Aspect</code> 어드바이저 빌더 내부에 저장된 <code>Advisor</code>를 모두 조회한다.</p>
</li>
<li><p>프록시 적용 대상 체크: 3번 과정에서 조회한 <code>Advisor</code>에 포함되어 있는 포인트컷을 사용해서 해당 객체가 프록시를 적용할 대상인지 판단한다. 클래스 + 메서드 하나하나를 모두 매칭해본다.</p>
</li>
<li><p>프록시 생성: 프록시 적용 대상이면 프록시를 생성하고 반환한다. 아니면 원본 객체를 반환한다.</p>
</li>
<li><p>빈 등록: 반환된 객체가 스프링 빈으로 등록된다.</p>
</li>
</ol>
<p><strong>핵심은 직접 등록한 <code>Advisor</code>와 <code>@Aspect</code>에서 변환된 <code>Advisor</code>가 동일하게 취급된다는 점이다.</strong> 자동 프록시 생성기는 두 종류의 어드바이저를 모두 모아놓고, 똑같은 기준으로 *&quot;이 빈에 프록시를 걸까?&quot;* 를 판단한다. 그래서 <code>@Aspect</code>를 쓴다고 해서 특별한 다른 매커니즘이 동작하는 게 아니라, 결국 어드바이저로 변환된 뒤에는 지금까지 다뤄온 것과 완전히 동일한 흐름을 따른다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Record 역직렬화 오류 해결하기 (feat. 커스텀 TypeResolver)]]></title>
            <link>https://velog.io/@rocker_nun/Record-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-feat.-%EC%BB%A4%EC%8A%A4%ED%85%80-TypeResolver-zctl9gm0</link>
            <guid>https://velog.io/@rocker_nun/Record-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-feat.-%EC%BB%A4%EC%8A%A4%ED%85%80-TypeResolver-zctl9gm0</guid>
            <pubDate>Fri, 16 Oct 2026 02:14:12 GMT</pubDate>
            <description><![CDATA[<h2 id="🤔-어떤-문제였나">🤔 어떤 문제였나</h2>
<p>Redis 캐시 적용 후 조회 API에서 아래와 같은 500 에러가 발생했습니다.</p>
<pre><code>SerializationException: Could not read JSON: 
Unexpected token (START_OBJECT), expected START_ARRAY: 
need Array value to contain `As.WRAPPER_ARRAY` type information for class java.lang.Object</code></pre><p>오류를 간단히 요약하자면, Redis에 넣어둔 캐시 값을 꺼낼 때, Jackson이 <strong>*&quot;이게 어떤 클래스였는지 모르겠다&quot;*</strong> 라는 내용의 오류였습니다.</p>
<p>&nbsp;</p>
<h2 id="🧠-jackson과-redis의-관계">🧠 Jackson과 Redis의 관계</h2>
<p>Spring Cache와 Redis 조합의 흐름은 간단합니다.</p>
<ol>
<li><strong>저장할 때</strong>: 객체 → JSON 문자열로 변환해 Redis에 저장</li>
<li><strong>꺼낼 때</strong>: JSON 문자열 → 객체로 복원</li>
</ol>
<p>문제는 JSON 자체에는 타입 정보가 없다는 점입니다. <code>{&quot;id&quot;: 1, &quot;name&quot;: &quot;A&quot;}</code> 라는 JSON만 봐서는 이게 <code>User</code>인지 <code>Product</code>인지 알 수 없습니다. 그래서 Jackson은 저장할 때 JSON에 다음과 같이 타입 정보를 심어둡니다.</p>
<pre><code class="language-json">{ &quot;@class&quot;: &quot;com.example.User&quot;, &quot;id&quot;: 1, &quot;name&quot;: &quot;A&quot; }</code></pre>
<p>하지만 모든 타입에 다 심으면 저장 공간이 커지므로, Jackson은 꼭 필요한 경우에만 심는 전략을 씁니다. 그 기본 전략이 바로 <code>DefaultTyping.NON_FINAL</code>입니다. <code>NON_FINAL</code>의 규칙은 <code>final</code> 키워드가 붙지 않은 타입에만 <code>@class</code>를 넣는다는 것입니다. </p>
<p><code>final</code> 키워드가 붙은 클래스는 어차피 서브타입이 나올 수 없으니 타입 정보가 불필요하기 때문에 붙이지 않는 것입니다. 여기서 첫 번째 문제가 생깁니다.</p>
<p>&nbsp;</p>
<h2 id="1️⃣-문제-1-record는-암묵적으로-final">1️⃣ 문제 1: record는 암묵적으로 final</h2>
<p><code>record</code>는 <a href="https://openjdk.org/jeps/395">JEP 395</a> 명세상 암묵적으로 <code>final</code> 키워드가 붙습니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/29d891f5-e37a-407c-8a2c-334fa9b07136/image.png" alt=""></p>
<pre><code class="language-java">// 개발자가 작성
public record InventoryResult(Long id, Long warehouseId, ...) { }

// 컴파일러가 실제 생성
public final class InventoryResult extends java.lang.Record { }</code></pre>
<p>&nbsp;</p>
<p>Jackson은 <code>NON_FINAL</code> 전략의 규칙대로 <code>final</code>이니까 타입 정보를 생략했기 때문에 Redis에 저장된 JSON에는 <code>@class</code>가 없는 것이었습니다.</p>
<pre><code class="language-json">// Redis에 저장된 record (@class 없음!)
{ &quot;id&quot;: 1, &quot;warehouseId&quot;: 10, ... }</code></pre>
<p>타입 정보가 없기 때문에 캐시에서 꺼낼 때 Jackson은 이게 뭔지 모르니 그냥 <code>Map</code>으로 복원하자고 결정하고 <code>LinkedHashMap</code>을 반환합니다. 그 결과를 <code>InventoryResult</code> 타입에 담으려 했기 때문에 <code>ClassCastException</code> 예외가 발생한 것입니다.</p>
<p>&nbsp;</p>
<h2 id="🩹-임시-처방-everything-전략의-세-가지-손해">🩹 임시 처방: EVERYTHING 전략의 세 가지 손해</h2>
<p>처음에는 가장 간단하게. <code>NON_FINAL</code> 전략을 <code>DefaultTyping.EVERYTHING</code> 전략으로 바꾼 것입니다. 이 전략은 모든 타입에 타입 정보를 심기 때문에 <code>record</code>도 당연히 포함됩니다.</p>
<pre><code class="language-java">objectMapper.activateDefaultTyping(
    BasicPolymorphicTypeValidator.builder()
        .allowIfSubType(Object.class)  // 모든 서브타입 허용
        .build(),
    ObjectMapper.DefaultTyping.EVERYTHING
);</code></pre>
<p>당장은 돌아갔지만 이 방법에는 문제가 있었습니다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c486865b-93fb-40f7-bb37-45c5cf2e4f10/image.png" alt=""></p>
<p>일단 위와 같이 저장되는 페이로드의 양이 증가합니다. <code>Long</code> 하나도 보다시피 <code>[&quot;java.lang.Long&quot;, 10]</code> 처럼 저장되고, <code>enum</code>, <code>LocalDateTime</code>과 같은 스칼라까지 포함됩니다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/97dff1c1-f94f-470f-860e-286b13c0499a/image.png" alt=""></p>
<p>그리고 애초에 Jackson 라이브러리 공식 레포에서 default typing 전반에 대해 <strong>*&quot;신뢰할 수 없는 입력이 들어오는 상황에서는 권장하지 않는다&quot;*</strong> 고 명시하고 있었습니다. <code>EVERYTHING</code>은 default typing의 가장 넓은 옵션이라 이 경고가 가장 강하게 적용되는 설정이라고 생각했습니다.</p>
<p>&nbsp;</p>
<h2 id="2️⃣-문제-2-streamtolist">2️⃣ 문제 2: <code>Stream.toList()</code></h2>
<p><code>List&lt;InventoryResult&gt;</code> 리스트를 캐시하는 메서드가 있었습니다.</p>
<pre><code class="language-java">@Cacheable(value = &quot;inventoryByWarehouse&quot;, key = &quot;#warehouseId&quot;)
public List&lt;InventoryResult&gt; getInventoriesByWarehouse(Long warehouseId) {
    return repository.findByWarehouseId(warehouseId).stream()
            .map(InventoryResult::from)
            .toList();  // ← 여기
}</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/6aaa7d22-7a8d-4c1b-b737-194ef055dc29/image.png" alt=""></p>
<p><code>Stream.toList()</code>는 Java 16+에서 추가된 간결한 문법입니다. 그런데 이게 반환하는 리스트의 실제 타입이 문제가 됐습니다. Javadoc에는 *&quot;The returned List is unmodifiable&quot;* 라고만 명시돼 있고, 바로 다음 문장에서 *&quot;There are no guarantees on the implementation type&quot;* 구체 구현 클래스를 보장하지 않는다고 선 긋고 있습니다. </p>
<p>실제로 테스트를 돌려보면 <code>java.util.ImmutableCollections$ListN</code> 같은 내부 클래스를 반환하고,  <code>List.of(...)</code>도 내부 중첩 클래스를 반환합니다. 그리고 반환된 내부 클래스는 <code>public</code> 생성자가 없기 때문에 Jackson이 역직렬화 중에 인스턴스를 만들 방법이 없습니다.</p>
<pre><code class="language-java">class StreamToListReturnTypeTest {

    @Nested
    @DisplayName(&quot;Stream.toList() 반환 타입&quot;)
    class StreamToList {

        @Test
        @DisplayName(&quot;요소 개수와 무관하게 ImmutableCollections 내부 중첩 클래스를 반환한다&quot;)
        void returnsImmutableCollectionsNestedClass() {
            assertThat(Stream.of().toList().getClass().getName())
                    .startsWith(&quot;java.util.ImmutableCollections$&quot;);
            assertThat(Stream.of(1).toList().getClass().getName())
                    .startsWith(&quot;java.util.ImmutableCollections$&quot;);
            assertThat(Stream.of(1, 2, 3, 4, 5).toList().getClass().getName())
                    .startsWith(&quot;java.util.ImmutableCollections$&quot;);
        }

        @Test
        @DisplayName(&quot;반환 클래스는 public 생성자가 없어 Jackson 역직렬화가 불가능하다&quot;)
        void returnedClassHasNoPublicConstructor() {
            Class&lt;?&gt; clazz = Stream.of(1, 2, 3).toList().getClass();

            assertThat(clazz.getConstructors())
                    .as(&quot;ImmutableCollections 내부 클래스는 public 생성자가 없다&quot;)
                    .isEmpty();
        }

        @Test
        @DisplayName(&quot;반환 리스트는 unmodifiable이다 (수정 시 UnsupportedOperationException)&quot;)
        void returnedListIsUnmodifiable() {
            List&lt;Integer&gt; list = Stream.of(1, 2, 3).toList();

            assertThatThrownBy(() -&gt; list.add(4))
                    .isInstanceOf(UnsupportedOperationException.class);
        }
    }

    @Nested
    @DisplayName(&quot;List.of(...)도 같은 계열이다&quot;)
    class ListOf {

        @Test
        @DisplayName(&quot;List.of도 ImmutableCollections 내부 중첩 클래스를 반환한다&quot;)
        void listOfReturnsSameFamily() {
            assertThat(List.of(1).getClass().getName())
                    .startsWith(&quot;java.util.ImmutableCollections$&quot;);
            assertThat(List.of(1, 2, 3).getClass().getName())
                    .startsWith(&quot;java.util.ImmutableCollections$&quot;);
        }
    }

    @Nested
    @DisplayName(&quot;Collectors.toList() - Jackson 역직렬화 호환 대안&quot;)
    class CollectorsToList {

        @Test
        @DisplayName(&quot;ArrayList 를 반환하므로 Jackson 이 정상 인스턴스화 가능하다&quot;)
        void returnsArrayListWithPublicConstructor() {
            List&lt;Integer&gt; list = Stream.of(1, 2, 3).collect(Collectors.toList());

            assertThat(list).isInstanceOf(ArrayList.class);
            assertThat(ArrayList.class.getConstructors())
                    .as(&quot;ArrayList는 public 생성자가 있다&quot;)
                    .isNotEmpty();
        }
    }
}</code></pre>
<p>위의 사실이 Jackson default typing과 만나면 두 가지 방식으로 실패합니다.</p>
<ul>
<li><p><code>EVERYTHING</code> 전략으로 타입 ID를 강제로 심으면 → <code>ImmutableCollections$ListN</code>이 JSON에 기록됨 → 역직렬화 시 <code>InvalidDefinitionException: no Creators</code></p>
</li>
<li><p>반대로 <code>NON_FINAL</code> 전략이면 → 타입 ID 생략 → 다른 종류의 실패 (<code>InvalidTypeIdException</code>)</p>
</li>
</ul>
<p>이처럼 JDK 내부 구현 클래스를 캐시 포맷에 노출시키는 것 자체가 위험한 행위라는 것을 깨달았습니다. JDK 버전이 업그레이드되면 Redis에 쌓인 과거 JSON들이 전부 읽히지 않는 상황이 발생할 수도 있습니다.</p>
<p>수정은 단순합니다. <code>Stream.toList()</code>를 <code>collect(Collectors.toList())</code>로 바꿉니다. 이렇게 하면 <code>ArrayList</code>를 반환하고, <code>ArrayList</code>는 public 생성자가 있어 Jackson이 정상 인스턴스화할 수 있습니다. JSON에는 <code>java.util.ArrayList</code>라는 안정적인 타입으로 기록됩니다.</p>
<p>&nbsp;</p>
<h2 id="3️⃣-문제-3-두-저장-포맷의-충돌">3️⃣ 문제 3: 두 저장 포맷의 충돌</h2>
<p>Jackson이 JSON에 타입 정보를 심는 방식에는 두 가지가 있었습니다.</p>
<table>
<thead>
<tr>
<th><strong>포맷</strong></th>
<th><strong>형태</strong></th>
<th><strong>예시</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>PROPERTY</code></td>
<td>객체 안에 필드로 심음</td>
<td><code>{&quot;@class&quot;:&quot;User&quot;, &quot;id&quot;:1}</code></td>
</tr>
<tr>
<td><code>WRAPPER_ARRAY</code></td>
<td>배열로 감쌈</td>
<td><code>[&quot;User&quot;, {&quot;id&quot;:1}]</code></td>
</tr>
</tbody></table>
<p>Jackson 내부에서 객체는 <code>PROPERTY</code>로<strong>,</strong> 리스트는 <code>WRAPPER_ARRAY</code> 로 자동 변환됩니다. 그 결과 같은 설정으로 저장했는데 꺼낼 때 디코더가 두 형태를 번갈아 만나 혼란을 일으킵니다. 리스트 캐시의 역직렬화가 특히 잘 깨집니다.</p>
<h3 id="🔧--왜-property를-설정해도-리스트는-wrapper_array로-가는가-참고">🔧  왜 PROPERTY를 설정해도 리스트는 WRAPPER_ARRAY로 가는가 (참고)</h3>
<p><code>AsPropertyTypeDeserializer</code>의 <code>deserializeTypedFromAny()</code>는 <code>START_ARRAY</code> 토큰을 만나면 <code>super.deserializeTypedFromArray()</code>로 넘깁니다. 즉 <strong><code>PROPERTY</code>를 설정해도 리스트/배열은 내부적으로 <code>WRAPPER_ARRAY</code> 규칙으로 우회</strong>됩니다.</p>
<p>반대로 <code>AsArrayTypeDeserializer</code>의 <code>_locateTypeId()</code>는 <code>START_ARRAY</code>가 아닌 토큰을 만나면 즉시 <code>wrongTokenException</code>을 던집니다.</p>
<p>문제는 <code>GenericJackson2JsonRedisSerializer</code>가 root가 배열(리스트 캐시의 반환값)일 때 pre-resolve 혜택을 받지 못하고 <code>Object.class</code> 기반 untyped 경로로 떨어진다는 것입니다. 거기서  </p>
<pre><code>MismatchedInputException: Unexpected token (START_OBJECT)
    AsArrayTypeDeserializer._locateTypeId
    AsArrayTypeDeserializer._deserialize
    AsArrayTypeDeserializer.deserializeTypedFromArray
    AsPropertyTypeDeserializer.deserializeTypedFromAny ← _arrayDelegate()에 위임
    UntypedObjectDeserializerNR.deserializeWithType</code></pre><p>PROPERTY 형식의 JSON을 WRAPPER_ARRAY 형식 디코더가 읽으려 하면서 터지는 경로입니다. (관련 이슈: jackson-databind #4849)</p>
<p>따라서 처음부터 모든 저장을 <code>WRAPPER_ARRAY</code> 포맷으로 통일해서 문제를 해결했습니다. 객체든 리스트든 <code>[&quot;타입&quot;, ...]</code> 형태가 됩니다.</p>
<p>&nbsp;</p>
<h2 id="✅-작업-최종-요약">✅ 작업 최종 요약</h2>
<h3 id="1️⃣-record-에만-타입-정보-강제-포함-커스텀-리졸버">1️⃣ record 에만 타입 정보 강제 포함: 커스텀 리졸버</h3>
<pre><code class="language-java">class RecordSupportingTypeResolver extends ObjectMapper.DefaultTypeResolverBuilder {

    RecordSupportingTypeResolver(ObjectMapper.DefaultTyping typing, PolymorphicTypeValidator ptv) {
        super(typing, ptv);
    }

    @Override
    public boolean useForType(JavaType type) {
        if (type.getRawClass().isRecord()) return true;  // record는 final이어도 포함
        return super.useForType(type);  // 나머지는 NON_FINAL 기본 로직
    }
}</code></pre>
<ul>
<li><p><code>Class.isRecord()</code> 는 JDK 16+ 표준 API라 외부 의존성 없음</p>
</li>
<li><p>스칼라(<code>Long</code>, <code>enum</code> 등)는 기본 규칙(<code>NON_FINAL</code>)에 따라 타입 정보 생략 → 저장 공간 낭비 없음</p>
</li>
<li><p>설정이 <code>config</code> 레이어에만 있어 도메인 record 자체는 전혀 손 안 댐</p>
</li>
</ul>
<p>&nbsp;</p>
<h3 id="2️⃣-포맷-통일-wrapper_array">2️⃣ 포맷 통일: <code>WRAPPER_ARRAY</code></h3>
<pre><code class="language-java">StdTypeResolverBuilder resolverBuilder = typeResolver
        .init(JsonTypeInfo.Id.CLASS, null)
        .inclusion(JsonTypeInfo.As.WRAPPER_ARRAY);</code></pre>
<p>&nbsp;</p>
<h3 id="3️⃣-역직렬화-허용-범위-제한-polymorphictypevalidator">3️⃣ 역직렬화 허용 범위 제한: <code>PolymorphicTypeValidator</code></h3>
<pre><code class="language-java">PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
        .allowIfSubType(&quot;com.kb.wmslab.goods_wms&quot;)  // 도메인 record만
        .allowIfSubType(&quot;java.util&quot;)  // 컬렉션(ArrayList 등)
        .build();</code></pre>
<p>&nbsp;</p>
<h2 id="🔬-결과-확인">🔬 결과 확인</h2>
<p>아래는 문제 수정 후 Redis에 실제로 저장된 형태입니다.</p>
<h3 id="🧀-단일-record-inventoryresult">🧀 단일 record (InventoryResult)</h3>
<pre><code class="language-json">[ &quot;com.kb.wmslab.goods_wms.business.application.inventory.InventoryResult&quot;, {
  &quot;id&quot;: 1,
  &quot;warehouseId&quot;: 10,
  &quot;zoneId&quot;: 100,
  &quot;productId&quot;: 5,
  &quot;normalQuantity&quot;: 50,
  &quot;damagedQuantity&quot;: 0,
  &quot;pendingInspectionQuantity&quot;: 2,
  &quot;reservedQuantity&quot;: 10,
  &quot;availableQuantity&quot;: 40
} ]</code></pre>
<p>&nbsp;</p>
<h3 id="🍕-리스트-listinventoryresult">🍕 리스트 (List&lt;InventoryResult&gt;)</h3>
<pre><code class="language-json">[ &quot;java.util.ArrayList&quot;, [
  [ &quot;com.kb.wmslab.goods_wms.business.application.inventory.InventoryResult&quot;, { ... } ],
  ...
] ]</code></pre>
<p>&nbsp;</p>
<h3 id="🍭-중첩-record-warehouseresult-안에-listzoneresult">🍭 중첩 record (WarehouseResult 안에 List&lt;ZoneResult&gt;)</h3>
<pre><code class="language-json">[ &quot;com.kb.wmslab.goods_wms.business.application.warehouse.WarehouseResult&quot;, {
  &quot;id&quot;: 1,
  &quot;name&quot;: &quot;서울 창고&quot;,
  &quot;zones&quot;: [ &quot;java.util.ArrayList&quot;, [
    [ &quot;com.kb.wmslab.goods_wms.business.application.warehouse.WarehouseResult$ZoneResult&quot;, {
      &quot;id&quot;: 1,
      &quot;zoneCode&quot;: &quot;A-01&quot;,
      &quot;zoneType&quot;: &quot;NORMAL&quot;
    } ]
  ] ],
  &quot;createdAt&quot;: &quot;2026-01-01T00:00:00&quot;
} ]</code></pre>
<p>모든 <code>record</code>에 타입 정보가 붙었고, 리스트는 <code>ArrayList</code>로 통일됐습니다. 깔끔하게 역직렬화에 성공했습니다.</p>
<p>&nbsp;</p>
<h2 id="🤷-다른-대안은-있을까">🤷 다른 대안은 있을까?</h2>
<h3 id="⚡️-대안-1-jsontypeinfo-를-각-record에-직접-선언">⚡️ 대안 1: @JsonTypeInfo 를 각 record에 직접 선언</h3>
<pre><code class="language-java">@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY)
public record InventoryResult(...) { }</code></pre>
<p>이 방법은 가장 명시적이지만 캐시 대상 <code>record</code> 가 늘어날 때마다 어노테이션을 빠뜨릴 위험이 있고, 직렬화 설정이 도메인 레이어의 로직과 섞이는 문제가 있습니다. 반면, 앞서 본 것처럼 커스텀 리졸버를 활용하면 설정이 <code>config</code> 레이어 한 곳에 모여 있어 도메인 오염이 없습니다.</p>
<p>&nbsp;</p>
<h3 id="🔄-대안-2-record를-일반-클래스로-되돌림">🔄 대안 2: record를 일반 클래스로 되돌림</h3>
<pre><code class="language-java">public class InventoryResult {
    @JsonCreator
    public InventoryResult(@JsonProperty(&quot;id&quot;) Long id, ...) { }
}</code></pre>
<p><code>record</code>의 불변성, 간결한 선언, <code>equals</code>/<code>hashCode</code>/<code>toString</code> 자동 생성되는 이점을 포기해야 합니다. 심지어 이미 도메인 전반을 <code>record</code>로 작성했기 때문에 되돌리기에는 손해가 크다고 판단했습니다.</p>
<hr>
<p><strong>*&lt;참고 자료&gt;*</strong></p>
<ul>
<li><a href="https://github.com/2026-KB-WMS/goods-wms/issues/36">#36 - Record 역직렬화를 위한 Record 전용 TypeResolver 구현 (이슈)</a></li>
<li><a href="https://github.com/2026-KB-WMS/goods-wms/pull/37">#37 - refactor: Redis 직렬화 전략을 EVERYTHING에서 RecordSupportingTypeResolver와 WRAPPER_ARRAY로 교체 (PR)</a></li>
</ul>
<ul>
<li><a href="https://github.com/FasterXML/jackson-docs/wiki/JacksonPolymorphicDeserialization">JacksonPolymorphicDeserialization: FasterXML Wiki</a></li>
<li><a href="https://docs.spring.io/spring-data-redis/reference/api/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.html">GenericJackson2JsonRedisSerializer: Spring Data Redis API</a></li>
<li><a href="https://openjdk.org/jeps/395">JEP 395: Records: OpenJDK</a></li>
<li><a href="https://docs.oracle.com/en/java/javase/17/language/records.html">Record Classes: Oracle Java 17 공식 문서</a></li>
<li><a href="https://github.com/FasterXML/jackson-databind/issues/5223">Java Records missing type information with DefaultTyping.NON_FINAL: jackson-databind #5223</a></li>
<li><a href="https://github.com/FasterXML/jackson-databind/issues/3512">Serialized result of java Class &amp; Record is different with RedisTemplate: jackson-databind #3512</a></li>
<li><a href="https://techblog.woowahan.com/22767/">Spring Cache(@Cacheable) + Spring Data Redis 사용 시 record 직렬화 오류 원인과 해결: 우아한기술블로그</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis 캐시 선택적 무효화]]></title>
            <link>https://velog.io/@rocker_nun/Redis-%EC%BA%90%EC%8B%9C-%EC%84%A0%ED%83%9D%EC%A0%81-%EB%AC%B4%ED%9A%A8%ED%99%94</link>
            <guid>https://velog.io/@rocker_nun/Redis-%EC%BA%90%EC%8B%9C-%EC%84%A0%ED%83%9D%EC%A0%81-%EB%AC%B4%ED%9A%A8%ED%99%94</guid>
            <pubDate>Wed, 14 Oct 2026 08:11:19 GMT</pubDate>
            <description><![CDATA[<h2 id="🤔-왜-캐시가-필요했는가">🤔 왜 캐시가 필요했는가</h2>
<p>굿즈 WMS에서 조회 API들이 압도적으로 많이 호출됩니다.</p>
<ul>
<li><code>GET /inventories?warehouseId=...&amp;zoneId=...&amp;productId=...</code>: 특정 재고 조회</li>
<li><code>GET /inventories/warehouses/{id}</code>: 창고의 전체 재고 조회</li>
<li><code>GET /warehouses</code>, <code>GET /warehouses/{id}</code>: 창고 정보</li>
</ul>
<p>재고의 특이점은 <strong>*&quot;조회 : 변경&quot;* 비율이 매우 크다</strong>는 것입니다. 한정판 출시 순간처럼 변경 트래픽이 폭발하는 때에도, 변경 1건당 재고 조회가 수십~수백 건씩 섞여 들어옵니다. 따라서 조회가 재고 변경보다 많을 것이기 때문에 캐시를 도입하는 것이 적절하다고 판단했습니다.</p>
<p>&nbsp;</p>
<h2 id="💾-1차-작업-spring-cache--redis-도입-pr-35">💾 1차 작업: Spring Cache + Redis 도입 (PR #35)</h2>
<p><a href="https://github.com/2026-KB-WMS/goods-wms/pull/35">feat: 재고 및 창고 조회 성능 개선을 위한 Redis 캐시 도입</a></p>
<h3 id="🔨-cacheable-적용">🔨 @Cacheable 적용</h3>
<pre><code class="language-java">@Cacheable(value = &quot;inventory&quot;, key = &quot;#warehouseId + &#39;:&#39; + #zoneId + &#39;:&#39; + #productId&quot;)
public InventoryResult getInventory(Long warehouseId, Long zoneId, Long productId) { ... }

@Cacheable(value = &quot;inventoryByWarehouse&quot;, key = &quot;#warehouseId&quot;)
public List&lt;InventoryResult&gt; getInventoriesByWarehouse(Long warehouseId) { ... }</code></pre>
<p><code>@Cacheable</code>은 메서드 실행 전에 캐시를 먼저 확인합니다. 히트 시 메서드를 호출하지 않고 캐시 값을 즉시 반환하고, 미스 시에만 메서드를 실행하고 결과를 저장합니다.</p>
<h3 id="⏰-캐시별-ttl">⏰ 캐시별 TTL</h3>
<table>
<thead>
<tr>
<th><strong>캐시</strong></th>
<th><strong>키 패턴</strong></th>
<th><strong>TTL</strong></th>
<th><strong>TTL 설정 근거</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>inventory</code></td>
<td><code>{warehouseId}:{zoneId}:{productId}</code></td>
<td>10분</td>
<td>evict가 즉시 처리하므로 TTL은 evict 누락 시의 최후 안전망</td>
</tr>
<tr>
<td><code>inventoryByWarehouse</code></td>
<td><code>{warehouseId}</code></td>
<td>10분</td>
<td>동일</td>
</tr>
<tr>
<td><code>warehouse</code></td>
<td><code>{warehouseId}</code></td>
<td>1시간</td>
<td>창고 정보 거의 안 바뀜</td>
</tr>
<tr>
<td><code>warehouseAll</code></td>
<td><code>&#39;all&#39;</code></td>
<td>1시간</td>
<td>동일</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h3 id="📝-성능-측정-apache-bench-n200-c10">📝 성능 측정 (Apache Bench, n=200, c=10)</h3>
<table>
<thead>
<tr>
<th><strong>엔드포인트</strong></th>
<th><strong>TPS (전 → 후)</strong></th>
<th><strong>p99 (전 → 후)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>GET /inventories/warehouses/1</code></td>
<td>1,874 → 2,773 (<strong>+48%</strong>)</td>
<td>34ms → 7ms (<strong>−79%</strong>)</td>
</tr>
<tr>
<td><code>GET /inventories?...</code></td>
<td>1,960 → 2,855 (<strong>+46%</strong>)</td>
<td>13ms → 7ms (<strong>−46%</strong>)</td>
</tr>
<tr>
<td><code>GET /warehouses</code></td>
<td>1,792 → 3,361 (<strong>+88%</strong>)</td>
<td>17ms → 7ms (<strong>−59%</strong>)</td>
</tr>
<tr>
<td><code>GET /warehouses/1</code></td>
<td>2,690 → 3,430 (<strong>+28%</strong>)</td>
<td>—</td>
</tr>
</tbody></table>
<p>측정 환경은 로컬 MySQL에 데이터 1건으로 <strong>DB 쿼리 자체가 이미 매우 빠른 조건</strong>입니다. 실제 수만 건 이상의 환경에서는 DB 조회가 수백 ms로 느려지므로 캐시 효과가 훨씬 더 커질 것입니다.</p>
<p>&nbsp;</p>
<h2 id="💥-cacheevict의-구조적-한계-발견">💥 @CacheEvict의 구조적 한계 발견</h2>
<p>재고를 변경하는 메서드마다 캐시를 비워야 한다는 문제가 있었습니다. 처음에는 다음과 같이 애노테이션으로 처리했습니다.</p>
<pre><code class="language-java">@Caching(evict = {
    @CacheEvict(value = &quot;inventory&quot;, allEntries = true),
    @CacheEvict(value = &quot;inventoryByWarehouse&quot;, allEntries = true)
})
public InboundResult completeInbound(Long inboundId) { ... }</code></pre>
<p>&nbsp;</p>
<p><code>@CacheEvict</code>의 <code>key</code> 속성은 메서드 파라미터만 SpEL로 참조할 수 있는데, 입출고 서비스의 메서드 시그니처는 <code>inboundId</code>나 <code>outboundId</code>만 받습니다.</p>
<pre><code class="language-java">public InboundResult completeInbound(Long inboundId)
public OutboundResult completeOutbound(Long outboundId)</code></pre>
<p><strong>어떤 (<code>warehouseId</code>, <code>zoneId</code>, <code>productId</code>) 조합이 변경될지 메서드 시그니처만으로는 알 수 없습니다</strong>. 특정 키를 지정한 evict가 애노테이션 레벨에서 구조적으로 불가능한 것입니다.</p>
<p><code>allEntries = true</code>는 단순하고 안전하지만 <strong>창고 A의 입출고 이벤트 하나로 창고 B, C, D, …의 캐시까지 전부 날립니다</strong>. 창고가 늘어날수록 파급 효과가 선형적으로 커질 것입니다.</p>
<p>창고 2개 환경에서 측정해보니, 입고 완료 한 번당 필요한 무효화 2개에 불필요한 무효화 2개가 섞여 총 4개가 지워졌습니다. 창고 10개면 20개가 지워지고 그 중 18개가 불필요할 것입니다.</p>
<p>&nbsp;</p>
<h2 id="👍-최종-설계-inventorycacheevictor-pr-40">👍 최종 설계: InventoryCacheEvictor (PR #40)</h2>
<p><a href="https://github.com/2026-KB-WMS/goods-wms/pull/40">refactor: 재고 캐시 무효화를 전체 삭제에서 영향 범위 기반 선택적 삭제로 개선</a></p>
<p><code>CacheManager</code>를 직접 주입받아 메서드 내부에서 영향받은 키를 <strong>그때그때 계산해서 evict</strong>하는 전용 컴포넌트를 만들었습니다.</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class InventoryCacheEvictor {

    private final CacheManager cacheManager;

    public void evictInventoryEntry(Long warehouseId, Long zoneId, Long productId) {
        String key = warehouseId + &quot;:&quot; + zoneId + &quot;:&quot; + productId;
        afterCommit(() -&gt; getCache(&quot;inventory&quot;).evict(key));
    }

    public void evictInventoryByWarehouse(Long warehouseId) {
        afterCommit(() -&gt; getCache(&quot;inventoryByWarehouse&quot;).evict(String.valueOf(warehouseId)));
    }
    // ...
}</code></pre>
<p>&nbsp;</p>
<p>호출 측(<code>InboundService.completeInbound()</code>)은 아래와 같이 변경됩니다.</p>
<pre><code class="language-java">for (InboundLine line : inbound.getLines()) {
    cacheEvictor.evictInventoryEntry(warehouseId, line.getNormalZoneId(), line.getProductId());
    if (line.getDamagedZoneId() != null) {
        cacheEvictor.evictInventoryEntry(warehouseId, line.getDamagedZoneId(), line.getProductId());
    }
}
cacheEvictor.evictInventoryByWarehouse(warehouseId);</code></pre>
<p>이제 창고 A의 입고가 창고 B의 캐시를 건드리지 않습니다.</p>
<p>&nbsp;</p>
<h2 id="⛑️-트랜잭션-안전성-왜-aftercommit에-등록해야-하지">⛑️ 트랜잭션 안전성: 왜 afterCommit에 등록해야 하지?</h2>
<p>여기서 미묘한 함정이 하나 있는데, 메서드 안에서 <code>cache.evict(key)</code>를 그냥 호출하면, DB 트랜잭션이 롤백되어도 <strong>evict는 되돌릴 수 없다는 것</strong>입니다.</p>
<h3 id="🖌️-시나리오">🖌️ 시나리오</h3>
<ol>
<li><code>completeInbound()</code> 안에서 재고 UPDATE 실행</li>
<li>그 직후 <code>cache.evict(key)</code> 호출 → 캐시 제거됨</li>
<li>트랜잭션 중 다른 로직에서 예외 발생 → DB 롤백</li>
<li>DB는 원래 값으로 돌아왔는데 캐시는 이미 비워진 상태. 다음 조회가 DB에서 다시 적재하므로 정합성 자체는 깨지지 않지만, <strong>불필요한 캐시 미스가 발생</strong>한다.</li>
</ol>
<p><code>@CacheEvict(beforeInvocation = false)</code>(기본값)는 *&quot;메서드가 정상 반환 후 evict&quot;* 지만, Spring 트랜잭션 완료 이전이기 때문에 동일한 문제가 생길 수 있습니다. (<a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/transaction/TransactionAwareCacheDecorator.html">TransactionAwareCacheDecorator</a>로 보완 가능)</p>
<p>&nbsp;</p>
<h3 id="🔄-aftercommit-콜백">🔄 afterCommit 콜백</h3>
<p><code>TransactionSynchronizationManager.registerSynchronization()</code>에 콜백을 등록하면 <strong>트랜잭션이 커밋된 이후에만</strong> 실행되게 할 수 있습니다.</p>
<pre><code class="language-java">private void afterCommit(Runnable task) {
    if (TransactionSynchronizationManager.isSynchronizationActive()) {
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                task.run();
            }
        });
    } else {
        task.run();
    }
}</code></pre>
<ul>
<li><code>isSynchronizationActive()</code>: 트랜잭션 컨텍스트가 있는지 확인</li>
<li>컨텍스트 있음: <code>afterCommit()</code> 콜백에 Runnable 등록 → 커밋 이후에만 실행</li>
<li>컨텍스트 없음(테스트 등): 즉시 실행 (fail-safe)</li>
</ul>
<p>이 두 줄로 DB는 롤백됐는데 캐시만 날아가는 버그를 차단할 수 있습니다.</p>
<p>&nbsp;</p>
<h2 id="🔍-검증-창고-2개-시나리오">🔍 검증: 창고 2개 시나리오</h2>
<h3 id="1️⃣-1단계-캐시-적재">1️⃣ 1단계: 캐시 적재</h3>
<pre><code>GET /inventories?warehouseId=1&amp;zoneId=1&amp;productId=1
GET /inventories?warehouseId=2&amp;zoneId=2&amp;productId=2
GET /inventories/warehouses/1
GET /inventories/warehouses/2</code></pre><p>Redis에 4개 캐시를 적재합니다.</p>
<p>&nbsp;</p>
<h3 id="2️⃣-2단계-창고-1에만-입고-완료">2️⃣ 2단계: 창고 1에만 입고 완료</h3>
<pre><code>POST   /inbounds  {&quot;warehouseId&quot;: 1, &quot;handlerId&quot;: 1, &quot;supplierName&quot;: &quot;테스트&quot;}
POST   /inbounds/{id}/lines  {&quot;productId&quot;: 1, &quot;orderedQuantity&quot;: 10}
PATCH  /inbounds/{id}/inspection/start
PATCH  /inbounds/{id}/lines/{lineId}/inspection  {&quot;normalQuantity&quot;: 10, &quot;damagedQuantity&quot;: 0, &quot;pendingInspectionQuantity&quot;: 0}
PATCH  /inbounds/{id}/complete</code></pre><p>&nbsp;</p>
<h3 id="👀-결과-비교">👀 결과 비교</h3>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/777c268f-3160-4f90-af9b-476ed3229a3b/image.png" alt=""></p>
<table>
<thead>
<tr>
<th><strong>방식</strong></th>
<th><strong>창고 1 캐시</strong></th>
<th><strong>창고 2 캐시</strong></th>
<th><strong>캐시 히트율</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>allEntries=true</code></td>
<td>삭제 (정상)</td>
<td>삭제 (<strong>불필요</strong>)</td>
<td>0% (전체 초기화)</td>
</tr>
<tr>
<td>선택적 무효화</td>
<td>삭제 (정상)</td>
<td><strong>유지</strong></td>
<td><strong>100% (영향 없는 창고)</strong></td>
</tr>
</tbody></table>
<hr>
<p><strong>*&lt;참고 자료&gt;*</strong></p>
<ul>
<li><a href="https://github.com/2026-KB-WMS/goods-wms/issues/34">#34: 재고 및 창고 조회 성능 개선을 위한 Redis 캐시 도입 (이슈)</a></li>
<li><a href="https://github.com/2026-KB-WMS/goods-wms/pull/35">#35: feat: 재고 및 창고 조회 성능 개선을 위한 Redis 캐시 도입 (PR)</a></li>
<li><a href="https://github.com/2026-KB-WMS/goods-wms/issues/38">#38: 재고 캐시 무효화를 영향 범위 기반 선택적 무효화로 개선 (이슈)</a></li>
<li><a href="https://github.com/2026-KB-WMS/goods-wms/pull/40">#40: refactor: 재고 캐시 무효화를 전체 삭제에서 영향 범위 기반 선택적 삭제로 개선 (PR)</a></li>
</ul>
<ul>
<li><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/support/TransactionSynchronization.html">TransactionSynchronization: Spring Framework Javadoc</a></li>
<li><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/transaction/TransactionAwareCacheDecorator.html">TransactionAwareCacheDecorator: Spring Framework Javadoc</a></li>
<li><a href="https://docs.spring.io/spring-framework/reference/integration/cache/annotations.html">Declarative Annotation-based Caching: Spring 공식 문서</a></li>
<li><a href="https://www.baeldung.com/spring-cache-tutorial">A Guide To Caching in Spring: Baeldung</a></li>
<li><a href="https://redis.io/blog/cache-consistency-strategies/">Cache consistency: strategies to keep data fresh: Redis Blog</a></li>
<li><a href="https://techblog.woowahan.com/23138/">이제 Redis를 멈춰보겠습니다: @CacheEvict 파헤치기: 우아한기술블로그</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[재고 동시성: 왜 재고에는 비관적 락, 상태 전이에는 낙관적 락?]]></title>
            <link>https://velog.io/@rocker_nun/%EC%9E%AC%EA%B3%A0-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%99%9C-%EC%9E%AC%EA%B3%A0%EC%97%90%EB%8A%94-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EC%83%81%ED%83%9C-%EC%A0%84%EC%9D%B4%EC%97%90%EB%8A%94-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD</link>
            <guid>https://velog.io/@rocker_nun/%EC%9E%AC%EA%B3%A0-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%99%9C-%EC%9E%AC%EA%B3%A0%EC%97%90%EB%8A%94-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EC%83%81%ED%83%9C-%EC%A0%84%EC%9D%B4%EC%97%90%EB%8A%94-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD</guid>
            <pubDate>Wed, 14 Oct 2026 07:12:24 GMT</pubDate>
            <description><![CDATA[<p>굿즈 WMS에서 동시 출고가 음수 재고를 만들 수 있는 상황을 어떻게 막았는지 정리한 글입니다. 처음에는 모든 JPA 엔티티에 <code>@Version</code> 낙관적 락을 적용했지만, 도메인 특성상 재고는 재시도 비용이 폭증하는 특성 때문에 비관적 락으로 교체했습니다. 같은 동시성 제어라도 작업 구간의 성격에 따라 다른 전략이 필요하다는 것을 수치로 확인한 과정입니다. </p>
<h2 id="💥-문제-요청은-다-성공했는데-데이터가-어긋난다">💥 문제: 요청은 다 성공했는데 데이터가 어긋난다</h2>
<p>리테일 시스템에서 가장 까다로운 상황은 <strong>*&quot;요청 하나하나는 정상인데 전체 데이터가 틀어지는&quot;*</strong> 경우입니다. 굿즈 WMS 설계 과정에서 두 종류의 동시성 문제가 가장 두드러졌습니다.</p>
<h3 id="1️⃣-lost-update-상태-전이-구간">1️⃣ Lost Update: 상태 전이 구간</h3>
<p>멤버의 역할을 바꾸는 PATCH 요청이 동시에 두 개 들어오는 경우:</p>
<ol>
<li><p>스레드 A가 <code>role=ADMIN</code> 상태의 Member를 읽음</p>
</li>
<li><p>스레드 B가 <code>role=ADMIN</code> 상태의 Member를 읽음</p>
</li>
<li><p>스레드 A가 <code>role=WAREHOUSE_MANAGER</code>로 저장 완료</p>
</li>
<li><p>스레드 B가 <code>role=OUTBOUND_HANDLER</code>로 저장 → 스레드 A의 변경이 <strong>조용히 유실됨</strong></p>
</li>
</ol>
<p>두 스레드 모두 200 OK를 받지만 B의 쓰기가 A의 쓰기를 덮어쓴다. 응답과 실제 상태가 불일치한다.</p>
<p>&nbsp;</p>
<h3 id="2️⃣-음수-재고-재고-수량-변경-구간">2️⃣ 음수 재고: 재고 수량 변경 구간</h3>
<p>출고 승인에서 벌어진다.</p>
<ol>
<li><p>재고 10개</p>
</li>
<li><p>스레드 A가 &quot;10개 가능&quot; 확인 → 차감</p>
</li>
<li><p>스레드 B가 &quot;10개 가능&quot; 확인 (A의 커밋 전) → 차감</p>
</li>
<li><p>재고 −10개</p>
</li>
</ol>
<p>여기서는 데이터가 유실 되는 것으로 끝나지 않고, <strong>실제로 재고가 없는데 출고되는 상황</strong>이 벌어진다. 리테일에서 가장 치명적인 상황이라고 볼 수 있습니다.</p>
<p>&nbsp;</p>
<h2 id="🤔-두-구간의-성격-차이">🤔 두 구간의 성격 차이</h2>
<table>
<thead>
<tr>
<th><strong>구간</strong></th>
<th><strong>예</strong></th>
<th><strong>동시 접근 빈도</strong></th>
<th><strong>실패의 비용</strong></th>
</tr>
</thead>
<tbody><tr>
<td>상태 전이</td>
<td>Member 역할 변경, Inbound 상태 전이, Warehouse 활성화</td>
<td>낮음</td>
<td>Lost update(논리적 불일치)</td>
</tr>
<tr>
<td>재고 수량 변경</td>
<td>출고 승인, 출고 완료 시 재고 차감</td>
<td>높음 (한정판/인기 상품 등)</td>
<td>음수 재고(물리적으로 불가능한 상태)</td>
</tr>
</tbody></table>
<p><strong>상태 전이</strong>는 두 사용자가 동시에 Member 역할을 바꾸는 상황이 자주 생기는 게 아니라, 어쩌다 중복 요청이 들어왔을 때 <strong>방어 수단</strong>이 필요한 것뿐입니다. 평시에는 충돌 없이 통과하고, 간혹 터지면 재시도하면 됩니다.</p>
<p><strong>재고 수량 변경</strong>은 한정판 굿즈 출시 시점에 수천 명이 같은 재고를 보고 동시에 요청을 던질 것입니다. <strong>충돌이 평시의 상태</strong>이므로 재시도 비용 자체가 폭증합니다.</p>
<p>&nbsp;</p>
<h2 id="⛓️-1차-시도-전부-낙관적-락-pr-26">⛓️ 1차 시도: 전부 낙관적 락 (PR #26)</h2>
<p><a href="https://github.com/2026-KB-WMS/goods-wms/pull/26">feat: JPA 엔티티 낙관적 락(@Version) 적용</a></p>
<p>처음에는 공식 문서와 많은 블로그가 <strong><em>“낙관적 락은 성능 손해가 적다&quot;</em></strong> 고 안내했기 때문에 <a href="https://github.com/2026-KB-WMS/goods-wms/pull/26">PR #26</a>에서 모든 JPA 엔티티에 <code>@Version</code>을 적용했습니다.</p>
<h3 id="👥-설계-분리-도메인은-version을-모른다">👥 설계 분리: 도메인은 <code>@Version</code>을 모른다</h3>
<p>도메인 객체는 JPA 애노테이션 없이 순수 POJO로 두고, <code>version</code> 필드만 <code>Long</code>으로 운반하고, <code>@Version</code>은 <code>JpaEntity</code>에만 붙입니다.</p>
<pre><code class="language-java">// 도메인 — JPA 모름
public class Member {
    private final Long version;  // 단순 숫자

    public static Member reconstitute(..., Long version) { ... }
    public static Member create(...) { /* version=null */ }
}

// JPA 엔티티 — @Version
@Entity
public class MemberJpaEntity {
    @Version
    private Long version;

    public static MemberJpaEntity from(Member member) {
        entity.version = member.getVersion();
        ...
    }

    public Member toDomain() {
        return Member.reconstitute(..., version);
    }
}</code></pre>
<p>도메인은 <code>version</code>을 운반만 하고 비즈니스 로직에는 영향이 없습니다. 테스트 용이성을 지키면서 JPA가 커밋 시점에 <code>UPDATE ... WHERE id=? AND version=?</code>로 충돌을 감지합니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/ad570dba-60b3-4f7e-b98e-5f4867eeef86/image.png" alt=""></p>
<h3 id="📢-전역-핸들러로-충돌-응답">📢 전역 핸들러로 충돌 응답</h3>
<pre><code class="language-java">@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity&lt;ApiResponse&lt;Void&gt;&gt; handleOptimisticLocking(...) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ApiResponse.error(&quot;다른 요청이 이미 해당 데이터를 수정했습니다. 다시 시도해주세요.&quot;));
}</code></pre>
<p>&nbsp;</p>
<h3 id="📝lost-update-재현-테스트">📝Lost Update 재현 테스트</h3>
<p><code>java.util.concurrent.CountDownLatch</code> 두 개를 조합해 두 스레드가 <strong>정확히 동시에 쓰기</strong>를 수행하도록 강제했습니다.</p>
<ul>
<li><code>readyLatch(2)</code>: 두 스레드가 모두 읽기를 완료할 때까지 대기</li>
<li><code>startLatch(1)</code>: 동시에 쓰기 시작 신호 발송</li>
</ul>
<p><code>@Version</code> 적용 전에는 두 요청이 모두 성공하며 첫 번째 변경이 유실됐지만, 적용 후에는 두 번째 쓰기가 <code>ObjectOptimisticLockingFailureException</code>을 던지며 깨끗이 분리됐습니다.</p>
<pre><code>[초기 상태] id=1, role=ADMIN

[스레드 A] 읽기 완료 - role=ADMIN
[스레드 B] 읽기 완료 - role=ADMIN

[두 스레드 모두 읽기 완료 → 동시 쓰기 시작]

[스레드 B] 저장 완료 (쓰기 순서 1번째) - role=OUTBOUND_HANDLER
[스레드 A] 예외 발생 - ObjectOptimisticLockingFailureException

[최종 상태] role=OUTBOUND_HANDLER
[예외 발생 수] 1</code></pre><p>&nbsp;</p>
<h2 id="🫣-리뷰에서-지적받은-치명적-간과">🫣 리뷰에서 지적받은 치명적 간과</h2>
<p>PR #26의 코드 리뷰에서 한 가지 사실을 간과한 걸 지적받았습니다.</p>
<blockquote>
<p><strong>*&quot;한정판이나 인기 굿즈의 특성상 재고에 대한 동시 요청은 상당히 빈번할 수 있다.&quot;*</strong></p>
</blockquote>
<p>낙관적 락이 성립하는 전제는 <strong>*&quot;충돌이 드물다&quot;*</strong> 는 것인데, <strong>굿즈</strong> WMS에서 재고는 <strong>*&quot;충돌이 자주 발생하는 자원&quot;*</strong> 입니다. 이걸 낙관적 락으로 처리하면 어떤 일이 벌어지는지 알아봤습니다.</p>
<p>&nbsp;</p>
<h3 id="🧮-thundering-herd-계산">🧮 Thundering Herd 계산</h3>
<p>재고 100개에 10명이 동시에 1개씩 차감하는 상황을 가정해보겠습니다.</p>
<ul>
<li><p><strong>1라운드</strong>: 10개 스레드 전부 <code>version=1</code> 상태의 재고를 읽음 → 10개 전부 UPDATE 시도 → 1개만 성공, 9개는 <code>ObjectOptimisticLockingFailureException</code> 예외 발생</p>
</li>
<li><p><strong>2라운드</strong>: 9개 재시도 → 1개 성공, 8개 실패</p>
</li>
<li><p>…</p>
</li>
<li><p><strong>총 쿼리 수</strong>: 10 + 9 + 8 + … + 1 = <strong>55번</strong></p>
</li>
</ul>
<p>100명이면 <strong>5,050번</strong>이 됩니다.</p>
<p>더 심한 건 <strong>클라이언트 측 재시도 로직</strong>이 필요하다는 점입니다. 서버는 *&quot;다시 시도해 주세요&quot;* 라고 하지만, 재시도 간격이 짧으면 다시 충돌할 것입니다. 백오프 전략, 재시도 횟수 제한 등과 같은 작업을 재고 승인 하나 하자고 지불해야 할 복잡도가 너무 크다고 판단했습니다.</p>
<p>&nbsp;</p>
<h2 id="👍-최종-설계-작업-구간에-따라-분기-pr-28">👍 최종 설계: 작업 구간에 따라 분기 (PR #28)</h2>
<p><a href="https://github.com/2026-KB-WMS/goods-wms/pull/28">refactor: 재고 도메인에서 적용한 낙관적 락을 비관적 락 방식으로 변경</a></p>
<p><a href="https://github.com/2026-KB-WMS/goods-wms/pull/28">PR #28</a>에서 <strong>재고(<code>Inventory</code>)만 비관적 락</strong>으로 교체하고, 상태 전이는 그대로 낙관적 락 유지했습니다.</p>
<h3 id="🔐-비관적-락-적용">🔐 비관적 락 적용</h3>
<pre><code class="language-java">public interface InventoryJpaRepository extends JpaRepository&lt;InventoryJpaEntity, Long&gt; {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(&quot;&quot;&quot;
        SELECT i FROM InventoryJpaEntity i
        WHERE i.warehouseId = :warehouseId
          AND i.zoneId = :zoneId
          AND i.productId = :productId
    &quot;&quot;&quot;)
    Optional&lt;InventoryJpaEntity&gt; findByWarehouseIdAndZoneIdAndProductIdWithLock(...);
}</code></pre>
<p>MySQL이 이 쿼리를 <code>SELECT ... FROM inventories WHERE ... FOR UPDATE</code>로 변환하고, 해당 row에 <strong>배타적 X-lock</strong>을 겁니다. 트랜잭션이 커밋/롤백되기 전까지 다른 트랜잭션의 쓰기(및 FOR UPDATE 조회)가 블로킹되는 것입니다.</p>
<p>따라서 재고 수량이 변경되는 모든 경로에서 비관적 락 조회를 쓰도록 변경했습니다.</p>
<ul>
<li><p><code>InboundService.applyInventory()</code>: 입고 완료 시 재고 증가 전</p>
</li>
<li><p><code>OutboundService.validateOutbound()</code>: 예약 전</p>
</li>
<li><p><code>OutboundService.completeOutbound()</code> : 예약 해제 + 정상 재고 차감 전</p>
</li>
</ul>
<p>&nbsp;</p>
<h3 id="📝-10개-스레드-동시-차감-테스트">📝 10개 스레드 동시 차감 테스트</h3>
<p><code>PessimisticLockInventoryTest</code>에서 <code>TransactionTemplate</code> + <code>CountDownLatch</code>로 10개 스레드가 정확히 동시에 1개씩 차감을 시도하는 상황을 관찰했습니다.</p>
<pre><code>[초기 재고] 100개

[10개 스레드 동시 차감 시작]

[처리 1번째] 14:16:52.009 차감 완료 → 남은 재고: 90개
[처리 2번째] 14:16:52.013 차감 완료 → 남은 재고: 80개
[처리 3번째] 14:16:52.015 차감 완료 → 남은 재고: 70개
[처리 4번째] 14:16:52.017 차감 완료 → 남은 재고: 60개
[처리 5번째] 14:16:52.020 차감 완료 → 남은 재고: 50개
[처리 6번째] 14:16:52.022 차감 완료 → 남은 재고: 40개
[처리 7번째] 14:16:52.024 차감 완료 → 남은 재고: 30개
[처리 8번째] 14:16:52.026 차감 완료 → 남은 재고: 20개
[처리 9번째] 14:16:52.029 차감 완료 → 남은 재고: 10개
[처리 10번째] 14:16:52.031 차감 완료 → 남은 재고: 0개

[최종 재고] 0개
[성공 횟수] 10
[실패 횟수] 0</code></pre><p>10개가 몇 ms 간격으로 순차 직렬화된 것을 볼 수 있었습니다.</p>
<p>&nbsp;</p>
<h2 id="🎭-상품-카테고리에-따라-분기하면-안-될까">🎭 상품 카테고리에 따라 분기하면 안 될까?</h2>
<p>여기서 중요한 설계 판단 하나를 짚고 싶습니다. 이 분기를 <strong>*&quot;일반 상품은 낙관적 락, 한정판은 비관적 락&quot;*</strong> 과 같이 상품 특성에 따라 나누는 방식도 생각할 수 있었습니다. 하지만 이렇게 하면 안 되는 이유가 있습니다.</p>
<ol>
<li><p><strong>한정판의 트래픽은 운영 중 동적으로 바뀐다.</strong> 어제까지 평범하던 상품이 콜라보 공지 하나로 수천 명이 몰리는 상품이 될 수 있습니다. 상품 레벨 메타데이터로 락 전략을 분기하면 운영팀이 매번 플래그를 바꿔야 할 것입니다.</p>
</li>
<li><p><strong>충돌 구간 자체는 운영 상태와 무관하게 코드에서 고정됩니다.</strong> 재고 수량 변경은 어떤 상품이든 항상 재고 수량 변경입니다. 코드 레벨에서 해당 구간은 항상 비관적 락이라고 선언해두면, 상품 메타데이터와 무관하게 구조적으로 안전해집니다.</p>
</li>
<li><p><strong>분기 축을 데이터에 두면 테스트가 어렵습니다.</strong> 상품의 인기도는 외부 상태인데, 테스트에서 재현한다는 것 자체가 어불성설입니다. 반면 출고 승인 코드 구간은 코드 자체이므로 테스트에 쓰기 좋습니다.</p>
</li>
</ol>
<p>분기 기준은 작업 구간의 성격이지, 데이터의 메타데이터가 아닙니다. 충돌이 평시 상태인 구간은 코드로 식별 가능하지만, 충돌이 평시 상태인 상품은 운영 중 결정되는 상태입니다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em></p>
<ul>
<li><a href="https://github.com/2026-KB-WMS/goods-wms/issues/25">#25: JPA 엔티티 낙관적 락 적용 (이슈)</a></li>
<li><a href="https://github.com/2026-KB-WMS/goods-wms/pull/26">#26: feat: JPA 엔티티 낙관적 락(@Version) 적용 (PR)</a></li>
<li><a href="https://github.com/2026-KB-WMS/goods-wms/issues/27">#27: 재고 도메인 락 전략을 낙관적 락에서 비관적 락으로 교체 (이슈)</a></li>
<li><a href="https://github.com/2026-KB-WMS/goods-wms/pull/28">#28: refactor: 재고 도메인에서 적용한 낙관적 락을 비관적 락 방식으로 변경 (PR)</a></li>
</ul>
<ul>
<li><a href="https://jakarta.ee/specifications/persistence/3.2/apidocs/jakarta.persistence/jakarta/persistence/LockModeType.html">LockModeType: Jakarta Persistence 3.2 API</a></li>
<li><a href="https://jakarta.ee/learn/docs/jakartaee-tutorial/current/persist/persistence-locking/persistence-locking.html">Controlling Concurrent Access to Entity Data with Locking: Jakarta EE Tutorial</a></li>
<li><a href="https://www.baeldung.com/jpa-pessimistic-locking">Pessimistic Locking in JPA: Baeldung</a></li>
<li><a href="https://hudi.blog/jpa-concurrency-control-optimistic-lock-and-pessimistic-lock/">JPA의 낙관적 락과 비관적 락을 통해 엔티티에 대한 동시성 제어하기: hudi.blog</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[N+1 문제부터 비관적 락까지]]></title>
            <link>https://velog.io/@rocker_nun/N1-%EB%AC%B8%EC%A0%9C%EB%B6%80%ED%84%B0-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@rocker_nun/N1-%EB%AC%B8%EC%A0%9C%EB%B6%80%ED%84%B0-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 06 Oct 2026 02:37:57 GMT</pubDate>
            <description><![CDATA[<h1 id="⚡️-조회-최적화">⚡️ 조회 최적화</h1>
<p>이전 글까지는 메서드 이름, <code>@Query</code>, 반환 타입, 페이징, 벌크 연산 등 <strong>쿼리를 어떻게 작성할지</strong>에 초점을 맞춰왔다. 이제는 그 쿼리가 <strong>어떻게 실행되는지</strong>를 들여다볼 차례다. N+1 문제를 해결하는 <code>@EntityGraph</code>, 영속성 컨텍스트의 메모리를 아끼는 <code>@QueryHints</code>, 동시성 제어를 위한 <code>@Lock</code>. 세 가지 모두 메서드 선언 위에 애노테이션 한 줄만 더하면 적용되는 도구들이다.</p>
<p>&nbsp;</p>
<h2 id="1️⃣-n1-문제-lazy-로딩의-함정">1️⃣ N+1 문제: LAZY 로딩의 함정</h2>
<p>조회 성능 이야기를 하려면 그 유명한 N+1 문제부터 짚고 넘어가야 한다. 지연 로딩(<code>FetchType.LAZY</code>)으로 설정된 연관관계는 처음 접근하는 시점에 <code>SELECT</code>가 나간다. 이게 반복되면 쿼리가 폭발적으로 늘어난다.</p>
<pre><code class="language-java">@Test
public void findMemberLazy() {
    teamRepository.save(new Team(&quot;teamA&quot;));
    teamRepository.save(new Team(&quot;teamB&quot;));
    memberRepository.save(new Member(&quot;member1&quot;, 10, teamA));
    memberRepository.save(new Member(&quot;member2&quot;, 11, teamB));
    em.flush();
    em.clear();

    List&lt;Member&gt; members = memberRepository.findAll();
    for (Member m : members) {
        System.out.println(&quot;팀명: &quot; + m.getTeam().getName()); // 이 지점에서 쿼리 발생
    }
}</code></pre>
<p>&nbsp;</p>
<p>실행되는 SQL은 다음과 같다.</p>
<pre><code class="language-sql">-- 1. 멤버 전체 조회 (1번)
select m.member_id, m.age, m.team_id, m.username from member m;

-- 2. m.getTeam().getName() 접근 시마다 팀 조회 (N번)
select t.team_id, t.name from team t where t.team_id = ?;
select t.team_id, t.name from team t where t.team_id = ?;</code></pre>
<p>멤버가 두 명이면 쿼리가 <strong>1 + 2 = 3번</strong> 나간다. 먼저 모든 멤버를 조회한 뒤, 각 멤버마다 팀을 따로 조회한다. 멤버가 1,000명이면? <strong>1,001번</strong>의 쿼리가 날아간다. 이게 <strong>N+1 문제</strong>다.</p>
<p>착각하기 쉬운 지점은 *&quot;LAZY가 문제니 EAGER로 바꾸면 되지 않을까?&quot;* 다. 그렇지 않다. EAGER는 JPQL에서 더 심각한 N+1 문제를 만들어내고, 당장 쓰지도 않는 연관 데이터까지 매번 함께 가져온다. <strong>연관관계는 모두 LAZY로 두고, 필요한 시점에만 Fetch Join이나 EntityGraph로 함께 로딩하는 게 정석이다.</strong></p>
<p>&nbsp;</p>
<h2 id="2️⃣-fetch-join-전통적인-해결책">2️⃣ Fetch Join: 전통적인 해결책</h2>
<p>JPQL에 <code>join fetch</code>를 쓰면 한 번의 SQL로 연관 엔티티까지 함께 가져온다.</p>
<pre><code class="language-java">@Query(&quot;select m from Member m left join fetch m.team&quot;)
List&lt;Member&gt; findMemberFetchJoin();</code></pre>
<p>&nbsp;</p>
<p>실행 SQL은 다음과 같다.</p>
<pre><code class="language-sql">select
    m.member_id, m.age, t.team_id, t.name, m.username
from member m
left join team t on t.team_id = m.team_id;  // 팀 엔티티까지 가져옴</code></pre>
<p>쿼리 한 번으로 끝난다. 가져온 <code>Team</code>은 프록시가 아닌 실제 Team 인스턴스이고, 영속성 컨텍스트에 바로 올라간다.</p>
<pre><code class="language-java">for (Member m : members) {
    System.out.println(m.getTeam().getClass()); // class com.study.data_jpa.entity.Team
    System.out.println(m.getTeam().getName()); // 추가 쿼리 없음
}</code></pre>
<p>Fetch Join은 강력하지만 쿼리를 직접 작성해야 하고, Spring Data JPA가 기본으로 제공하는 메서드(<code>findAll</code> 등)에 적용하기엔 번거롭다. 이러한 불편을 <code>@EntityGraph</code>으로 해결할 수 있다.</p>
<p>&nbsp;</p>
<h1 id="3️⃣-entitygraph-fetch-join의-애노테이션-버전">3️⃣ @EntityGraph: Fetch Join의 애노테이션 버전</h1>
<p><code>@EntityGraph</code>는 Fetch Join을 애노테이션으로 표현한다. JPQL을 직접 쓰지 않아도 된다.</p>
<h2 id="🎯-기본-사용-공통-메서드-오버라이드">🎯 기본 사용: 공통 메서드 오버라이드</h2>
<pre><code class="language-java">@Override
@EntityGraph(attributePaths = {&quot;team&quot;})
List&lt;Member&gt; findAll();</code></pre>
<p><code>JpaRepository</code>가 제공하는 <code>findAll()</code>을 그대로 쓰면 N+1이 터지지만, <code>@Override</code>로 재정의하고 <code>@EntityGraph</code>만 붙여두면 Fetch Join으로 동작한다. 구현체는 여전히 없다.</p>
<p>&nbsp;</p>
<h2 id="🏷️-query와-조합">🏷️ @Query와 조합</h2>
<pre><code class="language-java">@EntityGraph(attributePaths = {&quot;team&quot;})
@Query(&quot;select m from Member m&quot;)
List&lt;Member&gt; findMembers();</code></pre>
<p><code>@Query</code>로 직접 작성한 JPQL에도 붙일 수 있다. JPQL에 <code>join fetch</code>를 넣지 않아도 Spring Data JPA가 알아서 <code>team</code>을 함께 로딩해준다.</p>
<p>&nbsp;</p>
<h2 id="🧾-메서드-이름-쿼리와-조합">🧾 메서드 이름 쿼리와 조합</h2>
<pre><code class="language-java">@EntityGraph(attributePaths = {&quot;team&quot;})
List&lt;Member&gt; findEntityGraphByUsername(String username);</code></pre>
<p>메서드 이름으로 쿼리가 만들어지고, <code>@EntityGraph</code>가 거기에 팀 fetch만 더해준다. 가장 가볍게 쓰기 좋은 조합이다.</p>
<p>&nbsp;</p>
<h2 id="🎨-namedentitygraph">🎨 @NamedEntityGraph</h2>
<p>EntityGraph 정의를 엔티티에 미리 선언해두고 여러 메서드에서 재사용할 수도 있다.</p>
<pre><code class="language-java">@Entity
@NamedEntityGraph(name = &quot;Member.all&quot;, attributeNodes = @NamedAttributeNode(&quot;team&quot;))
public class Member { ... }</code></pre>
<pre><code class="language-java">@EntityGraph(&quot;Member.all&quot;)
List&lt;Member&gt; findEntityGraphByUsername(String username);</code></pre>
<p><code>attributePaths</code>를 매번 나열하는 대신 이름으로 참조한다. fetch할 연관관계가 많거나, 여러 리포지토리에서 같은 그래프를 쓰고 싶을 때 유용하다.</p>
<p>&nbsp;</p>
<h3 id="🏳️-fetch-join-vs-entitygraph">🏳️ fetch join vs @EntityGraph</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>fetch join (<code>@Query</code>)</th>
<th><code>@EntityGraph</code></th>
</tr>
</thead>
<tbody><tr>
<td>작성 방식</td>
<td>JPQL 직접 작성</td>
<td>애노테이션</td>
</tr>
<tr>
<td>공통 메서드에 적용</td>
<td>어려움 (JPQL 필요)</td>
<td><code>@Override</code>  • 애노테이션 한 줄</td>
</tr>
<tr>
<td>메서드 이름 쿼리에 적용</td>
<td>불가</td>
<td>바로 붙이면 됨</td>
</tr>
<tr>
<td>세세한 쿼리 제어</td>
<td>자유로움 (where, group by 등)</td>
<td>제한적</td>
</tr>
<tr>
<td>가독성</td>
<td>SQL처럼 명시적</td>
<td>의도가 깔끔함</td>
</tr>
</tbody></table>
<p>간단한 fetch는 <code>@EntityGraph</code>, 복잡한 쿼리는 fetch join. 둘은 경쟁 관계가 아니라 상황에 맞춰 골라 쓰는 도구다.</p>
<p>&nbsp;</p>
<h1 id="4️⃣-queryhints-읽기-전용-조회">4️⃣ @QueryHints: 읽기 전용 조회</h1>
<p>JPA는 엔티티를 조회할 때 원본 스냅샷을 함께 떠둔다. 트랜잭션 커밋 시점에 현재 상태와 스냅샷을 비교해서 바뀐 필드가 있으면 UPDATE를 날리기 위해서다. 이게 바로 <strong>변경 감지(dirty checking)</strong>의 원리였다.</p>
<p>그런데 지금 조회하는 엔티티를 수정할 일이 전혀 없다고 확신할 때, 스냅샷을 만드는 작업은 메모리 낭비다. 이럴 때 <code>readOnly</code> 힌트를 주면 스냅샷 생성 자체를 건너뛴다.</p>
<pre><code class="language-java">@QueryHints(value = @QueryHint(name = &quot;org.hibernate.readOnly&quot;, value = &quot;true&quot;))
Member findReadOnlyByUsername(String username);</code></pre>
<pre><code class="language-java">@Test
public void queryHint() {
    memberRepository.save(new Member(&quot;member1&quot;, 10));
    em.flush();
    em.clear();

    Member findMember = memberRepository.findReadOnlyByUsername(&quot;member1&quot;);
    findMember.setUsername(&quot;member2&quot;); // 필드 수정 시도

    em.flush(); // UPDATE 쿼리가 나가지 않는다
}</code></pre>
<p>일반 조회였다면 <code>flush()</code> 시점에 UPDATE가 나갔을 것이다. 하지만 <code>readOnly</code> 힌트를 준 경우에는 스냅샷 자체가 없어서 비교할 대상이 없고, UPDATE도 나가지 않는다.</p>
<p>&nbsp;</p>
<h3 id="🤔-언제-써야-할까">🤔 언제 써야 할까?</h3>
<p>사실 <strong>대부분의 조회에서는 체감되는 이익이 크지 않다</strong>. 스냅샷 생성 비용 자체가 그렇게 크지 않고, 진짜 병목은 대부분 SQL 쪽에 있다. 하지만 조회량이 압도적으로 많고 수정이 일어나지 않는 지점(예: 리포팅 페이지, 통계 대시보드, 외부 API 응답 조립)에는 충분히 유의미하다. 필요한 지점에만 선택적으로 적용하는 게 정석이다.</p>
<p>&nbsp;</p>
<h1 id="5️⃣-lock-비관적-락">5️⃣ @Lock: 비관적 락</h1>
<p>같은 데이터를 수정하려는 요청이 동시에 들어오면 <strong>갱신 손실(lost update)</strong>이 발생할 수 있다. 재고 차감이나 잔액 출금처럼 두 트랜잭션이 동시에 읽어서 각자 쓰면 한쪽의 결과가 사라져버린다. 이걸 막는 방법 중 하나가 <strong>비관적 락(Pessimistic Lock)</strong>이다.</p>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
List&lt;Member&gt; findLockByUsername(String username);</code></pre>
<p>&nbsp;</p>
<p>실행되는 SQL은 다음과 같다.</p>
<pre><code class="language-sql">select m.member_id, m.age, m.team_id, m.username
from member m
where m.username = ?
for update  -- 쓰기 잠금</code></pre>
<p>마지막 <code>for update</code> 절이 핵심이다. 조회한 레코드에 DB가 책임지고 쓰기 잠금을 걸어서, 다른 트랜잭션은 잠금이 해제될 때까지 대기해야 한다.</p>
<p>&nbsp;</p>
<h3 id="🔒-락-모드-비교">🔒 락 모드 비교</h3>
<table>
<thead>
<tr>
<th>LockModeType</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>PESSIMISTIC_READ</code></td>
<td>공유 락. 다른 트랜잭션은 읽기는 가능하지만 쓰기는 불가</td>
</tr>
<tr>
<td><code>PESSIMISTIC_WRITE</code></td>
<td><strong>배타 락</strong>. 다른 트랜잭션은 읽기도 쓰기도 대기 (일반적으로 사용)</td>
</tr>
<tr>
<td><code>OPTIMISTIC</code></td>
<td><strong>낙관적 락</strong>. 조회 시점에는 잠그지 않고, 커밋 시점에 버전을 비교해 충돌을 감지</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h2 id="⚠️-비관적-락-사용-시-주의">⚠️ 비관적 락 사용 시 주의</h2>
<p><code>PESSIMISTIC_WRITE</code>는 확실한 대신 DB 리소스를 오래 점유하는 비싼 전략이다. 쓰기 전에 몇 가지를 짚어봐야 한다.</p>
<ul>
<li><strong>데드락 위험</strong>: 여러 락을 서로 다른 순서로 잡으면 서로를 기다리다 교착 상태에 빠질 수 있다.</li>
<li><strong>대기 시간</strong>: 트래픽이 많은 테이블은 다른 요청이 대기하면서 응답이 느려진다.</li>
<li><strong>낙관적 락으로 충분한 경우</strong>: 충돌이 드문 상황이라면 <code>@Version</code>을 활용한 <strong>낙관적 락</strong>이 더 가볍고 확장성 있다.</li>
</ul>
<p>정말로 금전적 이해관계가 걸린 동시 수정(계좌 잔액, 결제, 재고 차감 등)에만 비관적 락을 걸고, 그 외의 경우에는 낙관적 락을 먼저 고려하는 게 좋다.</p>
<p>&nbsp;</p>
<h1 id="📝-정리">📝 정리</h1>
<table>
<thead>
<tr>
<th>도구</th>
<th>해결하는 문제</th>
<th>핵심 포인트</th>
</tr>
</thead>
<tbody><tr>
<td><strong>@EntityGraph</strong></td>
<td>N+1 (LAZY 로딩으로 인한 추가 쿼리)</td>
<td>fetch join의 애노테이션 버전, 공통 메서드에도 적용 가능</td>
</tr>
<tr>
<td><strong>@QueryHints(readOnly)</strong></td>
<td>불필요한 스냅샷 생성으로 인한 메모리/성능 낭비</td>
<td>변경 감지 비활성화, 대용량 읽기 전용 지점에 선택적으로 적용</td>
</tr>
<tr>
<td><strong>@Lock(PESSIMISTIC_WRITE)</strong></td>
<td>동시 수정으로 인한 갱신 손실</td>
<td><code>SELECT ... FOR UPDATE</code>, 금전적 맥락 외에는 낙관적 락 우선 고려</td>
</tr>
</tbody></table>
<p>세 가지 도구 모두 <strong>쿼리의 실행 전략을 제어</strong>하는 애노테이션이다. 쿼리가 무엇을 조회하는지는 JPQL이 결정하지만, 그 쿼리가 어떻게 실행되고 영속성 컨텍스트와 어떻게 상호작용하는지는 이 세 가지가 결정한다. N+1을 피하고, 불필요한 스냅샷에 메모리를 쓰지 않고, 동시 요청에 데이터가 깨지지 않을 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[쿼리 메서드부터 벌크 연산까지]]></title>
            <link>https://velog.io/@rocker_nun/%EC%BF%BC%EB%A6%AC-%EB%A9%94%EC%84%9C%EB%93%9C%EB%B6%80%ED%84%B0-%EB%B2%8C%ED%81%AC-%EC%97%B0%EC%82%B0%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@rocker_nun/%EC%BF%BC%EB%A6%AC-%EB%A9%94%EC%84%9C%EB%93%9C%EB%B6%80%ED%84%B0-%EB%B2%8C%ED%81%AC-%EC%97%B0%EC%82%B0%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 06 Oct 2026 01:48:07 GMT</pubDate>
            <description><![CDATA[<h1 id="1️⃣-메서드-이름으로-쿼리-생성">1️⃣ 메서드 이름으로 쿼리 생성</h1>
<p>이전 글에서는 Spring Data JPA의 공통 인터페이스가 반복적인 CRUD 코드를 어떻게 없애주는지 봤다. 하지만 공통 인터페이스는 모든 엔티티에 공통인 기능만 제공한다. <code>username</code>으로 회원을 찾는 것 같은 도메인 특화 쿼리는 여전히 필요하다.</p>
<p>이걸 해결하는 게 <strong>쿼리 메서드</strong>다. Spring Data JPA는 쿼리를 작성하는 여러 가지 방법을 제공한다. 가장 간단한 메서드 이름 기반 쿼리부터 시작해서, 복잡한 조건은 <code>@Query</code>로 직접 작성하고, 상황에 맞게 반환 타입을 고르고, 페이징과 벌크 연산도 가능하다.</p>
<p>&nbsp;</p>
<h2 id="👀-순수-jpa로-작성">👀 순수 JPA로 작성</h2>
<p>이름(<code>username</code>)과 나이(<code>age</code>) 조건으로 회원을 찾는 쿼리를 순수 JPA로 작성하면 이렇다.</p>
<pre><code class="language-java">public List&lt;Member&gt; findByUsernameAndAgeGreaterThan(String username, int age) {
    return em.createQuery(&quot;select m from Member m where m.username = :username and m.age &gt; :age&quot;)
            .setParameter(&quot;username&quot;, username)
            .setParameter(&quot;age&quot;, age)
            .getResultList();
}</code></pre>
<p>조건 하나를 추가하는 데 JPQL 문자열 작성, 파라미터 바인딩, <code>createQuery</code> 호출이 모두 필요하다. 조건이 늘어나면 이 코드가 비례해서 늘어난다.</p>
<p>&nbsp;</p>
<h2 id="✨-spring-data-jpa의-메서드-이름-쿼리">✨ Spring Data JPA의 메서드 이름 쿼리</h2>
<p>Spring Data JPA는 이걸 <strong>메서드 이름만으로 처리</strong>한다.</p>
<pre><code class="language-java">public interface MemberRepository extends JpaRepository&lt;Member, Long&gt; {
    List&lt;Member&gt; findByUsernameAndAgeGreaterThan(String username, int age);
}</code></pre>
<p>해당 인터페이스를 구현하지도 않았다. 단지 선언만 했을 뿐인데 쿼리가 자동으로 만들어진다. Spring Data JPA가 메서드 이름을 파싱해서 그에 맞는 JPQL을 생성해준 것이다.</p>
<p>&nbsp;</p>
<h3 id="📖-메서드-이름-규칙">📖 메서드 이름 규칙</h3>
<p>메서드 이름은 아래 규칙을 따른다.</p>
<table>
<thead>
<tr>
<th>접두사</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>findBy</code></td>
<td>조회 (<code>findBy</code>, <code>queryBy</code>, <code>readBy</code> 등도 가능)</td>
</tr>
<tr>
<td><code>countBy</code></td>
<td>개수 반환</td>
</tr>
<tr>
<td><code>existsBy</code></td>
<td>존재 여부 반환</td>
</tr>
<tr>
<td><code>deleteBy</code></td>
<td>삭제</td>
</tr>
</tbody></table>
<p>그리고 그 뒤에 조건 키워드가 붙는다. <code>And</code>, <code>Or</code>, <code>GreaterThan</code>, <code>LessThan</code>, <code>Between</code>, <code>Like</code>, <code>In</code>, <code>IsNull</code>, <code>OrderBy...Desc</code> 등등…</p>
<p>예를 들어 <code>findByUsernameAndAgeGreaterThan</code>은 Spring Data JPA가 다음처럼 파싱한다.</p>
<pre><code>findBy → SELECT
Username → username = ?
And → AND
AgeGreaterThan → age &gt; ?</code></pre><p>&nbsp;</p>
<h2 id="🚨-하지만-단점도-명확하다">🚨 하지만 단점도 명확하다</h2>
<p>조건이 2~3개일 때는 깔끔하게 처리할 수 있지만, 조건이 수도 없이 많아지면 어떻게 될까?</p>
<pre><code class="language-java">List&lt;Member&gt; findByUsernameAndAgeGreaterThanAndTeamNameAndCreatedDateAfterOrderByUsernameDesc(...);</code></pre>
<p>메서드 이름을 읽는 게 더 어렵다. 조건이 많거나 조금만 복잡해져도 다른 방법을 써야 한다. 중요한 포인트 하나 더 짚고 넘어가자면, Spring Data JPA는 엔티티 필드명과 메서드 이름의 매칭을 애플리케이션 로딩 시점에 검증한다. 엔티티 필드명을 바꾸고 메서드 이름을 안 바꾸면 서버 자체가 뜨지 않는다. </p>
<p>&nbsp;</p>
<h1 id="2️⃣-query로-리포지토리-메서드에-쿼리-정의">2️⃣ @Query로 리포지토리 메서드에 쿼리 정의</h1>
<p>메서드 이름 쿼리의 한계를 보완하는 게 <code>@Query</code> 애노테이션이다. JPQL을 메서드 위에 직접 작성할 수 있다.</p>
<pre><code class="language-java">@Query(&quot;select m from Member m where m.username = :username and m.age = :age&quot;)
List&lt;Member&gt; findUser(@Param(&quot;username&quot;) String username, @Param(&quot;age&quot;) int age);</code></pre>
<p>&nbsp;</p>
<h2 id="🛠-메서드-이름-쿼리와의-차이">🛠 메서드 이름 쿼리와의 차이</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>메서드 이름 쿼리</th>
<th>@Query</th>
</tr>
</thead>
<tbody><tr>
<td>조건이 복잡할 때</td>
<td>메서드 이름이 길어짐</td>
<td>JPQL 그대로 작성</td>
</tr>
<tr>
<td>JPQL 오타</td>
<td>애플리케이션 로딩 시점에 에러</td>
<td>애플리케이션 로딩 시점에 에러</td>
</tr>
<tr>
<td>조인/서브쿼리</td>
<td>어려움</td>
<td>자유롭게 가능</td>
</tr>
<tr>
<td>파라미터 바인딩</td>
<td>순서대로 자동</td>
<td><code>@Param</code>으로 이름 바인딩</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h2 id="💡-이름-기반-파라미터-바인딩">💡 이름 기반 파라미터 바인딩</h2>
<p><code>@Query</code>에서는 <code>@Param</code>으로 이름 바인딩을 쓴다. 위치 바인딩(<code>?1</code>)보다 명시적이고 안전하다.</p>
<pre><code class="language-java">@Query(&quot;select m from Member m where m.username = :name&quot;)
Member findByName(@Param(&quot;name&quot;) String username);</code></pre>
<p>파라미터가 많아져도 어디에 어떤 값이 들어가는지 명확하다. 위치 바인딩은 쿼리를 수정하다가 순서가 꼬이면 잘못된 결과를 반환할 수 있어서 피하는 게 좋다.</p>
<p>&nbsp;</p>
<h3 id="🧯-애플리케이션-로딩-시점에-쿼리-파싱">🧯 애플리케이션 로딩 시점에 쿼리 파싱</h3>
<p>여기서 중요한 포인트는 <code>@Query</code>에 작성한 JPQL은 애플리케이션이 로딩되는 시점에 파싱된다는 점이다. 쿼리에 오타가 있으면 서버 자체가 뜨지 않는다. 런타임이 아니라 배포 전에 걸러낼 수 있다는 뜻이다. 사실상 컴파일 타임 체크에 가까운 안전장치라고 볼 수 있다.</p>
<p>&nbsp;</p>
<h1 id="3️⃣-query로-값과-dto-조회">3️⃣ @Query로 값과 DTO 조회</h1>
<p><code>@Query</code>는 엔티티뿐 아니라 단순 값이나 DTO로도 조회할 수 있다.</p>
<h2 id="📄-단순-값-조회">📄 단순 값 조회</h2>
<pre><code class="language-java">@Query(&quot;select m.username from Member m&quot;)
List&lt;String&gt; findUsernameList();</code></pre>
<p>반환 타입을 <code>List&lt;String&gt;</code>으로 지정하면 <code>username</code> 컬럼만 꺼내온다. 특정 필드 하나만 필요할 때 유용하다.</p>
<p>&nbsp;</p>
<h2 id="🧰-dto-조회">🧰 DTO 조회</h2>
<p>조회 결과를 바로 DTO로 받고 싶다면 JPQL의 <code>new</code> 명령어를 쓴다.</p>
<pre><code class="language-java">public record MemberDto(Long id, String username, String teamName) {}</code></pre>
<pre><code class="language-java">@Query(&quot;select new com.study.data_jpa.dto.MemberDto(m.id, m.username, t.name) &quot; +
       &quot;from Member m join m.team t&quot;)
List&lt;MemberDto&gt; findMemberDto();</code></pre>
<p>주의할 점이 몇 가지 있는데,</p>
<ul>
<li>DTO 전체 패키지 경로를 적어야 한다 (<code>com.study.data_jpa.dto.MemberDto</code>).</li>
<li>DTO에는 생성자가 있어야 한다 (Java <code>record</code>는 자동으로 생성자가 생기므로 그대로 쓸 수 있다).</li>
<li>파라미터 순서가 생성자 시그니처와 정확히 일치해야 한다.</li>
</ul>
<p>이렇게 하면 엔티티를 전부 가져온 뒤 매핑하는 비용 없이, 처음부터 DTO 형태로 조회할 수 있다.</p>
<p>&nbsp;</p>
<h1 id="4️⃣-반환-타입">4️⃣ 반환 타입</h1>
<p>Spring Data JPA는 리포지토리 메서드의 반환 타입을 유연하게 지원한다. 상황에 맞게 골라 쓰면 된다.</p>
<pre><code class="language-java">List&lt;Member&gt; findListByUsername(String username);  // 컬렉션
Member findMemberByUsername(String username);  // 단건
Optional&lt;Member&gt; findOptionalByUsername(String username);  // 단건 Optional</code></pre>
<p>&nbsp;</p>
<h2 id="⚠️-결과가-없을-때의-동작이-다르다는-점에-유의하자">⚠️ 결과가 없을 때의 동작이 다르다는 점에 유의하자</h2>
<table>
<thead>
<tr>
<th>반환 타입</th>
<th>결과 없음</th>
<th>결과 2개 이상</th>
</tr>
</thead>
<tbody><tr>
<td><code>List&lt;T&gt;</code></td>
<td><strong>빈 리스트 반환</strong> (null 아님)</td>
<td>모두 반환</td>
</tr>
<tr>
<td><code>T</code> (단건)</td>
<td><strong>null 반환</strong></td>
<td><code>NonUniqueResultException</code></td>
</tr>
<tr>
<td><code>Optional&lt;T&gt;</code></td>
<td><code>Optional.empty()</code></td>
<td><code>NonUniqueResultException</code></td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h2 id="🫧-컬렉션은-절대-null이-아니다">🫧 컬렉션은 절대 null이 아니다</h2>
<p>Spring Data JPA가 리턴 전에 빈 컬렉션으로 감싸준다. 그래서 아래 코드는 항상 안전하다고 볼 수 있다.</p>
<pre><code class="language-java">List&lt;Member&gt; members = memberRepository.findListByUsername(&quot;ghost&quot;);
if (members.isEmpty()) {
    // null 체크 불필요
}</code></pre>
<p>반대로 단건 반환(<code>Member</code>)은 결과가 없으면 <code>null</code>이 나온다. 쓰기 전에 반드시 null 체크를 하거나 <code>Optional</code> 반환을 사용하는 것을 권장한다. 호출하는 쪽에서 조회 결과가 없을 수도 있다는 걸 타입 시그니처만 봐도 알 수 있기 때문이다.</p>
<pre><code class="language-java">memberRepository.findOptionalByUsername(username)
    .map(Member::getAge)
    .orElse(0);</code></pre>
<p>null 체크로 지저분해지는 코드를 위와 같이 함수형 체이닝으로 깔끔하게 처리할 수 있다.</p>
<p>&nbsp;</p>
<h1 id="5️⃣-페이징과-정렬">5️⃣ 페이징과 정렬</h1>
<h2 id="💥-순수-jpa의-페이징">💥 순수 JPA의 페이징</h2>
<pre><code class="language-java">public List&lt;Member&gt; findByPage(int age, int offset, int limit) {
    return em.createQuery(&quot;select m from Member m where m.age = :age order by m.username desc&quot;)
            .setParameter(&quot;age&quot;, age)
            .setFirstResult(offset)
            .setMaxResults(limit)
            .getResultList();
}

public long totalCount(int age) {
    return em.createQuery(&quot;select count(m) from Member m where m.age = :age&quot;, Long.class)
            .setParameter(&quot;age&quot;, age)
            .getSingleResult();
}</code></pre>
<p>페이징 쿼리와 <code>totalCount</code> 쿼리를 따로 작성해야 한다. 그리고 호출하는 쪽에서 <code>offset</code>, <code>limit</code>을 매번 계산해서 넘겨야 한다. 전형적인 반복 코드다.</p>
<p>&nbsp;</p>
<h2 id="✨-spring-data-jpa의-페이징">✨ Spring Data JPA의 페이징</h2>
<p>Spring Data JPA는 <code>Pageable</code>과 <code>Page</code>로 이걸 자동화한다.</p>
<pre><code class="language-java">Page&lt;Member&gt; findByAge(int age, Pageable pageable);</code></pre>
<p>&nbsp;</p>
<p>호출부는 아래와 같이 생겼다.</p>
<pre><code class="language-java">PageRequest pageRequest = PageRequest.of(0, 2, Sort.by(Direction.DESC, &quot;username&quot;));
Page&lt;Member&gt; result = memberRepository.findByAge(10, pageRequest);

List&lt;Member&gt; content = result.getContent();  // 조회된 데이터
long total = result.getTotalElements();  // 전체 개수
int pageNumber = result.getNumber();  // 현재 페이지
int totalPages = result.getTotalPages();  // 전체 페이지 수
boolean isFirst = result.isFirst();  // 첫 페이지?
boolean hasNext = result.hasNext();  // 다음 페이지 있음?</code></pre>
<p>여기서 재미있는 부분은 <code>Page&lt;T&gt;</code>를 반환하면 Spring Data JPA가 <code>totalCount</code> 쿼리를 자동으로 날려준다는 점이다. 개발자가 신경 쓸 필요가 없다.</p>
<p>&nbsp;</p>
<h2 id="📚-page-vs-slice">📚 Page vs Slice</h2>
<table>
<thead>
<tr>
<th>타입</th>
<th>totalCount 쿼리</th>
<th>사용처</th>
</tr>
</thead>
<tbody><tr>
<td><code>Page&lt;T&gt;</code></td>
<td>날림 (전체 페이지 수 계산)</td>
<td>전통적인 페이지네이션 (1, 2, 3... UI)</td>
</tr>
<tr>
<td><code>Slice&lt;T&gt;</code></td>
<td>날리지 않음 (hasNext만)</td>
<td>무한 스크롤 (다음 데이터 유무만 필요)</td>
</tr>
</tbody></table>
<p><code>Slice</code>는 다음 페이지가 있는지만 알면 되기 때문에 <code>limit + 1</code>개를 조회하는 전략을 쓴다. <code>totalCount</code> 쿼리가 비싼 상황(수백만 행 테이블)에서는 <code>Slice</code>가 훨씬 가볍다. 모바일 피드처럼 무한 스크롤이 많은 UI에서는 거의 Slice를 쓴다고 봐도 된다.</p>
<p>&nbsp;</p>
<h2 id="🎯-count-쿼리-분리-성능-최적화">🎯 Count 쿼리 분리 (성능 최적화)</h2>
<pre><code class="language-java">@Query(value = &quot;select m from Member m left join m.team t&quot;,
       countQuery = &quot;select count(m.username) from Member m&quot;)
Page&lt;Member&gt; findByAge(int age, Pageable pageable);</code></pre>
<p>기본 동작에서는 Spring Data JPA가 메인 쿼리를 기반으로 count 쿼리를 자동 생성한다. 그런데 메인 쿼리에 조인이 포함되어 있으면 count 쿼리에도 조인이 그대로 들어가서 성능이 떨어질 수 있다. <code>count</code>에는 조인이 필요 없는데도 말이다.</p>
<p><code>countQuery</code> 옵션으로 count 쿼리를 따로 분리하면 조인 없이 가벼운 count만 날릴 수 있다. 데이터가 많아질수록 체감 차이가 크다.</p>
<p>&nbsp;</p>
<h2 id="🔁-dto-매핑">🔁 DTO 매핑</h2>
<p>Page 객체는 엔티티를 그대로 외부에 노출하지 않도록 DTO로 변환해야 한다. <code>.map()</code>을 쓰면 페이지 메타데이터(현재 페이지, 전체 페이지 수 등)는 유지하면서 컨텐츠만 바꿀 수 있다.</p>
<pre><code class="language-java">Page&lt;MemberDto&gt; dtoPage = pageByAge.map(m -&gt; new MemberDto(m.getId(), m.getUsername(), null));</code></pre>
<p>컨트롤러에서 Page 그대로 반환하면 JSON에 페이징 메타데이터까지 함께 응답으로 나간다.</p>
<p>&nbsp;</p>
<h1 id="6️⃣-벌크성-수정-쿼리">6️⃣ 벌크성 수정 쿼리</h1>
<p>여러 행을 한 번에 수정하는 쿼리를 <strong>벌크 연산</strong>이라고 한다. *&quot;20세 이상 모든 회원의 나이를 +1&quot;* 같은 작업이다.</p>
<h2 id="💥-순수-jpa의-벌크-수정">💥 순수 JPA의 벌크 수정</h2>
<pre><code class="language-java">public int bulkAgePlus(int age) {
    return em.createQuery(&quot;update Member m set m.age = m.age + 1 where m.age &gt;= :age&quot;)
            .setParameter(&quot;age&quot;, age)
            .executeUpdate();
}</code></pre>
<p><code>executeUpdate()</code>가 영향받은 행의 수를 반환한다. 변경 감지(dirty checking)로는 처리할 수 없는 일괄 UPDATE/DELETE를 이때 쓴다.</p>
<p>&nbsp;</p>
<h2 id="✨-spring-data-jpa의-modifying">✨ Spring Data JPA의 @Modifying</h2>
<pre><code class="language-java">@Modifying
@Query(&quot;update Member m set m.age = m.age + 1 where m.age &gt;= :age&quot;)
int bulkAgePlus(@Param(&quot;age&quot;) int age);</code></pre>
<p><code>@Query</code>에 수정/삭제 쿼리를 쓸 때는 반드시 <code>@Modifying</code>을 함께 붙여야 한다. 안 붙이면 Spring Data JPA가 조회 쿼리로 간주하고 <code>getResultList()</code>를 호출하다가 예외가 터진다.</p>
<p>&nbsp;</p>
<h2 id="🚨-영속성-컨텍스트와의-불일치">🚨 영속성 컨텍스트와의 불일치</h2>
<p>벌크 연산의 핵심 함정이다. 벌크 쿼리는 영속성 컨텍스트를 거치지 않고 DB에 직접 반영된다. 그래서 영속성 컨텍스트에 올려둔 엔티티는 DB와 상태가 어긋나게 된다.</p>
<pre><code class="language-java">// member5의 나이는 25살로 저장되고 영속성 컨텍스트에 올라감
memberRepository.save(new Member(&quot;member5&quot;, 25));

// 20살 이상 모든 회원 +1 → DB에서 member5는 26살이 됨
memberRepository.bulkAgePlus(20);

// 영속성 컨텍스트에서 꺼낸 member5의 나이는?
Member member5 = memberRepository.findMemberByUsername(&quot;member5&quot;);
System.out.println(member5.getAge()); // 😱 25</code></pre>
<p><strong>벌크 쿼리는 DB를 바로 때렸지만 영속성 컨텍스트에는 반영되지 않았다.</strong> 1차 캐시에 올려둔 <code>member5</code>는 여전히 25살로 남아 있고, <code>findMemberByUsername</code>은 캐시에서 그 엔티티를 그대로 반환한다. DB와 메모리 상태가 어긋나는 전형적인 버그 상황이다.</p>
<p>&nbsp;</p>
<h2 id="🧯-해결-modifyingclearautomatically--true">🧯 해결: @Modifying(clearAutomatically = true)</h2>
<pre><code class="language-java">@Modifying(clearAutomatically = true)
@Query(&quot;update Member m set m.age = m.age + 1 where m.age &gt;= :age&quot;)
int bulkAgePlus(@Param(&quot;age&quot;) int age);</code></pre>
<p><code>clearAutomatically = true</code>를 주면 벌크 쿼리 실행 후 영속성 컨텍스트를 자동으로 clear해준다. 그 뒤에 조회하면 1차 캐시에 엔티티가 없으므로 DB에서 다시 가져오고, 상태가 일치한다.</p>
<p>수동으로 처리하면 이렇다.</p>
<pre><code class="language-java">int count = memberRepository.bulkAgePlus(20);
em.flush();
em.clear();  // 영속성 컨텍스트 비우기
Member member5 = memberRepository.findMemberByUsername(&quot;member5&quot;);  // 이제 26</code></pre>
<p><strong>실무 팁:</strong> 벌크 연산 뒤에 같은 트랜잭션에서 엔티티를 다시 쓸 일이 있다면 반드시 <code>clearAutomatically = true</code>를 쓰거나 직접 <code>clear</code>해야 한다. 벌크 연산은 성능상 매력적이지만 영속성 컨텍스트와 격리된다는 걸 늘 염두에 둬야 한다.</p>
<p>&nbsp;</p>
<h1 id="📝-정리">📝 정리</h1>
<table>
<thead>
<tr>
<th>주제</th>
<th>핵심 포인트</th>
</tr>
</thead>
<tbody><tr>
<td>메서드 이름 쿼리</td>
<td>선언만으로 자동 생성, 조건 많으면 가독성 하락, 로딩 시점 검증</td>
</tr>
<tr>
<td>@Query</td>
<td>JPQL 직접 작성, 애플리케이션 로딩 시점에 파싱 → 배포 전 오타 발견</td>
</tr>
<tr>
<td>값/DTO 조회</td>
<td><code>select new 패키지.DTO(...)</code> 문법으로 DTO 직접 반환</td>
</tr>
<tr>
<td>반환 타입</td>
<td><code>List</code>는 null 안남, 단건은 null 가능 → <code>Optional</code> 권장</td>
</tr>
<tr>
<td>페이징</td>
<td><code>Pageable</code>  • <code>Page/Slice</code>, <code>countQuery</code>로 count 쿼리 분리 가능</td>
</tr>
<tr>
<td>벌크 연산</td>
<td>DB 직접 반영 → 영속성 컨텍스트 불일치 → <code>clearAutomatically = true</code></td>
</tr>
</tbody></table>
<p>이처럼 쿼리 메서드는 Spring Data JPA의 꽃이라고 할 만한 영역이다. 메서드 이름 하나로 간단한 쿼리는 끝내고, 복잡해지면 <code>@Query</code>로 넘어가고, 결과 양에 따라 반환 타입을 바꾸고, 페이징과 벌크까지 다 처리할 수 있다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[도메인 모델 설계부터 공통 인터페이스까지]]></title>
            <link>https://velog.io/@rocker_nun/%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8-%EC%84%A4%EA%B3%84%EB%B6%80%ED%84%B0-%EA%B3%B5%ED%86%B5-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@rocker_nun/%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8-%EC%84%A4%EA%B3%84%EB%B6%80%ED%84%B0-%EA%B3%B5%ED%86%B5-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Mon, 28 Sep 2026 01:27:55 GMT</pubDate>
            <description><![CDATA[<h1 id="🏗-예제-도메인-모델">🏗 예제 도메인 모델</h1>
<p>순수 JPA로 리포지토리를 직접 구현해보고, Spring Data JPA의 공통 인터페이스가 어떻게 그 반복을 없애주는지, 내부적으로 어떻게 동작하는지 알아보도록 하자.</p>
<h2 id="⛓️-member와-team의-관계">⛓️ Member와 Team의 관계</h2>
<p>예제에서 사용할 도메인은 단순하다. 회원(<code>Member</code>)은 하나의 팀(<code>Team</code>)에 소속될 수 있고, 팀은 여러 회원을 가질 수 있다. 즉, 다대일(N:1) 관계다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/0dee346c-6100-472b-bf85-47c6bc29fddc/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>엔티티</th>
<th>필드</th>
</tr>
</thead>
<tbody><tr>
<td>Member</td>
<td>id (PK), username, age, team (FK)</td>
</tr>
<tr>
<td>Team</td>
<td>id (PK), name, members</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h3 id="🙋🏻♂️-member-엔티티">🙋🏻‍♂️ Member 엔티티</h3>
<pre><code class="language-java">@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {&quot;id&quot;, &quot;username&quot;, &quot;age&quot;})
public class Member {

    @Id @GeneratedValue
    @Column(name = &quot;member_id&quot;)
    private Long id;
    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;team_id&quot;)
    private Team team;

    public Member(String username, int age) { ... }
    public Member(String username, int age, Team team) { ... }

    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}</code></pre>
<p>&nbsp;</p>
<h3 id="👫-team-엔티티">👫 Team 엔티티</h3>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {&quot;id&quot;, &quot;name&quot;})
public class Team {

    @Id @GeneratedValue
    @Column(name = &quot;team_id&quot;)
    private Long id;
    private String name;

    @OneToMany(mappedBy = &quot;team&quot;)
    List&lt;Member&gt; members = new ArrayList&lt;&gt;();
}</code></pre>
<p>&nbsp;</p>
<h2 id="❗️-엔티티-설계-시-챙겨야-할-것들">❗️ 엔티티 설계 시 챙겨야 할 것들</h2>
<ul>
<li><p><strong><code>@NoArgsConstructor(access = AccessLevel.PROTECTED)</code></strong>: JPA는 내부적으로 리플렉션을 사용해 기본 생성자를 호출한다. 완전히 막을 수는 없지만 <code>PROTECTED</code>로 설정해두면 외부에서 <code>new Member()</code>로 직접 생성하는 실수를 막을 수 있다.</p>
</li>
<li><p><strong><code>@ToString</code>에는 연관관계 필드 넣지 않기:</strong> <code>team</code>을 <code>@ToString</code>에 포함시키면 <code>Member → Team → Member → ...</code> 무한 루프로 <code>StackOverflowError</code>가 난다. 반드시 기본 타입 필드만 포함해야 한다.</p>
</li>
<li><p><strong><code>@Setter</code>는 실무에서 지양:</strong> Setter를 열어두면 어디서든 엔티티 상태를 바꿀 수 있어 추적이 어려워진다. 상태 변경이 필요하다면 <code>changeTeam()</code>처럼 의도가 명확한 메서드를 따로 만드는 게 좋다. 지금은 학습의 편의를 위해 임시로 사용했다.</p>
</li>
<li><p><strong>연관관계 편의 메서드:</strong> 양방향 관계에서 한쪽만 설정하면 반대편이 동기화되지 않는다. <code>changeTeam()</code>에서 <code>this.team = team</code>과 <code>team.getMembers().add(this)</code>를 함께 처리해두면 실수를 줄일 수 있다. 모든 연관관계는 지연 로딩(<code>LAZY</code>)으로 설정하는 게 기본이다. <code>EAGER</code>는 예상치 못한 쿼리가 발생하기 쉽고, 특히 JPQL에서 N+1 문제의 원인이 된다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="💥-순수-jpa-리포지토리의-문제점">💥 순수 JPA 리포지토리의 문제점</h2>
<p>Spring Data JPA를 쓰기 전에, 순수 JPA로 리포지토리를 직접 구현해보자. <code>EntityManager</code>를 사용해 기본 CRUD를 작성하면 아래와 같다.</p>
<pre><code class="language-java">@Repository
public class MemberJpaRepository {

    @PersistenceContext
    private EntityManager em;

    public Member save(Member member) {
        em.persist(member);
        return member;
    }

    public void delete(Member member) {
        em.remove(member);
    }

    public List&lt;Member&gt; findAll() {
        return em.createQuery(&quot;select m from Member m&quot;, Member.class)
                 .getResultList();
    }

    public Optional&lt;Member&gt; findById(Long id) {
        Member member = em.find(Member.class, id);
        return Optional.ofNullable(member);
    }

    public long count() {
        return em.createQuery(&quot;select count(m) from Member m&quot;, Long.class)
                 .getSingleResult();
    }
}</code></pre>
<p>잘 동작한다. 근데 팀 리포지토리도 만들어야 한다. 그럼 <code>TeamJpaRepository</code>에도 <code>save</code>, <code>delete</code>, <code>findAll</code>, <code>findById</code>, <code>count</code>... 똑같은 코드가 반복될 것이다. 엔티티가 늘어날수록 이 반복은 계속된다. <strong>CRUD 코드가 엔티티마다 그대로 복붙된다</strong>는 게 핵심 문제다.</p>
<p>&nbsp;</p>
<h2 id="✨-spring-data-jpa-공통-인터페이스">✨ Spring Data JPA 공통 인터페이스</h2>
<p>Spring Data JPA는 이런 반복을 단 한 줄로 해결해준다.</p>
<pre><code class="language-java">// 구현체를 따로 만들 필요가 없음
public interface MemberRepository extends JpaRepository&lt;Member, Long&gt; {
}</code></pre>
<p><code>JpaRepository&lt;Member, Long&gt;</code>을 상속하는 것만으로 위에서 직접 구현했던 <code>save</code>, <code>delete</code>, <code>findAll</code>, <code>findById</code>, <code>count</code>가 모두 제공된다. </p>
<p>&nbsp;</p>
<h3 id="🤔-어떻게-동작하는-걸까">🤔 어떻게 동작하는 걸까?</h3>
<p>애플리케이션이 로딩될 때 Spring Data JPA가 <code>JpaRepository</code>를 상속한 인터페이스들을 스캔한다. 그리고 각 인터페이스에 대한 <strong>프록시 구현체를 자동으로 생성</strong>해서 스프링 컨테이너에 빈으로 등록해준다.</p>
<pre><code>1. 애플리케이션 로딩
2. Spring Data JPA가 JpaRepository 상속 인터페이스 스캔
3. 각 인터페이스에 대한 프록시 구현체 자동 생성
4. 스프링 컨테이너에 빈으로 등록
5. @Autowired로 주입받아 사용</code></pre><p>&nbsp;</p>
<p>실제로 주입된 객체를 출력해보면 직접 만든 클래스가 아니라 Spring Data JPA가 만든 <strong>프록시 객체</strong>임을 확인할 수 있다.</p>
<pre><code class="language-java">System.out.println(memberRepository.getClass());
// class com.sun.proxy.$ProxyXXX</code></pre>
<p>&nbsp;</p>
<h3 id="😁-repository-애노테이션-생략-가능">😁 @Repository 애노테이션 생략 가능</h3>
<p>Spring Data JPA 리포지토리는 <code>@Repository</code>를 붙이지 않아도 동작한다. 컴포넌트 스캔과 <strong>JPA 예외 → Spring 공통 예외 변환</strong>을 Spring Data JPA가 자동으로 처리해주기 때문이다.</p>
<p>자동 스캔 범위는 <code>@SpringBootApplication</code>이 선언된 패키지와 그 하위 패키지다. 리포지토리가 다른 패키지에 있다면 <code>@EnableJpaRepositories(basePackages = &quot;...&quot;)</code>를 직접 지정해야 한다.</p>
<p>&nbsp;</p>
<h2 id="🔍-jpa-영속성-컨텍스트와-테스트">🔍 JPA 영속성 컨텍스트와 테스트</h2>
<p>Spring Data JPA를 사용하다 보면 테스트에서 자주 막히는 지점이 있다. JPA의 영속성 컨텍스트 개념을 모르면 이유를 알 수 없는 실패들이다.</p>
<h3 id="👥-1차-캐시와-엔티티-동일성">👥 1차 캐시와 엔티티 동일성</h3>
<p>같은 트랜잭션 안에서 동일한 ID로 조회하면 DB가 아닌 <strong>1차 캐시</strong>에서 같은 인스턴스를 반환한다.</p>
<pre><code class="language-java">Member a = memberRepository.findById(1L).get();
Member b = memberRepository.findById(1L).get();
assertThat(a).isEqualTo(b); // true — DB 쿼리는 1번만 실행</code></pre>
<p>JPA는 동일 트랜잭션 내에서 <strong>같은 PK로 조회한 엔티티는 항상 같은 인스턴스</strong>임을 보장한다(<code>a == b</code>). 별도로 <code>equals()</code>를 구현하지 않아도 이 보장 덕분에 <code>isEqualTo()</code> 테스트가 통과한다.</p>
<p>&nbsp;</p>
<h3 id="🫣-transactional이-없으면-테스트가-실패하는-이유">🫣 @Transactional이 없으면 테스트가 실패하는 이유</h3>
<p><code>@Transactional</code>이 없으면 <code>save()</code>와 <code>findById()</code>가 <strong>서로 다른 트랜잭션</strong>으로 실행된다. <code>findById()</code> 호출 시점에 이전 영속성 컨텍스트는 이미 닫혀 있고, DB에서 <strong>새 인스턴스</strong>를 만들어 반환한다. <code>Member</code>에 <code>equals()</code>가 없으면 <code>Object.equals()</code>(참조 비교)로 fallback되어 테스트가 실패한다.</p>
<pre><code class="language-java">// @Transactional이 없으면 실패
@Test
void test() {
    Member saved = memberRepository.save(new Member(&quot;kim&quot;, 20));
    Member found = memberRepository.findById(saved.getId()).orElseThrow();
    assertThat(found).isEqualTo(saved); // 다른 인스턴스 → 실패
}</code></pre>
<p>테스트 클래스에 <code>@Transactional</code>을 붙이면 각 테스트가 하나의 트랜잭션 안에서 실행되고, 끝나면 자동으로 롤백된다.</p>
<p>&nbsp;</p>
<h3 id="🚨-변경-감지-dirty-checking">🚨 변경 감지 (Dirty Checking)</h3>
<p>JPA에서 <strong>수정은 별도의 update 메서드가 필요 없다.</strong> 트랜잭션 안에서 엔티티 필드를 직접 변경하면, 트랜잭션 커밋 시점에 자동으로 UPDATE SQL이 실행된다.</p>
<pre><code class="language-java">@Transactional
void update(Long id, String newName) {
    Member member = memberRepository.findById(id).orElseThrow();
    member.changeUsername(newName); // 이것만으로 UPDATE 실행됨
    // memberRepository.save(member) 불필요
}</code></pre>
<p>동작 원리는 아래와 같다.</p>
<ol>
<li>엔티티 조회 시 원본 <strong>스냅샷</strong> 저장</li>
<li>트랜잭션 커밋 직전 <code>flush()</code> 자동 호출</li>
<li>현재 상태와 스냅샷 비교</li>
<li>달라진 필드가 있으면 UPDATE SQL 실행</li>
</ol>
<p>&nbsp;</p>
<h3 id="🎭-flush-vs-clear">🎭 flush() vs clear()</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>동작</th>
</tr>
</thead>
<tbody><tr>
<td><code>em.flush()</code></td>
<td>변경 내용을 DB에 반영 (트랜잭션은 유지)</td>
</tr>
<tr>
<td><code>em.clear()</code></td>
<td>1차 캐시 초기화 → 이후 조회 시 DB에서 다시 로딩</td>
</tr>
</tbody></table>
<p>테스트에서 DB 반영 결과를 캐시 없이 검증하고 싶을 때 <code>flush() + clear()</code>를 함께 쓴다.</p>
<pre><code class="language-java">em.flush();  // DB에 반영
em.clear();  // 1차 캐시 비우기
Member fresh = memberRepository.findById(id).get(); // DB에서 새로 조회</code></pre>
<p>&nbsp;</p>
<h2 id="📐-jparepository-계층-구조">📐 JpaRepository 계층 구조</h2>
<p><code>JpaRepository</code>가 제공하는 기능들이 어떻게 구성되어 있는지 계층 구조를 보면 이해하기 쉽다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/5443651e-578d-4b28-a9f1-f7b52a4746cf/image.png" alt=""></p>
<pre><code>&lt;&lt;스프링 데이터&gt;&gt;
Repository ← 마커 인터페이스 (기능 없음)
└── CrudRepository ← save, findById, delete, count 등 기본 CRUD
    └── PagingAndSortingRepository ← findAll(Sort), findAll(Pageable)
        │
&lt;&lt;스프링 데이터 JPA&gt;&gt;
        └── JpaRepository ← JPA 특화 기능 추가 (flush, deleteInBatch 등)</code></pre><p><code>Repository</code>부터 <code>PagingAndSortingRepository</code>까지는 <strong>Spring Data Commons</strong>에 속한다. JPA 말고 MongoDB, Redis 등 다른 저장소에서도 똑같은 인터페이스를 쓸 수 있도록 추상화되어 있다. <code>JpaRepository</code>는 거기에 JPA에 특화된 기능을 얹은 것이다.</p>
<p>&nbsp;</p>
<h3 id="🖋️-제네릭-타입">🖋️ 제네릭 타입</h3>
<pre><code class="language-java">JpaRepository&lt;T, ID&gt;</code></pre>
<table>
<thead>
<tr>
<th>타입 파라미터</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>T</code></td>
<td>엔티티 타입</td>
</tr>
<tr>
<td><code>ID</code></td>
<td>식별자(PK) 타입</td>
</tr>
<tr>
<td><code>S</code></td>
<td>엔티티와 그 자식 타입 (save 등에서 사용)</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h3 id="📖-주요-메서드">📖 주요 메서드</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>save(S)</code></td>
<td>새 엔티티면 <code>persist</code>, 이미 있으면 <code>merge</code></td>
</tr>
<tr>
<td><code>findById(ID)</code></td>
<td><code>Optional&lt;T&gt;</code> 반환. 내부적으로 <code>em.find()</code> 호출</td>
</tr>
<tr>
<td><code>getReferenceById(ID)</code></td>
<td><strong>프록시</strong> 반환. 실제 필드 접근 시에 SELECT 실행. <code>em.getReference()</code> 호출</td>
</tr>
<tr>
<td><code>findAll(…)</code></td>
<td>전체 조회. <code>Sort</code>나 <code>Pageable</code> 파라미터 가능</td>
</tr>
<tr>
<td><code>delete(T)</code></td>
<td>내부적으로 <code>em.remove()</code> 호출</td>
</tr>
<tr>
<td><code>count()</code></td>
<td>전체 개수 반환</td>
</tr>
<tr>
<td><code>existsById(ID)</code></td>
<td>존재 여부 확인</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h3 id="🎏-findbyid-vs-getreferencebyid">🎏 findById vs getReferenceById</h3>
<p>연관관계를 설정할 때 굳이 엔티티 전체를 조회할 필요가 없다. ID만 필요한 경우 <code>getReferenceById()</code>를 쓰면 SELECT 없이 프록시만 받아올 수 있다.</p>
<pre><code class="language-java">// findById: SELECT 즉시 실행
Member member = memberRepository.findById(memberId).orElseThrow();

// getReferenceById: SELECT 없음. 연관관계 설정 등 ID만 필요한 경우에 유용
Member memberRef = memberRepository.getReferenceById(memberId);
order.setMember(memberRef); // 여기서도 SELECT 안 함</code></pre>
<p>&nbsp;</p>
<h3 id="😂-공통-인터페이스의-한계">😂 공통 인터페이스의 한계</h3>
<p><code>JpaRepository</code>는 모든 엔티티에 공통으로 적용할 수 있는 기능만 제공한다. <code>username</code>으로 회원을 찾는 것처럼 <strong>도메인에 특화된 쿼리는 제공하지 않는다.</strong></p>
<p>이걸 보완하는 게 <strong>쿼리 메서드</strong>다. 메서드 이름 규칙만 지키면 Spring Data JPA가 자동으로 쿼리를 만들어준다.</p>
<pre><code class="language-java">// 선언만 해도 동작
List&lt;Member&gt; findByUsernameAndAgeGreaterThan(String username, int age);</code></pre>
<p>&nbsp;</p>
<h2 id="📝-정리">📝 정리</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>핵심 포인트</th>
</tr>
</thead>
<tbody><tr>
<td>도메인 모델</td>
<td>연관관계는 LAZY, @ToString에 연관관계 필드 제외, 편의 메서드로 양방향 동기화</td>
</tr>
<tr>
<td>순수 JPA</td>
<td>EntityManager로 직접 구현 — 엔티티마다 CRUD 코드 반복</td>
</tr>
<tr>
<td>Spring Data JPA</td>
<td>인터페이스 선언만으로 프록시 구현체 자동 생성, @Repository 생략 가능</td>
</tr>
<tr>
<td>영속성 컨텍스트</td>
<td>1차 캐시로 동일 트랜잭션 내 엔티티 동일성 보장, 변경 감지로 update 불필요</td>
</tr>
<tr>
<td>테스트</td>
<td>@Transactional 없으면 엔티티 동일성 테스트 실패</td>
</tr>
<tr>
<td>JpaRepository</td>
<td>Repository → CrudRepository → PagingAndSortingRepository → JpaRepository 계층 구조</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[온라인 쇼핑몰 - 개념적 모델링]]></title>
            <link>https://velog.io/@rocker_nun/%EC%98%A8%EB%9D%BC%EC%9D%B8-%EC%87%BC%ED%95%91%EB%AA%B0-%EA%B0%9C%EB%85%90%EC%A0%81-%EB%AA%A8%EB%8D%B8%EB%A7%81</link>
            <guid>https://velog.io/@rocker_nun/%EC%98%A8%EB%9D%BC%EC%9D%B8-%EC%87%BC%ED%95%91%EB%AA%B0-%EA%B0%9C%EB%85%90%EC%A0%81-%EB%AA%A8%EB%8D%B8%EB%A7%81</guid>
            <pubDate>Fri, 18 Sep 2026 05:44:40 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-실전-요구-사항-분석">📝 실전 요구 사항 분석</h1>
<p>오프라인에서 대박을 친 스타트업의 온라인 쇼핑몰을 개발하는 일에 투입됐다고 가정해보자. 당연히 요구 사항은 어마어마하게 많을 것이다. 중요한 것은 모든 요구 사항을 일정 안에 한 번에 반영하는 것이 아니라, 지금 <strong>서비스에 당장 필요한 기능이 무엇인지에 대해 집중</strong>해야 한다. 단순히 기술 구현에만 매몰되지 않고, 비즈니스의 성공이라는 큰 그림을 봐야 한다.</p>
<p>이렇게 핵심 요구 사항을 정의하는 과정을 <strong>MVP(Minimum Viable Product)</strong>를 정의한다고 한다. 논의 끝에 아래와 같은 1차 오픈 스펙이 정해졌다고 가정해보자.</p>
<ul>
<li>회원: 고객이 가입하고 자신의 정보를 관리할 수 있어야 한다.</li>
<li>상품: 우리 서비스가 판매할 상품을 등록하고 관리할 수 있어야 한다.</li>
<li>주문: 회원이 상품을 구매할 수 있어야 한다.</li>
<li>결제: 주문에 대한 결제 정보를 기록하고 관리할 수 있어야 한다.</li>
<li>배송: 결제가 완료된 주문의 배송 상태를 관리할 수 있어야 한다.</li>
</ul>
<p>&nbsp;</p>
<h2 id="🔍-핵심-엔티티-도출">🔍 핵심 엔티티 도출</h2>
<p>이제 위 기능 명세서에서 핵심 엔티티를 도출해야 한다. <strong>엔티티(Entity)</strong>는 저장할 만한 가치를 지닌 정보를 여러 개 가지고 있으면서, 다른 것과는 명확히 구분되는 유무형의 모든 것이다. 특정 대상이 엔티티인지 아닌지 판단하기 위해서는 해당 대상에 <strong><em>“우리 시스템이 저장하고 관리해야 할 대상인가?”</em></strong> 라고 질문을 던져보자. 보통 명사를 뽑아내면 된다.</p>
<ul>
<li>회원(Member): 서비스를 사용하는 고객</li>
<li>상품(Product): 판매의 대상이 되는 물건</li>
<li>주문(Order): 회원의 구매 활동 결과</li>
<li>결제(Payment): 주문에 대한 지불 정보</li>
<li>배송(Delivery): 주문된 상품의 물리적 이동에 대한 정보</li>
</ul>
<p>여기서 <em>“주문”</em> 과 <em>“배송”</em> 을 별도의 엔티티로 분리한 것에 주목해야 한다. <em>“주문”</em> 은 <em>“결제”</em> 까지 포함하는 비즈니스 트랜잭션의 단위이고, <em>“배송”</em> 은 <em>“물류”</em> 라는 별개의 프로세스다. 이렇게 역할을 분리하면 나중에 <em>“주문은 완료되었지만 배송은 시작 전”</em> 과 같은 다양한 상태를 명확하게 관리할 수 있고, 각자의 책임이 분명해져 시스템을 유지보수하기 쉬워진다.</p>
<p>&nbsp;</p>
<h2 id="⛓️-속성과-관계-설정">⛓️ 속성과 관계 설정</h2>
<p>이제 각 엔티티가 어떤 구체적인 데이터 항목들을 가져야 하는지, 엔티티 사이에 어떤 관계로 연결되어 있는지를 요구 사항을 기반으로 정의해야 한다.</p>
<ul>
<li>회원(Member): 회원 id, 로그인 id, 비밀번호, 회원명, 이메일, 주소</li>
<li>상품(Product): 상품 id, 상품명, 상품 가격, 재고 수량</li>
<li>주문(Order): 주문 id, 주문 상태, 배송지 주소, 주문 일시</li>
<li>결제(Payment): 결제 id, 결제 수단, 결제 금액, 결제 상태, 결제 일시</li>
<li>배송(Delivery): 배송 id, 배송 상태, 운송장 번호</li>
</ul>
<ul>
<li>회원 &amp; 주문: 한 명의 회원은 주문을 여러 번 할 수 있다. (1 : N 관계)</li>
<li>주문 &amp; 결제: 하나의 주문은 하나의 결제로 이루어진다. (1 : 1 관계)</li>
<li>주문 &amp; 배송: 하나의 주문은 하나의 배송으로 이루어진다. (1 : 1 관계)</li>
<li>주문 &amp; 상품: 하나의 주문은 여러 개의 상품으로 이루어질 수 있고, 상품도 여러 주문에 속해 있을 수  있다. (M : N 관계)</li>
</ul>
<p>&nbsp;</p>
<h2 id="🤔-연관-엔티티로-다대다-관계-해소">🤔 연관 엔티티로 다대다 관계 해소</h2>
<p>여기서 눈에 띄는 관계가 있는데, 바로 주문과 상품이 다대다 관계로 연결되어 있다는 점이다. 상황에 따라 이렇게 다대다 관계로 연결되어 있는 엔티티들은 몇 가지 문제를 일으킬 수 있다. </p>
<h3 id="🚫-문제-1-물리적으로-구현할-수-없다">🚫 문제 1: 물리적으로 구현할 수 없다.</h3>
<blockquote>
<p><strong><em>“하나의 주문에는 여러 상품이 포함될 수 있고, 하나의 상품은 여러 주문에 포함될 수 있다.”</em></strong></p>
</blockquote>
<p>말만 들어보면, 아주 타당하다. 하지만 이걸 실제 데이터베이스의 테이블 구조로 옮길 수 없다. 관계형 DB는 테이블이라는 2차원 표 형태를 사용하며, 관계는 한 테이블의 기본 키(Primary Key)를 다른 테이블의 외래 키(Foreign Key)로 포함시켜 표현한다. </p>
<p>이 방식을 다대다 관계에 그대로 적용해보도록 하자. <strong>네이트(회원 id: 1)</strong>가 <strong>주문 1001번</strong>으로 <strong>상품 101(키보드)</strong>과 <strong>상품 102(마우스)</strong>를 주문했다고 가정해보자.</p>
<h4 id="시도-1-주문-테이블에-상품-id-컬럼-추가하기">시도 1: 주문 테이블에 상품 id 컬럼 추가하기</h4>
<table>
<thead>
<tr>
<th>주문 id (PK)</th>
<th>회원 id</th>
<th>주문 일시</th>
<th>상품 id (FK?)</th>
</tr>
</thead>
<tbody><tr>
<td>1001</td>
<td>1</td>
<td>2026-09-17</td>
<td>101</td>
</tr>
<tr>
<td>???</td>
<td>1</td>
<td>2026-09-17</td>
<td>102</td>
</tr>
</tbody></table>
<p>보다시피 주문 id가 1001인 주문에 마우스를 담을 수가 없다. 주문 id를 1002로 해서 새로운 주문을 생성해버리면 그건 더 이상 같은 주문이 아니게 되므로 의미가 없다. 이 구조는 하나의 주문에 단 하나의 상품만 가질 수 있기 때문에 여러 상품을 주문한 경우를 표현할 수 없다.</p>
<h4 id="시도-2-컬럼-계속-추가하기">시도 2: 컬럼 계속 추가하기</h4>
<table>
<thead>
<tr>
<th>주문 id (PK)</th>
<th>회원 id</th>
<th>주문 일시</th>
<th>상품1 id</th>
<th>상품2 id</th>
<th>상품3 id</th>
</tr>
</thead>
<tbody><tr>
<td>1001</td>
<td>1</td>
<td>2026-09-15</td>
<td>101</td>
<td>102</td>
<td>NULL</td>
</tr>
<tr>
<td>1002</td>
<td>3</td>
<td>2026-09-16</td>
<td>103</td>
<td>NULL</td>
<td>NULL</td>
</tr>
<tr>
<td>1003</td>
<td>1</td>
<td>2026-09-17</td>
<td>102</td>
<td>103</td>
<td>104</td>
</tr>
</tbody></table>
<p>대충 봐도 상황이 더 심각해졌다. 만약 어떤 고객이 상품을 4개 이상 주문한다면 개발자가 일일이 DDL을 실행해야 한다. 그리고 대부분의 주문에 어마어마하게 많은 상품들이 포함되지는 않을 텐데, 컬럼을 미리 많이 만들어두면 저장 공간이 상당 부분 낭비될 것이다. 마지막으로 특정 상품이 포함된 주문들을 모두 찾고 싶은 경우에는 WHERE 절에 모든 상품 id 컬럼을 작성해야 한다. </p>
<h4 id="시도-3-한-컬럼에-여러-값을-저장하도록-처리하기">시도 3: 한 컬럼에 여러 값을 저장하도록 처리하기</h4>
<table>
<thead>
<tr>
<th>주문 id (PK)</th>
<th>회원 id</th>
<th>주문 일시</th>
<th>상품 ids</th>
</tr>
</thead>
<tbody><tr>
<td>1001</td>
<td>1</td>
<td>2026-09-15</td>
<td>“101, 102”</td>
</tr>
<tr>
<td>1002</td>
<td>2</td>
<td>2026-09-17</td>
<td>“101, 102, 103”</td>
</tr>
</tbody></table>
<p>결국 최악의 실수를 저질러버리고 말았다. 이 방식은 관계형 DB의 근간을 흔드는 최악의 설계다.</p>
<p>만약 마우스가 포함된 모든 주문을 찾기 위해서는 매번 복잡하고 비효율적인 텍스트 검색을 해야 하고, 데이터를 수정하거나 삭제해야 할 경우에는 문자열을 긁어와서 파싱한 후에 다시 저장해야 한다. 그리고 무엇보다 이 설계는 데이터베이스의 제 1정규형, <strong><em>“테이블의 모든 칸은 원자적인 값 하나만 가져야 한다”</em></strong> 는 대원칙을 위반한다.</p>
<p>&nbsp;</p>
<h3 id="🚫-문제-2-관계에-속한-데이터를-저장할-장소가-없다">🚫 문제 2: 관계에 속한 데이터를 저장할 장소가 없다.</h3>
<p>주문과 상품 엔티티의 관계를 잘 들여다보면, 주문이 발생할 그 순간에만 의미를 갖는 데이터들이 존재한다. 현재 엔티티들로는 이 데이터들을 저장할 장소가 없다는 것이 문제다. 주문이 발생한 이후에 상품 정보가 일부 수정되었다고 가정해보자. </p>
<h4 id="주문-전-상품-테이블">&lt;주문 전 상품 테이블&gt;</h4>
<table>
<thead>
<tr>
<th>상품 id (PK)</th>
<th>상품명</th>
<th>가격(원)</th>
<th>재고 수량(개)</th>
</tr>
</thead>
<tbody><tr>
<td>101</td>
<td>기계식 키보드</td>
<td>120,000</td>
<td>50</td>
</tr>
<tr>
<td>102</td>
<td>무소음 마우스</td>
<td>45,000</td>
<td>100</td>
</tr>
</tbody></table>
<h4 id="주문-후-상품-테이블">&lt;주문 후 상품 테이블&gt;</h4>
<table>
<thead>
<tr>
<th>상품 id (PK)</th>
<th>상품명</th>
<th>가격(원)</th>
<th>재고 수량(개)</th>
</tr>
</thead>
<tbody><tr>
<td>101</td>
<td>기계식 키보드</td>
<td>150,000</td>
<td>20</td>
</tr>
<tr>
<td>102</td>
<td>무소음 마우스</td>
<td>40,000</td>
<td>120</td>
</tr>
</tbody></table>
<p>만약 고객이 기계식 키보드와 무소음 마우스를 각각 1개씩 구매했는데, 주문 이후에 가격이 변경되었다면 주문 당시의 주문 수량과 주문 가격은 어디에 저장해야 할까? 주문 테이블? 상품 테이블? 근데 생각해보면, 주문 테이블에는 변경 이전과 이후의 가격 중 어떤 가격을 컬럼에 넣어야 할지도 모르고, 상품 테이블에 넣자니 만약 상품이 없어졌다면…? 둘 다 말이 안 된다. 이렇게 주문 수량이나 주문 당시 가격은 주문이나 상품에 속한 속성이 아니라는 느낌이 강하게 온다. 이들은 주문과 상품이 관계를 맺음으로써 발생하는 속성이다.</p>
<p>&nbsp;</p>
<h3 id="👍-해결책-관계를-엔티티로-승격하기">👍 해결책: 관계를 엔티티로 승격하기</h3>
<p>해결책은 다대다 관계를 <strong>연관 엔티티(Associative Entity)</strong>로 바꾸는 것이다. 두 엔티티의 관계를 나타내는 새로운 엔티티를 만들어, 기존의 다대다 관계를 2개의 일대다 관계로 풀어내는 방식이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/26d50faa-aeb1-4ec5-a461-f5132a4e23ff/image.png" alt=""></p>
<p>여기서 <strong>주문 항목(order_item)</strong>이라는 새로운 연관 엔티티를 만드는 것이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/fe333c88-9bf2-4114-954b-e7585f2e2674/image.png" alt=""></p>
<p>이런 식으로 다대다 관계를 일대다(주문 &amp; 주문 항목), 다대일(주문 항목 &amp; 상품) 관계로 풀어내면 아래와 같이 깔끔하게 설명이 가능하다.</p>
<ul>
<li>하나의 주문에는 최소 하나 이상의 주문 항목이 포함, 하나의 주문 항목은 특정한 주문 한 건에 포함된다.</li>
<li>하나의 주문 항목은 반드시 하나의 상품을 가져야 하고, 하나의 상품은 여러 번 주문될 수도, 아직 한 번도 주문되지 않을 수도 있다.</li>
</ul>
<p>이처럼 다대다 관계를 발견하면 단순히 연관 엔티티를 설계하는 것을 넘어, 해당 관계가 만들어지는 순간에만 의미를 갖는 데이터는 무엇인지를 적극적으로 질문하고 찾아내야 한다.</p>
<p>&nbsp;</p>
<h1 id="🖌️-erd-작성">🖌️ ERD 작성</h1>
<p>지금까지 분석한 MVP 기능을 바탕으로 개념적 모델 ERD를 그려보면 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7fce00e7-0d00-49fd-8973-8d623bd92f7c/image.png" alt=""></p>
<h1 id="📖-용어-사전-작성">📖 용어 사전 작성</h1>
<p>프로젝트의 모든 이해 관계자들은 모두 같은 비즈니스 용어를 사용하는 것이 중요하다. 그렇지 않으면 시스템 구조를 파악하는 데 엄청난 시간과 비용이 발생하고, 유지보수하는 것도 힘들다. 이런 문제를 방지하기 위해 프로젝트 초기에 모든 구성원이 합의하여 비즈니스 용어와 실제 물리적인 데이터 이름을 매핑해두면, 모두가 일관된 용어를 사용하여 소통하고 개발할 수 있다. 이는 사소해 보이지만, 장기적으로는 개발 생산성과 시스템의 안정성을 극대화하는 매우 중요한 활동이다.</p>
<p>용어를 <strong>역할별로</strong> <strong>분류</strong>하고, <strong>약어와</strong> <strong>전체</strong> <strong>영문명을</strong> <strong>명확히</strong> <strong>구분</strong>하며, <strong>실제</strong> <strong>시스템에서</strong> <strong>어떻게</strong> <strong>사용되는지</strong> 구체적인 예시를 함께 기록하는 것이 좋다. 회원(member), 가격(price)처럼 가장 작은 단위의 단일어를 중심으로 정의하고, 이를 조합해서 작성해보도록 하자.</p>
<table>
<thead>
<tr>
<th>분류</th>
<th>명칭</th>
<th>전체 영문명</th>
<th>축약어</th>
<th>설명</th>
<th>관련 시스템 요소</th>
</tr>
</thead>
<tbody><tr>
<td><strong>엔티티</strong></td>
<td>회원</td>
<td>member</td>
<td></td>
<td>서비스를 사용하는 고객</td>
<td><code>member</code> 테이블</td>
</tr>
<tr>
<td></td>
<td>상품</td>
<td>product</td>
<td></td>
<td>판매하는 물건 또는 서비스</td>
<td><code>product</code> 테이블</td>
</tr>
<tr>
<td></td>
<td>주문</td>
<td>order</td>
<td></td>
<td>회원의 상품 구매 요청 행위</td>
<td><code>orders</code> 테이블</td>
</tr>
<tr>
<td></td>
<td>주문 항목</td>
<td>order_item</td>
<td></td>
<td>하나의 주문에 포함된 개별 상품 정보 (연관 엔티티)</td>
<td><code>order_item</code> 테이블</td>
</tr>
<tr>
<td></td>
<td>결제</td>
<td>payment</td>
<td>pay</td>
<td>주문에 대한 지불 정보</td>
<td><code>payment</code> 테이블</td>
</tr>
<tr>
<td></td>
<td>배송</td>
<td>delivery</td>
<td></td>
<td>주문된 상품의 물리적 이동 정보</td>
<td><code>delivery</code> 테이블</td>
</tr>
<tr>
<td><strong>주요 속성</strong></td>
<td>식별자</td>
<td>identifier</td>
<td>id</td>
<td>데이터를 고유하게 식별하는 번호</td>
<td><code>member_id</code>, <code>product_id</code></td>
</tr>
<tr>
<td></td>
<td>이름, 명</td>
<td>name</td>
<td></td>
<td>사람, 상품 등 대상을 지칭하는 명칭</td>
<td><code>member_name</code>, <code>product_name</code></td>
</tr>
<tr>
<td></td>
<td>가격</td>
<td>price</td>
<td></td>
<td>상품의 현재 판매가 또는 주문 시점의 가격</td>
<td><code>order_price</code></td>
</tr>
<tr>
<td></td>
<td>금액</td>
<td>amount</td>
<td></td>
<td>금액</td>
<td></td>
</tr>
<tr>
<td></td>
<td>재고</td>
<td>stock</td>
<td></td>
<td>상품의 재고</td>
<td><code>stock_quantity</code></td>
</tr>
<tr>
<td></td>
<td>수량</td>
<td>quantity</td>
<td></td>
<td>개수나 양</td>
<td><code>stock_quantity</code>, <code>order_quantity</code></td>
</tr>
<tr>
<td></td>
<td>개수, 횟수</td>
<td>count</td>
<td></td>
<td>개별 항목을 하나씩 세는 행위, 이벤트 발생 횟수</td>
<td><code>visit_count</code>, <code>click_count</code>, <code>employee_count</code></td>
</tr>
<tr>
<td></td>
<td>상태</td>
<td>status</td>
<td></td>
<td>주문, 배송 등 엔티티의 현재 상태를 나타내는 값</td>
<td><code>order_status</code>, <code>delivery_status</code></td>
</tr>
<tr>
<td></td>
<td>주소</td>
<td>address</td>
<td>addr</td>
<td>위치 정보</td>
<td><code>member.addr</code>, <code>order.ship_addr</code></td>
</tr>
<tr>
<td></td>
<td>비밀번호</td>
<td>password</td>
<td></td>
<td>로그인 시 사용하는 비밀번호</td>
<td><code>member.password</code></td>
</tr>
<tr>
<td></td>
<td>배송</td>
<td>shipping</td>
<td>ship</td>
<td>배송과 관련된 속성을 나타낼 때 접두사로 사용</td>
<td><code>ship_addr</code></td>
</tr>
<tr>
<td></td>
<td>로그인</td>
<td>login</td>
<td></td>
<td>시스템에 접속하는 행위</td>
<td><code>member.login_id</code></td>
</tr>
<tr>
<td></td>
<td>수단</td>
<td>method</td>
<td></td>
<td>결제 수단</td>
<td><code>payment_method</code></td>
</tr>
<tr>
<td></td>
<td>번호</td>
<td>number</td>
<td>no</td>
<td>번호</td>
<td><code>tracking_no</code></td>
</tr>
<tr>
<td></td>
<td>운송장 번호</td>
<td>tracking_no</td>
<td></td>
<td>배송 추적 번호</td>
<td></td>
</tr>
<tr>
<td><strong>행위 / 수식어</strong></td>
<td>생성</td>
<td>create</td>
<td></td>
<td>데이터가 만들어진 시점을 나타낼 때 사용</td>
<td><code>created_at</code></td>
</tr>
<tr>
<td></td>
<td>수정</td>
<td>update</td>
<td></td>
<td>데이터가 변경된 시점을 나타낼 때 사용</td>
<td><code>updated_at</code></td>
</tr>
</tbody></table>
<p>용어 사전은 한 번 만들고 끝나는 문서가 아니다. 프로젝트가 진행되면서 새로운 용어가 추가되고 새로운 비즈니스 용어가 생겨날 때마다 <strong>지속적으로</strong> <strong>업데이트</strong>되어야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[최종 프로젝트 인프라 2차 구축]]></title>
            <link>https://velog.io/@rocker_nun/%EC%B5%9C%EC%A2%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B8%ED%94%84%EB%9D%BC-2%EC%B0%A8-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@rocker_nun/%EC%B5%9C%EC%A2%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B8%ED%94%84%EB%9D%BC-2%EC%B0%A8-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Mon, 24 Aug 2026 04:31:11 GMT</pubDate>
            <description><![CDATA[<h1 id="🔄-dockerfile부터-cd-자동-배포까지">🔄 Dockerfile부터 CD 자동 배포까지</h1>
<p>백엔드를 이미지로 만들어 서버에 자동으로 올립니다. <code>develop</code> 브랜치에 push하면 WAR 빌드 → 이미지 생성 → Docker Hub 업로드 → API, Batch 서버 배포 → 헬스체크까지 자동으로 돌아갑니다.</p>
<h2 id="📄-전체-흐름">📄 전체 흐름</h2>
<pre><code>1. 개발자가 Github에 push
2. Github Actions가 아래 작업을 진행
    a. WAR 파일 빌드(Maven)
    b. Docker 이미지 빌드
    c. Docker Hub에 push
3. (빌드 성공 시) AWS SSM으로 각 API 서버와 Batch 서버에 배포 명령 전송
    a. API 서버: pull 받은 후 docker compose up -d
    b. Batch 서버: pull 받은 후 docker compose up -d
4. 로드 밸런서가 API 서버로 트래픽 전달
5. 헬스체크 통과
6. 배포 완료</code></pre><p>API 서버와 Batch 서버는 완전히 같은 이미지를 사용합니다. 실행할 때 프로필(<code>api</code> / <code>batch</code>)만 다르게 줍니다. 그래서 이미지는 한 번만 만들면 되고, 두 서버의 버전이 어긋날 일이 없습니다.</p>
<p>&nbsp;</p>
<h2 id="🖋️-dockerfile-작성">🖋️ Dockerfile 작성</h2>
<p>가장 먼저 만든 건 <strong>Dockerfile</strong>입니다. 이게 있어야 이미지가 생기고, 이미지가 있어야 서버에 올릴 컨테이너가 뜹니다. 프로젝트 루트(<code>pom.xml</code>과 같은 위치)에 만들었습니다.</p>
<pre><code class="language-docker">FROM tomcat:9-jdk17-temurin

# 기본 웹앱 제거 (manager, examples 등 불필요 + 보안)
RUN rm -rf /usr/local/tomcat/webapps/*

# WAR를 ROOT로 배포 → 컨텍스트 경로가 &quot;/&quot;
COPY target/*.war /usr/local/tomcat/webapps/ROOT.war

ENV TZ=Asia/Seoul
EXPOSE 8080</code></pre>
<ul>
<li><p><code>FROM tomcat:9-jdk17-temurin</code>: 톰캣 9 버전과 JDK 17이 이미 세팅된 이미지에서 출발합니다. 톰캣을 직접 설치할 필요가 없습니다.</p>
</li>
<li><p><code>rm -rf .../webapps/*</code>: 톰캣 기본 웹앱(manager, examples 등)을 통째로 지웁니다. 사용하지 않기도 하고, 이런 기본 앱들이 열려 있으면 보안 취약점이 되기 때문입니다.</p>
</li>
<li><p><code>COPY target/*.war ... /ROOT.war</code>: 빌드된 WAR를 <code>ROOT.war</code>라는 이름으로 넣습니다. 톰캣은 <code>ROOT.war</code>를 컨텍스트 경로 <code>/</code>로 배포합니다. 즉 <code>/api/...</code>로 바로 접근됩니다.</p>
</li>
<li><p><code>ENV TZ=Asia/Seoul</code>: 컨테이너 시간대를 한국으로 설정해서 로그, 스케줄, DB 시각이 어긋나지 않게 합니다.</p>
</li>
<li><p><code>EXPOSE 8080</code>: 톰캣이 8080에서 듣는다는 표시입니다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h3 id="🚫-dockerignore-파일-작성">🚫 .dockerignore 파일 작성</h3>
<p>같은 위치에 <code>.dockerignore</code>를 만들어, 이미지에 들어갈 필요 없는 것들을 빌드 컨텍스트에서 제외했습니다.</p>
<pre><code>.git
.github
.idea
*.iml
sql/
docs/
.env
README.md</code></pre><p>특히 <code>.env</code>를 반드시 제외해야 합니다. 실수로 이미지 안에 비밀값이 구워지면 이미지를 받는 누구나 볼 수 있게 됩니다.</p>
<p>&nbsp;</p>
<h2 id="🔍-로컬-검증">🔍 로컬 검증</h2>
<p>여기가 가장 중요한 지점이었습니다. 이미지가 로컬에서 뜨는 걸 확인하지 않고 바로 Actions에 올리면, 실패했을 때 원인 찾기가 훨씬 어렵습니다. 대부분의 문제(WAR 경로, 톰캣 버전, 컨텍스트 경로)가 여기서 드러나기 때문에, 이 단계를 통과하면 나머지가 수월해집니다.</p>
<pre><code class="language-bash"># 1) WAR 빌드
./mvnw clean package -DskipTests
ls -lh target/*.war # WAR가 실제로 만들어졌는지 확인

# 2) 이미지 빌드
docker build -t nagasseum-backend:test .

# 3) 실행 (로컬 DB, Redis가 떠 있어야 함)
docker run --rm -p 8081:8080 \
  -e JAVA_OPTS=&quot;-Dspring.profiles.active=local,api&quot; \
  -e DB_URL=&quot;jdbc:mysql://host.docker.internal:3306/independence?serverTimezone=Asia/Seoul&quot; \
  -e REDIS_HOST=&quot;host.docker.internal&quot; \
  nagasseum-backend:test</code></pre>
<ul>
<li>포트를 <code>8081:8080</code>으로 매핑한 이유: IntelliJ에서 띄우는 톰캣이 8080을 쓰기 때문에, 로컬 테스트 컨테이너는 8081로 빼서 충돌을 피했습니다.</li>
<li><code>host.docker.internal</code>: 컨테이너 안에서 호스트 PC의 DB/Redis에 접근할 때 쓰는 주소입니다.</li>
</ul>
<pre><code class="language-bash">curl http://localhost:8081/api/v1/members/health</code></pre>
<p>요청을 해보면 이미지가 만들어지고 WAR가 <code>/</code>로 배포되고 있는 것을 확인할 수 있습니다.</p>
<p>&nbsp;</p>
<h2 id="📂-서버용-compose-파일-2개">📂 서버용 compose 파일 2개</h2>
<p>로컬에서 이미지가 검증됐으면, 이제 EC2에서 이 이미지를 어떻게 띄울지를 정의합니다. API 서버용과 Batch 서버용을 나눠 두 개를 만들었습니다.</p>
<h3 id="📃-docker-composeyml-api-2a-api-2c에-배치">📃 docker-compose.yml (api-2a, api-2c에 배치)</h3>
<pre><code class="language-yaml">services:
  api-was:
    image: ${DOCKER_USER}/nagasseum-backend:latest
    container_name: api-was
    restart: always
    ports:
      - &quot;8080:8080&quot;  # ALB가 접근할 포트
    environment:
      JAVA_OPTS: &quot;-Dspring.profiles.active=prod,api -Xmx512m&quot;
      DB_URL: ${DB_URL}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: ${REDIS_HOST}
      REDIS_PORT: 6379
      CODEF_CLIENT_ID: ${CODEF_CLIENT_ID}
      CODEF_CLIENT_SECRET: ${CODEF_CLIENT_SECRET}
      CODEF_PUBLIC_KEY: ${CODEF_PUBLIC_KEY}
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;</code></pre>
<p>&nbsp;</p>
<h3 id="📃-docker-composeyml-batch-2a에-배치">📃 docker-compose.yml (batch-2a에 배치)</h3>
<pre><code class="language-yaml">services:
  batch-was:
    image: ${DOCKER_USER}/nagasseum-backend:latest  # API와 완전히 같은 이미지
    container_name: batch-was
    restart: always
    # ports 없음 — 외부에서 접근할 일이 없음
    environment:
      JAVA_OPTS: &quot;-Dspring.profiles.active=prod,batch -Xmx512m&quot;
      DB_URL: ${DB_URL}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: ${REDIS_HOST}
      REDIS_PORT: 6379
      CODEF_CLIENT_ID: ${CODEF_CLIENT_ID}
      CODEF_CLIENT_SECRET: ${CODEF_CLIENT_SECRET}
      CODEF_PUBLIC_KEY: ${CODEF_PUBLIC_KEY}
      SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL}
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;</code></pre>
<p>두 파일의 차이는 딱 세 가지입니다.</p>
<ol>
<li><p><strong>프로필</strong>: <code>prod, api</code>과 <code>prod, batch</code>입니다. 같은 코드가 프로필에 따라 API 서버로도, 배치 실행기로도 동작합니다.</p>
</li>
<li><p><strong>포트 노출</strong>: API는 ALB가 붙어야 하니 <code>8080</code>을 열고, Batch는 외부에서 부를 일이 없어 포트를 안 엽니다.</p>
</li>
<li><p><strong>Slack 웹훅</strong>: 배치 작업 결과 알림용이라 Batch에만 있습니다.</p>
</li>
</ol>
<p>&nbsp;</p>
<h3 id="2️⃣-꼭-챙긴-두-가지-설정">2️⃣ 꼭 챙긴 두 가지 설정</h3>
<ul>
<li><p><code>logging</code> (json-file, max-size 10m, max-file 3): 이걸 안 걸면 도커 로그가 무한정 쌓여 몇 주 만에 디스크를 꽉 채웁니다. 실제로 아주 흔한 장애 원인이라, 최대 10MB * 3개로 순환하도록 제한했습니다.</p>
</li>
<li><p><code>Xmx512m</code> (힙 제한): 서버가 t3.small(메모리 2GB)입니다. JVM은 힙을 명시하지 않으면 호스트 메모리의 약 1/4을 기본값으로 잡는데, 그러면 메모리 예측이 어긋납니다. 512MB로 못 박아 안정적으로 만들었습니다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="👤-서버의-env">👤 서버의 .env</h2>
<p>compose 파일의 <code>${DB_URL}</code> 같은 변수들은 각 EC2의 <code>~/nagasseum/.env</code>에서 채워집니다. 이 파일은 git에 절대 올리지 않습니다.</p>
<pre><code class="language-bash">DOCKER_USER=&lt;도커허브_아이디&gt;
DB_URL=jdbc:mysql://&lt;RDS_엔드포인트&gt;:3306/independence?serverTimezone=Asia/Seoul&amp;characterEncoding=UTF-8
DB_USERNAME=admin
DB_PASSWORD=&lt;RDS_마스터_비밀번호&gt;
REDIS_HOST=&lt;ElastiCache_Primary_엔드포인트&gt;
REDIS_PORT=6379
CODEF_CLIENT_ID=...
CODEF_CLIENT_SECRET=...
CODEF_PUBLIC_KEY=...
SLACK_WEBHOOK_URL=...</code></pre>
<blockquote>
<p>위 값들은 예시로 마스킹했습니다. 실제 엔드포인트, 비밀번호는 팀 내부 <code>.env</code>(및 GitHub Secrets)에만 존재합니다.</p>
</blockquote>
<p>여기서 주의할 부분이 있습니다.</p>
<ul>
<li><p><code>REDIS_HOST</code>는 반드시 <strong>Primary 엔드포인트</strong>로 해야 합니다. ElastiCache에는 읽기 전용 Reader 엔드포인트(<code>ro</code>가 붙음)가 따로 있는데, 이걸 넣으면 쓰기 작업이 전부 실패합니다.</p>
</li>
<li><p><code>DB_URL</code>에 <strong><code>characterEncoding=UTF-8</code>을 포함</strong>해야 합니다. 로컬에서 겪었던 한글 깨짐이 RDS에서도 재현될 수 있어서 미리 넣었습니다.</p>
</li>
</ul>
<p>파일 권한도 소유자만 읽도록 제한했습니다.</p>
<pre><code class="language-bash">chmod 600 ~/nagasseum/.env</code></pre>
<p>&nbsp;</p>
<h2 id="⛓️-cd-파이프라인">⛓️ CD 파이프라인</h2>
<p>이제 지금까지 만든 것들(이미지, compose, <code>.env</code>)을 하나로 잇습니다. <code>.github/workflows/cd.yml</code>을 만들어 빌드부터 배포, 검증까지 자동화했습니다.</p>
<pre><code class="language-yaml">name: CD - Build, Push &amp; Deploy

on:
  push:
    branches: [ develop ]
  workflow_dispatch:  # 버튼으로 수동 실행도 가능

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: 소스 체크아웃
        uses: actions/checkout@v4

      - name: JDK 17 설정
        uses: actions/setup-java@v4
        with:
          java-version: &#39;17&#39;
          distribution: &#39;temurin&#39;
          cache: maven

      - name: WAR 빌드
        run: ./mvnw clean package -DskipTests

      - name: 빌드 결과 확인
        run: ls -lh target/*.war

      - name: Docker Hub 로그인
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Buildx 설정
        uses: docker/setup-buildx-action@v3

      - name: 이미지 빌드 &amp; push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64
          push: true
          tags: |
            ${{ secrets.DOCKERHUB_USERNAME }}/nagasseum-backend:latest
            ${{ secrets.DOCKERHUB_USERNAME }}/nagasseum-backend:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: AWS 자격증명 설정
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: API 서버 배포
        run: |
          aws ssm send-command \
            --targets &quot;Key=tag:Role,Values=api&quot; \
            --document-name &quot;AWS-RunShellScript&quot; \
            --comment &quot;deploy ${{ github.sha }}&quot; \
            --parameters &#39;commands=[&quot;cd /home/ssm-user/nagasseum &amp;&amp; docker compose pull &amp;&amp; docker compose up -d &amp;&amp; docker image prune -f&quot;]&#39;

      - name: Batch 서버 배포
        run: |
          aws ssm send-command \
            --targets &quot;Key=tag:Role,Values=batch&quot; \
            --document-name &quot;AWS-RunShellScript&quot; \
            --parameters &#39;commands=[&quot;cd /home/ssm-user/nagasseum &amp;&amp; docker compose pull &amp;&amp; docker compose up -d &amp;&amp; docker image prune -f&quot;]&#39;

      - name: 배포 결과 확인
        run: |
          sleep 60
          curl -f https://api.nagasseum.com/api/v1/members/health
          echo &quot;&quot;
          echo &quot;배포 성공&quot;</code></pre>
<p>&nbsp;</p>
<h3 id="🔨-build-and-push-job-이미지-만들어서-올리기">🔨 build-and-push job: 이미지 만들어서 올리기</h3>
<ul>
<li><p><code>setup-java</code> &amp; <code>cache: maven</code>: JDK 17을 깔고 Maven 의존성을 캐시합니다. 두 번째 실행부터 빌드가 빨라집니다.</p>
</li>
<li><p><code>ls -lh target/*.war</code>: WAR가 실제로 나왔는지 로그에 남겨, 문제가 생겼을 때 빌드 단계인지 이미지 단계인지 바로 구분되게 했습니다.</p>
</li>
<li><p><code>platforms: linux/amd64</code>: Actions 러너는 x86이라 대개 amd64가 나오지만, 이렇게 설정하면 ARM 이미지가 섞여 서버에서 실행이 안 되는 사고를 막습니다.</p>
</li>
<li><p>태그 2개 (<code>latest</code> + <code>${{ github.sha }}</code>): <code>latest</code>는 배포 명령을 단순하게 해주고, 커밋 SHA 태그는 해당 이미지가 정확히 어느 커밋인지를 남겨 롤백 및 추적에 씁니다.</p>
</li>
<li><p><code>cache-from/to: gha</code>: 이미지 레이어를 GitHub Actions 캐시에 저장해 빌드 시간을 줄입니다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h3 id="🛫-deploy-job-실제-서버에-반영">🛫 deploy job: 실제 서버에 반영</h3>
<ul>
<li><p><code>needs: build-and-push</code>: 빌드/푸시가 성공해야만 배포가 시작됩니다. 이미지가 없거나 깨진 상태로 서버를 건드리는 일을 원천 차단하는 안전장치입니다.</p>
</li>
<li><p>SSH가 아니라 SSM으로 배포: 이 선택이 핵심인데, 이유는 아래에서 따로 설명합니다.</p>
</li>
<li><p>배포 명령 <code>cd ... &amp;&amp; docker compose pull &amp;&amp; docker compose up -d &amp;&amp; docker image prune -f</code></p>
<ul>
<li><code>pull</code>: 방금 올린 최신 이미지를 서버로 내려받고</li>
<li><code>up -d</code>: 새 이미지로 컨테이너를 교체하고</li>
<li><code>image prune -f</code>: 교체되고 떠도는 옛 이미지를 정리해 디스크를 아낍니다.</li>
</ul>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="🤔-왜-ssh가-아니라-ssm인가">🤔 왜 SSH가 아니라 SSM인가</h2>
<p>배포 방식으로 SSH 대신 <strong>AWS SSM(Systems Manager)</strong> <code>send-command</code>를 골랐습니다. 이유는 세 가지입니다.</p>
<ul>
<li><p><strong>접속 키가 필요 없습니다.</strong> SSH라면 GitHub Actions에 서버 개인키를 넣어둬야 합니다. SSM은 IAM 권한으로 명령을 보내므로 키를 보관 및 유출할 위험이 없고, 서버의 22번 포트를 열어둘 필요도 없습니다.</p>
</li>
<li><p><strong>태그로 대상을 지정할 수 있습니다.</strong> <code>Key=tag:Role,Values=api</code>는 Role 태그가 api인 인스턴스 전부라는 뜻입니다. IP를 하드코딩하지 않으니, 서버를 교체하거나 늘려도 태그만 맞으면 워크플로 수정 없이 배포됩니다. 따라서 API 서버가 <code>api-2a</code>, <code>api-2c</code> 둘인데도 명령 한 번으로 둘 다 처리할 수 있습니다.</p>
</li>
<li><p><strong>실행 기록이 남는다.</strong> 보낸 명령과 그 출력이 Systems Manager 콘솔의 실행 기록(Run Command)에 남아, 서버에 들어가지 않고도 배포 실패 원인을 확인할 수 있습니다.</p>
</li>
</ul>
<p>여기서 또 주의할 점이 있었는데, SSM <code>send-command</code>의 <code>commands</code> 배열은 각 항목이 서로 독립된 셸에서 실행된다는 점이었습니다. 그래서 아래와 같이 나눠 쓰면 안 됩니다.</p>
<pre><code class="language-json">// 잘못된 예 - cd가 다음 줄에 적용되지 않는다
&#39;commands=[
  &quot;cd /home/ssm-user/nagasseum&quot;,
  &quot;docker compose pull&quot;,  // ← 홈 디렉터리에서 실행됨 → compose 파일 못 찾음
  &quot;docker compose up -d&quot;
]&#39;</code></pre>
<p><code>cd</code>를 따로 두면, 다음 명령은 <strong>이동이 사라진 새 셸</strong>에서 실행돼 compose 파일을 못 찾고 배포가 실패합니다. 그래서 <code>&amp;&amp;</code>로 <strong>한 줄에 이어 붙여</strong> 같은 셸에서 순서대로 돌게 했습니다.</p>
<pre><code class="language-json">// 올바른 예 — 하나의 셸에서 디렉터리가 유지된 채 순차 실행
&#39;commands=[&quot;cd /home/ssm-user/nagasseum &amp;&amp; docker compose pull &amp;&amp; docker compose up -d &amp;&amp; docker image prune -f&quot;]&#39;</code></pre>
<p>덤으로 <code>&amp;&amp;</code>는 앞 명령이 성공했을 때만 다음으로 넘어가므로, <code>pull</code>이 실패하면 <code>up -d</code>까지 가지 않고 멈추는 안전장치도 됩니다.</p>
<p>&nbsp;</p>
<h2 id="💊-헬스체크">💊 헬스체크</h2>
<pre><code class="language-yaml">- name: 배포 결과 확인
  run: |
    sleep 60
    curl -f https://api.nagasseum.com/api/v1/members/health
    echo &quot;&quot;
    echo &quot;배포 성공&quot;</code></pre>
<ul>
<li><p><code>sleep 60</code>: 애플리케이션이 완전히 기동할 시간을 줍니다. 뜨자마자 때리면 아직 준비 안 된 서버를 실패로 오판할 수 있습니다.</p>
</li>
<li><p><code>curl -f</code>: <code>f</code>(fail)가 핵심입니다. HTTP 4xx/5xx가 오면 curl이 실패 코드로 끝나고, 그러면 이 스텝도 실패합니다. 즉 헬스체크가 통과하지 못하면 파이프라인이 초록불이 되지 않습니다. 배포는 됐는데 서버가 죽어 있는 상태를 놓치지 않게 해줍니다.</p>
</li>
</ul>
<p>이 요청은 <code>https://api.nagasseum.com</code>(ALB) → API 서버 컨테이너로 흐르니, 헬스체크가 통과했다는 건 <strong>ALB, 서버, 컨테이너, DB 경로가 다 살아있다</strong>는 뜻이기도 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[최종 프로젝트 인프라 1차 구축]]></title>
            <link>https://velog.io/@rocker_nun/%EC%B5%9C%EC%A2%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B8%ED%94%84%EB%9D%BC-1%EC%B0%A8-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@rocker_nun/%EC%B5%9C%EC%A2%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B8%ED%94%84%EB%9D%BC-1%EC%B0%A8-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Wed, 19 Aug 2026 06:16:01 GMT</pubDate>
            <description><![CDATA[<h1 id="🗺️-vpc-생성">🗺️ VPC 생성</h1>
<p><strong>VPC(Virtual Private Cloud)</strong>는 AWS 안에 만드는 논리적으로 격리된 사설 네트워크입니다. 이 안의 리소스들은 하나의 네트워크에 속하고, 밖에서는 우리가 허용한 경로로만 접근할 수 있습니다. EC2나 RDS 같은 리소스는 반드시 어떤 VPC에 속해야 생성됩니다. 그리고 VPC 단위로 IP 대역을 갖기 때문에, 내부 통신이 사설 IP로 이루어져 인터넷을 거치지 않습니다.</p>
<h2 id="⚙️-나갔음청년-인프라-설정">⚙️ 나갔음청년 인프라 설정</h2>
<pre><code>이름: nagasseum-vpc
CIDR: 10.0.0.0/16 → 사용 가능 IP 약 65,536개
IPv6: 없음
테넌시: 기본값
DNS 호스트 이름: 활성화</code></pre><p>CIDR을 <code>/16</code>으로 잡은 이유는 서브넷을 여러 개 나눠야 하고, 나중에 확장할 여지를 두기 위함입니다. <code>/16</code>이면 <code>/24</code> 서브넷을 256개까지 만들 수 있어 충분합니다. 그리고 DNS 호스트 이름을 활성화했습니다. RDS와 ElastiCache는 접속 주소를 도메인 형태로 제공하는데(<code>nagasseum-db.xxxx.rds.amazonaws.com</code>), 이 옵션이 꺼져 있으면 EC2가 그 도메인을 IP로 변환하지 못해 DB 연결이 실패합니다. 나중에 원인을 찾기 어려운 문제라 처음에 켜두었습니다.</p>
<p>&nbsp;</p>
<h1 id="🚧-서브넷-6개-생성">🚧 서브넷 6개 생성</h1>
<p><strong>서브넷(Subnet)</strong>은 VPC의 IP 대역을 쪼갠 구획입니다. 리소스는 서브넷 단위로 배치되고, 서브넷마다 라우팅 규칙을 다르게 줄 수 있습니다. 서브넷을 6개 생성하기로 결정한 이유는 우리 인프라 구조를 3계층, 가용영역 2개로 결정했기 때문입니다. </p>
<p>여기서 계층을 나눈 것은 인터넷에서 직접 닿을 수 있는 범위를 최소화하기 위함입니다. 인프라 멘토링에서 *&quot;서버가 퍼블릭 서브넷에 있을 필요가 없다&quot;, &quot;DB를 서비스와 같이 띄우지 말라&quot;* 는 지적을 받은 데 대한 대응입니다. 가용영역을 2개로 나눈 것은 데이터센터 하나가 통째로 장애가 나도 서비스가 이어지도록 하기 위함입니다. 또한 ALB와 RDS 서브넷 그룹은 최소 2개 AZ를 요구하므로 기술적 필수 요건이기도 합니다.</p>
<h2 id="⚙️-서브넷-설정">⚙️ 서브넷 설정</h2>
<table>
<thead>
<tr>
<th>이름</th>
<th>AZ</th>
<th>CIDR</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>nagasseum-public-2a</td>
<td>2a</td>
<td>10.0.1.0/24</td>
<td>NAT, ALB</td>
</tr>
<tr>
<td>nagasseum-public-2c</td>
<td>2c</td>
<td>10.0.2.0/24</td>
<td>NAT, ALB</td>
</tr>
<tr>
<td>nagasseum-app-2a</td>
<td>2a</td>
<td>10.0.11.0/24</td>
<td>API #1, Batch</td>
</tr>
<tr>
<td>nagasseum-app-2c</td>
<td>2c</td>
<td>10.0.12.0/24</td>
<td>API #2</td>
</tr>
<tr>
<td>nagasseum-data-2a</td>
<td>2a</td>
<td>10.0.21.0/24</td>
<td>RDS, Redis</td>
</tr>
<tr>
<td>nagasseum-data-2c</td>
<td>2c</td>
<td>10.0.22.0/24</td>
<td>RDS 대기, Redis 복제본</td>
</tr>
</tbody></table>
<p>세 번째 옥텟을 1, 11, 21로 나눈 이유는 십의 자리로 계층을 구분하면 IP만 보고 어느 계층인지 즉시 알 수 있습니다. 실제로 검증 단계에서 RDS가 <code>10.0.22.241</code>로 해석되는 것을 보고 Data 계층에 제대로 들어갔다는 것을 바로 확인할 수 있었습니다. 각 계층에 9개까지 여유가 있어 확장에도 대비됩니다.</p>
<p>그리고 NAT 인스턴스가 공인 IP를 받아야 하기 때문에 퍼블릭 서브넷 2개에만 퍼블릭 IPv4 주소 자동 할당을 활성화했습니다. App, Data는 비활성화하여 공인 IP 자체가 없는 상태를 만들었습니다.</p>
<p>엄밀히 말하면 이 시점에는 6개 모두 사실상 프라이빗입니다. 이름만 퍼블릭일 뿐, 라우팅 테이블에 IGW 경로가 들어가야 비로소 퍼블릭이 됩니다.</p>
<p>&nbsp;</p>
<h1 id="📝-internet-gateway--라우팅-테이블-설정">📝 Internet Gateway &amp; 라우팅 테이블 설정</h1>
<p><strong>IGW(Internet Gateway)</strong>는 VPC와 인터넷을 잇는 관문입니다. VPC에 하나만 붙일 수 있고, 이게 없으면 어떤 리소스도 인터넷과 통신할 수 없습니다. <strong>라우팅 테이블(Routing Table)</strong>은 특정 목적지로 가는 트래픽은 어디로 보낼지를 정의한 이정표입니다. 서브넷마다 하나씩 연결되며, 여기 무엇이 적혀 있느냐가 그 서브넷의 성격을 결정합니다.</p>
<h2 id="⚙️-라우팅-테이블-설정">⚙️ 라우팅 테이블 설정</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/ca76377b-d588-494b-8baa-67234e21fdd2/image.png" alt=""></p>
<p><strong>퍼블릭 라우팅 테이블 (1개)</strong></p>
<pre><code>라우팅 테이블 이름: nagasseum-routing-table-public
10.0.0.0/16 → local (VPC 내부 통신)
0.0.0.0/0 → IGW ← 이것이 퍼블릭의 정의
연결: public-2a, public-2c</code></pre><p>&nbsp;</p>
<p><strong>프라이빗 라우팅 테이블 (2개, AZ별)</strong></p>
<pre><code>라우팅 테이블 이름: nagasseum-routing-table-private-2a
10.0.0.0/16 → local
0.0.0.0/0 → NAT-2a (아웃바운드 전용)
pl-xxxx(S3) → VPC Endpoint
연결: app-2a, data-2a

라우팅 테이블 이름: nagasseum-routing-table-private-2c
0.0.0.0/0 → NAT-2c
연결: app-2c, data-2c</code></pre><p>&nbsp;</p>
<p>프라이빗을 AZ별로 나눈 이유가 핵심입니다. 하나로 합치면 두 AZ가 같은 NAT를 쓰게 되어 AZ 간 데이터 전송 요금이 발생하고, 그 NAT가 있는 AZ가 죽으면 반대편 AZ까지 인터넷이 끊겨 이중화의 의미가 사라집니다.</p>
<p>검증 결과, api-2a는 공인 IP <code>54.180.xxx.xxx</code>, api-2c는 <code>52.79.xxx.xxx</code>로 나갔습니다. 서로 다른 IP라는 것이 각 AZ가 자기 NAT를 쓰고 있다는 증거입니다.</p>
<p>&nbsp;</p>
<h1 id="🚥-nat-instance-2대-생성">🚥 NAT Instance 2대 생성</h1>
<p><strong>NAT Instance</strong>는 프라이빗 서브넷의 리소스가 인터넷으로 나갈 수 있게 해주는 일종의 중계 장치입니다. 자기 공인 IP를 빌려주고, 응답이 오면 원래 요청자에게 돌려줍니다.</p>
<p>우리 백엔드는 공인 IP가 없어 인터넷과 직접 통신할 수 없습니다. 그런데 밖으로 나갈 요청은 많습니다.</p>
<pre><code>Batch 서버 → CODEF, 국토부 실거래가 API
API 서버 → 카카오 로그인, CODEF 수동 동기화
전체 → SSM 에이전트가 AWS 엔드포인트에 등록</code></pre><p>NAT는 안에서 밖으로 나가는 요청만 처리합니다. 밖에서 안으로 들어오는 것은 불가능합니다. 그래서 외부 API는 호출할 수 있지만, 외부에서 우리 백엔드로 접속할 수는 없기 때문에 보안을 유지될 수 있습니다.</p>
<h2 id="🤔-nat-gateway가-아닌-instance를-택한-이유">🤔 NAT Gateway가 아닌 Instance를 택한 이유</h2>
<table>
<thead>
<tr>
<th></th>
<th>NAT Gateway 2대</th>
<th>NAT Instance 2대</th>
</tr>
</thead>
<tbody><tr>
<td>월 비용</td>
<td>~$90</td>
<td><strong>~$8</strong></td>
</tr>
<tr>
<td>데이터 처리 요금</td>
<td>GB당 $0.059</td>
<td>없음</td>
</tr>
<tr>
<td>관리</td>
<td>완전 관리형</td>
<td>직접 설정, 복구</td>
</tr>
<tr>
<td>자동 복구</td>
<td>있음</td>
<td>없음</td>
</tr>
</tbody></table>
<p>NAT Instance로 전환하면 NAT Gateway를 사용할 때보다 약 월 80달러를 절감할 수 있었습니다. 우리 아웃바운드 트래픽은 배치의 외부 API 호출 정도로 적고, NAT가 죽어도 서비스 전체가 아니라 외부 호출만 막히므로 감수할 만한 트레이드오프라고 판단했습니다.</p>
<h2 id="⚙️-nat-인스턴스-설정">⚙️ NAT 인스턴스 설정</h2>
<pre><code>이름: nagasseum-nat-2a, nagasseum-nat-2c
AMI: Amazon Linux 2023 (ARM64)
인스턴스 유형: t4g.nano
서브넷: 각 AZ의 Public Subnet
퍼블릭 IP: 활성화
보안그룹: 모든 트래픽 ← 10.0.0.0/16 ← VPC 내부 요청을 받아야 함
소스/대상 확인 중지 </code></pre><p>소스/대상 확인 중지가 반드시 필요합니다. EC2는 기본적으로 출발지나 목적지가 자기 IP가 아닌 패킷을 폐기합니다. NAT는 남의 패킷을 대신 전달하는 역할이므로 이 검사를 꺼야 합니다. 이 설정을 빼먹으면 NAT는 동작하지 않습니다.</p>
<p><strong>OS 레벨 설정 2가지</strong></p>
<pre><code class="language-bash"># ① IP 포워딩 — 패킷을 다른 인터페이스로 넘길 수 있게
net.ipv4.ip_forward = 1

# ② MASQUERADE — 출발지 IP를 NAT 자신의 IP로 바꿔치기
iptables -t nat -A POSTROUTING -o ens5 -j MASQUERADE</code></pre>
<p>&nbsp;</p>
<h1 id="↩️-vpc-gateway-endpoint-s3">↩️ VPC Gateway Endpoint (S3)</h1>
<p><strong>VPC Gateway Endpoint</strong>는 VPC 안에서 특정 AWS 서비스로 가는 전용 통로를 여는 기능입니다. Gateway 유형은 S3와 DynamoDB만 지원하며 요금이 없습니다.</p>
<p>S3는 우리 VPC 안에 있는 리소스가 아니라 AWS가 운영하는 공용 서비스입니다. 주소도 인터넷 도메인(<code>s3.ap-northeast-2.amazonaws.com</code>)이라, 기본적으로는 NAT를 거쳐 인터넷으로 나가야 접근 가능합니다.</p>
<pre><code>[기존] 백업 서버 → NAT → 인터넷 → S3 (NAT 데이터 처리 요금 발생)
[변경] 백업 서버 → Gateway Endpoint → S3 (무료, AWS 내부망)</code></pre><p>비용 절감보다는 보안 이점이 큽니다. 우리 DB 백업 파일에는 회원 정보와 암호화된 Connected ID가 들어 있는데, 이 트래픽이 공용 인터넷을 거치지 않고 AWS 내부망에만 머뭅니다.</p>
<h2 id="⚙️-gateway-설정">⚙️ Gateway 설정</h2>
<pre><code>서비스: com.amazonaws.ap-northeast-2.s3 (Gateway 유형)
VPC: nagasseum-vpc
라우팅: private-2a, private-2c ← 프라이빗만</code></pre><p>생성하면 해당 라우팅 테이블에 <code>pl-</code>로 시작하는 prefix list 항목이 자동 추가됩니다. AWS가 S3의 IP 대역을 관리해주므로 우리가 IP를 직접 적을 필요가 없습니다.</p>
<p>&nbsp;</p>
<h1 id="🛡️-보안그룹-4종-추가">🛡️ 보안그룹 4종 추가</h1>
<p><strong>보안그룹(Security Group)</strong>은 인스턴스 단위의 가상 방화벽입니다. 어디서 오는 무슨 포트를 허용할지를 정의합니다. 상태 저장(stateful) 방식이라, 아웃바운드를 허용하면 그 응답은 자동으로 들어옵니다.</p>
<h2 id="⚙️-보안그룹-설정">⚙️ 보안그룹 설정</h2>
<pre><code>security-group-alb 인바운드: 80, 443 ← 0.0.0.0/0 (인터넷 전체)
security-group-app 인바운드: 8080 ← security-group-alb (ALB에서만)
security-group-rds 인바운드: 3306 ← security-group-app (App 계층에서만)
security-group-redis 인바운드: 6379 ← security-group-app (App 계층에서만)</code></pre><p>AWS 리소스에 IP 대역이 아니라 <strong>다른 보안그룹 ID</strong>를 지정했습니다. 이 방식의 이점이 세 가지입니다.</p>
<ol>
<li><p><strong>인스턴스 IP가 바뀌어도 규칙이 유효합니다.</strong> EC2를 재생성하거나 오토스케일링으로 늘려도 그 보안그룹에 속하기만 하면 접근이 허용됩니다.</p>
</li>
<li><p><strong>의도가 코드처럼 읽힙니다.</strong> *&quot;10.0.11.0/24에서 3306 허용&quot;* 보다 *&quot;App 계층에서 3306 허용&quot;* 이 명확합니다.</p>
</li>
<li><p><strong>우회가 구조적으로 불가능합니다.</strong> 누군가 프라이빗 서브넷에 인스턴스를 만들어도, <code>sg-app</code>을 붙이지 않으면 DB에 접근할 수 없습니다.</p>
</li>
</ol>
<p>만드는 순서에 주의가 필요했습니다. <code>sg-app</code>이 <code>sg-alb</code>를 참조하려면 <code>sg-alb</code>가 먼저 존재해야 하므로, <strong>빈 보안그룹 4개를 먼저 만들고 규칙을 나중에 채우는</strong> 방식을 썼습니다. 그리고 SSH(22)는 어디에도 열지 않았습니다. SSM Session Manager를 쓰기로 했기 때문입니다. 이는 CIS AWS Foundations Benchmark의 네트워킹 권고와도 일치합니다.</p>
<p>&nbsp;</p>
<h1 id="💾-rds-mysql-생성">💾 RDS (MySQL) 생성</h1>
<p>인프라 리뷰에서 *&quot;DB를 서비스와 같이 띄우지 마라, 장애의 흔한 원인&quot;* 이라는 지적을 받았습니다. RDS를 구축해서 관리형으로 전환한 근거는 세 가지입니다.</p>
<ul>
<li><p><strong>장애 격리:</strong> 백엔드와 같은 인스턴스에 있으면, 백엔드가 메모리를 다 쓸 때 DB도 죽고 재배포로 재시작하면 DB도 함께 내려갑니다. 한쪽 문제가 다른 쪽으로 전파되는 구조를 끊었습니다.</p>
</li>
<li><p><strong>복구 시간:</strong> 직접 운영하면 장애 시 사람이 인스턴스를 살리고 컨테이너를 다시 띄워야 합니다. 새벽에 죽으면 아침까지 서비스가 멈춥니다. Multi-AZ는 1~2분 안에 대기 인스턴스가 자동 승격합니다.</p>
</li>
<li><p><strong>백업과 복구 지점:</strong> 직접 하면 mysqldump 스크립트 / cron / S3 업로드를 다 만들어야 하고, 하루 한 번이면 최대 24시간치를 잃습니다. RDS는 자동 백업에 더해 특정 시점 복구(PITR)를 지원합니다.</p>
</li>
</ul>
<h2 id="⚙️-rds-설정">⚙️ RDS 설정</h2>
<pre><code>엔진: MySQL 8.0
인스턴스: db.t3.micro
가용성: 다중 AZ DB 인스턴스 ← 동기 복제
스토리지: gp3 20GB (자동 조정 비활성화)
서브넷 그룹: data-2a, data-2c
퍼블릭 액세스: 아니요 
보안그룹: security-group-rds
초기 DB 이름: independence
자동 백업: 7일</code></pre><p>퍼블릭 액세스를 *&quot;아니요&quot;* 로 한 것이 프라이빗 배치의 핵심입니다. 이 설정이 *&quot;예&quot;* 면 프라이빗 서브넷에 있어도 인터넷에서 접근 가능한 엔드포인트가 생깁니다.</p>
<p>초기 데이터베이스 이름을 반드시 입<strong>력</strong>해야 합니다. 비워두면 인스턴스만 생기고 데이터베이스가 없어서 나중에 직접 <code>CREATE DATABASE</code>를 해야 합니다.</p>
<pre><code>인스턴스 (Multi-AZ)   ~$36     ← Single-AZ 대비 2배
스토리지 (×2)          ~$5
백업                  ~$2
────────────────────────────
합계                  ~$43/월</code></pre><p>&nbsp;</p>
<h1 id="💽-elasticache-redis-생성">💽 ElastiCache (Redis) 생성</h1>
<p>API 서버가 2대로 늘면서 프로세스 간에 공유해야 하는 상태가 생겼습니다. 자바 메모리는 프로세스별로 격리되어 있어 쓸 수 없습니다.</p>
<ol>
<li><p><strong>CODEF 액세스 토큰:</strong> 액세스 토큰의 ****유효기간은 7일입니다. 서버마다 따로 발급받으면 낭비이고 발급 횟수 제한도 있습니다.</p>
</li>
<li><p><strong>분산 락:</strong> 자산 연동 시 Connected ID 중복 발급을 막습니다. <code>synchronized</code>는 한 JVM 안에서만 유효해 다른 서버의 요청을 막지 못합니다. 대체 불가능한 용도입니다.</p>
</li>
<li><p><strong>비동기 작업 상태:</strong> 자산 동기화를 비동기로 전환하면서, 클라이언트가 폴링으로 진행 상태를 조회합니다. ALB가 요청을 분산하므로 폴링이 다른 서버로 갈 수 있어 공유 저장소가 필수입니다.</p>
</li>
<li><p><strong>집계 캐시:</strong> 또래 비교 집계는 매월 1일 기준으로 고정된 값이라 재사용이 자연스럽습니다.</p>
</li>
</ol>
<h2 id="⚙️-elasticache-설정">⚙️ ElastiCache 설정</h2>
<pre><code>엔진: Redis 7.1
클러스터 모드: 비활성화 ← 단순 구성
노드 유형: cache.t3.micro
복제본: 1개
다중 AZ: 활성화 ← 자동 장애 조치
저장 중 암호화: 활성화 (기본 키)
전송 중 암호화: 비활성화 ← VPC 내부 통신
자동 백업: 비활성화 ← 캐시라 불필요
유지 관리: 일요일 21:00 UTC ← 새벽 배치와 회피</code></pre><p>클러스터 모드를 활성화하면 샤딩 구조가 되어 Lettuce 클라이언트 설정이 복잡해집니다. 우리 용도에는 단일 노드 + 복제본이면 충분합니다.</p>
<p>자동 백업 옵션은 비활성화했습니다. Redis에 저장하는 것은 토큰 / 락 / 캐시 / 작업상태로, 없어져도 다시 만들 수 있는 데이터입니다. 백업 스토리지 요금만 나가고 실익이 없습니다. Redis에서 복제본을 두는 이유는 데이터 보존이 아니라 <strong>가용성</strong>입니다. Redis가 죽으면 데이터를 잃는 게 아니라 기능이 멈춥니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/5efa9804-8cba-480d-9c4c-27dc5ac02498/image.png" alt=""></p>
<p>주의할 점은 엔드포인트가 Primary와 Reader 두 개라는 점입니다. 우리 용도는 전부 쓰기 작업이므로 <strong>반드시 Primary</strong>를 써야 합니다. Reader로 설정하면 <code>SET</code>, <code>SETNX</code>가 모두 실패합니다.</p>
<p>&nbsp;</p>
<h1 id="🦾-ec2-3대--iam-역할">🦾 EC2 3대 &amp; IAM 역할</h1>
<h2 id="⚙️-인스턴스-구성">⚙️ 인스턴스 구성</h2>
<table>
<thead>
<tr>
<th>이름</th>
<th>유형</th>
<th>서브넷</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>nagasseum-api-2a</td>
<td>t3.small</td>
<td>app-2a</td>
<td>Tomcat (api 프로필)</td>
</tr>
<tr>
<td>nagasseum-api-2c</td>
<td>t3.small</td>
<td>app-2c</td>
<td>Tomcat (api 프로필)</td>
</tr>
<tr>
<td>nagasseum-batch-2a</td>
<td>t3.micro</td>
<td>app-2a</td>
<td>Tomcat (batch 프로필)</td>
</tr>
</tbody></table>
<p>API가 t3.small인 이유는 3계층 전환으로 MySQL, Redis가 빠져 EC2가 가벼워졌지만, 톰캣 JVM이 550MB 정도를 쓰고 OS 및 Docker를 합치면 830MB입니다. t3.micro(1GB)는 여유가 170MB뿐이라 GC나 요청 집중 시 위험합니다. Batch가 t3.micro인 이유는 사용자 요청을 받지 않고 새벽에만 동작하므로 부하가 낮습니다.</p>
<p>세 대 모두 프라이빗 서브넷에 배치했기 때문에 퍼블릭 IP는 없습니다. </p>
<h2 id="🤔-ssm-session-manager를-택한-이유">🤔 SSM Session Manager를 택한 이유</h2>
<p>Bastion Host 대신 SSM을 사용했을 때의 이점에는 4가지 정도가 있었습니다.</p>
<ol>
<li><strong>SSH 22번을 아예 열지 않아도 됩니다.</strong> 공인 IP를 가진 서버에 SSH 포트가 열려 있으면 무차별 로그인 시도의 표적이 됩니다.</li>
<li><strong>키 관리가 사라집니다.</strong> 개인키 파일을 Bastion Host에 복사하면, Bastion Host 뚫렸을 때 프라이빗 서버까지 함께 뚫립니다.</li>
<li><strong>EC2 한 대 값이 절약됩니다.</strong> 베스천도 인스턴스라 요금이 나가지만 SSM은 무료입니다.</li>
<li><strong>접속 기록이 남습니다.</strong> IAM 권한으로 접근을 통제하고 CloudTrail에 로그가 남습니다.</li>
</ol>
<h2 id="👤-iam-역할">👤 IAM 역할</h2>
<pre><code>역할명: nagasseum-ec2-ssm-role
정책: AmazonSSMManagedInstanceCore (SSM 접속), AmazonS3FullAccess (백업용, 추후 축소 예정)
신뢰 관계: ec2.amazonaws.com</code></pre><p><code>AmazonS3FullAccess</code>는 권한 범위가 넓어 정석은 아닙니다. 백업 버킷만 접근하는 커스텀 정책으로 좁히는 것이 맞으며, 개선 예정입니다.</p>
<p>&nbsp;</p>
<h1 id="💥-트러블슈팅-기록">💥 트러블슈팅 기록</h1>
<h2 id="🚫-ssm-접속-불가-→-iam-역할-미연결">🚫 SSM 접속 불가 → IAM 역할 미연결</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/74306191-efbf-498c-979e-ae2dd08bdbf7/image.png" alt=""></p>
<pre><code>SSM Agent unable to acquire credentials
Systems Manager&#39;s instance management role is not configured</code></pre><p>원인은 인스턴스 생성 시 IAM 인스턴스 프로파일을 지정하지 않아 발생하는 문제였습니다. SSM 에이전트는 IAM 역할로 AWS API에 인증하는데, 역할이 없으니 자격증명을 얻지 못했습니다.</p>
<p>따라서 <code>작업 → 보안 → IAM 역할 수정</code>으로 역할을 연결하고 인스턴스를 재부팅해서 해결했습니다. 에이전트가 자격증명 획득에 실패하면 재시도 간격을 늘리므로, 역할을 나중에 붙이면 즉시 반영되지 않습니다.</p>
<p>&nbsp;</p>
<h2 id="⛓️💥-nat-미동작-→-사용자-데이터-실행-순서-오류">⛓️‍💥 NAT 미동작 → 사용자 데이터 실행 순서 오류</h2>
<p>프라이빗 서브넷의 EC2가 SSM에 등록되지 않고, 외부 인터넷도 되지 않았습니다.</p>
<pre><code>1. SSM 접속 불가 → IAM 역할은 붙어 있음
2. 프라이빗 서버가 AWS 엔드포인트에 닿지 못한다고 추정 → NAT 의심
3. NAT 인스턴스에 접속(퍼블릭이라 접속 가능)해 상태 확인
      cat /proc/sys/net/ipv4/ip_forward → 1 (정상)
      iptables -t nat -L POSTROUTING -n → 비어 있음 (여기가 문제!!!)
4. cloud-init 로그 확인
      /var/log/cloud-init-output.log</code></pre><p>원인은 사용자 데이터 스크립트에서 <code>iptables</code> 명령을 먼저 실행하고 패키지 설치를 나중에 배치해서 발생한 문제였습니다. Amazon Linux 2023에는 <code>iptables</code>가 기본 설치되어 있지 않아 명령이 실패했고, 그 결과 MASQUERADE 규칙이 누락되었습니다. 따라서 IP 포워딩만 켜진 상태였으므로 패킷은 나가지만 출발지 IP가 사설 IP라 응답이 돌아오지 못하는 상황이었습니다.</p>
<pre><code>part-001: line 8: iptables: command not found
part-001: line 9: iptables: command not found</code></pre><p>아래와 같이 명령어를 입력하면 됩니다.</p>
<pre><code class="language-bash">IFACE=$(ip -o -4 route show to default | awk &#39;{print $5}&#39;)
sudo iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
sudo iptables -F FORWARD
sudo service iptables save # 재부팅 후에도 유지
sudo systemctl enable iptables</code></pre>
<p>따라서 사용자 데이터는 의존성 설치를 먼저 배치해야 합니다. 그리고 실패해도 인스턴스는 정상 기동하므로, <code>/var/log/cloud-init-output.log</code><strong>를</strong> 확인하는 습관이 필요합니다. 증상과 원인의 거리가 멀었다는 점도 기록해 둘 만합니다. *&quot;SSM 접속 불가&quot;* 라는 증상에서 *&quot;사용자 데이터의 명령 실행 순서&quot;* 라는 근본 원인까지, 계층을 세 번 내려가야 했습니다.</p>
<p>&nbsp;</p>
<h1 id="🎉-최종-검증-결과">🎉 최종 검증 결과</h1>
<p><code>nagasseum-api-2a</code>에서 SSM으로 접속해 확인했습니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d582578b-b604-436b-a38b-0b6bc6e95aea/image.png" alt=""></p>
<pre><code class="language-bash">$ curl -I https://oauth.codef.io
HTTP/1.1 200 ...
TRUEIP: 54.180.xxx.xxx ← NAT-2a 경유 확인

$ docker --version
Docker version 29.1.3

$ nc -zv nagasseum-db.xxxx.rds.amazonaws.com 3306
Connection to ... (10.0.22.xxx) 3306 succeeded! ← Data 서브넷

$ nc -zv nagasseum-cache.xxxx.cache.amazonaws.com 6379
Connection to ... (10.0.22.xxx) 6379 succeeded! ← Data 서브넷</code></pre>
<p><code>nagasseum-api-2c</code>에서도 확인했습니다.</p>
<pre><code class="language-bash">$ curl -I https://oauth.codef.io
TRUEIP: 52.79.xxx.xxx ← NAT-2c 경유 (다른 IP)</code></pre>
<p>여기서 중요한 점은 공인 IP가 서로 다르다는 것입니다. ****각 AZ가 자기 NAT를 통해 나가고 있습니다. AZ별 라우팅 테이블 분리가 실제로 동작하며, AZ 간 데이터 전송 요금이 발생하지 않고 한쪽 NAT 장애가 반대편에 영향을 주지 않습니다.</p>
<p>&nbsp;</p>
<h1 id="🏗️-1차-인프라-작업-완료-후-구조">🏗️ 1차 인프라 작업 완료 후 구조</h1>
<pre><code>Region: ap-northeast-2 (서울)
└── VPC: nagasseum-vpc (10.0.0.0/16)
    │
    ├── Public Subnet: 10.0.1.0/24 (2a), 10.0.2.0/24 (2c)
    │     ├── NAT Instance 2대 (t4g.nano)
    │     └── [예정] ALB
    │
    ├── Private App: 10.0.11.0/24 (2a), 10.0.12.0/24 (2c)
    │     ├── nagasseum-api-2a (t3.small)
    │     ├── nagasseum-api-2c (t3.small)
    │     └── nagasseum-batch-2a (t3.micro)
    │
    └── Private Data: 10.0.21.0/24 (2a), 10.0.22.0/24 (2c)
          ├── RDS MySQL 8.0 Multi-AZ → 10.0.22.241
          └── ElastiCache Redis 7.1 복제 → 10.0.22.195</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[몬테카를로로 주택 목표 달성 확률 예측하기]]></title>
            <link>https://velog.io/@rocker_nun/%EB%AA%AC%ED%85%8C%EC%B9%B4%EB%A5%BC%EB%A1%9C%EB%A1%9C-%EC%A3%BC%ED%83%9D-%EB%AA%A9%ED%91%9C-%EB%8B%AC%EC%84%B1-%ED%99%95%EB%A5%A0-%EC%98%88%EC%B8%A1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/%EB%AA%AC%ED%85%8C%EC%B9%B4%EB%A5%BC%EB%A1%9C%EB%A1%9C-%EC%A3%BC%ED%83%9D-%EB%AA%A9%ED%91%9C-%EB%8B%AC%EC%84%B1-%ED%99%95%EB%A5%A0-%EC%98%88%EC%B8%A1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 13 Aug 2026 05:49:53 GMT</pubDate>
            <description><![CDATA[<h1 id="🏠-n년-뒤-이-집-내가-살-수-있을까">🏠 n년 뒤 이 집, 내가 살 수 있을까?</h1>
<p>이 질문에 가능 혹은 불가능이 아니라 달성 확률로 답하기까지의 기록입니다. 미래 집값의 불확실성을 어떻게 수치로 다뤘는지, 그리고 그 과정에서 어떤 설계 판단을 했는지 정리했습니다.</p>
<ul>
<li><p><strong>가격 모델(Price Model)</strong>: 과거 실거래 데이터에서 <strong>집값의 추세(μ)</strong>와 <strong>불확실성(σ)</strong> 두 숫자를 뽑아냅니다.</p>
</li>
<li><p><strong>몬테카를로 엔진</strong>: 뽑아낸 집값의 추세와 불확실성으로 목표 시점의 집값을 1만 번 시뮬레이션해 <strong>가격 분포</strong>를 만들고, 사용자의 예상 예산이 그 분포에서 어느 위치에 있는지를 확률로 환산합니다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="％-왜-확률이어야-했나">％ 왜 확률이어야 했나?</h2>
<p>우리 서비스는 MZ세대의 주거 독립을 돕는 플랫폼입니다. 사용자는 원하는 지역, 주거 유형, 목표 시점을 입력하고 <em>“내가 이런 곳에 살 수 있을까?”</em> 를 진단받습니다.</p>
<p>초기에는 현재 시세와 현재 자산을 비교해서 <em>“가능”, “불가능”</em> 을 판정하는 방식이었지만 여기에는 아주 큰 허점이 있었습니다. <strong>바로 미래의 집값은 오늘과 다르다는 점</strong>입니다. 사용자가 만약 2년 뒤를 목표로 잡았다면, 비교해야 할 것은 오늘 시세가 아니라 2년 뒤의 시세일 것입니다. 그리고 정확한 미래 시세는 아무도 모르죠.</p>
<p>바로 이 불확실성을 정직하게 다루는 것이 이번 작업의 핵심이었습니다. 그냥 <em>“2년 뒤엔 2.2억이 됩니다.”</em> 라고 말해주는 대신, <strong><em>“2년 뒤엔 중앙값 2.2억, 나쁘면 1.6억, 좋으면 2.9억이고, 사용자 예산으로 살 수 있을 확률은 60%입니다.”</em></strong> 라고 구체적으로 알려주는 겁니다.</p>
<p>&nbsp;</p>
<h3 id="📈-가격-모델-집값의-추세와-변동성을-숫자로">📈 가격 모델: 집값의 추세와 변동성을 숫자로</h3>
<p>우리 서비스는 국토부 실거래 API에서 수집한 <code>rent_transaction</code> 테이블을 사용합니다. 아파트, 연립다세대, 오피스텔, 단독다가구의 전월세 데이터가 모두 들어 있습니다. 여기서 특정 조건(지역, 주거유형, 거래유형, 평수)에 맞는 최근 36개월치 거래를 조회합니다.</p>
<p>36개월로 정한 이유는 너무 짧으면 최근 몇 달의 일시적 변동성에 휘둘리고, 너무 길면 오늘날과는 맞지 않는 환경이 섞입니다. 그래서 단기적인 변동성을 흡수하면서도 현재 시장의 성격을 담기 위해 3년으로 결정했습니다. <del>3년치의 실거래가가 570만건인데, 그 이상 호출하니까 API 일일 호출 가능량을 초과했다...</del></p>
<p>&nbsp;</p>
<p>여기서 한 가지 주의할 점은 <strong>실거래 한 건 한 건은 서로 다른 매물이라는 것</strong>입니다. 롯데 캐슬과 동네 빌라를 비교해서 집값 상승률을 계산하는 것은 말이 안 되죠. 그래서 두 단계로 정규화합니다. </p>
<ol>
<li><p><strong>거래가를 면적으로 나눠서 평당 가격으로 바꿉니다.</strong> 이러면 20평이든 40평이든 같은 기준으로 비교됩니다. </p>
</li>
<li><p><strong>평당 가격을 월별로 묶어 각 달의 중앙값을 취합니다.</strong> 그러면 1월 중앙값, 2월 중앙값, … 처럼 하나의 깔끔한 시계열이 남습니다.</p>
</li>
</ol>
<p>&nbsp;</p>
<p>여기서 평균이 아니라 중앙값을 사용하는 이유는 만약 어느 달에 인접 단지의 특이 거래나 극단 매물이 한두 건 섞여도, 중앙값은 그 이상치에 거의 흔들리지 않습니다. </p>
<pre><code class="language-java">List&lt;Double&gt; monthlyMedians = raw.stream()
    .collect(Collectors.groupingBy(MonthlyPricePoint::getDealYm))
    .entrySet().stream()
    .filter(e -&gt; e.getValue().size() &gt;= MIN_SAMPLES_PER_MONTH)  // 월 5건 미만 제외
    .sorted(Map.Entry.comparingByKey())
    .map(e -&gt; medianPerPyeong(e.getValue()))
    .collect(Collectors.toList());</code></pre>
<p>표본이 적은 달의 중앙값은 신뢰하기 어려울 것입니다. 그래서 거래가 5건 미만인 달은 제외하고, 그렇게 걸러낸 뒤 유효한 달이 5개 미만이면 예외를 던지도록 했습니다. 근거가 빈약한 조건에 대해 그럴듯한 확률을 만들어내는 것보다, 솔직하게 판단할 데이터가 없다고 말하는 편이 사용자에게 더 도움이 될 것이라고 판단했기 때문입니다.</p>
<p>&nbsp;</p>
<p>아무튼 이렇게 월별 중앙값 시계열 <code>[P₁, P₂, …, Pₙ]</code>에서 두 숫자를 추정합니다.</p>
<ul>
<li><p><strong>변동성(σ):</strong> 달마다의 로그 수익률 <code>rₜ = ln(Pₜ / Pₜ₋₁)</code>을 구해 그 표준편차를 σ_월로 씁니다. 로그 수익률은 이번 달 가격이 지난달 대비 몇 % 움직였지 연속 복리로 표현한 값이라, 상승과 하락이 대칭적으로 다뤄지고 뒤의 기하 브라운 운동과 자연스럽게 맞물립니다.</p>
</li>
<li><p><strong>logDrift(b):</strong> 수익률의 추세는 로그 수익률의 평균이 아니라, <code>ln(Pₜ)</code>를 시간 t에 최소제곱(OLS) 회귀한 기울기로 잡습니다. 왜냐하면 로그 수익률의 평균은 <code>(1 / k)·Σ ln(Pₜ / Pₜ₋₁) = (1 / k)·ln(Pₙ / P₁)</code>이므로 결국 첫 달과 마지막 달 두 점에만 의존하기 때문입니다. 두 시점 CAGR과 똑같은 취약함이죠. 반면 로그 가격 회귀는 36개월의 모든 점으로 추세선을 그으므로 엔드포인트 노이즈에 강합니다.</p>
</li>
</ul>
<p>여기서 결정적인 사실은 GBM에서 로그 가격은 <code>ln P(t) = ln P(0) + (μ − σ²/2)·t + σ·W(t)</code>로 움직이므로, 회귀 기울기의 기댓값은 산술 드리프트 μ가 아니라 로그-드리프트 <code>(μ − σ²/2)</code>입니다. 시뮬레이션이 기대하는 산술 드리프트 μ로 넘기려면 <code>σ² / 2</code>를 다시 더해줘야 합니다.</p>
<p>&nbsp;</p>
<p>정리하면 연 단위로:</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/122976f0-1cd7-4036-9da7-d14b37ca4d8e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/067962bb-a1b3-4004-94e2-aec7834e0084/image.png" alt=""></p>
<pre><code>logDrift_연 = (ln(price)~t 회귀 기울기) × 12  // 로그-드리프트 (μ − σ²/2)
σ_연 = σ_월 × √12   // 변동성 (시간의 제곱근에 비례)
μ_연 = logDrift_연 + 0.5 × σ_연²  // 산술 드리프트 → 몬테카를로 입력
CAGR = exp(logDrift_연) − 1   // 표시용 연 상승률 (중앙값 기준)</code></pre><p>σ가 √12로 환산되는 건 무작위 걸음(random walk)에서 불확실성은 시간에 정비례해서가 아니라 시간의 제곱근에 비례해 커지기 때문입니다.</p>
<p>&nbsp;</p>
<h3 id="🎛️-몬테카를로-미래-집값을-1만-번-그려본다">🎛️ 몬테카를로: 미래 집값을 1만 번 그려본다</h3>
<p>집값이 시간에 따라 어떻게 움직이는지를 수학으로 표현할 때 가장 널리 사용하는 모델은 <strong>기하 브라운 운동(Geometric Brownian Motion, GBM)</strong>입니다. 원래 주가 모델링에서 출발했지만, 두 가지 성질 덕분에 부동산에도 합리적입니다.</p>
<ul>
<li><p>항상 양수: 집값은 음수가 될 수 없는데, GBM은 지수함수 형태라 절대 0 밑으로 내려가지 않습니다.</p>
</li>
<li><p>로그정규 분포: 가격이 퍼센트로 변한다는 직관과 맞습니다. (ex. 매달 몇 % 오른다)</p>
</li>
</ul>
<p>&nbsp;</p>
<p>수식은 아래와 같습니다.</p>
<pre><code>P(T) = P(0) × exp((μ − σ²/2)·T + σ·√T·Z)</code></pre><p>여기서 <code>Z</code>는 표준정규분포 <code>N(0, 1)</code>에서 뽑은 난수입니다. 지수 안은 두 부분으로 읽으면 됩니다. <code>(μ − σ² / 2)·T</code>는 <strong>방향(추세)</strong>, <code>σ·√T·Z</code>는 <strong>흔들림(불확실성)</strong>입니다. 매번 다른 <code>Z</code>를 뽑을 때마다 서로 다른 미래 하나가 그려집니다.</p>
<p>&nbsp;</p>
<p>여기서 집중해야 할 부분은 <code>σ²/2</code>, 즉 <strong>Itô 보정</strong>입니다. 왜 필요할까요? </p>
<p>직관적으로는, 드리프트가 μ라면 미래 가격의 기댓값이 <code>P(0) × exp(μT)</code>여야 합니다. 그런데 로그정규분포의 기댓값 공식 때문에, 보정 없이 <code>μT + σ√T·Z</code>를 지수에 그대로 넣으면 기댓값이 <code>P(0) × exp((μ + σ²/2)T)</code>로 나옵니다. <code>σ² / 2</code>만큼 부풀어 오르는 것이죠.</p>
<p>이유는 지수함수의 볼록성에 있습니다. 위로 튄 시나리오(큰 양수 Z)가 아래로 튄 시나리오(같은 크기 음수 Z)보다 지수를 취하면 더 크게 벌어집니다. 그래서 흔들림 자체가 평균을 위로 끌어올립니다. <code>σ² / 2</code>를 빼는 건 이 흔들림이 만드는 공짜 상승분을 정확히 상쇄하기 위한 것입니다. 빠뜨리면 집값 상승을 체계적으로 과대 예측하게 됩니다.</p>
<p>&nbsp;</p>
<p>이제 <code>Z</code>를 1만 번 바꿔 뽑아 1만 개의 미래를 만듭니다.</p>
<pre><code class="language-java">double drift = (annualDrift - 0.5 * annualVol * annualVol) * T;
double diffusion = annualVol * Math.sqrt(T);

Random rng = new Random(seed);  // 재현성을 위해 시드 고정
long[] prices = new long[simulations];

for (int i = 0; i &lt; simulations; i++) {
    double z = rng.nextGaussian();  // Z ~ N(0,1)  (Java는 Marsaglia 극좌표법 사용)
    prices[i] = Math.round(initialPrice * Math.exp(drift + diffusion * z));
}</code></pre>
<p>시드를 고정한 건 재현성 때문입니다. 같은 입력이면 항상 같은 결과가 나와야 캐싱과도 아귀가 맞고, 테스트도 안정적입니다. 시뮬레이션이 1만 개나 되므로 어떤 시드를 쓰든 분위값은 사실상 같은 곳으로 수렴합니다.</p>
<p>&nbsp;</p>
<p>1만 개의 가격을 정렬하면 미래 집값의 분포가 나옵니다. 여기서 세 지점을 뽑는 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/168076fd-e2c2-4e4b-8fab-b74537c22ac9/image.png" alt=""></p>
<ul>
<li><strong>P50(중앙값)</strong>: 가장 있을 법한 목표 시점 집값</li>
<li><strong>P5(하위 5%)</strong>: 집값이 크게 떨어진 시나리오</li>
<li><strong>P95(상위 5%)</strong>: 집값이 크게 오른 시나리오</li>
</ul>
<p>그리고 성공 확률은, 사용자의 예상 예산이 이 1만 개 중 몇 개를 넘어서는지를 세면 됩니다.</p>
<pre><code class="language-java">successProbability = (double) count(budgetAtT &gt;= price) / simulations;</code></pre>
<p>즉 성공 확률이란 1만 가지 미래 중 내 예산으로 이 집을 살 수 있는 시나리오의 비율입니다. 60%라고 한다면, 10000개의 멀티버스에서 6000개의 멀티버스에서는 목표를 이룬다는 뜻입니다. </p>
<p>&nbsp;</p>
<p>집값이 확률적이라면, 예산은 결정론적으로 둡니다. <code>BudgetCalculator</code>가 목표 시점의 예상 예산을 계산합니다.</p>
<pre><code>budgetAtT = 예적금 × (1 + 월이율)^T + 기타자산 + 월저축액 × 적립식미래가치계수</code></pre><p>예적금은 복리로 굴리고, 매달 저축액은 적립식으로 쌓아 올린 미래가치를 더합니다. 결국 이 구조는 확률적인 집값과 결정론적인 예산을 고려해서 사용자의 예산선이 집값 분포의 어디를 지나는지로 달성 확률을 읽어내는 것입니다.</p>
<p>&nbsp;</p>
<p>이해를 돕기 위해 또 다른 예시를 들어보겠습니다. 어떤 조건의 현재 시세 중앙값이 <code>P(0) = 2억</code>, 추정된 산술 드리프트 <code>μ = 5%</code>, 변동성 <code>σ = 12%</code>, 목표까지 <code>T = 2년</code>이라고 가정하겠습니다.</p>
<ul>
<li><p>추세 항: <code>(0.05 − 0.5 × 0.12²) × 2 = 0.0428 × 2 = 0.0856</code></p>
</li>
<li><p>흔들림 계수: <code>σ×√T = 0.12 × 1.414 = 0.170</code></p>
</li>
</ul>
<p>&nbsp;</p>
<p>이제 <code>Z</code>에 따라 미래가 갈립니다. </p>
<ul>
<li><p><strong>Z = 0 (평범한 미래)</strong>: <code>2.0억 × exp(0.0856) ≈ 2.18억</code> ← 대략 중앙값</p>
</li>
<li><p><strong>Z ≈ +1.65 (상위 5%)</strong>: <code>2.0억 × exp(0.0856 + 0.170 × 1.65) ≈ 2.88억</code></p>
</li>
<li><p><strong>Z ≈ −1.65 (하위 5%)</strong>: <code>2.0억 × exp(0.0856 − 0.170 × 1.65) ≈ 1.65억</code></p>
</li>
</ul>
<p>이때 만약 사용자의 목표 시점 예산이 2억 3천만원이라면, <strong>2억 3천만원 이하로 끝나는 시나리오</strong>의 비율이 성공 확률입니다. 계산해 보면 약 62%가 나옵니다. 사용자에게는 *&quot;2년 뒤 이 집을 살 수 있을 확률은 62%입니다&quot;* 와 같이 목표를 진단해줄 수 있습니다. </p>
<p>&nbsp;</p>
<h2 id="🤔-설계-과정에서의-판단들">🤔 설계 과정에서의 판단들</h2>
<p>수식만큼이나 중요했던 건, 이 로직을 서비스에 안전하게 작성하는 일이었습니다. 아래는 그 과정 속에서 했던 판단들입니다.</p>
<h3 id="📝-판단1-목표가-저장되기-전에도-시뮬레이션이-돌아야-한다">📝 판단1: 목표가 저장되기 전에도 시뮬레이션이 돌아야 한다</h3>
<p>처음 만든 API는 <code>simulate(memberId, goalId)</code>였습니다. 저장된 목표가 있어야만 목표를 진단할 수 있는 구조였던 것입니다. 하지만 팀에서 추천 알고리즘을 도입하기로 결정됐고 목표가 저장되기 전에도 시뮬레이션을 가동할 수 있어야 했습니다.</p>
<p>추천 알고리즘은 여러 후보 조건(지역, 주거유형, 거래유형, 평수 조합)을 훑으며 각각의 현실성을 평가합니다. 여기에 몬테카를로를 붙이려면 <code>goalId</code> 없이 해당 조건으로 일단 돌릴 수 있어야 했습니다.</p>
<p>그래서 오버로드를 추가했습니다.</p>
<pre><code class="language-java">// 저장된 목표용 — GoalController 엔드포인트
MonteCarloResponse simulate(Long memberId, Long goalId);

// 알고리즘 내부용 — goalId 불필요
MonteCarloEngine.Result simulate(PriceModelRequest housing,
                                 long initialPrice,
                                 long budgetAtT,
                                 int months);</code></pre>
<p>알고리즘이 넘기는 <code>initialPrice (현재 시세 중앙값)</code>와 <code>budgetAtT (BudgetCalculator 결과)</code>는 알고리즘이 이미 알고 있는 값입니다. 추가 조회 없이 그대로 넘기면 되니 낭비도 없습니다.</p>
<p>&nbsp;</p>
<h3 id="🚧-판단2-호출자가-하나뿐이어도-도메인-경계는-지킨다">🚧 판단2: 호출자가 하나뿐이어도 도메인 경계는 지킨다</h3>
<p><code>PriceModelService</code>의 직접 호출자가 <code>MonteCarloServiceImpl</code> 딱 하나여서, *&quot;서비스를 없애고 로직을 안으로 흡수하면 더 단순하지 않을까?&quot;* 를 검토했습니다. 결론은 불가였습니다.</p>
<p><code>PriceModelServiceImpl</code>은 property 도메인의 Mapper(<code>RegionMapper</code>, <code>RentTransactionMapper</code>)를 직접 사용합니다. 이 로직을 goal 도메인인 <code>MonteCarloServiceImpl</code>로 옮기면, goal 도메인 서비스가 property 도메인 Mapper를 직접 부르게 됩니다. 프로젝트 기획 당시 미리 정해놓은 도메인 접근은 반드시 해당 도메인의 서비스 계층을 통해서만 해야 한다는 규칙에 위반되는 행위였습니다.</p>
<p><code>PriceModelService</code>는 단순한 껍데기 추상화가 아니라, goal → property 도메인 경계를 지키는 문지기 역할을 하고 있었습니다. 호출자가 하나뿐이라는 건 경계를 허물 이유가 되지 못합니다.</p>
<p>&nbsp;</p>
<h3 id="🚫-판단3-같은-계산을-매번-반복할-이유가-없다">🚫 판단3: 같은 계산을 매번 반복할 이유가 없다</h3>
<p>처음엔 <code>GET /{goalId}/simulation</code>을 열어두고 사용자가 목표 상세 화면을 열 때마다 계산하게 했습니다. 그러다 한 가지 의문이 들었습니다.</p>
<blockquote>
<p><em>&quot;목표가 확정된 뒤엔, 목표를 다시 설정하지 않는 한 몬테카를로를 돌릴 일이 없는 것 아닌가?&quot;</em></p>
</blockquote>
<p>맞는 말이었습니다. 목표 조건(주거 조건, 목표 시점, 월 저축액)이 바뀌지 않으면 시뮬레이션 입력이 고정됩니다. 그런데 가장 비싼 연산인 <code>PriceModelService.estimate()</code>(36개월 DB 조회 + 회귀 분석)를 매 요청마다 되풀이할 이유가 없었죠.</p>
<p>그래서 Redis 캐싱을 도입하기로 결정했습니다.</p>
<pre><code>Key: goal:simulation:{goalId}
TTL: 30일</code></pre><p>TTL은 어디까지나 보조 안전장치입니다. 목표 수정 및 삭제 시 캐시를 명시적으로 지우므로, 정상 흐름에서는 TTL이 만료되기 전에 무효화됩니다.</p>
<p>&nbsp;</p>
<h3 id="⛓️💥-판단4-순환-의존을-어떻게-끊는가">⛓️‍💥 판단4: 순환 의존을 어떻게 끊는가</h3>
<p>캐시를 지우려면 <code>GoalServiceImpl</code>이 관련 컴포넌트를 주입받아야 합니다. 그런데 <code>MonteCarloServiceImpl</code>은 이미 <code>GoalService</code>를 주입받고 있었습니다. 그대로 <code>GoalServiceImpl</code>에 <code>MonteCarloService</code>를 넣으면 서로를 참조하는 순환이 생깁니다.</p>
<pre><code>MonteCarloServiceImpl → GoalService
GoalServiceImpl → MonteCarloService ← 순환!</code></pre><p>해결책은 <strong>책임을 쪼개는 것</strong>이었습니다. 캐시 삭제만 담당하는 <code>MonteCarloSimulationStore</code>를 별도 컴포넌트로 떼어내고, <code>GoalServiceImpl</code>에는 이 Store만 주입했습니다. 이제 <code>GoalServiceImpl</code>은 <code>MonteCarloService</code>의 존재 자체를 모르게 되어 순환이 사라집니다.</p>
<pre><code>MonteCarloServiceImpl → GoalService
GoalServiceImpl → MonteCarloSimulationStore ← 순환 없음</code></pre><p>&nbsp; </p>
<h2 id="🏰-최종-구조">🏰 최종 구조</h2>
<p>추천 알고리즘이 호출하는 경로 (목표 저장 전):</p>
<pre><code>추천 알고리즘
  ↓ simulate(housing, initialPrice, budgetAtT, months)
MonteCarloService
  ├── PriceModelService.estimate() → μ, σ 산출 (property 도메인)
  └── MonteCarloEngine.simulate() → 10,000 경로 (순수 계산)
  ↓
Result { p5, p50, p95, successProbability }</code></pre><p>&nbsp;</p>
<p>사용자가 목표 상세를 여는 경로 (목표 저장 후):</p>
<pre><code>GoalController  GET /{goalId}/simulation
  ↓ simulate(memberId, goalId)
MonteCarloServiceImpl
  ├── 소유권 확인 (findOwnedGoal)
  ├── 캐시 HIT  → MonteCarloSimulationStore.find() → 즉시 반환
  └── 캐시 MISS → compute() → save() → 반환</code></pre><p>&nbsp;</p>
<p>목표가 바뀌면 캐시를 지우는 경로:</p>
<pre><code>GoalServiceImpl
  updateGoal() → goalMarketTrendCacheStore.delete()
               → monteCarloSimulationStore.delete()
  deleteGoal() → goalMarketTrendCacheStore.delete()
               → monteCarloSimulationStore.delete()</code></pre><p>&nbsp;</p>
<h2 id="😁-결과">😁 결과</h2>
<p><strong>기능 관점</strong>: 추천 알고리즘은 이제 <code>MonteCarloService</code> 하나만 주입받아 시뮬레이션을 부를 수 있습니다. <code>goalId</code>가 없는 목표 저장 전 단계에서도 정상 동작합니다.</p>
<p><strong>성능 관점</strong>: <code>PriceModelService.estimate()</code>는 실행에 약 110~130ms가 걸립니다(JVM 워밍업 후 측정). 캐시 HIT 시 이 비용이 통째로 사라지므로, 목표 조건이 바뀌지 않는 한 사용자는 늘 캐시 응답을 받습니다.</p>
<p><strong>설계 관점</strong>: 도메인 경계(goal → property)를 지키면서, 순환 의존 없이 캐시 무효화 책임을 깔끔하게 분리했습니다. 앞으로 알고리즘이 더 붙더라도 <code>MonteCarloService.simulate(housing, initialPrice, budgetAtT, months)</code> 하나만 부르면 됩니다. 내부 최적화(캐싱, 모델 개선 등)는 호출자가 알 필요가 없습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[하나의 코드베이스, 두 개의 서버: Spring Profile 제대로 이해하기]]></title>
            <link>https://velog.io/@rocker_nun/%ED%95%98%EB%82%98%EC%9D%98-%EC%BD%94%EB%93%9C%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EB%91%90-%EA%B0%9C%EC%9D%98-%EC%84%9C%EB%B2%84-Spring-Profile-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/%ED%95%98%EB%82%98%EC%9D%98-%EC%BD%94%EB%93%9C%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EB%91%90-%EA%B0%9C%EC%9D%98-%EC%84%9C%EB%B2%84-Spring-Profile-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 12 Aug 2026 01:25:41 GMT</pubDate>
            <description><![CDATA[<p>같은 코드를 API 서버와 배치 서버, 두 가지로 나눠 실행하는 구조를 만들면서 Spring Profile을 깊게 들여다볼 일이 있었습니다. 이 글은 공식 문서를 따라가며 개념을 정리하고, 실제 프로젝트에 적용한 내용을 함께 기록한 글입니다.</p>
<p>먼저 이 글이 도달하려는 목표부터 그림으로 보여드리겠습니다. 똑같은 WAR 파일 하나를, 기동할 때 옵션 하나만 바꿔서 서로 다른 역할로 띄우는 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/93653c88-34de-40f6-9aca-17327076a339/image.png" alt=""></p>
<h2 id="🧐-profile이란">🧐 Profile이란</h2>
<blockquote>
<p><strong>*&quot;조건이 맞을 때만 컨테이너에 등록되는, 이름이 붙은 빈 정의(bean definition)의 논리적 그룹&quot;*</strong></p>
</blockquote>
<p>공식 문서의 정의입니다. 문장이 다소 딱딱하니, 처음 이해할 때 도움이 되는 <strong>세 가지 감각</strong>으로 바꿔 보겠습니다.</p>
<ol>
<li><p>Profile은 빈을 <em><strong>&quot;만드는&quot;</strong></em> 도구가 아니라, <em><strong>&quot;등록할지 말지&quot;</strong></em> 를 정하는 스위치입니다. 객체를 어떻게 생성하는지와는 무관합니다. <code>prod</code> 프로필에 속한 빈은 <code>prod</code>가 켜졌을 때만 컨테이너에 올라가고, 아니면 아예 존재하지 않습니다.</p>
</li>
<li><p>판정은 애플리케이션이 뜰 때 딱 한 번 일어납니다. 매 요청마다 돌아가는 <code>if</code> 문이 아닙니다. 기동 시점에 이 빈을 등록할지 말지를 한 번 결정하고 나면, 그 결과는 애플리케이션이 살아있는 내내 고정됩니다.</p>
</li>
<li><p>그래서 프로필 이름은 비즈니스 로직 어디에도 나타나지 않습니다. 어떤 빈을 쓸지는 설정 단계에서 이미 정해지므로, 실제 로직은 현재 무슨 프로필인지 알 필요가 없습니다.</p>
</li>
</ol>
<p>이 세 가지만 기억하고 넘어가면 나머지는 자연스럽게 따라옵니다.</p>
<p>Profile은 Spring 3.1부터 제공되며, <code>Environment</code>라는 인터페이스가 관리합니다. <code>Environment</code>는 뒤에서 자세히 다룰 텐데, 지금은 <strong>*&quot;지금 어떤 프로필이 켜져 있는지 알고 있는 관리자&quot;*</strong> 정도로만 생각하면 됩니다.</p>
<p>&nbsp;</p>
<h2 id="👍-핵심-활용법-역할-축-vs-환경-축">👍 핵심 활용법: 역할 축 vs 환경 축</h2>
<p>Profile의 진짜 힘은 <strong>여러 개를 조합</strong>하는 데 있습니다. 프로필을 두 개의 축으로 나눠서 생각하면 활용법이 한눈에 들어옵니다.</p>
<table>
<thead>
<tr>
<th>축</th>
<th>예시</th>
<th>무엇을 정하나</th>
</tr>
</thead>
<tbody><tr>
<td><strong>역할 축</strong></td>
<td><code>api</code> / <code>batch</code></td>
<td>이 인스턴스가 <strong>무슨 역할로</strong> 실행될지</td>
</tr>
<tr>
<td><strong>환경 축</strong></td>
<td><code>local</code> / <code>prod</code></td>
<td>이 인스턴스가 <strong>어느 환경에서</strong> 실행될지</td>
</tr>
</tbody></table>
<p>그리고 쉼표로 두 축을 동시에 켤 수 있습니다.</p>
<pre><code>-Dspring.profiles.active=local,api    # 로컬에서 + API 역할로
-Dspring.profiles.active=prod,batch   # 운영에서 + 배치 역할로</code></pre><p>이 조합이 앞에서 본 <strong>*&quot;WAR 하나, 두 서버&quot;*</strong> 를 가능하게 하는 핵심입니다. 이 감각을 미리 잡아두면, 아래에 나오는 문법들이 전부 이 그림을 채우기 위한 도구로 읽힙니다.</p>
<p>&nbsp;</p>
<h2 id="3️⃣-profile-사용법-세-가지-레벨">3️⃣ @Profile 사용법: 세 가지 레벨</h2>
<p><code>@Profile</code>은 붙이는 위치에 따라 적용 범위가 달라집니다. 가장 직관적인 것부터 살펴보겠습니다.</p>
<h3 id="🌕-컴포넌트-레벨-구현체를-통째로-교체">🌕 컴포넌트 레벨: 구현체를 통째로 교체</h3>
<p>가장 이해하기 쉬운 사용법입니다. 같은 인터페이스의 구현체를 프로필에 따라 갈아끼웁니다.</p>
<pre><code class="language-java">@Component
@Profile(&quot;local&quot;)
public class CodefMockClient implements CodefClient { }  // local일 때 이 구현체

@Component
@Profile(&quot;prod&quot;)
public class CodefRealClient implements CodefClient { }  // prod일 때 이 구현체</code></pre>
<p>주입받는 쪽은 그냥 <code>CodefClient</code>를 받을 뿐, 자기가 목(mock)을 받는지 실제 클라이언트를 받는지 전혀 몰라도 됩니다. 프로필을 바꾸면 주입되는 실체만 조용히 바뀝니다.</p>
<p>&nbsp;</p>
<h3 id="🌙-메서드-레벨-빈-하나씩-분기">🌙 메서드 레벨: 빈 하나씩 분기</h3>
<p><code>@Bean</code> 메서드에 붙이면 그 빈만 프로필에 종속됩니다. 환경별로 다른 <code>DataSource</code>를 만드는 전형적인 예입니다.</p>
<pre><code class="language-java">@Configuration
public class DataSourceConfig {

    @Bean(&quot;dataSource&quot;)
    @Profile(&quot;local&quot;)
    public DataSource localDataSource() {
        return new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .build();
    }

    @Bean(&quot;dataSource&quot;)
    @Profile(&quot;prod&quot;)
    public DataSource prodDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(System.getenv(&quot;DB_URL&quot;));
        return new HikariDataSource(config);
    }
}</code></pre>
<p>두 메서드 모두 <code>@Bean(&quot;dataSource&quot;)</code>로 <strong>같은 빈 이름</strong>을 갖되, <strong>메서드 이름은 다르게</strong> 뒀습니다. 이게 안전한 패턴인 데는 이유가 있습니다.</p>
<blockquote>
<p><strong>주의)</strong> 만약 메서드 이름까지 똑같이 만들어 자바 오버로딩이 되면, <code>@Profile</code> 조건을 <strong>모든 오버로딩 메서드에 일관되게</strong> 선언해야 합니다. 조건이 서로 다르면 첫 번째 선언의 조건만 적용됩니다. 위처럼 메서드 이름을 다르게 두면 애초에 오버로딩이 아니므로 이 함정 자체를 피할 수 있습니다.</p>
</blockquote>
<p>&nbsp;</p>
<h3 id="🌞-클래스-레벨-설정-클래스-전체를-묶기">🌞 클래스 레벨: 설정 클래스 전체를 묶기</h3>
<p><code>@Configuration</code> 클래스에 붙이면 파급력이 가장 큽니다. 조건이 맞지 않으면 <strong>그 안의 모든 것이 통째로 사라집니다.</strong></p>
<pre><code class="language-java">@Configuration
@Profile(&quot;batch&quot;)
@EnableScheduling
@ComponentScan(&quot;com.example.scheduler&quot;)
public class BatchConfig {
// batch 프로필일 때만 이 클래스 전체가 처리됨
}</code></pre>
<p>공식 문서가 강조하는 규칙이 여기 있습니다. <code>@Configuration</code> 클래스에 <code>@Profile</code>이 붙어 조건이 맞지 않으면, 그 클래스의 모든 <code>@Bean</code> 메서드와 <code>@Import</code>, <code>@ComponentScan</code>이 전부 건너뛰어집니다.</p>
<p>이 규칙이 왜 강력한지는 <code>@EnableScheduling</code>을 보면 드러납니다. <code>@EnableScheduling</code>의 실체는 <code>@Import(SchedulingConfiguration.class)</code>입니다. 즉 이것도 <code>@Import</code>의 일종이므로, <code>BatchConfig</code>가 통째로 무시되면 스케줄링 기능 자체가 등록되지 않습니다. <code>@ComponentScan</code>도 실행되지 않으니 해당 패키지를 아예 스캔하지 않습니다.</p>
<p>설정 클래스 하나에 <code>@Profile</code>을 붙이면, 그 안의 모든 기능이 하나의 스위치에 종속되는 것입니다.</p>
<pre><code class="language-java">@Configuration
@Profile(&quot;api&quot;)
public class ApiConfig {
    ...
}

@Configuration
@Profile(&quot;batch&quot;)
@EnableScheduling
@ComponentScan(basePackages = &quot;com.team.independence.scheduler&quot;)
public class BatchConfig {
}</code></pre>
<p>&nbsp;</p>
<h2 id="✍🏻-프로필-표현식">✍🏻 프로필 표현식</h2>
<p>단순한 이름 매칭을 넘어, 논리 연산자를 쓸 수 있습니다.</p>
<pre><code class="language-java">@Profile(&quot;production&quot;)  // production이 활성일 때
@Profile(&quot;!production&quot;)  // NOT — production이 아닐 때
@Profile(&quot;production &amp; us-east&quot;)  // AND — 둘 다 활성일 때
@Profile(&quot;production | eu-central&quot;)  // OR — 하나라도 활성일 때
@Profile(&quot;production &amp; (us-east | eu-central)&quot;)  // 복합 — 괄호 필수</code></pre>
<p>한 가지 규칙만 주의하면 됩니다. <code>&amp;</code>와 <code>|</code>를 괄호 없이 섞으면 유효하지 않은 표현식입니다.</p>
<pre><code class="language-java">@Profile(&quot;production &amp; us-east | eu-central&quot;)   // ❌ 잘못된 표현식
@Profile(&quot;production &amp; (us-east | eu-central)&quot;) // ✅ 괄호로 묶어야 함</code></pre>
<p>한편, 배열로 전달하면 자동으로 OR로 해석됩니다.</p>
<pre><code class="language-java">@Profile({&quot;local&quot;, &quot;dev&quot;})   // local 또는 dev
@Profile({&quot;p1&quot;, &quot;!p2&quot;})      // p1이 활성이거나, p2가 비활성이면 등록</code></pre>
<p>&nbsp;</p>
<h3 id="🥤-메타-애노테이션으로-뽑아내기">🥤 메타 애노테이션으로 뽑아내기</h3>
<p>자주 쓰는 조건은 커스텀 애노테이션으로 만들어두면 가독성이 좋아집니다. <code>@Profile</code>은 메타 애노테이션으로 사용할 수 있습니다.</p>
<pre><code class="language-java">@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Profile(&quot;production&quot;)
public @interface Production { }

// 사용
@Configuration
@Production
public class ProdConfig { }</code></pre>
<p>&nbsp;</p>
<h2 id="🎛️-프로필-활성화-방법">🎛️ 프로필 활성화 방법</h2>
<p>정의한 프로필을 실제로 켜는 방법은 여러 가지입니다.</p>
<ol>
<li><strong>JVM 시스템 프로퍼티 (가장 일반적)</strong></li>
</ol>
<pre><code>-Dspring.profiles.active=local,api</code></pre><p>IntelliJ에서는 Tomcat Run Configuration의 VM options에 입력합니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/1ae7b4ee-2973-4860-a7ae-42e6a31d52c9/image.png" alt=""></p>
<ol>
<li><strong>OS 환경변수</strong></li>
</ol>
<pre><code class="language-bash">export SPRING_PROFILES_ACTIVE=prod,batch</code></pre>
<p>&nbsp;</p>
<ol start="2">
<li><strong>web.xml (서블릿 환경)</strong></li>
</ol>
<pre><code class="language-xml">&lt;context-param&gt;
    &lt;param-name&gt;spring.profiles.active&lt;/param-name&gt;
    &lt;param-value&gt;production&lt;/param-value&gt;
&lt;/context-param&gt;</code></pre>
<p>&nbsp;</p>
<ol start="3">
<li><strong>코드로 직접 설정</strong></li>
</ol>
<pre><code class="language-java">AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
ctx.getEnvironment().setActiveProfiles(&quot;local&quot;, &quot;api&quot;);
ctx.register(RootConfig.class);
ctx.refresh();</code></pre>
<blockquote>
<p><code>setActiveProfiles()</code>는 <code>String...</code> 가변인자를 받습니다. 그리고 반드시 <code>register()</code> → <code>refresh()</code> <strong>이전에</strong> 호출해야 합니다. 프로필 판정이 컨텍스트 리프레시 시점에 일어나기 때문입니다.</p>
</blockquote>
<p>&nbsp;</p>
<ol start="4">
<li><strong>테스트에서</strong></li>
</ol>
<p><code>spring-test</code>의 <code>@ActiveProfiles</code>를 사용합니다.</p>
<pre><code class="language-java">@SpringJUnitConfig(RootConfig.class)
@ActiveProfiles({&quot;local&quot;, &quot;api&quot;})
class MemberServiceTest { }</code></pre>
<p>어떤 방법을 쓰든, 쉼표로 여러 개를 동시에 켤 수 있다는 점(2장의 역할 축 × 환경 축)이 활용의 핵심입니다.</p>
<p>&nbsp;</p>
<h2 id="💫-기본-프로필">💫 기본 프로필</h2>
<p>활성 프로필이 하나도 없을 때 적용되는 프로필입니다. 이름은 그냥 <code>default</code>입니다.</p>
<pre><code class="language-java">@Configuration
@Profile(&quot;default&quot;)
public class DefaultDataConfig {
    @Bean
    public DataSource dataSource() { ... }
}</code></pre>
<p>아무 프로필도 켜지 않고 기동하면 이 빈이 등록됩니다. 반대로 어떤 프로필이든 하나라도 켜지면 <code>default</code>는 적용되지 않습니다. (<code>local</code> 하나만 켜도 <code>default</code>는 빠집니다.)</p>
<p>기본 프로필의 이름 자체는 <code>setDefaultProfiles()</code>나 <code>spring.profiles.default</code> 프로퍼티로 바꿀 수 있습니다.</p>
<blockquote>
<p><strong>실무 팁)</strong> <code>default</code>에 안전한 기본값을 두면 프로필 지정을 깜빡했을 때의 방어막이 됩니다. 반대로 <strong>프로필 지정을 강제하고 싶다면 일부러 <code>default</code>를 비워두세요.</strong> 그러면 필수 빈이 없어 기동에 실패하므로, 설정 실수를 즉시 알아챌 수 있습니다.</p>
</blockquote>
<p>&nbsp;</p>
<h2 id="🧩-원리-profile은-결국-conditional이다">🧩 원리: @Profile은 결국 @Conditional이다</h2>
<p>지금까지 어떻게 쓰는지를 봤으니, 이제 왜 그렇게 동작하는지를 열어보겠습니다. 먼저 <code>@Profile</code>를 살펴보면 아래와 같습니다.</p>
<pre><code class="language-java">@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional({ProfileCondition.class})  // 핵심
public @interface Profile {
    String[] value();
}</code></pre>
<p>즉 <code>@Profile</code>은 독립적인 기능이 아니라, <code>@Conditional</code>에 <code>ProfileCondition</code>이라는 조건 하나를 끼워 넣은 특수 케이스입니다. 그리고 <code>ProfileCondition</code>이 하는 일은 단순합니다. <code>Environment</code>의 활성 프로필 목록에 그 이름이 들어 있는지 확인하는 것뿐입니다.</p>
<p>여기서 가장 중요한 것은 <strong>판정 시점</strong>입니다.</p>
<pre><code>JVM 기동 (-Dspring.profiles.active=prod,api)
  │
  ├─ Environment 생성 → activeProfiles = [prod, api]
  │
  ├─ ConfigurationClassPostProcessor가 설정 클래스를 파싱
  │     └─ 각 클래스/메서드마다 ConditionEvaluator.shouldSkip() 호출
  │           ├─ 조건 false → 통째로 건너뜀
  │           │              (@Bean, @ComponentScan, @Import 전부 무시)
  │           └─ 조건 true  → 빈 정의 등록
  │
  └─ 등록된 빈 정의로 싱글톤 인스턴스화</code></pre><p>빈이 만들어지기도 전, 설정 클래스를 읽는 단계에서 한 번 판정되고 끝납니다. 이것이 바로 매 요청마다 돌아가는 <code>if</code> 문이 아니라는 말의 의미입니다. 기동이 끝나면 프로필을 바꿀 수 없고, 등록되지 않은 빈이 나중에 되살아나지도 않습니다.</p>
<p>&nbsp;</p>
<h2 id="🏰-environment-인터페이스-프로필과-프로퍼티의-관리자">🏰 Environment 인터페이스: 프로필과 프로퍼티의 관리자</h2>
<p>방금 <code>ProfileCondition</code>이 결국 <code>Environment</code>에게 특정 프로필이 켜져 있는지 확인한다는 것을 봤습니다. 이제 그 <code>Environment</code>가 정확히 무엇인지 볼 차례입니다.</p>
<p>이름 때문에 그냥 환경 설정 객체 정도로 넘기기 쉽지만, <code>Environment</code>는 컨테이너에 통합되어 애플리케이션 환경의 <strong>두 축</strong>을 모델링하는 추상화입니다.</p>
<ul>
<li><strong>Profile:</strong> 지금 어떤 프로필이 활성인지, 기본값은 무엇인지</li>
<li><strong>Property:</strong> properties 파일, 시스템 프로퍼티, 환경변수 등에서 값을 찾아오는 창구</li>
</ul>
<p>&nbsp;</p>
<h3 id="💥-주요-메서드">💥 주요 메서드</h3>
<pre><code class="language-java">package org.springframework.core.env;

public interface Environment extends PropertyResolver {
    String[] getActiveProfiles();  // 활성 프로필 배열
    String[] getDefaultProfiles();  // 기본 프로필 배열

    default boolean matchesProfiles(String... profileExpressions) {
        return this.acceptsProfiles(Profiles.of(profileExpressions));
    }

    /**@deprecated */
    @Deprecated
    boolean acceptsProfiles(String... profiles);  // 표현식 매칭 (구버전)

    boolean acceptsProfiles(Profiles profiles);

// PropertyResolver로부터 상속받는 프로퍼티 조회
// String  getProperty(String key);
// String  getProperty(String key, String defaultValue);
// boolean containsProperty(String key);
}</code></pre>
<blockquote>
<p><code>acceptsProfiles(String...)</code> 형태는 Spring 5.1부터 deprecated입니다. <code>Profiles</code> 객체를 받는 쪽을 쓰면 되고, <code>Profiles.of(&quot;prod &amp; api&quot;)</code>처럼 표현식을 그대로 넘길 수 있습니다.</p>
</blockquote>
<p>&nbsp;</p>
<h3 id="🎭-읽기용과-조작용이-나뉘어-있다">🎭 읽기용과 조작용이 나뉘어 있다</h3>
<p><code>Environment</code>는 읽기 전용 조회만 제공하고, 값을 바꾸는 <strong>조작</strong>은 하위 인터페이스인 <code>ConfigurableEnvironment</code>에 있습니다. 그래서 애플리케이션 코드에서는 보통 <code>Environment</code>만 주입받아 읽기만 하고, 설정을 바꾸는 일은 컨테이너 기동 시점에 프레임워크가 처리합니다.</p>
<p>&nbsp;</p>
<h3 id="🎏-두-가지-구현체">🎏 두 가지 구현체</h3>
<p>어떤 구현체가 쓰이느냐에 따라 기본 프로퍼티 소스가 달라집니다.</p>
<table>
<thead>
<tr>
<th>구현체</th>
<th>사용처</th>
<th>기본 프로퍼티 소스</th>
</tr>
</thead>
<tbody><tr>
<td><code>StandardEnvironment</code></td>
<td>일반 애플리케이션</td>
<td>JVM 시스템 프로퍼티, OS 환경변수</td>
</tr>
<tr>
<td><code>StandardServletEnvironment</code></td>
<td><strong>서블릿(WAR) 환경</strong></td>
<td>JVM 시스템 프로퍼티, OS 환경변수, ServletConfig, ServletContext, JNDI</td>
</tr>
</tbody></table>
<p>WAR로 배포하는 프로젝트라면 자동으로 <code>StandardServletEnvironment</code>가 쓰입니다. 뒤에서 다룰 프로퍼티 우선순위가 다섯 계층이나 되는 이유가 바로 이것입니다. 실제로 이 구현체가 프로퍼티 소스를 추가하는 부분을 보면 계층이 어디서 생기는지 보입니다.</p>
<pre><code class="language-java">public class StandardServletEnvironment extends StandardEnvironment implements ConfigurableWebEnvironment {

    protected void customizePropertySources(MutablePropertySources propertySources) {
        propertySources.addLast(new PropertySource.StubPropertySource(&quot;servletConfigInitParams&quot;));
        propertySources.addLast(new PropertySource.StubPropertySource(&quot;servletContextInitParams&quot;));
        if (jndiPresent &amp;&amp; JndiLocatorDelegate.isDefaultJndiEnvironmentAvailable()) {
            propertySources.addLast(new JndiPropertySource(&quot;jndiProperties&quot;));
        }
        super.customizePropertySources(propertySources);  // 시스템 프로퍼티·환경변수 추가
    }
// ...
}</code></pre>
<p>&nbsp;</p>
<h3 id="🤔-언제-environment를-직접-쓰고-언제-쓰지-말아야-하나">🤔 언제 <code>Environment</code>를 직접 쓰고, 언제 쓰지 말아야 하나</h3>
<p><code>Environment</code>로 프로필을 직접 조회해서 분기하는 코드를 쓸 수도 있습니다.</p>
<pre><code class="language-java">// 이렇게 할 수 있지만...
if (environment.acceptsProfiles(Profiles.of(&quot;prod&quot;))) {
    return new CodefRealClient();
}
return new CodefMockClient();</code></pre>
<p>하지만 권장하는 방식은 아닙니다. 프로필 이름이 비즈니스 로직 안으로 스며들고, 새 환경이 생길 때마다 <code>if</code>가 늘어납니다. 같은 일을 <code>@Profile</code>로 하면 조건이 설정 영역에만 남고 로직은 깨끗해집니다.</p>
<pre><code class="language-java">@Component @Profile(&quot;prod&quot;)  class CodefRealClient implements CodefClient { }
@Component @Profile(&quot;local&quot;) class CodefMockClient implements CodefClient { }</code></pre>
<p>정리하면 역할 분담의 기준은 이렇습니다.</p>
<ul>
<li><strong>*&quot;등록할지 말지&quot;* 는 <code>@Profile</code>에게</strong> 맡긴다.</li>
<li><strong>*&quot;값이 무엇인지&quot;* 는 <code>Environment</code>에게</strong> 묻는다. (프로퍼티 조회, 기동 시 활성 프로필 로깅, 조건부 설정값 계산 등 <strong>값을 읽는</strong> 경우)</li>
</ul>
<p>&nbsp;</p>
<h3 id="👀-활성-프로필-직접-확인하기">👀 활성 프로필 직접 확인하기</h3>
<p>기동 시 어떤 프로필이 켜졌는지 로그로 남기는 것은 <code>Environment</code>를 적절하게 쓰는 대표적인 예입니다.</p>
<pre><code class="language-java">@Configuration
public class RootConfig {

    @Autowired
    private Environment environment;

    @PostConstruct
    public void logActiveProfiles() {
        System.out.println(&quot;활성 프로필: &quot;
                + Arrays.toString(environment.getActiveProfiles()));
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/40baae33-9f2a-4151-a5a9-3dec28e7cb5f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/6056ea4e-bf49-4db2-a272-76ff3e6f3128/image.png" alt=""></p>
<p>&nbsp;</p>
<h2 id="📝-propertysource-우선순위">📝 PropertySource 우선순위</h2>
<p><code>Environment</code>의 또 다른 책임인 프로퍼티를 볼 차례입니다. <code>Environment</code>는 여러 출처에서 값을 찾는데, 계층적으로 탐색하며 먼저 찾은 값이 이깁니다. 값이 병합되는 게 아니라 완전히 덮어써진다는 점이 중요합니다.</p>
<p>서블릿 환경(<code>StandardServletEnvironment</code>)의 전체 우선순위는 다음과 같습니다. 위쪽이 높습니다.</p>
<table>
<thead>
<tr>
<th>순위</th>
<th>출처</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>ServletConfig 파라미터 (DispatcherServlet 컨텍스트)</td>
</tr>
<tr>
<td>2</td>
<td>ServletContext 파라미터 (web.xml의 <code>context-param</code>)</td>
</tr>
<tr>
<td>3</td>
<td>JNDI 환경변수 (<code>java:comp/env/</code>)</td>
</tr>
<tr>
<td>4</td>
<td><strong>JVM 시스템 프로퍼티</strong> (<code>-D</code> 옵션)</td>
</tr>
<tr>
<td>5</td>
<td><strong>OS 환경변수</strong></td>
</tr>
</tbody></table>
<p><code>@PropertySource</code>로 등록한 파일은 기본적으로 이 아래에 놓입니다. 즉 <code>.properties</code>에 값이 있어도 환경변수가 있으면 환경변수가 이깁니다.</p>
<blockquote>
<p>&quot;시스템 프로퍼티 → 환경변수 → properties 파일&quot; 세 단계만 알고 있었다면, 서블릿 환경에서는 그 위에 세 계층이 더 있다는 점을 기억해두면 좋습니다. WAR로 배포하는 프로젝트라면 특히 그렇습니다.</p>
</blockquote>
<pre><code class="language-java">@Configuration
@PropertySource(&quot;classpath:config/app.properties&quot;)
@PropertySource(value = &quot;file:${user.dir}/.env&quot;, ignoreResourceNotFound = true)
public class RootConfig { }</code></pre>
<ul>
<li><code>ignoreResourceNotFound = true</code>를 붙이면 파일이 없어도 무시합니다. 로컬에만 두는 민감한 값(API 키, DB 비밀번호)을 <code>.env</code>로 관리할 때 유용합니다.</li>
<li><code>@PropertySource</code>를 여러 개 선언했을 때 같은 키가 겹치면 나중에 처리된 것이 이깁니다. 위 예시에서는 <code>.env</code>의 값이 <code>app.properties</code>를 덮어씁니다.</li>
</ul>
<p>이 우선순위 덕분에 코드를 고치지 않고 환경만 바꿀 수 있습니다. 로컬에서는 <code>app.properties</code>의 기본값을 쓰고, 서버에서는 환경변수로 주입된 값이 자동으로 이기는 구조입니다.</p>
<p>&nbsp;</p>
<h2 id="🛠️-실전-api-서버--배치-서버-분리">🛠️ 실전: API 서버 / 배치 서버 분리</h2>
<p>이제 처음의 그림으로 돌아옵니다. 같은 WAR를 두 역할로 실행하되, 기동 시 VM options 하나만 바꿉니다.</p>
<pre><code>동일한 WAR
├── -Dspring.profiles.active=local,api → API 서버
└── -Dspring.profiles.active=local,batch → 배치 서버</code></pre><p>&nbsp;</p>
<h3 id="🟣-배치-서버를-결정하는-클래스-하나">🟣 배치 서버를 결정하는 클래스 하나</h3>
<p>클래스 레벨 <code>@Profile</code>을 활용합니다.</p>
<pre><code class="language-java">@Configuration
@Profile(&quot;batch&quot;)
@EnableScheduling
@ComponentScan(&quot;com.example.scheduler&quot;)
public class BatchConfig { }</code></pre>
<p><code>api</code> 프로필로 실행하면 다음 연쇄가 일어납니다.</p>
<ol>
<li><p><code>@Profile(&quot;batch&quot;)</code> 조건 불일치 → BatchConfig 자체가 처리되지 않음</p>
</li>
<li><p><code>@ComponentScan</code>이 실행되지 않음 → scheduler 패키지를 스캔하지 않음</p>
</li>
<li><p><code>AssetSyncScheduler</code>에 <code>@Component</code>가 붙어 있어도 <strong>빈으로 등록되지 않음</strong></p>
</li>
<li><p><code>@EnableScheduling</code>도 없으니 <code>ScheduledAnnotationBeanPostProcessor</code>가 등록되지 않음</p>
</li>
<li><p><code>@Scheduled</code>를 읽어줄 후처리기가 없어 애노테이션이 <strong>그냥 무시됨</strong></p>
</li>
</ol>
<p>즉, 이중 안전장치입니다. 스케줄러 빈이 등록조차 되지 않고, 설령 어쩌다 등록되더라도 그걸 읽어줄 담당자가 없습니다.</p>
<blockquote>
<p>애노테이션은 스스로 아무 일도 하지 않습니다. <code>@Scheduled</code>는 표시일 뿐이고, 그것을 읽어 스케줄러에 등록해주는 후처리기가 있어야 동작합니다. <code>@EnableScheduling</code>이 하는 일이 바로 그 후처리기를 등록하는 것입니다.</p>
</blockquote>
<p>&nbsp;</p>
<h3 id="🧑🏻🔧-스케줄러는-언제-만-알고-무엇을-은-위임한다">🧑🏻‍🔧 스케줄러는 *&quot;언제&quot;* 만 알고 *&quot;무엇을&quot;* 은 위임한다</h3>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class AssetSyncScheduler {

    private final AssetSyncService assetSyncService;

    @Scheduled(cron = &quot;0 0 4 * * *&quot;, zone = &quot;Asia/Seoul&quot;)
    public void syncAllMembers() {
        log.info(&quot;[배치] 자산 동기화 시작&quot;);
        assetSyncService.syncAll();
        log.info(&quot;[배치] 자산 동기화 종료&quot;);
    }
}</code></pre>
<p>스케줄러에는 비즈니스 로직이 없습니다. 언제 실행할지만 알고 무엇을 할지는 서비스에 위임합니다. 덕분에 API 서버에서 수동 동기화 엔드포인트를 만들 때 동일한 <code>AssetSyncService</code>를 그대로 재사용할 수 있습니다. Service와 Mapper는 프로필과 무관하게 양쪽 모두 로딩되기 때문입니다.</p>
<p>&nbsp;</p>
<h3 id="🔄-배치-서버의-생애">🔄 배치 서버의 생애</h3>
<pre><code>1. Tomcat 기동 (batch 프로필)
2. Spring 컨텍스트 초기화
3. BatchConfig 로드
4. @EnableScheduling → 스케줄러 스레드 풀 시작
5. AssetSyncScheduler 빈 등록
6. [대기…  매일 04:00]
7. syncAllMembers() → syncAll() 실행</code></pre><p>API 서버가 HTTP 요청을 기다리는 것과 달리, 배치 서버는 이 스레드를 살려두는 것 자체가 목적입니다. 사용자 요청을 기다리는 게 아니라, 자기 안의 시계를 보다가 시간이 되면 스스로 실행하는 구조입니다. 운영 환경에서는 별도 인스턴스에 <code>batch</code> 프로필로만 띄우고, 외부 포트를 열지 않은 채 아웃바운드 호출만 하도록 구성합니다.</p>
<p>&nbsp;</p>
<h3 id="❓-환경-축은-아직-여지로-남겨둔다">❓ 환경 축은 아직 여지로 남겨둔다</h3>
<p>현재 프로젝트에는 <code>@Profile(&quot;local&quot;)</code>이 붙은 빈이 아직 없습니다. 외부 API를 환경별로 분기해야 할 때 아래처럼 활용할 예정입니다.</p>
<pre><code class="language-java">@Component @Profile(&quot;local&quot;) class CodefMockClient implements CodefClient { }
@Component @Profile(&quot;prod&quot;)  class CodefRealClient implements CodefClient { }</code></pre>
<p>시연 중 외부 API 장애가 나도 프로필 전환만으로 더미 응답으로 대체할 수 있다는 점이 실용적입니다.</p>
<hr>
<h2 id="🌈-정리">🌈 정리</h2>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@Profile(&quot;x&quot;)</code></td>
<td><code>x</code> 프로필이 활성일 때만 빈 등록</td>
</tr>
<tr>
<td>실체</td>
<td><code>@Conditional(ProfileCondition.class)</code>의 특수 케이스</td>
</tr>
<tr>
<td>판정 시점</td>
<td>설정 클래스 파싱 단계에서 <strong>기동 시 1회</strong> (런타임 <code>if</code> 아님)</td>
</tr>
<tr>
<td>클래스에 붙이면</td>
<td>내부 <code>@Bean</code> · <code>@Import</code> · <code>@ComponentScan</code> 전부 종속</td>
</tr>
<tr>
<td>표현식</td>
<td><code>!</code> <code>&amp;</code> `</td>
</tr>
<tr>
<td>배열 전달</td>
<td>OR 조건 (<code>@Profile({&quot;a&quot;,&quot;b&quot;})</code>)</td>
</tr>
<tr>
<td>기본 프로필</td>
<td>활성 프로필이 하나도 없을 때만 <code>default</code> 적용</td>
</tr>
<tr>
<td>다중 프로필</td>
<td>역할(api/batch) + 환경(local/prod) 조합 가능</td>
</tr>
<tr>
<td><code>Environment</code> 역할 분담</td>
<td>&quot;등록 여부&quot;는 <code>@Profile</code>, &quot;값 조회&quot;는 <code>Environment</code></td>
</tr>
<tr>
<td>프로퍼티 우선순위</td>
<td>서블릿 환경은 5계층, 먼저 찾은 값이 이김(덮어쓰기)</td>
</tr>
<tr>
<td><code>@EnableScheduling</code> 위치</td>
<td>BatchConfig 안에 두면 api 프로필에서 스케줄러 완전 차단</td>
</tr>
</tbody></table>
<p>Spring Profile은 하나의 코드베이스로 여러 실행 형태를 가능하게 하는 도구입니다. 환경 분기와 역할 분기를 조합하면, 복잡한 배포 구조도 코드 변경 없이 VM 옵션 하나로 제어할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[25초를 0.03초(?)로: 자산 동기화를 비동기로 전환]]></title>
            <link>https://velog.io/@rocker_nun/25%EC%B4%88%EB%A5%BC-0.03%EC%B4%88%EB%A1%9C-%EC%9E%90%EC%82%B0-%EB%8F%99%EA%B8%B0%ED%99%94%EB%A5%BC-%EB%B9%84%EB%8F%99%EA%B8%B0%EB%A1%9C-%EC%A0%84%ED%99%98</link>
            <guid>https://velog.io/@rocker_nun/25%EC%B4%88%EB%A5%BC-0.03%EC%B4%88%EB%A1%9C-%EC%9E%90%EC%82%B0-%EB%8F%99%EA%B8%B0%ED%99%94%EB%A5%BC-%EB%B9%84%EB%8F%99%EA%B8%B0%EB%A1%9C-%EC%A0%84%ED%99%98</guid>
            <pubDate>Wed, 05 Aug 2026 06:06:19 GMT</pubDate>
            <description><![CDATA[<p>MZ세대의 독립을 위한 자산 관리 서비스를 만들면서 CODEF API로 사용자의 금융 자산을 연동하는 기능을 맡았습니다. 기능은 동작했지만 응답에 평균 25.87초가 걸리는 동안 사용자가 할 수 있는 부분이 없었고, 이걸 0.029초로 줄인(?) 경험입니다.</p>
<h1 id="🫯-문제-상황-25초-동안의-침묵">🫯 문제 상황: 25초 동안의 침묵</h1>
<p>자산 동기화는 사용자가 *&quot;새로고침&quot;* 버튼을 누르면 연동된 금융기관에서 최신 계좌 정보를 다시 가져오는 기능입니다. 총 11개의 계좌를 연동한 계정으로 아래와 같이 동기화 API에 POST HTTP 요청을 보내고 응답이 오기까지 측정한 결과, 평균 25초 정도가 걸렸습니다.</p>
<pre><code>1. POST /api/v1/assets/sync?memberId=1
2. 평균 25초 대기...
3. 응답 200 OK  { &quot;syncedCount&quot;: ?, &quot;failedCount&quot;: ?, ... }</code></pre><table>
<thead>
<tr>
<th>회차</th>
<th>응답 시간</th>
</tr>
</thead>
<tbody><tr>
<td>첫 번째 동기화 시도</td>
<td>25.10초</td>
</tr>
<tr>
<td>두 번째 동기화 시도</td>
<td>29.40초</td>
</tr>
<tr>
<td>세 번째 동기화 시도</td>
<td>23.11초</td>
</tr>
<tr>
<td><strong>평균</strong></td>
<td><strong>25.87초</strong></td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h2 id="😖-왜-이렇게-느린가">😖 왜 이렇게 느린가</h2>
<p>CODEF는 실제 금융기관에 로그인해서 데이터를 긁어오는 서비스입니다. 호출하는 순간 CODEF 서버가 은행 사이트에 접속하고, 인증하고, 계좌 목록을 조회합니다. 어찌보면 오래 걸리는게 당연합니다.</p>
<p>기존 서비스 코드는 연동된 기관을 순차적으로 호출하고 있었습니다.</p>
<pre><code>우리은행 조회 → 삼성증권 조회 → KB증권 조회 = 25초</code></pre><p>평균 25.87초에 달하는 응답 지연은 사용자 경험(UX) 저하에 그치지 않고 백엔드 아키텍처 전체에 걸쳐 복합적인 스레드 고갈 및 네트워크 안전성 문제를 야기할 수 있습니다.</p>
<p>첫째, 서블릿 컨테이너(Apache Tomcat)의 스레드 고갈 현상입니다. Spring MVC 기반의 서블릿 애플리케이션은 요청 하나당 하나의 워커 스레드를 할당하는 Thread-per-Request 모델로 작동하는데, 스레드 풀의 크기가 한정된 상태에서 단일 동기화 요청이 25초 이상 서블릿 스레드를 차단하게 되면, 동시 동기화 요청 수에 비례하여 워커 스레드가 고갈됩니다. 이는 자산 동기화와 무관한 시스템 내 다른 조회 및 저장 API 요청까지 대기 상태로 만드는 파급 효과를 가져올 수 있었습니다.</p>
<p>둘째, 클라이언트 커넥션의 불안정성입니다. 25초 동안 HTTP 소켓 연결이 유지되는 과정에서 모바일 네트워크 환경의 변화나 Wi-Fi 연결이 끊긴다면 클라이언트는 응답을 받지 못하고 요청 실패로 인식합니다. 하지만 백엔드 서버는 클라이언트와의 소켓이 끊어진 후에도 작업을 계속 수행하므로, 작업은 정상 완료되었음에도 사용자는 실패로 오인하여 요청을 재시도하는 상태 불일치가 일어납니다.</p>
<p>셋째, 클라이언트 UI/UX의 전면적인 블로킹입니다. 25초 동안 화면은 로딩 상태에 묶이게 되며, 사용자의 다른 화면 이동이나 애플리케이션 이탈 시 HTTP 요청이 취소될 수 있어 서비스 이탈률을 높이는 원인이 될 수 있습니다.</p>
<p>&nbsp;</p>
<h1 id="👍-기다리게-하지-말고-나중에-확인하게-하자">👍 기다리게 하지 말고, 나중에 확인하게 하자</h1>
<p>25초를 줄이는 접근은 크게 두 가지로 생각할 수 있었습니다.</p>
<ol>
<li><p><strong>작업 자체를 빠르게:</strong> 기관별 호출을 병렬화하면 25초가 10초 정도로 줄어듭니다. 하지만 CODEF가 실제 은행에 접속하는 시간은 우리가 어쩔 수 없습니다. 10초도 여전히 길다고 느낄 사용자가 있을 수 있고 기관이 늘어나면 다시 길어져서 사용자 경험을 저하시킬 것입니다.</p>
</li>
<li><p><strong>기다리지 않게:</strong> 요청을 받자마자 접수했다고 응답하고, 실제 작업은 백그라운드에서 처리합니다. 클라이언트는 나중에 결과를 확인하면 됩니다.</p>
</li>
</ol>
<p>저는 후자를 택했습니다. 근본 원인(외부 API가 느리다는 것)을 없앨 수 없다면, 그 대기를 사용자에게 전가하지 않는 것이 맞다고 판단했기 때문입니다.</p>
<p>&nbsp;</p>
<h2 id="🛠️-기술-선택-왜-async인가">🛠️ 기술 선택: 왜 @Async인가</h2>
<p>비동기 처리에는 여러 선택지가 있었습니다.</p>
<h3 id="1-webclient-→-배제">1. WebClient → 배제</h3>
<p><code>WebClient</code>는 리액티브 환경에서 쓰는 논블로킹 HTTP 클라이언트입니다. 하지만 우리 서비스는 Spring MVC(서블릿 기반) 구조입니다.</p>
<p>서블릿은 요청 하나당 스레드 하나가 할당되는 블로킹 모델이라, <code>WebClient</code>를 써도 결국 <code>.block()</code>으로 결과를 기다려야 합니다. 그러면 <code>RestTemplate</code>과 동작이 같아지고 의존성만 늘어나기 때문에 도입할 이유가 없었습니다.</p>
<p>리액티브의 이점은 전 구간이 논블로킹일 때 나옵니다. 중간에 블로킹이 하나라도 끼면 그 지점에서 스레드가 묶여 이점이 사라집니다.</p>
<h3 id="2-kafka--rabbitmq-→-배제">2. Kafka / RabbitMQ → 배제</h3>
<p>메시지 큐는 서비스 간 비동기 통신이나 대용량 이벤트 처리에 적합한 도구입니다.</p>
<p>우리 서비스는 단일 모놀리식 서버에서 하나의 비동기 작업을 처리하려는 것뿐입니다. 여기에 브로커를 도입하면 토픽 관리, 컨슈머 설정, 브로커 운영이라는 비용이 따라오는데 얻는 이점보다 훨씬 큽니다. 그리고 t3.small 인스턴스에 브로커를 하나 더 올릴 여유도 없었습니다.</p>
<h3 id="3-async--threadpooltaskexecutor-→-채택">3. @Async &amp; ThreadPoolTaskExecutor → 채택!!!</h3>
<p>일단 Spring이 기본 제공하는 기능이라 추가 의존성이 없습니다. 그리고 스레드 풀을 명시적으로 등록해 스레드 수를 제어하고 다른 비동기 작업과 격리할 수 있습니다.</p>
<p>여기서 중요한 점은 Spring의 기본 실행기인 <code>SimpleAsyncTaskExecutor</code>는 요청마다 새 스레드를 만들고 재사용하지 않는다는 것입니다. 그대로 두면 동시 요청이 몰릴 때 스레드가 무한히 생성될 우려가 있어서 전용 풀을 등록하기로 결정했습니다.</p>
<pre><code class="language-java">@Bean(&quot;assetSyncExecutor&quot;)
public Executor assetSyncExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(2);  // 상시 대기: 동시 2건은 즉시 처리
    executor.setMaxPoolSize(5);  // 최대: 요청이 몰려도 5건까지
    executor.setQueueCapacity(10);  // 대기열: 풀이 차도 10건까지 대기
    executor.setThreadNamePrefix(&quot;asset-sync-&quot;);
    executor.initialize();
    return executor;
}</code></pre>
<table>
<thead>
<tr>
<th>설정</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>corePoolSize</td>
<td>기본적으로 유지되는 스레드 수</td>
</tr>
<tr>
<td>maxPoolSize</td>
<td>큐가 가득 찼을 때 확장 가능한 최대 스레드 수</td>
</tr>
<tr>
<td>queueCapacity</td>
<td>코어 스레드가 모두 사용 중일 때 작업이 대기하는 큐의 크기</td>
</tr>
<tr>
<td>threadNamePrefix</td>
<td>스레드 이름 접두사 (로그에서 비동기 스레드를 식별할 때 유용)</td>
</tr>
<tr>
<td>keepAliveSeconds</td>
<td>코어 수를 초과한 유휴 스레드가 제거되기까지의 대기 시간</td>
</tr>
<tr>
<td>rejectedExecutionHandler</td>
<td>큐와 스레드 모두 가득 찼을 때의 거부 정책</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<h2 id="✈️-rest-long-running-task-패턴을-이용한-api-설계">✈️ REST Long-Running Task 패턴을 이용한 API 설계</h2>
<p>HTTP의 <code>202 Accepted</code> 상태 코드가 제가 선택한 방법의 의도와 정확히 일치했습니다.</p>
<blockquote>
<p><code>202 Accepted</code>: 요청은 접수되었으나 처리가 완료되지 않았음. (RFC 9110, HTTP Semantics)</p>
</blockquote>
<p>&nbsp;</p>
<h3 id="⬅️-before">⬅️ Before</h3>
<pre><code>POST /api/v1/assets/sync?memberId=1
    ↓ 25초 대기
200 OK { &quot;syncedCount&quot;: ?, &quot;failedCount&quot;: ?, &quot;institutions&quot;: [...] }</code></pre><p>&nbsp;</p>
<h3 id="➡️-after">➡️ After</h3>
<pre><code>POST /api/v1/assets/sync?memberId=1
    ↓ 즉시
202 Accepted
Location: http://.../api/v1/assets/sync/status/900b55dd-...
{ &quot;success&quot;: true, &quot;data&quot;: { &quot;jobId&quot;: &quot;900b55dd-...&quot; } }</code></pre><pre><code>GET /api/v1/assets/sync/status/900b55dd-...
    ↓ 진행 중
200 OK { &quot;data&quot;: { &quot;status&quot;: &quot;PENDING&quot;, &quot;errorMessage&quot;: null, &quot;resultUrl&quot;: null } }

    ↓ 완료 후
200 OK { &quot;data&quot;: { &quot;status&quot;: &quot;SUCCESS&quot;, &quot;errorMessage&quot;: null, &quot;resultUrl&quot;: &quot;/api/v1/assets/summary/1&quot; } }</code></pre><p><img src="https://velog.velcdn.com/images/rocker_nun/post/52965146-1fc3-4e14-8033-62091db03a63/image.png" alt=""></p>
<h3 id="🎛️-패턴의-구성-요소">🎛️ 패턴의 구성 요소</h3>
<table>
<thead>
<tr>
<th>요소</th>
<th>구현</th>
</tr>
</thead>
<tbody><tr>
<td>즉시 응답</td>
<td><code>ResponseEntity.accepted()</code> → 202</td>
</tr>
<tr>
<td>폴링 URL 안내</td>
<td><code>Location</code> 헤더</td>
</tr>
<tr>
<td>상태 조회</td>
<td><code>GET /assets/sync/status/{jobId}</code></td>
</tr>
<tr>
<td>결과 위치 안내</td>
<td><code>resultUrl</code> 필드</td>
</tr>
<tr>
<td>실패 사유</td>
<td><code>errorMessage</code> 필드</td>
</tr>
</tbody></table>
<p><strong><code>Location</code> 헤더를 넣는 이유가 중요합니다.</strong> 클라이언트가 <code>jobId</code>만 받으면 폴링 URL을 직접 조립해야 합니다. 서버가 URL을 알려주면 클라이언트는 URL 구조를 몰라도 되고, 나중에 경로가 바뀌어도 클라이언트 코드를 고칠 필요가 없습니다.</p>
<p><code>resultUrl</code>도 같은 원리입니다. 완료 후 어디서 결과를 가져올지 서버가 알려주니, API 경로 변경이 클라이언트로 전파되지 않습니다.</p>
<p>&nbsp;</p>
<h3 id="🪄-실제-프론트엔드-측-연동">🪄 실제 프론트엔드 측 연동</h3>
<pre><code class="language-jsx">async function pollAssetSyncStatus(jobId) {
  const deadline = Date.now() + SYNC_POLL_TIMEOUT_MS

  while (Date.now() &lt; deadline) {
    const response = await getAssetSyncStatus(jobId)
    const { status, errorMessage } = response.data

    if (status === SYNC_STATUS.SUCCESS) return
    if (status === SYNC_STATUS.FAILED) {
      throw new Error(errorMessage ?? &#39;자산 동기화에 실패했어요.&#39;)
    }

    await wait(SYNC_POLL_INTERVAL_MS)
  }

  throw new Error(&#39;자산 동기화가 지연되고 있어요. 잠시 후 다시 시도해주세요.&#39;)
}

...

export async function getAssetSyncStatus(jobId) {
  const { data } = await httpClient.get(`/api/v1/assets/sync/status/${jobId}`)
  return data
}</code></pre>
<blockquote>
<p>왜 폴링인가? SSE나 WebSocket으로 서버가 완료를 밀어줄 수도 있습니다. 다만 우리 상황에선 폴링이 나았습니다. 작업이 25초 내외로 끝나 폴링 횟수가 10여 회에 불과하고, 연결을 오래 유지하지 않아 서버가 상태를 들고 있지 않아도 되며(무상태 유지), 프론트 구현도 단순합니다. 실시간성이 중요한 채팅 같은 기능이라면 반대로 선택했을 겁니다.</p>
</blockquote>
<p>&nbsp;</p>
<h2 id="🫢-트러블슈팅-async가-동작하지-않는다">🫢 트러블슈팅: @Async가 동작하지 않는다?</h2>
<p>가장 많이 시간을 쓴 부분입니다. <code>@Async</code>를 붙였는데 비동기로 동작하지 않았습니다. 여전히 25초를 기다렸습니다.</p>
<pre><code class="language-java">// ❌ 동작하지 않는 구조
@Service
public class AssetSyncServiceImpl implements AssetSyncService {

    public SyncJobResponse startSync(Long memberId) {
        String jobId = jobStore.createJob();
        this.doSyncAsync(memberId, jobId);  // 자기 호출
        return SyncJobResponse.builder().jobId(jobId).build();
    }

    @Async(&quot;assetSyncExecutor&quot;)  // 무시됨
    public void doSyncAsync(Long memberId, String jobId) {
        syncAccounts(memberId);
    }
}</code></pre>
<p>&nbsp;</p>
<h3 id="🫵🏻-원인-프록시를-거치지-않는-자기-호출">🫵🏻 원인: 프록시를 거치지 않는 자기 호출</h3>
<p>Spring의 <code>@Async</code>는 AOP 프록시로 동작합니다. 스프링은 <code>@Async</code>가 붙은 빈을 감싼 프록시 객체를 만들고, 그 프록시가 메서드 호출을 가로채 스레드 풀에 넘깁니다.</p>
<pre><code>정상 흐름
호출자 → [프록시] → 실제 객체
          ↑ 여기서 스레드 분기

자기 호출
실제 객체 내부에서 this.method()
                      ↑ 프록시를 안 거침 → 그냥 일반 메서드 호출</code></pre><p><code>this.doSyncAsync()</code>는 프록시가 아니라 원본 객체의 메서드를 직접 부릅니다. 프록시가 개입할 틈이 없으니 <code>@Async</code>는 아무 일도 하지 않습니다. 이건 <code>@Transactional</code>에서도 똑같이 발생하는 전형적인 문제입니다. 프록시 기반 AOP는 빈 경계를 넘을 때만 동작한다는 원리를 이해하면 두 경우 모두 설명됩니다.</p>
<p>&nbsp;</p>
<h3 id="🤩-해결-호출-경계를-빈-경계와-일치시키기">🤩 해결: 호출 경계를 빈 경계와 일치시키기</h3>
<p><code>@Async</code>가 반드시 프록시를 통과하도록 역할을 세 개의 빈으로 분리했습니다.</p>
<pre><code>[AssetController]
      ↓
[AssetSyncJobStarter]   jobId 생성 → Runner에 위임 → 즉시 리턴
      ↓  다른 빈 호출 → 프록시 통과
[AssetSyncJobRunner]    @Async 적용. 여기서 스레드가 분기됨
      ↓  다른 빈 호출 → 프록시 통과
[AssetSyncServiceImpl]  기존 @Transactional 동기화 로직 그대로</code></pre><p><code>jobRunner.runAsync()</code>를 호출하는 순간 <code>AssetSyncJobRunner</code>의 프록시가 개입해 <code>assetSyncExecutor</code> 풀에 작업을 넘기고 즉시 리턴합니다. <code>AssetSyncJobStarter.start()</code>는 <code>jobId</code>를 반환하고, 컨트롤러는 202를 바로 응답합니다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/1d547172-d463-45fc-a01a-97cd726820c0/image.png" alt=""></p>
<p>부수 효과도 좋았습니다. Runner가 Service를 프록시를 통해 호출하므로 기존 <code>@Transactional</code>도 정상 적용됩니다. 트랜잭션 경계를 건드리지 않고 비동기만 얹은 셈입니다.</p>
<p>책임을 나누려고 클래스를 쪼갠 게 아니라 프록시가 개입할 지점을 만들려고 쪼갰습니다. 처음엔 메서드 하나 때문에 클래스를 셋으로 나누는 게 과한가 싶었는데, 결과적으로 역할도 명확해졌습니다.</p>
<ul>
<li><code>Starter</code>: 작업 접수와 식별자 발급</li>
<li><code>Runner</code>: 비동기 실행과 상태 갱신</li>
<li><code>Service</code>: 실제 동기화 로직</li>
</ul>
<p>&nbsp;</p>
<h2 id="💾-작업-상태는-어디에-저장하나">💾 작업 상태는 어디에 저장하나</h2>
<p>비동기로 바꾸면 새로운 요구가 생깁니다. <strong>*&quot;이 작업이 지금 어디까지 진행됐는지&quot;*</strong> 를 어딘가에 저장해야 폴링 요청이 읽을 수 있습니다.</p>
<p>가장 단순한 방법은 그냥 <code>ConcurrentHashMap</code>를 사용하는 것이었습니다. 하지만 우리 인프라에서는 가용 영역을 두 곳을 두고 각각 API 서버를 배치하기 때문에 의미가 없었습니다.</p>
<pre><code>동기화 요청 → ALB → API 서버 #1  (자기 메모리에 상태 저장)
폴링 요청 → ALB → API 서버 #2  (그런 작업 없음)</code></pre><p>로드밸런서가 요청을 분산하니 폴링이 다른 서버로 갈 수 있습니다. 두 서버는 별개의 JVM 프로세스라 당연히 서로의 메모리를 볼 수 없습니다. 서버가 한 대였다면 충분했을 방법이, 이중화하면서 쓸 수 없게 된 것입니다.</p>
<p>&nbsp;</p>
<h3 id="😁-redis를-선택한-이유">😁 Redis를 선택한 이유</h3>
<p>Redis는 애플리케이션 안의 라이브러리가 아니라 <strong>독립적으로 떠 있는 별도 서버</strong>입니다. 세 서버(API 서버 2대, 배치 서버 1대)가 같은 엔드포인트를 바라보니 자연스럽게 공유됩니다. MySQL을 공유하는 것과 같은 원리인데, 메모리 기반이라 훨씬 빠릅니다.</p>
<p>DB 컬럼에 저장할 수도 있었지만 두 가지가 걸렸습니다. 폴링이 2초마다 오는데 매번 DB를 때리는 부담이 있고, 진행 상태처럼 곧 사라질 임시 데이터를 영구 저장소에 두는 것도 과하다고 생각했습니다.</p>
<p>&nbsp;</p>
<h3 id="🗝️-키-설계">🗝️ 키 설계</h3>
<pre><code>asset:sync:job:{jobId}:status → PENDING / SUCCESS / FAILED (TTL 10분)
asset:sync:job:{jobId}:error → 에러 메시지 (FAILED일 때만) (TTL 10분)
asset:sync:job:{jobId}:result → 결과 조회 URL (SUCCESS일 때만) (TTL 10분)</code></pre><p>동기화 완료 후 클라이언트가 상태를 확인할 시간은 충분히 주되, 완료된 작업이 Redis에 무기한 누적되는 건 막아야 했기 때문에 TTL을 10분으로 설정했습니다. </p>
<p>만료된 <code>jobId</code>는 404로 처리했습니다. Redis에 키가 없으면 만료됐거나 존재하지 않는 작업이므로 <code>ASSET_SYNC_JOB_NOT_FOUND</code>를 반환합니다. TTL을 쓰면 *&quot;없음&quot;* 과 *&quot;만료됨&quot;* 이 구분되지 않는데, 클라이언트 입장에선 어차피 결과를 못 받는 건 같으니 하나로 합쳤습니다.</p>
<p>&nbsp;</p>
<h2 id="🎉-결과">🎉 결과</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>기존 (동기)</th>
<th>변경 후 (비동기)</th>
</tr>
</thead>
<tbody><tr>
<td>1회차</td>
<td>25.10초</td>
<td>0.029초</td>
</tr>
<tr>
<td>2회차</td>
<td>29.40초</td>
<td>0.031초</td>
</tr>
<tr>
<td>3회차</td>
<td>23.11초</td>
<td>0.028초</td>
</tr>
<tr>
<td><strong>평균</strong></td>
<td><strong>25.87초</strong></td>
<td><strong>0.029초</strong></td>
</tr>
</tbody></table>
<p>응답 시간이 약 890배 빨라졌습니다(?). 다만 정확히 말하면 작업 자체가 빨라진 게 아닙니다. CODEF 호출은 여전히 25초가 걸립니다. 달라진 건 그 25초를 사용자가 기다리지 않게 된 것입니다. 동기화가 도는 동안 다른 화면을 보거나 앱을 닫아도 됩니다. 네트워크가 끊겨도 결과를 확인할 수 있습니다. <code>jobId</code>만 있으면 다시 접속해 상태를 조회하면 됩니다. 이게 사실 응답 속도보다 중요한 개선이라고 생각합니다.</p>
<p>&nbsp;</p>
<h2 id="📝-남은-과제">📝 남은 과제</h2>
<p><strong>중복 요청 처리:</strong> 응답이 즉시 오니 사용자가 버튼을 여러 번 누르기 더 쉬워졌습니다. 회원 단위 분산 락으로 중복 실행을 막고 있지만, 이미 진행 중인 작업이 있으면 새 <code>jobId</code>를 발급하는 대신 기존 <code>jobId</code>를 돌려주는 방식이 더 자연스러울 것 같습니다.</p>
<p><strong>서버 재시작 시 유실:</strong> 백그라운드 작업 도중 서버가 내려가면 그 작업은 사라지고 상태는 <code>PENDING</code>인 채 TTL로 만료됩니다. 클라이언트는 영원히 완료를 못 봅니다. 작업에 시작 시각을 남기고 일정 시간이 지나면 <code>FAILED</code>로 간주하는 처리가 필요합니다.</p>
<p><strong>기관별 병렬 호출:</strong> 지금도 백그라운드에서는 25초가 걸립니다. 기관별 호출을 병렬화하면 10초 정도로 줄어들 텐데, 사용자 체감에는 영향이 없어 우선순위를 낮췄습니다. 다만 CODEF의 호출 제한을 확인한 뒤 적용해볼 만합니다.</p>
<p><strong><code>@Async</code> 예외 처리:</strong> 반환 타입이 <code>void</code>인 <code>@Async</code> 메서드에서 예외가 나면 <strong>호출자에게 전파되지 않고 삼켜집니다.</strong> 지금은 Runner 내부에서 try-catch로 잡아 Redis에 <code>FAILED</code>를 기록하고 있지만, <code>AsyncUncaughtExceptionHandler</code>를 등록해 두는 편이 더 안전합니다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em></p>
<ul>
<li><a href="https://www.rfc-editor.org/rfc/rfc9110#name-202-accepted">RFC 9110 — HTTP Semantics, 15.3.3. 202 Accepted</a></li>
<li><a href="https://restfulapi.net/rest-api-design-for-long-running-tasks/">REST API Design for Long-Running Tasks</a></li>
<li><a href="https://docs.spring.io/spring-framework/reference/integration/scheduling.html">Spring Framework Reference — Task Execution and Scheduling</a></li>
<li><a href="https://devpanpan.tistory.com/106">Long-Running 작업을 다루기 위한 지침들</a></li>
<li><a href="https://dmoritle.tistory.com/197">[Spring] Spring의 비동기 처리 — @Async부터 Virtual Thread 통합까지</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[최소 권한 원칙과 보안 그룹]]></title>
            <link>https://velog.io/@rocker_nun/%EC%B5%9C%EC%86%8C-%EA%B6%8C%ED%95%9C-%EC%9B%90%EC%B9%99%EA%B3%BC-%EB%B3%B4%EC%95%88-%EA%B7%B8%EB%A3%B9</link>
            <guid>https://velog.io/@rocker_nun/%EC%B5%9C%EC%86%8C-%EA%B6%8C%ED%95%9C-%EC%9B%90%EC%B9%99%EA%B3%BC-%EB%B3%B4%EC%95%88-%EA%B7%B8%EB%A3%B9</guid>
            <pubDate>Thu, 30 Jul 2026 04:41:41 GMT</pubDate>
            <description><![CDATA[<h1 id="👁️-최소-권한의-원칙이란">👁️ 최소 권한의 원칙이란?</h1>
<p>보안에서 <strong>최소 권한의 원칙(PoLP, Principle of Least Privilege)</strong>이라는 아주 중요한 법칙이 있다. 이 원칙은 다양한 클라우드 플랫폼에서 필수적인 보안 정책으로 활용되고 있다. 이 원칙은 말 그대로 무언가에 접근할 때 필요한 최소한의 권한만을 가지도록 제한하는 보안 원칙이다.</p>
<p>그럼 지금까지 만들어놓은 인스턴스들이 최소 권한의 원칙을 제대로 지키고 있는지 직접 확인해보도록 하자. Bastion Host의 인바운드 규칙을 살펴보면, 모든 IP에 대해 22번, 80번 포트로 접속할 수 있도록 허용해 둔 상태다. 즉, 어떤 곳에서든 Bastion Host의 SSH로 접속할 수 있도록 권한이 설정되어 있다는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/6191c3a2-5139-48cf-8107-9c847269f62f/image.png" alt=""></p>
<p>근데 생각해보면 모든 사용자가 Bastion Host로 접속할 수 있도록 하면 안 된다. Bastion Host를 통해 EC2나 RDS 같은 자원에 접속해야 하는 어쩔 수 없는 상황에서, 관련된 관리자만 접속할 수 있도록 해야 한다. 지금은 최소 권한의 원칙을 어기고 있는 상황인 것이다.</p>
<p>&nbsp;</p>
<h2 id="🔒-보안-그룹이란">🔒 보안 그룹이란?</h2>
<p><strong>보안 그룹(Security Group)</strong>은 AWS 클라우드에서의 네트워크 보안을 뜻한다. AWS 리소스(EC2, RDS, ALB 등)를 집이라고 가정한다면, 보안 그룹은 집 주변에 설치된 울타리와 대문이라고 말할 수 있다. 바깥쪽에서 보안 그룹 안쪽으로 들어갈 때나 보안 그룹에서 바깥쪽으로 나올 때, 대문에서 지나가도 되는 트래픽인지를 검사하는 것이다. </p>
<p>보안 그룹은 보안 그룹의 규칙을 지정할 수 있다. 이 보안 그룹의 규칙에서는 트래픽으로 크게 2가지를 얘기하고 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f3420530-9252-4785-9863-8c180e55e8c3/image.png" alt=""></p>
<ul>
<li><strong>Inbound Traffic</strong>: 보안 그룹의 외부에서 보안 그룹 안쪽으로 들어가는 트래픽</li>
<li><strong>Outbound Traffic</strong>: 보안 그룹 안쪽에서 보안 그룹의 외부로 빠져나가는 트래픽</li>
</ul>
<p>따라서 각각의 경우에 대해 허용할 IP 범위와 포트를 가지고 설정해야 한다. 보안 그룹은 하나의 AWS 리소스에 대해서도 설정 가능하고, 여러 개의 리소스에 대해서도 보안 그룹을 묶어서 설정할 수도 있고, 심지어는 서브넷이나 가용 영역에 관계없이 크게 묶어서 보안 그룹을 설정할 수도 있다. 이제 기존에 생성해놓은 다른 AWS 리소스들을 하나씩 살펴보면서 최소 권한의 원칙을 지켰는지 살펴보도록 하자. </p>
<p>&nbsp;</p>
<h3 id="🔑-보안-그룹의-두-가지-핵심-특성">🔑 보안 그룹의 두 가지 핵심 특성</h3>
<p>리소스별 규칙을 만지기 전에, 보안 그룹의 중요한 특성 두 가지를 짚고 넘어가자. 이 두 가지를 알면 앞으로 왜 인바운드 규칙만 계속 건드리는지, 왜 IP 대신 다른 보안 그룹을 소스로 지정하는지가 자연스럽게 이해된다.</p>
<p><strong>① 보안 그룹은 Stateful하다.</strong>
인바운드로 허용된 요청에 대한 응답 트래픽은 아웃바운드 규칙과 상관없이 자동으로 허용된다. 예를 들어 ALB가 인바운드로 받은 HTTP 요청을 처리하고 응답을 돌려보낼 때, 그 응답은 아웃바운드 규칙을 따로 열어주지 않아도 나갈 수 있다. 그래서 이 글에서는 대부분 <strong>인바운드 규칙만</strong> 신경 쓰면 된다. (참고로 서브넷 단위로 동작하는 네트워크 ACL은 이와 달리 Stateless해서, 인바운드와 아웃바운드를 모두 명시해야 한다.)</p>
<p><strong>② 소스로 IP 대신 다른 보안 그룹을 지정할 수 있다.</strong>
인바운드 규칙의 소스에 특정 IP 대역이 아니라 다른 보안 그룹을 지정하면, 그 보안 그룹이 적용된 리소스로부터의 요청만 허용하겠다는 의미가 된다. 이 방식이 강력한 이유는, 백엔드 서버의 IP가 바뀌더라도(재배포, 오토스케일링 등) 규칙을 고칠 필요가 없기 때문이다. ALB로부터 오는 트래픽 허용처럼 <strong>관계 자체를 규칙으로 표현</strong>하기 때문에, 최소 권한과 유지보수성을 동시에 잡을 수 있다.</p>
<p>이제 기존에 생성해놓은 AWS 리소스들을 하나씩 살펴보면서 최소 권한의 원칙을 지켰는지 점검해보자.</p>
<p>&nbsp;</p>
<h3 id="🔀-로드-밸런서의-보안-규칙">🔀 로드 밸런서의 보안 규칙</h3>
<p>먼저 로드 밸런서를 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2015ff44-2445-4cf9-92b0-d72b81ddab42/image.png" alt=""></p>
<p>현재 ALB는 외부 사용자가 인터넷을 통해서 보내는 HTTP 요청을 받고 있다. </p>
<p>리스너 및 규칙을 살펴보면 ELB로 들어올 수 있는 요청은 HTTP 프로토콜, 즉 80번 포트 밖에 없기 때문에 보안 그룹의 인바운드 규칙도 HTTP 80번 포트에 대해서만 설정하면 된다. 또한, 로드 밸런서는 모든 사용자로부터 요청을 받을 수 있어야 하기 때문에 모든 IPv4에 대해서 트래픽을 허용하면 된다. 향후 HTTPS 요청을 받을 수 있다는 요구사항이 추가되면 그에 대해서도 보안 규칙을 설정해주면 된다. 지금은 최소 권한의 원칙에 따라 HTTPS에 대한 인바운드 규칙은 제거해주도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/677fb248-95e8-4d01-8eec-703afd390248/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/61deb457-b3f1-455f-93e3-997f35517fbd/image.png" alt=""></p>
<p>보안 그룹 수정 후 로드 밸런서로 요청을 보내보면 응답이 정상적으로 오는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/025dd4f8-55f6-4255-89c6-7ae5a3152337/image.png" alt=""></p>
<p>그럼 혹시 보안 그룹에 포함되지 않은 리소스가 요청을 보내면 어떻게 될까? 인바운드 규칙에서 HTTP에 대한 규칙도 지우고 다시 로드 밸런서에 요청을 보내면 아래와 같이 계속 로딩만 되다가 타임아웃이 발생하는 것을 볼 수 있다. 인프라를 구성하다가 이렇게 응답이 오지 않는다면 보안 규칙을 다시 한번 들여다 볼 필요가 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b574af71-cec5-4269-b6ec-10038709e461/image.png" alt=""></p>
<p><del>확인이 끝났으면 방금 지운 HTTP 80번 인바운드 규칙은 다시 복구해두자. (모든 IPv4 → HTTP 80)</del></p>
<p>&nbsp; </p>
<h3 id="👮🏻-bastion-host의-보안-규칙">👮🏻 Bastion Host의 보안 규칙</h3>
<p>이번에는 Bastion Host의 보안 그룹을 수정해보자. 현재 Bastion Host로 접근하는 주체는 인프라의 리소스들을 간접적으로 접근해서 관리해야 하는 관리자다. 따라서 인바운드 규칙에 개발자나 관리자의 IP 주소에 대해서만 허용해줘야 한다. 그리고 Bastion Host는 SSH로 접속하는 용도로만 쓰이기 때문에 22번 포트에 대해서만 인바운드 규칙을 추가해주도록 하자. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/925b70f1-604a-4887-b82b-eab4526c8038/image.png" alt=""></p>
<p>지금은 내 컴퓨터에서의 접근만 허용해주도록 설정했다. 다른 관리자의 접근도 허용하고 싶다면 그 관리자의 IP 주소도 아래에 추가해주면 된다.</p>
<p>이제 설정한 보안 그룹을 Bastion Host에 적용하고 확인해보면 잘 접속되는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f9ea4b18-cdf6-467e-bb08-bb764a9f1a66/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a484294e-1281-4ca2-8dd8-a4cc24633599/image.png" alt=""></p>
<p>&nbsp;</p>
<h3 id="💻-백엔드-서버의-보안-규칙">💻 백엔드 서버의 보안 규칙</h3>
<p>이제 Private Subnet에 있는 백엔드 서버들의 보안 규칙을 설정해보도록 하자. 일단 백엔드 서버로 접근해오는 경로들을 살펴보면 2가지다.</p>
<ol>
<li>Bastion Host가 EC2 인스턴스로 접근 (SSH 22번)</li>
<li>ALB가 사용자로부터 전달받은 트래픽을 EC2 인스턴스로 전달 (HTTP 80번)</li>
</ol>
<p>SSH로 접근하는 규칙은 앞서 생성해놓은 <strong>Bastion Host의 보안 그룹</strong>을 소스로, HTTP로 접근하는 규칙은 <strong>ALB의 보안 그룹</strong>을 소스로 지정해주면 된다. 앞서 설명한 소스로 다른 보안 그룹 지정 방식을 그대로 활용하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f6fff8fb-a2c3-40bc-b8ff-3c10222b1f2e/image.png" alt=""></p>
<p>이제 생성한 보안 그룹을 각각의 백엔드 서버의 보안 그룹으로 설정하고 요청이 제대로 오는지 직접 확인해보도록 하자. 먼저 ALB에서 요청이 오는 경우에 대해 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/50ce34da-6f59-4b79-854f-eff2b8e5a35c/image.png" alt=""></p>
<p>정상적으로 데이터가 잘 조회되는 것을 볼 수 있다. 다음은 Bastion Host에서 백엔드 서버로 직접 HTTP 요청을 보내봤을 때도 잘 응답하는지 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9984046e-7396-43b9-8a8e-0c0e5b4451c5/image.png" alt=""></p>
<p>응답이 오지 않는다. 현재 백엔드 서버의 HTTP 인바운드 규칙은 <strong>ALB 보안 그룹</strong>만 소스로 허용하고 있기 때문에, ALB를 거치지 않고 Bastion Host에서 직접 보낸 HTTP 요청은 차단되는 것이다. 보안 그룹에 막힌 것이 맞는지 확인하기 위해, 백엔드 서버의 HTTP 인바운드 규칙을 잠시 모든 요청으로부터 허용하도록 수정했더니 아래와 같이 응답이 잘 오는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c39384fe-f6b6-4e9d-b458-281a44c1eb21/image.png" alt=""></p>
<p>&nbsp;</p>
<h3 id="💾-rds-인스턴스의-보안-규칙">💾 RDS 인스턴스의 보안 규칙</h3>
<p>현재 RDS로 들어오는 요청들의 경로를 살펴보자.</p>
<ol>
<li>Bastion Host가 접근 (관리, 디버깅 목적)</li>
<li>백엔드 서버가 RDS 인스턴스에 접근 (실제 서비스 쿼리)</li>
</ol>
<p>RDS는 실제 사용자로부터 직접 요청을 받지 않고 백엔드 서버로부터만 요청을 받는다. 따라서 3306번 포트에 대해 허용해야 하는 주체는 <strong>Bastion Host와 백엔드 서버</strong> 둘뿐이다. 여기서도 IP가 아니라 보안 그룹을 소스로 지정한다. Bastion Host의 보안 그룹과 practice-server 보안 그룹을 각각 3306번 포트의 소스로 설정해주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2a69fc27-74fb-452c-9840-7c13c776840b/image.png" alt=""></p>
<p>확인해보면 RDS에 정상적으로 접근되는 것을 확인할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/8b6cd546-62f0-4749-a2a9-17664fcb1799/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AZ 하나가 통째로 죽어도 살아남는 서비스 만들기]]></title>
            <link>https://velog.io/@rocker_nun/AZ-%ED%95%98%EB%82%98%EA%B0%80-%ED%86%B5%EC%A7%B8%EB%A1%9C-%EC%A3%BD%EC%96%B4%EB%8F%84-%EC%82%B4%EC%95%84%EB%82%A8%EB%8A%94-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/AZ-%ED%95%98%EB%82%98%EA%B0%80-%ED%86%B5%EC%A7%B8%EB%A1%9C-%EC%A3%BD%EC%96%B4%EB%8F%84-%EC%82%B4%EC%95%84%EB%82%A8%EB%8A%94-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Thu, 30 Jul 2026 04:19:05 GMT</pubDate>
            <description><![CDATA[<h1 id="🏭-가용성이란">🏭 가용성이란?</h1>
<p><strong>가용성(Availability)</strong>이란 시스템이 서비스를 정상적으로 제공할 수 있는 가능성을 말한다. 가용성은 서비스의 정상 가동률(%)로 표시되는데, 예를 들어, 가용성이 99.99%라면 99.99%의 시간을 정상적으로 이용 가능한 시스템을 말한다. </p>
<p>고가용성 시스템을 만들기 위해서는 시스템을 이중화 시키는 것이 중요하다. 시스템 이중화란 시스템의 일부분을 사용할 수 없게 되어도 다른 시스템을 이용하여 서비스를 계속해서 이용할 수 있도록 만드는 것을 말한다. 고가용성 시스템을 만들기 위해 시스템 이중화뿐만 아니라 가용 영역에 대해서도 신경을 써야 한다. AWS는 자연 재해나 예상치 못한 사고로 데이터 센터가 파괴될 가능성을 고려해서 물리적으로 데이터 센터를 분리해놓았다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7d2c40b8-ae82-4806-a4c1-df4dd0ba37f9/image.png" alt=""></p>
<p>위 그림과 같이 분리된 각각의 데이터 센터를 가용 영역(AZ)이라고 한다. 이 가용 영역을 활용해서 시스템의 가용성을 높여 서비스의 장애 확률을 떨어뜨릴 수 있다. 지금까지 실습을 진행하면서는 가용 영역 1개 안에 모든 인프라를 구성했었다. 이러면 해당 가용 영역에 문제가 생겼을 때 복구되기 전까지는 서비스가 중단되는 치명적인 상황이 벌어질 수 있다. 따라서 안정성이 중요한 기업의 경우, 가용 영역을 2개 이상 활용하여 인프라를 구성한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/0bbe46f1-b4a6-4982-9b6f-3471b2d6db98/image.png" alt=""></p>
<p>위와 같이 가용 영역을 2개를 활용한 구조에서 만약 ap-northeast-2a 가용 영역에 장애가 나면 ap-northeast-2b 가용 영역에 있는 백엔드 서버로 요청이 가기 때문에 장애 확률이 떨어지는 것이다. </p>
<p>&nbsp;</p>
<h2 id="🏗️-가용-영역-추가하기">🏗️ 가용 영역 추가하기</h2>
<p>새로운 Public Subnet과 Private Subnet을 추가하고 가용 영역을 ap-northeast-2b로 설정해주자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/fcd03ccb-9e5e-466a-9297-20c42e993ad2/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/97bdf802-6908-4ae7-9a9d-de23b8e89cb7/image.png" alt=""></p>
<p>Internet Gateway는 이미 VPC에 붙여 놓았기 때문에 추가로 붙여줄 필요는 없다. 반면, NAT Gateway는 서브넷 단위로 동작한다.</p>
<p>여기서 한 가지 짚고 넘어갈 점이 있다. NAT Gateway는 <strong>가용 영역마다 하나씩</strong> 두는 것이 원칙이다. 만약 ap-northeast-2b의 Private Subnet이 ap-northeast-2a에 있는 NAT Gateway를 함께 사용하도록 구성하면, ap-northeast-2a 가용 영역에 장애가 발생했을 때 ap-northeast-2b의 아웃바운드 통신까지 함께 끊긴다. 애써 가용 영역을 나눈 의미가 사라지는 것이다. 따라서 <strong>AZ 간 장애를 격리하기 위해</strong> 새로운 가용 영역(ap-northeast-2b)에 대한 NAT Gateway를 별도로 생성해준다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/36a9008d-eb0b-4cd0-9a53-2716f36abd05/image.png" alt=""></p>
<p>기존에 생성된 퍼블릭 라우팅 테이블의 라우팅 정책을 별도로 수정할 게 없기 때문에, 새로 생성한 퍼블릭 서브넷은 기존 퍼블릭 라우팅 테이블을 같이 사용해도 된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9686fdc9-1064-4c6d-bb5d-7d6818ad18ef/image.png" alt=""></p>
<p>반면, 기존 프라이빗 라우팅 테이블은 ap-northeast-2a의 가용 영역에 있는 NAT 게이트웨이로 라우팅을 하고 있다. 새로 만든 프라이빗 서브넷은 ap-northeast-2b의 가용 영역에 있는 NAT 게이트웨이로 라우팅을 해야하기 때문에 새로운 라우팅 테이블을 만들어야 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/67c770d9-a5ab-4ea6-aed4-487e7af6ba29/image.png" alt=""></p>
<p>이제 구성이 끝났다. 새로운 가용 영역의 Private Subnet 내부에 EC2 인스턴스(백엔드 서버)를 배치해보도록 하자. 일단 인스턴스를 새로 생성해주자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/01eea679-c420-4cbb-aae4-cc100782cc97/image.png" alt=""></p>
<p>Bastion Host를 통해 가용 영역 ap-northeast-2b의 Private Subnet 내부에 인스턴스에 접근해보면 아래처럼 정상적으로 접근 가능한 것을 확인할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/312dcd9d-437e-4891-8b10-7add6b1df8f1/image.png" alt=""></p>
<p>&nbsp;</p>
<h1 id="💾-private-subnet에-rds-인스턴스-추가">💾 Private Subnet에 RDS 인스턴스 추가</h1>
<p>이번엔 각각의 가용 영역의 Private Subnet 내부에 RDS 인스턴스를 추가해보도록 하자. 이를 위해 RDS 메뉴에 들어가서 서브넷 그룹을 생성해주어야 한다. RDS를 VPC의 서브넷에 배치하기 위해서는 서브넷 그룹 생성이 필수적이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b09d076c-8195-4c30-b93a-8797eafdf297/image.png" alt=""></p>
<p>이제 보안 그룹을 생성해줘야 한다. EC2 페이지로 이동하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/10bc8fb8-20c3-4480-bf4c-cf85ace96cf9/image.png" alt=""></p>
<p>이제 RDS 인스턴스를 생성하면 된다. 여러 설정을 해주면 되는데, 현재 DB를 Private Subnet에 배치할 것이기 때문에 퍼블릭 액세스를 <em>“아니오”</em> 로 설정해주도록 하자. 그리고 추가 구성에서 해당 RDS 인스턴스가 생성되면서 practice 데이터베이스를 자동으로 생성하도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/5aa2fd3f-1ba8-46f0-8303-f137492c6bca/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d36bedbb-5424-4334-b364-772698c499c2/image.png" alt=""></p>
<p>이제 생성된 RDS에 접속해보도록 하자. 접속은 MySQL Workbench를 사용했다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/efca01ab-8271-45d5-9b90-591b4df946a0/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/43825072-0345-410c-95e5-fdce6fd6dee8/image.png" alt=""></p>
<p>근데 연결이 되지 않는다. 당연하게도 RDS 인스턴스가 현재 VPC의 Private Subnet에 구축되어 있기 때문이다. 즉, 외부 인터넷에서 직접 접근할 수 없다는 말이다. 그렇다면 같은 VPC 내부에 있는 Bastion Host를 통해 간접적으로 접근할 수 있지 않을까?</p>
<p>이때 사용하는 방법이 <strong>SSH 터널링(SSH Tunneling)</strong>이다. 내 로컬 컴퓨터에서 RDS로 직접 연결하는 대신, 먼저 Bastion Host에 SSH로 접속하고, 그 연결을 통로 삼아 RDS로 트래픽을 흘려보내는 방식이다. MySQL Workbench의 경우 연결 방식을 <code>Standard TCP/IP over SSH</code>로 설정하면 된다.</p>
<ul>
<li><strong>SSH Hostname</strong>: Bastion Host의 Public IP</li>
<li><strong>SSH Key File</strong>: Bastion Host 접속용 키페어(.pem)</li>
<li><strong>MySQL Hostname</strong>: RDS 엔드포인트 (Bastion 입장에서 접근하는 주소이므로 Private 경로로 해석됨)</li>
</ul>
<p>즉, 외부에서 볼 때는 Bastion Host에만 접속하는 것처럼 보이지만, 실제로는 그 뒤에 숨어 있는 Private Subnet의 RDS까지 안전하게 닿는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b40d25d7-8570-404f-bf5a-3c8d70076f0f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3dbe90bf-fbbe-4448-b928-77a0cbbd2d2d/image.png" alt=""></p>
<p>이제 EC2 인스턴스에 스프링부트 애플리케이션을 배포해보자. Bastion Host에서 Private Subnet에 있는 practice-server-a 인스턴스에 접속해보도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3963fe32-7066-4e03-929e-1d5a47661d57/image.png" alt=""></p>
<p>이제 JDK 17 버전을 설치해주도록 하자.</p>
<pre><code class="language-bash">sudo apt update
sudo apt install openjdk-17-jdk -y</code></pre>
<p>그리고 Github에서 스프링부트 프로젝트를 클론해와서 <code>application.yml</code> 파일을 아래와 같이 수정 후 빌드를 진행해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2f650c4a-9003-493c-a4b4-ea50a4ab893a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/39da43e4-dae1-4d0d-a841-4ccd6fcfeffa/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/92c5ff93-930c-4077-af63-cc9b1c884d0a/image.png" alt=""></p>
<p>빌드 후 백그라운드에서 스프링부트 프로젝트를 실행하고 헬스체크 및 간단한 요청을 보낸 후, DB를 확인해보면 데이터가 정상적으로 들어간 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/eef6fa3c-e4d3-4402-a704-c5390f0786c8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/83919855-299a-4707-80f0-57e739426aa5/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/da4317a3-a198-4f0c-9f50-0b82017ebd3a/image.png" alt=""></p>
<p>이로써 가용 영역 ap-northeast-2a의 Private Subnet 내부에 있는 EC2 인스턴스에 백엔드 서비스를 성공적으로 배포한 것이다. 다른 가용 영역 ap-northeast-2b의 Private Subnet 내부의 EC2 인스턴스에도 위와 동일한 과정을 거쳐 배포하면 된다.</p>
<p>근데 생각해보니 일반 사용자가 백엔드 API 서버에 요청을 보내기 위해 Bastion Host를 거치게 만들 수는 없다. 그래서 일반 사용자도 백엔드 서버에 직접 요청을 보낼 수 있게 만들어 주는 것이 중요하다. 이를 위해 로드 밸런서를 도입하도록 하자.</p>
<p>&nbsp;</p>
<h1 id="🚥-elb-도입하기">🚥 ELB 도입하기</h1>
<p>이제 <strong>ELB(Elastic Load Balancer)</strong>를 추가해서 사용자가 외부 인터넷을 통해서 Private Subnet에 있는 백엔드 서버로 요청을 보낼 수 있게 만들고, 두 인스턴스의 트래픽을 골고루 분산시킬 수 있도록 만들어보자.</p>
<p>참고로 ELB는 상위 개념이고, 실제로 생성할 때는 용도에 따라 유형을 골라야 한다. 지금처럼 HTTP 기반의 백엔드 API 요청을 분산하는 경우에는 <strong>ALB(Application Load Balancer)</strong>를 사용한다. (TCP/UDP 레벨의 분산이 필요하면 NLB, 예전 방식은 CLB다.)</p>
<p>그러기 위해서는 먼저 보안 그룹을 만들어줘야 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e0482733-e59e-48d0-ab07-bea57d325369/image.png" alt=""></p>
<p>그 후, 로드 밸런서의 기본 옵션을 설정해줘야 한다. 네트워크 매핑 탭에서 practice-vpc, 서브넷을 2개의 Public Subnet으로 설정하고, 대상 그룹도 설정하도록 하자. 로드 밸런서 자체는 Public Subnet에 위치해 외부 요청을 받고, 대상 그룹으로는 Private Subnet에 있는 백엔드 EC2를 지정한다. 이렇게 하면 사용자는 ALB까지만 접근하고, 실제 서버는 여전히 외부에 노출되지 않는다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e38c5d5b-e809-4353-89f1-395c853a08ff/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/4424a24a-57df-48a2-abe5-f5ad2b4bafda/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/6c05829b-c35c-460b-88b1-44f393cfb562/image.png" alt=""></p>
<p>이제 로드 밸런서 생성 페이지로 돌아와서 아래와 같이 생성한 대상 그룹을 추가하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/968722eb-822e-414b-af95-fbb7d826476b/image.png" alt=""></p>
<p>생성된 로드 밸런서가 실제로 2대의 EC2 인스턴스에 트래픽을 분산시킬 수 있는지 직접 Postman으로 확인해보자. ALB의 DNS 주소로 요청을 반복해서 보내니, 요청이 두 인스턴스로 번갈아 전달되며 각각에서 정상적으로 헬스 체크 응답이 오는 것을 볼 수 있었다. (각 응답에 찍힌 서버 식별 정보를 통해 서로 다른 인스턴스가 처리하고 있음을 확인할 수 있다.)</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/121a21ab-c2bc-4589-b13c-76b345487b22/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e8e2a83f-1182-4d67-8f0d-7eb1d82c0994/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9a062595-97e0-4284-8f90-87ed7f928e94/image.png" alt=""></p>
<p>이렇게 가용 영역을 2개로 나누고 로드 밸런서를 통해 트래픽을 분산시켜 가용성을 높일 수 있었다. 하지만 여전히 아쉬운 점은 아래 ap-northeast-2a의 가용 영역이 자연 재해로 인해 고장난다면, RDS 인스턴스가 작동하지 않으면서 서비스의 중단이 일어난다는 점이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/36f355aa-94fb-49bf-b1b9-8d720af782c9/image.png" alt=""></p>
<p>이를 방지하기 위해 EC2 인스턴스(백엔드 서버)를 서로 다른 가용 영역에 배치한 것처럼, RDS 인스턴스(데이터베이스 서버)를 서로 다른 가용 영역에 배치할 수 있다. 이 기능을 보고 다중 AZ 배포라고 한다. </p>
<p>&nbsp;</p>
<h1 id="🗺️-다중-az-배포">🗺️ 다중 AZ 배포</h1>
<p><strong>다중 AZ(Multi AZ) 배포</strong>란 다른 가용 영역에 RDS 인스턴스의 복제본을 생성해두는 걸 의미한다. 두 데이터베이스를 구분하기 위해 원본 DB를 <strong>Primary DB</strong> 또는 <strong>Master DB</strong>라고 부르고, 복제본 DB를 <strong>Standby DB(예비 데이터베이스)</strong>라고 부른다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b57d44b0-d4a2-4797-80ea-f0e737a6d58c/image.png" alt=""></p>
<p>Primary DB에 데이터가 추가됨에 따라 Standby DB에도 똑같이 데이터가 동기화된다. 만약 Primary DB에 장애가 발생할 경우, Standby DB로 자동으로 교체(Failover)해서 사용한다.</p>
<p>여기서 한 가지 오해하기 쉬운 점이 있다. <strong>Standby DB는 평상시에 읽기 요청조차 처리하지 않는다.</strong> 오로지 Primary가 죽었을 때를 대비해 대기만 하는 순수 예비 인스턴스다. 따라서 다중 AZ 배포는 읽기 성능 향상이 아니라 <strong>장애 대비(가용성)</strong>를 위한 기능이다. 읽기 부하를 분산하고 싶다면 그건 Read Replica라는 별도의 기능을 써야 한다.</p>
<p>이제 직접 RDS에 다중 AZ 배포 설정을 해보도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/25c95e1d-9e1f-4a15-a7be-775ca1becce0/image.png" alt=""></p>
<p>다만, 이 다중 AZ 배포 기능은 기존 RDS를 복제해서 2개의 RDS 인스턴스를 활용하는 기능이기 때문에 비용이 2배 발생한다. 이 점을 주의하도록 하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NAT Gateway와 Bastion Host로 Private Subnet 다루기]]></title>
            <link>https://velog.io/@rocker_nun/NAT-Gateway%EC%99%80-Bastion-Host%EB%A1%9C-Private-Subnet-%EB%8B%A4%EB%A3%A8%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/NAT-Gateway%EC%99%80-Bastion-Host%EB%A1%9C-Private-Subnet-%EB%8B%A4%EB%A3%A8%EA%B8%B0</guid>
            <pubDate>Tue, 28 Jul 2026 00:30:10 GMT</pubDate>
            <description><![CDATA[<h1 id="🪧-nat-gateway란">🪧 NAT Gateway란?</h1>
<p><strong>NAT Gateway</strong>는 외부 인터넷에서 서브넷으로 접근할 수는 없지만 서브넷에서 외부 인터넷으로 접근할 수 있게 해주는 장치다. </p>
<p>인터넷 게이트웨이는 외부 인터넷에서 서브넷의 방향이든 서브넷에서 외부 인터넷의 방향이든 양방향으로 통신이 가능했지만, <strong>NAT Gateway는 서브넷에서 외부 인터넷의 방향으로만 통신이 가능</strong>하다. 즉, 내부에서 외부로만 나갈 수 있는 특수한 출입구라고 생각하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e74e4de0-c70e-44c0-b126-4a89c955d27f/image.png" alt=""></p>
<ul>
<li><p><em>외부 인터넷 → 서브넷 방향의 통신</em></p>
<ul>
<li>서브넷에 포함된 EC2 인스턴스의 백엔드 서버로 API 요청</li>
<li>서브넷에 포함된 EC2 인스턴스에 SSH로 접속</li>
</ul>
</li>
<li><p><em>서브넷 → 외부 인터넷 방향의 통신</em></p>
<ul>
<li>서브넷에 포함된 EC2 인스턴스에서 외부 API(ex. 날씨 API, OpenAI API 등) 호출</li>
<li>서브넷에 포함된 EC2 인스턴스에서 소프트웨어(ex. Nginx, git 등) 설치</li>
</ul>
</li>
</ul>
<p>&nbsp;   </p>
<p>NAT Gateway를 어떻게 추가해야 하는 지 아래 그림으로 살펴보자. 특이하게도 NAT Gateway는 VPC도, Private Subnet도 아닌 <strong>Public Subnet에 연결을 해야 한다.</strong> Private Subnet을 외부 인터넷에 직접적으로 노출시키지 않으면서도, 인터넷에 접근은 할 수 있게 만들어야 해서 나온 구조로 예상된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7d45e94a-bd20-4807-9749-5c303c0a51ed/image.png" alt=""></p>
<p>이제 NAT Gateway를 직접 생성해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3d891cb3-7062-4fbf-a4f9-972e0f4ab085/image.png" alt=""></p>
<p>NAT Gateway가 생성된 이후에는 Private Subnet에 연결시킬 라우팅 테이블을 만들어야 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/450fb3af-c925-4182-853a-93f7f70c3c85/image.png" alt=""></p>
<p>라우팅 편집에서 VPC 내부에서 보내는 IPv4에 대한 모든 트래픽은 NAT Gateway로 보내는 것으로 설정했다. 이렇게 하면 NAT Gateway를 활용해서 외부 인터넷에서 접근할 수 없는 Private Subnet을 만드는 과정을 모두 완료한 것이다.</p>
<p>이제 Private Subnet에 EC2 인스턴스를 생성해서 실제 외부 인터넷에서 접근할 수 없는지, 인스턴스에서는 외부 인터넷으로 요청을 보낼 수 있는지도 직접 눈으로 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/936ba091-f747-4573-a291-004e3a688609/image.png" alt=""></p>
<p>이제 Private Subnet에 EC2를 배치하고 연결해보면 아래와 같은 경고 팝업이 뜬다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/81f60f9e-2ef1-4a97-88ad-a6c703d2d56a/image.png" alt=""></p>
<p>외부에서 접근을 막기 위해 의도적으로 퍼블릭 IP 주소를 할당하지 않았고, 인스턴스를 Public Subnet에 배치하지 않았기 때문에 해당 EC2 인스턴스에 접근할 수 없다는 경고 메시지다.</p>
<p>근데 이렇게 만든 EC2 인스턴스에 백엔드 프로젝트를 배포하거나 관리 및 디버깅해야 할 때는 EC2 인스턴스로 들어가야 할 텐데, 접속할 수 있는 방법이 없으면 안 되는 것 아닌가 하는 의문점이 생긴다. </p>
<p>&nbsp;</p>
<h2 id="🔐-private-subnet에-있는-ec2에-접근하기">🔐 Private Subnet에 있는 EC2에 접근하기</h2>
<p>생각해보면 같은 VPC 내부에서는 기존에 만들어 놓은 Public Subnet의 리소스와 Private Subnet의 리소스가 자유자재로 통신이 가능하다. 그 점을 활용해서 Private Subnet에 있는 EC2 인스턴스에 접근할 수 있다.</p>
<p>일단 터미널에서 키페어를 활용하여 Public Subnet에 존재하는 EC2 인스턴스에 접속해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/30091850-348f-41f6-8a72-d2375670b022/image.png" alt=""></p>
<p>성공적으로 Public Subnet에 있는 EC2에 접속했다. 이제 여기서 Private Subnet에 있는 EC2 인스턴스에 접속해야 한다. </p>
<p>근데 그러려면 그 인스턴스의 키페어가 있어야 하는데 현재 접속해있는 EC2에는 당연히 키페어 파일이 존재하지 않는다. 따라서 현재 내 컴퓨터에서 web-server 인스턴스로 키페어 파일을 옮겨야 한다. </p>
<p>새로운 터미널에서 아래 명령어로 키페어 파일을 옮기도록 하자.</p>
<pre><code class="language-bash">scp -i web-server.pem practice-server.pem ubuntu@[인스턴스 Public IPv4]:~/</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e54c2b57-2ac9-4cd5-bf2e-5da52bdd7693/image.png" alt=""></p>
<p>이제 Public Subnet에 있는 EC2의 내부에 정상적으로 키페어 파일이 전송된 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/5fb11e8f-6561-419a-8bc6-c940b1aa54ed/image.png" alt=""></p>
<p>이제 동일하게 Public Subnet 내부의 EC2 인스턴스에서 Private Subnet에 있는 practice-server에 접속하기 위해 명령어를 입력해주면 정상적으로 접속되는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/fabd5aaf-0274-4409-8a24-b8209ee35d79/image.png" alt=""></p>
<p>&nbsp;</p>
<h2 id="👮🏻-bastion-host란">👮🏻 Bastion Host란?</h2>
<p><strong>Bastion Host</strong>는 외부 인터넷에서 내부 네트워크(VPC)에 있는 리소스에 접근할 때 보안을 위해 가장 먼저 거치는 컴퓨터를 말한다. 근데 Bastion Host가 꼭 필요할까? 만약 Bastion Host가 없는 아키텍처라면 아래와 같은 문제가 발생한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3876d11c-c759-4fbe-afbc-2deffe5cf570/image.png" alt=""></p>
<p>보다시피 Public Subnet에 있는 EC2 인스턴스가 외부 인터넷에 노출된다. 해커 입장에서는 Public Subnet에 있는 모든 리소스에 접근할 수 있기 때문에 서비스를 운영하는 입장에서는 공격이 들어올 수 있는 포인트가 많아서 보안적으로 대비가 힘들어진다. 따라서 아래와 같이 Public Subnet에 Bastion Host를 배치해줘야 한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/36bb6289-bc8d-4b8c-8ea4-66afc4b6b489/image.png" alt=""></p>
<p>Bastion Host를 활용해 외부에서 접근할 수 있는 경로는 딱 하나만 만들어줬다. 외부에서 접근할 수 있는 경로는 단 하나이기 때문에 보안적인 조치를 하기도 쉽고 관리도 쉬워진다. </p>
<p>&nbsp;</p>
<h3 id="⚓️-bastion-host를-public-subnet에-배치하기">⚓️ Bastion Host를 Public Subnet에 배치하기</h3>
<p>먼저 Public Subnet에 있는 EC2에 터미널을 이용해서 접속해보자.</p>
<pre><code class="language-bash">$ cd [키 페어 파일이 존재하는 경로]
$ ssh -i &quot;web-server.pem&quot; ubuntu@[bastion-host 인스턴스의 Public IP]</code></pre>
<p>&nbsp;</p>
<p>접속한 후에 해당 EC2가 Bastion Host라는 것을 쉽게 알아보기 위해 인스턴스의 호스트 이름을 바꿔준 후에 인스턴스를 재시작하도록 하자.</p>
<pre><code class="language-bash">$ sudo vi /etc/hostname
$ sudo reboot # EC2 인스턴스 재시작하기</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/07c1524b-b8ca-4220-a55a-6879739d8aef/image.png" alt=""></p>
<p>다시 Bastion Host에 접속하면 아래와 같이 정상적으로 호스트 이름이 변경되 상태로 접속된 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c62651a5-a70c-4ed1-8d80-fafbd7be8899/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS VPC 완전 정복]]></title>
            <link>https://velog.io/@rocker_nun/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%84%B1-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
            <guid>https://velog.io/@rocker_nun/%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%84%B1-%EA%B0%80%EC%9D%B4%EB%93%9C</guid>
            <pubDate>Mon, 27 Jul 2026 07:55:50 GMT</pubDate>
            <description><![CDATA[<h1 id="🏝️-vpc란">🏝️ VPC란?</h1>
<p><strong>VPC(Virtual Private Cloud)</strong>의 정의는 <strong>가상의 네트워크 공간(컴퓨터들끼리 서로 통신하는 공간)</strong>이다. VPC를 사용하는 이유를 한 단어로 줄이면 <strong><em>“보안”</em></strong> 때문이다. VPC를 활용하면 외부에서 직접 접근할 수 없는 독립적인 네트워크 환경을 구성할 수 있어서, 보안적으로 안전하게 리소스(EC2, RDS 등)를 사용할 수 있다. </p>
<p>예를 들어, EC2 인스턴스 2대가 있다고 가정하자. 그런데 1대의 인스턴스는 인터넷에 자유롭게 접근하면서 사용하고 싶고, 나머지 1대는 좀 더 안전하고 비공개로 사용하고 싶을 수 있다. 이럴 때 VPC를 활용하면 된다. </p>
<p>VPC를 더 쉽게 이해하자면, 아래 그림과 같이 <strong>격자가 그려진 섬</strong>이라고 생각하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d9321982-c8ca-417c-8492-e0b3b7c90033/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/19698a75-9422-437e-a7f1-6b7b28c8c034/image.png" alt=""></p>
<p>섬의 각 구역에는 그 구역만의 주소(IP 주소)가 존재하고 VPC 안에는 여러 개의 IP 주소를 할당할 수 있다. 이후에 AWS에서 EC2를 생성하든, RDS를 생성하든, ELB를 생성하든 이 VPC 안에 아래와 같이 배치하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/513feb4b-8227-4381-b2db-a4734e700e58/image.png" alt=""></p>
<p>&nbsp;</p>
<h2 id="📝-cidr-표기법">📝 CIDR 표기법</h2>
<p> VPC의 크기를 정할 때는 IP 주소의 범위로 정한다. 위의 그림같이 크기로 정하려면 VPC의 IP 주소 범위를 <code>10.10.0.0</code> ~ <code>10.10.0.31</code>로 잡겠다고 설정하면 된다. 정확히 말하자면, 설정할 때 <code>10.10.0.0/27</code>의 범위로 VPC를 만들어야 한다. </p>
<p>이러한 표기법을 <strong>CIDR 표기 방식</strong>이라고 하는데, 특정 IP 주소의 범위를 나타내는 표기 방식이다. 실제 AWS에서 VPC를 만들 때는 CIDR 표기 방식을 활용해 입력해야 한다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a188189a-c38c-41fa-958c-16d82c8d3ea3/image.png" alt=""></p>
<p>알다시피 IP 주소는 4개의 숫자로 구성되어 있고, 각 숫자는 <code>0</code> ~ <code>255</code> 범위의 값을 가진다.</p>
<p>ex) <code>127.23.150.11</code>, <code>15.0.255.1</code></p>
<p>&nbsp;</p>
<p>CIDR 표기법으로 표기된 값을 해석하는 방법은 아래 절차대로 진행하면 된다.</p>
<ol>
<li>10진수를 2진수로 바꾼다.</li>
<li>CIDR 슬래시 뒤에 있는 숫자를 활용해 2진수를 자른다. </li>
<li>잘린 2진수의 왼쪽 값은 그대로 고정시키고, 잘린 2진수의 오른쪽 값에서 표현할 수 있는 최솟값과 최댓값을 구한다. </li>
<li>2진수로 표현된 최솟값과 최대값을 10진수로 변환한다.</li>
</ol>
<p>&nbsp;</p>
<p>예를 들어, CIDR 표기법으로 표기된 <code>13.25.82.0/24</code>라는 값이 있다고 해보자.</p>
<ol>
<li>10진수로 표현된 각 숫자들을 2진수로 바꾼다.<ul>
<li><code>00001101.00011001.01010010.00000000</code></li>
</ul>
</li>
<li>CIDR 슬래시 뒤에 있는 숫자를 활용해 2진수를 자른다. <ul>
<li><code>00001101.00011001.01010010</code> / <code>00000000</code></li>
</ul>
</li>
<li>잘린 2진수의 왼쪽 값은 그대로 고정시키고, 잘린 2진수의 오른쪽 값에서 표현할 수 있는 최솟값과 최댓값을 구한다. <ul>
<li>최소값 : <code>00001101.00011001.01010010</code> / <code>00000000</code></li>
<li>최대값 : <code>00001101.00011001.01010010</code> / <code>11111111</code></li>
</ul>
</li>
<li>2진수로 표현된 최솟값과 최대값을 10진수로 변환한다.<ul>
<li>최솟값 : <code>13.25.82.0</code></li>
<li>최댓값 : <code>13.25.82.255</code></li>
</ul>
</li>
</ol>
<p>즉, CIDR로 표기된 <code>13.25.82.0/24</code>의 IP 주소 범위는 <code>13.25.82.0 ~ 13.25.82.255</code>이라는 의미다. 이 과정을 이해하고 스스로 알아낼 수 있어야 한다.</p>
<p>&nbsp;</p>
<h2 id="🆚-public-ip-vs-private-ip">🆚 Public IP vs Private IP</h2>
<p><strong>Public IP(공인 IP)</strong>란 외부 인터넷에서 접근할 수 있는 공식적인 주소를 말한다. 네이버에 <em>“내 컴퓨터 ip 주소 확인”</em> 이라고 검색하면 바로 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a479b100-5652-476e-8bc3-66fe2a9287d2/image.png" alt=""></p>
<p>반면, <strong>Private IP(사설 IP)</strong>는 외부 인터넷과 직접 연결되지 않고, 내부 네트워크에서만 사용되는 주소를 뜻한다. Private IP의 특징은 동일한 네트워크 환경에서만 서로 통신할 수 있다는 것이다. 여기서 <strong>*&quot;동일한 네트워크”*</strong> 라는 뜻은 같은 공유기(와이파이)를 사용하고 있는 경우, 혹은 같은 VPC인 경우라고 생각하면 된다.</p>
<p>전세계에 딱 하나뿐인 Public IP와는 다르게 Private IP는 네트워크 환경마다 독립적으로 사설 IP를 사용할 수 있다. 한 마디로 사설 IP는 각자의 네트워크 환경에서만 사용하는 IP이다. 예를 들어, A라는 네트워크 환경에서 <code>10.0.0.0</code>이라는 사설 IP를 쓰고 있다고 하더라도, B라는 네트워크 환경 에서도 <code>10.0.0.0</code>을 별개로 사용할 수 있다는 말이다.</p>
<p>Private IP의 범위는 IETF라는 국제 인터넷 표준화 기구에서 아래와 같이 정해놓았다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b8f7b58d-bc15-42b9-9228-23d5c84d7950/image.png" alt=""></p>
<p>위의 3가지 범위에 속한 IP 주소로 통신하면 컴퓨터들은 무조건 Private IP로 인식한다. </p>
<p>실습을 진행해보자. EC2 하나를 생성하고 Nginx를 설치해준다. 그리고 EC2가 가지고 있는 Public IP에 접속하면 Nginx가 정상적으로 동작하는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3d28b1d0-ebec-4781-837d-ba7e9896534f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/1e361f25-46c4-45a6-ae31-56106ec60bdc/image.png" alt=""></p>
<p>이렇게 Public IP 주소로 응답이 잘 날라왔다는 것은 Public IP 주소를 활용해서 Nginx가 실행되고 있는 컴퓨터 인스턴스에 접근할 수 있다는 의미다.</p>
<p>&nbsp;</p>
<p>이번엔 Private IP 주소로 요청을 보내보자. 어떻게 될까? </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/cdc66710-e6ad-445f-8b1f-98b5c64a91b9/image.png" alt=""></p>
<p>보다시피 아무런 반응이 없다. 아까 말했듯이 Private IP는 동일한 네트워크 환경에서만 통신할 수 있는 주소라고 했다. 하지만 지금은 내 로컬 컴퓨터의 네트워크 환경과 AWS의 VPC에서 실행되고 있는 EC2 인스턴스의 네트워크 환경이 서로 다르기 때문에 접속되지 않는 것이다. 그럼 네트워크 환경을 동일하게 맞춰주면 되는거 아닌가? 한번 실험해보자.</p>
<p>먼저 Nginx가 실행되고 있는 EC2가 어떤 VPC(현재는 기본값)를 쓰고 있는지 확인해야 한다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c87c93ce-1dda-4418-a7c6-fb6c4dbd0883/image.png" alt=""></p>
<p>확인 후, 동일한 VPC(기본값)을 사용하도록 하고 보안 규칙을 추가 후 인스턴스를 생성해보자. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/0d3431c5-755c-4a0b-b5b4-57d7f2aca385/image.png" alt=""></p>
<p>현재 같은 VPC를 사용 중인 인스턴스 2대가 있는 상황이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/447ba993-22f4-42c6-af88-893bfaa5d157/image.png" alt=""></p>
<p>이제 새로 만든 web-server2에서 Nginx가 실행되고 있는 web-server에 Private IP로 통신을 시도해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/ff50ff74-5d0c-42a9-9900-5b51cb7526e8/image.png" alt=""></p>
<p>보다시피 동일한 VPC를 사용하고 있기 때문에 Private IP로 요청을 보내도 정상적으로 응답 받을 수 있는 것을 확인할 수 있다.</p>
<p>이번에는 VPC를 직접 생성해보도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/72c28158-3cd2-47b4-86ef-03f7ba94dfa8/image.png" alt=""></p>
<p>IP 주소의 범위는 CIDR 표기법으로 <code>10.0.0.0/16</code>으로 설정했다. 이렇게 설정한 이유는 IP 범위를 정할 때 Private IP 범위에 포함되어야 하기 때문이다. 이제 이렇게 직접 설정해서 생성한 VPC에 EC2 인스턴스를 생성해서 배치해보자.</p>
<p>근데 EC2를 생성하려고 보니까 서브넷을 찾을 수 없다는 문구가 나온다. 서브넷에 대해 알아보도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/cdc67593-52ba-4bfb-8d1e-4bca70e046b7/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d9c2dd64-6e0a-4f42-85e4-c04b23a0978f/image.png" alt=""></p>
<p>&nbsp;</p>
<h2 id="🗃️-subnet이란">🗃️ Subnet이란?</h2>
<p><strong>서브넷(Subnet)</strong>은 하나의 큰 네트워크를 작은 네트워크 단위로 쪼갠 것을 말한다. 하나의 VPC는 여러 개의 서브넷으로 나뉘어진다. 위에서 접한 그림에서 서브넷을 표현해보자면 아래와 같이 표현할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/31cc96ab-2ea9-4ae5-8e3a-fb3b5d7a0aa7/image.png" alt=""></p>
<p>이렇게 여러 서브넷으로 쪼개는 특별한 이유가 뭘까? 바로 <strong><em>“용도에 따라 네트워크를 분리해서 사용하고 싶기 때문”</em></strong> 이다. 예를 들어, 하나의 VPC에서 데이터베이스끼리만 모아놓은 네트워크와 백엔드 서버끼리만 모아놓은 네트워크를 분리해서 관리하고 싶을 수 있다. 이럴 때 서브넷을 활용하는 것이다.</p>
<p>근데 한 가지 중요한 궁금증이 있다. </p>
<blockquote>
<p><strong><em>“서브넷을 어떤 기준으로 나눠야 하는거지?”</em></strong></p>
</blockquote>
<p>이때 가장 많이 활용하는 기준이 <strong>외부에서 접근 가능한 네트워크(Public Subnet)</strong>로 할지, <strong>외부에서 접근이 불가능한 네트워크(Private Subnet)</strong>로 할지다. 위에서 생성해놓은 VPC의 서브넷을 만들어보자.</p>
<h3 id="👌🏻-public-subnet-생성">👌🏻 Public Subnet 생성</h3>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f8c061c5-6f24-4915-ab0a-8a6d9d30c10d/image.png" alt=""></p>
<h3 id="🚫-private-subnet-생성">🚫 Private Subnet 생성</h3>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f6a753f0-cdcc-4465-88d5-7b291aa84d0e/image.png" alt=""></p>
<p>이제 EC2를 하나 생성해서 외부에서 접근 가능하도록 기존에 만들어놓은 VPC의 Public Subnet에 배치해놓자. HTTP 80번 포트에 대해 모든 IP에서 접속 가능하도록 설정했다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c4b12b56-135f-4e1a-8a1b-d2d259707569/image.png" alt=""></p>
<p>그리고 EC2를 연결하려고 했는데…</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/0b9c0e9a-7c5b-4bf6-ba17-d3624a26e4ec/image.png" alt=""></p>
<p>인스턴스가 Public Subnet에 없다는 문구와 함께 서브넷을 Public Subnet으로 만들기 위해서는 서브넷 라우팅 테이블의 경로를 인터넷 게이트웨이에 추가해야 한다고 되어 있다. 분명히 Public Subnet으로 설정해줬는데 이게 어떻게 된 일일까?</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/47380cf5-708e-4c11-b436-10baa47fb1de/image.png" alt=""></p>
<p>우려한대로 접속이 되지 않는다. 접속이 안 되는 이유는 VPC의 외부 인터넷과 소통할 수 있는 출입구를 세팅해주지 않았기 때문이다. 이 출입구가 바로 <strong>인터넷 게이트웨이(Internet Gateway)</strong>다. 출입구를 VPC에 달아주도록 하자.</p>
<p>&nbsp;</p>
<h2 id="🚪internet-gateway란">🚪Internet Gateway란?</h2>
<p><strong>인터넷 게이트웨이(Internet Gateway)</strong>란 VPC와 외부 인터넷 간에 통신할 수 있게 해주는 장치를 뜻한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/bce377e6-3e00-4438-8040-fc8314a09a64/image.png" alt=""></p>
<p>이제 인터넷 게이트웨이를 직접 만들고 VPC에 달아주도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a3905130-20dd-4ee9-9912-95814caaa48e/image.png" alt=""></p>
<p>근데 외부 인터넷과 VPC 내부에 있는 EC2 인스턴스랑 통신이 되게 하기 위해서는 한 가지 더 세팅을 해야 한다. 바로 <strong>라우팅 테이블(Routing Table)</strong>이다.</p>
<p>&nbsp;</p>
<h2 id="🔀-routing-table이란">🔀 Routing Table이란?</h2>
<p><strong>라우팅 테이블(Routing Table)</strong>은 트래픽을 어디로 전송해야 하는 지 경로를 알려주는 테이블을 의미한다. 왜 필요할까? </p>
<p>아래 그림을 보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c50af9f4-6c48-4acf-a783-05d09ddd8d7f/image.png" alt=""></p>
<p>VPC 내부에 EC2 인스턴스가 있고, 이 인스턴스가 VPC 외부에 있는 특정 컴퓨터와 통신을 하고 싶다고 가정해보자. 그럼 EC2 인스턴스 입장에서는 외부 인터넷에 있는 컴퓨터와 통신을 하기 위해 인터넷 게이트웨이를 거쳐가야 하는데 <strong>문제는 인터넷 게이트웨이로 가는 방법 자체를 모른다는 것</strong>이다. </p>
<p>즉, EC2 인스턴스가 특정 컴퓨터로 통신을 보내려고 해도 어떤 방향으로 트래픽을 보내야 하는 지 모른다는 뜻이다. 그래서 EC2 인스턴스가 어떤 경로로 트래픽을 전송해야 되는 지 라우팅 테이블을 통해 알려줘야 하는 것이다.</p>
<p>라우팅 테이블을 만들고, Public Subnet만 연결해놓도록 하자. 그리고 라우팅 편집에서 몇 가지 설정을 해야 하는데,</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a2c759c7-2b96-4877-a212-d9c490c39dda/image.png" alt=""></p>
<p>첫 번째 설정은 VPC 내부에서 <code>10.0.0.0/16</code>으로 보내는 트래픽은 local로 보내라는 뜻이다. 여기서 local로 보낸다는 것은 Private IP를 활용해서 네트워크 내부에서만 통신하라는 의미다. </p>
<p>바로 아래 설정에서 <code>0.0.0.0/0</code>은 모든 IPv4 주소를 의미하는데, VPC 내부에서 모든 IPv4 주소로 보내는 트래픽은 인터넷 게이트웨이로 보내라는 의미다.</p>
<p>근데 뭔가 모순적이지 않나? 만약 <code>10.0.0.5</code> 같은 주소로 보내려면 어떻게 되는걸까? local로 보내야 할까, 인터넷 게이트웨이로 보내야 할까? 다행스럽게도 AWS 공식문서에 이에 대해 IP 주소 범위가 더 구체적인 조건을 우선 적용한다고 나와 있다. 즉, <code>10.0.0.5</code> 트래픽은 local로 보내게 되는 것이다.</p>
<p>여기까지 설정을 마치고, EC2에 접속해보면 정상적으로 접속되는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2f12e03e-5a9b-4bca-b17e-56a286b69aa3/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Batch 개념 정리]]></title>
            <link>https://velog.io/@rocker_nun/Spring-Batch-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@rocker_nun/Spring-Batch-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Fri, 26 Jun 2026 08:55:03 GMT</pubDate>
            <description><![CDATA[<h1 id="👨🏻🔧-배치batch란">👨🏻‍🔧 배치(Batch)란?</h1>
<p><strong>배치 처리(Batch Processing)</strong>는 대용량 데이터를 사용자 개입 없이 일괄로 처리하는 방식이다.</p>
<p>조금 더 풀어서 말하면, 배치 처리는 다음과 같은 특징을 가진다.</p>
<ul>
<li>대용량 데이터를 처리한다.</li>
<li>사용자가 응답을 기다리지 않는다.</li>
<li>보통 정해진 시간에 실행된다.</li>
<li>처리 상태를 기록할 수 있어야 한다.</li>
<li>실패 시 재시작할 수 있어야 한다.</li>
<li>데이터를 일정 단위로 나누어 안정적으로 처리해야 한다.</li>
</ul>
<p>배치 처리는 단순히 <code>for</code>문으로 많은 데이터를 반복 처리하는 것이 아니라, 운영 환경에서 대용량 데이터를 안정적으로 처리하기 위한 실행 방식이라고 볼 수 있다.</p>
<p>배치 처리는 일반적인 웹 API 처리와 다르다. 웹 API는 사용자의 요청에 즉시 응답해야 한다. 예를 들어 사용자가 상품 상세 페이지에 들어오면 서버는 상품 정보를 조회해서 빠르게 응답해야 한다. 반면, 배치 처리는 사용자가 직접 기다리는 작업이 아니라 정산, 통계 집계, 만료 쿠폰 삭제, 대량 알림 발송, 데이터 마이그레이션처럼 정해진 시점에 대량의 데이터를 한 번에 처리하는 경우에 이용되는 방식이다.</p>
<p>정리하자면, 웹 API는 <strong><em>“빠른 응답”</em></strong> 이 중요하고, 배치는 <strong><em>“안정적인 대량 처리”</em></strong> 가 중요하다.</p>
<p>&nbsp;</p>
<h1 id="😁-spring-batch">😁 Spring Batch</h1>
<p><strong>Spring Batch</strong>는 스프링 프레임워크 기반의 배치 처리 프레임워크다. 대용량 데이터를 안정적으로 처리하기 위해 필요한 기능들을 제공한다. Spring Batch가 제공하는 대표적인 기능은 다음과 같다.</p>
<ul>
<li><p><code>재시작 기능</code>: 배치 작업은 오래 걸릴 수 있다. 작업 도중 서버가 중단되거나 예외가 발생할 수 있다. Spring Batch는 작업이 어디까지 진행되었는지 데이터베이스에 기록한다. 그래서 작업이 실패하더라도 실행 이력을 바탕으로 다시 실행하거나, 실패 지점을 추적할 수 있다.</p>
</li>
<li><p><code>Chunk 단위 처리</code>: 100만 건의 데이터를 한 번에 메모리에 올리면 위험하다. Spring Batch는 데이터를 일정 개수씩 나누어 읽고 처리할 수 있다. 예를 들어 200만 건을 한 번에 처리하는 것이 아니라, 1000건씩 읽고, 처리하고, 저장하는 식으로 작업할 수 있다. 이렇게 일정 단위로 나누어 처리하는 방식을 Chunk 지향 처리라고 한다.</p>
</li>
<li><p><code>Skip/Retry 처리</code>: 운영 데이터는 항상 완전하지 않다. 특정 데이터 하나 때문에 예외가 발생할 수 있다. Spring Batch는 특정 예외가 발생했을 때 해당 데이터만 건너뛰거나, 일정 횟수만큼 재시도하는 기능을 제공한다. 이를 통해 일부 데이터 오류 때문에 전체 작업이 중단되는 상황을 줄일 수 있다.</p>
</li>
<li><p><code>메타 데이터 관리</code>: Spring Batch는 Job과 Step의 실행 이력을 데이터베이스에 기록한다. 예를 들어, 어떤 Job이 실행되었는지, 어떤 파라미터로 실행됐는지, 언제 시작되고 언제 종료됐는지, 성공했는지 실패했는지, 몇 건을 읽고 쓰고, 몇 건을 건너뛰었는지 등과 같은 정보들을 저장한다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="🏛️-spring-batch의-구조">🏛️ Spring Batch의 구조</h2>
<p>Spring Batch는 크게 3개의 레이어(Application, Batch Core, Batch Infrastructure)로 구성된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2ddbf493-660d-446c-b9d0-a6dd81ddcfa7/image.png" alt=""></p>
<ul>
<li><p><code>Application Layer</code>: 업무 로직이나 서비스, 혹은 잡 구조화와 관련된 내용이 포함된다. 월말 정산 배치를 만든다고 가정해보면 아래와 같은 내용들이 포함될 것이다.</p>
<ul>
<li>정산 Job은 어떤 Step들로 구성되는가?</li>
<li>판매자 데이터를 어디서 읽을 것인가?</li>
<li>정산 금액은 어떻게 계산할 것인가?</li>
<li>계산 결과를 어디에 저장할 것인가?</li>
<li>오류가 발생하면 어떻게 처리할 것인가?</li>
</ul>
<p>즉, 개발자가 주로 집중해야 하는 영역이 Application Layer라고 할 수 있다.</p>
</li>
</ul>
<ul>
<li><p><code>Batch Core Layer</code>: 배치 작업을 실행하고 제어하는 핵심 기능을 제공한다. 대표적으로 JobLauncher, Job, Step, JobRepository가 이 레이어에 존재한다. Application Layer에서 개발자가 Job과 Step을 정의하면, Batch Core Layer가 그 Job과 Step을 실행하고 제어한다.</p>
</li>
<li><p><code>Batch Infrastructure Layer</code>: 배치 처리에 필요한 공통 기반 기능을 제공한다. 예를 들어 CSV 파일을 읽는 FlatFileItemReader, 데이터베이스에서 데이터를 읽는 JdbcPagingItemReader, 데이터를 파일이나 DB에 쓰는 ItemWriter, 재시도 처리를 위한 RetryTemplate, 실행 이력을 저장하는 메타데이터 저장소 등이 이 레이어에 존재한다. 개발자는 Infrastructure Layer에서 제공하는 구현체를 가져와 Application Layer에서 조합하고, Batch Core Layer가 이를 실행하는 구조로 이해할 수 있다.</p>
</li>
</ul>
<h2 id="🔀-spring-batch-작업-흐름">🔀 Spring Batch 작업 흐름</h2>
<p>Spring Batch의 전체 작업 흐름에 대해 살펴보면 아래 그림과 같다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/acc44305-9761-4234-baf1-a3e66acd0089/image.png" alt=""></p>
<h3 id="🍔-triggering-system">🍔 Triggering System</h3>
<p><code>Triggering System</code>은 Batch Job 실행을 시작시키는 외부 시스템이다. Spring Batch 자체가 특정 시간에 Job을 자동으로 실행해주는 스케줄러는 아니다. 실제로 Job을 언제 실행할지는 외부 트리거가 결정한다.</p>
<p>예를 들면 다음과 같은 것들이 Triggering System이 될 수 있다.</p>
<ul>
<li>Spring Scheduler</li>
<li>Linux Cron</li>
<li>Jenkins</li>
<li>Kubernetes CronJob</li>
<li>운영자가 직접 실행하는 CLI</li>
<li>특정 API 요청</li>
</ul>
<p>예를 들어 매월 말 정산 배치를 실행한다면, 스케줄러가 정해진 시간에 Batch Job을 실행하도록 트리거할 수 있다.</p>
<pre><code>매월 말 00:00
    ↓
정산 Batch Job 실행</code></pre><p>즉, Triggering System은 <strong><em>“언제 배치를 실행할 것인가?”</em></strong> 를 담당한다.</p>
<p>&nbsp;</p>
<h3 id="🧀-joblauncher">🧀 JobLauncher</h3>
<p><code>JobLauncher</code>는 Job을 실행하는 진입점이다. Job을 정의했다고 해서 자동으로 실행되는 것은 아니다. 정의된 Job을 실제로 실행하려면 <code>JobLauncher</code>를 통해 실행 요청을 보내야 한다.</p>
<pre><code class="language-java">jobLauncher.run(job, jobParameters);</code></pre>
<p><code>JobLauncher</code>는 실행할 <code>Job</code>과 해당 Job에 전달할 <code>JobParameters</code>를 받아서 배치 작업을 시작한다. 쉽게 말하면 <code>JobLauncher</code>는 <em><strong>“이 Job을 이 파라미터로 실행해줘”</strong></em> 와 같은 요청을 처리한다.</p>
<p>&nbsp;</p>
<h3 id="🍿-jobparameters">🍿 JobParameters</h3>
<p><code>JobParameters</code>는 Job을 실행할 때 함께 전달하는 파라미터다. 단순히 Job 내부에서 사용할 값이라고만 생각하면 안 된다. Spring Batch에서 <code>JobParameters</code>는 <code>JobInstance</code>를 식별하는 중요한 기준이 된다.</p>
<p>Spring Batch는 <em>“Job 이름 + JobParameters”</em> 조합으로 하나의 <code>JobInstance</code>를 구분한다. 예를 들어 다음과 같은 Job이 있다고 해보자.</p>
<pre><code>Job 이름: settlementJob
JobParameters: settlementMonth=2026-06</code></pre><p>이 경우 Spring Batch는 이것을 <code>2026년 6월 정산 작업</code>이라는 하나의 논리적인 실행 단위로 보는 것이다.</p>
<p>&nbsp;</p>
<h3 id="🍖-job">🍖 Job</h3>
<p><code>Job</code>은 하나의 배치 작업 전체를 의미한다. 예를 들어 다음과 같은 작업은 각각 하나의 Job이 될 수 있다.</p>
<ul>
<li>월말 정산 Job</li>
<li>일별 매출 통계 생성 Job</li>
<li>만료 쿠폰 삭제 Job</li>
<li>휴면 회원 전환 Job</li>
<li>대량 알림 발송 Job</li>
</ul>
<p>즉, Job은 <strong><em>“무엇을 처리할 것인가?”</em></strong> 에 대한 가장 큰 단위다.</p>
<p>&nbsp;</p>
<h3 id="🍗-jobinstance">🍗 JobInstance</h3>
<p><code>JobInstance</code>는 <code>Job</code> 이름과 <code>JobParameters</code> 조합으로 식별되는 논리적인 실행 단위다. 예를 들어 다음과 같이 실행했다고 해보자.</p>
<pre><code>Job 이름: settlementJob
JobParameters: settlementMonth=2026-06</code></pre><p>그러면 Spring Batch는 이 조합을 하나의 <code>JobInstance</code>로 본다.</p>
<pre><code>settlementJob + settlementMonth=2026-06
= 하나의 JobInstance</code></pre><p>이 <code>JobInstance</code>는 <code>2026년 6월 정산 작업</code>이라는 논리적인 배치 작업을 의미한다. 중요한 점은 <strong>이미 성공적으로 완료된 <code>JobInstance</code>는 같은 파라미터로 다시 실행할 수 없다는 것</strong>이다. 왜냐하면 같은 데이터를 같은 조건으로 다시 처리하면 중복 정산, 중복 지급, 중복 알림 같은 문제가 발생할 수 있기 때문이다.</p>
<p>따라서 Spring Batch는 완료된 <code>JobInstance</code>의 동일 파라미터 재실행을 막는다. 새로운 <code>JobInstance</code>를 만들고 싶다면 새로운 파라미터를 사용해야 한다.</p>
<p>&nbsp;</p>
<h3 id="🍕-jobexecution">🍕 JobExecution</h3>
<p><code>JobExecution</code>은 <code>JobInstance</code>를 실제로 실행한 한 번의 시도다. <code>JobInstance</code>가 <strong><em>“어떤 작업인가?”</em></strong> 를 나타낸다면, <code>JobExecution</code>은 <strong><em>“그 작업을 실제로 실행한 한 번의 기록”</em></strong> 이라고 볼 수 있다.</p>
<p>예를 들어 <code>2026년 6월 정산 JobInstance</code>가 있다고 해보자. 이 작업을 실행했는데 중간에 실패했다면, <code>JobInstance</code>는 그대로 유지된다. 하지만 실행 시도인 <code>JobExecution</code>은 하나 생성된다.</p>
<p>이후 같은 <code>JobInstance</code>를 다시 실행하면 어떻게 될까? 논리적인 작업은 여전히 <code>2026년 6월 정산 작업</code>이므로 <code>JobInstance</code>는 기존 것을 재사용한다.</p>
<p>하지만 실행 시도는 새롭게 발생한 것이므로 <code>JobExecution</code>은 새로 생성된다. 정리하면 다음과 같다.</p>
<pre><code>JobInstance: 2026년 6월 정산 작업
JobExecution 1: 첫 번째 실행 시도 - 실패
JobExecution 2: 두 번째 실행 시도 - 성공</code></pre><p>즉, 하나의 <code>JobInstance</code>는 여러 개의 <code>JobExecution</code>을 가질 수 있다. <code>JobExecution</code>에는 보통 다음과 같은 정보가 기록된다.</p>
<ul>
<li>실행 상태</li>
<li>시작 시간</li>
<li>종료 시간</li>
<li>종료 코드</li>
<li>실패 예외</li>
<li>ExecutionContext</li>
</ul>
<p>&nbsp;</p>
<h3 id="🥗-step">🥗 Step</h3>
<p><code>Step</code>은 <code>Job</code>을 구성하는 독립적인 실행 단계다. 하나의 <code>Job</code>은 하나 이상의 <code>Step</code>으로 구성되어 있다.</p>
<p>예를 들어 월말 정산 <code>Job</code>은 다음과 같이 여러 <code>Step</code>으로 나눌 수 있다.</p>
<pre><code>월말 정산 Job
 ├─ Step 1. 정산 대상 판매자 조회
 ├─ Step 2. 판매자별 정산 금액 계산
 ├─ Step 3. 정산 결과 저장
 └─ Step 4. 정산 완료 알림 발송</code></pre><p>각 <code>Step</code>은 독립적인 실행 단위다. 앞의 <code>Step</code>이 성공해야 다음 <code>Step</code>이 실행되도록 구성할 수도 있고, 조건에 따라 다른 <code>Step</code>으로 분기할 수도 있다.</p>
<p>&nbsp;</p>
<h3 id="🍤-stepexecution">🍤 StepExecution</h3>
<p><code>StepExecution</code>은 <code>JobExecution</code> 안에서 각각의 <code>Step</code>이 실행된 정보를 의미한다.</p>
<p><code>JobExecution</code>이 <code>Job</code>의 실행 시도라면, <code>StepExecution</code>은 <code>Step</code>의 실행 시도다. 하나의 <code>Job</code> 안에 <code>Step</code>이 여러 개 있으면, 각 <code>Step</code>마다 <code>StepExecution</code>이 생성된다.</p>
<p>예를 들어 월말 정산 <code>Job</code>에 <code>Step</code>이 3개 있다면 다음과 같이 볼 수 있다.</p>
<pre><code>JobExecution
 ├─ StepExecution 1. 정산 대상 조회 Step 실행 정보
 ├─ StepExecution 2. 정산 금액 계산 Step 실행 정보
 └─ StepExecution 3. 정산 결과 저장 Step 실행 정보</code></pre><p><code>StepExecution</code>에는 해당 <code>Step</code>의 상태와 처리 건수 등이 기록된다.</p>
<p>예를 들면 다음과 같은 정보가 저장될 수 있다.</p>
<ul>
<li>Step 시작 시간</li>
<li>Step 종료 시간</li>
<li>Step 실행 상태</li>
<li>읽은 데이터 수</li>
<li>저장한 데이터 수</li>
<li>커밋 횟수</li>
<li>롤백 횟수</li>
<li>Skip 횟수</li>
</ul>
<p>&nbsp;</p>
<h3 id="🥟-executioncontext">🥟 ExecutionContext</h3>
<p><code>ExecutionContext</code>는 <code>JobExecution</code> 또는 <code>StepExecution</code> 범위에서 사용할 수 있는 key-value 형태의 상태 저장 공간이다.</p>
<p>쉽게 말하면 배치 실행 중 유지해야 하는 상태를 저장하는 공간이다. 예를 들어 다음과 같은 정보를 저장할 수 있다.</p>
<ul>
<li>마지막으로 읽은 파일 위치</li>
<li>마지막으로 처리한 ID</li>
<li>Step 간 공유해야 하는 값</li>
<li>재시작 시 복원해야 하는 상태</li>
</ul>
<p>Spring Batch는 <code>ExecutionContext</code>를 메타데이터 테이블에 저장한다. 그래서 배치 작업이 중간에 실패했다가 재시작될 때, 이전 실행에서 저장해둔 <code>ExecutionContext</code> 값을 다시 읽어와 이어서 처리할 수 있다.</p>
<p>즉, <code>ExecutionContext</code>는 재시작 기능과 밀접하게 연결된 개념이다.</p>
<p>&nbsp;</p>
<h3 id="🍟-itemreader">🍟 ItemReader</h3>
<p><code>ItemReader</code>는 데이터를 읽는 역할을 한다. 읽어오는 대상은 다양할 수 있다.</p>
<ul>
<li>파일</li>
<li>데이터베이스</li>
<li>메시지 큐</li>
<li>외부 API</li>
<li>다른 시스템의 데이터</li>
</ul>
<p><code>ItemReader</code>는 데이터를 한 건씩 읽어서 다음 단계로 넘긴다. 예를 들어 CSV 파일에서 데이터를 읽는다면 <code>FlatFileItemReader</code>를 사용할 수 있고, 데이터베이스에서 페이징 방식으로 데이터를 읽는다면 <code>JdbcPagingItemReader</code>를 사용할 수 있다.</p>
<p><code>ItemReader</code>의 중요한 특징은 읽을 데이터가 더 이상 없을 때 <code>null</code>을 반환한다는 것이다. Spring Batch는 이 <code>null</code> 반환을 보고 더 이상 처리할 데이터가 없다고 판단한다.</p>
<p>&nbsp;</p>
<h3 id="🍭-itemprocessor">🍭 ItemProcessor</h3>
<p><code>ItemProcessor</code>는 <code>ItemReader</code>가 읽은 데이터를 가공하거나 검증하는 역할을 한다. 예를 들어 정산 배치라면 <code>ItemProcessor</code>에서 다음과 같은 작업을 할 수 있다.</p>
<ul>
<li>판매 금액 계산</li>
<li>수수료 계산</li>
<li>환불 금액 차감</li>
<li>정산 대상 검증</li>
<li>잘못된 데이터 필터링</li>
<li>Entity를 저장용 DTO로 변환</li>
</ul>
<p><code>ItemProcessor</code>는 필수는 아니다. 데이터를 읽은 그대로 저장해도 된다면 <code>ItemProcessor</code> 없이 <code>ItemReader</code>와 <code>ItemWriter</code>만으로 <code>Step</code>을 구성할 수 있다.</p>
<p>하지만 실제 비즈니스 로직에서는 데이터를 가공하거나 검증해야 하는 경우가 많기 때문에 자주 사용된다. 또한 <code>ItemProcessor</code>에서 <code>null</code>을 반환하면 해당 아이템은 이후 <code>Writer</code>로 전달되지 않는다.</p>
<p>즉, 특정 조건에 맞지 않는 데이터를 필터링할 때 사용할 수 있다.</p>
<p>&nbsp;</p>
<h3 id="🍫-itemwriter">🍫 ItemWriter</h3>
<p><code>ItemWriter</code>는 처리된 데이터를 저장하는 역할을 한다. <code>ItemProcessor</code>를 거친 데이터는 Chunk 단위로 모이고, 정해진 개수만큼 쌓이면 <code>ItemWriter</code>가 한 번에 저장한다.</p>
<p>저장 대상은 다양할 수 있다.</p>
<ul>
<li>데이터베이스</li>
<li>파일</li>
<li>메시지 큐</li>
<li>외부 시스템</li>
</ul>
<p>예를 들어 정산 배치라면 <code>ItemWriter</code>는 계산된 정산 결과를 정산 테이블에 저장할 수 있다.</p>
<p><code>ItemWriter</code>는 Chunk 단위로 데이터를 전달받기 때문에, 한 건씩 저장하기보다는 여러 건을 모아서 저장하는 구조에 가깝다.</p>
<p>&nbsp;</p>
<h3 id="🍰-data-source">🍰 Data Source</h3>
<p><code>Data Source</code>는 배치 작업이 읽거나 쓰는 외부 데이터 저장소를 의미한다. 대표적으로 다음과 같은 것들이 있다.</p>
<ul>
<li>File System</li>
<li>JMS Queue</li>
<li>Database</li>
<li>외부 API</li>
<li>다른 애플리케이션 시스템</li>
</ul>
<p>Spring Batch의 <code>ItemReader</code>는 Data Source에서 데이터를 읽고, <code>ItemWriter</code>는 처리 결과를 Data Source에 쓴다.</p>
<p>정산 배치를 예로 들면 다음과 같이 볼 수 있다.</p>
<pre><code>Database에서 주문 데이터를 읽는다.
    ↓
정산 금액을 계산한다.
    ↓
Database에 정산 결과를 저장한다.</code></pre><p>즉, Data Source는 배치 작업의 입력이자 출력 대상이 될 수 있다.</p>
<p>&nbsp;</p>
<h3 id="🍜-jobrepository">🍜 JobRepository</h3>
<p><code>JobRepository</code>는 Spring Batch의 실행 메타데이터를 저장하고 관리하는 저장소다. </p>
<p>여기서 중요한 점은 <code>JobRepository</code>가 비즈니스 데이터를 저장하는 저장소가 아니라는 것이다. 예를 들어 정산 결과 자체는 정산 테이블에 저장된다. 반면 <code>JobRepository</code>는 다음과 같은 배치 실행 이력을 저장한다.</p>
<ul>
<li>어떤 Job이 실행되었는지</li>
<li>어떤 파라미터로 실행되었는지</li>
<li>Job이 언제 시작되고 끝났는지</li>
<li>Job이 성공했는지 실패했는지</li>
<li>어떤 Step이 실행되었는지</li>
<li>Step에서 몇 건을 읽었는지</li>
<li>Step에서 몇 건을 썼는지</li>
<li>Step에서 몇 건을 건너뛰었는지</li>
</ul>
<p>Spring Batch는 이 정보를 메타데이터 테이블에 저장한다. 대표적인 테이블은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>테이블</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>BATCH_JOB_INSTANCE</td>
<td>JobInstance 정보 저장</td>
</tr>
<tr>
<td>BATCH_JOB_EXECUTION</td>
<td>JobExecution 정보 저장</td>
</tr>
<tr>
<td>BATCH_JOB_EXECUTION_PARAMS</td>
<td>JobParameters 정보 저장</td>
</tr>
<tr>
<td>BATCH_STEP_EXECUTION</td>
<td>StepExecution 정보 저장</td>
</tr>
<tr>
<td>BATCH_JOB_EXECUTION_CONTEXT</td>
<td>JobExecutionContext 저장</td>
</tr>
<tr>
<td>BATCH_STEP_EXECUTION_CONTEXT</td>
<td>StepExecutionContext 저장</td>
</tr>
</tbody></table>
<p><code>JobRepository</code>가 중요한 이유는 재시작과 추적 때문이다. 배치 작업이 중간에 실패해도, Spring Batch는 <code>JobRepository</code>에 저장된 실행 이력을 바탕으로 어떤 <code>Job</code>이 어떤 상태였는지 확인할 수 있다.</p>
<p><code>JobRepository</code>는 크게 3가지 시점에서 동작한다.</p>
<ol>
<li><p>Job이 시작되기 전: 같은 이름과 파라미터로 조합으로 이전에 실행된 이력이 존재하는지 조회한다.</p>
</li>
<li><p>Job이 실행되는 중: Step이 처리한 건수, 현재 실행 상태 같은 Spring Batch에서 참조할 수 있는 진행 정보를 주기적으로 업데이트한다.</p>
</li>
<li><p>Job이 종료: 최종 상태를 기록한다. (COMPLETED, FAILED 등)</p>
</li>
</ol>
<p>&nbsp;</p>
<h2 id="📝-전체-흐름-요약">📝 전체 흐름 요약</h2>
<p>마지막으로 지금까지 나온 용어를 하나의 흐름으로 정리하면 다음과 같다.</p>
<pre><code>1. Triggering System이 배치 실행을 시작한다.

2. JobLauncher가 Job과 JobParameters를 받아 Job을 실행한다.

3. Spring Batch는 Job 이름과 JobParameters로 JobInstance를 식별한다.

4. JobInstance의 실제 실행 시도로 JobExecution이 생성된다.

5. Job은 하나 이상의 Step을 실행한다.

6. 각 Step 실행 정보는 StepExecution으로 기록된다.

7. Step은 Tasklet 또는 Chunk 방식으로 실행된다.

8. Chunk 방식에서는 ItemReader, ItemProcessor, ItemWriter가 Read → Process → Write 흐름으로 데이터를 처리한다.

9. ItemReader와 ItemWriter는 File, Queue, Database 같은 Data Source와 데이터를 주고받는다.

10. JobRepository는 이 모든 실행 이력을 메타데이터 테이블에 저장한다.</code></pre><hr>
<p><strong>&lt;참고 자료&gt;</strong>
<a href="https://www.toptal.com/developers/spring/spring-batch-tutorial">Spring Batch Tutorial: Batch Processing Made Easy with Spring</a>
<a href="https://gngsn.tistory.com/177#google_vignette">Spring Batch, 제대로 이해하기 (1) - 개념이해</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[eGovFrame VSCode Initializr 오픈소스 기여: txAdvisor 직접 호출 안티패턴 수정]]></title>
            <link>https://velog.io/@rocker_nun/eGovFrame-VSCode-Initializr-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-%EA%B8%B0%EC%97%AC-txAdvisor-%EC%A7%81%EC%A0%91-%ED%98%B8%EC%B6%9C-%EC%95%88%ED%8B%B0%ED%8C%A8%ED%84%B4-%EC%88%98%EC%A0%95</link>
            <guid>https://velog.io/@rocker_nun/eGovFrame-VSCode-Initializr-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-%EA%B8%B0%EC%97%AC-txAdvisor-%EC%A7%81%EC%A0%91-%ED%98%B8%EC%B6%9C-%EC%95%88%ED%8B%B0%ED%8C%A8%ED%84%B4-%EC%88%98%EC%A0%95</guid>
            <pubDate>Fri, 12 Jun 2026 08:45:01 GMT</pubDate>
            <description><![CDATA[<h1 id="🔍-문제의-발견">🔍 문제의 발견</h1>
<p>eGovFrame VSCode Initializr는 전자정부 표준프레임워크 기반 프로젝트를 VS Code 환경에서 쉽게 생성할 수 있도록 도와주는 도구다. 프로젝트 생성뿐만 아니라 CRUD 코드 생성, 설정 파일 생성 기능도 제공한다.</p>
<p>그중 설정 파일 생성 기능은 Spring Framework 설정 파일을 XML, Java Config, YAML, Properties 형식으로 생성할 수 있게 해준다. 트랜잭션 설정의 경우 <code>templates/templates-context-xml.json</code> 파일에 다음 세 가지 항목으로 등록되어 있었다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a9b546a8-d430-4920-b886-27f45cbebd88/image.png" alt=""></p>
<p>즉, 사용자가 설정 파일 생성 탭에서 트랜잭션 관련 설정을 만들 때 실제로 연결되는 템플릿 위치는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c36acd98-7f5a-490c-b625-295727e9dbf5/image.png" alt=""></p>
<p>여기서 <code>datasource.hbs</code>, <code>jpa.hbs</code>, <code>jta.hbs</code>는 XML 설정 템플릿이고, <code>datasource-java.hbs</code>, <code>jpa-java.hbs</code>, <code>jta-java.hbs</code>는 Java Config 설정 템플릿이다.</p>
<p>문제는 Java Config 템플릿에서 발견됐다. AOP 기반 트랜잭션 설정을 활성화하고 Java Config 파일을 생성하면, <code>txAdvisor</code> Bean 메서드 내부에서 <code>txAdvice(txManager)</code>를 직접 호출하는 코드가 생성되고 있었다.</p>
<p>&nbsp;</p>
<p>수정 전 코드는 다음과 같은 형태였다.</p>
<pre><code class="language-java">@Bean 
public Advisor txAdvisor(@Qualifier(&quot;txManager&quot;) DataSourceTransactionManager txManager) { 
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); 
    pointcut.setExpression( &quot;execution(* egovframework.example..*Impl.*(..)) &quot; + &quot;|| execution(* org.egovframe.rte.fdl.excel.impl.*Impl.*(..))&quot; ); 
    return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager)); 
}</code></pre>
<p>처음에는 이 코드가 크게 이상해 보이지 않을 수 있다. <code>txAdvisor</code>를 만들 때 필요한 <code>txAdvice</code>를 호출해서 넣어주는 것처럼 보이기 때문이다.</p>
<p>하지만 이 코드는 Spring 설정 클래스에서 다른 <code>@Bean</code> 메서드를 직접 호출하는 패턴이다. 그리고 이 패턴은 <code>@Configuration</code>의 동작 방식, 특히 <code>proxyBeanMethods</code> 설정에 따라 미묘하거나 실제적인 문제를 만들 수 있다.</p>
<p>&nbsp;</p>
<h2 id="❓-aop-config-transaction-옵션은-무엇인가">❓ AOP Config Transaction 옵션은 무엇인가?</h2>
<p>먼저 이 문제가 언제 발생하는지부터 정리해봤다.</p>
<p>설정 파일 생성 화면에는 <code>AOP Config Transaction</code>이라는 체크박스가 있다. 이 체크박스를 선택하면 내부 폼 데이터의 <code>chkAopConfigTransaction</code> 값이 <code>true</code>가 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9223e972-d130-41f2-bd3d-2c72ec00ae57/image.png" alt=""></p>
<p>그리고 템플릿 파일에서는 이 값을 조건문으로 사용한다. 즉, 사용자가 AOP 기반 트랜잭션 설정을 체크하면 <code>{{#if chkAopConfigTransaction}} ... {{/if}}</code> 블록 안에 있는 코드가 실제 생성 파일에 포함된다.</p>
<p>Java Config 템플릿에서는 다음과 같은 코드가 조건부로 생성된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/daf74151-4c80-4aa7-93d6-0b15f1bdf82e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/8c6fc783-4721-4982-bb50-71dc935342ef/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/26e4733c-974e-4fba-894e-b86cd5988520/image.png" alt=""></p>
<p>정리하면, 아래와 같은 흐름이다. </p>
<ol>
<li>사용자가 AOP Config Transaction 체크 </li>
<li><code>chkAopConfigTransaction</code> 값이 true가 됨</li>
<li>템플릿의 <code>{{#if chkAopConfigTransaction}}</code> 블록 활성화</li>
<li><code>@EnableAspectJAutoProxy(proxyTargetClass = true)</code>, <code>txAdvice</code>, <code>txAdvisor</code> 코드 생성</li>
</ol>
<p>&nbsp;</p>
<p>여기서 중요한 점은 <code>chkAopConfigTransaction</code>이 단순히 <code>@EnableAspectJAutoProxy</code>만 생성하는 옵션은 아니라는 것이다. 이 옵션이 켜지면 AOP 기반 트랜잭션 설정에 필요한 코드가 함께 생성된다. 대표적으로 다음 코드들이 포함된다.</p>
<pre><code class="language-java">@Bean(name = &quot;{{txtAdviceName}}&quot;)
public TransactionInterceptor txAdvice(PlatformTransactionManager txManager) {
    TransactionInterceptor txAdvice = new TransactionInterceptor();
    txAdvice.setTransactionManager(txManager);
    txAdvice.setTransactionAttributeSource(getNameMatchTransactionAttributeSource());
    return txAdvice;
}

@Bean
public Advisor txAdvisor(...) {
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
    pointcut.setExpression(&quot;{{txtPointCutExpression}}&quot;);
    return new DefaultPointcutAdvisor(pointcut, txAdvice);
}</code></pre>
<p>즉, <code>chkAopConfigTransaction</code>은 AOP 방식으로 트랜잭션을 적용하는 설정 코드를 생성할지 여부를 나타내는 옵션이다.</p>
<p>&nbsp;</p>
<h3 id="🤔-enableaspectjautoproxy-애노테이션">🤔 @EnableAspectJAutoProxy 애노테이션?</h3>
<p>AOP Config Transaction을 체크하면 Java Config 파일에 다음 애노테이션이 생성된다.</p>
<pre><code class="language-java">@EnableAspectJAutoProxy(proxyTargetClass = true)</code></pre>
<p>이 애노테이션을 한 문장으로 정리하면 다음과 같다.</p>
<blockquote>
<p><strong>Spring에게 AOP 프록시를 자동으로 만들어서, 특정 메서드 호출 앞뒤에 공통 기능을 끼워 넣을 수 있게 해달라고 요청하는 설정이다.</strong></p>
</blockquote>
<p>Spring 공식 문서에서는 <code>@EnableAspectJAutoProxy</code>를 AspectJ의 <code>@Aspect</code> 애노테이션이 붙은 컴포넌트를 처리할 수 있도록 지원을 활성화하는 애노테이션이라고 설명한다. XML 설정의 <code>&lt;aop:aspectj-autoproxy&gt;</code>와 비슷한 기능이며, 보통 <code>@Configuration</code> 클래스에 붙여 사용한다.</p>
<p>&nbsp;</p>
<p>예를 들면 다음과 같은 구조다.</p>
<pre><code class="language-java">@Configuration
@EnableAspectJAutoProxy
public class AppConfig {

    @Bean
    public FooService fooService() {
        return new FooService();
    }

    @Bean
    public MyAspect myAspect() {
        return new MyAspect();
    }
}</code></pre>
<pre><code class="language-java">@Aspect
public class MyAspect {

    @Before(&quot;execution(* FooService+.*(..))&quot;)
    public void advice() {
        // advise FooService methods as appropriate
    }
}</code></pre>
<p>이 경우 Spring은 <code>MyAspect</code>를 AOP 설정으로 인식하고, <code>FooService</code>에 Advice를 적용하기 위해 필요한 프록시를 생성한다.</p>
<p>흐름을 간단히 정리하면 다음과 같다.</p>
<ol>
<li><code>@EnableAspectJAutoProxy</code> 애노테이션을 붙인다.</li>
<li>Spring이 AOP 설정을 읽을 수 있게 된다.</li>
<li>어드바이스를 적용해야 하는 Bean을 찾는다.</li>
<li>그 Bean 앞에 프록시 객체를 세운다.</li>
<li>메서드 호출을 프록시가 가로챈다.</li>
<li>공통 기능 실행 후 실제 메서드를 호출한다.</li>
</ol>
<p>즉, <code>@EnableAspectJAutoProxy</code>는 Spring AOP 프록시 기능을 활성화하는 역할을 한다.</p>
<p>&nbsp;</p>
<p>다만 eGovFrame 템플릿에서는 <code>@Aspect</code> 클래스를 직접 만드는 방식이 아니라, <code>Advisor</code>, <code>Pointcut</code>, <code>TransactionInterceptor</code>를 직접 Bean으로 등록하는 방식이다.</p>
<pre><code class="language-java">@Bean 
public Advisor txAdvisor(@Qualifier(&quot;txAdvice&quot;) TransactionInterceptor txAdvice) { 
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); 
    pointcut.setExpression(&quot;execution(* egovframework.example..*Impl.*(..))&quot;); 
    return new DefaultPointcutAdvisor(pointcut, txAdvice); 
}</code></pre>
<p>이 구조는 다음과 같이 이해할 수 있다.</p>
<ul>
<li><code>@AspectJExpressionPointcut</code>: 어떤 메서드에 트랜잭션을 적용할지 결정한다.</li>
<li><code>@TransactionInterceptor</code>: 트랜잭션 시작, 커밋, 롤백을 처리한다.</li>
<li><code>@DefaultPointcutAdvisor</code>: Pointcut과 Advice를 묶는다.</li>
<li><code>@EnableAspectJAutoProxy</code>: Advisor를 보고 대상 Bean에 AOP 프록시를 적용할 수 있게 한다.</li>
</ul>
<p>결과적으로 포인트컷 표현식에 매칭되는 메서드가 호출되면, Spring AOP 프록시가 호출을 가로채고 <code>TransactionInterceptor</code>가 트랜잭션 처리를 수행한다.</p>
<p>&nbsp;</p>
<p>실행 흐름은 간단히 정리하면 다음과 같다.</p>
<ol>
<li><code>ServiceImpl</code> 메서드 호출</li>
<li>Spring AOP 프록시가 호출을 가로챔</li>
<li><code>txAdvisor</code>의 포인트컷에 매칭되는지 확인</li>
<li>매칭되면 <code>txAdvice(TransactionInterceptor)</code> 실행</li>
<li><code>PlatformTransactionManager</code>로 트랜잭션 시작</li>
<li>실제 <code>ServiceImpl</code> 메서드 실행</li>
<li>정상 종료면 커밋</li>
<li>예외 발생하면 롤백</li>
</ol>
<p>&nbsp;</p>
<h3 id="🎭--proxytargetclass--true는-무엇인가">🎭  proxyTargetClass = true는 무엇인가?</h3>
<p><code>@EnableAspectJAutoProxy</code>에는 <code>proxyTargetClass</code>라는 속성이 있다. </p>
<p>공식 문서 기준으로 <code>proxyTargetClass</code>의 기본값은 <code>false</code>다. 기본값인 <code>false</code>에서는 가능한 경우 인터페이스 기반 JDK 동적 프록시를 사용한다. 반면 <code>true</code>로 설정하면 CGLIB 스타일의 클래스 기반 프록시를 사용한다. 이처럼 Spring AOP의 프록시 방식은 크게 두 가지로 나눌 수 있다.</p>
<p>&nbsp;</p>
<p>첫 번째는 JDK 동적 프록시다.</p>
<pre><code class="language-java">public interface OrderService { 
    void saveOrder(); 
} 

public class OrderServiceImpl implements OrderService { 
    @Override 
    public void saveOrder() { 
        // 주문 저장 
    } 
}</code></pre>
<p>JDK 동적 프록시는 인터페이스를 기반으로 프록시를 만든다. 즉, <code>OrderService</code> 인터페이스를 구현한 프록시 객체가 생성되고, 실제 호출은 <code>OrderServiceImpl</code>로 위임된다.</p>
<p>&nbsp;</p>
<p>두 번째는 CGLIB 프록시다.</p>
<pre><code class="language-java">public class OrderServiceImpl { 
    public void saveOrder() { 
        // 주문 저장 
    } 
}</code></pre>
<p>CGLIB는 클래스를 상속해서 프록시를 만든다. 개념적으로는 아래 코드와 유사하다.</p>
<pre><code class="language-java">public class OrderServiceImplProxy extends OrderServiceImpl { 
    @Override 
    public void saveOrder() { 
        // 공통 기능 실행 
        super.saveOrder(); 
        // 공통 기능 실행 
    } 
}</code></pre>
<p>물론 실제 코드는 Spring과 CGLIB가 런타임에 생성하지만, 이해는 이런 식으로 하면 된다. eGovFrame 템플릿의 기본 포인트컷 표현식에는 다음과 같이 <code>*Impl.*(..)</code> 패턴이 들어 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b7076ef1-4480-4c9e-a5a9-81731772c2e6/image.png" alt=""></p>
<p>즉, 기본적으로 <code>*Impl</code>로 끝나는 구현 클래스의 메서드를 트랜잭션 적용 대상으로 삼으려는 의도로 볼 수 있다. 그래서 템플릿에서 <code>proxyTargetClass = true</code>를 사용한 것은 구현 클래스 중심의 전통적인 eGovFrame 서비스 구조에서 AOP 적용을 더 명확하게 하기 위한 설정으로 볼 수 있다.</p>
<p>&nbsp;</p>
<p>다만 여기서 주의할 점이 있다. <code>@EnableAspectJAutoProxy(proxyTargetClass = true)</code>는 트랜잭션을 직접 처리하는 애노테이션이 아니다. 이 애노테이션은 AOP 프록시 생성을 활성화하는 역할을 한다. 실제 트랜잭션 처리는 <code>TransactionInterceptor</code>가 담당한다.</p>
<p>정리하면 다음과 같다.</p>
<ol>
<li><code>@EnableAspectJAutoProxy(proxyTargetClass = true)</code></li>
<li>클래스 기반 AOP 프록시 생성 기능 활성화 <code>AspectJExpressionPointcut</code> </li>
<li>어떤 메서드에 적용할지 결정 <code>TransactionInterceptor</code> </li>
<li>트랜잭션 시작, 커밋, 롤백 처리 <code>DefaultPointcutAdvisor</code> </li>
<li>포인트컷과 어드바이스를 하나로 묶음</li>
</ol>
<p>&nbsp;</p>
<h2 id="💥-문제-코드-txadvicetxmanager-직접-호출">💥 문제 코드: txAdvice(txManager) 직접 호출</h2>
<p>이제 다시 문제 코드로 돌아가보자.</p>
<p>AOP 기반 트랜잭션 설정을 Java Config로 생성하면 기존 템플릿에서는 다음과 같은 코드가 만들어졌다.</p>
<pre><code class="language-java">@Bean
public Advisor txAdvisor(@Qualifier(&quot;txManager&quot;) DataSourceTransactionManager txManager) {
    ...
    return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager));
}</code></pre>
<p>&nbsp;</p>
<p>문제는 마지막 줄이다. <code>txAdvice(txManager)</code>와 같이 설정 클래스 안에 있는 다른 <code>@Bean</code> 메서드를 직접 호출하고 있었다.</p>
<pre><code class="language-java">@Bean(name = &quot;{{txtAdviceName}}&quot;)
public TransactionInterceptor txAdvice(@Qualifier(&quot;{{txtTransactionName}}&quot;) DataSourceTransactionManager transactionManager) {
    TransactionInterceptor txAdvice = new TransactionInterceptor();
    txAdvice.setTransactionManager(transactionManager);
    txAdvice.setTransactionAttributeSource(getNameMatchTransactionAttributeSource());
    return txAdvice;
}</code></pre>
<p>겉으로 보면 단순히 <code>txAdvisor</code>를 만들기 위해 필요한 <code>txAdvice</code>를 생성해서 넣는 것처럼 보인다. 하지만 Spring 설정 클래스에서 <code>@Bean</code> 메서드를 직접 호출하는 것은 일반 Java 메서드 호출과 다르게 해석될 수 있다.</p>
<p>이 문제를 이해하려면 <code>@Configuration</code>과 <code>@Bean</code>의 동작 방식을 다시 살펴봐야 한다.</p>
<p>&nbsp;</p>
<h3 id="🏭-configuration과-bean-메서드-호출">🏭 @Configuration과 @Bean 메서드 호출</h3>
<p>보통 Spring 설정 클래스는 다음과 같이 작성한다.</p>
<pre><code class="language-java">@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(
            memberRepository(),
            discountPolicy());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();  
    }
}</code></pre>
<p>여기서 <code>@Configuration</code>은 이 클래스가 Spring 설정 클래스라는 뜻이고, <code>@Bean</code>은 이 메서드가 반환하는 객체를 Spring 컨테이너에 Bean으로 등록하겠다는 뜻이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/bc67a069-8d0e-4864-993f-840716ae1eab/image.png" alt=""></p>
<p>그런데 중요한 부분은 다음과 같은 코드다.</p>
<pre><code class="language-java">@Bean
public MemberService memberService() {
    return new MemberServiceImpl(memberRepository());
}</code></pre>
<p><code>memberService()</code> 메서드 안에서 <code>memberRepository()</code> 메서드를 직접 호출하고 있다. 또 <code>orderService()</code> 메서드 안에서도 <code>memberRepository()</code>와 <code>discountPolicy()</code>를 직접 호출한다.</p>
<p>순수 Java 코드라면 <code>memberRepository()</code>를 호출할 때마다 매번 새로운 <code>MemoryMemberRepository</code> 인스턴스가 생성될 것이다. 이러면 <code>MemberServiceImpl</code>이 사용하는 <code>MemoryMemberRepository</code>와 <code>OrderServiceImpl</code>이 사용하는 <code>MemoryMemberRepository</code>가 서로 다른 객체가 될 수 있다.</p>
<p>&nbsp;</p>
<p>하지만 <code>@Configuration</code>이 붙은 Spring 설정 클래스에서는 기본적으로 그렇게 동작하지 않는다. Spring은 <code>@Configuration</code> 클래스를 그냥 그대로 사용하지 않고, CGLIB를 이용해 설정 클래스를 상속한 프록시 객체를 만든다. 개념적으로는 다음과 같은 느낌이다.</p>
<pre><code class="language-java">class AppConfigProxy extends AppConfig {

    private final SimpleSpringContainer springContainer = new SimpleSpringContainer();

    @Override
    public MemberService memberService() {
        return springContainer.getBean(&quot;memberService&quot;, () -&gt; super.memberService());
    }

    @Override
    public OrderService orderService() {
        return springContainer.getBean(&quot;orderService&quot;, () -&gt; super.orderService());
    }

    @Override
    public MemberRepository memberRepository() {
        return springContainer.getBean(&quot;memberRepository&quot;, () -&gt; super.memberRepository());
    }

    @Override
    public DiscountPolicy discountPolicy() {
        return springContainer.getBean(&quot;discountPolicy&quot;, () -&gt; super.discountPolicy());
    }
}</code></pre>
<p><code>@Bean</code> 메서드가 호출되면 곧바로 새로운 인스턴스를 생성하는 것이 아니라, 먼저 Spring 컨테이너에 해당 Bean이 이미 존재하는지 확인한다. 이미 존재하면 기존 Bean을 반환하고, 아직 없다면 원본 메서드를 실행해 Bean을 생성한 뒤 컨테이너에 등록한다.</p>
<p>&nbsp;</p>
<h3 id="🎏-full-mode와-lite-mode">🎏 Full mode와 Lite mode</h3>
<p>여기서 중요한 개념이 <strong>full mode</strong>와 <strong>lite mode</strong>다.</p>
<p>일반적으로 다음처럼 <code>@Configuration</code> 애노테이션을 붙여서 작성하면,</p>
<pre><code class="language-java">@Configuration
public class AppConfig {}</code></pre>
<p><code>@Configuration(proxyBeanMethods = true)</code>로 설정된다. 이 경우 Spring은 설정 클래스를 CGLIB 프록시로 감싸고, <code>@Bean</code> 메서드 간 직접 호출을 가로챈다. 이를 보통 full mode라고 이해할 수 있다. </p>
<p>반면 <code>@Configuration(proxyBeanMethods = false)</code> 로 설정하면 Spring은 설정 클래스를 CGLIB 프록시로 감싸지 않는다. 이 경우 <code>@Bean</code> 메서드는 일반 Java 메서드처럼 동작한다. 이를 lite mode라고 볼 수 있다. lite mode에서는 다음 코드가 진짜 일반 Java 메서드 호출이 된다.</p>
<pre><code class="language-java">@Bean 
public MemberService memberService() { 
    return new MemberServiceImpl(memberRepository()); 
}</code></pre>
<p>즉, <code>memberRepository()</code>를 호출할 때마다 새로운 <code>MemoryMemberRepository</code> 인스턴스가 만들어질 수 있다.</p>
<p>그래서 <code>proxyBeanMethods = false</code>를 사용할 때는 보통 다음처럼 작성하는 것이 좋다.</p>
<pre><code class="language-java">@Configuration(proxyBeanMethods = false)
public class AppConfig {

    @Bean
    public MemberService memberService(MemberRepository memberRepository) {
        return new MemberServiceImpl(memberRepository);
    }

    @Bean
    public OrderService orderService(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        return new OrderServiceImpl(memberRepository, discountPolicy);
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();  
    }
}</code></pre>
<p>이 방식은 <code>memberRepository()</code>를 직접 호출하지 않는다. 대신 Spring 컨테이너가 관리하는 <code>MemberRepository</code> Bean을 메서드 파라미터로 주입받는다.</p>
<p>Spring 공식 문서에서도 런타임 프록시가 없는 설정 클래스에서는 <code>@Bean</code> 메서드가 다른 <code>@Bean</code> 메서드를 직접 호출할 필요가 없고, 그런 호출은 팩토리 메서드 인자로 표현할 수 있다고 설명한다.</p>
<p>즉, 핵심을 간단하게 다음과 같다.</p>
<ul>
<li><code>@Bean</code> 메서드 직접 호출<ul>
<li><code>proxyBeanMethods = true</code>에서는 Spring이 가로채므로 겉으로는 동작 가능</li>
<li><code>proxyBeanMethods = false</code>에서는 일반 Java 메서드 호출이 되어 새로운 인스턴스가 생성</li>
</ul>
</li>
<li>파라미터 주입<ul>
<li><code>proxyBeanMethods</code> 설정과 무관하게 Spring 컨테이너가 관리하는 Bean을 사용</li>
</ul>
</li>
</ul>
<p>&nbsp;</p>
<h2 id="⏮️-다시-txadvisor-문제로-돌아가보자">⏮️ 다시 <code>txAdvisor</code> 문제로 돌아가보자</h2>
<p>이제 기존 코드가 왜 문제인지 더 명확해진다. 수정 전 코드는 다음과 같았다.</p>
<pre><code class="language-java">@Bean
public Advisor txAdvisor(@Qualifier(&quot;txManager&quot;) DataSourceTransactionManager txManager) {
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
    pointcut.setExpression(&quot;execution(* egovframework.example..*Impl.*(..)) || execution(* org.egovframe.rte.fdl.excel.impl.*Impl.*(..))&quot;);
    return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager));  // 직접 호출
}</code></pre>
<p>여기서 <code>txAdvice(txManager)</code>는 다른 <code>@Bean</code> 메서드를 직접 호출하는 코드다.</p>
<p>기본 <code>@Configuration(proxyBeanMethods = true)</code> 환경에서는 Spring이 이 호출을 가로챈다. 그래서 겉으로는 문제가 없어 보일 수 있다. Spring은 <code>txAdvice(txManager)</code>를 일반 Java 메서드 호출로 두지 않고, 컨테이너가 관리하는 <code>txAdvice</code> Bean을 반환한다.</p>
<p>&nbsp;</p>
<p>하지만 이 상황에서도 코드의 의미는 어색하다.</p>
<pre><code class="language-java">txAdvice(txManager)</code></pre>
<p>이 코드는 마치 <code>txManager</code>를 직접 넘겨서 <code>txAdvice</code>를 새로 만들거나 구성하는 것처럼 보인다. 하지만 실제 런타임에서는 Spring 컨테이너가 이미 관리 중인 <code>txAdvice</code> Bean을 반환한다. 이때 <code>txAdvice(txManager)</code>에 전달한 <code>txManager</code> 인자는 개발자가 예상하는 방식으로 사용되지 않을 수 있다.</p>
<p>즉, 코드의 겉모습은 <code>txAdvisor</code>를 만들 때 <code>txManager</code>를 넘겨서 <code>txAdvice</code>를 구성하는 것처럼 보이지만, 실제 동작은 Spring 컨테이너에서 이미 관리 중인 <code>txAdvice</code> Bean을 가져오는 것이다. 이렇게 코드의 표현과 실제 동작이 다르면, 나중에 이 코드를 읽는 개발자가 의도를 오해할 수 있다.</p>
<p>&nbsp;</p>
<p>더 큰 문제는 <code>proxyBeanMethods = false</code> 환경에서 발생한다.</p>
<pre><code class="language-java">@Configuration(proxyBeanMethods = false) 
public class TransactionConfig { ... }</code></pre>
<p>이 경우 Spring은 설정 클래스를 CGLIB 프록시로 감싸지 않는다. 따라서 <code>txAdvisor()</code> 안에서 호출한 <code>txAdvice(txManager)</code>는 진짜 일반 Java 메서드 호출이 된다.</p>
<p>&nbsp;</p>
<p>실제로 <code>proxyBeanMethods = false</code>로 설정 후 인스턴스 참조값을 비교해보면 다른 것을 확인할 수 있다.</p>
<pre><code>Spring 관리 txAdvice@1028908791
txAdvisor 내부 advice@ 395686760
→ 동일 인스턴스: false
→ [BUG] 별개 인스턴스 생성됨 — Spring 생명주기 미적용, 트랜잭션 미적용 위험</code></pre><p>그 후, <code>TransactionInterceptor</code>가 새로 생성되어 Spring 컨테이너가 관리하는 <code>txAdvice</code> Bean이 TransactionInterceptor 객체 A를 만들고, <code>txAdvisor</code> 내부에서 직접 호출로 만들어진 <code>txAdvice</code> Bean이 새로운 TransactionInterceptor 객체 B를 만드는 상황이 된다. </p>
<p>&nbsp;</p>
<h2 id="👀-근데-이게-무슨-문제가-될까">👀 근데 이게 무슨 문제가 될까?</h2>
<p><code>txAdvice</code>는 트랜잭션 AOP에서 실제 트랜잭션 처리 로직을 담고 있는 Advice다. 구체적으로는 <code>TransactionInterceptor</code> 객체다. <code>txAdvisor</code>는 어떤 메서드에 이 Advice를 적용할지 결정하는 객체고, 내부적으로 포인트컷과 어드바이스를 함께 가지고 있다.</p>
<p>여기서 중요한 것은 <code>txAdvisor</code>가 참조하는 <code>txAdvice</code>가 Spring 컨테이너가 관리하는 Bean이어야 한다는 점이다. <code>proxyBeanMethods = false</code> 환경에서 직접 호출이 발생하면, Spring 컨테이너가 관리하는 <code>txAdvice</code> Bean과 실제 <code>txAdvisor</code> 내부에 들어간 <code>txAdvice</code> 인스턴스가 달라진다. 겉으로는 트랜잭션이 동작하는 것처럼 보일 수도 있다. 하지만 구조적으로는 위험하다. </p>
<p>왜냐하면 설정, 생명주기, 후처리, 프록시 적용, Bean 관리 관점에서 Spring이 관리하는 객체와 실제 사용 객체가 달라질 수 있기 때문이다. 특히 나중에 <code>txAdvice</code> Bean에 추가 설정이 붙거나, Bean 후처리기가 개입하거나, 특정 스코프나 프록시가 적용되면 더 미묘한 문제가 생길 수 있다.</p>
<p>&nbsp;</p>
<h1 id="👍-해결-직접-호출-대신-파라미터-주입으로-변경">👍 해결: 직접 호출 대신 파라미터 주입으로 변경</h1>
<p>공식 문서가 권장하는 방식도 다른 <code>@Bean</code> 메서드를 직접 호출하지 말고, 필요한 Bean을 메서드 파라미터로 받으라는 것이다.</p>
<p>&nbsp;</p>
<p>수정 전 코드는 다음과 같았다.</p>
<pre><code class="language-java">@Bean 
public Advisor txAdvisor(@Qualifier(&quot;txManager&quot;) DataSourceTransactionManager txManager) { 
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); 
    pointcut.setExpression( &quot;execution(* egovframework.example..*Impl.*(..)) &quot; + &quot;|| execution(* org.egovframe.rte.fdl.excel.impl.*Impl.*(..))&quot; ); 
    return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager)); 
}</code></pre>
<p>&nbsp;</p>
<p>수정 후에는 다음과 같이 변경했다.</p>
<pre><code class="language-java">@Bean 
public Advisor txAdvisor(@Qualifier(&quot;txAdvice&quot;) TransactionInterceptor txAdvice) { 
    AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); 
    pointcut.setExpression( &quot;execution(* egovframework.example..*Impl.*(..)) &quot; + &quot;|| execution(* org.egovframe.rte.fdl.excel.impl.*Impl.*(..))&quot; ); 
    return new DefaultPointcutAdvisor(pointcut, txAdvice); 
}</code></pre>
<p>&nbsp;</p>
<p>첫 번째 변경점은 <code>txAdvisor</code>가 더 이상 <code>txManager</code>를 파라미터로 받지 않도록 한 것이다. 대신 <code>TransactionInterceptor</code> 타입의 <code>txAdvice</code> Bean을 직접 주입받는다.</p>
<pre><code class="language-java">@Qualifier(&quot;txAdvice&quot;) TransactionInterceptor txAdvice</code></pre>
<p>&nbsp;</p>
<p>두 번째 변경점은 <code>txAdvice(txManager)</code> 메서드 직접 호출을 제거한 부분이다. </p>
<pre><code class="language-java">// 수정 전
return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager));

// 수정 후
return new DefaultPointcutAdvisor(pointcut, txAdvice);</code></pre>
<p>이제 <code>txAdvisor()</code>는 <code>txAdvice()</code> 메서드를 직접 호출하지 않는다. Spring에게 <code>txAdvisor</code> Bean을 만들 때 <code>TransactionInterceptor</code> 타입의 <code>txAdvice</code> Bean을 주입을 요청하는 구조가 된다. 이 방식은 훨씬 명확하다. 결과적으로 <code>proxyBeanMethods</code> 설정과 무관하게 <code>txAdvisor</code>는 항상 Spring 컨테이너가 관리하는 <code>txAdvice</code> Bean을 참조한다.</p>
<p>&nbsp;</p>
<h1 id="🎉-pull-request-결과">🎉 Pull Request 결과</h1>
<p>PR의 핵심 내용은 다음과 같다.</p>
<blockquote>
<p><strong>AOP 기반 트랜잭션 Java Config 템플릿에서 <code>txAdvisor</code> Bean이 <code>txAdvice()</code>  Bean 메서드를 직접 호출하던 패턴을 제거하고, <code>@Qualifier</code>를 사용한 파라미터 주입 방식으로 변경했다.</strong></p>
</blockquote>
<p>변경 전에는 다음 구조였다.</p>
<pre><code class="language-java">return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager));</code></pre>
<p>&nbsp;</p>
<p>변경 후에는 다음 구조가 되었다.</p>
<pre><code class="language-java">return new DefaultPointcutAdvisor(pointcut, txAdvice);</code></pre>
<p>이 변경으로 <code>proxyBeanMethods = false</code> 환경에서도 <code>txAdvisor</code>가 Spring 컨테이너가 관리하는 <code>txAdvice</code> Bean을 안정적으로 참조하게 되었다. 그리고 해당 PR은 최종적으로 main 브랜치에 merge되었다.</p>
<p>&nbsp;</p>
<h2 id="😂-이럴거면-왜-proxybeanmethods--false-설정을-제공하는거지">😂 이럴거면 왜 proxyBeanMethods = false 설정을 제공하는거지?</h2>
<p>이렇게 의도치 않은 동작이 발생할 수도 있으니 그냥 항상 true로 두면 안 되는 건지 의문이 들었다. 하지만 proxyBeanMethods = true 는 CGLIB 프록시를 만들어야 한다. 즉, 런타임에 설정 클래스를 상속한 프록시 클래스를 만들고, 메서드 호출을 가로채야 하는데 이건 약간의 비용과 제약이 있다.</p>
<p>특히 Spring Boot의 자동 설정 클래스들에서는 보통 <code>proxyBeanMethods = false</code> 를 많이 사용한다. 왜냐하면 자동 설정 클래스는 대체로 <code>@Bean</code> 메서드끼리 직접 호출하지 않고, 필요한 의존성을 파라미터로 받도록 작성하기 때문이다. 실제로 Spring 5.2 이후부터는 자동 구성 클래스에 <code>proxyBeanMethods = false</code>가 기본값으로 사용되는 경우가 많아졌으며, 프록시를 생성하지 않아도 충분히 동작할 수 있는 경우 이를 활용하도록 공식 문서에서도 권장하고 있다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em>
<a href="https://github.com/eGovFramework/egovframe-vscode-initializr/issues/11">[Bug] AOP 기반 트랜잭션 설정 생성 시 txAdvisor Bean의 잘못된 직접 메서드 호출 패턴</a>
<a href="https://github.com/eGovFramework/egovframe-vscode-initializr/pull/12">fix: AOP 트랜잭션 설정 템플릿에서 txAdvisor의 잘못된 직접 메서드 호출 수정</a>
<a href="https://velog.io/@rocker_nun/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%B4-%EC%A7%80%EC%9B%90%ED%95%98%EB%8A%94-%ED%94%84%EB%A1%9D%EC%8B%9C">스프링이 지원하는 프록시</a>
<a href="https://docs.spring.io/spring-framework/reference/core/beans/java/basic-concepts.html">Basic Concepts: @Bean and @Configuration</a>
<a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/EnableAspectJAutoProxy.html">Annotation Interface EnableAspectJAutoProxy</a></p>
]]></description>
        </item>
    </channel>
</rss>