<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Logging my Bits &amp; Bytes</title>
        <link>https://velog.io/</link>
        <description>Backend Developer | Aspiring Full-Stack Enthusiast</description>
        <lastBuildDate>Sat, 25 Apr 2026 08:21:59 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Logging my Bits &amp; Bytes</title>
            <url>https://velog.velcdn.com/images/furaha_dev/profile/4c0f8862-846a-4855-82e0-fe51f7529251/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Logging my Bits &amp; Bytes. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/furaha_dev" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Spring] Spring 트랜잭션 롤백 전략과 외부 API 연동]]></title>
            <link>https://velog.io/@furaha_dev/Spring-Spring-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%A1%A4%EB%B0%B1-%EC%A0%84%EB%9E%B5%EA%B3%BC-%EC%99%B8%EB%B6%80-API-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@furaha_dev/Spring-Spring-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%A1%A4%EB%B0%B1-%EC%A0%84%EB%9E%B5%EA%B3%BC-%EC%99%B8%EB%B6%80-API-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Sat, 25 Apr 2026 08:21:59 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서는 트랜잭션 전파 옵션(REQUIRED, REQUIRES_NEW, NESTED 등)을 통해 트랜잭션이 어떻게 전파되고 분리되는지 살펴봤다. 이번 글에서는 한 걸음 더 나아가, 트랜잭션이 실패했을 때 어떻게 처리되는가를 다룬다.</p>
<p>이번 글에서 다룰 핵심 질문은 다음과 같다.</p>
<ul>
<li>예외 종류에 따라 롤백 여부가 달라지는 이유는 무엇인가?</li>
<li>체크 예외도 롤백시키거나, 언체크 예외를 커밋시키고 싶으면 어떻게 하는가?</li>
<li>외부 API 호출이 포함된 트랜잭션에서 데이터 정합성을 어떻게 유지하는가?</li>
</ul>
<hr>
<h2 id="1-spring의-기본-롤백-규칙">1. Spring의 기본 롤백 규칙</h2>
<p>Spring은 예외를 두 종류로 구분하고 다르게 처리한다.</p>
<h3 id="unchecked-exception-→-자동-롤백">Unchecked Exception → 자동 롤백</h3>
<p><code>RuntimeException</code>을 상속하는 예외다. <code>NullPointerException</code>, <code>IllegalArgumentException</code>, <code>IllegalStateException</code> 등이 여기에 속한다.</p>
<p>Spring은 이 예외를 예측 불가능한 버그나 시스템 오류로 간주한다. 데이터 정합성이 깨졌을 가능성이 높으므로, 즉시 롤백하는 것이 안전하다.</p>
<h3 id="checked-exception-→-기본-커밋">Checked Exception → 기본 커밋</h3>
<p><code>Exception</code>을 상속하지만 <code>RuntimeException</code>은 아닌 예외다. <code>IOException</code>, <code>SQLException</code> 등이 여기에 속한다.</p>
<p>Spring은 이 예외를 예측 가능한 비즈니스 상황으로 간주한다. 예를 들어 &quot;파일을 못 찾음&quot;, &quot;네트워크 일시 단절&quot; 같은 상황은 시스템 버그가 아니다. 롤백 여부를 개발자에게 위임하기 위해 기본적으로 커밋한다.</p>
<pre><code>Unchecked Exception (RuntimeException 상속) → 기본 롤백
Checked Exception (Exception 상속, RuntimeException 제외) → 기본 커밋</code></pre><hr>
<h2 id="2-롤백-규칙-커스터마이징">2. 롤백 규칙 커스터마이징</h2>
<h3 id="21-rollbackfor--체크-예외를-롤백시키기">2.1 rollbackFor — 체크 예외를 롤백시키기</h3>
<p>체크 예외가 발생했을 때 강제로 롤백시키고 싶다면 <code>rollbackFor</code>를 사용한다.</p>
<pre><code class="language-java">// 음수 가격은 비즈니스 규칙 위반 → 반드시 롤백
@Transactional(rollbackFor = CustomCheckedException.class)
public void updateProductPrice(Long productId, BigDecimal newPrice) throws CustomCheckedException {
    Product product = productRepository.findById(productId)
        .orElseThrow(() -&gt; new ProductNotFoundException(&quot;상품을 찾을 수 없습니다.&quot;));

    product.setPrice(newPrice);
    productRepository.save(product);

    if (newPrice.compareTo(BigDecimal.ZERO) &lt; 0) {
        throw new CustomCheckedException(&quot;가격은 음수가 될 수 없습니다.&quot;);
    }
}</code></pre>
<p><code>CustomCheckedException</code>이 던져지면 트랜잭션 관리자는 커밋 대신 롤백을 수행한다.</p>
<h3 id="22-norollbackfor--언체크-예외를-커밋시키기">2.2 noRollbackFor — 언체크 예외를 커밋시키기</h3>
<p>반대로 언체크 예외가 발생해도 특정 상황에서는 커밋이 필요할 때 사용한다. 예를 들어 재고 부족 예외가 발생해도, 그 이전에 기록한 로그는 남겨야 하는 경우다.</p>
<pre><code class="language-java">@Transactional(noRollbackFor = IllegalArgumentException.class)
public void reduceProductStockNoRollback(Long productId, int quantity) {
    Product product = productRepository.findById(productId)
        .orElseThrow(() -&gt; new ProductNotFoundException(&quot;상품을 찾을 수 없습니다.&quot;));

    // 예외 발생 전 로그 저장 → noRollbackFor 덕분에 커밋됨
    // logRepository.save(new Log(&quot;재고 차감 시도...&quot;));

    if (product.getStock() &lt; quantity) {
        throw new IllegalArgumentException(&quot;재고가 부족합니다.&quot;);
    }

    product.reduceStock(quantity);
    productRepository.save(product);
}</code></pre>
<p>정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th>속성</th>
<th>대상</th>
<th>효과</th>
</tr>
</thead>
<tbody><tr>
<td><code>rollbackFor</code></td>
<td>Checked Exception</td>
<td>해당 예외 발생 시 롤백</td>
</tr>
<tr>
<td><code>noRollbackFor</code></td>
<td>Unchecked Exception</td>
<td>해당 예외 발생 시 커밋</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-주의해야-할-함정--예외를-삼키면-롤백되지-않는다">3. 주의해야 할 함정 — 예외를 삼키면 롤백되지 않는다</h2>
<p><code>@Transactional</code>은 AOP Proxy가 메서드 밖으로 던져진 예외를 감지하는 방식으로 동작한다. 예외를 <code>try-catch</code>로 잡고 다시 던지지 않으면, 트랜잭션 관리자는 예외 발생 자체를 모른다.</p>
<pre><code class="language-java">// 잘못된 예제 — 롤백 안 됨
@Transactional
public void process() {
    try {
        throw new RuntimeException(&quot;심각한 오류!&quot;);
    } catch (RuntimeException e) {
        log.error(&quot;오류 내부 처리&quot;);
        // 예외가 여기서 사라짐 → 트랜잭션 관리자는 정상 종료로 판단 → 커밋
    }
}</code></pre>
<p>해결책은 두 가지다.</p>
<pre><code class="language-java">// 방법 1: 예외를 다시 던지기
catch (RuntimeException e) {
    log.error(&quot;오류 처리&quot;);
    throw e;
}

// 방법 2: 프로그래밍 방식으로 롤백 마킹
catch (RuntimeException e) {
    log.error(&quot;오류 처리&quot;);
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}</code></pre>
<hr>
<h2 id="4-외부-api-연동과-트랜잭션">4. 외부 API 연동과 트랜잭션</h2>
<h3 id="41-문제--외부-api는-롤백-대상이-아니다">4.1 문제 — 외부 API는 롤백 대상이 아니다</h3>
<p>DB 작업은 트랜잭션으로 롤백할 수 있다. 하지만 외부 API 호출은 그렇지 않다. 이미 외부 시스템에 요청이 전달된 이후에는 되돌릴 수 없다.</p>
<pre><code>1. 결제 API 호출 → 성공 (돈이 빠져나감)
2. DB에 주문 저장 → 실패
3. 트랜잭션 롤백 → DB는 원상복구
4. 결제는 이미 완료된 상태 → 불일치 발생</code></pre><h3 id="42-전략-1--순서-조정">4.2 전략 1 — 순서 조정</h3>
<p>DB 저장을 먼저 수행하고, 성공한 경우에만 외부 API를 호출한다. DB 롤백은 쉽지만 외부 API 취소는 어려우므로, 위험한 호출을 마지막으로 미루는 것이다.</p>
<pre><code>DB 저장 → (성공 시) 외부 API 호출</code></pre><h3 id="43-전략-2--보상-트랜잭션">4.3 전략 2 — 보상 트랜잭션</h3>
<p>외부 API 호출 후 DB 작업이 실패했을 때, 외부 API를 취소하는 별도의 로직을 추가한다.</p>
<pre><code>외부 API 호출 → DB 저장 실패 → 외부 API 취소 호출</code></pre><p>결제로 예를 들면, 결제 API 호출 성공 → 주문 저장 실패 → 결제 취소 API 호출의 흐름이다.</p>
<hr>
<h2 id="5-일시적-실패-대응--retry-전략">5. 일시적 실패 대응 — Retry 전략</h2>
<p>네트워크 오류나 외부 시스템 과부하는 일시적인 경우가 많다. 즉시 실패 처리하는 대신 잠깐 기다렸다가 재시도하면 성공률을 높일 수 있다.</p>
<p>Spring Retry를 사용하면 <code>@Retryable</code>로 선언적으로 처리할 수 있다.</p>
<pre><code class="language-groovy">// build.gradle
implementation &#39;org.springframework.retry:spring-retry&#39;</code></pre>
<pre><code class="language-java">@SpringBootApplication
@EnableRetry
public class Application { ... }</code></pre>
<pre><code class="language-java">@Transactional
@Retryable(
    value = DomainException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000) // 1초 간격
)
public void save() {
    ExternalProductResponse responses = externalShopClient.getProducts(1, 10);

    List&lt;ExternalResponse&gt; contents = responses.getMessage().getContents();
    if (contents.isEmpty()) {
        throw new DomainException(DomainExceptionCode.NOT_FOUND_PRODUCT);
    }

    Category category = categoryRepository.findById(1L)
        .orElseThrow(() -&gt; new DomainException(DomainExceptionCode.NOT_FOUND_PRODUCT));

    List&lt;Product&gt; products = contents.stream()
        .map(ext -&gt; Product.builder()
            .name(ext.getName())
            .description(ext.getDescription())
            .stock(ext.getStock())
            .price(ext.getPrice())
            .category(category)
            .build())
        .toList();

    productRepository.saveAll(products);
}</code></pre>
<h3 id="transactional과-retryable의-관계">@Transactional과 @Retryable의 관계</h3>
<p>여기서 중요한 점이 있다. <code>@Retryable</code>이 재시도를 하려면 예외가 메서드 밖으로 던져져야 한다. 예외가 던져지는 순간 트랜잭션은 이미 롤백되고 종료된다. 따라서 재시도마다 새로운 트랜잭션이 시작된다.</p>
<pre><code>@Retryable (바깥 Proxy)
    └── @Transactional (안쪽 Proxy)
            └── 실제 메서드

1회 시도 → 예외 발생 → 트랜잭션 롤백 → 1초 대기
2회 시도 → 새 트랜잭션 시작 → 예외 발생 → 트랜잭션 롤백 → 1초 대기
3회 시도 → 새 트랜잭션 시작 → 성공 or 최종 실패</code></pre><p><code>@Retryable</code>이 <code>@Transactional</code>보다 바깥을 감싸야 하는 이유가 여기에 있다. 순서가 반대이면 이미 롤백 마킹된 트랜잭션을 재사용하려다 오류가 발생한다.</p>
<hr>
<h2 id="6-전체-페이지-외부-데이터-저장--페이징-처리">6. 전체 페이지 외부 데이터 저장 — 페이징 처리</h2>
<p>외부 API가 페이징을 지원하는 경우, 모든 페이지를 순회하며 저장하는 패턴이다.</p>
<pre><code class="language-java">@Transactional
@Retryable(value = DomainException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void saveAllExternalProducts() {
    int page = 0;
    int pageSize = 10;
    boolean lastPage = false;

    while (!lastPage) {
        ExternalProductResponse responses = externalShopClient.getProducts(page, pageSize);

        if (responses == null || responses.getMessage() == null) {
            throw new DomainException(DomainExceptionCode.NOT_FOUND_PRODUCT);
        }

        List&lt;ExternalProductResponse.ExternalResponse&gt; contents =
            responses.getMessage().getContents();

        if (contents == null || contents.isEmpty()) {
            break;
        }

        Category category = categoryRepository.findById(1L)
            .orElseThrow(() -&gt; new DomainException(DomainExceptionCode.NOT_FOUND_PRODUCT));

        List&lt;Product&gt; products = contents.stream()
            .map(ext -&gt; Product.builder()
                .name(ext.getName())
                .description(ext.getDescription())
                .stock(ext.getStock())
                .price(ext.getPrice())
                .category(category)
                .build())
            .toList();

        productRepository.saveAll(products);

        ExternalProductResponse.ExternalPageable pageable =
            responses.getMessage().getPageable();
        lastPage = (pageable != null) ? pageable.isLast() : contents.size() &lt; pageSize;
        page++;
    }
}</code></pre>
<p><code>@Transactional</code>이 전체 while 루프를 감싸고 있으므로, 중간 페이지에서 예외가 발생하면 이전에 저장된 모든 데이터가 함께 롤백된다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>Spring의 롤백 기준은 두 가지다. 예외의 종류(Unchecked vs Checked)와, 예외가 메서드 밖으로 나왔는가. 외부 API는 트랜잭션 밖에 있으므로, 순서 조정 또는 보상 트랜잭션으로 별도 전략이 필요하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Huge Traffic Handling] 트랜잭션 전파 옵션 — 어디까지를 하나의 작업으로 볼 것인가]]></title>
            <link>https://velog.io/@furaha_dev/Huge-Traffic-Handling-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%A0%84%ED%8C%8C-%EC%98%B5%EC%85%98-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80%EB%A5%BC-%ED%95%98%EB%82%98%EC%9D%98-%EC%9E%91%EC%97%85%EC%9C%BC%EB%A1%9C-%EB%B3%BC-%EA%B2%83%EC%9D%B8%EA%B0%80</link>
            <guid>https://velog.io/@furaha_dev/Huge-Traffic-Handling-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%A0%84%ED%8C%8C-%EC%98%B5%EC%85%98-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80%EB%A5%BC-%ED%95%98%EB%82%98%EC%9D%98-%EC%9E%91%EC%97%85%EC%9C%BC%EB%A1%9C-%EB%B3%BC-%EA%B2%83%EC%9D%B8%EA%B0%80</guid>
            <pubDate>Sat, 25 Apr 2026 06:59:12 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지금까지 트랜잭션의 기초(14단계), 격리 수준(15단계), 낙관적/비관적 락(16단계)을 거치면서 트랜잭션이 어떻게 시작되고, 어떻게 서로를 보호하는지 익혔다.</p>
<p>이번에는 조금 다른 질문을 다룬다.</p>
<ul>
<li><code>ServiceA</code>가 <code>ServiceB</code>를 호출할 때, 트랜잭션은 몇 개가 생겨야 할까?</li>
<li>주문이 실패해도 로그는 반드시 남겨야 한다면?</li>
<li>쿠폰 발급만 실패했을 때 주문 전체를 롤백하는 게 맞을까?</li>
</ul>
<p>이 질문들에 답하는 것이 바로 <strong>트랜잭션 전파 옵션(Transaction Propagation)</strong>이다.</p>
<hr>
<h2 id="1-트랜잭션-전파란-무엇인가">1. 트랜잭션 전파란 무엇인가</h2>
<p><code>@Transactional</code>이 AOP Proxy로 동작한다는 건 이미 알고 있다. Proxy는 메서드 진입 시 트랜잭션을 시작하고, 종료 시 커밋 또는 롤백한다.</p>
<p>그런데 이런 상황을 생각해보자.</p>
<pre><code>ServiceA.methodA() → @Transactional → 트랜잭션 시작
    └─ ServiceB.methodB() → @Transactional → ???</code></pre><p><code>methodA</code>가 트랜잭션을 이미 시작했는데, <code>methodB</code>를 호출하면 어떻게 해야 할까?</p>
<p><strong>전파 옵션은 이 질문에 답하는 규칙이다.</strong> &quot;현재 트랜잭션이 있을 때, 나는 어떻게 행동할 것인가?&quot;</p>
<hr>
<h2 id="2-required--기본값-가장-자주-쓰는-옵션">2. REQUIRED — 기본값, 가장 자주 쓰는 옵션</h2>
<blockquote>
<p>없으면 새로 만들고, 있으면 합류한다.</p>
</blockquote>
<p><code>@Transactional</code>의 기본값이다. 별도 옵션을 지정하지 않으면 항상 이 방식으로 동작한다.</p>
<pre><code class="language-java">@Transactional // propagation = Propagation.REQUIRED 와 동일
public void createOrder(OrderRequest request) {
    orderRepository.save(order);
    stockService.decrease(request); // 이 메서드도 REQUIRED → 같은 트랜잭션 참여
}</code></pre>
<p><strong>핵심은 &quot;하나의 물리 트랜잭션을 공유한다&quot;는 점이다.</strong></p>
<p>주문 저장과 재고 감소는 동시에 성공하거나 동시에 실패해야 한다. 재고 감소 중 예외가 터지면 주문 저장도 함께 롤백된다. 이 원자성이 <code>REQUIRED</code>의 존재 이유다.</p>
<pre><code>트랜잭션 흐름:
createOrder() ──── [Tx-A 시작]
    stockService.decrease() ──── [Tx-A 참여]
        예외 발생 → Tx-A 전체 롤백</code></pre><p><strong>주의:</strong> 의도치 않게 너무 많은 작업을 하나의 트랜잭션으로 묶으면 DB 커넥션 점유 시간이 길어진다. 필요한 범위만 묶는 것이 좋다.</p>
<hr>
<h2 id="3-requires_new--항상-독립적인-트랜잭션">3. REQUIRES_NEW — 항상 독립적인 트랜잭션</h2>
<blockquote>
<p>기존 트랜잭션과 관계없이 항상 새로운 트랜잭션을 만든다.</p>
</blockquote>
<p><code>REQUIRED</code>의 문제를 먼저 보자.</p>
<p>주문 처리 중 예외가 발생해 전체가 롤백될 때, &quot;주문 시도 실패&quot; 로그도 함께 롤백된다. 로그는 실패 여부와 무관하게 반드시 DB에 남아야 하는데 말이다.</p>
<p><code>REQUIRES_NEW</code>는 이 문제를 해결한다.</p>
<pre><code class="language-java">// OrderService
@Transactional
public void createOrder(OrderRequest request) {
    try {
        processOrder(request);
    } catch (Exception e) {
        // 예외 발생 → 이 트랜잭션은 롤백됨
        throw e;
    } finally {
        logService.record(&quot;주문 시도&quot;); // 별도 트랜잭션으로 실행
    }
}

// LogService
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String message) {
    logRepository.save(new Log(message));
    // 주문 트랜잭션과 완전히 독립적으로 커밋됨
}</code></pre>
<pre><code>트랜잭션 흐름:
createOrder() ──── [Tx-A 시작]
    logService.record() ──── [Tx-A 중단, Tx-B 시작]
        Tx-B 커밋
    [Tx-A 재개]
        예외 발생 → Tx-A 롤백 (Tx-B와 무관)</code></pre><p><code>REQUIRES_NEW</code>를 쓰면 호출부마다 try-catch로 로그를 남길 필요가 없다. 로그 책임이 <code>LogService</code> 안에 캡슐화된다.</p>
<p><strong>주의:</strong> 새 트랜잭션 = 새 DB 커넥션 획득이다. 빈번하게 호출되면 성능 저하의 원인이 될 수 있다. 로그, 이력, 알림처럼 분리가 꼭 필요한 곳에만 제한적으로 사용한다.</p>
<hr>
<h2 id="4-nested--부모에-종속되지만-나만-부분-롤백-가능">4. NESTED — 부모에 종속되지만, 나만 부분 롤백 가능</h2>
<blockquote>
<p>부모 트랜잭션 안에 Savepoint를 생성한다.</p>
</blockquote>
<p><code>REQUIRES_NEW</code>와 헷갈리기 쉬운 옵션이다. 차이를 먼저 잡고 가자.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>REQUIRES_NEW</th>
<th>NESTED</th>
</tr>
</thead>
<tbody><tr>
<td>부모 트랜잭션 롤백 시</td>
<td>자식은 이미 커밋 → <strong>살아있음</strong></td>
<td>자식도 함께 <strong>롤백</strong></td>
</tr>
<tr>
<td>자식만 실패 시</td>
<td>자식만 롤백</td>
<td>자식만 롤백 (Savepoint로 복귀)</td>
</tr>
</tbody></table>
<p>쿠폰 발급 시나리오로 이해해보자.</p>
<ul>
<li>주문 처리 중 쿠폰 발급을 시도한다.</li>
<li>쿠폰 발급에 실패해도 주문은 성공시키고 싶다.</li>
<li>하지만 주문 전체가 실패하면 쿠폰도 없던 일이어야 한다.</li>
</ul>
<p><code>REQUIRES_NEW</code>를 쓰면 주문이 롤백돼도 쿠폰은 이미 커밋된 상태가 될 수 있다. 데이터 정합성이 깨진다. <code>NESTED</code>가 필요한 이유다.</p>
<pre><code class="language-java">// OrderService
@Transactional
public void createOrder(OrderRequest request) {
    processOrder(request); // 주문 처리

    try {
        couponService.issue(request.getUserId()); // Savepoint 생성
    } catch (Exception e) {
        // 쿠폰 발급 실패 → Savepoint로 롤백, 주문은 계속 진행
    }

    finalizeOrder(request); // 주문 마무리
}

// CouponService
@Transactional(propagation = Propagation.NESTED)
public void issue(Long userId) {
    couponRepository.save(new Coupon(userId));
    // 실패 시 이 메서드 범위만 롤백됨
}</code></pre>
<pre><code>트랜잭션 흐름:
createOrder() ──── [Tx-A 시작]
    couponService.issue() ──── [Savepoint 생성]
        예외 발생 → Savepoint로 롤백 (주문은 유지)
    finalizeOrder() ──── [Tx-A 계속]
Tx-A 커밋 (주문 완료, 쿠폰은 미발급)</code></pre><p><strong>NESTED vs REQUIRES_NEW 한 줄 요약:</strong></p>
<ul>
<li><code>REQUIRES_NEW</code>: 부모와 완전히 독립. 서로의 롤백이 서로에게 영향 없음.</li>
<li><code>NESTED</code>: 부모에 종속. 부모가 롤백되면 나도 롤백. 내가 실패해도 부모는 계속.</li>
</ul>
<p><strong>주의:</strong> Savepoint는 모든 DB가 지원하지 않는다. 사용 전 실행 환경의 지원 여부를 반드시 확인한다.</p>
<hr>
<h2 id="5-supports--있으면-참여-없으면-그냥-실행">5. SUPPORTS — 있으면 참여, 없으면 그냥 실행</h2>
<blockquote>
<p>트랜잭션이 있으면 합류하고, 없으면 트랜잭션 없이 실행한다.</p>
</blockquote>
<p>단순 조회 메서드에 어울리는 옵션이다. 트랜잭션이 꼭 필요한 작업은 아니지만, 다른 트랜잭션 안에서 호출될 경우 그 트랜잭션의 일관된 데이터 뷰를 함께 쓸 수 있어서 유리하다.</p>
<pre><code class="language-java">// ProductService
@Transactional(propagation = Propagation.SUPPORTS)
public Product getProduct(Long id) {
    return productRepository.findById(id)
        .orElseThrow(() -&gt; new DomainException(DomainExceptionCode.PRODUCT_NOT_FOUND));
}</code></pre>
<p>트랜잭션 없이 단독 호출되면 커밋/롤백 오버헤드 없이 조회만 수행한다. 트랜잭션 안에서 호출되면 그 트랜잭션에 합류한다.</p>
<p><strong>주의:</strong> 이 옵션이 붙은 메서드에 나중에 쓰기 로직이 추가되면 데이터 정합성이 깨질 수 있다. 진짜 읽기 전용 작업에만 사용한다.</p>
<hr>
<h2 id="6-전파-옵션-총정리">6. 전파 옵션 총정리</h2>
<table>
<thead>
<tr>
<th>옵션</th>
<th>트랜잭션 존재 시</th>
<th>트랜잭션 부재 시</th>
<th>주 사용처</th>
</tr>
</thead>
<tbody><tr>
<td><code>REQUIRED</code></td>
<td>참여</td>
<td>새로 생성</td>
<td>일반적인 비즈니스 로직 (기본값)</td>
</tr>
<tr>
<td><code>REQUIRES_NEW</code></td>
<td>기존 중단, 새로 생성</td>
<td>새로 생성</td>
<td>로그, 이력 — 메인 롤백과 무관하게 저장</td>
</tr>
<tr>
<td><code>NESTED</code></td>
<td>Savepoint 생성 (부분 롤백)</td>
<td>새로 생성</td>
<td>선택적 기능 — 실패해도 메인은 계속</td>
</tr>
<tr>
<td><code>SUPPORTS</code></td>
<td>참여</td>
<td>트랜잭션 없이 실행</td>
<td>단순 조회</td>
</tr>
</tbody></table>
<hr>
<h2 id="마치며">마치며</h2>
<p>트랜잭션 전파 옵션은 결국 하나의 질문으로 귀결된다. <strong>&quot;어디까지를 하나의 작업 단위로 볼 것인가.&quot;</strong></p>
<p><code>REQUIRED</code>로 원자성을 확보하고, <code>REQUIRES_NEW</code>로 독립성이 필요한 작업을 분리하고, <code>NESTED</code>로 부분 실패를 허용한다. 이 세 가지의 트레이드오프를 이해하면 복잡한 비즈니스 로직도 트랜잭션 경계를 명확하게 설계할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Huge Traffic Handling] 트랜잭션 격리 수준, 코드로 직접 확인해보기]]></title>
            <link>https://velog.io/@furaha_dev/Huge-Traffic-Handling-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80-%EC%BD%94%EB%93%9C%EB%A1%9C-%EC%A7%81%EC%A0%91-%ED%99%95%EC%9D%B8%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@furaha_dev/Huge-Traffic-Handling-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80-%EC%BD%94%EB%93%9C%EB%A1%9C-%EC%A7%81%EC%A0%91-%ED%99%95%EC%9D%B8%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Fri, 24 Apr 2026 15:54:58 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서 트랜잭션 격리 수준의 개념을 정리했다. Dirty Read, Non-Repeatable Read, Phantom Read가 각각 어떤 상황에서 발생하는지, 어떤 격리 수준이 이를 막는지 표로 정리했었다.</p>
<p>그런데 이론만으로는 뭔가 찜찜하다. 실제로 발생하는지 눈으로 확인해야 진짜 내 것이 된다.</p>
<p>이번 글에서는 이런 질문들을 코드로 직접 답해본다.</p>
<ul>
<li><code>READ_UNCOMMITTED</code>에서 정말 커밋 안 된 데이터가 읽힐까?</li>
<li><code>REPEATABLE_READ</code>는 어떻게 같은 값을 두 번 보장할까?</li>
<li><code>SERIALIZABLE</code>은 어떻게 새로운 행의 삽입까지 막을까?</li>
</ul>
<hr>
<h2 id="1-실습-환경-준비">1. 실습 환경 준비</h2>
<h3 id="테스트-구조">테스트 구조</h3>
<p>두 개의 스레드가 동시에 동작하는 상황을 만들어야 한다. Java의 <code>Thread</code>를 직접 생성해서 동시성 문제를 재현한다.</p>
<pre><code>Thread A (긴 트랜잭션) ──────────────────────────────────►
Thread B (짧은 트랜잭션)        ──────►
                         1초 후 시작</code></pre><p>테스트 클래스는 <code>@SpringBootTest</code>로 전체 컨텍스트를 띄우고, <code>@BeforeEach</code>로 매 테스트마다 초기 데이터를 세팅한다.</p>
<pre><code class="language-java">@SpringBootTest
public class ProductIsolationServiceTest {

    @Autowired private ProductRepository productRepository;
    @Autowired private ProductIsolationService productIsolationService;

    private Long productId;

    @BeforeEach
    void setUp() {
        Product product = productRepository.save(new Product(..., 20));
        productId = product.getId();
    }

