<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jk-kim</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Thu, 09 Jul 2026 16:49:13 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jk-kim</title>
            <url>https://velog.velcdn.com/images/jw_kim/profile/f07c5a9f-5913-42b4-afd1-5b4776d3a185/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jk-kim. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jw_kim" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[실제 gRPC 도입을 위한 PoC 과정과 결국 REST를 결정한 이유]]></title>
            <link>https://velog.io/@jw_kim/%EC%8B%A4%EC%A0%9C-gRPC-%EB%8F%84%EC%9E%85%EC%9D%84-%EC%9C%84%ED%95%9C-PoC-%EA%B3%BC%EC%A0%95%EA%B3%BC-%EA%B2%B0%EA%B5%AD-REST%EB%A5%BC-%EA%B2%B0%EC%A0%95%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@jw_kim/%EC%8B%A4%EC%A0%9C-gRPC-%EB%8F%84%EC%9E%85%EC%9D%84-%EC%9C%84%ED%95%9C-PoC-%EA%B3%BC%EC%A0%95%EA%B3%BC-%EA%B2%B0%EA%B5%AD-REST%EB%A5%BC-%EA%B2%B0%EC%A0%95%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Thu, 09 Jul 2026 16:49:13 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 <strong>REST vs gRPC 시리즈</strong></p>
<ol>
<li>gRPC의 이해 — REST와 무엇이, 왜 다른가</li>
<li><strong>실제 gRPC 도입을 위한 PoC 과정과 결국 REST를 결정한 이유</strong> (현재 글)</li>
</ol>
<p>모놀리식을 MSA로 분리하는 과정에서, 마이크로서비스 간 <strong>동기 통신</strong>을 REST로 할지 gRPC로 할지 정해야 했습니다. 구현에 앞서 진행한 PoC의 과정과 결과를 공유합니다.</p>
</blockquote>
<h2 id="들어가며--msa-전환과-서비스-간-동기-통신">들어가며 — MSA 전환과 서비스 간 동기 통신</h2>
<p>기존 모놀리식 애플리케이션을 여러 마이크로서비스로 분리하기로 하면서, <strong>서비스 간 동기 호출을 어떤 방식으로 표준화할지</strong>를 정해야 했습니다. 선택지는 익숙한 REST(JSON/HTTP1.1)와, 사내에서 &quot;더 빠르다&quot;고 기대받던 gRPC(Protobuf/HTTP2)였습니다.</p>
<p>문제는 &quot;gRPC가 빠르다&quot;는 말이 조건부라는 점이었습니다(1편 참고). 우리 서비스의 실제 트래픽 특성에서 그 이득이 유의미한지, 그리고 그 이득이 전환 비용을 정당화하는지는 직접 재봐야 알 수 있었습니다. 그래서 구현에 앞서, 두 방식을 같은 조건에서 비교하는 PoC를 진행했습니다.</p>
<blockquote>
<p>📌 이 글의 범위는 <strong>서비스 간 동기 통신</strong>에 한정됩니다. 비동기(메시지 큐·이벤트 기반) 통신은 별개의 주제이며 이 검증의 대상이 아니었습니다.</p>
</blockquote>
<p>이 글은 그 과정에서 관찰한 네 가지 실험과, 그럼에도 최종적으로 REST를 선택한 이유를 담고 있습니다.</p>
<h2 id="실험-환경">실험 환경</h2>
<ul>
<li><strong>서버</strong>: Spring Boot로 REST(h1/h2c)와 gRPC를 한 프로세스에 노출 (비즈니스 로직은 동일)</li>
<li><strong>부하 도구</strong>: k6 (양쪽 클라이언트 모두 응답을 역직렬화하는 공정 조건)</li>
<li><strong>격리</strong>: Docker, 2코어 컨테이너, 로컬 루프백</li>
<li><strong>측정 지표</strong>: 처리량(RPS), p95 지연, 요청당 CPU(µs/req), wire 바이트</li>
</ul>
<blockquote>
<p>⚠️ 모든 수치는 로컬 · 2코어 컨테이너 환경의 order-of-magnitude 값입니다. 원격/실서비스에서는 네트워크 RTT·대역폭·데이터 엔트로피에 따라 달라지므로, 절대값보다 <strong>추세와 상대 비교</strong>로 읽어 주세요.</p>
</blockquote>
<h2 id="실험-1--payload-스윕과-교차점">실험 1 — payload 스윕과 교차점</h2>
<p>상품 개수(payload)를 1 → 5,000으로 늘리며 처리량을 측정했습니다. 아래는 <code>gRPC RPS ÷ REST RPS</code> 비율로, 1.0보다 크면 gRPC 우세, 작으면 REST 우세입니다.</p>
<table>
<thead>
<tr>
<th>payload (상품 개수)</th>
<th>gRPC / REST 비율</th>
<th>우세</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>0.80×</td>
<td>REST</td>
</tr>
<tr>
<td>10</td>
<td>0.83×</td>
<td>REST</td>
</tr>
<tr>
<td>100</td>
<td>0.97×</td>
<td>≈ 동률</td>
</tr>
<tr>
<td>500</td>
<td>1.22×</td>
<td>gRPC</td>
</tr>
<tr>
<td>1000</td>
<td>1.20×</td>
<td>gRPC</td>
</tr>
<tr>
<td>2000</td>
<td>1.23×</td>
<td>gRPC</td>
</tr>
<tr>
<td>5000</td>
<td>1.20×</td>
<td>gRPC</td>
</tr>
</tbody></table>
<p><strong>관찰</strong>: 상품 100~500개 부근에서 교차합니다. 그보다 작으면 오히려 REST가 빠른데, 이는 gRPC의 호출 오버헤드 때문입니다. 커지면 gRPC가 약 20% 앞섭니다. 즉 &quot;작으면 REST로 충분하다&quot;는 결론의 직접적 근거입니다.</p>
<h2 id="실험-2--압축의-반전">실험 2 — 압축의 반전</h2>
<p>먼저 짚고 갈 것이 있습니다. 여기서 말하는 gzip은 통신 방식(동기/비동기)과는 무관한, <strong>HTTP 응답 본문 압축</strong>입니다. HTTP는 <code>Content-Encoding: gzip</code> 헤더로 본문을 압축해 전송하는 기능을 기본 제공하고, 대부분의 웹 서버·프레임워크는 JSON 응답에 대해 이 압축을 켜 두는 것이 관례입니다. 즉 REST API를 운영하면 별도 설정 없이도 JSON은 대개 gzip으로 압축되어 나갑니다.</p>
<p>이 점이 중요한 이유는, gRPC의 이득을 이야기할 때 흔히 드는 근거가 &quot;wire가 작다&quot;이기 때문입니다. 실제로 이번 PoC에서도 Protobuf는 JSON보다 약 29% 작았습니다. 다만 이 수치는 <strong>양쪽 모두 압축을 적용하지 않은 raw 크기</strong>를 비교한 값입니다. 앞서 말했듯 실제 REST는 gzip을 켜고 운영하므로, 공정하게 비교하려면 JSON에 gzip을 적용한 상태로 다시 재봐야 합니다.</p>
<p>아래는 같은 데이터를 인코딩했을 때 JSON 원본 대비 비율(낮을수록 작음)입니다.</p>
<table>
<thead>
<tr>
<th>payload</th>
<th>JSON 원본</th>
<th>JSON gzip</th>
<th>Protobuf 원본</th>
</tr>
</thead>
<tbody><tr>
<td>100</td>
<td>100%</td>
<td>5%</td>
<td>78%</td>
</tr>
<tr>
<td>1000</td>
<td>100%</td>
<td>4%</td>
<td>77%</td>
</tr>
<tr>
<td>5000</td>
<td>100%</td>
<td>4%</td>
<td>79%</td>
</tr>
</tbody></table>
<p><strong>반전</strong>: gzip을 켠 JSON이 압축하지 않은 Protobuf보다 작습니다. JSON의 약점(반복되는 필드명)이 바로 gzip이 가장 잘 압축하는 부분이기 때문입니다. 즉 바이트 크기만으로 gRPC를 정당화하기는 약합니다. (물론 공정하게 하려면 Protobuf도 압축해야 하고, 그 경우 격차는 다시 좁혀집니다. 테스트 데이터가 반복적이라 실데이터는 이 정도로 극단적이진 않습니다.)</p>
<h2 id="실험-3--서비스-간-통신--cpu-가장-유효한-실험">실험 3 — 서비스 간 통신 + CPU (가장 유효한 실험)</h2>
<p>MSA 관점에서 가장 중요한 실험입니다. <strong>게이트웨이→백엔드 왕복이 곧 마이크로서비스 간 동기 호출을 모사한 것</strong>이기 때문입니다. 실제 JVM 게이트웨이가 백엔드를 REST h1 / REST h2c / gRPC 세 방식으로 호출하고, 호출 시간과 CPU를 내부에서 직접 측정했습니다. 처리량(RPS)은 아래와 같습니다.</p>
<table>
<thead>
<tr>
<th>payload</th>
<th>REST h1</th>
<th>REST h2c</th>
<th>gRPC</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>9.5k</td>
<td>8.8k</td>
<td>9.2k</td>
</tr>
<tr>
<td>10</td>
<td>9.0k</td>
<td>8.1k</td>
<td>9.6k</td>
</tr>
<tr>
<td>100</td>
<td>6.5k</td>
<td>6.0k</td>
<td>7.6k</td>
</tr>
<tr>
<td>1000</td>
<td>1.9k</td>
<td>1.0k</td>
<td>2.4k</td>
</tr>
</tbody></table>
<p><strong>핵심 발견 — CPU</strong>: 대형(1000개)에서 gRPC는 REST-h1의 약 72% CPU만 소비했습니다(REST가 1.39배). 바이트 크기 차이(1.29배)보다 CPU 효율의 실익이 더 큽니다. 그리고 이 효과는 단순 부하 테스트로는 보이지 않고, 서비스 간 구조 + cgroup 측정이라야 드러납니다.</p>
<h2 id="실험-4--h2c-역전-현상">실험 4 — h2c 역전 현상</h2>
<p>REST를 그대로 두고 전송만 HTTP/2(h2c)로 바꿨더니, 대형 payload에서 오히려 느려졌습니다.</p>
<table>
<thead>
<tr>
<th>payload</th>
<th>REST h1 RPS</th>
<th>REST h2c RPS</th>
<th>h2c / h1</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>9,468</td>
<td>8,790</td>
<td>0.93×</td>
</tr>
<tr>
<td>100</td>
<td>6,547</td>
<td>6,042</td>
<td>0.92×</td>
</tr>
<tr>
<td>1000</td>
<td>1,877</td>
<td>1,048</td>
<td>0.56×</td>
</tr>
</tbody></table>
<p><strong>왜?</strong> HTTP/2 기본 flow-control window가 작아 큰 응답에 WINDOW_UPDATE 왕복이 잦고, 모든 요청이 단일 커넥션을 공유해 HOL(head-of-line) 블로킹이 생깁니다. gRPC도 HTTP/2지만 grpc-netty가 window를 튜닝해 이 문제를 피합니다.</p>
<blockquote>
<p><strong>결론: &quot;HTTP/2라서 빠른 게 아니라, 튜닝된 HTTP/2 + Protobuf라서 빠른 것.&quot;</strong></p>
</blockquote>
<h2 id="결정-가이드">결정 가이드</h2>
<p>실험을 종합하면 선택 기준이 명확해집니다.</p>
<p><strong>REST h1으로 충분한 경우</strong></p>
<ul>
<li>저~중 트래픽에 소형 JSON</li>
<li>단일/소수 언어 스택</li>
<li>브라우저·공개 API</li>
<li>사람이 curl로 디버깅하는 환경</li>
<li>이미지·파일 등 blob 전송</li>
</ul>
<p><strong>gRPC를 고려할 만한 경우</strong></p>
<ul>
<li>폴리글랏 내부 메시 + 코드 생성 계약</li>
<li>스트리밍(실시간/파이프라인)</li>
<li>필드가 많은 대형 구조화 응답</li>
<li>고QPS라 CPU·지연이 곧 비용이 되는 경우</li>
<li>fleet이 커서 28% CPU 절감이 실제 돈이 되는 규모</li>
</ul>
<h2 id="그래서-우리는-왜-rest를-선택했나">그래서 우리는 왜 REST를 선택했나</h2>
<p>결론부터 말하면, 우리 서비스의 트래픽 특성과 조직 상황에서는 gRPC 전환의 이득이 비용을 넘지 못했습니다. 이유는 세 가지입니다.</p>
<p>첫째, <strong>payload가 대부분 교차점 아래</strong>였습니다. 실험 1에서 봤듯 상품 수백 개 미만에서는 REST가 오히려 빠르거나 동률입니다. 우리 API 응답은 대체로 이 구간에 있어 gRPC의 이득이 거의 없었습니다.</p>
<p>둘째, <strong>CPU가 병목이 아니었습니다.</strong> 실험 3의 CPU 이득은 실재하지만, 그건 고QPS로 CPU가 실제 비용이 되는 fleet에서 의미가 있습니다. 현재 규모에서는 28% CPU 절감이 유의미한 비용 절감으로 이어지지 않았습니다.</p>
<p>셋째, <strong>전환 비용과 생태계 손실이 컸습니다.</strong> 브라우저 직접 호출 불가(gRPC-Web 프록시 필요), curl 디버깅 난이도, 기존 REST 도구·모니터링 자산, 팀의 학습 곡선까지 고려하면 전면 교체는 명백한 오버엔지니어링이었습니다.</p>
<blockquote>
<p><strong>정리</strong>: gRPC 전환의 트리거는 RPS 숫자가 아니라 &quot;CPU 프로파일에서 직렬화가 15% 이상을 먹거나, p99가 SLO를 치는 순간&quot;입니다. 그 지점에 도달하기 전에는 압축 켜기 → Jackson 튜닝 → 커넥션 재사용 → 정 안 되면 핫패스 1~2개만 부분 이관이 정석이고, 전면 교체는 금물입니다.</p>
</blockquote>
<h2 id="그렇다면-grpc는-어떤-서비스에-적절한가">그렇다면 gRPC는 어떤 서비스에 적절한가</h2>
<p>우리는 REST를 선택했지만, 이것이 &quot;gRPC는 쓸 필요 없다&quot;는 결론은 아닙니다. 위 실험들을 뒤집어 보면 gRPC가 확실히 유리한 조건이 드러납니다. 다음 특성 중 여러 개가 겹칠수록 gRPC 도입의 실익이 커집니다.</p>
<p><strong>내부 서비스 간 통신이 트래픽의 핵심인 서비스</strong>
브라우저가 아니라 서버끼리 대량으로 호출하는 백엔드 메시라면 gRPC-Web 프록시나 curl 디버깅 같은 REST의 강점이 애초에 필요 없습니다. 실험 3에서 봤듯 서비스 간 왕복에서 CPU 이득이 가장 크게 나타나므로, 호출량이 많은 내부 메시일수록 유리합니다.</p>
<p><strong>대형 구조화 응답을 고QPS로 주고받는 서비스</strong>
필드가 많은 객체 수천 개를 초당 수천 번 직렬화·역직렬화하는 서비스라면, Protobuf의 라벨 제거로 인한 CPU 절감이 그대로 비용으로 돌아옵니다. 반대로 payload가 작거나(실험 1의 교차점 아래) blob 위주라면 이득이 거의 없습니다.</p>
<p><strong>스트리밍이 본질인 서비스</strong>
실시간 데이터 파이프라인, 서버 푸시, 양방향 통신이 필요한 경우 gRPC는 HTTP/2 스트리밍을 네이티브로 지원합니다. REST에서 SSE·WebSocket을 별도로 얹는 것보다 계약(.proto) 기반으로 다루기 깔끔합니다.</p>
<p><strong>폴리글랏 환경에서 계약(contract)을 강제하고 싶은 조직</strong>
사실 실무에서 gRPC를 선택하는 가장 큰 이유는 성능이 아니라 이 부분인 경우가 많습니다. 성능 이득은 앞서 봤듯 조건부지만, 계약 강제는 폴리글랏 환경이라면 거의 항상 유효하기 때문입니다.</p>
<p>REST에서 서비스 간 계약은 대체로 &quot;문서상의 약속&quot;에 머뭅니다. OpenAPI/Swagger로 스펙을 적어둘 수는 있지만, 그건 코드와 자동으로 동기화되지 않는 별도 산출물입니다. 서버가 응답 필드명을 <code>userId</code>에서 <code>user_id</code>로 바꾸거나 타입을 바꿔도, 클라이언트는 런타임에 실제 호출해 보기 전까지 알 수 없습니다. 특히 서비스마다 언어가 다르면(예: 결제는 Java, 추천은 Python, 게이트웨이는 Go) 각 팀이 요청·응답 모델을 언어별로 손으로 다시 정의하게 되고, 이 중복 정의가 조금씩 어긋나면서 통합 시점에 깨집니다.</p>
<p>gRPC는 이 계약을 <code>.proto</code> 파일이라는 <strong>단일 원천(single source of truth)</strong> 으로 못 박습니다. 여기서 각 언어의 클라이언트·서버 코드(스텁, 메시지 타입)를 자동 생성하므로, 모든 서비스가 정확히 같은 계약을 공유합니다. 필드를 바꾸면 스키마가 바뀌고, 코드를 다시 생성하는 순간 타입이 맞지 않는 곳이 <strong>컴파일 단계에서</strong> 드러납니다. 런타임에 터지던 오류가 빌드 시점으로 앞당겨지는 셈입니다. 게다가 Protobuf는 필드 번호 기반이라, 필드 추가/폐기 규칙만 지키면 구버전·신버전 서비스가 공존하는 하위 호환도 REST보다 다루기 명확합니다.</p>
<p>MSA로 서비스와 팀이 늘어날수록 이 &quot;손으로 맞추는 계약&quot;의 비용은 급격히 커집니다. 언어와 팀이 많은 조직일수록 gRPC의 코드 생성 계약이 주는 안정성이 성능 이득보다 결정적인 도입 근거가 됩니다.</p>
<p><strong>fleet 규모가 커서 CPU 절감이 실제 비용이 되는 곳</strong>
같은 28% CPU 절감이라도 서버 몇 대에서는 무의미하지만, 수백~수천 대 규모에서는 실제 인프라 비용 절감으로 이어집니다. 이 지점에 도달했다면 gRPC 전환의 손익이 뒤집힙니다.</p>
<p>정리하면, gRPC는 &quot;빠른 프로토콜&quot;이라기보다 <strong>내부 서비스 간 · 대형 구조화 · 고QPS · 폴리글랏 · 대규모 fleet</strong>라는 조건에 특화된 도구입니다. 우리 서비스는 아직 이 조건들과 거리가 있었기에 REST를 유지했을 뿐, 조건이 갖춰진 서비스라면 gRPC가 정답에 가깝습니다.</p>
<h2 id="마치며">마치며</h2>
<p>MSA로 분리하면서 서비스 간 동기 통신을 무엇으로 표준화할지 고민하는 분이라면, &quot;gRPC가 빠르다더라&quot;만 보고 결정하지 않기를 권합니다. 우리의 결론은 gRPC가 나쁘다는 것이 아니라, <strong>현재 우리 트래픽 특성과 조직 규모에서는 아직 REST로 충분하다</strong>는 것이었습니다. 판단 기준은 벤치마크 절대값이 아니라 자신의 payload 크기·CPU 프로파일·서비스 규모여야 합니다. 필요해지는 순간이 오면 그때 핫패스부터 부분적으로 gRPC를 도입해도 늦지 않습니다. 이 시리즈가 그 의사결정의 출발점이 되었으면 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[gRPC의 이해 — REST와 무엇이, 왜 다른가]]></title>
            <link>https://velog.io/@jw_kim/gRPC%EC%9D%98-%EC%9D%B4%ED%95%B4-REST%EC%99%80-%EB%AC%B4%EC%97%87%EC%9D%B4-%EC%99%9C-%EB%8B%A4%EB%A5%B8%EA%B0%80</link>
            <guid>https://velog.io/@jw_kim/gRPC%EC%9D%98-%EC%9D%B4%ED%95%B4-REST%EC%99%80-%EB%AC%B4%EC%97%87%EC%9D%B4-%EC%99%9C-%EB%8B%A4%EB%A5%B8%EA%B0%80</guid>
            <pubDate>Thu, 09 Jul 2026 15:57:48 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 <strong>REST vs gRPC 시리즈</strong></p>
