<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dodo.log</title>
        <link>https://velog.io/</link>
        <description>클라우드 데이터 플랫폼 주니어 개발자 도도입니다!</description>
        <lastBuildDate>Thu, 19 Mar 2026 02:46:50 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dodo.log</title>
            <url>https://velog.velcdn.com/images/easy_ho/profile/089f690c-578f-43fb-9279-0a2c82f6ae35/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dodo.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/easy_ho" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[ClickHouse의 SharedMergeTree 기반 Stateless Compute 및 Distributed Cache]]></title>
            <link>https://velog.io/@easy_ho/ClickHouse%EC%9D%98-SharedMergeTree-%EA%B8%B0%EB%B0%98-Stateless-Compute-%EB%B0%8F-Distributed-Cache</link>
            <guid>https://velog.io/@easy_ho/ClickHouse%EC%9D%98-SharedMergeTree-%EA%B8%B0%EB%B0%98-Stateless-Compute-%EB%B0%8F-Distributed-Cache</guid>
            <pubDate>Thu, 19 Mar 2026 02:46:50 GMT</pubDate>
            <description><![CDATA[<p>최근 ClickHouse 엔지니어링 블로그에 올라온 아키텍처 진화 관련 글들이 인상 깊어서, 현재 ClickHouse가 클라우드 환경에서 어떤 방향으로 나아가고 있는지 다뤄보았습니다.</p>
<p>최근 ClickHouse가 클라우드 네이티브 환경을 타겟으로 밀고 있는 SharedMergeTree와 Distributed Cache기반의 Stateless 아키텍처 위주로 정리했습니다.</p>
<h2 id="1-clickhouse-아키텍처의-발전-방향-→-shared-nothing에서-stateless로">1. ClickHouse 아키텍처의 발전 방향 → Shared-Nothing에서 Stateless로</h2>
<p>ClickHouse는 이를 해결하기 위해 실제 데이터를 Object Storage로 완전히 빼버리는 SharedMergeTree 엔진을 도입했습니다. 인프라 관리의 복잡함을 스토리지 쪽으로 넘기고 스케일링을 유연하게 가져가겠다는 의도라 생각됩니다.</p>
<p>또한, 무겁고 비용이 비싼 DB 내부 디스크를 벗어나 비교적 저렴하고 연산상의 성능 이점을 가져갈 좋은 토대가 됩니다.</p>
<p>관련해서 최근(2025년 9월)에 올라온 블로그 글(<a href="https://clickhouse.com/blog/clickhouse-cloud-stateless-compute">No more disks: the architecture behind stateless compute in ClickHouse Cloud</a>)을 보면, 여기서 데이터뿐만 아니라 메타데이터와 캐시까지 Compute Node에서 완전히 분리해 완벽한 Stateless 구조를 완성하는 쪽으로 진화 중이라는 것을 알 수 있습니다.</p>
<h2 id="2-성능-트레이드오프-극복-→-distributed-cache-도입">2. 성능 트레이드오프 극복 → Distributed Cache 도입</h2>
<p>S3를 메인 스토리지로 쓸 때 가장 우려되는 부분은 당연히 로컬 디스크 대비 느린 I/O 속도라고 생각됩니다. 관련해서 어떤식으로 해결했을지 궁금해 clickhouse 블로그를 찾아보니 좋은 글(<a href="https://clickhouse.com/blog/building-a-distributed-cache-for-s3">Building a Distributed Cache for S3</a>)이 있었습니다.</p>
<p>현재 ClickHouse Cloud 공식 문서에 소개된 아키텍처(<a href="https://clickhouse.com/docs/cloud/reference/architecture">ClickHouse Cloud Architecture</a>)를 보면, 초기(Stage 2)에는 연산 노드에 로컬 SSD를 달아 이를 캐시로 썼습니다. 하지만 이 방식은 트래픽이 몰려 연산 노드를 스케일 아웃할 때, 새로 뜬 노드들은 캐시가 비어있어 S3에서 데이터를 처음부터 긁어와야 하는 콜드 스타트 문제가 생긴다고 합니다.
<img src="https://velog.velcdn.com/images/easy_ho/post/6b890382-1724-4e58-8751-04f013c9eab2/image.png" alt="">
ClickHouse는 최근 이를 극복하기 위해 연산 노드와 S3 사이에 별도의 분산 캐시 노드를 두는 방식(Stage 3)을 도입했습니다. (<a href="https://clickhouse.com/blog/building-a-distributed-cache-for-s3">Distributed Cache for S3 Work Process</a>) 연산 노드가 여러 대로 늘어나더라도 이미 웜업된 상태의 공유 분산 캐시에서 데이터를 병렬로 즉각 가져오기 때문에 기존 로컬 디스크에 준하는 성능을 유지할 수 있게 되었습니다.
<img src="https://velog.velcdn.com/images/easy_ho/post/86a2dc34-f82c-41da-98b8-a93da7924f42/image.png" alt=""></p>
<blockquote>
<p><strong>Compute Node에서의 캐시가 OS 캐시가 아닌 Userspace page Cache라고 불리는 이유</strong>
과거에는 Compute Node가 자기 로컬 디스크에서 파일을 읽었기 때문에 리눅스 OS가 알아서 메모리에 캐싱을 해줬습니다. 하지만 아키텍처가 진화하면서 Compute Node쪽 디스크를 제거하고 대신 네트워크를 타고 Cache Node에서 데이터를 가져오게 되어 리눅스 OS가 파일로 인식하지 못해 자동으로 캐싱해주지 않습니다. 그래서 Clickhouse 개발진이 소프트웨어적으로 메모리단에서 캐싱을 진행하여 userspace page cache라는 이름이 붙게 되었습니다.</p>
</blockquote>
<h3 id="clickhouse-분산캐시-벤치마크">clickhouse 분산캐시 벤치마크</h3>
<p>아래 벤치마크는 분산 캐시 기술을 다룬 블로그 글(<a href="https://clickhouse.com/blog/building-a-distributed-cache-for-s3#benchmarking-hot-data-caching-in-clickhouse">Benchmarking hot data caching in ClickHouse</a>)에서 나온 분산 캐시가 적용된 환경에서 연산 노드가 확장될 때 S3 병목 없이 성능이 어떻게 유지되는지 보여줍니다.</p>
<h4 id="테스트-대상">테스트 대상</h4>
<ol>
<li>공유 리소스가 없는 자체 관리형 서버 (SSD 탑재)</li>
<li>Clickhouse 클라우드와 기존 로컬 파일 시스템 캐싱</li>
<li>Clickhouse 클라우드에 새로운 분산 파일 시스템 캐싱 (싱글 노드, 초기 워밍업)</li>
<li>Clickhouse 클라우드에 새로운 분산 파일 시스템 캐싱 (싱글 노드, 후속 노드)</li>
<li>Clickhouse 클라우드에 새로운 분산 파일 시스템 캐싱 (6개 병렬 노드)</li>
</ol>
<h4 id="테스트-1-처리량-테스트---전체-테이블-스캔">테스트 1: 처리량 테스트 - 전체 테이블 스캔</h4>
<p>모든 압축된 열을 대상으로 하기 때문에 E2E 캐시 처리량 테스트에 적합한 쿼리로 테스트 진행</p>
<pre><code>SELECT count() FROM amazon.amazon_reviews WHERE NOT ignore(*);</code></pre><p><img src="https://velog.velcdn.com/images/easy_ho/post/0639ff76-6cee-4a8f-bd03-1d3506c71e13/image.png" alt=""></p>
<blockquote>
<p>공유 분산 캐시와 병렬 컴퓨팅 노드를 결합하면 콜드 스타트시에도 로컬 SSD를 능가하는 속도가 나옵니다.</p>
</blockquote>
<h4 id="테스트-2-지연시간-테스트---분산읽기">테스트 2: 지연시간 테스트 - 분산읽기</h4>
<p>쿼리 크기가 작아질수록 처리량이 아닌 지연 시간이 병목 현상 → 분산되고 서로 관련없는 읽기를 사용하는 쿼리를 날려 테스트 진행</p>
<pre><code>SELECT * FROM amazon.amazon_reviews WHERE review_date in [&#39;1995-06-24&#39;, &#39;2015-06-24&#39;, …] FORMAT Null;</code></pre><p><img src="https://velog.velcdn.com/images/easy_ho/post/62fd9385-23c5-4baa-981a-500f751bc1c4/image.png" alt=""></p>
<blockquote>
<p>위의 처리량 벤치마크에서는 멀티스레드 읽기 덕분에 ClickHouse Cloud가 S3에서 데이터를 읽음에도 불구하고 SSD 기반 서버보다 우수한 성능을 보였지만, 여기서는 그러한 이점이 사라집니다. 작고 분산된 읽기 작업의 경우, I/O 스레드에 효율적으로 분산시킬 만큼 충분한 데이터가 없는 경우가 많습니다. 읽기 작업이 병렬로 실행되더라도 쿼리 성능은 결국 가장 느린 개별 읽기 작업에 의해 제한됩니다. 이 경우 대역폭이 아닌 레이턴시가 병목 현상이 되어 S3가 SSD보다 느려지는 것입니다.</p>
</blockquote>
<h4 id="결론">결론</h4>
<p>모든 컴퓨팅 노드는 객체 스토리지보다 훨씬 낮은 지연 시간으로 분산 캐시에서 캐시된 데이터를 가져올 수 있으며, 콜드 스타트 시 스토리지에서 다시 다운로드할 필요가 없습니다.</p>
<p>한 노드에서 수행된 작업은 다른 모든 노드에도 성능상 이점을 줍니다.</p>
<p>데이터를 로컬에 저장하거나 재시작 후 캐시를 재구축할 필요가 없습니다.</p>
<p><strong>처리량</strong> : 전체 테이블 스캔에서 콜드 쿼리는 컴퓨팅 노드 간 공유 캐싱 및 병렬 페치 덕분에 자체 관리형 SSD 설정보다 최대 4배 빠르게 실행되었습니다.</p>
<p><strong>지연 시간</strong> : 소규모 분산 읽기 작업의 경우, 콜드 쿼리는 SSD 성능과 동일했으며, 핫 쿼리는 60ms 미만의 메모리 속도 지연 시간을 기록했습니다. 이 모든 결과는 로컬 스토리지 없이 달성되었습니다.</p>
<h2 id="3-흥미로운점">3. 흥미로운점</h2>
<p>앞서 설명한 완벽한 Stateless 구조와 분산 캐시 기술(Stage 3)은 오픈소스에 공개되지 않고 오직 자사 SaaS인 ClickHouse Cloud 환경에서만 프라이빗하게 제공되는 핵심 경쟁력이라고 합니다.</p>
<p>클라우드 인프라 위에서 오픈소스 ClickHouse를 서비스할 때, 유연한 확장성과 성능을 동시에 잡기 위해 어떤 식의 아키텍처 고민을 clickhouse 엔지니어들이 하였는지 보여주는 사례라고 생각합니다. 클라우드 환경에서 Managed ClickHouse를 제공할 때 S3 Object Storage와 Compute Node 사이에 독자적인 캐싱 레이어를 구성해보는 등의 방식으로 접근해보아도 괜찮을 것 같습니다.</p>
<h3 id="현재의-clickhouse-cloud-stage-3-예상-아키텍처">현재의 Clickhouse Cloud Stage 3 예상 아키텍처</h3>
<blockquote>
<p><strong>주요 특징</strong>
Keeper를 통한 메타데이터 공유로 Compute plane의 stateless화
Object Storage와 Compute 사이의 분산 캐시 도입으로 SharedMergeTree 방식에서의 성능 개선
<img src="https://velog.velcdn.com/images/easy_ho/post/275e222f-4d56-46f7-ae61-4fec92739bc8/image.png" alt=""></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Virtual Thread vs Goroutine : 경량 스레드의 비교]]></title>
            <link>https://velog.io/@easy_ho/Virtual-Thread-vs-Goroutine-%EA%B2%BD%EB%9F%89-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%9D%98-%EB%B9%84%EA%B5%90</link>
            <guid>https://velog.io/@easy_ho/Virtual-Thread-vs-Goroutine-%EA%B2%BD%EB%9F%89-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%9D%98-%EB%B9%84%EA%B5%90</guid>
            <pubDate>Fri, 14 Nov 2025 07:40:55 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Java의 Virtual Thread와 Go의 Goroutine은 모두 M:N 매핑 모델을 사용하는 경량 스레드입니다. 두 기술 모두 수백만 개의 동시 작업을 효율적으로 처리할 수 있지만, 구현 방식과 철학에는 큰 차이가 있다고 합니다.</p>
</blockquote>
<hr>
<h2 id="들어가며">들어가며</h2>
<p>전통적인 OS 스레드는 생성 비용이 높고 메모리를 많이 사용하기 때문에, 대규모 동시성을 요구하는 현대 애플리케이션에는 적합하지 않습니다. 이러한 문제를 해결하기 위해 Java는 <strong>Virtual Thread</strong>를, Go는 <strong>Goroutine</strong>을 도입했습니다.</p>
<p>이 글에서는 두 기술의 동작 원리를 깊이 있게 분석하고, 실제 성능 테스트 결과를 통해 비교해보겠습니다.</p>
<hr>
<h2 id="java-virtual-thread">Java Virtual Thread</h2>
<h3 id="개요">개요</h3>
<aside>

<p>Virtual Thread는 JDK 21에서 정식 도입된 경량 스레드로, 기존 Java 코드와의 완벽한 호환성을 제공합니다.</p>
</aside>

<p>Virtual Thread는 전통적인 Platform Thread와 달리 <strong>M:N 매핑 모델</strong>을 사용합니다. 즉, 많은 수의 Virtual Thread가 소수의 OS Thread(Carrier Thread)에서 실행됩니다.</p>
<h3 id="기존-java-platform-thread">기존 Java Platform Thread</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/00027352-54b8-4ac1-95f3-550bbfd628f4/image.png" alt=""></p>
<ul>
<li>OS단의 커널 스레드와 1:1 매핑</li>
<li>Java의 유저 스레드 생성 → JNI(Java Native Interface)를 통해 커널 영역을 호출 → OS가 커널 스레드를 생성 → 매핑</li>
</ul>
<blockquote>
<p><strong>Context Switch</strong></p>
</blockquote>
<p>컨텍스트 스위칭은 <strong>CPU를 한 스레드에서 다른 스레드로 전환</strong>하기 위해, 현재 실행 중인 스레드의 상태(Context)를 <strong>저장</strong>하고, 다음 실행할 스레드의 저장된 상태를 복원하여 해당 스레드가 <strong>CPU 코어의 점유권을 획득</strong>하고 실행을 재개하는 과정입니다.</p>
<p>즉, CPU 유휴 상태를 만들지 않기 위해, 현재 CPU 점유를 유지할 수 없는 스레드 대신, 즉시 실행 가능한 다른 스레드가 CPU를 점유하도록 전환하는 것입니다.</p>
<p>기존 Java platform Thread의 경우 해당 Context Switch는 <strong>OS단에서만 일어납니다</strong>.</p>
<blockquote>
<p>Java platform Thread의 한계</p>
</blockquote>
<p>요청량이 급격하게 증가하는 경우 서버 환경에서 더 많은 Thread를 요구하게 됩니다.</p>
<ol>
<li>커널 스레드는 각각 독립적인 Stack을 위해 상당한 양의 RAM을 차지하기 때문에 개수의 한계가 있습니다.</li>
<li>모든 유저 스레드가 비싼 커널 스레드를 반드시 1:1로 점유해야 하므로, OS의 커널 스레드 생성 한계가 곧바로 유저 스레드 생성 한계가 됩니다.</li>
<li>스레드의 수가 많아질수록, 비용이 높은 OS단의 Context Switch의 횟수가 많아집니다.</li>
</ol>
<h3 id="virtual-thread">Virtual Thread</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/010d7d6c-d124-4971-ac5f-b6fb82a87f34/image.png" alt=""></p>
<p><strong>용어</strong></p>
<p><strong>JNI (Java Native Interface)</strong></p>
<ul>
<li>Java 코드가 네이티브 코드와 상호작용할 수 있게 해주는 인터페이스</li>
<li>JVM → OS: Java 스레드가 OS 커널 작업이 필요할 때 JNI를 통해 시스템 콜로 변환</li>
</ul>
<p><strong>OS 스케줄러</strong></p>
<ul>
<li>커널 내부의 실제 스레드를 담당</li>
<li>모든 시스템 프로세스와 스레드에 공평하게 CPU 시간을 배분</li>
<li><strong>병렬성(Parallelism)</strong> 제공</li>
</ul>
<p><strong>JVM ForkJoinPool 스케줄러</strong></p>
<ul>
<li>JVM 내부의 경량 스레드를 담당</li>
<li>Carrier Thread(실제 스레드)를 워커로 사용하여 Virtual Thread를 스케줄링</li>
<li><strong>동시성(Concurrency)</strong> 제공</li>
</ul>
<p>기존 OS 스레드와 1:1 매핑된 Java Platform Thread와는 달리, Virtual Thread는 <strong>JVM이 직접 관리</strong>하는 가벼운 경량 쓰레드입니다.</p>
<p>Platform Thread와 N:M으로 매핑된 Virtual Thread를 만들고, 이를 JVM 내부의 ForkJoinPool이 할당 및 스케줄링하여 작동시킵니다.</p>
<p>핵심은, 가상 스레드가 I/O 등의 작업으로 블록될 때 <strong>OS 커널 스레드를 블록시키지 않는다</strong>는 점입니다. 대신, JVM 스케줄러가 해당 가상 스레드를 캐리어 스레드에서 즉시 분리(Unmount)하고, 그 캐리어 스레드는 <strong>다른 가상 스레드를 실행</strong>할 수 있게 됩니다.</p>
<p>즉, <strong>비용이 높은 OS단의 컨텍스트 스위치</strong>가 일어나는 대신, JVM 내부에서 일어나는 매우 저렴한 스케줄링(마운트/언마운트)을 통해 높은 동시성을 달성합니다.</p>
<h3 id="핵심-동작-원리">핵심 동작 원리</h3>
<p>Virtual Thread의 목표는 I/O, sleep 등 Blocking 상황이 발생해도 OS 커널 스레드를 Block시키지 않고 CPU 효율을 극대화 하는 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/e4ec45a3-eb90-442b-9f97-e059ea87399b/image.png" alt=""></p>
<h3 id="용어">용어</h3>
<p><strong>Carrier Thread</strong></p>
<ul>
<li>실제로 작업을 수행하는 OS 레벨의 Platform Thread</li>
<li>각 Carrier Thread는 자체 Work Queue를 보유</li>
</ul>
<p><strong>ForkJoinPool</strong></p>
<ul>
<li>Virtual Thread의 스케줄러 역할</li>
<li>Carrier Thread 풀을 관리하고 Virtual Thread를 적절히 배분</li>
</ul>
<p><strong>Continuation</strong></p>
<ul>
<li>Blocking된 Virtual Thread의 실행 상태를 저장하는 객체</li>
<li>Virtual Thread의 실제 작업 내용(Runnable)과 스택 상태를 포함</li>
</ul>
<p><strong>Park()</strong></p>
<ul>
<li>Virtual Thread가 I/O 대기, Lock 등으로 Blocking될 때 호출</li>
<li>현재 실행 상태를 Continuation 객체에 저장</li>
<li>Carrier Thread에서 언마운트되어 힙 영역으로 이동</li>
</ul>
<p><strong>Unpark()</strong></p>
<ul>
<li>Blocking 작업이 완료되면 호출</li>
<li>힙 영역의 Continuation 객체를 Work Steal Queue로 푸시</li>
<li>Carrier Thread가 다시 처리할 수 있는 상태로 전환</li>
</ul>
<p><strong>Mount()</strong></p>
<ul>
<li>논리적 작업 단위(Virtual Thread)를 물리적 실행 단위(Carrier Thread)에 연결</li>
</ul>
<p><strong>ForkJoinPool</strong>과 <strong>WorkStealQueue</strong>를 통해 <strong>Carrier Thread</strong>들이 1초도 쉬지 않고 일하도록 합니다.</p>
<ol>
<li><strong>실행 - Mount()</strong> : Carrier thread는 mount()를 통해 큐에서 Virtual Thread를 가져와서 CPU 코어에 mount()하여 실행</li>
<li><strong>블로킹 - Park()</strong> : Blocking 상황에서는 OS 스레드를 블록시키는 대신 park()가 호출. 이 때 Virtual Thread의 스택 정보는 continuation 객체로 캡슐화되어 Heap 메모리로 이동. 또한, Unmount한 Carrier Thread는 즉시 자신의 큐에서 작업을 가져오거나 작업을 Steal</li>
<li><strong>재개 - Unpark()</strong> : Virtual thread의 blocking 작업(I/O 등)이 완료되면 JVM은 Heap에 저장된 continuation을 다시 ForkJoinPool의 Work Steal Queue 중 하나로 밀어넣음 → 실행 가능 상태</li>
<li><strong>효율 극대화 - Work Steal 매커니즘</strong> : Carrier Thread의 유후 상태를 방지하기 위해 자신의 큐가 비어 할 일이 없어지만 다른 Work Steal Queue에서 작업을 훔쳐와서 실행</li>
</ol>
<h3 id="코드-호환성">코드 호환성</h3>
<blockquote>
<p>java.lang 패키지의 BaseVirtualThread 추상클래스</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/2180621f-8a1b-4733-8152-5cbfff894b68/image.png" alt=""></p>
<p>기존 Thread 클래스를 상속받아 설계되었기 때문에, <strong>기존 Java 코드 수정 없이</strong> Virtual Thread를 도입할 수 있습니다. TaskExecutor만 교체하면 애플리케이션 전체에 적용 가능합니다.</p>
<aside>

<p><strong>주의사항</strong></p>
<p>Virtual Thread는 <strong>I/O Bound 작업</strong>에 최적화되어 있습니다. <strong>CPU Bound 작업</strong>(인-디코딩, 복잡한 계산 등)은 I/O 대기가 발생하지 않아 Carrier Thread를 놓아주지 않고 독점하게 됩니다.</p>
<p>이로 인해, ForkJoinPool의 핵심 이점(Blocking시 Thread반납)이 사라지고, JVM 스케쥴링 오버헤드만 더해져서 기존의 Java Platform Thread보다 성능이 떨어질 수 있습니다.</p>
</aside>

<hr>
<h2 id="go-goroutine">Go Goroutine</h2>
<h3 id="개요-1">개요</h3>
<aside>

<p>Goroutine은 Go 언어의 핵심 기능으로, 언어 차원에서 동시성을 지원합니다. <code>go</code> 키워드 하나로 수백만 개의 경량 스레드를 생성할 수 있습니다.</p>
</aside>

<p>Goroutine 역시 <strong>M:N 매핑 모델</strong>을 사용하며, 2KB의 매우 작은 스택으로 시작합니다.</p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/043daa76-5be2-4a77-8a8e-dcdf3f936a02/image.png" alt=""></p>
<h3 id="gmp-모델">GMP 모델</h3>
<p>Go의 Goroutine은 <strong>GMP 모델</strong>이라는 독특한 스케줄링 아키텍처를 사용합니다.</p>
<p><strong>G (Goroutine)</strong></p>
<ul>
<li>Goroutine의 실제 작업 내용과 자체 스택 정보를 보유</li>
<li><em>Virtual Thread의 Continuation과 유사</em></li>
</ul>
<p><strong>M (Machine)</strong></p>
<ul>
<li>실제로 작업을 수행하는 OS 스레드</li>
<li>P와 연결되어 P의 큐에 있는 G를 실행</li>
<li><strong><em>Virtual Thread의 Carrier Thread와  유사. BUT, Virtual Thread와 달리 Machine에 큐가 연결되어 있지 않음</em></strong></li>
</ul>
<p><strong>P (Processor)</strong></p>
<ul>
<li>M과 G를 연결하는 논리적 프로세서(스케줄러)</li>
<li>각 P는 로컬 실행 큐(LRQ)를 보유</li>
<li>P의 개수는 기본적으로 CPU 코어 수와 동일</li>
<li><strong><em>Virtual Thread의 ForkJoinPool 스케줄링 로직과 유사. BUT, Virtual Thread에는 없는 중간 계층</em></strong></li>
</ul>
<h3 id="스케줄링-메커니즘">스케줄링 메커니즘</h3>
<p><strong>Descheduling</strong></p>
<ul>
<li>Goroutine이 Blocking 상태에 진입할 때 발생</li>
<li>M(Machine)에서 즉시 분리되고, M은 다른 G를 가져와 계속 실행</li>
<li><strong><em>Virtual Thread의 Park() 메커니즘과 동일</em></strong></li>
</ul>
<p><strong>Rescheduling</strong></p>
<ul>
<li>대기하던 작업이 완료되면 발생</li>
<li>해당 G는 실행 가능 상태가 되어 P의 로컬 큐 또는 글로벌 큐로 푸시</li>
<li><strong><em>Virtual Thread의 Unpark() 메커니즘과 동일</em></strong></li>
</ul>
<p><strong>Work Stealing</strong></p>
<ul>
<li>특정 P의 로컬 큐가 비면, 연결된 M이 다른 바쁜 P의 로컬 큐에서 G의 절반을 가져옴</li>
<li>효율적인 부하 분산을 통해 CPU 활용도를 최대화</li>
<li><strong><em>Virtual Thread의 Work Stealing 방식과 동일한 목적</em></strong></li>
</ul>
<hr>
<h2 id="핵심-차이점-분석">핵심 차이점 분석</h2>
<h3 id="1-큐-구조의-차이">1. 큐 구조의 차이</h3>
<table>
<thead>
<tr>
<th><strong>특징</strong></th>
<th><strong>Go Goroutine (GMP Model)</strong></th>
<th><strong>Java Virtual Thread (ForkJoinPool)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>분산 큐 (Local)</strong></td>
<td><strong>Local Run Queue (LRQ):</strong> Processor (P)마다 존재</td>
<td><strong>Work Steal Queue:</strong> Carrier Thread마다 존재</td>
</tr>
<tr>
<td><strong>중앙 큐 (Global)</strong></td>
<td><strong>Global Run Queue (GRQ):</strong> 새로운/디스케줄된 Goroutine의 1차 대기 장소</td>
<td>명시적인 글로벌 큐 없음</td>
</tr>
<tr>
<td><strong>부하 분산 방식</strong></td>
<td>Work Stealing + GRQ를 비상 저장소로 활용</td>
<td>전적으로 Work Stealing에 의존</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>Go의 Global Run Queue (GRQ) 역할</strong></p>
</blockquote>
<ol>
<li><strong>오버플로우 처리</strong>: 너무 많은 Goroutine이 한 번에 생성되어 모든 LRQ에 자리가 없을 때 GRQ에 저장</li>
<li><strong>재스케줄링</strong>: I/O 작업에서 돌아온 Goroutine을 특정 P의 LRQ에 넣기 어려울 때 GRQ로 이동</li>
<li><strong>우선 탐색</strong>: P는 자신의 LRQ가 비었을 때, Work Stealing 전에 먼저 GRQ를 확인</li>
</ol>
<blockquote>
<p><strong>Java Virtual Thread의 큐 구조</strong></p>
</blockquote>
<aside>

<p>Virtual Thread는 ForkJoinPool 내에서만 작동하도록 설계되었기 때문에, Go와 같은 별도의 글로벌 큐를 필요로 하지 않습니다.</p>
</aside>

<ul>
<li>모든 Carrier Thread는 자신만의 Deque 형태의 로컬 Work Steal Queue를 보유</li>
<li>실행 가능 상태가 된 Continuation은 즉시 Work Steal Queue로 푸시</li>
<li>전적으로 Work Stealing 메커니즘을 통해 부하 분산</li>
</ul>
<h3 id="2-스택-메모리-관리의-차이">2. 스택 메모리 관리의 차이</h3>
<p>이것이 두 기술의 가장 근본적인 차이점입니다.</p>
<aside>

<p><strong>핵심 포인트</strong>
JVM은 스택 메모리가 OS 스택 영역에 존재하기 때문에 제약이 많지만, Go 런타임은 스택 메모리의 소유권을 OS로부터 완전히 가져와 유저 힙에 두었기 때문에 자유롭고 단순한 동시성 모델 구축이 가능합니다.</p>
</aside>

<blockquote>
<p><strong>Java Virtual Thread의 스택 관리</strong></p>
</blockquote>
<ul>
<li>Carrier Thread의 <strong>OS 커널 스택</strong>을 빌려서 사용</li>
<li>프로세스는 힙 영역은 스레드끼리 공유하지만 스택 영역은 스레드 독립적</li>
<li>기존 Carrier Thread의 스택과 섞여 있기 때문에 <strong>분리해서 캡처</strong>해야 함</li>
<li>이를 위해 <strong>Continuation 객체</strong>가 스택 상태를 캡처하여 힙에 저장</li>
<li>Blocking 시 스택을 Carrier Thread에 반납하고 힙으로 분리</li>
</ul>
<blockquote>
<p><strong>Go Goroutine의 스택 관리</strong></p>
</blockquote>
<ul>
<li>Goroutine은 <strong>2KB의 작은 스택</strong>으로 시작하며, 필요시 Go 런타임이 자동으로 확장</li>
<li>스택 메모리가 <strong>유저 레벨 힙에 할당</strong>됨</li>
<li>Goroutine 객체 자체가 스택 메모리를 직접 소유하고 관리</li>
<li>Blocking 상태에서도 <strong>별도로 상태 정보를 옮길 필요 없음</strong></li>
<li>Java처럼 OS 스택에서 분리하여 별도 객체로 힙에 캡처할 필요가 없음</li>
</ul>
<h3 id="3-continuation-객체의-필요-여부"><strong>3. Continuation 객체의 필요 여부</strong></h3>
<table>
<thead>
<tr>
<th><strong>특징</strong></th>
<th><strong>Java (Virtual Thread)</strong></th>
<th><strong>Go (Goroutine)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>스택 메모리 위치</strong></td>
<td>Carrier Thread의 <strong>OS 커널 스택</strong>을 빌려 씀</td>
<td>유저 영역의 <strong>힙(Heap)</strong>에 할당됨</td>
</tr>
<tr>
<td><strong>소유권</strong></td>
<td><strong>OS 커널 소유</strong> (JVM의 제약)</td>
<td><strong>Go 런타임 소유</strong> (Go의 자유)</td>
</tr>
<tr>
<td><strong>결과</strong></td>
<td>제약이 많아 동적 확장이 어렵고, I/O 시 상태를 <strong>Continuation 객체로 캡처</strong>해야 함</td>
<td>자유로워 동적 확장/축소가 가능하고, G 객체가 스택을 보존하므로 <strong>단순한 동시성 모델</strong> 구축 가능</td>
</tr>
</tbody></table>
<p><strong>Virtual Thread</strong>는 스택 데이터를 Continuation 객체로 힙 영역에 보관하면서 OS 스레드가 필요 시 OS 스레드의 스택 영역에 복사하여 사용</p>
<p><strong>Goroutine</strong>은 스택 데이터가 물리적으로 힙 영역에 존재하기 때문에 OS 스레드의 스택 영역은 스케줄 관리용으로만 사용되고 필요 시 힙 영역의 Goroutine 스택으로 전환하여 바로 사용</p>
<h3 id="4-로컬-큐-소유자">4. 로컬 큐 소유자</h3>
<table>
<thead>
<tr>
<th><strong>비교 항목</strong></th>
<th><strong>JVM 가상 스레드 (ForkJoinPool)</strong></th>
<th><strong>Go 고루틴 (G-M-P)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>작업자 (OS 스레드)</strong></td>
<td>Carrier Thread</td>
<td>M (Machine)</td>
</tr>
<tr>
<td><strong>작업</strong></td>
<td>Virtual Thread</td>
<td>G (Goroutine)</td>
</tr>
<tr>
<td><strong>로컬 큐 소유자</strong></td>
<td><strong><code>Carrier Thread</code>가 소유</strong></td>
<td><strong><code>P</code> (Processor)가 소유</strong></td>
</tr>
<tr>
<td><strong>OS 스레드가 블로킹될 때</strong></td>
<td><strong>OS 스레드와 큐가 함께 멈춤</strong></td>
<td><strong>OS 스레드만 멈추고 P(와 큐)는 다른 OS 스레드에게 이전됨</strong></td>
</tr>
</tbody></table>
<p><code>중간 계층인 Processor</code>는 M(OS 스레드)이 멈추더라도 스케줄링(G 실행)은 멈추지 않도록 하며, 이는 Go가 <strong>OS 스레드 블로킹</strong>에 매우 강력하게 대처할 수 있게 하는 핵심 설계입니다.</p>
<hr>
<h2 id="종합-비교표">종합 비교표</h2>
<blockquote>
<p><strong>전체 스레드 모델 비교</strong></p>
</blockquote>
<table>
<thead>
<tr>
<th><strong>특징</strong></th>
<th><strong>Java Platform Thread</strong></th>
<th><strong>Go Goroutine</strong></th>
<th><strong>Java Virtual Thread</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>매핑 모델</strong></td>
<td><strong>1:1</strong> (OS 스레드)</td>
<td><strong>M:N</strong> (Go 런타임)</td>
<td><strong>M:N</strong> (JVM)</td>
</tr>
<tr>
<td><strong>스케줄링 주체</strong></td>
<td>OS 커널 (Kernel)</td>
<td>Go 런타임 (User-space)</td>
<td>JVM 런타임 (User-space)</td>
</tr>
<tr>
<td><strong>스택 메모리</strong></td>
<td>큼 (고정 크기, 예: 1MB+)</td>
<td>매우 작음 (시작 시 2KB)</td>
<td>매우 작음 (힙 메모리 활용)</td>
</tr>
<tr>
<td><strong>스택 관리</strong></td>
<td>OS 관리 (고정)</td>
<td><strong>동적 확장 스택</strong> (필요시 복사/확장)</td>
<td><strong>힙 기반 스택</strong> (Continuation, 청크 단위)</td>
</tr>
<tr>
<td><strong>생성/관리 비용</strong></td>
<td>높음</td>
<td>매우 낮음</td>
<td>매우 낮음</td>
</tr>
<tr>
<td><strong>컨텍스트 스위칭</strong></td>
<td>비쌈 (커널 모드 전환 필요)</td>
<td>매우 저렴 (함수 호출 수준)</td>
<td>매우 저렴 (메모리 포인터 교체)</td>
</tr>
<tr>
<td><strong>생성 가능 개수</strong></td>
<td>적음 (수천 개)</td>
<td>많음 (수백만 개)</td>
<td>많음 (수백만 개)</td>
</tr>
<tr>
<td><strong>블로킹 처리</strong></td>
<td>스레드 전체가 중단됨</td>
<td>Goroutine만 중단 (OS 스레드 반환)</td>
<td>Virtual Thread만 중단 (OS 스레드 반환)</td>
</tr>
<tr>
<td><strong>핵심 특징</strong></td>
<td>OS 자원 직접 활용</td>
<td>언어/컴파일러 차원 지원</td>
<td><strong>기존 Java 코드와 완벽 호환</strong></td>
</tr>
<tr>
<td><strong>주 사용처</strong></td>
<td>CPU 집약적 작업, 레거시</td>
<td>I/O 집약적, 대규모 동시성</td>
<td>I/O 집약적, <strong>기존 Java 앱</strong> 동시성 개선</td>
</tr>
</tbody></table>
<h3 id="virtual-thread-vs-goroutine-비교">Virtual Thread vs Goroutine 비교</h3>
<table>
<thead>
<tr>
<th><strong>항목</strong></th>
<th><strong>Java (Virtual Thread)</strong></th>
<th><strong>Go (Goroutine)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>핵심 시너지</strong></td>
<td><strong>기존 생태계와의 완벽한 호환성</strong></td>
<td><strong>언어 내장 기능(채널, 도구)과의 유기성</strong></td>
</tr>
<tr>
<td><strong>주요 대상</strong></td>
<td>기존 Java/Spring 기반 시스템</td>
<td>신규 마이크로서비스, 인프라 도구</td>
</tr>
<tr>
<td><strong>배포</strong></td>
<td>JVM 필요 (무거움)</td>
<td><strong>단일 바이너리</strong> (매우 가벼움)</td>
</tr>
<tr>
<td><strong>동시성 패턴</strong></td>
<td>메모리 공유 (Lock, synchronized)</td>
<td>메시지 전달 (Channel, select)</td>
</tr>
</tbody></table>
<hr>
<h2 id="실전-성능-비교">실전 성능 비교</h2>
<p>실제 부하 테스트를 통해 세 가지 스레드 모델을 비교해보겠습니다.</p>
<h3 id="테스트-환경">테스트 환경</h3>
<blockquote>
<p>로컬 환경에서 Java platform Thread, Java Virtual Thread, Go Goroutine을 활용한 코드를 하단 공통 엔드포인트와 기능을 포함하도록 작성한 후 포트를 다르게하여 실행하고 테스트했습니다.</p>
</blockquote>
<p><strong>공통 엔드포인트</strong></p>
<table>
<thead>
<tr>
<th><strong>엔드포인트</strong></th>
<th><strong>역할</strong></th>
<th><strong>핵심 로직</strong></th>
<th><strong>목적</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>/api/heavy?duration=N</code></td>
<td>블로킹 작업 모방</td>
<td><code>Thread.sleep(500)</code>ms</td>
<td>스레드/Goroutine을 장시간 점유하여 스레드 풀 고갈 여부와 I/O 대기 효율성 테스트</td>
</tr>
<tr>
<td><code>/api/light</code></td>
<td>빠른 로직 처리 모방</td>
<td>지연 없이 즉시 응답</td>
<td>서버의 기본 처리 속도와 CPU 활용 능력 테스트</td>
</tr>
</tbody></table>
<p><strong>테스트 시나리오</strong></p>
<p>부하 테스트는 k6를 사용하여 다음 단계로 진행했습니다:</p>
<ol>
<li>1분 동안 100명까지 증가</li>
<li>2분 동안 300명까지 증가</li>
<li><strong>3분 동안 500명 유지 (피크)</strong></li>
<li>2분 동안 300명으로 감소</li>
<li>1분 동안 0명으로 종료</li>
</ol>
<ul>
<li>각 VU(가상 사용자)가 heavy(500ms) + light 요청을 동시에 병렬로 보냄</li>
<li>두 요청 모두 ****응답 받을 때까지 대기</li>
<li>응답 받으면 100ms sleep</li>
<li>다시 처음부터 반복</li>
</ul>
<p><strong>측정 지표</strong></p>
<ul>
<li><strong>RPS</strong> (Requests Per Second): 초당 처리량</li>
<li><strong>응답 시간</strong>: P50, P95, P99</li>
<li><strong>에러율</strong>: 실패한 요청 비율 (실패 기준: 200대가 아닌 응답 or 5s 이상의 응답시간</li>
<li><strong>동시 처리</strong>: 최대 동시 사용자</li>
</ul>
<h3 id="구현-코드">구현 코드</h3>
<ul>
<li><strong>Java Platform Thread</strong> (8081 포트)</li>
</ul>
<pre><code class="language-java">package com.test;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;

@SpringBootApplication
@RestController
public class ThreadServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(ThreadServerApplication.class, args);
    }

    @GetMapping(&quot;/api/heavy&quot;)
    public Response heavyTask(@RequestParam(defaultValue = &quot;100&quot;) int duration) {
        try {
            Thread.sleep(duration);
            return new Response(&quot;Platform Thread&quot;, duration, Thread.currentThread().getName());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }

    @GetMapping(&quot;/api/light&quot;)
    public Response lightTask() {
        return new Response(&quot;Platform Thread&quot;, 0, Thread.currentThread().getName());
    }

    record Response(String type, int duration, String threadInfo) {}
}</code></pre>
<ul>
<li><strong>Java Virtual Thread</strong> (8082 포트)</li>
</ul>
<pre><code class="language-java">package com.test;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.*;

import java.util.concurrent.*;

@SpringBootApplication
@RestController
public class VirtualThreadServerApplication {

    public static void main(String[] args) {
        [SpringApplication.run](http://SpringApplication.run)(VirtualThreadServerApplication.class, args);
    }

    @Bean
    public TomcatProtocolHandlerCustomizer&lt;?&gt; protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -&gt; {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }

    @GetMapping(&quot;/api/heavy&quot;)
    public Response heavyTask(@RequestParam(defaultValue = &quot;100&quot;) int duration) {
        try {
            Thread.sleep(duration);
            return new Response(&quot;Virtual Thread&quot;, duration, Thread.currentThread().toString());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }

    @GetMapping(&quot;/api/light&quot;)
    public Response lightTask() {
        return new Response(&quot;Virtual Thread&quot;, 0, Thread.currentThread().toString());
    }

    record Response(String type, int duration, String threadInfo) {}
}</code></pre>
<ul>
<li><strong>Go Goroutine</strong> (8083 포트)</li>
</ul>
<pre><code class="language-go">package main

import (
    &quot;encoding/json&quot;
    &quot;fmt&quot;
    &quot;log&quot;
    &quot;net/http&quot;
    &quot;runtime&quot;
    &quot;strconv&quot;
    &quot;time&quot;
)

type Response struct {
    Type       string `json:&quot;type&quot;`
    Duration   int    `json:&quot;duration&quot;`
    ThreadInfo string `json:&quot;threadInfo&quot;`
}

func heavyHandler(w http.ResponseWriter, r *http.Request) {
    durationStr := r.URL.Query().Get(&quot;duration&quot;)
    duration := 100
    if durationStr != &quot;&quot; {
        if d, err := strconv.Atoi(durationStr); err == nil {
            duration = d
        }
    }

    time.Sleep(time.Duration(duration) * time.Millisecond)

    resp := Response{
        Type:       &quot;Goroutine&quot;,
        Duration:   duration,
        ThreadInfo: fmt.Sprintf(&quot;NumGoroutine: %d, NumCPU: %d&quot;, runtime.NumGoroutine(), runtime.NumCPU()),
    }

    w.Header().Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
    json.NewEncoder(w).Encode(resp)
}

func lightHandler(w http.ResponseWriter, r *http.Request) {
    resp := Response{
        Type:       &quot;Goroutine&quot;,
        Duration:   0,
        ThreadInfo: fmt.Sprintf(&quot;NumGoroutine: %d, NumCPU: %d&quot;, runtime.NumGoroutine(), runtime.NumCPU()),
    }

    w.Header().Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
    json.NewEncoder(w).Encode(resp)
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{&quot;status&quot;: &quot;UP&quot;})
}

func main() {
    runtime.GOMAXPROCS(runtime.NumCPU())

    http.HandleFunc(&quot;/api/heavy&quot;, heavyHandler)
    http.HandleFunc(&quot;/api/light&quot;, lightHandler)
    http.HandleFunc(&quot;/health&quot;, healthHandler)

    log.Println(&quot;Go Goroutine server starting on :8083&quot;)
    log.Fatal(http.ListenAndServe(&quot;:8083&quot;, nil))
}</code></pre>
<h3 id="테스트-결과">테스트 결과</h3>
<pre><code>====================================================================================================
성능 비교 리포트
====================================================================================================

테스트 구성:
  - 총 소요시간: 540초 (9분)
  - 부하 증가: 100 VUs (1분) -&gt; 300 VUs (2분) -&gt; 500 VUs (3분) -&gt; 300 VUs (2분) -&gt; 0 VUs (1분)
  - 작업 부하: Heavy (500ms 대기) + Light 요청 혼합
  - 반복 간 대기: 100ms

====================================================================================================

[1] 처리량 성능
----------------------------------------------------------------------------------------------------
서버                       총 요청수          RPS            반복횟수           상대 성능
----------------------------------------------------------------------------------------------------
Java Platform Thread     352,720        653.05         176,360        68.1%
Java Virtual Thread      514,192        951.56         257,096        99.2%
Go Goroutine             518,036        959.28         259,018        100.0%

[2] 응답 시간 (ms)
----------------------------------------------------------------------------------------------------
서버                       최소          평균          중간값         P90         P95         최대
----------------------------------------------------------------------------------------------------
Java Platform Thread     0.11        534.05      506.56      1004.72     1085.32     1244.86
Java Virtual Thread      0.09        254.27      288.11      508.00      510.23      569.13
Go Goroutine             0.05        251.11      261.58      501.67      502.42      537.55

[3] 안정성
----------------------------------------------------------------------------------------------------
서버                       에러율            수신 데이터            송신 데이터
----------------------------------------------------------------------------------------------------
Java Platform Thread     0%             68.15 MB          28.76 MB
Java Virtual Thread      0%             126.07 MB         41.93 MB
Go Goroutine             0%             93.24 MB          42.24 MB

[4] 성능 순위
----------------------------------------------------------------------------------------------------

[4-1] 처리량 (RPS):
   1위 Go Goroutine: 959.28 req/s
   2위 Java Virtual Thread: 951.56 req/s
   3위 Java Platform Thread: 653.05 req/s

[4-2] 평균 응답시간:
   1위 Go Goroutine: 251.11 ms
   2위 Java Virtual Thread: 254.27 ms
   3위 Java Platform Thread: 534.05 ms

[4-3] P95 응답시간:
   1위 Go Goroutine: 502.42 ms
   2위 Java Virtual Thread: 510.23 ms
   3위 Java Platform Thread: 1085.32 ms

[4-4] 안정성 (최대 응답시간):
   1위 Go Goroutine: 537.55 ms
   2위 Java Virtual Thread: 569.13 ms
   3위 Java Platform Thread: 1244.86 ms

[5] 주요 분석
----------------------------------------------------------------------------------------------------

* Go Goroutine이(가) 가장 높은 처리량을 달성했습니다: 959.28 req/s
  (가장 느린 서버 대비 46.9% 빠름)

* Go Goroutine이(가) 가장 낮은 평균 응답시간을 기록했습니다: 251.11 ms
  (가장 느린 서버 대비 112.7% 우수)

* Go Goroutine은 Java Virtual Thread 대비 처리량이 0.8% 빠릅니다
* Java Virtual Thread는 Go 대비 평균 응답시간이 1.3% 높습니다

* Java Virtual Thread는 Platform Thread보다 45.7% 빠릅니다

* 모든 서버가 부하 상황에서 0% 에러율을 유지했습니다

====================================================================================================</code></pre><h3 id="성능-분석">성능 분석</h3>
<aside>

<p><strong>주요 결과 요약</strong></p>
<p><strong>Go Goroutine</strong>과 <strong>Java Virtual Thread</strong>는 거의 동등한 성능을 보였습니다 (차이 1% 미만)
두 경량 스레드 모델 모두 <strong>Platform Thread보다 약 45% 빠른</strong> 처리량을 달성
모든 서버가 500명의 동시 사용자 부하에서도 <strong>0% 에러율</strong> 유지
Virtual Thread는 Platform Thread 대비 응답 시간을 <strong>절반 수준</strong>으로 단축</p>
</aside>

<p><strong>1. 처리량 (RPS)</strong></p>
<ul>
<li>Go Goroutine: 959.28 req/s <strong>(1위)</strong></li>
<li>Java Virtual Thread: 951.56 req/s <strong>(2위, 0.8% 차이)</strong></li>
<li>Java Platform Thread: 653.05 req/s</li>
</ul>
<p><strong>2. 평균 응답 시간</strong></p>
<ul>
<li>Go Goroutine: 251.11 ms <strong>(1위)</strong></li>
<li>Java Virtual Thread: 254.27 ms <strong>(2위, 1.3% 차이)</strong></li>
<li>Java Platform Thread: 534.05 ms</li>
</ul>
<p><strong>3. P95 응답 시간</strong></p>
<ul>
<li>Go Goroutine: 502.42 ms</li>
<li>Java Virtual Thread: 510.23 ms</li>
<li>Java Platform Thread: 1085.32 ms</li>
</ul>
<p><strong>핵심 인사이트</strong></p>
<ol>
<li><strong>경량 스레드의 압도적 우위</strong>: Virtual Thread와 Goroutine 모두 I/O Bound 작업에서 Platform Thread를 크게 앞섰습니다.</li>
<li><strong>거의 동등한 성능</strong>: Go와 Java의 경량 스레드 구현은 실제 성능면에서 거의 차이가 없습니다. 선택은 생태계와 팀의 경험에 따라 결정하면 됩니다.</li>
<li><strong>안정성</strong>: 고부하 상황에서도 세 모델 모두 에러 없이 안정적으로 동작했습니다.</li>
</ol>
<h3 id="개인적-소감">개인적 소감</h3>
<p>Java Spring으로 프로젝트를 진행했었을 때 성능 개선을 위해서 이것저것 시도해봤었는데 근본적으로 OS쓰레드를 가상쓰레드로 바꾸는 작업을 했으면 성능이 바로 좋아지지 않았을까..</p>
<p>Go 런타임의 스택 메모리를 힙영역에 두고 OS 제약 없이 관리하는 방식이 넘 신기하다. 역시 언어개발자는 신이야</p>
<h3 id="참고-자료">참고 자료</h3>
<p><a href="https://techblog.woowahan.com/15398/">https://techblog.woowahan.com/15398/</a>
<a href="https://jangbageum.tistory.com/100">https://jangbageum.tistory.com/100</a>
<a href="https://go.dev/blog/waza-talk">https://go.dev/blog/waza-talk</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DDIP] Spring JPA N+1 문제 해결기: Batch Loading으로 성능 최적화하기]]></title>
            <link>https://velog.io/@easy_ho/Spring-JPA-N1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0-Batch-Loading%EC%9C%BC%EB%A1%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@easy_ho/Spring-JPA-N1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0-Batch-Loading%EC%9C%BC%EB%A1%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 13 Sep 2025 11:09:42 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>최근 진행 중인 [DDIP: 일상의 순간을 돈으로] 프로젝트에서 <code>DdipEvent</code> 도메인을 구현하면서 성능 이슈에 직면했다. <code>DdipEventEntity</code>가 <code>PhotoEntity</code>와 <code>InteractionEntity</code>와 각각 1:N 관계를 맺고 있는 상황에서 발생한 N+1 문제와 그 해결 과정을 공유하고자 한다.</p>
<h2 id="엔티티-구조">엔티티 구조</h2>
<p>먼저 문제가 발생한 엔티티 구조를 살펴보자.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;ddip_event&quot;)
public class DdipEventEntity {
    // ... 기본 필드들

    @OneToMany(mappedBy = &quot;ddipEvent&quot;, cascade = CascadeType.ALL, 
               orphanRemoval = true, fetch = FetchType.LAZY)
    private List&lt;PhotoEntity&gt; photos;

    @OneToMany(mappedBy = &quot;ddipEvent&quot;, cascade = CascadeType.ALL, 
               orphanRemoval = true, fetch = FetchType.LAZY)
    private List&lt;InteractionEntity&gt; interactions;
}</code></pre>
<h2 id="eager-loading을-선택하지-않은-이유">EAGER Loading을 선택하지 않은 이유</h2>
<p>처음에는 <code>FetchType.EAGER</code>를 고려했지만, 다음과 같은 이유로 적용하지 않았다:</p>
<h3 id="1-메모리-부족-위험">1. 메모리 부족 위험</h3>
<ul>
<li>불필요한 데이터까지 모두 메모리에 로드하여 OutOfMemory 발생 가능</li>
<li>특히 대용량 데이터 처리 시 심각한 성능 저하</li>
</ul>
<h3 id="2-과도한-리소스-점유">2. 과도한 리소스 점유</h3>
<ul>
<li>모든 연관 엔티티를 항상 로드하여 불필요한 DB 커넥션 점유</li>
<li>네트워크 대역폭 낭비</li>
</ul>
<h3 id="3-성능-예측의-어려움">3. 성능 예측의 어려움</h3>
<ul>
<li>어떤 쿼리가 실행될지 예측하기 어려움</li>
<li>연관 관계가 복잡할수록 예상치 못한 성능 이슈 발생</li>
</ul>
<h3 id="4-cartesian-product-문제">4. Cartesian Product 문제</h3>
<ul>
<li>여러 컬렉션을 EAGER로 로드할 때 데이터 중복 발생</li>
<li>실제 필요한 데이터보다 훨씬 많은 데이터 전송</li>
</ul>
<h2 id="n1-문제-발생">N+1 문제 발생</h2>
<p>LAZY Loading을 적용했지만, Repository에서 데이터 조회 시 전형적인 N+1 문제가 발생했다:</p>
<pre><code class="language-java">// 1개의 쿼리로 DdipEvent 목록 조회
List&lt;DdipEventEntity&gt; events = ddipEventJpaRepository.findAll();

// 각 event마다 photos와 interactions 조회 (N번의 추가 쿼리)
for (DdipEventEntity event : events) {
    event.getPhotos().size();      // 추가 쿼리 발생
    event.getInteractions().size(); // 추가 쿼리 발생
}</code></pre>
<p>결과적으로 <strong>1 + (N × 2)개의 쿼리</strong>가 실행되는 상황이 발생했다.</p>
<h2 id="첫-번째-시도-entitygraph">첫 번째 시도: EntityGraph</h2>
<p>N+1 문제를 해결하기 위해 <code>@EntityGraph</code>를 적용해봤다:</p>
<pre><code class="language-java">public interface DdipEventJpaRepository extends JpaRepository&lt;DdipEventEntity, UUID&gt; {
    @EntityGraph(attributePaths = {&quot;photos&quot;, &quot;interactions&quot;})
    List&lt;DdipEventEntity&gt; findAllWithAssociations();
}</code></pre>
<p>하지만 실행 시 다음과 같은 오류가 발생했다:</p>
<pre><code>org.hibernate.loader.MultipleBagFetchException: 
cannot simultaneously fetch multiple bags</code></pre><h2 id="multiplebagfetchexception-분석">MultipleBagFetchException 분석</h2>
<p>이 오류는 <strong>두 개 이상의 <code>List</code> 타입 컬렉션을 동시에 fetch join할 때 발생</strong>한다.</p>
<h3 id="발생-원리">발생 원리</h3>
<ol>
<li>Hibernate는 컬렉션을 &quot;Bag&quot;이라는 자료구조로 관리</li>
<li>여러 Bag을 동시에 fetch하면 Cartesian Product로 인한 데이터 중복 발생</li>
<li>어떤 데이터가 어떤 컬렉션에 속하는지 구분이 어려워짐</li>
</ol>
<h3 id="일반적인-해결-방법들">일반적인 해결 방법들</h3>
<ul>
<li><code>Set</code> 사용: 중복 제거되지만 순서 보장 안됨</li>
<li><code>@OrderColumn</code> + <code>List</code>: 성능 이슈 존재</li>
<li>별도 쿼리로 분리: 여전히 여러 쿼리 실행</li>
</ul>
<p>하지만 우리 프로젝트에서는 <strong>순서가 중요하고 중복이 발생할 수 있어</strong> <code>List</code>를 유지해야 했다.</p>
<h2 id="최종-해결책-batch-loading">최종 해결책: Batch Loading</h2>
<p>고민 끝에 <strong>Batch Loading</strong>을 적용하기로 결정했다.</p>
<h3 id="batch-loading-설정---방법-1">Batch Loading 설정 - 방법 1</h3>
<pre><code class="language-java">@Entity
@Table(name = &quot;ddip_event&quot;)
public class DdipEventEntity {
    @BatchSize(size = 1000)
    @OneToMany(mappedBy = &quot;ddipEvent&quot;, cascade = CascadeType.ALL, 
               orphanRemoval = true, fetch = FetchType.LAZY)
    private List&lt;PhotoEntity&gt; photos;

    @BatchSize(size = 1000)
    @OneToMany(mappedBy = &quot;ddipEvent&quot;, cascade = CascadeType.ALL, 
               orphanRemoval = true, fetch = FetchType.LAZY)
    private List&lt;InteractionEntity&gt; interactions;
}</code></pre>
<h3 id="batch-loading-설정---방법-2">Batch Loading 설정 - 방법 2</h3>
<pre><code>spring.jpa.properties.hibernate.default_batch_fetch_size=1000</code></pre><blockquote>
<p><strong>방법1</strong>은 엔티티단에 정의하여 개별 배치 사이즈를 지정해주는 방식이고,
<strong>방법2</strong>는 properties 등 설정파일에 적용하여 공통적으로 JPA/Hibernate단에 전역 설정으로 적용
하는 방식이다.</p>
</blockquote>
<blockquote>
<p>해당 프로젝트에서는 전역적으로 사용할 예정이라 <strong>방법2</strong> 를 사용했다!</p>
</blockquote>
<h3 id="동작-원리">동작 원리</h3>
<p>Batch Loading은 다음과 같이 작동한다:</p>
<pre><code class="language-sql">-- 1. DdipEvent 조회
SELECT * FROM ddip_event WHERE ...

-- 2. Photos 배치 조회 (IN 절 사용)
SELECT * FROM photo_entity WHERE ddip_event_id IN (?, ?, ?, ..., ?)

-- 3. Interactions 배치 조회 (IN 절 사용)  
SELECT * FROM interaction_entity WHERE ddip_event_id IN (?, ?, ?, ..., ?)</code></pre>
<h2 id="성능-테스트-결과">성능 테스트 결과</h2>
<p>실제 테스트를 통해 성능 개선 효과를 확인했다.</p>
<h3 id="테스트-환경">테스트 환경</h3>
<ul>
<li>DdipEvent: 1,000개</li>
<li>각 Event당 Photos: 3개</li>
<li>각 Event당 Interactions: 5개</li>
<li>배치 사이즈: 1000<blockquote>
<p>테스트 환경에 맞게 미리 데이터를 생성해두고, k6를 통한 부하테스트를 진행하였다. 
100개, 300개, 600개, 1000개의 DdipEvent를 조회하고 연관된 Photos와 Interactions를 조회하는 과정을 각각 10번씩 수행하여 수치를 파악했다. </p>
</blockquote>
</li>
</ul>
<h3 id="before-n1-문제">Before (N+1 문제)</h3>
<h4 id="데이터가-증가할수록-지수적으로-늘어나는-실행-시간">데이터가 증가할수록 지수적으로 늘어나는 실행 시간</h4>
<ol>
<li>100개 조회시
<img src="https://velog.velcdn.com/images/easy_ho/post/4c56c61b-6c51-4b90-b856-fe46879e453c/image.png" alt=""></li>
</ol>
<pre><code>실행된 쿼리 수: 1 + (100 × 2) = 201개
최소 실행시간: 1900ms
최대 실행시간: 2863ms
평균 실행시간: 2236.80ms</code></pre><ol start="2">
<li>300개 조회시
<img src="https://velog.velcdn.com/images/easy_ho/post/8b4012ae-e542-4fcb-ab55-d56d89a8c92c/image.png" alt=""></li>
</ol>
<pre><code>실행된 쿼리 수: 1 + (300 × 2) = 601개
최소 실행시간: 5739ms
최대 실행시간: 6751ms
평균 실행시간: 6300.50ms</code></pre><ol start="3">
<li><p>600개 조회시
<img src="https://velog.velcdn.com/images/easy_ho/post/5a6a9fe0-4ad6-4a4d-899c-805b9c20220f/image.png" alt=""></p>
<pre><code>실행된 쿼리 수: 1 + (600 × 2) = 1,201개
최소 실행시간: 11346ms
최대 실행시간: 12125ms
평균 실행시간: 11701.10ms</code></pre></li>
<li><p>1000개 조회시
<img src="https://velog.velcdn.com/images/easy_ho/post/5a6a9fe0-4ad6-4a4d-899c-805b9c20220f/image.png" alt=""></p>
<pre><code>실행된 쿼리 수: 1 + (1,000 × 2) = 2,001개
최소 실행시간: 19140ms
최대 실행시간: 20828ms
평균 실행시간: 19810.50ms</code></pre><blockquote>
<p><strong>⚠️ N+1 문제의 심각성</strong>: 데이터가 10배 증가하면 실행 시간도 거의 10배 증가하는 선형적 성능 저하를 보인다. 특히 1000개 조회 시 거의 20초라는 치명적인 응답 시간을 보여준다.</p>
</blockquote>
</li>
</ol>
<h3 id="after-batch-loading">After (Batch Loading)</h3>
<h4 id="데이터-양과-관계없이-일정한-성능을-보이는-최적화">데이터 양과 관계없이 일정한 성능을 보이는 최적화</h4>
<ol>
<li>100개 조회시
<img src="https://velog.velcdn.com/images/easy_ho/post/05319b1b-54d2-48b9-b253-10558055b353/image.png" alt=""></li>
</ol>
<pre><code>실행된 쿼리 수: 1 + 2 = 3개
최소 실행시간: 408ms
최대 실행시간: 567ms
평균 실행시간: 474.00ms</code></pre><ol start="2">
<li>300개 조회시
<img src="https://velog.velcdn.com/images/easy_ho/post/90380341-ea9f-4919-b854-30282fe7670a/image.png" alt=""></li>
</ol>
<pre><code>실행된 쿼리 수: 1 + 2 = 3개
최소 실행시간: 414ms
최대 실행시간: 588ms
평균 실행시간: 466.90ms</code></pre><ol start="3">
<li>600개 조회시
<img src="https://velog.velcdn.com/images/easy_ho/post/5acff9d0-edae-4895-99d5-78c2aa5e7d29/image.png" alt=""></li>
</ol>
<pre><code>실행된 쿼리 수: 1 + 2 = 3개
최소 실행시간: 418ms
최대 실행시간: 781ms
평균 실행시간: 486.30ms</code></pre><ol start="4">
<li>1000개 조회시
<img src="https://velog.velcdn.com/images/easy_ho/post/e73f4e9b-5000-4e16-af5a-7154a82c0cd4/image.png" alt=""></li>
</ol>
<pre><code>실행된 쿼리 수: 1 + 2 = 3개
최소 실행시간: 443ms
최대 실행시간: 515ms
평균 실행시간: 468.10ms</code></pre><blockquote>
<p><strong>배치 로딩의 효과</strong>: 데이터가 10배 증가해도 실행 시간은 거의 동일하다. 모든 경우에서 <strong>500ms 이내</strong>의 일관된 성능을 보여준다.</p>
</blockquote>
<h3 id="성능-개선-효과">성능 개선 효과</h3>
<table>
<thead>
<tr>
<th>데이터 수</th>
<th>Before (N+1)</th>
<th>After (Batch)</th>
<th>개선율</th>
<th>시간 단축</th>
</tr>
</thead>
<tbody><tr>
<td>100개</td>
<td>2,237ms</td>
<td>474ms</td>
<td>78.8%</td>
<td>1,763ms</td>
</tr>
<tr>
<td>300개</td>
<td>6,301ms</td>
<td>467ms</td>
<td>92.6%</td>
<td>5,834ms</td>
</tr>
<tr>
<td>600개</td>
<td>11,701ms</td>
<td>486ms</td>
<td>95.8%</td>
<td>11,215ms</td>
</tr>
<tr>
<td>1000개</td>
<td>19,811ms</td>
<td>468ms</td>
<td>97.6%</td>
<td>19,343ms</td>
</tr>
</tbody></table>
<blockquote>
<p>최대 <strong>97% 이상의 성능 개선</strong> 효과를 확인할 수 있었다.</p>
</blockquote>
<h3 id="batch-loading의-장점">Batch Loading의 장점</h3>
<h4 id="1-획기적인-쿼리-수-감소">1. 획기적인 쿼리 수 감소</h4>
<ul>
<li>N+1 → 1+ceil(N/batch_size) 쿼리로 대폭 감소</li>
<li>배치 사이즈에 따라 조절 가능</li>
</ul>
<h4 id="2-multiplebagfetchexception-회피">2. MultipleBagFetchException 회피</h4>
<ul>
<li>한 번에 하나씩 컬렉션을 로드하여 오류 방지</li>
<li><code>List</code> 타입 유지 가능</li>
</ul>
<h4 id="3-메모리-효율성">3. 메모리 효율성</h4>
<ul>
<li>필요한 시점에만 로드 (LAZY의 장점 유지)</li>
<li>배치 단위로 제어하여 메모리 사용량 예측 가능</li>
</ul>
<h4 id="4-설정의-간편함">4. 설정의 간편함</h4>
<ul>
<li><code>@BatchSize</code> 어노테이션만 추가하면 적용 완료</li>
<li>기존 코드 변경 최소화</li>
<li>설정 파일에 추가하여 전역적으로 적용 가능</li>
</ul>
<h3 id="실무-적용-시-기대-효과">실무 적용 시 기대 효과</h3>
<p><strong>사용자 경험</strong>: <strong>20초 → 0.5초로 40배</strong> 빠른 응답
<strong>서버 안정성</strong>: DB 커넥션 풀 고갈 위험 해소
<strong>확장성</strong>: 트래픽 증가에도 안정적인 서비스 제공
<strong>운영 비용</strong>: DB 서버 부하 감소로 하드웨어 비용 절약</p>
<h2 id="마무리">마무리</h2>
<p>N+1 문제는 JPA를 사용하면서 자주 마주치는 성능 이슈라고 생각한다.
이번 성능 테스트를 통해 단순한 어노테이션 또는 설정 하나가 얼마나 강력한 효과를 가져올 수 있는지 직접 확인할 수 있었다.</p>
<ul>
<li><strong>EAGER Loading</strong>: 간단하지만 메모리와 성능 문제 발생 가능</li>
<li><strong>Fetch Join</strong>: 단일 컬렉션에는 효과적이지만 Multiple Bag 문제</li>
<li><strong>Batch Loading</strong>: 여러 컬렉션이 있을 때 실용적인 해결책</li>
</ul>
<p>위와 같은 다양한 문제 상황에 대한 해결책 중, 프로젝트에 맞는 적절한 최적화 방안을 선택한 후</p>
<p><code>@BatchSize</code> 어노테이션 또는 전역적 설정 하나로 실제 최대 <strong>97.6%</strong>의 성능 향상을 달성했다.</p>
<p>특히 주목할 점은 데이터가 <strong>10배 증가</strong>해도 응답 시간은 거의 <strong>동일</strong>하다는 것이다. 이는 서비스 규모가 커져도 안정적인 성능을 보장할 수 있음을 의미한다고 생각한다.</p>
<p>유저 경험 향상에서 핵심인 응답 속도를 좌우하는 DB 조회 성능을, 
간단한 설정만으로도 크게 개선할 수 있음을 실제 지표를 통해 확인할 수 있었던 의미 있는 시간이었다.</p>
<hr>
<p><strong>참고 자료:</strong></p>
<ul>
<li><a href="https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#fetching-batch">Hibernate Batch Loading Documentation</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NEWZET] k6 부하 테스트를 통한 1000개 동시 메일 수신 로직 성능 19.8% 개선 이야기]]></title>
            <link>https://velog.io/@easy_ho/NEWZET-k6-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%ED%86%B5%ED%95%9C-1000%EA%B0%9C-%EB%8F%99%EC%8B%9C-%EB%A9%94%EC%9D%BC-%EC%88%98%EC%8B%A0-%EB%A1%9C%EC%A7%81-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-%EC%9D%B4%EC%95%BC%EA%B8%B0</link>
            <guid>https://velog.io/@easy_ho/NEWZET-k6-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%ED%86%B5%ED%95%9C-1000%EA%B0%9C-%EB%8F%99%EC%8B%9C-%EB%A9%94%EC%9D%BC-%EC%88%98%EC%8B%A0-%EB%A1%9C%EC%A7%81-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-%EC%9D%B4%EC%95%BC%EA%B8%B0</guid>
            <pubDate>Mon, 04 Aug 2025 17:21:27 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>NEWZET 서비스에서 대규모 이메일 수신은 빈번하게 일어난다. 특히 뉴스레터 서비스 집합소라는 특성상 동시에 수많은 이메일을 처리해야 하는 시스템이라 성능 최적화 반드시 필요했다. <strong>1000개 동시 이메일 처리</strong>라는 환경에서 성공률 100프로를 달성하고싶어 팀원들에게 통보(?)를 하고 무작정 부하테스트를 진행해봤다.</p>