    @AfterEach
    void tearDown() {
        productRepository.deleteAll();
    }
}</code></pre>
<p><code>productId = 1L</code>처럼 하드코딩하지 않고, 저장 후 실제 ID를 받아오는 방식을 쓴다. 테스트 환경마다 ID가 다를 수 있기 때문이다.</p>
<hr>
<h2 id="2-dirty-read">2. Dirty Read</h2>
<h3 id="문제-재현-read_uncommitted">문제 재현 (<code>READ_UNCOMMITTED</code>)</h3>
<p><code>saveAndFlush()</code>는 영속성 컨텍스트의 변경사항을 즉시 DB에 동기화한다. <code>save()</code>만 하면 영속성 컨텍스트 안에만 머물러서 다른 스레드(다른 DB 커넥션)는 그 값을 볼 수 없다.</p>
<pre><code class="language-java">@Transactional
public void updateStockAndForceRollback(Long productId, int newStock) {
    Product product = productRepository.findById(productId).orElseThrow();
    product.setStock(newStock);
    productRepository.saveAndFlush(product); // DB에 즉시 반영
    Thread.sleep(5000);                       // 5초 대기
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // 의도적 롤백
}</code></pre>
<p><code>setRollbackOnly()</code>를 쓰는 이유는 메서드가 정상적으로 끝나더라도 롤백되는 상황을 만들기 위해서다. 예외를 던지면 메서드가 비정상 종료되어버리기 때문에 이 방식을 쓴다.</p>
<p>Thread A가 <code>saveAndFlush()</code> 후 5초 대기하는 사이, Thread B가 <code>READ_UNCOMMITTED</code>로 조회하면 커밋도 안 된 값 <code>10</code>을 읽어온다.</p>
<pre><code>Thread A: stock 20 → 10 변경 후 flush → 5초 대기 → 롤백
Thread B: 1초 후 조회 → 10 읽음 (Dirty Read 발생!)
최종 DB: stock = 20 (롤백으로 원복)</code></pre><p>Thread B는 결국 존재하지 않았던 값을 읽은 셈이다.</p>
<h3 id="해결-read_committed">해결 (<code>READ_COMMITTED</code>)</h3>
<p><code>READ_COMMITTED</code>는 커밋된 데이터만 읽는다. Thread A가 아직 커밋하지 않은 상태에서 Thread B가 조회하면, DB는 Undo Log를 참조해서 마지막으로 커밋된 값 <code>20</code>을 반환한다.</p>
<pre><code class="language-java">@Transactional(isolation = Isolation.READ_COMMITTED)
public int getStockWithReadCommitted(Long productId) {
    Product product = productRepository.findById(productId).orElseThrow();
    return product.getStock(); // 20 반환
}</code></pre>
<p>Undo Log는 데이터가 변경될 때 이전 값을 별도 공간에 기록해두는 것이다. <code>READ_COMMITTED</code>는 이 Undo Log를 참조해서 커밋된 버전의 데이터를 찾아온다.</p>
<hr>
<h2 id="3-non-repeatable-read">3. Non-Repeatable Read</h2>
<h3 id="문제-재현-read_committed">문제 재현 (<code>READ_COMMITTED</code>)</h3>
<p>하나의 트랜잭션 안에서 같은 데이터를 두 번 읽었는데 값이 달라지는 문제다.</p>
<pre><code class="language-java">@Transactional(isolation = Isolation.READ_COMMITTED)
public void demonstrateNonRepeatableRead(Long productId) {
    Product product1 = productRepository.findById(productId).orElseThrow();
    System.out.println(&quot;First Read: &quot; + product1.getStock()); // 20

    Thread.sleep(4000); // 이 사이에 다른 트랜잭션이 값을 변경

    Product product2 = productRepository.findById(productId).orElseThrow();
    System.out.println(&quot;Second Read: &quot; + product2.getStock()); // 5
}</code></pre>
<p><code>READ_COMMITTED</code>는 쿼리를 실행하는 시점의 최신 커밋 데이터를 읽기 때문에, 대기하는 사이 Thread B가 <code>stock</code>을 <code>5</code>로 바꾸고 커밋하면 두 번째 읽기에서 <code>5</code>가 나온다.</p>
<h3 id="해결-repeatable_read">해결 (<code>REPEATABLE_READ</code>)</h3>
<p><code>REPEATABLE_READ</code>는 트랜잭션이 시작되는 순간 스냅샷을 만들어둔다. 이후 같은 데이터를 아무리 읽어도 이 스냅샷 기준으로 반환하기 때문에, 다른 트랜잭션이 중간에 값을 바꿔도 영향을 받지 않는다.</p>
<pre><code class="language-java">@Transactional(isolation = Isolation.REPEATABLE_READ)
public void demonstrateRepeatableRead(Long productId) {
    Product product1 = productRepository.findById(productId).orElseThrow();
    System.out.println(&quot;First Read: &quot; + product1.getStock()); // 20

    Thread.sleep(4000);

    Product product2 = productRepository.findById(productId).orElseThrow();
    System.out.println(&quot;Second Read: &quot; + product2.getStock()); // 20 (스냅샷 기준)
}</code></pre>
<hr>
<h2 id="4-phantom-read">4. Phantom Read</h2>
<h3 id="문제-재현-repeatable_read">문제 재현 (<code>REPEATABLE_READ</code>)</h3>
<p><code>REPEATABLE_READ</code>는 기존 행의 값 변경은 막아주지만, 새로운 행이 추가되는 것은 막지 못할 수 있다.</p>
<pre><code class="language-java">@Transactional(isolation = Isolation.REPEATABLE_READ)
public void demonstratePhantomRead() {
    List&lt;Product&gt; products1 = productRepository.findAllByStockGreaterThan(5);
    System.out.println(&quot;First Read: &quot; + products1.size() + &quot;개&quot;); // 2개

    Thread.sleep(4000); // 이 사이에 stock &gt; 5인 신상품 INSERT + COMMIT

    List&lt;Product&gt; products2 = productRepository.findAllByStockGreaterThan(5);
    System.out.println(&quot;Second Read: &quot; + products2.size() + &quot;개&quot;); // 3개
}</code></pre>
<p>같은 조건으로 두 번 조회했는데 결과 개수가 달라졌다. 유령처럼 새로운 행이 끼어든 것이다.</p>
<h3 id="해결-serializable">해결 (<code>SERIALIZABLE</code>)</h3>
<p><code>SERIALIZABLE</code>은 범위 조회 시 해당 조건 범위 전체에 잠금을 건다. <code>stock &gt; 5</code> 조건으로 조회하는 순간, 이 범위에 새로운 데이터가 들어오는 것 자체를 차단한다.</p>
<pre><code class="language-java">@Transactional(isolation = Isolation.SERIALIZABLE)
public void demonstrateSerializable() {
    List&lt;Product&gt; products1 = productRepository.findAllByStockGreaterThan(5);
    System.out.println(&quot;First Read: &quot; + products1.size() + &quot;개&quot;); // 2개

    Thread.sleep(4000);
    // 이 사이에 Thread B가 INSERT 시도 → 범위 잠금에 막혀 대기

    List&lt;Product&gt; products2 = productRepository.findAllByStockGreaterThan(5);
    System.out.println(&quot;Second Read: &quot; + products2.size() + &quot;개&quot;); // 2개 (변화 없음)
}</code></pre>
<p>Thread A가 커밋하면 그제서야 잠금이 풀리고 Thread B의 INSERT가 실행된다. 마치 두 트랜잭션을 순서대로 실행한 것과 같은 결과를 보장한다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>세 가지 동시성 문제와 해결책을 정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th>문제</th>
<th>발생 격리 수준</th>
<th>해결 격리 수준</th>
<th>핵심 메커니즘</th>
</tr>
</thead>
<tbody><tr>
<td>Dirty Read</td>
<td>READ_UNCOMMITTED</td>
<td>READ_COMMITTED</td>
<td>Undo Log 참조</td>
</tr>
<tr>
<td>Non-Repeatable Read</td>
<td>READ_COMMITTED</td>
<td>REPEATABLE_READ</td>
<td>트랜잭션 시작 시 스냅샷</td>
</tr>
<tr>
<td>Phantom Read</td>
<td>REPEATABLE_READ</td>
<td>SERIALIZABLE</td>
<td>범위 잠금</td>
</tr>
</tbody></table>
<p>격리 수준은 높을수록 정합성이 좋아지지만 동시성이 떨어진다. 이론으로만 알던 트레이드오프를 코드로 직접 확인하고 나니 훨씬 선명하게 이해됐다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Huge Traffic Handling] 낙관적 락과 비관적 락 — 동시성 제어의 두 번째 무기]]></title>
            <link>https://velog.io/@furaha_dev/Huge-Traffic-Handling-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD%EA%B3%BC-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4%EC%9D%98-%EB%91%90-%EB%B2%88%EC%A7%B8-%EB%AC%B4%EA%B8%B0</link>
            <guid>https://velog.io/@furaha_dev/Huge-Traffic-Handling-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD%EA%B3%BC-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4%EC%9D%98-%EB%91%90-%EB%B2%88%EC%A7%B8-%EB%AC%B4%EA%B8%B0</guid>
            <pubDate>Sun, 19 Apr 2026 13:58:20 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서 트랜잭션 격리 수준으로 동시성 문제를 1차적으로 방어하는 방법을 살펴봤다.</p>
<p>그런데 Repeatable Read로 격리 수준을 올려도 여전히 해결되지 않는 문제가 있다.</p>
<ul>
<li>재고가 10개인데 동시에 100명이 주문하면?</li>
<li>티켓팅에서 같은 좌석을 동시에 두 명이 선택하면?</li>
</ul>
<p>이번 글에서는 아래 질문들을 다룬다.</p>
<ul>
<li>낙관적 락과 비관적 락은 각각 어떤 방식인가?</li>
<li>언제 낙관적 락을, 언제 비관적 락을 선택하는가?</li>
<li>데드락은 왜 생기고 어떻게 피하는가?</li>
</ul>
<hr>
<h2 id="1-왜-격리-수준만으로는-부족한가">1. 왜 격리 수준만으로는 부족한가</h2>
<p>Repeatable Read는 읽기의 일관성을 보장한다. 하지만 동시에 같은 행을 수정하려는 경쟁 자체는 막아주지 않는다.</p>
<pre><code>트랜잭션 A: 재고 10 읽음
트랜잭션 B: 재고 10 읽음
트랜잭션 A: 재고 3 차감 후 커밋 → 재고 7
트랜잭션 B: 재고 3 차감 후 커밋 → 재고 7  ← A의 차감이 덮어써짐</code></pre><p>이 문제를 해결하려면 <strong>락(Lock)</strong> 이 필요하다.</p>
<hr>
<h2 id="2-낙관적-락--충돌이-드물다고-가정">2. 낙관적 락 — 충돌이 드물다고 가정</h2>
<h3 id="개념">개념</h3>
<p>&quot;충돌이 거의 없을 것&quot;이라고 낙관하고 일단 작업을 진행한다. 커밋 시점에 충돌 여부를 확인하고, 충돌이 감지되면 롤백 후 재시도한다.</p>
<h3 id="구현--version">구현 — @Version</h3>
<pre><code class="language-java">@Entity
public class Product {
    @Id
    private Long id;

    private int stock;

    @Version
    private Long version; // 수정될 때마다 자동으로 1씩 증가
}</code></pre>
<p>동작 방식은 단순하다.</p>
<pre><code>트랜잭션 A: version=1인 상품 읽음
트랜잭션 B: version=1인 상품 읽음
트랜잭션 A: 수정 후 커밋 → version=2로 증가
트랜잭션 B: 커밋 시도 → DB의 version은 2인데 내가 읽은 건 1
            → 충돌 감지 → OptimisticLockException 발생 → 롤백 후 재시도</code></pre><p>version은 개발자가 직접 관리할 필요 없다. JPA가 커밋 시점에 자동으로 비교하고 불일치하면 예외를 던진다.</p>
<h3 id="장단점">장단점</h3>
<ul>
<li>장점: 락을 걸지 않으므로 대기 없음 → 성능이 좋다</li>
<li>단점: 충돌이 잦으면 재시도가 폭발적으로 증가한다</li>
</ul>
<h3 id="적합한-상황">적합한 상황</h3>
<p>충돌이 드문 경우에 어울린다.</p>
<ul>
<li>게시글 수정</li>
<li>사용자 프로필 업데이트</li>
<li>좋아요 등</li>
</ul>
<hr>
<h2 id="3-비관적-락--충돌이-반드시-난다고-가정">3. 비관적 락 — 충돌이 반드시 난다고 가정</h2>
<h3 id="개념-1">개념</h3>
<p>&quot;충돌이 무조건 날 것&quot;이라고 비관하고 처음부터 행에 잠금을 건다. 다른 트랜잭션은 잠금이 풀릴 때까지 대기한다.</p>
<h3 id="구현--lock">구현 — @Lock</h3>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;SELECT p FROM Product p WHERE p.id = :id&quot;)
Optional&lt;Product&gt; findByIdWithLock(@Param(&quot;id&quot;) Long id);</code></pre>
<p>실제로 실행되는 SQL은 아래와 같다.</p>
<pre><code class="language-sql">SELECT * FROM product WHERE id = 1 FOR UPDATE</code></pre>
<p><code>FOR UPDATE</code>가 붙으면 해당 행에 배타적 잠금이 걸린다. 다른 트랜잭션은 이 행을 읽지도, 수정하지도 못하고 대기한다.</p>
<h3 id="장단점-1">장단점</h3>
<ul>
<li>장점: 충돌 자체를 원천 차단 → 데이터 정합성 강력 보장</li>
<li>단점: 대기 시간 증가 → 성능 저하, 데드락 위험</li>
</ul>
<h3 id="적합한-상황-1">적합한 상황</h3>
<p>충돌이 잦은 경우에 어울린다.</p>
<ul>
<li>재고 차감</li>
<li>티켓팅 좌석 선택</li>
<li>포인트 차감</li>
</ul>
<hr>
<h2 id="4-데드락--비관적-락의-함정">4. 데드락 — 비관적 락의 함정</h2>
<h3 id="개념-2">개념</h3>
<p>두 트랜잭션이 서로가 잠근 행을 기다리며 영원히 대기하는 상태다.</p>
<pre><code>트랜잭션 A: 상품 행 잠금 → 재고 행 잠그려고 대기
트랜잭션 B: 재고 행 잠금 → 상품 행 잠그려고 대기</code></pre><p>A는 B를 기다리고, B는 A를 기다린다. 누구도 먼저 풀어주지 않으므로 영원히 진행되지 않는다.</p>
<h3 id="해결--항상-같은-순서로-잠그기">해결 — 항상 같은 순서로 잠그기</h3>
<p>데드락이 생기는 이유는 A와 B가 <strong>반대 순서</strong>로 잠갔기 때문이다. 모든 트랜잭션이 <strong>상품 → 재고</strong> 순서로만 잠그도록 규칙을 정하면 순환 대기가 사라진다.</p>
<pre><code>Before (데드락 발생)
트랜잭션 A: 상품 → 재고 순서로 잠금 시도
트랜잭션 B: 재고 → 상품 순서로 잠금 시도  ← 순서가 반대

After (데드락 방지)
트랜잭션 A: 상품 → 재고 순서로 잠금 시도
트랜잭션 B: 상품 → 재고 순서로 잠금 시도  ← 같은 순서</code></pre><hr>
<h2 id="5-낙관적-락-vs-비관적-락-정리">5. 낙관적 락 vs 비관적 락 정리</h2>
<table>
<thead>
<tr>
<th></th>
<th>낙관적 락</th>
<th>비관적 락</th>
</tr>
</thead>
<tbody><tr>
<td>가정</td>
<td>충돌이 드물다</td>
<td>충돌이 잦다</td>
</tr>
<tr>
<td>방식</td>
<td>커밋 시점에 version으로 충돌 감지</td>
<td>처음부터 FOR UPDATE로 행 잠금</td>
</tr>
<tr>
<td>충돌 시</td>
<td>OptimisticLockException → 재시도</td>
<td>다른 트랜잭션 대기</td>
</tr>
<tr>
<td>장점</td>
<td>대기 없음 → 성능 좋음</td>
<td>충돌 원천 차단</td>
</tr>
<tr>
<td>단점</td>
<td>충돌 잦으면 재시도 폭발</td>
<td>성능 저하, 데드락 위험</td>
</tr>
<tr>
<td>JPA 구현</td>
<td>@Version</td>
<td>@Lock(PESSIMISTIC_WRITE)</td>
</tr>
<tr>
<td>사용 예</td>
<td>게시글 수정, 좋아요</td>
<td>재고 차감, 티켓팅</td>
</tr>
</tbody></table>
<hr>
<h2 id="마치며">마치며</h2>
<p>동시성 제어는 <strong>격리 수준으로 1차 방어, 락으로 2차 방어</strong>하는 구조다. 도메인의 충돌 빈도를 먼저 판단하고, 드물면 낙관적 락, 잦으면 비관적 락을 선택하면 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Huge Traffic Handling] 트랜잭션 격리 수준 — 동시성과 정합성 사이의 선택]]></title>
            <link>https://velog.io/@furaha_dev/Huge-Traffic-Handling-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80-%EB%8F%99%EC%8B%9C%EC%84%B1%EA%B3%BC-%EC%A0%95%ED%95%A9%EC%84%B1-%EC%82%AC%EC%9D%B4%EC%9D%98-%EC%84%A0%ED%83%9D</link>
            <guid>https://velog.io/@furaha_dev/Huge-Traffic-Handling-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80-%EB%8F%99%EC%8B%9C%EC%84%B1%EA%B3%BC-%EC%A0%95%ED%95%A9%EC%84%B1-%EC%82%AC%EC%9D%B4%EC%9D%98-%EC%84%A0%ED%83%9D</guid>
            <pubDate>Sun, 19 Apr 2026 13:54:15 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서 <code>@Transactional</code>의 동작 원리와 전파 방식을 살펴봤다.
트랜잭션이 &quot;하나의 논리적 작업 단위&quot;라는 건 알겠는데, 여러 트랜잭션이 동시에 실행되면 어떤 일이 벌어질까?</p>
<p>이번 글에서는 아래 질문들을 다룬다.</p>
<ul>
<li>동시에 100명이 같은 데이터에 접근하면 무슨 일이 생기는가?</li>
<li>Dirty Read, Non-Repeatable Read, Phantom Read는 무엇인가?</li>
<li>격리 수준 4단계는 각각 어떤 문제를 막아주는가?</li>
</ul>
<hr>
<h2 id="1-동시성-문제--race-condition">1. 동시성 문제 — Race Condition</h2>
<p>재고가 10개인 상품을 100명이 동시에 주문한다고 가정해보자.</p>
<pre><code>트랜잭션 A → 재고 조회: 10개
트랜잭션 B → 재고 조회: 10개  (아직 A가 커밋 전)
트랜잭션 A → 재고 차감 후 커밋: 3개
트랜잭션 B → 재고 차감 후 커밋: 3개</code></pre><p>두 트랜잭션 모두 재고가 충분하다고 판단했기 때문에 실제로는 재고가 7개 팔렸지만 DB엔 3개만 남는다.</p>
<p>이처럼 여러 트랜잭션이 같은 데이터를 동시에 읽고 쓰면서 발생하는 문제를 <strong>Race Condition(경쟁 상태)</strong> 이라고 한다.</p>
<p>이를 막으려면 &quot;얼마나 엄격하게 트랜잭션을 격리할 것인가&quot;를 결정해야 한다. 그 옵션이 바로 <strong>트랜잭션 격리 수준(Transaction Isolation Level)</strong> 이다.</p>
<hr>
<h2 id="2-격리-수준-4단계">2. 격리 수준 4단계</h2>
<p>격리 수준은 낮을수록 성능이 좋고, 높을수록 데이터 정합성이 강하다. 정합성과 성능은 트레이드오프 관계다.</p>
<pre><code>Read Uncommitted  →  Read Committed  →  Repeatable Read  →  Serializable
      성능 우선                                                정합성 우선</code></pre><hr>
<h2 id="3-dirty-read--확정되지-않은-데이터를-읽는-문제">3. Dirty Read — 확정되지 않은 데이터를 읽는 문제</h2>
<h3 id="개념">개념</h3>
<p>트랜잭션 A가 데이터를 수정했지만 아직 COMMIT하지 않은 상태에서, 트랜잭션 B가 그 변경된 값을 읽어버리는 현상이다.</p>
<pre><code>트랜잭션 A: 재고 10 → 5 수정 (아직 COMMIT 전)
트랜잭션 B: 재고 조회 → 5 읽음  ← Dirty Read 발생
트랜잭션 A: ROLLBACK → 재고 다시 10으로 복원
트랜잭션 B: 존재하지 않았던 &#39;5&#39;를 기반으로 잘못된 판단</code></pre><p>트랜잭션 B는 언제든 사라질 수 있는 유령 데이터를 신뢰한 셈이다.</p>
<h3 id="해결--read-committed">해결 — Read Committed</h3>
<p>COMMIT된 데이터만 읽도록 보장한다. 트랜잭션 A가 수정 중이라면, B는 수정 전 마지막으로 COMMIT된 값을 읽는다.</p>
<hr>
<h2 id="4-non-repeatable-read--같은-데이터가-다르게-읽히는-문제">4. Non-Repeatable Read — 같은 데이터가 다르게 읽히는 문제</h2>
<h3 id="개념-1">개념</h3>
<p>하나의 트랜잭션 안에서 같은 SELECT를 두 번 실행했을 때, 그 사이에 다른 트랜잭션이 값을 수정하고 COMMIT하여 결과가 달라지는 현상이다.</p>
<pre><code>트랜잭션 A: 재고 조회 → 10개
트랜잭션 B: 재고를 5로 수정 후 COMMIT
트랜잭션 A: 재고 재조회 → 5개  ← 같은 트랜잭션인데 값이 달라짐</code></pre><p>Read Committed는 &quot;쿼리 실행 시점의 최신 COMMIT 데이터&quot;를 읽기 때문에 이 문제를 막지 못한다.</p>
<h3 id="해결--repeatable-read">해결 — Repeatable Read</h3>
<p>트랜잭션이 시작될 때 스냅샷을 찍어두고, 트랜잭션이 끝날 때까지 그 스냅샷만 바라본다. 다른 트랜잭션이 아무리 수정하고 COMMIT해도, 내 트랜잭션은 시작 시점의 데이터를 일관되게 읽는다.</p>
<blockquote>
<p>MySQL InnoDB는 MVCC(Multi-Version Concurrency Control) 기술로 이 스냅샷을 구현한다. 락 없이도 읽기 일관성을 보장할 수 있는 이유다.</p>
</blockquote>
<hr>
<h2 id="5-phantom-read--없던-행이-나타나는-문제">5. Phantom Read — 없던 행이 나타나는 문제</h2>
<h3 id="개념-2">개념</h3>
<p>하나의 트랜잭션 안에서 같은 조건으로 SELECT를 두 번 실행했을 때, 다른 트랜잭션이 새로운 행을 INSERT하고 COMMIT하여 첫 번째엔 없던 행이 두 번째 조회에서 나타나는 현상이다.</p>
<pre><code>트랜잭션 A: WHERE stock &gt; 10 조회 → 2개
트랜잭션 B: stock=15인 신상품 INSERT 후 COMMIT
트랜잭션 A: 같은 조건 재조회 → 3개  ← 유령 행 등장</code></pre><p>Repeatable Read의 스냅샷은 기존 행의 변경은 막아주지만, 스냅샷 이후에 새로 추가된 행은 막지 못한다.</p>
<h3 id="non-repeatable-read-vs-phantom-read">Non-Repeatable Read vs Phantom Read</h3>
<table>
<thead>
<tr>
<th></th>
<th>Non-Repeatable Read</th>
<th>Phantom Read</th>
</tr>
</thead>
<tbody><tr>
<td>문제</td>
<td>특정 행의 <strong>값</strong>이 바뀜</td>
<td>조회 결과의 <strong>행 개수</strong>가 바뀜</td>
</tr>
<tr>
<td>원인</td>
<td>다른 트랜잭션의 UPDATE/DELETE</td>
<td>다른 트랜잭션의 INSERT</td>
</tr>
</tbody></table>
<h3 id="해결--serializable">해결 — Serializable</h3>
<p>SELECT 쿼리의 조건 범위 자체에 잠금을 건다. <code>WHERE stock &gt; 10</code> 범위에 해당하는 INSERT 자체를 차단하여 Phantom Read를 원천적으로 막는다.</p>
<p>단, 동시 처리 성능이 크게 저하되므로 극히 제한적인 경우에만 사용한다.</p>
<hr>
<h2 id="6-정리">6. 정리</h2>
<table>
<thead>
<tr>
<th>격리 수준</th>
<th>Dirty Read</th>
<th>Non-Repeatable Read</th>
<th>Phantom Read</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>Read Uncommitted</td>
<td>발생</td>
<td>발생</td>
<td>발생</td>
<td>거의 사용 안 함</td>
</tr>
<tr>
<td>Read Committed</td>
<td>방지</td>
<td>발생</td>
<td>발생</td>
<td>Oracle, PostgreSQL 기본값</td>
</tr>
<tr>
<td>Repeatable Read</td>
<td>방지</td>
<td>방지</td>
<td>발생</td>
<td>MySQL 기본값</td>
</tr>
<tr>
<td>Serializable</td>
<td>방지</td>
<td>방지</td>
<td>방지</td>
<td>성능 저하, 제한적 사용</td>
</tr>
</tbody></table>
<p>실무에서는 대부분 <strong>Read Committed</strong> 또는 <strong>Repeatable Read</strong> 중에서 선택한다.</p>
<ul>
<li>상품 조회, 목록 페이징처럼 단순 읽기 → Read Committed</li>
<li>주문 처리, 재고 차감처럼 정합성이 중요한 경우 → Repeatable Read</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>격리 수준은 &quot;얼마나 엄격하게 트랜잭션을 격리할 것인가&quot;에 대한 선택이다. 정합성을 높이면 성능이 떨어지고, 성능을 높이면 정합성이 위험해진다.</p>
<p>다음 글에서는 격리 수준만으로 해결되지 않는 동시성 문제를 <strong>낙관적 락과 비관적 락</strong>으로 어떻게 해결하는지 다룬다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Huge Traffic Handling] 트랜잭션과 @Transactional — Spring은 왜 이걸 대신 해주는가]]></title>
            <link>https://velog.io/@furaha_dev/Huge-Traffic-Handling-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-Transactional-Spring%EC%9D%80-%EC%99%9C-%EC%9D%B4%EA%B1%B8-%EB%8C%80%EC%8B%A0-%ED%95%B4%EC%A3%BC%EB%8A%94%EA%B0%80</link>
            <guid>https://velog.io/@furaha_dev/Huge-Traffic-Handling-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-Transactional-Spring%EC%9D%80-%EC%99%9C-%EC%9D%B4%EA%B1%B8-%EB%8C%80%EC%8B%A0-%ED%95%B4%EC%A3%BC%EB%8A%94%EA%B0%80</guid>
            <pubDate>Sun, 19 Apr 2026 10:52:48 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서는 Spring AI의 Advisor 패턴과 Function Calling을 다뤘다. 이번 글에서는 잠시 AI 주제에서 벗어나, 백엔드 개발의 가장 기본적인 안전장치 중 하나인 트랜잭션을 짚고 넘어간다.</p>
<p>이런 질문들을 생각해보자.</p>
<ul>
<li>트랜잭션이 왜 필요한가?</li>
<li>Spring은 트랜잭션을 어떻게 대신 처리해주는가?</li>
<li>직접 제어해야 할 때는 언제인가?</li>
</ul>
<h2 id="1-트랜잭션이란">1. 트랜잭션이란</h2>
<p>트랜잭션은 여러 DB 작업을 하나의 논리적 단위로 묶는 것이다. 핵심은 <strong>All or Nothing</strong> — 전부 성공하거나, 전부 실패하거나.</p>
<p>주문 처리를 예로 들면, 주문 테이블 INSERT와 재고 UPDATE는 반드시 함께 성공하거나 함께 실패해야 한다. 주문만 들어가고 재고가 안 줄면 데이터가 엉망이 된다.</p>
<h2 id="2-acid-원칙">2. ACID 원칙</h2>
<p>트랜잭션이 보장해야 하는 4가지 원칙이다.</p>
<table>
<thead>
<tr>
<th>원칙</th>
<th>의미</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>Atomicity (원자성)</td>
<td>전부 성공 또는 전부 롤백</td>
<td>주문+재고 둘 다 성공해야</td>
</tr>
<tr>
<td>Consistency (일관성)</td>
<td>트랜잭션 전후 규칙 만족</td>
<td>이체 전후 총액 동일</td>
</tr>
<tr>
<td>Isolation (격리성)</td>
<td>트랜잭션 간 간섭 없음</td>
<td>동시 주문도 독립 처리</td>
</tr>
<tr>
<td>Durability (지속성)</td>
<td>커밋된 데이터는 영구 보존</td>
<td>서버 재시작 후에도 유지</td>
</tr>
</tbody></table>
<p>이 중 AutoCommit이 켜진 상태에서 가장 먼저 무너지는 건 <strong>원자성(A)</strong>이다. SQL 한 줄마다 자동으로 커밋되니, 중간에 에러가 나도 이미 커밋된 앞 작업은 되돌릴 수 없다.</p>
<p>그래서 JDBC 트랜잭션 코드에서 가장 먼저 하는 게 이것이다.</p>
<pre><code class="language-java">connection.setAutoCommit(false); // 커밋 타이밍을 내가 직접 제어할게</code></pre>
<h2 id="3-트랜잭션-관리-방식-두-가지">3. 트랜잭션 관리 방식 두 가지</h2>
<h3 id="3-1-프로그래밍-방식">3-1. 프로그래밍 방식</h3>
<p>개발자가 트랜잭션의 시작, 커밋, 롤백을 코드로 직접 제어하는 방식이다. Spring의 <code>PlatformTransactionManager</code>를 사용한다.</p>
<pre><code class="language-java">TransactionStatus status = transactionManager.getTransaction(
    new DefaultTransactionDefinition()); // 트랜잭션 시작

try {
    product.reduceStock(quantity);
    productRepository.save(product);

    transactionManager.commit(status);  // 성공 시 커밋

} catch (Exception ex) {
    transactionManager.rollback(status); // 실패 시 롤백
    throw ex;
}</code></pre>
<p>서비스 메서드가 100개라면 저 try-catch 블록이 100번 반복된다. 누락될 위험도 있고, 비즈니스 로직과 트랜잭션 로직이 뒤섞인다.</p>
<p>그럼에도 이 방식이 필요한 경우가 있다. 100개 배치 작업 중 1개가 실패해도 나머지 99개는 커밋해야 하는 <strong>부분 롤백</strong> 같은 경우다. 조건에 따라 트랜잭션 경계를 유연하게 조정해야 할 때 직접 제어가 빛을 발한다.</p>
<h3 id="3-2-선언적-방식-transactional">3-2. 선언적 방식 (@Transactional)</h3>
<p>Spring이 AOP Proxy를 통해 자동으로 처리하는 방식이다. 메서드 앞뒤를 Proxy가 감싸서 트랜잭션을 시작하고, 성공하면 커밋, 예외가 나면 롤백한다.</p>
<pre><code class="language-java">// Before: 트랜잭션 코드가 비즈니스 로직과 섞임
public void updateStock(Long id, int qty) {
    TransactionStatus status = transactionManager.getTransaction(...);
    try {
        product.reduceStock(qty);
        transactionManager.commit(status);
    } catch (Exception e) {
        transactionManager.rollback(status);
    }
}