<ol>
<li><strong>gRPC의 이해 — REST와 무엇이, 왜 다른가</strong> (현재 글)</li>
<li>실제 gRPC 도입을 위한 PoC 과정과 결국 REST를 결정한 이유 (작성 예정)</li>
</ol>
<p>이 시리즈는 사내에서 gRPC 도입을 검토하며 직접 벤치마크를 돌려본 경험을 바탕으로 합니다. 이번 글에서는 개념을 다지고, 다음 글에서 실제 PoC 과정과 최종적으로 REST를 선택하게 된 이유를 다룹니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>&quot;gRPC가 REST보다 빠르다&quot;는 말을 자주 듣습니다. 하지만 막상 도입을 검토하다 보면 이 문장은 반쪽짜리라는 걸 알게 됩니다. gRPC가 빠른 건 특정 조건에서이고, 그 조건이 무엇인지 이해하지 못하면 오버엔지니어링으로 이어지기 쉽습니다.</p>
<p>결국 두 방식의 차이는 <strong>직렬화 방식</strong>과 <strong>전송 프로토콜</strong>이라는 두 개의 층에서 발생합니다. 이 글에서는 이 두 층을 하나씩 뜯어보며 gRPC의 이득이 &quot;어디서&quot; 나오는지를 직관적으로 이해해 보겠습니다.</p>
<h2 id="1-두-방식의-스택-비교">1. 두 방식의 스택 비교</h2>
<p>REST와 gRPC를 비교할 때 흔히 &quot;REST vs gRPC&quot;로 대립시키지만, 정확히는 각각이 사용하는 기술 스택의 조합을 비교하는 것입니다. 이 글에서는 아래와 같은 전형적인 조합을 기준으로 이야기합니다.</p>
<table>
<thead>
<tr>
<th>계층</th>
<th>REST (전형)</th>
<th>gRPC</th>
</tr>
</thead>
<tbody><tr>
<td>인터페이스 정의</td>
<td>문서 / OpenAPI (느슨)</td>
<td>.proto 스키마 (엄격, 코드 생성)</td>
</tr>
<tr>
<td>직렬화</td>
<td>JSON (텍스트)</td>
<td>Protocol Buffers (바이너리)</td>
</tr>
<tr>
<td>전송</td>
<td>주로 HTTP/1.1</td>
<td>HTTP/2 (필수)</td>
</tr>
<tr>
<td>스트리밍</td>
<td>별도 (SSE / WebSocket)</td>
<td>네이티브 (서버 / 클라 / 양방향)</td>
</tr>
<tr>
<td>사람이 읽기</td>
<td>쉬움 (curl)</td>
<td>어려움 (바이너리)</td>
</tr>
<tr>
<td>브라우저</td>
<td>기본 지원</td>
<td>gRPC-Web 프록시 필요</td>
</tr>
</tbody></table>
<p>즉 우리가 &quot;REST vs gRPC 성능&quot;을 이야기할 때 실제로 비교하는 건 <strong>JSON/HTTP1.1 vs Protobuf/HTTP2</strong>입니다. 그래서 성능 차이의 상당 부분은 &quot;직렬화&quot;와 &quot;전송&quot; 두 요인이 합쳐진 결과라는 점을 기억해야 합니다.</p>
<h2 id="2-직렬화--json-vs-protobuf">2. 직렬화 — JSON vs Protobuf</h2>
<p>gRPC가 &quot;작다&quot;고 할 때 그 이득의 핵심은 <strong>필드명을 반복하지 않는다</strong>는 데 있습니다. 같은 상품 데이터 하나를 두 방식으로 인코딩해 보면 차이가 분명해집니다.</p>
<p>먼저 JSON입니다.</p>
<pre><code class="language-json">{&quot;id&quot;:42,&quot;name&quot;:&quot;Product-42&quot;,&quot;price&quot;:41.99,&quot;inStock&quot;:true,&quot;category&quot;:&quot;BOOKS&quot;}</code></pre>
<p>JSON은 <code>id</code>, <code>name</code>, <code>price</code>, <code>inStock</code>, <code>category</code>라는 필드명과 따옴표·콜론·중괄호가 레코드마다 매번 반복됩니다. 상품이 1,000개라면 이 라벨도 1,000번 실려 나가는 셈입니다.</p>
<p>반면 Protobuf는 필드명 없이 &quot;번호 + 값&quot;만 담습니다. 개념적으로 표현하면 아래와 같습니다.</p>
<pre><code>[08 2A]            // 필드1(id) = 42
[12 0A] Product-42 // 필드2(name)
[19 ..] 41.99      // price
[20 01]            // inStock = true</code></pre><p>필드명이 아예 없습니다. <code>08</code>, <code>12</code> 같은 1바이트 태그(필드번호 + 타입)만 붙을 뿐입니다. 이 &quot;라벨 제거&quot;가 바로 크기 이득의 원천입니다.</p>
<blockquote>
<p><strong>핵심 직관:</strong> 이득은 필드명이 반복되는 정도에 비례한다. 필드가 많고 레코드가 많을수록 JSON의 낭비가 커지고, 그만큼 Protobuf가 유리해진다.</p>
</blockquote>
<h2 id="3-전송--http11-vs-http2">3. 전송 — HTTP/1.1 vs HTTP/2</h2>
<p>gRPC는 HTTP/2 위에서만 동작합니다. 두 프로토콜의 가장 큰 차이는 <strong>멀티플렉싱</strong>입니다.</p>
<p>HTTP/1.1은 요청마다 커넥션이 필요하거나 커넥션을 순차적으로 재사용합니다. 대신 큰 응답은 여러 커넥션에 병렬로 나눠 처리할 수 있습니다. 반면 HTTP/2는 단일 커넥션에서 여러 스트림을 동시에 다중화하고 헤더도 압축합니다. 다만 큰 응답은 flow-control window에 걸릴 수 있다는 함정이 있습니다.</p>
<p>멀티플렉싱은 <strong>작은 요청이 많을 때</strong> 큰 이득입니다. 반대로 거대한 응답을 동시에 많이 보내면, 단일 커넥션이 오히려 병목이 될 수 있습니다. 이 부분은 다음 글의 실험에서 직접 관찰하게 됩니다(&quot;HTTP/2로 바꿨더니 오히려 느려진&quot; 역전 현상).</p>
<h2 id="4-가장-헷갈리는-부분--구조화-데이터-vs-blob">4. 가장 헷갈리는 부분 — 구조화 데이터 vs blob</h2>
<p>여기서 가장 흔한 오해를 짚고 넘어가야 합니다. <strong>&quot;데이터가 크면 무조건 gRPC가 유리하다&quot;는 틀린 말입니다.</strong> 기준은 크기가 아니라 그 바이트가 무엇으로 채워졌는가입니다.</p>
<p>이미지·동영상·zip 같은 <strong>blob(불투명한 덩어리)</strong> 은 이미 최종 바이트 형태라 직렬화할 구조 자체가 없습니다. 두 프로토콜 모두 그냥 바이트를 복사할 뿐이라 Protobuf의 이득이 거의 없습니다. 오히려 JSON에 base64로 실으면 크기가 약 33% 늘어나고, 이런 데이터는 Range 요청·캐싱·CDN을 쓸 수 있는 REST나 &quot;스토리지 + URL&quot; 방식이 더 유리합니다.</p>
<p>반대로 필드명과 타입이 붙은 객체가 수천 개인 <strong>구조화 데이터</strong>는 같은 데이터를 여러 방식으로 인코딩할 수 있고, JSON은 필드명을 매번 반복해 낭비합니다. 여기서 Protobuf는 라벨 제거로 크기와 CPU를 모두 절감하며, 그 이득은 레코드 수에 비례합니다.</p>
<blockquote>
<p><strong>한 줄 판별법:</strong> 텍스트 에디터로 열었을 때 <code>&quot;id&quot;:</code>, <code>&quot;name&quot;:</code>처럼 같은 필드명이 계속 반복되면 구조화 데이터(→ gRPC 유리), 알아볼 수 없는 덩어리면 blob(→ 프로토콜 무관)이다. 참고로 CSV는 필드명을 헤더에 한 번만 쓰므로 이미 효율적이라 blob에 가깝다.</p>
</blockquote>
<h2 id="정리하며">정리하며</h2>
<p>gRPC의 이득은 마법이 아니라 두 가지 구조적 선택에서 나옵니다. 하나는 <strong>Protobuf가 반복되는 필드명을 제거</strong>한다는 것, 다른 하나는 <strong>HTTP/2가 작은 요청을 멀티플렉싱</strong>한다는 것입니다. 그래서 gRPC의 이득은 payload가 크고 구조화될수록, 그리고 트래픽이 높을수록 커집니다.</p>
<p>바꿔 말하면, 소형 메시지에 저트래픽 환경이라면 REST(JSON/HTTP1.1)로도 충분한 경우가 많습니다. 그리고 blob성 데이터에는 gRPC의 이득이 거의 없습니다. &quot;빠르다더라&quot;는 소문만으로 도입하면 오버엔지니어링이 되기 쉬운 이유입니다.</p>
<p>다음 글에서는 이 개념들을 실제로 검증하기 위해 진행한 <strong>PoC 과정</strong>을 다룹니다. Spring Boot와 Docker, k6로 직접 벤치마크를 돌려 payload 크기별 처리량, 압축의 반전, 서비스 간 통신에서의 CPU 효율, 그리고 HTTP/2 역전 현상까지 측정한 결과를 공유하고, 그럼에도 우리가 최종적으로 REST를 선택하게 된 이유를 이야기하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[이벤트 없는 Saga — MSA 전환기에 REST로 데이터 정합성을 지킨 이야기]]></title>
            <link>https://velog.io/@jw_kim/%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%97%86%EB%8A%94-Saga-MSA-%EC%A0%84%ED%99%98%EA%B8%B0%EC%97%90-REST%EB%A1%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%95%ED%95%A9%EC%84%B1%EC%9D%84-%EC%A7%80%ED%82%A8-%EC%9D%B4%EC%95%BC%EA%B8%B0</link>
            <guid>https://velog.io/@jw_kim/%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%97%86%EB%8A%94-Saga-MSA-%EC%A0%84%ED%99%98%EA%B8%B0%EC%97%90-REST%EB%A1%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%95%ED%95%A9%EC%84%B1%EC%9D%84-%EC%A7%80%ED%82%A8-%EC%9D%B4%EC%95%BC%EA%B8%B0</guid>
            <pubDate>Tue, 07 Jul 2026 12:40:13 GMT</pubDate>
            <description><![CDATA[<h2 id="1-들어가며--예전엔-트랜잭션-하나면-끝났는데">1. 들어가며 — 예전엔 트랜잭션 하나면 끝났는데</h2>
<p>모놀리식으로 서비스를 운영할 때, 데이터 정합성은 사실 크게 고민할 거리가 아니었다. 주문을 받으면서 재고를 차감하는 로직이 있다고 해보자. 코드는 대충 이런 모양이었다.</p>
<pre><code class="language-kotlin">@Transactional
fun placeOrder(order: Order) {
    orderRepository.save(order)      // 주문 저장
    inventory.deduct(order.itemId)   // 재고 차감
}                                    // ← 여기서 커밋. 둘 다 성공 or 둘 다 롤백</code></pre>
<p><code>@Transactional</code> 하나면 충분했다. 주문 저장과 재고 차감이 같은 DB 안에 있으니, 둘 중 하나라도 실패하면 트랜잭션이 통째로 롤백된다. &quot;주문은 됐는데 재고는 안 빠진&quot; 어정쩡한 상태는 존재할 수 없었다. 데이터베이스의 ACID가 정합성을 알아서 지켜줬고, 우리는 그 위에 편하게 비즈니스 로직만 얹으면 됐다.</p>
<p>그러다 MSA 전환이 시작됐다.</p>
<p>오래된 모놀리식을 한 번에 걷어낼 수는 없었다. 그래서 새로운 기능은 신규 서비스로 떼어내되, 재고처럼 오래전부터 레거시 시스템이 쥐고 있던 데이터는 그 시스템에 그대로 두기로 했다. 신규 서비스는 자기 DB를 갖고, 레거시가 관리하는 재고는 API를 통해 빌려 쓰는 구조. 점진적 전환을 위한 현실적인 선택이었지만, 그 대가로 &quot;하나의 작업이 두 시스템에 걸치는&quot; 상황이 생겨났다.</p>
<p>우리 서비스는 더 이상 재고 테이블에 직접 접근하지 않고, 재고 시스템의 API를 REST로 호출해서 차감을 요청하게 됐다. 구조로 보면 이렇게 바뀐 것이다.</p>
<blockquote>
<p>📌 <strong>[다이어그램 1] 모놀리식 → MSA 구조 변화</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/658d50d9-4b7f-41bc-bd0e-47bb2225ad0f/image.png" alt=""></p>
<p>그리고 바로 그 순간, 예전엔 존재하지도 않던 질문이 튀어나왔다.</p>
<p><strong>&quot;주문은 우리 DB에 저장했는데, 재고 차감 REST를 호출하는 중에 네트워크가 끊기면? 재고가 빠진 건가, 안 빠진 건가?&quot;</strong></p>
<p><code>@Transactional</code> 어노테이션 하나로 끝나던 일이, 서비스가 쪼개지자 갑자기 풀기 어려운 문제가 됐다. 우리 DB의 커밋과 남의 시스템의 상태 변경을 하나의 트랜잭션으로 묶을 방법이 없기 때문이다. DB가 다르니 한쪽의 롤백이 다른 쪽에는 닿지 않는다.</p>
<p>이 글은 그 문제 — <strong>여러 서비스에 걸친 하나의 작업에서 데이터 정합성을 어떻게 지킬 것인가</strong> — 를 두고 우리가 Saga 패턴을 도입하기까지의 과정을 정리한 것이다. 흔히 Saga라고 하면 &quot;이벤트를 발행하고 보상 트랜잭션을 실행하는 것&quot;으로만 알려져 있는데, 실제로 부딪혀 보니 그건 Saga의 여러 얼굴 중 하나일 뿐이었다. 우리가 왜 이벤트 없이도 Saga를 구현했는지, 그리고 네트워크가 끊겼을 때 &quot;실패했다&quot;가 아니라 &quot;모른다&quot;를 다루는 것이 왜 이 문제의 핵심이었는지를 순서대로 풀어보려 한다.</p>
<hr>
<h2 id="2-왜-하나로-못-묶는가--분산-트랜잭션의-벽">2. 왜 하나로 못 묶는가 — 분산 트랜잭션의 벽</h2>
<p>문제를 정확히 들여다보자. 우리가 하려는 일은 두 단계다.</p>
<p>첫째, 우리 서비스의 DB에 주문을 저장한다. 둘째, 재고 시스템에 REST로 재고 차감을 요청한다. 이 둘은 &quot;함께 성공하거나 함께 실패해야&quot; 의미가 있다. 주문만 저장되고 재고가 안 빠지면 팔 수 없는 물건을 판 셈이고, 재고만 빠지고 주문이 안 남으면 고객은 돈을 냈는데 기록이 없는 셈이다.</p>
<p>모놀리식이라면 이걸 <code>@Transactional</code> 하나로 묶으면 그만이었다. 하지만 지금은 두 작업이 <strong>서로 다른 서비스, 서로 다른 DB</strong>에서 일어나고, 그 사이를 REST가 잇는다. 우리 DB의 트랜잭션은 우리 DB 안에서만 유효하다. 재고 시스템에서 무슨 일이 일어나든, 우리 쪽 롤백은 그 시스템에 손을 뻗칠 수 없다. 반대도 마찬가지다. 하나의 원자적 트랜잭션으로 두 시스템을 묶을 방법이 원천적으로 없는 것이다.</p>
<h3 id="그럼-2pc-쓰면-되지-않나">&quot;그럼 2PC 쓰면 되지 않나?&quot;</h3>
<p>분산 트랜잭션이라고 하면 흔히 <strong>2PC(Two-Phase Commit, 2단계 커밋)</strong> 를 떠올린다. 코디네이터가 참여자 모두에게 &quot;커밋할 준비 됐냐&quot;고 묻고(prepare), 전원이 &quot;준비됐다&quot;고 답하면 그제서야 &quot;커밋해&quot;라고 명령하는(commit) 방식이다. 이론적으로는 여러 DB에 걸친 원자성을 보장해준다.</p>
<p>그런데 우리 상황에는 맞지 않았다. 이유는 두 가지다.</p>
<p>하나, <strong>레거시 재고 시스템이 2PC를 지원하지 않는다.</strong> 애초에 분산 트랜잭션 프로토콜에 참여할 생각으로 만들어진 시스템이 아니다. 우리가 쓸 수 있는 건 평범한 REST API뿐이다.</p>
<p>둘, 설령 지원한다 해도 <strong>2PC는 마이크로서비스 경계에서 좋은 선택이 아니다.</strong> 2PC는 준비 단계부터 커밋까지 참여자들의 자원(락)을 붙잡아 둔다. 참여자 중 하나라도 느리거나 응답이 없으면 나머지 전부가 그만큼 기다린다. 한 서비스의 지연이나 장애가 트랜잭션에 엮인 모든 서비스로 번지는 것이다. 서비스를 쪼갠 이유가 독립성과 가용성이었는데, 2PC는 그 서비스들을 다시 하나의 운명공동체로 묶어버린다. 얻으려던 것을 도로 잃는 셈이다.</p>
<p>그래서 2PC는 접었다. 남은 길은 &quot;두 시스템을 하나로 묶는&quot; 대신, &quot;따로따로 진행하되 어긋나면 바로잡는&quot; 방향이었다. 다만 그 길로 가기 전에, 진짜 골치 아픈 지점을 먼저 마주해야 했다.</p>
<h3 id="네트워크-유실의-세-가지-얼굴">네트워크 유실의 세 가지 얼굴</h3>
<p>주문을 우리 DB에 저장하고, 이제 재고 차감 REST를 호출했다고 하자. 그런데 응답이 오지 않는다. 타임아웃. 혹은 5xx 에러. 자, 지금 재고는 빠진 걸까, 안 빠진 걸까?</p>
<p>문제는 이 &quot;예외&quot; 하나가 실제로는 <strong>전혀 다른 세 가지 상황</strong>을 뭉뚱그리고 있다는 것이다.</p>
<blockquote>
<p>📌 <strong>[다이어그램 2] 하나의 예외, 세 가지 실제 상황</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/d064bd67-4e1c-45c1-ab7b-4ae067f3cb0c/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>상황</th>
<th>재고 시스템의 실제 상태</th>
<th>호출자(우리)가 아는 것</th>
</tr>
</thead>
<tbody><tr>
<td>① 요청이 도달하지 못함</td>
<td>차감 <strong>안 됨</strong></td>
<td>timeout / 예외 — <strong>결과 모름</strong></td>
</tr>
<tr>
<td>② 차감은 됐으나 응답이 유실됨</td>
<td>차감 <strong>됨</strong></td>
<td>timeout / 예외 — <strong>결과 모름</strong></td>
</tr>
<tr>
<td>③ 내부 오류로 실제 실패</td>
<td>차감 <strong>안 됨</strong></td>
<td>5xx — <strong>결과 모름</strong></td>
</tr>
</tbody></table>
<p>세 경우가 호출자 입장에서는 구별되지 않는다. 전부 똑같이 &quot;예외가 났다&quot;로만 보인다. 재고 시스템 안에서 실제로 무슨 일이 일어났는지, 우리 쪽에서는 알 도리가 없다.</p>
<p>여기서 성급하게 판단하면 두 종류의 지옥이 열린다.</p>
<p><strong>&quot;실패했겠지&quot; 하고 재고를 다시 차감하면?</strong> 만약 실제로는 ②(차감은 됐는데 응답만 유실된) 상황이었다면, 재고가 두 번 빠진다. <strong>이중 차감</strong>이다.</p>
<p><strong>&quot;성공했겠지&quot; 하고 주문을 확정하면?</strong> 만약 실제로는 ①이나 ③(차감이 안 된) 상황이었다면, 재고는 그대로인데 주문만 확정된다. 팔 수 없는 물건을 판, 재고 없는 <strong>유령 주문</strong>이다.</p>
<p>어느 쪽도 허용할 수 없었다. 이중 차감은 고객의 손해고, 유령 주문은 시스템의 거짓말이다. 그리고 이 문제의 본질은 결국 하나였다 — <strong>우리는 &quot;성공했다&quot;도 &quot;실패했다&quot;도 단정할 수 없는, &quot;모른다&quot;는 상태를 다뤄야 한다.</strong> 분산 트랜잭션의 진짜 어려움은 여기에 있었다.</p>
<p>이 &quot;모른다&quot;를 어떻게 정직하게 표현하고, 어떻게 나중에 올바른 결말로 수렴시킬 것인가. 그 답을 찾는 과정에서 Saga 패턴을 만났다.</p>
<hr>
<h2 id="3-saga-패턴이란--그리고-흔한-오해-하나">3. Saga 패턴이란 — 그리고 흔한 오해 하나</h2>
<p>&quot;모른다&quot;를 다뤄야 한다는 데까지 왔으니, 이제 도구를 찾을 차례였다. 여러 서비스에 걸친 트랜잭션, 검색하면 가장 먼저 나오는 이름이 <strong>Saga</strong>다.</p>
<p>그런데 Saga를 공부하면서 한 가지 걸리는 게 있었다. 블로그 글이든 발표 자료든, Saga를 설명하는 글의 태반이 이런 식으로 시작한다. &quot;서비스 A가 이벤트를 발행하면, 서비스 B가 그걸 구독해서 처리하고, 실패하면 보상 이벤트를 쏘고…&quot; 카프카(Kafka)나 메시지 브로커 그림이 빠지지 않고 등장한다. 그러다 보니 자연스럽게 이런 인상이 남는다. <strong>&quot;Saga = 이벤트 기반 아키텍처(EDA)에서 쓰는 것.&quot;</strong></p>
<p>우리 상황은 이벤트도, 메시지 브로커도 없었다. 그냥 REST로 재고 시스템을 호출할 뿐이었다. 그럼 우리는 Saga를 쓸 수 없는 걸까? 결론부터 말하면, <strong>아니다.</strong> 이 오해를 풀고 나서야 문제가 제대로 풀리기 시작했다.</p>
<h3 id="saga의-진짜-정의">Saga의 진짜 정의</h3>
<p>Saga는 이벤트에 관한 패턴이 아니다. Saga는 <strong>하나의 큰 분산 트랜잭션을, 각각이 로컬 트랜잭션인 여러 단계의 시퀀스로 쪼개는</strong> 패턴이다. 전체를 한 번에 커밋하거나 롤백하는 대신, 단계별로 진행하고, 중간에 실패하면 앞서 성공한 단계를 되돌리는 <strong>보상 트랜잭션(compensation)</strong> 을 실행해 일관성을 맞춘다.</p>
<p>여기서 얻는 것과 잃는 것이 분명하다. ACID의 원자성(All-or-Nothing)은 포기한다. 대신 <strong>결과적 일관성(eventual consistency)</strong> 을 얻는다. 잠깐 어긋난 상태가 존재할 수는 있어도, 결국에는 올바른 상태로 수렴한다는 뜻이다.</p>
<p>이 정의 어디에도 &quot;이벤트&quot;나 &quot;메시지 브로커&quot;라는 말은 없다. Saga는 통신 방식에 관한 패턴이 아니라, <strong>분산 환경에서 데이터 정합성을 확보하기 위한 아키텍처 패턴</strong>이다. 실제로 Saga는 <a href="https://dl.acm.org/doi/10.1145/38713.38742">1987년 데이터베이스 논문(&quot;Sagas&quot;, Hector Garcia-Molina &amp; Kenneth Salem, ACM SIGMOD)</a>에서 나온 오래된 개념으로, 마이크로서비스도 카프카도 없던 시절의 아이디어다. 이벤트는 나중에 이 패턴을 구현하는 여러 방법 중 하나로 붙었을 뿐이다.</p>
<h3 id="그럼-이벤트는-어디서-나온-걸까--조율-방식-두-가지">그럼 이벤트는 어디서 나온 걸까 — 조율 방식 두 가지</h3>
<p>혼란의 진짜 원인은, Saga를 <strong>조율(coordinate)하는 방식</strong>이 두 가지라는 데 있다. 많은 글이 그중 하나만 설명하면서 그게 Saga의 전부인 것처럼 말한다.</p>
<blockquote>
<p>📌 <strong>[다이어그램 3] Saga 조율 방식 두 가지</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/2846c732-7a62-4489-8558-28dd862cc80b/image.png" alt=""></p>
<p><strong>Choreography(코레오그래피)</strong> 는 중앙 조율자가 없다. 각 서비스가 이벤트를 발행하고, 다른 서비스가 그 이벤트를 구독해서 스스로 다음 단계를 수행한다. 서비스 간 결합도가 낮은 대신, 전체 흐름이 여러 서비스에 흩어져 있어 &quot;지금 이 트랜잭션이 어디까지 왔는지&quot; 추적하기가 어렵다. 그리고 바로 <strong>이 방식이 이벤트와 메시지 브로커를 쓴다.</strong> 우리가 흔히 보는 &quot;Saga = 이벤트&quot; 설명은 사실 이 코레오그래피 하나만 놓고 이야기한 것이다.</p>
<p><strong>Orchestration(오케스트레이션)</strong> 은 중앙에 오케스트레이터를 둔다. 이 오케스트레이터가 각 단계를 순서대로 직접 호출하고, 결과를 판단하고, 실패 시 보상까지 지휘한다. 흐름이 한곳에 모여 있어 추적과 디버깅이 쉽다. 그리고 중요한 점 — <strong>이때 서비스를 부르는 방법은 이벤트일 필요가 없다. 평범한 REST 동기 호출로도 전혀 문제없다.</strong></p>
<p>정리하면 이렇다. 이벤트는 Saga의 필수 요소가 아니라, 코레오그래피라는 조율 방식이 쓰는 통신 수단일 뿐이다. 오케스트레이션으로 가면, 이벤트도 브로커도 없이 REST만으로 얼마든지 Saga를 구현할 수 있다.</p>
<h3 id="우리의-선택--rest-기반-orchestration">우리의 선택 — REST 기반 Orchestration</h3>
<p>우리는 오케스트레이션을 택했다. 이유는 명확했다.</p>
<p>우선 우리에게는 애초에 이벤트 인프라(메시지 브로커)가 없었고, 이 문제 하나 때문에 도입하는 건 과했다. 그리고 재고 차감이라는 흐름 자체가 &quot;주문 저장 → 재고 차감 → 결과 확정&quot;으로 순서가 뚜렷했다. 이렇게 단계가 분명한 흐름은, 흩어진 이벤트로 쫓아다니는 것보다 한 명의 오케스트레이터가 순서대로 지휘하는 편이 훨씬 추적하기 쉽다. 무엇보다 우리가 다뤄야 할 핵심이 &quot;네트워크가 끊겨 결과를 모르는 상황&quot;이었는데, 흐름이 한곳에 모여 있어야 그 애매한 상태를 붙잡고 관리하기가 쉬웠다.</p>
<p>그래서 우리 서비스 안에 오케스트레이터 역할을 하는 컴포넌트를 두고, 이 녀석이 &quot;주문 상태를 기록하고 → 재고 시스템을 REST로 호출하고 → 결과를 확정하는&quot; 과정을 직접 순서대로 지휘하게 만들었다. 이벤트는 한 줄도 쓰지 않았다.</p>
<p>다만 여기서 끝이 아니었다. 전통적인 Saga는 &quot;실패하면 즉시 보상 트랜잭션을 실행&quot;한다. 그런데 우리 문제의 핵심은 애초에 <strong>실패했는지조차 모르는</strong> 상황 아니었나. 즉시 보상할 수도 없다. 무엇을 보상해야 할지 모르니까. 그래서 우리는 오케스트레이션 Saga에 한 가지를 더 결합했다 — 끊긴 흐름을 나중에 다시 밀어 올바른 결말로 수렴시키는 <strong>Reconciliation(보정)</strong> 이다. 이 이야기가 다음 장의 주제다.</p>
<hr>
<h2 id="4-우리의-해법--모른다를-정직하게-다루는-세-가지">4. 우리의 해법 — &quot;모른다&quot;를 정직하게 다루는 세 가지</h2>
<p>앞에서 정리한 문제의 본질은 이거였다. 네트워크가 끊기면 우리는 &quot;성공했다&quot;도 &quot;실패했다&quot;도 단정할 수 없다. 그래서 우리 설계의 출발점은 <strong>&quot;모른다&quot;를 억지로 성공이나 실패로 우기지 않고, 있는 그대로 하나의 상태로 표현하자</strong>는 것이었다.</p>
<p>이걸 세 가지 아이디어로 풀었다.</p>
<h3 id="아이디어-1--차감을-시도하기-전에-시도할-것을-먼저-기록한다">아이디어 1 — 차감을 시도하기 전에, &quot;시도할 것&quot;을 먼저 기록한다</h3>
<p>가장 먼저 바꾼 습관은 순서다. 보통은 재고 시스템을 호출하고, 성공하면 그 결과를 우리 DB에 기록한다. 우리는 이 순서를 뒤집었다. <strong>재고 차감을 호출하기 전에, &quot;이 주문에 대해 재고 차감을 시도할 것&quot;이라는 흔적을 우리 DB에 먼저 남겨 커밋</strong>한다. 이 상태를 <code>REQUESTED</code>(요청됨)라고 부르자.</p>
<p>왜 미리 남길까? 만약 호출 도중 네트워크가 끊겨 흐름이 통째로 죽어버려도, DB에는 &quot;이 주문은 재고 차감을 시도하려다 끊겼다&quot;는 기록이 남아 있기 때문이다. 이 흔적이 있어야 나중에 누군가(뒤에 나올 보정 스케줄러)가 &quot;아, 이건 마무리가 안 됐네, 다시 밀어줘야겠다&quot;고 알아챌 수 있다. 만약 호출 성공 후에야 기록했다면, 끊긴 순간엔 아무 흔적도 없어 영영 미아가 됐을 것이다.</p>
<p>그래서 오케스트레이터는 하나의 주문 처리를 <strong>트랜잭션 세 덩어리</strong>로 나눈다.</p>
<blockquote>
<p>📌 <strong>[다이어그램 4] 정상 흐름 — Tx1(상태 기록) → REST 차감 → Tx2(최종 확정)</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/556d57ab-b792-4aae-8d3c-0d76009e1a22/image.png" alt=""></p>
<p>여기서 한 가지 중요한 원칙이 있다. <strong>외부 REST 호출은 어떤 DB 트랜잭션에도 걸쳐서는 안 된다.</strong> 만약 <code>Tx1</code>을 연 채로 재고 시스템을 호출하면, 응답이 느릴 경우 그 시간 내내 우리 DB 커넥션과 락이 붙잡혀 있게 된다. 남의 시스템 지연이 우리 DB 자원을 갉아먹는 것이다. 그래서 상태 기록(<code>Tx1</code>)을 확실히 커밋해 닫은 다음, 트랜잭션 바깥에서 REST를 호출하고, 결과가 오면 다시 새 트랜잭션(<code>Tx2</code>)을 열어 최종 상태를 기록한다.</p>
<p>의사코드로 보면 골격은 이렇다.</p>
<pre><code class="language-kotlin">// Tx1 — &quot;차감을 시도할 것&quot;을 먼저 커밋
val requestId = txTemplate.execute {
    repository.insert(status = REQUESTED)   // commit
}

// (트랜잭션 밖) 재고 시스템 REST 호출 — 어떤 트랜잭션에도 걸리지 않음
val result = inventoryClient.deduct(userId, requestId)

// Tx2 — 결과에 따라 최종 상태 확정
when (result) {
    is Deducted -&gt; txTemplate.execute { repository.update(requestId, APPLIED) }
    is NoStock  -&gt; txTemplate.execute { repository.update(requestId, REJECTED) }
}</code></pre>
<h3 id="아이디어-2--네트워크가-끊기면-성공도-실패도-아닌-채로-보류한다">아이디어 2 — 네트워크가 끊기면, 성공도 실패도 아닌 채로 &quot;보류&quot;한다</h3>
<p>이제 핵심이다. 재고 차감 REST가 타임아웃이 나거나, 5xx를 받거나, 혹은 뒤에 붙인 서킷 브레이커가 열려서 호출 자체가 막혔다고 하자. 이때 우리는 <strong>아무것도 단정하지 않는다.</strong></p>
<p>구현은 오히려 담백하다. 이런 네트워크성 오류가 나면 클라이언트가 예외를 던지고, 이 예외를 <strong>일부러 잡지 않는다.</strong> 예외가 그대로 전파되면 위 코드의 <code>Tx2</code>(최종 상태 확정)는 실행되지 못한다. 그 결과 해당 주문 행은 <code>Tx1</code>에서 커밋해 둔 <code>REQUESTED</code> 상태 그대로 남는다.</p>
<p>이게 바로 앞 장에서 말한 <strong>&quot;모른다&quot;를 하나의 상태로 표현한 것</strong>이다.</p>
<pre><code class="language-text">성공했나?  → 모름
실패했나?  → 모름
그래서?    → REQUESTED 상태로 그냥 둔다. 판단을 뒤로 미룬다.</code></pre>
<p>이렇게 두면 이중 차감도, 유령 주문도 생기지 않는다. 재차감을 하지 않으니 이중 차감이 없고, 주문을 확정하지 않으니 유령 주문도 없다. 그저 &quot;아직 결말이 안 난 주문&quot;으로 남겨둘 뿐이다.</p>
<p>한 가지 구분할 게 있다. <strong>재고 부족(예: 409 응답)은 네트워크 오류가 아니다.</strong> 이건 재고 시스템이 &quot;차감할 재고가 없다&quot;고 명확하게 알려준 정상적인 비즈니스 결과다. 결과를 모르는 게 아니라 분명히 아는 것이므로, 이 경우는 보류하지 않고 즉시 <code>REJECTED</code>로 확정한다. &quot;모른다&quot;와 &quot;안 된다&quot;는 전혀 다른 상태다.</p>
<h3 id="아이디어-3--정체된-주문을-나중에-다시-밀어-결말을-낸다-reconciliation">아이디어 3 — 정체된 주문을, 나중에 다시 밀어 결말을 낸다 (Reconciliation)</h3>
<p><code>REQUESTED</code>로 보류된 주문은 그대로 두면 영영 결말이 안 난다. 누군가는 이 미아들을 데려와 결말을 지어줘야 한다. 그 역할이 <strong>Reconciliation(보정)</strong> 이다.</p>
<p>주기적으로 도는 스케줄러가 이렇게 동작한다.</p>
<p>먼저 일정 시간(예: 몇 분) 이상 <code>REQUESTED</code> 상태로 정체된 주문들을 골라낸다. 방금 시작해 아직 진행 중일 수도 있는 건을 건드리지 않도록, 충분히 오래 멈춰 있는 것만 대상으로 삼는다. 그다음 각 주문에 대해 재고 차감을 <strong>다시 호출</strong>한다. 그 결과에 따라 성공이면 <code>APPLIED</code>로, 재고 부족이면 <code>REJECTED</code>로 최종 상태를 확정한다. 만약 이번에도 네트워크가 불안정해 또 실패하면? 상태를 그대로 <code>REQUESTED</code>로 두고, 다음 주기에 또 시도한다. 될 때까지 민다.</p>
<blockquote>
<p>📌 <strong>[다이어그램 5] 상태 전이 — REQUESTED를 중심으로</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/68603c72-3679-452b-a182-6b5365ca9298/image.png" alt=""></p>
<p>여기서 자연스럽게 떠오르는 질문. <strong>&quot;다시 호출한다고? 아까 ②번(차감은 됐는데 응답만 유실된) 경우였으면, 재고가 또 빠지는 거 아냐?&quot;</strong> 맞다. 이 재시도가 안전하려면 반드시 짚고 넘어가야 할 전제가 하나 있다. 바로 <strong>멱등성</strong>이다. 이게 세 아이디어 전체를 떠받치는 기둥인데, 다음 장에서 따로 다룬다.</p>
<hr>
<h2 id="5-멱등성--다시-불러도-안전하다는-약속">5. 멱등성 — &quot;다시 불러도 안전하다&quot;는 약속</h2>
<p>앞 장의 마지막 질문으로 돌아가자. 보정 스케줄러가 실패한 것 같은 주문을 다시 호출한다. 그런데 만약 그 주문이 사실은 <strong>첫 시도에서 이미 재고가 빠졌는데 응답만 유실된</strong> 경우였다면? 다시 호출하는 순간 재고가 두 번 빠진다. 우리가 그렇게 피하고 싶었던 이중 차감이다.</p>
<p>즉, &quot;실패하면 다시 부른다&quot;는 전략은 그 자체로는 위험하다. 이 전략이 성립하려면 반드시 하나의 전제가 필요하다.</p>
<blockquote>
<p><strong>같은 요청을 몇 번을 다시 불러도, 재고는 딱 한 번만 빠진다.</strong></p>
</blockquote>
<p>이 성질을 <strong>멱등성(idempotency)</strong> 이라고 한다. 같은 연산을 여러 번 수행해도 결과가 한 번 수행한 것과 같은 것. 재시도 기반의 모든 설계는 결국 이 멱등성 위에 서 있다. 멱등성이 없으면 재시도는 축복이 아니라 재앙이다.</p>
<h3 id="무엇으로-같은-요청임을-알아보는가">무엇으로 &quot;같은 요청&quot;임을 알아보는가</h3>
<p>멱등성의 출발점은 &quot;이 요청이 아까 그 요청과 같은 것&quot;임을 시스템이 알아보는 것이다. 그러려면 요청마다 고유한 <strong>멱등 키(idempotency key)</strong> 가 필요하다.</p>
<p>우리는 별도의 키를 새로 만드는 대신, 이미 갖고 있던 <strong>주문 요청 ID(앞서 <code>Tx1</code>에서 만든 그 행의 ID)</strong> 를 멱등 키로 썼다. 첫 시도든 열 번째 재시도든, 같은 주문이면 같은 ID로 재고 시스템을 부른다. 재고 시스템은 이 ID를 보고 &quot;아, 이 요청은 아까 이미 처리했네&quot; 하고 판단할 수 있다. 새 키를 위한 별도 컬럼을 만들지 않은 건 의도적이었다 — 이미 있는 식별자로 충분한데 스키마를 괜히 복잡하게 만들 이유가 없었다.</p>
<h3 id="멱등성을-한-겹이-아니라-여러-겹으로">멱등성을 한 겹이 아니라 여러 겹으로</h3>
<p>그런데 멱등성을 재고 시스템 한 곳에만 맡기기엔 불안했다. 그래서 서로 다른 층위에서 <strong>세 겹의 방어</strong>를 뒀다. 각 겹이 막는 문제가 조금씩 다르다.</p>
<blockquote>
<p>📌 <strong>[다이어그램 6] 멱등성 3중 방어</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/fd07d8fd-b7e0-45ef-80df-2f788c0fba56/image.png" alt=""></p>
<p><strong>첫 번째 겹은 근접 중복을 막는다.</strong> 사용자가 버튼을 빠르게 두 번 누르거나, 요청이 거의 동시에 두 개 들어오는 경우가 있다. 이런 찰나의 중복은 짧은 TTL을 가진 <strong>분산 락</strong>으로 직렬화한다. 같은 주문에 대한 요청은 한 번에 하나씩만 통과시켜, 애초에 동시 진입을 막는다.</p>
<p><strong>두 번째 겹은 영속적인 유일성을 보장한다.</strong> 락은 짧은 순간의 동시성만 막을 뿐, 시간을 두고 벌어진 중복까지 막지는 못한다. 그래서 DB에 <strong>UNIQUE 제약</strong>을 걸어, &quot;같은 주문에 대한 차감 행은 하나만 존재할 수 있다&quot;를 데이터베이스 차원에서 강제한다. 락을 어찌어찌 빠져나간 중복 시도가 있더라도, 여기서 DB가 거부한다.</p>
<p><strong>세 번째 겹이 진짜 핵심, 재고 시스템의 멱등 차감이다.</strong> 앞의 두 겹은 우리 서비스 안에서의 중복을 막지만, 정작 우리가 두려워한 시나리오(②번, 차감됐는데 응답만 유실 → 재시도)는 재고 시스템 쪽에서 막아야 한다. 재고 시스템은 같은 주문 ID로 다시 차감 요청이 오면, 재고를 또 빼지 않고 &quot;이미 처리된 요청&quot;이라고 응답한다. 취소(보상) 요청도 마찬가지로 멱등하게 동작한다. 이 겹이 있기에 비로소 &quot;실패하면 다시 부른다&quot;는 보정 전략이 안전해진다.</p>
<p>정리하면, 근접 중복은 락이, 시간차 중복은 DB 유니크가, 그리고 재시도로 인한 중복은 재고 시스템의 멱등 차감이 막는다. 세 겹이 각자 다른 각도의 중복을 걸러내면서, &quot;몇 번을 불러도 재고는 한 번만&quot;이라는 약속이 완성된다.</p>
<h3 id="짚고-넘어갈-의존성-하나">짚고 넘어갈 의존성 하나</h3>
<p>솔직하게 적자면, 이 설계의 안전성은 결국 <strong>재고 시스템이 멱등 차감을 제대로 구현하고 있다는 사실에 기댄다.</strong> 만약 재고 시스템이 같은 요청을 두 번 받았을 때 정말로 두 번 빼버린다면, 우리의 세 번째 겹은 무너진다. 다행히 우리 경우엔 재고 시스템이 이 계약을 지켜줬지만, 이런 재시도 기반 설계를 택할 때는 &quot;내가 호출하는 상대가 멱등한가&quot;를 반드시 먼저 확인해야 한다. 이건 뒤의 트레이드오프 장에서 다시 짚겠다.</p>
<hr>
<h2 id="6-그래서-어떻게-끝나는가--시나리오별-정리">6. 그래서 어떻게 끝나는가 — 시나리오별 정리</h2>
<p>지금까지 조각조각 설명한 것들을, 실제로 일어날 수 있는 상황별로 한 줄에 모아보자. 핵심은 <strong>어떤 상황이든 결국 <code>APPLIED</code>(차감 확정) 아니면 <code>REJECTED</code>(차감 안 됨)라는 명확한 결말로 수렴한다</strong>는 것이다. 애매한 채로 영영 남는 주문은 없다.</p>
<table>
<thead>
<tr>
<th>시나리오</th>
<th>재고 차감 응답</th>
<th>즉시 처리</th>
<th>최종 수렴</th>
</tr>
</thead>
<tbody><tr>
<td><strong>정상</strong></td>
<td>200 (차감 성공)</td>
<td><code>Tx2</code> → <code>APPLIED</code></td>
<td>즉시 완료</td>
</tr>
<tr>
<td><strong>재고 부족</strong></td>
<td>409</td>
<td><code>Tx2</code> → <code>REJECTED</code></td>
<td>즉시 완료 (차감 없음)</td>
</tr>
<tr>
<td><strong>요청 미도달 / 응답 유실</strong></td>
<td>timeout · 5xx · 서킷 열림</td>
<td><code>REQUESTED</code> 유지 (예외 전파)</td>
<td>몇 분 뒤 보정이 멱등 재호출 → <code>APPLIED</code> / <code>REJECTED</code></td>
</tr>
<tr>
<td><strong>차감됐으나 그새 주문 무효화</strong></td>
<td>(재호출 시) 이미 차감됨</td>
<td>—</td>
<td>보정이 취소(보상) 후 <code>REJECTED</code></td>
</tr>
<tr>
<td><strong>오래 지나도 계속 실패</strong></td>
<td>지속적 실패</td>
<td><code>REQUESTED</code> 유지</td>
<td>임계 시간 초과 시 알람 → 사람 개입</td>
</tr>
</tbody></table>
<p>위에서 세 번째 줄(요청 미도달 / 응답 유실)이 이 글 전체가 씨름해 온 바로 그 &quot;모른다&quot; 케이스다. 즉시 판단하지 않고 <code>REQUESTED</code>로 보류했다가, 보정이 나중에 멱등하게 다시 밀어 결말을 낸다.</p>
<p>네 번째 줄은 조금 더 미묘한 경우다. 첫 시도에서 재고는 이미 빠졌는데(②번 상황), 그 사이 주문 자체가 다른 이유로 무효가 되어버릴 수 있다. 이때 보정이 재호출하면 재고 시스템은 &quot;이미 차감했다&quot;고 답한다. 그런데 주문은 이제 확정할 수 없는 상태다. 그냥 두면 재고만 빠지고 주문은 없는, 거꾸로 된 유령이 된다. 그래서 이 경우엔 보정이 <strong>취소(보상) 요청을 보내 빠진 재고를 되돌린 뒤</strong> <code>REJECTED</code>로 확정한다. 여기서 전통적 Saga의 보상 트랜잭션이 실제로 등장하는 셈이다.</p>
<p>마지막 줄이 중요하다. 아무리 잘 설계해도, 재고 시스템이 오래도록 응답하지 않으면 어떤 주문은 계속 <code>REQUESTED</code>에 머문다. 이걸 무한정 조용히 재시도만 하면, 문제가 곪는 걸 아무도 모른다. 그래서 일정 시간(예: 한 시간)을 넘겨도 수렴하지 못한 주문이 있으면 <strong>에러 로그로 알람을 울려 사람의 개입을 요청</strong>한다. 자동 복구가 닿지 못하는 영역은, 자동인 척하지 않고 사람에게 넘기는 게 맞다.</p>
<p>이렇게 다섯 갈래를 다 그려놓고 나니 비로소 안심이 됐다. 어떤 경로로 흘러가든, 시스템은 거짓말(유령 주문)을 하지 않고, 손해(이중 차감)를 끼치지 않으며, 자기가 감당 못 하는 상황은 솔직하게 사람을 부른다.</p>
<hr>
<h2 id="7-왜-outbox는-아니었나--고려한-대안">7. 왜 Outbox는 아니었나 — 고려한 대안</h2>
<p>&quot;네트워크가 끊겨도 재고 차감을 결국 전달한다&quot;는 목표에 이르는 길은 우리가 택한 Reconciliation만 있는 게 아니다. 설계 단계에서 진지하게 저울질한 또 다른 후보가 <strong>Outbox 패턴</strong>이었다. 이 선택의 갈림길을 짚어두는 게, 결과만 아는 것보다 훨씬 도움이 될 것 같다.</p>
<h3 id="outbox-패턴이란">Outbox 패턴이란</h3>
<p>Outbox 패턴의 핵심 아이디어는 이렇다. 우리 DB에 비즈니스 데이터를 저장하는 바로 그 트랜잭션 안에서, &quot;이런 메시지를 외부로 보내야 한다&quot;는 기록을 <code>outbox</code>라는 전용 테이블에 함께 남긴다. 비즈니스 데이터 저장과 발송 예약이 같은 로컬 트랜잭션이므로 원자적이다. 둘 다 커밋되거나, 둘 다 롤백된다.</p>
<p>그다음 별도의 발행 워커(publisher)가 이 <code>outbox</code> 테이블을 계속 폴링하면서, 아직 안 보낸 메시지를 꺼내 외부로 전송하고, 성공하면 &quot;보냄&quot;으로 표시한다. 이렇게 하면 &quot;DB에는 저장됐는데 메시지는 유실되는&quot; 일이 원천적으로 사라진다. 발송 기록이 DB에 안전하게 남아 있으니, 워커가 될 때까지 재시도할 수 있다.</p>
<blockquote>
<p>📌 <strong>[다이어그램 7] Outbox 패턴 vs Reconciliation 패턴</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/8463649d-3749-4c05-ac05-540edf196df5/image.png" alt=""></p>
<p>얼핏 보면 우리 문제에도 잘 맞는 것 같다. 실제로 발송 보장이라는 관점에서는 견고한 패턴이다.</p>
<h3 id="그런데-왜-안-썼나">그런데 왜 안 썼나</h3>
<p>결정적인 이유는, 우리 경우엔 Outbox가 <strong>과설계</strong>였기 때문이다.</p>
<p>Outbox의 강점은 &quot;일단 나간 메시지는 어떻게든 전달된다&quot;는 fire-and-forget 상황, 즉 <strong>보낸 뒤 결과를 되물어볼 수 없는</strong> 경우에 빛난다. 그런데 재고 차감은 그렇지 않다. 우리는 언제든 재고 시스템에 같은 주문 ID로 다시 물어볼 수 있고, 그 응답으로 &quot;차감이 됐는지 안 됐는지&quot;를 재확인할 수 있다. <strong>조회(재호출)로 진실을 확인할 수 있는 상황</strong>에서는, 굳이 별도의 발송 큐를 두고 관리할 필요가 없다.</p>
<p>게다가 Outbox를 도입하면 새로 짊어질 짐이 있었다. 전용 <code>outbox</code> 테이블을 만들어야 하고, 그걸 폴링해 발행하는 워커를 따로 운영해야 하고, 그 워커의 장애와 지연까지 신경 써야 한다. 반면 Reconciliation은 <strong>이미 갖고 있던 주문 상태(<code>REQUESTED</code>)만으로 동작</strong>한다. 새 테이블도, 새 발행 파이프라인도 필요 없다. 정체된 주문을 골라 다시 호출하는 스케줄러 하나면 된다. 그리고 그 재호출의 안전성은 앞 장에서 다진 멱등성이 이미 보장해준다.</p>
<p>정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Reconciliation (채택)</th>
<th>Outbox (미채택)</th>
</tr>
</thead>
<tbody><tr>
<td>필요한 저장소</td>
<td>기존 주문 상태 재활용</td>
<td>전용 outbox 테이블 신설</td>
</tr>
<tr>
<td>필요한 운영 요소</td>
<td>스케줄러 하나</td>
<td>발행 워커 + 폴링 파이프라인</td>
</tr>
<tr>
<td>잘 맞는 상황</td>
<td>조회로 상태 재확인 가능</td>
<td>결과를 되물을 수 없는 fire-and-forget</td>
</tr>
<tr>
<td>우리 경우</td>
<td>재고를 재조회 가능 → 딱 맞음</td>
<td>재조회 가능한데 큐까지 두는 건 과함</td>
</tr>
</tbody></table>
<p>Outbox가 나쁜 패턴이라는 뜻이 결코 아니다. 실제로 우리 조직의 다른 영역에는 Outbox가 잘 쓰이고 있다. 다만 <strong>이 특정 문제, 즉 조회로 진실을 재확인할 수 있는 재고 차감</strong>에는 Reconciliation이 더 가볍고 정확했을 뿐이다. 패턴 선택에 절대적 정답은 없고, 문제의 성격에 맞추는 것이 있을 뿐이라는 걸 다시 확인한 대목이었다.</p>
<hr>
<h2 id="8-얻은-것과-감수한-것">8. 얻은 것과 감수한 것</h2>
<p>어떤 설계도 공짜는 없다. 우리가 이 구조로 무엇을 얻었고, 그 대가로 무엇을 내줬는지 솔직하게 정리해둔다.</p>
<h3 id="얻은-것">얻은 것</h3>
<p>가장 큰 소득은, 결국 처음의 두 지옥을 모두 피했다는 것이다. <strong>네트워크가 끊겨도 이중 차감이 나지 않고, 유령 주문도 생기지 않는다.</strong> &quot;모른다&quot;를 성급히 성공이나 실패로 우기지 않고 <code>REQUESTED</code>로 보류한 덕분이다. 이게 이 모든 설계의 출발점이자 가장 중요한 성과다.</p>
<p>두 번째로, 시스템이 스스로 <strong>결과적 일관성</strong>에 도달한다. 네트워크가 잠깐 어긋나도, 늦어도 수 분 안에 보정이 주문을 올바른 결말로 밀어 수렴시킨다. 사람이 매번 개입해 &quot;이거 처리됐나 확인해줘&quot;라고 할 필요가 없다.</p>
<p>세 번째로, 운영 관점의 안전장치를 갖췄다. 보정 스케줄러는 여러 노드에서 동시에 돌아도 <strong>중복 처리를 하지 않도록 잠금</strong>으로 제어되고, 그럼에도 오래도록 수렴하지 못하는 주문이 있으면 <strong>알람이 사람을 부른다.</strong> 자동으로 해결되는 건 자동으로, 자동이 감당 못 하는 건 사람에게 — 이 경계가 분명하다.</p>
<h3 id="감수한-것">감수한 것</h3>
<p>당연히 대가도 있다.</p>
<p>첫째, <strong>즉시 일관성을 포기했다.</strong> 정상적인 경우엔 곧바로 확정되지만, 네트워크가 유실된 케이스는 보정 주기를 기다려야 하므로 최종 확정까지 수 분 이상 지연될 수 있다. &quot;방금 주문했는데 상태가 아직 안 정해졌다&quot;는 창(window)이 존재한다는 뜻이다. 대부분의 도메인에선 감당할 만하지만, 밀리초 단위의 즉시 일관성이 반드시 필요한 곳이라면 이 설계는 맞지 않는다.</p>
<p>둘째, 앞 장에서 예고한 대로 <strong>이 안전성은 외부 시스템의 멱등 구현에 의존한다.</strong> 재고 시스템이 같은 요청을 두 번 받았을 때 정말로 한 번만 처리한다는 계약을 지켜준다는 전제 위에 모든 게 서 있다. 만약 그 상대가 멱등하지 않다면, 우리의 재시도 전략은 도리어 이중 차감의 원인이 된다. 그래서 이런 설계를 택하기 전엔 반드시 &quot;내가 부르는 상대가 멱등한가&quot;를 계약 수준에서 확인해야 한다. 우리 경우엔 다행히 그 계약이 지켜졌지만, 이건 우리가 통제할 수 없는 외부 의존이라는 점은 분명히 인지하고 있어야 한다.</p>
<p>셋째, <strong>스케줄러라는 움직이는 부품이 하나 늘었다.</strong> 즉시 처리로 끝나는 단순한 흐름이었다면 없었을, 주기적으로 도는 백그라운드 작업이 생겼다. 이건 활성화 여부를 관리해야 하고, 노드 간 잠금이 제대로 도는지 봐야 하고, 미수렴 알람을 모니터링해야 한다. 결과적 일관성은 공짜로 오지 않는다. 그 대가가 바로 이 운영 부담이다.</p>
<h3 id="그래서-남는-교훈">그래서, 남는 교훈</h3>
<p>이 트레이드오프의 본질은 결국 하나로 모인다. <strong>우리는 &quot;즉시 정확함&quot;을 &quot;결국 정확함&quot;과 맞바꿨다.</strong> 분산 환경에서는 모든 걸 그 자리에서 원자적으로 끝내겠다는 욕심을 내려놓아야 할 때가 있다. 대신 &quot;잠깐 어긋나더라도 결국은 맞춰진다&quot;를 설계로 보장하고, 그 &#39;결국&#39;이 언제 오는지를 관측 가능하게 만드는 것 — 그게 분산 트랜잭션과 함께 살아가는 현실적인 방법이었다.</p>
<hr>
<h2 id="9-마치며--실패와-성공-사이-모른다라는-상태">9. 마치며 — &quot;실패&quot;와 &quot;성공&quot; 사이, &quot;모른다&quot;라는 상태</h2>
<p>모놀리식에서 <code>@Transactional</code> 하나로 끝나던 일이, 서비스를 쪼개는 순간 왜 그렇게 어려워졌을까. 돌이켜보면 진짜 어려움은 분산 트랜잭션 그 자체가 아니었다. <strong>&quot;성공했다&quot;와 &quot;실패했다&quot; 사이에, &quot;모른다&quot;라는 상태가 존재한다는 사실</strong>을 받아들이는 일이었다.</p>
<p>모놀리식에선 이 상태가 없었다. 트랜잭션은 커밋되거나 롤백되거나, 둘 중 하나였다. 하지만 네트워크 너머의 시스템을 호출하는 순간, 우리는 &quot;요청은 갔는데 결과를 모르는&quot; 회색 지대에 들어선다. 이 회색 지대를 인정하지 않고 억지로 흑백으로 판단하려 들면, 이중 차감이나 유령 주문 같은 대가를 치른다.</p>
<p>우리가 한 일을 한 문장으로 줄이면 이렇다. <strong>&quot;모른다&quot;를 정직하게 하나의 상태로 남겨두고(<code>REQUESTED</code>), 나중에 다시 물어봐서(멱등 재호출) 결말을 지어줬다.</strong> 여기서 몇 가지 원칙을 뽑아낼 수 있다.</p>
<p>외부 시스템을 호출하기 전에, &quot;호출할 것이다&quot;라는 흔적을 먼저 내 DB에 남긴다. 그래야 도중에 끊겨도 무엇을 재시도할지 알 수 있다. 결과가 애매하면 성공도 실패도 단정하지 말고 보류한다. 단정은 언제나 나중에, 확실할 때 내린다. 재시도는 반드시 멱등성 위에서만 안전하다. &quot;다시 부른다&quot;를 설계에 넣기 전에 &quot;여러 번 불러도 한 번만 처리되는가&quot;를 먼저 확보한다. 그리고 자동 복구가 닿지 못하는 영역은 조용히 묻어두지 말고, 관측 가능하게 만들어 사람에게 넘긴다.</p>
<p>그리고 이 글의 처음 약속으로 돌아가자면 — Saga는 이벤트 패턴이 아니다. 우리는 메시지 브로커도, 이벤트도 없이 REST 동기 호출만으로 Saga를 구현했다. Saga의 본질은 &quot;여러 로컬 트랜잭션을 이어 붙여 결과적 일관성을 만든다&quot;는 데 있고, 그걸 이벤트로 조율할지 오케스트레이터로 조율할지는 문제의 성격이 정할 몫이다. 흔한 &quot;Saga = 카프카&quot; 그림에 갇히지 않았던 것이, 우리 문제를 가장 가볍게 푸는 출발점이었다.</p>
<p>MSA 전환을 앞두고 있다면, 언젠가 반드시 이 회색 지대를 마주하게 될 것이다. 그때 필요한 건 더 강력한 분산 트랜잭션 기술이 아니라, <strong>&quot;모른다&quot;를 두려워하지 않고 다루는 태도</strong>일지도 모른다. 실패했다고 단정하지 않는 것. 성공했다고 넘겨짚지 않는 것. 모르면 모른다고 남겨두고, 확실해질 때까지 정직하게 다시 물어보는 것. 우리 시스템이 배운 건 결국 그거였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI 시대, 구현보다 설계가 더 중요해진 이유]]></title>
            <link>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-AI-%EC%8B%9C%EB%8C%80-%EA%B5%AC%ED%98%84%EB%B3%B4%EB%8B%A4-%EC%84%A4%EA%B3%84%EA%B0%80-%EB%8D%94-%EC%A4%91%EC%9A%94%ED%95%B4%EC%A7%84-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-AI-%EC%8B%9C%EB%8C%80-%EA%B5%AC%ED%98%84%EB%B3%B4%EB%8B%A4-%EC%84%A4%EA%B3%84%EA%B0%80-%EB%8D%94-%EC%A4%91%EC%9A%94%ED%95%B4%EC%A7%84-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Mon, 08 Jun 2026 14:58:03 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>AI 기술이 고도화되면서, 개발자가 직접 &#39;코딩&#39;이라는 행위를 하는 빈도는 눈에 띄게 줄어들었다. 적어도 내 작업 방식은 그렇다.</p>