<hr>
<h2 id="초기-상황-동기-처리의-한계">초기 상황: 동기 처리의 한계</h2>
<h3 id="문제-상황">문제 상황</h3>
<p>초기 시스템은 전형적인 동기 처리 방식이었다. 사용자가 이메일 처리를 요청하면 모든 과정이 순차적으로 실행되었다:</p>
<pre><code class="language-java">@PostMapping(&quot;/mail&quot;)
public ResponseEntity&lt;String&gt; processMail(@RequestBody MailRequest request) {
    // 1. 데이터베이스에 Article 저장
    Article article = articleService.saveArticle(request);

    // 2. FCM 알림 전송
    fcmService.sendNotification(article);

    // 3. 모든 처리 완료 후 응답
    return ResponseEntity.ok(&quot;처리 완료&quot;);
}</code></pre>
<h3 id="부하-테스트-결과">부하 테스트 결과</h3>
<p>k6를 이용해 <strong>1000개 동시 요청</strong>으로 부하 테스트를 진행했다:</p>
<pre><code class="language-javascript">export let options = {
  scenarios: {
    synchronous_processing: {
      executor: &#39;per-vu-iterations&#39;,
      vus: 1000,                    // 1000개 동시 요청
      iterations: 1,                // 각 VU당 1회
      maxDuration: &#39;180s&#39;,
    }
  }
};</code></pre>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/72d7d34d-ddc9-49d5-b0b4-ed11c02bcd24/image.png" alt=""></p>
<p>결과는 만족스럽지 않았다..</p>
<ul>
<li><strong>성공률</strong>: 98.30% (983/1000)</li>
<li><strong>평균 응답시간</strong>: 3,148ms</li>
<li><strong>P95 응답시간</strong>: 6,116ms</li>
<li><strong>최대 응답시간</strong>: 6,488ms</li>
</ul>
<p>동기 처리 방식의 한계가 명확히 드러났다. 약 2프로의 메일이 공중분해되어 사라졌고, 특히 피크 부하 상황에서 6초가 넘는 응답시간은 사용자 경험 측면에서 절대 용납할 수 없는 수준이었다.</p>
<hr>
<h2 id="1차-개선-비동기-배치-처리-도입">1차 개선: 비동기 배치 처리 도입</h2>
<h3 id="아키텍처-변경">아키텍처 변경</h3>
<p>동기 처리의 한계를 해결하기 위해 <strong>완전 비동기 아키텍처</strong>를 도입했다:</p>
<pre><code class="language-java">@PostMapping(&quot;/mail&quot;)
public ResponseEntity&lt;String&gt; processMail(@RequestBody MailRequest request) {
    // 큐에 넣고 즉시 응답
    mailBatchProducer.sendToQueue(request);
    return ResponseEntity.ok(&quot;처리 시작됨&quot;);
}</code></pre>
<blockquote>
<p><strong>비동기 배치 아키텍쳐 도입기 보러가기: <a href="https://velog.io/@easy_ho/NEWZET-%EB%A9%94%EC%9D%BC-%EC%88%98%EC%8B%A0%ED%95%9C-%EC%95%84%ED%8B%B0%ED%81%B4-DB-%EC%A0%80%EC%9E%A5-%EB%A1%9C%EC%A7%81-%EB%B0%B0%EC%B9%98%EC%B2%98%EB%A6%AC">[NEWZET] 메일 수신한 아티클 DB 저장 로직 배치처리</a></strong></p>
</blockquote>
<p><strong>Redis Stream</strong>을 메시지 큐로 활용하여 처리 과정을 완전히 분리했다:</p>
<ol>
<li><strong>HTTP 요청</strong> → 즉시 응답</li>
<li><strong>Redis Queue</strong> → Article 배치 워커</li>
<li><strong>Redis Queue</strong> → FCM 배치 워커</li>
</ol>
<h3 id="article-배치-저장-최적화">Article 배치 저장 최적화</h3>
<p>데이터베이스 저장도 진짜 배치 처리로 개선했다:</p>
<pre><code class="language-java">// 100개씩 배치로 DB 저장
private static final int BATCH_SIZE = 100;