// After: 비즈니스 로직만 남음
@Transactional
public void updateStock(Long id, int qty) {
    product.reduceStock(qty);
}</code></pre>
<p>읽기 전용 메서드에는 <code>readOnly = true</code>를 붙인다. 이렇게 하면 Spring이 Dirty Checking(변경 감지)을 생략해서 불필요한 스냅샷 비교 오버헤드를 줄인다.</p>
<pre><code class="language-java">@Transactional(readOnly = true)
public Product getProduct(Long id) {
    return productRepository.findById(id)
        .orElseThrow(() -&gt; new DomainException(PRODUCT_NOT_FOUND));
}</code></pre>
<p>주의할 점은 AOP 기반이라는 것이다. private 메서드나 같은 클래스 내부 호출(self-invocation)에는 Proxy가 개입하지 못해서 트랜잭션이 적용되지 않는다. 이건 6단계에서 다뤘던 self-invocation 문제와 같은 맥락이다.</p>
<h2 id="4-두-방식-비교">4. 두 방식 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>선언적 (@Transactional)</th>
<th>프로그래밍 방식</th>
</tr>
</thead>
<tbody><tr>
<td>코드 간결성</td>
<td>어노테이션 한 줄</td>
<td>try-catch 반복</td>
</tr>
<tr>
<td>유지보수</td>
<td>비즈니스 로직과 분리</td>
<td>혼재 가능성</td>
</tr>
<tr>
<td>제어 유연성</td>
<td>속성으로 설정</td>
<td>조건부 커밋/롤백 가능</td>
</tr>
<tr>
<td>적합한 상황</td>
<td>일반적인 CRUD</td>
<td>부분 롤백, 복잡한 로직</td>
</tr>
</tbody></table>
<h2 id="마치며">마치며</h2>
<p>트랜잭션은 결국 <strong>데이터 무결성을 지키는 안전장치</strong>다. Spring은 <code>@Transactional</code>이라는 어노테이션 하나로 그 안전장치를 AOP Proxy가 자동으로 달아준다. 단순 CRUD는 선언적 방식으로 충분하고, 세밀한 제어가 필요할 때만 프로그래밍 방식을 꺼내면 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Huge Traffic Handling] Redis 영속성과 Spring Security 세션 인증]]></title>
            <link>https://velog.io/@furaha_dev/Huge-Traffic-Handling-Redis-%EC%98%81%EC%86%8D%EC%84%B1%EA%B3%BC-Spring-Security-%EC%84%B8%EC%85%98-%EC%9D%B8%EC%A6%9D</link>
            <guid>https://velog.io/@furaha_dev/Huge-Traffic-Handling-Redis-%EC%98%81%EC%86%8D%EC%84%B1%EA%B3%BC-Spring-Security-%EC%84%B8%EC%85%98-%EC%9D%B8%EC%A6%9D</guid>
            <pubDate>Sat, 18 Apr 2026 03:56:32 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서 Redis가 In-Memory 저장소로서 빠른 속도를 제공하고, 다양한 데이터 타입과 캐싱 전략을 통해 어떻게 시스템 성능을 끌어올리는지 살펴봤다. 그런데 여기서 자연스럽게 의문이 생긴다.</p>
<ul>
<li>Redis는 메모리에 저장하는데, 서버가 꺼지면 데이터는 어떻게 되는가?</li>
<li>다중 서버 환경에서 사용자 인증 세션은 어떻게 공유하는가?</li>
<li>Spring Security와 Redis를 어떻게 연동해서 인증 시스템을 구축하는가?</li>
</ul>
<p>이번 글에서는 이 세 가지 질문을 순서대로 풀어나간다.</p>
<hr>
<h2 id="1-redis-영속성-문제">1. Redis 영속성 문제</h2>
<h3 id="메모리의-본질적-한계">메모리의 본질적 한계</h3>
<p>Redis가 빠른 이유는 단순하다. 데이터를 디스크가 아닌 메모리(RAM)에 저장하기 때문이다. 그런데 메모리는 전원이 끊기는 순간 모든 데이터가 사라진다. 서버 재시작, 예상치 못한 장애, 배포 과정에서의 재부팅 — 이런 상황에서 메모리 위의 데이터는 순식간에 증발한다.</p>
<p>이 문제를 해결하기 위해 Redis는 데이터를 디스크에 저장하는 영속성(Persistence) 전략을 제공한다. 크게 두 가지 방식이 있다.</p>
<h3 id="rdb-스냅샷-방식">RDB: 스냅샷 방식</h3>
<p>RDB(Redis Database)는 특정 시점의 메모리 상태를 통째로 디스크에 저장하는 방식이다. 카메라로 사진을 찍듯이 현재 상태를 <code>.rdb</code> 파일로 기록한다.</p>
<pre><code>save 60 1000   # 60초마다 1000개 이상 변경되면 저장
save 300 10    # 300초마다 10개 이상 변경되면 저장</code></pre><p><strong>장점</strong>: 파일 크기가 작고 복구 속도가 빠르다. 스냅샷 저장 중 Redis 성능 저하가 비교적 적다.</p>
<p><strong>단점</strong>: 스냅샷 주기 사이에 서버가 죽으면 그 사이의 데이터는 유실된다. 5분 주기로 저장하는데 4분 59초에 장애가 발생하면 5분치 데이터가 사라진다.</p>
<h3 id="aof-쓰기-로그-방식">AOF: 쓰기 로그 방식</h3>
<p>AOF(Append-Only File)는 Redis에서 발생하는 모든 쓰기 명령을 로그 파일에 순차적으로 기록하는 방식이다. 일기장에 매일 있었던 일을 빠짐없이 적어두는 것과 같다.</p>
<pre><code>appendonly yes
appendfsync everysec   # 매 초마다 디스크에 동기화 (기본값)
appendfsync always     # 모든 쓰기 명령마다 동기화
appendfsync no         # 운영체제에 맡김</code></pre><p><strong>장점</strong>: 데이터 유실 가능성이 훨씬 낮다. <code>everysec</code> 설정 시 최대 1초치만 유실된다.</p>
<p><strong>단점</strong>: 모든 쓰기 명령을 기록하므로 파일 크기가 커진다. 쓰기 빈도가 높으면 디스크 I/O 오버헤드가 발생한다. Redis가 빠른 이유가 메모리 저장 때문인데, <code>always</code> 설정은 매 쓰기마다 디스크와 동기화하므로 결국 디스크 속도에 발목이 잡힌다. 그래서 기본값이 <code>everysec</code>인 것이다.</p>
<h3 id="rdb--aof-병행-사용">RDB + AOF 병행 사용</h3>
<p>두 방식을 함께 사용하면 각자의 단점을 보완할 수 있다.</p>
<table>
<thead>
<tr>
<th></th>
<th>RDB</th>
<th>AOF</th>
<th>RDB + AOF</th>
</tr>
</thead>
<tbody><tr>
<td>복구 속도</td>
<td>빠름</td>
<td>느림</td>
<td>빠름 (RDB 활용)</td>
</tr>
<tr>
<td>데이터 유실</td>
<td>스냅샷 주기만큼</td>
<td>최대 1초</td>
<td>최소화</td>
</tr>
<tr>
<td>파일 크기</td>
<td>작음</td>
<td>큼</td>
<td>-</td>
</tr>
</tbody></table>
<p>병행 사용 시 서버 재시작 때 Redis는 AOF를 우선적으로 읽는다. RDB 스냅샷 이후의 변경사항이 AOF에 기록되어 있기 때문에 AOF가 항상 더 최신 데이터를 갖고 있기 때문이다.</p>
<hr>
<h2 id="2-spring-security--spring-session--redis-연동">2. Spring Security + Spring Session + Redis 연동</h2>
<h3 id="인증과-인가">인증과 인가</h3>
<p>Spring Security를 이야기하기 전에 두 개념을 명확히 구분해야 한다.</p>
<ul>
<li><strong>인증(Authentication)</strong>: 네가 누구인지 확인하는 과정. 로그인이 대표적이다.</li>
<li><strong>인가(Authorization)</strong>: 인증된 사용자가 무엇을 할 수 있는지 결정하는 과정. 권한 검사다.</li>
</ul>
<p>순서가 중요하다. 인증이 먼저 되어야 인가가 의미를 갖는다.</p>
<h3 id="왜-redis에-세션을-저장하는가">왜 Redis에 세션을 저장하는가</h3>
<p>이전 글에서 세션 클러스터링을 다뤘다. 다중 서버 환경에서 Sticky Session은 부하 불균형 문제가 있고, Session Replication은 복제 비용이 증가한다. Redis를 중앙 세션 저장소로 사용하면 이 두 문제를 모두 해결할 수 있다.</p>
<pre><code>서버 A ──┐
서버 B ──┼── Redis (세션 저장소)
서버 C ──┘</code></pre><p>사용자가 서버 A에서 로그인하고 다음 요청이 서버 B로 라우팅되더라도, 서버 B는 Redis에서 동일한 세션을 꺼내 인증 상태를 유지한다.</p>
<h3 id="spring-session의-역할">Spring Session의 역할</h3>
<p>Spring Session은 기존 <code>HttpSession</code> 코드를 전혀 바꾸지 않고 저장소만 Redis로 교체해주는 추상화 계층이다.</p>
<pre><code class="language-java">@Configuration
@EnableRedisHttpSession
public class SessionConfig {

    @Bean
    public RedisSerializer&lt;Object&gt; springSessionDefaultRedisSerializer() {
        return RedisSerializer.java();
    }
}</code></pre>
<p><code>@EnableRedisHttpSession</code> 하나로 Spring에게 &quot;이제부터 HttpSession은 Redis로 관리해&quot;라고 선언한다. 개발자는 기존처럼 <code>HttpSession</code>을 쓰면 된다.</p>
<h3 id="사용자-정보-로드-userdetails와-userdetailsservice">사용자 정보 로드: UserDetails와 UserDetailsService</h3>
<p>Spring Security가 인증을 처리하려면 사용자 정보를 어떤 형태로 받을지 알아야 한다. 이를 위해 두 가지를 구현한다.</p>
<pre><code class="language-java">// 사용자 정보를 담는 객체
public class CustomUserDetails implements UserDetails, Serializable {
    private final Long userId;
    private final String email;
    private final String password;

    @Override
    public Collection&lt;? extends GrantedAuthority&gt; getAuthorities() {
        return Collections.singletonList(new SimpleGrantedAuthority(&quot;ROLE_USER&quot;));
    }
    // ...
}</code></pre>
<pre><code class="language-java">// DB에서 사용자를 조회해서 CustomUserDetails로 변환
@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String email) {
        User user = userRepository.findByEmail(email)
            .orElseThrow(() -&gt; new UsernameNotFoundException(&quot;User not found&quot;));
        return CustomUserDetails.from(user);
    }
}</code></pre>
<ul>
<li><strong><code>CustomUserDetails</code></strong>: Spring Security가 인식하는 형태로 사용자 정보를 담는 객체</li>
<li><strong><code>CustomUserDetailsService</code></strong>: 이메일로 DB에서 사용자를 조회해서 <code>CustomUserDetails</code>로 변환</li>
</ul>
<p><code>Serializable</code>을 구현하는 이유는 Redis에 저장할 때 직렬화가 필요하기 때문이다.</p>
<h3 id="로그인-흐름">로그인 흐름</h3>
<pre><code class="language-java">@Transactional
public LoginResponse login(LoginRequest loginRequest,
    HttpServletRequest request, HttpServletResponse response) {

    // 1. 인증 토큰 생성 및 검증
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(
            loginRequest.getEmail(),
            loginRequest.getPassword()
        )
    );

    // 2. SecurityContext에 인증 정보 저장 (스레드 로컬)
    SecurityContext context = SecurityContextHolder.createEmptyContext();
    context.setAuthentication(authentication);
    SecurityContextHolder.setContext(context);

    // 3. HTTP 세션에 저장 → Redis에 영속화
    securityContextRepository.saveContext(context, request, response);

    CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
    return LoginResponse.builder()
        .userId(userDetails.getUserId())
        .email(userDetails.getEmail())
        .build();
}</code></pre>
<p><strong>1단계: 인증 검증</strong></p>
<p><code>authenticationManager.authenticate()</code>가 호출되면 내부적으로 이런 일이 일어난다.</p>
<pre><code>UsernamePasswordAuthenticationToken 생성 (미인증 상태)
    → DaoAuthenticationProvider
    → CustomUserDetailsService.loadUserByUsername() → DB 조회
    → BCryptPasswordEncoder로 비밀번호 검증
    → 성공 시 인증된 Authentication 객체 반환</code></pre><p>비밀번호 검증에 BCrypt를 사용하는 이유가 있다. 같은 비밀번호라도 매번 다른 해시값을 생성하기 때문에 단순 해시 비교가 아닌 BCrypt 알고리즘으로 검증한다.</p>
<p><strong>2단계: SecurityContextHolder (스레드 로컬)</strong></p>
<p><code>SecurityContextHolder</code>는 현재 요청을 처리하는 스레드에 인증 정보를 저장한다. HTTP 요청 하나가 들어오면 스레드 하나가 할당되는데, 이 스레드가 살아있는 동안 Controller, Service, Repository 어디서든 별도 파라미터 없이 인증 정보에 접근할 수 있다.</p>
<pre><code class="language-java">// 어디서든 현재 인증 정보 접근 가능
Authentication auth = SecurityContextHolder.getContext().getAuthentication();</code></pre>
<p><strong>3단계: HTTP 세션에 저장</strong></p>
<p>스레드 로컬은 요청 처리가 끝나면 사라진다. 다음 요청에서도 인증 상태를 유지하려면 세션에 저장해야 한다. <code>securityContextRepository.saveContext()</code>가 <code>SecurityContext</code>를 HTTP 세션에 저장하고, Spring Session이 이를 Redis에 영속화한다.</p>
<pre><code>다음 요청 시 흐름:
Redis에서 세션 조회 → SecurityContext 복원 → SecurityContextHolder에 설정 → 인증된 상태로 처리</code></pre><h3 id="securityconfig-핵심-설정">SecurityConfig 핵심 설정</h3>
<pre><code class="language-java">@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .securityContext(context -&gt; context
            .securityContextRepository(securityContextRepository())
        )
        .sessionManagement(session -&gt; session
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
            .maximumSessions(1)
            .maxSessionsPreventsLogin(false)
        )
        .authorizeHttpRequests(auth -&gt; auth
            .requestMatchers(SECURITY_EXCLUDE_PATHS).permitAll()
            .anyRequest().authenticated()
        )
        .formLogin(AbstractHttpConfigurer::disable)
        .httpBasic(AbstractHttpConfigurer::disable)
        .exceptionHandling(ex -&gt; ex
            .authenticationEntryPoint((request, response, authException) -&gt; {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                // 커스텀 JSON 에러 응답
            })
        );

    return http.build();
}</code></pre>
<p>주요 설정의 이유를 정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>설정</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>csrf().disable()</code></td>
<td>REST API는 토큰 기반 인증을 사용하므로 불필요</td>
</tr>
<tr>
<td><code>formLogin().disable()</code></td>
<td>REST API 환경에서 폼 로그인 화면 불필요</td>
</tr>
<tr>
<td><code>httpBasic().disable()</code></td>
<td>커스텀 로그인 API를 사용하므로 불필요</td>
</tr>
<tr>
<td><code>maximumSessions(1)</code></td>
<td>동일 사용자 동시 세션 1개 제한</td>
</tr>
<tr>
<td><code>maxSessionsPreventsLogin(false)</code></td>
<td>새 로그인 시 기존 세션 만료 (차단 아님)</td>
</tr>
<tr>
<td><code>authenticationEntryPoint</code></td>
<td>인증 실패 시 커스텀 JSON 응답 반환</td>
</tr>
</tbody></table>
<h3 id="로그아웃">로그아웃</h3>
<pre><code class="language-java">@GetMapping(&quot;/logout&quot;)
public ApiResponse&lt;Void&gt; logout(HttpServletRequest request) {
    SecurityContextHolder.clearContext();      // 스레드 로컬 제거
    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate();                  // 세션 무효화 → Redis에서도 삭제
    }
    return ApiResponse.ok();
}</code></pre>
<p>두 작업이 모두 필요한 이유가 있다.</p>
<ul>
<li><code>clearContext()</code>만 하면 → 스레드 로컬은 지워지지만 Redis 세션이 살아있어 다음 요청에서 복원된다</li>
<li><code>session.invalidate()</code>만 하면 → Redis 세션은 지워지지만 현재 요청에서는 스레드 로컬에 인증 정보가 남아있다</li>
</ul>
<p>둘 다 해야 완전한 로그아웃이 된다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>Redis 영속성은 Redis 자체가 꺼졌을 때 데이터를 잃지 않기 위한 전략이고, Spring Session + Redis는 다중 서버 환경에서 인증 세션을 공유하기 위한 전략이다. 두 개념은 모두 &quot;Redis를 더 안정적으로 운영하기 위한&quot; 관점에서 연결된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Huge Traffic Handling] Redis 캐싱 전략 — Cache-Aside부터 Write-back까지]]></title>
            <link>https://velog.io/@furaha_dev/Huge-Traffic-Handling-Redis-%EC%BA%90%EC%8B%B1-%EC%A0%84%EB%9E%B5-Cache-Aside%EB%B6%80%ED%84%B0-Write-back%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@furaha_dev/Huge-Traffic-Handling-Redis-%EC%BA%90%EC%8B%B1-%EC%A0%84%EB%9E%B5-Cache-Aside%EB%B6%80%ED%84%B0-Write-back%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Fri, 17 Apr 2026 15:36:02 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서는 Redis의 핵심 데이터 타입을 살펴봤다. String, List, Set, Hash, Sorted Set이 각각 어떤 문제를 해결하는지, 왜 Redis 안에서 직접 처리하는 게 빠른지를 이해했다.</p>
<p>이번 글에서는 한 발 더 나아가 Redis를 캐시로 활용하는 방법을 다룬다. 아래 질문들을 중심으로 이야기를 풀어나갈 것이다.</p>
<ul>
<li>캐시와 DB 사이의 데이터 불일치는 어떻게 해결할까?</li>
<li>읽기가 많은 서비스와 쓰기가 많은 서비스는 캐싱 전략이 달라야 할까?</li>
<li>메모리가 꽉 찼을 때 Redis는 어떤 데이터를 먼저 삭제할까?</li>
</ul>
<hr>
<h2 id="1-캐싱의-핵심-트레이드오프">1. 캐싱의 핵심 트레이드오프</h2>
<p>Redis를 캐시로 사용하면 DB보다 훨씬 빠르게 데이터를 읽어올 수 있다. 인메모리 저장소이기 때문이다.</p>
<p>그런데 여기서 문제가 생긴다. DB에서 상품 가격이 변경됐는데, Redis에는 아직 이전 가격이 캐싱되어 있다면 사용자는 잘못된 데이터를 보게 된다.</p>
<p>캐싱 전략을 설계할 때는 항상 세 가지 요소를 고려해야 한다.</p>
<ul>
<li><strong>데이터 일관성</strong> — 캐시와 DB가 얼마나 동기화되어 있는가</li>
<li><strong>성능</strong> — 읽기/쓰기 속도가 얼마나 빠른가</li>
<li><strong>데이터 안전성</strong> — 장애 시 데이터 손실 위험이 얼마나 되는가</li>
</ul>
<p>이 세 가지는 동시에 완벽하게 만족시키기 어렵다. 하나를 얻으면 다른 걸 포기해야 하는 경우가 많다. 아래에서 다룰 4가지 전략은 이 트레이드오프를 각각 다르게 선택한 결과다.</p>
<hr>
<h2 id="2-cache-aside">2. Cache-Aside</h2>
<h3 id="개념">개념</h3>
<p>Cache-Aside는 가장 널리 사용되는 캐싱 패턴이다. 애플리케이션이 캐시를 직접 관리하는 방식으로, 읽기 요청이 들어오면 항상 캐시를 먼저 확인한다.</p>
<pre><code>읽기 요청
  → Redis 조회
    → 데이터 있음 (Cache Hit)  : Redis에서 바로 반환
    → 데이터 없음 (Cache Miss) : DB 조회 → Redis에 저장 → 반환</code></pre><p>도서관에 비유하면, 책상 위(Redis)에 책이 있으면 바로 읽고, 없으면 서고(DB)에서 꺼내서 책상 위에 올려두는 것이다. 다음에 또 필요하면 서고까지 안 가도 된다.</p>
<h3 id="코드-예제">코드 예제</h3>
<pre><code class="language-java">public List&lt;CategoryResponse&gt; findAllForCacheAside() {
    String cached = redisTemplate.opsForValue().get(CACHE_KEY);

    // Cache Hit
    if (!ObjectUtils.isEmpty(cached)) {
        return JsonUtil.fromJsonList(cached, CategoryResponse.class);
    }

    // Cache Miss: DB 조회 후 캐시에 저장
    List&lt;CategoryResponse&gt; categories = findAll();
    if (!categories.isEmpty()) {
        redisTemplate.opsForValue().set(CACHE_KEY, JsonUtil.toJson(categories), 1, TimeUnit.HOURS);
    }

    return categories;
}</code></pre>
<h3 id="장단점">장단점</h3>
<p>Cache-Aside의 장점은 읽기 성능이다. 자주 조회되는 데이터가 캐시에 올라오면 DB 접근 없이 빠르게 응답할 수 있다.</p>
<p>단점은 두 가지다. 첫째, Cache Miss 시 Redis도 확인하고 DB도 다녀오고 Redis에 저장까지 해야 하므로 그냥 DB에서 바로 가져오는 것보다 오히려 느릴 수 있다. 둘째, DB 데이터가 변경되어도 캐시는 TTL이 만료되기 전까지 오래된 데이터를 갖고 있는다.</p>
<p>TTL을 적절히 설정해서 두 번째 문제를 완화할 수 있다. 캐시에 유통기한을 붙이는 것이다. TTL이 지나면 Redis가 자동으로 데이터를 삭제하고, 다음 요청 시 DB에서 최신 데이터를 다시 가져온다.</p>
<h3 id="적합한-상황">적합한 상황</h3>
<p>읽기 요청이 많고 데이터가 자주 변경되지 않는 경우에 적합하다. 상품 목록, 뉴스 기사, 사용자 프로필 등이 대표적인 사례다.</p>
<hr>
<h2 id="3-write-through">3. Write-through</h2>
<h3 id="개념-1">개념</h3>
<p>Write-through는 데이터를 쓸 때 Redis와 DB를 동시에 업데이트하는 전략이다. 두 쓰기 작업이 모두 성공해야 완료로 간주한다.</p>
<pre><code>쓰기 요청
  → Redis 업데이트
  → DB 업데이트
  → 둘 다 성공 : 완료
  → 하나라도 실패 : 롤백</code></pre><h3 id="코드-예제-1">코드 예제</h3>
<pre><code class="language-java">@Transactional
public void saveWriteThrough(CategoryRequest request) {
    create(request);         // DB 저장
    updateCacheCategories(); // 캐시 즉시 업데이트
}

private void updateCacheCategories() {
    List&lt;CategoryResponse&gt; categories = findAll();
    if (!categories.isEmpty()) {
        redisTemplate.opsForValue().set(CACHE_KEY, JsonUtil.toJson(categories));
    }
}</code></pre>
<h3 id="장단점-1">장단점</h3>
<p>Cache-Aside의 단점이었던 데이터 불일치 문제가 해결된다. 쓰기할 때마다 캐시를 최신 상태로 유지하기 때문이다.</p>
<p>단점은 쓰기 성능 저하다. 매번 두 곳에 써야 하므로 DB 응답을 기다려야 하고, 쓰기 요청이 많은 시스템에서는 병목이 생길 수 있다.</p>
<h3 id="적합한-상황-1">적합한 상황</h3>
<p>데이터 일관성이 매우 중요한 경우에 적합하다. 금융 거래, 재고 관리, 주문 처리처럼 단 한 건의 데이터 불일치도 허용되지 않는 시스템이 대표적이다.</p>
<hr>
<h2 id="4-write-back">4. Write-back</h2>
<h3 id="개념-2">개념</h3>
<p>Write-back은 쓰기 요청 시 Redis에만 먼저 저장하고, DB에는 나중에 비동기로 반영하는 전략이다. 쓰기 성능을 극대화하는 데 초점을 맞춘다.</p>
<pre><code>쓰기 요청
  → Redis에만 저장 → 즉시 응답
  → (나중에 비동기로) DB에 반영</code></pre><h3 id="코드-예제-2">코드 예제</h3>
<pre><code class="language-java">public void saveWriteBack(CategoryRequest request) {
    // Redis에 먼저 저장
    redisTemplate.opsForValue().set(CACHE_KEY, JsonUtil.toJson(updatedCategories));

    // DB는 비동기로 나중에 처리
    saveToDatabaseAsync(request);
}

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveToDatabaseAsync(CategoryRequest request) {
    create(request);
}</code></pre>
<h3 id="장단점-2">장단점</h3>
<p>DB 응답을 기다릴 필요가 없으므로 쓰기 성능이 가장 빠르다. 여러 쓰기 요청을 모아서 DB에 한 번에 반영하는 배치 처리도 가능해 DB 부하를 줄일 수 있다.</p>
<p>치명적인 단점은 데이터 손실 위험이다. DB에 아직 반영되지 않고 Redis에만 존재하는 데이터가 있는 상태에서 Redis 서버가 다운되면 그 데이터는 영구적으로 사라진다.</p>
<h3 id="적합한-상황-2">적합한 상황</h3>
<p>쓰기 빈도가 매우 높고 데이터 손실이 조금 발생해도 무방한 경우에 적합하다. 게임 플레이 세션, 좋아요 카운트, 실시간 로그 수집 등이 대표적인 사례다.</p>
<hr>
<h2 id="5-write-around">5. Write-around</h2>
<h3 id="개념-3">개념</h3>
<p>Write-around는 쓰기 요청 시 Redis를 완전히 우회하고 DB에만 저장하는 전략이다. 캐시는 오로지 읽기 성능 향상에만 사용한다.</p>
<pre><code>쓰기 요청
  → DB에만 저장 (Redis는 건드리지 않음)

읽기 요청
  → Cache-Aside와 동일하게 동작</code></pre><h3 id="코드-예제-3">코드 예제</h3>