<p>요즘 내 하루는 이런 식으로 흘러간다. 요구사항을 직접 확인하고, 엣지 케이스와 모호한 지점을 기획자와 함께 명확히 한다. 그 다음 Claude Code를 실행해 정리된 요구사항을 그대로 집어넣는다. 그러면 대부분의 경우 코드가 완성되고, PR을 통과하고, merge와 배포까지 이어진다. 실제로 배포된 코드에서 문제가 발생하는 일도 거의 없다. 어느 순간부터는 AI를 &#39;믿고&#39; 일을 맡길 수 있는 수준에 도달했다.</p>
<h2 id="그런데-문제가-보이기-시작했다">그런데, 문제가 보이기 시작했다</h2>
<p>AI로 처리한 작업의 횟수가 쌓일수록 몇 가지 문제점이 눈에 들어오기 시작했다. 그중 가장 큰 문제는 이것이었다.</p>
<blockquote>
<p>AI는 코드를 &#39;최소한&#39;으로 고치지 않는다.</p>
</blockquote>
<p>지침에 YAGNI(You Aren&#39;t Gonna Need It)를 명시하고, 프롬프트로 &quot;수정 범위를 최소한으로 가져가라&quot;고 아무리 강조해도 소용이 없었다. 코드베이스가 방대해질수록 AI는 필요 이상의 코드를 건드렸고, 결국 사람이 리뷰하는 것이 불가능한 수준의 변경 덩어리를 만들어냈다.</p>
<p>처음에 나는 이 문제의 원인이 AI Agent 자체에 있다고 생각했다. 더 좋은 모델을 쓰거나, 더 정교한 프롬프트를 입력하면 해결될 거라고 믿었다. 하지만 두 영상을 보고 난 뒤 생각이 완전히 바뀌었다.</p>
<h2 id="원인은-ai가-아니라-아키텍처였다">원인은 AI가 아니라 &#39;아키텍처&#39;였다</h2>
<p>돌이켜보니, 잘못된 작업 결과물은 대부분 잘못된 아키텍처에서 비롯되고 있었다.</p>
<ul>
<li>계층 간 경계가 불확실하다</li>
<li>동일한 역할을 하는 메서드가 여기저기 중복되어 있다</li>
<li>외부로 공개되는 메서드가 인터페이스로 한정되지 않고 구현체를 직접 노출한다</li>
<li>도메인 경계가 모호하다</li>
</ul>
<p>이런 설계적 결함들이 AI Agent로 하여금 YAGNI 원칙을 지키지 못하게 만들고 있었다. 직접 써본 사람은 공감하겠지만, AI Agent는 시킨 일은 어떻게든 해내려고 한다. 그래서 경계가 무너진 코드베이스에서는, repository에 간단한 수정 하나를 요청해도 그것을 참조하는 모든 service 계층을 통째로 뒤집어엎는 일이 벌어진다. AI가 게을러서가 아니라, 변경의 파장을 막아줄 경계가 없기 때문이다.</p>
<h2 id="레거시-vs-새-프로젝트-직접-비교해본-결과">레거시 vs 새 프로젝트: 직접 비교해본 결과</h2>
<p>마침 회사에서 새 프로젝트를 시작하게 되면서, 이 가설을 레거시와 직접 비교해볼 기회가 생겼다.</p>
<p>새 프로젝트에서는 도메인 경계를 명확히 나누고, 계층 간 책임을 분명하게 부여한 뒤, 이것을 <strong>ArchUnit</strong>으로 강제했다. 사람이 직접 코딩하는 환경이었다면 이런 강제적인 규칙은 곧장 생산성 저하로 이어졌을 것이다. 하지만 AI로 작업하는 환경에서는 이야기가 완전히 달랐다.</p>
<p>결과적으로,</p>
<ul>
<li>도메인과 계층 간 <strong>응집도</strong>가 레거시에 비해 상당히 높아졌다.</li>
<li>기능 추가/수정이 발생할 때 작업이 <strong>특정 계층으로 집중</strong>되면서 훨씬 효율적으로 처리되었다.</li>
<li>무엇보다 공동 작업 시 <strong>작업 분량의 충돌을 최소화</strong>할 수 있었다.</li>
</ul>
<p>강제된 설계 원칙이 AI에게는 &#39;족쇄&#39;가 아니라 오히려 &#39;가드레일&#39;로 작동한 것이다.</p>
<h2 id="결론-ai-시대-구현보다-중요한-것은-아키텍처다">결론: AI 시대, 구현보다 중요한 것은 아키텍처다</h2>
<p>이런 경험들을 통해 나는 하나의 결론에 도달했다.</p>
<blockquote>
<p>AI 시대에 구현보다 중요한 것은 <strong>아키텍처</strong>다.</p>
</blockquote>
<p>이제 개발자는 AI를 통해 작업할 때, 설계 원칙을 명확하게 정하고 강제한 상태에서 출발해야 한다. 코드를 &#39;잘 짜는 능력&#39;보다, 코드가 머물 공간의 경계와 규칙을 &#39;잘 설계하는 능력&#39;이 결과물의 품질을 좌우하기 때문이다.</p>
<p>이것이 바로 두 영상 — Matt Pocock의 *&quot;Software Fundamentals Matter More Than Ever&quot;* — 이 던지는 메시지와 정확히 맞닿아 있다. 그의 표현을 빌리면, *&quot;Code is not cheap. Bad code is the most expensive it&#39;s ever been.&quot;* AI는 좋은 코드베이스 위에서만 빛나고, 그래서 수십 년 된 소프트웨어 설계 원칙들이 그 어느 때보다 중요해졌다는 것이다.</p>
<h2 id="그래서-영상이-말하는-핵심-설계-원칙들">그래서, 영상이 말하는 핵심 설계 원칙들</h2>
<p>그렇다면 AI 시대에 우리가 반드시 붙들어야 할 설계 원칙은 무엇일까. 영상에서 강조하는, 그리고 내가 가장 중요하다고 생각하는 원칙들을 정리해본다. 흥미로운 점은, 이것들이 전부 수십 년 전부터 존재하던 &#39;낡은&#39; 개념이라는 사실이다. 그 원칙들은 깨지지 않았다. 오히려 더 중요해졌을 뿐이다.</p>
<h3 id="1-ubiquitous-language-보편-언어">1. Ubiquitous Language (보편 언어)</h3>
<p>기획자, 개발자, 그리고 이제는 AI까지 — 모두가 같은 단어를 같은 의미로 쓰도록 도메인 언어를 통일하는 것이다.</p>
<p>AI 협업에서 이 원칙은 생각보다 훨씬 결정적으로 작동한다. AI Agent는 요구사항의 모호함을 &#39;제멋대로&#39; 메우기 때문이다. &#39;주문&#39;이라는 단어가 코드 어딘가에서는 <code>Order</code>, 다른 곳에서는 <code>Purchase</code>, 또 다른 곳에서는 <code>Transaction</code>으로 흩어져 있으면, AI는 매번 다른 추측을 하며 새로운 개념을 만들어낸다. 반대로 도메인 용어가 코드와 대화 양쪽에서 일관되게 정의되어 있으면, 프롬프트의 한 단어가 정확히 한 곳을 가리키게 되고 변경의 의도가 흐려지지 않는다.</p>
<h3 id="2-vertical-slices-수직-분할">2. Vertical Slices (수직 분할)</h3>
<p>기능을 &#39;Controller / Service / Repository&#39; 같은 계층 단위(수평)가 아니라, 하나의 완결된 세로 단면(수직)으로 잘라 작업하는 것이다.</p>
<p>AI에게 &quot;결제 취소 기능을 추가해줘&quot;라고 했을 때, 그 작업이 하나의 슬라이스 안에 응집되어 있으면 AI의 변경도 그 안에 갇힌다. 반대로 기능이 여러 계층에 얇게 흩뿌려져 있으면, AI는 그 기능을 건드리기 위해 코드베이스 전체를 헤집고 다닌다. 내가 겪었던 &quot;repository 수정 하나가 모든 service를 뒤엎는&quot; 문제의 정확한 해독제가 바로 이 수직 분할이다.</p>
<h3 id="3-deep-modules-깊은-모듈">3. Deep Modules (깊은 모듈)</h3>
<p>좁은 인터페이스 뒤에 풍부한 구현을 숨기는 것이다. John Ousterhout이 강조한 개념으로, 인터페이스는 단순하게 유지하되 그 안의 복잡성은 모듈 내부로 깊이 감춘다.</p>
<p>이것은 AI의 &#39;시야 범위&#39;를 통제하는 일이다. 구현체를 직접 노출하지 않고 인터페이스로만 한정하면, AI는 그 모듈을 &#39;쓰는 법&#39;만 알면 되고 &#39;내부를 어떻게 고칠지&#39;는 신경 쓸 필요가 없어진다. 변경의 파장이 모듈 경계를 넘지 못하고, 그만큼 AI가 건드릴 수 있는 표면적도 줄어든다. 얕은 모듈이 많을수록 AI는 더 많은 곳을 동시에 수정하려 든다.</p>
<h3 id="4-tdd-테스트-주도-개발">4. TDD (테스트 주도 개발)</h3>
<p>테스트를 먼저 쓰고, 그 테스트를 통과시키는 방향으로 구현을 이끌어가는 것이다.</p>
<p>AI 시대에 TDD의 의미는 한층 더 커진다. 사람이 일일이 리뷰할 수 없는 분량의 변경을, 테스트가 대신 &#39;의도 안에 머물고 있는지&#39; 검증해주기 때문이다. 테스트는 AI에게 주는 가장 명확한 명세이자, 가장 자동화된 가드레일이다. 게다가 잘 짜인 테스트는 그 자체로 &quot;이 코드가 무엇을 해야 하는가&quot;를 AI에게 알려주는 살아있는 문서가 된다.</p>
<hr>
<h2 id="그래서-만들어본-것-architecture-reviewer-스킬">그래서 만들어본 것: Architecture Reviewer 스킬</h2>
<p>여기까지 생각이 정리되자, 이 원칙들을 매번 머릿속으로만 점검하지 말고 도구로 만들어두면 어떨까 싶었다. 그래서 이 글에서 다룬 설계 원칙들을 그대로 담아 Claude 스킬을 하나 만들었다.</p>
<blockquote>
<p><strong>architecture-reviewer</strong> — 라인 단위 버그가 아니라 <strong>모듈 경계·복잡도·정보 은닉</strong> 같은 &#39;구조&#39;를 진단하는 스킬</p>
</blockquote>
<p>🔗 <a href="https://github.com/JK-Kim4/claude-plugins/tree/main/architecture-reviewer">GitHub: JK-Kim4/claude-plugins/architecture-reviewer</a></p>
<p>기존의 라인 단위 코드 리뷰(PR 리뷰)와는 의도적으로 범위를 분리했다. 이 스킬이 보는 것은 &#39;코드가 맞게 짜였는가&#39;가 아니라 &#39;구조가 AI와 사람이 안전하게 일할 수 있게 설계되었는가&#39;다. 기준은 이 글의 출처이기도 한 Matt Pocock의 발표, 그리고 John Ousterhout의 <em>A Philosophy of Software Design</em>, Eric Evans의 <em>DDD</em>를 따랐다.</p>
<h3 id="두-가지-모드">두 가지 모드</h3>
<ul>
<li><strong>진단 모드</strong>: &quot;이 모듈/디렉터리 리뷰해줘&quot;처럼 대상을 가리키면, 현재 구조를 7축으로 진단한다.</li>
<li><strong>방향 검증 모드</strong>: &quot;이런 구조로 바꾸려는데 괜찮아?&quot;처럼 방향을 제안하면, 현재 코드를 기준으로 그 방향의 적합성과 마이그레이션 리스크를 검증한다.</li>
</ul>
<h3 id="7축-진단-루브릭">7축 진단 루브릭</h3>
<p>이 글에서 강조한 원칙들이 그대로 진단 축으로 들어가 있다.</p>
<ol>
<li><strong>복잡도</strong> — 작은 변경이 여러 곳을 건드리는가(change amplification)</li>
<li><strong>모듈 깊이</strong> — 좁은 인터페이스 뒤에 복잡도를 숨기는 deep module인가</li>
<li><strong>정보 은닉 / 누수</strong> — 한 모듈의 구현 세부가 다른 모듈로 새어나가는가</li>
<li><strong>결합도 · 응집도 · SoC</strong> — 한 클래스가 repository·외부 API·도메인 규칙을 동시에 떠안는가</li>
<li><strong>Red Flags</strong> — Ousterhout의 구조적 악취 카탈로그(shallow module, pass-through method 등)</li>
<li><strong>경계 · 언어 (DDD)</strong> — 같은 개념을 같은 용어로 일관되게 쓰는가(ubiquitous language)</li>
<li><strong>테스트 용이성</strong> — &quot;good codebases are easy to test&quot;를 설계 품질의 프록시로 본다</li>
</ol>
<p>각 지적에는 영향도와 개선 비용을 기준으로 P0~P2 우선순위를 매기고, 구조적 문제에는 before/after 스케치를 덧붙이도록 했다. 앞서 이야기한 &quot;repository 수정 하나가 모든 service를 뒤엎는&quot; 문제는, 이 루브릭에서 1축(change amplification)과 4축(결합도)으로 잡히는 전형적인 신호다.</p>
<p>결국 이 스킬은 이 글의 결론을 도구로 옮긴 것이다. AI에게 더 나은 프롬프트를 주는 대신, <strong>AI가 일할 구조 자체를 사람이 먼저 점검하고 설계하자는 것</strong> — 그게 내가 이 도구를 만든 이유다.</p>
<h2 id="마치며">마치며</h2>
<p>처음 AI 도구를 쓰기 시작했을 때 나는 더 좋은 모델과 더 정교한 프롬프트를 찾아 헤맸다. 하지만 정작 결과물의 품질을 가른 것은 모델이 아니라, 그 모델이 일하게 될 코드베이스의 구조였다. 경계가 명확하고 책임이 분명한 코드 위에서 AI는 비로소 신뢰할 수 있는 동료가 되었고, 그렇지 않은 곳에서는 가장 빠르게 빚을 쌓는 존재가 되었다.</p>
<blockquote>
<p>결국 핵심은 Kent Beck의 말로 요약된다. <strong>&quot;매일 시스템 설계에 투자하라.&quot;</strong> AI가 코드를 대신 써주는 시대일수록, 그 코드가 살아갈 구조를 설계하는 일은 더더욱 사람의 몫으로 남는다.</p>
</blockquote>
<p>구현은 점점 더 AI에게 맡겨질 것이다. 그러나 무엇을, 어떤 경계 안에서, 어떤 원칙으로 만들 것인지를 정하는 일 — 즉 설계는, 앞으로도 오랫동안 개발자의 가장 중요한 역량으로 남을 것이다. AI 시대에 우리가 더 깊이 공부해야 할 것은 새로운 프롬프트 기법이 아니라, 어쩌면 수십 년간 변하지 않은 그 &#39;오래된 펀더멘털&#39;일지도 모른다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Claude Code + Spec Kit으로 스펙 주도 개발(SDD) 시작하기]]></title>
            <link>https://velog.io/@jw_kim/Claude-Code-Spec-Kit%EC%9C%BC%EB%A1%9C-%EC%8A%A4%ED%8E%99-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9CSDD-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jw_kim/Claude-Code-Spec-Kit%EC%9C%BC%EB%A1%9C-%EC%8A%A4%ED%8E%99-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9CSDD-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 26 Apr 2026 15:05:38 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>요즘 AI 코딩 에이전트 뉘앙에 힙스되는 개발 스타일이 있습니다. 바로 &quot;블라인드하게 코드를 짜다(vibe coding)&quot;. AI가 코드를 품품 내줘주니 빠르게 만들 수는 있지만, 스펙 없이 만들어진 코드는 나중에 센센이 엽혀 다시 짜는 상황이 생길 수 있습니다.</p>
<p>GitHub이 만든 오픈소스 툴킷인 <strong>Spec Kit</strong>은 이 문제를 다릅니다. 코드를 짜기 전에 <strong>뜻(스펙)을 먼저 정의</strong>하고, AI가 그를 실현하도로 유도하는 <strong>Spec-Driven Development(SDD)</strong> 방식을 지향합니다. Claude Code와 조합하면 몇 가지 커맨드만으로 스펙 작성부터 구현까지 일관된 플로우를 만들어낼 수 있습니다.</p>
<h2 id="spec-kit이란">Spec Kit이란?</h2>
<p>Spec Kit은 GitHub이 2025년에 공개한 오픈소스 툴킷으로, <strong>스펙 주도 개발(Spec-Driven Development)</strong>을 실천하기 위한 도구입니다. <code>specify</code>라는 CLI 도구와 AI 코딩 에이전트에서 사용할 수 있는 슬래시 커맨드들을 제공합니다.</p>
<p>SDD의 핵심 아이디어는 단순합니다.</p>
<blockquote>
<p><strong>코드를 짜기 전에 무엇을(what) 만들지를 명확히 하라.</strong>
스펙이 코드를 영도하고, 코드는 스펙을 구현한다.</p>
</blockquote>
<p>Claude Code, Gemini CLI, Cursor, Copilot 등 30개 이상의 AI 코딩 에이전트와 호환되며, 이 글에서는 <strong>Claude Code</strong> 기준으로 설명합니다.</p>
<h2 id="설치-및-쓰이-환경-준비">설치 및 쓰이 환경 준비</h2>
<h3 id="1-claude-code-설치">1. Claude Code 설치</h3>
<p>Node.js 18+이 설치되어 있어야 합니다.</p>
<pre><code class="language-bash">npm install -g @anthropic-ai/claude-code
claude  # 실행 후 Anthropic API 키 입력</code></pre>
<h3 id="2-spec-kit-clispecify-설치">2. Spec Kit CLI(specify) 설치</h3>
<p>공식 설치는 PyPI가 아닌 GitHub 레포 직접에서 해야 합니다.</p>
<pre><code class="language-bash"># uv를 사용하는 경우 (권장)
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git

# pipx를 사용하는 경우
pipx install git+https://github.com/github/spec-kit.git

# 설치 확인
specify version</code></pre>
<h2 id="sdd-워크플로우-5단계">SDD 워크플로우: 5단계</h2>
<p>Spec Kit은 다음 5단계 플로우로 개발을 진행합니다.</p>
<h3 id="step-0-프로젝트-초기화">STEP 0: 프로젝트 초기화</h3>
<p>프로젝트 디렉토리를 만들고 <code>specify init</code>으로 템플릿을 초기화합니다.</p>
<pre><code class="language-bash"># 신규 프로젝트
specify init MyApp
cd MyApp

# 기존 프로젝트에 적용
cd existing-project
specify init . --integration copilot
# Claude Code를 쓸 경우
specify init . --integration claude</code></pre>
<p>완료되면 <code>.specify/</code> 디렉토리와 <code>CLAUDE.md</code>가 생성됩니다. 또한 Claude Code에 <code>/speckit.*</code> 슬래시 커맨드들이 등록됩니다.</p>
<h3 id="step-1-원칙constitution-수립">STEP 1: 원칙(Constitution) 수립</h3>
<p>Claude Code를 실행한 뒤 <code>/speckit.constitution</code> 커맨드로 프로젝트의 개발 원칙을 수립합니다.</p>
<pre><code>claude

# Claude Code 안에서
/speckit.constitution 코드 품질, 테스트 커버리지, 성능 등을 중심으로 한 개발 원칙을 수립해줘</code></pre><p>이 커맨드는 <code>.specify/memory/constitution.md</code> 파일을 생성하며, 이후 모든 AI 응답의 기준이 됩니다.</p>
<h3 id="step-2-스펙spec-작성">STEP 2: 스펙(Spec) 작성</h3>
<p><code>/speckit.specify</code>로 만들 것에 대한 요구사항을 자연어로 작성합니다. 기술 스택은 여기서 언급하지 않는 게 포인트입니다.</p>
<pre><code>/speckit.specify 사용자가 할 일 목록을 관리하는 앱을 만들어줘.
사용자는 할 일을 추가/삭제/완료 체크할 수 있고,
우선순위를 지정할 수 있어야 해.
날짜 기준으로 정렬하는 기능도 있었으면 좋겠어.</code></pre><p>AI가 요구사항을 분석해 <code>.specify/specs/001-todo-app/spec.md</code> 파일을 생성합니다. User Story와 엑셍스 커버리지 충 등이 자동으로 정리됩니다.</p>
<h3 id="step-3-구현-계획plan-생성">STEP 3: 구현 계획(Plan) 생성</h3>
<p><code>/speckit.plan</code>에서 이제 기술 스택과 아키텐쳐를 알려줍니다.</p>
<pre><code>/speckit.plan React + TypeScript를 사용해서 만들어줘.
상태 관리는 Zustand, 데이터 저장은 localStorage 사용.
UI는 Tailwind CSS로 슬림하게 만들어줘.</code></pre><p>AI는 <code>plan.md</code>, <code>data-model.md</code> 등 세부 구현 문서를 생성합니다.</p>
<h3 id="step-4-태스크-분해">STEP 4: 태스크 분해</h3>
<p><code>/speckit.tasks</code>로 계획을 실행 가능한 태스크 단위로 취계합니다.</p>
<pre><code>/speckit.tasks</code></pre><p><code>tasks.md</code> 파일이 생성되며, 병렬 실행 가능한 태스크는 <code>[P]</code> 표시로 마크됩니다. 의존성 순서를 지켜서 태스크가 나열돬으며, 각 User Story별로 묵여서 정리됩니다.</p>
<h3 id="step-5-구현">STEP 5: 구현</h3>
<p><code>/speckit.implement</code>으로 AI가 tasks.md를 읽고 실제 코드를 작성합니다.</p>
<pre><code>/speckit.implement</code></pre><p>Claude Code는 constitution → spec → plan → tasks 순서로 컨텍스트를 확인하면서 코드를 작성합니다. 실행에 필요한 CLI 도구(npm, git 등)도 직접 호출합니다.</p>
<h2 id="유용한-추가-커맨드">유용한 추가 커맨드</h2>
<p>Spec Kit은 코어 5단계 외에도 성능/품질 강화를 위한 선택적 커맨드를 제공합니다.</p>
<table>
<thead>
<tr>
<th>커맨드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>/speckit.clarify</code></td>
<td>스펙 작성 후 더 명확하게 요구사항을 정리</td>
</tr>
<tr>
<td><code>/speckit.analyze</code></td>
<td>태스크 생성 후 컨시스턴시 검사</td>
</tr>
<tr>
<td><code>/speckit.checklist</code></td>
<td>요구사항 완성도 체크리스트 생성</td>
</tr>
</tbody></table>
<h2 id="커뮤니티-익스텐션-활용">커뮤니티 익스텐션 활용</h2>
<p>Spec Kit은 커뮤닄티가 만든 익스텐션을 <code>specify extension add</code>로 설치할 수 있습니다. 주목할 만한 것들로는:</p>
<ul>
<li><strong>spec-kit-review</strong>: 구현 후 코드 리뷰를 자동화</li>
<li><strong>spec-kit-cleanup</strong>: 구현 후 리팩토링과 소리 로직 수정</li>
<li><strong>spec-kit-jira</strong>: Jira 연동으로 태스크 자동 생성</li>
<li><strong>spec-kit-pr-bridge</strong>: PR 설명을 스펙에서 자동 생성</li>
<li><strong>spec-kit-security-review</strong>: 보안 삐넓 자동 검사</li>
</ul>
<pre><code class="language-bash"># 익스텐션 검색
specify extension search

# 설치
specify extension add spec-kit-review</code></pre>
<h2 id="정리">정리</h2>
<p>Spec Kit + Claude Code 조합은 AI 코딩의 장점은 살리면서, 스펙 없이 코드를 짜는 단점을 보완하는 접근법입니다. 특히 팀에서 AI 코딩 에이전트를 사용할 때, 스펙이 있으면 AI가 일관성 있는 코드를 생성하는 데 큰 도움이 됩니다.</p>
<ul>
<li>코드를 짜기 전에 <strong>뜻을 먼저</strong> 정의한다</li>
<li>AI는 스펙을 <strong>구현</strong>한다</li>
<li>스펙은 프로젝트 전체의 <strong>진실의 원천(single source of truth)</strong>이 된다</li>
</ul>
<p>이 세 원칙만 지켜도 AI 코딩의 편의성을 영리하지 않으면서도 훨씬 더 안정적인 개발을 할 수 있습니다.</p>
<hr>
<h2 id="실전-워크플로우-내가-실제로-사용하는-방식">실전 워크플로우: 내가 실제로 사용하는 방식</h2>
<p>Spec Kit 공식 가이드의 5단계 흐름에 더해, 실제로 운용하면서 효과적이라고 확인한 구체적인 방식을 공유합니다.</p>
<h3 id="1단계-constitution--tdd-강제--금지-원칙-중심으로-수립">1단계: Constitution — TDD 강제 + 금지 원칙 중심으로 수립</h3>
<p>Constitution을 작성할 때 가장 중요하게 생각하는 두 가지 원칙이 있습니다.</p>
<p><strong>TDD 강제</strong>: AI가 작성하는 모든 코드는 테스트 코드로 동작이 검증되고 명세될 수 있어야 합니다. 단순히 &quot;테스트를 작성해줘&quot;라고 하는 게 아니라, Constitution 수준에서 강제해야 AI가 구현 후 테스트를 붙이는 게 아니라 테스트를 명세로 먼저 정의하는 방향으로 작업합니다.</p>
<p><strong>허가 원칙보다 금지 원칙</strong>: AI는 모호한 상황에서 &quot;일단 해도 되겠지&quot;라고 판단하는 경향이 있습니다. &quot;이런 건 해도 된다&quot;는 허가 목록보다, &quot;이런 건 절대 하지 마라&quot;는 금지 목록이 훨씬 효과적으로 작동합니다. 예를 들어 &quot;도메인 로직을 UI 레이어에 작성하지 말 것&quot;, &quot;테스트 없이 구현 PR을 올리지 말 것&quot; 같은 식으로 구체적인 금지 지침을 명시합니다.</p>
<h3 id="2단계-specify--요구사항을-최대한-구체적으로">2단계: Specify — 요구사항을 최대한 구체적으로</h3>
<p><code>/speckit.specify</code>를 호출할 때 전달받은 요구사항이나 구현 목표를 최대한 상세하게 설명합니다. 명령 후 <code>.specify/specs/</code> 하위에 작업 디렉토리가 생성되는 것을 확인할 수 있습니다.</p>
<h3 id="3단계-clarify-반복--기획-구체화의-핵심">3단계: Clarify 반복 — 기획 구체화의 핵심</h3>
<p>이 단계가 전체 워크플로우에서 가장 공을 들이는 부분입니다. <code>/speckit.clarify</code>를 <strong>N번 반복 실행</strong>합니다.</p>
<p>실행할 때마다 두 가지 방식을 병행합니다.</p>
<ul>
<li><strong>능동적 구체화</strong>: 내가 직접 구체화가 필요한 지점을 명시해서 AI에게 인터뷰를 요청합니다.</li>
<li><strong>수동적 검토</strong>: 작성된 spec을 바탕으로 AI가 스스로 모호하거나 구체화가 필요한 지점을 찾아 인터뷰를 진행합니다.</li>
</ul>
<p>이 과정을 반복할수록 초기에는 보이지 않던 엣지 케이스, 정책 미결정 사항, 도메인 용어의 모호함 등이 드러납니다. clarify를 충분히 돌리고 나면 spec이 상당히 두꺼워지는데, 이게 나중에 plan과 tasks의 품질을 결정합니다.</p>
<h3 id="4단계-analyze--plan-전-일관성-검사">4단계: Analyze — Plan 전 일관성 검사</h3>
<p>plan을 수립하기 전에 <code>/speckit.analyze</code>를 실행해 spec 문서들 간의 일관성을 검사합니다. clarify를 여러 번 반복하다 보면 앞뒤가 맞지 않는 내용이 생길 수 있는데, 이 단계에서 미리 잡아두면 plan과 tasks 단계에서 AI가 엉뚱한 방향으로 작업하는 것을 막을 수 있습니다.</p>
<h3 id="5단계-plan--구현-계획-수립">5단계: Plan — 구현 계획 수립</h3>
<p><code>/speckit.plan</code>으로 기술 스택과 아키텍처를 명시하여 구현 계획을 수립합니다. 앞 단계에서 spec이 충분히 구체화되어 있으면 plan의 완성도도 자연히 높아집니다.</p>
<h3 id="6단계-tasks--원자적-단위로-분해">6단계: Tasks — 원자적 단위로 분해</h3>
<p><code>/speckit.tasks</code>로 plan을 태스크 단위로 분해하는 단계입니다. 이 단계의 품질이 구현 결과를 좌우합니다.</p>
<p>태스크를 나눌 때 핵심 기준은 다음과 같습니다.</p>
<ul>
<li><strong>원자적 단위</strong>: 하나의 태스크는 하나의 책임만 가집니다.</li>
<li><strong>도메인/계층 경계 존중</strong>: 도메인 경계나 계층 간 경계를 침범하지 않도록 분리합니다.</li>
<li><strong>독립 실행 가능</strong>: 독립적인 에이전트가 혼자서 작업할 수 있는 수준으로 분해합니다.</li>
</ul>
<p>이렇게 하면 두 가지 이점이 생깁니다. 개발자 입장에서 AI가 작업한 내용을 태스크 단위로 검토하기가 수월해지고, 기능 단위가 잘게 쪼개져 있어 추후 유지보수도 용이해집니다.</p>
<h3 id="7단계-implement--taskphase-단위-순차-검증">7단계: Implement — Task/Phase 단위 순차 검증</h3>
<p><code>/speckit.implement</code>로 구현을 진행할 때 전체를 한 번에 실행하기보다, <strong>태스크 단위로 실행하고 검증하는 사이클을 반복</strong>합니다. 경험적으로 phase 단위로 구현 작업을 나눠도 문제없이 구현됨을 확인했습니다.</p>
<p>이 방식의 장점은 AI가 작업 중 방향을 이탈했을 때 조기에 발견하고 수정할 수 있다는 점입니다. 태스크가 원자적으로 잘 분해되어 있을수록 검증 사이클도 빠르게 돌아갑니다.</p>
<hr>
<h3 id="전체-흐름-요약">전체 흐름 요약</h3>
<pre><code>constitution (TDD 강제 + 금지 원칙)
    ↓
specify (요구사항 상세 전달)
    ↓
clarify × N (기획 구체화 반복 인터뷰)
    ↓
analyze (spec 일관성 검사)
    ↓
plan (기술 스택 + 구현 계획)
    ↓
tasks (원자적 단위 분해)
    ↓
implement (task/phase 단위 순차 실행 + 검증 반복)</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot + Redis: Lettuce 동작 방식과 Connection Pool]]></title>
            <link>https://velog.io/@jw_kim/Spring-Boot-Redis-Lettuce-%EB%8F%99%EC%9E%91-%EB%B0%A9%EC%8B%9D%EA%B3%BC-Connection-Pool</link>
            <guid>https://velog.io/@jw_kim/Spring-Boot-Redis-Lettuce-%EB%8F%99%EC%9E%91-%EB%B0%A9%EC%8B%9D%EA%B3%BC-Connection-Pool</guid>
            <pubDate>Sun, 26 Apr 2026 07:57:38 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>Spring Boot 환경에서 Redis를 도입할 때, 단순히 의존성을 추가하고 <code>RedisTemplate</code>을 사용하는 것에서 그치는 경우가 많습니다. 하지만 Redis 클라이언트의 동작 방식을 제대로 이해하지 못하면, 운영 환경에서 예상치 못한 커넥션 폭증이나 성능 저하를 마주할 수 있습니다.</p>
