<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>nova-kim.log</title>
        <link>https://velog.io/</link>
        <description>SoftwareEngineer</description>
        <lastBuildDate>Fri, 13 Feb 2026 06:42:15 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>nova-kim.log</title>
            <url>https://velog.velcdn.com/images/nova-kim/profile/0601c7dd-1639-431a-b946-741d5debbe9a/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. nova-kim.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/nova-kim" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Git Worktree로 AI 워크플로 관리하기: 협업과 컨텍스트 분리 전략]]></title>
            <link>https://velog.io/@nova-kim/Git-Worktree%EB%A1%9C-AI-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0-%ED%98%91%EC%97%85%EA%B3%BC-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@nova-kim/Git-Worktree%EB%A1%9C-AI-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0-%ED%98%91%EC%97%85%EA%B3%BC-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Fri, 13 Feb 2026 06:42:15 GMT</pubDate>
            <description><![CDATA[<p>AI 기반 개발 흐름이 빠르게 자리 잡으면서,<br><strong>Git의 Worktree 기능</strong>은 협업과 자동화 워크플로를 구성할 때 유용한 무기가 되고 있습니다.</p>
<p>특히 Claude, Gemini, Codex 등 AI 도구들을 연계해 작업을 구성하다 보면<br><strong>여러 브랜치를 병렬로 다루고 싶거나</strong>,  
<strong>모듈별로 컨텍스트를 나눠 관리하고 싶은 상황</strong>이 많아지죠.</p>
<p>이 글에서는 <code>Git Worktree</code> 개념을 정리하고,<br><strong>AI 기반 워크플로에서의 활용법과 실무 전략</strong>을 함께 살펴봅니다.</p>
<hr>
<h2 id="1-git-worktree란">1. Git Worktree란?</h2>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/1bea0b1f-0150-4b7f-83c4-644b5ac673e1/image.png" alt=""></p>
<p><sub>출처: <a href="https://blog.invidelabs.com/git-worktree-to-make-daily-git-workflow-better/">blog.invidelabs.com</a></sub></p>
<p>Git Worktree는 하나의 Git 저장소에서 <strong>여러 작업 디렉토리(branch)를 동시에 체크아웃</strong>할 수 있게 해주는 기능입니다.</p>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>기존 Git</td>
<td>하나의 브랜치만 checkout 가능</td>
</tr>
<tr>
<td>Worktree</td>
<td>여러 브랜치를 동시에 작업할 수 있는 별도 디렉토리 생성</td>
</tr>
<tr>
<td>주요 사용처</td>
<td>기능별 분리 개발, 문서/코드 병렬 작성, 충돌 없는 병행 작업 등</td>
</tr>
</tbody></table>
<pre><code class="language-bash"># 새 워크트리 생성 예시
git worktree add ../exp-agent feature/agent-flow</code></pre>
<hr>
<h2 id="2-ai-워크플로와-git-worktree-왜-잘-맞을까">2. AI 워크플로와 Git Worktree, 왜 잘 맞을까?</h2>
<p>AI를 활용한 개발 환경에선 종종 이런 고민이 생깁니다:</p>
<ul>
<li>한쪽에서는 <strong>프롬프트 체인/모델 설정 작업</strong>,  </li>
<li>다른 한쪽에서는 <strong>도메인별 테스트나 실험 브랜치 작업</strong><br>→ <strong>작업 흐름은 연결되지만, 파일과 컨텍스트는 분리</strong>하고 싶음</li>
</ul>
<h3 id="이럴-때-worktree가-쓸만하죠-">이럴 때 Worktree가 쓸만하죠 !</h3>
<table>
<thead>
<tr>
<th>문제 상황</th>
<th>Worktree 활용</th>
</tr>
</thead>
<tbody><tr>
<td>여러 실험적 프롬프트 흐름을 병렬로 개발</td>
<td>브랜치마다 별도 디렉토리로 작업</td>
</tr>
<tr>
<td>하나의 메인 에이전트를 기준으로 다양한 사용 시나리오 설계</td>
<td>각 시나리오를 독립적인 worktree로 나눠 테스트</td>
</tr>
<tr>
<td>같은 코드베이스에서 다른 AI 모델 조합 실험</td>
<td>모델/설정 조합별로 브랜치 + 디렉토리 분리</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-실전-worktree-적용-예시">3. 실전 Worktree 적용 예시</h2>
<p>예를 들어, 메인 에이전트가 있고, 거기서 다양한 워크플로(모델/프롬프트 흐름)를 실험하는 경우:</p>
<pre><code class="language-bash">/my-agent-repo
├── main/               ← 기본 브랜치
├── .git/
├── worktrees/
│   ├── agent-v1-test/   ← Claude 기반 흐름 실험
│   ├── agent-v2-openai/ ← GPT 기반 흐름 분기
│   └── prompt-playground/</code></pre>
<p>각 worktree 디렉토리는 Git 브랜치와 연결되며,<br>충돌 없이 동시에 여러 흐름을 설계하고 테스트할 수 있게 해줍니다.</p>
<hr>
<h2 id="4-운영-전략-팁">4. 운영 전략 팁</h2>
<ul>
<li><code>main</code> (혹은 <code>develop</code>)브랜치는 공유 기준점으로 두고,  </li>
<li><strong>워크트리는 실험, 시나리오 분리, 프롬프트 구조 테스트 등</strong>에 적극 활용</li>
<li>GitHub Actions나 CI 설정도 <strong>워크트리마다 조건 분기</strong>로 관리 가능</li>
</ul>
<hr>
<h2 id="🔍-worktree가-잘-맞는-경우">🔍 Worktree가 잘 맞는 경우</h2>
<ul>
<li>AI Agent를 다양한 프롬프트 설계 구조로 실험 중일 때</li>
<li>“Prompt A vs B” A/B 테스트처럼 모델 설정을 비교하고 싶은 경우</li>
<li>Git 충돌 없이 <strong>여러 버전의 워크플로를 병렬 관리</strong>하고 싶은 경우</li>
<li>메인 브랜치 변경 없이 <strong>테스트 기반 브랜치만 따로 진행</strong>하고 싶은 경우</li>
</ul>
<hr>
<h2 id="마무리-정리">마무리 정리</h2>
<blockquote>
<p>AI 개발에서도 여전히 협업과 유지보수는 중요합니다.<br>Git Worktree는 <strong>프롬프트 설계, 워크플로 테스트, 모델 흐름 실험</strong>을  
<strong>브랜치 단위가 아닌 디렉토리 단위로 분리하고 동시 작업</strong>할 수 있게 해줍니다.</p>
</blockquote>
<p>따라서?!
<strong>AI + Git 워크플로</strong>를 더 체계적으로 다루고 싶다면,<br>Worktree는 꼭 알아두면 좋은 Git 기능입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스낵깃 🍪 : Fork → PR → Merge Conflict 해결법(협업 중 흔한 Git 충돌 정복하기)]]></title>
            <link>https://velog.io/@nova-kim/%EC%8A%A4%EB%82%B5%EA%B9%83-Fork-PR-Merge-Conflict-%ED%95%B4%EA%B2%B0%EB%B2%95%ED%98%91%EC%97%85-%EC%A4%91-%ED%9D%94%ED%95%9C-Git-%EC%B6%A9%EB%8F%8C-%EC%A0%95%EB%B3%B5%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@nova-kim/%EC%8A%A4%EB%82%B5%EA%B9%83-Fork-PR-Merge-Conflict-%ED%95%B4%EA%B2%B0%EB%B2%95%ED%98%91%EC%97%85-%EC%A4%91-%ED%9D%94%ED%95%9C-Git-%EC%B6%A9%EB%8F%8C-%EC%A0%95%EB%B3%B5%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 11 Feb 2026 13:14:22 GMT</pubDate>
            <description><![CDATA[<p>팀 프로젝트나 오픈소스 기여를 하다 보면 누구나 한 번쯤 겪는 상황이 있습니다.</p>
<blockquote>
<p>&quot;PR 보냈는데 <code>This branch has conflicts</code> 메시지가 떠요...&quot;<br>&quot;merge 하다가 <code>CONFLICT</code> 터졌는데 어떻게 해야 하죠?&quot;</p>
</blockquote>
<p>이건 실력 부족이 아니라, <strong>협업이 잘 이루어지고 있다는 신호</strong>입니다.<br>이번 글에서는 <code>Fork → PR → 충돌</code> 구간에서 Git Merge Conflict를 해결하는 전 과정을 정리합니다.</p>
<hr>
<h2 id="1-merge-conflict-상황-먼저-이해하기">1. Merge Conflict 상황 먼저 이해하기</h2>
<h3 id="협업-흐름-시각-설명">협업 흐름 시각 설명</h3>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/b4fea1aa-f525-4b28-9bfb-63d76ae8337e/image.png" alt=""></p>
<p><sub>출처: <a href="https://medium.com/@bhavyarawal/what-are-forks-pull-requests-and-upstreams-in-git-and-how-are-they-used-92f1fba5cbaa">Medium / Bhavya Rawal</a></sub></p>
<pre><code>원본 레포 (Main Repo)
        ↑
        | PR
        |
내 포크 레포 (Fork)</code></pre><ul>
<li>내가 fork한 레포에서 작업하는 동안  </li>
<li>다른 팀원이 <code>main</code> 브랜치에 코드를 먼저 머지함  </li>
<li>나의 브랜치와 같은 파일의 다른 부분이 수정됨  </li>
<li>Git: &quot;둘 중 어떤 코드가 맞는지 결정해줘&quot; → <strong>Merge Conflict 발생</strong></li>
</ul>
<hr>
<h2 id="2-merge-conflict란">2. Merge Conflict란?</h2>
<h3 id="merge-conflict란">Merge Conflict란?</h3>
<h3 id="충돌-예시-구조">충돌 예시 구조</h3>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/e00657be-c76c-4451-8679-d9a0a453dc18/image.png" alt="">
<sub>출처:Nova Kim</sub></p>
<p>내가 포크한 레포에서 작업을 하고 있는 사이,<br><strong>원본 레포(main)</strong>에 다른 팀원이 코드를 먼저 반영한 경우가 많아요.</p>
<p>그 사이에 같은 파일의 같은 부분이 서로 다르게 수정되었다면,<br>Git은 &quot;어떤 코드를 기준으로 병합해야 할지 모르겠는데;&quot; 하며<br><strong>Merge Conflict(충돌)</strong> 상태가 됩니다.</p>
<p>즉, 충돌은 <strong>Fork 기반 협업에서 자연스럽게 생기는 현상</strong>이에요.</p>
<hr>
<h2 id="3-git-용어-정리">3. Git 용어 정리</h2>
<table>
<thead>
<tr>
<th>이름</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>origin</td>
<td>내 포크 레포</td>
</tr>
<tr>
<td>upstream</td>
<td>원본 메인 레포</td>
</tr>
<tr>
<td>conflict</td>
<td>같은 부분이 다르게 수정된 상태</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-메인-레포-최신-코드-가져오기">4. 메인 레포 최신 코드 가져오기</h2>
<h3 id="💡-처음-한-번만-upstream-등록">💡 처음 한 번만 upstream 등록</h3>
<pre><code class="language-bash">git remote add upstream https://github.com/원본유저/메인레포.git
git remote -v</code></pre>
<h3 id="💡-최신-코드-가져오기">💡 최신 코드 가져오기</h3>
<pre><code class="language-bash">git fetch upstream</code></pre>
<hr>
<h2 id="5-내-작업-브랜치로-이동">5. 내 작업 브랜치로 이동</h2>
<pre><code class="language-bash">git switch feature/내브랜치</code></pre>
<hr>
<h2 id="6-최신-main-병합하기-→-여기서-충돌-발생">6. 최신 main 병합하기 → 여기서 충돌 발생</h2>
<pre><code class="language-bash">git merge upstream/main</code></pre>
<blockquote>
<p>예시 메시지:<br><code>CONFLICT (content): Merge conflict in xxx.java</code></p>
</blockquote>
<p>정상입니다. 이제 시작입니다😎😎</p>
<hr>
<h2 id="7-충돌-구간-확인-및-수정">7. 충돌 구간 확인 및 수정</h2>
<h3 id="👀-충돌난-파일에는-이렇게-표시됩니다">👀 충돌난 파일에는 이렇게 표시됩니다</h3>
<pre><code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD
내 코드
=======
메인 레포 코드
&gt;&gt;&gt;&gt;&gt;&gt;&gt; upstream/main</code></pre><h3 id="충돌-표시-의미">충돌 표시 의미</h3>
<table>
<thead>
<tr>
<th>구간</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD</code></td>
<td>내가 작업한 코드</td>
</tr>
<tr>
<td><code>=======</code></td>
<td>경계선</td>
</tr>
<tr>
<td><code>&gt;&gt;&gt;&gt;&gt;&gt;&gt; upstream/main</code></td>
<td>메인 레포의 코드</td>
</tr>
</tbody></table>
<hr>
<h2 id="8-충돌-해결-방법">8. 충돌 해결 방법</h2>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/44753298-fd3e-4dfc-a8f3-24f5650ee1fe/image.png" alt=""></p>
<p>앞에서 본 충돌 구간을 보고, 다음과 같은 순서로 해결하면 됩니다:</p>
<ol>
<li><strong>내 코드, 원본 코드 중 무엇을 남길지 결정</strong><br>(또는 두 코드를 적절히 섞어서 새로운 코드로 만들 수도 있어요)</li>
<li><code>&lt;&lt;&lt;&lt;&lt;&lt;&lt;</code>, <code>=======</code>, <code>&gt;&gt;&gt;&gt;&gt;&gt;&gt;</code> 같은 <strong>충돌 마커 기호들을 전부 삭제</strong></li>
<li>수정이 끝났으면 저장 후 <code>git add .</code>, <code>git commit</code> 으로 마무리</li>
</ol>
<p>💡 팁:<br>Intellij, VS Cod 등 IDE에서는 충돌 지점을 시각적으로 보여주고,<br>버튼 클릭으로 해결할 수도 있어서 훨씬 편해요!</p>
<hr>
<h2 id="9-충돌-해결-후-커밋--푸시">9. 충돌 해결 후 커밋 &amp; 푸시</h2>
<pre><code class="language-bash">git add .
git commit
git push origin feature/내브랜치</code></pre>
<p>→ GitHub PR 화면에서<br>“This branch has no conflicts” 메시지를 확인할 수 있습니다 🎉</p>
<hr>
<h2 id="10-머지를-중단하고-싶을-때">10. 머지를 중단하고 싶을 때</h2>
<h3 id="✅-일반-머지-중단">✅ 일반 머지 중단</h3>
<pre><code class="language-bash">git merge --abort</code></pre>
<h3 id="❗-에러-작업-디렉토리가-깨끗하지-않을-때">❗ 에러: 작업 디렉토리가 깨끗하지 않을 때</h3>
<h4 id="방법-1️⃣-변경사항-버리고-중단">방법 1️⃣ 변경사항 버리고 중단</h4>
<pre><code class="language-bash">git reset --hard HEAD
git merge --abort</code></pre>
<h4 id="방법-2️⃣-작업-내용-살리고-중단">방법 2️⃣ 작업 내용 살리고 중단</h4>
<pre><code class="language-bash">git stash
git merge --abort</code></pre>
<hr>
<h2 id="11-✨-실무-꿀팁-merge-vs-rebase">11. ✨ 실무 꿀팁: merge vs rebase</h2>
<table>
<thead>
<tr>
<th>방식</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>merge</td>
<td>안전하고 쉬움, 초보자 추천</td>
</tr>
<tr>
<td>rebase</td>
<td>커밋 히스토리 깔끔, 숙련자용</td>
</tr>
</tbody></table>
<h3 id="rebase-방식">Rebase 방식</h3>
<pre><code class="language-bash">git fetch upstream
git rebase upstream/main</code></pre>
<blockquote>
<p>충돌 났을 때:</p>
</blockquote>
<pre><code class="language-bash">git add .
git rebase --continue</code></pre>
<blockquote>
<p>완료 후 강제 푸시:</p>
</blockquote>
<pre><code class="language-bash">git push origin feature/내브랜치 --force-with-lease</code></pre>
<hr>
<h2 id="🎁-실수에서-배우는-git-강의도-있어요">🎁 실수에서 배우는 Git 강의도 있어요!</h2>
<p>혹시 Git 충돌, 되돌리기, 협업 브랜치 전략 등<br><strong>이런 문제들을 실습 중심으로 차근차근 배우고 싶다면</strong>,  
제가 실무 경험을 바탕으로 만든 강의도 참고해보세요!</p>
<ul>
<li><strong>되돌리는 감각:</strong> <code>reflog</code>, <code>reset</code>, <code>revert</code>, 안전한 push  </li>
<li><strong>협업 루틴:</strong> 브랜치 전략, PR 체크리스트, 보호 규칙  </li>
<li><strong>실습 레포 기반 시나리오 6종</strong>으로 직접 몸으로 익히는 흐름</li>
</ul>
<p>🎬 <strong>강의 소개 영상:</strong> <a href="https://lnkd.in/gMWTUSf3">https://lnkd.in/gMWTUSf3</a><br>📌 <strong>수강 링크:</strong> <a href="https://lnkd.in/gVVBhJ4g">https://lnkd.in/gVVBhJ4g</a><br>💸 오픈 기념 할인 중입니다!</p>
<blockquote>
<p>에이전트가 코드는 잘 짜줘도,<br><strong>버전 관리 습관은 우리가 만들어야 우리가 편해집니다.</strong><br>이번 기회에 Git과 좀 더 친해져보세요. 🙂</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[EC2 개발 환경 복제부터 GitHub Actions organization-level runner 등록까아쥐 (실무 가이드)]]></title>
            <link>https://velog.io/@nova-kim/EC2-%EA%B0%9C%EB%B0%9C-%ED%99%98%EA%B2%BD-%EB%B3%B5%EC%A0%9C%EB%B6%80%ED%84%B0-GitHub-Actions-organization-level-runner-%EB%93%B1%EB%A1%9D%EA%B9%8C%EC%95%84%EC%A5%90-%EC%8B%A4%EB%AC%B4-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
            <guid>https://velog.io/@nova-kim/EC2-%EA%B0%9C%EB%B0%9C-%ED%99%98%EA%B2%BD-%EB%B3%B5%EC%A0%9C%EB%B6%80%ED%84%B0-GitHub-Actions-organization-level-runner-%EB%93%B1%EB%A1%9D%EA%B9%8C%EC%95%84%EC%A5%90-%EC%8B%A4%EB%AC%B4-%EA%B0%80%EC%9D%B4%EB%93%9C</guid>
            <pubDate>Mon, 03 Nov 2025 09:54:18 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>정신없이 MVP 만들고,<br>회사에 도움이 되는 제품을 빠르게 출시하다 보면 배포 환경이 이중화되지 않은 채로 운영되는 경우가 많습니다.<br>(저만 그런 건 아니겠죠…?)</p>
</blockquote>
<p>그땐 괜찮다고 생각했어요.<br>“어차피 우리 아직 작은 서비스잖아. 배포 한 번 멈췄다고 누가 뭐라 하겠어?”  </p>
<p>하지만 시간이 지나고 배포 횟수가 늘어나면서,<br>조금씩 <strong>“이게 괜찮지 않았던 거구나”</strong> 를 체감하게 됩니다.<br>테스트 브랜치가 프로덕션으로 들어가기도 하고,<br>잠깐의 실수가 다운타임으로 이어지기도 하죠.  </p>
<blockquote>
<p>그리고 어느 날, 
<img src="https://velog.velcdn.com/images/nova-kim/post/75ced435-619c-4512-a8b2-afddf57476c7/image.gif" alt="">
라는 생각이 들었습니다.</p>
</blockquote>
<p>그래서 이번에 <strong>production 환경과 개발 환경을 완전히 분리</strong>하고,<br>각 환경에서 <strong>독립적인 GitHub Actions runner</strong>를 운영하도록 구성했습니다.<br>이 글에서는 그 과정을 정리했습니다.</p>
<hr>
<h2 id="🧩-전체-아키텍처-개요">🧩 전체 아키텍처 개요</h2>
<p>이 구조를 통해 <strong>develop 브랜치 배포는 개발 서버로</strong>,  
<strong>main 브랜치 배포는 프로덕션 서버로</strong> 각각 독립적으로 처리됩니다.<br><img src="https://velog.velcdn.com/images/nova-kim/post/75b02950-eb04-4ff0-a42f-c205196d373c/image.png" alt=""></p>
<hr>
<h2 id="1-ec2-복제-via-ami">1. EC2 복제 (via AMI)</h2>
<h3 id="1-1-ami-생성">1-1. AMI 생성</h3>
<p>AWS 콘솔에서 기존 프로덕션 인스턴스를 선택 후:</p>
<pre><code>Actions → Image → Create Image</code></pre><p>이미지 이름은 <code>crm-replica-prod-ami</code> 정도로 명명.</p>
<h3 id="1-2-새-인스턴스-실행">1-2. 새 인스턴스 실행</h3>
<ul>
<li>위 AMI를 기반으로 새 인스턴스를 실행  </li>
<li>인스턴스 타입, 키 페어, 보안 그룹은 동일하게 설정  </li>
<li>Elastic IP는 새로 할당해도 되고, 기존 IP를 재연결해도 됩니다  </li>
<li>태그 예시: <code>crm-dev</code></li>
</ul>
<blockquote>
<p>✅ 기존 Elastic IP를 붙일 예정이라면, 새 EC2가 완전히 준비된 뒤 재연결해 주세요.</p>
</blockquote>
<hr>
<h2 id="2-환경-설정-nginx-경로-로그">2. 환경 설정 (Nginx, 경로, 로그)</h2>
<p>새 EC2가 올라왔으면 바로 접속해서 아래 항목을 수정합니다.</p>
<ul>
<li><p><strong>Nginx 설정 (<code>/etc/nginx/sites-enabled/crm.conf</code>)</strong></p>
<ul>
<li>도메인명을 dev용으로 변경</li>
<li>포트는 그대로 유지</li>
<li>필요 없다면 <code>access_log</code>, <code>error_log</code> 끄기 (비용 절감)</li>
</ul>
</li>
<li><p><strong>프론트엔드 경로 변경</strong></p>
<pre><code class="language-bash">mv /var/www/frontend-prod /var/www/frontend-dev</code></pre>
</li>
</ul>
<hr>
<h2 id="3-github-actions-워크플로-추가">3. GitHub Actions 워크플로 추가</h2>
<p>개발용 배포 전용 워크플로를 새로 추가합니다.<br>예: <code>.github/workflows/deploy-dev.yml</code></p>
<pre><code class="language-yaml">runs-on: [self-hosted, dev]  # &quot;dev&quot; 라벨을 가진 러너에서만 실행</code></pre>
<p>테스트 커밋으로 트리거 확인:</p>
<pre><code class="language-bash">git commit --allow-empty -m &quot;test: trigger dev deploy&quot;
git push origin develop</code></pre>
<hr>
<h2 id="4-self-hosted-runner-설정-organization-level">4. Self-Hosted Runner 설정 (organization-level)</h2>
<p>복제된 EC2에는 프로덕션 러너 설정이 그대로 남아 있어서,<br>먼저 초기화를 해줘야 합니다.</p>
<h3 id="1-기존-runner-제거">(1) 기존 Runner 제거</h3>
<pre><code class="language-bash">sudo ./svc.sh stop
sudo ./svc.sh uninstall
./config.sh remove</code></pre>
<blockquote>
<p>새 토큰은 아래에서 발급받을 수 있습니다:<br><strong>GitHub → Organization → Settings → Actions → Runners → New self-hosted runner → Linux</strong></p>
</blockquote>
<hr>
<h3 id="2-폴더-초기화-선택">(2) 폴더 초기화 (선택)</h3>
<pre><code class="language-bash">cd ~
rm -rf actions-runner
mkdir actions-runner &amp;&amp; cd actions-runner</code></pre>
<hr>
<h3 id="3-runner-재등록">(3) Runner 재등록</h3>
<p>repository-level이 아닌 <strong>organization-level</strong>로 등록합니다.  </p>
<pre><code class="language-bash">./config.sh --url https://github.com/Allies-Tax-Relief --token &lt;TOKEN&gt;</code></pre>
<p>등록 과정 예시:</p>
<pre><code>? Name: ec2-dev-runner
? Labels: self-hosted, dev
? Work folder: [Enter]</code></pre><blockquote>
<p>⚠️ repo URL로 등록하면 GitHub API가 거절해요! </p>
</blockquote>
<hr>
<h3 id="4-서비스-등록-systemd">(4) 서비스 등록 (systemd)</h3>
<p>SSH 종료 후에도 러너가 계속 살아 있도록 설정합니다.</p>
<pre><code class="language-bash">sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status</code></pre>
<p>✅ <code>active (running)</code> 이면 정상 작동 중입니다.</p>
<p>나중에 중지/삭제할 때는:</p>
<pre><code class="language-bash">sudo ./svc.sh stop
sudo ./svc.sh uninstall</code></pre>
<hr>
<h2 id="5-runner-등록-확인">5. Runner 등록 확인</h2>
<p>이제 GitHub에서 runner가 정상적으로 등록됐는지 확인해봅니다.</p>
<blockquote>
<p><strong>GitHub → Organization → Settings → Actions → Runners</strong></p>
</blockquote>
<p>아래처럼 두 개의 runner가 보이면 성공 👇</p>
<table>
<thead>
<tr>
<th>Runner</th>
<th>Level</th>
<th>Labels</th>
</tr>
</thead>
<tbody><tr>
<td>ec2-prod-runner</td>
<td>organization</td>
<td>self-hosted, prod</td>
</tr>
<tr>
<td>ec2-dev-runner</td>
<td>organization</td>
<td>self-hosted, dev</td>
</tr>
</tbody></table>
<hr>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/315a3b75-4cff-4714-a972-0759e0f8f7df/image.png" alt=""></p>
<blockquote>
<p>두 runner 모두 <strong>“Shared with this repository”</strong> 상태여야 합니다.<br>상태가 <code>idle</code>로 표시되면 runner가 정상적으로 대기 중인 것입니다.(Offline이면 무언가 잘못된 것,,⭐️)</p>
</blockquote>
<hr>
<h2 id="6-배포-테스트">6. 배포 테스트</h2>
<p>이제 <code>develop</code> 브랜치에 커밋을 하나 푸시하면<br>dev 러너가 잡아서 배포를 실행합니다.</p>
<pre><code>√ Connected to GitHub
Current runner version: &#39;2.329.0&#39;
2025-11-03 08:19:19Z: Listening for Jobs
2025-11-03 08:22:41Z: Running job: Deploy (EC2 self-hosted runner)
2025-11-03 08:22:50Z: Job Deploy (EC2 self-hosted runner) completed with result: Succeeded</code></pre><p>깔끔하게 “Succeeded” 뜨면 완성 🎉</p>
<hr>
<h2 id="7-구조-요약-다이어그램">7. 구조 요약 다이어그램</h2>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/1723b135-460c-49d9-833b-d0f81a9c5527/image.png" alt=""></p>
<hr>
<h2 id="💡-실무-팁">💡 실무 팁</h2>
<ul>
<li><strong>organization-level runner</strong>를 사용하세요.</li>
<li><code>./run.sh</code>는 수동 테스트용으로만 쓰세요.<br>딸깍 배포 실행을 원하신다면 반드시 <code>svc.sh start</code>로 관리해야 합니다.</li>
<li>러너 로그 확인:<pre><code class="language-bash">journalctl -u actions.runner.*</code></pre>
</li>
</ul>
<hr>
<h2 id="🧭-마무리">🧭 마무리</h2>
<p>드디어 기다리던 배포환경 분리가 되었어요. <del>저는 사실 아직 하는 중이긴 해요</del>
세상의 모든 개발자 화이팅,,💪🏻💪🏻</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[타임스탬프만으론 안 된다? Cursor bugbot이 알려준 jobId 설계 실수]]></title>
            <link>https://velog.io/@nova-kim/jobId-%EC%A0%80%EC%B2%98%EB%9F%BC-%EB%A7%8C%EB%93%A4%EB%A9%B4-%EC%95%88%EB%90%98%EB%8A%94-%EC%9D%B4%EC%9C%A0-%EB%B6%80%ED%95%98%EA%B0%80-%ED%81%B0-%EC%83%81%ED%99%A9%EC%97%90%EC%84%9C-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-unique-ID-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@nova-kim/jobId-%EC%A0%80%EC%B2%98%EB%9F%BC-%EB%A7%8C%EB%93%A4%EB%A9%B4-%EC%95%88%EB%90%98%EB%8A%94-%EC%9D%B4%EC%9C%A0-%EB%B6%80%ED%95%98%EA%B0%80-%ED%81%B0-%EC%83%81%ED%99%A9%EC%97%90%EC%84%9C-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-unique-ID-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Sun, 22 Jun 2025 12:07:08 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며-숫자로-된-id-정말-안전할까요">들어가며: 숫자로 된 ID, 정말 안전할까요?</h2>