<pre><code class="language-java">public void saveWriteAround(CategoryRequest request) {
    // DB에만 저장
    create(request);

    // 기존 캐시 무효화 (선택적)
    redisTemplate.delete(CACHE_KEY);
}</code></pre>
<h3 id="장단점-3">장단점</h3>
<p>쓰기 작업이 캐시를 거치지 않으므로 캐시 부하가 없다. 항상 DB가 최신 데이터임을 신뢰할 수 있다.</p>
<p>단점은 데이터를 쓴 직후 바로 읽으려 하면 Cache Miss가 발생해 DB까지 다녀와야 한다는 점이다.</p>
<h3 id="적합한-상황-3">적합한 상황</h3>
<p>쓰기는 많지만 쓴 데이터를 즉시 읽을 필요가 없는 경우에 적합하다. 대용량 로그 수집, 배치 처리용 데이터 기록 등이 대표적이다.</p>
<hr>
<h2 id="6-전략-비교">6. 전략 비교</h2>
<table>
<thead>
<tr>
<th>전략</th>
<th>쓰기 대상</th>
<th>읽기 성능</th>
<th>쓰기 성능</th>
<th>일관성</th>
<th>데이터 손실</th>
</tr>
</thead>
<tbody><tr>
<td>Cache-Aside</td>
<td>DB만 (읽기 전략)</td>
<td>빠름</td>
<td>-</td>
<td>중간</td>
<td>없음</td>
</tr>
<tr>
<td>Write-through</td>
<td>Redis + DB 동시</td>
<td>빠름</td>
<td>느림</td>
<td>강함</td>
<td>없음</td>
</tr>
<tr>
<td>Write-back</td>
<td>Redis만 (나중에 DB)</td>
<td>빠름</td>
<td>매우 빠름</td>
<td>약함</td>
<td>위험</td>
</tr>
<tr>
<td>Write-around</td>
<td>DB만</td>
<td>초기 느림</td>
<td>보통</td>
<td>강함</td>
<td>없음</td>
</tr>
</tbody></table>
<p>실무에서는 단일 전략만 사용하지 않는다. 예를 들어 이커머스 서비스라면 상품 목록 조회에는 Cache-Aside를, 주문 생성에는 Write-through를 조합해서 사용하는 것이 일반적이다.</p>
<hr>
<h2 id="7-ttl과-캐시-제거-정책">7. TTL과 캐시 제거 정책</h2>
<h3 id="ttl-time-to-live">TTL (Time To Live)</h3>
<p>TTL은 Redis에 저장된 키에 유효 기간을 설정하는 기능이다. 설정한 시간이 지나면 Redis가 자동으로 해당 데이터를 삭제한다.</p>
<pre><code class="language-java">// 1시간 TTL 설정
redisTemplate.opsForValue().set(CACHE_KEY, data, 1, TimeUnit.HOURS);</code></pre>
<p>TTL은 데이터 특성에 따라 다르게 설정해야 한다.</p>
<ul>
<li>너무 짧으면 Cache Miss가 빈번해져 DB 부하가 증가하고 속도가 저하된다.</li>
<li>너무 길면 오래된 데이터가 오래 유지되어 데이터 불일치가 발생한다.</li>
</ul>
<p>환율 데이터는 하루에 한 번 바뀌니 24시간, 상품 목록은 자주 바뀌지 않으니 1시간처럼 데이터의 변경 주기를 기준으로 설정하는 것이 좋다.</p>
<h3 id="lru와-lfu">LRU와 LFU</h3>
<p>Redis는 인메모리 저장소이므로 메모리가 꽉 차면 기존 데이터 일부를 삭제해야 한다. 이때 어떤 데이터를 삭제할지 결정하는 것이 캐시 제거 정책이다.</p>
<p><strong>LRU (Least Recently Used)</strong>: 가장 오랫동안 사용되지 않은 데이터를 우선 삭제한다. 최근에 사용된 데이터는 다시 사용될 가능성이 높다는 가설에 기반한다. 사용자 세션, 최근 본 상품처럼 시간이 지남에 따라 가치가 떨어지는 데이터에 적합하다.</p>
<p><strong>LFU (Least Frequently Used)</strong>: 사용 빈도가 가장 낮은 데이터를 우선 삭제한다. 오랫동안 사용되지 않았어도 자주 사용되는 데이터라면 유지한다. 인기 상품, 메인 배너처럼 꾸준히 많이 조회되는 데이터에 적합하다.</p>
<p>두 정책의 차이가 명확하게 드러나는 상황이 있다. 어떤 상품 페이지가 어제 엄청 많이 조회됐지만 오늘은 아무도 안 본다면, LRU는 이 데이터를 삭제 대상으로 보지만 LFU는 누적 빈도가 높으므로 한동안 유지한다.</p>
<pre><code># redis.conf
maxmemory 512mb
maxmemory-policy allkeys-lru   # 또는 allkeys-lfu</code></pre><hr>
<h2 id="마치며">마치며</h2>
<p>캐싱 전략의 핵심은 &quot;어떤 트레이드오프를 선택할 것인가&quot;다. 속도, 일관성, 안전성을 동시에 완벽하게 만족시키는 전략은 없다. 서비스의 데이터 특성과 요구사항을 정확히 파악하고 적절한 전략을 선택하는 것이 중요하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Huge Traffic Handling] Redis의 데이터 타입: 왜 String 하나로는 부족한가]]></title>
            <link>https://velog.io/@furaha_dev/Huge-Traffic-Handling-Redis%EC%9D%98-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%83%80%EC%9E%85-%EC%99%9C-String-%ED%95%98%EB%82%98%EB%A1%9C%EB%8A%94-%EB%B6%80%EC%A1%B1%ED%95%9C%EA%B0%80</link>
            <guid>https://velog.io/@furaha_dev/Huge-Traffic-Handling-Redis%EC%9D%98-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%83%80%EC%9E%85-%EC%99%9C-String-%ED%95%98%EB%82%98%EB%A1%9C%EB%8A%94-%EB%B6%80%EC%A1%B1%ED%95%9C%EA%B0%80</guid>
            <pubDate>Fri, 17 Apr 2026 13:15:09 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서는 HTTP Session의 한계와 Redis를 세션 저장소로 활용하는 방법을 살펴봤다. Redis가 세션 관리에 적합한 이유는 In-Memory 저장소 특성상 매 요청마다 조회되는 세션 데이터를 빠르게 처리할 수 있기 때문이었다.</p>
<p>이번 글에서는 Redis를 단순히 &quot;빠른 저장소&quot;로만 보는 시각에서 벗어나, 왜 Redis가 <strong>다양한 데이터 타입</strong>을 지원하는지에 대해 이야기한다. 글을 읽으면서 스스로 이런 질문을 던져보자.</p>
<ul>
<li>String 하나로 모든 데이터를 저장하면 어떤 문제가 생길까?</li>
<li>Redis 데이터 타입이 각각 어떤 문제를 해결하기 위해 존재하는가?</li>
<li>분산 환경에서 서버단 처리와 Redis 처리의 차이는 무엇인가?</li>
</ul>
<hr>
<h2 id="1-왜-다양한-데이터-타입이-필요한가">1. 왜 다양한 데이터 타입이 필요한가</h2>
<p>Redis가 String 하나만 지원한다고 가정해보자. 게임 랭킹 Top 10을 저장하려면 어떻게 해야 할까?</p>
<pre><code>// Redis에 이런 식으로 저장해야 한다
SET game:ranking &quot;player1:2000,player2:1800,player3:1500,...&quot;</code></pre><p>이렇게 하면 &quot;1위부터 3위까지만 조회해줘&quot;라는 요청이 들어왔을 때 어떤 일이 벌어지는가?</p>
<pre><code>1. Redis에서 전체 String을 꺼낸다
2. 서버단에서 파싱한다 (split, 정규식 등)
3. 필요한 부분만 추출한다
4. 결과를 반환한다</code></pre><p>Redis의 가장 큰 장점은 <strong>속도</strong>다. 그런데 이 방식은 무거운 처리를 서버단으로 넘기는 순간, 그 장점을 상쇄시킨다. 더 심각한 문제는 <strong>동시성</strong>이다. 서버 100대가 동시에 같은 데이터를 읽고 수정하려 한다면 Race Condition이 발생한다.</p>
<p>Redis가 다양한 데이터 타입을 지원하는 핵심 이유는 하나다.</p>
<blockquote>
<p><strong>데이터 구조에 맞는 타입을 쓰면, 파싱 없이 Redis 안에서 직접 원하는 데이터만 꺼낼 수 있다.</strong></p>
</blockquote>
<hr>
<h2 id="2-string-범용-바이트-저장소">2. String: 범용 바이트 저장소</h2>
<h3 id="개념">개념</h3>
<p>Redis의 String은 이름과 달리 단순한 문자열 저장소가 아니다. 내부적으로 모든 데이터를 <strong>바이트(byte)</strong> 로 저장하기 때문에, Java의 <code>int</code>, <code>long</code> 같은 타입 구분이 없다. <code>&quot;123&quot;</code>이나 <code>&quot;hello&quot;</code>나 Redis 입장에서는 똑같이 바이트 덩어리다.</p>
<p>이 특성 덕분에 String은 <strong>범용 저장소</strong>에 가깝다.</p>
<h3 id="카운터와-동시성">카운터와 동시성</h3>
<p>String의 강력한 활용 중 하나는 카운터다.</p>
<pre><code class="language-java">// 좋아요 수 증가
Long likes = redisTemplate.opsForValue().increment(&quot;item:123:likes&quot;);

// 페이지 뷰 감소
Long views = redisTemplate.opsForValue().decrement(&quot;page:views&quot;);</code></pre>
<p>서버단에서 직접 구현한다면 이런 순서가 필요하다.</p>
<pre><code>1. Redis에서 &quot;123&quot; 꺼냄
2. Long으로 파싱
3. +1
4. 다시 String으로 저장</code></pre><p>이 과정에서 서버 100개가 동시에 같은 키에 접근하면 Race Condition이 발생한다. 100명이 좋아요를 눌렀는데 결과가 3이 되는 상황이다.</p>
<p>Redis의 <code>increment</code>는 이 문제를 해결한다. Redis는 <strong>Single Thread</strong>로 동작하기 때문에, <code>increment</code> 명령어는 <strong>원자적(Atomic)</strong> 으로 실행된다. get → +1 → set이 쪼개지지 않고 하나의 명령으로 처리되어, 100명이 동시에 눌러도 정확히 100이 나온다.</p>
<h3 id="정리">정리</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>저장 구조</td>
<td>Key → Value (단일 바이트)</td>
</tr>
<tr>
<td>주요 활용</td>
<td>단순 캐싱, 카운터, 세션 상태</td>
</tr>
<tr>
<td>핵심 강점</td>
<td>Atomic 연산으로 동시성 문제 없음</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-list-순서가-있는-작업-대기열">3. List: 순서가 있는 작업 대기열</h2>
<h3 id="개념-1">개념</h3>
<p>List는 <strong>삽입 순서를 유지</strong>하는 데이터 구조다. 양쪽 끝(left, right)에서 데이터를 넣고 뺄 수 있어서, 어느 쪽을 선택하느냐에 따라 Queue도 되고 Stack도 된다.</p>
<pre><code>Queue (FIFO): 한쪽에서 넣고 → 반대쪽에서 꺼냄
Stack (LIFO): 같은 쪽에서 넣고 → 같은 쪽에서 꺼냄</code></pre><h3 id="예제">예제</h3>
<pre><code class="language-java">// Queue (FIFO): left push → right pop
redisTemplate.opsForList().leftPushAll(&quot;tasks&quot;, &quot;Task1&quot;, &quot;Task2&quot;, &quot;Task3&quot;);
// Redis 상태: [Task3, Task2, Task1]

String task = redisTemplate.opsForList().rightPop(&quot;tasks&quot;); // Task1

// 최근 메시지 전체 조회
List&lt;String&gt; messages = redisTemplate.opsForList().range(&quot;chat:room1&quot;, 0, -1);</code></pre>
<h3 id="정리-1">정리</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>저장 구조</td>
<td>Key → [Value1, Value2, ...] (순서 유지)</td>
</tr>
<tr>
<td>주요 활용</td>
<td>작업 대기열, 채팅 메시지 기록, 최근 활동 내역</td>
</tr>
<tr>
<td>핵심 강점</td>
<td>양방향 입출력으로 Queue/Stack 모두 구현 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-set-분산-환경의-중앙-집합">4. Set: 분산 환경의 중앙 집합</h2>
<h3 id="개념-2">개념</h3>
<p>Set은 <strong>중복을 허용하지 않는</strong> 데이터 구조다. 같은 값을 여러 번 추가해도 한 번만 저장된다.</p>
<p>&quot;중복 제거는 Java의 <code>HashSet</code>으로도 되는데, 왜 Redis Set을 써야 하는가?&quot;라는 의문이 생길 수 있다.</p>
<p>Java <code>HashSet</code>은 <strong>서버 메모리</strong>에 올라간다. 서버가 3대라면 <code>HashSet</code>도 3개가 존재한다. 분산 환경에서는 각 서버가 서로 다른 상태를 가지게 되어 데이터 불일치가 발생한다.</p>
<p>Redis Set은 <strong>중앙 저장소</strong>에 하나만 존재한다. 모든 서버가 동일한 집합을 바라보기 때문에, 분산 환경에서도 일관성이 보장된다.</p>
<h3 id="집합-연산">집합 연산</h3>
<p>Set의 또 다른 강점은 교집합, 합집합, 차집합 연산을 <strong>Redis 안에서 직접</strong> 처리한다는 점이다.</p>
<pre><code class="language-java">redisTemplate.opsForSet().add(&quot;event1:users&quot;, &quot;User1&quot;, &quot;User2&quot;, &quot;User4&quot;);
redisTemplate.opsForSet().add(&quot;event2:users&quot;, &quot;User2&quot;, &quot;User3&quot;, &quot;User4&quot;);

// 두 이벤트 모두 참여한 유저
Set&lt;String&gt; common = redisTemplate.opsForSet().intersect(&quot;event1:users&quot;, &quot;event2:users&quot;);
// 결과: {&quot;User2&quot;, &quot;User4&quot;}

// 이벤트1에만 참여한 유저
Set&lt;String&gt; onlyEvent1 = redisTemplate.opsForSet().difference(&quot;event1:users&quot;, &quot;event2:users&quot;);
// 결과: {&quot;User1&quot;}</code></pre>
<p>서버단에서 이 연산을 직접 구현한다면, 전체 데이터를 꺼내서 반복문으로 비교하는 복잡한 코드가 필요하다. Redis는 명령어 하나로 끝낸다.</p>
<h3 id="정리-2">정리</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>저장 구조</td>
<td>Key → {Value1, Value2, ...} (중복 없음, 순서 없음)</td>
</tr>
<tr>
<td>주요 활용</td>
<td>고유 사용자 목록, 태그 관리, 이벤트 참여자</td>
</tr>
<tr>
<td>핵심 강점</td>
<td>분산 환경에서 중앙 집합 역할 + Redis 내 집합 연산</td>
</tr>
</tbody></table>
<hr>
<h2 id="5-hash-필드-단위로-읽고-쓰는-객체-저장소">5. Hash: 필드 단위로 읽고 쓰는 객체 저장소</h2>
<h3 id="개념-3">개념</h3>
<p>Hash는 하나의 Key 아래에 <strong>필드-값 쌍</strong>을 여러 개 저장하는 구조다. 관계형 DB의 레코드 한 행과 유사하다.</p>
<p>String으로도 사용자 프로필을 저장할 수 있다. <code>user:123</code> 키에 JSON 문자열로 저장하면 된다. 그런데 이메일만 변경하려면 어떻게 해야 하는가?</p>
<pre><code>1. JSON String 전체를 꺼낸다
2. 파싱한다
3. 이메일 필드를 수정한다
4. 다시 JSON으로 직렬화한다
5. 저장한다</code></pre><p>Hash라면 다르다.</p>
<h3 id="예제-1">예제</h3>
<pre><code class="language-java">// Before: JSON String 방식
redisTemplate.opsForValue().set(&quot;user:123&quot;, &quot;{\&quot;name\&quot;:\&quot;John\&quot;,\&quot;email\&quot;:\&quot;old@email.com\&quot;,\&quot;age\&quot;:30}&quot;);
// 이메일 변경: 전체 파싱 후 재직렬화 필요

// After: Hash 방식
redisTemplate.opsForHash().put(&quot;user:123&quot;, &quot;name&quot;, &quot;John Doe&quot;);
redisTemplate.opsForHash().put(&quot;user:123&quot;, &quot;email&quot;, &quot;john@example.com&quot;);
redisTemplate.opsForHash().put(&quot;user:123&quot;, &quot;age&quot;, &quot;30&quot;);

// 이메일만 변경: 한 줄로 끝
redisTemplate.opsForHash().put(&quot;user:123&quot;, &quot;email&quot;, &quot;new@email.com&quot;);

// 특정 필드만 조회
String email = (String) redisTemplate.opsForHash().get(&quot;user:123&quot;, &quot;email&quot;);</code></pre>
<p>업데이트가 잦고 사용자가 많은 서비스에서 이 차이는 성능에 직접적인 영향을 준다.</p>
<h3 id="정리-3">정리</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>저장 구조</td>
<td>Key → {field1: value1, field2: value2, ...}</td>
</tr>
<tr>
<td>주요 활용</td>
<td>사용자 프로필, 상품 속성, 객체 데이터</td>
</tr>
<tr>
<td>핵심 강점</td>
<td>필드 단위 읽기/쓰기로 불필요한 파싱 제거</td>
</tr>
</tbody></table>
<hr>
<h2 id="6-sorted-set-점수-기반-자동-정렬">6. Sorted Set: 점수 기반 자동 정렬</h2>
<h3 id="개념-4">개념</h3>
<p>Sorted Set은 Set과 마찬가지로 중복을 허용하지 않지만, 각 값에 <strong>Score(점수)</strong> 를 함께 저장한다. 저장된 요소들은 이 Score를 기준으로 <strong>자동 정렬</strong>된다.</p>
<p>일반 Set으로 랭킹을 구현하려면 전체 데이터를 서버단으로 꺼내서 직접 정렬해야 한다. Sorted Set은 Redis 안에서 정렬이 이미 되어 있기 때문에, 범위 조회 명령어 하나로 Top N 결과를 바로 가져올 수 있다.</p>
<h3 id="예제-2">예제</h3>
<pre><code class="language-java">// 점수와 함께 저장
redisTemplate.opsForZSet().add(&quot;game:ranking&quot;, &quot;Player1&quot;, 1500.0);
redisTemplate.opsForZSet().add(&quot;game:ranking&quot;, &quot;Player2&quot;, 2000.0);
redisTemplate.opsForZSet().add(&quot;game:ranking&quot;, &quot;Player3&quot;, 1800.0);

// Top 2 조회 (점수 높은 순)
Set&lt;String&gt; top2 = redisTemplate.opsForZSet().reverseRange(&quot;game:ranking&quot;, 0, 1);
// 결과: [Player2, Player3]

// 점수 업데이트
redisTemplate.opsForZSet().incrementScore(&quot;game:ranking&quot;, &quot;Player1&quot;, 100.0);
// Player1: 1500.0 → 1600.0

// 특정 점수 범위 조회
Set&lt;String&gt; inRange = redisTemplate.opsForZSet().rangeByScore(&quot;game:ranking&quot;, 1600.0, 1900.0);
// 결과: [Player1, Player3]</code></pre>
<h3 id="정리-4">정리</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>저장 구조</td>
<td>Key → {Value: Score, ...} (Score 기준 자동 정렬)</td>
</tr>
<tr>
<td>주요 활용</td>
<td>게임 리더보드, 실시간 랭킹, 시간 기반 데이터</td>
</tr>
<tr>
<td>핵심 강점</td>
<td>Redis 내 자동 정렬 + 범위/순위 조회</td>
</tr>
</tbody></table>
<hr>
<h2 id="7-5가지-타입-비교">7. 5가지 타입 비교</h2>
<table>
<thead>
<tr>
<th>타입</th>
<th>저장 구조</th>
<th>핵심 강점</th>
<th>대표 활용</th>
</tr>
</thead>
<tbody><tr>
<td>String</td>
<td>Key → Value</td>
<td>Atomic 연산, 동시성 안전</td>
<td>캐싱, 카운터, 세션 상태</td>
</tr>
<tr>
<td>List</td>
<td>Key → [순서 있는 목록]</td>
<td>양방향 입출력 (Queue/Stack)</td>
<td>작업 대기열, 메시지 기록</td>
</tr>
<tr>
<td>Set</td>
<td>Key → {중복 없는 집합}</td>
<td>분산 중앙 집합 + 집합 연산</td>
<td>고유 사용자, 태그, 참여자</td>
</tr>
<tr>
<td>Hash</td>
<td>Key → {필드: 값}</td>
<td>필드 단위 읽기/쓰기</td>
<td>객체 데이터, 사용자 프로필</td>
</tr>
<tr>
<td>Sorted Set</td>
<td>Key → {값: 점수}</td>
<td>Score 기반 자동 정렬</td>
<td>랭킹, 실시간 순위</td>
</tr>
</tbody></table>
<hr>
<h2 id="마치며">마치며</h2>
<p>Redis가 다양한 데이터 타입을 지원하는 이유는 단순히 &quot;편의성&quot; 때문이 아니다. 각 타입은 <strong>서버단에서 처리하면 속도 저하와 동시성 문제가 생기는 작업을 Redis 안에서 직접 처리</strong>하기 위해 설계된 도구다.</p>
<p>데이터 구조에 맞는 타입을 선택한다면, 서버 코드는 단순해지고 Redis의 속도 이점은 극대화된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Huge Traffic Handling] HTTP Session과 Session Clustering]]></title>
            <link>https://velog.io/@furaha_dev/Huge-Traffic-Handling-HTTP-Session%EA%B3%BC-Session-Clustering</link>
            <guid>https://velog.io/@furaha_dev/Huge-Traffic-Handling-HTTP-Session%EA%B3%BC-Session-Clustering</guid>
            <pubDate>Thu, 16 Apr 2026 16:14:28 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>지금까지 Spring MVC 흐름, JPA, 트랜잭션을 공부하면서 서버가 요청을 처리하는 방식을 살펴봤다. 그런데 한 가지 의문이 생긴다. 우리가 매일 쓰는 쇼핑몰에서는 로그인이 유지되고 장바구니도 사라지지 않는다. HTTP는 Stateless라고 했는데, 이게 어떻게 가능한 걸까?</p>
<p>이번 글에서는 다음 질문들을 따라가며 그 답을 찾아본다.</p>
<ul>
<li>HTTP가 Stateless라면, 서버는 어떻게 나를 기억하는가?</li>
<li>서버가 여러 대일 때 세션은 어떻게 공유되는가?</li>
<li>Redis는 왜 세션 저장소로 적합한가?</li>
</ul>
<hr>
<h2 id="1-http는-기억력이-없다">1. HTTP는 기억력이 없다</h2>
<p>HTTP는 각 요청을 완전히 독립적으로 처리한다. 첫 번째 요청이 끝나면 서버는 그 요청이 있었다는 사실 자체를 잊어버린다.</p>
<pre><code>요청 1: &quot;로그인 해줘&quot; → 서버: &quot;OK&quot;
요청 2: &quot;내 장바구니 보여줘&quot; → 서버: &quot;누구세요?&quot;</code></pre><p>이것이 Stateless의 실체다. 매 요청이 새로운 낯선 사람처럼 취급된다.</p>
<p>장점은 명확하다. 서버가 이전 상태를 전혀 기억하지 않아도 되니 가볍고 빠르다. 하지만 우리가 원하는 서비스를 만들려면 상태 유지가 필요하다.</p>
<hr>
<h2 id="2-세션과-쿠키-기억력을-만드는-방법">2. 세션과 쿠키: 기억력을 만드는 방법</h2>
<p>이 문제를 해결하기 위해 세션과 쿠키가 등장했다. 둘은 역할이 다르다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>저장 위치</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>쿠키</td>
<td>클라이언트(브라우저)</td>
<td>세션 ID 보관</td>
</tr>
<tr>
<td>세션</td>
<td>서버</td>
<td>실제 사용자 데이터 보관</td>
</tr>
</tbody></table>
<p>작동 방식은 다음과 같다.</p>
<pre><code>1. 사용자가 로그인
         ↓
2. 서버가 세션 생성 → 고유한 세션 ID 발급
         ↓
3. 세션 ID를 쿠키에 담아 클라이언트에 전달
   Set-Cookie: JSESSIONID=abc123; HttpOnly
         ↓
4. 이후 요청마다 브라우저가 쿠키를 헤더에 담아 자동 전송
   Cookie: JSESSIONID=abc123
         ↓
5. 서버가 세션 ID로 사용자 데이터 조회</code></pre><p>세션 자체가 쿠키에 담기는 게 아니다. 세션을 찾는 열쇠(ID)만 쿠키에 담긴다. 실제 데이터는 서버에 있다.</p>
<h3 id="spring에서의-세션-사용">Spring에서의 세션 사용</h3>
<p>Spring에서는 <code>HttpSession</code>을 통해 세션을 다룬다.</p>
<pre><code class="language-java">@PostMapping(&quot;/login&quot;)
public ApiResponse&lt;LoginResponse&gt; login(HttpSession httpSession,
        @RequestBody LoginRequest request) {
    LoginResponse loginResponse = authService.login(request);

    // 세션에 사용자 정보 저장
    httpSession.setAttribute(&quot;userId&quot;, loginResponse.getUserId());
    httpSession.setAttribute(&quot;email&quot;, loginResponse.getEmail());

    return ApiResponse.success(loginResponse);
}

@GetMapping(&quot;/status&quot;)
public ApiResponse&lt;LoginResponse&gt; checkStatus(HttpSession httpSession) {
    Long userId = (Long) httpSession.getAttribute(&quot;userId&quot;);
    String email = (String) httpSession.getAttribute(&quot;email&quot;);

    if (userId == null &amp;&amp; email == null) {
        throw new DomainException(DomainExceptionCode.NOT_FOUND_USER);
    }

    return ApiResponse.success(authService.getLoginInfo(userId, email));
}

@GetMapping(&quot;/logout&quot;)
public ApiResponse&lt;Void&gt; logout(HttpSession httpSession) {
    httpSession.invalidate(); // 세션 무효화
    return ApiResponse.success();
}</code></pre>
<p>로그인 후 브라우저 개발자 도구의 Application → Cookies 탭을 보면 <code>JSESSIONID</code> 쿠키가 생성된 것을 확인할 수 있다. 로그아웃 후에는 이 쿠키가 삭제된다.</p>
<hr>
<h2 id="3-단일-서버-세션의-한계">3. 단일 서버 세션의 한계</h2>
<p>단일 서버에서는 세션이 잘 동작한다. 하지만 실제 서비스는 서버가 한 대가 아니다.</p>
<p>트래픽이 늘어나면 서버를 여러 대 두고 로드 밸런서가 요청을 분산시킨다. 이때 문제가 생긴다.</p>
<pre><code>사용자 → 로드밸런서 → 서버A (로그인, 세션 생성)
사용자 → 로드밸런서 → 서버B (세션 없음 → 로그인 풀림)</code></pre><p>서버A에 저장된 세션을 서버B는 모른다. 사용자는 매번 다른 서버에 붙을 때마다 다시 로그인해야 한다. 이렇게 되면 로드 밸런서를 두는 의미가 없어진다.</p>
<hr>
<h2 id="4-세션-클러스터링-세-가지-해결책">4. 세션 클러스터링: 세 가지 해결책</h2>
<p>이 문제를 해결하기 위한 방법이 세션 클러스터링이다. 크게 세 가지 방식이 있다.</p>
<h3 id="sticky-session">Sticky Session</h3>
<p>로드 밸런서가 특정 사용자의 요청을 항상 같은 서버로 보내는 방식이다.</p>
<pre><code>사용자A → 항상 서버A
사용자B → 항상 서버B</code></pre><p>구현이 간단하지만 치명적인 단점이 있다. 서버A에 장애가 생기면 서버A에 묶인 모든 사용자의 세션이 사라진다. 또한 특정 서버에 요청이 몰릴 수 있어 로드 밸런싱의 효과가 떨어진다.</p>
<h3 id="session-replication">Session Replication</h3>
<p>모든 서버가 동일한 세션 데이터를 복제해서 가지고 있는 방식이다.</p>
<pre><code>서버A: {userId: 1, email: &quot;test@test.com&quot;}
서버B: {userId: 1, email: &quot;test@test.com&quot;}  ← 복제
서버C: {userId: 1, email: &quot;test@test.com&quot;}  ← 복제</code></pre><p>어느 서버로 요청이 가도 세션이 유지된다. 하지만 서버가 늘어날수록 복제 비용이 커지고 네트워크 부하가 증가한다.</p>
<h3 id="centralized-session-store">Centralized Session Store</h3>
<p>세션 데이터를 별도의 중앙 저장소에 보관하고, 모든 서버가 이 저장소를 바라보는 방식이다.</p>
<pre><code>서버A ─┐
서버B ─┼─→ Redis (세션 저장소)
서버C ─┘</code></pre><p>어떤 서버로 요청이 가도 Redis에서 세션을 조회하기 때문에 항상 일관된 데이터를 사용할 수 있다. 현업에서 가장 많이 쓰이는 방식이다.</p>
<h3 id="비교-요약">비교 요약</h3>
<table>
<thead>
<tr>
<th>방식</th>
<th>핵심 단점</th>
<th>적합한 환경</th>
</tr>
</thead>
<tbody><tr>
<td>Sticky Session</td>
<td>장애 시 세션 유실, 부하 불균형</td>
<td>소규모 시스템</td>
</tr>
<tr>
<td>Session Replication</td>
<td>복제 비용, 서버 증가 시 부하</td>
<td>고가용성 요구 환경</td>
</tr>
<tr>
<td>Centralized Store</td>
<td>중앙 저장소 장애 시 전체 영향</td>
<td>대규모 서비스</td>
</tr>
</tbody></table>
<hr>
<h2 id="5-redis를-세션-저장소로-쓰는-이유">5. Redis를 세션 저장소로 쓰는 이유</h2>
<p>Centralized Session Store 방식에서 Redis가 표준처럼 쓰이는 이유가 있다.</p>
<p>세션은 매 요청마다 조회된다. 사용자가 페이지를 이동할 때마다, API를 호출할 때마다 서버는 세션을 확인한다. 이 조회가 느리면 전체 응답 속도가 느려진다.</p>
<p>Redis는 In-Memory 저장소다. 데이터를 디스크가 아닌 메모리(RAM)에 저장하기 때문에 조회 속도가 극도로 빠르다.</p>
<pre><code>일반 DB (디스크): 수 ms ~ 수십 ms
Redis (메모리): 수십 μs</code></pre><h3 id="spring에서-redis-세션-연동">Spring에서 Redis 세션 연동</h3>
<p><code>build.gradle</code>에 의존성을 추가한다.</p>
<pre><code class="language-groovy">implementation &#39;org.springframework.boot:spring-boot-starter-data-redis&#39;
implementation &#39;org.springframework.session:spring-session-data-redis&#39;</code></pre>
<p><code>application.yml</code>에 Redis 설정을 추가한다.</p>
<pre><code class="language-yaml">spring:
  data:
    redis:
      host: localhost
      port: 6379
  session:
    store-type: redis</code></pre>
<p>이게 전부다. 기존 코드(<code>HttpSession</code>)는 한 줄도 바꾸지 않아도 된다. Spring Session이 내부적으로 세션 저장소를 서버 메모리에서 Redis로 교체해준다.</p>
<h3 id="동작-확인">동작 확인</h3>
<p>Redis CLI에서 실제로 세션이 저장됐는지 확인할 수 있다.</p>
<pre><code class="language-bash">docker exec -it redis-server redis-cli
&gt; keys spring:session*
1) &quot;spring:session:sessions:b1ccba81-a7f1-4601-9626-df84218037ca&quot;