<p>이 글에서는 Spring Boot에서 사용할 수 있는 Redis 클라이언트들을 비교하고, 기본 클라이언트인 <strong>Lettuce</strong>의 내부 동작 방식과 Connection Pool의 중요성에 대해 실제 트러블슈팅 경험을 바탕으로 설명합니다.</p>
<hr>
<h2 id="redis-클라이언트-비교-jedis-vs-lettuce-vs-redisson">Redis 클라이언트 비교: Jedis vs Lettuce vs Redisson</h2>
<p>Spring Boot에서 Redis를 사용할 때 선택할 수 있는 클라이언트는 크게 세 가지입니다.</p>
<table>
<thead>
<tr>
<th></th>
<th><strong>Jedis</strong></th>
<th><strong>Lettuce</strong></th>
<th><strong>Redisson</strong></th>
</tr>
</thead>
<tbody><tr>
<td>동작 방식</td>
<td>동기, 블로킹</td>
<td>비동기, 논블로킹</td>
<td>비동기, 논블로킹</td>
</tr>
<tr>
<td>Thread-safe</td>
<td>❌ (Pool 필수)</td>
<td>✅</td>
<td>✅</td>
</tr>
<tr>
<td>Reactive 지원</td>
<td>❌</td>
<td>✅</td>
<td>✅</td>
</tr>
<tr>
<td>Spring Data Redis</td>
<td>✅</td>
<td>✅</td>
<td>✅ (별도 연동)</td>
</tr>
<tr>
<td>분산락 / 고수준 기능</td>
<td>❌</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>Spring Boot 기본값</td>
<td>❌</td>
<td>✅</td>
<td>❌</td>
</tr>
</tbody></table>
<p><strong>Spring Boot 2.0부터 기본 Redis 클라이언트는 Lettuce입니다.</strong> <code>spring-boot-starter-data-redis</code> 의존성만 추가하면 별도 설정 없이 Lettuce가 사용됩니다.</p>
<p>Jedis는 커넥션이 thread-safe하지 않아 멀티스레드 환경에서 반드시 Connection Pool을 사용해야 하고, 동기 블로킹 방식이라 고부하 환경에서 불리합니다. Redisson은 분산락, 분산 컬렉션 등 고수준 기능이 필요할 때 적합하며, 다음 편에서 자세히 다룰 예정입니다.</p>
<hr>
<h2 id="lettuce-아키텍처">Lettuce 아키텍처</h2>
<p>Lettuce는 <strong>Netty</strong> 기반의 비동기 논블로킹 Redis 클라이언트입니다.</p>
<h3 id="thread-safe한-이유">Thread-safe한 이유</h3>
<p>Lettuce는 내부적으로 단일 TCP 커넥션(<code>StatefulRedisConnection</code>)을 통해 모든 커맨드를 처리합니다. 커맨드는 Netty의 이벤트 루프에서 순차적으로 처리되며, 각 커맨드는 내부 큐에 적재되어 순서대로 Redis 서버에 전송됩니다.</p>
<p>이 구조 덕분에 <strong>여러 스레드가 하나의 커넥션을 공유해도 thread-safe</strong>합니다. 각 스레드가 커맨드를 큐에 넣으면, Netty 이벤트 루프가 순서대로 처리하기 때문입니다.</p>
<hr>
<h2 id="lettuce의-connection-사용-방식-두-가지">Lettuce의 Connection 사용 방식 두 가지</h2>
<p>Lettuce(+ Spring Data Redis)에서 커넥션을 사용하는 방식은 크게 두 가지로 나뉩니다.</p>
<h3 id="1-shared-connection-기본값">1. Shared Connection (기본값)</h3>
<p>별도 설정이 없으면 Lettuce는 <strong>하나의 커넥션을 모든 스레드가 공유</strong>합니다.</p>
<pre><code class="language-java">@Configuration
public class RedisConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(&quot;localhost&quot;, 6379);
        // 기본 LettuceConnectionFactory — Connection Pool 없음
        return new LettuceConnectionFactory(config);
    }
}</code></pre>
<p>일반적인 GET/SET 커맨드처럼 <strong>커넥션을 독점할 필요가 없는 작업</strong>에서는 이 방식으로 충분합니다. 커넥션 하나를 재사용하므로 리소스 효율이 높습니다.</p>
<h3 id="2-connection-pool-방식">2. Connection Pool 방식</h3>
<p><strong>커넥션을 독점적으로 점유해야 하는 작업</strong>에는 Connection Pool이 필요합니다. 대표적으로:</p>
<ul>
<li><strong>Pipeline</strong> (<code>executePipelined</code>)</li>
<li><strong>Transaction</strong> (<code>MULTI/EXEC</code>)</li>
<li><strong>Blocking 커맨드</strong> (<code>BLPOP</code> 등)</li>
</ul>
<pre><code class="language-java">@Configuration
public class RedisConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(&quot;localhost&quot;, 6379);

        LettucePoolingClientConfiguration poolingConfig = LettucePoolingClientConfiguration.builder()
            .poolConfig(connectionPoolConfig())
            .build();

        return new LettuceConnectionFactory(config, poolingConfig);
    }
}</code></pre>
<hr>
<h2 id="executepipelined와-openpipeline의-동작-원리">executePipelined()와 openPipeline()의 동작 원리</h2>
<h3 id="pipeline이란">Pipeline이란?</h3>
<p>일반적으로 Redis 커맨드는 <strong>요청 → 응답 → 요청 → 응답</strong> 형태로 round-trip마다 네트워크 지연이 발생합니다. Pipeline은 <strong>여러 커맨드를 한 번에 묶어서 전송</strong>하고 응답도 한 번에 받는 방식으로, 네트워크 왕복 비용을 크게 줄일 수 있습니다.</p>
<p>Spring Data Redis에서는 <code>RedisTemplate.executePipelined()</code>로 Pipeline을 사용합니다.</p>
<pre><code class="language-java">List&lt;Object&gt; results = redisTemplate.executePipelined((RedisCallback&lt;Object&gt;) connection -&gt; {
    for (String key : keyList) {
        connection.stringCommands().get(key.getBytes(StandardCharsets.UTF_8));
    }
    return null;
});</code></pre>
<h3 id="pipeline의-실제-동작-방식">Pipeline의 실제 동작 방식</h3>
<p>Pipeline을 사용한다고 해서 Redis 서버가 커맨드를 <strong>동시에 실행</strong>하는 것은 아닙니다.</p>
<p>Redis 서버는 <strong>싱글 스레드 이벤트 루프</strong> 기반으로 동작하므로, Pipeline으로 묶인 커맨드도 수신된 순서대로 <strong>하나씩 순차 실행</strong>됩니다.</p>
<p>Pipeline의 핵심은 <strong>서버의 실행 방식 변경이 아니라, 클라이언트 ↔ 서버 간의 네트워크 왕복(RTT) 횟수를 줄이는 것</strong>입니다.</p>
<pre><code>[ Without Pipeline ] — N번의 RTT

Client                        Redis Server
  │                                │
  │── GET key1 ──────────────────► │  ← 수신 즉시 실행
  │ ◄──────────────────── value1 ──│
  │                                │
  │── GET key2 ──────────────────► │  ← 수신 즉시 실행
  │ ◄──────────────────── value2 ──│
  │                                │
  │── GET key3 ──────────────────► │  ← 수신 즉시 실행
  │ ◄──────────────────── value3 ──│

총 RTT: 3회 (커맨드 수만큼)


[ With Pipeline ] — 1번의 RTT

Client                        Redis Server
  │                                │
  │── GET key1 ──────────────────► │ ┐
  │── GET key2 ──────────────────► │ │ 싱글 스레드가
  │── GET key3 ──────────────────► │ │ 순서대로 실행
  │                                │ │
  │ ◄────────────────── value1 ────│ │ 결과를 한번에
  │ ◄────────────────── value2 ────│ │ 응답
  │ ◄────────────────── value3 ────│ ┘

총 RTT: 1회</code></pre><blockquote>
<p>⚠️ <strong>Pipeline은 병렬 실행이 아닙니다.</strong>
서버는 커맨드를 <strong>순서대로 하나씩 실행</strong>하며, 절약되는 것은 <strong>네트워크 왕복 비용(RTT)</strong> 입니다.</p>
</blockquote>
<p>또한 RTT 외에도 <strong>시스템 콜(syscall) 비용</strong>도 줄어듭니다. Pipeline 없이는 커맨드마다 <code>read()</code> / <code>write()</code> 시스템 콜이 발생해 user space ↔ kernel space 간 컨텍스트 스위칭이 일어나지만, Pipeline을 사용하면 여러 커맨드를 단 한 번의 <code>read()</code> / <code>write()</code> 시스템 콜로 처리합니다.</p>
<p>이 두 가지 효과가 합쳐져 Redis 공식 문서 기준으로 Pipeline 적용 시 처리량이 <strong>최대 10배</strong>까지 향상될 수 있습니다.</p>
<p>단, 한 번에 너무 많은 커맨드를 묶으면 서버가 응답을 메모리에 큐잉해야 하므로 메모리 부하가 증가합니다. <strong>1,000 ~ 10,000개 단위</strong>로 나눠서 전송하는 것이 권장됩니다.</p>
<h3 id="왜-커넥션을-독점해야-하는가">왜 커넥션을 독점해야 하는가?</h3>
<p>Pipeline은 내부적으로 <code>openPipeline()</code>을 호출하여 커넥션을 <strong>파이프라인 모드</strong>로 전환합니다. 이 상태에서는 커맨드가 즉시 전송되지 않고 버퍼에 누적되다가 <code>flushCommands()</code> 시점에 한꺼번에 전송됩니다.</p>
<p>만약 이 커넥션을 다른 스레드와 공유한다면, <strong>다른 스레드의 커맨드가 파이프라인 버퍼에 섞여</strong> 의도치 않은 커맨드가 함께 전송되거나, 응답 순서가 뒤섞이는 문제가 발생합니다. 따라서 Pipeline은 반드시 <strong>커넥션을 독점</strong>해야 합니다.</p>
<hr>
<h2 id="실제-트러블슈팅-커넥션-폭증-문제">실제 트러블슈팅: 커넥션 폭증 문제</h2>
<h3 id="문제-상황">문제 상황</h3>
<p>아래와 같이 여러 키에 대해 <code>SMEMBERS</code> 커맨드를 Pipeline으로 묶어 처리하는 코드가 있었습니다. 이 코드는 <strong>가장 많이 호출되는 API의 핵심 로직</strong>이었습니다.</p>
<pre><code class="language-java">public Map&lt;Long, Set&lt;Object&gt;&gt; getGroupIdAndItemIdSetMap(List&lt;Long&gt; groupIdList, String cacheKey) {
    Map&lt;Long, Set&lt;Object&gt;&gt; resultMap = new HashMap&lt;&gt;();

    List&lt;Object&gt; results = redisTemplate.executePipelined((RedisCallback&lt;Object&gt;) connection -&gt; {
        for (Long groupId : groupIdList) {
            String key = cacheKey + &quot;:&quot; + groupId;
            connection.setCommands().sMembers(key.getBytes(StandardCharsets.UTF_8));
        }
        return null;
    });

    for (int i = 0; i &lt; groupIdList.size(); i++) {
        Set&lt;Object&gt; items = (Set&lt;Object&gt;) results.get(i);
        if (items.contains(&quot;PLACEHOLDER&quot;)) {
            resultMap.put(groupIdList.get(i), Collections.emptySet());
        } else {
            resultMap.put(groupIdList.get(i), items);
        }
    }

    return resultMap;
}</code></pre>
<h3 id="왜-커넥션이-폭증했는가">왜 커넥션이 폭증했는가?</h3>
<p><code>executePipelined()</code>는 내부적으로 커넥션을 <strong>독점적으로 점유</strong>해야 합니다. Connection Pool이 설정되지 않은 상태에서는 Shared Connection을 파이프라인 전용으로 사용할 수 없기 때문에, <strong>매 <code>executePipelined()</code> 호출마다 새로운 커넥션을 생성</strong>하게 됩니다.</p>
<p>트래픽이 몰리는 피크타임에 이 API가 대량으로 호출되자, <strong>Redis 신규 커넥션 수립 지표가 1,000개 이상으로 폭증</strong>했습니다. 이는 Redis 서버의 커넥션 부하를 높이고 자원 고갈 위험을 초래하는 상황이었습니다.</p>
<pre><code>[Connection Pool 없는 경우]

스레드 A → executePipelined() → 새 커넥션 생성 → 사용 후 종료
스레드 B → executePipelined() → 새 커넥션 생성 → 사용 후 종료
스레드 C → executePipelined() → 새 커넥션 생성 → 사용 후 종료
...
(동시 요청 수만큼 커넥션 생성)</code></pre><h3 id="해결-connection-pool-설정">해결: Connection Pool 설정</h3>
<pre><code>[Connection Pool 있는 경우]

스레드 A → executePipelined() → Pool에서 커넥션 대여 → 반납
스레드 B → executePipelined() → Pool에서 커넥션 대여 → 반납
스레드 C → executePipelined() → Pool이 고갈되면 maxWait 동안 대기 → 대여
...
(최대 maxTotal 수의 커넥션만 유지)</code></pre><hr>
<h2 id="connection-pool-설정-코드">Connection Pool 설정 코드</h2>
<pre><code class="language-java">@Configuration
public class RedisConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration serverConfig =
            new RedisStandaloneConfiguration(&quot;localhost&quot;, 6379);

        LettucePoolingClientConfiguration poolingConfig = LettucePoolingClientConfiguration.builder()
            .poolConfig(connectionPoolConfig())
            .build();

        return new LettuceConnectionFactory(serverConfig, poolingConfig);
    }

    private GenericObjectPoolConfig&lt;StatefulConnection&lt;?, ?&gt;&gt; connectionPoolConfig() {
        GenericObjectPoolConfig&lt;StatefulConnection&lt;?, ?&gt;&gt; poolConfig = new GenericObjectPoolConfig&lt;&gt;();

        // 피크 기준 전체 엔드포인트 동시 Redis 작업 수의 2~3배 여유를 확보한 값
        poolConfig.setMaxTotal(16);
        // maxTotal의 절반: 트래픽 스파이크 시 신규 커넥션 생성 없이 즉시 사용 가능한 커넥션 예비
        poolConfig.setMaxIdle(8);
        // 저트래픽 시간대에도 최소 2개 상시 유지하여 첫 요청 시 cold start 지연 방지
        poolConfig.setMinIdle(2);
        // 풀 고갈 시 최대 대기 시간: commandTimeout보다 짧게 설정하여 풀 대기 중 타임아웃 선행 방지
        poolConfig.setMaxWait(Duration.ofSeconds(2));
        // eviction 실행 시 유휴 커넥션 유효성 검증: 커넥션 끊김 문제를 선제적으로 감지
        poolConfig.setTestWhileIdle(true);
        // 30초 주기로 eviction 스레드를 실행하여 유휴 커넥션 정리 및 유효성 검증
        poolConfig.setTimeBetweenEvictionRuns(Duration.ofSeconds(30));
        // 60초 초과 유휴 커넥션 회수: minIdle 아래로 풀 크기를 줄여 Redis 서버 측 커넥션 부하 최소화
        poolConfig.setMinEvictableIdleDuration(Duration.ofSeconds(60));

        return poolConfig;
    }
}</code></pre>
<h3 id="각-설정값의-의미">각 설정값의 의미</h3>
<ul>
<li><strong>maxTotal(16)</strong>: Pool이 유지할 수 있는 최대 커넥션 수. 피크 트래픽 기준 동시 Redis 작업 수의 2~3배로 설정하여 여유를 확보합니다.</li>
<li><strong>maxIdle(8)</strong>: 유휴 상태로 유지할 최대 커넥션 수. 트래픽 스파이크 시 즉시 사용 가능한 커넥션을 예비해 둡니다.</li>
<li><strong>minIdle(2)</strong>: 항상 유지할 최소 커넥션 수. 저트래픽 시간대에도 최소한의 커넥션을 상시 유지하여 첫 요청의 cold start 지연을 방지합니다.</li>
<li><strong>maxWait(2s)</strong>: Pool이 고갈되었을 때 커넥션을 기다리는 최대 시간. commandTimeout보다 짧게 설정하여 풀 대기 중 타임아웃이 먼저 발생하는 상황을 방지합니다.</li>
<li><strong>testWhileIdle(true)</strong>: eviction 실행 시 유휴 커넥션의 유효성을 검증합니다. 네트워크 장애 등으로 끊어진 커넥션을 선제적으로 감지합니다.</li>
<li><strong>timeBetweenEvictionRuns(30s)</strong>: eviction 스레드 실행 주기. 30초마다 유휴 커넥션을 정리하고 유효성을 검증합니다.</li>
<li><strong>minEvictableIdleDuration(60s)</strong>: 60초 이상 유휴 상태인 커넥션을 회수합니다. minIdle 이하로는 줄이지 않으며, Redis 서버 측 커넥션 부하를 최소화합니다.</li>
</ul>
<h3 id="이렇게-설정하면-실제로-어떻게-달라지는가">이렇게 설정하면 실제로 어떻게 달라지는가?</h3>
<p><strong>설정 전 (기본값 사용 시)</strong></p>
<p><code>GenericObjectPoolConfig</code>를 별도로 지정하지 않으면 Commons Pool2의 기본값이 적용됩니다. 기본값은 <code>maxTotal=8</code>, <code>maxIdle=8</code>, <code>minIdle=0</code>, <code>maxWait=-1(무제한)</code> 입니다.</p>
<p>이 상태에서 <code>executePipelined()</code>를 사용하면:</p>
<ul>
<li><code>minIdle=0</code>이므로 저트래픽 구간에 유휴 커넥션이 모두 반납되고, 다음 요청 시 매번 새 커넥션을 생성하는 <strong>cold start 지연</strong>이 발생합니다.</li>
<li><code>maxWait=-1(무제한)</code>이므로 Pool이 고갈되면 스레드가 무한정 대기하여 <strong>요청 처리 지연 및 장애</strong>로 이어질 수 있습니다.</li>
<li>Connection Pool이 없는 경우(기본 <code>LettuceConnectionFactory</code> 사용 시) 피크타임에 <strong>동시 요청 수만큼 신규 커넥션이 생성</strong>되어 Redis 서버 측 커넥션 지표가 폭증합니다.</li>
</ul>
<p><strong>설정 후 (위 Pool Config 적용 시)</strong></p>
<ul>
<li><strong>maxTotal(16)</strong> 덕분에 최대 16개의 커넥션만 생성되어, 피크타임에도 <strong>Redis 서버의 신규 커넥션 수립 지표가 안정적으로 유지</strong>됩니다. 실제로 피크타임 1,000개 이상이던 신규 커넥션 수립 지표가 Pool 적용 후 <strong>maxTotal 범위 내로 수렴</strong>하여 안정화되었습니다.</li>
<li><strong>minIdle(2)</strong> 덕분에 저트래픽 시간대에도 최소 2개의 커넥션이 상시 유지되어 <strong>첫 요청의 커넥션 생성 지연(cold start)이 제거</strong>됩니다.</li>
<li><strong>maxWait(2s)</strong> 덕분에 Pool이 고갈되더라도 최대 2초 내로 대기가 끊기므로 <strong>무한 대기에 의한 스레드 블로킹 장애를 방지</strong>합니다. commandTimeout보다 짧게 설정하여 Pool 대기 중 타임아웃 예외가 먼저 발생하는 상황도 방지됩니다.</li>
<li><strong>testWhileIdle + timeBetweenEvictionRuns(30s)</strong> 조합으로 30초마다 유휴 커넥션의 유효성을 검증하여, 네트워크 장애나 Redis 서버 재시작으로 인해 <strong>끊어진 커넥션이 Pool에 남아 있다가 사용되는 문제를 선제적으로 방지</strong>합니다.</li>
<li><strong>minEvictableIdleDuration(60s)</strong> 덕분에 트래픽이 줄어드는 야간 시간대에 60초 이상 유휴 상태인 커넥션이 자동 회수되어 <strong>Redis 서버의 불필요한 커넥션 유지 부하를 줄일 수 있습니다</strong>.</li>
</ul>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>권장 방식</th>
</tr>
</thead>
<tbody><tr>
<td>일반 GET/SET 등 단순 커맨드</td>
<td>Shared Connection (기본값)</td>
</tr>
<tr>
<td>Pipeline, Transaction, Blocking 커맨드</td>
<td>Connection Pool 필수</td>
</tr>
</tbody></table>
<p>Lettuce는 기본적으로 훌륭한 thread-safe 클라이언트이지만, <strong>Pipeline처럼 커넥션을 독점해야 하는 작업</strong>을 Connection Pool 없이 사용하면 운영 환경에서 커넥션 폭증이라는 심각한 문제로 이어질 수 있습니다.</p>
<p><code>executePipelined()</code>를 사용하고 있다면, 반드시 <code>LettucePoolingClientConfiguration</code>을 통해 Connection Pool을 설정하세요.</p>
<hr>
<h2 id="다음-편-예고">다음 편 예고</h2>
<p>다음 편에서는 Lettuce와 자주 비교되는 <strong>Redisson</strong> 클라이언트를 다룹니다. 분산락, 분산 컬렉션 등 고수준 기능이 필요한 경우 Redisson이 어떤 이점을 제공하는지 살펴볼 예정입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring framework 란?]]></title>
            <link>https://velog.io/@jw_kim/Spring-Spring-framework-%EB%9E%80</link>
            <guid>https://velog.io/@jw_kim/Spring-Spring-framework-%EB%9E%80</guid>
            <pubDate>Fri, 16 Jan 2026 05:37:44 GMT</pubDate>
            <description><![CDATA[<p>스프링 프레임워크에 대해 알아보기에 앞서 프레임워크란 무엇인지 알아보겠습니다.</p>
<h3 id="framwork와-spring-framework">Framwork와 Spring Framework</h3>
<p>프레임워크는 프로그래밍 환경에서 발생할 수 있는 다양한 문제를 쉽고 빠르게 해결할 수 있도록 도와주는 미리 정의된 기능의 집합입니다.
스프링 프레임워크란(정확히는 자바 기반 애플리케이션 전반에서) 개발자가 구현해야하는 다양하고 복잡한 문제들을 이미 구현해두었고, 개발자는 이를 활용해서 웹을 포함한 애플리케이션을 쉽고 빠르게 구축할 수 있도록 도와주는 프레임워크 입니다.</p>
<h3 id="spring-frmaework의-특징">Spring Frmaework의 특징</h3>
<p>그렇다면 스프링 프레임워크는 어떻게 애플리케이션을 쉽고 빠르게 구축할 수 있도록 도와줄까요? 이는 스프링 프레임워크의 특징을 살펴보면 이해할 수 있습니다.
일반적으로 스프링 프레임워크의 특징을 이야기 할 때, IoC container, DI, AOP 등을 이야기합니다.</p>
<h3 id="ioc-container와-di">IoC Container와 DI</h3>
<p>IoC는 Inversion Of Control의 축약어로 ‘제어의 역전’을 의미합니다. 일반적인 프로그래밍 환경에서 개발자는 직접 객체를 생성하고 객체간의 의존 관계를 정의하며 기능을 구현해냅니다. 하지만 스프링을 사용하면 객체 생성과 의존 관계 연결 같은 공통 작업을 프레임워크가 대신 관리해주기 때문에, 개발자 입장에서는 대부분의 경우 객체의 생명주기 자체를 일일이 관리하기보다 비지니스 로직 작성에 더 집중할 수 있게 됩니다.</p>
<p>이런 ‘제어의 역전’을 구현하는 대표적인 방식이 IoC Container와 DI(의존성 주입) 입니다. 개발자가 비지니스 로직 작성에 필요한 컴포넌트들을 등록해두면, Spring은 이를 Bean으로 관리하고 자체 컨테이너 안에 보관합니다. 이 컨테이너를 IoC Container 라고 합니다.
개발자는 컴포넌트 사용을 위해 매번 새로운 객체를 생성할 필요 없이, IoC 컨테이너가 관리하는 Bean을 주입(DI) 받는 형태로 간단하게 가져와서 사용할 수 있게됩니다.</p>
<h3 id="aop">AOP</h3>
<p>다음으로 AOP(관점 지향 프로그래밍)에 대해 알아보겠습니다. 프로그램을 구현하다 보면 핵심 기능에 해당하는 비지니스 로직 뿐만 아니라 사용자 요청과 응답의 기록(Logging), 권한 검증 등 다양한 부가 로직들도 함께 작성이 됩니다.
만약 하나의 메소드에 핵심 비지니스 로직 이외의 부가적인 역할을 하는 부가 로직이 같이 작성된다면, 단일 책임 원칙을 위반할 뿐만 아니라 코드 가독성이 저해되고 유지보수가 힘들어지는 상황을 초래할 수 있습니다.</p>
<p>AOP는 이런 부가 로직을 핵심 로직과 분리해서 적용할 수 있게 도와줍니다. 보통 애노테이션 등을 활용해서 “어디에 적용할지”를 선언할 수 있고, 스프링은 그 지점에 부가 기능이 실행되도록 연결해줍니다.
결국 비지니스 로직은 핵심 책임에 더 집중할 수 있고, 부가 로직은 공통 기능으로 관리할 수 있어서 유지보수가 쉬워지며 기능 확장도 더 수월해집니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[주니어 백엔드 실무지식] 5장. 비동기 연동, 언제 어떻게 써야 할까]]></title>
            <link>https://velog.io/@jw_kim/%EC%A3%BC%EB%8B%88%EC%96%B4-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%8B%A4%EB%AC%B4%EC%A7%80%EC%8B%9D-5%EC%9E%A5.-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%97%B0%EB%8F%99-%EC%96%B8%EC%A0%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@jw_kim/%EC%A3%BC%EB%8B%88%EC%96%B4-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%8B%A4%EB%AC%B4%EC%A7%80%EC%8B%9D-5%EC%9E%A5.-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%97%B0%EB%8F%99-%EC%96%B8%EC%A0%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Thu, 15 Jan 2026 15:44:24 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>동기(synchronous)방식은 순차적으로 실행된다. 동기 방식은 한 작업이 끝날 때까지 다음 작업이 진행되지 않는다. 동기 방식은 코드 순서가 곧 실행 방식이 된다. 
