<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>fbehddn</title>
        <link>https://velog.io/</link>
        <description>꾸준함 빼면 시체</description>
        <lastBuildDate>Wed, 13 May 2026 08:06:32 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>fbehddn</title>
            <url>https://velog.velcdn.com/images/dw_db/profile/630eb21e-6608-4fed-b170-1e07935c6c36/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. fbehddn. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dw_db" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[동시에 수만개의 좋아요 요청이 몰리면 어떻게 처리가 될까?]]></title>
            <link>https://velog.io/@dw_db/%EB%8F%99%EC%8B%9C%EC%97%90-%EC%88%98%EB%A7%8C%EA%B0%9C%EC%9D%98-%EC%A2%8B%EC%95%84%EC%9A%94-%EC%9A%94%EC%B2%AD%EC%9D%B4-%EB%AA%B0%EB%A6%AC%EB%A9%B4-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%B2%98%EB%A6%AC%EA%B0%80-%EB%90%A0%EA%B9%8C</link>
            <guid>https://velog.io/@dw_db/%EB%8F%99%EC%8B%9C%EC%97%90-%EC%88%98%EB%A7%8C%EA%B0%9C%EC%9D%98-%EC%A2%8B%EC%95%84%EC%9A%94-%EC%9A%94%EC%B2%AD%EC%9D%B4-%EB%AA%B0%EB%A6%AC%EB%A9%B4-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%B2%98%EB%A6%AC%EA%B0%80-%EB%90%A0%EA%B9%8C</guid>
            <pubDate>Wed, 13 May 2026 08:06:32 GMT</pubDate>
            <description><![CDATA[<p>인스타그램이나 X에서 좋아요 버튼을 누르는 행위는 단순하다. 버튼 하나, 클릭 한 번. 
인기 게시물에 수천 명이 동시에 그 버튼을 누른다면 어떻게 될까? </p>
<p><code>INSERT INTO article_likes (article_id,user_id) VALUES (?, ?)</code> 
한 줄짜리 쿼리가 발생할 것이고, <code>INSERT</code>라 lock 경합은 발생하지 않을것이다.</p>
<p>병목은 다른 곳에 있었다.</p>
<hr>
<h3 id="버튼-하나에-숨어있던-병목">버튼 하나에 숨어있던 병목</h3>
<p>3000 req/s 스파이크 환경에서 좋아요 API를 테스트했을 때, p95 응답 시간이 221ms까지 치솟았다. </p>
<p><strong>테스트 구성</strong>
k6를 사용하였고 3단계로 구성했다.</p>
<ul>
<li>Baseline: 10 req/s × 20s, VU 20개 (정상 부하)</li>
<li>Spike: 3000 req/s × 20s, VU 200개 (스파이크)</li>
<li>Recovery: 10 req/s × 20s, VU 20개 (회복 확인)</li>
</ul>
<blockquote>
<p><strong>왜 3000 req/s 인가?</strong>
pool size = 15, 로컬 Docker 환경에서 <code>INSERT</code> 평균 레이턴시는 약 5ms였다. 
커넥션 풀이 포화되는 임계점은 pool_size / avg_latency = 15 / 0.005 = 3000 req/s
이 수치 아래에서는 요청이 아무리 몰려도 커넥션이 반납되어 pending이 쌓이지 않는것을 확인하였다. 
로컬 환경이라 네트워크 레이턴시가 없어 실제 운영 환경보다 임계점이 낮게 잡혔을 것이고, 운영 환경이었다면 더 낮은 req/s에서도 포화가 발생했을 것이다.</p>
</blockquote>
<p>원인을 추적해보니 HikariCP 커넥션 풀 포화였다. 좋아요 요청 하나가 들어올 때마다 각각의 HTTP thread가 DB 커넥션을 점유하고, INSERT가 끝나야 반납한다. </p>
<p>pool size가 15인 상황에서 요청이 몰리면 커넥션을 기다리는 요청이 pending 큐에 쌓이기 시작한다.</p>
<p>스파이크 구간에서 Connection을 얻기 위해 대기중인 스레드가 182개임을 확인하였다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/a538d52b-61a3-4ce5-8e87-fc35f5d1e834/image.png" alt=""></p>
<p>pending 상태의 스레드들은 커넥션을 얻을 때까지 블로킹된다. <code>connection-timeout: 3000ms</code>으로 설정하였기에 3초 안에 커넥션을 받을수 있지만, 응답이 지연된 상태일 것이다. </p>
<p>timeout 시간을 초과하면 예외가 발생해 사용자는 에러를 받는다. 느려지다가 결국 실패하는 구조다.</p>
<p>더 큰 문제는 처리량이었다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/b08e99ed-b04b-4563-9860-dfbde3868f53/image.png" alt=""></p>
<p>3000 req/s를 목표로 쏘았지만 실제로 처리된 건 647 req/s에 그쳤다. 나머지 21,525건은 커넥션을 잡지 못한 채 드롭됐다. 소화할 수 있는 양 자체가 낮은 구조였다.</p>
<hr>
<h3 id="db를-즉시-치지-않으면-어떨까">DB를 즉시 치지 않으면 어떨까?</h3>
<p>근본 원인은 사용자의 요청마다 DB 커넥션을 점유하는 구조에 있었다. 요청마다 커넥션을 점유하고, INSERT가 끝나야 반납한다.</p>
<p>사용자는 버튼을 눌렀을 때 즉각 반응을 원한다. DB에 정확히 쓰인 시점은 사용자가 알 필요가 없다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/18e65dcd-83c9-4d45-ab38-36a645118007/image.png" alt=""></p>
<p>위 사항에 착안하여 Redis를 도입한 Write-Behind 전략을 떠올릴 수 있었고, 요청이 들어오면 Redis에 즉시 반영하고 응답한다. DB 동기화는 나중에 비동기로 처리할 수 있다.</p>
<blockquote>
<p>Redis는 인메모리 저장소라 디스크 I/O가 없다. Lettuce 클라이언트를 사용하면 Netty 기반 멀티플렉싱으로 하나의 커넥션에 여러 요청을 동시에 처리하기 때문에, HikariCP처럼 커넥션 슬롯을 기다리며 블로킹되는 구조가 아니다.
<a href = https://redis.io/tutorials/what-is-redis/>What is Redis?: An Overview</a>
<a href = https://redis.io/docs/latest/develop/clients/pools-and-muxing/> Connection pools and multiplexing </a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/dw_db/post/c8ef57d2-5572-4ef6-90b7-fc0839ec684b/image.png" alt=""></p>
<p>위 상황처럼 같은 유저가 순간적으로 따닥 연속 클릭할 수 있다.
하지만 SADD가 Set 자료구조이기 때문에 중복을 방지하여 추가 구현 없이 처리할 수 있다.</p>
<blockquote>
<p>Redis는 6.0 이후 네트워크 I/O는 멀티스레드, 명령 실행은 싱글스레드다. 
개별 명령 하나는 원자적이지만, 명령과 명령 사이에는 다른 요청이 끼어들 수 있다.
<a href = https://redis.io/blog/diving-into-redis-6/>Redis 공식 문서 - Diving Into Redis 6.0</a> </p>
</blockquote>
<p>Write-Through(요청마다 DB도 함께 업데이트)와 비교했을 때 정합성 측면에서 리스크가 있을 것이다.</p>
<p>비동기로 RDB에 쓰는중에 Redis가 죽으면 아직 DB에 반영되지 않은 데이터가 사라질 수 있지만, 좋아요 개수는 결제나 재고 도메인과는 성격이 다르기에 약간의 오차가 허용된다고 판단하여 이 트레이드 오프를 감안하였다.</p>
<hr>
<h3 id="정합성을-위한-비동기-처리-변경된-것만-db에-반영하면-어떨까">정합성을 위한 비동기 처리, 변경된 것만 DB에 반영하면 어떨까?</h3>
<p>30초마다 스케줄러가 Redis의 데이터를 DB에 동기화한다. 
단순하게 구현하면 모든 게시물을 순회하며 전부 DB에 쓰게 된다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/9e4ddeed-600e-4f4f-9ef7-af4a5ecd587f/image.png" alt=""></p>
<p>이 방식은 좋아요 부하와 무관하게 두 가지 문제를 안고 있다.</p>
<ol>
<li><p>불필요한 DB 쓰기
게시물이 10만 개라면 30초마다 10만 건의 <code>SELECT</code>가 발생한다.
그 중 실제로 좋아요가 발생한 게시물이 1,000개라고 가정했을때, 나머지 99,000건은 Redis와 DB가 이미 일치하기 때문에 낭비이다.</p>
</li>
<li><p>KEYS 는 Redis를 블로킹함
모든 게시물 키를 가져오려면 <code>KEYS article:likes:*</code> 명령어를 써야하는데, 이 명령어는 전체 keyspace를 <code>O(N)</code>으로 스캔하면서 그 동안 다른 명령을 처리하지 못하게 블로킹한다. 게시물이 늘어날수록 스케줄러 자체가 Redis 응답 지연을 만들어내는 구조가 된다.</p>
</li>
</ol>
<p>위 두 문제 상황을 고려하여 좋아요 이벤트가 발생할 때마다 해당 게시물 ID를 별도의 Redis Set에 기록하여 처리하는 Dirty Set을 도입했다.</p>
<blockquote>
<p>&#39;Dirty&#39;는 수정됐지만 아직 영구 저장소에 반영되지 않은 상태를 뜻한다. 
InnoDB가 변경된 버퍼 풀 페이지를 dirty page로 추적해 배치로 디스크에 쓰는 것과 같은 원리이다.
<a href= https://dev.mysql.com/doc/refman/9.7/en/innodb-buffer-pool-flushing.html> MySQL 공식 문서 - 17.8.3.5 Configuring Buffer Pool Flushing </a></p>
</blockquote>
<p>스케줄러는 Dirty Set에 있는 항목만 골라 DB에 쓰게 되고, 완료되면 Set을 비운다. 10만 개의 게시물이 있어도 실제 변경이 발생한 게시물만 처리하므로 쿼리 수는 트래픽에 비례할 것이다.</p>
<hr>
<h3 id="세-연산을-원자적으로-묶기">세 연산을 원자적으로 묶기</h3>
<p>Dirty Set을 도입하면서 좋아요 처리에 세 가지 연산이 필요해졌다.</p>
<p>① SADD  article:likes:{id}   userId      → 좋아요 누른 유저 기록
② INCR  article:like:count:{id}          → 게시글 좋아요 개수 증가
③ SADD  article:like:dirty   articleId   → 좋아요 개수가 변한 게시글 기록</p>
<p>이 세 연산이 개별 명령으로 분리되어 있으면 부분 실패가 발생할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/fb032550-707d-427a-9d7d-4d68a50e7a1d/image.png" alt=""></p>
<p>①②는 성공했는데 ③에서 실패하면, likes Set에는 userId가 추가되고 count도 올랐지만 dirty 마킹이 누락된다. 스케줄러는 해당 게시물을 건너뛰고, DB에는 영영 반영되지 않아 Redis와 DB 사이에 불일치가 생긴다.</p>
<p>위 세 연산을 원자적으로 묶기 위해 Lua Script를 사용하였다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/f2cac57b-50b8-4d49-b1ff-a855750b863f/image.png" alt=""></p>
<p>Redis는 명령 실행이 싱글스레드다. Lua Script는 Redis 내부에서 실행되기 때문에 스크립트 전체가 하나의 명령 슬롯을 점유한다. 스크립트가 실행되는 동안 다른 클라이언트의 명령은 끼어들 수 없기에 원자성이 보장된다.</p>
<p><code>RedisScript.of()</code>는 스크립트를 SHA1로 해시해 캐싱한다. 실제 실행 시 <code>EVALSHA &lt;hash&gt;</code>로 보내고, 서버에 해당 SHA가 없을 때만 스크립트 전체를 전송하는 <code>EVAL</code>로 폴백한다. 매 요청마다 스크립트 본문을 네트워크로 전송하지 않아도 된다.</p>
<p><code>added == 1</code>이면 새로운 좋아요, 0이면 이미 누른 상태다. 조건 분기 하나로 원자성과 중복 방지가 동시에 처리된다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/d4ff0b86-a802-44b0-989a-f755176bc3f1/image.png" alt=""></p>
<p>unlike도 동일한 구조로 처리한다. <code>SREM</code>이 실제로 제거된 원소 수를 반환하므로, 좋아요를 누르지 않은 상태에서의 취소 요청이나 Redis에 Set이 없는 cold start 상황은 <code>removed == 0</code>으로 자연스럽게 걸러진다.</p>
<p>좋아요 로직의 최종 구조는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/d62f3199-a800-4973-ba3c-9afab1659fbc/image.png" alt=""></p>
<hr>
<h3 id="그래도-틈은-생긴다">그래도 틈은 생긴다</h3>
<p>Redis 자체가 죽는 상황은 AOF everysec 설정으로 대응했다. 최대 1초치 write만 유실되도록 했고, 좋아요 도메인에서 이 범위의 오차는 비즈니스를 멈추는 수준이 아니라고 판단했다.</p>
<pre><code>appendonly yes # AOF 활성화
appendfsync everysec # 1초마다 fsync -&gt; 최대 1초치 유실

auto-aof-rewrite-min-size 64mb # AOF 파일 압축 트리거 (최소 크기)
auto-aof-rewrite-percentage 100 # AOF 파일 압축 트리거 (직전 대비 2배 증가 시)

save &quot;&quot; # RDB 비활성화</code></pre><p>AOF는 write 명령을  파일에 순서대로 기록하고, 재시작 시 이를 재실행해 상태를 복구한다. <code>appendfsync</code>는 실제 디스크에 내려쓰는 주기인데, <code>always</code>, <code>everysec</code>, <code>no</code> 중 <code>everysec</code>을 선택했다. AOF 파일은 명령이 쌓일수록 커지기 때문에 <code>auto-aof-rewrite</code>로 주기적으로 현재 메모리 상태 기준으로 압축한다. <code>save &quot;&quot;</code>는 AOF만으로 복구가 가능하므로 RDB 스냅샷을 비활성화해 디스크 I/O 중복을 없앴다.</p>
<p>스케줄러 오류나 애플리케이션 재시작의 경우, dirty set이 Redis에 영속되기 때문에 JVM이 내려가도 미처리 항목이 유실되지 않는다. 재시작 후 스케줄러가 뜨면 남아 있는 dirty set을 그대로 이어 처리하고, flush 도중 예외가 발생하면 해당 <code>articleId</code>를 dirty set에서 제거하지 않아 다음 사이클에 자동으로 재시도된다.</p>
<p>강한 정합성이 필요한 도메인이었다면 Write-Through로 단순하게 가거나, DB 트랜잭션 내에서 outbox 테이블에 이벤트를 함께 기록하는 Transactional Outbox 패턴을 고려했을 것이다.</p>
<hr>
<h3 id="결과">결과</h3>
<p>✅ 로컬 Docker 환경이라 네트워크 레이턴시가 없어서 프로덕션보다 수치가 낮게 나타남</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/87036195-0bd4-48f9-8f68-5285d96b4886/image.png" alt=""></p>
<p>Naive DB 구현체 대비 p95 응답과 처리량이 크게 개선되었고, DB에 직접 쓰던 구현에서 발생하던 DBCP pending도 사라졌다.</p>
<blockquote>
<p><strong>p95</strong>: 221ms → 11.46ms  (19배)
<strong>처리량</strong>: 647 req/s → 1003 req/s
<strong>dropped_iterations</strong>: 21,525 → 194  (99.2% 감소)</p>
</blockquote>
<p>사실 이전에 Kafka Outbox 기반으로 구현한 적이 있었다
<a href = https://velog.io/@dw_db/비동기-이벤트-기반-좋아요-시스템-안정화-과정>비동기 이벤트 기반 좋아요 시스템 안정화 과정</a></p>
<p>당시 <code>article_like</code> 테이블에 유니크 키가 걸려있어, outbox 테이블로 우회해 이벤트를 저장했고, 덕분에 p95는 개선됐다. 다만 outbox write 자체가 크리티컬 패스에 남아 DB I/O는 그대로였다. 도메인보다 Kafka 학습에 무게를 뒀었기 때문이다. 브로커 설정부터 Producer/Consumer 튜닝까지 깊게 다뤄볼 수 있었던 것은 수확이었지만, 이번 리팩토링을 진행하면서 초기에 기술 선택 기준을 더 생각해야 된다는 것을 배울 수 있었다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[서브쿼리(Dependent Subquery)를 Self-Join으로 최적화하기]]></title>
            <link>https://velog.io/@dw_db/%EC%84%9C%EB%B8%8C%EC%BF%BC%EB%A6%ACDependent-Subquery%EB%A5%BC-Anti-Join%EC%9C%BC%EB%A1%9C-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dw_db/%EC%84%9C%EB%B8%8C%EC%BF%BC%EB%A6%ACDependent-Subquery%EB%A5%BC-Anti-Join%EC%9C%BC%EB%A1%9C-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 27 Feb 2026 09:52:34 GMT</pubDate>
            <description><![CDATA[<p>하지만 여전히 아래와 같은 성능 저하가 발생한다</p>
<ul>
<li><code>Using temporary; Using filesort</code><ul>
<li>정렬 사용</li>
</ul>
</li>
<li><code>DEPENDENT SUBQUERY</code><ul>
<li>서브 쿼리는 독립적으로 실행될 수 없고, 외부(메인 쿼리)에서 id 값을 받아야만 실행</li>
</ul>
</li>
</ul>
<p>이전 포스팅에 이어서</p>
<hr>
<p>우선, <code>Using filesort</code>는 랜덤 추천(<code>ORDER BY RAND()</code>)이라는 비즈니스 로직을 유지하는 한 당장 인덱스만으로 제거하기 까다롭다고 생각한다. 따라서 정렬 최적화는 뒤로 미루고, 시스템을 마비시킬 수 있는 구조적 결함인 상관 서브쿼리 문제를 먼저 개선하고자 한다.</p>
<p>현재 상관 서브쿼리의 작동 방식은 다음과 같다.</p>
<ol>
<li>메인 쿼리(id=1)가 Word 테이블에서 단어 1개를 가져온다</li>
<li>이 단어를 가지고 해당 단어의 기록을 찾기 위한 서브쿼리를 실행한다 </li>
<li>메인 쿼리가 다음 단어 1개를 가져온다.</li>
<li>해당 단어의 기록을 찾기 위한 서브쿼리를 실행한다. </li>
<li>이 단계를 단어의 개수만큼 반복한다. (10,000 번)</li>
</ol>
<p><code>EXPLAIN ANALYZE</code> 를 통해 쿼리 실행 시간을 정밀하게 분석해 보았다.</p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT w1_0.id
FROM word w1_0
LEFT JOIN word_learning_history wlh1_0
     ON wlh1_0.word_id = w1_0.id AND wlh1_0.user_id = 1
WHERE
     wlh1_0.id IS NULL
     OR (
         wlh1_0.id = (
             SELECT MAX(wlh2_0.id)
             FROM word_learning_history wlh2_0
             WHERE wlh2_0.word_id = w1_0.id AND wlh2_0.user_id = 1
         )
         AND wlh1_0.result = &#39;WRONG&#39;
     )
LIMIT 10;</code></pre>
<p><img src="https://velog.velcdn.com/images/dw_db/post/90db622f-68d2-4f1a-998b-7be2fdc3b73c/image.png" alt=""></p>
<p>결과를 보면 <code>loops</code> 수가 52회에 불과하고, 전체 실행 속도 또한 <code>1.34ms</code>로 매우 빠르다. 서브쿼리로 인한 N+1 문제가 발생했음에도 왜 이렇게 빠를까?</p>
<p>실행 계획을 분석해 보면 이는 Early Termination (탐색 조기 종료) 덕분임을 알 수 있다.
<code>Covering index scan on w1_0</code> 항목을 보면 DB는 총 9,545개(<code>rows=9545</code>)의 단어를 읽을 준비를 했지만 <strong>실제로 스캔한 단어는 단 26개(<code>actual ... rows=26</code>)</strong>뿐이었다. 현재 테스트 데이터에는 아직 학습하지 않거나(<code>NULL</code>) 틀린(<code>WRONG</code>) 단어가 앞부분에 많아서, DB가 단어를 고작 26개만 스캔하고도 빠르게 <code>LIMIT 10</code> 조건을 채운 뒤 탐색을 멈춰버린 <strong>최선의 경우</strong>였던 것이다. </p>
<p><strong>그렇다면 최악의 경우는 어떨까?</strong>
사용자가 아주 공부를 열심히 해서 10,000개의 단어 중 9,990개를 다 맞춘 상황을 가정해 보자.
DB는 조건에 맞는(틀린 단어) 10개의 단어를 찾기 위해, 1만 개의 단어를 하나씩 꺼낼 때마다 &quot;이 단어 최근에 틀렸어?&quot;라고 묻는 서브쿼리를 1만 번 실행하게 될 것이다.</p>
<p>이 잠재적인 폭탄을 눈으로 확인하기 위해, 쿼리에 의도적으로 <code>OFFSET 5000</code> 조건을 부여하여 실행 계획을 다시 확인해 보았다. <code>OFFSET 5000</code>을 주면 DB는 유효한 데이터 5,000개를 건너뛰어야 하므로, 조기 종료를 하지 못하고 무수한 단어들에 대해 강제로 서브쿼리를 실행하게 된다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/3f0aec32-aff0-43dd-88bd-63faf8c879d9/image.png" alt=""></p>
<p>예상대로였다. 조기 종료가 불가능해진 DB는 <strong>9,930개(<code>actual ... rows=9930</code>)</strong>의 단어를 스캔하며 테이블의 끝까지 뒤져야 했고, 그 결과 총 실행 시간이 1.34ms에서 116ms로 대폭 상승했다. 무엇보다 <strong>서브 쿼리 실행 횟수(<code>loops</code>)가 52회에서 24,828회로 약 400배 폭증</strong>하며, 서브쿼리로 인한 구조적 결함이 데이터 규모에 따라 얼마나 치명적으로 변할 수 있는지 확인했다.</p>
<hr>
<blockquote>
<p>조기 종료는 어떻게 알 수 있을까?</p>
</blockquote>
<p>EXPLAIN ANALYZE 결과의 실제 스캔 행(<code>actual rows</code>)과 루프(<code>loops</code>) 횟수를 보면 명확하다.
LIMIT 10 쿼리에서는 전체 1만 개의 단어 중 고작 26개(<code>rows=26</code>)만 스캔했고, 서브쿼리 역시 52번(<code>loops=52</code>)만 실행되었다. 조건에 맞는 10개를 찾자마자 탐색을 멈췄기 때문이다.</p>
<p>반면 OFFSET 5000을 부여하여 유효 데이터 5천 개를 건너뛰게 만들자, DB는 조기 종료를 하지 못했다. 그 결과 1만 개의 단어 테이블을 거의 끝까지 스캔(<code>rows=9930</code>)해야 했고, 서브쿼리 루프는 24,828번이나 돌며 서버 리소스를 낭비하는 끔찍한 결과를 보여주었다.</p>
<hr>
<p>문제점을 확인했으니 이제 개선해보자
목표는 사용자별 각 단어의 최신 기록을 가져올때, <code>DEPENDENT SUBQUERY</code>를 사용하지 않게 쿼리 튜닝을 진행해야한다. </p>
<p>그렇다면 서브쿼리 없이 &#39;가장 최신 기록&#39;을 어떻게 찾을 수 있을까? </p>
<p>서브쿼리를 안티조인(셀프조인)으로 변경하였다.</p>
<pre><code class="language-sql">SELECT w1_0.id
FROM word w1_0
LEFT JOIN word_learning_history wlh1_0
    ON wlh1_0.word_id = w1_0.id AND wlh1_0.user_id = 1
-- 나보다 ID가 큰(최신인) 기록이 존재하는지 자기 자신과 한번 더 조인 (wlh2_0)
LEFT JOIN word_learning_history wlh2_0
    ON wlh2_0.word_id = w1_0.id 
    AND wlh2_0.user_id = 1
    AND wlh1_0.id &lt; wlh2_0.id 
WHERE 
    -- 나보다 더 최신 기록이 &#39;없는(NULL)&#39; 데이터만 남긴다 = wlh1_0이 최신 기록
    wlh2_0.id IS NULL 
    AND (wlh1_0.id IS NULL OR wlh1_0.result = &#39;WRONG&#39;)
LIMIT 10 OFFSET 5000;</code></pre>
<p>이 쿼리의 실행 계획을 통해 변화를 확인해보았다.</p>
<ul>
<li>서브쿼리 사용
<img src="https://velog.velcdn.com/images/dw_db/post/06a9c513-00ff-47a0-85d7-a054c4362513/image.png" alt=""></li>
<li>조인 사용
<img src="https://velog.velcdn.com/images/dw_db/post/f702de91-75d7-433e-9d72-c4a464a68559/image.png" alt=""></li>
</ul>
<blockquote>
<ul>
<li><code>DEPENDENT SUBQUERY</code>가 완전히 사라지고 모든 조회가 <code>SIMPLE</code>로 변경되었다. </li>
</ul>
</blockquote>
<ul>
<li>MySQL 옵티마이저가 <code>LEFT JOIN ... IS NULL</code> 패턴을 인식하여 Not exists 최적화(조건을 만족하면 조기 종료)를 적용하였다.</li>
<li><code>filtered</code> 값이 100 -&gt; 10 으로 변경되었다.</li>
</ul>
<p>세 가지 변경 사항에 대한 이유를 파악해보자.</p>
<ol>
<li><p><code>DEPENDENT SUBQUERY</code> -&gt;  <code>SIMPLE</code>
절차적 쿼리에서 집합적 처리로의 전환</p>
</li>
<li><p><code>Not exists</code> 최적화
전체 탐색에서 조기 종료로 전환</p>
</li>
<li><p><code>filtered</code> 값 100 -&gt;  10
데이터 필터링 효율 극대화</p>
</li>
</ol>
<hr>
<p>이전에 확인했던 최악의 상황에서 <code>EXPLAIN ANALYZE</code> 를 통해 결과를 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/65e374b1-2a07-429f-a3f0-4611f5c17f79/image.png" alt=""></p>
<p>EXPLAIN ANALYZE 결과를 자세히 보면 wlh2_0 인덱스 룩업 과정에서 <code>loops=24710</code>이 발생한 것을 볼 수 있다. 이전 서브쿼리 방식(<code>loops=24828</code>)과 횟수가 비슷한데, 과연 성능이 개선된 것일까?</p>
<p>아래와 같은 지표들을 근거로, 루프의 질이 경량화 되었다고 생각한다.</p>
<ol>
<li>전체 실행 시간</li>
</ol>
<ul>
<li>서브쿼리 방식: <code>actual time=104..105</code> (약 105ms)</li>
<li>안티 조인 방식: <code>actual time=82.6..82.7</code> (약 82.7ms)</li>
<li>약 21% 더 빠른 속도를 기록</li>
</ul>
<ol start="2">
<li>루프 내부 연산 방식의 차이</li>
</ol>
<ul>
<li>두 방식 모두 유효 데이터를 찾기 위한 루프 수는 약 2만 4천번으로 유사</li>
<li>하지만 루프마다 연산하는 시간이 다름</li>
<li>서브쿼리: <code>actual time = 0.0014 .. 0.00178</code></li>
<li>셀프조인: <code>actual time = 885e-6 .. 0.00117(0.000885ms ~ 0.00117ms)</code></li>
</ul>
<p>이전의 DEPENDENT SUBQUERY 루프는 매번 새로운 서브쿼리를 실행하고 <code>MAX()</code> 집계 함수를 연산해야 하는 무거운 작업이었다. 반면 조인으로 바꾼 현재의 루프는 Nested Loop Join 파이프라인 안에서 이미 메모리에 올라온 데이터를 가지고 인덱스만 빠르게 조회할 수 있게 되었다.</p>
<p><strong>결과적으로 메인 쿼리에 의해서 반복하는 루프수는 동일하지만, 각 루프의 실행 시간이 경량화되어 최종적으로 조회 성능을 21% 향상시킬 수 있었던 것이다.</strong></p>
<hr>
<p>블로그를 다 작성하고보니, 서브쿼리의 조기종료를 위해서라면 <code>exists</code>를 활용할 수도 있다는 생각이 들었다. .. .</p>
<p>상관 서브쿼리에 <code>exists</code> 사용 vs 셀프 조인 사용
다음 포스팅은 이 두 결과를 비교하여 더 최적화된것이 무엇인지 확인해 볼 예정이다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[5만 건 데이터 조회 속도 개선: 복합 인덱스 설계의 이유와 결과]]></title>
            <link>https://velog.io/@dw_db/5%EB%A7%8C-%EA%B1%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%86%8D%EB%8F%84-%EA%B0%9C%EC%84%A0-%EB%B3%B5%ED%95%A9-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%84%A4%EA%B3%84%EC%9D%98-%EC%9D%B4%EC%9C%A0%EC%99%80-%EA%B2%B0%EA%B3%BC</link>
            <guid>https://velog.io/@dw_db/5%EB%A7%8C-%EA%B1%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%86%8D%EB%8F%84-%EA%B0%9C%EC%84%A0-%EB%B3%B5%ED%95%A9-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%84%A4%EA%B3%84%EC%9D%98-%EC%9D%B4%EC%9C%A0%EC%99%80-%EA%B2%B0%EA%B3%BC</guid>
            <pubDate>Tue, 10 Feb 2026 07:24:36 GMT</pubDate>
            <description><![CDATA[<p>이전 포스팅에서는 JPA <code>findAll()</code>로 인한 애플리케이션 메모리 병목을 해결하기 위해, DB 레벨로 필터링을 이관하여 속도를 15배 개선한 내용을 담았다.</p>
<p>하지만 당시 작성했던 초기 쿼리에는 비즈니스 로직상 치명적인 허점이 있었다. 유저가 과거에 틀렸지만 최근에 다시 풀어서 맞춘 단어도 계속 추천 목록에 뜨는 문제였다. 단순히 <code>WRONG</code>인 기록만 찾았기 때문이다. </p>
<p>이를 해결하기 위해 &#39;해당 단어의 가장 최신 학습 기록을 확인하는 로직&#39;과 &#39;오답 단어를 최우선으로 노출하는 정렬 로직&#39;을 추가하며 쿼리를 개선했다.</p>
<pre><code class="language-sql">@Override
    public List&lt;Word&gt; findRecommendedWords(Long userId, int limit) {
        QWordLearningHistory subHistory = new QWordLearningHistory(&quot;subHistory&quot;);

        return queryFactory
            .selectFrom(word1)
            .leftJoin(wordLearningHistory)
            .on(wordLearningHistory.wordId.eq(word1.id)
                .and(wordLearningHistory.userId.eq(userId)))
            .where(
                wordLearningHistory.isNull()
                    .or(
                        wordLearningHistory.id.eq(
                                //최신 학습 기록
                                JPAExpressions
                                    .select(subHistory.id.max())
                                    .from(subHistory)
                                    .where(subHistory.wordId.eq(word1.id)
                                        .and(subHistory.userId.eq(userId)))
                            )
                            .and(wordLearningHistory.result.eq(LearningResult.WRONG))
                    )
            )
            //오답 단어 최우선 노출
            .orderBy(
                new CaseBuilder()
                    .when(wordLearningHistory.result.eq(LearningResult.WRONG)).then(1)
                    .otherwise(2).asc(),
                Expressions.numberTemplate(Double.class, &quot;function(&#39;rand&#39;)&quot;).asc()
            ).limit(limit)
            .fetch();
    }</code></pre>
<p>기능은 완벽하게 동작했지만, 이 &#39;최신 기록을 찾기 위한 서브쿼리&#39;가 대용량 데이터 환경에서 성능의 발목을 잡는 새로운 병목을 만들어냈다. </p>
<p>이번 포스팅에서는 DB 인덱스를 설계하고 실행 계획을 튜닝하는 과정을 다룰것이다.</p>
<hr>
<h3 id="문제-상황">문제 상황</h3>
<ul>
<li>시나리오: &quot;사용자가 아직 안 외운 단어&quot; 혹은 &quot;틀린 단어&quot;를 추천해 주는 쿼리.</li>
<li>데이터 규모: 단어 1만 개, 학습 기록 5만 개.</li>
<li>문제의 쿼리: <code>LEFT JOIN</code>과 <code>WHERE</code> 절의 서브쿼리가 포함된 쿼리</li>
<li>실행 계획:<ul>
<li><code>type: ALL</code> (Full Table Scan) 발생.</li>
<li>Word(1만) x History(5만) 조인 시, 인덱스가 없어 DB가 5만 건을 매번 다 뒤지는 상황.</li>
</ul>
</li>
</ul>
<h3 id="해결-전략-redis-캐싱-대신-rdbms-인덱스-튜닝을-선택한-이유">해결 전략: Redis 캐싱 대신 RDBMS 인덱스 튜닝을 선택한 이유</h3>
<ul>
<li><p>이전 프로젝트에서는 조회 성능을 개선하기 위해 외부 메모리 저장소인 Redis를 적극적으로 도입했었다. 하지만 실무적인 관점에서는 &quot;조회가 느리면 캐시를 붙인다&quot;는 맹목적인 접근보다, 인프라 유지보수 비용과 데이터의 특성을 고려한 트레이드오프분석이 선행되어야 한다고 생각한다.</p>
</li>
<li><p>RDBMS 내부 최적화를 선택한 구체적인 근거는 다음과 같다.</p>
<ul>
<li><p>초개인화된 데이터: 공지사항이나 &#39;오늘의 인기 단어&#39; 같은 글로벌 데이터가 아니다. 유저마다 학습 상태가 다르므로 유저 수만큼의 방대한 Cache Key가 필요하여 메모리 효율이 떨어진다. 따라서 캐시 히트 비율도 낮을 것이다.</p>
</li>
<li><p>빈번한 상태 변경: 사용자가 단어를 학습할 때마다 해당 단어의 상태(<code>미학습</code> → <code>WRONG</code> → <code>CORRECT</code>)가 실시간으로 바뀐다. 잦은 데이터 갱신으로 인해 빈번한 캐시 무효화와 쓰기 작업이 발생하므로, 캐시 도입으로 얻는 읽기 이점보다 동기화 비용이 훨씬 커서 배보다 배꼽이 더 커지는 상황이라고 판단했다.</p>
</li>
</ul>
</li>
<li><p>따라서 외부 시스템 의존성을 높이기 전에, RDBMS가 제공하는 본연의 검색 최적화 기능인 <strong>인덱스</strong>를 설계하여 풀 테이블 스캔 문제를 해결하기로 결정했다.</p>
</li>
</ul>
<hr>
<h3 id="해당-sql의-쿼리-실행-계획을-확인해보자">해당 SQL의 쿼리 실행 계획을 확인해보자</h3>
<p><img src="https://velog.velcdn.com/images/dw_db/post/631624a2-e170-4501-aa61-dd9ed811b6b3/image.png" alt=""></p>
<ul>
<li><code>type: ALL</code><ul>
<li><code>word</code> 테이블(1만 건)과 <code>word_learning_history</code> 테이블(5만 건)을 처음부터 끝까지 다 읽고 있는것을 확인.</li>
</ul>
</li>
<li><code>DEPENDENT SUBQUERY</code><ul>
<li>단어가 1만 개라면, 서브쿼리도 1만 번 실행되는데, 그 서브쿼리조차 <code>ALL</code> 5만 건 스캔</li>
<li>최악의 시나리오: 10,000(단어) × 50,000(기록) = 5억 번의 연산이 발생할 수 있는 구조</li>
</ul>
</li>
<li><code>Using temporary; Using filesort</code><ul>
<li>복잡한 정렬 조건으로 인해, 메모리 or 디스크에 임시 테이블을 만들어서 정렬하는 구조</li>
</ul>
</li>
</ul>
<h3 id="인덱스-생성-및-적용">인덱스 생성 및 적용</h3>
<p>수만 건의 학습 기록을 모두 뒤지지 않고 필요한 데이터만 즉시 찾아내기 위해 복합 인덱스(멀티 컬럼 인덱스)를 적용한다. </p>
<p>현재 <code>word</code>, <code>word_learning_history</code> 테이블의 인덱스 목록은 InnoDB가 생성해주는 기본 인덱스들만 존재한다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/b5a218f2-0665-4f0b-8445-d810670cbb97/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/98c034e6-a154-4f05-a760-38115f635d9a/image.png" alt=""></p>
<p>단순히 카디널리티가 높은 순서로 컬럼을 나열하는 것이 아니라, 실제 쿼리의 탐색 패턴을 최우선으로 고려하여 컬럼의 순서를 다음과 같이 설계했다.</p>
<pre><code class="language-sql">CREATE INDEX idx_wlh_user_word_id ON word_learning_history (user_id, word_id, id);</code></pre>
<ul>
<li><p><code>user_id</code> (1순위 - 동등 조건 필터링)
쿼리의 시작점인 <code>WHERE user_id = ?</code> 조건에서 사용된다. 전체 5만 건의 데이터 중 특정 유저의 기록만으로 탐색 범위를 즉각적으로 대폭 줄여주기 때문에 가장 앞단에 배치했다.</p>
</li>
<li><p><code>word_id</code> (2순위 - 조인 조건)
<code>user_id</code>로 필터링된 결과 내에서, Word 테이블과 조인(<code>ON wlh.word_id = w.id</code>)할 때 사용된다. 유저 단위로 좁혀진 인덱스 블록 안에서 특정 단어를 빠르게 매핑하는 역할을 한다.</p>
</li>
<li><p><code>id</code> (3순위 - 최신값 탐색 및 커버링 인덱스)
비즈니스 로직 상 해당 단어의 &#39;가장 최근 기록(<code>MAX(id)</code>)&#39;을 찾아야 한다. 복합 인덱스는 선행 컬럼 기준으로 이미 정렬된 상태를 유지한다. 따라서 id를 인덱스의 마지막에 두면, 실제 테이블의 데이터 페이지(디스크)를 읽지 않고 인덱스만 스캔하여 최댓값을 즉시 찾아내는 커버링 인덱스 효과를 얻을 수 있다.</p>
</li>
</ul>
<h3 id="인덱스-생성-후-실행-계획-확인">인덱스 생성 후 실행 계획 확인</h3>
<p> <img src="https://velog.velcdn.com/images/dw_db/post/2a2eb496-c860-46e2-9cfb-943b63143261/image.png" alt=""></p>
<ul>
<li><code>type: ref</code><ul>
<li>전체를 뒤지는 대신 인덱스 주소로 바로 접근</li>
</ul>
</li>
<li><code>rows</code><ul>
<li>5</li>
</ul>
</li>
<li><code>DEPENDENT SUBQUERY</code><ul>
<li>단어가 1만 개라면, 서브쿼리도 1만 번 실행되는데, 그 서브쿼리조차 <code>ALL</code> 5만 건 스캔</li>
<li>최악의 시나리오: 10,000(단어) × 50,000(기록) = 5억 번의 연산이 발생할 수 있는 구조</li>
</ul>
</li>
</ul>
<p>탐색한 <code>rows</code>는 대폭 감소한것을 확인할 수 있다.</p>
<p>하지만 여전히 아래와 같은 성능 저하가 발생한다</p>
<ul>
<li><code>Using temporary; Using filesort</code> -&gt; 정렬 사용</li>
<li><code>DEPENDENT SUBQUERY</code> -&gt; 서브 쿼리가 독립적으로 실행될 수 없고, 메인 쿼리에서 <code>id</code> 값을 받아야만 실행</li>
</ul>
<p>이후 포스팅에서 위 성능 저하 요인을 개선하는 작업을 이어서 진행할 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[데이터 처리 계층 변경(App -> DB)을 통한 추천 로직 병목 해결기]]></title>
            <link>https://velog.io/@dw_db/%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-15%EB%B0%B0-%EA%B0%9C%EC%84%A0%EA%B8%B0-1.38s-92ms-QueryDSL-%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@dw_db/%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-15%EB%B0%B0-%EA%B0%9C%EC%84%A0%EA%B8%B0-1.38s-92ms-QueryDSL-%EB%8F%84%EC%9E%85</guid>
            <pubDate>Sat, 31 Jan 2026 06:49:27 GMT</pubDate>
            <description><![CDATA[<h3 id="배경-및-문제-상황">배경 및 문제 상황</h3>
<p>현재 개발 중인 MailVoca 서비스에는 사용자별 학습 기록을 분석하여 단어를 추천해 주는 기능이 있다. 추천 로직의 우선순위는 다음과 같다.</p>
<ol>
<li>틀린 단어: 사용자가 학습했으나 틀린 기록이 있는 단어</li>
<li>학습하지 않은 단어: 아직 학습 기록이 없는 단어</li>
<li>맞춘 단어: 추천 제외</li>
</ol>
<p>초기 개발 단계에서는 빠른 구현을 위해 JPA의 <code>findAll()</code>을 사용하여 모든 단어와 기록을 애플리케이션 메모리로 로딩한 후, Java Stream을 이용해 필터링하는 방식을 사용하였다.</p>
<p>하지만 데이터가 적을 때는 문제가 없었으나, 더미 데이터(단어 1만 건, 학습 기록 5만 건)를 적재하고 부하 테스트를 진행하자 병목 현상이 발생하였다.</p>
<hr>
<h3 id="as-is-성능-측정-및-원인-분석">AS-IS 성능 측정 및 원인 분석</h3>
<p><img src="https://velog.velcdn.com/images/dw_db/post/89561c11-df60-4a8b-b771-b9e8ffc26ff3/image.png" alt=""></p>
<p>로컬 Docker 환경에서 실제 운영 환경(AWS t2.micro)과 유사한 스펙으로 제한을 두고, k6를 이용해 부하 테스트를 진행한 결과이다.</p>
<ul>
<li>테스트 데이터: 단어(Word) 10,000건 / 학습 기록(History) 50,000건</li>
<li>테스트 조건: VUser 10명 / 30초 지속</li>
</ul>
<p>▼ 개선 전 테스트 결과</p>
<ul>
<li>P95: 1.38s (1,380ms)<ul>
<li>사용자가 단어 추천을 받기 위해 1.3초 이상 대기.</li>
</ul>
</li>
<li>Avg: 487.09ms</li>
<li>Throughput: 6.5 req/s</li>
</ul>
<p>원인 분석</p>
<ul>
<li>메모리 부하: 매 요청마다 DB에서 6만 건의의 데이터를 모두 조회하여 Heap 메모리에 올리고, 동시 접속자가 늘어날 경우 OOM 발생 위험이 크다고 판단</li>
<li>비효율적인 연산: 모든 데이터를 애플리케이션으로 가져온 뒤 반복문을 돌며 필터링과 정렬을 수행하여 CPU 사용량이 급증 ?</li>
</ul>
<hr>
<h3 id="데이터-처리-위치-이관-app---db">데이터 처리 위치 이관 (App -&gt; DB)</h3>
<p>병목을 해결하기 위해 애플리케이션 메모리에서 처리하던 로직을 DB 레벨로 이관하기로 결정하였고, 복잡한 동적 쿼리와 조인을 타입 세이프하게 작성하기 위해 QueryDSL을 도입</p>
<h4 id="쿼리-최적화">쿼리 최적화</h4>
<p>기존의 <code>findAll()</code> 로직을 제거하고, <code>Left Join</code>과 <code>Where</code> 조건을 사용하여 필요한 데이터만 DB에서 가져오도록 변경</p>
<pre><code class="language-java">@Override
public List&lt;Word&gt; findRecommendedWords(Long userId, int limit) {
    return queryFactory
            .selectFrom(word1)
            .leftJoin(wordLearningHistory)
                .on(wordLearningHistory.wordId.eq(word1.id)
                    .and(wordLearningHistory.userId.eq(userId))) 
            .where(
                wordLearningHistory.isNull()
                .or(wordLearningHistory.result.eq(LearningResult.WRONG))
            )
            .orderBy(Expressions.numberTemplate(Double.class, &quot;function(&#39;rand&#39;)&quot;).asc())
            .limit(limit)
            .fetch();
}</code></pre>
<ul>
<li><p>Left Join을 통한 전체 모집단 유지</p>
<ul>
<li>추천 대상에는 &#39;한 번도 학습하지 않은 단어(기록 없음)&#39;가 포함되어야 한다</li>
<li><code>Inner Join</code>을 사용할 경우, 학습 기록이 있는 단어만 교집합으로 조회되므로 &#39;안 배운 단어&#39;가 누락되는 문제가 발생할 것이다.</li>
<li>따라서 Word(전체 단어)를 기준으로 History(학습 기록)를 Left Join하여, 학습 이력이 없는 단어까지 조회 대상에 포함시켰다.</li>
</ul>
</li>
<li><p>ON 절을 활용한 조인 범위 축소</p>
<ul>
<li><code>leftJoin(...).on(...)</code> 절 내부에 <code>userId</code> 조건을 부여</li>
<li>전체 학습 기록을 다 가져와서 필터링하는 것이 아니라, 해당 유저의 학습 기록만을 대상으로 단어와 매핑하도록 조인 대상을 한정지었다.</li>
</ul>
</li>
<li><p>정교한 Where 절 필터링 </p>
<ul>
<li>애플리케이션의 조건문을 DB의 조건문으로 변환</li>
<li><code>history.isNull()</code>: 조인 결과 학습 기록이 없는 경우 (안 배운 단어)</li>
<li><code>OR history.result.eq(WRONG)</code>: 학습 기록은 있으나 결과가 틀린 경우 (오답 단어)</li>
<li>이 두 조건에 해당하지 않는 경우(즉, 학습 기록이 있고 정답인 경우)는 자연스럽게 결과 집합에서 배제</li>
</ul>
</li>
</ul>
<hr>
<h3 id="to-be">TO-BE</h3>
<p>쿼리 최적화 후, 동일한 데이터와 조건으로 k6 테스트를 다시 수행했다.
<img src="https://velog.velcdn.com/images/dw_db/post/49ed1fe3-e935-4b49-8781-46d7e0f46ce6/image.png" alt=""></p>
<p>▼ 개선 후 테스트 결과</p>
<ul>
<li>P95: 1.38s → 92.73ms (약 15배 속도 향상)</li>
<li>Avg: 487ms → 85.4ms</li>
<li>Throughput: 6.5 req/s → 9.2 req/s</li>
</ul>
<hr>
<h3 id="회고">회고</h3>
<p>실무에서는 빠른 응답을 반환하기 위해 다양한 방법으로 최적화하는것이 필수적이다. 이번 과정을 통해 &quot;데이터가 어디서 처리되는 것이 가장 효율적인가?&quot;를 고민하는 계기가 되었다. 모든 로직을 DB에 위임하는 것이 정답은 아니라고 생각한다. 복잡한 비즈니스 계산이나 DB 부하 분산이 필요한 경우에는 애플리케이션 처리가 유리할 수도 있을 것이다.</p>
<p>현재 정렬에 사용한 MySQL의 <code>ORDER BY RAND()</code>는 간단한 구현에는 유용하지만, 데이터가 수백만 건 이상으로 늘어날 경우 인덱스를 타지 못해 <code>Full Table Scan</code>과 <code>File Sort</code>를 유발하여 다시 성능 저하를 유발할 수 있을 것이라고 나만의 작은 비서에게 도움을 받았다. 이 내용은 Real MySQL 1.0을 읽으면서 학습했던 내용인데, 개념적인 내용을 실제 코드로 적용해본 경험은 없어서 다음 포스팅에서 시도해볼 예정이다. (랜덤값이 아니라 기본키의 인덱스인 클러스터링 인덱스를 잘 사용할 수 있지 않을까..?)</p>
<p>사실 Redis를 사용한다면 조회성능을 대폭 향상시킬 수 있을 것이지만, 외부 인프라 의존성을 늘리기 전에 RDBMS 자체가 가진 인덱싱 구조를 최대한 활용해 보는 것이 더 가치 있는 경험이라 생각했다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[테스트 환경 통합으로 스프링 부트 실행 횟수 줄이기]]></title>
            <link>https://velog.io/@dw_db/%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%99%98%EA%B2%BD-%ED%86%B5%ED%95%A9%EC%9C%BC%EB%A1%9C-%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8-%EC%8B%A4%ED%96%89-%ED%9A%9F%EC%88%98-%EC%A4%84%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@dw_db/%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%99%98%EA%B2%BD-%ED%86%B5%ED%95%A9%EC%9C%BC%EB%A1%9C-%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8-%EC%8B%A4%ED%96%89-%ED%9A%9F%EC%88%98-%EC%A4%84%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Sat, 24 Jan 2026 16:14:53 GMT</pubDate>
            <description><![CDATA[<p>현재 전체 통합 테스트를 진행하면 아래와 같이 세 번의 스프링 부트 서버가 실행된다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/dw_db/post/d14979de-e542-4d70-91ee-960ee0b157ac/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/dw_db/post/be38b678-4273-4bb5-93ac-0dd43b4d6148/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/dw_db/post/e0b01fd3-a109-491a-9076-e9555a09209f/image.png" alt=""></p>
<p>테스트 코드를 작성하고 실행하며 유지보수하는 과정은 모두 비용이다. 따라서 테스트 시간을 단축하는 것은 개발 생산성을 위해 중요한 과제다. 현재는 프로젝트 규모가 작아 차이가 미미할 수 있지만, 규모가 커질수록 이 차이는 확연하게 벌어질 것이다.</p>
<p>테스트 환경을 통합하여 컨텍스트 로딩 횟수를 최적화하기로 결정했다. 목표는 API 계층인 <code>UserControllerTest</code>와 <code>WordControllerTest</code>를 통합하여 하나의 스프링 부트 환경에서 실행되도록 만드는 것이다.</p>
<h4 id="왜-서버가-여러-번-뜨는가">왜 서버가 여러 번 뜨는가?</h4>
<p>근본적인 원인은 스프링의 컨텍스트 캐싱 전략 때문이다. 스프링은 테스트 간에 설정(Configuration)이 동일하면 컨텍스트를 재사용한다. 하지만 설정이 조금이라도 다르면 새로운 환경으로 인식하여 컨텍스트를 다시 로드한다.</p>
<p>각 컨트롤러 테스트 코드의 코드를 살펴보자.</p>
<pre><code class="language-java">@WebMvcTest(
    controllers = UserController.class,
    excludeFilters = @ComponentScan.Filter(
        type = FilterType.ASSIGNABLE_TYPE,
        classes = JwtAuthenticationFilter.class
    )
)@ActiveProfiles(&quot;test&quot;)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;
    // ...</code></pre>
<pre><code class="language-java">@WebMvcTest(controllers = WordController.class)
@Import(SecurityConfig.class)
public class WordControllerTest {
    @MockitoBean private WordService wordService;
    @MockitoBean private JwtAuthenticationFilter jwtAuthenticationFilter;
    // ...
}</code></pre>
<p>두 테스트는 <code>@WebMvcTest</code>를 사용하지만, <code>@MockitoBean</code>의 구성이 다르고 Security 필터 적용 여부(<code>excludeFilters</code>)가 다르다. 스프링 입장에서는 서로 다른 환경이므로 서버를 각각 띄울 수밖에 없다.</p>
<h4 id="부모-클래스를-통한-환경-통일">부모 클래스를 통한 환경 통일</h4>
<p>해결책은 간단하다. 모든 컨트롤러 테스트가 동일한 환경 설정을 공유하도록 부모 클래스를 만드는 것이다.</p>
<ol>
<li>모든 컨트롤러를 <code>controllers</code> 속성에 등록한다.</li>
<li>모든 API 테스트에서 필요한 의존성(<code>@MockitoBean</code>)을 한곳에 모아 정의한다.</li>
<li>Security 설정도 <code>@Import</code>로 통일한다.</li>
</ol>
<p>이렇게 하면 <code>UserControllerTest</code>를 실행할 때 생성된 컨텍스트를 <code>WordControllerTest</code>에서도 그대로 재사용할 수 있을 것이다.</p>
<h4 id="security-필터와-mock-객체의-동작">Security 필터와 Mock 객체의 동작</h4>
<p>통합 과정에서 한 가지 문제가 발생했다.</p>
<p>기존 <code>UserControllerTest</code>는 필터를 아예 제외했기에 별다른 설정 없이 통과됐었다. 하지만 통합 환경에서는 모든 테스트가 <code>JwtAuthenticationFilter</code> Mock 객체를 공유하게 된다. Mock 객체는 기본적으로 아무런 동작을 하지 않기 때문에, 요청이 필터에서 멈춰버려 컨트롤러까지 도달하지 못하는 문제가 발생했다.</p>
<p>따라서 부모 클래스에 <code>setUp()</code> 메서드를 정의하여, &#39;Mock 필터가 요청을 받으면 다음 체인으로 넘겨주라&#39;는 공통 설정을 추가했다.</p>
<pre><code class="language-java">@WebMvcTest(controllers = {
    UserController.class,
    WordController.class
})
@ActiveProfiles(&quot;test&quot;)
@Import(SecurityConfig.class)
public abstract class ControllerTestSupport {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    // 1. Word 관련 의존성
    @MockitoBean
    protected WordService wordService;

    // 2. Security &amp; Global 관련 의존성
    @MockitoBean
    protected JwtTokenProvider jwtTokenProvider;

    @MockitoBean
    protected CustomUserDetailsService customUserDetailsService;

    @MockitoBean
    protected JwtAuthenticationFilter jwtAuthenticationFilter;

    @MockitoBean
    protected OAuth2SuccessHandler oAuth2SuccessHandler;

    // 모든 컨트롤러 테스트가 실행되기 전, Mock 필터가 요청을 통과시키도록 설정
    @BeforeEach
    void setUp() throws Exception {
        doAnswer(invocation -&gt; {
            HttpServletRequest request = invocation.getArgument(0);
            HttpServletResponse response = invocation.getArgument(1);
            FilterChain chain = invocation.getArgument(2);
            chain.doFilter(request, response);
            return null;
        }).when(jwtAuthenticationFilter).doFilter(any(), any(), any());
    }
}</code></pre>
<p>이제 각 컨트롤러 테스트는 이 클래스를 상속받기만 하면 된다.</p>
<pre><code class="language-java">class WordControllerTest extends ControllerTestSupport {
}</code></pre>
<h4 id="결과-및-결론">결과 및 결론</h4>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/dw_db/post/7012857f-f401-4509-b0a5-64bde12b60b3/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/dw_db/post/63dda3c7-2ce0-4a10-8d6a-dee8edb323a5/image.png" alt=""></p>
<p>적용 결과, 전체 테스트 수행 시 스프링 부트 컨텍스트 로딩 횟수가 3회에서 2회로 감소했다. (API 계층 통합 1회 + 리포지토리 계층 1회)</p>
<p>스프링 테스트 통합 환경 설정을 진행하면서 트레이드 오프가 존재할 것이라고 생각했다. <code>UserController</code>만 테스트하고 싶을 때도 <code>WordService</code> 같은 관련 없는 빈까지 모두 로드해야 하므로 초기 구동 비용이 약간 증가할 수 있다. </p>
<p>하지만 전체 테스트 케이스를 실행할 때 얻는 시간 단축 이득이 훨씬 클 것이며, CI/CD 파이프라인에서의 시간도 단축될 수 있다고 생각하여 효율성을 고려했을 때 충분히 가치 있는 최적화라고 생각한다.</p>
<img src=https://velog.velcdn.com/images/dw_db/post/4e4d6be5-819f-4ad5-9fc4-7b88eccfe488/image.png width=50%>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot 테스트 코드 작성기 - 기본적인 테스트 원칙과 어노테이션]]></title>
            <link>https://velog.io/@dw_db/Spring-Boot-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1%EA%B8%B0-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%B6%80%ED%84%B0-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@dw_db/Spring-Boot-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1%EA%B8%B0-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%B6%80%ED%84%B0-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Thu, 22 Jan 2026 07:33:49 GMT</pubDate>
            <description><![CDATA[<h3 id="나는-왜-테스트-코드를-작성하는가">나는 왜 테스트 코드를 작성하는가?</h3>
<p>지금까지는 API를 만들고 나면 Postman을 켜고, 버튼을 누르고, DB를 조회하며 &quot;눈&quot;으로 검증했다. 하지만 다양한 프로젝트를 진행할수록 이 과정은 지루해지고 번거로웠다. <del>(물론 Postman으로 검증하지 않는것은 아닙니다)</del></p>
<p>특히 이번 프로젝트에서는 TDD의 철학을 조금이라도 녹여내고 싶었은 마음이 있었다.
테스트 코드 강의를 수강하며( <a href="https://www.inflearn.com/course/practical-testing-실용적인-테스트-가이드/dashboard"> 박우빈 - Practical Testing: 실용적인 테스트 가이드</a>) 테스트는 단순한 버그 찾기가 아니라 <strong>&quot;코드를 변경했을 때 기존 기능이 안전하다는 것을 보장해주는 안전장치&quot;</strong>라는 말에 공감을 할 수 있었기 때문이다.</p>
<p>강사님이 말씀하신 테스트 코드란 아래와 같다.</p>
<blockquote>
<p>올바른 테스트 코드는</p>
</blockquote>
<ul>
<li>자동화 테스트로 비교적 빠른 시간 안에 버그를 발견할 수 있고, 수동 테스트에 드는 비용을 크게 절약할 수 있다</li>
<li>소프트웨어의 빠른 변화를 지원한다</li>
<li>팀원들의 집단 지성을 팀 차원의 이익으로 승격시킨다</li>
<li>가까이 보면 느리지만, 멀리 보면 가장 빠르다<ul>
<li>팀 차원, 소프트웨어 주기 관점에서 봤을 경우.</li>
</ul>
</li>
</ul>
<p>그리고 테스트 코드를 관리하는 비용을 추가로 들이지 않으려면, 테스트 코드를 &quot;잘&quot; 짜야 한다는것이 핵심이라고 생각한다.</p>
<p>이번 포스팅은 비즈니스 로직의 핵심인 Service Layer 단위 테스트를 작성한 과정을 정리해보려한다. </p>
<h3 id="service-layer-테스트-코드">Service Layer 테스트 코드</h3>
<p>먼저 WordService의 테스트 코드이다. 단순히 단어 3개를 DB에서 조회하는 과정이고, 실제 DB를 띄우지 않고, 오직 자바 코드로만 빠르게 검증하는 단위 테스트 방식을 택했다.</p>
<pre><code class="language-java">@ExtendWith(MockitoExtension.class)
class WordServiceTest {

    @InjectMocks // 가짜 객체들을 주입받을 실제 테스트 대상
    private WordService wordService;

    @Mock // 가짜(Mock) 협력 객체
    private WordRepository wordRepository;

    @Test
    @DisplayName(&quot;랜덤 단어 3개를 가져온다&quot;)
    void getRandomWords() {
       // given
       List&lt;Word&gt; mockWords = Arrays.asList(
          Word.from(&quot;Apple&quot;, &quot;사과&quot;, &quot;I eat apple&quot;),
          Word.from(&quot;Banana&quot;, &quot;바나나&quot;, &quot;I eat banana&quot;),
          Word.from(&quot;Cherry&quot;, &quot;체리&quot;, &quot;I eat cherry&quot;)
       );

       given(wordRepository.findRandomWords(3)).willReturn(mockWords);

       // when
       List&lt;WordResponse&gt; result = wordService.getRandomWords();

       // then 
       assertThat(result).hasSize(3);
       assertThat(result.get(0).word()).isEqualTo(&quot;Apple&quot;);

       // 검증: 리포지토리가 진짜로 1번 호출되었는가?
       verify(wordRepository, times(1)).findRandomWords(3);
    }
}</code></pre>
<h3 id="왜-이-어노테이션들을-사용했나">왜 이 어노테이션들을 사용했나?</h3>
<p>테스트 코드를 보면 낯선 어노테이션들이 등장하는데, 마치 스프링을 처음 학습할때가 떠오른다.
&quot;왜 이것을 썼는지&quot; 이유를 정리해 보았다.</p>
<ol>
<li><code>@SpringBootTest</code> vs <code>@ExtendWith(MockitoExtension.class)</code>
처음엔 통합 테스트인 <code>@SpringBootTest</code>를 쓸까 고민했지만, 서비스 레이어 테스트의 목적은 <strong>&quot;비즈니스 로직 검증&quot;</strong>이지, DB 연결이나 스프링 컨텍스트 로딩이 아니라고 생각하였다.</li>
</ol>
<ul>
<li><code>@SpringBootTest</code>: 스프링의 모든 빈을 다 띄운다. 무겁고 느림.</li>
<li><code>@ExtendWith(MockitoExtension.class)</code>: <code>JUnit5</code>에서 Mockito 기능을 사용하기 위한 설정. 스프링을 띄우지 않고 가짜 객체만 사용하여 밀리초 단위로 빠르게 실행된다.</li>
</ul>
<ol start="2">
<li><code>@Mock</code>과 <code>@InjectMocks</code>
서비스는 리포지토리가 없으면 동작하지 않지만, 단위 테스트에서는 <strong>&quot;진짜 리포지토리&quot;</strong>를 쓸 필요가 없다고 생각한다.</li>
</ol>
<ul>
<li><code>@Mock</code>: 껍데기만 있는 가짜 객체를 만들어 실제 DB에 접근하지 않는다.</li>
<li><code>@InjectMocks</code>: 테스트 대상인 <code>wordService</code>를 생성하고, 그 안에 위에서 만든 <code>@Mock</code> 객체들을 주입해준다.</li>
</ul>
<p>이 어노테이션들 덕분에 DB 상태와 상관없이 서비스 로직만 순수하게 테스트할 수 있었다.</p>
<h3 id="bddbehavior-driven-development-스타일-적용하기">BDD(Behavior Driven Development) 스타일 적용하기</h3>
<p>테스트 코드 내부를 보면 <code>Mockito.when()</code> 대신 <code>given()</code>을 사용하였고, 이는 BDDMockito 라이브러리이다. </p>
<p>왜 <code>Mockito.when()</code> 대신 <code>BDDMockito.given()</code>인가?
&#39;테스트는 문서다&#39; 라는 기본 개념을 지키기 위해 가독성을 중요하다고 생각하여<code>Given-When-Then</code> 패턴을 적용하는데, 이때 기본 Mockito 문법은 이 흐름을 깨버린다.</p>
<pre><code class="language-Java">// given 섹션인데 함수 이름이 when 이라서 헷갈림
when(wordRepository.findRandomWords(3)).thenReturn(mockWords);

// 주석(given)과 코드가 일치함 -&gt; 가독성 향상
given(wordRepository.findRandomWords(3)).willReturn(mockWords);</code></pre>
<p>결국 기능은 같지만, 테스트 시나리오가 하나의 문서처럼 작성되어 좋은 가독성을 위해 <code>BDDMockito</code>를 선택하였다.</p>
<h3 id="검증">검증</h3>
<p>마지막으로 검증 단계(<code>then</code>)에서는 JUnit의 기본 <code>assertEquals</code> 대신 AssertJ의 <code>assertThat</code>을 사용하였다.</p>
<ul>
<li>JUnit: assertEquals(3, result.size()); (파라미터 순서가 헷갈림: 기대값 먼저? 실제값 먼저?)</li>
<li>AssertJ: assertThat(result).hasSize(3); </li>
</ul>
<p>추가로 <code>verify(wordRepository, times(1))</code>를 통해, 서비스 로직이 리포지토리를 정확히 한 번 호출했는지 행위까지 검증하며 테스트의 완성도를 높일 수 있었다.</p>
<h3 id="정리">정리</h3>
<p>이번 서비스 레이어 테스트를 작성하며 느낀 점은 <strong>&quot;테스트는 문서다&quot;</strong>라는 것이다. <code>@DisplayName</code>으로 테스트의 의도를 분명히 밝히고, <code>Given-When-Then</code> 구조로 흐름을 잡고, 코드 자체가 하나의 명세서로서 기능할 수 있게 되었다. </p>
<p>하나의 테스트 메서드에는 하나의 목적만 가져야 되는 규칙도 되게 인상깊었는데, 이는 추후 포스팅에서 다룰 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[쿠폰 선착순 발급 기능의 동시성 테스트 작성기 ]]></title>
            <link>https://velog.io/@dw_db/%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%EB%8F%99%EC%8B%9C%EC%84%B1-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@dw_db/%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%EB%8F%99%EC%8B%9C%EC%84%B1-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Sun, 28 Dec 2025 05:29:58 GMT</pubDate>
            <description><![CDATA[<h4 id="1-jpa-외부-빈-등록-이슈-해결">1. JPA 외부 빈 등록 이슈 해결</h4>
<p><code>SpringBootTest</code>는 Kafka, Redis, Elasticsearch 등 테스트 목적과 무관한 외부 의존성까지 모두 로드하여 속도가 느리고, 환경 설정이 복잡하다는 단점이 있었다. 이를 해결하기 위해 JPA 관련 빈만 로드하는 슬라이스 테스트인 <code>@DataJpaTest</code>를 도입하였다.</p>
<p>하지만 <code>@DataJpaTest</code>는 <code>@Service</code>, <code>@Component</code> 등을 스캔하지 않으므로, 내가 작성한 비즈니스 로직 빈들은 자동으로 등록되지 않는다. 특히 현재 프로젝트의 Persistence Layer는 포트-어댑터패턴의 헥사고날 아키텍처를 따르고 있어, <code>JpaRepository</code>를 직접 상속받지 않는 별도의 Adapter가 존재한다.</p>
<p>따라서 테스트에 필요한 핵심 클래스들을 <code>@Import</code>를 통해 명시적으로 등록해주었다.</p>
<pre><code class="language-java">@DataJpaTest
@Import({
    CouponService.class,
    CouponJpaAdapter.class,
    JpaAuditingConfig.class
})</code></pre>
<h4 id="2-쿠폰-발급-비즈니스-로직">2. 쿠폰 발급 비즈니스 로직</h4>
<pre><code class="language-java">@Transactional
public void issueCoupon(CouponType type, User user) {
    Coupon coupon = couponRepository.findByType(type);
    if (coupon == null) throw new EntityNotFoundException(&quot;쿠폰이 없습니다.&quot;);

    coupon.increaseCount();
    UserCoupon userCoupon = UserCoupon.from(user, coupon);
    userCouponRepository.save(userCoupon);
}</code></pre>
<p>쿠폰이 존재하면 수량을 증가시키고 <code>UserCoupon</code> 테이블에 저장하는 방식이다. 
수량 증가 로직은 객체지향적으로 쿠폰 도메인 엔티티 내부에서 처리하도록 구현하였다.</p>
<pre><code class="language-java">public void increaseCount() {
    if (this.issuedCount &gt;= this.maxCount) {
        throw new IllegalArgumentException(&quot;발급 가능 개수를 초과하였습니다.&quot;);
    }
    this.issuedCount++;
}</code></pre>
<h4 id="3-동시성-테스트-환경-구성">3. 동시성 테스트 환경 구성</h4>
<p>100명의 유저가 동시에 쿠폰 발급을 요청하는 상황을 가정하여 멀티 스레드 테스트를 작성하였다.</p>
<pre><code class="language-java">int taskCount = 100; // 전체 요청 수

ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(taskCount);</code></pre>
<p>32개의 스레드 풀이 100개의 요청 작업을 병렬로 처리하도록 구성하였다. <code>CountDownLatch</code>는 100개의 작업이 모두 완료될 때까지 메인 스레드가 대기하도록 하는 역할을 한다.</p>
<h4 id="4-트랜잭션-격리-문제와-해결">4. 트랜잭션 격리 문제와 해결</h4>
<p><code>@DataJpaTest</code>는 기본적으로 메서드 단위로 트랜잭션이 적용되며, 테스트가 끝나면 자동 롤백된다. 
하지만 멀티 스레드 환경에서는 이 방식이 문제가 되었다.</p>
<p>메인 스레드에서 <code>given</code> 절을 통해 유저 데이터를 <code>save</code> 하더라도, 트랜잭션이 아직 커밋되지 않은 상태이기 때문에 다른 스레드(Worker Thread)에서는 해당 유저 데이터를 조회할 수 없었다. (트랜잭션의 격리성으로 인해 커밋되지 않은 데이터 접근 불가)</p>
<p>이 문제를 해결하기 위해 해당 테스트 메서드에만 트랜잭션 전파 속성을 <code>NOT_SUPPORTED</code>로 설정하여 트랜잭션을 껐다.</p>
<pre><code class="language-java">@DisplayName(&quot;...&quot;)
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED) // 트랜잭션 비활성화 (즉시 커밋)
void issueCoupon_Concurrency() { ... }</code></pre>
<h4 id="5-데이터-초기화-teardown">5. 데이터 초기화 (TearDown)</h4>
<p>트랜잭션을 껐기 때문에 <code>@DataJpaTest</code>의 자동 롤백 기능이 동작하지 않는다. </p>
<p>따라서 테스트가 끝난 후 데이터가 DB에 남게 되므로, 다음 테스트에 영향을 주지 않도록 수동으로 데이터를 삭제하는 <code>tearDown</code> 메서드를 작성하였다.</p>
<pre><code class="language-java">@AfterEach
void tearDown() {
    // FK 제약 조건을 고려하여 자식 테이블부터 삭제
    userCouponRepository.deleteAllInBatch();
    couponRepository.deleteAllInBatch();
    userRepository.deleteAllInBatch();
}</code></pre>
<h4 id="6-테스트-결과-및-회고">6. 테스트 결과 및 회고</h4>
<p>테스트 결과, 100개의 쿠폰 발급을 기대했으나 실제로는 그보다 훨씬 적은 수량만 발급되었다.</p>
<pre><code class="language-java">// then
assertThat(findCoupon.getIssuedCount()).isEqualTo(100);</code></pre>
<img src = https://velog.velcdn.com/images/dw_db/post/406a2aa1-971b-4d08-8a5f-5606e93435a4/image.png width=60%>


<p>이는 동시에 여러 스레드가 수량을 조회하고 업데이트하는 과정에서 갱신 손실이 발생했기 때문이다. 이를 통해 동시성 처리가 되지 않았음을 명확히 확인할 수 있었다</p>
<p>동시성 충돌 이슈를 해결하기 위한 방법으로 생각나는 몇가지는 아래와 같다</p>
<ul>
<li>JPA Lock: 낙관적 락또는 비관적 락 적용</li>
<li>Redis: Redis의 싱글 스레드 특성을 활용한 원자적 연산 또는 분산 락 도입</li>
<li>Database: Named Lock 활용</li>
</ul>
<p>이번 테스트를 작성하며 멀티 스레드 환경에서의 트랜잭션 격리 수준에 대해 다시 한번 리마인드할 수 있었고, <code>@SpringBootTest</code>와 <code>@DataJpaTest</code>, 그리고 <code>@Import</code>의 정확한 사용법과 차이를 명확히 학습할 수 있었다.</p>
<p>과거 프로젝트에서 Kafka를 활용해 게시글 좋아요 API의 응답 속도와 데이터 무결성을 향상시킨 경험이 있다. 이번 쿠폰 도메인의 동시성 이슈는 비동기 처리보다는, 데이터베이스의 락을 활용하여 데이터의 정확성을 보장하는 방식으로 먼저 개선해볼 예정이다.</p>
<p>또한, 이번 과정을 통해 적절한 테스트 범위를 설정하는 것의 중요성을 느꼈는데, 앞으로도 TDD 기반의 견고한 애플리케이션을 구축하는 과정을 꾸준히 기록해나가려 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[기본 생성자가 없는데 JSON 역직렬화가 된다고? ]]></title>
            <link>https://velog.io/@dw_db/%EA%B8%B0%EB%B3%B8-%EC%83%9D%EC%84%B1%EC%9E%90%EA%B0%80-%EC%97%86%EB%8A%94%EB%8D%B0-JSON-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94%EA%B0%80-%EB%90%9C%EB%8B%A4%EA%B3%A0-Jackson-vs-Spring-Boot-vs-Lombok</link>
            <guid>https://velog.io/@dw_db/%EA%B8%B0%EB%B3%B8-%EC%83%9D%EC%84%B1%EC%9E%90%EA%B0%80-%EC%97%86%EB%8A%94%EB%8D%B0-JSON-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94%EA%B0%80-%EB%90%9C%EB%8B%A4%EA%B3%A0-Jackson-vs-Spring-Boot-vs-Lombok</guid>
            <pubDate>Wed, 17 Dec 2025 08:36:23 GMT</pubDate>
            <description><![CDATA[<p>테스트 강의를 들으며 토이 프로젝트를 진행하던 중 흥미로운 현상을 발견했다. </p>
<p>HTTP 요청에서 <code>Java Object &lt;-&gt; JSON</code> 직렬화/역직렬화 과정은 필수적인데, 이전에 Redis 캐싱을 구현하며 겪었던 이슈가 이번에도 생각나 정리를 해보려 한다.</p>
<h3 id="기본-생성자가-없는-dto">기본 생성자가 없는 DTO</h3>
<p>상황은 단순했다. 주문 요청 API를 만들고 있었고 코드는 다음과 같다.</p>
<pre><code class="language-java">@PostMapping(&quot;/api/v1/orders/new&quot;)
public OrderResponse createOrder(@RequestBody OrderCreateRequest request) {
    LocalDateTime registeredDateTime = LocalDateTime.now();
    return orderService.createOrder(request, registeredDateTime);
}</code></pre>
<p><code>@RequestBody</code>로 매핑되는 DTO(<code>OrderCreateRequest</code>)의 내부는 이렇게 생겼다.</p>
<pre><code class="language-java">@Getter
public class OrderCreateRequest {
    private List&lt;String&gt; productNumbers;

    @Builder
    private OrderCreateRequest(List&lt;String&gt; productNumbers) {
        this.productNumbers = productNumbers;
    }
}</code></pre>
<p>강의 코드를 따라 작성할 때는 몰랐는데, 다시 보니 의문이 생겼다.</p>
<blockquote>
<p>&quot;어라? 기본 생성자(<code>@NoArgsConstructor</code>)가 없는데 어떻게 역직렬화가 되는 거지?&quot;</p>
</blockquote>
<p>내가 알기로 Jackson 라이브러리가 JSON 데이터를 자바 객체로 만들 때(역직렬화) 가장 먼저 기본 생성자를 호출하고, 그 뒤에 Getter나 Setter(혹은 리플렉션)를 사용한다고 알고 있었기 때문이다.</p>
<p>하지만 위 코드는 <code>private</code>으로 선언된 인자 있는 생성자만 존재하는데도 요청이 정상적으로 동작했다.</p>
<hr>
<h3 id="spring-boot의-마법--parameters">Spring Boot의 마법 (<code>-parameters</code>)</h3>
<p>결론부터 말하자면, <strong>Spring Boot가 빌드 시점에 개입</strong>했기 때문이다.</p>
<p><strong>Jackson은 역직렬화 시 기본 생성자가 없으면, 인자가 있는 생성자를 찾아 데이터를 넣으려고 시도</strong>한다. 하지만 문제는 컴파일된 <code>.class</code> 파일에는 파라미터의 이름이 남지 않는다는 점이다. 파라미터 이름이 <code>arg0</code> 같이 변환되므로, JSON의 키(&quot;productNumbers&quot;)와 매칭할 수 없어 실패해야 정상이다.</p>
<p>하지만 Spring Boot는 이를 해결하기 위해 두 가지 장치를 자동으로 해준다.</p>
<ol>
<li>컴파일 옵션 추가 (<code>-parameters</code>): Java 8부터 지원되는 기능으로, 컴파일 시 파라미터 이름을 클래스 파일에 남겨준다. <a href = https://docs.spring.io/spring-boot/gradle-plugin/reacting.html#reacting-to-other-plugins.java>Reacting to the Java Plugin</a></li>
<li>Jackson 모듈 등록 (<code>ParameterNamesModule</code>): Jackson이 위에서 남겨진 파라미터 이름을 읽을 수 있도록 <code>ParameterNamesModule</code>을 자동으로 등록한다.</li>
</ol>
<p>실제로 Spring Boot의 <code>JacksonAutoConfiguration</code>을 보면 아래와 같이 모듈을 등록하는 코드가 있다.</p>
<pre><code class="language-java">@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {
    //...
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(ParameterNamesModule.class)
    static class ParameterNamesModuleConfiguration {

        @Bean
        @ConditionalOnMissingBean
        ParameterNamesModule parameterNamesModule() {
            return new ParameterNamesModule(JsonCreator.Mode.DEFAULT);
        }
    }
    //...
}</code></pre>
<p>확실한 검증을 위해 <code>build.gradle</code>에서 컴파일 옵션을 제거해 보았다.</p>
<pre><code>tasks.withType(JavaCompile) {
    options.compilerArgs.remove(&quot;-parameters&quot;)
}</code></pre><p>내 예상대로라면</p>
<ol>
<li><code>-parameters</code> 옵션이 사라짐.</li>
<li>Jackson이 생성자의 파라미터 이름을 못 읽음 (<code>arg0</code>으로 인식).</li>
<li><strong>역직렬화 실패 에러 발생.</strong></li>
</ol>
<p>이어야 했다. </p>
<img src=https://velog.velcdn.com/images/dw_db/post/4c005505-2a4f-4169-b2eb-4e2b84818a56/image.png width=100%>

<p>예상한대로 <code>InvalidDefinitionException</code>이 발생했다.</p>
<hr>
<h3 id="record를-활용한-완벽한-불변-객체">Record를 활용한 완벽한 불변 객체</h3>
<p>사실 Jackson에서 <strong>단일 인자 생성자</strong>는 &#39;값 위임(Delegating)&#39;인지 &#39;프로퍼티 매칭(Properties)&#39;인지 판단하기 어려운 모호한 대상이라고 한다.</p>
<p>내 코드가 어노테이션(<code>@JsonProperty</code>) 없이 동작한 이유는 <code>ParameterNamesModule</code>이 파라미터 이름(<code>productNumbers</code>)을 알려주었고, Jackson은 파라미터 이름과 JSON을 매칭하여, &#39;이건 프로퍼티 매칭 방식이구나&#39;라고 추론해낸 것이다. </p>
<p>하지만 컴파일 옵션이나 모듈의 추론에 의존하기보다, 모던 자바의 트렌드에 맞춰 <code>record</code>를 사용하면 이 문제를 더깔끔하게 해결할 수 있을 것이다.</p>
<pre><code class="language-java">public record OrderCreateRequest(
    List&lt;String&gt; productNumbers
) {
}

// 컴파일러가 실제로 만든 코드 
public final class OrderCreateRequest {
    private final List&lt;String&gt; productNumbers; // final 필드

    // Jackson은 이 생성자를 호출
    public OrderCreateRequest(List&lt;String&gt; productNumbers) {
        this.productNumbers = productNumbers;
    }
    // ... getter, equals, hashCode 등
}</code></pre>
<p><code>record</code>를 사용하면 다음과 같은 장점이 있다.</p>
<ol>
<li><strong>완벽한 불변성</strong>: 모든 필드가 <code>private final</code>로 선언되며, 생성 시점에 값이 꽉 채워진 상태로 생성된다.</li>
<li><strong>명시적인 파라미터 정보</strong>: <code>record</code>는 컴파일 시 파라미터 이름 정보가 클래스 파일에 표준 스펙으로 저장된다. 따라서 <code>-parameters</code> 옵션이나 별도의 설정 없이 Jackson이 파라미터 이름을 인식할 수 있다.</li>
<li><strong>간결함</strong>: lombok(<code>@Getter</code>, <code>@Builder</code>, 생성자 등) 없이도 코드가 매우 간결해진다.</li>
</ol>
<hr>
<h3 id="정리-및-결론">정리 및 결론</h3>
<p>이번 트러블슈팅을 통해 Jackson의 역직렬화 전략 우선순위를 알게 되었다.</p>
<ol>
<li><strong>기본 생성자</strong>가 있으면 가장 우선 사용.</li>
<li>없으면 <strong>인자 있는 생성자</strong> 사용 시도.</li>
</ol>
<ul>
<li>이때 파라미터 이름을 알기 위해 <strong><code>-parameters</code></strong> 옵션 혹은 <strong><code>@ConstructorProperties</code></strong>를 활용.</li>
</ul>
<p>마음 편하게 <strong>기본 생성자 + Setter(Getter)</strong> 조합을 쓸 수도 있다. 하지만 <strong>&quot;생성자 주입 방식&quot;</strong>을 사용하면 객체가 생성되는 시점에 값이 꽉 채워지므로 <strong>불변 객체</strong>를 안전하게 만들 수 있다는 큰 장점이 있다. </p>
<p>특히 Java16 이상을 사용한다면 DTO를 <code>record</code>로 전환하는것도 좋은 것 같다.</p>
<p>+) 추가로 알게된 점 (Spring DI) (25.12.16)
Spring이 의존성 주입(DI)을 할 때 빈의 이름을 찾는 과정도 이와 유사하다고 한다. 과거에는 중복된 이름의 빈이 있을경우, <code>@Qualifier</code>나 <code>@Primary</code>가 필수였지만, 이제는 파라미터 이름 전략(<code>ParameterNameDiscoverer</code>)을 통해 빈을 똑똑하게 주입받을 수 있다. 이 부분은 추후에 더 깊게 공부해 봐야겠다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[회원가입 이메일 발송: 트랜잭션 분리와 아웃박스 패턴 트러블슈팅]]></title>
            <link>https://velog.io/@dw_db/%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EC%84%B1%EA%B3%B5%EC%8B%9C-%EB%A9%94%EC%9D%BC-%EB%AC%B4%ED%95%9C-%EB%B0%9C%EC%86%A1</link>
            <guid>https://velog.io/@dw_db/%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EC%84%B1%EA%B3%B5%EC%8B%9C-%EB%A9%94%EC%9D%BC-%EB%AC%B4%ED%95%9C-%EB%B0%9C%EC%86%A1</guid>
            <pubDate>Sat, 13 Dec 2025 13:45:05 GMT</pubDate>
            <description><![CDATA[<p>회원가입 성공 시 가입 축하 메일을 발송하는 로직을 구현했다. 초기에는 단순한 동기 호출로 시작했지만, 시스템의 결합도를 낮추고 데이터 정합성을 보장하기 위해 점진적으로 구조를 고도화했다.</p>
<ol>
<li><strong>초기</strong>: 이벤트 발행 없는 강결합 로직</li>
<li><strong>개선</strong>: &#39;회원가입&#39;과 &#39;메일발송&#39;의 결합을 끊기 위해 Kafka 도입</li>
</ol>
<p>현재 프로젝트가 거창한 MSA 환경은 아니다. 하지만 아래와 같은 케이스를 방지하고 데이터 정합성을 보장하기 위해 트랜잭션 아웃박스패턴을 흉내(?) 내며 겪었던 이슈와 해결 과정을 정리해 본다.</p>
<p><strong>[목표 요구사항]</strong></p>
<ul>
<li>회원가입이 DB에 커밋되면 메일 발송도 (결국엔) 성공해야 한다.</li>
<li>회원가입은 성공했으나 메일 발송이 실패했다고 해서 회원가입이 롤백되면 안 된다.</li>
<li>회원가입 트랜잭션이 실패하면 메일도 발송되지 않아야 한다. (중복 검사 실패 등)</li>
</ul>
<hr>
<h4 id="초기-구현-이벤트-발행-x">초기 구현: 이벤트 발행 X</h4>
<p>가장 직관적이고 단순한 형태다.</p>
<pre><code class="language-java">@Transactional
public void create(UserCreateRequest request) {
    String encodedPassword = passwordEncoder.encode(request.password());
    User user = User.create(request.email(), encodedPassword, request.username());
    userRepository.save(user);

    // 외부 API 호출
    mailService.sendEmail(request.email());
}</code></pre>
<p>이 코드는 <strong>&#39;회원가입 트랜잭션이 커밋되기 전&#39;</strong>에 외부 API(메일 전송)를 호출한다는 치명적인 단점이 있다. 만약 메일 전송은 성공했는데, 그 직후 DB 커밋 단계에서 에러가 발생해 롤백된다면? &#39;회원은 없는데 가입 축하 메일은 날아간&#39; 유령 상태가 발생한다.</p>
<hr>
<h4 id="kafka를-활용한-아웃박스-패턴-리팩토링">Kafka를 활용한 아웃박스 패턴 리팩토링</h4>
<p>이를 해결하기 위해 메일 전송 요청을 DB에 먼저 저장하고, 별도의 스케줄러가 이를 처리하는 방식을 도입했다. 메일 이벤트 상태는 <code>Enum</code>으로 관리한다.</p>
<ul>
<li><code>PENDING</code>: 이벤트 생성 직후</li>
<li><code>SENT</code>: 이메일 발송 성공</li>
</ul>
<p>회원가입과 메일 발송 이벤트 저장을 <strong>하나의 DB 트랜잭션</strong>으로 묶는다. 
이렇게 하면 회원가입이 실패(롤백)할 경우 메일 이벤트도 함께 사라지므로 정합성이 보장된다.</p>
<pre><code class="language-java">@Transactional
public void create(UserCreateRequest request) {
    // ... User 저장 로직 ...
    userRepository.save(user);

    // 같은 트랜잭션 내에서 이벤트 저장
    MailEventOutbox mailEvent = MailEventOutbox.from(request.email());
    mailEventOutboxRepository.save(mailEvent);
}</code></pre>
<p><code>PENDING</code> 상태인 이벤트를 조회하여 전송 후 <code>SENT</code>로 변경한다. 트랜잭션 범위가 분리되어 롤백 문제는 해결되었지만, 이제 시스템 간 결합도를 더 낮추기 위해 Kafka를 도입하기로 했다.</p>
<p><del>지금보니, 구체적인 예외인 <code>MailException</code>을 처리하는것이 더 이상적일것 같습니다.</del></p>
<pre><code class="language-java">@Scheduled(fixedDelay = 3000)
public void processPendingMail() {
    List&lt;MailEventOutbox&gt; pendingMails = repository.findByStatusOrderByIdAsc(MailStatus.PENDING, PageRequest.of(0, 10));

    for (MailEventOutbox pendingMail : pendingMails) {
        try {
            mailService.sendEmail(pendingMail.getToEmail());
            pendingMail.markAsSent();
        } catch (Exception ex) {
            log.error(&quot;MAIL FAILED: {}&quot;, pendingMail.getToEmail());
        }
    }
}</code></pre>
<h4 id="publisher-구현과-상태-관리">Publisher 구현과 상태 관리</h4>
<p>메일 이벤트의 상태를 세분화했다.</p>
<ul>
<li><code>PENDING</code>: 생성됨 (DB 저장 완료)</li>
<li><code>PUBLISHED</code>: Kafka Broker로 발행 완료 (Consumer 소비 전)</li>
<li><code>SENT</code>: Consumer가 메일 발송 완료</li>
<li><code>DENY</code>: 메일 서버에 문제가 발생할 경우 상태를 변경하여 무한재시도를 방지하기위한 상태값</li>
</ul>
<p>Kafka 발행 로직은 다음과 같이 구현했다. 핵심은 <strong>&#39;DB 트랜잭션을 길게 가져가지 않는 것&#39;</strong>이다.</p>
<pre><code class="language-java">// 스케줄러에 의해 주기적으로 실행
for (MailEventOutbox pendingMail : pendingMails) {
    // 비동기 전송
    sendAsync(TOPIC, pendingMail, (result, exception) -&gt; {
        if (exception != null) {
            log.error(...);
            return;
        }
        // 콜백: 전송 성공 시 상태 업데이트만 별도 트랜잭션으로 수행
        tx.executeWithoutResult(status -&gt; {
            pendingMail.markAsPublished(); 
        });
    });
}</code></pre>
<p>스케줄러 전체를 <code>@Transactional</code>로 묶으면 Kafka 네트워크 I/O 시간만큼 DB 커넥션을 점유하게 된다. 이는 장애의 원인이 될 수 있으므로, <strong>콜백 내부에서 상태 업데이트</strong>만 <code>TransactionTemplate(REQUIRES_NEW)</code>로 짧게 수행하도록 설계했다.</p>
<p>Kafka Broker로 발행에 성공한 이벤트는 <code>Published</code> 상태로 업데이트하였다. 즉시 <code>SENT</code>로 변경하지 않은 이유는, <code>Consumer</code>에 장애가 발생하거나 트래픽이 몰려 지연이 발생할 경우, 이벤트는 성공적으로 발행했지만 계속해서 스케줄러가 DB에서 다시 조회해서 재발행하는 것을 방지하기 위함이다.</p>
<hr>
<h4 id="️-문제-발생-무한으로-즐기는-메일-발송">‼️ 문제 발생: 무한으로 즐기는 메일 발송</h4>
<p>설계는 완벽해 보였으나, 테스트 과정에서 심각한 문제가 발생했다.</p>
<blockquote>
<p>Kafka로 메시지는 잘 넘어가는데, DB의 상태가 <code>PUBLISHED</code>로 변하지 않는다. 
스케줄러가 3초마다 계속 <code>PENDING</code> 상태인 이벤트를 조회해서 메일을 무한 중복 발송함.</p>
</blockquote>
<p><del>방해금지모드 켜놔서 첫 메일만 확인 후 이후에 알게되었다는...
만약 실제 서비스일경우 사용자가 많이 화가날 수 있는 🤬 ...
이런 문제 발생에 대비하기 위해 로그를 남기고 알림시스템을 구축해놓는건가?</del></p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/b55db665-84cb-410c-8339-f3333b21ac3c/image.png" alt=""></p>
<h4 id="원인-스레드-불일치와-영속성-컨텍스트">원인: 스레드 불일치와 영속성 컨텍스트</h4>
<p>문제의 원인은 스레드 모델과 JPA 영속성 컨텍스트의 동작 방식에 있었다.</p>
<p>회원가입부터 메일발송의 과정은 다음과같다.</p>
<ol>
<li><code>pendingMails</code>를 조회한다. 이때 엔티티들은 영속성 컨텍스트의 관리를 받는다.</li>
<li><code>sendAsync</code>의 콜백은 별도의 Kafka Producer 스레드에서 실행된다.</li>
<li>Spring의 <code>EntityManager</code>는 스레드 로컬에 바인딩된다. 즉, <strong>스케줄러 스레드의 영속성 컨텍스트와 콜백 스레드의 컨텍스트는 서로 다르다.</strong></li>
<li>콜백 스레드 입장에서 <code>pendingMail</code> 객체는 다른 스레드에서 넘어온, 영속성 컨텍스트가 관리하지 않는 준영속객체일 뿐이다.</li>
<li>준영속 엔티티의 값을 아무리 변경해도, 변경 감지가 동작하지 않아 DB에 <code>UPDATE</code> 쿼리가 나가지 않았던 것이다.</li>
</ol>
<h4 id="해결-엔티티-재조회">해결: 엔티티 재조회</h4>
<p>콜백 스레드에서 새로운 트랜잭션을 열고, 엔티티를 다시 조회하여 영속 상태로 만든 뒤 수정했다.</p>
<pre><code class="language-java">sendAsync(TOPIC, pendingMail, (result, exception) -&gt; {
    if (exception != null) { ... }

    tx.executeWithoutResult(status -&gt; {
        // ID로 다시 조회하여 영속 상태(Managed)로 만듦
        MailEventOutbox managed = mailRepository.findById(outboxId)
            .orElseThrow(() -&gt; new EntityNotFoundException(&quot;...&quot;));

        managed.markAsPublished(); // 변경 감지 동작 O
    });
});</code></pre>
<hr>
<h4 id="마무리-및-개선-포인트">마무리 및 개선 포인트</h4>
<p>트러블슈팅 과정에서 <strong>&quot;쿼리를 한 번 더 날리는 비효율&quot;</strong>과 <strong>&quot;DB 커넥션 점유 시간&quot;</strong> 사이에서 고민이 있었다.</p>
<ul>
<li><strong>스케줄러 전체 트랜잭션</strong>: 조회 쿼리 1번으로 끝낼 수 있지만, Kafka I/O 시간 동안 DB 커넥션을 물고 있어야 한다. 병목 지점이 될 수 있다.</li>
<li><strong>짧은 트랜잭션 + 재조회</strong>: 쿼리가 한 번(SELECT) 더 나가지만, 트랜잭션 유지 시간이 극도로 짧아진다.</li>
</ul>
<p>DB 커넥션은 서비스에서 비싼 자원 중 하나다. 따라서 쿼리가 한 번 더 발생하더라도, 트랜잭션의 범위를 최소화하여 DB 자원을 효율적으로 쓰는 방식을 선택했다.</p>
<p><strong>[추후 개선 예정]</strong></p>
<ol>
<li>Direct Update: 현재는 <code>findById</code>(SELECT) -&gt; <code>Dirty Checking</code>(UPDATE) 과정이다. 이를 JPQL 등을 사용해 바로 <code>UPDATE</code> 쿼리를 날린다면 SELECT 비용까지 절감할 수 있다.</li>
<li>Kafka Key: 현재 키 값을 지정하지 않아 라운드 로빈으로 파티션에 들어간다. 순서 보장이나 중복 제거를 위해 Key 전략을 고민해 볼 예정이다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Kafka Producer·Consumer 지연 문제 분석 및 해결(ing) (RPS 1000 부하 테스트)]]></title>
            <link>https://velog.io/@dw_db/Kafka-ProducerConsumer-%EC%A7%80%EC%97%B0-%EB%AC%B8%EC%A0%9C-%EB%B6%84%EC%84%9D-%EB%B0%8F-%ED%95%B4%EA%B2%B0-RPS-1000-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@dw_db/Kafka-ProducerConsumer-%EC%A7%80%EC%97%B0-%EB%AC%B8%EC%A0%9C-%EB%B6%84%EC%84%9D-%EB%B0%8F-%ED%95%B4%EA%B2%B0-RPS-1000-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Sun, 09 Nov 2025 08:17:56 GMT</pubDate>
            <description><![CDATA[<p>rps500의 부하는 견뎠지만 rps1000에서 장애가 발생하였고, 성능 개선을 위해 실험한 결과를 정리한 포스팅입니다.</p>
<h3 id="rps-500">RPS 500</h3>
<h4 id="grafana-dashboard">Grafana dashboard</h4>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/d05d42ec-7bc3-45ab-b13c-d6dbb26392b0/image.png width=90%>
</blockquote>
<h4 id="k6">K6</h4>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/5480800c-95d3-4057-a888-f41e25a687ff/image.png width=90%>
</blockquote>
<h3 id="rps-1000">RPS 1000</h3>
<h4 id="grafana-dashboard-1">Grafana dashboard</h4>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/9401b209-22b6-49a4-814a-1c79572c544e/image.png width=90%>
</blockquote>
<h4 id="k6-1">K6</h4>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/a24e3ece-1232-4903-bcd8-d110f0bd225a/image.png width=90%>
</blockquote>
<h3 id="성능-테스트-결과-정리-표">성능 테스트 결과 정리 표</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>500 RPS</th>
<th>1000 RPS</th>
<th>변화</th>
</tr>
</thead>
<tbody><tr>
<td><strong>p95 응답 시간</strong></td>
<td><strong>7.95 ms</strong></td>
<td><strong>61.2 ms</strong></td>
<td>🔺 약 7.7배 증가</td>
</tr>
<tr>
<td><strong>평균 응답 시간</strong></td>
<td><strong>4.77 ms</strong></td>
<td><strong>36.0 ms</strong></td>
<td>🔺 약 7.5배 증가</td>
</tr>
<tr>
<td><strong>Kafka Producer 지연</strong></td>
<td>200 ~ 300 ms 내외</td>
<td><strong>2000 ~ 9000 ms 폭증</strong></td>
<td>🚨 <strong>명확한 병목 지점</strong></td>
</tr>
<tr>
<td><strong>Kafka Consumer 처리속도</strong></td>
<td>안정 (1.5 ~ 2 ms 수준)</td>
<td><strong>10 ms 이상으로 증가</strong></td>
<td>Producer→Consumer 전달지연 누적</td>
</tr>
<tr>
<td><strong>DB 커넥션 사용률</strong></td>
<td>20 ~ 30 %</td>
<td><strong>40 ~ 50 %</strong></td>
<td>정상 범위지만 확실히 부하 상승</td>
</tr>
<tr>
<td><strong>Thread Pool 사용률</strong></td>
<td>15 %</td>
<td>15 % 유지</td>
<td>Async 풀은 여유 있음</td>
</tr>
</tbody></table>
<p>해당 테스트 결과에서 알 수 있듯, DBCP 커넥션 사용률이 증가하였고, 응답 시간과 Kafka Producer 지연이 확인되었습니다. </p>
<p>그리고 궁금한 점으로 Kafka Consumer 처리속도가 평균 1.5ms -&gt; 10ms 이상으로 증가하였는데 처리 속도가 빠르면 좋은거 아닌가? 왜 병목이 생기지 생각하였습니다. </p>
<p>Kafka Consumer 처리속도 promql은 아래와 같습니다.</p>
<pre><code class="language-json">(
  rate(spring_kafka_listener_seconds_sum{name=&quot;org.springframework.kafka.KafkaListenerEndpointContainer#0-0&quot;}[1m])
  /
  rate(spring_kafka_listener_seconds_count{name=&quot;org.springframework.kafka.KafkaListenerEndpointContainer#0-0&quot;}[1m])
) * 1000</code></pre>
<p>생각해보면... 처리속도가 늘어났다는건 처리하는데 Consumer가 소비하는데 시간이 더 오래걸린다는 뜻이므로 성능이 저하된것이 맞았습니다.</p>
<blockquote>
<p>Producer는 메시지를 계속 보내는데, Consumer가 늦게 처리하니까 Broker 큐에 메시지가 쌓이기 시작한다.</p>
</blockquote>
<p>라고 받아들였습니다.</p>
<p>하지만 Spring 서버에서 아래와 같은 예외를 확인하였습니다.</p>
<h3 id="문제의-시작-producer-timeoutexception">문제의 시작: Producer TimeoutException</h3>
<p>부하 테스트 중 <code>org.apache.kafka.common.errors.TimeoutException: Expiring record(s)</code> 예외가 반복적으로 발생하였습니다.
특이한 점은, Consumer lag은 0인데도 Producer에서만 timeout이 발생했다는 것이였습니다.</p>
<p>즉, Kafka Consumer는 정상적으로 메시지를 소비 중인데, Producer가 Broker로 메시지를 전송하다가 타임아웃으로 실패하는 상황이라고 생각합니다.</p>
<p>혹시나해서 Consumer 튜닝을 먼저 진행해봤습니다.</p>
<h4 id="1-병렬-처리-시도--consumer-파티션-확장">1. 병렬 처리 시도 — Consumer 파티션 확장</h4>
<p>처음에는 Consumer의 처리 성능(p95 latency) 을 높이기 위해 병렬 처리를 하기 위해
기존 1개였던 파티션을 3개로 확장하고 3개의 Consumer를 병렬로 실행하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/a4f92d36-8701-4ea7-b924-7412f73aff8c/image.png" alt="">
그런데 예상과 달리 여전히 하나의 Consumer만 메시지를 처리하는것을 확인하였습니다.
이는 Producer에서 articleId를 Kafka send key로 사용하고 있었기 때문이였습니다.</p>
<p>Kafka는 같은 key를 가진 메시지는 항상 같은 partition으로 보내게되고,
즉, articleId가 같아서 3개의 파티션 중 하나의 파티션에만 메시지가 몰린것입니다.</p>
<p>그 결과, Consumer 한 개만 일하게 되고 나머지는 대기 상태에 빠졌습니다.</p>
<h4 id="2-key-제거--병렬-처리는-됐지만-무결성은-깨졌다">2. Key 제거 — 병렬 처리는 됐지만 무결성은 깨졌다</h4>
<p>이번엔 key를 제거한 채로 테스트를 진행하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/e4255222-14c4-416a-8d06-d26d6f440b12/image.png" alt=""></p>
<p>이 경우, 메시지는 라운드 로빈 방식으로 각 파티션에 고르게 분배되었고
덕분에 3개의 Consumer가 병렬로 메시지를 처리하기 시작했습니다.</p>
<p>하지만 곧 새로운 문제가 발생했는데,
동일한 articleId에 대한 like/unlike 요청이 서로 다른 Consumer에서 동시에 처리되며 DB의 unique 제약 조건을 위반하여 데이터 무결성 예외가 발생하였습니다.</p>
<p>즉, 병렬 처리는 성공했지만, 논리적 일관성이 깨졌고, 같은 key에 대한 순서 보장과 원자성이 사라지게 되었습니다.</p>
<p>결국 이 방법은 포기하고, 다시 key 기반 구조로 되돌렸습니다.</p>
<h4 id="3-broker-설정-변경--io-확장-시도">3. Broker 설정 변경 — I/O 확장 시도</h4>
<p>현재 지식의 한계로 Consumer의 설정을 여기까지 하고, 브로커 레벨의 성능 병목을 의심하였습니다.
근본적으로 Producer -&gt; Broker 전송시 TimeOut이 발생하였기 때문입니다.</p>
<p>Kafka의 내부 네트워크 및 I/O 스레드 수와 내부 버퍼를 다음과같이 확장하였습니다.</p>
<ul>
<li>KAFKA_CFG_NUM_NETWORK_THREADS=8</li>
<li>KAFKA_CFG_NUM_IO_THREADS=16</li>
<li>KAFKA_CFG_SOCKET_SEND_BUFFER_BYTES=1048576</li>
<li>KAFKA_CFG_SOCKET_RECEIVE_BUFFER_BYTES=1048576</li>
<li>KAFKA_CFG_SOCKET_REQUEST_MAX_BYTES=104857600</li>
</ul>
<p>결과적으로 전송 처리량은 약간 개선되었지만, TimeoutException은 여전히 잔존하였습니다...</p>
<h4 id="4-producer-설정-튜닝--recordaccumulator-최적화">4. Producer 설정 튜닝 — RecordAccumulator 최적화</h4>
<p>마지막으로, Producer 설정을 집중적으로 변경하였습니다.</p>
<ul>
<li>acks=all</li>
<li>enable.idempotence=true</li>
<li>linger.ms=5</li>
<li>batch.size=128KB</li>
<li>buffer.memory=512MB</li>
<li>max.in.flight.requests.per.connection=1</li>
</ul>
<p>현재 브로커는 한 대로 구성하였기 때문에 <code>ack</code> 값을 <code>1</code>, <code>all</code> 같은 의미일 것입니다.
<code>linger.ms</code>로 배치 주기를 조정하고, <code>batch.size</code>와 <code>buffer.memory</code>로 버퍼 효율을 개선했지만, 여전히 일부 batch가 expire되어 TimeoutException이 발생하였습니다.</p>
<h4 id="원인-분석-recordaccumulator와-단일-스트림의-한계">원인 분석: RecordAccumulator와 단일 스트림의 한계</h4>
<p>결과적으로 다음 사실이 명확해졌습니다.</p>
<ul>
<li>Consumer lag = 0 → 브로커와 Consumer는 정상.</li>
<li>Timeout은 Producer 내부에서만 발생.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dw_db/post/f3b4933f-0646-47fb-a28d-9874ce590fa9/image.png" alt=""></p>
<p>즉, 문제는 RecordAccumulator → Sender 전송 구간의 지연이였습니다.</p>
<p>특히 <code>max.in.flight.requests.per.connection=1</code> 설정이 결정적인 역할을 한다고 생각합니다.
이 설정은 순서 보장(멱등성)을 위해 한 번에 하나의 요청만 Broker로 보내게 됩니다.
ACK를 받기 전에는 다음 배치를 보내지 않으므로, 
hot key(articleId 하나에 RPS 1000)가 몰릴 경우, 병목이 심화된 것입니다.</p>
<p>즉, 단일 파티션 구조에서 ack가 늦어지는 순간,
뒤에 쌓인 batch들은 <code>delivery.timeout.ms</code>를 초과하며 expire 되었습니다.</p>
<h3 id="실험-결과-요약">실험 결과 요약</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>조치</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>Consumer 병렬화</td>
<td>파티션 3개 / key 유지</td>
<td>병렬화 실패 (1 consumer만 작동)</td>
</tr>
<tr>
<td>Key 제거</td>
<td>병렬화 성공, 무결성 깨짐</td>
<td>데이터 무결성 에러</td>
</tr>
<tr>
<td>Broker 튜닝</td>
<td>네트워크 및 I/O 스레드 확장</td>
<td>전송속도 소폭 개선, timeout 지속</td>
</tr>
<tr>
<td>Producer 튜닝</td>
<td>linger/batch/buffer 확장</td>
<td>p95 개선, 일부 timeout 잔존</td>
</tr>
</tbody></table>
<h3 id="배운-점">배운 점</h3>
<ol>
<li>Kafka의 병목은 Cousumer의 <code>lag=0</code>이어도 timeout이 Producer에서 발생할 수 있다.</li>
<li>key 기반 파티셔닝은 순서를 보장하지만 병렬성을 제한한다.
특히 단일 key가 존재할 경우 병목이 불가피한 것 같다 
(? 불확실
단일키를 여러 파티션으로 나누는 방법이 있는지?
key를 지정하지 않고 파티션을 나눴을때, 병렬처리로 인해 서로다른 스레드의 DB 접근시 데이터 무결성 예외가 발생했지만 개선할 여지가 있는지? </li>
<li>실제 트래픽 패턴이 중요하다.
articleId가 다양하게 분산된 실제 환경에서는 병목이 거의 발생하지 않는다. (이 상황에서도 하나의 article에 1000rps는 부하가 있을 법 하다)</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[비동기 이벤트 기반 좋아요 시스템 안정화 과정]]></title>
            <link>https://velog.io/@dw_db/%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%B0%98-%EC%A2%8B%EC%95%84%EC%9A%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%88%EC%A0%95%ED%99%94-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@dw_db/%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%B0%98-%EC%A2%8B%EC%95%84%EC%9A%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%95%88%EC%A0%95%ED%99%94-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Sat, 01 Nov 2025 14:56:55 GMT</pubDate>
            <description><![CDATA[<p>kafka를 도입한 후 부하 테스트를 진행하며 발생했던 현상들을 정리하였습니다.</p>
<h3 id="0️⃣-테스트셋">0️⃣ 테스트셋</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Hardware / OS</strong></td>
<td>MacBook Pro 14-inch (2021) · Apple M1 Pro · 16GB RAM · macOS Tahoe 26.0.1</td>
</tr>
<tr>
<td><strong>Environment</strong></td>
<td>Localhost (Docker Compose: MySQL 8.0 · Redis latest · Kafka latest)</td>
</tr>
<tr>
<td><strong>Spring Boot</strong></td>
<td>3.3.4 (Gradle, OpenJDK 21)</td>
</tr>
<tr>
<td><strong>Tool</strong></td>
<td>k6</td>
</tr>
<tr>
<td><strong>Spike Test</strong></td>
<td>5분간 초당 500명 요청 (VU 500명)</td>
</tr>
<tr>
<td><strong>Recovery Test</strong></td>
<td>1분간 초당 20명 요청 (VU 30 → 50명)</td>
</tr>
<tr>
<td><strong>Thresholds</strong></td>
<td><code>p(95)&lt;300ms</code>, <code>p(99)&lt;500ms</code>, 실패율 <code>&lt;5%</code></td>
</tr>
<tr>
<td><strong>Pre-step</strong></td>
<td>200명 회원가입 → 로그인 → 게시글 1건 생성 후 테스트 수행</td>
</tr>
</tbody></table>
<hr>
<h3 id="1️⃣-dbcp-확장---http--async-스레드-동시-접근-문제">1️⃣ DBCP 확장 - HTTP + Async 스레드 동시 접근 문제</h3>
<h4 id="문제-인식">문제 인식</h4>
<p>현재 좋아요 API는 단순한 기능이지만 내부적으로 다음 과정을 거칩니다.</p>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/e8df715c-d364-4053-9a98-70cff795e8f0/image.png width=80%>
</blockquote>
<p>즉, 하나의 요청에서 <strong>HTTP 요청 스레드</strong>와 <strong>비동기 스레드</strong>가 모두 DB에 접근합니다.</p>
<p>현재 HikariCP와 Executor 설정값은 다음과 같습니다.</p>
<pre><code>// HikariCP 기본값
spring.datasource.hikari.minimumIdle=5
spring.datasource.hikari.connectionTimeout=10000
spring.datasource.hikari.maximumPoolSize=10

//비동기 스레드 풀 설정
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(10);</code></pre><p><em>HikariCP와 Executor의 동작 방식에는 약간의 차이가 있는데 다음 포스팅에 정리하겠습니다.</em></p>
<blockquote>
<p>10(Async) + n(HTTP) + m(KafkaConsumer)
→ 동시 DB 점유 → <code>connections.pending</code> 급상승</p>
</blockquote>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/005fcb6d-83dd-43ff-bc7d-23ff4a9c723a/image.png width=100%>
</blockquote>
<p>HikariCP 커넥션이 피크를 칠때, 커넥션 고갈로 인해 대기중인 스레드가 발생하는것을 확인하였습니다. 
결과적으로 <strong>HTTP 응답 p99</strong>이 1초 이상 지연되는 현상이 확인되었습니다.</p>
<h4 id="행동">행동</h4>
<p>✅ <strong>DBCP 사이즈 변경</strong>
최대 풀 사이즈를 10-&gt;30으로 늘리고, 기본 유휴 스레드 값을 5-&gt;10으로 변경하였습니다.</p>
<pre><code class="language-yml">spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      pool-name: HikariPool-synapse</code></pre>
<p>✅ <strong><code>existsById()</code> 쿼리 최적화 검토 및 개선 과정</strong></p>
<ol>
<li><p>엔티티 조회 → 존재 여부 확인 로직으로 변경
기존 <code>findById()</code> 사용 시 JPA 1차 캐시에 엔티티가 적재되며, 단순 존재 확인만 필요한 경우에도 불필요한 객체 생성이 발생했습니다.
이에 따라 반환 타입을 <code>Optional&lt;Article&gt; → boolean</code>으로 단순화하기 위해
<code>existsById()</code>를 사용하도록 수정하였습니다.</p>
</li>
<li><p>JPA existsById() 내부 쿼리 분석
Hibernate에서 <code>existsById()</code> 실행 시, 실제로는 <code>SELECT COUNT(*) FROM ... WHERE id = ?</code> 형태의 쿼리가 실행됩니다.
MySQL InnoDB 엔진은 <code>EXISTS</code> 대신 <code>COUNT(*)</code> 집계 쿼리를 사용하므로 모든 행을 스캔할 가능성이 있다는 점을 확인했습니다.</p>
</li>
<li><p>Native Query 기반의 최적화 시도
<code>EXISTS</code> 서브쿼리를 사용해 즉시 종료되는 형태로 개선하기 위해 <code>existsByIdFast()</code> 메서드를 추가하였습니다.</p>
<p><code>@Query(value = &quot;select exists(select 1 from article_like where article_id = :articleId and user_id = :userId)&quot;, nativeQuery = true)</code></p>
<p>그러나 MySQL은 <code>EXISTS</code>의 결과를 <code>TINYINT(1)</code> 형태(0 또는 1)로 반환하기 때문에
JPA에서 <code>boolean</code>으로 직접 매핑하는 과정에서
<code>ClassCastException(Long cannot be cast to Boolean)</code> 예외가 발생했습니다.</p>
</li>
<li><p>대안 검토 및 최종 결정</p>
</li>
</ol>
<ul>
<li><code>CASE WHEN EXISTS(...) THEN TRUE ELSE FALSE</code> 형태로 변환하거나 0/1을 직접 사용하는 방식으로 해결할 수 있었으나, 해당 ID 컬럼이 PK(유니크 인덱스) 이므로 InnoDB가 인덱스를 이용해 단일 행만 탐색하고 즉시 종료함을 확인했습니다. </li>
<li>따라서 성능상 실질적인 이점이 미미하다고 판단하여, 기본 JPA 메서드인 <code>existsById()</code>를 유지하기로 결정하였습니다.</li>
</ul>
<h4 id="결과">결과</h4>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/8f2358d7-c384-44c1-a63b-729c013ad612/image.png width=90%>
</blockquote>
<ul>
<li>활성 커넥션 수는 30 중 약 20 수준에서 안정화</li>
<li>p95 응답 시간은 <strong>1.1s → 480ms</strong>로 감소</li>
<li>HikariCP 대기 스레드 0</li>
</ul>
<p>단, 풀을 키우는 것은 임시 방편일 뿐
<strong>비즈니스 로직 단의 커넥션 점유 최적화</strong>가 필요하다고 생각하였습니다.</p>
<hr>
<h3 id="2️⃣-dltdead-letter-topic-도입---kafka-retry-loop-문제">2️⃣ DLT(Dead Letter Topic) 도입 - Kafka Retry Loop 문제</h3>
<h4 id="문제-인식-1">문제 인식</h4>
<p>Kafka 비동기 전송 중 <code>DataIntegrityViolationException</code>이 반복 발생했습니다.
이는 <strong>Consumer가 이미 존재하는 Like 데이터를 다시 INSERT</strong>하면서 <code>Unique Key</code> 제약 조건에 위배된 상황이었습니다. </p>
<p>Kafka는 해당 예외를 재시도 가능한 오류로 간주해 같은 메시지를 무한히 재처리했고, 이로 인해 다음과 같은 문제가 발생했습니다.</p>
<ol>
<li>Retry Loop로 인한 <strong>Lag 폭증</strong></li>
<li><strong>DBCP 커넥션 피크</strong> 증가 (<code>HikariCP maximumPoolSize=30</code>, <code>Async Executor maxPoolSize=10</code>)</li>
</ol>
<p>이 현상은 k6 부하 테스트에서는 감지되지 않았지만,
Spring 서버 로그와 Grafana 메트릭을 통해 Kafka Lag이 지속적으로 누적됨을 확인했습니다.</p>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/4ff19f52-a684-4ed1-ba91-b5bda3f539e8/image.png width=100%>
</blockquote>
<h4 id="행동-1">행동</h4>
<p>❌ <strong>Rollback 무효화 (실패)</strong>
처음에는 <code>DataIntegrityViolationException</code> 발생 시 트랜잭션 rollback을 막기 위해 아래처럼 설정했습니다.</p>
<pre><code class="language-java">@Transactional(noRollbackFor = DataIntegrityViolationException.class) </code></pre>
<p>그러나 내부적으로 여전히 rollback이 수행되어 문제를 해결하지 못했습니다.
정확한 이유는 파악하지 못했지만, 제 생각은 다음과 같습니다.</p>
<blockquote>
<p>“listen 메서드에 noRollbackFor을 걸어도,
실제 DB 예외가 카프카 리스너 내부 스레드에서 발생하기 때문에 rollback이 계속 일어난다</p>
</blockquote>
<p>우선 이 코드는 <code>@KafkaListener</code> 내부 비동기 처리 메서드에 붙어있습니다.</p>
<pre><code class="language-java">@KafkaListener(topics = &quot;like-change&quot;, containerFactory = &quot;immediateListenerContainerFactory&quot;)
@Transactional(noRollbackFor = DataIntegrityViolationException.class)
public void listen(LikeChange likeChange) { ... }</code></pre>
<p>Kafka의 리스너 컨테이너는 내부적으로 <code>KafkaMessageListenerContainer</code> → <code>ListenerInvoker</code> → <code>invokeListener()</code> 체인을 통해 메시지를 처리합니다.</p>
<p>이 시점에서 Kafka 내부의 트랜잭션은 논리 트랜잭션이기 때문에 <code>rollbackOnly=true</code> 를 마킹하고, Kafka 내부 트랜잭션의 부모 트랜잭션(물리 트랜잭션)이 rollback 되는 것입니다.</p>
<p><a href=https://velog.io/@dw_db/스프링-트랜잭션-전파-옵션과-물리논리-트랜잭션-이해하기 >물리/논리 트랜잭션 이해하기</a>
⬆ 해당 글에서 이전에 정리했던 개념과 같은 상황인것 같습니다.</p>
<p>즉, <code>@Transactional(noRollbackFor)</code>은 현재 스레드의 트랜잭션 관리 범위에서 발생한 예외에만 적용됩니다.
Kafka Listener는 내부적으로 별도 트랜잭션 경계를 만들기 때문에 rollback 방지가 적용되지 않습니다.</p>
<p>✅ <strong>임시 대응: DLT 적용</strong></p>
<p>임시 방안으로 <code>@RetryableTopic</code>을 통해 <strong>5회 재시도 후 DLT(Dead Letter Topic)</strong> 으로 메시지를 버리도록 구성했습니다.</p>
<pre><code class="language-java">@RetryableTopic(attempts = &quot;5&quot;, dltTopicSuffix = &quot;.dlt&quot;)
@KafkaListener(topics = &quot;like-change&quot;, groupId = &quot;like-group&quot;)</code></pre>
<h4 id="결과-1">결과</h4>
<ul>
<li>Consumer 무한 루프 해소</li>
<li>Kafka Lag 감소</li>
</ul>
<p>다만, DLT는 _복구 불가능한 메시지 처리를 위한 설계 요소_로
이처럼 도메인 정합성 문제를 “버리는 방식”으로 해결하는 것은 바람직하지 않았습니다.
이에 따라 도메인 레벨에서 멱등성을 보장하는 구조로 개선하였습니다.</p>
<hr>
<h3 id="3️⃣-dlt-제거-및-도메인-레벨-멱등성idempotency-검증">3️⃣ DLT 제거 및 도메인 레벨 멱등성(Idempotency) 검증</h3>
<h4 id="문제-인식-2">문제 인식</h4>
<p>DLT로 무한 루프는 해소되었으나, 
근본적으로는 Like 데이터 중복 생성이라는 도메인 정합성 문제가 남아 있었습니다.</p>
<h4 id="행동-2">행동</h4>
<p>✅ <strong>사전 검증(Pre-check) 로직 추가</strong>
Kafka Consumer에서 Like를 DB에 저장하기 전,
이미 Redis에 저장된 Like 상태를 확인하여 <strong>멱등성</strong>을 보장하도록 수정했습니다.</p>
<pre><code class="language-java">//to-be
boolean added = redisTemplate.opsForSet().isMember(KEY_PREFIX + articleId, userId.toString());

if (likeChange.added()) {
    if (added) {
        log.debug(&quot;Skip duplicate like: articleId={}, userId={}&quot;, articleId, userId);
        return;
    }
    articleLikeRepository.save(ArticleLike.create(articleId, userId));
} else {
    if (!added) {
        log.debug(&quot;Skip non-existing like deletion: articleId={}, userId={}&quot;, articleId, userId);
        return;
    }
    articleLikeRepository.deleteByArticleIdAndUserId(articleId, userId);
}</code></pre>
<p>RDB 접근을 최소화하기 위해 Redis 기반 검증을 진행하였고,
멱등성이 보장된 이후 <code>ArticleLike</code> 객체를 생성함으로써, 기존 로직(생성 후 검증)보다 JVM의 힙 메모리 낭비를 줄였습니다.</p>
<h4 id="결과-2">결과</h4>
<ul>
<li>중복 INSERT 완전 차단</li>
<li>Kafka Lag = 0 유지</li>
<li>DB 커넥션 점유율 40% → 20%로 감소</li>
<li>목적성에 맞지 않는 DLT 제거</li>
</ul>
<hr>
<h3 id="4️⃣-async-스레드-포화-및-트랜잭션-분리">4️⃣ Async 스레드 포화 및 트랜잭션 분리</h3>
<h4 id="문제-인식-3">문제 인식</h4>
<p>Kafka 프로듀서가 전송 성공 시, Outbox 테이블의 이벤트를 즉시 삭제하는 로직을 비동기(Async) 스레드에서 수행하고 있었습니다.</p>
<p>즉, Kafka 전송 성공 콜백 내부에서 다음 코드가 실행되었습니다.</p>
<pre><code class="language-java">future.whenComplete((result, excepion) -&gt; {
    if (excepion == null) {
        outboxService.deleteById(outboxEvent.getId());
        RecordMetadata meta = result.getRecordMetadata();
    } else {
        log.error(&quot;failed to send LikeChange event (articleId={} userId={}): {}&quot;,
        event.articleId(), event.userId(), excepion.getMessage(), excepion);
    }
});</code></pre>
<p>그러나 비동기 스레드 풀이 포화되며 다음 예외(<code>RejectedExecutionException</code>)가 발생했습니다.</p>
<pre><code>Caused by: java.util.concurrent.RejectedExecutionException: 
Task java.util.concurrent.FutureTask@5f831c19[Not completed, task = org.springframework.aop.interceptor.AsyncExecutionInterceptor$$Lambda/...]
rejected from ThreadPoolTaskExecutor[Running, pool size = 10, active threads = 1, queued tasks = 10, completed tasks = 9953]
</code></pre><p>이는 <strong>Kafka 전송 직후 Outbox 삭제 트랜잭션이 비동기적으로 몰리면서</strong>, 
Executor 큐가 한계에 도달해 발생한 문제였습니다.</p>
<h4 id="행동-3">행동</h4>
<p>✅ <strong>비동기 스레드 풀을 확장하고, 큐 용량을 늘려 스레드 대기를 완화했습니다.</strong></p>
<pre><code class="language-java">executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(200);</code></pre>
<h4 id="결과-3">결과</h4>
<ul>
<li>Async 스레드 대기 해소</li>
</ul>
<h3 id="최종-결과">최종 결과</h3>
<p>✅ <strong>k6 부하 테스트</strong></p>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/37d5b4e2-061d-4c27-93dc-42411a95be3c/image.png width=100%>
</blockquote>
<p>✅ <strong>백로그 0 유지</strong></p>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/60e2cf90-58d5-4e32-b36b-890035123148/image.png width=100%>
</blockquote>
<p>✅ <strong>Grafana 지표</strong></p>
<blockquote>
<img src=https://velog.velcdn.com/images/dw_db/post/fe34d919-fbf1-4fc2-9ffa-6bcfb4786b92/image.png width=100%>
</blockquote>
<h3 id="이후-작업">이후 작업</h3>
<ul>
<li><strong>Kafka Consumer 멀티 스레드 적용을 통한 병렬 처리 성능 향상</strong></li>
<li>4️⃣번 과정 보강<ul>
<li><strong>프로듀서 Outbox 삭제 로직</strong>을 Hard Delete → Soft Delete로 변경하여 InnoDB의 락 경합을 줄이기(update, delete 쿼리 성능 파악)</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[2025 하반기 삼성SDS 서류 및 소프트웨어 역량 테스트]]></title>
            <link>https://velog.io/@dw_db/2025-%ED%95%98%EB%B0%98%EA%B8%B0-%EC%82%BC%EC%84%B1SDS-%EC%84%9C%EB%A5%98-%EB%B0%8F-%EC%86%8C%ED%94%84%ED%8A%B8%EC%9B%A8%EC%96%B4-%EC%97%AD%EB%9F%89-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@dw_db/2025-%ED%95%98%EB%B0%98%EA%B8%B0-%EC%82%BC%EC%84%B1SDS-%EC%84%9C%EB%A5%98-%EB%B0%8F-%EC%86%8C%ED%94%84%ED%8A%B8%EC%9B%A8%EC%96%B4-%EC%97%AD%EB%9F%89-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Wed, 08 Oct 2025 15:22:52 GMT</pubDate>
            <description><![CDATA[<h3 id="서류-결과">서류 결과</h3>
<p><img src="https://velog.velcdn.com/images/dw_db/post/cf297790-c9b4-4512-becb-68992445bb98/image.png" alt="">
운이 좋게 서류에 통과했다.
직무적합성평가에 합격했다길래 나는 직무적합성평가를 본 적이 없고, 서류만 제출했는데 뭐지 싶어서 삼잘알 친구한테 전화해서 물어봤는데 서류 통과한거라고 알려줬길래 기분이 좋았당</p>
<p>자기소개서는 세 가지 문항에 대해 작성하였고, 아래와 같다.</p>
<ol>
<li>삼성SDS를 지원한 이유와 입사 후 회사에서 이루고 싶은 꿈을 기술하십시오. (0/700)</li>
<li>본인의 성장과정을 간략히 기술하되 현재의 자신에게 가장 큰 영향을 끼친 사건, 인물 등을 포함하여 기술하시기 바랍니다. (※작품 속 가상인물도 가능) (0/1500)</li>
<li>최근 사회 이슈 중 중요하다고 생각되는 한 가지를 선택하고 이에 관한 자신의 견해를 기술해 주시기 바랍니다. (0/1000)</li>
</ol>
<p>작성하는데 시간을 오래 쓰진 않았지만 내가 읽어봐도 정말 잘 쓴 자소서였던것 같다.
<del>특히 2번 문항은 이전에 책을 많이 읽었었고, 요즘도 다독중이여서 인상깊었던 책의 주인공을 작성하였는데 기가막혔다.</del></p>
<h3 id="알고리즘">알고리즘</h3>
<img src=https://velog.velcdn.com/images/dw_db/post/d637cea5-9ff0-4516-b269-9029bf3251f7/image.png width=100%>
상반기에는 알고리즘 문제를 열심히 풀었지만... 5월? 쯤 부터는 안한거 같아서 벼락치기로 문제를 풀기 시작했다. 

<p><del>기록하는걸 좋아서 알고리즘 문제, 프로젝트 등 기록하는 편인데, 이때도 엄청 열심히 하진 않았고 프로그래머스, 백준, 리트코드 꾸준하게 다양하게 푼 것 같다</del></p>
<p>월요일에 서류합격을 확인하고 그 주 일요일에 시험이여서, 부랴부랴 몇 문제 풀었지만 다른분들에 비해서는 턱없이 부족한 문제량이다 ㅎ_ㅎ..</p>
<img src=https://velog.velcdn.com/images/dw_db/post/84a46113-6d4e-42ce-ad71-51756c005ebd/image.png width=100%>



<h3 id="시험-당일">시험 당일</h3>
<img src=https://velog.velcdn.com/images/dw_db/post/400a412a-8c71-48e3-b8eb-54acab643080/image.jpeg width=70%>
우와1

<img src=https://velog.velcdn.com/images/dw_db/post/835e6796-3d1e-4e98-a255-5a4435e70777/image.jpeg width=70%>
우와2

<img src=https://velog.velcdn.com/images/dw_db/post/1efedd12-40d1-4a8b-866e-9ff7aa5210d7/image.heic width=70%>
우와3 건물이 정말 크고 높다.

<img src=https://velog.velcdn.com/images/dw_db/post/3b37a7fa-1664-4abe-a5e2-9214bae50c6d/image.jpeg width=70%>
건물 1층 모든곳에 안내문이 붙어있었고, 모든 담당자분들이 친절하게 안내해주셔서 대우받는것 같아 기분이 좋았다. 

<p>시험장은 지하1,2층을 사용한 것 같고, 정말 5m 간격으로 안내해주시는 분들이 계셨었다. 
지하로 내려가면서, 코딩테스트를 오프라인으로 할 수 있는, 응시자들을 모두 수용할 수 있는 기업이 얼마나 있을까 싶었다. (시험 당일 일요일이였는데 안내해주시는 분들 + 각 반마다 두 분의 감독관분들 모두 정장으로 빼입고 계셔서 멋있었고, 감사하다고 생각했다)</p>
<p>아 준비물은 신분증이랑 소프트웨어 역량 테스트 수험표를 가져갔었다. (+ 개인필기구)</p>
<h3 id="시험-후기">시험 후기</h3>
<p>처음에는 2문제를 네 시간동안 풀이한다고해서 &quot;어떻게 그렇게 오래 집중을 하지&quot; 라고 생각했다. 
하지만!! 막상 시간가는줄 모르고 풀고있던 나를 돌아보며 꽤나 뿌듯함을 느낄 수 있었다.</p>
<p>사실 &#39;기대만큼&#39; 어렵지는 않았던 것 같다. (<del>본인은 1솔도 애매함 ㅋ_ㅋ</del>)
첫 번째 문제는 BFS + 시뮬레이션으로 빡구현을 했어야 했고(<del>300줄 가까이 나온듯</del>), 두 번째 문제는 읽다가 시간이 끝났다. 평소에 꾸준히 풀었다면 기본적인 기능 껍데기를 빠르게 구성하고, 엣지 케이스를 빠르게 캐치하여 두 번째 문제까지 도전해볼 수 있었을 것 같은데... 그러지 못해서... 디버깅에 너무 시간을 많이 써서... 아쉬웠당 ㅠ !</p>
<p>준비된 자에게 기회가 온다는것은 알고 있엇지만, 
<strong>준비된 자가 기회를 확실하게 잡는다는것</strong>도 배울 수 있었던 좋은 경험이였다.</p>
<p>아무튼 불황인 취업시장에서, 오랜만에 대기업 서류가 붙엇고 지금까지 했던것들이 헛되지는 않았구나 생각하였다. 
결과에 상관없이 더 꾸준하게, 열심히 살아갈 수 있는 원동력을 얻을 수 있는 값진 경험을 하였다 <del>~</del>!!!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA 생명주기 콜백과 네이티브 쿼리의 관계]]></title>
            <link>https://velog.io/@dw_db/JPA-%EC%83%9D%EB%AA%85%EC%A3%BC%EA%B8%B0-%EC%BD%9C%EB%B0%B1%EA%B3%BC-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EC%BF%BC%EB%A6%AC%EC%9D%98-%EA%B4%80%EA%B3%84</link>
            <guid>https://velog.io/@dw_db/JPA-%EC%83%9D%EB%AA%85%EC%A3%BC%EA%B8%B0-%EC%BD%9C%EB%B0%B1%EA%B3%BC-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EC%BF%BC%EB%A6%AC%EC%9D%98-%EA%B4%80%EA%B3%84</guid>
            <pubDate>Tue, 01 Jul 2025 13:04:53 GMT</pubDate>
            <description><![CDATA[<p>JPA를 사용하면서 겪었던 해결 과정을 공유하려고 합니다.</p>
<h3 id="문제-상황">문제 상황</h3>
<p>저는 프로젝트의 모든 엔티티에 생성 및 수정 시간을 자동으로 기록하기 위해, 아래와 같이 <code>BaseTimeEntity</code> 를 만들어 상속해서 사용하고 있었습니다.</p>
<pre><code class="language-java">@Getter
@MappedSuperclass // 자식 클래스에 매핑 정보만 상속
@Getter
@MappedSuperclass
public class BaseTimeEntity{

    @CreatedDate
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @Column(nullable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    public void onCreate(){
        this.createdAt = LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    @PreUpdate
    public void onUpdate(){
        this.updatedAt = LocalDateTime.now();
    }
}</code></pre>
<p><code>@PrePersist</code>와 <code>@PreUpdate</code>는 JPA의 생명주기 콜백으로, 엔티티가 저장되거나 업데이트되기 직전에 지정된 메서드를 실행해주는 편리한 기능입니다.</p>
<p>Kafka를 도입하며 이벤트 발생 시각과 DB에 저장되는 시간차를 구해서 내부 API의 지연시간을 구하는것이 목표였습니다.</p>
<p>대부분의 엔티티에서는 이 기능이 잘 동작했습니다. 하지만 유독 <code>ArticleLike</code>라는 엔티티만 <code>createdAt</code>과 <code>updatedAt</code> 필드에 <code>null</code> (또는 <code>0000-00-00...</code>)이 들어가는 문제가 발생했습니다.</p>
<hr>
<h3 id="원인-분석">원인 분석</h3>
<p>문제의 원인은 ArticleLike 엔티티를 저장하는 방식에 있었습니다.</p>
<p>ArticleLike는 중복된 &#39;좋아요&#39; 이벤트가 발생했을 때 DB 에러를 피하기 위해, 아래와 같은 <code>INSERT IGNORE</code> 구문을 사용하는 커스텀 메서드로 저장되고 있었습니다.</p>
<pre><code class="language-java">public interface ArticleLikeRepository extends JpaRepository&lt;ArticleLike, Long&gt; {
    @Modifying
    @Query(
    value = &quot;INSERT IGNORE INTO article_likes (article_id, user_id, created_at, updated_at) &quot; 
            + &quot;VALUES (:articleId, :userId, NOW(), NOW())&quot;,
               nativeQuery = true)
    void insertIgnore(@Param(&quot;articleId&quot;) Long articleId, @Param(&quot;userId&quot;) Long userId);
}</code></pre>
<p>바로 <code>nativeQuery = true</code> 옵션이 문제의 핵심이었습니다.</p>
<ul>
<li><p><code>repository.save(entity)</code>: 이 메서드는 Spring Data JPA를 통해 EntityManager에게 엔티티의 관리를 위임합니다. <code>EntityManager</code>는 엔티티의 상태 변화를 감지하고, 그 과정에서 <code>@PrePersist</code>와 같은 생명주기 콜백을 순서대로 실행합니다.</p>
</li>
<li><p><code>네이티브 쿼리 (nativeQuery = true)</code>: 이 방식은 JPA의 생명주기 관리를 완전히 건너뛰고, 작성된 SQL을 데이터베이스로 직접 전송합니다. <code>EntityManager</code>가 관여하지 않으므로, <code>BaseTimeEntity</code>에 정의된 <code>@PrePersist</code> 메서드는 호출될 기회조차 없습니다.</p>
</li>
</ul>
<p>결국 Article 엔티티는 <code>save()</code>를 통해 저장되어 시간이 잘 기록됐지만, ArticleLike 엔티티는 네이티브 쿼리를 통해 저장되어 생명주기 콜백이 무시되었던 것입니다.</p>
<hr>
<h3 id="문제-해결">문제 해결</h3>
<p>이 문제를 해결하는 가장 올바른 방법은 네이티브 쿼리 대신, JPA의 표준 <code>save()</code> 메서드를 사용하는 것입니다. 중복 저장 문제는 DB의 유니크 제약조건(<code>uniqueConstraint</code>)을 활용하고, <code>DataIntegrityViolationException</code>을 <code>try-catch</code>로 처리하여 해결할 수 있습니다.</p>
<h4 id="변경-전-코드">변경 전 코드</h4>
<pre><code class="language-java">@Transactional
public void listen(LikeChange likeChange) {
    Long articleId = likeChange.articleId();
    Long userId = likeChange.userId();

    if (likeChange.added()) {
        articleLikeRepository.insertIgnore(articleId, userId);
    } else {
        articleLikeRepository.deleteByArticleIdAndUserId(articleId, userId);
    }
}</code></pre>
<h4 id="변경-후-코드">변경 후 코드</h4>
<pre><code class="language-java">@Transactional
public void listen(LikeChange likeChange) {
    if (likeChange.added()) {
        ArticleLike newLike = ArticleLike.create(likeChange.articleId(), likeChange.userId());
        try {
            // 네이티브 쿼리 대신 save() 사용
            articleLikeRepository.save(newLike);
        } catch (DataIntegrityViolationException e) {
            // 중복 데이터로 인한 예외 발생 시, 무시하고 로그만 남김
            log.warn(&quot;Like already exists, ignoring. Article: {}, User: {}&quot;, 
                     likeChange.articleId(), likeChange.userId());
        }
    } else {
        articleLikeRepository.deleteByArticleIdAndUserId(likeChange.articleId(), likeChange.userId());
    }
    // ...
}</code></pre>
<p>이렇게 수정하자 <code>save()</code> 메서드가 <code>@PrePersist</code> 콜백을 정상적으로 트리거하여, <code>createdAt</code>과 <code>updatedAt</code>에 시간이 올바르게 기록되었습니다.</p>
<hr>
<h3 id="결론">결론</h3>
<p>JPA의 <code>@PrePersist</code>, <code>@PreUpdate</code>나 Spring Data JPA의 Auditing(<code>@CreatedDate</code> 등)과 같은 편리한 기능들은 JPA의 영속성 컨텍스트와 생명주기 관리 하에서만 동작합니다. </p>
<p>성능 최적화 등의 이유로 네이티브 쿼리를 사용할 때는, 이러한 자동화 기능들이 동작하지 않을 수 있다는 점을 항상 염두에 두어야 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis 캐시 도입 후 발생한 문제점과 메시징 큐를 활용한 해결 방안]]></title>
            <link>https://velog.io/@dw_db/Redis-%EC%BA%90%EC%8B%9C-%EB%8F%84%EC%9E%85-%ED%9B%84-%EB%B0%9C%EC%83%9D%ED%95%9C-%EB%AC%B8%EC%A0%9C%EC%A0%90%EA%B3%BC-%EB%A9%94%EC%8B%9C%EC%A7%95-%ED%81%90%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EC%95%88</link>
            <guid>https://velog.io/@dw_db/Redis-%EC%BA%90%EC%8B%9C-%EB%8F%84%EC%9E%85-%ED%9B%84-%EB%B0%9C%EC%83%9D%ED%95%9C-%EB%AC%B8%EC%A0%9C%EC%A0%90%EA%B3%BC-%EB%A9%94%EC%8B%9C%EC%A7%95-%ED%81%90%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EC%95%88</guid>
            <pubDate>Thu, 26 Jun 2025 09:57:30 GMT</pubDate>
            <description><![CDATA[<h3 id="기존-아키텍처-구조">기존 아키텍처 구조</h3>
<p>현재 프로젝트는 아래와 같은 아키텍처로 구성된 상태입니다.</p>
<p><code>Spring Boot ↔ Redis (SET 구조) ↔ MySQL</code></p>
<p>사용자가 게시글 상세 페이지를 조회하면, 기본적인 게시글 정보(제목, 내용 등)와 함께 좋아요 수도 함께 반환됩니다. 이때 좋아요 수는 실시간성과 성능을 고려하여 RDB가 아닌 Redis의 Set 자료구조를 활용해 계산하고 있습니다.</p>
<pre><code class="language-java">// Redis SET size를 통해 좋아요 개수 계산
public long getLikeCount(Long articleId) {
    return redisTemplate.opsForSet().size(&quot;article:likes:&quot; + articleId);
}</code></pre>
<p>이 구조를 통해 MySQL의 부하를 줄이고 동시성 문제도 자연스럽게 해결했지만, 다음과 같은 두 가지 문제점이 발생하였습니다.</p>
<hr>
<h3 id="문제-1--redis-서버-재부팅-시-좋아요-수-0으로-조회되는-문제">문제 1 – Redis 서버 재부팅 시 좋아요 수 0으로 조회되는 문제</h3>
<p>Redis는 메모리 기반 저장소이기 때문에 서버가 다운되거나 재부팅되면 기존의 좋아요 정보가 모두 날아가버릴 수 있습니다. 이 경우, RDB에는 분명히 좋아요 정보가 남아 있음에도 불구하고 Redis에는 해당 키가 존재하지 않기 때문에 좋아요 수가 0으로 반환됩니다.</p>
<p><strong>해결 방안 – Redis Warm-up</strong>
이 문제를 해결하기 위해, 애플리케이션 시작 시점에 RDB의 데이터를 Redis에 다시 적재하는 <code>Warm-up</code> 로직을 구현했습니다.</p>
<pre><code class="language-text">- 서버 시작 → Redis 좋아요 키 존재 여부 확인
- 키가 없다면 Kafka에서 과거 메시지 읽기
- Redis에 재적재</code></pre>
<p>이를 통해 Redis 유실 혹은 서버가 다운 됐을 경우에도 빠른 복구를 기대할 수 있습니다.</p>
<hr>
<h3 id="문제-2--스케줄러-기반-rdb-동기화-시-정합성-이슈">문제 2 – 스케줄러 기반 RDB 동기화 시 정합성 이슈</h3>
<p>초기에는 좋아요 API가 호출되면 Redis에만 먼저 반영하고, 일정 주기로 동작하는 <code>Spring Scheduler</code>가 Redis 데이터를 RDB에 배치 저장하는 구조로 구현되어 있었습니다.</p>
<p>하지만 이 경우 다음과 같은 문제가 발생합니다</p>
<ul>
<li>Redis: 좋아요 수 10개</li>
<li>RDB: 좋아요 수 9개 (아직 반영되지 않음)</li>
<li>이 때 Redis 서버 다운 → 좋아요 1개 정보 유실</li>
</ul>
<p>즉, 스케줄러에 지정한 시간에 따라 Redis → RDB write 사이의 시간차로 인해 데이터 정합성이 깨질 수 있는 구조라고 판단하였습니다.</p>
<p><strong>해결 방안 – Kafka 메시징 큐 기반 아키텍처로 개선</strong>
이를 해결하기 위해 Kafka와 같은 메시징 큐를 도입하게 된다면, 좋아요 이벤트 발생 시점에 LikeChange 객체를 Kafka로 발행하고, Consumer에서 해당 데이터를 비동기로 RDB에 적재할 수 있을 것 같았습니다.</p>
<pre><code class="language-text">1. Redis에 좋아요 상태 반영
2. Kafka로 이벤트 발행 (트랜잭션 커밋 이후)
3. Consumer가 Kafka 메시지를 읽고 RDB에 저장</code></pre>
<p><strong>문제1</strong>에서 어플리케이션 재부팅(레디스 서버 유실시)을 하면 RDB의 데이터를 redis 서버로 적재하는 <code>warm-up</code> 과정을 진행하게 되는데, 이때 카프카의 특정 컨슈머에 해당 역할을 부여할 수 있을 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/c7761fa0-b88a-4d58-904e-f33eaf5e84a0/image.png" alt="">
또한  이전에 간단하게 MSA를 경험하며 카프카를 사용해본 적이 있는데, 이때는 주키퍼를 사용해서 카프카 클러스터를 관리해야하는 구조였습니다.</p>
<p>하지만 KRaft라는 새로운 협의 프로토콜이 등장하게 되고, 이전에 주키퍼를 통해 카프카 브로커의 메타데이터를 관리하는 의존성을 제거할 수 있기에, 사용해보면 좋겠다는 생각이 들어 카프카 메세징 큐를 채택하게 되었습니다</p>
<blockquote>
<p>KRaft는 Apache Kafka의 새로운 협의 프로토콜로, 주키퍼 없이 카프카 자체적으로 메타데이터 관리를 가능하게 합니다. 카프카 2.8 버전에서 처음 소개되었고, 3.3 버전에서 프로덕션 레벨 릴리스가 되었습니다. 카프카 3.6 버전까지는 주키퍼 모드와 KRaft 모드를 모두 지원하며, 4.0 버전부터는 KRaft 모드로만 사용 가능합니다</p>
</blockquote>
<p>또한 발행에 실패하거나 Consumer가 실패할 경우를 대비하여 Outbox 패턴을 도입하여 이벤트 객체를 DB에 먼저 저장하고, Kafka 발행 후 상태를 갱신하는 구조를 적용한다면 더욱 안정적으로 서버 운영을 기대할 수 있습니다.</p>
<blockquote>
<p>주키퍼 모드 vs KRaft 모드의 차이점은 아래 링크에서 자세히 확인할 수 있습니다.
<a href="https://devocean.sk.com/blog/techBoardDetail.do?ID=165711&boardType=techBlog">Apache Kafka의 새로운 협의 프로토콜인 KRaft에 대해(1)</a></p>
</blockquote>
<hr>
<p>이번글에서는 앞으로 성능과 데이터 정합성을 고려한 설계를 고민하고 작성하였습니다.
다음 글에서는 카프카를 도입하여 겪었던 에러사항들과 도입 후 개선된점들을 모니터링을 통하여 정량적인 데이터를 분석해보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis 유무에 따른 좋아요API 성능 테스트(redis, k6, spring scheduler)]]></title>
            <link>https://velog.io/@dw_db/Redis-%EC%9C%A0%EB%AC%B4%EC%97%90-%EB%94%B0%EB%A5%B8-%EC%A2%8B%EC%95%84%EC%9A%94API-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8redis-k6-spring-scheduler</link>
            <guid>https://velog.io/@dw_db/Redis-%EC%9C%A0%EB%AC%B4%EC%97%90-%EB%94%B0%EB%A5%B8-%EC%A2%8B%EC%95%84%EC%9A%94API-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8redis-k6-spring-scheduler</guid>
            <pubDate>Thu, 19 Jun 2025 05:46:49 GMT</pubDate>
            <description><![CDATA[<h2 id="redis-도입-전">Redis 도입 전</h2>
<p>기존 게시글 좋아요 API는 RDB(관계형 데이터베이스)에 대한 직접적인 I/O 작업을 통해 좋아요 상태를 토글하는 방식으로 구현되어 있었습니다.</p>
<h3 id="기존-좋아요-api-로직">기존 좋아요 API 로직</h3>
<pre><code class="language-java">public void likeArticle(Long articleId, Long userId) {
    Article article = articleRepository.find(articleId)
                .orElseThrow(() -&gt; new EntityNotFoundException(&quot;Article not found&quot;));
    boolean exists = articleLikeRepository.existsByArticleIdAndUserId(articleId, userId);

    if (exists) {
        articleLikeRepository.deleteByArticleIdAndUserId(articleId, userId);
        article.unlike();
    } else {
        ArticleLike like = ArticleLike.create(articleId, userId);
        articleLikeRepository.save(like);
        article.like();
    }
}</code></pre>
<p>이 로직은 좋아요의 존재 여부를 확인하고, 존재하면 삭제, 없으면 새로 생성하여 즉시 RDB에 저장합니다. 이러한 방식은 락 경합 및 동시성 이슈 발생 가능성이 높다고 판단했습니다.</p>
<h3 id="기존-로직의-문제점레이스-컨디션">기존 로직의 문제점:레이스 컨디션</h3>
<pre><code class="language-java">@Entity
@Table(
    name = &quot;article_likes&quot;,
    uniqueConstraints = @UniqueConstraint(
        name = &quot;uk_article_like_article_user&quot;,
        columnNames = {&quot;article_id&quot;, &quot;user_id&quot;}
    )
)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ArticleLike</code></pre>
<p>DB에 <code>UNIQUE(article_id, user_id)</code> 제약 조건이 설정되어 있더라도, 동시에 두 요청이 들어올 경우 다음과 같은 레이스 컨디션이 발생할 수 있을 것 같았습니다.</p>
<ul>
<li>A, B 두 스레드가 거의 같은 순간에 &quot;해당 좋아요가 존재하지 않는다&quot;고 판단하여 둘 다 삽입 로직을 실행한다면, 이 경우 둘 중 하나가 먼저 DB에 레코드를 넣고, 나머지는 제약 위반으로 예외가 발생하여 테스트에서 실패 케이스로 기록될 수 있습니다.</li>
<li>반대로 이미 좋아요가 있을 때, 동시에 두 스레드가 &quot;존재한다&quot;고 판단하여 둘 다 삭제를 시도할 경우, 하나는 &quot;삭제 대상이 없음&quot;으로 실패할 수 있습니다.</li>
</ul>
<p>이러한 문제점을 확인하기 위해 K6 테스트 툴을 사용하여 성능을 측정했습니다. 
(K6는 JMeter와 유사한 부하 테스트 툴로, 최근 테스트 진영에서 점유율이 증가하고 있어 선택하게 되었습니다.)</p>
<h3 id="테스트-스크립트">테스트 스크립트</h3>
<pre><code class="language-javascript">export let options = {
    scenarios: {
        baseline: {
            executor: &#39;constant-arrival-rate&#39;,
            rate: 20,
            timeUnit: &#39;1s&#39;,
            duration: &#39;10m&#39;,
            preAllocatedVUs: 30,
            maxVUs: 100,
        },
        spike: {
            executor: &#39;constant-arrival-rate&#39;,
            rate: 100,
            timeUnit: &#39;1s&#39;,
            duration: &#39;1m&#39;,
            startTime: &#39;10m&#39;,      // baseline 종료 시점
            preAllocatedVUs: 100,
            maxVUs: 200,
        },
    },
};</code></pre>
<p>로그인 인증 및 인가 과정은 제외한 핵심 테스트 구성 인자들입니다.
아래의 테스트들은 해당 스크립트를 통해 진행한 테스트입니다.</p>
<h3 id="redis-도입-전-성능-테스트-결과">Redis 도입 전 성능 테스트 결과</h3>
<h4 id="k6-테스트">k6 테스트</h4>
<p>baseline test: <code>16:05 ~ 16:15</code>
spike test: <code>16:15 ~ 16:16</code>
<img src="https://velog.velcdn.com/images/dw_db/post/980f158f-3fc6-437e-b463-d2722aff5a3a/image.png" alt=""></p>
<p>그리 크지 않은 부하의 스파이크 테스트임에도 불구하고 0.01%의 동시성 이슈가 발생하여 실패했습니다.</p>
<ul>
<li>Throughput: 27.3 req/s</li>
<li>최대 지연: 384ms (DB 직행으로 인한 I/O 또는 락 대기 타임이 튀는 구간이 있었을 것으로 예상됩니다.)</li>
<li>에러(0.01%) 동시성 토글 충돌이 남아있어 캐시 계층으로 제거가 필요하다고 판단했습니다.</li>
<li>p(95): 14.69ms</li>
</ul>
<h4 id="스프링-서버-모니터링">스프링 서버 모니터링</h4>
<p><img src="https://velog.velcdn.com/images/dw_db/post/821f07f8-2eab-4b70-8009-a803d2dd402f/image.png" alt=""></p>
<ul>
<li>HikariCP 풀 사이즈(10) 대비 여유가 있었으나, 스파이크 발생 시 순간적으로 8개의 커넥션이 사용되었습니다.</li>
</ul>
<img src=https://velog.velcdn.com/images/dw_db/post/6805ea7d-4b0f-4011-9d83-144dccebbe67/image.png width=100%>

<ul>
<li>프로세스 평균 CPU 사용률은 0.75%, 피크 시 4.78%를 기록했습니다. 부하 순간에도 CPU는 여유가 있었던 것으로 추정됩니다.</li>
</ul>
<h3 id="mysql-모니터링">MySQL 모니터링</h3>
<p><img src="https://velog.velcdn.com/images/dw_db/post/6e33661c-c118-4069-83c5-a30c3bc02487/image.png" alt=""></p>
<ul>
<li>External_lock Handlers 지표에서 락 대기 및 경합 발생량이 많아 동시성 취약점이 확인되었습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dw_db/post/43e8ebbf-4b53-4220-90c9-28e146f47d55/image.png" alt=""></p>
<ul>
<li>MySQL Questions 지표는 안정 상태에서 180/s, 스파이크 시 850/s를 기록했습니다.</li>
</ul>
<hr>
<h2 id="redis-도입-후">Redis 도입 후</h2>
<p>위에서 확인된 동시성 문제와 RDB 부하를 개선하기 위해 Spring Boot와 RDB 사이에 Redis NoSQL 서버를 도입하기로 결정했습니다. 
기존에도 Spring Redis를 사용하고 있었기에, 동일한 프레임워크를 채택하는 것이 더욱 유용하다고 판단했습니다.</p>
<p>좋아요 개수를 레디스에서 따로 뽑아 게시글 상세조회 등 API에 게시글 객체와 더해 반환하도록 수정하였습니다. 
따라서 Article 도메인에서 likeCount 필드를 제거하였습니다.
<img src="https://velog.velcdn.com/images/dw_db/post/f4a0ffb9-e6a4-43d2-ade7-e181f08b31be/image.png" alt=""></p>
<pre><code class="language-java">public ArticleDetailResponse find(Long id) {
        Article article = articleQueryService.find(id);
        long likeCount = articleLikeService.getLikeCount(id);

        return ArticleDetailResponse.from(article, likeCount);
    }
}</code></pre>
<p>이후 좋아요를 각 게시글 조회시마다 게시글 ID를 통해 redis에서 따로 조회하여 반환하도록 수정하였습니다.</p>
<h3 id="redis-도입-후-좋아요-api-로직">Redis 도입 후 좋아요 API 로직</h3>
<pre><code class="language-java">public void likeArticle(Long articleId, Long userId) {
    articleRepository.find(articleId)
            .orElseThrow(() -&gt; new EntityNotFoundException(&quot;Article not found&quot;));

    String key = KEY_PREFIX + articleId;
    BoundSetOperations&lt;String, String&gt; ops = redisTemplate.boundSetOps(key);

    boolean added = ops.add(userId.toString()) == 1;
    if (!added) {
        ops.remove(userId.toString());
    }
    likeChangeQueue.enqueue(new LikeChange(articleId, userId, added));
}

public long getLikeCount(Long articleId) {
    String key = KEY_PREFIX + articleId;
    return redisTemplate.opsForSet().size(key);
}</code></pre>
<p>즉, 이제 좋아요 API 호출시 RDB에 직접 쓰기 작업을 하지 않고, redis 에 적재한 후, 해당 좋아요 발생 이벤트에 대해 <code>likeChangeQueue</code> 에 저장한 후, 스프링 스케줄러를 통해 정해진 시간마다 db에 batch 쓰기 작업을 진행합니다.</p>
<pre><code class="language-java">@Component
public class LikeChangeQueue {
    private final BlockingQueue&lt;LikeChange&gt; queue = new LinkedBlockingQueue&lt;&gt;();

    public void enqueue(LikeChange likeChange) {
        queue.put(likeChange);
    }

    public List&lt;LikeChange&gt; drain() {
        List&lt;LikeChange&gt; list = new ArrayList&lt;&gt;();
        queue.drainTo(list);

        return list;
    }
}</code></pre>
<p><code>LinkedBlockingQueue</code>는 내부적으로 스레드 안전하게 동작하며, <code>put()</code>과 <code>take()</code> 메서드를 통해 큐가 가득 차거나 비었을 때 자동으로 대기(Blocking)할 수 있어 producer-consumer 패턴 구현에 효과적입니다.</p>
<p><code>LinkedBlockingQueue#drainTo(Collection c)</code> 는 큐에 현재 들어있는 요소들을 가능한 만큼 꺼내서(c로) 옮기고, 큐에서는 제거합니다. 스냅샷처럼 현 시점에서의 데이터를 모두 읽고 비우는 메서드입니다. 
또한, 락을 잡아서 일괄적으로 처리하기 때문에 <code>poll()</code>을 반복하는 것 보다 락 경합이 줄어들어 효율적이라고 합니다.</p>
<p>현재 인메모리 구조로써, 서버가 내려가면 날라가기 때문에 Redis Stream이나 Kafka 메세지 큐를 추후 추가 예정입니다.</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class ArticleLikeFlushJob {
    private final LikeChangeQueue likeChangeQueue;
    private final ArticleLikeRepository articleLikeRepository;

    @Scheduled(fixedDelayString = &quot;${like.flush.interval:60000}&quot;)
    @Transactional
    public void flush() {
        for (LikeChange lc : likeChangeQueue.drain()) {
            if (lc.added()) {
                articleLikeRepository.insertIgnore(lc.articleId(), lc.userId());
            } else {
                articleLikeRepository.deleteByArticleIdAndUserId(lc.articleId(), lc.userId());
            }
        }
    }
}</code></pre>
<p>위 스케줄러에 정해진 시간마다 rdb로 flush() 작업 진행</p>
<p>(25.10.23) 내용 보완
위 <code>flush()</code> 메서드는 하나의 트랜잭션에서 다수의 ChangeEvent에 대해 처리하기 때문에, 하나의 이벤트 처리중 장애가 발생하면 트랜잭션이 롤백되어 다른 이벤트도 유실되는 문제가 발생할 것 같습니다.</p>
<p>따라서 아래처럼 많은 이벤트를 나눠서 처리한다면, 각각의 트랜잭션이 짧아질 것고,</p>
<pre><code class="language-java">List&lt;LikeChange&gt; batch = likeChangeQueue.drain();
for (List&lt;LikeChange&gt; chunk : Lists.partition(batch, 1000)) {
    // 1000건씩 나눠서 처리, 해당 메서드 구현 시 트랜잭션을 새로 걸어야 함
    processChunk(chunk);
}</code></pre>
<p>결국 기존의 <code>HTTP → RDB</code> 구조를 <code>HTTP → Cache → RDB</code> 로 변경함으로써 얻는 이점은 다음과 같습니다.</p>
<ol>
<li><strong>쓰기 지연(Write-behind)</strong> 을 통해 RDB에 반영하기 전에 <strong>중복된 이벤트를 정제</strong>할 수 있다.
→ 동일 유저의 빠른 연속 요청(좋아요/취소 반복)도 최종 상태만 반영되어 DB 부하를 줄인다.</li>
<li><strong>싱글 스레드 기반의 Redis 구조</strong>를 활용하여, 데이터에 대한 <strong>동시성 이슈를 자연스럽게 해결</strong>할 수 있다.
→ 별도의 락(lock) 없이도 일관성이 유지된다.</li>
</ol>
<p>이 중 1번을 만족시키기 위해,
<code>flush()</code> 실행 전 <code>HashMap</code> 자료구조를 사용하여 최종 상태만 남기는 방식으로 처리할 수 있습니다.</p>
<pre><code class="language-java">Map&lt;Pair&lt;Long, Long&gt;, LikeChange&gt; latest = new HashMap&lt;&gt;();
for (LikeChange lc : likeChangeQueue.drain()) {
    latest.put(Pair.of(lc.articleId(), lc.userId()), lc);  // 동일 (articleId, userId) 키는 마지막 이벤트로 덮어씀
}</code></pre>
<p>이렇게 하면 <code>(articleId, userId)</code> 조합별로 마지막 이벤트만 남기므로,
<code>좋아요 → 취소 → 다시 좋아요</code> 같은 연속 이벤트가 들어와도 최종 상태만 RDB에 반영됩니다.</p>
<p>향후 Redis Stream 또는 Kafka로 LikeChange 이벤트를 발행하도록 확장할 예정이며, 소비자는 동일한 flush 로직을 수행하여 분산 환경에서도 데이터 일관성과 내구성을 보장할 계획입니다</p>
<h2 id="redis-도입-후-성능-테스트-결과">Redis 도입 후 성능 테스트 결과</h2>
<h3 id="k6">k6</h3>
<p><img src="https://velog.velcdn.com/images/dw_db/post/bcf71cb3-fb68-4115-8cc3-aa8f2a37d2db/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>지표</th>
<th>Redis 도입 전</th>
<th>Redis 도입 후</th>
<th>변화</th>
</tr>
</thead>
<tbody><tr>
<td>최대 지연 (max latency)</td>
<td>384ms</td>
<td><strong>445.44ms</strong></td>
<td>약간 증가</td>
</tr>
<tr>
<td>p(95)</td>
<td>14.69ms</td>
<td><strong>13.98ms</strong></td>
<td>소폭 개선</td>
</tr>
<tr>
<td>처리량 (http reqs/s)</td>
<td>27.35/s</td>
<td><strong>27.35/s</strong></td>
<td>동일</td>
</tr>
<tr>
<td>에러율</td>
<td>0.1%</td>
<td><strong>0%</strong></td>
<td>완전 해소</td>
</tr>
</tbody></table>
<ul>
<li>지연시간이 다소 증가한 이유는 Redis를 경유하며 발생한 <code>네트워크 비용과 직렬화/역직렬화 과정에서 비용</code>이 추가된 영향으로 추정됩니다. 하지만 상위 95%의 응답속도는 오히려 개선되었습니다.</li>
<li>에러율이 0%로 개선된 것은 Redis의 싱글스레드의 원자적 연산 덕분에 DB 레벨의 동시성 충돌이 제거된 결과로 해석됩니다.</li>
</ul>
<h3 id="스프링-서버-모니터링-1">스프링 서버 모니터링</h3>
<p><img src="https://velog.velcdn.com/images/dw_db/post/e06b4319-98a3-43ce-b9a3-601f8e26cd69/image.png" alt="">
<strong>HikariCP Connection Pool</strong></p>
<ul>
<li>풀 사이즈: 10</li>
<li>커넥션 타임아웃: 0</li>
<li>스파이크 시에도 Active/Idle 수치가 거의 변동 없음</li>
</ul>
<p>좋아요 API가 더 이상 DB에 직접 쓰기작업을 진행하지 않기 때문입니다. 대부분의 요청이 Redis 레벨에서 처리되어, DB 커넥션 풀에 부하가 걸리지 않게 되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/0de5452f-2f3b-42d0-a0cd-64621728bb47/image.png" alt="">
<strong>CPU Usage</strong></p>
<ul>
<li>평균 CPU: 1.68%</li>
<li>피크 시 CPU: 6.87%</li>
<li>이전 대비 약간 증가</li>
</ul>
<p>Redis 연동과 직렬화과정 그리고 <code>BlockingQueue</code>의 enqueue 등 추가 로직으로 인해 CPU 부하를 미세하게 증가시켰습니다. 하지만 전체 부하가 7% 미만이므로 충분히 안정적인 상황인 것 같습니다.</p>
<h3 id="mysql-모니터링-1">MySQL 모니터링</h3>
<p><img src="https://velog.velcdn.com/images/dw_db/post/65637f2d-752e-4307-b070-ad6212313091/image.png" alt="">
<strong>External_lock Handlers</strong> </p>
<ul>
<li>피크 시 1100 -&gt; 400(약 63% 감소)</li>
</ul>
<p>이전에는 각 요청이 직접 insert/delete를 수행하며 테이블 락에 대하여 경쟁하는 상황이였지만, Redis 도입 후에 실시간 DB write가 사라지고 스케줄러를 통해 write 작업을 진행하기 때문입니다.</p>
<p>즉, DB 접근 횟수가 줄어들어 락이 짧게 유지되어 락 경합이 해소되었다고 판단됩니다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/27b78859-5ac1-487d-ad09-737cec7f64a7/image.png" alt="">
<strong>MySQL Questions</strong></p>
<ul>
<li>안정 구간: 180/s -&gt; 140/s</li>
<li>스파이크 구간: 850/s -&gt; 700/s</li>
</ul>
<p>Redis에서 좋아요 상태를 캐시하고 실제 DB 반영은 배치 타이밍에만 일어나기 때문에 트랜잭션 및 질의 횟수가 자연스럽게 줄어든 결과입니다. </p>
<h2 id="결론">결론</h2>
<p>Redis 도입을 통해 좋아요 API의 동시성 문제가 성공적으로 해결할 수 있었고, RDB의 락 경합 및 부하가 상당 부분 완화되었음을 확인할 수 있었습니다. 서비스의 안정성과 성능 향상에 기여할 수 있는 아키텍쳐를 구성하였습니다.</p>
<p>현재 인메모리에 좋아요 이벤트를 저장하고 있기 때문에, 이후에는 비동기 메세징 큐를 도입하여 레디스 서버의 유실 방지 및 스프링 이벤트 구조를 도입할 예정입니다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Docker Compose로 모니터링 환경 구축하기 (MySQL, Redis, Prometheus, Grafana)]]></title>
            <link>https://velog.io/@dw_db/MyBoard-6-Docker-Compose%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-MySQL-Redis-Prometheus-Grafana</link>
            <guid>https://velog.io/@dw_db/MyBoard-6-Docker-Compose%EB%A1%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-MySQL-Redis-Prometheus-Grafana</guid>
            <pubDate>Wed, 04 Jun 2025 03:47:17 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 <strong>Docker Compose</strong>를 활용해 MyBoard 애플리케이션의 <strong>모니터링 환경을 구축하는 과정</strong>을 단계별로 설명합니다.</p>
<p>MySQL과 Redis의 메트릭을 Prometheus로 수집하고 Grafana로 시각화하는 과정을 다루며, 설정 과정에서 발생한 주요 이슈와 해결법도 함께 정리했습니다.</p>
<hr>
<h2 id="1️⃣-mysql·redis-및-exporter-설정">1️⃣ MySQL·Redis 및 Exporter 설정</h2>
<p>Prometheus는 직접 MySQL이나 Redis에서 메트릭을 가져오는 것이 아니라, <strong>Exporter</strong>를 통해 메트릭을 수집합니다. </p>
<p>Exporter는 각 서비스의 내부 상태를 Prometheus 형식으로 변환해 HTTP로 노출하는 역할을 합니다.</p>
<h3 id="▶️-mysql-설정--exporter">▶️ MySQL 설정 + Exporter</h3>
<p><code>docker-compose.yml</code>의 MySQL 부분은 다음과 같습니다</p>
<pre><code class="language-yaml">services:
  mysqldb:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: myboard
      TZ: Asia/Seoul
    volumes:
      - ./mysql-data:/var/lib/mysql
      - ./init-exporter.sql:/docker-entrypoint-initdb.d/init-exporter.sql:ro
    ports: [&quot;3306:3306&quot;]
    healthcheck:
      test: [&quot;CMD&quot;, &quot;mysqladmin&quot;, &quot;ping&quot;, &quot;-h&quot;, &quot;localhost&quot;, &quot;-uroot&quot;, &quot;-proot&quot;]
      interval: 5s; timeout: 2s; retries: 5

  mysql-exporter:
    image: prom/mysqld-exporter:latest
    platform: linux/amd64
    command:
      - &quot;--config.my-cnf=/etc/mysql_exporter.cnf&quot;
      - &quot;--web.listen-address=:9104&quot;
    volumes:
      - ./etc/mysql_exporter.cnf:/etc/mysql_exporter.cnf:ro
    ports: [&quot;9104:9104&quot;]
    depends_on:
      - mysqldb</code></pre>
<p><strong>Prometheus</strong>가 메트릭을 스크랩하려면 서비스별 <strong>Exporter</strong>가 필요합니다.
Exporter는 해당 서비스의 내부 메트릭을 Prometheus 형식으로 변환해 HTTP로 노출합니다.</p>
<p>MySQL Exporter를 사용하려면 별도 계정을 생성하고 적절한 권한을 부여해야 합니다.<br>아래 SQL 스크립트를 <code>/docker-entrypoint-initdb.d/</code> 경로에 바인딩하면,<br>컨테이너가 <strong>최초 실행될 때만</strong> 이 SQL이 자동으로 실행됩니다.</p>
<ul>
<li><code>init-exporter.sql</code> (빈 볼륨 첫 기동 시 실행)    </li>
</ul>
<pre><code class="language-sql">CREATE USER &#39;exporter&#39;@&#39;%&#39; IDENTIFIED BY &#39;export_pwd&#39;;
GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO &#39;exporter&#39;@&#39;%&#39;;</code></pre>
<p>위에서 생성한 <code>exporter</code> 전용 계정에 대한 정보를 일치하게 <code>mysql-exporter</code> 실행시 필요한 <code>.cnf</code> 파일에 동일하게 값을 넣어줍니다.</p>
<ul>
<li><code>etc/mysql_exporter.cnf</code><pre><code class="language-ini">[client]
user=exporter
password=export_pwd
host=mysqldb</code></pre>
</li>
</ul>
<h3 id="▶️-redis-설정--exporter">▶️ Redis 설정 + Exporter</h3>
<pre><code class="language-yml">services:
  redis:
    image: redis:latest
    ports: [&quot;6379:6379&quot;]
    healthcheck:
      test: [&quot;CMD&quot;, &quot;redis-cli&quot;, &quot;ping&quot;]
      interval: 5s; timeout: 2s; retries: 5

  redis-exporter:
    image: oliver006/redis_exporter:latest
    command: [&quot;--redis.addr=redis:6379&quot;]
    ports: [&quot;9121:9121&quot;]
    depends_on:
      - redis</code></pre>
<p>redis exporter는 mysql과 다르게 특정 계정 및 권한이 필요하지 않습니다.</p>
<hr>
<h2 id="2️⃣-spring-boot-애플리케이션-설정">2️⃣ Spring Boot 애플리케이션 설정</h2>
<p>MySQL과 Redis가 정상적으로 시작된 이후에만 Spring Boot 애플리케이션을 실행하도록, 컨테이너의 상태를 체크하는 <code>depends_on</code> 조건을 추가했습니다.</p>
<pre><code class="language-yml">services:
  springboot:
    build:
      context: .
      dockerfile: Dockerfile
    image: my-board:latest
    environment:
      SPRING_PROFILES_ACTIVE: docker
      SPRING_DATASOURCE_URL: jdbc:mysql://mysqldb:3306/myboard?serverTimezone=Asia/Seoul
      SPRING_DATASOURCE_USERNAME: 
      SPRING_DATASOURCE_PASSWORD: 
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PORT: 6379
    ports: [&quot;8080:8080&quot;]
    depends_on:
      mysqldb:
        condition: service_healthy
      redis:
        condition: service_healthy</code></pre>
<ul>
<li><strong>Dockerfile</strong> (코드 변경 시 재빌드 필요)</li>
</ul>
<pre><code class="language-dockerfile">FROM openjdk:21-jdk
COPY build/libs/my-board-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]</code></pre>
<p>코드 수정 후 재빌드를 하지 않고 <code>docker compose up -d --build</code>를 실행했었는데, 이 과정에서 오류는 아래에서 정리하겠습니다.</p>
<hr>
<h2 id="3️⃣-prometheus·grafana">3️⃣ Prometheus·Grafana</h2>
<p>모니터링을 담당하는 Prometheus와 Grafana가 다른 서비스보다 나중에 시작되도록 <code>depends_on</code> 옵션을 설정하고, 메트릭 수집을 위한 각 컨테이너를 연결합니다.</p>
<p>이때 <code>docker-compose.yml</code> 에서 정의한 컨테이너 이름과 동일해야합니다.</p>
<pre><code class="language-yml">services:
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus:/etc/prometheus
    ports: [&quot;9090:9090&quot;]
    depends_on:
      - springboot
      - mysql-exporter
      - redis-exporter

  grafana:
    image: grafana/grafana:latest
    ports: [&quot;3000:3000&quot;]
    depends_on:
      - prometheus</code></pre>
<p>아직 <strong>springboot</strong> 와 <strong>mysql</strong> 만 필요하지만, 추후 성능 개선을 위해 <strong>redis</strong> 를 도입할 수 있으니 <code>redis-exporter</code>를 넣어 함께 데이터를 수집하겠습니다.</p>
<img src=https://velog.velcdn.com/images/dw_db/post/f3e6a9ba-22b4-4c1d-9746-9c87049ca1fc/image.png>


<p><code>http://localhost:9090/targets</code> 접속시 나타나는 <strong>Prometheus UI</strong>입니다.
위와 같은 UI를 띄우기 위한 설정 방법을 정리하겠습니다.</p>
<p>Prometheus가 15초 간격으로 각 엔드포인트를 스크랩하도록 설정합니다.</p>
<p><strong>Prometheus 설정 (<code>prometheus.yml</code>)</strong></p>
<pre><code class="language-yml">global:
  scrape_interval: 15s

scrape_configs:
  - job_name: &#39;spring-boot&#39;
    metrics_path: &#39;/actuator/prometheus&#39;
    static_configs:
      - targets: [&#39;springboot:8080&#39;]

  - job_name: &#39;mysql&#39;
    static_configs:
      - targets: [&#39;mysql-exporter:9104&#39;]

  - job_name: &#39;redis&#39;
    static_configs:
      - targets: [&#39;redis-exporter:9121&#39;]</code></pre>
<ul>
<li><p><code>job_name</code>
Prometheus UI에 표시될 서비스 그룹의 이름입니다.
이 예제에서는 <code>spring-boot</code>, <code>mysql</code>, <code>redis</code> 3개의 job이 생성되며,
UI의 <strong>Status → Targets</strong> 화면에서 각각의 이름으로 확인할 수 있습니다.</p>
</li>
<li><p><code>metrics_path</code>
메트릭을 수집할 엔드포인트 경로입니다.
대부분의 애플리케이션은 기본 <code>/metrics</code>를 사용하지만, </p>
</li>
<li><p><em>Spring Boot Actuator*</em>를 쓸 때는 <code>/actuator/prometheus</code>로 변경해야 합니다.</p>
</li>
<li><p><code>static_configs.targets</code>
실제 메트릭을 가져올 호스트와 포트입니다.</p>
<ul>
<li><code>springboot:8080</code> → Spring Boot 컨테이너(Actuator)</li>
<li><code>mysql-exporter:9104</code> → MySQL Exporter</li>
<li><code>redis-exporter:9121</code> → Redis Exporter</li>
</ul>
</li>
</ul>
<p>이 설정이 완료되면, Prometheus가 15초마다 지정된 엔드포인트로 HTTP 요청을 보내고,
수집된 시계열 데이터가 <strong>spring-boot, mysql, redis</strong> 세 가지 job으로 구분되어 저장됩니다.</p>
<hr>
<h2 id="4️⃣-spring-boot-actuator--보안-설정">4️⃣ Spring Boot Actuator &amp; 보안 설정</h2>
<h3 id="spring-boot-actuator">Spring Boot Actuator</h3>
<p>Prometheus로 메트릭을 수집하고 Grafana에서 시각화하려면, Spring Boot의 Actuator 엔드포인트를 외부에 노출하고 시큐리티 설정에서 <code>/actuator/**</code> 경로를 모두 허용해야 합니다. </p>
<p><code>application-docker.yml</code>에 아래와 같이 설정하면, <code>/actuator/health</code>, <code>/actuator/info</code>, <code>/actuator/prometheus</code> 세 개의 엔드포인트가 열립니다.</p>
<pre><code class="language-yml"># application-docker.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus
  endpoint:
    health:
      show-details: always</code></pre>
<p>이제 <code>/actuator/health</code> 호출 시 데이터베이스(<code>db</code>), 디스크 용량(<code>diskSpace</code>), 네트워크 핑(<code>ping</code>), Redis 연결(<code>redis</code>), SSL 설정(<code>ssl</code>) 등 상세한 상태 정보를 확인할 수 있습니다. </p>
<img src=https://velog.velcdn.com/images/dw_db/post/fed7e87d-9d8a-4939-abc1-5341bcc1faae/image.png>

<p><code>localhost:8080/actuator/health</code> 접속시 나오는 화면입니다.
가시적으로 Spring boot 서버 health 체크를 할 수 있습니다.</p>
<p>위 사진에서 확인할 수 있듯이, 데이터베이스와 애플리케이션의 전반적인 상태는 정상(<code>UP</code>)으로 표시되었으나, Redis 연결 상태는 <code>DOWN</code>으로 나타났습니다. </p>
<p>이 원인에 대해서는 하단의 트러블슈팅 항목에서 자세히 다루겠습니다.</p>
<p>Spring Security를 사용하는 환경에서는 Actuator 경로를 인증 없이 접근할 수 있도록 설정해줘야 합니다. 이는 <code>SecurityFilterChain</code>에서 다음과 같이 처리할 수 있습니다.</p>
<pre><code class="language-java">@Bean
public SecurityFilterChain apiSecurityChain(HttpSecurity http) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(sm -&gt; sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(authz -&gt; authz
            .requestMatchers(ACTUATOR_PATTERN).permitAll()
            .requestMatchers(SWAGGER_PATTERNS).permitAll()
            .requestMatchers(STATIC_RESOURCES_PATTERNS).permitAll()
            .requestMatchers(PERMIT_ALL_PATTERNS).permitAll()
            .requestMatchers(PUBLIC_ENDPOINTS).permitAll()
            .requestMatchers(AUTH_ENDPOINTS).permitAll()
            .anyRequest().authenticated()
        )
        .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}</code></pre>
<p>위 설정은 <code>/actuator/**</code> 경로를 인증 없이 허용하며, 나머지 API 경로에 대해서는 인증된 사용자만 접근할 수 있도록 구성합니다.</p>
<hr>
<h2 id="🚨-트러블슈팅">🚨 트러블슈팅</h2>
<h3 id="docker-이미지-재빌드--build-문제">Docker 이미지 재빌드(--build) 문제</h3>
<p>소스 코드 수정 후, Docker Compose를 통해 컨테이너를 재기동했지만 변경 사항이 반영되지 않는 경우가 있었습니다. 
이는 Dockerfile에서 사용하는 <code>.jar</code> 파일이 갱신되지 않고 캐시된 이전 파일을 사용하기 때문입니다.</p>
<p>기본 Dockerfile은 다음과 같습니다.</p>
<pre><code class="language-dockerfile">FROM openjdk:21-jdk
COPY build/libs/my-board-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]</code></pre>
<p><code>docker compose up -d --build</code> 명령어는 Dockerfile 자체나 <code>.jar</code> 파일이 변경될 때만 새로운 이미지를 빌드합니다. </p>
<p>따라서 코드 변경 후 다음과 같은 절차를 반드시 수행해야 합니다.</p>
<ol>
<li><code>./gradlew clean build -x test</code> 명령어로 최신 <code>.jar</code> 파일을 생성합니다.</li>
<li>이후 <code>docker compose up -d --build</code>로 이미지를 재빌드하고 컨테이너를 재시작합니다.</li>
</ol>
<h3 id="prometheus-health-down-redis-연결-문제">Prometheus Health DOWN (Redis 연결 문제)</h3>
<p>Prometheus가 Spring Boot 애플리케이션의 메트릭은 정상적으로 수집하였지만, <code>/actuator/health</code> 엔드포인트에서 상태가 <code>DOWN</code>으로 나타났습니다. </p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/cde007cd-6814-4854-823d-e14072a9ff13/image.png" alt=""></p>
<p>Spring Actuator을 통해 로그를 분석해본 결과, Redis 연결 실패 (<code>RedisConnectionFailureException</code>)가 원인이었습니다.</p>
<p>이는 애플리케이션 내부에서 Redis 호스트 정보를 <code>localhost</code>로 하드코딩하여, 컨테이너 내부 네트워크에서 Redis를 인식하지 못한 것이 문제였습니다.</p>
<p>아래처럼 설정을 변경하여 해결하였습니다.</p>
<pre><code class="language-java">// 수정 전
@Value(&quot;localhost&quot;) private String redisHost;
@Value(&quot;6379&quot;)      private int redisPort;

// 수정 후
@Value(&quot;${spring.data.redis.host}&quot;) private String redisHost;
@Value(&quot;${spring.data.redis.port}&quot;) private int redisPort;</code></pre>
<p>이후 <code>application-docker.yml</code>과 <code>docker-compose.yml</code>에서 다음과 같이 환경 변수를 설정하여 컨테이너 간 통신이 정상적으로 이루어지게 하였습니다.</p>
<pre><code class="language-yml">SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379</code></pre>
<p>이렇게 설정을 수정한 후 다시 실행했을 때, Health 상태가 정상적으로 <code>UP</code>으로 변경되는 것을 확인할 수 있었습니다.</p>
<hr>
<p>이번 글에서는 Docker Compose를 활용한 Prometheus-Grafana 모니터링 환경 구축 과정과 발생했던 주요 트러블슈팅 사항을 정리하였습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Redis 역직렬화 미친(positive) 삽질기]]></title>
            <link>https://velog.io/@dw_db/MyBoard-5-%EA%B2%8C%EC%8B%9C%EA%B8%80-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B0%8F-%EC%A7%81%EB%A0%AC%ED%99%94%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94-%EC%97%90%EB%9F%AC-Cacheable-LocalDateTime-LinkedHashMap-As.WRAPPERARRAY</link>
            <guid>https://velog.io/@dw_db/MyBoard-5-%EA%B2%8C%EC%8B%9C%EA%B8%80-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B0%8F-%EC%A7%81%EB%A0%AC%ED%99%94%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94-%EC%97%90%EB%9F%AC-Cacheable-LocalDateTime-LinkedHashMap-As.WRAPPERARRAY</guid>
            <pubDate>Sun, 01 Jun 2025 07:54:51 GMT</pubDate>
            <description><![CDATA[<p><em>Redis 캐싱 적용 → 성능이 얼마나 달라졌을까? + 직렬화 삽질 기록</em></p>
<blockquote>
<ul>
<li>게시글이 10 건일 땐 “캐시 있든 없든 그게 그거”</li>
<li>10 만 건으로 늘리자 평균 응답 -63 %, RPS +12 % 개선!</li>
<li>적용 과정에서 터졌던 <code>LocalDateTime</code>, <code>LinkedHashMap</code>, <code>WRAPPER_ARRAY</code> 오류 해결법까지 정리했습니다.</li>
</ul>
</blockquote>
<p>게시글은 특성상 생성 및 수정보다 조회가 더 빈번하다고 생각하여 캐싱을 통한 읽기 성능 최적화하기 적합한 도메인이라고 판단하였습니다.</p>
<p>이후 k6와 postman을 통한 테스트 결과와 캐싱 과정에서의 3가지 트러블 슈팅 과정을 이어서 작성하겠습니다.</p>
<p>캐싱 전/후 코드는 다음과 같습니다.</p>
<p><strong>캐싱 전</strong></p>
<pre><code class="language-java">public ArticleListResponse findAll(Pageable pageable) {
    Page&lt;Article&gt; articles = articleQueryService.findAll(pageable);
    List&lt;ArticleResponse&gt; response = articles.stream()
        .map(ArticleResponse::from)
        .toList();

    return ArticleListResponse.from(response);
}</code></pre>
<p><strong>캐싱 후</strong></p>
<pre><code class="language-java">@Cacheable(value = &quot;articles::all&quot;, key = &quot;&#39;p:&#39; + #pageable.pageNumber + &#39;:s:&#39; + #pageable.pageSize&quot;)
public ArticleListResponse findAll(Pageable pageable) {
    Page&lt;Article&gt; articles = articleQueryService.findAll(pageable);
    List&lt;ArticleResponse&gt; response = articles.stream()
        .map(ArticleResponse::from)
        .toList();

    return ArticleListResponse.from(response);
}</code></pre>
<p>Spring Cache Abstraction에서 제공하는 캐싱 저장 및 조회 어노테이션은 다음과 같습니다
<img src=https://velog.velcdn.com/images/dw_db/post/313fe969-52e7-4afa-a1b8-6ce263375a48/image.png width=80%></p>
<p>이 중에서 캐시를 저장할 수 있는 기능을 제공하는 어노테이션은 <code>@Cachable</code>과 <code>@Cacheput</code> 입니다. </p>
<ul>
<li><code>@Cacheable</code><ul>
<li>&#39;캐시가 존재하지 않을 경우&#39; 캐시를 저장</li>
<li>캐시 존재시 메서드 호출 전 실행</li>
<li>캐시 미 존재시 메서드 호출 후 실행</li>
</ul>
</li>
<li><code>@Cacheput</code><ul>
<li>&#39;캐시의 존재여부를 떠나서&#39; 항상 새롭게 캐시를 저장</li>
<li>캐시 존재시 메서드 호출 후 실행</li>
<li>캐시 미 존재시 메서드 호출 후 실행</li>
</ul>
</li>
</ul>
<p>&quot;메서드 호출 후&quot; 실행의 의미는 db i/o 부하가 그만큼 더 많이 발생한다는 뜻입니다
따라서 캐시가 존재할 시 db read 작업을 줄일 수 있고, 간편하게 조회할 수 있는 <code>@Cacheable</code>을 사용하려고 합니다.</p>
<blockquote>
<p>자세한 정보는 아래 <strong>공식 레퍼런스</strong>에서 확인할 수 있습니다.
<a href = https://docs.spring.io/spring-framework/reference/integration/cache/annotations.html>Declarative Annotation-based Caching
</a></p>
</blockquote>
<hr>
<h2 id="데이터셋-설정">데이터셋 설정</h2>
<h3 id="데이터셋-10개">데이터셋 10개</h3>
<p>게시글 초기 데이터셋을 insert 쿼리를 통해 10개정도만 넣었을때는 캐싱 전/후 근소한 차이만 존재하고,
눈에 띄는 차이는 확인할 수 없었습니다.</p>
<h4 id="캐싱-전">캐싱 전</h4>
<p><img src="https://velog.velcdn.com/images/dw_db/post/752b3eaa-8f1c-46e7-b0aa-7925caeb8318/image.png" alt=""></p>
<h4 id="캐싱-후">캐싱 후</h4>
<p><img src="https://velog.velcdn.com/images/dw_db/post/15cffeaf-b17b-4420-9353-41907cd2d6e2/image.png" alt=""></p>
<hr>
<h3 id="데이터셋-100000개">데이터셋 100,000개</h3>
<p>표본이 부족하여 DB에서 읽어올때 비교적 빨리 찾을 수 있기 때문에 차이가 나지 않는것이라고 생각하였습니다.
따라서 데이터셋을 100,000개로 늘려서 테스트를 진행하였고, 아래에서 결과 지표를 분석해보겠습니다.</p>
<h4 id="캐싱-전-1">캐싱 전</h4>
<p>초기 250ms
<img src="https://velog.velcdn.com/images/dw_db/post/ff17d4ad-a932-4ed3-afa8-e01eb94be394/image.png" alt=""></p>
<p>이후 반복 조회에도 평균 57ms 측정 확인
<img src="https://velog.velcdn.com/images/dw_db/post/1b4be8e3-209e-42a1-9864-5a3d3071a23e/image.png" alt=""></p>
<p>k6 테스트 결과
<img src="https://velog.velcdn.com/images/dw_db/post/5d57ccc1-cc96-4b26-b780-63e71a13dd67/image.png" alt=""></p>
<h4 id="캐싱-후-1">캐싱 후</h4>
<p>초기에는 캐시미스로 인해 268ms 시간 발생 -&gt; 캐싱 사용전(250ms)보다 많은 시간을 소요한 것을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/dw_db/post/60e01788-7e09-4fe3-9bdf-344114393a24/image.png" alt=""></p>
<p>cache miss로 인해 redis에 해당 데이터셋이 새롭게 적재되었고, 이후에는 cache hit를 기대할 수 있습니다.
<img src="https://velog.velcdn.com/images/dw_db/post/2bf5ea7c-75e5-4d99-a4a7-9ccb07781604/image.png" alt=""></p>
<p>포스트맨으로 확인 -&gt; 19ms로 캐싱 적용 전 (57ms) 보다 대폭 향상된 것을 확인하였습니다.
<img src="https://velog.velcdn.com/images/dw_db/post/81fedf52-37ac-4f7c-adc7-76a520f5550c/image.png" alt=""></p>
<p>k6 테스트 결과
<img src="https://velog.velcdn.com/images/dw_db/post/1bcef024-2e41-424f-8e24-a367bebdedf9/image.png" alt=""></p>
<h3 id="핵심-지표">핵심 지표</h3>
<ol>
<li><p><strong>평균 지연 63 % 감소</strong>  </p>
<ul>
<li>DB 조회 → Redis 메모리 히트로 전환되며 평균 22 ms → 8 ms.  </li>
<li>p90/p95도 30 ms대 → 10 ms대 초반으로 안정화 ⇒ <strong>꼬리 지연 완화</strong>.</li>
</ul>
</li>
<li><p><strong>처리량(RPS) 12 % 증가</strong>  </p>
<ul>
<li>같은 50 VU에서도 Redis가 I/O 경합을 줄여 초당 50여 건 추가 처리.</li>
</ul>
</li>
<li><p><strong><code>iteration_duration</code> 감소폭이 작다</strong>  </p>
<ul>
<li>스크립트에 think-time 0.1 s가 고정돼 있어, 응답이 빨라져도 루프 전체가 100 ms 이상을 소비.  </li>
<li>(실제 사용자 체감은 <code>http_req_duration</code>으로 판단한다고 합니다)</li>
</ul>
</li>
<li><p><strong>최대 지연치는 오히려 상승</strong> </p>
<ul>
<li>캐싱 전 max ≈ 178 ms</li>
<li>캐싱 후 max ≈ 409 ms는 <strong>캐시 미스가 1 회</strong> 발생했음을 의미.  </li>
<li><code>@Cacheable</code> 을 사용하여 캐시 조회/저장을 구현하였고, 해당 어노테이션의 특성은 다음과 같다<ul>
<li>캐시 존재시: 메서드 호출 전 실행 </li>
<li>캐시 미존재시: 메서드 호출 후 실행</li>
<li>따라서 DB에서 데이터를 가져온 후, 캐시 서버에서 다시 조회하므로, 초기 조회시 <code>@Caching</code> 사용 전 보다 후가 약간 더 지연되는것이라 추정됩니다.</li>
</ul>
</li>
<li>DB-hit→캐시 저장 구간이 존재하므로, 최악 지연을 줄이려면 <strong>cache-warming / TTL 전략</strong> 고려하여 추후 개선할 수 있을 것 같습니다.</li>
</ul>
</li>
</ol>
<hr>
<p>이제.. <code>@Cacheable</code>을 적용하며 <strong>삽질했던 각종 에러</strong>들을 정리하려고합니다.
<del>(사실 이제부터 포스팅의 핵심)</del></p>
<h1 id="각종-트러블-슈팅-처리">각종 트러블 슈팅 처리</h1>
<h3 id="localdatetime">LocalDateTime</h3>
<h4 id="🚨에러-로그">🚨에러 로그</h4>
<p><code>com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type java.time.LocalDateTime is not supported by default. Add the module &quot;com.fasterxml.jackson.datatype:jackson-datatype-jsr310&quot; to enable handling.</code></p>
<h4 id="문제-원인">문제 원인</h4>
<p>스프링 데이터 Redis의 <code>GenericJackson2JsonRedisSerializer</code>는 내부적으로 Jackson의 기본 <code>ObjectMapper</code>를 사용합니다.  </p>
<p>기본 <code>ObjectMapper</code>는 Java 8 날짜/시간 API(<code>LocalDate</code>, <code>LocalDateTime</code> 등)를 직렬화 및 역직렬화할 수 있는 모듈이 등록되어 있지 않아 오류가 발생합니다.</p>
<ul>
<li><strong>일반 Jackson</strong>: 스프링 부트 기본 설정에서 <code>jackson-datatype-jsr310</code> 모듈이 자동 등록되어 <code>LocalDateTime</code>을 바로 처리  </li>
<li><strong>Generic Jackson</strong>: 별도의 <code>ObjectMapper</code>를 생성해야 하며, 커스텀 모듈 등록이 필요</li>
</ul>
<h4 id="해결-방법">해결 방법</h4>
<p><code>ObjectMapper</code>에 <code>JavaTimeModule</code>을 등록해주면 <code>LocalDateTime</code> 타입을 처리할 수 있습니다. 
다음과 같이 <code>RedisTemplate</code>과 <code>RedisCacheManager</code>에 전달할 <code>ObjectMapper</code>를 직접 생성하였습니다.</p>
<ul>
<li>일반 잭슨은 기본 오브젝트 매퍼를 통해 직렬화/역직렬화 자동</li>
<li>제너릭 잭슨시 오브젝터 매퍼 커스텀이 필요함</li>
<li><code>new GenericJackson2JsonRedisSerializer(objectMapper())</code></li>
</ul>
<pre><code class="language-java">public ObjectMapper objectMapper() {
    BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
        .allowIfBaseType(Object.class)
        .build();

    return new ObjectMapper()
        .registerModule(new JavaTimeModule())
        .activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL);
}</code></pre>
<p><code>@Bean</code> 을 사용하지 않은 이유는 Redis 직렬화와 스프링 캐시 부분만 따로 커스터마이징할 목적이라면, 굳이 글로벌 빈으로 등록할 필요 없이 위처럼 objectMapper()를 직접 호출해 사용하는 방법도 전혀 문제되지 않는다고 하여 Bean으로 등록하지 않았습니다.</p>
<hr>
<h3 id="linkedhashmap">LinkedHashMap</h3>
<h4 id="🚨에러-로그-1">🚨에러 로그</h4>
<p><code>java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class</code></p>
<h4 id="문제-원인-1">문제 원인</h4>
<p>Redis 캐시에서 꺼낸 JSON 데이터를 Jackson이 타입 정보를 알지 못한 채 역직렬화하면 기본적으로 <code>LinkedHashMap</code> 객체로 만들어집니다.
따라서 실제 DTO(<code>ArticleResponse</code>)로 바로 캐스팅하려 하면 <code>ClassCastException</code>이 발생합니다.</p>
<h4 id="해결-방법-1">해결 방법</h4>
<p>Jackson에 <strong>객체 타입 정보를 함께 저장</strong> 하도록 설정해야 합니다. 
이를 위해 <code>activateDefaultTyping(...)</code>을 사용해 <code>ObjectMapper</code>에 폴리모픽 타입 정보를 포함시킵니다. </p>
<pre><code class="language-java">return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL);</code></pre>
<p>이후 커스텀한 <code>ObjectMapper</code>를 직렬화에 사용하여, 다음과 같은 캐시 저장 정보를 받을 수 있었습니다.</p>
<pre><code class="language-java">[&quot;com.servertech.myboard.article.application.dto.response.ArticleListResponse&quot;,
  {
    &quot;articles&quot;: [
      &quot;java.util.ImmutableCollections$ListN&quot;,
      [
        [&quot;com.servertech.myboard.article.application.dto.response.ArticleResponse&quot;,
          {&quot;id&quot;:1,&quot;title&quot;:&quot;게시글1&quot;,&quot;author&quot;:&quot;tom&quot;}
        ]
      ]
    ]
  }
]</code></pre>
<ul>
<li><p>첫 번째 요소(<code>com.servertech...ArticleListResponse</code>)
→ 이 캐시 값이 원래 어떤 타입(클래스)인지 알려주는 <code>@class</code> 정보 역할을 합니다.
→ 직렬화 시점에 <code>activateDefaultTyping</code>가 붙어 있으면, Jackson은 <code>JSON</code>을 배열(<code>WRAPPER_ARRAY</code>)로 감싸고 <strong>첫번째 자리에 클래스 풀 경로</strong>를 넣어 줍니다.</p>
</li>
<li><p>두 번째 요소( <code>{ &quot;articles&quot;: [ … ] }</code> )
→ 실제 <code>ArticleListResponse</code> 객체의 필드들이 들어 있는 JSON 객체 부분입니다.
→ 키 <code>&quot;articles&quot;</code> 아래에는 또 내부 리스트가 포함되어 있습니다.</p>
</li>
</ul>
<p>Redis에서 꺼낼 때 Jackson이 <strong>첫 번째 배열의 값</strong>을 보고 <code>ArticleListResponse</code> 타입으로 <strong>역직렬화</strong>할 수 있습니다.</p>
<p>아래는 실제 Redis에 저장된 key:value 값이고, <code>@class</code>정보가 앞에 붙어있는것을 알 수 있습니다.</p>
<img src=https://velog.velcdn.com/images/dw_db/post/dd480b0d-16e9-4246-b09f-2c050a282d40/image.png width=80%>

<p>게시글 추가 후 조회한 결과입니다.</p>
<img src=https://velog.velcdn.com/images/dw_db/post/04c4ed60-7a0d-42e1-a068-7447e919a81c/image.png width=80%>

<p><code>articles</code> 배열 안에 게시글 정보가 잘 추가된것을 확인할 수 있었고, Redis Insight를 통해 가시성 있게 <code>JSON</code>으로 인코딩하여 확인할 수 있었습니다.</p>
<hr>
<h3 id="aswrapper_array">AsWRAPPER_ARRAY</h3>
<h4 id="🚨에러-로그-2">🚨에러 로그</h4>
<p><code>expected START_ARRAY: need Array value to contain As.WRAPPER_ARRAY type information</code></p>
<h4 id="문제-원인-2">문제 원인</h4>
<p><code>record</code> 형태의 DTO는 내부적으로 불변(immutable) 속성만 가지고 있고, Jackson의 기본 폴리모픽 타입 처리 방식(<code>As.WRAPPER_ARRAY</code>)과 충돌을 일으킵니다.</p>
<p>구체적으로, <code>activateDefaultTyping(..., DefaultTyping.NON_FINAL)</code>를 사용하면 Jackson이 객체 직렬화 시 다음과 같은 <strong>배열 래퍼(WRAPPER_ARRAY)</strong> 구조를 만들어냅니다</p>
<p>즉, 바로 위 에러 처리 상황에서 봤던것처럼, </p>
<ul>
<li>첫째 요소에 클래스 이름</li>
<li>둘째 요소에 실제 필드</li>
</ul>
<p><strong>Java record</strong>는 Jackson이 이 배열 래퍼 구조를 예상대로 처리하지 못하여 <code>START_ARRAY</code>를 찾을 수 없다는 오류가 발생합니다.</p>
<h4 id="해결-방법-2">해결 방법</h4>
<p>문제를 해결하려면 </p>
<ul>
<li>배열 래퍼 방식 대신 객체형 포맷으로 폴리모픽 타입 정보를 직렬화</li>
<li><code>record</code> 대신 일반 <code>class</code>를 쓰되 Jackson이 JSON -&gt; 객체 매핑 시 필요한 생성자나 접근자를 확실히 제공</li>
</ul>
<p>위 두 가지 방법이 있습니다.
우선 이번 해결 과정에서는 비교적 간단한 두 번째 방법으로 문제를 해결하였습니다.</p>
<p>기존 record로 생성한 <code>ArticleResponse</code> 입니다.</p>
<pre><code class="language-java">@Builder
public record ArticleResponse(
    Long id,
    String title,
    String author
) implements Serializable {
    public static ArticleResponse from(Article article) {
        return ArticleResponse.builder()
            .id(article.getId())
            .title(article.getTitle())
            .author(article.getAuthor())
            .build();
    }
}</code></pre>
<p>아래는 적절한 어노테이션을 사용한 <code>ArticleResponse</code> 입니다.</p>
<pre><code class="language-java">@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ArticleResponse implements Serializable {
    private Long id;
    private String title;
    private String author;

    public static ArticleResponse from(Article article) {
        return ArticleResponse.builder()
            .id(article.getId())
            .title(article.getTitle())
            .author(article.getAuthor())
            .build();
    }
}</code></pre>
<ul>
<li><code>@NoArgsConstructor</code> : Jackson이 리플렉션으로 객체를 생성할 때 반드시 필요한 기본 생성자를 만들어 줍니다.</li>
<li><code>@Getter</code> : 프로퍼티를 JSON으로 직렬화할 때 getter 메서드를 통해 접근할 수 있어야 합니다.</li>
<li><code>@AllArgsConstructor</code> + <code>@Builder</code> : 빌더 패턴을 그대로 유지하면서도 생성자 인젝션이 가능합니다.</li>
</ul>
<p>이번 해결 과정에서는 단순히 <code>record</code> -&gt; <code>class</code> 변환을 통해 해결하였지만, 
다음에는 <strong>배열 래퍼 방식 대신 객체형 포맷으로 폴리모틱 타입 정보를 직렬화</strong> 하는 방법을 적용한 후 후기를 작성하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@AuthenticationPrincipal 인증정보 주입]]></title>
            <link>https://velog.io/@dw_db/MyBoard-4-AuthenticationPrincipal-%EC%9D%B8%EC%A6%9D%EC%A0%95%EB%B3%B4-%EC%A3%BC%EC%9E%85</link>
            <guid>https://velog.io/@dw_db/MyBoard-4-AuthenticationPrincipal-%EC%9D%B8%EC%A6%9D%EC%A0%95%EB%B3%B4-%EC%A3%BC%EC%9E%85</guid>
            <pubDate>Sat, 24 May 2025 14:39:25 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 MyBoard 프로젝트에서 로그인한 사용자의 정보를 간편하게 가져오는 방법으로 Spring Security의 <code>@AuthenticationPrincipal</code> 어노테이션을 사용하는 방법과 과정을 기록하려고 합니다.</p>
<p>게시글 작성 및 수정, 댓글 작성, 좋아요 등의 기능은 <strong>로그인한 사용자만 이용</strong>할 수 있도록 설계되었습니다. </p>
<p>따라서 로그인한 사용자의 정보를 간단하고 효과적으로 가져오는 방법이 필요했습니다.</p>
<hr>
<h2 id="로그인한-사용자-정보-가져오는-2가지-방법">로그인한 사용자 정보 가져오는 2가지 방법</h2>
<h3 id="1-직접-securitycontextholder를-사용하는-방법">1. 직접 SecurityContextHolder를 사용하는 방법</h3>
<pre><code class="language-java">SecurityContextHolder.getContext().getAuthentication().getPrincipal();</code></pre>
<p>이 방법은 직접적으로 Spring Security가 관리하는 <code>Principal</code> 또는 <code>UserDetails</code> 객체를 가져옵니다. 다만, 이 방법을 자주 사용할 경우 코드 중복이 많아지고 가독성이 떨어지는 단점이 있습니다.</p>
<h3 id="2-authenticationprincipal-사용하여-간접적으로-가져오는-방법">2. @AuthenticationPrincipal 사용하여 간접적으로 가져오는 방법</h3>
<pre><code class="language-java">@PostMapping(&quot;/articles&quot;)
public ResponseEntity&lt;ArticleResponse&gt; createArticle(
    @RequestBody CreateArticleRequest request,
    @AuthenticationPrincipal CustomUserDetails principal
) {
    ArticleResponse response = articleFacade.save(request, principal.getId());
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}</code></pre>
<p>두 번째 방법은 보다 깔끔하고 유지보수성이 뛰어나며 코드의 중복을 방지할 수 있습니다.</p>
<p>이번 프로젝트에서는 두 번째 방법을 사용했으며, 이 과정에서 Spring MVC의 ArgumentResolver 역할 및 구현체와 Spring Security의 인증 구조를 함께 정리하겠습니다.</p>
<hr>
<h2 id="authenticationprincipal-동작-원리">@AuthenticationPrincipal 동작 원리</h2>
<p><code>@AuthenticationPrincipal</code>을 처리하는 핵심은 바로 <code>AuthenticationPrincipalArgumentResolver</code> 입니다.</p>
<h3 id="authenticationprincipalargumentresolver란">AuthenticationPrincipalArgumentResolver란?</h3>
<p><img src="https://velog.velcdn.com/images/dw_db/post/b91daa3a-2d79-4fe0-a794-b7fd45cdb0ea/image.png" alt=""></p>
<p>Spring Security 4.0부터 제공되며, 최근에는 <code>org.springframework.security.web.method.annotation</code> 패키지에서 제공됩니다.</p>
<p><img src="https://velog.velcdn.com/images/dw_db/post/0c24b03a-477e-4177-9598-8f8c2fd31ff9/image.png" alt=""></p>
<p><code>AuthenticationPrincipalArgumentResolver</code>는 컨트롤러의 파라미터에서 <code>@AuthenticationPrincipal</code> 어노테이션이 붙어있는지를 확인하고, Spring Security의 <code>SecurityContextHolder</code>로부터 현재 로그인한 사용자의 정보를 가져와 자동으로 주입하는 역할을 하는 <strong><code>HandlerMethodArgumentResolver</code> 구현체</strong>입니다.</p>
<h3 id="argumentresolver-">ArgumentResolver ?</h3>
<p><strong>ArgumentResolver</strong> 라는 네이밍이 낯이 있어서 이전에 공부했던 강의를 다시 찾아봤습니다.</p>
<ul>
<li>Argument는 매개변수를 의미하고, Resolver는 단어 그대로 해결해준다는 의미이다</li>
<li>예에에엣날에 영한쌤 스프링 MVC 강의 처음 볼 때 <code>ArgumentResolver</code>라고 부르던 것이 바로 <code>HandlerMethodArgumentResolver</code>였다…</li>
</ul>
<p><code>HandlerMethodArgumentResolver</code> 를 상속받은 구현체는 대략 30개가 넘는것 같습니다.
그 중에서도 오늘 정리할 <code>AuthenticationPrincipalArgumentResolver</code>가 있는것을 확인할 수 있었습니다.</p>
<img src=https://velog.velcdn.com/images/dw_db/post/fa6db60a-c75d-424d-b1e2-ff3f09cdf93f/image.png>

<blockquote>
<p><code>ArgumentResolver</code>가 처리할 수 있는 어노테이션 기반의 요청인 파라미터는 아래 스프링 공식 레퍼런스에 나와있습니다.
<a href = https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/arguments.html>Method Arguments :: Spring Framework</a></p>
</blockquote>
<p>위 공식 레퍼런스의 <code>@AuthenticationPrincipal</code> 에 대한 정보입니다.
<img src=https://velog.velcdn.com/images/dw_db/post/3d413b5f-5307-4a9d-9916-8b5b763bb9ac/image.png></p>
<blockquote>
<p>Currently authenticated user — possibly a specific <code>Principal</code> implementation class if known.</p>
</blockquote>
<p>&quot;현재 인증된 사용자(Principal)”를 가리키며, 상황에 따라 <strong>특정 <code>Principal</code> 구현 클래스</strong>로 주입될 수도 있다.</p>
<blockquote>
<p>Note that this argument is not resolved eagerly, if it is annotated in order to allow a custom resolver to resolve it before falling back on default resolution via <code>HttpServletRequest#getUserPrincipal</code>.</p>
<p>For example, the Spring Security <code>Authentication</code> implements <code>Principal</code> and would be injected as such via <code>HttpServletRequest#getUserPrincipal</code>, unless it is also annotated with <code>@AuthenticationPrincipal</code> in which case it is resolved by a custom Spring Security resolver through <code>Authentication#getPrincipal</code>.</p>
</blockquote>
<p>이 매개변수(컨트롤러 파라미터)는 <strong>지연(lazy) 방식</strong>으로 값이 결정된다.</p>
<p>즉, 파라미터에 <strong>특정 어노테이션이 붙어 있으면</strong> 먼저 <strong>커스텀 ArgumentResolver</strong>가 처리할 기회를 갖고, 만약 커스텀 처리기가 없으면 <strong>기본 동작</strong>( <code>HttpServletRequest#getUserPrincipal</code>)으로 넘어간다.</p>
<p>예를 들어 Spring Security의 <code>Authentication</code> 객체는 <code>Principal</code>을 구현하므로, 일반적으로는 <code>HttpServletRequest#getUserPrincipal</code>을 통해 그대로 주입된다.
하지만 파라미터에 <code>@AuthenticationPrincipal</code>이 붙어 있으면 <strong>Spring Security 전용 ArgumentResolver</strong>가 동작하여 <code>Authentication#getPrincipal</code> 값을 주입한다.</p>
<p><strong>Spring Security 전용 ArgumentResolver</strong>가 바로 <code>AuthenticationPrincipalArgumentResolver</code>인 것 같습니다.</p>
<h3 id="authenticationprincipalargumentresolver-내부-동작">AuthenticationPrincipalArgumentResolver 내부 동작</h3>
<p><img src="https://velog.velcdn.com/images/dw_db/post/c2ca06ad-f9fd-440f-ae24-571530dc9420/image.png" alt=""></p>
<ul>
<li><p><code>supprotsParameter()</code>: <del>파라미터에 <code>@AuthenticationPrincipal</code>이 붙어 있는지 확인합니다</del> 
주어진 메소드의 파라미터가 <code>ArgumentResolver</code>에서 지원하는 타입인지 검사합니다. 지원하면 <code>true</code>, 그렇지 않으면 <code>false</code>를 반환합니다.</p>
</li>
<li><p><code>resolverArgument()</code>: SecurityContext에서 principal을 꺼내 <strong>타입 캐스팅</strong> 후 반환합니다</p>
<ul>
<li><strong>null 처리</strong>
  <img src="https://velog.velcdn.com/images/dw_db/post/9efce1fb-0879-4968-b4c1-e82f0620e74b/image.png" alt=""><ul>
<li><code>Authentication</code> 또는 <code>principal</code> 자체가 <code>null</code> → <code>null</code> 반환</li>
<li>타입이 맞지 않으면 기본값은 <code>null</code> 반환, 
단 <code>@AuthenticationPrincipal(errorOnInvalidType = true)</code> 라면 <code>ClassCastException</code> 발생</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>그렇다면, <code>HandlerMethodArgumentResolver</code>의 <code>resolvedArguement</code>를 오버라이딩한 <code>AuthenticationPrincipalArgumentResolver</code> 의 메서드를 확인해보겠습니다.</p>
<img src=https://velog.velcdn.com/images/dw_db/post/d2a1a439-4235-4d76-904a-004088d5dda3/image.png>

<p>빨간 네모박스에서, SecuriyContext 에서 꺼내온 객체를 기반으로 <code>null</code>이 아니라면 <code>Principal</code>을 꺼내오는 코드를 확인할 수 있습니다.
또한 해당 어노테이션이 붙은 파라미터가 존재하는지 확인한 후, <code>Principal</code>객체를 반환합니다.</p>
<hr>
<h2 id="커스텀-메타-에너테이션으로-더-간결하게">커스텀 메타-에너테이션으로 더 간결하게</h2>
<p>자주 사용된다면, 아래와 같이 별도의 메타-애너테이션을 정의해서 더욱 간결하게 사용할 수 있습니다.</p>
<pre><code class="language-java">@Target({ ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal
public @interface CurrentUser {
}</code></pre>
<p>이를 적용하면 컨트롤러에서 더욱 직관적으로 로그인한 사용자 객체를 받을 수 있습니다.</p>
<pre><code class="language-java">@Controller
public class MyController {
   @GetMapping(&quot;/profile&quot;)
   public String profile(@CurrentUser CustomUserDetails userDetails) {
       // 로그인한 사용자 정보 사용
   }
}</code></pre>
<hr>
<h2 id="my-board-프로젝트의-customuserdetails-사용-이유">my-board 프로젝트의 CustomUserDetails 사용 이유</h2>
<p>MyBoard 프로젝트에서는 로그인 사용자 정보를 <code>CustomUserDetails</code>로 관리합니다.</p>
<h3 id="왜-별도의-customuserdetails를-사용할까">왜 별도의 CustomUserDetails를 사용할까?</h3>
<p>초기에는 User JPA 엔티티가 직접 <code>UserDetails</code>를 구현하도록 하였으나, 이 방법은 JPA 엔티티가 프레젠테이션 계층까지 직접 노출되어 트랜잭션 문제나 지연 로딩 문제, 프록시 객체 문제 등의 여러 가지 부작용을 유발할 수 있었습니다.</p>
<p>이러한 문제를 해결하고자 별도의 객체로 책임을 명확히 나눈 것이 바로 <code>CustomUserDetails</code>입니다.</p>
<h3 id="customuserdetails-코드-예시">CustomUserDetails 코드 예시</h3>
<pre><code class="language-java">@Getter
@EqualsAndHashCode
@RequiredArgsConstructor
@Builder
public class CustomUserDetails implements UserDetails {
    private final Long id;
    private final String email;
    private final String password;
    private final String username;
    private final Collection&lt;? extends GrantedAuthority&gt; authorities;
    private final boolean enabled;

    public static CustomUserDetails from(User user) {
        return CustomUserDetails.builder()
                .id(user.getId())
                .email(user.getEmail())
                .password(user.getPassword())
                .username(user.getUsername())
                .authorities(List.of(new SimpleGrantedAuthority(&quot;ROLE_USER&quot;)))
                .enabled(true)
                .build();
    }
}</code></pre>
<p>이 클래스를 통해 인증 계층과 데이터베이스 계층을 명확히 분리하며, <code>UserDetails</code>를 효과적으로 구현하여 Spring Security가 제공하는 인증·인가 기능을 최대한 활용할 수 있게 되었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT 인증 구현기(AccessToken, RefreshToken, Redis) ]]></title>
            <link>https://velog.io/@dw_db/MyBoard-3-JWT-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84%EA%B8%B0</link>
            <guid>https://velog.io/@dw_db/MyBoard-3-JWT-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84%EA%B8%B0</guid>
            <pubDate>Tue, 20 May 2025 06:33:56 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 MyBoard 프로젝트에 <strong>JWT 기반 인증</strong>을 도입한 과정을 단계별로 정리하려고 합니다.</p>
<ol>
<li>AccessToken(AT)을 구현한 방법을 소개</li>
<li>토큰 인증 과정에서의 의문점</li>
<li>AT만 사용할 때 발생하는 한계를 보완하기 위해 RefreshToken(RT)을 설계한 과정 4. 클라이언트에서 처리하는 과정</li>
<li>RT를 데이터베이스 대신 Redis 1차 캐시로 관리했을 때의 장점</li>
</ol>
<p>순서대로 내용을 정리하려고 합니다.</p>
<hr>
<h2 id="jwt-란-무엇인가">JWT 란 무엇인가?</h2>
<blockquote>
<p>JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the <strong>HMAC</strong> algorithm) or a public/private key pair using <strong>RSA</strong> or <strong>ECDSA</strong>.</p>
</blockquote>
<blockquote>
<p>Although JWTs can be encrypted to also provide secrecy between parties, we will focus on signed tokens. Signed tokens can verify the integrity of the claims contained within it, while encrypted tokens hide those claims from other parties. When tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it.</p>
</blockquote>
<h2 id="다양한-인증-방법-중-jwt를-채택한-이유">다양한 인증 방법 중 JWT를 채택한 이유</h2>
<p>웹 애플리케이션에서 가장 많이 사용하는 인증 방식은 서버에 세션을 저장하는 <strong>세션 기반</strong> 방법입니다.  </p>
<p>세션 기반은 보안성이 높고 강제 로그아웃이나 세션 만료를 서버에서 제어할 수 있다는 장점이 있지만, 서버 메모리나 DB에 세션 정보를 유지해야 하므로 부하가 커지고 여러 서버로 확장할 때 세션 동기화가 필요합니다.  </p>
<p>반면 <strong>JWT(Json Web Token)</strong> 는 토큰 자체에 사용자 ID나 권한(Role) 같은 클레임 정보를 담아 전송하고, 서버는 별도의 세션 저장 없이 토큰만 검증하면 되기 때문에 완전한 무상태(<strong>Stateless</strong>) API 구현이 가능합니다.  </p>
<p>또한 필터 단계에서 토큰 서명을 검증한 뒤 사용자의 권한 정보를 바로 꺼내 쓸 수 있어 <strong>성능과 확장성</strong> 측면에서도 적합하다고 판단하여 <strong>JWT</strong>를 선택했습니다.</p>
<hr>
<h2 id="jwt-구조와-인증-흐름">JWT 구조와 인증 흐름</h2>
<p>JWT는 크게 세 부분으로 이루어집니다.  </p>
<ul>
<li><strong>Header</strong>: 사용할 서명 알고리즘(alg)과 토큰 타입(typ)이 JSON 형태로 저장 </li>
<li><strong>Payload</strong>: <code>sub</code>(주로 사용자 식별자), <code>exp</code>(만료시간), <code>roles</code> 같은 클레임이 포함</li>
<li><strong>Signature</strong>: <code>Base64UrlEncode(header) + &quot;.&quot; + Base64UrlEncode(payload)</code>에 비밀키를 적용한 <code>HMACSHA256</code> 서명값</li>
</ul>
<p>인증 절차는 다음과 같습니다.</p>
<ol>
<li>클라이언트가 로그인 요청을 보내면 서버에서 사용자 자격 증명을 확인하고 AT를 발급합니다.  </li>
<li>클라이언트는 이후 모든 API 요청에 HTTP 헤더 <code>Authorization: Bearer &lt;AT&gt;</code>를 포함합니다.  </li>
<li>서버는 매 요청마다 JWT 필터를 통해 서명을 검증하고 클레임을 파싱해 사용자 정보를 SecurityContext에 저장합니다.  </li>
<li>SecurityContext에 저장된 사용자 ID나 권한을 바탕으로 비즈니스 로직을 처리한 뒤 응답을 반환합니다.</li>
</ol>
<hr>
<h2 id="accesstokenat-구현">AccessToken(AT) 구현</h2>
<p>MyBoard에서는 <code>JwtProvider</code>라는 컴포넌트에서 AT를 생성하고 파싱하도록 구현했습니다.<br>30분의 만료 시간을 가지는 AT를 아래 코드처럼 발급합니다</p>
<pre><code class="language-java">@Component
public class JwtProvider {
    private final SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(&quot;your-secret-key&quot;));
    private final long accessTokenValidity = 1000 * 60 * 30; // 30분

    public String generateAccessToken(Long userId, String username) {
        return Jwts.builder()
            .setSubject(userId.toString())
            .claim(&quot;username&quot;, username)
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + accessTokenValidity))
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();
    }

    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(HEADER_AUTHORIZATION);
        if (bearerToken != null &amp;&amp; bearerToken.startsWith(TOKEN_PREFIX)) {
            return bearerToken.substring(TOKEN_PREFIX.length());
        }
        return null;
    }

    public boolean validateToken(String token) {
        if (token == null) {
            return false;
        }

        Jwts.parser()
            .setSigningKey(jwtProperties.getSecretKey())
            .parseClaimsJws(token);
        return true;
    }

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(getClaims(token).getSubject());
        return new UsernamePasswordAuthenticationToken(userDetails, &quot;&quot;, userDetails.getAuthorities());
    }
}</code></pre>
<p>이렇게 생성된 토큰은 <code>JwtAuthenticationFilter</code>에서 헤더를 확인한 뒤 파싱하여 인증 객체로 변환합니다.</p>
<p>필터는 <code>OncePerRequestFilter</code>를 상속받아 모든 요청에 대해 한 번만 실행됩니다</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain)
        throws ServletException, IOException {

        String token = jwtProvider.resolveToken(request);

        if (jwtProvider.validateToken(token)) {
            Authentication authentication = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}</code></pre>
<ol>
<li>요청 도착 -&gt; 헤더에서 토큰 추출</li>
<li><code>validate(token)</code> 을 통해 토큰 생성시 <code>secretKey</code> 값과 같은지 확인 </li>
<li>2번의 과정이 <code>true</code> 이면, 현재 스레드의 <code>SecurityContext</code>에 인증 정보 저장</li>
</ol>
<br>


<pre><code class="language-java">Jwts.parser()
            .setSigningKey(jwtProperties.getSecretKey())
            .parseClaimsJws(token);
        return true;</code></pre>
<h3 id="setsigningkey"><code>setSigningKey</code></h3>
<p>지금까지 <code>jwt</code>를 구현할때, 토큰을 검증하는 과정에서 <code>setSigningKey</code> 를 통해 서명을 검증하는 줄 알고 있었습니다.</p>
<p>아래 <code>Jwts.parseCliamsJws</code> 인터페이스를 뜯어보겠습니다.</p>
<h4 id="jwtsparseclaimsjwstoken">Jwts.parseClaimsJws(token)</h4>
<pre><code class="language-java">Jws&lt;Claims&gt; parseClaimsJws(String var1) throws ExpiredJwtException, 
                                            UnsupportedJwtException, 
                                            MalformedJwtException, 
                                            SignatureException, 
                                            IllegalArgumentException;</code></pre>
<ul>
<li><strong>ExpiredJwtException</strong>: 토큰의 exp(만료 시간)를 지났을 때</li>
<li><strong>UnsupportedJwtException</strong>: 지원하지 않는 형식의 JWT일 때</li>
<li><strong>MalformedJwtException</strong>: 토큰 포맷이 잘못됐을 때</li>
<li><strong>SignatureException</strong>: 서명 검증에 실패했을 때</li>
<li><strong>IllegalArgumentException</strong>: 입력값이 null이거나 빈 문자열일 때</li>
</ul>
<p>해당 인터페이스를 구현하는 메서드는 두 가지입니다.</p>
<p>1) <code>DefaultJwtParser.parseClaimsJws</code></p>
<pre><code class="language-java">public Jws&lt;Claims&gt; parseClaimsJws(String claimsJws) {
    return (Jws)this.parse(claimsJws, new JwtHandlerAdapter&lt;Jws&lt;Claims&gt;&gt;() {
        public Jws&lt;Claims&gt; onClaimsJws(Jws&lt;Claims&gt; jws) {
            return jws;
        }
    });
}</code></pre>
<p><code>DefaultJwtParser</code>는 공통 로직 위에 간단한 핸들러 어댑터를 씌운 얇은 래퍼(wrapper) 역할을합니다.</p>
<p>2)<code>ImmutableJwtParser.parseClaimsJws</code></p>
<pre><code class="language-java">public Jws&lt;Claims&gt; parseClaimsJws(String claimsJws) throws ExpiredJwtException, 
                                                            UnsupportedJwtException, 
                                                            MalformedJwtException, 
                                                            SignatureException, 
                                                            IllegalArgumentException {
    return this.jwtParser.parseClaimsJws(claimsJws);
}</code></pre>
<p><code>ImmutableJwtParser</code> 가 실제 검증을 담당합니다</p>
<p><code>setSigningKey</code> 를 통해 초기 생성시 사용한 키를 설정한 후 내부 로직에서 다양한 에러처리와 함께 토큰을 검증하는 것입니다.</p>
<hr>
<h2 id="at의-한계를-보완하기-위한-refreshtokenrt-설계">AT의 한계를 보완하기 위한 RefreshToken(RT) 설계</h2>
<p>AT는 만료 시간이 짧아 보안에는 유리하지만, 만료 후 다시 로그인해야 하는 불편함이 있습니다.
이를 해결하기 위해 AT와 함께 수명(expiresAt)이 긴 RT를 발급해 두고, AT가 만료되면 RT를 사용해 새로운 AT를 재발급하는 방식을 도입했습니다.</p>
<p>초기에는 RT를 refresh_tokens라는 엔티티로 DB에 관리했습니다:</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;refresh_tokens&quot;)
public class RefreshToken {
    @Id @GeneratedValue
    private Long id;
    private Long userId;
    private String token;
    private Instant expiresAt;
    ...
}</code></pre>
<p>로그인 시 <code>AT</code>와 <code>RT</code>를 동시에 생성해 저장하고, 클라이언트는 <code>AT</code> 만료 시 <code>/api/auth/refresh</code> 엔드포인트를 호출해 <code>RT</code>를 검증받고 새 <code>AT</code>를 발급받습니다.</p>
<p>이 방식으로 사용자는 재로그인 없이도 원활하게 인증을 유지할 수 있었습니다.</p>
<p>처음에는, 클라이언트가 <code>AT</code>가 만료됐을 경우 어떻게 <code>auth/refresh</code> 를 호출하는가에 대한 의문이 있었습니다. 아래는 인증/인가 관련 클라이언트 코드의 핵심 부분 예제입니다.</p>
<pre><code class="language-javascript">// 1) 요청 헤더에 AT 자동 추가
config.headers[&#39;Authorization&#39;] = `Bearer ${localStorage.getItem(&#39;accessToken&#39;)}`

// 2) 401 응답 감지 &amp; 단일 재시도 플래그
if (error.response?.status === 401 &amp;&amp; !originalRequest._retry) {
  originalRequest._retry = true

  // 3) RT로 새 AT 발급
  const { data } = await axios.post(&#39;/api/auth/refresh&#39;, {}, {
    headers: { Authorization: `Bearer ${localStorage.getItem(&#39;refreshToken&#39;)}` }
  })

  // 4) 로컬스토리지에 갱신된 AT 저장
  localStorage.setItem(&#39;accessToken&#39;, data.accessToken)

  // 5) 갱신된 AT로 원래 요청 재실행
  originalRequest.headers[&#39;Authorization&#39;] = `Bearer ${data.accessToken}`
  return axios(originalRequest)
}</code></pre>
<p><code>401</code>에러가 발생했음을 감지하고, <code>/api/auth/refresh</code> uri를 통해  <code>localStorage</code>에 보관하고 있던 <code>RT</code>를 헤더에 넣어 전달하게 됩니다.</p>
<p>서버에서는 <code>/api/auth/refresh</code>, <code>/api/auth/login</code> 시에 같은 <code>DTO</code>를 반환하므로 새롭게 <code>AT</code>, <code>RT</code>를 발급받을 수 있고, 새롭게 발급받은 <code>AT</code>를 다시 클라이언트의 <code>localStrage</code>에 담게됩니다.</p>
<hr>
<h2 id="rt-엔티티-관리에서-redis-1차-캐시로-전환했을-때의-장점">RT 엔티티 관리에서 Redis 1차 캐시로 전환했을 때의 장점</h2>
<p>DB에 RT를 저장하는 방식은 일관성 유지를 위해 편리하지만, 대규모 동시 요청이 몰릴 때 DB 부하가 커질 수 있습니다.</p>
<p>또한 만료된 토큰을 데이터베이스에서 수동으로 제거해야 된다는 번거로움이 발생할 수 있습니다.
따라서 Redis를 1차 캐시로 활용해 RT를 관리하는 구조로 개선했습니다.</p>
<p>Redis에 RT를 저장할 때는 <code>RedisTemplate</code>의 <code>opsForValue()</code> API를 사용해 간단히 키-값 형태로 저장하고 <strong>TTL(만료 시간)</strong>을 설정할 수 있습니다:</p>
<pre><code class="language-java">String accessToken = jwtProvider.generateAccessToken(user.getEmail());
String refreshToken = jwtProvider.generateRefreshToken(user.getEmail());

RefreshToken rt = RefreshToken.from(request.email(), refreshToken, RT_TTL);
refreshTokenStore.save(rt);

return JwtResponse.from(accessToken, refreshToken);</code></pre>
<p>로그인시 <code>AT</code>와 <code>RT</code>를 만료시간을 각각 설정하여 생성하고, 생성한 <code>RT</code>는 <code>refreshTokenStore</code>에 저장하였습니다.</p>
<pre><code class="language-java">//refreshTokenStore.class
private final StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = &quot;rt:&quot;;

@Override
public void save(RefreshToken rt) {
    redisTemplate.opsForValue().set(KEY_PREFIX + rt.email(), rt.value(), rt.ttl());
}</code></pre>
<h3 id="rt-저장시-stringredistemplate을-사용한-이유"><code>RT</code> 저장시 <code>StringRedisTemplate</code>을 사용한 이유</h3>
<ol>
<li>RT(email:key, token:value, TTL) 모두 문자열이므로 별도 Serializer 설정 없이 그대로 사용이 가능합니다.</li>
<li>성능 최적화: String 전용 Serializer는 다른 직렬화 방식보다 가볍고 빠릅니다.</li>
</ol>
<ul>
<li>복잡한 객체를 다루지 않고, 단순 키-값만 캐시하거나 토큰을 저장할 때는 <code>StringRedisTemplate</code>을 사용하는 것이 빠르다고 합니다.</li>
</ul>
<p><code>StringRedisTemplate</code> vs <code>RedisTemplate&lt;String,String&gt;</code>
같은 역할을 하지만, 후자를 선택하게 된다면</p>
<pre><code class="language-java">@Bean
public RedisTemplate&lt;String, String&gt; redisTemplate(RedisConnectionFactory cf) {
    RedisTemplate&lt;String, String&gt; rt = new RedisTemplate&lt;&gt;();
    rt.setConnectionFactory(cf);
    rt.setKeySerializer(new StringRedisSerializer());
    rt.setValueSerializer(new StringRedisSerializer());
    return rt;
}</code></pre>
<p>위 코드처럼, <code>RedisConfig</code> 설정시 직렬화를 적용하기 위해 해당 빈을 새롭게 구성해야 하는 번거로움이 있습니다.</p>
<hr>
<p>이번 글에서는 <code>JWT</code>를 사용한 사용자 인증과정 및 <code>RT</code>를 도입하여 UX의 편리함을 줄 수 있는 방법을 정리하였습니다</p>
<p>다음 글에서는 이번에 구현한 <code>JWT</code>를 통해 <code>SecurityContext</code>를 사용하여 어떤 작업을 할 수 있는지 알아보겠습니다.</p>
<blockquote>
<p>Reference: <a src = https://jwt.io/introduction#> Introduction to JSON Web Tokens </img></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[도메인 모델 설계, 어떤 고민을 했을까?
]]></title>
            <link>https://velog.io/@dw_db/MyBoard-2-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8-%EC%84%A4%EA%B3%84-%EC%96%B4%EB%96%A4-%EA%B3%A0%EB%AF%BC%EC%9D%84-%ED%96%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@dw_db/MyBoard-2-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8-%EC%84%A4%EA%B3%84-%EC%96%B4%EB%96%A4-%EA%B3%A0%EB%AF%BC%EC%9D%84-%ED%96%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Wed, 14 May 2025 12:00:38 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 MyBoard 프로젝트의 도메인 모델을 어떻게 설계했는지,<br>그 과정에서 어떤 고민을 했고 어떤 기준으로 구조를 결정했는지 정리해보려 합니다.</p>
<hr>
<h2 id="핵심-도메인-구성">핵심 도메인 구성</h2>
<p>MyBoard는 게시판 서비스로서 다음과 같은 주요 도메인으로 구성되어 있습니다.</p>
<ul>
<li><code>User</code>: 사용자 계정 정보를 관리</li>
<li><code>Article</code>: 게시글 정보 (제목, 내용, 작성자 등)</li>
<li><code>Comment</code>: 댓글 정보 (내용, 작성자, 게시글 참조)</li>
<li><code>ArticleLike</code>, <code>CommentLike</code>: 좋아요 기록을 위한 조인 테이블</li>
</ul>
<hr>
<h2 id="설계하면서-고민한-지점들">설계하면서 고민한 지점들</h2>
<h3 id="1-양방향-연관관계-사용-이유">1. 양방향 연관관계 사용 이유</h3>
<p>현재는 <code>User</code>와 <code>Article</code>, <code>Comment</code> 간에 <strong>양방향 연관관계</strong>를 사용하고 있습니다.</p>
<pre><code class="language-java">// User
@OneToMany(mappedBy = &quot;user&quot;)
private List&lt;Article&gt; articles;

// Article
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = &quot;user_id&quot;, nullable = false)
private User user;</code></pre>
<p>이 구조를 선택한 이유는 다음과 같습니다:</p>
<ul>
<li><strong>양방향 연관은 서비스나 테스트 코드에서 더 유연하게 활용할 수 있음</strong><ul>
<li>예: 사용자가 작성한 게시글 목록을 조회하는 경우</li>
<li>단방향보다 조회/응답 설계가 명확해질 수 있음</li>
</ul>
</li>
</ul>
<p>다만, 양방향 연관관계는 아래와 같은 점을 주의해야 합니다:</p>
<ul>
<li><strong>순환 참조 문제</strong> 발생 가능
→ DTO 변환 시 <code>@JsonManagedReference</code>, <code>@JsonBackReference</code> 또는 <code>ModelMapper</code> 설정이 필요</li>
<li><strong>엔티티 생명주기 관리가 어려워질 수 있음</strong></li>
</ul>
<p>현재는 JPA 기본 사용법을 익히고 연관 탐색을 실습하는 목적에서 양방향을 적용했지만,
<strong>필요에 따라 단방향으로 리팩토링할 계획</strong>도 가지고 있습니다.</p>
<hr>
<h3 id="2-좋아요-기능의-설계-기준">2. 좋아요 기능의 설계 기준</h3>
<p><code>ArticleLike</code>, <code>CommentLike</code>는 별도의 조인 테이블로 분리했습니다.</p>
<ul>
<li><code>(article_id, user_id)</code> / <code>(comment_id, user_id)</code> 복합 유니크 제약 조건을 걸어 중복 방지 
→ (추후, <strong>비즈니스 로직에서 중복을 체크하는 방식과 DB 유니크 제약의 필요성</strong>에 대한 고민을 따로 포스팅할 예정입니다)</li>
</ul>
<p>이렇게 설계하면 다음과 같은 이점이 있습니다:</p>
<ul>
<li>사용자가 어떤 글/댓글에 좋아요를 눌렀는지 기록 가능</li>
<li>추후 ‘내가 좋아요한 게시글 목록’ 등의 기능으로 확장 가능</li>
</ul>
<p>추가로, 성능을 고려하여 likeCount 필드를 도메인에 따로 두었습니다.</p>
<pre><code class="language-java">@Column(nullable = false)
private long likeCount = 0L;</code></pre>
<p>이는 좋아요 수를 매번 조인해서 계산하기보다,
<strong>실시간으로 증가/감소시키는 방식</strong>을 통해 빠르게 조회할 수 있도록 하기 위함입니다.</p>
<hr>
<h3 id="3-생성-메서드-도입으로-의도-명확화">3. 생성 메서드 도입으로 의도 명확화</h3>
<p>엔티티 생성 시 단순 생성자 대신 <strong>정적 팩토리 메서드</strong>를 사용했습니다.</p>
<pre><code class="language-java">public static User create(String email, String password, String username) {
    return User.builder()
        .email(email)
        .password(password)
        .username(username)
        .build();
}</code></pre>
<pre><code class="language-java">public static Article create(String title, String content, User user) {
    return Article.builder()
        .title(title)
        .content(content)
        .author(user.getUsername())
        .user(user)
        .likeCount(0L)
        .build();
}</code></pre>
<p>정적 팩토리 메서드는 객체 생성의 명확한 의도를 전달할 수 있고,
<strong>추가적인 유효성 검사나 로직 삽입이 용이한 장점</strong>이 있습니다.</p>
<hr>
<h3 id="4-basetimeentity로-공통-필드-관리">4. BaseTimeEntity로 공통 필드 관리</h3>
<p>모든 엔티티에 공통으로 필요한 <code>createdAt</code>, <code>updatedAt</code> 필드를
<code>@MappedSuperclass</code>로 정의된 <code>BaseTimeEntity</code>를 상속하여 관리하고 있습니다.</p>
<pre><code class="language-java">@Getter
@MappedSuperclass
public class BaseTimeEntity{
    @Column(nullable = false)
    private LocalDateTime createdAt;

    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    public void onCreate(){
        this.createdAt = LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    @PreUpdate
    public void onUpdate(){
        this.updatedAt = LocalDateTime.now();
    }
}</code></pre>
<p>이렇게 하면 각 엔티티에서 중복 없이
시간 정보를 자동으로 관리할 수 있어 유지보수가 편리합니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 도메인 설계에서는 연관관계 방향, 테이블 분리 기준, 생성 방식 등을 고민하며
단순히 돌아가는 코드가 아닌, 유지보수성과 확장성을 고려한 구조를 만들고자 했습니다.</p>
<p>아직 실무 수준의 복잡한 도메인은 아니지만,
향후 단방향 전환, 연관 최소화, 이벤트 기반 구조 전환 등도 실험해볼 계획입니다.</p>
<p>다음 글에서는 JWT 기반 인증/인가 구현기를 공유할 예정입니다.</p>
]]></description>
        </item>
    </channel>
</rss>