<p>백엔드 시스템에서 각종 작업(Job), 메시지, 트랜잭션 등을 식별하기 위한 <strong>고유 ID 생성</strong>은 굉장히 기본적인 기능이지만, 동시에 많은 문제가 숨어 있는 지점이기도 합니다. 특히 부하가 큰 상황에서는 &quot;시간 기반 ID&quot; 하나만으로는 유니크함을 보장하기 어렵지요.</p>
<p>이번 글에서는 실제 서비스 배포 이전 새로운 기능 구현에서 <code>jobId</code>를 밀리초 타임스탬프 기반으로 만들었다가 <a href="https://docs.cursor.com/bugbot">Cursor AI의 버그 봇</a>한테 혼나고(!)
<img src="https://velog.velcdn.com/images/nova-kim/post/1441f263-e06d-473a-b53a-332970b2f7fa/image.png" alt="">이를 계기로 더 안전한 방식으로 고친 이야기를 풀어보려 합니다. 실무에서도 유사한 이슈는 충분히 발생할 수 있으니, 참고가 되셨으면 좋겠습니다.</p>
<h2 id="1-문제-상황--jobid가-중복되는-이유">1. 문제 상황 : <code>jobId</code>가 중복되는 이유</h2>
<p>저같은 경우에는, 스케줄링 시스템에서 메시지 배치 작업의 식별을 위해 <code>jobId</code>를 다음과 같이 생성하고 있었습니다:</p>
<pre><code class="language-typescript">const jobId = DateTime.utc().toMillis();</code></pre>
<p>UTC 기준 밀리초 단위의 타임스탬프를 사용하는 이 방식은 겉보기에 유니크한 값을 보장할 것처럼 보입니다. 그러나 <strong>동일한 밀리초 내에 여러 요청이 들어오는 경우</strong>, 똑같은 <code>jobId</code>가 생성되는 심각한 문제가 발생할 수 있습니다.</p>
<h3 id="실제-문제-사례-거의-발생할-뻔한-문제">실제 문제 사례 (거의 발생할 뻔한 문제)</h3>
<p>풀리퀘를 열고 Cursor Bug Bot으로 코드 리뷰를 받았는데,
<del>(아니 마침 코드래빗 프리티어 끝난 시점에 이녀석들이 시용기간을 주지 뭡니까?! 개꿀)</del></p>
<p>바로 이 부분이 지적됐습니다:</p>
<blockquote>
<p>&quot;타임스탬프 기반 ID는 충돌 가능성이 있어요. 부하가 큰 상황에선 동일한 밀리초 안에 여러 job이 생성될 수 있습니다.&quot;</p>
</blockquote>
<p>처음엔 &quot;그렇게까지 동시 요청이 많을까...?&quot; 싶었지만, <strong>정말 중요한 포인트</strong>를 짚어준 리뷰였기에, 더 안전한 방식으로 바꾸기로 했습니다.</p>
<h2 id="2-흔히-저지르는-실수-왜-타임스탬프만-썼을까">2. 흔히 저지르는 실수: 왜 타임스탬프만 썼을까?</h2>
<p>단순한 타임스탬프 기반 ID 생성 방식은 다음과 같은 장점 때문에 널리 사용됩니다:</p>
<ul>
<li>구현이 간단하고,</li>
<li>시간 순서에 따라 정렬이 가능하며,</li>
<li>외부 라이브러리 없이 숫자형 ID를 쉽게 만들 수 있기 때문이죠.
<img src="https://velog.velcdn.com/images/nova-kim/post/d3f2c0f2-1790-4ed5-9449-82c229709b6c/image.png" alt=""></li>
</ul>
<p>그치만 이 방식의 <strong>근본적인 한계</strong>는 다음과 같습니다:</p>
<ul>
<li>단일 서버 또는 싱글스레드 상황에서는 괜찮지만,</li>
<li>부하가 큰, 혹은 멀티스레드 환경에서는 동시성 문제가 발생하기 쉽습니다.</li>
</ul>
<h2 id="3-해결-방법--진짜-유니크한-jobid-만들기">3. 해결 방법 : 진짜 유니크한 <code>jobId</code> 만들기</h2>
<p>이 문제를 해결하기 위해 <code>timestamp + counter + random</code> 조합 방식을 도입했습니다.</p>
<h3 id="개선된-코드-예시">개선된 코드 예시</h3>
<pre><code class="language-typescript">private static jobIdCounter = 0;

private generateUniqueJobId(): number {
  const timestamp = DateTime.utc().toMillis();
  SchedulingService.jobIdCounter = (SchedulingService.jobIdCounter + 1) % 1000;
  const randomComponent = Math.floor(Math.random() * 1000);
  return timestamp * 1000000 + SchedulingService.jobIdCounter * 1000 + randomComponent;
}</code></pre>
<h3 id="개선-포인트-요약">개선 포인트 요약</h3>
<table>
<thead>
<tr>
<th>요소</th>
<th>설명</th>
<th>효과</th>
</tr>
</thead>
<tbody><tr>
<td><code>timestamp</code></td>
<td>UTC 기준 밀리초</td>
<td>기본적인 시간 순서 보장</td>
</tr>
<tr>
<td><code>counter</code></td>
<td>0~999 순환 카운터</td>
<td>동일 밀리초 내 요청 구분</td>
</tr>
<tr>
<td><code>random</code></td>
<td>0~999 랜덤 값</td>
<td>충돌 가능성 최소화</td>
</tr>
</tbody></table>
<h2 id="4-다른-id-생성-방식과-비교">4. 다른 ID 생성 방식과 비교</h2>
<table>
<thead>
<tr>
<th>방식</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td><strong>UUID</strong></td>
<td>충돌 거의 없음, 전역 유니크</td>
<td>길고 복잡한 문자열, 숫자 ID 필요시 부적합</td>
</tr>
<tr>
<td><strong>Snowflake ID</strong></td>
<td>시간 정렬 + 유니크, 대규모 분산 환경에 적합</td>
<td>구현 복잡, 시스템 시간 오류에 취약</td>
</tr>
<tr>
<td><strong>Timestamp + Counter + Random (현 방식)</strong></td>
<td>시간 정렬, 숫자 ID, 간단한 구현</td>
<td>슬프게도 분산 환경에서는 global counter 관리 필요</td>
</tr>
</tbody></table>
<h2 id="5-이렇게-하면-이런-장점이-있답니다">5. 이렇게 하면 이런 장점이 있답니다?</h2>
<ul>
<li><strong>충돌 없음</strong> : 동일 밀리초에도 counter와 random이 보완</li>
<li><strong>정렬 가능</strong> : 시간순 정렬 유지</li>
<li><strong>숫자 포맷 유지</strong> : 기존 시스템과 호환성 유지</li>
<li><strong>부하가 클 때 대응 가능</strong> : 1ms 내 최대 1,000건까지 안전 처리 가능</li>
</ul>
<h3 id="예시-job-id">예시 Job ID</h3>
<ul>
<li><code>1672531200000000123456</code> → (timestamp: 1672531200000, counter: 123, random: 456)</li>
<li><code>1672531200000001234567</code> → 같은 timestamp라도 counter와 random 값으로 구분</li>
</ul>
<h2 id="6-잠깐만-그런데-javascript에서-이-방식-써도-괜찮은-걸까">6. (잠깐만) 그런데 JavaScript에서 이 방식 써도 괜찮은 걸까?</h2>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/e3dc2a36-e1e8-47c9-96b7-3274dd8f4add/image.png" alt="">
Cursor 리뷰를 반영해서 개선한 이 방식, 얼핏 보면 완벽해 보입니다. (저도 그런 줄 알았는데 말이죠?) 그런데 한 가지 치명적인 문제가 더 있었으니...</p>
<p>바로 JavaScript의 (그리고 TypeScript의 number 타입의) <strong><code>Number.MAX_SAFE_INTEGER</code> 한계 초과</strong> 이슈입니다.</p>
<p>현재 방식은 <code>timestamp * 1_000_000</code> 계산을 기반으로 합니다. 그런데 이때 <code>timestamp</code>는 보통 <code>1.7e12</code> 수준 (밀리초)이기 때문에, 전체 값은 <code>1.7e18</code> 근처가 됩니다. 문제는 이게 JS의 안전한 정수 표현 범위(<code>~9e15</code>)를 야무지게 넘는다는 점입니다.</p>
<h3 id="🤯-왜-이게-문제일까요">🤯 왜 이게 문제일까요?</h3>
<ul>
<li>TypeScript의 <code>number</code>는 JavaScript와 동일한 <code>Number</code> 타입을 따릅니다. 이 타입은 <strong>정밀도를 보장할 수 있는 최대 정수</strong>가 약 9조(<code>2^53 - 1</code>)까지예요.</li>
<li>이 범위를 넘는 숫자는 계산이나 비교, 정렬 등의 연산에서 <strong>정밀도가 깨지거나 이상한 결과</strong>를 만들 수 있습니다.</li>
<li>즉, <code>jobId</code>가 유일하더라도 정렬이 깨지거나, 숫자끼리 비교 시 <strong>서로 다른 값인데도 같다고 인식</strong>될 수도 있는 거죠.</li>
</ul>
<h3 id="해결책-밀리초-→-초-단위로-바꾸자💡">해결책: 밀리초 → 초 단위로 바꾸자!💡</h3>
<p>다행히도 해결책은 단순합니다:</p>
<pre><code class="language-typescript">const timestamp = Math.floor(DateTime.utc().toSeconds());</code></pre>
<p>즉, 초 단위로 timestamp를 줄이면:</p>
<ul>
<li><code>timestamp</code> ≈ 1.7e9</li>
<li>최종 jobId ≈ 1.7e15 수준 → <strong>Number.MAX_SAFE_INTEGER 이내!</strong></li>
</ul>
<p>나머지 구조(counter, random)는 그대로 유지할 수 있어 안정성 + 정렬성 + 유니크함까지 모두 잡을 수 있습니다.</p>
<hr>
<h2 id="7-결론--절대-과거의-저처럼ㅎ-타임스탬프에만-의존하지-마세요">7. 결론 : 절대 (과거의 저처럼ㅎ) 타임스탬프에만 의존하지 마세요</h2>
<p>밀리초 단위의 타임스탬프만으로 유니크 ID를 생성하는 것은 <strong>부하가 큰 시스템에서 너무 위험해버리는 전략</strong>입니다. </p>
<p>복합 요소를 조합하고, 정수 범위도 체크하는 <strong>신중한 ID 설계</strong>가 더더욱 필요합니다. (그리고 가능하다면... Cursor bugbot 리뷰 한 번 받아보는 것도 추천드려요 😎)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Best Practices for Scaling with Messaging Services]]></title>
            <link>https://velog.io/@nova-kim/Best-Practices-for-Scaling-with-Messaging-Services</link>
            <guid>https://velog.io/@nova-kim/Best-Practices-for-Scaling-with-Messaging-Services</guid>
            <pubDate>Sat, 10 May 2025 15:26:19 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>⚠️ 이 글은 Twilio 공식 문서 <a href="https://www.twilio.com/docs/messaging/guides/best-practices-at-scale#1-are-you-sending-one-way-or-two-way-messages">Best Practices for Scaling with Messaging Services</a> 를 번역한 내용입니다.</p>