&gt; hgetall spring:session:sessions:b1ccba81-...
sessionAttr:userId → 1
sessionAttr:email  → test@test.com</code></pre>
<p>서버 메모리가 아닌 Redis에 세션 데이터가 저장된 것을 확인할 수 있다.</p>
<p>Redis 연동 후 가장 눈에 띄는 차이는 서버를 재시작해도 세션이 유지된다는 점이다. 기존에는 서버 메모리에 세션이 있었기 때문에 재시작하면 세션이 사라졌다. Redis에 저장된 세션은 서버와 독립적으로 존재한다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>HTTP의 Stateless 특성을 세션과 쿠키로 보완하고, 다중 서버 환경에서 Redis를 이용해 세션을 중앙에서 관리하는 흐름을 살펴봤다. 핵심은 세션 데이터를 서버 바깥으로 꺼내는 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring AI] Agentic Workflow — AI가 스스로 생각하고 행동하는 방법]]></title>
            <link>https://velog.io/@furaha_dev/SpringAI-Agentic-Workflow-AI%EA%B0%80-%EC%8A%A4%EC%8A%A4%EB%A1%9C-%EC%83%9D%EA%B0%81%ED%95%98%EA%B3%A0-%ED%96%89%EB%8F%99%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@furaha_dev/SpringAI-Agentic-Workflow-AI%EA%B0%80-%EC%8A%A4%EC%8A%A4%EB%A1%9C-%EC%83%9D%EA%B0%81%ED%95%98%EA%B3%A0-%ED%96%89%EB%8F%99%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Sat, 11 Apr 2026 15:19:19 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서는 Spring AI의 Advisor 패턴과 Function Calling을 통해 LLM이 외부 도구를 활용하는 방법을 살펴봤다. 그런데 도구를 쓸 수 있다고 해서 에이전트가 되는 건 아니다.</p>
<p>이번 글에서는 다음 질문들을 중심으로 Agentic Workflow를 다룬다.</p>
<ul>
<li>ChatBot과 Agent는 무엇이 다른가?</li>
<li>LLM이 스스로 판단하고 반복 실행하려면 어떤 구조가 필요한가?</li>
<li>복잡한 문제를 더 잘 풀기 위한 패턴에는 어떤 것들이 있는가?</li>
</ul>
<hr>
<h2 id="1-chatbot-vs-agent">1. ChatBot vs Agent</h2>
<p>일반 ChatBot은 입력에 대해 즉각적인 답변을 생성한다. 질문 하나에 답변 하나, 그게 전부다.</p>
<p>반면 Agent는 다르다. 목표가 주어지면 스스로 계획을 세우고, 도구를 선택해 실행하며, 결과를 관찰한 뒤 다음 행동을 결정한다. 이 과정을 목표가 달성될 때까지 반복한다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>ChatBot</th>
<th>Agent</th>
</tr>
</thead>
<tbody><tr>
<td>작동 방식</td>
<td>입력 → 즉각 출력</td>
<td>목표 → 계획 → 실행 → 관찰 반복</td>
</tr>
<tr>
<td>자율성</td>
<td>사용자 가이드 의존</td>
<td>스스로 다음 단계 결정</td>
</tr>
<tr>
<td>도구 활용</td>
<td>내부 지식만 사용</td>
<td>외부 API, DB 등 자유 활용</td>
</tr>
<tr>
<td>적합한 상황</td>
<td>단발성 질문</td>
<td>다단계 복잡한 워크플로우</td>
</tr>
</tbody></table>
<p>핵심 차이는 <strong>추론 루프(Reasoning Loop)</strong> 의 유무다. 에이전트는 답을 낼 때까지 생각하고 행동하는 과정을 반복한다.</p>
<hr>
<h2 id="2-react-패턴--생각하고-행동하고-관찰한다">2. ReAct 패턴 — 생각하고, 행동하고, 관찰한다</h2>
<p>ReAct(Reasoning + Acting)는 에이전트의 가장 기본적인 사고 방식이다. 네 단계로 이루어진다.</p>
<pre><code>[Thought]     현재 상황을 분석하고 필요한 행동을 결정
[Action]      도구를 선택하고 실행
[Observation] 실행 결과를 확인
[Answer]      모든 정보가 모이면 최종 답변 생성</code></pre><p>예를 들어 &quot;서울과 도쿄 날씨를 비교해서 옷차림 추천해줘&quot;라는 요청이 들어오면 이렇게 흘러간다.</p>
<pre><code>[Thought 1]     서울 날씨 데이터가 필요하다
[Action 1]      getWeather(&quot;Seoul&quot;) 호출
[Observation 1] 서울: 12도, 흐림

[Thought 2]     도쿄 날씨도 필요하다
[Action 2]      getWeather(&quot;Tokyo&quot;) 호출
[Observation 2] 도쿄: 22도, 맑음

[Thought 3]     두 도시 날씨를 알았으니 옷차림을 추천할 수 있다
[Answer]        서울은 코트, 도쿄는 가디건 추천</code></pre><p>이전 Observation이 다음 Thought의 재료가 된다. 이 연결고리가 루프를 만든다.</p>
<h3 id="에이전트가-도구를-선택하는-방법">에이전트가 도구를 선택하는 방법</h3>
<p>에이전트는 자바 코드를 직접 읽지 못한다. <code>@Tool</code>의 <code>description</code>을 읽고 판단한다.</p>
<pre><code class="language-java">@Tool(description = &quot;특정 도시의 현재 날씨를 조회합니다.&quot;)
public WeatherResponse getWeather(String city) { ... }</code></pre>
<p>역할 분담은 이렇다.</p>
<pre><code>LLM    → description을 읽고 어떤 도구를 쓸지 판단
Spring → LLM의 판단을 받아 실제 메서드를 실행하고 결과를 반환</code></pre><p>description이 구체적일수록 LLM이 올바른 도구를 선택할 확률이 높아진다.</p>
<h3 id="spring-ai-코드">Spring AI 코드</h3>
<pre><code class="language-java">public String solveWithAgent(String goal) {
    return chatClientBuilder.build().prompt()
            .system(&quot;Thought → Action → Observation을 반복하여 문제를 해결하세요.&quot;)
            .tools(&quot;getWeather&quot;, &quot;calculator&quot;, &quot;getCurrentTime&quot;)
            .user(goal)
            .call()
            .content();
}</code></pre>
<hr>
<h2 id="3-plan-and-execute--계획-먼저-실행-나중">3. Plan-and-Execute — 계획 먼저, 실행 나중</h2>
<p>ReAct는 단순한 요청에 적합하다. 하지만 요청이 복잡해지면 중간에 길을 잃을 수 있다.</p>
<blockquote>
<p>&quot;서울과 부산 날씨 비교하고, 평균 기온 계산하고, 여행지 추천까지 해줘&quot;</p>
</blockquote>
<p>이런 요청을 계획 없이 바로 실행하면 순서가 꼬이거나 일부 작업을 빠뜨릴 수 있다. Plan-and-Execute는 전체 로드맵을 먼저 작성한 뒤 실행한다.</p>
<pre><code>Planner  → 전체 계획 수립 (도구 없음, 생각만)
Executor → 계획을 순서대로 실행 (도구 사용)</code></pre><p>Planner에 <code>.tools()</code>가 없는 이유가 여기 있다. 계획 단계에서는 무엇을 해야 할지 판단만 하면 되기 때문이다.</p>
<pre><code class="language-java">// 1단계: 계획 수립 (도구 없음)
String plan = chatClientBuilder.build().prompt()
        .system(&quot;복잡한 목표를 위한 단계별 계획을 수립하는 전략가입니다.&quot;)
        .user(goal)
        .call()
        .content();

// 2단계: 계획 실행 (도구 사용)
return chatClientBuilder.build().prompt()
        .system(&quot;주어진 계획을 정확히 이행하는 실행 전문가입니다.&quot;)
        .tools(&quot;getWeather&quot;, &quot;calculator&quot;, &quot;getCurrentTime&quot;)
        .user(&quot;수립된 계획: &quot; + plan + &quot;\n원래 목표: &quot; + goal)
        .call()
        .content();</code></pre>
<hr>
<h2 id="4-self-reflection--실행-후-스스로-검증한다">4. Self-Reflection — 실행 후 스스로 검증한다</h2>
<p>LLM은 틀린 답을 자신 있게 내놓는 환각(Hallucination) 문제가 있다. Self-Reflection은 답변을 생성한 뒤 스스로 검토하고 수정하는 과정을 반복한다.</p>
<pre><code>답변 생성 → 검증 → APPROVED? → 종료
                 → 미흡?    → 피드백 반영 후 재시도</code></pre><p>검토자도 LLM이다. LLM이 자신의 답변을 스스로 비판하는 구조다.</p>
<pre><code class="language-java">for (int i = 0; i &lt; maxIterations; i++) {
    // 1. 답변 생성
    currentResponse = client.prompt()
            .system(&quot;도구를 사용하여 문제를 해결하세요. 이전 피드백을 반영하세요.&quot;)
            .user(&quot;문제: &quot; + problem + &quot;\n이전 피드백: &quot; + feedback)
            .call().content();

    // 2. 검증
    feedback = client.prompt()
            .system(&quot;답변의 결함을 검토하세요. 완벽하면 &#39;APPROVED&#39;라고 답하세요.&quot;)
            .user(&quot;검토 대상: &quot; + currentResponse)
            .call().content();

    if (feedback.contains(&quot;APPROVED&quot;)) break;
}</code></pre>
<p>한 번에 완벽한 답을 요구하는 게 아니라, 틀려도 괜찮으니 스스로 고쳐나가는 구조다.</p>
<hr>
<h2 id="5-multi-agent--전문화된-역할-분리">5. Multi-Agent — 전문화된 역할 분리</h2>
<p>하나의 에이전트가 모든 걸 처리하면 각 역할이 어중간해진다. Multi-Agent는 전문화된 페르소나를 가진 여러 에이전트가 협력한다.</p>
<pre><code>Researcher → 데이터 수집 전문
Analyst    → 인사이트 도출 전문
Writer     → 보고서 작성 전문</code></pre><p>각 에이전트의 출력이 다음 에이전트의 입력이 된다.</p>
<p>더 나아가 Dynamic Orchestration 패턴은 문제 유형에 따라 필요한 에이전트만 소집한다.</p>
<pre><code class="language-java">List&lt;AgentRole&gt; pipeline = switch (problemType) {
    case &quot;CALCULATION&quot;     -&gt; List.of(AgentRole.ANALYST, AgentRole.WRITER);
    case &quot;DATA_COLLECTION&quot; -&gt; List.of(AgentRole.RESEARCHER, AgentRole.WRITER);
    default                -&gt; List.of(AgentRole.RESEARCHER, AgentRole.ANALYST, AgentRole.WRITER);
};</code></pre>
<p>LLM 호출은 곧 비용과 시간이다. 불필요한 에이전트를 호출하지 않는 것만으로도 효율이 크게 달라진다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>네 가지 패턴 모두 공통된 목표를 가진다. LLM의 환각을 줄이고 더 나은 답변 품질을 얻는 것이다.</p>
<pre><code>ReAct            → Thought → Action → Observation 반복
Plan-and-Execute → 계획 먼저, 실행 나중 (복잡한 요청에 유리)
Self-Reflection  → 실행 후 스스로 검증 (환각 최소화)
Multi-Agent      → 전문화된 역할 분리 + 동적 조합 (품질 + 비용 최적화)</code></pre><p>단순히 LLM을 호출하는 것과 에이전트를 설계하는 것은 다른 차원의 문제다. 어떤 패턴을 선택하느냐는 결국 풀려는 문제의 복잡도에 달려 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring AI] Spring AI Advisor 패턴과 Function Calling]]></title>
            <link>https://velog.io/@furaha_dev/SpringAI-Spring-AI-Advisor-%ED%8C%A8%ED%84%B4%EA%B3%BC-Function-Calling</link>
            <guid>https://velog.io/@furaha_dev/SpringAI-Spring-AI-Advisor-%ED%8C%A8%ED%84%B4%EA%B3%BC-Function-Calling</guid>
            <pubDate>Fri, 10 Apr 2026 06:26:45 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서는 RAG(Retrieval-Augmented Generation)를 통해 LLM이 외부 문서를 참조해서 답변하는 방법을 배웠다. 이번 글에서는 한 단계 더 나아간다.</p>
<p>이런 질문들을 생각해보자.</p>
<ul>
<li>대화 기록 저장, 욕설 필터링 같은 공통 로직을 매번 Service에 직접 넣어야 할까?</li>
<li>LLM은 &quot;지금 서울 날씨&quot;처럼 실시간 정보를 어떻게 가져올 수 있을까?</li>
<li>LLM이 직접 외부 API를 호출할 수 있을까?</li>
</ul>
<p>이 두 가지를 해결하는 것이 <strong>Advisor 패턴</strong>과 <strong>Function Calling</strong>이다.</p>
<hr>
<h2 id="1-advisor-패턴">1. Advisor 패턴</h2>
<h3 id="왜-필요한가">왜 필요한가</h3>
<p>서비스가 10개 있고, 모든 서비스에 로깅 코드를 직접 넣었다고 가정해보자. 로깅 형식을 바꿔야 한다면 10개 서비스를 전부 열어서 수정해야 한다. 한 곳이라도 빠뜨리면 로그 형식이 제각각이 된다.</p>
<p><code>@Transactional</code>이 트랜잭션 관리 코드를 Service에서 분리했던 것처럼, Advisor는 <strong>LLM 호출과 관련된 공통 관심사(대화 기록, 필터링, 로깅 등)를 ChatClient 외부로 분리</strong>한다.</p>
<pre><code class="language-java">// ❌ Advisor 없이 — 모든 관심사가 Service에 섞여 있다
public String chat(String message) {
    List&lt;Message&gt; history = chatMemory.get(conversationId);       // 관심사 1
    List&lt;Document&gt; docs = vectorStore.similaritySearch(message);  // 관심사 2
    String context = docs.stream()...collect(Collectors.joining());
    String fullPrompt = &quot;Context: &quot; + context + &quot;\nHistory: &quot; + history + &quot;\nQuestion: &quot; + message;
    String response = chatClient.call(fullPrompt);
    chatMemory.add(conversationId, message, response);            // 관심사 1 마무리
    return response;
}

// ✅ Advisor 사용 — Service는 오직 &quot;질문하고 답 받기&quot;에만 집중
public String chat(String message) {
    return chatClient.prompt()
        .user(message)
        .call()
        .content();
}</code></pre>
<h3 id="동작-원리--양파-껍질-구조">동작 원리 — 양파 껍질 구조</h3>
<p>Advisor는 LLM 호출 과정을 <strong>양파 껍질처럼</strong> 감싼다. 요청이 나갈 때는 바깥에서 안으로(before), 응답이 돌아올 때는 안에서 밖으로(after) 순서로 실행된다.</p>
<pre><code>사용자 질문
    ↓
[Advisor1] before()  ← 등록 순서대로
    ↓
[Advisor2] before()
    ↓
[Advisor3] before()
    ↓
       LLM
    ↓
[Advisor3] after()   ← 역순으로
    ↓
[Advisor2] after()
    ↓
[Advisor1] after()
    ↓
최종 응답</code></pre><p>들어갈 때 순서의 반대로 나온다. <code>@Transactional</code> Proxy가 메서드를 감쌌던 것과 같은 구조다.</p>
<h3 id="context--advisor-간-공유-저장소">Context — Advisor 간 공유 저장소</h3>
<p>Advisor들이 서로 데이터를 주고받아야 할 때 사용하는 공유 바구니다. 단순한 <code>Map&lt;String, Object&gt;</code> 형태로, 체인을 따라 흘러다닌다.</p>
<pre><code class="language-java">// RAG Advisor가 찾은 문서를 Context에 담아두면
request.mutate()
    .context(&quot;retrieved_docs&quot;, docs)
    .build();

// 다음 로깅 Advisor가 꺼내서 기록할 수 있다
List&lt;Document&gt; docs = (List&lt;Document&gt;) response.context().get(&quot;retrieved_docs&quot;);</code></pre>
<h3 id="before와-after에서-할-수-있는-일">before()와 after()에서 할 수 있는 일</h3>
<table>
<thead>
<tr>
<th>단계</th>
<th>활용 예시</th>
</tr>
</thead>
<tbody><tr>
<td><code>before()</code></td>
<td>이전 대화 기록을 프롬프트에 추가, 벡터 DB 검색 결과 삽입, 욕설 입력 차단</td>
</tr>
<tr>
<td><code>after()</code></td>
<td>대화 기록 DB 저장, 토큰 사용량 로깅, 부적절한 응답 교체</td>
</tr>
</tbody></table>
<h3 id="내장-advisor-3가지">내장 Advisor 3가지</h3>
<p><strong>PromptChatMemoryAdvisor</strong></p>
<p>대화 기록을 하나의 텍스트 덩어리로 이어 붙여서 프롬프트에 포함시킨다. 구현이 단순하지만, 이미지 같은 미디어 데이터를 다루기 어렵다.</p>
<p><strong>MessageChatMemoryAdvisor</strong></p>
<p>대화 기록을 Message 객체 단위로 구조화해서 전달한다. 각 메시지의 역할(USER / ASSISTANT / SYSTEM)과 메타데이터가 보존된다. 멀티모달 대응이 가능하고, Prompt Injection 방어에도 유리하다.</p>
<pre><code class="language-java">// 두 Advisor의 차이
// PromptChatMemoryAdvisor — 텍스트를 이어 붙임
&quot;User: 안녕 / Assistant: 안녕하세요 / User: 내 이름이 뭐야?&quot;

// MessageChatMemoryAdvisor — 메시지 객체를 구조화해서 전달
[UserMessage(&quot;안녕&quot;), AssistantMessage(&quot;안녕하세요&quot;), UserMessage(&quot;내 이름이 뭐야?&quot;)]</code></pre>
<p><strong>SafeGuardAdvisor</strong></p>
<p>입력과 출력을 두 번 검사한다. <code>before()</code>에서 유해한 질문을 LLM에 전달하기 전에 차단하고, <code>after()</code>에서 부적절한 응답을 안전한 문구로 교체한다.</p>
<pre><code>유해한 질문 → [SafeGuardAdvisor before()] → 차단 → LLM 호출 안 함
정상 질문   → [SafeGuardAdvisor before()] → 통과 → LLM → [SafeGuardAdvisor after()] → 검증 후 전달</code></pre><h3 id="커스텀-advisor-구현">커스텀 Advisor 구현</h3>
<p><code>BaseAdvisor</code> 인터페이스를 구현하면 된다. <code>before()</code>와 <code>after()</code> 두 메서드만 작성하면 Spring AI가 나머지 흐름을 자동으로 처리한다.</p>
<pre><code class="language-java">public class ChatMemoryAdvisor implements BaseAdvisor {

    private final Map&lt;String, List&lt;Message&gt;&gt; conversationStore = new ConcurrentHashMap&lt;&gt;();
    private final String conversationId;
    private final int maxMessages;

    @Override
    public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
        // 1. 이전 대화 기록 조회
        List&lt;Message&gt; history = conversationStore.getOrDefault(conversationId, new CopyOnWriteArrayList&lt;&gt;());

        // 2. 이전 기록 + 현재 질문을 합쳐서 프롬프트 재구성
        List&lt;Message&gt; fullMessages = new ArrayList&lt;&gt;(history);
        fullMessages.addAll(request.prompt().getInstructions());

        // 3. 현재 질문을 Context에 저장 (after에서 꺼내 쓰기 위해)
        String userText = request.prompt().getInstructions().stream()
            .filter(m -&gt; m.getMessageType() == MessageType.USER)
            .map(Message::getText)
            .findFirst().orElse(&quot;&quot;);

        return request.mutate()
            .prompt(new Prompt(fullMessages))
            .context(&quot;user_text&quot;, userText)
            .build();
    }

    @Override
    public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
        List&lt;Message&gt; history = conversationStore.computeIfAbsent(conversationId, k -&gt; new CopyOnWriteArrayList&lt;&gt;());

        // 1. before에서 저장한 질문 복구 후 저장
        Optional.ofNullable(response.context().get(&quot;user_text&quot;))
            .map(Object::toString)
            .filter(text -&gt; !text.isBlank())
            .ifPresent(text -&gt; history.add(new UserMessage(text)));

        // 2. LLM 응답 저장
        if (response.chatResponse() != null &amp;&amp; response.chatResponse().getResult() != null) {
            var output = response.chatResponse().getResult().getOutput();
            history.add(new AssistantMessage(output.getText(), output.getMetadata()));
        }

        // 3. 용량 초과 시 오래된 메시지 제거 (FIFO)
        while (history.size() &gt; maxMessages &amp;&amp; !history.isEmpty()) {
            history.remove(0);
        }

        return response;
    }
}</code></pre>
<p><code>before()</code>에서 현재 질문을 Context에 저장하는 이유가 있다. <code>after()</code>가 실행될 시점에는 원본 요청 객체에 접근하기 어렵기 때문에, 미리 Context라는 공유 바구니에 담아두는 것이다.</p>
<hr>
<h2 id="2-function-calling">2. Function Calling</h2>
<h3 id="llm의-한계">LLM의 한계</h3>
<p>LLM은 학습된 데이터를 바탕으로 텍스트를 생성한다. 그래서 다음 세 가지를 혼자서는 할 수 없다.</p>
<ul>
<li><strong>실시간 정보</strong> — 날씨, 주가, 뉴스 (학습 데이터 커트라인 이후)</li>
<li><strong>사내 데이터</strong> — 회사 내부 DB는 학습된 적이 없다</li>
<li><strong>실제 행동</strong> — 이메일 발송, 결제 처리, DB 저장</li>
</ul>
<p>Function Calling은 이 한계를 뚫는다. LLM이 외부 도구(API, DB, 계산기 등)를 <strong>직접 호출</strong>할 수 있게 해주는 기술이다.</p>
<h3 id="중요한-포인트--llm은-판단만-한다">중요한 포인트 — LLM은 판단만 한다</h3>
<p>LLM이 직접 코드를 실행하는 것이 아니다. <strong>어떤 함수를 어떤 파라미터로 실행해야 할지 판단</strong>하고, 실제 실행은 Spring(개발자 서버)이 담당한다.</p>
<pre><code>사용자: &quot;서울 날씨 알려줘&quot;
    ↓
LLM: &quot;getWeather(&quot;서울&quot;)을 호출해야겠다&quot; (판단만)
    ↓
Spring: 실제 함수 실행
    ↓
Spring: 결과값을 다시 LLM에게 전달
    ↓
LLM: &quot;서울은 현재 15도이고 맑습니다&quot; (자연어로 변환)
    ↓
사용자</code></pre><table>
<thead>
<tr>
<th>비교</th>
<th>일반 API 호출</th>
<th>Function Calling</th>
</tr>
</thead>
<tbody><tr>
<td>호출 주체</td>
<td>개발자가 <code>if-else</code>로 명시</td>
<td>LLM이 의도를 보고 자동 판단</td>
</tr>
<tr>
<td>유연성</td>
<td>정해진 키워드에서만 작동</td>
<td>다양한 표현을 문맥으로 이해</td>
</tr>
<tr>
<td>복잡한 의도</td>
<td>처리 어려움</td>
<td>&quot;비 오면 우산 챙길지 알려줘&quot; 같은 복합 질문 처리 가능</td>
</tr>
</tbody></table>
<h3 id="spring-ai-구현">Spring AI 구현</h3>
<p><strong>1단계 — @Tool로 함수 선언</strong></p>
<p>일반 Java 메서드에 <code>@Tool</code>을 붙이면 LLM이 쓸 수 있는 도구가 된다. <code>description</code>이 LLM의 사용 설명서 역할을 하므로 구체적으로 작성해야 한다. 설명이 애매하면 LLM이 엉뚱한 함수를 호출하거나 필요한 함수를 찾지 못할 수 있다.</p>
<pre><code class="language-java">@Service
public class FunctionTools {

    @Tool(description = &quot;특정 도시의 현재 날씨 정보를 조회합니다&quot;)
    public WeatherResponse getWeather(WeatherRequest request) {
        // 실제 날씨 API 호출
        return WeatherResponse.builder()
            .city(request.getCity())
            .temperature(15)
            .condition(&quot;맑음&quot;)
            .build();
    }

    @Tool(description = &quot;두 숫자의 사칙연산을 수행합니다&quot;)
    public CalculatorResponse calculator(CalculatorRequest request) {
        double result = switch (request.getOperation()) {
            case &quot;add&quot;      -&gt; request.getA() + request.getB();
            case &quot;subtract&quot; -&gt; request.getA() - request.getB();
            case &quot;multiply&quot; -&gt; request.getA() * request.getB();
            case &quot;divide&quot;   -&gt; request.getA() / request.getB();
            default -&gt; throw new IllegalArgumentException(&quot;지원하지 않는 연산&quot;);
        };
        return CalculatorResponse.builder().result(result).build();
    }

    @Tool(description = &quot;현재 날짜와 시간을 반환합니다&quot;)
    public CurrentTimeResponse getCurrentTime() {
        LocalDateTime now = LocalDateTime.now();
        return CurrentTimeResponse.builder()
            .readableFormat(now.format(DateTimeFormatter.ofPattern(&quot;yyyy년 MM월 dd일 HH시 mm분&quot;)))
            .build();
    }
}</code></pre>
<p><strong>2단계 — .tools()로 ChatClient에 전달</strong></p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class FunctionCallingService {

    private final ChatClient.Builder clientBuilder;
    private final FunctionTools functionTools;

    public String chat(String userMessage) {
        return clientBuilder.build()
            .prompt()
            .user(userMessage)
            .tools(functionTools)  // 도구 상자 전달
            .call()
            .content();
    }
}</code></pre>
<p><strong>특정 도구만 활성화하기</strong></p>
<pre><code class="language-java">// 날씨 도구만 사용하도록 제한
chatClient.prompt()
    .toolNames(&quot;getWeather&quot;)
    .call();</code></pre>
<h3 id="dto-설계--llm이-파라미터를-추출하는-방식">DTO 설계 — LLM이 파라미터를 추출하는 방식</h3>
<p>LLM은 사용자의 자연어 문장에서 함수에 필요한 인자를 뽑아낸다. DTO 필드명과 타입을 명확하게 정의하는 것이 중요하다.</p>
<pre><code class="language-java">// 날씨 요청 DTO — LLM이 &quot;서울 날씨&quot;에서 city = &quot;서울&quot;을 추출
@Getter
@NoArgsConstructor
public class WeatherRequest {
    private String city;
}

// 계산기 요청 DTO — LLM이 &quot;253 곱하기 47&quot;에서 a=253, operation=&quot;multiply&quot;, b=47을 추출
@Getter
public class CalculatorRequest {
    private double a;
    private double b;
    private String operation;
}</code></pre>
<hr>
<h2 id="마치며">마치며</h2>
<p>Advisor 패턴은 LLM 호출의 공통 관심사를 분리해서 Service 코드를 깔끔하게 유지한다. Function Calling은 LLM이 판단하고 Spring이 실행하는 구조로, LLM의 실시간 정보 부재와 외부 시스템 고립 문제를 해결한다.</p>
<p>두 개념의 관계를 정리하면:</p>
<ul>
<li><strong>Advisor</strong> — LLM 호출 흐름을 감싸서 앞뒤를 제어</li>
<li><strong>Function Calling</strong> — LLM이 외부 세계와 상호작용할 수 있도록 손과 발을 달아줌</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring AI] Spring AI와 RAG — LLM에게 참고서를 쥐어주는 법]]></title>
            <link>https://velog.io/@furaha_dev/SpringAI-Spring-AI%EC%99%80-RAG-LLM%EC%97%90%EA%B2%8C-%EC%B0%B8%EA%B3%A0%EC%84%9C%EB%A5%BC-%EC%A5%90%EC%96%B4%EC%A3%BC%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@furaha_dev/SpringAI-Spring-AI%EC%99%80-RAG-LLM%EC%97%90%EA%B2%8C-%EC%B0%B8%EA%B3%A0%EC%84%9C%EB%A5%BC-%EC%A5%90%EC%96%B4%EC%A3%BC%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Fri, 10 Apr 2026 02:51:22 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서는 Vector DB와 임베딩을 다뤘다. 텍스트를 숫자 배열로 변환해서 의미 기반 유사도 검색을 할 수 있다는 것, 그리고 기존 LIKE 검색이 왜 의미 파악에 한계가 있는지를 살펴봤다.</p>
<p>이번 글에서는 그 Vector DB를 실제로 활용하는 패턴인 RAG를 다룬다. 글을 읽으면서 다음 세 가지 질문에 답할 수 있게 된다.</p>
<ul>
<li>LLM은 왜 혼자 두면 위험한가?</li>
<li>RAG는 내부에서 어떤 순서로 동작하는가?</li>
<li>Threshold와 프롬프트 설계가 왜 답변 품질을 결정하는가?</li>
</ul>
<hr>
<h2 id="1-llm의-한계--왜-rag가-필요한가">1. LLM의 한계 — 왜 RAG가 필요한가</h2>
<p>LLM은 학습 데이터를 기반으로 답변한다. 여기에 세 가지 태생적 약점이 있다.</p>
<p><strong>Knowledge Cutoff</strong>: 모델이 학습을 마친 시점 이후의 정보는 모른다.</p>
<p><strong>Private Data</strong>: 기업 내부 문서, 사내 규정처럼 공개되지 않은 데이터는 학습 데이터에 포함되지 않는다.</p>
<p><strong>Hallucination</strong>: 모르는 내용에 대해 아는 척 그럴듯하게 지어내는 현상이다.</p>
<pre><code>사용자: &quot;우리 회사 연차는 몇 일이에요?&quot;
LLM:    &quot;죄송하지만 귀사의 내부 정책은 알 수 없습니다.&quot;</code></pre><p>RAG는 이 문제를 정면으로 해결한다. LLM에게 오픈북 테스트를 허용하는 것이다.
머릿속 기억에만 의존하지 않고, 옆에 놓인 참고서를 보고 답하게 만든다.</p>
<hr>
<h2 id="2-rag-동작-흐름">2. RAG 동작 흐름</h2>
<p>RAG는 이름 그대로 세 단계로 나뉜다.</p>
<pre><code>Retrieval   → Vector DB에서 관련 청크 검색
Augmented   → 질문 + 청크를 합쳐서 프롬프트 강화
Generation  → LLM이 강화된 프롬프트로 답변 생성</code></pre><p>전체 흐름을 순서대로 따라가면 아래와 같다.</p>
<pre><code>① 사용자 질문 입력
② 질문을 벡터로 변환 (임베딩)
③ Vector DB에서 유사도 검색
④ 관련 청크 Top-K 추출
⑤ 질문 + 청크를 합쳐 프롬프트 구성
⑥ LLM이 프롬프트 기반으로 답변 생성
⑦ 사용자에게 답변 반환</code></pre><p>여기서 핵심은 ⑤번이다. 질문만 달랑 보내는 게 아니라,
<code>&quot;이 문서들을 참고해서 답해줘&quot;</code> 라고 컨텍스트를 함께 붙여서 LLM에 전달한다.
이것이 Augmentation의 본질이다.</p>
<hr>
<h2 id="3-코드로-보는-rag-구현">3. 코드로 보는 RAG 구현</h2>
<h3 id="3-1-유사도-검색과-threshold">3-1. 유사도 검색과 Threshold</h3>
<pre><code class="language-java">public AnswerResponse ask(String question) {
    // threshold 0.0 → 필터링 없음 (유사도와 무관하게 전부 가져옴)
    List&lt;Document&gt; relevantDocs = searchDocuments(question, 5, 0.0);
    if (relevantDocs.isEmpty()) {
        throw new DomainException(DomainExceptionCode.NOT_FOUND_CONVERSATION);
    }
    return AnswerResponse.builder()
        .answer(generateAnswer(question, relevantDocs))
        .build();
}

