<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>성용의 프로그래밍 블로그</title>
        <link>https://velog.io/</link>
        <description>성용의 프로그래밍 블로그</description>
        <lastBuildDate>Fri, 27 Mar 2026 03:33:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>성용의 프로그래밍 블로그</title>
            <url>https://velog.velcdn.com/images/sh__y_/profile/884e899a-5272-4db7-86b8-72f0ec93a7b2/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 성용의 프로그래밍 블로그. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sh__y_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[RAG 챗봇, 검색이 틀리면 LLM은 의미 없다]]></title>
            <link>https://velog.io/@sh__y_/RAG-%EC%B1%97%EB%B4%87-%EA%B2%80%EC%83%89%EC%9D%B4-%ED%8B%80%EB%A6%AC%EB%A9%B4-LLM%EC%9D%80-%EC%9D%98%EB%AF%B8-%EC%97%86%EB%8B%A4</link>
            <guid>https://velog.io/@sh__y_/RAG-%EC%B1%97%EB%B4%87-%EA%B2%80%EC%83%89%EC%9D%B4-%ED%8B%80%EB%A6%AC%EB%A9%B4-LLM%EC%9D%80-%EC%9D%98%EB%AF%B8-%EC%97%86%EB%8B%A4</guid>
            <pubDate>Fri, 27 Mar 2026 03:33:00 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-만들었는가">왜 만들었는가</h2>
<p>최근에 WMS 사용자 가이드 문서를 작성했습니다. 작성하면서 한 가지 생각이 들었는데, 이 가이드 문서와 실제 코드베이스를 함께 참조할 수 있는 챗봇이 있으면 꽤 쓸모있겠다는 것이었습니다.</p>
<p>간단한 질문이나 기능 관련 요구사항은 챗봇이 문서와 코드를 기반으로 1차 정제를 해줄 수 있습니다. 무엇보다 비개발자인 동료들이 매번 테크팀을 찾아오는 대신 봇에게 먼저 질문하고 답을 얻을 수 있다면, 양쪽 모두 시간을 아낄 수 있을 것이라 생각했습니다.</p>
<p>그래서 Slack에서 멘션 한 번이면 Obsidian 가이드 문서와 GitHub 코드를 기반으로 답변해주는 봇을 만들기로 했습니다.</p>
<hr>
<h2 id="아키텍처--rag를-몸으로-배운-과정">아키텍처 — RAG를 몸으로 배운 과정</h2>
<p>구조 자체는 단순합니다.</p>
<pre><code>Slack 질문 → 관련 문서 검색(임베딩) → LLM에게 컨텍스트와 함께 질문 → 답변</code></pre><p>이른바 <strong>RAG</strong>(Retrieval-Augmented Generation)입니다. 개념은 간단한데, 각 단계에서 어떤 모델을 쓰느냐에 따라 결과가 완전히 달라진다는 걸 직접 체감했습니다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>역할</th>
<th>핵심</th>
</tr>
</thead>
<tbody><tr>
<td>검색 (Retrieval)</td>
<td>질문과 관련된 문서/코드 조각을 찾아냄</td>
<td>임베딩 모델의 품질이 좌우</td>
</tr>
<tr>
<td>생성 (Generation)</td>
<td>검색된 컨텍스트를 기반으로 답변 생성</td>
<td>LLM의 지시 따르기 능력이 좌우</td>
</tr>
</tbody></table>
<hr>
<h2 id="로컬-llm을-포기한-이유--무료의-대가는-품질이었다">로컬 LLM을 포기한 이유 — 무료의 대가는 품질이었다</h2>
<p>처음에는 비용 0원을 목표로 <strong>Ollama + gemma3(4B)</strong>를 로컬에서 돌렸습니다. Mac 16GB에서 돌아가긴 합니다. 돌아가기만 합니다.</p>
<p>&quot;WMS 로그인 방법 알려줘&quot;라고 물어봤더니 돌아온 답변입니다:</p>
<blockquote>
<p>WMS에 대한 로그인은 아직 관련된 문서가 없어서 답변할 수 없습니다. 그러나, 실사는 다음과 같은 상태를 거쳐 진행됩니다. 요청 → 진행중 → 완료...</p>
</blockquote>
<p>로그인을 물어봤는데 재고 실사 이야기를 합니다. 별도의 가이드 문서에 로그인 방법이 상세하게 적혀있는데도 말입니다.</p>
<p>원인은 두 가지였습니다.</p>
<h3 id="1-임베딩-모델의-한국어-성능">1. 임베딩 모델의 한국어 성능</h3>
<p><code>nomic-embed-text</code>로 &quot;로그인 방법&quot;을 검색하면, 정작 로그인 관련 문서가 아니라 전혀 관련 없는 다른 가이드 문서들이 상위에 올라왔습니다. 임베딩 모델이 한국어 의미를 제대로 파악하지 못하니, 아무리 좋은 LLM을 붙여도 엉뚱한 컨텍스트가 들어갑니다.</p>
<h3 id="2-소형-llm의-지시-따르기-한계">2. 소형 LLM의 지시 따르기 한계</h3>
<p>gemma3 4B는 컨텍스트를 제공해도 그 안에서 관련 정보를 골라내는 능력이 떨어집니다. &quot;컨텍스트에 없으면 모른다고 답변하라&quot;는 시스템 프롬프트도 무시하고 아는 척을 합니다. 한국어 지시 따르기 능력이 근본적으로 부족한 것입니다.</p>
<blockquote>
<p>사내에서 쓸 봇인데 엉뚱한 답변을 하면 아무도 쓰지 않습니다. 결국 무료의 대가는 품질이었습니다.</p>
</blockquote>
<hr>
<h2 id="검색이-답변보다-중요하다--임베딩-모델-교체의-효과">검색이 답변보다 중요하다 — 임베딩 모델 교체의 효과</h2>
<p>RAG에서 가장 중요한 것은 LLM이 아니라 <strong>검색</strong>입니다. LLM은 주어진 컨텍스트를 잘 요약하면 되지만, 검색이 엉뚱한 문서를 가져오면 게임이 끝납니다.</p>
<p>이를 깨달은 것은 임베딩 모델을 교체하고 나서입니다. <code>nomic-embed-text</code>(로컬)에서 <strong>Voyage Code 3</strong>(API)로 바꾸자, &quot;로그인&quot;이라고 검색하면 로그인 관련 문서가 바로 1순위로 올라왔습니다. LLM 코드는 한 줄도 건드리지 않았는데 답변 품질이 확연히 달라졌습니다.</p>
<h3 id="추가로-효과가-있었던-것들">추가로 효과가 있었던 것들</h3>
<table>
<thead>
<tr>
<th>개선</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>섹션 단위 분할</strong></td>
<td>고정 크기(1500자)로 자르면 &quot;로그인&quot; 섹션이 &quot;비밀번호 변경&quot; 섹션과 뒤섞입니다. 마크다운 헤딩(<code>##</code>) 기준으로 의미 단위로 잘라야 검색 정확도가 올라갑니다.</td>
</tr>
<tr>
<td><strong>청크에 파일명 포함</strong></td>
<td><code>[문서: 카테고리 &gt; 문서명]</code>과 같이 출처 정보를 청크 앞에 붙이면 임베딩 시 문맥 힌트가 됩니다.</td>
</tr>
<tr>
<td><strong>문서 우선 가중치</strong></td>
<td>코드보다 가이드 문서에 30% 부스트를 적용하여, 사용자 질문에 문서가 먼저 매칭되도록 했습니다.</td>
</tr>
</tbody></table>
<hr>
<h2 id="최종-구성과-비용--현실적인-선택">최종 구성과 비용 — 현실적인 선택</h2>
<p>결국 정착한 스택입니다.</p>
<table>
<thead>
<tr>
<th>역할</th>
<th>선택</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>답변 생성</td>
<td><strong>Claude Haiku</strong></td>
<td>한국어 우수, 지시 준수, 저렴</td>
</tr>
<tr>
<td>임베딩</td>
<td><strong>Voyage Code 3</strong></td>
<td>한국어+코드 임베딩 성능, 무료 200M 토큰</td>
</tr>
<tr>
<td>벡터DB</td>
<td><strong>ChromaDB</strong></td>
<td>로컬 파일 기반, 별도 설정 불필요</td>
</tr>
<tr>
<td>Slack 연동</td>
<td><strong>Slack Bolt (Socket Mode)</strong></td>
<td>공인IP 불필요, 로컬에서 바로 동작</td>
</tr>
</tbody></table>
<p>비용은 Haiku 기준 질문당 약 10원입니다. 하루 30번 사용하면 월 9,000원 정도. 팀 생산성 대비 무시할 수 있는 수준입니다.</p>
<hr>
<h2 id="프롬프트-설계--봇의-답변-품질은-시스템-프롬프트가-결정한다">프롬프트 설계 — 봇의 답변 품질은 시스템 프롬프트가 결정한다</h2>
<p>RAG 챗봇에서 프롬프트는 단순한 인사말 설정이 아닙니다. LLM이 검색된 컨텍스트를 어떻게 활용할지를 제어하는 핵심 장치입니다.</p>
<p>초기 프롬프트는 단순했습니다:</p>
<blockquote>
<p>컨텍스트를 참고하여 질문에 답변하세요.</p>
</blockquote>
<p>이렇게 하면 LLM이 컨텍스트에 없는 내용도 자기 지식으로 채워 넣습니다. 사내 봇에서 이것은 치명적입니다. 실제와 다른 정보를 자신 있게 답변하기 때문입니다.</p>
<p>최종적으로 정착한 시스템 프롬프트의 핵심 규칙들입니다:</p>
<table>
<thead>
<tr>
<th>규칙</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>컨텍스트에 있는 정보<strong>만</strong> 사용</td>
<td>환각(hallucination) 방지</td>
</tr>
<tr>
<td>없는 내용은 &quot;찾을 수 없습니다&quot;로 답변</td>
<td>틀린 답변보다 모르는 게 낫습니다</td>
</tr>
<tr>
<td>문서 기반으로 답변하되, 코드로 보강</td>
<td>사용자 가이드가 주, 코드가 보조</td>
</tr>
<tr>
<td>Slack mrkdwn 형식으로 작성</td>
<td>마크다운 문법이 Slack에서 평문으로 노출되는 문제 방지</td>
</tr>
</tbody></table>
<p>마지막 규칙은 의외로 중요합니다. LLM은 기본적으로 마크다운으로 답변하는데, Slack은 마크다운을 지원하지 않습니다. <code>## 제목</code>이나 <code>**굵게**</code>가 그대로 노출되면 가독성이 크게 떨어집니다. Slack의 <code>*굵게*</code>, <code>`코드`</code> 형식을 쓰도록 명시해야 합니다.</p>
<hr>
<h2 id="코드-보안--사내-코드를-외부-api에-보내도-되는가">코드 보안 — 사내 코드를 외부 API에 보내도 되는가</h2>
<p>이 봇은 GitHub 코드를 임베딩하여 외부 API(Voyage, Claude)에 전송합니다. 사내 코드가 외부로 나가는 것이므로 보안 관점에서 반드시 짚어야 할 부분입니다.</p>
<h3 id="현재-구조의-보안-특성">현재 구조의 보안 특성</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td>코드 전송 범위</td>
<td>질문과 관련된 청크(약 1500자)만 전송, 전체 코드베이스가 나가지 않음</td>
</tr>
<tr>
<td>Anthropic 데이터 정책</td>
<td>API로 전송된 데이터는 모델 학습에 사용하지 않음</td>
</tr>
<tr>
<td>Voyage 데이터 정책</td>
<td>임베딩 입력 데이터를 저장하지 않음</td>
</tr>
<tr>
<td>인덱스 저장 위치</td>
<td>ChromaDB가 로컬(또는 자체 서버)에 저장, 외부 DB 없음</td>
</tr>
</tbody></table>
<h3 id="더-강화하려면">더 강화하려면</h3>
<p>엄격한 보안 요구사항이 있다면 몇 가지 추가 조치를 고려할 수 있습니다:</p>
<ul>
<li><strong>코드 인덱싱 제외</strong>: 문서만 인덱싱하고 코드는 뺄 수 있습니다. 답변 품질은 떨어지지만 코드 유출 우려가 사라집니다.</li>
<li><strong>민감 파일 필터링</strong>: <code>.env</code>, 인증 관련 파일, 설정 파일 등을 인덱싱 대상에서 제외합니다.</li>
<li><strong>로컬 LLM 전환</strong>: 보안이 최우선이라면 성능을 감수하고 Ollama로 돌아가는 선택지도 있습니다. 모든 데이터가 로컬에서만 처리됩니다.</li>
</ul>
<p>현재 구조에서는 API 제공사의 데이터 정책을 신뢰하는 전제 하에 사용하고 있으며, 민감한 설정 파일은 인덱싱 대상에서 제외하고 있습니다.</p>
<hr>
<h2 id="모델-선택--비용을-더-쓰더라도-좋은-모델을-써야-하는가">모델 선택 — 비용을 더 쓰더라도 좋은 모델을 써야 하는가</h2>
<p>Haiku로 시작했지만, 이 질문은 계속 남아있습니다.</p>
<h3 id="모델별-비용-비교-질문-1회-기준">모델별 비용 비교 (질문 1회 기준)</h3>
<table>
<thead>
<tr>
<th>모델</th>
<th>1회 비용</th>
<th>월 비용 (30회/일)</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Haiku</strong></td>
<td>~10원</td>
<td>~9,000원</td>
<td>빠르고 저렴, 대부분의 질문에 충분</td>
</tr>
<tr>
<td><strong>Sonnet</strong></td>
<td>~40원</td>
<td>~36,000원</td>
<td>복잡한 코드 분석, 다단계 추론에 강점</td>
</tr>
<tr>
<td><strong>Opus</strong></td>
<td>~200원</td>
<td>~180,000원</td>
<td>최고 품질, 비용 대비 효용은 의문</td>
</tr>
</tbody></table>
<p>현재 결론은 <strong>Haiku로 충분하다</strong>입니다.</p>
<p>이 봇의 주요 질문은 &quot;이 기능 어떻게 쓰나요?&quot;, &quot;이 절차가 뭐예요?&quot;, &quot;이 API 파라미터 뭐야&quot; 같은 것들입니다. 검색이 올바른 문서를 가져오기만 하면, Haiku도 정확하게 답변합니다. 복잡한 추론이 필요한 질문은 거의 없습니다.</p>
<p>다만 용도가 확장된다면 이야기가 달라집니다:</p>
<blockquote>
<ul>
<li>여러 파일에 걸친 코드 흐름을 추적해야 하는 질문</li>
<li>&quot;이 기능을 이렇게 바꾸면 영향 범위가 어디까지야?&quot; 같은 분석</li>
<li>문서와 코드 간의 불일치를 찾아달라는 요청</li>
</ul>
</blockquote>
<p>이런 수준의 질문이 빈번해진다면 Sonnet으로 올리는 것을 고려할 것입니다. 모델 교체는 <code>.env</code> 파일의 값 하나만 바꾸면 되므로, 필요할 때 전환하면 됩니다.</p>
<hr>
<h2 id="socket-mode--로컬에서-slack-봇이-동작하는-원리">Socket Mode — 로컬에서 Slack 봇이 동작하는 원리</h2>
<p>&quot;로컬에서 Slack 봇을 어떻게 돌리나요?&quot; 하고 의아할 수 있습니다. 보통 Slack 봇은 외부에서 접근 가능한 공인 URL이 필요한데, <strong>Socket Mode</strong>를 쓰면 방식이 다릅니다.</p>
<pre><code>일반 방식:  Slack 서버 → HTTP POST → 로컬 PC (❌ 공인IP 없음)
Socket Mode: 로컬 PC → WebSocket 연결 → Slack 서버 → 이벤트 전달 (✅)</code></pre><p>봇이 먼저 Slack 서버에 WebSocket 연결을 열어둡니다. Slack은 그 열린 통로로 메시지를 보내줍니다. 카카오톡과 같은 원리입니다. 내 폰에 서버가 있는 것이 아니라, 폰이 카카오 서버에 접속해서 메시지를 수신하는 것과 동일합니다. ngrok 같은 터널링도 필요 없습니다.</p>
<hr>
<h2 id="삽질-로그--기록해둘-만한-것들">삽질 로그 — 기록해둘 만한 것들</h2>
<h3 id="좀비-프로세스-12개">좀비 프로세스 12개</h3>
<p>봇을 여러 번 재시작하면서 이전 프로세스를 제대로 종료하지 않았더니, 12개가 동시에 돌고 있었습니다. 옛날 버전(Ollama 기반)이 Slack 이벤트를 먼저 가로채서 계속 <code>model &#39;llama3.1&#39; not found</code> 에러가 발생했습니다.</p>
<blockquote>
<p><code>ps aux | grep python</code>은 습관적으로 확인해야 합니다.</p>
</blockquote>
<h3 id="임베딩-차원-불일치">임베딩 차원 불일치</h3>
<p><code>nomic-embed-text</code>(1024차원)로 만든 인덱스에 Voyage(768차원) 쿼리를 날리면 당연히 에러가 발생합니다. 임베딩 모델을 바꾸면 인덱스를 처음부터 다시 만들어야 합니다.</p>
<h3 id="voyage-무료-크레딧의-함정">Voyage 무료 크레딧의 함정</h3>
<p><code>voyage-3</code>에는 무료 토큰이 없고 <code>voyage-code-3</code>에 200M 토큰이 있었습니다. 모델마다 무료 할당량이 다르므로, 대시보드의 Free Token 탭을 반드시 확인해야 합니다. 확인하지 않으면 의도치 않게 과금됩니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>Claude에게 &quot;Slack 챗봇 만들어줘&quot;라고 하면 30분 만에 돌아가는 코드가 나옵니다. 하지만 <strong>&quot;쓸 만한&quot;</strong> 챗봇이 되려면 검색 품질, 청킹 전략, 모델 선택 같은 판단이 필요하고, 이것은 직접 결과를 보면서 조정해야 합니다.</p>
<p>로컬 LLM으로 비용을 아끼려다 품질을 포기하는 것보다, API 비용 월 만 원을 쓰고 팀이 실제로 사용하는 봇을 만드는 편이 낫다는 결론입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MongoDB에서 동시성을 다루는 법]]></title>
            <link>https://velog.io/@sh__y_/MongoDB%EC%97%90%EC%84%9C-%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%84-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@sh__y_/MongoDB%EC%97%90%EC%84%9C-%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%84-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Sat, 14 Mar 2026 08:14:10 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>MongoDB 기반의 서비스를 개발하다 보면, 동시성 제어에서 RDB와는 다른 접근이 필요하다는 것을 느끼게 됩니다.</p>