</blockquote>
<p>Twilio 메시징 서비스를 본격적으로 확장할 준비가 되셨나요? 확장을 고려할 때에는 몇 가지 핵심 질문과 함께, 구축 방식과 설정에 있어 꼭 알아두셔야 할 팁들이 있습니다. 이 가이드는 대표적인 메시징 사용 사례와 국가별 특수 상황을 정리해두었으며, Twilio와 함께 메시징을 운영하면서 놓치기 쉬운 부분을 미리 방지할 수 있도록 돕습니다.</p>
<h2 id="1-메시지를-보낼-땐-메시징-서비스를-활용하세요">1. 메시지를 보낼 땐 메시징 서비스를 활용하세요</h2>
<p>PoC(Proof of Concept) 단계에서는 단일 전화번호(롱코드)를 통해 메시지를 보내는 방식으로도 충분할 수 있습니다. 하지만 서비스가 성장함에 따라 숏코드, 알파벳 발신 ID, WhatsApp 발신 번호 등 다양한 유형의 발신자를 고려해야 할 수 있습니다. 또한 <strong>Advanced Opt-Out</strong> 같은 기능을 활용하여 수신자 동의/거부 관리를 자동화하거나, <strong>Smart Encoding</strong>으로 유니코드 문자로 인한 세그먼트 초과를 방지하는 기능도 중요해집니다.</p>
<p>메시징 서비스는 발신자 ID를 묶고 메시지 전송 설정을 통합적으로 관리할 수 있는 컨테이너 역할을 합니다. 예를 들어, 하나의 메시징 서비스에 숏코드와 지역 번호들을 등록하고, 동일한 Webhook으로 연결하여 수신 메시지에 응답하거나, 국가별 수신 거부(Stop) 키워드를 통일적으로 설정하는 것이 가능합니다.</p>
<blockquote>
<p>메시지를 전송할 때는 <code>From</code> 필드 대신 <code>messagingServiceSid</code>를 사용하여 메시징 서비스를 통해 전송할 수 있습니다. 이 방식은 자동 라우팅 및 다양한 Twilio 메시징 기능들을 함께 사용할 수 있도록 해줍니다.</p>
</blockquote>
<h2 id="2-어떤-발신자가-적절할지-결정해보세요">2. 어떤 발신자가 적절할지 결정해보세요</h2>
<h3 id="✔️-1-단방향-메시지인가요-아니면-양방향-메시지인가요">✔️ 1) 단방향 메시지인가요? 아니면 양방향 메시지인가요?</h3>
<ul>
<li>사용자가 메시지에 답장할 수 있는 구조라면 **양방향 메시지(two-way messaging)**입니다.</li>
<li>WhatsApp은 기본적으로 양방향을 지원하지만, SMS는 발신 번호 유형에 따라 양방향 여부가 달라질 수 있습니다.</li>
<li>단방향만 필요하다면 선택할 수 있는 번호 유형의 폭은 넓어지지만, 국가별 규정을 함께 고려해야 합니다.</li>
</ul>
<h3 id="✔️-2-어느-국가로-메시지를-보내시나요">✔️ 2) 어느 국가로 메시지를 보내시나요?</h3>
<ul>
<li>각 국가마다 메시징 관련 규제가 다르므로, Twilio에서 제공하는 <a href="https://www.twilio.com/guidelines/sms">국가별 가이드라인</a>을 꼭 확인하셔야 합니다.</li>
</ul>
<p><strong>미국/캐나다</strong>:</p>
<ul>
<li>사용 가능한 발신 번호 유형: 10DLC 롱코드, 숏코드, Toll-Free 번호, WhatsApp</li>
<li>캐나다는 롱코드를 통한 A2P 메시징을 금지하고 있으므로 Toll-Free 또는 숏코드를 사용해야 합니다.</li>
</ul>
<p><strong>그 외 국가</strong>:</p>
<ul>
<li>사용 가능한 발신 번호 유형: 알파벳 발신 ID, 롱코드, 숏코드, Toll-Free, WhatsApp</li>
<li>일부 국가에서는 인바운드 응답을 받기 위해 반드시 현지 번호 사용이 요구될 수 있습니다.</li>
</ul>
<h3 id="✔️-3-어떤-종류의-메시지를-보내시나요">✔️ 3) 어떤 종류의 메시지를 보내시나요?</h3>
<p><strong>단방향 메시지:</strong></p>
<ul>
<li>마케팅, 배송 알림, 정보성 메시지 등</li>
<li>예: 프랑스에서는 P2P가 아닌 A2P 트래픽에 대해 지역 롱코드를 금지하고 있으므로, 알파벳 발신 ID나 숏코드만 사용할 수 있습니다.</li>
</ul>
<p><strong>양방향 메시지:</strong></p>
<ul>
<li>챗봇, 리마인더, 고객 대화 등</li>
<li>인바운드 수신이 가능한 번호 유형(예: 현지 롱코드, 숏코드, WhatsApp)을 사용해야 합니다.</li>
</ul>
<h2 id="3-메시징-처리량mps을-계산해보세요">3. 메시징 처리량(MPS)을 계산해보세요</h2>
<ul>
<li>MPS는 초당 메시지 세그먼트(Message Segments Per Second)로 측정되며, 발신자 유형과 국가에 따라 다릅니다.</li>
</ul>
<p><strong>미국 기준:</strong></p>
<ul>
<li>롱코드: A2P 등록 결과에 따라 MPS가 달라짐</li>
<li>Toll-Free: 기본 3 MPS (증설 요청 가능)</li>
<li>숏코드: 기본 100 MPS 이상</li>
</ul>
<p><strong>기타 국가:</strong></p>
<ul>
<li>알파벳 ID 및 롱코드: 기본 10 MPS</li>
</ul>
<blockquote>
<p>Twilio는 메시지를 수신한 순서대로 큐에 넣어 전송하므로, API 요청은 빠르게 보내더라도 실제 발송은 MPS에 맞춰 이루어집니다. 안정성을 위해 메시징 서비스를 통해 큐 관리를 맡기는 것이 권장됩니다.</p>
</blockquote>
<h2 id="4-높은-처리량이-필요하다면-숏코드-또는-toll-free-번호를-고려하세요">4. 높은 처리량이 필요하다면 숏코드 또는 Toll-Free 번호를 고려하세요</h2>
<ul>
<li>롱코드를 여러 개 추가해서 처리량을 분산시키는 <strong>&quot;스노우슈잉(snowshoeing)&quot;</strong> 방식은 필터링 위험이 커지므로 비권장됩니다.</li>
<li>Twilio는 미국, 캐나다, 브라질 등 14개국에서 숏코드를 제공합니다. 숏코드는 사전 심사된 번호이기 때문에 필터링 위험이 낮고 고속 처리에 적합합니다.</li>
<li>한 국가에만 적용되는 것이기 때문에 국제 발송이 필요하다면 알파벳 발신 ID 또는 Toll-Free를 함께 고려해야 합니다.</li>
</ul>
<blockquote>
<p>알파벳 발신 ID는 Sender Pool 내 다른 번호보다 우선 적용되며, 메시지가 큐에 있어도 대체 발신으로 넘어가지 않습니다. MPS 증설이 필요한 경우 Twilio에 요청할 수 있습니다.</p>
</blockquote>
<h2 id="마무리하며">마무리하며</h2>
<p>Twilio Programmable Messaging을 통해 확장을 고려하고 있다면, 우선 메시징 서비스를 활용하여 구성하는 것을 강력히 권장합니다. 이렇게 하면 하나의 Sender Pool 내에서 다양한 번호와 기능을 통합 관리하면서 글로벌 메시징 확장에도 유연하게 대응할 수 있습니다.</p>
<p>처음부터 다음 사항을 명확히 해두시면 확장 시 큰 도움이 됩니다:</p>
<ul>
<li>메시지를 보낼 국가</li>
<li>메시지 내용 유형 (마케팅/정보성/인증 등)</li>
<li>응답이 필요한지 여부 (양방향 메시징)</li>
<li>처리량 요구 사항</li>
</ul>
<hr>
<p>▶⃣ 함께 읽으면 좋은 자료:</p>
<ul>
<li><a href="https://www.twilio.com/docs/messaging/services">Twilio Messaging Services 개요</a></li>
<li><a href="https://www.twilio.com/guidelines/sms">국가별 SMS 가이드라인</a></li>
<li><a href="https://www.twilio.com/docs/messaging/guides/rate-limits-message-queues">Rate Limit 및 Message Queue 이해하기</a></li>
<li><a href="https://www.twilio.com/docs/messaging/guides/campaign-registry-overview">통신사 필터링은 어떻게 작동할까요?</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Manage opt-ins and opt-outs]]></title>
            <link>https://velog.io/@nova-kim/Manage-opt-ins-and-opt-outs</link>
            <guid>https://velog.io/@nova-kim/Manage-opt-ins-and-opt-outs</guid>
            <pubDate>Sat, 10 May 2025 15:18:12 GMT</pubDate>
            <description><![CDATA[<h2 id="🌐-advanced-opt-out-설정-가이드-한글-번역">🌐 Advanced Opt-Out 설정 가이드 (한글 번역)</h2>
<blockquote>
<p>원문 링크: <a href="https://www.twilio.com/docs/messaging/guides/customize-opt-out-keywords-advanced-opt-out">Customize Users’ Opt-in and Opt-out Experience</a></p>
</blockquote>
<hr>
<h3 id="✨-advanced-opt-out란">✨ Advanced Opt-Out란?</h3>
<p>Advanced Opt-Out 기능을 사용하면, 고객이 SMS 수신을 동의하거나 거부하는 <strong>키워드와 응답 메시지</strong>를 <strong>전 세계 언어와 국가별로 맞춤 설정</strong>할 수 있습니다. 이 기능은 Messaging Service에 포함된 발신번호 전체에 적용됩니다.</p>
<hr>
<h3 id="✅-기본-제공되는-키워드">✅ 기본 제공되는 키워드</h3>
<p>Twilio는 다음의 기본 키워드를 인식합니다:</p>
<ul>
<li><strong>수신 거부 (Opt-Out)</strong>: <code>STOP</code>, <code>UNSUBSCRIBE</code>, <code>END</code>, <code>QUIT</code>, <code>STOPALL</code>, <code>CANCEL</code></li>
<li><strong>수신 동의 (Opt-In)</strong>: <code>START</code>, <code>UNSTOP</code></li>
<li><strong>도움말 (Help)</strong>: <code>HELP</code></li>
</ul>
<p>2025년 4월 29일부터는 <code>REVOKE</code>, <code>OPTOUT</code>도 기본 Opt-Out 키워드로 포함됩니다.</p>
<hr>
<h3 id="🛠-설정-순서">🛠 설정 순서</h3>
<h4 id="1-messaging-service-생성">1. Messaging Service 생성</h4>
<pre><code class="language-ts">const service = await client.messaging.v1.services.create({
  friendlyName: &quot;My First Messaging Service&quot;,
});</code></pre>
<h4 id="2-sms-발신-가능한-번호-구매-및-연결">2. SMS 발신 가능한 번호 구매 및 연결</h4>
<ul>
<li>Twilio Console에서 구매</li>
<li>번호의 SID (<code>PNxxx</code>)를 Messaging Service에 연결</li>
</ul>
<pre><code class="language-ts">await client.messaging.v1.services(&quot;MGxxx&quot;).phoneNumbers.create({
  phoneNumberSid: &quot;PNxxx&quot;
});</code></pre>
<h4 id="3-advanced-opt-out-활성화">3. Advanced Opt-Out 활성화</h4>
<ul>
<li>Twilio Console &gt; Messaging &gt; Services &gt; Opt-Out Management 섹션</li>
<li>&quot;Enable Advanced Opt-Out&quot; 클릭</li>
</ul>
<blockquote>
<p>⚠ 활성화 이후 비활성화는 Support 요청 필요</p>
</blockquote>
<hr>
<h3 id="🔤-키워드-커스터마이징-방식">🔤 키워드 커스터마이징 방식</h3>
<h4 id="▪-표준-키워드standard-keywords">▪ 표준 키워드(Standard Keywords)</h4>
<ul>
<li>모든 국가에 공통 적용되는 키워드와 응답 메시지</li>
</ul>
<h4 id="▪-언어별-키워드language-specific">▪ 언어별 키워드(Language-specific)</h4>
<ul>
<li>예: 스페인어 사용자에게는 <code>SALIR</code> 입력 시 스페인어 응답 전송</li>
</ul>
<h4 id="▪-국가-코드별-키워드country-specific">▪ 국가 코드별 키워드(Country-specific)</h4>
<ul>
<li>특정 국가 번호(예: <code>+82</code>)에만 다른 키워드와 메시지 지정</li>
<li>언어별 중복 키워드는 허용되지 않음</li>
</ul>
<hr>
<h3 id="📌-toll-free-번호-주의사항">📌 Toll-Free 번호 주의사항</h3>
<ul>
<li>Toll-Free 번호는 항상 <code>STOP</code>으로 수신 거부 처리됨 (커스텀 메시지 무시)</li>
<li><code>START</code> 또는 <code>UNSTOP</code>으로만 차단 해제 가능</li>
</ul>
<hr>
<h3 id="🧾-사용자-상태-추적하기">🧾 사용자 상태 추적하기</h3>
<ul>
<li>사용자 메시지가 키워드와 일치하면 Twilio는 webhook으로 <code>OptOutType</code> 포함하여 알림</li>
<li><code>OptOutType</code> 값: <code>START</code>, <code>STOP</code>, <code>HELP</code></li>
<li>Twilio가 이미 응답했기 때문에, 별도 메시지를 다시 보내는 것은 권장되지 않음</li>
</ul>
<hr>
<h3 id="📚-다음-단계">📚 다음 단계</h3>
<ul>
<li><a href="https://www.twilio.com/docs/messaging/services">Messaging Service Overview</a></li>
<li><a href="https://www.twilio.com/docs/messaging/tutorials/send-messages-with-messaging-services">How to send messages with Messaging Services</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Traffic Shaping 가이드 (Twilio 공식 문서 번역)]]></title>
            <link>https://velog.io/@nova-kim/Traffic-Shaping-%EA%B0%80%EC%9D%B4%EB%93%9C-Twilio-%EA%B3%B5%EC%8B%9D-%EB%AC%B8%EC%84%9C-%EB%B2%88%EC%97%AD-97j8wxug</link>
            <guid>https://velog.io/@nova-kim/Traffic-Shaping-%EA%B0%80%EC%9D%B4%EB%93%9C-Twilio-%EA%B3%B5%EC%8B%9D-%EB%AC%B8%EC%84%9C-%EB%B2%88%EC%97%AD-97j8wxug</guid>
            <pubDate>Sat, 10 May 2025 13:55:23 GMT</pubDate>
            <description><![CDATA[<h2 id="traffic-shaping-가이드-twilio-공식-문서-번역">Traffic Shaping 가이드 (Twilio 공식 문서 번역)</h2>
<blockquote>
<p><strong>원본 문서</strong>: <a href="https://www.twilio.com/docs/messaging/traffic-shaping">Traffic Shaping | Twilio Docs</a></p>
</blockquote>
<h3 id="🌍-개요">🌍 개요</h3>
<p><strong>Traffic Shaping</strong>은 Twilio의 Programmable Messaging 제품 중 하나로, <strong>대량 메시징 환경에서 메시지의 우선순위에 따라 처리 속도(MPS)를 조절</strong>할 수 있게 해주는 기능입니다. 주로 OTP(일회용 비밀번호), 보안 알림, 계정 알림처럼 <strong>시간에 민감한 메시지</strong>를 다른 마케팅 메시지보다 빠르게 보내고자 할 때 유용합니다.</p>
<blockquote>
<p>🚫 미국 및 캐나다의 <strong>A2P 10DLC 메시지</strong>는 지원되지 않습니다.</p>
</blockquote>
<hr>
<h3 id="⚙️-서비스-레벨-service-levels">⚙️ 서비스 레벨 (Service Levels)</h3>
<p>Traffic Shaping은 총 **3개의 Service Level(우선순위 큐)**을 제공합니다.</p>
<table>
<thead>
<tr>
<th>서비스 레벨</th>
<th>용도 예시</th>
<th>기본 처리 할당량 (%)</th>
</tr>
</thead>
<tbody><tr>
<td>Level 1</td>
<td>OTP, 보안, 계정 알림 등</td>
<td>50%</td>
</tr>
<tr>
<td>Level 2</td>
<td>고객센터, 배송 알림 등</td>
<td>30%</td>
</tr>
<tr>
<td>Level 3</td>
<td>마케팅, 투표, 공지 등</td>
<td>20%</td>
</tr>
</tbody></table>
<blockquote>
<p>원한다면 Level 1과 Level 3만 사용하는 것도 가능합니다.</p>
</blockquote>
<hr>
<h3 id="📨-메시지에-서비스-레벨-지정하기">📨 메시지에 서비스 레벨 지정하기</h3>
<p>메시지를 보낼 때 <code>MessageIntent</code> 파라미터를 설정하여 어느 Service Level에 들어갈지 지정할 수 있습니다. 지정하지 않으면 기본값인 <strong>Level 3</strong>으로 처리됩니다.</p>
<table>
<thead>
<tr>
<th>Use Case</th>
<th>MessageIntent 값</th>
<th>기본 Service Level</th>
</tr>
</thead>
<tbody><tr>
<td>OTP</td>
<td><code>otp</code></td>
<td>Level 1</td>
</tr>
<tr>
<td>알림</td>
<td><code>notifications</code></td>
<td>Level 1</td>
</tr>
<tr>
<td>보안 경고</td>
<td><code>security</code></td>
<td>Level 1</td>
</tr>
<tr>
<td>고객센터</td>
<td><code>customercare</code></td>
<td>Level 2</td>
</tr>
<tr>
<td>이벤트 마케팅</td>
<td><code>events</code></td>
<td>Level 2</td>
</tr>
<tr>
<td>마케팅 캠페인</td>
<td><code>marketing</code></td>
<td>Level 3</td>
</tr>
</tbody></table>
<h4 id="✅-예시-curl-명령어">✅ 예시 (curl 명령어)</h4>
<pre><code class="language-bash">curl -X POST &quot;https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Messages.json&quot; \
--data-urlencode &quot;From=+15557122661&quot; \
--data-urlencode &quot;Body=Your one-time passcode is: 8458881&quot; \
--data-urlencode &quot;To=+15558675310&quot; \
--data-urlencode &quot;MessageIntent=otp&quot; \
-u $TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN</code></pre>
<hr>
<h3 id="♻️-동적-처리량-재분배">♻️ 동적 처리량 재분배</h3>
<p>어떤 Service Level 큐에 메시지가 없으면, 그 큐에 할당된 처리량(MPS)은 <strong>다른 큐로 자동 재분배</strong>됩니다. 즉, 전체 계정 처리량은 100% 다 활용됩니다.</p>
<hr>
<h3 id="🎯-장점">🎯 장점</h3>
<ul>
<li>메시지 우선순위에 따라 <strong>처리량(MPS)을 유연하게 분배</strong> 가능</li>
<li>하나의 번호에서 여러 우선순위 메시지를 보낼 경우에도 <strong>큐 간 간섭 없이 독립적으로 처리</strong></li>
<li>Multi-Tenancy와 <strong>동시에 사용 가능</strong> (서브 계정 간 처리량 분배 + 메시지 레벨 처리 분배)</li>
</ul>
<hr>
<h3 id="⚠️-제약-사항">⚠️ 제약 사항</h3>
<ul>
<li><strong>미국/캐나다의 A2P 10DLC</strong> 메시지에는 적용되지 않음</li>
<li>메시지 우선순위 설정을 위해 <strong>MessageIntent 파라미터</strong> 필수 사용</li>
</ul>
<hr>
<h3 id="🚀-시작하기-onboarding-절차">🚀 시작하기 (Onboarding 절차)</h3>
<ol>
<li><strong>Twilio 지원팀에 연락</strong> → Traffic Shaping 데모/적용 요청</li>
<li><strong>트래픽 예측 및 서비스 구성 협의</strong></li>
<li><strong>설정 리뷰 및 승인</strong></li>
<li><strong>비혼잡 시간대에 설정 반영 및 트래픽 마이그레이션</strong></li>
</ol>
<hr>
<h3 id="🔚-마무리">🔚 마무리</h3>
<p>Traffic Shaping을 사용하면 대량 메시지 처리 중에도 긴급한 메시지를 빠르게 보낼 수 있고, 전체 처리 효율도 높일 수 있습니다. 특히 OTP나 보안 경고처럼 <strong>시간 민감한 메시지에 최적화된 설계</strong>로, 기존의 Market Throughput보다 훨씬 정밀한 제어가 가능합니다.</p>
<blockquote>
<p><strong>Twilio에서 제공 중인 Public Beta 기능입니다.</strong> 요금 및 사용 관련 자세한 내용은 Twilio 계정 매니저나 고객지원팀에 문의하세요.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Bulk Upsert Consents API (한국어 번역)]]></title>
            <link>https://velog.io/@nova-kim/Bulk-Upsert-Consents-API-%ED%95%9C%EA%B5%AD%EC%96%B4-%EB%B2%88%EC%97%AD-o0mq35sz</link>
            <guid>https://velog.io/@nova-kim/Bulk-Upsert-Consents-API-%ED%95%9C%EA%B5%AD%EC%96%B4-%EB%B2%88%EC%97%AD-o0mq35sz</guid>
            <pubDate>Sat, 10 May 2025 13:43:42 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>원문 링크: <a href="https://www.twilio.com/docs/messaging/consent-api/bulk-upsert">Twilio Docs - Bulk Upsert Consents</a></p>
</blockquote>
<h3 id="📢-소개">📢 소개</h3>
<p><strong>Bulk Upsert Consents API</strong>는 여러 연락처의 동의 상태를 한 번에 등록하거나 갱신할 수 있는 기능입니다. 현재는 <strong>SMS 채널</strong>만 지원하며, <strong>Pilot 단계</strong>에 있습니다. 즉, Twilio는 현재 초기 사용자들의 피드백을 받고 있으며, 이 기능을 미리 사용해보고 의견을 줄 수 있는 기회가 있다는 뜻입니다.</p>
<p>⚠️ <strong>주의:</strong> 이 API는 <strong>HIPAA 인증 서비스가 아닙니다</strong>. 의료 정보를 다루는 시스템에서는 사용하지 마세요.</p>
<hr>
<h3 id="✅-기능-개요">✅ 기능 개요</h3>
<ul>
<li>최대 25개의 <code>consents</code> 객체를 한 요청으로 생성하거나 업데이트 가능</li>
<li>각 요청은 고유한 <code>correlation_id</code>로 구분되어, 개별 응답 확인 가능</li>
<li>실패한 요청은 개별 <code>correlation_id</code>에 에러 메시지와 함께 응답됨</li>
</ul>
<hr>
<h3 id="🔐-제한-사항">🔐 제한 사항</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>제한</th>
</tr>
</thead>
<tbody><tr>
<td>요청 수</td>
<td>분당 100건</td>
</tr>
<tr>
<td>응답 타임아웃</td>
<td>최대 3초 (99%는 1초 이내)</td>
</tr>
</tbody></table>
<hr>
<h3 id="🔄-요청-포맷">🔄 요청 포맷</h3>
<p><strong>POST</strong> <code>https://accounts.twilio.com/v1/Consents/Bulk</code></p>
<p><strong>Content-Type:</strong> <code>application/x-www-form-urlencoded</code></p>
<h4 id="요청-파라미터">요청 파라미터</h4>
<ul>
<li><p><code>items</code> (필수): 연락처의 동의 정보를 담은 객체 배열</p>
<ul>
<li><code>contact_id</code>: E.164 포맷의 전화번호 (예: <code>+19999999991</code>)</li>
<li><code>correlation_id</code>: 32자리 UUID 문자열</li>
<li><code>sender_id</code>: 메시징 서비스 SID 또는 전화번호</li>
<li><code>status</code>: <code>opt-in</code> 또는 <code>opt-out</code></li>
<li><code>source</code>: <code>website</code>, <code>offline</code>, <code>opt-in-message</code>, <code>opt-out-message</code>, <code>others</code></li>
<li><code>date_of_consent</code>: ISO 8601 날짜 문자열 (선택)</li>
</ul>
</li>
</ul>
<h4 id="예시-nodejs">예시 (Node.js)</h4>
<pre><code class="language-js">const twilio = require(&quot;twilio&quot;);
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const client = twilio(accountSid, authToken);

async function createBulkConsents() {
  const bulkConsent = await client.accounts.v1.bulkConsents.create({
    items: [
      {
        contact_id: &quot;+19999999991&quot;,
        correlation_id: &quot;ad388b5a46b33b874b0d41f7226db2ef&quot;,
        sender_id: &quot;MG00000000000000000000000000000000&quot;,
        date_of_consent: &quot;2025-02-28T10:05:27Z&quot;,
        status: &quot;opt-in&quot;,
        source: &quot;website&quot;,
      },
      {
        contact_id: &quot;+19&quot;,
        correlation_id: &quot;02520cfa6c432f0e3ec3a38c122d428d&quot;,
        sender_id: &quot;12345&quot;,
        date_of_consent: &quot;2025-02-25T10:05:27Z&quot;,
        status: &quot;opt-out&quot;,
        source: &quot;opt-out-message&quot;,
      },
    ],
  });

  console.log(bulkConsent.items);
}

createBulkConsents();</code></pre>
<hr>
<h3 id="📥-응답-구조">📥 응답 구조</h3>
<pre><code class="language-json">{
  &quot;items&quot;: [
    {
      &quot;correlation_id&quot;: &quot;ad388b5a46b33b874b0d41f7226db2ef&quot;,
      &quot;error_code&quot;: 0,
      &quot;error_messages&quot;: []
    },
    {
      &quot;correlation_id&quot;: &quot;02520cfa6c432f0e3ec3a38c122d428d&quot;,
      &quot;error_code&quot;: 30646,
      &quot;error_messages&quot;: [
        &quot;INVALID_CONTACT_ID&quot;
      ]
    }
  ]
}</code></pre>
<ul>
<li><code>error_code</code>: 0이면 성공, 그 외에는 실패 코드</li>
<li><code>error_messages</code>: 유효성 검사 실패 시 메시지 포함</li>
</ul>
<hr>
<h3 id="🧾-참고-주요-에러-코드">🧾 참고: 주요 에러 코드</h3>
<ul>
<li><code>30646</code>: 잘못된 <code>contact_id</code>, 누락된 필드 등 유효성 검사 실패</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Build to scale: queueing and latency on Twilio]]></title>
            <link>https://velog.io/@nova-kim/Build-to-scale-queueing-and-latency-on-Twilio-g95uslwa</link>
            <guid>https://velog.io/@nova-kim/Build-to-scale-queueing-and-latency-on-Twilio-g95uslwa</guid>
            <pubDate>Sat, 10 May 2025 13:20:07 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>🔗 원본 문서: <a href="https://www.twilio.com/docs/messaging/guides/build-to-scale-queueing-latency">Build to scale: queueing and latency on Twilio</a></p>