public RagResponse askWithSource(String question) {
    // threshold 0.7 → 유사도 70% 이상 청크만 선별
    List&lt;Document&gt; docs = searchDocuments(question, 5, 0.7);
    ...
}</code></pre>
<p><code>ask()</code>와 <code>askWithSource()</code>의 차이는 Threshold에 있다.</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>Threshold</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>ask()</code></td>
<td>0.0</td>
<td>답변만 반환 → 출처 신뢰도가 덜 중요</td>
</tr>
<tr>
<td><code>askWithSource()</code></td>
<td>0.7</td>
<td>출처를 명시 → 엉뚱한 근거를 보여주면 신뢰도가 무너짐</td>
</tr>
</tbody></table>
<p>Threshold는 LLM 앞단에서 작동하는 필터다.
Vector DB 검색 시점에 낮은 유사도의 청크를 걸러내어,
품질 낮은 컨텍스트가 LLM에 전달되는 것을 막는다.</p>
<h3 id="3-2-프롬프트-설계--hallucination-방지">3-2. 프롬프트 설계 — Hallucination 방지</h3>
<pre><code class="language-java">private static final String RAG_PROMPT_TEMPLATE = &quot;&quot;&quot;
    다음 문서들을 참고하여 질문에 답변해주세요.
    문서에 없는 내용은 답변하지 마세요.  ← 핵심
    답변은 한국어로 작성해주세요.

    [참고 문서]
    %s

    [질문]
    %s
    &quot;&quot;&quot;;</code></pre>
<p><code>&quot;문서에 없는 내용은 답변하지 마세요&quot;</code> 이 한 줄이 없으면
LLM은 문서에 없는 내용을 자신의 사전 학습 지식으로 채워 넣는다.
Hallucination이 발생하는 지점이 바로 여기다.</p>
<p>프롬프트로 명시적 제약을 걸어야 LLM의 상상력을 차단할 수 있다.</p>
<h3 id="3-3-출처-포함-응답">3-3. 출처 포함 응답</h3>
<pre><code class="language-java">public RagResponse askWithSource(String question) {
    List&lt;Document&gt; docs = searchDocuments(question, 5, 0.7);
    String answer = generateAnswer(question, docs);

    List&lt;RagResponse.DocumentSource&gt; sources = docs.stream()
        .map(doc -&gt; RagResponse.DocumentSource.builder()
            .filename((String) doc.getMetadata().get(&quot;filename&quot;))
            .documentId(doc.getId())
            .preview(doc.getText().substring(0, Math.min(doc.getText().length(), 100)))
            .build())
        .toList();

    return RagResponse.builder()
        .answer(answer)
        .sources(sources)
        .build();
}</code></pre>
<p>출처를 함께 반환하면 사용자는 <code>&quot;이 답변이 어느 문서에서 왔는지&quot;</code> 확인할 수 있다.
Threshold를 높게 잡는 이유도 여기 있다.
출처가 틀리면 LLM 전체에 대한 신뢰가 무너지기 때문이다.</p>
<hr>
<h2 id="4-rag-vs-fine-tuning">4. RAG vs Fine-tuning</h2>
<p>RAG와 자주 비교되는 것이 Fine-tuning이다.</p>
<ul>
<li><strong>RAG</strong>: 참고서를 찾아보는 것</li>
<li><strong>Fine-tuning</strong>: 머릿속에 외우는 것</li>
</ul>
<table>
<thead>
<tr>
<th>항목</th>
<th>RAG</th>
<th>Fine-tuning</th>
</tr>
</thead>
<tbody><tr>
<td>업데이트</td>
<td>문서만 추가하면 즉시 반영</td>
<td>재학습 필요 (고비용)</td>
</tr>
<tr>
<td>신뢰도</td>
<td>출처 제공 가능</td>
<td>블랙박스</td>
</tr>
<tr>
<td>비용</td>
<td>저렴</td>
<td>고성능 GPU 필요</td>
</tr>
<tr>
<td>적합한 경우</td>
<td>최신 정보, 사내 문서</td>
<td>말투 변경, 도메인 특화 스타일</td>
</tr>
</tbody></table>
<p>사내 규정 챗봇, 최신 문서 기반 QA 시스템이라면 RAG가 압도적으로 유리하다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>RAG는 LLM에게 오픈북 테스트를 허용하는 패턴이다.
질문을 벡터화 → 유사도 검색 → 청크 추출 → 프롬프트 증강 → 답변 생성의 흐름으로,
문서 기반의 정확하고 신뢰할 수 있는 답변을 이끌어낸다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring AI] Vector DB와 Spring AI]]></title>
            <link>https://velog.io/@furaha_dev/SpringAI-Vector-DB%EC%99%80-Spring-AI</link>
            <guid>https://velog.io/@furaha_dev/SpringAI-Vector-DB%EC%99%80-Spring-AI</guid>
            <pubDate>Sat, 04 Apr 2026 06:32:16 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>LLM은 Stateless하기 때문에 매 요청마다 컨텍스트를 직접 전달해야 한다. 그런데 컨텍스트가 길어질수록 토큰 비용이 폭증하고, 필요한 정보만 골라서 전달해야 한다는 문제가 남아 있었다.</p>
<p>이번 글에서는 그 해결책의 핵심인 <strong>Vector DB</strong>를 다룬다. 아래 세 가지 질문을 중심으로 풀어나간다.</p>
<ol>
<li>기존 DB 검색과 Vector DB 검색은 무엇이 다른가?</li>
<li>텍스트를 어떻게 숫자로 바꾸면 &quot;의미가 비슷하다&quot;는 걸 판단할 수 있는가?</li>
<li>Spring AI와 pgvector로 어떻게 구현하는가?</li>
</ol>
<hr>
<h2 id="1-기존-db-검색의-한계">1. 기존 DB 검색의 한계</h2>
<p>MyBatis로 Oracle을 다뤄본 사람이라면 검색 쿼리를 이렇게 짰을 것이다.</p>
<pre><code class="language-sql">SELECT * FROM documents WHERE content LIKE &#39;%병가%&#39;</code></pre>
<p>이 방식은 <strong>글자가 일치하는지</strong>만 본다. 사용자가 &quot;몸이 안 좋아서 쉬고 싶어&quot;라고 검색했을 때, DB에 &quot;병가 신청 방법&quot;이라는 문서가 있어도 찾지 못한다. 두 표현은 의미상 같은 말이지만 글자가 완전히 다르기 때문이다.</p>
<p>Vector DB는 이 문제를 다르게 접근한다. <strong>글자 일치</strong>가 아니라 <strong>의미의 유사함</strong>으로 검색한다.</p>
<hr>
<h2 id="2-임베딩embedding-의미를-숫자로">2. 임베딩(Embedding): 의미를 숫자로</h2>
<p>컴퓨터가 &quot;의미의 유사함&quot;을 판단하려면 텍스트를 숫자로 바꿔야 한다. 이 과정을 <strong>임베딩</strong>이라고 한다.</p>
<p>원리는 간단하다. 단어를 여러 기준으로 점수 매기는 것이다.</p>
<pre><code>&quot;강아지&quot; 기준별 점수:
- 생물인가?    → 0.9
- 귀여운가?   → 0.8
- 바퀴가 있나? → 0.0

→ 벡터: [0.9, 0.8, 0.0]

&quot;개&quot;:           [0.9, 0.8, 0.0]  ← 거의 동일!
&quot;자동차&quot;:       [0.1, 0.2, 1.0]  ← 완전히 다름!</code></pre><p>두 벡터의 숫자가 비슷하다는 건, 좌표 공간에서 <strong>가까운 위치</strong>에 있다는 뜻이다. 실제 임베딩 모델은 이 기준(차원)이 3개가 아니라 <strong>768~3072개</strong>다. 차원이 많아질수록 단어의 의미를 더 세밀하게 표현할 수 있다.---</p>
<h2 id="3-vector-db-전체-흐름">3. Vector DB 전체 흐름</h2>
<p>Vector DB를 쓰는 흐름은 크게 두 단계다: <strong>저장</strong>과 <strong>검색</strong>.---</p>
<h2 id="4-왜-pgvector인가">4. 왜 pgvector인가</h2>
<p>Vector DB 전용 솔루션(Pinecone, Weaviate 등)을 별도로 도입하면 세 가지 문제가 생긴다.</p>
<ul>
<li>서버를 하나 더 관리해야 한다</li>
<li>비용이 추가로 발생한다</li>
<li>기존 DB와 데이터 동기화 문제가 생긴다</li>
</ul>
<p>pgvector는 이 문제를 한 줄로 해결한다.</p>
<pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS vector;</code></pre>
<p>기존 PostgreSQL에 이 한 줄만 추가하면 Vector DB 기능이 생긴다. 새로운 인프라 없이, SQL 문법 그대로, ACID 트랜잭션 보장까지.</p>
<pre><code class="language-sql">-- 벡터 유사도 검색
SELECT content FROM vector_store
ORDER BY embedding &lt;-&gt; &#39;[0.12, 0.05, ...]&#39;
LIMIT 5;</code></pre>
<p><code>&lt;-&gt;</code> 연산자 하나로 수학적 거리 계산이 처리된다.</p>
<hr>
<h2 id="5-테이블-설계-왜-두-테이블인가">5. 테이블 설계: 왜 두 테이블인가</h2>
<p>처음 보면 &quot;왜 하나로 합치지 않지?&quot;라는 의문이 생긴다. 답은 간단하다. <strong>1:N 관계</strong>이기 때문이다.</p>
<pre><code>vector_documents (1)
└── vector_store (N)
    ├── 청크 1: &quot;병가는 연간...&quot;  [0.9, 0.2, ...]
    ├── 청크 2: &quot;신청 방법은...&quot;  [0.8, 0.3, ...]
    └── 청크 3: &quot;HR팀 연락처...&quot;  [0.7, 0.1, ...]</code></pre><p>파일 하나를 쪼개면 청크가 여러 개 나온다. 원본은 <code>vector_documents</code>에, 청크들은 <code>vector_store</code>에 따로 관리한다.</p>
<p>부가적으로, 각 청크의 메타데이터에 <code>document_id</code>를 달아두면 검색 결과가 &quot;어느 파일에서 왔는지&quot; 역추적이 가능하다. 출처 표시가 되는 것이다.</p>
<hr>
<h2 id="6-핵심-구현-코드">6. 핵심 구현 코드</h2>
<h3 id="문서-업로드-흐름">문서 업로드 흐름</h3>
<pre><code class="language-java">@Transactional
public DocumentUploadResponse uploadDocument(MultipartFile file) throws IOException {
    String content = new String(file.getBytes(), StandardCharsets.UTF_8);

    // 1. 원본 저장
    VectorDocument saved = vectorDocumentRepository.save(
        VectorDocument.builder()
            .fileName(file.getOriginalFilename())
            .content(content)
            .build()
    );

    // 2. 청크 분할 + 임베딩 + Vector DB 저장
    List&lt;Document&gt; chunks = createChunks(content, saved);
    vectorStore.add(chunks); // Spring AI가 임베딩까지 처리

    return DocumentUploadResponse.builder()
        .documentId(saved.getId().toString())
        .chunkCount(chunks.size())
        .build();
}</code></pre>
<h3 id="청크-분할">청크 분할</h3>
<pre><code class="language-java">TextSplitter splitter = new TokenTextSplitter(
    500,   // 청크당 최대 토큰 수
    100,   // 오버랩 크기 (앞뒤 청크와 겹치는 양)
    5,     // 너무 짧은 문장은 제외
    10000, // 파일당 최대 청크 수
    true   // 문단 구분자 유지
);</code></pre>
<h3 id="유사도-검색">유사도 검색</h3>
<pre><code class="language-java">List&lt;Document&gt; results = vectorStore.similaritySearch(
    SearchRequest.builder()
        .query(query)
        .topK(5) // 유사도 상위 5개만 반환
        .filterExpression(/* document_id 필터 */)
        .build()
);</code></pre>
<hr>
<h2 id="7-top-k-몇-개를-가져올-것인가">7. Top-K: 몇 개를 가져올 것인가</h2>
<table>
<thead>
<tr>
<th>값</th>
<th>문제점</th>
</tr>
</thead>
<tbody><tr>
<td>너무 작을 때 (1~2)</td>
<td>필요한 정보 누락 → 불완전한 답변</td>
</tr>
<tr>
<td>적당할 때 (3~5)</td>
<td>정확도와 비용의 균형점</td>
</tr>
<tr>
<td>너무 클 때 (10+)</td>
<td>관련 없는 청크 유입 → 환각 증가, 비용 폭증</td>
</tr>
</tbody></table>
<p><strong>3~5</strong>가 권장값이다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>Vector DB의 핵심은 &quot;글자 일치&quot;에서 &quot;의미 유사도&quot;로의 패러다임 전환이다. 임베딩으로 텍스트를 벡터로 바꾸고, 그 벡터 간 거리를 계산해서 의미적으로 가까운 데이터를 찾는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring AI] 로컬에서 AI 모델 돌리기 — Spring AI + Ollama 연동]]></title>
            <link>https://velog.io/@furaha_dev/SpringAI-%EB%A1%9C%EC%BB%AC%EC%97%90%EC%84%9C-AI-%EB%AA%A8%EB%8D%B8-%EB%8F%8C%EB%A6%AC%EA%B8%B0-Spring-AI-Ollama-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@furaha_dev/SpringAI-%EB%A1%9C%EC%BB%AC%EC%97%90%EC%84%9C-AI-%EB%AA%A8%EB%8D%B8-%EB%8F%8C%EB%A6%AC%EA%B8%B0-Spring-AI-Ollama-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Sat, 04 Apr 2026 05:27:19 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지금까지 Spring AI로 Gemini와 OpenAI 같은 클라우드 API를 연동해봤다.
<code>ChatClient</code> 하나로 다양한 모델을 추상화해서 쓸 수 있다는 것도 확인했다.</p>
<p>그런데 이런 의문이 생길 수 있다.</p>
<ul>
<li>클라우드 API를 쓰면 데이터가 외부 서버로 나가는데, 민감한 정보는 어떻게 처리하지?</li>
<li>호출 횟수가 많아지면 비용이 감당이 안 되는데, 대안이 없을까?</li>
<li>인터넷이 안 되는 환경에서도 AI를 써야 한다면?</li>
</ul>
<p>이번 글에서는 이 세 가지 문제를 한 번에 해결하는 방법인 <strong>로컬 LLM 실행</strong>을 다룬다.
핵심 도구는 <strong>Ollama</strong>고, Spring AI와 어떻게 연결하는지까지 살펴본다.</p>
<hr>
<h2 id="1-클라우드-api-vs-로컬-모델">1. 클라우드 API vs 로컬 모델</h2>
<p>클라우드 API 방식은 편하다. API 키 하나만 있으면 GPT-4, Claude 같은 최고 성능의 모델을 바로 쓸 수 있다.
하지만 이 방식에는 구조적인 단점이 있다.</p>
<pre><code>Spring Boot
    │
    │ HTTPS (외부 인터넷)
    ▼
OpenAI / Anthropic 서버</code></pre><p>모든 데이터가 외부로 나간다. 의료 기록, 금융 거래 내역, 사내 기밀 문서 같은 민감한 정보를 다루는 시스템이라면 이건 심각한 문제다.
게다가 사용량이 늘어날수록 비용도 선형이 아니라 폭발적으로 증가한다.</p>
<p>로컬 모델 방식은 구조가 다르다.</p>
<pre><code>Spring Boot
    │
    │ localhost:11434
    ▼
Ollama (내 컴퓨터)
    └── qwen2.5:3b (AI 모델)</code></pre><p>데이터가 밖으로 나가지 않는다. 초기 설정 이후에는 추가 비용도 없다.
인터넷이 없는 환경에서도 동작한다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>클라우드 API</th>
<th>로컬 모델 (Ollama)</th>
</tr>
</thead>
<tbody><tr>
<td>비용</td>
<td>토큰당 과금</td>
<td>초기 설정 이후 무료</td>
</tr>
<tr>
<td>데이터 보안</td>
<td>외부 서버 전송</td>
<td>로컬 저장</td>
</tr>
<tr>
<td>인터넷 의존</td>
<td>필수</td>
<td>불필요</td>
</tr>
<tr>
<td>응답 속도</td>
<td>네트워크 지연 발생</td>
<td>로컬 통신 (빠름)</td>
</tr>
<tr>
<td>모델 성능</td>
<td>초거대 모델 사용 가능</td>
<td>하드웨어에 따라 제한</td>
</tr>
<tr>
<td>하드웨어 요구</td>
<td>없음</td>
<td>RAM 8GB 이상 권장</td>
</tr>
</tbody></table>
<p>두 방식은 경쟁 관계가 아니라 보완 관계다.
보안이 중요하거나 고빈도 호출이 필요한 서비스는 로컬 모델, 최고 성능이 필요하거나 초기 출시를 빠르게 해야 한다면 클라우드 API가 적합하다.</p>
<hr>
<h2 id="2-ollama란-무엇인가">2. Ollama란 무엇인가</h2>
<p>Ollama는 한 줄로 이렇게 정의할 수 있다.</p>
<blockquote>
<p>&quot;AI 모델계의 Docker&quot;</p>
</blockquote>
<p>Docker가 컨테이너 이미지를 패키징해서 어디서든 동일하게 실행시켜주듯,
Ollama는 AI 모델을 패키징해서 내 로컬 환경에서 동일하게 실행할 수 있게 해준다.</p>
<pre><code>Docker          Ollama
─────────────   ──────────────────
이미지          AI 모델 (qwen2.5:3b)
컨테이너 런타임  Ollama 런타임
포트 노출       localhost:11434</code></pre><p>Docker로 설치하는 방법은 간단하다.</p>
<pre><code class="language-bash"># Ollama 컨테이너 실행
docker run -d \
  -v ollama:/root/.ollama \
  -p 11434:11434 \
  --name ollama \
  ollama/ollama

# 모델 다운로드 및 실행
docker exec -it ollama ollama run qwen2.5:3b</code></pre>
<p>실행 후 브라우저에서 <code>http://localhost:11434</code>에 접속했을 때 &quot;Ollama is running&quot;이 뜨면 준비 완료다.</p>
<hr>
<h2 id="3-어떤-모델을-선택할까">3. 어떤 모델을 선택할까</h2>
<p>Ollama에서 제공하는 모델은 다양하다. 이번 실습에서는 <strong>Qwen2.5</strong>를 사용했다.
Alibaba Cloud가 만든 모델로, 한국어 처리 능력이 뛰어나고 4b(40억 파라미터) 크기는 일반 개발용 노트북에서도 무리 없이 돌아간다.</p>
<table>
<thead>
<tr>
<th>모델</th>
<th>RAM 요구량</th>
<th>속도</th>
<th>품질</th>
<th>추천 용도</th>
</tr>
</thead>
<tbody><tr>
<td>qwen2.5:0.5b</td>
<td>2GB</td>
<td>매우 빠름</td>
<td>낮음</td>
<td>단순 분류, 테스트</td>
</tr>
<tr>
<td>qwen2.5:3b</td>
<td>8GB</td>
<td>빠름</td>
<td>좋음</td>
<td>균형 잡힌 일반 용도</td>
</tr>
<tr>
<td>qwen2.5:7b</td>
<td>16GB</td>
<td>보통</td>
<td>매우 좋음</td>
<td>복잡한 로직, RAG</td>
</tr>
</tbody></table>
<p>하드웨어 사양에 따라 모델을 선택하면 된다. RAM이 16GB 이상이라면 7b를, 그렇지 않다면 3b가 현실적인 선택이다.</p>
<hr>
<h2 id="4-spring-ai에-ollama-연동하기">4. Spring AI에 Ollama 연동하기</h2>
<p>이제 핵심이다. Spring AI에서 Ollama를 연결하는 방법은 Gemini나 OpenAI와 구조가 동일하다.</p>
<h3 id="4-1-의존성-추가">4-1. 의존성 추가</h3>
<pre><code class="language-groovy">dependencies {
    // Ollama 연동 스타터
    implementation &#39;org.springframework.ai:spring-ai-ollama-spring-boot-starter&#39;
}</code></pre>
<p><code>starter</code>를 추가하는 순간, Spring Boot Auto Configuration이 동작해서 <code>ChatClient</code> Bean을 자동으로 등록한다.
우리가 직접 <code>@Component</code>를 붙이거나 Bean 설정을 작성할 필요가 없다.</p>
<h3 id="4-2-applicationyml-설정">4-2. application.yml 설정</h3>
<pre><code class="language-yaml">spring:
  ai:
    ollama:
      base-url: http://localhost:11434  # Ollama 서버 주소
      chat:
        options:
          model: qwen2.5:3b
          temperature: 0.7
          top-k: 40
          top-p: 0.9
          num-predict: 10000
          repeat-penalty: 1.1</code></pre>
<p>클라우드 API와의 차이점은 하나다. <code>api-key</code> 대신 <code>base-url</code>을 설정한다.
로컬에서 돌아가는 서버이므로 인증 키가 필요 없고, 대신 어디에 서버가 있는지만 알려주면 된다.</p>
<h3 id="4-3-service-코드">4-3. Service 코드</h3>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class OllamaService {

    private final ChatClient chatClient;  // Auto Configuration이 주입해준 Bean

    public String chat(String message) {
        return chatClient.prompt()
            .user(message)
            .call()
            .content();
    }

    public String chatWithOptions(String systemPrompt, String message, Double temperature) {
        return chatClient.prompt()
            .system(systemPrompt)
            .user(message)
            .options(OllamaOptions.builder()
                .temperature(temperature)
                .build())
            .call()
            .content();
    }
}</code></pre>
<p>주목할 점은 <code>ChatClient</code>를 사용하는 코드가 Gemini를 쓰던 것과 완전히 동일하다는 것이다.
모델이 바뀌었지만 비즈니스 로직은 한 줄도 수정하지 않았다.</p>
<p>이것이 가능한 이유는 1단계에서 배운 <strong>추상화</strong> 때문이다.
<code>ChatClient</code>라는 인터페이스에 의존하기 때문에, 구체적인 구현체(Gemini, Ollama)가 무엇인지 알 필요가 없다.</p>
<pre><code>개발자 코드          Spring AI              실제 모델
──────────────      ──────────────         ──────────────
ChatClient.prompt() → ChatClient (인터페이스)  → Gemini
                                              → Ollama
                                              → OpenAI</code></pre><p>yaml 설정만 바꾸면 모델이 교체된다. 코드는 그대로다.</p>
<hr>
<h2 id="5-응답-품질을-결정하는-세-가지-파라미터">5. 응답 품질을 결정하는 세 가지 파라미터</h2>
<p>모델의 답변 품질은 파라미터로 조절할 수 있다. 세 가지를 이해하면 대부분의 상황에 대응할 수 있다.</p>
<h3 id="temperature">temperature</h3>
<p>가장 기본적인 파라미터다. 0부터 1 사이의 값으로 답변의 창의성 정도를 조절한다.</p>
<ul>
<li><strong>0에 가까울수록</strong>: 항상 가장 확률이 높은 단어를 선택 → 일관되고 예측 가능한 답변</li>
<li><strong>1에 가까울수록</strong>: 낮은 확률의 단어도 선택 → 다양하고 창의적인 답변</li>
</ul>
<p>코드 생성이나 사실 기반 답변은 낮은 temperature, 아이디어 제안이나 창작은 높은 temperature가 적합하다.</p>
<h3 id="top-k">top-k</h3>
<p>다음 단어 후보를 <strong>확률 상위 k개로 고정</strong>해서 제한하는 방식이다.</p>
<pre><code>전체 어휘: 50,000개
top-k = 40 설정
→ 확률 상위 40개만 후보로 인정
→ 나머지 49,960개는 무조건 제외</code></pre><p>후보군 크기가 항상 고정되어 있어서 예측 가능하다는 특징이 있다.</p>
<h3 id="top-p-nucleus-sampling">top-p (nucleus sampling)</h3>
<p>누적 확률이 p에 도달할 때까지의 단어들만 후보로 삼는 방식이다.</p>
<pre><code>확률 분포: &quot;맑아&quot;(70%), &quot;흐려&quot;(20%), &quot;더워&quot;(5%), &quot;춥고&quot;(3%) ...
top-p = 0.9 설정
→ 70% + 20% = 90% 도달
→ &quot;맑아&quot;, &quot;흐려&quot; 2개만 후보</code></pre><p>top-k와 달리 <strong>후보 개수가 문맥에 따라 동적으로 변한다</strong>는 것이 핵심이다.
문맥이 명확할수록 후보가 줄고, 애매할수록 후보가 늘어난다.
결과적으로 더 자연스러운 답변을 생성하는 경향이 있다.</p>
<table>
<thead>
<tr>
<th>파라미터</th>
<th>역할</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>temperature</td>
<td>창의성 정도 조절</td>
<td>0 = 정확, 1 = 창의적</td>
</tr>
<tr>
<td>top-k</td>
<td>후보 개수 고정 제한</td>
<td>항상 동일한 개수</td>
</tr>
<tr>
<td>top-p</td>
<td>누적 확률 기반 제한</td>
<td>문맥에 따라 동적 조절</td>
</tr>
</tbody></table>
<p>실무에서는 세 파라미터를 함께 사용한다. 일반적인 챗봇이라면 <code>temperature: 0.7</code>, <code>top-k: 40</code>, <code>top-p: 0.9</code> 조합이 무난한 출발점이다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>로컬 LLM은 클라우드 API의 대안이 아니라 상황에 따른 선택지다.
보안이 중요하거나 비용이 부담되는 상황이라면 Ollama + Spring AI 조합이 실용적인 해답이 된다.</p>
<p>Spring AI의 추상화 덕분에 클라우드 API를 쓰던 코드를 그대로 유지하면서 모델만 교체할 수 있다.
이것이 Spring AI가 제공하는 가장 큰 가치다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring AI] LLM 컨텍스트 관리와 멀티모달]]></title>
            <link>https://velog.io/@furaha_dev/SpringAI-LLM-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8-%EA%B4%80%EB%A6%AC%EC%99%80-%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%8B%AC</link>
            <guid>https://velog.io/@furaha_dev/SpringAI-LLM-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8-%EA%B4%80%EB%A6%AC%EC%99%80-%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%8B%AC</guid>
            <pubDate>Fri, 03 Apr 2026 15:40:43 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서는 LLM과 대화를 설계하는 방법을 다뤘다. System / User / Assistant 세 가지 메시지 타입을 이해하고, 히스토리를 어떻게 구성해야 AI가 맥락을 잘 이해하는지 살펴봤다.</p>
