<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>찬밥의 개발기록</title>
        <link>https://velog.io/</link>
        <description>서두르지 않으나 쉬지 않고</description>
        <lastBuildDate>Wed, 04 Jun 2025 10:29:44 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. 찬밥의 개발기록. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/happy_code" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Prometheus와 Spring Actuator 엔드포인트 문제 해결]]></title>
            <link>https://velog.io/@happy_code/Prometheus%EC%99%80-Spring-Actuator-%EC%97%94%EB%93%9C%ED%8F%AC%EC%9D%B8%ED%8A%B8-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@happy_code/Prometheus%EC%99%80-Spring-Actuator-%EC%97%94%EB%93%9C%ED%8F%AC%EC%9D%B8%ED%8A%B8-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Wed, 04 Jun 2025 10:29:44 GMT</pubDate>
            <description><![CDATA[<p>아침에 EC2에 Prometheus랑 Spring Actuator를 연결해보려고 했더니, Actuator Prometheus 엔드포인트가 잡히지 않는 문제를 겪었다. 어떻게 해결했는지 내 TIL을 정리해본다.</p>
<hr>
<h2 id="문제-상황">문제 상황</h2>
<ul>
<li>로컬에서는 Prometheus가 <code>localhost:8080/actuator/prometheus</code>와 <code>localhost:9090/metrics</code> 엔드포인트 둘 다 스크랩 잘됨.</li>
<li>그런데 EC2에 올리자 Prometheus UI에서는 <code>localhost:9090/metrics</code>만 보이고, <code>localhost:8080/actuator/prometheus</code>가 잡히지 않음.</li>
<li>EC2 내부에서 <code>curl http://127.0.0.1:8080/actuator/prometheus</code>는 잘 열림. 문제는 Prometheus가 제대로 스크랩하지 못하는 것.</li>
</ul>
<hr>
<h3 id="0-전체-목표">0. 전체 목표</h3>
<ul>
<li>EC2에 Prometheus 설치</li>
<li>Spring Boot(8080) Actuator Prometheus 엔드포인트(<code>/actuator/prometheus</code>)를 스크랩해서 모니터링</li>
<li>최종적으로 Grafana 대시보드에 시각화</li>
</ul>
<hr>
<h2 id="1-prometheus-설치-및-기본-설정">1. Prometheus 설치 및 기본 설정</h2>
<ol>
<li><p>EC2(Ubuntu) 터미널에서 Prometheus 바이너리 다운로드 &amp; 압축 해제</p>
<pre><code class="language-bash">wget https://github.com/prometheus/prometheus/releases/download/v2.47.0/prometheus-2.47.0.linux-amd64.tar.gz
tar xvfz prometheus-2.47.0.linux-amd64.tar.gz
cd prometheus-2.47.0.linux-amd64</code></pre>
</li>
<li><p>바이너리(<code>prometheus</code>, <code>promtool</code>)를 시스템 경로로 복사</p>
<pre><code class="language-bash">sudo cp prometheus /usr/local/bin/
sudo cp promtool /usr/local/bin/</code></pre>
</li>
<li><p>콘솔 템플릿, 라이브러리, 설정 파일을 <code>/etc/prometheus</code>로 복사</p>
<pre><code class="language-bash">sudo mkdir -p /etc/prometheus
sudo cp -r consoles /etc/prometheus/
sudo cp -r console_libraries /etc/prometheus/
sudo cp prometheus.yml /etc/prometheus/</code></pre>
</li>
<li><p><code>/etc/prometheus/prometheus.yml</code> 열어보면 기본값으로 <code>localhost:9090/metrics</code>만 스크랩하도록 설정돼 있음.</p>
<pre><code>global:
  scrape_interval: 15s
scrape_configs:
  - job_name: &#39;prometheus&#39;
    static_configs:
      - targets: [&#39;localhost:9090&#39;]</code></pre><p>→ 아직 Spring 애플리케이션 스크랩 설정 없음.</p>
</li>
</ol>
<hr>
<h2 id="2-spring-boot-actuator-설정-확인">2. Spring Boot Actuator 설정 확인</h2>
<p>로컬 개발 환경에서 이미 다음 작업을 해뒀다.</p>
<ul>
<li><p><code>build.gradle</code> 또는 <code>pom.xml</code>에</p>
<pre><code class="language-groovy">implementation &#39;org.springframework.boot:spring-boot-starter-actuator&#39;
implementation &#39;io.micrometer:micrometer-registry-prometheus&#39;</code></pre>
</li>
<li><p><code>application.yml</code>에</p>
<pre><code class="language-yaml">management:
  endpoints:
    web:
      exposure:
        include: &quot;*&quot;</code></pre>
</li>
<li><p>이 상태에서 <code>http://localhost:8080/actuator/prometheus</code> 접속하면 Micrometer 메트릭이 JSON으로 잘 찍힘.</p>
</li>
</ul>
<p>로컬에서는 <code>/actuator/prometheus</code>와 <code>/metrics</code> 둘 다 Prometheus UI에 잡힘.</p>
<hr>
<h2 id="3-prometheus에-spring-actuator-잡아주기">3. Prometheus에 Spring Actuator 잡아주기</h2>
<p>EC2에 올려놓은 <code>prometheus.yml</code>을 수정해서 Actuator 스크랩 잡아주려고 했다.</p>
<pre><code class="language-yaml">global:
  scrape_interval: 15s

scrape_configs:
  - job_name: &quot;prometheus&quot;
    static_configs:
      - targets: [&quot;localhost:9090&quot;]

  # 추가
  - job_name: &quot;spring-actuator&quot;
    metrics_path: &#39;/actuator/prometheus&#39;
    scrape_interval: 15s
    static_configs:
      - targets: [&#39;localhost:8080&#39;]</code></pre>
<p>수정 후 Prometheus 재시작:</p>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl restart prometheus</code></pre>
<p>근데 Prometheus 웹 UI → Targets 가서 봐도 여전히 &quot;spring-actuator&quot; job이 등록되지 않고 <code>localhost:9090/metrics</code>만 보였다.
즉, 설정이 반영되지 않는 듯한 증상.</p>
<hr>
<h2 id="4-etcprometheusprometheusyml에-수정이-안-된-문제">4. <code>/etc/prometheus/prometheus.yml</code>에 수정이 안 된 문제</h2>
<ul>
<li>EC2 터미널에서 <code>nano /etc/prometheus/prometheus.yml</code> 열어보니, 내가 수정한 내용이 반영되어 있지 않았다.</li>
<li>그냥 <code>~/prometheus-2.47.0.linux-amd64/prometheus.yml</code>만 수정했고, 실제로 Prometheus가 읽는 <code>/etc/prometheus/prometheus.yml</code>은 그대로 기본값이었다.</li>
</ul>
<p>확인:</p>
<pre><code class="language-bash">cat /etc/prometheus/prometheus.yml
# ... 스크랩 설정이 기본상태로 남아 있었음</code></pre>
<hr>
<h2 id="5-홈-디렉터리-수정본을-실제-경로로-복사">5. 홈 디렉터리 수정본을 실제 경로로 복사</h2>
<p>결국 <strong>홈 디렉터리에서 수정한 <code>prometheus.yml</code>을 <code>/etc/prometheus/</code>로 복사</strong>한 뒤에야 설정이 먹혔다.</p>
<pre><code class="language-bash">sudo cp ~/prometheus-2.47.0.linux-amd64/prometheus.yml /etc/prometheus/prometheus.yml
sudo systemctl daemon-reload
sudo systemctl restart prometheus</code></pre>
<p>이제 Prometheus UI → Targets에서 “spring-actuator → localhost:8080/actuator/prometheus”가 <strong>UP</strong> 상태로 떠 있었다.</p>
<hr>
<h2 id="6-prometheus-서비스-등록별도-사용자를-만들진-않고-바로-실행">6. Prometheus 서비스 등록(별도 사용자를 만들진 않고 바로 실행)</h2>
<p>서비스 유닛 파일은 이렇게 만들었다.
(<code>/etc/systemd/system/prometheus.service</code>)</p>
<pre><code class="language-ini">[Unit]
Description=Prometheus Monitoring Service
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/prometheus \
  --config.file=/etc/prometheus/prometheus.yml \
  --storage.tsdb.path=/var/lib/prometheus \
  --web.console.templates=/etc/prometheus/consoles \
  --web.console.libraries=/etc/prometheus/console_libraries
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target</code></pre>
<p>그리고 실행:</p>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl enable prometheus
sudo systemctl start prometheus
sudo systemctl status prometheus   # Active: active (running) 확인</code></pre>
<p>—&gt; 이로써 <code>nohup prometheus</code> 같은 백그라운드 실행 없이,
EC2가 재부팅돼도 Prometheus가 자동 시작되도록 설정 완료.</p>
<hr>
<h2 id="7-최종-확인">7. 최종 확인</h2>
<ol>
<li><p>EC2 내부(SSH 터미널)에서:</p>
<pre><code class="language-bash">curl -v http://127.0.0.1:8080/actuator/prometheus</code></pre>
<p>→ 200 OK, Micrometer 메트릭 JSON 출력됨.</p>
</li>
<li><p>Prometheus UI (http://EC2_PUBLIC_IP:9090) → <strong>Targets</strong> 탭 확인</p>
<ul>
<li><code>prometheus → localhost:9090/metrics</code> → UP</li>
<li><code>spring-actuator → localhost:8080/actuator/prometheus</code> → UP</li>
</ul>
</li>
<li><p>Grafana 대시보드에서 Prometheus 데이터 소스로 <code>http://EC2_PUBLIC_IP:9090</code> 추가 후 시각화 가능해졌다.
<img src="https://velog.velcdn.com/images/happy_code/post/5090b6d1-7cee-4f4c-8ca6-40a0227ddbb5/image.png" alt=""></p>
</li>
</ol>
<hr>
<h3 id="til-포인트">TIL 포인트</h3>
<ul>
<li>Prometheus 설정 파일은 <strong>실제 서비스가 읽는 경로</strong>(<code>/etc/prometheus/prometheus.yml</code>)를 수정해야 한다.</li>
<li>Spring Boot Actuator 메트릭 스크랩은 절대 <code>public IP:8080</code>이 아니라 **<code>localhost:8080</code>**을 targets에 넣어야 EC2 내부에서 제대로 접근 가능하다.</li>
</ul>
<p>이상으로 “EC2 Prometheus, Spring Actuator 엔드포인트가 잡히지 않을 때”에 대한 내 TIL 정리 끝.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GitHub Actions 테스트 실패 원인과 해결 과정 (Spring Boot + YML 설정)]]></title>
            <link>https://velog.io/@happy_code/GitHub-Actions-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8B%A4%ED%8C%A8-%EC%9B%90%EC%9D%B8%EA%B3%BC-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95-Spring-Boot-YML-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@happy_code/GitHub-Actions-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8B%A4%ED%8C%A8-%EC%9B%90%EC%9D%B8%EA%B3%BC-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95-Spring-Boot-YML-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Sun, 25 May 2025 05:06:13 GMT</pubDate>
            <description><![CDATA[<p>CI/CD 설정하면서 <code>./gradlew test</code>만 유독 GitHub Actions에서 계속 실패했다.</p>
<hr>
<h2 id="상황-요약">상황 요약</h2>
<ul>
<li><p>CI 파이프라인은 다음 순서로 짜놨음:</p>
<ol>
<li>Redis 설치</li>
<li>테스트 실행</li>
<li>Gradle 빌드</li>
<li>EC2 배포</li>
</ol>
</li>
<li><p>테스트는 로컬에선 <code>-Dspring.profiles.active=test</code> 옵션 주고 하면 잘 됨</p>
</li>
<li><p>근데 GitHub Actions에선 테스트 단계에서 무조건 실패</p>
</li>
</ul>
<hr>
<h2 id="⚠️-실패-로그들">⚠️ 실패 로그들</h2>
<p>대표적인 실패 에러는 이거들:</p>
<h3 id="1-프로퍼티-안-채워짐">1. 프로퍼티 안 채워짐</h3>
<pre><code class="language-bash">Caused by: java.lang.IllegalArgumentException at PropertyPlaceholderHelper.java:180</code></pre>
<p>→ <code>${xxx}</code> 로 설정을 불러오는데 해당 값이 없어서 생긴 에러</p>
<h3 id="2-jwt-secret-문제">2. JWT secret 문제</h3>
<pre><code class="language-bash">Caused by: io.jsonwebtoken.security.WeakKeyException at Keys.java:96</code></pre>
<p>→ secret 값이 너무 짧거나 base64 디코딩 실패</p>
<hr>
<h2 id="🔍-원인-찾기">🔍 원인 찾기</h2>
<p>처음에는 test용 yml 파일이 잘못됐나 싶었는데, 진짜 문제는 <code>application.yml</code>에 있었다.</p>
<pre><code class="language-yaml">spring:
  profiles:
    active: local
    include: secret</code></pre>
<p>이게 모든 상황에서 <code>local</code>과 <code>secret</code> 프로파일을 로드함.
test 프로파일만 써도 결국 MySQL, JWT, mail 등 민감한 설정이 필요해졌던 거다.</p>
<p>→ 그래서 CI 환경에서는 저 설정들이 없으니까 에러가 나는 구조였던 것.</p>
<hr>
<h3 id="application-localyml-secretyml을-base64로-인코딩해서-github-환경변수secrets에-등록">application-local.yml, secret.yml을 base64로 인코딩해서 GitHub 환경변수(Secrets)에 등록</h3>
<pre><code class="language-yaml">- name: Restore local.yml
  run: echo &quot;${{ secrets.LOCAL_YML }}&quot; | base64 -d &gt; src/main/resources/application-local.yml

- name: Restore secret.yml
  run: echo &quot;${{ secrets.SECRET_YML }}&quot; | base64 -d &gt; src/main/resources/application-secret.yml</code></pre>
<p><img src="https://velog.velcdn.com/images/happy_code/post/7d7167a2-fd78-4867-89c9-5b26aed753be/image.png" alt=""></p>
<p>이렇게 해도 실패. 어째서???</p>
<pre><code class="language-plaintext">Caused by: org.hibernate.HibernateException at DialectFactoryImpl.java:191

그리고 마지막 한 줄:

BUILD FAILED in 55s
Error: Process completed with exit code 1.
</code></pre>
<p>즉, DB 연결 시도 중 Dialect 감지를 못 해서 터진 것.</p>
<p>application-test.yml에서는 H2로 설정되어 있지만, 해당 에러는 테스트 중 JPA가 H2가 아닌 MySQL을 로딩하려다 발생한 거였다.</p>
<p>즉, <code>application-test.yml</code>이 로드되지 않고 <code>application-local.yml</code>이 먼저 로드된 상태.</p>
<p>CI 환경에는 MySQL이 없으니 당연히 실패한 거다.</p>
<hr>
<h3 id="이유는-applicationyml의-고정된-profile-설정-때문이었다">이유는 <code>application.yml</code>의 고정된 Profile 설정 때문이었다.</h3>
<pre><code class="language-yaml">spring:
  profiles:
    active: local
    include: secret</code></pre>
<p>이렇게 되어 있었기 때문에, 내가 아무리 CI 환경에서
<code>- Dspring.profiles.active=test</code>를 주더라도 <code>application.yml</code>은 여전히 local과 secret 프로파일을 로딩했다.</p>
<p>결국 테스트에서도 MySQL 설정이 적용되어버리는 구조였던 것.
CI 환경에는 MySQL이 없으니 당연히 DB Dialect도 찾지 못하고 터지는 게 맞았던 거다.</p>
<hr>
<h2 id="✅-결국-해결한-방법">✅ 결국 해결한 방법</h2>
<p><code>application.yml</code>에서 프로파일을 환경변수 기반으로 바꿨음:</p>
<pre><code class="language-diff">spring:
  profiles:
-   active: local
-   include: secret
+   active: ${SPRING_PROFILES_ACTIVE:local}
+   include: ${SPRING_PROFILES_INCLUDE:secret}</code></pre>
<p><img src="https://velog.velcdn.com/images/happy_code/post/b4f39482-aeca-4e2a-a464-c4f6291a12e3/image.png" alt=""></p>
<p>→ 기본은 여전히 local/secret이지만, 환경변수로 덮어쓸 수 있게 된 것이다.</p>
<p>그리고 GitHub Actions에서 이렇게 설정했다:</p>
<pre><code class="language-yaml">- name: Set profile
  run: echo &quot;SPRING_PROFILES_ACTIVE=test&quot; &gt;&gt; $GITHUB_ENV</code></pre>
<p>→ <code>application.yml</code>이 기본값은 local/secret 쓰지만, CI 환경에서는 test만 쓰도록 override 가능해짐.</p>
<hr>
<h2 id="배포까지-확인">배포까지 확인</h2>
<p><img src="https://velog.velcdn.com/images/happy_code/post/bbf383fd-e8c9-467c-947f-57df053165b6/image.png" alt=""></p>
<hr>
<h2 id="회고">회고</h2>
<ul>
<li>진짜 핵심은 <code>application.yml</code>을 너무 고정값으로 설정해놨던 거</li>
<li>CI 환경에서는 로컬이랑 완전히 동일하게 설정해줘야 제대로 작동함</li>
<li><code>application.yml</code>은 최대한 유연하게 구성해야 한다는 걸 뼈저리게 느낌</li>
<li>삽질도 많았지만 덕분에 Spring Profile, GitHub Actions, CI/CD 전반을 한층 이해하게 됨</li>
</ul>
<hr>
<h2 id="기억할-것">기억할 것</h2>
<blockquote>
<p>CI 환경에서 계속 실패하면 application.yml부터 의심하자.
환경 변수로 유연하게 바꾸는 게 답이다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS SES 스팸함 전송]]></title>
            <link>https://velog.io/@happy_code/%EA%B5%AC%EA%B8%80-SMTP-AWS-SES-%EC%A0%84%ED%99%98%EA%B8%B0-%EC%82%BD%EC%A7%88-%EA%B8%B0%EB%A1%9D</link>
            <guid>https://velog.io/@happy_code/%EA%B5%AC%EA%B8%80-SMTP-AWS-SES-%EC%A0%84%ED%99%98%EA%B8%B0-%EC%82%BD%EC%A7%88-%EA%B8%B0%EB%A1%9D</guid>
            <pubDate>Fri, 23 May 2025 10:19:40 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황-메일은-가지만-스팸함에-간다">문제 상황: 메일은 가지만 스팸함에 간다?</h2>
<p>초기에는 간단하게 Spring Boot에서 <strong>구글 SMTP</strong>를 사용하여 메일을 보내고 있었다.
메일 전송은 성공적으로 되었지만, 중요한 문제가 있었다.</p>
<blockquote>
<p>❌ 메일이 전부 스팸함으로 분류됨</p>
</blockquote>
<p>운영 서비스에서는 치명적인 문제라 판단했고,
<strong>도메인 기반 인증이 가능한 AWS SES(Simple Email Service)</strong> 로 전환하기로 결정했다.</p>
<hr>
<h2 id="🔧-1차-삽질-mailconfig-수정-누락">🔧 1차 삽질: MailConfig 수정 누락</h2>
<p><code>application.yml</code>과 IAM 사용자 설정 등 SES 전환을 잘 마쳤다고 생각했는데, 메일 전송 시 아래 에러가 발생했다:</p>
<pre><code>jakarta.mail.MessagingException: Could not convert socket to TLS;
  nested exception is:
        java.io.IOException: Server is not trusted: email-smtp.ap-northeast-2.amazonaws.com</code></pre><p>한참 TLS 인증 문제로 삽질했지만, 원인은 어이없게도...</p>
<pre><code class="language-java">// 기존 설정 (구글 SMTP)
props.put(&quot;mail.smtp.ssl.trust&quot;, &quot;smtp.gmail.com&quot;);

// → AWS SES 변경 시 이 부분도 바꿔줘야 한다!
props.put(&quot;mail.smtp.ssl.trust&quot;, &quot;email-smtp.ap-northeast-2.amazonaws.com&quot;);</code></pre>
<p>📌 <strong>MailConfig에서 호스트 변경은 했지만 <code>ssl.trust</code>는 안 바꿔서 TLS 오류가 발생했던 것...</strong></p>
<hr>
<h2 id="🔐-2차-삽질-535-authentication-credentials-invalid">🔐 2차 삽질: <code>535 Authentication Credentials Invalid</code></h2>
<p>TLS 에러를 해결하고 나니 이번엔 인증 오류가 떴다:</p>
<pre><code>jakarta.mail.AuthenticationFailedException: 535 Authentication Credentials Invalid</code></pre><p>그래서 <a href="https://docs.aws.amazon.com/ko_kr/ses/latest/dg/smtp-credentials.html">AWS 공식 문서</a>를 보고
Secret Access Key → SMTP 비밀번호로 변환하는 Python 스크립트를 사용했다.</p>
<pre><code class="language-python">#!/usr/bin/env python3

import hmac
import hashlib
import base64
import argparse

SMTP_REGIONS = [
    &quot;us-east-2&quot;,  # US East (Ohio)
    &quot;us-east-1&quot;,  # US East (N. Virginia)
    &quot;us-west-2&quot;,  # US West (Oregon)
    &quot;ap-south-1&quot;,  # Asia Pacific (Mumbai)
    &quot;ap-northeast-2&quot;,  # Asia Pacific (Seoul)
    &quot;ap-southeast-1&quot;,  # Asia Pacific (Singapore)
    &quot;ap-southeast-2&quot;,  # Asia Pacific (Sydney)
    &quot;ap-northeast-1&quot;,  # Asia Pacific (Tokyo)
    &quot;ca-central-1&quot;,  # Canada (Central)
    &quot;eu-central-1&quot;,  # Europe (Frankfurt)
    &quot;eu-west-1&quot;,  # Europe (Ireland)
    &quot;eu-west-2&quot;,  # Europe (London)
    &quot;eu-south-1&quot;,  # Europe (Milan)
    &quot;eu-north-1&quot;,  # Europe (Stockholm)
    &quot;sa-east-1&quot;,  # South America (Sao Paulo)
    &quot;us-gov-west-1&quot;,  # AWS GovCloud (US)
    &quot;us-gov-east-1&quot;,  # AWS GovCloud (US)
]

# These values are required to calculate the signature. Do not change them.
DATE = &quot;11111111&quot;
SERVICE = &quot;ses&quot;
MESSAGE = &quot;SendRawEmail&quot;
TERMINAL = &quot;aws4_request&quot;
VERSION = 0x04


def sign(key, msg):
    return hmac.new(key, msg.encode(&quot;utf-8&quot;), hashlib.sha256).digest()


def calculate_key(secret_access_key, region):
    if region not in SMTP_REGIONS:
        raise ValueError(f&quot;The {region} Region doesn&#39;t have an SMTP endpoint.&quot;)

    signature = sign((&quot;AWS4&quot; + secret_access_key).encode(&quot;utf-8&quot;), DATE)
    signature = sign(signature, region)
    signature = sign(signature, SERVICE)
    signature = sign(signature, TERMINAL)
    signature = sign(signature, MESSAGE)
    signature_and_version = bytes([VERSION]) + signature
    smtp_password = base64.b64encode(signature_and_version)
    return smtp_password.decode(&quot;utf-8&quot;)


def main():
    parser = argparse.ArgumentParser(
        description=&quot;Convert a Secret Access Key to an SMTP password.&quot;
    )
    parser.add_argument(&quot;secret&quot;, help=&quot;The Secret Access Key to convert.&quot;)
    parser.add_argument(
        &quot;region&quot;,
        help=&quot;The AWS Region where the SMTP password will be used.&quot;,
        choices=SMTP_REGIONS,
    )
    args = parser.parse_args()
    print(calculate_key(args.secret, args.region))


if __name__ == &quot;__main__&quot;:
    main()
</code></pre>
<p>하지만...</p>
<blockquote>
<p>❌ 여전히 535 에러 발생</p>
</blockquote>
<p>멘탈 붕괴 후… 그냥 <strong>CSV 파일에서 받은 Secret Access Key를 그대로 넣어봤는데…</strong></p>
<blockquote>
<p>✅ 성공했다.</p>
</blockquote>
<p>즉, <strong>변환할 필요 없음!!</strong></p>
<p>SMTP 전용 자격 증명을 발급하면 <strong>그 자체가 비밀번호로 바로 사용 가능</strong>하다.</p>
<hr>
<h2 id="✅-마무리-spf-설정도-필수">✅ 마무리: SPF 설정도 필수</h2>
<p>메일이 받은편지함에 잘 도착하기 위해서는 도메인에 아래 TXT 레코드를 추가해야 한다:</p>
<pre><code class="language-txt">TXT    @    &quot;v=spf1 include:amazonses.com ~all&quot;</code></pre>
<p><img src="https://velog.velcdn.com/images/happy_code/post/76d43c28-151f-445a-99d3-0f30be87565f/image.png" alt=""></p>
<hr>
<h2 id="💭-회고">💭 회고</h2>
<blockquote>
<p>🧠 &quot;문서가 항상 맞는 건 아니다. 직접 해보는 것이 답이다.&quot;</p>
</blockquote>
<p>Spring Boot에서 AWS SES를 설정할 땐 단순히 <code>username</code>/<code>password</code>만 입력한다고 끝이 아니라,
<strong>JavaMail 설정, 도메인 인증, 자격 증명 발급 방식까지</strong> 모두 정확히 이해하고 설정해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE 연결 실패 트러블슈팅]]></title>
            <link>https://velog.io/@happy_code/SSE-%EC%97%B0%EA%B2%B0-%EC%8B%A4%ED%8C%A8-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@happy_code/SSE-%EC%97%B0%EA%B2%B0-%EC%8B%A4%ED%8C%A8-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Sun, 18 May 2025 14:01:45 GMT</pubDate>
            <description><![CDATA[<h3 id="문제-상황">문제 상황</h3>
<p>프론트엔드에서 SSE(Server-Sent Events)를 이용해 알림을 구독하려 할 때, 다음과 같은 에러가 발생했다.</p>
<pre><code>EventSource&#39;s response has a Content-Type specifying an unsupported type: text/event-stream, text/event-stream. Aborting the connection.</code></pre><p>또한 연결이 되자마자 <code>readyState</code>는 <code>2</code>(closed)로 바뀌며, <code>onerror</code> 콜백이 실행되었다.
HTTP 응답 코드는 200이었기 때문에, 일단 백엔드나 프록시에서 문제가 발생했다고 생각하지는 않았다.</p>
<hr>
<h3 id="1-설정은-다-맞췄는데-왜-안-되지">1. &quot;설정은 다 맞췄는데 왜 안 되지?&quot;</h3>
<p>처음에는 백엔드 쪽을 의심했다.</p>
<ul>
<li><code>SseEmitter</code>는 연결 직후 <code>.event(&quot;connect&quot;).data(&quot;connected&quot;)</code>를 정상적으로 보내고 있었고,</li>
<li>heartbeat도 <code>.comment(&quot;heartbeat&quot;)</code> 형식으로 주기적으로 전송하고 있었다.</li>
<li>Nginx도 <code>proxy_buffering off</code>, <code>chunked_transfer_encoding on</code>, <code>X-Accel-Buffering no</code> 등 기본 설정은 잘 반영되어 있었다.</li>
</ul>
<p>게다가 <code>curl</code>로 테스트하면 정상적으로 연결이 유지되었고, heartbeat도 잘 수신되었다.
<img src="https://velog.velcdn.com/images/happy_code/post/17b7167f-93ff-462f-a120-149d783cfd75/image.png" alt=""></p>
<p>그래서 프론트 문제일 가능성을 의심했지만, 브라우저 콘솔이 알려준 핵심 단서는 바로 이 부분이었다:</p>
<pre><code>Content-Type: text/event-stream, text/event-stream</code></pre><hr>
<h3 id="2-content-type-헤더-중복-문제">2. Content-Type 헤더 중복 문제</h3>
<p><code>Content-Type</code> 헤더가 <strong>중복되어 내려가고 있었던 것</strong>이다.
이는 Nginx에서 수동으로 <code>add_header Content-Type text/event-stream;</code>를 설정한 것이 원인이었다.</p>
<p>하지만 Spring에서도 이미 <code>text/event-stream</code>을 자동으로 내려주고 있었기 때문에,
브라우저는 이를 비정상적인 응답으로 판단하고 SSE 연결을 바로 종료시켰던 것이다.</p>
<p><strong>해결 방법:</strong>
<code>/etc/nginx/sites-available/default</code></p>
<pre><code class="language-nginx"># 아래 줄은 제거
add_header Content-Type text/event-stream;</code></pre>
<p>이후 <code>nginx -s reload</code>로 설정을 반영한 뒤 문제는 해결되었다.</p>
<hr>
<h3 id="3-heartbeat가-있는데도-연결이-끊기는-이유">3. heartbeat가 있는데도 연결이 끊기는 이유</h3>
<p>한 가지 이상한 점은 heartbeat도 주기적으로 보내고 있었는데,
브라우저에서는 연결이 유지되지 않았다는 점이다.</p>
<p>그 이유는 heartbeat를 <code>.comment(&quot;heartbeat&quot;)</code>로 보내고 있었기 때문이다.
이 방식은 EventSource 내부에서 keep-alive 용도로 사용되긴 하지만,
클라이언트에서 직접 수신하는 이벤트로는 인식되지 않는다.</p>
<p>하지만 프론트에서는 다음과 같이 <code>addEventListener(&quot;heartbeat&quot;)</code>로 수신을 시도하고 있었다.</p>
<pre><code class="language-ts">eventSource.addEventListener(&quot;heartbeat&quot;, (e) =&gt; {
  console.log(&quot;💓 heartbeat 수신:&quot;, e.data);
});</code></pre>
<p>따라서 heartbeat도 아래와 같이 수정해주었다.</p>
<pre><code class="language-java">emitter.send(
  SseEmitter.event()
    .name(&quot;heartbeat&quot;)
    .data(&quot;ping&quot;)
);</code></pre>
<hr>
<h3 id="결과-요약">결과 요약</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>수정 전</th>
<th>수정 후</th>
</tr>
</thead>
<tbody><tr>
<td>Content-Type</td>
<td><code>text/event-stream, text/event-stream</code> (중복)</td>
<td>Spring이 설정한 헤더만 유지</td>
</tr>
<tr>
<td>heartbeat</td>
<td><code>.comment(&quot;heartbeat&quot;)</code></td>
<td><code>.name(&quot;heartbeat&quot;).data(&quot;ping&quot;)</code></td>
</tr>
<tr>
<td>연결 상태</td>
<td>연결 직후 종료됨 (<code>readyState: 2</code>)</td>
<td>안정적으로 유지됨 (<code>readyState: 1</code>)</td>
</tr>
</tbody></table>
<hr>
<h3 id="회고">회고</h3>
<p>이번 문제는 언뜻 보기엔 별 문제가 없어 보였지만,
브라우저가 SSE를 다루는 방식은 매우 엄격하며 사소한 설정 차이도 치명적인 결과를 초래할 수 있다는 점을 배웠다.</p>
<p>특히 다음 두 가지는 기억해두어야 할 교훈이다:</p>
<ul>
<li><strong>헤더 중복</strong>은 예상보다 더 쉽게 발생하고, 브라우저가 연결을 아예 차단할 수 있다.</li>
<li><strong>heartbeat는 브라우저가 인식 가능한 방식(<code>event + data</code>)으로 명시적으로 보내야 한다.</strong></li>
</ul>
<p>처음엔 명확한 로그도 없이 애매하게 끊기고, <code>curl</code>에서는 잘 되던 탓에
문제를 좁히는 데 시간이 오래 걸렸지만,
오히려 그 과정을 통해 SSE의 구조와 동작 원리를 더 깊이 이해할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[임시]]></title>
            <link>https://velog.io/@happy_code/%EC%9E%84%EC%8B%9C</link>
            <guid>https://velog.io/@happy_code/%EC%9E%84%EC%8B%9C</guid>
            <pubDate>Sun, 18 May 2025 13:45:47 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[JavaMailSender에서 설정값이 null로 인식된 원인과 해결]]></title>
            <link>https://velog.io/@happy_code/JavaMailSender%EC%97%90%EC%84%9C-%EC%84%A4%EC%A0%95%EA%B0%92%EC%9D%B4-null%EB%A1%9C-%EC%9D%B8%EC%8B%9D%EB%90%9C-%EC%9B%90%EC%9D%B8%EA%B3%BC-%ED%95%B4%EA%B2%B0-rjfb6u23</link>
            <guid>https://velog.io/@happy_code/JavaMailSender%EC%97%90%EC%84%9C-%EC%84%A4%EC%A0%95%EA%B0%92%EC%9D%B4-null%EB%A1%9C-%EC%9D%B8%EC%8B%9D%EB%90%9C-%EC%9B%90%EC%9D%B8%EA%B3%BC-%ED%95%B4%EA%B2%B0-rjfb6u23</guid>
            <pubDate>Sun, 13 Apr 2025 07:32:39 GMT</pubDate>
            <description><![CDATA[<h3 id="🧩-문제-상황">🧩 문제 상황</h3>
<p>이메일 인증 기능을 구현하기 위해 Gmail SMTP 설정을 <code>application-secret.yml</code>에 저장했다.  </p>
<pre><code class="language-yaml">spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: coldrice99@gmail.com
    password: my-app-password
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true</code></pre>
<p>스프링 프로파일도 정상적으로 지정한 상태였다.  </p>
<pre><code class="language-yaml"># application.yml
spring:
  profiles:
    active: local
    include: secret</code></pre>
<p>✅ 실행 시 출력도 분명히 두 개의 profile이 활성화된 것으로 확인됨:</p>
<pre><code>The following 2 profiles are active: &quot;secret&quot;, &quot;local&quot;</code></pre><p>그런데도 <strong>메일 발송 기능은 항상 실패</strong>하고 콘솔에는 아래와 같은 로그가 출력되었다.</p>
<pre><code>✅ 메일 설정 확인:
➡ Host = null
➡ Port = -1
➡ Username = null</code></pre><h3 id="🔍-잘못된-진단">🔍 잘못된 진단</h3>
<ul>
<li>처음에는 <code>application-secret.yml</code>이 제대로 로딩되지 않는다고 판단하고 프로파일 설정을 수차례 확인</li>
<li>yml 구조, 들여쓰기, 파일 위치, 프로파일 명칭 등 다양한 부분을 의심하며 불필요한 수정들을 반복함</li>
<li>하지만 설정 로딩 자체는 전혀 문제가 없었음</li>
</ul>
<hr>
<h3 id="💡-진짜-원인-javamailsender를-직접-bean-등록하지-않았기-때문">💡 진짜 원인: <code>JavaMailSender</code>를 직접 Bean 등록하지 않았기 때문</h3>
<p>Spring Boot는 <code>JavaMailSender</code>를 자동 구성해주지만, <strong>조건부 자동 설정(@ConditionalOnMissingBean)</strong>이기 때문에<br><strong>명시적인 Bean 등록을 하지 않으면, 해당 설정을 사용하는 시점까지 mail 관련 속성을 로딩하지 않음.</strong></p>
<blockquote>
<p>즉, application.yml에 mail 속성이 들어 있어도, 그걸 사용할 JavaMailSender가 없으면 아무 일도 일어나지 않음.</p>
</blockquote>
<hr>
<h3 id="✅-최종-해결-방법">✅ 최종 해결 방법</h3>
<p><code>JavaMailSender</code>를 직접 Bean으로 등록하고, 설정 값을 주입받도록 구성했다.</p>
<pre><code class="language-java">@Configuration
public class MailConfig {

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

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

    @Value(&quot;${spring.mail.username}&quot;)
    private String username;

    @Value(&quot;${spring.mail.password}&quot;)
    private String password;

    @Bean
    public JavaMailSender javaMailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(host);
        mailSender.setPort(port);
        mailSender.setUsername(username);
        mailSender.setPassword(password);

        Properties props = mailSender.getJavaMailProperties();
        props.put(&quot;mail.smtp.auth&quot;, &quot;true&quot;);
        props.put(&quot;mail.smtp.starttls.enable&quot;, &quot;true&quot;);

        return mailSender;
    }
}</code></pre>
<p>이후 정상적으로 아래와 같은 로그가 출력되었고, <strong>이메일도 성공적으로 발송</strong>되었다.</p>
<pre><code>✅ 메일 설정 확인:
➡ Host = smtp.gmail.com
➡ Port = 587
➡ Username = coldrice99@gmail.com</code></pre><hr>
<h3 id="✍️-마무리">✍️ 마무리</h3>
<p>이번 경험을 통해 Spring Boot의 <strong>자동 설정은 무조건 되는 것이 아니라 조건부로 작동</strong>함을 다시금 깨달았다.<br>특히 외부 설정값(yml)에만 집중하다 보면, 실제로 그것을 <strong>사용하는 객체가 없어서</strong> 생기는 문제를 놓칠 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[equals() 미구현으로 발생한 JPA Entity 비교 오류]]></title>
            <link>https://velog.io/@happy_code/equals-%EB%AF%B8%EA%B5%AC%ED%98%84%EC%9C%BC%EB%A1%9C-%EB%B0%9C%EC%83%9D%ED%95%9C-JPA-Entity-%EB%B9%84%EA%B5%90-%EC%98%A4%EB%A5%98</link>
            <guid>https://velog.io/@happy_code/equals-%EB%AF%B8%EA%B5%AC%ED%98%84%EC%9C%BC%EB%A1%9C-%EB%B0%9C%EC%83%9D%ED%95%9C-JPA-Entity-%EB%B9%84%EA%B5%90-%EC%98%A4%EB%A5%98</guid>
            <pubDate>Sun, 30 Mar 2025 09:47:52 GMT</pubDate>
            <description><![CDATA[<p>클럽 가입 신청을 관리자가 승인하는 기능을 만들던 중, 관리자가 본인의 클럽에 대해 승인하려 했는데, <strong>&quot;유효하지 않은 클럽 관리자입니다&quot;</strong> 라는 오류가 발생했다. 분명히 로그인한 사용자는 해당 클럽의 관리자였는데도 말이다.</p>
<hr>
<h2 id="✅-문제-코드">✅ 문제 코드</h2>
<p>아래는 클럽 관리자인지 검증하는 로직이다:</p>
<pre><code class="language-java">if (!club.getManager().equals(manager)) {
    throw new GlobalException(ExceptionCode.UNAUTHORIZED_MANAGER);
}</code></pre>
<p>로그를 찍어보면 다음과 같았다:</p>
<pre><code class="language-java">System.out.println(&quot;club.getManager() = &quot; + club.getManager().getId());
System.out.println(&quot;requesting manager = &quot; + manager.getId());</code></pre>
<p>📋 출력 결과:</p>
<pre><code>club.getManager() = 2
requesting manager = 2</code></pre><p><strong>두 ID는 분명히 같지만, equals는 false를 반환하여 예외가 발생</strong>하고 있었다.</p>
<hr>
<h2 id="🔍-원인-분석">🔍 원인 분석</h2>
<p>JPA에서 <code>equals()</code>를 오버라이드하지 않으면, 기본적으로 <code>Object.equals()</code>가 사용된다. 이는 <strong>객체의 참조(주소)가 같은지</strong>를 비교하기 때문에, <strong>같은 ID를 가진 Member라도 다른 영속성 컨텍스트에서 가져온 객체는 다르다고 판단</strong>한다.</p>
<p>즉, 다음과 같은 상황이 되는 것이다:</p>
<pre><code class="language-java">Member managerFromClub = club.getManager(); // 영속성 컨텍스트 A
Member currentUser = userDetails.getMember(); // 영속성 컨텍스트 B

managerFromClub.equals(currentUser); // false → 예외 발생</code></pre>
<hr>
<h2 id="✅-해결-방법">✅ 해결 방법</h2>
<p><code>Member</code> 엔티티에 <code>equals()</code>와 <code>hashCode()</code>를 오버라이드하여 <strong>ID 기준으로 논리적 동일성</strong>을 판단하도록 수정했다:</p>
<pre><code class="language-java">@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Member member = (Member) o;
    return id != null &amp;&amp; id.equals(member.id);
}

@Override
public int hashCode() {
    return getClass().hashCode();
}</code></pre>
<p>🔎 이렇게 하면 ID만 같아도 <code>equals()</code>가 true로 판단되어 로직이 제대로 작동한다. 위 코드는 JPA에서 권장하는 패턴이며, Hibernate 공식 문서에서도 소개된 방식이다.</p>
<hr>
<h2 id="📌-결론">📌 결론</h2>
<ul>
<li>인증/인가 관련 로직에서 equals로 객체 비교하는 경우 주의해야 한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[FairLock을 활용한 순서 보장 확인]]></title>
            <link>https://velog.io/@happy_code/FairLock%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%88%9C%EC%84%9C-%EB%B3%B4%EC%9E%A5-%ED%99%95%EC%9D%B8</link>
            <guid>https://velog.io/@happy_code/FairLock%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%88%9C%EC%84%9C-%EB%B3%B4%EC%9E%A5-%ED%99%95%EC%9D%B8</guid>
            <pubDate>Fri, 20 Dec 2024 04:30:47 GMT</pubDate>
            <description><![CDATA[<h4 id="문제-상황">문제 상황</h4>
<p>처음에는 Redis <code>getFairLock</code>을 사용하여 처리 순서를 보장하려 했으나, 테스트 로그를 통해 순서가 보장되지 않는다고 잘못 판단했다. 이로 인해 <code>FairLock</code>은 성능만 저하시킨다고 생각해 사용을 배제했다.<br>그러나 서비스 로그를 통해 <strong>Redis Fair Lock이 요청 순서를 정확히 보장하고 있었음</strong>을 뒤늦게 확인했다.</p>
<hr>
<h3 id="트러블슈팅-과정">트러블슈팅 과정</h3>
<h4 id="1-초기-테스트-로그와-문제점">1. 초기 테스트 로그와 문제점</h4>
<p>초기 테스트 코드는 <strong>스레드 시작 및 완료 로그</strong>를 출력하여 처리 순서를 확인했다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;Redis 락 동시성 제어 테스트&quot;)
void redisLockConcurrencyTest() throws InterruptedException {
    for (int i = 0; i &lt; threadCount; i++) {
        int threadId = i;
        executorService.submit(() -&gt; {
            try {
                System.out.println(&quot;Thread 시작: &quot; + Thread.currentThread().getId());
                Member threadMember = new Member(&quot;Test User&quot; + threadId, &quot;test&quot; + threadId + &quot;@example.com&quot;,
                    &quot;password&quot;, MemberRole.USER);
                memberRepository.save(threadMember);
                couponService.issueCoupon(coupon.getId(), new RequestDto(threadMember.getId()), threadMember);
            } catch (GlobalException e) {
                System.out.println(&quot;Thread 예외: &quot; + Thread.currentThread().getId() + &quot;, &quot; + e.getMessage());
            } finally {
                latch.countDown();
                System.out.println(&quot;Thread 완료: &quot; + Thread.currentThread().getId());
            }
        });
    }
}</code></pre>
<p>테스트 로그 결과:</p>
<pre><code class="language-plaintext">Thread 시작: 70
Thread 시작: 63
Thread 시작: 61
...
Thread 완료: 61
Thread 완료: 70
Thread 완료: 63
...</code></pre>
<p><strong>문제점</strong>: 이 로그를 기반으로 <code>FairLock</code>이 제대로 동작하지 않는다고 판단했다.<br>그러나 <strong>테스트 코드의 <code>Thread 시작</code> 로그는 실제 락 처리 순서를 반영하지 않았다.</strong></p>
<hr>
<h4 id="2-서비스-로그를-통한-검증">2. 서비스 로그를 통한 검증</h4>
<p>서비스 코드에서 실제로 락을 대기하고 획득하는 부분에 로그를 추가해 검증했다.</p>
<pre><code class="language-java">@Transactional
public void issueCoupon(Long couponId, RequestDto request, Member member) {
    String lockKey = &quot;coupon:&quot; + couponId;
    RLock lock = redissonClient.getFairLock(lockKey);

    try {
        System.out.println(&quot;대기중: &quot; + Thread.currentThread().getId());
        if (!lock.tryLock(1, 10, TimeUnit.SECONDS)) {
            System.out.println(&quot;Thread &quot; + Thread.currentThread().getId() + &quot;락 획득 실패.&quot;);
            throw new IllegalStateException(&quot;동시에 너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.&quot;);
        }
        System.out.println(&quot;Thread &quot; + Thread.currentThread().getId() + &quot;락 획득.&quot;);

        // 비즈니스 로직 처리
        Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow(() -&gt; new GlobalException(COUPON_NOT_FOUND));
        coupon.validateQuantity();
        coupon.decreaseQuantity();

        Issuance issuance = Issuance.builder()
            .member(member)
            .coupon(coupon)
            .build();
        issuanceRepository.save(issuance);

    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
            System.out.println(&quot;Thread &quot; + Thread.currentThread().getId() + &quot; released lock.&quot;);
        }
    }
}</code></pre>
<hr>
<h4 id="3-로그-결과를-통한-순서-보장-확인">3. 로그 결과를 통한 순서 보장 확인</h4>
<p><strong>대기 중 로그</strong>와 <strong>락 획득 및 해제 로그</strong>를 비교해 <code>FairLock</code>이 요청 순서를 정확히 보장하고 있음을 확인했다.</p>
<p><img src="https://velog.velcdn.com/images/happy_code/post/fe8788bf-7626-486f-9fad-178a2cf182ca/image.png" alt=""></p>
<ol>
<li><p><strong>대기 중 순서</strong>:</p>
<pre><code>63, 71, 65, 70, 62, 61, 66, 64, 68, 67, 103, 98, 139...</code></pre></li>
<li><p><strong>락 획득 순서</strong>:</p>
<pre><code>70, 63, 61, 65, 64, 71, 62, 66, 68, 67, 103, 98, 139...</code></pre></li>
</ol>
<p><strong>분석</strong>:</p>
<ul>
<li>초기 몇 개의 스레드는 락 대기 순서와 락 획득 순서가 불일치했으나, 68번 스레드 이후부터는 <strong>락 대기 순서대로 락을 획득</strong>하고 있었다.</li>
<li>초기 순서 불일치는 <strong>스레드 스케줄링의 비결정성</strong>과 <strong>로그 출력 시점과 락 처리의 비동기적 특성</strong>에 의해 발생한 것이다.</li>
</ul>
<hr>
<h3 id="결론">결론</h3>
<ol>
<li><p><strong>Redis Fair Lock은 요청 순서를 보장</strong>:</p>
<ul>
<li><code>FairLock</code>은 Redis 대기열을 기반으로 락 획득 순서를 보장한다는 점이 확인됐다.</li>
<li>초기 혼란은 테스트 로그가 실제 순서를 정확히 반영하지 않았기 때문이었다.</li>
</ul>
</li>
<li><p><strong>초기 순서 불일치는 자연스러운 현상</strong>:</p>
<ul>
<li>스레드 스케줄링과 Redis 통신의 비동기적 특성으로 인해, 초기 몇 개의 스레드에서는 대기 순서와 락 획득 순서가 다를 수 있다.</li>
<li>그러나 이후부터는 락 대기열에 따라 순서가 명확히 유지된다.</li>
</ul>
</li>
<li><p><strong><code>FairLock</code> 사용 유지 결정</strong>:</p>
<ul>
<li><code>FairLock</code>을 사용하면 성능이 약간 저하될 수 있지만, 요청 순서가 중요한 경우에는 반드시 사용해야 한다는 결론을 내렸다.</li>
</ul>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis Lock 요청 순서 처리 트러블슈팅]]></title>
            <link>https://velog.io/@happy_code/Redis-Lock-%EC%9A%94%EC%B2%AD-%EC%88%9C%EC%84%9C-%EC%B2%98%EB%A6%AC-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85%EC%8B%A4%ED%8C%A8</link>
            <guid>https://velog.io/@happy_code/Redis-Lock-%EC%9A%94%EC%B2%AD-%EC%88%9C%EC%84%9C-%EC%B2%98%EB%A6%AC-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85%EC%8B%A4%ED%8C%A8</guid>
            <pubDate>Fri, 20 Dec 2024 02:21:33 GMT</pubDate>
            <description><![CDATA[<h3 id="문제-상황">문제 상황</h3>
<p>쿠폰 발급 API에서 다수의 요청이 동시에 들어왔을 때, <strong>동시성 문제</strong>와 <strong>요청 순서 처리</strong> 문제가 발생했다. 초기 코드는 Redis의 <code>RLock</code>을 사용하여 동시성 문제를 해결했으나, 요청을 <strong>순서대로 처리</strong>하는 데 실패했다. 이를 해결하기 위해 다양한 방법을 시도했으나, 동시에 들어오는 요청의 순서 처리에 <strong>완벽한 보장</strong>을 구현하기 어렵다는 결론을 지었다.</p>
<hr>
<h2 id="트러블슈팅-과정">트러블슈팅 과정</h2>
<h3 id="1-초기-코드와-문제점">1. 초기 코드와 문제점</h3>
<h4 id="초기-코드">초기 코드</h4>
<pre><code class="language-java">@Transactional
public void issueCoupon(Long couponId, RequestDto request, Member member) {
    // Redis 락
    String lockKey = &quot;coupon:&quot; + couponId;
    RLock lock = redissonClient.getLock(lockKey);

    try {
        // 락 획득 시도 (최대 1초 대기, 10초 유지)
        if (!lock.tryLock(1, 10, TimeUnit.SECONDS)) {
            throw new IllegalStateException(&quot;동시에 너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.&quot;);
        }

        Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow(() -&gt; new GlobalException(COUPON_NOT_FOUND));

        if (issuanceRepository.findByMemberIdAndCouponId(member.getId(), couponId).isPresent()) {
            throw new GlobalException(COUPON_ALREADY_ISSUED);
        }

        coupon.validateQuantity();
        coupon.decreaseQuantity();

        Issuance issuance = Issuance.builder()
            .member(member)
            .coupon(coupon)
            .build();
        issuanceRepository.save(issuance);
    } catch (InterruptedException e) {
        throw new IllegalArgumentException(&quot;Redis 락을 획득하는데 실패했습니다.&quot;, e);
    } finally {
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
            @Override
            public void afterCompletion(int status) {
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        });
    }
}</code></pre>
<h4 id="문제점">문제점</h4>
<ol>
<li><strong>동시성 제어</strong>: <code>RLock</code>으로 처리되어 문제없음.</li>
<li><strong>요청 순서 처리 실패</strong>: 동시에 여러 요청이 들어왔을 때, 요청 순서대로 처리되지 않음.</li>
</ol>
<hr>
<h3 id="2-요청-순서-처리-시도">2. 요청 순서 처리 시도</h3>
<h4 id="21-redis-queue를-활용한-요청-순서-처리">2.1 Redis Queue를 활용한 요청 순서 처리</h4>
<ul>
<li><strong>아이디어</strong>: Redis List 자료구조에 요청을 순차적으로 추가하고, 본인의 순서가 되면 실행.</li>
<li><strong>코드</strong>:<pre><code class="language-java">String queueKey = &quot;coupon:queue:&quot; + couponId;
redisTemplate.opsForList().rightPush(queueKey, threadId);
</code></pre>
</li>
</ul>
<p>while (true) {
    String currentThreadId = redisTemplate.opsForList().index(queueKey, 0);
    if (threadId.equals(currentThreadId)) {
        break;
    }
    Thread.sleep(50); // 자신의 순서가 아닐 경우 대기
}</p>
<pre><code>- **문제점**:
  - 요청 간 간격이 매우 짧을 경우 **동시에 여러 요청이 추가**되어 순서 꼬임.
  - Redis 외부에서의 동시성 문제로 인해 요청 간 지연 발생.

#### 2.2 Lua Script를 활용한 요청 순서 확인
- **아이디어**: Redis Lua Script를 활용해 현재 큐의 첫 번째 요청인지 확인.
- **코드**:
```java
String luaScript = &quot;&quot;&quot;
    local sortedSetKey = KEYS[1]
    local memberId = ARGV[1]
    local timestamp = ARGV[2]

    redis.call(&#39;ZADD&#39;, sortedSetKey, timestamp, memberId)
    local minMember = redis.call(&#39;ZRANGE&#39;, sortedSetKey, 0, 0)[1]

    if minMember == memberId then
        return true
    else
        return false
    end
&quot;&quot;&quot;;

Boolean isEligible = redisTemplate.execute(
    script,
    Collections.singletonList(sortedSetKey),
    String.valueOf(member.getId()), String.valueOf(uniqueTimestamp)
);</code></pre><ul>
<li><strong>문제점</strong>:<ul>
<li>Lua Script는 Redis 서버에서 원자적으로 실행되지만, <strong>Redis 외부의 동시성 문제</strong>를 해결하지 못함.</li>
<li>요청의 <code>timestamp</code>가 거의 동일하거나 충돌할 경우, 요청 순서가 어긋남.</li>
</ul>
</li>
</ul>
<h4 id="23-redis-sorted-set을-활용한-요청-순서-처리">2.3 Redis Sorted Set을 활용한 요청 순서 처리</h4>
<ul>
<li><strong>아이디어</strong>: Redis Sorted Set에 요청을 추가하고, 최소 점수를 가진 요청만 실행.</li>
<li><strong>문제점</strong>:<ul>
<li><code>System.nanoTime()</code> 기반 <code>timestamp</code> 값이 충돌할 가능성 있음.</li>
<li>Redis Sorted Set은 점수가 동일할 경우 <strong>memberId의 사전순 정렬</strong>을 따르므로, 예상과 다른 순서로 실행.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="3-요청-순서-처리-실패-원인-분석">3. 요청 순서 처리 실패 원인 분석</h3>
<h4 id="31-redis와-lua-script의-한계">3.1 Redis와 Lua Script의 한계</h4>
<ul>
<li>Redis는 매우 빠른 속도를 제공하지만, <strong>외부의 동시성 문제</strong>를 제어하지 못함.</li>
<li>Lua Script가 Redis 서버 내에서 원자적으로 실행되더라도, <strong>응답 간 시간 차</strong>로 인해 외부 요청의 순서가 엉킬 수 있음.</li>
</ul>
<h4 id="32-점수-충돌로-인한-정렬-실패">3.2 점수 충돌로 인한 정렬 실패</h4>
<ul>
<li>Redis Sorted Set에서 점수가 동일할 경우, <strong>memberId를 기준으로 사전순 정렬</strong>이 수행됨.</li>
<li><code>System.nanoTime()</code> 값을 사용해 점수를 생성했지만, 요청 간 간격이 매우 짧아 충돌 발생.</li>
</ul>
<h4 id="33-현실적인-요청-간-간격-문제">3.3 현실적인 요청 간 간격 문제</h4>
<ul>
<li>테스트 환경에서는 <code>Thread.sleep(20)</code>으로 간격을 강제 조정해 요청 순서를 처리했으나, 이는 실제 서비스 환경에서 비현실적.</li>
</ul>
<hr>
<h3 id="4-최종-결론-거의-동시에-들어오는-요청에-대한-순서-처리-포기">4. 최종 결론: 거의 동시에 들어오는 요청에 대한 순서 처리 포기</h3>
<ol>
<li><strong>이유</strong></li>
</ol>
<ul>
<li>Redis의 기본 데이터 구조와 Lua Script를 활용해 여러 시도를 했지만, 거의 동시에 들어오는 요청의 순서 보장은 현실적으로 어렵다는 결론에 도달.</li>
<li>요청 간 간격이 거의 없어 점수 충돌과 정렬 문제가 발생.</li>
</ul>
<ol start="2">
<li><strong>현실적인 해결책</strong><ul>
<li>테스트에서 요청 간에 약간의 시간 간격(<code>Thread.sleep(20);</code>, 약 0.02초)을 두니 요청 순서가 보장되었음.</li>
<li>따라서 <strong>0.02초 이상의 간격</strong>을 두고 요청이 들어오는 경우, 요청 순서대로 처리가 되는 것을 확인.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="5-테스트-코드">5. 테스트 코드</h3>
<pre><code class="language-java">@Test
@DisplayName(&quot;Redis 락 동시성 제어 &amp; 요청 순서 처리 테스트&quot;)
void redisLockConcurrencyTest() throws InterruptedException {
    for (int i = 0; i &lt; threadCount; i++) {
        int threadId = i;
        Thread.sleep(20); // 20ms 간격
        executorService.submit(() -&gt; {
            try {
                System.out.println(&quot;Thread 시작: &quot; + Thread.currentThread().getId());
                Member threadMember = new Member(&quot;Test User&quot; + threadId, &quot;test&quot; + threadId + &quot;@example.com&quot;,
                    &quot;password&quot;, MemberRole.USER);
                memberRepository.save(threadMember);
                couponService.issueCoupon(coupon.getId(), new RequestDto(threadMember.getId()), threadMember);
            } catch (GlobalException e) {
                System.out.println(&quot;Thread 예외: &quot; + Thread.currentThread().getId() + &quot;, &quot; + e.getMessage());
            } finally {
                latch.countDown();
                System.out.println(&quot;Thread 완료: &quot; + Thread.currentThread().getId());
            }
        });
    }

    latch.await();

    // 결과 검증
    Coupon updatedCoupon = couponRepository.findById(coupon.getId()).orElseThrow();
    System.out.println(&quot;남은 쿠폰 수량: &quot; + updatedCoupon.getQuantity());
    System.out.println(&quot;발급된 쿠폰 수량: &quot; + issuanceRepository.count());

    assertEquals(0, updatedCoupon.getQuantity());
    assertEquals(100, issuanceRepository.count());
}</code></pre>
<hr>
<h3 id="테스트-결과">테스트 결과</h3>
<h4 id="요청-순서-처리-실패-거의-동시에-요청">요청 순서 처리 실패 (거의 동시에 요청)</h4>
<p><img src="https://velog.velcdn.com/images/happy_code/post/9019438a-0325-44b6-a67f-d6fa73dbca80/image.png" alt=""></p>
<h4 id="002초-간격-추가-후-요청-순서-처리-성공">0.02초 간격 추가 후 요청 순서 처리 성공</h4>
<p><img src="https://velog.velcdn.com/images/happy_code/post/f5bfb63d-a120-4d02-84cc-9bba9ed9fcc7/image.png" alt="">
<img src="https://velog.velcdn.com/images/happy_code/post/d968894f-baa3-4d68-8f5a-af4b5a2c5bb7/image.png" alt=""></p>
<hr>
<h3 id="향후-개선-방향">향후 개선 방향</h3>
<ol>
<li><strong>메시지 큐 도입</strong>:<ul>
<li>요청 순서가 중요한 경우 Kafka나 RabbitMQ 같은 메시지 큐를 고려.</li>
</ul>
</li>
<li><strong>Redis 활용의 한계 인식</strong>:<ul>
<li>Redis는 주로 간단한 동시성 제어 및 캐싱에 적합하며, 복잡한 요청 순서 처리는 별도의 도구로 해결 필요.</li>
</ul>
</li>
</ol>
<hr>
<p>다음 포스트에서 FairLock을 사용하여 간단하게 순서가 보장하는 것을 성공
<a href="https://velog.io/@happy_code/FairLock%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%88%9C%EC%84%9C-%EB%B3%B4%EC%9E%A5-%ED%99%95%EC%9D%B8">https://velog.io/@happy_code/FairLock%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%88%9C%EC%84%9C-%EB%B3%B4%EC%9E%A5-%ED%99%95%EC%9D%B8</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[비관적 락, 레디스 락, 낙관적 락 쿠폰 발급 테스트 비교 분석]]></title>
            <link>https://velog.io/@happy_code/%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%A0%88%EB%94%94%EC%8A%A4-%EB%9D%BD-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B9%84%EA%B5%90-%EB%B6%84%EC%84%9D</link>
            <guid>https://velog.io/@happy_code/%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%A0%88%EB%94%94%EC%8A%A4-%EB%9D%BD-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B9%84%EA%B5%90-%EB%B6%84%EC%84%9D</guid>
            <pubDate>Thu, 19 Dec 2024 06:33:29 GMT</pubDate>
            <description><![CDATA[<h3 id="1-테스트-환경"><strong>1. 테스트 환경</strong></h3>
<ul>
<li><strong>테스트 도구</strong>: <strong>nGrinder</strong>  </li>
<li><strong>테스트 스크립트</strong>: 로그인 후 쿠폰 발급 API 호출 (동일한 API 테스트)  </li>
<li><strong>Vuser 수</strong>: <strong>99명</strong> → 99명의 가상 사용자가 동시에 부하를 발생시킴  </li>
<li><strong>Processes/Threads</strong>: <strong>3 프로세스</strong>, 각 프로세스당 <strong>33 쓰레드</strong>  </li>
<li><strong>Duration</strong>: <strong>1분</strong> → 테스트는 1분간 진행  </li>
<li><strong>Ramp-Up</strong>: 비활성화 (즉시 99명의 사용자 부하 발생)  </li>
<li><strong>테스트 조건</strong>:  <ul>
<li><strong>비관적 락</strong>: 데이터베이스 수준에서 락 관리  </li>
<li><strong>레디스 락</strong>: <strong>RedissonClient</strong>를 이용한 분산 락 관리  </li>
<li><strong>낙관적 락</strong>: @Retryable을 활용하여 낙관적 락 충돌 시 재시도  </li>
</ul>
</li>
</ul>
<hr>
<h3 id="2-결과-비교"><strong>2. 결과 비교</strong></h3>
<table>
<thead>
<tr>
<th><strong>지표</strong></th>
<th><strong>비관적 락</strong></th>
<th><strong>레디스 락</strong></th>
<th><strong>낙관적 락</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>TPS (초당 처리율)</strong></td>
<td><strong>90.5</strong></td>
<td><strong>3,938.0</strong></td>
<td><strong>94.8</strong></td>
</tr>
<tr>
<td><strong>Peak TPS (최대 TPS)</strong></td>
<td><strong>117</strong></td>
<td><strong>4,811</strong></td>
<td><strong>115</strong></td>
</tr>
<tr>
<td><strong>Mean Test Time</strong></td>
<td><strong>1,089.81 ms</strong></td>
<td><strong>22.95 ms</strong></td>
<td><strong>1,043.60 ms</strong></td>
</tr>
<tr>
<td><strong>Executed Tests</strong></td>
<td><strong>4,958</strong></td>
<td><strong>214,969</strong></td>
<td><strong>5,176</strong></td>
</tr>
<tr>
<td><strong>Successful Tests</strong></td>
<td><strong>4,958</strong></td>
<td><strong>214,969</strong></td>
<td><strong>5,176</strong></td>
</tr>
<tr>
<td><strong>Errors</strong></td>
<td><strong>0</strong></td>
<td><strong>0</strong></td>
<td><strong>0</strong></td>
</tr>
</tbody></table>
<hr>
<h3 id="3-성능-분석"><strong>3. 성능 분석</strong></h3>
<h4 id="비관적-락"><strong>비관적 락</strong></h4>
<p><img src="https://velog.velcdn.com/images/happy_code/post/8d22c4c5-9095-4f51-9051-367f4b980c7f/image.png" alt=""></p>
<ul>
<li><strong>TPS</strong>: <strong>90.5</strong>  </li>
<li><strong>Mean Test Time</strong>: <strong>1,089.81 ms</strong>  </li>
</ul>
<p>비관적 락은 데이터베이스 수준에서 자원에 대한 락을 독점하기 때문에 <strong>경합</strong>이 심해질수록 대기 시간이 길어지고, 동시 요청을 처리하는 데 병목이 발생.  </p>
<ul>
<li>약 <strong>5,000건의 요청</strong>만 성공적으로 처리되었으며, 초당 처리량이 <strong>90~117 TPS</strong>로 제한.</li>
</ul>
<p><strong>주요 원인</strong>:  </p>
<ul>
<li>락을 획득한 트랜잭션이 완료될 때까지 다른 쓰레드가 대기해야 함.  </li>
</ul>
<hr>
<h4 id="레디스-락"><strong>레디스 락</strong></h4>
<p><img src="https://velog.velcdn.com/images/happy_code/post/c5c3f789-758e-44c1-a89d-b82ab37cdbf7/image.png" alt=""></p>
<ul>
<li><strong>TPS</strong>: <strong>3,938.0</strong>  </li>
<li><strong>Mean Test Time</strong>: <strong>22.95 ms</strong>  </li>
</ul>
<p>레디스 락은 <strong>Redis</strong>의 분산 락 기능을 활용하여 더 빠르게 락을 관리하고 해제. 트랜잭션 부담이 줄어들어 더 많은 동시 요청을 효율적으로 처리.  </p>
<ul>
<li>약 <strong>214,969건의 요청</strong>을 처리하였으며, 초당 처리량은 <strong>3,938 TPS</strong>에 달한다.  </li>
</ul>
<p><strong>주요 원인</strong>:  </p>
<ul>
<li>레디스 락은 락의 획득/해제가 비동기적이고 빠르게 이루어지며, 데이터베이스에 직접 락을 걸지 않음.  </li>
</ul>
<hr>
<h4 id="낙관적-락"><strong>낙관적 락</strong></h4>
<p><img src="https://velog.velcdn.com/images/happy_code/post/6d6d213f-9ca7-4864-a20d-06b962223b7c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/happy_code/post/13309a6f-4258-4821-86b4-56e6277a6704/image.png" alt=""></p>
<ul>
<li><strong>TPS</strong>: <strong>94.8</strong>  </li>
<li><strong>Mean Test Time</strong>: <strong>1,043.60 ms</strong>  </li>
</ul>
<p>낙관적 락은 트랜잭션 시작 시에는 락을 걸지 않고, 커밋 단계에서만 충돌을 감지. 충돌 발생 시 <strong>@Retryable</strong>을 활용해 재시도 로직을 처리.  </p>
<ul>
<li>초당 처리량은 약 <strong>94.8 TPS</strong>로 비관적 락과 유사하나, 평균 응답 시간이 소폭 개선됨.  </li>
</ul>
<p><strong>재시도 관련 데이터</strong>:</p>
<ul>
<li>최대 재시도 횟수: <strong>62회</strong>  </li>
</ul>
<p><strong>주요 원인</strong>:  </p>
<ul>
<li>데이터베이스에 락을 걸지 않고도 충돌을 효율적으로 처리.  </li>
<li>하지만 충돌이 잦을 경우 재시도 로직으로 인해 TPS가 제한적.  </li>
</ul>
<hr>
<h3 id="4-결론"><strong>4. 결론</strong></h3>
<ul>
<li><strong>레디스 락</strong>은 <strong>비관적 락</strong>과 <strong>낙관적 락</strong> 대비 <strong>성능이 월등히 우수</strong>합니다.  </li>
<li>특히 <strong>TPS</strong>와 <strong>평균 응답 시간</strong>에서 큰 차이를 보이며, 동시 요청이 많은 환경에서 레디스 락이 훨씬 더 적합.  </li>
<li><strong>낙관적 락</strong>은 비관적 락과 유사한 성능을 보이지만, 재시도 로직으로 인해 시스템 자원 사용량이 다소 증가할 수 있음.  </li>
</ul>
<p><strong>비관적 락</strong>은 데이터 무결성이 절대적으로 중요한 시스템에 적합. |
<strong>레디스 락</strong>은 동시성이 중요한 대규모 시스템에서 성능 병목을 해소하는 데 효과적. ✅
<strong>낙관적 락</strong>은 경합이 적은 환경에서 데이터 처리 성능을 높이는 데 유리. |</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[낙관적 락 동시성 제어 이슈와 해결 과정]]></title>
            <link>https://velog.io/@happy_code/%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%EC%9D%B4%EC%8A%88%EC%99%80-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@happy_code/%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%EC%9D%B4%EC%8A%88%EC%99%80-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Thu, 19 Dec 2024 05:57:53 GMT</pubDate>
            <description><![CDATA[<h4 id="문제-상황">문제 상황</h4>
<p>낙관적 락(Optimistic Lock)을 적용하여 동시성 제어를 시도했지만, 버전 충돌이 발생했을 때 재시도 로직이 제대로 작동하지 않음. 재시도가 실행되어야 하는 상황에서 <strong>기타 예외</strong>가 발생하며 스레드가 종료되는 현상을 겪음.</p>
<hr>
<h4 id="이슈-발견-및-원인-분석">이슈 발견 및 원인 분석</h4>
<ol>
<li><p><strong>기타 예외 로그 분석</strong><br>로그에 출력된 메시지:</p>
<pre><code>기타 예외 발생: Row was updated or deleted by another transaction</code></pre><p>이 로그를 보고 처음에는 낙관적 락이 제대로 작동하지 않는 것으로 판단. 그러나 실제로는 <strong>버전 충돌(Exception)</strong>이 잘못 감지되고 있었음.</p>
</li>
<li><p><strong>Exception 감지 문제</strong><br>버전 충돌(Exception)은 <code>OptimisticEntityLockException</code>으로 감지될 것으로 예상했으나, 실제로는 <strong><code>ObjectOptimisticLockingFailureException</code></strong>로 발생하는 것을 확인. 따라서 잘못된 예외를 처리하려다 보니 재시도 로직이 실행되지 않고 종료된 것이 문제의 원인이었음.</p>
</li>
</ol>
<hr>
<h4 id="해결-과정">해결 과정</h4>
<ol>
<li><p><strong>올바른 예외 처리 적용</strong></p>
<ul>
<li>낙관적 락에서 발생하는 <strong><code>ObjectOptimisticLockingFailureException</code></strong>을 감지하도록 예외 처리 로직을 수정.</li>
</ul>
</li>
<li><p><strong>재시도 로직 구현</strong></p>
<ul>
<li>재시도를 수동으로 처리하는 <code>while</code> 루프 대신, Spring의 <strong><code>@Retryable</code></strong> 어노테이션을 활용.</li>
<li><code>@Retryable</code>은 지정된 예외가 발생했을 때, 자동으로 재시도하도록 설정할 수 있음.</li>
<li>재시도 횟수를 넉넉하게 설정(예: 100회)하여 높은 동시성 환경에서도 충분히 처리 가능하도록 조정.</li>
</ul>
</li>
<li><p><strong>최종 코드</strong></p>
<pre><code class="language-java">@Transactional
@Retryable(
    value = {ObjectOptimisticLockingFailureException.class},
    maxAttempts = 100,
    backoff = @Backoff(delay = 50) // 50ms 간격으로 재시도
)
public void issueCoupon(Long couponId, RequestDto request, Member member) {
    System.out.println(&quot;재시도 횟수: &quot; + RetrySynchronizationManager.getContext().getRetryCount());

    // 디비 단에서 낙관적 락 적용
    Coupon coupon = couponRepository.findByIdWithOptimisticLock(couponId)
        .orElseThrow(() -&gt; new GlobalException(COUPON_NOT_FOUND));

    if (issuanceRepository.findByMemberIdAndCouponId(member.getId(), couponId).isPresent()) {
        throw new GlobalException(COUPON_ALREADY_ISSUED);
    }

    coupon.validateQuantity();
    coupon.decreaseQuantity();

    // 버전 업데이트
    couponRepository.saveAndFlush(coupon);

    Issuance issuance = Issuance.builder()
        .member(member)
        .coupon(coupon)
        .build();
    issuanceRepository.save(issuance);

    System.out.println(&quot;쿠폰 발급 성공. 쿠폰 버전: &quot; + coupon.getVersion());
}</code></pre>
</li>
<li><p><strong>테스트 코드 개선</strong>
재시도 횟수 확인과 동시성 제어 테스트를 위해 로그와 테스트 환경을 조정:</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;낙관적 락 동시성 제어 테스트&quot;)
void optimisticLockConcurrencyTest() throws InterruptedException {
    for (int i = 0; i &lt; threadCount; i++) {
        int threadId = i;
        executorService.submit(() -&gt; {
            try {
                System.out.println(&quot;Thread 시작: &quot; + threadId);
                Member threadMember = new Member(&quot;Test User&quot; + threadId, &quot;test&quot; + threadId + &quot;@example.com&quot;, &quot;password&quot;, MemberRole.USER);
                memberRepository.save(threadMember);

                couponService.issueCoupon(coupon.getId(), new RequestDto(threadMember.getId()), threadMember);
            } catch (Exception e) {
                System.out.println(&quot;Thread 예외: &quot; + threadId + &quot;, &quot; + e.getMessage());
            } finally {
                latch.countDown();
                System.out.println(&quot;Thread 완료: &quot; + threadId);
            }
        });
    }

    latch.await();

    // 결과 검증
    Coupon updatedCoupon = couponRepository.findById(coupon.getId()).orElseThrow();
    System.out.println(&quot;남은 쿠폰 수량: &quot; + updatedCoupon.getQuantity());
    System.out.println(&quot;발급된 쿠폰 수량: &quot; + issuanceRepository.count());
}</code></pre>
</li>
</ol>
<hr>
<h4 id="결과">결과</h4>
<ul>
<li><strong>동시성 제어 성공</strong><br>재시도 로직이 제대로 동작하면서 낙관적 락을 통한 데이터 정합성이 보장됨.</li>
<li><strong>재시도 횟수 확인</strong><br>테스트 결과 재시도 횟수는 많게는 19번까지 발생했으며, 설정된 최대 재시도 횟수 내에서 처리 성공.</li>
</ul>
<hr>
<h4 id="느낀-점">느낀 점</h4>
<ul>
<li>Spring의 <strong><code>@Retryable</code></strong>을 활용하면 수동적인 재시도 로직보다 간결하고 효과적으로 동시성 문제를 해결할 수 있음.</li>
<li>Exception 타입의 정확한 확인이 문제 해결의 열쇠였음. 추후 Exception 발생 시 로그를 꼼꼼히 살펴보고 문제를 정확히 정의하는 습관을 가져야겠다고 느꼈음.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[비관적 락과 레디스 락 쿠폰 발급 테스트 비교 분석]]></title>
            <link>https://velog.io/@happy_code/%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EA%B3%BC-%EB%A0%88%EB%94%94%EC%8A%A4-%EB%9D%BD-%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B9%84%EA%B5%90-%EB%B6%84%EC%84%9D</link>
            <guid>https://velog.io/@happy_code/%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EA%B3%BC-%EB%A0%88%EB%94%94%EC%8A%A4-%EB%9D%BD-%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B9%84%EA%B5%90-%EB%B6%84%EC%84%9D</guid>
            <pubDate>Wed, 18 Dec 2024 10:37:48 GMT</pubDate>
            <description><![CDATA[<h3 id="비관적-락과-레디스-락-쿠폰-발급-테스트-비교-분석"><strong>비관적 락과 레디스 락 쿠폰 발급 테스트 비교 분석</strong></h3>
<hr>
<h4 id="1-테스트-환경"><strong>1. 테스트 환경</strong></h4>
<ul>
<li><strong>테스트 도구</strong>: <strong>nGrinder</strong>  </li>
<li><strong>테스트 스크립트</strong>: 로그인 후 쿠폰 발급 API 호출 (동일한 API 테스트)  </li>
<li><strong>Vuser 수</strong>: <strong>99명</strong>  → 99명의 가상 사용자가 동시에 부하를 발생시킴</li>
<li><strong>Processes/Threads</strong>: <strong>3 프로세스</strong>, 각 프로세스당 <strong>33 쓰레드</strong>  </li>
<li><strong>Duration</strong>: <strong>1분</strong>  → 테스트는 1분간 진행</li>
<li><strong>Ramp-Up</strong>: 비활성화 (즉시 99명의 사용자 부하 발생)  </li>
<li><strong>테스트 조건</strong>:  <ul>
<li><strong>비관적 락</strong>: 데이터베이스 수준에서 락 관리  </li>
<li><strong>레디스 락</strong>: <strong>RedissonClient</strong>를 이용한 분산 락 관리  </li>
</ul>
</li>
</ul>
<hr>
<p><img src="https://velog.velcdn.com/images/happy_code/post/45c92362-74ce-4d1a-a3b2-e5677b8d8803/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/happy_code/post/d8281c3b-911d-4ec0-859d-fbf2c619d3a9/image.png" alt=""></p>
<h4 id="2-결과-비교"><strong>2. 결과 비교</strong></h4>
<table>
<thead>
<tr>
<th><strong>지표</strong></th>
<th><strong>비관적 락</strong></th>
<th><strong>레디스 락</strong></th>
<th><strong>비교</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>TPS (초당 처리율)</strong></td>
<td><strong>90.5</strong></td>
<td><strong>3,938.0</strong></td>
<td><strong>레디스 락이 약 43배 이상 우수</strong></td>
</tr>
<tr>
<td><strong>Peak TPS (최대 TPS)</strong></td>
<td><strong>117</strong></td>
<td><strong>4,811</strong></td>
<td><strong>레디스 락이 압도적으로 높음</strong></td>
</tr>
<tr>
<td><strong>Mean Test Time</strong></td>
<td><strong>1,089.81 ms</strong></td>
<td><strong>22.95 ms</strong></td>
<td><strong>레디스 락이 47배 빠름</strong></td>
</tr>
<tr>
<td><strong>Executed Tests</strong></td>
<td><strong>4,958</strong></td>
<td><strong>214,969</strong></td>
<td><strong>레디스 락이 약 43배 많은 요청을 처리</strong></td>
</tr>
<tr>
<td><strong>Successful Tests</strong></td>
<td><strong>4,958</strong></td>
<td><strong>214,969</strong></td>
<td><strong>모두 100% 성공</strong></td>
</tr>
<tr>
<td><strong>Errors</strong></td>
<td><strong>0</strong></td>
<td><strong>0</strong></td>
<td><strong>오류 없음</strong></td>
</tr>
</tbody></table>
<hr>
<h4 id="3-성능-분석"><strong>3. 성능 분석</strong></h4>
<h5 id="비관적-락"><strong>비관적 락</strong></h5>
<ul>
<li><strong>TPS</strong>: <strong>90.5</strong>  </li>
<li><strong>Mean Test Time</strong>: <strong>1,089.81 ms</strong>  </li>
</ul>
<p>비관적 락은 데이터베이스 수준에서 자원에 대한 락을 독점하기 때문에 <strong>경합</strong>이 심해질수록 대기 시간이 길어지고, 동시 요청을 처리하는 데 병목이 발생.  </p>
<ul>
<li>약 <strong>5,000건의 요청</strong>만 성공적으로 처리되었으며, 초당 처리량이 <strong>90~117 TPS</strong>로 제한.</li>
</ul>
<p><strong>주요 원인</strong>:  </p>
<ul>
<li>락을 획득한 트랜잭션이 완료될 때까지 다른 쓰레드가 대기해야 함.  </li>
</ul>
<hr>
<h5 id="레디스-락"><strong>레디스 락</strong></h5>
<ul>
<li><strong>TPS</strong>: <strong>3,938.0</strong>  </li>
<li><strong>Mean Test Time</strong>: <strong>22.95 ms</strong>  </li>
</ul>
<p>레디스 락은 <strong>Redis</strong>의 분산 락 기능을 활용하여 더 빠르게 락을 관리하고 해제. 트랜잭션 부담이 줄어들어 더 많은 동시 요청을 효율적으로 처리.  </p>
<ul>
<li>약 <strong>214,969건의 요청</strong>을 처리하였으며, 초당 처리량은 <strong>3,938 TPS</strong>에 달한다.  </li>
</ul>
<p><strong>주요 원인</strong>:  </p>
<ul>
<li>레디스 락은 락의 획득/해제가 비동기적이고 빠르게 이루어지며, 데이터베이스에 직접 락을 걸지 않음.  </li>
</ul>
<hr>
<h4 id="4-결론"><strong>4. 결론</strong></h4>
<ul>
<li><strong>레디스 락</strong>은 <strong>비관적 락</strong>과 비교하여 <strong>성능이 월등히 우수</strong>합니다.  </li>
<li>특히 <strong>TPS</strong>와 <strong>평균 응답 시간</strong>에서 큰 차이를 보이며, 동시 요청이 많은 환경에서 레디스 락이 훨씬 더 적합.  </li>
</ul>
<p>| <strong>결론</strong> | <strong>비관적 락</strong>은 데이터 무결성이 절대적으로 중요한 시스템에 적합하지만, 성능 병목이 발생할 수 있다. |
| <strong>레디스 락</strong>은 동시성이 중요한 대규모 시스템에서 성능 병목을 해소하는 데 효과적. ✅ |</p>
<hr>
<h4 id="5-제언"><strong>5. 제언</strong></h4>
<ul>
<li><strong>대규모 동시 요청</strong>을 처리해야 하는 시스템에서는 <strong>레디스 락</strong>을 사용하는 것이 권장.  </li>
<li>데이터 무결성이 절대적으로 필요하고 경합이 적은 시스템에서는 <strong>비관적 락</strong>을 사용할 수 있다.  </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[RedissonMultiLock과 isHeldByCurrentThread()의 문제]]></title>
            <link>https://velog.io/@happy_code/RedissonMultiLock%EA%B3%BC-isHeldByCurrentThread%EC%9D%98-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@happy_code/RedissonMultiLock%EA%B3%BC-isHeldByCurrentThread%EC%9D%98-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Tue, 17 Dec 2024 09:44:51 GMT</pubDate>
            <description><![CDATA[<h4 id="1-문제-상황"><strong>1. 문제 상황</strong></h4>
<p><strong>쿠폰 발급 기능</strong>에서 <strong>Redisson RedLock</strong>을 활용해 동시성 제어를 구현했다. 락 해제 시 중복 해제를 방지하기 위해 <code>isHeldByCurrentThread()</code>를 사용했지만, <strong>RedissonMultiLock</strong>에서 <strong><code>UnsupportedOperationException</code></strong> 예외가 발생했다.</p>
<hr>
<h4 id="2-원인-분석"><strong>2. 원인 분석</strong></h4>
<ol>
<li><p><strong>RedissonMultiLock</strong> 특성  </p>
<ul>
<li><strong>RedissonMultiLock</strong>은 여러 노드에 대한 락을 조합하여 사용하는 <strong>RedLock 구현체</strong>이다.  </li>
<li>개별 노드의 락을 관리하므로 <strong>단일 노드의 상태</strong>를 검사하는 <code>isHeldByCurrentThread()</code> 메서드를 <strong>지원하지 않는다</strong>.</li>
</ul>
</li>
<li><p><strong><code>isHeldByCurrentThread()</code></strong> 호출 시 문제  </p>
<ul>
<li><code>isHeldByCurrentThread()</code>는 <code>RedissonLock</code>에는 존재하지만, <strong><code>RedissonMultiLock</code>에서는 지원되지 않는다.</strong>  </li>
<li>이를 호출할 경우 <code>UnsupportedOperationException</code> 예외가 발생한다.  </li>
</ul>
</li>
</ol>
<p><strong>발생한 코드</strong>  </p>
<pre><code class="language-java">if (redLock.isHeldByCurrentThread()) { 
    redLock.unlock();
}</code></pre>
<p><strong>예외 로그</strong>  </p>
<pre><code>java.lang.UnsupportedOperationException: null  
    at org.redisson.RedissonMultiLock.isHeldByCurrentThread(RedissonMultiLock.java:490)</code></pre><hr>
<h4 id="3-해결-방법"><strong>3. 해결 방법</strong></h4>
<p><strong>핵심 전략</strong>:  
<strong>RedissonMultiLock</strong>에서는 <strong><code>isHeldByCurrentThread()</code>를 사용하지 말고</strong>,  
<strong>트랜잭션 종료 시점에 <code>unlock()</code>만 호출</strong>하도록 한다.</p>
<p><strong>최종 코드</strong>  </p>
<pre><code class="language-java">TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
    @Override
    public void afterCompletion(int status) {
        try {
            redLock.unlock(); // 바로 unlock 호출
            System.out.println(&quot;Thread &quot; + Thread.currentThread().getId() + &quot; released lock.&quot;);
        } catch (IllegalMonitorStateException e) {
            System.out.println(&quot;Lock already released or not held by thread.&quot;);
        } catch (Exception e) {
            System.err.println(&quot;Unexpected exception during lock release: &quot; + e.getMessage());
        }
    }
});</code></pre>
<p><strong>포인트</strong>:</p>
<ul>
<li><code>isHeldByCurrentThread()</code> 검사를 제거한다.  </li>
<li><code>unlock()</code> 호출 시 <strong>이미 해제된 락</strong>에 대한 예외(<code>IllegalMonitorStateException</code>)는 명확하게 처리한다.</li>
</ul>
<hr>
<h4 id="4-개선된-동작"><strong>4. 개선된 동작</strong></h4>
<ol>
<li><p><strong>불필요한 검증 제거</strong>  </p>
<ul>
<li><code>isHeldByCurrentThread()</code> 대신 트랜잭션 종료 시점에 <code>unlock()</code>만 호출한다.</li>
</ul>
</li>
<li><p><strong>RedissonMultiLock 호환성 유지</strong>  </p>
<ul>
<li><code>RedissonMultiLock</code>의 제한사항을 준수하면서도 안전하게 락을 해제한다.</li>
</ul>
</li>
<li><p><strong>안정적인 예외 처리</strong>  </p>
<ul>
<li>이미 해제된 락에 대한 <code>unlock()</code> 호출은 예외를 무시하도록 처리한다.</li>
</ul>
</li>
</ol>
<hr>
<h4 id="5-학습한-점"><strong>5. 학습한 점</strong></h4>
<ol>
<li><p><strong>RedissonMultiLock의 한계</strong>  </p>
<ul>
<li><code>isHeldByCurrentThread()</code>는 <code>RedissonLock</code> 전용 메서드이며, <strong><code>RedissonMultiLock</code>에서는 지원되지 않는다.</strong></li>
</ul>
</li>
<li><p><strong>중복 해제 방지 전략</strong>  </p>
<ul>
<li>트랜잭션 종료 시점에만 <strong><code>unlock()</code></strong>을 호출해야 하며, 불필요한 검증 로직은 제거해야 한다.</li>
</ul>
</li>
<li><p><strong>예외 처리</strong>  </p>
<ul>
<li><code>unlock()</code> 호출 시 이미 해제된 락에 대해 <strong><code>IllegalMonitorStateException</code></strong>이 발생할 수 있으므로 이를 적절히 처리한다.</li>
</ul>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redisson 락을 활용한 동시성 문제 해결]]></title>
            <link>https://velog.io/@happy_code/Redis-%EA%B8%B0%EB%B0%98-%EB%9D%BD%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@happy_code/Redis-%EA%B8%B0%EB%B0%98-%EB%9D%BD%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Mon, 16 Dec 2024 07:28:42 GMT</pubDate>
            <description><![CDATA[<h4 id="문제-상황"><strong>문제 상황</strong></h4>
<p>쿠폰 발급 기능에서 Redisson 락을 활용하여 동시성 문제를 해결하려고 했지만, <strong>레이스 컨디션</strong>으로 인해 <strong>남은 쿠폰 수량</strong>과 <strong>발급된 쿠폰 수량</strong>이 불일치하는 문제가 발생했습니다.</p>
<ul>
<li><strong>증상:</strong><ul>
<li>락이 해제된 시점에 트랜잭션이 아직 커밋되지 않아 <strong>다른 스레드가 동일한 리소스에 접근</strong>하게 됨.</li>
<li>결과적으로 <strong>남은 쿠폰 수량</strong>은 줄지 않고, <strong>발급된 쿠폰 수량</strong>은 중복되어 계산됨.</li>
</ul>
</li>
</ul>
<hr>
<h4 id="원인-분석"><strong>원인 분석</strong></h4>
<ol>
<li><p><strong>트랜잭션과 락의 비동기적 해제</strong></p>
<ul>
<li><code>issuanceRepository.save()</code>로 쿠폰 발급 데이터를 저장했지만, DB 트랜잭션이 <strong>커밋되지 않은 상태</strong>에서 락이 해제됨.</li>
<li>다른 스레드가 락을 획득하여 <strong>발급 중인 데이터</strong>가 아직 반영되지 않은 쿠폰 수량을 기반으로 작업을 수행함.</li>
</ul>
</li>
<li><p><strong>트랜잭션 종료와 락 해제의 타이밍 차이</strong></p>
<ul>
<li>트랜잭션 커밋은 비즈니스 로직이 끝난 후에 발생.</li>
<li>하지만 락은 트랜잭션 종료와 무관하게 <code>finally</code> 블록에서 해제됨.</li>
</ul>
</li>
</ol>
<hr>
<h4 id="해결-방법"><strong>해결 방법</strong></h4>
<h5 id="1-락을-트랜잭션-커밋-이후에-해제"><strong>1. 락을 트랜잭션 커밋 이후에 해제</strong></h5>
<p>락을 트랜잭션의 <strong>종료 후</strong> 해제하도록 구현. 이를 위해 <code>TransactionSynchronizationManager.registerSynchronization</code>을 사용하여 트랜잭션의 커밋 이후 이벤트를 감지.</p>
<hr>
<h4 id="수정된-코드"><strong>수정된 코드</strong></h4>
<pre><code class="language-java">@Transactional
public void issueCoupon(Long couponId, RequestDto request, Member member) {
    String lockKey = &quot;coupon:&quot; + couponId;
    RLock lock = redissonClient.getLock(lockKey);

    try {
        // 락 획득
        if (!lock.tryLock(5, 10, TimeUnit.SECONDS)) {
            System.out.println(&quot;Thread &quot; + Thread.currentThread().getId() + &quot;락 획득 실패.&quot;);
            throw new IllegalStateException(&quot;동시에 너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.&quot;);
        }
        System.out.println(&quot;Thread &quot; + Thread.currentThread().getId() + &quot;락 획득.&quot;);

        // 트랜잭션 종료 후 락 해제를 보장
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
            @Override
            public void afterCompletion(int status) {
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                    System.out.println(&quot;Thread &quot; + Thread.currentThread().getId() + &quot; released lock.&quot;);
                }
            }
        });

        // 비즈니스 로직
        Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow(() -&gt; new GlobalException(COUPON_NOT_FOUND));

        if (issuanceRepository.findByMemberIdAndCouponId(member.getId(), couponId).isPresent()) {
            throw new GlobalException(COUPON_ALREADY_ISSUED);
        }

        coupon.validateQuantity();
        coupon.decreaseQuantity();

        Issuance issuance = Issuance.builder()
            .member(member)
            .coupon(coupon)
            .build();
        issuanceRepository.save(issuance);

    } catch (InterruptedException e) {
        System.out.println(&quot;Thread &quot; + Thread.currentThread().getId() + &quot; was interrupted.&quot;);
        throw new IllegalArgumentException(&quot;Redis 락을 획득하는데 실패했습니다.&quot;, e);
    }
}</code></pre>
<hr>
<h4 id="결과"><strong>결과</strong></h4>
<ul>
<li><strong>테스트 시 기대 결과</strong>:<ul>
<li><strong>남은 쿠폰 수량</strong>: 0</li>
<li><strong>발급된 쿠폰 수량</strong>: 100</li>
</ul>
</li>
<li><strong>동시성 문제 해결</strong>:<ul>
<li>락이 트랜잭션 종료 후에 해제되므로 <strong>DB 커밋된 데이터</strong>를 기반으로 다음 스레드가 작업을 수행함.</li>
<li>레이스 컨디션 방지 성공.</li>
</ul>
</li>
</ul>
<hr>
<h4 id="배운-점"><strong>배운 점</strong></h4>
<ol>
<li><p><strong>트랜잭션과 락의 타이밍 관리</strong>:</p>
<ul>
<li>락 해제는 트랜잭션 종료 이후에 실행해야 동시성 문제를 방지할 수 있음.</li>
</ul>
</li>
<li><p><strong><code>TransactionSynchronizationManager</code>의 활용</strong>:</p>
<ul>
<li>트랜잭션의 상태(커밋/롤백)를 감지하여, 필요한 로직을 실행할 수 있음.</li>
</ul>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[동시성 제어 테스트에서 비관적 락 실패 문제 해결]]></title>
            <link>https://velog.io/@happy_code/%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EC%8B%A4%ED%8C%A8-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@happy_code/%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EC%8B%A4%ED%8C%A8-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Mon, 16 Dec 2024 05:15:13 GMT</pubDate>
            <description><![CDATA[<h3 id="1️⃣-문제-상황">1️⃣ <strong>문제 상황</strong></h3>
<ul>
<li><strong>목표</strong>: 쿠폰 발급 기능에서 동시성 제어를 테스트하기 위해 <strong>비관적 락 (Pessimistic Lock)</strong>을 적용.</li>
<li><strong>증상</strong>: <ul>
<li>테스트 코드 실행 결과:<ul>
<li>쿠폰 수량: 100</li>
<li>발급된 쿠폰 수량: 0</li>
</ul>
</li>
<li>동시성 문제가 해결되지 않았으며, 비관적 락이 제대로 동작하지 않음.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="2️⃣-문제-원인-분석">2️⃣ <strong>문제 원인 분석</strong></h3>
<h4 id="🔍-비관적-락과-트랜잭션의-관계">🔍 <strong>비관적 락과 트랜잭션의 관계</strong></h4>
<ul>
<li><strong>비관적 락은 트랜잭션 내에서만 유효</strong>합니다.<ul>
<li>락은 트랜잭션 시작 시 설정되고, 트랜잭션 종료 시 해제됩니다.</li>
</ul>
</li>
<li>테스트 실행 시, <code>@Transactional</code>이 없는 상태에서 <strong>트랜잭션이 제대로 관리되지 않아 락이 즉시 해제</strong>되었음.</li>
<li>결과적으로, 여러 스레드가 동시에 쿠폰 수량을 감소시키려 했으나, 락이 동작하지 않아 동시성 문제가 발생.</li>
</ul>
<h4 id="🔍-테스트-코드의-설계-문제">🔍 <strong>테스트 코드의 설계 문제</strong></h4>
<ul>
<li>서비스 레이어에서 트랜잭션 경계를 명확히 설정하지 않았음.</li>
<li>테스트 메서드 자체에서 트랜잭션을 사용하려고 했으나, 비관적 락은 트랜잭션 범위를 벗어나면 동작하지 않음.</li>
</ul>
<hr>
<h3 id="3️⃣-문제-해결">3️⃣ <strong>문제 해결</strong></h3>
<h4 id="🛠️-서비스-레이어에-transactional-추가">🛠️ <strong>서비스 레이어에 <code>@Transactional</code> 추가</strong></h4>
<ul>
<li><code>CouponService</code>의 <code>issueCoupon</code> 메서드에 트랜잭션을 적용.<ul>
<li>비관적 락의 생명 주기를 트랜잭션 경계 내로 설정하여 락이 올바르게 동작하도록 수정.</li>
</ul>
</li>
</ul>
<pre><code class="language-java">@Transactional
public void issueCoupon(Long couponId, RequestDto request, Member member) {
    Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId)
        .orElseThrow(() -&gt; new GlobalException(COUPON_NOT_FOUND));

    if (issuanceRepository.findByMemberIdAndCouponId(member.getId(), couponId).isPresent()) {
        throw new GlobalException(COUPON_ALREADY_ISSUED);
    }

    coupon.validateQuantity();
    coupon.decreaseQuantity();

    Issuance issuance = Issuance.builder()
        .member(member)
        .coupon(coupon)
        .build();
    issuanceRepository.save(issuance);
}</code></pre>
<h4 id="🛠️-비관적-락-적용">🛠️ <strong>비관적 락 적용</strong></h4>
<ul>
<li><code>CouponRepository</code>에 비관적 락을 적용한 JPQL 작성:<pre><code class="language-java">@Query(&quot;SELECT c FROM Coupon c WHERE c.id = :id FOR UPDATE&quot;)
Optional&lt;Coupon&gt; findByIdWithPessimisticLock(@Param(&quot;id&quot;) Long id);</code></pre>
</li>
</ul>
<h4 id="🛠️-테스트-코드-수정">🛠️ <strong>테스트 코드 수정</strong></h4>
<ul>
<li>기존 테스트 코드에서 트랜잭션 경계를 테스트 메서드가 아닌 서비스 레이어로 이동.</li>
<li>각 스레드가 쿠폰을 발급받을 때 새로운 <code>Member</code>를 생성하여 동시성 문제를 재현.</li>
<li>스레드 실행 결과를 검증하도록 추가 로그 및 검증 코드 작성.</li>
</ul>
<hr>
<h3 id="4️⃣-결과">4️⃣ <strong>결과</strong></h3>
<h4 id="✅-수정-후-테스트-결과">✅ <strong>수정 후 테스트 결과</strong></h4>
<ul>
<li><strong>남은 쿠폰 수량</strong>: 0</li>
<li><strong>발급된 쿠폰 수량</strong>: 100</li>
<li>비관적 락이 트랜잭션 경계 내에서 제대로 동작하여 <strong>동시성 문제가 해결</strong>됨.</li>
</ul>
<h4 id="✅-로그">✅ <strong>로그</strong></h4>
<pre><code class="language-text">Thread 시작: 0
Thread 시작: 1
Thread 시작: 2
...
Thread 완료: 98
Thread 완료: 99
남은 쿠폰 수량: 0
발급된 쿠폰 수량: 100</code></pre>
<hr>
<h3 id="5️⃣-트랜잭션의-중요성">5️⃣ <strong>트랜잭션의 중요성</strong></h3>
<h4 id="🔑-왜-트랜잭션이-중요한가">🔑 <strong>왜 트랜잭션이 중요한가?</strong></h4>
<ul>
<li>비관적 락은 <strong>트랜잭션 내부에서만 유효</strong>합니다.</li>
<li>트랜잭션이 시작되지 않으면 락이 바로 해제되어 다른 스레드가 데이터에 접근.</li>
<li>스프링의 <code>@Transactional</code>은 트랜잭션의 경계를 명확히 설정하여 락의 생명 주기를 보장.</li>
</ul>
<hr>
<h3 id="6️⃣-트랜잭션-미적용-시-문제점">6️⃣ <strong>트랜잭션 미적용 시 문제점</strong></h3>
<ul>
<li><strong>트랜잭션이 없는 경우</strong>:<ul>
<li>비관적 락이 제대로 적용되지 않아 동시성 문제가 발생.</li>
<li>테스트 코드 실행 결과, 모든 스레드가 동시에 쿠폰을 발급하려고 시도하여 <strong>발급된 쿠폰 수량이 0</strong>으로 유지됨.</li>
</ul>
</li>
<li><strong>트랜잭션 적용 후</strong>:<ul>
<li>비관적 락이 트랜잭션 시작 시 설정되고 종료 시 해제.</li>
<li>하나의 스레드가 쿠폰 발급 작업을 완료하기 전까지 다른 스레드는 대기.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="7️⃣-향후-개선점">7️⃣ <strong>향후 개선점</strong></h3>
<ol>
<li><p><strong>비관적 락과 성능 분석</strong>:</p>
<ul>
<li>비관적 락은 성능 저하(TPS 감소)를 초래할 수 있음.</li>
<li>Redis 분산 락과의 성능 비교를 통해 최적의 동시성 제어 방식 선택.</li>
</ul>
</li>
<li><p><strong>테스트 시나리오 확장</strong>:</p>
<ul>
<li>대규모 사용자 환경(TPS 증가)을 가정한 테스트.</li>
<li>실제 배포 환경과 유사한 성능 테스트 도구(nGrinder) 활용.</li>
</ul>
</li>
<li><p><strong>에러 로그 및 예외 처리 개선</strong>:</p>
<ul>
<li><code>GlobalException</code> 외에 동시성 제어 실패 로그를 추가하여 성능 병목점 분석.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="8️⃣-최종-정리">8️⃣ <strong>최종 정리</strong></h3>
<ul>
<li>비관적 락은 트랜잭션 경계 내에서만 동작하며, 트랜잭션 관리가 필수.</li>
<li><strong>트랜잭션을 활용한 락 관리</strong>로 동시성 제어 문제를 해결할 수 있음.</li>
<li>쿠폰 발급과 같은 민감한 동시성 로직에는 락 방식과 성능의 균형을 고려한 설계가 중요.</li>
</ul>
<hr>
<h3 id="참고-코드-테스트-결과-확인">참고 코드: 테스트 결과 확인</h3>
<pre><code class="language-java">@Test
@DisplayName(&quot;비관적 락 동시성 제어 테스트&quot;)
void pessimisticLockConcurrencyTest() throws InterruptedException {
    for (int i = 0; i &lt; threadCount; i++) {
        int threadId = i;
        executorService.submit(() -&gt; {
            try {
                Member threadMember = new Member(&quot;Test User &quot; + threadId, &quot;test&quot; + threadId + &quot;@example.com&quot;, &quot;password&quot;, MemberRole.USER);
                memberRepository.save(threadMember);
                couponService.issueCoupon(coupon.getId(), new RequestDto(threadMember.getId()), threadMember);
            } catch (GlobalException e) {
                System.out.println(&quot;Thread &quot; + threadId + &quot; 예외 발생: &quot; + e.getMessage());
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    Coupon updatedCoupon = couponRepository.findById(coupon.getId()).orElseThrow();
    System.out.println(&quot;남은 쿠폰 수량: &quot; + updatedCoupon.getQuantity());
    System.out.println(&quot;발급된 쿠폰 수량: &quot; + issuanceRepository.count());
    assertEquals(0, updatedCoupon.getQuantity());
    assertEquals(100, issuanceRepository.count());
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[@Transactional과 동시성 테스트의 문제점 및 해결 방법]]></title>
            <link>https://velog.io/@happy_code/Transactional%EA%B3%BC-%EB%8F%99%EC%8B%9C%EC%84%B1-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%9D%98-%EB%AC%B8%EC%A0%9C%EC%A0%90-%EB%B0%8F-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@happy_code/Transactional%EA%B3%BC-%EB%8F%99%EC%8B%9C%EC%84%B1-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%9D%98-%EB%AC%B8%EC%A0%9C%EC%A0%90-%EB%B0%8F-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Fri, 13 Dec 2024 11:27:59 GMT</pubDate>
            <description><![CDATA[<p>Spring Boot 테스트에서 <code>@Transactional</code>을 사용할 때 동시성 테스트에서 예상치 못한 문제가 발생할 수 있다. 이러한 문제는 트랜잭션 격리 수준과 롤백 동작으로 인해 발생하며, 동시성 테스트를 정확히 진행하기 위해선 <code>@Transactional</code>의 동작을 이해하고 적절한 대안을 선택해야 한다.</p>
<hr>
<h3 id="1️⃣-문제-상황">1️⃣ <strong>문제 상황</strong></h3>
<p>쿠폰 발급 API의 동시성 문제를 확인하기 위해 다음과 같은 테스트 코드를 작성했다:</p>
<pre><code class="language-java">@SpringBootTest
@Transactional
public class CouponConcurrencyTest {
    @Test
    void issueCoupon_concurrencyTest() throws InterruptedException {
        int threadCount = 10;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i &lt; threadCount; i++) {
            executorService.submit(() -&gt; {
                try {
                    couponService.issueCoupon(coupon.getId(), new RequestDto(member.getId()), member);
                } catch (Exception e) {
                    System.out.println(&quot;예외 발생: &quot; + e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
    }
}</code></pre>
<p>테스트 실행 결과, 아래와 같은 예외가 발생하며 동작하지 않았다:</p>
<pre><code>예외 발생: 해당 쿠폰을 찾을 수 없습니다.</code></pre><hr>
<h3 id="2️⃣-원인-분석">2️⃣ <strong>원인 분석</strong></h3>
<ol>
<li><p><strong>트랜잭션 격리로 인한 데이터 접근 제한</strong>:</p>
<ul>
<li><code>@Transactional</code>은 트랜잭션이 종료되기 전까지 데이터 변경 사항을 <strong>해당 트랜잭션 범위 내에서만 볼 수 있다</strong>.</li>
<li>동시성 테스트에서 다른 스레드가 데이터에 접근하려 하면, <strong>커밋되지 않은 데이터에 접근할 수 없어 조회에 실패</strong>한다.</li>
</ul>
</li>
<li><p><strong>테스트의 롤백 동작</strong>:</p>
<ul>
<li>테스트 메서드에 <code>@Transactional</code>을 붙이면 테스트 종료 후 데이터가 자동으로 롤백된다.</li>
<li>롤백 동작으로 인해 데이터가 제대로 반영되지 않아 다른 스레드가 동일한 데이터를 사용할 수 없다.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="3️⃣-해결-방안">3️⃣ <strong>해결 방안</strong></h3>
<h4 id="✅-1-transactional-제거-및-명시적-데이터-초기화">✅ <strong>1. <code>@Transactional</code> 제거 및 명시적 데이터 초기화</strong></h4>
<ul>
<li>테스트 메서드에서 <code>@Transactional</code>을 제거하고, 테스트 종료 후 명시적으로 데이터를 초기화한다.</li>
<li><code>@AfterEach</code>를 활용하여 데이터 정리를 진행한다:</li>
</ul>
<pre><code class="language-java">@AfterEach
void tearDown() {
    issuanceRepository.deleteAll();
    couponRepository.deleteAll();
    memberRepository.deleteAll();
}</code></pre>
<hr>
<h4 id="✅-2-트랜잭션-격리-수준-조정-비권장">✅ <strong>2. 트랜잭션 격리 수준 조정 (비권장)</strong></h4>
<ul>
<li><code>READ_UNCOMMITTED</code>로 설정하여 다른 스레드가 커밋되지 않은 데이터를 읽을 수 있도록 허용할 수 있다:</li>
</ul>
<pre><code class="language-java">@Transactional(isolation = Isolation.READ_UNCOMMITTED)</code></pre>
<ul>
<li>하지만 이는 Dirty Read(더티 읽기)와 같은 데이터 무결성 문제가 발생할 가능성이 있어 권장되지 않는다.</li>
</ul>
<hr>
<h4 id="✅-3-동시성-테스트를-별도의-통합-테스트로-진행">✅ <strong>3. 동시성 테스트를 별도의 통합 테스트로 진행</strong></h4>
<ul>
<li>동시성 문제를 테스트할 때는 데이터베이스와 트랜잭션 관리 없이, 독립적으로 테스트를 진행한다.</li>
<li>트랜잭션 롤백과 같은 문제를 회피하며, 동시성 시나리오를 단순화할 수 있다.</li>
</ul>
<hr>
<h3 id="4️⃣-적용-결과">4️⃣ <strong>적용 결과</strong></h3>
<p><code>@Transactional</code>을 제거하고 <code>@AfterEach</code>로 데이터 초기화를 추가한 후, 테스트가 정상적으로 동작하며 쿠폰 발급 및 동시성 문제가 확인되었다:</p>
<pre><code>남은 쿠폰 수량: 10 // 동시성 제어 문제로 중복 쿠폰 발급이 발생.
발급된 쿠폰 수량: 10</code></pre><hr>
<h3 id="5️⃣-배운-점">5️⃣ <strong>배운 점</strong></h3>
<ul>
<li><strong>트랜잭션 격리와 롤백</strong>은 테스트 환경에서 예상치 못한 문제를 일으킬 수 있다.</li>
<li>동시성 테스트를 수행할 때는 트랜잭션 관리 방식을 조정하거나 제거하여 테스트 환경을 적합하게 설정해야 한다.</li>
<li>테스트 데이터 관리는 <code>@Transactional</code> 없이 명시적으로 관리하는 것이 동시성 테스트에 유리하다.</li>
</ul>
<hr>
<h3 id="💭-회고">💭 <strong>회고</strong></h3>
<p>이번 경험을 통해 Spring의 <code>@Transactional</code>과 트랜잭션 격리 수준이 테스트 동작에 어떻게 영향을 미치는지 깊이 이해할 수 있었다. 특히 동시성 테스트는 단순히 코드를 작성하는 것만이 아니라, 테스트 환경과 트랜잭션 동작 방식에 대한 명확한 이해가 필요함을 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[H2 임베디드와 Mockito: 테스트 코드에서의 활용과 차이점]]></title>
            <link>https://velog.io/@happy_code/H2-%EC%9E%84%EB%B2%A0%EB%94%94%EB%93%9C%EC%99%80-Mockito-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C%EC%97%90%EC%84%9C%EC%9D%98-%ED%99%9C%EC%9A%A9%EA%B3%BC-%EC%B0%A8%EC%9D%B4%EC%A0%90</link>
            <guid>https://velog.io/@happy_code/H2-%EC%9E%84%EB%B2%A0%EB%94%94%EB%93%9C%EC%99%80-Mockito-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C%EC%97%90%EC%84%9C%EC%9D%98-%ED%99%9C%EC%9A%A9%EA%B3%BC-%EC%B0%A8%EC%9D%B4%EC%A0%90</guid>
            <pubDate>Tue, 10 Dec 2024 14:39:15 GMT</pubDate>
            <description><![CDATA[<p>테스트 코드는 프로젝트의 품질을 보장하는 핵심 도구입니다. 특히 데이터베이스와 연동된 코드를 테스트할 때, 테스트 환경이 실제 데이터베이스와 유사한지 여부는 매우 중요한 요소입니다. 이 글에서는 <strong>H2 임베디드 데이터베이스</strong>와 <strong>Mockito</strong>를 사용하는 방식의 차이를 비교하며, 왜 H2 임베디드 데이터베이스가 데이터베이스 연동 테스트에 권장되는지에 대해 다룹니다.</p>
<hr>
<h2 id="1️⃣-h2-임베디드-데이터베이스-왜-사용할까">1️⃣ H2 임베디드 데이터베이스: 왜 사용할까?</h2>
<p>H2 임베디드는 메모리 기반의 경량 데이터베이스로, <strong>실제 데이터베이스와 유사한 환경</strong>을 제공합니다. 이를 통해 JPA나 JDBC와 같은 데이터 액세스 로직을 실제 데이터베이스에서 테스트하듯이 검증할 수 있습니다.</p>
<h3 id="✅-장점-1-실제-데이터베이스와-유사한-환경"><strong>✅ 장점 1: 실제 데이터베이스와 유사한 환경</strong></h3>
<p>H2 임베디드는 실제 SQL 문법과 동작을 지원하므로, 실제 데이터베이스 환경과 유사한 조건에서 테스트를 실행할 수 있습니다. </p>
<pre><code class="language-java">@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
public class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    public void userSaveTest() {
        // Given
        User user = new User(&quot;test@example.com&quot;, &quot;password&quot;);

        // When
        userRepository.save(user);

        // Then
        User result = userRepository.findByEmail(&quot;test@example.com&quot;).orElseThrow();
        assertEquals(&quot;test@example.com&quot;, result.getEmail());
    }
}</code></pre>
<p>위 예제는 실제 데이터베이스를 사용하는 것처럼 JPA 레포지토리 동작을 테스트합니다.<br><strong>장점</strong>: </p>
<ul>
<li>엔티티 매핑 오류, 쿼리 오류, 트랜잭션 문제 등 데이터베이스와 관련된 잠재적인 문제를 초기에 발견 가능.</li>
</ul>
<hr>
<h3 id="✅-장점-2-통합-테스트-환경-제공"><strong>✅ 장점 2: 통합 테스트 환경 제공</strong></h3>
<p>H2 임베디드는 단순히 데이터를 저장하는 것을 넘어, 애플리케이션과 데이터베이스 간의 연동을 포함한 <strong>통합 테스트</strong>를 가능하게 합니다.<br>이는 단순 로직 테스트를 넘어 <strong>애플리케이션 전체의 동작을 검증</strong>할 수 있는 강력한 도구가 됩니다.</p>
<hr>
<h2 id="2️⃣-mockito를-사용하는-방식-언제-유용할까">2️⃣ Mockito를 사용하는 방식: 언제 유용할까?</h2>
<p>Mockito는 주로 단위 테스트에서 레포지토리, 서비스, 컨트롤러의 특정 메서드를 <strong>모킹(mocking)</strong>하여 테스트하는 데 사용됩니다.</p>
<pre><code class="language-java">@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    public void findUserTest() {
        // Given
        User mockUser = new User(&quot;test@example.com&quot;, &quot;password&quot;);
        Mockito.when(userRepository.findByEmail(&quot;test@example.com&quot;)).thenReturn(Optional.of(mockUser));

        // When
        User result = userService.findUserByEmail(&quot;test@example.com&quot;);

        // Then
        assertEquals(&quot;test@example.com&quot;, result.getEmail());
    }
}</code></pre>
<p>Mockito를 사용하면 특정 레포지토리 메서드의 동작을 모킹하여, 반환값을 미리 설정하고 로직을 검증할 수 있습니다.</p>
<h3 id="⛔-문제점-실제-데이터베이스와의-불일치"><strong>⛔ 문제점: 실제 데이터베이스와의 불일치</strong></h3>
<p>Mockito는 테스트 환경에서 실제 데이터베이스와 독립적으로 동작하므로 아래와 같은 한계가 존재합니다:</p>
<ol>
<li><p><strong>레포지토리 구현 검증 실패</strong></p>
<ul>
<li>쿼리 작성 오류가 있어도 테스트가 성공할 수 있습니다.</li>
<li>예를 들어, <code>@Query</code>로 작성한 JPQL이 실제 데이터베이스에서 실행되지 않을 경우, Mockito 테스트에서는 이를 검출하지 못합니다.</li>
</ul>
</li>
<li><p><strong>엔티티 매핑 검증 부족</strong></p>
<ul>
<li><code>@Column</code>, <code>@OneToMany</code> 등 데이터베이스와의 매핑이 잘못되었을 때 오류를 발견할 수 없습니다.</li>
</ul>
</li>
<li><p><strong>테스트 신뢰도 저하</strong></p>
<ul>
<li>실제 데이터베이스와의 상호작용 없이 반환값을 강제로 설정하기 때문에, 레포지토리의 동작이 실제 서비스와 일치하지 않을 가능성이 높아집니다.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="3️⃣-h2-임베디드와-mockito-언제-어떻게-사용할까">3️⃣ H2 임베디드와 Mockito: 언제, 어떻게 사용할까?</h2>
<h3 id="h2-임베디드를-사용할-때"><strong>H2 임베디드를 사용할 때</strong></h3>
<ul>
<li><strong>JPA, JDBC 테스트</strong>: 실제 데이터베이스와의 연동을 검증해야 하는 경우.</li>
<li><strong>쿼리 검증</strong>: JPQL이나 Native Query의 실행 가능성과 성능 검증이 필요한 경우.</li>
<li><strong>통합 테스트</strong>: 데이터베이스와의 상호작용을 포함한 전체적인 동작 검증이 필요한 경우.</li>
</ul>
<h3 id="mockito를-사용할-때"><strong>Mockito를 사용할 때</strong></h3>
<ul>
<li><strong>단위 테스트</strong>: 데이터베이스와 독립적으로 특정 메서드나 로직을 검증해야 하는 경우.</li>
<li><strong>테스트 속도</strong>: 빠른 테스트가 필요한 경우.</li>
<li><strong>의존성 분리</strong>: 데이터베이스와의 상호작용 없이 서비스나 컨트롤러 로직만 검증할 때.</li>
</ul>
<hr>
<h2 id="4️⃣-h2-임베디드를-사용하는-이유">4️⃣ H2 임베디드를 사용하는 이유</h2>
<p>결론적으로, H2 임베디드는 데이터베이스와 연동된 애플리케이션의 테스트에서 <strong>테스트 신뢰도를 높이는 가장 적합한 도구</strong>입니다.<br>Mockito는 단위 테스트에서는 유용하지만, 실제 데이터베이스와 관련된 문제를 발견하는 데는 한계가 있습니다.</p>
<p><strong>요약</strong>:</p>
<ul>
<li><strong>H2 임베디드</strong>: 데이터베이스 연동 로직, 엔티티 매핑, 쿼리 검증 등 통합 테스트에 적합.</li>
<li><strong>Mockito</strong>: 특정 메서드나 로직을 빠르고 독립적으로 테스트할 때 적합.</li>
</ul>
<hr>
<p><strong>테스트의 목표는 단순히 테스트 코드를 작성하는 것이 아니라, 실제로 발생할 수 있는 오류를 조기에 발견하여 프로젝트의 품질을 높이는 데 있습니다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[빌더 패턴 & Optional 활용으로 배우는 객체 생성과 조회 로직]]></title>
            <link>https://velog.io/@happy_code/%EB%B9%8C%EB%8D%94-%ED%8C%A8%ED%84%B4-Optional-%ED%99%9C%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%B0%B0%EC%9A%B0%EB%8A%94-%EA%B0%9D%EC%B2%B4-%EC%83%9D%EC%84%B1%EA%B3%BC-%EC%A1%B0%ED%9A%8C-%EB%A1%9C%EC%A7%81</link>
            <guid>https://velog.io/@happy_code/%EB%B9%8C%EB%8D%94-%ED%8C%A8%ED%84%B4-Optional-%ED%99%9C%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%B0%B0%EC%9A%B0%EB%8A%94-%EA%B0%9D%EC%B2%B4-%EC%83%9D%EC%84%B1%EA%B3%BC-%EC%A1%B0%ED%9A%8C-%EB%A1%9C%EC%A7%81</guid>
            <pubDate>Wed, 04 Dec 2024 05:06:17 GMT</pubDate>
            <description><![CDATA[<h4 id="1-코드"><strong>1. 코드</strong></h4>
<pre><code class="language-java">Cart cart = cartRepository.findByMemberIdAndMenuIdAndStoreId(member.getId(), request.menuId(), request.storeId())
                .map(existingCart -&gt; {
                    existingCart.updateQuantity(request.quantity());
                    return existingCart;
                })
                .orElseGet(() -&gt; Cart.builder()
                        .member(member)
                        .menu(menu)
                        .store(store)
                        .quantity(request.quantity())
                        .price(request.price())
                        .build());</code></pre>
<p>이 코드는 <strong>Optional</strong>, <strong>빌더 패턴</strong>, <strong>람다 표현식</strong>을 활용하여 객체 조회와 생성 로직을 간결하게 처리하는 매우 유용한 예제입니다. 이 코드에서 배운 점과 주요 개념들을 정리했습니다.</p>
<hr>
<h4 id="2-주요-개념"><strong>2. 주요 개념</strong></h4>
<ol>
<li><p><strong>Optional</strong>:</p>
<ul>
<li>데이터가 있을 수도, 없을 수도 있는 상황을 처리하는 Java의 클래스.</li>
<li><code>map()</code>과 <code>orElseGet()</code>을 사용해 조회 결과에 따라 다른 동작을 수행.</li>
</ul>
</li>
<li><p><strong>빌더 패턴</strong>:</p>
<ul>
<li>객체 생성 시 복잡한 생성자를 대신해, <strong>메서드 체이닝</strong>으로 가독성 있고 직관적인 코드 작성.</li>
</ul>
</li>
<li><p><strong>람다 표현식</strong>:</p>
<ul>
<li>간결한 문법으로 코드를 줄이고 가독성을 높임.</li>
</ul>
</li>
<li><p><strong>메서드 체이닝</strong>:</p>
<ul>
<li>여러 메서드를 연결해 순차적으로 작업을 처리.</li>
</ul>
</li>
</ol>
<hr>
<h4 id="3-코드-해석"><strong>3. 코드 해석</strong></h4>
<p>이 코드의 목적은 <strong>특정 조건에 맞는 카트를 조회하거나, 없다면 새로 생성하는 것</strong>입니다. </p>
<h5 id="1-카트-조회"><strong>1) 카트 조회</strong></h5>
<pre><code class="language-java">cartRepository.findByMemberIdAndMenuIdAndStoreId(member.getId(), request.menuId(), request.storeId())</code></pre>
<ul>
<li><code>cartRepository</code>에서 특정 멤버, 메뉴, 가게 ID를 조건으로 카트를 검색.</li>
<li>반환값은 <strong>Optional<Cart></strong>:<ul>
<li>조회된 카트가 존재 → <code>Optional</code> 내부에 <code>Cart</code> 객체 포함.</li>
<li>조회된 카트가 없음 → 비어 있는 <code>Optional</code> 반환.</li>
</ul>
</li>
</ul>
<hr>
<h5 id="2-조회된-카트가-존재하는-경우-map"><strong>2) 조회된 카트가 존재하는 경우 (<code>map</code>)</strong></h5>
<pre><code class="language-java">.map(existingCart -&gt; {
    existingCart.updateQuantity(request.quantity());
    return existingCart;
})</code></pre>
<ul>
<li><code>Optional.map()</code>은 값이 존재할 때 실행.</li>
<li><code>existingCart</code>는 <code>Optional</code> 내부의 실제 <code>Cart</code> 객체.</li>
<li>동작:<ol>
<li><code>updateQuantity()</code>로 카트 수량을 업데이트.</li>
<li>업데이트된 <code>existingCart</code>를 반환.</li>
</ol>
</li>
</ul>
<hr>
<h5 id="3-조회된-카트가-없는-경우-orelseget"><strong>3) 조회된 카트가 없는 경우 (<code>orElseGet</code>)</strong></h5>
<pre><code class="language-java">.orElseGet(() -&gt; Cart.builder()
        .member(member)
        .menu(menu)
        .store(store)
        .quantity(request.quantity())
        .price(request.price())
        .build());</code></pre>
<ul>
<li><code>orElseGet()</code>은 값이 없을 때 실행.</li>
<li><code>Cart.builder()</code>로 새 카트를 생성.</li>
<li>동작:<ol>
<li>필요한 필드(<code>member</code>, <code>menu</code>, <code>store</code>, <code>quantity</code>, <code>price</code>)를 설정.</li>
<li><code>.build()</code> 호출로 새 <code>Cart</code> 객체 생성.</li>
</ol>
</li>
</ul>
<hr>
<h4 id="4-빌더-패턴의-역할"><strong>4. 빌더 패턴의 역할</strong></h4>
<p>빌더 패턴은 복잡한 객체 생성 로직을 간결하게 처리합니다. 
예를 들어:</p>
<pre><code class="language-java">Cart.builder()
    .member(member)
    .menu(menu)
    .store(store)
    .quantity(request.quantity())
    .price(request.price())
    .build();</code></pre>
<ul>
<li>필드 설정이 명시적이고 순서에 구애받지 않아 가독성이 뛰어납니다.</li>
<li>특히, 객체가 Immutable(불변)해야 하는 경우에도 빌더 패턴이 유용합니다.</li>
</ul>
<hr>
<h4 id="5-optional과-빌더-패턴의-결합"><strong>5. Optional과 빌더 패턴의 결합</strong></h4>
<ul>
<li><strong>Optional</strong>은 데이터 조회 후 결과에 따라 로직을 분기 처리합니다.</li>
<li><strong>빌더 패턴</strong>은 조건에 따라 새 객체를 유연하게 생성할 수 있습니다.</li>
<li>이 두 가지를 결합하면 <strong>조회-수정-생성 로직</strong>을 간단하게 구현할 수 있습니다.</li>
</ul>
<hr>
<h4 id="6-오늘의-배운-점"><strong>6. 오늘의 배운 점</strong></h4>
<ol>
<li><p><strong>Optional 활용</strong>:</p>
<ul>
<li>조회 결과가 존재할 때는 <code>map()</code>으로 값 수정.</li>
<li>조회 결과가 없을 때는 <code>orElseGet()</code>으로 기본값 생성.</li>
</ul>
</li>
<li><p><strong>빌더 패턴 사용</strong>:</p>
<ul>
<li>복잡한 객체 생성 로직을 간단하고 명확하게 작성.</li>
<li>선택적 필드 초기화 및 가독성 높은 코드 작성 가능.</li>
</ul>
</li>
<li><p><strong>람다 표현식과 메서드 체이닝</strong>:</p>
<ul>
<li>간결한 코드 작성과 가독성 향상.</li>
</ul>
</li>
</ol>
<hr>
<h4 id="7-실전에서의-활용"><strong>7. 실전에서의 활용</strong></h4>
<ol>
<li><p><strong>상태 기반 객체 처리</strong>:</p>
<ul>
<li>조회된 데이터가 존재하면 수정하고, 없으면 생성.</li>
<li>상태 전환 로직을 <code>Optional</code>과 빌더로 쉽게 구현.</li>
</ul>
</li>
<li><p><strong>Immutable 객체 생성</strong>:</p>
<ul>
<li>빌더 패턴은 객체를 불변하게 설계하는 데 유리.</li>
</ul>
</li>
<li><p><strong>코드 간소화</strong>:</p>
<ul>
<li>람다 표현식과 빌더 패턴을 활용해 코드 중복 최소화.</li>
</ul>
</li>
</ol>
<hr>
<h4 id="8-앞으로의-적용"><strong>8. 앞으로의 적용</strong></h4>
<ul>
<li><strong>복잡한 객체 생성 시 빌더 패턴을 적극 활용</strong>: 특히, 생성자 매개변수가 많거나 선택적 필드가 많을 때.</li>
<li><strong>조회-수정-생성 로직에 Optional 사용</strong>: 데이터 상태에 따라 유연하게 처리.</li>
<li>코드 가독성을 높이는 방법을 고민하며, 메서드 체이닝과 람다 표현식 활용을 늘려야겠다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[허용 가능한 중복과 멀티 모듈 설계의 균형]]></title>
            <link>https://velog.io/@happy_code/%ED%97%88%EC%9A%A9-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%A4%91%EB%B3%B5%EA%B3%BC-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EC%84%A4%EA%B3%84%EC%9D%98-%EA%B7%A0%ED%98%95</link>
            <guid>https://velog.io/@happy_code/%ED%97%88%EC%9A%A9-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%A4%91%EB%B3%B5%EA%B3%BC-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EC%84%A4%EA%B3%84%EC%9D%98-%EA%B7%A0%ED%98%95</guid>
            <pubDate>Tue, 03 Dec 2024 06:22:04 GMT</pubDate>
            <description><![CDATA[<h3 id="1-허용-가능한-중복이란"><strong>1. 허용 가능한 중복이란?</strong></h3>
<p>허용 가능한 중복은 <strong>도메인별 요구사항이나 특수성</strong> 때문에 모듈 간 유사한 코드가 반복되지만, 이를 완전히 제거하지 않고 설계의 유연성을 위해 일부 중복을 허용하는 접근 방식이다. </p>
<hr>
<h3 id="2-왜-중복이-발생할까"><strong>2. 왜 중복이 발생할까?</strong></h3>
<ol>
<li><p><strong>도메인별 요구사항의 차이</strong>  </p>
<ul>
<li>각 API(User, Boss, Admin)의 비즈니스 로직이 다름.  </li>
<li>예: User API는 간단한 검증, Admin API는 복잡한 인증과 권한 확인.</li>
</ul>
</li>
<li><p><strong>모듈의 독립성 유지</strong>  </p>
<ul>
<li>모든 로직을 Core에 몰아넣으면 과도한 의존성으로 수정 시 영향 범위가 커짐.  </li>
<li>각 API는 독립적으로 동작해야 함.</li>
</ul>
</li>
<li><p><strong>중복 제거의 비용</strong>  </p>
<ul>
<li>중복을 완전히 제거하려 하면 복잡한 추상화로 인해 코드 가독성과 유지보수성이 저하될 수 있음.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="3-허용-가능한-중복의-예시"><strong>3. 허용 가능한 중복의 예시</strong></h3>
<h4 id="1-데이터-검증"><strong>1) 데이터 검증</strong></h4>
<ul>
<li>User API는 간단한 이메일/비밀번호 검증.</li>
<li>Admin API는 관리자 전용 키와 권한 검증 추가.<br>→ 공통 부분은 재사용하되, 도메인별 검증은 각 모듈에서 구현.</li>
</ul>
<h4 id="2-응답-포맷"><strong>2) 응답 포맷</strong></h4>
<ul>
<li>공통 응답 포맷(<code>ApiResponse</code>)은 Core에서 제공.  </li>
<li>도메인별 데이터 구조나 메시지 차이는 API 모듈에서 처리.</li>
</ul>
<hr>
<h3 id="4-중복-제거와-허용-중복의-균형"><strong>4. 중복 제거와 허용 중복의 균형</strong></h3>
<ol>
<li><p><strong>중복 제거</strong>  </p>
<ul>
<li>공통 로직(예: 예외 처리, 공통 응답 포맷 등)은 Core 모듈에 배치.  </li>
</ul>
</li>
<li><p><strong>허용 중복</strong>  </p>
<ul>
<li>도메인별 요구사항이 다른 경우 각 API 모듈에서 구현.  </li>
<li>필요 이상의 일반화는 피하고, 특화 로직은 분리.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="5-결론"><strong>5. 결론</strong></h3>
<p>모든 중복을 제거하려는 시도는 설계를 복잡하게 만들 수 있다.<br><strong>공통 로직은 Core에서 관리</strong>하되, <strong>도메인별 요구사항은 API 모듈에서 처리</strong>하도록 설계 균형을 맞추는 것이 중요하다.<br>필요한 중복은 프로젝트의 유연성과 유지보수성을 보장하는 데 기여할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[멀티 모듈 프로젝트 셋팅 순서]]></title>
            <link>https://velog.io/@happy_code/%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%85%8B%ED%8C%85-%EC%88%9C%EC%84%9C</link>
            <guid>https://velog.io/@happy_code/%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%85%8B%ED%8C%85-%EC%88%9C%EC%84%9C</guid>
            <pubDate>Tue, 03 Dec 2024 06:00:14 GMT</pubDate>
            <description><![CDATA[<h3 id="1-프로젝트-기본-셋업"><strong>1. 프로젝트 기본 셋업</strong></h3>
<h4 id="11-gradle-멀티-모듈-프로젝트-생성">1.1. <strong>Gradle 멀티 모듈 프로젝트 생성</strong></h4>
<ol>
<li><p><strong>루트 프로젝트 생성</strong>:</p>
<ul>
<li>Gradle 기반의 프로젝트를 생성한다.</li>
<li>프로젝트 이름은 팀 프로젝트 이름에 맞게 설정한다.</li>
</ul>
</li>
<li><p><strong>Gradle 설정 파일 생성</strong>:</p>
<ul>
<li><code>settings.gradle</code> 파일에서 멀티 모듈을 정의한다.</li>
<li>하위 모듈의 경로를 지정.</li>
</ul>
</li>
</ol>
<p><strong><code>settings.gradle</code> 예시</strong>:</p>
<pre><code class="language-gradle">rootProject.name = &#39;waiting-system&#39;

include &#39;libs:core&#39;
include &#39;libs:database&#39;
include &#39;libs:s3-client&#39;
include &#39;libs:queue-client&#39;
include &#39;api:user-api&#39;
include &#39;api:boss-api&#39;
include &#39;api:admin-api&#39;</code></pre>
<ol start="3">
<li><strong>디렉토리 구조 생성</strong>:<ul>
<li><code>libs/</code>와 <code>api/</code> 디렉토리를 만들고, 각각 모듈 디렉토리를 생성한다.</li>
</ul>
</li>
</ol>
<p><strong>최종 디렉토리 구조</strong>:</p>
<pre><code class="language-plaintext">waiting-system/
├── build.gradle
├── settings.gradle
├── libs/
│   ├── core/
│   ├── database/
│   ├── s3-client/
│   └── queue-client/
└── api/
    ├── user-api/
    ├── boss-api/
    └── admin-api/</code></pre>
<hr>
<h3 id="2-각-모듈별-초기화"><strong>2. 각 모듈별 초기화</strong></h3>
<h4 id="21-core-모듈-초기화">2.1. <strong>Core 모듈 초기화</strong></h4>
<ol>
<li><p><strong><code>libs/core</code> 디렉토리로 이동</strong>:</p>
<ul>
<li>Gradle 초기화 명령 실행 (<code>gradle init</code>).</li>
</ul>
</li>
<li><p><strong><code>build.gradle</code> 파일 작성</strong>:</p>
<ul>
<li>Core 모듈에 필요한 의존성(Spring Web, Validation 등)을 추가.</li>
</ul>
</li>
</ol>
<p><strong><code>libs/core/build.gradle</code></strong>:</p>
<pre><code class="language-gradle">dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;
}</code></pre>
<hr>
<h4 id="22-database-모듈-초기화">2.2. <strong>Database 모듈 초기화</strong></h4>
<ol>
<li><p><strong><code>libs/database</code> 디렉토리로 이동</strong>:</p>
<ul>
<li>Gradle 초기화 명령 실행.</li>
</ul>
</li>
<li><p><strong><code>build.gradle</code> 파일 작성</strong>:</p>
<ul>
<li>JPA 및 Core 모듈 의존성을 추가.</li>
</ul>
</li>
</ol>
<p><strong><code>libs/database/build.gradle</code></strong>:</p>
<pre><code class="language-gradle">dependencies {
    implementation project(&#39;:libs:core&#39;) // Core 모듈 의존
    implementation &#39;org.springframework.boot:spring-boot-starter-data-jpa&#39;
}</code></pre>
<hr>
<h4 id="23-s3-client-모듈-초기화">2.3. <strong>S3-Client 모듈 초기화</strong></h4>
<ol>
<li><p><strong><code>libs/s3-client</code> 디렉토리로 이동</strong>:</p>
<ul>
<li>Gradle 초기화 명령 실행.</li>
</ul>
</li>
<li><p><strong><code>build.gradle</code> 파일 작성</strong>:</p>
<ul>
<li>Core 모듈과 S3 관련 의존성을 추가.</li>
</ul>
</li>
</ol>
<p><strong><code>libs/s3-client/build.gradle</code></strong>:</p>
<pre><code class="language-gradle">dependencies {
    implementation project(&#39;:libs:core&#39;)
    implementation &#39;com.amazonaws:aws-java-sdk-s3&#39;
}</code></pre>
<hr>
<h4 id="24-queue-client-모듈-초기화">2.4. <strong>Queue-Client 모듈 초기화</strong></h4>
<ol>
<li><p><strong><code>libs/queue-client</code> 디렉토리로 이동</strong>:</p>
<ul>
<li>Gradle 초기화 명령 실행.</li>
</ul>
</li>
<li><p><strong><code>build.gradle</code> 파일 작성</strong>:</p>
<ul>
<li>Core 모듈과 메시지 큐 관련 의존성을 추가.</li>
</ul>
</li>
</ol>
<p><strong><code>libs/queue-client/build.gradle</code></strong>:</p>
<pre><code class="language-gradle">dependencies {
    implementation project(&#39;:libs:core&#39;)
    implementation &#39;org.springframework.boot:spring-boot-starter-amqp&#39; // RabbitMQ 예시
}</code></pre>
<hr>
<h4 id="25-api-모듈-초기화-user-boss-admin">2.5. <strong>API 모듈 초기화 (User, Boss, Admin)</strong></h4>
<ol>
<li><p><strong>각 API 모듈 디렉토리 초기화</strong>:</p>
<ul>
<li><code>api/user-api</code>, <code>api/boss-api</code>, <code>api/admin-api</code> 각각 초기화.</li>
</ul>
</li>
<li><p><strong><code>build.gradle</code> 파일 작성</strong>:</p>
<ul>
<li>Core 모듈과 필요한 라이브러리 의존성을 추가.</li>
</ul>
</li>
</ol>
<p><strong><code>api/user-api/build.gradle</code></strong>:</p>
<pre><code class="language-gradle">dependencies {
    implementation project(&#39;:libs:core&#39;) // 공통 기능
    implementation project(&#39;:libs:database&#39;) // 데이터베이스
    implementation project(&#39;:libs:s3-client&#39;) // 파일 저장
    implementation project(&#39;:libs:queue-client&#39;) // 메시지 큐
}</code></pre>
<hr>
<h3 id="3-공통-빌드-설정"><strong>3. 공통 빌드 설정</strong></h3>
<h4 id="31-루트-프로젝트-buildgradle-설정">3.1. <strong>루트 프로젝트 <code>build.gradle</code> 설정</strong></h4>
<ol>
<li><strong>공통 의존성 버전 관리</strong>:<ul>
<li><code>build.gradle</code>에서 플러그인 및 공통 의존성 버전을 설정.</li>
</ul>
</li>
</ol>
<p><strong><code>build.gradle</code></strong>:</p>
<pre><code class="language-gradle">plugins {
    id &#39;org.springframework.boot&#39; version &#39;3.1.0&#39; apply false
    id &#39;io.spring.dependency-management&#39; version &#39;1.1.3&#39; apply false
    id &#39;java&#39;
}

allprojects {
    group = &#39;com.example&#39;
    version = &#39;1.0.0&#39;

    repositories {
        mavenCentral()
    }
}

subprojects {
    apply plugin: &#39;java&#39;
    apply plugin: &#39;io.spring.dependency-management&#39;

    dependencies {
        testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
    }
}</code></pre>
<hr>
<h3 id="4-프로젝트-초기-테스트"><strong>4. 프로젝트 초기 테스트</strong></h3>
<ol>
<li><p><strong>Gradle 빌드 확인</strong>:</p>
<ul>
<li>최상위 디렉토리에서 <code>./gradlew build</code>를 실행해 빌드가 정상적으로 작동하는지 확인.</li>
</ul>
</li>
<li><p><strong>모듈 간 의존성 확인</strong>:</p>
<ul>
<li>각 모듈에서 <code>./gradlew dependencies</code>를 실행해 의존성이 제대로 연결되었는지 점검.</li>
</ul>
</li>
<li><p><strong>Spring Boot 애플리케이션 실행</strong>:</p>
<ul>
<li>API 모듈 중 하나(예: <code>user-api</code>)에 <code>@SpringBootApplication</code>을 추가해 애플리케이션이 정상적으로 실행되는지 확인.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="5-초기-설계의-유의점"><strong>5. 초기 설계의 유의점</strong></h3>
<ol>
<li><p><strong>모듈 경량화</strong>:</p>
<ul>
<li>각 모듈은 역할에 맞는 기능만 포함해야 하며, 필요 이상으로 의존성을 추가하지 않는다.</li>
</ul>
</li>
<li><p><strong>의존성 주의</strong>:</p>
<ul>
<li>Core 모듈에서만 Spring Web을 의존하도록 설정하고, 다른 모듈에서 직접 사용하지 않도록 제한.</li>
</ul>
</li>
<li><p><strong>테스트 전략 수립</strong>:</p>
<ul>
<li>Core, Database, API 모듈 각각의 단위 테스트와 통합 테스트를 별도로 작성한다.</li>
</ul>
</li>
<li><p><strong>유지보수 고려</strong>:</p>
<ul>
<li>라이브러리(S3, Queue 등)는 API 모듈에서 직접 구현하지 않고, 공통 모듈을 활용해 재사용성을 높인다.</li>
</ul>
</li>
</ol>
]]></description>
        </item>
    </channel>
</rss>