<p>RDB 환경이라면 <code>SELECT FOR UPDATE</code>로 행 단위 비관적 락을 걸 수 있습니다. 트랜잭션 안에서 데이터를 읽으면서 잠금을 걸고, 작업이 끝날 때까지 다른 요청을 기다리게 만드는 익숙한 방식입니다.</p>
<p><strong>MongoDB에는 이 메커니즘이 없습니다.</strong></p>
<p>트랜잭션은 지원하지만 성격이 다릅니다. 두 트랜잭션이 같은 Document를 동시에 수정하려 하면, 하나는 성공하고 나머지는 <code>WriteConflict</code> 에러를 받게 됩니다. 대기시키는 것이 아니라 실패를 반환하는 구조입니다.</p>
<p>이 제약 안에서 정합성과 throughput을 모두 확보하기 위해, 상황에 따라 다른 전략을 적용해야 했습니다. 이 글에서는 실무에서 자연스럽게 정리된 네 가지 패턴을 소개합니다. 각 패턴이 어떤 동시성 문제를 해결하는지, 그리고 어떤 상황에서 선택해야 하는지를 중심으로 다룹니다.</p>
<table>
<thead>
<tr>
<th>#</th>
<th>패턴</th>
<th>적용 범위</th>
<th>핵심 키워드</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>조건부 원자 연산</td>
<td>단일 Document</td>
<td><code>findAndModify</code>, 락 불필요</td>
</tr>
<tr>
<td>2</td>
<td>Bulk Operations</td>
<td>다수 Document (독립적)</td>
<td>배치, 병렬 실행</td>
</tr>
<tr>
<td>3</td>
<td>분산 락</td>
<td>다수 Collection / 복합 로직</td>
<td>Redis, 직렬화</td>
</tr>
<tr>
<td>4</td>
<td>Outbox 패턴</td>
<td>시스템 경계 (서버 간)</td>
<td>비동기, 최종 일관성</td>
</tr>
</tbody></table>
<hr>
<h2 id="1-조건부-원자-연산--락-없이-정합성-확보하기">1. 조건부 원자 연산 — 락 없이 정합성 확보하기</h2>
<p>MongoDB를 사용하는 서비스에서 가장 빈번하게 마주치는 동시성 문제는 <strong>수량 변경</strong>입니다. 재고 차감, 좌석 예약, 포인트 차감 등 &quot;현재 값을 읽고 → 계산하고 → 저장하는&quot; 연산은 어디에나 있습니다.</p>
<h3 id="문제-read-modify-write의-lost-update">문제: Read-Modify-Write의 Lost Update</h3>
<p>자연스럽게 떠오르는 흐름은 데이터를 조회하고, 값을 확인한 뒤, 새 값을 저장하는 것입니다. RDB에서 하던 방식 그대로입니다.</p>
<p>문제는 <strong>조회와 저장 사이의 시간 간격</strong>에 있습니다.</p>
<blockquote>
<table>
<thead>
<tr>
<th>순서</th>
<th>Thread A</th>
<th>Thread B</th>
<th>DB 상태</th>
</tr>
</thead>
<tbody><tr>
<td>①</td>
<td>수량 조회 → <strong>10</strong></td>
<td></td>
<td>qty = 10</td>
</tr>
<tr>
<td>②</td>
<td></td>
<td>수량 조회 → <strong>10</strong></td>
<td>qty = 10</td>
</tr>
<tr>
<td>③</td>
<td>10 - 3 = <strong>7</strong> 저장</td>
<td></td>
<td>qty = 7</td>
</tr>
<tr>
<td>④</td>
<td></td>
<td>10 - 5 = <strong>5</strong> 저장</td>
<td>qty = 5</td>
</tr>
<tr>
<td>결과</td>
<td></td>
<td></td>
<td><strong>5</strong> (기대값: 2)</td>
</tr>
</tbody></table>
<p>→ Thread A의 차감 3이 유실됨</p>
</blockquote>
<p>두 요청 모두 수량을 10으로 읽고, 각각 차감한 결과를 덮어쓰면서 최종 값이 틀어집니다. 전형적인 Lost Update 문제입니다.</p>
<h3 id="해결-findandmodify">해결: findAndModify</h3>
<p>MongoDB는 <strong>단일 Document 레벨의 원자성</strong>을 보장합니다. <code>findAndModify</code>는 조건 검증과 업데이트를 하나의 원자적 연산으로 실행하기 때문에, 중간에 다른 연산이 끼어들 여지가 없습니다.</p>
<p>예를 들어 &quot;수량이 3 이상인 경우에만 3을 차감하라&quot;는 조건과 연산을 하나로 묶습니다. 조건에 맞지 않으면 아무것도 수정하지 않고 <code>null</code>을 반환합니다.</p>
<blockquote>
<table>
<thead>
<tr>
<th>순서</th>
<th>Thread A</th>
<th>Thread B</th>
<th>DB 상태</th>
</tr>
</thead>
<tbody><tr>
<td>①</td>
<td>findAndModify (qty &gt;= 3 → qty -= 3)</td>
<td></td>
<td><strong>원자적 실행</strong> → qty = 7</td>
</tr>
<tr>
<td>②</td>
<td></td>
<td>findAndModify (qty &gt;= 5 → qty -= 5)</td>
<td><strong>원자적 실행</strong> → qty = 2</td>
</tr>
<tr>
<td>결과</td>
<td></td>
<td></td>
<td><strong>2</strong> ✅</td>
</tr>
</tbody></table>
</blockquote>
<p>여기서 핵심은 <strong>조회와 수정이 분리되지 않는다</strong>는 점입니다. MongoDB 엔진 레벨에서 하나의 연산으로 실행되기 때문에, 별도의 락 없이도 정합성이 보장됩니다.</p>
<p>만약 Thread B의 시점에 수량이 5 미만이었다면? <code>findAndModify</code>는 아무것도 수정하지 않고 <code>null</code>을 반환합니다. 이 반환값으로 비즈니스 로직에서 &quot;수량 부족&quot; 등의 처리를 하면 됩니다.</p>
<h3 id="어떤-상황에서-사용할-수-있는가">어떤 상황에서 사용할 수 있는가</h3>
<p>이 패턴은 단일 Document 안에서 해결되는 연산이라면 폭넓게 적용할 수 있습니다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>활용 방식</th>
</tr>
</thead>
<tbody><tr>
<td><strong>수량 차감/증가</strong></td>
<td><code>qty &gt;= 차감량</code> 조건 + <code>$inc</code> 연산. 재고, 좌석, 쿠폰 등 수량이 음수가 되면 안 되는 모든 케이스</td>
</tr>
<tr>
<td><strong>상태 전이</strong></td>
<td><code>status = SENT</code> 조건 + <code>$set(status, PROCESSING)</code>. 여러 서버 인스턴스가 동시에 같은 작업을 선점하려 할 때, 먼저 도착한 하나만 성공</td>
</tr>
<tr>
<td><strong>필드 간 이동</strong></td>
<td>같은 Document 안에서 <code>$inc(fieldA, -1)</code> + <code>$inc(fieldB, +1)</code>. 가용 재고 → 예약 재고 전환 등</td>
</tr>
<tr>
<td><strong>Upsert</strong></td>
<td>동일 조건의 Document가 있으면 수량 증가, 없으면 생성. 조회→분기→저장 방식의 중복 생성 문제를 <code>upsert: true</code> 옵션으로 해결</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>CAS와의 관계</strong></p>
<p>이 패턴은 CPU 수준의 CAS(Compare-And-Swap) 명령어와 원리가 유사합니다. &quot;현재 값이 기대한 상태일 때만 변경&quot;하는 구조입니다. 다만 <code>findAndModify</code>는 CAS와 달리 <strong>다양한 조건 조합</strong>(<code>$in</code>, <code>$gte</code>, 복합 조건 등)을 사용할 수 있어, 단순 값 비교를 넘어서는 복잡한 상태 전이에도 활용할 수 있습니다.</p>
</blockquote>
<h3 id="왜-이-패턴을-먼저-고려해야-하는가">왜 이 패턴을 먼저 고려해야 하는가</h3>
<p>이 패턴의 가장 큰 장점은 <strong>락이 없다</strong>는 것입니다. 락이 없으니 대기가 없고, 데드락도 없으며, throughput도 높습니다. 단일 Document 범위 안에서 해결할 수 있는 문제라면 이것이 가장 단순하고 성능이 좋은 방법입니다.</p>
<hr>
<h2 id="2-bulk-operations--배치-연산의-네트워크-최적화">2. Bulk Operations — 배치 연산의 네트워크 최적화</h2>
<p>주문 하나에 상품이 10종류 포함되어 있다면, 출고 배정 시 10개 상품의 수량을 모두 변경해야 합니다. 패턴 1의 원자적 연산을 10번 개별 호출하면 네트워크 왕복이 10번 발생합니다.</p>
<h3 id="해결-연산을-묶어서-전송">해결: 연산을 묶어서 전송</h3>
<p>MongoDB의 Bulk Operations는 여러 연산을 하나의 요청으로 묶어 서버로 전송합니다.</p>
<blockquote>
<p><strong>개별 호출</strong> — 10번 왕복</p>
<pre><code>App → req1 → MongoDB → res1 → App
App → req2 → MongoDB → res2 → App
...
App → req10 → MongoDB → res10 → App</code></pre><p><strong>Bulk Operations</strong> — 1번 왕복</p>
<pre><code>App → [req1, req2, ... req10] → MongoDB → [res1, res2, ... res10] → App</code></pre></blockquote>
<h3 id="왜-unordered-모드인가">왜 UNORDERED 모드인가</h3>
<p>여기서 중요한 선택은 <strong>UNORDERED 모드</strong>입니다. 각 상품의 수량 변경은 서로 독립적인 연산입니다. A 상품의 수량을 줄이는 것과 B 상품의 수량을 줄이는 것은 아무 관계가 없습니다.</p>
<p>UNORDERED 모드를 사용하면 MongoDB가 이 연산들을 <strong>병렬로 실행</strong>할 수 있어 throughput이 더 높아집니다. 반면 ORDERED 모드는 순차 실행되며, 중간에 하나가 실패하면 나머지를 건너뜁니다.</p>
<h3 id="어떤-상황에서-사용할-수-있는가-1">어떤 상황에서 사용할 수 있는가</h3>
<table>
<thead>
<tr>
<th>상황</th>
<th>활용 방식</th>
</tr>
</thead>
<tbody><tr>
<td><strong>일괄 수량 변경</strong></td>
<td>주문에 포함된 여러 상품의 재고를 한 번에 차감하거나 복원</td>
</tr>
<tr>
<td><strong>일괄 상태 변경</strong></td>
<td>조건에 맞는 여러 Document의 상태를 동시에 전환</td>
</tr>
<tr>
<td><strong>필드 간 일괄 이동</strong></td>
<td>여러 Document에서 가용 수량 → 예약 수량 등의 전환을 병렬로 처리</td>
</tr>
</tbody></table>
<h3 id="bulk-operations-≠-transaction">Bulk Operations ≠ Transaction</h3>
<p>한 가지 주의할 점이 있습니다. Bulk Operations는 <strong>트랜잭션이 아닙니다.</strong> 10개 중 3개가 실패해도 나머지 7개는 성공합니다.</p>
<p>따라서 실행 후 반환되는 <code>modifiedCount</code>를 확인하여 기대한 만큼 처리되었는지 비즈니스 로직에서 검증해야 합니다. 전부 성공하거나 전부 실패해야 하는 요구사항이라면, 트랜잭션이나 보상 로직을 별도로 구성해야 합니다.</p>
<hr>
<h2 id="3-분산-락--복합-연산의-동시-접근-제어">3. 분산 락 — 복합 연산의 동시 접근 제어</h2>
<p>패턴 1과 2는 단일 Document 범위에서의 원자성을 활용합니다. 하지만 실제 비즈니스 로직은 대부분 여러 컬렉션에 걸쳐 있습니다.</p>
<h3 id="왜-트랜잭션만으로는-부족한가">왜 트랜잭션만으로는 부족한가</h3>
<p>주문이 접수되면 주문 생성, 재고 배정, 배송 정보 생성, 이력 저장을 하나의 논리적 단위로 처리해야 합니다. 그리고 네트워크 타임아웃 등으로 인해 같은 요청이 재시도될 수 있습니다.</p>
<p>MongoDB 트랜잭션은 여러 연산의 원자성(전부 성공하거나 전부 실패)을 보장하지만, <strong>동일 자원에 대한 동시 접근 자체를 막아주지는 않습니다.</strong> 두 트랜잭션이 동시에 같은 주문을 처리하려 하면 하나는 <code>WriteConflict</code>로 실패합니다.</p>
<p>실패한 쪽의 재시도 로직을 직접 구현하고 관리하는 것보다는, <strong>애초에 하나만 진입하도록 제어하는 것</strong>이 더 안정적입니다.</p>
<h3 id="해결-redis-기반-분산-락">해결: Redis 기반 분산 락</h3>
<p>Redisson 라이브러리의 <code>PermitExpirableSemaphore</code>를 기반으로 커스텀 어노테이션을 구현했습니다. 함수에 어노테이션을 붙이고 락 이름과 키를 지정하면, 해당 키에 대해 클러스터 전체에서 하나의 요청만 통과합니다.</p>
<blockquote>
<p>분산 락의 구현 상세와 성능 개선 과정은 <a href="https://velog.io/@sh__y_/Spring-WebFlux-Redisson-%EB%B6%84%EC%82%B0-%EB%9D%BD-%EC%B2%98%EB%A6%AC-%EC%84%B1%EB%8A%A5-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0">이전 글</a>에서 다루었습니다. 이 글에서는 <strong>언제 분산 락을 적용하는지</strong>, 그리고 <strong>트랜잭션과 어떻게 조합하는지</strong>에 초점을 맞춥니다.</p>
</blockquote>
<h3 id="키-설계가-throughput을-결정한다">키 설계가 throughput을 결정한다</h3>
<p>분산 락에서 <strong>키 설계는 성능을 좌우하는 핵심 결정</strong>입니다.</p>
<p>주문 처리의 경우 락 키를 <strong>주문번호</strong>로 설정했습니다. 기능 전체에 하나의 락을 거는 것이 아니라, 주문번호별로 개별 락을 겁니다.</p>
<blockquote>
<p><strong>기능 단위 락</strong> — 모든 주문이 직렬화</p>
<table>
<thead>
<tr>
<th>요청</th>
<th>락</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>주문 A</td>
<td><code>🔒 주문처리</code></td>
<td>대기 → 처리</td>
</tr>
<tr>
<td>주문 B</td>
<td><code>🔒 주문처리</code></td>
<td>대기 → 처리</td>
</tr>
<tr>
<td>주문 C</td>
<td><code>🔒 주문처리</code></td>
<td>대기 → 처리</td>
</tr>
</tbody></table>
<p>→ throughput 심각하게 저하</p>
</blockquote>
<blockquote>
<p><strong>키 단위 락</strong> — 같은 주문만 직렬화, 나머지는 병렬</p>
<table>
<thead>
<tr>
<th>요청</th>
<th>락</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>주문 A</td>
<td><code>🔒 LOCK:A</code></td>
<td>즉시 처리</td>
</tr>
<tr>
<td>주문 B</td>
<td><code>🔒 LOCK:B</code></td>
<td>즉시 처리 (병렬)</td>
</tr>
<tr>
<td>주문 C</td>
<td><code>🔒 LOCK:C</code></td>
<td>즉시 처리 (병렬)</td>
</tr>
<tr>
<td>주문 A (재시도)</td>
<td><code>🔒 LOCK:A</code></td>
<td>대기 (A 완료 후 처리)</td>
</tr>
</tbody></table>
<p>→ 서로 다른 주문은 완전히 병렬 처리</p>
</blockquote>
<p><strong>경합이 실제로 발생하는 최소 단위로 키를 설정하는 것</strong>이 throughput 확보의 핵심입니다.</p>
<h3 id="3단계-방어-전략">3단계 방어 전략</h3>
<p>단순히 Redis에 락을 걸고 푸는 것만으로는 운영 환경에서 병목이 드러났습니다. 트래픽이 몰리면 Redis 자체가 병목이 되거나, 락 대기열이 길어져 스레드 풀이 고갈되는 상황이 발생했습니다.</p>
<p>이를 해결하기 위해 세 단계의 보호 장치를 적용했습니다.</p>
<blockquote>
<p><strong>Layer 1 — Local Semaphore (JVM)</strong></p>
<ul>
<li>같은 인스턴스 내에서 동일 키에 대해 <strong>1개 요청만 Redis로 전달</strong></li>
<li>나머지는 JVM 레벨에서 대기 → Redis 호출이 1/N로 감소</li>
<li>Thundering Herd 방지</li>
</ul>
<p>↓</p>
<p><strong>Layer 2 — Redis PermitExpirableSemaphore</strong></p>
<ul>
<li>클러스터 전체에서 <strong>1개 요청만 통과</strong></li>
<li><code>leaseTime</code> 이후 자동 만료 → 데드락 방지</li>
<li><code>trySetPermits</code> 캐싱으로 초기화 Redis 호출 1회 제한</li>
</ul>
<p>↓</p>
<p><strong>Layer 3 — maxWaiters Circuit Breaker</strong></p>
<ul>
<li>대기자 수가 임계값 초과 시 <strong>즉시 실패 반환</strong></li>
<li>스레드 풀 고갈 방지</li>
<li>시스템 전체 응답 지연 확산 차단</li>
</ul>
</blockquote>
<h3 id="락과-트랜잭션의-실행-순서">락과 트랜잭션의 실행 순서</h3>
<p>분산 락과 MongoDB 트랜잭션을 함께 사용할 때, <strong>실행 순서가 중요합니다.</strong></p>
<blockquote>
<pre><code>① Lock 획득        ← DistributedLockAspect (@Order = 0)
  ② Transaction 시작  ← TransactionInterceptor (@Order = LOWEST)
    ③ 비즈니스 로직 실행
  ④ Transaction 커밋 (또는 롤백)
⑤ Lock 해제</code></pre></blockquote>
<p>반드시 <strong>락을 먼저 획득</strong>하고, 그 안에서 트랜잭션을 시작해야 합니다.</p>
<p>만약 트랜잭션 커밋 전에 락이 해제되면 어떻게 될까요?</p>
<p>Thread A가 데이터를 변경하고 아직 커밋하지 않은 상태에서 락을 해제하면, Thread B가 락을 획득한 시점에 조회하는 데이터는 <strong>커밋되지 않은 이전 상태</strong>입니다. 정합성이 깨지게 됩니다.</p>
<p>Spring AOP의 <code>@Order</code>를 활용하여 이 순서를 보장했습니다. 분산 락 Aspect를 <code>@Order(0)</code>으로 설정하면 AOP 체인의 가장 바깥쪽에 위치하고, 트랜잭션 인터셉터(<code>LOWEST_PRECEDENCE</code>)는 안쪽에 위치합니다. 결과적으로 락이 트랜잭션을 감싸는 구조가 됩니다.</p>
<h3 id="왜-리액티브-환경에서-락-해제-시점이-달라지는가">왜 리액티브 환경에서 락 해제 시점이 달라지는가</h3>
<p>Spring WebFlux + Kotlin Coroutine 기반 환경에서는 한 가지 추가로 주의할 부분이 있습니다.</p>
<p>Spring의 <code>@Transactional</code>이 suspend 함수에 적용되면, AOP 프록시는 내부적으로 코루틴을 Reactor의 <code>Mono</code>로 변환합니다. AOP의 around advice 관점에서 <code>joinPoint.proceed()</code>의 반환값은 비즈니스 로직의 결과가 아니라 <strong>아직 실행되지 않은 <code>Mono</code> 객체</strong>입니다.</p>
<blockquote>
<p><strong>블로킹 (Spring MVC)</strong></p>
<pre><code>proceed() → 비즈니스 로직 실행 → 트랜잭션 커밋 → 결과 반환
                                                 → 이 시점에 락 해제 ✅</code></pre><p><strong>리액티브 (WebFlux + Coroutine)</strong></p>
