<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>웅이의 기록하는 공간</title>
        <link>https://velog.io/</link>
        <description>https://giwoong01.tistory.com/</description>
        <lastBuildDate>Mon, 16 Mar 2026 06:12:19 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>웅이의 기록하는 공간</title>
            <url>https://velog.velcdn.com/images/dev_choi0409/profile/976da7d7-f06d-4961-a7ff-6ebb161db743/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 웅이의 기록하는 공간. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dev_choi0409" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[1GB 서버의 반복 OOM, 지표 기반 모니터링 시스템으로 해결하기까지]]></title>
            <link>https://velog.io/@dev_choi0409/1GB-%EC%84%9C%EB%B2%84%EC%9D%98-%EB%B0%98%EB%B3%B5-OOM-%EC%A7%80%ED%91%9C-%EA%B8%B0%EB%B0%98-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9C%BC%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@dev_choi0409/1GB-%EC%84%9C%EB%B2%84%EC%9D%98-%EB%B0%98%EB%B3%B5-OOM-%EC%A7%80%ED%91%9C-%EA%B8%B0%EB%B0%98-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9C%BC%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Mon, 16 Mar 2026 06:12:19 GMT</pubDate>
            <description><![CDATA[<p>운영 중인 일정 관리 서비스(끄적끄적)를 모니터링하던 중
약 11일 주기로 서버가 다운되는 문제를 발견했습니다.</p>
<p>처음에는 단순한 리소스 부족 문제처럼 보였지만,
분석을 진행하면서 단일 지표만으로 설명하기 어려운 장애 패턴이 존재한다는 것을 알게 되었습니다.</p>
<p>이번 글에서는 Prometheus 메트릭 상관 분석을 통해
장애를 조기에 탐지하고 대응 체계를 개선한 과정을 정리해보려고 합니다.</p>
<h2 id="문제-상황">문제 상황</h2>
<p>서비스를 운영하던 중 약 11일 내외 주기로 서버가 다운되는 문제가 반복적으로 발생했습니다.
운영 환경은 대략 다음과 같았습니다.</p>
<ul>
<li>Oracle Cloud VM.Standard.E2.1.Micro (1GB RAM + 2GB Swap)</li>
<li>Docker 기반 운영<ul>
<li>Kafka</li>
<li>Redis</li>
<li>Spring Boot 애플리케이션 (Dev, Blue/Green 배포)</li>
<li>Prometheus</li>
<li>Grafana</li>
</ul>
</li>
</ul>
<p>장애가 발생할 때의 공통적인 패턴은 다음과 같았습니다.</p>
<pre><code>1. 메모리 사용률이 서서히 증가
2. Swap 사용량이 빠르게 상승
3. 결국 OOM(Out Of Memory) 으로 프로세스 종료</code></pre><p>문제는 단순히 “메모리가 부족하다”가 아니었습니다.
왜 이 문제가 주기적으로 반복되는지, 어떤 조합의 지표가 문제를 설명하는지 빠르게 설명하기 어려웠습니다.</p>
<h2 id="단일-임계치-알림의-한계">단일 임계치 알림의 한계</h2>
<p>이미 Prometheus + Grafana 기반 모니터링 환경은 구축되어 있었고 처음에는 아주 단순한 알림만 설정해두었습니다.</p>
<pre><code>Memory usage &gt; 80% → Alert</code></pre><p>하지만 실제 장애 상황을 분석해보니, 문제의 본질은 단일 지표가 아니었습니다.</p>
<p>장애가 발생할 때는 항상 다음 지표들이 동시에 변화했습니다.</p>
<ul>
<li>Memory 사용률 증가</li>
<li>Swap 사용량 증가</li>
<li>JVM 메모리 패턴 변화</li>
<li>특정 워크로드의 메모리 증가</li>
</ul>
<p>즉, 단일 임계치 기반 알림은 위험 신호는 알려주지만 맥락을 설명하지 못한다는 한계가 있었습니다.</p>
<h2 id="접근-방식">접근 방식</h2>
<p>반복되는 장애를 계기로 지표 기반 이상 패턴 탐지 시스템을 직접 설계했습니다.
프로젝트 이름은 “pingo”입니다.</p>
<p>목표는 다음과 같았습니다.</p>
<pre><code>1. 여러 메트릭을 함께 분석
2. 장애 전조 패턴 탐지
3. 원인 후보 도출
4. 대응 가이드 제공</code></pre><h2 id="모니터링-아키텍처">모니터링 아키텍처</h2>
<p>전체 구조는 다음과 같이 구성했습니다.</p>
<pre><code>node-exporter / Spring Boot Actuator
                ↓
            Prometheus
                ↓
              pingo
        (Backend / Frontend)
                ↓
             Discord</code></pre><p>각 구성 요소의 역할은 다음과 같습니다.</p>
<ul>
<li>node-exporter<ul>
<li>인스턴스 리소스 메트릭 수집</li>
<li>CPU / Memory / Swap / Disk / Network</li>
</ul>
</li>
<li>Spring Boot Actuator<ul>
<li>애플리케이션 메트릭 제공</li>
<li>JVM Memory / GC / Thread / Request Metrics</li>
</ul>
</li>
<li>Prometheus<ul>
<li>메트릭 스크랩</li>
<li>시계열 데이터 저장</li>
</ul>
</li>
<li>pingo<ul>
<li>메트릭 조회</li>
<li>상태 계산</li>
<li>알림 트리거</li>
<li>분석 지원</li>
</ul>
</li>
</ul>
<h2 id="인스턴스-메모리-사용률-계산">인스턴스 메모리 사용률 계산</h2>
<p><strong>인스턴스 상태 판단</strong>에는 <code>node-exporter</code> 메트릭을 활용했습니다.</p>
<p>대표적으로 사용한 지표는 다음과 같습니다.</p>
<ul>
<li><code>node_memory_MemTotal_bytes</code></li>
<li><code>node_memory_MemAvailable_bytes</code></li>
</ul>
<p>코드에서는 Prometheus 쿼리로 다음과 같이 가져옵니다.</p>
<ul>
<li><code>memoryTotalBytes = sum(node_memory_MemTotal_bytes{...})</code></li>
<li><code>memoryAvailableBytes = sum(node_memory_MemAvailable_bytes{...})</code></li>
</ul>
<p>사용 메모리는 다음과 같이 계산했습니다.</p>
<pre><code class="language-tsx">const memoryUsedBytes = memoryTotalBytes - memoryAvailableBytes;</code></pre>
<p>그리고 메모리 사용률을 계산합니다.</p>
<pre><code class="language-tsx">const memUsagePercent =
  memoryUsedBytes !== null &amp;&amp; memoryTotalBytes !== null &amp;&amp; memoryTotalBytes &gt; 0
    ? (memoryUsedBytes / memoryTotalBytes) * 100
    : null;</code></pre>
<p>예시</p>
<ul>
<li>Total: 1GB</li>
<li>Available: 300MB</li>
<li>Used: 700MB</li>
</ul>
<p>메모리 사용률:</p>
<ul>
<li>(700MB / 1GB) \times 100 ≒ 70%</li>
</ul>
<h2 id="상태-머신에-전달되는-메모리-비율">상태 머신에 전달되는 메모리 비율</h2>
<p>상태 머신에서는 퍼센트 대신 0~1 범위의 ratio 값을 사용합니다.</p>
<p>따라서 계산된 값을 다음과 같이 변환했습니다.</p>
<pre><code class="language-tsx">const memRatio = memUsagePercent !== null ? memUsagePercent / 100 : null;</code></pre>
<p>예시</p>
<table>
<thead>
<tr>
<th>Memory Usage</th>
<th>Ratio</th>
</tr>
</thead>
<tbody><tr>
<td>70%</td>
<td>0.70</td>
</tr>
<tr>
<td>85%</td>
<td>0.85</td>
</tr>
<tr>
<td>92%</td>
<td>0.92</td>
</tr>
</tbody></table>
<p>이 값이 상태 머신의 입력 값이 됩니다.</p>
<h2 id="상태-머신-기반-인스턴스-상태-관리">상태 머신 기반 인스턴스 상태 관리</h2>
<p>pingo에서는 단순한 조건문 대신 상태 머신(state machine) + 히스테리시스(hysteresis) 기반으로 인스턴스 상태를 관리했습니다.
흐름은 다음과 같습니다.</p>
<blockquote>
<p>metric → ratio → state machine → alert</p>
</blockquote>
<p>인스턴스 상태는 다음과 같이 정의했습니다.</p>
<ul>
<li><code>NORMAL | WARNING | CRITICAL | UNKNOWN</code></li>
</ul>
<p>기본 임계값(코드의 기본 설정값)은 다음과 같습니다.</p>
<ul>
<li>WARNING 진입 (<code>INSTANCE_MEMORY_WARN_RATIO</code>): 0.85 (85%)</li>
<li>CRITICAL 진입 (<code>INSTANCE_MEMORY_CRITICAL_RATIO</code>): 0.95 (95%)</li>
<li>WARNING 해제 (<code>INSTANCE_MEMORY_WARN_CLEAR_RATIO</code>): 0.83 (83%)<ul>
<li>기본값: <code>warnRatio - 0.02</code></li>
</ul>
</li>
<li>CRITICAL 해제 (<code>INSTANCE_MEMORY_CRITICAL_CLEAR_RATIO</code>): 0.93 (93%)<ul>
<li>기본값: <code>critRatio - 0.02</code></li>
</ul>
</li>
</ul>
<p>즉, 메모리 사용률이:</p>
<ul>
<li><p>85% 이상이면 <code>WARNING</code></p>
</li>
<li><p>95% 이상이면 <code>CRITICAL</code></p>
</li>
<li><p>떨어질 때는 93% 아래로 내려와야 <code>CRITICAL</code>에서 벗어나고,</p>
<p>  83% 아래로 내려와야 <code>WARNING</code>에서 <code>NORMAL</code>로 돌아가도록 설계되어 있습니다.</p>
</li>
</ul>
<p>이렇게 진입 임계값과 해제 임계값을 다르게 두는 방식이 바로 히스테리시스(hysteresis)이며, 경계값 근처에서 상태가 계속 튀는 문제를 줄여줍니다.</p>
<h2 id="알림-노이즈를-줄인-방식">알림 노이즈를 줄인 방식</h2>
<p>모니터링 시스템에서 자주 발생하는 문제는 알림 노이즈(Alert Noise) 입니다.
알림이 너무 자주 울리면, 결국 아무도 보지 않게 됩니다.
이를 줄이기 위해 pingo에서는 다음 전략을 적용했습니다.</p>
<ol>
<li>상태 전이 기반 알림<ul>
<li>매 수집 주기마다 알림을 보내지 않고,</li>
<li><code>NORMAL → WARNING</code>, <code>WARNING → CRITICAL</code> 같은 상태 변화가 있을 때만 알림을 발생시켰습니다.</li>
</ul>
</li>
<li>Cooldown 적용<ul>
<li>같은 이벤트가 반복되는 것을 막기 위해,</li>
<li>룰별로 <code>cooldownSeconds</code>를 두고 그 안에서는 동일 상태 전이 알림을 재발송하지 않도록 했습니다.</li>
</ul>
</li>
<li>이벤트 이력 저장<ul>
<li>다음 이벤트 타입을 기록했습니다.<ul>
<li><code>FIRING</code></li>
<li><code>RESOLVED</code></li>
<li><code>NO_DATA</code></li>
<li><code>DATASOURCE_ERROR</code></li>
</ul>
</li>
</ul>
</li>
</ol>
<p>이 방식으로 실제 대응이 필요한 시점의 신호 밀도를 높일 수 있었습니다.</p>
<h2 id="보조-분석으로서의-ai-활용">보조 분석으로서의 AI 활용</h2>
<p>이상 상황이 탐지된 이후에는 수집된 메트릭 요약을 기반으로 보조 분석 기능을 제공했습니다.</p>
<p>AI는 다음 정보를 생성합니다.</p>
<ul>
<li>원인 후보</li>
<li>점검 체크리스트</li>
<li>대응 액션 초안</li>
</ul>
<p>다만 중요한 원칙은 다음이었습니다.</p>
<ul>
<li>AI가 운영 판단을 대체하지 않는다.</li>
<li>AI는 옵션 기능으로 제공하고,</li>
<li>실패하더라도 기본 룰 기반 분석만으로도 동작하도록 설계했다.</li>
</ul>
<h2 id="실제-조치-사례">실제 조치 사례</h2>
<p>저사양 VM 환경에서 반복되는 메모리 압박을 완화하기 위해
워크로드별 메모리 예산을 재설계했습니다.
예를 들어 Kafka 컨테이너의 JVM 힙을 명시적으로 제한했습니다.</p>
<pre><code class="language-yaml">environment:
  KAFKA_HEAP_OPTS: &quot;-Xms256m -Xmx256m&quot;</code></pre>
<p>또한 Docker Compose에서 컨테이너 리소스 상한을 설정했습니다.</p>
<pre><code class="language-yaml">deploy:
  resources:
    limits:
      memory: 300M</code></pre>
<p>추가적으로 다음 항목을 점검했습니다.</p>
<ul>
<li>로그 보존 정책</li>
<li>JVM 옵션</li>
<li>컨테이너 메모리 상한</li>
</ul>
<h2 id="결과">결과</h2>
<p>적용 이후 다음과 같은 변화를 확인할 수 있었습니다.</p>
<ul>
<li>메모리 피크 구간 완화</li>
<li>Swap 급증 구간 감소</li>
<li>장애 대응 시 원인 분석 시간 단축</li>
</ul>
<p>가장 큰 변화는 다음이었습니다.</p>
<blockquote>
<p>같은 문제가 다시 발생했을 때,
더 빠르게 탐지하고 대응할 수 있는 구조를 만들었다는 점</p>
</blockquote>
<h2 id="이번-경험에서-얻은-기준">이번 경험에서 얻은 기준</h2>
<p>이번 경험을 통해 다음과 같은 운영 기준을 정리할 수 있었습니다.</p>
<ul>
<li>장애는 로그보다 지표에서 먼저 전조가 나타나는 경우가 많다.</li>
<li>단일 임계치 알림만으로는 운영 의사결정에 한계가 있다.</li>
<li>상태 전이 / 쿨다운 / 히스테리시스 설계가 알림 품질을 좌우한다.</li>
<li><code>NO_DATA</code>와 <code>DATASOURCE_ERROR</code>는 반드시 분리해야 한다.</li>
<li>AI는 의사결정 보조 도구로 활용할 때 가장 안정적이다.</li>
</ul>
<h2 id="프로젝트-링크">프로젝트 링크</h2>
<ul>
<li>GitHub: <a href="https://github.com/giwoong01/pingo">https://github.com/giwoong01/pingo</a></li>
</ul>
<h2 id="마무리">마무리</h2>
<p>이번 작업은 단순히 모니터링 화면을 추가한 것이 아니라, 재발하는 장애에 대응하기 위한 운영 구조를 정리하는 과정이었습니다.</p>
<p>다음 단계로는</p>
<ul>
<li>로그 계층(Loki) 연동</li>
<li>메트릭 + 로그 상관 분석 확장</li>
</ul>
<p>을 진행할 계획입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[학습 기록] 비관적 락과 분산 락 사이에서 성능 최적점 찾아보기]]></title>
            <link>https://velog.io/@dev_choi0409/%ED%95%99%EC%8A%B5-%EA%B8%B0%EB%A1%9D-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EA%B3%BC-%EB%B6%84%EC%82%B0-%EB%9D%BD-%EC%82%AC%EC%9D%B4%EC%97%90%EC%84%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%EC%A0%90-%EC%B0%BE%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@dev_choi0409/%ED%95%99%EC%8A%B5-%EA%B8%B0%EB%A1%9D-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EA%B3%BC-%EB%B6%84%EC%82%B0-%EB%9D%BD-%EC%82%AC%EC%9D%B4%EC%97%90%EC%84%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%EC%A0%90-%EC%B0%BE%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Fri, 29 Aug 2025 16:25:47 GMT</pubDate>
            <description><![CDATA[<h2 id="1-들어가며">1. 들어가며</h2>
<p>개인적으로 동시성 제어에 대해 공부하던 중, &#39;DB 부하를 줄이기 위해 분산 락을 도입하는 것이 항상 최적의 성능을 보장할까?&#39; 라는 호기심이 생겼고, 이 질문에 대한 답을 데이터 기반으로 찾고자 테스트를 설계하고 진행했습니다.</p>
<h2 id="2-시나리오-설정-한정-재고와-몰려드는-주문">2. 시나리오 설정: 한정 재고와 몰려드는 주문</h2>
<p>먼저, 동시성 문제를 테스트하기 위해 JPA의 비관적 락(Pessimistic Lock)을 사용한 기본적인 재고 차감 로직을 작성했습니다.</p>
<pre><code class="language-java">// 비관적 락 + DB 재고 확인
@Transactional
public void decreaseStock_DB_LOCK_DB_CHECK(Long productId, int quantity) {
    long start = System.currentTimeMillis();

    try {
        // 상품 ROW에 비관적 락 적용
        Product product = productRepository.findByIdForUpdate(productId)
                .orElseThrow(() -&gt; new IllegalArgumentException(&quot;상품이 존재하지 않습니다.&quot;));

        long orderedCount = orderRepository.countByProduct(product);
        if (orderedCount &gt;= product.getStock()) {
            throw new IllegalStateException(&quot;재고 부족&quot;);
        }

        Order order = new Order(product);
        orderRepository.save(order);

        product.decreaseStock(quantity);
    } finally {
        long end = System.currentTimeMillis();
        System.out.printf(&quot;조합1 실행 시간: %d ms%n&quot;, (end - start));
    }
}</code></pre>
<p>이 방식은 데이터 정합성을 완벽하게 보장하지만, 실제 서비스 환경에서는 DB에 큰 부하를 주어 병목 현상을 일으킬 수 있습니다. 여기서 “DB 부하를 줄이면서 동시성을 제어할 방법은 없을까?”라는 궁금증이 생겼고, 해결책으로 Redis를 떠올렸습니다.</p>
<h2 id="3-왜-비관적-락과-분산-락을-비교했는가">3. 왜 비관적 락과 분산 락을 비교했는가?</h2>
<p>테스트를 설계하기 전, 다른 동시성 제어 방식들도 검토했습니다. 하지만 이번 시나리오에 가장 적합하다고 판단한 두 가지를 선택하고 집중하기로 했습니다.</p>
<ul>
<li><p>synchronized를 제외한 이유: synchronized는 하나의 서버에서 단일 JVM 프로세스 내에서만 스레드 안전성을 보장합니다. 현대 웹 서비스는 대부분 여러 대의 서버로 운영되는 분산 환경이므로, synchronized만으로는 동시성 문제를 해결할 수 없어 비교 대상에서 제외했습니다.</p>
</li>
<li><p>낙관적 락(Optimistic Lock)을 제외한 이유: 낙관적 락은 &quot;충돌이 거의 발생하지 않을 것&quot;이라고 가정하는 방식입니다. 하지만 &#39;타임딜&#39;처럼 충돌이 매우 빈번할 것으로 예상되는 상황에서는 수많은 요청이 실패하고 재시도하는 과정에서 오히려 성능이 저하될 수 있어, 이번 시나리오와는 맞지 않다고 판단했습니다.</p>
</li>
<li><p>네임드 락(Named Lock)을 제외한 이유: MySQL의 GET_LOCK과 같은 네임드 락은 DB를 이용해 분산 락을 구현하는 좋은 방법입니다. 하지만 이번 테스트의 핵심 목표는 &quot;DB 중심의 제어&quot;와 &quot;DB 외부 솔루션(Redis)을 이용한 제어&quot;의 성능을 비교하는 것이었습니다. 네임드 락은 결국 DB에 의존하므로, 대표성을 가진 두 방식을 비교하는 것이 더 의미 있다고 생각했습니다.</p>
</li>
</ul>
<p>결론적으로, 분산 환경에서 가장 현실적인 두 대안인 비관적 락과 Redis 분산 락을 비교하는 것이 이번 학습 목표에 가장 부합했습니다.</p>
<h2 id="4-4가지-해결책-그리고-가설">4. 4가지 해결책, 그리고 가설</h2>
<p>DB 부하를 줄이는 목표를 가지고, 동시성 제어 주체(Lock)와 재고 확인 주체(Check)를 다르게 조합하여 총 4가지 시나리오를 설계하고 성능을 테스트했습니다.</p>
<h3 id="조합-1-비관적-락--db-재고-확인">[조합 1] 비관적 락 + DB 재고 확인</h3>
<ul>
<li>설명: 기본 로직. 성능 비교의 기준점입니다. (코드는 위 &#39;2. 시나리오 설정&#39; 참고)</li>
</ul>
<h3 id="조합-2-비관적-락--redis-재고-확인">[조합 2] 비관적 락 + Redis 재고 확인</h3>
<ul>
<li><p>설명: 데이터 정합성은 DB 락으로 보장하되, 부하가 심한 재고 확인 로직만 Redis의 원자적 연산으로 대체합니다.</p>
</li>
<li><p>코드:</p>
<pre><code class="language-java">  @Transactional
  public void decreaseStock_DB_LOCK_REDIS_CHECK(Long productId, int quantity) {
      long start = System.currentTimeMillis();

      try {
          // 상품 ROW에 비관적 락 적용
          Product product = productRepository.findByIdForUpdate(productId)
                  .orElseThrow(() -&gt; new IllegalArgumentException(&quot;상품이 존재하지 않습니다.&quot;));

          Long currentStock = decreaseStockInRedis(productId, quantity);

          Order order = new Order(product);
          orderRepository.save(order);

          product.updateStock(currentStock);
      } finally {
          long end = System.currentTimeMillis();
          System.out.printf(&quot;조합2 실행 시간: %d ms%n&quot;, (end - start));
      }
  }

  private Long decreaseStockInRedis(Long productId, int quantity) {
      String key = &quot;product:stock:&quot; + productId;
      Long currentStock = redisTemplate.opsForValue().decrement(key, quantity);

      if (currentStock &lt; 0) {
          redisTemplate.opsForValue().increment(key, quantity);
          throw new SoldOutException(&quot;재고가 부족합니다.&quot;);
      }
      return currentStock;
  }</code></pre>
</li>
</ul>
<h3 id="조합-3-redis-분산-락--db-재고-확인">[조합 3] Redis 분산 락 + DB 재고 확인</h3>
<ul>
<li><p>설명: DB 락 대신 Redisson 분산 락을 사용하여 DB 부하를 줄입니다. 락 획득 후 재고 확인 로직은 조합 1과 유사합니다.</p>
</li>
<li><p>코드:</p>
<pre><code class="language-java">  @Transactional
  public void decreaseStock_DIST_LOCK_DB_CHECK(Long productId, int quantity) {
      long start = System.currentTimeMillis();

      try {
          String lockKey = &quot;lock:product:&quot; + productId;
          RLock lock = redissonClient.getLock(lockKey);

          try {
              if (!lock.tryLock(10, 5, TimeUnit.SECONDS)) {
                  throw new RuntimeException(&quot;락 획득 실패&quot;);
              }

              Product product = productRepository.findById(productId)
                      .orElseThrow(() -&gt; new IllegalArgumentException(&quot;상품이 존재하지 않습니다.&quot;));

              long orderedCount = orderRepository.countByProduct(product);
              if (orderedCount &gt;= product.getStock()) {
                  throw new IllegalStateException(&quot;재고 부족&quot;);
              }

              Order order = new Order(product);
              orderRepository.save(order);

              product.decreaseStock(quantity);
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
              throw new RuntimeException(&quot;인터럽트&quot;, e);
          } finally {
              if (lock.isLocked() &amp;&amp; lock.isHeldByCurrentThread()) {
                  lock.unlock();
              }
          }
      } finally {
          long end = System.currentTimeMillis();
          System.out.printf(&quot;조합3 실행 시간: %d ms%n&quot;, (end - start));
      }
  }</code></pre>
</li>
</ul>
<h3 id="조합-4-redis-분산-락--redis-재고-확인">[조합 4] Redis 분산 락 + Redis 재고 확인</h3>
<ul>
<li><p>설명: 락과 재고 확인을 모두 Redis에서 처리하여 DB 의존성을 최소화합니다.</p>
</li>
<li><p>코드:</p>
<pre><code class="language-java">  @Transactional
  public void decreaseStock_DIST_LOCK_REDIS_CHECK(Long productId, int quantity) {
      long start = System.currentTimeMillis();

      try {
          String lockKey = &quot;lock:product:&quot; + productId;
          RLock lock = redissonClient.getLock(lockKey);

          try {
              if (!lock.tryLock(10, 5, TimeUnit.SECONDS)) {
                  throw new RuntimeException(&quot;락 획득 실패&quot;);
              }

              Product product = productRepository.findById(productId)
                      .orElseThrow(() -&gt; new IllegalArgumentException(&quot;상품이 존재하지 않습니다.&quot;));

              Long currentStock = decreaseStockInRedis(productId, quantity);

              Order order = new Order(product);
              orderRepository.save(order);

              product.updateStock(currentStock);
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
              throw new RuntimeException(&quot;인터럽트&quot;, e);
          } finally {
              if (lock.isLocked() &amp;&amp; lock.isHeldByCurrentThread()) {
                  lock.unlock();
              }
          }
      } finally {
          long end = System.currentTimeMillis();
          System.out.printf(&quot;조합4 실행 시간: %d ms%n&quot;, (end - start));
      }
  }

  private Long decreaseStockInRedis(Long productId, int quantity) {
      String key = &quot;product:stock:&quot; + productId;
      Long currentStock = redisTemplate.opsForValue().decrement(key, quantity);

      if (currentStock &lt; 0) {
          redisTemplate.opsForValue().increment(key, quantity);
          throw new IllegalStateException(&quot;재고가 부족합니다.&quot;);
      }

      return currentStock;
  }</code></pre>
</li>
</ul>
<h2 id="5-예상과-다른-테스트-결과">5. 예상과 다른 테스트 결과</h2>
<p>동시 요청 상황을 가정하여, ExecutorService로 스레드를 동시에 실행하고 각 로직의 순수 실행 시간을 측정하는 간단한 테스트 코드를 작성했습니다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;50개의 재고에 100개의 동시 주문 요청 테스트&quot;)
void concurrent_order_test() throws InterruptedException {
    Product product = productRepository.save(new Product(&quot;상품&quot;, 50L));
    String key = &quot;product:stock:&quot; + product.getId();
    redisTemplate.opsForValue().set(key, String.valueOf(product.getStock()));

    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    long start = System.currentTimeMillis();

    for (int i = 0; i &lt; threadCount; i++) {
        executorService.submit(() -&gt; {
            try {
                // 각 조합에 맞는 서비스 메소드 호출
                stockService.decreaseStock_... // 여기에 각 조합 메소드 이름 기입
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    long end = System.currentTimeMillis();
    System.out.printf(&quot;=== 실행 시간: %d ms ===&quot;, (end - start));

    // ... 결과 검증 로직 ...
}</code></pre>
<p>그리고 제 예상은 완전히 빗나갔습니다.</p>
<h3 id="단일-요청-평균-실행-시간"><strong>[단일 요청 평균 실행 시간]</strong></h3>
<p>테스트를 여러 번 실행한 결과, 한 가지 흥미로운 점을 발견했습니다. 2번이 가장 빠르고 3번이 가장 느린 것은 일관되었지만, 1번과 4번의 순위는 테스트 환경의 미세한 차이에 따라 뒤바뀌곤 했습니다.</p>
<ul>
<li>어떨 때는 2번 &lt; 1번 &lt; 4번 &lt; 3번</li>
<li>또 어떨 때는 2번 &lt; 4번 &lt; 1번 &lt; 3번</li>
</ul>
<table>
<thead>
<tr>
<th>1번 (비관적 락 + DB 재고 확인)</th>
<th>2번 (비관적 락 + Redis 재고 확인)</th>
<th>3번 (Redis 분산 락 + DB 재고 확인)</th>
<th>4번 (Redis 분산 락 + Redis 재고 확인)</th>
</tr>
</thead>
<tbody><tr>
<td><img src="https://velog.velcdn.com/images/dev_choi0409/post/966a7b46-4b25-43a4-9cd9-4eb85bf79623/image.png" alt="조합 1번"></td>
<td><img src="https://velog.velcdn.com/images/dev_choi0409/post/d51554dc-cf67-43be-be25-c7d2c228db7a/image.png" alt="조합 2번"></td>
<td><img src="https://velog.velcdn.com/images/dev_choi0409/post/12531863-36c1-4272-b90a-ae5f431fc1b5/image.png" alt="조합 3번"></td>
<td><img src="https://velog.velcdn.com/images/dev_choi0409/post/b4a2178f-aecc-4b73-9c12-013c67f59a5d/image.png" alt="조합 4번"></td>
</tr>
</tbody></table>
<blockquote>
<p>🚨 이 테스트의 의미는?</p>
<p>전문 부하 테스트 툴(k6, nGrinder 등)은 사용자 관점에서 네트워크 지연까지 포함한 전체 응답 시간을 측정하지만, 이번 테스트는 서버 내부에서 로직 자체가 얼마나 걸리는지에 집중했습니다. 이 방식만으로도 각 조합의 상대적인 성능을 비교하고 가장 큰 병목 지점을 찾는 데는 충분했습니다.</p>
</blockquote>
<p>가장 빠를 거라 예상했던 4번(Redis 분산 락 + Redis 재고 확인)이 기준점인 1번보다 느릴 때도 있었고, 전혀 기대하지 않았던 2번(비관적 락 + Redis 재고 확인)이 1위를 차지했습니다. 대체 왜 이런 결과가 나온 걸까요?</p>
<h2 id="6-결과-분석">6. 결과 분석</h2>
<p>각 작업의 숨겨진 비용을 생각하며 결과를 다시 해석해 보겠습니다.</p>
<ul>
<li>DB 재고 확인 (SELECT + COUNT): 디스크를 직접 읽는 매우 비싼 I/O 작업입니다.</li>
<li>Redis 재고 확인 (DECR): 메모리에서 바로 처리되는 매우 저렴한 작업입니다.</li>
<li>비관적 락: DB 커넥션을 점유하지만, 일단 연결되면 작업은 DB 내부에서만 이루어집니다</li>
<li>분산 락: Redis 서버와 락 획득/해제를 위해 네트워크 통신(RTT)이 반드시 발생합니다.</li>
</ul>
<p>이 관점으로 각 조합을 다시 봅시다.</p>
<ul>
<li><strong>1위</strong>: 2번 (비관적 락 + Redis 재고 확인)<ul>
<li>비용: DB Lock (비쌈) + Redis Check (저렴)</li>
<li>분석: 가장 비싼 &#39;DB 재고 확인&#39;을 가장 저렴한 &#39;Redis 재고 확인&#39;으로 대체한 효과가 결정적이었습니다. DB Lock의 부하는 여전하지만, 가장 큰 병목이 사라지면서 최고의 효율을 보여줬습니다.</li>
</ul>
</li>
<li><strong>2, 3위 경쟁</strong>: 1번(비관적 락 + DB 재고 확인) vs 4번(Redis 분산 락 + Redis 재고 확인)<ul>
<li>1번 비용: DB Lock (비쌈) + DB Check (매우 비쌈)</li>
<li>4번 비용: Redis Lock (네트워크 비용) + Redis Check (저렴)</li>
<li>분석: 여기서 순위가 뒤바뀌는 현상이 발생합니다. 이는 DB ‘내부 처리 비용&#39;과 &#39;네트워크 왕복 비용&#39; 사이의 미세한 줄다리기 때문입니다. 테스트 환경의 네트워크 상태나 DB 컨디션에 따라 어떤 날은 4번이, 어떤 날은 1번이 더 빠르게 측정될 수 있습니다. 이는 분산 락이 항상 비관적 락보다 빠르지 않다는 증거가 됩니다.</li>
</ul>
</li>
<li><strong>4위</strong>: 3번 (Redis 분산 락 + DB 재고 확인)<ul>
<li>비용: Redis Lock (네트워크 비용) + DB Check (매우 비쌈)</li>
<li>분석: 가장 느린 조합이었습니다. Redis와 통신하는 네트워크 비용을 지불하고, DB에 접속해서 가장 비싼 I/O 작업까지 수행했습니다. 두 시스템의 단점만 합쳐진 결과입니다.</li>
</ul>
</li>
</ul>
<h2 id="7-결론"><strong>7. 결론</strong></h2>
<p>이번 테스트를 통해 몇 가지 중요한 교훈을 얻었습니다.</p>
<ol>
<li>가장 큰 병목부터 해결하라: 이번 테스트에서는 Lock보다 SELECT와 COUNT로 인한 DB I/O가 훨씬 큰 성능 저하의 원인이었습니다.</li>
<li>분산 락은 만능이 아니다: 분산 락은 DB 부하를 줄여주지만, ‘네트워크 지연’이라는 새로운 비용을 청구합니다. 테스트 결과에서 보았듯, 서비스 환경에 따라 비관적 락이 더 빠를 수도 있습니다.</li>
<li>추측하지 말고 측정하라: 막연한 기대 대신 실제 데이터를 기반으로 판단하는 것이 얼마나 중요한지 다시 한번 깨달았습니다.</li>
</ol>
<p>결론적으로 이번 테스트 환경에서는 2번, 즉 비관적 락으로 데이터 정합성을 보장하되, 부하가 심한 재고 확인만 Redis로 대체하는 하이브리드 방식이 가장 효율적이었습니다.</p>
<p>기술을 선택할 때 단순히 ‘새롭다’, ‘좋아 보인다’가 아니라, 그 기술의 동작 원리와 트레이드오프를 명확히 이해하고 시스템에 맞는 최적의 조합을 찾아가는 과정이 중요하다는 것을 배운 값진 경험이었습니다.</p>
<blockquote>
<p>💡 마지막으로, 이번 테스트는 단일 서버 환경에서 서버 내부 로직 자체가 얼마나 걸리는지에 초점을 맞췄습니다.
멀티 서버 환경이나 실제 트래픽 기반 부하 테스트를 진행한다면 네트워크 지연, 락 경쟁 양상 등이 달라져서 성능 순서가 달라질 수 있습니다.
그렇기 때문에 이번 결과는 특정 조건에서의 상대적 비교일 뿐, 절대적인 성능 지표라기보다는 “트레이드오프를 이해하고 적합한 방식을 선택하는 과정”에 참고 자료로 활용하는 것이 좋을 것 같습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[깨끗한 JPA 엔티티와 더러운 테스트 사이, ID 생성자 딜레마]]></title>
            <link>https://velog.io/@dev_choi0409/%EA%B9%A8%EB%81%97%ED%95%9C-JPA-%EC%97%94%ED%8B%B0%ED%8B%B0%EC%99%80-%EB%8D%94%EB%9F%AC%EC%9A%B4-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%82%AC%EC%9D%B4-ID-%EC%83%9D%EC%84%B1%EC%9E%90-%EB%94%9C%EB%A0%88%EB%A7%88</link>
            <guid>https://velog.io/@dev_choi0409/%EA%B9%A8%EB%81%97%ED%95%9C-JPA-%EC%97%94%ED%8B%B0%ED%8B%B0%EC%99%80-%EB%8D%94%EB%9F%AC%EC%9A%B4-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%82%AC%EC%9D%B4-ID-%EC%83%9D%EC%84%B1%EC%9E%90-%EB%94%9C%EB%A0%88%EB%A7%88</guid>
            <pubDate>Thu, 21 Aug 2025 10:32:04 GMT</pubDate>
            <description><![CDATA[<h2 id="1-들어가며">1. 들어가며</h2>
<p>최근 한 기업의 백엔드 개발 과제를 진행하며 흥미로운 딜레마에 빠졌습니다. 과제의 요구사항에 따라 JPA로 엔티티를 설계하며, 저는 제가 사랑하는 원칙을 충실히 따랐습니다.</p>
<blockquote>
<p>ID는 데이터베이스가 생성하는 것, 고로 내 코드는 ID 없는 순수한 도메인 객체만 다뤄야 한다.</p>
</blockquote>
<p>@GeneratedValue가 붙은 ID 필드를 보며 흐뭇해하고, ID를 제외한 생성자를 만들며 &#39;이것이 바로 객체지향적인 설계지&#39;라며 뿌듯해했습니다.</p>
<p>하지만 단위 테스트 작성 단계에 들어서는 순간, 이 아름다운 원칙은 현실의 벽에 부딪혔습니다. Mock 객체를 만들고 검증 로직을 짜던 제 테스트 코드가 외쳤습니다.</p>
<p><strong>&quot;그래서 이 객체 ID는 뭔데?&quot;</strong></p>
<p>… 🤦🏻‍♂️🤦🏻‍♂️🤦🏻‍♂️</p>
<p>이 글은 과제를 해결하는 과정에서 마주한, &#39;깨끗한 엔티티 설계&#39;와 &#39;현실적인 테스트 코드&#39; 사이의 딜레마를 어떻게 해결했는지에 대한 기록입니다.</p>
<h2 id="2-이상ideal을-향한-원칙-왜-우리는-id를-생성자에-넣기-싫어할까">2. <strong>이상(Ideal)을 향한 원칙</strong>: 왜 우리는 ID를 생성자에 넣기 싫어할까?</h2>
<p>ID를 엔티티 생성자에서 받지 않으려는 이유는 명확합니다.</p>
<h3 id="책임의-분리">책임의 분리</h3>
<p>ID 생성 책임은 DB에게 있습니다. 엔티티는 비즈니스 데이터만 관리하면 됩니다.</p>
<h3 id="객체-상태-구분">객체 상태 구분</h3>
<p>id = null 인 엔티티는 “아직 영속화되지 않은 객체”라는 의미를 가집니다.
생성자에서 ID를 받게 되면 이 구분이 모호해집니다.</p>
<h3 id="실수-방지">실수 방지</h3>
<p>만약 ID를 받는 생성자가 있으면, 운영 코드에서 실수로 해당 생성자를 호출할 위험이 있습니다.
이는 @GeneratedValue 전략과 충돌합니다.</p>
<h2 id="3-현실reality의-벽-테스트-코드는-왜-id를-요구하는가">3. <strong>현실(Reality)의 벽</strong>: 테스트 코드는 왜 ID를 요구하는가?</h2>
<p>반대로, 테스트 코드는 왜 자꾸 ID를 원할까요?</p>
<h3 id="mocking의-현실">Mocking의 현실</h3>
<p>서비스 레이어 테스트에서 repository.findById(1L)이 호출되면, 반환할 객체는 id = 1L을 갖고 있어야 합니다.</p>
<h3 id="검증의-필요성">검증의 필요성</h3>
<p>assertThat(result.getCouponId()).isEqualTo(1L); 같은 검증을 하려면, 애초에 Mock 객체의 ID가 세팅돼 있어야 합니다.</p>
<p>즉, 테스트는 “ID가 있는 객체”를 필요로 합니다.</p>
<p>이 지점에서 순수한 엔티티 vs 테스트의 현실이라는 딜레마가 발생합니다.</p>
<h2 id="4-딜레마-해결을-위한-3가지-방법">4. 딜레마 해결을 위한 3가지 방법</h2>
<h3 id="1-테스트-전용-생성자">1. 테스트 전용 생성자</h3>
<p>운영 코드에서는 쓰지 않지만, 테스트 편의성을 위해 ID까지 받는 생성자나 ofTest(…) 같은 팩토리 메소드를 추가합니다.</p>
<p>테스트 코드가 깔끔해지고, 타입 세이프하다는 장점이 생기지만, 운영 코드에 테스트 전용 코드가 섞여 들어간다는 단점이 있습니다.</p>
<pre><code class="language-java">public CouponBenefit(Long id, String name, ...) {
    this.id = id; -&gt; id가 있는 생성자 추가
    this.name = name;
    ...
}</code></pre>
<h3 id="2-reflectiontestutils">2. ReflectionTestUtils</h3>
<p>운영 코드는 ID 없는 순수 상태를 유지하고, 테스트에서만 리플렉션으로 id를 주입합니다.</p>
<p>운영 코드가 100% 순수하게 유지되는 장점이 있는 반면에 리팩토링에 취약하고, 테스트 가독성이 떨어집니다.</p>
<pre><code class="language-java">ReflectionTestUtils.setField(couponBenefit, &quot;id&quot;, 1L);</code></pre>
<h3 id="3-builder--allargsconstructor">3. @Builder + @AllArgsConstructor</h3>
<p>@Builder + @AllArgsConstructor를 통해 ID까지 포함한 빌더를 만들고, 운영 코드에서는 .id()를 쓰지 않는 약속을 지킵니다.</p>
<p>운영 코드와 테스트 코드 모두에서 생성 메커니즘이 통일되지만, 약속을 어기면 ID를 잘못 세팅할 수 있습니다.</p>
<pre><code class="language-java">CouponBenefit benefit = CouponBenefit.builder()
    .id(1L) -&gt; 테스트에서만 사용
    .name(&quot;테스트 쿠폰&quot;)
    .build();</code></pre>
<h2 id="5-결론-정답은-없지만-더-나은-선택이-있다">5. 결론: 정답은 없지만, 더 나은 선택이 있다.</h2>
<p>정답은 없습니다.</p>
<p>팀마다 코드의 순수성을 더 중시할 수도 있고, 테스트 편의성을 더 중시할 수도 있습니다.</p>
<p>운영 코드의 순수성을 최우선으로 한다면? → ReflectionTestUtils를 사용하면됩니다.
테스트의 안정성과 가독성을 중시한다면? → ID 생성자 or @Builder를 사용합니다.</p>
<h2 id="6-그래서-나의-선택은">6. 그래서 나의 선택은?</h2>
<p>저는 최종적으로 @Builder + private 생성자 방식을 선택했습니다.</p>
<pre><code class="language-java">@Builder
private CouponBenefit(Integer id, ... , ...) {
    this.id = id;
        .
        .
        .
}</code></pre>
<p>이것은 앞서 설명한 방법들의 장점을 결합한, 아주 세련된 해결책입니다.</p>
<ol>
<li>생성자가 private이므로, 운영 코드에서 개발자가 new CouponBenefit(id, …)를 호출할 실수 자체를 아예 차단합니다.</li>
<li>객체 생성은 반드시 빌더를 통해서만 가능하도록 강제합니다.</li>
<li>테스트 코드에서는 .id(1L)를 통해 안전하고 명확하게 ID를 설정할 수 있습니다.</li>
</ol>
<p>저는 이 방식 덕분에 운영 코드와 테스트 코드의 객체 생성 전략을 하나로 통일할 수 있었고, 테스트 코드도 훨씬 읽기 좋아졌습니다.</p>
<blockquote>
<p>💡 <strong>여러분은 어떤 방법을 선택하고 계신가요?</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[내 개발 습관이 만든 첫 라이브러리: 공통 응답과 예외 처리]]></title>
            <link>https://velog.io/@dev_choi0409/%EB%82%B4-%EA%B0%9C%EB%B0%9C-%EC%8A%B5%EA%B4%80%EC%9D%B4-%EB%A7%8C%EB%93%A0-%EC%B2%AB-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EA%B3%B5%ED%86%B5-%EC%9D%91%EB%8B%B5%EA%B3%BC-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@dev_choi0409/%EB%82%B4-%EA%B0%9C%EB%B0%9C-%EC%8A%B5%EA%B4%80%EC%9D%B4-%EB%A7%8C%EB%93%A0-%EC%B2%AB-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EA%B3%B5%ED%86%B5-%EC%9D%91%EB%8B%B5%EA%B3%BC-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Sat, 12 Jul 2025 02:49:15 GMT</pubDate>
            <description><![CDATA[<p>개발을 하다 보면, “이 코드… 또 복붙이네?” 하는 순간이 자주 찾아옵니다.
저도 마찬가지였고, 결국 그런 습관이 하나의 라이브러리를 만들게 했습니다.</p>
<p>이번 글에서는 제가 왜 공통 응답 템플릿과 예외 처리 로직을 라이브러리로 분리하게 되었는지, 어떻게 설계했는지, 그리고 어떻게 적용하는지를 공유해보려고 합니다.</p>
<h2 id="1-왜-만들었나요">1. 왜 만들었나요?</h2>
<p>Spring Boot로 프로젝트를 시작할 때마다 반복되는 작업들이 있었습니다.</p>
<ul>
<li>매번 생성해야 하는 공통 응답 템플릿</li>
<li>프로젝트마다 중복되는 @RestControllerAdvice</li>
<li>예외 상황에 따라 각각 정의해야 하는 HTTP 상태 코드</li>
</ul>
<p>기능은 단순했지만, “어차피 똑같은 로직을 왜 계속 만들고 있지?” 라는 생각이 들었습니다.</p>
<p>그래서 이왕이면 아예 라이브러리로 만들어서 다른 프로젝트에서도 바로 쓸 수 있게 만들기로 결심했습니다.</p>
<h2 id="2-반복되는-문제들">2. 반복되는 문제들</h2>
<p>프로젝트를 여러 개 만들면서 느낀 문제는 일관성 없는 예외 처리 였습니다.</p>
<p>응답 메시지  형식이 프로젝트마다 다르고, HTTP 상태 코드 대응도 통일되지 않았습니다.
그리고 전역 예외 처리 핸들러도 매번 작성해야 했습니다.</p>
<p>이런 문제들이 사소하지만 협업이나 유지보수에도 좋지 않았고, 자잘한 실수를 가져올 수 있습니다.</p>
<p>그래서! 이 문제들을 해결하기 위해 아래와 같은 구성의 라이브러리를 만들었습니다.</p>
<h2 id="3-어떤-기능을-제공하나요">3. 어떤 기능을 제공하나요?</h2>
<p><img src="https://velog.velcdn.com/images/dev_choi0409/post/e55b1647-6720-4e6f-bbe3-4b9af8e6b332/image.png" alt="image"></p>
<ul>
<li><code>RspTemplate&lt;T&gt;</code><ul>
<li>공통 응답 템플릿</li>
</ul>
</li>
<li><code>~~GroupExcepion</code><ul>
<li>일관된 예외 체계</li>
</ul>
</li>
<li><code>ControllerAdvice</code><ul>
<li>전역 예외 처리</li>
</ul>
</li>
<li><code>AutoConfig</code><ul>
<li>Spring에서 자동으로 스캔될 수 있도록 설정</li>
</ul>
</li>
</ul>
<p>라이브러리는 Github에 공개했고, JitPack으로 쉽게 의존성을 추가할 수 있습니다.</p>
<h2 id="4-사용-방법">4. 사용 방법</h2>
<p>build.gradle에 의존성을 추가합니다.</p>
<pre><code>repositories {
    mavenCentral()
    maven { url &#39;https://jitpack.io&#39; } &lt;- 추가
}

dependencies {
    implementation &#39;com.github.giwoong01.spring-api-common:v0.1.5&#39; &lt;- 추가
}</code></pre><p>⭐️ 최신 버전은 <a href="https://github.com/giwoong01/spring-api-common/releases">Release 페이지</a> 참고</p>
<p>이 외 별도 설정은 필요하지 않습니다.</p>
<p>기능 사용법은 아래 노션 문서에 정리해두었습니다.</p>
<h2 id="5-github--문서"><strong>5. GitHub &amp; 문서</strong></h2>
<ul>
<li>🔗 GitHub: <a href="https://github.com/giwoong01/spring-api-common">https://github.com/giwoong01/spring-api-common</a></li>
<li>📘 라이브러리 &amp; 기능 별 사용법 문서: <a href="https://www.notion.so/22d61205022e804185a5ffdde1e92813?pvs=21">Notion 바로가기</a></li>
</ul>
<h2 id="6-마무리"><strong>6. 마무리</strong></h2>
<p>지금 당장은 작아 보일지 몰라도, 이런 작은 귀찮음의 반복이 결국 좋은 개발 습관을 만들고 더 나아가 도구를 만드는 개발자로 나아가는 길이라고 생각합니다.</p>
<p>더 나아가 자주 사용하는 기능들을 지속적으로 확장하고 계속 발전시킬 예정입니다.</p>
<blockquote>
<p>☺️ 더 나은 개선 방향이나 궁금한 점이 있다면 언제든지 의견 남겨주세요!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE, 어디까지 써봤니? 분산 서버 환경에서의 연결 유지와 Kafka 활용 (2편)]]></title>
            <link>https://velog.io/@dev_choi0409/SSE-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EC%8D%A8%EB%B4%A4%EB%8B%88-%EB%B6%84%EC%82%B0-%EC%84%9C%EB%B2%84-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C%EC%9D%98-%EC%97%B0%EA%B2%B0-%EC%9C%A0%EC%A7%80%EC%99%80-Kafka-%ED%99%9C%EC%9A%A9-2%ED%8E%B8</link>
            <guid>https://velog.io/@dev_choi0409/SSE-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EC%8D%A8%EB%B4%A4%EB%8B%88-%EB%B6%84%EC%82%B0-%EC%84%9C%EB%B2%84-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C%EC%9D%98-%EC%97%B0%EA%B2%B0-%EC%9C%A0%EC%A7%80%EC%99%80-Kafka-%ED%99%9C%EC%9A%A9-2%ED%8E%B8</guid>
            <pubDate>Tue, 15 Apr 2025 03:31:19 GMT</pubDate>
            <description><![CDATA[<h2 id="1-들어가며">1. 들어가며</h2>
<p>이전 1편에서는 SSE(Server-Sent Events)의 개념과 단일 서버 환경에서의 적용 과정을 다루었습니다.</p>
<p>이번글에서는 운영환경에서의 확장성과 안전성 확보를 중심으로, Redis를 통한 SSE 연결 관리와 Kafka를 통한 다중 서버 메시지 브로드캐스트 방식을 다루겠습니다.</p>
<p>무중단 배포, 스케일 아웃, 서버 장애 대응 등 실전 상황에서 고려할 요소들이 많아지면서, 단일 서버에서 관리되던 SSE 연결 방식에는 한계가 있습니다. 이를 해결하기 위해 저는 Redis와 Kafka를 도입하였습니다.</p>
<hr>
<h2 id="2-다중-서버-환경에서의-sse-문제점">2. 다중 서버 환경에서의 SSE 문제점</h2>
<p>단일 서버 환경에서는 각 사용자와의 SSE 연결을 SseEmitter를 통해 서버 메모리에서 직접 관리하면 충분합니다.</p>
<p>하지만 서비스가 확장되어 서버가 여러 대로 분산되면, 상황이 달라집니다.</p>
<ul>
<li>사용자가 연결된 서버에만 메시지가 전송됩니다.</li>
<li>무중단 배포나 서버 장애가 발생하면 연결이 끊어지고, 복구가 어려워집니다.</li>
<li>서버 메모리만으로는 전체 연결 상태를 추적하기 어렵습니다.</li>
</ul>
<p>결과적으로, 서버 간 연결 상태 공유와 메시지 전달이 보장되지 않기 때문에 확장성과 안정성 모두에 제약이 생기게 됩니다.</p>
<p>이러한 문제를 해결하기 위해, 저는 다음과 같은 구조로 접근했습니다.</p>
<hr>
<h2 id="3-redis를-활용한-연결-상태-공유">3. Redis를 활용한 연결 상태 공유</h2>
<p>다중 서버 환경에서 각 사용자의 연결 상태를 공유하기 위해, Redis를 캐시 저장소로 활용하여 SSE 연결 상태를 공유하기로 했습니다.</p>
<h3 id="3-1-저장-방식">3-1. 저장 방식</h3>
<p>SSE 연결 정보를 저장하기 위해 Redis Hash 구조를 사용합니다.</p>
<p>Hash는 하나의 키 아래 여러 필드와 값이 저장할 수 있기 때문에,</p>
<p>sse_connections라는 키에 emitterId와 해당 사용자가 연결된 serverId를 쌍으로 저장합니다.</p>
<p>Hash 자료구조를 사용하기 때문에 특정 사용자의 연결 상태를 빠르게 조회할 수 있습니다.</p>
<pre><code class="language-java">public SseEmitter connect(final Long memberId) {
    String emitterId = String.valueOf(memberId);
        SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(DEFAULT_TIMEOUT));
        String serverId = serverIdProvider.getServerId();

        redisTemplate.opsForHash().put(&quot;sse_connections&quot;, emitterId, serverId);

        registerEmitterCallbacks(emitter, emitterId);

        sendToClient(emitter, emitterId, &quot;이벤트 스트림 생성 memberId: &quot; + memberId);

        return emitter;
}</code></pre>
<p>이 코드와 같이 SSE 연결 메소드인 connect 가 호출될 때, 캐시 스토리지에 저장해줍니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_choi0409/post/26c889b2-78cc-45a5-a984-1af4e095e23b/image.png" alt=""></p>
<pre><code class="language-markdown">Key: sse_connections
Field (Key): emitterId  (&quot;7&quot;)
Value: SERVER_ID        (&quot;399a6ff8-e8ba-478f-8136-81a9699bf841&quot;)</code></pre>
<p>이를 통해 특정 사용자의 SSE 연결이 어느 서버에 존재하는지를 쉽게 알 수 있습니다.</p>
<h3 id="3-2-하지만">3-2. 하지만?</h3>
<p>Redis에 연결 상태를 저장한다고 해서 알림 전송 문제가 해결되지 않습니다.</p>
<p>예를 들어, 사용자가 A 서버에 연결된 상태에서 B서버에서 알림이 발생했다면, B 서버는 해당 사용자가 어느 서버에 연결되어 있는지를 Redis를 통해 알 수는 있어도, 실제로 해당 알림을 직접 전송할 수는 없습니다.</p>
<p>이 문제를 해결하기 위해서 Kafka를 도입합니다.</p>
<hr>
<h2 id="4-메시지-브로커-kafka-도입">4. 메시지 브로커 Kafka 도입</h2>
<p>앞서 Redis를 활용해 각 사용자의 SSE 연결 상태와 서버 ID를 저장했습니다.</p>
<p>하지만 메시지를 다른 서버에 있는 사용자에게 알림으로 전달하는 것까지는 해결되지 않았습니다.</p>
<p>이를 해결하기 위해 Kafka 메시지 브로커를 도입합니다.</p>
<p>분산 환경에서 안정적인 데이터 스트리밍을 지원하는 메시지 브로커입니다. 이를 활용하면 멀티 서버 환경에서도 효율적인 이벤트 전파와 메시지 처리가 가능합니다.</p>
<h3 id="4-1-kafka-producer의-역할">4-1. Kafka Producer의 역할</h3>
<p>Producer는 메시지를 생성해서 Kafka에 전달하는 역할을 합니다.</p>
<p>SSE 알림 시스템에서는 특정 사용자에게 전송할 메시지를 생성하고,</p>
<p>해당 사용자가 연결된 서버(serverId)로 메시지를 Kafka 토픽에 전송합니다.</p>
<p>즉, 누구에게 어떤 메시지를 보낼지 결정하고 Kafka로 보낸다는 점에서 <strong>‘생산자’</strong> 역할을 합니다.</p>
<p>아래는 Producer의 예시 코드입니다.</p>
<pre><code class="language-java">public void send(Member targetMember, String message) {
    String emitterId = String.valueOf(targetMember.getId());
    String serverId = (String) redisTemplate.opsForHash().get(&quot;sse_connections&quot;, emitterId);

    if (serverId != null) {
        String data = emitterId + &quot;|&quot; + message;
        kafkaTemplate.send(&quot;sse_connections&quot;, emitterId, data);
    }
}</code></pre>
<p>send 메소드는 메시지를 targetMember에게 전송하는 메소드입니다.</p>
<ul>
<li>emitterId: 사용자 식별자</li>
<li>serverId: 해당 사용자가 연결된 서버의 고유 ID (Redis에서 조회)</li>
<li>data: Kafka 메시지 본문 (emitterId|message 형식 - Consumer가 가공)</li>
<li>kafkaTemplate.send(…):<ul>
<li>Topic: “sse_connections”</li>
<li>Key: serverId (해당 서버에서만 메시지를 소비)</li>
<li>Value: data (emitterId와 함께 보낼 메시지)</li>
</ul>
</li>
</ul>
<p>이 메소드는 Kafka의 sse_connections 토픽에 메시지를 보내는 Producer 역할을 합니다.</p>
<p>Kafka 메시지를 받은 각 서버는 자신이 담당하는 serverId에 해당하는 메시지만 소비하고, 그 안에 포함된 emitterId를 기준으로 실제 SSE 연결에 메시지를 전송합니다.</p>
<h3 id="4-2-kafka-consumer의-역할">4-2. Kafka Consumer의 역할</h3>
<p>Consumer는 Kafka에 저장된 메시지를 받아서 처리하는 역할을 합니다.</p>
<p>Kafka를 통해 전달된 메시지 중 자신이 담당하는 서버(serverId)에 해당하는 메시지만 받아서,</p>
<p>해당 사용자(emitterId)의 SSE 연결을 찾아 메시지를 전송합니다.</p>
<p>즉, Kafka로부터 메시지를 소비하고, 적절한 사용자에게 전달하는 <strong>‘소비자’</strong> 역할을 수행합니다.</p>
<p>아래는 Consumer의 예시 코드입니다.</p>
<pre><code class="language-java">@KafkaListener(topics = &quot;sse_notifications&quot;, groupId = &quot;#{serverIdProvider.getServerId()}&quot;)
public void listen(String message) {
    String[] parts = message.split(&quot;\\|&quot;);

    if (parts.length &lt; 2) {
        return;
    }

    String emitterId = parts[0];
    String content = parts[1];

    SseEmitter emitter = emitterRepository.findById(emitterId);

    if (emitter != null) {
        sseEmitterManager.sendToClient(emitter, emitterId, content);
    }
}</code></pre>
<ul>
<li>@KafkaListener(…): sse_notifications 토픽을 구독하고, 메시지를 수신하면 listen 메소드가 실행됩니다.</li>
<li>message.split(”\|”): Kafka 메시지는 “emitterId|content” 형식으로 구성되어있기 때문에, 이를 분리합니다.</li>
<li>emitterRepository.findById(…): 해당 사용자의 SSE 연결 (SseEmitter)을 조회합니다.</li>
<li>sseEmitterManager.sendToClient(…): 실제 클라이언트에게 알림을 전송합니다.</li>
</ul>
<p>이처럼 각 서버는 자신이 관리하는 emitterId에 해당하는 메시지만 처리하고, 클라이언트와의 SSE 연결이 유지되는 동안 실시간 알림을 안정적으로 전달할 수 있습니다.</p>
<h3 id="4-3-동작-과정">4-3. 동작 과정</h3>
<p>Kafka는 일반적으로 여러 서버에서 운영이 되지만, 저는 단일 서버 환경에서 포트를 나누어 동작을 검증했습니다.</p>
<p>위의 예시 코드의 동작 과정을 살펴보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_choi0409/post/5d752185-c0db-463d-83f7-918ee8a900a2/image.png" alt="image"></p>
<ol>
<li>Producer(A 서버 또는 B서버)는 알림 보낼 사용자의 memberId를 Key로 설정하여 sse_notifications 토픽에 메시지를 발행합니다.</li>
<li>Kafka는 이 메시지를 토픽에 구독하는 모든 서버(A, B)에게 브로드캐스팅합니다. (각 서버는 고유한 groupId를 가집니다.)</li>
<li>메시지를 수신한 각 Consumer(A, B)는 자신의 로컬 메모리를 확인합니다.<ul>
<li>수신된 메시지의 memberId가 자신에게 연결된 사용자가 맞으면 SseEmitter를 통해 클라이언트에게 알림을 전송합니다.</li>
<li>자신과 관련 없는 메시지라면 무시합니다.</li>
</ul>
</li>
</ol>
<p>이러한 구조 덕분에, 어떤 서버에서든 사용자가 연결된 서버를 찾아 메시지를 전송할 수 있는 확장 가능하고 안정적인 아키텍처가 완성됩니다.</p>
<hr>
<h2 id="5-마무리">5. 마무리</h2>
<p>이번 글에서는 SSE 연결 상태를 Redis로 공유하고, Kafka를 통해 메시지를 안정적으로 전달하는 구조를 정리해보았습니다.
멀티 서버 환경에서도 유실 없이 알림을 전송할 수 있는 방법을 고민 중이라면, 위와 같은 방식이 하나의 좋은 선택지가 될 수 있습니다.</p>
<p>해당 내용의 코드와 자세한 구현 방식과 실제 동작은 아래 깃허브에서 확인할 수 있습니다.</p>
<p>🔗 <a href="https://github.com/giwoong01/sse-study">GitHub: sse-study</a></p>
<blockquote>
<p>☺️ 더 나은 개선 방향이나 궁금한 점이 있다면 언제든지 의견 남겨주세요!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE, 어디까지 써봤니? 실전 적용과 서버 구현 (1편)]]></title>
            <link>https://velog.io/@dev_choi0409/SSE-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EC%8D%A8%EB%B4%A4%EB%8B%88-%EC%8B%A4%EC%A0%84-%EC%A0%81%EC%9A%A9%EA%B3%BC-%EC%84%9C%EB%B2%84-%EA%B5%AC%ED%98%84-1%ED%8E%B8</link>
            <guid>https://velog.io/@dev_choi0409/SSE-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EC%8D%A8%EB%B4%A4%EB%8B%88-%EC%8B%A4%EC%A0%84-%EC%A0%81%EC%9A%A9%EA%B3%BC-%EC%84%9C%EB%B2%84-%EA%B5%AC%ED%98%84-1%ED%8E%B8</guid>
            <pubDate>Tue, 04 Mar 2025 14:15:38 GMT</pubDate>
            <description><![CDATA[<h2 id="1-들어가며">1. 들어가며</h2>
<p>실시간 데이터 전송이 필요한 환경에서는 WebSocket, Long Polling, Server-Sent Events(SSE) 같은 기술이 활용됩니다. 이번 글에서는 SSE를 실전에서 적용하면서 경험한 것들을 공유하고자 합니다.</p>
<p>SSE를 처음 접한 것은 이전에 진행한 끄적끄적 프로젝트에서 실시간 알림 기능을 구현할 때였습니다. 당시 SSE를 적용하면서 기본적인 동작 방식과 활용 가능성을 경험할 수 있었습니다. 하지만 실제 운영 환경에서는 연결 유지, 다중 서버 대응 등 여러 가지 개선이 필요하다는 점을 알게 되었습니다.</p>
<p>이 블로그에서는 SSE 기반 실시간 알림 기능을 최적화하고 확장하는 과정을 정리하고자 합니다. 1편에서는 SSE의 기본 개념과 프로젝트 적용 과정을 다루고, 2편에서는 Redis를 활용한 SSE 연결 관리 및 Kafka를 이용한 다중 서버 환경에서의 동작 방식을 소개하겠습니다.</p>
<hr>
<h2 id="2-sse란">2. SSE란?</h2>
<p>SSE(Server-Sent Events)는 서버에서 클라이언트로 단방향 스트리밍 데이터를 전송하는 기술입니다.</p>
<p>HTTP 기반으로 작동하며, 클라이언트가 연결을 맺으면 서버가 지속적으로 데이터를 전송할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_choi0409/post/669eb13b-6deb-4a39-99af-8d8894ae0302/image.png" alt="image"></p>
<h3 id="2-1-sse-특징">2-1. SSE 특징</h3>
<ul>
<li>단방향 통신 (서버 → 클라이언트)<ul>
<li>클라이언트가 한 번 구독하면 끊길때까지 서버는 지속적으로 통신이 가능합니다.</li>
</ul>
</li>
<li>HTTP 기반으로 별도의 프로토콜 없이 사용이 가능합니다.</li>
<li>자동 재연결을 지원합니다.</li>
</ul>
<h3 id="2-2-sse-선택-이유">2-2. SSE 선택 이유</h3>
<p>SSE는 단방향 데이터 전송에 최적화되어 있어 실시간 알림 시스템에 적합합니다.</p>
<p>기본적으로 자동 재연결을 지원하며, 별도의 핸드셰이크 없이 HTTP 기반으로 간편하게 적용할 수 있습니다.</p>
<p>그리고 EventSource API를 활용하면 클라이언트 구현이 간단하고 유지보수가 용이합니다.</p>
<p>WebSocket보다 가벼운 연결 방식으로 리소스 사용이 적으며, 프록시 환경에서도 안정적으로 동작합니다. 특히, WebSocket은 비표준 프로토콜(ws, wss)을 사용하기 때문에 방화벽이나 프록시 서버에서 차단될 가능성이 있으며, 연결이 불안정할 수 있습니다.</p>
<p>따라서, 양방향 통신이 필요하지 않은 실시간 알림 기능에 가장 적합한 기술이라 생각했기에 SSE를 사용하였습니다.</p>
<hr>
<h2 id="3-프로젝트에서-sse-적용하기">3. 프로젝트에서 SSE 적용하기</h2>
<h3 id="3-1-동작-과정">3-1. 동작 과정</h3>
<p>프로젝트에 적용하기 전에 먼저 동작 과정을 살펴보겠습니다.</p>
<ol>
<li><strong>클라이언트가 SSE 연결 요청</strong>을 보내면, 서버는 <strong>HTTP 스트림을 열어 유지</strong>합니다.</li>
<li>서버는 text/event-stream <strong>형식으로 데이터를 지속적으로 전송</strong>합니다.</li>
<li><strong>이벤트가 발생할 때마다 데이터를 전송</strong>하며, 필요하면 특정 클라이언트에게만 전송할 수도 있습니다.</li>
<li><strong>클라이언트 연결이 끊기면 자동으로 재연결 요청</strong>이 오며, 서버는 이를 처리하여 다시 연결을 유지할 수 있습니다.</li>
</ol>
<h3 id="3-2-프로젝트-적용">3-2. 프로젝트 적용</h3>
<p>먼저 SSE를 연결하기 위해서는 SseEmitter 를 알고가야합니다.</p>
<p>SseEmitter는 Spring에서 SSE 기능을 지원하는 클래스로, 서버가 클라이언트에게 비동기적으로 데이터를 지속적으로 전송할 수 있도록 도와줍니다.</p>
<p>SseEmitter의 특징으로는</p>
<ul>
<li>비동기 처리를 지원합니다.<ul>
<li>클라이언트와 연결을 유지하면서 데이터 전송이 가능합니다.</li>
</ul>
</li>
<li>연결을 유지합니다.<ul>
<li>서버에서 이벤트가 발생할 때마다 클라이언트에 데이터를 푸시합니다.</li>
</ul>
</li>
<li>타임아웃 설정이 가능합니다.<ul>
<li>기본적으로 일정 시간이 지나면 자동으로 종료됩니다.</li>
</ul>
</li>
<li>멀티스레드 환경에서 안전합니다.<ul>
<li>다른 쓰레드에서 데이터를 전송할 수 있습니다.</li>
</ul>
</li>
</ul>
<p>즉, SseEmitter는 Spring에서 SSE를 구현할 때 핵심 역할을 하는 객체이며, 클라이언트가 구독한 후 비동기적으로 이벤트를 전송하는데 활용됩니다.</p>
<p>SseEmitter는 이와 같이 사용합니다.</p>
<ol>
<li><p>객체 생성</p>
<pre><code class="language-java"> SseEmitter sseEmitter = new SseEmitter(timeout)</code></pre>
<p> 을 사용하여 인스턴스를 생성합니다.</p>
</li>
<li><p>데이터 전송</p>
<pre><code class="language-java"> sseEmitter.send(data)</code></pre>
<p> 를 호출하여 클라이언트로 데이터를 전송합니다.</p>
</li>
<li><p>완료 처리</p>
<pre><code class="language-java"> sseEmitter.complte()</code></pre>
<p> 로 스트림을 닫거나, onCompletion()을 활용해 자동으로 정리합니다.</p>
</li>
</ol>
<p>이제 정말로 프로젝트에 적용해보겠습니다.</p>
<p>[SSE 연결]</p>
<pre><code class="language-java">public class SseEmitterManager {

    private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; // 1시간
    private final Map&lt;String, SseEmitter&gt; emitters = new ConcurrentHashMap&lt;&gt;();

    public SseEmitter connect(final Long memberId) {
        String emitterId = String.valueOf(memberId);
        SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);

        emitters.put(emitterId, emitter);

        registerEmitterCallbacks(emitter, emitterId);

        log.info(&quot;새로운 SSE 연결 생성: {}&quot;, emitterId);

        return emitter;
    }

    private void registerEmitterCallbacks(SseEmitter emitter, String emitterId) {
        emitter.onCompletion(() -&gt; {
            log.info(&quot;비동기 요청 완료&quot;);
            removeEmitter(emitterId);
        });

        emitter.onTimeout(() -&gt; {
            log.info(&quot;시간 초과&quot;);
            emitter.complete();
            removeEmitter(emitterId);
        });

        emitter.onError((e) -&gt; {
            removeEmitter(emitterId);
            throw new EmitterCallbackException(&quot;Emitter 에러발생&quot;, e);
        });
    }

    private void removeEmitter(String emitterId) {
        emitters.remove(emitterId);
    }

}</code></pre>
<ol>
<li>ConcurrentHashMap을 사용해 SseEmitter 객체를 관리합니다.<ul>
<li>memberId 기반의 emitterId를 생성하여 저장합니다.</li>
<li>멀티스레드 환경에서도 안전하게 동작합니다.</li>
<li>Map으로 관리하는 이유는 사용자별 SSE 연결을 효율적으로 관리하고, 연결이 끊어졌을 때 빠르게 정리하여 메모리 누수를 방지할 수 있기때문입니다.</li>
</ul>
</li>
<li>connect(Long memberId) 메소드<ul>
<li>새로운 SseEmitter를 생성하고 emitterId를 키로 하여 저장합니다.</li>
<li>registerEmitterCallbacks를 호출해 콜백을 등록합니다.</li>
</ul>
</li>
<li>콜백 메서드 (onCompletion, onTimeout, onError)<ul>
<li>SSE 연결이 종료되거나 에러 발생 시 removeEmitter(emitterId) 호출하여 정리합니다.</li>
</ul>
</li>
</ol>
<p>[데이터 전송]</p>
<pre><code class="language-java">public class SseEmitterManager {

    public void send(Member targetMember, Object data) {
        String emitterId = String.valueOf(targetMember.getId());
        SseEmitter emitter = emitters.get(emitterId);

        try {
            emitter.send(SseEmitter.event()
                    .id(emitterId)
                    .data(data));
        } catch (IOException exception) {
            removeEmitter(emitterId);
            throw new SendFailedException(&quot;전송 실패&quot;, exception);
        }
    }

}</code></pre>
<ul>
<li>SseEmitter.event()를 이용해 이벤트 객체를 생성합니다.</li>
<li>.id(emitterId)로 이벤트 ID를 설정합니다.</li>
<li>.data(data)로 클라이언트로 보낼 데이터를 설정합니다.</li>
</ul>
<p>[실행 결과]</p>
<p><img src="https://velog.velcdn.com/images/dev_choi0409/post/6b62c555-411d-44d9-8b36-29eabc1ee7b0/image.png" alt="image"></p>
<p>이와 같이 SSE 연결이 된 것을 확인할 수 있고, 데이터가 클라이언트에게 전달된 것을 확인할 수 있습니다.</p>
<hr>
<h2 id="4-마무리">4. 마무리</h2>
<p>이번 글에서는 <strong>SSE를 프로젝트에 적용하는 과정</strong>을 다뤘습니다.</p>
<p>다음 2편에서는 <strong>운영 환경에서의 SSE 최적화</strong>를 중심으로, <strong>연결 유지, 다중 서버 대응</strong> 등에 대해 고민하고 해결한 내용을 공유하겠습니다.</p>
<p>해당 내용의 코드와 자세한 구현 방식은 아래 깃허브에서 확인할 수 있습니다.</p>
<p>🔗 <a href="https://github.com/giwoong01/sse-study">GitHub: sse-study</a></p>
<blockquote>
<p>☺️ 더 나은 개선 방향이나 궁금한 점이 있다면 언제든지 의견 남겨주세요!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[끄적끄적 서비스 조회 부하 테스트 2편: 데이터베이스 인덱스로 성능 향상. 가능할까?]]></title>
            <link>https://velog.io/@dev_choi0409/%EB%81%84%EC%A0%81%EB%81%84%EC%A0%81-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-2%ED%8E%B8-%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%EB%A1%9C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81.-%EA%B0%80%EB%8A%A5%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@dev_choi0409/%EB%81%84%EC%A0%81%EB%81%84%EC%A0%81-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-2%ED%8E%B8-%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%EB%A1%9C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81.-%EA%B0%80%EB%8A%A5%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Mon, 23 Dec 2024 05:35:53 GMT</pubDate>
            <description><![CDATA[<h3 id="이전-상황"><strong>[이전 상황]</strong></h3>
<p>이전 <a href="https://giwoong01.tistory.com/26">1편</a>에서는 쿼리 최적화를 통해 조회 성능을 크게 개선한 사례를 다뤘습니다. 쿼리 구조를 단순화하고 효율적인 접근 방식을 도입한 결과, 성능 지표에서 상당한 개선을 확인할 수 있었습니다.</p>
<p>하지만 실무에서는 조금 더 안전하고 빠른 조회를 요구하는 상황이 자주 발생합니다. 이러한 요구를 충족하기 위해, 이번에는 데이터베이스 인덱스 적용을 통해 성능 최적화를 한 단계 더 끌어올리고자 합니다.</p>
<hr>
<h3 id="문제의-핵심"><strong>[문제의 핵심]</strong></h3>
<p>쿼리 최적화를 통해 초기 성능 문제를 해결했지만, 데이터가 계속해서 증가하면서 대규모 데이터를 처리하는 데 한계가 드러났습니다.</p>
<p>특히, 테이블 크기가 커질수록 쿼리 처리 시간이 선형적으로 증가하여 성능이 저하되었습니다.</p>
<p>이에 따라, 기존 최적화 방법만으로는 부족하다고 판단해 DB 인덱스라는 새로운 해법을 검토하게 되었습니다.</p>
<hr>
<h3 id="db-인덱스란-무엇인가"><strong>[DB 인덱스란 무엇인가?]</strong></h3>
<p>인덱스에 대한 자세한 설명은 이미 여러 블로그에서 다루고 있으므로, 여기서는 간단히 핵심만 짚고 넘어가겠습니다.</p>
<p>데이터베이스 인덱스는 데이터를 빠르게 검색할 수 있도록 돕는 구조로, 주로 B-Tree 또는 Hash Table을 기반으로 동작합니다.</p>
<ul>
<li>B-Tree 인덱스<ul>
<li>데이터가 정렬된 상태로 저장되어, 범위 검색과 순차 검색에 뛰어난 성능을 발휘합니다.</li>
<li>MySQL의 InnoDB 스토리지 엔진은 B-Tree 기반으로 인덱스를 생성합니다.</li>
</ul>
</li>
<li>Hash 인덱스<ul>
<li>키-값 매핑을 기반으로 동작하며, 정확한 값 검색(Equality Search)에 최적화되어 있습니다.</li>
<li>그러나 범위 검색(e.g., <code>BETWEEN</code>, <code>&gt;</code>, <code>&lt;</code>)에는 사용할 수 없습니다.</li>
<li>MySQL에서는 메모리 테이블에서 주로 사용됩니다.</li>
</ul>
</li>
</ul>
<h3 id="db-인덱스의-장점과-단점"><strong>[DB 인덱스의 장점과 단점]</strong></h3>
<ul>
<li><strong>장점</strong><ul>
<li>데이터 검색 속도 향상<ul>
<li>테이블의 데이터가 많아도 빠르게 검색할 수 있습니다.</li>
<li>데이터베이스는 인덱스를 통해 특정 데이터를 바로 찾아가므로, <strong>풀 테이블 스캔</strong>을 피할 수 있습니다.</li>
</ul>
</li>
<li>범위 검색 최적화</li>
<li>정렬 속도 개선</li>
</ul>
</li>
<li><strong>단점</strong><ul>
<li>데이터 수정 시 성능 저하</li>
<li>추가 저장 공간 필요</li>
<li>과도한 인덱스 사용 부작용</li>
</ul>
</li>
</ul>
<p>현재 프로젝트에서는 MySQL을 사용하고 있으며, 블록을 조회하는데 정렬 및 범위 검색이 빈번하기 때문에 DB 인덱스를 적용합니다.</p>
<hr>
<h3 id="db-인덱스-적용"><strong>[DB 인덱스 적용]</strong></h3>
<p>이제부터 끄적끄적 서비스에 DB 인덱스를 적용하려합니다.</p>
<p>DB 인덱스를 적용하는 방법에는 2가지가 있습니다.</p>
<ol>
<li><strong>JPA를 활용한 테이블 설정</strong><ul>
<li><code>@Table</code>과 <code>@Index</code> 어노테이션을 사용하여 엔티티 클래스에 인덱스를 정의합니다.</li>
<li>JPA가 실행되는 시점에  DDL을 생성하여 데이터베이스에 반영합니다.
<img src="https://velog.velcdn.com/images/dev_choi0409/post/51fa7e89-a65e-44a1-8a40-1b7056cdb52d/image.png" alt=""></li>
</ul>
</li>
</ol>
<ol start="2">
<li><strong>MySQL 명령어로 직접 설정</strong><ul>
<li>MySQL의 <code>CREATE INDEX</code> 명령어를 사용하여 수동으로 추가합니다.
<img src="https://velog.velcdn.com/images/dev_choi0409/post/8b478869-5a1f-46d5-9a6d-a8bccb5948cc/image.png" alt=""></li>
</ul>
</li>
</ol>
<br/>

<p><strong>[선택]</strong></p>
<ol>
<li>코드 기반의 관리<ul>
<li>현재 프로젝트에서 JPA를 사용하고 있기 때문에, 코드 기반 관리가 더 직관적이고 일관성을 유지할 수 있습니다.</li>
</ul>
</li>
<li>자동화된 DB 작업<ul>
<li>JPA가 실행되는 시점에 자동으로 데이터베이스를 생성 및 수정하므로, 직접적인 DB 작업 부담을 줄일 수 있습니다.</li>
</ul>
</li>
<li>유지보수의 용이성<ul>
<li>가장 중요한 이유는 유지보수의 편리함입니다.</li>
<li>저희는 3명의 백엔드 개발자가 협업하고 있기 때문에, 엔티티에 정의된 인덱스 정보를 통해 인덱스 구조를 직관적으로 이해할 수 있습니다.</li>
<li>이는 협업 과정에서 오해를 줄이고, 생산성을 높이는 데 큰 도움이 됩니다.</li>
</ul>
</li>
</ol>
<p>이러한 이유들로 인해, 저는 JPA 기반의 테이블 설정 방법을 선택했습니다.</p>
<hr>
<h3 id="테스트-결과-k6"><strong>[테스트 결과 (k6)]</strong></h3>
<p>1편에서 했던 테스트와 같은 환경으로 진행합니다.</p>
<p><strong>쿼리 최적화 후 결과 (1편 최종 결과)</strong></p>
<pre><code>data_received..................: 4.3 GB 70 MB/s
data_sent......................: 25 kB  398 B/s
http_req_blocked...............: avg=58.12µs  min=3µs     med=7µs      max=1.1ms    p(90)=13µs     p(95)=424.29µs
http_req_connecting............: avg=15.67µs  min=0s      med=0s       max=379µs    p(90)=0s       p(95)=186.29µs
http_req_duration..............: avg=2.34s    min=1.36s   med=2.16s    max=4.92s    p(90)=2.92s    p(95)=4.74s   
  { expected_response:true }...: avg=2.34s    min=1.36s   med=2.16s    max=4.92s    p(90)=2.92s    p(95)=4.74s   
http_req_failed................: 0.00%  0 out of 183
http_req_receiving.............: avg=189.73ms min=96.54ms med=173.96ms max=495.27ms p(90)=225.14ms p(95)=472.17ms
http_req_sending...............: avg=42.14µs  min=10µs    med=22µs     max=511µs    p(90)=55.2µs   p(95)=193.99µs
http_req_tls_handshaking.......: avg=0s       min=0s      med=0s       max=0s       p(90)=0s       p(95)=0s      
http_req_waiting...............: avg=2.15s    min=1.2s    med=2s       max=4.43s    p(90)=2.76s    p(95)=4.3s    
http_reqs......................: 183    2.945099/s
iteration_duration.............: avg=3.34s    min=2.36s   med=3.16s    max=5.92s    p(90)=3.92s    p(95)=5.74s   
iterations.....................: 183    2.945099/s
vus............................: 3      min=3        max=10
vus_max........................: 10     min=10       max=10
</code></pre><p><strong>DB 인덱스 적용 후 결과</strong></p>
<pre><code>data_received..................: 5.1 GB 82 MB/s
data_sent......................: 29 kB  467 B/s
http_req_blocked...............: avg=46.41µs min=1µs      med=6µs     max=1ms      p(90)=11µs     p(95)=20.39µs
http_req_connecting............: avg=12.61µs min=0s       med=0s      max=331µs    p(90)=0s       p(95)=0s
http_req_duration..............: avg=1.85s   min=1.11s    med=1.8s    max=3.04s    p(90)=2.01s    p(95)=2.44s
  { expected_response:true }...: avg=1.85s   min=1.11s    med=1.8s    max=3.04s    p(90)=2.01s    p(95)=2.44s
http_req_failed................: 0.00%  0 out of 214
http_req_receiving.............: avg=180.1ms min=98.18ms  med=180.9ms max=274.13ms p(90)=208.97ms p(95)=212.93ms
http_req_sending...............: avg=24.31µs min=3µs      med=19µs    max=789µs    p(90)=34.7µs   p(95)=46.69µs
http_req_tls_handshaking.......: avg=0s      min=0s       med=0s      max=0s       p(90)=0s       p(95)=0s
http_req_waiting...............: avg=1.67s   min=964.91ms med=1.61s   max=2.9s     p(90)=1.83s    p(95)=2.31s
http_reqs......................: 214    3.458618/s
iteration_duration.............: avg=2.86s   min=2.12s    med=2.8s    max=4.04s    p(90)=3.01s    p(95)=3.44s
iterations.....................: 214    3.458618/s
vus............................: 4      min=4        max=10
vus_max........................: 10     min=10       max=10</code></pre><p>쿼리 최적화 전과 후, 그리고 DB 인덱스 적용 후의 3가지 결과를 보기 좋게 표로 정리합니다.</p>
<h3 id="결과-데이터"><strong>[결과 데이터]</strong></h3>
<table>
<thead>
<tr>
<th><strong>항목</strong></th>
<th><strong>쿼리 최적화 전</strong></th>
<th><strong>쿼리 최적화 후</strong></th>
<th><strong>DB 인덱스 적용 후</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>데이터 수신량</strong></td>
<td>2.3 GB (35 MB/s)</td>
<td>4.3 GB (70 MB/s)</td>
<td>5.1 GB (82 MB/s)</td>
</tr>
<tr>
<td><strong>http_req_duration<br/>(HTTP 평균 요청 처리 시간)</strong></td>
<td>5.49s</td>
<td>2.34s</td>
<td>1.85s</td>
</tr>
<tr>
<td><strong>P90 요청 시간</strong></td>
<td>7.13s</td>
<td>2.92s</td>
<td>2.01s</td>
</tr>
<tr>
<td><strong>P95 요청 시간</strong></td>
<td>7.9s</td>
<td>4.74s</td>
<td>2.44s</td>
</tr>
<tr>
<td><strong>HTTP 요청 수</strong></td>
<td>96 요청 (1.49 요청/초)</td>
<td>183 요청 (2.95 요청/초)</td>
<td>214 요청 (3.46 요청/초)</td>
</tr>
<tr>
<td><strong>HTTP 요청 수신 대기 시간</strong></td>
<td>237.22ms</td>
<td>189.73ms</td>
<td>180.1ms</td>
</tr>
<tr>
<td><strong>http_req_waiting<br/>(HTTP 평균 요청 대기 시간)</strong></td>
<td>5.26s</td>
<td>2.15s</td>
<td>1.67s</td>
</tr>
<tr>
<td><strong>총 요청 실패율</strong></td>
<td>0.00%</td>
<td>0.00%</td>
<td>0.00%</td>
</tr>
<tr>
<td><strong>총 요청 수</strong></td>
<td>96 요청</td>
<td>183 요청</td>
<td>214 요청</td>
</tr>
<tr>
<td><strong>iteration 평균 시간<br/>(하나의 테스트가 실행되는데 걸리는 시간)</strong></td>
<td>6.5s</td>
<td>3.34s</td>
<td>2.86s</td>
</tr>
</tbody></table>
<br/>

<p>[쿼리 최적화 후 → DB 인덱스 적용 후] 성능 개선을 요약하면 이와 같습니다.</p>
<ul>
<li><strong>평균 요청 처리 시간</strong>: 20.9% 감소 (2.34s → 1.85s)</li>
<li><strong>P90/P95 성능 개선</strong>:<ul>
<li>P90(2.92s → 2.01s) 및 P95(4.74s → 2.44s) 지표가 각각 31.2%, 48.5% 단축되었습니다.</li>
<li>대부분의 요청이 빠르게 처리되며 안정성이 높아졌습니다.</li>
</ul>
</li>
<li><strong>평균 요청 대기 시간</strong>: 22.3% 감소 (2.15s → 1.67s)</li>
<li><strong>HTTP 요청 수</strong>: 16.6% 증가 (183 → 214)</li>
<li><strong>iteration 평균 시간</strong>: 14.3% 단축 (3.34s → 2.86s)</li>
</ul>
<hr>
<h3 id="정리"><strong>[정리]</strong></h3>
<ul>
<li>총 2번의 최적화로 검색 성능을 획기적으로 향상시켰습니다.</li>
<li>테스트 결과, 평균 요청 시간과 최악의 응답 시간이 모두 눈에 띄게 감소했습니다.</li>
</ul>
<hr>
<h3 id="향후-고민"><strong>[향후 고민]</strong></h3>
<ol>
<li><strong>인덱스 유지 비용</strong><ul>
<li>업데이트나 삭제 작업 시 인덱스 재구성 비용이 발생합니다.</li>
<li>progress, status 같은 변동이 잦은 필드에 인덱스가 적합한지 고민 중입니다.</li>
</ul>
</li>
<li><strong>캐싱 도입 가능성</strong><ul>
<li>업데이트/삽입 빈도가 높은 데이터는 DB 쿼리를 줄이고, 캐싱 레이어를 추가하는 것도 하나의 대안으로 보고있습니다.</li>
</ul>
</li>
</ol>
<br/>

<p>✏️ <a href="https://kkeujeok-kkeujeok.vercel.app">끄적끄적 서비스 링크</a></p>
<p>🐈‍⬛ <a href="https://github.com/AJD-Archive">끄적끄적 프로젝트 깃허브 링크</a></p>
]]></description>
        </item>
    </channel>
</rss>