비동기(asynchronous)방식은 한 작업이 끝날 때까지 기다리지 않고 바로 다음 작업을 처리한다. 비동기 방식을 사용하면 외부 연동이 끝날 때까지 기다리지 않고 바로 다음 작업을 수행한다.</p>
</blockquote>
<h2 id="동기-연동과-비동기-연동">동기 연동과 비동기 연동</h2>
<p>동기 방식은 프로그램의 흐름을 직관적으로 이해할 수 있고, 디버깅이 용이하다. 하지만 동기 방식으로 외부 연동을 처리할 경우 외부 연동 실패가 전체 기능의 실패인지 확인이 필요하다.</p>
<p>비동기 방식은 한 작업이 끝날 때까지 기다리지 않고 다음 작업을 실행한다. 비동기 연동은 다양한 방식으로 구현할 수 있다.</p>
<p><strong>질문</strong></p>
<ul>
<li><strong>동기/비동기의 차이를 “실행 흐름” 말고 “결과 전달(성공/실패), 자원 점유, 장애 전파” 관점에서 설명해보세요.</strong></li>
<li><strong>외부 연동을 동기로 둘지 비동기로 둘지 결정 기준 3가지는?</strong> (예: 사용자 응답 요구, 정합성 요구, 장애/지연 전파 허용 여부)</li>
<li><strong>동기 외부 호출이 늘어질 때(지연/타임아웃) 서버에서는 어떤 병목이 연쇄적으로 생기나요?</strong> (스레드 점유 → 커넥션풀/큐 대기 → 타임아웃 확산 등)</li>
<li><strong>동기 연동 실패 시 재시도는 어디에서 어떻게 해야 하나요?</strong> (클라이언트/서버/메시지 레벨, 백오프/서킷브레이커)</li>
<li><strong>비동기로 바꾸면 ‘사용자에게 성공을 언제 알려야’ 하나요?</strong> (즉시 성공 vs 접수 성공, 상태 조회/콜백/웹훅)</li>
</ul>
<hr>
<h2 id="별도-스레드로-실행하기">별도 스레드로 실행하기</h2>
<p>비동기 연동을 실행하는 가장 쉬운 방법은 별도의 스레드로 실행하는 것이다.</p>
<pre><code class="language-kotlin">fun placeOrder(request: OrderRequest): OrderResult {
    ... // 주문 생성 처리

    // 별도 스레드를 이용해 푸시 발송
    Thread { pushClient.sendPush(pushData) }.start()

    // 스레드 풀을 이용
    // executor.submit(() -&gt; pushClient.sendPush(pushData)).start();

    return successResult(....); // 푸시 발송 요청 응답을 기다리지 않고 리턴

}</code></pre>
<p>별도 스레드로 실행하면 연동 과정에서 발생한 오류 처리에 더 신경 써야 한다. 별도 스레드로 실행되는 코드는 익셉션이 전파되지 않기 때문에 실행되는 코드 내부에서 연동 과정에서 발생한 오류를 직접 처리해야한다.</p>
<p><strong>질문</strong></p>
<ul>
<li><strong>새 스레드로 던져버리는 방식의 운영 리스크는 뭐고, 어떻게 완화하나요?</strong> (스레드 폭증, 컨텍스트 전파, 종료/유실, 관측성)</li>
<li><strong>별도 스레드에서는 예외가 전파되지 않는데, 실패 처리는 어떻게 설계하나요?</strong> (재시도, DLQ 성격의 저장, 알림/모니터링)</li>
</ul>
<hr>
<h2 id="메세징">메세징</h2>
<p>서로 다른 시스템 간에 비동기로 연동할 때 주로 사용하는 방식은 메세징 시스템을 사용하는 방식이다.</p>
<p>메세징 시스템을 사용하면 전체적인 구조가 복잡해지는 대신 다른 이점을 얻을 수 있다.</p>
<p><strong>두 시스템이 서로 영향을 주지 않는다.</strong></p>
<p>시스템 A와 연동되어있는 시스템 B로 처리량 이상의 트래픽이 발생하는 상황을 가정하면, 직접 연동이 되어있을 경우 시스템 B에 성능 저하가 발생하고 이는 전체적인 시스템 성능 저하로 이어질 수 있다. 메세징 시스템은 시스템 A가 보낸 메세지를 일단 저장하고, 시스템 B가 처리 가능한 만큼만 처리할 수 있다.</p>
<p><strong>확장이 용이하다.</strong></p>
<p>시스템 A와 새로운 시스템 C의 연동이 필요해질 때, 직접 연동의 경우 시스템 A와 시스템 C 모두 연동 로직을 추가해야하지만, 메시지 시스템을 도입 할 경우 시스템 A 수정 없이 시스템 C에서 메세지를 수신하여 처리하는 로직만 작성하면 된다.</p>
<p><strong>질문</strong></p>
<ul>
<li><strong>메시징을 도입하면 얻는 이점(디커플링)과 동시에 생기는 비용(복잡도)은 무엇이고, 언제 도입이 정당화되나요?</strong></li>
</ul>
<h3 id="메세지-생성-측-고려-사항">메세지 생성 측 고려 사항</h3>
<p>메세지를 생성할 때 고려할 점은 메세지 유실에 대한 것이다. 메세지가 유실되었을 경우</p>
<ul>
<li>무시하거나</li>
<li>재시도하거나</li>
<li>실패 기록을 남기거나</li>
</ul>
<p>오류 처리를 위해 위 세가지 방식 중 하나를 선택하여 처리해야한다. 이는 메세지의 특징을 파악하여 적절한 처리 방식을 선택할 필요가 있다.</p>
<p>메세지 생성자는 DB 트랜잭션과의 연동도 고려해야한다. DB 트랜잭션의 커밋과 롤백 시점과 메세지 발생 시점을 고려해야한다.</p>
<p><strong>질문</strong></p>
<ul>
<li><strong>메시지 유실을 허용/재시도/실패 기록 중 무엇을 고르는 기준은?</strong> (업무 중요도, 부작용 크기, 사용자 영향)</li>
<li><strong>DB 트랜잭션 커밋과 메시지 발행의 원자성을 어떻게 맞추나요?</strong> (커밋 전에 발행/커밋 후 발행 각각의 문제점)</li>
<li><strong>‘정확히 한 번 발행(exactly-once)’이 어렵다면 현실적으로 어떤 보장을 목표로 하나요?</strong> (at-least-once + 멱등 처리)</li>
</ul>
<h3 id="메세지-소비-측-고려-사항">메세지 소비 측 고려 사항</h3>
<p>메세지 소비 측은 </p>
<ul>
<li>메세지 생산자가 동일한 내용의 메세지를 메세징 시스템에 중복 발행</li>
<li>소비자가 메세지 처리 과정에서 발생한 오류로 인해 메세지 재수신</li>
</ul>
<p>이러한 이유로 동일한 메세지를 중복해서 처리할 수 있다. 중복 처리가 문제가 되지 않는 경우도 있지만, 데이터 정합성이 틀어지는 상황이 대부분이다.</p>
<p>중복 소비를 방지하기 위해  멱등성을 보장하는 API를 구현하게 되면 동일 요청을 여러번 하더라도 결과가 바뀌지 않는다.</p>
<p><strong>질문</strong></p>
<ul>
<li><strong>왜 중복 소비가 발생하고, 중복을 “없애는 것”과 “허용하고 멱등으로 흡수” 중 무엇을 택하나요?</strong></li>
<li><strong>멱등성을 구현하는 대표 패턴을 설명해보세요.</strong> (idempotency key 저장, unique constraint, 처리 로그, 상태 전이)</li>
<li><strong>소비 실패 시 재처리 설계는 어떻게 하나요?</strong> (재시도 횟수/백오프, DLQ, poison message, 수동 복구)</li>
</ul>
<h3 id="메세지-종류-이벤트와-커멘드">메세지 종류: 이벤트와 커멘드</h3>
<p><strong>이벤트</strong></p>
<p>어떤 일이 발생했음을 알려주는 메세지 (예: 주문함, 로그인에 실패함, 상품 정보를 조회함)</p>
<p>정해진 수신자가 없다. 발생한 사건에 관심이 있는 소비자가 메세지를 수신한다.</p>
<p><strong>커멘드</strong></p>
<p>무언가를 요청하는 메세지 (예: 포인트 지급하기, 배송 완료 문자 발송하기)</p>
<p>메세지를 수신할 측의 기능 실행에 초점이 맞춰져있다. 수신자가 정해져있다.</p>
<p><strong>질문</strong></p>
<ul>
<li><strong>이벤트와 커맨드를 어떻게 구분하고, 잘못 설계하면 어떤 문제가 생기나요?</strong> (결합도 상승, 책임 혼선, 확장성 저하)</li>
<li><strong>이벤트에 “정해진 수신자 없음”을 유지하려면 무엇을 조심해야 하나요?</strong> (특정 소비자 요구사항을 이벤트 스키마에 박아넣는 문제)</li>
<li><strong>같은 주제(예: 포인트 지급)를 이벤트로 할지 커맨드로 할지 결정 기준은?</strong> (요청/책임/보상 필요 여부)</li>
</ul>
<hr>
<h2 id="트랜잭션-아웃박스-패턴">트랜잭션 아웃박스 패턴</h2>
<p>메세지 생성자는 잘못된 메세지의 발생을 막기 위해 DB 트랜잭션이 완료된 이후 메세지를 전송하는 방식을 고려할 수 있다. 메세지에 해당하는 데이터를 DB에 저장하고 저장된 메세지를 읽어 메세지 시스템에 전송하는 방식을 <strong>트랜잭션 아웃박스 패턴</strong>이라고한다.</p>
<ul>
<li>실제 업무 로직에 필요한 DB 변경 작업을 수행한다.</li>
<li>메세지 데이터를 아웃박스 테이블에 추가한다.</li>
</ul>
<p>위 두 가지 로직이 하나의 트랜잭션으로 묶인다면 원자성을 보장하며 데이터 변경과 메세지 발행 요청을 처리할 수 있다. DB에 저장된 메세지 데이터는 별도의메세지 중계 프로세스가 주기적으로 읽어 메세징 시스템에 전송한다.</p>
<h3 id="아웃박스-테이블-구조">아웃박스 테이블 구조</h3>
<p>아웃박스 테이블은 각자 상황에 맞게 구성된다. 아웃박스 테이블은 메세지 종류와 페이로드, 처리 상태등을 저장하고있어야한다.</p>
<p><strong>질문</strong></p>
<ul>
<li><strong>아웃박스 패턴이 해결하는 문제와, 정확히 어떤 실패 시나리오를 막는지 설명해보세요.</strong> (DB 커밋은 됐는데 메시지 발행 실패 등)</li>
<li><strong>아웃박스 릴레이(중계)가 중복 발행/재시작/지연될 때 정합성은 어떻게 보장하나요?</strong> (상태 컬럼, 락/폴링, 멱등 소비)</li>
<li><strong>아웃박스 테이블을 운영하면 생기는 추가 이슈는?</strong> (테이블 증가/정리, 인덱스, 폴링 부하, 지연 SLA, 모니터링)</li>
</ul>
<hr>
<h2 id="배치-전송">배치 전송</h2>
<p>데이터를 연동하는 가장 전통적인 방법. 일정 간격으로 데이터를 전송하는 방식</p>
<ol>
<li>DB에서 전송할 데이터를 조회</li>
<li>조회한 결과를 파일로 기록</li>
<li>파일을 연동 시스템에 전달</li>
</ol>
<p><strong>질문</strong></p>
<ul>
<li><strong>배치 방식이 여전히 유효한 상황은 언제고, 실시간 메시징 대비 트레이드오프는?</strong> (지연 허용, 단순성, 비용)</li>
<li><strong>배치에서 ‘부분 실패’가 나면 재처리를 어떻게 설계하나요?</strong> (체크포인트, 재실행 범위, 중복 처리/멱등)</li>
<li><strong>대용량 배치에서 성능 병목은 보통 어디서 생기고 어떻게 튜닝하나요?</strong> (조회/정렬/인덱스, 파일 I/O, 네트워크, 청크 처리)</li>
</ul>
<h3 id="재처리-기능-만들기">재처리 기능 만들기</h3>
<hr>
<h2 id="cdc">CDC</h2>
<p>변경된 데이터를 추적하고 판별해서 변경된 데이터로 작업을 수행할 수 있도록 하는 소프트웨어 설계 패턴</p>
<p>다양한 DBMS 시스템이 데이터가 변경되면 그 내용을 통지하는 기능을 제공한다.</p>
<p>DB는 <strong>커밋된 데이터만 변경된 순서에</strong> 맞게 CDC 처리기로 전달한다. 롤백된 데이터가 전달되거나 잘못된 순서로 데이터가 전달되지 않는다.</p>
<h3 id="cdc와-데이터-위치">CDC와 데이터 위치</h3>
<p>CDC 처리기는 변경된 데이터를 어디까지 처리했는지 기록해야한다. 이를 기록해야 CDC 처리기를 재시작 할 때 마지막으로 조회한 로그부터 읽어올 수 있다.</p>
<h3 id="cdc가-유용할-때">CDC가 유용할 때</h3>
<p>신규 서비스의 도입으로 레거시 시스템에 연동 코드를 추가해야할 때, 이를 위한 작업 공수가 필요 이상으로 발생하거나 불가능한 상황이 있다. 이런 경우 시스템 코드를 수정하지 않고 CDC를 활용하여 데이터를 전파할 수 있다.</p>
<p><strong>질문</strong></p>
<ul>
<li><strong>CDC가 유리한 케이스와 불리한 케이스를 비교해보세요.</strong> (레거시 수정 불가 vs 운영 복잡도/스키마 변화 대응)</li>
<li><strong>CDC는 “커밋된 변경”을 순서대로 준다고 했는데, 그럼에도 고려해야 할 정합성 이슈는 뭐가 있나요?</strong> (스냅샷/초기 적재, 스키마 진화, 지연/재처리)</li>
<li><strong>CDC 처리기의 오프셋/체크포인트 관리는 어떻게 설계하고, 장애 복구 시 어떤 전략을 쓰나요?</strong> (at-least-once, 재처리 범위, idempotency)</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[주니어 백엔드 실무 지식]  4. 부 연동이 문제일 때 살펴봐야 할 것들]]></title>
            <link>https://velog.io/@jw_kim/%EC%A3%BC%EB%B0%98%EB%B0%B1%EC%8B%A4-4.-%EB%B6%80-%EC%97%B0%EB%8F%99%EC%9D%B4-%EB%AC%B8%EC%A0%9C%EC%9D%BC-%EB%95%8C-%EC%82%B4%ED%8E%B4%EB%B4%90%EC%95%BC-%ED%95%A0-%EA%B2%83%EB%93%A4</link>
            <guid>https://velog.io/@jw_kim/%EC%A3%BC%EB%B0%98%EB%B0%B1%EC%8B%A4-4.-%EB%B6%80-%EC%97%B0%EB%8F%99%EC%9D%B4-%EB%AC%B8%EC%A0%9C%EC%9D%BC-%EB%95%8C-%EC%82%B4%ED%8E%B4%EB%B4%90%EC%95%BC-%ED%95%A0-%EA%B2%83%EB%93%A4</guid>
            <pubDate>Tue, 06 Jan 2026 07:40:32 GMT</pubDate>
            <description><![CDATA[<h1 id="4장-외부-연동이-문제일-때-살펴봐야-할-것들">4장. 외부 연동이 문제일 때 살펴봐야 할 것들</h1>
<blockquote>
<p>💡 외부 연동은 서버 개발에서 없어서는 안될 요소가 되었다. 마이크로서비스를 도입하는 기업이 늘어나면서 내부 서비스 간 연동도 복잡해지고있다. 연동하는 서비스에 장애가 발생하면 우리 서비스도 영향을 받는다. 서비스 간 연동이 많아질수록 연동 시스템의 품질도 함께 신경써야한다.
연동 서비스의 문제를 완전히 차단하기는 어렵다. 하지만 그 영향도를 줄일 수 있다.</p>
</blockquote>
<h2 id="타임아웃">타임아웃</h2>
<blockquote>
<p>외부 연동에서 가장 중요한 설정 중 하나가 타임아웃이다. 연동 서비스를 호출할 때 타임아웃을 적절하게 설정하지 않으면, 연동 서비스에 장애가 발생했을 때 전체 서비스의 품질이 급격하게 나빠질 수 있다.</p>
</blockquote>
<h3 id="연결-타임아웃과-읽기-타임아웃">연결 타임아웃과 읽기 타임아웃</h3>
<p>API 연동 과정을 단순화하면 연결 → 요청 → 응답 → 종료 4단계로 나눌 수 있다.</p>
<p><strong>연결</strong></p>
<p>네트워크을 통한 연결에는 시간이 소요된다. 네트워크 상황이나 연결할 서버의 상태에 따라 연결에 오랜 시간이 소요될 수도 있다. 연결에 시간이 오래 걸리면 사용자의 대기 시간도 함께 증가하게된다. 대기 시간이 무한정 길어지면 성능에 문제가 발생하므로, <strong>연결 타임아웃</strong>을 설정해 연결 대기 시간을 제한해야한다.</p>
<p>일단 연결이 되면 요청을 하고 응답을 기다린다. 이때 응답을 받기까지 시간이 오래 걸리면 대기 시간 문제가 다시 발생한다. 따라서 <strong>읽기 타임아웃</strong>을 설정하여 응답 대기 시간을 제한해야한다.</p>
<p>❓연결 타임아웃(connect timeout)과 읽기 타임아웃(read timeout)의 차이를 “연동 단계(연결/요청/응답)” 기준으로 설명해보세요.</p>
<p>❓읽기 타임아웃이 발생했을 때 “서버가 실제로 처리를 완료했을 가능성”은 왜 존재하나요?</p>
<p>❓서버 응답 지연이 늘었을 때 “타임아웃으로 실패를 빠르게 만들면” 시스템 안정성은 왜 좋아질 수 있나요?</p>
<hr>
<h2 id="재시도">재시도</h2>
<blockquote>
<p>외부 연동에 실패했을 때 처리 방법 중 하나는 재시도를 하는 것</p>
</blockquote>
<h3 id="재시도-가능-조건">재시도 가능 조건</h3>
<p>재시도를 통해 연동 실패를 줄일 수 있지만, 항상 재시도를 할 수 있는 것은 아니다. 연동 API가 다시 호출해도 되는 조건인지 확인해야 한다. 포인트 차감 기능과 같은 외부 API에 재시도를 적절하지 않게 설정하면 중복 차감 등 심각한 문제가 발생할 수 있다.</p>
<p><strong>재시도 가능 조건</strong></p>
<ul>
<li>단순 조회 기능: 단순 조회 기능은 재시도하여도 원본 데이터를 변경하지 않는다.</li>
<li>연결 타임아웃: 연결 타임아웃이 발생할 경우 외부 API 연결에 실패하였으므로 재시도로 인해 발생할 수 있는 중복 결제 등의 문제가 없다.</li>
<li><strong>멱등성</strong>을 가진 변경 기능: 멱등성을 보장하는 API의 경우 동일 요청에 항상 동일 응답을 반환하기 때문에 재시도로 인해 중복 처리 문제가 발생하지 않는다.</li>
</ul>
<p>❓어떤 조건에서 재시도가 안전한가요? (조회/연결 실패/멱등 보장)</p>
<p>❓“멱등성”이 있다고 판단하려면 어떤 전제가 필요하나요? (요청 키, 서버 구현, 중복 처리 방지)</p>
<p>❓결제/포인트 차감 같은 “변경 API”는 왜 재시도가 위험한가요?</p>
<p>❓읽기 타임아웃은 왜 재시도 위험도가 큰 편인가요? 그럼에도 재시도가 필요하면 뭘 추가해야 하나요?</p>
<h3 id="재시도-횟수와-간격">재시도 횟수와 간격</h3>
<p>재시도 전략을 수립할 때 <strong>횟수와 간격 2가지</strong>를 고려해야한다.</p>
<p><strong>재시도 횟수</strong></p>
<p>재시도를 무한정 할 수는 없다. 재시도 횟수 만큼 응답 시간도 함께 증가하며, 외부 서버에 가해지는 부하도 가중되기 때문에, 적당한 (보통 1-2회) 횟수로 재시도를 제한해야한다. </p>
<p><strong>재시도 간격</strong></p>
<p>네트워크 연결 상태가 6초간 좋지 않은 상황에서, API 연결 타임 아웃을 3초로 설정할 경우 최초 호출이 실패하게된다. 이 경우 즉시 재시도 요청을 할 경우 두 번째 요청도 실패하게된다. 때문에 일시적 네트워크 문제등이 해소되면 연결에 성공할 수 있도록 재시도 간격을 적절하게 설정하는것이 중요하다.</p>
<p>❓재시도 횟수를 1~2회로 제한하는 이유를 <strong>사용자 응답 시간</strong>과 <strong>상대 시스템 부하</strong> 관점에서 설명해보세요.</p>
<p>❓즉시 재시도 vs 지연 재시도는 어떤 상황에서 유리한가요?</p>
<p>❓재시도 정책을 “전 구간 동일”하게 두면 어떤 문제가 생기나요? (엔드포인트별/오류별 분리 필요성)</p>
<h3 id="재시도-폭풍retry-storm-안티-패턴">재시도 폭풍(retry storm) 안티 패턴</h3>
<p>재시도를 통해 성공 가능성을 높일 수 있지만, 반대로 연동 서비스에 더 큰 부하를 줄 수 있다. 따라서 재시도를 검토할 때는 연동 시브스의 성능 상황도 함께 고려해야한다.</p>
<p>❓retry storm이 발생하는 전형적인 시나리오를 설명해보세요.</p>
<p>❓retry storm을 막기 위해 어떤 기법을 조합하나요? (지수 백오프, 지터, 서킷브레이커, 동시요청 제한)</p>
<p>❓재시도는 “클라이언트”와 “서버/게이트웨이” 중 어디에서 해야 하나요? 각각의 장단점은?</p>
<hr>
<h2 id="동시-요청-제한">동시 요청 제한</h2>
<blockquote>
<p>연동 서비스의 동시 요청 수가 100건일 때, 연동 서비스로 동시 요청이 300개 들어오면, 연동 서비스 최대 처리량을 초과하여 응답 시간 지연이 발생한다.</p>
</blockquote>
<p>연동 서비스에 임계치 이상의 요청을 보내면 발생하는 성능 저하 문제를 완화하는 방법은, 연동 서비스에 요청을 일정 수준 이상으로 보내지 않는 것</p>
<p>❓동시 요청 제한이 필요한 이유를 “상대 서비스 처리량 한계”와 “큐잉 지연”으로 설명해보세요.</p>
<p>❓제한 초과 시 동작은 어떻게 해야 하나요? (즉시 실패/대기/큐잉) 사용자 경험 관점에서 답해보세요.</p>
<p>❓동시성 제한과 rate limit(QPS 제한)은 무엇이 다르고, 각각 어떤 장애를 막나요?</p>
<hr>
<h2 id="서킷-브레이커">서킷 브레이커</h2>
<blockquote>
<p>연동 서비스에 과부하가 발생해 응답을 제대로 주지 못하고있는 상황이라면, 연동 서비스가 정상화되기 까지 요청을 보내도 계속해서 에러 응답만 발생한다.
외부 서비스에 장애가 발생하였을 때, 내부 서비스에서 장애 상황을 인지하고 사용자에게 빠른 응답을 주는 것이 필요</p>
</blockquote>
<p>서킷브레이커는 누전차단기와 같이 동작한다. 외부 서비스 연동 과정에 과도한 오류가 발생하면 연동을 중지시키서고 즉시 에러를 응답한다.</p>
<p>서킷브레이커가 열린 상태에서는 외부 연동 요청을 수행하지 않고 빠르게 에러 응답을 사용자에게 전달한다. 서킷브레이커는 닫힘-반열림-열림 상태를 변경하며 외부 서비스 장애에 대응한다.</p>
<p>❓서킷 브레이커의 목적을 “장애 전파 차단” 관점에서 설명해보세요.</p>
<p>❓어떤 지표로 open을 판단하나요? (error rate, slow call rate, timeouts)</p>
<hr>
<h2 id="외부-연동과-db-연동">외부 연동과 DB 연동</h2>
<h3 id="외부-연동과-트랜잭션-처리">외부 연동과 트랜잭션 처리</h3>
<p>DB연동과 외부 연동을 하나의 트랜잭션으로 실행할 경우, 오류 발생 시 DB 트랜잭션을 어떻게 처리할지 판단해야한다.</p>
<p><strong>외부 연동이 실패했을 경우 전체 트랜잭션 롤백</strong></p>
<p>트랜잭션 범위 안에서 외부 연동에 실패한경우, 트랜잭션을 롤백할 수 있다. 이 경우 변경한 데이터가 DB에 반영되지 않기 때문에 DB 데이터에 이상이 생기는 것을 방지할 수있다.</p>
<p>하지만 외부 서비스 호출 과정에서 <strong>읽기 타임아웃</strong>이 발생하였을 경우, 실제로 외부 서비스에서 요청을 성공적으로 처리했을 가능성을 염두해두어야한다. 이런 경우 아래와 같은 두 가지 방법을 검토해야한다.</p>
<ul>
<li>일정 주기로 두 시스템간 데이터 일치 여부를 확인하고 보정</li>
<li>성공 확인 API 호출로 성공 여부 확인</li>
</ul>
<p><strong>외부 연동은 성공했지만 DB 트랜잭션이 실패해 트랜잭션을 롤백</strong></p>
<p>외부 연동을 성공했지만 DB 트랜잭션이 실패하는 경우는 취소 API를 추가적으로 호출하여 외부 연동 이전 상태로 데이터를 복원해야한다. 취소 API가 없는 경우 일정 주기로 데이터를 보정하는 프로세스가 추가적으로 필요하게된다.</p>
<p>❓ “DB 트랜잭션 안에서 외부 API 호출”이 위험한 이유를 설명해보세요. (락/커넥션 점유/지연 전파)</p>
<p>❓ 외부 연동 실패 시 롤백 전략을 어떻게 결정하나요? (비즈니스 규칙, 정합성 요구 수준)</p>
<p>❓ 읽기 타임아웃이 떴을 때 “외부는 성공했을 수 있음”을 어떻게 다루나요?</p>
<p>❓외부는 성공했는데 DB가 실패하면 왜 보상 트랜잭션(취소 API)이 필요하나요?</p>
<h3 id="외부-연동이-느려질-때-db-커넥션-풀-문제">외부 연동이 느려질 때 DB 커넥션 풀 문제</h3>
<p>외부 연동이 포함되어있는 트랜잭션에서 외부 연동 시간 증가로 인해 커넥션 풀이 고갈되는 상황이 발생할 수 있다. 이 경우 DB 처리 시간은 동일하지만 외부 연동 문제로 인해 응답 시간이 증가하는 상황이 발생한다.</p>
<p>DB 연동과 무관하게 외부 연동을 실행하는 방안도 고려해볼 필요가 있다.</p>
<p>❓ 외부 연동 지연이 DB 커넥션 풀 고갈로 이어지는 메커니즘을 설명해보세요.</p>
<p>❓ “DB 처리 시간은 동일한데 응답 시간이 늘어나는” 상황에서 어떤 지표로 원인을 특정하나요?</p>
<p>❓ 외부 연동을 트랜잭션 밖으로 빼는 설계는 어떤 경우에 가능한가요? 불가능한 경우는?</p>
<p>❓ 비동기 처리(큐/이벤트)로 전환하면 정합성·사용자 경험은 어떻게 설계해야 하나요</p>
<hr>
<h2 id="연동-서비스-이중화">연동 서비스 이중화</h2>
<p>이커머스 서비스에서 외부 연동을 통해 결제를 처리할 경우, A 결제 서비스의 장애로 인해 결제가 불가능한 상황이 발생할 수 있다. 만약 결제 서비스가 이중화되어있지 않을 경우 A 결제 서비스의 장애가 복구될 때 까지 우리 서비스의 결제가 불가능한 상황이 발생한다.</p>
<p>이러한 상황은 추가적인 결제 서비스를 도입 이중화를 통해 해소할 수 있다. A 결제 서비스의 장애가 발생할 경우 B 결제 서비스를 통해 결제를 진행하는 방식이다.</p>
<p>❓ 결제 서비스 이중화의 필요성을 “단일 장애점(SPOF)” 관점에서 설명해보세요.</p>
<p>❓ A 장애 시 B로 failover 할 때 라우팅은 어디에서 결정하나요? (앱, 게이트웨이, 서비스 디스커버리)</p>
<p>❓ 이중화 시 “정산/취소/중복 결제” 같은 정합성 이슈를 어떻게 방지하나요?</p>
<p>❓ 서로 다른 결제사의 API 스펙 차이를 어떻게 흡수하나요? (어댑터/포트-어댑터, 표준화 모델)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[주니어 백엔드 실무 지식]  3. 성능을 좌우하는 DB 설계와 쿼리]]></title>
            <link>https://velog.io/@jw_kim/%EC%A3%BC%EB%B0%98%EB%B0%B1%EC%8B%A4-3.-%EC%84%B1%EB%8A%A5%EC%9D%84-%EC%A2%8C%EC%9A%B0%ED%95%98%EB%8A%94-DB-%EC%84%A4%EA%B3%84%EC%99%80-%EC%BF%BC%EB%A6%AC</link>
            <guid>https://velog.io/@jw_kim/%EC%A3%BC%EB%B0%98%EB%B0%B1%EC%8B%A4-3.-%EC%84%B1%EB%8A%A5%EC%9D%84-%EC%A2%8C%EC%9A%B0%ED%95%98%EB%8A%94-DB-%EC%84%A4%EA%B3%84%EC%99%80-%EC%BF%BC%EB%A6%AC</guid>
            <pubDate>Sat, 03 Jan 2026 16:05:05 GMT</pubDate>
            <description><![CDATA[<h1 id="3장-성능을-좌우하는-db-설계와-쿼리">3장. 성능을 좌우하는 DB 설계와 쿼리</h1>
<blockquote>
<p>💡 <strong>DB 성능은 연동하는 모든 서버 성능에 영향</strong>을 준다. DB 성능 문제로 전체 서비스가 먹통이 되는 상황도 빈번하게 발생한다. 쿼리 실행 시간이 길어지면 전체 서비스가 느려지는 성능 문제는 흔히 발생하는 문제이다. 하지만 DB 자체가 문제인 상황은 많지 않다. 오히려 DB를 잘못 사용해서 발생한 문제가 더 많다. DB를 전문가 수준으로 깊이 이해할 수 있다면 이상적이겠지만, <strong>서버 개발자 입장에서 조금만 신경써도 DB 성능 문제를 충분히 줄이거나 없앨 수 있다.</strong></p>
</blockquote>
<h2 id="조회-성능을-고려한-인덱스-설계">조회 성능을 고려한 인덱스 설계</h2>
<h3 id="인덱스-설계">인덱스 설계.</h3>
<p>DB 테이블을 설계할 때는 조회 기능과 트래픽 규모를 고려해야 한다. 전체 게시글이 1000만건 이상 적제된 테이블을 대상으로 사용자가 특정 카테고리의 게시글 조회 요청을 할 경우, (카테고리 컬럼에) 인덱스가 없다면 1000만건의 데이터를 비교해야한다. </p>
<p>많은 사용자가 동시에 게시글 조회를 하게되면 다수의 풀 스캔이 발생하고 DB의 CPU 사용률이 100%에 도달할 경우 DB가 제 기능을 하지 못하게 된다.</p>
<ul>
<li>풀 스캔이 발생하지 않도록 하려면 <strong>조회 패턴을 기준으로 인덱스를 설계</strong>해야 한다.</li>
</ul>
<h3 id="단일-인덱스와-복합-인덱스">단일 인덱스와 복합 인덱스</h3>
<ul>
<li>단일 인덱스: userId와 같이 단일한 컬럼을 인덱스로 사용</li>
<li>복합 인덱스: (userId, activityDate)와 같이 여러 컬럼은 묶어서 인덱스로 사용</li>
</ul>
<p>데이터의 특징을 파악하면 단일 인덱스/복합 인덱스 중 더욱 적절한 형태의 인덱스가 적절한지 판단하는데 도움이 된다.</p>
<h3 id="선택도를-고려한-인덱스-컬럼-선택">선택도를 고려한 인덱스 컬럼 선택</h3>
<p>인덱스를 생성할 때는 <strong>선택도</strong>가 높은 컬럼을 골라야 한다.</p>
<ul>
<li>선택도: 특정 칼럼의 고유한 값 비율 (카디널리티)</li>
</ul>
<p>선택도가 높으면 해당 칼럼에 고유값이 많다는 뜻, 선택도가 높을 수록 인덱스를 이용한 조회 효율이 높아진다.</p>
<blockquote>
<p>작업 상태 컬럼과 같이 특정 배치 작업에서 일정 상태값을 조회하는 경우 선택도가 낮더라도 반복 작업의 효율을 높히기 위해 선택도가 낮은 컬럼을 인덱스로 대상으로 선정하여 효율을 높일 수도 있다.</p>
</blockquote>
<h3 id="커버링-인덱스">커버링 인덱스</h3>
<p>커버링 인덱스는 특정 쿼리를 실행하는 데 필요한 컬럼을 모두 포함하는 인덱스를 말한다.</p>
<pre><code class="language-sql">select activityDate, activityType
from activityLog
where activityDate = &#39;2025-01-01&#39; and activityType = &#39;VISIT&#39;;</code></pre>
<p>위와 같은 쿼리를 실행할 때 (activityDate, activityType)을 사용하는 커버링 인덱스가 생성되어 있다면, 실제 데이터를 읽어오는 과정을 생략하고 인덱스에서 필터링과 조회가 가능하기 때문에 더욱 빠른 응답이 가능하다.</p>
<h3 id="인덱스는-필요한-만큼만-만들기">인덱스는 필요한 만큼만 만들기</h3>
<p>효과가 적은 인덱스를 추가할 경우 오히려 성능에 악영향을 줄 수 있음. 인덱스는 조회 속도를 빠르게 하지만 데이터 추가, 변경, 삭제 시에는 인덱스 관리에 따른 추가 비용이 발생하기 때문. 또한 인덱스 자체도 데이터이기 때문에 인덱스가 많아질수록 메모리와 디스크 사용량도 함께 증가</p>
<hr>
<h2 id="조회-성능-개선-방법">조회 성능 개선 방법</h2>
<p>인덱스를 적용하지 않고 조회 성능을 개선하는 몇가지 방법</p>
<h3 id="미리-집계">미리 집계</h3>
<p>게시글에 대한 댓글, 좋아요 와 같은 정보가 서로 다른 테이블에 저장되어있을 경우 1개의 (집계 쿼리가 포함된)게시물 조회 요청에 대하여 N개의 댓글 + M개의 좋아요로 인해 과도한 쿼리 수행 시간이 소요될 수 있다.</p>
<p>이 경우 반정규화(비정규화)를 통해 게시글 테이블에 좋아요 수를 관리할 컬럼을 추가하는 등의 방식으로 추가적인 연산에 소요되는 시간을 줄일 수 있다.</p>
<h3 id="페이지네이션-정보를-기준으로-목록-조회하는-대신-id고유번호를-기준으로-조회">페이지네이션 정보를 기준으로 목록 조회하는 대신 ID(고유번호)를 기준으로 조회</h3>
<h3 id="조회-범위를-시간-기준으로-제한">조회 범위를 시간 기준으로 제한</h3>
<p>조회 범위를 제한하여 최신 데이터만 조회할 수 있도록 설계할 경우 전체 데이터를 조회하지 않기 때문에 조회 성능을 개선할 수 있음</p>
<h3 id="전체-개수-세지-않기">전체 개수 세지 않기</h3>
<p>대량의 데이터를 보관하고있는 테이블을 대상으로 전체 개수를 세지 않도록 기능 협의 혹은 반정규화 등으로 해결</p>
<h3 id="오래된-데이터-삭제-및-분리-보관">오래된 데이터 삭제 및 분리 보관</h3>
<p>데이터 개수가 늘어날 수록 쿼리 수행 시간은 증가한다. 대부분의 조회는 최신 데이터를 기준으로 발생하고 오래된 데이터를 사용하는 빈도는 줄어들기 때문에 일정 크기의 데이터 사이즈를 유지하며 오래된 데이터는 삭제(혹은 별도 테이블,데이터베이스 이동)하는 방식으로 조회 성능을 개선(일정 수준 유지)할 수 있다.</p>
<h3 id="그-외">그 외</h3>
<p>그 외 조회 성능을 개선하기 위한 방법은 DB 장비 확장(스케일), 별도 캐시 서버 구성 등이 있다.</p>
<hr>
<h2 id="몇-가지-주의-사항">몇 가지 주의 사항</h2>
<h3 id="쿼리-타임-아웃">쿼리 타임 아웃</h3>
<p>응답 시간은 처리량에 큰 영향을 준다. 동시 사용자가 증가할 때 응답 시간이 길어지면 처리량은 감소한다.</p>
<p>앞선 요청이 처리되지 않은 상태에서의 사용자 요청은 서버 부하를 더욱 가중시킨다. 사용자의 재시도가 반복되면 서버 부하는 폭증하게 된다.</p>
<p>이런 상황을 방지하는 방법 중 하나는 쿼리 실행 시간(쿼리 타임아웃)을 설정하는 것. 예를 들어 쿼리 실행 시간을 5초로 제한 할 경우 5초를 초과하는 쿼리에 대하여 타임아웃 에러를 발생시켜 사용자에게 빠르게 오류 응답을 전달할 수 있다.</p>
<p>결제와 같은 데이터 정합성이 중요한 요청의 경우 타임아웃 에러가 발생하면 후속 처리와 데이터 정합성에 대해 고려해야한다.</p>
<ul>
<li>적절한 타임아웃 길이를 결정해야한다.</li>
</ul>
<h3 id="상태-변경-기능은-복제-db에서-조회하지-않기">상태 변경 기능은 복제 DB에서 조회하지 않기</h3>
<p>primary DB - replica DB 구조를 사용하는 경우 데이터 변경은 primary, 데이터 조회는 replica를 사용한다.</p>
<p>하지만 데이터 변경을 위한 select 실행을 replica DB대상으로 실행하였을 경우 문제가 발생할 수 있다.</p>
<ul>
<li>변경 데이터의 전파 과정에서 일시적인 데이터 불일치 발생으로 인한</li>
</ul>
<p>때문에 데이터 변경 시 데이터 변경 대상 데이터(primary)를 조회하여 처리해야한다.</p>
<h3 id="배치-쿼리-실행-시간-증가">배치 쿼리 실행 시간 증가</h3>
<p>배치 프로그램은 데이터를 일괄로 조회, 집계, 생성하는 등의 작업을 수행한다. 데이터가 많아질 수록 일괄 처리 쿼리를 수행하는 시간도 함께 증가한다. 이런 문제를 예방하려면 배치에서 사용하는 쿼리 실행 시간을 지속적으로 추적해야할 필요가 있다. 만약 배치 쿼리에서 문제가 발생한다면 아래와 같은 방식으로 성능 최적화를 진행할 수 있다.</p>
<ul>
<li>커버링 인덱스 활용</li>
<li>데이터를 일정한 크기로 나눠서(chunk) 처리</li>
</ul>
<h3 id="타입이-다른-컬럼의-조인">타입이 다른 컬럼의 조인</h3>
<p>만약 join에 활용되는 컬럼의 타입이 테이블별로 상이하다면 데이터베이스는 두 테이블을 조인하는 과정에서 타입 변환을 추가적으로 수행한다.</p>
<p>만약 서로 다른 타입의 값을 조인에 활용해야한다면 <code>cast()</code> 등을 활용하여 불필요한 타입 변환을 줄일 수 있다.</p>
<h3 id="테이블-변경">테이블 변경</h3>
<p>데이터가 많은 테이블에 새로운 컬럼을 추가하거나, 기존 열거 타입을 변경할 때 데이터베이스는 변경된 새 테이블을 생성하고, 원본 테이블의 데이터를 복사하여 최종 대체하는 방식으로 테이블 변경 작업을 진행한다. 테이블 복사 과정에서 DML의 실행은 허용하지 않기 때문에 복사 시간만큼 서비스가 멈춘다.</p>
<p>만약 복사 시간이 길어진다면, 복사 시간만큼 서비스가 먹통이 되는 위험한 상황이 발생할 수 있어 테이블 변경은 항상 신중하게 진행해야한다.</p>
<h3 id="db-최대-연결-개수">DB 최대 연결 개수</h3>
<hr>
<h2 id="실패와-트랜잭션-고려">실패와 트랜잭션 고려</h2>
]]></description>
        </item>
        <item>
            <title><![CDATA[[주니어 백엔드 실무 지식] 2장.느려진 서비스, 어디서부터 봐야 할까 ]]></title>
            <link>https://velog.io/@jw_kim/%EC%A3%BC%EB%B0%B1%EB%B0%98%EC%8B%A4-2%EC%9E%A5.%EB%8A%90%EB%A0%A4%EC%A7%84-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%96%B4%EB%94%94%EC%84%9C%EB%B6%80%ED%84%B0-%EB%B4%90%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@jw_kim/%EC%A3%BC%EB%B0%B1%EB%B0%98%EC%8B%A4-2%EC%9E%A5.%EB%8A%90%EB%A0%A4%EC%A7%84-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%96%B4%EB%94%94%EC%84%9C%EB%B6%80%ED%84%B0-%EB%B4%90%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Thu, 01 Jan 2026 12:15:26 GMT</pubDate>
            <description><![CDATA[<h1 id="2장-느려진-서비스-어디서부터-봐야-할까">2장. 느려진 서비스, 어디서부터 봐야 할까</h1>
<blockquote>
<p>💡 사용자는 무언가를 실행했을 때 동작하기까지 걸리는 시간으로 성능을 판단하지만, 실제로는 다양한 지표가 성능과 관련되어있다.
이러한 다양한 지표 중에서 서버 성능과 관련있는 중요한 지표들에 대하여 알아보고 성능 개선을 위한 방안을 확인해볼 수 있어야한다.</p>
</blockquote>
<h3 id="응답-시간">응답 시간</h3>
<p><strong>‘응답 시간’</strong>이란 <strong>사용자의 요청을 처리하는 데 걸리는 시간</strong>을 의미한다.</p>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/39815ec6-6d23-4abe-936a-20e1b8fa6763/image.svg" alt=""></p>
<p><strong>응답 시간</strong>은 크게 <strong>API 요청 전송 시간, 서버의 처리 시간, API 응답 시간</strong>으로 나눌 수 있다. 이중 개발자가 주로 확인하게 되는<strong>서버의 처리 시간</strong>은 아래와 같이 구성되어있다.</p>
<ul>
<li><strong>로직 수행</strong></li>
<li><strong>DB 연동</strong></li>
<li><strong>외부 API 연동</strong></li>
<li><strong>응답 데이터 생성</strong></li>
</ul>
<p>등으로 구성되고 이중 가장 높은 수행 시간 비중을 차지하는 것은 일반적으로
<strong>외부 API 연동 &gt; DB 연동 &gt; 로직 수행 &gt; 응답 데이터 생성</strong> 순이다.</p>
<p>❓<strong>DB 시간과 애플리케이션 로직 시간을 구분해서 측정하려면 어떤 방식(로그/트레이싱/DB 슬로우쿼리)을 쓰나요?</strong></p>
<h3 id="처리량">처리량</h3>
<p>처리량은 단위 시간당 시스템이 처리하는 작업량을 의미, 흔히 TPS, RPS로 처리량을 나타낸다</p>
<ul>
<li><strong>TPS</strong>: 초당 트랜잭션 수</li>
<li><strong>RPS</strong>: 초당 요청 수</li>
</ul>
<p>최대 TPS는 시스템이 처리할 수 있는 최대 요청의 수를 의미한다. 최대 TPS를 초과하는 요청이 들어왔을 때, 서버는 초과한 요청을 나중에 처리한다. 이 과정에서 응답 시간이 증가하게 되고 사용자의 이탈로 이어질 수 있다.</p>
<p>처리량을 늘릴 수 있는 방법</p>
<ul>
<li>서버가 동시에 처리할 수 있는 요청 수를 늘려 대기 시간 줄이기</li>
<li>처리 시간 자체를 줄여 대기 시간 줄이기</li>
</ul>
<p>⭐ 성능 개선을 위해서는 우선적으로 현재 서버의 TPS와 응답 시간을 파악해야한다. 막연히 성능이 좋지 않다, 느리다라는 것 보다 </p>
<ul>
<li>트래픽이 많은 시간대의 TPS와 응답 시간 측정 결과</li>
</ul>
<p>등을 바탕으로 목표 TPS와 응답 시간을 설정하고 효과적인 개선안을 도출해야한다.</p>
<h3 id="병목-지점">병목 지점</h3>
<p>문제 지점을 찾는 간단한 방법은 처리 시간이 오래 걸리는 작업을 식별 하는것</p>
<ul>
<li>모니터링 도구를 이용</li>
<li>경험에 의한 추측</li>
<li>의심되는 코드에 로그를 기록</li>
</ul>
<p><strong>❓ 성능 저하가 발생했을 때 “가장 먼저 확인할 3가지”를 정해 순서대로 설명해보세요.</strong></p>
<p><strong>❓ CPU/메모리/디스크/네트워크/DB 중 어디가 병목인지 어떻게 판별하나요?</strong></p>
<p><strong>❓ APM 없이도 병목을 찾기 위해 어떤 로그(구간 측정, correlation id 등)를 설계하나요?</strong></p>
<p><strong>❓ “경험에 의한 추측”을 줄이기 위해 어떤 관측 가능성(Observability) 구성을 해두면 좋나요?</strong></p>
<h3 id="수직-확장과-수평-확장">수직 확장과 수평 확장</h3>
<p>성능 문제의 원인을 찾았다면 우선적으로 사용자가 서비스를 정상적으로 이용 가능한 상황을 만드는 것이 가장 중요. 이 때 수직 확장을 고려할 수 있음</p>
<p>‘수직 확장’이란 CPU, 메모리, 디스크 등 자원을 증가시키는 방법</p>
<ul>
<li>더 빠른 CPU 혹은 더 많은 코어수의 CPU로 교체</li>
<li>메모리 확장</li>
<li>HDD → SDD 변경</li>
</ul>
<p>이러한 작업만으로도 서버 자체 처리량이 증가하기 때문에 일시적으로 문제를 해결할 수 있음 (클라우드 환경에서는 비교적 빠르게 적용 가능) 
수직 확장은 즉각적인 효과를 얻을 수 있지만, 수직확장을 무한정 반복할 수 없음 (비용과 기술적인 한계)</p>
<p><strong>❓서버를 수평 확장했는데 TPS가 늘지 않는 다면 어떤 이유일지?</strong></p>
<p><strong>❓커넥션 풀을 키우면 무조건 성능이 좋아지나요? 오히려 나빠지는 케이스는?</strong></p>
<p>‘수평 확장’이란 자원을 추가하는 것이 아닌 서버 자체를 추가로 투입하는 방법</p>
<ul>
<li>인스턴스의 물리적인 수를 증가시켜 사용자 트래픽을 분산하여 TPS를 높이는 방식</li>
</ul>
<p>단, 이 경우 병목 지점을 파악하는 것이 중요. DB 수준에서 성능 문제가 발생할 경우 서바 인스턴스 수평 확장은 DB로의 부하를 더욱 가중시켜 TPS가 향상되지 않을 뿐더러 추가적인 성능 저하가 발생할 수 있음.</p>
<p>또한, 물리적 인스턴스의 수를 증가시키기 때문에 트래픽을 적절하게 분산시킬 수 있는 방법(로드밸런스 등)을 추가적으로 고려해야함</p>
<h3 id="db-커넥션-풀">DB 커넥션 풀</h3>
<p>서버는 DB와 통신을 위해 네트워크 연결을 생성하고 반납하는 작업을 수행한다. 이 작업에는 0.5ms 이상 시간이 소요되기 때문에 매 요청마다 연결을 새로 생성하여 사용하면 응답 시간이 길어지고 성능에 영향을 줄 수 있다.</p>
<p>때문에 애플리케이션 수준에서 DB 커넥션을 미리 생성하여 보관하고 요청이 필요할 때 이 커넥션을 사용하여 빠르게 DB 로직을 수행할 수 있다. 이를 <strong>‘커넥션풀’</strong>이라 한다.</p>
<ul>
<li>커넥션 풀의 크기(최소, 최대)</li>
<li>커넥션을 구할 때까지 대기할 시간</li>
<li>커넥션 유지 시간</li>
</ul>
<p>등을 관리하여 DB 질의를 보다 효과적으로 처리할 수 있다.</p>
<p><strong>❓ 커넥션 풀을 왜 쓰는지, “매 요청마다 커넥션 생성”이 왜 비싼지 설명해보세요.</strong></p>
<p><strong>❓ 커넥션 풀이 부족할 때 애플리케이션에서 어떤 증상(스레드 대기, 타임아웃)이 나타나나요?</strong></p>
<p><strong>❓커넥션 풀을 키우면 무조건 성능이 좋아지나요? 오히려 나빠지는 케이스는?</strong></p>
<h3 id="커넥션-풀의-크기">커넥션 풀의 크기</h3>
<p>전체 응답 시간과 TPS를 고려하여 적절한 커넥션 풀의 크기를 설정하는 것이 중요</p>
<p>커넥션풀에 유효 커넥션이 존재하지 않을 경우, 이후 사용자의 요청은 커넥션 풀이 반납될 때까지 대기하게된다.</p>
<h3 id="커넥션-대기-시간">커넥션 대기 시간</h3>
<p>유효한 커넥션이 존재하지 않을 경우, 커넥션 획득을 위한 대기 시간을 설정할 수 있다.(Hikari 기본 대기시간 30초). 하지만 커넥션 획득을 위해 장시간 대기할 경우 사용자 입장에서 응답 없는 상황이라 받아들일 수 있으므로 적절한 대기 시간을 설정하고, 커넥션 획득이 어려울 경우 빠른 오류 응답이 더 적절할 수도있음.</p>
<p><strong>❓성능 저하 API를 개선하기 위해서는 어떤 지표들의 확인이 필요한가</strong></p>
<p><strong>❓평소 잘 동작하던 API 서버에 성능 저하가 발생하였을 경우 어떤 순서로 성능 저하 원인을 파악할것인가</strong></p>
<hr>
<h3 id="서버-캐시">서버 캐시</h3>
<ul>
<li>서버의 확장은 많은 비용이 발생한다.</li>
<li>DB 서버 수평적 확장은 (처리량은 늘어나지만) 실행 시간이 획기적으로 줄어들지 않을 수 있다.</li>
</ul>
<p>만약 비용적인 한계가 있는 상황에서 응답 시간과 처리량을 개선하기 위해서는 <strong>‘캐시(cache)’</strong> 도입을 고려해볼 수있다.</p>
<p>캐시는 (키, 값) 쌍을 저장하는 Map 형태의 데이터 저장소이다. 스토리지에서 데이터를 읽어오는 DB와는 달리 캐시 저장소는 메모리에 적재되어있는 데이터를 key 기반으로 읽어오기 때문에 읽는 속도가 DB에 비해 빠르다.</p>
<p><strong>❓ 비용적인 제한으로 서버 혹은 DB 인스턴스의 스케일 확장이 불가능 할 경우 성능 개선을 위한 방법이 있는지</strong> </p>
<p><strong>❓ 캐시 도입이 “응답 시간”과 “처리량”에 각각 어떤 영향을 주나요?</strong></p>
<p><strong>❓캐시를 도입했는데도 효과가 없는 대표적인 원인(적중률 낮음, 키 계 문제 등)은?</strong></p>
<p><strong>❓캐시가 오히려 장애를 유발하는 케이스(캐시 스탬피드, 핫키)는 무엇이고 어떻게 막나요?</strong></p>
<h3 id="적중률과-삭제-규칙">적중률과 삭제 규칙</h3>
<p>캐시의 효율성을 판단하기 위해 적중률(Hit rate)을 참고할 수 있다.</p>
<ul>
<li>적중률 = 캐시에 존재한 건수 / 캐시에서 조회를 시도한 건수</li>
</ul>
<p>캐시 적중률이 높을 수록 사용자 요청이 DB에서 조회되지 않고 캐시 데이터로 응답을 많이 했다는 의미. 이는 곧 DB 부하 감소</p>
<p>캐시는 메모리 공간을 사용하기 때문에 데이터를 무한정 저장할 수 없다. 때문에 조회 빈도수가 높거나, 빠른 응답 성능이 필요한 적절한 대상 API를 선정하는 것이 중요</p>
<p><strong>❓ 적중률을 어떤 기준으로 “좋다/나쁘다” 판단하나요? 시스템/도메인에 따라 다른가요?</strong></p>
<p><strong>❓ 캐시 용량이 제한될 때 “무엇을 캐시해야 하는지” 우선순위를 정하는 기준은?</strong></p>
<h3 id="로컬-캐시와-리모트-캐시">로컬 캐시와 리모트 캐시</h3>
<p>서버가 사용하는 캐시에는 두 종류가 있다.</p>
<ul>
<li>로컬 캐시: 서버 프로세스와 동일한 메모리 저장소를 공유하며, 캐시 저장소로 사용</li>
<li>리모트 캐시: 서버 프로세스와는 다른 별도의 프로세스를 캐시 저장소로 사용</li>
</ul>
<p>로컬 캐시는 구현이 간단하고 외부 연동이 필요하지 않다는 장점이 있으나, 서버 프로세스와 자원을 공유하기 때문에 저장할 수 있는 데이터에 한계가 있음, 또한 서버 프로세스를 재시작 할 경우 캐시 데이터가 초기화되는 문제</p>
<p>리모트 캐시의 경우 캐시 크기를 유연하게 확장할 수 있으며, 서버 프로세스가 재시작되더라도 캐시 데이터는 유지된다. 다만 주가적인 인프라 도입이 필요하고 서버와 연동을 위한 기능을 추가적으로 작성해야한다.</p>
<ul>
<li>현재 상황에서 캐시 대상 데이터들의 성질을 파악하고 적절한 형태의 캐시 저장소를 도입해야한다.</li>
</ul>
<p>트래픽이 순간적으로 급증하는 패턴에 대하여 캐시 데이터를 사전에 적재하는 전략도 고려할 수 있음.</p>
<p><strong>❓ 로컬 캐시와 리모트 캐시를 각각 선택해야 하는 상황을 예시로 설명해보세요.</strong></p>
<p><strong>❓ 수평 확장 환경에서 로컬 캐시를 쓸 때 일관성 문제를 어떻게 다루나요?</strong></p>
<p><strong>❓ 리모트 캐시(예: Redis)를 도입했을 때 새로 생기는 운영/장애 포인트는?</strong></p>
<p><strong>❓로컬 캐시 도입을 결정하기 위해 고려한 사항들은 무엇인지, 리모트 캐시를 도입하지 않은 이유는?</strong></p>
<h3 id="캐시-무효화">캐시 무효화</h3>
<p>캐시를 사용할 경우 유효하지 않은 데이터에 대한 캐시 무효화(삭제) 전략을 반드시 고려해야한다.</p>
<p>데이터의 원본이 수정된 상황에서 캐시 데이터가 갱신되거나 삭제되지 않을 경우 사용자 입장에서는 변경되기 이전의 데이터를 확인하기 때문에 심각한 문제가 발생할 수 있다.</p>
<p><strong>❓ 캐시 무효화 전략 3가지(Write-through/Write-around/Cache-aside 등)를 비교해보세요.</strong></p>
<p><strong>❓데이터 변경이 잦은 도메인에서 캐시 일관성을 보장하려면 어떤 패턴(이벤트 기반 무효화 등)을 쓰나요?</strong></p>
<h3 id="그-외-서버-성능-향상-방법">그 외 서버 성능 향상 방법</h3>
<p>그 외 서버의 성능을 향상시킬 수 있는 방법으로는 </p>
<ul>
<li>가비지 컬렉터 튜닝</li>
<li>대용량 처리를 위한 스트림 활용(메모리 활용)</li>
<li>응답 데이터를 압축하여 서빙</li>
<li>브라우져 캐시 활용 또는 CDN 도입</li>
<li>대기열 도입</li>
</ul>
<p>등이 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Cloud로 구현하는 MSA 인프라 아키텍처 - (1) Service Registry & Discovery]]></title>
            <link>https://velog.io/@jw_kim/Spring-Cloud%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94-MSA-%EC%9D%B8%ED%94%84%EB%9D%BC-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-1-Service-Registry-Discovery</link>
            <guid>https://velog.io/@jw_kim/Spring-Cloud%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94-MSA-%EC%9D%B8%ED%94%84%EB%9D%BC-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-1-Service-Registry-Discovery</guid>
            <pubDate>Wed, 03 Dec 2025 18:13:21 GMT</pubDate>
            <description><![CDATA[<h3 id="1-왜-service-registry가-필요한가">1. 왜 Service Registry가 필요한가?</h3>
<p>모놀리식 아키텍처에서는 하나의 애플리케이션이 단일 배포 단위로 묶여 있다.
애플리케이션 프로세스가 하나고, 보통 고정된 포트에서 동작하기 때문에 “서비스 위치”를 관리하는 것은 어렵지 않다.</p>
<p>[단일 애플리케이션]  →  <a href="http://app.example.com:8080">http://app.example.com:8080</a></p>
<p>반면 <strong>MSA(Microservices Architecture)</strong>에서는 도메인 중심으로 여러 개의 독립적인 서비스가 분리되고, 각 서비스가 각각의 배포 단위를 가진다. 여기에 클라우드 컴푸팅 기술이 더해지면 다음과 같은 특징이 생긴다.</p>
<ul>
<li><p>서비스 인스턴스 수가 동적으로 증가하거나 감소</p>
</li>
<li><p>각 인스턴스의 IP / Port가 고정되어 있지 않음</p>
</li>
<li><p>배포·롤링 업데이트마다 인스턴스 구성이 지속적으로 변경</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/bdcf06a3-f567-4cd0-9316-668e73f5630f/image.png" alt=""></p>
<p>규모가 작은 환경에서는 환경 변수나 설정 파일에 서비스 URL을 직접 적어 두는 방식으로 어느 정도 버틸 수 있다.
하지만 실제 아마존이나 넷플릭스처럼 수백~수천 개의 마이크로서비스를 운영하는 환경에서는</p>
<p><code>“모든 서비스 인스턴스의 IP/Port를 사람이 직접 관리한다”</code> 는 것은 사실상 불가능하다.</p>
<p>이런 배경 때문에 등장한 것이 Service Registry / Service Discovery 패턴이다.</p>
<hr>
<h3 id="2-service-registry--service-discovery-패턴-정리">2. Service Registry &amp; Service Discovery 패턴 정리</h3>
<h4 id="21-service-registry-서버-레지스트리">2.1 Service Registry (서버 레지스트리)</h4>
<p><strong>Service Registry(서비스 레지스트리)</strong>는 마이크로서비스 인스턴스의 </p>
<ul>
<li>IP / Port</li>
<li>서비스 이름</li>
<li>메타데이터(환경, 버전, zone 등) </li>
</ul>
<p>를 중앙에서 저장하고, 각 인스턴스를 서비스 이름으로 식별할 수 있도록 매핑 정보를 관리하는 컴포넌트다.</p>
<p>간단히 말해:</p>
<pre><code>“catalog-service라는 이름으로 어떤 인스턴스들이 어디에서 떠 있는지”
를 알고 있는 전화번호부 역할을 한다.</code></pre><h4 id="22-service-discovery-서버-디스커버리">2.2 Service Discovery (서버 디스커버리)</h4>
<p><strong>Service Discovery(서비스 디스커버리)</strong>는 클라이언트가</p>
<pre><code>“order-service야, catalog-service로 요청 하나 보내줘.”</code></pre><p>라고 서비스 이름만으로 요청을 보냈을 때,</p>
<ul>
<li><p>Service Registry에서 실제 인스턴스 목록을 조회하고</p>
</li>
<li><p>헬스 체크 / 로드밸런싱 정책을 적용해</p>
</li>
<li><p>어느 인스턴스로 보낼지 결정하는 메커니즘을 의미한다.</p>
</li>
</ul>
<p>즉, 클라이언트 입장에서는 더 이상 <code>http://10.0.1.23:8080</code> 같은 구체적인 주소를 알 필요가 없고,  <code>“catalog-service에게 보내라”</code> 는 논리적인 이름만 알고 있으면 된다.</p>
<hr>
<h3 id="3-spring-eureka-소개">3. Spring Eureka 소개</h3>
<p>넷플릭스는 자사 인프라에서 이 문제를 해결하기 위해 Eureka라는 이름의 Service Registry &amp; Service Discovery 서버를 개발해 사용해 왔다.</p>
<p>이후 Spring 진영에서는 이를 손쉽게 사용할 수 있도록 Spring Cloud Netflix 프로젝트에 통합(2015.03)했고, Spring Boot 애플리케이션에서도 Eureka 기반의 서비스 등록·조회 기능을 쉽게 사용할 수 있게 되었다.</p>
<p>Eureka는 크게 두 가지 역할로 나뉜다.</p>
<ul>
<li><p><strong>Eureka Server</strong></p>
<ul>
<li><p>Service Registry 역할</p>
</li>
<li><p>서비스 인스턴스 정보(IP, Port, 상태 등)를 저장하고 관리</p>
</li>
</ul>
</li>
<li><p><strong>Eureka Client</strong></p>
<ul>
<li><p>실제 비즈니스 로직을 수행하는 각 마이크로서비스</p>
</li>
<li><p>기동 시 자신을 Eureka Server에 등록하고,  다른 서비스 위치가 필요할 때 Eureka로부터 조회</p>
</li>
</ul>
</li>
</ul>
<p>Spring Boot에서의 의존성은 대략 다음과 같이 추가한다.</p>
<pre><code>// Eureka Server
implementation &#39;org.springframework.cloud:spring-cloud-starter-netflix-eureka-server&#39;

// Eureka Client
implementation &#39;org.springframework.cloud:spring-cloud-starter-netflix-eureka-client&#39;</code></pre><hr>
<h3 id="4-eureka-server-기본-구성">4. Eureka Server 기본 구성</h3>
<h4 id="41-서버-애플리케이션-만들기">4.1 서버 애플리케이션 만들기</h4>
<p>먼저 Eureka Server로 동작할 Spring Boot 애플리케이션을 하나 만든다.
여기에 @EnableEurekaServer 애노테이션을 추가하면 된다.</p>
<pre><code class="language-java">@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}</code></pre>
<p>이제 설정 파일(application.yml)에서 서버 포트와 기본 설정을 잡아준다.</p>
<pre><code class="language-yaml">server:
  port: 8761

spring:
  application:
    name: eureka-server</code></pre>
<h4 id="42-단일-eureka-server로-동작시키기">4.2 단일 Eureka Server로 동작시키기</h4>
<p>Eureka Server는 내부적으로도 Eureka Client 역할을 수행한다. 즉, 다른 Eureka Server에 자신을 등록하거나, 레지스트리를 조회할 수 있다.</p>
<p>하지만 단일 레지스트리 서버로만 사용할 때는 자기 자신을 다시 Eureka에 등록할 필요가 없기 때문에 Client 기능을 끄는 것이 일반적이다.</p>
<pre><code class="language-yaml">eureka:
  client:
    # 서버 자신을 Eureka에 등록하지 않음
    register-with-eureka: false
    # 다른 레지스트리로부터 레지스트리를 가져오지 않음
    fetch-registry: false</code></pre>
<p><code>register-with-eureka: false</code></p>
<ul>
<li>“나는 다른 Eureka Server에 등록하지 않겠다”</li>
</ul>
<p><code>fetch-registry: false</code></p>
<ul>
<li>“다른 Eureka Server로부터 레지스트리를 가져오지 않겠다”</li>
</ul>
<hr>
<h3 id="5-eureka-server가-client-역할을-하는-이유">5. Eureka Server가 Client 역할을 하는 이유</h3>
<p>그렇다면 왜 Eureka Server도 Client 역할을 할 수 있도록 설계되어 있을까?</p>
<p>고가용성(HA)이 필요한 환경에서는 Eureka Server 역시 다중 인스턴스로 구성하고, 각 서버가 서로를 peer로 인식해서 레지스트리를 복제한다.</p>
<p>서버가 1대만 있으면 장애 시 전체 시스템에 큰 영향을 미친다. 
서버를 2대 이상 두고 서로 레지스트리를 동기화하면,</p>
<p>한 대가 장애가 나더라도 나머지 서버가 계속 Service Registry 역할을 수행할 수 있다.</p>
<p>이를 위해 Eureka Server도 Eureka Client로서 동작하면서 서로를 service-url.defaultZone에 등록하는 구조를 사용한다.</p>
<hr>
<h3 id="6-자주-사용하는-eureka-server-설정">6. 자주 사용하는 Eureka Server 설정</h3>
<h4 id="61-인스턴스헬스-체크-관련-설정-eurekainstance">6.1 인스턴스/헬스 체크 관련 설정 (eureka.instance)</h4>
<p>Eureka Server 자체도 “하나의 서비스 인스턴스”이기 때문에,
다른 서비스들과 마찬가지로 eureka.instance 설정을 사용할 수 있다.</p>
<pre><code class="language-yaml">eureka:
  instance:
    hostname: eureka-server-1           # 실제 환경에 맞는 호스트/DNS
    # prefer-ip-address: true           # 필요 시 IP 기반으로 등록
    health-check-url-path: /actuator/health
    status-page-url-path: /actuator/info
    lease-renewal-interval-in-seconds: 30
    lease-expiration-duration-in-seconds: 90</code></pre>
<ul>
<li><p><code>hostname</code></p>
<ul>
<li><p>Eureka에 등록될 때 사용할 호스트 이름</p>
</li>
<li><p>실제로는 보통 클라이언트 서비스에서 도메인 기반 등록을 할 때 더 많이 활용</p>
</li>
</ul>
</li>
<li><p><code>health-check-url-path</code></p>
<ul>
<li><p>Eureka가 인스턴스 상태를 확인하기 위해 호출하는 URL</p>
</li>
<li><p>Spring Boot Actuator를 사용하면 /actuator/health로 지정하는 것이 일반적</p>
</li>
</ul>
</li>
<li><p><code>status-page-url-path</code></p>
<ul>
<li>상태 페이지 URL (Eureka 대시보드에서 링크로 노출)</li>
</ul>
</li>
<li><p><code>lease-renewal-interval-in-seconds</code></p>
<ul>
<li><p>클라이언트가 Eureka에 <strong>하트비트(heartbeat)</strong>를 보내는 주기</p>
</li>
<li><p>기본 30초 수준. 너무 짧게 줄이면 트래픽 부담 증가</p>
</li>
</ul>
</li>
<li><p><code>lease-expiration-duration-in-seconds</code></p>
<ul>
<li>이 시간 동안 하트비트가 오지 않으면, Eureka가 해당 인스턴스를 죽었다고 판단하는 시간</li>
</ul>
</li>
</ul>
<h4 id="62-자기-보호-모드-설정-eurekaserver">6.2 자기 보호 모드 설정 (eureka.server)</h4>
<p>Eureka Server는 네트워크 이슈 등으로 인해 하트비트가 갑자기 줄어드는 경우에도,
정상 인스턴스를 함부로 레지스트리에서 제거하지 않도록 자기 보호 모드(Self-Preservation)를 제공한다.</p>
<pre><code class="language-yaml">eureka:
  server:
    enable-self-preservation: true
    renewal-percent-threshold: 0.85
    eviction-interval-timer-in-ms: 60000</code></pre>
<ul>
<li><p><code>enable-self-preservation</code></p>
<ul>
<li><p>기본값은 true이며, 자기 보호 모드를 활성화한다.</p>
</li>
<li><p>네트워크 장애 등으로 하트비트가 일시적으로 급감하더라도 일정 기준까지는 인스턴스를 바로 제거하지 않는다.</p>
</li>
<li><p>운영 환경에서는 보통 true 유지가 일반적이고, 개발 환경에서는 디버깅을 위해 false로 두기도 한다.</p>
</li>
</ul>
</li>
<li><p><code>renewal-percent-threshold</code></p>
<ul>
<li>예상되는 정상 하트비트 대비, 어느 정도 비율 이상이 유지되어야 “정상”으로 볼 것인지 기준</li>
</ul>
</li>
<li><p><code>eviction-interval-timer-in-ms</code></p>
<ul>
<li>만료된 인스턴스를 레지스트리에서 실제로 제거하는 주기 (기본 60초 수준)</li>
</ul>
</li>
</ul>
<p>개발 단계에서 인스턴스를 자주 재기동하면, “이미 죽은 인스턴스가 대시보드에서 한동안 남아 있는” 현상을 볼 수 있는데, 대부분 이 <code>self-preservation</code>과 <code>eviction</code> 주기 설정 때문에 발생한다.</p>
<hr>
<h3 id="7-eureka-server-다중-인스턴스peer-구성-예시">7. Eureka Server 다중 인스턴스(peer) 구성 예시</h3>
<p>Eureka Server를 2대로 구성하는 예시를 보자.
각 인스턴스는 서로를 peer로 등록해 레지스트리를 복제한다.</p>
<h4 id="71-eureka-server-1-설정">7.1 eureka-server-1 설정</h4>
<pre><code class="language-yaml">spring:
  application:
    name: eureka-server

server:
  port: 8761

eureka:
  instance:
    hostname: eureka-server-1
    lease-renewal-interval-in-seconds: 30
    lease-expiration-duration-in-seconds: 90

  client:
    # peer 구성이므로 두 옵션 모두 true
    register-with-eureka: true
    fetch-registry: true
    service-url:
      # &quot;내가 등록/조회할 대상&quot; → 반대편 서버 주소
      defaultZone: http://eureka-server-2:8762/eureka/

  server:
    enable-self-preservation: true
    eviction-interval-timer-in-ms: 60000</code></pre>
<h4 id="72-eureka-server-2-설정">7.2 eureka-server-2 설정</h4>
<pre><code class="language-yaml">spring:
  application:
    name: eureka-server

server:
  port: 8762  # 1번과 다른 포트 (LB 앞에 두면 동일 포트도 가능)

eureka:
  instance:
    hostname: eureka-server-2
    lease-renewal-interval-in-seconds: 30
    lease-expiration-duration-in-seconds: 90

  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      # 이 서버는 1번 서버를 peer로 본다
      defaultZone: http://eureka-server-1:8761/eureka/

  server:
    enable-self-preservation: true
    eviction-interval-timer-in-ms: 60000</code></pre>
<p><strong>두 설정의 핵심은 다음 한 줄이다.</strong></p>
<pre><code class="language-yaml">eureka.client.service-url.defaultZone</code></pre>
<ul>
<li><p>“이 인스턴스가 등록/조회할 Eureka Server는 어디인가?” 를 지정한다.</p>
</li>
<li><p>peer 구성에서는 서로의 주소를 defaultZone에 등록하여 레지스트리 정보를 양방향으로 동기화한다.</p>
</li>
</ul>
<hr>
<h3 id="8-정리">8. 정리</h3>
<p>여기까지 정리한 내용을 다시 한 번 요약해보면:</p>
<ul>
<li><p>MSA &amp; 클라우드 환경에서는 서비스 인스턴스의 IP/Port가 동적으로 변하기 때문에 사람이나 설정 파일만으로는 서비스 위치 관리가 불가능에 가깝다.</p>
</li>
<li><p>이를 해결하기 위해 Service Registry / Service Discovery 패턴이 등장했고, 넷플릭스는 이를 구현한 Eureka를 운영해 왔다.</p>
</li>
<li><p>Spring에서는 Spring Cloud Netflix Eureka를 통해 Spring Boot 애플리케이션에서도 손쉽게 Eureka 기반 서비스 등록/조회 기능을 제공한다.</p>
</li>
<li><p>Eureka는 </p>
<ul>
<li>Eureka Server: 레지스트리 관리, 서비스 등록/조회</li>
<li>Eureka Client: 실제 비즈니스 서비스, 자신 등록 + 다른 서비스 조회
로 역할이 나뉜다.</li>
</ul>
</li>
<li><p>Eureka Server는</p>
<ul>
<li>단일 서버 모드에서는 register-with-eureka=false, fetch-registry=false</li>
<li>다중 서버(HA) 구성에서는 서로를 peer로 등록하여 레지스트리를 복제한다.</li>
</ul>
</li>
<li><p>실제 운영 환경에서는</p>
<ul>
<li>health-check-url-path, lease-interval, self-preservation 등
몇 가지 핵심 옵션을 이해하고 적절히 튜닝하는 것이 중요하다.</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] mysqldump를 활용한 MySQL DB 백업과 복원 방법 01]]></title>
            <link>https://velog.io/@jw_kim/MySQL-mysqldump%EB%A1%9C-DB-%EB%B0%B1%EC%97%85%ED%95%98%EA%B8%B0-1</link>
            <guid>https://velog.io/@jw_kim/MySQL-mysqldump%EB%A1%9C-DB-%EB%B0%B1%EC%97%85%ED%95%98%EA%B8%B0-1</guid>
            <pubDate>Tue, 08 Jul 2025 04:04:40 GMT</pubDate>
            <description><![CDATA[<h2 id="💡-왜-데이터베이스-백업이-중요한가">💡 왜 데이터베이스 백업이 중요한가?</h2>
<p>데이터베이스는 대부분의 서비스에서 핵심 자산이며, 사용자 정보, 주문 내역, 결제 기록 등 손실 시 복구가 불가능하거나 심각한 비즈니스 피해를 야기할 수 있는 정보를 담고 있습니다. 하드웨어 장애, 실수로 인한 데이터 삭제, 랜섬웨어 공격, 소프트웨어 버그 등은 언제든 발생할 수 있으며, 이때 정기적인 백업 없이는 서비스를 정상 상태로 복구하기 어렵습니다. 백업은 단순한 보안책이 아니라, 서비스의 신뢰성과 연속성을 보장하는 가장 기본적인 안전망입니다.</p>
<p>이번 포스트에서는 MySQL에서 제공하는 <code>mysqldump</code>를 활용하여 MySQL DB를 백업하고 복원하는 방법에 대해 알아보겠습니다.</p>
<h2 id="📌-데이터베이스-백업의-종류">📌 데이터베이스 백업의 종류</h2>
<p>데이터베이스 백업은 목적과 방식에 따라 다음과 같이 나뉩니다:</p>
<table>
<thead>
<tr>
<th>백업 유형</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>전체 백업</strong></td>
<td>데이터베이스 전체를 백업. 복원이 가장 간단하나, 백업 시간이 오래 걸릴 수 있음.</td>
</tr>
<tr>
<td><strong>차등 백업</strong></td>
<td>마지막 전체 백업 이후 변경된 데이터만 백업. 전체 백업과 함께 사용됨.</td>
</tr>
<tr>
<td><strong>증분 백업</strong></td>
<td>마지막 백업(전체 또는 증분) 이후 변경된 데이터만 백업. 저장 공간 효율적이지만 복원 복잡도가 높음.</td>
</tr>
<tr>
<td><strong>논리 백업</strong></td>
<td>SQL 쿼리 형식으로 덤프 (<code>mysqldump</code>, <code>pg_dump</code> 등). 이식성과 가독성이 좋음.</td>
</tr>
<tr>
<td><strong>물리 백업</strong></td>
<td>실제 데이터 파일을 복사 (<code>xtrabackup</code>, <code>LVM snapshot</code>, 디스크 복사 등). 대용량에 적합하며 성능 손실이 적음.</td>
</tr>
</tbody></table>
<hr>
<h2 id="1mysqldump로-백업하기">1.<code>mysqldump</code>로 백업하기</h2>
<h3 id="1-전체-db-백업">(1) 전체 DB 백업</h3>
<pre><code class="language-bash">mysqldump -u [사용자명] -p --all-databases &gt; all-databases.sql
</code></pre>
<h3 id="2-특정-db만-백업">(2) 특정 DB만 백업</h3>
<pre><code class="language-bash">mysqldump -u [사용자명] -p [데이터베이스명] &gt; database.sql
</code></pre>
<h3 id="3-특정-테이블만-백업">(3) 특정 테이블만 백업</h3>
<pre><code class="language-bash">mysqldump -u [사용자명] -p [데이터베이스명] table1 table2 &gt; partial.sql
</code></pre>
<h3 id="4-자주-사용하는-옵션">(4) 자주 사용하는 옵션</h3>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>--single-transaction</code></td>
<td>InnoDB 사용 시 전체 일관된 상태로 백업</td>
</tr>
<tr>
<td><code>--quick</code></td>
<td>메모리 절약을 위해 row 단위로 읽기</td>
</tr>
<tr>
<td><code>--routines</code></td>
<td>저장 프로시저 포함</td>
</tr>
<tr>
<td><code>--triggers</code></td>
<td>트리거 포함 (기본값: 포함됨)</td>
</tr>
<tr>
<td><code>--set-gtid-purged=OFF</code></td>
<td>GTID 복제 환경에서 권장</td>
</tr>
</tbody></table>
<h3 id="예시">예시</h3>
<pre><code class="language-bash">mysqldump -u root -p --single-transaction --quick --routines mydb &gt; mydb.sql
</code></pre>
<hr>
<h2 id="2-백업-파일-압축하기">2. 백업 파일 압축하기</h2>
<p>백업 파일은 용량이 크기 때문에 압축하는 것이 일반적입니다.</p>
<pre><code class="language-bash">gzip mydb.sql
# 결과: mydb.sql.gz
</code></pre>
<p>복원 전에는 압축을 해제해야 합니다.</p>
<pre><code class="language-bash">gunzip mydb.sql.gz
</code></pre>
<hr>
<h2 id="3-mysqldump로-복원하기">3. <code>mysqldump</code>로 복원하기</h2>
<p>복원은 단순히 <code>mysql</code> 클라이언트를 통해 백업 SQL 파일을 실행하면 됩니다.</p>
<pre><code class="language-bash">mysql -u [사용자명] -p [대상DB명] &lt; mydb.sql
</code></pre>
<p><strong>주의:</strong> 복원 전에 DB가 생성되어 있어야 합니다. 없다면 먼저 생성합니다.</p>
<pre><code class="language-sql">CREATE DATABASE mydb;
</code></pre>
<hr>
<h2 id="마무리">마무리</h2>
<p>mysqldump의 강력한 기능 덕분에 데이터베이스의 백업과 복원을 쉽고 빠르게 할 수 있게되었습니다. 다만, mysqldump로 생성된 백업 파일들이 서비스와 동일한 서버에 보관될 경우, 서버에 문제가 발생 시 복원에 필요한 파일에도 접근이 불가능한 문제가 발생할 수 있습니다. 때문에 NAS나 S3같은 별도의 저장소에 백업 파일을 보관하는 것이 일반적입니다.
다음 포스트에서는 AWS CLI를 활용하여 S3(NCP Object Storage) 저장소에 백업 파일을 전송하는 방법을 알아보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[기타] 불변 객체와 가변객체: 불변 객체를 선호해야하는 이유]]></title>
            <link>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%B6%88%EB%B3%80-%EA%B0%9D%EC%B2%B4%EC%99%80-%EA%B0%80%EB%B3%80%EA%B0%9D%EC%B2%B4-%EB%B6%88%EB%B3%80-%EA%B0%9D%EC%B2%B4%EB%A5%BC-%EC%84%A0%ED%98%B8%ED%95%B4%EC%95%BC%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%B6%88%EB%B3%80-%EA%B0%9D%EC%B2%B4%EC%99%80-%EA%B0%80%EB%B3%80%EA%B0%9D%EC%B2%B4-%EB%B6%88%EB%B3%80-%EA%B0%9D%EC%B2%B4%EB%A5%BC-%EC%84%A0%ED%98%B8%ED%95%B4%EC%95%BC%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Sun, 29 Jun 2025 12:49:54 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 많은 고수 개발자 분들이 불변 객체 사용의 중요성에 대해 이야기하고있습니다. 소프트웨어 개발에서 매우 중요하지만 때로는 간과될 수 있는 개념인 <strong>불변 객체(Immutable Object)</strong>와 <strong>가변 객체(Mutable Object)</strong>에 대해 조금 더 깊이 알아보고 왜 가변 객체의 사용보다 불변 객체 사용을 지향해야 하는지 그 이유를 알아보겠습니다.</p>