<p>그런데 히스토리를 쌓다 보면 자연스럽게 이런 의문이 생긴다.</p>
<ul>
<li>대화가 100턴을 넘어가면 어떻게 관리해야 할까?</li>
<li>서버가 재시작되면 이전 대화가 날아가지 않을까?</li>
<li>텍스트 말고 이미지나 파일도 AI에게 분석시킬 수 있을까?</li>
</ul>
<p>이번 글에서는 이 세 가지 질문에 답한다.</p>
<hr>
<h2 id="1-llm은-왜-기억을-못할까">1. LLM은 왜 기억을 못할까</h2>
<p>LLM은 <strong>Stateless</strong>다. 매 요청이 완전히 독립적으로 처리된다. 이전 대화가 무엇이었는지, 사용자가 누구인지, 아무것도 모르는 상태에서 시작한다.</p>
<p>그래서 &quot;기억&quot;을 제공하려면 개발자가 직접 이전 대화를 매 요청마다 함께 보내줘야 한다. 이게 컨텍스트 관리의 출발점이다.</p>
<p>문제는 대화가 길어질수록 비용이 폭증한다는 것이다.</p>
<pre><code>턴 1:   200 토큰
턴 10:  2,000 토큰
턴 50:  25,000 토큰
턴 100: 100,000 토큰  ← 비용 급증</code></pre><p>게다가 컨텍스트가 너무 길어지면 LLM이 중간에 있는 중요한 정보를 놓치는 <strong>&quot;Lost in the Middle&quot;</strong> 현상도 발생한다. 비용과 성능, 두 마리 토끼를 모두 잡아야 한다.</p>
<hr>
<h2 id="2-메모리-3계층-전략">2. 메모리 3계층 전략</h2>
<p>이 문제를 해결하기 위해 메모리를 세 가지 레이어로 나눠서 관리한다. 각 레이어는 속도, 영속성, 용량 측면에서 서로 다른 특성을 가진다.</p>
<table>
<thead>
<tr>
<th>레이어</th>
<th>저장소</th>
<th>특성</th>
<th>활용처</th>
</tr>
</thead>
<tbody><tr>
<td>Volatile Memory</td>
<td>Redis, RAM</td>
<td>초고속, 휘발성</td>
<td>현재 진행 중인 활성 세션</td>
</tr>
<tr>
<td>Non-volatile Memory</td>
<td>RDBMS (DB)</td>
<td>영구 저장</td>
<td>세션 종료 후 맥락 복원</td>
</tr>
<tr>
<td>Semantic Memory</td>
<td>Vector DB</td>
<td>의미 기반 검색</td>
<td>과거 대화에서 유사 내용 추출 (RAG)</td>
</tr>
</tbody></table>
<p>각 레이어의 역할을 냉장고에 비유하면 이렇다. Volatile은 손이 닿는 곳에 올려둔 오늘 먹을 음식, Non-volatile은 냉장고 안에 보관된 식재료, Semantic은 레시피 북에서 지금 요리에 맞는 레시피를 검색하는 것이다.</p>
<blockquote>
<p>실무에서는 세 레이어를 조합한 하이브리드 방식을 사용한다. 캐시 확인 → DB 복원 → Vector DB 검색 순서로 컨텍스트를 구성한다.</p>
</blockquote>
<hr>
<h2 id="3-db-기반-컨텍스트-영속화">3. DB 기반 컨텍스트 영속화</h2>
<p>Non-volatile Memory를 구현할 때 핵심은 테이블 설계다. 대화(Conversation)와 메시지(Message)를 분리해서 관리한다.</p>
<pre><code>ChatConversation  ─────  ChatMessage
(채팅방)                  (메시지)
id (PK)                   id (PK)
title                     conversation_id (FK)
created_at                role  -- USER, ASSISTANT, SYSTEM, SUMMARY
updated_at                message
                          prompt_tokens
                          completion_tokens
                          total_tokens</code></pre><p>이렇게 분리하면 여러 채팅방의 컨텍스트가 섞이지 않는다. <code>conversation_id</code>로 특정 채팅방의 메시지만 조회할 수 있기 때문이다.</p>
<pre><code class="language-java">List&lt;ChatMessage&gt; findByConversation_IdAndStatus(
    UUID chatConversationId, StatusType status
);</code></pre>
<hr>
<h2 id="4-컨텍스트-압축-전략-슬라이딩-윈도우--요약">4. 컨텍스트 압축 전략: 슬라이딩 윈도우 + 요약</h2>
<p>메시지가 쌓일수록 전부 LLM에 전달하는 건 비효율적이다. 두 가지 전략을 조합해서 해결한다.</p>
<h3 id="슬라이딩-윈도우-sliding-window">슬라이딩 윈도우 (Sliding Window)</h3>
<p>최근 N개의 메시지만 원본으로 유지한다. 그 이전 메시지는 요약 대상이 된다.</p>
<h3 id="요약-summarization">요약 (Summarization)</h3>
<p>오래된 메시지를 AI가 직접 요약해서 하나의 메시지로 압축한다. 이렇게 하면 80% 이상의 토큰을 절감하면서도 전체 맥락을 유지할 수 있다.</p>
<pre><code>턴 1-40  →  요약본 1개 (500 토큰)
턴 41-50 →  원본 유지 (5,000 토큰)
합계: 5,500 토큰  (78% 절감)</code></pre><p>요약본은 <code>SUMMARY</code> 타입으로 저장하고, LLM에 전달할 때는 <strong>System Message</strong>로 주입한다. System Message로 주입하면 LLM이 이를 &quot;반드시 알고 있어야 할 배경 지식&quot;으로 인식하기 때문이다.</p>
<pre><code class="language-java">private Message mapToSpringAiMessage(ChatMessage entity) {
    return switch (entity.getRole()) {
        case USER      -&gt; new UserMessage(content);
        case ASSISTANT -&gt; new AssistantMessage(content);
        case SYSTEM,
             SUMMARY   -&gt; new SystemMessage(content); // 요약본은 System으로 주입
    };
}</code></pre>
<hr>
<h2 id="5-멀티모달-텍스트-너머로">5. 멀티모달: 텍스트 너머로</h2>
<p>멀티모달(Multimodal)은 텍스트 외에 이미지, 오디오, 비디오 등 다양한 타입의 데이터를 LLM의 입력으로 받는 기능이다.</p>
<p>Spring AI에서 이미지를 전달할 때는 <code>Media</code> 객체를 사용한다. 핵심은 이미지 데이터와 함께 MIME 타입을 반드시 함께 넘겨야 한다는 것이다. LLM이 받은 데이터가 어떤 종류인지 알아야 처리 방식을 결정할 수 있기 때문이다.</p>
<pre><code class="language-java">chatClient.prompt()
    .user(u -&gt; u
        .text(message)
        .media(MimeTypeUtils.parseMimeType(contentType), image.getResource())
    )
    .call()
    .chatResponse();</code></pre>
<hr>
<h2 id="6-구조화된-출력으로-ai-응답-활용하기">6. 구조화된 출력으로 AI 응답 활용하기</h2>
<p>이미지 분석 결과를 코드에서 바로 사용하려면 자연어 응답이 아니라 구조화된 데이터가 필요하다.</p>
<p>&quot;영수증 정보 알려줘&quot;라고 하면 이런 응답이 온다.</p>
<pre><code>&quot;이 영수증은 스타벅스에서 2026년 4월 4일에
총 13,500원을 결제한 내역입니다...&quot;</code></pre><p>파싱이 불가능하다. 반면 JSON을 강제하면 이렇게 된다.</p>
<pre><code class="language-json">{
  &quot;storeName&quot;: &quot;스타벅스&quot;,
  &quot;date&quot;: &quot;2026-04-04&quot;,
  &quot;totalAmount&quot;: 13500
}</code></pre>
<p><code>objectMapper.readValue()</code>로 바로 Java 객체로 변환할 수 있다. AI 프롬프트에서 출력 형식을 명시적으로 지정하는 것이 실무에서 매우 중요한 이유다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>LLM은 Stateless하기 때문에 컨텍스트 관리는 개발자의 몫이다. 3계층 메모리 전략과 슬라이딩 윈도우 + 요약 조합으로 비용과 성능을 동시에 잡을 수 있다. 멀티모달은 텍스트 외 입력을 가능하게 하고, 구조화된 출력 설계로 AI 응답을 서비스에 바로 녹여낼 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring AI] LLM과 제대로 대화하기: Context, History, Token, Streaming]]></title>
            <link>https://velog.io/@furaha_dev/SpringAI-LLM%EA%B3%BC-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%8C%80%ED%99%94%ED%95%98%EA%B8%B0-Context-History-Token</link>
            <guid>https://velog.io/@furaha_dev/SpringAI-LLM%EA%B3%BC-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%8C%80%ED%99%94%ED%95%98%EA%B8%B0-Context-History-Token</guid>
            <pubDate>Fri, 03 Apr 2026 14:34:59 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서 Spring AI의 기본 구조와 <code>ChatClient</code>를 통해 LLM에 질문을 던지는 방법을 살펴봤다.
그런데 막상 챗봇을 만들려고 하면 이런 의문이 생긴다.</p>
<ul>
<li>AI는 내가 아까 뭐라고 했는지 기억하는가?</li>
<li>대화가 길어질수록 비용이 얼마나 늘어나는가?</li>
<li>AI 응답을 실시간으로 보여주려면 어떻게 해야 하는가?</li>
<li>AI가 항상 정해진 형식으로만 응답하게 만들 수 있는가?</li>
</ul>
<p>이번 글에서는 이 네 가지 질문을 중심으로, LLM과의 대화를 설계하는 핵심 개념들을 정리한다.</p>
<hr>
<h2 id="1-llm은-기억이-없다">1. LLM은 기억이 없다</h2>
<p>가장 먼저 이 사실을 받아들여야 한다. LLM은 요청을 받고 응답을 돌려주는 순간, 그 내용을 전혀 기억하지 않는다. 다음 요청이 들어오면 완전히 새로운 대화로 인식한다.</p>
<p>그렇다면 ChatGPT나 Claude가 이전 대화를 기억하는 것처럼 느껴지는 이유는 무엇일까? 답은 간단하다. 매 요청마다 이전 대화 내용을 통째로 함께 보내기 때문이다.</p>
<p>LLM과의 대화에는 세 가지 메시지 타입이 존재한다.</p>
<pre><code>System   : AI의 역할과 규칙 정의 (사용자에게 보이지 않음)
User     : 사용자의 질문
Assistant: AI의 이전 응답 (다음 요청 시 히스토리로 포함)</code></pre><p>대화가 쌓이면 요청 구조는 다음과 같이 눈덩이처럼 불어난다.</p>
<pre><code>1회차: [System] + [User 1]
2회차: [System] + [User 1] + [Assistant 1] + [User 2]
3회차: [System] + [User 1] + [Assistant 1] + [User 2] + [Assistant 2] + [User 3]</code></pre><p>코드로 표현하면 이렇다.</p>
<pre><code class="language-java">// AI는 history 전체를 읽고 나서 User 3에 답변한다
ChatResponse response = chatClient.prompt()
    .messages(history)  // 지금까지의 모든 대화
    .call()
    .chatResponse();

// AI 응답을 다시 히스토리에 추가
history.add(new AssistantMessage(response.getResult().getOutput().getText()));</code></pre>
<hr>
<h2 id="2-히스토리가-쌓이면-생기는-문제">2. 히스토리가 쌓이면 생기는 문제</h2>
<p>대화가 100번, 1000번 쌓이면 매 요청마다 엄청난 양의 텍스트를 AI에게 읽어줘야 한다. 문제는 세 가지다.</p>
<p><strong>비용 폭주</strong>: LLM API는 토큰 단위로 과금한다. 누적된 히스토리가 길수록 입력 토큰이 늘어나고, 비용이 기하급수적으로 증가한다.</p>
<p><strong>응답 지연</strong>: AI가 읽어야 할 내용이 많을수록 응답 생성 시간이 길어진다.</p>
<p><strong>주의력 분산</strong>: Context가 너무 길면 AI가 중간에 있는 정보를 무시하는 경향이 생긴다. 오히려 답변 품질이 떨어질 수 있다.</p>
<hr>
<h2 id="3-히스토리-관리-전략-3가지">3. 히스토리 관리 전략 3가지</h2>
<h3 id="전략-a-슬라이딩-윈도우-sliding-window">전략 A: 슬라이딩 윈도우 (Sliding Window)</h3>
<p>가장 최근 N개의 메시지만 AI에게 전달하는 방식이다. 전체 대화는 DB에 저장하되, AI에게는 최근 것만 보여준다.</p>
<pre><code>전체 히스토리: [1][2][3]...[98][99][100]
AI에게 전달:                  [91][92]...[99][100] + [새 질문]</code></pre><pre><code class="language-java">private List&lt;Message&gt; getRecentMessages(List&lt;Message&gt; history, int max) {
    if (history.size() &lt;= max) return history;
    return history.subList(history.size() - max, history.size()); // 최근 N개만
}</code></pre>
<p>구현이 단순하고 토큰 사용량이 일정하게 유지된다는 장점이 있다. 다만 오래된 맥락이 잘릴 수 있다.</p>
<h3 id="전략-b-요약-기반-압축-summarization">전략 B: 요약 기반 압축 (Summarization)</h3>
<p>오래된 대화를 AI를 통해 짧게 요약하고, 최근 대화만 원본으로 유지하는 방식이다.</p>
<pre><code>[오래된 대화 20개] → AI가 요약 → &quot;유저는 Spring AI에 대해 물어봤고, 감정 분석 API 구현을 논의했다.&quot;
[최근 대화 10개] → 원본 유지</code></pre><pre><code class="language-java">String summary = chatClient.prompt()
    .user(&quot;다음 대화를 200자 이내로 요약해: &quot; + convertToText(oldHistory))
    .call().content();

// 요약본을 System Message로 교체
optimized.add(new SystemMessage(&quot;이전 대화 요약: &quot; + summary));
optimized.addAll(recentHistory);</code></pre>
<p>토큰 절약 효과가 크지만, 요약 과정에서 중요한 세부 정보가 손실될 수 있다.</p>
<h3 id="전략-c-중요도-기반-필터링-smart-filtering">전략 C: 중요도 기반 필터링 (Smart Filtering)</h3>
<p>모든 메시지를 동등하게 취급하지 않고, 비즈니스적으로 중요한 메시지를 선별하여 보내는 방식이다.</p>
<pre><code class="language-java">private boolean isImportant(Message msg) {
    String content = msg.getText().toLowerCase();
    // 도메인에 따라 중요 키워드가 달라진다
    return content.contains(&quot;결제&quot;) || content.contains(&quot;주소&quot;) || content.length() &gt; 100;
}</code></pre>
<p>이 전략의 핵심 난이도는 구현 자체가 아니다. <strong>&quot;무엇이 중요한가&quot;를 정의하는 것</strong>이 어렵다. 쇼핑몰이라면 결제/배송이 중요하고, 의료 챗봇이라면 증상/약 이름이 중요하다. 도메인마다 기준이 완전히 달라지기 때문에, 중요도 정의 설계가 곧 이 전략의 성패를 가른다.</p>
<table>
<thead>
<tr>
<th>전략</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>슬라이딩 윈도우</td>
<td>구현 단순, 비용 예측 가능</td>
<td>오래된 맥락 손실</td>
</tr>
<tr>
<td>요약 기반</td>
<td>토큰 절약 효과 큼</td>
<td>세부 정보 손실 가능</td>
</tr>
<tr>
<td>중요도 기반</td>
<td>핵심 정보 보존</td>
<td>중요도 정의가 어려움</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-토큰은-곧-비용이다">4. 토큰은 곧 비용이다</h2>
<p>LLM API는 토큰 단위로 과금한다. 토큰은 AI가 텍스트를 처리하는 기본 단위로, 영어는 약 0.75단어당 1토큰, 한글은 한 글자당 약 1~2토큰이다.</p>
<p>과금 구조는 입력과 출력으로 나뉜다.</p>
<pre><code>총 비용 = (입력 토큰 수 × 입력 단가) + (출력 토큰 수 × 출력 단가)</code></pre><p>주의할 점은 출력 단가가 입력 단가보다 약 3~5배 비싸다는 것이다. 짧고 명확한 답변을 유도하는 것이 비용 측면에서도 중요하다.</p>
<p>Spring AI에서는 <code>ChatResponse</code>에서 토큰 사용량을 확인할 수 있다.</p>
<pre><code class="language-java">ChatResponse response = chatClient.prompt()
    .user(question)
    .call()
    .chatResponse();

var usage = response.getMetadata().getUsage();
log.info(&quot;입력: {}, 출력: {}, 합계: {}&quot;,
    usage.getPromptTokens(),
    usage.getCompletionTokens(),
    usage.getTotalTokens());</code></pre>
<hr>
<h2 id="5-토큰-최적화-전략">5. 토큰 최적화 전략</h2>
<h3 id="system-message를-짧게">System Message를 짧게</h3>
<p>System Message는 <strong>모든 요청마다 포함</strong>된다. 한 번 줄이면 호출 횟수만큼 곱해서 절약된다.</p>
<pre><code class="language-java">// Before: 약 150 토큰
&quot;당신은 매우 친절하고 상냥하며 사용자를 배려하는 AI 어시스턴트입니다.
 사용자의 질문에 항상 정중하고 예의바르게 답변해야 하며...&quot;

// After: 약 15 토큰
&quot;친절한 AI 어시스턴트. 명확하고 간결하게 답변.&quot;</code></pre>
<p>1000회 호출 기준으로 135,000 토큰 절약, 약 90% 감소 효과다.</p>
<h3 id="maxtokens를-동적으로-설정">maxTokens를 동적으로 설정</h3>
<p>모든 질문에 동일한 maxTokens를 설정하면 낭비가 생긴다. 질문의 의도에 따라 필요한 만큼만 할당한다.</p>
<pre><code class="language-java">private int determineMaxTokens(String question) {
    String q = question.toLowerCase();

    if (q.contains(&quot;코드&quot;) || q.contains(&quot;구현&quot;)) return 2000; // 코드 생성
    if (q.contains(&quot;설명&quot;) || q.contains(&quot;알려줘&quot;))  return 500;  // 정보 요약
    if (question.length() &lt; 30)                      return 100;  // 단답형
    return 1000;                                                   // 기본값
}</code></pre>
<p>단답형 질문 100개 처리 시, 고정 방식(maxTokens=2000) 대비 동적 방식(maxTokens=100)은 약 95% 비용 절감 효과를 기대할 수 있다.</p>
<hr>
<h2 id="6-스트리밍-응답-streaming">6. 스트리밍 응답 (Streaming)</h2>
<p>AI가 답변을 생성하는 데 10초가 걸린다고 가정해보자.</p>
<ul>
<li><code>call()</code> 방식: 10초가 지난 뒤 완성된 답변을 한 번에 전달한다. 사용자는 10초 동안 아무것도 볼 수 없다.</li>
<li><code>stream()</code> 방식: 단어가 생성되는 즉시 실시간으로 전달한다. 사용자는 첫 단어부터 바로 읽기 시작한다.</li>
</ul>
<p>실제 처리 시간은 동일하지만 체감이 완전히 달라진다. ChatGPT나 Claude가 타이핑하듯 답변을 보여주는 것이 바로 이 방식이다.</p>
<p>Spring AI에서 스트리밍은 <code>Flux&lt;String&gt;</code>과 SSE를 조합해서 구현한다.</p>
<pre><code class="language-java">// Controller
@PostMapping(value = &quot;/stream&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux&lt;String&gt; streamChat(@RequestBody ContextChatRequest request) {
    return chatService.chatStream(request.getMessage());
}

// Service
public Flux&lt;String&gt; chatStream(String question) {
    return chatClient.prompt()
        .user(question)
        .stream()   // call() 대신 stream() 사용
        .content();
}</code></pre>
<p>여기서 두 가지 핵심 개념이 등장한다.</p>
<p><strong>Flux&lt;String&gt;</strong></p>
<p>Project Reactor 라이브러리의 타입으로, 데이터 조각이 시간 순서대로 흘러오는 스트림을 표현한다. <code>String</code>이 완성된 답변 하나를 담는다면, <code>Flux&lt;String&gt;</code>은 조각들이 컨베이어 벨트처럼 계속 흘러오는 구조다.</p>
<pre><code>String       : [완성된 전체 답변] → 한 번에 전달
Flux&lt;String&gt; : [안] [녕] [하] [세] [요] → 생성될 때마다 즉시 전달</code></pre><p>Reactor에는 두 가지 핵심 타입이 있다.</p>
<pre><code>Mono&lt;T&gt; : 0개 또는 1개의 데이터를 비동기로 처리 (call() 방식에 대응)
Flux&lt;T&gt; : 0개 ~ N개의 데이터를 비동기로 처리 (stream() 방식에 대응)</code></pre><p><strong>SSE (Server-Sent Events)</strong></p>
<p><code>TEXT_EVENT_STREAM_VALUE</code>는 HTTP 통신 방식 중 SSE를 의미한다. 일반 HTTP와의 차이는 연결 유지 방식에 있다.</p>
<pre><code>일반 HTTP: 클라이언트 요청 → 서버 응답 → 연결 끊김 (1회성)
SSE:       클라이언트 요청 → 서버가 연결을 유지하며 데이터를 계속 흘려보냄</code></pre><p>비슷한 기술로 WebSocket이 있지만, AI 스트리밍에는 SSE가 더 적합하다. AI 응답은 서버에서 클라이언트로 <strong>단방향</strong>으로만 흐르면 충분하기 때문이다. WebSocket은 양방향 통신이 필요한 경우(채팅, 게임 등)에 사용한다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>방향</th>
<th>연결 유지</th>
<th>사용 사례</th>
</tr>
</thead>
<tbody><tr>
<td>일반 HTTP</td>
<td>단방향</td>
<td>X</td>
<td>일반 API</td>
</tr>
<tr>
<td>SSE</td>
<td>단방향 (서버 → 클라이언트)</td>
<td>O</td>
<td>AI 스트리밍, 알림</td>
</tr>
<tr>
<td>WebSocket</td>
<td>양방향</td>
<td>O</td>
<td>실시간 채팅, 게임</td>
</tr>
</tbody></table>
<hr>
<h2 id="7-structured-output으로-응답-형식-강제하기">7. Structured Output으로 응답 형식 강제하기</h2>
<p>감정 분석 API를 만든다고 가정해보자. System Message로 이렇게 지시할 수 있다.</p>
<pre><code class="language-java">return chatClient.prompt()
    .system(&quot;positive, neutral, negative 중 하나로만 답변하세요.&quot;)
    .user(text)
    .call()
    .content();</code></pre>
<p>문제는 아무리 강하게 지시해도 AI가 다른 형태로 응답할 가능성이 항상 존재한다는 것이다.</p>
<pre><code>&quot;positive&quot;              &lt;- 정상
&quot;This is positive.&quot;     &lt;- 문장으로 옴
&quot;긍정적입니다&quot;           &lt;- 한국어로 옴
&quot;POSITIVE&quot;              &lt;- 대소문자가 다름</code></pre><p>이를 문자열 파싱으로 방어하면 예외 케이스가 생길 때마다 방어 코드를 계속 추가해야 한다.</p>
<pre><code class="language-java">// 문자열 파싱 방식 - 방어 로직이 계속 늘어난다
String result = response.toLowerCase().trim();
if (result.contains(&quot;positive&quot;)) return &quot;positive&quot;;
if (result.contains(&quot;negative&quot;)) return &quot;negative&quot;;
return &quot;neutral&quot;;</code></pre>
<p>더 나은 방법은 <strong>Structured Output</strong>이다. AI가 처음부터 Java 객체 형태로 응답하게 만드는 방식으로, 응답 구조가 항상 동일할 때 특히 효과적이다.</p>
<pre><code class="language-java">// 응답 구조를 클래스로 정의
public record SentimentResult(
    String sentiment,   // positive / neutral / negative
    double confidence   // 0.0 ~ 1.0
) {}

// Before: String으로 받아서 파싱
String raw = chatClient.prompt().user(text).call().content();
// 파싱 로직 필요...

// After: Java 객체로 바로 받기
SentimentResult result = chatClient.prompt()
    .user(text)
    .call()
    .entity(SentimentResult.class); // 구조를 강제

result.sentiment();   // &quot;positive&quot;
result.confidence();  // 0.95</code></pre>
<p><code>entity()</code>를 사용하면 Spring AI가 내부적으로 AI에게 JSON 형식으로 응답하도록 지시하고, 응답을 자동으로 지정한 클래스로 변환해준다. 파싱 로직도, 방어 코드도 필요 없다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>LLM은 기억이 없고, 히스토리 관리와 토큰 최적화는 실무에서 반드시 고려해야 할 설계 문제다. 스트리밍으로 사용자 체감 속도를 높이고, Structured Output으로 응답 형식을 안정적으로 제어하면 완성도 높은 AI 서비스를 만들 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring AI] 시작하기]]></title>
            <link>https://velog.io/@furaha_dev/Spring-AI-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@furaha_dev/Spring-AI-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 03 Apr 2026 14:13:19 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지금까지 IoC/DI, Bean, Spring MVC 흐름을 익히면서 Spring이 &quot;복잡한 것을 추상화해서 개발자가 비즈니스 로직에 집중하게 해준다&quot;는 철학을 반복해서 봐왔다. 오늘은 그 철학이 AI 영역에서 어떻게 적용되는지를 다룬다.</p>
<p>이번 글을 읽으면서 스스로에게 던져볼 질문이 있다.</p>
<ul>
<li>AI 기능을 추가할 때 API Key는 어디에 두어야 하는가?</li>
<li>OpenAI에서 Gemini로 모델을 바꾸려면 코드를 얼마나 고쳐야 하는가?</li>
<li>AI 응답을 Java 객체로 어떻게 안전하게 받아낼 수 있는가?</li>
</ul>
<hr>
<h2 id="1-왜-백엔드에서-ai를-호출해야-하는가">1. 왜 백엔드에서 AI를 호출해야 하는가</h2>
<p>가장 단순한 방법은 프론트엔드에서 직접 AI API를 호출하는 것이다. 코드도 짧고, 백엔드를 거치지 않으니 빠르다. 그런데 이 방식에는 치명적인 문제가 있다.</p>
<pre><code class="language-tsx">// ❌ 브라우저에서 직접 호출
fetch(&#39;https://api.openai.com/v1/chat/completions&#39;, {
  headers: { &#39;Authorization&#39;: &#39;Bearer sk-proj-abc123...&#39; } // API Key 노출
})</code></pre>
<p>브라우저 개발자 도구의 Network 탭을 열면 API Key가 그대로 보인다. 누구든 이 키를 복사해서 쓸 수 있고, 그 비용은 키 소유자가 전부 부담하게 된다. 실제로 GitHub에 키를 실수로 올렸다가 몇 시간 만에 수천 달러가 청구된 사례가 적지 않다.</p>
<p>해결책은 단순하다. AI API 호출을 백엔드로 옮기는 것이다.</p>
<pre><code>브라우저 → Spring Boot → AI API
               ↓
          API Key 안전 보관
          인증/인가 체크
          사용량 제한
          프롬프트 가공</code></pre><p>브라우저는 우리 서버만 알면 되고, AI API Key는 서버 내부(환경 변수)에서만 존재한다. 인증된 사용자만 AI를 쓸 수 있고, 하루 호출 횟수도 제한할 수 있다.</p>
<hr>
<h2 id="2-spring-ai가-해결하는-문제">2. Spring AI가 해결하는 문제</h2>
<p>AI를 백엔드에서 직접 연동하면 또 다른 문제가 생긴다. OpenAI, Gemini, Claude는 각자 API 명세가 다르다. 오늘 OpenAI로 짠 코드는 내일 Gemini로 바꾸는 순간 대부분 다시 써야 한다.</p>
<p>이전에 DI/IoC를 공부하면서 배운 내용이 여기서 그대로 적용된다. 구현체가 바뀌어도 호출하는 쪽 코드가 바뀌지 않으려면 Interface가 필요하다.</p>
<p>Spring AI가 정확히 이걸 한다.</p>
<pre><code>ChatClient (Interface)
      ↑
OpenAIImpl   GeminiImpl   ClaudeImpl</code></pre><p>개발자는 <code>ChatClient</code>만 바라보고 코드를 짠다. 모델을 바꾸고 싶으면 <code>application.yml</code>의 설정값 하나만 수정하면 끝이다. 비즈니스 로직은 손댈 필요가 없다.</p>
<hr>
<h2 id="3-chatclient-기본-사용법">3. ChatClient 기본 사용법</h2>
<p>Spring AI의 모든 AI 호출은 <code>ChatClient</code>에서 시작한다. Fluent API 방식으로 체이닝해서 사용한다.</p>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/api/ai&quot;)
public class AiController {

    private final ChatClient chatClient;

    public AiController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @PostMapping(&quot;/chat&quot;)
    public String chat(@RequestBody String message) {
        return chatClient.prompt()
            .user(message)   // 사용자 질문
            .call()          // AI 호출
            .content();      // String으로 응답 추출
    }
}</code></pre>
<p>각 메서드의 역할은 명확하다.</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>.prompt()</code></td>
<td>대화 시작</td>
</tr>
<tr>
<td><code>.user()</code></td>
<td>사용자 질문 설정</td>
</tr>
<tr>
<td><code>.call()</code></td>
<td>AI 모델 호출</td>
</tr>
<tr>
<td><code>.content()</code></td>
<td>응답을 String으로 추출</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-system-message와-prompt-template">4. System Message와 Prompt Template</h2>
<h3 id="system-message--ai에게-배역을-준다">System Message — AI에게 배역을 준다</h3>
<p>기본 <code>ChatClient</code>는 AI가 어떤 역할인지 모른다. 고객센터 챗봇을 만들었는데 AI가 갑자기 날씨 얘기를 할 수도 있다. <code>.system()</code>으로 AI의 성격과 행동 지침을 고정한다.</p>
<pre><code class="language-java">chatClient.prompt()
    .system(&quot;당신은 스파르타 몰의 친절한 쇼핑 어시스턴트입니다. 항상 존댓말을 사용하세요.&quot;)
    .user(message)
    .call()
    .content();</code></pre>
<p>System Message는 사용자 메시지보다 우선순위가 높다. 사용자가 어떤 질문을 해도 AI는 설정된 페르소나를 유지한다.</p>
<h3 id="prompt-template--뼈대와-데이터를-분리한다">Prompt Template — 뼈대와 데이터를 분리한다</h3>
<p>동적인 값이 들어가는 프롬프트를 문자열 연결로 만들면 금방 관리하기 어려워진다.</p>
<pre><code class="language-java">// ❌ 문자열 직접 조합
.user(&quot;상품명 &quot; + productName + &quot;의 마케팅 문구를 작성해줘. 특징: &quot; + features)</code></pre>
<p>Prompt Template은 뼈대와 데이터를 분리한다.</p>
<pre><code class="language-java">// ✅ Prompt Template 사용
String template = &quot;&quot;&quot;
    상품명 {productName}의 마케팅 문구를 작성해줘.
    특징: {features}
    조건: 감성적이고 100자 이내로.
    &quot;&quot;&quot;;

chatClient.prompt()
    .user(u -&gt; u.text(template)
        .param(&quot;productName&quot;, productName)
        .param(&quot;features&quot;, features))
    .call()
    .content();</code></pre>