</blockquote>
<hr>
<h3 id="🧨-시나리오-블랙-프라이데이-대란-대비하기">🧨 시나리오: 블랙 프라이데이 대란 대비하기</h3>
<p>11시에 1000개의 상품이 할인 오픈! 이 소식을 오전 10시에 <strong>100만 명</strong>에게 한꺼번에 문자로 알려야 한다면?</p>
<ul>
<li>10시: 프로모션 문자 (링크 포함)</li>
<li>로그인/회원가입 후: OTP 문자 인증</li>
</ul>
<p>이렇게 두 종류의 메시지가 필요하다면, <strong>각 유스케이스마다 별도의 메시징 서비스(Messaging Service)</strong> 를 설정해야 해요.</p>
<hr>
<h3 id="📦-메시징-서비스messaging-service란">📦 메시징 서비스(Messaging Service)란?</h3>
<p>메시징 서비스는 <strong>여러 발신번호(발신자 풀)</strong> 와 <strong>설정값</strong>을 하나의 목적에 맞게 묶은 단위예요. 이를 사용하면:</p>
<ul>
<li><strong>발신자 관리 자동화</strong></li>
<li><strong>Opt-Out 처리</strong></li>
<li><strong>다국가 대응 설정</strong></li>
</ul>
<p>등을 손쉽게 할 수 있어요.</p>
<p>⚠️ 단, <strong>여러 번호를 동시에 사용하는 건 지역 규제에 따라 제한</strong>될 수 있어요. 특히 미국에서는 &quot;스노우슈잉(snowshoeing)&quot;으로 간주될 수 있으니 <a href="https://www.twilio.com/docs/messaging/guidelines/us">Twilio 정책</a> 확인 필수!</p>
<hr>
<h3 id="💡-메시지-발송-흐름-요청-→-발송">💡 메시지 발송 흐름 (요청 → 발송)</h3>
<ol>
<li>메시징 서비스로 API 요청 전송</li>
<li>서비스가 큐에 저장하고 적절한 발신자 선택</li>
<li>통신사(Carrier)로 전달</li>
<li>최종 수신자에게 도착</li>
</ol>
<hr>
<h3 id="✅-유스케이스에-맞게-설계하기">✅ 유스케이스에 맞게 설계하기</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>유스케이스</th>
<th>메시지 수</th>
<th>긴급도</th>
<th>권장 Validity Period</th>
</tr>
</thead>
<tbody><tr>
<td>프로모션</td>
<td>링크 포함 마케팅 메시지</td>
<td>1,000,000</td>
<td>낮음</td>
<td>기본값 (10시간)</td>
</tr>
<tr>
<td>OTP</td>
<td>로그인 인증 코드</td>
<td>약 20,000</td>
<td>높음</td>
<td>2~3분 (예: 120초)</td>
</tr>
</tbody></table>
<hr>
<h3 id="📈-큐-사이즈와-mps-계산법">📈 큐 사이즈와 MPS 계산법</h3>
<ul>
<li><strong>기본 큐 가능 시간</strong>: 10시간 (36,000초)</li>
<li><strong>MPS (Messages Per Second)</strong> = 메시지 수 ÷ 큐 시간</li>
<li><strong>Twilio 권장</strong>: 안전하게 2배 버퍼 적용</li>
</ul>
<h4 id="예시-계산">예시 계산</h4>
<p><strong>프로모션</strong></p>
<ul>
<li>1M ÷ 36,000초 = 27.7 → 버퍼 적용 시 60 MPS 필요</li>
</ul>
<p><strong>OTP</strong></p>
<ul>
<li>20,000 ÷ 180초 = 111 → 버퍼 적용 시 220 MPS 필요</li>
</ul>
<hr>
<h3 id="🛠-메시징-서비스-생성-예시-otp용">🛠 메시징 서비스 생성 예시 (OTP용)</h3>
<pre><code class="language-js">const service = await client.messaging.v1.services.create({
  friendlyName: &quot;My OTP Messaging Service&quot;,
  validityPeriod: 120
});</code></pre>
<hr>
<h3 id="🔢-발신자pool-구성-예시">🔢 발신자(Pool) 구성 예시</h3>
<h4 id="프로모션용">프로모션용</h4>
<table>
<thead>
<tr>
<th>번호 타입</th>
<th>수량</th>
<th>개별 MPS</th>
<th>총 MPS</th>
</tr>
</thead>
<tbody><tr>
<td>숏코드</td>
<td>1</td>
<td>100</td>
<td>100</td>
</tr>
<tr>
<td>영국 장번호</td>
<td>2</td>
<td>10</td>
<td>20</td>
</tr>
<tr>
<td>미국 장번호</td>
<td>10</td>
<td>1</td>
<td>10</td>
</tr>
<tr>
<td>캐나다 장번호</td>
<td>10</td>
<td>1</td>
<td>10</td>
</tr>
<tr>
<td><strong>합계</strong></td>
<td>23</td>
<td></td>
<td><strong>140</strong></td>
</tr>
</tbody></table>
<h4 id="otp용">OTP용</h4>
<table>
<thead>
<tr>
<th>번호 타입</th>
<th>수량</th>
<th>개별 MPS</th>
<th>총 MPS</th>
</tr>
</thead>
<tbody><tr>
<td>숏코드</td>
<td>1</td>
<td>100</td>
<td>100</td>
</tr>
<tr>
<td>톨프리</td>
<td>2</td>
<td>25</td>
<td>50</td>
</tr>
<tr>
<td>영국 장번호</td>
<td>4</td>
<td>10</td>
<td>40</td>
</tr>
<tr>
<td>미국 장번호</td>
<td>10</td>
<td>1</td>
<td>10</td>
</tr>
<tr>
<td>캐나다 장번호</td>
<td>2</td>
<td>10</td>
<td>20</td>
</tr>
<tr>
<td><strong>합계</strong></td>
<td>19</td>
<td></td>
<td><strong>220</strong></td>
</tr>
</tbody></table>
<hr>
<h3 id="⏱-캠페인-타이밍-전략">⏱ 캠페인 타이밍 전략</h3>
<ul>
<li>프로모션 메시지: 오전 중 미리 큐에 올려두기</li>
<li>OTP: 사용자 행동 발생 시 실시간 발송</li>
</ul>
<pre><code class="language-js">const message = await client.messages.create({
  body: &quot;세일 1시간 전! 로그인하세요!&quot;,
  messagingServiceSid: &quot;MGxxx&quot;,
  to: &quot;+821012345678&quot;
});</code></pre>
<hr>
<h3 id="📊-성능-모니터링-messaging-insights">📊 성능 모니터링: Messaging Insights</h3>
<ul>
<li><strong>Latency Report</strong>: 메시지가 Twilio에서 처리되는 시간</li>
<li><strong>Delivery &amp; Errors Report</strong>: 전송 성공률, 실패 원인 파악</li>
<li>각 발신자/서비스/계정 단위로 확인 가능</li>
</ul>
<p>⚠️ 오류 예시: Queue Overflow, 30001 등 → <strong>백오프 + 재시도 로직</strong> 필요</p>
<hr>
<h3 id="🚨-긴급-상황-대응">🚨 긴급 상황 대응</h3>
<p>대규모 장애나 공지 필요 시, 기존 한도 초과될 수 있어요. 이럴 땐 <strong>Twilio 지원팀에 한도 상향 요청</strong>할 수 있어요.</p>
<hr>
<h3 id="📎-참고-리소스">📎 참고 리소스</h3>
<ul>
<li><a href="https://www.twilio.com/docs/messaging/insights">Messaging Insights 사용법</a></li>
<li><a href="https://www.twilio.com/docs/messaging/services">Messaging Service 기능 전체 보기</a></li>
<li><a href="https://www.twilio.com/docs/messaging/tutorials/send-messages-with-messaging-services">Messaging Service 생성 가이드</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Send Messages with Messaging Services]]></title>
            <link>https://velog.io/@nova-kim/Send-Messages-with-Messaging-Services</link>
            <guid>https://velog.io/@nova-kim/Send-Messages-with-Messaging-Services</guid>
            <pubDate>Sat, 10 May 2025 13:16:27 GMT</pubDate>
            <description><![CDATA[<p>[📘 공식 문서 번역 시리즈] Twilio Messaging Services 가이드</p>
<blockquote>
<p>본 문서는 Twilio 공식 문서(<a href="https://www.twilio.com/docs/messaging/tutorials/send-messages-with-messaging-services">원문 링크</a>)를 기반으로 자연스럽고 실무에 맞게 번역한 자료입니다.</p>
</blockquote>
<hr>
<h1 id="twilio-messaging-services로-메시지-보내기">Twilio Messaging Services로 메시지 보내기</h1>
<p>Messaging Service를 사용하면 고객에게 더 나은 SMS 또는 WhatsApp 경험을 제공할 수 있어요. Twilio Console을 통해 발신 번호를 지역화하거나, 여러 발신자에 걸쳐 메시지를 분산하고, 고객과 고정된 번호로 소통할 수도 있죠. 이 가이드는 Messaging Service의 기능과 실제 메시지 전송 방법을 소개합니다.</p>
<hr>
<h2 id="✅-messaging-service를-왜-써야-할까">✅ Messaging Service를 왜 써야 할까?</h2>
<p>고객에게 전 세계적으로 많은 메시지를 보내는 상황이라면 복잡도가 확 올라갑니다. Twilio는 이런 복잡도를 줄이기 위해 Messaging Service라는 개념을 도입했어요. 발신자 관리, 규제 준수, 사용자 경험 일관성까지 모두 커버할 수 있죠.</p>
<p>주요 기능은 다음과 같아요:</p>
<ul>
<li><strong>Sticky Sender</strong>: 동일한 고객에게 항상 같은 발신 번호 사용</li>
<li><strong>Smart Encoding</strong>: Unicode 문자 자동 변환으로 메시지 분할 방지 (비용 절감!)</li>
<li><strong>MMS Converter</strong>: MMS 미지원 국가에 링크 형태로 변환 발송</li>
<li><strong>Advanced Opt-Out</strong>: 고객의 수신 거부 키워드 설정 및 응답 메시지 지정</li>
<li><strong>Sender ID 사전 등록 알림</strong>: 사전 등록이 필요한 국가에서 메시지 발송 시 알림</li>
</ul>
<p>👉 더 많은 기능은 <a href="https://www.twilio.com/docs/messaging/services">Messaging Service Overview</a> 문서 참고!</p>
<hr>
<h2 id="🏗️-messaging-service-생성--구성하기">🏗️ Messaging Service 생성 &amp; 구성하기</h2>
<h3 id="1-서비스-생성">1. 서비스 생성</h3>
<pre><code class="language-js">const twilio = require(&quot;twilio&quot;);
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const client = twilio(accountSid, authToken);

async function createService() {
  const service = await client.messaging.v1.services.create({
    friendlyName: &quot;My First Messaging Service&quot;,
  });
  console.log(service.sid);
}

createService();</code></pre>
<p>서비스 SID(MG로 시작하는 값)를 꼭 기억하세요. 이걸로 나중에 메시지 전송합니다!</p>
<h3 id="2-sms-가능한-번호-구매">2. SMS 가능한 번호 구매</h3>
<p>Twilio Console &gt; Phone Numbers 메뉴에서 SMS 전송이 가능한 번호를 구매하세요. Trial 계정의 경우 미국에 보내려면 Toll-Free 번호가 필요합니다.</p>
<h3 id="3-sender-pool에-번호-추가">3. Sender Pool에 번호 추가</h3>
<pre><code class="language-js">const phoneNumber = await client.messaging.v1
  .services(&quot;MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&quot;)
  .phoneNumbers.create({
    phoneNumberSid: &quot;PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&quot;,
  });</code></pre>
<p>Sender Pool에 번호를 등록해야 메시지 전송이 가능해요!</p>
<hr>
<h2 id="📤-messaging-service로-메시지-전송하기">📤 Messaging Service로 메시지 전송하기</h2>
<pre><code class="language-js">const message = await client.messages.create({
  body: &quot;Hello from your Messaging Service!&quot;,
  messagingServiceSid: &quot;MG9752274e9e519418a7406176694466fa&quot;,
  to: &quot;+15553332222&quot;,
});</code></pre>
<p>발신 번호 대신 <code>messagingServiceSid</code>만 넣으면 Twilio가 자동으로 적절한 번호를 골라줘요.</p>
<hr>
<h2 id="🔎-메시지-상태와-로그-확인">🔎 메시지 상태와 로그 확인</h2>
<ul>
<li>Twilio Console의 <strong>Messaging Insights</strong> 및 <strong>로그</strong> 메뉴에서 상태 확인 가능</li>
<li>메시지가 실패한 경우 <code>status = failed</code>로 표시되며 오류 코드 확인 가능</li>
</ul>
<p>※ MessagingServiceSid를 명시하지 않으면 Service 정보가 로그에 남지 않으니 주의!</p>
<hr>
<h2 id="📈-다음-단계로">📈 다음 단계로!</h2>
<p>이제 메시지 하나 보내봤다면, 그 다음은?</p>
<ul>
<li><a href="https://www.twilio.com/docs/messaging/guides/advanced-opt-out">Advanced Opt-Out 설정하기</a></li>
<li><a href="https://www.twilio.com/docs/whatsapp/quickstart/node">WhatsApp 연동 시작하기</a></li>
<li><a href="https://www.twilio.com/docs/messaging/api/service-resource">Messaging Service REST API 살펴보기</a></li>
</ul>
<hr>
<p>👉 시리즈 전체 보러가기: <a href="https://www.twilio.com/docs/messaging/services">Twilio Messaging 가이드 시리즈</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Twilio Multi-Tenancy 기능 소개 (공식 문서 번역)]]></title>
            <link>https://velog.io/@nova-kim/Twilio-Multi-Tenancy-%EA%B8%B0%EB%8A%A5-%EC%86%8C%EA%B0%9C-%EA%B3%B5%EC%8B%9D-%EB%AC%B8%EC%84%9C-%EB%B2%88%EC%97%AD-msvyzrxs</link>
            <guid>https://velog.io/@nova-kim/Twilio-Multi-Tenancy-%EA%B8%B0%EB%8A%A5-%EC%86%8C%EA%B0%9C-%EA%B3%B5%EC%8B%9D-%EB%AC%B8%EC%84%9C-%EB%B2%88%EC%97%AD-msvyzrxs</guid>
            <pubDate>Sat, 10 May 2025 13:01:52 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>📄 원문 링크: <a href="https://www.twilio.com/docs/messaging/multi-tenancy">Twilio Multi-Tenancy (Public Beta)</a></p>
</blockquote>
<p>Twilio의 <strong>Multi-Tenancy</strong>는 현재 퍼블릭 베타(Public Beta)로 제공 중인 기능으로, <strong>Traffic Optimization Engine</strong>의 하위 기능입니다. 이 기능을 통해 메시지 처리량을 세부적으로 제어하고, 여러 서브 계정(Subaccount) 간에 트래픽 용량을 공정하게 분배할 수 있습니다.</p>
<hr>
<h2 id="🧩-multi-tenancy란">🧩 Multi-Tenancy란?</h2>
<p>Multi-Tenancy를 사용하면 어떤 서브 계정이나 발신자가 전체 트래픽 처리량(throughput)을 독점하는 것을 방지하고, 계층 구조 내에서 다른 계정들도 충분한 처리량을 보장받을 수 있도록 합니다.</p>
<p>Twilio가 서브 계정 간 트래픽을 자동으로 분배하여 수동으로 처리량 설정을 하지 않아도 되며, 각 국가 및 채널(예: SMS, MMS)의 설정에 맞춰 알고리즘적으로 공정하게 처리량이 할당됩니다.</p>
<blockquote>
<p>❗ A2P 10DLC(미국/캐나다) 트래픽에는 적용되지 않습니다.</p>
</blockquote>
<hr>
<h2 id="⚙️-동작-방식">⚙️ 동작 방식</h2>
<ol>
<li><strong>필요할 때</strong> 각 서브 계정에 최소 처리량을 자동 할당합니다.</li>
<li><strong>메시지 처리가 끝나면</strong>, 사용된 처리량은 다시 큐에 있는 다른 서브 계정에 재분배됩니다.</li>
</ol>
<hr>
<h2 id="🔧-구성-옵션-국가별-적용">🔧 구성 옵션 (국가별 적용)</h2>
<h3 id="1-none-미적용">1. <strong>None (미적용)</strong></h3>
<ul>
<li>서브 계정별 보장 처리량 없음</li>
<li>메시지는 하나의 큐에 들어가며, 부모 계정의 최대 처리량만큼 순차적으로 전송됨</li>
</ul>
<h3 id="2-even-균등-분배">2. <strong>Even (균등 분배)</strong></h3>
<ul>
<li>모든 서브 계정에 동일한 처리량 할당</li>
<li>예: 총 100 MPS → 서브 계정 3개 → 각 33.33 MPS</li>
<li>어느 한 큐가 비어 있으면 나머지 큐에 그 처리량이 자동 공유됨</li>
</ul>
<h3 id="3-weighted-가중치-분배">3. <strong>Weighted (가중치 분배)</strong></h3>
<ul>
<li>특정 서브 계정에 더 높은 처리량 할당 가능</li>
<li>예: 서브 계정 3번에 80%, 나머지 2개에 10%씩 분배</li>
<li>비어 있는 큐의 처리량은 동일한 Tier 내에서만 공유됨</li>
</ul>
<hr>
<h2 id="🚀-시작하기">🚀 시작하기</h2>
<ul>
<li>현재 모든 Programmable Messaging 고객에게 <strong>Public Beta</strong>로 제공됨</li>
<li>사용을 원하면 <strong>계정 소유자 또는 Twilio Support</strong>에 문의</li>
</ul>
<hr>
<h2 id="✅-사전-준비사항">✅ 사전 준비사항</h2>
<p>Multi-Tenancy를 활성화하려면, <strong>Market Throughput</strong> 기능이 계정에 먼저 적용되어 있어야 합니다.</p>
<p><a href="https://www.twilio.com/docs/messaging/market-throughput">Market Throughput 가이드 보기</a></p>
<hr>
<h2 id="🛠️-온보딩-단계">🛠️ 온보딩 단계</h2>
<ol>
<li><strong>요청 접수</strong>: Twilio 계정 소유자 또는 Support 팀에 데모 또는 적용 요청</li>
<li><strong>처리량 예측/설정</strong>: 어떤 국가/채널에 어떤 옵션을 적용할지 결정</li>
<li><strong>리뷰</strong>: Twilio가 설정 요청 검토</li>
<li><strong>적용 및 마이그레이션</strong>: 설정 적용 및 오프피크 시간에 트래픽 이관</li>
</ol>
<blockquote>
<p>기존 Twilio 연동에는 별도의 변경이 <strong>필요하지 않습니다.</strong></p>
</blockquote>
<hr>
<h2 id="🧠-정리">🧠 정리</h2>
<table>
<thead>
<tr>
<th>옵션</th>
<th>특징</th>
<th>처리량 제어 방식</th>
</tr>
</thead>
<tbody><tr>
<td>None</td>
<td>미적용</td>
<td>전 서브 계정 공유 큐</td>
</tr>
<tr>
<td>Even</td>
<td>균등 분배</td>
<td>각 서브 계정 동일 처리량 보장</td>
</tr>
<tr>
<td>Weighted</td>
<td>가중치 분배</td>
<td>특정 서브 계정에 우선권 부여</td>
</tr>
</tbody></table>
<hr>
<p>필요에 따라 처리량을 효율적으로 제어하고 싶은 경우, Multi-Tenancy는 훌륭한 선택이 될 수 있어요. 특히 여러 서비스나 브랜드를 하나의 Twilio 계정으로 운영하는 환경에서 유용합니다.</p>
<p>📌 다음 포스트에서는 Market Throughput이나 Traffic Shaping에 대해 다뤄볼게요. 궁금한 거 있음 댓글이나 DM 주세요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Twilio Messaging Services Overview(톺아보기)]]></title>
            <link>https://velog.io/@nova-kim/Twilio-Messaging-Services-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5</link>
            <guid>https://velog.io/@nova-kim/Twilio-Messaging-Services-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5</guid>
            <pubDate>Sat, 10 May 2025 12:56:34 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>📎 본 글은 <a href="https://www.twilio.com/docs/messaging/services">Twilio 공식 문서 - Messaging Services</a>를 참고해 작성된 한글 번역 및 해설입니다.</p>
</blockquote>
<hr>
<h2 id="messaging-services란">Messaging Services란?</h2>
<p>미국이든 전 세계든, 대량의 메시지를 보내기 시작하면 시스템은 금방 복잡해지죠. 이때 Twilio의 <strong>Messaging Services</strong> 기능을 이용하면 발신 번호 관리, 메시지 로그, 기능 설정 등을 깔끔하게 구성할 수 있어요.</p>
<p>Messaging Service는 일종의 <strong>메시지 발신자(번호) 그룹</strong>을 만들고, 이 그룹에 <strong>공통된 기능이나 설정</strong>을 적용할 수 있도록 도와주는 상위 개념이에요. 예를 들어 여러 개의 전화번호, 숏코드(short code), 톨프리 번호를 하나의 서비스에 묶고, 해당 서비스 단위로 기능을 설정하는 식이죠.</p>
<hr>
<h2 id="메시지-전송-messaging-service를-통해-보내기">메시지 전송: Messaging Service를 통해 보내기</h2>
<p>Twilio REST API를 이용해 메시지를 보낼 때, <code>from</code>에 직접 전화번호를 넣는 대신 <code>MessagingServiceSid</code>를 넣으면 Twilio가 해당 서비스에 연결된 발신 번호 중 적절한 번호를 골라 메시지를 보내줍니다.</p>
<pre><code class="language-js">const twilio = require(&quot;twilio&quot;);

const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const client = twilio(accountSid, authToken);

async function createMessage() {
  const message = await client.messages.create({
    body: &quot;Revenge of the Sith was clearly the best of the prequel trilogy.&quot;,
    messagingServiceSid: &quot;MG9752274e9e519418a7406176694466fa&quot;,
    to: &quot;+441632960675&quot;,
  });
  console.log(message.body);
}

createMessage();</code></pre>
<p>이렇게 하면 Twilio가 자동으로 최적의 발신 번호를 선택해서 메시지를 보내요.</p>
<hr>
<h2 id="상태-콜백-status-callback-url">상태 콜백 (Status Callback URL)</h2>
<p>메시지 전송 성공 여부나 에러 등은 Twilio가 비동기적으로 전달해줘요. 이때 콜백 받을 URL은 콘솔에서 설정할 수 있고, API로도 지정 가능해요.</p>
<p><strong>Console 경로:</strong> Messaging Services &gt; Integration &gt; Delivery Status Callback</p>
<hr>
<h2 id="whatsapp-rcs-발신-번호도-추가-가능해요">WhatsApp, RCS 발신 번호도 추가 가능해요!</h2>
<p>Twilio에서 발급받은 WhatsApp 번호나 RCS Sender도 Messaging Service에 추가해서 사용할 수 있어요. 덕분에 WhatsApp 메시지도 동일한 설정과 기능으로 통합 관리할 수 있죠.</p>
<blockquote>
<p>예를 들어 SMS, WhatsApp, Toll-free 번호가 모두 하나의 서비스에 묶이면, 발신자 선택 로직이나 유효기간 설정 등이 한 번에 적용됩니다.</p>
</blockquote>
<hr>
<h2 id="messaging-service의-기본-제공-기능">Messaging Service의 기본 제공 기능</h2>
<p>서비스를 생성하자마자 자동으로 적용되는 주요 기능들이 있어요:</p>
<h3 id="✅-alphanumeric-sender-id-브랜드-이름으로-발송">✅ Alphanumeric Sender ID (브랜드 이름으로 발송)</h3>
<ul>
<li>SMS O / WhatsApp X</li>
<li>예: <code>&quot;YourBrand&quot;</code> 이름으로 메시지 발송 (단, 국가 제한 있음)</li>
</ul>
<h3 id="✅-short-code-reroute">✅ Short Code Reroute</h3>
<ul>
<li>SMS O / WhatsApp X</li>
<li>숏코드가 막힌 경우엔 Twilio가 자동으로 다른 번호로 대체 발송</li>
</ul>
<h3 id="✅-country-code-geomatch">✅ Country Code Geomatch</h3>
<ul>
<li>SMS O / WhatsApp O</li>
<li>수신자의 국가와 동일한 Twilio 번호로 자동 매핑</li>
</ul>
<h3 id="✅-scaler-자동-부하-분산">✅ Scaler (자동 부하 분산)</h3>
<ul>
<li>SMS O / WhatsApp O</li>
<li>서비스에 묶인 여러 발신 번호에 트래픽을 자동 분산시켜줘요</li>
</ul>
<blockquote>
<p>⚠️ 톨프리 번호는 하나만 사용하는 게 좋아요. 여러 개 넣으면 블록될 수 있음!</p>
</blockquote>
<hr>
<h2 id="선택적으로-설정-가능한-고급-기능들">선택적으로 설정 가능한 고급 기능들</h2>
<h3 id="🌟-sticky-sender">🌟 Sticky Sender</h3>
<ul>
<li>항상 같은 수신자에겐 같은 발신 번호로 전송 (신뢰도 증가)</li>
</ul>
<h3 id="🌟-area-code-geomatch-미국캐나다-한정">🌟 Area Code Geomatch (미국/캐나다 한정)</h3>
<ul>
<li>수신자의 지역 번호와 동일한 번호를 우선 사용</li>
</ul>
<h3 id="🌟-validity-period">🌟 Validity Period</h3>
<ul>
<li>메시지가 Twilio 플랫폼 내에서 유효한 시간 설정 (1~36,000초)</li>
</ul>
<h3 id="🌟-smart-encoding">🌟 Smart Encoding</h3>
<ul>
<li>보이지 않는 유니코드 문자 자동 치환 → 메시지 분할 방지</li>
</ul>
<h3 id="🌟-mms-converter">🌟 MMS Converter</h3>
<ul>
<li>MMS가 안 되는 캐리어엔 링크 포함된 SMS로 자동 변환</li>
</ul>
<h3 id="🌟-sender-id-pre-registration-alert">🌟 Sender ID Pre-registration Alert</h3>
<ul>
<li>사전 등록 필수 국가로 발신 시 경고 알림 제공</li>
</ul>
<h3 id="🌟-advanced-opt-out">🌟 Advanced Opt-Out</h3>
<ul>
<li>STOP, HELP 같은 키워드 커스터마이징 + 다국어 설정 가능</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>Twilio Messaging Services는 단순히 메시지를 보내는 수준을 넘어서, 대규모 메시징 환경을 <strong>스마트하게 확장</strong>할 수 있도록 도와주는 툴이에요.</p>
<ul>
<li>발신자 관리 자동화</li>
<li>수신자 경험 통일</li>
<li>지역 기반 발신 번호 자동 선택</li>
<li>메시지 유효성/형식 자동 조정 등</li>
</ul>
<p>이 모든 걸 하나의 서비스 단위로 묶어서 사용할 수 있는 게 진짜 매력입니다. 이제 메시지 하나도 똑똑하게 보내봅시다!</p>
<hr>
<p>📎 원문 링크: <a href="https://www.twilio.com/docs/messaging/services">Twilio Messaging Services 공식 문서</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[📆 Twilio 메시지 예약 전송 완전 정복 (SMS/WhatsApp Scheduling 가이드)]]></title>
            <link>https://velog.io/@nova-kim/Twilio-%EB%A9%94%EC%8B%9C%EC%A7%80-%EC%98%88%EC%95%BD-%EC%A0%84%EC%86%A1-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5-SMSWhatsApp-Scheduling-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
            <guid>https://velog.io/@nova-kim/Twilio-%EB%A9%94%EC%8B%9C%EC%A7%80-%EC%98%88%EC%95%BD-%EC%A0%84%EC%86%A1-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5-SMSWhatsApp-Scheduling-%EA%B0%80%EC%9D%B4%EB%93%9C</guid>
            <pubDate>Sat, 10 May 2025 12:49:46 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/nova-kim/post/88fdfa12-ad41-43e6-90a2-0d7a1af064e3/image.svg" alt=""></p>