</blockquote>
<h3 id="1-불변-객체와-가변-객체-그-정의부터">1. 불변 객체와 가변 객체, 그 정의부터!</h3>
<p>우선 두 개념을 명확히 정의해보곘습니다.</p>
<ul>
<li><strong>가변 객체(Mutable Object)</strong>: 이름 그대로 값이 변할 수 있는 객체를 의미합니다. 객체가 생성된 후에도 외부에서 해당 객체의 상태(필드 값)를 변경할 수 있습니다. 대부분의 언어에서 기본적으로 제공하는 List, Map 같은 컬렉션 타입이 대표적인 가변 객체입니다.</li>
</ul>
<pre><code class="language-java">// Java 예시
List&lt;String&gt; mutableList = new ArrayList&lt;&gt;();
mutableList.add(&quot;Apple&quot;); // 객체의 상태 변경
mutableList.add(&quot;Banana&quot;);</code></pre>
<ul>
<li><strong>불변 객체(Immutable Object)</strong>: 가변 객체와 달리 한번 생성되면 그 상태를 절대 변경할 수 없는 객체를 의미합니다. 객체가 생성될 때 모든 값이 할당되며, 이후에는 어떤 메서드를 통해서도 내부 상태를 수정할 수 없습니다. 값을 변경해야 할 때는 기존 객체의 값을 복사한 새로운 불변 객체를 생성하여 반환하는 방식으로 동작합니다. 자바의 String 클래스나 Integer 같은 래퍼 클래스들이 대표적인 불변 객체입니다.</li>
</ul>
<pre><code class="language-java">// Java 예시
String originalString = &quot;Hello&quot;;
String newString = originalString.concat(&quot; World&quot;); // 새로운 String 객체 생성
// originalString은 여전히 &quot;Hello&quot; 입니다.</code></pre>
<hr>
<h3 id="2-왜-가변-객체보다-불변-객체를-선호해야-할까">2. 왜 가변 객체보다 불변 객체를 선호해야 할까?</h3>
<p>이제 핵심 질문입니다. 왜 많은 전문가들이 불변 객체 사용을 권장할까요? 특히 현대의 복잡한 소프트웨어 환경에서는 불변 객체의 장점이 더욱 두드러집니다.</p>
<h4 id="1-예측-가능성-및-이해-용이성-코드를-읽는-즐거움">1) 예측 가능성 및 이해 용이성: 코드를 읽는 즐거움</h4>
<p>가변 객체는 언제든 상태가 변할 수 있으므로, 해당 객체를 사용하는 코드 전체에서 그 상태 변화를 예측하고 추적하기 어렵습니다. 특히 함수나 메서드를 호출할 때 원본 객체가 변경될 수 있다면, 개발자는 항상 <strong>부수 효과(Side Effect)</strong>를 염두에 두어야 합니다.</p>
<p>반면 불변 객체는 한 번 생성되면 상태가 변하지 않으므로, 객체의 동작을 예측하기 매우 쉽습니다. <strong>특정 시점에 객체가 어떤 상태를 가지고 있는지 명확</strong>하게 알 수 있어, 코드를 이해하고 디버깅하는 데 드는 많은 공수를 크게 줄여줍니다. 이는 클린 아키텍처에서 강조하는 <strong>계층 간의 명확한 역할 분리</strong>와도 맞닿아 있습니다.</p>
<h4 id="2-⭐️-스레드-안정성thread-safety-멀티스레드-환경의-구세주">2) ⭐️ 스레드 안정성(Thread Safety): 멀티스레드 환경의 구세주</h4>
<p>멀티스레드 환경은 현대 소프트웨어 개발의 필수 요소입니다. Spring Applicaion의 경우 톰캣 기반으로 구동하기 때문에 기본적으로는 멀티 스레드 환경에서 구동되는 소프트웨어라고 할 수 있습니다. <strong>여러 활성 상태의 스레드가 동일한 가변 객체에 동시에 접근하여 상태를 변경</strong>하는 경우를 생각해봅시다. 이런 상황에서 너무나 간단하게 <strong>동시성 문제가 발생</strong>할 수 있습니다. 이는 데이터 불일치, 값이 오염되는 문제, 심지어 애플리케이션 충돌로 이어질 수 있으며, 복잡한 동기화 메커니즘(락, 세마포어 등)을 필요로 합니다. 이러한 동기화 로직은 데드락이나 라이브락과 같은 더 큰 문제를 야기하기도 합니다.</p>
<p>하지만 불변 객체는 상태가 변경되지 않으므로, <strong>여러 스레드가 동시에 접근하더라도 데이터를 안전하게 공유</strong>할 수 있습니다. 별도의 동기화 메커니즘이 필요 없어 멀티스레드 프로그래밍의 복잡성을 크게 줄여주며, <strong>도메인 주도 설계(DDD)</strong>에서 도메인 모델의 일관성을 유지하는 데 결정적인 이점을 제공합니다.</p>
<h4 id="3-부수-효과-최소화-버그-없는-코드의-지름길">3) 부수 효과 최소화: 버그 없는 코드의 지름길</h4>
<p>가변 객체를 사용하는 메서드는 종종 객체의 내부 상태를 변경하는 &#39;부수 효과&#39;를 가질 수 있습니다. 이러한 부수 효과는 예기치 않은 동작을 유발하고 코드의 흐름을 파악하기 어렵게 만들어 버그의 온상이 됩니다.</p>
<p>불<strong>변 객체는 상태를 변경하는 메서드를 가질 수 없습니다</strong>. 대신, <strong>상태가 변경된 새로운 객체를 반환</strong>하는 방식으로 동작합니다. 이는 함수형 프로그래밍 패러다임과도 잘 맞으며, <strong>부수 효과를 최소화하여 코드의 순수성을 높이고 버그 발생 가능성을 현저히 줄여줍니다.</strong></p>
<h4 id="4-캐싱-및-컬렉션-사용-용이성-성능과-편의성-모두-잡기">4) 캐싱 및 컬렉션 사용 용이성: 성능과 편의성 모두 잡기</h4>
<p>가변 객체는 상태가 변할 수 있으므로 캐싱된 데이터의 유효성을 관리하기 어렵습니다. 캐시된 객체가 변경되면 캐시된 데이터와 실제 데이터 간의 불일치가 발생할 수 있습니다.</p>
<p>반면 불변 객체는 상태가 변하지 않으므로 한 번 계산된 값을 안전하게 캐시할 수 있습니다. 이는 성능 최적화에 유리하며, HashMap이나 HashSet 같은 컬렉션의 키로 사용될 때도 안정적인 동작을 보장합니다. DDD에서 복잡한 계산 결과나 <strong>값 객체(Value Object)</strong>를 효율적으로 재사용할 수 있게 합니다.</p>
<h4 id="5-테스트-용이성-믿을-수-있는-코드">5) 테스트 용이성: 믿을 수 있는 코드</h4>
<p>가변 객체를 테스트할 때는 객체의 초기 상태를 설정하고, 테스트 도중 발생할 수 있는 모든 상태 변화를 고려해야 하므로 테스트 코드가 복잡해질 수 있습니다.</p>
<p><strong>불변 객체는 상태가 고정되어 있으므로 특정 상태를 재현하기 쉽고, 테스트 간의 독립성이 보장</strong>됩니다. 이는 클린 아키텍처에서 각 <strong>계층의 단일 책임 원칙(SRP)</strong>을 지키고, 유닛 테스트를 용이하게 하는 데 크게 기여합니다.</p>
<hr>
<h3 id="3-ddddomain-driven-design-관점에서의-불변-객체">3. DDD(Domain-Driven Design) 관점에서의 불변 객체</h3>
<p>DDD는 복잡한 비즈니스 도메인을 모델링하는 데 중점을 둡니다. 불변 객체는 DDD의 여러 핵심 개념과 시너지를 발휘합니다.</p>
<ul>
<li><p><strong>값 객체(Value Object)</strong>: DDD의 가장 중요한 개념 중 하나인 값 객체는 &quot;측정하거나 서술하는 속성들을 모아놓은 객체&quot;로 정의됩니다. 예를 들어, <code>Money</code>, <code>Address</code>, <code>DateRange</code> 등이 있습니다. 값 객체는 그 본질상 불변이어야 합니다. $5는 항상 $5이지, $10으로 변하지 않습니다. 만약 금액이 변하면 새로운 Money 객체가 생성되는 것이 자연스럽습니다. 불변 값 객체는 공유하기 쉽고 부수 효과가 없으므로 도메인 모델의 견고성을 높입니다.</p>
</li>
<li><p><strong>도메인 이벤트(Domain Event)</strong>: 도메인 이벤트는 과거에 발생한 사실을 나타내므로 불변해야 합니다. 이벤트가 발생한 후에는 그 내용이 변경될 수 없습니다.</p>
</li>
<li><p><strong>스냅샷(Snapshot)</strong>: 특정 시점의 도메인 모델 상태를 저장하는 스냅샷도 불변 객체로 생성되어야 합니다.</p>
</li>
</ul>
<hr>
<h3 id="4-불변-객체-만능은-아니다-주의할-점">4. 불변 객체, 만능은 아니다! (주의할 점)</h3>
<p>불변 객체의 장점이 많지만, 무분별한 사용이 항상 최선은 아닙니다.</p>
<ul>
<li><p><strong>메모리 및 GC(Garbage Collection) 오버헤드</strong>: 불변 객체는 상태 변경 시 항상 새로운 객체를 생성하므로, 매우 빈번하게 객체를 변경해야 하는 상황에서는 많은 객체가 생성되고 소멸됩니다. 이는 가비지 컬렉션의 부담을 늘려 애플리케이션의 응답 시간에 미세한 영향을 줄 수 있습니다.</p>
</li>
<li><p><strong>성능 최적화는 나중에</strong>: 하지만 현대의 JVM과 GC 기술은 매우 발전하여 <strong>대부분의 비즈니스 애플리케이션에서는 이러한 오버헤드가 큰 문제가 되지 않습니다</strong>. <strong>불변 객체 사용으로 인한 성능 이슈는 대부분의 경우 코드의 안정성, 가독성, 유지보수성보다 후순위</strong>에 있습니다. 프로파일링을 통해 명확한 성능 병목 현상이 발견될 때만 가변 객체로의 전환이나 객체 풀링 같은 최적화 기법을 고려해야 합니다. 섣부른 최적화는 오히려 복잡성과 버그를 유발할 수 있습니다.</p>
</li>
</ul>
<hr>
<h3 id="마무리하며">마무리하며</h3>
<p>불변 객체는 현대 소프트웨어 개발에서 강력한 이점을 제공하며, 여러분의 코드를 더욱 예측 가능하고, 안전하며, 유지보수하기 쉽게 만들어 줄 것입니다. 특히 멀티스레드 환경과 복잡한 도메인 로직을 다룰 때 그 가치는 더욱 빛을 발합니다.</p>
<p>이제 여러분의 코드베이스에서 불변 객체를 적극적으로 활용해보는 것은 어떨까요? 작은 변화가 큰 차이를 만들 것입니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[데이터베이스] 인덱스. 왜 빠르고, 어떻게 동작할까?]]></title>
            <link>https://velog.io/@jw_kim/%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EC%9D%B8%EB%8D%B1%EC%8A%A4.-%EC%99%9C-%EB%B9%A0%EB%A5%B4%EA%B3%A0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%99%EC%9E%91%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@jw_kim/%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EC%9D%B8%EB%8D%B1%EC%8A%A4.-%EC%99%9C-%EB%B9%A0%EB%A5%B4%EA%B3%A0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%99%EC%9E%91%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Fri, 27 Jun 2025 03:42:11 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡며칠 전부터 <a href="https://www.maeil-mail.kr/">매일매일</a>을 구독하면서 하루에 하나씩 기술 관련 이론을 공부하며 하루를 시작하고있습니다. 최근 데이터베이스 인덱스에 대한 질문에 대하여 공부를 하던 중 흥미로운 내용을 발견해서 함께 정리해보려 합니다.</p>