<p><code>{변수명}</code> 플레이스홀더에 <code>.param()</code>으로 값을 주입한다. 프롬프트 구조가 바뀌어도 Java 코드를 건드릴 필요가 없다.</p>
<p>두 개념을 정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>System Message</th>
<th>Prompt Template</th>
</tr>
</thead>
<tbody><tr>
<td>역할</td>
<td>AI 역할 고정</td>
<td>동적 데이터 삽입</td>
</tr>
<tr>
<td>변화</td>
<td>고정적</td>
<td>매번 바뀜</td>
</tr>
<tr>
<td>비유</td>
<td>배우의 배역</td>
<td>대본의 빈칸</td>
</tr>
</tbody></table>
<p>실제 서비스에서는 둘을 함께 쓸 때 가장 강력하다.</p>
<hr>
<h2 id="5-structured-output--응답을-java-객체로-받는다">5. Structured Output — 응답을 Java 객체로 받는다</h2>
<p>AI 응답을 String으로 받으면 문제가 생긴다. 점수, 감정, 요약을 각각 꺼내 쓰려면 파싱이 필요한데, AI는 매번 형식이 달라진다.</p>
<pre><code>&quot;이 리뷰는 긍정적입니다. 점수는 8점이고, 품질이 우수합니다.&quot;
&quot;굳이 따지자면 8점 정도고, 나쁘지 않네요.&quot;</code></pre><p>정규식으로 파싱하면 AI가 조금만 다르게 답해도 로직이 망가진다. 타입 안정성도 없다.</p>
<p>Spring AI의 <code>.entity()</code>는 이 문제를 한 줄로 해결한다.</p>
<pre><code class="language-java">// DTO 정의
@Getter
@NoArgsConstructor
public class ProductAnalysis {
    String sentiment; // positive / neutral / negative
    int score;        // 1 ~ 10
    String summary;
}

// 자동 파싱
ProductAnalysis result = chatClient.prompt()
    .user(&quot;이 리뷰 분석해줘: &quot; + review)
    .call()
    .entity(ProductAnalysis.class); // JSON 스키마 자동 생성 + 파싱

int score = result.getScore(); // 바로 사용 가능</code></pre>
<p>내부적으로 Spring AI가 DTO 구조를 JSON 스키마로 변환해서 AI에게 전달하고, 응답을 다시 Java 객체로 파싱해준다. 개발자는 DTO 클래스만 정의하면 된다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>Spring AI는 Spring이 늘 해왔던 일을 AI 영역에서 그대로 한다. 복잡한 것을 추상화하고, 개발자가 비즈니스 로직에 집중하게 해준다. <code>ChatClient</code> 인터페이스 하나로 모델을 교체하고, System Message로 AI 역할을 고정하고, <code>.entity()</code>로 응답을 안전하게 받아낸다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 트랜잭션과 영속성 컨텍스트 — JPA가 save() 없이도 DB를 업데이트하는 비밀]]></title>
            <link>https://velog.io/@furaha_dev/Spring-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8-JPA%EA%B0%80-save-%EC%97%86%EC%9D%B4%EB%8F%84-DB%EB%A5%BC-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%ED%95%98%EB%8A%94-%EB%B9%84%EB%B0%80</link>
            <guid>https://velog.io/@furaha_dev/Spring-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8-JPA%EA%B0%80-save-%EC%97%86%EC%9D%B4%EB%8F%84-DB%EB%A5%BC-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%ED%95%98%EB%8A%94-%EB%B9%84%EB%B0%80</guid>
            <pubDate>Sun, 22 Mar 2026 04:15:05 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서 JPA의 기본 구조와 엔티티 매핑, 그리고 <code>@Transactional</code>의 존재를 처음 만났다. 그런데 코드를 짜다 보면 이런 의문이 생긴다.</p>
<ul>
<li><code>product.decreaseStock()</code>만 호출했는데 왜 DB에 UPDATE가 날아가지?</li>
<li><code>save()</code>를 안 불렀는데 어떻게 변경사항이 반영되는 거지?</li>
<li>Flush와 Commit은 뭐가 다른 거지?</li>
</ul>
<p>이번 글에서는 이 세 가지 질문에 답하면서, JPA의 핵심 엔진인 영속성 컨텍스트가 어떻게 동작하는지 파헤쳐본다.</p>
<hr>
<h2 id="1-트랜잭션--전부-아니면-전무">1. 트랜잭션 — &quot;전부 아니면 전무&quot;</h2>
<p>트랜잭션은 여러 DB 작업을 하나의 논리적 단위로 묶는 안전장치다. 계좌 이체를 예로 들면 이해가 쉽다.</p>
<pre><code>A 계좌 -1만원  →  성공
B 계좌 +1만원  →  시스템 장애 발생</code></pre><p>트랜잭션이 없다면 A의 돈은 사라졌는데 B는 못 받는 최악의 상황이 생긴다. 트랜잭션은 이걸 막기 위해 &quot;두 작업이 모두 성공하거나, 하나라도 실패하면 전부 없던 일로 만든다&quot;는 것을 보장한다.</p>
<p>이 보장을 <strong>ACID 원칙</strong>이라고 부른다.</p>
<table>
<thead>
<tr>
<th>원칙</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>Atomicity (원자성)</td>
<td>전부 성공 아니면 전부 실패</td>
</tr>
<tr>
<td>Consistency (일관성)</td>
<td>트랜잭션 전후 데이터 규칙이 유지됨</td>
</tr>
<tr>
<td>Isolation (격리성)</td>
<td>트랜잭션끼리 서로 간섭하지 않음</td>
</tr>
<tr>
<td>Durability (지속성)</td>
<td>커밋된 결과는 영구 저장됨</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-transactional--proxy가-앞뒤를-감싼다">2. @Transactional — Proxy가 앞뒤를 감싼다</h2>
<p>Spring에서는 <code>@Transactional</code> 어노테이션 하나로 트랜잭션을 관리한다. 내부적으로는 <strong>Proxy</strong>라는 가상의 대리 객체가 메서드 앞뒤를 감싸서 동작한다.</p>
<p>연예인과 매니저로 비유하면 이렇다. 연예인(실제 Service 객체)한테 직접 연락하는 게 아니라, 매니저(Proxy)를 통해 연락한다. 매니저는 일정 시작 전에 &quot;트랜잭션 시작&quot;, 일정이 끝나면 &quot;Commit&quot;, 문제가 생기면 &quot;전부 취소(Rollback)&quot;를 처리한다.</p>
<pre><code class="language-java">// Spring이 내부적으로 하는 일
try {
    beginTransaction();   // 트랜잭션 시작
    placeOrder(request);  // 실제 메서드 실행
    commit();             // 성공 → 커밋
} catch (RuntimeException e) {
    rollback();           // 실패 → 롤백
}</code></pre>
<p>한 가지 주의할 점이 있다. <code>@Transactional</code>은 기본적으로 <strong>RuntimeException</strong>에만 롤백한다. Checked Exception(IOException 등)은 롤백하지 않는다.</p>
<pre><code class="language-java">// RuntimeException → 롤백 O
@Transactional
public void placeOrder() {
    throw new IllegalArgumentException(&quot;재고 부족&quot;); // 롤백됨
}

// Checked Exception → 기본적으로 롤백 X
@Transactional
public void placeOrder() throws IOException {
    throw new IOException(&quot;파일 없음&quot;); // 롤백 안됨!
}

// Checked Exception도 롤백하려면
@Transactional(rollbackFor = Exception.class)
public void placeOrder() throws IOException {
    throw new IOException(&quot;파일 없음&quot;); // 롤백됨
}</code></pre>
<hr>
<h2 id="3-영속성-컨텍스트--jpa의-장바구니">3. 영속성 컨텍스트 — JPA의 장바구니</h2>
<p>영속성 컨텍스트는 애플리케이션과 DB 사이의 <strong>중간 저장소</strong>다. 마트에서 장을 볼 때 물건을 집을 때마다 계산대로 달려가지 않고 장바구니에 담아뒀다가 한 번에 계산하는 것과 같다.</p>
<p>엔티티는 영속성 컨텍스트와의 관계에 따라 4가지 상태를 가진다.</p>
<pre><code>비영속 (Transient)  → 장바구니에 안 담긴 상태  ( new Product() )
영속   (Managed)    → 장바구니에 담긴 상태     ( findById(), persist() )
준영속 (Detached)   → 장바구니에서 꺼낸 상태   ( detach(), close() )
삭제   (Removed)    → &quot;이거 빼주세요&quot; 한 상태   ( remove() )</code></pre><p>중요한 건 <strong>모든 마법은 영속 상태에서만 일어난다</strong>는 점이다.</p>
<hr>
<h2 id="4-dirty-checking--save-없이도-update가-날아가는-이유">4. Dirty Checking — save() 없이도 UPDATE가 날아가는 이유</h2>
<p>영속성 컨텍스트에 엔티티가 들어오는 순간, JPA는 엔티티 말고 <strong>Snapshot(원본 복사본)</strong>을 하나 더 저장해둔다.</p>
<pre><code>[영속성 컨텍스트]
  └ [1차 캐시]
      └ Key: 1L
        ├ Entity:   Product(id=1, stock=100)  ← 코드에서 다루는 객체
        └ Snapshot: {stock=100}               ← JPA가 들고 있는 원본 사진</code></pre><p>트랜잭션이 끝나는 시점에 JPA는 Entity와 Snapshot을 필드별로 비교한다. 달라진 점이 있으면 UPDATE 쿼리를 자동으로 생성한다. 이게 <strong>Dirty Checking</strong>이다.</p>
<pre><code class="language-java">@Transactional
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findById(productId).get();
    // Entity: stock=90, Snapshot: stock=100 → 차이 감지
    product.decreaseStock(quantity);
    // save() 호출 없음. Dirty Checking이 알아서 UPDATE 생성
}</code></pre>
<p>Before / After로 비교하면 차이가 명확하다.</p>
<pre><code class="language-java">// Before: JPA 없이 직접 UPDATE
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findById(productId).get();
    product.decreaseStock(quantity);
    productRepository.save(product); // 직접 save() 호출 필요
}

// After: Dirty Checking 활용
@Transactional
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findById(productId).get();
    product.decreaseStock(quantity);
    // save() 불필요. 트랜잭션 종료 시 자동 반영
}</code></pre>
<hr>
<h2 id="5-flush--변경사항을-db에-밀어넣는-동기화-작업">5. Flush — 변경사항을 DB에 밀어넣는 동기화 작업</h2>
<p>Flush는 영속성 컨텍스트의 변경 내용을 DB에 전송하는 작업이다. 여기서 중요한 구분이 있다.</p>
<p><strong>Flush ≠ Commit</strong></p>
<p>Flush는 쿼리를 DB에 &quot;전송&quot;만 한다. Commit은 그 변경을 DB에 &quot;최종 확정&quot;한다. Flush 이후에도 Rollback이 일어나면 모든 변경은 취소된다.</p>
<p>Flush가 일어나는 시점은 세 가지다.</p>
<pre><code>1. 트랜잭션 Commit 직전   → Spring이 자동 호출
2. JPQL 쿼리 실행 직전    → JPA가 자동 호출
3. entityManager.flush()  → 개발자가 직접 호출</code></pre><p>2번이 특히 중요하다. Flush 없이 JPQL을 실행하면 데이터 불일치가 생긴다.</p>
<pre><code class="language-java">@Transactional
public void createAndFind() {
    Product product = new Product(&quot;키보드&quot;);
    productRepository.save(product);
    // 이 시점: 쓰기 지연 저장소에만 있고 DB에는 없음

    // JPQL 실행 직전 → Flush 자동 호출 → DB에 INSERT 먼저 반영
    List&lt;Product&gt; products = productRepository.findAllByName(&quot;키보드&quot;);
    // Flush 없었다면? DB에 키보드가 없어서 빈 리스트 반환
}</code></pre>
<p>3번은 DB 프로시저나 트리거를 호출해야 할 때처럼, 트랜잭션이 끝나기 전에 DB 반영이 필요한 특수한 상황에서 사용한다.</p>
<pre><code class="language-java">@Transactional
public void saveAndCallProcedure(Product product) {
    productRepository.save(product);
    productRepository.flush(); // DB에 즉시 INSERT 전송

    // 이제 DB에 데이터가 있으니 프로시저 정상 실행
    storedProcedureRepository.executeProcess(product.getId());
}</code></pre>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 글의 핵심은 딱 하나다. <strong>영속 상태의 엔티티는 JPA가 직접 관리하기 때문에, 개발자는 객체의 상태만 바꾸면 Dirty Checking → Flush → Commit 흐름이 자동으로 처리된다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Lombok, Validation, Service 설계, MapStruct, CRUD 흐름]]></title>
            <link>https://velog.io/@furaha_dev/Spring-Lombok-Validation-Service-%EC%84%A4%EA%B3%84-MapStruct-CRUD-%ED%9D%90%EB%A6%84</link>
            <guid>https://velog.io/@furaha_dev/Spring-Lombok-Validation-Service-%EC%84%A4%EA%B3%84-MapStruct-CRUD-%ED%9D%90%EB%A6%84</guid>
            <pubDate>Sat, 21 Mar 2026 07:39:53 GMT</pubDate>
            <description><![CDATA[<p>이전 글에서 Spring MVC 흐름, JPA, 영속성 컨텍스트까지 살펴봤다. 이번 글에서는 그 위에 실전 코드를 올린다.</p>
<p>다음 세 가지 질문을 중심으로 이야기를 풀어간다.</p>
<ul>
<li>반복적인 Getter/Setter/생성자 코드를 어떻게 없앨 수 있을까?</li>
<li>잘못된 데이터가 Service까지 도달하기 전에 어디서 막아야 할까?</li>
<li>Entity와 DTO 간 변환 코드가 수십 군데 흩어지는 문제를 어떻게 해결할까?</li>
</ul>
<hr>
<h2 id="1-lombok-반복-코드를-지우는-어노테이션">1. Lombok: 반복 코드를 지우는 어노테이션</h2>
<p>Java 클래스를 만들면 거의 자동으로 따라오는 코드들이 있다. Getter, Setter, 생성자, toString. 이 코드들은 비즈니스 로직과 직접적인 관련이 없다. 객체를 만들기 위한 준비 코드일 뿐이다.</p>
<p>Lombok은 그 준비 코드를 어노테이션 하나로 대신 써준다.</p>
<pre><code class="language-java">// Before Lombok
public class UserDto {
    private String name;
    private String email;

    public String getName() { return name; }
    public String getEmail() { return email; }
    public UserDto(String name, String email) { ... }
}</code></pre>
<pre><code class="language-java">// After Lombok
@Getter
@RequiredArgsConstructor
public class UserDto {
    private final String name;
    private final String email;
}</code></pre>
<h3 id="setter를-지양하는-이유">@Setter를 지양하는 이유</h3>
<p>Setter가 열려있으면 코드 어디서든 객체의 상태를 바꿀 수 있다.</p>
<pre><code class="language-java">User user = userRepository.findById(1L);
// ... 코드 100줄 ...
user.setStatus(&quot;DELETED&quot;); // 누가? 왜? 여기서?</code></pre>
<p>버그가 생겼을 때 &quot;이 값이 어디서 바뀐 거지?&quot;를 추적하는 게 매우 어려워진다. 실무에서는 &quot;객체는 한 번 만들어지면 가능한 한 바뀌지 않아야 한다&quot;는 불변성(Immutability) 원칙을 선호한다. Setter 대신 Builder를 사용하는 게 그 대안이다.</p>
<h3 id="builder-setter-없이-안전하게-객체-생성">@Builder: Setter 없이 안전하게 객체 생성</h3>
<p>생성자 방식의 문제는 명확하다. 파라미터 순서를 외워야 하고, 빠진 값이 있어도 컴파일 에러가 나지 않는다.</p>
<pre><code class="language-java">// 이게 뭔지 바로 알 수 있는가?
new User(&quot;홍길동&quot;, &quot;hong@email.com&quot;, &quot;010-1234-5678&quot;, &quot;ACTIVE&quot;, 25);</code></pre>
<p>Builder 패턴을 쓰면 필드 이름을 명시하며 값을 설정하기 때문에 훨씬 명확하다.</p>
<pre><code class="language-java">User user = User.builder()
    .name(&quot;홍길동&quot;)
    .email(&quot;hong@email.com&quot;)
    .phone(&quot;010-1234-5678&quot;)
    .status(&quot;ACTIVE&quot;)
    .age(25)
    .build();</code></pre>
<p><code>@Builder</code>는 클래스 또는 생성자에 붙일 수 있다. 두 방식의 차이는 &quot;얼마나 자유롭게 만들게 할 것인가&quot;에 있다.</p>
<table>
<thead>
<tr>
<th>대상</th>
<th>추천 방식</th>
</tr>
</thead>
<tbody><tr>
<td>DTO, Response 객체</td>
<td>클래스에 <code>@Builder</code> — 모든 필드를 자유롭게 채워야 하는 경우</td>
</tr>
<tr>
<td>Entity, 도메인 객체</td>
<td>생성자에 <code>@Builder</code> — 필수 값 강제, 생성자 안에 검증 로직 추가 가능</td>
</tr>
</tbody></table>
<p>생성자에 <code>@Builder</code>를 붙이면 검증 로직도 함께 넣을 수 있다.</p>
<pre><code class="language-java">@Builder
public Product(String name, double price) {
    if (price &lt; 0) throw new IllegalArgumentException(&quot;가격은 음수일 수 없습니다.&quot;);
    this.name = name;
    this.price = price;
}</code></pre>
<p>객체가 만들어지는 순간부터 올바른 상태를 보장할 수 있다.</p>
<h3 id="data는-쓰지-않는다">@Data는 쓰지 않는다</h3>
<p><code>@Data</code>는 <code>@Getter</code>, <code>@Setter</code>, <code>@ToString</code>, <code>@EqualsAndHashCode</code>, <code>@RequiredArgsConstructor</code>를 한 번에 묶은 어노테이션이다. 편리하지만 실무에서는 기피한다.</p>
<p>이유는 단순하다. <code>@Setter</code>가 딸려오기 때문이다. 거기에 JPA 엔티티에 <code>@ToString</code>을 사용하면 양방향 연관관계에서 순환 참조가 발생해 StackOverflowError로 앱이 죽는다.</p>
<pre><code class="language-java">// 이렇게 하면 Order.toString() -&gt; User.toString() -&gt; Order.toString() ... 무한 반복
@Entity @Data public class Order { @ManyToOne private User user; }
@Entity @Data public class User  { @OneToMany private List&lt;Order&gt; orders; }</code></pre>
<p>필요한 어노테이션만 명시적으로 조합해서 쓰는 것이 훨씬 안전하다.</p>
<pre><code>@Data            → 쓰지 않는다
@Setter          → 꼭 필요한 경우만
@Getter          → 자유롭게
@Builder         → 객체 생성에 적극 활용
@RequiredArgsConstructor → DI에 적극 활용</code></pre><hr>
<h2 id="2-validation-controller-진입점의-방어막">2. Validation: Controller 진입점의 방어막</h2>
<p>검증에는 두 종류가 있다.</p>
<table>
<thead>
<tr>
<th>계층</th>
<th>검증 종류</th>
</tr>
</thead>
<tbody><tr>
<td>Controller</td>
<td>형식 검증 — 이메일 형식이 맞는가? 값이 비어있지 않은가?</td>
</tr>
<tr>
<td>Service</td>
<td>비즈니스 검증 — 이미 가입된 이메일인가? 재고가 충분한가?</td>
</tr>
</tbody></table>
<p>형식이 잘못된 데이터는 Controller에서 바로 막는 게 맞다. Service까지 도달시킬 필요가 없다.</p>
<h3 id="동작-원리">동작 원리</h3>
<p>검증 규칙은 DTO 클래스의 필드에 선언한다. Controller에서는 <code>@Valid</code> 하나만 붙이면 된다.</p>
<pre><code class="language-java">// 규칙 선언 — DTO
public class UserRequest {
    @NotBlank(message = &quot;이름은 필수입니다.&quot;)
    @Size(min = 2, max = 50)
    private String name;

    @NotBlank
    @Email(message = &quot;이메일 형식이 아닙니다.&quot;)
    private String email;

    @Pattern(regexp = &quot;^010-\\d{4}-\\d{4}$&quot;)
    private String phone;
}</code></pre>
<pre><code class="language-java">// 실행 트리거 — Controller
@PostMapping
public ApiResponse&lt;UserResponse&gt; create(@Valid @RequestBody UserRequest request) {
    return ApiResponse.ok(userService.save(request)); // 검증은 이미 끝났다
}</code></pre>
<p>역할이 깔끔하게 분리된다.</p>
<pre><code>UserRequest → &quot;나는 이런 형식이어야 해&quot; (규칙 선언)
@Valid      → &quot;이 규칙대로 검증해줘&quot;   (실행 트리거)
Controller  → 비즈니스 로직에만 집중</code></pre><p><code>@Valid</code> 검증이 실패하면 Spring이 자동으로 <code>MethodArgumentNotValidException</code>을 발생시킨다. <code>GlobalExceptionHandler</code>가 이를 가로채 400 Bad Request 응답을 반환한다. Controller와 Service는 이 예외를 직접 처리할 필요가 없다.</p>
<h3 id="주요-어노테이션">주요 어노테이션</h3>
<table>
<thead>
<tr>
<th>어노테이션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@NotBlank</code></td>
<td>null + 빈 문자열 + 공백 전부 막음. 문자열 필드에 가장 안전</td>
</tr>
<tr>
<td><code>@Size(min, max)</code></td>
<td>문자열 길이, 컬렉션 크기 검증</td>
</tr>
<tr>
<td><code>@Min</code> / <code>@Max</code></td>
<td>숫자 최솟값 / 최댓값 검증</td>
</tr>
<tr>
<td><code>@Positive</code></td>
<td>양수만 허용</td>
</tr>
<tr>
<td><code>@Email</code></td>
<td>이메일 형식 검증</td>
</tr>
<tr>
<td><code>@Pattern(regexp)</code></td>
<td>정규식으로 형식 검증. 전화번호 등에 활용</td>
</tr>
<tr>
<td><code>@Future</code> / <code>@Past</code></td>
<td>날짜가 미래 / 과거인지 검증</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-service-계층-설계-get-vs-find-순환-참조">3. Service 계층 설계: get vs find, 순환 참조</h2>
<p>Service 계층은 애플리케이션의 두뇌다. 비즈니스 규칙을 처리하고, 계층 간 데이터를 가공한다.</p>
<h3 id="get-vs-find-메서드-이름이-계약이다">get vs find: 메서드 이름이 계약이다</h3>
<p>메서드 이름은 해당 메서드의 &quot;동작 계약&quot;을 나타낸다.</p>
<pre><code>get...  → &quot;반드시 찾아드립니다. 없으면 예외를 던집니다.&quot;
find... → &quot;찾아보겠습니다. 없을 수도 있으니 직접 처리하세요.&quot;</code></pre><pre><code class="language-java">// get → 없으면 예외
public User getUserById(Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -&gt; new DomainException(NOT_FOUND_USER));
}

// find → Optional 반환, 호출한 쪽에서 처리
public Optional&lt;User&gt; findUserByEmail(String email) {
    return userRepository.findByEmail(email);
}</code></pre>
<p>이메일 중복 확인은 <code>find</code>다. 없는 게 정상일 수 있기 때문이다. 로그인한 사용자 조회는 <code>get</code>이다. 반드시 존재해야 하기 때문이다.</p>
<h3 id="순환-참조-설계-문제의-신호">순환 참조: 설계 문제의 신호</h3>
<pre><code class="language-java">@Service
public class OrderService {
    private final UserService userService; // UserService 주입
}

@Service
public class UserService {
    private final OrderService orderService; // OrderService 주입
}</code></pre>
<p>Spring이 앱 시작 시점에 Bean을 생성하지 못한다. 서로가 서로를 기다리기 때문이다.</p>
<pre><code>OrderService 만들려면 → UserService 필요
UserService 만들려면  → OrderService 필요
OrderService 만들려면 → ...</code></pre><p>이는 단순히 기술적인 오류가 아니라 두 서비스의 책임이 명확히 분리되지 않았다는 설계 문제의 신호다. 해결책은 다른 도메인의 Service 대신 Repository를 직접 주입받는 것이다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final UserRepository userRepository; // UserService 대신 직접 주입

    public void create(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -&gt; new DomainException(NOT_FOUND_USER));
        // ...
    }
}</code></pre>
<hr>
<h2 id="4-mapstruct-객체-변환-자동화">4. MapStruct: 객체 변환 자동화</h2>
<p>계층을 분리하면 Entity, DTO, Request, Response 등 다양한 객체를 사용하게 된다. 이때 객체 간 변환 코드가 반드시 발생한다.</p>
<p>수동으로 작성하면 두 가지 문제가 생긴다. 첫째, Entity에 필드가 추가될 때마다 변환 코드가 있는 모든 곳을 찾아 수정해야 한다. 둘째, 실수로 필드 매핑을 누락해도 컴파일 에러가 나지 않는다.</p>
<p>MapStruct는 인터페이스만 정의하면 컴파일 시점에 구현체를 자동 생성해준다.</p>
<h3 id="기본-사용법">기본 사용법</h3>
<pre><code class="language-java">@Mapper(componentModel = &quot;spring&quot;)
public interface UserMapper {
    UserResponse toResponse(User user);
    User toEntity(UserRequest request);
}</code></pre>
<p>Service에서는 주입받아 한 줄로 사용한다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final UserMapper userMapper;

    public UserResponse getUser(Long id) {
        User user = getUserById(id);
        return userMapper.toResponse(user); // 한 줄로 끝
    }
}</code></pre>
<h3 id="필드-이름이-다를-때">필드 이름이 다를 때</h3>
<p>Entity와 DTO의 필드명이 다를 경우 <code>@Mapping</code>으로 규칙을 지정한다.</p>
<pre><code class="language-java">@Mapper(componentModel = &quot;spring&quot;)
public interface ProductMapper {
    @Mapping(target = &quot;name&quot;, source = &quot;productName&quot;) // productName -&gt; name
    ProductResponse toResponse(Product product);
}</code></pre>
<p>중첩 객체의 필드는 점(.)으로 경로를 지정한다.</p>
<pre><code class="language-java">@Mapping(target = &quot;username&quot;, source = &quot;user.name&quot;) // order.getUser().getName()
OrderResponse toResponse(Order order);</code></pre>
<hr>
<h2 id="5-crud-전체-흐름-요청부터-응답까지">5. CRUD 전체 흐름: 요청부터 응답까지</h2>
<p>지금까지 배운 모든 것을 사용자 등록(<code>POST /api/users</code>) 하나로 조립한다.</p>
<pre><code>Client (JSON)
      |
      v
DispatcherServlet -&gt; HandlerMapping -&gt; HandlerAdapter
      |
      v
Controller
  - @RequestBody  : JSON -&gt; UserRequest 변환
  - @Valid        : 형식 검증 (실패 시 400 Bad Request)
      |
      v
Service
  - findByEmail   : 이메일 중복 확인 (비즈니스 검증)
  - passwordEncoder: 비밀번호 암호화
  - userMapper    : DTO -&gt; Entity
      |
      v
Repository
  - save()        : DB 저장
      |
      v
Service
  - userMapper    : Entity -&gt; DTO
      |
      v
Controller
  - ApiResponse.ok : 201 Created 응답
      |
      v
Client (JSON)</code></pre><h3 id="controller">Controller</h3>
<pre><code class="language-java">@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/api/users&quot;)
public class UserController {
    private final UserService userService;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED) // 성공 시 201 반환
    public ApiResponse&lt;UserResponse&gt; create(@Valid @RequestBody UserRequest request) {
        return ApiResponse.ok(userService.save(request));
    }
}</code></pre>
<h3 id="service">Service</h3>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final UserMapper userMapper;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public UserResponse save(UserRequest request) {
        // 비즈니스 검증
        if (userRepository.findByEmail(request.getEmail()).isPresent()) {
            throw new DomainException(DUPLICATE_EMAIL);
        }

        String encodedPassword = passwordEncoder.encode(request.getPassword());
        User user = userMapper.toEntity(request, encodedPassword);
        return userMapper.toResponse(userRepository.save(user));
    }
}</code></pre>
<h3 id="repository">Repository</h3>
<pre><code class="language-java">public interface UserRepository extends JpaRepository&lt;User, Long&gt; {
    Optional&lt;User&gt; findByEmail(String email); // Query Method로 자동 구현
}</code></pre>
<p><code>JpaRepository</code>를 상속받으면 <code>save()</code>, <code>findById()</code>, <code>findAll()</code> 등 기본 CRUD 메서드가 이미 내장되어 있다. 추가로 필요한 쿼리만 메서드 이름 규칙에 맞게 선언하면 된다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>오늘 다룬 내용을 한 줄씩 정리한다.</p>
<table>
<thead>
<tr>
<th>개념</th>
<th>핵심</th>
</tr>
</thead>
<tbody><tr>
<td>Lombok</td>
<td>반복 코드를 어노테이션으로 자동 생성. <code>@Data</code>는 쓰지 않는다</td>
</tr>
<tr>
<td><code>@Builder</code></td>
<td>Setter 없이 필드 이름을 명시하며 안전하게 객체 생성</td>
</tr>
<tr>
<td>Validation</td>
<td>Controller 진입점에서 형식 검증. 규칙은 DTO 필드에 선언</td>
</tr>
<tr>
<td>Service 설계</td>
<td><code>get</code>/<code>find</code> 네이밍 계약, 순환 참조 방지로 계층 책임 분리</td>
</tr>
<tr>
<td>MapStruct</td>
<td>Entity ↔ DTO 변환을 컴파일 시점에 자동 생성</td>
</tr>
</tbody></table>
]]></description>
        </item>
    </channel>
</rss>