<pre><code>proceed() → Mono 객체 반환 (아직 실행 전!)
          → 이 시점에 락 해제? ❌ 트랜잭션 커밋 전!

Mono.subscribe() → 비즈니스 로직 실행 → 트랜잭션 커밋
                                      → doFinally에서 락 해제 ✅</code></pre></blockquote>
<p>이 <code>Mono</code>가 실제로 subscribe되어 완료되는 시점이 트랜잭션 커밋 시점입니다. 따라서 <code>Mono</code> 객체를 받자마자 락을 해제하면, 트랜잭션 커밋 전에 락이 풀리게 됩니다.</p>
<p>이를 방지하기 위해 <code>Mono</code>의 <code>doFinally</code> 콜백에서 — Mono가 완전히 완료된 후에 — 락을 해제하도록 처리했습니다. 코루틴이 실제로 suspend되는 경우에는 <code>Continuation</code>을 래핑하여 코루틴 재개 시점에 락이 해제되도록 했습니다.</p>
<p>블로킹 방식의 Spring MVC에서는 고려할 필요 없는 부분이지만, 리액티브 스택에서는 <strong>&quot;반환 시점&quot;과 &quot;완료 시점&quot;이 다르기 때문에</strong> 반드시 고려해야 합니다.</p>
<hr>
<h2 id="4-outbox-패턴--시스템-경계를-넘는-정합성">4. Outbox 패턴 — 시스템 경계를 넘는 정합성</h2>
<p>앞선 패턴들은 하나의 서버, 하나의 MongoDB 안에서의 동시성 문제를 다루었습니다. 하지만 서비스가 여러 서버로 분리되어 각각 별도의 DB를 사용하고 있다면, 하나의 트랜잭션으로 여러 서버의 데이터를 묶을 수 없습니다.</p>
<h3 id="문제-서버-간-데이터-불일치">문제: 서버 간 데이터 불일치</h3>
<p>출고가 완료되면 외부 시스템에 배송 상태를 변경해달라는 요청을 보내야 하는 상황을 생각해 봅니다. 단순하게 트랜잭션 안에서 외부 API를 호출하면 두 가지 문제가 발생합니다.</p>
<table>
<thead>
<tr>
<th>문제</th>
<th>상황</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td><strong>불일치</strong></td>
<td>API 호출 성공 → 로컬 트랜잭션 롤백</td>
<td>외부 시스템은 변경, 로컬은 미변경</td>
</tr>
<tr>
<td><strong>장애 전파</strong></td>
<td>외부 API 지연/장애</td>
<td>로컬 비즈니스 로직 자체가 블로킹</td>
</tr>
</tbody></table>
<h3 id="해결-이벤트-분리">해결: 이벤트 분리</h3>
<p>Outbox 패턴은 이 문제를 <strong>비동기 이벤트 처리</strong>로 해결합니다.</p>
<blockquote>
<p><strong>Step 1</strong> — 비즈니스 로직 + 이벤트 저장 <strong>(같은 트랜잭션)</strong></p>
<ul>
<li>비즈니스 로직 완료</li>
<li>Outbox 컬렉션에 이벤트 저장 (상태: <code>SENT</code>)</li>
<li>둘 다 성공하거나 둘 다 실패</li>
</ul>
<p><strong>Step 2</strong> — 스케줄러가 Outbox 폴링</p>
<ul>
<li>미처리 이벤트 조회</li>
<li><code>findAndModify</code>로 원자적 선점 (<code>SENT</code> → <code>PROCESSING</code>)</li>
</ul>
<p><strong>Step 3</strong> — 외부 시스템 호출</p>
<ul>
<li>성공 → 상태를 <code>SUCCESS</code>로 변경</li>
<li>실패 → 상태를 <code>FAILED</code>로 변경 → 별도 스케줄러가 재시도</li>
</ul>
</blockquote>
<p>비즈니스 로직과 이벤트 저장이 같은 트랜잭션 안에 있으므로, 둘 다 성공하거나 둘 다 실패합니다. 외부 API 호출은 트랜잭션 밖에서 비동기적으로 처리되기 때문에 장애가 전파되지 않습니다.</p>
<h3 id="앞선-패턴들의-조합">앞선 패턴들의 조합</h3>
<p>이 패턴에서 주목할 부분은, <strong>앞서 다룬 동시성 제어 패턴들이 함께 사용된다</strong>는 점입니다.</p>
<table>
<thead>
<tr>
<th>지점</th>
<th>사용 패턴</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>이벤트 선점</td>
<td><strong>패턴 1</strong> — <code>findAndModify</code> (조건부 원자 연산)</td>
<td>여러 인스턴스가 같은 이벤트를 가져가는 것 방지</td>
</tr>
<tr>
<td>스케줄러 실행</td>
<td><strong>패턴 3</strong> — 분산 락</td>
<td>같은 유형의 스케줄러가 여러 인스턴스에서 동시 실행 방지</td>
</tr>
<tr>
<td>비즈니스 로직</td>
<td><code>@Transactional</code></td>
<td>이벤트 저장과 비즈니스 로직의 원자성 보장</td>
</tr>
</tbody></table>
<h3 id="어떤-상황에서-사용할-수-있는가-2">어떤 상황에서 사용할 수 있는가</h3>
<p>서로 다른 DB를 사용하는 서비스 간 데이터 정합성이 필요한 모든 시나리오에 적용할 수 있습니다. 실무에서는 주문 수집, 주문 취소, 배송지 변경, 부분 취소, 배송 상태 동기화 등 외부 시스템과의 연동이 필요한 시나리오에 적용했습니다.</p>
<p>각 이벤트 유형마다 미처리용 스케줄러 락과 실패 재처리용 스케줄러 락을 분리해두면, 서로 다른 유형의 이벤트는 병렬로 처리됩니다.</p>
<hr>
<h2 id="정리-어떤-상황에서-어떤-패턴을-선택할-것인가">정리: 어떤 상황에서 어떤 패턴을 선택할 것인가</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>패턴</th>
<th>Throughput</th>
<th>정합성</th>
</tr>
</thead>
<tbody><tr>
<td>단일 Document 상태 변경 / 수량 증감</td>
<td><strong>패턴 1</strong> — 조건부 원자 연산</td>
<td>높음 (락 없음)</td>
<td>강한 일관성</td>
</tr>
<tr>
<td>여러 Document 독립적 수량 변경</td>
<td><strong>패턴 2</strong> — Bulk Operations</td>
<td>높음 (병렬)</td>
<td>개별 원자성</td>
</tr>
<tr>
<td>여러 Collection에 걸친 복합 로직</td>
<td><strong>패턴 3</strong> — 분산 락 + 트랜잭션</td>
<td>중간 (직렬화)</td>
<td>강한 일관성</td>
</tr>
<tr>
<td>시스템 경계를 넘는 연동</td>
<td><strong>패턴 4</strong> — Outbox 패턴</td>
<td>비동기</td>
<td>최종 일관성</td>
</tr>
</tbody></table>
<hr>
<p>MongoDB에서 RDB의 <code>SELECT FOR UPDATE</code>가 없다는 것은 처음에는 제약이었지만, 돌이켜보면 <strong>모든 동시성 문제에 동일한 해법을 적용하는 습관에서 벗어나게 해준 계기</strong>였습니다.</p>
<p>&quot;이 연산이 정말 락이 필요한가?&quot;를 먼저 판단하고, 필요하지 않다면 원자적 연산으로, 필요하다면 최소 범위의 락으로 해결하는 것. 그것이 정합성과 성능 사이에서 균형을 잡는 방법이었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring WebFlux + Redisson: 분산 락 처리 성능 개선하기]]></title>
            <link>https://velog.io/@sh__y_/Spring-WebFlux-Redisson-%EB%B6%84%EC%82%B0-%EB%9D%BD-%EC%B2%98%EB%A6%AC-%EC%84%B1%EB%8A%A5-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sh__y_/Spring-WebFlux-Redisson-%EB%B6%84%EC%82%B0-%EB%9D%BD-%EC%B2%98%EB%A6%AC-%EC%84%B1%EB%8A%A5-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 14 Feb 2026 07:19:54 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-배경">문제 배경</h2>
<p>이전 글에서 WebFlux 환경에서 분산 락을 <strong>Reactive 체인의 라이프사이클에 맞춰</strong> 안전하게 관리하는 방법을 다뤘습니다.
락 획득과 해제 시점이 트랜잭션 커밋과 정확히 맞물리게 만들었고, 기능적으로는 문제가 없었습니다.</p>
<p>하지만 부하 테스트를 통해 <strong>성능 측면에서 3가지 병목</strong>이 드러났습니다.</p>
<table>
<thead>
<tr>
<th>지표</th>
<th>부하 테스트 결과</th>
</tr>
</thead>
<tbody><tr>
<td>성공률</td>
<td><strong>23%</strong> (77% 실패)</td>
</tr>
<tr>
<td>평균 대기 시간</td>
<td><strong>56초</strong></td>
</tr>
<tr>
<td>최대 동시 대기 수</td>
<td><strong>64+</strong> (Dispatchers.IO 스레드 고갈)</td>
</tr>
<tr>
<td>Redis 오류/타임아웃</td>
<td>발생</td>
</tr>
</tbody></table>
<p>50req/min, 10분 기준으로 측정한 수치입니다.
기능은 정상이지만, 실 서비스 부하에서는 <strong>대부분의 요청이 타임아웃으로 실패</strong>하는 상황이었습니다.</p>
<hr>
<h2 id="기존-코드의-문제점">기존 코드의 문제점</h2>
<p>기존 <code>acquireLock()</code> 의 흐름은 단순했습니다.</p>
<pre><code class="language-kotlin">private fun acquireLock(...): LockResource {
    val lockKey = resolveLockKey(distributedLock, joinPoint)
    val semaphore = redissonClient.getPermitExpirableSemaphore(lockKey)
    semaphore.trySetPermits(1)  // ① 매번 Redis 호출

    val permitId = semaphore.tryAcquire(  // ② 모든 요청이 직접 Redis로
        distributedLock.waitTime,          // ③ 60초 동안 무한 대기
        distributedLock.leaseTime,
        TimeUnit.SECONDS
    )
    ...
}</code></pre>
<p>이 코드에는 세 가지 문제가 숨어 있습니다.</p>
<h3 id="문제-1-trysetpermits의-불필요한-반복-호출">문제 1: trySetPermits의 불필요한 반복 호출</h3>
<p><code>trySetPermits(1)</code> 은 세마포어의 허가(permit) 수를 설정하는 메서드입니다.
<strong>이미 설정되어 있으면 무시</strong>되지만, Redisson 내부적으로는 매번 Redis에 Lua 스크립트를 전송합니다.</p>
<pre><code>요청 1: trySetPermits(1) → Redis Lua 실행 → 설정됨 ✓
요청 2: trySetPermits(1) → Redis Lua 실행 → 이미 설정됨, 무시
요청 3: trySetPermits(1) → Redis Lua 실행 → 이미 설정됨, 무시
...
요청 N: trySetPermits(1) → Redis Lua 실행 → 이미 설정됨, 무시</code></pre><p>결과적으로 모든 요청에 대해 <strong>의미 없는 Redis 왕복(round-trip)</strong> 이 발생합니다.
500회 반복 측정 결과, 이 불필요한 호출만으로 <strong>수백 ms의 누적 오버헤드</strong>가 발생했습니다.</p>
<h3 id="문제-2-thundering-herd-우레-떼">문제 2: Thundering Herd (우레 떼)</h3>
<p>Redisson의 <code>PermitExpirableSemaphore</code> 는 내부적으로 <strong>Pub/Sub</strong>을 사용합니다.
누군가 락을 해제하면 Redis가 대기 중인 모든 클라이언트에 알림을 보내고,
<strong>알림을 받은 모든 클라이언트가 동시에</strong> <code>tryAcquire</code> Lua 스크립트를 실행합니다.</p>
<pre><code>락 해제 이벤트 발생!
  ├─ 대기자 1: tryAcquire Lua 실행 → 성공 ✓
  ├─ 대기자 2: tryAcquire Lua 실행 → 실패 (이미 1이 가져감)
  ├─ 대기자 3: tryAcquire Lua 실행 → 실패
  ├─ ...
  └─ 대기자 50: tryAcquire Lua 실행 → 실패</code></pre><p>허가는 1개인데 50개의 Lua 스크립트가 동시에 실행됩니다.
<strong>49개는 반드시 실패할 연산</strong>인데도 Redis CPU를 소모합니다.</p>
<p>이것이 반복되면 Redis의 single-threaded 특성상 <strong>다른 모든 Redis 연산이 지연</strong>됩니다.</p>
<h3 id="문제-3-무제한-대기자-누적">문제 3: 무제한 대기자 누적</h3>
<p>기존 <code>waitTime</code> 기본값은 <strong>60초</strong>였습니다.
분당 50개의 요청이 들어오고, 처리량이 분당 ~11개(평균 점유 5.5초 기준)라면:</p>
<pre><code>매 분마다 약 39개의 요청이 대기열에 누적
→ 1분 후: 39개 대기
→ 2분 후: 78개 대기
→ ...
→ Dispatchers.IO 스레드(기본 64개) 전부 블로킹 대기 상태
→ 새로운 코루틴을 디스패치할 스레드가 없음
→ near-deadlock</code></pre><p>60초를 기다리다 결국 실패할 요청들이 <strong>스레드를 점유한 채 대기</strong>하면서,
정작 처리할 수 있는 요청마저 스레드를 할당받지 못하는 악순환이 발생합니다.</p>
<hr>
<h2 id="해결-3단계-방어">해결: 3단계 방어</h2>
<p>개선된 <code>acquireLock()</code> 의 흐름은 다음과 같습니다.</p>
<pre><code>요청 도착
  │
  ▼
① maxWaiters 체크 ──── 초과 시 즉시 실패 (fast-fail)
  │
  ▼
② 로컬 세마포어 획득 ── Pod 내 1개만 통과
  │
  ▼
③ trySetPermits 캐싱 ── 최초 1회만 Redis 호출
  │
  ▼
④ Redis 락 획득 ─────── 남은 시간으로 tryAcquire
  │
  ▼
비즈니스 로직 실행
  │
  ▼
⑤ Redis 락 해제 → 로컬 세마포어 해제 → waiterCount 감소</code></pre><p>하나씩 살펴보겠습니다.</p>
<hr>
<h2 id="1단계-trysetpermits-캐싱--concurrenthashmapnewkeyset">1단계: trySetPermits 캐싱 — <code>ConcurrentHashMap.newKeySet()</code></h2>
<h3 id="아이디어">아이디어</h3>
<p><code>trySetPermits(1)</code> 은 키당 한 번만 호출하면 됩니다.
&quot;이 키는 이미 초기화했다&quot;는 사실을 <strong>JVM 메모리에 캐싱</strong>하면 됩니다.</p>
<pre><code class="language-kotlin">// trySetPermits 중복 호출 방지 캐시
private val initializedKeys: MutableSet&lt;String&gt; = ConcurrentHashMap.newKeySet()</code></pre>
<pre><code class="language-kotlin">// acquireLock() 내부
if (initializedKeys.add(lockKey)) {  // 새 원소면 true, 이미 있으면 false
    semaphore.trySetPermits(1)       // true일 때만 Redis 호출
}</code></pre>
<p><code>Set.add()</code> 는 원소가 새로 추가되면 <code>true</code>, 이미 존재하면 <code>false</code> 를 반환합니다.
이 한 줄로 <strong>check-and-set이 원자적으로</strong> 처리됩니다.</p>
<h3 id="왜-concurrenthashmapnewkeyset-인가">왜 ConcurrentHashMap.newKeySet() 인가?</h3>
<p>멀티스레드 환경에서 <code>Set</code> 을 안전하게 쓰는 방법은 여러 가지가 있습니다.</p>
<table>
<thead>
<tr>
<th>자료구조</th>
<th>읽기</th>
<th>쓰기</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td><code>HashSet</code> + <code>synchronized</code></td>
<td>전체 락</td>
<td>전체 락</td>
<td>모든 연산이 직렬화</td>
</tr>
<tr>
<td><code>Collections.synchronizedSet()</code></td>
<td>전체 락</td>
<td>전체 락</td>
<td>위와 동일 (래퍼일 뿐)</td>
</tr>
<tr>
<td><code>CopyOnWriteArraySet</code></td>
<td>lock-free</td>
<td>배열 전체 복사</td>
<td>쓰기 시 O(n) 메모리 + GC</td>
</tr>
<tr>
<td><strong><code>ConcurrentHashMap.newKeySet()</code></strong></td>
<td><strong>lock-free</strong></td>
<td><strong>버킷 단위 락</strong></td>
<td>-</td>
</tr>
</tbody></table>
<p><code>ConcurrentHashMap</code> 은 Java 8부터 내부적으로 <strong>CAS(Compare-And-Swap) + 버킷별 synchronized</strong> 를 사용합니다.</p>
<pre><code>ConcurrentHashMap 내부 구조 (개념)

버킷 0: [keyA] ←── 이 버킷에 쓰기 시, 이 버킷만 잠금
버킷 1: [keyB, keyC]
버킷 2: (비어있음) ←── 읽기는 항상 lock-free
버킷 3: [keyD]
...</code></pre><ul>
<li><strong>읽기 (contains/add에서 존재 확인)</strong>: <code>volatile</code> 읽기로 lock-free. 락 없이 최신 값 확인</li>
<li><strong>쓰기 (add에서 새 원소 삽입)</strong>: 해당 버킷의 헤드 노드에만 <code>synchronized</code>. 다른 버킷의 읽기/쓰기를 차단하지 않음</li>
</ul>
<p>분산 락 키는 <code>ORDER_LOCK:12345</code> 같은 형태로, 서로 다른 키가 서로 다른 버킷에 분산됩니다.
결과적으로 <strong>대부분의 연산이 서로 간섭 없이 병렬 처리</strong>됩니다.</p>
<h3 id="pod-재시작-시-동작">Pod 재시작 시 동작</h3>
<p>Pod가 재시작되면 <code>initializedKeys</code> 는 빈 상태로 초기화됩니다.
이때 <code>trySetPermits(1)</code> 이 다시 호출되지만, Redis에 이미 설정된 키라면 Redisson이 무시합니다.
별도의 만료 처리나 동기화 없이도 <strong>자연스럽게 정합성이 유지</strong>됩니다.</p>
<hr>
<h2 id="2단계-로컬-세마포어--javautilconcurrentsemaphore">2단계: 로컬 세마포어 — <code>java.util.concurrent.Semaphore</code></h2>
<h3 id="아이디어-1">아이디어</h3>
<p>Thundering Herd의 근본 원인은 <strong>여러 요청이 동시에 Redis에 도달</strong>하는 것입니다.
어차피 허가는 1개이므로, <strong>Pod 안에서 1개만 통과</strong>시키면 나머지는 Redis까지 갈 필요가 없습니다.</p>
<pre><code>기존:
  요청 1 ──→ Redis tryAcquire (Lua) ──→ 성공
  요청 2 ──→ Redis tryAcquire (Lua) ──→ 실패 (대기)
  요청 3 ──→ Redis tryAcquire (Lua) ──→ 실패 (대기)
  ...
  요청 50 ─→ Redis tryAcquire (Lua) ──→ 실패 (대기)
  → Redis에 50개 Lua 스크립트 동시 실행