</blockquote>
<h3 id="인덱스">인덱스?</h3>
<p><strong>인덱스</strong>는 데이터베이스를 사용하는 개발자라면 반드시 알아야 할 핵심 개념 중 하나입니다. 인덱스를 설명할 때 흔히 백과사전의 <strong>색인</strong>을 예로 들곤 하는데요. 수많은 페이지 중에서 특정 내용을 빠르게 찾고 싶을 때, 색인 페이지를 보면 해당 키워드가 몇 페이지에 있는지 바로 알 수 있습니다.</p>
<p>데이터베이스 인덱스도 이와 비슷합니다. 추가적인 저장 공간(페이지)을 사용해서 자료 검색에 특화된 새로운 자료 구조를 만들고, 이를 통해 <strong>조회 성능을 크게 향상시키는 것</strong>이 바로 인덱스의 목적입니다.</p>
<p>그렇다면 이 인덱스의 데이터는 어떤 형태로 저장되어 있길래 조회 성능을 이렇게까지 높일 수 있는 걸까요? 인덱스의 자료 구조를 검색해보면 <strong>B-Tree</strong>라는 단어를 자주 접할 수 있습니다. 실제로 현대 대부분의 관계형 데이터베이스(RDB)는 인덱스 자료 구조로 B-Tree (혹은 B-Tree의 변형인 B+Tree)를 채택하고 있습니다.</p>
<table>
<thead>
<tr>
<th>DBMS</th>
<th>기본 인덱스 구조</th>
<th>특수 인덱스 유형</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td><strong>MySQL (InnoDB)</strong></td>
<td>B+Tree</td>
<td>Full-text, Spatial, Hash (MEMORY 엔진)</td>
<td>InnoDB는 클러스터형 인덱스를 사용해요.</td>
</tr>
<tr>
<td><strong>PostgreSQL</strong></td>
<td>B-Tree</td>
<td>Hash, GiST, GIN, BRIN, SP-GiST</td>
<td>다양한 데이터 타입에 최적화된 인덱스를 제공해요.</td>
</tr>
<tr>
<td><strong>Oracle</strong></td>
<td>B-Tree</td>
<td>Bitmap, Hash, Clustered, Function-based</td>
<td>병렬 처리와 옵티마이저 힌트와의 연동이 강력해요.</td>
</tr>
<tr>
<td><strong>SQL Server</strong></td>
<td>B+Tree</td>
<td>Full-text, XML, Spatial, Filtered</td>
<td>클러스터형/비클러스터형 인덱스를 선택할 수 있어요.</td>
</tr>
<tr>
<td><strong>MongoDB</strong></td>
<td>B-Tree</td>
<td>Geospatial, Text, Hashed</td>
<td>복합 인덱스와 다중 필드 인덱스를 지원해요.</td>
</tr>
<tr>
<td><strong>Redis</strong></td>
<td>없음 (자료구조 기반)</td>
<td>ZSET(Range), HASH, SET, BITMAP</td>
<td>검색용 인덱스가 아니라 데이터 구조 자체가 인덱스 역할을 해요.</td>
</tr>
<tr>
<td><strong>Elasticsearch</strong></td>
<td>Inverted Index</td>
<td>BKD Tree (for range), Term dictionary</td>
<td>검색 엔진에 특화되어 있고, 텍스트 분석 기반이에요.</td>
</tr>
</tbody></table>
<p>하지만 사실 인덱스에는 B-Tree 형태 외에도 여러 가지 자료 구조가 사용될 수 있습니다.</p>
<table>
<thead>
<tr>
<th>자료구조</th>
<th>특징</th>
<th>사용 목적</th>
</tr>
</thead>
<tbody><tr>
<td><strong>B-Tree / B+Tree</strong></td>
<td>정렬된 키를 저장하고, 범위 검색에 효율적이에요.</td>
<td>대부분의 조건( <code>=</code>, <code>&lt;</code>, <code>&gt;</code>, <code>BETWEEN</code>, <code>ORDER BY</code>)에 사용되는 <strong>범용 인덱스</strong>입니다.</td>
</tr>
<tr>
<td><strong>Hash Table</strong></td>
<td><code>=</code> (등가) 검색에 매우 빠르지만, 정렬/범위 검색은 불가능해요.</td>
<td><code>WHERE key = value</code>와 같이 정확히 일치하는 값을 찾을 때만 빠르게 처리해요.</td>
</tr>
<tr>
<td><strong>Bitmap</strong></td>
<td>공간 효율이 좋고, 카디널리티(중복도)가 낮은 데이터에 적합해요.</td>
<td><code>성별 = 남</code> / <code>국가코드 = KR</code>처럼 중복되는 값이 많은 경우에 유용해요.</td>
</tr>
<tr>
<td><strong>GIN (Postgres)</strong></td>
<td>다중 키워드 검색에 특화되어 있어요.</td>
<td>배열, JSONB, 전문 검색(Full-text search) 등에 사용돼요.</td>
</tr>
<tr>
<td><strong>GiST (Postgres)</strong></td>
<td>범용 인덱스 프레임워크로, 다양한 데이터 타입과 연산자를 지원해요.</td>
<td>위치 정보, 유사도 검색 등에 활용돼요.</td>
</tr>
<tr>
<td><strong>ZSET (Redis)</strong></td>
<td>정렬된 Set 형태로, 스코어(score) 기반으로 정렬돼요.</td>
<td>실시간 랭킹, 우선순위 큐 등에 사용돼요.</td>
</tr>
<tr>
<td><strong>BKD Tree (Lucene)</strong></td>
<td>고차원 숫자 및 벡터 데이터 처리에 강해요.</td>
<td>Elasticsearch에서 범위/필터 검색에 사용돼요.</td>
</tr>
</tbody></table>
<p>오늘 우리는 이 중에서 인덱스 자료 구조의 핵심이라고 할 수 있는 <strong>B-Tree</strong>와 <strong>Hash Table</strong>의 개념을 자세히 알아보고, 왜 인덱스에 다른 자료 구조가 아닌 B-Tree가 주로 사용되는지(사용되어야 하는지)에 대해 이야기해보겠습니다.</p>
<hr>
<h3 id="인덱스-자료-구조-자세히-보기">인덱스 자료 구조 자세히 보기</h3>
<p>앞서 살펴본 것처럼 인덱스에는 다양한 자료 구조가 사용될 수 있습니다. 이 중에서 Hash Table과 B-Tree 자료 구조에 대해 조금 더 자세히 알아볼겠습니다.</p>
<h3 id="hash-table-해시-테이블">Hash Table (해시 테이블)</h3>
<p>Hash Table은 <strong>(Key, Value) 쌍</strong>의 형태로 데이터를 저장하는 자료 구조입니다. Key 값으로 사용될 데이터를 <strong>해시 함수(Hash Function)</strong>를 통해 고유한 인덱스(주소)를 생성하고, 그 주소에 데이터를 저장합니다.</p>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/3fd8cf69-aad9-4151-9bf0-1c74bbceda67/image.png" alt=""></p>
<p>이러한 특성 때문에 Hash Table이 적용된 인덱스에서는 <code>WHERE key = value</code>와 같이 <strong>정확히 일치하는 값을 찾는 등가(Equal) 검색 시</strong> 데이터의 위치를 한 번에 계산할 수 있어 <strong>O(1)이라는 매우 빠른 시간 복잡도</strong>로 검색을 지원합니다. 말 그대로 찰나의 순간에 데이터를 찾아낼 수 있다는 뜻이죠.</p>
<p>하지만 인덱스 자료구조로 Hash Table을 사용하지 못하는 데에는 이유가 있습니다. <code>where</code> 조건으로 고유 번호에 해당하는 1:1 대응 데이터를 조회하는 것 외에 <strong>대부분 데이터 조회는 특정 범위에 해당하는 범위 연산</strong>을 필요로 합니다. 하지만 Hash Table은 <strong>부등호</strong> (<code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code>, <code>&gt;=</code>)나 <strong><code>BETWEEN</code> 등을 사용한 범위 연산을 지원하지 않습니다.</strong> 데이터가 해시 함수에 의해 무작위로 흩어져 저장되기 때문에, &quot;A부터 Z까지&quot;와 같은 <strong>범위의 데이터를 찾으려면 결국 전체 테이블을 스캔(Table Full Scan)</strong>해야 합니다. 또한, 데이터를 정렬하여 저장하지 않기 때문에 클러스터링(Clustering)을 지원하지 않는다는 단점도 있습니다. 그럼에도 불구하고 동등 비교 시 O(1)이라는 매력적인 성능 향상 효과 때문에 인덱스에 제한적으로 사용되기도 합니다.</p>
<blockquote>
<p>📘 <strong>잠깐! 스캔 방식에 따른 성능 차이</strong></p>
<p>데이터베이스는 데이터를 조회할 때 여러 방식의 <strong>스캔 전략</strong>을 사용합니다.<br>아래는 스캔 방식에 따른 <strong>성능 효율</strong>을 간단히 정리한 표입니다.</p>
<table>
<thead>
<tr>
<th>스캔 방식</th>
<th>설명</th>
<th>성능 효율</th>
</tr>
</thead>
<tbody><tr>
<td>Full Table Scan</td>
<td>인덱스 없이 테이블 전체를 읽음</td>
<td>❌ 매우 낮음</td>
</tr>
<tr>
<td>Index Full Scan</td>
<td>인덱스 전체를 순차적으로 읽음</td>
<td>⚠️ 중간</td>
</tr>
<tr>
<td>Index Range Scan</td>
<td>인덱스 범위 조건에 해당하는 부분만 탐색</td>
<td>✅ 효율적</td>
</tr>
<tr>
<td>Index Unique Scan</td>
<td>PK 또는 Unique 조건으로 1건만 조회</td>
<td>✅✅ 매우 효율적</td>
</tr>
<tr>
<td>Index Only Scan</td>
<td>인덱스만으로 모든 데이터 조회</td>
<td>✅✅ 매우 효율적</td>
</tr>
</tbody></table>
<p>Hash Table 자료구조를 사용하는 인덱스를 기준으로 범위 조회를 실행하게 된다면 가장 좋지 않은 성능의 <strong>Full Table Scan</strong>이 수행되는 것입니다.</p>
</blockquote>
<hr>
<h3 id="b-tree-비-트리">B-Tree (비-트리)</h3>
<p>B-Tree는 <strong>&quot;Balanced Tree(균형 트리)&quot;</strong>의 약자로, 이름처럼 균형이 잘 잡혀 있는 트리(Tree) 자료 구조를 의미합니다. 우리가 흔히 아는 이진 트리(Binary Tree)와 비슷하지만, B-Tree는 하나의 노드가 여러 개의 자식 노드를 가질 수 있는 <strong>다원 탐색 트리</strong>의 일종입니다.</p>
<p>B-Tree는 크게 <strong>루트 노드(Root Node), 내부 노드(Internal Node), 리프 노드(Leaf Node)</strong>로 구성되어 있으며, 각 노드에 데이터를 저장하고 있습니다.</p>
<h3 id="b-tree의-구조와-특징">B-Tree의 구조와 특징</h3>
<p>B-Tree는 데이터베이스 인덱스의 <strong>표준</strong>이라고 할 수 있습니다. 그 이유는 바로 B-Tree가 인덱스의 핵심 목표인 <strong>빠른 검색</strong>과 더불어 <strong>범위 검색, 데이터 삽입/삭제 시의 효율성</strong>을 모두 만족시키기 때문입니다.</p>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/35866e4b-9088-48be-ae5c-d7d5094e0822/image.png" alt=""></p>
<h3 id="b-tree의-구성-요소">B-Tree의 구성 요소:</h3>
<ul>
<li><strong>루트 노드 (Root Node):</strong> 트리의 가장 상위에 있는 노드입니다. 모든 검색은 루트 노드에서 시작됩니다.</li>
<li><strong>내부 노드 (Internal Node):</strong> 루트 노드와 리프 노드 사이에 있는 노드들입니다. 내부 노드에는 다음 레벨의 자식 노드로 이동하기 위한 <strong>키(Key)</strong> 값과 해당 자식 노드의 <strong>주소(포인터)</strong>가 저장됩니다. 실제 데이터는 저장하지 않고, 검색 경로를 안내하는 역할을 합니다.</li>
<li><strong>리프 노드 (Leaf Node):</strong> 트리의 가장 하위에 있는 노드들입니다. 리프 노드에는 실제 데이터 레코드의 <strong>키 값</strong>과 해당 레코드가 저장된 테이블의 <strong>주소(ROWID 또는 Primary Key)</strong>가 함께 저장됩니다. 리프 노드는 항상 같은 레벨에 위치하여 트리가 <strong>균형을 유지</strong>하도록 돕습니다.</li>
</ul>
<h3 id="b-tree의-작동-원리-검색">B-Tree의 작동 원리 (검색):</h3>
<p>B-Tree에서 데이터를 검색하는 과정은 매우 효율적입니다. 예를 들어, 특정 <code>ID</code>를 가진 레코드를 찾는다고 가정해봅시다.</p>
<ol>
<li><strong>루트 노드</strong>에서 시작하여 찾으려는 <code>ID</code>와 노드에 저장된 키 값들을 비교합니다.</li>
<li>비교 결과에 따라 다음 레벨의 <strong>적절한 자식 노드</strong>로 이동합니다. (예: 찾으려는 ID가 노드의 키 값보다 작으면 왼쪽 자식, 크면 오른쪽 자식 등)</li>
<li>이 과정을 <strong>리프 노드</strong>에 도달할 때까지 반복합니다.</li>
<li>리프 노드에 도달하면 해당 키 값이 있는지 확인하고, 있다면 연결된 실제 데이터 레코드의 주소를 통해 데이터를 가져옵니다.</li>
</ol>
<p>B-Tree는 노드에 저장되는 키의 개수가 많을수록 트리의 깊이가 얕아지므로, 디스크 I/O (데이터를 읽고 쓰는 작업) 횟수를 줄여 검색 속도를 향상시킵니다. 트리의 깊이가 얕다는 것은 데이터를 찾기 위해 거쳐야 하는 노드의 수가 적다는 의미이고, 이는 곧 디스크에서 데이터를 읽어와야 하는 횟수가 적다는 것을 뜻합니다.</p>
<hr>
<h3 id="btree-비플러스-트리와-b-tree의-차이점">B+Tree (비플러스-트리)와 B-Tree의 차이점</h3>
<p>대부분의 현대 관계형 데이터베이스는 B-Tree보다는 <strong>B+Tree</strong>를 인덱스 자료 구조로 채택하고 있습니다. B+Tree는 B-Tree의 장점을 계승하면서, 특히 <strong>범위 검색 성능을 극대화</strong>하기 위해 고안된 자료 구조입니다.</p>
<h3 id="btree의-주요-특징-b-tree와의-차이점">B+Tree의 주요 특징 (B-Tree와의 차이점):</h3>
<ul>
<li><strong>내부 노드에는 데이터가 저장되지 않는다:</strong> B-Tree는 모든 노드에 데이터를 저장할 수 있지만, B+Tree는 <strong>오직 리프 노드에만 데이터(또는 데이터 레코드의 포인터)를 저장</strong>합니다. 내부 노드는 오로지 자식 노드를 가리키는 <strong>키 값과 포인터만</strong> 가지고 있습니다.<ul>
<li><strong>장점:</strong> 내부 노드의 크기가 작아지므로, 한 블록(페이지)에 더 많은 키 값을 저장할 수 있습니다. 이는 트리의 높이를 더욱 낮춰 디스크 I/O를 줄이는 데 유리합니다.</li>
</ul>
</li>
<li><strong>리프 노드는 연결 리스트(Linked List)로 연결되어 있다:</strong> B+Tree의 모든 리프 노드는 <strong>좌우로 연결 리스트</strong>처럼 서로 연결되어 있습니다.<ul>
<li><strong>장점:</strong> 이 연결 덕분에 <strong>범위 검색(Range Search)</strong>이 매우 효율적입니다. 특정 범위의 데이터를 찾을 때, 시작 값을 가진 리프 노드를 찾은 후 연결된 리스트를 따라가면서 필요한 범위의 데이터를 순차적으로 읽어낼 수 있습니다. B-Tree에서는 범위 검색 시 여러 노드를 오가며 찾아야 할 수도 있어 비효율적입니다.</li>
</ul>
</li>
<li><strong>모든 키 값은 리프 노드에 존재한다:</strong> B+Tree에서는 모든 키 값이 리프 노드에 중복 저장됩니다. 내부 노드는 단지 검색 경로를 위한 키 값을 가지고 있을 뿐입니다.<ul>
<li><strong>장점:</strong> 어떤 종류의 검색(단일 값 검색이든 범위 검색이든)이든 항상 리프 노드까지 도달해야 하므로, 검색 시간이 예측 가능하고 일관적입니다.</li>
</ul>
</li>
</ul>
<h3 id="왜-b-tree-대신-btree를-사용할까">왜 B-Tree 대신 B+Tree를 사용할까?</h3>
<p>데이터베이스 인덱스의 주된 목적 중 하나는 <strong>범위 검색</strong>입니다. 사용자는 특정 값을 찾는 것 외에도 &quot;나이가 20세에서 30세 사이인 사람들&quot;이나 &quot;날짜가 2025년 1월부터 3월까지인 데이터&quot;와 같은 범위 조건을 자주 사용합니다. B+Tree는 리프 노드가 연결 리스트로 이어져 있어 이러한 범위 검색에 압도적으로 효율적입니다.</p>
<p>또한, 내부 노드에 데이터를 저장하지 않음으로써 한 노드에 더 많은 키를 담을 수 있어 트리의 높이가 더 낮아지고, 이는 디스크에서 데이터를 읽어오는 횟수를 줄여 전반적인 성능을 향상시킵니다. 따라서 B+Tree는 대용량 데이터를 처리하는 관계형 데이터베이스의 인덱스에 가장 적합한 자료 구조로 널리 사용됩니다.</p>
<hr>
<h3 id="결론-왜-인덱스는-b-tree-btree일까">결론: 왜 인덱스는 B-Tree (B+Tree)일까?</h3>
<p>이제 왜 데이터베이스 인덱스에 B-Tree (정확히는 B+Tree)가 주로 사용되는지 명확해졌을 것입니다.</p>
<ul>
<li><strong>빠른 탐색 속도:</strong> 균형 잡힌 트리 구조로 어떤 데이터를 찾든 최악의 경우에도 트리의 깊이만큼만 탐색하면 되므로, 일관되고 빠른 검색 성능을 보장합니다. 이는 디스크 I/O를 최소화하는 데 핵심적입니다.</li>
<li><strong>효율적인 범위 검색:</strong> 특히 B+Tree의 리프 노드 연결은 <code>WHERE</code> 절에 부등호나 <code>BETWEEN</code>을 사용하는 범위 검색을 매우 효율적으로 처리할 수 있게 합니다.</li>
<li><strong>삽입/삭제 시 성능 유지:</strong> 새로운 데이터가 삽입되거나 삭제될 때도 트리의 균형을 자동으로 조절하여 검색 성능 저하를 방지합니다. Hash Table과 달리 데이터의 추가/삭제가 빈번해도 충돌 문제를 걱정할 필요가 적습니다.</li>
<li><strong>다양한 연산 지원:</strong> <code>=</code> 뿐만 아니라 <code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code>, <code>&gt;=</code>, <code>BETWEEN</code>, <code>ORDER BY</code> 등 다양한 조건절에 인덱스를 활용할 수 있어 활용도가 높습니다.</li>
</ul>
<p>결론적으로 B-Tree와 B+Tree는 <strong>빠른 단일 검색, 효율적인 범위 검색, 그리고 데이터 변경에 강하다는 인덱스의 요구사항을 모두 만족</strong>시키기 때문에 현대 데이터베이스 인덱스의 핵심 자료 구조로 자리 잡았습니다.</p>
<p>인덱스는 단순히 빠른 조회를 위한 도구가 아니라, 데이터베이스의 성능을 좌우하는 중요한 요소입니다. 오늘 다룬 B-Tree와 Hash Table의 개념을 잘 이해하고 활용하여 더욱 효율적인 데이터베이스 설계와 쿼리 작성을 할 수 있기를 바랍니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[기타] 분산락, 분산 환경에서 동시성 제어하기]]></title>
            <link>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%B6%84%EC%82%B0%EB%9D%BD-%EB%B6%84%EC%82%B0-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%B6%84%EC%82%B0%EB%9D%BD-%EB%B6%84%EC%82%B0-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 09 May 2025 15:41:31 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>ℹ️ 이 글은 항해99 백엔드 플러스 과정 6주차 회고를 위해 작성되었습니다.
분산 락의 개념과 도입 시 고려할 점들을 실제 예제와 함께 정리해보았습니다. <br>
<strong>6주차 목표</strong></p>
</blockquote>
<ul>
<li>분산 락을 도입하는 이유에 대해 알아보고 E-Commerce 서비스에서 적절한 적용 지점을 찾아봅니다.</li>
<li>Redis를 활용하여 데이터 캐시를 적용하고 성능을 분석해봅니다.</li>
</ul>
<h3 id="경쟁-상태race-condition와-동시성-제어">경쟁 상태(Race condition)와 동시성 제어</h3>
<p>대부분의 웹 서비스는 여러 사용자가 동시에 이용하게 됩니다. 예를 들어, 이커머스 서비스에서는 하나의 상품을 여러 명이 동시에 주문하고, 콘서트 예매 서비스에서는 하나의 좌석을 여러 명이 동시에 예매하려고 시도합니다. 이러한 요청은 모두 하나의 데이터베이스에서 동일한 데이터를 읽거나 수정하려고 하기 때문에 충돌이 발생할 수 있습니다.</p>
<p>두 명의 사용자가 재고가 1개 남은 상품을 동시에 주문하는 상황을 가정해봅시다. 재고가 1개뿐이라면 두 명 중 한 명은 주문에 실패해야 하지만, 동시 요청에 대한 처리가 없다면 두 사용자 모두 주문 성공 처리가 되는 상황이 발생할 수 있습니다. 이처럼 동시에 같은 자원에 대한 접근으로 인해 발생하는 상황을 <strong>경쟁 상태(Race Condition)</strong>라고 합니다.</p>
<p>이러한 문제를 방지하기 위해서는 동시 요청에 대해 순서를 정해 차례대로 처리하는 장치, 즉 <strong>동시성 제어</strong>가 필요합니다.</p>
<h3 id="동시성-제어-기법">동시성 제어 기법</h3>
<p>경쟁 상태를 막기 위해 사용되는 것이 바로 <strong>동시성 제어 기법</strong>입니다. 대표적으로 사용되는 기법들은 다음과 같습니다:</p>
<ul>
<li><strong>비관적 락(Pessimistic Lock)</strong>: 충돌이 발생할 가능성이 높다고 판단하여 데이터를 사용하는 시점부터 락을 걸어 다른 접근을 차단하는 방식입니다. 주로 <code>SELECT ... FOR UPDATE</code>와 같은 데이터베이스의 락 기능을 사용합니다.</li>
<li><strong>낙관적 락(Optimistic Lock)</strong>: 충돌이 드물다고 가정하고 작업을 먼저 수행한 뒤, 최종 저장 시점에 데이터가 변경되지 않았는지 확인합니다. 변경 감지는 버전 정보나 타임스탬프 등을 활용합니다.</li>
<li><strong>큐 기반 직렬 처리</strong>: 요청을 큐에 저장하고 하나씩 순차적으로 처리하여 순서를 보장하는 방식입니다. 실시간성이 크게 요구되지 않으면서도 동시 처리가 위험한 상황에서 자주 사용됩니다.</li>
<li><strong>분산 락(Distributed Lock)</strong>: 여러 서버나 인스턴스에서 동시에 자원에 접근할 수 있는 환경에서는 Redis, ZooKeeper 등의 외부 시스템을 활용한 분산 락이 필요합니다. 단일 서버 환경에서는 고려하지 않아도 되지만, 마이크로서비스나 클라우드 환경에서는 필수적입니다.</li>
<li><strong>이벤트 기반 비동기 처리</strong>: 요청을 즉시 처리하지 않고 이벤트를 큐에 넣어 비동기로 처리함으로써 자원 접근 시점을 조율하는 방식입니다. 주문 처리나 알림 발송 같은 후처리 작업에서 자주 사용됩니다.</li>
</ul>
<p>동시성 제어는 시스템의 안정성과 직결되는 요소이며, 서비스의 특성, 처리량, 인프라 구조에 따라 적절한 전략을 선택해야 합니다. <strong>각 기법의 특성과 한계를 이해하고 이를 설계에 반영하는 것</strong>이 중요합니다.</p>
<h3 id="분산-락의-개념">분산 락의 개념</h3>
<p>앞서 학습한 비관적 락과 낙관적 락은 데이터베이스를 기반으로 한 동시성 제어 기법입니다. 이들은 단일 데이터베이스 환경에서 매우 효과적이지만, 시스템의 규모가 커지면서 서버와 데이터베이스가 여러 대로 구성되는 분산 환경에서는 한계가 발생합니다.</p>
<p>이때 고려할 수 있는 대안이 바로 <strong>분산 락(Distributed Lock)</strong>입니다.</p>
<p><strong>분산 락</strong>은 여러 서버나 인스턴스가 동시에 하나의 자원에 접근하지 못하도록 제어하는 락입니다. 단일 서버에서는 synchronized 블록이나 데이터베이스 락으로 충분할 수 있지만, 다중 인스턴스 환경에서는 서로 다른 서버가 같은 데이터를 동시에 변경할 수 있기 때문에, 이들 사이에서 <strong>공통으로 사용할 수 있는 락의 기준점</strong>이 필요합니다.</p>
<p>이런 기준점 역할을 하는 도구로는 <strong>Redis, ZooKeeper, Etcd</strong> 등이 있으며, 특히 Redis는 Redisson 라이브러리를 통해 분산 락 구현을 간편하게 할 수 있습니다. 예를 들어, 하나의 재고 데이터를 여러 서버가 동시에 처리할 수 있는 구조라면, 해당 재고에 대한 락을 Redis에 설정하여 하나의 인스턴스만 작업을 수행하도록 만들 수 있습니다.</p>
<p>다음과 같은 상황에서는 분산 락의 도입을 고려할 수 있습니다:</p>
<ul>
<li>여러 서버가 동일한 데이터를 동시에 갱신할 수 있는 구조일 때</li>
<li>중복 처리가 절대 발생해서는 안 되는 중요한 작업이 있을 때 (예: 쿠폰 발급, 재고 차감 등)</li>
<li>이벤트 기반 또는 비동기 처리 환경에서 여러 컨슈머 간 작업 순서를 제어할 필요가 있을 때</li>
</ul>
<p>단, 분산 락은 일반적인 락보다 구현이 복잡하며, 잘못 사용할 경우 병목이나 데드락을 유발할 수 있으므로 사용 여부를 신중하게 판단해야 합니다.</p>
<h3 id="💡-왜-redisson인가">💡 왜 Redisson인가?</h3>
<p>Redisson은 Redis를 기반으로 동작하는 Java 클라이언트 라이브러리로, 단순한 키-값 저장소를 넘어서 분산 락, 세마포어, 큐, 캐시 등 다양한 동기화 도구를 제공합니다. 특히 여러 서버에서 동시에 같은 자원에 접근할 수 있는 분산 환경에서 Redisson은 락의 획득과 해제를 안정적으로 처리할 수 있도록 고수준 API를 제공하여 개발자가 복잡한 동시성 제어 로직을 직접 구현하지 않아도 되도록 도와줍니다.</p>
<h3 id="💡-예제-시나리오">💡 예제 시나리오</h3>
<p><strong>재고가 1개 남은 상품</strong>에 대해 여러 사용자가 동시에 주문할 때, 중복 주문이 발생하지 않도록 분산 락을 활용하여 <strong>동시에 하나의 서버만 재고 차감 작업을 진행</strong>하도록 합니다.</p>
<pre><code class="language-java">@Service
public class OrderService {

    private final RedissonClient redissonClient;
    private final ProductRepository productRepository;

    public OrderService(RedissonClient redissonClient, ProductRepository productRepository) {
        this.redissonClient = redissonClient;
        this.productRepository = productRepository;
    }