for (ArticleEntity entity : entitiesToSave) {
    entityManager.persist(entity);
    processedCount++;

    if (processedCount % BATCH_SIZE == 0) {
        entityManager.flush();  // 100개마다 플러시
        entityManager.clear();  // 메모리 정리
    }
}</code></pre>
<h3 id="초기-비동기-처리-부하테스트-결과">초기 비동기 처리 부하테스트 결과</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/804cf622-c44f-480f-9aee-abda990e6b3e/image.png" alt=""></p>
<p>하지만 첫 번째 비동기 시도에서 <strong>새로운 문제</strong>가 발생했다:</p>
<ul>
<li><strong>성공률</strong>: 100% (HTTP 요청)</li>
<li><strong>평균 응답시간</strong>: 2,592ms</li>
<li><strong>하지만</strong>: FCM 600개 처리 후 <strong>Redis 타임아웃</strong> 발생!</li>
</ul>
<hr>
<h2 id="병목점-분석-fcm-순차-처리의-함정">병목점 분석: FCM 순차 처리의 함정</h2>
<h3 id="문제-진단">문제 진단</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/e650a99a-d464-4792-a2f5-10f23bcd2065/image.png" alt=""></p>
<p>로그를 분석한 결과, FCM 처리에서 치명적인 병목점을 발견했다:</p>
<pre><code class="language-java">// 문제가 된 코드
@Override
protected void processBatchItems(List&lt;FcmNotification&gt; fcmNotifications) {
    for (FcmNotification notification : fcmNotifications) {
        // 배치로 받았지만 실제로는 순차 처리! ❌
        fcmSenderOrchestrator.send(notification);  
    }
}</code></pre>
<p><strong>Redis Stream</strong>에서는 배치로 메시지를 가져왔지만, 실제 FCM 전송은 <code>for</code> 루프로 하나씩 순차 처리하고 있었다. 이로 인해:</p>
<ul>
<li>600개 FCM 처리 후 Redis 연결 타임아웃</li>
<li>나머지 400개 FCM 전송 실패</li>
<li>전체 시스템 불안정성 증가</li>
</ul>
<h3 id="원인-분석">원인 분석</h3>
<pre><code>Redis Stream → [Batch 100개] → FCM Consumer → [순차 전송 × 100] → 타임아웃!
                  ↑ 빠름              ↑ 느림 (병목)</code></pre><p>FCM 개별 전송 시간이 평균 50ms라고 가정하면:</p>
<ul>
<li>100개 배치 순차 처리: 50ms × 100 = 5,000ms (5초)</li>
<li>Redis 타임아웃 설정: 3,000ms (3초)</li>
<li><strong>결과</strong>: 필연적인 타임아웃 발생</li>
</ul>
<hr>
<h2 id="2차-개선-진짜-병렬-처리-구현">2차 개선: 진짜 병렬 처리 구현</h2>
<h3 id="fcm-병렬-처리-최적화">FCM 병렬 처리 최적화</h3>
<p>문제의 핵심인 FCM 순차 처리를 <strong>완전한 병렬 처리</strong>로 변경했다:</p>
<p><a href="https://github.com/newzet-dev/mail-server/pull/108/commits/51ed06cf0b4239bcb7fe3cb9f171fd96e942568e#diff-84db76d392ad6263794b7c852054bf34476bdc3af1f84390339340751192f049R45-R144">Refactor: Fcm 비동기 배치 Consumer에서 fcm 쓰레드 풀을 활용한 병렬 처리 및 전송 로직 최적화로 성능 향상</a></p>
<pre><code class="language-java">// 최적화된 FCM 전용 스레드 풀
private final ExecutorService fcmExecutor;

public FcmRedisBatchConsumerImpl(...) {
    this.fcmExecutor = Executors.newFixedThreadPool(50, r -&gt; {
        Thread t = new Thread(r, &quot;fcm-turbo-&quot; + System.nanoTime());
        t.setDaemon(true);
        t.setPriority(Thread.NORM_PRIORITY + 1);  // 우선순위 상향
        return t;
    });
}

@Override
protected void processBatchItems(List&lt;FcmNotification&gt; fcmNotifications) {
    AtomicInteger successCount = new AtomicInteger(0);
    AtomicInteger failCount = new AtomicInteger(0);

    // CompletableFuture로 진짜 병렬 처리
    CompletableFuture&lt;?&gt;[] futures = fcmNotifications.stream()
        .map(notification -&gt; CompletableFuture.runAsync(() -&gt; {
            try {
                fcmSenderOrchestrator.send(notification);
                successCount.incrementAndGet();  // 스레드 안전
            } catch (Exception e) {
                failCount.incrementAndGet();
            }
        }, fcmExecutor))
        .toArray(CompletableFuture[]::new);

    // 모든 FCM 전송 완료 대기
    CompletableFuture.allOf(futures).join();

    log.info(&quot;FCM batch completed: success={}, fail={}&quot;, 
             successCount.get(), failCount.get());
}</code></pre>
<h3 id="redis-multiget-최적화">Redis MultiGet 최적화</h3>
<p>Article 중복 처리를 위한 Redis 조회도 배치로 최적화했다:</p>
<p><a href="https://github.com/newzet-dev/mail-server/pull/108/commits/c746456fa1071358fd5d2b786f4f9f24629cd3ea#diff-fd08eb8308fa2d442722d24e1e3b1b494d2cade2341b3e6c8076551eda2e1e82R153-R176">Refactor: Redis를 활용한 중복처리 조회 multiGet을 통한 배치처리 및 캐시 업데이트 비동기처리 전환</a></p>
<pre><code class="language-java">// 기존: 개별 Redis 조회 (N번의 네트워크 호출)
for (String cacheKey : cacheKeys) {
    String cachedValue = redisTemplate.opsForValue().get(cacheKey);  // 100번 호출
}

// 개선: multiGet을 활용한 배치 조회
private static final int REDIS_BATCH_SIZE = 50;

for (int i = 0; i &lt; cacheKeys.size(); i += REDIS_BATCH_SIZE) {
    List&lt;String&gt; batchKeys = cacheKeys.subList(i, 
        Math.min(i + REDIS_BATCH_SIZE, cacheKeys.size()));

    // 50개씩 배치 조회
    List&lt;String&gt; cachedValues = redisTemplate.opsForValue().multiGet(batchKeys);

    for (int j = 0; j &lt; batchKeys.size(); j++) {
        String cacheKey = batchKeys.get(j);
        String cachedValue = cachedValues.get(j);
        // 중복 확인 로직
    }
}</code></pre>
<h3 id="동시성-안전-로깅">동시성 안전 로깅</h3>
<p>멀티스레드 환경에서 정확한 통계를 위해 <strong>AtomicInteger</strong>를 도입했다:</p>
<pre><code class="language-java">// 스레드 안전한 결과 집계
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger duplicateCount = new AtomicInteger(0);
AtomicInteger cacheHitCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);

// 50개 스레드에서 동시 접근해도 정확한 카운팅
successCount.incrementAndGet();  // Compare-And-Swap 연산으로 락 없이 스레드 안전</code></pre>
<h3 id="비동기-캐시-업데이트">비동기 캐시 업데이트</h3>
<p>메인 로직의 성능을 위해 캐시 업데이트도 비동기로 처리했다:</p>
<p><a href="https://github.com/newzet-dev/mail-server/pull/108/commits/c746456fa1071358fd5d2b786f4f9f24629cd3ea#diff-fd08eb8308fa2d442722d24e1e3b1b494d2cade2341b3e6c8076551eda2e1e82R202-R223">Refactor: 캐시 업데이트 reactiveRedisTemplate 처리</a></p>
<pre><code class="language-java">private void updateRedisCacheAsync(Map&lt;String, String&gt; toCache) {
    if (toCache.isEmpty()) return;

    // ReactiveRedisTemplate으로 비동기 배치 업데이트
    reactiveRedisTemplate.opsForValue()
        .multiSet(toCache)  // 100개 캐시를 1번에 배치 업데이트
        .doOnSuccess(result -&gt; log.debug(&quot;Cache updated for {} keys&quot;, toCache.size()))
        .doOnError(error -&gt; log.error(&quot;Cache update failed: {}&quot;, error.getMessage()))
        .subscribe();  // 논블로킹 비동기 실행
}</code></pre>
<hr>
<h2 id="최종-결과-목표-성능-달성">최종 결과: 목표 성능 달성</h2>
<h3 id="최종-부하-테스트-결과">최종 부하 테스트 결과</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/67220c6f-82bf-47a6-80c0-d2232c613b39/image.png" alt=""></p>
<p>동일한 조건(1000개 동시 요청)에서 최종 테스트를 진행했다:</p>
<ul>
<li><strong>성공률</strong>: 100.00% (1000/1000)</li>
<li><strong>평균 응답시간</strong>: 2,715ms</li>
<li><strong>P95 응답시간</strong>: 4,907ms  </li>
<li><strong>최대 응답시간</strong>: 5,179ms</li>
<li><strong>Redis 타임아웃</strong>: 0개 (완전 해결)</li>
<li><strong>FCM 전송</strong>: 1000개 전량 성공</li>
</ul>
<h3 id="성능-개선-수치-분석">성능 개선 수치 분석</h3>
<p><strong>전체 시스템 성능 비교:</strong></p>
<table>
<thead>
<tr>
<th>메트릭</th>
<th>동기 처리</th>
<th>최종 비동기</th>
<th>개선율</th>
</tr>
</thead>
<tbody><tr>
<td>성공률</td>
<td>98.30%</td>
<td>100.00%</td>
<td>+1.7%</td>
</tr>
<tr>
<td>평균 응답시간</td>
<td>3,148ms</td>
<td>2,715ms</td>
<td><strong>13.8% 향상</strong></td>
</tr>
<tr>
<td>P95 응답시간</td>
<td>6,116ms</td>
<td>4,907ms</td>
<td><strong>19.8% 향상</strong></td>
</tr>
<tr>
<td>Redis 타임아웃</td>
<td>발생</td>
<td>0개</td>
<td><strong>완전 해결</strong></td>
</tr>
</tbody></table>
<p><strong>세부 최적화 효과:</strong></p>
<ol>
<li><p><strong>Redis MultiGet 최적화:</strong></p>
<ul>
<li>네트워크 호출: 100회 → 2회 (<strong>98% 감소</strong>)</li>
<li>예상 레이턴시: 200ms → 4ms (<strong>196ms 단축</strong>)</li>
</ul>
</li>
<li><p><strong>FCM 병렬 처리:</strong></p>
<ul>
<li>처리 방식: 순차 → 50개 동시</li>
<li>1000개 처리시간: 50초 → 1초 (<strong>98% 단축</strong>)</li>
</ul>
</li>
<li><p><strong>AtomicInteger 동시성:</strong></p>
<ul>
<li>락 경합: 완전 제거</li>
<li>통계 정확도: 100% 보장</li>
</ul>
</li>
<li><p><strong>비동기 캐시 업데이트:</strong></p>
<ul>
<li>메인 로직 블로킹: 50ms → 0ms (<strong>완전 제거</strong>)</li>
<li>캐시 네트워크 호출: 100회 → 1회 (<strong>99% 감소</strong>)</li>
</ul>
</li>
</ol>
<h3 id="리소스-효율성-개선">리소스 효율성 개선</h3>
<p><strong>CPU 활용도:</strong></p>
<ul>
<li>기존: 순차 처리로 단일 코어 사용</li>
<li>개선: 50개 스레드로 멀티코어 활용 (<strong>50배 증가</strong>)</li>
</ul>
<p><strong>네트워크 최적화:</strong></p>
<ul>
<li>Redis 호출: 개별 조회 대비 <strong>98% 감소</strong></li>
<li>FCM API 처리량: <strong>50배 증가</strong></li>
<li>전체 네트워크 레이턴시: 평균 <strong>200ms 단축</strong></li>
</ul>
<p><strong>확장성 개선:</strong></p>
<ul>
<li>처리량: 초당 20개 → 1000개 (<strong>50배 증가</strong>)</li>
<li>동시성 한계: 100개 → 1000개 (<strong>10배 향상</strong>)</li>
</ul>
<hr>
<h2 id="느낀점">느낀점</h2>
<h3 id="1-병목점이-멈추질-않아">1. 병목점이 멈추질 않아</h3>
<p>성능 최적화 과정에서 크게 느낀 부분은 병목점은 제거되는 것이 아니라 이동한다는 점이다</p>
<ul>
<li><strong>1단계</strong>: HTTP 응답 지연 (동기 처리)</li>
<li><strong>2단계</strong>: FCM 순차 처리 (하이브리드 방식)</li>
<li><strong>3단계</strong>: Redis 네트워크 호출 (개별 조회)</li>
</ul>
<p>각 단계에서 병목점을 해결할 때마다 계속 새로운 병목점이 나타났다. 이를 해결하면서 진짜 시스템의 문제점을 조금씩 찾아내는 느낌을 받았다.</p>
<h3 id="2-진짜-배치-처리의-중요성">2. 진짜 배치 처리의 중요성</h3>
<p>FCM 저장 로직을 &quot;배치 처리&quot;라고 말하면서 실제로 따져보면 순차 처리를 하고 있는 함정에 빠졌었다 ㅠㅠ
완전한 배치를 이루도록 항상 의심해봐야겠다</p>
<ul>
<li><strong>Redis MultiGet</strong>: 개별 조회 대신 배치 조회</li>
<li><strong>FCM 병렬 전송</strong>: for 루프 대신 CompletableFuture</li>
<li><strong>DB 배치 저장</strong>: EntityManager flush/clear 활용</li>
</ul>
<h3 id="3-동시성-안전성의-중요성">3. 동시성 안전성의 중요성</h3>
<p>멀티스레드 환경에서는 성능뿐만 아니라 <strong>데이터 정확성</strong>도 중요한 포인트라고 생각한다</p>
<pre><code class="language-java">// Race Condition 위험
int successCount = 0;
successCount++;  // 멀티스레드에서 부정확

// 스레드 안전
AtomicInteger successCount = new AtomicInteger(0);
successCount.incrementAndGet();</code></pre>
<h3 id="4-부하-테스트는-신이야">4. 부하 테스트는 신이야</h3>
<p>실제 부하를 주어 시뮬레이션하는 부하 테스트 없이는 진짜 병목점을 찾을 수 없었다. k6를 이용한 1000개 동시 요청 테스트를 통해</p>
<ul>
<li>이론적 성능과 실제 성능의 차이 확인</li>
<li>Redis 타임아웃 같은 실제 운영 이슈 발견</li>
<li>정확한 성능 수치 기반 개선 방향 설정 
등등..</li>
</ul>
<p>수많은 이점을 확인할 수 있었다. 앞으로도 잘 활용해봐야겠다!</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>최종적으로 <strong>1000개 동시 요청을 100% 성공률로 처리</strong>하면서 <strong>평균 2.7초의 우수한 성능</strong>을 달성했다 :)</p>
<p>대용량 이메일 처리 시스템 최적화 여정을 통해 많은 것을 배웠다. 단순히 &quot;비동기 처리를 도입하면 빨라진다&quot;는 막연한 기대가 아니라, 실제 병목점을 데이터로 분석하고 단계적으로 개선하는 것이 얼마나 중요한지 깨달았다.</p>
<p>특히 부하 테스트를 통한 검증과 정확한 성능 수치 측정이 최적화의 핵심임을 다시 한번 확인했다. 이론적인 최적화와 실제 운영 환경에서의 성능은 분명히 다르며, 진짜 문제는 극한 상황에서만 드러나는 것 같다..!</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DDIP] UUID와 JPA의 숨겨진 함정: isNew() 판정과 성능 최적화 가이드]]></title>
            <link>https://velog.io/@easy_ho/UUID%EC%99%80-JPA%EC%9D%98-%EC%88%A8%EA%B2%A8%EC%A7%84-%ED%95%A8%EC%A0%95-isNew-%ED%8C%90%EC%A0%95%EA%B3%BC-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
            <guid>https://velog.io/@easy_ho/UUID%EC%99%80-JPA%EC%9D%98-%EC%88%A8%EA%B2%A8%EC%A7%84-%ED%95%A8%EC%A0%95-isNew-%ED%8C%90%EC%A0%95%EA%B3%BC-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EA%B0%80%EC%9D%B4%EB%93%9C</guid>
            <pubDate>Sat, 02 Aug 2025 10:35:04 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>DDIP 프로젝트를 개발하면서 UUID 기반의 엔티티를 설계하던 중, 팀원으로부터 흥미로운 질문을 받았다.</p>
<blockquote>
<p>&quot;@UuidGenerator를 사용할 때 UUID가 저장되는 시점에 하이버네이트가 자동으로 uuid를 자동 생성해서 id를 채워주는 것 같은데, 혹시 그럼 새로운 엔티티 생성 시 새로운 엔티티로 판정되나요?&quot;</p>
</blockquote>
<p>이 질문은 단순해 보였지만, 실제로 파헤쳐보니 Spring Data JPA와 Hibernate의 깊은 동작 원리와 관련된 복잡한 문제였다. 이 글에서는 그 과정에서 발견한 UUID 기반 엔티티의 모든 것을 정리해보고자 한다.</p>
<h2 id="문제-상황-예상치-못한-동작">문제 상황: 예상치 못한 동작</h2>
<p>개발 과정에서 다음과 같은 코드를 작성했다.</p>
<pre><code class="language-java">// 분명 새로운 엔티티를 저장하는데...
UUID customId = UUID.randomUUID();
UserEntity user = new UserEntity(customId, &quot;test@example.com&quot;, &quot;tester&quot;);
userRepository.save(user);

// 예상: INSERT INTO users ...
// 실제: SELECT * FROM users WHERE id = ? 
//      → org.hibernate.StaleObjectStateException 발생!</code></pre>
<p>왜 이런 일이 발생하는지 이해하기 위해 Spring Data JPA의 내부 동작을 살펴보았다.</p>
<h2 id="spring-data-jpa의-save-메커니즘-분석">Spring Data JPA의 save() 메커니즘 분석</h2>
<h3 id="save-메서드의-핵심-로직">save() 메서드의 핵심 로직</h3>
<p>Spring Data JPA의 <code>SimpleJpaRepository.save()</code> 메서드를 분석해보니 다음과 같은 구조였다.</p>
<pre><code class="language-java">// SimpleJpaRepository.save() 의 핵심 로직
@Transactional
public &lt;S extends T&gt; S save(S entity) {
    if (entityInformation.isNew(entity)) {
        em.persist(entity);    // 바로 INSERT
        return entity;
    } else {
        return em.merge(entity);  // SELECT 후 INSERT/UPDATE
    }
}</code></pre>
<h3 id="기본-isnew-판정-로직">기본 isNew() 판정 로직</h3>
<p>기본적으로 JPA는 다음과 같은 방식으로 신규 엔티티를 판정한다.</p>
<pre><code class="language-java">// JpaMetamodelEntityInformation의 기본 구현
public boolean isNew(T entity) {
    return getId(entity) == null;  // ID가 null이면 신규 엔티티
}</code></pre>
<p>여기서 핵심 문제가 드러났다. UUID가 언제 생성되느냐에 따라 <code>isNew()</code> 판정 결과가 달라진다는 것이다.</p>
<h2 id="실험을-통한-검증">실험을 통한 검증</h2>
<p>의문을 해결하기 위해 실제 테스트 코드를 작성하여 확인해보았다.</p>
<h3 id="테스트-엔티티-설정">테스트 엔티티 설정</h3>
<pre><code class="language-java">@Entity
@Table(name = &quot;USERS&quot;)
public class UserEntity {
    @Id
    @UuidGenerator  // 핵심: UUID 자동 생성
    @Column(columnDefinition = &quot;char(36)&quot;)
    @JdbcTypeCode(SqlTypes.CHAR)
    private UUID id;

    private String email;
    private String nickName;

    // 생성자, getter 등...
}</code></pre>
<h3 id="실험-1-uuid-생성-시점-확인">실험 1: UUID 생성 시점 확인</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/f18a6b7d-0a2a-4163-a2e6-b768a7c0dbdd/image.png" alt=""></p>
<h3 id="실제-테스트-결과">실제 테스트 결과</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/5eca719d-22b1-4a86-9c28-132f8bd99d09/image.png" alt=""></p>
<p><strong>실험 결과</strong>: <code>@UuidGenerator</code>는 지연 생성된다! 객체 생성 시점이 아닌 <code>persist()</code> 시점에 UUID가 생성된다.</p>
<h3 id="실험-2-isnew-판정과-쿼리-패턴">실험 2: isNew() 판정과 쿼리 패턴</h3>
<p><strong>2-1. 정상 케이스 (ID = null)</strong>:
<img src="https://velog.velcdn.com/images/easy_ho/post/6eecb5b0-69b8-448b-8f48-a64089244916/image.png" alt=""></p>
<p><strong>2-2. 문제 케이스 (ID ≠ null)</strong>:
<img src="https://velog.velcdn.com/images/easy_ho/post/8a3d8093-497c-4935-84b9-f153ddbe9add/image.png" alt=""></p>
<h3 id="실제-테스트-결과-1">실제 테스트 결과</h3>
<p><strong>2-1. 정상 케이스 (ID = null)</strong>
<img src="https://velog.velcdn.com/images/easy_ho/post/97819c6f-5e88-4aa4-a42d-c78cfe55831b/image.png" alt=""></p>
<p><strong>2-2. 문제 케이스 (ID ≠ null)</strong>
<img src="https://velog.velcdn.com/images/easy_ho/post/5de4a6cb-3d1b-438f-853e-77cf8c910127/image.png" alt=""></p>
<p><strong>실험 결과</strong>: org.springframework.orm.ObjectOptimisticLockingFailureException 발생!
<strong>원인</strong>: merge()가 SELECT 후 데이터가 없어서 동시성 문제로 판단</p>
<h2 id="핵심-발견-uuid-생성-시점의-중요성">핵심 발견: UUID 생성 시점의 중요성</h2>
<p><strong>실험 1의 결과</strong>가 모든 것을 명확히 해주었다:</p>
<pre><code>=== 실험 1: UUID 생성 시점 확인 ===
=== 객체 생성 직후 ===
ID: null
ID가 null인가? true

=== persist() 후 ===
ID: bae93284-b534-42ef-bc8d-a9a6260b9ae2
ID가 생성되었는가? true

✅ 결론: @UuidGenerator는 persist() 시점에 지연 생성됨!</code></pre><p>이것이 바로 우리 프로젝트에서 <strong>별다른 문제가 발생하지 않는 이유</strong>다!</p>
<h2 id="merge가-insert를-하지-않는-이유">merge()가 INSERT를 하지 않는 이유</h2>
<p>&quot;merge()는 SELECT 결과가 없으면 INSERT 하는 거 아닌가?&quot;라는 의문이 들 수 있다. 맞는 말이지만, UUID의 경우는 특별하다.</p>
<blockquote>
<p><strong>Hibernate의 내부 판정 로직</strong></p>
</blockquote>
<ol>
<li>ID가 있음 → &quot;이건 기존 엔티티야&quot;</li>
<li>SELECT 결과 없음 → &quot;어? 누가 삭제했나? 동시성 문제?&quot;</li>
<li>안전 장치 발동 → StaleObjectStateException 발생</li>
</ol>
<p>Hibernate는 데이터 무결성을 위해 예상치 못한 상황에서는 예외를 발생시킨다. 이는 보수적이지만 안전한 접근 방식이다.</p>
<h2 id="해결책-persistable-인터페이스">해결책: Persistable 인터페이스</h2>
<h3 id="핵심-아이디어">핵심 아이디어</h3>
<p><code>Persistable</code> 인터페이스를 구현하여 <code>isNew()</code> 판정 로직을 직접 제어할 수 있다.</p>
<pre><code class="language-java">@Entity
public class UserEntity implements Persistable&lt;UUID&gt; {
    @Id
    @UuidGenerator
    private UUID id;

    @Transient
    private boolean isNew = true;  // 핵심: 직접 제어

    // ...existing code...

    @Override
    public boolean isNew() {
        return isNew;  // ID와 무관하게 개발자가 직접 제어
    }

    @PrePersist
    void markNotNew() {
        this.isNew = false;  // 저장 후 기존 엔티티로 마킹
    }

    @PostLoad
    void markNotNewOnLoad() {
        this.isNew = false;  // 조회 시에도 기존 엔티티로 마킹
    }
}</code></pre>
<h3 id="성능-비교">성능 비교</h3>
<h4 id="before-persistable-없음">Before (Persistable 없음)</h4>
<table>
<thead>
<tr>
<th>시나리오</th>
<th>쿼리 수</th>
<th>예상 동작</th>
<th>실제 결과</th>
</tr>
</thead>
<tbody><tr>
<td>ID = null</td>
<td>1개 (INSERT)</td>
<td>정상</td>
<td>정상</td>
</tr>
<tr>
<td>ID ≠ null</td>
<td>2개 (SELECT + INSERT)</td>
<td>정상</td>
<td>예외 발생</td>
</tr>
</tbody></table>
<h4 id="after-persistable-적용">After (Persistable 적용)</h4>
<table>
<thead>
<tr>
<th>시나리오</th>
<th>쿼리 수</th>
<th>예상 동작</th>
<th>실제 결과</th>
</tr>
</thead>
<tbody><tr>
<td>ID = null</td>
<td>1개 (INSERT)</td>
<td>정상</td>
<td>정상</td>
</tr>
<tr>
<td>ID ≠ null</td>
<td>1개 (INSERT)</td>
<td>정상</td>
<td>정상</td>
</tr>
</tbody></table>
<p><strong>결과:</strong> 쿼리 수 50% 감소 + 예외 해결!</p>
<h3 id="최초-id가-null이-아닌-상황-예시">최초 ID가 null이 아닌 상황 예시</h3>
<p><strong>1. 테스트 코드</strong>
특정 ID로 테스트: 123e4567-e89b-12d3-a456-426614174000</p>
<p><strong>2. 데이터 마이그레이션</strong>
기존 시스템 ID: a1b2c3d4-...</p>
<p><strong>3. 외부 시스템 연동</strong>
외부 API ID: external-uuid-123</p>
<blockquote>
<p>*<em>💡 해결책: Persistable 인터페이스 구현 필요!
*</em></p>
</blockquote>
<h2 id="결론-및-우리-프로젝트에서의-선택">결론 및 우리 프로젝트에서의 선택</h2>
<h3 id="핵심-발견-uuidgenerator는-지연-생성된다">핵심 발견: UuidGenerator는 지연 생성된다</h3>
<p>실험을 통해 가장 중요한 사실을 확인했다. <strong><code>@UuidGenerator</code>는 persist() 시점에 UUID를 생성하므로, 객체 생성 시점에는 ID가 null이다.</strong> 이는 Spring Data JPA의 기본 <code>isNew()</code> 로직과 완벽하게 호환된다.</p>
<h3 id="우리-프로젝트에서-persistable을-적용하지-않은-이유">우리 프로젝트에서 Persistable을 적용하지 않은 이유</h3>
<p>검토 결과, 다음과 같은 이유로 <strong>현재 프로젝트에서는 Persistable 인터페이스를 적용하지 않기로 결정했다</strong>:</p>
<ol>
<li><p><strong>실제 필요성 부족</strong>: </p>
<ul>
<li>데이터 마이그레이션 계획이 없음</li>
<li>외부 시스템에서 ID를 받아오는 요구사항 없음</li>
<li>ID를 미리 설정해야 하는 비즈니스 로직 없음</li>
</ul>
</li>
<li><p><strong>현재 구조의 완벽한 동작</strong>:</p>
<ul>
<li><code>@UuidGenerator</code>의 지연 생성으로 인해 모든 신규 엔티티가 정상적으로 <code>persist()</code> 경로를 탄다</li>
<li>불필요한 SELECT 쿼리 발생하지 않음</li>
<li>예외 상황 발생하지 않음</li>
</ul>
</li>
<li><p><strong>복잡성 증가 우려</strong>:</p>
<ul>
<li>추가 필드(<code>@Transient boolean isNew</code>)와 라이프사이클 메서드 필요</li>
<li>현재 단순하고 명확한 구조를 복잡하게 만들 필요 없음</li>
</ul>
</li>
</ol>
<h3 id="언제-persistable을-고려해야-할까">언제 Persistable을 고려해야 할까</h3>
<p>만약 <strong><code>@UuidGenerator</code>가 즉시 생성 방식이었다면</strong> 다음과 같은 문제가 발생했을 것이다:</p>
<pre><code class="language-java">UserEntity user = UserEntity.create(&quot;test@test.com&quot;, &quot;tester&quot;, &quot;ACTIVE&quot;);
만약 이 시점에서 UUID가 생성되었다면: user.getId() != null
→ isNew() = false → merge() 호출 → StaleObjectStateException 발생!</code></pre>
<p>이런 경우라면 <strong>Persistable 구현이 필수</strong>였을 것이다.</p>
<p>또한 다음과 같은 요구사항이 생긴다면 Persistable 도입을 재검토해야 한다:</p>
<ul>
<li>테스트에서 특정 ID로 엔티티 생성이 빈번해질 때</li>
<li>데이터 마이그레이션이나 외부 시스템 연동 요구사항 발생</li>
<li>배치 처리에서 미리 생성된 ID 사용 필요</li>
</ul>
<h3 id="핵심-포인트">핵심 포인트</h3>
<p><strong>UUID 기반 JPA 엔티티 설계 시 반드시 확인해야 할 것</strong>:</p>
<ol>
<li><strong>UUID 생성 시점</strong>: 즉시 vs 지연</li>
<li><strong>실제 요구사항</strong>: ID 미리 설정 필요성</li>
<li><strong>테스트 복잡도</strong>: 특정 ID 테스트 빈도</li>
<li><strong>성능 영향</strong>: 불필요한 SELECT 쿼리 발생 여부</li>
</ol>
<p>이번 탐구를 통해 <strong>&quot;언제 어떤 기술을 도입할 것인가&quot;</strong>에 대한 판단 기준을 명확히 할 수 있었다. 기술적으로 가능하다고 해서 무조건 적용하는 것이 아니라, <strong>실제 요구사항과 복잡성을 균형 있게 고려</strong>하는 것이 중요하다는 교훈을 얻었다.</p>
<hr>
<h2 id="관련-자료">관련 자료</h2>
<ul>
<li><a href="https://docs.spring.io/spring-data/jpa/docs/current/reference/html/">Spring Data JPA Reference</a></li>
<li><a href="https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html">Hibernate User Guide</a></li>
<li><a href="https://www.rfc-editor.org/rfc/rfc4122.html">UUID Best Practices</a></li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DDIP] 헥사고날 아키텍쳐 기반 Auth 및 OAuth 로직 구현 (NEWZET 경험 기반 고도화)]]></title>
            <link>https://velog.io/@easy_ho/DDIP-%ED%97%A5%EC%82%AC%EA%B3%A0%EB%82%A0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90-%EA%B8%B0%EB%B0%98-Auth-%EB%B0%8F-OAuth-%EB%A1%9C%EC%A7%81-%EA%B5%AC%ED%98%84-NEWZET-%EA%B2%BD%ED%97%98-%EA%B8%B0%EB%B0%98-%EA%B3%A0%EB%8F%84%ED%99%94</link>
            <guid>https://velog.io/@easy_ho/DDIP-%ED%97%A5%EC%82%AC%EA%B3%A0%EB%82%A0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90-%EA%B8%B0%EB%B0%98-Auth-%EB%B0%8F-OAuth-%EB%A1%9C%EC%A7%81-%EA%B5%AC%ED%98%84-NEWZET-%EA%B2%BD%ED%97%98-%EA%B8%B0%EB%B0%98-%EA%B3%A0%EB%8F%84%ED%99%94</guid>
            <pubDate>Fri, 01 Aug 2025 18:48:12 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>DDIP 프로젝트의 백엔드로 참여하게 되었다. DDIP 프로젝트는 일상의 순간을 돈으로 바꾸기 위한 서비스를 만들기 위해 구성된 경북대학교 컴퓨터학부 학우들의 프로젝트이다.</p>