개선:
  요청 1 ──→ [로컬 세마포어 통과] ──→ Redis tryAcquire ──→ 성공
  요청 2 ──→ [로컬 세마포어 대기] (JVM 내부)
  요청 3 ──→ [로컬 세마포어 대기] (JVM 내부)
  ...
  요청 50 ─→ [로컬 세마포어 대기] (JVM 내부)
  → Redis에는 항상 1개만 도달</code></pre><pre><code class="language-kotlin">// 로컬 세마포어: 키별 1개 요청만 Redis 도달
private val localSemaphores = ConcurrentHashMap&lt;String, Semaphore&gt;()</code></pre>
<pre><code class="language-kotlin">// acquireLock() 내부
val localSemaphore = localSemaphores.computeIfAbsent(lockKey) { Semaphore(1) }
val localAcquired = localSemaphore.tryAcquire(waitTimeMs, TimeUnit.MILLISECONDS)
if (!localAcquired) {
    // 로컬 대기 중 타임아웃
    throw Exception(ErrorCode.Common.LOCK_FAILURE)
}</code></pre>
<h3 id="왜-javautilconcurrentsemaphore-인가">왜 java.util.concurrent.Semaphore 인가?</h3>
<p>동시성을 제어하는 도구는 여러 가지가 있습니다.</p>
<table>
<thead>
<tr>
<th>도구</th>
<th>소유자 개념</th>
<th>timeout 지원</th>
<th>크로스 스레드 해제</th>
<th>코루틴 호환</th>
</tr>
</thead>
<tbody><tr>
<td><code>synchronized</code></td>
<td>있음 (스레드)</td>
<td>없음</td>
<td>불가</td>
<td>불가</td>
</tr>
<tr>
<td><code>ReentrantLock</code></td>
<td>있음 (스레드)</td>
<td>있음</td>
<td><strong>불가</strong></td>
<td>불가</td>
</tr>
<tr>
<td><code>Semaphore</code></td>
<td><strong>없음</strong></td>
<td>있음</td>
<td><strong>가능</strong></td>
<td>불가</td>
</tr>
<tr>
<td>Kotlin <code>Mutex</code></td>
<td>없음</td>
<td>있음</td>
<td>가능</td>
<td><strong>전용</strong></td>
</tr>
</tbody></table>
<p>여기서 핵심은 <strong>&quot;소유자 개념이 없다&quot;</strong> 는 점입니다.</p>
<p><code>ReentrantLock</code> 은 <strong>락을 획득한 스레드만 해제</strong>할 수 있습니다.
하지만 우리 코드에서는 AOP가 락을 획득하고, <code>Mono.doFinally</code> 또는 <code>wrappedContinuation</code> 에서 해제합니다.
이 두 지점이 <strong>같은 스레드에서 실행된다는 보장이 없습니다.</strong></p>
<pre><code>AOP around() [Thread-1] → acquireLock() → 로컬 세마포어 획득
    ↓
비즈니스 로직 (suspend, 스레드 전환 가능)
    ↓
Mono.doFinally [Thread-3] → releaseLock() → 로컬 세마포어 해제</code></pre><p><code>Semaphore</code> 는 어떤 스레드에서든 <code>release()</code> 를 호출할 수 있으므로 이 패턴에 적합합니다.</p>
<p>Kotlin <code>Mutex</code> 는 코루틴 전용이라, AOP의 <code>around()</code> 메서드(일반 함수)에서 직접 사용할 수 없습니다.</p>
<h3 id="computeifabsent로-인스턴스-관리">computeIfAbsent로 인스턴스 관리</h3>
<pre><code class="language-kotlin">val localSemaphore = localSemaphores.computeIfAbsent(lockKey) { Semaphore(1) }</code></pre>
<p><code>computeIfAbsent()</code> 는 키가 없을 때만 팩토리를 호출하여 값을 생성합니다.</p>
<ul>
<li><strong>키가 이미 존재</strong>: lock-free 읽기로 기존 Semaphore 반환. 오버헤드 없음</li>
<li><strong>키가 없음</strong>: 해당 버킷만 잠금 후 Semaphore 생성. 동일 키에 대해 <strong>인스턴스가 하나만</strong> 생성됨</li>
</ul>
<p><code>putIfAbsent(key, Semaphore(1))</code> 와의 차이:</p>
<pre><code class="language-kotlin">// putIfAbsent: 항상 Semaphore 객체를 먼저 생성한 뒤, 이미 있으면 버림
localSemaphores.putIfAbsent(lockKey, Semaphore(1))  // 불필요한 객체 생성 가능

// computeIfAbsent: 키가 없을 때만 팩토리 호출
localSemaphores.computeIfAbsent(lockKey) { Semaphore(1) }  // 필요할 때만 생성</code></pre>
<h3 id="남은-시간-계산">남은 시간 계산</h3>
<p>로컬 세마포어에서 대기한 시간을 차감하여, Redis 락 획득에 남은 시간만 사용합니다.</p>
<pre><code class="language-kotlin">val waitTimeMs = distributedLock.waitTime * 1000
val startTime = System.currentTimeMillis()

// 로컬 세마포어 대기 (여기서 시간이 소모됨)
localSemaphore.tryAcquire(waitTimeMs, TimeUnit.MILLISECONDS)

// 남은 시간 계산
val elapsedMs = System.currentTimeMillis() - startTime
val remainingMs = waitTimeMs - elapsedMs

// Redis 락은 남은 시간으로만 시도
semaphore.tryAcquire(remainingMs, leaseTime, TimeUnit.MILLISECONDS)</code></pre>
<p>이렇게 하면 전체 대기 시간이 <code>waitTime</code> 을 초과하지 않습니다.</p>
<hr>
<h2 id="3단계-maxwaiters-fast-fail--atomicinteger">3단계: maxWaiters fast-fail — <code>AtomicInteger</code></h2>
<h3 id="아이디어-2">아이디어</h3>
<p>로컬 세마포어가 Redis 부하를 줄여주지만, <strong>대기자 자체가 무한히 쌓이는 문제</strong>는 해결하지 못합니다.
60초(기존 waitTime) 동안 대기하는 요청들이 스레드를 점유하면 <code>Dispatchers.IO</code> 가 고갈됩니다.</p>
<p>해결 방법은 단순합니다: <strong>&quot;너무 많이 기다리고 있으면, 줄 서지 말고 바로 돌아가라.&quot;</strong></p>
<pre><code class="language-kotlin">// 키별 대기자 수 추적
private val waiterCounts = ConcurrentHashMap&lt;String, AtomicInteger&gt;()</code></pre>
<pre><code class="language-kotlin">// acquireLock() 최상단
val waiterCount = waiterCounts.computeIfAbsent(lockKey) { AtomicInteger(0) }
val currentWaiters = waiterCount.incrementAndGet()

if (currentWaiters &gt; distributedLock.maxWaiters) {
    waiterCount.decrementAndGet()
    throw Exception(ErrorCode.Common.LOCK_FAILURE)  // 즉시 실패
}</code></pre>
<h3 id="왜-atomicinteger-인가">왜 AtomicInteger 인가?</h3>
<p>대기자 수를 추적하려면 <strong>증가/감소가 원자적</strong>이어야 합니다.</p>
<pre><code class="language-kotlin">// 위험: 일반 Int
var count = 0
count++  // read → increment → write: 3단계, 스레드 간 race condition 발생

// 안전: AtomicInteger
val count = AtomicInteger(0)
count.incrementAndGet()  // CAS 기반 원자 연산</code></pre>
<p><code>AtomicInteger</code> 는 <strong>CAS(Compare-And-Swap)</strong> 을 사용합니다.
CAS는 &quot;현재 값이 예상한 값과 같으면 새 값으로 교체&quot;하는 CPU 레벨 원자 연산입니다.</p>
<pre><code>CAS 동작:
1. 현재 값 읽기: 5
2. 새 값 계산: 6
3. CAS(expected=5, new=6)
   - 성공: 다른 스레드가 건드리지 않았으므로 5→6 교체
   - 실패: 다른 스레드가 이미 5→7로 바꿨음 → 1번부터 재시도 (스핀)</code></pre><p><code>synchronized</code> 와의 차이:</p>