    public void placeOrder(Long productId) {
        String lockKey = &quot;lock:product:&quot; + productId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 락 획득 시도: 최대 3초 대기, 락을 잡으면 5초 후 자동 해제
            if (lock.tryLock(3, 5, TimeUnit.SECONDS)) {
                Product product = productRepository.findById(productId)
                        .orElseThrow(() -&gt; new IllegalArgumentException(&quot;상품이 존재하지 않습니다.&quot;));

                if (product.getStock() &lt;= 0) {
                    throw new IllegalStateException(&quot;상품이 품절되었습니다.&quot;);
                }

                product.decreaseStock(1); // 재고 차감
                productRepository.save(product);
            } else {
                throw new IllegalStateException(&quot;다른 사용자가 주문을 처리 중입니다. 잠시 후 다시 시도해주세요.&quot;);
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(&quot;락 획득 중 인터럽트 발생&quot;, e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock(); // 반드시 락 해제
            }
        }
    }
}</code></pre>
<h3 id="📘-코드-설명">📘 코드 설명</h3>
<ul>
<li><strong><code>lockKey</code></strong>: 상품 ID 기반으로 고유한 락 키를 설정하여, 상품 단위로 락을 분리합니다.</li>
<li><strong><code>tryLock(대기시간, 자동해제시간, 단위)</code></strong>: 락이 해제될 때까지 최대 3초간 기다리고, 락을 획득하면 5초 뒤 자동으로 해제됩니다. 첫 번째 인자는 대기 시간, 두 번째는 락 유지 시간입니다.</li>
<li><strong><code>finally</code> 블록에서의 락 해제</strong>는 필수입니다. 락을 잡은 스레드만 해제할 수 있으므로 조건을 확인해야 합니다.</li>
</ul>
<h3 id="✅-마무리하며">✅ 마무리하며</h3>
<p>Race Condition과 동시성 제어는 모든 웹 서비스에서 한 번쯤은 마주하게 되는 주제입니다. 이번 글에서는 그 개념부터 분산 환경에서의 해결책인 분산 락까지, 실무에서 자주 사용되는 방식들을 중심으로 정리해보았습니다. 특히 Redisson을 활용한 분산 락 구현은 마이크로서비스 환경에서의 실질적인 대안이 될 수 있습니다.</p>
<p>서비스가 커지면 동시성 제어는 선택이 아닌 필수가 됩니다. 상황에 맞는 적절한 방법을 이해하고 활용할 수 있다면, 안정성과 확장성을 모두 갖춘 구조를 만들 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[기타] NCP Object Storage + Global CDN 으로 안전하게 미디어파일 제공하기]]></title>
            <link>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-NCP-Object-Storage-Global-CDN-%EC%9C%BC%EB%A1%9C-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%A0%95%EC%A0%81%ED%8C%8C%EC%9D%BC-%EC%A0%9C%EA%B3%B5%ED%95%98%EA%B8%B0-qntdwg7v</link>
            <guid>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-NCP-Object-Storage-Global-CDN-%EC%9C%BC%EB%A1%9C-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EC%A0%95%EC%A0%81%ED%8C%8C%EC%9D%BC-%EC%A0%9C%EA%B3%B5%ED%95%98%EA%B8%B0-qntdwg7v</guid>
            <pubDate>Tue, 21 Jan 2025 00:41:07 GMT</pubDate>
            <description><![CDATA[<h2 id="🎧-정적-파일-보안을-위한-실전-접근법--ncp-object-storage--global-cdn-secure-token-활용기">🎧 정적 파일 보안을 위한 실전 접근법 – NCP Object Storage + Global CDN Secure Token 활용기</h2>
<blockquote>
<p>회사에서 운영 중인 오디오북 서비스에서 <strong>음원 파일의 무분별한 접근을 방지</strong>하고자 한 실전 적용 사례입니다.</p>
</blockquote>
<hr>
<h3 id="✅-왜-원본-파일-보안이-필요할까">✅ 왜 원본 파일 보안이 필요할까?</h3>
<p>정적 파일(이미지, 음원, 영상 등)을 인터넷에 공개할 경우, 사용자 누구나 직접 다운로드하거나 재사용이 가능해집니다. DRM 솔루션을 도입하면 복제와 재생을 제어할 수 있지만, 다음과 같은 단점이 있습니다.</p>
<ul>
<li>💸 <strong>비용</strong>: 상용 DRM 솔루션은 라이선스 비용이 발생</li>
<li>🔧 <strong>복잡성</strong>: 기술 구현 및 플랫폼 연동 난이도가 높음</li>
</ul>
<p>저희 팀은 <strong>오디오북 음원 파일의 무단 접근 문제</strong>를 해결하고자, 비교적 간단하고 효과적인 방법으로 **Naver Cloud Platform(NCP)**의 Object Storage와 Global CDN의 Secure Token 기능을 도입했습니다.</p>
<hr>
<h3 id="🗃️-object-storage를-활용한-원본-파일-저장">🗃️ Object Storage를 활용한 원본 파일 저장</h3>
<p>원본 파일을 안전하게 저장하고 외부 접근을 제어하기 위해, NCP의 Object Storage를 선택했습니다.</p>
<h4 id="📁-버킷-생성-및-권한-설정">📁 버킷 생성 및 권한 설정</h4>
<ul>
<li><p>버킷 생성 시 기본 설정 유지</p>
</li>
<li><p><strong>권한 관리: “공개” 설정</strong></p>
<ul>
<li>버킷 전체는 비공개라도, 개별 파일을 공개하면 접근 가능</li>
<li>그러나 “공개 안함”으로 설정하면 개별 파일이 공개되어도 접근 불가</li>
</ul>
</li>
</ul>
<h4 id="🔍-파일-업로드-및-접근-테스트">🔍 파일 업로드 및 접근 테스트</h4>
<ul>
<li><code>sample-audio.mp3</code> 파일 업로드 후 공개 상태에서 접근 시, 링크만 알면 누구나 다운로드 가능</li>
<li>버킷/파일 권한을 &quot;공개 안함&quot;으로 설정하면 접근 차단됨</li>
</ul>
<blockquote>
<p>✔️ 기본적으로는 &quot;공개 안함&quot; 상태로 파일을 업로드하고,
필요 시 <strong>Secure Token</strong>이나 <strong>Presigned URL</strong>을 통해 제한적 접근을 허용하는 방식이 적절합니다.</p>
</blockquote>
<hr>
<h3 id="🌍-global-cdn--secure-token-적용하기">🌍 Global CDN + Secure Token 적용하기</h3>
<p>NCP에서는 기존 <code>CDN+</code> 대신 <code>Global CDN</code>을 새롭게 제공하고 있으며, 보안 기능으로 <strong>Secure Token</strong> 발급을 지원합니다.</p>
<h4 id="1-cdn-생성하기">1. CDN 생성하기</h4>
<ul>
<li><p><strong>서비스 설정</strong>: 이름 설정, 프로토콜은 <code>ALL</code> 선택</p>
</li>
<li><p><strong>원본 설정</strong>: 앞서 생성한 Object Storage 버킷을 원본으로 지정</p>
</li>
<li><p><strong>캐싱 설정</strong>: 기본값 사용</p>
</li>
<li><p><strong>Viewer 전송 설정</strong>:</p>
<ul>
<li>Secure Token → <code>사용</code></li>
<li>방식 → <code>Query String</code> 선택</li>
</ul>
</li>
</ul>
<h4 id="2-서비스-도메인-확인">2. 서비스 도메인 확인</h4>
<p>CDN이 생성되면 약 10분 내 활성화됩니다. 이후 <code>https://{CDN 도메인}/sample-audio.mp3</code> 형식으로 접근할 수 있습니다.
단, 버킷이 “공개 안함” 상태이므로 기본적으로 접근은 차단됩니다.</p>
<hr>
<h3 id="🔐-secure-token-생성하기">🔐 Secure Token 생성하기</h3>
<p>Secure Token은 다음 조건을 포함한 암호화된 문자열로, 일정 시간 동안만 특정 파일에 접근할 수 있도록 제한합니다.</p>
<ul>
<li><strong>start_time</strong>: 토큰 유효 시작 시간</li>
<li><strong>window_seconds</strong>: 토큰 유지 시간(초)</li>
<li><strong>acl</strong>: 접근 허용 경로</li>
<li><strong>key</strong>: CDN에서 제공하는 보안 키</li>
</ul>
<h4 id="🐍-python으로-토큰-발급하기">🐍 Python으로 토큰 발급하기</h4>
<p>네이버에서 제공하는 Python 예제를 기반으로 토큰을 생성할 수 있습니다.
코드를 실행하면 아래와 같이 토큰이 발급됩니다.</p>
<pre><code class="language-bash">$ python test.py -k [보안키] -n token -s now -w 3600 -a &#39;/sample-audio.mp3*&#39;

# 결과 예시
token=st=1738757016~exp=1738760616~acl=/sample-audio.mp3*~hmac=...</code></pre>
<h4 id="🔗-파일-요청-예시">🔗 파일 요청 예시</h4>
<pre><code class="language-http">https://{CDN도메인}/sample-audio.mp3?token=st=...~exp=...~hmac=...</code></pre>
<p>이 URL로 접근하면, 지정한 시간 동안만 해당 파일을 안전하게 요청할 수 있습니다.</p>
<hr>
<h3 id="🧾-마무리-정리">🧾 마무리 정리</h3>
<ul>
<li><p>정적 파일은 공개 설정만으로는 충분한 보안이 어렵습니다.</p>
</li>
<li><p><strong>Object Storage + Secure Token CDN 방식</strong>은 비교적 간단하면서도 실효적인 파일 보호 수단입니다.</p>
</li>
<li><p>보안을 강화할 필요가 있는 경우,</p>
<ul>
<li><code>Presigned URL</code>, <code>Secure Token</code>, <code>암호화된 파일 업로드</code> 등을 상황에 따라 선택적으로 사용해야 합니다.</li>
</ul>
</li>
<li><p>추가적으로 Java SDK나 AWS S3 호환 기능도 함께 검토해볼 수 있습니다.</p>
</li>
</ul>
<blockquote>
<p>📚 더 자세한 사용 방법은 <a href="https://guide.ncloud-docs.com/docs/ko/home">NCP 공식 가이드</a>를 참고하세요.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[기타] 데이터 조회 성능 향상시키기 3편]]></title>
            <link>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0-3%ED%8E%B8</link>
            <guid>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0-3%ED%8E%B8</guid>
            <pubDate>Fri, 20 Dec 2024 04:45:59 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0-2%ED%8E%B8-c96cg854">이전 포스트</a>에서 Redis를 설치하고 Spring boot 프로젝트와 연동하는 방법을 알아보았습니다. 이번 포스트에서는 실제로 Redis를 적용하는 방법과 성능의 차이를 비교해보도록 하겠습니다.</p>
</blockquote>
<h3 id="redis-cache-설정하기">Redis cache 설정하기</h3>
<p>Redis는 key:value형식으로 데이터를 다룹니다. value로 입력되는 값은 직렬화된 형태로 저장됩니다. Redis Config 파일에 직렬화/역직렬화를 위한 cacheManager 설정 부분을 추가하도록 하겠습니다.</p>
<h4 id="redisconfigclass">RedisConfig.class</h4>
<pre><code class="language-java">    @Bean
    public CacheManager cacheManager() {
        RedisCacheManager.RedisCacheManagerBuilder builder =
                RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());

        RedisCacheConfiguration configuration =
                RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .disableCachingNullValues()
                .entryTtl(Duration.ofMinutes(30L));

        builder.cacheDefaults(configuration);

        return builder.build();
    }</code></pre>
<p>Java에서는 직렬화/역직렬화를 위해 다양한 <strong>직렬화 규칙</strong>을 적용 할 수 있습니다. (StringRedisSerializer, Jackson2JsonRedisSerializer, <strong>GenericJackson2JsonRedisSerializer</strong> 등)</p>
<p>저는 이중 GenericJackson2JsonRedisSerializer를 사용해보겠습니다. 
GenericJackson2JsonRedisSerializer의 이점은 다른 직/역직렬화 규칙과는 다르게 <strong>@class정보를 value값과 함께 등록하여 Object mapping</strong>하여 사용할 수 있다는 점입니다.</p>
<hr>
<h3 id="cache-annotations">Cache Annotations</h3>
<p>Cache적용에 앞서 캐싱하기 위한 데이터의 형태와 변경이 발생하였을 경우 대응 방안 등 캐싱 전략을 우선적으로 수립하여야합니다.(<a href="https://wnsgml972.github.io/database/2020/12/13/Caching/">캐싱 전략에 대하여 개발자 KimJunHee님의 블로그 글</a>) </p>
<p>이 포스팅은 캐싱으로 인한 조회 성능 개선 비교를 중심으로 작성하기 때문에 캐싱 전략에 대한 설명은 생략하겠습니다. Spring framework을 사용하신다면 <strong>org.springframework.cache.annotation</strong>에서 제공하는 <strong>@Cacheable, @CachePut, @CacheEvict</strong> annotation을 사용하여 간단하게 데이터를 저장하거나 수정, 조회할 수 있습니다.</p>
<h4 id="cacheable">@Cacheable</h4>
<p>메서드의 반환값을 <strong>캐시 저장소에 저장하고 조회</strong>를 위해 사용합니다. <strong>@Cacheable</strong>이 붙어있는 메소드가 실행되면 데이터를 DB에서 조회하는 것이 아닌 <strong>우선 Cache 저장소</strong>(이 경우 Redis)<strong>에 명시된 key값</strong>으로 데이터 존재 유무를 확인합니다.</p>
<ul>
<li><p>만약 <strong>데이터가 존재</strong>할 경우 DB조회 단계는 생략되고 <strong>Cache 저장소의 내용을 반환</strong>하게됩니다.</p>
</li>
<li><p>** 데이터가 존재하지 않을 경우** DB조회 로직을 수행하게되고 반환하는 결과값에 대하여 Cache 저장소에 저장 후 결과값을 반환하게됩니다.</p>
</li>
</ul>
<p>캐싱되어있지 않은 데이터를 조회할 경우 최초 요청에 대해서는 Redis 저장소에 데이터를 등록하는 추가적인 작업이 필요하기 때문에 단순 조회에 비해 성능에서 희생을 필요로하게되지만, 이후 요청에 대해서는 단순 조회보다 월등히 뛰어난 성능을 보여주게 됩니다. </p>
<p>다음은 체육관 전체 목록을 조회하는 로직입니다.</p>
<pre><code class="language-java">@Cacheable(cacheNames = &quot;gyms&quot;, key = &quot;&#39;all&#39;&quot;)
public Gyms findAllGymsWithCache(){
    List&lt;Gym&gt; gyms = gymRepository.findAll();
     return new Gyms(gyms);
}</code></pre>
<p>체육관의 전체 목록에 대한 조회 요청이 있을 경우 우선 Redis 저장소의 gyms::all으로 저장되어있는 데이터를 조회합니다. 
만약 데이터가 존재하지 않을 경우 <code>gymRepository.findAll();</code>이 실행되고 결과값에 대하여 Redis 저장소에 등록, 데이터 반환 과정이 차례대로 진행됩니다.</p>
<p>Test 코드를 통해 위 로직을 실행하고 최초 등록 시 응답에 걸리는 시간과 Redis 저장소에 등록 내용을 확인해보겠습니다.</p>
<pre><code class="language-java">    @Nested
    @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
    class 체육관_조회_테스트{

        @Test
        @Rollback(value = false)
        @DisplayName(&quot;00_벌크 데이터 등록&quot;)
        public void test_a(){
            int gymCount = 30000;
            for (int i = 1; i &lt;= gymCount; i++) {
                Gym gym = new Gym(&quot;test gym&quot; + i);
                repository.save(gym);
                for(int j = 0; j &lt; 2; j++){
                    Member member = new Member(&quot;test member&quot;, gym.getName(), TestValue.IPSUM);
                    memberRepository.save(member);
                }
            }
        }


        @Test
        @DisplayName(&quot;01_체육관 정보 조회&quot;)
        public void test_b(){
            long before = System.currentTimeMillis();

            List&lt;Gym&gt; gyms = service.findAllGyms();

            long after = System.currentTimeMillis();
            long diff = after - before;

            log.debug(&quot;전체 데이터 크기: {}&quot;, gyms.size());
            log.debug(&quot;전체 조회 실행 시간: {}&quot;, diff);
        }

        @Test
        @DisplayName(&quot;02_체육관 정보 조회 캐시적용&quot;)
        public void test_c(){
            long before = System.currentTimeMillis();

            Gyms gyms = service.findAllGymsWithCache();

            long after = System.currentTimeMillis();
            long diff = after - before;
            log.debug(&quot;전체 데이터 수: {}&quot;, gyms.getGyms().size());
            log.debug(&quot;전체 조회 캐시 최초 실행 시간: {}&quot;, diff);
        }

        @Test
        @DisplayName(&quot;03_체육관 전체 정보 캐시조회&quot;)
        public void test_d(){
            long before = System.currentTimeMillis();

            Gyms gyms = service.findAllGymsWithCache();

            long after = System.currentTimeMillis();
            long diff = after - before;
            log.debug(&quot;전체 데이터 수: {}&quot;, gyms.getGyms().size());
            log.debug(&quot;전체 조회 캐시 적용 실행 시간: {}&quot;, diff);
        }

        @Test
        @DisplayName(&quot;04_체육관 전체 정보 캐시조회&quot;)
        public void test_e(){
            long before = System.currentTimeMillis();

            Gyms gyms = service.findAllGymsWithCache();

            long after = System.currentTimeMillis();
            long diff = after - before;
            log.debug(&quot;전체 데이터 수: {}&quot;, gyms.getGyms().size());
            log.debug(&quot;전체 조회 캐시 적용 실행 시간: {}&quot;, diff);
        }
    }</code></pre>
<p>30,000건의 bulk data를 등록 후 <code>일반 조회 -&gt; 캐시 조회 (최초) -&gt; 캐시 조회 2회</code> 순서로 테스트 코드를 실행하고 각 단계별로 조회에 소요된 시간을 기록하여 비교해보도록 하겠습니다.</p>
<h3 id="실행-결과-비교">실행 결과 비교</h3>
<h4 id="일반-조회">일반 조회</h4>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/264d1104-df0f-483c-b376-050423c3380d/image.png" alt=""> redis cache가 적용되지 않은 일반 조회 로직을 실행하였을 때, <strong>30,000개의 데이터</strong>를 조회하는데 <strong>311ms</strong>가 소요되었습니다. </p>
<hr>
<h4 id="redis-cache-조회-최초-실행">Redis cache 조회 최초 실행</h4>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/7f350bb0-cd0f-4038-aeba-2fd52c967089/image.png" alt=""> 다음으로 redis cache가 적용된 로직을 실행한 경우 중 최초 실행하였을때입니다. 실행 시간은 493ms로 앞서 일반 조회 성능보다 180ms이상 성능이 악화되었습니다. 
실행된 hibernate 로그를 확인해보면 데이터 조회하는 SQL이 실행된 것을 볼 수 있습니다.</p>
<pre><code>2025-01-02T13:26:57.107+09:00 DEBUG 43420 --- [board-for-workers] [           main] org.hibernate.SQL                        : select g1_0.gym_id,g1_0.address,g1_0.close_time,g1_0.is_open,g1_0.location,g1_0.name,g1_0.open_time,g1_0.phone_number from gym g1_0
Hibernate: select g1_0.gym_id,g1_0.address,g1_0.close_time,g1_0.is_open,g1_0.location,g1_0.name,g1_0.open_time,g1_0.phone_number from gym g1_0</code></pre><p><strong>cache가 적용된 로직</strong>의 경우 최초 실행 시 명시된 <strong>cache 저장소</strong>에서 요청된 <strong>key::value 형태로 저장되어있는 값이 존재하는지 우선적으로 확인</strong>하게됩니다. <strong>만약 존재하지않다면 SQL을 실행하여 데이터베이스 조회</strong>를 실행하고 사용자에게 응답 전 <strong>cache 저장소에 해당 내용을 기록</strong>하게됩니다. 최초 실행 시 일반 실행보다 더 많은 시간이 소요되는 것은 이 cache 저장소에 최초로 기록하는 작업이 발생하기 때문입니다.
다시 말해 cache는 <strong>최초 요청자에 대한 응답을 희생</strong>하여 <strong>이후 동일한 요청이 있을 때 성능 향상</strong>을 기대하는 것입니다.</p>
<hr>
<h4 id="redis-cache-조회-실행-2회">Redis cache 조회 실행 (2회)</h4>
<p>앞서 이야기한대로라면 이번 조회 실행부터는 일반 조회 혹은 cache 최초 실행에 비해 더 빠른 응답 시간을 기대할 수 있습니다.</p>
<ul>
<li>1회 실행
<img src="https://velog.velcdn.com/images/jw_kim/post/516ec3c2-734c-40c8-9b4a-1a297fa3aa20/image.png" alt=""></li>
<li>2회 실행
<img src="https://velog.velcdn.com/images/jw_kim/post/a76ca531-c437-4a63-bd9c-53ca12eb8da0/image.png" alt=""></li>
</ul>
<p>위와같이 cache 저장소에 값이 입력되어있을 경우 일<strong>반 조회에 비하여 절반 정도의 성능이 향상</strong>된 것을 확인할 수 있습니다. 실제로 데이터 조회를 위해 SQL을 실행하지 않고 Redis cache 저장소의 값을 return하기때문에 hibernate 로그도 찍히지 않았습니다. </p>
<h3 id="마무리-주의-사항">마무리 (주의 사항)</h3>
<p>실제 테스트를 진행한 환경은 단순 30,000건의 데이터를 조회하는 테스트기때문에 폭발적인 성능의 향상까지는 확인할 수 없었습니다. 하지만 실제로 join연산이 동반되거나 수십만건의 데이터를 조회할 경우 cache의 적용만으로도 엄청난 성능 향상이 되는 것을 확인할 수 있습니다.
하지만 이런 <strong>cache 저장소의 적용에 앞서 반드시 주의해야할 내용</strong>이 한가지 있습니다. 바로 c<strong>ache저장소에 저장된 내용에 대하여는 SQL을 실행하지 않기 때문에 발생</strong>하는 문제입니다. </p>
<p>아래의 경우를 생각해보겠습니다.
<code>Gym 전체 목록 조회 요청 (cache/최초) -&gt; SQL 실행 -&gt; 데이터 cache 저장소 등록 -&gt; 사용자에게 응답 -&gt; Gym 목록에서 2개의 데이터 삭제</code>
기존 데이터에 수정이나 삭제 등 변경이 있을 경우 cache 저장소의 데이터에 별도 처리가 없을 경우, 사용자가 동일한 요청을 보냈을 때 SQL이 실행되지 않고 cache 저장소의 값을 응답하기 때문에 사용자는 수정 이전에 데이터를 조회하는 문제가 발생합니다.
때문에 cache 저장소의 적용에 앞서 데이터 동기화 처리를 위한 cache 설계 전략을 수립해야합니다. (예: 데이터 등록/삭제/수정 시 관련 cache 데이터 clear)</p>
<p>이번 포스팅은 이것으로 마무리를 하고 다음 포스트에서는 실제 실무에서 사용하였을 때 어느정도의 성능 차이가 있는지 확인해보도록 하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[기타] 데이터 조회 성능 향상시키기 2편]]></title>
            <link>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0-2%ED%8E%B8-c96cg854</link>
            <guid>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0-2%ED%8E%B8-c96cg854</guid>
            <pubDate>Wed, 18 Dec 2024 00:43:55 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0-1%ED%8E%B8">이전 포스트</a>에서 Index를 적용하여 데이터 조회 성능을 향상시키는 방법에 대하여 알아보았습니다. 이번 포스트에서는 Redis in-memory cache를 적용하여 조회 성능을 향상시키는 방법에 대해 알아보겠습니다.</p>
</blockquote>
<h3 id="index-적용의-한계">Index 적용의 한계</h3>
<p>데이터베이스의 Index는 데이터 조회 성능을 향상시키기 위해 추가적인 색인 자료구조를 생성하는 방식입니다. 때문에 읽기의 성능을 향상될 수 있으나 쓰기 작업에 대하여는 데이터 이외 색인 쓰기 작업이 추가로 발생하기 때문에 성능 저하가 발생할 수 있습니다. 또한 컴퓨팅 자원 증설의 한계로 일정 시점 이상으로 성능 향상을 기대할 수 없다는 단점이 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/03e06aa5-0191-4670-80be-550aa4c173e6/image.png" alt="">(<em>출처:데이터베이스 배움터</em>)</p>
<h3 id="in-memory-cache">in-memory cache</h3>
<p>위와 같이 Index로는 더이상 효과를 보기 어려운 상황이거나 추가적으로 조회 성능에 개선이 필요한 시점이 오게 될 경우, <strong>in-memory cache</strong>의 도입을 검토해 볼 수 있겠습니다. 컴퓨터의 연산 작업은 일반적으로 아래와 같은 절차를 거치게됩니다.</p>
<p><code>비지니스 로직 실행 -&gt; 연산 장치(CPU)의 데이터 요청 -&gt; 주 기억장치 (memory) -&gt; 보조 기억장치(SSD, HDD 등) -&gt; 연산 장치 데이터 전송 -&gt; 결과 반환</code></p>
<p>이 과정에서 연산 장치가 필요로 하는 데이터가 주기억장치에 존재한다면 보조 기억장치로의 데이터 요청 작업이 생략되고 연산이 수행되며 결과를 반환하게 됩니다. 하지만 <strong>주기억장치에 데이터가 존재하지 않을 경우</strong> 이를 <strong>보조기억장치에 요청</strong>하게되고 주기억장치와 보조기억장치간의 <strong>데이터 교환 비용</strong>이 발생하게 됩니다.(연산 과정에서 가장 많은 비용 발생)</p>
<p>in-memory cache는 연산에 필요한 데이터 혹은 연산의 결과값을 주기억장치에 저장하여 보조 기억장치로의 데이터 요청 작업을 생략할 수 있게 도와줍니다.</p>
<p>주로 사용되는 in-memory cache에는 Ehcache, Redis, Memcached등이 있습니다.</p>
<hr>
<h3 id="redis">Redis</h3>
<h4 id="in-memory-cache의-종류">in-memory cache의 종류</h4>
<p>Spring framework을 사용하고 있다면 간단하게 적용 가능한 Ehcache가 유효한 선택지가 될 수 있습니다. (<a href="https://jojoldu.tistory.com/57">이동욱 개발자님의 ehcache 예제 포스팅</a>) 다만 프레임워크 라이브러리로 제공되는 기능이다보니 cli를 활용한 데이터 조회나 외부 서비스 연동에 어려움이 있습니다. 하여 이번 포스팅에서는 범용적으로 사용 가능한 <strong>Redis</strong>를 예로 들어 작성해보겠습니다.</p>
<h4 id="redis-설치-macos">Redis 설치 (macOS)</h4>
<p>맥 PC의 경우 <a href="https://brew.sh/ko/">brew 패키지 관리자</a>를 사용하면 간단하게 Redis를 설치할 수 있습니다.
터미널을 실행 후 <code>brew install redis</code> 명령어를 입력하면 Redis가 설치됩니다. (설치 완료 후 <code>redis-server --version</code>로 확인할 수 있습니다.)</p>
<p>설치가 완료되었다면 redis를 실행해보겠습니다.
<img src="https://velog.velcdn.com/images/jw_kim/post/dfd6a02b-9713-421a-95e7-592f733d7324/image.png" alt="">현재는 <strong>foregraund로</strong> 실행된 상태이기때문에 터미널을 종료하면 redis도 함께 종료가 됩니다. brew의 프로세스 관리 명령어를 사용하여 <strong>background로 실행</strong> 하는 방법을 알아보겠습니다.
<img src="https://velog.velcdn.com/images/jw_kim/post/0494c757-141f-4239-ac66-45bd39da8bbe/image.png" alt=""></p>
<p>redis가 background모드로 실행되고있는 것을 확인할 수 있습니다. (프로세스 종료 명령어 <code>brew services stop redis</code>) Redis가 실행중이라면 cli를 활용하여 접속할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/7bfd0b74-711f-49e5-844b-173b40d6e5ef/image.png" alt=""> redis는 기본적으로 6379번 port에서 실행됩니다.</p>
<hr>
<h3 id="spring-project-연동">Spring project 연동</h3>
<p>앞서 Redis를 설치하고 실행하는 방법에 대해 알아보았습니다. 이제 실행된 Redis를 Spring boot 프로젝트와 연동하여 데이터를 등록하거나 조회, 수정하는 방법을 알아보겠습니다.</p>
<blockquote>
<p>프로젝트 환경</p>
</blockquote>
<ul>
<li>OS: macOS</li>
<li>JDK: openJDK 17</li>
<li>Spring version: spring boot 3.x</li>
<li>Database: H2 database</li>
</ul>
<h4 id="spring-boot-redis-환경-설정">spring-boot redis 환경 설정</h4>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/4b31bd5b-6d0e-4d4b-a5fe-f68a7d8e0120/image.png" alt="">spring boot와 redis를 연동하기 위해 build.gradle &gt; dependencies에
<code>implementation &#39;org.springframework.boot:spring-boot-starter-data-redis&#39;</code> 를 추가하였습니다.</p>
<p>다음으로 application.yml(application.properties)에 연결할 redis의 port와 host를 명시해주겠습니다.
<img src="https://velog.velcdn.com/images/jw_kim/post/a46d37d0-629a-4803-a525-55af8565dc40/image.png" alt=""> 로컬 환경에서의 연동 테스를 위한 프로젝트이기 때문에 접속 password는 설정하지않았습니다.(상용 환경에서이 도입을 원할 경우 반드시 password를 설정해주세요.)</p>
<h4 id="redisconfigclass">RedisConfig.class</h4>
<p>다음으로 redis bean 생성을 위한 class 파일을 추가해보겠습니다.</p>
<pre><code class="language-java">@Configuration
public class RedisConfig {

    @Value(&quot;${spring.data.redis.host}&quot;)
    private String host;

    @Value(&quot;${spring.data.redis.port}&quot;)
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate&lt;String, Object&gt; redisTemplate() {
        RedisTemplate&lt;String, Object&gt; redisTemplate = new RedisTemplate&lt;&gt;();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }

}</code></pre>
<p>java에서 제공하는 Redis client에는 Jedis와 Lettuce 두 종류가 있습니다. 저는 Lettuce를 선택하여 사용하도록 하겠습니다.(<a href="https://jojoldu.tistory.com/418">이동욱 개발자님의 Jedis 보다 Lettuce 를 쓰자</a>)</p>
<h4 id="redis-test">Redis test</h4>
<p>Redis client를 사용하여 데이터를 입력 / 조회하는 테스트 코드를 작성해보겠습니다.</p>
<pre><code class="language-java">@SpringBootTest
public class RedisServiceTest {

    @Autowired
    private StringRedisTemplate redisTemplate;


    @Test
    @Transactional
    public void redis_key_insert_test(){
        String key = &quot;test-redis-key-01&quot;;
        String value = &quot;test-redis-value-01&quot;;

        final ValueOperations&lt;String, String&gt; stringStringValueOperations = redisTemplate.opsForValue();
        stringStringValueOperations.set(key, value);

        String redisValue = redisTemplate.opsForValue().get(key);

        Assertions.assertEquals(value, redisValue);
    }
}</code></pre>
<p>&quot;test-redis-key-01&quot;:&quot;test-redis-value-01&quot;과 같이 key:value를 입력하고 입력된 데이터를 key값으로 조회하여 일치 여부를 검증하는 테스트 코드입니다.</p>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/2cc8fb2a-1896-471b-9f3b-ecfdf181f66d/image.png" alt="">테스트가 정상적으로 실행되었습니다. 이제 redis-cli로 실제 입력된 데이터를 확인해보겠습니다.
<img src="https://velog.velcdn.com/images/jw_kim/post/ab16d35a-8aee-4e7e-a6cb-105f9a897574/image.png" alt="">
테스트 코드를 통하여 입력하였던 key:value가 등록되어있습니다.</p>
<hr>
<h3 id="마무리">마무리</h3>
<p>실제 조회 로직에 redis를 적용하기 위해서는 @CachePut, @Cacheable, @CacheEvict을 사용하여 캐싱 전략을 결정하여야합니다. 이번 포스팅에서는 기본적인 Redis의 설치와 사용법을 알아보았다면, 다음 포스팅에서는 실제 데이터를 조회하고 변경된 데이터에 대하여 캐시를 갱신하는 등의 작업과 redis적용 이전과 이후의 성능 차이를 비교해보도록 하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[기타] 데이터 조회 성능 향상시키기 1편]]></title>
            <link>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0-1%ED%8E%B8</link>
            <guid>https://velog.io/@jw_kim/%EA%B8%B0%ED%83%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0-1%ED%8E%B8</guid>
            <pubDate>Thu, 12 Dec 2024 11:29:33 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>데이터의 조회 성능을 향상시키는 방법은 데이터베이스의 종류에 따라 또 데이터의 규모에 따라 다양합니다. 이번 포스트로 알아볼 것은 데이터 조회 성능을 향상시킬 수 있는 가장 기본적인 index 적용에 대하여 알아보고 실제 벌크 데이터를 활용하여 성능을 비교해보겠습니다.</p>
</blockquote>
<h3 id="인덱스란">인덱스란?</h3>
<p>흔히 index라는 단어는 사전이나 두꺼운 책의 맨 뒷부분에서 찾아볼 수 있습니다. 읽는이로 하여금 찾아보고싶은 키워드가 몇 페이지에 있는지 빠르게 찾아볼 수 있도록 만들어둔 별도의 페이지를 인덱스라합니다.</p>
<blockquote>
</blockquote>
<p><em>데이터베이스 분야에 있어서 <strong>테이블에 대한 동작의 속도를 높여주는 자료 구조</strong>를 일컫는다. 인덱스는 테이블 내의 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있다. 고속의 검색 동작뿐만 아니라 레코드 접근과 관련 효율적인 순서 매김 동작에 대한 기초를 제공한다. 인덱스를 저장하는 데 필요한 디스크 공간은 보통 테이블을 저장하는 데 필요한 디스크 공간보다 작다. (왜냐하면 보통 인덱스는 키-필드만 갖고 있고, 테이블의 다른 세부 항목들은 갖고 있지 않기 때문이다.) 관계형 데이터베이스에서는 인덱스는 테이블 부분에 대한 하나의 사본이다.</em> 
<a href="https://ko.wikipedia.org/wiki/%EC%9D%B8%EB%8D%B1%EC%8A%A4_(%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4)">위키피디아(인덱스)</a></p>
<p>대부분의 관계형 데이터베이스는 인덱스 기능을 제공합니다. Mysql, Oracle, Postgresql은 물론 이번 포스팅에서 사용할 H2 데이터베이스 역시 인덱스 기능을 지원합니다.</p>
<pre><code class="language-bash">[Mysql]
SHOW INDEX FROM tablename; ## Table 내 인덱스 조회
ALTER TABLE tablename ADD INDEX indexname (column1, column2 ...); ## Table에 새로운 인덱스 추가</code></pre>
<h3 id="성능-조회-테스트">성능 조회 테스트</h3>
<p>성능 조회는 테스트를 위하여 Gym과 Member 테이블을 만들었습니다.</p>
<pre><code class="language-sql">
create table Gym (
    gym_id            bigint primary key,      ## 8byte
    name             varchar(255),            ## 255byte
    location        varchar(255),            ## 255byte
    address            varchar(255),            ## 255byte
    phone_number    varchar(255),            ## 255byte
    is_open            tinyint,                ## 1byte
    open_time        timestemp,                ## 4byte
    close_time        timestemp                ## 4byte
); ## 총 1037byte

create table Member (
    member_id        bigint primary key,        ## 8byte
    real_name        varchar(255),            ## 255byte
    gym_name        varchar(255),            ## 255byte
    desc            varchar(512),            ## 512byte
    test_str1        varchar(255),            ## 255byte
    test_str2        varchar(255),            ## 255byte
    test_str3        varchar(255),            ## 255byte
    append_date        timestemp,                ## 4byte
    update_date        timestemp                ## 4byte

); ## 총 1803 byte</code></pre>
<p>Gym 테이블에는 15000개의 데이터가, Member 테이블에는 30000개의 데이터가 들어가있습니다. 
데이터 조회를 위한 Spring 프로젝트를 생성하여 전체 조회에 소요되는 시간을 계산해보았습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/jw_kim/post/ec88f45b-26b1-4da8-8eb8-94412386f278/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/jw_kim/post/cab22028-42f7-4d40-9cd2-b6df8588e0cc/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td>(단위:ms)</td>
<td></td>
</tr>
</tbody></table>
<p>특정 컬럼에 인덱스를 추가하여 where 연산으로 성능을 측정할수도 있지만, join인 연산을 활용하여 member 테이블과 gym 테이블이 조인된 30000개의 데이터를 조회하는 것으로 성능 테스트를 진행해보겠습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/jw_kim/post/95ceae07-56ea-4835-acbd-bf89c305dfaf/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/jw_kim/post/fa14872c-1ce4-461b-a2d1-e7b8685b0a88/image.png" alt=""></th>
</tr>
</thead>
</table>
<p>조인된 30000개의 데이터를 조회하는데 무려 <strong>19221ms(19sec)</strong>의 시간이 소요되었습니다. 어떤 웹 페이지에서 데이터 목록을 조회하는데 19초동안이나 응답이 없다는 것은 말도 안되는 응답 시간입니다.</p>
<h4 id="인덱스-추가-후-성능-테스트">인덱스 추가 후 성능 테스트</h4>
<p>이제 join 조건이 되는 Gym 테이블의 name 컬럼에 인덱스를 추가해주겠습니다.</p>
<pre><code class="language-sql">[H2]
create index gym_name on gym(name);
</code></pre>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/80f2a559-020e-4e8b-9334-e502ccd1923f/image.png" alt=""></p>
<p>이제 앞서 테스트했던 join 연산과 동일한 로직을 실행하여 실행 시간을 비교해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jw_kim/post/84aaba25-e132-4fe6-83b9-e5ac7a77edca/image.png" alt=""></p>
<p>그 결과 연산 수행 시간이 <strong>19221ms -&gt; 1792ms</strong>로 10배 이상의 성능 향상이 된 결과를 볼 수 있습니다.</p>
<p>이처럼 데이터베이스에 적절한 인덱스를 추가하는 간단한 작업만으로도 성능을 폭발적으로 향상 시킬 수 있습니다. 실제 제가 있는 회사에서는 방대한 데이터로 인하여 join 연산 수행 중에 결과값을 얻지 못하고 쿼리가 죽어버리는 경우가 있었습니다. 문제의 테이블에는 join 연산에 사용되는 컬럼에 어떠한 인덱스도 추가되어있지 않아 간단하게 인덱스만을 추가하는 작업으로 수행 시간을 평균 900ms까지 줄일 수 있었습니다.</p>
<p>이처럼 만약 실무에서 쿼리가 실행되는 데 과도하게 시간이 많이 걸린다면 연관된 테이블을 확인하여 인덱스를 추가하는 방안을 고려해 볼 수 있겠습니다.</p>
<p>다음 포스트로는 인덱스만으로는 더 이상 조회 성능의 향상을 기대하기 어려울 때 고려해 볼 수 있는 캐시 메모리의 적용을 redis적용 예제를 진행하며 알아보겠습니다.</p>
]]></description>
        </item>
    </channel>
</rss>