<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>o_z.log</title>
        <link>https://velog.io/</link>
        <description>트러블슈팅과 구현기를 위주로 기록합니다-</description>
        <lastBuildDate>Fri, 27 Feb 2026 05:56:32 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>o_z.log</title>
            <url>https://velog.velcdn.com/images/o_z/profile/3191170f-9379-44dd-b545-b6fed56f9189/image.JPG</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. o_z.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/o_z" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[SSL 인증서 자동 갱신 모니터링 구축하기 (Discord 알림)]]></title>
            <link>https://velog.io/@o_z/SSL-%EC%9D%B8%EC%A6%9D%EC%84%9C-%EB%B0%9C%EA%B8%89-%EC%84%B1%EA%B3%B5%EC%8B%A4%ED%8C%A8-%EC%8B%9C-Discord-%ED%8F%AC%EC%9B%8C%EB%94%A9-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@o_z/SSL-%EC%9D%B8%EC%A6%9D%EC%84%9C-%EB%B0%9C%EA%B8%89-%EC%84%B1%EA%B3%B5%EC%8B%A4%ED%8C%A8-%EC%8B%9C-Discord-%ED%8F%AC%EC%9B%8C%EB%94%A9-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 27 Feb 2026 05:56:32 GMT</pubDate>
            <description><![CDATA[<p>오늘 서비스의 cerbot SSL 인증서가 만료됐다. 만료된 사실을 우리 서비스에 직접 들어가고서야 알게 됐다. 만약 우리가 직접 서비스에 안들어가봤다면 들어갈 때까지 몰랐을 것이다. ssl 인증서 만료로 인해 서비스 전체가 마비됐고, 즉시 certbot renew로 살렸다. </p>
<p>이런 일이 재발하는 것을 예방하기 위해 자동 갱신 스케줄러를 추가했지만, 사후대책이 필요했다.
실제로 언제 갱신됐는지, 갱신이 실패하진 않았는지 매번 시스템 로그로 확인해야한다.</p>
<p>ssl 인증서가 갱신되는 타이밍을 더 쉽게 확인하고, 만약 모종의 이유로 실패한다면 이를 빠르게 개발자들에게 알리는 장치가 필요했다. 인증서 갱신 시도 시 실패가 발생하면 적어도 약 30일간의 복구 여유 기간이 있기 때문이다. (certbot 인증서 재발급은 만료기간으로부터 30일 전부터 할 수 있다.)</p>
<p>우리는 핵심 소통 창구로 디스코드를 사용하고 있으므로 <strong>Discord Webhook을 통해 인증서 발급 상태를 가시화 하고, 만료로 인한 서비스 중단 리스크를 낮추고자 한다.</strong></p>
<hr>
<h2 id="📝-알림-전송-파이프라인">📝 알림 전송 파이프라인</h2>
<p>큰 설계는 다음과 같다. (자동갱신은 systemd timer로 구성돼 있다.)</p>
<blockquote>
<ol>
<li>systemd timer가 정해진 시각에 <code>certbot-renew.service</code>를 실행한다.</li>
<li>certbot renew는 &quot;갱신 시도(실행)”만 하고, <strong>실제로 갱신이 필요할 때만</strong> 인증서를 갱신한다.</li>
<li><strong>실제로 갱신된 인증서가 있을 경우에만</strong>, certbot이 
<code>/etc/letsencrypt/renewal-hooks/deploy/*</code> 안의 실행 파일들을 <strong>순서대로 실행</strong>한다.</li>
<li><code>deploy-hook</code>에서<ul>
<li><code>nginx -t</code> → <code>nginx reload</code>를 수행하고,</li>
<li><strong>성공했을 때만 “성공 알림”을 디스코드로 전송한다.</strong></li>
</ul>
</li>
<li>certbot renew 자체가 실패하거나, <code>deploy-hook</code>에서 nginx reload가 실패하면 <strong>종료 코드는 0이 아닌 값</strong>으로 끝난다.</li>
<li>systemd는 이를 실패로 판정하고, <strong>OnFailure로 연결된 알림 서비스가 실패 알림</strong>을 디스코드로 전송한다.</li>
</ol>
</blockquote>
<h4 id="📍핵심-설계">📍핵심 설계</h4>
<ul>
<li><strong>성공 알림</strong>: &quot;갱신됨 + nginx reload까지 성공”일 때만</li>
<li><strong>실패 알림</strong>: <code>certbot-renew.service</code> 및 nginx reload가 실패(exit code ≠ 0)일 때</li>
</ul>
<hr>
<h2 id="1️⃣-discord-webhook-url-파일-추가하기">1️⃣ Discord Webhook url 파일 추가하기</h2>
<p>먼저 공통으로 사용할 Discord webhook url을 파일로 관리한다.</p>
<pre><code class="language-powershell">sudo install -d -m 700 /etc/discord
sudo tee /etc/discord/webhook.url &gt;/dev/null &lt;&lt;&#39;EOF&#39;
https://discord.com/api/webhooks/XXXX/YYYY
EOF
sudo chmod 600 /etc/discord/webhook.url</code></pre>
<hr>
<h2 id="2️⃣-갱신-성공-시-webhook-알림-전송하기">2️⃣ 갱신 성공 시 webhook 알림 전송하기</h2>
<p>성공 기준은 단순히 “certbot 갱신 성공”이 아니라:</p>
<blockquote>
<p><strong>certbot renew가 실제 갱신을 수행했고 + nginx reload까지 성공</strong></p>
</blockquote>
<p>그래서 deploy-hook을 2단으로 나눴다.</p>
<ul>
<li><code>10-reload-nginx.sh</code> : reload 성공 여부를 마커로 남김</li>
<li><code>20-notify-discord-success.sh</code> : 마커가 있을 때만 성공 알림 전송</li>
</ul>
<h3 id="2-1-10-reload-nginxsh-nginx-reload-성공-마커-남기기"><strong>2-1) 10-reload-nginx.sh: nginx reload 성공 마커 남기기</strong></h3>
<p><code>/etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh</code></p>
<pre><code class="language-shell">#!/bin/sh
set -e

# nginx 설정 문법 체크 (실패하면 reload 안 함)
nginx -t

# 무중단 reload
systemctl reload nginx

MARKER_DIR=&quot;/run/letsencrypt&quot;
mkdir -p &quot;$MARKER_DIR&quot;

# lineage 기반으로 고유 키 생성(여러 인증서/여러 서버에서도 충돌 방지)
KEY=&quot;$(printf &#39;%s&#39; &quot;${RENEWED_LINEAGE:-unknown}&quot; | sha256sum | awk &#39;{print $1}&#39;)&quot;
MARKER=&quot;$MARKER_DIR/nginx_reload_ok_${KEY}&quot;

date -Is &gt; &quot;$MARKER&quot;</code></pre>
<pre><code class="language-java">// 변경사항 저장 후 실행 권한 추가
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh</code></pre>
<blockquote>
<h4 id="❓-왜-run-디렉토리인가">❓ 왜 <code>/run</code> 디렉토리인가?</h4>
<p><code>/run</code>은 런타임 디렉토리라 재부팅 시 자동으로 비워진다. 
“이번 갱신 사이클에서만 쓰는 성공 신호”로 적합하다.</p>
</blockquote>
<h3 id="2-2-20-notify-discord-successsh-성공-알림-전송"><strong>2-2) 20-notify-discord-success.sh: 성공 알림 전송</strong></h3>
<p><code>/etc/letsencrypt/renewal-hooks/deploy/20-notify-discord-success.sh</code></p>
<ul>
<li>마커가 없으면 reload 성공 보장 불가로 조용히 종료한다.</li>
<li>갱신 시각 / 다음 만료 / 갱신 시작 가능 날짜만 embed로 전송한다.</li>
<li>성공 후 중복 알림 방지를 위해 마커는 삭제한다.<pre><code class="language-shell">#!/bin/sh
set -eu
</code></pre>
</li>
</ul>
<h1 id="-설정-">===== 설정 =====</h1>
<p>ENV_NAME=&quot;${ENV_NAME:-PROD}&quot;
TZ=&quot;Asia/Seoul&quot;
export TZ</p>
<p>WEBHOOK_URL=&quot;$(cat /etc/discord/webhook.url)&quot;
NOW_ISO=&quot;$(date -Is)&quot;
NOW_KST=&quot;$(date &#39;+%Y-%m-%d %H:%M:%S %Z&#39;)&quot;</p>
<p>LINEAGE=&quot;${RENEWED_LINEAGE:-}&quot;
CERT_NAME=&quot;$(basename &quot;$LINEAGE&quot;)&quot;
CERT_PEM=&quot;$LINEAGE/fullchain.pem&quot;</p>
<h1 id="-----중요-nginx-reload-성공-마커-확인-----">---- (중요) nginx reload 성공 마커 확인 ----</h1>
<p>MARKER_DIR=&quot;/run/letsencrypt&quot;
KEY=&quot;$(printf &#39;%s&#39; &quot;${RENEWED_LINEAGE:-unknown}&quot; | sha256sum | awk &#39;{print $1}&#39;)&quot;
MARKER=&quot;$MARKER_DIR/nginx_reload_ok_${KEY}&quot;</p>
<h1 id="마커가-없으면-성공-알림을-보내지-않음reload-성공-보장-불가">마커가 없으면 성공 알림을 보내지 않음(=reload 성공 보장 불가)</h1>
<p>[ -f &quot;$MARKER&quot; ] || exit 0</p>
<h1 id="-----renew_before_expiry-certbot-260-기본-30일-설정-있으면-override-----">---- renew_before_expiry (certbot 2.6.0 기본 30일, 설정 있으면 override) ----</h1>
<p>RENEW_BEFORE_DAYS=&quot;30&quot;
CONF=&quot;/etc/letsencrypt/renewal/${CERT_NAME}.conf&quot;
if [ -f &quot;$CONF&quot; ]; then
  v=&quot;$(grep -E &#39;^[[:space:]]<em>renew_before_expiry[[:space:]]</em>=&#39; &quot;$CONF&quot; <br>      | tail -n 1 | sed -E &#39;s/.<em>=[[:space:]]</em>([0-9]+).*/\1/&#39;)&quot;
  if echo &quot;$v&quot; | grep -Eq &#39;^[0-9]+$&#39;; then
    RENEW_BEFORE_DAYS=&quot;$v&quot;
  fi
fi</p>
<h1 id="-----만료일--갱신가능-시작일-계산-----">---- 만료일 / 갱신가능 시작일 계산 ----</h1>
<p>ENDDATE_RAW=&quot;$(openssl x509 -in &quot;$CERT_PEM&quot; -noout -enddate | cut -d= -f2)&quot;
EXPIRES_KST=&quot;$(date -d &quot;$ENDDATE_RAW&quot; &#39;+%Y-%m-%d %H:%M:%S KST&#39;)&quot;
RENEW_FROM_KST=&quot;$(date -d &quot;$ENDDATE_RAW - ${RENEW_BEFORE_DAYS} days&quot; &#39;+%Y-%m-%d %H:%M:%S KST&#39;)&quot;</p>
<h1 id="-----python이-읽을-수-있도록-환경변수-export-----">---- python이 읽을 수 있도록 환경변수 export ----</h1>
<p>export ENV_NAME NOW_ISO NOW_KST EXPIRES_KST RENEW_FROM_KST RENEW_BEFORE_DAYS</p>
<h1 id="-----디스코드-embed-전송-----">---- 디스코드 embed 전송 ----</h1>
<p>python3 - &lt;&lt;PY | curl -sS -H &quot;Content-Type: application/json&quot; -X POST -d @- &quot;$WEBHOOK_URL&quot; &gt;/dev/null
import json, os</p>
<p>env_name = os.environ[&quot;ENV_NAME&quot;]
now_iso = os.environ[&quot;NOW_ISO&quot;]
now_kst = os.environ[&quot;NOW_KST&quot;]
expires_kst = os.environ[&quot;EXPIRES_KST&quot;]
renew_from_kst = os.environ[&quot;RENEW_FROM_KST&quot;]
renew_before_days = os.environ[&quot;RENEW_BEFORE_DAYS&quot;]</p>
<p>embed = {
  &quot;title&quot;: f&quot;✅ [{env_name}] SSL 인증서 갱신 성공&quot;,
  &quot;description&quot;: &quot;Nginx reload 완료&quot;,
  &quot;color&quot;: 0x2ECC71,
  &quot;fields&quot;: [
    {&quot;name&quot;: &quot;🕐 갱신 시각&quot;, &quot;value&quot;: f&quot;<code>{now_kst}</code>&quot;, &quot;inline&quot;: True},
    {&quot;name&quot;: &quot;⏰ 다음 만료 예정&quot;, &quot;value&quot;: f&quot;<code>{expires_kst}</code>&quot;, &quot;inline&quot;: True},
    {&quot;name&quot;: &quot;📍 갱신 시작 가능&quot;, &quot;value&quot;: f&quot;<code>{renew_from_kst}</code>  <em>(만료 {renew_before_days}일 전)</em>&quot;, &quot;inline&quot;: False},
  ],
  &quot;timestamp&quot;: now_iso
}</p>
<p>print(json.dumps({&quot;embeds&quot;: [embed]}, ensure_ascii=False))
PY</p>
<h1 id="-----마커는-사용-후-제거중복-알림-방지-----">---- 마커는 사용 후 제거(중복 알림 방지) ----</h1>
<p>rm -f &quot;$MARKER&quot;</p>
<pre><code>
```bash
// 권한 추가
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/20-notify-discord-success.sh</code></pre><h3 id="2-3-성공-웹훅-테스트실제-갱신-없이"><strong>2-3) 성공 웹훅 테스트(실제 갱신 없이)</strong></h3>
<p>성공 알림은 “마커가 있어야” 전송되므로, 테스트는 다음처럼 한다.</p>
<pre><code class="language-bash">sudo ls -1 /etc/letsencrypt/live</code></pre>
<pre><code class="language-bash">LINEAGE=&quot;/etc/letsencrypt/live/example.com&quot;
KEY=&quot;$(printf &#39;%s&#39; &quot;$LINEAGE&quot; | sha256sum | awk &#39;{print $1}&#39;)&quot;

sudo mkdir -p /run/letsencrypt
date -Is | sudo tee &quot;/run/letsencrypt/nginx_reload_ok_${KEY}&quot; &gt;/dev/null

sudo env ENV_NAME=PROD RENEWED_LINEAGE=&quot;$LINEAGE&quot; \
  /etc/letsencrypt/renewal-hooks/deploy/20-notify-discord-success.sh</code></pre>
<hr>
<p>테스트 알림이 잘 도착했다! 지금은 갱신 시각, 다음 만료 날짜, 갱신 시작 가능 날짜가 언제인지 넣지 않아서 안떠있다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/ec5403fe-df22-46a8-868a-f9d8161d174f/image.png" alt=""></p>
<hr>
<h2 id="3️⃣-갱신-실패-시-알림-전송하기-systemd-onfailure">3️⃣ 갱신 실패 시 알림 전송하기 <strong>(systemd OnFailure)</strong></h2>
<p>성공은 certbot 훅으로 처리했지만, 실패는 <strong>systemd가 제일 잘 감지</strong>한다.</p>
<ul>
<li>certbot-renew.service가 실패하면(exit code ≠ 0)</li>
<li>OnFailure=certbot-renew-failed.service가 실행되고</li>
<li>디스코드로 실패 알림을 보낸다.</li>
</ul>
<h3 id="3-1-실패-알림-스크립트-만들기"><strong>3-1) 실패 알림 스크립트 만들기</strong></h3>
<p><code>/usr/local/bin/notify-certbot-renew-failure.sh</code></p>
<pre><code class="language-bash">sudo nano /usr/local/bin/notify-certbot-renew-failure.sh</code></pre>
<pre><code class="language-shell">#!/bin/sh
set -eu

TZ=&quot;Asia/Seoul&quot;
export TZ

WEBHOOK_URL=&quot;$(cat /etc/discord/webhook.url)&quot;
NOW_ISO=&quot;$(date -Is)&quot;
NOW_KST=&quot;$(date &#39;+%Y-%m-%d %H:%M:%S %Z&#39;)&quot;

# 실패 상태 요약
RESULT=&quot;$(systemctl show certbot-renew.service -p Result --value 2&gt;/dev/null || echo &quot;unknown&quot;)&quot;
CODE=&quot;$(systemctl show certbot-renew.service -p ExecMainCode --value 2&gt;/dev/null || echo &quot;unknown&quot;)&quot;
STATUS=&quot;$(systemctl show certbot-renew.service -p ExecMainStatus --value 2&gt;/dev/null || echo &quot;unknown&quot;)&quot;

# 최근 로그(너무 길면 디스코드 제한 걸려서 적당히 잘라 보냄)
JOURNAL=&quot;$(
  journalctl -u certbot-renew.service -n 60 --no-pager 2&gt;/dev/null || true
)&quot;
LELOG=&quot;$(
  tail -n 60 /var/log/letsencrypt/letsencrypt.log 2&gt;/dev/null || true
)&quot;

# JSON(Embed) 생성 + 전송
python3 - &lt;&lt;PY | curl -sS -H &quot;Content-Type: application/json&quot; -X POST -d @- &quot;$WEBHOOK_URL&quot; &gt;/dev/null
import json, os

def clip(s: str, max_len: int = 900) -&gt; str:
    s = (s or &quot;&quot;).strip()
    if len(s) &lt;= max_len:
        return s
    return &quot;…(truncated)…\n&quot; + s[-max_len:]

env_name = os.environ.get(&quot;ENV_NAME&quot;, &quot;PROD&quot;)
now_iso = os.environ.get(&quot;NOW_ISO&quot;, &quot;&quot;)
now_kst = os.environ.get(&quot;NOW_KST&quot;, &quot;&quot;)
result = os.environ.get(&quot;RESULT&quot;, &quot;unknown&quot;)
code = os.environ.get(&quot;CODE&quot;, &quot;unknown&quot;)
status = os.environ.get(&quot;STATUS&quot;, &quot;unknown&quot;)

journal = clip(os.environ.get(&quot;JOURNAL&quot;, &quot;&quot;), 900)
lelog = clip(os.environ.get(&quot;LELOG&quot;, &quot;&quot;), 900)

embed = {
  &quot;title&quot;: f&quot;🚨 [{env_name}] SSL 인증서 갱신 실패&quot;,
  &quot;description&quot;: &quot;certbot-renew.service 실행이 실패했습니다.&quot;,
  &quot;color&quot;: 0xE74C3C,  # red
  &quot;fields&quot;: [
    {&quot;name&quot;: &quot;발생 시각&quot;, &quot;value&quot;: f&quot;`{now_kst}`&quot;, &quot;inline&quot;: True},
    {&quot;name&quot;: &quot;서비스 상태&quot;, &quot;value&quot;: f&quot;`Result={result}, Code={code}, Status={status}`&quot;, &quot;inline&quot;: False},
    {&quot;name&quot;: &quot;최근 로그 (certbot-renew.service)&quot;, &quot;value&quot;: f&quot;```{journal}```&quot;, &quot;inline&quot;: False},
    {&quot;name&quot;: &quot;최근 로그 (/var/log/letsencrypt/letsencrypt.log)&quot;, &quot;value&quot;: f&quot;```{lelog}```&quot;, &quot;inline&quot;: False},
  ],
  &quot;footer&quot;: {&quot;text&quot;: &quot;systemd OnFailure • certbot renew&quot;},
  &quot;timestamp&quot;: now_iso
}

print(json.dumps({&quot;embeds&quot;: [embed]}, ensure_ascii=False))
PY</code></pre>
<pre><code class="language-bash">sudo chmod +x /usr/local/bin/notify-certbot-renew-failure.sh</code></pre>
<p>로그가 길면 디스코드 제한 때문에 일부 잘린다. 그래서 가장 핵심 원인에 밀접할 마지막 부분을 남기도록 했다.</p>
<h3 id="3-2-실패-알림-systemd-service-만들기"><strong>3-2) 실패 알림 systemd service 만들기</strong></h3>
<p><code>/etc/systemd/system/certbot-renew-failed.service</code></p>
<pre><code class="language-bash">sudo nano /etc/systemd/system/certbot-renew-failed.service</code></pre>
<pre><code class="language-shell">[Unit]
Description=Notify Discord when certbot-renew.service fails
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
Environment=ENV_NAME=PROD
ExecStart=/usr/local/bin/notify-certbot-renew-failure.sh</code></pre>
<h3 id="3-3-certbot-renewservice에-onfailure-연결하기"><strong>3-3) certbot-renew.service에 OnFailure 연결하기</strong></h3>
<pre><code class="language-bash">sudo systemctl edit certbot-renew.service</code></pre>
<pre><code class="language-shell">### Editing /etc/systemd/system/certbot-renew.service.d/override.conf
### Anything between here and the comment below will become the new contents of the file

# 여기에 아래처럼 추가
[Unit]
OnFailure=certbot-renew-failed.service

### Lines below this comment will be discarded
...</code></pre>
<pre><code class="language-bash">sudo systemctl daemon-reload // 반영하기</code></pre>
<h3 id="3-4-실패-알림-테스트"><strong>3-4) 실패 알림 테스트</strong></h3>
<p>실제 실패를 만들기 전에, <strong>실패 알림 서비스만 단독 실행</strong>해도 웹훅이 잘 가는지 검증 가능하다.</p>
<pre><code class="language-bash">sudo systemctl start certbot-renew-failed.service</code></pre>
<p>테스트 시 아래처럼 알림이 잘 왔다.
<img src="https://velog.velcdn.com/images/o_z/post/7f4464c5-a0da-4b85-882c-58bf73d5151e/image.png" alt=""></p>
<hr>
<p>이제 인증서 만료를 <strong>LB 서버에 직접 들어가서 발견</strong>하는 일은 줄어든다.</p>
<ul>
<li>인증서가 실제로 갱신되면 디스코드에 ✅ 성공 알림(갱신 시각/만료일/갱신 가능 시작일)이 온다.</li>
<li>갱신이 실패하면 systemd가 🚨 실패 알림을 보내서, 로그를 서버에 접속하지 않고도 바로 확인할 수 있다.</li>
</ul>
<p>운영 관점에서 중요한 건 “갱신 자체”보다 <strong>갱신 실패를 빠르게 감지하고 대응하는 체계</strong>였다. 비즈니스 로직 뿐 만 아니라, 이런 개발 인프라도 꾸준하게 개선해야 더 좋은 서비스를 제공할 수 있다는 것을 깨달은 경험이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[레거시 코드 리팩토링으로 OCP 지키기]]></title>
            <link>https://velog.io/@o_z/%EB%A0%88%EA%B1%B0%EC%8B%9C-%EC%BD%94%EB%93%9C-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81%EC%9C%BC%EB%A1%9C-OCP-%EC%A7%80%ED%82%A4%EA%B8%B0</link>
            <guid>https://velog.io/@o_z/%EB%A0%88%EA%B1%B0%EC%8B%9C-%EC%BD%94%EB%93%9C-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81%EC%9C%BC%EB%A1%9C-OCP-%EC%A7%80%ED%82%A4%EA%B8%B0</guid>
            <pubDate>Wed, 25 Feb 2026 12:12:19 GMT</pubDate>
            <description><![CDATA[<p>원래 fcm 서버의 코드는 뉴스레터 아티클의 도착을 알리는 알림 용도로 구현되었다. 당시 빠르게 기능을 구현하는 것에 초점이 맞추었다보니 다른 알림이 추가됐을 때의 상황을 많이 고려하지 못했다.</p>
<p>그리고 이번에 이벤트 시작 알림, 챌린지 시작 알림, 챌린지 TODO 미완료 알림까지 여러 종류의 알림이 늘어나게 되면서 fcm 서버 코드를 전반적으로 재사용할 수 없게 되었다. 코드 내에서 if-else/switch문을 반복하는 코드가 되어 확장에 매우 불리한 구조였고, 이 레거시를 리팩토링 해야겠다고 생각했다.</p>
<p>지금 구조를 유지하면 ‘알림 타입 증가’에 따라 변경 영향 범위가 계속 커져서, 지속적으로 개발 속도와 운영 안정성이 함께 떨어질 것이라 판단했다. 따라서 기존의 기능은 유지하되, 알림 별 정책을 도메인 별로 분리할 수 있도록 &quot;구조 정리&quot; 관점의 리팩토링을 진행하는 것이 좋을 것이다.</p>
<p>그래서 <strong>“확장성을 확보하자”</strong>는 목표로 fcm 레거시를 리팩토링 하기로 했다.</p>
<hr>
<h2 id="🚨-리팩토링-전-핵심-문제">🚨 리팩토링 전 핵심 문제</h2>
<p>리팩토링 전 fcm 서버 코드의 핵심 문제들은 다음과 같았다.</p>
<blockquote>
<ol>
<li>아티클 도착시 알림에 대해 도메인이 한정적인 코드이다. </li>
</ol>
<p><strong>메서드 파라미터로 articleId를 넘기는 등 다른 종류의 알림이 추가되면 재사용할 수 없는 메서드들이 많았다.</strong></p>
</blockquote>
<ol start="2">
<li>메세지 생성 로직이 data payload 생성 로직에 강결합 되어있어, <strong>알림 종류별로 payload를 다르게 만들 수 없었다.</strong><blockquote>
</blockquote>
</li>
<li><strong>알림의 상태를 변경하는 책임의 서비스가 아티클 도착 알림에 강결합</strong> 되어있어, 다른 종류의 알림이 추가될 시 상태 변경 서비스의 메서드가 선형으로 증가하는 구조이다.</li>
</ol>
<p>위 점들을 통합하면 <strong>“기존 알림 도메인과의 강결합”</strong>이 문제이며, 이로 인한 <strong>“확장성 부재”</strong>가 가장 큰 특징이다.</p>
<hr>
<h2 id="😶🌫️-기존-fcm-서버-코드-구조">😶‍🌫️ 기존 fcm 서버 코드 구조</h2>
<p>전체 흐름도는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/1a41834c-4ec3-4985-a1a3-41947b843b3d/image.png" alt=""></p>
<h3 id="1-notificationscheduler">1. NotificationScheduler</h3>
<ul>
<li>알림을 30초마다 전송하는 역할</li>
<li>백오프 시간을 지났으며 PENDING / FAILED 상태인 알림들을 가져와 전송/재전송을 시도</li>
<li>최대 재시도 횟수를 넘겼을 경우 Failed로 격리</li>
<li>시도하는 알림들은 NotificationProcessingService로 위임</li>
</ul>
<h3 id="2-notificationprocessingservice">2. NotificationProcessingService</h3>
<ul>
<li>받은 알림 전송에 대한 오케스트레이션 역할</li>
<li>알림 수신 동의 여부를 판단하고, FCM 토큰이 존재하는지 확인한 후 이 토큰들에게 알림 전송<ul>
<li>한 명의 회원 당 여러 개의 디바이스를 가질 수 있으므로 FCM 토큰은 여러 개 존재 가능</li>
</ul>
</li>
<li>알림 전송 후 결과로 알림 상태 업데이트</li>
</ul>
<h3 id="3-notificationsenderservice">3. NotificationSenderService</h3>
<ul>
<li>토큰들을 순회하며 디바이스 별 해당 알림을 전송</li>
<li>전송하면서 성공/실패/스킵 개수 결과들을 집계하는 역할</li>
</ul>
<h3 id="4-notificationservice">4. NotificationService</h3>
<ul>
<li>토큰/제목/내용 유효성 체크 후 Message를 만들어 fcm 전송 메서드를 호출하는 역할</li>
<li>토큰,제목,내용을 구성하고 payload에 notificationType을 설정</li>
</ul>
<h3 id="5-fcmnotificationsender">5. FcmNotificationSender</h3>
<ul>
<li>FirebaseMessaging의 send로 최종 전송하고 결과 반환하는 역할</li>
</ul>
<h3 id="6-notificationstatusservice">6. NotificationStatusService</h3>
<ul>
<li>알림 전송 결과에 따른 상태 지정 역할</li>
<li>성공/전체 스킵일 경우 SENT</li>
<li>전부 실패하면 FAILED 및 재시도 백오프 계산해서 nextRetryAt 업데이트</li>
</ul>
<h3 id="🤔-개선할-점">🤔 개선할 점</h3>
<h4 id="1-메세지-생성">1. 메세지 생성</h4>
<p>기존 코드에서 메세지 생성 역할을 <code>NotificationService</code>에서 수행하고 있었다. 메서드 파라미터로 <code>articleId</code>를 넘기고 <code>notificationType</code>으로 <code>ARTICLE</code>이 고정이었기에, 이를 추상화해 각 알림 타입에 맞는 데이터와 <code>notificationType</code>을 지정할 필요가 있었다.</p>
<h4 id="2-알림-상태-업데이트">2. 알림 상태 업데이트</h4>
<p>알림은 종류마다 중요도/재시도의미/시간에 따른 유효성이 모두 다르다. 이 정책들은 ‘알림’이라는 하나의 도메인이 아닌, 각 종류별 가져야할 도메인 특성일 것이다. 따라서 알림의 상태전이는 공통 로직이 아닌 도메인 별 로직으로 분리하는 것이 합리적이다.</p>
<p>아티클 알림은 전송에 성공하면 SENT로 상태를 업데이트하고 최대 시도 횟수 이상 실패하면 <code>FailedRepository</code>로 이동한다. 이 역할을 <code>NotificationStatusService</code>에서 하고 있다. 최대 횟수까지 실패한 알림의 경우 모니터링 및 원인 추적을 위해서 <code>FailedRepository</code>에 보관하는데, 모니터링용 데이터를 보관하기 위해서는 <code>lastError</code>, <code>failedAt</code>, <code>memberId</code>, 그리고 알림 타입을 저장하는 것이 모니터링에 효율적일 것이라고 판단한다. </p>
<p>지금 아티클 전용인 <code>FailedRepository</code>를 <code>NotificationFailedRepository</code> 하나로 통합하고 컬럼들을 더 통용적으로 구성해서 테이블도 효율적으로 관리할 수 있겠다고 생각했다.</p>
<h4 id="3-알림-카테고리-지정">3. 알림 카테고리 지정</h4>
<p>알림 수신 동의 여부를 판단하는 메서드가 <code>NotificationProcessingService</code>에 있다. 이 때 카테고리를 <code>ARTICLE</code>로 고정해 <code>NotificationSettingService</code>로 넘겨주고 있어서 다른 알림에 대한 설정 판단이 어려웠다. 이제 알림 별 카테고리를 파라미터로 넘겨주고 수신 동의 여부를 판단하는 것이 바람직할 것이다.</p>
<p>이들을 리팩토링해서 <strong>알림 전송의 공통 파이프라인은 유지하되, 
새로운 알림 종류가 늘어날수록 플러그인처럼 끼워넣듯이 클래스를 추가만 하면 되도록</strong> 구성하고자 했다.</p>
<hr>
<h2 id="1️⃣-notification-자체를-하나의-엔티티로-만들-것인가">1️⃣ Notification 자체를 하나의 엔티티로 만들 것인가?</h2>
<p><code>ArticleArrivedNotification</code> 외의 다른 알림들이 생겨나면서, “알림”이라는 도메인 자체에 대한 추상화에 고민했다. </p>
<p>알림이란 크게 수신자, 제목, 내용, 그리고 그 외에 발송 상태, 재시도 일시 등을 포함하는 도메인이다. </p>
<p>따라서 Notification이라는 큰 도메인 하나 아래, 각 카테고리 별 필요한 제목, 내용을 삽입하고 category를 컬럼으로 저장해 구분하는 방식을 고민하게 되었다.</p>
<blockquote>
<h4 id="1-notification-도메인-하나로-통일하기">1. Notification 도메인 하나로 통일하기</h4>
</blockquote>
<ul>
<li>장점: 하나의 테이블로 통일해 어떤 종류의 알림이 추가돼도 이 형태만 유지한다면 쉽게 추가할 수 있다.</li>
<li>단점: 알림 데이터의 정합성을 확보하기 어렵다(너무 자유로운 형식의 컬럼), 
현재 <code>ArrivedArticleNotification</code>의 테이블에서 마이그레이션 하는 것도 비용이 든다.<blockquote>
<h4 id="2-알림-종류-별로-알림-도메인을-추가하기">2. 알림 종류 별로 알림 도메인을 추가하기</h4>
</blockquote>
</li>
<li>장점: 각 도메인 알림 별 필요한 데이터를 컬럼으로 정규화할 수 있고, 정합성 확보가 좋다.
각 알림에 필요한 데이터만을 저장할 수 있다.</li>
<li>단점: 매번 알림 종류가 늘어날 때마다 엔티티 추가가 필요하다.</li>
</ul>
<p>결과적으로 <strong>2. 알림 종류 별로 알림 도메인을 추가하기</strong> 방식을 채택했다.</p>
<p>알림 도메인에 따라서 각자 필요한 데이터 컬럼이 다를 것이라고 생각했다. 예를 들자면 아티클 관련된 알림은 <code>articleId</code>를 data payload에 포함해주어야 하지만, 챌린지 관련 알림은 <code>challengeId</code>를 포함해주어야 한다.</p>
<p>“content 내용에 추가해주면 되는 것 아닌가?” 라는 의문이 생길 수 있지만, 이는 곧 알림 payload의 유효성 검증 역할을 알림 서버가 아닌 본서버가 갖게 해야하는 것이다. 본서버에서 비즈니스 로직을 돌리며 알림 데이터를 outbox 테이블에 저장해야하는데, 이 경우 <code>Notification</code>의 데이터를 삽입할 때부터 payload에 필요한 데이터를 본서버에서 책임을 갖고 있어야 하므로 강결합이 발생한다.</p>
<p>또한, 기존 아티클 알림 데이터를 마이그레이션 하는 것도 큰 비용이 발생할 것이라고 예상해 2번 방법을 택하게 되었다.</p>
<p>대신 <code>memberId</code>, 재시도 횟수나 다음 재시도 일시, <code>lastError</code> 등의 컬럼은 모든 알림이 공통적으로 필요한 컬럼이라고 판단해 <code>Notification</code> 추상클래스로 빼서 다른 알림 엔티티들이 이를 상속해 사용하도록 재구성했다. 그리고 <code>Notification</code> 추상클래스에 재시도 정책이나 백오프 정책을 메서드로 갖게 해 오버라이드 해서 구현할 수 있도록 구현했다. </p>
<pre><code class="language-java">@Getter
@MappedSuperclass
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public abstract class Notification extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected Long id;

    @Column(nullable = false)
    protected Long memberId;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    protected NotificationStatus status;

    @Column(nullable = false, columnDefinition = &quot;INT DEFAULT 0&quot;)
    protected int attempts = 0;

    protected LocalDateTime nextRetryAt;

    @Column(length = 1024)
    protected String lastError;

    ...

    /**
     * 기본은 재시도 없음. 필요한 경우 하위 클래스 override.
     */
    public boolean shouldRetry() {
        return attempts &lt; getMaxRetryAttempts();
    }

    /**
     * 기본은 0회(재시도 안 함). 필요한 경우 하위 클래스 override.
     */
    protected int getMaxRetryAttempts() {
        return 0;
    }

    /**
     * 기본은 재시도 없음. 필요한 경우 하위 클래스 override.
     */
    public LocalDateTime calculateNextRetryTime(int attempts) {
        return null;
    }
}</code></pre>
<p>이제는 새로운 종류의 알림이 추가되면 <code>Notification</code> 추상클래스를 상속하는 알림 엔티티를 추가해주면 된다.</p>
<hr>
<h2 id="2️⃣-메세지-생성의-추상화">2️⃣ 메세지 생성의 추상화</h2>
<p>각 알림 종류에 따라 메세지 생성을 다르게 할 수 있다. 이를 유연하게 지원하기 위해 전략패턴을 생각했다.</p>
<p>전략 패턴을 도입해 각 알림 도메인 별 지원하는 <code>MessageBuilder</code>를 갖도록 설계했다. <code>MessageBuilder</code>라는 인터페이스로 추상화 해 각 알림 도메인 별로 필요한 메세지를 생성하도록 구성했다.</p>
<pre><code class="language-java">public interface NotificationMessageBuilder {

    boolean supports(Notification notification);

    NotificationMessage build(Notification notification, MemberFcmToken token);
}</code></pre>
<p>위처럼 지원하는 <code>Notification</code> type을 <code>supports()</code>로 판단하고 필요한 <code>NotificationMessage</code>에 대해 <code>Notification</code>을 받아 구성할 수 있도록 구현한다. </p>
<p>예를 들어 챌린지 시작 알림의 <code>MessageBuilder</code>는 다음과 같다.</p>
<pre><code class="language-java">@Component
public class ChallengeStartMessageBuilder implements NotificationMessageBuilder {

    @Override
    public boolean supports(Notification notification) {
        return notification instanceof ChallengeStartNotification;
    }

    @Override
    public NotificationMessage build(Notification notification, MemberFcmToken token) {
        ChallengeStartNotification startNotification = (ChallengeStartNotification) notification;
        String title = startNotification.getChallengeName() + &quot; 오늘부터 시작!&quot;;
        String content = &quot;1일차가 열렸어요 ✅ 오늘 미션부터 가볍게 출발해요!&quot;;

        return NotificationMessage.builder()
                .recipient(token.getFcmToken())
                .title(title)
                .content(content)
                .type(NotificationType.FCM)
                .data(Map.of(
                        &quot;challengeId&quot;, String.valueOf(startNotification.getChallengeId()),
                        &quot;notificationType&quot;, NotificationPayloadType.CHALLENGE_START
                ))
                .build();
    }
}</code></pre>
<p><code>MessageBuilder</code>의 역할은 자신의 알림 도메인을 가지고 실제 알림 전송을 위해 필요한 <code>NotificationMessage</code>를 구성하는 역할이다. <code>title</code>, <code>content</code>, <code>type</code>, <code>data</code>를 만든다. </p>
<p>전략 선택은 다음과 같이 진행된다.</p>
<blockquote>
</blockquote>
<ol>
<li><code>NotificationMessageBuilder</code>를 구현한 bean 모두 등록</li>
<li><code>resolveBuilder()</code>를 통해 현재 알림에 맞는 <code>MessageBuilder</code> 선택</li>
<li>찾은 <code>MessageBuilder</code>에서 메세지를 생성해 반환 후 전송</li>
</ol>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class NotificationSenderService {

    private final NotificationService notificationService;
    private final List&lt;NotificationMessageBuilder&gt; messageBuilders; // Bean으로 등록된 모든 builder가 List로 저장됨

    private SendResult sendToDevice(Notification notification, MemberFcmToken fcmToken) {
        ...
        try {
            NotificationMessageBuilder messageBuilder = resolveBuilder(notification); // MessageBuilder 선택
            NotificationMessage message = messageBuilder.build(notification, fcmToken); // 각 알림에 맞는 Message 생성
            NotificationResult result = notificationService.send(message); // 알림 전송
            ...
        }
        ...
    }

    /*
    * 현재 Notification 타입과 매핑되는 MessageBuilder 선택
    */
    private NotificationMessageBuilder resolveBuilder(Notification notification) {
        return messageBuilders.stream()
                .filter(b -&gt; b.supports(notification))
                .findFirst()
                .orElseThrow(() -&gt; new IllegalArgumentException(
                        &quot;지원하지 않는 알림 타입: &quot; + notification.getClass().getSimpleName()));
    }
}</code></pre>
<p><code>NotificationMessageBuilder</code>로 메세지 생성을 추상화 함으로써, 이제 새로운 알림이 추가돼도 <code>NotificationMessageBuilder</code> 구현체만 추가하면 된다.</p>
<hr>
<h2 id="3️⃣-알림-관련-상수들의-역할-구체화-및-분리">3️⃣ 알림 관련 상수들의 역할 구체화 및 분리</h2>
<p>기존 코드에서 <code>NotificationCategory</code>는 알림 카테고리 ENUM이었다. <code>ARTICLE</code>과 <code>EVENT</code>를 갖고 있었고, 필드로 알림 수신 동의에 대한 default 값, topic 사용 여부, topic 이름이 포함되어 있다. </p>
<pre><code class="language-java">/**
 * 회원 알림 설정 및 전송 정책(토픽/개별 발송)을 구분하는 카테고리
 */
@Getter
@RequiredArgsConstructor
public enum NotificationCategory {

    ARTICLE(true, false, null),
    EVENT(false, true, &quot;bombom_event&quot;),
    CHALLENGE_TODO_REMINDER(true, false, null),
    CHALLENGE_START(true, false, null),
    ;

    private final boolean defaultSetting;
    private final boolean useTopic;         // FCM 토픽 사용 여부
    private final String topicName;         // FCM 토픽 이름 (null이면 토픽 사용 안함)

    public boolean getDefaultSetting() {
        return defaultSetting;
    }
}</code></pre>
<p>data payload에서는 우리가 자체적 비즈니스 데이터로 payload에 &#39;notificationType&#39;이라는 키를 전송한다. 이 type에 따라 알림을 통해 우리 서비스를 접속했을 때 프론트에서 어디로 분기처리할지 구분하는 키이다. notificationType에 DEFAULT 값을 넣으면 분기처리 없이 그냥 앱을 켜는 형태로 처리할 수 있다.</p>
<p><code>NotificationCategory</code>는 ENUM 역할 상 알림 ‘설정’의 분류이다. <code>ARTICLE</code>, <code>EVENT</code> 알림의 커스텀 수신 설정에 대한 값들을 다루고 있다. </p>
<p>위의 &#39;notificationType&#39;을 <code>NotificationCategory</code> 값으로 관리할까 고민했다. 하지만 <code>NotificationCategory</code>와 payload의 &#39;notificationType&#39;은 엄연히 다른 역할이라고 생각해 둘을 서로 다른 상수로 관리하도록 분리했다.</p>
<p><strong>따라서 기존 <code>NotificationCategory</code>는 알림 설정용 카테고리, 그리고 payload에 담길 상수는 <code>NotificationPayloadType</code>으로 추가했다.</strong></p>
<pre><code class="language-java">/**
 * FCM data 영역에서 분기를 위한 비즈니스 알림 타입
 */
@Getter
public enum NotificationPayloadType {

    ARTICLE,
    EVENT,
    CHALLENGE_START,
    DEFAULT
}</code></pre>
<p>여기에는 <code>ARTICLE</code>, <code>EVENT</code>, <code>CHALLENGE_START</code>, <code>DEFAULT</code>를 갖도록 했다. </p>
<p>아직 합의된 바는 없지만, <code>CHALLENGE_START</code>의 경우 <code>challengeId</code>를 함께 넘겨주면 첫 날의 각오 작성 페이지를 리다이렉트 해주면 좋을 것 같아 상수를 추가했다. 
챌린지 TODO 리마인더는 <code>DEFAULT</code>로 넘겨주어 따로 리다이렉트 하지 않도록 &#39;notificationType&#39;을 구성했다. </p>
<pre><code class="language-java"> return NotificationMessage.builder()
                .recipient(token.getFcmToken())
                .title(article.getNewsletterName())
                .content(article.getArticleTitle())
                .type(NotificationType.FCM) // NotificationType은 알림 전송 수단 타입을 관리 (ex: fcm, email 등..)
                .data(Map.of(
                        &quot;articleId&quot;, String.valueOf(article.getArticleId()),
                        &quot;notificationType&quot;, NotificationPayloadType.ARTICLE
                ))
                .build();</code></pre>
<p>따라서 두 상수의 역할을 정리해보자면 다음과 같다.</p>
<ul>
<li><strong>NotificationCategory: 수신설정/전송정책</strong></li>
<li><strong>NotificationPayloadType: 클라이언트 라우팅 전용 상수</strong></li>
</ul>
<hr>
<h2 id="4️⃣-알림-도메인-별-statushandler-분리">4️⃣ 알림 도메인 별 StatusHandler 분리</h2>
<p>기존에는 알림 상태를 <code>NotificationStatusService</code>에서 제어했다. 아티클 도착 알림에 대해 도메인 및 로직이 한정적이었고, 이 로직을 다른 알림이 추가됐을 때도 OCP를 확보할 수 있도록 구성하는 것이 목표이다.</p>
<p><strong>알림의 상태는 알림의 중요도 및 정보의 유효기간에 따라 성공/부분실패/전체스킵/실패 시 
상태 전이 정책이 달라질 수 있다.</strong>
예를 들자면, 아티클 도착 알림의 경우 한 번 실패했어도 재시도 정책에 따라 백오프 이후 전송해도 정보의 유효도가 떨어지지 않아 재시도가 의미있을 것이다. 
하지만 선착순 실시간 이벤트 알림이 제시간에 성공하지 못했을 경우, 백오프에 따라 뒤늦게 보내진다 하더라도 정보의 가치가 떨어져 재시도의 의미가 하락할 것이다.</p>
<p>따라서 도메인별로 상태를 다르게 관리할 수 있도록 <code>StatusService</code>를 추상화했다.
<code>NotificationStatusHandler&lt;T extends Notification&gt;</code>으로 알림 상태를 변경할 인터페이스를 추상화하고, 이를 구현하도록 구조화했다.</p>
<pre><code class="language-java">public interface NotificationStatusHandler&lt;T extends Notification&gt; {

    void updateStatus(T notification, NotificationResultResponse result);
    void markAsFailed(T notification, String reason);
}</code></pre>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChallengeStartNotificationStatusService
        implements NotificationStatusHandler&lt;ChallengeStartNotification&gt; {

        private final ChallengeStartNotificationRepository notificationRepository;

        @Override
            @Transactional
            public void updateStatus(ChallengeStartNotification notification, NotificationResultResponse result) {
            }

            @Override
              @Transactional
            public void markAsFailed(ChallengeStartNotification notification, String reason) {
            }
}</code></pre>
<p>현재 아티클 도착 알림의 경우, 실패하면 Failed 테이블로 마이그레이션 된다. 이 경우 <code>StatusService</code>의 <code>markAsFailed()</code> 및 <code>updateStatus()</code>에서 최대 시도 횟수를 초과했을 때 Failed 테이블로 이전하는 작업도 포함될 것이다. 챌린지 관련 알림은 최대 재시도 횟수 초과 시 별도 처리가 없으므로 상태만 변경해주는 역할을 갖는다.</p>
<hr>
<h2 id="5️⃣-알림-도메인-별--processor-분리">5️⃣ 알림 도메인 별  Processor 분리</h2>
<p>기존 <code>NotificationProcessingService</code>는 알림 전송에 대한 오케스트레이션 역할을 하고 전송 결과를 기반으로 알림 상태를 업데이트 하는 역할이었다.</p>
<p>기존에는 <code>NotificationProsessingService</code>가 <code>NotificationStatusService</code>를 갖는 구조였다. 또한 <code>NotificationProcessingService</code>가 직접 <code>ArticleArrivedNotificationRepository</code>를 갖고있어, 전송 대상을 가져오는 역할도 겸했다.</p>
<p>여기서 알림의 종류가 많아질수록 <code>NotificationProcessingService</code>는 늘어나는 알림 도메인에 대한 repository를 계속해서 의존해야하고, 이로인해 알림 종류가 늘어날수록 <code>NotificationProcessingService</code>와 도메인간 강결합이 발생할 것이다.</p>
<p>그래서 <code>NotificationProcessingService</code>를 진입하기 전, <strong>각자의 도메인 repository에서 전송할 알림 대상을 직접 조회하고 검증하는 로직을 갖도록 도메인 별 <code>NotificationProcessor</code>를 도입했다.</strong></p>
<p>알림 전송하는 메서드를 추상화한 <code>NotificationProcessor</code> 인터페이스를 생성했다.</p>
<pre><code class="language-java">public interface NotificationProcessor {

    String type(); // 지원 타입
    void processPendingNotifications(LocalDateTime now); // 알림 전송
}</code></pre>
<p>챌린지 시작 알림에 대한 Processor 구조는 다음과 같다. 
이를 통해 조회하려는 알림 대상을 도메인 별로 다르게 조회할 수도 있고, 
알림 <code>Category</code>를 넘겨서 <code>NotificationProcessingService</code>를 재사용할 수 있도록 구성했다.</p>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class ChallengeStartNotificationProcessor implements NotificationProcessor {

    private final ChallengeStartNotificationRepository notificationRepository;
    private final NotificationProcessingService notificationProcessingService;
    private final ChallengeStartNotificationStatusService challengeStatusService;

    @Override
    public String type() {
        return NotificationCategory.CHALLENGE_START.name();
    }

    @Override
    public void processPendingNotifications(LocalDateTime now) {
        List&lt;ChallengeStartNotification&gt; pendingNotifications =
                notificationRepository.findRetryCandidates(
                        List.of(NotificationStatus.PENDING, NotificationStatus.FAILED),
                        now
                );

        log.info(&quot;[{}] 처리할 알림 개수: {}&quot;, type(), pendingNotifications.size());

        for (ChallengeStartNotification notification : pendingNotifications) {
            try {
                if (!notification.shouldRetry()) {
                    log.warn(&quot;[{}] 최대 재시도 횟수 초과로 처리 중단: notificationId={}, attempts={}&quot;,
                            type(), notification.getId(), notification.getAttempts());
                    continue;
                }

                notificationProcessingService.processNotification(
                        notification,
                        NotificationCategory.CHALLENGE_START,
                        challengeStatusService
                );
            } catch (Exception e) {
                log.error(&quot;[{}] 알림 처리 중 오류 발생: notificationId={}&quot;, type(), notification.getId(), e);
            }
        }
    }
}</code></pre>
<p><code>NotificationProcessingService</code>의 메서드 파라미터를 변경했다. 
<code>NotificationStatusHandler&lt;T&gt;</code>를 넘겨서 FCM 토큰이 없는 경우에 따른 상태 처리도 <code>ProcessingService</code>에서 수행할 수 있도록 만들었다.</p>
<pre><code class="language-java">@Transactional
    public &lt;T extends Notification&gt; void processNotification(T notification,
                                                             NotificationCategory category,
                                                             NotificationStatusHandler&lt;T&gt; statusHandler) {
        Long memberId = notification.getMemberId();
        if (!notificationSettingService.isEnabled(memberId, category)) {
            log.info(&quot;알림 수신 동의하지 않음: memberId={}, category={}&quot;, memberId, category);
            return;
        }

        List&lt;MemberFcmToken&gt; fcmTokens = notificationTokenService.resolveTokens(memberId);
        if (fcmTokens.isEmpty()) {
            statusHandler.markAsFailed(notification, &quot;FCM 토큰 없음&quot;);
            return;
        }

        NotificationResultResponse result = notificationSender.sendToAllDevices(notification, fcmTokens);
        statusHandler.updateStatus(notification, result);
    }</code></pre>
<p>‘<code>NotificationProcessor</code>랑 <code>NotificationProcessingService</code>의 역할 차이가 뭐냐?’라고 할 수 있지만, 둘은 <strong>“알림 도메인 정책을 다루는지”에 대한 여부가 다르다.</strong></p>
<ul>
<li><strong><code>NotificationProcessor</code></strong>: <strong>“어떤 알림들을 언제/무엇으로 처리할지”를 결정하는 오케스트레이터</strong>로, 구현체가 도메인별로 존재하고, pending 조회, category 선택, 재시도 초과 정책 같은 도메인 정책을 가진다.</li>
<li><strong><code>NotificationProcessingService</code></strong>: <strong>“한 건의 알림을 실제로 처리하는 공통 파이프라인”</strong>으로, 
수신 동의 체크 → 토큰 조회 → 발송 → 상태 핸들러 호출까지 공통 로직을 수행한다.</li>
</ul>
<hr>
<h2 id="🏆-레거시-리팩토링-결과">🏆 레거시 리팩토링 결과</h2>
<p><img src="https://velog.velcdn.com/images/o_z/post/e04323c3-1b1a-42ba-9bec-1fdae6cb221c/image.png" alt=""></p>
<p>기존의 <code>NotificationProcessingService</code>의 오케스트레이션 역할은 유지하면서도, 의존성을 최대한 필요한 것들 위주로 구성해서 계층적 구조를 이루었다. </p>
<p>이제는 새로운 종류의 알림이 생성될 경우, 크게 아래 4가지를 추가해주면 된다.</p>
<h4 id="1-domainnotification">1. DomainNotification</h4>
<ul>
<li>새로운 종류의 알림 정보가 저장될 엔티티<h4 id="2-domainnotificationprocessor">2. DomainNotificationProcessor</h4>
</li>
<li>해당 도메인의 알림 전송 전체 로직 오케스트레이션 역할</li>
<li>전송하려는 알림 대상을 조회해오고, 정책에 따라 전송 대상을 필터링 하기도 함</li>
<li>실제 알림을 보내기 전 도메인 정책 핸들링<h4 id="3-domainstatusservice">3. DomainStatusService</h4>
</li>
<li>알림 전송 후 성공/부분 실패/실패/재시도 등 상태에 따른 로직 추가</li>
<li>최종 재시도 실패 후 마이그레이션 작업 추가 가능<h4 id="4-domainmessagebuilder">4. DomainMessageBuilder</h4>
</li>
<li>알림 종류에 따라 전송하는 메세지 빌드</li>
</ul>
<p>최종적으로, 새로운 알림 추가에 대해서는 4개 클래스 추가만으로 열려있고, 변경에는 일부 도메인에 대해서만 관리하면 되는 OCP를 확보할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[좋아요 기능 설계하기 -2. 동시성 제어]]></title>
            <link>https://velog.io/@o_z/%EC%A2%8B%EC%95%84%EC%9A%94-%EA%B8%B0%EB%8A%A5-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0-2.-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4</link>
            <guid>https://velog.io/@o_z/%EC%A2%8B%EC%95%84%EC%9A%94-%EA%B8%B0%EB%8A%A5-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0-2.-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4</guid>
            <pubDate>Fri, 23 Jan 2026 04:50:37 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/o_z/post/e62408c2-823f-4af1-8f25-778ab5471b47/image.png" alt="">
이전 <a href="https://velog.io/@o_z/%EC%A2%8B%EC%95%84%EC%9A%94-%EA%B8%B0%EB%8A%A5-%EB%8F%84%EC%9E%85%ED%95%98%EA%B8%B0-1.-%EB%A9%B1%EB%93%B1%ED%95%9C-API"><strong>좋아요 기능 설계하기 -1. 멱등한 API</strong></a> 편에서도 언급했듯이, 좋아요 기능은 재시도 가능성이 높다.
동시성 문제가 가능하기 아주 쉬운 기능인 것이다.</p>
<p>그래서 원자적 연산과 가시성을 보장하도록 동시성을 제어할 다양한 방법들을 고려해봐야 한다.</p>
<hr>
<h2 id="동시성-문제가-발생하는-시나리오">동시성 문제가 발생하는 시나리오</h2>
<p>현재 상태에서는 동시에 여러 요청이 발생할 경우 race condition으로 인한 좋아요 개수 누락이 발생할 수 있다. 좋아요 등록 service 메서드의 예시를 보자.</p>
<pre><code class="language-java">@Transactional
public ChallengeCommentLikeResponse addCommentLike(Long memberId, Long challengeId, Long commentId){
    // 해당 챌린지에 참여 정보 조회
    ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);

        // 코멘트 조회 해오기
        ChallengeComment comment = challengeCommentRepository.findById(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);

        // 좋아요 데이터 등록
      int insertCount = challengeCommentLikeRepository.insertIgnoreByParticipantIdAndCommentId(
                participant.getId(),
                  comment.getId()
      );

      if(insertCount == 1){ // 좋아요가 insert 된 경우
          comment.updateLikeCount(+1); // 좋아요 개수 업데이트
        }

        return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}</code></pre>
<p><img src="https://velog.velcdn.com/images/o_z/post/08a287dd-1155-40c4-b41d-0f2b7bbc590f/image.png" alt=""></p>
<p>기존에 좋아요가 10개였던 코멘트에 대해 두 사용자가 동시에 좋아요를 눌렀다고 가정해보자.</p>
<blockquote>
</blockquote>
<ol>
<li>Thread A와 Thread B가 동시에 <code>Comment</code>를 조회했다. <blockquote>
</blockquote>
</li>
<li>두 Thread가 보고 있는 <code>Comment</code>의 <code>likeCount</code>는 <strong>10</strong>이다.<blockquote>
</blockquote>
</li>
<li>Thread B가 먼저 작업을 끝내고 <code>likeCount++</code>를 한 다음 commit 했다.
 → <strong>10 + 1 = 11</strong>로 데이터베이스에 update 된다.<blockquote>
</blockquote>
</li>
<li>이후에 Thread A가 작업을 끝내서 <code>likeCount++</code>를 한 다음 commit했다.
 → <strong>10 + 1 = 11</strong>로 데이터베이스에 update 된다.</li>
<li><strong>실제 등록된 좋아요 수는 12개지만, <code>Comment</code>에 업데이트 된  <code>likeCount</code>는 11개로, 1개가 누락되었다.</strong></li>
</ol>
<p>race condition의 대표적 문제점인 lost update인 것이다.
두 개의 스레드가 하나의 동작을 요청해도 순서가 어떻냐에 따라 <code>likeCount</code>가 11이 될 수 있고 12가 될 수 있다.</p>
<hr>
<h2 id="동시성-문제-상황-재현하기">동시성 문제 상황 재현하기</h2>
<p>위 service 코드에 대해 동시성 문제가 재현될 수 있는 테스트코드를 작성했다.</p>
<p>동시에 100명의 사용자가 좋아요 등록 요청을 한다고 가정한다. </p>
<pre><code class="language-java">@Test
void 동시에_100번_좋아요를_누르면_likeCount가_유실될_수_있다() throws Exception {
    // given
    // challenge, team, writer, comment 세팅 후

    int threadCount = 100; // 100명이 좋아요를 누른다고 가정
    System.out.println(&quot;insert하려는 좋아요 개수=&quot; + threadCount);

        // 좋아요 누를 참여자들 100명 세팅
    List&lt;ChallengeParticipant&gt; likers = challengeParticipantRepository.saveAll(
            LongStream.rangeClosed(10_000, 10_000 + threadCount - 1)
                    .mapToObj(memberId -&gt; TestFixture.createChallengeParticipantWithTeam(
                            challenge.getId(),
                            memberId,
                            team.getId(),
                            0,
                            0
                    ))
                    .toList()
    );

    CountDownLatch start = new CountDownLatch(1); // 다같이 동시 시작을 위한 세팅
    CountDownLatch done = new CountDownLatch(threadCount); // 100개 요청이 모두 처리될 때까지 대기하기 위한 세팅
    ExecutorService pool = Executors.newFixedThreadPool(threadCount); // 스레드풀 100개 세팅

    // when
    for (ChallengeParticipant liker : likers) {
        pool.submit(() -&gt; {
            try {
                start.await(); // start가 0 될 때까지 대기함
                challengeCommentService.addChallengeCommentLike(
                        liker.getMemberId(),
                        challenge.getId(),
                        comment.getId()
                );
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                done.countDown(); // 각 스레드가 작업 끝날 때마다 cound를 줄여감
            }
        });
    }

    start.countDown(); // 메인 스레드가 start count를 1-&gt;0으로 줄임 -&gt; 요청 100개 동시에 풀림
    boolean finished = done.await(30, TimeUnit.SECONDS); // done count가 0이 되면 true 반환, 30초도안 기다렸는데도 안되면 false 반환
    pool.shutdownNow(); // 스레드풀에 즉시 종료 요청

    assertThat(finished).isTrue(); // 100개 요청들이 모두 잘 완료됐는지 검증

    // then: 최신 상태로 검증하기 위해 clear + 새 트랜잭션에서 조회
    int finalLikeCount = transactionTemplate.execute(status -&gt; { // 코멘트 필드에 반영된 최종 좋아요 개수
        em.clear();
        ChallengeComment refreshed = challengeCommentRepository.findById(comment.getId()).orElseThrow();
        return refreshed.getLikeCount();
    });

    long likeRows = challengeCommentLikeRepository.count(); // 테이블에 실제로 저장된 좋아요 개수

    System.out.println(&quot;insert된 좋아요 row 수=&quot; + likeRows + &quot;, 코멘트에 반영된 최종 좋아요 수=&quot; + finalLikeCount);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/o_z/post/b863acde-1bc9-4639-be37-7ea36649ccaa/image.png" alt=""></p>
<p>실행 결과, 코멘트 필드로 반영된 최종 좋아요 개수는 12개로 실제 좋아요 개수는 100개인데 정상적으로 필드에 반영된 상황은 12개 뿐이다.</p>
<p>정확하게 실제 코멘트 개수대로 Comment 필드에도 반영되려면 동시성 제어가 필수적이다.</p>
<p>동시성 제어 방법으로 크게 5가지를 고민했다.</p>
<blockquote>
<p><strong>1. 메서드에 synchronized 사용하기
2. 낙관적 락 사용하기
3. 비관적 락 사용하기
4. 분산 락 사용하기 (네임드 락)
5. update 쿼리 직접 날리기</strong></p>
</blockquote>
<hr>
<h2 id="1-메서드에-synchronized-사용하기">1. 메서드에 synchronized 사용하기</h2>
<p>Java의 <code>synchronized</code>는 메서드 레벨의 race condition을 제어해주는 키워드로, 이 키워드가 붙은 메서드에는 오로지 하나의 스레드만 접근할 수 있는 일종의 뮤텍스와 유사한 메커니즘이다.</p>
<pre><code class="language-java">@Transactional
public synchronized ChallengeCommentLikeResponse addChallengeCommentLike(Long memberId, Long challengeId, Long commentId) {
    // 좋아요를 등록하는 로직
}</code></pre>
<p>단순히 메서드에 <code>synchronized</code> 키워드만 추가하고 동시성 테스트를 재실행했다.<img src="https://velog.velcdn.com/images/o_z/post/2b44c0fe-48bb-4cd9-b7b6-91f72799ce02/image.png" alt="">이전에 12개만 반영되던 상황에서 50개까지 반영된 개수가 늘었다. 
<strong>하지만 여전히 100개가 온전하게 모두 반영된 것은 아니었다.</strong> 이유가 무엇일까?</p>
<p>Spring의 트랜잭션을 사용하는 환경이라면 <code>synchronized</code>도 완전하게 동시성을 제어할 순 없다. 
Spring의 <code>@Transactional</code>가 붙은 메서드면 AOP의 service 프록시 객체가 감싸서 실행하기 때문이다.</p>
<pre><code class="language-java">class ChallengeCommentServiceTxProxy {

    private ChallengeCommentService target;
    private TransactionManager tx;

    ChallengeCommentLikeResponse addChallengeCommentLike(memberId, challengeId, commentId) {
        // 1) 트랜잭션 시작(또는 기존 트랜잭션 참여)
        tx.begin();
        // 2) 실제 서비스 메서드 호출 (synchronized 영역)
        result = target.addChallengeCommentLike(memberId, challengeId, commentId);
        // 3) 트랜잭션 커밋 (이때 flush -&gt; SQL 실행 -&gt; commit)
        tx.commit();

        return result;
    }
}</code></pre>
<p>어디서 간극이 발생할까? 여러 개의 요청이 들어왔을 때 2번과 3번 사이 간극에서 다른 요청이 해당 <code>Comment</code>의 <code>likeCount</code>를 읽어갈 수 있다고 예상했다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/e7adfe9e-c3bc-4d16-a23f-bccb56b267d6/image.png" alt=""></p>
<h3 id="1-1-transactional-적용-간격-때문이-확실할까">1-1. @Transactional 적용 간격 때문이 확실할까?</h3>
<p><strong>“synchronized의 완벽한 제어가 안되는 원인이 <code>@Transactional</code> 때문”</strong>이라는 검증을 해보고싶어서 동일한 비즈니스 로직을 수행하도록 코드를 일부 변경해 테스트했다.</p>
<p>왜 코드를 변경하는가? 라고 물어본다면, 지금 상태에서는 <code>@Transactional</code> 을 제거한 상태의 테스트가 불가능하다. <code>@Transactional</code>을 제거하기에는 <code>addChallengeCommentLike()</code> 내부 로직에서 데이터를 INSERT 하기 위해선 쿼리에 <code>@Modifying</code>이 필수이며, 이는 트랜잭션이 보장되어야 사용할 수 있다. <code>Comment</code>의 <code>likeCount</code> 또한 더티 체킹을 사용하고 있으므로, 트랜잭션의 commit이 발생하지 않는 이상 flush 되지 않아 변경사항이 데이터베이스에 반영되지 못한다.</p>
<p>그래서 아래는 좋아요를 직접 save 하고, unique 제약으로 인해 발생하는 exception은 일단 간단하게 Exception으로 잡아 바로 comment의 좋아요 개수를 반환하도록 수정했다. 또한, 기존의 <code>Comment</code> 좋아요 개수를 더티체킹으로 업데이트 했던 부분을 명시적 save 하도록 변경했다.</p>
<pre><code class="language-java">@Transactional
public synchronized ChallengeCommentLikeResponse addChallengeCommentLike(Long memberId, Long challengeId, Long commentId) {
    ChallengeParticipant participant = getChallengeParticipant(memberId, challengeId);
    ChallengeComment comment = challengeCommentRepository.findById(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);

    try { // 좋아요 엔티티를 생성해 save 하도록 코드 수정
        challengeCommentLikeRepository.save(ChallengeCommentLike.builder()
                .participantId(participant.getId())
                .commentId(comment.getId())
                .build()
        );
    } catch (Exception e) { // unique로 인해 save 안되면 바로 반환
        return ChallengeCommentLikeResponse.of(comment);
    }

    comment.updateLikeCount(+1);
    challengeCommentRepository.save(comment); // 명시적 flush
    return ChallengeCommentLikeResponse.of(comment);
}</code></pre>
<p>일단 <code>@Transactional</code> 을 붙였을 때의 결과이다.<img src="https://velog.velcdn.com/images/o_z/post/c2da7d85-4560-4f32-98b6-68b59b2935bd/image.png" alt="">아까와 동일한 상황으로, <code>synchronized</code>를 붙였음에도 100개로 다 업데이트 되지 않고 50개가 누락됐다.</p>
<p>이제 <code>@Transactional</code>을 제거해보자.<img src="https://velog.velcdn.com/images/o_z/post/600b4602-2be5-4960-bd96-050a1b8c5129/image.png" alt=""></p>
<p>100개가 누락되지 않고 완벽하게 업데이트 되었다. 
정말 <code>@Transactional</code>이 붙었을 때 프록시 객체를 통해 트랜잭션을 수행하는 간극 사이에 누락이 발생한다는 점을 검증하게 되었다.</p>
<p>일단 기존에 의도했던 코드 베이스는 아까 말한 INSERT 쿼리의 <code>@Modifying</code>으로 인해 <code>@Transactional</code>과 <code>synchronized</code>를 모두 사용해선 동시성을 완전히 제어할 수는 없다.</p>
<h3 id="1-2-synchronized의-치명적-단점">1-2. synchronized의 치명적 단점</h3>
<p>그리고 치명적인 단점으로는 <strong>분산환경에서 동시성 제어가 안된다는 점</strong>이다. 
같은 JVM 내에 있는 스레드끼리는 락 상태가 공유되어 다른 스레드들이 접근할 수 없지만, 다른 서버는 다른 JVM이므로 이 락 상태를 공유할 수 없어 결국 동시성 문제가 똑같이 발생한다.</p>
<p><strong>현재 우리 서버는 분산환경이므로 이 <code>synchronized</code> 방식을 적용할 수 없다.</strong></p>
<hr>
<h2 id="2-낙관적-락-optimistic-lock-사용하기">2. 낙관적 락 (Optimistic Lock) 사용하기</h2>
<p><strong>낙관적 락</strong>이란 <strong>“충돌은 자주 나지 않는다”</strong>라는 낙관적 가정이 기반인 공유자원 처리 방식이다.</p>
<p><strong>“누가 접근 안했겠지~” 하고 데이터를 그냥 읽어와서 처리를 다 한 후, 
마지막에 커밋하기 전에 자원이 처음에 봤던 상태 그대로 있는지를 확인한다. 
만약 달라져있다면 충돌이 발생했다고 판단해 실패처리한다.</strong>
데이터베이스 레벨의 실제 락은 걸지 않아 다른 스레드들과의 동시처리 성능은 좋다.</p>
<p>낙관적 락은 버저닝을 통해 판단한다. 
내가 자원을 가져올 때 봤던 version이 커밋 시점 version과 달라지면 다른 스레드가 먼저 업데이트 했다고 판단하고 <code>OptimisticLockException</code> 예외를 발생시킨다.</p>
<p>낙관적 락의 메커니즘 정리는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/e84a3e34-884d-499a-b2a0-259de80548d4/image.png" alt=""></p>
<blockquote>
<ol>
<li>스레드 A, B 둘다 version = 1인 데이터를 조회했다.</li>
<li>스레드 B 트랜잭션이 변경사항 커밋을 먼저 시도한다.</li>
<li>DB를 조회했을 때 해당 데이터의 version이 1이다. </li>
</ol>
<p><strong>내가 처음에 조회했던 version인 1과 일치해 version을 1 올리고 업데이트한다.</strong>
4. 이후 스레드 A가 변경사항 커밋을 시도한다.
5. <strong>스레드 A가 처음에 조회했던 version은 1인데 DB의 version은 2이므로 충돌 발생이라고 여겨 exception을 던진다.</strong></p>
</blockquote>
<p>이제 비즈니스코드에 낙관적 락을 도입해보자!</p>
<h4 id="1️⃣-entity에-version-필드-세팅">1️⃣ Entity에 version 필드 세팅</h4>
<p>낙관적 락을 구현하기 위해서는 대상 테이블의 컬럼으로 version을 추가해야한다.</p>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChallengeComment extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Builder.Default
    private int likeCount;

    ...

    @Version // jakarta.persistence.Version
    private Long version;

    ...
}</code></pre>
<p><code>@Version</code> 애노테이션의 역할은 다음과 같다.</p>
<p><strong>1. 자동으로 version 컬럼을 업데이트 해준다.</strong></p>
<p>낙관적 락을 직접 구현하게 되면 해당 엔티티가 업데이트 될 때마다 아래처럼 version 확인 + version 업데이트를 하는 쿼리를 작성해야할 것이다.</p>
<pre><code class="language-sql">UPDATE comment
SET like_count = :like_count, version = version + 1
WHERE id = :id AND version = :version</code></pre>
<p>하지만 <code>@Version</code> 을 붙여두면 JPA가 자동으로 version 확인 + version 업데이트를 진행해준다.</p>
<p><strong>2. 필드를 변경한 트랜잭션 커밋 시점에 충돌이 감지되면 <code>ObjectOptimisticLockingFailureException</code> 을 발생시킨다.</strong></p>
<p>충돌이 생기면 자동으로 <code>ObjectOptimisticLockingFailureException</code>을 발생시켜주지만, catch해서 후처리하는건 개발자의 몫이다.</p>
<h4 id="2️⃣-repository에서-조회할-때-locklockmodetypeoptimistic-걸기">2️⃣ Repository에서 조회할 때 <code>@Lock(LockModeType.OPTIMISTIC)</code> 걸기</h4>
<p>해당 엔티티를 조회 해올 때 낙관적 락을 사용한다는 애노테이션이다.</p>
<pre><code class="language-java">public interface ChallengeCommentRepository extends JpaRepository&lt;ChallengeComment, Long&gt; {

    @Lock(LockModeType.OPTIMISTIC)
    @Query(value = &quot;SELECT cc FROM ChallengeComment cc WHERE cc.id = :id&quot;)
    Optional&lt;ChallengeComment&gt; findByIdWithOptimisticLock(Long id);
    ...
}</code></pre>
<h4 id="3️⃣-service에서-낙관적-락-사용하도록-수정하기">3️⃣ Service에서 낙관적 락 사용하도록 수정하기</h4>
<p>2번 repository 메서드를 호출하도록 수정만 하면 된다.</p>
<pre><code class="language-java">@Transactional
public ChallengeCommentLikeResponse addChallengeCommentLike(Long memberId, Long challengeId, Long commentId) {
    ChallengeParticipant participant = getChallengeParticipant(memberId, challengeId);

    // 낙관적 락 적용 후 조회하는 메서드로 변경
    ChallengeComment comment = challengeCommentRepository.findByIdWithOptimisticLock(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);
        ...
}</code></pre>
<h4 id="4️⃣-facade-패턴으로-락-점유-실패-시-재시도-로직-작성하기">4️⃣ Facade 패턴으로 락 점유 실패 시 재시도 로직 작성하기</h4>
<p>이렇게 낙관적 락으로 업데이트를 시도했다가 version 불일치로 인한 업데이트 실패 시, 일정 횟수 재시도하는 로직을 추가해야한다. 
Spring이 제공하는 <code>@Retryable</code>도 사용 가능하고, 아니면 try-catch로 직접 구현할 수도 있다.</p>
<p>나는 <code>@Retryable</code>을 사용해서 구현해봤다.</p>
<p>gradle에 아래처럼 의존성을 추가해야한다.</p>
<pre><code class="language-java">// @Retryable
implementation(&quot;org.springframework.retry:spring-retry&quot;)
implementation(&quot;org.springframework:spring-aspects&quot;)</code></pre>
<p>재시도 로직을 추가한 <code>ChallengeCommentLikeFacade</code> 를 생성했고, 
내부에 <code>ChallengeCommentService</code>를 갖고있게 만들어서 재시도 가능하도록 호출했다. </p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class ChallengeCommentLikeFacade {

    private final ChallengeCommentService challengeCommentService;

    @Retryable(
            include = ObjectOptimisticLockingFailureException.class,
            maxAttempts = 10
    )
    @Transactional
    public ChallengeCommentLikeResponse addChallengeCommentLike(Long memberId, Long challengeId, Long commentId) {
        return challengeCommentService.addChallengeCommentLike(memberId, challengeId, commentId);
    }
}</code></pre>
<p>처음에는 좋아요 insert랑 comment 필드 update 작업은 완전히 독립적이라고 생각했다.
그래서 재시도 로직을 짤 때 ‘update 로직까지 내려온거면 insert는 이미 보장된거니까 필드 update만 재시도 하면 되겠지?’ 라고 생각하고, 재시도 로직에는 <code>Comment</code>의 <code>likeCount</code>를 업데이트하는 로직만 넣었었다.</p>
<p>그런데 다시 생각해보니 낙관적 락 충돌로 인해 <code>ObjectOptimisticLockingFailureException</code> 예외가 발생하면, 이는 곧 본 작업의 트랜잭션 롤백으로 이어진다. 
그래서 롤백 과정에서 insert 했던 좋아요까지 롤백되어 insert 보장이 안되는 것이다.
따라서 재시도 로직은 항상 실패했던 비즈니스 로직 자체를 원자적으로 재시도하도록 구성해야한다.</p>
<p>테스트에서도 <code>ChallengeCommentLikeFacade</code> 의 메서드를 호출하도록 수정했다.
(이래서 비즈니스 로직의 추상화가 필요하단 걸 깨달았다..)</p>
<p>변경 후 테스트 실행 시, 온전하게 100개의 좋아요 수가 성공적으로 반영됐다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/6c2a1bb7-fa4d-46bf-ace1-563118cc7792/image.png" alt=""></p>
<p>낙관적 락을 구현해보니 충돌 발생 시 후처리 / retry를 직접 구현해야 한다는 점이 정말 큰 단점으로 와닿았다..</p>
<p>또한 충돌 횟수가 많은 상황에서는 그만큼 재시도 횟수를 많이 올려야 성공적으로 동시성 제어가 될 것이다.
하지만 이는 곧 비즈니스 로직 전체를 N번 반복하는 것이므로, 실행 성능도 매우 좋지 못하다.
그래서 “충돌이 거의 발생하지 않는”라는 상황에서만 사용한다는 점이 이해가 갔다.</p>
<hr>
<h2 id="3-비관적-락-pessimistic-lock-사용하기">3. 비관적 락 (Pessimistic Lock) 사용하기</h2>
<p><strong>비관적 락</strong>은 <strong>“충돌은 자주 발생한다”</strong>는 비관적 가정을 기반으로 미리 DB 락을 거는 방식이다.</p>
<p>*<em>“누가 접근할 수 있다”라고 생각해, 처음부터 데이터베이스에서 변경하려는 데이터를 조회해올 때 
row level로 lock을 걸고 다른 트랜잭션은 수정/접근하지 못하도록 한다. 
락 획득에 실패한 트랜잭션은 일단 blocking waiting 하고, 
lock_wait_timeout보다 오래 대기하면 timeout이 발생한다. *</em></p>
<p>스프링에서는 <code>@Lock(PESSIMISTIC_WRITE)</code> 를 통해 비관적 락을 구현할 수 있다. </p>
<h4 id="1️⃣-repository에서-조회할-때-locklockmodetypepessimistic_write-걸기">1️⃣ Repository에서 조회할 때 <code>@Lock(LockModeType.PESSIMISTIC_WRITE)</code> 걸기</h4>
<pre><code class="language-java">public interface ChallengeCommentRepository extends JpaRepository&lt;ChallengeComment, Long&gt; {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(value = &quot;SELECT cc FROM ChallengeComment cc WHERE cc.id = :id&quot;)
    Optional&lt;ChallengeComment&gt; findByIdWithPessimisticLock(Long id);
    ...
}</code></pre>
<p><code>LockModeType</code> 에 <code>PESSIMISTIC_READ</code> 가 있고 <code>PESSIMISTIC_WRITE</code> 가 있다.</p>
<ul>
<li><code>PESSIMISTIC_READ</code> : 읽기 용도로 비관적 락을 건다. (S lock)</li>
<li><code>PESSIMISTIC_WRITE</code> : 쓰기 용도로 비관적 락을 건다. (X lock)</li>
</ul>
<p>지금 상황에서는 <code>ChallengeComment</code>의 <code>likeCount</code> 업데이트가 목적이기 때문에 <code>PESSIMISTIC_WRTIE</code> 을 건다.</p>
<p>위처럼 설정해주면 실제로 나가는 조회 쿼리는 다음과 같다.</p>
<pre><code class="language-sql">SELECT cc
FROM ChallengeComment cc
WHERE cc.id = :id
FOR UPDATE; // 수정의 목적으로 조회함 (X-lock 조회)</code></pre>
<h4 id="2️⃣-service에서-비관적-락-메서드-호출로-변경하기">2️⃣ Service에서 비관적 락 메서드 호출로 변경하기</h4>
<p>이렇게만 변경하면 끝이다!</p>
<pre><code class="language-java">@Transactional
public ChallengeCommentLikeResponse addChallengeCommentLike(Long memberId, Long challengeId, Long commentId) {
    ChallengeParticipant participant = getChallengeParticipant(memberId, challengeId);

    // 비관적 락 적용 후 조회하는 메서드로 변경
    ChallengeComment comment = challengeCommentRepository.findByIdWithPessimisticLock(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);
        ...
}</code></pre>
<p>비관적 락 적용 후 동시성 테스트를 진행했다. 모두 온전하게 잘 반영됨을 확인했다.
<img src="https://velog.velcdn.com/images/o_z/post/5e74f44b-0ab5-4664-b2b0-5496298e77c4/image.png" alt=""></p>
<p>비관적 락은 동시처리 요청이 많이 들어올 경우, 일종의 줄세우기처럼 직렬처리를 수행하는 것이므로 트랜잭션의 대기 상태가 오래 지속될 수 있다. 이는 곧 병목지점이 될 가능성이 높다. 
따라서 적절한 Timeout 정책이 필요하며 트랜잭션을 오래 붙잡고 있지 않도록 해주어야한다. </p>
<p>확실한 상호배제가 중요한 쿼리, 로직이라면 비관적 락이 좋은 선택이 될 것이다. 
예를 들자면 좌석 예약이나 쿠폰 사용같이 혹여나 중복 처리가 발생해버리면 큰 비즈니스적 문제로 이어질 수 있는 경우들이다. 
성공률을 올리는 대신 처리량을 희생하는 것이다.</p>
<hr>
<h2 id="4-분산-락-distributed-lock-사용하기-네임드-락">4. 분산 락 (Distributed Lock) 사용하기 (네임드 락)</h2>
<p>분산 락은 여러 프로세스/서버가 동시에 임계영역에 접근하지 못하게 공유 저장소로 락을 거는 방식이다.</p>
<p>여기서 공유저장소란 Redis, DB 같은 곳이 될 것이다.
분산 락 구현 방법으로 크게 생각나는건 두 가지였다.</p>
<blockquote>
<ol>
<li>Redis를 통한 분산 락 구현하기</li>
<li>MySQL에서 제공하는 네임드 락 활용하기</li>
</ol>
</blockquote>
<p>사실상 첫 번째는 고려사항에서 제외이다. 
우리 서비스는 지금 Redis도 사용하지 않고 있어서, 이 동시성 제어를 위해 Redis를 도입하기엔 더 큰 비용이 들어 투자 대비 임팩트가 좋지 못하다. 
그래서 사실상 선택지는 2번으로 확정되었다.</p>
<h3 id="4-1-mysql의-네임드-락-named-lock-사용하기">4-1. MySQL의 네임드 락 (Named Lock) 사용하기</h3>
<p>MySQL 엔진 레벨에서 제공하는 네임드 락은 일종의 분산락”처럼” 사용할 수 있다. 
(완전히 분산 락을 위한 구현체는 아니라고 한다!!)
**
네임드 락은 특정 문자열을 가진 락을 걸어두면, 다른 커넥션이 이 락을 점유하고자 시도했을 때 대기시키거나 실패시키는 원리이다.**</p>
<p>네임드 락의 락은 DB 커넥션에 붙어있는 락이다.
그래서 트랜잭션의 commit/rollback과는 무관하고, 커넥션이 종료되어야 암묵적으로 락이 해제된다.
트랜잭션과 무관하므로 자동 락 획득/해제도 불가능하다. 락 획득/해제 과정도 개발자가 직접 구현해주어야 한다.</p>
<p>네임드 락에서 락을 획득/해제하는 쿼리는 다음과 같다.</p>
<pre><code class="language-sql">SELECT GET_LOCK(&#39;update_query&#39;, 5); // &#39;update_query&#39;라는 이름의 락 획득, 최대 5초 대기
SELECT RELEASE_LOCK(&#39;update_query&#39;); // &#39;update_query&#39;라는 이름의 락 해제</code></pre>
<h4 id="1️⃣-repository에-네임드-락-획득해제-쿼리-추가하기">1️⃣ Repository에 네임드 락 획득/해제 쿼리 추가하기</h4>
<p>사실 네임드 락 획득/해제 쿼리는 엔티티들과 무관한 데이터베이스 자체 쿼리라서, JPA Repository를 사용하는 것보다 <code>JdbcTemplate</code>을 사용해서 구현하는게 더 올바를 것이라고 생각한다.
하지만 우리 서비스는 <code>JdbcTemplate</code>을 안쓰고 있으니 일단 JPA Repository에 욱여넣어서(?) 테스트 하기로 했다.</p>
<pre><code class="language-java">public interface ChallengeCommentRepository extends JpaRepository&lt;ChallengeComment, Long&gt; {

    @Query(value = &quot;SELECT GET_LOCK(:key, 5)&quot;, nativeQuery = true)
    void getLockByKey(String key);

    @Query(value = &quot;SELECT RELEASE_LOCK(:key)&quot;, nativeQuery = true)
    void releaseLockByKey(String key);
    ...
}</code></pre>
<h4 id="2️⃣-service에서-네임드-락-획득-해제-추가하기">2️⃣ Service에서 네임드 락 획득, 해제 추가하기</h4>
<p>서비스 메서드의 앞뒤로 네임드 락을 획득하고 해제하는 로직을 추가해야한다.</p>
<pre><code class="language-java">// 4. 네임드 락
@Transactional
public ChallengeCommentLikeResponse addChallengeCommentLike(Long memberId, Long challengeId, Long commentId) {
    String namedLockKey = &quot;add_comment_like:&quot; + commentId;
    challengeCommentRepository.getLockByKey(namedLockKey); // 네임드락 획득

    ChallengeParticipant participant = getChallengeParticipant(memberId, challengeId);
    ChallengeComment comment = challengeCommentRepository.findById(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);

    int insertCount = challengeCommentLikeRepository.insertIgnoreByParticipantIdAndCommentId(
            participant.getId(),
            comment.getId()
    );

    if (insertCount == 1) {
        comment.updateLikeCount(+1);
    }

    challengeCommentRepository.releaseLockByKey(namedLockKey); // 네임드락 해제
    return ChallengeCommentLikeResponse.of(comment);
}</code></pre>
<p>이렇게 구현하고 테스트 했는데 아래처럼 일부 count 누락이 발생했다.<img src="https://velog.velcdn.com/images/o_z/post/bb9f2f99-a8de-4c58-9e70-652bd2ef1627/image.png" alt=""></p>
<p>왜 발생했지..? 싶었는데 네임드 락 해제 시점과 트랜잭션 커밋 시점의 격차 때문에 발생한 문제인 것 같다.
지금 내 코드에서 트랜잭션의 begin/commit, 네임드 락의 획득/해제, 비즈니스 로직의 실행 순서를 보면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/204b6e35-a6ea-434a-8030-42d06fb524ab/image.png" alt=""></p>
<p>먼저 락을 얻었던 스레드가 네임드 락을 먼저 해제한 후, 변경 사항을 커밋하려고 한다.
그 사이에 다른 스레드가 네임드 락을 즉시 획득해서 이전 변경 사항이 커밋되기 전에 데이터를 조회해온 것이다.</p>
<p>결국 <code>likeCount</code>에 동일한 누락 현상이 발생한다.</p>
<p>그래서 비즈니스 로직의 트랜잭션을 네임드 락 획득/해제의 안쪽에 위치하도록 수행해야한다.
기존에 Service 비즈니스 로직은 그대로 두로, Facade에 네임드 락 획득/해제 로직으로 감싸준다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class ChallengeCommentLikeFacade {

    private final ChallengeCommentService challengeCommentService;
    private final ChallengeCommentRepository challengeCommentRepository;

    public ChallengeCommentLikeResponse addChallengeCommentLikeWithNamedLock(Long memberId, Long challengeId, Long commentId) {
        String namedLockKey = &quot;add_comment_like:&quot; + commentId;
        try {
                // 네임드 락 획득 후
            challengeCommentRepository.getLockByKey(namedLockKey);
            // 비즈니스 로직 트랜잭션 수행
            return addChallengeCommentLike(memberId, challengeId, commentId);
        } finally {
                // 예외 발생 상관없이 네임드 락 해제하기
            challengeCommentRepository.releaseLockByKey(namedLockKey);
        }
    }
}</code></pre>
<p>락 해제와 트랜잭션 간 간극 문제가 맞았다! 이제 누락 없이 모두 업데이트 되었다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/5b3d9e94-1922-4180-92df-ccd53c252cd4/image.png" alt=""></p>
<p>네임드 락으로 분산 락을 구현해서 비즈니스 로직을 원자적으로 잘 수행했다.
하지만 아까 말했던 JPA Repository를 사용해서 구현해야한다는 점, 그리고 이를 위해 <code>JdbcTemplate</code>이나 native query를 실행할 계층 또는 repository 구현체를 추가해야한다는 점이 걸림돌이라고 느껴졌다.</p>
<hr>
<h2 id="5-직접-update-쿼리-작성하기">5. 직접 Update 쿼리 작성하기</h2>
<p>직접 UPDATE 쿼리를 작성하면 row level의 X-lock이 발생한다.
X-lock은 다른 트랜잭션이 S-lock, X-lock 모두 얻지 못하며, 데이터 수정을 위해 row를 독점한다.</p>
<h4 id="1️⃣-repository에-update-쿼리-추가하기">1️⃣ Repository에 UPDATE 쿼리 추가하기</h4>
<p>Repository에 UPDATE 쿼리를 직접 날리도록 메서드를 추가했다.
<code>likeCount = likeCount + 1</code>로 써도 되지만 나는 좋아요 취소에서도 재활용하고자 amount를 파라미터로 받도록 만들었다.</p>
<pre><code class="language-java">public interface ChallengeCommentRepository extends JpaRepository&lt;ChallengeComment, Long&gt; {

    @Modifying(clearAutomatically = true)
    @Query(&quot;UPDATE ChallengeComment SET likeCount = likeCount + :amount WHERE id = :commentId&quot;)
    void updateLikeCount(Long commentId, int amount);
}</code></pre>
<blockquote>
<p>*<em>✅ <code>@Modifying</code> 옵션에는 <code>clearAutomatically</code>와 <code>flushAutomatically</code>가 있다. *</em></p>
</blockquote>
<ul>
<li><strong><code>clearAutomatically</code> : 쿼리를 실행한 후 영속성 컨텍스를 clear 한다.</strong><ul>
<li>쿼리 실행 후 해당 엔티티를 다시 조회하게 될 때, 영속성 컨텍스트의 엔티티와 DB의 데이터가 동기화 되지 않는다.</li>
<li>따라서 영속성 컨텍스트를 비우고 다시 조회해오도록 할 때 사용하는 옵션이다.</li>
</ul>
</li>
<li><strong><code>flushAutomatically</code> : 쿼리를 실행하기 전 영속성 컨텍스트의 변경사항을 모두 flush 한다.</strong><ul>
<li>같은 트랜잭션에서 이전에 변경한 것들이 flush 안되어 있을 수 있다. </li>
<li>이 때 반영된 최신 상태에서 지금의 쿼리를 수행해야 할 경우 사용하는 옵션이다.</li>
</ul>
</li>
</ul>
<p>지금 상태에서는 좋아요 개수 업데이트를 한 후 다시 코멘트 좋아요 수를 조회해야하므로 영속성 컨텍스트를 비우고자 <code>clearAutomatically</code>를 켰다.
같은 트랜잭션의 이전 작업들은 위 좋아요 업데이트와 독립적인 작업들이다. 
따라서 <code>flushAutomatically</code>는 키지 않았다.</p>
<h4 id="2️⃣-service에서-update-메서드-사용하기">2️⃣ Service에서 UPDATE 메서드 사용하기</h4>
<p>코멘트에 좋아요 개수 수정하는 부분만 repository 메서드로 바꿔주면 된다.</p>
<pre><code class="language-java">@Transactional
public ChallengeCommentLikeResponse addChallengeCommentLike(Long memberId, Long challengeId, Long commentId) {
    ChallengeParticipant participant = getChallengeParticipant(memberId, challengeId);
    ChallengeComment comment = challengeCommentRepository.findById(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);

    int insertCount = challengeCommentLikeRepository.insertIgnoreByParticipantIdAndCommentId(
            participant.getId(),
            comment.getId()
    );

    if (insertCount == 1) { // update 쿼리 직접 날리기
        challengeCommentRepository.updateLikeCount(commentId, +1);
    }
    return ChallengeCommentLikeResponse.of(comment);
}</code></pre>
<p>누락없이 좋아요 개수가 모두 잘 반영됐다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/29be10c4-2098-42fa-825e-4b22cca13fe9/image.png" alt=""></p>
<hr>
<h2 id="최종-비교-및-선택하기">최종 비교 및 선택하기</h2>
<h3 id="1-synchronized">1. synchronized</h3>
<table>
<thead>
<tr>
<th>개념</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>JVM에서 메서드 레벨로 제어하는 동시성</td>
<td>사용하기 간편 <br> 상호배제 보장</td>
<td>분산환경에서 사용 불가, <br> <code>@Transactional</code>에서 사용시 온전한 동시성 보장 불가</td>
</tr>
<tr>
<td><code>synchronized</code>는 사용하기 정말 편리했지만 현재 분산환경인 우리 서비스에서는 활용할 수 없는 방식이다. 또한 <code>@Transactional</code>이 필수인 메서드라 사용할 수 없어 제외했다.</td>
<td></td>
<td></td>
</tr>
</tbody></table>
<h3 id="2-낙관적-락">2. 낙관적 락</h3>
<table>
<thead>
<tr>
<th>개념</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>처음 가져온 락 버전과 데이터 변경 후 락 버전 비교 <br> - 일치하면 충돌 X 판단 후 업데이트 <br> - 일치하지 않으면 다른 트랜잭션과 충돌해 예외 발생</td>
<td>DB 락을 오래 붙들고있지 않음 <br> → 동시처리성 성능 좋음</td>
<td>충돌 발생 시 재시도 로직 직접 개발 필요, <br> 재시도 횟수 및 충돌 횟수 따라 <br>정합성 보장 안될 수 있음</td>
</tr>
<tr>
<td>낙관적 락은 재시도 횟수, 충돌 횟수에 따라 정합성 보장이 안될 수 있다는 점이 꽤 불안정하다고 생각했다.</td>
<td></td>
<td></td>
</tr>
</tbody></table>
<p>또한 재시도 로직도 직접 개발을 해줘야 한다는 점에서도 단점이 느껴졌지만, 
쓰기보다 읽기가 많은 우리 서비스에서는 어느정도 생각해볼만 방법이라 일단은 보류로 두었다.</p>
<h3 id="3-비관적-락">3. 비관적 락</h3>
<table>
<thead>
<tr>
<th>개념</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>데이터베이스 레벨에서 직접 row level lock 걸기 <br> (<code>SELECT</code> ~ <code>FOR SHARE</code>, <code>FOR UPDATE</code>)</td>
<td>DB 락이라 동시성 보장 확실</td>
<td>락 오래 붙잡고 있으면 트랜잭션 오래 유지 <br> → 타 트랜잭션들도 오래 대기</td>
</tr>
<tr>
<td>비관적 락은 데이터베이스 락을 직접 걸어서 동시성 보장도 확실하게 되어서 안전해보였다.</td>
<td></td>
<td></td>
</tr>
</tbody></table>
<p>비관적 락의 단점이 비즈니스 로직이 복잡해지면 락을 그만큼 오래 갖고있게 되는데, 
우리 로직도 조회 → 삽입 → 업데이트까지 락을 갖고있는 상황이라, 
이 또한 오래 점유하는 것일까봐 우려되어 보류했다.</p>
<h3 id="4-분산-락---네임드-락">4. 분산 락 - 네임드 락</h3>
<table>
<thead>
<tr>
<th>개념</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>MySQL에서 제공하는 커넥션 기반 문자열 락 <br> - 락 점유한 커넥션이 작업 수행 <br> - 타 커넥션이 락 점유하려고 하면 대기 후 타임아웃</td>
<td>비즈니스 로직 자체를 묶어서 <br> 원자적으로 락 걸기 가능</td>
<td>락의 자동 획득/해제가 없어서 직접 구현 필요 <br> 로직 단위 락이라 오래 점유 가능</td>
</tr>
</tbody></table>
<p>분산 락을 네임드 락으로 구현하면, 긴 비즈니스 로직 작업 전체를 원자적으로 수행해야할 때 사용하면 좋을 것이다.</p>
<p>하지만 구현해보면서 락의 획득/해제 타이밍이 트랜잭션과 맞지 않다는 점이 불편하게 느껴졌다.
자동 락 획득/해제 로직이 없어서 직접 구현해야하고, 트랜잭션과 커넥션 간 격차를 세밀하게 제어해줘야했다. </p>
<p>또한 앞서 말했듯 네임드 락은 테이블 스키마와 상관없게 쿼리를 발생하는 로직이므로 <code>JdbcTemplate</code>을 사용하면 좋겠지만, 이를 위해 도입하는 것은 비용 대비 임팩트가 적다고 생각했다.</p>
<p>그래서 네임드 락은 동시성 후보에서 제외했다.</p>
<h3 id="5-update-쿼리-직접-사용">5. update 쿼리 직접 사용</h3>
<table>
<thead>
<tr>
<th>개념</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>update 쿼리 작성해 실행하면  X-lock이 걸려 <br>타 트랜잭션의 update는 막음</td>
<td>check-then-act 없이 <br>원자적 처리 유용, 단순함</td>
<td>직속 쿼리 수행으로 영속성 컨텍스트와의 동기화 필요</td>
</tr>
<tr>
<td>MySQL에서 update 쿼리를 사용하면 row level의 X-lock으로 타 트랜잭션의 update를 막아 원자적 연산을 할 수 있다.</td>
<td></td>
<td></td>
</tr>
<tr>
<td>현재 우리 서비스에서 가장 간편하게 구현하고 동시성도 온전히 제어하기에 최적의 방법으로 생각했다.</td>
<td></td>
<td></td>
</tr>
<tr>
<td>영속성 컨텍스트와의 동기화는 단순 <code>findById()</code>로 조회해오면 됐기에, 그리 큰 비용이 아니라고 생각했다.</td>
<td></td>
<td></td>
</tr>
</tbody></table>
<h4 id="✅-결론적으로는-update-쿼리를-직접-사용하는-방식이-가장-적절하다고-생각해-이-방식을-채택했다">✅ 결론적으로는 &quot;update 쿼리를 직접 사용&quot;하는 방식이 가장 적절하다고 생각해 이 방식을 채택했다.</h4>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[좋아요 기능 도입하기 - 1. 멱등한 API]]></title>
            <link>https://velog.io/@o_z/%EC%A2%8B%EC%95%84%EC%9A%94-%EA%B8%B0%EB%8A%A5-%EB%8F%84%EC%9E%85%ED%95%98%EA%B8%B0-1.-%EB%A9%B1%EB%93%B1%ED%95%9C-API</link>
            <guid>https://velog.io/@o_z/%EC%A2%8B%EC%95%84%EC%9A%94-%EA%B8%B0%EB%8A%A5-%EB%8F%84%EC%9E%85%ED%95%98%EA%B8%B0-1.-%EB%A9%B1%EB%93%B1%ED%95%9C-API</guid>
            <pubDate>Mon, 19 Jan 2026 14:49:51 GMT</pubDate>
            <description><![CDATA[<p>봄봄 챌린지에 코멘트 좋아요 기능이 추가된다. 
좋아요가 많은 코멘트라고 항상 더 가치있다고 보장할 순 없지만, 더 가치있을 확률이 높다. 
그래서 사용자에게 더 가치있는 정보를 먼저 보여줄 수 있도록 좋아요가 많은 순으로 정렬해서 보여주고자 좋아요 기능을 추가한다.</p>
<p>좋아요 기능 도입을 위해 고려해야할 사항들은 다음과 같다.</p>
<blockquote>
<ol>
<li>좋아요를 등록/취소하는 API를 추가한다.</li>
<li>코멘트 목록을 조회할 땐 좋아요 개수, 내가 좋아요를 눌렀는지 여부를 추가한다.</li>
</ol>
</blockquote>
<hr>
<h2 id="1-좋아요-도메인-세팅하기">1. 좋아요 도메인 세팅하기</h2>
<h3 id="코멘트-좋아요-엔티티-추가">코멘트 좋아요 엔티티 추가</h3>
<p>하나의 코멘트에는 한 사람 당 하나의 좋아요만 누를 수 있다. 누가 어떤 코멘트에 좋아요를 눌렀는지 저장하기 위해 코멘트 좋아요 엔티티를 추가한다.</p>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChallengeCommentLike extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private Long participantId; // 좋아요 누른 회원 아이디 (FK)

    @Column(nullable = false)
    private Long commentId; // 좋아요 코멘트 (FK)

    @Builder
    public ChallengeCommentLike(@NonNull Long memberId, @NonNull Long commentId) {
        this.memberId = memberId;
        this.commentId = commentId;
    }
}</code></pre>
<h3 id="코멘트에-좋아요-개수-필드-추가">코멘트에 좋아요 개수 필드 추가</h3>
<p>코멘트 목록을 조회할 때 좋아요 개수가 필요한데, 이 때 두 가지 계산 방법이 있다.</p>
<p><strong>1. 목록을 조회할 때마다 <code>commentId</code> 에 해당하는 <code>ChallengeCommentLike</code> 개수 계산하기</strong></p>
<ul>
<li><strong>장점</strong> : 코멘트 등록/삭제와 개수 동기화 문제가 발생하지 않는다. <code>ChallengeCommentLike</code> 를 직접 count() 하는 것이 원천이기 때문이다.</li>
<li><strong>단점</strong> : 코멘트 개수를 조회할 때 비용이 커질 수 있다. 좋아요 데이터 수가 많아질수록 쿼리가 무거워진다. 캐시를 쓰지 않으면 조회 성능이 현저히 떨어진다.</li>
</ul>
<p><strong>2. 반정규화로 <code>ChallengeComment</code>에 좋아요 개수 컬럼을 추가해 이를 보여주기</strong></p>
<ul>
<li><strong>장점</strong> : 코멘트 목록 조회 시 비용이 싸다. ‘좋아요 많은 순’ 같은 정렬/필터링도 쉽게 적용 가능하다. 성능 저하 우려가 거의 없다.</li>
<li><strong>단점</strong> : 동시성/정합성을 맞춰야 한다. 좋아요 추가/취소 시 좋아요 개수 컬럼도 함께 업데이트 되어야 하는데, 이 작업이 트랜잭션 경계에 서로 들어갈 수 있어 race condition이 발생할 수 있다. 컬럼 업데이트 요청이 순간적으로 몰리면 해당 레코드에 대한 UPDATE 락 경합/대기가 발생할 수 있다.</li>
</ul>
<p>나는 두 번째 방법 <strong>좋아요 개수 컬럼 추가하기</strong>를 선택했다.</p>
<p>첫 번째 방법은 <strong>코멘트 목록 조회 횟수 ≤ 코멘트 좋아요 등록/취소 횟수</strong> 인 케이스에서 유리하다고 생각한다.</p>
<p>반면 두 번째 방법은 <strong>코멘트 목록 조회 횟수 &gt; 코멘트 좋아요 등록/취소 횟수</strong> 일 경우 유리하다.</p>
<p>좋아요 등록/취소 횟수가 더 많은 케이스는 보통 실시간 스트리밍에서 한 사람이 좋아요를 여러 개 누를 수 있는 경우가 대부분일 것이다. 짧은 시간 내에 좋아요를 많이 눌러야 하는 이벤트 상황에 폭발적으로 좋아요 개수 변동이 일어날 것이므로 좋아요 등록/취소 횟수가 조회보다 더 많을 것이다.</p>
<p>하지만 우리 서비스 특성상 좋아요 등록/취소보다 조회는 이벤트처럼 짧은 시간 내에 많이 발생할 케이스도 아니고, 한 사람 당 하나의 좋아요가 제한되기에 폭발적으로 증가하는 일은 없을 것이다.</p>
<p><strong>반정규화</strong>가 필요한 시점은 <strong>&#39;정규화로 인한 조회 성능 하락&#39;</strong>일 때 필요하다.
특히나 데이터 집계가 많이 필요한 테이블일 경우에 가치있는 솔루션이다. (ex: 개수 총합, 계산)
반정규화의 단점은 데이터 중복을 허용하게 되는 것이므로 정합성에서 문제가 발생할 수 있다는 점이다.</p>
<p>따라서 <strong>조회 횟수가 등록/취소 횟수보다 월등히 높을 것이라고 판단해 조회 성능을 챙기고자</strong> 2번 방식으로 결정했고, 코멘트 엔티티에 좋아요 개수 컬럼을 추가했다.</p>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChallengeComment extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private Long participantId;

    @Column(nullable = false)
    private String comment;

        ...

    @Builder.Default
    private int likeCount; // 좋아요 개수 컬럼 추가, 명시를 안하면 기본값은 0이다.</code></pre>
<hr>
<h2 id="2-api-설계-고민하기">2. API 설계 고민하기</h2>
<p>크게 구현 방법은 두 가지가 있을 것이다.</p>
<blockquote>
<ol>
<li>&#39;좋아요 상태 업데이트&#39; 관점으로 <code>PATCH</code> API 하나로 사용하기</li>
<li>&#39;좋아요 등록&#39;/&#39;좋아요 취소&#39;의 <code>PUT</code>/<code>DELETE</code> API 두 개로 사용하기</li>
</ol>
</blockquote>
<h3 id="1️⃣-patch-api-하나로-사용하기">1️⃣ PATCH API 하나로 사용하기</h3>
<p>좋아요 업데이트 API를 PATCH 하나로 두어, 좋아요 등록/취소 여부 상관없이 하나의 API로 처리한다.</p>
<pre><code class="language-java">@Transactional
public ChallengeCommentLikeResponse updateChallengeCommentLike(Long memberId, Long challengeId, Long commentId) {
    // 해당 챌린지에 참여 정보 조회
    ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);

    // 코멘트 조회 해오기
    ChallengeComment comment = challengeCommentRepository.findById(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);

    // 좋아요 존재 여부에 따른 분기처리
    Optional&lt;ChallengeCommentLike&gt; like = challengeCommentLikeRepository.findByParticipantIdAndCommentId(participant.getId(), comment.getId());

    if(like.isEmpty()){ // 좋아요 누르지 않았을 경우
        challengeCommentLikeRepository.save( // 좋아요 등록
            ChallengeCommentLike.builder()
              .participantId(participant.getId())
                  .commentId(comment.getId())
                  .build()
           );

           comment.updateLikeCount(+1); // 좋아요 개수 업데이트
           return;
    }

    // 좋아요 눌렀을 경우
    challengeCommentLikeRepository.delete(like.get()); // 좋아요 데이터 삭제
    comment.updateLikeCount(-1); // 좋아요 개수 업데이트

    return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}</code></pre>
<ul>
<li><strong>장점</strong>: API 구조가 단순해진다.</li>
<li><strong>단점</strong>: 네트워크 상황에 의해 재시도 되는 로직이 수행될 경우, 사용자 의도와는 다르게 API가 두 번 호출되면서 좋아요가 취소될 수 있다. 좋아요가 추가됐는지/삭제됐는지 추적이 어렵다.</li>
</ul>
<h3 id="2️⃣-putdelete-api-분리">2️⃣ PUT/DELETE API 분리</h3>
<p>좋아요 등록은 PUT, 좋아요 취소는 DELETE를 사용하도록 API를 분리하는 방식이다.</p>
<pre><code class="language-java">@Transactional
public ChallengeCommentLikeResponse addCommentLike(Long memberId, Long challengeId, Long commentId){
    // 해당 챌린지에 참여 정보 조회
    ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);

    // 코멘트 조회 해오기
    ChallengeComment comment = challengeCommentRepository.findById(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);

    // 좋아요 데이터 등록
    challengeCommentLikeRepository.save(
            ChallengeCommentLike.builder()
                .participantId(participant.getId())
                .commentId(comment.getId())
                .build()
     );
    comment.updateLikeCount(+1); // 좋아요 개수 업데이트

    return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}

@Transactional
public ChallengeCommentLikeResponse deleteCommentLike(Long memberId, Long challengeId, Long commentId){
    // 해당 챌린지에 참여 정보 조회
    ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);

    // 코멘트 조회 해오기
    ChallengeComment comment = getChallengeComment(commentId);

    // 좋아요 데이터 삭제
    challengeCommentLikeRepository.deleteByPariticpantIdAndCommentId(participant.getId(), comment.getId());
    comment.updateLikeCount(-1); // 좋아요 개수 업데이트

    return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}</code></pre>
<ul>
<li><strong>장점</strong>: API를 명확하게 분리하면서 PUT/DELETE로 멱등성을 기대할 수 있다. 네트워크 상황에 의한 의도치않은 재시도가 발생하더라도 멱등성으로 인해 사용자 의도를 해치지 않을 수 있다. (지금 당장의 코드에서는 멱등성 보장이 안보이지만, 구체적으로 구현 시 보장하도록 수정할 예정이다.)</li>
<li><strong>단점</strong>: API 분리로 복잡해진다. 클라이언트는 상황에 따라 API를 다르게 요청해야한다.</li>
</ul>
<h3 id="❓-멱등성이란">❓ 멱등성이란?</h3>
<p><strong><a href="https://developer.mozilla.org/ko/docs/Glossary/Idempotent">API의 멱등성</a></strong>은 <strong>클라이언트가 동일한 요청을 여러 번 보내더라도 동일한 서버 상태를 보장받는 개념</strong>이다.</p>
<p>일시적인 네트워크 오류에 따라 클라이언트가 API 실패 또는 타임아웃을 받게 될 경우, 안전하게 재시도해서 의도했던 결과를 받을 수 있도록 설계해야한다. 결제같은 중요한 API는 멱등성이 보장되지 않을 경우 이중결제가 발생하면서 사용자 의도와 다른 서버 결과를 초래할 수 있다. 
HTTP 메서드 중 <strong>멱등성을 보장하는 메서드</strong>는 대표적으로 <strong><code>PUT</code></strong>, <strong><code>DELETE</code></strong>, <strong><code>GET</code></strong> 이 있으며, 보장하지 않는 메서드는 <code>POST</code>, <code>PATCH</code> 가 있다.</p>
<p>여기서 <strong>‘네트워크 상태로 인한 재시도가 자주 발생하는가?’</strong> 라는 질문이 발생할 수 있는데, 지금 API는 자주 발생할 수 있다.</p>
<p>사용자 입장에서 좋아요 버튼을 눌렀을 때 잠시 네트워크 상태로 인해 즉각적인 변화가 보이지 않으면, 다시 한 번 눌러보는 경우는 빈번한 케이스이다.</p>
<p>또한, 모바일에서는 HTTP 클라이언트 라이브러리가 연결 문제 발생 시, 조용히 복구하기 위해 재시도하기도 한다.</p>
<p>위처럼 재호출이 자주 발생할 수 있는 좋아요 기능은 API 멱등성을 보장해 사용자 의도를 해치지 않는 것이 더 중요할 것이라고 판단했다. 그래서 <code>PUT</code>/<code>DELETE</code> 메서드로 API를 분리하고자 한다.</p>
<hr>
<h2 id="3-api의-멱등성-보장하기">3. API의 멱등성 보장하기</h2>
<h3 id="좋아요-등록-api">좋아요 등록 API</h3>
<p><strong>PUT</strong> 메서드는 데이터가 존재하지 않으면 삽입, 존재하면 그대로 skip해 멱등성을 보장한다.</p>
<ul>
<li>좋아요 등록 API flow</li>
</ul>
<blockquote>
<ol>
<li>챌린지 참여 정보를 조회한다.</li>
<li>해당 코멘트를 조회한다.</li>
<li>현재 사용자에 대한 좋아요 정보를 가져온다.
4-1. 존재하지 않을 경우, 새로 생성하고 insert 한다. likeCount++도 수행한다.
4-2. 존재할 경우, return 한다.</li>
</ol>
</blockquote>
<h4 id="🤔-put의-멱등성-보장-방법"><strong>🤔 <code>PUT</code>의 멱등성 보장 방법</strong></h4>
<p><strong>1️⃣ UNIQUE 제약 사용해서 예외 catch 후 처리하기</strong></p>
<p>4-1, 4-2 과정을 <code>ChallengeCommentLike</code> 의 unique 제약 {comment_id, participant_id}로 보장한다. 중복 insert가 발생할 경우, unique 제약으로 예외가 발생하면서 조용히 return해 넘어가는 방식으로 구현한다.</p>
<pre><code class="language-java">@Transactional
public ChallengeCommentLikeResponse addCommentLike(Long memberId, Long challengeId, Long commentId){
    // 해당 챌린지에 참여 정보 조회
    ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);

        // 코멘트 조회 해오기
        ChallengeComment comment = challengeCommentRepository.findById(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);

        // 좋아요 데이터 등록
        try {
              challengeCommentLikeRepository.save(
                  ChallengeCommentLike.builder()
                    .participantId(participant.getId())
                        .commentId(comment.getId())
                        .build()
            );
              comment.updateLikeCount(+1); // 좋아요 개수 업데이트
        } catch (DataIntegrityViolationException e){
        String violated = extractConstraintName(e);
        if (UK_COMMENT_LIKE.equalsIgnoreCase(violated)) {
            log.warn(&quot;코멘트 좋아요가 이미 존재합니다. -&gt; skip. participantId={}, commentId={}&quot;, participant.getId(), comment.getId());
            return;
        }
        throw e;
        }

        return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}

private String extractConstraintName(Throwable e) {
    Throwable cur = e;
    while (cur != null) {
        if (cur instanceof ConstraintViolationException cve) {
            return cve.getConstraintName();
        }
        cur = cur.getCause();
    }
        return null;
}</code></pre>
<p>2️⃣ <strong>MySQL의 <code>INSERT IGNORE</code> 쿼리를 사용해 insert 된 행 개수에 따라 처리하기</strong></p>
<p><code>INSERT IGNORE</code> 을 사용하면 UNIQUE 제약으로 인해 중복되는 데이터 삽입일 경우 무시한다. 이렇게 삽입한 데이터 개수를 repository의 return 값으로 받으면 1일 경우 좋아요 데이터가 추가된 것이고, 0일 경우 중복 데이터로 삽입되지 않은 것이다. insert 된 개수가 1일때만 좋아요 개수를 업데이트 한다.</p>
<pre><code class="language-java">@Transactional
public ChallengeCommentLikeResponse addCommentLike(Long memberId, Long challengeId, Long commentId){
    // 해당 챌린지에 참여 정보 조회
    ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);

        // 코멘트 조회 해오기
        ChallengeComment comment = challengeCommentRepository.findById(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);

        // 좋아요 데이터 등록
      int insertCount = challengeCommentLikeRepository.insertIgnoreByParticipantIdAndCommentId()
                participant.getId(),
                  comment.getId()
      );

      if(insertCount == 1){ // 좋아요가 insert 된 경우
          comment.updateLikeCount(+1); // 좋아요 개수 업데이트
        }

        return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}

public interface ChallengeCommentLikeRepository extends JpaRepository&lt;ChallengeCommentLike, Long&gt; {
    @Modifying
    @Query(value = &quot;&quot;&quot;
        INSERT IGNORE INTO challenge_comment_like (participant_id, comment_id)
        VALUES (:participantId, :commentId)
        &quot;&quot;&quot;, nativeQuery = true)
    int insertIgnoreByParticipantIdAndCommentId(@Param(&quot;participantId&quot;) Long participantId,
                     @Param(&quot;commentId&quot;) Long commentId);
}</code></pre>
<p>첫 번째 방식에서 나타나는 <code>DataIntegrityViolationException</code> 의 경우, 원래 UNIQUE 제약 뿐 만 아니라 데이터 삽입 시 발생할 수 있는 다양한 제약으로 인해 발생할 수 있는 예외이다. 이를 파싱하고 UNIQUE 제약으로 발생한 예외인지 추출해야한다. 이는 추후에 UNIQUE 제약의 이름이 변경될 경우 이 코드도 건들여 수정해야 하므로 유지보수에서도 유리하지 못하다.</p>
<p>두 번째 방식을 사용하면 <code>INSERT IGNORE</code>을 통해 명확하게 내가 원하는 멱등성을 보장할 수 있으며, 코드도 더 깔끔해 가독성이 좋기도 하다.</p>
<p>따라서 두 개 방식 중 <strong>2번째 <code>INSERT IGNORE</code> 사용하기</strong>를 채택했다.</p>
<h3 id="좋아요-취소-api">좋아요 취소 API</h3>
<p><strong>DELETE</strong> 메서드는 데이터가 존재하면 삭제, 존재하지 않으면 그대로 skip해 멱등성을 보장한다.</p>
<ul>
<li>좋아요 취소 API flow</li>
</ul>
<blockquote>
<ol>
<li>챌린지 참여 정보를 조회한다.</li>
<li>해당 코멘트를 조회한다.</li>
<li>현재 사용자에 대한 좋아요 정보를 가져온다.
4-1. 존재할 경우, 좋아요 정보를 delete 한다. likeCount--도 수행한다.
4-2. 존재하지 않을 경우, return한다.</li>
</ol>
</blockquote>
<p>delete는 query에서부터 멱등성이 보장된다. 따라서 insert와 마찬가지로 delete한 row 수 케이스에 따라 1개일 경우 좋아요 개수를 업데이트하고 0일 경우는 skip해 멱등성을 보장한다.</p>
<pre><code class="language-java">@Transactional
public ChallengeCommentLikeResponse deleteCommentLike(Long memberId, Long challengeId, Long commentId){
    // 해당 챌린지에 참여 정보 조회
    ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);

        // 코멘트 조회 해오기
        ChallengeComment comment = challengeCommentRepository.findById(commentId)
            .orElseThrow(() -&gt; new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);

        // 좋아요 데이터 삭제
    int deletedCount = challengeCommentLikeRepository.deleteByPariticpantIdAndCommentId(
            participant.getId(), 
            comment.getId()
        );

        if(deletedCount == 1) { // 좋아요가 delete 된 경우
            comment.updateLikeCount(-1); // 좋아요 개수 업데이트
        }

        return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}</code></pre>
<hr>
<p>일단 여기까지 좋아요 기능 설계 시 멱등성을 고려하는 방법이었다.
다음에는 좋아요 기능의 대표적 문제점인 동시성 문제에 대해 정리하고자한다.</p>
<hr>
<blockquote>
<p>참고
<a href="https://developer.mozilla.org/ko/docs/Glossary/Idempotent!%5B%5D(https://velog.velcdn.com/images/o_z/post/c4b1fde5-716f-474c-81c4-2d67bca69a16/image.png)">https://developer.mozilla.org/ko/docs/Glossary/Idempotent![](https://velog.velcdn.com/images/o_z/post/c4b1fde5-716f-474c-81c4-2d67bca69a16/image.png)</a>
<img src="https://velog.velcdn.com/images/o_z/post/97d7afaf-c5cf-43da-877c-529cbe965b41/image.png" alt=""></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[EC2 디스크 Full 장애 추적하기 (feat. 범인은 Docker 로그)]]></title>
            <link>https://velog.io/@o_z/EC2-%EB%94%94%EC%8A%A4%ED%81%AC-Full-%EC%9E%A5%EC%95%A0-%EC%B6%94%EC%A0%81%ED%95%98%EA%B8%B0-%EB%B2%94%EC%9D%B8%EC%9D%80-Docker-%EB%A1%9C%EA%B7%B8</link>
            <guid>https://velog.io/@o_z/EC2-%EB%94%94%EC%8A%A4%ED%81%AC-Full-%EC%9E%A5%EC%95%A0-%EC%B6%94%EC%A0%81%ED%95%98%EA%B8%B0-%EB%B2%94%EC%9D%B8%EC%9D%80-Docker-%EB%A1%9C%EA%B7%B8</guid>
            <pubDate>Sun, 18 Jan 2026 13:21:09 GMT</pubDate>
            <description><![CDATA[<p>알림 전송을 위한 FCM 서버를 배포하는 도중 아래 이유로 배포에 실패했다.
<img src="https://velog.velcdn.com/images/o_z/post/c9992127-4b82-4cbf-bdeb-e933c6d3b5cd/image.png" alt=""></p>
<blockquote>
<p><strong>no space left on device</strong></p>
</blockquote>
<p>docker 이미지를 레포지토리에서 pull 해야하는데 <strong>서버에 디스크 공간이 부족</strong>하다는 이유이다.
뭐가 이렇게 디스크를 많이 잡아먹나 싶어서 서버를 확인하러 갔다.</p>
<pre><code>[ec2-user@ bin]$ df -h
Filesystem        Size  Used Avail Use% Mounted on
devtmpfs          4.0M     0  4.0M   0% /dev
tmpfs             1.9G     0  1.9G   0% /dev/shm
tmpfs             768M  924K  767M   1% /run
/dev/nvme0n1p1     30G   30G    0M  100% /
tmpfs             1.9G     0  1.9G   0% /tmp
/dev/nvme0n1p128   10M  1.4M  8.7M  14% /boot/efi
tmpfs             384M     0  384M   0% /run/user/0</code></pre><p>EBS 볼륨을 <strong>30G</strong> 할당했는데 다썼다..
당시에는 다른 작업 우선순위가 높아 이에 대한 근본적인 원인을 파악할 시간이 없었다.
그래서 일단 급한 불 끄자는 생각에 임시방편으로 50G까지 볼륨을 올렸다.</p>
<p>저 사태가 있고 며칠 지나지 않아서, 다른 작업들 다 끝내고 지금은 괜찮나 싶어 서버를 확인했는데 이번에도 거의 disk full 상태에 육박했었다.</p>
<pre><code>[ec2-user@ bin]$ df -h
Filesystem      Size  Used Avail Use% Mounted on
devtmpfs        4.0M     0  4.0M   0% /dev
tmpfs           1.9G     0  1.9G   0% /dev/shm
tmpfs           768M   73M  695M  10% /run
/dev/nvme0n1p1   50G   47G    3G  97% /
tmpfs           1.9G     0  1.9G   0% /tmp
/dev/nvme0n1p128  10M  1.4M  8.7M  14% /boot/efi
tmpfs           384M     0  384M   0% /run/user/0</code></pre><p>EBS를 무작정 늘릴 순 없기에 이젠 근본적인 원인을 파악해서 해결해야했다.</p>
<hr>
<h2 id="용량을-많이-차지하는-공간-확인하기">용량을 많이 차지하는 공간 확인하기</h2>
<p>현재 디렉토리들 중 어디가 잡아먹는지를 확인하려고 했다.
<code>/var</code>, <code>/home-user</code>, <code>/opt</code>, <code>/usr</code> 디렉토리들을 확인하려고 했다.</p>
<p><code>/var</code> 디렉토리 공간을 확인해보았다.</p>
<pre><code class="language-shell">sudo du -xhd1 /var | sort -h</code></pre>
<pre><code>[ec2-user@ ~]$ sudo du -xhd1 /var | sort -h
0       /var/account
0       /var/adm
0       /var/db
0       /var/empty
0       /var/ftp
0       /var/games
0       /var/kerberos
0       /var/local
0       /var/nis
0       /var/opt
0       /var/preserve
0       /var/spool
0       /var/yp
16K     /var/tmp
64M     /var/cache
358M    /var/log
36G     /var
36G     /var/lib</code></pre><p><code>/var/lib</code>가 무려 <strong>36G</strong>나 잡아먹고 있었다. 더 세분화해서 정확히 무엇이 원인인지 파악하고자 파고들었다.</p>
<pre><code>sudo du -xhd1 /var/lib | sort -h</code></pre><pre><code>[ec2-user@ ~]$ sudo du -xhd1 /var/lib | sort -h
0       /var/lib/games
0       /var/lib/gssproxy
(생략)   ...
18M     /var/lib/selinux
36G     /var/lib
36G     /var/lib/docker</code></pre><p>docker가 <code>/var/lib</code>의 대부분 용량을 차지하고 있었다.
<code>/var/lib/docker</code> 용량을 파고들어보자.</p>
<pre><code>[ec2-user@ip-10-0-5-182 ~]$ sudo du -xhd1 /var/lib/docker | sort -h
0       /var/lib/docker/plugins
(생략)   ...
5.8M    /var/lib/docker/image
30M     /var/lib/docker/volumes
3.1G    /var/lib/docker/overlay2
32.7G   /var/lib/docker/containers
36G     /var/lib/docker</code></pre><p>36GB 중 32.7GB를 먹는 <code>/containers</code> 디렉토리..</p>
<p>처음엔 <code>/docker/containers</code>에 이미지나 볼륨이 저장되어서 
이렇게까지 큰 용량을 차지하는 것이라고 예상했다.</p>
<p>그런데 오히려 <code>/image</code>, <code>/volumes</code> 디렉토리는 바깥에 따로 있었고, 
찾아보니 <code>/containers</code>의 대부분은 <strong>컨테이너 로그</strong>가 차지한다는 사실을 알게됐다.</p>
<blockquote>
<p>** ❓ <code>/var/lib/docker/containers</code>에는 어떤 데이터가 저장되어있을까? **</p>
</blockquote>
<ul>
<li>stdout/stderr로 출력되는 컨테이너 로그 (<code>*-json.log</code>)</li>
<li>컨테이너 메타데이터 (ex: 환경변수, 네트워크 등 상태정보)</li>
</ul>
<pre><code>[ec2-user@ ~]$ sudo du -h /var/lib/docker/containers | sort -h | tail
0       /var/lib/docker/containers/8e10d3f0ca6e74807a4e66bfde.../mounts
0       /var/lib/docker/containers/931f313c2b036a41cb39a5ce6b.../checkpoints
0       /var/lib/docker/containers/555e26892d87ae65ce7e4b4a7a2...
(생략)   ...
241M    /var/lib/docker/containers/8e10d3f0ca6e74807a4e66bfde2...
31G     /var/lib/docker/containers/11ff45adf6149a269721ec345c1...
32G     /var/lib/docker/containers</code></pre><p>특정 컨테이너에 대한 로그가 31G 쌓인 것이었다.
어떤 컨테이너에서 이렇게 로그를 많이 쌓나 확인했더니 컨테이너 ID가 FCM 애플리케이션 컨테이너였다.</p>
<p><strong>왜이렇게까지 docker 컨테이너의 로그가 폭증했을까?</strong></p>
<hr>
<h2 id="docker는-default-log-rotation이-없다">Docker는 default log rotation이 없다</h2>
<p>Docker처럼 잘 만들어진 플랫폼은 log rotation이나 retention 정책에 대해 
당연히 default 값이 있을 것이라고 생각했다.</p>
<p>근데 알고보니 Docker는 내가 직접 설정해주지 않는 이상, 로그 보관과 관련된 default 설정이 없다고 한다.</p>
<h3 id="docker의-logging-driver">Docker의 logging driver</h3>
<p>Docker는 <a href="https://docs.docker.com/engine/logging/configure/">logging driver</a>가 탑재되어있다.
흔히 우리가 <code>docker logs</code>로 로그를 가져올 수 있도록 해주는 로그 메커니즘이다.
각 Docker에는 이 logging driver가 데몬 프로세스로 탑재되어 있다.</p>
<blockquote>
<p>&quot;<em>As a default, Docker uses the <strong><code>json-file</code></strong> logging driver, which caches container logs as JSON internally.</em>&quot;</p>
</blockquote>
<p><em>&quot;기본적으로 Docker는 컨테이너 로그를 JSON 형태로 내부적으로 캐싱하는 <code>json-file</code> 로깅 드라이버를 사용합니다.&quot;</em></p>
<p>공식문서를 참고해보면 Docker의 default driver는 <code>json-file</code> 로깅 드라이버라고 한다.</p>
<p>그 아래로 공식문서를 읽다보면 logging driver 설정에 대한 Tip 부분이 있다.</p>
<blockquote>
<p>&quot;<em>*<em>Use the <code>local</code> logging driver to prevent disk-exhaustion. *</em>
By default, no log-rotation is performed. 
As a result, log-files stored by the default <code>json-file</code> logging driver logging driver can cause a significant amount of disk space to be used for containers that generate much output, which can lead to disk space exhaustion.</em>&quot;</p>
</blockquote>
<p><em>&quot;<strong>디스크 공간 부족을 방지하려면 <code>local</code> 로깅 드라이버를 사용하세요.</strong> 
기본적으로 로그 rotation은 수행되지 않습니다. 
따라서 기본 <code>json-file</code> 로깅 드라이버가 저장하는 로그 파일은 출력을 많이 생성하는 컨테이너에서 상당한 디스크 공간을 차지하여 디스크 공간 부족으로 이어질 수 있습니다.&quot;</em></p>
<p>Docker에서도 공식적으로 디스크 공간 차지 문제 때문에 logging driver를 
<code>json-file</code>보다 <strong><code>local</code></strong>로 사용하도록 권장하고 있다.</p>
<p><strong><code>json-file</code>과 <code>local</code>은 무슨 차이일까?</strong></p>
<h4 id="json-file">json-file</h4>
<ul>
<li>컨테이너 stdout/stderr를 JSON 라인 포맷으로 파일에 그대로 저장</li>
<li>default log rotation / retention 없음<h4 id="local">local</h4>
</li>
<li>stdout/stderr를 Docker가 관리하는 내부 바이너리/최적화 포맷으로 저장하고 자동 압축</li>
<li>default log rotation / retention 있음</li>
</ul>
<blockquote>
<p><strong>❓ Docker는 왜 default logging driver를 <code>json-file</code>로 했을까?</strong></p>
</blockquote>
<p>두 개의 드라이버 설명을 보면 <code>json-file</code>보다 <code>local</code>의 이점이 훨씬 커보였다.
그럼 Docker는 왜 default로 <code>local</code>을 안두고 <code>json-file</code>로 두었을까?</p>
<blockquote>
<p>아래는 Docker 공식문서 내용이다.</p>
</blockquote>
<p>&quot;<em><strong>Docker는 이전 버전의 Docker와의 하위 호환성을 유지</strong>하고 
Docker가 Kubernetes의 런타임으로 사용되는 상황을 위해 (로그 순환 기능이 없는) 
<code>json-file</code> 로깅 드라이버를 기본값으로 유지합니다.</em>&quot;</p>
<blockquote>
</blockquote>
<p>Docker 이전 버전의 하위호환성 문제와 쿠버네티스간 호환성 문제였다. 
<del>(옛날에는 쿠버네티스에서 Docker의 컨테이너 로그가 json-file로 쌓인다는 전제가 있었다고 한다.)</del></p>
<h3 id="우리-서버는-json-file을-고집해야-하는가">우리 서버는 json-file을 고집해야 하는가?</h3>
<p>이 문제를 위한 근본적인 해결책은 <strong>로깅의 rotation 지정</strong>이다.
따라서 options를 통해 rotation 설정만 켜주면 괜찮을 수 있다.
하지만 공식 문서가 권장하듯, <code>json-file</code>보다 <code>local</code>이 더 좋은 옵션이라고 판단해서
 <strong>&#39;<code>local</code>이 지금 우리 서버에 적절한가?&#39;</strong>를 고려하게 되었다.</p>
<p>그래서 우리 서버가 <code>json-file</code> 드라이버를 고집해야 하는 필수적인 이유가 있지 않다면 <code>local</code>로 변경해도 무방할 것이라고 생각했다.
<strong>우리 서버가 <code>json-file</code>을 고집해야 하는 이유가 있을까?</strong></p>
<p>서버는 Promtail을 통해 애플리케이션 로그를 수집하고, 이를 loki에게 push 해주는 방식이다.
이 때 Promtail의 <strong>Docker service discovery(docker_sd_configs)</strong>를 쓰는 구성일 경우, 
즉, <strong>Promtail이 docker log를 스크랩 하는 경우</strong> 공식 문서에 <strong>컨테이너는 <code>json-file</code> 또는 <code>journald</code> 로깅 드라이버여야 한다고 명시돼 있다.</strong> 
<img src="https://velog.velcdn.com/images/o_z/post/63cc37d9-ee7b-4565-907a-5ecdee5e1e1a/image.png" alt="">
이 경우 local로 바꾸면 Promtail이 기대하는 방식으로 못 읽을 수 있는 것이다.</p>
<p>하지만 지금 알림 서버는 logback 기반으로 생성된 로그를 스크래핑 하고 있었으므로,
<strong>docker log는 json format을 유지할 필요가 없었다.</strong></p>
<p>따라서 <code>local</code> 드라이버를 적용해도 무방하겠다는 결론을 지었다.</p>
<h3 id="fcm-서버에-적용하기">FCM 서버에 적용하기</h3>
<p>그래서 Docker 공식 문서에서도 권장함에 따라, 우리도 <code>json-file</code>에서 <code>local</code>로 logging driver를 변경했다.
docker-compose.yml 파일에서 설정할 수 있다.</p>
<pre><code class="language-yaml">services:
  fcm-app:
      ...
    logging:
      driver: &quot;local&quot;
      options:
          max-size: &quot;50m&quot;
          max-file: &quot;5&quot;</code></pre>
<ul>
<li><strong><code>max-size</code></strong>: 로그 파일 1개가 커질 수 있는 최대 크기이다. (default: 100m)<ul>
<li>이 크기에 도달할 시 Docker가 로그 rotation을 시켜 새 파일로 넘긴다.</li>
</ul>
</li>
<li><strong><code>max-file</code></strong>: rotation 된 로그 파일을 유지할 최대 개수이다. (default: 5)<ul>
<li>파일 개수가 <code>max-file</code>개를 넘으면 가장 오래된 파일부터 삭제한다.</li>
</ul>
</li>
</ul>
<blockquote>
<p>위 설정들은 <code>json-file</code> 드라이버를 사용해도 위처럼 설정할 수 있다.
<del>default 값이 없을 뿐이다.</del></p>
</blockquote>
<p>따라서 실제 컨테이너당 로그 상한은 <code>max-size * max-file</code> 이 될 것이다. 
두 설정 뿐 만 아니라 logging driver도 <code>local</code>로 지정했으므로 압축까지 발생해서
사실상 이보다 더 작은 용량을 차지할 것이다.</p>
<p>무난한 운영 기본 값으로 <code>max-size: 50m</code>, <code>max-file: 5</code>로 지정해, 
FCM 서버 컨테이너 당 약 최대 <strong>250MB</strong> 수준으로 유지되도록 지정했다.</p>
<hr>
<h2 id="docker-log-rotation-적용-후-디스크-용량">Docker log rotation 적용 후 디스크 용량</h2>
<p>적용 후 docker의 디스크 용량을 확인해보았다.
확실하게 <code>/container</code> 용량이 완전히 줄어들었다.</p>
<pre><code>[ec2-user@ ~]$ sudo du -xhd1 /var/lib/docker | sort -h
0       /var/lib/docker/plugins
0       /var/lib/docker/runtimes
0       /var/lib/docker/swarm
0       /var/lib/docker/tmp
100K    /var/lib/docker/buildkit
108K    /var/lib/docker/network
5.8M    /var/lib/docker/image
26M     /var/lib/docker/volumes
987M    /var/lib/docker/containers
3.1G    /var/lib/docker/overlay2
4.1G    /var/lib/docker</code></pre><p>전체 디스크 사용량도 확인해보았다.
<strong>97%</strong> 사용하던 디스크가 <strong>35%</strong>까지 내려간 모습이다.</p>
<pre><code>[ec2-user@ bin]$ df -h
Filesystem        Size  Used Avail Use% Mounted on
devtmpfs          4.0M     0  4.0M   0% /dev
tmpfs             1.9G     0  1.9G   0% /dev/shm
tmpfs             768M  1.3M  766M   1% /run
/dev/nvme0n1p1     50G   18G   33G  35% /
tmpfs             1.9G     0  1.9G   0% /tmp
/dev/nvme0n1p128   10M  1.4M  8.7M  14% /boot/efi
tmpfs             384M     0  384M   0% /run/user/0</code></pre><p>(2월 5일 기준, 여전히 35~36%의 사용률을 유지하고 있다.)</p>
<hr>
<p>위에서 설정한 이 값들이 정답이라는건 아니다.</p>
<p>로그 보관 정책은 서비스 특성과 내부 정책에 따라 다양할 수 있으며, 
디스크 사용량도 마냥 적다고 좋아할 것은 아니다.</p>
<p>로그를 운영상 얼마나 보관하고 있는 것이 적절할지, 안전한 목표 디스크 사용량도 고민해봐야한다.
꾸준히 모니터링하고 고민해봐야 할 사항인 것 같다.</p>
<hr>
<blockquote>
<p>참고
<a href="https://docs.docker.com/engine/logging/configure/">https://docs.docker.com/engine/logging/configure/</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[대용량 트래픽으로 인한 매트릭 단절 문제 해결하기]]></title>
            <link>https://velog.io/@o_z/%EB%A7%A4%ED%8A%B8%EB%A6%AD%EC%9D%B4-%EB%8B%A8%EC%A0%88%EB%90%98%EB%8A%94-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@o_z/%EB%A7%A4%ED%8A%B8%EB%A6%AD%EC%9D%B4-%EB%8B%A8%EC%A0%88%EB%90%98%EB%8A%94-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 13 Dec 2025 16:31:29 GMT</pubDate>
            <description><![CDATA[<p>봄봄에서 Grafana + Prometheus로 모니터링을 구축하면서 매트릭이 단절된 문제가 있었다.</p>
<p>다량의 API 요청 공격이 들어오니 일정 시간동안 매트릭이 수집되지 않던 것이다.
우리는 모니터링 인프라를 더 견고하게 분리하고, 공격 상황에서도 가능한 한 살아남는 Observability 구조를 다시 설계하기로 했다.</p>
<hr>
<h2 id="매트릭이-수집되지-않은-상황">매트릭이 수집되지 않은 상황</h2>
<p>데모데이(15:19 ~ 15:27) 때 발생한 현상이다.
15:19 ~ 15:27 구간동안 매트릭이 끊겨 지표를 관찰할 수 없었다.
<img src="https://velog.velcdn.com/images/o_z/post/eb1b369a-f753-40ce-b148-e16f10cc6221/image.png" alt=""></p>
<p>정확하게 몇 건의 요청을 동시 요청한지는 확실히 알지 못하지만 대략 3000건보다 많은 요청을 동시에 보낸 것으로 예상한다.</p>
<hr>
<h2 id="매트릭-단절이-발생한-원인-파악">매트릭 단절이 발생한 원인 파악</h2>
<h3 id="1️⃣-cpu-의심하기">1️⃣ CPU 의심하기</h3>
<p>처음에는 애플리케이션 서버의 CPU 사용량이 급증해서 서버가 비정상적으로 동작하고, 이로 인해 응답까지 제대로 전달되지 않은 것이라고 추측했다.
<img src="https://velog.velcdn.com/images/o_z/post/f9a6e920-4d62-4a21-8a6b-35ddf4dd3c78/image.png" alt="">하지만 위 사진처럼 Cloudwatch로 서버 매트릭을 확인한 결과 그 당시 최대 3.74%로, 서버의 CPU 부하라기엔 눈에 띄게 증가한 사용률은 아니었다.</p>
<p>이 지표를 보고 CPU 연산이 아닌 스레드, DB, 네트워크, 디스크 I/O 등에서 병목 현상이 발생했을 것이라고 예측했다.</p>
<h3 id="2️⃣-상황-재현하기">2️⃣ 상황 재현하기</h3>
<p>위 상황을 재현하기 위해 k6를 사용해 부하 테스트를 진행했다.</p>
<p>10개의 가상스레드로 당시처럼 <code>/api/v1/newsletters</code> API를 약 4000번 호출하는 테스트를 진행했다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/ac829c12-7f0a-41cf-879c-f6d80f48cc3c/image.png" alt="">역시나 매트릭이 끊겨서 요청 조차 가지 않았다.</p>
<p>그리고 k6는 진행하면서 수많은 error 및 warn 로그가 남았다.</p>
<pre><code>[ec2-user@ k6]$ docker compose run --rm k6

         /\      Grafana   /‾‾/
    /\  /  \     |\  __   /  /
   /  \/    \    | |/ /  /   ‾‾\
  /          \   |   (  |  (‾)  |
 / __________ \  |_|\_\  \_____/

     execution: local
        script: /scripts/newsletters.js
        output: InfluxDBv1

     scenarios: (100.00%) 1 scenario, 500 max VUs, 50s max duration (incl. graceful stop):
              * spike: Up to 500 looping VUs for 50s over 3 stages (gracefulRampDown: 0s, gracefulStop: 30s)


WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: read tcp 172.18.0.2:57342-&gt;3.39.40.13:443: read: connection reset b
y peer&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0012] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0013] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0013] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0013] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0013] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0013] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0013] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0013] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: read tcp 172.18.0.2:49426-&gt;3.39.40.13:443: read: connection reset b
y peer&quot;
WARN[0024] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0024] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0024] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0024] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0024] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;
WARN[0025] Request Failed                                error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;

     checks.........................: 26.25% 21 out of 80
     data_received..................: 1.9 MB 39 kB/s
     data_sent......................: 296 kB 5.9 kB/s
     http_req_blocked...............: avg=6.49ms   min=0s       med=5.36ms   max=52.71ms p(90)=15.02ms  p(95)=15.86ms
     http_req_connecting............: avg=978.15µs min=481.51µs med=672.08µs max=7.09ms  p(90)=1.45ms   p(95)=1.84ms
   ✗ http_req_duration..............: avg=5.3s     min=0s       med=204.37ms max=20.74s  p(90)=11.99s   p(95)=11.99s
       { expected_response:true }...: avg=2.14s    min=101.8ms  med=190.36ms max=20.74s  p(90)=405.19ms p(95)=20.54s
   ✗ http_req_failed................: 73.75% 59 out of 80
     http_req_receiving.............: avg=246.82ms min=0s       med=0s       max=18.91s  p(90)=17.82ms  p(95)=30ms
     http_req_sending...............: avg=155.78µs min=0s       med=40.62µs  max=4.15ms  p(90)=98.97µs  p(95)=361.02µs
     http_req_tls_handshaking.......: avg=5.46ms   min=0s       med=4.6ms    max=31.67ms p(90)=13.47ms  p(95)=14.98ms
     http_req_waiting...............: avg=5.06s    min=0s       med=193.34ms max=20.25s  p(90)=11.99s   p(95)=11.99s
     http_reqs......................: 80     1.599918/s
     iteration_duration.............: avg=10.83s   min=203.8ms  med=12.32s   max=32.95s  p(90)=12.53s   p(95)=32.21s
     iterations.....................: 40     0.799959/s
     vus............................: 8      min=8        max=500
     vus_max........................: 500    min=500      max=500


running (50.0s), 000/500 VUs, 40 complete and 500 interrupted iterations
spike ✓ [======================================] 001/500 VUs  50s
ERRO[0050] thresholds on metrics &#39;http_req_duration, http_req_failed&#39; have been crossed</code></pre><h4 id="1-actuatorprometheus에서-request-timeout-발생">1. <code>/actuator/prometheus</code>에서 request timeout 발생</h4>
<pre><code>WARN[0033] Request Failed     error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: request timeout&quot;</code></pre><p>부하테스트 진행 시 request timeout이 발생했다. k6가 설정한 timeout 시간 내 응답을 받지 못했단 뜻이다. 해당 구간에서 /actuator/prometheus는 응답 지연이 timeout을 초과할 정도로 느려졌다.</p>
<h4 id="2-api-prometheus에서-eof-발생">2. API, Prometheus에서 EOF 발생</h4>
<pre><code>WARN[0013] Request Failed     error=&quot;Get \&quot;https://api.bombom.com/api/v1/newsletters\&quot;: EOF&quot;
WARN[0013] Request Failed     error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: EOF&quot;</code></pre><p>HTTP 응답을 제대로 받기 전에 연결이 갑자기 종료되었을 때 발생하는 로그이다.
찾아보니 프록시나 서버가 과부하로 연결을 닫았을 경우, upstream이 죽거나 리셋/종료 했을 경우 발생하는 로그라고 한다.</p>
<h4 id="3-read-connection-reset-by-peer">3. read: connection reset by peer</h4>
<pre><code>WARN[0013] Request Failed     error=&quot;Get \&quot;https://api.bombom.com/actuator/prometheus\&quot;: read tcp 172.18.0.2:49426-&gt;3.39.40.13:443: read: connection reset by peer&quot;</code></pre><p>상대(peer)가 TCP RST로 강제로 연결 끊었다는 의미이다. RST란 TCP 통신에서 연결을 끊겠다는 의사의 패킷이다. 부하 중 서버나 프록시가 강제로 종료하려는 상황인 것이다.</p>
<h3 id="3️⃣-애플리케이션-의심하기">3️⃣ 애플리케이션 의심하기</h3>
<p>위 로그를 통해 처음에 우리가 기대한 결과는 애플리케이션에서 WARN이나 ERROR 레벨의 로그가 다량 발생하거나, DB 커넥션 풀을 모두 사용해서 바쁘게 DB CPU가 돌아가는 등 애플리케이션 서버에 부하가 발생하는 모습이었다. 
그로 인해 서버 부하로 애플리케이션이 종료되는 시점까지 다다를 것이라고 예상했다.</p>
<p>하지만, <strong>애플리케이션 로그에는 어떠한 WARN이나 ERROR 로그 모두 발생하지 않았고, 
요청이 폭주해도 애플리케이션은 잘 띄워져있었다.
또한 CPU도 처음 CloudWatch에서 관측했던 것처럼 스파이크 현상도 없었다.</strong></p>
<p>client → nginx → tomcat → application 의 요청 과정에서 application에는 로그가 남지 않았음을 확인했다.
이를 통해 애플리케이션 자체가 아닌 <strong>Tomcat에서의 대기 또는 서버 앞단에 설치한 Nginx</strong>가 병목 지점일 것이라고 범위를 좁혀갔다.</p>
<h3 id="4️⃣-nginx-의심하기">4️⃣ Nginx 의심하기</h3>
<p>프록시 서버 Nginx의 error.log를 먼저 분석했는데, 아래와 같이 warn 로그가 반복적으로 발생했다.</p>
<pre><code>2025/11/27 05:53:17 [warn] 2168#2168: 1024 worker_connections are not enough, reusing connections
2025/11/27 05:53:18 [warn] 2168#2168: 1024 worker_connections are not enough, reusing connections
2025/11/27 05:53:19 [warn] 2168#2168: *1326302 an upstream response is buffered to a temporary file /var/lib/nginx/tmp/proxy/2/81/0000001812 while reading upstream, client: 13.209.14.16, server: api-dev.bombom.news, request: &quot;GET /actuator/prometheusHTTP/1.1&quot;, upstream: &quot;http://127.0.0.1:8080/actuator/prometheus&quot;, host: &quot;api-dev.bombom.news&quot;
2025/11/27 05:53:19 [warn] 2168#2168: *1326372 an upstream response is buffered to a temporary file /var/lib/nginx/tmp/proxy/3/81/0000001813 while reading upstream, client: 13.209.14.16, server: api-dev.bombom.news, request: &quot;GET /actuator/prometheusHTTP/1.1&quot;, upstream: &quot;http://127.0.0.1:8080/actuator/prometheus&quot;, host: &quot;api-dev.bombom.news&quot;
2025/11/27 05:53:19 [warn] 2168#2168: 1024 worker_connections are not enough, reusing connections
2025/11/27 05:53:20 [warn] 2168#2168: 1024 worker_connections are not enough, reusing connections</code></pre><p>Nginx는 하나의 worker_process가 여러 개의 worker_connections를 갖는다.
이 worker_connection 하나 당 하나의 TCP 요청을 처리하지만, 사용하고 버려지는 것이 아니라 재사용 되기도 한다.
Nginx 워커 프로세스가 동시에 처리할 수 있는 연결 수 상한(worker_connections)에 가까워졌거나 도달했다는 경고의 로그로, 해당 시점에 Nginx가 동시 연결 처리 한계에 걸렸다는 의미일 수 있다.</p>
<p>또한, <code>an upstream response is buffered to a temporary file</code> 로그로 보아, 업스트림에서 오는 응답을 Nginx가 메모리 버퍼에 못 담아서, 디스크 임시파일(/var/lib/nginx/tmp/proxy/…)에 쓰면서 전달하고 있다는 의미이다. 디스크 I/O로 인한 더많은 부하가 발생하고 있다는 의미이기도 하다.</p>
<p>Nginx의 access.log에 업스트림으로부터 받아오는 실제 처리 시간과, Nginx와 업스트림의 응답코드 등 다양한 지표를 Nginx 로그에 추가해보았다. nginx.conf에서 설정하며, 추가한 지표들은 다음과 같다.</p>
<pre><code>http{
   log_format main
   &#39;[$time_local] &quot;$request&quot; &#39;
   &#39;st=$status rt=$request_time &#39;
   &#39;ust=$upstream_status &#39;
   &#39;uct=$upstream_connect_time uht=$upstream_header_time urt=$upstream_response_time&#39;;
   ...
}</code></pre><ul>
<li><code>st=$status</code> : Nginx가 클라이언트에 준 상태코드</li>
<li><code>rt=$request_time</code> : Nginx 관점 전체 시간</li>
<li><code>ust=$upstream_status</code> : 업스트림(Tomcat) 응답코드</li>
<li><code>urt=$upstream_response_time</code> : 업스트림이 응답 보내는 데 걸린 시간</li>
<li><code>uc=$upstream_connect_time</code> : 업스트림과 연결 맺는데 걸린 시간</li>
<li><code>uht=$upstream_header_time</code> : 업스트림 헤더 첫 바이트까지 시간(TTFB)</li>
</ul>
<p>이후 부하테스트를 진행했을 때 결과는 다음과 같다.</p>
<pre><code class="language-shell">[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=28.516 ust=- uct=0.000 uht=- urt=28.516
[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=24.292 ust=- uct=0.001 uht=- urt=24.292
[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=26.739 ust=- uct=0.001 uht=- urt=26.740
[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=20.885 ust=- uct=0.001 uht=- urt=20.886
[27/Nov/2025:07:45:06 +0000] &quot;GET /actuator/prometheus HTTP/1.1&quot; st=499 rt=11.992 ust=- uct=0.001 uht=- urt=11.992
[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=31.836 ust=- uct=0.000 uht=- urt=31.836
[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=31.767 ust=- uct=0.001 uht=- urt=31.768
[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=33.193 ust=- uct=0.000 uht=- urt=33.193
[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=36.365 ust=- uct=0.000 uht=- urt=36.365
[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=32.623 ust=- uct=0.000 uht=- urt=32.623
[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=28.698 ust=- uct=0.000 uht=- urt=28.698
[27/Nov/2025:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=12.842 ust=- uct=0.001 uht=- urt=12.843</code></pre>
<p>대부분 비슷한 패턴으로 나타나는 것 같아, API의 Nginx 로그를 하나 떼고 분석해보았다.</p>
<pre><code class="language-shell">[27/Nov/2026:07:45:06 +0000] &quot;GET /api/v1/newsletters HTTP/1.1&quot; st=499 rt=31.767 ust=- uct=0.001 uht=- urt=31.768</code></pre>
<ul>
<li><code>st=499</code> : Nginx가 클라이언트에게 상태코드로 499를 내보냈다.<ul>
<li>클라이언트가 먼저 timeout으로 연결을 닫아버렸기 때문이다.</li>
<li>Nginx는 더이상 업스트림 응답을 끝까지 받을 필요가 없어 업스트림 상태코드를 못받은채로 종료한다.</li>
</ul>
</li>
<li><code>rt=31.767</code> : Nginx 관점의 전체 처리 시간이다.<ul>
<li>NGINX에 들어오고 31.8초동안 진행/대기 하다가 끝났다.</li>
</ul>
</li>
<li><code>ust=-</code> : 업스트림의 상태코드를 받지 못했다.<ul>
<li>Tomcat이 응답코드나 헤더를 보내기 전에 요청이 종료된 것이다.</li>
</ul>
</li>
<li><code>uct=0.001</code> : Nginx에서 업스트림 TCP 연결 성립까지 걸린 시간이다.<ul>
<li>3-way Handshake가 발생하기까지 걸리는 시간으로 0.001초 걸렸다.</li>
<li>지연 없이 거의 즉시 성공했다.</li>
</ul>
</li>
<li><code>uht=-</code> : 업스트림에서 응답 헤더 첫 바이트를 보낼 때 걸리는 시간이다.<ul>
<li>헤더를 아예 받지 못한 것으로 보아, 응답이 시작되지도 않았음을 유추한다.</li>
</ul>
</li>
<li><code>urt=31.768</code> : 업스트림이 응답을 보내는데 걸리는 시간이다.<ul>
<li>하지만 응답의 시작도 받지 못했으므로, 사실상 요청을 보내둔 채로 업스트림을 기다린 시간이라고 봐도 무방하다.</li>
</ul>
</li>
</ul>
<p>즉, Nginx는 Tomcat과 연결을 맺긴 했지만 애플리케이션에서 처리하기 전에 클라이언트가 먼저 연결을 끊어서 요청이 종료된 것이다. 이 과정에서 실험 p(95)가 11s까지 올라가는 것을 보아, 스크랩은 timeout 때문에 끊긴 것이 확실하다.</p>
<h3 id="4️⃣-tomcat-의심하기">4️⃣ Tomcat 의심하기</h3>
<p>Nginx에서 Tomcat까지 도달 자체가 안되었다기엔 위에서 봤듯이 Nginx가 업스트림으로 연결까진 가능했다. 
Tomcat은 따로 설정 안해주면 로그가 안보여서 직접 설정하고 다시 테스트를 해봤다.</p>
<pre><code>[27/Nov/2026:15:55:48 +0900] 172.19.0.1 &quot;GET /api/v1/newsletters HTTP/1.0&quot; 200 1179560
[27/Nov/2026:15:55:48 +0900] 172.19.0.1 &quot;GET /actuator/prometheus HTTP/1.0&quot; 200 1336956
[27/Nov/2026:15:55:48 +0900] 172.19.0.1 &quot;GET /api/v1/newsletters HTTP/1.0&quot; 200 1065874
[27/Nov/2026:15:55:48 +0900] 172.19.0.1 &quot;GET /api/v1/newsletters HTTP/1.0&quot; 200 1327648
[27/Nov/2026:15:55:48 +0900] 172.19.0.1 &quot;GET /api/v1/newsletters HTTP/1.0&quot; 200 1262679</code></pre><p>Tomcat에서는 신기하게도 200 응답을 주고있다.</p>
<pre><code>[27/Nov/2026:15:55:48 +0900] 172.19.0.1 &quot;GET /api/v1/newsletters HTTP/1.0&quot; 200 1262679</code></pre><p>하지만 아까도 봤듯이, 이미 client에서 요청을 끊어버려서 서버는 200을 보냈어도 Nginx는 timeout으로 연결을 먼저 끊고 499를 얻는다.</p>
<p>Tomcat 로그에서는 Nginx의 커넥션 고갈이 원인임을 규명할 수 있는 정보가 없을까 싶었는데, HTTP 버전이 힌트가 되기도 했다.</p>
<p><a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html?utm_source=chatgpt.com">NGINX 공식문서</a>에 따르면, HTTP 프로토콜 버전을 지정 안할 경우 기본이 1.0이라고 한다. </p>
<blockquote>
<p><em>&quot;Sets the HTTP protocol version for proxying. By default, version 1.0 is used. Version 1.1 or 2 (1.29.4) is recommended for use with keepalive connections and NTLM authentication.&quot;</em></p>
</blockquote>
<p><em>&quot;프록싱에 사용할 HTTP 프로토콜 버전을 설정합니다. 기본값은 버전 1.0입니다. keepalive 연결 및 NTLM 인증을 사용하려면 버전 1.1 또는 2(1.29.4)를 사용하는 것이 좋습니다.&quot;</em></p>
<p>우리는 버전을 따로 설정 안했으므로 HTTP 1.0이었고, HTTP 1.0은 연결 재사용이 안된다. 
Nginx의 커넥션은 client→Nginx, Nginx→upstream 각각에 대해 사용하므로 Nginx→upstream에서 사용한 연결들은 재사용 되지 못하는 것이다.
따라서 Nginx의 일부 연결들은 재사용 되지 못하고 매번 생성/삭제가 되고 있다.</p>
<h3 id="🤔-prometheus가-스크랩에-실패하는-이유는">🤔 Prometheus가 스크랩에 실패하는 이유는?</h3>
<p>결국 위 지표들을 모아, 이번에 지연이 발생한 이유를 아래처럼 정리했다.</p>
<blockquote>
<ol>
<li>4000건 이상의 동시처리를 하기엔 worker_connections 수 자체가 부족하다.</li>
<li>prometheus의 응답이 커 Nginx 메모리에 담지 못하고, Disk I/O가 발생해 커넥션을 오래 점유한다.</li>
<li>Nginx의 default HTTP 버전이 1.0이라서 커넥션이 재사용 되지 못한다.</li>
</ol>
</blockquote>
<p>➡️ 따라서 Nginx의 worker connections의 빠른 과부하가 발생한다.</p>
<p>Prometheus는 pull 방식으로 타겟에 주기적인 <code>/actuator/prometheus</code> HTTP 요청을 보내 지표를 응답받고 저장한다.</p>
<p>문제는 이 <strong>스크랩 요청 API가 일반 사용자 트래픽과 동일하게 Nginx를 통과하고 있었다는 점</strong>이다.
Nginx 입장에서는 client의 API 요청과 Prometheus의 스크랩 요청을 같은 worker connections 목록으로 받아 처리하게 될 것이다.
그래서 Nginx가 부하상태에 도달하면 Prometheus 같은 매트릭 요청도 같은 지연에 걸려들어 스크랩 실패로 이어지는 것이다.</p>
<p>우리는 Prometheus의 <code>scrape_interval=15s</code>으로 설정해, 15초에 한 번씩 스크랩을 요청해 매트릭을 수집하도록 구성했다.
Prometheus의 <code>scrape_timeout</code>은 일정 시간동안 매트릭 응답이 오지 않을 경우, 스크랩 실패로 처리할 시간 기준인데, 기본값은 10s로 <strong>10초동안 응답이 없으면 매트릭 수집을 실패했다고 판단하고 소켓을 끊어버린다.</strong></p>
<p>아까 매트릭이 끊긴 상황에서는 클라이언트가 이 전에 이미 4000개 이상의 요청을 보낸 상태였다.
그래서 <strong>Prometheus의 스크랩 요청은 클라이언트 요청과 동일하게 요청 처리 대기에 들어가면서, 
결국 앞에 있는 요청이 처리될 때까지 대기해야 하는 것</strong>이다.
Nginx 레벨에서 발생하는 지연으로 인해, Prometheus 요청은 대기 시간으로 10초를 쉽게 넘기게 되고 결국 스크랩 실패로 이어져 모니터링에서 단절되어 보인다.</p>
<p>Nginx를 통해 모니터링 포함 모든 요청을 처리하는 기존 아키텍처는 서비스 장애가 모니터링 단절까지 이어지는 <strong>병목 지점</strong>이었던 것이다.</p>
<hr>
<h2 id="모니터링-병목-지점-해결하기">모니터링 병목 지점 해결하기</h2>
<p>현재 문제를 해결하기 위한 핵심 목표는 <strong>&quot;API 요청량과는 상관없이 매트릭을 수집한다&quot;</strong>는 것이다.
그렇다면 API 요청 경로와 매트릭 수집 요청은 별도로 처리되어야 한다.
이 목적을 위해 생각했던 방법으로는 크게 2가지정도 있었다.</p>
<blockquote>
<p><strong>1. Prometheus의 매트릭 수집 방법을 Pull → Push로 변경한다.</strong>
<strong>2. 매트릭 수집 경로가 Nginx를 타지 않도록 분리한다.</strong></p>
</blockquote>
<h3 id="1️⃣-prometheus를-push-방식으로-바꾸기">1️⃣ Prometheus를 Push 방식으로 바꾸기</h3>
<p>Prometheus가 매트릭을 수집하는 방법은 <strong>Pull</strong>과 <strong>Push</strong> 방식이 있다.</p>
<p><strong>Pull</strong> 방식은 현재 우리가 사용하는 방식으로, <strong>Prometheus가 직접 대상 서버의 /actuator/prometheus 엔드포인트에 주기적으로 HTTP 요청을 보내 매트릭을 받아온다.</strong> Prometheus가 직접 스크랩 대상들을 관리하고 수집 주기를 제어한다. Prometheus의 기본 수집 모델이기도 하다. </p>
<blockquote>
<h4 id="❓-prometheus의-기본-수집-모델은-왜-pull일까">❓ Prometheus의 기본 수집 모델은 왜 Pull일까?</h4>
<p><a href="https://prometheus.io/docs/introduction/faq/">Prometheus FAQ</a>에서 답하고 있다.</p>
</blockquote>
<p><em>Q. Why do you pull rather than push?</em>
<em>A. Pulling over HTTP offers a number of advantages:</em></p>
<blockquote>
</blockquote>
<ul>
<li><em>You can start extra monitoring instances as needed, e.g. on your laptop when developing changes.</em><ul>
<li>모니터링 인스턴스 추가가 쉽다.</li>
</ul>
</li>
<li><em>You can <strong>more easily and reliably tell if a target is down.</strong></em><ul>
<li><strong>타겟 서버가 다운됐는지 더 쉽고 신뢰성 있게 확인할 수 있다.</strong></li>
</ul>
</li>
<li><em>You can manually go to a target and inspect its health with a web browser.</em><ul>
<li>브라우저로 직접 대상 엔드포인트를 열어 상태를 확인하기 쉽다.</li>
</ul>
</li>
</ul>
<p><strong>Push</strong> 방식은 반대로 <strong>대상 서버가 매트릭 정보를 생성해 Prometheus에게 전송하는 방식이다.</strong> 
크게 두 가지 방식으로 구현할 수 있는데, 첫 번째는 <strong>Pushgateway를 만들어 여기에 서버가 매트릭을 push하면 Prometheus가 이를 pull</strong> 해오는 방식이 있고, 두 번째로는 <strong>Prometheus 서버에서 OTLP receiver를 활성화</strong>하는 것이다.</p>
<h4 id="🛠️-1-pushgateway로-구현하기">🛠️ 1. Pushgateway로 구현하기</h4>
<p><strong>Pushgateway</strong>란 Prometheus가 애플리케이션으로부터 직접 스크랩 해오기 어려운 매트릭들을 저장해두는 <strong>중간 보관소</strong>이다.
애플리케이션 서버가 Push Gateway로 매트릭을 export 하고, Prometheus는 Pushgateway에 저장된 매트릭을 스크랩해오는 방식이다.</p>
<p>Pushgateway는 docker 이미지로 띄울 수 있으며, 애플리케이션에서 exporter를 Pushgateway로 지정하면 매트릭을 전달할 수 있다. Prometheus는 타겟 서버로 Pushgateway를 지정해주면 된다.</p>
<blockquote>
<p><strong>[ 구현 요약 ]</strong></p>
</blockquote>
<ol>
<li>Pushgateway 컨테이너를 실행한다.</li>
<li>Prometheus에서 Pushgateway를 scrape target으로 등록한다.</li>
<li>애플리케이션에서는 <code>Prometheus registry</code> + <code>Pushgateway exporter</code>를 활성화한다.</li>
</ol>
<h4 id="🛠️-2-otlp-receiver로-매트릭-push-구현하기">🛠️ 2. OTLP receiver로 매트릭 Push 구현하기</h4>
<p><code>MeterRegistry</code>는 애플리케이션에서 생성한 메트릭을 등록 및 보관하고, 이를 각 모니터링 백엔드 형식에 맞게 export하는 핵심 객체이다. <strong><code>Prometheus registry</code></strong>는 Prometheus scrape 형식으로 메트릭을 노출하는 <strong>pull 지향 구현체</strong>이고, <strong><code>OTLP registry</code></strong>는 지정한 OTLP endpoint로 메트릭을 주기적으로 전송하는 <strong>push 지향 구현체</strong>이다.</p>
<p>매트릭 수집을 Push 방식으로 전환하려면 애플리케이션의 Registry를 <code>Prometheus registry</code>에서 <code>OTLP registry</code>로 전환해야한다.</p>
<p> 이때 Prometheus 서버는 OTLP receiver를 활성화해 <code>/api/v1/otlp/v1/metrics</code> 경로로 들어오는 메트릭을 수신하도록 설정해야 한다.</p>
<blockquote>
<p><strong>[구현 요약]</strong></p>
</blockquote>
<ol>
<li>Prometheus 서버에서 OTLP receiver를 연다.</li>
<li>애플리케이션의 metrics exporter를 <code>Prometheus Registry</code>에서 <code>OTLP Registry</code>로 전환한다.</li>
<li>애플리케이션이 전송할 OTLP endpoint, 헤더, export 주기(step)를 설정한다.</li>
</ol>
<blockquote>
<p>📍 <a href="https://prometheus.io/docs/practices/pushing/"><strong>Prometheus 공식 문서</strong></a>를 보면 <strong>push 방식은 너무 짧은 시간 내에 실행되어 pull 방식으로 스크랩이 어려운 매트릭들</strong>에 대해서만 적용하길 권장한다.</p>
</blockquote>
<p>Prometheus의 철학은 신뢰성 있는 서버 상태 정보 수집을 위해 모니터링 시스템이 직접 타겟을 확인하는 것이다. 이는 확실히 Push 방식이 지키기엔 어려운 점이라 Push 방식은 정말 필요한 케이스에만 사용하길 권장하는 것이다.</p>
<p>Pull과 Push 방식의 장단점을 비교하면 다음과 같다.
<img src="https://velog.velcdn.com/images/o_z/post/2f1be10f-0a65-4290-8deb-091c6176240e/image.png" alt=""></p>
<p>지금 목표는 &quot;매트릭 스크랩 요청과 API 요청 경로의 분리&quot;이기에, 매트릭 수집 방식을 Push로 변환하면 이를 해결할 수 있을 것이라고 생각했다. </p>
<p>하지만 <strong>Prometheus 관점에서 직접적인 타깃 상태 신호의 신뢰성을 유지하는 것이 중요하다</strong>고 판단해 push 방식은 채택하지 않았다.</p>
<p>Prometheus의 pull 방식에서는 scrape 성공 여부와 <code>up</code> 매트릭을 통해, 모니터링 시스템이 각 애플리케이션 타겟에 직접 도달 가능한지 지속적으로 확인할 수 있다. 
반면 push 방식으로 전환하면 이러한 직접적인 상태 신호가 약해진다. 특히 Pushgateway를 사용할 경우 Prometheus는 애플리케이션이 아니라 Pushgateway를 scrape하게 되고, OTLP receiver를 사용할 경우에도 Prometheus 공식 문서 기준 <code>up</code> 메트릭, staleness 등 pull 기반 기능이 동일하게 동작하지 않는다고 한다. </p>
<p><strong>따라서 Push로 변환하기에는 운영 신뢰성을 더 중요하게 보아 기존 pull 방식을 유지하기로 결정했다.</strong></p>
<h3 id="2️⃣-매트릭-수집-경로가-nginx를-타지-않도록-분리하기">2️⃣ 매트릭 수집 경로가 Nginx를 타지 않도록 분리하기</h3>
<p>현재의 문제 해결 목적인 <strong>&quot;매트릭 수집과 API 처리 경로를 분리한다&quot;</strong>는 관점에서, 
두 요청이 모두 병목 지점(Nginx)으로 들어가면 안된다고 판단했다.
그래서 <strong>API 요청은 기존처럼 Nginx, 매트릭은 애플리케이션 서버에 직속 요청</strong>하도록 해결 방향을 잡았다.</p>
<h3 id="기존-매트릭-수집-아키텍처">기존 매트릭 수집 아키텍처</h3>
<p>문제가 발생하던 서버 아키텍처 일부와 매트릭 수집 구조는 아래와 같다.
<img src="https://velog.velcdn.com/images/o_z/post/4472fb3e-abc3-420d-9ec1-eb59e8b714a6/image.png" alt=""></p>
<p>애플리케이션 서버는 80, 443 포트만 열어두었고, 
https ssl 인증서 적용을 위해 <strong>앞단에 nginx를 설치해 리버스 프록시</strong> 하고있었다.</p>
<p>모니터링 서버의 Prometheus는 매트릭 수집에 대해 pull 방식을 사용한다.
애플리케이션 서버에 <code>https://domain.com/actuator/prometheus</code> 로 요청을 보내 
매트릭을 스크랩 해오고 Grafana가 prometheus를 조회해 시각화하는 구조이다.</p>
<h4 id="⚠️-지금-구조의-문제점">⚠️ 지금 구조의 문제점</h4>
<p>당시 서버를 구축할 땐 잘 몰랐는데, 이제보니 매트릭 수집 통신을 매우 비효율적으로 하고있었다.
<strong>애플리케이션 서버와 모니터링 서버가 같은 VPC 안에 있는데도, domain name으로 요청해서 IGW를 거쳐 다시 들어오는 통신을 하기 때문이다.</strong>
같은 건물의 1층에 있다가 3층에 가겠다고 건물을 나갔다가 다시 들어오는 셈이다.
같은 VPC 안에 있다면 private IP로 통신해 IGW를 나가지 않고도 local 라우터를 타서 통신할 수 있다.</p>
<h3 id="1-private-ip로-우회하기">1. private IP로 우회하기</h3>
<p>현재 구조의 문제점을 먼저 해결하기 위해 기존에 <code>https://domain.com/actuator/prometheus</code>로 요청하던 것을 <code>http://&lt;private-ip&gt;:8080/actuator/prometheus</code>로 요청하도록 수정했다.
<img src="https://velog.velcdn.com/images/o_z/post/6e0c9a41-8707-4ae6-9aa1-2d9b33da34e9/image.png" alt="">
Nginx는 도메인 네임으로 80, 443 포트를 listening 하고있다.
애플리케이션 서버와 모니터링 서버는 같은 VPC 내에 있으므로, 
private IP로 접속하면 local 라우팅을 타서 Nginx를 우회해 요청할 수 있다.</p>
<p>다만 <strong>요청 경로 자체는 &quot;Nginx를 타는지 안타는지 여부&quot;에서 분리가 되지만, 
애플리케이션 내에 있는 커넥터는 분리되지 않았다.</strong></p>
<p>지금은 두 요청이 같은 포트를 사용해 접근하므로, 같은 Tomcat 커넥터를 사용하고 있다.
API와 매트릭 수집이 같은 Tomcat 커넥터의 스레드 풀을 사용하고 있어, connection 처리나 스레드 자원을 공유하면 여전히 두 요청이 서로 영향을 줄 수 있다.</p>
<p>Tomcat 스레드 풀이 늘어나는 것이니 서버 자체의 CPU 자원 부하가 우려될까 싶었지만, API에 비해 매트릭 수집 요청은 부하가 발생할정도의 요청 폭주는 일어나지 않을 것이다. 15초에 한 번 씩 스크랩 하기 때문이다.</p>
<p>두 요청의 서로 영향력을 최대한 분리하고, Tomcat property 설정도 각 상황에 맞게 세분화할 수 있다는 것이 장점이라고 생각해, <strong>API와 매트릭 수집이 완전히 다른 커넥터</strong>를 사용하는 것이 좋겠다고 판단했다.</p>
<p>따라서 <strong>애플리케이션 API는 8080, Prometheus는 8081</strong>로 요청을 보내도록 port를 분리하기로 했다.</p>
<h3 id="2-prometheus-요청-전용-port를-분리하기">2. Prometheus 요청 전용 port를 분리하기</h3>
<p><img src="https://velog.velcdn.com/images/o_z/post/c88524b3-6af0-4c5e-91bd-fe34ab5a367f/image.png" alt=""></p>
<p>기존의 8080 포트로 API 요청과 매트릭 스크랩 요청을 모두 받아냈지만, <code>management.server.port=8081</code> 설정을 추가해 8081 포트를 매트릭 수집 전용 포트로 열어 커넥션을 분리했다. 
<code>docker-compose.yml</code> 파일에서도 8081 포트를 listen 하도록 열어두었다.</p>
<p>지금 보안그룹에는 80, 443 포트만 열려있기 때문에 application server의 보안그룹 인바운드 규칙에도 모니터링 서버 security group을 등록하고 8081 포트를 열어야 한다.</p>
<p>그리고 매트릭 수집은 <code>http://&lt;application-private-ip&gt;:8081/actuator/prometheus</code>로 요청하면 Nginx는 타지 않고 VPC 내부 네트워크로 직접 연결되며, 애플리케이션 API와 커넥터도 분리된다.</p>
<p>이렇게 포트를 분리해 커넥터를 따로 쓰게 되면 아래처럼 Tomcat 리소스 분리가 가능하다.</p>
<ul>
<li>스레드 풀이 분리되어 API 요청들은 대기해도 매트릭 요청은 다른 스레드 풀에서 즉시 처리될 수 있다.</li>
<li>acceptQueue를 분리해 연결 대기 큐도 따로 사용한다.</li>
<li>네트워크 소켓 리스너도 OS 레벨에서 서로 다른 포트로 분산한다.</li>
</ul>
<p>이제 Nginx는 client의 요청만 처리하면 되고, 모니터링의 요청은 API 요청과의 경쟁 없이 application으로 직속 처리하면 되므로 서로 독립적인 처리가 가능하다.
포트도 8080과 8081로 분리되어있어, 실제 웹 서버 커넥터가 다르게 띄워져서 매트릭도 독립적으로 요청을 보낼 수 있게 되었다.</p>
<hr>
<p>지금 구조로 애플리케이션 API와 매트릭 수집 경로는 분리했는데, 이와 별개로 보안문제가 하나 있다.
운영 서버가 public subnet에 존재하는 것이다. public IP로 IGW를 통해 어디서든 접근이 가능하다는 것이다. 
<strong>이렇게 되면 공격자는 public IP를 통해 이 운영 서버의 모니터링 endpoint를 알게될 수 있고, 이는 서버 내부 상태 정보를 포함하므로 노출되었을 때 치명적인 허점이 될 수 있다.</strong></p>
<p>따라서 <strong>운영 서버를 private subnet</strong>에 옮겨두고, <strong>리버스 프록시 역할의 Nginx를 public subnet</strong>에 두어 운영 서버의 안정성을 좀 더 확보하고자 했다.</p>
<h3 id="3-리버스-프록시-서버-추가하기">3. 리버스 프록시 서버 추가하기</h3>
<p><img src="https://velog.velcdn.com/images/o_z/post/49c88951-b9fa-46e5-9aeb-ba2f17cfdcde/image.png" alt=""></p>
<p>리버스 프록시 역할의 서버를 하나 추가하고, 해당 서버는 public subnet에 위치시킨다.
그리고 기존에 있던 application 서버를 private subnet으로 이동시켰다.
private subnet으로 옮겨진 애플리케이션 서버의 인바운드 규칙으로는 두 가지를 추가했다.</p>
<ol>
<li>리버스 프록시 서버로부터 오는 API를 요청 받기 위해, 
<strong>리버스 프록시 서버의 sg를 소스로 8080</strong> 포트를 열었다.</li>
<li>모니터링 서버로부터 오는 매트릭 수집을 요청 받기 위해, </li>
</ol>
<p><strong>모니터링 서버의 sg를 소스로 8081 포트</strong>를 열었다.</p>
<p>이렇게 하면 API 요청은 리버스 프록시 서버의 Nginx를 통해 들어오도록 하고,
Prometheus의 매트릭 요청은 애플리케이션 서버로 직접 향하게 한다.
또한 운영 서버까지 private subnet으로 옮기면서 기존보다 더 안전한 네트워크가 됐다.</p>
<h3 id="개선된-최종-서버-아키텍처">개선된 최종 서버 아키텍처</h3>
<p>총 세 단계에 걸쳐 개선한 최종 서버 아키텍처이다.
아래 구조의 장점들을 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/49c88951-b9fa-46e5-9aeb-ba2f17cfdcde/image.png" alt=""></p>
<h4 id="1️⃣-사용자-트래픽-폭주와-별개로-매트릭을-안정적으로-수집할-수-있다">1️⃣ 사용자 트래픽 폭주와 별개로 매트릭을 안정적으로 수집할 수 있다.</h4>
<p>트래픽 진입점을 리버스 프록시 서버로 분리하고 Prometheus는 앱으로 직행시켰다.
Tomcat 커넥터까지 분리하면서 서로 요청 처리에 대해 영향력을 최소화 할 수 있다.
이 구조를 통해 사용자 트래픽 폭주가 모니터링 단절로 전파되는 현상을 방지할 수 있어, 기존의 병목 지점이던 Nginx 부하도 해소할 수 있다.</p>
<p>모니터링이 사용자 트래픽 폭주로 인해 장애가 발생하면 모니터링의 역할이 무의미할 것이다.
트래픽 폭주 속에서도 Observability가 살아있어야 장애 상황에서도 모니터링을 통해 원인 분석 가능성을 높일 수 있다.</p>
<h4 id="2️⃣-기존-구조보다-더-안전한-네트워크가-됐다">2️⃣ 기존 구조보다 더 안전한 네트워크가 됐다.</h4>
<p>기존에는 개발 편의성을 위해 애플리케이션을 public subnet에 두었다.
이는 Public IP와 Internet Gateway에도 모두 개방되어 있어 접근이 매우 쉬워지므로 보안상 좋지 못한 설계가 된다. 특히나 8081 포트를 열어두는건 서버 내부 정보를 외부에 노출할 수 있으므로 위험하다.</p>
<p>운영 서버를 private subnet에 두면 인터넷에서 직접 접근할 수 없고, 외부 트래픽은 오직 리버스 프록시 서버를 통해서만 들어오도록 강제할 수 있다.
/actuator 같은 운영 엔드포인트는 외부에 노출되면 위험할 수 있는데, 애플리케이션을 private subnet에 위치시키고 모니터링 서버의 SG만 허용하게 되면서 노출 범위를 명확하게 통제할 수 있게 된다.</p>
<h4 id="3️⃣-스케일아웃에-유리해진다">3️⃣ 스케일아웃에 유리해진다.</h4>
<p>최종 아키텍처처럼 리버스 프록시 서버를 따로 앞에 두게 되면 이 Nginx는 로드밸런서의 역할을 할 수 있다.
앱 서버는 그냥 같은 포트로만 여러 대를 붙이고 LB의 Nginx에는 upstream에 추가한 애플리케이션 서버만 등록하면 된다.</p>
<p>이번에 발생한 요청 지연 문제는 Nginx 문제가 유력했지만, 만약 WAS 자체에서도 버티기 힘든 수준이라면 스케일아웃을 하는 방향으로도 충분히 고려할만하다고 생각했다.
그래서 최종 아키텍처가 이번 문제 뿐 만 아니라 애플리케이션 내에서도 스케일아웃에 이점이 있다고 생각했다.</p>
<hr>
<p>이렇게 아키텍처를 변경한 후 다시 4000건 정도의 동시 트래픽을 요청해본 결과, 단절 없이 성공적으로 매트릭을 모두 관측할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/67157b3d-405c-4744-8bc2-746ba9f20f41/image.png" alt=""></p>
<hr>
<p>모니터링 단절을 해결할 방법으로 서비스 아키텍처 자체가 더 보안적으로 향상하고 확장성에도 열린 구조가 되었다.
모니터링도 안정적으로 할 수 있을 뿐더러 여러모로 많은 이점을 얻은 아키텍처 개선이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[회원 탈퇴하면 데이터가 다 삭제될 때까지 기다려야하나요?]]></title>
            <link>https://velog.io/@o_z/%ED%9A%8C%EC%9B%90-%ED%83%88%ED%87%B4%ED%95%98%EB%A9%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EA%B0%80-%EB%8B%A4-%EC%82%AD%EC%A0%9C%EB%90%A0-%EB%95%8C%EA%B9%8C%EC%A7%80-%EA%B8%B0%EB%8B%A4%EB%A0%A4%EC%95%BC%ED%95%98%EB%82%98%EC%9A%94</link>
            <guid>https://velog.io/@o_z/%ED%9A%8C%EC%9B%90-%ED%83%88%ED%87%B4%ED%95%98%EB%A9%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EA%B0%80-%EB%8B%A4-%EC%82%AD%EC%A0%9C%EB%90%A0-%EB%95%8C%EA%B9%8C%EC%A7%80-%EA%B8%B0%EB%8B%A4%EB%A0%A4%EC%95%BC%ED%95%98%EB%82%98%EC%9A%94</guid>
            <pubDate>Sat, 22 Nov 2025 17:16:50 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/o_z/post/7c95f44e-1565-4d43-8846-18dd3a446726/image.png" alt="">
<img src="https://velog.velcdn.com/images/o_z/post/6be35fee-ee18-4618-b7db-22dfbc06cdc7/image.png" alt="">
회원 탈퇴는 개인정보를 다뤄야 하기에 삭제 시점과 보관할 데이터를 엄격하게 처리해야 한다.</p>
<p>민감한 개인정보(이메일, 이름)는 즉시 삭제하고, 탈퇴 회원의 행동 로그나 성별, 나이대 등은 수집해서 테이블을 분리해 저장하기로 했다.</p>
<p>회원 탈퇴를 구현하는 중 마주한 2가지 고민에 대해 정리했다.</p>
<hr>
<h2 id="🤔-회원-탈퇴-서비스가-모든-도메인의-서비스를-알아야하나">🤔 회원 탈퇴 서비스가 모든 도메인의 서비스를 알아야하나?</h2>
<p>기본적으로 회원 탈퇴에서 구현해야 할 과정은 아래와 같았다.</p>
<blockquote>
<p><strong>1. 탈퇴 회원 분석에 필요한 지표 저장하기
2. 회원 데이터 제거하기
3. 해당 회원과 관련된 모든 데이터 제거하기</strong></p>
</blockquote>
<p>회원 한 명에 대해 연관관계를 갖고 있는 데이터들은 아래와 같다.</p>
<blockquote>
</blockquote>
<ul>
<li><code>article</code></li>
<li><code>bookmark</code></li>
<li><code>highlight</code></li>
<li><code>pet</code></li>
<li><code>subscribe</code></li>
<li><code>today_reading</code>, <code>monthly_reading</code>, <code>yearly_reading</code>...</li>
</ul>
<p>회원이 탈퇴했을 때 회원과 관련된 모든 데이터를 제거해야한다.
<strong>이 때 이 도메인들에 대한 모든 service 계층을 회원 탈퇴를 위해 의존해야할까?</strong>
만약 모든 service를 의존해 데이터를 삭제하게 되면 아래처럼 된다.</p>
<pre><code class="language-java">@Transactional
public void withdraw(Long memberId) {
    Member member = findMemberById(memberId);

    /*
    회원 탈퇴 신청 시 즉시 withdrawnMember로 정보 이전
    이벤트에서 회원 관련된 모든 정보 제거: articles, pet, highlight, bookmark, reading, subscribe
     */
    withdrawService.migrateDeletedMember(member);
    articleService.deleteByMember(member);
    petService.deleteByMember(member);
    highlightService.deleteByMember(member);
    bookmarkService.deleteByMember(member);
    subscribeService.deleteByMember(member);
    readingService.deleteByMember(member);

    memberRepository.delete(member);
    log.info(&quot;회원 탈퇴 처리 완료. MemberId: {}&quot;, memberId);
}</code></pre>
<p>위 구현 방식의 문제점은 두 가지이다.</p>
<blockquote>
<h4 id="1-회원-탈퇴-메서드는-탈퇴-확정이-핵심-책임이다">1. 회원 탈퇴 메서드는 &quot;탈퇴 확정&quot;이 핵심 책임이다.</h4>
<p>현재 withdraw() 메서드에서는 회원 탈퇴 뿐 만 아니라 데이터 정리까지의 책임을 갖는다.
&#39;회원 탈퇴&#39;는 서비스에 해당 회원이 존재하지 않는다는 동작만을 확정하는 것이 바람직하다.
또한 이렇게 무분별한 서비스 계층간의 의존성은 순환 의존 위험성을 초래하기 쉽다.
회원 탈퇴 시 어떤 정보들이 여기에 연동되어있고, 삭제되어야하는지 알 책임은 없는 것이다.</p>
</blockquote>
<blockquote>
<h4 id="2-한-번의-데이터-삭제-실패가-회원-탈퇴-전체의-실패로-이어진다">2. 한 번의 데이터 삭제 실패가 회원 탈퇴 전체의 실패로 이어진다.</h4>
<p>이 구조의 더 큰 문제는 중요도가 다른 삭제 작업이 하나로 묶여서 실패한다는 것이다. 
회원 탈퇴의 본질은 “탈퇴 의사를 반영하고 회원을 탈퇴 상태로 전이하는 것”인데, 예를 들어 reading 정리 중 일시적 오류가 났다고 해서 회원 탈퇴 자체가 실패하는 것은 과도한 결합이다. 즉, 핵심 작업과 후처리 작업이 분리되지 않았다는 문제가 있다.</p>
</blockquote>
<p>문제점들을 해소할 방법으로 <strong>Spring의 이벤트 발행</strong>을 도입하게 되었다.</p>
<h3 id="✅-spring-event로-결합도-낮추기">✅ Spring Event로 결합도 낮추기</h3>
<p>&#39;회원 탈퇴&#39;는 일종의 도메인 이벤트로 존재할 수 있다.
또한 회원 탈퇴는 어떤 도메인 데이터들을 어떻게 지워야하는지는 각 도메인 스스로가 알고있어야 하는 책임이라고 생각했다.</p>
<p>그래서 회원 탈퇴 시에는 각 도메인 별 서비스 메서드를 직접 호출하는 것이 아니라,
<strong><code>WithdrawnEvent</code> 하나만을 발행하고 다른 도메인들의 삭제 <code>EventListener</code>들이 이를 구독하는 방식으로 변경했다.</strong></p>
<pre><code class="language-java">@Transactional
public void withdraw(Long memberId) {
    Member member = findMemberById(memberId);

    /*
    회원 탈퇴 신청 시 즉시 withdrawnMember로 정보 이전
    이벤트에서 회원 관련된 모든 정보 제거: articles, pet, highlight, bookmark, reading, subscribe
     */
    withdrawService.migrateDeletedMember(member);
    applicationEventPublisher.publishEvent(new WithdrawEvent(memberId));

    memberRepository.delete(member);
    log.info(&quot;회원 탈퇴 처리 완료. MemberId: {}&quot;, memberId);
}</code></pre>
<p>이벤트 기반 방식으로 변경하게 되면서 각 도메인 별 구독한 리스너 클래스는 아래와 같이 생성됐다.</p>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class DeleteArticlesByWithdrawListener {

    private final ArticleService articleService;

    // default 옵션은 AFETER_COMMIT: 이벤트 발행처의 트랜잭션이 커밋된 후에 실행된다.
    @TransactionalEventListener
    public void on(WithdrawEvent event) {
        log.info(&quot;회원 탈퇴 따른 아티클 삭제 시작 - memberId={}&quot;, event.memberId());
        try {
            articleService.deleteAllByMemberId(event.memberId());
        } catch (Exception e) {
            log.error(&quot;회원 탈퇴 따른 아티클 삭제 처리 실패 - memberId={}&quot;, event.memberId(), e);
        }
    }
}</code></pre>
<p>이제 회원 탈퇴 시 <code>MemberService</code>는 더이상 모든 도메인의 service를 참조하지 않아도 되며, 
오로지 &#39;회원 탈퇴&#39;라는 이벤트 하나만 발행해주면 다른 도메인들의 삭제 책임은 분산된다.</p>
<h3 id="✅-트랜잭션-전파-수준-조정하기">✅ 트랜잭션 전파 수준 조정하기</h3>
<p>또한 트랜잭션 전파수준을 조정해, 각 도메인 데이터 별 삭제는 자체 트랜잭션 경계를 갖도록 전파 수준을 조정했다.</p>
<pre><code class="language-java">@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deleteAllByMemberId(Long memberId) {
    articleRepository.deleteAllByMemberId(memberId);
}</code></pre>
<p>propagation으로 <code>REQUIRES_NEW</code>를 사용하면 부모 트랜잭션이 존재해도 새로운 자식 트랜잭션을 만들어 로직을 처리한다. 논리 트랜잭션이 새로 생기는 것이므로 이 트랜잭션의 성공/실패 여부는 부모 트랜잭션에게 영향이 가지 않는다.</p>
<hr>
<h2 id="🤔-사용자는-모든-데이터-삭제를-대기해야할까">🤔 사용자는 모든 데이터 삭제를 대기해야할까?</h2>
<p>이벤트 기반으로 처리하도록 구현한 후 개발 서버에 있는 내 계정을 직접 탈퇴해 테스트했다.
회원탈퇴의 Trace span을 보며 의문이 들었던 점이 있다.<img src="https://velog.velcdn.com/images/o_z/post/590f2366-c62b-4574-999d-bd9d48b82e9f/image.png" alt="">회원탈퇴를 확정하기까지, 즉 <code>MemberRepository.delete()</code>가 커밋되기까지는 <strong>약 270ms</strong> 걸렸다. </p>
<p>그 이후의 span을 보면 <strong>서비스의 도메인 데이터 삭제를 처리하는데 585ms가 걸렸다.</strong>
회원탈퇴 응답 시간의 절반 이상이 도메인 데이터 삭제로 인한 것이다.</p>
<p>그래서 아래와 같은 의문이 들었다.</p>
<h4 id="회원은-데이터가-삭제되기까지-모두-기다려야할까">회원은 데이터가 삭제되기까지 모두 기다려야할까?</h4>
<p>회원에게는 &#39;빠른 피드백&#39;이 중요하다.
회원은 &#39;지금 즉시 모든 데이터가 제거&#39;되는 것을 기대하는 것보다,
<strong>&#39;탈퇴가 정상적으로 접수 되었다는 빠른 피드백&#39;</strong>을 더 기대하는 것에 가깝다.
서비스 내부에서 사용하는 지표 데이터들을 지우느라 사용자가 기다리는 것은 불필요한 지연이라고 생각했다.</p>
<p>지금이야 개발서버라서 데이터의 절대적인 수 자체도 적기에 처리 시간이 그리 오래걸리지 않는다. 하지만 운영서버는 개발서버보다 사용자 수와 데이터가 훨씬 더 많고, 사용자 데이터가 누적될수록 삭제처리는 더욱 시간이 오래 걸릴 것이다.</p>
<h4 id="위와-같은-이유들로-지금-구조-개선을-시도했다">위와 같은 이유들로 지금 구조 개선을 시도했다.</h4>
<h3 id="✅-데이터-삭제를-비동기처리-하기">✅ 데이터 삭제를 비동기처리 하기</h3>
<p>지금 구조에서 가장 적절한 해결책은 <strong>데이터 삭제 작업들을 API 처리에서 분리하는 것</strong>이라고 생각했다.
그리고 그 방법으로는 <strong>비동기 전환</strong>이 있다.</p>
<p>개인적으로는 Spring의 이벤트 기반 동작의 큰 장점은 두 가지라고 생각한다.</p>
<blockquote>
<ol>
<li>layer간의 결합도 감소</li>
<li>손쉽고 명확한 비동기 처리</li>
</ol>
</blockquote>
<p>이벤트를 도입한 이상 사실 2번 장점을 보고 &#39;그럼 모든 정보를 다 비동기로 제거하면 엄청 빠르겠네!&#39; 라고 할 수 있다.</p>
<p>하지만 비동기를 사용하는 것도 상황에 따라 다르게 적용해야한다.
<strong>지금같은 회원 탈퇴 로직에서는 회원 개인정보삭제를 비동기로 처리해버리면, 삭제를 실패했을 때 큰일 날 수 있다..</strong>
&#39;삭제 성공&#39;이라는 응답으로 오해를 불러일으키고 개인정보는 서비스에서 갖고있게 된다면 이는 나중에 정말 큰 법적 문제로 이어질 수 있다.</p>
<p>사용자에게 &#39;정상적으로 탈퇴 처리가 됨&#39;이라는 응답을 빠르게 보내기 위해, 일단 하나의 트랜잭션 내에서 실행되어야 할 작업으로 동기적 삭제를 해야하는 데이터와 비동기로 삭제할 데이터를 분리해보았다.</p>
<ul>
<li><p><strong>동기적으로 삭제할 데이터</strong> : <strong>회원 개인정보</strong>
민감한 개인정보는 사용자의 탈퇴 요청에 즉시 삭제되어야 하기 때문이다.
사용자에게 즉시 반영됨을 보장해야하는 최소한의 작업 단위가 된다.</p>
</li>
<li><p><strong>비동기적으로 삭제할 데이터</strong> : <strong>회원의 연관 활동 로그, 아티클, 지표 등</strong>
사용자가 즉시 삭제됨을 기대하는 데이터들이 아닌 것들이다.
회원 탈퇴 이벤트 발행을 통해 리스너에서 자체적으로 정리하도록 위임한 작업들의 데이터들이다.</p>
</li>
</ul>
<p>위 내용을 고려하면 <strong>이벤트리스너에서 실행되는 데이터 정리 작업은 백그라운드에서 안전하게 비동기로 수행하는 구조로 개선할 수 있어보였다.</strong></p>
<p>먼저 동기로 삭제할 데이터는 <code>member</code> 테이블로, 이미 위에서 <code>withdraw()</code>의 단일 트랜잭션 내에서 처리하고 있었다.</p>
<p>이제 비동기적으로 회원 연관 데이터들을 삭제하도록 이벤트리스너 메서드를 수정해보자.</p>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class DeleteArticlesByWithdrawListener {

    private final ArticleService articleService;

    @Async // 비동기처리를 위한 애노테이션 추가
    @TransactionalEventListener
    public void on(WithdrawEvent event) {
        log.info(&quot;회원 탈퇴 따른 아티클 삭제 시작 - memberId={}&quot;, event.memberId());
        try {
            articleService.deleteAllByMemberId(event.memberId());
        } catch (Exception e) {
            log.error(&quot;회원 탈퇴 따른 아티클 삭제 처리 실패 - memberId={}&quot;, event.memberId(), e);
        }
    }
}</code></pre>
<p>이 외에도 <code>WithdrawEvent</code>를 구독하고 있는 모든 이벤트 리스너들의 함수에 <code>@Async</code>로 비동기처리를 적용했다.
이로써 회원은 탈퇴했어도 개인정보만 확실히 제거되어 commit 된 후에 응답을 받을 수 있고, 백그라운드로는 회원의 읽기 지표, 아티클 등 관련 데이터를 동시다발적으로 제거할 수 있게 됐다.</p>
<h3 id="✅-비동기-처리는-스레드-풀을-지정해주자">✅ 비동기 처리는 스레드 풀을 지정해주자</h3>
<p>Spring의 <code>@Async</code>를 사용해서 비동기 처리할 경우 고려해야 하는 사항이 있는데, 바로 <strong>스레드 풀</strong>이다.
비동기 작업은 <code>TaskExecutor</code> 인터페이스를 통해 실행되는데,
이 구현체로 <code>SimpleAsyncTaskExecutor</code>, <code>ThreadPoolTaskExecutor</code> 가 있다.</p>
<h4 id="simpleasynctaskexecutor">SimpleAsyncTaskExecutor</h4>
<p>Spring은 기본적으로 IoC 컨테이너에 <code>TaskExecutor</code> 빈이 1개만 존재할 경우 그걸 쓰고, 아니면 <code>taskExecutor</code>라는 이름의 Executor 빈을 찾는다. 만약 둘 다 없으면 <code>SimpleAsyncTaskExecutor</code>를 사용하게 된다.</p>
<p>이 <code>SimpleAsyncTaskExecutor</code>의 내부에서는 작업마다 새 스레드를 생성해, 성능 이슈가 발생하기 쉬워 운영에서 사용하는건 좋지 못하다.</p>
<p>아래는 <code>SimpleAsyncTaskExecutor</code>의 주석 내용이다.</p>
<blockquote>
<p><em>NOTE: <strong>This implementation does not reuse threads!</strong> Consider a thread-pooling <code>TaskExecutor</code> implementation instead, in particular for executing a large number of short-lived tasks. Alternatively, on JDK 21, consider setting setVirtualThreads to <code>true</code>.</em></p>
</blockquote>
<p>실제 <code>SimpleAsyncTaskExecutor</code>의 <code>execute()</code> 메서드를 따라가보면 <code>doExecute()</code>가 호출되는걸 볼 수 있는데, 최종적으로는 아래처럼 스레드 풀 관리 없이 계속해서 스레드를 생성하고 있다.
<img src="https://velog.velcdn.com/images/o_z/post/a7b7a4fd-5ed3-4198-be42-ff38eaed9144/image.png" alt=""></p>
<h4 id="threadpooltaskexecutor">ThreadPoolTaskExecutor</h4>
<p><code>SimpleAsyncTaskExecutor</code>가 스레드 재사용을 하지 않는 문제를 해결하기 위해, 스레드 풀을 사용하도록 설정하는 구현체이다.
스레드 풀 크기, 큐, 종료 등을 빈 생성할 때 config로 설정할 수 있다.</p>
<pre><code class="language-java">@EnableAsync
@Configuration
public class AsyncConfig {

    @Bean(name = &quot;taskExecutor&quot;) // name은 메서드명이 default라 안써도 된다.
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(1); // 유지되는 스레드 개수
        executor.setMaxPoolSize(3); // 최대 사용할 수 있는 스레드 개수
        executor.setQueueCapacity(50); // 큐에 50개 대기 가능
        executor.setWaitForTasksToCompleteOnShutdown(true); // graceful shutdown 처리 설정
        executor.setAwaitTerminationSeconds(60); // 60초까지만 대기 후 강제 종료
        executor.initialize();
        return executor;
    }
}</code></pre>
<p>위 설정 값들은 절대적인 정답이 있진 않다. 
얼마나 비동기 처리가 자주 발생하는지, 비동기 작업들이 어떤 속도로 수행되어야 하는지 등 기준에 따라 설정 값은 다양해질 수 있다.</p>
<ul>
<li><code>corePoolSize = 1</code> : 현재 비동기 작업들은 자주 발생하는 핵심 트래픽 작업들이 아니다. 항상 높은 처리량을 요구하는 작업도 아니므로 평소는 순차 처리로 진행해 리소스 점유를 최소화 하고자 했다.</li>
<li><code>maxPoolSize = 3</code> : 완전한 직렬 처리로 두기엔 여러 건의 작업이 한 번에 발생했을 때 대기열이 많아질 수 있다. 이 때는 잠깐 처리량을 높이기 위해 최대 풀 크기를 3으로 지정했다.</li>
<li><code>queueCapacity = 50</code> : 비동기 처리가 실시간으로 완료되어야 하는 작업은 아니므로 여러 개의 작업이 대기해도 괜찮다. 그래서 즉시 실패시키기보다 큐의 크기를 늘려 어느정도 대기할 수 있도록 50개까지 늘려두었다.</li>
</ul>
<p>지금은 회원가입 디스코드 알림도 비동기로 처리되고 있어서 둘의 비동기 스레드 풀을 분리해야할까 고민했다.
하지만 현재 서비스 규모 상 MDC와 회원 탈퇴 모두 한 번에 빈번하게 일어날 기능이 아니라고 생각해 일단은 <code>taskExecutor</code> 하나로 통합해두었다.</p>
<p>만약 두 스레드 풀을 분리해야한다면 아마 이런 식으로 하게 될 것이다.</p>
<pre><code class="language-java">@EnableAsync
@Configuration
public class AsyncConfig {

    @Bean(name = &quot;cleanWithdrawnMemberExecutor&quot;) // 회원 탈퇴 처리 전용
    public Executor cleanWithdrawnMemberExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        ...
        return executor;
    }

    @Bean(name = &quot;discordNotificationExecutor&quot;) // 회원가입 알림 전용
    public Executor discordNotificationExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(1);
        ...
        return executor;
    }
}</code></pre>
<pre><code class="language-java">// 비동기 로직 수행 시
@Async(&quot;cleanWithdrawnMemberExecutor&quot;) // 전용 스레드 풀 지정
@TransactionalEventListener
public void on(WithdrawEvent event) {
    ...
}</code></pre>
<hr>
<h2 id="📝-비동기-변환-전후-비교하기">📝 비동기 변환 전/후 비교하기</h2>
<h3 id="📍-비동기-변환-전-trace-span-분석">📍 비동기 변환 전 Trace span 분석</h3>
<p>비동기 변환 전 회원 탈퇴 Trace를 분석했다.
main span을 보면 삭제의 모든 작업 끝까지 걸쳐서 응답을 기다리고 있는 모습이다.
<strong>응답까지 총 1.59s 소요됐고, 이 당시 VU=3/iteration=200으로 P95를 측정했을 때 1.3s이 발생했다.</strong><img src="https://velog.velcdn.com/images/o_z/post/50433219-664c-49fa-a79d-d5c166599e0e/image.png" alt="">Trace상 가장 마지막 처리인 <code>WarningSetting</code> 삭제의 span attribute를 확인해보면 
실행 스레드가 요청 스레드에 속해있음을 확인할 수 있다.</p>
<ul>
<li>main span attribute<img src="https://velog.velcdn.com/images/o_z/post/6a0941cf-c2b5-495d-9751-17e417aa75c5/image.png" alt=""></li>
<li><code>WarningSettingRepository.delete()</code> span attribute <img src="https://velog.velcdn.com/images/o_z/post/54174e26-bdfa-4afc-9403-f0bd3793df40/image.png" alt=""></li>
</ul>
<h3 id="📍-비동기-변환-후-trace-span-분석">📍 비동기 변환 후 Trace span 분석</h3>
<p>비동기로 변환 후의 회원 탈퇴 Trace span을 분석해보았다.<img src="https://velog.velcdn.com/images/o_z/post/6e23962d-12ee-4399-9fbe-e8823752d8a5/image.png" alt="">(Trace의 전반 과정을 편하게 보기 위해 duration &gt;= 10ms 필터링을 적용해서 몇 개의 작업들이 생략되었다.)</p>
<h4 id="1️⃣-http-응답-완료까지는-duration이-39983ms">1️⃣ HTTP 응답 완료까지는 Duration이 399.83ms</h4>
<p>맨 위에 있는 main span의 총 duration이 <strong>399.83ms</strong>이다.
요청 응답을 보내기까지 걸린 시간이다.</p>
<h4 id="2️⃣-main-span-이후로-이어지는-삭제-작업">2️⃣ main span 이후로 이어지는 삭제 작업</h4>
<p><code>ArticleRepository.deleteByMemberId()</code>부터 비동기로 삭제된다. </p>
<ul>
<li>main span attribute<img src="https://velog.velcdn.com/images/o_z/post/9b3bf481-4d05-4d6f-8a57-df63e7c45196/image.png" alt=""></li>
<li><code>ArticleRepository.delete()</code> span attribute <img src="https://velog.velcdn.com/images/o_z/post/c05c7b41-3cee-458c-944d-5257b8eb21fe/image.png" alt=""></li>
</ul>
<p>main span이 완료된 이후로도 <strong>1.53s까지 이어지는 삭제 작업들을 확인할 수 있다.</strong>
<strong>이는 곧 HTTP 응답을 보낸 후에도 요청 스레드와 분리되어 비동기로 삭제 작업이 진행됨을 의미한다.</strong> 
만약 동기로 처리했다면 사용자는 1.53s까지 응답을 기다려야했을 것이다.</p>
<hr>
<h2 id="📌-숙제-데이터-삭제를-보장해야한다">📌 숙제: &quot;데이터 삭제&quot;를 보장해야한다</h2>
<p>비동기로 서비스 데이터를 제거해 사용자 경험에 있어서 속도의 측면은 개선할 수 있었다.
하지만 회원탈퇴의 기능을 온전히 보장하려면 <strong>&quot;완전한 데이터 삭제&quot;</strong>가 중요하다.
사용자가 회원 탈퇴에 성공했다는 응답을 받았지만, 지속적으로 탈퇴한 회원의 데이터가 서비스에 보인다면 매우 부정적인 영향을 줄 것이다.
<strong>지금은 삭제 실패 시 error 로그는 남기고 있지만, 삭제 실패에 대한 후처리는 없는 상태이다.</strong></p>
<p>지금 서비스 규모에서는 우리 개발자들이 직접 error 로그를 통해 확인할 수 있지만, 
사용자 수가 매우 증가하게 될 경우 일일이 확인해서 처리하기 어려울 것이다.
따라서 추후에 사용자가 더 많아진다면 지금보다 견고하게 데이터 삭제를 보장하고, 
실패시 재시도를 보장하기 위한 방법을 고안해야한다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[ShedLock을 사용해도 스케줄러가 두 번 실행 되는 문제]]></title>
            <link>https://velog.io/@o_z/ShedLock%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%8F%84-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC%EA%B0%80-%EB%91%90-%EB%B2%88-%EC%8B%A4%ED%96%89-%EB%90%98%EB%8A%94-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@o_z/ShedLock%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%8F%84-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC%EA%B0%80-%EB%91%90-%EB%B2%88-%EC%8B%A4%ED%96%89-%EB%90%98%EB%8A%94-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Fri, 14 Nov 2025 12:10:40 GMT</pubDate>
            <description><![CDATA[<p>저번에 Spring scheduler로 이달의 랭킹을 업데이트 하는데, 두 개의 인스턴스가 한 번에 <code>monthly_reading</code> 테이블에 대한 락을 점유하려고 해서 Deadlock이 발생하는 일이 있었다.</p>
<p>그래서 <code>@SchedulerLock</code>을 사용해 둘 중 한 애플리케이션만 락을 점유하도록 데드락을 방지했다.
<img src="https://velog.velcdn.com/images/o_z/post/b56371ca-5b02-4761-9711-30c6dba40820/image.png" alt="">그럼에도 불필요하게 두 서버 모두가 스케줄러를 실행하고 있다.</p>
<p>다른 시간대에도 계속 중복 스케줄링이 발생하나 싶어서 다른 로그들도 살펴봤지만, 꼭 <strong>매일 자정에만 중복으로 2번의 업데이트 스케줄링이 발생</strong>하고 있었다.</p>
<p><strong>ShedLock</strong>을 도입하게 된 배경이 <strong>스케줄러가 중복 실행</strong>을 해결해준다는 점이었는데, 자정에 또 두 번씩 실행된다. </p>
<h2 id="중복-실행이-다시-발생한-이유">중복 실행이 다시 발생한 이유</h2>
<p><code>@SchedulerLock</code> 의 설정 내용은 아래와 같다.</p>
<pre><code class="language-java">@Scheduled(cron = EVERY_TEN_MINUTES_CRON, zone = TIME_ZONE)
@SchedulerLock(name = &quot;ten_minutely_calculate_member_rank&quot;, lockAtLeastFor = &quot;PT1.5S&quot;, lockAtMostFor = &quot;PT3S&quot;)
public void calculateMemberRank() {
    log.info(&quot;이달의 독서왕 순위 업데이트&quot;);
    readingService.updateMonthlyRanking();
    log.info(&quot;이달의 독서왕 순위 업데이트 완료&quot;);
}</code></pre>
<ul>
<li><code>lockAtLeastFor</code> : 작업이 일찍 끝나더라도 최소 1.5초는 락을 잡고 있는다.</li>
<li><code>lockAtMostFor</code> : 작업에 대해 최대 3초 락을 잡고 있는다.</li>
</ul>
<h3 id="shedlock-내부-로직">ShedLock 내부 로직</h3>
<p>⚠️ 아래 나오는 쿼리는 실제 발생하는 쿼리가 아닌 내부 로직을 이해하기 위한 pseudo code이다.</p>
<ol>
<li>락을 점유하려는 서버가 <code>shedlock</code> 테이블에 아래 작업을 시도한다.</li>
</ol>
<pre><code class="language-sql"># pseudo code
UPDATE shedlock
SET lock_until = now + lockAtMostFor, # 락 최대 점유 시간까지 타 서버가 락을 점유하지 못하도록 업데이트한다.
    locked_at  = :lockedAt, # 현재 락 점유 시점
    locked_by  = :lockedBy # 점유한 락의 서버
WHERE name = :name
  AND lock_until &lt;= :now; # 현재 시점 이전에 락이 풀려있을 경우</code></pre>
<ol start="2">
<li>락 점유를 성공한 스레드가 작업을 완료하면 <code>shedlock</code> 테이블을 업데이트한다.</li>
</ol>
<pre><code class="language-sql"># pseudo code
UPDATE shedlock
SET lock_until = MAX(start_at + :lockAtLeastFor, NOW())
WHERE name = :lockName
    AND locked_by = :currentInstance;</code></pre>
<ul>
<li><code>start_at</code> + <code>lockAtLeastFor</code> : 최소한으로 락을 점유하기까지의 기간</li>
<li><code>NOW()</code> : 작업이 끝난 현재 시점</li>
<li>스케줄러가 일찍 끝나더라도 바로 락이 풀려서 재실행이 되지 않도록 <code>lockAtLeastFor</code> 을 적절히 지정해야한다.</li>
</ul>
<h3 id="스케줄러-실행-타임라인-분석">스케줄러 실행 타임라인 분석</h3>
<p>중복 실행 타임라인을 shedlock 점유 과정과 함께 분석했다.<img src="https://velog.velcdn.com/images/o_z/post/b56371ca-5b02-4761-9711-30c6dba40820/image.png" alt=""></p>
<blockquote>
<h4 id="📝-스케줄러-실행-타임라인">📝 스케줄러 실행 타임라인</h4>
</blockquote>
<ol>
<li>T00:00:00.1294: <strong>prod-sub</strong>에서 작업 시작</li>
<li>T00:00:01.6729 : <strong>prod-sub</strong>에서 작업 종료<ul>
<li><code>lock_until</code> = MAX(<code>start_at</code> + <code>lockAtLeastFor</code>, <code>ended_at</code>) = T00:00:0<strong>1.6729</strong><ul>
<li><code>start_at</code>(0.1294s) + <code>lockAtLeastFor</code>(1.5s) = <strong>1.6294s</strong></li>
<li><code>ended_at</code> = <strong>1.6729s</strong></li>
</ul>
</li>
</ul>
</li>
<li>T00:00:02.6789 : <strong>prod-main</strong>에서 작업 시작<ul>
<li><strong>1.6279s</strong>(<code>lock_until</code>) <strong>≤ 2.6789s</strong>(<code>now</code>) 이므로 <strong>락 점유 가능</strong></li>
<li>prod-main에서 락 점유 후 스케줄러 작업 실행</li>
</ul>
</li>
<li>T00:00:02.6861 : <strong>prod-main</strong>에서 작업 종료</li>
</ol>
<p>스케줄러는 00:00:00, 즉 자정에 실행되도록 cron을 설정했다. 
prod-sub는 00:00:00에 시도했는데, <strong>prod-main은 00:00:02로 2초의 delay가 발생</strong>한 것이다.</p>
<p>예상했던 것은 prod-sub, prod-main 두 서버 다 스케줄러 실행 로그가 00:00:00에 찍혀야했다.</p>
<p><strong>왜 이러한 스케줄러 실행 delay가 발생할까?</strong></p>
<h2 id="스케줄러가-000000에-시작하지-않는-이유">스케줄러가 00:00:00에 시작하지 않는 이유</h2>
<p>랭킹 업데이트 스케줄러는 10분 단위로 실행된다.
그래서 00:00:00부터 10분 단위로 로그를 분석해본 결과, <strong>오로지 자정에만 딜레이가 발생한다.</strong>
10분, 20분 등 그 이후의 스케줄러는 정상적으로 한 번 작동함을 확인할 수 있었다.</p>
<p>cron을 00:00:00로 지정한 스케줄러가 꽤 있는데, 이 부분들까지 포함해서 분석했다. <img src="https://velog.velcdn.com/images/o_z/post/69c9214f-ed2e-4f3a-9ea3-1b379dd35d3d/image.png" alt=""> 하지만 실행하는 시간은 모두 다르고, 초 단위도 넘어서 딜레이가 발생한다.</p>
<p>이를 통해 스케줄러가 <strong>직렬로 실행됨</strong>을 파악했다. 왜 직렬로 실행됐을까?</p>
<h3 id="spring-scheduler의-스레드-풀-사이즈는-1이다">Spring Scheduler의 스레드 풀 사이즈는 1이다.</h3>
<p>Spring은 Scheduler 전용 스레드 풀을 갖고있다.
Spring이 내부적으로 <code>TaskScheduler</code>를 하나 만들어두고 그 스레드 풀에서 주기적으로 메서드를 실행해주는 것이다. (일반적으로 client에서 요청하는 Tomcat 요청 스레드 풀과는 완전히 다르다.)</p>
<p><code>@EnableScheduling</code>이 켜져있으면, 기본 스케줄러 풀 크기를 1로 지정한다.</p>
<p>아래는 <code>@EnableScheduling</code>의 주석이다.<img src="https://velog.velcdn.com/images/o_z/post/5dc6c280-fb1d-491f-93e4-8b9096843c69/image.png" alt=""></p>
<blockquote>
<p>Spring은 기본적으로 scheduler와 관련된 빈 정의를 탐색합니다. (ex: <code>TaskScheduler</code>) 
<code>TaskScheduler</code>나 <code>ScheduledExecutorService</code>에 대한 커스텀 빈이 존재하지 않으면, 기본적으로 <strong>로컬 싱글 스레드</strong>가 생성되어 스케줄러에 사용됩니다.</p>
</blockquote>
<p>따라서 별도의 스케줄러 스레드풀 설정이 없다면 <strong>애플리케이션 하나 당 싱글 스레드로 스케줄러를 실행하는 것</strong>이다.</p>
<p>즉, 매일 자정에 실행하는 스케줄러가 10개 있으면 이들이 <strong>단일 스레드로 순차 처리</strong>된다.</p>
<h2 id="고려한-해결법">고려한 해결법</h2>
<h3 id="1-spring-scheduler의-스레드-풀-크기를-늘린다">1. Spring Scheduler의 스레드 풀 크기를 늘린다.</h3>
<p>스케줄러를 멀티스레드로 돌리기 위해 스케줄러 스레드 풀 크기를 늘리는 방법이 있다.</p>
<ol>
<li>yaml 파일에서 설정하기</li>
</ol>
<pre><code class="language-yaml">spring:
    task:
        scheduling:
            pool:
                size: 3</code></pre>
<ol>
<li>코드에서 <code>TaskSchedulerConfiguration</code> 추가하기</li>
</ol>
<pre><code class="language-java">@Configuration
public class SchedulingConfig {

    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(10); // Set your desired pool size
        scheduler.setThreadNamePrefix(&quot;my-scheduled-task-&quot;);
        scheduler.initialize();
        return scheduler;
    }
}</code></pre>
<p><strong>Q. 스레드 풀을 늘리면 지금의 문제가 해결되는가?</strong></p>
<p>스레드풀 사이즈를 정각 스케줄러 개수만큼 늘리면 해결이 가능하다.
정각에 모든 스케줄러가 병렬 실행되기 때문이다.</p>
<p><strong>하지만 정각에 실행하는 스케줄러 수가 더 늘어난다면 어떨까?</strong>
이전 증상과 똑같이 스케줄러 task는 pending 될 수 있으며, <strong>딜레이가 생기는건 마찬가지</strong>일 것이다. </p>
<p>따라서 근본적인 해결 방법이라기보단, 지금의 상황을 위한 일종의 <strong>임시 방편</strong>이다.</p>
<blockquote>
<h4 id="❓스케줄러에서-스레드-풀을-키워야-하는-경우는">❓<strong>스케줄러에서 스레드 풀을 키워야 하는 경우는?</strong></h4>
</blockquote>
<p>스레드 풀 크기를 너무 크게 늘려버리면 리소스가 낭비되기 쉽다.
따라서 정말 필요한 경우일 때 스레드 풀을 조정하고 사용해야 한다.</p>
<blockquote>
</blockquote>
<p><strong>1. 하나의 스케줄러가 오랜 시간 작업할 경우</strong>
하나의 스케줄러가 오랜 시간 실행되면, 스케줄러의 단일 스레드를 오랫동안 점유하게 된다.
같은 스케줄러에 등록된 다른 작업들은 이 스케줄러가 비워질 때까지 대기해야 한다.
그럴 때 스레드 풀 크기를 적절히 늘려서 병렬로 실행하면, 다른 작업들은 오래 걸리는 스케줄러의 영향을 받지 않을 것이다. </p>
<ul>
<li>외부 API 호출하기</li>
<li>S3 같은 외부 시스템에 대한 접근</li>
<li>대량 데이터 정리같은 데이터 볼륨이 큰 작업<blockquote>
</blockquote>
</li>
<li><em>2. 짧은 텀으로 자주 실행되는 경우*</em>
짧은 텀으로 실행하는 스케줄러가 가끔이라도 지연되면, 싱글 스레드로 인해 뒤에 예정된 실행들이 연속적으로 지연될 수 있다. 즉, 정해진 텀마다 실행한다는 보장이 깨질 수 있다.
이 때 스레드 풀 크기를 늘리면 한 번 스케줄러가 지연되어도 다음 작업은 여분의 스레드에서 실행할 수 있으므로 영향을 받지 않는다.</li>
<li>실시간 랭킹 갱신</li>
<li>모니터링</li>
<li>알림 트리거 등 계속 도는 가벼운 작업<blockquote>
</blockquote>
🤔 하지만 이러한 경우들은 보통 스프링 스케줄러 자체에서 모두 처리하는게 아니라, 
스케줄러는 일종의 **작업 트리거 역할을 하고</li>
</ul>
<ol>
<li>실제 작업은 <code>@Async</code>로 비동기 처리 하거나</li>
<li>별도의 메세지 큐로 넘기는 것이 더 합리적일 것** 같단 생각이 든다.<blockquote>
</blockquote>
</li>
</ol>
<h3 id="2-lockatleastfor을-조정한다">2. lockAtLeastFor을 조정한다.</h3>
<p>최소 락을 점유하고 있을 시간을 더 늘리면 해결된다.</p>
<pre><code class="language-java">@Scheduled(cron = EVERY_TEN_MINUTES_CRON, zone = TIME_ZONE)
@SchedulerLock(name = &quot;ten_minutely_calculate_member_rank&quot;, lockAtLeastFor = &quot;PT1.5S&quot;, lockAtMostFor = &quot;PT3S&quot;)
public void calculateMemberRank() {
    log.info(&quot;이달의 독서왕 순위 업데이트&quot;);
    readingService.updateMonthlyRanking();
    log.info(&quot;이달의 독서왕 순위 업데이트 완료&quot;);
}</code></pre>
<p>현재의 <code>lockAtLeastFor</code>은 1.5초이다.
적절하게 락을 잡으려면 어떻게 지정해야할까?</p>
<p><code>Shedlock</code> Github의 README에 아래같은 예시가 있다.</p>
<blockquote>
<p><strong>Example</strong></p>
<p>Let&#39;s say you have a task which you execute every 15 minutes and which usually takes few minutes to run. Moreover, you want to execute it at most once per 15 minutes. In that case, you can configure it like this:</p>
<pre><code class="language-java">import net.javacrumbs.shedlock.core.SchedulerLock;

@Scheduled(cron = &quot;0 */15 * * * *&quot;)
@SchedulerLock(name = &quot;scheduledTaskName&quot;, lockAtMostFor = &quot;14m&quot;, lockAtLeastFor = &quot;14m&quot;)
public void scheduledTask() {
    // do something
}</code></pre>
<p>By setting <code>lockAtMostFor</code> we make sure that the lock is released even if the node dies. By setting <code>lockAtLeastFor</code> we make sure it&#39;s not executed more than once in fifteen minutes.</p>
</blockquote>
<p>15분에 한 번씩 실행되는 스케줄러의 텀을 보장하기 위해 <code>lockAtLeastFor</code>을 14m으로 지정한 것이다.그래서 나도 10분이라는 단위를 보장하기 위해 <code>lockAtLeastFor</code>을 9분으로 잡으면 되지 않을까 싶었다.</p>
<h4 id="하지만-스케줄러-작업은-아무리-길어도-약-56s-이내에-완료될텐데-shedlock의-최소-락-점유-시간을-이렇게까지-길게-잡아도-될까"><strong>하지만 스케줄러 작업은 아무리 길어도 약 5~6s 이내에 완료될텐데, shedlock의 최소 락 점유 시간을 이렇게까지 길게 잡아도 될까?</strong></h4>
<p>앞 순서에 있던 작업들이 <strong>1분 이상 늦어지면</strong>, 다음 텀에 <strong>스케줄러 실행이 스킵</strong>될 수 있다.</p>
<blockquote>
<p><strong>📝 스케줄러 스킵 시나리오</strong>
스프링 스케줄러는 ‘싱글 스레드’이고, <code>lockAtLeastFor</code>을 9분으로 잡았다고 가정해보자.</p>
<ol>
<li><p>‘이달의 랭킹 업데이트’ 스케줄러가 10개 task 중 <strong>10번째</strong>에 배치됐다.</p>
</li>
<li><p>9번째 스케줄러 작업이 끝난 후, 10번째 스케줄러 실행 시작 시점이 <strong>00:01:34</strong> 이다.</p>
</li>
<li><p><code>lock_until</code> = <code>start_at</code> + <code>lockAtLeastFor</code> 
 = 00:01:34 + 00:09:00
 = <strong>00:10:34</strong>
 즉, <strong>다음 스케줄러는 00시 10분 34초 이후에 락을 점유할 수 있다.</strong></p>
</li>
</ol>
<ol start="4">
<li>다음 스케줄러 실행 시간인 00:10:00 에는 ‘이달의 랭킹 업데이트’ 스케줄러가 싱글 스레드의 <strong>첫 번째</strong> 작업으로 할당됐다.</li>
<li>00:10:00(<code>start_at</code>) &lt; 00:10:34(<code>lock_until</code>) 이므로, <strong>이번 스케줄러는 락을 점유할 수 없다.</strong></li>
<li>따라서 10분에 실행한 스케줄러는 스킵된다.</li>
</ol>
</blockquote>
<p>물론 지금 서비스상으로는 몇 초 내에 모든 스케줄러 실행이 가능해서 1분 이상 지연되는 경우는 흔치 않다.</p>
<p>하지만 데이터가 충분히 쌓이면서 스케줄러 하나하나가 작업이 점점 오래걸리게 되면 가능할 수 있는 시나리오라고 생각한다.</p>
<p>그래서 <strong>최대 딜레이가 발생할 수 있는 시간</strong>으로 적절하게 <code>lockAtLeastFor</code>을 설정한다.</p>
<p>현재까지 발생한 스케줄러 딜레이 시간을 고려했을 때 약 2~3s 걸렸으므로 <code>lockAtLeastFor</code>은 여기서 더 여유있게 지정해 <strong>6s</strong>로 설정했다.</p>
<h3 id="선택한-해결-방법--lockatleastfor-조정">선택한 해결 방법 : lockAtLeastFor 조정</h3>
<p>이미 shedlock을 사용하고 있는 상태에서 가장 비용이 적게 들고 근본적으로 <code>shedlock</code>이 락을 점유할 수 있게 된 것을 방지하는 방법은 <code>lockAtLeastFor</code> 값을 조정하는 것이라고 판단했다.
그래서 <code>lockAtLeastFor</code>을 3s로 수정했다.</p>
<hr>
<blockquote>
</blockquote>
<p>참고
<a href="https://umbum.dev/2032/">https://umbum.dev/2032/</a>
<a href="https://github.com/lukas-krecan/ShedLock?utm_source=chatgpt.com">https://github.com/lukas-krecan/ShedLock?utm_source=chatgpt.com</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[우리가 Deadlock을 겪다니 (원인 파악부터 해결까지)]]></title>
            <link>https://velog.io/@o_z/%EC%9A%B0%EB%A6%AC%EA%B0%80-Deadlock%EC%9D%84-%EA%B2%AA%EB%8B%A4%EB%8B%88-%EC%9B%90%EC%9D%B8-%ED%8C%8C%EC%95%85%EB%B6%80%ED%84%B0-%ED%95%B4%EA%B2%B0%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@o_z/%EC%9A%B0%EB%A6%AC%EA%B0%80-Deadlock%EC%9D%84-%EA%B2%AA%EB%8B%A4%EB%8B%88-%EC%9B%90%EC%9D%B8-%ED%8C%8C%EC%95%85%EB%B6%80%ED%84%B0-%ED%95%B4%EA%B2%B0%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Sat, 08 Nov 2025 10:01:46 GMT</pubDate>
            <description><![CDATA[<p>우리 서비스는 뉴스레터 읽기 개수로 &#39;이달의 랭킹&#39;이 10분마다 갱신된다.
그런데 어느날부터 이 스케줄링에서 Deadlock 예외가 발생했다.</p>
<p>내부 스케줄링으로 갱신되는 랭킹 작업을 추적하기 위해 로그와 트레이스를 분석했다.
그러고나니 스케줄러 수행 시점에 prod 서버에서 데드락 error trace가 반복적으로 발생하는 것을 발견했다.<img src="https://velog.velcdn.com/images/o_z/post/7cc1cb81-6caf-451f-9d86-dd587c87cfe6/image.png" alt="">trace의 예외 로그는 다음과 같았다.</p>
<pre><code>exception.stacktrace ![](https://velog.velcdn.com/images/o_z/post/90649468-b7ff-4cdb-beab-5d216ea95f14/image.png)
&quot;org.springframework.dao.CannotAcquireLockException: JDBC exception executing SQL [ 
    &amp;{이달의 랭킹 업데이트 쿼리;}
] [Deadlock found when trying to get lock; try restarting transaction] [n/a]; SQL [n/a]
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:287)
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:256)
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244)
    at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:560)
    at org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:...)
    at org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:343)</code></pre><hr>
<h2 id="🎯-deadlock이-발생하는-작업">🎯 Deadlock이 발생하는 작업</h2>
<p>서비스에서 이달의 랭킹을 10분마다 업데이트한다. 이 작업은 Spring의 <code>@Scheduled</code> 로 수행하고 있었다. <code>monthly_reading</code> 테이블에 대해 UPDATE 하는 쿼리를 실행하는 작업인데, 여기서 계속 <strong><code>Deadlock found when trying to get lock</code></strong> 에러가 발생하고 있었다.</p>
<p><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/dao/CannotAcquireLockException.html">CannotAcquireLockException</a>이 발생한 것으로 보아, 데이터베이스 레벨에서 트랜잭션을 수행하는데 데드락이 발생한 것이다.</p>
<hr>
<h2 id="🤔-deadlock이-발생하는-원인-찾기">🤔 Deadlock이 발생하는 원인 찾기</h2>
<p><strong>Deadlock</strong>이란 <strong>두 개 이상의 프로세스나 스레드가 서로의 자원을 기다리며 무한히 기다리는 현상</strong>이다.</p>
<p>위 작업에서 Deadlock이 발생한 이유는 <strong>두 트랜잭션이 서로가 보유한 <code>monthly_reading</code> 락을 무한히 대기</strong>하고 있기 때문이다.</p>
<blockquote>
<h4 id="❓하나의-애플리케이션에서-두-개-이상의-스케줄러끼리-데드락이-발생할-수-있을까">❓<strong>하나의 애플리케이션에서 두 개 이상의 스케줄러끼리 데드락이 발생할 수 있을까?</strong></h4>
</blockquote>
<p><code>@Scheduled</code>는 Spring에서 제공하는 스케줄링 전용 스레드 풀에서 스레드를 꺼내 사용하는데, 
이 스레드 풀에 대해 추가적으로 설정한 config가 없으면 <strong>default 스레드 풀 사이즈가 1</strong>이다. </p>
<blockquote>
</blockquote>
<p>즉, <strong>단일 스레드 환경에서 스케줄러가 실행</strong>되는 것이다.</p>
<blockquote>
</blockquote>
<p>따라서 하나의 애플리케이션 안에서 스케줄러 스레드 여러 개가 <code>monthly_reading</code> 을 점유하느라 발생한 데드락은 아닌 것이다.</p>
<p>데드락이 발생한 결과 정보를 얻기위해 <code>SHOW ENGINE INNODB STATUS</code>를 사용했다.</p>
<blockquote>
<h4 id="❓show-engine-innodb-status">❓<code>SHOW ENGINE INNODB STATUS</code></h4>
<p><a href="https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-engine-innodb-status"><strong><code>SHOW ENGINE INNODB STATUS</code></strong></a> 명령은 현재 InnoDB Monitor의 결과를 보여준다. 
결과에는 백그라운드로 실행되는 스레드, 세마포어, 가장 최근에 발생한 FK 에러, 그리고 가장 최근에 감지된 Deadlock 등이 포함된다.</p>
<p>실행 결과에서 한 개의 블럭은 PK 하나의 레코드에 대한 락 정보를 보여준다.</p>
</blockquote>
<pre><code class="language-sql">*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 65 page no 4 n bits 192 index PRIMARY of table `bombom`.`monthly_reading` trx id 351844299710552 lock mode S
Record lock, heap no 56 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
 0: len 8; hex 8000000000000002; asc         ;;
 1: len 6; hex 000000000006; asc     kv;;
 ...
 7: len 5; hex 900000000c; asc    l\;;
 8: len 5; hex 9000000000; asc      ;;</code></pre>
<blockquote>
</blockquote>
<ul>
<li><code>*** (1) HOLDS THE LOCK(S):</code> 하위에는 해당 트랜잭션이 얻은 모든 lock의 정보가 나타난다.<ul>
<li>첫 번째 트랜잭션이 S lock을 점유한다. (lock mode S) (<code>LOCK(S)</code>에 있는 S는 상관 X)</li>
<li>락은 primary key에 대한 인덱스, 즉 클러스터드 PK 인덱스에 대해 lock을 점유한다.<ul>
<li><code>monthly_reading</code> 테이블의 PK는 id이므로, id에 대한 인덱스이다.<blockquote>
</blockquote>
<pre><code class="language-sql">0: len 8; hex 8000000000000002; asc         ;;</code></pre>
</li>
</ul>
</li>
</ul>
</li>
<li>InnoDB는 인덱스의 key 값을 0번 필드에 기록한다.    <ul>
<li><code>hex 8000000000000002</code> : 0번 필드 값을 나타내므로 PK 값인 것이다.<ul>
<li>PK 값이 2인 인덱스임을 추측할 수 있다.</li>
</ul>
</li>
</ul>
</li>
<li>나머지 1 ~ 8번 까지는 해당 레코드의 다른 필드 데이터들이다.</li>
</ul>
<h4 id="실제-조회한-deadlock-메타데이터를-분석해보자">실제 조회한 Deadlock 메타데이터를 분석해보자.</h4>
<p><strong>1) 첫 번째 애플리케이션의 트랜잭션</strong> </p>
<p>첫 번째 트랜잭션이 id = 2인 레코드에 대해 S lock을 점유하고 있다.</p>
<pre><code class="language-sql">*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 65 page no 4 n bits 192 index PRIMARY of table `bombom`.`monthly_reading` trx id 351844299710552 lock mode S
Record lock, heap no 56 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
 0: len 8; hex 8000000000000002; asc         ;;
 ...</code></pre>
<p>첫 번째 트랜잭션은 id = 9인 레코드에 대해 S lock을 대기하고 있다.</p>
<pre><code class="language-sql">*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 65 page no 4 n bits 192 index PRIMARY of table `bombom`.`monthly_reading` trx id 351844299710552 lock mode S waiting
Record lock, heap no 57 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
 0: len 8; hex 8000000000000009; asc         ;;
 ...</code></pre>
<p><strong>2) 두 번째 애플리케이션의 트랜잭션</strong></p>
<p>두 번째 트랜잭션은 id = 9인 레코드에 대해 X record lock을 점유하고 있다.</p>
<pre><code class="language-sql">*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 65 page no 4 n bits 192 index PRIMARY of table `bombom`.`monthly_reading` trx id 158951 lock_mode X locks rec but not gap
Record lock, heap no 57 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
 0: len 8; hex 8000000000000009; asc         ;;
 ...</code></pre>
<p>두 번째 트랜잭션은 id = 2인 레코드에 대해 X record lock을 대기하고 있다.</p>
<pre><code class="language-sql">
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 65 page no 4 n bits 192 index PRIMARY of table `bombom`.`monthly_reading` trx id 158951 lock_mode X locks rec but not gap waiting
Record lock, heap no 56 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
 0: len 8; hex 8000000000000002; asc         ;;
 ...</code></pre>
<p><strong>T1 : S(2) holding → S(9) request</strong></p>
<p><strong>T2 : X(9) holding → X(2) request</strong></p>
<p>서로의 lock을 요청하고 있었기에 deadlock이 발생하고 있었다.
🚨 그리고 중요한 단서로는 <strong>두 개의 트랜잭션 IP가 달랐다</strong>.</p>
<blockquote>
<h4 id="❓-update-쿼리에-s-lock은-왜-포함되어-있을까">❓ UPDATE 쿼리에 S lock은 왜 포함되어 있을까?</h4>
</blockquote>
<p>쿼리에 UPDATE 대상인 테이블 <code>monthly_reading</code> 이 <strong>조인/서브쿼리에서도 읽기용으로 참조</strong>한다. 
MySQL의 의도에 따르면, UPDATE 대상 테이블이 조인/서브쿼리에서 동일하게 사용되면 <a href="https://bugs.mysql.com/bug.php?id=72005">읽기 과정을 위해 S lock을 점유하도록 설계</a>되어있다고 한다.</p>
<blockquote>
</blockquote>
<p>이를 따른다면 쿼리 실행에 따른 lock 점유 순서는 아래와 같을 것이다.</p>
<blockquote>
</blockquote>
<ol>
<li>첫 번째 JOIN 서브쿼리에 대해 S lock 획득 
→ <code>monthly_reading</code>의 모든 레코드가 <strong>S lock</strong> 점유<blockquote>
</blockquote>
<pre><code class="language-sql">SELECT member_id,
    RANK() OVER (ORDER BY current_count DESC) AS calculated_rank
FROM monthly_reading;</code></pre>
<blockquote>
</blockquote>
</li>
<li>두 번째 LEFT JOIN 서브쿼리에 대해 S lock 획득
→ <code>monthly_reading</code> 에서 ON 조건에 맞는 레코드들이 <strong>S lock</strong> 점유<blockquote>
</blockquote>
<pre><code class="language-sql">SELECT DISTINCT
    mr1.member_id,
    COALESCE(MIN(mr2.current_count) - mr1.current_count, 0) AS next_diff
FROM monthly_reading mr1
LEFT JOIN monthly_reading mr2
    ON mr2.current_count &gt; mr1.current_count
GROUP BY mr1.member_id, mr1.current_count</code></pre>
<blockquote>
</blockquote>
</li>
<li>UPDATE 시점에 대상 레코드들에 대해 X lock으로 승격
→ <code>monthly_reading</code> 의 모든 레코드가 <strong>X lock</strong>으로 승격<pre><code class="language-sql">UPDATE monthly_reading mr
JOIN (sub_query_1) ranks ON mr.member_id = ranks.member_id
LEFT JOIN (sub_query_2) diffs ON mr.member_id = diffs.member_id
SET mr.rank_order = ranks.calculated_rank,
 mr.next_rank_difference = diffs.next_diff;</code></pre>
</li>
</ol>
<h3 id="📌-원인-결론-분산환경으로-인한-스케줄링-중복-실행">📌 원인 결론: 분산환경으로 인한 스케줄링 중복 실행</h3>
<p>현재 서비스는 <strong>분산 환경</strong>으로, <strong>ec2 인스턴스 2개</strong>로 운영 중이다. 
따라서 두 개의 애플리케이션에서 <strong>동일한 시간에 스케줄러가 실행</strong>된다.
이 때 두 개의 스케줄러 스레드가 동시에 <code>monthly_reading</code> 에 대해 update를 시도한다.</p>
<p>그리고 이 부분이 원인임을 단정지을 수 있던 가장 명확한 근거는 <strong>두 개의 트랜잭션 IP가 달랐다</strong>는 점이다.</p>
<hr>
<h2 id="📝-deadlock-해결하기">📝 Deadlock 해결하기</h2>
<p>위 Deadlock을 해결하기 위해선 <strong>스케줄러의 단일 실행을 보장</strong>하면 된다.
애플리케이션 레벨에서 제어해서 해결하도록 시도했다.
고려해본 방법으로 3가지가 있었다.</p>
<blockquote>
<p><strong>1. Spring 환경변수로 스케줄러 실행 활성화 / 비활성화
2. 스케줄러 수행 API를 만들고 cron job으로 실행하기
3. 분산락 도입</strong></p>
</blockquote>
<h3 id="1️⃣-spring-환경변수로-스케줄러-실행-활성화--비활성화">1️⃣ Spring 환경변수로 스케줄러 실행 활성화 / 비활성화</h3>
<p>yml 파일 환경변수로 각 서버 별 <code>scheduler.enabled</code>를 분리하고, Config 파일에서 스케줄러 실행 여부를 제어할 수 있다.</p>
<pre><code class="language-yaml"># application.yml
scheduler:
  enabled: true // false</code></pre>
<h4 id="1-서버의-전체-scheduler에-대한-onoff">1) 서버의 전체 Scheduler에 대한 On/Off</h4>
<p><code>SchedulerConfig</code> 파일을 생성하고 <code>@ConditionalOnProperty</code> 애노테이션으로 환경변수에 따른 스케줄링 실행을 On/Off 한다.
<code>@ConditionalOnProperty</code>는 bean을 등록할지 여부에 대해 조건을 거는 애노테이션이다.</p>
<pre><code class="language-java">@Configuration
@EnableScheduling // ServerApplication에서는 애노테이션 제거
@ConditionalOnProperty(
        prefix = &quot;scheduler&quot;,
        name = &quot;enabled&quot;,
        havingValue = &quot;true&quot;,
        matchIfMissing = false // scheduler.enabled 미설정 시 false
)
public class SchedulerConfig {
}</code></pre>
<p><code>@ConditionalOnProperty</code> 조건이 true가 되면 이 <code>SchedulerConfig</code>가 config로 로딩된다.
그럼 <code>@EnableScheduling</code>이 활성화 되고 <code>@Scheduled</code>가 동작한다.</p>
<h4 id="2-개별-scheduler-class에-대한-onoff">2) 개별 Scheduler Class에 대한 On/Off</h4>
<p><code>@ConditionalOnProperty</code> 애노테이션을 개별 Scheduler Class에 적용한다.</p>
<pre><code class="language-java">@Component
@ConditionalOnProperty(
        prefix = &quot;scheduler&quot;,
        name = &quot;enabled&quot;,
        havingValue = &quot;true&quot;,
        matchIfMissing = false
)
public class ReadingScheduler {
...</code></pre>
<p>조건이 true면 Scheduler 클래스가 bean으로 등록되므로 스케줄러가 실행된다.</p>
<h4 id="3-개별-scheduler-method에-대한-onoff">3) 개별 Scheduler method에 대한 On/Off</h4>
<p>메서드 수준의 제어를 위해선 <code>@ConditionalOnProperty</code> 애노테이션만으로는 해결할 수 없다.
따라서 <code>@Value</code>로 직접 property 값을 가져와서 early return 해야한다.</p>
<pre><code class="language-java">@Component
public class RankingScheduler {

    @Value(&quot;${scheduler.enabled:false}&quot;)
    private boolean isEnabled;

    @Scheduled(cron = &quot;0 0 * * * *&quot;)
    public void updateMonthlyReading() {
        if (!isEnabled) {
            return; // 해당 스케줄러 비활성화
        }
        ...
    }

    @Scheduled(cron = &quot;0 */10 * * * *&quot;)
    public void updateRanking() {
        // 두 서버에서 스케줄러 모두 실행
        ...
    }
}</code></pre>
<h4 id="q-도입한다면-어떤-수준을-사용해야할까">Q. 도입한다면 어떤 수준을 사용해야할까?</h4>
<p>서버 전체 → 클래스 → 메서드 순으로 세밀하게 제어할 수 있다.
만약 도입한다면 1번 <strong>서버 전체 스케줄러 On/Off</strong> 수준으로 제어해도 괜찮을 것 같다.</p>
<p>우리 서비스는 <strong>단일 DB</strong>이기 때문이다.
만약 각 서버가 다른 DB를 보고 있었다면, 스케줄러가 두 번 수행되는 것이 의미있을지도 모른다.
하지만 지금은 두 서버가 하나의 DB를 바라보기 때문에 결국 하나의 DB에 중복 연산을 하는 것이다.
어떤 스케줄러들은 서버 각각 실행해야 하고, 어떤 스케줄러는 한 번만 실행 해야할 필요가 없다.
모든 스케줄러가 두 서버 통들어 딱 한 번만 실행하면 된다.</p>
<p>따라서 서버 전체 스케줄러에 대한 On/Off로 제어할 수 있을 것이다.</p>
<h3 id="2️⃣-스케줄러-수행-api를-만들고-cron-job으로-실행하기">2️⃣ 스케줄러 수행 API를 만들고 cron job으로 실행하기</h3>
<p>현재 실행되는 스케줄러를 API로 만들어서 cron job으로 실행하는 방법이다.
lambda에서 특정 시간에 스케줄러 API를 실행하게 만들면 로드밸런싱을 통해 한 쪽으로만 API 요청이 가서 중복 실행을 방지할 수 있다. 
이 뿐만 아니라 서버가 죽었을 때 skip 된 스케줄러를 수동으로 실행하기에도 편리한 방안을 제공한다. 다만 외부에서 API를 접근할 수 없게 보안을 철저히 관리해야한다.</p>
<p>처음 생각한 방법은 스케줄러 API를 만들고 LB 서버에서 cron job 스크립트를 작성해 호출하는 방법이었다. 그런데 AWS를 찾아보니 <strong>EventBridge Scheduler + Lambda</strong> 조합으로 스케줄러 API를 호출하는 방법도 있었다.</p>
<h4 id="2-1-비용-고려-10분-주기-1개는-거의-0원에-가깝다">2-1. 비용 고려: 10분 주기 1개는 거의 0원에 가깝다.</h4>
<p>랭킹 스케줄러는 <strong>10분에 한 번</strong> 호출한다.
한 달 실행 횟수는 대략 <strong>6회/시간 × 24시간 × (30~31)일 = 약 4,320회</strong>다.</p>
<p><a href="https://aws.amazon.com/ko/eventbridge/pricing"><strong>EventBridge Scheduler</strong></a>는 월 1,400만회까지 무료 구간이 있고, 
초과하더라도 백만 건당 과금이라 이 수준은 비용이 사실상 0원에 가깝다.
<a href="https://aws.amazon.com/ko/lambda/pricing"><strong>Lambda</strong></a>도 월 100만건까지 무료 지원이므로 월 4천 번대 호출이면 요청 비용은 체감이 어려운 수준이다.</p>
<h4 id="2-2-구현-아키텍처-랭킹-스케줄러-1개">2-2. 구현 아키텍처 (랭킹 스케줄러 1개)</h4>
<ul>
<li><strong>EventBridge Scheduler</strong>: 10분마다 Lambda 트리거 (크론 등록)</li>
<li><strong>Lambda</strong>: 내부 인증 헤더를 붙여 기존 LB의 내부 전용 경로 호출</li>
<li><strong>기존 LB(Nginx/HAProxy)</strong>: <code>/internal/jobs/*</code> 경로만 백엔드로 프록시</li>
<li><strong>Spring Scheduler API</strong>: 랭킹 계산 실행 + 중복 실행 방지 테이블로 선점
<img src="https://velog.velcdn.com/images/o_z/post/8f140603-2676-456d-9492-986da09f9019/image.png" alt=""></li>
</ul>
<p>(우리는 비용 절감이 최우선이었기에 ALB 대신 자체 LB 서버를 구축해 사용하고 있다.)</p>
<h4 id="2-3-구현-과정">2-3. 구현 과정</h4>
<p><strong>1) 랭킹 스케줄러를 “내부 전용 API”로 만들기</strong>
엔드포인트는 외부 공개가 목적이 아니므로 internal로 엔드포인트를 명시해준다.</p>
<ul>
<li><code>POST</code> <code>/internal/jobs/monthly-ranking/run</code></li>
</ul>
<pre><code class="language-java">@RestController
@RequiredArgsConstructors
@RequestMapping(&quot;/internal/jobs&quot;)
public class RankingSchedulerController {

    private final RankingSchedulerJob rankingSchedulerJob;

    @PostMapping(&quot;/monthly-ranking/run&quot;)
    public JobRunResult run() {
        return rankingSchedulerJob.run();
    }
}</code></pre>
<p><strong>2) 공유 저장소에 검증용 헤더 토큰 생성 및 검증 추가</strong>
안전하게 내부 서비스에서만 해당 스케줄러 API를 제어할 수 있도록 스케줄러 실행 전 헤더에 검증 토큰을 담아야한다. AWS의 secret으로 등록해서 Lambda, Spring 스케줄러 서버 모두 읽어서 갖고 있는다.</p>
<p>Spring 스케줄러에서는 엔드포인트가 스케줄러 실행 시도일 시 Filter로 internal-token이 자신이 보유한 값과 같은지 검증한다.</p>
<pre><code class="language-java">@Component
public class InternalSchedulerTokenFilter extends OncePerRequestFilter {

    private static final String INTERNAL_PREFIX = &quot;/internal/&quot;;
    private static final String HEADER_NAME = &quot;X-Internal-Token&quot;;

    @Value(&quot;${internal.scheduler.token}&quot;)
    private String expectedToken;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return uri == null || !uri.startsWith(INTERNAL_PREFIX);
    }

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

        // 스프링이 토큰을 못가져왔으면 안전하게 내부 엔드포인트 자체를 막기
        if (expectedToken == null || expectedToken.isBlank()) {
            deny(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, &quot;internal token not configured&quot;);
            return;
        }

        // 요청에서 내부 토큰 헤더를 포함하지 않으면 거부
        String provided = request.getHeader(HEADER_NAME);
        if (provided == null || provided.isBlank()) {
            deny(response, HttpServletResponse.SC_UNAUTHORIZED, &quot;missing internal token&quot;);
            return;
        }

        // 토큰 값이 같은지 확인
        if (!expectedToken.equals(provided)) {
            deny(response, HttpServletResponse.SC_FORBIDDEN, &quot;invalid internal token&quot;);
            return;
        }

        filterChain.doFilter(request, response);
    }

    private void deny(HttpServletResponse response, int status, String message) throws IOException {
        // 예외에 따른 response 세팅
    }
}</code></pre>
<p><strong>Step 3) 기존 LB에 내부 경로 라우팅 추가</strong>
LB 레벨에서 <code>/internal/jobs/*</code>를 백엔드로 넘기되, 외부 트래픽은 차단하는 게 핵심이다. 
따라서 LB 레벨에서 Internal-Token 헤더 존재 여부로 사전 차단하면 좋다.</p>
<pre><code class="language-shell">  location /internal/jobs/monthly-ranking/run {
    # 1. 내부 호출 토큰이 없으면 차단(선택)
    if ($http_x_internal_token = &quot;&quot;) { return 401; }

    # 2. 백엔드로 프록시 (요청 1건 → 백엔드 1대)
    proxy_http_version 1.1;
    proxy_set_header Connection &quot;&quot;;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # 3. 토큰 헤더를 백엔드로 전달
    proxy_set_header X-Internal-Token $http_x_internal_token;

    proxy_connect_timeout 2s;
    proxy_read_timeout 30s;

    proxy_pass http://api.domain.com;
  }</code></pre>
<p><strong>Step 4) Lambda 구현: Scheduler payload → 내부 API 호출</strong>
Lambda는 내부 토큰을 헤더에 포함해서 전달한다. Node.js를 사용하면 외부 의존성 없이 간편하게 구성할 수 있다고 한다.</p>
<pre><code class="language-js">// index.mjs (Node.js 18/20)
// handler = index.handler

export const handler = async (event) =&gt; {
  const url = process.env.TARGET_URL;
  const token = process.env.INTERNAL_TOKEN;
  const timeoutMs = Number(process.env.TIMEOUT_MS ?? &quot;8000&quot;);

  if (!url || !token) {
    throw new Error(&quot;Missing env: TARGET_URL or INTERNAL_TOKEN&quot;);
  }

  const ac = new AbortController();
  const t = setTimeout(() =&gt; ac.abort(), timeoutMs);

  const startedAt = Date.now();
  try {
    const res = await fetch(url, {
      method: &quot;POST&quot;,
      headers: {
        &quot;X-Internal-Token&quot;: token,
        &quot;Content-Type&quot;: &quot;application/json&quot;,
      },
      signal: ac.signal,
    });

    const elapsed = Date.now() - startedAt;
    console.log(JSON.stringify({
      msg: &quot;ranking_recalculate_called&quot;,
      status: res.status,
      elapsedMs: elapsed,
    }));

    // 실패면 에러로 올려서 CloudWatch에서 바로 보이도록 함
    if (!res.ok) {
      const body = await res.text().catch(() =&gt; &quot;&quot;);
      throw new Error(`Scheduler API failed: ${res.status} ${body}`);
    }

    return { ok: true };
  } finally {
    clearTimeout(t);
  }
};</code></pre>
<p><strong>Step 5) EventBridge Scheduler 등록</strong>
10분 주기로 EventBridge Scheduler를 등록한다.
<img src="https://velog.velcdn.com/images/o_z/post/ed58d690-a11d-475d-8336-97b6d10e9ed3/image.png" alt="">
이제 이 EventBridge Scheduler의 대상으로 아까 등록한 Lambda job을 등록하면 된다.</p>
<p>이 구조의 장점은 스케줄 실행 주체가 서버 밖으로 빠지면서, 
<strong>서버 재시작/장애로 스케줄이 누락돼도 수동 호출</strong>이 쉬워진다는 점이다.</p>
<h3 id="3️⃣-분산락-도입">3️⃣ 분산락 도입</h3>
<p>분산락을 사용해 하나의 서버만 스케줄러를 동작하게 만들도록 하는 방법이 있다.
분산락 구현 방법으로는 <strong>MySQL의 네임드락</strong>과 <strong>ShedLock 라이브러리</strong>를 고려했다.</p>
<h4 id="1-네임드락으로-제어하기">1. 네임드락으로 제어하기</h4>
<p>MySQL의 네임드락을 사용하면 락을 얻은 쪽은 정상 수행하고, 락을 얻지 못한 쪽은 대기하게 된다. 이 때 대기 시간을 0초로 설정하면 락을 얻지 못한 쪽은 즉시 실패하면서 하나의 서버만 스케줄러를 실행하도록 보장할 수 있다.</p>
<p>네임드락의 주의점은 <strong>락이 커넥션에 붙는 것이므로 트랜잭션과 무관하다.</strong>
따라서 <code>@Transactional</code>의 commit/rollback과 락의 get/release는 상관없다.
주의할 시나리오가 락 release에서 실패할 시 트랜잭션과 작업 사항들은 rollback 되지만 락이 점유된 상태로 유지될 수 있다는 것이다.
그래서 네임드락을 점유하는 DataSource 커넥션 또한 같은 것으로 사용되어야 한다.
또한 JPA는 Entity 기반의 쿼리 실행에 최적화 되어있지만, 이런 Native Query가 필요한 경우는 JDBC가 적합하다.</p>
<p>그래서 만약 네임드락을 도입하게 된다면, <code>JdbcTemplate</code> 기반으로 아래와 같이 callback 패턴의 락 get/release를 구현해야한다.</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class NamedLockExecutor {

    private final JdbcTemplate jdbcTemplate;

    public boolean execute(String lockName, Runnable task) {
        return Boolean.TRUE.equals(jdbcTemplate.execute((ConnectionCallback&lt;Boolean&gt;) con -&gt; {
            if (!getLock(con, lockName, 0)) { // 못 잡으면 바로 종료
                return false;
            }

            try {
                task.run(); // 랭킹 스케줄러 수행
                return true; // 성공 시 true
            } finally {
                releaseLock(con, lockName); // 락 해제
            }
        }));
    }

    private boolean getLock(Connection con, String lockName, int timeout) throws SQLException {
        try (PreparedStatement ps = con.prepareStatement(&quot;SELECT GET_LOCK(?, ?)&quot;)) {
            ps.setString(1, lockName);
            ps.setInt(2, timeout);

            try (ResultSet rs = ps.executeQuery()) {
                rs.next();
                return rs.getInt(1) == 1;
            }
        }
    }

    private void releaseLock(Connection con, String lockName) throws SQLException {
        try (PreparedStatement ps = con.prepareStatement(&quot;SELECT RELEASE_LOCK(?)&quot;)) {
            ps.setString(1, lockName);
            ps.executeQuery();
        }
    }
}</code></pre>
<p>사용할 땐 이처럼 사용한다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class RankingJobService {

    private final NamedLockExecutor namedLockExecutor;
    private final RankingService rankingService;

    public boolean run() {
        return namedLockExecutor.execute(&quot;monthly-ranking-scheduler&quot;, () -&gt; {
            rankingService.calculateMonthlyRanking(); // 여기에 @Transactional
        });
    }
}</code></pre>
<h4 id="2-shedlock-라이브러리-사용하기">2. ShedLock 라이브러리 사용하기</h4>
<p><strong>ShedLock</strong> 라이브러리를 사용하면 간편하게 서버 한 곳에서만 스케줄러를 실행하도록 만들 수 있다.</p>
<p><a href="https://github.com/lukas-krecan/ShedLock"><strong>ShedLock</strong></a>은 스케줄링 실행 시 <strong>동시에 최대 한 번만 실행되도록 제어하는 라이브러리</strong>이다.
하나의 스레드에서 작업이 실행 중이면 잠금이 걸리고, 다른 스레드에서 동일한 작업이 실행되지 않도록 하는 일종의 <strong>분산락</strong> 개념이다.
한 노드에서 이미 실행 중인 작업이 있을 경우, 다른 스레드는 대기하지 않고 건너뛴다.
MongoDB, JDBC, Redis 등 외부 저장소를 사용해서 테이블에 스케줄러의 실행 정보를 저장해야한다.</p>
<p>⚠️ 데이터베이스 레벨에서 직접 데이터 접근 락을 제어하는게 아닌, <strong>애플리케이션 레벨에서 스케줄러 실행 횟수를 제어하는 것</strong>이다.</p>
<h4 id="🔒-shedlock의-원리">🔒 ShedLock의 원리</h4>
<h4 id="1-lock-얻는-과정">1) Lock 얻는 과정</h4>
<ol>
<li>모든 서버가 동시에 스케줄러 작업을 수행하려고 한다.</li>
<li><code>@SchedulerLock</code> AOP가 먼저 개입해 <code>LockProvider</code>를 통해 락을 시도한다.</li>
<li>현재 시각 기준 점유할 수 있는 락(<code>lock_until &lt;= now</code>)일 경우에만 업데이트 한다.<pre><code class="language-sql"># pseudo code
UPDATE shedlock
SET lock_until = now + lockAtMostFor,
 locked_at  = :lockedAt,
 locked_by  = :lockedBy
WHERE name = :name
AND lock_until &lt;= :now;</code></pre>
</li>
<li>해당 update가 성공해서 1 row가 변경되면, 해당 인스턴스가 락을 획득한 인스턴스이다.<ul>
<li>나머지 서버들은 0 row 업데이트이므로, 락을 못잡아 작업을 스킵한다.</li>
</ul>
</li>
</ol>
<h4 id="2-lock을-얻은-후-작업-실행">2) Lock을 얻은 후 작업 실행</h4>
<ol>
<li>락을 잡은 서버가 <code>lock_until = now + lockAtMostFor</code>인 상태로 작업 수행을 시작한다.<ul>
<li>다른 서버는 <code>now + lockAtMostFor</code> 이전까지는 락 획득에 실패한다. </li>
</ul>
</li>
</ol>
<h4 id="3-lock-해제-과정">3) Lock 해제 과정</h4>
<ol>
<li>작업이 정상적으로 끝나면 <code>lockAtLeastFor</code>을 고려해 <code>lock_until</code>을 조정한다.<ul>
<li>락의 최소 유지 시점과 실제 작업이 종료되는 시간 중 최대 시간으로 <code>lock_until</code>을 업데이트한다.</li>
<li>락의 최소 유지 시간을 반영해, 너무 짧은 시간 내에 다른 서버가 재실행을 하는 것을 방지한다.<pre><code class="language-sql"># pseudo code
UPDATE shedlock
SET lock_until = MAX(start_at + lockAtLeastFor, NOW())
WHERE name = :lockName
AND locked_by = :currentInstance;</code></pre>
</li>
</ul>
</li>
</ol>
<h4 id="🔒-shedlock-적용하기">🔒 ShedLock 적용하기</h4>
<p>기존의 스케줄링 메서드는 아래와 같다.</p>
<pre><code class="language-java">@Scheduled(cron = EVERY_TEN_MINUTES_CRON, zone = TIME_ZONE)
public void tenMinutelyCalculateMemberRank() {
    log.info(&quot;이달의 독서왕 순위 업데이트&quot;);
    readingService.updateMonthlyRanking();
    log.info(&quot;이달의 독서왕 순위 업데이트 완료&quot;);
}</code></pre>
<p>ShedLock을 적용하기 위해선 ShedLock 라이브러리를 import하고, 
Application 클래스에 <code>@EnableSchedulerLock</code> 애노테이션을 붙여준다.</p>
<pre><code class="language-java">...
@EnableSchedulerLock
@SpringBootApplication
public class BomBomServerApplication {
    ...</code></pre>
<p>이제 <code>shedlock</code> 전용 테이블이 필요하다.
작업에 대해 락을 잡고 처리하는데 필요한 정보가 <code>shedlock</code> 테이블에 저장되기 때문이다. 
<code>shedlock</code>을 생성하는 테이블 스키마는 아래와 같다.</p>
<pre><code class="language-sql">CREATE TABLE shedlock(
    name VARCHAR(64) NOT NULL,
    lock_until TIMESTAMP(3) NOT NULL,
    locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    locked_by VARCHAR(255) NOT NULL, 
    PRIMARY KEY (name)
);</code></pre>
<ul>
<li><code>name</code> : 작업 이름이다. (PK)</li>
<li><code>lock_until</code> : 이 시간까지 락이 유효하다.</li>
<li><code>locked_at</code> : 락을 획득한 시간이다.</li>
<li><code>locked_by</code> : 락을 가진 인스턴스이다.</li>
</ul>
<p>ShedLock을 얻는데 사용하는 <code>ShedLockProvider</code> bean도 등록한다.</p>
<pre><code class="language-java">@Configuration
public class ShedLockConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(dataSource);
    }
}</code></pre>
<p>그리고 ShedLock을 적용하려는 스케줄러에 아래처럼 <code>@SchedulerLock</code>을 적용하면 된다.</p>
<pre><code class="language-java">@Scheduled(cron = EVERY_TEN_MINUTES_CRON, zone = TIME_ZONE)
@SchedulerLock(name = &quot;ten_minutely_calculate_member_rank&quot;, lockAtLeastFor = &quot;PT1.5S&quot;, lockAtMostFor = &quot;PT3S&quot;)
public void tenMinutelyCalculateMemberRank() {
    log.info(&quot;이달의 독서왕 순위 업데이트&quot;);
    readingService.updateMonthlyRanking();
    log.info(&quot;이달의 독서왕 순위 업데이트 완료&quot;);
}</code></pre>
<ul>
<li><code>name</code> : ShedLock용 테이블에 PK로 들어갈 작업 이름이다.<ul>
<li>서버가 여러 대 떠있어도 하나의 시점에 <code>name</code>의 락을 갖는 서버는 딱 한 대이다.</li>
</ul>
</li>
<li><code>lockAtLeastFor</code> : 적어도 락을 유지할 시간을 지정한다.<ul>
<li>서버들 간 시간 차이로 인해 작업이 너무 빨리 끝나서 락을 해제하면, 타 서버에서는 락을 점유할 수 있다고 오인할 수 있기 때문에 필요하다.</li>
</ul>
</li>
<li><code>lockAtMostFor</code> : 락을 최대 유지할 시간을 지정한다.<ul>
<li>평소 예상 실행 시간보다 훨씬 길게 잡아 안전장치로 두어야 한다.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="🚀-최종적으로-선택한-방법">🚀 최종적으로 선택한 방법</h3>
<p>아래는 각 방법들의 장단점을 정리했다.</p>
<h4 id="1-spring-환경변수로-스케줄러-실행-활성화--비활성화">1. Spring 환경변수로 스케줄러 실행 활성화 / 비활성화</h4>
<table>
<thead>
<tr>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>- 단일 실행 단위를 서버부터 메서드까지 세밀한 제어가 가능하다.</td>
<td>- 스케줄링 담당 서버가 죽을 경우 실행되지 않는다.</td>
</tr>
<tr>
<td></td>
<td>- 서버를 교체하려면 다시 스케줄링 서버를 재설정해주어야 한다.</td>
</tr>
<tr>
<td>#### 2. 스케줄러 수행 API를 만들고 cron job으로 실행하기</td>
<td></td>
</tr>
<tr>
<td>장점</td>
<td>단점</td>
</tr>
<tr>
<td>---</td>
<td>---</td>
</tr>
<tr>
<td>- EventBridge+Lambda 조합으로 서버 오류 및 재가동으로 인한 재시도가 수월하다.</td>
<td>- 스케줄링 API를 외부에 공개하게 되므로 헤더 토큰이나 보안그룹 등 민감한 보안 관리가 필요하다.</td>
</tr>
<tr>
<td>- 스케줄링 실행 주기나 일시중지, 재시도 등의 정책이 배포없이 이뤄질 수 있다.</td>
<td></td>
</tr>
</tbody></table>
<h4 id="3-분산락-도입">3. 분산락 도입</h4>
<table>
<thead>
<tr>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>- 하나의 서버가 죽으면 나머지 다른 서버가 스케줄러를 실행하면 되므로 유연하게 단일 실행을 보장할 수 있다.</td>
<td>- 락 저장소에 장애가 발생하면 스케줄러도 연계적으로 같이 멈춘다.</td>
</tr>
<tr>
<td>- 락 테이블로 어떤 서버가 언제 스케줄러를 실행했는지 모니터링이 용이하다.</td>
<td></td>
</tr>
</tbody></table>
<p>첫 번째 방법은 스케줄링 담당 서버에게 스케줄링 책임이 과도하게 부여된다는 점이 큰 단점으로 와닿았다.
또한 이 방법의 장점도 우리에겐 크게 다가오지 않는다. 모든 스케줄러가 단일 실행되어야 하므로 메서드 별 제어가 필요 없었기 때문이다. 그래서 첫 번째 방법은 제외했다.</p>
<p>두 번째 방법인 EventBridge+Lambda 조합을 활용한 스케줄러 API를 cron job으로 수행하기도 고려했으나, 보안상의 문제로 좋지 못하다고 판단했다. 만약 우리 자체 LB 서버가 아닌 ALB를 사용했다면 internal API와 internet-facing이 명확히 분리해서 메인 서버에는 이 ALB로부터 온 API만을 허용하도록 스케줄러 API 보안을 탄탄하게 구성할 수 있었을 것이다. 하지만 ALB는 꽤나 비용이 나가는 AWS 서비스이며, 우리는 현재 비용 절감이 더 중요한 상황이라 선택하지 않았다.</p>
<p><strong>3번 분산락 도입</strong>은 나머지 두 방법 대비 유지보수 비용과 유연하단 장점이 컸고, 단점이 크지 않게 느껴졌다.
분산락을 어떻게 구현하더라도 우리는 RDS를 사용해야했고, 사실상 RDS가 SPOF는 맞지만 이 단점은 모든 방법들도 마찬가지였기 때문이다.. 감안할 수 있는 수준의 단점이라고 여겼다.</p>
<p>그래서 분산락 구현 방법 중 <strong>네임드락 구현</strong>과 <strong>ShedLock 라이브러리 사용</strong>의 장단점을 비교했다.</p>
<h4 id="mysql-네임드락-구현">MySQL 네임드락 구현</h4>
<table>
<thead>
<tr>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>- 지금 스택에서 추가 인프라 없이 구현할 수 있다.</td>
<td>- 현재 JPA 구조로는 한계가 있어 JDBC를 도입해야 한다.</td>
</tr>
<tr>
<td></td>
<td>- 커넥션에 붙은 락이므로 커넥션 관리를 직접 해줘야해 구현복잡도가 높다.</td>
</tr>
</tbody></table>
<h4 id="shedlock-라이브러리-사용">ShedLock 라이브러리 사용</h4>
<table>
<thead>
<tr>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>- 락을 잡은 후 서버가 죽는 상황이 와도 lockAtMostFor 옵션으로 자동 락 해제가 되어 안정적이다.</td>
<td>- 락 점유 시간에 대한 튜닝이 필요하다. (lockAtLeastFor, lockAtMostFor)</td>
</tr>
<tr>
<td>- 애노테이션으로 손쉽게 분산락 적용이 가능하다.</td>
<td>- 라이브러리 의존성이 추가된다.</td>
</tr>
</tbody></table>
<p>두 방법을 비교했을 때, 네임드락 대비 <strong>ShedLock</strong>의 장점이 훨씬 크다고 판단했다.
분산락 적용도 라이브러리 import와 애노테이션 붙이기로 간단했으며, lockAtLeastFor, lockAtMostFor 옵션들이 주어져 더 안정적으로 락을 제어할 수 있다.</p>
<h4 id="✅-그래서-결론적으로-shedlock을-도입하기로-결정했다">✅ 그래서 결론적으로 ShedLock을 도입하기로 결정했다.</h4>
<hr>
<p>이렇게 Deadlock 발생 원인에 대해 파악해보고 ShedLock으로 간단하게 해결했다.
분산환경에서 발생할 수 있는 문제로 &quot;스케줄러 중복 실행&quot;을 깨닫게 됐다.</p>
<p>공부해보면서 Shedlock은 분산 환경 + 단일 DB 일 때 사용하기 좋은 전략임을 느꼈다.
하지만 규모가 훨씬 큰 서비스에서는 스케줄러 전용 서버가 분리될 수도 있을 것 같고, 여러 대의 스케줄러 서버가 생기면 AWS의 ALB + EventBridge + Lambda 조합을 사용할 것 같다. 아무래도 수동 재시도의 이점이 크게 여겨지지 않을까 싶다.</p>
<hr>
<blockquote>
<p>참고
<a href="https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-engine-innodb-status">https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-engine-innodb-status</a>
<a href="https://bugs.mysql.com/bug.php?id=72005">https://bugs.mysql.com/bug.php?id=72005</a>
<a href="https://github.com/lukas-krecan/ShedLock">https://github.com/lukas-krecan/ShedLock</a>
<a href="https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-engine-innodb-status">https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-engine-innodb-status</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[300만 건의 아티클 데이터에서 조회 성능 개선하기]]></title>
            <link>https://velog.io/@o_z/300%EB%A7%8C%EA%B1%B4%EC%9D%98-%EC%95%84%ED%8B%B0%ED%81%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%97%90%EC%84%9C-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@o_z/300%EB%A7%8C%EA%B1%B4%EC%9D%98-%EC%95%84%ED%8B%B0%ED%81%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%97%90%EC%84%9C-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 29 Oct 2025 06:43:02 GMT</pubDate>
            <description><![CDATA[<p>우리 봄봄 서비스에 정말 많은 MAU를 확보했다는 행복한 상상을 하며, 정말 많은 아티클 데이터가 쌓이는 상황에서의 성능 개선을 진행해보았다.</p>
<hr>
<h2 id="쿼리-튜닝이란">쿼리 튜닝이란?</h2>
<blockquote>
<p><em>Query optimization is a feature of many relational database management systems and other databases such as NoSQL and graph databases. The query optimizer attempts to determine the most efficient way to execute a given query by considering the possible query plans.</em>
_쿼리 최적화(Query Optimization)는 많은 관계형 데이터베이스 관리 시스템(RDBMS)뿐 아니라 NoSQL이나 그래프 데이터베이스 같은 다른 데이터베이스에서도 제공되는 기능입니다.
쿼리 옵티마이저(Query Optimizer)는 주어진 쿼리를 실행하는 여러 가능한 실행 계획(Query Plan)을 고려하여, 그중 가장 효율적인 방법으로 쿼리를 수행하는 방식을 결정하려고 시도합니다. _</p>
</blockquote>
<p>위는 wikipedia에서 말하는 <strong>Query Optimization</strong>의 의미이다. 간단하게는, 쿼리 튜닝은 데이터베이스 쿼리 실행 계획을 분석하고 불필요한 작업을 줄여 응답 시간을 최소화 하는 과정이다. </p>
<hr>
<h2 id="어떻게-쿼리-튜닝을-할까">어떻게 쿼리 튜닝을 할까?</h2>
<p>나도 처음 쿼리 튜닝이라는 단어를 들었을 땐 단순히 SQL문 구조를 변경해서 절의 실행 순서를 조정하는 것이라고 생각했다. 하지만 생각보다 쿼리 튜닝의 의미는 매우 넓었다. 쿼리가 수행 되는 과정 자체를 개선하는 작업이기 때문에 SQL문 수정 외에도 다양한 방법들이 있다.</p>
<blockquote>
<ol>
<li>SQL문 최적화</li>
<li>인덱스 활용</li>
<li>데이터 아키텍처 수정</li>
<li>캐싱 활용</li>
</ol>
</blockquote>
<p>이 외에도 다양한 방법이 있고, 각 카테고리별로도 솔루션이 세분화 되는데 만약 MySQL을 사용한다면 <a href="https://dev.mysql.com/doc/refman/9.0/en/optimization.html"><strong>MySQL 최적화 공식 문서</strong></a>를 읽어봐도 좋을 듯 하다.</p>
<hr>
<h2 id="언제-쿼리-튜닝을-해야할까">언제 쿼리 튜닝을 해야할까?</h2>
<p>아래는 실제 봄봄 서비스에서 부하 테스트 했을 때 Grafana로 관측했던 p99 그래프이다.
<img src="https://velog.velcdn.com/images/o_z/post/b79550c1-c288-4a60-9fbd-2ec1dfa39807/image.png" alt=""> 위처럼 모니터링에서 <strong>p99 값의 두드러진 급증</strong>이 나타날 경우, 쿼리 튜닝이 필요한 신호로 이해할 수 있다. </p>
<blockquote>
<h4 id="❓-p99란">❓ p99란?</h4>
<p>p99란 <strong>전체 요청 중 가장 느린 상위 1% 요청의 응답 시간</strong>을 의미한다. 
p99 값이 높다는 것은, 대부분의 요청이 빠르지만 <strong>일부 요청이 매우 느리다</strong>는 것이다.</p>
</blockquote>
<p>기존의 평균 응답 시간으로 판단하면 평균의 함정으로 인해 감지되지 않는 병목이나 쿼리 이상 징후를 판단하기가 어렵다.
그래서 p99를 측정해 평균 값으로는 감지할 수 없던 응답의 병목 현상 및 이상 징후를 확인할 수 있다.</p>
<p>p99가 갑자기 치솟는다는 건, 특정 시점부터 일부 요청이 비정상적으로 오래 걸리기 시작했다는 신호이기도 하다. 따라서, 평균 지연 시간보다 이러한 p99 수치를 주기적으로 관찰하면 쿼리 튜닝이 필요한 API를 알아낼 수 있다.</p>
<hr>
<p>봄봄에서는 조회 비율이 가장 높은 핵심 도메인 <strong><code>Article</code></strong> 에 대해 데이터를 쌓고 쿼리 튜닝을 해보고자 한다.</p>
<pre><code class="language-sql">CREATE TABLE article (
    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    contents MEDIUMTEXT NOT NULL,
    member_id BIGINT NOT NULL,
    arrived_date_time DATETIME NOT NULL,
    ...
);</code></pre>
<p>나는 위에서 소개한 4개의 방법들 중 <strong>인덱스</strong>를 활용해 두 가지 시나리오에 대해 개선했다.</p>
<blockquote>
<p>❓ <strong>인덱스란?</strong>
특정 컬럼의 데이터들을 정렬해 데이터베이스 테이블에 대한 검색 속도를 향상시켜주는 자료구조이다.</p>
</blockquote>
<p><em>※ 아래에서 사용한 RDBMS는 MySQL 8.0 (InnoDB) 기준이다.</em></p>
<hr>
<h2 id="첫-번째-시나리오">첫 번째 시나리오</h2>
<h3 id="1-요구사항">1. 요구사항</h3>
<blockquote>
<p><strong>회원에게 오늘 온 아티클 목록을 최신순</strong>으로 보여주려 한다.</p>
</blockquote>
<p>매우 간단하고도 필수적인 요구사항이다.
<code>Article</code> 테이블에서 <code>member_id</code> 조건을 걸고 <code>arrived_date_time</code>을 내림차순 정렬해 조회하는 쿼리이다.</p>
<pre><code class="language-sql">SELECT *
FROM article
WHERE member_id = ?
ORDER BY arrived_date_time DESC;</code></pre>
<h3 id="2-300만건-article에-대한-실행-결과">2. 300만건 article에 대한 실행 결과</h3>
<p><img src="https://velog.velcdn.com/images/o_z/post/e9c19ef0-ba43-493a-bd1d-cd0c22fd0398/image.png" alt=""> 아티클 300만건에 대해 특정 member_id를 넣고 위 쿼리를 실행해보았다. 
두 번째 쿼리부터 10번을 실행했을 때 평균적인 실행 결과 약 <strong>4s</strong>정도가 걸렸다.</p>
<blockquote>
<p>❓<strong>첫 번째 쿼리는 왜 제외했는가?</strong>
쿼리를 처음 실행할 경우, 대상 데이터 페이지가 아직 InnoDB 버퍼 풀에 로드되지 않은 상태이다. </p>
</blockquote>
<p><strong>1. 처음 실행 시 : 요청한 데이터 페이지가 InnoDB 버퍼 풀에 없다.</strong>
→ 디스크에서 페이지를 읽어 버퍼 풀에 적재한다. (<strong>Random I/O</strong>가 발생)
→ I/O 지연으로 실행 시간이 더 길어진다.
→ 이후 버퍼 풀에 캐시된 상태로 유지한다.
(데이터가 많은 테이블 기준 약 2초 더 걸렸던 것 같다.)</p>
<blockquote>
<p><strong>2. 두 번째 이후 실행 시 : 동일한 데이터 페이지가 이미 버퍼 풀에 존재한다.</strong>
→ 메모리 접근만으로도 처리 가능하다.
→ Disk I/O가 없어 실행 시간이 짧아진다.</p>
</blockquote>
<p>첫 번째 실행 결과를 평균 계산에 포함하면 Disk I/O로 인한 왜곡이 발생한다.
따라서 2~10회에서 평균값을 사용하는 것이 더 정확한 값을 내기에 적합하다.</p>
<h3 id="3-실행-계획-파악하기">3. 실행 계획 파악하기</h3>
<p>MySQL에서는 쿼리의 앞에 <code>EXPLAIN</code> 키워드만 붙여주면 실행 계획을 간단히 파악할 수 있다.</p>
<pre><code class="language-sql">EXPLAIN SELECT *
FROM article
WHERE member_id = ?
ORDER BY arrived_date_time DESC;</code></pre>
<blockquote>
<p><strong>❓<code>EXPLAIN</code> vs <code>EXPLAIN ANALYZE</code></strong>
실행계획을 파악할 수 있는 2가지 방법으로 <code>EXPLAIN</code>과 <code>EXPLAIN ANALYZE</code>가 있다. </p>
</blockquote>
<p><strong><code>EXPLAIN</code></strong>: DB 옵티마이저가 예상한 실행 계획을 보여준다.
<strong><code>EXPLAIN ANALYZE</code></strong>: 실제 쿼리를 실행하면서 걸린 시간과 row 수를 측정한다. </p>
<blockquote>
<p>이러면 당연히 <code>EXPLAIN ANALYZE</code>가 훨씬 정확하고 좋은 방법 아니냐 할 수 있는데, <code>EXPLAIN ANALYZE</code>는 <strong>실제로 쿼리를 실행</strong>하므로 실행 계획을 파악하는데 <code>EXPLAIN</code>보다 더 오래 걸릴 수 있다.</p>
<p>뿐 만 아니라, SELECT 쿼리는 데이터에 영향을 주지 않지만 데이터를 변경/추가/삭제 하는 쿼리에 적용할 경우 의도치않게 데이터를 조작하게 될 수 있다.</p>
</blockquote>
<p>실행 계획 결과는 아래와 같다.
<img src="https://velog.velcdn.com/images/o_z/post/13785156-1db0-4172-8ab8-f12ea140b008/image.png" alt=""> 실행 계획에서 <code>type</code>과 <code>Extra</code> 컬럼에 집중해보자. </p>
<h4 id="1-type-all">1. type: ALL</h4>
<p>type은 <strong>MySQL 옵티마이저가 InnoDB에게 어떤 방식으로 데이터를 읽어오라고 지시했는지</strong>를 나타낸다. 즉, 데이터 테이블을 읽어오는 방식을 의미한다. <code>ALL</code>은 가장 비용이 큰 방식으로, 전체 테이블 스캔을 의미한다.
인덱스를 활용하지 못하고 테이블의 모든 레코드를 처음부터 끝까지 읽는 상황인 것이다.</p>
<h4 id="2-extra-using-where-using-filesort">2. Extra: Using where; Using filesort</h4>
<p>Extra 컬럼은 <strong>MySQL Executor가 메모리 버퍼풀에 있는 데이터들에 대해 수행한 추가 작업</strong>을 알려준다.</p>
<p><strong>Using where</strong></p>
<ul>
<li>필터링이 인덱스가 아닌 Executor 단계에서 수행한다.</li>
<li><code>WHERE member_id = ?</code> 부분을 의미한다.</li>
</ul>
<p><strong>Using filesort</strong></p>
<ul>
<li>ORDER BY 수행 시 메모리/디스크에 별도 정렬이 발생한다.</li>
<li><code>ORDER BY arrived_date_time DESC</code> 부분을 의미한다.</li>
</ul>
<h3 id="4-개선-포인트-정리">4. 개선 포인트 정리</h3>
<p>실행 계획 내용을 통합해, 다음과 같이 문제 상황을 정의했다.</p>
<blockquote>
<ol>
<li>WHERE 조건에 대해 <strong>full table scan</strong></li>
<li>ORDER BY절의 <strong>추가 정렬</strong></li>
</ol>
</blockquote>
<p>full table scan을 하고 있다는 점, 그리고 ORDER BY를 위한 정렬을 직접 하고 있다는 점으로 미루어 보아, 인덱스를 사용하면 개선할 수 있을 것 같다.</p>
<p>그 중 <strong><code>Composite Index</code></strong>를 사용하고자 한다.</p>
<h3 id="5-composite-index로-개선하기">5. Composite Index로 개선하기</h3>
<p><strong>Composite Index(복합 인덱스)</strong>란, <strong>두 개 이상의 컬럼을 포함하는 인덱스</strong>이다. 
(a, b, c)로 복합 인덱스를 구성할 경우, a -&gt; b -&gt; c 순서를 기준으로 두고 정렬해 인덱스를 생성한다. 
a 컬럼으로 먼저 정렬한 후 그 다음으로 b 기준 정렬, c 정렬 인 것이다.</p>
<p>개선할 쿼리를 보고 적절한 복합 인덱스를 구성해보자.</p>
<pre><code class="language-sql">SELECT *
FROM article
WHERE member_id = ?
ORDER BY arrived_date_time DESC;</code></pre>
<ul>
<li>WHERE 조건 : member_id</li>
<li>정렬 기준 : arrived_date_time</li>
</ul>
<p>따라서 member_id와 arrived_date_time을 복합 인덱스에 포함하면 될 것이다.</p>
<p>다만 복합 인덱스는 <strong>인덱스의 선행 컬럼</strong>이 중요하다. MySQL의 절 실행 순서 때문에 <strong>왼쪽부터 일치해야</strong> 인덱스를 효율적으로 사용할 수 있다.</p>
<blockquote>
<h4 id="❓-mysql의-sql절-실행-순서">❓ MySQL의 SQL절 실행 순서</h4>
<p>아래와 같이 MySQL 파서는 구문을 인식하는 논리적 처리 순서가 있다.</p>
</blockquote>
<ol>
<li><code>FROM</code> : 테이블 및 조인 대상 결정</li>
<li><code>ON</code> : 조인 조건 평가</li>
<li><code>JOIN</code> : 조인 결과 생성</li>
<li><code>WHERE</code> : 행 필터링</li>
<li><code>GROUP BY</code> : 그룹화 수행</li>
<li><code>HAVING</code> : 그룹 필터링</li>
<li><code>SELECT</code> : 필요한 컬럼 선택</li>
<li><code>DISTINCT</code> : 중복 제거</li>
<li><code>ORDER BY</code> : 정렬 수행</li>
<li><code>LIMIT</code> : 결과 행 수 제한</li>
</ol>
<p>지금 개선할 쿼리에서는 절의 실행 순서가 <code>WHERE</code> -&gt; <code>ORDER BY</code> 이다.
따라서 복합 인덱스를 생성할 때 <code>member_id</code> -&gt; <code>arrived_date_time</code> 순으로 배치하는 것이 올바르다.</p>
<pre><code class="language-sql">-- ❌ 잘못된 Composite Index : 선행 컬럼 불일치로 활용 불가
CREATE INDEX idx_article_member_arrived
ON article (arrived_date_time, member_id);

-- ✅ 올바른 Composite Index
CREATE INDEX idx_article_member_arrived
ON article (member_id, arrived_date_time);</code></pre>
<p>생성된 인덱스 결과는 아래 형태이다.
<img src="https://velog.velcdn.com/images/o_z/post/4ba3b359-b037-427a-97b8-8073c28fb758/image.png" alt=""></p>
<p>member_id로 먼저 정렬하고 arrived_date_time으로 정렬된 인덱스 모습이다.</p>
<h3 id="6-개선-후-실행-계획-파악하기">6. 개선 후 실행 계획 파악하기</h3>
<p>Composite Index를 생성했으니, 기존 쿼리에서 인덱스를 활용하는지 확인해야 한다.
똑같이 <code>EXPLAIN</code>를 붙여 실행 계획을 확인했다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/f207a050-1223-425a-8f25-4812a7176c68/image.png" alt=""></p>
<h4 id="1-type-ref">1. type: ref</h4>
<p>기존에 <code>ALL</code>이었던 type이 <code>ref</code>로 바뀌었다.
<strong><code>ref</code></strong>는 <strong>인덱스를 사용해 특정 키 값에 매칭되는 행을 찾는 것</strong>을 의미한다.
MySQL 엔진이 <code>idx_article_member_arrived</code> 인덱스의 존재를 알고, InnoDB에게 이를 활용하도록 지시한다. 그래서 InnoDB가 디스크로부터 데이터를 읽어올 때, 인덱스를 이용해 필요한 레코드 위치를 찾고 해당하는 레코드들만 가져온다.</p>
<h4 id="2-extra-null">2. Extra: NULL</h4>
<p>Extra 컬럼은 NULL인 것을 보아 <strong>MySQL Executor가 데이터들에 대해 추가 수행하는 작업이 없음</strong>을 알 수 있다.
<code>Using where</code>가 없어진 이유는 인덱스를 통해 이미 <code>WHERE member_id = ?</code>로 필터링 된 데이터만 가져왔기 때문이다.
<code>Using filesort</code>는 왜 없어졌나 싶을 수 있지만, 복합 인덱스의 두 번째 컬럼이 arrived_date_time이다. 즉, 필터링으로 가져온 데이터들이 arrived_date_time으로 이미 정렬 되어있는 것이다. 따라서 추가 정렬이 필요 없다.</p>
<blockquote>
<h4 id="❓-인덱스는-asc로-정렬되는데-desc-정렬-쿼리에서도-활용-가능한가">❓ 인덱스는 ASC로 정렬되는데 DESC 정렬 쿼리에서도 활용 가능한가?</h4>
<p>활용 가능하다.
MySQL의 InnoDB 엔진을 사용하면 인덱스는 B+Tree 구조로 생성된다.
기본적으로는 ASC 정렬된 형태이지만, <strong>인덱스 트리는 양방향 탐색이 가능하다.</strong>
즉, 리프 노드를 뒤에서부터 읽어 DESC 정렬 효과를 그대로 낼 수 있는 것이다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/o_z/post/b4926a51-fdec-473d-b170-7c7e17173fff/image.png" alt=""> 인덱스의 적용 여부를 확인하려면 <code>key</code> 값을 집중해야 한다.</p>
<h4 id="3-key-idx_article_member_arrived">3. key: idx_article_member_arrived</h4>
<p><code>key</code>는 MySQL 옵티마이저가 실제로 사용하기로 선택한 인덱스를 의미한다.
사용 가능한 인덱스들(<code>possible_keys</code>) 중에서 최종 선택한 인덱스로, 이를 사용해 InnoDB가 데이터에 접근한다.</p>
<p>아까 생성했던 복합 인덱스의 이름(idx_article_member_arrived)이 <code>key</code>로 들어간 것을 보아, 의도대로 인덱스가 잘 채택됨을 알 수 있다.</p>
<h3 id="7-개선-결과-확인하기">7. 개선 결과 확인하기</h3>
<p>이번에도 동일하게 300만건 아티클 데이터에 대해 동일한 쿼리를 실행해보았다.
<img src="https://velog.velcdn.com/images/o_z/post/01593314-de6b-46c3-b4be-3bc24e8fed2e/image.png" alt="">기존에는 평균 약 4s 걸리던 쿼리가 Composite Index 적용 이후로는 <strong>0.01s</strong>까지 개선됨을 확인했다. </p>
<hr>
<h2 id="두-번째-시나리오">두 번째 시나리오</h2>
<h3 id="1-요구사항-1">1. 요구사항</h3>
<blockquote>
<p>회원의 <strong>아티클 목록을 제목들로만 간결하게</strong> 보여주려고 한다.
artice의 <strong>id와 title만 있으면 된다.</strong></p>
</blockquote>
<p>내 아티클 목록을 간단한 summary처럼 보여준다고 생각해보자.</p>
<pre><code class="language-sql">SELECT id, title
FROM article
WHERE member_id = ?</code></pre>
<h3 id="2-300만건-article에-대한-실행-결과-1">2. 300만건 article에 대한 실행 결과</h3>
<p><img src="https://velog.velcdn.com/images/o_z/post/6677aa84-e08e-4fab-910c-a33206799ffc/image.png" alt="">이번에도 300만건의 아티클에 대해 특정 member_id를 넣고 위 쿼리를 실행했다. 
두 번째 쿼리부터 10번 실행 시 평균 결과 약 <strong>3.56s</strong> 걸렸다.</p>
<h3 id="3-실행-계획-파악하기-1">3. 실행 계획 파악하기</h3>
<p><code>EXPLAIN</code> 키워드를 통해 실행 계획을 파악해본다.</p>
<pre><code class="language-sql">EXPLAIN SELECT id, title
FROM article
WHERE member_id = ?</code></pre>
<p>실행 계획 결과는 아래와 같았다. 
<img src="https://velog.velcdn.com/images/o_z/post/d049f3bc-a92d-47e3-ad91-69b2212f3d05/image.png" alt=""> 실행 계획에서 마찬가지로 <code>type</code>과 <code>Extra</code>를 분석한다.</p>
<h4 id="1-type-all-1">1. type: ALL</h4>
<p>이번 쿼리도 마찬가지로 인덱스 없이 테이블의 모든 레코드를 처음부터 끝까지 읽고 있다.</p>
<h4 id="2-extra-using-where">2. Extra: Using where</h4>
<p>이번에도 WHERE절에 대한 필터링을 Executor 단계에서 수행한다.</p>
<h3 id="4-개선-포인트-정리-1">4. 개선 포인트 정리</h3>
<p>이번 쿼리에서도 발생하는 문제점은 동일하다.</p>
<blockquote>
<ol>
<li>WHERE 조건에 대한 <strong>full table scan</strong></li>
</ol>
</blockquote>
<p>이 외에도 실행 계획에서는 나타나지 않는 추가적인 개선 포인트를 찾을 것이 있다.</p>
<h4 id="불필요한-컬럼들의-메모리-적재">&quot;불필요한 컬럼들의 메모리 적재&quot;</h4>
<p><code>SELECT id, title</code> 과정에서 필요한 필드들(id, title)은 MySQL 엔진에서만 알고있다.
그리고 InnoDB 엔진은 MySQL 엔진으로부터 어떤 <strong>&quot;데이터 페이지&quot;</strong>를 읽을지에 대한 정보만을 얻는다.
따라서, InnoDB 엔진은 조건에 해당하는 <strong>&quot;데이터 페이지&quot;</strong>를 버퍼 풀에 올리는 역할만 한다.</p>
<p>즉 <strong>InnoDB가 조건에 해당하는 모든 레코드를 버퍼 풀에 올려주면, MySQL 엔진은 여기서 필요한 컬럼만 골라 쓰는 것이다.</strong></p>
<p>현재 article 테이블에는 컬럼 수가 14개이다. 이 중 쿼리에서 필요한 컬럼은 단 2개이다.
2개의 컬럼을 위해 <strong>나머지 12개의 컬럼은 불필요하게 메모리에 적재되는 것이다.</strong></p>
<p>이 과정을 통해 최종적으로 개선해야 할 부분들은 아래와 같다.</p>
<blockquote>
<ol>
<li>WHERE 조건에 대한 <strong>full table scan</strong></li>
<li>id, title 컬럼을 위해 <strong>불필요한 나머지 컬럼이 메모리에 적재된다.</strong></li>
</ol>
</blockquote>
<p>개선점 2번을 미루어보아 적용하기 가장 좋은 인덱스는 <strong><code>Covering Index</code></strong>이다.</p>
<h3 id="5-covering-index로-개선하기">5. Covering Index로 개선하기</h3>
<p><strong>Covering Index(커버링 인덱스)</strong>란 쿼리를 수행할 때 필요한 모든 컬럼이 인덱스에 포함되어 있어, 데이터 페이지 접근 없이 인덱스만으로 결과를 반환할 수 있는 인덱스이다. 
즉, InnoDB가 <strong>디스크 테이블의 실제 데이터를 읽지 않고 인덱스(B+Tree)만으로 쿼리를 처리</strong>할 수 있게 되는 구조이다.</p>
<p>일반 인덱스와 커버링 인덱스의 동작 차이는 아래와 같다.</p>
<blockquote>
</blockquote>
<p><strong>일반 인덱스</strong> : 인덱스 탐색 → PK 추출 → PK 사용해 데이터 페이지 접근 (모든 컬럼 로드)
<strong>커버링 인덱스</strong> : 인덱스 탐색 → 필요 컬럼들이 인덱스에 모두 있으므로 <strong>데이터 바로 반환</strong></p>
<p>즉, 커버링 인덱스는 데이터 페이지 접근이 없으므로 <strong>Disk I/O가 생략되는 것이다.</strong>
성능상으로 강력한 이점을 볼 수 있다.</p>
<blockquote>
<h4 id="❓그럼-항상-모든-컬럼을-인덱스에-포함하면-되지-않나">❓그럼 항상 모든 컬럼을 인덱스에 포함하면 되지 않나?</h4>
<p>Covering Index는 디스크 접근을 없애주는 강력한 효과를 갖기 때문에,
“그럼 모든 컬럼을 인덱스에 넣으면 I/O가 사라지고 쿼리 속도가 가장 빠르지 않을까?” 싶었다.</p>
</blockquote>
<p>하지만 <strong>인덱스도 결국 디스크에 저장되는 별도의 페이지</strong>이다.
따라서 인덱스 컬럼을 많이 포함할수록, 인덱스 페이지 자체가 커지고 무거워진다.
이는 곧 디스크 낭비로 이어질 수 있으므로, 적절한 컬럼을 선택해 포함하는 것이 중요하다.</p>
<p>이번 쿼리에서 적절한 커버링 인덱스를 구성해보자.</p>
<pre><code class="language-sql">SELECT id, title
FROM article
WHERE member_id = ?</code></pre>
<ul>
<li>필요한 컬럼 : id, title, member_id</li>
</ul>
<p>필요한 3가지 컬럼을 모두 포함하는 인덱스를 아래처럼 생성했다.
이번 인덱스도 여러 개의 필드를 포함하므로, 사실상 <strong>복합 인덱스와의 교집합 인덱스</strong>이기도 하다.
따라서 이번에도 선행 컬럼 순서를 잘 고려해 생성하도록 하자.</p>
<pre><code class="language-sql">CREATE INDEX idx_article_member_id_title
ON article (member_id, title);</code></pre>
<h4 id="️-id가-포함이-안되어있는데">⁉️ id가 포함이 안되어있는데?</h4>
<p>id는 일부로 포함하지 않았다.
이유는 <strong>InnoDB의 보조 인덱스(Secondary index) 구조</strong> 때문이다.
InnoDB의 리프 노드는 <strong>데이터 페이지의 레코드를 찾아가기 위해, 항상 PK 값을 리프 노드 말단에 함께 저장한다.</strong>
따라서 사실상 인덱스는 아래처럼 <strong>(member_id, title, id)</strong>로, <strong>PK가 포함</strong>되어 생성되는 것이나 다름없다.<img src="https://velog.velcdn.com/images/o_z/post/a1aa43b7-fb4b-4a75-b321-90071015b9d4/image.png" alt=""> id가 명시적으로 인덱스에 없어도, 리프 노드에 이미 포함되어 있으므로 디스크 접근 없이도 PK 값을 얻을 수 있다.
따라서 (member_id, title) 인덱스만으로도 <strong>완전한 커버링 인덱스(Covering Index) 역할을 수행</strong>한다.</p>
<blockquote>
<h4 id="❓-인덱스에-명시적으로-pk를-포함할-때의-차이">❓ 인덱스에 명시적으로 PK를 포함할 때의 차이</h4>
<p>InnoDB는 인덱스에 PK를 명시하지 않아도 자동으로 갖고 있는데,
“그렇다면 굳이 <strong>인덱스 생성에 PK를 명시할 필요가 있을까?</strong>”</p>
</blockquote>
<p>→ <strong>필요하기도 하다. 목적이 다를 뿐이다.</strong></p>
<blockquote>
</blockquote>
<p>이미 인덱스를 잘 알고 있다면 눈치 챘겠지만, 명시적으로 PK를 포함하면 <strong>PK 기준 정렬</strong>이 수행된다.
(member_id, title, id)로 인덱스를 생성하면 2번 째 컬럼(title)까지 tie-break가 발생할 경우,
<strong>마지막은 id 값을 기준으로 정렬을 수행하는 것이다.</strong></p>
<blockquote>
</blockquote>
<p>만약 tie-break에 대한 순서가 중요하고 PK를 정렬해 사용해야한다는 요구사항이 명확하면
PK도 인덱스 생성 시 포함하자.</p>
<h3 id="6-개선-후-실행-계획-파악하기-1">6. 개선 후 실행 계획 파악하기</h3>
<p>이제 쿼리가 생성한 Covering Index를 채택하는지 확인한다.
마찬가지로 <code>EXPLAIN</code>으로 확인한 결과는 아래와 같다.
<img src="https://velog.velcdn.com/images/o_z/post/cb59b86e-28be-4358-a0bf-4dc7d388479e/image.png" alt=""></p>
<h4 id="1-type-ref-1">1. type: ref</h4>
<p>이번에도 type 값이 <code>ref</code>가 되었다. <strong>인덱스로 특정 키 값에 매칭되는 행을 찾고 있다.</strong>
WHERE절의 member_id 일치 비교로 매칭되는 행을 찾는 것이다.</p>
<h4 id="2-extra-using-index">2. Extra: Using Index</h4>
<p>Extra 컬럼은 <code>Using Index</code>라는 값이 생겼다.
<strong>데이터 테이블 접근 없이 인덱스만으로 결과 반환 가능</strong>하다는 것을 뜻한다.
커버링 인덱스 사용을 의미한다.</p>
<p>인덱스 적용 여부도 <code>key</code> 값으로 확인해보자.
<img src="https://velog.velcdn.com/images/o_z/post/38f5921e-786d-40e0-8215-3971da73af7c/image.png" alt=""></p>
<h4 id="3-key-idx_article_member_id_title">3. key: idx_article_member_id_title</h4>
<p>생성한 커버링 인덱스(idx_article_member_id_title)이 채택됨을 확인했다.</p>
<h3 id="7-개선-결과-확인하기-1">7. 개선 결과 확인하기</h3>
<p>같은 데이터셋에 대해 동일한 쿼리를 실행했다.
<img src="https://velog.velcdn.com/images/o_z/post/9c07fd01-9567-48d3-ac84-3d6028945d5d/image.png" alt=""> 기존에는 3.56s 걸리던 쿼리가 <strong>0.00s, ms 단위까지 성능이 개선됐다.</strong></p>
<hr>
<p>이번에 쿼리 성능 향상을 해보면서, 쿼리 튜닝 중 가장 간단하고도 강력한 개선 효과를 볼 수 있는 것이 인덱스라는 점을 확실히 깨달을 수 있었다.
쓰기 작업 대비 조회 수행률이 높다면 인덱스 추가는 어쩌면 당연한 작업이 아닐까 싶다. </p>
<hr>
<blockquote>
<p>참고
<a href="https://dev.mysql.com/doc/refman/9.0/en/optimize-overview.html">https://dev.mysql.com/doc/refman/9.0/en/optimize-overview.html</a>
<a href="https://en.wikipedia.org/wiki/Query_optimization">https://en.wikipedia.org/wiki/Query_optimization</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[could not initialize proxy - no session 이슈 해결]]></title>
            <link>https://velog.io/@o_z/could-not-initialize-proxy-no-session-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@o_z/could-not-initialize-proxy-no-session-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Sun, 19 Jan 2025 08:19:40 GMT</pubDate>
            <description><![CDATA[<p>우리 서비스에서 매일 정각마다 수행하는 스케줄러가 있는데, 아래같은 에러가 발생했다.</p>
<pre><code>20250119T000002 scheduling-1 ERROR o.s.s.s.TaskUtils$LoggingErrorHandler Unexpected error occurred in scheduled task
org.hibernate.LazyInitializationException: could not initialize proxy [com.gamzabat.algohub.feature.user.domain.User#8] - no Session
...</code></pre><hr>
<h2 id="원인">원인</h2>
<p>로그를 보면 scheduled task에서 error가 발생했음을 알 수 있다. 그 중, User 엔티티의 proxy를 initialize 할 수 없다는 내용이다. </p>
<h3 id="lazyinitializationexception">LazyInitializationException</h3>
<p>기본적으로 지연로딩은 영속성 컨텍스트에서 객체를 접근할 수 있어야 한다는 조건이 있다. <code>LazyInitializationException</code> 은 지연 로딩으로 엔티티를 조회할 때, 해당 엔티티가 포함된 영속성 컨텍스트가 닫혀있거나 접근할 수 없는 상태일 경우 발생한다. </p>
<p><img src="https://velog.velcdn.com/images/o_z/post/9108d760-3d41-4bec-a9f7-8ab20aba5291/image.png" alt=""></p>
<p>현재 우리 프로젝트에서는 OSIV 설정을 안 했으므로 스프링의 기본 영속성 컨텍스트 범위를 따른다. 즉, 영속성 컨텍스트가 유지되는 범위는 <code>@Transactional</code> 적용 범위가 된다.</p>
<hr>
<h2 id="exception-발생-flow">Exception 발생 flow</h2>
<p>기존에 있던 스케줄러 메소드는 <code>@Transactional</code>이 없었다.</p>
<pre><code class="language-java">@Scheduled(cron = &quot;0 0 0 * * ?&quot;, zone = &quot;Asia/Seoul&quot;)
public void dailyProblemScheduler() {
    ...
    notifyProblemStartsToday(now);
    ...
}

private void notifyProblemStartsToday(LocalDate now) {
    List&lt;Problem&gt; problems = problemRepository.findAllByStartDate(now);
    for (Problem problem : problems) {
        notificationService.sendNotificationToMembers(
            ...
            groupMemberRepository.findAllByStudyGroup(problem.getStudyGroup()),
            ...
        );
    }
}
...</code></pre>
<p>즉, 위 코드에서<code>problems</code>와 <code>groupMemberRepository.findAllByStudyGroup()</code> 으로 찾은 데이터들은 <strong>영속성 컨텍스트에 포함되지 않는 것이다.</strong></p>
<p>이 상태로 <code>sendNotificationToMembers()</code> 메소드를 호출하면서, 파라미터로는 <strong>영속성 컨텍스트에 포함 안 된 members</strong>를 넘겨준다. 그래서 <code>sendNotificationToMembers()</code> 메소드의 로직 중, 아래 코드에서 에러가 발생한다.</p>
<pre><code class="language-java">if (setting.isAllNotifications() &amp;&amp; isSettingOn(setting, category))
    users.add(member.getUser().getEmail());</code></pre>
<p><code>member.getUser().getEmail()</code> 로 User 데이터를 지연 조회 하려는데, member 객체가 현재 영속성 컨텍스트에 포함되지 않으니 <code>LazyInitializationException</code> 가 발생하는 것이다.</p>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<p><code>LazyInitializationException</code>를 해결할 수 있는 방법으로는 OSIV를 사용해 영속성 컨텍스트 범위를 열어주거나, 지연로딩 대신 EAGER를 사용하는 방법도 있었다. 하지만 이번 케이스에서는 영속성 컨텍스트의 범위가 문제 되는 것이었다. </p>
<p>그래서 기존에 영속성 컨텍스트에 포함되지 않던 members가 포함될 수 있게 수정하고자 했다. 그 방법으로 스케줄러 메소드 자체에 <code>@Trnasactional</code>을 붙여서 영속성 컨텍스트에 포함되도록 만들어 해결했다. </p>
<pre><code class="language-java">@Transactional
@Scheduled(cron = &quot;0 0 0 * * ?&quot;, zone = &quot;Asia/Seoul&quot;)
public void dailyProblemScheduler() {
   ...
}</code></pre>
<hr>
<blockquote>
<p>참고
<a href="https://strong-park.tistory.com/entry/orghibernatelazyinitializationexception-could-not-initialize-proxy-%EC%97%90%EB%9F%AC-%ED%95%B4%EA%B2%B0">https://strong-park.tistory.com/entry/orghibernatelazyinitializationexception-could-not-initialize-proxy-%EC%97%90%EB%9F%AC-%ED%95%B4%EA%B2%B0</a>
<a href="https://stackoverflow.com/questions/345705/hibernate-lazyinitializationexception-could-not-initialize-proxy">https://stackoverflow.com/questions/345705/hibernate-lazyinitializationexception-could-not-initialize-proxy</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[N번의 delete query와 batch delete]]></title>
            <link>https://velog.io/@o_z/N%EB%B2%88%EC%9D%98-delete-query%EC%99%80-batch-delete</link>
            <guid>https://velog.io/@o_z/N%EB%B2%88%EC%9D%98-delete-query%EC%99%80-batch-delete</guid>
            <pubDate>Sat, 21 Dec 2024 09:45:12 GMT</pubDate>
            <description><![CDATA[<p>기존에 delete query를 jpa에서 제공하는 메소드 그대로 사용했었다.</p>
<pre><code class="language-java">groupMemberRepository.deleteByStudyGroup(group);</code></pre>
<p>그런데 발생하는 delete 쿼리가 한 개가 아니었다.</p>
<p>실제로 4개의 GroupMember 엔티티를 위 메소드로 삭제하면 아래처럼 쿼리가 발생한다.</p>
<pre><code class="language-java">Hibernate: 
    delete 
    from
        group_member 
    where
        id=?
Hibernate: 
    delete 
    from
        group_member 
    where
        id=?
Hibernate: 
    delete 
    from
        group_member 
    where
        id=?
Hibernate: 
    delete 
    from
        group_member 
    where
        id=?</code></pre>
<p><code>deleteBy-</code> 로 시작하는 메소드를 사용해 데이터를 삭제하면 건수에 상관 없이 먼저 select로 조회한 후에 이에 대한 결과를 한 건씩 삭제한다.</p>
<p>N개의 레코드를 지운다면 N번의 delete 쿼리가 발생한다는 것이다. 지금은 엔티티 수가 많이 없으니 문제가 안되겠지만, 쿼리 수가 많은 건 디스크 I/O, 네트워크 오버헤드 등 성능 저하의 원인이 될 수 있다. 그래서 이를 해결할 수 있는 대안들을 실험해보았다.</p>
<hr>
<h2 id="1-deleteall">1. deleteAll()</h2>
<pre><code class="language-java">groupMemberRepository.deleteAll(groupMemberRepository.findAllByStudyGroup(studyGroup));</code></pre>
<p><code>GroupMemberRepository</code>에 해당 유저들이 존재하는지 먼저 select로 확인한 후, 이를 List&lt;&gt;로 만들어 <code>deleteAll</code> 에 넣어보았다. </p>
<pre><code class="language-java">Hibernate: 
    select
        gm1_0.id,
        gm1_0.is_visible,
        gm1_0.join_date,
        gm1_0.role,
        gm1_0.study_group_id,
        gm1_0.user_id 
    from
        group_member gm1_0 
    where
        gm1_0.study_group_id=?
Hibernate: 
    delete 
    from
        group_member 
    where
        id=?
Hibernate: 
    delete 
    from
        group_member 
    where
        id=?
Hibernate: 
    delete 
    from
        group_member 
    where
        id=?
Hibernate: 
    delete 
    from
        group_member 
    where
        id=?</code></pre>
<p>하지만 여전히 delete query는 4번 나갔다. 게다가 파라미터로 입력 받은 GroupMember 리스트를 조회하는 메소드 때문에 select 쿼리도 한 번 수행하게 된다. <code>deleteBy-</code> 보다 더 비효율 적인 것이다.</p>
<p><code>deleteAll()</code> 메소드 또한 리스트 하나하나에 대해 해당 id가 존재하는지 검사하며 delete를 진행한다. id 개수만큼 for문을 돌리게 되는 것이다. 이는 <code>deleteBy-</code> 만큼이나 성능 저하의 원인이 되므로 사용하지 않는다.</p>
<hr>
<h2 id="2-deleteallinbatch">2. deleteAllInBatch()</h2>
<p>이번에는 Spring Data JPA에 있는 <code>deleteAllInBatch()</code> 메소드를 사용해봤다. 한 번의 쿼리로 데이터들을 삭제할 수 있다. </p>
<pre><code class="language-java">groupMemberRepository.deleteAllInBatch(groupMemberRepository.findAllByStudyGroup(studyGroup));</code></pre>
<pre><code class="language-java">Hibernate: 
    select
        gm1_0.id,
        gm1_0.is_visible,
        gm1_0.join_date,
        gm1_0.role,
        gm1_0.study_group_id,
        gm1_0.user_id 
    from
        group_member gm1_0 
    where
        gm1_0.study_group_id=?
Hibernate: 
    delete gm1_0 
    from
        group_member gm1_0 
    where
        gm1_0.id=? 
        or gm1_0.id=? 
        or gm1_0.id=? 
        or gm1_0.id=?</code></pre>
<p>batch query라 확실하게 많이 줄었다. 처음 select문으로 delete 할 GroupMember 레코드들을 조회하고, 해당 쿼리로 받아온 엔티티들을 batch delete로 제거한다. <code>deleteAll</code> 보단 <code>deleteAllInBatch</code> 를 사용하는 게 확실히 나은 것 같다.</p>
<hr>
<h2 id="3-delete-쿼리-직접-작성">3. delete 쿼리 직접 작성</h2>
<p>이번엔 where 절을 직접 작성해보기로 한다.</p>
<pre><code class="language-java">@Modifying
@Query(&quot;delete from GroupMember gm where gm.studyGroup = :studyGroup&quot;)
void deleteAllByStudyGroup(StudyGroup studyGroup);</code></pre>
<pre><code class="language-java">Hibernate: 
    delete gm1_0 
    from
        group_member gm1_0 
    where
        gm1_0.study_group_id=?</code></pre>
<p><code>@Query</code> 작성한 그대로 수행한다. 사실 가장 좋은 방법이다. 웬만하면 조건부로 데이터를 삭제해야할 땐 직접 쿼리를 작성하는 방법으로 진행하자.</p>
<hr>
<blockquote>
<p>참고
<a href="https://monsters-dev.tistory.com/88">https://monsters-dev.tistory.com/88</a>
<a href="https://jojoldu.tistory.com/235">https://jojoldu.tistory.com/235</a>
<a href="https://frogand.tistory.com/172">https://frogand.tistory.com/172</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[AOP와 내부 메소드 이슈 | Self-Invocation]]></title>
            <link>https://velog.io/@o_z/AOP%EC%99%80-%EB%82%B4%EB%B6%80-%EB%A9%94%EC%86%8C%EB%93%9C-%EC%9D%B4%EC%8A%88</link>
            <guid>https://velog.io/@o_z/AOP%EC%99%80-%EB%82%B4%EB%B6%80-%EB%A9%94%EC%86%8C%EB%93%9C-%EC%9D%B4%EC%8A%88</guid>
            <pubDate>Sat, 23 Nov 2024 14:06:58 GMT</pubDate>
            <description><![CDATA[<p>그룹에서 멤버가 탈퇴하면 랭킹 변동 사항을 반영하기 위해 AOP로 이를 처리하고자 했다. 
내가 의도한 메소드 순서는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/o_z/post/d96a4bf7-3316-4bde-a326-f99b6fef6328/image.png" alt="이제 보니 이름 참 구리게 지은 것 같다"></p>
<p><code>deleteGroup()</code>과 <code>deleteMember()</code> 메소드가 공통적으로 <code>deleteMemberFromStudyGroup()</code> 를 사용하고, 이 두 메소드가 종료되면 랭킹을 업데이트 해야한다. 그래서 <code>deleteMemberFromStudyGroup()</code> 이 완전히 종료 됐을 때 AOP로 <code>updateRanking()</code> 을 수행하고자 했다. </p>
<p>하지만, 생각처럼 AOP가 수행되지 않았다. 로그를 찍어보니 <code>deleteMemberFromStudyGroup()</code> 메소드가 완료 되고도 AOP 수행 자체가 되지 않았다. 원인이 도저히 뭔지 모르겠어서.. 찾아보니 내부 메소드 호출이 문제였다.</p>
<hr>
<h3 id="문제-원인">문제 원인</h3>
<p>프록시 객체는 원래 객체를 감싸고 있는 객체이다. 보통 접근을 제어하고 싶거나, 부가 기능을 추가하고 싶을 때 사용한다.</p>
<p>이번 이슈의 발생 원인은 프록시 객체의 내부 메서드를 호출하는 것이다.</p>
<p>AOP를 작동하려면 프록시를 무조건 거쳐야 한다. </p>
<p>AOP를 적용하게 되면 <code>클라이언트</code> → <code>프록시</code> → <code>메서드</code> 순서로 접근하는데, 이 때 메서드에서 다른 내부 메서드를 호출하게 되면 <code>클라이언트</code> → <code>프록시</code> → <code>외부 메서드</code> → <code>내부 메서드</code> 순서가 된다. 그래서 AOP의 pointcut을 내부 메서드로 지정하면 프록시가 이를  감지할 수 없게 되는 것이다. 외부 메서드에서 내부 메서드 호출은 <code>this.inner()</code> 로 호출되기 때문이다. <code>this</code> 를 사용한 호출이 발생하는 경우, 프록시가 적용되지 않은 내부 객체를 호출하게 되는 것이다. </p>
<hr>
<h2 id="해결책">해결책</h2>
<h3 id="1-자기자신-주입">1. 자기자신 주입</h3>
<pre><code class="language-java">@Service
public class Service{
    private final Service service;

    public void setService(Service service1){
        this.service = service;
    }

    public void deleteMember(){ // 외부 메서드
        // 내부 메서드 호출
        service.deleteMemberFromStudyGroup();
    }

    public void deleteMemberFromStudyGroup(){ // 내부 메서드
        ...
    }
}</code></pre>
<p>자기 자신을 주입해서 내부 메서드를 호출하는 방식이다.</p>
<p>하지만 현재 사용중인 스프링 3.X 버전에서는 자기 자신을 생성함과 동시에 주입하면 순환 참조 에러가 발생한다. <code>spring.main.allow-circular-references=true</code>로 설정하면 이를 허용할 수 있다는데, 이 기능 하나 때문에 순환 참조에 대한 위험성을 얹어가기엔 과도 하다고 생각했다. 그래서 사용하지 않았다.</p>
<h3 id="2-지연-조회">2. 지연 조회</h3>
<p><code>ObjectProvider</code> 또는 <code>ApplicationContext</code> 를 사용해 조회하는 방법이다. 둘 다 Bean을 조회 해 가져오는 원리는 동일하다.</p>
<p><code>ObjectProvider</code> 는 스프링 컨테이너에 등록된 Bean을 직접 조회하고 조회된 해당 Bean을 통해 메서드를 호출하는 원리이다.</p>
<pre><code class="language-java">@Service
public class Service{
    private final ObjectProvider&lt;Service&gt; serviceProvider;

    public void setService(ObjectProvider&lt;Service&gt; serviceProvider){
        this.serviceProvider = serviceProvider;
    }

    public void deleteMember(){ // 외부 메서드
            // ObjectProvider 사용한 내부 메서드 호출
            serviceProvider.getObject().deleteMemberFromStudyGroup();
    }

    public void deleteMemberFromStudyGroup(){ // 내부 메서드
            ...
    }
}</code></pre>
<p><code>deleteMember()</code> 이 호출되는 시점에 Bean을 조회하고, Bean에서 찾은 <code>Service</code> 의 메서드를 호출하는 것이다. 근본적인 해결 원리는 1번과 비슷하다.</p>
<p><code>ApplicationContext</code>를 사용한 예시는 아래와 같다.</p>
<pre><code class="language-java">@Service
public class Service{
    private final ApplicationContext applicationContext;

    public void deleteMember(){ // 외부 메서드
        // ApplicationContext를 사용한 Bean 조회 후 호출
        Service service = applicationContext.getBean(Service.class);
        service.deleteMemberFromStudyGroup();
    }

    public void deleteMemberFromStudyGroup(){ // 내부 메서드
        ...
    }
}</code></pre>
<hr>
<p>사실상 가장 좋은 방법은 AOP를 정상적으로 적용할 수 있도록 <code>deleteMemberFromStudyGroup()</code> 메소드를 다른 클래스로 옮기는 것이다. 프록시 객체를 사용할 수 있도록 구조를 변경하는 것이 가장 좋은 방법인데, 현재 나의 케이스에서는.. 저 메소드 하나를 위해 클래스를 분리하기엔 오버 엔지니어링이라고 생각이 들어 2번 지연 조회 방법을 채택해서 사용했다. </p>
<hr>
<p>원인을 찾는게 꽤 걸렸는데 AOP 원리를 제대로 알지 못했던 것 같다. AOP를 완벽히 이해하지 못하면 한 번쯤 겪어볼만 한 이슈일 것 같다..</p>
<hr>
<blockquote>
<p>참고
<a href="https://harrislee.tistory.com/100">https://harrislee.tistory.com/100</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[@SuperBuilder]]></title>
            <link>https://velog.io/@o_z/SuperBuilder</link>
            <guid>https://velog.io/@o_z/SuperBuilder</guid>
            <pubDate>Sat, 23 Nov 2024 11:45:10 GMT</pubDate>
            <description><![CDATA[<p>프로젝트 요구사항 중, DTO 하나를 상속해서 필드를 추가할 필요가 있었다. 기존 <code>GetSolutionResponse</code> DTO에서 그룹 아이디를 추가해 넘겨줘야 했는데, 그룹 Id가 없는 기존의 <code>GetSolutionResponse</code> 도 다른 API에서 필요했다. 그래서 <code>GetSolutionResponse</code> 와 동일한 DTO를 하나 더 파서 거기에 groupId를 추가해야 하는데, 그러기엔 기존 DTO가 필드도 많고 헤비해 이런 클래스를 또 만들어야 하는 건 너무 재사용성이 떨어지는 설계라고 생각했다.</p>
<p>그래서 생각한 방법이 <code>GetSolutionResponse</code> 를 상속하는 것이었는데, 이 때 <code>@Builder</code> 를 사용하니 문제가 발생했다.</p>
<p><code>@Builder</code> 는 상속받은 필드를 builder에 사용할 수 없다. 즉, 자식 클래스에서는 새로 생성한 필드만 builder에 추가할 수 있다는 것이다. 나는 상속받은 모든 필드도 builder 패턴에 추가돼야 했기에, 다른 방법을 모색했다.</p>
<hr>
<h2 id="superbuilder">@SuperBuilder</h2>
<p><code>@SuperBuilder</code> 는 생성자에서 상속 받은 필드도 builder에 사용 가능하다. </p>
<p>이 어노테이션은 부모, 자식 클래스 모두 어노테이션을 추가해야 한다. </p>
<h3 id="부모-dto">부모 DTO</h3>
<p>기존의 부모 DTO는 아래와 같다. </p>
<pre><code class="language-java">@Builder
@Getter
public class GetSolutionResponse {
    private Long solutionId;
    ...
    private Long commentCount;

    public static GetSolutionResponse toDTO(Solution solution, Long commentCount) {
        return GetSolutionResponse.builder()
            .solutionId(solution.getId())
            ...
            .commentCount(commentCount)
            .build();
    }
}</code></pre>
<p>여기서 <code>@Builder</code> 를 제거하고 <code>@SuperBuilder</code> 로 바꿔줘야 한다.</p>
<pre><code class="language-java">@SuperBuilder
@Getter
public class GetSolutionResponse {
    private Long solutionId;
    ...
    private Long commentCount;

    public static GetSolutionResponse toDTO(Solution solution, Long commentCount) {
        return GetSolutionResponse.builder()
            .solutionId(solution.getId())
            ...
            .commentCount(commentCount)
            .build();
    }
}</code></pre>
<h3 id="자식-dto">자식 DTO</h3>
<p><code>@SuperBuilder</code> 는 자식 클래스에도 달아주어야 한다.</p>
<pre><code class="language-java">@SuperBuilder
@Getter
public class GetSolutionWithGroupIdResponse extends GetSolutionResponse {
    private final Long groupId;

    public static GetSolutionWithGroupIdResponse toDTO(Solution solution, Long commentCount) {
        return GetSolutionWithGroupIdResponse.builder()
            .solutionId(solution.getId())
            ...
            .commentCount(commentCount)
            .groupId(solution.getProblem().getStudyGroup().getId())
            .build();
    }
}</code></pre>
<p>이제 부모 필드를 자식 DTO builder에서도 사용할 수 있게 되었다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[REVERSE() functional index가 사용되지 않던 이슈 ( + LIKE query)]]></title>
            <link>https://velog.io/@o_z/REVERSE-functional-index%EA%B0%80-%EC%82%AC%EC%9A%A9%EB%90%98%EC%A7%80-%EC%95%8A%EB%8D%98-%EC%9D%B4%EC%8A%88-LIKE-query</link>
            <guid>https://velog.io/@o_z/REVERSE-functional-index%EA%B0%80-%EC%82%AC%EC%9A%A9%EB%90%98%EC%A7%80-%EC%95%8A%EB%8D%98-%EC%9D%B4%EC%8A%88-LIKE-query</guid>
            <pubDate>Mon, 14 Oct 2024 10:57:21 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/o_z/post/b6a033d9-4ac9-4187-8a18-5486c51806e2/image.png" alt="">
solution 테이블에 대해 result를 가지고 아래와 같이 조회할 일이 있었다.</p>
<pre><code class="language-java">SELECT * FROM solution WHERE result LIKE &#39;%점&#39;;</code></pre>
<p>solution은 테스트 데이터로 FAKER를 사용해 1000만개 데이터를 삽입했다. 그 중 저 쿼리의 결과는 1,001,664개다. 전체 데이터의 약 10%정도를 차지한다.</p>
<p>보통 wildcard(%)를 맨 앞에 위치시키면 인덱스를 사용하지 못한다는 점에서 성능 저하가 발생한다고 알려져 있다.</p>
<p>하지만 생각보다 성능이 좋았다.
<img src="https://velog.velcdn.com/images/o_z/post/239c4a1f-e061-41fb-bfc4-f33d94c36dd1/image.png" alt=""></p>
<p>EXPLAIN으로 실행 설계를 찾아보았다. </p>
<table>
<thead>
<tr>
<th><strong>id</strong></th>
<th><strong>select_type</strong></th>
<th><strong>table</strong></th>
<th><strong>partitions</strong></th>
<th><strong>type</strong></th>
<th><strong>possible_keys</strong></th>
<th><strong>key</strong></th>
<th><strong>key_len</strong></th>
<th><strong>ref</strong></th>
<th><strong>rows</strong></th>
<th><strong>filtered</strong></th>
<th><strong>Extra</strong></th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>SIMPLE</td>
<td>solution</td>
<td>NULL</td>
<td>ALL</td>
<td>NULL</td>
<td>NULL</td>
<td>NULL</td>
<td>NULL</td>
<td>9622368</td>
<td>11.11</td>
<td>Using where</td>
</tr>
</tbody></table>
<p>key가 NULL인걸 보아, full scan을 하고있단 것을 알 수 있다. filtered도 11.11%로 꽤나 정확한 수치를 예측하고 있다.</p>
<hr>
<h2 id="1-를-맨-앞에-뒀을-때-성능-저하가-되는-수준은-어느-정도일까">1. %를 맨 앞에 뒀을 때 성능 저하가 되는 수준은 어느 정도일까?</h2>
<p>이걸 처음에 알아내는 게 정말 힘들었다. 일일이 테스트 데이터 수를 조정해가며 언제부터 부하가 일어나는지를 찾고 있었기에..</p>
<p>stackoverflow에서 조회하고자 하는 데이터가 전체의 20%정도가 되면 옵티마이저가 index scan보다 full scan이 더 효율적이라고 판단한다는 이야기를 보았다.</p>
<p>현재 케이스의 경우, 전체 100만건 데이터 중 10%도 안된다. 그럼에도 성능이 저정도였다.</p>
<p>1000만건 중 약 1%로 약 10만개의 데이터가 저 쿼리에 해당하도록 데이터를 만들었다.
<img src="https://velog.velcdn.com/images/o_z/post/5adfeda2-2898-48d4-904b-7801612abc06/image.png" alt="">여전히 빠르다.. </p>
<p>이번엔 그냥 1000만건 중 100건만 해당하도록 데이터를 세팅했다.<img src="https://velog.velcdn.com/images/o_z/post/c40d5f0d-675d-4122-9d16-b7f899a26754/image.png" alt="">이제야 부하가 발생한다. </p>
<p>1000건이 해당하도록 데이터를 수정했다.<img src="https://velog.velcdn.com/images/o_z/post/2da54c56-0385-4baa-9fe7-b0963bcfad11/image.png" alt="">급격하게 조회 속도가 빨라진다. </p>
<p>아마 성능 부하가 있는 마지노선의 데이터 수는 몇백건대가 되는 것 같다. 1000만 중에 수백이라니...</p>
<hr>
<h2 id="2-왜-index-scan-없이도-full-table-scan으로-성능이-좋을-수-있는걸까">2. 왜 index scan 없이도 full table scan으로 성능이 좋을 수 있는걸까?</h2>
<p>성능 부하가 발생하는 데이터 수는 대강 추려졌고, 그렇다면 왜 <code>full scan</code>으로도 성능이 좋을수가 있을까?
<img src="https://velog.velcdn.com/images/o_z/post/e1b82e95-c254-43cc-a86f-fb44f60bb690/image.png" alt="">
인덱스는 손익분기점이 있다. DB에서 데이터를 조회할 때 일정 건수 이상을 추출하게 되면 <code>index scan</code>보다 <code>full scan</code>이 덜 걸릴 수 있다. 그 지점이 <strong>손익분기점</strong>이다.</p>
<p>그렇다면 이러한 손익분기점은 왜 나타날까?</p>
<p>인덱스를 스캔하는 이유는 WHERE을 만족하는 적은 수의 데이터에 대해 인덱스를 빠르게 찾기 위해서이다. 인덱스 스캔 후 테이블 레코드를 랜덤 액세스 하는데 이 과정에서 부하가 발생한다.</p>
<p>데이터가 적을 경우에는 이 방식이 속도 향상에 이점을 줄 수 있다. 하지만, 이 구역을 모두 찾아봐야 한다면 인덱스 → I/O로 반복적인 접근이 필요하다.</p>
<p><code>full scan</code>의 경우 <strong>순차 접근</strong>이다. 메모리에 적재해야 하는 건 많아져도 블록 별 순차 접근으로 접근 비용이 감소한다. 더구나 Multi block I/O 방식을 사용해 I/O call이 필요한 시점에 인접한 블록들을 같이 읽어 메모리에 적재한다. 이로 인해 동시 처리량이 늘어나는 것이다. 
<code>Index scan</code>은 Single block I/O 방식으로 블록을 읽는다. 한 번의 I/O call에 하나의 데이터 블록만 읽어 메모리에 적재하는 것이다. </p>
<p>그럼 손익분기점의 기준이 어떻게 될까?</p>
<p>보통 손익분기점은 5%~20%의 낮은 수준으로 결정된다고 한다. <code>Clustering factor</code>에 의해 많이 달라지며, CF가 나쁘면 손익분기점이 5% 미만에서 결정되고, 심하면 1% 미만으로 낮아지기도 한다. 반대로 CF가 좋으면 90%까지 향상한다. 나의 케이스에서는 데이터가 완전한 랜덤 저장이기에 CF가 매우 좋지 않다. 그래서 1% 미만이 된 것인가 싶기도 하다.</p>
<hr>
<h2 id="3-성능-저하-상태에서-revese-function을-사용한-functional-index를-활용해보자">3. 성능 저하 상태에서 REVESE() function을 사용한 functional index를 활용해보자</h2>
<p>아까 위에서 작성한 쿼리의 성능이 저하된 상태의 데이터 셋을 만들었다. 아까 쿼리의 치명적인 단점은 와일드카드(%)를 맨 앞에 쓴다는 것이다. 인덱스를 사용하지 못하게 되는데, 그렇다면 와일드카드가 맨 뒤로 오도록 만들어서 성능을 향상시킬 수 있지 않을까? 라는 생각을 하게 되었다. <strong>function-based index</strong>를 사용하는 것이다.</p>
<p>MySQL은 8 버전부터 functional index를 지원한다. 기존엔 컬럼의 형태를 그대로 유지한 채 인덱스를 생성했다. 그래서 조회할 때 인덱스를 사용하고자 하는 컬럼의 형태가 변형되면 인덱스를 사용하지 못한다는 단점이 있었다. functional index가 이러한 점을 보완한다. </p>
<p>functional index는 컬럼 형태를 변형해 인덱스를 만드는 것이다. 내가 위에서 제시한 쿼리를 아래 형태처럼 변형해보고자 한다.</p>
<pre><code class="language-sql">SELECT * FROM solution WHERE REVERSE(result) LIKE &quot;점%&quot;;</code></pre>
<p>result의 값을 거꾸로 뒤집으면 기존에 ‘100점’, ‘틀렸습니다’ 등이 ‘다니습렸틀’, ‘점001’ 이런 식으로 변형되는 원리를 사용하고자 한 것이다. 그렇다면 점수로 끝나는 result의 solution도 와일드카드를 뒤에 붙이면서 인덱스 사용도 가능해지게 되고 성능도 향상될 것이라고 생각했다.</p>
<p>위 쿼리를 수행하기 위해선 아래와 같은 functional index를 생성한다.</p>
<pre><code class="language-sql">CREATE INDEX idx_reversed_result ON solution((REVERSE(result)));</code></pre>
<p>functional index를 사용하기 위해선 변형한 컬럼 그대로의 형태로 사용해야 한다는 규칙이 있다. 즉, 위와 같은 쿼리를 사용하면 되는 것이다. </p>
<p>저렇게 생성하고 테스트를 해봤는데 여전히 성능이 향상되지 못했다. EXPLAIN으로 실행 계획을 살펴보니 idx_reversed_result를 사용하지 않았다. 여전히 full scan을 사용했다.</p>
<p>혹시 MySQL의 옵티마이저가 적절치 않은 인덱스라고 판단해 사용하지 않은 것일까 싶었다. 그래서 <code>FORCE INDEX (idx_reversed_result)</code> 로 인덱스도 지정해보았다. 하지만 이번에도 인덱스를 사용하지 않았다. </p>
<p><code>USE INDEX</code>였으면 해당 인덱스를 사용하라는 힌트만 줄 뿐 강요하지 않는다. 이도 옵티마이저가 판단하에 적절한 인덱스다 싶으면 사용하는 것이다. 하지만 <code>FORCE INDEX</code>는 다르다. <code>FORCE INDEX</code>는 직접 인덱스를 지정해주고 강제로 사용하게 한다. 그럼에도 사용하지 않는데.. 원인이 뭘까 싶어서 진짜 몇주간 찾아본 것 같다.</p>
<h2 id="4-결론은-mysql의-버그">4. 결론은.. MySql의 버그</h2>
<p><a href="https://bugs.mysql.com/bug.php?id=104713">https://bugs.mysql.com/bug.php?id=104713</a>
<a href="https://stackoverflow.com/questions/79031941/why-my-query-doesnt-use-a-functional-index-in-mysql8">https://stackoverflow.com/questions/79031941/why-my-query-doesnt-use-a-functional-index-in-mysql8</a></p>
<p>너무 허무하게도.. MySQL에서 functional index를 도입할 때 <code>LIKE</code>가 포함된 쿼리에 대해서는 버그가 있다고 한다. MySQL 버그 리포트를 찾다가 저 글을 보긴 했었다. <code>LOWER()</code> 함수 인덱스에 대해서 <code>LIKE</code> 쿼리가 작동하지 않는다는 내용이었는데, 그게 초점이 <code>LOWER()</code> 함수가 아닌 <strong>functional index</strong>와 <strong>LIKE 쿼리</strong>였던 것이다. <code>REVERSE()</code> 함수 뿐 만 아니라 모든 function에 대해서 index를 만들면 LIKE 쿼리에선 적용되지 않는 버그가 있었고, 이미 2021년에 보고된 버그라고 한다. 현재 기준 가장 최신 버전에서도 여전히 해결이 안된 모양이다.</p>
<p>마지막이 좀 허무한 버그로 끝나버렸지만.. 그래도 DB에 대해 좀 더 깊은 내용들을 공부해 볼 수 있었던 게 좋은 경험이었다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPARepository save() 할 때 발생하는 select]]></title>
            <link>https://velog.io/@o_z/JPARepository-save-%ED%95%A0-%EB%95%8C-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-select</link>
            <guid>https://velog.io/@o_z/JPARepository-save-%ED%95%A0-%EB%95%8C-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-select</guid>
            <pubDate>Tue, 10 Sep 2024 13:51:37 GMT</pubDate>
            <description><![CDATA[<p>현재 비밀번호 변경 API의 서비스 메서드는 아래와 같다.</p>
<pre><code class="language-java">@Transactional
    public void editPassword(User user, EditUserPasswordRequest request) {

        if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) {
            throw new UncorrectedPasswordException(&quot;비밀번호가 틀렸습니다.&quot;);
        }

        String encodedPassword = passwordEncoder.encode(request.newPassword());
        user.editPassword(encodedPassword);

        userRepository.save(user);
    }</code></pre>
<p>위 API를 실제로 실행하면 아래처럼 쿼리가 발생한다.</p>
<pre><code class="language-sql">Hibernate: 
    select
        u1_0.id,
        u1_0.bj_nickname,
        u1_0.deleted_at,
        u1_0.email,
        u1_0.nickname,
        u1_0.password,
        u1_0.profile_image,
        u1_0.role 
    from
        user u1_0 
    where
        (
            u1_0.deleted_at IS NULL
        ) 
        and u1_0.email=?
Hibernate: 
    select
        u1_0.id,
        u1_0.bj_nickname,
        u1_0.deleted_at,
        u1_0.email,
        u1_0.nickname,
        u1_0.password,
        u1_0.profile_image,
        u1_0.role 
    from
        user u1_0 
    where
        u1_0.id=? 
        and (
            u1_0.deleted_at IS NULL
        )
Hibernate: 
    update
        user 
    set
        bj_nickname=?,
        deleted_at=?,
        email=?,
        nickname=?,
        password=?,
        profile_image=?,
        role=? 
    where
        id=?
</code></pre>
<p>총 2번의 select와 1번의 update가 발생한다. </p>
<p>첫 번째 select의 경우, 컨트롤러에서 JWT로 User 객체를 가져오는 <code>@AuthedUser</code> 어노테이션 때문에 발생하는 쿼리이다.</p>
<p>어디서 select가 한 번 더 발생하는 건지 모르겠어서 <code>save()</code>를 제거하고 실행해보았다. 그 결과 select, update 쿼리 모두 발생하지 않았다. 그럼 <code>save()</code> 할 때 select, update가 한 번씩 발생한단 뜻인데 select는 왜 발생할까?</p>
<hr>
<h2 id="save-메서드는-캐시-데이터와-비교하기-위해-select를-수행한다">save() 메서드는 캐시 데이터와 비교하기 위해 select를 수행한다</h2>
<p>컨트롤러단에서 서비스 메서드로 가져온 User 객체는 <strong>준영속 상태</strong>이다. 즉, DB에서 한 번 가져왔지만 영속성 컨텍스트가 관리하지 않는 엔티티라는 뜻이다. 이러한 객체는 JPA가 관리하지 않으므로 객체를 수정해도 Dirty checking이 안되기에 DB에 update가 일어나지 않는다. </p>
<p>이 때 <code>save()</code> 메서드를 수행하면 새로운 데이터를 로드하는 것이 아닌, <strong>준영속 상태의 엔티티를 영속성 컨텍스트에 다시 병합</strong>하는 역할을 한다. 이 과정에서 데이터베이스에 해당 User 객체가 실제로 존재하는지를 확인하기 위해 select 쿼리가 발생하는 것이다. DB에 있을 경우 엔티티에 입력된 정보로 merge가 발생한다.</p>
<p>이렇게 준영속 상태의 엔티티 User가 merge 되면 update 쿼리가 발생하여 변경된 내용을 반영하게 된다. </p>
<p>JPARepository의 <code>save()</code> 내용을 보면 아래와 같다.</p>
<pre><code class="language-java">@Transactional
@Override
public &lt;S extends T&gt; S save(S entity) {

    if (entityInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    } else {
        return em.merge(entity);
    }
}</code></pre>
<p>DB를 조회 했을 때 파라미터의 엔티티가 새로운 객체이면 persist로 해당 엔티티를 DB에 저장하고, 이미 있던 데이터라면 merge를 수행한다. </p>
<p>merge는 detach 된 엔티티를 다시 영속 상태로 만들기 위해 사용한다. 파라미터로 넘어온 entity가 영속성 컨텍스트 1차 캐시에 있는지 확인 후 없으면 select 쿼리를 날려 조회한다. DB에서 조회 되면 트랜잭션 커밋 시점에 파라미터 엔티티 값과 1차 캐시의 엔티티 값 간 차이가 있으면 update 쿼리로 수정한다. DB에 해당 값이 없으면 insert 쿼리가 발생한다.</p>
<p>결론적으로는 JPARepository의 <code>save()</code> 메서드에서 merge 과정을 거치지 위해 발생한 select 쿼리가 한 번 있었으며, 이 차이를 반영하기 위한 update 쿼리가 발생하게 된 것이다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[RequestBody에서 Enum 값 안전하게 받기: String + 애노테이션을 통한 검증]]></title>
            <link>https://velog.io/@o_z/RequestBody%EC%97%90%EC%84%9C-Enum-%EA%B0%92-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EB%B0%9B%EA%B8%B0-String-%EC%95%A0%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98%EC%9D%84-%ED%86%B5%ED%95%9C-%EA%B2%80%EC%A6%9D</link>
            <guid>https://velog.io/@o_z/RequestBody%EC%97%90%EC%84%9C-Enum-%EA%B0%92-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EB%B0%9B%EA%B8%B0-String-%EC%95%A0%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98%EC%9D%84-%ED%86%B5%ED%95%9C-%EA%B2%80%EC%A6%9D</guid>
            <pubDate>Mon, 02 Sep 2024 10:56:03 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/o_z/post/955b2b07-a775-4263-baa2-5e3d4b37d422/image.png" alt="">
API 요청에서 멤버의 역할을 수정하기 위해 request body에 <code>RoleOfGroupMember</code>라는 enum을 받아야했다.
DTO를 만들다보니 enum은 enum 그 자체로 받아야할지, String으로 받아와서 파싱해야할지 고민이 됐다.</p>
<h2 id="request-body에서-enum은-enum-vs-string">Request Body에서 Enum은 Enum vs String</h2>
<p>request의 body로 요청을 보낼 때, 서버단에서 enum 타입으로 DTO 필드를 지정해줘도 결국 클라이언트쪽에서 요청하는 건 string으로 입력된다. json도 결국 본질적으로는 string의 일종인 데이터 형태이기 때문이다. 
그렇다면 request body로 enum 값을 받을 때 DTO에 enum으로 받아야할까, String으로 받아야할까?</p>
<p>간단하게 각 방법에 대해 장단점을 비교하자면 아래와 같다.</p>
<h3 id="1-enum">1. Enum</h3>
<ul>
<li>장점 : 값만 제대로 들어온다면 알아서 Json parsing을 통해 서버의 enum 값과 매칭해줘서 편하다.</li>
<li>단점 : 값이 잘못 들어왔을 때 <code>HttpMessageNotReadableException</code>이 발생하는데, 이 때 발생하는 에러가 핸들링 하기 까다롭다. 
해당 에러는 enum 값이 잘못된 경우 외에도 <code>HttpMessageConverter</code> 메서드가 실패할 때 발생하기에 핸들링 하기엔 케이스가 너무 다양하다.</li>
</ul>
<h3 id="2-string">2. String</h3>
<ul>
<li>장점 : 에러 처리가 쉽다. 커스텀으로 에러 핸들링이 가능하다.</li>
<li>단점 : 서버에서 Enum으로 사용하기 위해 따로 매핑해주는 메서드나 로직이 구현되어야 한다.</li>
</ul>
<p>나는 값의 안전성을 중요하게 생각하는데, enum에서 에러 처리가 어렵다는 단점을 크게 여겼기에 String으로 받기로 결정했다.</p>
<hr>
<h2 id="validenum-커스텀-애노테이션-만들기">@ValidEnum 커스텀 애노테이션 만들기</h2>
<p><code>@RequestBody</code>로 받을 DTO의 형태는 아래와 같다.</p>
<pre><code class="language-java">public record UpdateGroupMemberRoleRequest(@NotNull(message = &quot;스터디 그룹 고유 아이디는 필수 입력입니다.&quot;) Long studyGroupId,
                                           @NotNull(message = &quot;스터디 그룹 멤버의 고유 아이디는 필수 입력입니다.&quot;) Long memberId,
                                           @NotBlank(message = &quot;변경할 역할은 필수 입력입니다.&quot;) RoleOfGroupMember role) {
}</code></pre>
<p>이제 저 <code>RoleOfGroupMember</code> enum을 String 타입으로 바꾸고자 한다. </p>
<pre><code class="language-java">public record UpdateGroupMemberRoleRequest(@NotNull(message = &quot;스터디 그룹 고유 아이디는 필수 입력 입니다.&quot;) Long studyGroupId,
                                           @NotNull(message = &quot;스터디 그룹 멤버의 고유 아이디는 필수 입력 입니다.&quot;) Long memberId,
                                           @NotBlank(message = &quot;변경할 역할은 필수 입력 입니다.&quot;) String role) {
}</code></pre>
<p>이렇게 바꾸었을 때 해당 role 값이 <code>RoleOfGroupMember</code>에 유효한 값인지를 확인하는 로직이 필요하다. </p>
<h3 id="validenum">ValidEnum</h3>
<p>먼저 <code>@ValidEnum</code>이라는 어노테이션으로 만들기 위해 @interface를 만들어준다.</p>
<pre><code class="language-java">@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface ValidEnum {
    String message() default &quot;잘못된 enum 값입니다.&quot;;

    Class&lt;?&gt;[] groups() default {};

    Class&lt;? extends Payload&gt;[] payload() default {};

    Class&lt;? extends java.lang.Enum&lt;?&gt;&gt; enumClass();
}
</code></pre>
<p>Target을 정할 때 지금 상황에서는 <code>ElementType.FIELD</code>만 필요하지만, 추후 다양한 곳에서 사용할 수 있도록 확장하고자 <code>ElementType.METHOD</code>와 <code>ElementType.PARAMETER</code>,<code>ElementType.FIELD</code>을 사용했다. </p>
<h4 id="message">message</h4>
<p><code>message</code>는 검증에 실패했을 때 default로 들어갈 메세지 값이다. 해당 어노테이션을 사용할 때 message를 아래처럼 지정할 수도 있다.</p>
<pre><code class="language-java">@ValidEnum(message = &quot;올바르지 않은 enum 값입니다. : DONE, INPROGRESS, QUEUED&quot;)</code></pre>
<h4 id="groups">groups</h4>
<p><code>groups</code>는 특정 상황에서만 검증을 하기 위해 검증을 그룹화할 때 사용한다고 한다. 나도 이해가 잘 안돼서 예시를 찾아봤었는데..
먼저 특정 상황을 구별할 빈 인터페이스로 CreateCheck, EditCheck 두 개를 생성했다고 가정한다. 그리고 아래와 같은 클래스가 있다고 가정하자.</p>
<pre><code class="language-java">public class CustomStatus{
    @NotBlank(groups = {CreateCheck.class, EditCheck.class})
    String name;
    @NotNull(groups = {EditCheck.class})
    Long hit;
}</code></pre>
<p>이 후, 아래와 같은 컨트롤러에서 위 validation을 진행한다고 가정해보자. 
<del>(여기서는 내가 만든 @ValidEnum과 @CustomValid를 별개라고 봐주어야 한다. @CustomValid는 예시의 일부이다.</del>)</p>
<pre><code class="language-java">@RestController
@RequiredArgsConstructor
public class CustomController{
    @PostMapping
    public ResponseEntity&lt;Object&gt; createTest(@CustomValid(groups = CreateCheck.class) @ModelAttribute CustomStatus status, Errors errors){
        if(errors.hasError())
            throw new RequestException(&quot;잘못된 요청입니다.&quot;,errors);
        return ResponseEntity.ok();
    }
}</code></pre>
<p>이렇게 되면 위의 <strong>CustomStatus 클래스에서 CreateCheck 인터페이스가 등록된 validation만 진행</strong>한다는 의미이다. 고로 위의 경우라면 name에 대해 <code>@NotBlank</code>만 검증을 진행하고 hit는 <code>@NotNull</code> 검증이 진행되지 않을 것이다.
사실 <code>groups</code>는 내가 만들고자 하는 enum 검증 어노테이션에서는 잘 사용되지 않을 것 같긴 하다. 실제로도 사용되는 경우가 많이 없다고 한다.</p>
<h4 id="payload">payload</h4>
<p><code>payload</code>는 검증하면서 메타데이터를 전달할 때 사용한다. 보통 검증을 실패했을 경우에 대한 심각성 수준을 커스텀 에러 클래스로 등록하고 이를 사용해 따로 처리한다. 아래와 같은 커스텀 state 클래스가 있다고 가정하자.</p>
<pre><code class="language-java">public class CustomLevel{
    public static class Info implement Payload{}
    public static class Warning implement Payload{}
    public static class Error implement Payload{}
}</code></pre>
<p>이를 아래와 같이 해당 검증에 실패했을 때 특정 수준의 검증 실패임을 나타내도록 등록할 수 있다.</p>
<pre><code class="language-java">@ValidEnum(payload = CustomLevel.Warning.class)</code></pre>
<h4 id="enumclass">enumClass</h4>
<p><code>enumClass</code>는 검증하고자 하는 Enum 클래스를 지정한다. 필수 입력이다.</p>
<pre><code class="language-java">@ValidEnum(enumClass = StatusEnum.class)</code></pre>
<p>위의 4개 중 집중적으로 사용될 것은 <code>message</code>와 <code>enumClass</code>라고 생각하면 된다.</p>
<h3 id="enumvalidator">EnumValidator</h3>
<p>이제 enum validation 로직을 위해 <code>ConstraintValidator&lt;ValidEnum, String&gt;</code>을 구현한다.
앞의 ValidEnum은 아까 만들었던 @interface로 검증할 어노테이션이고, 뒤의 String은 검증을 진행할 값 타입을 의미한다.</p>
<pre><code class="language-java">public class EnumValidator implements ConstraintValidator&lt;ValidEnum, String&gt; {
    private Class&lt;? extends Enum&lt;?&gt;&gt; enumClass;

    @Override
    public void initialize(ValidEnum constraintAnnotation) {
        this.enumClass = constraintAnnotation.enumClass();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
        Object[] enumValues = this.enumClass.getEnumConstants();
        if (enumValues != null) {
            for (Object enumValue : enumValues) {
                if (value.equals(enumValue.toString())) {
                    return true;
                }
            }
        }
        return false;
    }
}</code></pre>
<p><code>initialize()</code>에서 <code>@ValidEnum</code>으로부터 입력받은 enum 클래스를 여기에 등록해준다.
<code>isValid()</code>에서 enum 값에 대한 검증을 시작하는데, enumValue 값이 입력받은 String value와 같으면 검증된 값으로 true를 리턴한다. 끝까지 같은 값이 없으면 false를 리턴해 검증이 실패된다. </p>
<hr>
<p>이제 <code>@ValidEnum</code>을 아까 DTO에 적용하면 끝이다.</p>
<pre><code class="language-java">public record UpdateGroupMemberRoleRequest(@NotNull(message = &quot;스터디 그룹 고유 아이디는 필수 입력 입니다.&quot;) Long studyGroupId,
                                           @NotNull(message = &quot;스터디 그룹 멤버의 고유 아이디는 필수 입력 입니다.&quot;) Long memberId,
                                           @NotBlank(message = &quot;변경할 역할은 필수 입력 입니다.&quot;)
                                           @ValidEnum(enumClass = RoleOfGroupMember.class, message = &quot;올바른 enum 값을 입력해주세요. (ADMIN, PARTICIPANT)&quot;)
                                           String role) {
}</code></pre>
<p>나는 <code>enumClass</code>와 <code>message</code>만 사용해도 충분해서 두 개만 설정하고 검증을 진행했다. 검증코드가 엄청 깔끔해졌다!<img src="https://velog.velcdn.com/images/o_z/post/199f317f-8b3d-45ae-9b4c-1fc6105656e7/image.png" alt="">role에 ADMIN,PARTICIPANT가 아닌 잘못된 값을 넣으니 아까 등록한 message로 error response가 발생한다.</p>
<hr>
<blockquote>
<p>참고
<a href="https://m.blog.naver.com/aservmz/222823126774">https://m.blog.naver.com/aservmz/222823126774</a>
<a href="https://jsy1110.github.io/2022/enum-class-validation/">https://jsy1110.github.io/2022/enum-class-validation/</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Service 단위 테스트와 Mocking 이슈들 (feat. RestTemplate) ]]></title>
            <link>https://velog.io/@o_z/Service-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%99%80-Mocking-%EC%9D%B4%EC%8A%88%EB%93%A4-feat.-RestTemplate</link>
            <guid>https://velog.io/@o_z/Service-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%99%80-Mocking-%EC%9D%B4%EC%8A%88%EB%93%A4-feat.-RestTemplate</guid>
            <pubDate>Sat, 24 Aug 2024 18:32:35 GMT</pubDate>
            <description><![CDATA[<p>이번엔 내가 API를 추가하면서 테스트 코드를 작성할 때 너무 헷갈렸던 부분들을 정리해보고자 한다. <code>RestTemplate</code>을 테스트 코드에서 Mocking 할 때의 이야기이다. </p>
<p>현재 진행하고 있는 프로젝트에서 백준 닉네임이 유효한지 검증하는 API를 추가하는데 <code>RestTemplate</code>을 사용했다. 이 API의 Service 단위 테스트 코드를 작성하면서 직면했던 두 개의 문제들이 있었다.</p>
<blockquote>
<ol>
<li><code>RestTemplate</code>을 Service 메서드 내부에서 생성했을 때 Mocking 동작이 안된다</li>
<li><code>RestTemplate</code>의 동작을 지정하지 않았는데 테스트가 성공한다 (<code>when()</code>을 쓰지 않은 경우)</li>
</ol>
</blockquote>
<hr>
<h2 id="1-resttemplate을-service-메서드-내부에서-생성했을-때-mocking-동작이-안된다">1. RestTemplate을 Service 메서드 내부에서 생성했을 때 Mocking 동작이 안된다</h2>
<p>사실 이건 지금 생각해보면 너무 당연하다. 그래도 다음 번엔 다신 바보같지 않기 위해..
내가 처음 설계했던 Service 메서드는 아래와 같았다.</p>
<pre><code class="language-java">@Transactional(readOnly = true)
public void checkBjNickname(String bjNickname) {
        String bjUserUrl = &quot;https://www.acmicpc.net/user/&quot; + bjNickname;
        ![](https://velog.velcdn.com/images/o_z/post/19ad1644-f01e-4925-a580-411aac665aa1/image.png)

        HttpHeaders headers = new HttpHeaders();
        headers.set(&quot;User-Agent&quot;,
            &quot;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36&quot;);
        HttpEntity&lt;String&gt; entity = new HttpEntity&lt;&gt;(headers);
        RestTemplate restTemplate = new RestTemplate();
        try {
            restTemplate.exchange(bjUserUrl, HttpMethod.GET, entity, String.class);
            if (userRepository.existsByBjNickname(bjNickname))
                throw new CheckBjNicknameValidationException(HttpStatus.CONFLICT.value(), &quot;이미 가입된 백준 닉네임 입니다.&quot;);
        } catch (HttpClientErrorException e) {
            if (e.getStatusCode() == HttpStatus.NOT_FOUND)
                throw new CheckBjNicknameValidationException(HttpStatus.NOT_FOUND.value(), &quot;백준 닉네임이 유효하지 않습니다.&quot;);
        } catch (HttpServerErrorException e) {
            log.info(&quot;BOJ server error occurred : &quot; + e.getMessage());
            throw new BOJServerErrorException(&quot;현재 백준 서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.&quot;);
        }
        log.info(&quot;success to check baekjoon nickname validity&quot;);
}</code></pre>
<p><code>RestTemplate</code>을 메서드 내부에서 new로 생성해 사용했다. 이때까진 DI 고려를 안했어서 그냥 생성해 사용해도 되겠다고 생각했다. 
이렇게 작성한 후 테스트 코드들 중 하나는 아래와 같았다. </p>
<pre><code class="language-java">@Test
@DisplayName(&quot;백준 닉네임 유효성 검증 : 이미 가입된 백준 닉네임&quot;)
void checkBjNickname_2() {
        // given
        String bjNickname = &quot;bjNickname&quot;;
        when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
            .thenReturn(new ResponseEntity&lt;&gt;(HttpStatus.OK));
        when(userRepository.existsByBjNickname(bjNickname)).thenReturn(true);
        // when, then
        assertThatThrownBy(() -&gt; userService.checkBjNickname(bjNickname))
            .isInstanceOf(CheckBjNicknameValidationException.class)
            .hasFieldOrPropertyWithValue(&quot;code&quot;, HttpStatus.CONFLICT.value())
            .hasFieldOrPropertyWithValue(&quot;error&quot;, &quot;이미 가입된 백준 닉네임 입니다.&quot;);
}</code></pre>
<p>테스트를 수행하면 계속 결과가 아래와 같았다.<img src="https://velog.velcdn.com/images/o_z/post/a58be29d-6005-4506-aee3-75c5130eb0bc/image.png" alt=""> 409가 발생해야 하는데 404..? <code>NOT FOUND</code>면 백준 닉네임이 유효하지 않는다는 예외가 발생하는 건데..
테스트 코드에서 bjNickname을 내 실제 백준 닉네임으로도 수정해서 테스트를 돌려봤다.
<img src="https://velog.velcdn.com/images/o_z/post/b9446c35-614c-4325-b4f4-679a38cc85ed/image.png" alt="">??? 이번엔 <code>RestTemplate</code>에 대해 <code>when()</code>으로 동작을 설정한 부분이 필요 없는 stubbing이라고 한다.
닉네임에 따라 결과가 달라진다..? <code>RestTemplate</code>으로 API 호출이 실제로 일어나고 있단 것이다. 분명 난 <code>@Mock</code>으로 <code>RestTemplate</code>도 등록했는데 왜 사용이 안될까? 그리고 <code>when()</code>으로 등록한 동작도 왜 필요 없는 stubbing일까?</p>
<p>사실 조금만 생각해보면 당연한 거였다.. Service 메서드 내에서 <code>RestTemplate</code>을 생성했기 때문에 애초에 Mock을 안해도 되는 것이다. 그러기에 실제로 <code>RestTemplate</code>을 통해 API 호출이 발생한 것이고.. </p>
<p><del>(다시 이 때의 테스트 코드를 보면 외부 API 호출에 의해 테스트 코드 성공/실패 여부가 갈리는 게 정말 의존성을 분리한 단위 테스트라고 볼 수 있을지 의문이다. 아마 다른 방법이 있었을 것 같은데 결과적으로는 RestTemplate을 메서드 내에서 생성하지 않게 되어 이 부분은 더 찾아보지 못했다.)</del></p>
<hr>
<h2 id="2-resttemplate의-동작을-지정하지-않았는데-테스트가-성공한다-when을-쓰지-않은-경우">2. RestTemplate의 동작을 지정하지 않았는데 테스트가 성공한다 (when()을 쓰지 않은 경우)</h2>
<p>1번 문제를 한 번 겪은 후, 이대로 PR을 올리려 했었는데 다른 코드들을 한 번 검토해보다가 <code>RestTemplate</code>을 사용하는 API를 하나 더 발견했다. 거기서도 <code>RestTemplate</code>을 기본으로만 사용하고 있었는데, 여러 곳에서 사용하고 있기도 하고 timeout을 모든 <code>RestTemplate</code>에 일괄적으로 적용하도록 만들고 싶어서 <code>RestTemplateConfig</code>를 따로 등록해서 DI로 사용하고자 설계를 변경했다.</p>
<p>그래서 UserService 코드가 아래처럼 변경되었다. 변경된 부분이라곤 UserService에서 <code>RestTemplate</code>을 DI 해주고 메서드 내에서 <code>RestTemplate</code>을 생성한 코드를 삭제한 것 밖에 없다. </p>
<pre><code class="language-java">...
public class UserService{
    // DI로 RestTemplate 주입
    private final RestTemplate restTemplate;
    ...
    @Transactional(readOnly = true)
    public void checkBjNickname(String bjNickname) {
        String bjUserUrl = &quot;https://www.acmicpc.net/user/&quot; + bjNickname;

        HttpHeaders headers = new HttpHeaders();
        headers.set(&quot;User-Agent&quot;,
            &quot;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36&quot;);
        HttpEntity&lt;String&gt; entity = new HttpEntity&lt;&gt;(headers);
        // 내부에서 RestTemplate 생성 부분 없음
        try {
            restTemplate.exchange(bjUserUrl, HttpMethod.GET, entity, String.class);
            if (userRepository.existsByBjNickname(bjNickname))
                throw new CheckBjNicknameValidationException(HttpStatus.CONFLICT.value(), &quot;이미 가입된 백준 닉네임 입니다.&quot;);
        } catch (HttpClientErrorException e) {
            if (e.getStatusCode() == HttpStatus.NOT_FOUND)
                throw new CheckBjNicknameValidationException(HttpStatus.NOT_FOUND.value(), &quot;백준 닉네임이 유효하지 않습니다.&quot;);
        } catch (HttpServerErrorException e) {
            log.info(&quot;BOJ server error occurred : &quot; + e.getMessage());
            throw new BOJServerErrorException(&quot;현재 백준 서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.&quot;);
        }
        log.info(&quot;success to check baekjoon nickname validity&quot;);
    }</code></pre>
<p>이렇게 했을 때 이번엔 정말 Mocking과 동작 지정이 필요하다고 생각해 테스트 코드도 아까와 똑같이 작성했다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;백준 닉네임 유효성 검증 : 이미 가입된 백준 닉네임&quot;)
void checkBjNickname_2() {
        // given
        String bjNickname = &quot;bjNickname&quot;;
        when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
            .thenReturn(new ResponseEntity&lt;&gt;(HttpStatus.OK));
        when(userRepository.existsByBjNickname(bjNickname)).thenReturn(true);
        // when, then
        assertThatThrownBy(() -&gt; userService.checkBjNickname(bjNickname))
            .isInstanceOf(CheckBjNicknameValidationException.class)
            .hasFieldOrPropertyWithValue(&quot;code&quot;, HttpStatus.CONFLICT.value())
            .hasFieldOrPropertyWithValue(&quot;error&quot;, &quot;이미 가입된 백준 닉네임 입니다.&quot;);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/o_z/post/e2c0a8ff-bd21-494c-b09a-093ff1c677c5/image.png" alt="">테스트는 성공했다. 
그런데 여기서 <code>when()</code>의 동작을 없앴을 때도 테스트 코드가 성공한다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;백준 닉네임 유효성 검증 : 이미 가입된 백준 닉네임&quot;)
void checkBjNickname_2() {
        // given
        String bjNickname = &quot;bjNickname&quot;;
        // when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
        //    .thenReturn(new ResponseEntity&lt;&gt;(HttpStatus.OK));
        when(userRepository.existsByBjNickname(bjNickname)).thenReturn(true);
        // when, then
        assertThatThrownBy(() -&gt; userService.checkBjNickname(bjNickname))
            .isInstanceOf(CheckBjNicknameValidationException.class)
            .hasFieldOrPropertyWithValue(&quot;code&quot;, HttpStatus.CONFLICT.value())
            .hasFieldOrPropertyWithValue(&quot;error&quot;, &quot;이미 가입된 백준 닉네임 입니다.&quot;);
}</code></pre>
<p>이유가 뭘까? <code>RestTemplate</code>도 Mocking하고 동작도 설정해야 할텐데..? 
한참을 고민하고 찾아봤는데 Mocking의 기본 개념에서 답이 있었다.</p>
<p>Mocking한 객체의 동작을 <code>when()</code>으로 따로 설정해주지 않으면 해당 객체에 대한 메서드 호출 결과가 default로 null이다. 따라서 <code>when()</code>으로 동작을 지정해주지 않으면  <code>restTemplate.exchange()</code>의 결과는 null이 된다. </p>
<pre><code class="language-java">...
try {
    restTemplate.exchange(bjUserUrl, HttpMethod.GET, entity, String.class); // return null
    // 위에서 Exception 발생 안함 =&gt; 백준 닉네임은 유효하다고 판단
    if (userRepository.existsByBjNickname(bjNickname))
        throw new CheckBjNicknameValidationException(HttpStatus.CONFLICT.value(), &quot;이미 가입된 백준 닉네임 입니다.&quot;);
} 
...</code></pre>
<p>이 과정에서 return null이 <code>HttpClientErrorException</code>,<code>HttpServerErrorException</code>를 throw 하지 않기에 백준 닉네임이 유효하다고 판단하고 try 내 로직이 계속 수행되는 것이다.</p>
<p>사실 테스트 코드를 통과하는 게 목적이라면 <code>when()</code>을 작성하지 않아도 될테지만, 테스트 코드는 동작을 명확히 하고 API 안정성을 높이는 것이 우선이라고 생각한다. 그래서 나는 <code>when()</code>으로 <code>restTemplate.exchange()</code>의 결과로 <code>ResponseEntity&lt;&gt;(HttpStatus.OK)</code>를 지정해 명확히 하기로 했다.</p>
<hr>
<p><code>RestTemplate</code> 사용해서 외부 API 호출하는 것까진 별 어려움 없이 진행했는데, 테스트 코드를 작성하면서 생각보다 꽤 멈춰 있던 것 같다. 글은 <code>RestTemplate</code>의 테스트 코드를 다뤘지만 이번 고민의 지식 핵심은 모두 <strong>Mocking</strong>이었던 것 같다. 좀 익숙해졌다 생각했는데 아직 Mocking에 대한 지식이 제대로 자리 잡지 못했다는 것도 깨달았고 다시 한 번 개념을 명확히 하는 데에 좋은 기회가 된 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ArgumentCaptor | 메서드 인자 테스트 하기 ]]></title>
            <link>https://velog.io/@o_z/ArgumentCaptor-%EB%A9%94%EC%84%9C%EB%93%9C-%EC%9D%B8%EC%9E%90-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@o_z/ArgumentCaptor-%EB%A9%94%EC%84%9C%EB%93%9C-%EC%9D%B8%EC%9E%90-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 13 Jul 2024 16:18:40 GMT</pubDate>
            <description><![CDATA[<p>현재 Springboot 프로젝트에서 Mockito를 사용해 테스트 코드를 작성하고 있다. Service 계층 테스트를 작성하는데, read, update는 결과를 확인하고 delete는 verify를 사용해 호출 횟수를 확인한다. 그런데 create는.. verify로만 확인하기엔 아무래도 새로운 객체가 제대로 생성된 것이 맞는지 항상 애매한 느낌이었다. 그래서 save를 호출 할 때 전달되는 객체가 정상적으로 생성된 것인지 확인할 방법을 찾고 있었다.</p>
<hr>
<h2 id="argumentcaptor-적용하기">ArgumentCaptor 적용하기</h2>
<p> <strong>ArgumentCaptor</strong>가 이에 적절한 해결책이다. <strong>ArgumentCaptor</strong>란 Mockito에서 제공하는 클래스로, 메서드 호출 시 전달되는 인자를 캡쳐할 수 있다. </p>
<p>먼저, <code>ArgumentCaptor</code>를 사용하기 전 create 테스트 코드는 아래와 같았다. </p>
<pre><code class="language-java">@Test
void createProblem() {
        // given
        CreateProblemRequest request = CreateProblemRequest.builder()
            .groupId(10L)
            .link(&quot;link&quot;)
            .deadline(LocalDate.now())
            .build();
        Problem problem = Problem.builder().studyGroup(group)
          .link(&quot;link&quot;)
          .deadline(LocalDate.now())
          .build();
        when(groupRepository.findById(10L)).thenReturn(Optional.ofNullable(group));
        // when
        problemService.createProblem(user,request);
        // then
        verify(problemRepository,times(1)).save(problem);
}</code></pre>
<p>create 테스트 코드를 작성할 때, verify 한 줄로 create를 검증한다는 부분이 항상 애매한 느낌이었고 더 확실한 테스트 방법을 찾고 있었다. </p>
<p>이제 <code>ArgumentCaptor</code>을 적용해보자면, 먼저 캡쳐 할 인자의 클래스를 가지고 <code>ArgumentCaptor</code>을 생성한다. </p>
<pre><code class="language-java">@Captor
private ArgumentCaptor&lt;Problem&gt; problemCaptor;</code></pre>
<p>이렇게 하면 <code>Problem</code> 클래스를 캡쳐하는 <code>ArgumentCaptor</code> 객체가 생성된 것이다. </p>
<p><code>ArgumentCaptor</code>에서 쓰이는 메서드는 크게 두 가지 인데, </p>
<blockquote>
<ol>
<li><strong><code>capture()</code></strong> : 해당 메서드의 인자로 들어가는 객체를 캡쳐해 ArgumentCaptor로 감싸서 갖고 있는다.</li>
<li><strong><code>getValue()</code></strong> : <code>capture()</code>로 캡쳐한 <code>ArgumentCaptor</code> 객체에서 인자 객체를 추출한다.  </li>
</ol>
</blockquote>
<p>따라서 verify의 save 메서드에 <code>problemCaptor.capture()</code>를 넣어 인자를 캡쳐한 후, <code>problemCaptor.getValue()</code>로 추출한 객체로 assert를 진행하면 된다.</p>
<pre><code class="language-java">@Test
void createProblem() {
        // given
        CreateProblemRequest request = CreateProblemRequest.builder()
            .groupId(10L)
            .link(&quot;link&quot;)
            .deadline(LocalDate.now())
            .build();
        when(groupRepository.findById(10L)).thenReturn(Optional.ofNullable(group));
        // when
        problemService.createProblem(user,request);
        // then
        verify(problemRepository,times(1)).save(problemCaptor.capture());
        Problem result = problemCaptor.getValue();
        assertThat(result.getStudyGroup()).isEqualTo(group);
        assertThat(result.getLink()).isEqualTo(&quot;link&quot;);
        assertThat(result.getDeadline()).isEqualTo(LocalDate.now())
}</code></pre>
<p>인자로 넘어간 객체를 확실하게 확인할 수 있는 테스트가 되었다. Mockito로 테스트를 작성한다면 꽤 유용하게 사용할만 하다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[JwtAuthenticationFilter 적용 URL 설정하기]]></title>
            <link>https://velog.io/@o_z/JwtAuthenticationFilter%EC%A0%81%EC%9A%A9URL%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@o_z/JwtAuthenticationFilter%EC%A0%81%EC%9A%A9URL%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 08 Jul 2024 09:26:55 GMT</pubDate>
            <description><![CDATA[<p>JWT를 이용한 로그인을 구현하고, 로그인 한 토큰에 대해 검증하는 <code>JwtAuthenticationFilter</code>의 예외처리를 구현하고 있었다. Swagger를 통해 <code>JwtAuthenticationFilter</code>로 예외 처리가 잘 되는지 API를 테스트 해보고 있었는데, 아래처럼 수행하는 과정에서 내가 생각하지 못한 방식으로 작동 됐다.</p>
<hr>
<h2 id="문제-시나리오">문제 시나리오</h2>
<blockquote>
<ol>
<li>토큰으로 예외가 터질 값을 넣는다. ( ex : JWT 형태가 아닌 아무 글자 )</li>
<li><code>JwtAuthenticationFilter</code>를 거쳐야 하는, 즉 토큰을 검증 로직이 필요한 API를 호출한다.</li>
<li>2번의 API에서 예외 처리로 ErrorResponse가 발생한다. ( &quot;잘못된 형태의 토큰입니다.&quot; 내용의 예외 )</li>
<li>잘못된 토큰 헤더를 계속 유지한 채, 유효한 email과 password로 로그인 API를 호출한다. </li>
</ol>
</blockquote>
<p>내가 예상했던 4번의 결과는 정상적으로 로그인 API에 대한 response가 반환 되는 것이었는데, 실제로는 아까 3번에서 나타난 ErrorResponse가 동일하게 발생했다.</p>
<hr>
<h2 id="원인">원인</h2>
<p>4번 과정을 보면, 로그인 API 호출에 헤더는 여전히 아까 설정한 잘못된 토큰이 포함되어 있는 것이다. Filter 순서 상 API를 호출하기 전에 <code>JwtAuthenticationFilter</code>를 거치기 때문에 토큰 검증 과정을 거치게 된다. 그래서 토큰 검증이 필요 없는 API여도 토큰 검증을 거치게 되며, 아까 헤더에 설정해 둔 잘못된 토큰에서 예외가 발생하게 되는 것이다. </p>
<hr>
<h2 id="해결">해결</h2>
<p>회원가입, 로그인 API는 토큰 정보가 필요 없다. 그래서 회원가입, 로그인 API는 <code>JwtAuthenticationFilter</code>의 토큰 검증 과정을 거치지 않도록 설정했다. <code>shouldNotFilter</code> 메서드를 사용해 현재 들어온 API 요청이 토큰 검증에서 제외할 API URL인지 확인하고, 이에 해당하면 토큰 검증 과정을 지나친다. 이 외의 URL이면 토큰 검증을 진행한다. <code>shouldNotFilter</code>에 주목하자!
<del><em>(JwtAuthenticationFilter를 사용하려면 SpringSecurityConfig 설정이 더 필요하지만, 여기선 Filter를 거칠 URL를 설정하는 내용이 위주여서 SpringSecurityConfig 코드는 포함하지 않았다.)</em></del></p>
<pre><code class="language-java">@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter{
    private final TokenProvider tokenProvider;
    // 토큰 검증 과정을 지나칠 API URL들
    private final List&lt;String&gt; excludedPaths = Arrays.asList(&quot;/api/user/sign-in&quot;,&quot;/api/user/register&quot;);

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException{
        // 로그인, 회원가입 API URL이 포함되는지 확인하는 함수
        String path = request.getRequestURI();
        return excludedPaths.stream().anyMatch(path::startsWith);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        // 로그인 회원가입 API가 아닌 경우, 토큰 검증 과정 거침 
        try{
            String token = resolveToken(request);
            if (token != null &amp;&amp; tokenProvider.validateToken(token)) {
                Authentication authentication = tokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            filterChain.doFilter(request, response);
        } catch (JwtRequestException e) {
            sendErrorResponse(response,e);
        }
    }

    private String resolveToken(HttpServletRequest request){
        String token = request.getHeader(&quot;Authorization&quot;);
        if (StringUtils.hasValue(token) &amp;&amp; token.startsWith(&quot;Bearer&quot;))
            return token.substring(7);
        return null;
    }

    private void sendErrorResponse(HttpServletResponse response, JwtRequestException e) throws IOException {
        response.reset();
        response.setStatus(e.getCode());
        response.setContentType(&quot;application/json&quot;);
        response.setCharacterEncoding(&quot;UTF-8&quot;);
        response.getWriter().write(new ObjectMapper().writeValueAsString(
            new ErrorResponse(e.getCode(), e.getError(), e.getMessages())
        ));
    }
}
</code></pre>
<hr>
<h3 id="springsecurityconfig-설정으로는-안되는-이유">SpringSecurityConfig 설정으로는 안되는 이유?</h3>
<p>처음에는 <code>SpringSecurityConfig</code>에 Request 요청에 대한 제한을 두면 될 것이라 생각했고, 생각한 방식대로 시도 했었다. </p>
<pre><code class="language-java">@Bean
public SecurityFilterChain filterChain(HttpSecurity security) throws Exception {
    return security
        ...
        .authorizeHttpRequests(auth -&gt; auth
            .requestMatchers(&quot;/api/user/sign-in&quot;, &quot;/api/user/register&quot;).permitAll()
            .anyRequest().authenticated()
        )
        ...
        .build();
}</code></pre>
<p>하지만 내 생각대로 작동되지 않았다.</p>
<p>Spring Security의 인증, 권한 부여 로직을 설정하는 것은 맞지만, <code>permitAll()</code>은 특정 uri에 대해 요청을 허용한다는 의미가 내가 생각했던 것과 달랐다.</p>
<p><code>permitAll()</code>에 등록한 요청이어도 Spring Security 필터 체인은 모두 통과한다. 마지막 단에 있는 <code>FilterSecurityInterceptor</code>에서 진행하는 인증 과정을 생략할 뿐이다. 해당 인터셉터에서 Security Config에 등록되어있는 <code>permitAll()</code> 요청이면 인증 과정을 생략하여 요청을 전달하고, 등록되지 않았다면 <code>SecurityContext</code>에서 Authentication 객체를 가져오는 것이다.</p>
<p> 결론적으로는 <code>FilterSecurityInterceptor</code>의 앞에서 Security 필터 체인은 모두 거치는 것이고, <code>UsernamePasswordAuthenticationFilter</code>의 앞에 설정해둔 <code>JwtAuthenticationFilter</code>를 통과한다. 결국 <code>SpringSecurityConfig</code>에서 설정한 <code>.permitAll()</code>은 <code>JwtAuthenticationFilter</code> 통과 여부에는 영향력이 없는 것이다. </p>
<p><code>JwtAuthenticationFilter</code>의 <code>shouldNotFilter</code> 방식은 JWT 토큰 검증 로직 자체를 건너뛰게 한다. 지정된 URL에 대해서는 JWT 토큰의 존재 여부나 유효성을 아예 확인하지 않게 하는 것이다. 필터 수준에서 동작하므로, Spring Security의 다른 설정보다 먼저 적용하기에, JWT 유효성 검증을 건너뛰려면 <code>JwtAuthenticationFilter</code>에서 <code>shouldNotFilter</code>를 사용해 URL을 특정해주어야 JWT 검증이 필요하지 않은 API에서는 토큰 검증을 하지 않을 수 있다.</p>
<hr>
]]></description>
        </item>
    </channel>
</rss>