<ul>
<li><code>synchronized</code>: 락 획득 실패 시 <strong>스레드를 블로킹</strong> (컨텍스트 스위칭 비용)</li>
<li>CAS: 실패 시 <strong>즉시 재시도</strong> (스핀). 경합이 낮으면 대부분 한 번에 성공</li>
</ul>
<p>대기자 수 추적은 <strong>경합이 낮고, 연산이 가벼운</strong> 케이스이므로 CAS가 적합합니다.</p>
<h3 id="근사치여도-안전한-이유">근사치여도 안전한 이유</h3>
<p>멀티스레드 환경에서 <code>incrementAndGet()</code> → <code>비교</code> 사이에 다른 스레드도 증가시킬 수 있습니다.
최악의 경우 <code>maxWaiters + 1~2</code> 명이 통과할 수 있지만, 이는 <strong>안전</strong>합니다.</p>
<ul>
<li>maxWaiters는 &quot;정확히 10명만 허용&quot;이 아니라, &quot;대략 10명 수준에서 제한&quot;이 목적</li>
<li>1~2명 더 통과해도 스레드 고갈에는 영향 없음</li>
<li>정확한 카운트가 필요했다면 <code>synchronized</code> 블록이 필요하지만, 그만한 비용을 치를 이유가 없음</li>
</ul>
<hr>
<h2 id="lockresource-확장과-릴리스-보장">LockResource 확장과 릴리스 보장</h2>
<h3 id="lockresource">LockResource</h3>
<p><code>LockResource</code> 에 로컬 세마포어와 대기자 카운트 참조를 추가하여, 해제 시 한 곳에서 정리합니다.</p>
<pre><code class="language-kotlin">private data class LockResource(
    val semaphore: RPermitExpirableSemaphore,
    val permitId: String,
    val lockKey: String,
    val localSemaphore: Semaphore,      // 추가
    val waiterCount: AtomicInteger       // 추가
)</code></pre>
<h3 id="releaselock">releaseLock</h3>
<pre><code class="language-kotlin">private fun releaseLock(lockResource: LockResource) {
    try {
        lockResource.semaphore.release(lockResource.permitId)  // Redis 해제
    } catch (e: Exception) {
        logger.warn(&quot;Failed to release lock: ${lockResource.lockKey}&quot;, e)
    } finally {
        lockResource.localSemaphore.release()       // 로컬 세마포어 해제
        lockResource.waiterCount.decrementAndGet()   // 대기자 수 감소
    }
}</code></pre>
<p><code>finally</code> 블록에 배치하여 <strong>Redis 해제가 실패하더라도</strong> 로컬 리소스는 반드시 정리됩니다.</p>
<p>이전 글에서 설명한 Mono/Flux/COROUTINE_SUSPENDED 모든 경로가 이 <code>releaseLock()</code> 을 통과하므로,
어떤 반환 타입이든 리소스 누수 없이 안전하게 해제됩니다.</p>
<hr>
<h2 id="부하-테스트-결과">부하 테스트 결과</h2>
<h3 id="현실적-부하-200600ms-점유-50reqmin-2분">현실적 부하 (200~600ms 점유, 50req/min, 2분)</h3>
<p>실제 비즈니스 로직의 락 점유 시간에 가까운 조건입니다.</p>
<table>
<thead>
<tr>
<th>지표</th>
<th>기존</th>
<th>개선</th>
</tr>
</thead>
<tbody><tr>
<td>성공률</td>
<td>23%</td>
<td><strong>100%</strong></td>
</tr>
<tr>
<td>평균 대기 시간</td>
<td>56,000ms</td>
<td><strong>2ms</strong></td>
</tr>
<tr>
<td>최대 동시 대기 수</td>
<td>64+</td>
<td><strong>1</strong></td>
</tr>
<tr>
<td>Redis 오류</td>
<td>발생</td>
<td><strong>0건</strong></td>
</tr>
</tbody></table>
<p>이론적 처리량(150/min)이 유입(50/min)보다 충분히 크므로,
요청이 도착하면 <strong>거의 즉시(2ms)</strong> 락을 획득합니다.</p>
<h3 id="극한-부하-110초-점유-50reqmin-10분">극한 부하 (1~10초 점유, 50req/min, 10분)</h3>
<p>의도적으로 처리량(10.9/min)보다 유입(50/min)이 훨씬 높은 상황입니다.</p>
<table>
<thead>
<tr>
<th>지표</th>
<th>기존</th>
<th>개선</th>
</tr>
</thead>
<tbody><tr>
<td>성공률</td>
<td>23%</td>
<td>21%</td>
</tr>
<tr>
<td>평균 대기 시간</td>
<td>56,000ms</td>
<td><strong>4,279ms</strong></td>
</tr>
<tr>
<td>최대 동시 대기 수</td>
<td>64+ (스레드 고갈)</td>
<td><strong>6</strong> (안정)</td>
</tr>
<tr>
<td>Redis 오류/타임아웃</td>
<td>발생</td>
<td><strong>0건</strong></td>
</tr>
<tr>
<td>대기자 추이</td>
<td>계속 누적</td>
<td><strong>4~6으로 일정</strong></td>
</tr>
</tbody></table>
<p>성공률이 비슷한 이유는 <strong>물리적 한계</strong> 때문입니다.
처리량 10.9/min으로 50/min을 소화할 수 없으므로, 어떤 최적화를 해도 ~22%가 한계입니다.</p>
<p>하지만 핵심 차이는:</p>
<ul>
<li><strong>기존</strong>: 56초 대기 → 실패. 그 동안 스레드 점유. 스레드 고갈. 연쇄 장애</li>
<li><strong>개선</strong>: 4초 대기 → 실패. 대기자 6명 이내 유지. 스레드 여유. 시스템 안정</li>
</ul>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>문제</th>
<th>원인</th>
<th>해결</th>
<th>핵심 기술</th>
</tr>
</thead>
<tbody><tr>
<td>불필요한 Redis 호출</td>
<td>trySetPermits 매번 실행</td>
<td>키별 초기화 캐싱</td>
<td><code>ConcurrentHashMap.newKeySet()</code></td>
</tr>
<tr>
<td>Thundering Herd</td>
<td>모든 대기자가 동시에 Redis 접근</td>
<td>Pod 내 1개만 통과</td>
<td><code>Semaphore</code> + <code>ConcurrentHashMap</code></td>
</tr>
<tr>
<td>스레드 고갈</td>
<td>무제한 대기자 누적</td>
<td>maxWaiters 초과 시 즉시 실패</td>
<td><code>AtomicInteger</code> (CAS)</td>
</tr>
<tr>
<td>긴 실패 대기</td>
<td>waitTime 60초</td>
<td>5초로 단축</td>
<td>fast-fail</td>
</tr>
</tbody></table>
<p>공통적으로 <strong><code>ConcurrentHashMap</code></strong> 이 키별 자원 관리의 중심 역할을 합니다.
lock-free 읽기와 버킷 단위 쓰기 잠금으로, 분산 락이 관리하는 <strong>수십~수백 개의 서로 다른 키</strong>에 대해
각각 독립적인 세마포어, 카운터, 초기화 상태를 효율적으로 유지합니다.</p>
<p>한 줄 정리: <strong>&quot;Redis에 가기 전에 JVM 안에서 먼저 정리하자&quot;</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[벨만-포드 알고리즘 완벽 이해하기]]></title>
            <link>https://velog.io/@sh__y_/bellman-ford</link>
            <guid>https://velog.io/@sh__y_/bellman-ford</guid>
            <pubDate>Sat, 25 Oct 2025 15:15:38 GMT</pubDate>
            <description><![CDATA[<h2 id="1-벨만-포드-알고리즘이란">1. 벨만-포드 알고리즘이란?</h2>
<p>벨만-포드(Bellman-Ford)는 <strong>음수 가중치가 포함된 그래프</strong>에서 <strong>최단 경로를 찾는 알고리즘</strong>이다.</p>
<h3 id="다익스트라-vs-벨만-포드">다익스트라 vs 벨만-포드</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>다익스트라 (Dijkstra)</th>
<th>벨만-포드 (Bellman-Ford)</th>
</tr>
</thead>
<tbody><tr>
<td>음수 가중치</td>
<td>불가능</td>
<td>가능</td>
</tr>
<tr>
<td>음수 사이클 감지</td>
<td>불가능</td>
<td>가능</td>
</tr>
<tr>
<td>시간 복잡도</td>
<td>O(E log V)</td>
<td>O(V × E)</td>
</tr>
<tr>
<td>구현 특징</td>
<td>우선순위 큐 사용</td>
<td>모든 간선을 V-1번 반복</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-핵심-원리">2. 핵심 원리</h2>
<h3 id="21-왜-v-1번-반복할까">2.1 왜 V-1번 반복할까?</h3>
<p>최단 경로는 <strong>최대 V-1개의 간선</strong>을 포함한다.<br>(사이클이 없는 한, 더 이상 추가 간선을 거치지 않아도 모든 노드에 도달 가능)</p>
<table>
<thead>
<tr>
<th>반복 횟수</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>시작점</td>
</tr>
<tr>
<td>1</td>
<td>시작점 → 1개 간선</td>
</tr>
<tr>
<td>2</td>
<td>시작점 → 2개 간선</td>
</tr>
<tr>
<td>...</td>
<td>...</td>
</tr>
<tr>
<td>V-1</td>
<td>시작점 → V-1개 간선 (모든 노드 도달 가능)</td>
</tr>
</tbody></table>
<h3 id="22-음수-사이클-감지">2.2 음수 사이클 감지</h3>
<p>V번째 반복에서도 <strong>거리가 갱신된다면</strong>, 음수 사이클이 존재한다는 의미다.</p>
<pre><code class="language-java">// V-1번 완화
for (int i = 1; i &lt; V; i++) {
    for (모든 간선 u → v) {
        if (dist[u] + weight &lt; dist[v]) {
            dist[v] = dist[u] + weight;
        }
    }
}

// V번째 반복 - 음수 사이클 감지
for (모든 간선 u → v) {
    if (dist[u] + weight &lt; dist[v]) {
        return &quot;음수 사이클 존재!&quot;;
    }
}</code></pre>
<hr>
<h2 id="3-문제별-적용-분석">3. 문제별 적용 분석</h2>
<h3 id="문제-1-1865번---웜홀-음수-사이클-감지">문제 1: 1865번 - 웜홀 (음수 사이클 감지)</h3>
<p><strong>핵심 아이디어</strong></p>
<ul>
<li>일반 도로: 양방향, 양수 가중치  </li>
<li>웜홀: 단방향, 음수 가중치 (시간을 되돌림)  </li>
<li>음수 사이클이 존재한다면 “출발 시간보다 더 이전으로 돌아올 수 있음”</li>
</ul>
<p><strong>가상 노드 추가</strong></p>
<pre><code class="language-java">for (int i = 1; i &lt;= inputArr[0]; i++) {
    graph.computeIfAbsent(0, k -&gt; new ArrayList&lt;&gt;())
         .add(new int[]{i, 0});  // 0 → 모든 노드, 가중치 0
}</code></pre>
<p><strong>이유</strong></p>
<ul>
<li>시작점이 정해지지 않음  </li>
<li>어느 노드에서든 음수 사이클 존재 여부를 확인해야 함  </li>
<li>가상 노드 0에서 모든 노드로 가중치 0으로 연결하면 전체 탐색 가능</li>
</ul>
<hr>
<h3 id="문제-2-1738번---골목길-최장-경로--양의-사이클">문제 2: 1738번 - 골목길 (최장 경로 + 양의 사이클)</h3>
<p><strong>핵심 아이디어</strong></p>
<ul>
<li>최단 경로가 아닌 <strong>최장 경로</strong>를 구하는 문제  </li>
<li>가중치를 음수로 변환하여 벨만-포드로 풀이  </li>
<li><strong>양의 사이클이 존재하면 무한히 돈을 벌 수 있음 → -1 출력</strong></li>
</ul>
<p><strong>양의 사이클 감지 및 전파</strong></p>
<pre><code class="language-java">boolean[] inPositiveCycle = new boolean[n + 1];

// 1단계: 양의 사이클 존재 확인
for (모든 간선 u → v) {
    if (balance[u] + w &gt; balance[v]) {
        inPositiveCycle[v] = true;
    }
}

// 2단계: 사이클 영향 전파 (V-1번 반복)
for (int i = 1; i &lt; n; i++) {
    for (모든 간선 u → v) {
        if (inPositiveCycle[u]) {
            inPositiveCycle[v] = true;
        }
    }
}</code></pre>
<p><strong>왜 전파가 필요한가?</strong></p>
<ul>
<li>양의 사이클은 탐지했지만, 그 영향을 받는 노드들도 표시해야 한다.  </li>
<li>사이클에 연결된 노드는 모두 무한 이득 가능.</li>
</ul>
<hr>
<h3 id="문제-3-1219번---오민식의-고민-복합-문제">문제 3: 1219번 - 오민식의 고민 (복합 문제)</h3>
<p><strong>핵심 아이디어</strong></p>
<ul>
<li>이동 경로에 따른 교통비와 도시별 수입이 모두 고려됨  </li>
<li>양의 사이클로 인해 무한히 돈을 벌 수 있는지 판단  </li>
<li>목적지까지 도달 가능한지 확인해야 함</li>
</ul>
<p><strong>복합 갱신 조건</strong></p>
<pre><code class="language-java">if(balance[u] != Long.MIN_VALUE &amp;&amp;
   balance[u] + w + earn[v] &gt; balance[v]) {
    balance[v] = balance[u] + w + earn[v];
}</code></pre>
<p><strong>BFS로 사이클 영향 전파</strong></p>
<pre><code class="language-java">Queue&lt;Integer&gt; queue = new LinkedList&lt;&gt;();
for (int i = 0; i &lt; N; i++) {
    if (inPositiveCycle[i]) queue.offer(i);
}

while (!queue.isEmpty()) {
    int u = queue.poll();
    if(graph.containsKey(u)) {
        for(var edge : graph.get(u)) {
            int v = edge[0];
            if (!inPositiveCycle[v]) {
                inPositiveCycle[v] = true;
                queue.offer(v);
            }
        }
    }
}</code></pre>
<p>BFS 방식은 불필요한 반복 완화를 줄여 효율적이다.</p>
<hr>
<h2 id="4-구현-패턴-정리">4. 구현 패턴 정리</h2>
<h3 id="기본-벨만-포드-템플릿">기본 벨만-포드 템플릿</h3>
<pre><code class="language-java">// 1. 초기화
int[] dist = new int[N];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[start] = 0;

// 2. V-1번 완화
for (int i = 1; i &lt; N; i++) {
    for (모든 간선 u → v, weight w) {
        if (dist[u] != Integer.MAX_VALUE &amp;&amp;
            dist[u] + w &lt; dist[v]) {
            dist[v] = dist[u] + w;
        }
    }
}

// 3. 음수 사이클 감지
for (모든 간선 u → v, weight w) {
    if (dist[u] != Integer.MAX_VALUE &amp;&amp;
        dist[u] + w &lt; dist[v]) {
        // 음수 사이클 존재!
    }
}</code></pre>
<h3 id="변형-패턴">변형 패턴</h3>
<table>
<thead>
<tr>
<th>문제 유형</th>
<th>변형 방식</th>
</tr>
</thead>
<tbody><tr>
<td>최장 경로</td>
<td>가중치 부호 반전, 비교 부등호 반대</td>
</tr>
<tr>
<td>시작점 불명</td>
<td>가상 노드 추가</td>
</tr>
<tr>
<td>사이클 영향 전파</td>
<td>BFS/DFS 또는 V-1번 추가 완화</td>
</tr>
<tr>
<td>복합 계산</td>
<td>갱신 시 수입/비용 포함</td>
</tr>
</tbody></table>
<hr>
<h2 id="5-주의사항">5. 주의사항</h2>
<h3 id="51-무한대-처리">5.1 무한대 처리</h3>
<pre><code class="language-java">// 잘못된 방식
if (dist[u] + w &lt; dist[v])

// 올바른 방식
if (dist[u] != Integer.MAX_VALUE &amp;&amp; dist[u] + w &lt; dist[v])</code></pre>
<h3 id="52-사이클-감지만으로는-부족">5.2 사이클 감지만으로는 부족</h3>
<ul>
<li>사이클이 있어도 목적지와 관련 없으면 결과에 영향 없음  </li>
<li>반드시 <strong>사이클 → 목적지 연결 여부 확인 필요</strong></li>
</ul>
<h3 id="53-자료형-주의">5.3 자료형 주의</h3>
<ul>
<li>1219번의 경우 돈 단위가 커질 수 있으므로 long 사용 필수</li>
</ul>
<hr>
<h2 id="6-시간복잡도-분석">6. 시간복잡도 분석</h2>
<ul>
<li>기본 벨만-포드: <strong>O(V × E)</strong>  <ul>
<li>V-1번 반복 × 각 반복에서 E개의 간선 확인  </li>
</ul>
</li>
<li>사이클 전파 추가 시: <strong>BFS → O(V + E)</strong>  <ul>
<li>완화 방식 전파(1738번)는 여전히 O(V × E)</li>
</ul>
</li>
</ul>
<p><strong>총합:</strong> O(V × E)</p>
<hr>
<h2 id="7-언제-벨만-포드를-사용할까">7. 언제 벨만-포드를 사용할까?</h2>
<table>
<thead>
<tr>
<th>사용해야 하는 경우</th>
<th>사용하지 말아야 하는 경우</th>
</tr>
</thead>
<tbody><tr>
<td>음수 가중치 존재</td>
<td>모든 가중치가 양수</td>
</tr>
<tr>
<td>음수/양수 사이클 감지 필요</td>
<td>단순 최단 경로 탐색</td>
</tr>
<tr>
<td>시작점이 불명확</td>
<td>그래프가 매우 큼 (V × E 과다)</td>
</tr>
</tbody></table>
<hr>
<h2 id="정리">정리</h2>
<p>세 문제는 모두 벨만-포드의 핵심인 <strong>V-1번 완화 + V번째 사이클 감지</strong> 원리를 기반으로 한다.<br>하지만 각 문제는 그래프 구조나 요구 조건에 맞게 다음과 같이 변형되었다.</p>
<table>
<thead>
<tr>
<th>문제</th>
<th>주요 포인트</th>
</tr>
</thead>
<tbody><tr>
<td>1865 웜홀</td>
<td>음수 사이클 감지, 가상 노드 활용</td>
</tr>
<tr>
<td>1738 골목길</td>
<td>최장 경로 + 양의 사이클 전파</td>
</tr>
<tr>
<td>1219 오민식의 고민</td>
<td>수입/비용 계산 + BFS 전파</td>
</tr>
</tbody></table>
<p><strong>결국 벨만-포드는 “경로 완화 기반의 상태 갱신 알고리즘”이다.</strong><br>그래프가 복잡해질수록, 이 기본 구조를 문제에 맞게 변형하는 능력이 중요하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[시뮬레이션(Simulation) 문제 — 상태를 직접 움직이는 사고법]]></title>
            <link>https://velog.io/@sh__y_/simulation</link>
            <guid>https://velog.io/@sh__y_/simulation</guid>
            <pubDate>Sun, 19 Oct 2025 11:35:43 GMT</pubDate>
            <description><![CDATA[<h2 id="1-시뮬레이션-문제란">1. 시뮬레이션 문제란</h2>
<p>시뮬레이션 문제는 <strong>주어진 규칙에 따라 상태를 직접 변화시키며 최종 결과를 도출하는 문제 유형</strong>이다.<br>정해진 공식이나 점화식으로 답을 구하는 DP, 탐색, 수학적 문제와 달리<br><strong>“규칙을 그대로 구현해야 하는 문제”</strong>가 대부분이다.</p>
<p>즉, 해답의 핵심은 알고리즘보다는 <strong>과정의 재현</strong>이다.<br>문제에서 말하는 조건, 반복, 제약을 하나하나 실제 코드 흐름으로 옮겨야 한다.<br>이 때문에 코드의 복잡도는 높지만, 알고리즘 자체는 단순한 경우가 많다.</p>
<hr>
<h2 id="2-시뮬레이션-문제의-특징">2. 시뮬레이션 문제의 특징</h2>
<ol>
<li><p><strong>명확한 규칙 기반 동작</strong>  </p>
<ul>
<li>문제에 “~을 반복한다”, “조건에 따라 ~을 수행한다” 등의 문장이 많다.  </li>
<li>수학적 최적화보다는 순서 제어가 중심이 된다.</li>
</ul>
</li>
<li><p><strong>상태 변화 추적이 핵심</strong>  </p>
<ul>
<li>배열, 리스트, 큐 등에서 “현재 상태”를 기준으로 다음 상태를 계산.  </li>
<li>변화가 누적되기 때문에, 매 단계에서 상태 관리가 중요하다.</li>
</ul>
</li>
<li><p><strong>조건 분기와 반복문 중심의 구조</strong>  </p>
<ul>
<li>if / while / for문이 주요 논리 흐름을 구성.  </li>
<li>탐색보다는 “규칙을 따라가며 결과를 갱신”하는 방식.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="3-시뮬레이션-문제를-푸는-사고-순서">3. 시뮬레이션 문제를 푸는 사고 순서</h2>
<p>시뮬레이션 문제를 풀 때는 다음과 같은 사고 과정을 거친다.</p>
<ol>
<li><p><strong>문제 속 객체(Object) 정의</strong>  </p>
<ul>
<li>어떤 것이 ‘상태’를 가지는지 파악한다.  </li>
<li>예: 스위치의 on/off, 원판의 회전 각도, 벽의 높이 등  </li>
</ul>
</li>
<li><p><strong>상태를 표현할 자료 구조 설계</strong>  </p>
<ul>
<li>1차원 배열, 2차원 배열, offset 배열 등  </li>
<li>단순히 저장이 아니라 “시간에 따라 변할 수 있는 구조”로 설계해야 한다.  </li>
</ul>
</li>
<li><p><strong>명령 또는 이벤트 정의</strong>  </p>
<ul>
<li>문제에서 주어진 동작을 ‘하나의 함수 단위’로 생각한다.  </li>
<li>ex) rotate(), flip(), spread(), erase()  </li>
</ul>
</li>
<li><p><strong>반복 구조 설계</strong>  </p>
<ul>
<li>명령이 여러 번 주어지는 경우, 입력 순서대로 처리.  </li>
<li>각 단계에서 상태를 정확히 갱신.  </li>
</ul>
</li>
<li><p><strong>종료 조건과 출력 관리</strong>  </p>
<ul>
<li>특정 횟수 반복 또는 조건 충족 시 종료.  </li>
<li>누적된 상태로부터 결과 계산.</li>
</ul>
</li>
</ol>
<p>이 과정은 실제 프로그램을 설계하는 것과 매우 유사하다.<br>그래서 시뮬레이션 문제는 <strong>“코드 구현 능력”을 가장 직접적으로 평가하는 문제 유형</strong>이라 할 수 있다.</p>
<hr>
<h2 id="4-시뮬레이션-문제의-통상적-풀이-방법">4. 시뮬레이션 문제의 통상적 풀이 방법</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>내용</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>초기 상태 구성</td>
<td>입력을 배열이나 객체로 저장</td>
</tr>
<tr>
<td>2</td>
<td>명령 실행</td>
<td>조건에 따라 상태 갱신</td>
</tr>
<tr>
<td>3</td>
<td>경계 조건 처리</td>
<td>배열 인덱스 범위, 대칭 여부 등</td>
</tr>
<tr>
<td>4</td>
<td>누적 연산 또는 조건 비교</td>
<td>삭제, 누적, 평균 조정 등</td>
</tr>
<tr>
<td>5</td>
<td>결과 계산</td>
<td>전체 합, 특정 상태 수 등</td>
</tr>
</tbody></table>
<p>여기서 중요한 점은 <strong>“모든 상태 변화는 즉시 반영하거나, 별도 구조로 저장 후 일괄 반영해야 한다”</strong>는 것이다.<br>즉, 문제의 규칙을 따라가는 과정에서 <strong>타이밍과 순서</strong>가 핵심 포인트가 된다.</p>
<hr>
<h2 id="5-예제별-풀이-분석">5. 예제별 풀이 분석</h2>
<p>아래의 세 문제는 모두 시뮬레이션의 전형적인 형태를 가지고 있다.<br>각각의 코드는 “상태를 직접 제어하고 갱신하는 구조”라는 공통된 흐름을 갖고 있다.</p>
<hr>
<h3 id="1-스위치-켜고-끄기--단순-규칙-기반-시뮬레이션">(1) 스위치 켜고 끄기 — 단순 규칙 기반 시뮬레이션</h3>
<p>이 문제는 가장 기초적인 형태의 시뮬레이션이다.<br>스위치의 on/off 상태를 그대로 구현하는 문제로, <strong>상태 갱신의 반복</strong>이 핵심이다.</p>
<h4 id="상태-정의">상태 정의</h4>
<ul>
<li><code>switches[]</code>: 각 스위치의 현재 상태(boolean)  </li>
<li>true = 켜짐, false = 꺼짐  </li>
</ul>
<h4 id="규칙-시뮬레이션">규칙 시뮬레이션</h4>
<ul>
<li>남학생: 받은 번호의 배수마다 상태 반전  </li>
<li>여학생: 좌우 대칭이 유지되는 범위까지만 확장 반전  </li>
</ul>
<h4 id="구현-포인트">구현 포인트</h4>
<ul>
<li>실제 시뮬레이션은 단순한 반복문으로 구성  </li>
<li>각 명령이 입력될 때마다 배열 상태가 즉시 갱신됨  </li>
</ul>
<h4 id="핵심-요약">핵심 요약</h4>
<ul>
<li>명령 → 즉시 반영 구조  </li>
<li>시뮬레이션의 가장 단순한 형태 (조건 + 반복)</li>
</ul>
<hr>
<h3 id="2-빗물--구조적-시각화를-통한-상태-추적">(2) 빗물 — 구조적 시각화를 통한 상태 추적</h3>
<p>이 문제는 시뮬레이션을 “시각적으로 표현”하는 형태다.<br>실제 물이 고이는 상황을 배열로 그대로 표현했다.</p>
<h4 id="상태-정의-1">상태 정의</h4>
<ul>
<li><code>world[H][W]</code>: 벽의 유무 (1이면 벽, 0이면 빈 공간)</li>
</ul>
<h4 id="로직-흐름">로직 흐름</h4>
<ol>
<li>입력된 벽 높이를 기준으로 world[][] 구성  </li>
<li>각 행을 기준으로 좌→우로 스캔  </li>
<li>첫 벽 이후 두 번째 벽 사이에 생긴 빈 칸 개수를 물로 카운트  </li>
</ol>
<h4 id="구현-의도">구현 의도</h4>
<ul>
<li>1차원으로도 가능하지만, 실제 고이는 모습을 2차원으로 시뮬레이션  </li>
<li>물이 쌓이는 구조를 코드로 “그려본다”는 느낌에 가깝다.  </li>
</ul>
<h4 id="핵심-요약-1">핵심 요약</h4>
<ul>
<li>상태를 직접 구조화한 형태의 시뮬레이션  </li>
<li>시각화 중심 접근 (직관적, 절차적)  </li>
</ul>
<hr>
<h3 id="3-원판-돌리기--복잡한-상태-전이와-다단계-규칙">(3) 원판 돌리기 — 복잡한 상태 전이와 다단계 규칙</h3>
<p>이 문제는 전형적인 고난도 시뮬레이션 문제다.<br>단일 조건의 반복이 아니라,<br><strong>여러 개의 규칙이 순차적으로 연쇄적으로 작동</strong>하는 구조를 가진다.</p>
<h4 id="상태-정의-2">상태 정의</h4>
<ul>
<li><code>circle[][]</code>: 각 원판의 숫자 상태  </li>
<li><code>pos[]</code>: 각 원판의 회전 offset 상태  </li>
</ul>
<h4 id="주요-시뮬레이션-단계">주요 시뮬레이션 단계</h4>
<ol>
<li><strong>회전 처리</strong> — pos[]를 이용해 offset 이동  </li>
<li><strong>인접한 수 탐색 및 삭제 표시</strong>  </li>
<li><strong>삭제 후 평균 조정</strong>  </li>
<li><strong>다음 명령 반복</strong>  </li>
</ol>
<h4 id="구현-의도-1">구현 의도</h4>
<ul>
<li>실제 회전을 수행하지 않고 offset으로 상태 추적  </li>
<li>삭제와 평균 조정을 별도 단계로 분리하여 로직 충돌 방지  </li>
</ul>
<h4 id="핵심-요약-2">핵심 요약</h4>
<ul>
<li>다단계 시뮬레이션의 대표 예시  </li>
<li>한 번의 명령에 여러 상태 변화 발생  </li>
<li>offset을 이용해 메모리 효율성과 명확한 제어 구조 확보  </li>
</ul>
<hr>
<h2 id="6-시뮬레이션-문제에서-중요한-사고">6. 시뮬레이션 문제에서 중요한 사고</h2>
<ol>
<li><p><strong>문제를 코드로 옮기는 순서를 정확히 설계해야 한다.</strong>  </p>
<ul>
<li>조건을 해석하는 순서가 곧 정답을 결정한다.</li>
</ul>
</li>
<li><p><strong>상태를 한눈에 볼 수 있는 구조를 만들어야 한다.</strong>  </p>
<ul>
<li>배열, 리스트, offset 등은 단순 저장이 아니라 ‘현재 시점의 상태’를 표현해야 한다.</li>
</ul>
</li>
<li><p><strong>변화는 즉시 반영하거나 일괄 반영 중 하나로 통일해야 한다.</strong>  </p>
<ul>
<li>원판 돌리기처럼 여러 단계의 변화를 다루는 문제에서는 “시점 통제”가 가장 중요하다.</li>
</ul>
</li>
<li><p><strong>시뮬레이션은 디버깅 중심의 문제다.</strong>  </p>
<ul>
<li>논리적 실수보다는 구현 순서, 조건 누락, 오프셋 계산 오류가 주된 원인이다.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="7-결론">7. 결론</h2>
<p>시뮬레이션 문제는 <strong>“상태의 변화”를 코드로 재현하는 문제</strong>다.<br>문제의 본질은 복잡하지 않지만, 구현 과정에서 정확한 제어 흐름과 세밀한 조건 처리가 요구된다.</p>
<ul>
<li>스위치 켜고 끄기: 단일 명령 반복 구조  </li>
<li>빗물: 구조적 시각화 중심  </li>
<li>원판 돌리기: 다단계 상태 전이  </li>
</ul>
<p>이 세 문제는 각각 시뮬레이션의 기본, 확장, 복합 형태를 잘 보여준다.<br>즉, 시뮬레이션 문제를 잘 다루려면 <strong>“상태를 코드로 표현하고 조작하는 감각”</strong>이 필요하다.  </p>
<p>이 감각이 익숙해지면, 단순 구현 문제를 넘어서<br>복잡한 게임 로직, 프로세스 시뮬레이션, 동적 상태 제어 문제에도 자연스럽게 응용할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[REST API vs GraphQL API의 데이터 통합 관점 차이]]></title>
            <link>https://velog.io/@sh__y_/REST-API-vs-GraphQL-API</link>
            <guid>https://velog.io/@sh__y_/REST-API-vs-GraphQL-API</guid>
            <pubDate>Thu, 09 Oct 2025 12:49:45 GMT</pubDate>
            <description><![CDATA[<h2 id="1-서론">1. 서론</h2>
<p>REST와 GraphQL은 모두 널리 쓰이는 API 접근 방식이지만, <strong>데이터를 어떻게 요청·조합·전달할 것인지에 대한 철학</strong>이 다르다. 본 글은 두 방식을 데이터 통합 관점에서 비교하고, 어떤 상황에서 어떤 방식을 선택할지 실무 기준을 제시한다.</p>
<hr>
<h2 id="2-개념적-차이">2. 개념적 차이</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>REST API</th>
<th>GraphQL API</th>
</tr>
</thead>
<tbody><tr>
<td>기본 개념</td>
<td>자원(Resource) 중심</td>
<td>질의(Query) 중심</td>
</tr>
<tr>
<td>엔드포인트</td>
<td>다수(<code>/users</code>, <code>/orders</code>)</td>
<td>단일(<code>/graphql</code>)</td>
</tr>
<tr>
<td>응답 구조</td>
<td>서버가 고정 정의</td>
<td>클라이언트가 선택(필드 단위)</td>
</tr>
<tr>
<td>문서화</td>
<td>Swagger/OpenAPI</td>
<td>스키마 자체가 문서화(Self-documenting)</td>
</tr>
<tr>
<td>변경 관리</td>
<td>버전 분기(<code>/v1</code>, <code>/v2</code>)</td>
<td>스키마 확장/Deprecated 필드</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-요청응답-구조-비교">3. 요청/응답 구조 비교</h2>
<h3 id="31-rest-방식예시">3.1 REST 방식(예시)</h3>
<p>두 서비스가 있다고 가정한다. <code>User</code>는 사용자 서비스, <code>Attendance</code>는 이벤트 서비스가 소유.</p>
<pre><code>GET /users/1
→ { &quot;id&quot;: 1, &quot;name&quot;: &quot;Kim&quot;, &quot;email&quot;: &quot;kim@example.com&quot; }

GET /attendance?userId=1
→ { &quot;userId&quot;: 1, &quot;streak&quot;: 6, &quot;lastAttendanceDate&quot;: &quot;2025-10-08&quot; }</code></pre><ul>
<li>클라이언트는 두 번 호출하고 결과를 합쳐 화면을 만든다.</li>
<li>필요 이상 데이터를 포함하거나(Overfetching), 원하는 데이터를 얻기 위해 여러 번 호출(Underfetching)할 수 있다.</li>
</ul>
<h3 id="32-graphql-방식예시">3.2 GraphQL 방식(예시)</h3>
<p>하나의 질의로 필요한 필드만 요청한다.</p>
<pre><code class="language-graphql"># POST /graphql
query {
  user(id: 1) {
    name
    attendance {
      streak
      lastAttendanceDate
    }
  }
}</code></pre>
<p>응답:</p>
<pre><code class="language-json">{
  &quot;data&quot;: {
    &quot;user&quot;: {
      &quot;name&quot;: &quot;Kim&quot;,
      &quot;attendance&quot;: {
        &quot;streak&quot;: 6,
        &quot;lastAttendanceDate&quot;: &quot;2025-10-08&quot;
      }
    }
  }
}</code></pre>
<ul>
<li>한 번의 요청으로 다수 서비스 데이터를 <strong>통합</strong>하여 수신.</li>
<li>필요한 필드만 골라 받아 Over/Underfetching 문제를 최소화.</li>
</ul>
<hr>
<h2 id="4-overfetching--underfetching">4. Overfetching / Underfetching</h2>
<ul>
<li><strong>REST</strong>: 엔드포인트별 고정 응답 구조 탓에, 과다 데이터 수신(Overfetching)이나 여러 API 호출(Underfetching)이 발생하기 쉽다.</li>
<li><strong>GraphQL</strong>: 클라이언트가 필드를 선택해 요청하므로 두 문제를 구조적으로 완화한다.</li>
</ul>
<hr>
<h2 id="5-엔드포인트와-버전-관리">5. 엔드포인트와 버전 관리</h2>
<h3 id="51-엔드포인트-구조">5.1 엔드포인트 구조</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>REST</th>
<th>GraphQL</th>
</tr>
</thead>
<tbody><tr>
<td>요청 방식</td>
<td>URL + 메서드</td>
<td>단일 URL + 질의 언어</td>
</tr>
<tr>
<td>응답 구조</td>
<td>서버가 정함</td>
<td>클라이언트가 선택</td>
</tr>
</tbody></table>
<h3 id="52-버전-관리">5.2 버전 관리</h3>
<ul>
<li><strong>REST</strong>: <code>/api/v1/users</code>, <code>/api/v2/users</code> 처럼 URL 버전을 분리.</li>
<li><strong>GraphQL</strong>: 필드 Deprecation을 활용해 점진적 변경을 유도.</li>
</ul>
<pre><code class="language-graphql">type User {
  id: ID!
  name: String!
  email: String @deprecated(reason: &quot;Use contact.email instead&quot;)
  contact: Contact
}</code></pre>
<hr>
<h2 id="6-데이터-통합-rest-aggregation-vs-graphql-federation">6. 데이터 통합: REST Aggregation vs GraphQL Federation</h2>
<h3 id="61-rest-aggregation게이트웨이-집계">6.1 REST Aggregation(게이트웨이 집계)</h3>
<p>Gateway가 여러 REST 서비스를 호출해 응답을 합쳐 단일 응답으로 제공할 수 있다.</p>
<pre><code class="language-ts">// 예시: GET /api/user/summary/:id
// 사용자 기본 정보 + 출석 정보를 합쳐 반환
const user = await http.get(`http://user-service/users/${id}`);
const attendance = await http.get(`http://event-service/attendance?userId=${id}`);
return {
  id,
  name: user.data.name,
  attendance: {
    streak: attendance.data.streak,
    lastDate: attendance.data.lastAttendanceDate,
  },
};</code></pre>
<p><strong>장점</strong>: 단순하고 의도 통제가 쉬움.<br><strong>단점</strong>: 통합 포인트가 늘수록 엔드포인트 폭증, 응답 구조 중복, 문서 동기화 부담.</p>
<h3 id="62-graphql-federation스키마-기반-통합">6.2 GraphQL Federation(스키마 기반 통합)</h3>
<p>여러 서비스의 스키마를 합쳐 하나의 GraphQL 게이트웨이가 질의를 분해/중계/집계한다.</p>
<ul>
<li>클라이언트는 단일 <code>/graphql</code>로 다양한 조합을 자유롭게 질의.</li>
<li>응답 구조는 스키마로 일원화되고, 필드 단위로 점진적 변경이 가능.</li>
</ul>
<p><strong>장점</strong>: 클라이언트 주도형 통합, 스키마 일관성, Over/Underfetching 완화.<br><strong>단점</strong>: Federation 구성 복잡성, N+1·캐싱·관측성 설계 필요, 운영 난이도.</p>
<p><strong>핵심 정리</strong></p>
<ul>
<li>REST도 “게이트웨이에서 합치면” 데이터 통합이 가능하다.</li>
<li>GraphQL은 그 과정을 <strong>스키마와 질의 언어로 표준화</strong>하여, 통합 지점을 코드가 아닌 <strong>스키마 레벨</strong>에서 관리하도록 해준다.</li>
</ul>
<hr>
<h2 id="7-단점과-주의사항">7. 단점과 주의사항</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>REST</th>
<th>GraphQL</th>
</tr>
</thead>
<tbody><tr>
<td>장점</td>
<td>단순, 캐싱 용이, 관측 쉬움</td>
<td>유연한 질의, 통합 조회, 스키마 문서화</td>
</tr>
<tr>
<td>단점</td>
<td>다중 요청·엔드포인트 폭증</td>
<td>복잡한 쿼리로 서버 부하, 캐싱/관측 난이도</td>
</tr>
<tr>
<td>성숙도</td>
<td>매우 높음</td>
<td>빠르게 성장 중이나 운영 난이도 존재</td>
</tr>
<tr>
<td>적합</td>
<td>행위 중심(로그인/명령형), 단순 API</td>
<td>조회 중심 복합 화면, 집계/탐색형 API</td>
</tr>
</tbody></table>
<hr>
<h2 id="8-선택-가이드실무-요약">8. 선택 가이드(실무 요약)</h2>
<p>1) <strong>행위 중심(CUD)·단순 트랜잭션</strong>: REST(또는 REST + MQ).<br>2) <strong>조회 중심·복합 화면</strong>: GraphQL(또는 REST Aggregation).<br>3) <strong>내부 대량 조회/저지연</strong>: gRPC(또는 GraphQL 서브그래프 내부 호출).<br>4) <strong>상태 변경 전파/비동기 파이프라인</strong>: MQ(Kafka/RabbitMQ/Redis Streams).<br>5) <strong>혼합 전략</strong>: <code>/auth</code>, <code>/admin</code> 등은 REST, 복합 조회는 <code>/graphql</code>로 제공.</p>
<hr>
<h2 id="9-결론">9. 결론</h2>
<ul>
<li>REST와 GraphQL 모두 <strong>데이터 통합</strong>이 가능하다.  </li>
<li>차이는 “<strong>어디에서, 어떤 추상화로 통합을 관리하느냐</strong>”에 있다.  <ul>
<li>REST: 게이트웨이 코드로 엔드포인트별 통합 구현(명령적).  </li>
<li>GraphQL: 스키마·질의 언어로 통합을 선언/조합(선언적).  </li>
</ul>
</li>
<li>팀의 역량, 트래픽 특성, 화면 복잡도, 운영 도구 성숙도에 따라 병행 구성이 최선일 수 있다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[MSA에서 API Gateway 중심의 서버 간 통신 방식 비교]]></title>
            <link>https://velog.io/@sh__y_/msaapigatewaycommunication</link>
            <guid>https://velog.io/@sh__y_/msaapigatewaycommunication</guid>
            <pubDate>Thu, 09 Oct 2025 12:47:16 GMT</pubDate>
            <description><![CDATA[<h2 id="1-개요">1. 개요</h2>
<p>MSA(Microservice Architecture)에서는 하나의 거대한 애플리케이션 대신, 역할별로 쪼개진 여러 서비스가 독립적으로 배포/확장됩니다. 이때 모든 외부 트래픽은 <strong>API Gateway</strong>를 통해 유입되며, Gateway는 인증/인가, 라우팅, 로깅/모니터링, 속도 제한, 응답 집계(데이터 통합) 같은 공통 관심사를 담당합니다.</p>
<pre><code>[Client]
   ↓
[API Gateway]  ← 인증/인가 · 라우팅 · 로깅 · 캐싱 · 속도 제한 · 응답 집계
   ↓
 ┌───────────────┬────────────────┐
 │               │                │
[Auth Service]  [Event Service]  [Other Services...]</code></pre><hr>
<h2 id="2-주요-통신-방식">2. 주요 통신 방식</h2>
<p>Gateway와 내부 서비스(또는 서비스 간) 통신은 단일 해법이 아닌 목적에 따라 다양한 방식을 조합합니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>프로토콜</th>
<th>통신 형태</th>
<th>대표 용도</th>
</tr>
</thead>
<tbody><tr>
<td>(1)</td>
<td>HTTP REST</td>
<td>요청/응답(동기)</td>
<td>범용 API, 인증/인가, 간단한 CRUD</td>
</tr>
<tr>
<td>(2)</td>
<td>gRPC</td>
<td>요청/응답(동기) + 스트리밍</td>
<td>고성능 내부 RPC, 다량 조회</td>
</tr>
<tr>
<td>(3)</td>
<td>Message Queue(Kafka/RabbitMQ/Redis Streams)</td>
<td>비동기</td>
<td>이벤트 발행/구독, 비동기 CUD 처리</td>
</tr>
<tr>
<td>(4)</td>
<td>GraphQL Federation</td>
<td>질의 기반(동기)</td>
<td>다수 서비스 데이터의 통합 조회</td>
</tr>
<tr>
<td>(5)</td>
<td>WebSocket / SSE</td>
<td>실시간 양방향/단방향 스트림</td>
<td>실시간 알림, 상태 갱신</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-통신-방식별-특징과-장단점">3. 통신 방식별 특징과 장단점</h2>
<h3 id="31-http-rest">3.1 HTTP REST</h3>
<p><strong>특징</strong></p>
<ul>
<li>가장 널리 쓰이는 동기 통신. 브라우저/네이티브 클라이언트 친화적, 디버깅이 단순.</li>
<li>JSON 직렬화와 HTTP 헤더로 인한 오버헤드가 존재.</li>
</ul>
<p><strong>장점</strong></p>
<ul>
<li>구현/운영/문서화(Swagger/OpenAPI) 용이.</li>
<li>캐싱, 로드밸런싱, 프록시 등 주변 생태계가 성숙.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>트래픽이 매우 많거나 내부 호출이 잦은 경우 성능/지연 비용이 커질 수 있음.</li>
<li>고정 응답 구조로 인해 Overfetching/Underfetching 발생 가능.</li>
</ul>
<p><strong>권장 사용처</strong></p>
<ul>
<li>인증/인가, 설정/메타데이터, 단순한 조회/명령, 외부 공개 API.</li>
</ul>
<hr>
<h3 id="32-grpc">3.2 gRPC</h3>
<p><strong>특징</strong></p>
<ul>
<li>HTTP/2 + Protocol Buffers 기반의 고성능 RPC. 양방향 스트리밍 지원.</li>
<li>엄격한 스키마(.proto)로 타입 안정성·성능 확보.</li>
</ul>
<p><strong>장점</strong></p>
<ul>
<li>REST 대비 높은 직렬화 효율(저용량·저지연).</li>
<li>내부 서비스 간 대량 조회·저지연 처리에 적합.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>브라우저 직접 호출 곤란(Gateway에서 변환 필요).</li>
<li>디버깅/관측 난이도, .proto 관리 오버헤드.</li>
</ul>
<p><strong>권장 사용처</strong></p>
<ul>
<li>내부 서비스 간 <strong>Read 중심</strong> 호출(상태/구성/참조 데이터 조회), 대량 트래픽.</li>
</ul>
<hr>
<h3 id="33-message-queue-kafka--rabbitmq--redis-streams">3.3 Message Queue (Kafka / RabbitMQ / Redis Streams)</h3>
<p><strong>특징</strong></p>
<ul>
<li>비동기 메시지 기반. 발행/구독으로 서비스 결합도 감소, 장애 격리에 유리.</li>
<li>재시도/재처리, 소비자 수평 확장에 강함.</li>
</ul>
<p><strong>장점</strong></p>
<ul>
<li>요청-처리 분리로 시스템 탄력성↑, 피크 완화.</li>
<li>트랜잭션 경계를 느슨하게 가져가며 결과적 일관성 모델 구현 용이.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>즉시 응답이 어려움(최종적 반영). 순서/중복/멱등성 관리 필요.</li>
<li>운영 복잡도(Kafka 클러스터 등)와 비용.</li>
</ul>
<p><strong>권장 사용처</strong></p>
<ul>
<li><strong>Create/Update/Delete(CUD) 중심</strong>의 상태 변경 이벤트 처리, 보상/쿠폰 지급, 사후 파이프라인.</li>
</ul>
<hr>
<h3 id="34-graphql-federation">3.4 GraphQL Federation</h3>
<p><strong>특징</strong></p>
<ul>
<li>여러 서비스의 스키마를 게이트웨이에서 합쳐 단일 GraphQL 엔드포인트로 제공.</li>
<li>클라이언트가 필요한 필드만 선택하여 <strong>통합 조회</strong> 가능.</li>
</ul>
<p><strong>장점</strong></p>
<ul>
<li>Over/Underfetching 최소화, 스키마가 곧 문서.</li>
<li>다수 서비스의 데이터를 한 번의 질의로 조합.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>Federation 구성·관리가 복잡. N+1 쿼리·캐싱 전략 설계 필요.</li>
<li>쓰기(CUD)보다 <strong>조회(Read) 통합</strong>에 강점.</li>
</ul>
<p><strong>권장 사용처</strong></p>
<ul>
<li>복합 화면/집계 조회 레이어, 데이터 카탈로그형 API.</li>
</ul>
<p><strong>데이터 통합이란?</strong></p>
<ul>
<li>서로 다른 서비스가 소유한 데이터를 <strong>게이트웨이(또는 GraphQL Gateway)가 조합</strong>해 하나의 응답으로 제공하는 것.</li>
<li>예: <code>User</code>는 사용자 서비스, <code>Attendance</code>는 이벤트 서비스가 관리하지만, 클라이언트는 “사용자 이름 + 출석 현황”을 한 번에 요청.</li>
</ul>
<hr>
<h3 id="35-websocket--server-sent-eventssse">3.5 WebSocket / Server-Sent Events(SSE)</h3>
<p><strong>특징</strong></p>
<ul>
<li>실시간 스트림 전송(양방향 또는 서버→클라이언트 단방향).</li>
</ul>
<p><strong>장점</strong></p>
<ul>
<li>즉시성 요구(알림/상태 표시/대시보드)에 적합.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>연결 상태 관리, 수평 확장 시 세션 문제, 관측 난이도.</li>
</ul>
<p><strong>권장 사용처</strong></p>
<ul>
<li>“7일 연속 출석 달성” 같은 실시간 알림, 진행 현황 스트림.</li>
</ul>
<hr>
<h2 id="4-동기-vs-비동기와-crud-매핑">4. 동기 vs 비동기와 CRUD 매핑</h2>
<ul>
<li><strong>동기 통신(REST/gRPC) → Read 중심</strong><ul>
<li>사용자가 즉시 화면에 보여줄 데이터를 가져올 때 적합.</li>
<li>내부 고성능 조회는 gRPC, 외부·범용 조회는 REST.</li>
</ul>
</li>
<li><strong>비동기 통신(MQ) → CUD 중심</strong><ul>
<li>상태 변경을 이벤트로 발행하여 느슨한 결합과 재처리를 보장.</li>
<li>예: 출석 체크 성공 → “AttendanceRecorded” 이벤트 발행 → 포인트/쿠폰 서비스가 비동기 수신 후 반영.</li>
</ul>
</li>
</ul>
<table>
<thead>
<tr>
<th>분류</th>
<th>선호 통신</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>Read(조회)</td>
<td>gRPC(내부), REST(외부)</td>
<td>지연 최소화/범용 접근성</td>
</tr>
<tr>
<td>Create/Update/Delete</td>
<td>MQ</td>
<td>멱등/재시도/탄력성/확장성</td>
</tr>
<tr>
<td>복합 조회</td>
<td>GraphQL 또는 Gateway 집계</td>
<td>다수 서비스 데이터의 단일 응답</td>
</tr>
</tbody></table>
<hr>
<h2 id="5-통신-방식-비교-요약">5. 통신 방식 비교 요약</h2>
<table>
<thead>
<tr>
<th>방식</th>
<th>형태</th>
<th>속도</th>
<th>결합도</th>
<th>실시간성</th>
<th>CRUD 적합성</th>
<th>주요 용도</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP REST</td>
<td>동기</td>
<td>중간</td>
<td>중간~높음</td>
<td>낮음</td>
<td>Read(범용), 간단 CUD</td>
<td>외부/범용 API, 인증</td>
</tr>
<tr>
<td>gRPC</td>
<td>동기</td>
<td>빠름</td>
<td>중간</td>
<td>중간(스트리밍)</td>
<td>Read(내부 고성능)</td>
<td>내부 RPC 조회</td>
</tr>
<tr>
<td>MQ</td>
<td>비동기</td>
<td>빠름(버퍼링)</td>
<td>낮음</td>
<td>낮음</td>
<td>CUD(비동기 반영)</td>
<td>이벤트 드리븐 처리</td>
</tr>
<tr>
<td>GraphQL Federation</td>
<td>동기</td>
<td>중간</td>
<td>중간</td>
<td>낮음</td>
<td>Read(복합/집계)</td>
<td>통합 조회 레이어</td>
</tr>
<tr>
<td>WebSocket/SSE</td>
<td>스트림</td>
<td>빠름</td>
<td>중간</td>
<td>높음</td>
<td>실시간 피드백</td>
<td>알림/대시보드</td>
</tr>
</tbody></table>
<hr>
<h2 id="6-실무-구성-예시">6. 실무 구성 예시</h2>
<table>
<thead>
<tr>
<th>계층</th>
<th>통신 방식</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>Client ↔ Gateway</td>
<td>REST 또는 GraphQL</td>
<td>JWT 검증/속도 제한/로깅</td>
</tr>
<tr>
<td>Gateway ↔ Auth</td>
<td>REST 또는 gRPC</td>
<td>로그인/토큰 검증/유저 조회</td>
</tr>
<tr>
<td>Gateway ↔ Event</td>
<td>REST</td>
<td>출석/보상 API</td>
</tr>
<tr>
<td>Event ↔ Reward/Coupon</td>
<td>MQ(Kafka 등)</td>
<td>CUD 이벤트 비동기 반영</td>
</tr>
<tr>
<td>Gateway ↔ Client(실시간)</td>
<td>WebSocket/SSE</td>
<td>보상 달성 알림</td>
</tr>
</tbody></table>
<hr>
<h2 id="7-통신-구조-다이어그램">7. 통신 구조 다이어그램</h2>
<pre><code class="language-mermaid">graph TD
    Client --&gt;|HTTP/GraphQL| Gateway
    Gateway --&gt;|HTTP REST| AuthService
    Gateway --&gt;|HTTP REST| EventService
    EventService --&gt;|Kafka Topic (CUD)| RewardService
    Gateway --&gt;|WebSocket/SSE| Client</code></pre>
<hr>
<h2 id="8-결론">8. 결론</h2>
<p>1) 단일 해법은 없다. <strong>도메인 특성</strong>과 <strong>경험적 지표</strong>에 따라 혼합한다.<br>2) <strong>동기(Read) / 비동기(CUD)</strong> 분할을 기본 축으로 삼으면 설계가 단순해진다.<br>3) 복합 조회가 많다면 <strong>GraphQL 또는 Gateway 집계 레이어</strong>로 데이터 통합을 제공한다.<br>4) 실시간 피드백이 중요하면 WebSocket/SSE를 보조로 도입한다.<br>5) 관측성(Tracing/Logging/Metrics)과 멱등성/재처리 전략을 초기부터 포함해 운영 복잡도를 낮춘다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백트래킹(Backtracking) — 완전탐색을 효율적으로 만드는 방법]]></title>
            <link>https://velog.io/@sh__y_/backtracking</link>
            <guid>https://velog.io/@sh__y_/backtracking</guid>
            <pubDate>Wed, 08 Oct 2025 10:49:26 GMT</pubDate>
            <description><![CDATA[<h2 id="1-백트래킹의-개념">1. 백트래킹의 개념</h2>
<p>백트래킹(Backtracking)은 <strong>모든 가능한 경우의 수를 탐색하면서, 불필요한 경로를 조기에 차단하는 탐색 알고리즘</strong>이다.<br>쉽게 말해 <strong>“조건에 맞지 않으면 바로 돌아가는(Backtrack)” 방식의 완전탐색</strong>이다.</p>
<p>완전탐색(Brute-force)은 가능한 모든 경우를 시도하기 때문에, 경우의 수가 많을수록 계산량이 급격히 늘어난다.<br>하지만 백트래킹은 탐색 도중 “이 경로로 가도 정답이 될 수 없다”는 판단이 내려지면 그 즉시 탐색을 중단하고 이전 단계로 돌아간다.<br>이 과정을 반복하면서 <strong>필요한 경로만 탐색</strong>하게 되어 시간 복잡도를 크게 줄일 수 있다.</p>
<hr>
<h2 id="2-백트래킹의-동작-방식">2. 백트래킹의 동작 방식</h2>
<p>백트래킹은 기본적으로 <strong>DFS(깊이 우선 탐색)</strong> 형태로 동작한다.<br>탐색 도중 조건을 만족하지 않으면 즉시 되돌아가며, 이를 위해 다음과 같은 구조를 갖는다.</p>
<ol>
<li><p><strong>결정(Choose)</strong>  </p>
<ul>
<li>현재 단계에서 하나의 선택을 수행한다.  </li>
<li>예: 수 하나를 선택, 퀸을 배치, 연산자를 사용 등</li>
</ul>
</li>
<li><p><strong>탐색(Explore)</strong>  </p>
<ul>
<li>해당 선택을 기준으로 재귀적으로 다음 단계로 이동한다.</li>
</ul>
</li>
<li><p><strong>복원(Unchoose)</strong>  </p>
<ul>
<li>다음 단계로 진행 후 다시 돌아올 때, 이전 선택을 원래 상태로 되돌린다.  </li>
<li>이를 통해 다른 가능한 선택지를 탐색할 수 있게 된다.</li>
</ul>
</li>
</ol>
<p>이 세 단계가 재귀적으로 반복되며, 최종적으로 모든 가능한 조합이 만들어진다.<br>핵심은 “<strong>가능하지 않은 선택은 더 이상 진행하지 않는다</strong>”는 점이다.</p>
<hr>
<h2 id="3-백트래킹의-적용-방법">3. 백트래킹의 적용 방법</h2>
<p>백트래킹을 적용할 때는 다음 세 가지를 명확히 정의해야 한다.</p>
<ol>
<li><p><strong>탐색 대상 (state)</strong>  </p>
<ul>
<li>어떤 상태를 기준으로 탐색을 진행할지 정의  </li>
<li>예: 현재 위치, 선택된 수열, 배치된 퀸 개수 등</li>
</ul>
</li>
<li><p><strong>유효성 검사 (constraint check)</strong>  </p>
<ul>
<li>현재 상태가 조건을 만족하는지 판단  </li>
<li>조건을 만족하지 않으면 즉시 탐색 중단 (pruning)</li>
</ul>
</li>
<li><p><strong>종료 조건 (termination)</strong>  </p>
<ul>
<li>탐색이 끝나는 시점을 명확히 정의  </li>
<li>예: 조합의 길이가 6이 되었을 때, 모든 퀸이 배치되었을 때 등</li>
</ul>
</li>
</ol>
<p>이 세 가지가 정의되면 백트래킹 알고리즘의 기본 구조는 자연스럽게 만들어진다.</p>
<hr>
<h2 id="4-문제별-백트래킹-적용">4. 문제별 백트래킹 적용</h2>
<p>아래 세 문제를 통해 백트래킹의 실제 활용 방식을 정리했다.</p>
<hr>
<h3 id="1-로또--조합-생성">(1) 로또 — 조합 생성</h3>
<h4 id="문제-개요">문제 개요</h4>
<p>주어진 집합 S에서 6개의 수를 고르는 모든 조합을 출력하는 문제.</p>
<h4 id="백트래킹-적용-방식">백트래킹 적용 방식</h4>
<ul>
<li><strong>탐색 대상:</strong> 현재 선택된 숫자 조합  </li>
<li><strong>유효성 검사:</strong> 남은 숫자의 개수가 부족하면 탐색 중단  </li>
<li><strong>종료 조건:</strong> 6개 숫자를 모두 선택했을 때 출력  </li>
</ul>
<pre><code class="language-java">if (k - pos - 1 &lt; 6 - depth) return; // 남은 숫자가 부족하면 중단</code></pre>
<p>백트래킹의 핵심은 <strong>가능한 조합만 탐색</strong>하도록 가지치기를 수행한 것이다.<br>남은 원소가 부족하면 더 이상 조합이 완성될 수 없기 때문에, 그 시점에서 재귀 호출을 중단한다.</p>
<p>이를 통해 완전탐색이지만, <strong>불필요한 분기는 제거된 조합 탐색</strong>이 가능하다.</p>
<hr>
<h3 id="2-n-queen--상태-제약-기반-탐색">(2) N-Queen — 상태 제약 기반 탐색</h3>
<h4 id="문제-개요-1">문제 개요</h4>
<p>N×N 체스판 위에 N개의 퀸을 서로 공격하지 않게 배치하는 경우의 수를 구하는 문제.</p>
<h4 id="백트래킹-적용-방식-1">백트래킹 적용 방식</h4>
<ul>
<li><strong>탐색 대상:</strong> 현재 행(row)에 퀸을 배치할 열(col)  </li>
<li><strong>유효성 검사:</strong> 해당 위치가 공격 가능한 위치인지 확인  </li>
<li><strong>종료 조건:</strong> 모든 행에 퀸이 배치되었을 때 결과 증가  </li>
</ul>
<pre><code class="language-java">if (chess[row][col] == 0) {
    placeQueen(row, col);
    backtracking(row + 1);
    removeQueen(row, col);
}</code></pre>
<p>백트래킹의 핵심은 <strong>상태 관리</strong>이다.<br><code>chess</code> 배열을 이용해 공격 가능한 칸을 숫자로 표시하고, 퀸을 배치하거나 제거할 때 해당 영역을 동적으로 갱신한다.</p>
<p>이 과정을 통해 “공격 가능한 위치는 탐색하지 않는다”는 pruning이 자연스럽게 적용된다.<br>즉, <strong>탐색 가능한 영역을 실시간으로 줄여나가는 형태의 백트래킹</strong>이다.</p>
<hr>
<h3 id="3-연산자-끼워넣기--선택-순서-탐색">(3) 연산자 끼워넣기 — 선택 순서 탐색</h3>
<h4 id="문제-개요-2">문제 개요</h4>
<p>주어진 수열 사이에 연산자를 끼워 넣어 만들 수 있는 결과값의 최대값과 최소값을 구하는 문제.</p>
<h4 id="백트래킹-적용-방식-2">백트래킹 적용 방식</h4>
<ul>
<li><strong>탐색 대상:</strong> 현재까지 계산된 식의 값  </li>
<li><strong>유효성 검사:</strong> 사용 가능한 연산자의 개수가 남아 있는지 확인  </li>
<li><strong>종료 조건:</strong> 모든 수를 사용했을 때 결과 비교 및 갱신  </li>
</ul>
<pre><code class="language-java">operators[i]--;
backtracking(depth + 1, newValue);
operators[i]++;</code></pre>
<p>연산자를 선택하고, 재귀적으로 다음 연산을 수행한 뒤, 돌아올 때 다시 복원한다.<br>이 과정에서 <strong>상태 복원(Unchoose)</strong>이 핵심이며, 이를 통해 모든 가능한 연산자 조합을 완전탐색할 수 있다.</p>
<hr>
<h2 id="5-백트래킹의-핵심-정리">5. 백트래킹의 핵심 정리</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>가능한 선택지를 시도</td>
</tr>
<tr>
<td>2</td>
<td>조건을 만족하지 않으면 되돌아감 (Pruning)</td>
</tr>
<tr>
<td>3</td>
<td>조건을 만족하면 재귀적으로 다음 단계 탐색</td>
</tr>
<tr>
<td>4</td>
<td>종료 조건을 만나면 결과 저장</td>
</tr>
<tr>
<td>5</td>
<td>돌아올 때 상태를 복원하여 다른 경우 탐색</td>
</tr>
</tbody></table>
<p>세 문제 모두 이 패턴을 그대로 따른다.<br>로또는 “조합의 깊이”, N-Queen은 “위치의 유효성”, 연산자 끼워넣기는 “상태 복원”을 중심으로 백트래킹을 구성했다.</p>
<hr>
<h2 id="6-결론">6. 결론</h2>
<p>백트래킹은 완전탐색과 유사하지만, <strong>조건 기반 가지치기를 통해 효율을 확보하는 탐색 알고리즘</strong>이다.<br>즉, 가능한 모든 해를 시도하면서도 불필요한 계산을 피한다는 점에서 차별화된다.</p>
<p>문제를 풀 때는 다음 질문을 기준으로 접근하면 된다.</p>
<ol>
<li>어떤 상태를 기준으로 탐색을 진행할 수 있는가?  </li>
<li>어떤 조건에서 탐색을 중단할 수 있는가?  </li>
<li>탐색이 끝났다는 것은 어떤 상태를 의미하는가?  </li>
</ol>
<p>이 세 가지를 명확히 정의할 수 있다면, 어떤 문제든 백트래킹으로 접근할 수 있다.<br>로또, N-Queen, 연산자 끼워넣기 세 문제는 이러한 사고 과정을 훈련하기에 적합한 대표적인 예시다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[동적 계획법 (Dynamic Programming, DP)]]></title>
            <link>https://velog.io/@sh__y_/dynamic-programming</link>
            <guid>https://velog.io/@sh__y_/dynamic-programming</guid>
            <pubDate>Mon, 06 Oct 2025 12:47:15 GMT</pubDate>
            <description><![CDATA[<h2 id="개념">개념</h2>
<p>DP는 <strong>큰 문제를 작은 문제로 나누고</strong>, 그 <strong>결과를 저장하여 재활용하는 방식</strong>으로 문제를 해결하는 기법이다.  </p>
<p>핵심 요약</p>
<ul>
<li>같은 계산을 반복하지 않음</li>
<li>이전 단계의 최적 결과를 이용</li>
<li>중복 계산을 줄여 효율적으로 계산</li>
</ul>
<hr>
<h2 id="dp-사고-과정">DP 사고 과정</h2>
<ol>
<li><p><strong>문제를 쪼갤 수 있는지 확인</strong></p>
<ul>
<li>큰 문제를 작은 하위 문제로 분리 가능해야 함</li>
<li>예: “N일 때”를 “N-1일 때”로 표현 가능해야 함</li>
</ul>
</li>
<li><p><strong>점화식 도출</strong></p>
<ul>
<li>현재 상태를 이전 상태로 표현하는 식을 찾는 단계</li>
</ul>
</li>
<li><p><strong>중복 계산 여부 확인</strong></p>
<ul>
<li>같은 계산이 반복된다면 DP 적용 가능</li>
</ul>
</li>
<li><p><strong>저장할 값 정의</strong></p>
<ul>
<li>dp 배열의 각 인덱스가 의미하는 값을 명확히 정의</li>
<li>예: dp[i] = i번째 원소를 끝으로 하는 최대합 등</li>
</ul>
</li>
<li><p><strong>기본값 설정</strong></p>
<ul>
<li>dp[0], dp[1] 등 최소 단위의 초기값 설정</li>
</ul>
</li>
<li><p><strong>순차적 계산</strong></p>
<ul>
<li>작은 인덱스부터 점화식에 따라 순서대로 계산</li>
</ul>
</li>
</ol>
<hr>
<h2 id="예시별-정리">예시별 정리</h2>
<h3 id="1-백준-11055--가장-큰-증가-부분-수열">1. 백준 11055 — 가장 큰 증가 부분 수열</h3>
<h4 id="문제-구조">문제 구조</h4>
<ul>
<li>입력: 수열</li>
<li>목표: 증가하는 부분 수열 중 합의 최대값</li>
</ul>
<h4 id="dp-정의">dp 정의</h4>
<p><code>dp[i]</code> = i번째 원소를 <strong>끝으로 하는 증가 부분 수열의 최대합</strong></p>
<h4 id="점화식">점화식</h4>
<pre><code>dp[i] = max(dp[i], dp[j] + arr[i])   (단, arr[j] &lt; arr[i])</code></pre><h4 id="사고-과정">사고 과정</h4>
<ol>
<li>현재 원소 <code>arr[i]</code>보다 작은 이전 원소 <code>arr[j]</code>를 탐색</li>
<li>가능한 경우의 합 중 최댓값을 선택</li>
<li>dp 배열 중 최댓값이 전체 결과</li>
</ol>
<h4 id="핵심-요약">핵심 요약</h4>
<p>이전 원소 중 자신보다 작은 값에 이어붙인 최대합을 선택하는 구조</p>
<hr>
<h3 id="2-백준-11052--카드-구매하기">2. 백준 11052 — 카드 구매하기</h3>
<h4 id="문제-구조-1">문제 구조</h4>
<ul>
<li>입력: 카드팩 가격 배열</li>
<li>목표: 카드 N개를 구매할 때의 최대 금액</li>
</ul>
<h4 id="dp-정의-1">dp 정의</h4>
<p><code>dp[i]</code> = 카드 i개를 구매할 때의 최대 금액</p>
<h4 id="점화식-1">점화식</h4>
<pre><code>dp[i] = max(dp[i - j] + arr[j - 1])   (1 ≤ j ≤ i)</code></pre><h4 id="사고-과정-1">사고 과정</h4>
<ol>
<li>i개를 만들기 위해 j개 + (i-j)개 조합을 고려</li>
<li>모든 조합 중 최댓값 선택</li>
<li>dp[i]에 저장</li>
</ol>
<h4 id="핵심-요약-1">핵심 요약</h4>
<p>모든 가능한 조합을 비교하고, 최대값을 선택하는 반복 구조</p>
<hr>
<h3 id="3-백준-10422--괄호">3. 백준 10422 — 괄호</h3>
<h4 id="문제-구조-2">문제 구조</h4>
<ul>
<li>입력: 괄호 문자열의 길이 L</li>
<li>목표: 올바른 괄호 문자열의 개수</li>
</ul>
<h4 id="dp-정의-2">dp 정의</h4>
<p><code>dp[n]</code> = 길이 2n인 올바른 괄호 문자열의 개수</p>
<h4 id="점화식-2">점화식</h4>
<pre><code>dp[n] = dp[0]*dp[n-1] + dp[1]*dp[n-2] + ... + dp[n-1]*dp[0]</code></pre><h4 id="사고-과정-2">사고 과정</h4>
<ol>
<li>전체 괄호 문자열을 (A)B 형태로 분리</li>
<li>A, B 모두 각각 올바른 괄호 구조를 가져야 함</li>
<li>가능한 모든 분할의 조합을 더함</li>
</ol>
<h4 id="핵심-요약-2">핵심 요약</h4>
<p>전체 구조를 좌우 두 부분으로 나누고, 각 조합의 곱을 누적하는 구조</p>
<hr>
<h2 id="세-문제를-통한-dp의-본질">세 문제를 통한 DP의 본질</h2>
<table>
<thead>
<tr>
<th>문제</th>
<th>핵심 사고</th>
<th>저장 기준</th>
<th>점화식 형태</th>
</tr>
</thead>
<tbody><tr>
<td>11055</td>
<td>이전 원소 합의 최댓값</td>
<td>마지막 원소 기준</td>
<td>누적 최대값</td>
</tr>
<tr>
<td>11052</td>
<td>가능한 구매 조합</td>
<td>카드 개수 기준</td>
<td>조합 최댓값</td>
</tr>
<tr>
<td>10422</td>
<td>괄호 구조 분할</td>
<td>괄호 쌍 개수 기준</td>
<td>곱의 누적</td>
</tr>
</tbody></table>
<hr>
<h2 id="결론">결론</h2>
<p>DP는 단순한 배열 채우기가 아니라,<br><strong>작은 단위 문제의 결과를 이용해 전체 문제를 해결하는 사고 과정</strong>이다.  </p>
<p>핵심 포인트</p>
<ol>
<li>dp[i]의 의미를 명확히 정의  </li>
<li>이전 결과로 현재를 유도하는 점화식 구성  </li>
<li>중복 계산 제거  </li>
<li>누적 계산을 통해 전체 문제 해결  </li>
</ol>
<p>세 문제 모두 공통적으로 “변하지 않는 하위 문제의 패턴”을 기반으로 동작하며,<br>이 패턴을 찾는 것이 DP 문제 해결의 핵심이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redisson 분산 락 해제 방식: forceUnlock() vs unlock(threadId)]]></title>
            <link>https://velog.io/@sh__y_/Redisson-release-forceUnlock-vs-unlockthreadId</link>
            <guid>https://velog.io/@sh__y_/Redisson-release-forceUnlock-vs-unlockthreadId</guid>
            <pubDate>Sun, 05 Oct 2025 12:36:30 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>Spring WebFlux 환경에서 Redisson을 이용해 분산 락을 구현할 때,<br>락을 해제하는 방법으로 보통 아래 두 가지가 있습니다</p>
<ol>
<li><code>forceUnlock()</code></li>
<li><code>unlock(threadId)</code></li>
</ol>
<p>두 메서드는 겉보기엔 비슷하지만, 내부적으로 동작 방식과 안전성 측면에서 꽤 큰 차이가 있습니다.<br>이 차이를 정확히 이해하지 않으면, <strong>의도치 않은 락 해제</strong>나 <strong>데이터 경합(race condition)</strong> 같은 문제가 발생할 수 있습니다.</p>
<hr>
<h2 id="unlockthreadid--스레드-단위-안전한-해제"><code>unlock(threadId)</code> — 스레드 단위 안전한 해제</h2>
<h3 id="동작-방식">동작 방식</h3>
<p>Redisson의 락(<code>RLock</code>)은 내부적으로 <strong>스레드 ID 기반 소유권(ownership)</strong> 을 관리합니다.<br>특정 스레드가 락을 획득하면 Redis 내부 키에 해당 스레드의 ID 정보가 기록됩니다.</p>
<p><code>unlock(threadId)</code>는 이 정보를 비교해서,  </p>
<blockquote>
<p>“이 락을 실제로 잡은 스레드인지”<br>를 검증한 뒤에만 해제합니다.</p>
</blockquote>
<pre><code class="language-kotlin">lock.unlock(threadId)</code></pre>
<h3 id="장점">장점</h3>
<ul>
<li><strong>가장 안전한 해제 방식</strong></li>
<li>자신이 잡은 락만 해제 가능 (다른 스레드가 잘못 해제 불가)</li>
<li>분산 환경에서도 락 소유권 일관성 유지</li>
</ul>
<h3 id="단점">단점</h3>
<ul>
<li><code>Redisson 3.15.x</code> 이하 버전에서는 <strong>threadId 기반 unlock이 제공되지 않음</strong></li>
<li>WebFlux / Coroutine 환경에서는 실제 실행 스레드가 <strong>락 획득 시점과 해제 시점이 다를 수 있음</strong>
→ 같은 코루틴이더라도 다른 스레드 풀에서 동작할 수 있기 때문</li>
</ul>
<blockquote>
<p>WebFlux + 코루틴 환경에서는 “스레드 기반 검증” 자체가 의미가 약해질 수 있습니다.</p>
</blockquote>
<hr>
<h2 id="forceunlock--소유권-검증-없이-강제-해제"><code>forceUnlock()</code> — 소유권 검증 없이 강제 해제</h2>
<h3 id="동작-방식-1">동작 방식</h3>
<p><code>forceUnlock()</code>은 이름 그대로 <strong>락 소유자 검증 없이 Redis에서 해당 락 키를 삭제</strong>합니다.</p>
<pre><code class="language-kotlin">lock.forceUnlock()</code></pre>
<p>→ 락의 owner threadId가 누구든 상관없이 무조건 해제합니다.</p>
<h3 id="장점-1">장점</h3>
<ul>
<li>해제가 <strong>비동기 환경에서도 확실하게 동작</strong>
(실행 스레드가 다르더라도 락을 풀 수 있음)</li>
<li>Deadlock 방지나 긴급 상황에서 유용함</li>
</ul>
<h3 id="단점-1">단점</h3>
<ul>
<li><strong>다른 스레드의 락을 풀어버릴 위험 존재</strong></li>
<li>타 서비스나 동일 인스턴스 내 다른 비즈니스 로직이 잠깐 잡은 락도 풀릴 수 있음</li>
<li>일시적인 <strong>데이터 정합성 문제</strong> 발생 가능  </li>
</ul>
<hr>
<h2 id="실제-사용-시-권장-패턴">실제 사용 시 권장 패턴</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>권장 방식</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>동기 환경 (Spring MVC)</td>
<td><code>unlock(threadId)</code></td>
<td>동일 스레드에서 락 획득/해제 일관성 보장</td>
</tr>
<tr>
<td>비동기 환경 (WebFlux, Coroutine)</td>
<td><code>forceUnlock()</code></td>
<td>실행 스레드가 달라져도 해제 보장</td>
</tr>
<tr>
<td>강제 정리(에러 복구, Deadlock 회피 등)</td>
<td><code>forceUnlock()</code></td>
<td>임시 조치로 사용 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="webflux-환경에서의-해제-전략">WebFlux 환경에서의 해제 전략</h2>
<p>WebFlux에서는 Reactor가 스레드를 자유롭게 스위칭합니다.<br>즉, 락을 잡을 때와 해제할 때의 <strong>Thread ID가 다를 수 있습니다.</strong></p>
<p>예를 들어:</p>
<pre><code>Lock acquired by Thread-A
Transaction committed on Thread-B</code></pre><p>이런 경우 <code>unlock(threadId)</code>는 <strong>&quot;다른 스레드에서 해제를 시도&quot;</strong> 하므로 실패하거나 예외를 던질 수 있습니다.<br>그래서 WebFlux 기반 시스템에서는 <strong><code>forceUnlock()</code></strong> 으로 일관되게 해제하는 게 현실적인 선택입니다.</p>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th><code>unlock(threadId)</code></th>
<th><code>forceUnlock()</code></th>
</tr>
</thead>
<tbody><tr>
<td>소유자 검증</td>
<td>있음</td>
<td>없음</td>
</tr>
<tr>
<td>안전성</td>
<td>높음</td>
<td>낮음</td>
</tr>
<tr>
<td>비동기 환경 호환성</td>
<td>낮음</td>
<td>높음</td>
</tr>
<tr>
<td>Deadlock 회피용</td>
<td>비권장</td>
<td>가능</td>
</tr>
<tr>
<td>WebFlux 환경 추천</td>
<td>사용불가</td>
<td>현실적방안</td>
</tr>
</tbody></table>
<hr>
<h2 id="결론">결론</h2>
<ul>
<li><code>unlock(threadId)</code>는 “<strong>안전하지만 스레드 일관성</strong>”이 필요한 방식  </li>
<li><code>forceUnlock()</code>은 “<strong>비동기 환경에서의 현실적 선택</strong>”  </li>
<li>WebFlux + Redisson 조합에서는 <code>forceUnlock()</code>으로 <strong>락 해제 시점을 명시적으로 관리</strong>하는 것이 안정적  </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring WebFlux + Redisson: 트랜잭션과 분산 락 해제 타이밍 문제 해결하기]]></title>
            <link>https://velog.io/@sh__y_/Spring-WebFlux-Redisson-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-%EB%B6%84%EC%82%B0-%EB%9D%BD-%ED%95%B4%EC%A0%9C-%ED%83%80%EC%9D%B4%EB%B0%8D-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sh__y_/Spring-WebFlux-Redisson-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-%EB%B6%84%EC%82%B0-%EB%9D%BD-%ED%95%B4%EC%A0%9C-%ED%83%80%EC%9D%B4%EB%B0%8D-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 27 Sep 2025 10:28:00 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-배경">문제 배경</h2>