</blockquote>
<p>백엔드로 참여하기 전, NEWZET 프로젝트에서 구성하였던 Auth 로직의 활용을 위해 인증/인가 파트를 도맡아 최초 서비스를 구성하기로 하였고 좀 더 안정성있고 확장 가능한 코드를 구성을 위한 여러 시도들을 해보았다!</p>
<h2 id="배경">배경</h2>
<p>기존 NEWZET 프로젝트에서 구현했던 객체지향적 JWT 인증 처리와 확장 가능한 OAuth 구조를 베이스로, 더욱 발전된 헥사고날 아키텍처를 도입했습니다. PostgreSQL에서 MySQL로의 마이그레이션, DeviceType 재정의, API 인터페이스 분리 등을 통해 이전 프로젝트의 한계점들을 보완한 백엔드 시스템으로 진화시켰다.</p>
<p>뉴젯의 작업물
<a href="https://velog.io/@easy_ho/%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5%EC%A0%81-JWT-%EC%9D%B8%EC%A6%9D-%EC%B2%98%EB%A6%AC-%EB%B0%8F-%EC%BB%A4%EC%8A%A4%ED%85%80-%EB%A6%AC%EC%A1%B8%EB%B2%84%EC%9D%B8%ED%84%B0%EC%85%89%ED%84%B0-%EB%8F%84%EC%9E%85">[NEWZET - AUTH 1탄] 객체지향적 JWT 인증 처리 및 커스텀 리졸버/인터셉터 도입</a>
<a href="https://velog.io/@easy_ho/NEWZET-%ED%99%95%EC%9E%A5-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5%EC%A0%81-OAuth-%EA%B5%AC%EC%A1%B0-%EC%84%A4%EA%B3%84-%EB%B0%8F-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84">[NEWZET - AUTH 2탄] 확장 가능한 객체지향적 OAuth 구조 설계 및 카카오 소셜 로그인 구현</a></p>
<h2 id="작업-내용">작업 내용</h2>
<blockquote>
<p>기존 NEWZET 프로젝트 대비 주요 개선사항</p>
</blockquote>
<h4 id="1-devicetype-전략적-재설계">1. DeviceType 전략적 재설계</h4>
<p>변경사항: Web/App → TABLET/PHONE
기존: 플랫폼 기반 구분 (웹/앱)
개선: 디바이스 형태 기반 구분 (태블릿/폰)
이유: 반응형 웹 시대에 맞는 더 명확한 사용자 경험 구분</p>
<h4 id="2-헥사고날-아키텍쳐-패턴-고도화-구현">2. 헥사고날 아키텍쳐 패턴 고도화 구현</h4>
<pre><code>DDIP 아키텍처 구조
├── business
│   ├── service/ - 비즈니스 로직 오케스트레이션
│   ├── port/in/ - 인바운드 포트 (Controller가 호출)
│   └── port/out/ - 아웃바운드 포트 (Repository 추상화)
├── domain (순수 비즈니스 로직)
│   ├── 도메인 엔티티 (User, Token, OAuthMapping)
│   ├── 값 객체 (DeviceType, OAuthProvider)
│   └── 도메인 인터페이스
├── Infrastructure
│   ├── JPA Repository 구현체
│   ├── external/ - 외부 API (카카오, Redis)
│   └── entity/ - JPA 엔티티
└── presentation
    ├── controller/ - HTTP 요청 처리
    ├── api/ - API 명세 인터페이스 (Swagger 분리)
    └── interceptor/ - 인증/인가 처리</code></pre><h4 id="3-mysql-마이그레이션-및-uuid-최적화">3. MySQL 마이그레이션 및 UUID 최적화</h4>
<pre><code>// PostgreSQL (기존)
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;

// MySQL (개선)
@Id
@UuidGenerator
@Column(columnDefinition = &quot;char(36)&quot;, updatable = false, nullable = false)
@JdbcTypeCode(SqlTypes.CHAR)
private UUID id;</code></pre><p>해결된 이슈:</p>
<ul>
<li>UUID 타입 호환성 문제 해결</li>
<li>char(36) 타입으로 인덱스 성능 최적화</li>
<li>MySQL 8.0 호환성 보장</li>
</ul>
<h4 id="4--api-설계-interface-segregation-패턴-도입">4.  API 설계 Interface Segregation 패턴 도입</h4>
<p><a href="https://github.com/dev-DDIP/ddip-BE/commit/212f3623c0e3466c62b29ea91ddbafd6242225f2">Feat: OAuth관련 엔드포인트 swagger 설정을 위한 인터페이스 구성</a></p>
<p><a href="https://github.com/dev-DDIP/ddip-BE/commit/b382b76713855a8f643b5ddd379025b5a0e23310">Feat: User용 presentation 단에서 사용할 swagger및 rest 설정 인터페이스 구성</a></p>
<pre><code>// 기존: Controller에 Swagger 어노테이션 직접 추가 (관심사 혼재)
@RestController
@Tag(name = &quot;유저&quot;)
public class UserController {
    @PostMapping(&quot;/signup&quot;)
    @Operation(summary = &quot;회원가입&quot;)
    public ResponseEntity&lt;JwtResponse&gt; signup() { ... }
}

// 개선: API 명세와 구현체 완전 분리
@Tag(name = &quot;유저&quot;, description = &quot;유저 관련 API&quot;)
@RequestMapping(&quot;/api&quot;)
public interface UserApi {
    @Operation(summary = &quot;회원 가입&quot;, description = &quot;OAuth 후 회원가입 진행&quot;)
    ResponseEntity&lt;JwtResponse&gt; signup(@Valid @RequestBody SignupRequest request);
}

