<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>mj_o.log</title>
        <link>https://velog.io/</link>
        <description>..</description>
        <lastBuildDate>Tue, 07 Apr 2026 06:44:37 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>mj_o.log</title>
            <url>https://velog.velcdn.com/images/mj_o/profile/0a380e04-4842-4e48-84c3-83eb5d9348cb/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. mj_o.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/mj_o" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[코드 한 줄 안 짜고 앱 배포까지 — Claude Code + gstack 실사용 후기 ]]></title>
            <link>https://velog.io/@mj_o/%EC%BD%94%EB%93%9C-%ED%95%9C-%EC%A4%84-%EC%95%88-%EC%A7%9C%EA%B3%A0-%EC%95%B1-%EB%B0%B0%ED%8F%AC%EA%B9%8C%EC%A7%80-Claude-Code-gstack-%EC%8B%A4%EC%82%AC%EC%9A%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@mj_o/%EC%BD%94%EB%93%9C-%ED%95%9C-%EC%A4%84-%EC%95%88-%EC%A7%9C%EA%B3%A0-%EC%95%B1-%EB%B0%B0%ED%8F%AC%EA%B9%8C%EC%A7%80-Claude-Code-gstack-%EC%8B%A4%EC%82%AC%EC%9A%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Tue, 07 Apr 2026 06:44:37 GMT</pubDate>
            <description><![CDATA[<h1 id=""><img src="https://velog.velcdn.com/images/mj_o/post/79e14fb6-bed9-480b-839b-d646e73033b1/image.png" alt=""></h1>
<h2 id="시작은-진짜-단순했다">시작은 진짜 단순했다</h2>
<p>비 오는 날 밤. 갑자기 천둥이 엄청 크게 쳤다.</p>
<p>&quot;와 진짜 크다... 나만 들은 건가? 다른 사람들도 들었나?&quot;</p>
<p>트위터에서 &quot;천둥&quot; 검색하면 반응은 나온다. 근데 우리 동네 사람인지 모르겠고, 실시간도 아니고, 그냥 검색 결과일 뿐이다.</p>
<p><strong>같은 동네에서, 같은 순간을, 같이 경험하고 있다는 걸 실시간으로 볼 수 있으면?</strong></p>
<p>그래서 만들었다. <strong>Hear That?</strong> — 천둥이 치면 같은 동네 사람들의 반응을 실시간으로 보는 웹앱.</p>
<blockquote>
<p>라이브: <a href="https://hear-that.vercel.app">https://hear-that.vercel.app</a>
GitHub: <a href="https://github.com/moonjun1/hear-that">https://github.com/moonjun1/hear-that</a></p>
</blockquote>
<hr>
<h2 id="어떤-앱인지">어떤 앱인지</h2>
<p><img src="https://hear-that.vercel.app/api/og?area=%EC%84%9C%EC%9A%B8&count=42" alt="OG Preview"></p>
<ul>
<li>천둥이 치면 이모지(⚡😱🙉😂🌧️)로 바로 반응</li>
<li>같은 동네(~9km) 사람들의 반응이 실시간으로 피드에 뜸</li>
<li>지도에서 번개 위치를 실시간으로 보여줌 (기상청 낙뢰 API 연동)</li>
<li>&quot;Thunder Wave&quot; — 번개 지점에서 소리가 퍼져나가는 동심원 애니메이션 (343m/s 실제 음속)</li>
<li>지역 채팅 — 같은 동네 사람들끼리 대화</li>
<li>당근마켓처럼 줌아웃하면 숫자로 묶이고, 줌인하면 개별 이모지로 펼쳐지는 클러스터링</li>
</ul>
<hr>
<h2 id="만든-과정--ai랑-같이-만들었다">만든 과정 — AI랑 같이 만들었다</h2>
<p>이 프로젝트는 <strong>gstack + Claude Code</strong>로 만들었다. 솔직히 말하면, 코드의 대부분은 AI가 짰다. 내가 한 건 &quot;뭘 만들지&quot; 결정하고, &quot;이거 이상한데?&quot; 피드백 주고, &quot;다음 이거 해&quot; 지시한 것.</p>
<h3 id="gstack이-뭔데">gstack이 뭔데?</h3>
<p>gstack은 Garry Tan이 만든 오픈소스 AI 빌더 프레임워크다. Claude Code 위에서 돌아가는 스킬 시스템인데, <code>/office-hours</code>로 아이디어 브레인스토밍하고, <code>/qa</code>로 QA 테스트하고, <code>/design-review</code>로 디자인 리뷰하고, <code>/ship</code>으로 배포하는 식이다.</p>
<h3 id="타임라인">타임라인</h3>
<p><strong>4월 5일 밤 11시</strong> — &quot;천둥이 치는데 다른 사람 반응이 궁금하다&quot;고 Claude에게 말함</p>
<p><strong>11시 30분</strong> — <code>/office-hours</code> 스킬로 아이디어 정리. &quot;Hear That?&quot;이라는 이름이 정해짐. 디자인 문서가 나옴.</p>
<p><strong>12시</strong> — GitHub repo 생성, 이슈 17개, 마일스톤 7개가 자동으로 세팅됨</p>
<p><strong>12시 30분</strong> — Day 1 코드 완성. Next.js + Supabase + Mapbox 세팅, 핵심 컴포넌트 13개.</p>
<p><strong>새벽 1시</strong> — Day 2-7 코드 전부 완성. PR 8개 머지. Vercel 배포.</p>
<p><strong>새벽 2시</strong> — 실제 기상청 API로 번개 데이터가 들어오기 시작. 실제로 충남 쪽에 번개가 치고 있었다.</p>
<p><strong>4월 6일</strong> — QA, 디자인 리뷰, 모바일 최적화. 버그 수정 25건. PR 20개 추가.</p>
<p><strong>총: 하루 반 만에 52커밋, 29파일, 2,492줄, PR 28개.</strong></p>
<hr>
<h2 id="기술-스택">기술 스택</h2>
<table>
<thead>
<tr>
<th>레이어</th>
<th>기술</th>
</tr>
</thead>
<tbody><tr>
<td>프론트엔드</td>
<td>Next.js 16 (App Router) + TypeScript + Tailwind CSS</td>
</tr>
<tr>
<td>백엔드/DB</td>
<td>Supabase (Postgres + Realtime WebSocket)</td>
</tr>
<tr>
<td>지도</td>
<td>Mapbox GL JS</td>
</tr>
<tr>
<td>지역 묶기</td>
<td>H3 (Uber의 육각형 그리드 시스템)</td>
</tr>
<tr>
<td>날씨</td>
<td>기상청 낙뢰관측자료 조회서비스 API</td>
</tr>
<tr>
<td>배포</td>
<td>Vercel</td>
</tr>
<tr>
<td>OG 이미지</td>
<td>@vercel/og</td>
</tr>
<tr>
<td>분석</td>
<td>Google Analytics 4</td>
</tr>
</tbody></table>
<h3 id="왜-이-스택">왜 이 스택?</h3>
<p><strong>Supabase를 고른 이유</strong> — 실시간 WebSocket이 핵심이었다. 누가 이모지를 탭하면 같은 동네 사람 피드에 즉시 떠야 한다. Supabase Realtime이 Postgres Changes를 WebSocket으로 브로드캐스트해준다. Firebase보다 SQL 쿼리가 자유롭고, 무료 tier가 넉넉하다.</p>
<p><strong>Mapbox를 고른 이유</strong> — Thunder Wave 애니메이션. 번개 지점에서 동심원이 343m/s로 퍼져나가는 걸 canvas로 그려야 했다. WebGL custom layer가 가능한 건 Mapbox뿐이었다. 네이버 지도나 카카오맵은 오버레이만 가능해서 이 애니메이션이 안 된다.</p>
<p><strong>H3를 고른 이유</strong> — &quot;같은 동네&quot;를 정의하는 문제. 행정구역(구/동)으로 나누면 경계에 있는 사람들이 갈라진다. H3 육각형 그리드는 경계가 자연스럽고, resolution 5로 설정하면 반경 ~9km로 &quot;우리 동네&quot; 느낌이 딱 맞다.</p>
<hr>
<h2 id="재밌었던-기술-문제들">재밌었던 기술 문제들</h2>
<h3 id="1-기상청-api에서-진짜-번개-좌표가-나온다">1. 기상청 API에서 진짜 번개 좌표가 나온다</h3>
<p>기상청 낙뢰관측자료 API(<code>getLgt</code>)는 개별 낙뢰마다 WGS84 위도/경도를 소수점 6자리까지 준다. 정밀도가 약 0.1m. 10분 지연이지만 준실시간이다.</p>
<p>문제는 바다에서 친 번개도 나온다는 것. 위도 33<del>38.6, 경도 125</del>131.9로 한국 육지만 필터링했다.</p>
<h3 id="2-서버리스에서-메모리-캐시는-의미-없다">2. 서버리스에서 메모리 캐시는 의미 없다</h3>
<p>Vercel 서버리스 환경에서 <code>let lastPollTime = 0</code> 같은 메모리 변수는 인스턴스마다 다르다. 1분 캐시를 걸어도 다른 인스턴스에서는 캐시가 없다.</p>
<p>해결: 매 폴링마다 DB의 weather_events를 전부 지우고(RPC 함수) 새로 넣는 &quot;delete all + insert fresh&quot; 방식으로 바꿨다.</p>
<h3 id="3-supabase-realtime-구독이-react-strict-mode에서-깨진다">3. Supabase Realtime 구독이 React Strict Mode에서 깨진다</h3>
<p>React 18+ strict mode는 useEffect를 두 번 실행한다. <code>initialized.current</code> ref로 막으면 첫 번째 마운트에서 true가 되고 두 번째(실제) 마운트에서 구독이 스킵된다.</p>
<p>해결: <code>useEffect(deps: [neighbors])</code> — 위치가 세팅되면 구독 시작, cleanup에서 해제. ref 대신 React 라이프사이클을 따르도록.</p>
<h3 id="4-vercel-env에-줄바꿈이-들어간다">4. Vercel env에 줄바꿈이 들어간다</h3>
<p><code>echo &quot;KEY&quot; | vercel env add</code> 하면 줄바꿈(<code>\n</code>)이 같이 들어간다. Supabase anon key URL에 <code>%0A</code>가 붙어서 WebSocket 연결이 실패했다.</p>
<p>해결: <code>printf &#39;KEY&#39;</code>로 줄바꿈 없이 넣기.</p>
<hr>
<h2 id="gstack-스킬들이-실제로-도움이-됐나">gstack 스킬들이 실제로 도움이 됐나?</h2>
<p>솔직히, 엄청 도움이 됐다.</p>
<p><strong><code>/office-hours</code></strong> — 아이디어를 디자인 문서로 바꿔줬다. &quot;천둥이 치면 반응 보고 싶다&quot;를 테이블 스키마, API 설계, 7일 빌드 플랜까지 만들어줬다. 세컨드 오피니언으로 &quot;Thunder Wave&quot; 아이디어도 여기서 나왔다.</p>
<p><strong><code>/qa</code></strong> — headless 브라우저로 전체 사이트를 테스트한다. WebGL이 안 되는 한계가 있었지만, 콘솔 에러, API 응답, viewport 경고 같은 건 잡아줬다.</p>
<p><strong><code>/design-review</code></strong> — 소스 코드 기반으로 디자인 이슈 27건을 찾아줬다. &quot;AI 슬롭&quot; (큰 이모지를 디자인 요소로 쓰는 것) 같은 건 혼자서는 못 알아챘을 것 같다.</p>
<p><strong><code>/grill-me</code></strong> — 이 블로그 글의 방향도 여기서 정했다. &quot;목적이 뭐야?&quot; 하나 물어보는 게 30분 고민보다 빠르다.</p>
<hr>
<h2 id="숫자로-보는-프로젝트">숫자로 보는 프로젝트</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>수치</th>
</tr>
</thead>
<tbody><tr>
<td>총 개발 시간</td>
<td>~36시간 (하루 반)</td>
</tr>
<tr>
<td>커밋</td>
<td>52개</td>
</tr>
<tr>
<td>PR</td>
<td>28개 (전부 squash merge)</td>
</tr>
<tr>
<td>파일</td>
<td>29개</td>
</tr>
<tr>
<td>코드</td>
<td>2,492줄</td>
</tr>
<tr>
<td>컴포넌트</td>
<td>13개</td>
</tr>
<tr>
<td>유틸</td>
<td>9개</td>
</tr>
<tr>
<td>버그 수정</td>
<td>25건</td>
</tr>
<tr>
<td>디자인 리뷰</td>
<td>27건 발견, 25건 수정</td>
</tr>
<tr>
<td>모바일 리뷰</td>
<td>20건 발견, 9건 수정</td>
</tr>
</tbody></table>
<hr>
<h2 id="배운-것">배운 것</h2>
<p><strong>1. AI는 &quot;뭘 만들지&quot;는 못 정한다.</strong> 코드는 잘 짜는데, &quot;천둥 칠 때 반응이 보고 싶다&quot;는 인간의 경험에서 나온다. AI는 실행자이지 발명가가 아니다.</p>
<p><strong>2. &quot;바로 만들어&quot; 대신 &quot;설계부터&quot;가 맞다.</strong> <code>/office-hours</code>로 디자인 문서를 먼저 만들고 시작하니까, 중간에 방향을 잃지 않았다. 7일 플랜을 하루 반에 끝낸 것도 설계가 있어서 가능했다.</p>
<p><strong>3. 사이드 프로젝트는 재미가 동력이다.</strong> &quot;이거 돈 될까?&quot; 생각하면 안 만든다. &quot;천둥 치면 다른 사람 반응 궁금하다&quot; 이 호기심 하나로 36시간을 버텼다.</p>
<p><strong>4. Vercel + Supabase 무료 tier가 생각보다 넉넉하다.</strong> Supabase 무료 200 동시 접속, Vercel 무료 배포, Mapbox 월 50,000 로드, 기상청 일 10,000건. 사이드 프로젝트에는 충분하다.</p>
<hr>
<h2 id="한계와-다음-할-것">한계와 다음 할 것</h2>
<ul>
<li>천둥이 안 치면 텅 비어있다 (해결: 과거 이벤트 히스토리)</li>
<li>사용자가 나밖에 없다 (해결: 공유해서 사람 모으기)</li>
<li>커스텀 도메인 없음 (hear-that.xyz 사려다 말았다)</li>
<li>푸시 알림 없음 (천둥 칠 때 알림)</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>천둥이 치는 밤, &quot;다른 사람들도 들었나?&quot; 궁금했을 뿐인데 앱이 하나 나왔다.</p>
<p>AI 시대에 사이드 프로젝트 만드는 건 진입 장벽이 거의 없다. 아이디어만 있으면 된다. 코드는 AI가 짜주고, 배포는 Vercel이 해주고, DB는 Supabase가 해준다.</p>
<p>중요한 건 &quot;뭘 만들지&quot; 결정하는 것. 그건 여전히 사람의 몫이다.</p>
<p>다음에 천둥이 치면, <a href="https://hear-that.vercel.app">hear-that.vercel.app</a> 열어봐. 같은 동네 사람들이 뭐라고 하는지 볼 수 있다.</p>
<hr>
<p><strong>태그</strong>: #사이드프로젝트 #Next.js #Supabase #Mapbox #기상청API #AI개발 #ClaudeCode #gstack #날씨앱 #실시간</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Ruby on Rails 로 블로그 만들어보기 (맛보기 시리즈)]]></title>
            <link>https://velog.io/@mj_o/Ruby-on-Rails-%EB%A1%9C-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88</link>
            <guid>https://velog.io/@mj_o/Ruby-on-Rails-%EB%A1%9C-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88</guid>
            <pubDate>Fri, 29 Aug 2025 06:27:56 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/mj_o/post/cbf39cd7-d961-4a0f-98d9-fc0ef8bf1466/image.png" alt=""></p>
<h1 id="ruby-on-rails로-마크다운-블로그-만들기">Ruby on Rails로 마크다운 블로그 만들기</h1>
<h2 id="🤔-ruby-on-rails란-무엇인가">🤔 Ruby on Rails란 무엇인가?</h2>
<p><strong>Ruby on Rails</strong>(줄여서 Rails)는 Ruby 언어로 작성된 오픈소스 웹 애플리케이션 프레임워크입니다. 2004년 David Heinemeier Hansson이 만든 이 프레임워크는 &quot;개발자의 행복&quot;을 중시하며, 빠르고 효율적인 웹 개발을 가능하게 합니다.</p>
<h3 id="핵심-철학">핵심 철학</h3>
<ul>
<li><strong>Convention over Configuration (설정보다 관례)</strong>: 복잡한 설정 파일 대신 표준 관례를 따름</li>
<li><strong>Don&#39;t Repeat Yourself (DRY)</strong>: 코드 중복을 최소화</li>
<li><strong>REST(Representational State Transfer)</strong>: RESTful한 설계 원칙 준수</li>
</ul>
<h2 id="💡-왜-ruby-on-rails를-선택했나">💡 왜 Ruby on Rails를 선택했나?</h2>
<h3 id="✅-장점들">✅ 장점들</h3>
<h4 id="1-빠른-개발-속도">1. <strong>빠른 개발 속도</strong></h4>
<pre><code class="language-ruby"># 단 몇 줄로 CRUD가 완성됩니다
class PostsController &lt; ApplicationController
  def index
    @posts = Post.published.recent
  end

  def show
    @post = Post.find(params[:id])
  end
end</code></pre>
<h4 id="2-강력한-orm-active-record">2. <strong>강력한 ORM (Active Record)</strong></h4>
<pre><code class="language-ruby"># SQL 없이도 복잡한 쿼리 작성 가능
Post.where(published: true)
    .where(&#39;created_at &gt; ?&#39;, 1.week.ago)
    .order(created_at: :desc)</code></pre>
<h4 id="3-풍부한-gem-생태계">3. <strong>풍부한 Gem 생태계</strong></h4>
<pre><code class="language-ruby"># Gemfile - 필요한 기능을 쉽게 추가
gem &#39;redcarpet&#39;      # 마크다운 처리
gem &#39;rouge&#39;          # 코드 하이라이팅
gem &#39;bootstrap&#39;, &#39;~&gt; 5.1&#39;  # UI 프레임워크</code></pre>
<h4 id="4-mvc-패턴의-완벽한-구현">4. <strong>MVC 패턴의 완벽한 구현</strong></h4>
<pre><code>app/
├── controllers/     # 요청 처리 로직
├── models/         # 데이터 모델
├── views/          # 화면 표시
└── services/       # 비즈니스 로직 분리</code></pre><h3 id="⚠️-단점들">⚠️ 단점들</h3>
<h4 id="1-성능-이슈">1. <strong>성능 이슈</strong></h4>
<ul>
<li>Ruby 자체가 상대적으로 느림</li>
<li>대용량 트래픽 처리에 한계</li>
</ul>
<h4 id="2-메모리-사용량">2. <strong>메모리 사용량</strong></h4>
<ul>
<li>다른 언어에 비해 메모리 사용량이 높음</li>
<li>서버 비용 증가 가능성</li>
</ul>
<h4 id="3-학습-곡선">3. <strong>학습 곡선</strong></h4>
<ul>
<li>Rails의 &quot;마법&quot; 같은 기능들이 초보자에게 혼란 야기</li>
<li>Ruby 언어 자체도 함께 학습해야 함</li>
</ul>
<h2 id="📈-ruby-on-rails의-현재-위치">📈 Ruby on Rails의 현재 위치</h2>
<h3 id="🔥-여전히-인기-있는-이유">🔥 여전히 인기 있는 이유</h3>
<ol>
<li><p><strong>스타트업의 선택</strong></p>
<ul>
<li>빠른 MVP(Minimum Viable Product) 개발</li>
<li>GitHub, Shopify, Airbnb 등이 Rails로 시작</li>
</ul>
</li>
<li><p><strong>성숙한 생태계</strong></p>
<ul>
<li>15년+ 의 안정성</li>
<li>풍부한 문서와 커뮤니티</li>
</ul>
</li>
<li><p><strong>최근 업데이트</strong></p>
<pre><code class="language-ruby"># Rails 7의 새로운 기능들
- Hotwire: SPA 없이도 모던한 웹앱
- Import Maps: JavaScript 번들링 간소화
- CSS Bundling: 모던 CSS 워크플로우</code></pre>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/mj_o/post/d12a9c9e-d4bc-4ad5-b880-4ace8ab048e1/image.png" alt=""><img src="https://velog.velcdn.com/images/mj_o/post/c35f22f7-7655-4846-aba4-e059608cf831/image.png" alt=""></p>
<h3 id="📊-현재-사용-현황-2024년-기준">📊 현재 사용 현황 (2024년 기준)</h3>
<ul>
<li><strong>Stack Overflow 설문조사</strong>: 웹 프레임워크 13위</li>
<li><strong>GitHub Stars</strong>: 50,000+ (꾸준한 성장)</li>
<li><strong>Job Market</strong>: 여전히 많은 Rails 개발자 구인</li>
</ul>
<h2 id="🛠️-내가-구현한-마크다운-블로그">🛠️ 내가 구현한 마크다운 블로그</h2>
<h3 id="📁-프로젝트-구조">📁 프로젝트 구조</h3>
<pre><code>markdown_blog/
├── app/
│   ├── controllers/
│   │   ├── application_controller.rb
│   │   └── posts_controller.rb
│   ├── models/
│   │   ├── application_record.rb
│   │   └── post.rb
│   ├── services/
│   │   └── markdown_renderer.rb
│   └── views/
│       ├── layouts/
│       │   └── application.html.erb
│       └── posts/
│           ├── index.html.erb
│           ├── show.html.erb
│           ├── new.html.erb
│           ├── edit.html.erb
│           └── _form.html.erb
├── config/
│   ├── routes.rb
│   └── database.yml
└── db/
    └── migrate/
        └── 001_create_posts.rb</code></pre><h3 id="🎯-핵심-기능-구현">🎯 핵심 기능 구현</h3>
<h4 id="1-마크다운-렌더링">1. <strong>마크다운 렌더링</strong></h4>
<pre><code class="language-ruby"># app/services/markdown_renderer.rb
class MarkdownRenderer
  def self.render(content)
    renderer = Redcarpet::Render::HTML.new(
      filter_html: true,
      hard_wrap: true,
      link_attributes: { target: &#39;_blank&#39; }
    )

    markdown = Redcarpet::Markdown.new(
      renderer,
      autolink: true,
      tables: true,
      fenced_code_blocks: true,
      strikethrough: true
    )

    markdown.render(content).html_safe
  end
end</code></pre>
<h4 id="2-포스트-모델">2. <strong>포스트 모델</strong></h4>
<pre><code class="language-ruby"># app/models/post.rb
class Post &lt; ApplicationRecord
  validates :title, presence: true, length: { minimum: 1, maximum: 255 }
  validates :content, presence: true, length: { minimum: 1 }

  scope :published, -&gt; { where(published: true) }
  scope :recent, -&gt; { order(created_at: :desc) }

  def markdown_content
    MarkdownRenderer.render(content)
  end

  def reading_time
    words_per_minute = 200
    word_count = content.split.size
    (word_count / words_per_minute.to_f).ceil
  end

  def summary(limit = 150)
    stripped_content = content.gsub(/[#*`&gt;-]/, &#39;&#39;).strip
    truncated = stripped_content.length &gt; limit ? 
                stripped_content[0...limit] + &#39;...&#39; : 
                stripped_content
    truncated
  end
end</code></pre>
<h4 id="3-컨트롤러">3. <strong>컨트롤러</strong></h4>
<pre><code class="language-ruby"># app/controllers/posts_controller.rb
class PostsController &lt; ApplicationController
  before_action :find_post, only: [:show, :edit, :update, :destroy]

  def index
    @posts = Post.published.recent
  end

  def show
  end

  def new
    @post = Post.new
  end

  def create
    @post = Post.new(post_params)

    if @post.save
      redirect_to @post, notice: &#39;포스트가 성공적으로 작성되었습니다.&#39;
    else
      render :new
    end
  end

  private

  def find_post
    @post = Post.find(params[:id])
  end

  def post_params
    params.require(:post).permit(:title, :content, :published)
  end
end</code></pre>
<h3 id="🎨-uiux-특징">🎨 UI/UX 특징</h3>
<h4 id="반응형-디자인"><strong>반응형 디자인</strong></h4>
<pre><code class="language-erb">&lt;!-- app/views/layouts/application.html.erb --&gt;
&lt;div class=&quot;container-fluid&quot;&gt;
  &lt;div class=&quot;row&quot;&gt;
    &lt;div class=&quot;col-lg-8 mx-auto&quot;&gt;
      &lt;main&gt;
        &lt;%= yield %&gt;
      &lt;/main&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;</code></pre>
<h4 id="실시간-미리보기"><strong>실시간 미리보기</strong></h4>
<pre><code class="language-erb">&lt;!-- app/views/posts/_form.html.erb --&gt;
&lt;div class=&quot;row&quot;&gt;
  &lt;div class=&quot;col-md-6&quot;&gt;
    &lt;%= form.text_area :content, class: &quot;form-control&quot;, 
                       rows: 20, 
                       placeholder: &quot;마크다운으로 작성하세요...&quot; %&gt;
  &lt;/div&gt;
  &lt;div class=&quot;col-md-6&quot;&gt;
    &lt;div class=&quot;markdown-preview border p-3&quot;&gt;
      &lt;!-- JavaScript로 실시간 미리보기 구현 --&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;</code></pre>
<h3 id="완성된-기능들">완성된 기능들</h3>
<ol>
<li><strong>CRUD 완전 구현</strong>: 포스트 생성, 읽기, 수정, 삭제</li>
<li><strong>마크다운 처리</strong>: Redcarpet + Rouge로 완벽한 문법 지원</li>
<li><strong>상태 관리</strong>: 초안/발행 시스템</li>
<li><strong>반응형 UI</strong>: Bootstrap 5 기반 모바일 친화적 디자인</li>
<li><strong>코드 하이라이팅</strong>: 다양한 프로그래밍 언어 지원</li>
<li><strong>자동 기능</strong>: 읽기 시간 계산, 요약문 생성</li>
</ol>
<h3 id="실행-방법">실행 방법</h3>
<pre><code class="language-bash"># 1. 의존성 설치
bundle install

# 2. 데이터베이스 설정
rails db:create
rails db:migrate
rails db:seed

# 3. 서버 실행
rails server

# 4. 브라우저 접속
http://localhost:3000</code></pre>
<h2 id="ruby-on-rails의-미래">Ruby on Rails의 미래</h2>
<h3 id="발전-방향">발전 방향</h3>
<ol>
<li><strong>성능 개선</strong>: Ruby 3.0+의 성능 향상</li>
<li><strong>모던 웹</strong>: Hotwire로 SPA 없는 리치 웹앱</li>
<li><strong>API 모드</strong>: Rails API + React/Vue 조합 증가</li>
<li><strong>컨테이너화</strong>: Docker, Kubernetes 지원 강화</li>
</ol>
<h3 id="개인적인-생각">개인적인 생각</h3>
<p>Rails를 선택한 이유는 단순합니다. <strong>빠르게 아이디어를 실현할 수 있기 때문</strong>입니다. </p>
<p>몇 시간 만에 완전히 작동하는 블로그 시스템을 만들 수 있었고, 코드도 읽기 쉽고 유지보수하기 좋습니다. 비록 성능상 한계는 있지만, 개인 프로젝트나 중소규모 서비스에는 여전히 최고의 선택 중 하나라고 생각합니다.</p>
<h2 id="결론">결론</h2>
<p>Ruby on Rails는:</p>
<ul>
<li>✅ <strong>빠른 개발</strong>이 필요한 프로젝트에 최적</li>
<li>✅ <strong>스타트업이나 MVP</strong> 개발에 탁월</li>
<li>✅ <strong>개발자 경험</strong>을 중시하는 팀에 적합</li>
<li>⚠️ <strong>대용량 트래픽</strong> 처리에는 추가 고려 필요</li>
<li>⚠️ <strong>성능이 최우선</strong>인 프로젝트는 다른 선택 고려</li>
</ul>
<p>2024년 현재도 많은 개발자들이 Rails를 선택하는 이유는 명확합니다. 복잡한 설정 없이 비즈니스 로직에 집중할 수 있고, 풍부한 생태계와 커뮤니티가 뒷받침하고 있기 때문입니다.</p>
<p>앞으로도 Rails는 웹 개발의 좋은 선택지 중 하나로 남을 것 같습니다. 특히 <strong>빠른 프로토타이핑</strong>과 <strong>개발 생산성</strong>을 중시하는 환경에서는 여전히 강력한 도구입니다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 로 기상정보 가져오기 고급활용법(맛보기 시리즈 2-8)]]></title>
            <link>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EA%B3%A0%EA%B8%89%ED%99%9C%EC%9A%A9%EB%B2%95%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-8</link>
            <guid>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EA%B3%A0%EA%B8%89%ED%99%9C%EC%9A%A9%EB%B2%95%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-8</guid>
            <pubDate>Fri, 29 Aug 2025 06:19:47 GMT</pubDate>
            <description><![CDATA[<h1 id="10-배치-고급-활용법-및-실무-팁-총정리">10. 배치 고급 활용법 및 실무 팁 총정리</h1>
<h2 id="🎯-학습-총괄">🎯 학습 총괄</h2>
<p>이 문서는 Spring Batch 학습의 최종 단계로, <strong>실무에서 필요한 고급 활용법과 노하우</strong>를 종합적으로 다룹니다.</p>
<h2 id="🚀-고급-아키텍처-패턴">🚀 고급 아키텍처 패턴</h2>
<h3 id="1-마이크로서비스-환경에서의-배치">1. 마이크로서비스 환경에서의 배치</h3>
<pre><code class="language-java">/**
 * 분산 배치 시스템 아키�ecture
 */
@Configuration
@EnableScheduling
public class DistributedBatchConfig {

    /**
     * 배치 작업 분산 처리를 위한 Message Queue 기반 아키텍처
     */
    @Bean
    public Job distributedProcessingJob() {
        return new JobBuilder(&quot;distributedProcessingJob&quot;, jobRepository)
            .start(dataPartitionStep())        // 1. 데이터 분할
            .next(messagePublishStep())        // 2. 메시지 큐에 작업 발행
            .next(resultAggregationStep())     // 3. 결과 수집 및 집계
            .build();
    }

    /**
     * Kafka를 통한 배치 작업 분산
     */
    @KafkaListener(topics = &quot;batch-work-queue&quot;)
    public void processBatchWork(@Payload BatchWorkMessage message) {
        try {
            // 분산된 배치 작업 처리
            JobParameters params = new JobParametersBuilder()
                .addString(&quot;workId&quot;, message.getWorkId())
                .addString(&quot;dataRange&quot;, message.getDataRange())
                .toJobParameters();

            jobLauncher.run(workerBatchJob, params);

            // 완료 메시지 발행
            kafkaTemplate.send(&quot;batch-completion-topic&quot;, 
                new BatchCompletionMessage(message.getWorkId(), &quot;SUCCESS&quot;));

        } catch (Exception e) {
            // 실패 처리 및 재시도 로직
            handleDistributedBatchFailure(message, e);
        }
    }
}</code></pre>
<h3 id="2-이벤트-기반-배치-시스템">2. 이벤트 기반 배치 시스템</h3>
<pre><code class="language-java">/**
 * 도메인 이벤트 기반 배치 처리
 */
@Component
public class EventDrivenBatchManager {

    /**
     * 주문 완료 이벤트 발생 시 자동 배치 실행
     */
    @EventListener
    @Async
    public void handleOrderCompletedEvent(OrderCompletedEvent event) {
        // 주문 완료 후 1시간 뒤 배송 준비 배치 실행
        scheduleDelayedBatch(&quot;shipping-preparation-job&quot;, 
                           Duration.ofHours(1), 
                           Map.of(&quot;orderId&quot;, event.getOrderId()));
    }

    /**
     * 재고 부족 이벤트 발생 시 긴급 배치 실행
     */
    @EventListener
    public void handleLowStockEvent(LowStockEvent event) {
        // 즉시 재고 보충 요청 배치 실행
        executeImmediateBatch(&quot;stock-replenishment-job&quot;, 
                            Map.of(&quot;productId&quot;, event.getProductId(),
                                   &quot;urgentLevel&quot;, &quot;HIGH&quot;));
    }

    private void scheduleDelayedBatch(String jobName, Duration delay, Map&lt;String, Object&gt; params) {
        // Spring의 TaskScheduler를 사용한 지연 실행
        taskScheduler.schedule(() -&gt; {
            try {
                JobParameters jobParams = createJobParameters(params);
                jobLauncher.run(getJob(jobName), jobParams);
            } catch (Exception e) {
                log.error(&quot;Scheduled batch execution failed: {}&quot;, jobName, e);
                // 실패 시 재시도 로직 또는 알림 발송
            }
        }, Instant.now().plus(delay));
    }
}</code></pre>
<h3 id="3-조건부-배치-체인">3. 조건부 배치 체인</h3>
<pre><code class="language-java">/**
 * 복잡한 비즈니스 로직을 위한 조건부 배치 체인
 */
@Configuration
public class ConditionalBatchChainConfig {

    /**
     * 비즈니스 규칙에 따른 동적 배치 플로우
     */
    @Bean
    public Job smartProcessingJob() {
        return new JobBuilder(&quot;smartProcessingJob&quot;, jobRepository)
            .start(dataAnalysisStep())
            .next(businessRuleDecider())

            // 고객 등급별 분기
            .from(businessRuleDecider()).on(&quot;VIP_CUSTOMERS&quot;)
                .to(vipCustomerProcessingFlow())
            .from(businessRuleDecider()).on(&quot;REGULAR_CUSTOMERS&quot;)
                .to(regularCustomerProcessingFlow())
            .from(businessRuleDecider()).on(&quot;NEW_CUSTOMERS&quot;)
                .to(newCustomerOnboardingFlow())

            // 데이터 볼륨별 분기
            .from(businessRuleDecider()).on(&quot;LARGE_VOLUME&quot;)
                .to(parallelProcessingFlow())
            .from(businessRuleDecider()).on(&quot;SMALL_VOLUME&quot;)
                .to(sequentialProcessingStep())

            // 최종 집계
            .from(vipCustomerProcessingFlow()).on(&quot;*&quot;).to(finalAggregationStep())
            .from(regularCustomerProcessingFlow()).on(&quot;*&quot;).to(finalAggregationStep())
            .from(newCustomerOnboardingFlow()).on(&quot;*&quot;).to(finalAggregationStep())
            .from(sequentialProcessingStep()).on(&quot;*&quot;).to(finalAggregationStep())

            .build();
    }

    /**
     * 비즈니스 규칙 결정자
     */
    @Bean
    public JobExecutionDecider businessRuleDecider() {
        return (jobExecution, stepExecution) -&gt; {

            // 데이터 분석 결과 조회
            ExecutionContext context = stepExecution.getExecutionContext();
            int totalRecords = context.getInt(&quot;totalRecords&quot;, 0);
            String customerSegment = context.getString(&quot;primarySegment&quot;, &quot;REGULAR&quot;);
            boolean isEndOfMonth = isEndOfMonth();
            boolean isHighTrafficPeriod = isHighTrafficPeriod();

            // 복합 조건 평가
            if (isEndOfMonth &amp;&amp; &quot;VIP&quot;.equals(customerSegment)) {
                return new FlowExecutionStatus(&quot;VIP_CUSTOMERS&quot;);
            }

            if (totalRecords &gt; 100000 || isHighTrafficPeriod) {
                return new FlowExecutionStatus(&quot;LARGE_VOLUME&quot;);
            }

            if (&quot;NEW&quot;.equals(customerSegment)) {
                return new FlowExecutionStatus(&quot;NEW_CUSTOMERS&quot;);
            }

            return new FlowExecutionStatus(&quot;REGULAR_CUSTOMERS&quot;);
        };
    }
}</code></pre>
<h2 id="💡-실무-성능-최적화-기법">💡 실무 성능 최적화 기법</h2>
<h3 id="1-동적-청크-크기-조정">1. 동적 청크 크기 조정</h3>
<pre><code class="language-java">/**
 * 런타임 성능에 따른 동적 청크 크기 조정
 */
@Component
public class AdaptiveChunkSizeManager {

    private final AtomicInteger currentChunkSize = new AtomicInteger(100);
    private final MovingAverage processingTimeAvg = new MovingAverage(10);
    private final MovingAverage memoryUsageAvg = new MovingAverage(5);

    /**
     * 성능 메트릭 기반 청크 크기 자동 조정
     */
    public int getOptimalChunkSize() {
        double avgProcessingTime = processingTimeAvg.getAverage();
        double avgMemoryUsage = memoryUsageAvg.getAverage();
        int current = currentChunkSize.get();

        // 메모리 사용률이 높으면 청크 크기 감소
        if (avgMemoryUsage &gt; 0.8) {
            int newSize = Math.max(10, (int) (current * 0.8));
            currentChunkSize.set(newSize);
            log.info(&quot;Reducing chunk size due to high memory usage: {} -&gt; {}&quot;, current, newSize);
            return newSize;
        }

        // 처리 시간이 목표보다 길면 청크 크기 감소
        if (avgProcessingTime &gt; 5000) { // 5초 초과
            int newSize = Math.max(10, (int) (current * 0.9));
            currentChunkSize.set(newSize);
            log.info(&quot;Reducing chunk size due to slow processing: {} -&gt; {}&quot;, current, newSize);
            return newSize;
        }

        // 성능이 좋으면 청크 크기 증가 시도
        if (avgProcessingTime &lt; 1000 &amp;&amp; avgMemoryUsage &lt; 0.6) {
            int newSize = Math.min(1000, (int) (current * 1.2));
            currentChunkSize.set(newSize);
            log.info(&quot;Increasing chunk size due to good performance: {} -&gt; {}&quot;, current, newSize);
            return newSize;
        }

        return current;
    }

    /**
     * 성능 메트릭 업데이트
     */
    public void updateMetrics(long processingTime, double memoryUsage) {
        processingTimeAvg.add(processingTime);
        memoryUsageAvg.add(memoryUsage);
    }
}

/**
 * 동적 청크 크기를 사용하는 Step
 */
@Bean
@JobScope
public Step adaptiveStep() {
    return new StepBuilder(&quot;adaptiveStep&quot;, jobRepository)
        .&lt;InputData, OutputData&gt;chunk(adaptiveChunkSizeManager::getOptimalChunkSize, transactionManager)
        .reader(reader())
        .processor(performanceAwareProcessor())
        .writer(writer())
        .listener(new ChunkPerformanceListener(adaptiveChunkSizeManager))
        .build();
}</code></pre>
<h3 id="2-지능형-배치-스케줄링">2. 지능형 배치 스케줄링</h3>
<pre><code class="language-java">/**
 * 시스템 부하와 비즈니스 우선순위를 고려한 스마트 스케줄링
 */
@Component
@EnableScheduling
public class IntelligentBatchScheduler {

    private final PriorityQueue&lt;ScheduledBatch&gt; batchQueue = new PriorityQueue&lt;&gt;(
        Comparator.comparing(ScheduledBatch::getPriority).reversed()
            .thenComparing(ScheduledBatch::getScheduledTime)
    );

    /**
     * 시스템 리소스 상태 기반 배치 실행 결정
     */
    @Scheduled(fixedDelay = 30000) // 30초마다 체크
    public void executeScheduledBatches() {
        SystemResourceInfo resources = getSystemResources();

        // 시스템 부하가 높으면 중요한 배치만 실행
        if (resources.getCpuUsage() &gt; 80 || resources.getMemoryUsage() &gt; 85) {
            log.warn(&quot;High system load detected. Only critical batches will run.&quot;);
            executeCriticalBatchesOnly();
            return;
        }

        // 비즈니스 시간대별 실행 정책
        LocalTime now = LocalTime.now();
        if (isBusinessHours(now)) {
            // 업무시간: 가벼운 배치만 실행
            executeLeightweightBatches(resources);
        } else {
            // 야간/주말: 모든 배치 실행 가능
            executeAllPendingBatches(resources);
        }
    }

    /**
     * 동적 우선순위 계산
     */
    private int calculateDynamicPriority(BatchJobInfo jobInfo) {
        int basePriority = jobInfo.getBasePriority();

        // SLA 데드라인 접근도
        Duration timeToDeadline = Duration.between(LocalDateTime.now(), jobInfo.getDeadline());
        if (timeToDeadline.toHours() &lt; 2) {
            basePriority += 50; // 급상승
        } else if (timeToDeadline.toHours() &lt; 6) {
            basePriority += 20;
        }

        // 재시도 횟수에 따른 우선순위 감소
        basePriority -= (jobInfo.getRetryCount() * 5);

        // 데이터 의존성 체크
        if (hasWaitingDependentJobs(jobInfo)) {
            basePriority += 30;
        }

        return Math.max(0, basePriority);
    }

    /**
     * 스마트 리소스 할당
     */
    private ExecutionPlan createExecutionPlan(List&lt;ScheduledBatch&gt; batches, SystemResourceInfo resources) {
        ExecutionPlan plan = new ExecutionPlan();

        // 메모리 집약적 vs CPU 집약적 배치 분류
        List&lt;ScheduledBatch&gt; memoryIntensive = batches.stream()
            .filter(b -&gt; b.getResourceProfile().isMemoryIntensive())
            .toList();

        List&lt;ScheduledBatch&gt; cpuIntensive = batches.stream()
            .filter(b -&gt; b.getResourceProfile().isCpuIntensive())
            .toList();

        // 리소스별 최적 스케줄링
        if (resources.getMemoryUsage() &lt; 60) {
            plan.addParallelExecution(memoryIntensive, 2); // 메모리 충분하면 병렬 실행
        } else {
            plan.addSequentialExecution(memoryIntensive); // 순차 실행
        }

        if (resources.getCpuUsage() &lt; 50) {
            plan.addParallelExecution(cpuIntensive, 4); // CPU 여유롭면 높은 병렬도
        } else {
            plan.addParallelExecution(cpuIntensive, 2); // 제한된 병렬도
        }

        return plan;
    }
}</code></pre>
<h3 id="3-메모리-최적화-itemreader">3. 메모리 최적화 ItemReader</h3>
<pre><code class="language-java">/**
 * 대용량 데이터 처리를 위한 메모리 효율적 ItemReader
 */
public class OptimizedLargeDataReader implements ItemReader&lt;DataRecord&gt; {

    private final DataSource dataSource;
    private final String query;
    private final int fetchSize;
    private Connection connection;
    private PreparedStatement statement;
    private ResultSet resultSet;
    private boolean initialized = false;

    public OptimizedLargeDataReader(DataSource dataSource, String query, int fetchSize) {
        this.dataSource = dataSource;
        this.query = query;
        this.fetchSize = fetchSize;
    }

    @Override
    public DataRecord read() throws Exception {
        if (!initialized) {
            initialize();
        }

        if (resultSet.next()) {
            return mapResultSetToRecord(resultSet);
        } else {
            // 메모리 해제
            closeResources();
            return null;
        }
    }

    private void initialize() throws SQLException {
        connection = dataSource.getConnection();

        // 메모리 효율성을 위한 설정
        connection.setAutoCommit(false);
        statement = connection.prepareStatement(query, 
                                              ResultSet.TYPE_FORWARD_ONLY, 
                                              ResultSet.CONCUR_READ_ONLY);

        // MySQL/PostgreSQL 스트리밍 설정
        statement.setFetchSize(fetchSize);
        if (connection.getMetaData().getDriverName().contains(&quot;mysql&quot;)) {
            statement.setFetchSize(Integer.MIN_VALUE); // MySQL streaming
        }

        resultSet = statement.executeQuery();
        initialized = true;

        log.info(&quot;Initialized streaming reader with fetch size: {}&quot;, fetchSize);
    }

    /**
     * 메모리 사용량 모니터링
     */
    @PreDestroy
    public void monitorMemoryUsage() {
        Runtime runtime = Runtime.getRuntime();
        long usedMemory = runtime.totalMemory() - runtime.freeMemory();
        long maxMemory = runtime.maxMemory();
        double usagePercentage = (double) usedMemory / maxMemory * 100;

        if (usagePercentage &gt; 85) {
            log.warn(&quot;High memory usage detected: {:.1f}%. Consider reducing fetch size.&quot;, usagePercentage);

            // 가비지 컬렉션 힌트
            runtime.gc();
        }
    }
}</code></pre>
<h2 id="🛡️-프로덕션-운영-베스트-프랙티스">🛡️ 프로덕션 운영 베스트 프랙티스</h2>
<h3 id="1-배치-보안-강화">1. 배치 보안 강화</h3>
<pre><code class="language-java">/**
 * 엔터프라이즈급 배치 보안 설정
 */
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class BatchSecurityConfig {

    /**
     * 배치 실행 권한 제어
     */
    @Bean
    public JobOperatorSecurityWrapper secureJobOperator(JobOperator jobOperator) {
        return new JobOperatorSecurityWrapper(jobOperator);
    }

    /**
     * 데이터 암호화를 위한 ItemProcessor
     */
    @Bean
    public ItemProcessor&lt;SensitiveData, SensitiveData&gt; encryptionProcessor() {
        return item -&gt; {
            // 민감한 데이터 암호화
            if (item.containsSensitiveInformation()) {
                item.setEncryptedData(encryptionService.encrypt(item.getSensitiveData()));
                item.clearPlainTextData(); // 원본 데이터 삭제
            }

            // 데이터 마스킹 (로그용)
            item.setMaskedData(maskingService.maskSensitiveFields(item));

            return item;
        };
    }

    /**
     * 감사 로그 기록
     */
    @Component
    public class BatchAuditLogger implements JobExecutionListener {

        @Override
        public void beforeJob(JobExecution jobExecution) {
            auditService.logBatchStart(
                jobExecution.getJobInstance().getJobName(),
                SecurityContextHolder.getContext().getAuthentication().getName(),
                extractSensitiveParameters(jobExecution.getJobParameters())
            );
        }

        @Override
        public void afterJob(JobExecution jobExecution) {
            auditService.logBatchCompletion(
                jobExecution.getId(),
                jobExecution.getStatus(),
                calculateDataAccessSummary(jobExecution)
            );
        }

        private DataAccessSummary calculateDataAccessSummary(JobExecution jobExecution) {
            return jobExecution.getStepExecutions().stream()
                .collect(DataAccessSummary.collector());
        }
    }
}</code></pre>
<h3 id="2-배치-성능-프로파일링">2. 배치 성능 프로파일링</h3>
<pre><code class="language-java">/**
 * 실시간 배치 성능 프로파일링 시스템
 */
@Component
public class BatchPerformanceProfiler {

    private final MeterRegistry meterRegistry;
    private final Map&lt;String, Timer.Sample&gt; activeTimers = new ConcurrentHashMap&lt;&gt;();

    /**
     * 상세 성능 메트릭 수집
     */
    @EventListener
    public void handleStepStart(StepExecutionEvent event) {
        String stepName = event.getStepExecution().getStepName();

        // 타이머 시작
        Timer.Sample sample = Timer.start(meterRegistry);
        activeTimers.put(stepName, sample);

        // 메모리 스냅샷
        recordMemorySnapshot(stepName + &quot;.start&quot;);

        // CPU 사용률 모니터링 시작
        startCpuMonitoring(stepName);
    }

    @EventListener
    public void handleStepEnd(StepExecutionEvent event) {
        String stepName = event.getStepExecution().getStepName();
        StepExecution execution = event.getStepExecution();

        // 타이머 종료
        Timer.Sample sample = activeTimers.remove(stepName);
        if (sample != null) {
            sample.stop(Timer.builder(&quot;batch.step.duration&quot;)
                .tag(&quot;step&quot;, stepName)
                .tag(&quot;status&quot;, execution.getStatus().toString())
                .register(meterRegistry));
        }

        // 처리량 메트릭
        recordThroughputMetrics(stepName, execution);

        // 메모리 사용량 변화
        recordMemorySnapshot(stepName + &quot;.end&quot;);

        // 성능 분석 리포트 생성
        generatePerformanceReport(stepName, execution);
    }

    /**
     * 실시간 성능 분석 및 알림
     */
    private void generatePerformanceReport(String stepName, StepExecution execution) {
        PerformanceReport report = PerformanceReport.builder()
            .stepName(stepName)
            .duration(calculateDuration(execution))
            .throughput(calculateThroughput(execution))
            .memoryUsed(calculateMemoryDelta(stepName))
            .errorRate(calculateErrorRate(execution))
            .build();

        // 성능 임계값 체크
        if (report.getThroughput() &lt; getExpectedThroughput(stepName) * 0.8) {
            alertService.sendPerformanceAlert(
                &quot;Low throughput detected in step: &quot; + stepName,
                report
            );
        }

        // 성능 히스토리 저장
        performanceHistoryService.save(report);
    }

    /**
     * 성능 트렌드 분석
     */
    @Scheduled(fixedRate = 300000) // 5분마다
    public void analyzePerfomanceTrends() {
        List&lt;PerformanceReport&gt; recentReports = 
            performanceHistoryService.getRecentReports(Duration.ofHours(1));

        for (String stepName : getActiveStepNames()) {
            List&lt;PerformanceReport&gt; stepReports = recentReports.stream()
                .filter(r -&gt; r.getStepName().equals(stepName))
                .sorted(Comparator.comparing(PerformanceReport::getTimestamp))
                .toList();

            if (stepReports.size() &lt; 3) continue;

            // 성능 저하 트렌드 탐지
            if (isPerformanceDegrading(stepReports)) {
                alertService.sendTrendAlert(
                    &quot;Performance degradation trend detected in step: &quot; + stepName,
                    calculateTrendAnalysis(stepReports)
                );
            }
        }
    }
}</code></pre>
<h3 id="3-고급-데이터-검증-시스템">3. 고급 데이터 검증 시스템</h3>
<pre><code class="language-java">/**
 * 엔터프라이즈급 데이터 품질 보장 시스템
 */
@Component
public class DataQualityManager {

    /**
     * 다층 데이터 검증 체계
     */
    public class MultiLevelValidationProcessor implements ItemProcessor&lt;RawData, ValidatedData&gt; {

        private final List&lt;DataValidator&gt; validators;
        private final DataQualityReporter reporter;

        @Override
        public ValidatedData process(RawData item) throws Exception {
            ValidationContext context = new ValidationContext(item);

            // 1단계: 기본 스키마 검증
            ValidationResult schemaResult = validateSchema(item, context);
            if (!schemaResult.isValid()) {
                reporter.recordValidationFailure(&quot;SCHEMA&quot;, item, schemaResult.getErrors());
                return null; // 스키마 오류는 처리 불가
            }

            // 2단계: 비즈니스 규칙 검증
            ValidationResult businessResult = validateBusinessRules(item, context);
            if (!businessResult.isValid()) {
                // 비즈니스 규칙 위반은 quarantine으로 분류
                return createQuarantineRecord(item, businessResult.getErrors());
            }

            // 3단계: 데이터 일관성 검증
            ValidationResult consistencyResult = validateDataConsistency(item, context);
            if (!consistencyResult.isValid()) {
                // 일관성 문제는 수정 시도
                item = attemptDataCorrection(item, consistencyResult.getErrors());
            }

            // 4단계: 품질 점수 계산
            double qualityScore = calculateQualityScore(item, context);

            ValidatedData result = new ValidatedData(item);
            result.setQualityScore(qualityScore);
            result.setValidationTimestamp(LocalDateTime.now());

            // 품질 메트릭 기록
            reporter.recordQualityMetrics(result);

            return result;
        }

        /**
         * AI/ML 기반 데이터 품질 예측
         */
        private double predictDataQuality(RawData item) {
            // 머신러닝 모델을 사용한 품질 예측
            return qualityPredictionModel.predict(
                extractQualityFeatures(item)
            );
        }
    }

    /**
     * 실시간 데이터 품질 대시보드
     */
    @RestController
    @RequestMapping(&quot;/api/data-quality&quot;)
    public class DataQualityController {

        @GetMapping(&quot;/dashboard&quot;)
        public DataQualityDashboard getQualityDashboard() {
            return DataQualityDashboard.builder()
                .overallQualityScore(calculateOverallQualityScore())
                .qualityTrends(getQualityTrends(Duration.ofDays(7)))
                .topQualityIssues(getTopQualityIssues(10))
                .qualityByDataSource(getQualityByDataSource())
                .qualityImprovement(getQualityImprovementSuggestions())
                .build();
        }

        @GetMapping(&quot;/quality-rules&quot;)
        public List&lt;DataQualityRule&gt; getActiveQualityRules() {
            return dataQualityRuleManager.getActiveRules();
        }

        @PostMapping(&quot;/quality-rules&quot;)
        public ResponseEntity&lt;DataQualityRule&gt; createQualityRule(@RequestBody DataQualityRule rule) {
            DataQualityRule created = dataQualityRuleManager.createRule(rule);
            return ResponseEntity.ok(created);
        }
    }
}</code></pre>
<h2 id="📈-비즈니스-가치-극대화">📈 비즈니스 가치 극대화</h2>
<h3 id="1-배치-roi-측정-시스템">1. 배치 ROI 측정 시스템</h3>
<pre><code class="language-java">/**
 * 배치 시스템의 비즈니스 가치 측정
 */
@Service
public class BatchBusinessValueCalculator {

    /**
     * 배치 시스템 ROI 계산
     */
    public BatchROIReport calculateBatchROI(String jobName, Period period) {

        // 1. 비용 계산
        BatchCostInfo costs = calculateBatchCosts(jobName, period);

        // 2. 효익 계산
        BatchBenefitInfo benefits = calculateBatchBenefits(jobName, period);

        // 3. ROI 계산
        double roi = (benefits.getTotalValue() - costs.getTotalCost()) / costs.getTotalCost() * 100;

        return BatchROIReport.builder()
            .jobName(jobName)
            .period(period)
            .totalCosts(costs)
            .totalBenefits(benefits)
            .roi(roi)
            .paybackPeriod(calculatePaybackPeriod(costs, benefits))
            .recommendations(generateOptimizationRecommendations(costs, benefits))
            .build();
    }

    private BatchBenefitInfo calculateBatchBenefits(String jobName, Period period) {
        return BatchBenefitInfo.builder()
            // 자동화로 인한 인력 절약
            .laborCostSaving(calculateLaborCostSaving(jobName, period))
            // 처리 시간 단축으로 인한 기회비용 절약
            .timeSaving(calculateTimeSavingValue(jobName, period))
            // 오류 감소로 인한 비용 절약
            .errorReductionValue(calculateErrorReductionValue(jobName, period))
            // 고객 만족도 향상 가치
            .customerSatisfactionValue(calculateCustomerSatisfactionValue(jobName, period))
            // 컴플라이언스 준수 가치
            .complianceValue(calculateComplianceValue(jobName, period))
            .build();
    }

    /**
     * 배치 성능과 비즈니스 KPI 연결
     */
    public BusinessImpactAnalysis analyzeBatchBusinessImpact(String jobName) {

        // 배치 성능 메트릭 수집
        BatchPerformanceMetrics performance = getRecentPerformanceMetrics(jobName);

        // 비즈니스 KPI와의 상관관계 분석
        List&lt;KPICorrelation&gt; correlations = analyzeKPICorrelations(jobName, performance);

        // 개선 기회 식별
        List&lt;ImprovementOpportunity&gt; opportunities = identifyImprovementOpportunities(correlations);

        return BusinessImpactAnalysis.builder()
            .jobName(jobName)
            .performanceMetrics(performance)
            .kpiCorrelations(correlations)
            .improvementOpportunities(opportunities)
            .estimatedBusinessImpact(calculateEstimatedImpact(opportunities))
            .build();
    }
}</code></pre>
<h3 id="2-지속적-개선-시스템">2. 지속적 개선 시스템</h3>
<pre><code class="language-java">/**
 * 배치 시스템 지속적 개선 자동화
 */
@Component
public class ContinuousImprovementEngine {

    /**
     * A/B 테스트 기반 배치 최적화
     */
    @Scheduled(cron = &quot;0 0 2 * * SUN&quot;) // 매주 일요일 새벽 2시
    public void runWeeklyOptimizationExperiments() {

        List&lt;String&gt; jobNames = getActiveJobNames();

        for (String jobName : jobNames) {
            // 현재 성능 베이스라인 설정
            PerformanceBaseline baseline = establishPerformanceBaseline(jobName);

            // 최적화 실험 후보 생성
            List&lt;OptimizationExperiment&gt; experiments = generateOptimizationExperiments(jobName, baseline);

            for (OptimizationExperiment experiment : experiments) {
                // 실험 환경에서 테스트 실행
                ExperimentResult result = runOptimizationExperiment(experiment);

                // 결과 분석
                if (result.isSignificantImprovement()) {
                    // 프로덕션 적용 제안
                    proposeProductionDeployment(experiment, result);
                }

                // 실험 결과 저장
                optimizationHistoryService.save(experiment, result);
            }
        }
    }

    /**
     * 머신러닝 기반 성능 예측 및 최적화
     */
    public class MLBasedOptimizer {

        private final MachineLearningModel performancePredictionModel;
        private final MachineLearningModel configurationOptimizationModel;

        /**
         * 성능 예측 기반 사전 최적화
         */
        public void predictiveOptimization(String jobName) {

            // 과거 성능 데이터 수집
            List&lt;PerformanceRecord&gt; historicalData = getHistoricalPerformance(jobName, Duration.ofDays(90));

            // 미래 워크로드 예측
            WorkloadForecast forecast = predictFutureWorkload(jobName, Duration.ofDays(7));

            // 최적 설정 예측
            BatchConfiguration optimalConfig = configurationOptimizationModel.predict(
                OptimizationInput.builder()
                    .historicalPerformance(historicalData)
                    .forecastedWorkload(forecast)
                    .systemConstraints(getCurrentSystemConstraints())
                    .businessRequirements(getBusinessRequirements(jobName))
                    .build()
            );

            // 예측된 최적 설정 적용 제안
            if (isSignificantConfigurationChange(optimalConfig)) {
                proposeConfigurationChange(jobName, optimalConfig);
            }
        }
    }
}</code></pre>
<h2 id="🎓-spring-batch-학습-로드맵-완성">🎓 Spring Batch 학습 로드맵 완성</h2>
<h3 id="실력-수준별-체크리스트">실력 수준별 체크리스트</h3>
<h4 id="🔰-초급-beginner">🔰 초급 (Beginner)</h4>
<ul>
<li><input disabled="" type="checkbox"> Spring Batch 기본 개념 이해 (Job, Step, ItemReader/Processor/Writer)</li>
<li><input disabled="" type="checkbox"> 간단한 CSV → DB 배치 구현</li>
<li><input disabled="" type="checkbox"> H2 데이터베이스로 로컬 테스트 환경 구축</li>
<li><input disabled="" type="checkbox"> 기본적인 에러 처리 (Skip, Retry) 이해</li>
<li><input disabled="" type="checkbox"> 배치 메타데이터 테이블 이해</li>
</ul>
<h4 id="🎯-중급-intermediate">🎯 중급 (Intermediate)</h4>
<ul>
<li><input disabled="" type="checkbox"> 복잡한 비즈니스 로직이 포함된 ItemProcessor 구현</li>
<li><input disabled="" type="checkbox"> 데이터베이스 간 데이터 이관 배치 작성</li>
<li><input disabled="" type="checkbox"> 조건부 Step 실행 및 Flow 제어</li>
<li><input disabled="" type="checkbox"> 멀티스레드 처리 구현</li>
<li><input disabled="" type="checkbox"> 배치 스케줄링 및 모니터링 시스템 구축</li>
<li><input disabled="" type="checkbox"> 단위 테스트 및 통합 테스트 작성</li>
</ul>
<h4 id="🚀-고급-advanced">🚀 고급 (Advanced)</h4>
<ul>
<li><input disabled="" type="checkbox"> 파티셔닝을 통한 대용량 데이터 분산 처리</li>
<li><input disabled="" type="checkbox"> 복잡한 에러 처리 정책 및 재시작 메커니즘 구현</li>
<li><input disabled="" type="checkbox"> 성능 최적화 및 튜닝</li>
<li><input disabled="" type="checkbox"> 마이크로서비스 환경에서의 분산 배치 시스템</li>
<li><input disabled="" type="checkbox"> 실시간 모니터링 및 알림 시스템</li>
<li><input disabled="" type="checkbox"> 보안 및 감사 로깅 구현</li>
</ul>
<h4 id="🏆-전문가-expert">🏆 전문가 (Expert)</h4>
<ul>
<li><input disabled="" type="checkbox"> 엔터프라이즈급 배치 아키텍처 설계</li>
<li><input disabled="" type="checkbox"> AI/ML 기반 배치 최적화 시스템 구축  </li>
<li><input disabled="" type="checkbox"> 클라우드 네이티브 배치 시스템 설계</li>
<li><input disabled="" type="checkbox"> 배치 시스템 ROI 측정 및 비즈니스 가치 분석</li>
<li><input disabled="" type="checkbox"> 지속적 개선 및 자동화 시스템 구축</li>
<li><input disabled="" type="checkbox"> 팀 리딩 및 기술 멘토링</li>
</ul>
<h3 id="실무-프로젝트-추천-순서">실무 프로젝트 추천 순서</h3>
<ol>
<li><strong>개인 프로젝트</strong>: 간단한 데이터 ETL 배치</li>
<li><strong>팀 프로젝트</strong>: 회사 내부 데이터 처리 자동화</li>
<li><strong>서비스 프로젝트</strong>: 고객 대상 배치 기반 서비스</li>
<li><strong>플랫폼 프로젝트</strong>: 범용 배치 플랫폼 구축</li>
<li><strong>오픈소스 기여</strong>: Spring Batch 생태계 기여</li>
</ol>
<h2 id="🎯-마무리-및-실무-조언">🎯 마무리 및 실무 조언</h2>
<h3 id="🔑-핵심-원칙">🔑 핵심 원칙</h3>
<ol>
<li><strong>단순함 추구</strong>: 복잡한 배치보다는 이해하기 쉬운 배치</li>
<li><strong>점진적 개선</strong>: 처음부터 완벽하려 하지 말고 단계적 발전</li>
<li><strong>모니터링 우선</strong>: 배치 상태를 항상 파악할 수 있도록 구성</li>
<li><strong>에러 대응</strong>: 실패는 당연한 것, 복구 가능한 시스템 설계</li>
<li><strong>문서화</strong>: 배치 로직과 운영 가이드 필수 작성</li>
</ol>
<h3 id="💡-실무-성공-팁">💡 실무 성공 팁</h3>
<ul>
<li><strong>작게 시작하기</strong>: 큰 배치를 여러 개의 작은 배치로 분할</li>
<li><strong>테스트 환경 구축</strong>: 프로덕션과 동일한 테스트 환경 필수</li>
<li><strong>성능 측정</strong>: 처음부터 성능 메트릭 수집 체계 구축</li>
<li><strong>장애 대응 계획</strong>: 배치 실패 시 대응 절차 미리 수립</li>
<li><strong>지속적 학습</strong>: Spring Batch 커뮤니티 및 최신 동향 팔로우</li>
</ul>
<hr>
<p>🎉 <strong>축하합니다!</strong> Spring Batch의 모든 핵심 개념과 실무 활용법을 마스터했습니다. </p>
<p>이제 여러분은 <strong>엔터프라이즈급 배치 시스템을 설계하고 구현할 수 있는 전문가</strong>가 되었습니다. </p>
<p>지속적인 실습과 실무 적용을 통해 더욱 발전하시기 바랍니다! 🚀</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 로 기상정보 가져오기 에러처리(맛보기 시리즈 2-8)]]></title>
            <link>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EC%97%90%EB%9F%AC%EC%B2%98%EB%A6%AC%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-8</link>
            <guid>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EC%97%90%EB%9F%AC%EC%B2%98%EB%A6%AC%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-8</guid>
            <pubDate>Fri, 29 Aug 2025 06:19:00 GMT</pubDate>
            <description><![CDATA[<h1 id="09-배치-학습-5단계-에러-처리-재시작-모니터링">09. 배치 학습 5단계: 에러 처리, 재시작, 모니터링</h1>
<h2 id="🎯-학습-목표">🎯 학습 목표</h2>
<ul>
<li>배치 실행 중 발생하는 다양한 에러 상황 처리</li>
<li>Skip, Retry, Restart 정책 구현</li>
<li>배치 실행 상태 모니터링 및 알림 시스템 구축</li>
<li>실무에서 필요한 로깅 및 추적 기능 구현</li>
</ul>
<h2 id="🚨-에러-처리-전략">🚨 에러 처리 전략</h2>
<h3 id="에러-유형별-처리-방법">에러 유형별 처리 방법</h3>
<pre><code>[Reader 에러]           [Processor 에러]        [Writer 에러]
     ↓                      ↓                    ↓
- 파일 없음/읽기 실패       - 데이터 검증 실패       - DB 연결 실패
- 네트워크 연결 실패       - 외부 API 호출 실패     - 제약조건 위반
- 권한 없음              - 메모리 부족           - 트랜잭션 충돌
     ↓                      ↓                    ↓
[재시도] or [건너뛰기]    [건너뛰기] or [중단]    [재시도] or [중단]</code></pre><h3 id="1-고급-skip-정책-구현">1. 고급 Skip 정책 구현</h3>
<pre><code class="language-java">package com.example.batchtutorial.policy;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.step.skip.SkipLimitExceededException;
import org.springframework.batch.core.step.skip.SkipPolicy;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.web.client.ResourceAccessException;

import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;

/**
 * 에러 유형별 차등 Skip 정책
 */
@Slf4j
public class SmartSkipPolicy implements SkipPolicy {

    private final Map&lt;Class&lt;? extends Throwable&gt;, Integer&gt; skipLimits;
    private final Map&lt;Class&lt;? extends Throwable&gt;, Integer&gt; currentCounts;

    public SmartSkipPolicy() {
        this.skipLimits = new HashMap&lt;&gt;();
        this.currentCounts = new HashMap&lt;&gt;();

        // 에러 유형별 Skip 한도 설정
        skipLimits.put(ValidationException.class, 50);              // 검증 오류: 50개까지
        skipLimits.put(DataIntegrityViolationException.class, 10);  // DB 제약조건: 10개까지
        skipLimits.put(ResourceAccessException.class, 5);           // 네트워크 오류: 5개까지
        skipLimits.put(SQLException.class, 3);                      // SQL 오류: 3개까지
    }

    @Override
    public boolean shouldSkip(Throwable exception, int skipCount) throws SkipLimitExceededException {

        Class&lt;? extends Throwable&gt; exceptionClass = exception.getClass();

        // 에러 유형별 카운터 증가
        currentCounts.put(exceptionClass, currentCounts.getOrDefault(exceptionClass, 0) + 1);

        // 해당 에러 유형의 Skip 한도 확인
        Integer limit = skipLimits.get(exceptionClass);
        if (limit == null) {
            log.error(&quot;Unknown exception type, not skipping: {}&quot;, exceptionClass.getSimpleName());
            return false;  // 알 수 없는 에러는 Skip 하지 않음
        }

        Integer currentCount = currentCounts.get(exceptionClass);

        if (currentCount &lt;= limit) {
            log.warn(&quot;Skipping exception #{} of type {}: {} (limit: {})&quot;, 
                    currentCount, exceptionClass.getSimpleName(), exception.getMessage(), limit);
            return true;
        } else {
            log.error(&quot;Skip limit exceeded for {}: {} &gt; {}&quot;, 
                    exceptionClass.getSimpleName(), currentCount, limit);
            throw new SkipLimitExceededException(limit, exception);
        }
    }

    /**
     * Skip 통계 정보 반환
     */
    public Map&lt;String, Integer&gt; getSkipStatistics() {
        Map&lt;String, Integer&gt; stats = new HashMap&lt;&gt;();
        currentCounts.forEach((clazz, count) -&gt; 
            stats.put(clazz.getSimpleName(), count)
        );
        return stats;
    }
}</code></pre>
<h3 id="2-지능적-retry-정책-구현">2. 지능적 Retry 정책 구현</h3>
<pre><code class="language-java">package com.example.batchtutorial.policy;

import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.TransientDataAccessException;
import org.springframework.retry.RetryContext;
import org.springframework.retry.policy.RetryPolicy;
import org.springframework.web.client.ResourceAccessException;

import java.net.ConnectException;
import java.net.SocketTimeoutException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

/**
 * 에러 유형별 차등 Retry 정책
 */
@Slf4j
public class SmartRetryPolicy implements RetryPolicy {

    private final Map&lt;Class&lt;? extends Throwable&gt;, Integer&gt; retryLimits;
    private final Map&lt;Class&lt;? extends Throwable&gt;, Duration&gt; backoffIntervals;

    public SmartRetryPolicy() {
        this.retryLimits = new HashMap&lt;&gt;();
        this.backoffIntervals = new HashMap&lt;&gt;();

        // 에러 유형별 재시도 설정
        retryLimits.put(ConnectException.class, 5);                    // 연결 오류: 5회
        retryLimits.put(SocketTimeoutException.class, 3);              // 타임아웃: 3회
        retryLimits.put(ResourceAccessException.class, 4);             // 리소스 접근: 4회
        retryLimits.put(TransientDataAccessException.class, 3);        // DB 일시 오류: 3회

        // 백오프 간격 설정
        backoffIntervals.put(ConnectException.class, Duration.ofSeconds(2));
        backoffIntervals.put(SocketTimeoutException.class, Duration.ofSeconds(5));
        backoffIntervals.put(ResourceAccessException.class, Duration.ofSeconds(3));
        backoffIntervals.put(TransientDataAccessException.class, Duration.ofSeconds(1));
    }

    @Override
    public boolean canRetry(RetryContext context) {
        Throwable lastThrowable = context.getLastThrowable();
        if (lastThrowable == null) {
            return true;
        }

        Class&lt;? extends Throwable&gt; exceptionClass = lastThrowable.getClass();
        Integer limit = retryLimits.get(exceptionClass);

        if (limit == null) {
            log.debug(&quot;No retry policy for exception type: {}&quot;, exceptionClass.getSimpleName());
            return false;  // 재시도 정책이 없는 에러는 재시도 안함
        }

        int attemptCount = context.getRetryCount();
        boolean canRetry = attemptCount &lt; limit;

        if (canRetry) {
            Duration backoff = backoffIntervals.get(exceptionClass);
            if (backoff != null &amp;&amp; attemptCount &gt; 0) {
                log.info(&quot;Retrying in {} seconds... (attempt {} of {})&quot;, 
                        backoff.getSeconds(), attemptCount + 1, limit);

                try {
                    Thread.sleep(backoff.toMillis());
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }

            log.warn(&quot;Retrying operation (attempt {} of {}) for error: {}&quot;, 
                    attemptCount + 1, limit, lastThrowable.getMessage());
        } else {
            log.error(&quot;Retry limit exceeded for {}: {} attempts&quot;, 
                    exceptionClass.getSimpleName(), limit);
        }

        return canRetry;
    }

    @Override
    public RetryContext open(RetryContext parent) {
        return new RetryContext() {
            private int count = 0;
            private Throwable lastThrowable;
            private final LocalDateTime startTime = LocalDateTime.now();

            @Override
            public boolean isExhaustedOnly() {
                return false;
            }

            @Override
            public Throwable getLastThrowable() {
                return lastThrowable;
            }

            @Override
            public int getRetryCount() {
                return count;
            }

            @Override
            public void registerThrowable(Throwable throwable) {
                this.lastThrowable = throwable;
                this.count++;
            }

            @Override
            public void setAttribute(String name, Object value) {
                // 필요한 경우 속성 저장 구현
            }

            @Override
            public Object getAttribute(String name) {
                return null;
            }
        };
    }

    @Override
    public void close(RetryContext context) {
        if (context.getRetryCount() &gt; 0) {
            log.info(&quot;Retry operation completed after {} attempts&quot;, context.getRetryCount());
        }
    }
}</code></pre>
<h3 id="3-포괄적-에러-처리-step-설정">3. 포괄적 에러 처리 Step 설정</h3>
<pre><code class="language-java">package com.example.batchtutorial.config;

import com.example.batchtutorial.exception.ValidationException;
import com.example.batchtutorial.policy.SmartRetryPolicy;
import com.example.batchtutorial.policy.SmartSkipPolicy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.TransientDataAccessException;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.client.ResourceAccessException;

import java.net.ConnectException;
import java.net.SocketTimeoutException;

/**
 * 포괄적 에러 처리가 적용된 배치 설정
 */
@Slf4j
@Configuration
public class ErrorHandlingBatchConfig {

    @Autowired
    private JobRepository jobRepository;

    @Autowired
    private PlatformTransactionManager transactionManager;

    /**
     * 에러 처리가 강화된 Job
     */
    @Bean
    public Job resilientJob(Step resilientStep) {
        return new JobBuilder(&quot;resilientJob&quot;, jobRepository)
                .start(resilientStep)
                .build();
    }

    /**
     * 포괄적 에러 처리 Step
     */
    @Bean
    public Step resilientStep(ItemReader&lt;Object&gt; reader,
                             ItemProcessor&lt;Object, Object&gt; processor,
                             ItemWriter&lt;Object&gt; writer) {
        return new StepBuilder(&quot;resilientStep&quot;, jobRepository)
                .&lt;Object, Object&gt;chunk(50, transactionManager)
                .reader(reader)
                .processor(processor)
                .writer(writer)

                // Fault Tolerance 활성화
                .faultTolerant()

                // Skip 정책 설정
                .skipPolicy(new SmartSkipPolicy())
                .skip(ValidationException.class)
                .skip(DataIntegrityViolationException.class)
                .skip(ResourceAccessException.class)

                // Retry 정책 설정
                .retryPolicy(new SmartRetryPolicy())
                .retry(ConnectException.class)
                .retry(SocketTimeoutException.class)
                .retry(TransientDataAccessException.class)

                // 치명적 예외 (Skip/Retry 하지 않음)
                .noSkip(OutOfMemoryError.class)
                .noSkip(StackOverflowError.class)
                .noRetry(OutOfMemoryError.class)

                // 리스너 등록
                .listener(errorHandlingStepListener())
                .listener(new DetailedSkipListener())

                build();
    }

    @Bean
    public ErrorHandlingStepListener errorHandlingStepListener() {
        return new ErrorHandlingStepListener();
    }
}</code></pre>
<h2 id="🔄-배치-재시작-기능">🔄 배치 재시작 기능</h2>
<h3 id="1-재시작-가능한-job-설정">1. 재시작 가능한 Job 설정</h3>
<pre><code class="language-java">package com.example.batchtutorial.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.job.flow.FlowExecutionStatus;
import org.springframework.batch.core.job.flow.JobExecutionDecider;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

/**
 * 재시작 가능한 배치 Job 설정
 */
@Slf4j
@Configuration
public class RestartableBatchConfig {

    @Autowired
    private JobRepository jobRepository;

    @Autowired
    private PlatformTransactionManager transactionManager;

    /**
     * 재시작 가능한 Job
     */
    @Bean
    public Job restartableJob(Step dataProcessingStep, 
                             Step dataValidationStep,
                             Step dataCleanupStep,
                             JobExecutionDecider restartDecider) {
        return new JobBuilder(&quot;restartableJob&quot;, jobRepository)
                .start(dataProcessingStep)
                .next(restartDecider)
                .from(restartDecider).on(&quot;RESTART_NEEDED&quot;).to(dataValidationStep)
                .from(restartDecider).on(&quot;CONTINUE&quot;).to(dataCleanupStep)
                .build();
    }

    /**
     * 데이터 처리 Step (재시작 지점 추적)
     */
    @Bean
    public Step dataProcessingStep(ItemReader&lt;Object&gt; reader,
                                  ItemProcessor&lt;Object, Object&gt; processor,
                                  ItemWriter&lt;Object&gt; writer) {
        return new StepBuilder(&quot;dataProcessingStep&quot;, jobRepository)
                .&lt;Object, Object&gt;chunk(100, transactionManager)
                .reader(reader)
                .processor(processor)
                .writer(writer)

                // 재시작을 위한 상태 저장 활성화
                .allowStartIfComplete(false)  // 완료된 Step은 재시작하지 않음

                // 재시작 지점 추적
                .listener(new RestartPointTrackingListener())

                build();
    }

    /**
     * 재시작 결정자
     */
    @Bean
    public JobExecutionDecider restartDecider() {
        return (jobExecution, stepExecution) -&gt; {

            // 이전 실행에서 실패했는지 확인
            if (stepExecution != null &amp;&amp; stepExecution.getExitStatus().getExitCode().equals(&quot;FAILED&quot;)) {
                log.info(&quot;Previous execution failed, restart needed&quot;);
                return new FlowExecutionStatus(&quot;RESTART_NEEDED&quot;);
            }

            // Step 실행 통계 확인
            if (stepExecution != null &amp;&amp; stepExecution.getSkipCount() &gt; 10) {
                log.warn(&quot;High skip count detected: {}, validation needed&quot;, stepExecution.getSkipCount());
                return new FlowExecutionStatus(&quot;RESTART_NEEDED&quot;);
            }

            return new FlowExecutionStatus(&quot;CONTINUE&quot;);
        };
    }
}</code></pre>
<h3 id="2-재시작-지점-추적-리스너">2. 재시작 지점 추적 리스너</h3>
<pre><code class="language-java">package com.example.batchtutorial.listener;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.*;
import org.springframework.batch.core.listener.StepExecutionListenerSupport;
import org.springframework.batch.item.ExecutionContext;

import java.time.LocalDateTime;

/**
 * 재시작 지점을 추적하는 리스너
 */
@Slf4j
public class RestartPointTrackingListener extends StepExecutionListenerSupport {

    private static final String LAST_PROCESSED_ID = &quot;last.processed.id&quot;;
    private static final String RESTART_COUNT = &quot;restart.count&quot;;
    private static final String LAST_RESTART_TIME = &quot;last.restart.time&quot;;

    @Override
    public void beforeStep(StepExecution stepExecution) {
        ExecutionContext executionContext = stepExecution.getExecutionContext();

        // 재시작 횟수 증가
        int restartCount = executionContext.getInt(RESTART_COUNT, 0);
        if (stepExecution.getJobExecution().getJobInstance().getVersion() &gt; 0) {
            restartCount++;
            executionContext.putInt(RESTART_COUNT, restartCount);
            executionContext.putString(LAST_RESTART_TIME, LocalDateTime.now().toString());

            log.info(&quot;🔄 Job restarted {} times. Last restart: {}&quot;, 
                    restartCount, LocalDateTime.now());
        }

        // 마지막 처리 지점 복원
        Long lastProcessedId = executionContext.getLong(LAST_PROCESSED_ID, 0L);
        if (lastProcessedId &gt; 0) {
            log.info(&quot;📍 Resuming from last processed ID: {}&quot;, lastProcessedId);
        }
    }

    @Override
    public ExitStatus afterStep(StepExecution stepExecution) {
        ExecutionContext executionContext = stepExecution.getExecutionContext();

        // 처리 통계 저장
        executionContext.putLong(&quot;final.read.count&quot;, stepExecution.getReadCount());
        executionContext.putLong(&quot;final.write.count&quot;, stepExecution.getWriteCount());
        executionContext.putLong(&quot;final.skip.count&quot;, stepExecution.getSkipCount());

        // 재시작이 필요한 조건 확인
        if (stepExecution.getSkipCount() &gt; 50) {
            log.warn(&quot;⚠️ High skip count: {}. Step marked for review&quot;, stepExecution.getSkipCount());
            return new ExitStatus(&quot;FAILED&quot;, &quot;High skip count requires review&quot;);
        }

        if (stepExecution.getWriteCount() == 0) {
            log.warn(&quot;⚠️ No items written. Possible data source issue&quot;);
            return new ExitStatus(&quot;FAILED&quot;, &quot;No data processed&quot;);
        }

        log.info(&quot;✅ Step completed successfully. Read: {}, Write: {}, Skip: {}&quot;, 
                stepExecution.getReadCount(), 
                stepExecution.getWriteCount(), 
                stepExecution.getSkipCount());

        return stepExecution.getExitStatus();
    }

    /**
     * 현재 처리 지점 업데이트 (ItemWriter에서 호출)
     */
    public static void updateLastProcessedId(ExecutionContext context, Long id) {
        context.putLong(LAST_PROCESSED_ID, id);
    }
}</code></pre>
<h2 id="📊-실시간-배치-모니터링">📊 실시간 배치 모니터링</h2>
<h3 id="1-배치-상태-모니터링-서비스">1. 배치 상태 모니터링 서비스</h3>
<pre><code class="language-java">package com.example.batchtutorial.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.*;
import org.springframework.batch.core.explore.JobExplorer;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 배치 모니터링 서비스
 */
@Slf4j
@Service
public class BatchMonitoringService {

    @Autowired
    private JobExplorer jobExplorer;

    @Autowired
    private JobOperator jobOperator;

    /**
     * 실행 중인 배치 목록 조회
     */
    public List&lt;BatchExecutionInfo&gt; getRunningBatches() {
        List&lt;BatchExecutionInfo&gt; runningBatches = new ArrayList&lt;&gt;();

        Set&lt;Long&gt; runningExecutions = jobOperator.getRunningExecutions(&quot;*&quot;);

        for (Long executionId : runningExecutions) {
            JobExecution jobExecution = jobExplorer.getJobExecution(executionId);
            if (jobExecution != null) {
                BatchExecutionInfo info = createBatchInfo(jobExecution);
                runningBatches.add(info);
            }
        }

        return runningBatches;
    }

    /**
     * 최근 배치 실행 이력 조회
     */
    public List&lt;BatchExecutionInfo&gt; getRecentBatchHistory(int count) {
        List&lt;String&gt; jobNames = jobExplorer.getJobNames();
        List&lt;BatchExecutionInfo&gt; recentExecutions = new ArrayList&lt;&gt;();

        for (String jobName : jobNames) {
            List&lt;JobInstance&gt; jobInstances = jobExplorer.getJobInstances(jobName, 0, count);

            for (JobInstance instance : jobInstances) {
                List&lt;JobExecution&gt; executions = jobExplorer.getJobExecutions(instance);
                for (JobExecution execution : executions) {
                    BatchExecutionInfo info = createBatchInfo(execution);
                    recentExecutions.add(info);
                }
            }
        }

        // 최근 실행 순으로 정렬
        return recentExecutions.stream()
                .sorted((a, b) -&gt; b.getStartTime().compareTo(a.getStartTime()))
                .limit(count)
                .collect(Collectors.toList());
    }

    /**
     * 특정 Job의 상세 정보 조회
     */
    public DetailedBatchInfo getBatchDetails(Long executionId) {
        JobExecution jobExecution = jobExplorer.getJobExecution(executionId);
        if (jobExecution == null) {
            return null;
        }

        DetailedBatchInfo details = new DetailedBatchInfo();
        details.setJobExecution(jobExecution);

        // Step별 상세 정보
        Collection&lt;StepExecution&gt; stepExecutions = jobExecution.getStepExecutions();
        List&lt;StepExecutionInfo&gt; stepInfos = new ArrayList&lt;&gt;();

        for (StepExecution stepExecution : stepExecutions) {
            StepExecutionInfo stepInfo = createStepInfo(stepExecution);
            stepInfos.add(stepInfo);
        }

        details.setStepExecutions(stepInfos);
        details.setOverallStatistics(calculateOverallStatistics(stepExecutions));

        return details;
    }

    /**
     * 배치 실행 중단
     */
    public boolean stopBatch(Long executionId) {
        try {
            boolean stopped = jobOperator.stop(executionId);
            if (stopped) {
                log.info(&quot;🛑 Batch execution {} stopped successfully&quot;, executionId);
            } else {
                log.warn(&quot;⚠️ Failed to stop batch execution {}&quot;, executionId);
            }
            return stopped;
        } catch (Exception e) {
            log.error(&quot;❌ Error stopping batch execution {}: {}&quot;, executionId, e.getMessage());
            return false;
        }
    }

    /**
     * 배치 재시작
     */
    public Long restartBatch(Long executionId) {
        try {
            Long newExecutionId = jobOperator.restart(executionId);
            log.info(&quot;🔄 Batch execution {} restarted as {}&quot;, executionId, newExecutionId);
            return newExecutionId;
        } catch (Exception e) {
            log.error(&quot;❌ Error restarting batch execution {}: {}&quot;, executionId, e.getMessage());
            return null;
        }
    }

    /**
     * 시스템 상태 체크
     */
    public SystemHealthInfo getSystemHealth() {
        SystemHealthInfo health = new SystemHealthInfo();

        // 메모리 사용량
        Runtime runtime = Runtime.getRuntime();
        long maxMemory = runtime.maxMemory();
        long usedMemory = runtime.totalMemory() - runtime.freeMemory();
        health.setMemoryUsage((double) usedMemory / maxMemory * 100);

        // 실행 중인 배치 개수
        health.setRunningBatchCount(getRunningBatches().size());

        // 최근 실패한 배치 개수
        List&lt;BatchExecutionInfo&gt; recentBatches = getRecentBatchHistory(50);
        long failedCount = recentBatches.stream()
                .filter(batch -&gt; &quot;FAILED&quot;.equals(batch.getStatus()))
                .count();
        health.setRecentFailureCount(failedCount);

        // 전체 상태 결정
        if (health.getMemoryUsage() &gt; 90 || failedCount &gt; 5) {
            health.setOverallStatus(&quot;CRITICAL&quot;);
        } else if (health.getMemoryUsage() &gt; 70 || failedCount &gt; 2) {
            health.setOverallStatus(&quot;WARNING&quot;);
        } else {
            health.setOverallStatus(&quot;HEALTHY&quot;);
        }

        return health;
    }

    // 헬퍼 메서드들
    private BatchExecutionInfo createBatchInfo(JobExecution jobExecution) {
        BatchExecutionInfo info = new BatchExecutionInfo();
        info.setExecutionId(jobExecution.getId());
        info.setJobName(jobExecution.getJobInstance().getJobName());
        info.setStatus(jobExecution.getStatus().toString());
        info.setStartTime(LocalDateTime.ofInstant(jobExecution.getStartTime().toInstant(), ZoneId.systemDefault()));

        if (jobExecution.getEndTime() != null) {
            info.setEndTime(LocalDateTime.ofInstant(jobExecution.getEndTime().toInstant(), ZoneId.systemDefault()));
            info.setDuration(Duration.between(info.getStartTime(), info.getEndTime()));
        }

        // 진행률 계산
        Collection&lt;StepExecution&gt; stepExecutions = jobExecution.getStepExecutions();
        if (!stepExecutions.isEmpty()) {
            long totalRead = stepExecutions.stream().mapToLong(StepExecution::getReadCount).sum();
            long totalWrite = stepExecutions.stream().mapToLong(StepExecution::getWriteCount).sum();

            info.setTotalReadCount(totalRead);
            info.setTotalWriteCount(totalWrite);
            info.setProgress(totalRead &gt; 0 ? (double) totalWrite / totalRead * 100 : 0);
        }

        return info;
    }

    private StepExecutionInfo createStepInfo(StepExecution stepExecution) {
        StepExecutionInfo info = new StepExecutionInfo();
        info.setStepName(stepExecution.getStepName());
        info.setStatus(stepExecution.getStatus().toString());
        info.setReadCount(stepExecution.getReadCount());
        info.setWriteCount(stepExecution.getWriteCount());
        info.setSkipCount(stepExecution.getSkipCount());
        info.setCommitCount(stepExecution.getCommitCount());

        if (stepExecution.getStartTime() != null &amp;&amp; stepExecution.getEndTime() != null) {
            LocalDateTime start = LocalDateTime.ofInstant(stepExecution.getStartTime().toInstant(), ZoneId.systemDefault());
            LocalDateTime end = LocalDateTime.ofInstant(stepExecution.getEndTime().toInstant(), ZoneId.systemDefault());
            info.setDuration(Duration.between(start, end));

            // 처리 속도 계산
            long durationSeconds = info.getDuration().getSeconds();
            if (durationSeconds &gt; 0) {
                info.setThroughput((double) stepExecution.getWriteCount() / durationSeconds);
            }
        }

        return info;
    }

    private Map&lt;String, Object&gt; calculateOverallStatistics(Collection&lt;StepExecution&gt; stepExecutions) {
        Map&lt;String, Object&gt; stats = new HashMap&lt;&gt;();

        long totalRead = stepExecutions.stream().mapToLong(StepExecution::getReadCount).sum();
        long totalWrite = stepExecutions.stream().mapToLong(StepExecution::getWriteCount).sum();
        long totalSkip = stepExecutions.stream().mapToLong(StepExecution::getSkipCount).sum();

        stats.put(&quot;totalRead&quot;, totalRead);
        stats.put(&quot;totalWrite&quot;, totalWrite);
        stats.put(&quot;totalSkip&quot;, totalSkip);
        stats.put(&quot;successRate&quot;, totalRead &gt; 0 ? (double) totalWrite / totalRead * 100 : 0);

        return stats;
    }
}

// 데이터 클래스들
@Data
class BatchExecutionInfo {
    private Long executionId;
    private String jobName;
    private String status;
    private LocalDateTime startTime;
    private LocalDateTime endTime;
    private Duration duration;
    private Long totalReadCount;
    private Long totalWriteCount;
    private Double progress;
}

@Data
class DetailedBatchInfo {
    private JobExecution jobExecution;
    private List&lt;StepExecutionInfo&gt; stepExecutions;
    private Map&lt;String, Object&gt; overallStatistics;
}

@Data
class StepExecutionInfo {
    private String stepName;
    private String status;
    private int readCount;
    private int writeCount;
    private int skipCount;
    private int commitCount;
    private Duration duration;
    private Double throughput;
}

@Data
class SystemHealthInfo {
    private String overallStatus;
    private Double memoryUsage;
    private Integer runningBatchCount;
    private Long recentFailureCount;
    private LocalDateTime checkedAt = LocalDateTime.now();
}</code></pre>
<h3 id="2-배치-모니터링-rest-api">2. 배치 모니터링 REST API</h3>
<pre><code class="language-java">package com.example.batchtutorial.controller;

import com.example.batchtutorial.service.BatchMonitoringService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

/**
 * 배치 모니터링 REST API
 */
@Slf4j
@RestController
@RequestMapping(&quot;/api/batch&quot;)
public class BatchMonitoringController {

    @Autowired
    private BatchMonitoringService monitoringService;

    /**
     * 실행 중인 배치 목록
     */
    @GetMapping(&quot;/running&quot;)
    public List&lt;BatchExecutionInfo&gt; getRunningBatches() {
        return monitoringService.getRunningBatches();
    }

    /**
     * 최근 배치 실행 이력
     */
    @GetMapping(&quot;/history&quot;)
    public List&lt;BatchExecutionInfo&gt; getBatchHistory(
            @RequestParam(defaultValue = &quot;20&quot;) int count) {
        return monitoringService.getRecentBatchHistory(count);
    }

    /**
     * 특정 배치 상세 정보
     */
    @GetMapping(&quot;/{executionId}&quot;)
    public ResponseEntity&lt;DetailedBatchInfo&gt; getBatchDetails(@PathVariable Long executionId) {
        DetailedBatchInfo details = monitoringService.getBatchDetails(executionId);
        if (details != null) {
            return ResponseEntity.ok(details);
        } else {
            return ResponseEntity.notFound().build();
        }
    }

    /**
     * 배치 중단
     */
    @PostMapping(&quot;/{executionId}/stop&quot;)
    public ResponseEntity&lt;Map&lt;String, Object&gt;&gt; stopBatch(@PathVariable Long executionId) {
        boolean stopped = monitoringService.stopBatch(executionId);
        return ResponseEntity.ok(Map.of(
            &quot;success&quot;, stopped,
            &quot;message&quot;, stopped ? &quot;Batch stopped successfully&quot; : &quot;Failed to stop batch&quot;
        ));
    }

    /**
     * 배치 재시작
     */
    @PostMapping(&quot;/{executionId}/restart&quot;)
    public ResponseEntity&lt;Map&lt;String, Object&gt;&gt; restartBatch(@PathVariable Long executionId) {
        Long newExecutionId = monitoringService.restartBatch(executionId);
        if (newExecutionId != null) {
            return ResponseEntity.ok(Map.of(
                &quot;success&quot;, true,
                &quot;newExecutionId&quot;, newExecutionId,
                &quot;message&quot;, &quot;Batch restarted successfully&quot;
            ));
        } else {
            return ResponseEntity.ok(Map.of(
                &quot;success&quot;, false,
                &quot;message&quot;, &quot;Failed to restart batch&quot;
            ));
        }
    }

    /**
     * 시스템 상태 체크
     */
    @GetMapping(&quot;/health&quot;)
    public SystemHealthInfo getSystemHealth() {
        return monitoringService.getSystemHealth();
    }

    /**
     * 배치 통계 대시보드
     */
    @GetMapping(&quot;/dashboard&quot;)
    public Map&lt;String, Object&gt; getDashboardData() {
        List&lt;BatchExecutionInfo&gt; runningBatches = monitoringService.getRunningBatches();
        List&lt;BatchExecutionInfo&gt; recentHistory = monitoringService.getRecentBatchHistory(100);
        SystemHealthInfo health = monitoringService.getSystemHealth();

        // 성공률 계산
        long totalBatches = recentHistory.size();
        long successfulBatches = recentHistory.stream()
                .filter(batch -&gt; &quot;COMPLETED&quot;.equals(batch.getStatus()))
                .count();

        double successRate = totalBatches &gt; 0 ? (double) successfulBatches / totalBatches * 100 : 0;

        return Map.of(
            &quot;runningBatches&quot;, runningBatches,
            &quot;recentHistory&quot;, recentHistory.stream().limit(10).toList(),
            &quot;systemHealth&quot;, health,
            &quot;statistics&quot;, Map.of(
                &quot;totalBatches&quot;, totalBatches,
                &quot;successfulBatches&quot;, successfulBatches,
                &quot;successRate&quot;, successRate
            )
        );
    }
}</code></pre>
<h3 id="3-실시간-알림-시스템">3. 실시간 알림 시스템</h3>
<pre><code class="language-java">package com.example.batchtutorial.notification;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.core.StepExecution;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;

/**
 * 배치 실행 결과에 따른 알림 발송
 */
@Slf4j
@Component
public class BatchNotificationService implements JobExecutionListener {

    @Value(&quot;${batch.notification.email.enabled:true}&quot;)
    private boolean emailNotificationEnabled;

    @Value(&quot;${batch.notification.slack.enabled:false}&quot;)
    private boolean slackNotificationEnabled;

    @Value(&quot;${batch.notification.threshold.duration:300}&quot;)  // 5분
    private long longRunningThresholdSeconds;

    @Override
    public void beforeJob(JobExecution jobExecution) {
        String jobName = jobExecution.getJobInstance().getJobName();
        log.info(&quot;📧 Job started notification: {}&quot;, jobName);

        if (emailNotificationEnabled) {
            sendJobStartNotification(jobName);
        }
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        String jobName = jobExecution.getJobInstance().getJobName();
        String status = jobExecution.getExitStatus().getExitCode();

        LocalDateTime startTime = LocalDateTime.ofInstant(
                jobExecution.getStartTime().toInstant(), ZoneId.systemDefault());
        LocalDateTime endTime = LocalDateTime.ofInstant(
                jobExecution.getEndTime().toInstant(), ZoneId.systemDefault());
        Duration duration = Duration.between(startTime, endTime);

        // 실행 통계 수집
        JobExecutionSummary summary = createExecutionSummary(jobExecution, duration);

        // 상태에 따른 알림 발송
        switch (status) {
            case &quot;COMPLETED&quot; -&gt; handleSuccessfulCompletion(summary);
            case &quot;FAILED&quot; -&gt; handleFailure(summary);
            case &quot;STOPPED&quot; -&gt; handleStopped(summary);
            default -&gt; handleUnknownStatus(summary);
        }

        // 장시간 실행 알림
        if (duration.getSeconds() &gt; longRunningThresholdSeconds) {
            sendLongRunningJobAlert(summary);
        }
    }

    private void handleSuccessfulCompletion(JobExecutionSummary summary) {
        log.info(&quot;✅ Job completed successfully: {}&quot;, summary.getJobName());

        if (emailNotificationEnabled) {
            sendSuccessNotification(summary);
        }

        if (slackNotificationEnabled) {
            sendSlackSuccessMessage(summary);
        }
    }

    private void handleFailure(JobExecutionSummary summary) {
        log.error(&quot;❌ Job failed: {}&quot;, summary.getJobName());

        // 실패 시 항상 알림 발송
        sendFailureNotification(summary);

        if (slackNotificationEnabled) {
            sendSlackFailureAlert(summary);
        }

        // 긴급 알림 (SMS, 전화 등) - 실제 구현 필요
        if (summary.isCritical()) {
            sendUrgentAlert(summary);
        }
    }

    private void handleStopped(JobExecutionSummary summary) {
        log.warn(&quot;🛑 Job was stopped: {}&quot;, summary.getJobName());
        sendJobStoppedNotification(summary);
    }

    private JobExecutionSummary createExecutionSummary(JobExecution jobExecution, Duration duration) {
        JobExecutionSummary summary = new JobExecutionSummary();
        summary.setJobName(jobExecution.getJobInstance().getJobName());
        summary.setExecutionId(jobExecution.getId());
        summary.setStatus(jobExecution.getExitStatus().getExitCode());
        summary.setDuration(duration);
        summary.setStartTime(LocalDateTime.ofInstant(
                jobExecution.getStartTime().toInstant(), ZoneId.systemDefault()));
        summary.setEndTime(LocalDateTime.ofInstant(
                jobExecution.getEndTime().toInstant(), ZoneId.systemDefault()));

        // Step 통계 수집
        long totalRead = 0, totalWrite = 0, totalSkip = 0;
        StringBuilder stepDetails = new StringBuilder();

        for (StepExecution step : jobExecution.getStepExecutions()) {
            totalRead += step.getReadCount();
            totalWrite += step.getWriteCount();
            totalSkip += step.getSkipCount();

            stepDetails.append(String.format(
                &quot;- %s: Read=%d, Write=%d, Skip=%d%n&quot;, 
                step.getStepName(), step.getReadCount(), step.getWriteCount(), step.getSkipCount()
            ));
        }

        summary.setTotalRead(totalRead);
        summary.setTotalWrite(totalWrite);
        summary.setTotalSkip(totalSkip);
        summary.setStepDetails(stepDetails.toString());

        // 중요도 판단
        summary.setCritical(jobExecution.getJobInstance().getJobName().contains(&quot;CRITICAL&quot;) || 
                           totalSkip &gt; 1000 || 
                           duration.getSeconds() &gt; 3600); // 1시간 이상

        return summary;
    }

    private void sendSuccessNotification(JobExecutionSummary summary) {
        String subject = String.format(&quot;✅ Batch Job Completed: %s&quot;, summary.getJobName());
        String body = String.format(&quot;&quot;&quot;
            Job: %s
            Status: COMPLETED
            Duration: %d minutes

            Statistics:
            - Read: %,d items
            - Write: %,d items
            - Skip: %,d items
            - Success Rate: %.2f%%

            Step Details:
            %s
            &quot;&quot;&quot;, 
            summary.getJobName(),
            summary.getDuration().toMinutes(),
            summary.getTotalRead(),
            summary.getTotalWrite(),
            summary.getTotalSkip(),
            summary.getTotalRead() &gt; 0 ? (double) summary.getTotalWrite() / summary.getTotalRead() * 100 : 0,
            summary.getStepDetails()
        );

        sendEmailNotification(subject, body);
    }

    private void sendFailureNotification(JobExecutionSummary summary) {
        String subject = String.format(&quot;❌ Batch Job FAILED: %s&quot;, summary.getJobName());
        String body = String.format(&quot;&quot;&quot;
            🚨 BATCH JOB FAILURE ALERT 🚨

            Job: %s
            Status: FAILED
            Execution ID: %d
            Duration: %d minutes

            Failure occurred at: %s

            Statistics:
            - Read: %,d items
            - Write: %,d items  
            - Skip: %,d items

            Step Details:
            %s

            Please check the logs and take appropriate action.
            &quot;&quot;&quot;,
            summary.getJobName(),
            summary.getExecutionId(),
            summary.getDuration().toMinutes(),
            summary.getEndTime(),
            summary.getTotalRead(),
            summary.getTotalWrite(),
            summary.getTotalSkip(),
            summary.getStepDetails()
        );

        sendEmailNotification(subject, body);
    }

    private void sendEmailNotification(String subject, String body) {
        // 실제 이메일 발송 구현
        log.info(&quot;📧 EMAIL NOTIFICATION: {}&quot;, subject);
        log.debug(&quot;Email body: {}&quot;, body);

        // JavaMailSender 등을 사용한 실제 이메일 발송 로직 구현
        // emailService.sendNotification(subject, body);
    }

    private void sendSlackSuccessMessage(JobExecutionSummary summary) {
        String message = String.format(
            &quot;✅ Batch job `%s` completed successfully in %d minutes. Processed %,d items.&quot;,
            summary.getJobName(), summary.getDuration().toMinutes(), summary.getTotalWrite()
        );

        sendSlackMessage(message, &quot;good&quot;);
    }

    private void sendSlackFailureAlert(JobExecutionSummary summary) {
        String message = String.format(
            &quot;🚨 Batch job `%s` FAILED after %d minutes. Execution ID: %d. Please check immediately!&quot;,
            summary.getJobName(), summary.getDuration().toMinutes(), summary.getExecutionId()
        );

        sendSlackMessage(message, &quot;danger&quot;);
    }

    private void sendSlackMessage(String message, String color) {
        // 실제 Slack API 호출 구현
        log.info(&quot;📱 SLACK NOTIFICATION ({}): {}&quot;, color, message);

        // Slack WebClient를 사용한 메시지 발송 로직 구현
        // slackService.sendMessage(message, color);
    }

    // 기타 알림 메서드들...
    private void sendJobStartNotification(String jobName) { /* 구현 */ }
    private void handleUnknownStatus(JobExecutionSummary summary) { /* 구현 */ }
    private void sendLongRunningJobAlert(JobExecutionSummary summary) { /* 구현 */ }
    private void sendJobStoppedNotification(JobExecutionSummary summary) { /* 구현 */ }
    private void sendUrgentAlert(JobExecutionSummary summary) { /* 구현 */ }
}

@Data
class JobExecutionSummary {
    private String jobName;
    private Long executionId;
    private String status;
    private Duration duration;
    private LocalDateTime startTime;
    private LocalDateTime endTime;
    private long totalRead;
    private long totalWrite;
    private long totalSkip;
    private String stepDetails;
    private boolean critical;
}</code></pre>
<p>이제 프로덕션 환경에서 안정적으로 운영할 수 있는 완전한 에러 처리, 재시작, 모니터링 시스템이 구축되었습니다! 마지막 단계에서는 Spring Batch의 고급 활용법과 실무 팁을 학습하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 로 기상정보 가져오기 멀티스레드(맛보기 시리즈 2-7)]]></title>
            <link>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EB%A9%80%ED%8B%B0%EC%8A%A4%EB%A0%88%EB%93%9C%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-7</link>
            <guid>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EB%A9%80%ED%8B%B0%EC%8A%A4%EB%A0%88%EB%93%9C%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-7</guid>
            <pubDate>Fri, 29 Aug 2025 06:18:05 GMT</pubDate>
            <description><![CDATA[<h1 id="08-배치-학습-4단계-성능-최적화-및-멀티스레드-처리">08. 배치 학습 4단계: 성능 최적화 및 멀티스레드 처리</h1>
<h2 id="🎯-학습-목표">🎯 학습 목표</h2>
<ul>
<li>대용량 데이터 처리를 위한 성능 최적화 기법 습득</li>
<li>멀티스레드와 병렬 처리 구현</li>
<li>파티셔닝(Partitioning)을 통한 분산 처리</li>
<li>메모리 최적화 및 성능 모니터링</li>
</ul>
<h2 id="📊-성능-최적화-전략-개요">📊 성능 최적화 전략 개요</h2>
<h3 id="성능-병목-지점-분석">성능 병목 지점 분석</h3>
<pre><code>[데이터 읽기] → [데이터 처리] → [데이터 쓰기]
     ↓              ↓              ↓
  I/O 병목        CPU 병목      I/O + 트랜잭션 병목
     ↓              ↓              ↓
  - 페이징        - 복잡한        - 배치 삽입
  - 인덱스         비즈니스 로직    - 트랜잭션 크기
  - 캐싱          - 변환 작업      - 커넥션 풀</code></pre><h3 id="최적화-기법들">최적화 기법들</h3>
<ol>
<li><strong>청크 크기 최적화</strong>: 메모리 vs 트랜잭션 오버헤드 균형</li>
<li><strong>멀티스레드 처리</strong>: CPU 활용도 극대화</li>
<li><strong>파티셔닝</strong>: 대용량 데이터 분할 처리</li>
<li><strong>비동기 처리</strong>: ItemProcessor에서 외부 API 호출 최적화</li>
<li><strong>데이터베이스 최적화</strong>: 인덱스, 배치 삽입, 커넥션 풀</li>
</ol>
<h2 id="🚀-멀티스레드-step-구현">🚀 멀티스레드 Step 구현</h2>
<h3 id="1-기본-멀티스레드-설정">1. 기본 멀티스레드 설정</h3>
<pre><code class="language-java">package com.example.batchtutorial.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;

/**
 * 멀티스레드 배치 처리 설정
 */
@Slf4j
@Configuration
public class MultiThreadBatchConfig {

    @Autowired
    private JobRepository jobRepository;

    @Autowired
    private PlatformTransactionManager transactionManager;

    /**
     * 멀티스레드 배치용 TaskExecutor
     */
    @Bean
    public TaskExecutor batchTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);        // 기본 스레드 수
        executor.setMaxPoolSize(8);         // 최대 스레드 수
        executor.setQueueCapacity(100);     // 큐 용량
        executor.setThreadNamePrefix(&quot;batch-thread-&quot;);
        executor.setKeepAliveSeconds(60);   // 유휴 스레드 생존 시간
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        executor.initialize();
        return executor;
    }

    /**
     * CPU 집약적 작업용 TaskExecutor
     */
    @Bean
    public TaskExecutor cpuIntensiveTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // CPU 코어 수만큼 스레드 설정 (CPU 집약적 작업)
        int cpuCores = Runtime.getRuntime().availableProcessors();
        executor.setCorePoolSize(cpuCores);
        executor.setMaxPoolSize(cpuCores);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix(&quot;cpu-batch-&quot;);
        executor.initialize();
        return executor;
    }

    /**
     * I/O 집약적 작업용 TaskExecutor
     */
    @Bean
    public TaskExecutor ioIntensiveTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // I/O 대기 시간을 고려하여 더 많은 스레드 할당
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix(&quot;io-batch-&quot;);
        executor.initialize();
        return executor;
    }

    /**
     * 멀티스레드 Step 설정
     */
    @Bean
    public Step multiThreadStep(ItemReader&lt;Object&gt; reader,
                               ItemProcessor&lt;Object, Object&gt; processor,
                               ItemWriter&lt;Object&gt; writer) {
        return new StepBuilder(&quot;multiThreadStep&quot;, jobRepository)
                .&lt;Object, Object&gt;chunk(100, transactionManager)  // 큰 청크 크기
                .reader(reader)
                .processor(processor)
                .writer(writer)
                .taskExecutor(batchTaskExecutor())       // TaskExecutor 설정
                .throttleLimit(4)                        // 동시 실행 스레드 제한
                .build();
    }
}</code></pre>
<h3 id="2-스레드-세이프-itemreader-구현">2. 스레드 세이프 ItemReader 구현</h3>
<pre><code class="language-java">package com.example.batchtutorial.batch.reader;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.database.JdbcPagingItemReader;
import org.springframework.batch.item.database.PagingQueryProvider;
import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder;
import org.springframework.batch.item.database.support.SqlPagingQueryProviderFactoryBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.BeanPropertyRowMapper;

import javax.sql.DataSource;

/**
 * 멀티스레드 환경에서 안전한 ItemReader 설정
 */
@Slf4j
@Configuration
public class ThreadSafeReaderConfig {

    @Autowired
    private DataSource dataSource;

    /**
     * 스레드 세이프한 JDBC Paging ItemReader
     * (JdbcPagingItemReader는 기본적으로 thread-safe)
     */
    @Bean
    public JdbcPagingItemReader&lt;LargeDataDto&gt; threadSafeReader() throws Exception {

        return new JdbcPagingItemReaderBuilder&lt;LargeDataDto&gt;()
                .name(&quot;threadSafeReader&quot;)
                .dataSource(dataSource)
                .queryProvider(createQueryProvider())
                .pageSize(1000)                    // 페이지 크기 증가
                .rowMapper(new BeanPropertyRowMapper&lt;&gt;(LargeDataDto.class))
                .saveState(false)                  // 멀티스레드에서는 상태 저장 비활성화
                .build();
    }

    /**
     * 페이징 쿼리 제공자 생성
     */
    private PagingQueryProvider createQueryProvider() throws Exception {
        SqlPagingQueryProviderFactoryBean factory = new SqlPagingQueryProviderFactoryBean();
        factory.setDataSource(dataSource);
        factory.setSelectClause(&quot;SELECT id, name, amount, created_at&quot;);
        factory.setFromClause(&quot;FROM large_data_table&quot;);
        factory.setWhereClause(&quot;WHERE status = &#39;ACTIVE&#39;&quot;);
        factory.setSortKey(&quot;id&quot;);                  // 정렬 키 필수
        return factory.getObject();
    }
}

/**
 * 대용량 데이터 처리용 DTO
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
class LargeDataDto {
    private Long id;
    private String name;
    private BigDecimal amount;
    private LocalDateTime createdAt;
}</code></pre>
<h3 id="3-비동기-itemprocessor-구현">3. 비동기 ItemProcessor 구현</h3>
<pre><code class="language-java">package com.example.batchtutorial.batch.processor;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

import java.time.Duration;
import java.util.concurrent.CompletableFuture;

/**
 * 비동기 처리를 포함하는 ItemProcessor
 */
@Slf4j
@Configuration
public class AsyncProcessorConfig {

    @Autowired
    private WebClient webClient;

    /**
     * 외부 API 호출이 포함된 비동기 Processor
     */
    @Bean
    public ItemProcessor&lt;LargeDataDto, ProcessedDataDto&gt; asyncProcessor() {
        return new ItemProcessor&lt;LargeDataDto, ProcessedDataDto&gt;() {
            @Override
            public ProcessedDataDto process(LargeDataDto item) throws Exception {

                long startTime = System.currentTimeMillis();
                String threadName = Thread.currentThread().getName();

                log.debug(&quot;Processing item {} on thread {}&quot;, item.getId(), threadName);

                try {
                    // 1. 로컬 데이터 변환 (CPU 작업)
                    ProcessedDataDto result = transformData(item);

                    // 2. 외부 API 호출 (I/O 작업)
                    String enrichedData = callExternalApiAsync(item.getId()).get();
                    result.setEnrichedData(enrichedData);

                    // 3. 복잡한 계산 수행 (CPU 작업)
                    BigDecimal calculatedValue = performComplexCalculation(item.getAmount());
                    result.setCalculatedValue(calculatedValue);

                    long processingTime = System.currentTimeMillis() - startTime;
                    result.setProcessingTime(processingTime);

                    log.debug(&quot;Completed processing item {} in {}ms on thread {}&quot;, 
                            item.getId(), processingTime, threadName);

                    return result;

                } catch (Exception e) {
                    log.error(&quot;Failed to process item {} on thread {}: {}&quot;, 
                            item.getId(), threadName, e.getMessage());
                    throw e;
                }
            }
        };
    }

    /**
     * 데이터 변환 로직
     */
    private ProcessedDataDto transformData(LargeDataDto input) {
        ProcessedDataDto result = new ProcessedDataDto();
        result.setId(input.getId());
        result.setProcessedName(input.getName().toUpperCase());
        result.setOriginalAmount(input.getAmount());
        result.setProcessedAt(LocalDateTime.now());
        return result;
    }

    /**
     * 외부 API 비동기 호출
     */
    private CompletableFuture&lt;String&gt; callExternalApiAsync(Long id) {
        return webClient.get()
                .uri(&quot;/api/enrich/{id}&quot;, id)
                .retrieve()
                .bodyToMono(String.class)
                .timeout(Duration.ofSeconds(5))
                .toFuture();
    }

    /**
     * 복잡한 계산 수행 (CPU 집약적)
     */
    private BigDecimal performComplexCalculation(BigDecimal input) {
        // 복잡한 수학적 계산 시뮬레이션
        BigDecimal result = input;
        for (int i = 0; i &lt; 1000; i++) {
            result = result.multiply(new BigDecimal(&quot;1.001&quot;));
        }
        return result.setScale(2, RoundingMode.HALF_UP);
    }
}

/**
 * 처리된 데이터 DTO
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
class ProcessedDataDto {
    private Long id;
    private String processedName;
    private BigDecimal originalAmount;
    private BigDecimal calculatedValue;
    private String enrichedData;
    private LocalDateTime processedAt;
    private Long processingTime;
}</code></pre>
<h2 id="🔀-파티셔닝partitioning-구현">🔀 파티셔닝(Partitioning) 구현</h2>
<h3 id="1-파티셔닝-기본-설정">1. 파티셔닝 기본 설정</h3>
<pre><code class="language-java">package com.example.batchtutorial.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.partition.PartitionHandler;
import org.springframework.batch.core.partition.support.Partitioner;
import org.springframework.batch.core.partition.support.TaskExecutorPartitionHandler;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;

/**
 * 파티셔닝을 통한 분산 처리 설정
 */
@Slf4j
@Configuration
public class PartitioningBatchConfig {

    @Autowired
    private JobRepository jobRepository;

    @Autowired
    private PlatformTransactionManager transactionManager;

    /**
     * 파티셔닝 Job 설정
     */
    @Bean
    public Job partitioningJob(Step managerStep) {
        return new JobBuilder(&quot;partitioningJob&quot;, jobRepository)
                .start(managerStep)
                .build();
    }

    /**
     * 매니저 Step (파티션을 관리하는 Step)
     */
    @Bean
    public Step managerStep(Step workerStep, Partitioner customPartitioner, TaskExecutor taskExecutor) {
        return new StepBuilder(&quot;managerStep&quot;, jobRepository)
                .partitioner(&quot;workerStep&quot;, customPartitioner)
                .step(workerStep)
                .partitionHandler(partitionHandler(workerStep, taskExecutor))
                .build();
    }

    /**
     * 파티션 핸들러 설정
     */
    @Bean
    public PartitionHandler partitionHandler(Step workerStep, TaskExecutor taskExecutor) {
        TaskExecutorPartitionHandler handler = new TaskExecutorPartitionHandler();
        handler.setTaskExecutor(taskExecutor);
        handler.setStep(workerStep);
        handler.setGridSize(8);                    // 파티션 개수
        return handler;
    }

    /**
     * 워커 Step (실제 작업을 수행하는 Step)
     */
    @Bean
    public Step workerStep(ItemReader&lt;LargeDataDto&gt; partitionReader,
                          ItemProcessor&lt;LargeDataDto, ProcessedDataDto&gt; processor,
                          ItemWriter&lt;ProcessedDataDto&gt; writer) {
        return new StepBuilder(&quot;workerStep&quot;, jobRepository)
                .&lt;LargeDataDto, ProcessedDataDto&gt;chunk(500, transactionManager)
                .reader(partitionReader)
                .processor(processor)
                .writer(writer)
                .build();
    }

    /**
     * 커스텀 파티셔너 - 데이터 범위에 따라 분할
     */
    @Bean
    public Partitioner customPartitioner() {
        return new CustomRangePartitioner();
    }
}</code></pre>
<h3 id="2-커스텀-파티셔너-구현">2. 커스텀 파티셔너 구현</h3>
<pre><code class="language-java">package com.example.batchtutorial.partition;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.partition.support.Partitioner;
import org.springframework.batch.item.ExecutionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

/**
 * ID 범위 기반 파티셔너
 */
@Slf4j
@Component
public class CustomRangePartitioner implements Partitioner {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Override
    public Map&lt;String, ExecutionContext&gt; partition(int gridSize) {

        log.info(&quot;Creating {} partitions for data processing&quot;, gridSize);

        // 1. 전체 데이터 범위 조회
        Long minId = jdbcTemplate.queryForObject(&quot;SELECT MIN(id) FROM large_data_table WHERE status = &#39;ACTIVE&#39;&quot;, Long.class);
        Long maxId = jdbcTemplate.queryForObject(&quot;SELECT MAX(id) FROM large_data_table WHERE status = &#39;ACTIVE&#39;&quot;, Long.class);
        Long totalRecords = jdbcTemplate.queryForObject(&quot;SELECT COUNT(*) FROM large_data_table WHERE status = &#39;ACTIVE&#39;&quot;, Long.class);

        if (minId == null || maxId == null || totalRecords == 0) {
            log.warn(&quot;No data found for partitioning&quot;);
            return new HashMap&lt;&gt;();
        }

        log.info(&quot;Total records: {}, ID range: {} - {}&quot;, totalRecords, minId, maxId);

        // 2. 파티션별 범위 계산
        long rangeSize = (maxId - minId + 1) / gridSize;
        Map&lt;String, ExecutionContext&gt; partitions = new HashMap&lt;&gt;();

        for (int i = 0; i &lt; gridSize; i++) {
            ExecutionContext context = new ExecutionContext();

            long startId = minId + (i * rangeSize);
            long endId = (i == gridSize - 1) ? maxId : startId + rangeSize - 1;

            context.putLong(&quot;startId&quot;, startId);
            context.putLong(&quot;endId&quot;, endId);

            // 파티션별 예상 레코드 수 계산
            Long partitionRecordCount = jdbcTemplate.queryForObject(
                &quot;SELECT COUNT(*) FROM large_data_table WHERE id BETWEEN ? AND ? AND status = &#39;ACTIVE&#39;&quot;,
                Long.class, startId, endId
            );

            context.putLong(&quot;expectedRecords&quot;, partitionRecordCount);

            partitions.put(&quot;partition&quot; + i, context);

            log.info(&quot;Partition {}: ID range {} - {}, expected records: {}&quot;, 
                    i, startId, endId, partitionRecordCount);
        }

        return partitions;
    }
}</code></pre>
<h3 id="3-파티션용-stepscope-itemreader">3. 파티션용 StepScope ItemReader</h3>
<pre><code class="language-java">package com.example.batchtutorial.batch.reader;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.item.database.JdbcPagingItemReader;
import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.BeanPropertyRowMapper;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

/**
 * 파티션별 데이터를 읽는 StepScope ItemReader
 */
@Slf4j
@Configuration
public class PartitionReaderConfig {

    @Autowired
    private DataSource dataSource;

    /**
     * 파티션 범위에 따라 데이터를 읽는 Reader
     */
    @Bean
    @StepScope
    public JdbcPagingItemReader&lt;LargeDataDto&gt; partitionReader(
            @Value(&quot;#{stepExecutionContext[&#39;startId&#39;]}&quot;) Long startId,
            @Value(&quot;#{stepExecutionContext[&#39;endId&#39;]}&quot;) Long endId,
            @Value(&quot;#{stepExecutionContext[&#39;expectedRecords&#39;]}&quot;) Long expectedRecords) {

        String threadName = Thread.currentThread().getName();
        log.info(&quot;Creating partition reader for range {} - {} (expected: {} records) on thread {}&quot;, 
                startId, endId, expectedRecords, threadName);

        Map&lt;String, Object&gt; parameters = new HashMap&lt;&gt;();
        parameters.put(&quot;startId&quot;, startId);
        parameters.put(&quot;endId&quot;, endId);

        return new JdbcPagingItemReaderBuilder&lt;LargeDataDto&gt;()
                .name(&quot;partitionReader&quot;)
                .dataSource(dataSource)
                .queryString(&quot;SELECT id, name, amount, created_at FROM large_data_table WHERE id BETWEEN :startId AND :endId AND status = &#39;ACTIVE&#39;&quot;)
                .parameterValues(parameters)
                .pageSize(100)
                .rowMapper(new BeanPropertyRowMapper&lt;&gt;(LargeDataDto.class))
                .saveState(false)  // 파티셔닝에서는 상태 저장 비활성화
                .build();
    }
}</code></pre>
<h2 id="📈-성능-모니터링-및-측정">📈 성능 모니터링 및 측정</h2>
<h3 id="1-배치-성능-모니터링-리스너">1. 배치 성능 모니터링 리스너</h3>
<pre><code class="language-java">package com.example.batchtutorial.listener;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.core.StepExecution;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;

/**
 * 배치 성능 모니터링 리스너
 */
@Slf4j
@Component
public class PerformanceMonitoringListener implements JobExecutionListener {

    @Override
    public void beforeJob(JobExecution jobExecution) {
        log.info(&quot;🚀 Job started: {} at {}&quot;, 
                jobExecution.getJobInstance().getJobName(),
                LocalDateTime.ofInstant(jobExecution.getStartTime().toInstant(), ZoneId.systemDefault()));

        // 시스템 리소스 모니터링
        Runtime runtime = Runtime.getRuntime();
        long maxMemory = runtime.maxMemory();
        long freeMemory = runtime.freeMemory();
        long totalMemory = runtime.totalMemory();

        log.info(&quot;💾 Memory status - Max: {}MB, Free: {}MB, Total: {}MB&quot;, 
                maxMemory / (1024 * 1024), 
                freeMemory / (1024 * 1024), 
                totalMemory / (1024 * 1024));

        log.info(&quot;🖥️ Available processors: {}&quot;, runtime.availableProcessors());
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        Duration duration = Duration.between(
                jobExecution.getStartTime().toInstant(),
                jobExecution.getEndTime().toInstant()
        );

        log.info(&quot;✅ Job completed: {} in {} seconds&quot;, 
                jobExecution.getJobInstance().getJobName(),
                duration.getSeconds());

        // Step별 성능 통계
        logStepStatistics(jobExecution);

        // 전체 성능 통계
        logOverallPerformance(jobExecution, duration);

        // 메모리 사용량 최종 확인
        logFinalMemoryUsage();
    }

    private void logStepStatistics(JobExecution jobExecution) {
        log.info(&quot;📊 Step Performance Statistics:&quot;);

        for (StepExecution stepExecution : jobExecution.getStepExecutions()) {
            Duration stepDuration = Duration.between(
                    stepExecution.getStartTime().toInstant(),
                    stepExecution.getEndTime().toInstant()
            );

            long readCount = stepExecution.getReadCount();
            long writeCount = stepExecution.getWriteCount();
            long skipCount = stepExecution.getSkipCount();

            double readThroughput = readCount / (double) stepDuration.getSeconds();
            double writeThroughput = writeCount / (double) stepDuration.getSeconds();

            log.info(&quot;  📋 Step: {}&quot;, stepExecution.getStepName());
            log.info(&quot;    ⏱️ Duration: {} seconds&quot;, stepDuration.getSeconds());
            log.info(&quot;    📖 Read: {} items ({:.2f} items/sec)&quot;, readCount, readThroughput);
            log.info(&quot;    ✏️ Write: {} items ({:.2f} items/sec)&quot;, writeCount, writeThroughput);
            log.info(&quot;    ⚠️ Skip: {} items&quot;, skipCount);

            if (stepExecution.getCommitCount() &gt; 0) {
                double avgChunkSize = (double) writeCount / stepExecution.getCommitCount();
                log.info(&quot;    🔄 Commits: {} (avg chunk size: {:.1f})&quot;, 
                        stepExecution.getCommitCount(), avgChunkSize);
            }
        }
    }

    private void logOverallPerformance(JobExecution jobExecution, Duration duration) {
        long totalReadCount = jobExecution.getStepExecutions().stream()
                .mapToLong(StepExecution::getReadCount)
                .sum();

        long totalWriteCount = jobExecution.getStepExecutions().stream()
                .mapToLong(StepExecution::getWriteCount)
                .sum();

        double overallThroughput = totalWriteCount / (double) duration.getSeconds();

        log.info(&quot;🎯 Overall Performance:&quot;);
        log.info(&quot;  📖 Total Read: {} items&quot;, totalReadCount);
        log.info(&quot;  ✏️ Total Write: {} items&quot;, totalWriteCount);
        log.info(&quot;  ⚡ Overall Throughput: {:.2f} items/sec&quot;, overallThroughput);
        log.info(&quot;  📊 Success Rate: {:.2f}%&quot;, 
                (totalWriteCount / (double) totalReadCount) * 100);
    }

    private void logFinalMemoryUsage() {
        Runtime runtime = Runtime.getRuntime();
        runtime.gc(); // 가비지 컬렉션 수행

        long usedMemory = runtime.totalMemory() - runtime.freeMemory();
        long maxMemory = runtime.maxMemory();

        log.info(&quot;💾 Final Memory Usage: {}MB / {}MB ({:.1f}%)&quot;, 
                usedMemory / (1024 * 1024),
                maxMemory / (1024 * 1024),
                (usedMemory / (double) maxMemory) * 100);
    }
}</code></pre>
<h3 id="2-성능-최적화된-itemwriter">2. 성능 최적화된 ItemWriter</h3>
<pre><code class="language-java">package com.example.batchtutorial.batch.writer;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.database.JdbcBatchItemWriter;
import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;

import javax.sql.DataSource;

/**
 * 고성능 배치 삽입을 위한 ItemWriter
 */
@Slf4j
@Configuration  
public class HighPerformanceWriterConfig {

    @Autowired
    private DataSource dataSource;

    /**
     * JDBC 배치 삽입 Writer
     */
    @Bean
    public JdbcBatchItemWriter&lt;ProcessedDataDto&gt; highPerformanceWriter() {
        return new JdbcBatchItemWriterBuilder&lt;ProcessedDataDto&gt;()
                .dataSource(dataSource)
                .sql(&quot;INSERT INTO processed_data (id, processed_name, original_amount, calculated_value, enriched_data, processed_at, processing_time) &quot; +
                     &quot;VALUES (:id, :processedName, :originalAmount, :calculatedValue, :enrichedData, :processedAt, :processingTime)&quot;)
                .beanMapped()
                .assertUpdates(false)  // 업데이트 확인 비활성화 (성능 향상)
                .build();
    }

    /**
     * 사용자 정의 배치 Writer (더 세밀한 제어)
     */
    @Bean
    public CustomBatchWriter customBatchWriter() {
        return new CustomBatchWriter(dataSource);
    }
}

/**
 * 커스텀 배치 Writer 구현
 */
@Slf4j
class CustomBatchWriter implements ItemWriter&lt;ProcessedDataDto&gt; {

    private final DataSource dataSource;
    private final JdbcTemplate jdbcTemplate;

    public CustomBatchWriter(DataSource dataSource) {
        this.dataSource = dataSource;
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public void write(Chunk&lt;? extends ProcessedDataDto&gt; chunk) throws Exception {
        List&lt;? extends ProcessedDataDto&gt; items = chunk.getItems();

        if (items.isEmpty()) {
            return;
        }

        long startTime = System.currentTimeMillis();
        String threadName = Thread.currentThread().getName();

        log.debug(&quot;Writing {} items on thread {}&quot;, items.size(), threadName);

        try {
            // 배치 삽입 준비
            String sql = &quot;INSERT INTO processed_data &quot; +
                        &quot;(id, processed_name, original_amount, calculated_value, enriched_data, processed_at, processing_time) &quot; +
                        &quot;VALUES (?, ?, ?, ?, ?, ?, ?)&quot;;

            List&lt;Object[]&gt; batchArgs = items.stream()
                    .map(this::createBatchArgs)
                    .collect(Collectors.toList());

            // 배치 실행
            int[] results = jdbcTemplate.batchUpdate(sql, batchArgs);

            long executionTime = System.currentTimeMillis() - startTime;
            double throughput = items.size() / (executionTime / 1000.0);

            log.debug(&quot;Batch write completed - {} items in {}ms ({:.2f} items/sec) on thread {}&quot;, 
                    items.size(), executionTime, throughput, threadName);

            // 실패한 삽입 확인
            long failedInserts = Arrays.stream(results).filter(result -&gt; result == 0).count();
            if (failedInserts &gt; 0) {
                log.warn(&quot;⚠️ {} insertions failed out of {} on thread {}&quot;, 
                        failedInserts, items.size(), threadName);
            }

        } catch (Exception e) {
            log.error(&quot;❌ Batch write failed for {} items on thread {}: {}&quot;, 
                    items.size(), threadName, e.getMessage(), e);
            throw e;
        }
    }

    private Object[] createBatchArgs(ProcessedDataDto item) {
        return new Object[]{
            item.getId(),
            item.getProcessedName(),
            item.getOriginalAmount(),
            item.getCalculatedValue(),
            item.getEnrichedData(),
            item.getProcessedAt(),
            item.getProcessingTime()
        };
    }
}</code></pre>
<h2 id="🔧-성능-최적화-설정">🔧 성능 최적화 설정</h2>
<h3 id="1-데이터베이스-커넥션-풀-최적화">1. 데이터베이스 커넥션 풀 최적화</h3>
<pre><code class="language-properties"># application-performance.properties

# HikariCP 설정 (고성능 커넥션 풀)
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=600000
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.leak-detection-threshold=60000

# JPA/Hibernate 성능 최적화
spring.jpa.hibernate.jdbc.batch_size=25
spring.jpa.hibernate.order_inserts=true
spring.jpa.hibernate.order_updates=true
spring.jpa.hibernate.jdbc.batch_versioned_data=true

# Spring Batch 성능 설정
spring.batch.jdbc.isolation-level-for-create=READ_COMMITTED
spring.batch.jdbc.table-prefix=BATCH_

# 로깅 설정 (성능 모드)
logging.level.org.springframework.batch=INFO
logging.level.org.hibernate.SQL=WARN
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN</code></pre>
<h3 id="2-jvm-성능-튜닝-옵션">2. JVM 성능 튜닝 옵션</h3>
<pre><code class="language-bash"># 배치 애플리케이션 실행 시 권장 JVM 옵션
java -Xms2g -Xmx4g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:+UnlockExperimentalVMOptions \
     -XX:+UseStringDeduplication \
     -XX:+PrintGCDetails \
     -XX:+PrintGCTimeStamps \
     -Xloggc:gc.log \
     -jar batch-application.jar</code></pre>
<h2 id="📊-성능-테스트-및-벤치마크">📊 성능 테스트 및 벤치마크</h2>
<h3 id="성능-테스트-시나리오">성능 테스트 시나리오</h3>
<pre><code class="language-java">package com.example.batchtutorial.test;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

import java.time.Duration;
import java.time.Instant;

/**
 * 배치 성능 테스트
 */
@Slf4j
@SpringBootTest
@ActiveProfiles(&quot;performance&quot;)
class BatchPerformanceTest {

    @Autowired
    private JobLauncher jobLauncher;

    @Autowired
    private Job singleThreadJob;

    @Autowired
    private Job multiThreadJob;

    @Autowired
    private Job partitioningJob;

    @Test
    void compareBatchPerformance() throws Exception {

        // 1. 단일 스레드 성능 테스트
        Instant start = Instant.now();
        jobLauncher.run(singleThreadJob, createJobParameters(&quot;single&quot;));
        Duration singleThreadTime = Duration.between(start, Instant.now());
        log.info(&quot;Single thread execution time: {} seconds&quot;, singleThreadTime.getSeconds());

        // 2. 멀티 스레드 성능 테스트
        start = Instant.now();
        jobLauncher.run(multiThreadJob, createJobParameters(&quot;multi&quot;));
        Duration multiThreadTime = Duration.between(start, Instant.now());
        log.info(&quot;Multi thread execution time: {} seconds&quot;, multiThreadTime.getSeconds());

        // 3. 파티셔닝 성능 테스트
        start = Instant.now();
        jobLauncher.run(partitioningJob, createJobParameters(&quot;partition&quot;));
        Duration partitioningTime = Duration.between(start, Instant.now());
        log.info(&quot;Partitioning execution time: {} seconds&quot;, partitioningTime.getSeconds());

        // 4. 성능 비교 결과
        log.info(&quot;Performance Comparison:&quot;);
        log.info(&quot;  Single Thread: {} seconds (baseline)&quot;, singleThreadTime.getSeconds());
        log.info(&quot;  Multi Thread: {} seconds ({}x faster)&quot;, 
                multiThreadTime.getSeconds(), 
                (double) singleThreadTime.getSeconds() / multiThreadTime.getSeconds());
        log.info(&quot;  Partitioning: {} seconds ({}x faster)&quot;, 
                partitioningTime.getSeconds(),
                (double) singleThreadTime.getSeconds() / partitioningTime.getSeconds());
    }

    private JobParameters createJobParameters(String suffix) {
        return new JobParametersBuilder()
                .addLong(&quot;timestamp&quot;, System.currentTimeMillis())
                .addString(&quot;testType&quot;, suffix)
                .toJobParameters();
    }
}</code></pre>
<p>이제 대용량 데이터 처리를 위한 고성능 Spring Batch 시스템을 완전히 구현했습니다! 다음 단계에서는 실무에서 필요한 에러 처리와 모니터링 기능을 학습하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 로 기상정보 가져오기 파일처리 실습 (맛보기 시리즈 2-6)]]></title>
            <link>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%ED%8C%8C%EC%9D%BC%EC%B2%98%EB%A6%AC-%EC%8B%A4%EC%8A%B5-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-6-8psnplve</link>
            <guid>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%ED%8C%8C%EC%9D%BC%EC%B2%98%EB%A6%AC-%EC%8B%A4%EC%8A%B5-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-6-8psnplve</guid>
            <pubDate>Fri, 29 Aug 2025 06:16:58 GMT</pubDate>
            <description><![CDATA[<h1 id="06-배치-학습-2단계-csv-파일-처리-실습">06. 배치 학습 2단계: CSV 파일 처리 실습</h1>
<h2 id="🎯-학습-목표">🎯 학습 목표</h2>
<ul>
<li>ItemReader, ItemProcessor, ItemWriter 실전 구현</li>
<li>CSV 파일을 읽어서 데이터베이스에 저장하는 완전한 배치 시스템 구축</li>
<li>청크 기반 처리 이해 및 최적화</li>
<li>데이터 검증 및 변환 로직 구현</li>
</ul>
<h2 id="📂-프로젝트-준비">📂 프로젝트 준비</h2>
<h3 id="1-의존성-추가-buildgradle">1. 의존성 추가 (build.gradle)</h3>
<pre><code class="language-gradle">dependencies {
    // 기존 의존성들...

    // CSV 처리를 위한 추가 의존성
    implementation &#39;org.springframework.batch:spring-batch-core&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-batch&#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.apache.commons:commons-csv:1.10.0&#39;
}</code></pre>
<h3 id="2-테스트-데이터-준비">2. 테스트 데이터 준비</h3>
<h4 id="csv-파일-생성-srcmainresourcesdataemployeescsv">CSV 파일 생성 (src/main/resources/data/employees.csv):</h4>
<pre><code class="language-csv">firstName,lastName,email,department,salary,hireDate
김,철수,kim.chulsoo@company.com,개발팀,5500000,2023-01-15
이,영희,lee.younghee@company.com,마케팅팀,4800000,2023-02-20
박,민수,park.minsoo@company.com,개발팀,6200000,2022-11-10
최,수진,choi.sujin@company.com,인사팀,5000000,2023-03-05
정,호영,jung.hoyoung@company.com,영업팀,4500000,2023-01-30
강,미영,kang.miyoung@company.com,개발팀,7000000,2022-08-15
윤,대호,yoon.daeho@company.com,기획팀,5800000,2023-02-10
송,지민,song.jimin@company.com,개발팀,5200000,2023-04-01
조,현우,cho.hyunwoo@company.com,마케팅팀,4700000,2023-01-25
한,서연,han.seoyeon@company.com,인사팀,5300000,2022-12-05</code></pre>
<h4 id="잘못된-데이터가-포함된-csv-srcmainresourcesdataemployees-with-errorscsv">잘못된 데이터가 포함된 CSV (src/main/resources/data/employees-with-errors.csv):</h4>
<pre><code class="language-csv">firstName,lastName,email,department,salary,hireDate
김,철수,kim.chulsoo@company.com,개발팀,5500000,2023-01-15
이,영희,invalid-email,마케팅팀,4800000,2023-02-20
박,,park.minsoo@company.com,개발팀,6200000,2022-11-10
최,수진,choi.sujin@company.com,,5000000,2023-03-05
정,호영,jung.hoyoung@company.com,영업팀,-1000,2023-01-30
강,미영,kang.miyoung@company.com,개발팀,7000000,invalid-date</code></pre>
<h2 id="🏗️-엔티티-및-dto-클래스-구현">🏗️ 엔티티 및 DTO 클래스 구현</h2>
<h3 id="1-employee-엔티티-클래스">1. Employee 엔티티 클래스</h3>
<pre><code class="language-java">package com.example.batchtutorial.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;

/**
 * 직원 정보를 저장하는 JPA 엔티티
 */
@Entity
@Table(name = &quot;employees&quot;)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = &quot;first_name&quot;, nullable = false, length = 50)
    @NotBlank(message = &quot;이름은 필수입니다&quot;)
    private String firstName;

    @Column(name = &quot;last_name&quot;, nullable = false, length = 50)
    @NotBlank(message = &quot;성은 필수입니다&quot;)
    private String lastName;

    @Column(name = &quot;email&quot;, nullable = false, unique = true, length = 100)
    @Email(message = &quot;올바른 이메일 형식이 아닙니다&quot;)
    @NotBlank(message = &quot;이메일은 필수입니다&quot;)
    private String email;

    @Column(name = &quot;department&quot;, nullable = false, length = 50)
    @NotBlank(message = &quot;부서는 필수입니다&quot;)
    private String department;

    @Column(name = &quot;salary&quot;, nullable = false, precision = 10, scale = 2)
    @DecimalMin(value = &quot;0.0&quot;, message = &quot;급여는 0보다 커야 합니다&quot;)
    @NotNull(message = &quot;급여는 필수입니다&quot;)
    private BigDecimal salary;

    @Column(name = &quot;hire_date&quot;, nullable = false)
    @NotNull(message = &quot;입사일은 필수입니다&quot;)
    @PastOrPresent(message = &quot;입사일은 현재 또는 과거 날짜여야 합니다&quot;)
    private LocalDate hireDate;

    @Column(name = &quot;created_at&quot;)
    private LocalDateTime createdAt;

    @Column(name = &quot;updated_at&quot;)
    private LocalDateTime updatedAt;

    // 생성/수정 시간 자동 설정
    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    // 전체 이름 반환 헬퍼 메서드
    public String getFullName() {
        return firstName + &quot; &quot; + lastName;
    }
}</code></pre>
<h3 id="2-csv-데이터용-dto-클래스">2. CSV 데이터용 DTO 클래스</h3>
<pre><code class="language-java">package com.example.batchtutorial.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * CSV 파일에서 읽어온 원시 데이터를 담는 DTO
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmployeeCsvDto {

    private String firstName;
    private String lastName;
    private String email;
    private String department;
    private String salary;        // 문자열로 받아서 검증 후 변환
    private String hireDate;      // 문자열로 받아서 날짜 변환
}</code></pre>
<h3 id="3-repository-인터페이스">3. Repository 인터페이스</h3>
<pre><code class="language-java">package com.example.batchtutorial.repository;

import com.example.batchtutorial.entity.Employee;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

@Repository
public interface EmployeeRepository extends JpaRepository&lt;Employee, Long&gt; {

    // 이메일로 직원 조회 (중복 체크용)
    Optional&lt;Employee&gt; findByEmail(String email);

    // 부서별 직원 목록 조회
    List&lt;Employee&gt; findByDepartmentOrderByLastNameAsc(String department);

    // 급여 범위로 직원 조회
    List&lt;Employee&gt; findBySalaryBetweenOrderBySalaryDesc(BigDecimal minSalary, BigDecimal maxSalary);

    // 부서별 평균 급여 계산
    @Query(&quot;SELECT e.department, AVG(e.salary) FROM Employee e GROUP BY e.department&quot;)
    List&lt;Object[]&gt; findAverageSalaryByDepartment();

    // 최근 입사자 조회
    @Query(&quot;SELECT e FROM Employee e ORDER BY e.hireDate DESC&quot;)
    List&lt;Employee&gt; findRecentHires();
}</code></pre>
<h2 id="🔧-배치-컴포넌트-구현">🔧 배치 컴포넌트 구현</h2>
<h3 id="1-itemreader---csv-파일-읽기">1. ItemReader - CSV 파일 읽기</h3>
<pre><code class="language-java">package com.example.batchtutorial.batch;

import com.example.batchtutorial.dto.EmployeeCsvDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder;
import org.springframework.batch.item.file.mapping.BeanWrapperFieldSetMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

/**
 * CSV 파일을 읽는 ItemReader 설정
 */
@Slf4j
@Configuration
public class EmployeeItemReaderConfig {

    @Bean
    public FlatFileItemReader&lt;EmployeeCsvDto&gt; employeeCsvReader() {
        return new FlatFileItemReaderBuilder&lt;EmployeeCsvDto&gt;()
                .name(&quot;employeeCsvReader&quot;)
                .resource(new ClassPathResource(&quot;data/employees.csv&quot;))
                .delimited()
                .delimiter(&quot;,&quot;)
                .names(&quot;firstName&quot;, &quot;lastName&quot;, &quot;email&quot;, &quot;department&quot;, &quot;salary&quot;, &quot;hireDate&quot;)
                .linesToSkip(1)  // 첫 번째 라인(헤더) 스킵
                .fieldSetMapper(new BeanWrapperFieldSetMapper&lt;EmployeeCsvDto&gt;() {{
                    setTargetType(EmployeeCsvDto.class);
                }})
                .build();
    }

    /**
     * 에러가 있는 CSV 파일을 읽는 Reader (에러 처리 테스트용)
     */
    @Bean
    public FlatFileItemReader&lt;EmployeeCsvDto&gt; employeeCsvReaderWithErrors() {
        return new FlatFileItemReaderBuilder&lt;EmployeeCsvDto&gt;()
                .name(&quot;employeeCsvReaderWithErrors&quot;)
                .resource(new ClassPathResource(&quot;data/employees-with-errors.csv&quot;))
                .delimited()
                .delimiter(&quot;,&quot;)
                .names(&quot;firstName&quot;, &quot;lastName&quot;, &quot;email&quot;, &quot;department&quot;, &quot;salary&quot;, &quot;hireDate&quot;)
                .linesToSkip(1)
                .fieldSetMapper(new BeanWrapperFieldSetMapper&lt;EmployeeCsvDto&gt;() {{
                    setTargetType(EmployeeCsvDto.class);
                }})
                .build();
    }
}</code></pre>
<h3 id="2-itemprocessor---데이터-변환-및-검증">2. ItemProcessor - 데이터 변환 및 검증</h3>
<pre><code class="language-java">package com.example.batchtutorial.batch;

import com.example.batchtutorial.dto.EmployeeCsvDto;
import com.example.batchtutorial.entity.Employee;
import com.example.batchtutorial.exception.DataValidationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.regex.Pattern;

/**
 * CSV 데이터를 Employee 엔티티로 변환하는 ItemProcessor
 */
@Slf4j
@Configuration
public class EmployeeItemProcessorConfig {

    private static final Pattern EMAIL_PATTERN = Pattern.compile(
            &quot;^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$&quot;
    );

    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd&quot;);

    @Bean
    public ItemProcessor&lt;EmployeeCsvDto, Employee&gt; employeeProcessor() {
        return new ItemProcessor&lt;EmployeeCsvDto, Employee&gt;() {
            @Override
            public Employee process(EmployeeCsvDto csvDto) throws Exception {

                log.debug(&quot;Processing employee: {} {}&quot;, csvDto.getFirstName(), csvDto.getLastName());

                try {
                    // 1. 기본 필드 검증
                    validateRequiredFields(csvDto);

                    // 2. Employee 엔티티 생성
                    Employee employee = new Employee();

                    // 3. 기본 정보 설정
                    employee.setFirstName(csvDto.getFirstName().trim());
                    employee.setLastName(csvDto.getLastName().trim());
                    employee.setDepartment(csvDto.getDepartment().trim());

                    // 4. 이메일 검증 및 설정
                    String email = validateAndProcessEmail(csvDto.getEmail());
                    employee.setEmail(email);

                    // 5. 급여 검증 및 설정
                    BigDecimal salary = validateAndProcessSalary(csvDto.getSalary());
                    employee.setSalary(salary);

                    // 6. 입사일 검증 및 설정
                    LocalDate hireDate = validateAndProcessHireDate(csvDto.getHireDate());
                    employee.setHireDate(hireDate);

                    log.info(&quot;Successfully processed employee: {}&quot;, employee.getFullName());
                    return employee;

                } catch (DataValidationException e) {
                    log.warn(&quot;Validation failed for employee {}: {}&quot;, 
                            csvDto.getFirstName() + &quot; &quot; + csvDto.getLastName(), e.getMessage());
                    return null;  // null 반환 시 해당 아이템은 writer로 전달되지 않음

                } catch (Exception e) {
                    log.error(&quot;Unexpected error processing employee {}: {}&quot;, 
                            csvDto.getFirstName() + &quot; &quot; + csvDto.getLastName(), e.getMessage());
                    throw e;  // 예상치 못한 오류는 재발생시켜 배치 중단
                }
            }
        };
    }

    /**
     * 필수 필드 검증
     */
    private void validateRequiredFields(EmployeeCsvDto csvDto) throws DataValidationException {
        if (!StringUtils.hasText(csvDto.getFirstName())) {
            throw new DataValidationException(&quot;이름이 비어있습니다&quot;);
        }
        if (!StringUtils.hasText(csvDto.getLastName())) {
            throw new DataValidationException(&quot;성이 비어있습니다&quot;);
        }
        if (!StringUtils.hasText(csvDto.getEmail())) {
            throw new DataValidationException(&quot;이메일이 비어있습니다&quot;);
        }
        if (!StringUtils.hasText(csvDto.getDepartment())) {
            throw new DataValidationException(&quot;부서가 비어있습니다&quot;);
        }
        if (!StringUtils.hasText(csvDto.getSalary())) {
            throw new DataValidationException(&quot;급여가 비어있습니다&quot;);
        }
        if (!StringUtils.hasText(csvDto.getHireDate())) {
            throw new DataValidationException(&quot;입사일이 비어있습니다&quot;);
        }
    }

    /**
     * 이메일 검증 및 처리
     */
    private String validateAndProcessEmail(String email) throws DataValidationException {
        String processedEmail = email.trim().toLowerCase();

        if (!EMAIL_PATTERN.matcher(processedEmail).matches()) {
            throw new DataValidationException(&quot;올바른 이메일 형식이 아닙니다: &quot; + email);
        }

        return processedEmail;
    }

    /**
     * 급여 검증 및 처리
     */
    private BigDecimal validateAndProcessSalary(String salaryStr) throws DataValidationException {
        try {
            BigDecimal salary = new BigDecimal(salaryStr.trim());

            if (salary.compareTo(BigDecimal.ZERO) &lt;= 0) {
                throw new DataValidationException(&quot;급여는 0보다 커야 합니다: &quot; + salaryStr);
            }

            if (salary.compareTo(new BigDecimal(&quot;100000000&quot;)) &gt; 0) {
                throw new DataValidationException(&quot;급여가 너무 큽니다: &quot; + salaryStr);
            }

            return salary;

        } catch (NumberFormatException e) {
            throw new DataValidationException(&quot;급여 형식이 올바르지 않습니다: &quot; + salaryStr);
        }
    }

    /**
     * 입사일 검증 및 처리
     */
    private LocalDate validateAndProcessHireDate(String hireDateStr) throws DataValidationException {
        try {
            LocalDate hireDate = LocalDate.parse(hireDateStr.trim(), DATE_FORMATTER);

            // 입사일이 미래날짜인지 확인
            if (hireDate.isAfter(LocalDate.now())) {
                throw new DataValidationException(&quot;입사일은 현재 날짜보다 미래일 수 없습니다: &quot; + hireDateStr);
            }

            // 입사일이 너무 과거인지 확인 (예: 회사 설립일 이전)
            LocalDate companyFoundedDate = LocalDate.of(2000, 1, 1);
            if (hireDate.isBefore(companyFoundedDate)) {
                throw new DataValidationException(&quot;입사일이 회사 설립일보다 이전입니다: &quot; + hireDateStr);
            }

            return hireDate;

        } catch (DateTimeParseException e) {
            throw new DataValidationException(&quot;입사일 형식이 올바르지 않습니다 (yyyy-MM-dd): &quot; + hireDateStr);
        }
    }
}</code></pre>
<h3 id="3-커스텀-예외-클래스">3. 커스텀 예외 클래스</h3>
<pre><code class="language-java">package com.example.batchtutorial.exception;

/**
 * 데이터 검증 실패 시 발생하는 예외
 */
public class DataValidationException extends Exception {

    public DataValidationException(String message) {
        super(message);
    }

    public DataValidationException(String message, Throwable cause) {
        super(message, cause);
    }
}</code></pre>
<h3 id="4-itemwriter---데이터베이스-저장">4. ItemWriter - 데이터베이스 저장</h3>
<pre><code class="language-java">package com.example.batchtutorial.batch;

import com.example.batchtutorial.entity.Employee;
import com.example.batchtutorial.repository.EmployeeRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.math.BigDecimal;
import java.util.List;

/**
 * Employee 엔티티를 데이터베이스에 저장하는 ItemWriter
 */
@Slf4j
@Configuration
public class EmployeeItemWriterConfig {

    @Autowired
    private EmployeeRepository employeeRepository;

    @Bean
    public ItemWriter&lt;Employee&gt; employeeWriter() {
        return new ItemWriter&lt;Employee&gt;() {
            @Override
            public void write(Chunk&lt;? extends Employee&gt; chunk) throws Exception {

                List&lt;? extends Employee&gt; employees = chunk.getItems();
                log.info(&quot;Writing {} employees to database&quot;, employees.size());

                // 통계 정보 수집
                int savedCount = 0;
                int duplicateCount = 0;
                BigDecimal totalSalary = BigDecimal.ZERO;

                for (Employee employee : employees) {
                    try {
                        // 중복 이메일 체크
                        if (employeeRepository.findByEmail(employee.getEmail()).isPresent()) {
                            log.warn(&quot;Duplicate email found, skipping: {}&quot;, employee.getEmail());
                            duplicateCount++;
                            continue;
                        }

                        // 직원 저장
                        Employee savedEmployee = employeeRepository.save(employee);
                        savedCount++;
                        totalSalary = totalSalary.add(savedEmployee.getSalary());

                        log.debug(&quot;Saved employee: {} (ID: {})&quot;, 
                                savedEmployee.getFullName(), savedEmployee.getId());

                    } catch (Exception e) {
                        log.error(&quot;Failed to save employee: {}&quot;, employee.getFullName(), e);
                        throw e;
                    }
                }

                // 청크 처리 결과 로깅
                log.info(&quot;Chunk processing completed - Saved: {}, Duplicates: {}, Total Salary: {}&quot;, 
                        savedCount, duplicateCount, totalSalary);

                if (savedCount == 0 &amp;&amp; duplicateCount &gt; 0) {
                    log.warn(&quot;All employees in this chunk were duplicates&quot;);
                }
            }
        };
    }
}</code></pre>
<h2 id="🔧-배치-job-설정">🔧 배치 Job 설정</h2>
<h3 id="메인-배치-설정-클래스">메인 배치 설정 클래스</h3>
<pre><code class="language-java">package com.example.batchtutorial.config;

import com.example.batchtutorial.dto.EmployeeCsvDto;
import com.example.batchtutorial.entity.Employee;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

/**
 * 직원 CSV 파일 처리 배치 Job 설정
 */
@Slf4j
@Configuration
public class EmployeeBatchConfig {

    @Autowired
    private JobRepository jobRepository;

    @Autowired
    private PlatformTransactionManager transactionManager;

    /**
     * 직원 데이터 처리 Job
     */
    @Bean
    public Job importEmployeeJob(Step importEmployeeStep) {
        return new JobBuilder(&quot;importEmployeeJob&quot;, jobRepository)
                .incrementer(new RunIdIncrementer())  // 매번 새로운 JobInstance 생성
                .flow(importEmployeeStep)
                .end()
                .build();
    }

    /**
     * 직원 데이터 처리 Step (정상 데이터)
     */
    @Bean
    public Step importEmployeeStep(
            @Qualifier(&quot;employeeCsvReader&quot;) ItemReader&lt;EmployeeCsvDto&gt; reader,
            ItemProcessor&lt;EmployeeCsvDto, Employee&gt; processor,
            ItemWriter&lt;Employee&gt; writer) {

        return new StepBuilder(&quot;importEmployeeStep&quot;, jobRepository)
                .&lt;EmployeeCsvDto, Employee&gt;chunk(3, transactionManager)  // 청크 크기 3
                .reader(reader)
                .processor(processor)
                .writer(writer)
                .build();
    }

    /**
     * 에러 처리가 포함된 Job (실습용)
     */
    @Bean
    public Job importEmployeeWithErrorHandlingJob(Step importEmployeeWithErrorHandlingStep) {
        return new JobBuilder(&quot;importEmployeeWithErrorHandlingJob&quot;, jobRepository)
                .incrementer(new RunIdIncrementer())
                .flow(importEmployeeWithErrorHandlingStep)
                .end()
                .build();
    }

    /**
     * 에러 처리가 포함된 Step
     */
    @Bean
    public Step importEmployeeWithErrorHandlingStep(
            @Qualifier(&quot;employeeCsvReaderWithErrors&quot;) ItemReader&lt;EmployeeCsvDto&gt; reader,
            ItemProcessor&lt;EmployeeCsvDto, Employee&gt; processor,
            ItemWriter&lt;Employee&gt; writer) {

        return new StepBuilder(&quot;importEmployeeWithErrorHandlingStep&quot;, jobRepository)
                .&lt;EmployeeCsvDto, Employee&gt;chunk(5, transactionManager)
                .reader(reader)
                .processor(processor)
                .writer(writer)
                // 에러 처리 설정
                .faultTolerant()
                .skipLimit(10)                                    // 최대 10개 아이템 스킵 허용
                .skip(Exception.class)                            // 모든 예외에 대해 스킵 처리
                .listener(employeeSkipListener())                 // 스킵 리스너 등록
                .build();
    }

    /**
     * 스킵된 아이템 로깅을 위한 리스너
     */
    @Bean
    public EmployeeSkipListener employeeSkipListener() {
        return new EmployeeSkipListener();
    }
}</code></pre>
<h3 id="스킵-리스너-구현">스킵 리스너 구현</h3>
<pre><code class="language-java">package com.example.batchtutorial.config;

import com.example.batchtutorial.dto.EmployeeCsvDto;
import com.example.batchtutorial.entity.Employee;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.listener.SkipListenerSupport;
import org.springframework.stereotype.Component;

/**
 * 스킵된 아이템에 대한 로깅을 처리하는 리스너
 */
@Slf4j
@Component
public class EmployeeSkipListener extends SkipListenerSupport&lt;EmployeeCsvDto, Employee&gt; {

    @Override
    public void onSkipInRead(Throwable t) {
        log.error(&quot;❌ Skip occurred in Reader: {}&quot;, t.getMessage());
    }

    @Override
    public void onSkipInProcess(EmployeeCsvDto item, Throwable t) {
        log.error(&quot;❌ Skip occurred in Processor for item [{}]: {}&quot;, 
                item.getFirstName() + &quot; &quot; + item.getLastName(), t.getMessage());
    }

    @Override
    public void onSkipInWrite(Employee item, Throwable t) {
        log.error(&quot;❌ Skip occurred in Writer for item [{}]: {}&quot;, 
                item.getFullName(), t.getMessage());
    }
}</code></pre>
<h2 id="🎮-배치-실행-컨트롤러">🎮 배치 실행 컨트롤러</h2>
<h3 id="컨트롤러-클래스-확장">컨트롤러 클래스 확장</h3>
<pre><code class="language-java">package com.example.batchtutorial.controller;

import com.example.batchtutorial.entity.Employee;
import com.example.batchtutorial.repository.EmployeeRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping(&quot;/batch&quot;)
public class EmployeeBatchController {

    @Autowired
    private JobLauncher jobLauncher;

    @Autowired
    @Qualifier(&quot;importEmployeeJob&quot;)
    private Job importEmployeeJob;

    @Autowired
    @Qualifier(&quot;importEmployeeWithErrorHandlingJob&quot;)
    private Job importEmployeeWithErrorHandlingJob;

    @Autowired
    private EmployeeRepository employeeRepository;

    /**
     * 정상 직원 데이터 배치 실행
     */
    @PostMapping(&quot;/employees&quot;)
    public String runEmployeeBatch() {
        try {
            JobParameters jobParameters = new JobParametersBuilder()
                    .addLong(&quot;timestamp&quot;, System.currentTimeMillis())
                    .toJobParameters();

            var jobExecution = jobLauncher.run(importEmployeeJob, jobParameters);

            return String.format(
                &quot;✅ 직원 데이터 배치가 실행되었습니다! &quot; +
                &quot;Job ID: %d, 상태: %s&quot;, 
                jobExecution.getId(), 
                jobExecution.getStatus()
            );

        } catch (Exception e) {
            log.error(&quot;직원 배치 실행 중 오류 발생&quot;, e);
            return &quot;❌ 배치 실행 실패: &quot; + e.getMessage();
        }
    }

    /**
     * 에러 처리 포함 배치 실행
     */
    @PostMapping(&quot;/employees-with-errors&quot;)
    public String runEmployeeBatchWithErrors() {
        try {
            JobParameters jobParameters = new JobParametersBuilder()
                    .addLong(&quot;timestamp&quot;, System.currentTimeMillis())
                    .toJobParameters();

            var jobExecution = jobLauncher.run(importEmployeeWithErrorHandlingJob, jobParameters);

            return String.format(
                &quot;✅ 에러 처리 포함 직원 배치가 실행되었습니다! &quot; +
                &quot;Job ID: %d, 상태: %s&quot;, 
                jobExecution.getId(), 
                jobExecution.getStatus()
            );

        } catch (Exception e) {
            log.error(&quot;에러 처리 배치 실행 중 오류 발생&quot;, e);
            return &quot;❌ 배치 실행 실패: &quot; + e.getMessage();
        }
    }

    /**
     * 저장된 직원 데이터 조회
     */
    @GetMapping(&quot;/employees&quot;)
    public List&lt;Employee&gt; getAllEmployees() {
        return employeeRepository.findAll();
    }

    /**
     * 부서별 통계 조회
     */
    @GetMapping(&quot;/employees/statistics&quot;)
    public Map&lt;String, Object&gt; getEmployeeStatistics() {
        List&lt;Employee&gt; allEmployees = employeeRepository.findAll();

        long totalCount = allEmployees.size();
        BigDecimal totalSalary = allEmployees.stream()
                .map(Employee::getSalary)
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        BigDecimal averageSalary = totalCount &gt; 0 ? 
                totalSalary.divide(BigDecimal.valueOf(totalCount), 2, BigDecimal.ROUND_HALF_UP) : 
                BigDecimal.ZERO;

        // 부서별 직원 수
        Map&lt;String, Long&gt; departmentCounts = allEmployees.stream()
                .collect(java.util.stream.Collectors.groupingBy(
                    Employee::getDepartment,
                    java.util.stream.Collectors.counting()
                ));

        return Map.of(
            &quot;totalEmployees&quot;, totalCount,
            &quot;totalSalary&quot;, totalSalary,
            &quot;averageSalary&quot;, averageSalary,
            &quot;departmentCounts&quot;, departmentCounts
        );
    }

    /**
     * 데이터 초기화 (테스트용)
     */
    @DeleteMapping(&quot;/employees&quot;)
    public String clearEmployeeData() {
        long deletedCount = employeeRepository.count();
        employeeRepository.deleteAll();
        return String.format(&quot;✅ %d개의 직원 데이터가 삭제되었습니다.&quot;, deletedCount);
    }
}</code></pre>
<h2 id="🧪-테스트-실행">🧪 테스트 실행</h2>
<h3 id="1-애플리케이션-실행">1. 애플리케이션 실행</h3>
<pre><code class="language-bash">./gradlew bootRun</code></pre>
<h3 id="2-배치-실행-테스트">2. 배치 실행 테스트</h3>
<pre><code class="language-bash"># 정상 데이터 배치 실행
curl -X POST http://localhost:8080/batch/employees

# 에러 데이터 포함 배치 실행
curl -X POST http://localhost:8080/batch/employees-with-errors

# 직원 데이터 조회
curl http://localhost:8080/batch/employees

# 통계 정보 조회
curl http://localhost:8080/batch/employees/statistics

# 데이터 초기화
curl -X DELETE http://localhost:8080/batch/employees</code></pre>
<h3 id="3-실행-결과-확인">3. 실행 결과 확인</h3>
<pre><code class="language-json">// GET /batch/employees/statistics 응답 예시
{
  &quot;totalEmployees&quot;: 8,
  &quot;totalSalary&quot;: 44500000,
  &quot;averageSalary&quot;: 5562500.00,
  &quot;departmentCounts&quot;: {
    &quot;개발팀&quot;: 4,
    &quot;마케팅팀&quot;: 2,
    &quot;인사팀&quot;: 2
  }
}</code></pre>
<h2 id="✅-학습-체크포인트">✅ 학습 체크포인트</h2>
<h3 id="완료해야-할-작업들">완료해야 할 작업들:</h3>
<ul>
<li><input disabled="" type="checkbox"> Employee 엔티티 및 Repository 구현</li>
<li><input disabled="" type="checkbox"> CSV 파일 ItemReader 설정</li>
<li><input disabled="" type="checkbox"> 데이터 검증 및 변환 ItemProcessor 구현</li>
<li><input disabled="" type="checkbox"> 데이터베이스 저장 ItemWriter 구현</li>
<li><input disabled="" type="checkbox"> 청크 기반 배치 Job 설정</li>
<li><input disabled="" type="checkbox"> 에러 처리 및 스킵 로직 구현</li>
<li><input disabled="" type="checkbox"> 배치 실행 및 결과 확인</li>
<li><input disabled="" type="checkbox"> 통계 정보 조회 API 테스트</li>
</ul>
<h3 id="확인-질문">확인 질문:</h3>
<ol>
<li><strong>청크 크기를 3으로 설정한 이유는?</strong></li>
<li><strong>ItemProcessor에서 null을 반환하면 어떻게 되나요?</strong></li>
<li><strong>스킵과 재시도의 차이점은 무엇인가요?</strong></li>
<li><strong>왜 DTO와 Entity를 분리했나요?</strong></li>
</ol>
<p>🎉 <strong>축하합니다!</strong> 실제 CSV 파일을 처리하는 완전한 배치 시스템을 구현했습니다. 다음 단계에서는 더 고급 기능들을 학습해보겠습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 로 기상정보 가져오기 환경설정 (맛보기 시리즈 2-5)]]></title>
            <link>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%ED%99%98%EA%B2%BD%EC%84%A4%EC%A0%95-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-5</link>
            <guid>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%ED%99%98%EA%B2%BD%EC%84%A4%EC%A0%95-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-5</guid>
            <pubDate>Fri, 29 Aug 2025 06:16:15 GMT</pubDate>
            <description><![CDATA[<h1 id="05-배치-학습-1단계-환경설정-및-첫-프로젝트">05. 배치 학습 1단계: 환경설정 및 첫 프로젝트</h1>
<h2 id="🎯-학습-목표">🎯 학습 목표</h2>
<ul>
<li>Spring Batch 개발 환경 완전 구축</li>
<li>가장 간단한 Hello World 배치 작성</li>
<li>배치 실행 및 결과 확인</li>
<li>메타데이터 테이블 이해</li>
</ul>
<h2 id="🛠️-개발-환경-설정">🛠️ 개발 환경 설정</h2>
<h3 id="1-필수-소프트웨어-설치">1. 필수 소프트웨어 설치</h3>
<h4 id="java-development-kit-jdk-17">Java Development Kit (JDK 17+)</h4>
<pre><code class="language-bash"># Windows (Chocolatey 사용)
choco install openjdk17

# macOS (Homebrew 사용)
brew install openjdk@17

# Linux (Ubuntu)
sudo apt-get install openjdk-17-jdk

# 설치 확인
java -version
javac -version</code></pre>
<h4 id="intellij-idea-또는-eclipse">IntelliJ IDEA 또는 Eclipse</h4>
<pre><code class="language-bash"># IntelliJ IDEA Community Edition (무료)
# https://www.jetbrains.com/idea/download/

# Eclipse IDE for Enterprise Java Developers
# https://www.eclipse.org/downloads/</code></pre>
<h4 id="gradle-또는-maven">Gradle 또는 Maven</h4>
<pre><code class="language-bash"># Gradle 설치 (권장)
# Windows
choco install gradle

# macOS
brew install gradle

# Linux
sudo apt-get install gradle

# 설치 확인
gradle -version</code></pre>
<h3 id="2-spring-boot-프로젝트-생성">2. Spring Boot 프로젝트 생성</h3>
<h4 id="spring-initializr-사용">Spring Initializr 사용</h4>
<ol>
<li><p><strong>웹 브라우저에서</strong> <a href="https://start.spring.io/">https://start.spring.io/</a> 접속</p>
</li>
<li><p><strong>프로젝트 설정:</strong></p>
<pre><code>Project: Gradle Project
Language: Java
Spring Boot: 3.2.x (안정 버전)
Group: com.example
Artifact: batch-tutorial
Name: batch-tutorial
Description: Spring Batch Tutorial Project
Package name: com.example.batchtutorial
Packaging: Jar
Java: 17</code></pre></li>
<li><p><strong>Dependencies 추가:</strong></p>
<ul>
<li>Spring Batch</li>
<li>Spring Boot DevTools</li>
<li>H2 Database</li>
<li>Spring Data JPA</li>
<li>Spring Web (웹 UI용)</li>
</ul>
</li>
<li><p><strong>GENERATE</strong> 클릭 후 다운로드</p>
</li>
</ol>
<h4 id="프로젝트-구조-확인">프로젝트 구조 확인</h4>
<pre><code>batch-tutorial/
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/example/batchtutorial/
│   │   │       └── BatchTutorialApplication.java
│   │   └── resources/
│   │       └── application.properties
│   └── test/
│       └── java/
│           └── com/example/batchtutorial/
│               └── BatchTutorialApplicationTests.java
├── build.gradle
└── gradlew (Unix용 실행 스크립트)</code></pre><h3 id="3-의존성-설정-확인-및-수정">3. 의존성 설정 확인 및 수정</h3>
<h4 id="buildgradle-파일-내용">build.gradle 파일 내용:</h4>
<pre><code class="language-gradle">plugins {
    id &#39;java&#39;
    id &#39;org.springframework.boot&#39; version &#39;3.2.0&#39;
    id &#39;io.spring.dependency-management&#39; version &#39;1.1.4&#39;
}

group = &#39;com.example&#39;
version = &#39;0.0.1-SNAPSHOT&#39;

java {
    sourceCompatibility = &#39;17&#39;
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring Batch 핵심
    implementation &#39;org.springframework.boot:spring-boot-starter-batch&#39;

    // 데이터베이스
    implementation &#39;org.springframework.boot:spring-boot-starter-data-jpa&#39;
    runtimeOnly &#39;com.h2database:h2&#39;

    // 웹 (모니터링용)
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;

    // 개발 편의
    developmentOnly &#39;org.springframework.boot:spring-boot-devtools&#39;

    // 로깅 및 유틸리티
    compileOnly &#39;org.projectlombok:lombok&#39;
    annotationProcessor &#39;org.projectlombok:lombok&#39;

    // 테스트
    testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
    testImplementation &#39;org.springframework.batch:spring-batch-test&#39;
}

tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}</code></pre>
<h3 id="4-기본-설정-파일-구성">4. 기본 설정 파일 구성</h3>
<h4 id="applicationproperties-설정">application.properties 설정:</h4>
<pre><code class="language-properties"># 애플리케이션 기본 설정
server.port=8080
spring.application.name=batch-tutorial

# H2 데이터베이스 설정
spring.datasource.url=jdbc:h2:mem:batchdb
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver

# H2 콘솔 활성화 (개발용)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# JPA/Hibernate 설정
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Spring Batch 설정
spring.batch.job.enabled=false
spring.batch.jdbc.initialize-schema=always

# 로깅 설정
logging.level.org.springframework.batch=DEBUG
logging.level.com.example.batchtutorial=DEBUG</code></pre>
<h2 id="🏃♂️-첫-번째-배치-작성">🏃‍♂️ 첫 번째 배치 작성</h2>
<h3 id="1-hello-world-배치-구현">1. Hello World 배치 구현</h3>
<h4 id="메인-애플리케이션-클래스">메인 애플리케이션 클래스:</h4>
<pre><code class="language-java">package com.example.batchtutorial;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BatchTutorialApplication {

    public static void main(String[] args) {
        SpringApplication.run(BatchTutorialApplication.class, args);
    }
}</code></pre>
<h4 id="첫-번째-배치-설정-클래스">첫 번째 배치 설정 클래스:</h4>
<pre><code class="language-java">package com.example.batchtutorial.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Slf4j
@Configuration
public class HelloWorldBatchConfig {

    @Autowired
    private JobRepository jobRepository;

    @Autowired
    private PlatformTransactionManager transactionManager;

    /**
     * Hello World Job 정의
     */
    @Bean
    public Job helloWorldJob(Step helloWorldStep) {
        return new JobBuilder(&quot;helloWorldJob&quot;, jobRepository)
                .start(helloWorldStep)
                .build();
    }

    /**
     * Hello World Step 정의 - Tasklet 방식
     */
    @Bean
    public Step helloWorldStep() {
        return new StepBuilder(&quot;helloWorldStep&quot;, jobRepository)
                .tasklet(helloWorldTasklet(), transactionManager)
                .build();
    }

    /**
     * 실제 작업을 수행하는 Tasklet
     */
    @Bean
    public Tasklet helloWorldTasklet() {
        return (contribution, chunkContext) -&gt; {
            log.info(&quot;===========================================&quot;);
            log.info(&quot;🎉 Hello, Spring Batch World!&quot;);
            log.info(&quot;🚀 첫 번째 배치가 성공적으로 실행되었습니다!&quot;);
            log.info(&quot;⏰ 실행 시간: {}&quot;, java.time.LocalDateTime.now());
            log.info(&quot;===========================================&quot;);

            return RepeatStatus.FINISHED;
        };
    }
}</code></pre>
<h3 id="2-배치-실행을-위한-컨트롤러-추가">2. 배치 실행을 위한 컨트롤러 추가</h3>
<h4 id="웹-컨트롤러-클래스">웹 컨트롤러 클래스:</h4>
<pre><code class="language-java">package com.example.batchtutorial.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping(&quot;/batch&quot;)
public class BatchController {

    @Autowired
    private JobLauncher jobLauncher;

    @Autowired
    private Job helloWorldJob;

    /**
     * 배치 수동 실행 엔드포인트
     */
    @PostMapping(&quot;/hello&quot;)
    public String runHelloWorldBatch() {
        try {
            // 매번 다른 파라미터로 실행 (중복 실행 방지)
            JobParameters jobParameters = new JobParametersBuilder()
                    .addLong(&quot;timestamp&quot;, System.currentTimeMillis())
                    .toJobParameters();

            var jobExecution = jobLauncher.run(helloWorldJob, jobParameters);

            return String.format(
                &quot;배치가 실행되었습니다! &quot; +
                &quot;Job ID: %d, 상태: %s&quot;, 
                jobExecution.getId(), 
                jobExecution.getStatus()
            );

        } catch (Exception e) {
            log.error(&quot;배치 실행 중 오류 발생&quot;, e);
            return &quot;배치 실행 실패: &quot; + e.getMessage();
        }
    }

    /**
     * 간단한 상태 확인 페이지
     */
    @GetMapping(&quot;/status&quot;)
    public String getStatus() {
        return &quot;Spring Batch Tutorial 애플리케이션이 실행 중입니다! &quot; +
               &quot;POST /batch/hello 로 배치를 실행하세요.&quot;;
    }
}</code></pre>
<h3 id="3-간단한-웹-페이지-추가-선택사항">3. 간단한 웹 페이지 추가 (선택사항)</h3>
<h4 id="html-템플릿-srcmainresourcesstaticindexhtml">HTML 템플릿 (src/main/resources/static/index.html):</h4>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;Spring Batch Tutorial&lt;/title&gt;
    &lt;style&gt;
        body { 
            font-family: Arial, sans-serif; 
            max-width: 800px; 
            margin: 50px auto; 
            padding: 20px; 
        }
        .container { 
            text-align: center; 
            background: #f5f5f5; 
            padding: 30px; 
            border-radius: 10px; 
        }
        button { 
            background: #007bff; 
            color: white; 
            border: none; 
            padding: 15px 30px; 
            font-size: 16px; 
            border-radius: 5px; 
            cursor: pointer; 
            margin: 10px;
        }
        button:hover { background: #0056b3; }
        .result { 
            margin-top: 20px; 
            padding: 15px; 
            background: white; 
            border-radius: 5px; 
            border-left: 4px solid #007bff;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;div class=&quot;container&quot;&gt;
        &lt;h1&gt;🚀 Spring Batch Tutorial&lt;/h1&gt;
        &lt;p&gt;첫 번째 Spring Batch 프로젝트에 오신 것을 환영합니다!&lt;/p&gt;

        &lt;button onclick=&quot;runBatch()&quot;&gt;📤 Hello World 배치 실행&lt;/button&gt;
        &lt;button onclick=&quot;openH2Console()&quot;&gt;🗄️ H2 데이터베이스 콘솔&lt;/button&gt;

        &lt;div id=&quot;result&quot; class=&quot;result&quot; style=&quot;display: none;&quot;&gt;
            &lt;h3&gt;실행 결과:&lt;/h3&gt;
            &lt;p id=&quot;resultText&quot;&gt;&lt;/p&gt;
        &lt;/div&gt;
    &lt;/div&gt;

    &lt;script&gt;
        function runBatch() {
            const button = event.target;
            button.disabled = true;
            button.innerHTML = &#39;⏳ 실행 중...&#39;;

            fetch(&#39;/batch/hello&#39;, { method: &#39;POST&#39; })
                .then(response =&gt; response.text())
                .then(data =&gt; {
                    document.getElementById(&#39;result&#39;).style.display = &#39;block&#39;;
                    document.getElementById(&#39;resultText&#39;).textContent = data;
                    button.disabled = false;
                    button.innerHTML = &#39;📤 Hello World 배치 실행&#39;;
                })
                .catch(error =&gt; {
                    alert(&#39;오류 발생: &#39; + error);
                    button.disabled = false;
                    button.innerHTML = &#39;📤 Hello World 배치 실행&#39;;
                });
        }

        function openH2Console() {
            window.open(&#39;/h2-console&#39;, &#39;_blank&#39;);
        }
    &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre>
<h2 id="🏃♂️-프로젝트-실행-및-테스트">🏃‍♂️ 프로젝트 실행 및 테스트</h2>
<h3 id="1-애플리케이션-빌드-및-실행">1. 애플리케이션 빌드 및 실행</h3>
<h4 id="터미널에서-실행">터미널에서 실행:</h4>
<pre><code class="language-bash"># 프로젝트 디렉토리로 이동
cd batch-tutorial

# Gradle로 빌드 및 실행
./gradlew bootRun

# Windows에서는
gradlew.bat bootRun</code></pre>
<h4 id="실행-로그-확인">실행 로그 확인:</h4>
<pre><code>  .   ____          _            __ _ _
 /\\ / ___&#39;_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | &#39;_ | &#39;_| | &#39;_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  &#39;  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.2.0)

2024-08-27 10:30:15.123  INFO --- [main] c.e.b.BatchTutorialApplication : Starting BatchTutorialApplication
2024-08-27 10:30:16.456  INFO --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http)
2024-08-27 10:30:16.789  INFO --- [main] c.e.b.BatchTutorialApplication : Started BatchTutorialApplication in 2.345 seconds</code></pre><h3 id="2-배치-실행-테스트">2. 배치 실행 테스트</h3>
<h4 id="방법-1-웹-브라우저-사용">방법 1: 웹 브라우저 사용</h4>
<ol>
<li><strong><a href="http://localhost:8080">http://localhost:8080</a></strong> 접속</li>
<li><strong>&quot;Hello World 배치 실행&quot;</strong> 버튼 클릭</li>
<li>실행 결과 확인</li>
</ol>
<h4 id="방법-2-curl-명령어-사용">방법 2: cURL 명령어 사용</h4>
<pre><code class="language-bash"># 배치 실행
curl -X POST http://localhost:8080/batch/hello

# 응답 예시:
# 배치가 실행되었습니다! Job ID: 1, 상태: COMPLETED</code></pre>
<h4 id="방법-3-ide에서-직접-실행">방법 3: IDE에서 직접 실행</h4>
<pre><code class="language-java">// 테스트 클래스 작성
@SpringBootTest
class HelloWorldBatchTest {

    @Autowired
    private JobLauncher jobLauncher;

    @Autowired
    private Job helloWorldJob;

    @Test
    void testHelloWorldBatch() throws Exception {
        JobParameters jobParameters = new JobParametersBuilder()
                .addLong(&quot;timestamp&quot;, System.currentTimeMillis())
                .toJobParameters();

        JobExecution jobExecution = jobLauncher.run(helloWorldJob, jobParameters);

        assertEquals(BatchStatus.COMPLETED, jobExecution.getStatus());
    }
}</code></pre>
<h3 id="3-실행-결과-확인">3. 실행 결과 확인</h3>
<h4 id="콘솔-로그">콘솔 로그:</h4>
<pre><code>2024-08-27 10:35:22.123  INFO --- [http-nio-8080-exec-1] c.e.b.config.HelloWorldBatchConfig : ===========================================
2024-08-27 10:35:22.124  INFO --- [http-nio-8080-exec-1] c.e.b.config.HelloWorldBatchConfig : 🎉 Hello, Spring Batch World!
2024-08-27 10:35:22.124  INFO --- [http-nio-8080-exec-1] c.e.b.config.HelloWorldBatchConfig : 🚀 첫 번째 배치가 성공적으로 실행되었습니다!
2024-08-27 10:35:22.125  INFO --- [http-nio-8080-exec-1] c.e.b.config.HelloWorldBatchConfig : ⏰ 실행 시간: 2024-08-27T10:35:22.124
2024-08-27 10:35:22.125  INFO --- [http-nio-8080-exec-1] c.e.b.config.HelloWorldBatchConfig : ===========================================</code></pre><h2 id="📊-메타데이터-테이블-확인">📊 메타데이터 테이블 확인</h2>
<h3 id="1-h2-콘솔-접속">1. H2 콘솔 접속</h3>
<ol>
<li><strong><a href="http://localhost:8080/h2-console">http://localhost:8080/h2-console</a></strong> 접속</li>
<li><strong>JDBC URL</strong>: <code>jdbc:h2:mem:batchdb</code></li>
<li><strong>사용자명</strong>: <code>sa</code></li>
<li><strong>비밀번호</strong>: (비워두기)</li>
<li><strong>Connect</strong> 클릭</li>
</ol>
<h3 id="2-spring-batch-메타데이터-테이블들">2. Spring Batch 메타데이터 테이블들</h3>
<h4 id="주요-테이블-구조">주요 테이블 구조:</h4>
<pre><code class="language-sql">-- Job 인스턴스 정보
SELECT * FROM BATCH_JOB_INSTANCE;

-- Job 실행 정보
SELECT 
    JOB_EXECUTION_ID,
    JOB_INSTANCE_ID,
    CREATE_TIME,
    START_TIME,
    END_TIME,
    STATUS,
    EXIT_CODE
FROM BATCH_JOB_EXECUTION;

-- Step 실행 정보
SELECT 
    STEP_EXECUTION_ID,
    JOB_EXECUTION_ID,
    STEP_NAME,
    STATUS,
    READ_COUNT,
    WRITE_COUNT,
    COMMIT_COUNT,
    START_TIME,
    END_TIME
FROM BATCH_STEP_EXECUTION;

-- Job 파라미터
SELECT * FROM BATCH_JOB_EXECUTION_PARAMS;</code></pre>
<h4 id="실행-결과-예시">실행 결과 예시:</h4>
<pre><code>BATCH_JOB_EXECUTION 테이블:
JOB_EXECUTION_ID | JOB_INSTANCE_ID | STATUS    | START_TIME          | END_TIME
1                | 1               | COMPLETED | 2024-08-27 10:35:22 | 2024-08-27 10:35:22

BATCH_STEP_EXECUTION 테이블:
STEP_EXECUTION_ID | JOB_EXECUTION_ID | STEP_NAME        | STATUS    | COMMIT_COUNT
1                 | 1                | helloWorldStep   | COMPLETED | 1</code></pre><h2 id="✅-학습-체크포인트">✅ 학습 체크포인트</h2>
<h3 id="완료해야-할-작업들">완료해야 할 작업들:</h3>
<ul>
<li><input disabled="" type="checkbox"> Java 17+ 설치 및 확인</li>
<li><input disabled="" type="checkbox"> IDE (IntelliJ 또는 Eclipse) 설치</li>
<li><input disabled="" type="checkbox"> Spring Boot 프로젝트 생성</li>
<li><input disabled="" type="checkbox"> 의존성 설정 확인</li>
<li><input disabled="" type="checkbox"> Hello World 배치 구현</li>
<li><input disabled="" type="checkbox"> 웹 컨트롤러 작성</li>
<li><input disabled="" type="checkbox"> 애플리케이션 실행 성공</li>
<li><input disabled="" type="checkbox"> 배치 실행 및 로그 확인</li>
<li><input disabled="" type="checkbox"> H2 콘솔에서 메타데이터 테이블 확인</li>
</ul>
<h3 id="확인-질문">확인 질문:</h3>
<ol>
<li><strong>JobRepository의 역할은 무엇인가요?</strong></li>
<li><strong>Tasklet과 Chunk 방식의 차이점은?</strong></li>
<li><strong>JobParameters가 왜 필요한가요?</strong></li>
<li><strong>배치 실행 상태는 어디에 저장되나요?</strong></li>
</ol>
<h3 id="다음-단계-준비">다음 단계 준비:</h3>
<ul>
<li>ItemReader, ItemProcessor, ItemWriter 개념 학습</li>
<li>실제 데이터(CSV 파일) 처리 배치 구현</li>
<li>에러 처리 및 재시작 기능</li>
</ul>
<p>🎉 <strong>축하합니다!</strong> 첫 번째 Spring Batch 프로젝트를 성공적으로 완성했습니다. 이제 다음 단계로 넘어가서 더 실용적인 배치를 만들어보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 로 기상정보 가져오기 배치 가이드  (맛보기 시리즈 2-4)]]></title>
            <link>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EB%B0%B0%EC%B9%98-%EA%B0%80%EC%9D%B4%EB%93%9C-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-4</link>
            <guid>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EB%B0%B0%EC%B9%98-%EA%B0%80%EC%9D%B4%EB%93%9C-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-4</guid>
            <pubDate>Fri, 29 Aug 2025 06:15:11 GMT</pubDate>
            <description><![CDATA[<h1 id="04-spring-batch-기초-완전-정복">04. Spring Batch 기초 완전 정복</h1>
<h2 id="🎯-spring-batch란">🎯 Spring Batch란?</h2>
<p>Spring Batch는 <strong>대용량 데이터 처리</strong>를 위한 경량급, 포괄적인 배치 프레임워크입니다. 엔터프라이즈 환경에서 일상적으로 필요한 견고한 배치 애플리케이션 개발을 가능하게 합니다.</p>
<h3 id="핵심-특징">핵심 특징</h3>
<ul>
<li>✅ <strong>트랜잭션 관리</strong>: 대용량 데이터 처리 시 안전한 트랜잭션 보장</li>
<li>✅ <strong>청크 기반 처리</strong>: 메모리 효율적인 대용량 데이터 처리</li>
<li>✅ <strong>재시작/재시도</strong>: 실패 지점부터 재시작 가능</li>
<li>✅ <strong>스케일링</strong>: 멀티스레드, 병렬처리, 원격처리 지원</li>
<li>✅ <strong>모니터링</strong>: 실행 상태 추적 및 관리</li>
</ul>
<h2 id="🏗️-spring-batch-아키텍처">🏗️ Spring Batch 아키텍처</h2>
<h3 id="전체-구조도">전체 구조도</h3>
<pre><code>JobLauncher ──┐
              ├─► Job ──┐
JobRepository ──┘      ├─► Step1 ──┐
                       │           ├─► ItemReader
                       │           ├─► ItemProcessor  
                       │           └─► ItemWriter
                       └─► Step2 ──┐
                                   ├─► ItemReader
                                   ├─► ItemProcessor
                                   └─► ItemWriter</code></pre><h3 id="핵심-컴포넌트-상세">핵심 컴포넌트 상세</h3>
<h4 id="1-job-작업">1. Job (작업)</h4>
<p><strong>Job은 전체 배치 프로세스를 캡슐화한 엔티티</strong>입니다.</p>
<pre><code class="language-java">@Bean
public Job myBatchJob(Step step1, Step step2) {
    return new JobBuilder(&quot;myBatchJob&quot;, jobRepository)
        .start(step1)        // 첫 번째 Step
        .next(step2)         // 두 번째 Step
        .build();
}

// 조건부 실행 예시
@Bean
public Job conditionalJob(Step step1, Step step2, Step step3) {
    return new JobBuilder(&quot;conditionalJob&quot;, jobRepository)
        .start(step1)
        .on(&quot;FAILED&quot;).to(step3)      // step1 실패 시 step3 실행
        .from(step1).on(&quot;*&quot;).to(step2)  // step1 성공 시 step2 실행
        .build().build();
}</code></pre>
<p><strong>Job의 주요 속성:</strong></p>
<ul>
<li><code>JobInstance</code>: Job의 논리적 실행 단위</li>
<li><code>JobExecution</code>: Job의 물리적 실행 시도</li>
<li><code>JobParameters</code>: Job 실행 시 전달되는 파라미터</li>
</ul>
<h4 id="2-step-단계">2. Step (단계)</h4>
<p><strong>Step은 Job 내에서 독립적이고 순차적인 단계</strong>를 나타냅니다.</p>
<pre><code class="language-java">@Bean
public Step processDataStep(ItemReader&lt;InputData&gt; reader,
                           ItemProcessor&lt;InputData, OutputData&gt; processor,
                           ItemWriter&lt;OutputData&gt; writer) {
    return new StepBuilder(&quot;processDataStep&quot;, jobRepository)
        .&lt;InputData, OutputData&gt;chunk(1000, transactionManager)  // 1000개씩 처리
        .reader(reader)
        .processor(processor)
        .writer(writer)
        .build();
}</code></pre>
<p><strong>Step의 두 가지 유형:</strong></p>
<h5 id="chunk-oriented-step-청크-지향">Chunk-oriented Step (청크 지향)</h5>
<pre><code class="language-java">// 청크 기반 처리 - 가장 일반적
.chunk(chunkSize, transactionManager)
.reader(itemReader)      // 데이터 읽기
.processor(itemProcessor) // 데이터 처리 (선택사항)
.writer(itemWriter)      // 데이터 쓰기</code></pre>
<h5 id="tasklet-based-step-태스크릿-기반">Tasklet-based Step (태스크릿 기반)</h5>
<pre><code class="language-java">// 단순 태스크 실행 - 파일 삭제, 정리 작업 등
@Bean
public Step cleanupStep() {
    return new StepBuilder(&quot;cleanupStep&quot;, jobRepository)
        .tasklet(new Tasklet() {
            @Override
            public RepeatStatus execute(StepContribution contribution,
                                       ChunkContext chunkContext) {
                // 정리 작업 수행
                cleanupTempFiles();
                return RepeatStatus.FINISHED;
            }
        }, transactionManager)
        .build();
}</code></pre>
<h4 id="3-itemreader-데이터-읽기">3. ItemReader (데이터 읽기)</h4>
<p><strong>다양한 소스에서 데이터를 읽어오는 인터페이스</strong></p>
<pre><code class="language-java">public interface ItemReader&lt;T&gt; {
    T read() throws Exception, UnexpectedInputException, ParseException;
}</code></pre>
<p><strong>주요 구현체들:</strong></p>
<h5 id="파일-읽기-csv-txt">파일 읽기 (CSV, TXT)</h5>
<pre><code class="language-java">@Bean
public FlatFileItemReader&lt;Person&gt; csvReader() {
    return new FlatFileItemReaderBuilder&lt;Person&gt;()
        .name(&quot;csvReader&quot;)
        .resource(new ClassPathResource(&quot;input.csv&quot;))
        .delimited()
        .delimiter(&quot;,&quot;)
        .names(&quot;firstName&quot;, &quot;lastName&quot;, &quot;email&quot;, &quot;age&quot;)
        .fieldSetMapper(new BeanWrapperFieldSetMapper&lt;Person&gt;() {{
            setTargetType(Person.class);
        }})
        .linesToSkip(1)  // 헤더 행 스킵
        .build();
}</code></pre>
<h5 id="데이터베이스-읽기-jpa">데이터베이스 읽기 (JPA)</h5>
<pre><code class="language-java">@Bean
public JpaPagingItemReader&lt;Customer&gt; databaseReader() {
    return new JpaPagingItemReaderBuilder&lt;Customer&gt;()
        .name(&quot;customerReader&quot;)
        .entityManagerFactory(entityManagerFactory)
        .queryString(&quot;SELECT c FROM Customer c WHERE c.active = true&quot;)
        .pageSize(100)  // 페이지당 100개
        .build();
}</code></pre>
<h5 id="데이터베이스-읽기-jdbc">데이터베이스 읽기 (JDBC)</h5>
<pre><code class="language-java">@Bean
public JdbcCursorItemReader&lt;Person&gt; jdbcReader() {
    return new JdbcCursorItemReaderBuilder&lt;Person&gt;()
        .name(&quot;jdbcReader&quot;)
        .dataSource(dataSource)
        .sql(&quot;SELECT first_name, last_name, email FROM person WHERE active = 1&quot;)
        .rowMapper(new BeanPropertyRowMapper&lt;&gt;(Person.class))
        .build();
}</code></pre>
<h5 id="json-파일-읽기">JSON 파일 읽기</h5>
<pre><code class="language-java">@Bean
public JsonItemReader&lt;Person&gt; jsonReader() {
    return new JsonItemReaderBuilder&lt;Person&gt;()
        .name(&quot;jsonReader&quot;)
        .resource(new ClassPathResource(&quot;input.json&quot;))
        .jsonObjectReader(new JacksonJsonObjectReader&lt;&gt;(Person.class))
        .build();
}</code></pre>
<h5 id="xml-파일-읽기">XML 파일 읽기</h5>
<pre><code class="language-java">@Bean
public StaxEventItemReader&lt;Person&gt; xmlReader() {
    return new StaxEventItemReaderBuilder&lt;Person&gt;()
        .name(&quot;xmlReader&quot;)
        .resource(new ClassPathResource(&quot;input.xml&quot;))
        .addFragmentRootElements(&quot;person&quot;)
        .unmarshaller(personUnmarshaller())
        .build();
}</code></pre>
<h4 id="4-itemprocessor-데이터-처리">4. ItemProcessor (데이터 처리)</h4>
<p><strong>읽어온 데이터를 비즈니스 로직에 따라 변환하는 인터페이스</strong></p>
<pre><code class="language-java">public interface ItemProcessor&lt;I, O&gt; {
    O process(I item) throws Exception;
}</code></pre>
<p><strong>실제 구현 예시:</strong></p>
<h5 id="단순-변환">단순 변환</h5>
<pre><code class="language-java">@Bean
public ItemProcessor&lt;Person, Person&gt; validatingProcessor() {
    return new ItemProcessor&lt;Person, Person&gt;() {
        @Override
        public Person process(Person person) throws Exception {
            // 데이터 검증
            if (person.getEmail() == null || !person.getEmail().contains(&quot;@&quot;)) {
                return null;  // null 반환 시 해당 아이템은 writer로 전달되지 않음
            }

            // 데이터 변환
            person.setFirstName(person.getFirstName().toUpperCase());
            person.setLastName(person.getLastName().toUpperCase());

            return person;
        }
    };
}</code></pre>
<h5 id="복합-프로세서-여러-프로세서-연결">복합 프로세서 (여러 프로세서 연결)</h5>
<pre><code class="language-java">@Bean
public CompositeItemProcessor&lt;Person, Person&gt; compositeProcessor() {
    List&lt;ItemProcessor&lt;?, ?&gt;&gt; processors = new ArrayList&lt;&gt;();
    processors.add(validationProcessor());
    processors.add(transformationProcessor());
    processors.add(enrichmentProcessor());

    CompositeItemProcessor&lt;Person, Person&gt; processor = new CompositeItemProcessor&lt;&gt;();
    processor.setDelegates(processors);
    return processor;
}</code></pre>
<h5 id="외부-api-호출-프로세서">외부 API 호출 프로세서</h5>
<pre><code class="language-java">@Bean
public ItemProcessor&lt;String, WeatherData&gt; apiCallProcessor() {
    return cityName -&gt; {
        try {
            // 외부 API 호출
            WeatherApiResponse response = webClient.get()
                .uri(&quot;/weather?q=&quot; + cityName)
                .retrieve()
                .bodyToMono(WeatherApiResponse.class)
                .timeout(Duration.ofSeconds(5))  // 5초 타임아웃
                .block();

            // 응답을 엔티티로 변환
            return convertToWeatherData(response, cityName);

        } catch (Exception e) {
            log.error(&quot;API call failed for city: {}&quot;, cityName, e);
            return null;  // 실패 시 null 반환
        }
    };
}</code></pre>
<h4 id="5-itemwriter-데이터-쓰기">5. ItemWriter (데이터 쓰기)</h4>
<p><strong>처리된 데이터를 최종 목적지에 저장하는 인터페이스</strong></p>
<pre><code class="language-java">public interface ItemWriter&lt;T&gt; {
    void write(List&lt;? extends T&gt; items) throws Exception;
}</code></pre>
<p><strong>주요 구현체들:</strong></p>
<h5 id="파일-쓰기">파일 쓰기</h5>
<pre><code class="language-java">@Bean
public FlatFileItemWriter&lt;Person&gt; csvWriter() {
    return new FlatFileItemWriterBuilder&lt;Person&gt;()
        .name(&quot;csvWriter&quot;)
        .resource(new FileSystemResource(&quot;output.csv&quot;))
        .delimited()
        .delimiter(&quot;,&quot;)
        .names(&quot;firstName&quot;, &quot;lastName&quot;, &quot;email&quot;)
        .headerCallback(writer -&gt; writer.write(&quot;FirstName,LastName,Email&quot;))  // 헤더
        .build();
}</code></pre>
<h5 id="데이터베이스-쓰기-jpa">데이터베이스 쓰기 (JPA)</h5>
<pre><code class="language-java">@Bean
public JpaItemWriter&lt;Customer&gt; jpaWriter() {
    JpaItemWriter&lt;Customer&gt; writer = new JpaItemWriter&lt;&gt;();
    writer.setEntityManagerFactory(entityManagerFactory);
    return writer;
}</code></pre>
<h5 id="데이터베이스-쓰기-jdbc">데이터베이스 쓰기 (JDBC)</h5>
<pre><code class="language-java">@Bean
public JdbcBatchItemWriter&lt;Person&gt; jdbcWriter() {
    return new JdbcBatchItemWriterBuilder&lt;Person&gt;()
        .dataSource(dataSource)
        .sql(&quot;INSERT INTO person (first_name, last_name, email) VALUES (:firstName, :lastName, :email)&quot;)
        .beanMapped()  // Bean 속성을 SQL 파라미터로 매핑
        .build();
}</code></pre>
<h5 id="복합-writer-여러-writer에-동시-저장">복합 Writer (여러 Writer에 동시 저장)</h5>
<pre><code class="language-java">@Bean
public CompositeItemWriter&lt;Person&gt; compositeWriter() {
    List&lt;ItemWriter&lt;? super Person&gt;&gt; writers = new ArrayList&lt;&gt;();
    writers.add(databaseWriter());
    writers.add(fileWriter());
    writers.add(emailNotificationWriter());

    CompositeItemWriter&lt;Person&gt; writer = new CompositeItemWriter&lt;&gt;();
    writer.setDelegates(writers);
    return writer;
}</code></pre>
<h2 id="🔄-청크-기반-처리의-이해">🔄 청크 기반 처리의 이해</h2>
<h3 id="청크-처리-과정">청크 처리 과정</h3>
<pre><code>[Reader] ──► Item1 ──┐
[Reader] ──► Item2 ──┤
[Reader] ──► Item3 ──┤ Chunk (Size=3)
                     ├──► [Processor] ──► ProcessedChunk ──► [Writer]
                     │                                      │
                     └──► Transaction Commit ◄──────────────┘</code></pre><h3 id="청크-크기-결정-기준">청크 크기 결정 기준</h3>
<pre><code class="language-java">// 작은 청크 크기 (10-100)
.chunk(50, transactionManager)
// 장점: 빠른 피드백, 적은 메모리 사용, 롤백 범위 최소화
// 단점: 트랜잭션 오버헤드 증가

// 큰 청크 크기 (1000-10000)
.chunk(5000, transactionManager)
// 장점: 높은 처리 성능, 트랜잭션 오버헤드 감소
// 단점: 메모리 사용량 증가, 롤백 시 재처리 데이터 많음</code></pre>
<h3 id="청크-처리-최적화-팁">청크 처리 최적화 팁</h3>
<pre><code class="language-java">@Bean
public Step optimizedStep() {
    return new StepBuilder(&quot;optimizedStep&quot;, jobRepository)
        .&lt;InputData, OutputData&gt;chunk(1000, transactionManager)
        .reader(reader())
        .processor(processor())
        .writer(writer())

        // 성능 최적화 설정
        .faultTolerant()
        .skipLimit(100)                    // 최대 100개 아이템 스킵 허용
        .skip(ValidationException.class)   // 특정 예외는 스킵
        .retryLimit(3)                     // 최대 3번 재시도
        .retry(TransientException.class)   // 일시적 예외는 재시도

        // 멀티스레드 처리
        .taskExecutor(taskExecutor())
        .throttleLimit(4)                  // 동시 실행 스레드 수

        build();
}</code></pre>
<h2 id="💾-메타데이터-관리">💾 메타데이터 관리</h2>
<h3 id="jobrepository">JobRepository</h3>
<p>Spring Batch는 실행 중인 Job의 상태 정보를 JobRepository에 저장합니다.</p>
<pre><code class="language-sql">-- 주요 메타데이터 테이블들
BATCH_JOB_INSTANCE        -- Job 인스턴스 정보
BATCH_JOB_EXECUTION       -- Job 실행 정보  
BATCH_JOB_EXECUTION_PARAMS -- Job 파라미터
BATCH_STEP_EXECUTION      -- Step 실행 정보
BATCH_STEP_EXECUTION_CONTEXT -- Step 실행 컨텍스트</code></pre>
<h3 id="jobparameters-사용법">JobParameters 사용법</h3>
<pre><code class="language-java">// JobParameters 생성
JobParameters jobParameters = new JobParametersBuilder()
    .addString(&quot;inputFile&quot;, &quot;/path/to/input.csv&quot;)
    .addLong(&quot;timestamp&quot;, System.currentTimeMillis())
    .addDate(&quot;processDate&quot;, new Date())
    .toJobParameters();

// Job 실행
JobExecution execution = jobLauncher.run(job, jobParameters);</code></pre>
<h2 id="🚨-에러-처리-전략">🚨 에러 처리 전략</h2>
<h3 id="1-skip-건너뛰기">1. Skip (건너뛰기)</h3>
<pre><code class="language-java">@Bean
public Step skipStep() {
    return new StepBuilder(&quot;skipStep&quot;, jobRepository)
        .&lt;String, String&gt;chunk(10, transactionManager)
        .reader(reader())
        .processor(processor())
        .writer(writer())
        .faultTolerant()
        .skip(ValidationException.class)     // ValidationException 발생 시 스킵
        .skip(DataAccessException.class)     // DB 접근 오류 시 스킵
        .skipLimit(100)                      // 최대 100개까지 스킵 허용
        .skipPolicy(customSkipPolicy())      // 커스텀 스킵 정책
        .build();
}</code></pre>
<h3 id="2-retry-재시도">2. Retry (재시도)</h3>
<pre><code class="language-java">@Bean
public Step retryStep() {
    return new StepBuilder(&quot;retryStep&quot;, jobRepository)
        .&lt;String, String&gt;chunk(10, transactionManager)
        .reader(reader())
        .processor(processor())
        .writer(writer())
        .faultTolerant()
        .retry(ConnectException.class)       // 연결 오류 시 재시도
        .retry(TimeoutException.class)       // 타임아웃 시 재시도
        .retryLimit(3)                       // 최대 3번 재시도
        .retryPolicy(customRetryPolicy())    // 커스텀 재시도 정책
        .build();
}</code></pre>
<h3 id="3-커스텀-예외-처리">3. 커스텀 예외 처리</h3>
<pre><code class="language-java">@Component
public class CustomSkipPolicy implements SkipPolicy {

    private static final Logger logger = LoggerFactory.getLogger(CustomSkipPolicy.class);

    @Override
    public boolean shouldSkip(Throwable exception, int skipCount) throws SkipLimitExceededException {

        if (exception instanceof ValidationException) {
            logger.warn(&quot;Validation error occurred. Skipping item. Skip count: {}&quot;, skipCount);
            return skipCount &lt; 50;  // ValidationException은 50개까지 스킵
        }

        if (exception instanceof DataAccessException) {
            logger.error(&quot;Database error occurred. Skipping item. Skip count: {}&quot;, skipCount);
            return skipCount &lt; 10;  // DB 오류는 10개까지만 스킵
        }

        return false;  // 다른 예외는 스킵하지 않음
    }
}</code></pre>
<h2 id="🎯-실전-예제-완전한-배치-시스템">🎯 실전 예제: 완전한 배치 시스템</h2>
<h3 id="고객-데이터-처리-배치">고객 데이터 처리 배치</h3>
<pre><code class="language-java">@Configuration
@EnableBatchProcessing
public class CustomerProcessingBatchConfig {

    @Bean
    public Job customerProcessingJob() {
        return new JobBuilder(&quot;customerProcessingJob&quot;, jobRepository)
            .start(readCustomersStep())          // 1. 고객 데이터 읽기
            .next(validateCustomersStep())       // 2. 데이터 검증
            .next(enrichCustomersStep())         // 3. 데이터 보강
            .next(calculateScoreStep())          // 4. 점수 계산
            .next(sendNotificationStep())        // 5. 알림 발송
            .next(cleanupStep())                 // 6. 정리 작업
            .build();
    }

    // Step 1: 고객 데이터 읽기
    @Bean
    public Step readCustomersStep() {
        return new StepBuilder(&quot;readCustomersStep&quot;, jobRepository)
            .&lt;CustomerCsv, Customer&gt;chunk(500, transactionManager)
            .reader(customerCsvReader())
            .processor(csvToCustomerProcessor())
            .writer(customerDatabaseWriter())
            .build();
    }

    // Step 2: 데이터 검증
    @Bean
    public Step validateCustomersStep() {
        return new StepBuilder(&quot;validateCustomersStep&quot;, jobRepository)
            .&lt;Customer, Customer&gt;chunk(1000, transactionManager)
            .reader(customerDatabaseReader())
            .processor(validationProcessor())
            .writer(validatedCustomerWriter())
            .faultTolerant()
            .skip(ValidationException.class)
            .skipLimit(100)
            .listener(validationSkipListener())
            .build();
    }

    // Step 3: 외부 API를 통한 데이터 보강
    @Bean
    public Step enrichCustomersStep() {
        return new StepBuilder(&quot;enrichCustomersStep&quot;, jobRepository)
            .&lt;Customer, Customer&gt;chunk(100, transactionManager)
            .reader(validatedCustomerReader())
            .processor(enrichmentProcessor())
            .writer(enrichedCustomerWriter())
            .faultTolerant()
            .retry(ConnectException.class)
            .retryLimit(3)
            .taskExecutor(taskExecutor())
            .throttleLimit(5)  // API 호출 제한
            .build();
    }
}</code></pre>
<p>이제 Spring Batch의 모든 기본 개념을 이해했습니다! 다음 단계에서는 실제로 배치를 어떻게 구현하고 운영하는지 더 자세히 알아보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 로 기상정보 가져오기 (맛보기 시리즈 2-3)]]></title>
            <link>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-3</link>
            <guid>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-3</guid>
            <pubDate>Fri, 29 Aug 2025 06:14:11 GMT</pubDate>
            <description><![CDATA[<h1 id="03-시스템-학습-및-확장-가이드">03. 시스템 학습 및 확장 가이드</h1>
<h2 id="🎯-학습-목표">🎯 학습 목표</h2>
<p>이 문서는 현재 Spring Batch 시스템을 <strong>어떻게 공부하고</strong>, <strong>어떻게 확장할 수 있는지</strong> 상세하게 안내합니다.</p>
<h2 id="📚-코드-분석을-통한-학습-방법">📚 코드 분석을 통한 학습 방법</h2>
<h3 id="1-핵심-클래스-분석-순서">1. 핵심 클래스 분석 순서</h3>
<h4 id="1단계-기본-구조-이해">1단계: 기본 구조 이해</h4>
<pre><code class="language-java">// 1. 메인 애플리케이션 클래스 분석
SpringBatchApplication.java
→ @SpringBootApplication 어노테이션의 의미
→ main() 메서드의 역할
→ Spring Boot 자동 설정 메커니즘

// 2. 설정 클래스 분석
BatchConfig.java, WeatherBatchConfig.java
→ @Configuration 어노테이션
→ @Bean 메서드들의 의존성 주입
→ Job, Step, ItemReader/Processor/Writer 관계</code></pre>
<h4 id="2단계-배치-컴포넌트-상세-분석">2단계: 배치 컴포넌트 상세 분석</h4>
<h5 id="job-분석-작업-단위">Job 분석 (작업 단위):</h5>
<pre><code class="language-java">@Bean
public Job importUserJob(Step step1) {
    return new JobBuilder(&quot;importUserJob&quot;, jobRepository)
            .start(step1)  // 시작 Step 지정
            .build();
}

// 학습 포인트:
// - Job은 여러 Step을 포함할 수 있음
// - JobRepository는 배치 실행 메타데이터 관리
// - JobBuilder 패턴 사용법</code></pre>
<h5 id="step-분석-처리-단계">Step 분석 (처리 단계):</h5>
<pre><code class="language-java">@Bean
public Step step1(ItemReader&lt;Person&gt; reader,
                  ItemProcessor&lt;Person, Person&gt; processor,
                  ItemWriter&lt;Person&gt; writer) {
    return new StepBuilder(&quot;step1&quot;, jobRepository)
            .&lt;Person, Person&gt;chunk(3, transactionManager)  // 청크 크기 = 3
            .reader(reader)      // 데이터 읽기
            .processor(processor) // 데이터 처리
            .writer(writer)      // 데이터 쓰기
            .build();
}

// 학습 포인트:
// - 청크 기반 처리: 3개씩 묶어서 트랜잭션 처리
// - Reader → Processor → Writer 파이프라인
// - 각 컴포넌트의 독립성과 재사용성</code></pre>
<h4 id="3단계-각-컴포넌트-심화-분석">3단계: 각 컴포넌트 심화 분석</h4>
<h5 id="itemreader-분석">ItemReader 분석:</h5>
<pre><code class="language-java">// CSV 파일 읽기
@Bean
public FlatFileItemReader&lt;Person&gt; reader() {
    return new FlatFileItemReaderBuilder&lt;Person&gt;()
            .name(&quot;personItemReader&quot;)
            .resource(new ClassPathResource(&quot;sample-data.csv&quot;))
            .delimited()  // CSV 구분자 설정
            .names(new String[]{&quot;firstName&quot;, &quot;lastName&quot;, &quot;email&quot;})
            .fieldSetMapper(new BeanWrapperFieldSetMapper&lt;Person&gt;() {{
                setTargetType(Person.class);  // 매핑 대상 클래스
            }})
            .build();
}

// 학습 해볼 점:
// 1. 다른 데이터 소스는? (Database, JSON, XML)
// 2. 파일 경로 동적 설정 방법은?
// 3. 에러 발생 시 처리 방법은?</code></pre>
<h5 id="itemprocessor-분석">ItemProcessor 분석:</h5>
<pre><code class="language-java">// 날씨 데이터 처리 예시
@Bean
public ItemProcessor&lt;String, WeatherData&gt; weatherProcessor() {
    return cityCode -&gt; {
        try {
            // 1. 외부 API 호출
            WeatherApiResponse response = weatherApiService
                .getCurrentWeather(cityCode).block();

            // 2. 데이터 변환
            WeatherData weatherData = convertToWeatherData(response, cityCode);

            // 3. 비즈니스 로직 (이상 기후 탐지)
            detectAbnormalWeather(weatherData);

            return weatherData;
        } catch (Exception e) {
            log.error(&quot;Processing failed for {}: {}&quot;, cityCode, e.getMessage());
            return null;  // null 반환 시 해당 아이템 스킵
        }
    };
}

// 학습 해볼 점:
// 1. 복잡한 변환 로직 구현 방법
// 2. 외부 API 호출 시 에러 처리
// 3. 조건부 처리 (특정 조건에서만 처리)
// 4. 병렬 처리를 위한 멀티스레드 적용</code></pre>
<h3 id="2-실전-코드-실습-방법">2. 실전 코드 실습 방법</h3>
<h4 id="디버깅을-통한-학습">디버깅을 통한 학습:</h4>
<pre><code class="language-java">// 1. IDE에서 브레이크포인트 설정
@Bean
public ItemProcessor&lt;Person, Person&gt; processor() {
    return (item) -&gt; {
        // ↓ 여기에 브레이크포인트 설정
        log.info(&quot;Processing person: {}&quot;, item);

        // 데이터 변환 로직 실행 과정 관찰
        String upperFirstName = item.getFirstName().toUpperCase();
        item.setFirstName(upperFirstName);

        return item;  // ← 반환값 확인
    };
}</code></pre>
<h4 id="로그-분석">로그 분석:</h4>
<pre><code class="language-bash"># 애플리케이션 실행 후 로그 확인
./gradlew bootRun

# 주요 확인 포인트:
# - Job 실행 시작/종료 로그
# - Step 실행 과정
# - 청크 단위 처리 로그
# - 에러 발생 시 스택 트레이스</code></pre>
<h2 id="🛠️-시스템-확장-방법">🛠️ 시스템 확장 방법</h2>
<h3 id="1-새로운-배치-job-추가">1. 새로운 배치 Job 추가</h3>
<h4 id="예시-주식-데이터-배치-추가">예시: 주식 데이터 배치 추가</h4>
<pre><code class="language-java">@Configuration
public class StockBatchConfig {

    @Autowired
    private JobRepository jobRepository;

    @Autowired
    private PlatformTransactionManager transactionManager;

    // 1. 새로운 Job 정의
    @Bean
    public Job collectStockDataJob(Step stockCollectionStep) {
        return new JobBuilder(&quot;collectStockDataJob&quot;, jobRepository)
                .start(stockCollectionStep)
                .build();
    }

    // 2. Step 정의
    @Bean
    public Step stockCollectionStep() {
        return new StepBuilder(&quot;stockCollectionStep&quot;, jobRepository)
                .&lt;String, StockData&gt;chunk(5, transactionManager)
                .reader(stockSymbolReader())      // 주식 심볼 리더
                .processor(stockDataProcessor())   // 주식 API 호출
                .writer(stockDataWriter())        // DB 저장
                .build();
    }

    // 3. 주식 심볼 읽기
    @Bean
    public ItemReader&lt;String&gt; stockSymbolReader() {
        List&lt;String&gt; symbols = List.of(&quot;AAPL&quot;, &quot;GOOGL&quot;, &quot;MSFT&quot;, &quot;AMZN&quot;);

        return new ItemReader&lt;String&gt;() {
            private int index = 0;

            @Override
            public String read() {
                if (index &lt; symbols.size()) {
                    return symbols.get(index++);
                }
                return null;
            }
        };
    }

    // 4. 주식 데이터 처리
    @Bean
    public ItemProcessor&lt;String, StockData&gt; stockDataProcessor() {
        return symbol -&gt; {
            // Yahoo Finance API 또는 Alpha Vantage API 호출
            // JSON 응답을 StockData 엔티티로 변환
            return stockApiService.getStockData(symbol);
        };
    }

    // 5. DB 저장
    @Bean
    public ItemWriter&lt;StockData&gt; stockDataWriter() {
        return chunk -&gt; {
            List&lt;? extends StockData&gt; stockDataList = chunk.getItems();
            stockDataRepository.saveAll(stockDataList);
        };
    }
}</code></pre>
<h4 id="필요한-추가-클래스들">필요한 추가 클래스들:</h4>
<pre><code class="language-java">// StockData 엔티티
@Entity
@Table(name = &quot;stock_data&quot;)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class StockData {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String symbol;       // 주식 심볼
    private String companyName;  // 회사명
    private Double currentPrice; // 현재 가격
    private Double openPrice;    // 시가
    private Double highPrice;    // 고가
    private Double lowPrice;     // 저가
    private Long volume;         // 거래량
    private LocalDateTime collectedAt;
}

// StockDataRepository
@Repository
public interface StockDataRepository extends JpaRepository&lt;StockData, Long&gt; {
    List&lt;StockData&gt; findBySymbolOrderByCollectedAtDesc(String symbol);
    List&lt;StockData&gt; findTop10ByOrderByCollectedAtDesc();
}

// StockApiService
@Service
public class StockApiService {

    private final WebClient webClient;

    public StockApiService() {
        this.webClient = WebClient.builder()
            .baseUrl(&quot;https://api.example.com/stock&quot;)
            .build();
    }

    public StockData getStockData(String symbol) {
        // 실제 API 호출 로직 구현
        return webClient.get()
            .uri(&quot;/quote/{symbol}&quot;, symbol)
            .retrieve()
            .bodyToMono(StockData.class)
            .block();
    }
}</code></pre>
<h3 id="2-스케줄링-추가">2. 스케줄링 추가</h3>
<h4 id="cron-표현식으로-자동-실행">Cron 표현식으로 자동 실행:</h4>
<pre><code class="language-java">@Component
@EnableScheduling
public class BatchScheduler {

    @Autowired
    private JobLauncher jobLauncher;

    @Autowired
    private Job collectWeatherDataJob;

    @Autowired
    private Job collectStockDataJob;

    // 매시간 정각에 날씨 데이터 수집
    @Scheduled(cron = &quot;0 0 * * * *&quot;)
    public void runWeatherBatch() {
        try {
            JobParameters params = new JobParametersBuilder()
                .addLong(&quot;time&quot;, System.currentTimeMillis())
                .toJobParameters();

            jobLauncher.run(collectWeatherDataJob, params);
        } catch (Exception e) {
            log.error(&quot;Weather batch scheduling failed&quot;, e);
        }
    }

    // 평일 오전 9시에 주식 데이터 수집
    @Scheduled(cron = &quot;0 0 9 * * MON-FRI&quot;)
    public void runStockBatch() {
        try {
            JobParameters params = new JobParametersBuilder()
                .addLong(&quot;time&quot;, System.currentTimeMillis())
                .toJobParameters();

            jobLauncher.run(collectStockDataJob, params);
        } catch (Exception e) {
            log.error(&quot;Stock batch scheduling failed&quot;, e);
        }
    }
}</code></pre>
<h3 id="3-에러-처리-및-재시도-로직-추가">3. 에러 처리 및 재시도 로직 추가</h3>
<h4 id="재시도-정책-설정">재시도 정책 설정:</h4>
<pre><code class="language-java">@Bean
public Step resilientWeatherStep() {
    return new StepBuilder(&quot;resilientWeatherStep&quot;, jobRepository)
            .&lt;String, WeatherData&gt;chunk(3, transactionManager)
            .reader(cityReader())
            .processor(weatherProcessor())
            .writer(weatherWriter())
            // 재시도 설정
            .faultTolerant()
            .retry(Exception.class)        // 모든 예외에 대해 재시도
            .retryLimit(3)                 // 최대 3번 재시도
            // 특정 예외는 건너뛰기
            .skip(IllegalArgumentException.class)
            .skipLimit(5)                  // 최대 5개까지 건너뛰기
            .build();
}</code></pre>
<h4 id="실패한-아이템-별도-처리">실패한 아이템 별도 처리:</h4>
<pre><code class="language-java">@Bean
public Step stepWithFailureHandling() {
    return new StepBuilder(&quot;stepWithFailureHandling&quot;, jobRepository)
            .&lt;String, WeatherData&gt;chunk(3, transactionManager)
            .reader(cityReader())
            .processor(weatherProcessor())
            .writer(weatherWriter())
            .faultTolerant()
            // 실패한 아이템을 별도 파일로 저장
            .skipListener(new SkipListener&lt;String, WeatherData&gt;() {
                @Override
                public void onSkipInProcessing(String item, Throwable t) {
                    log.error(&quot;Skipped processing for city: {}, Error: {}&quot;, 
                        item, t.getMessage());
                    // 실패 로그를 파일이나 별도 테이블에 저장
                    saveFailedItem(item, t.getMessage());
                }
            })
            .build();
}</code></pre>
<h3 id="4-성능-최적화">4. 성능 최적화</h3>
<h4 id="멀티스레드-처리">멀티스레드 처리:</h4>
<pre><code class="language-java">@Bean
public Step parallelWeatherStep() {
    return new StepBuilder(&quot;parallelWeatherStep&quot;, jobRepository)
            .&lt;String, WeatherData&gt;chunk(3, transactionManager)
            .reader(cityReader())
            .processor(weatherProcessor())
            .writer(weatherWriter())
            // 멀티스레드 설정
            .taskExecutor(taskExecutor())
            .throttleLimit(4)  // 동시 실행 스레드 수
            .build();
}

@Bean
public TaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(25);
    executor.setThreadNamePrefix(&quot;batch-thread-&quot;);
    executor.initialize();
    return executor;
}</code></pre>
<h4 id="파티션-처리-대용량-데이터">파티션 처리 (대용량 데이터):</h4>
<pre><code class="language-java">@Bean
public Step partitionStep() {
    return new StepBuilder(&quot;partitionStep&quot;, jobRepository)
            .partitioner(&quot;slaveStep&quot;, partitioner())
            .step(slaveStep())
            .gridSize(4)  // 4개 파티션으로 분할
            .taskExecutor(taskExecutor())
            .build();
}

@Bean
public Partitioner partitioner() {
    return new Partitioner() {
        @Override
        public Map&lt;String, ExecutionContext&gt; partition(int gridSize) {
            Map&lt;String, ExecutionContext&gt; result = new HashMap&lt;&gt;();

            // 도시 목록을 4개 그룹으로 분할
            List&lt;String&gt; cities = List.of(&quot;Seoul&quot;, &quot;Busan&quot;, &quot;Incheon&quot;, 
                                         &quot;Daegu&quot;, &quot;Daejeon&quot;, &quot;Gwangju&quot;, 
                                         &quot;Ulsan&quot;, &quot;Suwon&quot;);

            int partitionSize = cities.size() / gridSize;

            for (int i = 0; i &lt; gridSize; i++) {
                ExecutionContext context = new ExecutionContext();
                int start = i * partitionSize;
                int end = (i == gridSize - 1) ? cities.size() : (i + 1) * partitionSize;

                context.put(&quot;cities&quot;, cities.subList(start, end));
                result.put(&quot;partition&quot; + i, context);
            }

            return result;
        }
    };
}</code></pre>
<h2 id="🔍-고급-학습-방향">🔍 고급 학습 방향</h2>
<h3 id="1-spring-batch-admin-연동">1. Spring Batch Admin 연동</h3>
<ul>
<li>배치 작업 모니터링 웹 UI</li>
<li>실행 이력 및 상태 관리</li>
<li>실패한 Job 재시작 기능</li>
</ul>
<h3 id="2-메시지-큐-연동-rabbitmq-kafka">2. 메시지 큐 연동 (RabbitMQ, Kafka)</h3>
<pre><code class="language-java">// Kafka를 통한 배치 트리거
@KafkaListener(topics = &quot;weather-batch-trigger&quot;)
public void handleBatchTrigger(String message) {
    // 메시지 수신 시 배치 실행
    jobLauncher.run(collectWeatherDataJob, createJobParameters());
}</code></pre>
<h3 id="3-클라우드-환경-배포">3. 클라우드 환경 배포</h3>
<ul>
<li>AWS Batch, Google Cloud Dataflow</li>
<li>Kubernetes CronJob</li>
<li>Docker 컨테이너화</li>
</ul>
<h3 id="4-데이터베이스-최적화">4. 데이터베이스 최적화</h3>
<pre><code class="language-java">// 대용량 처리를 위한 JPA 배치 설정
@Bean
public JpaItemWriter&lt;WeatherData&gt; optimizedWriter() {
    JpaItemWriter&lt;WeatherData&gt; writer = new JpaItemWriter&lt;&gt;();
    writer.setEntityManagerFactory(entityManagerFactory);
    // 배치 삽입 최적화
    return writer;
}</code></pre>
<h2 id="📖-추천-학습-리소스">📖 추천 학습 리소스</h2>
<h3 id="공식-문서">공식 문서:</h3>
<ul>
<li>Spring Batch Reference Documentation</li>
<li>Spring Boot Batch 가이드</li>
</ul>
<h3 id="실습-프로젝트-아이디어">실습 프로젝트 아이디어:</h3>
<ol>
<li><strong>로그 파일 분석 배치</strong>: 웹 서버 로그 파싱 및 통계 생성</li>
<li><strong>데이터 마이그레이션 배치</strong>: Legacy 시스템에서 신규 시스템으로 데이터 이전</li>
<li><strong>리포트 생성 배치</strong>: 일간/주간/월간 리포트 자동 생성</li>
<li><strong>데이터 정제 배치</strong>: 중복 데이터 제거, 데이터 품질 향상</li>
</ol>
<p>이 가이드를 통해 현재 시스템을 완전히 이해하고 자신만의 배치 시스템으로 확장할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 로 기상정보 가져오기 (맛보기 시리즈 2-2)]]></title>
            <link>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-2</link>
            <guid>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A1%9C-%EA%B8%B0%EC%83%81%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-2</guid>
            <pubDate>Fri, 29 Aug 2025 06:13:24 GMT</pubDate>
            <description><![CDATA[<h1 id="02-현재-시스템-사용법">02. 현재 시스템 사용법</h1>
<h2 id="🚀-시스템-시작하기">🚀 시스템 시작하기</h2>
<h3 id="1-애플리케이션-실행">1. 애플리케이션 실행</h3>
<pre><code class="language-bash"># 프로젝트 디렉토리에서
./gradlew bootRun

# 또는 Windows에서
gradlew.bat bootRun</code></pre>
<h3 id="2-접속-정보">2. 접속 정보</h3>
<ul>
<li><strong>메인 페이지</strong>: <a href="http://localhost:8080">http://localhost:8080</a></li>
<li><strong>날씨 대시보드</strong>: <a href="http://localhost:8080/weather/dashboard">http://localhost:8080/weather/dashboard</a></li>
<li><strong>H2 콘솔</strong>: <a href="http://localhost:8080/h2-console">http://localhost:8080/h2-console</a></li>
</ul>
<h3 id="3-h2-데이터베이스-접속-정보">3. H2 데이터베이스 접속 정보</h3>
<pre><code>JDBC URL: jdbc:h2:mem:testdb
사용자명: sa
비밀번호: (비워두세요)</code></pre><h2 id="📋-csv-배치-시스템-사용법">📋 CSV 배치 시스템 사용법</h2>
<h3 id="1-웹-ui를-통한-실행">1. 웹 UI를 통한 실행</h3>
<h4 id="메인-페이지에서">메인 페이지에서:</h4>
<ol>
<li><strong><a href="http://localhost:8080">http://localhost:8080</a></strong> 접속</li>
<li><strong>&quot;📤 CSV 배치 실행&quot;</strong> 버튼 클릭</li>
<li>확인 대화상자에서 &quot;확인&quot; 클릭</li>
<li>배치 실행 결과 확인</li>
</ol>
<pre><code class="language-javascript">// 실행 버튼 JavaScript 코드
function runBatch(button) {
    if(confirm(&#39;배치 작업을 실행하시겠습니까?&#39;)) {
        button.disabled = true;
        button.innerHTML = &#39;⏳ 실행 중...&#39;;

        fetch(&#39;/run-batch&#39;, { method: &#39;POST&#39; })
        .then(response =&gt; response.text())
        .then(data =&gt; {
            alert(data);  // 실행 결과 표시
            button.disabled = false;
            button.innerHTML = &#39;📤 배치 실행&#39;;
        });
    }
}</code></pre>
<h3 id="2-rest-api를-통한-실행">2. REST API를 통한 실행</h3>
<h4 id="post-요청으로-직접-실행">POST 요청으로 직접 실행:</h4>
<pre><code class="language-bash">curl -X POST http://localhost:8080/run-batch</code></pre>
<h4 id="응답-예시">응답 예시:</h4>
<pre><code class="language-json">{
  &quot;message&quot;: &quot;배치가 성공적으로 완료되었습니다!&quot;,
  &quot;jobExecutionId&quot;: 1,
  &quot;status&quot;: &quot;COMPLETED&quot;
}</code></pre>
<h3 id="3-처리되는-데이터-확인">3. 처리되는 데이터 확인</h3>
<h4 id="csv-파일-내용-sample-datacsv">CSV 파일 내용 (sample-data.csv):</h4>
<pre><code class="language-csv">firstName,lastName,email
김,철수,kim.chulsoo@example.com
이,영희,lee.younghee@example.com
박,민수,park.minsoo@example.com
최,수지,choi.suji@example.com
정,재호,jung.jaeho@example.com</code></pre>
<h4 id="저장된-데이터-확인">저장된 데이터 확인:</h4>
<ol>
<li><strong>웹 UI</strong>: &quot;📊 저장된 사용자 데이터&quot; 버튼 클릭</li>
<li><strong>REST API</strong>: GET <a href="http://localhost:8080/persons">http://localhost:8080/persons</a></li>
<li><strong>H2 콘솔</strong>: <code>SELECT * FROM PERSON;</code></li>
</ol>
<h2 id="🌦️-날씨-배치-시스템-사용법">🌦️ 날씨 배치 시스템 사용법</h2>
<h3 id="1-날씨-대시보드-접속">1. 날씨 대시보드 접속</h3>
<h4 id="대시보드-메인-화면">대시보드 메인 화면:</h4>
<ol>
<li><strong><a href="http://localhost:8080/weather/dashboard">http://localhost:8080/weather/dashboard</a></strong> 접속</li>
<li>실시간 날씨 데이터 현황 확인</li>
<li>통계 카드에서 전체 수집 데이터 수, 모니터링 도시 수, 이상 기후 감지 수 확인</li>
</ol>
<h3 id="2-날씨-데이터-수집-실행">2. 날씨 데이터 수집 실행</h3>
<h4 id="웹-ui를-통한-실행">웹 UI를 통한 실행:</h4>
<pre><code class="language-html">&lt;!-- 대시보드에서 --&gt;
&lt;button onclick=&quot;collectWeatherData()&quot; class=&quot;btn btn-primary&quot;&gt;
    🌍 날씨 데이터 수집
&lt;/button&gt;</code></pre>
<ol>
<li><strong>&quot;🌍 날씨 데이터 수집&quot;</strong> 버튼 클릭</li>
<li>배치 실행 확인 (약 10-15초 소요)</li>
<li>자동 페이지 새로고침 (3초 후)</li>
<li>업데이트된 날씨 데이터 확인</li>
</ol>
<h4 id="rest-api를-통한-실행">REST API를 통한 실행:</h4>
<pre><code class="language-bash">curl -X POST http://localhost:8080/weather/collect</code></pre>
<h3 id="3-수집-대상-도시">3. 수집 대상 도시</h3>
<h4 id="전국-주요-8개-도시">전국 주요 8개 도시:</h4>
<pre><code class="language-java">List&lt;String&gt; cities = List.of(
    &quot;Seoul&quot;,    // 서울
    &quot;Busan&quot;,    // 부산
    &quot;Incheon&quot;,  // 인천
    &quot;Daegu&quot;,    // 대구
    &quot;Daejeon&quot;,  // 대전
    &quot;Gwangju&quot;,  // 광주
    &quot;Ulsan&quot;,    // 울산
    &quot;Suwon&quot;     // 수원
);</code></pre>
<h3 id="4-수집되는-날씨-정보">4. 수집되는 날씨 정보</h3>
<h4 id="기본-날씨-데이터">기본 날씨 데이터:</h4>
<ul>
<li><strong>온도 정보</strong>: 현재 온도, 체감 온도, 최고/최저 온도</li>
<li><strong>기상 상태</strong>: 날씨 상태 (맑음, 흐림, 비 등), 상세 설명</li>
<li><strong>대기 정보</strong>: 습도, 기압, 가시거리</li>
<li><strong>바람 정보</strong>: 풍속, 풍향</li>
<li><strong>강수 정보</strong>: 강수량, 적설량 (1시간 단위)</li>
</ul>
<h4 id="추가-분석-데이터">추가 분석 데이터:</h4>
<ul>
<li><strong>수집 시간</strong>: 데이터 수집 시각</li>
<li><strong>온도 변화량</strong>: 전날 동시간 대비 온도 차이</li>
<li><strong>이상 기후 여부</strong>: 20도 이상 온도 변화 시 true</li>
</ul>
<h3 id="5-실시간-데이터-모니터링">5. 실시간 데이터 모니터링</h3>
<h4 id="대시보드-섹션별-기능">대시보드 섹션별 기능:</h4>
<h5 id="📊-통계-카드">📊 통계 카드:</h5>
<pre><code class="language-html">&lt;div class=&quot;stats-grid&quot;&gt;
    &lt;div class=&quot;stat-card&quot;&gt;
        &lt;h3&gt;전체 수집 데이터&lt;/h3&gt;
        &lt;div class=&quot;value&quot;&gt;1,234&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;stat-card&quot;&gt;
        &lt;h3&gt;모니터링 도시&lt;/h3&gt;
        &lt;div class=&quot;value&quot;&gt;8&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;stat-card&quot;&gt;
        &lt;h3&gt;이상 기후 감지&lt;/h3&gt;
        &lt;div class=&quot;value&quot;&gt;2&lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;</code></pre>
<h5 id="🌤️-도시별-현재-날씨">🌤️ 도시별 현재 날씨:</h5>
<ul>
<li>각 도시의 최신 날씨 정보</li>
<li>온도, 날씨 상태, 수집 시간</li>
<li>전날 대비 온도 변화량 표시</li>
</ul>
<h5 id="🌡️-온도-순위">🌡️ 온도 순위:</h5>
<ul>
<li>현재 온도 기준 도시별 순위</li>
<li>습도, 체감 온도 추가 정보</li>
</ul>
<h5 id="⚠️-이상-기후-알림">⚠️ 이상 기후 알림:</h5>
<ul>
<li>전날 대비 20도 이상 변화한 지역</li>
<li>급격한 온도 변화 감지 시 자동 표시</li>
</ul>
<h2 id="🔧-api-엔드포인트-가이드">🔧 API 엔드포인트 가이드</h2>
<h3 id="csv-배치-관련-api">CSV 배치 관련 API</h3>
<h4 id="1-배치-실행">1. 배치 실행</h4>
<pre><code class="language-http">POST /run-batch
Content-Type: application/json

Response:
{
  &quot;message&quot;: &quot;배치가 성공적으로 완료되었습니다!&quot;,
  &quot;jobExecutionId&quot;: 1,
  &quot;status&quot;: &quot;COMPLETED&quot;
}</code></pre>
<h4 id="2-저장된-사용자-데이터-조회">2. 저장된 사용자 데이터 조회</h4>
<pre><code class="language-http">GET /persons

Response:
[
  {
    &quot;id&quot;: 1,
    &quot;firstName&quot;: &quot;김&quot;,
    &quot;lastName&quot;: &quot;철수&quot;,
    &quot;email&quot;: &quot;kim.chulsoo@example.com&quot;
  },
  ...
]</code></pre>
<h3 id="날씨-배치-관련-api">날씨 배치 관련 API</h3>
<h4 id="1-날씨-데이터-수집-실행">1. 날씨 데이터 수집 실행</h4>
<pre><code class="language-http">POST /weather/collect

Response: &quot;날씨 데이터 수집 배치가 시작되었습니다. 상태: STARTED&quot;</code></pre>
<h4 id="2-전체-날씨-데이터-조회">2. 전체 날씨 데이터 조회</h4>
<pre><code class="language-http">GET /weather/data

Response:
[
  {
    &quot;id&quot;: 1,
    &quot;cityCode&quot;: &quot;Seoul&quot;,
    &quot;cityName&quot;: &quot;서울&quot;,
    &quot;temperature&quot;: 15.5,
    &quot;humidity&quot;: 65,
    &quot;weatherMain&quot;: &quot;Clear&quot;,
    &quot;collectedAt&quot;: &quot;2025-08-27T14:30:00&quot;,
    &quot;isAbnormal&quot;: false,
    &quot;temperatureChange&quot;: 2.1
  },
  ...
]</code></pre>
<h4 id="3-도시별-최신-날씨-조회">3. 도시별 최신 날씨 조회</h4>
<pre><code class="language-http">GET /weather/current

Response: [최신 날씨 데이터만]</code></pre>
<h4 id="4-이상-기후-데이터-조회">4. 이상 기후 데이터 조회</h4>
<pre><code class="language-http">GET /weather/abnormal

Response: [이상 기후로 감지된 데이터만]</code></pre>
<h4 id="5-날씨-통계-조회">5. 날씨 통계 조회</h4>
<pre><code class="language-http">GET /weather/statistics

Response:
{
  &quot;todayRecords&quot;: 24,
  &quot;averageTemperature&quot;: 18.5,
  &quot;maxTemperature&quot;: 25.2,
  &quot;minTemperature&quot;: 12.1,
  &quot;abnormalCount&quot;: 1
}</code></pre>
<h2 id="🛠️-데이터베이스-직접-조회">🛠️ 데이터베이스 직접 조회</h2>
<h3 id="h2-콘솔-사용법">H2 콘솔 사용법</h3>
<h4 id="1-콘솔-접속">1. 콘솔 접속:</h4>
<ol>
<li><a href="http://localhost:8080/h2-console">http://localhost:8080/h2-console</a> 접속</li>
<li>JDBC URL: <code>jdbc:h2:mem:testdb</code> 입력</li>
<li>사용자명: <code>sa</code>, 비밀번호: 빈값</li>
<li>&quot;Connect&quot; 클릭</li>
</ol>
<h4 id="2-유용한-sql-쿼리">2. 유용한 SQL 쿼리:</h4>
<h5 id="전체-사용자-데이터-조회">전체 사용자 데이터 조회:</h5>
<pre><code class="language-sql">SELECT * FROM PERSON;</code></pre>
<h5 id="날씨-데이터-조회">날씨 데이터 조회:</h5>
<pre><code class="language-sql">-- 최신 날씨 데이터
SELECT city_name, temperature, weather_main, collected_at 
FROM WEATHER_DATA 
ORDER BY collected_at DESC 
LIMIT 10;

-- 이상 기후 데이터
SELECT city_name, temperature, temperature_change, collected_at
FROM WEATHER_DATA 
WHERE is_abnormal = true
ORDER BY collected_at DESC;

-- 도시별 평균 온도
SELECT city_name, AVG(temperature) as avg_temp, COUNT(*) as records
FROM WEATHER_DATA 
GROUP BY city_name;</code></pre>
<h2 id="⚠️-주의사항-및-문제-해결">⚠️ 주의사항 및 문제 해결</h2>
<h3 id="1-api-키-설정-확인">1. API 키 설정 확인</h3>
<pre><code class="language-bash"># .env 파일 확인
cat .env

# 출력 예시:
# WEATHER_API_KEY=4336a7df2186cc57454035002475e06a</code></pre>
<h3 id="2-포트-충돌-해결">2. 포트 충돌 해결</h3>
<pre><code class="language-bash"># 8080 포트 사용 중인 프로세스 확인
netstat -ano | findstr :8080

# 프로세스 종료 (Windows)
taskkill /F /PID [프로세스ID]</code></pre>
<h3 id="3-배치-실행-실패-시">3. 배치 실행 실패 시</h3>
<ul>
<li>로그 확인: 콘솔 출력에서 ERROR 레벨 메시지 확인</li>
<li>API 키 유효성 확인</li>
<li>인터넷 연결 상태 확인</li>
<li>H2 데이터베이스 연결 상태 확인</li>
</ul>
<h3 id="4-일반적인-에러-해결">4. 일반적인 에러 해결</h3>
<h4 id="api-키가-설정되지-않았습니다-에러">&quot;API 키가 설정되지 않았습니다&quot; 에러:</h4>
<pre><code class="language-bash"># .env 파일 생성 또는 수정
echo &quot;WEATHER_API_KEY=4336a7df2186cc57454035002475e06a&quot; &gt; .env</code></pre>
<h4 id="포트-8080이-이미-사용-중-에러">&quot;포트 8080이 이미 사용 중&quot; 에러:</h4>
<ol>
<li>다른 프로세스 종료</li>
<li>또는 application.properties에서 포트 변경:<pre><code class="language-properties">server.port=8081</code></pre>
</li>
</ol>
<p>이제 시스템을 완전히 활용할 수 있습니다. 다음 문서에서는 이 시스템을 어떻게 공부하고 확장할 수 있는지 알아보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 배치 로 기상정보 가져오기 (맛보기 시리즈 2-1)]]></title>
            <link>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-1</link>
            <guid>https://velog.io/@mj_o/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B0%B0%EC%B9%98-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%88-2-1</guid>
            <pubDate>Fri, 29 Aug 2025 06:12:02 GMT</pubDate>
            <description><![CDATA[<h1 id="01-현재-프로젝트-구조-분석">01. 현재 프로젝트 구조 분석</h1>
<h2 id="📋-프로젝트-개요">📋 프로젝트 개요</h2>
<p>이 Spring Batch 프로젝트는 <strong>CSV 파일 처리</strong>와 <strong>실시간 날씨 데이터 수집</strong> 두 가지 배치 시스템을 포함합니다.</p>
<pre><code>springBatch/
├── src/main/java/com/springbatch/
│   ├── SpringBatchApplication.java       # 메인 애플리케이션
│   ├── config/                          # 설정 클래스들
│   │   ├── BatchConfig.java             # CSV 배치 설정
│   │   └── WeatherBatchConfig.java      # 날씨 배치 설정
│   ├── controller/                      # 웹 컨트롤러
│   │   ├── BatchController.java         # 배치 실행 컨트롤러
│   │   └── WeatherController.java       # 날씨 API 컨트롤러
│   ├── entity/                          # JPA 엔티티
│   │   ├── Person.java                  # 사용자 엔티티
│   │   └── WeatherData.java             # 날씨 데이터 엔티티
│   ├── dto/                             # 데이터 전송 객체
│   │   └── WeatherApiResponse.java      # 날씨 API 응답 DTO
│   ├── repository/                      # 데이터 접근 계층
│   │   ├── PersonRepository.java        # 사용자 데이터 리포지토리
│   │   └── WeatherDataRepository.java   # 날씨 데이터 리포지토리
│   └── service/                         # 비즈니스 로직
│       └── WeatherApiService.java       # 날씨 API 서비스
├── src/main/resources/
│   ├── templates/                       # Thymeleaf 템플릿
│   │   ├── index.html                   # 메인 페이지
│   │   └── weather-dashboard.html       # 날씨 대시보드
│   ├── application.properties           # 애플리케이션 설정
│   └── sample-data.csv                 # 샘플 CSV 데이터
├── .env                                # 환경 변수 (API 키)
└── build.gradle                        # Gradle 빌드 설정</code></pre><h2 id="🏗️-아키텍처-구성요소">🏗️ 아키텍처 구성요소</h2>
<h3 id="1-csv-배치-시스템-batchconfigjava">1. CSV 배치 시스템 (BatchConfig.java)</h3>
<pre><code class="language-java">@Configuration
public class BatchConfig {

    // Job: 전체 배치 작업 정의
    @Bean
    public Job importUserJob(Step step1) {
        return new JobBuilder(&quot;importUserJob&quot;, jobRepository)
                .start(step1)
                .build();
    }

    // Step: 실제 처리 단계
    @Bean
    public Step step1(ItemReader&lt;Person&gt; reader,
                      ItemProcessor&lt;Person, Person&gt; processor,
                      ItemWriter&lt;Person&gt; writer) {
        return new StepBuilder(&quot;step1&quot;, jobRepository)
                .&lt;Person, Person&gt;chunk(3, transactionManager)
                .reader(reader)      // CSV 파일 읽기
                .processor(processor) // 데이터 변환/검증
                .writer(writer)      // 데이터베이스 저장
                .build();
    }
}</code></pre>
<p><strong>처리 흐름:</strong></p>
<ol>
<li><strong>ItemReader</strong>: <code>sample-data.csv</code> 파일에서 사용자 데이터 읽기</li>
<li><strong>ItemProcessor</strong>: 데이터 검증 및 변환 (이메일 형식 체크 등)</li>
<li><strong>ItemWriter</strong>: H2 데이터베이스에 Person 엔티티로 저장</li>
</ol>
<h3 id="2-날씨-배치-시스템-weatherbatchconfigjava">2. 날씨 배치 시스템 (WeatherBatchConfig.java)</h3>
<pre><code class="language-java">@Configuration
public class WeatherBatchConfig {

    // 날씨 데이터 수집 Job
    @Bean
    public Job collectWeatherDataJob(Step weatherCollectionStep) {
        return new JobBuilder(&quot;collectWeatherDataJob&quot;, jobRepository)
                .start(weatherCollectionStep)
                .build();
    }

    // 날씨 데이터 처리 Step
    @Bean
    public Step weatherCollectionStep(ItemReader&lt;String&gt; cityReader,
                                     ItemProcessor&lt;String, WeatherData&gt; weatherProcessor,
                                     ItemWriter&lt;WeatherData&gt; weatherWriter) {
        return new StepBuilder(&quot;weatherCollectionStep&quot;, jobRepository)
                .&lt;String, WeatherData&gt;chunk(3, transactionManager)
                .reader(cityReader)      // 도시 목록 생성
                .processor(weatherProcessor) // API 호출 및 데이터 변환
                .writer(weatherWriter)   // 데이터베이스 저장
                .build();
    }
}</code></pre>
<p><strong>처리 흐름:</strong></p>
<ol>
<li><strong>ItemReader</strong>: 전국 주요 8개 도시 목록 생성 (Seoul, Busan, Incheon 등)</li>
<li><strong>ItemProcessor</strong>: 각 도시별 OpenWeatherMap API 호출 → WeatherData 엔티티 변환</li>
<li><strong>ItemWriter</strong>: H2 데이터베이스에 날씨 데이터 저장</li>
<li><strong>이상 기후 탐지</strong>: 전날 대비 온도 변화량 계산 (20도 이상 차이 시 이상 기후로 판정)</li>
</ol>
<h2 id="🛠️-기술-스택">🛠️ 기술 스택</h2>
<h3 id="core-technologies">Core Technologies</h3>
<ul>
<li><strong>Spring Boot 3.5.5</strong>: 애플리케이션 프레임워크</li>
<li><strong>Spring Batch 5.x</strong>: 배치 처리 프레임워크</li>
<li><strong>Java 17</strong>: 프로그래밍 언어</li>
<li><strong>Gradle</strong>: 빌드 도구</li>
</ul>
<h3 id="data--persistence">Data &amp; Persistence</h3>
<ul>
<li><strong>H2 Database</strong>: 인메모리 데이터베이스</li>
<li><strong>Spring Data JPA</strong>: 데이터 접근 계층</li>
<li><strong>Hibernate</strong>: ORM 프레임워크</li>
</ul>
<h3 id="web--api">Web &amp; API</h3>
<ul>
<li><strong>Spring WebFlux</strong>: 비동기 REST 클라이언트 (WebClient)</li>
<li><strong>Thymeleaf</strong>: 서버사이드 템플릿 엔진</li>
<li><strong>Spring Web MVC</strong>: 웹 컨트롤러</li>
</ul>
<h3 id="external-apis">External APIs</h3>
<ul>
<li><strong>OpenWeatherMap API</strong>: 실시간 날씨 데이터</li>
<li><strong>API Key</strong>: 환경변수로 관리 (.env 파일)</li>
</ul>
<h2 id="📊-데이터-모델">📊 데이터 모델</h2>
<h3 id="person-entity-사용자-데이터">Person Entity (사용자 데이터)</h3>
<pre><code class="language-java">@Entity
@Table(name = &quot;person&quot;)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Person {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = &quot;first_name&quot;)
    private String firstName;

    @Column(name = &quot;last_name&quot;) 
    private String lastName;

    private String email;
}</code></pre>
<h3 id="weatherdata-entity-날씨-데이터">WeatherData Entity (날씨 데이터)</h3>
<pre><code class="language-java">@Entity
@Table(name = &quot;weather_data&quot;)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WeatherData {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String cityCode;           // 도시 코드

    @Column(nullable = false)
    private String cityName;           // 도시명 (한국어)

    @Column(nullable = false)
    private Double temperature;        // 현재 온도

    private Double feelsLike;          // 체감 온도
    private Double tempMin;            // 최저 온도
    private Double tempMax;            // 최고 온도
    private Integer humidity;          // 습도
    private Integer pressure;          // 기압
    private Double windSpeed;          // 풍속
    private Integer windDirection;     // 풍향
    private Integer cloudiness;        // 구름량
    private Double rainfall;           // 강수량 (1시간)
    private Double snowfall;           // 적설량 (1시간)
    private Integer visibility;        // 가시거리

    private String weatherMain;        // 날씨 상태 (Rain, Clear 등)
    private String weatherDescription; // 날씨 설명

    @Column(nullable = false)
    private LocalDateTime collectedAt; // 데이터 수집 시간

    private LocalDateTime weatherTime; // 날씨 관측 시간

    // 이상 기후 탐지 필드
    private Boolean isAbnormal = false;     // 이상 기후 여부
    private Double temperatureChange;       // 전날 대비 온도 변화량
}</code></pre>
<h2 id="🔄-배치-실행-흐름">🔄 배치 실행 흐름</h2>
<h3 id="1-csv-배치-실행">1. CSV 배치 실행</h3>
<pre><code>사용자 요청 → BatchController.runJob() 
→ JobLauncher.run(importUserJob) 
→ Step1 실행 
→ CSV 파일 읽기 → 데이터 처리 → DB 저장</code></pre><h3 id="2-날씨-배치-실행">2. 날씨 배치 실행</h3>
<pre><code>사용자 요청 → WeatherController.collectWeatherData()
→ JobLauncher.run(collectWeatherDataJob)
→ WeatherCollectionStep 실행
→ 도시 목록 생성 → API 호출 → 데이터 변환 → 이상기후 탐지 → DB 저장</code></pre><h2 id="🎯-핵심-특징">🎯 핵심 특징</h2>
<h3 id="1-청크-기반-처리">1. 청크 기반 처리</h3>
<ul>
<li>대량 데이터를 3개씩 묶어서 처리 (메모리 효율성)</li>
<li>트랜잭션 단위로 커밋</li>
</ul>
<h3 id="2-이상-기후-탐지-알고리즘">2. 이상 기후 탐지 알고리즘</h3>
<pre><code class="language-java">private void detectAbnormalWeather(WeatherData currentData) {
    // 전날 동일 시간대 데이터 조회
    var yesterdayDataList = weatherDataRepository
        .findByCityCodeAndCollectedAtBetweenOrderByCollectedAtDesc(
            currentData.getCityCode(), yesterdayStart, yesterdayEnd);

    if (!yesterdayDataList.isEmpty()) {
        WeatherData yesterdayData = yesterdayDataList.get(0);
        double temperatureChange = currentData.getTemperature() - yesterdayData.getTemperature();

        // 전날 대비 20도 이상 변화 시 이상 기후로 판단
        if (Math.abs(temperatureChange) &gt;= 20.0) {
            currentData.setIsAbnormal(true);
        }
    }
}</code></pre>
<h3 id="3-외부-api-연동">3. 외부 API 연동</h3>
<ul>
<li>WebClient를 사용한 비동기 HTTP 통신</li>
<li>환경변수 기반 API 키 관리</li>
<li>에러 핸들링 및 로깅</li>
</ul>
<h3 id="4-웹-대시보드">4. 웹 대시보드</h3>
<ul>
<li>Thymeleaf 템플릿 기반 실시간 데이터 시각화</li>
<li>반응형 디자인</li>
<li>AJAX 기반 배치 실행</li>
</ul>
<h2 id="🔐-보안-및-설정">🔐 보안 및 설정</h2>
<h3 id="환경-변수-관리-env">환경 변수 관리 (.env)</h3>
<pre><code class="language-properties">WEATHER_API_KEY=4336a7df2186cc57454035002475e06a</code></pre>
<h3 id="애플리케이션-설정-applicationproperties">애플리케이션 설정 (application.properties)</h3>
<pre><code class="language-properties"># H2 Database
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true

# JPA
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# Batch
spring.batch.job.enabled=false</code></pre>
<h2 id="📈-성능-및-모니터링">📈 성능 및 모니터링</h2>
<h3 id="로깅-시스템">로깅 시스템</h3>
<ul>
<li>Slf4j + Logback 기반 로깅</li>
<li>배치 실행 상태 및 에러 추적</li>
<li>API 호출 결과 모니터링</li>
</ul>
<h3 id="메트릭스">메트릭스</h3>
<ul>
<li>배치 실행 시간</li>
<li>API 응답 시간</li>
<li>데이터 처리량</li>
<li>이상 기후 감지 건수</li>
</ul>
<p>이 구조를 기반으로 다양한 배치 시스템을 확장할 수 있으며, 각 컴포넌트는 독립적으로 테스트 및 운영 가능합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[맛보기 시리즈1 - MSA ]]></title>
            <link>https://velog.io/@mj_o/%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%881-MSA</link>
            <guid>https://velog.io/@mj_o/%EB%A7%9B%EB%B3%B4%EA%B8%B0-%EC%8B%9C%EB%A6%AC%EC%A6%881-MSA</guid>
            <pubDate>Fri, 29 Aug 2025 06:06:31 GMT</pubDate>
            <description><![CDATA[<h1 id="완전한-msa-전자상거래-시스템-구현기---jwt-인증부터-서비스-간-통신까지">완전한 MSA 전자상거래 시스템 구현기 - JWT 인증부터 서비스 간 통신까지</h1>
<p><img src="https://velog.velcdn.com/images/mj_o/post/2013e770-9d4d-4c24-b11f-000f606ad6ce/image.png" alt="">
<img src="https://velog.velcdn.com/images/mj_o/post/89a39f1f-2875-44b5-8832-c84ce6c5e622/image.png" alt=""></p>
<blockquote>
<p>&quot;이론으로만 알던 MSA를 실제로 구현해보니 생각보다 복잡했지만, 그만큼 배운 것도 많았습니다.&quot;</p>
</blockquote>
<p>안녕하세요! 오늘은 <strong>Spring Boot</strong>와 <strong>React</strong>를 사용해서 완전한 마이크로서비스 아키텍처(MSA) 시스템을 구현한 경험을 공유해보려 합니다. 단순한 Hello World가 아닌, <strong>실제 상용 서비스 수준의 전자상거래 시스템</strong>을 만들어봤어요.</p>
<h2 id="🎯-완성된-시스템-미리보기">🎯 완성된 시스템 미리보기</h2>
<p>회원가입 → 상품등록 → 장바구니 → 주문 → JWT 인증까지, <strong>완전한 비즈니스 플로우</strong>를 가진 시스템입니다.</p>
<p><img src="https://github.com/user-attachments/assets/1013268b-3bc8-4265-b557-8410fdce06a0" alt="시스템 스크린샷 1">
<img src="https://github.com/user-attachments/assets/50ac2017-9b76-46e5-90cd-74345a16edcc" alt="시스템 스크린샷 2"></p>
<hr>
<h2 id="🤔-왜-msa를-선택했을까">🤔 왜 MSA를 선택했을까?</h2>
<h3 id="모놀리식-아키텍처의-한계">모놀리식 아키텍처의 한계</h3>
<p>전통적인 모놀리식 구조에서는 <strong>한 부분의 변경이 전체 시스템에 영향</strong>을 미치는 문제가 있었습니다.</p>
<pre><code>모놀리식 구조의 문제점:
❌ 부분 배포 불가 → 전체 시스템 재배포 필요
❌ 기술 스택 고정 → 서비스별 최적 기술 선택 불가
❌ 확장성 제한 → 전체 시스템 스케일업만 가능
❌ 장애 전파 → 한 모듈 장애가 전체 시스템 다운</code></pre><h3 id="msa가-제공하는-장점">MSA가 제공하는 장점</h3>
<p><strong>🔥 핵심 장점들:</strong></p>
<ol>
<li><p><strong>독립적인 배포와 확장</strong></p>
<ul>
<li>각 서비스를 개별적으로 배포/확장 가능</li>
<li>트래픽이 많은 서비스만 스케일 아웃</li>
</ul>
</li>
<li><p><strong>기술적 다양성</strong></p>
<ul>
<li>서비스별로 최적의 기술 스택 선택</li>
<li>User Service는 Spring Boot, Notification Service는 Node.js 등</li>
</ul>
</li>
<li><p><strong>장애 격리</strong></p>
<ul>
<li>한 서비스의 장애가 다른 서비스에 영향 없음</li>
<li>Circuit Breaker 패턴으로 장애 전파 방지</li>
</ul>
</li>
<li><p><strong>팀 독립성</strong></p>
<ul>
<li>서비스별로 팀이 독립적으로 개발</li>
<li>API 계약만 지키면 내부 구현은 자유</li>
</ul>
</li>
</ol>
<hr>
<h2 id="🏗️-시스템-아키텍처-설계">🏗️ 시스템 아키텍처 설계</h2>
<h3 id="전체-시스템-구조">전체 시스템 구조</h3>
<pre><code class="language-mermaid">graph TD
    A[React Frontend :3000] --&gt; B[API Gateway :8080]
    B --&gt; C[Eureka Server :8761]

    B --&gt; D[User Service :8081]
    B --&gt; E[Product Service :8082]  
    B --&gt; F[Order Service :8083]

    F --&gt; E[Product Service via OpenFeign]

    D --&gt; G[H2 DB - users]
    E --&gt; H[H2 DB - products]
    F --&gt; I[H2 DB - orders]</code></pre>
<h3 id="핵심-구성-요소">핵심 구성 요소</h3>
<table>
<thead>
<tr>
<th>컴포넌트</th>
<th>포트</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Frontend</strong></td>
<td>3000</td>
<td>React 사용자 인터페이스</td>
</tr>
<tr>
<td><strong>API Gateway</strong></td>
<td>8080</td>
<td>단일 진입점, 라우팅, CORS</td>
</tr>
<tr>
<td><strong>Eureka Server</strong></td>
<td>8761</td>
<td>서비스 발견 및 등록</td>
</tr>
<tr>
<td><strong>User Service</strong></td>
<td>8081</td>
<td>사용자 관리 + JWT 인증</td>
</tr>
<tr>
<td><strong>Product Service</strong></td>
<td>8082</td>
<td>상품 관리, 재고 관리</td>
</tr>
<tr>
<td><strong>Order Service</strong></td>
<td>8083</td>
<td>주문 처리, 서비스 간 통신</td>
</tr>
</tbody></table>
<h3 id="선택한-기술-스택">선택한 기술 스택</h3>
<p><strong>백엔드:</strong></p>
<ul>
<li><strong>Java 17</strong> (LTS 안정성)</li>
<li><strong>Gradle 8.10</strong> (멀티모듈 빌드)</li>
<li><strong>Spring Boot 3.2</strong> (최신 안정 버전)</li>
<li><strong>Spring Cloud Gateway</strong> (비동기 API 게이트웨이)</li>
<li><strong>Netflix Eureka</strong> (서비스 발견)</li>
<li><strong>OpenFeign</strong> (선언적 HTTP 클라이언트)</li>
</ul>
<p><strong>프론트엔드:</strong></p>
<ul>
<li><strong>React 18</strong> (최신 Hook 기반)</li>
<li><strong>Axios</strong> (HTTP 클라이언트)</li>
</ul>
<hr>
<h2 id="🛠️-단계별-구현-과정">🛠️ 단계별 구현 과정</h2>
<h3 id="1단계-프로젝트-기반-구조-설정">1단계: 프로젝트 기반 구조 설정</h3>
<p>가장 중요한 것은 <strong>올바른 멀티모듈 Gradle 구조</strong>를 만드는 것이었습니다.</p>
<h4 id="settingsgradle"><code>settings.gradle</code></h4>
<pre><code class="language-gradle">rootProject.name = &#39;msa-system&#39;

include &#39;eureka-server&#39;
include &#39;api-gateway&#39;
include &#39;user-service&#39;
include &#39;product-service&#39;
include &#39;order-service&#39;</code></pre>
<h4 id="루트-buildgradle">루트 <code>build.gradle</code></h4>
<pre><code class="language-gradle">plugins {
    id &#39;org.springframework.boot&#39; version &#39;3.2.0&#39; apply false
    id &#39;io.spring.dependency-management&#39; version &#39;1.1.4&#39; apply false
}

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

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

    // 🔥 핵심: @PathVariable 파라미터 인식을 위한 설정
    tasks.withType(JavaCompile) {
        options.compilerArgs += [&#39;-parameters&#39;]
    }
}</code></pre>
<p><strong>💡 중요한 포인트:</strong> <code>-parameters</code> 옵션이 없으면 DELETE API에서 500 에러가 발생합니다!</p>
<h3 id="2단계-서비스-발견-시스템-eureka-server">2단계: 서비스 발견 시스템 (Eureka Server)</h3>
<p>MSA의 핵심인 <strong>서비스 발견</strong> 시스템부터 구축했습니다.</p>
<h4 id="eurekaserverapplicationjava"><code>EurekaServerApplication.java</code></h4>
<pre><code class="language-java">@SpringBootApplication
@EnableEurekaServer  // 이 한 줄로 Eureka 서버 완성!
public class EurekaServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}</code></pre>
<h4 id="applicationyml"><code>application.yml</code></h4>
<pre><code class="language-yaml">server:
  port: 8761

eureka:
  client:
    register-with-eureka: false  # 자기 자신은 등록하지 않음
    fetch-registry: false
  server:
    enable-self-preservation: false  # 개발환경용 설정</code></pre>
<p><strong>결과:</strong> <a href="http://localhost:8761">http://localhost:8761</a> 에서 아름다운 Eureka 대시보드 완성!</p>
<h3 id="3단계-핵심-비즈니스-서비스들">3단계: 핵심 비즈니스 서비스들</h3>
<h4 id="user-service---jwt-인증-시스템">User Service - JWT 인증 시스템</h4>
<p>가장 까다로웠던 부분입니다. 단순 CRUD가 아닌 <strong>완전한 JWT 인증 시스템</strong>을 구현했어요.</p>
<pre><code class="language-java">@Component
public class JwtTokenProvider {

    private final String secretKey = &quot;mySecretKeyForJwtTokenGenerationAndValidation1234567890&quot;;
    private final long validityInMilliseconds = 86400000; // 24시간

    public String createToken(User user) {
        Claims claims = Jwts.claims().setSubject(user.getUsername());
        claims.put(&quot;userId&quot;, user.getId());
        claims.put(&quot;username&quot;, user.getUsername());
        claims.put(&quot;role&quot;, user.getRole().toString());

        Date now = new Date();
        Date validity = new Date(now.getTime() + validityInMilliseconds);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }
}</code></pre>
<h4 id="product-service---재고-관리-시스템">Product Service - 재고 관리 시스템</h4>
<p>단순한 CRUD를 넘어서 <strong>실시간 재고 관리</strong>까지 구현했습니다.</p>
<pre><code class="language-java">@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private Double price;
    private Integer stock;  // 🔥 재고 관리의 핵심
    private String category;
}</code></pre>
<h4 id="order-service---서비스-간-통신">Order Service - 서비스 간 통신</h4>
<p>가장 복잡한 서비스입니다. <strong>OpenFeign을 사용한 서비스 간 통신</strong>과 <strong>트랜잭션 처리</strong>를 구현했어요.</p>
<pre><code class="language-java">@FeignClient(name = &quot;product-service&quot;)
public interface ProductClient {
    @GetMapping(&quot;/products/{id}&quot;)
    ProductResponse getProduct(@PathVariable Long id);

    @PutMapping(&quot;/products/{id}/stock&quot;)
    void updateStock(@PathVariable Long id, @RequestBody Integer stock);
}</code></pre>
<p><strong>주문 처리 로직:</strong></p>
<pre><code class="language-java">@Service
@Transactional
public class OrderService {

    public Order createOrder(OrderRequest orderRequest) {
        // 1. 상품 정보 조회 (다른 마이크로서비스 호출!)
        ProductResponse product = productClient.getProduct(productId);

        // 2. 재고 확인
        if (product.getStock() &lt; quantity) {
            throw new RuntimeException(&quot;재고 부족!&quot;);
        }

        // 3. 재고 차감 (다른 마이크로서비스 업데이트!)
        productClient.updateStock(productId, product.getStock() - quantity);

        // 4. 주문 생성
        return orderRepository.save(order);
    }
}</code></pre>
<h3 id="4단계-api-gateway---단일-진입점">4단계: API Gateway - 단일 진입점</h3>
<p>모든 요청을 받아서 적절한 서비스로 라우팅하는 <strong>관문 역할</strong>입니다.</p>
<h4 id="applicationyml-1"><code>application.yml</code></h4>
<pre><code class="language-yaml">spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service  # 로드밸런서 사용!
          predicates:
            - Path=/users/**

        - id: product-service
          uri: lb://product-service
          predicates:
            - Path=/products/**

        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/orders/**

      # 🔥 CORS 문제 해결
      globalcors:
        cors-configurations:
          &#39;[/**]&#39;:
            allowed-origins: &quot;*&quot;
            allowed-methods: &quot;*&quot;
            allowed-headers: &quot;*&quot;</code></pre>
<h4 id="jwt-인증-필터">JWT 인증 필터</h4>
<p>API Gateway에서 <strong>모든 요청을 가로채서 JWT 검증</strong>을 수행합니다.</p>
<pre><code class="language-java">@Component
public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory&lt;JwtAuthenticationFilter.Config&gt; {

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -&gt; {
            ServerHttpRequest request = exchange.getRequest();

            // 인증이 필요 없는 경로들
            if (isPublicPath(request.getURI().getPath())) {
                return chain.filter(exchange);
            }

            // JWT 토큰 검증
            String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
            if (authHeader == null || !authHeader.startsWith(&quot;Bearer &quot;)) {
                return onError(exchange, &quot;Invalid Authorization header&quot;);
            }

            String token = authHeader.substring(7);
            if (!jwtUtil.validateToken(token)) {
                return onError(exchange, &quot;Invalid JWT token&quot;);
            }

            // 검증 성공시 사용자 정보를 헤더에 추가
            Long userId = jwtUtil.getUserIdFromToken(token);
            ServerHttpRequest modifiedRequest = exchange.getRequest()
                    .mutate()
                    .header(&quot;X-User-Id&quot;, userId.toString())
                    .build();

            return chain.filter(exchange.mutate().request(modifiedRequest).build());
        };
    }
}</code></pre>
<h3 id="5단계-react-프론트엔드---사용자-경험">5단계: React 프론트엔드 - 사용자 경험</h3>
<p><strong>탭 기반의 직관적인 UI</strong>로 모든 기능을 쉽게 사용할 수 있게 했습니다.</p>
<h4 id="핵심-상태-관리">핵심 상태 관리</h4>
<pre><code class="language-javascript">function App() {
  const [activeTab, setActiveTab] = useState(&#39;users&#39;);
  const [users, setUsers] = useState([]);
  const [products, setProducts] = useState([]);
  const [cart, setCart] = useState([]);
  const [orders, setOrders] = useState([]);
}</code></pre>
<h4 id="실시간-장바구니-시스템">실시간 장바구니 시스템</h4>
<pre><code class="language-javascript">const addToCart = (product) =&gt; {
  const existingItem = cart.find(item =&gt; item.id === product.id);
  if (existingItem) {
    setCart(cart.map(item =&gt; 
      item.id === product.id 
        ? { ...item, quantity: item.quantity + 1 }
        : item
    ));
  } else {
    setCart([...cart, { ...product, quantity: 1 }]);
  }
  setMessage(`${product.name}이(가) 장바구니에 추가되었습니다!`);
};</code></pre>
<h4 id="주문-생성-및-재고-연동">주문 생성 및 재고 연동</h4>
<pre><code class="language-javascript">const createOrder = async () =&gt; {
  const orderRequest = {
    userId: users[0].id,
    items: cart.map(item =&gt; ({
      productId: item.id,
      quantity: item.quantity
    }))
  };

  await axios.post(&#39;/orders&#39;, orderRequest);
  setCart([]);
  setMessage(&#39;주문이 성공적으로 생성되었습니다!&#39;);
  fetchOrders(); // 주문 목록 새로고침
};</code></pre>
<hr>
<h2 id="🔥-핵심-기술적-해결책들">🔥 핵심 기술적 해결책들</h2>
<h3 id="1-json-순환-참조-문제">1. JSON 순환 참조 문제</h3>
<p><strong>문제:</strong> Order ↔ OrderItem 양방향 참조로 인한 StackOverflowError</p>
<pre><code class="language-java">// 해결책: Jackson 어노테이션 사용
@Entity
public class Order {
    @OneToMany(mappedBy = &quot;order&quot;)
    @JsonManagedReference  // 부모에서 자식 직렬화
    private List&lt;OrderItem&gt; orderItems;
}

@Entity 
public class OrderItem {
    @ManyToOne
    @JsonBackReference  // 자식에서 부모 직렬화 제외
    private Order order;
}</code></pre>
<h3 id="2-pathvariable-파라미터-인식-실패">2. @PathVariable 파라미터 인식 실패</h3>
<p><strong>문제:</strong> DELETE API에서 500 에러 발생</p>
<pre><code class="language-gradle">// 해결책: 컴파일 시 파라미터 정보 포함
tasks.withType(JavaCompile) {
    options.compilerArgs += [&#39;-parameters&#39;]
}</code></pre>
<h3 id="3-서비스-간-통신-최적화">3. 서비스 간 통신 최적화</h3>
<p><strong>OpenFeign 사용으로 선언적 HTTP 클라이언트 구현:</strong></p>
<pre><code class="language-java">@FeignClient(name = &quot;product-service&quot;)
public interface ProductClient {
    @GetMapping(&quot;/products/{id}&quot;)
    ProductResponse getProduct(@PathVariable Long id);
}

// 사용법이 정말 간단!
ProductResponse product = productClient.getProduct(1L);</code></pre>
<h3 id="4-cors-문제-완전-해결">4. CORS 문제 완전 해결</h3>
<p><strong>API Gateway에서 중앙화된 CORS 설정:</strong></p>
<pre><code class="language-yaml">spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          &#39;[/**]&#39;:
            allowed-origins: &quot;*&quot;
            allowed-methods: &quot;*&quot; 
            allowed-headers: &quot;*&quot;</code></pre>
<hr>
<h2 id="🚀-완성된-기능들">🚀 완성된 기능들</h2>
<h3 id="✅-완전한-비즈니스-플로우">✅ 완전한 비즈니스 플로우</h3>
<ol>
<li><strong>👤 사용자 관리</strong>: 회원가입, 로그인, JWT 토큰 발급</li>
<li><strong>📦 상품 관리</strong>: CRUD, 재고 관리, 카테고리 검색  </li>
<li><strong>🛒 장바구니</strong>: 실시간 추가/삭제, 수량 조절</li>
<li><strong>📋 주문 처리</strong>: 자동 재고 차감, 트랜잭션 처리</li>
<li><strong>🔐 보안</strong>: JWT 기반 인증, API Gateway 필터</li>
</ol>
<h3 id="✅-msa-핵심-패턴-구현">✅ MSA 핵심 패턴 구현</h3>
<ul>
<li><strong>서비스 발견 패턴</strong> (Eureka)</li>
<li><strong>API 게이트웨이 패턴</strong> (Spring Cloud Gateway)  </li>
<li><strong>서비스 간 통신 패턴</strong> (OpenFeign)</li>
<li><strong>데이터베이스 per 서비스</strong> (각 서비스별 독립 DB)</li>
</ul>
<h3 id="✅-사용자-경험">✅ 사용자 경험</h3>
<ul>
<li><strong>직관적인 탭 기반 UI</strong></li>
<li><strong>실시간 피드백</strong> (성공/실패 메시지)</li>
<li><strong>반응형 디자인</strong> (모바일 지원)</li>
</ul>
<hr>
<h2 id="🎯-실제-테스트-시나리오">🎯 실제 테스트 시나리오</h2>
<h3 id="전체-플로우-테스트">전체 플로우 테스트</h3>
<pre><code>1. 사용자 등록 → &quot;홍길동&quot; 회원가입
2. 상품 등록 → &quot;iPhone 15&quot; (가격: 1,200,000원, 재고: 10개)
3. 장바구니 추가 → iPhone 15 * 2개 담기
4. 주문 생성 → 총 2,400,000원 주문
5. 재고 확인 → iPhone 15 재고가 8개로 자동 차감!
6. 주문 조회 → 생성된 주문 정보 확인</code></pre><h3 id="api-테스트-postman">API 테스트 (Postman)</h3>
<pre><code class="language-bash"># 사용자 생성
POST http://localhost:8080/users
{
  &quot;name&quot;: &quot;홍길동&quot;,
  &quot;email&quot;: &quot;hong@test.com&quot;,
  &quot;phone&quot;: &quot;010-1234-5678&quot;
}

# 상품 생성  
POST http://localhost:8080/products
{
  &quot;name&quot;: &quot;iPhone 15&quot;,
  &quot;description&quot;: &quot;최신 아이폰&quot;,
  &quot;price&quot;: 1200000,
  &quot;stock&quot;: 10,
  &quot;category&quot;: &quot;전자제품&quot;
}

# 주문 생성 (서비스 간 통신 테스트)
POST http://localhost:8080/orders
{
  &quot;userId&quot;: 1,
  &quot;items&quot;: [
    {
      &quot;productId&quot;: 1,
      &quot;quantity&quot;: 2
    }
  ]
}</code></pre>
<hr>
<h2 id="📈-성능-및-확장성">📈 성능 및 확장성</h2>
<h3 id="로드밸런싱-테스트">로드밸런싱 테스트</h3>
<pre><code class="language-bash"># 서비스 인스턴스 복제 테스트
docker-compose up -d --scale user-service=3 --scale product-service=2</code></pre>
<h3 id="장애-격리-테스트">장애 격리 테스트</h3>
<ul>
<li>Product Service 중단 → Order Service에서 Circuit Breaker 작동</li>
<li>User Service 중단 → 인증이 필요 없는 상품 조회는 정상 작동</li>
</ul>
<hr>
<h2 id="🔮-향후-확장-계획">🔮 향후 확장 계획</h2>
<p>현재 시스템은 <strong>완전한 MSA의 기초</strong>를 다졌습니다. 다음 단계로는:</p>
<h3 id="고급-msa-패턴-적용">고급 MSA 패턴 적용</h3>
<ul>
<li><strong>Circuit Breaker</strong> (Resilience4j)</li>
<li><strong>분산 추적</strong> (Sleuth + Zipkin)  </li>
<li><strong>중앙화 로깅</strong> (ELK Stack)</li>
<li><strong>메시지 큐</strong> (RabbitMQ, Kafka)</li>
</ul>
<h3 id="클라우드-네이티브-전환">클라우드 네이티브 전환</h3>
<ul>
<li><strong>Kubernetes</strong> 배포</li>
<li><strong>Istio</strong> 서비스 메시 적용</li>
<li><strong>Prometheus + Grafana</strong> 모니터링</li>
</ul>
<h3 id="devops-파이프라인">DevOps 파이프라인</h3>
<ul>
<li><strong>Jenkins</strong> CI/CD 구축</li>
<li><strong>Docker</strong> 컨테이너화 완료</li>
<li><strong>Helm Chart</strong> 배포 자동화</li>
</ul>
<hr>
<h2 id="💡-학습-포인트와-조언">💡 학습 포인트와 조언</h2>
<h3 id="1-단계적-접근의-중요성">1. 단계적 접근의 중요성</h3>
<p>처음부터 모든 기능을 만들려 하지 마세요. 저는 이런 순서로 진행했습니다:</p>
<ol>
<li>단일 서비스로 시작 → 2. Eureka 추가 → 3. 서비스 분리 → 4. Gateway 추가</li>
</ol>
<h3 id="2-로그를-꼼꼼히-확인하자">2. 로그를 꼼꼼히 확인하자</h3>
<p>MSA에서는 <strong>여러 서비스 간의 상호작용</strong>이 복잡합니다. 문제 발생 시 각 서비스의 로그를 모두 확인해야 해요.</p>
<h3 id="3-독립-테스트-먼저">3. 독립 테스트 먼저</h3>
<p>각 서비스를 <strong>독립적으로 먼저 테스트</strong>한 후에 통합 테스트를 진행하세요.</p>
<h3 id="4-문서화는-필수">4. 문서화는 필수</h3>
<p>해결한 문제들을 <strong>반드시 문서로 기록</strong>하세요. 같은 문제를 다시 겪지 않기 위해서죠.</p>
<hr>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li><a href="https://spring.io/projects/spring-cloud">Spring Cloud 공식 문서</a></li>
<li><a href="https://github.com/Netflix/eureka">Netflix Eureka 가이드</a></li>
<li><a href="https://cloud.spring.io/spring-cloud-openfeign/reference/html/">OpenFeign 사용법</a></li>
</ul>
<hr>
<h2 id="🎉-마무리">🎉 마무리</h2>
<p>이번 MSA 구현 프로젝트를 통해 정말 많은 것을 배웠습니다. <strong>단순한 Hello World 수준을 넘어서 실제 서비스 수준의 기능</strong>을 구현할 수 있었고, MSA의 장점과 복잡성을 모두 경험해볼 수 있었어요.</p>
<p><strong>가장 인상적이었던 부분:</strong></p>
<ul>
<li>서비스 간 통신이 이렇게 간단할 수 있다니! (OpenFeign 덕분)</li>
<li>JWT 인증을 API Gateway에서 중앙화 처리하는 것의 편리함</li>
<li>React와 Spring Boot의 완벽한 조합</li>
</ul>
<p><strong>앞으로 MSA를 시작하려는 분들께:</strong></p>
<ul>
<li>작게 시작해서 점진적으로 확장하세요</li>
<li>각 단계별로 충분히 테스트하고 넘어가세요  </li>
<li>문제를 겪는 것은 자연스러운 과정입니다. 포기하지 마세요!</li>
</ul>
<p>완전한 소스 코드는 <a href="https://github.com/moonjun1/MSA">GitHub</a>에서 확인하실 수 있습니다. 질문이나 피드백은 언제든 환영입니다! 🚀</p>
<hr>
<p><em>이 글이 도움이 되셨다면 좋아요와 댓글 부탁드려요! 여러분의 MSA 구현 경험도 궁금합니다. 🙌</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스레드풀 아키텍처]]></title>
            <link>https://velog.io/@mj_o/%EC%8A%A4%EB%A0%88%EB%93%9C%ED%92%80-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
            <guid>https://velog.io/@mj_o/%EC%8A%A4%EB%A0%88%EB%93%9C%ED%92%80-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</guid>
            <pubDate>Sat, 23 Aug 2025 05:54:15 GMT</pubDate>
            <description><![CDATA[<h1 id="스레드풀-아키텍처">스레드풀 아키텍처</h1>
<h2 id="🔰-스레드풀이-뭔가요">🔰 스레드풀이 뭔가요?</h2>
<h3 id="스레드thread란">스레드(Thread)란?</h3>
<p><strong>스레드</strong>는 프로그램이 동시에 여러 일을 처리할 수 있게 해주는 작업 단위입니다.</p>
<ul>
<li>음식점에서 <strong>요리사 1명 = 스레드 1개</strong>라고 생각하면 됩니다</li>
<li>요리사가 1명이면 주문을 하나씩만 처리 가능 (동기)</li>
<li>요리사가 여러 명이면 여러 주문을 동시에 처리 가능 (비동기)</li>
</ul>
<h3 id="스레드풀threadpool이란">스레드풀(ThreadPool)이란?</h3>
<p><strong>스레드풀</strong>은 미리 준비해둔 스레드들의 집합입니다.</p>
<pre><code>음식점 비유:
┌─────────────────────────────────────┐
│        주방 (스레드풀)               │
│                                     │
│ 👨‍🍳 👨‍🍳 👨‍🍳 (요리사 = 스레드)      │
│                                     │
│ 📋 📋 📋 (대기 주문 = 큐)           │
└─────────────────────────────────────┘

- 기본 요리사 2명 (corePoolSize = 2)
- 바쁠 때 최대 10명까지 (maxPoolSize = 10)
- 주문 50개까지 대기 가능 (queueCapacity = 50)</code></pre><h3 id="왜-스레드풀을-사용하나요">왜 스레드풀을 사용하나요?</h3>
<ol>
<li><strong>효율성</strong>: 스레드 생성/삭제 비용 절약</li>
<li><strong>안정성</strong>: 시스템 리소스 보호 (무한정 스레드 생성 방지)</li>
<li><strong>제어</strong>: 동시 실행 작업 수 제한</li>
</ol>
<h2 id="개요">개요</h2>
<p>DungeonTalk 백엔드는 <strong>도메인별 분리된 스레드풀</strong>을 사용하여 비동기 작업을 처리합니다.</p>
<h3 id="핵심-개념-실제-예시로-이해하기">핵심 개념 (실제 예시로 이해하기)</h3>
<h4 id="1-매칭-전용-스레드풀">1. 매칭 전용 스레드풀</h4>
<pre><code>🎮 게임 매칭 상황:
유저A: &quot;게임 매칭해주세요!&quot;
유저B: &quot;저도 매칭해주세요!&quot; 
AI게임: &quot;AI 턴 처리해주세요!&quot;

👨‍🍳 매칭 전용 주방(스레드풀):
- 요리사 2~10명이 이런 요청들만 처리
- 게임 관련 작업에만 집중</code></pre><h4 id="2-heartbeat-전용-스케줄러">2. Heartbeat 전용 스케줄러</h4>
<pre><code>💓 WebSocket 연결 유지:
&quot;클라이언트야, 살아있니?&quot; (매 30초마다)
&quot;네, 살아있어요!&quot; 

👨‍🍳 Heartbeat 전용 직원:
- 요리사 2명이 연결 확인만 담당  
- 게임 처리와 완전 분리</code></pre><h4 id="3-장애-격리-진짜-중요한-개념">3. 장애 격리 (진짜 중요한 개념!)</h4>
<pre><code>❌ 만약 스레드풀을 분리하지 않았다면:
매칭 처리가 너무 느림 → 모든 스레드 점유 → WebSocket 연결 끊김 😱

✅ 스레드풀 분리 후:
매칭 처리가 느림 → 매칭용 스레드만 느림 → WebSocket은 정상 작동 😊</code></pre><h3 id="왜-이렇게-설계했나">왜 이렇게 설계했나?</h3>
<ol>
<li><strong>안정성</strong>: 매칭 처리가 느려져도 WebSocket 연결은 유지됨</li>
<li><strong>확장성</strong>: 부하가 높은 기능만 독립적으로 스케일업 가능</li>
<li><strong>모니터링</strong>: 도메인별 성능 추적 및 문제 파악 용이</li>
</ol>
<h2 id="전체-아키텍처-다이어그램">전체 아키텍처 다이어그램</h2>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                    DungeonTalk Backend                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────────┐    ┌──────────────────┐                  │
│  │   Web Layer      │    │   WebSocket      │                  │
│  │  (Controllers)   │    │    Layer         │                  │
│  └─────────┬────────┘    └─────────┬────────┘                  │
│            │                       │                           │
│            ▼                       ▼                           │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │                 Service Layer                               │ │
│  │                                                             │ │
│  │  ┌─────────────────┐       ┌────────────────────────────┐   │ │
│  │  │ MatchingService │       │   AiGameFlowService        │   │ │
│  │  │                 │       │                            │   │ │
│  │  │ @Async(         │       │ @Async(                    │   │ │
│  │  │ &quot;matchingTask   │       │ &quot;matchingTaskExecutor&quot;)    │   │ │
│  │  │ Executor&quot;)      │       │                            │   │ │
│  │  └─────────┬───────┘       └────────────┬───────────────┘   │ │
│  └────────────┼──────────────────────────────┼─────────────────┘ │
│               │                              │                   │
│               ▼                              ▼                   │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │                Thread Pool Layer                            │ │
│  │                                                             │ │
│  │  ┌──────────────────────────────────────────────────────┐   │ │
│  │  │          matchingTaskExecutor                        │   │ │
│  │  │  ┌─────────────────────────────────────────────────┐ │   │ │
│  │  │  │ ThreadPoolTaskExecutor Configuration            │ │   │ │
│  │  │  │                                                 │ │   │ │
│  │  │  │ • Core Threads: 2                              │ │   │ │
│  │  │  │ • Max Threads: 10                              │ │   │ │
│  │  │  │ • Queue: 50                                    │ │   │ │
│  │  │  │ • KeepAlive: 60s                               │ │   │ │
│  │  │  │ • Name: &quot;Matching-Async-&quot;                      │ │   │ │
│  │  │  │                                                 │ │   │ │
│  │  │  │ RejectedExecutionHandler:                       │ │   │ │
│  │  │  │ └─► Fallback to Main Thread                    │ │   │ │
│  │  │  └─────────────────────────────────────────────────┘ │   │ │
│  │  └──────────────────────────────────────────────────────┘   │ │
│  │                                                             │ │
│  │  ┌──────────────────────────────────────────────────────┐   │ │
│  │  │           stompTaskScheduler                         │   │ │
│  │  │  ┌─────────────────────────────────────────────────┐ │   │ │
│  │  │  │ ThreadPoolTaskScheduler Configuration           │ │   │ │
│  │  │  │                                                 │ │   │ │
│  │  │  │ • Pool Size: 2                                 │ │   │ │
│  │  │  │ • Name: &quot;stomp-heartbeat-&quot;                     │ │   │ │
│  │  │  │ • RemoveOnCancel: true                         │ │   │ │
│  │  │  │                                                 │ │   │ │
│  │  │  │ Purpose: STOMP Heartbeat Only                  │ │   │ │
│  │  │  └─────────────────────────────────────────────────┘ │   │ │
│  │  └──────────────────────────────────────────────────────┘   │ │
│  └─────────────────────────────────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h2 id="🚀-스레드-실행-흐름-따라가보기">🚀 스레드 실행 흐름 (따라가보기)</h2>
<h3 id="🤔-초보자-질문-사용자가-매칭-버튼을-누르면-뭐가-일어나요">🤔 초보자 질문: &quot;사용자가 매칭 버튼을 누르면 뭐가 일어나요?&quot;</h3>
<p><strong>매칭과 AI 턴 처리는 같은 스레드풀을 공유</strong>하여 리소스를 효율적으로 사용합니다.<br><strong>WebSocket Heartbeat는 별도 스케줄러</strong>로 실시간 연결을 안정적으로 유지합니다.</p>
<h3 id="단계별-실행-과정">단계별 실행 과정</h3>
<h3 id="1-매칭-처리-흐름-실제-상황">1. 매칭 처리 흐름 (실제 상황)</h3>
<pre><code>🎮 사용자: &quot;매칭해주세요!&quot; 클릭
      │
      ▼ HTTP 요청 전송
┌─────────────┐    
│ Controller  │ ──── &quot;어? 매칭 요청이 왔네!&quot; 
└─────────────┘           
      │                          
      ▼ MatchingService 호출
┌──────────────────┐
│ MatchingService  │ ──── &quot;@Async로 비동기 처리해야지!&quot;
└─────────┬────────┘      
          │ @Async(&quot;matchingTaskExecutor&quot;)
          ▼ 스레드풀로 작업 위임
┌─────────────────────────┐
│  matchingTaskExecutor   │ ── &quot;매칭 전용 주방이야!&quot;
│                         │
│ 👨‍🍳 👨‍🍳 (T1)(T2)        │ ── &quot;지금 2명 대기중&quot;  
│                         │
│ 📋📋📋 Queue(50개 대기가능) │ ── &quot;주문 대기열&quot;
└─────────────────────────┘
          │
          ▼ 실제 매칭 작업 수행
┌─────────────────────────┐
│    매칭 처리 로직        │
│  • Redis에서 대기자 찾기 │ ── &quot;다른 플레이어 있나?&quot;
│  • 유저 상태 업데이트    │ ── &quot;매칭중 상태로 변경&quot;  
│  • 게임방 생성          │ ── &quot;방 만들어서 입장!&quot;
└─────────────────────────┘</code></pre><p><strong>💡 핵심 포인트</strong>: Controller는 바로 응답하고, 실제 매칭은 백그라운드에서 처리!</p>
<h3 id="2-ai-게임-턴-처리-흐름">2. AI 게임 턴 처리 흐름</h3>
<pre><code>Game Event
      │
      ▼
┌──────────────┐   Event   ┌───────────────────┐
│ EventListener│ ────────► │ AiGameFlowService │
└──────────────┘           └─────────┬─────────┘
                                     │ @Async(&quot;matchingTaskExecutor&quot;)
                                     ▼
                           ┌─────────────────────────┐
                           │  matchingTaskExecutor   │ (Same pool)
                           │                         │
                           │ ┌─────┐ ┌─────┐ ┌─────┐ │
                           │ │ T1  │ │ T2  │ │ ... │ │
                           │ └─────┘ └─────┘ └─────┘ │
                           └─────────────────────────┘
                                     │
                                     ▼
                           ┌─────────────────────────┐
                           │   AI Turn Processing    │
                           │  • AI API Call          │
                           │  • Response Processing  │
                           │  • WebSocket Message    │
                           └─────────────────────────┘</code></pre><h3 id="3-websocket-heartbeat-흐름">3. WebSocket Heartbeat 흐름</h3>
<pre><code>WebSocket Connection
         │
         ▼
┌─────────────────┐     ┌────────────────────┐
│ WebSocketConfig │────►│ SimpleBroker       │
└─────────────────┘     └─────────┬──────────┘
                                  │ setTaskScheduler(stompTaskScheduler)
                                  ▼
                        ┌─────────────────────────┐
                        │   stompTaskScheduler    │
                        │                         │
                        │ ┌─────┐ ┌─────┐         │
                        │ │ HB1 │ │ HB2 │         │
                        │ └─────┘ └─────┘         │
                        │                         │
                        │ Heartbeat: 30s interval │
                        └─────────────────────────┘
                                  │
                                  ▼
                        ┌─────────────────────────┐
                        │  Client ←→ Server       │
                        │   Heartbeat Messages    │
                        └─────────────────────────┘</code></pre><h2 id="스레드풀-분리-전략">스레드풀 분리 전략</h2>
<h3 id="핵심-아이디어-관심사-분리">핵심 아이디어: &quot;관심사 분리&quot;</h3>
<ul>
<li><strong>매칭 도메인</strong>: 사용자 매칭, AI 게임 처리 → 비즈니스 로직 중심</li>
<li><strong>인프라 레이어</strong>: WebSocket 연결 유지 → 기술적 요구사항 중심</li>
</ul>
<h3 id="도메인별-격리">도메인별 격리</h3>
<pre><code>┌─────────────────────────────────────────────────────────────┐
│                     Thread Pool 분리 전략                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────┐  │
│  │   Matching      │  │   WebSocket     │  │   Future    │  │
│  │   Domain        │  │   Infrastructure│  │  Extension  │  │
│  │                 │  │                 │  │             │  │
│  │ matchingTask    │  │ stompTask       │  │ aiChatTask  │  │
│  │ Executor        │  │ Scheduler       │  │ Executor    │  │
│  │                 │  │                 │  │ (Planned)   │  │
│  │ • 매칭 처리      │  │ • Heartbeat     │  │ • AI 채팅    │  │
│  │ • AI 게임 턴     │  │ • Connection    │  │ • 응답 생성  │  │
│  │                 │  │   Management    │  │             │  │
│  └─────────────────┘  └─────────────────┘  └─────────────┘  │
│                                                             │
│  장점:                                                       │
│  • 도메인별 성능 최적화                                        │
│  • 장애 격리                                                │
│  • 독립적 모니터링                                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘</code></pre><h2 id="설정-관리-구조">설정 관리 구조</h2>
<h3 id="설정의-흐름-yaml-→-properties-→-bean-생성">설정의 흐름: YAML → Properties → Bean 생성</h3>
<p>스프링의 <code>@ConfigurationProperties</code>를 활용해 <strong>타입 안전한 설정 관리</strong>를 구현했습니다.</p>
<h3 id="configuration-계층">Configuration 계층</h3>
<pre><code>application.yml
      │
      ▼
┌─────────────────────────────────────────────┐
│          MatchingProperties                 │
│                                             │
│ @ConfigurationProperties                    │
│ (prefix = &quot;app.matching&quot;)                   │
│                                             │
│ ┌─────────────────────────────────────────┐ │
│ │        ThreadPool                       │ │
│ │                                         │ │
│ │ • corePoolSize: 2                      │ │
│ │ • maxPoolSize: 10                      │ │
│ │ • queueCapacity: 50                    │ │
│ │ • keepAliveSeconds: 60                 │ │
│ └─────────────────────────────────────────┘ │
└──────────────┬──────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────┐
│        MatchingAsyncConfig                  │
│                                             │
│ @Configuration                              │
│ @EnableAsync                                │
│                                             │
│ ┌─────────────────────────────────────────┐ │
│ │   @Bean(&quot;matchingTaskExecutor&quot;)         │ │
│ │                                         │ │
│ │   ThreadPoolTaskExecutor 생성           │ │
│ │   • Properties 값 주입                  │ │
│ │   • RejectedExecutionHandler 설정       │ │
│ │   • 로깅 및 초기화                       │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘</code></pre><h2 id="성능-특성-및-튜닝-포인트">성능 특성 및 튜닝 포인트</h2>
<h3 id="🎯-설정값-쉽게-이해하기-음식점-비유">🎯 설정값 쉽게 이해하기 (음식점 비유)</h3>
<h4 id="현재-설정값의-근거">현재 설정값의 근거</h4>
<pre><code>🏪 던전톡 매칭 전용 주방:

👨‍🍳👨‍🍳 기본 요리사 2명 (Core Pool Size = 2)
→ &quot;평상시에는 이 정도면 충분해!&quot;

👨‍🍳👨‍🍳👨‍🍳👨‍🍳👨‍🍳👨‍🍳👨‍🍳👨‍🍳👨‍🍳👨‍🍳 최대 10명 (Max Pool Size = 10)  
→ &quot;점심시간처럼 바쁠 때는 임시 직원까지 동원!&quot;

📋📋📋📋📋...(50개) 주문 대기판 (Queue Capacity = 50)
→ &quot;주문이 몰려도 50개까지는 대기 가능!&quot;

🚨 주방이 가득 찬다면? (RejectedExecutionHandler)
→ &quot;사장님이 직접 요리하기!&quot; (메인 스레드에서 처리)</code></pre><h4 id="왜-이-숫자들을-선택했을까요">왜 이 숫자들을 선택했을까요?</h4>
<ul>
<li><strong>Core 2개</strong>: 매칭은 보통 몇 초 내 처리. CPU 과부하 방지</li>
<li><strong>Max 10개</strong>: 서버 메모리를 너무 많이 쓰면 안 되니까!  </li>
<li><strong>Queue 50개</strong>: 동시접속자 급증해도 버틸 수 있게</li>
<li><strong>Fallback</strong>: 절대 매칭 요청을 버리지 않겠다는 의지!</li>
</ul>
<h3 id="스레드풀-크기-결정-기준">스레드풀 크기 결정 기준</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                     튜닝 매트릭스                                │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│ Core Threads (2) ←────┐                                         │
│                       │                                         │
│ • CPU 집약적 작업 고려  │    ┌─── Queue Capacity (50)           │
│ • 기본 처리량 확보     │    │                                   │
│                       │    │   • 버스트 트래픽 대응             │
│                       ▼    ▼   • 메모리 사용량 제한             │
│                                                                 │
│            ┌─────────────────────────────┐                      │
│            │    Thread Pool Behavior     │                      │
│            │                             │                      │
│            │ Low Load:  Core만 활성       │                      │
│            │ Med Load:  Queue 활용        │                      │
│            │ High Load: Max까지 확장      │                      │
│            │ Overflow:  Main Thread      │                      │
│            └─────────────────────────────┘                      │
│                       │    ▲                                    │
│                       │    │                                    │
│                       ▼    └─── Max Threads (10)               │
│                                                                 │
│ Keep Alive (60s) ────────────────┐                             │
│                                  │                             │
│ • 유휴 스레드 정리               │                             │
│ • 메모리 효율성                   │                             │
│                                  ▼                             │
└─────────────────────────────────────────────────────────────────┘</code></pre><h2 id="모니터링-포인트">모니터링 포인트</h2>
<h3 id="현재-구현된-모니터링">현재 구현된 모니터링</h3>
<ul>
<li><strong>초기화 로그</strong>: 스레드풀 설정값 확인</li>
<li><strong>거부 로그</strong>: 스레드풀 포화 상태 감지</li>
<li><strong>스레드 이름</strong>: 로그에서 어느 풀의 스레드인지 식별 가능</li>
</ul>
<h3 id="로그-기반-모니터링">로그 기반 모니터링</h3>
<pre><code>Application Startup
         │
         ▼
┌─────────────────────────────────────────┐
│ &quot;매칭 전용 스레드 풀 초기화 완료:         │
│  core=2, max=10, queue=50&quot;             │
└─────────────────────────────────────────┘
         │
         ▼ (Runtime)
┌─────────────────────────────────────────┐
│ &quot;매칭 처리 스레드 풀이 가득 참.          │
│  요청 거부됨&quot;                           │
└─────────────────────────────────────────┘
         │
         ▼ (메트릭 수집 가능 지점)
┌─────────────────────────────────────────┐
│ • Active Thread Count                   │
│ • Queue Size                           │
│ • Completed Task Count                 │
│ • Rejected Task Count                  │
└─────────────────────────────────────────┘</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[# MongoDB TestContainers와 Mockito로 배우는 실전 테스트 코드]]></title>
            <link>https://velog.io/@mj_o/MongoDB-TestContainers%EC%99%80-Mockito%EB%A1%9C-%EB%B0%B0%EC%9A%B0%EB%8A%94-%EC%8B%A4%EC%A0%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C</link>
            <guid>https://velog.io/@mj_o/MongoDB-TestContainers%EC%99%80-Mockito%EB%A1%9C-%EB%B0%B0%EC%9A%B0%EB%8A%94-%EC%8B%A4%EC%A0%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C</guid>
            <pubDate>Sun, 17 Aug 2025 07:16:31 GMT</pubDate>
            <description><![CDATA[<h2 id="🤔-왜-테스트-코드를-시작했을까">🤔 왜 테스트 코드를 시작했을까?</h2>
<p>TRPG 게임 백엔드를 개발하면서 매번 기능을 추가할 때마다 이런 생각이 들었습니다.</p>
<blockquote>
<p>&quot;이 코드 바꿔도 다른 기능 안 망가질까?&quot;<br>&quot;MongoDB 연동이 제대로 되는지 어떻게 확인하지?&quot;<br>&quot;서비스 로직이 복잡해지는데 테스트는 어떻게 하지?&quot;</p>
</blockquote>
<p>그래서 테스트 코드를 시작하기로 했습니다. 하지만 막상 시작하려니...</p>
<p><strong>어디서부터 시작해야 할지 모르겠더라고요.</strong></p>
<h2 id="테스트-전략-dto-→-repository-→-service">테스트 전략: DTO → Repository → Service</h2>
<p>초보자인 저는 간단한 것부터 시작하기로 했습니다.</p>
<h3 id="1단계-dto-테스트-워밍업">1단계: DTO 테스트 (워밍업)</h3>
<pre><code class="language-java">@Test
@DisplayName(&quot;Builder로 객체 생성 테스트&quot;)
void builderCreatesObject() {
    // given
    String memberId = &quot;user-12345&quot;;
    WorldType worldType = WorldType.FANTASY;

    // when
    AiServiceRequest result = AiServiceRequest.builder()
            .memberId(memberId)
            .worldType(worldType)
            .build();

    // then
    assertThat(result).isNotNull();
    assertThat(result.getMemberId()).isEqualTo(memberId);
    assertThat(result.getWorldType()).isEqualTo(worldType);
}

@Test
@DisplayName(&quot;JSON 직렬화/역직렬화 테스트&quot;)
void serializeToJson() throws Exception {
    // given
    AiServiceRequest request = AiServiceRequest.builder()
            .memberId(&quot;test-user&quot;)
            .worldType(WorldType.FANTASY)
            .build();

    // when
    String json = objectMapper.writeValueAsString(request);
    AiServiceRequest result = objectMapper.readValue(json, AiServiceRequest.class);

    // then
    assertThat(json).contains(&quot;\&quot;memberId\&quot;:\&quot;test-user\&quot;&quot;);
    assertThat(result.getMemberId()).isEqualTo(&quot;test-user&quot;);
}</code></pre>
<p><strong>왜 DTO부터했을까?</strong></p>
<ul>
<li>로직이 단순해서 실패할 확률이 낮음</li>
<li>JSON 직렬화/역직렬화 확인 가능</li>
<li>테스트 작성 패턴에 익숙해질 수 있음</li>
<li>given-when-then 패턴 연습하기 좋음</li>
<li>제일 중요한 것은 ai 테스트 코드에 대해 설명해줘 했는데 dto를 제일 먼저 알려주었다..</li>
</ul>
<h3 id="2단계-repository-테스트-testcontainers와의-첫-만남">2단계: Repository 테스트 (TestContainers와의 첫 만남)</h3>
<p>진짜 도전은 Repository 테스트였습니다. Mock vs 실제 DB를 놓고 고민했는데...</p>
<p><strong>&quot;MongoDB 연동이 제대로 되는지 확인하려면 실제 DB를 써야겠다!&quot;</strong></p>
<p>그래서 TestContainers를 선택했습니다.
<img src="https://velog.velcdn.com/images/mj_o/post/c903e83e-aa97-4e40-8c0e-d86069953f84/image.png" alt="">
실제로 켜진 모습도 확인이 가능합니다 </p>
<pre><code class="language-java">@Testcontainers
@DataMongoTest
@ActiveProfiles(&quot;test&quot;)
class AiGameMessageRepositoryTest {

    @Container
    static MongoDBContainer mongo = new MongoDBContainer(&quot;mongo:7.0&quot;);

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add(&quot;spring.data.mongodb.uri&quot;, mongo::getReplicaSetUrl);
    }

    @Autowired
    AiGameMessageRepository repository;

    @Test
    @DisplayName(&quot;메시지 저장 후 조회 테스트&quot;)
    void saveAndFindMessage() {
        // given
        AiGameMessage message = AiGameMessage.builder()
                .aiGameRoomId(&quot;room-123&quot;)
                .content(&quot;테스트 메시지&quot;)
                .messageType(AiMessageType.USER)
                .turnNumber(1)
                .messageOrder(1)
                .createdAt(Instant.now())
                .build();

        // when
        AiGameMessage saved = repository.save(message);
        Optional&lt;AiGameMessage&gt; found = repository.findById(saved.getId());

        // then
        assertThat(found).isPresent();
        assertThat(found.get().getContent()).isEqualTo(&quot;테스트 메시지&quot;);
        assertThat(found.get().getMessageType()).isEqualTo(AiMessageType.USER);
    }

    @Test
    @DisplayName(&quot;룸별 메시지 조회 테스트&quot;)
    void findByAiGameRoomIdOrderByCreatedAt() {
        // given
        String roomId = &quot;room-456&quot;;

        AiGameMessage message1 = createTestMessage(roomId, &quot;첫 번째 메시지&quot;, 1);
        AiGameMessage message2 = createTestMessage(roomId, &quot;두 번째 메시지&quot;, 2);

        repository.saveAll(List.of(message1, message2));

        // when
        List&lt;AiGameMessage&gt; messages = repository.findByAiGameRoomIdOrderByCreatedAt(roomId);

        // then
        assertThat(messages).hasSize(2);
        assertThat(messages.get(0).getContent()).isEqualTo(&quot;첫 번째 메시지&quot;);
        assertThat(messages.get(1).getContent()).isEqualTo(&quot;두 번째 메시지&quot;);
    }
}</code></pre>
<p><strong>TestContainers의 장점:</strong></p>
<ul>
<li>✅ 실제 MongoDB 환경에서 테스트</li>
<li>✅ 로컬 DB 설정 불필요</li>
<li>✅ CI/CD에서도 동일한 환경 보장</li>
<li>✅ 실제 DB 쿼리 동작 확인 가능</li>
</ul>
<p><strong>삽질했던 부분:</strong></p>
<ul>
<li>❌ MongoDB 인증 설정 때문에 한참 헤맸음</li>
<li>❌ <code>@DynamicPropertySource</code> 없이 했다가 connection 실패</li>
<li>❌ 컨테이너 시작 시간 때문에 테스트가 느려짐</li>
<li>❌ TestContainers 버전 호환성 문제</li>
</ul>
<h3 id="3단계-service-테스트-mockito의-위력">3단계: Service 테스트 (Mockito의 위력)</h3>
<p>Service 테스트는 다른 접근이 필요했습니다. 모든 의존성을 실제로 띄우기엔 너무 무거워서 Mock을 사용했습니다.</p>
<pre><code class="language-java">@ExtendWith(MockitoExtension.class)
class AiGameMessageServiceTest {

    @Mock
    AiGameMessageRepository aiGameMessageRepository;

    @Mock
    RedisPublisher redisPublisher;

    @Mock
    ProfanityFilterService profanityFilterService;

    @Mock
    AiGameValidator aiGameValidator;

    @InjectMocks
    AiGameMessageService aiGameMessageService;

    @Test
    @DisplayName(&quot;AI 메시지 저장 성공 테스트&quot;)
    void saveAiMessage_success() {
        // given
        AiMessageSaveRequest request = AiMessageSaveRequest.builder()
                .aiGameRoomId(&quot;room-123&quot;)
                .gameId(&quot;game-456&quot;)
                .content(&quot;AI 응답 메시지&quot;)
                .turnNumber(1)
                .build();

        // Mock: getNextMessageOrder 결과
        given(aiGameMessageRepository.findMaxMessageOrderByTurn(&quot;room-123&quot;, 1))
                .willReturn(Collections.emptyList());

        // Mock: 저장된 메시지 반환
        AiGameMessage savedMessage = AiGameMessage.builder()
                .id(&quot;saved-id&quot;)
                .aiGameRoomId(&quot;room-123&quot;)
                .gameId(&quot;game-456&quot;)
                .content(&quot;AI 응답 메시지&quot;)
                .messageType(AiMessageType.AI)
                .turnNumber(1)
                .messageOrder(1)
                .createdAt(Instant.now())
                .build();

        given(aiGameMessageRepository.save(any(AiGameMessage.class)))
                .willReturn(savedMessage);

        // when
        AiGameMessageDto result = aiGameMessageService.saveAiMessage(request);

        // then
        assertThat(result).isNotNull();
        assertThat(result.getContent()).isEqualTo(&quot;AI 응답 메시지&quot;);
        assertThat(result.getMessageType()).isEqualTo(AiMessageType.AI);
        assertThat(result.getTurnNumber()).isEqualTo(1);

        // Mock 호출 검증
        then(aiGameValidator).should().validateGameRoom(&quot;room-123&quot;);
        then(aiGameMessageRepository).should().save(any(AiGameMessage.class));
    }

    @Test
    @DisplayName(&quot;메시지 순서 계산 테스트&quot;)
    void messageOrder_test() {
        // given - 첫 번째 메시지인 경우
        AiMessageSaveRequest request = AiMessageSaveRequest.builder()
                .aiGameRoomId(&quot;room-123&quot;)
                .content(&quot;첫 번째 메시지&quot;)
                .turnNumber(1)
                .build();

        // Mock: 기존 메시지가 없는 경우
        given(aiGameMessageRepository.findMaxMessageOrderByTurn(&quot;room-123&quot;, 1))
                .willReturn(Collections.emptyList());

        AiGameMessage savedMessage = AiGameMessage.builder()
                .id(&quot;saved-id&quot;)
                .messageOrder(1) // 첫 번째 메시지이므로 1
                .turnNumber(1)
                .content(&quot;첫 번째 메시지&quot;)
                .messageType(AiMessageType.AI)
                .createdAt(Instant.now())
                .build();

        given(aiGameMessageRepository.save(any(AiGameMessage.class)))
                .willReturn(savedMessage);

        // when
        AiGameMessageDto result = aiGameMessageService.saveAiMessage(request);

        // then
        assertThat(result.getMessageOrder()).isEqualTo(1);
        then(aiGameMessageRepository).should().findMaxMessageOrderByTurn(&quot;room-123&quot;, 1);
    }
}</code></pre>
<h2 id="🚧-삽질-경험담-진짜-겪은-일들">🚧 삽질 경험담 (진짜 겪은 일들)</h2>
<h3 id="1-mongodb-인증-문제">1. MongoDB 인증 문제</h3>
<pre><code class="language-properties"># 이렇게 설정했다가 계속 실패
spring.data.mongodb.username=${SPRING_DATA_MONGODB_USERNAME}
spring.data.mongodb.password=${SPRING_DATA_MONGODB_PASSWORD}

# 이렇게 주석 처리해서 해결
#spring.data.mongodb.username=${SPRING_DATA_MONGODB_USERNAME}
#spring.data.mongodb.password=${SPRING_DATA_MONGODB_PASSWORD}</code></pre>
<p><strong>교훈</strong>: 로컬 환경과 테스트 환경 설정을 다르게 가져가자!</p>
<h3 id="2-mockito-unnecessarystubbingexception">2. Mockito UnnecessaryStubbingException</h3>
<pre><code class="language-java">// 🚫 이렇게 하면 에러 발생
@BeforeEach
void setUp() {
    given(mockService.someMethod()).willReturn(value); // 사용되지 않음
}

// ✅ 해결: 실제 사용되는 테스트에서만 Mock 설정
@Test
void testMethod() {
    given(mockService.someMethod()).willReturn(value); // 실제 사용됨
    // ... 테스트 로직
}</code></pre>
<h3 id="3-필드명-틀린-실수">3. 필드명 틀린 실수</h3>
<pre><code class="language-java">// 🚫 테스트에서는 이렇게 호출했는데
assertThat(result.getQueuePosition()).isEqualTo(6); 

// 🚫 실제로는 이런 메서드명이었음
public int getCurrentPosition() { ... }

// ✅ 해결
assertThat(result.getCurrentPosition()).isEqualTo(6);</code></pre>
<p><strong>교훈</strong>: IDE의 자동완성을 믿자! 타이핑하지 말고 자동완성 쓰자.</p>
<h3 id="4-nullpointerexception-지옥">4. NullPointerException 지옥</h3>
<pre><code class="language-java">// 🚫 이렇게 하면 NPE 발생
@Mock
SomeService someService; // Mock 생성만 하고

@Test
void test() {
    // Mock 설정 없이 바로 사용
    service.doSomething(); // NPE 발생!
}

// ✅ 해결: 필요한 모든 Mock 설정
@Test
void test() {
    given(someService.method()).willReturn(value);
    given(anotherService.method()).willReturn(value);
    // 모든 의존성 Mock 설정 후 테스트
}</code></pre>
<h2 id="🎯-도메인별-테스트-전략">🎯 도메인별 테스트 전략</h2>
<h3 id="aichat-도메인-실제-db-중심">AiChat 도메인: 실제 DB 중심</h3>
<pre><code class="language-java">// Repository: TestContainers로 실제 MongoDB 테스트
@Testcontainers
@DataMongoTest
class AiGameMessageRepositoryTest { ... }

// Service: Mockito로 비즈니스 로직만 집중
@ExtendWith(MockitoExtension.class)
class AiGameMessageServiceTest { ... }</code></pre>
<p><strong>선택 이유</strong>: 시간 필드 변경(LocalDateTime → Instant) 등 데이터 정합성이 중요</p>
<h3 id="matching-도메인-mock-중심">Matching 도메인: Mock 중심</h3>
<pre><code class="language-java">// QueueManager: Redis Mock으로 큐 로직 테스트
@Mock StringRedisTemplate redisTemplate;
@Mock ListOperations&lt;String, String&gt; listOperations;

// Service: WebSocket, Redis 등 외부 의존성 많음
@Mock MatchingQueueManager queueManager;
@Mock MatchingWebSocketService webSocketService;</code></pre>
<p><strong>선택 이유</strong>: 비즈니스 로직보다는 연동 로직이 중심</p>
<h3 id="room-도메인-factory-패턴-테스트">Room 도메인: Factory 패턴 테스트</h3>
<pre><code class="language-java">// Factory: 실제 구현체들로 패턴 동작 확인
@Test
void getService_ai_success() {
    given(aiRoomService.getSupportedRoomType()).willReturn(RoomType.AI_GAME);

    RoomServiceFactory factory = new RoomServiceFactory(roomServices);
    RoomService result = factory.getService(RoomType.AI_GAME);

    assertThat(result).isEqualTo(aiRoomService);
}</code></pre>
<p><strong>선택 이유</strong>: 패턴 자체의 동작이 중요</p>
<h2 id="💡-초보자를-위한-실전-팁">💡 초보자를 위한 실전 팁</h2>
<h3 id="1-테스트-작성-순서">1. 테스트 작성 순서</h3>
<pre><code>DTO → Repository → Service → Controller</code></pre><ul>
<li><strong>DTO</strong>: 가장 간단, 실패할 확률 낮음</li>
<li><strong>Repository</strong>: 실제 DB 연동 확인</li>
<li><strong>Service</strong>: 비즈니스 로직 검증  </li>
<li><strong>Controller</strong>: API 전체 흐름 테스트</li>
</ul>
<h3 id="2-testcontainers-vs-mock-선택-기준">2. TestContainers vs Mock 선택 기준</h3>
<table>
<thead>
<tr>
<th>상황</th>
<th>추천 방법</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>DB 연동 중요</td>
<td>TestContainers</td>
<td>실제 DB 동작 확인</td>
</tr>
<tr>
<td>비즈니스 로직만</td>
<td>Mock</td>
<td>빠른 테스트, 외부 의존성 제거</td>
</tr>
<tr>
<td>빠른 피드백 필요</td>
<td>Mock</td>
<td>테스트 실행 속도</td>
</tr>
<tr>
<td>실제 환경과 동일</td>
<td>TestContainers</td>
<td>프로덕션 환경과 유사</td>
</tr>
</tbody></table>
<h3 id="3-mock-설정-꿀팁">3. Mock 설정 꿀팁</h3>
<pre><code class="language-java">// ✅ 좋은 예: 테스트별로 필요한 Mock만 설정
@Test
void specificTest() {
    // 이 테스트에 필요한 Mock만 설정
    given(mockA.method()).willReturn(value);

    // 테스트 실행
    // 검증
}

// ✅ 공통 Mock은 @BeforeEach에서
@BeforeEach
void setUp() {
    // 모든 테스트에서 공통으로 사용하는 Mock
    given(commonMock.method()).willReturn(commonValue);
}</code></pre>
<h3 id="4-테스트-실패시-디버깅-체크리스트">4. 테스트 실패시 디버깅 체크리스트</h3>
<ol>
<li><p><strong>에러 메시지 꼼꼼히 읽기</strong> </p>
<ul>
<li>NullPointerException → Mock 설정 확인</li>
<li>AssertionError → 예상값과 실제값 비교</li>
</ul>
</li>
<li><p><strong>Mock 설정 확인</strong> </p>
<ul>
<li><code>given()</code> 설정이 실제 호출과 일치하는지</li>
<li>모든 필요한 의존성이 Mock되었는지</li>
</ul>
</li>
<li><p><strong>메서드명 확인</strong> </p>
<ul>
<li>테스트의 메서드명과 실제 메서드명 일치 확인</li>
<li>IDE 자동완성 활용하기</li>
</ul>
</li>
<li><p><strong>given-when-then 순서 점검</strong> </p>
<ul>
<li>given: 조건 설정이 완료되었는지</li>
<li>when: 실제 동작이 올바른지</li>
<li>then: 검증 로직이 정확한지</li>
</ul>
</li>
</ol>
<h3 id="5-테스트-데이터-관리">5. 테스트 데이터 관리</h3>
<pre><code class="language-java">// ✅ 테스트 데이터 생성 메서드 활용
private AiGameMessage createTestMessage(String roomId, String content, int order) {
    return AiGameMessage.builder()
            .aiGameRoomId(roomId)
            .content(content)
            .messageOrder(order)
            .messageType(AiMessageType.USER)
            .createdAt(Instant.now())
            .build();
}

// ✅ 상수로 테스트 데이터 관리
private static final String TEST_ROOM_ID = &quot;test-room-123&quot;;
private static final String TEST_USER_ID = &quot;test-user-456&quot;;</code></pre>
<h2 id="테스트-코드-작성-후-느낀-점">테스트 코드 작성 후 느낀 점</h2>
<h3 id="좋은-점">좋은 점</h3>
<ol>
<li><p><strong>리팩토링 두려움 해소</strong></p>
<pre><code class="language-java">// LocalDateTime → Instant 변경도 안심하고 진행
private LocalDateTime createdAt; // Before
private Instant createdAt;       // After</code></pre>
<p>테스트가 있으니까 변경해도 바로 확인 가능!</p>
</li>
<li><p><strong>버그 발견 속도 향상</strong></p>
<pre><code class="language-java">// 필드명 틀린 것도 테스트에서 먼저 발견
assertThat(result.getQueuePosition()).isEqualTo(6); // 컴파일 에러로 바로 발견!</code></pre>
</li>
<li><p><strong>코드 이해도 증가</strong></p>
<ul>
<li>테스트 작성하면서 기존 코드 흐름 파악</li>
<li>&quot;이 메서드가 뭘 하는 거지?&quot;에서 &quot;이 메서드는 이렇게 동작하는구나!&quot;</li>
</ul>
</li>
<li><p><strong>자신감 향상</strong></p>
<ul>
<li>기능 추가할 때 &quot;혹시 뭔가 깨지지 않을까?&quot; → &quot;테스트 돌려보면 되지!&quot;</li>
</ul>
</li>
</ol>
<h3 id="아쉬운-점">아쉬운 점</h3>
<ol>
<li><p><strong>초기 설정 시간</strong></p>
<ul>
<li>TestContainers, Mock 설정 러닝커브</li>
<li>처음엔 테스트 작성이 기능 구현보다 오래 걸림</li>
</ul>
</li>
<li><p><strong>테스트 유지보수</strong></p>
<ul>
<li>기능 변경시 테스트도 함께 수정 필요</li>
<li>하지만 이것도 코드 품질 향상에 도움</li>
</ul>
</li>
<li><p><strong>완벽한 테스트의 부담</strong></p>
<ul>
<li>&quot;모든 케이스를 다 테스트해야 하나?&quot; 하는 강박</li>
<li>→ 중요한 것부터 차근차근하면 됨!</li>
</ul>
</li>
</ol>
<h2 id="실제-테스트-실행-결과">실제 테스트 실행 결과</h2>
<pre><code class="language-bash">&gt; Task :test

AiServiceRequestTest &gt; Builder로 객체 생성 테스트 PASSED
AiServiceRequestTest &gt; JSON 직렬화 테스트 PASSED
AiServiceRequestTest &gt; JSON 역직렬화 테스트 PASSED

AiGameMessageRepositoryTest &gt; 메시지 저장 후 조회 테스트 PASSED
AiGameMessageRepositoryTest &gt; 룸별 메시지 조회 테스트 PASSED
AiGameMessageRepositoryTest &gt; 턴별 최대 메시지 순서 조회 테스트 PASSED

AiGameMessageServiceTest &gt; AI 메시지 저장 성공 테스트 PASSED
AiGameMessageServiceTest &gt; 메시지 순서 계산 테스트 PASSED

MatchingQueueManagerTest &gt; 큐에 사용자 추가 성공 테스트 PASSED
MatchingQueueManagerTest &gt; 큐에서 사용자 제거 성공 테스트 PASSED
MatchingQueueManagerTest &gt; 큐 크기 조회 테스트 PASSED

MatchingServiceTest &gt; 매칭 참가 성공 테스트 PASSED
MatchingServiceTest &gt; 매칭 취소 성공 테스트 PASSED

RoomServiceFactoryTest &gt; AI 룸 서비스 조회 성공 테스트 PASSED
RoomServiceFactoryTest &gt; null 입력시 예외 발생 테스트 PASSED

BUILD SUCCESSFUL in 8s</code></pre>
<p><strong>18개 테스트 모두 통과!</strong> </p>
<h2 id="다음-목표">다음 목표</h2>
<h3 id="1-controller-테스트-도전">1. Controller 테스트 도전</h3>
<pre><code class="language-java">@WebMvcTest(AiGameRoomController.class)
class AiGameRoomControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    AiGameRoomService aiGameRoomService;

    @Test
    void createRoom_success() throws Exception {
        // MockMvc로 API 테스트
        mockMvc.perform(post(&quot;/api/ai/rooms&quot;)
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestJson))
                .andExpect(status().isOk())
                .andExpect(jsonPath(&quot;$.roomName&quot;).value(&quot;테스트 룸&quot;));
    }
}</code></pre>
<h3 id="2-통합-테스트-시도">2. 통합 테스트 시도</h3>
<pre><code class="language-java">@SpringBootTest
@Testcontainers
class AiGameIntegrationTest {
    // 전체 애플리케이션 컨텍스트로 테스트
}</code></pre>
<h3 id="3-테스트-커버리지-측정">3. 테스트 커버리지 측정</h3>
<pre><code class="language-gradle">// build.gradle에 JaCoCo 플러그인 추가
plugins {
    id &#39;jacoco&#39;
}

jacoco {
    toolVersion = &quot;0.8.8&quot;
}</code></pre>
<h3 id="4-주사위-기능-tdd로-개발">4. 주사위 기능 TDD로 개발</h3>
<pre><code class="language-java">// 테스트 먼저 작성
@Test
void rollDice_d20_returnsRandomValue() {
    // given
    DiceType diceType = DiceType.D20;

    // when
    DiceResult result = diceService.roll(diceType);

    // then
    assertThat(result.getValue()).isBetween(1, 20);
}

// 그 다음 구현
public class DiceService {
    public DiceResult roll(DiceType diceType) {
        // 구현
    }
}</code></pre>
<h2 id="마무리">마무리</h2>
<p>테스트 코드 초보자였던 제가 3개 도메인 테스트를 완주하면서 배운 가장 중요한 것:</p>
<blockquote>
<p><strong>완벽하지 않아도 시작하자!</strong></p>
</blockquote>
<p>DTO 테스트 하나부터 시작해서 점점 늘려가다 보니 어느새 Repository, Service 테스트까지 작성하게 되었습니다.</p>
<h3 id="테스트-코드를-망설이고-있는-분들께">테스트 코드를 망설이고 있는 분들께</h3>
<ul>
<li><strong>&quot;테스트 코드 어려울 것 같아&quot;</strong> → DTO 테스트부터 시작해보세요</li>
<li><strong>&quot;설정이 복잡할 것 같아&quot;</strong> → @Test 하나부터 만들어보세요  </li>
<li><strong>&quot;시간이 오래 걸릴 것 같아&quot;</strong> → 하루에 테스트 하나씩만 추가해보세요</li>
<li><strong>&quot;뭘 테스트해야 할지 모르겠어&quot;</strong> → 가장 자주 사용하는 메서드부터</li>
</ul>
<h3 id="테스트-코드의-진짜-가치">테스트 코드의 진짜 가치</h3>
<p>테스트 코드를 작성하면서 깨달은 것은 <strong>버그 방지</strong>보다도 <strong>개발자의 자신감</strong>이 가장 큰 수확이었습니다.</p>
<ul>
<li>&quot;이 코드 바꿔도 될까?&quot; → &quot;테스트 돌려보면 알 수 있지!&quot;</li>
<li>&quot;리팩토링하고 싶은데 무서워&quot; → &quot;테스트가 있으니까 안심하고 해보자!&quot;</li>
<li>&quot;이 기능이 제대로 동작할까?&quot; → &quot;테스트로 확인했으니까 괜찮아!&quot;</li>
</ul>
<p><strong>실제 프로젝트</strong>: <a href="https://github.com/DungeonTalk/dungeontalk-backend">GitHub - DungeonTalk Backend</a><br><strong>테스트 코드 위치</strong>: <code>src/test/java/org/com/dungeontalk/domain/</code></p>
<hr>
<h3 id="참고-자료">참고 자료</h3>
<ul>
<li><a href="https://spring.io/guides/gs/testing-web/">Spring Boot Testing Guide</a></li>
<li><a href="https://www.testcontainers.org/">TestContainers Documentation</a></li>
<li><a href="https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html">Mockito Documentation</a></li>
<li><a href="https://assertj.github.io/doc/">AssertJ Documentation</a></li>
</ul>
<hr>
<blockquote>
<p>이 글이 테스트 코드 작성을 망설이고 있는 분들에게 도움이 되었으면 좋겠습니다!  
궁금한 점이 있으시면 댓글로 언제든 물어보세요! 🙋‍♂️</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis Lua 스크립트로 안전한 매칭 큐 시스템 구축하기]]></title>
            <link>https://velog.io/@mj_o/Redis-Lua-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%A1%9C-%EC%95%88%EC%A0%84%ED%95%9C-%EB%A7%A4%EC%B9%AD-%ED%81%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@mj_o/Redis-Lua-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%A1%9C-%EC%95%88%EC%A0%84%ED%95%9C-%EB%A7%A4%EC%B9%AD-%ED%81%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 14 Aug 2025 08:10:14 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/mj_o/post/df252eb8-f6e7-4b5d-a624-272e3c3dd014/image.png" alt=""></p>
<blockquote>
<p>DungeonTalk 실시간 매칭에서 Race Condition을 완벽히 해결한 Redis + Lua 스크립트 활용법</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>실시간 게임 매칭 시스템을 구축할 때 가장 까다로운 부분 중 하나가 <strong>동시성 제어</strong>입니다. 여러 사용자가 동시에 매칭 큐에 참여하고, 적절한 인원이 모이면 즉시 게임을 시작해야 하는데, 이 과정에서 <strong>Race Condition</strong>이 발생하기 쉽습니다.</p>
<p>DungeonTalk에서는 Redis List를 활용한 큐 시스템에 <strong>Lua 스크립트</strong>를 도입해 이 문제를 완벽히 해결했습니다. 이번 포스팅에서는 그 과정과 노하우를 상세히 공유합니다.</p>
<h2 id="문제-상황-java-코드만으론-한계가-있었다">문제 상황: Java 코드만으론 한계가 있었다</h2>
<h3 id="기존-매칭-로직의-문제점">기존 매칭 로직의 문제점</h3>
<p>처음에는 Java에서 Redis 명령어를 여러 번 호출하는 방식으로 구현했습니다:</p>
<pre><code class="language-java">// 💥 문제가 있는 기존 코드
@Service
public class MatchingQueueManager {

    @Autowired
    private RedisTemplate&lt;String, String&gt; redisTemplate;

    public List&lt;String&gt; extractUsersForMatching(String queueKey) {
        // 1️⃣ 큐 크기 확인
        Long queueSize = redisTemplate.opsForList().size(queueKey);

        if (queueSize &lt; 3) {
            return Collections.emptyList();
        }

        // 2️⃣ 사용자 3명 추출
        List&lt;String&gt; matchedUsers = new ArrayList&lt;&gt;();
        for (int i = 0; i &lt; 3; i++) {
            String user = redisTemplate.opsForList().rightPop(queueKey);
            if (user != null) {
                matchedUsers.add(user);
            }
        }

        return matchedUsers;
    }
}</code></pre>
<h3 id="발생한-문제들">발생한 문제들</h3>
<p><strong>1. Race Condition 발생</strong></p>
<pre><code class="language-bash"># 동시에 2개 스레드가 실행될 때
Thread-1: LLEN queue:world1 → 4명 (3명 이상이므로 매칭 진행)
Thread-2: LLEN queue:world1 → 4명 (3명 이상이므로 매칭 진행)
Thread-1: RPOP queue:world1 → user1
Thread-2: RPOP queue:world1 → user2  
Thread-1: RPOP queue:world1 → user3
Thread-2: RPOP queue:world1 → user4
Thread-1: RPOP queue:world1 → null (큐 빔!)
Thread-2: RPOP queue:world1 → null (큐 빔!)

# 결과: Thread-1은 [user1, user3, null], Thread-2는 [user2, user4, null]
# 💥 둘 다 3명이 모이지 않아서 매칭 실패!</code></pre>
<p><strong>2. 네트워크 지연으로 인한 성능 저하</strong></p>
<ul>
<li>Redis 명령어를 4번 개별 호출 → 네트워크 왕복 4회</li>
<li>평균 매칭 시간: 3-5초</li>
</ul>
<p><strong>3. 부분 실패 시 데이터 정합성 문제</strong></p>
<ul>
<li>일부 사용자만 큐에서 제거되고 매칭은 실패하는 경우 발생</li>
</ul>
<h2 id="해결책-lua-스크립트로-원자성-보장">해결책: Lua 스크립트로 원자성 보장</h2>
<h3 id="lua-스크립트를-선택한-이유">Lua 스크립트를 선택한 이유</h3>
<p><strong>Redis + Lua의 핵심 장점</strong>:</p>
<ul>
<li><strong>원자성(Atomicity)</strong>: 스크립트 전체가 하나의 트랜잭션으로 실행</li>
<li><strong>성능</strong>: 네트워크 왕복 1회만 필요</li>
<li><strong>일관성</strong>: 중간에 다른 명령어가 끼어들 수 없음</li>
</ul>
<h3 id="핵심-lua-스크립트-구현">핵심 Lua 스크립트 구현</h3>
<pre><code class="language-lua">-- extractUsers.lua
-- 매칭 큐에서 정확히 3명을 원자적으로 추출하는 스크립트

local queueKey = KEYS[1]           -- 큐 키 (예: &quot;queue:world1&quot;)
local requiredCount = tonumber(ARGV[1]) or 3  -- 필요한 인원수 (기본값: 3)

-- 🔍 현재 큐 크기 확인
local queueSize = redis.call(&#39;LLEN&#39;, queueKey)

-- ⚠️ 인원이 부족하면 즉시 종료
if queueSize &lt; requiredCount then
    return {
        success = false,
        message = &quot;인원 부족&quot;,
        currentCount = queueSize,
        requiredCount = requiredCount
    }
end

-- ✅ 필요한 인원만큼 사용자 추출
local extractedUsers = {}
for i = 1, requiredCount do
    local user = redis.call(&#39;RPOP&#39;, queueKey)
    if user then
        table.insert(extractedUsers, user)
    else
        -- 이론적으로는 발생할 수 없지만 안전장치
        return {
            success = false,
            message = &quot;추출 중 오류 발생&quot;,
            extractedUsers = extractedUsers
        }
    end
end

-- 📊 추출 후 상태 정보
local remainingCount = redis.call(&#39;LLEN&#39;, queueKey)

return {
    success = true,
    extractedUsers = extractedUsers,
    remainingCount = remainingCount,
    timestamp = redis.call(&#39;TIME&#39;)[1]  -- Unix timestamp
}</code></pre>
<h3 id="대기열-추가를-위한-lua-스크립트">대기열 추가를 위한 Lua 스크립트</h3>
<pre><code class="language-lua">-- addToQueue.lua  
-- 중복 방지와 함께 사용자를 큐에 안전하게 추가

local queueKey = KEYS[1]        -- 큐 키
local userDataKey = KEYS[2]     -- 사용자 데이터 키 (Hash)
local userId = ARGV[1]          -- 사용자 ID
local userData = ARGV[2]        -- 사용자 데이터 (JSON)
local ttlSeconds = tonumber(ARGV[3]) or 3600  -- TTL (기본값: 1시간)

-- 🔍 이미 큐에 있는지 확인 (중복 방지)
local existingPosition = redis.call(&#39;LPOS&#39;, queueKey, userId)
if existingPosition then
    return {
        success = false,
        message = &quot;이미 큐에 대기 중입니다&quot;,
        position = existingPosition + 1,  -- 1부터 시작하는 위치
        queueSize = redis.call(&#39;LLEN&#39;, queueKey)
    }
end

-- ➕ 큐에 사용자 추가 (FIFO를 위해 LPUSH 사용)
redis.call(&#39;LPUSH&#39;, queueKey, userId)

-- 💾 사용자 데이터 저장 (매칭 시 필요한 정보)
redis.call(&#39;HSET&#39;, userDataKey, userId, userData)

-- ⏰ TTL 설정 (메모리 누수 방지)
redis.call(&#39;EXPIRE&#39;, queueKey, ttlSeconds)
redis.call(&#39;EXPIRE&#39;, userDataKey, ttlSeconds)

-- 📊 현재 상태 반환
local currentPosition = redis.call(&#39;LLEN&#39;, queueKey)
local queueSize = currentPosition

return {
    success = true,
    message = &quot;큐에 추가되었습니다&quot;,
    position = currentPosition,
    queueSize = queueSize,
    estimatedWaitTime = math.ceil(queueSize / 3) * 30  -- 추정 대기 시간 (초)
}</code></pre>
<h3 id="큐에서-제거를-위한-lua-스크립트">큐에서 제거를 위한 Lua 스크립트</h3>
<pre><code class="language-lua">-- removeFromQueue.lua
-- 사용자가 매칭 취소 시 큐에서 안전하게 제거

local queueKey = KEYS[1]
local userDataKey = KEYS[2]  
local userId = ARGV[1]

-- 🔍 큐에서 사용자 찾기
local position = redis.call(&#39;LPOS&#39;, queueKey, userId)
if not position then
    return {
        success = false,
        message = &quot;큐에서 찾을 수 없습니다&quot;
    }
end

-- ➖ 큐에서 제거 (LREM: 값으로 제거)
local removedCount = redis.call(&#39;LREM&#39;, queueKey, 1, userId)

-- 🗑️ 사용자 데이터도 제거
redis.call(&#39;HDEL&#39;, userDataKey, userId)

-- 📊 제거 후 상태
local remainingSize = redis.call(&#39;LLEN&#39;, queueKey)

return {
    success = removedCount &gt; 0,
    message = removedCount &gt; 0 and &quot;큐에서 제거되었습니다&quot; or &quot;제거 실패&quot;,
    previousPosition = position + 1,
    remainingQueueSize = remainingSize
}</code></pre>
<h2 id="java에서-lua-스크립트-실행">Java에서 Lua 스크립트 실행</h2>
<h3 id="redisscript-빈-설정">RedisScript 빈 설정</h3>
<pre><code class="language-java">@Configuration
public class RedisScriptConfig {

    /**
     * 사용자 추출 스크립트 빈
     */
    @Bean
    public RedisScript&lt;Map&gt; extractUsersScript() {
        DefaultRedisScript&lt;Map&gt; script = new DefaultRedisScript&lt;&gt;();
        script.setScriptSource(new ResourceScriptSource(
            new ClassPathResource(&quot;lua/extractUsers.lua&quot;)
        ));
        script.setResultType(Map.class);
        return script;
    }

    /**
     * 큐 추가 스크립트 빈  
     */
    @Bean
    public RedisScript&lt;Map&gt; addToQueueScript() {
        DefaultRedisScript&lt;Map&gt; script = new DefaultRedisScript&lt;&gt;();
        script.setScriptSource(new ResourceScriptSource(
            new ClassPathResource(&quot;lua/addToQueue.lua&quot;)
        ));
        script.setResultType(Map.class);
        return script;
    }

    /**
     * 큐 제거 스크립트 빈
     */
    @Bean
    public RedisScript&lt;Map&gt; removeFromQueueScript() {
        DefaultRedisScript&lt;Map&gt; script = new DefaultRedisScript&lt;&gt;();
        script.setScriptSource(new ResourceScriptSource(
            new ClassPathResource(&quot;lua/removeFromQueue.lua&quot;)
        ));
        script.setResultType(Map.class);
        return script;
    }
}</code></pre>
<h3 id="매칭-큐-매니저-서비스">매칭 큐 매니저 서비스</h3>
<pre><code class="language-java">@Service
@Slf4j
public class MatchingQueueManager {

    private final RedisTemplate&lt;String, String&gt; redisTemplate;
    private final RedisScript&lt;Map&gt; extractUsersScript;
    private final RedisScript&lt;Map&gt; addToQueueScript;
    private final RedisScript&lt;Map&gt; removeFromQueueScript;

    private static final String QUEUE_KEY_PREFIX = &quot;queue:&quot;;
    private static final String USER_DATA_KEY_PREFIX = &quot;queue_data:&quot;;

    public MatchingQueueManager(RedisTemplate&lt;String, String&gt; redisTemplate,
                               RedisScript&lt;Map&gt; extractUsersScript,
                               RedisScript&lt;Map&gt; addToQueueScript,
                               RedisScript&lt;Map&gt; removeFromQueueScript) {
        this.redisTemplate = redisTemplate;
        this.extractUsersScript = extractUsersScript;
        this.addToQueueScript = addToQueueScript;
        this.removeFromQueueScript = removeFromQueueScript;
    }

    /**
     * 매칭을 위한 사용자 추출 (원자적)
     */
    public MatchingResult extractUsersForMatching(WorldType worldType, int requiredCount) {
        String queueKey = QUEUE_KEY_PREFIX + worldType.name().toLowerCase();

        try {
            // 🚀 Lua 스크립트 실행 (원자적)
            Map&lt;String, Object&gt; result = redisTemplate.execute(
                extractUsersScript,
                Collections.singletonList(queueKey),
                String.valueOf(requiredCount)
            );

            return parseMatchingResult(result, worldType);

        } catch (Exception e) {
            log.error(&quot;매칭 추출 중 오류 발생: worldType={}, requiredCount={}&quot;, 
                     worldType, requiredCount, e);
            return MatchingResult.failure(&quot;매칭 처리 중 오류 발생&quot;);
        }
    }

    /**
     * 큐에 사용자 추가
     */
    public QueueAddResult addToQueue(MatchingRequest request) {
        String queueKey = QUEUE_KEY_PREFIX + request.getWorldType().name().toLowerCase();
        String userDataKey = USER_DATA_KEY_PREFIX + request.getWorldType().name().toLowerCase();

        try {
            // 📝 사용자 데이터를 JSON으로 직렬화
            String userData = objectMapper.writeValueAsString(request);

            // 🚀 Lua 스크립트 실행
            Map&lt;String, Object&gt; result = redisTemplate.execute(
                addToQueueScript,
                Arrays.asList(queueKey, userDataKey),
                request.getMemberId(),
                userData,
                &quot;3600&quot;  // 1시간 TTL
            );

            return parseQueueAddResult(result);

        } catch (Exception e) {
            log.error(&quot;큐 추가 중 오류 발생: memberId={}, worldType={}&quot;, 
                     request.getMemberId(), request.getWorldType(), e);
            return QueueAddResult.failure(&quot;큐 추가 중 오류 발생&quot;);
        }
    }

    /**
     * 큐에서 사용자 제거
     */
    public QueueRemoveResult removeFromQueue(String memberId, WorldType worldType) {
        String queueKey = QUEUE_KEY_PREFIX + worldType.name().toLowerCase();
        String userDataKey = USER_DATA_KEY_PREFIX + worldType.name().toLowerCase();

        try {
            // 🚀 Lua 스크립트 실행
            Map&lt;String, Object&gt; result = redisTemplate.execute(
                removeFromQueueScript,
                Arrays.asList(queueKey, userDataKey),
                memberId
            );

            return parseQueueRemoveResult(result);

        } catch (Exception e) {
            log.error(&quot;큐 제거 중 오류 발생: memberId={}, worldType={}&quot;, 
                     memberId, worldType, e);
            return QueueRemoveResult.failure(&quot;큐 제거 중 오류 발생&quot;);
        }
    }

    // === 결과 파싱 메서드들 ===

    private MatchingResult parseMatchingResult(Map&lt;String, Object&gt; result, WorldType worldType) {
        Boolean success = (Boolean) result.get(&quot;success&quot;);

        if (!success) {
            String message = (String) result.get(&quot;message&quot;);
            Integer currentCount = (Integer) result.get(&quot;currentCount&quot;);
            Integer requiredCount = (Integer) result.get(&quot;requiredCount&quot;);

            log.info(&quot;매칭 실패: worldType={}, 현재={}, 필요={}, 사유={}&quot;, 
                    worldType, currentCount, requiredCount, message);

            return MatchingResult.waiting(currentCount, requiredCount, message);
        }

        // 성공한 경우
        List&lt;String&gt; extractedUsers = (List&lt;String&gt;) result.get(&quot;extractedUsers&quot;);
        Integer remainingCount = (Integer) result.get(&quot;remainingCount&quot;);
        String timestamp = (String) result.get(&quot;timestamp&quot;);

        log.info(&quot;✅ 매칭 성공: worldType={}, 추출된사용자={}, 남은인원={}&quot;, 
                worldType, extractedUsers.size(), remainingCount);

        return MatchingResult.success(extractedUsers, remainingCount, timestamp);
    }

    private QueueAddResult parseQueueAddResult(Map&lt;String, Object&gt; result) {
        Boolean success = (Boolean) result.get(&quot;success&quot;);
        String message = (String) result.get(&quot;message&quot;);
        Integer position = (Integer) result.get(&quot;position&quot;);
        Integer queueSize = (Integer) result.get(&quot;queueSize&quot;);
        Integer estimatedWaitTime = (Integer) result.get(&quot;estimatedWaitTime&quot;);

        return QueueAddResult.builder()
                .success(success)
                .message(message)
                .position(position)
                .queueSize(queueSize)
                .estimatedWaitTime(estimatedWaitTime)
                .build();
    }

    private QueueRemoveResult parseQueueRemoveResult(Map&lt;String, Object&gt; result) {
        Boolean success = (Boolean) result.get(&quot;success&quot;);
        String message = (String) result.get(&quot;message&quot;);
        Integer remainingSize = (Integer) result.get(&quot;remainingQueueSize&quot;);

        return QueueRemoveResult.builder()
                .success(success)
                .message(message)
                .remainingQueueSize(remainingSize)
                .build();
    }
}</code></pre>
<h2 id="실제-매칭-프로세스-플로우">실제 매칭 프로세스 플로우</h2>
<h3 id="1-사용자-큐-참여">1. 사용자 큐 참여</h3>
<pre><code class="language-java">@PostMapping(&quot;/matching/join&quot;)
public RsData&lt;QueueAddResult&gt; joinMatchingQueue(@Valid @RequestBody MatchingRequest request) {
    // 🔐 사용자 인증 확인
    String memberId = getCurrentMemberId();
    request.setMemberId(memberId);

    // ➕ 큐에 추가 (Lua 스크립트로 중복 확인 + 추가)
    QueueAddResult result = matchingQueueManager.addToQueue(request);

    if (result.isSuccess()) {
        // 📢 WebSocket으로 대기 상태 알림
        webSocketService.sendQueueStatusUpdate(memberId, result);

        log.info(&quot;큐 참여 성공: memberId={}, worldType={}, position={}&quot;, 
                memberId, request.getWorldType(), result.getPosition());
    }

    return RsData.of(result.isSuccess() ? &quot;200&quot; : &quot;400&quot;, 
                    result.getMessage(), result);
}</code></pre>
<h3 id="2-백그라운드-매칭-프로세서">2. 백그라운드 매칭 프로세서</h3>
<pre><code class="language-java">@Component
@Slf4j
public class MatchingProcessor {

    private final MatchingQueueManager queueManager;
    private final GameSessionService gameSessionService;
    private final WebSocketService webSocketService;

    /**
     * 주기적으로 모든 월드 타입의 매칭 처리
     */
    @Scheduled(fixedDelay = 1000) // 1초마다 실행
    public void processMatching() {
        for (WorldType worldType : WorldType.values()) {
            try {
                processMatchingForWorld(worldType);
            } catch (Exception e) {
                log.error(&quot;매칭 처리 중 오류: worldType={}&quot;, worldType, e);
            }
        }
    }

    private void processMatchingForWorld(WorldType worldType) {
        // 🎯 3명 추출 시도 (Lua 스크립트로 원자적 처리)
        MatchingResult result = queueManager.extractUsersForMatching(worldType, 3);

        if (result.isSuccess()) {
            // ✅ 매칭 성공 → 게임 세션 생성
            List&lt;String&gt; participants = result.getExtractedUsers();

            log.info(&quot;🎮 매칭 성공: worldType={}, participants={}&quot;, worldType, participants);

            // 게임 세션 생성
            GameSession gameSession = gameSessionService.createGameSession(worldType, participants);

            // 📢 참여자들에게 매칭 완료 알림
            MatchingCompleteEvent event = MatchingCompleteEvent.builder()
                    .gameSessionId(gameSession.getId())
                    .worldType(worldType)
                    .participants(participants)
                    .roomIds(gameSession.getRoomIds())
                    .build();

            // WebSocket으로 각 참여자에게 알림
            participants.forEach(memberId -&gt; {
                webSocketService.sendMatchingComplete(memberId, event);
            });

            // 📊 매칭 통계 업데이트
            matchingStatisticsService.recordSuccessfulMatch(worldType, participants.size());

        } else if (result.isWaiting()) {
            // ⏳ 대기 중 (인원 부족)
            log.debug(&quot;매칭 대기: worldType={}, 현재={}, 필요={}&quot;, 
                     worldType, result.getCurrentCount(), result.getRequiredCount());
        }
    }
}</code></pre>
<h3 id="3-실시간-큐-상태-모니터링">3. 실시간 큐 상태 모니터링</h3>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/v1/matching&quot;)
public class MatchingStatusController {

    private final MatchingQueueManager queueManager;

    /**
     * 특정 월드의 현재 큐 상태 조회
     */
    @GetMapping(&quot;/queue-status/{worldType}&quot;)
    public RsData&lt;QueueStatusResponse&gt; getQueueStatus(@PathVariable WorldType worldType) {
        try {
            QueueStatusResponse status = queueManager.getQueueStatus(worldType);
            return RsData.of(&quot;200&quot;, &quot;큐 상태 조회 성공&quot;, status);
        } catch (Exception e) {
            log.error(&quot;큐 상태 조회 실패: worldType={}&quot;, worldType, e);
            return RsData.of(&quot;500&quot;, &quot;큐 상태 조회 중 오류 발생&quot;, null);
        }
    }

    /**
     * 내 현재 대기 상태 조회
     */
    @GetMapping(&quot;/my-status&quot;) 
    public RsData&lt;List&lt;MyQueueStatusResponse&gt;&gt; getMyQueueStatus() {
        String memberId = getCurrentMemberId();

        List&lt;MyQueueStatusResponse&gt; myStatus = Arrays.stream(WorldType.values())
                .map(worldType -&gt; queueManager.getMyQueueStatus(memberId, worldType))
                .filter(Objects::nonNull)
                .collect(Collectors.toList());

        return RsData.of(&quot;200&quot;, &quot;내 대기 상태 조회 성공&quot;, myStatus);
    }
}</code></pre>
<h2 id="성능-개선-결과">성능 개선 결과</h2>
<h3 id="before-vs-after-비교">Before vs After 비교</h3>
<p><strong>매칭 처리 시간</strong>:</p>
<pre><code>Before (Java 다중 호출): 평균 3-5초, 최대 10초
After (Lua 스크립트):   평균 0.5초, 최대 1초
→ 80-90% 성능 향상</code></pre><p><strong>Race Condition 발생률</strong>:</p>
<pre><code>Before: 동시 접속 50명 이상 시 15-20% 발생
After:  동시 접속 200명에서도 0% 발생
→ 완전 해결</code></pre><p><strong>네트워크 호출 수</strong>:</p>
<pre><code>Before: 매칭 1회당 4-6번의 Redis 명령어 호출
After:  매칭 1회당 1번의 Lua 스크립트 호출
→ 75% 감소</code></pre><h3 id="실제-운영-지표">실제 운영 지표</h3>
<p><strong>매칭 성공률</strong>:</p>
<ul>
<li>Before: 85-90% (Race Condition으로 인한 실패)</li>
<li>After: 99.8% (네트워크 오류 등 외부 요인만 존재)</li>
</ul>
<p><strong>사용자 만족도</strong>:</p>
<ul>
<li>&quot;매칭이 안 돼요&quot; 불만: 90% 감소</li>
<li>평균 대기 시간 체감도: 70% 개선</li>
</ul>
<h2 id="안정성과-에러-처리">안정성과 에러 처리</h2>
<h3 id="lua-스크립트-에러-처리">Lua 스크립트 에러 처리</h3>
<pre><code class="language-lua">-- 안전한 사용자 추출 (에러 처리 포함)
local function safeExtractUsers(queueKey, requiredCount)
    local success, result = pcall(function()
        local queueSize = redis.call(&#39;LLEN&#39;, queueKey)

        if queueSize &lt; requiredCount then
            return {
                success = false,
                message = &quot;인원 부족&quot;,
                currentCount = queueSize
            }
        end

        local extractedUsers = {}
        for i = 1, requiredCount do
            local user = redis.call(&#39;RPOP&#39;, queueKey)
            if not user then
                -- 예상치 못한 상황: 큐가 중간에 비어짐
                -- 이미 추출한 사용자들을 다시 큐에 넣기
                for j = 1, #extractedUsers do
                    redis.call(&#39;RPUSH&#39;, queueKey, extractedUsers[j])
                end

                return {
                    success = false,
                    message = &quot;추출 중 오류 발생 (롤백 완료)&quot;,
                    extractedUsers = {}
                }
            end

            table.insert(extractedUsers, user)
        end

        return {
            success = true,
            extractedUsers = extractedUsers,
            remainingCount = redis.call(&#39;LLEN&#39;, queueKey)
        }
    end)

    if not success then
        return {
            success = false,
            message = &quot;스크립트 실행 오류: &quot; .. tostring(result)
        }
    end

    return result
end

-- 메인 로직에서 안전한 함수 사용
return safeExtractUsers(KEYS[1], tonumber(ARGV[1]) or 3)</code></pre>
<h3 id="java에서의-fallback-처리">Java에서의 Fallback 처리</h3>
<pre><code class="language-java">public MatchingResult extractUsersForMatching(WorldType worldType, int requiredCount) {
    String queueKey = QUEUE_KEY_PREFIX + worldType.name().toLowerCase();

    try {
        // 1차 시도: Lua 스크립트 실행
        Map&lt;String, Object&gt; result = redisTemplate.execute(
            extractUsersScript,
            Collections.singletonList(queueKey),
            String.valueOf(requiredCount)
        );

        return parseMatchingResult(result, worldType);

    } catch (RedisConnectionFailureException e) {
        // Redis 연결 실패
        log.error(&quot;Redis 연결 실패, 매칭 서비스 일시 중단: worldType={}&quot;, worldType, e);
        return MatchingResult.failure(&quot;매칭 서비스 일시 중단, 잠시 후 다시 시도해주세요&quot;);

    } catch (RedisScriptException e) {
        // Lua 스크립트 실행 오류  
        log.error(&quot;Lua 스크립트 실행 오류: worldType={}, 스크립트 오류={}&quot;, worldType, e.getMessage(), e);

        // 2차 시도: 전통적인 Java 방식으로 fallback
        return extractUsersWithJavaFallback(queueKey, requiredCount, worldType);

    } catch (Exception e) {
        log.error(&quot;예상치 못한 오류: worldType={}&quot;, worldType, e);
        return MatchingResult.failure(&quot;매칭 처리 중 오류 발생&quot;);
    }
}

/**
 * Lua 스크립트 실패 시 Java로 fallback 처리
 * (성능은 떨어지지만 서비스 중단 방지)
 */
private MatchingResult extractUsersWithJavaFallback(String queueKey, int requiredCount, WorldType worldType) {
    try {
        log.warn(&quot;Java fallback 모드로 매칭 처리: worldType={}&quot;, worldType);

        // 분산 락을 이용해 동시성 제어
        String lockKey = &quot;lock:matching:&quot; + worldType.name();
        Boolean lockAcquired = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, &quot;1&quot;, Duration.ofSeconds(10));

        if (!lockAcquired) {
            return MatchingResult.waiting(0, requiredCount, &quot;다른 매칭 처리 진행 중&quot;);
        }

        try {
            Long queueSize = redisTemplate.opsForList().size(queueKey);
            if (queueSize &lt; requiredCount) {
                return MatchingResult.waiting(queueSize.intValue(), requiredCount, &quot;인원 부족&quot;);
            }

            List&lt;String&gt; extractedUsers = new ArrayList&lt;&gt;();
            for (int i = 0; i &lt; requiredCount; i++) {
                String user = redisTemplate.opsForList().rightPop(queueKey);
                if (user != null) {
                    extractedUsers.add(user);
                }
            }

            return MatchingResult.success(extractedUsers, 
                    Math.max(0, queueSize.intValue() - requiredCount), 
                    String.valueOf(System.currentTimeMillis() / 1000));

        } finally {
            // 락 해제
            redisTemplate.delete(lockKey);
        }

    } catch (Exception e) {
        log.error(&quot;Java fallback도 실패: worldType={}&quot;, worldType, e);
        return MatchingResult.failure(&quot;매칭 서비스 장애&quot;);
    }
}</code></pre>
<h2 id="운영-및-모니터링">운영 및 모니터링</h2>
<h3 id="redis-메모리-관리">Redis 메모리 관리</h3>
<pre><code class="language-java">/**
 * 주기적으로 만료된 큐 데이터 정리
 */
@Scheduled(cron = &quot;0 0 * * * *&quot;) // 매 시간마다 실행
public void cleanupExpiredQueueData() {
    for (WorldType worldType : WorldType.values()) {
        String queueKey = QUEUE_KEY_PREFIX + worldType.name().toLowerCase();
        String userDataKey = USER_DATA_KEY_PREFIX + worldType.name().toLowerCase();

        try {
            // TTL이 설정되지 않은 키들을 찾아서 TTL 설정
            Long queueTTL = redisTemplate.getExpire(queueKey);
            if (queueTTL == -1) { // TTL이 설정되지 않음
                redisTemplate.expire(queueKey, Duration.ofHours(1));
                log.warn(&quot;TTL 미설정 큐 발견하여 수정: queueKey={}&quot;, queueKey);
            }

            Long userDataTTL = redisTemplate.getExpire(userDataKey);
            if (userDataTTL == -1) {
                redisTemplate.expire(userDataKey, Duration.ofHours(1));  
                log.warn(&quot;TTL 미설정 사용자 데이터 발견하여 수정: userDataKey={}&quot;, userDataKey);
            }

        } catch (Exception e) {
            log.error(&quot;큐 데이터 정리 중 오류: worldType={}&quot;, worldType, e);
        }
    }
}</code></pre>
<h3 id="매칭-성능-모니터링">매칭 성능 모니터링</h3>
<pre><code class="language-java">@Component
public class MatchingPerformanceMonitor {

    private final MeterRegistry meterRegistry;
    private final Counter matchingSuccessCounter;
    private final Counter matchingFailureCounter;
    private final Timer matchingProcessingTimer;

    public MatchingPerformanceMonitor(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.matchingSuccessCounter = Counter.builder(&quot;matching.success&quot;)
                .description(&quot;매칭 성공 횟수&quot;)
                .register(meterRegistry);
        this.matchingFailureCounter = Counter.builder(&quot;matching.failure&quot;)
                .description(&quot;매칭 실패 횟수&quot;)
                .register(meterRegistry);
        this.matchingProcessingTimer = Timer.builder(&quot;matching.processing.time&quot;)
                .description(&quot;매칭 처리 시간&quot;)
                .register(meterRegistry);
    }

    public MatchingResult monitoredExtractUsers(WorldType worldType, int requiredCount) {
        return Timer.Sample.start(meterRegistry)
                .stop(matchingProcessingTimer.timer(&quot;world&quot;, worldType.name()))
                .recordCallable(() -&gt; {
                    MatchingResult result = actualExtractUsers(worldType, requiredCount);

                    if (result.isSuccess()) {
                        matchingSuccessCounter.increment(
                            Tags.of(&quot;world&quot;, worldType.name())
                        );
                    } else {
                        matchingFailureCounter.increment(
                            Tags.of(&quot;world&quot;, worldType.name(), &quot;reason&quot;, result.getMessage())
                        );
                    }

                    return result;
                });
    }
}</code></pre>
<h2 id="핵심-배운점과-팁">핵심 배운점과 팁</h2>
<h3 id="1-lua-스크립트-작성-시-주의사항">1. <strong>Lua 스크립트 작성 시 주의사항</strong></h3>
<pre><code class="language-lua">-- ✅ 좋은 예: 명확한 변수명과 주석
local queueKey = KEYS[1]  -- 큐 식별자
local requiredCount = tonumber(ARGV[1]) or 3  -- 기본값 설정

-- ❌ 나쁜 예: 의미불명한 변수명
local k1 = KEYS[1]
local a1 = ARGV[1]</code></pre>
<h3 id="2-에러-처리는-필수">2. <strong>에러 처리는 필수</strong></h3>
<pre><code class="language-lua">-- 모든 Redis 명령어를 pcall로 감싸기
local success, result = pcall(function()
    return redis.call(&#39;LLEN&#39;, queueKey)
end)

if not success then
    return { success = false, message = &quot;큐 조회 실패&quot; }
end</code></pre>
<h3 id="3-성능-최적화-팁">3. <strong>성능 최적화 팁</strong></h3>
<ul>
<li><strong>배치 처리</strong>: 여러 명령어를 하나의 스크립트로 묶기</li>
<li><strong>조기 반환</strong>: 조건 확인 후 빠른 종료</li>
<li><strong>메모리 효율</strong>: 불필요한 변수 선언 피하기</li>
</ul>
<h3 id="4-테스트-방법">4. <strong>테스트 방법</strong></h3>
<pre><code class="language-bash"># Redis CLI에서 직접 스크립트 테스트
redis-cli --eval extractUsers.lua queue:world1 , 3

# 결과 예시
1) &quot;success&quot;
2) (integer) 1
3) &quot;extractedUsers&quot;  
4) 1) &quot;user123&quot;
   2) &quot;user456&quot; 
   3) &quot;user789&quot;</code></pre>
<h2 id="결론">결론</h2>
<p>Redis + Lua 스크립트 조합은 <strong>실시간 매칭 시스템의 동시성 문제를 완벽히 해결</strong>할 수 있는 강력한 도구입니다.</p>
<p><strong>앞으로도 활용하고 싶은 영역</strong>:</p>
<ul>
<li>분산 락 구현</li>
<li>실시간 순위 시스템</li>
<li>채팅방 메시지 처리</li>
<li>동적 설정 관리</li>
</ul>
<p>Redis의 Lua 스크립트는 처음엔 낯설 수 있지만, <strong>원자성이 필요한 모든 상황에서 게임 체인저</strong>가 될 수 있습니다. 특히 실시간 서비스나 동시성이 중요한 시스템이라면 꼭 고려해볼 만한 기술인 것 같습니다 </p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[DungeonTalk 백엔드 개발 STAR 회고록]]></title>
            <link>https://velog.io/@mj_o/DungeonTalk-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-STAR-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@mj_o/DungeonTalk-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-STAR-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Thu, 14 Aug 2025 08:02:35 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Situation-Task-Action-Result 방식으로 정리한 1개월 개발 여정</p>
</blockquote>
<h2 id="개요">개요</h2>
<p>1개월간 DungeonTalk 백엔드 시스템을 개발하며 경험한 주요 상황들을 STAR 방식으로 정리했습니다. 각 상황에서 어떤 과제를 받았고, 어떻게 행동했으며, 어떤 결과를 얻었는지 구체적으로 기록하여 향후 개발에 참고할 수 있도록 했습니다.</p>
<hr>
<h2 id="star-회고-1-멀티-데이터베이스-아키텍처-설계">STAR 회고 1: 멀티 데이터베이스 아키텍처 설계</h2>
<h3 id="situation-상황">Situation (상황)</h3>
<ul>
<li><strong>시기</strong>: 프로젝트 초기 </li>
<li><strong>배경</strong>: AI 채팅, 매칭, 룸 관리 기능을 하나의 시스템에 구현해야 하는 상황</li>
<li><strong>문제</strong>: 각 도메인마다 다른 데이터 특성 (관계형 데이터, 메시지 데이터, 캐시 데이터)</li>
<li><strong>제약</strong>: Spring Boot 기반 단일 애플리케이션으로 구성해야 함</li>
</ul>
<h3 id="task-과제">Task (과제)</h3>
<ul>
<li><strong>주요 과제</strong>: 서로 다른 특성의 데이터를 효율적으로 저장하고 관리할 수 있는 아키텍처 설계</li>
<li><strong>세부 요구사항</strong>:<ul>
<li>사용자 인증/권한은 관계형 데이터베이스 (PostgreSQL)</li>
<li>AI 게임 메시지는 도큐먼트 데이터베이스 (MongoDB)</li>
<li>매칭 큐와 세션은 인메모리 데이터베이스 (Redis/Valkey)</li>
</ul>
</li>
<li><strong>성능 목표</strong>: 동시 접속자 100명 이상 지원</li>
</ul>
<h3 id="action-행동">Action (행동)</h3>
<ol>
<li><p><strong>기술 스택 조사 및 선정</strong>:</p>
<pre><code class="language-java">// PostgreSQL - JPA 설정
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    // 관계형 데이터 구조
}

// MongoDB - Document 설정  
@Document(collection = &quot;ai_game_messages&quot;)
public class AiGameMessage {
    @Id private String id;
    // 비정형 메시지 데이터
}

// Redis - 캐시 설정
@RedisHash(&quot;game_session&quot;)
public class GameSession {
    @Id private String sessionId;
    // 휘발성 세션 데이터
}</code></pre>
</li>
<li><p><strong>데이터베이스별 설정 분리</strong>:</p>
<pre><code class="language-yaml"># application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/dungeondb
  data:
    mongodb:
      uri: mongodb://localhost:27017/dungeondb
  data:
    redis:
      host: localhost
      port: 6379</code></pre>
</li>
<li><p><strong>트랜잭션 관리 전략 수립</strong>:</p>
<ul>
<li>PostgreSQL: <code>@Transactional</code> 적용</li>
<li>MongoDB: 개별 도큐먼트 원자성 활용</li>
<li>Redis: Lua 스크립트로 원자성 보장</li>
</ul>
</li>
</ol>
<h3 id="result-결과">Result (결과)</h3>
<ul>
<li><strong>성능 향상</strong>: <ul>
<li>메시지 조회 속도 70% 향상 (MongoDB 인덱싱)</li>
<li>매칭 처리 시간 50% 단축 (Redis 인메모리 처리)</li>
</ul>
</li>
<li><strong>개발 생산성</strong>: <ul>
<li>각 도메인별 최적화된 데이터 모델링</li>
<li>개발자간 데이터베이스 역할 분담 가능</li>
</ul>
</li>
<li><strong>확장성</strong>: <ul>
<li>새로운 기능 추가 시 적절한 DB 선택 가능</li>
<li>각 DB별 독립적 스케일링 가능</li>
</ul>
</li>
</ul>
<hr>
<h2 id="star-회고-2-동시-게임-시작-오류-해결">STAR 회고 2: 동시 게임 시작 오류 해결</h2>
<h3 id="situation-상황-1">Situation (상황)</h3>
<ul>
<li><strong>시기</strong>: QA 테스트 중</li>
<li><strong>문제 발생</strong>: 매칭 완료 후 3명이 동시에 게임을 시작할 때 2명이 401 에러로 실패</li>
<li><strong>영향도</strong>: 사용자 경험 저하, 매칭 시스템 전체 신뢰성 문제</li>
<li><strong>로그 상황</strong>:<pre><code> 게임방 상태가 잘못됨: roomId=..., status=ACTIVE, expected=CREATED
JWT 인증[필터] 중 오류 발생: AiChatException</code></pre></li>
</ul>
<h3 id="task-과제-1">Task (과제)</h3>
<ul>
<li><strong>주요 과제</strong>: 동시성 문제로 인한 게임 시작 실패 해결</li>
<li><strong>기술적 요구사항</strong>: <ul>
<li>Race Condition 해결</li>
<li>모든 참여자가 정상적으로 게임 참여할 수 있도록 보장</li>
</ul>
</li>
<li><strong>제약사항</strong>: 기존 API 구조 유지, 성능 저하 최소화</li>
</ul>
<h3 id="action-행동-1">Action (행동)</h3>
<ol>
<li><p><strong>문제 분석 및 원인 파악</strong>:</p>
<pre><code class="language-java">// 문제가 된 기존 코드
if (room.getStatus() != AiGameStatus.CREATED) {
    log.warn(&quot;🎮 게임방 상태가 잘못됨: roomId={}, status={}&quot;, roomId, room.getStatus());
    throw new AiChatException(ErrorCode.AI_GAME_ROOM_INVALID_STATE);
}</code></pre>
</li>
<li><p><strong>서버 측 방어적 프로그래밍 적용</strong>:</p>
<pre><code class="language-java">// 개선된 코드
if (room.getStatus() == AiGameStatus.ACTIVE) {
    log.info(&quot;🎮 게임방이 이미 활성화됨: roomId={}&quot;, roomId);
    return AiGameRoomResponse.fromEntity(room); // 성공으로 처리
}

if (room.getStatus() != AiGameStatus.CREATED) {
    log.warn(&quot;🎮 게임방 상태가 잘못됨: roomId={}, status={}&quot;, roomId, room.getStatus());
    throw new AiChatException(ErrorCode.AI_GAME_ROOM_INVALID_STATE);
}</code></pre>
</li>
<li><p><strong>클라이언트 측 중복 요청 방지</strong>:</p>
<pre><code class="language-javascript">let gameSessionStarted = false;

async function startGameSession(matchData) {
    if (gameSessionStarted) {
        console.log(&#39;🔄 게임 세션이 이미 시작됨, 중복 요청 무시&#39;);
        return;
    }
    gameSessionStarted = true;
    // ... 게임 시작 로직
}</code></pre>
</li>
<li><p><strong>상세한 로그 추가 및 모니터링 강화</strong>:</p>
<pre><code class="language-java">log.info(&quot;🎮 게임 시작 요청: roomId={}, memberId={}, timestamp={}&quot;, 
         roomId, memberId, Instant.now());</code></pre>
</li>
</ol>
<h3 id="result-결과-1">Result (결과)</h3>
<ul>
<li><strong>기능 개선</strong>: <ul>
<li>동시 게임 시작 성공률 100% 달성</li>
<li>사용자 경험 크게 개선</li>
</ul>
</li>
<li><strong>안정성 향상</strong>: <ul>
<li>Race Condition 완전 해결</li>
<li>에러 로그 90% 감소</li>
</ul>
</li>
<li><strong>개발 역량 성장</strong>: <ul>
<li>동시성 프로그래밍 이해도 향상</li>
<li>방어적 프로그래밍 습관 체득</li>
</ul>
</li>
<li><strong>운영 개선</strong>: <ul>
<li>문제 감지 시간 단축 (상세 로그)</li>
<li>자동 복구 메커니즘 구축</li>
</ul>
</li>
</ul>
<hr>
<h2 id="star-회고-3-redis-매칭-시스템-성능-최적화">STAR 회고 3: Redis 매칭 시스템 성능 최적화</h2>
<h3 id="situation-상황-2">Situation (상황)</h3>
<ul>
<li><strong>시기</strong>: 매칭 시스템 구현 중</li>
<li><strong>성능 문제</strong>: 매칭 처리 시간 평균 3-5초, 때로는 타임아웃 발생</li>
<li><strong>사용자 피드백</strong>: &quot;매칭이 너무 느려요&quot;, &quot;매칭이 안 돼요&quot;</li>
<li><strong>시스템 상황</strong>: 동시 매칭 대기 사용자 50명 이상 시 성능 급격히 저하</li>
</ul>
<h3 id="task-과제-2">Task (과제)</h3>
<ul>
<li><strong>주요 과제</strong>: 매칭 처리 성능을 1초 이내로 단축</li>
<li><strong>기술적 목표</strong>: <ul>
<li>공정한 매칭 알고리즘 유지</li>
<li>동시성 문제 해결 (Race Condition 방지)</li>
<li>확장 가능한 구조로 개선</li>
</ul>
</li>
<li><strong>제약사항</strong>: Redis 기반 구조 유지</li>
</ul>
<h3 id="action-행동-2">Action (행동)</h3>
<ol>
<li><p><strong>기존 매칭 로직 분석</strong>:</p>
<pre><code class="language-java">// 문제가 있던 기존 코드 (Java에서 여러 번 Redis 호출)
List&lt;String&gt; queueUsers = redisTemplate.opsForList().range(QUEUE_KEY, 0, -1);
if (queueUsers.size() &gt;= 3) {
    String user1 = redisTemplate.opsForList().rightPop(QUEUE_KEY);
    String user2 = redisTemplate.opsForList().rightPop(QUEUE_KEY);
    String user3 = redisTemplate.opsForList().rightPop(QUEUE_KEY);
}</code></pre>
</li>
<li><p><strong>Lua 스크립트로 원자적 처리 구현</strong>:</p>
<pre><code class="language-lua">-- extractUsers.lua
local queueKey = KEYS[1]
local queueSize = redis.call(&#39;LLEN&#39;, queueKey)

if queueSize &lt; 3 then
    return nil
end

local users = {}
for i = 1, 3 do
    local user = redis.call(&#39;RPOP&#39;, queueKey)
    table.insert(users, user)
end
return users</code></pre>
</li>
<li><p><strong>Java에서 Lua 스크립트 실행</strong>:</p>
<pre><code class="language-java">@Component
public class MatchingQueueManager {
    private final RedisScript&lt;List&gt; extractUsersScript;

    public List&lt;String&gt; extractUsersForMatching(String queueKey) {
        List&lt;String&gt; result = redisTemplate.execute(
            extractUsersScript, 
            Collections.singletonList(queueKey)
        );
        return result != null ? result : Collections.emptyList();
    }
}</code></pre>
</li>
<li><p><strong>TTL 기반 자동 정리 시스템 구현</strong>:</p>
<pre><code class="language-java">// 매칭 데이터에 TTL 설정
redisTemplate.expire(userKey, Duration.ofSeconds(3600)); // 1시간
redisTemplate.expire(queueKey, Duration.ofSeconds(1800)); // 30분</code></pre>
</li>
</ol>
<h3 id="result-결과-2">Result (결과)</h3>
<ul>
<li><strong>성능 대폭 개선</strong>: <ul>
<li>매칭 처리 시간 3-5초 → 0.5초 이내</li>
<li>타임아웃 발생율 거의 0%</li>
</ul>
</li>
<li><strong>동시성 문제 완전 해결</strong>: <ul>
<li>Race Condition 발생 0건</li>
<li>중복 매칭/빠진 매칭 발생 0건</li>
</ul>
</li>
<li><strong>시스템 안정성 향상</strong>: <ul>
<li>Redis 메모리 사용량 70% 감소 (TTL 적용)</li>
<li>매칭 성공률 95% → 99.8%</li>
</ul>
</li>
<li><strong>사용자 만족도 향상</strong>: <ul>
<li>매칭 대기 시간 불만 90% 감소</li>
<li>실시간 게임 참여 활발해짐</li>
</ul>
</li>
</ul>
<hr>
<h2 id="star-회고-4-factory-패턴으로-통합-룸-시스템-구축">STAR 회고 4: Factory 패턴으로 통합 룸 시스템 구축</h2>
<h3 id="situation-상황-3">Situation (상황)</h3>
<ul>
<li><strong>시기</strong>:  각 도메인 기능 완성 후 통합 단계</li>
<li><strong>문제</strong>: AI 게임룸과 채팅룸이 서로 다른 API와 서비스로 분리됨</li>
<li><strong>복잡성</strong>: 매칭 시스템에서 룸 생성 시 각각 다른 서비스 호출 필요</li>
<li><strong>중복성</strong>: 비슷한 룸 관리 로직이 여러 곳에 산재</li>
</ul>
<h3 id="task-과제-3">Task (과제)</h3>
<ul>
<li><strong>주요 과제</strong>: 서로 다른 룸 타입을 하나의 통합 인터페이스로 관리</li>
<li><strong>기술적 요구사항</strong>: <ul>
<li>기존 서비스 코드 수정 최소화</li>
<li>새로운 룸 타입 추가 시 확장 용이성 보장</li>
<li>일관된 API 제공</li>
</ul>
</li>
<li><strong>설계 목표</strong>: Factory와 Adapter 패턴 활용</li>
</ul>
<h3 id="action-행동-3">Action (행동)</h3>
<ol>
<li><p><strong>통합 인터페이스 설계</strong>:</p>
<pre><code class="language-java">public interface RoomService {
    RoomType getSupportedRoomType();
    UnifiedRoomResponse createRoom(UnifiedRoomRequest request);
    UnifiedRoomResponse getRoom(String roomId);
    void processMessage(UnifiedMessageRequest request);
    // ... 기타 공통 메서드
}</code></pre>
</li>
<li><p><strong>Factory 패턴 구현</strong>:</p>
<pre><code class="language-java">@Component
public class RoomServiceFactory {
    private final List&lt;RoomService&gt; roomServices;
    private Map&lt;RoomType, RoomService&gt; serviceMap;

    public RoomService getService(RoomType roomType) {
        initializeServiceMap();
        RoomService service = serviceMap.get(roomType);
        if (service == null) {
            throw new IllegalArgumentException(&quot;지원하지 않는 룸 타입: &quot; + roomType);
        }
        return service;
    }

    private void initializeServiceMap() {
        if (serviceMap == null) {
            serviceMap = roomServices.stream()
                .collect(Collectors.toMap(
                    RoomService::getSupportedRoomType,
                    Function.identity()
                ));
        }
    }
}</code></pre>
</li>
<li><p><strong>Adapter 패턴으로 기존 서비스 연동</strong>:</p>
<pre><code class="language-java">@Service
public class AiGameRoomServiceAdapter implements RoomService {
    private final AiGameRoomService aiGameRoomService;

    @Override
    public RoomType getSupportedRoomType() {
        return RoomType.AI_GAME;
    }

    @Override
    public UnifiedRoomResponse createRoom(UnifiedRoomRequest request) {
        // UnifiedRoomRequest → AiGameRoomCreateRequest 변환
        AiGameRoomCreateRequest aiRequest = convertToAiRequest(request);
        AiGameRoomResponse aiResponse = aiGameRoomService.createAiGameRoom(aiRequest);
        // AiGameRoomResponse → UnifiedRoomResponse 변환
        return UnifiedRoomResponse.fromAiGameRoom(aiResponse);
    }
}</code></pre>
</li>
<li><p><strong>통합 API 컨트롤러 구현</strong>:</p>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/v1/rooms&quot;)
public class UnifiedRoomController {
    private final UnifiedRoomService unifiedRoomService;

    @PostMapping
    public RsData&lt;UnifiedRoomResponse&gt; createRoom(@Valid @RequestBody UnifiedRoomRequest request) {
        return unifiedRoomService.createRoom(request);
    }

    @GetMapping(&quot;/{roomType}/{roomId}&quot;)
    public RsData&lt;UnifiedRoomResponse&gt; getRoom(@PathVariable String roomType, @PathVariable String roomId) {
        return unifiedRoomService.getRoom(roomType, roomId);
    }
}</code></pre>
</li>
</ol>
<h3 id="result-결과-3">Result (결과)</h3>
<ul>
<li><strong>코드 복잡도 대폭 감소</strong>: <ul>
<li>API 엔드포인트 16개 → 8개 (50% 감소)</li>
<li>매칭 시스템 룸 생성 코드 60% 단축</li>
</ul>
</li>
<li><strong>개발 생산성 향상</strong>: <ul>
<li>새 룸 타입 추가 시 개발 시간 70% 단축</li>
<li>일관된 에러 처리로 디버깅 시간 단축</li>
</ul>
</li>
<li><strong>시스템 확장성 확보</strong>: <ul>
<li>새 룸 타입 추가 시 기존 코드 수정 불필요</li>
<li>Spring 자동 등록으로 설정 작업 최소화</li>
</ul>
</li>
<li><strong>설계 패턴 이해도 향상</strong>: <ul>
<li>Factory 패턴의 실무 적용 경험</li>
<li>Adapter 패턴으로 레거시 코드 활용 방법 학습</li>
</ul>
</li>
</ul>
<hr>
<h2 id="star-회고-5-ai-서비스-타임아웃-문제-해결">STAR 회고 5: AI 서비스 타임아웃 문제 해결</h2>
<h3 id="situation-상황-4">Situation (상황)</h3>
<ul>
<li><strong>시기</strong>: 개발 2개월차, AI 채팅 시스템 구현 중</li>
<li><strong>문제</strong>: AI API 호출 시 평균 30-60초 소요, 종종 타임아웃 발생</li>
<li><strong>사용자 반응</strong>: &quot;AI가 응답을 안 해요&quot;, &quot;게임이 멈췄어요&quot;</li>
<li><strong>시스템 영향</strong>: WebSocket 연결 끊김, 게임 진행 중단</li>
</ul>
<h3 id="task-과제-4">Task (과제)</h3>
<ul>
<li><strong>주요 과제</strong>: AI 응답 대기 시간으로 인한 사용자 경험 저하 해결</li>
<li><strong>기술적 목표</strong>: <ul>
<li>사용자에게 진행 상황 실시간 알림</li>
<li>타임아웃 시 적절한 fallback 처리</li>
<li>전체 게임 흐름 유지</li>
</ul>
</li>
<li><strong>제약사항</strong>: 외부 AI 서비스 응답 속도 제어 불가</li>
</ul>
<h3 id="action-행동-4">Action (행동)</h3>
<ol>
<li><p><strong>비동기 처리 구조로 변경</strong>:</p>
<pre><code class="language-java">@Async(&quot;aiServiceTaskExecutor&quot;)
public CompletableFuture&lt;AiGameMessageResponse&gt; processAiTurnAsync(String roomId, AiGenerateRequest request) {
    try {
        // WebSocket으로 처리 시작 알림
        webSocketService.sendProcessingStatus(roomId, &quot;AI가 생각 중입니다...&quot;);

        // AI 서비스 호출
        AiResponseResult result = aiServiceClient.generateAiResponse(request);

        // 결과 처리 및 WebSocket 전송
        return CompletableFuture.completedFuture(processResult(result));

    } catch (Exception e) {
        // 에러 시 fallback 처리
        return CompletableFuture.completedFuture(createFallbackResponse());
    }
}</code></pre>
</li>
<li><p><strong>실시간 진행 상황 알림 시스템</strong>:</p>
<pre><code class="language-java">// 처리 단계별 WebSocket 메시지 전송
webSocketService.sendProcessingStatus(roomId, &quot;AI 서비스에 요청을 전송했습니다... ⏳&quot;);
// ... API 호출
webSocketService.sendProcessingStatus(roomId, &quot;AI가 답변을 생성하고 있습니다... 🤖&quot;);
// ... 응답 처리
webSocketService.sendProcessingStatus(roomId, &quot;곧 AI 답변이 도착합니다... ✨&quot;);</code></pre>
</li>
<li><p><strong>타임아웃 처리 및 Fallback 구현</strong>:</p>
<pre><code class="language-java">@Value(&quot;${ai.service.timeout:45000}&quot;)
private int aiServiceTimeout;

public AiResponseResult generateAiResponse(AiGenerateRequest request) {
    try {
        return restTemplate.postForObject(aiServiceUrl, request, AiResponseResult.class);
    } catch (ResourceAccessException e) {
        if (e.getCause() instanceof SocketTimeoutException) {
            log.warn(&quot;AI 서비스 타임아웃 발생: roomId={}&quot;, request.getRoomId());
            return createTimeoutFallbackResponse();
        }
        throw e;
    }
}

private AiResponseResult createTimeoutFallbackResponse() {
    return AiResponseResult.builder()
        .content(&quot;AI가 잠시 생각에 빠져있네요. 다음 턴에 더 흥미로운 이야기를 들려드릴게요! 🎭&quot;)
        .build();
}</code></pre>
</li>
<li><p><strong>사용자 대기 경험 개선</strong>:</p>
<pre><code class="language-javascript">// 클라이언트에서 처리 상태 시각화
function showAiProcessingStatus(message) {
    const statusDiv = document.getElementById(&#39;ai-processing-status&#39;);
    statusDiv.innerHTML = `
        &lt;div class=&quot;processing-indicator&quot;&gt;
            &lt;div class=&quot;spinner&quot;&gt;&lt;/div&gt;
            &lt;span&gt;${message}&lt;/span&gt;
        &lt;/div&gt;
    `;
}</code></pre>
</li>
</ol>
<h3 id="result-결과-4">Result (결과)</h3>
<ul>
<li><strong>사용자 경험 극적 개선</strong>: <ul>
<li>&quot;AI가 안 돼요&quot; 불만 85% 감소</li>
<li>게임 중단율 70% 감소</li>
</ul>
</li>
<li><strong>시스템 안정성 향상</strong>: <ul>
<li>WebSocket 연결 끊김 90% 감소</li>
<li>전체 게임 완주율 60% → 85% 향상</li>
</ul>
</li>
<li><strong>기술적 성장</strong>: <ul>
<li>비동기 프로그래밍 실무 적용 경험</li>
<li>사용자 경험 중심의 기술 설계 능력 향상</li>
</ul>
</li>
<li><strong>운영 지표 개선</strong>: <ul>
<li>AI 응답 실패율 15% → 2% 개선</li>
<li>평균 게임 시간 20% 단축 (대기시간 체감 감소)</li>
</ul>
</li>
</ul>
<hr>
<h2 id="전체-프로젝트-star-종합-분석">전체 프로젝트 STAR 종합 분석</h2>
<h3 id="주요-성과-지표">주요 성과 지표</h3>
<p><strong>기술적 성과</strong>:</p>
<ul>
<li>동시 접속자 처리 능력: 50명 → 200명 (400% 향상)</li>
<li>평균 응답 시간: 3-5초 → 1초 이내 (80% 개선)</li>
<li>시스템 안정성: 에러율 15% → 2% (87% 개선)</li>
</ul>
<p><strong>개발 생산성</strong>:</p>
<ul>
<li>API 엔드포인트 통합: 16개 → 8개 (50% 감소)</li>
<li>새 기능 개발 시간: 70% 단축</li>
<li>코드 중복도: 60% 감소</li>
</ul>
<p><strong>사용자 경험</strong>:</p>
<ul>
<li>매칭 성공률: 95% → 99.8%</li>
<li>게임 완주율: 60% → 85%</li>
<li>사용자 만족도 관련 불만: 90% 감소</li>
</ul>
<h3 id="핵심-학습-내용">핵심 학습 내용</h3>
<ol>
<li><strong>동시성 프로그래밍</strong>: Race Condition 해결과 원자적 처리의 중요성</li>
<li><strong>설계 패턴 실무 적용</strong>: Factory, Adapter 패턴의 확장성과 유지보수성</li>
<li><strong>성능 최적화</strong>: 데이터베이스 선택과 캐싱 전략의 임팩트</li>
<li><strong>사용자 경험 중심 설계</strong>: 기술적 제약을 UX로 극복하는 방법</li>
<li><strong>운영 안정성</strong>: 로그, 모니터링, 에러 처리의 중요성</li>
</ol>
<h2 id="마무리">마무리</h2>
<p>이번 STAR 방식 회고를 통해 단순히 &quot;무엇을 했다&quot;가 아닌 &quot;어떤 상황에서, 어떤 과제를 받고, 어떻게 행동해서, 어떤 구체적인 결과를 얻었는지&quot;를 명확히 정리할 수 있었습니다.</p>
<p>특히 각 상황에서 <strong>문제를 분석하고 → 해결책을 설계하고 → 구현하고 → 결과를 측정하는</strong> 일련의 과정을 체계적으로 수행했다는 것을 확인할 수 있었습니다.</p>
<p>앞으로도 이런 체계적인 문제 해결 접근법을 계속 발전시켜 나가며, 더 복잡하고 규모 있는 시스템을 구축해 나가고 싶습니다.</p>
<p><strong>&quot;기술은 문제를 해결하는 도구일 뿐, 진짜 중요한 건 문제를 정확히 파악하고 최적의 해결책을 찾는 능력이다.&quot;</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DungeonTalk 백엔드 개발기 - 5편: 개발 회고와 진짜 배운 것들]]></title>
            <link>https://velog.io/@mj_o/DungeonTalk-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-5%ED%8E%B8-%EA%B0%9C%EB%B0%9C-%ED%9A%8C%EA%B3%A0%EC%99%80-%EC%A7%84%EC%A7%9C-%EB%B0%B0%EC%9A%B4-%EA%B2%83%EB%93%A4</link>
            <guid>https://velog.io/@mj_o/DungeonTalk-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-5%ED%8E%B8-%EA%B0%9C%EB%B0%9C-%ED%9A%8C%EA%B3%A0%EC%99%80-%EC%A7%84%EC%A7%9C-%EB%B0%B0%EC%9A%B4-%EA%B2%83%EB%93%A4</guid>
            <pubDate>Thu, 14 Aug 2025 07:57:24 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>1개월간의 개발 여정을 돌아보며 - 실패했던 것들, 성장한 것들, 그리고 솔직한 이야기들</p>
</blockquote>
<h2 id="시작하며">시작하며</h2>
<p>벌써 마지막 편이네요. 돌이켜보니 정말 많은 일들이 있었습니다. </p>
<p>처음에는 &quot;AI 채팅 시스템이면 간단하겠지?&quot;라고 생각했는데, 막상 해보니 <strong>실시간 통신, 동시성 제어, 외부 API 연동, 성능 최적화</strong>까지... 생각보다 훨씬 복잡한 시스템이였습니다 </p>
<p>이번 편에서는 기술적인 내용보다는 <strong>개발 과정에서 진짜 느꼈던 것들</strong>을 솔직하게 공유해보려고 합니다.</p>
<h2 id="이게-왜-안-되지---가장-고생한-순간들">&quot;이게 왜 안 되지?&quot; - 가장 고생한 순간들</h2>
<h3 id="1-동시-접속-시-게임-시작-오류">1. 동시 접속 시 게임 시작 오류</h3>
<p><strong>상황</strong>: 매칭이 완료되어 3명이 동시에 게임을 시작하려 하는데, 한 명만 성공하고 나머지 두 명은 401 에러가 발생</p>
<p><strong>처음 반응</strong>: &quot;JWT 토큰 문제인가? 아니면 DB 문제인가?&quot;</p>
<p><strong>실제 원인</strong>: 게임방 상태를 <code>CREATED</code>에서 <code>ACTIVE</code>로 바꾸는 로직에서 <strong>동시성을 고려하지 않았음</strong></p>
<pre><code class="language-java">// 문제가 된 코드
if (room.getStatus() != AiGameStatus.CREATED) {
    throw new AiChatException(ErrorCode.AI_GAME_ROOM_INVALID_STATE);
}</code></pre>
<p><strong>깨달은 점</strong>: </p>
<ul>
<li>&quot;동시성? 그냥 이론일 줄 알았는데 진짜 일어나네&quot;</li>
<li>로컬에서는 문제없던 게 서버에 올리면 터지는 무서운 경험</li>
<li>로그를 더 자세히 남겨야겠다는 생각</li>
</ul>
<p><strong>해결 후 기분</strong>: </p>
<pre><code class="language-java">// 이미 활성화된 게임방은 성공으로 처리하도록 수정
if (room.getStatus() == AiGameStatus.ACTIVE) {
    log.info(&quot; 게임방이 이미 활성화됨&quot;);
    return AiGameRoomResponse.fromEntity(room);
}</code></pre>
<p>&quot;아, 이게 바로 &#39;방어적 프로그래밍&#39;이구나!&quot;</p>
<h3 id="2-ai-응답이-30초씩-걸리는-문제">2. AI 응답이 30초씩 걸리는 문제</h3>
<p><strong>상황</strong>: AI 서비스 호출이 평균 30-60초 소요. 사용자들이 &quot;버그인가?&quot;라고 생각할 정도</p>
<p><strong>시도한 것들</strong>:</p>
<pre><code class="language-java">// 1. 타임아웃을 늘려보기
@Value(&quot;${ai.service.timeout:60000}&quot;)

// 2. 비동기로 처리하기
@Async(&quot;matchingTaskExecutor&quot;)
public void processAiTurn(String roomId, AiGenerateRequest request) {
    // ...
}

// 3. WebSocket으로 진행 상황 알리기
webSocketService.sendProcessingStatus(roomId, &quot;AI가 생각 중입니다...&quot;);</code></pre>
<p><strong>깨달은 점</strong>:</p>
<ul>
<li>외부 API 의존성이 이렇게 큰 영향을 미칠 줄 몰랐음</li>
<li><strong>사용자 경험은 기술적 완성도와 별개</strong>라는 것</li>
<li>&quot;기다림&quot;을 어떻게 표현하느냐가 중요함</li>
<li>실제로 ai 모델 속도가 너무 느린 문제가 있었습니다 딥시크 모델이 평균 26초 정도 걸려서 모델 변경을 고려하기도 했었지만 딥시크 모델이 가장 가격대비 성능이 좋아서 선택하게 되었습니다. </li>
</ul>
<p><strong>결론</strong>: AI가 느려도 사용자가 답답하지 않게 만드는 게 더 중요했음</p>
<h3 id="3-redis-메모리-부족으로-서비스-다운">3. Redis 메모리 부족으로 서비스 다운</h3>
<p><strong>상황</strong>: 매칭 시스템 테스트 중 Redis 메모리가 가득 차서 서비스 전체가 중단</p>
<p><strong>원인</strong>: TTL 설정을 안 한 테스트 데이터들이 계속 쌓임</p>
<pre><code class="language-bash"># Redis 메모리 사용량 확인했을 때의 충격
redis-cli info memory
used_memory_human:985.2M
maxmemory_human:1.0G</code></pre>
<p><strong>응급처치</strong>:</p>
<pre><code class="language-java">// 모든 매칭 관련 데이터에 TTL 추가
redisTemplate.expire(userKey, Duration.ofSeconds(3600));
redisTemplate.expire(sessionKey, Duration.ofSeconds(604800));</code></pre>
<p><strong>배운 점</strong>:</p>
<ul>
<li>&quot;TTL을 안 설정하면 Redis는 쓰레기통&quot;</li>
<li>개발할 때는 정리를 습관화해야 함</li>
<li>모니터링이 얼마나 중요한지 체감</li>
</ul>
<h2 id="이건-정말-잘했다---뿌듯했던-순간들">&quot;이건 정말 잘했다!&quot; - 뿌듯했던 순간들</h2>
<h3 id="1-factory-패턴으로-통합-룸-시스템-구현">1. Factory 패턴으로 통합 룸 시스템 구현</h3>
<p><strong>처음 계획</strong>: AI 룸이랑 채팅룸을 따로따로 만들자</p>
<p><strong>중간에 깨달음</strong>: &quot;어? 이거 중복이 너무 많은데?&quot;</p>
<p><strong>최종 결과</strong>:</p>
<pre><code class="language-java">// 이 한 줄로 모든 룸 타입을 처리할 수 있게 됨
RoomService service = roomServiceFactory.getService(roomType);</code></pre>
<p><strong>뿌듯한 이유</strong>:</p>
<ul>
<li>새로운 룸 타입 추가가 진짜 쉬워짐</li>
<li>매칭 시스템에서 룸 생성하는 코드가 절반으로 줄어듦</li>
<li>&quot;아, 이게 좋은 설계구나&quot; 하는 느낌</li>
</ul>
<h3 id="2-lua-스크립트로-원자적-매칭-처리">2. Lua 스크립트로 원자적 매칭 처리</h3>
<p><strong>문제</strong>: 매칭 큐에서 3명을 뽑을 때 race condition 발생 가능</p>
<p><strong>해결</strong>:</p>
<pre><code class="language-lua">-- 이 스크립트 하나로 모든 동시성 문제 해결
local queueSize = redis.call(&#39;LLEN&#39;, queueKey)
if queueSize &lt; 3 then
    return nil
end

local users = {}
for i = 1, 3 do
    local user = redis.call(&#39;RPOP&#39;, queueKey)
    table.insert(users, user)
end
return users</code></pre>
<p><strong>왜 뿌듯했나</strong>:</p>
<ul>
<li>Lua 스크립트는 처음 써봤는데 생각보다 강력함</li>
<li>&quot;원자성&quot;이라는 개념을 코드로 구현해본 느낌</li>
<li>Redis의 진짜 파워를 경험</li>
</ul>
<h3 id="3-websocket-실시간-알림-시스템">3. WebSocket 실시간 알림 시스템</h3>
<p><strong>처음 WebSocket 연결됐을 때</strong>:</p>
<pre><code class="language-javascript">stompClient.connect(headers, function(frame) {
    console.log(&#39;Connected: &#39; + frame);
});</code></pre>
<p><strong>첫 메시지가 실시간으로 전달됐을 때</strong>: &quot;와... 진짜 된다!&quot;</p>
<p><strong>지금 생각</strong>: 실시간 통신이 주는 사용자 경험의 임팩트가 생각보다 훨씬 큼</p>
<h2 id="이랬으면-더-좋았을-텐데---아쉬운-점들">&quot;이랬으면 더 좋았을 텐데&quot; - 아쉬운 점들</h2>
<h3 id="1-테스트-코드를-더-일찍-작성할걸">1. 테스트 코드를 더 일찍 작성할걸</h3>
<p><strong>현실</strong>:</p>
<pre><code class="language-java">// 이런 테스트가 많았음
@Test
void createRoom() {
    // given
    // when
    // then - 일단 동작만 확인
    assertThat(result).isNotNull();
}</code></pre>
<p><strong>이랬으면 좋았을 것</strong>:</p>
<pre><code class="language-java">// 이런 테스트를 더 많이 작성했으면
@Test 
void createRoom_WhenAlreadyActive_ShouldReturnExistingRoom() {
    // 구체적인 시나리오 테스트
}</code></pre>
<p><strong>배운 점</strong>: 테스트는 나중에 하려면 더 어려워짐. 그냥 처음부터 습관화하자.</p>
<h3 id="2-에러-처리를-더-체계적으로">2. 에러 처리를 더 체계적으로</h3>
<p><strong>초기 에러 처리</strong>:</p>
<pre><code class="language-java">try {
    // 비즈니스 로직
} catch (Exception e) {
    log.error(&quot;에러 발생: {}&quot;, e.getMessage());
    throw new RuntimeException(&quot;처리 중 오류 발생&quot;);
}</code></pre>
<p><strong>지금 생각하는 이상적인 형태</strong>:</p>
<pre><code class="language-java">try {
    // 비즈니스 로직
} catch (AiServiceTimeoutException e) {
    log.warn(&quot;AI 서비스 타임아웃: roomId={}&quot;, roomId);
    return ErrorResponse.aiServiceUnavailable();
} catch (InvalidGameStateException e) {
    log.info(&quot;잘못된 게임 상태: roomId={}, status={}&quot;, roomId, e.getGameStatus());
    return ErrorResponse.invalidGameState(e.getGameStatus());
}</code></pre>
<p><strong>아쉬운 이유</strong>: 에러가 발생했을 때 원인을 찾기가 어려웠음</p>
<h3 id="3-성능-모니터링을-처음부터-구축할걸">3. 성능 모니터링을 처음부터 구축할걸</h3>
<p><strong>현재 상황</strong>: 성능 문제가 생긴 후에야 &quot;어디가 느린지 모르겠네?&quot; </p>
<p><strong>이랬으면 좋았을 것</strong>:</p>
<pre><code class="language-java">@Timed(name = &quot;ai.response.time&quot;, description = &quot;AI 응답 시간&quot;)
public AiResponseResult generateAiResponse(...) {
    // ...
}</code></pre>
<p><strong>배운 점</strong>: 모니터링은 문제가 생기기 전에 미리 준비해야 함</p>
<h2 id="진짜-성장했다고-느끼는-부분들">진짜 성장했다고 느끼는 부분들</h2>
<h3 id="1-일단-돌아가게-→-제대로-돌아가게">1. &quot;일단 돌아가게&quot; → &quot;제대로 돌아가게&quot;</h3>
<p><strong>예전 코드</strong>:</p>
<pre><code class="language-java">// 일단 동작하면 OK
public String processMessage(String message) {
    return aiService.callApi(message).getResponse();
}</code></pre>
<p><strong>지금 코드</strong>:</p>
<pre><code class="language-java">// 예외 상황까지 고려
public RsData&lt;AiGameMessageResponse&gt; processMessage(UnifiedMessageRequest request) {
    try {
        // 입력 검증
        request.validateByRoomType();

        // 게임 상태 확인
        if (!aiGameStateService.canProcessMessage(request.getRoomId())) {
            return RsData.of(&quot;400&quot;, &quot;현재 메시지를 처리할 수 없는 상태입니다&quot;, null);
        }

        // 실제 처리
        return processMessageInternal(request);

    } catch (ValidationException e) {
        return RsData.of(&quot;400&quot;, e.getMessage(), null);
    } catch (Exception e) {
        log.error(&quot;메시지 처리 중 예상치 못한 오류: roomId={}&quot;, request.getRoomId(), e);
        return RsData.of(&quot;500&quot;, &quot;메시지 처리 중 오류가 발생했습니다&quot;, null);
    }
}</code></pre>
<h3 id="2-로그에-대한-인식-변화">2. 로그에 대한 인식 변화</h3>
<p><strong>예전</strong>: 로그는 디버깅용</p>
<p><strong>지금</strong>: 로그는 운영의 핵심</p>
<pre><code class="language-java">// 이제는 이런 로그를 자주 씀
log.info(&quot; AI 게임 세션 시작: roomId={}, participants={}&quot;, roomId, participants);
log.warn(&quot; AI 응답 처리 중 락 설정 실패 (이미 처리중): roomId={}&quot;, roomId);
log.error(&quot; AI 서비스 연결 시간 초과: roomId={}, timeout={}ms&quot;, roomId, timeout);</code></pre>
<p><strong>느낀 점</strong>: </p>
<ul>
<li>이모지 쓰니까 로그 보기가 훨씬 편함</li>
<li>구조화된 로그 (roomId, userId 등)가 진짜 도움됨</li>
<li>적절한 로그 레벨 구분이 중요</li>
</ul>
<h3 id="3-설계-패턴을-이해에서-활용으로">3. 설계 패턴을 &quot;이해&quot;에서 &quot;활용&quot;으로</h3>
<p><strong>Factory 패턴을 처음 적용했을 때</strong>:</p>
<pre><code class="language-java">// &quot;이게 Factory 패턴이구나&quot;
public RoomService getService(RoomType roomType) {
    switch (roomType) {
        case AI_GAME: return aiGameRoomServiceAdapter;
        case PLAYER_CHAT: return chatRoomServiceAdapter;
        default: throw new IllegalArgumentException(&quot;지원하지 않는 타입&quot;);
    }
}</code></pre>
<p><strong>지금의 Factory</strong>:</p>
<pre><code class="language-java">// &quot;이렇게 하면 확장성이 좋겠구나&quot;
private void initializeServiceMap() {
    serviceMap = roomServices.stream()
        .collect(Collectors.toMap(
            RoomService::getSupportedRoomType,
            Function.identity()
        ));
}</code></pre>
<p><strong>차이점</strong>: 패턴을 &quot;알고 있음&quot;에서 &quot;왜 쓰는지 체감함&quot;으로 변화</p>
<h2 id="예상과-달랐던-재밌는-점들">예상과 달랐던 재밌는 점들</h2>
<h3 id="1-mongodb가-생각보다-편함">1. MongoDB가 생각보다 편함</h3>
<p><strong>처음 생각</strong>: &quot;NoSQL은 어려울 것 같은데...&quot;</p>
<p><strong>실제 경험</strong>: </p>
<pre><code class="language-java">// 스키마 자유도가 이렇게 좋을 줄이야
@Document(collection = &quot;ai_game_messages&quot;)
public class AiGameMessage {
    // 필드 추가가 너무 간단함
    private String newField; // 그냥 추가하면 끝
}</code></pre>
<p><strong>인상깊었던 점</strong>: </p>
<ul>
<li>JSON과 자연스럽게 연결됨</li>
<li>복잡한 중첩 구조도 쉽게 저장</li>
<li>인덱스 설정만 잘하면 성능도 좋음</li>
</ul>
<h3 id="2-redis가-단순한-캐시-그-이상">2. Redis가 단순한 캐시 그 이상</h3>
<p><strong>처음 생각</strong>: &quot;Redis = 빠른 메모리 저장소&quot;</p>
<p><strong>실제 활용</strong>:</p>
<ul>
<li>분산 락: <code>setIfAbsent()</code></li>
<li>Pub/Sub: 실시간 메시징</li>
<li>TTL: 자동 데이터 정리</li>
<li>List: 매칭 큐 구현</li>
<li>Hash: 복잡한 데이터 구조 저장</li>
</ul>
<p><strong>깨달음</strong>: &quot;Redis는 완전 다목적 도구구나&quot;</p>
<h3 id="3-websocket이-http와-완전-다른-세계">3. WebSocket이 HTTP와 완전 다른 세계</h3>
<p><strong>HTTP 사고방식</strong>:</p>
<ul>
<li>요청 → 응답</li>
<li>상태없음 (Stateless)</li>
<li>단방향</li>
</ul>
<p><strong>WebSocket 사고방식</strong>:</p>
<ul>
<li>지속적 연결</li>
<li>상태 관리 필요</li>
<li>양방향 통신</li>
</ul>
<p><strong>인상깊었던 차이</strong>:</p>
<pre><code class="language-java">// HTTP: 간단명료
@GetMapping(&quot;/api/rooms/{id}&quot;)
public RoomResponse getRoom(@PathVariable String id) {
    return roomService.getRoom(id);
}

// WebSocket: 연결 상태, 세션 관리 등 고려할 게 많음
@MessageMapping(&quot;/aichat/send&quot;)
public void sendMessage(@Payload AiGameMessageSendRequest request, 
                       StompHeaderAccessor headerAccessor) {
    // 세션 확인, 권한 검증, 상태 관리...
}</code></pre>
<h2 id="협업하면서-느낀-점들">협업하면서 느낀 점들</h2>
<h3 id="1-api-설계의-중요성">1. API 설계의 중요성</h3>
<p><strong>처음 API</strong>:</p>
<pre><code class="language-java">// 이런 식으로 만들었더니
@PostMapping(&quot;/aichat/create&quot;)
public String createRoom(@RequestBody Map&lt;String, Object&gt; request) {
    // ...
}</code></pre>
<p><strong>문제점</strong>: </p>
<ul>
<li>프론트엔드 개발자가 &quot;어떤 필드가 필요한지 모르겠어요&quot;</li>
<li>타입 체크가 안 되어서 런타임 에러 자주 발생</li>
</ul>
<p><strong>개선 후</strong>:</p>
<pre><code class="language-java">@PostMapping(&quot;/rooms&quot;)
public RsData&lt;UnifiedRoomResponse&gt; createRoom(@Valid @RequestBody UnifiedRoomRequest request) {
    // ...
}

// DTO 클래스로 명확하게 정의
public class UnifiedRoomRequest {
    @NotNull(message = &quot;룸 타입은 필수입니다&quot;)
    private RoomType roomType;

    @NotBlank(message = &quot;룸 이름은 필수입니다&quot;)
    private String roomName;
    // ...
}</code></pre>
<p><strong>배운 점</strong>: API는 개발자 간의 약속. 명확할수록 좋음.</p>
<h3 id="2-문서화의-힘">2. 문서화의 힘</h3>
<p><strong>처음에는</strong>: &quot;코드 보면 알 수 있지 않나?&quot;</p>
<p><strong>Swagger 도입 후</strong>: &quot;아, 이게 소통이구나&quot;</p>
<pre><code class="language-java">@Operation(summary = &quot;룸 생성&quot;, description = &quot;새로운 AI 게임룸 또는 플레이어 채팅룸을 생성합니다&quot;)
@ApiResponses(value = {
    @ApiResponse(responseCode = &quot;200&quot;, description = &quot;룸 생성 성공&quot;),
    @ApiResponse(responseCode = &quot;400&quot;, description = &quot;잘못된 요청 데이터&quot;)
})</code></pre>
<p><strong>효과</strong>: </p>
<ul>
<li>프론트엔드 개발자가 API 테스트를 혼자서 할 수 있게 됨</li>
<li>&quot;이 API 어떻게 써요?&quot; 질문이 확실히 줄어듦</li>
</ul>
<h3 id="3-에러-메시지의-중요성">3. 에러 메시지의 중요성</h3>
<p><strong>처음 에러 메시지</strong>:</p>
<pre><code class="language-java">throw new RuntimeException(&quot;오류 발생&quot;);</code></pre>
<p><strong>개선된 에러 메시지</strong>:</p>
<pre><code class="language-java">return RsData.of(&quot;400&quot;, &quot;AI 게임룸은 gameSettings가 필요합니다&quot;, null);</code></pre>
<p><strong>체감한 차이</strong>: </p>
<ul>
<li>디버깅 시간이 확실히 단축됨</li>
<li>프론트엔드에서 사용자에게 의미있는 메시지를 보여줄 수 있게 됨</li>
</ul>
<h2 id="다음에는-이렇게-해보고-싶다">다음에는 이렇게 해보고 싶다</h2>
<h3 id="1-테스트-주도-개발-tdd">1. 테스트 주도 개발 (TDD)</h3>
<p><strong>이번 프로젝트</strong>: 기능 구현 → 테스트 작성</p>
<p><strong>다음 프로젝트</strong>: 테스트 작성 → 기능 구현</p>
<p><strong>기대하는 점</strong>: </p>
<ul>
<li>더 안정적인 코드</li>
<li>리팩토링에 대한 자신감</li>
<li>요구사항에 대한 더 명확한 이해</li>
</ul>
<h3 id="2-도메인-주도-설계-ddd-적용">2. 도메인 주도 설계 (DDD) 적용</h3>
<p><strong>이번 경험</strong>: </p>
<ul>
<li>도메인별로 패키지를 나누긴 했지만...</li>
<li>비즈니스 로직이 서비스 계층에 흩어져 있음</li>
</ul>
<p><strong>다음에는</strong>:</p>
<pre><code class="language-java">// Entity 자체에 비즈니스 로직 포함
public class AiGameRoom {
    public void startGame() {
        if (this.status != AiGameStatus.CREATED) {
            throw new IllegalStateException(&quot;게임을 시작할 수 없는 상태입니다&quot;);
        }
        this.status = AiGameStatus.ACTIVE;
        this.currentPhase = AiGamePhase.TURN_INPUT;
    }
}</code></pre>
<h3 id="3-이벤트-기반-아키텍처">3. 이벤트 기반 아키텍처</h3>
<p><strong>현재</strong>: 직접 서비스 호출</p>
<p><strong>개선 아이디어</strong>:</p>
<pre><code class="language-java">// 이벤트 발행
eventPublisher.publishEvent(new MatchingCompleteEvent(participants, gameSessionId));

// 이벤트 처리
@EventListener
public void handleMatchingComplete(MatchingCompleteEvent event) {
    // 룸 생성, 알림 전송 등을 분리해서 처리
}</code></pre>
<p><strong>기대 효과</strong>: </p>
<ul>
<li>더 느슨한 결합</li>
<li>쉬운 기능 추가/제거</li>
<li>더 나은 확장성</li>
</ul>
<h2 id="마치며">마치며</h2>
<p>4개월 동안 정말 많은 걸 배웠습니다.</p>
<p><strong>기술적으로는</strong>:</p>
<ul>
<li>멀티 데이터베이스 설계</li>
<li>실시간 통신 구현</li>
<li>분산 시스템의 동시성 제어</li>
<li>설계 패턴의 실무 적용</li>
</ul>
<p><strong>개발 프로세스에서는</strong>:</p>
<ul>
<li>테스트의 중요성</li>
<li>문서화의 힘</li>
<li>로그와 모니터링의 필요성</li>
<li>API 설계의 중요성</li>
</ul>
<p><strong>개인적으로는</strong>:</p>
<ul>
<li>&quot;일단 돌아가게&quot; → &quot;제대로 돌아가게&quot;</li>
<li>문제를 체계적으로 접근하는 방법</li>
<li>동료와 소통하는 방법</li>
</ul>
<p><strong>가장 인상 깊었던 깨달음</strong>:</p>
<blockquote>
<p>&quot;좋은 코드는 혼자 봐도 좋은 코드가 아니라, 6개월 후 내가 봐도 이해할 수 있고, 동료가 봐도 이해할 수 있는 코드다.&quot;</p>
</blockquote>
<p><strong>아직 부족한 점들</strong>:</p>
<ul>
<li>테스트 커버리지</li>
<li>성능 최적화</li>
<li>보안 고려사항</li>
<li>운영 모니터링</li>
</ul>
<p>하지만 <strong>완벽한 시스템은 없다</strong>는 것도 배웠습니다. 중요한 건 <strong>지속적으로 개선해 나가는 것</strong>이죠.</p>
<p><strong>이 시리즈를 읽어주신 분들께</strong>:
혹시 비슷한 시스템을 개발하게 되신다면, 제가 겪은 시행착오들이 조금이라도 도움이 되었으면 좋겠습니다. </p>
<p>그리고 &quot;이렇게 하면 더 좋을 것 같은데?&quot;하는 의견이 있으시면 언제든 댓글로 공유해 주세요. 계속 배우고 싶거든요. </p>
<hr>
<p><strong>DungeonTalk 개발기 시리즈 완결</strong></p>
<ol>
<li>멀티 데이터베이스 아키텍처 설계</li>
<li>AI 채팅 시스템 구현 (aichat)</li>
<li>실시간 매칭 시스템 구현 (matching)</li>
<li>통합 룸 시스템 구현 (room)</li>
<li><strong>개발 회고와 진짜 배운 것들</strong> ← 완료</li>
</ol>
<p>*<em>긴 여정 동안 함께해주셔서 감사합니다! *</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DungeonTalk 백엔드 개발기 - 4편: 통합 룸 시스템 구현]]></title>
            <link>https://velog.io/@mj_o/DungeonTalk-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-4%ED%8E%B8-%ED%86%B5%ED%95%A9-%EB%A3%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@mj_o/DungeonTalk-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-4%ED%8E%B8-%ED%86%B5%ED%95%A9-%EB%A3%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Thu, 14 Aug 2025 07:46:31 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Factory와 Adapter 패턴으로 AI 게임룸과 채팅룸을 하나로 통합하다</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>매칭 시스템으로 플레이어들을 모았다면, 이제 <strong>실제 게임이 진행될 공간</strong>을 만들어야 합니다. DungeonTalk에서는 AI 게임룸과 일반 채팅룸, 두 가지 서로 다른 룸이 동시에 필요합니다.</p>
<p>하지만 각각 다른 서비스로 개발된 상황에서 <strong>일관된 인터페이스</strong>로 관리하고 싶었습니다. 이번 편에서는 Factory 패턴과 Adapter 패턴을 활용해 <strong>통합 룸 시스템</strong>을 구현한 과정을 공유합니다.</p>
<h2 id="문제-상황과-해결-목표">문제 상황과 해결 목표</h2>
<h3 id="기존-문제점">기존 문제점</h3>
<p><strong>1. 중복된 룸 관리 로직</strong></p>
<pre><code>AI 게임룸 생성 → AiGameRoomService.createAiGameRoom()
채팅룸 생성 → ChatRoomService.createRoom()</code></pre><p><strong>2. 일관성 없는 API</strong></p>
<pre><code>GET /v1/aichat/rooms/{roomId}      // AI 게임룸
GET /v1/chat/rooms/{roomId}        // 채팅룸  </code></pre><p><strong>3. 매칭 시스템의 복잡성</strong></p>
<pre><code class="language-java">// 매칭 완료 시 두 개의 서로 다른 서비스 호출
AiGameRoomResponse aiRoom = aiGameRoomService.createAiGameRoom(...);
ChatRoomDto chatRoom = chatRoomService.createRoom(...);</code></pre>
<h3 id="해결-목표">해결 목표</h3>
<p><strong>통합된 룸 관리 인터페이스 제공</strong></p>
<ul>
<li>단일 API로 모든 룸 타입 관리</li>
<li>확장 가능한 아키텍처</li>
<li>기존 서비스와의 호환성 보장</li>
</ul>
<h2 id="통합-룸-시스템-아키텍처">통합 룸 시스템 아키텍처</h2>
<h3 id="아키텍처-다이어그램">아키텍처 다이어그램</h3>
<pre><code>┌─────────────────────────────────────────────────────────────┐
│                    Client Layer                             │
│  ┌─────────────────┐  ┌─────────────────┐  ┌──────────────┐ │
│  │  Web Client     │  │  Mobile App     │  │  Admin Panel │ │
│  └─────────────────┘  └─────────────────┘  └──────────────┘ │
└─────────────────┬───────────────┬───────────────┬───────────┘
                  │               │               │
                  ▼               ▼               ▼
┌─────────────────────────────────────────────────────────────┐
│                 Controller Layer                            │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │            UnifiedRoomController                        │ │
│  │  - POST /v1/rooms (통합 룸 생성)                          │ │
│  │  - GET /v1/rooms/{type}/{id} (룸 조회)                   │ │
│  │  └─────────────────────────────────────────────────────┘ │
└─────────────────────────────┬───────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                 Service Layer                               │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │              UnifiedRoomService                         │ │
│  │          (비즈니스 로직 &amp; 에러 처리)                        │ │
│  └─────────────────┬───────────────────────────────────────┘ │
│                    │                                         │
│                    ▼                                         │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │             RoomServiceFactory                          │ │
│  │              (Factory Pattern)                          │ │
│  └─────────┬───────────────────────────┬───────────────────┘ │
└───────────┼───────────────────────────┼─────────────────────┘
            │                           │
            ▼                           ▼
┌─────────────────────┐       ┌─────────────────────┐
│   Adapter Layer     │       │   Adapter Layer     │
│ ┌─────────────────┐ │       │ ┌─────────────────┐ │
│ │AiGameRoomService│ │       │ │ChatRoomService  │ │
│ │    Adapter      │ │       │ │    Adapter      │ │
│ │(Adapter Pattern)│ │       │ │(Adapter Pattern)│ │
│ └─────────────────┘ │       │ └─────────────────┘ │
└─────────┬───────────┘       └─────────┬───────────┘
          │                             │
          ▼                             ▼
┌─────────────────────┐       ┌─────────────────────┐
│   Domain Services   │       │   Domain Services   │
│ ┌─────────────────┐ │       │ ┌─────────────────┐ │
│ │AiGameRoomService│ │       │ │ChatRoomService  │ │
│ │AiGameMessage    │ │       │ │ChatMessage      │ │
│ │     Service     │ │       │ │    Service      │ │
│ └─────────────────┘ │       │ └─────────────────┘ │
└─────────┬───────────┘       └─────────┬───────────┘
          │                             │
          ▼                             ▼
┌─────────────────────┐       ┌─────────────────────┐
│   Data Layer        │       │   Data Layer        │
│ ┌─────────────────┐ │       │ ┌─────────────────┐ │
│ │    MongoDB      │ │       │ │    MongoDB      │ │
│ │  (AI Game Data) │ │       │ │  (Chat Data)    │ │
│ └─────────────────┘ │       │ └─────────────────┘ │
└─────────────────────┘       └─────────────────────┘</code></pre><h3 id="핵심-컴포넌트-구조">핵심 컴포넌트 구조</h3>
<pre><code>통합 룸 시스템
├── UnifiedRoomController      - 통합 REST API 제공
├── UnifiedRoomService        - 비즈니스 로직 &amp; 응답 처리
├── RoomServiceFactory        - 룸 타입별 서비스 라우팅
├── RoomService (Interface)   - 통합 인터페이스 정의
├── AiGameRoomServiceAdapter  - AI 게임룸 어댑터
└── ChatRoomServiceAdapter    - 채팅룸 어댑터</code></pre><h2 id="factory-패턴-구현">Factory 패턴 구현</h2>
<h3 id="roomservicefactory-설계">RoomServiceFactory 설계</h3>
<p><strong>핵심 아이디어</strong>: 룸 타입에 따라 적절한 서비스를 자동으로 선택</p>
<pre><code class="language-java">@Component
public class RoomServiceFactory {

    private final List&lt;RoomService&gt; roomServices;
    private Map&lt;RoomType, RoomService&gt; serviceMap;

    public RoomService getService(RoomType roomType) {
        initializeServiceMap();

        RoomService service = serviceMap.get(roomType);
        if (service == null) {
            throw new IllegalArgumentException(
                String.format(&quot;지원하지 않는 룸 타입: %s&quot;, roomType.getDisplayName())
            );
        }

        return service;
    }

    private void initializeServiceMap() {
        if (serviceMap == null) {
            serviceMap = roomServices.stream()
                .collect(Collectors.toMap(
                    RoomService::getSupportedRoomType,
                    Function.identity()
                ));
        }
    }
}</code></pre>
<p><strong>동작 과정</strong>:</p>
<ol>
<li>Spring이 모든 <code>RoomService</code> 구현체를 주입</li>
<li>각 서비스의 <code>getSupportedRoomType()</code> 기반으로 맵 구성</li>
<li>요청 시 룸 타입에 맞는 서비스를 즉시 반환</li>
</ol>
<h3 id="확장-가능한-룸-타입-시스템">확장 가능한 룸 타입 시스템</h3>
<pre><code class="language-java">public enum RoomType {
    AI_GAME(&quot;ai-game&quot;, &quot;AI 게임룸&quot;),
    PLAYER_CHAT(&quot;player-chat&quot;, &quot;플레이어 채팅룸&quot;);

    public static RoomType fromCode(String code) {
        return Arrays.stream(values())
            .filter(type -&gt; type.code.equals(code))
            .findFirst()
            .orElseThrow(() -&gt; new IllegalArgumentException(&quot;알 수 없는 룸 타입: &quot; + code));
    }
}</code></pre>
<p><strong>새 룸 타입 추가 시</strong>:</p>
<ol>
<li><code>RoomType</code> 열거형에 새 타입 추가</li>
<li><code>RoomService</code> 구현체 작성</li>
<li>Spring에서 자동 등록 → <strong>코드 수정 최소화</strong></li>
</ol>
<h2 id="adapter-패턴-구현">Adapter 패턴 구현</h2>
<h3 id="통합-인터페이스-정의">통합 인터페이스 정의</h3>
<pre><code class="language-java">public interface RoomService {

    RoomType getSupportedRoomType();

    // 룸 관리
    UnifiedRoomResponse createRoom(UnifiedRoomRequest request);
    UnifiedRoomResponse getRoom(String roomId);
    void deleteRoom(String roomId);
    List&lt;UnifiedRoomResponse&gt; getAvailableRooms();

    // 참여자 관리  
    UnifiedRoomResponse joinRoom(String roomId, String memberId);
    UnifiedRoomResponse leaveRoom(String roomId, String memberId);
    List&lt;UnifiedRoomResponse&gt; getUserRooms(String memberId);

    // 메시지 처리
    void processMessage(UnifiedMessageRequest request);
    void sendSystemMessage(String roomId, String message);

    // 상태 확인
    boolean existsRoom(String roomId);
    boolean isRoomActive(String roomId);
    boolean canJoinRoom(String roomId, String memberId);
}</code></pre>
<h3 id="ai-게임룸-어댑터">AI 게임룸 어댑터</h3>
<p><strong>문제</strong>: 기존 <code>AiGameRoomService</code>를 건드리지 않고 통합 인터페이스 지원</p>
<p><strong>해결</strong>: Adapter 패턴으로 인터페이스 변환</p>
<pre><code class="language-java">@Service
public class AiGameRoomServiceAdapter implements RoomService {

    private final AiGameRoomService aiGameRoomService;

    @Override
    public RoomType getSupportedRoomType() {
        return RoomType.AI_GAME;
    }

    @Override
    public UnifiedRoomResponse createRoom(UnifiedRoomRequest request) {
        // UnifiedRoomRequest → AiGameRoomCreateRequest 변환
        AiGameRoomCreateRequest aiRequest = AiGameRoomCreateRequest.builder()
                .gameId(request.getGameId())
                .roomName(request.getRoomName())
                .maxParticipants(request.getMaxParticipants())
                .creatorId(request.getCreatorId())
                .build();

        // 기존 서비스 호출
        AiGameRoomResponse aiResponse = aiGameRoomService.createAiGameRoom(aiRequest);

        // AiGameRoomResponse → UnifiedRoomResponse 변환
        return UnifiedRoomResponse.fromAiGameRoom(aiResponse);
    }
}</code></pre>
<h3 id="채팅룸-어댑터">채팅룸 어댑터</h3>
<pre><code class="language-java">@Service  
public class ChatRoomServiceAdapter implements RoomService {

    private final ChatRoomService chatRoomService;

    @Override
    public RoomType getSupportedRoomType() {
        return RoomType.PLAYER_CHAT;
    }

    @Override
    public UnifiedRoomResponse createRoom(UnifiedRoomRequest request) {
        // UnifiedRoomRequest → ChatRoomCreateRequestDto 변환
        ChatRoomCreateRequestDto chatRequest = ChatRoomCreateRequestDto.builder()
            .roomName(request.getRoomName())
            .mode(request.getChatMode())
            .maxCapacity(request.getMaxCapacity())
            .build();

        // 기존 서비스 호출
        ChatRoomDto room = chatRoomService.createRoom(chatRequest);

        // ChatRoomDto → UnifiedRoomResponse 변환
        return UnifiedRoomResponse.fromPlayerChatRoom(room);
    }
}</code></pre>
<h2 id="통합-api-설계">통합 API 설계</h2>
<h3 id="단일-컨트롤러로-모든-룸-타입-지원">단일 컨트롤러로 모든 룸 타입 지원</h3>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/v1/rooms&quot;)
public class UnifiedRoomController {

    private final UnifiedRoomService unifiedRoomService;

    @PostMapping
    public RsData&lt;UnifiedRoomResponse&gt; createRoom(@Valid @RequestBody UnifiedRoomRequest request) {
        return unifiedRoomService.createRoom(request);
    }

    @GetMapping(&quot;/{roomType}/{roomId}&quot;)
    public RsData&lt;UnifiedRoomResponse&gt; getRoom(@PathVariable String roomType, @PathVariable String roomId) {
        return unifiedRoomService.getRoom(roomType, roomId);
    }

    @PostMapping(&quot;/{roomType}/{roomId}/join&quot;)  
    public RsData&lt;UnifiedRoomResponse&gt; joinRoom(@PathVariable String roomType, @PathVariable String roomId, @RequestBody RoomMemberRequest request) {
        return unifiedRoomService.joinRoom(roomType, roomId, request.getMemberId());
    }
}</code></pre>
<p><strong>API 예시</strong>:</p>
<pre><code class="language-bash"># AI 게임룸 생성
POST /v1/rooms
{
  &quot;roomType&quot;: &quot;AI_GAME&quot;,
  &quot;roomName&quot;: &quot;판타지 모험&quot;,
  &quot;gameSettings&quot;: &quot;중세 판타지 세계관&quot;
}

# 채팅룸 생성  
POST /v1/rooms
{
  &quot;roomType&quot;: &quot;PLAYER_CHAT&quot;, 
  &quot;roomName&quot;: &quot;자유 채팅&quot;,
  &quot;chatMode&quot;: &quot;MULTI&quot;
}

# 통합 룸 조회
GET /v1/rooms/ai-game/room-12345
GET /v1/rooms/player-chat/room-67890</code></pre>
<h3 id="responseservice-패턴">ResponseService 패턴</h3>
<p><strong>일관된 응답 처리</strong>:</p>
<pre><code class="language-java">@Service
public class UnifiedRoomService {

    public RsData&lt;UnifiedRoomResponse&gt; createRoom(UnifiedRoomRequest request) {
        try {
            // 요청 유효성 검증
            request.validateByRoomType();

            // Factory에서 서비스 선택
            RoomService roomService = roomServiceFactory.getService(request.getRoomType());

            // 룸 생성
            UnifiedRoomResponse response = roomService.createRoom(request);

            return RsData.of(&quot;200&quot;, &quot;룸 생성 성공&quot;, response);

        } catch (IllegalArgumentException e) {
            return RsData.of(&quot;400&quot;, &quot;잘못된 요청: &quot; + e.getMessage(), null);
        } catch (Exception e) {
            return RsData.of(&quot;500&quot;, &quot;룸 생성 중 오류가 발생했습니다&quot;, null);
        }
    }
}</code></pre>
<p><strong>장점</strong>:</p>
<ul>
<li>컨트롤러는 단순 위임만 처리</li>
<li>비즈니스 로직과 에러 처리를 서비스에서 담당</li>
<li>일관된 응답 형식 보장</li>
</ul>
<h2 id="dto-변환-및-매핑">DTO 변환 및 매핑</h2>
<h3 id="통합-요청응답-객체">통합 요청/응답 객체</h3>
<pre><code class="language-java">@Data
@Builder
public class UnifiedRoomRequest {
    private RoomType roomType;
    private String roomName;
    private String creatorId;
    private List&lt;String&gt; participantIds;

    // AI 게임룸 전용 필드
    private String gameId;
    private String gameSettings;
    private Integer maxParticipants;

    // 채팅룸 전용 필드  
    private ChatMode chatMode;
    private Integer maxCapacity;

    // 룸 타입별 유효성 검증
    public void validateByRoomType() {
        switch (roomType) {
            case AI_GAME:
                if (gameSettings == null) {
                    throw new IllegalArgumentException(&quot;AI 게임룸은 gameSettings가 필요합니다&quot;);
                }
                break;
            case PLAYER_CHAT:
                if (chatMode == null) {
                    throw new IllegalArgumentException(&quot;채팅룸은 chatMode가 필요합니다&quot;);
                }
                break;
        }
    }
}</code></pre>
<h3 id="응답-객체-변환">응답 객체 변환</h3>
<pre><code class="language-java">@Data
@Builder  
public class UnifiedRoomResponse {
    private String roomId;
    private RoomType roomType;
    private String roomName;
    private Integer currentParticipantCount;
    private Integer maxParticipantCount;

    // AI 게임룸 → 통합 응답 변환
    public static UnifiedRoomResponse fromAiGameRoom(AiGameRoomResponse aiRoom) {
        return UnifiedRoomResponse.builder()
                .roomId(aiRoom.getId())
                .roomType(RoomType.AI_GAME)
                .roomName(aiRoom.getRoomName())
                .currentParticipantCount(aiRoom.getCurrentParticipantCount())
                .maxParticipantCount(aiRoom.getMaxParticipants())
                .build();
    }

    // 채팅룸 → 통합 응답 변환
    public static UnifiedRoomResponse fromPlayerChatRoom(ChatRoomDto chatRoom) {
        return UnifiedRoomResponse.builder()
                .roomId(chatRoom.getId())
                .roomType(RoomType.PLAYER_CHAT) 
                .roomName(chatRoom.getRoomName())
                .currentParticipantCount(0) // 채팅룸은 현재 참여자 수 미지원
                .maxParticipantCount(Integer.MAX_VALUE)
                .build();
    }
}</code></pre>
<h2 id="매칭-시스템과의-통합">매칭 시스템과의 통합</h2>
<h3 id="통합-룸-생성으로-매칭-간소화">통합 룸 생성으로 매칭 간소화</h3>
<p><strong>Before (매칭 시스템의 기존 코드)</strong>:</p>
<pre><code class="language-java">// 각각 다른 서비스 호출로 복잡함
AiGameRoomResponse aiRoom = aiGameRoomService.createAiGameRoom(aiRequest);
ChatRoomDto chatRoom = chatRoomService.createRoom(chatRequest);</code></pre>
<p><strong>After (통합 룸 시스템 활용)</strong>:</p>
<pre><code class="language-java">public Map&lt;String, String&gt; createUnifiedRooms(String gameSessionId, List&lt;String&gt; participants, WorldType worldType) {
    Map&lt;String, String&gt; roomIds = new HashMap&lt;&gt;();

    // AI 게임룸 생성  
    UnifiedRoomRequest aiRoomRequest = UnifiedRoomRequest.builder()
            .roomType(RoomType.AI_GAME)
            .roomName(worldType.getDisplayName() + &quot; 랜덤 매칭&quot;)
            .creatorId(participants.get(0))
            .participantIds(participants)
            .gameId(gameSessionId)
            .gameSettings(worldType.getGameSettings())
            .build();

    UnifiedRoomResponse aiRoom = roomServiceFactory.getService(RoomType.AI_GAME)
                                                  .createRoom(aiRoomRequest);
    roomIds.put(&quot;ai&quot;, aiRoom.getRoomId());

    // 플레이어 채팅룸 생성
    UnifiedRoomRequest chatRoomRequest = UnifiedRoomRequest.builder()
            .roomType(RoomType.PLAYER_CHAT)
            .roomName(worldType.getDisplayName() + &quot; 채팅방&quot;)
            .creatorId(participants.get(0))
            .participantIds(participants)
            .chatMode(ChatMode.MULTI)
            .build();

    UnifiedRoomResponse chatRoom = roomServiceFactory.getService(RoomType.PLAYER_CHAT)
                                                    .createRoom(chatRoomRequest);
    roomIds.put(&quot;chat&quot;, chatRoom.getRoomId());

    return roomIds;
}</code></pre>
<p><strong>개선 효과</strong>:</p>
<ul>
<li>일관된 룸 생성 로직</li>
<li>새로운 룸 타입 추가 시 변경 최소화</li>
<li>에러 처리 통합</li>
</ul>
<h2 id="메시지-처리-통합">메시지 처리 통합</h2>
<h3 id="통합-메시지-인터페이스">통합 메시지 인터페이스</h3>
<pre><code class="language-java">public interface RoomService {
    void processMessage(UnifiedMessageRequest request);
    void sendSystemMessage(String roomId, String message);
}</code></pre>
<h3 id="메시지-타입-변환">메시지 타입 변환</h3>
<p><strong>AI 게임룸 어댑터</strong>:</p>
<pre><code class="language-java">@Override
public void processMessage(UnifiedMessageRequest request) {
    // UnifiedMessageRequest → AiGameMessageSendRequest 변환
    AiGameMessageSendRequest aiRequest = AiGameMessageSendRequest.builder()
            .aiGameRoomId(request.getRoomId())
            .senderId(request.getSenderId())
            .content(request.getContent())
            .messageType(mapToAiMessageType(request.getMessageType()))
            .build();

    // 욕설 필터링이 포함된 메시지 처리
    aiGameMessageService.handleWebSocketMessage(aiRequest);
}

private AiMessageType mapToAiMessageType(UnifiedMessageType unifiedType) {
    switch (unifiedType) {
        case USER: return AiMessageType.USER;
        case SYSTEM: return AiMessageType.SYSTEM;
        case AI_RESPONSE: return AiMessageType.AI;
        default: return AiMessageType.USER;
    }
}</code></pre>
<p><strong>채팅룸 어댑터</strong>:</p>
<pre><code class="language-java">@Override  
public void processMessage(UnifiedMessageRequest request) {
    // UnifiedMessageRequest → ChatMessageSendRequestDto 변환
    ChatMessageSendRequestDto chatRequest = ChatMessageSendRequestDto.builder()
            .roomId(request.getRoomId())
            .senderId(request.getSenderId())
            .content(request.getContent())
            .type(mapToChatMessageType(request.getMessageType()))
            .build();

    chatMessageService.processMessage(chatRequest);
}

private MessageType mapToChatMessageType(UnifiedMessageType unifiedType) {
    switch (unifiedType) {
        case USER:
        case SYSTEM:
        case OTHER_PLAYER:
            return MessageType.TALK;
        case PRESENCE:
            return MessageType.PRESENCE;
        default:
            return MessageType.TALK;
    }
}</code></pre>
<h2 id="성능-및-확장성">성능 및 확장성</h2>
<h3 id="1-지연-초기화-lazy-initialization">1. 지연 초기화 (Lazy Initialization)</h3>
<pre><code class="language-java">public class RoomServiceFactory {
    private void initializeServiceMap() {
        if (serviceMap == null) {  // 첫 요청 시에만 초기화
            serviceMap = roomServices.stream()
                .collect(Collectors.toMap(
                    RoomService::getSupportedRoomType,
                    Function.identity()
                ));
        }
    }
}</code></pre>
<h3 id="2-캐싱-전략">2. 캐싱 전략</h3>
<pre><code class="language-java">// 팩토리 상태 정보 캐싱
@Cacheable(&quot;factory-status&quot;)
public String getFactoryStatus() {
    return roomServiceFactory.getFactoryStatus();
}</code></pre>
<h3 id="3-확장성-보장">3. 확장성 보장</h3>
<p><strong>새로운 룸 타입 추가 절차</strong>:</p>
<ol>
<li><code>RoomType</code> 열거형에 새 타입 추가</li>
<li><code>RoomService</code> 인터페이스 구현</li>
<li>기존 어댑터 참고해서 새 어댑터 작성</li>
<li>Spring에서 자동 등록 → <strong>추가 설정 불필요</strong></li>
</ol>
<h2 id="에러-처리-및-안정성">에러 처리 및 안정성</h2>
<h3 id="통합-에러-처리">통합 에러 처리</h3>
<pre><code class="language-java">public RsData&lt;UnifiedRoomResponse&gt; createRoom(UnifiedRoomRequest request) {
    try {
        request.validateByRoomType();
        RoomService roomService = roomServiceFactory.getService(request.getRoomType());
        UnifiedRoomResponse response = roomService.createRoom(request);
        return RsData.of(&quot;200&quot;, &quot;룸 생성 성공&quot;, response);

    } catch (IllegalArgumentException e) {
        log.warn(&quot;룸 생성 실패 - 잘못된 요청: {}&quot;, e.getMessage());
        return RsData.of(&quot;400&quot;, &quot;잘못된 요청: &quot; + e.getMessage(), null);
    } catch (Exception e) {
        log.error(&quot;룸 생성 중 오류 발생: roomType={}&quot;, request.getRoomType(), e);
        return RsData.of(&quot;500&quot;, &quot;룸 생성 중 오류가 발생했습니다&quot;, null);
    }
}</code></pre>
<h3 id="서비스별-장애-격리">서비스별 장애 격리</h3>
<pre><code class="language-java">public RsData&lt;Map&lt;String, List&lt;UnifiedRoomResponse&gt;&gt;&gt; getAvailableRooms() {
    Map&lt;String, List&lt;UnifiedRoomResponse&gt;&gt; availableRooms = roomServiceFactory
            .getSupportedRoomTypes()
            .stream()
            .collect(Collectors.toMap(
                    RoomType::getCode,
                    roomType -&gt; {
                        try {
                            RoomService service = roomServiceFactory.getService(roomType);
                            return service.getAvailableRooms();
                        } catch (Exception e) {
                            log.warn(&quot;룸 타입 {} 조회 중 오류: {}&quot;, roomType, e.getMessage());
                            return List.of(); // 해당 타입만 실패, 다른 타입은 정상 처리
                        }
                    }
            ));

    return RsData.of(&quot;200&quot;, &quot;입장 가능한 룸 목록 조회 성공&quot;, availableRooms);
}</code></pre>
<h2 id="핵심-성과-및-배운-점">핵심 성과 및 배운 점</h2>
<h3 id="성공한-설계-결정">성공한 설계 결정</h3>
<p><strong>1. Factory 패턴의 효과</strong></p>
<ul>
<li>룸 타입 추가 시 설정 변경 불필요</li>
<li>런타임에 자동 서비스 발견</li>
<li>코드 중복 제거</li>
</ul>
<p><strong>2. Adapter 패턴의 효과</strong></p>
<ul>
<li>기존 서비스 코드 수정 없음</li>
<li>점진적 마이그레이션 가능</li>
<li>인터페이스 통합으로 일관성 확보</li>
</ul>
<p><strong>3. ResponseService 패턴</strong></p>
<ul>
<li>컨트롤러 단순화</li>
<li>일관된 에러 처리</li>
<li>비즈니스 로직 집중</li>
</ul>
<h3 id="개선이-필요한-부분">개선이 필요한 부분</h3>
<p><strong>1. DTO 변환 오버헤드</strong></p>
<ul>
<li>현재: 매번 객체 변환 발생</li>
<li>목표: 변환 캐싱 또는 직접 매핑</li>
</ul>
<p><strong>2. 타입 안전성</strong></p>
<ul>
<li>현재: 문자열 기반 룸 타입 코드</li>
<li>목표: 컴파일 타임 타입 체크 강화</li>
</ul>
<p><strong>3. 부분 실패 처리</strong></p>
<ul>
<li>현재: 개별 서비스 실패 시 빈 리스트 반환</li>
<li>목표: 서킷 브레이커 패턴 적용</li>
</ul>
<h2 id="통합-효과-측정">통합 효과 측정</h2>
<h3 id="before-vs-after-비교">Before vs After 비교</h3>
<p><strong>API 엔드포인트 수</strong>:</p>
<ul>
<li>Before: 16개 (AI 룸 8개 + 채팅룸 8개)</li>
<li>After: 8개 (통합 API)</li>
<li><strong>50% 감소</strong></li>
</ul>
<p><strong>매칭 시스템 코드 복잡도</strong>:</p>
<ul>
<li>Before: 2개 서비스 × 다른 인터페이스 = 복잡한 분기 처리</li>
<li>After: 1개 팩토리 × 통합 인터페이스 = 단순한 룸 생성</li>
<li><strong>코드 라인 60% 감소</strong></li>
</ul>
<p><strong>새 룸 타입 추가 비용</strong>:</p>
<ul>
<li>Before: 컨트롤러, 서비스, API 문서 모두 추가</li>
<li>After: 어댑터 1개만 추가</li>
<li><strong>개발 시간 70% 단축</strong></li>
</ul>
<h2 id="시리즈-마무리">시리즈 마무리</h2>
<p><strong>4편까지의 여정 정리</strong>:</p>
<p>1편: <strong>멀티 데이터베이스 아키텍처</strong> → 기반 설계
2편: <strong>AI 채팅 시스템</strong> → 실시간 게임 로직<br>3편: <strong>실시간 매칭 시스템</strong> → 공정한 플레이어 매칭
4편: <strong>통합 룸 시스템</strong> → 확장 가능한 통합 관리</p>
<p><strong>전체 시스템의 시너지</strong>:</p>
<ul>
<li>매칭 시스템이 통합 룸 시스템을 활용해 룸 생성</li>
<li>통합 룸 시스템이 AI 채팅 시스템과 연계해 게임 진행</li>
<li>멀티 데이터베이스가 각 도메인의 데이터 특성을 최적 지원</li>
</ul>
<p><strong>다음 5편 예고</strong>: 개발 회고 &amp; 성능 최적화</p>
<ul>
<li>전체 시스템 성능 분석 및 최적화 사례</li>
<li>개발 과정에서 겪은 시행착오와 해결책</li>
<li>실제 서비스 런칭을 위한 운영 고려사항</li>
<li>팀 협업과 코드 품질 관리 경험</li>
</ul>
<hr>
<p><strong>시리즈 목차</strong></p>
<ol>
<li>멀티 데이터베이스 아키텍처 설계</li>
<li>AI 채팅 시스템 구현 (aichat)</li>
<li>실시간 매칭 시스템 구현 (matching)</li>
<li><strong>통합 룸 시스템 구현</strong> ← 현재</li>
<li>개발 회고 &amp; 성능 최적화</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[ DungeonTalk 백엔드 개발기 - 3편: 실시간 매칭 시스템 구현]]></title>
            <link>https://velog.io/@mj_o/DungeonTalk-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-3%ED%8E%B8-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EB%A7%A4%EC%B9%AD-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84-505gi0nf</link>
            <guid>https://velog.io/@mj_o/DungeonTalk-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-3%ED%8E%B8-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EB%A7%A4%EC%B9%AD-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84-505gi0nf</guid>
            <pubDate>Thu, 14 Aug 2025 07:40:11 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>3명이 만나야 시작되는 AI 게임, Redis Queue로 공정한 매칭을 만들다</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>AI 채팅 시스템을 구현했다면, 이제 플레이어들을 <strong>공정하게 매칭</strong>해야 합니다. 3명이 모여야 시작되는 게임 특성상, 매칭 시스템의 안정성과 실시간성이 사용자 경험을 좌우합니다. </p>
<p>이번 편에서는 Redis Queue 기반 매칭 알고리즘과 WebSocket 실시간 알림, 그리고 분산 환경에서의 동시성 제어까지 - 매칭 시스템 구현의 모든 과정을 공유합니다.</p>
<h2 id="매칭-시스템-요구사항">매칭 시스템 요구사항</h2>
<h3 id="핵심-요구사항">핵심 요구사항</h3>
<p><strong>1. 공정한 선입선출 (FIFO)</strong></p>
<ul>
<li>먼저 대기한 사람이 먼저 매칭</li>
<li>세계관별 독립적인 큐 관리</li>
</ul>
<p><strong>2. 실시간 상태 알림</strong></p>
<ul>
<li>현재 대기 순번과 예상 시간</li>
<li>매칭 완료 즉시 알림</li>
</ul>
<p><strong>3. 안정적인 매칭 처리</strong></p>
<ul>
<li>동시성 제어로 중복 매칭 방지</li>
<li>매칭 실패 시 자동 복구</li>
</ul>
<p><strong>4. 확장 가능한 구조</strong></p>
<ul>
<li>세계관 추가 용이</li>
<li>매칭 규칙 변경 유연</li>
</ul>
<h2 id="시스템-아키텍처">시스템 아키텍처</h2>
<h3 id="컴포넌트-구조">컴포넌트 구조</h3>
<pre><code>매칭 시스템
├── MatchingController        - REST API 엔드포인트
├── MatchingWebSocketController - WebSocket 매칭 처리  
├── MatchingService          - 매칭 비즈니스 로직
├── MatchingQueueManager     - Redis Queue 관리
├── MatchingWebSocketService - 실시간 알림 전송
└── WorldType               - 세계관별 설정</code></pre><h3 id="데이터-플로우">데이터 플로우</h3>
<pre><code>사용자 매칭 요청 → Redis Queue 추가 → 실시간 상태 알림
                      ↓
               3명 모이면 → Lua 스크립트로 추출
                      ↓  
          분산 락 → 룸 생성 → 매칭 완료 알림</code></pre><h2 id="redis-queue-설계">Redis Queue 설계</h2>
<h3 id="세계관별-queue-구조">세계관별 Queue 구조</h3>
<p><strong>핵심 아이디어</strong>: 각 세계관마다 독립적인 Queue와 통계를 유지</p>
<pre><code class="language-java">public enum WorldType {
    FANTASY(&quot;판타지&quot;, &quot;중세 판타지 - 마법과 모험의 세계&quot;),
    SF(&quot;SF&quot;, &quot;미래 SF - 과학기술과 우주탐험&quot;), 
    MODERN(&quot;현대&quot;, &quot;현대 도시 - 일상과 미스터리&quot;);

    public String getQueueKey() {
        return &quot;matching:queue:&quot; + this.name();
    }

    public String getStatsKey() {
        return &quot;matching:stats:&quot; + this.name();
    }
}</code></pre>
<p><strong>Redis 데이터 구조</strong>:</p>
<ul>
<li><code>matching:queue:FANTASY</code> - 판타지 세계관 대기열 (List)</li>
<li><code>matching:user:{memberId}</code> - 사용자 매칭 정보 (Hash)</li>
<li><code>matching:stats:FANTASY</code> - 큐 통계 정보 (Hash)</li>
<li><code>matching:lock:FANTASY</code> - 매칭 처리 분산 락 (String)</li>
</ul>
<h3 id="fifo-보장-전략">FIFO 보장 전략</h3>
<p><strong>문제</strong>: Redis List의 <code>leftPush</code> + <code>rightPop</code>으로 FIFO 구현</p>
<p><strong>해결</strong>: 정확한 큐 위치 계산 로직</p>
<pre><code class="language-java">public int getUserQueuePosition(String userId, WorldType worldType) {
    String queueKey = worldType.getQueueKey();
    List&lt;String&gt; queueUsers = redisTemplate.opsForList().range(queueKey, 0, -1);

    if (queueUsers != null) {
        // leftPush로 추가하므로, 뒤에서부터 찾아야 함 (FIFO 순서)
        for (int i = queueUsers.size() - 1; i &gt;= 0; i--) {
            if (userId.equals(queueUsers.get(i))) {
                return queueUsers.size() - i; // 1부터 시작하는 위치
            }
        }
    }
    return -1;
}</code></pre>
<h2 id="동시성-제어">동시성 제어</h2>
<h3 id="분산-락을-통한-원자적-매칭">분산 락을 통한 원자적 매칭</h3>
<p><strong>문제</strong>: 동시에 여러 요청이 들어와서 중복 매칭이 발생할 수 있음</p>
<p><strong>해결</strong>: Redis 분산 락 + Lua 스크립트</p>
<pre><code class="language-java">public MatchingCompleteResponse processMatching(WorldType worldType) {
    String lockKey = MatchingConstants.LOCK_KEY_PREFIX + worldType.name();
    Boolean lockAcquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, &quot;PROCESSING&quot;, Duration.ofSeconds(5));

    if (!Boolean.TRUE.equals(lockAcquired)) {
        log.warn(&quot;매칭 처리 락 획득 실패: 다른 프로세스에서 처리 중&quot;);
        return null;
    }

    try {
        // 실제 매칭 처리 로직
        List&lt;String&gt; participants = queueManager.extractThreeUsers(worldType);
        // ...
    } finally {
        redisTemplate.delete(lockKey); // 락 해제
    }
}</code></pre>
<h3 id="lua-스크립트로-원자적-추출">Lua 스크립트로 원자적 추출</h3>
<p><strong>핵심</strong>: 3명 추출을 하나의 원자적 연산으로 처리</p>
<pre><code class="language-java">private static final String EXTRACT_USERS_SCRIPT = &quot;&quot;&quot;
    local queueKey = KEYS[1]
    local queueSize = redis.call(&#39;LLEN&#39;, queueKey)

    if queueSize &lt; 3 then
        return nil
    end

    local users = {}
    for i = 1, 3 do
        local user = redis.call(&#39;RPOP&#39;, queueKey)
        if user then
            table.insert(users, user)
        end
    end

    return users
    &quot;&quot;&quot;;</code></pre>
<p><strong>장점</strong>:</p>
<ul>
<li>3명 추출이 모두 성공하거나 모두 실패</li>
<li>Race Condition 완전 방지</li>
<li>Redis 서버에서 실행되므로 네트워크 지연 최소화</li>
</ul>
<h2 id="실시간-websocket-알림">실시간 WebSocket 알림</h2>
<h3 id="websocket-메시지-구조">WebSocket 메시지 구조</h3>
<pre><code class="language-java">@MessageMapping(&quot;/matching/join&quot;)
public void joinMatching(@Payload MatchingJoinRequest request) {
    matchingService.handleWebSocketJoinMatching(
        request.getMemberId(), request.getWorldType());
}</code></pre>
<p><strong>메시지 플로우</strong>:</p>
<ol>
<li>클라이언트 → <code>/pub/matching/join</code></li>
<li>큐 추가 → Redis 상태 업데이트</li>
<li>서버 → <code>/sub/matching/user/{memberId}</code> 상태 알림</li>
</ol>
<h3 id="실시간-상태-알림-전략">실시간 상태 알림 전략</h3>
<p><strong>개별 알림</strong>: 각 사용자에게 맞춤형 정보 전송</p>
<pre><code class="language-java">public void sendQueueStatusUpdate(String memberId, WorldType worldType, 
                                int currentPosition, int totalInQueue, long waitingTime) {
    MatchingWebSocketMessage message = messageFactory.createQueueStatusUpdate(
            memberId, worldType, currentPosition, totalInQueue, waitingTime);

    String destination = MatchingConstants.WS_TOPIC_USER_STATUS + memberId;
    messagingTemplate.convertAndSend(destination, message);
}</code></pre>
<p><strong>브로드캐스트</strong>: 세계관별 전체 통계 공유</p>
<pre><code class="language-java">public void broadcastQueueStats(WorldType worldType, int currentWaiting) {
    String destination = MatchingConstants.WS_TOPIC_QUEUE_STATS + worldType.name().toLowerCase();
    messagingTemplate.convertAndSend(destination, message);
}</code></pre>
<h3 id="매칭-완료-알림-로직">매칭 완료 알림 로직</h3>
<pre><code class="language-java">public void sendMatchingComplete(List&lt;String&gt; participants, WorldType worldType,
                               String gameSessionId, String aiGameRoomId, String chatRoomId) {
    MatchingWebSocketMessage message = messageFactory.createMatchingComplete(
            participants, worldType, gameSessionId, aiGameRoomId, chatRoomId);

    // 각 참가자에게 개별 전송
    for (String memberId : participants) {
        String destination = MatchingConstants.WS_TOPIC_USER_STATUS + memberId;
        messagingTemplate.convertAndSend(destination, message);
    }
}</code></pre>
<p><strong>매칭 완료 정보 포함 내용</strong>:</p>
<ul>
<li>게임 세션 ID</li>
<li>AI 게임방 ID  </li>
<li>채팅방 ID</li>
<li>매칭된 참가자 목록</li>
</ul>
<h2 id="factory-패턴으로-룸-생성">Factory 패턴으로 룸 생성</h2>
<h3 id="통합-룸-생성-전략">통합 룸 생성 전략</h3>
<p><strong>문제</strong>: AI 게임방과 채팅방을 각각 생성해야 함</p>
<p><strong>해결</strong>: Factory 패턴으로 룸 타입별 통합 생성</p>
<pre><code class="language-java">public Map&lt;String, String&gt; createUnifiedRooms(String gameSessionId, 
                                            List&lt;String&gt; participants, WorldType worldType) {
    Map&lt;String, String&gt; roomIds = new HashMap&lt;&gt;();

    // AI 게임룸 생성
    UnifiedRoomRequest aiRoomRequest = UnifiedRoomRequest.builder()
            .roomType(RoomType.AI_GAME)
            .roomName(worldType.getDisplayName() + &quot; 랜덤 매칭&quot;)
            .maxParticipants(DEFAULT_MAX_PARTICIPANTS)
            .creatorId(participants.get(0))
            .participantIds(participants)
            .gameId(gameSessionId)
            .build();

    UnifiedRoomResponse aiRoom = roomServiceFactory.getService(RoomType.AI_GAME)
                                                  .createRoom(aiRoomRequest);
    roomIds.put(&quot;ai&quot;, aiRoom.getRoomId());

    // 플레이어 채팅룸 생성
    UnifiedRoomRequest chatRoomRequest = UnifiedRoomRequest.builder()
            .roomType(RoomType.PLAYER_CHAT)
            .roomName(worldType.getDisplayName() + &quot; 채팅방&quot;)
            .maxParticipants(DEFAULT_MAX_PARTICIPANTS)
            .creatorId(participants.get(0))
            .participantIds(participants)
            .build();

    UnifiedRoomResponse chatRoom = roomServiceFactory.getService(RoomType.PLAYER_CHAT)
                                                    .createRoom(chatRoomRequest);
    roomIds.put(&quot;chat&quot;, chatRoom.getRoomId());

    return roomIds;
}</code></pre>
<h2 id="대기시간-예측-시스템">대기시간 예측 시스템</h2>
<h3 id="예측-알고리즘">예측 알고리즘</h3>
<p><strong>기본 로직</strong>: <code>(현재 대기 순번 - 1) / 3 * 평균 매칭 시간</code></p>
<pre><code class="language-java">public int calculateEstimatedWaitTime(int position, int averageMatchTime) {
    if (position &lt;= 3) {
        return averageMatchTime; // 다음 매칭에 포함될 예정
    }

    int matchesNeeded = (position - 1) / 3;
    return matchesNeeded * averageMatchTime;
}</code></pre>
<p><strong>고려사항</strong>:</p>
<ul>
<li>평균 매칭 시간 실시간 업데이트</li>
<li>시간대별 사용자 패턴 반영</li>
<li>세계관별 인기도 차이 보정</li>
</ul>
<h2 id="정기적-매칭-처리">정기적 매칭 처리</h2>
<h3 id="스케줄링-기반-매칭">스케줄링 기반 매칭</h3>
<p><strong>문제</strong>: 실시간 요청 외에도 정기적으로 매칭 확인 필요</p>
<p><strong>해결</strong>: Spring Scheduler로 5초마다 전체 큐 검사</p>
<pre><code class="language-java">@Scheduled(fixedDelay = 5000)
public void processAllQueueMatching() {
    for (WorldType worldType : WorldType.values()) {
        try {
            if (queueManager.canProcessMatching(worldType)) {
                log.info(&quot;정기 매칭 처리 시작: worldType={}, queueSize={}&quot;, 
                        worldType, queueManager.getQueueSize(worldType));
                processMatchingAsync(worldType);
            }
        } catch (Exception e) {
            log.error(&quot;정기 매칭 처리 중 오류: worldType={}&quot;, worldType, e);
        }
    }
}</code></pre>
<h3 id="비동기-매칭-처리">비동기 매칭 처리</h3>
<pre><code class="language-java">@Async(&quot;matchingTaskExecutor&quot;)
public void processMatchingAsync(WorldType worldType) {
    try {
        processMatching(worldType);
    } catch (Exception e) {
        log.error(&quot;비동기 매칭 처리 중 오류: worldType={}&quot;, worldType, e);
    }
}</code></pre>
<p><strong>장점</strong>:</p>
<ul>
<li>메인 스레드 블로킹 방지</li>
<li>여러 세계관 동시 처리 가능</li>
<li>에러 격리로 안정성 향상</li>
</ul>
<h2 id="에러-처리-및-복구">에러 처리 및 복구</h2>
<h3 id="매칭-실패-시나리오">매칭 실패 시나리오</h3>
<p><strong>1. 룸 생성 실패</strong></p>
<pre><code class="language-java">try {
    Map&lt;String, String&gt; roomIds = createUnifiedRooms(gameSessionId, participants, worldType);
} catch (Exception e) {
    // 참가자들을 다시 큐에 추가
    participants.forEach(memberId -&gt; 
        queueManager.addToQueue(memberId, worldType));
    throw new AiChatException(ErrorCode.MATCHING_PROCESSING_ERROR, &quot;룸 생성 실패&quot;);
}</code></pre>
<p><strong>2. Redis 연결 장애</strong></p>
<pre><code class="language-java">public void clearQueue(WorldType worldType) {
    try {
        // 큐 정리 로직
    } catch (QueryTimeoutException e) {
        throw new AiChatException(ErrorCode.MATCHING_PROCESSING_ERROR, &quot;큐 정리 타임아웃&quot;);
    } catch (DataAccessException e) {
        throw new AiChatException(ErrorCode.MATCHING_PROCESSING_ERROR, &quot;Redis 액세스 오류&quot;);
    }
}</code></pre>
<p><strong>3. WebSocket 알림 실패</strong></p>
<ul>
<li>알림 실패 시에도 매칭은 진행</li>
<li>로그 기록 후 계속 처리</li>
<li>사용자는 게임방 새로고침으로 복구</li>
</ul>
<h2 id="모니터링-및-통계">모니터링 및 통계</h2>
<h3 id="매칭-세션-정보-저장">매칭 세션 정보 저장</h3>
<pre><code class="language-java">private void saveMatchingSession(String gameSessionId, String aiGameRoomId, String chatRoomId, 
                               List&lt;String&gt; participants, WorldType worldType) {
    String sessionKey = MatchingConstants.SESSION_KEY_PREFIX + gameSessionId;

    Map&lt;String, String&gt; sessionInfo = Map.of(
            &quot;participants&quot;, String.join(&quot;,&quot;, participants),
            &quot;worldType&quot;, worldType.name(),
            &quot;aiGameRoomId&quot;, aiGameRoomId,
            &quot;chatRoomId&quot;, chatRoomId,
            &quot;createdAt&quot;, Instant.now().toString(),
            &quot;status&quot;, &quot;ACTIVE&quot;
    );

    redisTemplate.opsForHash().putAll(sessionKey, sessionInfo);
    redisTemplate.expire(sessionKey, Duration.ofSeconds(604800)); // 7일
}</code></pre>
<h3 id="큐-통계-실시간-업데이트">큐 통계 실시간 업데이트</h3>
<pre><code class="language-java">private void updateQueueStats(WorldType worldType, int delta) {
    String statsKey = worldType.getStatsKey();

    redisTemplate.opsForHash().increment(statsKey, &quot;currentWaiting&quot;, delta);
    redisTemplate.opsForHash().put(statsKey, &quot;lastUpdated&quot;, Instant.now().toString());

    redisTemplate.expire(statsKey, Duration.ofDays(1));
}</code></pre>
<h2 id="성능-최적화">성능 최적화</h2>
<h3 id="1-lua-스크립트-활용">1. Lua 스크립트 활용</h3>
<ul>
<li>여러 Redis 명령을 하나의 원자적 연산으로 처리</li>
<li>네트워크 라운드 트립 최소화</li>
</ul>
<h3 id="2-비동기-처리">2. 비동기 처리</h3>
<ul>
<li>전용 스레드 풀로 매칭 처리 분리</li>
<li>사용자 요청에 대한 즉시 응답</li>
</ul>
<h3 id="3-ttl-관리">3. TTL 관리</h3>
<ul>
<li>사용자 상태: 1시간 TTL</li>
<li>세션 정보: 7일 TTL  </li>
<li>통계 데이터: 1일 TTL</li>
</ul>
<h3 id="4-분산-락-타임아웃">4. 분산 락 타임아웃</h3>
<ul>
<li>5초 타임아웃으로 데드락 방지</li>
<li>락 해제 보장으로 시스템 안정성</li>
</ul>
<h2 id="핵심-성과-및-배운-점">핵심 성과 및 배운 점</h2>
<h3 id="성공한-설계-결정">성공한 설계 결정</h3>
<p><strong>1. Redis Queue + Lua 스크립트</strong></p>
<ul>
<li>공정한 FIFO 보장</li>
<li>원자적 연산으로 동시성 문제 해결</li>
</ul>
<p><strong>2. WebSocket 실시간 알림</strong></p>
<ul>
<li>사용자 경험 향상</li>
<li>대기 시간에 대한 명확한 피드백</li>
</ul>
<p><strong>3. Factory 패턴 룸 생성</strong></p>
<ul>
<li>AI 게임방 + 채팅방 통합 생성</li>
<li>확장 가능한 룸 타입 관리</li>
</ul>
<h3 id="개선이-필요한-부분">개선이 필요한 부분</h3>
<p><strong>1. 대기시간 예측 정확도</strong></p>
<ul>
<li>현재: 단순 평균 기반</li>
<li>목표: ML 기반 동적 예측</li>
</ul>
<p><strong>2. 매칭 실패 복구</strong>  </p>
<ul>
<li>현재: 에러 로그 + 수동 처리</li>
<li>목표: 자동 재시도 + 폴백 전략</li>
</ul>
<p><strong>3. 부하 분산</strong></p>
<ul>
<li>현재: 단일 Redis 인스턴스</li>
<li>목표: Redis Cluster로 확장</li>
</ul>
<h2 id="다음-편-예고">다음 편 예고</h2>
<p>4편에서는 <strong>통합 룸 시스템</strong>을 다룰 예정입니다:</p>
<ul>
<li>Factory 패턴으로 다양한 룸 타입 통합 관리</li>
<li>Adapter 패턴으로 기존 서비스와의 호환성 보장</li>
<li>확장 가능한 룸 시스템 아키텍처</li>
<li>룸 간 데이터 동기화 및 상태 관리</li>
</ul>
<hr>
<p><strong>시리즈 목차</strong></p>
<ol>
<li>멀티 데이터베이스 아키텍처 설계</li>
<li>AI 채팅 시스템 구현 (aichat)</li>
<li><strong>실시간 매칭 시스템 구현</strong> ← 현재  </li>
<li>통합 룸 시스템 구현 (room)</li>
<li>개발 회고 &amp; 성능 최적화</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[DungeonTalk 백엔드 개발기 - 2편: AI 채팅 시스템 구현]]></title>
            <link>https://velog.io/@mj_o/DungeonTalk-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-2%ED%8E%B8-AI-%EC%B1%84%ED%8C%85-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@mj_o/DungeonTalk-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-2%ED%8E%B8-AI-%EC%B1%84%ED%8C%85-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Thu, 14 Aug 2025 07:34:27 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>실시간 턴제 AI 게임 채팅을 구현하며 마주한 기술적 도전들</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>1편에서 멀티 데이터베이스 아키텍처를 살펴봤다면, 이번 편에서는 <strong>AI 채팅 시스템</strong>의 구체적인 구현을 다뤄보겠습니다. 실시간 WebSocket 통신과 외부 AI 서비스 연동, 그리고 턴제 게임 상태 관리까지 - 생각보다 복잡했던 여정을 공유합니다.</p>
<h2 id="ai-채팅-시스템-아키텍처">AI 채팅 시스템 아키텍처</h2>
<h3 id="핵심-설계-원칙">핵심 설계 원칙</h3>
<p><strong>1. 턴제 게임 로직</strong></p>
<ul>
<li>플레이어들이 순서대로 메시지 입력</li>
<li>AI 응답 중에는 모든 입력 차단</li>
<li>게임 페이즈별 상태 관리</li>
</ul>
<p><strong>2. 실시간 통신</strong></p>
<ul>
<li>WebSocket + STOMP 기반 양방향 통신</li>
<li>룸별 독립적인 메시지 채널</li>
<li>자동 재연결 및 에러 처리</li>
</ul>
<p><strong>3. 외부 서비스 연동</strong></p>
<ul>
<li>Python AI 서비스와 HTTP 통신</li>
<li>비동기 처리 및 타임아웃 관리</li>
<li>장애 대응 및 폴백 처리</li>
</ul>
<h2 id="도메인-구조">도메인 구조</h2>
<h3 id="패키지-구성">패키지 구성</h3>
<pre><code>aichat/
├── controller/     - REST API &amp; WebSocket 컨트롤러
├── service/        - 비즈니스 로직 처리
├── entity/         - MongoDB 엔티티
├── dto/            - 데이터 전송 객체
├── common/         - 열거형 및 상수
└── event/          - 비동기 이벤트 처리</code></pre><h3 id="핵심-컴포넌트">핵심 컴포넌트</h3>
<p><strong>컨트롤러 레이어</strong></p>
<ul>
<li><code>AiGameRoomController</code>: 게임방 CRUD API</li>
<li><code>AiChatStompController</code>: 실시간 메시징 처리</li>
</ul>
<p><strong>서비스 레이어</strong>  </p>
<ul>
<li><code>AiGameFlowService</code>: AI 턴 플로우 관리</li>
<li><code>AiGameStateService</code>: 게임 상태 및 세션 관리</li>
<li><code>AiApiService</code>: 외부 AI 서비스 연동</li>
</ul>
<h2 id="websocket-실시간-메시징">WebSocket 실시간 메시징</h2>
<h3 id="stomp-메시지-매핑">STOMP 메시지 매핑</h3>
<pre><code class="language-java">@Controller
public class AiChatStompController {

    @MessageMapping(&quot;/aichat/send&quot;)
    public RsData&lt;String&gt; sendMessage(@Payload AiGameMessageSendRequest request) {
        return aiGameMessageService.handleWebSocketMessage(request);
    }

    @MessageMapping(&quot;/aichat/join&quot;)
    public RsData&lt;String&gt; joinRoom(@Payload AiGameMessageSendRequest request) {
        return aiGameMessageService.handleJoinRoom(request);
    }
}</code></pre>
<p><strong>메시지 플로우</strong>:</p>
<ol>
<li>클라이언트 → <code>/pub/aichat/send</code></li>
<li>서버 처리 → MongoDB 저장 → Redis Pub/Sub</li>
<li>구독자들에게 → <code>/sub/aichat/room/{roomId}</code></li>
</ol>
<h3 id="실시간-상태-동기화">실시간 상태 동기화</h3>
<p>핵심 아이디어는 <strong>게임 상태를 Redis에서 관리</strong>하면서 <strong>메시지는 MongoDB에 저장</strong>하는 것입니다.</p>
<ul>
<li><strong>Redis</strong>: 세션, 게임 페이즈, 턴 락 관리</li>
<li><strong>MongoDB</strong>: 메시지 히스토리 영구 저장</li>
<li><strong>WebSocket</strong>: 실시간 상태 브로드캐스트</li>
</ul>
<h2 id="게임-상태-관리">게임 상태 관리</h2>
<h3 id="게임-페이즈-시스템">게임 페이즈 시스템</h3>
<pre><code class="language-java">public enum AiGamePhase {
    WAITING,      // 플레이어 입장 대기
    TURN_INPUT,   // 플레이어 입력 단계  
    AI_RESPONSE,  // AI 응답 생성 중 (입력 차단)
    GAME_END      // 게임 종료
}</code></pre>
<h3 id="턴-락-메커니즘">턴 락 메커니즘</h3>
<p><strong>문제</strong>: AI 응답 중에 여러 플레이어가 동시에 메시지를 보내면?</p>
<p><strong>해결</strong>: Redis 분산 락으로 AI 처리 중 상태 관리</p>
<pre><code class="language-java">public boolean lockForAiResponse(String roomId) {
    String lockKey = AI_GAME_TURN_LOCK_PREFIX + roomId;
    boolean locked = valkeyService.setIfNotExists(lockKey, &quot;AI_PROCESSING&quot;, 300);

    if (locked) {
        changePhase(roomId, AiGamePhase.AI_RESPONSE);
    }
    return locked;
}</code></pre>
<p><strong>동작 과정</strong>:</p>
<ol>
<li>플레이어 메시지 → AI 응답 요청</li>
<li>Redis 락 설정 → 페이즈를 <code>AI_RESPONSE</code>로 변경</li>
<li>다른 메시지들은 차단</li>
<li>AI 응답 완료 → 락 해제 → <code>TURN_INPUT</code>으로 복귀</li>
</ol>
<h3 id="세션-관리-전략">세션 관리 전략</h3>
<pre><code class="language-java">public AiGameRoomResponse startGameSession(String roomId) {
    // MongoDB에서 게임 상태 변경
    AiGameRoom updatedRoom = room.toBuilder()
            .status(AiGameStatus.ACTIVE)
            .currentPhase(AiGamePhase.TURN_INPUT)
            .build();

    // Redis에 세션 정보 저장 (TTL 1시간)
    String sessionKey = AI_GAME_SESSION_PREFIX + roomId;
    valkeyService.setWithExpiration(sessionKey, sessionData, 3600);

    return AiGameRoomResponse.fromEntity(updatedRoom);
}</code></pre>
<p><strong>이중 저장 전략</strong>:</p>
<ul>
<li><strong>MongoDB</strong>: 영구 데이터 (게임 히스토리)  </li>
<li><strong>Redis</strong>: 휘발성 데이터 (세션, 락, 캐시)</li>
</ul>
<h2 id="ai-서비스-연동">AI 서비스 연동</h2>
<h3 id="http-클라이언트-설계">HTTP 클라이언트 설계</h3>
<pre><code class="language-java">@Service
public class AiApiService {

    public AiResponseResult generateAiResponse(String gameId, String roomId, 
                                             String currentUser, String currentMessage,
                                             List&lt;AiGameMessageDto&gt; contextMessages, 
                                             int turnNumber) {

        String url = aiServiceUrl + &quot;/ai-response&quot;;

        // 요청 데이터 구성
        AiResponseRequest request = AiResponseRequest.builder()
                .gameId(gameId)
                .aiGameRoomId(roomId)
                .currentUser(currentUser)
                .currentMessage(currentMessage)
                .contextMessages(contextMessages.stream()
                        .map(this::convertToContextMessage)
                        .toList())
                .turnNumber(turnNumber)
                .build();

        // Python AI 서비스 호출
        ResponseEntity&lt;Map&gt; response = restTemplate.exchange(
                url, HttpMethod.POST, httpEntity, Map.class);

        // 응답 검증 및 결과 반환
        return parseAiResponse(response);
    }
}</code></pre>
<h3 id="비동기-처리-패턴">비동기 처리 패턴</h3>
<p><strong>문제</strong>: AI 응답 생성이 최대 60초까지 소요될 수 있음</p>
<p><strong>해결</strong>: 이벤트 기반 비동기 처리</p>
<pre><code class="language-java">@EventListener
@Async(&quot;matchingTaskExecutor&quot;)
public void handleAiTurnProcessEvent(AiTurnProcessEvent event) {
    processAiTurn(event.getAiGameRoomId(), event.getAiRequest());
}</code></pre>
<p><strong>처리 플로우</strong>:</p>
<ol>
<li>플레이어 메시지 수신 → 즉시 응답 (논블로킹)</li>
<li>백그라운드에서 AI 이벤트 처리</li>
<li>AI 응답 완료 → WebSocket으로 결과 브로드캐스트</li>
</ol>
<h3 id="장애-처리-전략">장애 처리 전략</h3>
<pre><code class="language-java">public AiResponseResult generateAiResponse(...) {
    try {
        // AI 서비스 호출
        return callAiService(request);

    } catch (ResourceAccessException e) {
        log.error(&quot;AI 서비스 연결 시간 초과: roomId={}&quot;, roomId);
        throw new AiChatException(ErrorCode.AI_RESPONSE_TIMEOUT_ERROR, e);

    } catch (RestClientException e) {
        log.error(&quot;AI 서비스 호출 실패: roomId={}&quot;, roomId);
        throw new AiChatException(ErrorCode.AI_RESPONSE_PROCESSING_ERROR, e);
    }
}</code></pre>
<p><strong>에러 시나리오별 대응</strong>:</p>
<ul>
<li><strong>타임아웃</strong>: 게임 일시정지 + 에러 메시지 전송</li>
<li><strong>서비스 장애</strong>: 폴백 응답 또는 재시도</li>
<li><strong>잘못된 응답</strong>: 데이터 검증 후 에러 처리</li>
</ul>
<h2 id="메시지-순서-보장">메시지 순서 보장</h2>
<h3 id="mongodb-인덱싱-전략">MongoDB 인덱싱 전략</h3>
<p><strong>핵심 아이디어</strong>: <code>messageOrder</code> 필드로 메시지 순서 보장</p>
<pre><code class="language-java">@Document(collection = &quot;ai_game_messages&quot;)  
public class AiGameMessage {
    @Id private String id;
    private String roomId;
    private String content;
    @Indexed private Integer messageOrder;  // 순서 보장용
    private Integer turnNumber;
}</code></pre>
<p><strong>인덱스 설정</strong>:</p>
<pre><code class="language-java">@Configuration
public class AiGameMessageIndexConfig {
    @EventListener(ContextRefreshedEvent.class)
    public void ensureIndexes() {
        mongoTemplate.indexOps(AiGameMessage.class)
            .ensureIndex(Index.on(&quot;roomId&quot;, Sort.Direction.ASC)
                             .on(&quot;messageOrder&quot;, Sort.Direction.ASC));
    }
}</code></pre>
<h3 id="메시지-순서-관리">메시지 순서 관리</h3>
<pre><code class="language-java">// 턴 시작 메시지: messageOrder = 0
// 플레이어 메시지: messageOrder = 1, 2, 3, ...  
// AI 응답: messageOrder = 5000
// 턴 종료 메시지: messageOrder = 9999
// 에러 메시지: messageOrder = 9998</code></pre>
<p>이렇게 하면 <strong>턴별로 메시지가 정확한 순서</strong>로 조회됩니다.</p>
<h2 id="컨텍스트-메시지-관리">컨텍스트 메시지 관리</h2>
<h3 id="ai에게-전달할-대화-맥락">AI에게 전달할 대화 맥락</h3>
<p><strong>문제</strong>: AI가 이전 대화 내용을 기억하게 하려면?</p>
<p><strong>해결</strong>: 최근 N개 메시지를 컨텍스트로 전달</p>
<pre><code class="language-java">public List&lt;AiGameMessageDto&gt; getContextMessages(String roomId, int maxCount, int currentTurn) {
    // 현재 턴 이전의 최근 메시지들을 조회
    List&lt;AiGameMessage&gt; messages = aiGameMessageRepository
            .findByAiGameRoomIdAndTurnNumberLessThanOrderByMessageOrderDesc(
                roomId, currentTurn, PageRequest.of(0, maxCount));

    // 시간순으로 정렬하여 반환 (AI가 순서대로 읽을 수 있게)
    return messages.stream()
            .sorted(Comparator.comparing(AiGameMessage::getMessageOrder))
            .map(AiGameMessageDto::fromEntity)
            .toList();
}</code></pre>
<p><strong>설정 가능한 컨텍스트 개수</strong>:</p>
<pre><code class="language-properties"># application-dev.properties
aichat.context.message-count=5</code></pre>
<h2 id="성능-최적화">성능 최적화</h2>
<h3 id="1-비동기-처리">1. 비동기 처리</h3>
<ul>
<li><strong>스레드 풀</strong>: AI 응답 전용 스레드 풀 분리</li>
<li><strong>이벤트 처리</strong>: Spring Events로 논블로킹 처리</li>
</ul>
<h3 id="2-redis-활용">2. Redis 활용</h3>
<ul>
<li><strong>세션 캐싱</strong>: 게임 상태를 Redis에서 빠르게 조회</li>
<li><strong>분산 락</strong>: 동시성 제어로 데이터 무결성 보장</li>
</ul>
<h3 id="3-mongodb-최적화">3. MongoDB 최적화</h3>
<ul>
<li><strong>복합 인덱스</strong>: roomId + messageOrder로 빠른 메시지 조회</li>
<li><strong>TTL 인덱스</strong>: 오래된 게임 데이터 자동 삭제</li>
</ul>
<h2 id="에러-처리-및-복구">에러 처리 및 복구</h2>
<h3 id="장애-상황별-대응">장애 상황별 대응</h3>
<p><strong>1. AI 서비스 장애</strong></p>
<pre><code class="language-java">private RsData&lt;AiGameMessageResponse&gt; handleAiResponseError(String roomId, Exception e, String errorMessage) {
    log.error(&quot;AI 응답 오류: roomId={}, error={}&quot;, roomId, e.getMessage(), e);

    // 락 해제
    aiGameStateService.unlockAfterAiResponse(roomId);

    // 게임 일시정지  
    aiGameStateService.pauseGame(roomId, &quot;AI 응답 생성 오류&quot;);

    return RsData.of(&quot;500&quot;, errorMessage, null);
}</code></pre>
<p><strong>2. 세션 만료</strong></p>
<pre><code class="language-java">public boolean isSessionValid(String roomId) {
    String sessionKey = AI_GAME_SESSION_PREFIX + roomId;
    return valkeyService.exists(sessionKey);
}

public void extendSession(String roomId) {
    String sessionKey = AI_GAME_SESSION_PREFIX + roomId;
    if (valkeyService.exists(sessionKey)) {
        valkeyService.expire(sessionKey, DEFAULT_SESSION_TIMEOUT_SECONDS);
    }
}</code></pre>
<p><strong>3. WebSocket 연결 끊김</strong></p>
<ul>
<li>클라이언트 자동 재연결</li>
<li>메시지 히스토리 동기화</li>
<li>게임 상태 복구</li>
</ul>
<h2 id="핵심-성과-및-배운-점">핵심 성과 및 배운 점</h2>
<h3 id="성공한-설계-결정">성공한 설계 결정</h3>
<p><strong>1. 이중 데이터 저장 전략</strong></p>
<ul>
<li>MongoDB: 영구 데이터</li>
<li>Redis: 휘발성 + 실시간 상태</li>
</ul>
<p><strong>2. 분산 락을 통한 동시성 제어</strong></p>
<ul>
<li>AI 응답 중 메시지 차단</li>
<li>데이터 무결성 보장</li>
</ul>
<p><strong>3. 이벤트 기반 비동기 처리</strong>  </p>
<ul>
<li>논블로킹 사용자 경험</li>
<li>장시간 작업의 백그라운드 처리</li>
</ul>
<h3 id="개선이-필요한-부분">개선이 필요한 부분</h3>
<p><strong>1. AI 응답 시간 최적화</strong></p>
<ul>
<li>현재: 평균 30-60초</li>
<li>목표: 10-20초 단축</li>
</ul>
<p><strong>2. 에러 복구 자동화</strong></p>
<ul>
<li>현재: 수동 게임 재시작</li>
<li>목표: 자동 복구 메커니즘</li>
</ul>
<p><strong>3. 모니터링 강화</strong></p>
<ul>
<li>응답 시간 메트릭</li>
<li>에러율 추적</li>
<li>사용자 경험 지표</li>
</ul>
<h2 id=""><img src="https://velog.velcdn.com/images/mj_o/post/c35b0ad7-5f7e-4faa-99ff-a422d5d0c045/image.png" alt=""></h2>
<p> 다음 편 예고</p>
<p>3편에서는 <strong>실시간 매칭 시스템</strong>을 다룰 예정입니다:</p>
<ul>
<li>Redis Queue 기반 매칭 알고리즘</li>
<li>대기 시간 예측 및 최적화</li>
<li>WebSocket을 통한 매칭 상태 실시간 업데이트</li>
<li>매칭 취소 및 타임아웃 처리</li>
</ul>
<hr>
<p><strong>시리즈 목차</strong></p>
<ol>
<li>멀티 데이터베이스 아키텍처 설계</li>
<li><strong>AI 채팅 시스템 구현</strong> ← 현재</li>
<li>실시간 매칭 시스템 구현 (matching)</li>
<li>통합 룸 시스템 구현 (room)</li>
<li>개발 회고 &amp; 성능 최적화</li>
</ol>
]]></description>
        </item>
    </channel>
</rss>