<p>Spring MVC 같은 <strong>동기 트랜잭션 환경</strong>에서는 <code>@Transactional</code> 바깥에서 분산 락을 잡고, finally 블록에서 분산 락을 해제하면 큰 문제가 없습니다.<br>하지만 <strong>WebFlux 환경</strong>에서는 이야기가 달라집니다.</p>
<ul>
<li>요청은 <strong>논블로킹(Non-blocking)</strong> 으로 처리됩니다.  </li>
<li>DB 트랜잭션 커밋 시점은 Reactor 체인 안에서 <strong>비동기적으로</strong> 완료됩니다.  </li>
<li>단순히 <code>try-finally</code> 에서 락을 해제하면, 분산 락 획득과 트랜잭션 실행이 서로 다른 컨텍스트에서 실행되기에 <strong>트랜잭션 커밋 전에 락이 풀려버릴 위험</strong>이 있습니다.  </li>
</ul>
<p>위와 같은 이유로 다른 요청이 같은 자원에 접근해서 <strong>데이터 정합성이 깨질 수 있는 상황</strong>이 발생합니다.</p>
<hr>
<h2 id="핵심-아이디어">핵심 아이디어</h2>
<p>WebFlux 환경에서는 <strong>Reactive Streams 라이프사이클</strong>에 맞춰 락을 관리해야 합니다.  </p>
<p>그래서 <code>Mono.usingWhen</code> / <code>Flux.usingWhen</code> 패턴을 적용했습니다.</p>
<ol>
<li><strong>리소스 획득</strong>: Reactive 체인이 시작될 때 Redisson 분산 락 획득  </li>
<li><strong>비즈니스 로직 실행</strong>: DB 트랜잭션 포함 비즈니스 로직 진행  </li>
<li><strong>정상 종료 / 에러 / 취소</strong>: 체인 종료 시점에 맞춰 락 해제  </li>
</ol>
<p>이렇게 하면, 락 해제가 항상 <strong>트랜잭션 커밋 이후</strong>에 일어나도록 보장할 수 있습니다.</p>
<hr>
<h2 id="mono-와-flux-차이"><code>Mono</code> 와 <code>Flux</code> 차이</h2>
<ul>
<li><code>Mono&lt;T&gt;</code> -&gt; <strong>0 또는 1개</strong>의 결과를 발행하는 Publisher</li>
<li><code>Flux&lt;T&gt;</code> -&gt; <strong>0개 이상 N개</strong>의 결과를 발행하는 Publisher</li>
</ul>
<p><code>usingWhen</code>은 두 타입에 대해 각각 <strong>오버로드(overload)</strong> 되어 있습니다.</p>
<ul>
<li><code>Mono.usingWhen(resource, use, release)</code> -&gt; <code>Mono&lt;R&gt;</code></li>
<li><code>Flux.usingWhen(resource, use, release)</code> -&gt; <code>Flux&lt;R&gt;</code></li>
</ul>
<p>위를 통해, <code>joinPoint.proceed()</code> 결과가 <code>Mono</code> 인지 <code>Flux</code> 인지에 따라 맞는 오버로드를 써야 컴파일러가 타입을 인식할 수 있습니다.</p>
<h3 id="공통-처리가-불가능한-이유">공통 처리가 불가능한 이유</h3>
<p>Kotlin/Java 에서 <code>Publisher&lt;T&gt;</code> 라는 공통 부모 타입은 있지만, <code>Publisher.usingWhen(...)</code> 같은 추상화된 API는 존재하지 않습니다.</p>
<p>따라서 <code>Mono</code>/<code>Flux</code>를 한 번에 처리하려면</p>
<ul>
<li><strong>런타임 캐스팅</strong>을 통해 when 분기 처리 (<code>is Mono&lt;*&gt;</code> / <code>is Flux&lt;*&gt;</code>)</li>
<li>아니면 <strong>공통 래퍼 메서드</strong>를 따로 작성 (<code>fun &lt;T&gt; usingWhenPublisher(...)</code>)</li>
</ul>
<p>이렇게 해야 합니다. 아래 코드에서 when 으로 분기한 이유는 첫 번째 방법을 택한 것입니다.</p>
<hr>
<h2 id="reactor-체인의-실행-흐름">Reactor 체인의 실행 흐름</h2>
<p>WebFlux에서 <code>Mono</code>나 <code>Flux</code>는 <strong>Publisher</strong>입니다.
코드에 <code>Mono.just(...)</code>를 쓴다고 해서 곧바로 실행되는 게 아니라, 구독(subscribe)이 발생해야 체인이 동작합니다.</p>
<p>실제 실행은 이 순서로 진행됩니다.</p>
<ol>
<li><code>subscribe()</code> 호출 -&gt; 체인 실행 시작</li>
<li><code>onNext()</code> -&gt; 데이터 발행 (중간 오퍼레이터를 실행)</li>
<li><code>onComplete</code> / <code>onError</code> / <code>onCancel</code> -&gt; 체인 종료</li>
</ol>
<hr>
<h2 id="usingwhen-패턴의-동작">usingWhen 패턴의 동작</h2>
<p><code>Mono.usingWhen(resource, use, release)</code> 는 리소스를 안전하게 쓰기 위한 Reactor 도구입니다.</p>
<ul>
<li><strong>resource</strong>: 체인 시작 시 실행 (예: <code>acquireLock</code>)</li>
<li><strong>use</strong>: 비즈니스 로직 실행 (<code>joinPoint.proceed()</code>)</li>
<li><strong>release</strong>: 체인 종료 시점에 실행 (정상 완료, 에러, 취소 모두 처리됨)</li>
</ul>
<p>위를 이용하여서</p>
<ul>
<li><code>acquireLock()</code> 은 Reactor 체인이 <strong>시작될 때</strong> 실행됩니다. (= 락 획득 시점)</li>
<li><code>releaseLock()</code> 은 Reactor 체인이 <strong>끝날 때</strong> 실행됩니다. (= 락 해제 시점)</li>
</ul>
<hr>
<h2 id="핵심-코드-단순화">핵심 코드 (단순화)</h2>
<pre><code class="language-kotlin">@Around(&quot;@annotation(redisDistributedLock)&quot;)
fun around(joinPoint: ProceedingJoinPoint, redisDistributedLock: DistributedLock): Any? {
    val result = joinPoint.proceed()
    return when (result) {
        is Mono&lt;*&gt; -&gt; {
            Mono.usingWhen(
                acquireLock(redisDistributedLock, joinPoint),   // 락 획득
                { result as Mono&lt;Any&gt; },                        // 비즈니스 로직 실행
                { res -&gt; releaseLock(res) },                    // 정상 종료 시 해제
                { res, _ -&gt; releaseLock(res) },                 // 에러 발생 시 해제
                { res -&gt; releaseLock(res) }                     // 취소 시 해제
            )
        }
        is Flux&lt;*&gt; -&gt; {
            Flux.usingWhen(
                acquireLock(redisDistributedLock, joinPoint),
                { result as Flux&lt;Any&gt; },
                { res -&gt; releaseLock(res) },
                { res, _ -&gt; releaseLock(res) },
                { res -&gt; releaseLock(res) }
            )
        }
        else -&gt; result
    }
}</code></pre>
<p>여기서 중요한 건 <strong>분산 락 획득(acquireLock)</strong> 과 <strong>해제(releaseLock)</strong> 시점이 Reactor 체인의 시작과 종료에 정확히 맞물려 있다는 점입니다.</p>
<hr>
<h2 id="정확히-맞물려-있다의-의미">&quot;정확히 맞물려 있다&quot;의 의미</h2>
<ul>
<li><p><strong>락 획득 시점은 체인 시작 시점과 동기화됨</strong></p>
</li>
<li><blockquote>
<p>누군가 <code>subscribe()</code> 해서 체인이 실행되기 전까지는 락을 안 잡음</p>
</blockquote>
</li>
<li><p><strong>락 해제 시점은 체인 종료 시점과 동기화됨</strong></p>
</li>
<li><blockquote>
<p>체인이 <code>onComplete</code>, <code>onError</code>, <code>onCancel</code> 로 끝날 때 반드시 해제됨</p>
</blockquote>
</li>
</ul>
<p>핵심은 락이 Reactor 체인의 <strong>라이프사이클과 1:1로 연결</strong>된다는 것입니다.
그래서 <strong>트랜잭션 커밋이 끝나고 체인이 종료될 때만 락이 풀림</strong>을 보장을 할 수 있습니다.</p>
<p>한 줄 정리: <strong>&quot;락은 체인이 시작될 때 획득되고, 체인이 끝날 때 해제된다&quot;</strong></p>
<hr>
<h2 id="효과">효과</h2>
<ul>
<li><strong>트랜잭션 커밋 이후에만 락 해제</strong>가 보장됨  </li>
<li>WebFlux 비동기 흐름과 자연스럽게 통합  </li>
<li>에러 / 취소 상황에서도 누락 없이 안전하게 해제  </li>
</ul>
<hr>
<h2 id="결론">결론</h2>
<p>WebFlux 환경에서 분산 락을 안전하게 다루려면, 단순히 <code>try-finally</code> 로는 부족합니다.<br><strong>Reactive 체인의 생명주기에 맞춰 자원을 관리</strong>해야 하며, <code>usingWhen</code> 패턴이 이를 깔끔하게 해결해 줍니다.</p>
<hr>
<p>다음 글에서는 <strong>Redisson 락 해제 방식(forceUnlock vs threadId 기반 unlock)</strong> 의 차이점을 다루겠습니다.</p>
]]></description>
        </item>
    </channel>
</rss>