<?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 | AI Integration &amp; Product Minded</description>
        <lastBuildDate>Sat, 20 Jun 2026 08:22:17 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[[MSA] DDD패턴을 Hexagonal 폴더에 매핑하기]]></title>
            <link>https://velog.io/@furaha_dev/MSA-DDD%ED%8C%A8%ED%84%B4%EC%9D%84-Hexagonal-%ED%8F%B4%EB%8D%94%EC%97%90-%EB%A7%A4%ED%95%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@furaha_dev/MSA-DDD%ED%8C%A8%ED%84%B4%EC%9D%84-Hexagonal-%ED%8F%B4%EB%8D%94%EC%97%90-%EB%A7%A4%ED%95%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 20 Jun 2026 08:22:17 GMT</pubDate>
            <description><![CDATA[<h2 id="할-일">할 일</h2>
<p>지난 번에 폴더 구조를 잡았다. 이번엔 그 폴더 안을 실제로 채우기 전에, &quot;어떤 개념이 어디에 들어가는지&quot;를 먼저 정리했다. 코드를 짜다가 &quot;이게 어디 가야 하지?&quot;로 헤매는 시간을 줄이기 위해서다.</p>
<hr>
<h2 id="ddd-전술-패턴이란">DDD 전술 패턴이란</h2>
<p>DDD에는 전략 패턴과 전술 패턴이 있다.</p>
<p><strong>전략 패턴</strong>은 큰 그림이다. &quot;시스템을 어떻게 나눌 것인가&quot;를 다루는데 조직 구조나 팀 간 협업까지 영향을 주는 수준이라 개인 프로젝트에서 다 적용하면 설계 문서 쓰는 시간이 코딩보다 많아진다. 이번엔 생략했다.</p>
<p><strong>전술 패턴</strong>은 코드 수준이다. 실제로 클래스를 어떻게 구성할지에 관한 이야기다. 이번 단계에서 적용한 것들은 이렇다.</p>
<table>
<thead>
<tr>
<th>패턴</th>
<th>내가 만든 것</th>
<th>위치</th>
</tr>
</thead>
<tbody><tr>
<td>Entity</td>
<td><code>User</code> — 순수 자바, id로 동일성 판단</td>
<td><code>domain/model/</code></td>
</tr>
<tr>
<td>Repository (포트)</td>
<td><code>UserRepository</code> — 인터페이스</td>
<td><code>application/port/out/</code></td>
</tr>
<tr>
<td>UseCase</td>
<td><code>RegisterUseCase</code>, <code>GetUserUseCase</code></td>
<td><code>application/port/in/</code></td>
</tr>
<tr>
<td>Command</td>
<td><code>RegisterCommand</code> — 유스케이스 입력 모델</td>
<td><code>application/port/in/</code></td>
</tr>
<tr>
<td>Application Service</td>
<td><code>RegisterService</code> — 흐름 조율</td>
<td><code>application/service/</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="핵심-규칙-의존성-방향">핵심 규칙: 의존성 방향</h2>
<p>헥사고날에서 모든 결정의 기준은 이거다.</p>
<blockquote>
<p><strong>의존성은 항상 안쪽으로. 도메인은 아무것도 모른다.</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/furaha_dev/post/3e4cf923-085a-496e-8297-1a1bec395782/image.png" alt=""></p>
<p><code>User</code> 도메인 모델은 JPA도, Spring도, HTTP도 모른다. 순수 자바 객체다. 덕분에 DB를 H2에서 MySQL로 바꿔도, HTTP를 다른 방식으로 바꿔도 도메인은 건드릴 필요가 없다.</p>
<hr>
<h2 id="각-파일의-역할">각 파일의 역할</h2>
<h3 id="domainmodeluserjava">domain/model/User.java</h3>
<p>순수 자바 객체다. <code>@Entity</code>, <code>@Column</code> 같은 JPA 어노테이션이 없다. Lombok <code>@Getter</code>, <code>@Builder</code>는 써도 된다.</p>
<p>비즈니스 규칙은 여기에 있어야 한다. <code>setFollowerCount(n)</code>이 아니라 <code>incrementFollowerCount()</code>, <code>decrementFollowerCount()</code>처럼 의미 있는 메서드로 상태를 변경한다.</p>
<pre><code class="language-java">// 이렇게 하지 않는다
user.setFollowerCount(user.getFollowerCount() - 1);

// 이렇게 한다
user.decrementFollowerCount();  // 내부에서 Math.max(0, count - 1) 처리</code></pre>
<h3 id="applicationportinregisterusecasejava">application/port/in/RegisterUseCase.java</h3>
<p>컨트롤러가 &quot;회원 등록을 해달라&quot;고 요청할 수 있는 계약(인터페이스)이다. 컨트롤러는 이 인터페이스만 알고, 구현체(<code>RegisterService</code>)를 직접 알지 않는다.</p>
<p>유스케이스를 <code>UserUseCase</code> 하나로 합치지 않고 <code>RegisterUseCase</code>, <code>GetUserUseCase</code>로 나눈 이유는 인터페이스 분리 원칙(ISP) 때문이다. <code>UserController</code>가 회원가입만 필요하다면 <code>RegisterUseCase</code>만 의존하면 된다. <code>GetUserUseCase</code>가 바뀌어도 영향을 받지 않는다.</p>
<h3 id="applicationportinregistercommandjava">application/port/in/RegisterCommand.java</h3>
<p>유스케이스의 입력 모델이다. 웹 DTO(<code>RegisterRequest</code>)를 유스케이스에 그대로 넘기지 않는 이유가 여기 있다.</p>
<p>만약 <code>RegisterService</code>가 <code>RegisterRequest</code>를 직접 <code>import</code>하면 <code>application</code>이 <code>adapter</code>를 바라보게 된다. 의존성 방향이 안→바깥이 되어 헥사고날 규칙이 깨진다.</p>
<pre><code>RegisterRequest  →  웹 어댑터 소유 (adapter/in/dto)
RegisterCommand  →  application 소유 (application/port/in)</code></pre><p>변환(<code>RegisterRequest → RegisterCommand</code>)은 컨트롤러에서 한다. 방향이 바깥→안으로 유지된다.</p>
<p><code>Command</code>라는 이름은 DDD 관습에서 온다. 시스템 상태를 바꾸는 요청은 <code>Command</code>, 조회만 하는 요청은 <code>Query</code>라고 부른다.</p>
<pre><code class="language-java">// record를 쓰면 불변 데이터 캐리어를 간결하게 만들 수 있다
public record RegisterCommand(
        String email,
        String password,
        String username,
        String displayName
) {
    // compact constructor — 형식 검증
    public RegisterCommand {
        if (email == null || email.isBlank())
            throw new IllegalArgumentException(&quot;email is required&quot;);
    }
}</code></pre>
<h3 id="applicationportoutuserrepositoryjava">application/port/out/UserRepository.java</h3>
<p>서비스가 DB에 접근할 때 쓰는 아웃고잉 포트다. 인터페이스만 있고 구현은 없다.</p>
<pre><code class="language-java">public interface UserRepository {
    User save(User user);
    Optional&lt;User&gt; findById(String id);
    Optional&lt;User&gt; findByEmail(String email);
    Optional&lt;User&gt; findByUsername(String username);
    boolean existsByEmail(String email);
    boolean existsByUsername(String username);
}</code></pre>
<p><code>@Repository</code>도 없고 <code>JpaRepository</code> 상속도 없다. 완전히 순수한 자바 인터페이스다. 반환 타입이 도메인 <code>User</code>인 것도 포인트다. JPA 엔티티(<code>UserJpaEntity</code>)가 밖으로 나오지 않는다.</p>
<h3 id="applicationserviceregisterservicejava">application/service/RegisterService.java</h3>
<p>유스케이스를 구현한다. <code>RegisterUseCase</code> 인터페이스를 구현하고, <code>UserRepository</code> 인터페이스에 의존한다. 구현체(<code>UserPersistenceAdapter</code>)를 직접 알지 않는다.</p>
<p>흐름 조율이 여기서 일어난다. &quot;이메일 중복 확인 → 도메인 객체 생성 → 저장&quot;처럼. 트랜잭션도 여기에 <code>@Transactional</code>로 건다.</p>
<h3 id="adapteroutpersistenceuserjpaentityjava">adapter/out/persistence/UserJpaEntity.java</h3>
<p>JPA 기술에 종속된 객체다. <code>@Entity</code>, <code>@Table</code>, <code>@Column</code>이 여기에만 있다. 도메인 <code>User</code>와 분리되어 있고, 변환 책임을 어댑터가 담당한다.</p>
<p><code>UserEntity</code>가 아니라 <code>UserJpaEntity</code>로 이름 붙인 이유는 &quot;이건 JPA에 종속된 객체다&quot;를 이름으로 드러내기 위해서다. 나중에 MongoDB로 일부를 바꾼다면 <code>UserMongoEntity</code>가 추가되는 식으로 확장된다.</p>
<h3 id="adapteroutpersistenceuserpersistenceadapterjava">adapter/out/persistence/UserPersistenceAdapter.java</h3>
<p><code>UserRepository</code> 인터페이스를 실제로 구현한다. <code>UserJpaRepository</code>를 호출하고, 도메인 ↔ JPA 엔티티 변환을 담당한다.</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class UserPersistenceAdapter implements UserRepository {

    private final UserJpaRepository userJpaRepository;

    @Override
    public User save(User user) {
        UserJpaEntity entity = toJpaEntity(user);
        return toDomain(userJpaRepository.save(entity));
    }

    @Override
    public Optional&lt;User&gt; findByEmail(String email) {
        return userJpaRepository.findByEmail(email).map(this::toDomain);
    }

    private UserJpaEntity toJpaEntity(User user) { ... }
    private User toDomain(UserJpaEntity entity) { ... }
}</code></pre>
<p>변환 로직(<code>toJpaEntity</code>, <code>toDomain</code>)은 이 클래스의 <code>private</code> 메서드에만 있다. 도메인 객체와 JPA 엔티티가 서로를 알 필요가 없고, 변환이 한 곳에만 있어서 수정할 때 어댑터만 건드리면 된다.</p>
<hr>
<h2 id="정리--convention">정리- Convention</h2>
<p>이 단계에서 확립한 네이밍 규칙이다. 일관성이 있으면 파일 찾기가 쉬워진다.</p>
<table>
<thead>
<tr>
<th>역할</th>
<th>네이밍</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>인커밍 포트</td>
<td><code>*UseCase</code></td>
<td><code>RegisterUseCase</code></td>
</tr>
<tr>
<td>아웃고잉 포트</td>
<td><code>*Repository</code></td>
<td><code>UserRepository</code></td>
</tr>
<tr>
<td>어댑터</td>
<td><code>*Adapter</code></td>
<td><code>UserPersistenceAdapter</code></td>
</tr>
<tr>
<td>DB 엔티티</td>
<td><code>*JpaEntity</code></td>
<td><code>UserJpaEntity</code></td>
</tr>
<tr>
<td>Spring Data 인터페이스</td>
<td><code>*JpaRepository</code></td>
<td><code>UserJpaRepository</code></td>
</tr>
<tr>
<td>유스케이스 입력 모델</td>
<td><code>*Command</code></td>
<td><code>RegisterCommand</code></td>
</tr>
<tr>
<td>웹 입력 DTO</td>
<td><code>*Request</code></td>
<td><code>RegisterRequest</code></td>
</tr>
<tr>
<td>웹 출력 DTO</td>
<td><code>*Response</code></td>
<td><code>UserResponse</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="이-단계에서-배운-것">이 단계에서 배운 것</h2>
<p>파일이 많아지는 게 이 구조의 대가이긴 한데, 직접 만들다 보니 이유가 보였다. UserRepository 인터페이스가 있으면 RegisterService 테스트를 DB 없이 짤 수 있다. RegisterUseCase 인터페이스가 있으면 컨트롤러 테스트를 서비스 없이 짤 수 있다. 인터페이스가 많아지지만 독립적으로 테스트를 진행할 수 있게 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MSA] Spring Boot 멀티모듈 골격 잡기]]></title>
            <link>https://velog.io/@furaha_dev/MSA-Spring-Boot-%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%93%88-%EA%B3%A8%EA%B2%A9-%EC%9E%A1%EA%B8%B0</link>
            <guid>https://velog.io/@furaha_dev/MSA-Spring-Boot-%EB%A9%80%ED%8B%B0%EB%AA%A8%EB%93%88-%EA%B3%A8%EA%B2%A9-%EC%9E%A1%EA%B8%B0</guid>
            <pubDate>Sat, 20 Jun 2026 08:07:38 GMT</pubDate>
            <description><![CDATA[<h2 id="시작-전에-결정해야-했던-것들">시작 전에 결정해야 했던 것들</h2>
<p>0단계에서 &quot;MSA + 헥사고날 + DDD&quot;로 가기로 했으니, 프로젝트 뼈대를 어떻게 잡을지가 첫 과제였다.</p>
<p>결정해야 할 게 생각보다 많았다.</p>
<ul>
<li>Gradle 단일 모듈로 갈지, 멀티모듈로 갈지</li>
<li>모듈 이름을 <code>user-service</code>로 할지 <code>user</code>로 할지</li>
<li>Spring Initializr를 어떻게 활용할지</li>
<li>JAR로 패키징할지 WAR로 할지</li>
</ul>
<p>하나씩 결론을 냈다.</p>
<hr>
<h2 id="왜-멀티모듈인가">왜 멀티모듈인가</h2>
<p>단일 모듈로 시작하면 나중에 MSA로 분리할 때 공통 코드를 어디에 둘지가 문제가 된다. <code>:common</code> 모듈을 처음부터 두면 <code>ApiResponse</code>, <code>ErrorCode</code> 같은 공통 유틸을 여러 서비스가 공유할 수 있다.</p>
<pre><code>common ← user, post, like, notification, feed</code></pre><p>의존성 방향은 항상 서비스 → common. 반대 방향은 금지다.</p>
<hr>
<h2 id="spring-initializr-활용--세팅-순서">Spring Initializr 활용 + 세팅 순서</h2>
<p>멀티모듈을 Initializr로 한 번에 뽑는 건 안 된다. Initializr는 단일 모듈만 만들어준다. 그래서 이렇게 했다.</p>
<ol>
<li><a href="https://start.spring.io">start.spring.io</a>에서 루트 프로젝트 하나만 생성 (Gradle - Groovy, Java 17, Spring Boot 3.5.15, 의존성 없음)</li>
<li>압축 풀고 <code>gradlew</code>, <code>gradlew.bat</code>, <code>gradle/wrapper/</code>만 가져오기</li>
<li><code>src/</code> 폴더 삭제 (루트엔 코드 없어야 함)</li>
<li><code>settings.gradle</code>, <code>build.gradle</code> 직접 재작성</li>
<li>모듈 폴더와 각자의 <code>build.gradle</code>은 손으로 생성</li>
</ol>
<p>Initializr에서 가장 중요하게 챙긴 건 <strong>Gradle Wrapper</strong>다. <code>gradlew</code>가 없으면 시스템에 Gradle이 설치돼 있어야 하고, 버전이 안 맞으면 이유 모를 에러가 생긴다.</p>
<p><strong>JAR vs WAR</strong>는 고민할 것도 없었다. Docker 컨테이너에서 <code>java -jar app.jar</code>로 바로 띄우려면 JAR이다. WAR은 외부 WAS가 따로 있어야 한다.</p>
<p><strong>모듈 이름</strong>은 <code>-service</code> 없이 도메인 이름만 쓰기로 했다. <code>user-service</code>보다 <code>user</code>가 짧고 패키지 경로도 깔끔해진다.</p>
<hr>
<h2 id="settingsgradle">settings.gradle</h2>
<pre><code class="language-groovy">rootProject.name = &#39;imon&#39;

include &#39;admin&#39;
include &#39;common&#39;
include &#39;feed&#39;
include &#39;like&#39;
include &#39;notification&#39;
include &#39;post&#39;
include &#39;user&#39;</code></pre>
<hr>
<h2 id="루트-buildgradle">루트 build.gradle</h2>
<p>멀티모듈에서 루트 <code>build.gradle</code>의 역할은 &quot;모든 서브모듈에 공통으로 적용할 것들을 한 곳에서 관리&quot;다.</p>
<pre><code class="language-groovy">plugins {
    id &#39;java&#39;
    id &#39;org.springframework.boot&#39; version &#39;3.5.15&#39; apply false  // 선언만, 적용은 서비스 모듈에서
    id &#39;io.spring.dependency-management&#39; version &#39;1.1.7&#39;
}

allprojects {
    group = &#39;com.imon&#39;
    version = &#39;0.0.1-SNAPSHOT&#39;
    repositories { mavenCentral() }
}

subprojects {
    apply plugin: &#39;java&#39;
    apply plugin: &#39;io.spring.dependency-management&#39;

    java {
        toolchain {
            languageVersion = JavaLanguageVersion.of(17)
        }
    }

    dependencyManagement {
        imports {
            mavenBom &quot;org.springframework.boot:spring-boot-dependencies:3.5.15&quot;
        }
    }

    dependencies {
        compileOnly &#39;org.projectlombok:lombok&#39;
        annotationProcessor &#39;org.projectlombok:lombok&#39;
        testCompileOnly &#39;org.projectlombok:lombok&#39;
        testAnnotationProcessor &#39;org.projectlombok:lombok&#39;
        testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
        testRuntimeOnly &#39;org.junit.platform:junit-platform-launcher&#39;
    }

    tasks.named(&#39;test&#39;) {
        useJUnitPlatform()
    }
}</code></pre>
<p>여기서 중요한 포인트가 두 가지다.</p>
<p><strong><code>apply false</code>의 의미</strong>는 &quot;이 플러그인을 클래스패스에는 올려두되, 지금 바로 적용하지는 않는다&quot;는 뜻이다. Spring Boot 플러그인은 <code>bootJar</code> 태스크를 활성화하는데, <code>:common</code>은 실행 모듈이 아니라 라이브러리라서 <code>bootJar</code>가 필요 없다. 서비스 모듈에서만 <code>id &#39;org.springframework.boot&#39;</code>를 적어서 켜면 된다.</p>
<p><strong><code>sourceCompatibility</code> 대신 toolchain을 쓴 이유</strong>는 JDK 버전을 더 명시적으로 고정할 수 있어서다. 시스템에 JDK가 여러 버전 깔려있어도 17로 강제된다.</p>
<hr>
<h2 id="common-모듈-buildgradle">:common 모듈 build.gradle</h2>
<pre><code class="language-groovy">// 라이브러리 모듈 — Spring Boot 플러그인 미적용
// bootJar 태스크 없음, 일반 jar로 빌드되어 각 서비스 모듈에 포함됨

dependencies {
    // ApiResponse, ApiError에서 ResponseEntity / HttpStatus 사용
    // ErrorCode에서 HttpStatus 사용
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;

    // 모든 서비스 PK 생성에 사용하는 Monotonic ULID 라이브러리
    implementation &#39;com.github.f4b6a3:ulid-creator:5.2.3&#39;
}</code></pre>
<p><code>:common</code>에 뭘 넣을지 기준을 잡아뒀다. 이 기준이 없으면 나중에 뭐가 들어가야 하는지 헷갈린다.</p>
<pre><code>:common 포함 기준 — 아래 조건을 모두 만족할 때만 추가한다.
  1. 모든(또는 대부분의) 서비스 모듈에서 필요하다
  2. 서비스마다 다른 구현이 필요하지 않다
  3. 도메인 로직이 아닌 인프라/공통 유틸 성격이다

현재 common 포함 항목:
  - UlidGenerator          → 모든 서비스가 동일한 PK 생성 전략 사용
  - ErrorCode (인터페이스) → 각 서비스가 자기 에러코드 enum을 구현
  - CommonErrorCode        → 인증, 입력값 오류 등 서비스 무관한 공통 에러
  - BusinessException      → 모든 서비스가 동일한 예외 체계 사용
  - ApiResponse, ApiError  → 모든 서비스가 동일한 응답 포맷 사용</code></pre><p><code>BaseEntity</code>와 <code>GlobalExceptionHandler</code>는 common에 두지 않는다. <code>BaseEntity</code>는 JPA에 종속된 코드라 JPA를 쓰는 서비스 모듈(persistence 패키지) 안에 위치하고, <code>GlobalExceptionHandler</code>는 Spring MVC 컨텍스트에 등록되는 빈이라 각 서비스의 컴포넌트 스캔 범위 안에 있어야 한다. 둘 다 서비스 모듈에 두는 게 자연스럽다.</p>
<p>서비스 전용 에러코드도 common에 섞지 않는다. <code>UserErrorCode</code>처럼 도메인별 에러는 해당 서비스 모듈 안에 <code>*ErrorCode</code> enum으로 정의하고 <code>ErrorCode</code> 인터페이스를 구현한다. 이렇게 하면 user 서비스가 post 서비스의 에러코드를 알게 되는 상황을 막을 수 있다.</p>
<hr>
<h2 id="user-모듈-buildgradle">:user 모듈 build.gradle</h2>
<pre><code class="language-groovy">plugins {
    id &#39;org.springframework.boot&#39;  // bootJar 활성화, 버전은 루트에서 관리
}

dependencies {
    implementation project(&#39;:common&#39;)

    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-data-jpa&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-data-redis&#39;
    implementation &#39;org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-actuator&#39;

    runtimeOnly &#39;com.mysql:mysql-connector-j&#39;
    runtimeOnly &#39;io.micrometer:micrometer-registry-prometheus&#39;
    runtimeOnly &#39;com.h2database:h2&#39;
}</code></pre>
<p>처음에 <code>ulid-creator</code>를 <code>:user</code>에도 중복 선언했는데, <code>:common</code>에 이미 있어서 제거했다. <code>implementation</code>은 컴파일 클래스패스까지 전파되지 않아서 직접 호출할 일이 있으면 명시해야 하지만, <code>UlidGenerator</code>는 <code>:common</code> 안에서 쓰고 서비스에서 직접 import 안 하기 때문에 중복이었다.</p>
<hr>
<h2 id="헥사고날-패키지-구조">헥사고날 패키지 구조</h2>
<p>헥사고날 아키텍처는 adapter, application, domain 세 계층으로 나뉜다. <code>application</code> 레이어를 명시적으로 분리해야 의존성 방향이 명확해진다.</p>
<pre><code>user/src/main/java/com/imon/user/

├── adapter/
│   ├── in/
│   │   └── web/
│   │       ├── UserController.java
│   │       ├── GlobalExceptionHandler.java
│   │       └── dto/
│   │           ├── RegisterRequest.java
│   │           └── UserResponse.java
│   └── out/
│       └── persistence/
│           ├── BaseEntity.java
│           ├── UserJpaEntity.java
│           ├── UserJpaRepository.java
│           └── UserPersistenceAdapter.java
│
├── application/
│   ├── port/
│   │   ├── in/
│   │   │   ├── RegisterUseCase.java
│   │   │   └── GetUserUseCase.java
│   │   └── out/
│   │       └── UserRepository.java
│   └── service/
│       └── RegisterService.java
│
└── domain/
    └── model/
        └── User.java</code></pre><p>의존성 방향은 <strong>adapter → application → domain</strong> 단방향이다. 도메인은 아무것도 의존하지 않는다.</p>
<hr>
<h2 id="막혔던-부분들">막혔던 부분들</h2>
<p><strong>Gradle Wrapper 경로 문제</strong>: Windows PowerShell에서 <code>./gradlew</code>가 안 됐다. <code>./</code>이 아니라 <code>.\</code>을 써야 하고, <code>gradlew</code>는 루트에서 실행해야 한다.</p>
<pre><code class="language-powershell"># 틀린 것
./gradlew :user:compileJava

# 맞는 것 (루트에서, Windows)
.\gradlew :user:compileJava</code></pre>
<p><strong>common 의존성 전파</strong>: <code>implementation</code>은 런타임 클래스패스까지만 전파된다. 소비 모듈 컴파일 타임엔 보이지 않는다. <code>api</code>를 쓰면 컴파일까지 전파되는데, 이를 사용하려면 <code>java</code> 플러그인 대신 <code>java-library</code> 플러그인이 필요하다. 현재 구조에선 서비스가 공통 코드를 직접 import해서 쓰는 경우가 있어서, 이 부분을 고려해서 의존성 구성을 했다.</p>
<hr>
<h2 id="빌드-성공-확인">빌드 성공 확인</h2>
<pre><code class="language-powershell">.\gradlew :user:compileJava
# BUILD SUCCESSFUL in 2s</code></pre>
<hr>
<p>다음 글에서는 DDD 패턴(Entity, Repository, UseCase, Command)을 이 폴더 구조에 실제로 매핑한 과정을 기록할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MSA로 SNS 처음부터 끝까지 만들어보자!]]></title>
            <link>https://velog.io/@furaha_dev/MSA%EB%A1%9C-SNS-%EC%B2%98%EC%9D%8C%EB%B6%80%ED%84%B0-%EB%81%9D%EA%B9%8C%EC%A7%80-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@furaha_dev/MSA%EB%A1%9C-SNS-%EC%B2%98%EC%9D%8C%EB%B6%80%ED%84%B0-%EB%81%9D%EA%B9%8C%EC%A7%80-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Thu, 18 Jun 2026 14:23:18 GMT</pubDate>
            <description><![CDATA[<h1 id="msa--헥사고날--ddd-왜-이걸-선택했나--설계-전-고민-기록">MSA + 헥사고날 + DDD, 왜 이걸 선택했나 — 설계 전 고민 기록</h1>
<h2 id="시작은-springboot으로-된-프로젝트-한-번-만들어보자였다">시작은 &quot;Springboot으로 된 프로젝트 한 번 만들어보자&quot;였다</h2>
<blockquote>
<p>이 글은 Spring Boot로 SNS 플랫폼을 만들면서 남기는 학습 일지입니다.
이런 저런 설계 과정부터 개발, 배포까지 꾸준히 글을 남겨보려고 합니다!</p>
</blockquote>
<h3 id="spring-생태계로-돌아온-이유">Spring 생태계로 돌아온 이유</h3>
<p>경력은 4.X년 차지만, 솔직히 Spring은 입사 전 포트폴리오를 만들면서 사용해 본 이후 거의 다뤄보지 못했다. 현재 회사에서는 Java와 자체 프레임워크를 사용하고 있어 Spring Boot, JPA, Gradle 멀티모듈 같은 Spring 생태계를 제대로 경험할 기회가 많지 않았다.</p>
<p>그렇다고 백엔드 개발 경험이 부족했던 건 아니다. 최근에는 현업에서 Hexagonal Architecture를 직접 도입하며 프로젝트 구조를 설계하고 개발을 주도했다. LLM과 LangChain4j를 활용해 기존 제품에 AI 기능을 접목하는 업무도 진행하고 있다.</p>
<p>다만 설계 역량과 Spring 생태계 경험은 서로 대체할 수 있는 게 아니었다. 아는 것과 직접 구현해 보는 것은 분명 다르기 때문이다. Spring Boot, JPA, 멀티모듈 프로젝트, 테스트 전략, 트랜잭션 설계, 아키텍처까지 직접 고민하며 구현해 보고 싶었다. 이번 프로젝트는 그 공백을 메우기 위해 시작했다.</p>
<h3 id="프로젝트의-목표">프로젝트의 목표</h3>
<p>주제는 텍스트와 사진 기반 SNS 플랫폼이다. Threads처럼 단순한 글을 작성하는 서비스지만, 사용자 관계, 피드 조회, 좋아요, 댓글, 인증 등 실제 서비스에서 고민해야 할 다양한 요구사항을 자연스럽게 녹여낼 수 있는 도메인이라고 생각했다.</p>
<p>이 프로젝트의 목표는 단순히 CRUD를 구현하는 것이 아니다. Spring Boot와 JPA, 멀티모듈 구조, 테스트, 아키텍처를 직접 설계하고 구현하며 Spring 생태계를 깊이 있게 경험하는 것이 첫 번째 목표다.</p>
<p>여기에 한 가지를 더하고 싶었다. 현재 실무에서 LLM과 LangChain4j로 AI 기능을 개발하고 있는 만큼, 이번 프로젝트에서도 AI를 자연스럽게 녹여보고자 한다. 단순히 &quot;AI를 붙였다&quot;는 수준이 아니라 추천 피드, 관심사 기반 콘텐츠 추천, 검색 품질 개선처럼 실제 사용자 경험을 높이는 기능을 직접 설계하고 구현해 볼 예정이다. 궁극적으로는 Spring 기반 백엔드와 AI를 함께 활용해 서비스를 설계하고 개발할 수 있는 개발자가 되는 것, 그것이 이번 프로젝트의 목표다.</p>
<hr>
<h2 id="왜-msa인가">왜 MSA인가</h2>
<p>처음엔 &quot;그냥 모놀리식으로 빠르게 만들면 안 되나?&quot; 싶었다.
근데 SNS를 뜯어보니까 자연스럽게 서비스가 쪼개진다.</p>
<ul>
<li>게시글을 쓰는 것과</li>
<li>피드를 읽는 것과</li>
<li>알림을 받는 것과</li>
<li>사용자 정보를 관리하는 것은</li>
</ul>
<p>사실 성격이 다른 일이다. 피드 조회가 폭발적으로 늘어난다고 해서 알림 서비스를 같이 스케일 아웃할 필요는 없다.</p>
<p>MSA를 선택한 이유를 정리하면:</p>
<ul>
<li>서비스마다 독립적으로 배포하고 스케일할 수 있다</li>
<li>장애가 한 서비스에 격리된다 (알림 서버 다운돼도 게시글 작성은 된다)</li>
<li>각 서비스를 다른 기술 스택으로 최적화할 수 있다 (나중에)</li>
</ul>
<p>물론 단점도 있다. 서비스 간 통신 복잡도, 분산 트랜잭션, 데이터 일관성 문제. 하지만 이걸 직접 부딪혀보는 게 목적이었다.</p>
<hr>
<h2 id="왜-헥사고날-아키텍처인가">왜 헥사고날 아키텍처인가</h2>
<p>일반적인 레이어드 아키텍처(Controller → Service → Repository)의 문제를 먼저 이해했다.</p>
<pre><code>[레이어드 아키텍처의 흔한 문제]

Controller
    ↓
Service ──── 여기서 JPA Entity를 직접 쓰기 시작함
    ↓
Repository (JPA)

→ 비즈니스 로직이 DB 기술에 종속됨
→ DB를 바꾸면 서비스 로직도 손대야 함
→ 테스트할 때 DB 없으면 못 돌림</code></pre><p>헥사고날(포트 &amp; 어댑터) 아키텍처는 이 문제를 <strong>의존성 역전</strong>으로 푼다.</p>
<p><img src="https://velog.velcdn.com/images/furaha_dev/post/70658e2b-3c09-4e20-8f43-2ed1797bef7d/image.png" alt=""></p>
<p>핵심 규칙은 딱 하나다:</p>
<blockquote>
<p><strong>의존성은 항상 안쪽으로. 도메인은 아무것도 모른다.</strong></p>
</blockquote>
<p>도메인 모델(<code>User</code>)은 JPA도, Spring도, HTTP도 모른다. 순수한 자바 객체다.
덕분에 DB를 H2에서 MySQL로 바꿔도, HTTP를 gRPC로 바꿔도 도메인은 건드리지 않아도 된다.</p>
<hr>
<h2 id="왜-ddd인가">왜 DDD인가</h2>
<p>DDD(도메인 주도 설계)를 전술 패턴 수준에서 적용하기로 했다.
전략 패턴(바운디드 컨텍스트, 유비쿼터스 언어 등)까지 하면 범위가 너무 넓어지니까.</p>
<p>전술 패턴에서 챙긴 것들:</p>
<table>
<thead>
<tr>
<th>패턴</th>
<th>내가 적용한 방식</th>
</tr>
</thead>
<tbody><tr>
<td>Entity</td>
<td><code>User</code> — 순수 자바, 식별자로 동일성 판단</td>
</tr>
<tr>
<td>Repository (포트)</td>
<td><code>UserRepository</code> — 인터페이스로 추상화</td>
</tr>
<tr>
<td>Use Case</td>
<td><code>RegisterUseCase</code>, <code>GetUserUseCase</code> — 행동을 인터페이스로 선언</td>
</tr>
<tr>
<td>Command</td>
<td><code>RegisterCommand</code> — 상태 변경 요청을 데이터 객체로 표현</td>
</tr>
</tbody></table>
<p>DDD를 적용하면서 가장 크게 달라진 건 &quot;어디에 뭘 둬야 하는가&quot;가 명확해진다는 거다.
비즈니스 규칙은 도메인에, 흐름 조율은 서비스에, 기술 연결은 어댑터에.</p>
<hr>
<h2 id="설계-전-핵심-고민들">설계 전 핵심 고민들</h2>
<h3 id="피드-시스템-fan-out-on-write-vs-read">피드 시스템: Fan-out on Write vs Read</h3>
<p>SNS에서 피드를 어떻게 만들지가 가장 큰 설계 결정이었다.</p>
<pre><code>Fan-out on Write (쓰기 시 분산)
  게시글 작성 → 팔로워 모두의 타임라인에 미리 복사
  장점: 읽기 빠름
  단점: 팔로워 100만 명인 계정이 글 쓰면 100만 번 INSERT

Fan-out on Read (읽기 시 조합)  
  피드 요청 → 그때그때 팔로잉 목록의 최신 글 조합
  장점: 쓰기 가벼움
  단점: 읽기 느림, 팔로잉 많으면 복잡

현실적인 답: 하이브리드
  일반 유저 → Fan-out on Write
  팔로워 10만 이상 셀럽 → Fan-out on Read</code></pre><p>지금 단계에선 단순하게 가고, 나중에 개선하는 방향으로 결정했다.</p>
<h3 id="id-전략-uuid-→-ulid">ID 전략: UUID → ULID</h3>
<p>UUID를 쓰면 완전 랜덤값이라 DB B-Tree 인덱스에서 Page Split이 빈번하게 발생한다.
ULID는 앞 48비트가 타임스탬프라 시간순으로 정렬된 값이 들어온다.</p>
<pre><code>UUID: 550e8400-e29b-41d4-a716-446655440000  (완전 랜덤)
ULID: 01HQ2MXYZ0000000000000AAAA            (시간 + 랜덤)</code></pre><p>실제 코드에서는 <code>getMonotonicUlid()</code>를 쓴다.</p>
<pre><code class="language-java">UlidCreator.getMonotonicUlid().toString();
// 같은 밀리초 안에도 단조 증가 보장 (항상 이전 것보다 큼)</code></pre>
<p>일반 <code>getUlid()</code>는 같은 밀리초 안에서 랜덤값이 들어갈 수 있어서 순서가 역전될 수 있다. Monotonic은 같은 밀리초 안에서도 직전 값보다 크다는 걸 보장해줘서 정렬 안정성이 더 높다.</p>
<p>ULID를 쓰면 커서 페이지네이션도 단순해진다.</p>
<pre><code class="language-sql">-- UUID였으면: 복합 커서 필요
WHERE (created_at, id) &lt; (:cursorTime, :cursorId)

-- ULID는: id 하나로 시간 순서 + 유일성 모두 보장
WHERE id &lt; :cursorId ORDER BY id DESC</code></pre>
<h3 id="이력-관리-얼마나-복잡하게-할-것인가">이력 관리: 얼마나 복잡하게 할 것인가</h3>
<p>실제 SNS들은 어떻게 하는지 찾아봤다.</p>
<ul>
<li><strong>Threads/Instagram</strong>: 수정 시 &#39;수정됨&#39; 라벨만 표시, 이전 내용은 비공개. 삭제된 게시글은 30일간 Soft Delete 상태로 보관 후 Batch로 영구 삭제.</li>
<li><strong>Twitter/X</strong>: 수정 이력 전면 공개. &#39;수정됨&#39; 버튼을 누르면 원본부터 수정본까지 시간순으로 모두 조회 가능. 리트윗된 글의 맥락을 유지하기 위한 투명성 정책.</li>
<li><strong>국내 서비스</strong>: 통신비밀보호법에 따라 접속 로그를 3개월 의무 보관. 명예훼손 등 법적 분쟁 대응을 위해 사용자가 삭제한 게시글도 일정 기간 Soft Delete 상태로 유지.
내가 선택한 방향:</li>
<li>기본은 Soft Delete (<code>deleted_at</code> 컬럼)</li>
<li>게시글 수정만 <code>edit_history</code> JSONB로 이력 보관</li>
<li>좋아요 취소해도 알림은 회수 안 함 (실제 서비스 표준)</li>
</ul>
<hr>
<h2 id="최종-아키텍처-결정">최종 아키텍처 결정</h2>
<p><img src="https://velog.velcdn.com/images/furaha_dev/post/5e455b72-7548-4102-9ebb-365c77b3e694/image.png" alt=""></p>
<p>각 서비스는 독립된 헥사고날 구조를 갖는다.
서비스 간 통신은 나중에 다루려고 한다.</p>
<hr>
<h2 id="이-단계에서-배운-것">이 단계에서 배운 것</h2>
<p>아키텍처를 선택하는 건 &quot;뭐가 더 좋아 보이냐&quot;가 아니라 <strong>트레이드오프를 이해하고 결정하는 것</strong>이라는 걸 알게 됐다.</p>
<p>MSA는 복잡도가 올라가는 대신 독립성을 얻는다.
헥사고날은 파일이 많아지는 대신 변경에 강해진다.
DDD는 코드량이 늘어나는 대신 의도가 명확해진다.</p>
<p>세 가지를 함께 쓰면 배울 게 많아진다. 그게 목적이었으니까 맞는 선택이었다.</p>
<hr>
<p>다음 글에서는 실제로 Spring Boot 프로젝트 스켈레톤을 잡고, 헥사고날 폴더 구조를 어떻게 잡았는지 기록할 예정이다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JAX-RS/Jersey] @Provider vs web.xml 명시적 등록 비교: 어떤 방식을 선택해야 할까?]]></title>
            <link>https://velog.io/@furaha_dev/JAX-RSJersey-Provider-vs-web.xml-%EB%AA%85%EC%8B%9C%EC%A0%81-%EB%93%B1%EB%A1%9D-%EB%B9%84%EA%B5%90-%EC%96%B4%EB%96%A4-%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@furaha_dev/JAX-RSJersey-Provider-vs-web.xml-%EB%AA%85%EC%8B%9C%EC%A0%81-%EB%93%B1%EB%A1%9D-%EB%B9%84%EA%B5%90-%EC%96%B4%EB%96%A4-%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Tue, 26 May 2026 23:50:10 GMT</pubDate>
            <description><![CDATA[<p>최근 동기와 함께 JAX-RS(Jersey) 환경에서 <code>DependencyBinder</code>나 필터(Filter) 등을 등록할 때 어떤 방식이 맞는지 치열하게 고민했다.</p>
<p><strong>&quot;간편하게 코드 위에 <code>@Provider</code> 어노테이션 하나만 딱 붙여서 스캔하게 두는 게 맞을까?&quot;</strong>
<strong>&quot;아니면 아키텍처 원칙을 지키기 위해 <code>web.xml</code>에 긴 클래스 경로를 하나하나 꼼꼼하게 다 명시하는 게 맞을까?&quot;</strong></p>
<p>이 두 가지 방식은 결과적으로 &#39;프레임워크가 해당 클래스를 사용하게 한다&#39;는 목적은 같지만, 동작 방식과 아키텍처 관점에서의 의미는 완전히 다르다. 이 두 방식의 차이점과 장단점, 그리고 실무에서 어떤 방식을 선택해야 할지 정리해 보았다.</p>
<hr>
<h2 id="1-동작-방식의-차이-명찰-vs-vip-명단">1. 동작 방식의 차이: &quot;명찰&quot; vs &quot;VIP 명단&quot;</h2>
<p>이해를 돕기 위해 프레임워크(Jersey)를 식당 매니저라고 가정해 보자.</p>
<h3 id="방식-a-provider-어노테이션-자동-스캔---auto-discovery">방식 A: <code>@Provider</code> 어노테이션 (자동 스캔 - Auto Discovery)</h3>
<ul>
<li><strong>비유:</strong> 직원에게 &#39;스태프 명찰&#39;을 달아주는 방식이다.</li>
<li><strong>동작 원리:</strong> 톰캣이 실행되면 Jersey는 <code>web.xml</code>에 설정된 <code>packages</code> 경로(식당 내부)를 재귀적으로 샅샅이 뒤진다. 그리고 수많은 클래스 중에서 <code>@Provider</code>라는 명찰이 붙은 녀석들만 찾아서 메모리에 올린다.</li>
<li><strong>특징:</strong> 코드를 옮기거나 패키지 구조가 바뀌어도, 스캔 범위(<code>packages</code>) 안에만 있다면 별도의 설정 변경 없이 알아서 잘 작동한다.</li>
</ul>
<h3 id="방식-b-webxml-명시-수동-등록---explicit-registration">방식 B: <code>web.xml</code> 명시 (수동 등록 - Explicit Registration)</h3>
<ul>
<li><strong>비유:</strong> 매니저에게 &#39;VIP 명단&#39;을 쥐여주는 방식이다.</li>
<li><strong>동작 원리:</strong> Jersey는 패키지를 굳이 스캔하지 않는다. <code>web.xml</code>의 <code>jersey.config.server.provider.classnames</code>에 적힌 클래스의 정확한 전체 경로(Full Class Name)를 보고, 리플렉션(Reflection)을 통해 해당 클래스만 콕 집어서 즉시 메모리에 올린다.</li>
<li><strong>특징:</strong> 패키지를 이동하면 전체 경로가 바뀌기 때문에, <strong>반드시 <code>web.xml</code> 파일도 함께 수정해야 한다.</strong> 그렇지 않으면 서버 기동 시 에러가 발생하거나 해당 기능이 무시된다.</li>
</ul>
<hr>
<h2 id="2-핵심-차이-비교표">2. 핵심 차이 비교표</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th><code>@Provider</code> 어노테이션 (자동 스캔)</th>
<th><code>web.xml</code> <code>classnames</code> (수동 명시)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>방식</strong></td>
<td>Auto Discovery</td>
<td>Explicit Registration</td>
</tr>
<tr>
<td><strong>코드 침투성</strong></td>
<td>높음 (Java 소스 코드에 프레임워크 기술이 묻음)</td>
<td>낮음 (Java 코드는 순수하게 유지됨)</td>
</tr>
<tr>
<td><strong>리팩토링 유연성</strong></td>
<td>높음 (패키지 이동 시 설정 파일 수정 불필요)</td>
<td>낮음 (패키지 이동 시 <code>web.xml</code> 수정 필수)</td>
</tr>
<tr>
<td><strong>서버 시작 속도</strong></td>
<td>스캔 범위가 넓을 경우 상대적으로 미세하게 느려짐</td>
<td>스캔을 생략하므로 가장 빠름</td>
</tr>
<tr>
<td><strong>통제권</strong></td>
<td>암묵적 (누군가 맘대로 명찰을 달면 의도치 않게 등록됨)</td>
<td>명시적 (설정 파일에서 100% 중앙 통제 가능)</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-아키텍처-관점에서의-고민-무엇을-선택해야-할까">3. 아키텍처 관점에서의 고민: 무엇을 선택해야 할까?</h2>
<p>기능적으로는 둘 다 완벽하게 동작하지만, 팀의 설계 철학과 프로젝트 규모에 따라 선택이 갈린다.</p>
<h3 id="💡-실용주의-개발-속도와-편의성이-중요하다면-provider-선택">💡 실용주의: 개발 속도와 편의성이 중요하다면 (<code>@Provider</code> 선택)</h3>
<p>XML 설정 파일이 길어지고 복잡해지는 것을 선호하지 않는 경우 적합하다. 프로젝트 내부에서 직접 만든 커스텀 필터나 <code>DependencyBinder</code> 같은 클래스는, 코드 위에 <code>@Provider</code> 하나만 달아두면 관리가 매우 편해진다.</p>
<h3 id="💡-원칙주의-완벽한-통제와-관심사-분리가-중요하다면-webxml-선택">💡 원칙주의: 완벽한 통제와 관심사 분리가 중요하다면 (<code>web.xml</code> 선택)</h3>
<p>규모가 큰 엔터프라이즈 프로젝트나, 아키텍처 원칙(설정과 구현의 분리)을 강력하게 지키고자 할 때 권장하는 방식이다. 그 이유는 다음과 같다.</p>
<ol>
<li><strong>설정과 비즈니스 로직의 분리 (Clean Architecture):</strong>
Java 클래스 파일은 순수하게 &quot;어떤 인터페이스에 어떤 객체를 매핑할지&quot;만 담고, 이 로직을 &quot;실제 웹 서버에 등록할지 말지&quot;는 철저하게 외부 환경 설정(<code>web.xml</code>)이 전담해야 한다는 철학이다. 이를 통해 코드가 특정 프레임워크 기술에 오염되는 것을 막는다.</li>
<li><strong>매직(Magic) 방지:</strong>
자동 스캔을 사용하면 누군가 테스트용으로 만든 클래스에 깜빡하고 <code>@Provider</code>를 남겨두었을 때, 서버 기동 시 의도치 않게 주입되어 시스템을 망가뜨릴 위험이 있다. 반면 <code>web.xml</code>을 사용하면 서버에 어떤 기능이 켜져 있는지 중앙에서 100% 통제하고 한눈에 파악할 수 있다.</li>
<li><strong>환경별 스위칭의 유연성:</strong>
로컬 개발 환경, 테스트 환경, 운영 환경마다 주입해야 하는 구현체가 다를 때, 소스 코드를 건드리지 않고 배포 시점의 XML 설정 파일만 교체하여 유연하게 대처할 수 있다.</li>
</ol>
<blockquote>
<p><strong>참고 (Third-Party 라이브러리):</strong> &gt; 우리가 직접 소스 코드를 열어서 어노테이션을 붙일 수 없는 외부 라이브러리의 기능(예: 파일 업로드를 위한 <code>MultiPartFeature</code>)을 활성화할 때는, 선택의 여지 없이 무조건 <code>web.xml</code>이나 <code>ResourceConfig</code>에 하나하나 직접 명시하는 방식을 사용해야 한다.</p>
</blockquote>
<hr>
<h2 id="4-결론">4. 결론</h2>
<p>무조건적인 정답은 없다. 팀의 컨벤션과 아키텍처 방향성에 맞춰 선택하면 된다.</p>
<ul>
<li><strong>빠르고 직관적인 개발과 유지보수의 편의성</strong>을 원한다면 <code>@Provider</code> 어노테이션을 사용하자.</li>
<li><strong>프레임워크 비종속성과 엄격한 중앙 통제, 설정의 외부화</strong>를 원한다면 번거롭더라도 <code>web.xml</code>에 클래스를 하나하나 명시하는 방식을 선택하자.</li>
</ul>
]]></description>
        </item>
        <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>
    </channel>
</rss>