<blockquote>
<p>📘 <strong>본 문서는 Twilio 공식 문서인 <a href="https://www.twilio.com/docs/messaging/features/message-scheduling">Message Scheduling</a>를 한국어로 번역/정리한 글입니다.</strong>
최신 정보 및 자세한 기능은 반드시 공식 문서를 참고해주세요.</p>
</blockquote>
<hr>
<p>Twilio의 <strong>Message Scheduling</strong> 기능은 미래의 특정 시점에 SMS, MMS, WhatsApp 메시지를 예약 전송할 수 있는 기능입니다. 이 기능은 <strong>Messaging Service를 통해서만</strong> 사용할 수 있으며, <strong>Engagement Suite</strong>에 포함되어 있습니다.</p>
<hr>
<h2 id="✅-사전-조건">✅ 사전 조건</h2>
<ul>
<li>이미 메시지를 일반적으로 Twilio Messaging Service를 통해 전송할 수 있어야 함</li>
<li>WhatsApp 메시지를 예약하려면 WhatsApp 발신 번호가 Messaging Service의 Sender Pool에 등록되어 있어야 하며, <strong>사전 승인된 템플릿</strong>을 사용해야 함</li>
</ul>
<hr>
<h2 id="✉️-메시지-예약-전송-방식">✉️ 메시지 예약 전송 방식</h2>
<p>Twilio에서 예약 메시지를 생성할 땐, 일반 메시지와 동일하게 <code>Message</code> 리소스를 생성하되,
두 가지 추가 파라미터를 포함해야 합니다:</p>
<table>
<thead>
<tr>
<th>파라미터</th>
<th>필수 값</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>scheduleType</code></td>
<td><code>fixed</code></td>
<td>예약 전송을 의미하는 고정값</td>
</tr>
<tr>
<td><code>sendAt</code></td>
<td>ISO 8601 형식의 날짜 (<code>2025-06-10T15:00:00Z</code>)</td>
<td>메시지를 전송할 시점</td>
</tr>
</tbody></table>
<p>그 외에도 아래 값들이 필요합니다:</p>
<ul>
<li><code>messagingServiceSid</code>: 메시지를 전송할 Messaging Service의 SID</li>
<li><code>body</code> 또는 <code>mediaUrl</code> 또는 <code>contentSid</code></li>
<li><code>to</code>: 수신자 번호 (예: +821012345678) 또는 WhatsApp 주소 (예: <code>whatsapp:+821012345678</code>)</li>
</ul>
<h3 id="🔒-제한사항">🔒 제한사항</h3>
<ul>
<li>메시지는 <strong>전송 5분 전까지</strong>는 예약되어야 함</li>
<li>예약은 <strong>최대 35일 후</strong>까지 가능함</li>
</ul>
<hr>
<h2 id="💻-예제-코드-nodejs">💻 예제 코드 (Node.js)</h2>
<pre><code class="language-js">const twilio = require(&quot;twilio&quot;);
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const client = twilio(accountSid, authToken);

async function createMessage() {
  const message = await client.messages.create({
    body: &quot;이건 예약된 메시지입니다.&quot;,
    messagingServiceSid: &quot;MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&quot;,
    scheduleType: &quot;fixed&quot;,
    sendAt: new Date(&quot;2025-06-10T15:00:00Z&quot;),
    to: &quot;+821012345678&quot;,
  });
  console.log(message.sid);
}

createMessage();</code></pre>
<hr>
<h2 id="📩-twilio-응답-예시">📩 Twilio 응답 예시</h2>
<pre><code class="language-json">{
  &quot;status&quot;: &quot;scheduled&quot;,
  &quot;sid&quot;: &quot;SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&quot;,
  &quot;to&quot;: &quot;+821012345678&quot;,
  &quot;messaging_service_sid&quot;: &quot;MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&quot;
}</code></pre>
<ul>
<li>메시지가 정상 예약되면 HTTP 응답 코드 201과 함께 <code>status: scheduled</code>로 표시됨</li>
<li>메시지가 실패하면 400 오류 발생 (시간 형식 오류 등)</li>
</ul>
<hr>
<h2 id="⚠️-전송-시-실패할-수-있는-경우">⚠️ 전송 시 실패할 수 있는 경우</h2>
<h3 id="1-수신자가-opt-out-한-경우">1. <strong>수신자가 Opt-Out 한 경우</strong></h3>
<p>예약 당시에는 생성되지만, 전송 시점에 해당 사용자가 차단(Opt-Out)했을 경우 메시지는 실패 처리됨 (<code>21610</code>)</p>
<p>→ <strong>메시지 취소 API</strong>를 사용해 사전에 취소 가능</p>
<h3 id="2-whatsapp-템플릿-유효성-실패">2. <strong>WhatsApp 템플릿 유효성 실패</strong></h3>
<p>템플릿은 메시지 생성 시가 아닌, 실제 전송 시점에 유효성 검사를 거침.
허용되지 않은 템플릿이면 전송 실패</p>
<hr>
<h2 id="🛑-예약-메시지-취소">🛑 예약 메시지 취소</h2>
<pre><code class="language-js">async function cancelMessage() {
  const message = await client
    .messages(&quot;SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&quot;)
    .update({ status: &quot;canceled&quot; });
  console.log(message.status); // canceled
}</code></pre>
<ul>
<li><code>status: &quot;canceled&quot;</code> 로 업데이트하면 예약이 취소됨</li>
<li>이때 <code>status callback</code>에는 <code>canceled</code> 이벤트가 전송됨</li>
</ul>
<hr>
<h2 id="💡-유의사항">💡 유의사항</h2>
<ul>
<li>하나의 Twilio 계정(서브계정 포함) 당 최대 <strong>1,000,000개의 예약 메시지</strong> 생성 가능</li>
<li>예약 메시지는 <strong>예약된 순간부터 카운트</strong>됨</li>
</ul>
<hr>
<h2 id="🏷️-관련-문서">🏷️ 관련 문서</h2>
<ul>
<li><a href="https://www.twilio.com/docs/messaging/features/message-scheduling">Twilio Message Scheduling (공식 문서)</a></li>
<li><a href="https://www.twilio.com/docs/sms/api/message-resource">Message Resource API 문서</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPQL UPDATE와 비관적 락, 낙관적 락 차이: 동시성 문제를 실무처럼 고민하기]]></title>
            <link>https://velog.io/@nova-kim/JPQL-UPDATE%EC%99%80-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-%EC%B0%A8%EC%9D%B4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EB%A5%BC-%EC%8B%A4%EB%AC%B4%EC%B2%98%EB%9F%BC-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@nova-kim/JPQL-UPDATE%EC%99%80-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-%EC%B0%A8%EC%9D%B4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EB%A5%BC-%EC%8B%A4%EB%AC%B4%EC%B2%98%EB%9F%BC-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 06 May 2025 07:37:15 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 하다 보면,<br>다른 엔티티의 상태 변화에 따라 특정 값을 함께 갱신해야 하는 요구사항을 종종 마주하게 됩니다.</p>
<p>예를 들어 리뷰가 등록되면 Book의 <code>reviewCount</code>를,<br>댓글이 달리면 Review의 <code>commentCount</code>를 함께 갱신해야 하는 식이죠.</p>
<p>이번 글은 코드잇 스프링 부트캠프에서 실제로 멘토링 중 나왔던 이와 같은 고민에서 출발해,<br><strong>JPQL UPDATE와 락 전략, 그리고 동시성 문제</strong>에 대해 정리해보았습니다.</p>
<hr>
<h2 id="🤔-고민의-시작은-이랬습니다">🤔 고민의 시작은 이랬습니다</h2>
<ul>
<li>Book 엔티티의 <code>reviewCount</code>는 리뷰가 생성될 때마다 어떻게 갱신할까?</li>
<li>Review 엔티티의 <code>commentCount</code>는 댓글이 생성될 때마다 어떻게 관리해야 할까?</li>
</ul>
<p>네모님은 다음 세 가지 방식 사이에서 <strong>동시성과 정합성 측면에서 어떤 전략이 최선일지</strong> 깊이 고민하셨습니다:</p>
<ol>
<li>리뷰 생성 시, 단순히 <code>book.reviewCount++</code>  </li>
<li>리뷰 생성 시, <code>SELECT COUNT(*) FROM Review WHERE book_id = ?</code>로 매번 다시 세기  </li>
<li>JPQL UPDATE를 통해 DB에서 직접 집계 + 갱신</li>
</ol>
<p>그중 네모님은 <strong>3번 방식</strong>, 즉 JPQL UPDATE 쿼리로 reviewCount와 rating을 함께 갱신하는 방식을 선택하셨어요.</p>
<p>구체적으로 락이나 JPQL의 동작 방식에 대해 언급하시진 않았지만,<br>그 선택만으로도 실무적인 고민이 엿보였기 때문에</p>
<p>👉 이 글에서는 그 흐름을 따라<br><strong>락(Lock)</strong>, <strong>JPQL UPDATE의 특성</strong>,  
그리고 <strong>비관적 락과 낙관적 락 중 어떤 전략을 선택할 수 있는지</strong>까지 함께 정리해보고자 합니다.</p>
<hr>
<h2 id="💡-선택한-구현-방식">💡 선택한 구현 방식</h2>
<pre><code class="language-java">@Modifying
@Query(&quot;&quot;&quot;
  UPDATE Book b
  SET 
    b.reviewCount = (SELECT COUNT(r) FROM Review r WHERE r.book.id = :bookId),
    b.rating = (SELECT COALESCE(AVG(r.rating), 0) FROM Review r WHERE r.book.id = :bookId)
  WHERE b.id = :bookId
&quot;&quot;&quot;)
void recalcStats(@Param(&quot;bookId&quot;) UUID bookId);</code></pre>
<ul>
<li><code>@Modifying</code>과 함께 JPQL UPDATE 사용</li>
<li>리뷰 수와 평점을 <strong>한 번의 쿼리로 동시 갱신</strong></li>
<li>이때 발생하는 <strong>락(lock) 동작을 이해하는 것</strong>이 핵심 포인트입니다.</li>
</ul>
<hr>
<h2 id="jpql-update-사용할-때-꼭-알아야-할-점-⚠️-clearautomatically">JPQL UPDATE 사용할 때 꼭 알아야 할 점 ⚠️: clearAutomatically</h2>
<p>그치만 ..!!!
JPQL UPDATE는 JPA의 영속성 컨텍스트를 무시하고 DB에 바로 반영되기 때문에,<br>같은 트랜잭션 내에서 <strong>조회 결과가 최신 값과 불일치</strong>할 수 있습니다.</p>
<p>이 문제를 방지하려면 <code>@Modifying(clearAutomatically = true)</code> 옵션을 반드시 사용하는 것이 좋아요.</p>
<pre><code class="language-java">@Modifying(clearAutomatically = true)
@Query(&quot;UPDATE Book b SET b.reviewCount = :count WHERE b.id = :bookId&quot;)
void updateCount(@Param(&quot;count&quot;) long count, @Param(&quot;bookId&quot;) UUID bookId);</code></pre>
<p>→ <a href="https://rudaks.tistory.com/entry/JPA%EC%97%90%EC%84%9C-Modifying%EC%9D%98-clearAutomatically%EA%B0%80-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%9C%EA%B0%80">관련 설명 자세히 보기</a></p>
<hr>
<h2 id="jpql-update는-락🔒을-건다-yes-비관적-락🔒입니다">JPQL UPDATE는 락🔒을 건다? Yes, 비관적 락🔒입니다</h2>
<p>JPQL로 직접 UPDATE를 수행하면, JPA는 해당 row에 대해 <strong>쓰기 락(write lock)</strong>을 요청해요.<br>이는 DB 차원에서 <strong>다른 트랜잭션의 접근을 차단</strong>하는 <strong>비관적 락</strong>(Pessimistic Lock)의 대표적인 예시입니다.</p>
<h3 id="락-전략-비교">락 전략 비교</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>비관적 락 (Pessimistic)</th>
<th>낙관적 락 (Optimistic)</th>
</tr>
</thead>
<tbody><tr>
<td>전략</td>
<td>&quot;다른 트랜잭션이 수정할 수도 있으니, 미리 잠그자&quot;(부정핑)</td>
<td>&quot;설마 동시에 수정하겠어? 나중에 충돌 검사하자&quot;(긍정?핑)</td>
</tr>
<tr>
<td>구현 방식</td>
<td>DB 레벨 락 (예: SELECT FOR UPDATE, JPQL UPDATE)</td>
<td>버전 필드(<code>@Version</code>) 기반 충돌 체크</td>
</tr>
<tr>
<td>사용 예</td>
<td>JPQL UPDATE, SELECT FOR UPDATE</td>
<td>엔티티 merge 시 버전 체크</td>
</tr>
<tr>
<td>장점</td>
<td>정합성 강력 보장</td>
<td>성능 우수, 락 없음</td>
</tr>
<tr>
<td>단점</td>
<td>성능 저하, 블로킹 발생 가능</td>
<td>충돌 시 예외, 로직 복잡도 증가</td>
</tr>
</tbody></table>
<hr>
<h2 id="락-전략-시각화-mermaid">락 전략 시각화 (Mermaid)</h2>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/49e0ba16-0ff4-4e01-a870-ca0f4965ca17/image.png" alt="락 전략"></p>
<pre><code class="language-mermaid">graph TD
    A[리뷰 생성 요청] --&gt; B{락 전략 선택}
    B --&gt;|비관적 락| C[JPQL UPDATE → row 잠금]
    B --&gt;|낙관적 락| D[@Version 기반 충돌 검사]
    C --&gt; E[동시 수정 방지, 성능 부담]
    D --&gt; F[충돌 시 예외 발생, 성능 우위]</code></pre>