@RestController
@RequiredArgsConstructor
public class UserController implements UserApi {
    // 순수한 비즈니스 로직만 집중
}</code></pre><h4 id="5-테스트-인프라-개선">5. 테스트 인프라 개선</h4>
<p><a href="https://github.com/dev-DDIP/ddip-BE/commit/c053693b14ac6d2ea6c136d67ec930fab0a65740">Test: Redis 테스트 컨테이너 설정</a></p>
<p><a href="https://github.com/dev-DDIP/ddip-BE/commit/80c9122dcc16599e4574a7c17a229369db22faf0">Test: MySQL의 테스트 컨테이너를 primary로 테스트 환경에서 설정</a></p>
<p><a href="https://github.com/dev-DDIP/ddip-BE/commit/224d40987d8fe605e4cd5a414361ac84cc7e8ebd">Test: Redis의 테스트 컨테이너를 primary로 테스트 환경에서 설정</a></p>
<p><a href="https://github.com/dev-DDIP/ddip-BE/commit/98aa6381ca5bd94f6666d1c52133e0bb2645cf57">Test: gitignore된 환경변수들을 테스트환경에서 임의의 값으로 사용할 수 있도록 구성</a></p>
<p>주요 항목: </p>
<ul>
<li>TEST 하위 config에 MySQL, Redis 관련 컨테이너 구성</li>
<li><blockquote>
<p>실제 DB를 사용하지 않고 통일된 환경에서 테스트 가능</p>
</blockquote>
</li>
<li>primary bean 설정으로 테스트 환경에서만 작동하도록 구현</li>
<li>CI 환경에서의 env 제거</li>
</ul>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/42ae435b-35d4-4288-a76d-cff2e40b21bd/image.png" alt=""></p>
<p>마지막 X들은 비밀...</p>
<blockquote>
<p>PR : <a href="https://github.com/dev-DDIP/ddip-BE/pull/17">https://github.com/dev-DDIP/ddip-BE/pull/17</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NEWZET] FCM 알림 전송 비동기 배치처리]]></title>
            <link>https://velog.io/@easy_ho/NEWZET-FCM-%EC%95%8C%EB%A6%BC-%EC%A0%84%EC%86%A1-%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%B0%B0%EC%B9%98%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@easy_ho/NEWZET-FCM-%EC%95%8C%EB%A6%BC-%EC%A0%84%EC%86%A1-%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%B0%B0%EC%B9%98%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Mon, 23 Jun 2025 06:12:26 GMT</pubDate>
            <description><![CDATA[<h1 id="개요">개요</h1>
<p>FCM 알림 전송을 위한 비동기 배치 처리 시스템 구현으로 대량 트래픽 상황에서 시스템 안정성 및 응답성 향상</p>
<h1 id="배경">배경</h1>
<p>메일 서비스 특성상 단기간에 대량의 메일 수신이 발생하며, 각 메일 수신 시마다 FCM 알림을 전송해야 하는 상황이다. 
동기 처리 방식으로 구현한다면 다음과 같은 문제점이 발생할 것이다:</p>
<p>응답 지연: 메일 수신 → Article 저장 → FCM 전송이 모두 동기로 처리되어 사용자 <strong>응답 시간이 급격히 증가</strong>
시스템 부하: 대량 메일 수신 시 FCM API 호출이 <strong>메인 애플리케이션 스레드를 블로킹</strong>
장애 전파: FCM 전송 실패가 Article <strong>저장 프로세스에 영향을 미쳐</strong> 전체 시스템 불안정
메모리 부족: 대량의 FCM 요청을 <strong>동시에 처리</strong>할 때 <strong>메모리 사용량 급증</strong></p>
<p>이러한 문제 발생을 방지하기 위해 비동기 배치 처리 시스템이 필요했다.</p>
<h1 id="변경된-점">변경된 점</h1>
<h3 id="1-fcm-배치-처리-인터페이스-구조-설계">1. FCM 배치 처리 인터페이스 구조 설계</h3>
<p><a href="https://github.com/newzet-dev/mail-server/commit/a196fc1d3cc10794d74a279b9f0a3ddb015e82aa">Feat: BatchProducer와 BatchConsumer를 상속받아 인터페이스 Article용으로 제작</a>
<a href="https://github.com/newzet-dev/mail-server/commit/597b15f33354fa360cf53892bafd9dcd4e0580e4">Feat: BatchProducer와 BatchConsumer를 상속받은 FCM용 배치 인터페이스 생성</a></p>
<ul>
<li>common에 정의된 배치 BatchProducer, BatchConsumer 인터페이스를 FCM 도메인에 맞게 상속하여 개별 인터페이스 제작</li>
<li>FcmBatchProducer, FcmBatchConsumer 인터페이스를 통해 도메인별 배치 처리 구현</li>
<li>공통 인터페이스의 재사용성과 코드 통일성 향상, OCP(Open-Closed Principle) 원칙 준수</li>
</ul>
<h3 id="2-redis-stream-기반-비동기-배치-처리-시스템-구현">2. Redis Stream 기반 비동기 배치 처리 시스템 구현</h3>
<p><strong>시스템 안정성 및 응답성 향상</strong></p>
<p><strong>Producer</strong>: <a href="https://github.com/newzet-dev/mail-server/commit/08ee03bc20a8e48308d05865830635ea618063a0">Feat: FcmBatchProducer의 Redis 기반 구현체 추가</a></p>
<ul>
<li>FCM 알림 요청을 Redis Stream에 즉시 추가 후 응답 반환</li>
<li>ReactiveRedisTemplate을 사용한 논블로킹 처리로 메인 스레드 영향 없음</li>
<li>유효하지 않은 알림에 대한 사전 검증 및 필터링</li>
</ul>
<p><strong>Consumer</strong>: <a href="https://github.com/newzet-dev/mail-server/commit/b6abb99bbb5db07b64a8b799fba8398146df0e50">Feat: FcmBatchConsumer의 Redis 기반 구현체 추가</a></p>
<ul>
<li>전용 스레드 풀에서 배치 단위로 FCM 알림 처리</li>
<li>Firebase Messaging API를 통한 실제 알림 전송</li>
<li>배치 크기 및 타임아웃 설정 기반 유연한 처리</li>
</ul>
<p><strong>장애 격리 및 부하 분산</strong></p>
<ul>
<li>FCM 전송 실패가 Article 저장 프로세스에 영향을 주지 않음</li>
<li>단기간 대량 메일 수신 시 FCM 전송을 시간에 걸쳐 분산 처리</li>
<li>Redis Stream을 통한 메모리 효율적인 큐 관리</li>
</ul>
<h3 id="3-fcm-토큰-관리-및-알림-전송용-아키텍쳐">3. FCM 토큰 관리 및 알림 전송용 아키텍쳐</h3>
<p><strong>Repository Layer</strong>: <a href="https://github.com/newzet-dev/mail-server/commit/88fa1886ffaca17850156fe58d52a5b8226f9f99">Feat: 유저 ID 기반 FcmToken 전체 조회용 repo 구현체 메서드 추가</a></p>
<ul>
<li>FCM 토큰의 CRUD 작업 처리</li>
<li>사용자별 토큰 조회 및 관리</li>
</ul>
<p><strong>Service Layer</strong>: <a href="https://github.com/newzet-dev/mail-server/commit/df81cd2185fc33bd7e8f19e1577dca499e8bdc4a">Feat: fcm 비즈니스 로직에 sendFcmWhenMailReceivedBatch 추가</a></p>
<ul>
<li>FCM 토큰 등록/갱신/삭제 비즈니스 로직</li>
<li>메일 수신 시 배치 알림 전송 처리</li>
<li>사용자당 다중 디바이스 지원 (중복 FCM 토큰 허용)</li>
</ul>
<p><strong>Orchestrator Layer</strong>: <a href="https://github.com/newzet-dev/mail-server/commit/d27f2a781184ad040e23bf8308996da4c36fb944">Feat: FcmTokenOrchestrator에 하위 서비스단에서 추가된 로직 추가</a></p>
<ul>
<li>서비스 비즈니스 로직 기반 연결 매개체</li>
</ul>
<p><strong>Controller Layer</strong>: <a href="https://github.com/newzet-dev/mail-server/commit/925e0c657e4356abdb0c1736e455207bc5fcede4">Feat: fcm용 실시간 배치 처리 상태 모니터링 및 제어 api 제공 컨트롤러 추가</a></p>
<ul>
<li>FCM 토큰 관리 REST API 제공</li>
</ul>
<h3 id="4-토큰-관리-및-실패-처리">4. 토큰 관리 및 실패 처리</h3>
<p><a href="https://github.com/newzet-dev/mail-server/commit/b6abb99bbb5db07b64a8b799fba8398146df0e50">Feat: FcmBatchConsumer의 Redis 기반 구현체 추가</a></p>
<p><a href="https://github.com/newzet-dev/mail-server/commit/10e6a4a23fa005bde9104faa04e34481b14fd63c">Feat: 배치 처리 결과 전달을 위한 클래스 추가</a></p>
<ul>
<li>FCM 전송 실패 시 상세 로깅 처리</li>
<li>자동 토큰 정리: 유효하지 않은 토큰(만료/삭제된 앱) 자동 삭제로 불필요한 처리 방지</li>
<li>배치 처리 결과 통계 제공 (FcmBatchProcessingResult)</li>
</ul>
<h3 id="5-article-도메인과의-완전한-비동기-연동">5. Article 도메인과의 완전한 비동기 연동</h3>
<p><a href="https://github.com/newzet-dev/mail-server/commit/14aef60c7cc57bd0492f22ad29465d889d74f66d">Feat: Article 저장 성공 시 FCM 배치 처리 호출 로직 추가</a></p>
<p><a href="https://github.com/newzet-dev/mail-server/commit/b6abb99bbb5db07b64a8b799fba8398146df0e50">Feat: FcmBatchConsumer의 Redis 기반 구현체 추가</a></p>
<ul>
<li>Article 저장 완료 후 FCM 알림을 배치 큐에 추가만 하고 즉시 응답</li>
<li>ArticleRedisBatchConsumerImpl에서 Article 저장 성공 시 FCM 배치 처리 호출</li>
<li>응답 시간 대폭 단축: 동기 FCM 전송 대기 시간 제거</li>
</ul>
<h2 id="참고자료">참고자료</h2>
<h3 id="배치-처리-흐름-및-성능-이점">배치 처리 흐름 및 성능 이점</h3>
<pre><code>1. 메일 수신 → Article 도메인 배치 처리
2. Article 저장 성공 → FCM 알림 배치 큐에 추가 (비동기)
3. 즉시 사용자 응답 반환
4. 백그라운드에서 FCM 배치 Consumer가 일정 주기/크기로 알림 전송
5. 전송 실패 시 유효하지 않은 토큰 자동 삭제</code></pre><h3 id="핵심-성능-최적화-포인트">핵심 성능 최적화 포인트</h3>
<ul>
<li>Redis Stream을 통한 비동기 처리로 응답 시간 단축</li>
<li>장애 격리로 FCM 장애가 메일 처리에 영향 없음</li>
<li>배치 처리로 대량 트래픽 상황에서도 안정적 처리</li>
<li>ReactiveRedisTemplate 사용으로 논블로킹 I/O 구현</li>
<li>실패한 토큰 자동 정리로 시스템 효율성 지속 향상 및 유저별 다중 디바이스 FCM Token 허용 가능</li>
</ul>
<h3 id="firebase-설정-관련-주의사항">Firebase 설정 관련 주의사항</h3>
<p>firebase-service-account.json 파일을 resourses 하위에 두고 .gitignore에 추가한 상태
향후 컨테이너 환경에서 Firebase 인증 파일 마운트 작업으로 Docker Compose에서 사용할 수 있는 환경 구성 필요</p>
<h3 id="로컬-환경에서-웹-기반-fcm-정상-작동-확인">로컬 환경에서 웹 기반 fcm 정상 작동 확인</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/1bf12eac-1bab-478b-b80b-15ac2846550b/image.png" alt=""></p>
<hr>
<h2 id="pr-코멘트-반영-변경항목">PR 코멘트 반영 변경항목</h2>
<h3 id="1-인터페이스-→-추상클래스-패턴-기반-아키텍처-도입">1. 인터페이스 → 추상클래스 패턴 기반 아키텍처 도입</h3>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/92/commits/0a908fcca27f58738cd5b3823193acfb07c32158">Feat: BatchProducer에 대한 추상클래스 구현체 생성</a></p>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/92/commits/558ec02c6244cf269c31a6373177b2cb112ffe83">Feat: BatchConsumer에 대한 추상클래스 구현체 생성</a></p>
<p><strong>변경 전: 인터페이스만 사용하는 구조</strong></p>
<pre><code>Interface
├── BatchProducer&lt;T&gt;
├── BatchConsumer
    │
    ├── ArticleBatchProducer
    ├── FcmBatchProducer  
    ├── ArticleBatchConsumer
    ├── FcmBatchConsumer
        │
        └── 구현체들
            ├── ArticleRedisBatchProducerImpl
            ├── FcmRedisBatchProducerImpl
            ├── ArticleRedisBatchConsumerImpl (Redis 연결, 직렬화, 배치처리 등 중복코드)
            └── FcmRedisBatchConsumerImpl (Redis 연결, 직렬화, 배치처리 등 중복코드)</code></pre><blockquote>
<p>모든 구현체에서 Redis 처리 로직 등 일부 코드가 중복된다는 문제 발생</p>
</blockquote>
<p><strong>변경 후: 추상클래스 + 인터페이스 구조</strong></p>
<pre><code>Interface
├── BatchProducer&lt;T&gt;
├── BatchConsumer
    │
    ├── AbstractBatchProducer&lt;T&gt; (공통 로직)
    ├── AbstractBatchConsumer&lt;T&gt; (공통 로직)
        │
        ├── ArticleBatchProducer
        ├── FcmBatchProducer
        ├── ArticleBatchConsumer  
        ├── FcmBatchConsumer
            │
            └── 구현체들
                ├── ArticleRedisBatchProducerImpl (도메인 로직만)
                ├── FcmRedisBatchProducerImpl (도메인 로직만)
                ├── ArticleRedisBatchConsumerImpl (Article 처리 로직만)
                └── FcmRedisBatchConsumerImpl (FCM 전송 로직만)</code></pre><ul>
<li>AbstractBatchProducer<T> 및 AbstractBatchConsumer<T> 추상 클래스 생성</li>
<li>Producer와 Consumer의 공통 로직을 추상 클래스로 분리하여 코드 중복 제거</li>
<li>각 도메인별 구현체는 도메인 특화 로직에만 집중하도록 개선</li>
</ul>
<blockquote>
<p><strong>변경으로 인한 개선 효과</strong></p>
</blockquote>
<ul>
<li>코드 중복 제거: ~40% 코드량 감소</li>
<li>유지보수성 향상: 공통 로직 한 곳에서 관리</li>
<li>개발 생산성: 새 배치 추가시 도메인 로직만 구현</li>
<li>관심사 분리: 인프라 로직 vs 비즈니스 로직 명확히 분리</li>
</ul>
<h3 id="2-fcm-토큰-관리-로직-개선">2. FCM 토큰 관리 로직 개선</h3>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/92/commits/cc3b958a9cdfc270e2d8239b3256043dde33cf9b">Refactor: fcmNotification에 대한 검증을 생선단계에서 실행하도록 변경</a></p>
<p>commit. : <a href="https://github.com/newzet-dev/mail-server/pull/92/commits/5d0800ab564561d27c7dce18464154989625497a">Refactor: FcmRedisBatchConsumerImpl에서 유효하지 않은 fcmToken에 대한 삭제를 repo단에서 수행하도록 변경</a></p>
<ul>
<li>FCM 알림 검증 로직을 생성 단계에서 실행하도록 변경</li>
<li>유효하지 않은 FCM 토큰 삭제 로직을 Repository 레이어로 이동</li>
<li>Producer 단계에서 불필요한 유효성 검사 로직 제거</li>
</ul>
<h3 id="3-도메인별-배치-구현체-리팩토링">3. 도메인별 배치 구현체 리팩토링</h3>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/92/commits/52762a628d7e91794be66951b8c911509071c411">Feat: Aritcle 하위 Batch에 추상클래스 상속</a></p>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/92/commits/aef3474297cc37874187dcd72289df8789d43c31">Feat: Fcm 하위 Batch에 추상클래스 상속</a></p>
<ul>
<li>ArticleRedisBatchProducerImpl / ArticleRedisBatchConsumerImpl을 추상 클래스 상속 구조로 변경</li>
<li>FcmRedisBatchProducerImpl / FcmRedisBatchConsumerImpl을 추상 클래스 상속 구조로 변경</li>
<li>각 구현체는 도메인별 특화 로직만 구현하도록 단순화</li>
</ul>
<hr>
<h2 id="pr-코멘트-반영-변경항목-2">PR 코멘트 반영 변경항목 2</h2>
<h3 id="1-fcm-전송-로직-분리">1. FCM 전송 로직 분리</h3>
<blockquote>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/92/commits/562578a748309948aefc71ea6b808e7b31b5eac2">Feat: FcmToken 비즈니스 로직에서 FcmSender 관련 로직 분리 및 batch가 아닌 Fcm 메시지 전송 로직 구현</a></p>
</blockquote>
<ul>
<li>기존: FcmTokenService에서 토큰 관리와 FCM 전송을 모두 처리</li>
<li>개선: FcmSenderService 신규 생성하여 FCM 전송 책임만 담당</li>
</ul>
<h3 id="2-fcmsenderservice-신규-생성">2. FcmSenderService 신규 생성</h3>
<pre><code>@Service
public class FcmSenderService {
    // 배치 전송 메서드
    public void sendFcmWhenMailReceivedBatch(UUID userId, String fromName, String title)
    public void sendFcmNotBatch(UUID userId, String fromName, String title)

    // 단일 전송 메서드
    public void send(FcmNotification fcmNotification)

    // 내부 유틸리티 메서드들
    private Message buildFcmMessage(FcmNotification fcmNotification)
    private void handleSendFailure(FcmNotification fcmNotification, Exception e)
    private boolean isInvalidTokenError(Exception e)
}</code></pre><h3 id="3-consumer-로직-개선">3. Consumer 로직 개선</h3>
<blockquote>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/92/commits/d4a3bcd183b07d9509a2c5659158b831530d9476">Refactor: 분리된 FcmSender 로직으로 Article 배치 로직 의존성 변경</a></p>
</blockquote>
<ul>
<li>기존: Consumer에서 직접 FCM 전송 실패 시 토큰 삭제 처리</li>
<li>개선: FcmSenderOrchestrator를 통해 통합된 전송 로직 사용</li>
<li><blockquote>
<p>FCM 전송 실패 시 토큰 삭제 로직이 FcmSenderService 내부로 캡슐화됨</p>
</blockquote>
</li>
</ul>
<h3 id="4-orchestrator-레이어-활용">4. Orchestrator 레이어 활용</h3>
<ul>
<li>FcmSenderOrchestrator를 통해 FCM 전송 로직에 대한 진입점 제공</li>
<li>Consumer는 더 이상 직접적인 FCM 처리 로직을 포함하지 않음</li>
</ul>
<hr>
<blockquote>
<p><strong>PR 보러가기</strong> : <a href="https://github.com/newzet-dev/mail-server/pull/92">https://github.com/newzet-dev/mail-server/pull/92</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NEWZET] 메일 수신한 아티클 DB 저장 로직 배치처리]]></title>
            <link>https://velog.io/@easy_ho/NEWZET-%EB%A9%94%EC%9D%BC-%EC%88%98%EC%8B%A0%ED%95%9C-%EC%95%84%ED%8B%B0%ED%81%B4-DB-%EC%A0%80%EC%9E%A5-%EB%A1%9C%EC%A7%81-%EB%B0%B0%EC%B9%98%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@easy_ho/NEWZET-%EB%A9%94%EC%9D%BC-%EC%88%98%EC%8B%A0%ED%95%9C-%EC%95%84%ED%8B%B0%ED%81%B4-DB-%EC%A0%80%EC%9E%A5-%EB%A1%9C%EC%A7%81-%EB%B0%B0%EC%B9%98%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Mon, 02 Jun 2025 13:04:18 GMT</pubDate>
            <description><![CDATA[<h1 id="개요">개요</h1>
<p>메일 저장 프로세스의 전반적인 성능과 확장성을 개선하기 위해 Redis 기반 배치 처리 시스템과 효율적인 중복 감지 메커니즘을 구현
이를 통해 데이터베이스 부하를 줄이고, 대량의 메일 데이터를 빠르게 처리 가능한 인프라가 도입</p>
<h1 id="배경">배경</h1>
<ul>
<li>개별 처리로 인한 성능 저하: 각 아티클이 도착할 때마다 즉시 DB에 저장하여 불필요한 DB 연결과 트랜잭션이 반복 발생</li>
<li>시스템 확장성 제한: 트래픽 증가 시 DB에 직접적인 부하 증가</li>
<li>비효율적인 자원 사용: 단일 아티클 처리로 인한 시스템 리소스 낭비</li>
<li>개발 과정중, 중복 감지 로직에서 exist로 db에 접근하여 모든 중복 체크가 매번 데이터베이스 쿼리를 실행해 성능을 저하하는 문제 발생</li>
</ul>
<h1 id="변경된-점">변경된 점</h1>
<h3 id="1-redis-stream을-활용한-배치-처리-도입">1. Redis Stream을 활용한 배치 처리 도입</h3>
<p><a href="https://github.com/newzet-dev/mail-server/commit/6c8bd26a612c340456d45d2d69df7b679b64f209">Feat: BatchProcessor에 대한 Article Redis 배치 구현체 추가</a></p>
<p><strong>Redis Stream 기반 배치 구현</strong></p>
<ul>
<li>ArticleRedisBatchProcessorImpl 클래스를 통해 메시지 수집 및 처리</li>
<li>설정 가능한 배치 크기와 타임아웃으로 유연한 배치 처리</li>
<li>배치 상태 모니터링 및 관리 기능 구현</li>
<li>비동기 메시지 처리</li>
</ul>
<p><strong>아티클 수신 시 Redis Stream에 비동기적으로 추가 (addToBatch 메서드)</strong></p>
<ul>
<li>클라이언트 응답 시간 개선 목적</li>
<li>트래픽 피크 시에도 안정적인 처리 가능<pre><code>public void addToBatch(ArticleDto articleDto) {
  // Redis Stream에 비동기적으로 메시지 추가
  reactiveRedisTemplate.opsForStream()
      .add(ARTICLE_STREAM_KEY, fields)
      .subscribeOn(Schedulers.boundedElastic())
      .subscribe();
}</code></pre></li>
</ul>
<p><strong>효율적인 배치 수집 및 처리</strong></p>
<ul>
<li>설정된 크기(batchSize)에 도달하거나 제한 시간(timeoutSeconds)이 경과하면 처리 트리거</li>
<li>효율과 응답성의 최적 균형점 제공<pre><code>receiver.receive(Consumer.from(CONSUMER_GROUP, CONSUMER_NAME),
      StreamOffset.create(ARTICLE_STREAM_KEY, ReadOffset.lastConsumed()))
  .bufferTimeout(batchConfig.getBatchSize(),
      Duration.ofSeconds(batchConfig.getTimeoutSeconds()))
  .doOnNext(records -&gt; {
      if (!records.isEmpty()) {
          processBatchWithAck(records);
      }
  })
  .subscribe();</code></pre></li>
</ul>
<h3 id="2-다중-레벨-중복-감지-메커니즘">2. 다중 레벨 중복 감지 메커니즘</h3>
<p><a href="https://github.com/newzet-dev/mail-server/commit/6c8bd26a612c340456d45d2d69df7b679b64f209">Feat: BatchProcessor에 대한 Article Redis 배치 구현체 추가</a></p>
<p><strong>배치 내 중복 감지</strong></p>
<ul>
<li>단일 배치 내에서 중복된 아티클을 메모리 내에서 신속하게 필터링</li>
<li>동일한 캐시 키를 가진 아티클을 그룹화하여 첫 번째 항목만 처리</li>
</ul>
<pre><code>// 배치 내 중복을 효율적으로 그룹화
Map&lt;String, List&lt;ArticleEntityDto&gt;&gt; keyToArticlesMap = new HashMap&lt;&gt;();
for (ArticleDto articleDto : articles) {
    String cacheKey = generateSimpleCacheKey(/*...*/);
    keyToArticlesMap.computeIfAbsent(cacheKey, k -&gt; new ArrayList&lt;&gt;()).add(entityDto);
}</code></pre><p><strong>Redis 캐시 기반 중복 감지</strong></p>
<ul>
<li>DB 쿼리 대신 Redis를 사용한 빠른 중복 체크</li>
<li>효율적인 캐시 키 생성 알고리즘 구현</li>
<li>TTL 기반 캐시 관리로 메모리 사용 효율화 (기본 10분)</li>
</ul>
<pre><code>// Redis를 통한 중복 체크
String cachedValue = redisTemplate.opsForValue().get(cacheKey);
if (cachedValue != null) {
    // 중복된 아티클 - 건너뜀
    result.incrementDuplicateCount();
    result.incrementCacheHitCount();
} else {
    // 새로운 아티클 - 저장 및 캐싱
    toSave.add(entityDto);
    markForCaching(toCache, cacheKey);
}</code></pre><h3 id="3-데이터베이스-배치-처리-최적화">3. 데이터베이스 배치 처리 최적화</h3>
<p><a href="https://github.com/newzet-dev/mail-server/commit/3e1337cc607a3acb6ddb3a21502be3f01312b1e7">Feat: ArticleRepository 배치처리 적용 구현체 도입</a></p>
<p><strong>EntityManager를 활용한 배치 저장</strong></p>
<ul>
<li>JPA의 saveAll 대신 EntityManager를 직접 사용한 최적화된 배치 처리</li>
<li>배치 크기(50개) 단위로 flush/clear를 수행하여 영속성 컨텍스트 효율적 관리</li>
<li>대량 삽입 시 JPA 성능 문제 해결</li>
</ul>
<pre><code>// 최적화된 배치 저장 구현
private static final int BATCH_SIZE = 50;

for (ArticleEntity entity : entities) {
    entityManager.persist(entity);
    i++;
    if (i % BATCH_SIZE == 0) {
        entityManager.flush();
        entityManager.clear();
    }
}</code></pre><h3 id="4-객체지향-설계-원칙-적용">4. 객체지향 설계 원칙 적용</h3>
<p><a href="https://github.com/newzet-dev/mail-server/commit/f7c601d08ac6b69e76070494b2951ea6426cacf0">Feat: BatchProcessor 인터페이스 정의</a></p>
<p><a href="https://github.com/newzet-dev/mail-server/commit/41a64bd516399505deaa85ead40eea090e7e2f62">Feat: Article 저장을 위한 인터페이스 정의</a></p>
<p><a href="https://github.com/newzet-dev/mail-server/commit/cc5b0e8997ffe39a089ac3a24a1386f0c30a01dc">Feat: ArticleRedisBatchProcessor구현체에서 사용될 dto 정의</a></p>
<p><strong>인터페이스 기반 설계로 OCP(Open-Closed Principle) 준수</strong></p>
<ul>
<li>BatchProcessor 인터페이스를 통해 다양한 메시징 시스템(Redis, RabbitMQ, Kafka 등) 지원 가능</li>
<li>구현체 교체 시 코어 로직 변경 없이 확장 가능</li>
</ul>
<pre><code>public interface BatchProcessor {
    void addToBatch(ArticleDto articleDto);
    Map&lt;String, Object&gt; getBatchStatus();
    void startProcessing();
    void stopProcessing();
}</code></pre><p><strong>책임 분리를 통한Single Responsibility Principle 준수</strong></p>
<pre><code>배치 처리 로직을 다음 책임으로 메서드 분리:
1. 메시지 수집 (processBatchesAsync)
2. 아티클 변환 및 캐시 키 생성 (prepareArticlesWithCacheKeys)
3. 중복 감지 (identifyUniqueArticles)
4. DB 저장 (saveToDatabaseAndUpdateCounters)
5. Redis 캐시 업데이트 (updateRedisCache)</code></pre><p><strong>로직 내부 데이터 캡슐화</strong></p>
<ul>
<li>BatchProcessingResult와 BatchSaveData를 통해 처리 상태와 결과를 객체로 캡슐화</li>
<li>코드 가독성 향상 및 데이터 응집도 증가</li>
</ul>
<h3 id="5-테스트">5. 테스트</h3>
<p><a href="https://github.com/newzet-dev/mail-server/commit/b9529fde86966e99a6b980fcaf33caf2f2643155">Test: Article 도메인 유닛테스트</a></p>
<p><a href="https://github.com/newzet-dev/mail-server/commit/bf4c46447673f6451e2305b8927dc1782cf6bc5f">Test: ArticleRepository에 대한 배치처리 및 메서드 테스트</a></p>
<p><a href="https://github.com/newzet-dev/mail-server/commit/281c1df97808c52c2e98da69b952806be741f7c2">Test: ArticleRedisBatch처리와 관련된 통합테스트</a></p>
<ul>
<li>분기 커버리지 100% (일부 설정 제외)</li>
<li>엣지 케이스를 포함한 단위 테스트 작성</li>
<li>Redis와 JPA 상호작용을 검증하는 통합 테스트 구현</li>
</ul>
<h2 id="참고자료">참고자료</h2>
<p><a href="https://docs.spring.io/spring-data/redis/reference/">Spring Data Redis 공식문서</a>
<a href="https://velog.io/@dev_hammy/GuideAccessing-Data-Reactively-with-Redis">Guide_Accessing Data Reactively with Redis</a></p>
<h1 id="추가-변경사항">추가 변경사항</h1>
<h2 id="기능-개선-feat">기능 개선 (Feat)</h2>
<p><strong>1. 배치 처리 구조 개선</strong></p>
<blockquote>
<p><a href="https://github.com/newzet-dev/mail-server/pull/79/commits/dff7c070dbcf606b9d9d0591e1055ed87a584fef">Feat: db 배치단위 50 -&gt; 100으로 조정 및 persist로 통일</a></p>
</blockquote>
<blockquote>
<p><a href="https://github.com/newzet-dev/mail-server/pull/79/commits/fafefd8237c998dda30c116de22444781e0d657d">Feat: saveAll 과정중 일부가 persist 단계에서 실패해도 로그만 남기고 이외의 데이터는 정상 저장되도록 구현</a></p>
</blockquote>
<ul>
<li>JDBC단에서의 배치처리 적용</li>
<li>배치 단위를 50에서 100으로 증가하여 성능 최적화</li>
<li>DB 처리 방식을 persist로 통일하여 일관성 확보</li>
<li>saveAll 과정에서 일부 데이터가 persist 단계에서 실패해도 로그만 남기고 나머지 데이터는 정상 저장되도록 개선 → 안정성 향상
아키텍처 개선</li>
</ul>
<hr>
<h2 id="실제-db단에서의-배치처리-확인">실제 DB단에서의 배치처리 확인</h2>
<blockquote>
<p><strong>generate_statistics</strong> 옵션을 통해 redis를 통해 배치단위로 모아준 객체들을
saveAll메서드로 저장한 경우 실제 배치처리가 db단에서 적용되는지 확인
-&gt; 단순 saveAll메서드 내부 flush 단위를 batch 단위만큼 잡은것으로는 정상적으로 배치처리가 작동하지 않은것을 확인</p>
</blockquote>
<pre><code>Hibernate: insert into article (content_url,created_at,deleted_at,from_domain,from_name,is_like,is_read,is_share,mailing_list,title,to_user_id,id) values (?,?,?,?,?,?,?,?,?,?,?,?)
Hibernate: insert into article (content_url,created_at,deleted_at,from_domain,from_name,is_like,is_read,is_share,mailing_list,title,to_user_id,id) values (?,?,?,?,?,?,?,?,?,?,?,?)
Hibernate: insert into article (content_url,created_at,deleted_at,from_domain,from_name,is_like,is_read,is_share,mailing_list,title,to_user_id,id) values (?,?,?,?,?,?,?,?,?,?,?,?)
2025-05-27T14:41:28.324+09:00  INFO 95192 --- [     parallel-1] i.StatisticalLoggingSessionEventListener : Session Metrics {
    16195917 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    1053708 nanoseconds spent preparing 3 JDBC statements;
    40412375 nanoseconds spent executing 3 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    59803583 nanoseconds spent executing 1 flushes (flushing a total of 3 entities and 0 collections);
    0 nanoseconds spent executing 0 pre-partial-flushes;
    0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}</code></pre><blockquote>
<p>3개의 insert에 대해 3개의 JDBC 접근 발생 및 JDBC 배치는 0회 실행. 이를 해결하기 위해, </p>
</blockquote>
<pre><code>      hibernate.jdbc.batch_size: 100
      hibernate.order_inserts: true
      hibernate.order_updates: true</code></pre><blockquote>
<p>application.yml 자체에 해당 설정을 적용 (참고. 단일 save에서도 배치가 적용되지만 오버헤드는 크지 않음)</p>
</blockquote>
<pre><code>Hibernate: insert into article (content_url,created_at,deleted_at,from_domain,from_name,is_like,is_read,is_share,mailing_list,title,to_user_id,id) values (?,?,?,?,?,?,?,?,?,?,?,?)
Hibernate: insert into article (content_url,created_at,deleted_at,from_domain,from_name,is_like,is_read,is_share,mailing_list,title,to_user_id,id) values (?,?,?,?,?,?,?,?,?,?,?,?)
Hibernate: insert into article (content_url,created_at,deleted_at,from_domain,from_name,is_like,is_read,is_share,mailing_list,title,to_user_id,id) values (?,?,?,?,?,?,?,?,?,?,?,?)
2025-05-27T14:44:05.938+09:00  INFO 97290 --- [     parallel-1] i.StatisticalLoggingSessionEventListener : Session Metrics {
    20383042 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    278958 nanoseconds spent preparing 1 JDBC statements;
    0 nanoseconds spent executing 0 JDBC statements;
    17699875 nanoseconds spent executing 1 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    38036083 nanoseconds spent executing 1 flushes (flushing a total of 3 entities and 0 collections);
    0 nanoseconds spent executing 0 pre-partial-flushes;
    0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}</code></pre><blockquote>
<p><strong>정상적으로 3개의 insert에 대해 1개의 JDBC batches가 실행된것을 확인</strong></p>
</blockquote>
<hr>
<p><strong>2. 기존 BatchProcess를 Producer와 Consumer로 분리하여 역할 명확화</strong></p>
<blockquote>
<p><a href="https://github.com/newzet-dev/mail-server/pull/79/commits/af344781d4e34c2819b2c0a39e57a3126c90e9ef">Refactor: 기존 BatchProcess를 Producer와 Consumer로 분리</a></p>
</blockquote>
<ul>
<li>변경에 따른 의존성 처리 및 이름 변경</li>
</ul>
<h2 id="코드-정리-removerefactor">코드 정리 (Remove/Refactor)</h2>
<p><strong>1. 불필요한 코드 제거</strong></p>
<blockquote>
<p><a href="https://github.com/newzet-dev/mail-server/pull/79/commits/3d6f375145114c519346458f386ce8b5aec70d3e">Remove: 사용하지 않는 메서드 삭제</a></p>
</blockquote>
<blockquote>
<p><a href="https://github.com/newzet-dev/mail-server/pull/79/commits/fe6eccb821e99d1e929797b0ede802b5ae1d1e71">Remove: 사용하지 않는 JPA 삭제</a></p>
</blockquote>
<ul>
<li>사용하지 않는 메서드 삭제</li>
<li>사용하지 않는 JPA 관련 코드 삭제</li>
</ul>
<p><strong>2. 가독성 개선</strong></p>
<blockquote>
<p><a href="https://github.com/newzet-dev/mail-server/pull/79/commits/19c15a97e8936eac17fcc3d41f03139f7407f4cb">Refactor: 변수명 가독성 좋게 변경</a></p>
</blockquote>
<ul>
<li>변수명을 더 명확하게 변경하여 코드 가독성 향상</li>
</ul>
<h2 id="문서화-및-테스트-docstest">문서화 및 테스트 (Docs/Test)</h2>
<p><strong>1. 문서 개선</strong></p>
<blockquote>
<p><a href="https://github.com/newzet-dev/mail-server/pull/79/commits/2295ed53b8691931bce0e7d433676a8ae86e1e37">Docs: 관리자용 api 명시</a></p>
</blockquote>
<ul>
<li>관리자용 API 명시적으로 문서화</li>
</ul>
<p><strong>2. 테스트 코드 업데이트</strong></p>
<blockquote>
<p><a href="https://github.com/newzet-dev/mail-server/pull/79/commits/1a71d096b5b609a97dcee85289ce2fc4abfe6bb0">Test: 변경에 따른 테스트코드 의존성 변경</a></p>
</blockquote>
<ul>
<li>변경된 로직에 맞춰 테스트 코드 수정</li>
<li>Producer/Consumer 구조 변경에 따른 테스트 코드 의존성 변경</li>
</ul>
<h2 id="의문사항-해결">의문사항 해결</h2>
<blockquote>
<p>수신 -&gt; 중복제거와 동시에 redis key 삭제(completableFutre) -&gt; for문으로 중복제거 후 redis 캐시 키 한번 더 확인 -&gt; db 저장 -&gt; 캐시 update(?) 인 것 같습니다.</p>
</blockquote>
<p>라는 팀원의 의문이 있었다.</p>
<blockquote>
<p><strong>결론적으로 중복제거 중 현재 stream단의 중복제거와 수신한 객체에 대한 db 저장 전 중복제거는 동일하지 않다!</strong></p>
</blockquote>
<pre><code>CompletableFuture.runAsync(() -&gt; {
    List&lt;String&gt; ackedMessageIds = new ArrayList&lt;&gt;();

    for (MapRecord&lt;String, String, String&gt; record : records) {
        String messageId = record.getId().getValue();
        try {
            redisTemplate.opsForStream()
                .acknowledge(ARTICLE_STREAM_KEY, CONSUMER_GROUP, messageId);
            ackedMessageIds.add(messageId);
        } catch (Exception e) {
            log.warn(&quot;Ack failed for message {}, skipping for now: {}&quot;, messageId,
                e.getMessage());
        }
    }

    if (!ackedMessageIds.isEmpty()) {
        try {
            redisTemplate.opsForStream()
                .delete(ARTICLE_STREAM_KEY, ackedMessageIds.toArray(new String[0]));
        } catch (Exception e) {
            log.error(&quot;Failed to delete acked messages: {}&quot;, e.getMessage(), e);
        }
    }
}, ackExecutorService);</code></pre><p>위 로직에서의 ack 수신 후 delete의 로직은 비동기적으로 일어나며, db저장의 중복 제거가 아닌 redis stream의 메시지들을 두번 중복해서 처리하지 않기 위한 ack 수신 후 삭제의 과정이다.</p>
<p>실제 db단에서의 중복 처리는 배치단에서의 캐시 키를 이용한 map을 통한 중복전달 전처리 후, 그룹화하여 1차적인 중복처리를 진행하고, 배치 밖에서의 중복를 처리하기 위해 redis를 이용하여 ttl 10분까지리의 정보를 앞서 생성한 캐시 키를 이용하여 저장해 이를 활용한 2차적인 중복처리를 진행한다.
이 과정에서 중복 여부와 관계없이 redis 캐시에 저장된 ttl 10분의 캐시값은 삭제되지 않는다.</p>
<p>따라서 과정을 다시 정리하면</p>
<p><strong>수신 -&gt; 수신한 stream에 대한 로직 실행과 동시에 비동기적으로 ack 보냄 -&gt; 배치단에서의 1차 중복제거 -&gt; redis를 활용한 2차 중복제거 처리 -&gt; db 저장 -&gt; 중복이 아닌 경우 타 데이터의 중복처리를 위한 redis에 TTL 10분으로 저장
위 과정도중 ack가 수신되면(비동기적이기 때문에 언제든 가능) 해당 redis stream을 삭제하여 stream이 중복되어 처리되지 않도록 함</strong></p>
<p>이다!</p>
<blockquote>
<p>** 실제 PR 보러가기 : <a href="https://github.com/newzet-dev/mail-server/pull/79">https://github.com/newzet-dev/mail-server/pull/79</a>**</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[2025 Google Developer Groups APAC Solution Challenge] AI-Powered Emotional Support Diary "Todak"]]></title>
            <link>https://velog.io/@easy_ho/2025-Google-Developer-Groups-APAC-Solution-Challenge-AI-Powered-Emotional-Support-Diary-Todak</link>
            <guid>https://velog.io/@easy_ho/2025-Google-Developer-Groups-APAC-Solution-Challenge-AI-Powered-Emotional-Support-Diary-Todak</guid>
            <pubDate>Mon, 19 May 2025 14:52:53 GMT</pubDate>
            <description><![CDATA[<h1 id="2025-google-developer-groups-apac-solution-challenge--todak-프로젝트-회고">2025 Google Developer Groups APAC Solution Challenge – Todak 프로젝트 회고</h1>
<hr>
<p>2025년 초, 약 2개월에 걸쳐 진행된<br><strong>Google Developer Groups APAC Solution Challenge</strong>에 참가하며<br>‘<strong>Todak(토닥)</strong>’이라는 감정 케어 서비스 프로젝트를 완성하게 되었습니다.</p>
<p>이번 글에서는 서비스의 기획부터 개발, 배포까지<br>실제 구현 과정을 간단히 소개하고, 제가 맡은 역할을 정리해보려 합니다.</p>
<h1 id="ai-powered-emotional-support-diary-todak">AI-Powered Emotional Support Diary &quot;Todak&quot;</h1>
<p>Todak(토닥) is an innovative, private diary service (Closed-Type SNS) where users can freely express their emotions while receiving AI-powered empathetic responses.</p>
  <img src="https://velog.velcdn.com/images/easy_ho/post/29dcc391-03d0-44a4-ae19-41e28ad7649e/image.png" width="300"/>

</div>
<br>

<h2 id="overview">Overview</h2>
<p>In modern society, many people lack a safe and private space to express their emotions and receive emotional support. It&#39;s often difficult to openly share personal feelings with others, leading to emotional isolation and stress. Todak creates a warm, supportive environment where individuals can freely share their feelings and feel genuinely understood through AI-powered emotional support.</p>
<p>Our solution was developed for the APAC Solution Challenge to address this critical emotional well-being need.</p>
<p>The name &quot;토닥&quot; in Korean means to gently pat someone to offer comfort or emotional support - perfectly capturing the essence of our service.</p>
<h2 id="🎥-demo-video">🎥 Demo Video</h2>
<p><a href="https://www.youtube.com/watch?v=jZ-gx8eNw50"><img src="https://img.youtube.com/vi/jZ-gx8eNw50/0.jpg" alt="Demo Video"></a></p>
<h2 id="🏗️-system-architecture">🏗️ System Architecture</h2>
<p>A web application architecture diagram showing a monolithic Spring Boot application deployed in multiple instances for rolling updates within a Kubernetes cluster, featuring client-side Vue.js and React, a shared MySQL database</p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/50ee88cd-b633-46d8-8821-dade9fbd9f90/image.png" alt=""></p>
<h2 id="🖼️-use-case-diagram">🖼️ Use Case Diagram</h2>
<p>The following diagram illustrates the main interactions and relationships between users, AI friends, and the system</p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/d7309958-99fa-4b63-828f-732109140e88/image.png" alt=""></p>
<h2 id="🎯-key-features">🎯 Key Features</h2>
<h3 id="✍️-1-private-diary-writing">✍️ 1. Private Diary Writing</h3>
<p>Users can freely write and store their emotions in a closed, secure environment that encourages honest emotional expression without fear of judgment.  </p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/940b975a-309a-45a0-be73-e36df88b169c/image.png" alt=""></p>
<hr>
<h3 id="🤖-2-ai-empathy-companion">🤖 2. AI Empathy Companion</h3>
<p>Our AI, &quot;토닥이&quot; reads diary entries and generates personalized, empathetic comments that feel like support from a warm, understanding friend. </p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/a0d9d228-113f-4d89-86a6-308de9183621/image.png" alt=""></p>
<hr>
<h3 id="🧬-3-mbti-based-ai-responses">🧬 3. MBTI-Based AI Responses</h3>
<p>AI provides empathetic and comforting comments based on the diary written by the user, based on one of the 16 MBTI personality types.  </p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/ed085662-6d8b-4cbe-b29f-27c9780441f2/image.png" alt=""></p>
<hr>
<h3 id="🕵️♀️-4-anonymous-comment-function">🕵️‍♀️ 4. Anonymous Comment Function</h3>
<p>Based on the diary written by the user, users registered as friends and AI can comment. All comments by users and AI are provided anonymously, and anonymity can be deactivated through points.  </p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/b3fb6003-4fc8-4edf-968e-2229c6dfba1d/image.png" alt=""></p>
<hr>
<h3 id="🌱-5-emotion-growth-visualization">🌱 5. Emotion Growth Visualization</h3>
<p>A virtual tree on the main screen grows as users consistently write in their diary, symbolizing emotional growth and encouraging continuous engagement through visual feedback.  </p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/57ed4f5a-c10f-47cc-8857-0eab7d833c9c/image.png" alt=""></p>
<hr>
<h3 id="👫-6-friend-features">👫 6. Friend Features</h3>
<p>Users can access friends&#39; diaries and guestbooks (with permission) and write comments to support each other, creating a community of emotional support.  </p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/c3cda1c5-8272-42f0-aa89-fb3fa6753841/image.png" alt=""></p>
<hr>
<h3 id="💎-7-points-system">💎 7. Points System</h3>
<p>Users can earn points by checking in daily, writing in a journal, and leaving comments, which can be used to grow the tree and unlock visual progress milestones. Anonymous comments can also be unlocked.  </p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/14b561c0-91b9-44aa-ab46-9da8dd438ea6/image.png" alt=""></p>
<hr>
<h3 id="📡-8-real-time-notifications">📡 8. Real-Time Notifications</h3>
<p>The system alerts users when someone comments on their diary or guestbook, implemented using lightweight Server-Sent Events (SSE) and Redis Streams for efficient real-time communication.  </p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/bfd70412-ce97-4c7f-902d-9b0e576a8d1c/image.png" alt=""></p>
<hr>
<h3 id="🔧-9-admin-dashboard">🔧 9. Admin Dashboard</h3>
<p>Built with Thymeleaf, the admin dashboard offers dynamic filtering and management by user, date, and status for effective platform oversight.  </p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/2e8019b3-ecfe-4918-b268-df41363b3a7c/image.png" alt=""></p>
<hr>
<h3 id="📊-10-monitoring-and-logging">📊 10. Monitoring and Logging</h3>
<p>The backend is monitored using Prometheus and Grafana, with error logs stored per user for analysis and stability improvement.  </p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/2cad2498-f9ce-4596-a964-8ac76ae37936/image.png" alt=""></p>
<hr>
<h2 id="📁-repository">📁 Repository</h2>
<h3 id="👉-fe-github-repository"><a href="https://github.com/GDG-on-Campus-KNU/4th-SC-TEAM1-FE">👉 FE Github Repository</a></h3>
<h3 id="👉-be-github-repository"><a href="https://github.com/GDG-on-Campus-KNU/4th-SC-TEAM1-BE">👉 BE Github Repository</a></h3>
<h2 id="🛠️-tech-spec">🛠️ Tech Spec</h2>
<h3 id="💻-frontend">💻 Frontend</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/9f221f71-c94c-4d96-b5e9-754912c85639/image.png" alt=""></p>
<h3 id="⚙️-backend">⚙️ Backend</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/7eed1799-f35d-48c5-8600-a7aa2aca8c80/image.png" alt=""></p>
<h3 id="📊-monitoring">📊 Monitoring</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/4e9efd39-d34e-4d30-9c09-c3634841351c/image.png" alt=""></p>
<h2 id="🗃️-erd">🗃️ ERD</h2>
<p>Server Entity Relationship Diagram
<img src="https://velog.velcdn.com/images/easy_ho/post/2c78e210-41a0-4502-81b8-cc1d7c1f17e6/image.png" alt=""></p>
<h2 id="👥-team-todak">👥 Team Todak</h2>
<p>This project was developed by <strong>Team Todak</strong> to participate in the <strong>APAC Solution Challenge</strong>.
We aim to create a warm service that provides emotional support and emotional stability through technology.</p>
<h3 id="👤-my-role--engineering-the-heartbeat-of-todak">👤 My Role — <em>Engineering the Heartbeat of Todak</em></h3>
<p>As the <strong>Team Lead</strong> and <strong>Backend Architect</strong>, I played a pivotal role in transforming <em>Todak</em> from an idea into a fully deployed, emotionally intelligent platform.<br>My responsibilities spanned across both <strong>technical leadership</strong> and <strong>hands-on development</strong>, including:</p>
<hr>
<h4 id="🚀-system--architecture-design">🚀 System &amp; Architecture Design</h4>
<ul>
<li>Designed a <strong>scalable web application architecture</strong> using Spring Boot with Kubernetes for high availability and rolling updates.</li>
</ul>
<h4 id="🔧-devops--infrastructure">🔧 DevOps &amp; Infrastructure</h4>
<ul>
<li>Built and managed <strong>CI/CD pipelines</strong> for seamless and automated deployment.  </li>
<li>Maintained and secured <strong>production server environments</strong> using best practices.  </li>
</ul>
<h4 id="📡-monitoring--observability">📡 Monitoring &amp; Observability</h4>
<ul>
<li>Integrated <strong>Prometheus &amp; Grafana</strong> for real-time monitoring and analytics.  </li>
<li>Implemented detailed <strong>user-specific error logging</strong> for precise debugging and stability.</li>
</ul>
<h4 id="💡-core-backend-development">💡 Core Backend Development</h4>
<p>Led the implementation of <strong>mission-critical business features</strong> that power the emotional intelligence of <em>Todak</em>:</p>
<ul>
<li><p>🌳 <strong>Emotion Tree Growth System</strong><br>→ Visualizes emotional progress by growing a virtual tree as users engage</p>
</li>
<li><p>📔 <strong>Diary System</strong><br>→ Enables secure, anonymous emotional expression in a private space</p>
</li>
<li><p>👫 <strong>Friendship Features</strong><br>→ Allows users to view friends’ diaries and guestbooks, and exchange supportive comments</p>
</li>
<li><p>🕵️‍♂️ <strong>Anonymous Interaction Module</strong><br>→ Supports anonymous posting, which can be unlocked using points</p>
</li>
<li><p>💎 <strong>Point &amp; Lock Mechanism</strong><br>→ Encourages engagement with a gamified experience and unlockable features</p>
</li>
</ul>
<hr>
<p>I combined <strong>technical excellence</strong> with <strong>empathy-driven engineering</strong> to build a platform that truly understands and supports its users.</p>
<hr>
<p>📂 <strong>GitHub Profile</strong>: <a href="https://github.com/GitJIHO">https://github.com/GitJIHO</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NEWZET - AUTH 2탄] 확장 가능한 객체지향적 OAuth 구조 설계 및 카카오 소셜 로그인 구현]]></title>
            <link>https://velog.io/@easy_ho/NEWZET-%ED%99%95%EC%9E%A5-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5%EC%A0%81-OAuth-%EA%B5%AC%EC%A1%B0-%EC%84%A4%EA%B3%84-%EB%B0%8F-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@easy_ho/NEWZET-%ED%99%95%EC%9E%A5-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5%EC%A0%81-OAuth-%EA%B5%AC%EC%A1%B0-%EC%84%A4%EA%B3%84-%EB%B0%8F-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Sat, 26 Apr 2025 11:03:09 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/easy_ho/post/086ba12c-667b-4a72-bb1a-da6a0cc295e0/image.png" alt=""></p>
<p>지난 포스트인 <a href="https://velog.io/@easy_ho/%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5%EC%A0%81-JWT-%EC%9D%B8%EC%A6%9D-%EC%B2%98%EB%A6%AC-%EB%B0%8F-%EC%BB%A4%EC%8A%A4%ED%85%80-%EB%A6%AC%EC%A1%B8%EB%B2%84%EC%9D%B8%ED%84%B0%EC%85%89%ED%84%B0-%EB%8F%84%EC%9E%85">객체지향적 JWT 인증 처리 및 커스텀 리졸버/인터셉터 도입</a>에 이어 확장 가능한 객체지향적 OAuth 구조 설계 및 카카오 소셜 로그인을 구현하였다.</p>
<h1 id="개요">개요</h1>
<p>객체지향적 OAuth 구조 설계 및 구현
OAuth를 통한 사용자 정보 획득 및 서버 회원 계정 연결 로직 구현
카카오 소셜 로그인 기능 추가</p>
<blockquote>
<p>확장 가능한 객체지향적 OAuth 구조를 설계하고, 카카오 소셜 로그인 기능을 구현하여 사용자 경험을 개선하는 데 중점을 두었습니다. 추후 다른 소셜 로그인 제공자 추가 시에도 최소한의 변경으로 확장이 가능한 구조로 설계되었습니다.</p>
</blockquote>
<h1 id="배경">배경</h1>
<ul>
<li>사용자 편의성을 위해 소셜 로그인 기능 필요</li>
<li>추후 다양한 OAuth 제공자(Google, Naver 등)의 유연한 확장을 고려한 설계 필요</li>
<li>웹/모바일 등 다양한 디바이스에서의 로그인 지원 필요</li>
</ul>
<h1 id="변경된-점">변경된 점</h1>
<h3 id="1-oauth-관련-도메인-설계">1. OAuth 관련 도메인 설계</h3>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/9c1edef86fc79fc736727d3dc59f1f6d72ca6d90">Feat: OAuthProvider 정의</a> [OAuthProvider] 열거형: 소셜 로그인 제공자(KAKAO, APPLE) 관리</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/35dd05cf08091d064cd74efe02f16c0ac8bd644d">Feat: OAuthToken 정의</a> [OAuthToken]: 액세스 토큰, 리프레시 토큰 등 소셜 로그인 인증 정보 관리</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/5f958660bc69c9cdadd04482b0e488625c45ea15">Feat: OAuthUserInfo 정의</a> [OAuthUserInfo]: 소셜 로그인 사용자 정보(ID, 이메일, 이름) 객체화</li>
</ul>
<h3 id="2-oauth-서비스-계층">2. OAuth 서비스 계층</h3>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/445e89375c04da94bf0799f538057a03271ca2a2">Feat: OAuthService 인터페이스 추가</a> [OAuthService] 인터페이스: 모든 OAuth 제공자(Apple 제외) 가 구현해야 할 기본 계약 정의<blockquote>
<ul>
<li>getUserInfo: 인증 코드로 사용자 정보 조회</li>
<li>getRedirectUrl: OAuth 로그인 페이지 URL 반환</li>
<li>isBackendRedirect: 백엔드 리다이렉트 지원 여부</li>
</ul>
</blockquote>
</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/7c540b0f0a5700c40e6497f351750634f52c108e">Feat: OAuthService의 구현체 KakaoOAuthService 구현</a> [KakaoOAuthService]: 카카오 OAuth 구현체<blockquote>
<ul>
<li>카카오 인증 토큰 발급 및 사용자 정보 조회 로직</li>
</ul>
</blockquote>
</li>
</ul>
<h3 id="3-oauth-매핑-엔티티-구현">3. OAuth 매핑 엔티티 구현</h3>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/261bf62792f88e7f710d925f71c5273e83f8367c">Feat: OAuthMappingEntity 엔티티 추가</a> [OAuthMappingEntity]: OAuth 사용자와 서비스 사용자 간 매핑 관리</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/57a994e05560c95390133f7b65f7ab065530eb71">Feat: OAuthMappingEntityDto 및 정적 생성자 구현</a> [OAuthMappingEntityDto]: 비즈니스 계층과 리포지토리 계층 간 데이터 전송</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/e345736ab6d22de54defdc2f68faf8e3fbf30a0f">Feat: OAuthRepository 인터페이스 추가</a>[OAuthRepository]: OAuth 매핑 정보 CRUD 인터페이스<blockquote>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/1fa1768b712f6e8e4235e8a3330e0a33f36cc43e">Feat: OAuthRepository의 구현체 OAuthRepositoryImpl 구현</a> [OAuthRepositoryImpl]: JPA 기반 구현체</li>
</ul>
</blockquote>
</li>
</ul>
<h3 id="4-oauth-로그인-로직-구현">4. OAuth 로그인 로직 구현</h3>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/19df49ad5f427e16410e6866cf2de14b2108c917">Feat: OAuthLoginService 구현</a> [OAuthLoginService]: 로그인 처리 및 회원 연결 로직 구현<blockquote>
<ul>
<li>OAuth 제공자 동적 선택을 위한 Map 기반 서비스 관리</li>
<li>웹/앱 환경에 따른 리다이렉트 URL 처리</li>
<li>신규/기존 사용자 분기 처리</li>
<li>JWT 토큰 발급 및 관리</li>
</ul>
</blockquote>
</li>
</ul>
<h3 id="5-oauth-컨트롤러">5. OAuth 컨트롤러</h3>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/549517a8b79d3d6f5a12d5b2d882a2ed0a30f488">Feat: Oauth Controller 구현</a> [OAuthController]: OAuth 관련 API 엔드포인트 제공
<code>auth/oauth/{provider}/login</code>: 소셜 로그인 페이지로 리다이렉션
<code>auth/oauth/{provider}/callback</code>: OAuth 콜백 처리</li>
</ul>
<h3 id="6-회원가입-연동">6. 회원가입 연동</h3>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/dcf9458e0d1e0de1a3e43ec24bd2efd46316e338">Feat: UserService에 Oauth 비즈니스 로직 연동</a> [UserService]: 소셜 로그인 후 신규 사용자의 경우 회원가입 절차 연계</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/39d263a086a7f2421ee07f706beced3a316f929e">Feat: UserController에 추가된 비즈니스 로직 기반 수정 및 리턴타입에 SuccessResponse 도입</a> [UserController]: 회원가입 API와 OAuth 매핑 연결</li>
</ul>
<h3 id="oauth-회원가입-흐름-정리">OAuth 회원가입 흐름 정리</h3>
<ul>
<li><strong>Notion 정리글: <a href="https://verbena-brother-737.notion.site/NEWZET-OAuth-1e1c60340b2080c29789c3ecbc0f470a">NEWZET OAuth(카카오) 회원가입 흐름 상세 정리</a></strong></li>
</ul>
<h3 id="기술적-특징">기술적 특징</h3>
<ol>
<li><strong>전략 패턴 활용</strong>: OAuth 서비스 구현에 전략 패턴을 적용하여 다양한 소셜 로그인 제공자를 쉽게 확장 가능하도록 설계</li>
<li><strong>의존성 주입</strong>: 스프링 의존성 주입을 활용한 느슨한 결합 구조</li>
<li><strong>캡슐화</strong>: 도메인 모델에서 내부 상태 변경 로직 캡슐화</li>
<li><strong>테스트 코드</strong>: 철저한 단위 테스트 작성으로 코드 품질 보장</li>
</ol>
<h3 id="특이사항">특이사항</h3>
<p><a href="https://github.com/newzet-dev/mail-server/commit/e8dc7fa616cfc9bdedae6e20b60d70f6baedaf1f">Chore: 프로젝트 환경설정에 oauth관련 환경변수 세팅</a> 에서 PostgreSQL <code>prepareThreshold=0</code>으로 설정한 이유</p>
<ul>
<li><strong>Notion 정리글: <a href="https://verbena-brother-737.notion.site/PostgreSQL-prepareThreshold-0-1dec60340b2080d28fffe9931d57cf6c">PostgreSQL prepareThreshold=0 설정 이유</a></strong><blockquote>
<p>요약: <code>prepareThreshold=0</code> 설정은 일단 문제를 회피하기 위한 임시 조치이며,
추후 다음과 같은 방향으로 개선할 예정</p>
<ul>
<li>쿼리 캐시 전략 개선 혹은 수동 prepare statement 사용 방식 고려</li>
<li>반복 쿼리를 줄이거나 배치 방식 개선</li>
<li>JDBC 드라이버 및 Hibernate 설정을 조정하여 충돌 최소화</li>
</ul>
</blockquote>
</li>
</ul>
<h2 id="추가-수정사항">추가) 수정사항</h2>
<h3 id="1-도메인-분리">1. 도메인 분리</h3>
<blockquote>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/65/commits/a6a97f85c281f61e59f219ef786ebd9a450dec02">Refactor: OAuthMapping 도메인 생성 후 jpa entity단과 dto단의 비즈니스 로직을 도메인으로 이전, RepositoryImpl에서는 도메인을 거치지 않고 더티 채킹 활용하도록 리팩터링</a> </p>
</blockquote>
<p>원인: OAuthMappingEntity와 OAuthMappingEntityDto에 비즈니스 로직이 일부 포함되어있는 이슈를 발견</p>
<ul>
<li>OAuthMapping 도메인 클래스를 신규 생성하여 비즈니스 로직 담당</li>
<li>OAuthMappingEntity는 JPA 엔티티로써 순수하게 영속성 관리만 담당하도록 변경</li>
<li>OAuthMappingEntityDto를 순수 데이터 전달 객체(DTO)로 변환</li>
</ul>
<h3 id="2-비즈니스-로직-수정">2. 비즈니스 로직 수정</h3>
<blockquote>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/65/commits/a6a97f85c281f61e59f219ef786ebd9a450dec02">Refactor: OAuthMapping 도메인 생성 후 jpa entity단과 dto단의 비즈니스 로직을 도메인으로 이전, RepositoryImpl에서는 도메인을 거치지 않고 더티 채킹 활용하도록 리팩터링</a> </p>
</blockquote>
<p>원인: 비즈니스 로직에서 기존 entityDto를 활용하던 로직이 존재</p>
<ul>
<li>OAuthLoginService 등에서 도메인 객체를 활용하도록 수정</li>
</ul>
<h3 id="3-repository-로직-수정">3. repository 로직 수정</h3>
<blockquote>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/65/commits/a6a97f85c281f61e59f219ef786ebd9a450dec02">Refactor: OAuthMapping 도메인 생성 후 jpa entity단과 dto단의 비즈니스 로직을 도메인으로 이전, RepositoryImpl에서는 도메인을 거치지 않고 더티 채킹 활용하도록 리팩터링</a> </p>
</blockquote>
<p>원인: repository impl단에서 엔티티 - dto - 도메인 을 통한 변환 후 수정하여 역순으로 변환하고 save하는 로직을 더티 채킹으로 간소화 가능</p>
<ul>
<li>OAuthRepositoryImpl 도메인을 거치지 않고 더티 채킹을 활용하도록 수정</li>
</ul>
<h3 id="4-단위-테스트-추가">4. 단위 테스트 추가</h3>
<blockquote>
<p>commit : <a href="https://github.com/newzet-dev/mail-server/pull/65/commits/c515de3cb4178d79807397e337192cb5642cf3df">Feat: OAuthToken과 OAuthUserInfo 도메인에 대한 유닛 테스트 추가</a></p>
</blockquote>
<p>원인 : OAuthToken과 OAuthUserInfo 도메인에 대한 단위 테스트 부재</p>
<ul>
<li>OAuthToken과 OAuthUserInfo 도메인에 대한 유닛 테스트 추가</li>
</ul>
<h2 id="참고자료">참고자료</h2>
<p><a href="https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api">카카오 OAuth 2.0 개발 가이드</a></p>
<blockquote>
<p><strong><a href="https://github.com/newzet-dev/mail-server/pull/65">해당 PR</a> 에서도 확인하실 수 있습니다 ☺️</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NEWZET - AUTH 1탄] 객체지향적 JWT 인증 처리 및 커스텀 리졸버/인터셉터 도입]]></title>
            <link>https://velog.io/@easy_ho/%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5%EC%A0%81-JWT-%EC%9D%B8%EC%A6%9D-%EC%B2%98%EB%A6%AC-%EB%B0%8F-%EC%BB%A4%EC%8A%A4%ED%85%80-%EB%A6%AC%EC%A1%B8%EB%B2%84%EC%9D%B8%ED%84%B0%EC%85%89%ED%84%B0-%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@easy_ho/%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5%EC%A0%81-JWT-%EC%9D%B8%EC%A6%9D-%EC%B2%98%EB%A6%AC-%EB%B0%8F-%EC%BB%A4%EC%8A%A4%ED%85%80-%EB%A6%AC%EC%A1%B8%EB%B2%84%EC%9D%B8%ED%84%B0%EC%85%89%ED%84%B0-%EB%8F%84%EC%9E%85</guid>
            <pubDate>Fri, 11 Apr 2025 04:02:28 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/easy_ho/post/bead95d3-4d63-4a84-a2f9-817ba9c13e24/image.png" alt=""></p>
<blockquote>
<h4 id="뉴젯-프로젝트에-함께하게-되었다">뉴젯 프로젝트에 함께하게 되었다.</h4>
<p>뉴젯 프로젝트란 현재 상용화되어 앱시장에 나와있는 뉴젯 서비스의 백엔드 프레임워크를 스프링으로 이전하고 성능 개선 및 유저 피드백 개선에 초점을 둔 프로젝트이다.</p>
</blockquote>
<p>첫 번째 과정으로, 인증파트를 도맡아 구현하였고 그 과정을 담았다.</p>
<h1 id="개요">개요</h1>
<ul>
<li>기존 슈퍼베이스 기반 서드파티 인증 로직을 서버 내부로 이전한다</li>
<li>JWT 관리용 환경을 객체지향적으로 설계하고, 구현한다.</li>
<li>인터셉터와 리졸버, 커스텀 어노테이션 등을 이용하여 인증 시스템을 도입한다.</li>
</ul>
<h1 id="배경">배경</h1>
<blockquote>
<p>기존 인증 시스템은 슈퍼베이스에 의존하고 있어 다음과 같은 문제점이 있었다</p>
</blockquote>
<ol>
<li>외부 서비스 의존도가 높아 장애 발생 시 대응이 어려움</li>
<li>인증 로직의 커스터마이징 제약이 있음</li>
<li>토큰 관리 및 검증 과정에서의 제어 권한 부족</li>
<li>보안 정책 적용의 유연성 부족</li>
</ol>
<p>이러한 문제를 해결하기 위해 서버 내부에서 JWT를 직접 발급하고 관리하는 시스템으로 전환하여, 더 안정적이고 확장 가능한 인증 시스템을 구축하였다.</p>
<h1 id="변경된-점">변경된 점</h1>
<h3 id="1-도메인-중심-설계">1. 도메인 중심 설계</h3>
<p>핵심 도메인 객체 구현</p>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/5ec5495099cdf6557b14be3cb590ce1dbd2512e5">Feat: 토큰 객체 추가</a> [Token] : 토큰의 타입, 값, 유효기간 등을 캡슐화한 불변 객체 (Oauth 토큰 복수 사용 가능)</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/8e4e6abf13832b7cd368259fca706c5437e2012b">Feat: 인증된 유저 객체 생성</a> [AuthUser] : 인증된 사용자 정보를 표현하는 도메인 객체 (필요 필드 추가 가능)</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/5ec5495099cdf6557b14be3cb590ce1dbd2512e5">Feat: 토큰 객체 추가</a> [TokenType] : 액세스 토큰과 리프레시 토큰을 구분하는 enum (타입 확장 가능)</li>
</ul>
<h3 id="2-jwt-관리-시스템-구현">2. JWT 관리 시스템 구현</h3>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/f67836640c28f3ac03aa3ae456055e36edd9ee73">Feat: 토큰 생성용 Factory 구현</a> [JwtFactory] : JWT 생성을 담당하는 팩토리 클래스</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/f5ef800813378e0032087e609d0c83c86dcd9576">Feat: Token 메인 비즈니스 로직 구현</a> [JwtService] : 토큰 발급, 검증, 갱신 등의 비즈니스 로직 처리</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/ce948a0103ee11386cbda7d4036b83e59d6ad045">Feat: TokenRepository 인터페이스의 레디스 구현체 추가</a> [RedisRefreshTokenRepository] : 리프레시 토큰 저장소를 Redis로 구현하여 빠른 액세스와 자동 만료 기능 활용</li>
</ul>
<h3 id="3-인증-인프라-구축">3. 인증 인프라 구축</h3>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/78814de2c172469958a640936fe324b0ef993576">Feat: JWT 인증용 인터셉터 구현</a> [AuthInterceptor] : @RequireAuth 어노테이션으로 인증 필요 여부를 판단, 요청 전처리 및 JWT 유효성 검증</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/2a07cef33ffa93823ae969df3bc215d55edc5d04">Feat: 인증 어노테이션 활용 및 AuthUser 전달을 위한 리졸버 구현</a> [AuthUserArgumentResolver] : @Login 어노테이션이 붙은 컨트롤러 파라미터에 인증된 사용자 정보 주입</li>
</ul>
<h3 id="4-테스트-커버리지-확보">4. 테스트 커버리지 확보</h3>
<ul>
<li>단위 테스트: 각 컴포넌트별 격리된 테스트로 동작 검증
JwtFactoryTest, JwtServiceTest 등</li>
<li>Mock 기반 테스트: 인터셉터와 리졸버의 정상 작동 검증 등</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/6a24451633ae5a22b741300bd0109f7fe2e57bca">Test: TokenRepository의 Redis 구현체에 대한 실제 test레디스 통합테스트 추가</a> 통합 테스트: Redis 리포지토리 리프레쉬 토큰 테스트</li>
</ul>
<h3 id="5-보안-강화-및-편의-기능">5. 보안 강화 및 편의 기능</h3>
<ul>
<li><a href="https://github.com/newzet-dev/mail-server/commit/500d87eaa8e38dfc573ebb3cfd4d05b422710c4d">Feat: Token 인증용 Validator 구현</a> [TokenStolenException] : 토큰 탈취 감지 메커니즘 도입</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/ce948a0103ee11386cbda7d4036b83e59d6ad045">Feat: TokenRepository 인터페이스의 레디스 구현체 추가</a> [removeToken] : 서버 측 리프레쉬 토큰 무효화 가능 환경 구성</li>
<li><a href="https://github.com/newzet-dev/mail-server/commit/ce948a0103ee11386cbda7d4036b83e59d6ad045">Feat: TokenRepository 인터페이스의 레디스 구현체 추가</a> [deviceType] : deviceType 도입으로 디바이스별 고유한 유저 리프레쉬 토큰 관리 가능으로 동일 유저의 복수 디바이스 인증 용이성 확보</li>
</ul>
<h3 id="6-특이사항">6. 특이사항</h3>
<p>기존에는 인터셉터 단에서 발생하는 예외를 잡기 위해 커스텀 필터를 추가했으나, 실제 테스트 결과 해당 예외는 글로벌 예외 처리기(@RestControllerAdvice)에서 정상적으로 캐치됨을 확인했다.</p>
<blockquote>
<h4 id="원인-분석">원인 분석</h4>
<p>Spring MVC는 요청 처리 시 아래와 같은 순서로 DispatcherServlet을 통해 요청을 위임한다.</p>
</blockquote>
<pre><code>Client → Filter → DispatcherServlet → Interceptor → Controller → ExceptionResolver (ex. @ControllerAdvice)</code></pre><p>초기에는 인터셉터 내부에서 예외 발생 시 필터에서 이를 잡아야 한다고 판단했지만, DispatcherServlet 이후에 발생한 예외는 Spring 자체의 ExceptionResolver에서 처리되므로, 커스텀 필터를 통한 예외 캐치가 필요하지 않음을 확인했다.</p>
<blockquote>
<h4 id="결과">결과</h4>
<p>불필요한 필터 제거를 통해 요청 흐름을 단순화하고 유지보수를 용이하게 했다.
Spring의 예외 처리 흐름(DispatcherServlet → ExceptionResolver)을 신뢰하고 이를 활용한 처리 방식으로 정리했다.</p>
</blockquote>
<h2 id="참고자료">참고자료</h2>
<p>JWT 공식 문서: <a href="https://jwt.io/introduction">https://jwt.io/introduction</a>
도메인 주도 설계 DDD: <a href="https://incheol-jung.gitbook.io/docs/q-and-a/architecture/ddd">https://incheol-jung.gitbook.io/docs/q-and-a/architecture/ddd</a>
서블릿으로 예외처리 하기 : <a href="https://develop-writing.tistory.com/97">https://develop-writing.tistory.com/97</a></p>
<blockquote>
<h4 id="해당-pr-에서도-확인하실-수-있습니다-☺️"><a href="https://github.com/newzet-dev/mail-server/pull/39">해당 PR</a> 에서도 확인하실 수 있습니다 ☺️</h4>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[다중 pod 환경에서 분산 락을 이용한 동시성 처리 및 서버 에러 로그 전송 로직 구현]]></title>
            <link>https://velog.io/@easy_ho/%EB%8B%A4%EC%A4%91-pod-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%B6%84%EC%82%B0-%EB%9D%BD%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%B2%98%EB%A6%AC-%EB%B0%8F-%EC%84%9C%EB%B2%84-%EC%97%90%EB%9F%AC-%EB%A1%9C%EA%B7%B8-%EC%A0%84%EC%86%A1-%EB%A1%9C%EC%A7%81-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@easy_ho/%EB%8B%A4%EC%A4%91-pod-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%B6%84%EC%82%B0-%EB%9D%BD%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%B2%98%EB%A6%AC-%EB%B0%8F-%EC%84%9C%EB%B2%84-%EC%97%90%EB%9F%AC-%EB%A1%9C%EA%B7%B8-%EC%A0%84%EC%86%A1-%EB%A1%9C%EC%A7%81-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Fri, 04 Apr 2025 12:13:24 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Google Developer Groups on knu에서 2025 솔루션챌린지에 참가하게 되었다. 해당 프로젝트에서 백엔드파트를 맡아 진행하던 중 포인트 로직 구현 과정에서 안정성과 위험부담을 줄이기 위해 동시성 처리 및 로그 저장 로직을 구현하는 과정을 담았다.</p>
</blockquote>
<h3 id="배경">배경</h3>
<p>시작하기 전, 해당 프로젝트의 웹 서버 아키텍쳐는 다음과 같이 구성되어 있다.
<img src="https://velog.velcdn.com/images/easy_ho/post/9013af3b-9b9f-402f-9ca5-199d6a618ec5/image.png" alt=""></p>
<p>주목해야 할 부분은 스프링어플리케이션이 3개가 동시에 돌아가면서 쿠버네티스가 트래픽을 분산시켜준다. (MSA는 아닙니다 ㅎㅎ)</p>
<p>우선, 포인트 획득 비즈니스 로직은</p>
<pre><code> @Transactional
    public void earnAttendancePointPerDay(Member member) {
        Point point = getPoint(member);

        Instant today = Instant.now();

        if (!pointLogRepository.existsByCreatedAtAndMemberAndPointTypeIn(today, member, ATTENDANCE_LISTS)) {
            int consecutiveDays = calculateConsecutiveAttendanceDays(member);

            int totalPoints = ATTENDANCE_BASE_POINT + calculateBonusPoints(consecutiveDays);

            PointType attendanceType = getAttendanceType(consecutiveDays);

            pointLogService.createPointLog(new PointLogRequest(member, totalPoints, attendanceType, PointStatus.EARNED));

            point.earnPoint(totalPoints);
        }
    }</code></pre><p>단순하게 구현되어있다.</p>
<h3 id="도입-이유">도입 이유</h3>
<p>아직은 동시성 처리가 전혀 적용되어 있지 않은 상태로
<strong>1. 여러명의 유저가 동시에 충전 요청을 진행할 때</strong>
뿐만 아니라 다중 pod 환경이기 때문에
*<em>2. 한명의 유저가 오류로 여러번의 요청을 진행할 때
*</em>의 문제가 생길 수 있다.</p>
<p>우선적으로 고려했던 동시성 처리 로직은, version을 통한 낙관적 락을 적용하는 것이다. 실제로 이전에 진행했던 프로젝트들에서는 이러한 방식을 자주 사용하였다.</p>
<blockquote>
<p>낙관적 락은 보통 JPA의 @Version 필드를 통해 작동하는데, 이는 하나의 애플리케이션 인스턴스 내에서 엔티티를 관리하고 버전을 비교하면서 충돌을 감지하는 방식이다.</p>
</blockquote>
<p>*<em>하지만 다중 Pod 환경에서는
*</em></p>
<p>각 Pod가 서로 다른 JVM에서 돌아가는 독립된 프로세스이기 때문에,</p>
<p>하나의 Pod에서 조회한 데이터가 다른 Pod에서 업데이트되어도 그 사실을 모른 채 저장을 시도할 수 있다.</p>
<p>그 결과, 충돌 감지는 가능하지만 충돌 자체를 방지하지는 못한다는 문제가 있다.</p>
<p>즉, <strong>낙관적 락은 충돌 &quot;탐지&quot;용이지, 충돌 &quot;방지&quot;는 못한다.</strong></p>
<p>이를 해결하기 위해 충돌 자체를 방지해주는 분산 환경에서 공유 가능한 락을 도입하기로 결정하였다.</p>
<blockquote>
<p>우선, 현재 프로젝트에서는 알림 기능 구현을 위해 Redis Stream을 사용하고 있으며, refreshToken 저장을 위한 Redis 환경도 이미 구축되어 있는 상태이다. </p>
</blockquote>
<p>이처럼 Redis 인프라가 기본적으로 준비된 상황에서, 별도의 분산 락 서버나 복잡한 오케스트레이션 없이도 곧바로 연동이 가능한 Redis 기반의 락 구현체가 필요했다.</p>
<blockquote>
<p>Redisson은 Redis를 기반으로 하면서도 Java 개발자 친화적인 API, Fair Lock, TryLock, Watchdog 같은 고급 락 기능, RedLock 알고리즘을 통한 고가용성 분산 락 구현, 클러스터/싱글 인스턴스 환경 모두 대응 가능 등의 이유로 가장 적합하다고 판단하였다.</p>
</blockquote>
<blockquote>
<p>또한 Redisson은 이미 구축된 Redis 위에서 작동하기 때문에, 운영 비용을 최소화하면서도 안정적인 분산 락 기능을 프로젝트에 도입할 수 있는 현실적인 선택이었다.</p>
</blockquote>
<p>일련의 이유로 분산 락 중 Redis 기반의 <strong>Redisson</strong>을 도입했다.</p>
<h3 id="도입-과정">도입 과정</h3>
<p>우선 Redisson 사용을 위해서 관련 Configuation을 설정해주었다.</p>
<pre><code>@Configuration
public class RedissonConfig {

    @Value(&quot;${REDIS_HOST}&quot;)
    private String host;

    @Value(&quot;${REDIS_PORT}&quot;)
    private int port;

    @Value(&quot;${REDIS_PASSWORD}&quot;)
    private String password;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();

        config.useSingleServer()
                .setAddress(&quot;redis://&quot; + host + &quot;:&quot; + port)
                .setPassword(password)
                .setConnectionPoolSize(10)
                .setConnectionMinimumIdleSize(1);

        return Redisson.create(config);
    }
}</code></pre><p>또한 기존의 earnAttendancePointPerDay 로직에 이를 적용하였다.</p>
<pre><code>@Transactional
    public void earnAttendancePointPerDay(Member member) {
        String lockKey = &quot;pointLock:&quot; + member.getId();
        RLock lock = redissonClient.getLock(lockKey);

        try {
            if (lock.tryLock(5, 2, TimeUnit.SECONDS)) {
                Point point = getPoint(member);

                ZoneId zone = ZoneId.systemDefault();
                Instant now = Instant.now();
                Instant startOfDay = now.atZone(zone).truncatedTo(ChronoUnit.DAYS).toInstant();
                Instant endOfDay = startOfDay.plus(1, ChronoUnit.DAYS).minusMillis(1);

                if (!pointLogRepository.existsByCreatedAtBetweenAndMemberAndPointTypeIn(startOfDay, endOfDay, member, ATTENDANCE_LISTS)) {
                    int consecutiveDays = calculateConsecutiveAttendanceDays(member);
                    int totalPoints = ATTENDANCE_BASE_POINT + calculateBonusPoints(consecutiveDays);
                    PointType attendanceType = getAttendanceType(consecutiveDays);

                    pointLogService.createPointLog(
                            new PointLogRequest(member, totalPoints, attendanceType, PointStatus.EARNED, LocalDateTime.now())
                    );

                    point.earnPoint(totalPoints);
                }
            } else {
                throw new IllegalStateException(&quot;포인트 적립 락 획득 실패&quot;);
            }
        } catch (InterruptedException e) {
            throw new ConflictException(&quot;포인트 적립 중 락 에러&quot;);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }</code></pre><h4 id="pointlock--membergetid">&quot;pointLock:&quot; + member.getId();</h4>
<p>락의 key는 pointLock: 과 member들의 id를 합쳐 unique하게 만든다.</p>
<h4 id="redissonclientgetlocklockkey">redissonClient.getLock(lockKey);</h4>
<p>설정해둔 락 key 이름으로 락을 획득할 준비를 한다.</p>
<h4 id="locktrylock5-2-timeunitseconds">lock.tryLock(5, 2, TimeUnit.SECONDS)</h4>
<p>Redisson의 분산 락을 시도할 때 사용하는 메서드로, 다음과 같은 의미를 가진다</p>
<ul>
<li><strong>5:</strong> 5초, 락을 획득하기 위해 최대 <strong>5초간 기다림</strong></li>
<li><strong>2:</strong> 2초, 락을 획득한 뒤 <strong>2초간 유지(자동 해제)</strong></li>
<li><strong>TimeUnit.SECONDS:</strong> 시간 단위를 <strong>초(Seconds)</strong>로 설정</li>
</ul>
<h4 id="if-lockisheldbycurrentthread--lockunlock-">if (lock.isHeldByCurrentThread()) { lock.unlock(); }</h4>
<p>락을 획득한 후 로직이 끝나면, 2초 이내일 시 직접 unlock을 진행한다.</p>
<h3 id="테스트">테스트</h3>
<p>정상적으로 도입되었는지 확인을 위해, Redisson을 모방한 유닛 테스트를 mock을 테스트했다.</p>
<p>*<em>1. 락 정상 획득 여부를 확인하기 위한 테스트
*</em></p>
<pre><code>    @Test
    @DisplayName(&quot;락 정상 획득 테스트&quot;)
    void acquireLockSuccessfullyTest() throws InterruptedException {
        // given
        Member member = Member.builder().userId(&quot;test&quot;).salt(&quot;test&quot;).password(&quot;test&quot;).nickname(&quot;test&quot;).build();
        Point point = Point.builder().member(member).build();

        when(redissonClient.getLock(any())).thenReturn(rLock);
        when(rLock.tryLock(5, 2, TimeUnit.SECONDS)).thenReturn(true);
        when(pointRepository.findByMember(member)).thenReturn(Optional.of(point));
        when(pointLogRepository.existsByCreatedAtBetweenAndMemberAndPointTypeIn(any(), any(), eq(member), any())).thenReturn(false);
        given(rLock.isHeldByCurrentThread()).willReturn(true);

        // when
        pointService.earnAttendancePointPerDay(member);

        // then
        verify(rLock, times(1)).unlock();
        verify(pointLogService, times(1)).createPointLog(any());
    }</code></pre><p><img src="https://velog.velcdn.com/images/easy_ho/post/fdc54747-cb95-41b4-b459-7602bf38d72f/image.png" alt=""></p>
<p><strong>2. 락 획득 실패시 IllegalStateException 발생 여부 테스트</strong></p>
<pre><code>@Test
    @DisplayName(&quot;락 획득 실패 시 예외 발생 테스트&quot;)
    void throwExceptionWhenLockFailsTest() throws InterruptedException {
        // given
        Member member = Member.builder().userId(&quot;test&quot;).salt(&quot;test&quot;).password(&quot;test&quot;).nickname(&quot;test&quot;).build();

        when(redissonClient.getLock(any())).thenReturn(rLock);
        when(rLock.tryLock(5, 2, TimeUnit.SECONDS)).thenReturn(false);

        // when &amp; then
        assertThatThrownBy(() -&gt; pointService.earnAttendancePointPerDay(member))
                .isInstanceOf(IllegalStateException.class)
                .hasMessage(&quot;포인트 적립 락 획득 실패&quot;);
    }</code></pre><p><img src="https://velog.velcdn.com/images/easy_ho/post/9fd632b8-50a2-4b23-ad2f-e2e8cf68b0e2/image.png" alt=""></p>
<p><strong>3. 락 획득 도중 예외 발생시 ConflictException 발생 여부 테스트</strong></p>
<pre><code>@Test
    @DisplayName(&quot;락 획득 중 예외 발생시 예외 발생 테스트&quot;)
    void throwConflictExceptionWhenInterruptedTest() throws InterruptedException {
        // given
        Member member = Member.builder().userId(&quot;test&quot;).salt(&quot;test&quot;).password(&quot;test&quot;).nickname(&quot;test&quot;).build();

        when(redissonClient.getLock(any())).thenReturn(rLock);
        when(rLock.tryLock(5, 2, TimeUnit.SECONDS)).thenThrow(new InterruptedException(&quot;interrupted&quot;));

        // when &amp; then
        assertThatThrownBy(() -&gt; pointService.earnAttendancePointPerDay(member))
                .isInstanceOf(ConflictException.class)
                .hasMessageContaining(&quot;포인트 적립 중 락 에러&quot;);
    }</code></pre><p><img src="https://velog.velcdn.com/images/easy_ho/post/01e07bae-d1b8-4e18-8673-6eb908dcb614/image.png" alt=""></p>
<p><strong>4. 한명이 여러번 동시에 요청을 진행했을 때 한번만 적용되는지 테스트</strong></p>
<pre><code>@Test
    @DisplayName(&quot;동시에 여러 요청이 들어와도 포인트는 한 번만 적립되는지 테스트&quot;)
    void earnPointOnlyOnceWhenMultipleRequestsComeTest() throws InterruptedException {
        // given
        Member member = Member.builder().userId(&quot;test&quot;).salt(&quot;test&quot;).password(&quot;test&quot;).nickname(&quot;test&quot;).build();
        Point point = Point.builder().member(member).build();

        when(pointRepository.findByMember(any())).thenReturn(Optional.of(point));
        when(pointLogRepository.existsByCreatedAtBetweenAndMemberAndPointTypeIn(any(), any(), eq(member), any()))
                .thenReturn(false);
        when(redissonClient.getLock(any())).thenReturn(rLock);
        when(rLock.isHeldByCurrentThread()).thenReturn(true);
        when(rLock.tryLock(anyLong(), anyLong(), any()))
                .thenAnswer(new Answer&lt;Boolean&gt;() {
                    private boolean locked = false;

                    @Override
                    public synchronized Boolean answer(InvocationOnMock invocation) {
                        if (!locked) {
                            locked = true;
                            return true;
                        }
                        return false;
                    }
                });

        doNothing().when(pointLogService).createPointLog(any());

        int threadCount = 5;
        Thread[] threads = new Thread[threadCount];

        for (int i = 0; i &lt; threadCount; i++) {
            threads[i] = new Thread(() -&gt; {
                try {
                    pointService.earnAttendancePointPerDay(member);
                } catch (Exception ignored) {
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        // then
        verify(pointLogService, times(1)).createPointLog(any());
    }</code></pre><p><img src="https://velog.velcdn.com/images/easy_ho/post/34bed30f-894e-47eb-a95c-bb036a476bfb/image.png" alt=""></p>
<p><strong>5. 여러명의 유저가 동시에 충전 요청을 진행할 때 각자 한번만 포인트 적립여부 테스트</strong></p>
<pre><code>@DisplayName(&quot;여러 명의 유저가 동시에 요청해도 각각 한번만 포인트가 적립되는지 테스트&quot;)
    @Test
    void earnPointOncePerUserWhenMultipleUsersRequestConcurrentlyTest() throws InterruptedException {
        int userCount = 5;
        Thread[] threads = new Thread[userCount];

        Member[] members = new Member[userCount];
        Point[] points = new Point[userCount];

        for (int i = 0; i &lt; userCount; i++) {
            Member member = Member.builder()
                    .userId(&quot;user&quot; + i)
                    .salt(&quot;salt&quot;)
                    .password(&quot;pw&quot;)
                    .nickname(&quot;nick&quot; + i)
                    .build();
            Point point = Point.builder().member(member).build();

            members[i] = member;
            points[i] = point;

            when(pointRepository.findByMember(eq(member))).thenReturn(Optional.of(point));
            when(pointLogRepository.existsByCreatedAtBetweenAndMemberAndPointTypeIn(any(), any(), eq(member), any()))
                    .thenReturn(false);

            RLock mockLock = mock(RLock.class);
            when(redissonClient.getLock(any())).thenReturn(mockLock);
            when(mockLock.tryLock(anyLong(), anyLong(), any())).thenReturn(true);
            when(mockLock.isHeldByCurrentThread()).thenReturn(true);
        }

        doNothing().when(pointLogService).createPointLog(any());

        for (int i = 0; i &lt; userCount; i++) {
            int finalI = i;
            threads[i] = new Thread(() -&gt; {
                try {
                    pointService.earnAttendancePointPerDay(members[finalI]);
                } catch (Exception ignored) {
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        // then
        verify(pointLogService, times(userCount)).createPointLog(any());
    }</code></pre><p><img src="https://velog.velcdn.com/images/easy_ho/post/8e7dcf54-523e-4b9b-8b72-d0c2aa9a1dcf/image.png" alt=""></p>
<p>테스트가 모두 정상적으로 성공함을 확인했다.</p>
<p>실제 서비스 환경에서 테스트를 진행해보기 위해, 실제 포인트 획득 로직을 작동시키고 Redis에 해당 락이 저장되는지 확인해 본 결과</p>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/c87efb74-3bd0-4e99-bf54-ca027f70b4d9/image.png" alt="">
위와같이 설정해둔 key로 락이 획득되었고, 2초 이내에 로직이 끝나 자동으로 해제됨을 확인했다.</p>
<h3 id="서버에-로그-저장">서버에 로그 저장</h3>
<p>구현 후, 걱정되는 부분이 있었다.
현재는 5초 이내에 락을 획득하지 못하거나 인터럽트가 발생한 경우, 단순 에러를 throw 하기만 할 뿐 이 정보를 쉽게 확인할 수 없다.
아직 모니터링 도구를 도입하지 않은 상황에서, 이를 위한 간단하면서 효율적인 방법을 고안했다.</p>
<p>서버에서는 s3 대용으로 사용하기 위해
<a href="https://velog.io/@easy_ho/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EB%8B%A4%EC%A4%91-pod-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B3%B5%EC%9C%A0-%EC%A0%80%EC%9E%A5%EA%B3%B5%EA%B0%84-Kubernetes%EC%97%90%EC%84%9C-PVC-PV-NFS%EC%9D%98-%EC%97%AD%ED%95%A0%EA%B3%BC-%EB%A1%9C%EC%A7%81-%ED%9D%90%EB%A6%84">쿠버네티스 다중 pod 환경에서 사용 가능한 공유 저장공간 - Kubernetes에서 PVC, PV, NFS의 역할과 로직 흐름</a>
공유 저장공간이 이미 구현되어 있다. 관련된 적용 원리는 위 velog 참고!</p>
<p>이를 활용하여 락 획득 실패 및 락 처리중 인터럽트 발생시 로그를 서버에 유저별로 저장하도록 구현해보았다.</p>
<pre><code>public void saveLockErrorLogToServer(Member member, String message) {
        String subDirectory = &quot;lockErrorLogs/&quot; + member.getUserId();
        Path directoryPath = Paths.get(uploadFolder, subDirectory);
        Path logFilePath = directoryPath.resolve(&quot;lock-errors.txt&quot;);

        try {
            if (!Files.exists(directoryPath)) {
                Files.createDirectories(directoryPath);
            }

            String logEntry = String.format(
                    &quot;[%s] UserId: %s - %s%n&quot;,
                    LocalDateTime.now().format(DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd HH:mm:ss&quot;)),
                    member.getUserId(),
                    message
            );

            Files.writeString(logFilePath, logEntry,
                    StandardOpenOption.CREATE, StandardOpenOption.APPEND);

        } catch (IOException e) {
            throw new FileException(&quot;락 에러 로그 업로드를 실패했습니다.&quot;);

        }
    }</code></pre><pre><code>    @Transactional
    public void earnAttendancePointPerDay(Member member) {
        String lockKey = &quot;pointLock:&quot; + member.getId();
        RLock lock = redissonClient.getLock(lockKey);

        try {
            if (lock.tryLock(5, 2, TimeUnit.SECONDS)) {
                Point point = getPoint(member);

                ZoneId zone = ZoneId.systemDefault();
                Instant now = Instant.now();
                Instant startOfDay = now.atZone(zone).truncatedTo(ChronoUnit.DAYS).toInstant();
                Instant endOfDay = startOfDay.plus(1, ChronoUnit.DAYS).minusMillis(1);

                if (!pointLogRepository.existsByCreatedAtBetweenAndMemberAndPointTypeIn(startOfDay, endOfDay, member, ATTENDANCE_LISTS)) {
                    int consecutiveDays = calculateConsecutiveAttendanceDays(member);
                    int totalPoints = ATTENDANCE_BASE_POINT + calculateBonusPoints(consecutiveDays);
                    PointType attendanceType = getAttendanceType(consecutiveDays);

                    pointLogService.createPointLog(
                            new PointLogRequest(member, totalPoints, attendanceType, PointStatus.EARNED, LocalDateTime.now())
                    );

                    point.earnPoint(totalPoints);
                }
            } else {
                pointLogService.saveLockErrorLogToServer(member, &quot;Failed to acquire lock within 5 seconds&quot;);
                throw new IllegalStateException(&quot;포인트 적립 락 획득 실패&quot;);
            }
        } catch (InterruptedException e) {
            pointLogService.saveLockErrorLogToServer(member, &quot;Interrupted while trying to acquire lock&quot;);
            throw new ConflictException(&quot;포인트 적립 중 락 에러&quot;);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }</code></pre><p>즉, 문제 발생 시 관련 메세지와 관련자의 정보를 서버의 정해진 경로로 저장하여 로그를 영구 보관하고 만약의 사태에 대비하였다.</p>
<h3 id="서버-로그-확인">서버 로그 확인</h3>
<p>간단하게 로컬에서 테스트를 해보았다.</p>
<blockquote>
<p>url : &quot;서버도메인/backend/files/lockErrorLogs/{ userID }/lock-errors.txt&quot; 에서 조회 가능하도록 WebConfig에서 설정해주었다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/c4408a06-5124-4477-8f1c-64f893f9f8a2/image.png" alt="">
<img src="https://velog.velcdn.com/images/easy_ho/post/544aef4e-2cd1-472b-a2e6-55839c34ef94/image.png" alt=""></p>
<p>정상적으로 작동한다! 😊</p>
<h3 id="pr">PR</h3>
<p>이외의 하단 내용이 궁금하신 분들은</p>
<blockquote>
<h4 id="📝-작업-내용">📝 작업 내용</h4>
</blockquote>
<ul>
<li>Connect to the point creation logic member domain</li>
<li>Writing point generation test code and resolving empty conflicts</li>
<li>Points are awarded based on attendance, diary writing, and comment writing logic</li>
<li>DB standard time changed to Seoul</li>
<li>Change attendance date confirmation logic to between</li>
<li>Add point log saving logic</li>
<li>Add point logic test codes</li>
<li>Applying Redisson distributed locks for concurrency processing</li>
<li>Added log storage logic to the server to prepare for errors in lock processing logic</li>
</ul>
<p><strong><a href="https://github.com/GDG-on-Campus-KNU/4th-SC-TEAM1-BE/pull/34/commits">해당 PR</a></strong> 을 참고해주세요 🧑‍💻</p>
<h4 id="참고자료">참고자료</h4>
<p>Redisson 공식 문서 : <a href="https://redisson.pro/docs">https://redisson.pro/docs</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[GDG 사이드 프로젝트] 함께 공부하는 공간 "알고하이브"]]></title>
            <link>https://velog.io/@easy_ho/GDG-%EC%82%AC%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%95%A8%EA%BB%98-%EA%B3%B5%EB%B6%80%ED%95%98%EB%8A%94-%EA%B3%B5%EA%B0%84-%EC%95%8C%EA%B3%A0%ED%95%98%EC%9D%B4%EB%B8%8C-%EC%B5%9C%EC%A2%85-%EC%99%84%EC%84%B1%EB%B3%B8</link>
            <guid>https://velog.io/@easy_ho/GDG-%EC%82%AC%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%95%A8%EA%BB%98-%EA%B3%B5%EB%B6%80%ED%95%98%EB%8A%94-%EA%B3%B5%EA%B0%84-%EC%95%8C%EA%B3%A0%ED%95%98%EC%9D%B4%EB%B8%8C-%EC%B5%9C%EC%A2%85-%EC%99%84%EC%84%B1%EB%B3%B8</guid>
            <pubDate>Thu, 27 Mar 2025 08:14:48 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Google Developer Groups on 경북대학교 팀원들과 진행한 사이드프로젝트입니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/44839f98-b850-43b3-81b5-a3be847e5a4c/image.png" alt=""></p>
<h1 id="함께-공부하는-공간-알고하이브">함께 공부하는 공간 &quot;알고하이브&quot;</h1>
<p>알고하이브는 &quot;Algo(Algorithm)&quot;와 &quot;Hive(협업 공간)&quot;를 결합한 함께 알고리즘 문제를 풀며 공부할 수 있는 서비스입니다.</p>
<hr>
<h2 id="🗓️-개발-기간">🗓️ 개발 기간</h2>
<h3 id="20251--20252">2025.1 ~ 2025.2</h3>
<hr>
<h2 id="👥-팀원">👥 팀원</h2>
<table>
<thead>
<tr>
<th align="center">Frontend</th>
<th align="center">Frontend</th>
<th align="center">Frontend</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><a href="https://github.com/Dobbymin"><img src="https://github.com/Dobbymin.png" width="100px"></a></td>
<td align="center"><a href="https://github.com/Catleap02"><img src="https://github.com/Catleap02.png" width="100px"></a></td>
<td align="center"><a href="https://github.com/gogumalatte"><img src="https://github.com/gogumalatte.png" width="100px"></a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Dobbymin">김강민</a></td>
<td align="center"><a href="https://github.com/Catleap02">고희연</a></td>
<td align="center"><a href="https://github.com/gogumalatte">최기영</a></td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th align="center">Backend</th>
<th align="center">Backend</th>
<th align="center">Backend</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><a href="https://github.com/zzoe2346"><img src="https://github.com/zzoe2346.png" width="100px"></a></td>
<td align="center"><a href="https://github.com/GitJIHO"><img src="https://github.com/GitJIHO.png" width="100px"></a></td>
<td align="center"><a href="https://github.com/2iedo"><img src="https://github.com/2iedo.png" width="100px"></a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/zzoe2346">정성훈</a></td>
<td align="center"><a href="https://github.com/GitJIHO">이지호</a></td>
<td align="center"><a href="https://github.com/2iedo">이도훈</a></td>
</tr>
<tr>
<td align="center">---</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">## 🚩 주요 기능</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">#### - MARKDOWN 형식으로 작성 가능한 게시판: 게시판 생성, 보기, 수정, 삭제, 좋아요, 댓글 작성의 기능을 사용할 수 있다.</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">#### - 채팅: 채팅방을 생성할 수 있고, 다른 사용자와 채팅을 실시간으로 이용할 수 있다.</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">#### - AI 코드리뷰: 코딩 테스트 문제를 풀고 붙여 넣으면 ai로부터 코드리뷰를 받을 수 있다.</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">---</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">### 1. 메인 화면</td>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center"><img src="https://velog.velcdn.com/images/easy_ho/post/2bf8e0b7-6186-4ff3-9c2d-ce31bbdba593/image.png" alt=""></td>
<td align="center"></td>
<td align="center"></td>
</tr>
</tbody></table>
<hr>
<h3 id="2-게시판-글-작성-화면">2. 게시판 글 작성 화면</h3>
<blockquote>
<p>MarkDown 실시간 적용</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/d9b26f54-cd81-49d3-8f14-846f1afc98e9/image.png" alt=""></p>
<hr>
<h3 id="3-게시판-글-내부-사진-삽입">3. 게시판 글 내부 사진 삽입</h3>
<blockquote>
<p>서버 내부 저장</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/36d27748-5ffe-4988-b49a-c5a27acbe475/image.png" alt=""></p>
<hr>
<h3 id="4-ai-코드-리뷰">4. AI 코드 리뷰</h3>
<blockquote>
<p>각종 언어 지원</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/6220cdcd-883a-4369-840a-2b84142fca49/image.png" alt="">
<img src="https://velog.velcdn.com/images/easy_ho/post/6176de20-fb4e-4992-99a0-a2312ea37100/image.png" alt=""></p>
<hr>
<h3 id="5-ai-코드-리뷰-결과-게시글">5. AI 코드 리뷰 결과 게시글</h3>
<blockquote>
<p>자동 업로드 가능</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/def59f1a-925a-47c5-b965-c0ea6ed5ae29/image.png" alt=""></p>
<hr>
<h3 id="6-채팅-기능">6. 채팅 기능</h3>
<blockquote>
<p>실시간 채팅방별 접속자, 접속자별 채팅방 위치 확인 가능</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/a3183509-8cd3-4fac-8d9a-2d46ae278082/image.png" alt=""></p>
<hr>
<h2 id="💻-web-server-architecture">💻 Web Server Architecture</h2>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/c835b2a3-0ca9-4f64-9a71-65cf11d1924b/image.png" alt=""></p>
<hr>
<h2 id="⚒️-techspec">⚒️ TechSpec</h2>
<h3 id="--frontend">- Frontend</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/c88819ec-d631-4e67-a9b6-f5ad4bfb45d3/image.png" alt=""></p>
<h3 id="--backend">- Backend</h3>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/3f8dc0af-15d1-42e0-9d87-0a33f20fcd68/image.png" alt=""></p>
<blockquote>
<h4 id="github-링크--httpsgithubcomsix-gogumaalgo-hive-be">Github 링크 : <a href="https://github.com/six-goguma/Algo-Hive-BE">https://github.com/six-goguma/Algo-Hive-BE</a></h4>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[백준] 6118 : 숨바꼭질 (JAVA)]]></title>
            <link>https://velog.io/@easy_ho/%EB%B0%B1%EC%A4%80-6118-%EC%88%A8%EB%B0%94%EA%BC%AD%EC%A7%88-JAVA</link>
            <guid>https://velog.io/@easy_ho/%EB%B0%B1%EC%A4%80-6118-%EC%88%A8%EB%B0%94%EA%BC%AD%EC%A7%88-JAVA</guid>
            <pubDate>Mon, 10 Feb 2025 06:29:24 GMT</pubDate>
            <description><![CDATA[<p>백준 6118번 문제는 &quot;숨바꼭질&quot; 문제로, 최단 거리를 구하는 문제입니다.  N개의 노드가 있고, 각 노드는 양방향으로 연결되어 있습니다.  특정 노드에서 시작하여 다른 모든 노드로의 최단 거리를 구하고, 가장 먼 노드까지의 거리와 그러한 노드의 개수를 출력하는 문제입니다.</p>
<p>이 문제를 해결하기 위한 알고리즘은 <strong>BFS(Breadth-First Search)</strong>를 사용했습니다.  BFS는 너비 우선 탐색으로, 그래프 탐색 알고리즘 중 하나입니다.  이 문제에서 BFS를 선택한 이유는 최단 거리를 구해야 하기 때문입니다.  BFS는 시작 노드로부터 거리가 가까운 노드부터 탐색하기 때문에, 최단 거리를 효율적으로 찾을 수 있습니다.  DFS(Depth-First Search)를 사용하면 최단 거리가 아닌 경로를 먼저 탐색할 수 있으므로 적합하지 않습니다.</p>
<p><strong>단계별 접근 방법 및 로직:</strong></p>
<ol>
<li><p><strong>입력:</strong>  노드의 개수 N과 연결된 노드 쌍의 개수 M을 입력받습니다.  그리고 각 노드간의 연결 정보를 인접 리스트 형태로 저장합니다.  인접 리스트는 그래프를 효율적으로 표현하는 방법 중 하나로, 각 노드에 연결된 노드들을 리스트로 관리합니다.</p>
</li>
<li><p><strong>BFS 탐색:</strong> 1번 노드(문제에서 시작 노드는 1번으로 지정)부터 BFS 탐색을 시작합니다.  큐(Queue) 자료구조를 사용하여 탐색을 진행합니다.  큐에 노드를 추가하고, 큐에서 노드를 꺼내면서 인접 노드들을 방문합니다.  각 노드에 대한 최단 거리를 저장하는 <code>dist</code> 배열을 사용합니다.  방문한 노드는 <code>visited</code> 배열을 통해 관리합니다.</p>
</li>
<li><p><strong>최대 거리 및 노드 개수 계산:</strong>  BFS 탐색이 완료되면, <code>dist</code> 배열에서 최대 거리를 찾습니다.  최대 거리를 가진 노드의 개수를 카운트합니다.</p>
</li>
<li><p><strong>출력:</strong> 첫 번째는 1번부터 최대로 떨어져있는 노드의 번호를(만약 거리가 같은 노드가 여러개면 가장 작은 노드 번호를 출력), 두 번째는 그 노드까지의 거리를, 세 번째는 그 노드와 같은 거리를 갖는 노드의 개수를 출력합니다.</p>
</li>
</ol>
<p><strong>알고리즘 특징 및 장단점:</strong></p>
<ul>
<li><strong>BFS (Breadth-First Search):</strong><ul>
<li><strong>장점:</strong> 최단 경로를 보장합니다.  계층적인 탐색을 통해 효율적으로 최단 거리를 찾습니다.</li>
<li><strong>단점:</strong> 메모리 사용량이 DFS보다 클 수 있습니다.  큐에 많은 노드가 저장될 수 있습니다.  하지만 이 문제의 경우, 노드의 수가 충분히 작아 메모리 문제는 발생하지 않습니다.</li>
</ul>
</li>
</ul>
<p><strong>코드 (Python):</strong></p>
<pre><code>import java.io.*;
import java.util.*;

public class b6118 {
    // 입력을 위한 BufferedReader와 출력을 위한 BufferedWriter 선언
    static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
    static BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));

    static int N, M; // N: 노드 개수, M: 간선 개수
    static ArrayList&lt;ArrayList&lt;Integer&gt;&gt; graph; // 인접 리스트로 그래프 표현
    static int[] dist; // 각 노드까지의 거리 저장 배열
    static int MAX_INTEGER = 20000000; // 초기 거리 값 (충분히 큰 값)
    static int max_dist; // 최장 거리 저장 변수

    // 입력 처리 메서드
    static void input() throws IOException {
        String[] input = br.readLine().split(&quot; &quot;);
        N = Integer.parseInt(input[0]); // 노드 개수 입력
        M = Integer.parseInt(input[1]); // 간선 개수 입력

        // 그래프 초기화 (0번 인덱스는 사용하지 않음)
        graph = new ArrayList&lt;&gt;();
        for (int i = 0; i &lt;= N; i++) {
            graph.add(new ArrayList&lt;&gt;());
        }
        dist = new int[N + 1]; // 거리 배열 초기화

        // 간선 정보 입력 및 인접 리스트 저장
        for (int i = 0; i &lt; M; i++) {
            input = br.readLine().split(&quot; &quot;);
            int start = Integer.parseInt(input[0]);
            int end = Integer.parseInt(input[1]);

            graph.get(start).add(end);
            graph.get(end).add(start);
        }
    }

    // BFS 수행하여 최단 거리 계산
    static void bfs() {
        Queue&lt;Integer&gt; queue = new LinkedList&lt;&gt;();
        queue.add(1); // 시작 노드는 1번
        Arrays.fill(dist, MAX_INTEGER); // 모든 거리를 큰 값으로 초기화
        dist[1] = 0; // 시작 노드의 거리는 0
        max_dist = 0; // 최장 거리 초기화

        while (!queue.isEmpty()) {
            int start = queue.poll(); // 현재 노드 가져오기

            // 현재 노드와 연결된 모든 노드 탐색
            for (int node : graph.get(start)) {
                if (dist[node] &gt; dist[start] + 1) { // 더 짧은 거리 발견 시 갱신
                    dist[node] = dist[start] + 1;
                    max_dist = Math.max(max_dist, dist[node]); // 최장 거리 업데이트
                    queue.add(node); // 다음 탐색을 위해 큐에 추가
                }
            }
        }
    }

    // 결과 출력 메서드
    static void result() throws IOException {
        int count = 0; // 최장 거리에 있는 노드 개수
        int num_first = 0; // 최장 거리를 가지는 첫 번째 노드

        for (int i = 1; i &lt;= N; i++) {
            if (dist[i] == max_dist) { // 최장 거리 노드 찾기
                count++;
                if (num_first == 0) { // 첫 번째로 등장한 노드 저장
                    num_first = i;
                }
            }
        }

        // 첫 번째 최장 거리 노드, 거리, 해당 거리의 노드 개수 출력
        bw.write(num_first + &quot; &quot; + max_dist + &quot; &quot; + count);
    }

    public static void main(String[] args) throws IOException {
        input(); // 입력 처리
        bfs(); // BFS 수행
        result(); // 결과 출력
        bw.flush(); // 출력 버퍼 비우기
        bw.close(); br.close(); // 리소스 닫기
    }
}
</code></pre><p>이 코드는 입력받은 그래프를 이용하여 BFS를 수행하고, 최대 거리와 그 거리에 해당하는 노드의 개수를 출력합니다.  주석을 통해 각 부분의 역할을 명확히 설명했습니다. <code>dist</code> 배열을 사용하여 각 노드까지의 최단 거리를 저장하고, <code>max_dist</code>와 <code>count</code> 변수를 사용하여 최대 거리와 그에 해당하는 노드의 개수를 관리합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[백준] 12852 : 1로 만들기 2 (JAVA)]]></title>
            <link>https://velog.io/@easy_ho/%EB%B0%B1%EC%A4%80-12852-1%EB%A1%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-2-JAVA</link>
            <guid>https://velog.io/@easy_ho/%EB%B0%B1%EC%A4%80-12852-1%EB%A1%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-2-JAVA</guid>
            <pubDate>Thu, 06 Feb 2025 17:29:01 GMT</pubDate>
            <description><![CDATA[<p>백준 온라인 저지의 12852번 문제, &quot;1로 만들기 2&quot;를 푸는 코드입니다.  문제는 자연수 N이 주어졌을 때, N을 1로 만들기 위한 최소 연산 횟수와 그 과정을 출력하는 것입니다.  N을 1로 만들기 위해서는 다음 세 가지 연산 중 하나를 수행할 수 있습니다:</p>
<ol>
<li>N에서 1을 뺀다.</li>
<li>N을 2로 나눈다. (N이 짝수인 경우)</li>
<li>N을 3으로 나눈다. (N이 3의 배수인 경우)</li>
</ol>
<p>이 코드는 다이나믹 프로그래밍(Dynamic Programming) 알고리즘을 사용하여 문제를 해결합니다.  다음은 단계별 설명입니다.</p>
<p><strong>1. 초기 설정:</strong></p>
<ul>
<li><code>BufferedReader</code>와 <code>BufferedWriter</code>를 사용하여 입력/출력 속도를 향상시킵니다.  Java의 <code>Scanner</code>보다 효율적입니다.</li>
<li><code>N</code>: 입력으로 받는 자연수입니다.</li>
<li><code>dp[i]</code>: i를 1로 만들기 위한 최소 연산 횟수를 저장하는 배열입니다.  <code>dp[1] = 0</code>으로 초기화됩니다. (1은 이미 1이므로 연산이 필요없음)</li>
<li><code>path[i]</code>: i를 1로 만들기 위한 과정에서 i 바로 이전의 수를 저장하는 배열입니다.  이는 최소 연산 과정을 역추적하기 위해 사용됩니다.</li>
</ul>
<p><strong>2. 다이나믹 프로그래밍:</strong></p>
<ul>
<li><code>for</code> 루프를 사용하여 2부터 N까지의 모든 수에 대해 최소 연산 횟수를 계산합니다.</li>
<li><code>dp[i] = dp[i - 1] + 1; path[i] = i - 1;</code>:  기본적으로 i에서 1을 빼는 연산을 고려합니다.  이는 항상 가능한 연산이며, 최소 연산 횟수의 상한선을 제공합니다.</li>
<li><code>if (i % 2 == 0 &amp;&amp; dp[i] &gt; dp[i / 2] + 1)</code>: i가 짝수이고, i를 2로 나누는 것이 기존 최소 연산 횟수보다 적다면, <code>dp[i]</code>와 <code>path[i]</code>를 업데이트합니다.</li>
<li><code>if (i % 3 == 0 &amp;&amp; dp[i] &gt; dp[i / 3] + 1)</code>: i가 3의 배수이고, i를 3으로 나누는 것이 기존 최소 연산 횟수보다 적다면, <code>dp[i]</code>와 <code>path[i]</code>를 업데이트합니다.</li>
</ul>
<p>이 부분에서 다이나믹 프로그래밍의 핵심 아이디어인 <strong>최적 부분 구조 (Optimal Substructure)</strong>가 사용됩니다.  즉, i를 1로 만드는 최소 연산 횟수는 i/2 또는 i/3을 1로 만드는 최소 연산 횟수에 1을 더한 값과 비교하여 결정됩니다. 이미 계산된 <code>dp[i/2]</code>와 <code>dp[i/3]</code>의 값을 활용하여 효율적으로 최소 연산 횟수를 구합니다.</p>
<p><strong>3. 결과 출력:</strong></p>
<ul>
<li><code>bw.write(dp[N])</code>: N을 1로 만들기 위한 최소 연산 횟수를 출력합니다.</li>
<li><code>for</code> 루프를 사용하여 <code>path</code> 배열을 역추적하여 최소 연산 과정을 출력합니다.  <code>path[index]</code>는 현재 수 <code>index</code>의 이전 수를 가리키므로, <code>index</code>를 <code>path[index]</code>로 계속 업데이트하면 1까지의 과정을 역순으로 얻을 수 있습니다.</li>
</ul>
<p><strong>알고리즘 특징 및 장단점:</strong></p>
<ul>
<li><strong>장점:</strong> 다이나믹 프로그래밍을 사용하여 효율적으로 최소 연산 횟수와 과정을 구합니다. 시간 복잡도는 O(N)입니다.</li>
<li><strong>단점:</strong> 메모리를 O(N)만큼 사용합니다.  N이 매우 큰 경우 메모리 제한에 걸릴 수 있습니다.  하지만 이 문제의 제한된 N의 크기에서는 문제가 되지 않습니다.</li>
</ul>
<p><strong>코드:</strong></p>
<pre><code class="language-java">import java.util.*;
import java.io.*;

public class b12852 {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
        int N = Integer.parseInt(br.readLine()), dp[] = new int[N + 1], path[] = new int[N + 1];
        // dp[i]: i를 1로 만드는 최소 연산 횟수
        // path[i]: i를 1로 만드는 과정에서 i 바로 이전의 수

        for (int i = 2; i &lt;= N; i++) {
            dp[i] = dp[i - 1] + 1; // 기본적으로 1을 빼는 연산
            path[i] = i - 1;       // 이전 수는 i-1

            if (i % 2 == 0 &amp;&amp; dp[i] &gt; dp[i / 2] + 1) { // 2로 나누는 연산이 더 효율적인 경우
                dp[i] = dp[i / 2] + 1;
                path[i] = i / 2;
            }
            if (i % 3 == 0 &amp;&amp; dp[i] &gt; dp[i / 3] + 1) { // 3으로 나누는 연산이 더 효율적인 경우
                dp[i] = dp[i / 3] + 1;
                path[i] = i / 3;
            }
        }

        bw.write(dp[N] + &quot;\n&quot;); // 최소 연산 횟수 출력
        for (int index = N; index &gt; 0; index = path[index]) { // 최소 연산 과정 역추적 및 출력
            bw.write(index + &quot; &quot;);
        }
        bw.flush();
        bw.close();
        br.close();
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[백준] 12865 : 평범한 배낭 (JAVA)]]></title>
            <link>https://velog.io/@easy_ho/%EB%B0%B1%EC%A4%80-12865-%ED%8F%89%EB%B2%94%ED%95%9C-%EB%B0%B0%EB%82%AD-JAVA</link>
            <guid>https://velog.io/@easy_ho/%EB%B0%B1%EC%A4%80-12865-%ED%8F%89%EB%B2%94%ED%95%9C-%EB%B0%B0%EB%82%AD-JAVA</guid>
            <pubDate>Thu, 06 Feb 2025 17:28:05 GMT</pubDate>
            <description><![CDATA[<p>백준 온라인 저지의 12865번 문제인 &quot;평범한 배낭&quot;을 푸는 동적 계획법(Dynamic Programming)을 이용한 풀이입니다.</p>
<p><strong>문제 설명:</strong></p>
<p>문제는 N개의 물건이 주어지고, 각 물건은 무게(weight)와 가치(value)를 갖습니다. 최대 K 무게의 배낭에 물건을 담아 가치의 합을 최대화하는 문제입니다.  각 물건은 하나만 담을 수 있습니다.</p>
<p><strong>알고리즘 및 접근 방법:</strong></p>
<p>이 문제는 0/1 배낭 문제의 전형적인 예시이며, 동적 계획법을 이용하여 효율적으로 해결할 수 있습니다.  코드에서 사용된 접근 방법은 다음과 같습니다.</p>
<p><strong>1. 입력:</strong></p>
<ul>
<li><code>input()</code> 함수는 문제의 입력을 받습니다.<ul>
<li><code>N</code>: 물건의 개수</li>
<li><code>K</code>: 배낭의 최대 무게</li>
<li><code>dp[][]</code>: 동적 계획법 테이블. <code>dp[i][j]</code>는 i번째 물건까지 고려했을 때, 무게 j를 넘지 않는 최대 가치를 저장합니다.  <code>N+1</code>과 <code>K+1</code>로 크기를 설정하는 이유는 0번째 물건과 0 무게의 경우를 처리하기 위해서입니다.</li>
<li>각 물건의 무게와 가치를 입력받아 <code>dp</code> 테이블을 채우는 반복문이 있습니다.</li>
</ul>
</li>
</ul>
<p><strong>2. 동적 계획법:</strong></p>
<ul>
<li><code>input()</code> 함수 내부의 이중 반복문이 동적 계획법의 핵심입니다.<ul>
<li><code>i</code>: 물건의 인덱스 (1부터 N까지)</li>
<li><code>j</code>: 배낭의 현재 무게 (1부터 K까지)</li>
<li><code>if (j &gt;= weight)</code>: 현재 물건의 무게가 배낭의 현재 무게 제한보다 작거나 같다면, 현재 물건을 넣을 수 있습니다.<ul>
<li><code>dp[i][j] = Math.max(dp[i-1][j-weight]+value, dp[i-1][j]);</code>:  현재 물건을 넣었을 때의 가치 (<code>dp[i-1][j-weight]+value</code>)와 현재 물건을 넣지 않았을 때의 가치 (<code>dp[i-1][j]</code>) 중 더 큰 값을 <code>dp[i][j]</code>에 저장합니다.</li>
</ul>
</li>
<li><code>else</code>: 현재 물건의 무게가 배낭의 현재 무게 제한보다 크다면, 현재 물건을 넣을 수 없습니다.<ul>
<li><code>dp[i][j] = dp[i-1][j];</code>: 이전 물건까지 고려했을 때의 최대 가치를 그대로 사용합니다.</li>
</ul>
</li>
</ul>
</li>
</ul>
<p><strong>3. 결과 출력:</strong></p>
<ul>
<li><code>result()</code> 함수는 <code>dp[N][K]</code> 값을 출력합니다.  <code>dp[N][K]</code>는 모든 물건을 고려했을 때, 최대 무게 K를 넘지 않는 최대 가치를 저장하고 있으므로, 문제의 답이 됩니다.</li>
</ul>
<p><strong>알고리즘의 특징 및 장단점:</strong></p>
<ul>
<li><strong>장점:</strong> 동적 계획법은 0/1 배낭 문제를 효율적으로 해결합니다. 시간 복잡도는 O(NK)로, N과 K가 비교적 작은 경우 효과적입니다.  탐욕적 방법과 달리 최적해를 보장합니다.</li>
<li><strong>단점:</strong> 공간 복잡도 또한 O(NK)입니다. N과 K가 매우 큰 경우 메모리 제한에 걸릴 수 있습니다.  공간 복잡도를 줄이기 위해서는 1차원 배열을 이용하는 등의 최적화 기법이 필요할 수 있습니다.</li>
</ul>
<p><strong>코드:</strong></p>
<pre><code class="language-java">import java.util.*;
import java.io.*;

public class b12865 {
    static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
    static BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
    static int N, K;
    static int[][] dp;

    static void input() throws IOException {
        String[] input = br.readLine().split(&quot; &quot;);
        N = Integer.parseInt(input[0]);
        K = Integer.parseInt(input[1]);
        dp = new int[N + 1][K + 1]; // 0번째 행과 열을 고려하여 크기 설정

        for (int i = 1; i &lt;= N; i++) {
            input = br.readLine().split(&quot; &quot;);
            int weight = Integer.parseInt(input[0]);
            int value = Integer.parseInt(input[1]);
            for (int j = 1; j &lt;= K; j++) {
                if (j &gt;= weight) {
                    dp[i][j] = Math.max(dp[i - 1][j - weight] + value, dp[i - 1][j]); // 현재 물건을 넣거나 넣지 않거나 중 최대값 선택
                } else {
                    dp[i][j] = dp[i - 1][j]; // 현재 물건의 무게가 초과하면 이전 결과를 그대로 사용
                }
            }
        }
    }

    static void result() throws IOException {
        bw.write(String.valueOf(dp[N][K]));
    }

    public static void main(String[] args) throws IOException {
        input();
        result();
        bw.flush();
        bw.close();
        br.close();
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[쿠버네티스 다중 pod 환경에서 사용 가능한 공유 저장공간 - Kubernetes에서 PVC, PV, NFS의 역할과 로직 흐름]]></title>
            <link>https://velog.io/@easy_ho/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EB%8B%A4%EC%A4%91-pod-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B3%B5%EC%9C%A0-%EC%A0%80%EC%9E%A5%EA%B3%B5%EA%B0%84-Kubernetes%EC%97%90%EC%84%9C-PVC-PV-NFS%EC%9D%98-%EC%97%AD%ED%95%A0%EA%B3%BC-%EB%A1%9C%EC%A7%81-%ED%9D%90%EB%A6%84</link>
            <guid>https://velog.io/@easy_ho/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EB%8B%A4%EC%A4%91-pod-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B3%B5%EC%9C%A0-%EC%A0%80%EC%9E%A5%EA%B3%B5%EA%B0%84-Kubernetes%EC%97%90%EC%84%9C-PVC-PV-NFS%EC%9D%98-%EC%97%AD%ED%95%A0%EA%B3%BC-%EB%A1%9C%EC%A7%81-%ED%9D%90%EB%A6%84</guid>
            <pubDate>Tue, 04 Feb 2025 12:09:19 GMT</pubDate>
            <description><![CDATA[<p><em>위는 이해를 돕기 위한 사진. 실제로는 3개의 pod를 사용하며 저장공간은 NFS를 이용하여 VM 내부를 활용중이다.</em></p>
<blockquote>
<h4 id="클라이언트-마이페이지단에서-사진-이미지를-저장해야하는-상황이다-그러나-단순하게-pod에-저장하면-영속성을-보장하지-못하고-pod끼리-사진-이미지를-공유하지-못하기-때문에-pv-pvc-nfs를-통해-이-문제를-해결하였다">클라이언트 마이페이지단에서 사진 이미지를 저장해야하는 상황이다. 그러나, 단순하게 pod에 저장하면 영속성을 보장하지 못하고 pod끼리 사진 이미지를 공유하지 못하기 때문에 PV, PVC, NFS를 통해 이 문제를 해결하였다.</h4>
</blockquote>
<h3 id="1-기본-개념"><strong>1. 기본 개념</strong></h3>
<ul>
<li><strong>PV (Persistent Volume)</strong>:<ul>
<li>클러스터에서 사용할 수 있는 물리적 또는 네트워크 스토리지</li>
<li>NFS, AWS EBS, Ceph 등 다양한 스토리지 백엔드 지원</li>
</ul>
</li>
<li><strong>PVC (Persistent Volume Claim)</strong>:<ul>
<li>애플리케이션(파드)이 스토리지를 요청하는 방식</li>
<li>특정 용량과 접근 모드를 정의하여 PV를 요청함</li>
<li>PV와 연결되면 해당 PVC를 사용하는 모든 파드는 데이터를 공유 가능</li>
</ul>
</li>
<li><strong>NFS (Network File System)</strong>:<ul>
<li>여러 서버(또는 Pod)에서 동시에 접근 가능한 파일 시스템</li>
<li>PV의 백엔드 스토리지가 될 수 있음</li>
</ul>
</li>
</ul>
<hr>
<h3 id="2-현재-서버의-스토리지-구조"><strong>2. 현재 서버의 스토리지 구조</strong></h3>
<pre><code>Pod 내부 경로: /usr/share/nginx/html  (NFS가 마운트됨)
PV 실제 저장 경로: /mnt/nfs  (NFS 서버에서 관리)</code></pre><ol>
<li><strong>PVC가 PV를 요청</strong><ul>
<li>PVC가 특정 용량과 접근 권한을 요구</li>
<li>PV 중 해당 요청을 충족하는 스토리지가 PVC와 바인딩됨</li>
</ul>
</li>
<li><strong>NFS를 통한 스토리지 공유</strong><ul>
<li>PV가 NFS 서버의 <strong><code>/mnt/nfs</code></strong> 디렉터리를 사용</li>
<li>PVC를 통해 <strong><code>/usr/share/nginx/html</code></strong> 경로에 마운트됨</li>
<li>여러 Pod가 같은 PVC를 사용하여 동일한 데이터를 공유 가능</li>
</ul>
</li>
<li><strong>Pod에서 파일 저장 및 접근</strong><ul>
<li>백엔드 서버는 <strong><code>/usr/share/nginx/html/files</code></strong> 경로에 파일을 저장</li>
<li>파일은 NFS를 통해 <strong><code>/mnt/nfs/files</code></strong>에 저장됨</li>
<li>Nginx가 <strong><code>/usr/share/nginx/html/files</code></strong>를 서빙하여 외부에서 접근 가능</li>
</ul>
</li>
</ol>
<hr>
<h3 id="3-백엔드에서-파일-업로드--조회-흐름"><strong>3. 백엔드에서 파일 업로드 &amp; 조회 흐름</strong></h3>
<ul>
<li><strong>파일 업로드</strong></li>
</ul>
<ol>
<li>백엔드에서 클라이언트가 보낸 파일을 <strong><code>/usr/share/nginx/html/files/</code></strong> 경로에 저장</li>
<li>PVC를 통해 파일이 NFS 서버의 <strong><code>/mnt/nfs/files/</code></strong> 경로에 저장됨</li>
<li>여러 Pod에서 같은 PVC를 사용하기 때문에 동일한 파일 접근 가능</li>
</ol>
<ul>
<li><strong>파일 조회</strong></li>
</ul>
<ol>
<li>백엔드에서 파일 경로(<strong><code>/usr/share/nginx/html/files/{filename}</code></strong>)를 활용하여 직접 조회</li>
<li>프론트엔드가 HTTP 요청을 통해 백엔드에서 해당 파일을 받을 수 있음</li>
<li>또는 <strong><code>http://algo.knu-soft.site/files/{filename}</code></strong> URL을 통해 Nginx에서 직접 파일 서빙 가능</li>
</ol>
<hr>
<h3 id="4-정리"><strong>4. 정리</strong></h3>
<ul>
<li><strong>PVC와 PV의 역할</strong><ul>
<li>PVC는 Pod가 요청하는 논리적 스토리지</li>
<li>PV는 실제 물리적 스토리지 (NFS)</li>
</ul>
</li>
<li><strong>NFS를 사용하는 이유</strong><ul>
<li>여러 Pod가 같은 파일을 공유 가능</li>
<li>영구적인 파일 저장 가능</li>
</ul>
</li>
<li><strong>파일 업로드 및 조회 방식</strong><ul>
<li><strong><code>/usr/share/nginx/html/files/</code></strong> 경로에 저장하면 자동으로 NFS에 저장됨</li>
<li>프론트엔드는 백엔드 API를 통해 파일을 조회하거나 Nginx URL로 직접 접근 가능</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[다중 pod를 이용한 쿠버네티스 환경에서 단체채팅방 구현 방법 - 실시간 채팅방별 접속인원 조회 기능 구현]]></title>
            <link>https://velog.io/@easy_ho/%EB%8B%A4%EC%A4%91-pod%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%8B%A8%EC%B2%B4%EC%B1%84%ED%8C%85%EB%B0%A9-%EA%B5%AC%ED%98%84-%EB%B0%A9%EB%B2%95-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%B1%84%ED%8C%85%EB%B0%A9%EB%B3%84-%EC%A0%91%EC%86%8D%EC%9E%90%EC%88%98-%EC%A1%B0%ED%9A%8C</link>
            <guid>https://velog.io/@easy_ho/%EB%8B%A4%EC%A4%91-pod%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%8B%A8%EC%B2%B4%EC%B1%84%ED%8C%85%EB%B0%A9-%EA%B5%AC%ED%98%84-%EB%B0%A9%EB%B2%95-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%B1%84%ED%8C%85%EB%B0%A9%EB%B3%84-%EC%A0%91%EC%86%8D%EC%9E%90%EC%88%98-%EC%A1%B0%ED%9A%8C</guid>
            <pubDate>Mon, 03 Feb 2025 13:32:19 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h4 id="사이드-프로젝트-프론트단에서-채팅방별-접속자수를-실시간으로-조회하는-기능이-필요하다는-요청을-받았다">사이드 프로젝트 프론트단에서 &quot;채팅방별 접속자수&quot;를 실시간으로 조회하는 기능이 필요하다는 요청을 받았다.</h4>
</blockquote>
<h3 id="현재-상황">현재 상황</h3>
<p>해당 요청을 해결하기 위해서 우선, 현재 존재하는 UserStatusProducer 클래스의 메서드를 확인했다</p>
<pre><code>@Service
public class UserStatusProducer {

    private static final String USER_QUEUE_NAME = &quot;allUserQueue&quot;;

    private final RabbitTemplate rabbitTemplate;

    public UserStatusProducer(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }
    @Async
    public void sendUserJoinMessage(String userName, String roomName, StompHeaderAccessor headerAccessor) {
        UserInRoomResponse userInRoomResponse = new UserInRoomResponse(userName, roomName, true);
        headerAccessor.getSessionAttributes().put(&quot;username&quot;, userName);
        headerAccessor.getSessionAttributes().put(&quot;roomName&quot;, roomName);
        rabbitTemplate.convertAndSend(USER_QUEUE_NAME, userInRoomResponse);
    }
    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String username = (String) headerAccessor.getSessionAttributes().get(&quot;username&quot;);
        String roomName = (String) headerAccessor.getSessionAttributes().get(&quot;roomName&quot;);
        if (username != null &amp;&amp; roomName != null) {
            UserInRoomResponse userInRoomResponse = new UserInRoomResponse(username, roomName, false);
            rabbitTemplate.convertAndSend(USER_QUEUE_NAME, userInRoomResponse);
        }
    }
}</code></pre><p>이후, Consumer에 해당하는 UserStatusConsumer의 메서드들을 확인했다.</p>
<pre><code>@Service
public class UserStatusConsumer {

    private static final String USER_QUEUE_NAME = &quot;allUserQueue&quot;;
    private static final String REDIS_USER_KEY = &quot;connectedUsers&quot;;

    private final SimpMessagingTemplate messagingTemplate;
    private final RedisTemplate&lt;String, Object&gt; redisTemplate;

    public UserStatusConsumer(SimpMessagingTemplate messagingTemplate, RedisTemplate&lt;String, Object&gt; redisTemplate) {
        this.messagingTemplate = messagingTemplate;
        this.redisTemplate = redisTemplate;
    }

    @RabbitListener(queues = USER_QUEUE_NAME, concurrency = &quot;5-10&quot;)
    public void receiveUserStatus(@Payload UserInRoomResponse userInRoomResponse) {
        UsersResponse user = new UsersResponse(userInRoomResponse.userName(), userInRoomResponse.roomName());

        if (userInRoomResponse.isJoin()) {
            redisTemplate.opsForSet().add(REDIS_USER_KEY, user);
        } else {
            redisTemplate.opsForSet().remove(REDIS_USER_KEY, user);
        }

        Set&lt;Object&gt; rawUsers = redisTemplate.opsForSet().members(REDIS_USER_KEY);
        Set&lt;UsersResponse&gt; connectedUsers = rawUsers.stream()
                .map(obj -&gt; (UsersResponse) obj)
                .collect(Collectors.toSet());

        messagingTemplate.convertAndSend(&quot;/topic/users&quot;, connectedUsers);
    }
}</code></pre><p>다중 Pod 환경이기 때문에 일반적인 웹소켓과는 다른 방식으로 작동한다.</p>
<blockquote>
</blockquote>
<p><strong>1. Producer에서 사용자 웹소켓 접속과 퇴장시 사전에 설정한 RabbitMQ Queue에 메시지를 보낸다
2. Consumer에서 RabbitMQ와 연동된 유저 Queue를 Listen하고, 메시지가 들어오면 읽는다
3. 메시지 데이터에 따라 접속자면 redis에 이름, 채팅방을 추가한다.
3-1. 메시지 데이터에 따라 퇴장자면 redis에서 이름, 채팅방을 제거한다.
4. /topic/users 경로에 redis에서 읽은 접속 유저 정보 Set을 보낸다
5. 클라이언트에서 해당 Set을 변환하여 표시한다</strong></p>
<h3 id="해결방법">해결방법</h3>
<p>즉, 현재 다중pod 환경에 맞는 설정은 redis와 rabbitMQ를 통해 해결된 상태이다</p>
<blockquote>
<h4 id="이를-활용하여-채팅방--유저-수-의-해시값을-redis에-추가하면-해결">이를 활용하여 {채팅방 : 유저 수} 의 해시값을 Redis에 추가하면 해결!</h4>
</blockquote>
<pre><code>@Service
public class UserStatusConsumer {

    private static final String USER_QUEUE_NAME = &quot;chatUsersQueue&quot;;
    private static final String REDIS_USER_KEY = &quot;connectedUsers&quot;;
    private static final String REDIS_ROOM_COUNT_KEY = &quot;roomUserCounts&quot;;

    private final SimpMessagingTemplate messagingTemplate;
    private final RedisTemplate&lt;String, Object&gt; redisTemplate;

    public UserStatusConsumer(SimpMessagingTemplate messagingTemplate, RedisTemplate&lt;String, Object&gt; redisTemplate) {
        this.messagingTemplate = messagingTemplate;
        this.redisTemplate = redisTemplate;
    }

    @RabbitListener(queues = USER_QUEUE_NAME, concurrency = &quot;5-10&quot;)
    public void receiveUserStatus(@Payload UserInRoomResponse userInRoomResponse) {
        UsersResponse user = new UsersResponse(userInRoomResponse.userName(), userInRoomResponse.roomName());
        String roomName = userInRoomResponse.roomName();

        if (userInRoomResponse.isJoin()) {
            redisTemplate.opsForSet().add(REDIS_USER_KEY, user);
            redisTemplate.opsForHash().increment(REDIS_ROOM_COUNT_KEY, roomName, 1);
        } else {
            redisTemplate.opsForSet().remove(REDIS_USER_KEY, user);
            Long count = redisTemplate.opsForHash().increment(REDIS_ROOM_COUNT_KEY, roomName, -1);

            if (count &lt;= 0) {
                redisTemplate.opsForHash().delete(REDIS_ROOM_COUNT_KEY, roomName);
            }
        }

        Set&lt;Object&gt; rawUsers = redisTemplate.opsForSet().members(REDIS_USER_KEY);
        Set&lt;UsersResponse&gt; connectedUsers = rawUsers.stream()
                .map(obj -&gt; (UsersResponse) obj)
                .collect(Collectors.toSet());

        Set&lt;Object&gt; roomNames = redisTemplate.opsForHash().keys(REDIS_ROOM_COUNT_KEY);
        Map&lt;String, Integer&gt; roomUserCounts = roomNames.stream()
                .collect(Collectors.toMap(
                        room -&gt; (String) room,
                        room -&gt; Integer.valueOf(redisTemplate.opsForHash().get(REDIS_ROOM_COUNT_KEY, room).toString())
                ));

        messagingTemplate.convertAndSend(&quot;/topic/users&quot;, connectedUsers);

        messagingTemplate.convertAndSend(&quot;/topic/room-users&quot;, roomUserCounts);
    }</code></pre><blockquote>
<p>해당 과정에서 Queue가 기존 Queue와 동일하면, 업데이트가 되지 않는 문제가 발생하여
AllUserQueue -&gt; ChatUsersQueue 로 RabbitMQ에서 사용할 Queue의 이름을 변경하고 새로 생성하였다.</p>
</blockquote>
<p>이에 맞추어 RabbitConfig와 UserStatusProducer의 Queue 이름을 변경해주었다.</p>
<p>최종 로직은 다음과 같다.</p>
<blockquote>
<p><strong>1. Producer에서 사용자 웹소켓 접속과 퇴장시 사전에 설정한 RabbitMQ Queue에 메시지를 보낸다
2. Consumer에서 RabbitMQ와 연동된 유저 Queue를 Listen하고, 메시지가 들어오면 읽는다
3. 메시지 데이터에 따라 접속자면 redis에 이름, 채팅방을 추가한다. 또한, HashMap의 형태로 채팅방별 인원을 +1 한다.
3-1. 메시지 데이터에 따라 퇴장자면 redis에서 이름, 채팅방을 제거한다. 또한, HashMap의 형태로 채팅방별 인원을 -1 한다. 단, 이 과정에서 채팅방 인원이 0명이 되는 순간 해당 값을 Redis에서 제거한다
4. /topic/users 경로에 redis에서 읽은 접속 유저 정보 Set을 보낸다
5. /topic/room-users 경로에 redis에서 읽은 채팅방별 유저인원 Map을 보낸다
6. 클라이언트에서 해당 Set과 Map을 변환하여 표시한다</strong></p>
</blockquote>
<h3 id="결과">결과</h3>
<p>클라이언트 코드는 기능 구현 확인을 위해 간단하게 작성하였다</p>
<blockquote>
<h4 id="테스터1-테스터2-테서트3-접속">테스터1, 테스터2, 테서트3 접속</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/768f1afd-2783-4098-902d-1c753c580e2f/image.png" alt=""></p>
</blockquote>
<blockquote>
<h4 id="테스터1이-굿굿-채팅방에-접속">테스터1이 &quot;굿굿&quot; 채팅방에 접속</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/d9f16874-edd8-4dd0-be26-f94c08b75341/image.png" alt=""></p>
</blockquote>
<blockquote>
<h4 id="테스터2도-굿굿-채팅방에-접속">테스터2도 &quot;굿굿&quot; 채팅방에 접속</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/52c543ae-4684-4e20-8b05-0171b2b0c0e6/image.png" alt=""></p>
</blockquote>
<blockquote>
<h4 id="테스터3은-yee-채팅방에-접속">테스터3은 &quot;yee&quot; 채팅방에 접속</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/17599493-20ab-4d90-8e74-cf87fbb276f4/image.png" alt=""></p>
</blockquote>
<blockquote>
<h4 id="최종-상태의-redis">최종 상태의 Redis</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/c6b135da-2b1d-4420-a068-126ab1c7757f/image.png" alt=""></p>
</blockquote>
<p>정상적으로 다중Pod 환경에서 작동한다! 😄😄</p>
<h3 id="향후-계획">향후 계획</h3>
<ul>
<li>간헐적으로 한글을 입력시 전송이 두번되는 문제가 발생한다. 아마 구독을 최초, 채팅방 변경시 진행하여 그런것이라 생각하여 수정할 계획이다.</li>
<li>프론트와 협업하여 사이드프로젝트에 채팅기능을 삽입하고, 이 과정에서 발생할 문제를 해결해봐야겠다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[다중 pod를 이용한 쿠버네티스 환경에서 단체채팅방 구현 방법 - 채팅방 접속자 조회 기능 추가]]></title>
            <link>https://velog.io/@easy_ho/%EC%B1%84%ED%8C%85%EB%B0%A9-%EC%A0%91%EC%86%8D%EC%9E%90-%EB%AA%A9%EB%A1%9D-%EC%A1%B0%ED%9A%8C-%EA%B8%B0%EB%8A%A5-%EC%B6%94%EA%B0%80-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-pod-3%EA%B0%9C%EC%9D%98-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C</link>
            <guid>https://velog.io/@easy_ho/%EC%B1%84%ED%8C%85%EB%B0%A9-%EC%A0%91%EC%86%8D%EC%9E%90-%EB%AA%A9%EB%A1%9D-%EC%A1%B0%ED%9A%8C-%EA%B8%B0%EB%8A%A5-%EC%B6%94%EA%B0%80-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-pod-3%EA%B0%9C%EC%9D%98-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C</guid>
            <pubDate>Sat, 25 Jan 2025 17:53:54 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>지난번 만들었던 채팅방에서 접속자 목록을 조회하는 기능을 추가하였다</p>
</blockquote>
<h3 id="최초-구현물">최초 구현물</h3>
<p>WebSocket을 이용해 사용자 연결 및 채팅방 입장, 실시간 메시지 수신, 사용자 목록 업데이트 등을 처리하는 로직을 구현했다.
사용자는 채팅방에 입장하기 전에 이름을 설정하고, 이를 WebSocket 서버에 전송해 사용자 정보를 등록한다.</p>
<p>이후 사용자는 채팅방의 메시지를 실시간으로 수신하고, 전역 사용자 목록을 실시간으로 갱신하여 화면에 반영할 수 있다.
채팅방에 입장할 때마다 입장 트리거가 작동해 여러 pod에서 구동 가능하도록 구현하였고, 채팅방에서 발생하는 메시지와 사용자 목록은 실시간으로 업데이트된다. 사용자가 채팅방을 떠날 때는 퇴장 트리거를 통해 접속 유저를 확인 가능하고, UI는 이를 자동으로 갱신하도록 임시로 구현했다.</p>
<p>또한, 여러 개의 Pod에서 동시에 WebSocket 서버가 동작할 수 있도록 로드 밸런싱을 고려하여 설계되어, 3개 이상의 Pod에서도 안정적으로 작동할 수 있다.</p>
<h3 id="접속자-조회-흐름">접속자 조회 흐름</h3>
<h4 id="1-사용자-이름-설정-및-websocket-연결">1. 사용자 이름 설정 및 WebSocket 연결</h4>
<p>사용자가 채팅방에 입장하기 전에, 반드시 사용자 이름을 설정해야 한다. 사용자가 이름을 설정하면 해당 이름을 WebSocket 서버에 전송하여 사용자를 등록한다. 이 과정은 채팅방에 처음 연결할 때 발생하며, 이름 설정 후 WebSocket 서버와 연결을 통해 해당 사용자를 채팅방에 추가할 수 있다.</p>
<h4 id="2-채팅방에-연결">2. 채팅방에 연결</h4>
<p>사용자가 이름을 설정하고 나면, WebSocket을 통해 채팅방에 연결된다. 이때 채팅방의 이름이 제공되며, 사용자는 해당 채팅방에 입장하게 된다. 만약 사용자가 채팅방을 변경할 경우, 새로운 채팅방으로 WebSocket 연결을 다시 설정하여 입장한다.</p>
<h4 id="3-websocket-연결-및-메시지-구독">3. WebSocket 연결 및 메시지 구독</h4>
<p>WebSocket 연결이 성공하면 사용자는 해당 채팅방에서 발생하는 메시지를 실시간으로 받을 수 있도록 설정된다. 또한, 사용자는 전역 사용자 목록을 구독하여 실시간으로 채팅방에 속한 다른 사용자의 목록을 업데이트할 수 있다. 메시지가 채팅방에 도달하면, 이를 실시간으로 처리하여 화면에 표시한다.</p>
<h4 id="4-사용자-입장-알림">4. 사용자 입장 알림</h4>
<p>사용자가 채팅방에 입장할 때마다 rabbitMQ를 통해 서버로 queue를 전달하고 소비하여 전체 접속자 현황을 여러 pod에서 확인할 수 있다.</p>
<h4 id="주요-흐름">주요 흐름</h4>
<p>WebSocket 연결: 클라이언트는 WebSocket 서버와 연결하여 채팅방에서 발생하는 메시지를 실시간으로 수신한다.
사용자 이름 설정: 사용자는 채팅방에 입장하기 전에 이름을 설정하고, 이를 서버에 전송하여 사용자를 등록한다.
메시지 및 사용자 목록 구독: 채팅방의 메시지와 전역 사용자 목록을 구독하여 실시간으로 갱신된 정보를 화면에 반영한다.
사용자 입장 알림: 사용자가 채팅방에 입장하면 이를 서버를 통해 저장한다.</p>
<blockquote>
<h4 id="하지만-pod-3개의-환경에서-간과한-부분이-있었다-rabbitmq를-통해-입장과-퇴장은-각-pod마다-전송했으나-이를-저장하는-set이-인메모리형식이라-결국-도루묵">하지만, pod 3개의 환경에서 간과한 부분이 있었다,, rabbitmq를 통해 입장과 퇴장은 각 pod마다 전송했으나 이를 저장하는 set이 인메모리형식이라 결국 도루묵,,</h4>
</blockquote>
<h3 id="변경-핵심사항">변경 핵심사항</h3>
<p>가장 문제되는 부분인 Set을 인메모리 방식이 아닌 redis를 이용해 외부db로 관리하여 pod개수가 2개 이상이더라도 문제가 없도록 구현했다.
이 과정에서 기존에 사용하던 redis Serializer를 용도에 맞게 분할하였고 기존에 있던 기능들을 최대한 유지하면서 개선했다.</p>
<h3 id="변경된-접속자-조회-흐름">변경된 접속자 조회 흐름</h3>
<h4 id="1-사용자-이름-설정-및-websocket-연결-1">1. 사용자 이름 설정 및 WebSocket 연결</h4>
<p>사용자가 채팅방에 입장하기 전에, 반드시 사용자 이름을 설정해야 한다. 이 과정은 채팅방에 처음 연결할 때 발생하며, 이름 설정 후 WebSocket 서버와 연결을 통해 해당 사용자를 채팅방에 추가할 수 있다.</p>
<blockquote>
<h4 id="사용자-이름을-websocket으로-보낼-필요가-없다-why-외부-브로커를-사용하기-때문에-모든-메시지에-유저-이름과-방-정보가-들어가기-때문">사용자 이름을 WebSocket으로 보낼 필요가 없다. why? 외부 브로커를 사용하기 때문에 모든 메시지에 유저 이름과 방 정보가 들어가기 때문!</h4>
</blockquote>
<h4 id="2-사용자-조회-로직-구독">2. 사용자 조회 로직 구독</h4>
<p>1의 과정이 끝난 직후 바로 사용자 조회 엔드포인트를 구독하여 현재 접속중인 사용자를 바로 확인할 수 있게 한다. 단, 최초에는 채팅방에 입장하지 않았기 때문에 채팅방 이름은 &quot;채팅방 미접속&quot;으로 설정했다.</p>
<blockquote>
<h4 id="이로-인해-채팅방에-접속하기-전에도-현재-접속중인-사람을-확인할-수-있다">이로 인해 채팅방에 접속하기 전에도 현재 접속중인 사람을 확인할 수 있다.</h4>
</blockquote>
<h4 id="3-userstatusproducer를-사용하여-설정된-queue에-유저-정보-전송">3. UserStatusProducer를 사용하여 설정된 queue에 유저 정보 전송</h4>
<p>이 과정에서 세션에 정보를 저장해두고, 이후에 사용한다. queue로 전송하는 과정에는 rabbitMQ가 사용된다.</p>
<h4 id="4-userstatusconsumer를-사용하여-queue-정보-획득">4. UserStatusConsumer를 사용하여 queue 정보 획득</h4>
<p>queue로 들어온 정보를 통해 현재 접속중인 유저를 저장하고, 이 과정에서 인메모리 -&gt; Redis로 사용이 변경되었다.</p>
<blockquote>
<h4 id="실제-redis에-저장이-잘-되는것을-확인할-수-있다">실제 Redis에 저장이 잘 되는것을 확인할 수 있다.</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/5eb4e9ca-5430-4330-b392-3fdcf46790e2/image.png" alt=""></p>
</blockquote>
<h4 id="5-채팅방에-연결">5. 채팅방에 연결</h4>
<p>사용자가 이름을 설정하고 나면, WebSocket을 통해 채팅방에 연결된다. 이때 채팅방의 이름이 제공되며, 사용자는 해당 채팅방에 입장하게 된다. 만약 사용자가 채팅방을 변경할 경우, 새로운 채팅방으로 WebSocket 연결을 다시 설정하여 입장한다. </p>
<blockquote>
<h4 id="이-과정에서-34의-과정이-계속-반복되어-전체-pod들이-모두-동일한-상태를-가질-수-있다">이 과정에서 3~4의 과정이 계속 반복되어 전체 pod들이 모두 동일한 상태를 가질 수 있다</h4>
<p>테스트유저가 테스트2 채팅방에 접속한 상황
<img src="https://velog.velcdn.com/images/easy_ho/post/e4001e83-eb47-4451-b4b7-1311c912da03/image.png" alt=""></p>
</blockquote>
<h4 id="6-websocket-연결-및-메시지-구독">6. WebSocket 연결 및 메시지 구독</h4>
<p>WebSocket 연결이 성공하면 사용자는 해당 채팅방에서 발생하는 메시지를 실시간으로 받을 수 있도록 설정된다. 또한, 사용자는 전역 사용자 목록을 구독하여 실시간으로 채팅방에 속한 다른 사용자의 목록을 업데이트할 수 있다. 메시지가 채팅방에 도달하면, 이를 실시간으로 처리하여 화면에 표시한다.</p>
<h4 id="7-사용자-퇴장-알림">7. 사용자 퇴장 알림</h4>
<p>사용자가 채팅방에서 퇴장하면 UserStatusProducer의 EventListener가 웹소켓 연결해제를 감지하여 설정된 queue로 해당 유저의 퇴장 신호를 보내고, 4의 과정이 진행되어 Redis에서 유저 정보가 사라진다. </p>
<blockquote>
<h4 id="유저-퇴장-후-redis-만약-다른-유저가-남아있다면-해당-유저의-정보들이-뜬다">유저 퇴장 후 Redis (만약 다른 유저가 남아있다면 해당 유저의 정보들이 뜬다)</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/34286af4-f05e-49b3-af81-41f3a5f67f1d/image.png" alt=""></p>
</blockquote>
<h3 id="구현-화면">구현 화면</h3>
<blockquote>
<h4 id="유저-a-유저-b-유저-c가-접속-후-채팅방-입장-전-왼쪽부터-차례로-유저-a-b-c">유저 A, 유저 B, 유저 C가 접속 후 채팅방 입장 전 (왼쪽부터 차례로 유저 A, B, C)</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/d801b8e3-9f91-4eb6-85bc-f57f79237b70/image.png" alt=""></p>
</blockquote>
<blockquote>
<h4 id="유저-a만-채팅방-테스트1에-접속">유저 A만 채팅방 &quot;테스트1&quot;에 접속</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/0be98f39-589b-4b2f-8ace-e9182b0e82a5/image.png" alt=""></p>
</blockquote>
<blockquote>
<h4 id="유저-b도-채팅방-테스트1에-접속">유저 B도 채팅방 &quot;테스트1&quot;에 접속</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/ed0c4a17-28ec-42b7-bfd8-e5a0b4c3127a/image.png" alt=""></p>
</blockquote>
<blockquote>
<h4 id="유저-c가-퇴장">유저 C가 퇴장</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/3ccb8f5b-958e-4191-94f1-386ae8837a25/image.png" alt=""></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[다중 pod를 이용한 쿠버네티스 환경에서 단체채팅방 구현 방법 - 채팅기능 구현]]></title>
            <link>https://velog.io/@easy_ho/%EB%8B%A4%EC%A4%91-pod%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%8B%A8%EC%B2%B4%EC%B1%84%ED%8C%85%EB%B0%A9-%EA%B5%AC%ED%98%84-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@easy_ho/%EB%8B%A4%EC%A4%91-pod%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%8B%A8%EC%B2%B4%EC%B1%84%ED%8C%85%EB%B0%A9-%EA%B5%AC%ED%98%84-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Tue, 21 Jan 2025 09:46:09 GMT</pubDate>
            <description><![CDATA[<h2 id="문제상황">문제상황</h2>
<p>프로젝트에서 단체채팅방 기능을 구현해야 하는 상황이 왔다.</p>
<p>초기에는 WebSocket을 Handler와 함께 사용하여 Simp브로커로 구현하였다.</p>
<p>-&gt; 단일 서버에서는 문제없이 사용 가능</p>
<blockquote>
<p>why? 인메모리 방식을 사용한 웹소켓 통신이기 때문이다.</p>
</blockquote>
<p>그러나, 현재 서버는 쿠버네티스의 롤링 업데이트 방식으로 돌아가기 때문에 3개의 pod가 스프링 어플리케이션을 돌리고 있다.</p>
<p>-&gt; 같은 pod끼리만 채팅이 가능한 문제가 있었다.</p>
<h2 id="해결방법">해결방법</h2>
<p>구독/발행 방식을 통해 해결하고자 하였다.</p>
<p>단순하게 설명하면 이렇다.</p>
<ol>
<li>채팅방을 구독</li>
<li>하나의 pod에서 채팅을 입력하여 웹소켓으로 전달</li>
<li>메시지 브로커가 구독된 채팅방에 모두 채팅 내용을 발행</li>
<li>다른 구독중인 pod에서도 채팅 소비 가능</li>
</ol>
<h4 id="이를-구현하기-위해-비동기-메세지를-지원하며-중간-메세지-브로커-역할을-하는-rabbitmq를-활성화하였다">이를 구현하기 위해 비동기 메세지를 지원하며 중간 메세지 브로커 역할을 하는 RabbitMq를 활성화하였다.</h4>
<h2 id="로직-흐름">로직 흐름</h2>
<h4 id="1-사용자가-채팅-메시지-전송">1. 사용자가 채팅 메시지 전송</h4>
<p>사용자가 클라이언트에서 채팅 메시지를 입력하고 전송
메시지는 WebSocket을 통해 서버로 전달</p>
<h4 id="2-websocket-메시지-처리">2. WebSocket 메시지 처리</h4>
<p>클라이언트가 보낸 WebSocket 메시지는 Spring의 @MessageMapping을 통해 수신</p>
<blockquote>
<p>/api/app/chat/{roomName}로 메시지가 들어오면 WebSocketChatController의 sendMessage()가 호출</p>
</blockquote>
<h4 id="3-메시지-저장-service-layer">3. 메시지 저장 (Service Layer)</h4>
<p>ChatMessageService에서 수신된 메시지를 데이터베이스에 저장
메시지 내용과 방 이름 등을 포함한 ChatMessageInfo 객체로 변환
이 단계에서 데이터의 무결성을 보장하며, 메시지가 손실되지 않도록 처리</p>
<h4 id="4-rabbitmq로-메시지-전달-message-producer">4. RabbitMQ로 메시지 전달 (Message Producer)</h4>
<p>MessageProducer를 사용해 변환된 메시지를 RabbitMQ로 전송
RabbitMQ는 채팅 데이터를 다른 Pod와 공유할 수 있도록 중앙 메시지 큐 역할
여러 Pod가 RabbitMQ에 연결되어 있으므로, 어떤 Pod로 메시지가 전송되더라도 동일한 채팅 방의 모든 사용자가 메시지를 받을 수 있음!</p>
<h4 id="5-rabbitmq의-메시지-전달">5. RabbitMQ의 메시지 전달</h4>
<p>RabbitMQ는 채팅 메시지를 해당 큐(채팅 방 이름 기반)로 전달
연결된 모든 Pod는 RabbitMQ에서 메시지를 수신 대기</p>
<h4 id="6-pod-내-메시지-소비-message-consumer">6. Pod 내 메시지 소비 (Message Consumer)</h4>
<p>각 Pod의 Consumer가 RabbitMQ에서 메시지를 수신
RabbitMQ로부터 받은 메시지는 다시 WebSocket을 통해 방에 연결된 사용자들에게 브로드캐스트</p>
<blockquote>
<p>SimpMessagingTemplate을 사용해 /api/topic/chat/{roomName}로 메시지를 전달</p>
</blockquote>
<h4 id="7-다중-pod-환경에서의-메시지-동기화">7. 다중 Pod 환경에서의 메시지 동기화</h4>
<p>RabbitMQ를 통해 메시지가 전달되므로, 사용자가 어떤 Pod에 연결되어 있더라도 동일한 방의 모든 사용자가 메시지를 실시간으로 받을 수 있음</p>
<blockquote>
<p>쿠버네티스 환경에서 확장성 보장
새로운 Pod를 추가하더라도 RabbitMQ를 통해 메시지가 동기화되므로, 무중단 확장이 가능</p>
</blockquote>
<h4 id="8-사용자-이름-설정">8. 사용자 이름 설정</h4>
<p>클라이언트가 api/app/chat/setName/{roomName}로 사용자 이름을 설정하면, 이를 WebSocket을 통해 수신</p>
<blockquote>
<p>RabbitMQ를 통해 이름 변경 정보도 모든 Pod와 동기화하여, 동일한 방의 사용자들이 즉시 반영된 정보를 볼 수 있음</p>
</blockquote>
<h3 id="rabbitmq를-메시지-브로커로-사용하여-다중-pod-간의-메시지-전달-문제를-해결">RabbitMQ를 메시지 브로커로 사용하여 다중 Pod 간의 메시지 전달 문제를 해결!</h3>
<blockquote>
<h4 id="프론트-코드는-작동-확인을-위한-최소한의-코드만-구현">프론트 코드는 작동 확인을 위한 최소한의 코드만 구현</h4>
<p><img src="https://velog.velcdn.com/images/easy_ho/post/64804e6f-2d9d-4eaa-b51f-38f7599cb2e7/image.png" alt=""></p>
</blockquote>
<h3 id="깃허브-코드">깃허브 코드</h3>
<p><a href="https://github.com/six-goguma/Algo-Hive-BE/pulls?q=is%3Apr+is%3Aclosed">[GITHUB] Feat: pod 3개를 공유하기 위한 채팅 로직 수정</a></p>
<blockquote>
<p>작동은 하지만 아직 부족한 부분이 많다. 속도처리, 병렬처리 안정화, 리소스 사용 조정 등등,,</p>
</blockquote>
<h3 id="이후-계획">이후 계획</h3>
<p>서버 안정화를 우선적으로 해결하고, 현재 접속중인 사람의 닉네임이 뜨게하는 기능을 추가해볼 예정이다! (가능하다면..)</p>
]]></description>
        </item>
    </channel>
</rss>