<hr>
<h2 id="네모님의-선택-실무에서도-충분히-고려할-수-있는-접근이었습니다">네모님의 선택, 실무에서도 충분히 고려할 수 있는 접근이었습니다</h2>
<p>리뷰 생성 시 <code>book.reviewCount</code>와 <code>rating</code>을 어떻게 갱신할지 고민하던 네모님은,<br><strong>JPQL UPDATE 쿼리</strong>를 통해 두 값을 한 번에 갱신하는 방법을 선택하셨어요.</p>
<p>이 방식이 야무졌던 이유는 다음과 같아요:</p>
<ul>
<li>리뷰는 자주 생성되고,</li>
<li>Book은 여러 사용자가 동시에 접근할 가능성이 높으며,</li>
<li>단순 <code>++</code> 연산은 동시성 이슈에 쉽게 노출될 수 있기 때문이죠.</li>
</ul>
<p>복잡한 조건을 고려한 결정이라기보단, <strong>현실적인 상황에서 자연스럽게 나온 선택</strong>이었지만,<br>결과적으로는 실무에서도 자주 사용하는 안전한 방식과 맞닿아 있었습니다.</p>
<p>이 선택에 대해서는 9팀 멘토링 시간에 직접 들을 수 있었고,<br>그때 떠올랐던 기술적 배경과 함께 알아두면 좋을 내용을<br><strong>큐레이션 형식으로 정리해보면 좋겠다</strong>는 생각에 이 글을 작성하게 되었습니다.</p>
<hr>
<h2 id="💬-꼭-jpql-update를-써야-할까-비관적-락은-jpa에서도-가능해요">💬 꼭 JPQL UPDATE를 써야 할까? 비관적 락은 JPA에서도 가능해요</h2>
<p>한편, 꼭 JPQL UPDATE를 쓰지 않아도 JPA에서 <strong>비관적 락</strong>을 명시적으로 걸 수 있는 방법이 있습니다.</p>
<p>예를 들어 <code>findById</code>와 함께 <code>PESSIMISTIC_WRITE</code>를 사용하면,<br>JPQL 없이도 아래처럼 트랜잭션 레벨에서 락을 적용할 수 있어요:</p>
<pre><code class="language-java">Book book = em.find(Book.class, bookId, LockModeType.PESSIMISTIC_WRITE);
// 이후 book.reviewCount = ... 로직 수행</code></pre>
<p>이 방식은 DB 락을 안전하게 걸면서도 <strong>엔티티를 수정하는 흐름을 그대로 유지</strong>할 수 있어,<br>JPQL UPDATE보다 더 유연하고 타입 안전한 구조로 이어질 수 있습니다.<br>→ <a href="https://pixx.tistory.com/351">관련 예제 보기</a></p>
<hr>
<h2 id="🔗-참고-자료">🔗 참고 자료</h2>
<ul>
<li><a href="https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#locking">공식 Hibernate User Guide - Locking</a></li>
<li><a href="https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.modifying-queries">Spring Data JPA - Modifying Queries</a></li>
<li><a href="https://rudaks.tistory.com/entry/JPA%EC%97%90%EC%84%9C-Modifying%EC%9D%98-clearAutomatically%EA%B0%80-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%9C%EA%B0%80">rudaks 블로그 - JPQL @Modifying의 clearAutomatically가 필요한 이유</a></li>
<li><a href="https://www.baeldung.com/jpa-optimistic-locking">Baeldung - Optimistic Locking in JPA</a></li>
<li><a href="https://www.baeldung.com/jpa-pessimistic-locking">Baeldung - JPA에서 비관적 락 적용하기</a></li>
<li><a href="https://pixx.tistory.com/351">pixx 블로그 - JPA에서의 비관적 락 사용 예제</a></li>
</ul>
<hr>
<h2 id="마무리하며">마무리하며</h2>
<ul>
<li>JPQL UPDATE는 <strong>비관적 락</strong>을 유발합니다</li>
<li><code>reviewCount++</code>처럼 단순히 값을 증가시키는 방식은<br><strong>여러 트랜잭션이 동시에 접근할 경우, 마지막 값만 반영되는 문제</strong>가 발생할 수 있습니다<br>→ 즉, <strong>중복 업데이트나 값 손실</strong>로 이어질 수 있어 동시성 충돌에 매우 취약합니다</li>
<li>동시성이 중요한 필드라면 <strong>쿼리 기반 갱신</strong>과 <strong>락 전략 고려</strong>가 필요합니다</li>
<li>실무에서는 <strong>데이터 특성과 트래픽 패턴</strong>을 고려해 적절한 락을 선택해야 합니다</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[RateLimit 적용기 — 선 넘는 호출, 선 긋는 Redis 설계 (항해 시네마 Log #4)]]></title>
            <link>https://velog.io/@nova-kim/RateLimit-%EC%A0%81%EC%9A%A9%EA%B8%B0-%EC%84%A0-%EB%84%98%EB%8A%94-%ED%98%B8%EC%B6%9C-%EC%84%A0-%EA%B8%8B%EB%8A%94-Redis-%EC%84%A4%EA%B3%84-%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-Log-4</link>
            <guid>https://velog.io/@nova-kim/RateLimit-%EC%A0%81%EC%9A%A9%EA%B8%B0-%EC%84%A0-%EB%84%98%EB%8A%94-%ED%98%B8%EC%B6%9C-%EC%84%A0-%EA%B8%8B%EB%8A%94-Redis-%EC%84%A4%EA%B3%84-%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-Log-4</guid>
            <pubDate>Tue, 15 Apr 2025 17:23:01 GMT</pubDate>
            <description><![CDATA[<h2 id="4주차-개요">4주차 개요</h2>
<p>4주차는 항해 시네마 프로젝트의 마지막 주차였습니다.</p>
<p>이번 주의 주제는 바로 —<br><strong>&quot;RateLimit 기능 구현&quot;</strong>  </p>
<p>실제 서비스에 가까운 환경에서 다음과 같은 제한 정책을 Redis로 구현하는 것이 핵심이었어요:</p>
<ul>
<li><strong>API 호출 수 제한</strong></li>
<li><strong>예약 중복 제한</strong></li>
<li><strong>분산 환경에서의 요청 제어</strong></li>
</ul>
<blockquote>
<p>🧱 과부하 트래픽에도 무너지지 않는 구조,<br>우리가 직접 설계해 본 시간입니다.</p>
</blockquote>
<p>또한 이번 주는 단순한 기능 구현을 넘어,<br><strong>항해라는 브랜드와 함께한 성장 여정</strong>을 되돌아보는 시간이기도 했습니다.</p>
<hr>
<h2 id="1-ratelimit은-왜-필요할까">1. RateLimit은 왜 필요할까?</h2>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/7c75d77d-e770-4f3c-abfc-54710262b029/image.png" alt=""></p>
<ul>
<li>실제 서비스에서 무제한 호출은 곧 <strong>서버 리소스 고갈, 시스템 장애</strong>로 이어질 수 있겠죠.</li>
<li>인증 여부와 관계없이, 우리는 클라이언트의 과도한 요청을 제어해줘야만 해요.</li>
<li>특히 <strong>분산 환경</strong>에서의 RateLimit은 <strong>원자성과 TTL 관리, 일관성 보장</strong>이라는 도전과제를 동반합니다.</li>
</ul>
<blockquote>
<p>🎯 <strong>이번 주의 기술 목표</strong></p>
<ul>
<li>IP 기반의 RateLimit 정책을 Redis로 구현</li>
<li>API 호출 및 예약에 각각 다른 제한 적용</li>
<li>테스트와 AOP 적용을 통해 선언적이고 검증 가능한 구조로 설계</li>
</ul>
</blockquote>
<hr>
<h2 id="2-어떻게-구현할까-설계-방향부터-고민하기">2. 어떻게 구현할까? 설계 방향부터 고민하기</h2>
<p>RateLimit 기능은 단순히 요청을 막는 로직이 아닙니다.<br><strong>서비스의 안정성을 유지하면서도, 개발자가 관리하기 쉬운 구조</strong>로 설계되어야 하죠.</p>
<p>그래서 이번 구현에서는 아래 세 가지 원칙을 기준 삼아 설계했어요:</p>
<ul>
<li><strong>선언적 구조</strong> : 요청 제한을 각 컨트롤러 메서드 위에서 바로 볼 수 있어야 햇!</li>
<li><strong>관심사의 분리</strong> : 비즈니스 로직과 RateLimit 로직을 철저히 분리하자!</li>
<li><strong>확장 가능한 구조</strong> : 추후 다양한 타입의 제한이나 정책 변경에도 유연하게 대응 가능하게!</li>
</ul>
<p>이를 위해 사용한 방법은 바로 <code>@RateLimit</code> 애노테이션 + AOP 기반의 분리였습니다.</p>
<hr>
<h2 id="3-기술-구현">3. 기술 구현</h2>
<h3 id="3-1-ratelimit-애노테이션과-aop-기반-제어">3-1. @RateLimit 애노테이션과 AOP 기반 제어</h3>
<p>RateLimit을 적용할 메서드에 애노테이션만 붙이면, 자동으로 제한 로직이 적용되는 구조를 설계했습니다.
이런 선언적인 방식은 컨트롤러의 가독성을 높이고, 로직 재사용에도 큰 장점이 있죠.</p>
<pre><code class="language-kotlin">@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RateLimit(
    val type: RateLimitType
)

enum class RateLimitType {
    API_CALL,    // 분당 50회 제한
    BOOKING      // 5분당 1회 제한
}</code></pre>
<p>그리고 AOP를 활용해 실제 실행 흐름에서 제한 여부를 체크하도록 구성했습니다.
이 방식은 실제 API 동작 흐름을 해치지 않으면서도, 일관된 방식으로 제한 로직을 삽입할 수 있다는 장점이 있어요.</p>
<pre><code class="language-kotlin">@Around(&quot;@annotation(rateLimit)&quot;)
fun rateLimitCheck(joinPoint: ProceedingJoinPoint, rateLimit: RateLimit): Any {
    val clientIp = request.remoteAddr
    ... // 제한 확인 후 예외 발생 또는 proceed()
}</code></pre>
<p>이처럼 관심사 분리(SOC) 원칙을 잘 살린 구조는 리뷰에서도 긍정적인 평가를 받을 수 있었습니다.</p>
<blockquote>
<p>💬 <strong>리뷰 피드백</strong>  </p>
<ul>
<li>AOP 기반 구조와 IP 차단 방식이 잘 설계되었다는 리뷰를 받았습니다.☺️</li>
<li>예외 메시지는 상수로 분리하면 유지보수에 더 유리하다는 피드백도 함께 받았습니다.
<img src="https://velog.velcdn.com/images/nova-kim/post/f6014f9b-26a7-4730-a9c2-17a9ddf40824/image.png" alt=""></li>
</ul>
</blockquote>
<h3 id="3-2-redis-기반-ratelimiter-구현---lua-script-방식">3-2. Redis 기반 RateLimiter 구현 - Lua Script 방식</h3>
<p>처음에는 Redis의 AtomicLong을 활용해 간단한 방식으로 RateLimit을 구현했어요.
하지만 테스트 과정에서 Race Condition 문제와 제한 정확성 이슈가 발생했고,
결국 더 안정적이고 원자적인 방식을 고민하게 되었습니다.</p>
<p>그 대안으로 선택한 방식이 바로 Lua Script 기반 구현입니다.</p>
<pre><code class="language-lua">local key = KEYS[1]
local limit = tonumber(ARGV[1])
...
local current = redis.call(&#39;incr&#39;, key)
if current == 1 then
  redis.call(&#39;expire&#39;, key, 60)
end
...</code></pre>
<p>이 방식은 Redis 내부에서 스크립트를 단일 트랜잭션처럼 처리하기 때문에,
동시성 문제 없이 안전하게 요청 횟수를 제어할 수 있어요.
또한 TTL(만료 시간)을 명확하게 설정할 수 있어 자동화된 리셋도 가능했습니다.</p>
<blockquote>
<p>💬 <strong>리뷰 피드백</strong>  </p>
<ul>
<li>Lua Script를 통한 원자성 확보, TTL 처리 방식 모두 잘 설계되었다는 긍정적인 피드백을 받았습니다.
<img src="https://velog.velcdn.com/images/nova-kim/post/c1bda9f4-af8a-4291-bac1-76fc9224736a/image.png" alt=""></li>
</ul>
</blockquote>
<h3 id="3-3-controller-적용-예시">3-3. Controller 적용 예시</h3>
<p>RateLimit 로직을 설계하고 검증한 이후, 이제 실제 서비스 흐름에 적용할 차례입니다.</p>
<p>@RateLimit 애노테이션을 컨트롤러 메서드에 붙이기만 하면
유형별 제한 정책이 자동 적용되는 구조가 완성돼요.</p>
<p>API 유형에 따라 API 호출 제한(API_CALL),
예약 중복 제한(BOOKING) 으로 나눠 선언적으로 적용했습니다.</p>
<pre><code class="language-kotlin">@RateLimit(type = RateLimitType.API_CALL)
@GetMapping(&quot;/api/v1/movies&quot;)
fun getMovies() = ...

@RateLimit(type = RateLimitType.BOOKING)
@PostMapping(&quot;/api/v1/movies/{scheduleId}/reservations&quot;)
fun reserve(...) = ...</code></pre>
<h3 id="3-4-테스트-구성과-시나리오-검증">3-4. 테스트 구성과 시나리오 검증</h3>
<p>로직을 구현했다면, 검증은 필수겠죠.</p>
<p>RateLimit 기능의 정확한 동작을 검증하기 위해,<br><strong>AOP 레벨의 흐름 테스트부터 Controller 단위의 통합 테스트까지</strong> 다층적으로 테스트를 구성했습니다.</p>
<h3 id="✅-단위-테스트--ratelimitaspect-내부-흐름-검증">✅ 단위 테스트 : RateLimitAspect 내부 흐름 검증</h3>
<p>AOP 로직이 잘 작동하는지, 제한 조건에 따라 예외가 발생하는지를 검증합니다.</p>
<pre><code class="language-kotlin">@Test
fun `1분 내 API 호출이 50회를 초과하면 1시간 동안 차단된다`() {
    // given
    whenever(rateLimit.type).thenReturn(RateLimitType.API_CALL)
    whenever(guavaRateLimiter.isBlocked(testIp)).thenReturn(false)
    whenever(guavaRateLimiter.checkApiRateLimit(testIp))
        .thenReturn(true)  // 50회까지는 허용
        .thenReturn(false) // 51번째는 차단

    // when
    repeat(50) { rateLimitAspect.rateLimitCheck(joinPoint, rateLimit) }

    // then - 51번째 요청에서 예외 발생
    assertThrows&lt;RateLimitExceededException&gt; {
        rateLimitAspect.rateLimitCheck(joinPoint, rateLimit)
    }
}</code></pre>
<blockquote>
<p>💬 <strong>리뷰 피드백</strong>  </p>
<ul>
<li>핵심 흐름(정상 요청 → 제한 초과 → 예외 발생)이 <strong>명확히 드러난 테스트 설계</strong>라는 평가를 받았어요.</li>
<li>다만 mock 기반 테스트만으로는 Redis 동작을 완전히 검증할 수 없으므로,<br><strong>실제 환경 기반 테스트의 보완이 필요하다</strong>는 코멘트도 함께 받았습니다.
<img src="https://velog.velcdn.com/images/nova-kim/post/417d10ca-3a80-4b7f-a612-259420979fc7/image.png" alt=""></li>
</ul>
</blockquote>
<h3 id="✅-통합-테스트--api-호출-흐름-검증">✅ 통합 테스트 : API 호출 흐름 검증</h3>
<p>Spring의 MockMvc를 사용해, 실제 API 호출을 반복하며 RateLimit 정책이 정확히 반영되는지 검증했습니다.</p>
<pre><code class="language-kotlin">@Test
fun `영화 목록 API를 51회 호출하면 마지막 요청은 차단된다`() {
    repeat(50) {
        mockMvc.perform(get(&quot;/api/v1/movies&quot;))
            .andExpect(status().isOk)
    }

    mockMvc.perform(get(&quot;/api/v1/movies&quot;))
        .andExpect(status().isTooManyRequests)
        .andExpect(jsonPath(&quot;$.message&quot;).value(&quot;분당 최대 50회 요청 가능합니다.&quot;))
}</code></pre>
<p>이 테스트는 정책상 허용된 50회까지는 OK 응답을 받고,
51번째 요청부터는 429 Too Many Requests 응답을 받도록 검증합니다.</p>
<blockquote>
<p>💬 <strong>리뷰 피드백</strong><br>Presentation Layer에서의 흐름 테스트가 잘 구성되어 있다는 피드백을 받았습니다.
<img src="https://velog.velcdn.com/images/nova-kim/post/3c10fd7d-aff8-440a-bc0d-b48bdf0e81c0/image.png" alt=""></p>
</blockquote>
<blockquote>
<p>✍️ <strong>개인 회고</strong>
실제 호출 흐름을 따라가는 테스트는 단순한 단위 검증보다
“제한 정책이 진짜로 적용되고 있는지”를 입증하는 데에 더 도움이 된다고 느꼈어요.
특히 이와 같은 흐름 검증은 사용자 경험 측면에서도 중요한 포인트라고 생각합니다.</p>
</blockquote>
<hr>
<h2 id="4-이번-주를-정리하며--실전-피드백과-회고">4. 이번 주를 정리하며 — 실전 피드백과 회고</h2>
<blockquote>
<p>🎉 <strong>Best Practice 선정 소식!</strong>
이번 주 구현 내용은 <a href="https://hh-skillup.oopy.io/">항해 단기 스킬업</a> Slack에서 Best Practice 사례로도 선정되었어요.
(Guava와 Redis를 활용한 RateLimit 설계 및 테스트 구성이 특히 좋은 평가를 받았습니다.)<img src="https://velog.velcdn.com/images/nova-kim/post/1c76e40b-e489-4512-ba2f-c1bc70d60021/image.png" alt=""></p>
</blockquote>
<h3 id="🔍-리뷰어-코멘트-요약">🔍 리뷰어 코멘트 요약</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>피드백 요약</th>
</tr>
</thead>
<tbody><tr>
<td>RateLimit 적용</td>
<td>AOP 방식과 IP 기반 제한 구조, 잘 설계됨 👍</td>
</tr>
<tr>
<td>예외 처리</td>
<td><code>@ResponseStatus(429)</code>와 명확한 메시지, UX에 도움 👏</td>
</tr>
<tr>
<td>매직넘버</td>
<td>상수화 필요 (ex: 50회 제한, TTL 등) 🧂</td>
</tr>
<tr>
<td>테스트</td>
<td>통합 테스트 우수, 단위 테스트 보완 필요 🔧</td>
</tr>
<tr>
<td>Lua Script 사용</td>
<td>원자성 보장 + TTL 관리 측면에서 적절한 선택 👌</td>
</tr>
</tbody></table>
<p>이번 피드백을 통해 실무에서도 중요시되는 <strong>“구조의 명확함”</strong>과  
<strong>테스트의 실용적 가치</strong>에 대해 다시금 체감할 수 있었습니다.</p>
<p>특히 <strong>“예외 메시지의 상수화”</strong>나 <strong>“제한 정책의 표현 방식 개선”</strong>처럼, 
사소해보이지만 유지보수성과 직결되는 포인트들을 직접 느낄 수 있었어요.</p>
<p>📎 자세한 구현 및 리뷰 코멘트는 <a href="https://github.com/hanghae-skillup/redis_2nd/pull/71">4주차 PR - RateLimit 기능 구현</a>에서 확인해보실 수 있어요.</p>
<hr>
<h2 id="시리즈-돌아보기">시리즈 돌아보기</h2>
<p>👉 지난 글 보기: <a href="https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-3-%EB%9D%BD%EC%9D%84-%EC%8D%BC%EC%A7%80%EB%A7%8C-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%8A%94-%EB%82%98%EB%9D%BD#%EC%8B%9C%EB%A6%AC%EC%A6%88-%EB%8F%8C%EC%95%84%EB%B3%B4%EA%B8%B0">항해 시네마 개발 Log📄 #3 : 락을 썼지만, 테스트는 나락...</a><br>👉 첫 글 보기: <a href="https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-1-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EC%84%A4%EA%B3%84-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%A0%87%EA%B2%8C-%ED%92%80%EC%97%88%EB%8B%A4">멀티 모듈 설계, 이렇게 고민하고 이렇게 풀었다</a></p>
<hr>
<h2 id="마무리하며">마무리하며</h2>
<p><a href="https://hanghae99.spartacodingclub.kr/plus/be">항해 플러스 백엔드 과정</a>에 이어<br>이번 <a href="https://hh-skillup.oopy.io/">단기 스킬업 과정</a>까지 —<br>저는 두 번의 항해를 무사히 완주했습니다.</p>
<p><a href="https://hanghae99.spartacodingclub.kr/">항해</a>는 언제나 <strong>단순한 구현을 넘어, 더 나은 방향을 제시해주는 공간</strong>이었어요.<br>이번 과정에서도 멋쟁이 코치님들의 깊이 있는 리뷰 덕분에<br>기술뿐 아니라 <strong>설계, 테스트, 구조적인 시야까지 함께 성장</strong>할 수 있었습니다.</p>
<p>그리고 무엇보다 —<br>제가 <strong><a href="https://hanghae99.spartacodingclub.kr/">항해</a>를 진심으로 애정하게 된 이유는</strong><br>개발을 바라보는 시야가 정말 넓어졌다는 점이에요.  </p>
<p>이제는 &quot;어떻게 만들까&quot;를 넘어서,<br><strong>&quot;어디로 가볼까?&quot;</strong>, <strong>&quot;무엇을 시도해볼까?&quot;</strong><br>더 많은 가능성을 열어두고 고민할 수 있게 되었어요.</p>
<p>그게 저는 정말, 너무 좋습니다. 😊</p>
<hr>
<p>다음엔 번외편으로,<br><strong>Optimistic Lock 재도전</strong>, <strong>Redis 설정 후 AOP 기반 Distributed Lock 적용</strong>, <strong>테스트 커버리지 개선</strong>과 같은 이야기들도 기록해보려 합니다.</p>
<p>그럼, 진짜 마지막 여정에서 또 만나요 ⛵️</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[트랜잭션과 락, 그리고 테스트 나락까지 (항해 시네마 개발 Log #3)]]></title>
            <link>https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-3-%EB%9D%BD%EC%9D%84-%EC%8D%BC%EC%A7%80%EB%A7%8C-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%8A%94-%EB%82%98%EB%9D%BD</link>
            <guid>https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-3-%EB%9D%BD%EC%9D%84-%EC%8D%BC%EC%A7%80%EB%A7%8C-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%8A%94-%EB%82%98%EB%9D%BD</guid>
            <pubDate>Sat, 05 Apr 2025 15:30:31 GMT</pubDate>
            <description><![CDATA[<h2 id="3주차-개요">3주차 개요</h2>
<p>3주차는 <strong>동시성 제어와 락 구현</strong>이라는 무게감 있는 주제를 마주한 주차였습니다.</p>
<p>하지만 현실은 언제나 시련을 동반하죠. 요구사항은 <code>Pessimistic → Optimistic → Distributed → 함수형 락</code>까지 구현하는 것이었지만, 결국 비관적 락에서 멈춰버렸습니다. 😇</p>
<p>그럼에도 불구하고, 동시성 제어의 핵심 원리와 Pessimistic Lock의 실전 적용 경험, 테스트 설계와 삽질 기록까지 모두 정리해보겠어요.</p>
<hr>
<h2 id="3주차-핵심-목표-및-달성-현황">3주차 핵심 목표 및 달성 현황</h2>
<table>
<thead>
<tr>
<th>목표 항목</th>
<th>구현 여부</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>예약 API 구현</td>
<td>✅ 완료</td>
<td>기본 예약 로직, 좌석 연속성 검증 포함</td>
</tr>
<tr>
<td>Pessimistic Lock 적용</td>
<td>✅ 완료</td>
<td>테스트 코드로 동시성 제어 검증</td>
</tr>
<tr>
<td>Optimistic Lock 적용</td>
<td>❌ 미완료</td>
<td>구현 시도 전 테스트 실패로 보류</td>
</tr>
<tr>
<td>AOP 기반 Distributed Lock 적용</td>
<td>❌ 미완료</td>
<td>Redis 설정 이전 단계에서 보류</td>
</tr>
<tr>
<td>함수형 Distributed Lock 전환</td>
<td>❌ 미완료</td>
<td>위 항목 미완료로 자연히 미진행</td>
</tr>
</tbody></table>
<blockquote>
<p>📌 과정이 끝난 시점인 5주차에서는 미완료 항목들을 중심으로 구현을 마무리할 계획입니다.</p>
</blockquote>
<hr>
<h2 id="1-예약-api-구현과-동시성-문제의-본질">1. 예약 API 구현과 동시성 문제의 본질</h2>
<h3 id="1-1-왜-동시성-제어가-필요한가">1-1. 왜 동시성 제어가 필요한가?</h3>
<ul>
<li>예매 시스템에서 <strong>좌석은 공유 자원</strong>입니다.</li>
<li>다수의 사용자가 동시에 특정 좌석을 예매하려는 경우 <strong>데이터 무결성</strong> 문제가 발생할 수 있죠.</li>
<li>실무 사례 참고: T-money Go 앱도 <strong>클라이언트 단 선점 처리</strong>로 동시성 이슈를 방지한다고 합니다.</li>
</ul>
<blockquote>
<p>🎯 <strong>목표</strong>: 서버 사이드에서 이러한 동시성 이슈를 <strong>트랜잭션과 락을 활용해</strong> 제어할 수 있을지 직접 구현해봅니다.</p>
</blockquote>
<p>트랜잭션 경계 안에서 데이터 정합성을 보장하기 위해, 가장 먼저 적용한 방법은 Pessimistic Lock이었습니다.</p>
<h3 id="1-2-pessimistic-lock으로-해결한-첫-단계">1-2. Pessimistic Lock으로 해결한 첫 단계</h3>
<pre><code class="language-kotlin">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;&quot;&quot;
    SELECT r FROM Reservation r
    WHERE r.schedule.id = :scheduleId
    AND r.seat.id IN :seatIds
&quot;&quot;&quot;)
fun findAllByScheduleIdAndSeatIdInWithPessimisticLock(...)</code></pre>
<ul>
<li>Critical Section에 대해 락을 설정하여 경쟁 조건을 방지했어요.</li>
<li>데이터 정합성 보장에 강력하지만, 동시성 성능 저하 우려가 돼요🤔</li>
</ul>
<h3 id="1-2-테스트-코드-설계">1-2. 테스트 코드 설계</h3>
<pre><code class="language-kotlin">@Test
    fun `동시 예약 시도시 하나만 성공하고 나머지는 실패해야 한다`() {
        // given
        val threadCount = 5
        val executorService = Executors.newFixedThreadPool(threadCount)
        val latch = CountDownLatch(threadCount)
        val results = mutableMapOf&lt;String, Throwable?&gt;()

        // when
        repeat(threadCount) { index -&gt;
            executorService.submit {
                try {
                    val userId = &quot;user-$index&quot;
                    val request = ReservationRequest(
                        scheduleId = schedule.id!!,
                        seatIds = listOf(seats.first().id!!)
                    )
                    reservationFacade.reserve(request, userId)
                    results[userId] = null // 성공
                } catch (e: Exception) {
                    results[&quot;user-$index&quot;] = e
                } finally {
                    latch.countDown()
                }
            }
        }

        // then
        latch.await(10, TimeUnit.SECONDS)
        executorService.shutdown()

        val successCount = results.count { it.value == null }
        val lockFailureCount = results.count { it.value is PessimisticLockingFailureException }

        assertEquals(1, successCount, &quot;하나의 예약만 성공해야 합니다&quot;)
        assertEquals(threadCount - 1, lockFailureCount, &quot;나머지는 락 획득 실패로 실패해야 합니다&quot;)
    }</code></pre>
<ul>
<li>동시성 상황을 시뮬레이션하는 구조화된 테스트 코드 구성</li>
<li>하지만 문제 발생…</li>
</ul>
<hr>
<h2 id="2-테스트는-나락으로-실패의-기록🏳️">2. 테스트는 나락으로… 실패의 기록🏳️</h2>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/dd725c0a-31ae-4ad9-97f7-3bc0de83d073/image.png" alt=""></p>
<h3 id="2-1-testcontainers-네가-문제인거니">2-1. TestContainers, 네가 문제인거니…</h3>
<p>처음에는 테스트가 전혀 실행되지 않아 의아했지만, 로그를 꼼꼼히 들여다본 끝에 다음과 같은 에러 메시지를 발견할 수 있었습니다.
<img src="https://velog.velcdn.com/images/nova-kim/post/e8023fad-0c54-4beb-ad1c-76738de3dc15/image.png" alt=""></p>
<p>처음엔 테이블 자체가 없다는 뜻인가 싶었지만, 에러를 하나씩 없애보다보니
진짜 문제는 바로…</p>
<blockquote>
<p>💣 <strong>엔티티 - DDL(sql) 간 컬럼 불일치</strong>였습니다.</p>
</blockquote>
<h3 id="문제가-발생한-구조">문제가 발생한 구조</h3>
<table>
<thead>
<tr>
<th>대상</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td><code>schema.sql</code></td>
<td>테이블 생성은 정상적으로 되고 있었음</td>
</tr>
<tr>
<td><code>data.sql</code></td>
<td>테이블 스키마와 컬럼이 맞지 않아, <strong>INSERT문이 실행되지 않음</strong></td>
</tr>
<tr>
<td>JPA Entity</td>
<td>엔티티에 존재하는 필드와 스키마의 컬럼 정의가 일치하지 않음</td>
</tr>
</tbody></table>
<h3 id="해결-방법">해결 방법</h3>
<p>언제나 해결 방법은 삽질을 아무리 오래 했어도^^,,, 간단하죠.</p>
<ol>
<li><p><strong>Entity 정의 기준으로 <code>schema.sql</code>을 다시 정리</strong></p>
<ul>
<li>Hibernate DDL 로그를 참고해 컬럼명, 제약 조건, 타입 등 모두 일치시킴</li>
</ul>
</li>
<li><p><strong><code>data.sql</code>도 이에 맞춰 INSERT문 다시 작성</strong></p>
<ul>
<li>모든 컬럼에 값이 들어가도록 하고, 외래 키 제약도 맞춤</li>
</ul>
</li>
<li><p><strong><code>application-test.yml</code>에 다음 설정을 추가</strong></p>
<pre><code class="language-yaml">spring:
  jpa:
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect
        format_sql: true
    show-sql: true
    defer-datasource-initialization: true

  sql:
    init:
      mode: always
      schema-locations: schema.sql
      data-locations: data.sql</code></pre>
</li>
<li><p><strong>테스트 클래스에서 <code>withInitScript(&quot;init.sql&quot;)</code> → 제거하고, DynamicProperty 설정 단순화</strong></p>
<pre><code class="language-kotlin">@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
    registry.add(&quot;spring.datasource.url&quot;) { &quot;jdbc:tc:mysql:8.0.32:///${mysqlContainer.databaseName}&quot; }
    registry.add(&quot;spring.datasource.driver-class-name&quot;) { &quot;org.testcontainers.jdbc.ContainerDatabaseDriver&quot; }
    registry.add(&quot;spring.datasource.username&quot;) { mysqlContainer.username }
    registry.add(&quot;spring.datasource.password&quot;) { mysqlContainer.password }
}</code></pre>
</li>
</ol>
<blockquote>
<p>✅ <strong>최종 정리</strong><br><code>schema.sql</code>, <code>data.sql</code>을 Entity 기준으로 재작성하고,<br>Spring Boot 설정과 TestContainers를 <strong>분리 구성</strong>함으로써 문제를 해결했습니다.</p>
</blockquote>
<p>그 결과, 테스트가 정상적으로 실행되었고 드디어 본격적인 <strong>동시성 테스트 코드 실행까지 진입</strong>할 수 있었습니다!</p>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/85bd08f5-5569-407f-9dd8-024150f04a5e/image.png" alt=""></p>
<h3 id="2-2-테스트는-돌아갔지만-성공한-예약은-0건">2-2. 테스트는 돌아갔지만… 성공한 예약은 0건?</h3>
<p>Testcontainers + schema.sql + data.sql 조합으로 환경을 잘 구성한 덕분에, 테스트는 드디어 정상 실행되기 시작했습니다.</p>
<p>하지만 기대와는 다르게 결과는...</p>
<blockquote>
<p>❌ <code>Expected :1, Actual :0</code><br>🤯 하나의 예약도 성공하지 못하고 모두 실패!</p>
</blockquote>
<p>테스트 메서드는 동시성 상황에서 5명의 유저가 동시에 같은 좌석을 예약하려고 시도하고,<br><strong>하나는 성공하고 나머지는 Pessimistic Lock 실패로 예외가 나야 정상이죠.</strong></p>
<p>그런데 지금은 아예 <strong>모두 실패(PessimisticLockingFailureException)</strong> 하고 있습니다.</p>
<pre><code class="language-kotlin">val successCount = results.count { it.value == null }   // 성공한 쓰레드 수
val lockFailureCount = results.count { it.value is PessimisticLockingFailureException }</code></pre>
<pre><code class="language-plaintext">org.opentest4j.AssertionFailedError: 하나의 예약만 성공해야 합니다
Expected :1
Actual   :0</code></pre>
<p>즉, 락이 작동은 했지만... <strong>처음 시도한 쓰레드조차 락을 못 잡고 실패한 셈</strong>입니다.</p>
<hr>
<h3 id="2-3-왜-전부-실패했을까-😰">2-3. 왜 전부 실패했을까? 😰</h3>
<p>현재 예상되는 원인은 다음과 같아요.</p>
<table>
<thead>
<tr>
<th>원인 후보</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>트랜잭션 타이밍</td>
<td>예약 로직 내부 트랜잭션 시작 전, DB 락이 유효하지 않거나 커밋 전에 경쟁이 생길 가능성</td>
</tr>
<tr>
<td>테스트 쓰레드 풀 문제</td>
<td><code>FixedThreadPool(5)</code>이 병렬성 제대로 안 나올 수도 있음 (e.g. GIL, JVM 동기화 큐)</td>
</tr>
<tr>
<td>락이 걸리는 대상이 적절하지 않음</td>
<td>현재 <code>seatId</code> 기준으로 걸지만, 해당 row가 존재하지 않거나 이미 잠겨있을 수 있음</td>
</tr>
<tr>
<td><code>PessimisticLockingFailureException</code> 발생 전 다른 예외</td>
<td>실제 예외가 catch되지 않거나, 다른 이유로 실패하고 있을 가능성</td>
</tr>
</tbody></table>
<blockquote>
<p>✅ <strong>시도 예정 해결 방향</strong>
다음과 같은 시도를 통해 테스트 정확도를 높일 예정입니다:</p>
<ul>
<li><code>Executors.newCachedThreadPool()</code>로 병렬성 높이기</li>
<li>락이 정확히 <code>@Transactional</code> 경계 안에서 작동하는지 재확인</li>
<li>초기화된 <code>seat</code>가 실제로 DB에 존재하고 예약 가능한 상태인지 검증</li>
</ul>
</blockquote>
<hr>
<h3 id="2-4-로그로-확인한-쿼리-흐름">2-4. 로그로 확인한 쿼리 흐름</h3>
<p>실제로 테스트 중 쿼리 로그를 확인해보면, <code>for update</code> 쿼리가 발생하고 있는 걸 확인할 수 있습니다:</p>
<pre><code class="language-sql">select r1_0.id, ... from reservations r1_0 
where r1_0.schedule_id=? and r1_0.seat_id in (?) for update</code></pre>
<p>하지만 <strong>그 뒤에 insert가 발생하지 않거나</strong>, 전부 롤백된 정황입니다.</p>
<blockquote>
<p>🤔 결론적으로 락 자체는 시도되었지만, 테스트 환경의 트랜잭션/실행 타이밍/예외 흐름 등이 <strong>성공까지 이어지지 못한 상태</strong>로 보입니다.</p>
</blockquote>
<hr>
<h3 id="2-5-다음-시도-방향">2-5. 다음 시도 방향</h3>
<ul>
<li><code>Thread.sleep</code>을 삽입해 락 점유 시간을 늘려 race condition 유도해본다!</li>
<li><code>@Transactional(propagation = Propagation.REQUIRES_NEW)</code> 등 트랜잭션을 세밀하게 조정해본다!</li>
<li><code>TestMessageService</code>에 예약 시점 로깅 추가하여 <strong>첫 번째 요청 시도 기록 추적^^..</strong></li>
</ul>
<hr>
<h2 id="3-이번-주를-정리하며--실전-피드백과-회고">3. 이번 주를 정리하며 — 실전 피드백과 회고</h2>
<p>이번 주차는 단순 락 구현을 넘어서, <strong>트랜잭션 경계 설정과 테스트 환경 구성</strong>의 중요성까지 체감할 수 있었던 시기였습니다.
락을 통해 동시성 문제를 해결하는 것이 목표였지만, 나락 속에서 몇 가지 과제는 다음 주차로 넘기게 되었어요.</p>
<h3 id="잘한-점">잘한 점</h3>
<ul>
<li>TestContainers 환경 구성과 DDL/data.sql 싱크 맞춤</li>
<li>Pessimistic Lock을 적용해 실제로 동시성 이슈를 잡아본 경험</li>
</ul>
<h3 id="아쉬운-점">아쉬운 점</h3>
<ul>
<li>&quot;하나의 예약만 성공해야 한다&quot; 테스트는 여전히 <strong>실패 상태</strong></li>
<li>Pessimistic Lock까지만 구현하고, Optimistic/Distributed Lock은 미완료</li>
</ul>
<h3 id="리뷰-피드백--다음에-꼭-챙길-것">리뷰 피드백 &amp; 다음에 꼭 챙길 것</h3>
<p>이번 주 주제와 직접 연관되진 않지만, 리뷰 과정에서 아래와 같은 피드백도 받았습니다:</p>
<ul>
<li><strong>Exception 분리</strong>: <code>IllegalArgumentException</code> 대신 도메인 예외 클래스로 세분화</li>
<li><strong>URL 프리픽스(v1)</strong>: REST API 버전 관리 도입 필요</li>
<li><strong>도메인 Validation 분리</strong>: 도메인 내부의 유효성 검사를 명확히 함수로 추출</li>
</ul>
<blockquote>
<p>급하게 기능 구현에 집중하느라 놓쳤던 부분들이지만,<br>다음 주차 리팩토링과 분산락 구현 타이밍에 맞춰 반드시 반영할 예정이에요.</p>
</blockquote>
<hr>
<p>📎 3주차 Pessimistic Lock 적용 PR: <a href="https://github.com/hanghae-skillup/redis_2nd/pull/65">#45 - 예약 API 및 Pessimistic Lock 적용 PR</a></p>
<hr>
<h2 id="시리즈-돌아보기">시리즈 돌아보기</h2>
<p>👉 지난 글 보기: <a href="https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-2-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%B3%B4%EB%8B%A4-%EC%BA%90%EC%8B%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%8B%A4%EC%A0%84-%EA%B8%B0%EB%A1%9D">항해 시네마 개발 Log #2 - 인덱스보다 캐시? 성능 최적화 실전 기록</a><br>👉 첫 글 보기: <a href="https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-1-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EC%84%A4%EA%B3%84-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%A0%87%EA%B2%8C-%ED%92%80%EC%97%88%EB%8B%A4">멀티 모듈 설계, 이렇게 고민하고 이렇게 풀었다</a></p>
<hr>
<p>그럼 다음 글에서는 <strong>RateLimit 구현기와 테스트 커버리지 개선 여정</strong>으로 다시 찾아뵐게요!</p>
<blockquote>
<p>이 글은 <strong>항해 플러스 스킬업 과정</strong>에서 진행한 실전 프로젝트 경험을 바탕으로 작성되었습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[인덱스보다 캐시? 성능 최적화 실전 기록(항해 시네마 개발 Log #2)]]></title>
            <link>https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-2-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%B3%B4%EB%8B%A4-%EC%BA%90%EC%8B%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%8B%A4%EC%A0%84-%EA%B8%B0%EB%A1%9D</link>
            <guid>https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-2-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%B3%B4%EB%8B%A4-%EC%BA%90%EC%8B%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%8B%A4%EC%A0%84-%EA%B8%B0%EB%A1%9D</guid>
            <pubDate>Sun, 30 Mar 2025 17:15:59 GMT</pubDate>
            <description><![CDATA[<h2 id="2주차-개요">2주차 개요</h2>
<p>2주차의 핵심은 <strong>실제 트래픽을 고려한 성능 최적화 실험</strong>이었습니다.
지난 주차에 설계한 구조 위에 기능을 추가하면서 발생한 성능 저하 문제를 진단하고, 
이를 해결하기 위해 다양한 전략을 직접 테스트하며 비교 분석했습니다.</p>
<h3 id="2주차-핵심-목표">2주차 핵심 목표</h3>
<ul>
<li>검색 기능 추가로 인한 성능 변화 분석</li>
<li>인덱스 기반 쿼리 최적화 실험</li>
<li>로컬 캐시(Caffeine) 및 분산 캐시(Redis) 적용 비교</li>
<li>성능 테스트 자동화 및 수치 기반 개선 검증</li>
</ul>
<h2 id="1-성능-테스트-제대로-하는-법부터-시작하자">1. 성능 테스트, 제대로 하는 법부터 시작하자</h2>
<h3 id="1-1-성능-테스트-시나리오-설계-dau-기반-접근">1-1. <strong>성능 테스트 시나리오 설계: DAU 기반 접근</strong></h3>
<ul>
<li>하루 사용자 수(DAU) 1,000명, 사용자당 2회 접속을 가정</li>
<li>피크 시간대는 일반 트래픽 대비 10배 증가 시나리오 설정</li>
<li>실전처럼 현실적인 조건을 시뮬레이션하며 성능 테스트 전제를 정리</li>
</ul>
<blockquote>
<p>📸 테스트 보고서 내 전제 조건 설명: 리뷰어 피드백에서 “테스트 가정이 매우 논리적이다”는 평가를 받았습니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/2720987d-6cb8-4e30-8e17-dff3303c97c3/image.png" alt=""></p>
<h3 id="1-2-단계적-성능-측정의-중요성">1-2. 단계적 성능 측정의 중요성</h3>
<ul>
<li><p>성능은 한 번의 테스트로 끝나지 않지요. 변화 과정을 기록하며 개선 방향을 잡는 것이 중요합니다.</p>
</li>
<li><p>1주차 기본 API → 검색 기능 추가 → 인덱스 적용 → 캐시 적용 → Redis 적용 흐름으로 테스트를 진행했고, 아래는 그 결과를 정리한 성능 비교표입니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>평균 응답 시간</th>
<th>p95 응답 시간</th>
<th>처리량(RPS)</th>
<th>실패율</th>
</tr>
</thead>
<tbody><tr>
<td>1주차 API</td>
<td>17.18ms</td>
<td>21.62ms</td>
<td>52.80</td>
<td>10.27%</td>
</tr>
<tr>
<td>인덱스 적용 전</td>
<td>9.42s</td>
<td>14.14s</td>
<td>7.42</td>
<td>10.24%</td>
</tr>
<tr>
<td>인덱스 적용 후</td>
<td>9.59s</td>
<td>15.08s</td>
<td>7.34</td>
<td>10.29%</td>
</tr>
<tr>
<td>로컬 캐시 적용</td>
<td>4.38ms</td>
<td>6.78ms</td>
<td>53.33</td>
<td>9.75%</td>
</tr>
<tr>
<td>Redis 캐시 적용</td>
<td>10.08s</td>
<td>14.77s</td>
<td>7.02</td>
<td>100%</td>
</tr>
</tbody></table>
</li>
</ul>
<blockquote>
<p>📊 위 데이터를 통해 어떤 전략이 실질적으로 성능에 가장 큰 영향을 주었는지 직관적으로 파악할 수 있어요.</p>
</blockquote>
<blockquote>
<p>특히, 인덱스보다 <strong>로컬 캐시 적용의 효과가 가장 극적</strong>이었고, Redis 캐시는 설정 이슈로 인해 실패율 100%라는 슬픈 결과를 보였습니다.</p>
</blockquote>
<h2 id="2-인덱스-적용-기대와-다른-실전-결과">2. 인덱스 적용, 기대와 다른 실전 결과</h2>
<h3 id="2-1-작은-데이터셋에서는-오히려-역효과">2-1. <strong>작은 데이터셋에서는 오히려 역효과?</strong></h3>
<ul>
<li>Full Scan은 단순 작업이라 소규모 DB에선 빠를 수 있음</li>
<li>인덱스를 타는 쿼리는 오히려 오버헤드가 생길 수 있음</li>
<li>실제 테스트 결과, 인덱스 적용 전후 성능 차이는 미미함</li>
</ul>
<blockquote>
<p>📸리뷰 코멘트:<br>“작은 테이블에서는 인덱스가 성능에 도움이 안 될 수도 있습니다.”</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/c0d2097c-29b6-4275-be24-93b4449d0978/image.png" alt="">
위 피드백은 실제로 인덱스를 적용했을 때 성능 차이가 미미했던 실험 결과와도 잘 맞아떨어지지요.
소규모 데이터셋에서 인덱스는 오히려 불필요한 오버헤드가 될 수 있다는 걸 직접 체감한 사례입니다. </p>
<h3 id="2-2-복합-인덱스-vs-단일-인덱스-무엇이-더-나을까">2-2. <strong>복합 인덱스 vs 단일 인덱스, 무엇이 더 나을까?</strong></h3>
<ul>
<li>카디널리티(중복도) 기반으로 인덱스 설계 고려</li>
<li>복합 인덱스(<code>title, genre, releaseAt DESC</code>) 대신 단일 인덱스 선택</li>
<li>쿼리 실행 계획을 통해 인덱스가 실제로 사용되는지 확인</li>
</ul>
<blockquote>
<p>📸 리뷰 질문: &quot;복합 인덱스를 선택하지 않은 이유가 궁금합니다.&quot;</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/ccf6a006-5db5-418b-b008-61fe5b7d9a88/image.png" alt="">
이 리뷰를 통해 <strong>인덱스 선택의 기준이 단순히 필드 수가 아니라, 쿼리 특성과 데이터 분포(카디널리티)</strong>임을 다시금 실감했습니다. 
실무에서도 복합 인덱스를 무조건적으로 적용하기보다는, 상황에 맞는 전략적 선택이 중요하니까요😅</p>
<h2 id="3-캐시-전략의-진화-caffeine-→-redis">3. 캐시 전략의 진화: Caffeine → Redis</h2>
<h3 id="3-1-로컬-캐시caffeine로-얻은-성능-개선-효과">3-1. <strong>로컬 캐시(Caffeine)로 얻은 성능 개선 효과</strong></h3>
<ul>
<li>평균 응답 시간 4.38ms, 처리량 53.33 RPS로 비약적 개선</li>
<li>기존 API 대비 약 2,150배 빠르고 7배 이상 처리량 향상</li>
</ul>
<h3 id="3-2-분산-캐시redis-도입-후-마주친-도전들">3-2. <strong>분산 캐시(Redis) 도입 후 마주친 도전들</strong></h3>
<ul>
<li>Redis 연결 문제로 실패율 100% 기록</li>
<li>캐시 미스 시 fallback 미비, 장애 발생 시 전체 실패</li>
<li>분산 환경에서는 예외 처리 및 장애 설계가 매우 중요함을 실감</li>
</ul>
<blockquote>
<p>📸 리뷰 피드백: &quot;Redis 장애가 발생해도 응답이 아예 실패하면 안 됩니다.&quot;</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/f5277acb-93fc-4c01-a92b-b2c9714ee9d6/image.png" alt=""></p>
<p>이 피드백을 통해 단순히 캐시를 적용하는 데 그치지 않고,<br><strong>장애 상황에서도 서비스가 무너지지 않도록 설계하는 것이 얼마나 중요한지</strong> 다시금 깨달았습니다.<br>실제 운영 환경에서는 <strong>성능보다 안정성</strong>이 더 중요한 순간이 많기 때문에,<br>“실패했을 때 어떻게 복구할 것인가”에 대한 고민은 반드시 선행되어야 한다고 느꼈습니다.</p>
<h3 id="3-3-캐시-키-설계의-함정">3-3. <strong>캐시 키 설계의 함정</strong></h3>
<ul>
<li><code>title + genre</code> 조합은 캐시 효율이 낮을 수 있음</li>
<li>캐시 효율을 위해서는 인기 장르 중심 캐싱이 더 나은 전략</li>
</ul>
<blockquote>
<p>📸 리뷰 인사이트: &quot;캐시 히트율 높은 장르만 선택적으로 캐싱해보는 것을 고려해보세요.&quot;</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/fd6f3cdf-e49e-4a42-bb92-5cb082e029f3/image.png" alt="">
이 피드백을 통해 단순히 키를 조합하는 방식만으로는 캐시 효율을 끌어올리기 어렵다는 점을 알 수 있었습니다.<br>실제로는 사용 패턴이나 자주 조회되는 데이터를 분석해, <strong>“무엇을 캐싱할 것인가”를 전략적으로 결정하는 것</strong>이 훨씬 더 중요하다는 걸 배웠죠.</p>
<h2 id="4-실전에서-배운-진짜-교훈">4. 실전에서 배운 진짜 교훈</h2>
<h3 id="4-1-테스트-데이터가-성능을-왜곡할-수도-있다">4-1. <strong>테스트 데이터가 성능을 왜곡할 수도 있다</strong></h3>
<ul>
<li>랜덤으로 4개 타이틀만 사용하는 경우, 캐싱 효과가 과대하게 나타남</li>
<li>다양한 타이틀 데이터로 테스트해야 실제 환경과 유사</li>
</ul>
<blockquote>
<p>📸 리뷰 피드백: &quot;데이터 다양성이 부족하면 캐시 히트율이 왜곡될 수 있습니다.&quot;</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/571cb58b-0225-41c7-a259-b90031d33331/image.png" alt=""></p>
<p>이 피드백 덕분에 성능 테스트에서 <strong>데이터 샘플의 다양성과 현실성</strong>이 얼마나 중요한지 느낄 수 있었습니다.<br>제대로 된 검증을 위해서는 단순히 많은 데이터를 넣는 것이 아니라, <strong>실제 사용 시나리오와 유사한 분포와 구조를 설계해야</strong> 한다는 것을 배웠습니다.</p>
<h3 id="4-2-실패에-강한-시스템을-설계하라">4-2. <strong>실패에 강한 시스템을 설계하라</strong></h3>
<ul>
<li>Redis 장애 시 fallback 응답 설계 필요</li>
<li>캐시 미스, 캐시 삭제 등 예외 상황 고려한 로직 필요</li>
<li>장애 대응, 재시도, 타임아웃 등 전체 흐름을 고려한 설계가 필요함</li>
</ul>
<h2 id="5-종합-비교-분석">5. 종합 비교 분석</h2>
<h3 id="성능-지표-비교">성능 지표 비교</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>평균 응답 시간</th>
<th>p95 응답 시간</th>
<th>처리량(RPS)</th>
<th>실패율</th>
</tr>
</thead>
<tbody><tr>
<td>1주차 API</td>
<td>17.18ms</td>
<td>21.62ms</td>
<td>52.80</td>
<td>10.27%</td>
</tr>
<tr>
<td>인덱스 적용 전</td>
<td>9.42s</td>
<td>14.14s</td>
<td>7.42</td>
<td>10.24%</td>
</tr>
<tr>
<td>인덱스 적용 후</td>
<td>9.59s</td>
<td>15.08s</td>
<td>7.34</td>
<td>10.29%</td>
</tr>
<tr>
<td>로컬 캐시 적용</td>
<td>4.38ms</td>
<td>6.78ms</td>
<td>53.33</td>
<td>9.75%</td>
</tr>
<tr>
<td>Redis 캐시 적용</td>
<td>10.08s</td>
<td>14.77s</td>
<td>7.02</td>
<td>100%</td>
</tr>
</tbody></table>
<p>표로 비교해보니 더욱 분명해졌습니다. 
가장 극적인 개선은 인덱스가 아닌 <strong>로컬 캐시(Caffeine)</strong> 적용에서 나타났으며, 반대로 가장 큰 실패는 <strong>Redis 설정 미비^^..</strong>로 인해 발생했습니다. 단순히 기술을 사용하는 것을 넘어서, 그것을 운영 가능한 구조로 만드는 것이 실전 개발에서 훨씬 더 중요한 과제라는 사실을 다시금 느꼈습니다.</p>
<h3 id="결론-요약">결론 요약</h3>
<ul>
<li>인덱스는 소규모 데이터셋에서는 성능 향상이 미미할 수 있음</li>
<li>로컬 캐시(Caffeine)는 가장 큰 성능 개선을 가져왔으며, 응답 시간과 처리량 모두 크게 향상</li>
<li>Redis는 설정 오류로 실패율 100%, 분산 캐시에서는 예외 처리 및 장애 대비가 중요함</li>
<li>향후 확장성과 안정성을 고려한 캐시 전략, 장애 대응 설계가 필요함</li>
</ul>
<hr>
<h2 id="시리즈-돌아보기">시리즈 돌아보기</h2>
<p>👉 지난 글 보기: <a href="https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-1-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EC%84%A4%EA%B3%84-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%A0%87%EA%B2%8C-%ED%92%80%EC%97%88%EB%8B%A4">항해 시네마 개발 Log #1 - 멀티 모듈 설계, 이렇게 고민하고 이렇게 풀었다</a></p>
<p>위 글에서는 멀티 모듈 아키텍처를 어떻게 설계하고, 각 계층을 어떻게 분리했는지에 대한 고민과 그 과정을 담았습니다. 이어지는 이번 글에서는 이 구조 위에 실제 성능을 어떻게 쌓아올릴 수 있을지 실험하고 분석한 기록을 담았습니다.</p>
<h2 id="마무리하며">마무리하며</h2>
<p>이번 주는 단순한 튜닝이 아닌 <strong>왜 성능이 안 나오는가?🤦🏻‍♀️</strong>를 깊이 파고드는 시간이었습니다. 
단순한 수치 개선을 넘어서, 데이터 설계와 장애 대응까지 고려하는 아키텍처 고민이 얼마나 중요한지를 실감했습니다.</p>
<p>👉 관련 PR 보기: <a href="https://github.com/hanghae-skillup/redis_2nd/pull/45">김인후 - 2주차 성능 최적화 PR</a></p>
<p>👉 다음 글에서는 예약 API 구현과 함께,<br>Pessimistic Lock → Optimistic Lock → Distributed Lock 순으로 어떻게 동시성 문제를 해결해 나갔는지,<br>그리고 Redisson의 내부 동작과 Lua Script, 함수형 락 전환 경험까지 실전 사례를 정리해볼 예정입니다.</p>
<blockquote>
<p>이 글은 <a href="https://hh-skillup.oopy.io/">항해 플러스 스킬업 과정</a>에서 진행한 &#39;항해 시네마 프로젝트&#39; 경험을 바탕으로 작성되었습니다. 
실전 프로젝트를 통해 설계, 성능 최적화, 장애 대응까지 전방위적으로 경험하고 있습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[멀티 모듈 설계, 이렇게 고민하고 이렇게 풀었다(항해 시네마 개발 Log #1)]]></title>
            <link>https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-1-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EC%84%A4%EA%B3%84-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%A0%87%EA%B2%8C-%ED%92%80%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@nova-kim/%ED%95%AD%ED%95%B4-%EC%8B%9C%EB%84%A4%EB%A7%88-%EA%B0%9C%EB%B0%9C-Log-1-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EC%84%A4%EA%B3%84-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EA%B3%A0%EB%AF%BC%ED%95%98%EA%B3%A0-%EC%9D%B4%EB%A0%87%EA%B2%8C-%ED%92%80%EC%97%88%EB%8B%A4</guid>
            <pubDate>Fri, 21 Mar 2025 11:17:33 GMT</pubDate>
            <description><![CDATA[<h2 id="지난-주차-목표">지난 주차 목표</h2>
<p>지난 주차의 가장 큰 목표는 <strong>프로젝트의 골격을 잡는 것</strong>이었습니다. 단순히 기능 구현을 넘어, 앞으로 수주간 쌓아갈 개발 과정의 기반이 되는 설계 말이지요.
그래서 더더욱 신중하게, 그리고 고민하며 진행했죠.</p>
<ul>
<li>Multi Module Design 설계 및 ERD 작성</li>
<li>메인 페이지용 &#39;상영 중인 영화 조회 API&#39; 개발</li>
<li>Docker 기반 DB 환경 및 초기 데이터 세팅</li>
</ul>
<h2 id="🤔-김인후가-가장-고민했던-지점">🤔 &quot;김인후&quot;가 가장 고민했던 지점</h2>
<h3 id="1-멀티-모듈-경계는-어디까지">1. 멀티 모듈 경계는 어디까지?</h3>
<p>처음에는 ‘domain, application, api’만으로 충분하지 않을까 싶었는데, 인프라 계층을 별도로 두지 않으면 점점 도메인이 더러워질 위험이 보였습니다. 결국 <code>cinema-infrastructure</code> 모듈을 따로 두는 것으로 결정 했지요!</p>
<h3 id="2-도메인은-얼마나-순수해야-할까">2. 도메인은 얼마나 순수해야 할까?</h3>
<p>‘정말 비즈니스 로직 외의 어떤 것도 들어가면 안 된다.’ 이 말을 지키려니 인터페이스 하나 작성할 때도 “이게 진짜 도메인의 책임인가?” 재귀적으로 스스로 질문하며 진행했습니다.</p>
<h3 id="3-facade는-필수일까">3. Facade는 필수일까?</h3>
<p>처음에는 단순한 서비스만 있어도 충분할 줄 알았는데, 여러 도메인을 조합하는 순간 코드가 난잡해지더군요. Facade 패턴의 필요성을 온몸으로 느꼈습니다.</p>
<h2 id="erd-설계--그림으로-먼저-그려봤어요">ERD 설계 — 그림으로 먼저 그려봤어요</h2>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/5f7809aa-834f-40f7-a644-f3fa6463930a/image.png" alt=""></p>
<ul>
<li><strong>느낀 점</strong>: 관계는 간단해 보이지만, 실제 API 응답 설계 시 join과 fetch 전략을 잘못 잡으면 바로 N+1 지옥이 열린다는 걸 알았습니다.</li>
</ul>
<h2 id="모듈-구조--내가-선택한-이유와-구성">모듈 구조 — 내가 선택한 이유와 구성</h2>
<table>
<thead>
<tr>
<th>모듈</th>
<th>역할</th>
<th>고민했던 이유</th>
<th>결론</th>
</tr>
</thead>
<tbody><tr>
<td>cinema-api</td>
<td>외부 요청 처리</td>
<td>application에서 직접 컨트롤러 넣을까 고민</td>
<td>Controller는 독립 분리, 테스트 용이성을 위해 api 분리</td>
</tr>
<tr>
<td>cinema-application</td>
<td>비즈니스 유스케이스 조합</td>
<td>도메인에 너무 많은 서비스가 몰리는 문제</td>
<td>application에서 orchestrator 역할로 Facade 활용</td>
</tr>
<tr>
<td>cinema-domain</td>
<td>순수 도메인 엔티티, 인터페이스</td>
<td>인프라 레이어까지 같이 넣으면?</td>
<td>NO. 철저히 순수하게 유지</td>
</tr>
<tr>
<td>cinema-infrastructure</td>
<td>구현체와 외부 연동</td>
<td>초기에는 통합할까 고민</td>
<td>분리해서 DIP 완벽 적용</td>
</tr>
</tbody></table>
<h2 id="상영-중인-영화-조회-api-설계-및-응답-예시">상영 중인 영화 조회 API 설계 및 응답 예시</h2>
<pre><code>GET /api/movies/now-playing</code></pre><pre><code class="language-json">[
  {
    &quot;id&quot;: 1,
    &quot;title&quot;: &quot;범죄도시4&quot;,
    &quot;rating&quot;: &quot;15세 이상&quot;,
    &quot;releaseDate&quot;: &quot;2023-12-01&quot;,
    &quot;thumbnailUrl&quot;: &quot;https://example.com/thumbnail1.jpg&quot;,
    &quot;runningTime&quot;: 120,
    &quot;genre&quot;: &quot;액션&quot;,
    &quot;schedules&quot;: [
      {
        &quot;id&quot;: 1,
        &quot;theaterName&quot;: &quot;1관&quot;,
        &quot;startTime&quot;: &quot;2023-12-10T10:00:00&quot;,
        &quot;endTime&quot;: &quot;2023-12-10T12:00:00&quot;
      }
    ]
  }
]</code></pre>
<ul>
<li><strong>&quot;김인후&quot; 생각</strong>: ‘조회 API’ 하나지만, 페이징 없이 500건 이상 조회 조건 때문에 최적화가 필수였고, DB-서비스 계층의 설계가 진짜 중요하다는 걸 뼈저리게 느꼈습니다.</li>
</ul>
<h2 id="시행착오와-해결-과정-진짜-있었던-일들">시행착오와 해결 과정 (진짜 있었던 일들)</h2>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/6864ff3e-dea9-44dd-87e7-193814a4297b/image.png" alt=""></p>
<ul>
<li><strong>N+1 문제 발생</strong>: 영화마다 스케줄 가져오는데 총 쿼리가 501개 나오는 거 보고 식은땀이...<ul>
<li>해결: <code>@EntityGraph</code>와 JPQL fetch join으로 갈아타려고요^_^</li>
</ul>
</li>
<li><strong>순환 참조 문제</strong>: 인프라 계층이 잘못 application 계층을 참조하도록 설정해서 빌드 실패.<ul>
<li>해결: Configuration class에서 <code>@Bean</code> 주입과 모듈 의존 방향을 철저히 점검.</li>
</ul>
</li>
<li><strong>초기 데이터 문제</strong>: 500개 스케줄 데이터 생성 중 실수로 인덱스 걸어서 MySQL 인서트 속도 지옥...<ul>
<li>해결: 1주차 조건에 맞게 인덱스 제거 후 삽입.</li>
</ul>
</li>
</ul>
<h2 id="김인후가-준비한-테스트-환경">&quot;김인후&quot;가 준비한 테스트 환경</h2>
<ul>
<li>Docker로 PostgreSQL 환경을 구성했습니다.</li>
<li>개발 및 테스트는 모두 <code>test</code> 프로필로 분리해서 진행했고, 
<code>TestContainers</code>를 사용해 로컬 환경에서도 최대한 프로덕션과 유사한 DB 구성을 유지하려고 했습니다.</li>
<li>API 테스트는 IntelliJ HTTP Client를 적극 활용했고, 
PR 리뷰에서도 <code>.http</code> 파일을 제공해 리뷰어가 쉽게 실행 가능하도록 준비했습니다.  </li>
</ul>
<h2 id="코치님의-피드백--김인후가-느낀-점">코치님의 피드백 &amp; &quot;김인후&quot;가 느낀 점</h2>
<p>지난 1주차 과제에서 감사하게도 코치님께 Best Practice로 선정되었고,<br>구조와 책임 분리, 리드미 작성이 협업에 도움이 되었다는 좋은 피드백을 받았습니다.<img src="https://velog.velcdn.com/images/nova-kim/post/8cc6e06e-ec9b-4f24-9bc7-4ea5aece9958/image.png" alt=""></p>
<blockquote>
<p>&quot;프로젝트 구조가 잘 설계되어 있고, 각 모듈 별 책임도 명확해서 좋았습니다.<br>리드미와 기타 문서를 통해 소통 장치를 잘 만들어서 협업하기 좋은 사람이라는 인상이 들었습니다.&quot;</p>
</blockquote>
<p><strong>&quot;김인후&quot;가 느낀 점:</strong><br>리드미 작성과 모듈 설계는 단순한 형식이 아니라,<br>결국 협업과 커뮤니케이션의 시작이라는 걸 새삼 깨달았습니다.<br>내가 뭘 고민했고, 어떻게 풀었는지를 미리 잘 담아두는 것만으로도 팀원과 리뷰어에게 신뢰를 줄 수 있다는 걸 배운 주차였어요.</p>
<blockquote>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/496f2cd4-b9e6-49ac-b1b5-001fdc27e9bb/image.png" alt="코치님 피드백 캡쳐"></p>
</blockquote>
<p>  👉  <a href="https://github.com/hanghae-skillup/redis_2nd/pull/15#event-16893194826">김인후 PR 링크</a></p>
<h2 id="마무리하며">마무리하며</h2>
<p>지난 주는 설계와 구조를 고민하느라 손이 느렸지만, 머리가 정말 야무지게  <img src="https://velog.velcdn.com/images/nova-kim/post/9130e68b-194d-4e2f-982c-fb17a4247012/image.png" alt="">
돌아갔던 시간이었습니다.
‘잘 짠 설계는 코드보다 오래 간다&#39;는 말을 몸소 느꼈고,이번 주에는 성능 튜닝과 인덱스 적용이라는 새로운 도전이 기다리고 있다는 생각에 벌써 기대가 됩니다.</p>
<h2 id="다음-글-예고">다음 글 예고</h2>
<p>다음 글에서는 실제로 쿼리를 어떻게 튜닝했는지, 인덱스를 적용하며 어떤 시행착오를 겪었는지 그리고 성능 개선 전후의 변화를 자세히 정리해보려고 합니다.</p>
<p>잘 정리된 설계 위에서 성능까지 챙기는 야무진 과정을 함께 해보시렵니까?</p>
<blockquote>
<p>이 글은 <a href="https://hh-skillup.oopy.io/">항해 플러스 스킬업 과정</a>에서 진행한 &#39;항해 시네마 프로젝트&#39; 경험을 바탕으로 작성되었습니다.<br>실전 프로젝트를 통해 배우고 성장하는 경험이 궁금하다면, 꼭 한 번 확인해 보세요!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[스낵AI🤖 - MCP 편 #0: MCP와 Cursor, 개발자 필수 도구 야무진 정리]]></title>
            <link>https://velog.io/@nova-kim/%EC%8A%A4%EB%82%B5AI-MCP-%ED%8E%B8-0-MCP%EC%99%80-Cursor-%EA%B0%9C%EB%B0%9C%EC%9E%90-%ED%95%84%EC%88%98-%EB%8F%84%EA%B5%AC-%EC%95%BC%EB%AC%B4%EC%A7%84-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@nova-kim/%EC%8A%A4%EB%82%B5AI-MCP-%ED%8E%B8-0-MCP%EC%99%80-Cursor-%EA%B0%9C%EB%B0%9C%EC%9E%90-%ED%95%84%EC%88%98-%EB%8F%84%EA%B5%AC-%EC%95%BC%EB%AC%B4%EC%A7%84-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Fri, 21 Mar 2025 08:45:09 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>생각보다 많은 개발자들이 아직 MCP와 Cursor IDE의 역할을 잘 모르는 것 같아 이 시리즈 &#39;스낵AI🤖&#39;를 시작하게 되었습니다.</strong><br>복잡한 설명 대신, <strong>짧고 간결하지만 실전에 바로 적용할 수 있는 핵심 내용</strong>만 전해드릴게요.  </p>
</blockquote>
<hr>
<h2 id="mcp란-무엇인가">MCP란 무엇인가?</h2>
<p><strong>MCP (Model Context Protocol)</strong>는  
AI 모델과 코드 편집기(IDE), 브라우저 간에 정보를 실시간으로 주고받을 수 있도록 만들어진 표준 프로토콜입니다.  </p>
<h3 id="✅-mcp가-제공하는-핵심-기능">✅ MCP가 제공하는 핵심 기능</h3>
<ul>
<li>콘솔 로그 실시간 공유</li>
<li>네트워크 요청/응답 모니터링</li>
<li>현재 선택된 DOM 요소 전송</li>
<li>스크린샷 캡처 및 자동 전달</li>
<li>SEO 및 퍼포먼스 점검 (Lighthouse 연동)</li>
<li>Debugger Mode, Audit Mode 등 AI 주도의 자동화 기능  </li>
</ul>
<p>MCP를 지원하는 에디터에선, 이제 이렇게 요청할 수 있습니다:  </p>
<blockquote>
<p>&quot;이거 왜 안돼? 디버깅 모드로 들어가 줘.&quot;<br>&quot;선택한 버튼 요소 색상 바꿔줘.&quot;<br>&quot;지금 SEO 점검 리포트 생성해줘.&quot;  </p>
</blockquote>
<p><strong>👉 즉, MCP는 &#39;말하는 디버깅&#39;과 &#39;말하는 코드 수정&#39;을 가능하게 해주는 기술 표준이에요.</strong></p>
<hr>
<h2 id="cursor-ide란-무엇인가">Cursor IDE란 무엇인가?</h2>
<p><strong>Cursor</strong>는 MCP를 완벽하게 지원하는 AI 기반 코드 에디터입니다.  </p>
<ul>
<li>Claude 3.5 Sonnet 등 강력한 AI 엔진 지원</li>
<li>MCP 서버 통합 기능 내장</li>
<li>실시간 디버깅 및 코드 자동 수정 지원</li>
<li>GitHub Copilot 같은 추천 기능과 Chat 기능 내장</li>
<li>AI Prompt로 ‘코드 생성 → 테스트 → 수정’까지 대화형 가능  </li>
</ul>
<p>특히 Cursor는 <strong>vibe coding</strong>의 대표 주자로,  </p>
<blockquote>
<p>&quot;코드 설명해줘&quot;<br>&quot;이 함수 최적화 해줘&quot;<br>&quot;테스트 코드 자동 생성해줘&quot;<br>와 같은 요청을 자연어로 할 수 있는 개발자 필수 툴입니다.  </p>
</blockquote>
<hr>
<h2 id="mcp--cursor--vibe-coding">MCP + Cursor = vibe coding</h2>
<p><img src="https://velog.velcdn.com/images/nova-kim/post/4898e53a-e60b-4601-895b-e3f70aa5d0b8/image.png" alt=""></p>
<p>MCP 프로토콜이 <strong>정보 교환 표준</strong>이라면,<br>Cursor는 그것을 <strong>실제로 구동시키는 도구</strong>입니다.<br>두 가지가 결합되면 여러분은 이제 <strong>마우스를 거의 쓰지 않고, 대화만으로 코드를 디버깅하고, 수정하고, 점검할 수 있는 환경</strong>을 만들 수 있습니다.  </p>
<hr>
<h2 id="다음-편-예고-🎯">다음 편 예고 🎯</h2>
<p><strong>스낵AI🤖 - MCP 편 #1</strong><br>👉 <code>AgentDesk browser-tools 설치 및 Cursor MCP 연동 가이드</code>에서 직접 설치 과정을 차근차근 알려드립니다!  </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스낵깃 🍪 : Git Submodule - 여러 레포를 한 프로젝트에서 다루는 법]]></title>
            <link>https://velog.io/@nova-kim/%EC%8A%A4%EB%82%B5%EA%B9%83-Git-Submodule-%EC%97%AC%EB%9F%AC-%EB%A0%88%ED%8F%AC%EB%A5%BC-%ED%95%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@nova-kim/%EC%8A%A4%EB%82%B5%EA%B9%83-Git-Submodule-%EC%97%AC%EB%9F%AC-%EB%A0%88%ED%8F%AC%EB%A5%BC-%ED%95%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Sat, 15 Mar 2025 15:29:19 GMT</pubDate>
            <description><![CDATA[<h3 id="git-submodule을-써야-할까-🤔">Git Submodule을 써야 할까? 🤔</h3>
<p>한 프로젝트에서 여러 개의 저장소를 함께 관리해야 하는 순간, Git Submodule을 고려하게 되는데요.</p>
<blockquote>
<p>하지만 정말 Submodule이 최선일까요? 🤔</p>
</blockquote>
<p>독립적인 프로젝트를 유지하면서 특정 버전 상태를 고정해야 한다면 Submodule이 적절할 수 있습니다.</p>
<p>하지만 코드 변경이 잦고, 최신 버전을 자주 반영해야 한다면 Subtree나 패키지 매니저(npm, pip 등)가 더 나을 수도 있습니다.</p>
<p>이제 Git Submodule의 개념과 활용법, 그리고 실제 MSA(Microservices Architecture)에서 어떻게 활용할 수 있는지 알아보겠습니다.</p>
<p>⸻</p>
<h2 id="msa에서-git-submodule을-활용하는-방법">MSA에서 Git Submodule을 활용하는 방법</h2>
<h3 id="✅-submodule이-적절한-경우">✅ Submodule이 적절한 경우</h3>
<p>MSA 환경에서는 여러 개의 독립적인 서비스가 협력하여 하나의 시스템을 구성하는데요.
각 서비스가 공통된 코드를 유지하면서도 독립적으로 운영되길 원할 때, Git Submodule이 유용할 수 있습니다.</p>
<h4 id="📌-예제-공통-유틸리티를-여러-마이크로서비스에서-공유하는-경우">📌 예제: 공통 유틸리티를 여러 마이크로서비스에서 공유하는 경우</h4>
<p>**
상황**</p>
<ul>
<li>MSA에서는 여러 개의 마이크로서비스(예: user-service, order-service, payment-service)가 존재합니다.</li>
<li>모든 서비스에서 <strong>공통된 유틸리티 코드(common-utils)</strong>를 사용해야 합니다.</li>
<li>각 서비스가 이 공통 유틸리티를 특정 버전으로 유지해야 합니다.</li>
</ul>
<p><strong>해결 방법</strong></p>
<ul>
<li><p>각 마이크로서비스에서 공통 유틸리티를 Git Submodule로 추가하면,
각 서비스는 독립적으로 개발되면서도 특정 버전의 common-utils를 유지할 수 있습니다.</p>
<h4 id="각-마이크로서비스에서-공통-유틸리티를-서브모듈로-추가">각 마이크로서비스에서 공통 유틸리티를 서브모듈로 추가</h4>
<pre><code>cd user-service
git submodule add https://github.com/company/common-utils.git libs/common-utils
git commit -m &quot;Add common-utils submodule&quot;</code></pre><pre><code>cd ../order-service
git submodule add https://github.com/company/common-utils.git libs/common-utils
git commit -m &quot;Add common-utils submodule&quot;</code></pre></li>
</ul>
<ul>
<li><p>이렇게 하면 common-utils는 각 마이크로서비스의 독립적인 폴더로 존재하지만,
변경이 필요하면 한 번의 업데이트로 모든 서비스에 반영할 수 있습니다.</p>
<ul>
<li>각 서비스는 특정 버전의 common-utils를 유지하면서 필요할 때만 업데이트할 수 있습니다.</li>
</ul>
<h4 id="공통-유틸리티-업데이트-필요할-때만">공통 유틸리티 업데이트 (필요할 때만)</h4>
<pre><code>cd libs/common-utils
git pull origin main
cd ../..
git add .
git commit -m &quot;Update common-utils to latest version&quot;</code></pre></li>
</ul>
<p>⸻</p>
<h3 id="❌-git-submodule이-적절하지-않은-경우">❌ Git Submodule이 적절하지 않은 경우</h3>
<p>MSA에서 Git Submodule을 사용하는 것이 비효율적인 경우도 있습니다.</p>
<ul>
<li>공통 코드가 너무 자주 변경되어 모든 서비스에서 항상 최신 버전을 유지해야 할 때</li>
<li>공통 모듈이 아니라 단순 라이브러리라면, 패키지 매니저(npm, pip, maven)로 관리하는 것이 더 효율적</li>
<li>모든 마이크로서비스가 같은 저장소에 있는 모놀리식(monolithic) 프로젝트일 때</li>
</ul>
<p><strong>📌 예제: API 요청을 처리하는 공통 라이브러리가 있는 경우</strong></p>
<p><strong>상황</strong></p>
<ul>
<li>api-client라는 공통 라이브러리가 있고,
모든 마이크로서비스(user-service, order-service, payment-service)에서 이 라이브러리를 사용해야 합니다.</li>
<li>이 라이브러리는 자주 변경되며, 모든 서비스에서 최신 버전으로 유지해야 합니다.</li>
</ul>
<p><strong>해결 방법</strong></p>
<ul>
<li><p>이 경우 api-client를 Git Submodule로 추가하는 것보다,
패키지 매니저(npm, pip, maven 등)를 활용하는 것이 더 적절합니다.</p>
<h4 id="패키지-매니저로-공통-라이브러리-관리-예-npm">패키지 매니저로 공통 라이브러리 관리 (예: npm)</h4>
<pre><code>npm install @company/api-client@latest</code></pre><ul>
<li>이렇게 하면 모든 마이크로서비스에서 최신 버전의 api-client를 자동으로 가져올 수 있어,
Git Submodule을 사용하는 것보다 훨씬 편리합니다.</li>
</ul>
</li>
</ul>
<p>⸻</p>
<h3 id="git-submodule-vs-git-subtree-vs-패키지-매니저-비교">Git Submodule vs Git Subtree vs 패키지 매니저 비교</h3>
<p>Git Submodule을 도입하기 전에, 비슷한 기능을 제공하는 다른 방법들과 비교해볼게요.
<img src="https://velog.velcdn.com/images/itstimi-/post/478785a1-733e-41ac-90ff-d5ddbd5700d2/image.png" alt=""></p>
<p>즉, </p>
<ul>
<li>Git Submodule은 독립적인 프로젝트를 포함해야 하지만, 코드 변경이 자주 일어나지 않는 경우 적절합니다.</li>
<li>코드 변경이 자주 일어난다면 Git Subtree나 패키지 매니저가 더 유용할 수 있습니다.</li>
</ul>
<p>⸻</p>
<h3 id="git-submodule-사용법-요약">Git Submodule 사용법 요약</h3>
<p><strong>Submodule 추가</strong></p>
<pre><code>git submodule add &lt;서브모듈_레포_URL&gt; &lt;서브모듈_디렉토리_경로&gt;
git commit -m &quot;Add submodule&quot;
</code></pre><p><strong>서브모듈이 포함된 프로젝트를 클론할 때</strong></p>
<pre><code>git clone --recurse-submodules &lt;레포_URL&gt;</code></pre><p>or</p>
<pre><code>git submodule update --init --recursive</code></pre><p><strong>서브모듈 업데이트</strong></p>
<pre><code>git submodule update --remote</code></pre><p><strong>서브모듈 삭제</strong></p>
<pre><code>git submodule deinit -f &lt;서브모듈_디렉토리&gt;
rm -rf .git/modules/&lt;서브모듈_디렉토리&gt;
git rm -f &lt;서브모듈_디렉토리&gt;
git commit -m &quot;Remove submodule&quot;</code></pre><p>⸻</p>
<h3 id="📌-결론-git-submodule을-써야-할까">📌 결론: Git Submodule을 써야 할까?</h3>
<p><strong>Git Submodule을 쓰기 좋은 경우🤭</strong></p>
<ul>
<li>독립적인 프로젝트를 유지하면서도, 내 프로젝트에 포함해야 할 때</li>
<li>특정 외부 라이브러리를 고정된 버전으로 유지해야 할 때</li>
<li>변경이 자주 일어나지 않는 서브 프로젝트를 관리할 때</li>
</ul>
<p><strong>Git Submodule이 비효율적인 경우😵</strong></p>
<ul>
<li>코드 변경이 자주 발생하고, 최신 버전을 계속 유지해야 하는 경우</li>
<li>패키지 매니저(npm, pip, maven)로 쉽게 관리할 수 있는 라이브러리인 경우</li>
</ul>
<p>⸻</p>
<blockquote>
</blockquote>
<p>Git Submodule, 여러분은 어떻게 생각하시나요?</p>
<p>Git Submodule을 써야 할까요?
아니면 Git Subtree나 패키지 매니저가 더 나을까요?</p>
<hr>
<h2 id="🎁-실수에서-배우는-git-강의도-있어요">🎁 실수에서 배우는 Git 강의도 있어요!</h2>
<p>혹시 Git 충돌, 되돌리기, 협업 브랜치 전략 등<br><strong>이런 문제들을 실습 중심으로 차근차근 배우고 싶다면</strong>,  
제가 실무 경험을 바탕으로 만든 강의도 참고해보세요!</p>
<ul>
<li><strong>되돌리는 감각:</strong> <code>reflog</code>, <code>reset</code>, <code>revert</code>, 안전한 push  </li>
<li><strong>협업 루틴:</strong> 브랜치 전략, PR 체크리스트, 보호 규칙  </li>
<li><strong>실습 레포 기반 시나리오 6종</strong>으로 직접 몸으로 익히는 흐름</li>
</ul>
<p>🎬 <strong>강의 소개 영상:</strong> <a href="https://lnkd.in/gMWTUSf3">https://lnkd.in/gMWTUSf3</a><br>📌 <strong>수강 링크:</strong> <a href="https://lnkd.in/gVVBhJ4g">https://lnkd.in/gVVBhJ4g</a><br>💸 오픈 기념 할인 중입니다!</p>
<blockquote>
<p>에이전트가 코드는 잘 짜줘도,<br><strong>버전 관리 습관은 우리가 만들어야 우리가 편해집니다.</strong><br>이번 기회에 Git과 좀 더 친해져보세요. 🙂</p>
</blockquote>
]]></description>
        </item>
    </channel>
</rss>