<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jiiim_ni.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 10 Aug 2026 23:58:56 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jiiim_ni.log</title>
            <url>https://velog.velcdn.com/images/jiiim_ni/profile/747440f3-0e0e-476e-99f4-777865c24683/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jiiim_ni.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jiiim_ni" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[리눅스 배포 기초 정리]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%A6%AC%EB%88%85%EC%8A%A4-%EB%B0%B0%ED%8F%AC-%EA%B8%B0%EC%B4%88-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@jiiim_ni/%EB%A6%AC%EB%88%85%EC%8A%A4-%EB%B0%B0%ED%8F%AC-%EA%B8%B0%EC%B4%88-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 10 Aug 2026 23:58:56 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/1b3d974b-5bd6-405e-b2fc-1fdec6e94a6d/image.png" alt=""></p>
<p>Spring Boot 애플리케이션을 서버에 배포하려면 단순히 JAR 파일을 실행하는 것만으로는 부족하다. 서버 접속부터 파일 관리, 프로세스와 서비스 운영, 네트워크, 로그 확인, 롤백까지 전체 흐름을 이해해야 한다.</p>
<p>이번 글에서는 리눅스 서버에 애플리케이션을 배포할 때 필요한 기본 개념과 명령어를 정리해 본다.</p>
<h2 id="목차">목차</h2>
<ol>
<li>일반적인 배포 흐름</li>
<li>배포의 전체 구조</li>
<li>리눅스 기본 명령어</li>
<li>파일 내용 확인과 검색</li>
<li>프로그램, 프로세스, 서비스의 차이</li>
<li>프로세스 확인과 종료</li>
<li>systemd로 서비스 관리하기</li>
<li>리눅스 네트워크 기초</li>
<li>SSH로 서버 접속하기</li>
<li>실제 배포 흐름</li>
<li>무중단 배포의 기본 개념</li>
</ol>
<hr>
<h2 id="1-일반적인-배포-흐름">1. 일반적인 배포 흐름</h2>
<p>일반적인 서버 배포는 다음과 같은 순서로 진행된다.</p>
<pre><code class="language-text">1. 서버에 SSH로 접속
2. Java, Node.js 같은 런타임 설치
3. 애플리케이션 파일 또는 Docker 이미지 전달
4. 환경 변수와 설정 파일 구성
5. 데이터베이스 마이그레이션
6. 애플리케이션 실행
7. Nginx 리버스 프록시 설정
8. HTTPS 인증서 설정
9. 로그와 상태 확인
10. 문제가 생기면 이전 버전으로 롤백</code></pre>
<p>예를 들어 Spring Boot 애플리케이션이라면 파일을 다음과 같이 구성할 수 있다.</p>
<pre><code class="language-text">/opt/myapp/
├── app.jar
├── application-prod.yml
└── logs/</code></pre>
<p>애플리케이션 프로세스는 <code>systemd</code>가 관리하고, Nginx가 외부 요청을 애플리케이션에 전달하는 형태다.</p>
<h2 id="2-배포의-전체-구조">2. 배포의 전체 구조</h2>
<pre><code class="language-text">사용자 브라우저
    │
    │ https://api.example.com
    ▼
DNS
도메인 이름을 서버 IP 주소로 변환
    │
    ▼
서버 방화벽
443 포트 요청 허용 여부 확인
    │
    ▼
Nginx
HTTPS 처리 및 요청 전달
    │
    │ http://127.0.0.1:8080
    ▼
Spring Boot 애플리케이션
    │
    ▼
데이터베이스 · Redis · 외부 API</code></pre>
<p>각 요소의 역할을 간단히 정리하면 다음과 같다.</p>
<ul>
<li><strong>Linux</strong>: 애플리케이션과 Nginx가 실행되는 운영체제</li>
<li><strong>SSH</strong>: 개발자가 서버에 원격으로 접속하는 방법</li>
<li><strong>systemd</strong>: 애플리케이션 프로세스를 실행하고 관리하는 도구</li>
<li><strong>Nginx</strong>: 외부 요청을 애플리케이션으로 전달하는 웹 서버 또는 리버스 프록시</li>
<li><strong>DNS</strong>: 도메인 이름을 서버 IP 주소로 변환하는 시스템</li>
<li><strong>TLS 인증서</strong>: HTTPS 통신을 암호화하고 서버의 신원을 확인하는 인증서</li>
<li><strong>로그</strong>: 요청, 오류, 애플리케이션 상태를 기록한 정보</li>
<li><strong>방화벽</strong>: 특정 포트로 들어오는 요청을 허용하거나 차단하는 장치</li>
</ul>
<hr>
<h2 id="3-리눅스-기본-명령어">3. 리눅스 기본 명령어</h2>
<p>배포 과정에서 자주 사용하는 명령어부터 살펴보자.</p>
<pre><code class="language-bash">pwd                     # 현재 위치 확인
ls -al                  # 파일 목록 확인
cd /var/www/myapp       # 디렉터리 이동

mkdir app               # 디렉터리 생성
cp source target        # 파일 복사
mv old new              # 파일 이동 또는 이름 변경
rm file                 # 파일 삭제

cat app.log             # 파일 전체 내용 출력
less app.log            # 긴 파일을 페이지 단위로 확인
tail -f app.log         # 실시간 로그 확인

grep &quot;ERROR&quot; app.log    # 문자열 검색
find . -name &quot;*.log&quot;    # 파일 검색</code></pre>
<p>특히 <code>cd</code>, <code>ls</code>, <code>cp</code>, <code>mv</code>, <code>less</code>, <code>tail</code>, <code>grep</code>, <code>find</code>는 배포와 장애 대응 과정에서 자주 사용한다.</p>
<blockquote>
<p><code>rm -rf</code>는 삭제한 내용을 되돌리기 어려우므로 매우 조심해서 사용해야 한다.</p>
</blockquote>
<h3 id="리눅스의-파일과-디렉터리">리눅스의 파일과 디렉터리</h3>
<p>리눅스 터미널은 항상 어떤 디렉터리를 기준으로 동작한다.</p>
<p><code>pwd</code>는 <strong>print working directory</strong>의 약자로, 현재 작업 중인 디렉터리를 출력한다.</p>
<h4 id="절대-경로">절대 경로</h4>
<p>루트 디렉터리인 <code>/</code>부터 시작하는 전체 경로다. 현재 어느 디렉터리에 있든 항상 같은 파일을 가리킨다.</p>
<h4 id="상대-경로">상대 경로</h4>
<p>현재 위치를 기준으로 나타내는 경로다.</p>
<pre><code class="language-text">.  : 현재 디렉터리
.. : 상위 디렉터리
~  : 현재 사용자의 홈 디렉터리
/  : 파일 시스템의 최상위 디렉터리</code></pre>
<p>리눅스에서 자주 접하는 디렉터리는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>경로</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><code>/home</code></td>
<td>일반 사용자의 홈 디렉터리</td>
</tr>
<tr>
<td><code>/root</code></td>
<td>root 사용자의 홈 디렉터리</td>
</tr>
<tr>
<td><code>/etc</code></td>
<td>시스템과 서비스의 설정 파일</td>
</tr>
<tr>
<td><code>/var/log</code></td>
<td>시스템과 일부 서비스의 로그</td>
</tr>
<tr>
<td><code>/opt</code></td>
<td>별도로 설치한 애플리케이션</td>
</tr>
<tr>
<td><code>/usr/bin</code></td>
<td>일반적인 실행 명령어</td>
</tr>
<tr>
<td><code>/tmp</code></td>
<td>임시 파일</td>
</tr>
<tr>
<td><code>/run</code></td>
<td>실행 중인 서비스의 임시 상태 정보</td>
</tr>
<tr>
<td><code>/var/lib</code></td>
<td>서비스가 사용하는 영속 데이터</td>
</tr>
</tbody></table>
<blockquote>
<p><code>/var/www</code>는 정적 웹 파일을 둘 때 많이 사용한다. 독립적인 백엔드 애플리케이션은 <code>/opt</code>나 <code>/srv</code>에 두기도 한다.</p>
</blockquote>
<h3 id="ls-파일-목록-확인"><code>ls</code>: 파일 목록 확인</h3>
<ul>
<li><code>ls -l</code>: 권한, 소유자, 그룹, 크기, 수정 시간 등 상세 정보 표시</li>
<li><code>ls -a</code>: <code>.</code>으로 시작하는 숨김 파일까지 표시</li>
<li><code>ls -alh</code>: 숨김 파일과 상세 정보를 함께 보여 주고 파일 크기를 KB, MB처럼 읽기 쉽게 표시<ul>
<li><code>a</code>: 숨김 파일 포함</li>
<li><code>l</code>: 상세 정보 표시</li>
<li><code>h</code>: 사람이 읽기 쉬운 단위로 파일 크기 표시</li>
</ul>
</li>
</ul>
<h3 id="mkdir-디렉터리-생성"><code>mkdir</code>: 디렉터리 생성</h3>
<pre><code class="language-bash">mkdir releases</code></pre>
<p>여러 단계의 디렉터리를 한 번에 만들 때는 <code>-p</code>를 사용한다.</p>
<pre><code class="language-bash">mkdir -p /opt/myapp/releases</code></pre>
<p><code>-p</code>가 없으면 중간 디렉터리가 존재하지 않을 때 오류가 발생한다.</p>
<h3 id="cp-복사"><code>cp</code>: 복사</h3>
<pre><code class="language-bash">cp app.jar app-backup.jar</code></pre>
<p>디렉터리 전체를 복사하려면 <code>-r</code> 옵션이 필요하다.</p>
<pre><code class="language-bash">cp -r config config-backup</code></pre>
<p>기존 파일과 같은 이름으로 복사하면 덮어쓸 수 있으므로 운영 서버에서는 대상 경로를 먼저 확인해야 한다.</p>
<h3 id="mv-이동-또는-이름-변경"><code>mv</code>: 이동 또는 이름 변경</h3>
<p>파일 이름을 변경할 수 있다.</p>
<pre><code class="language-bash">mv app-new.jar app.jar</code></pre>
<p>파일을 다른 디렉터리로 이동할 때도 사용한다.</p>
<p><code>mv</code> 역시 대상 위치에 같은 이름의 파일이 있으면 덮어쓸 수 있다. 운영 환경에서는 파일 이름에 버전을 포함하는 방식을 많이 사용한다.</p>
<pre><code class="language-text">app-1.0.0.jar
app-1.0.1.jar</code></pre>
<p>이전 버전 파일이 남아 있으므로 문제가 생겼을 때 롤백하기 쉬워진다.</p>
<h3 id="rm-삭제"><code>rm</code>: 삭제</h3>
<p>디렉터리와 내부 파일을 재귀적으로 강제 삭제하는 다음 명령은 매우 위험하다.</p>
<pre><code class="language-bash">rm -rf 디렉터리</code></pre>
<ul>
<li><code>-r</code>: 내부 디렉터리와 파일까지 재귀적으로 처리</li>
<li><code>-f</code>: 별도의 확인 없이 강제로 처리</li>
</ul>
<p>리눅스 터미널에서 삭제한 파일은 일반적으로 휴지통으로 가지 않는다. 특히 관리자 권한과 함께 잘못 사용하면 시스템 전체를 손상시킬 수 있다.</p>
<p>삭제 전에는 대상 경로를 확인하는 습관을 들이는 것이 좋다.</p>
<pre><code class="language-bash">pwd
ls -al 삭제하려는경로</code></pre>
<p>운영 배포에서는 오래된 파일을 즉시 삭제하기보다 별도의 백업 디렉터리로 옮긴 뒤 보존 기간에 따라 정리하는 편이 안전하다.</p>
<hr>
<h2 id="4-파일-내용-확인과-검색">4. 파일 내용 확인과 검색</h2>
<h3 id="cat-파일-전체-내용을-한-번에-출력"><code>cat</code>: 파일 전체 내용을 한 번에 출력</h3>
<pre><code class="language-bash">cat application.yml</code></pre>
<p>짧은 설정 파일을 확인하기에는 좋지만 로그처럼 큰 파일에 사용하면 화면이 빠르게 지나간다.</p>
<p>또한 비밀번호나 API 키가 포함된 파일을 화면에 출력하면 터미널 기록이나 화면 공유를 통해 노출될 수 있으므로 주의해야 한다.</p>
<h3 id="less-긴-파일을-페이지-단위로-확인"><code>less</code>: 긴 파일을 페이지 단위로 확인</h3>
<pre><code class="language-bash">less application.log</code></pre>
<p><code>less</code> 내부에서 자주 사용하는 키는 다음과 같다.</p>
<ul>
<li>방향키: 이동</li>
<li><code>/ERROR</code>: <code>ERROR</code> 검색</li>
<li><code>n</code>: 다음 검색 결과</li>
<li><code>N</code>: 이전 검색 결과</li>
<li><code>q</code>: 종료</li>
</ul>
<h3 id="head와-tail-파일의-앞부분과-마지막-부분-확인"><code>head</code>와 <code>tail</code>: 파일의 앞부분과 마지막 부분 확인</h3>
<pre><code class="language-bash"># 파일 앞부분 확인
head application.log
head -n 50 application.log

# 파일 마지막 부분 확인
tail application.log
tail -n 100 application.log

# 실시간으로 추가되는 로그 확인
tail -f application.log</code></pre>
<p><code>tail -f</code>를 종료할 때는 <code>Ctrl + C</code>를 누른다.</p>
<h3 id="grep-파일-내용에서-특정-문자열-검색"><code>grep</code>: 파일 내용에서 특정 문자열 검색</h3>
<pre><code class="language-bash"># ERROR 검색
grep &quot;ERROR&quot; application.log

# 대소문자를 구분하지 않고 검색
grep -i &quot;error&quot; application.log

# 줄 번호 포함
grep -n &quot;ERROR&quot; application.log

# 디렉터리 내부를 재귀적으로 검색
grep -R &quot;database.password&quot; /opt/myapp/config</code></pre>
<p>프로세스를 확인할 때 파이프(<code>|</code>)와 함께 자주 사용하기도 한다.</p>
<pre><code class="language-bash">ps aux | grep java</code></pre>
<p>파이프는 왼쪽 명령의 출력을 오른쪽 명령의 입력으로 전달한다. 위 명령에서는 <code>ps aux</code>의 전체 출력 가운데 <code>java</code>가 포함된 줄만 선택한다.</p>
<p>단, <code>grep java</code> 명령 자체도 결과에 나타날 수 있다. 다음처럼 작성하면 그 문제를 피할 수 있다.</p>
<pre><code class="language-bash">ps aux | grep &#39;[j]ava&#39;</code></pre>
<h3 id="find-이름이나-조건으로-실제-파일-찾기"><code>find</code>: 이름이나 조건으로 실제 파일 찾기</h3>
<pre><code class="language-bash"># 특정 경로에서 로그 파일 검색
find /opt/myapp -name &quot;*.log&quot;

# 현재 디렉터리부터 검색
find . -name &quot;application*.yml&quot;

# 파일만 검색
find . -type f -name &quot;*.jar&quot;

# 디렉터리만 검색
find . -type d -name &quot;logs&quot;</code></pre>
<h4 id="grep과-find의-차이"><code>grep</code>과 <code>find</code>의 차이</h4>
<ul>
<li><code>find</code>: 파일과 디렉터리 자체를 찾는다.</li>
<li><code>grep</code>: 파일 내용에서 문자열을 찾는다.</li>
</ul>
<hr>
<h2 id="5-프로그램-프로세스-서비스의-차이">5. 프로그램, 프로세스, 서비스의 차이</h2>
<h3 id="프로그램">프로그램</h3>
<p>디스크에 저장된 실행 가능한 파일이다.</p>
<h3 id="프로세스">프로세스</h3>
<p>프로그램이 실제로 실행 중인 상태다. 프로세스마다 PID라는 고유한 번호가 있다.</p>
<pre><code class="language-bash">ps aux</code></pre>
<p>출력 예시는 다음과 같다.</p>
<pre><code class="language-text">appuser  2145  2.3  8.1 ... java -jar /opt/myapp/app.jar</code></pre>
<p>여기서 <code>2145</code>가 PID다.</p>
<h3 id="서비스">서비스</h3>
<p>운영체제가 지속적으로 관리하는 백그라운드 프로그램이다. 애플리케이션을 서비스로 등록하면 시작, 종료, 재시작, 부팅 시 자동 실행 등을 일관된 방식으로 관리할 수 있다.</p>
<hr>
<h2 id="6-프로세스-확인과-종료">6. 프로세스 확인과 종료</h2>
<h3 id="ps-현재-실행-중인-프로세스-확인"><code>ps</code>: 현재 실행 중인 프로세스 확인</h3>
<pre><code class="language-bash">ps aux

# 특정 프로세스 검색
ps aux | grep &#39;[j]ava&#39;

# PID를 더 간단하게 검색
pgrep -af java</code></pre>
<p><code>pgrep</code> 옵션의 의미는 다음과 같다.</p>
<ul>
<li><code>-a</code>: 전체 명령어 표시</li>
<li><code>-f</code>: 실행 명령 전체를 대상으로 검색</li>
</ul>
<h3 id="top-cpu와-메모리-사용량-실시간-확인"><code>top</code>: CPU와 메모리 사용량 실시간 확인</h3>
<pre><code class="language-bash">top</code></pre>
<p>주로 다음 항목을 확인한다.</p>
<ul>
<li>CPU를 많이 사용하는 프로세스</li>
<li>메모리를 많이 사용하는 프로세스</li>
<li>시스템 전체 부하</li>
<li>실행 중인 프로세스 수</li>
</ul>
<p><code>q</code>를 누르면 종료된다.</p>
<h3 id="kill-프로세스에-시그널-보내기"><code>kill</code>: 프로세스에 시그널 보내기</h3>
<p><code>kill</code>은 무조건 프로세스를 강제 종료하는 명령이 아니라 프로세스에 시그널을 보내는 명령이다.</p>
<pre><code class="language-bash">kill -15 2145</code></pre>
<p>15, 즉 <code>SIGTERM</code>은 정상 종료를 요청한다. 애플리케이션은 <code>SIGTERM</code>을 받으면 다음 작업을 수행할 기회를 가질 수 있다.</p>
<ul>
<li>새로운 요청 수신 중단</li>
<li>처리 중인 요청 마무리</li>
<li>데이터 저장</li>
<li>DB 연결 종료</li>
<li>로그 기록</li>
<li>프로세스 종료</li>
</ul>
<p>반면 다음 명령은 <code>SIGKILL</code>을 보낸다.</p>
<pre><code class="language-bash">kill -9 2145</code></pre>
<p><code>SIGKILL</code>은 애플리케이션이 정리할 기회를 주지 않고 운영체제가 즉시 종료한다. 따라서 보통 다음 순서를 따른다.</p>
<ol>
<li><code>SIGTERM</code>으로 정상 종료 요청</li>
<li>일정 시간 동안 종료 여부 확인</li>
<li>정상 종료되지 않을 때만 <code>SIGKILL</code> 고려</li>
</ol>
<blockquote>
<p>서비스로 등록된 애플리케이션이라면 PID를 직접 종료하기보다 <code>sudo systemctl stop myapp</code>을 우선 사용한다. systemd에 정의된 종료 절차와 제한 시간을 적용할 수 있기 때문이다.</p>
</blockquote>
<hr>
<h2 id="7-systemd로-서비스-관리하기">7. systemd로 서비스 관리하기</h2>
<p><code>systemd</code>는 많은 리눅스 배포판에서 서비스의 시작, 종료, 자동 실행, 재시작, 로그 등을 관리한다.</p>
<h3 id="자주-사용하는-명령어">자주 사용하는 명령어</h3>
<pre><code class="language-bash">sudo systemctl start myapp
sudo systemctl stop myapp
sudo systemctl restart myapp
sudo systemctl status myapp

# 서버 부팅 시 자동 시작
sudo systemctl enable myapp

# 자동 시작 해제
sudo systemctl disable myapp

# 서비스 파일 수정 후 systemd가 다시 읽게 하기
sudo systemctl daemon-reload</code></pre>
<p>서비스 설정을 수정한 뒤에는 일반적으로 다음 순서로 확인한다.</p>
<pre><code class="language-text">서비스 설정 파일 수정
→ systemctl daemon-reload
→ systemctl restart myapp
→ systemctl status myapp
→ 로그 확인</code></pre>
<h3 id="systemd-서비스-파일-예시">systemd 서비스 파일 예시</h3>
<pre><code class="language-ini">[Unit]
Description=My Spring Boot Application
After=network.target

[Service]
User=appuser
Group=appgroup
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar
EnvironmentFile=/etc/myapp/myapp.env
Restart=on-failure
RestartSec=5
SuccessExitStatus=143

[Install]
WantedBy=multi-user.target</code></pre>
<h4 id="unit"><code>[Unit]</code></h4>
<p>서비스 자체의 설명과 실행 순서를 정의한다.</p>
<pre><code class="language-ini">Description=My Spring Boot Application
After=network.target</code></pre>
<p><code>After=network.target</code>은 네트워크 기본 구성이 준비된 뒤 실행 순서를 잡겠다는 의미다. 이것만으로 데이터베이스에 실제로 연결할 수 있는 상태까지 보장되는 것은 아니다.</p>
<h4 id="service"><code>[Service]</code></h4>
<p>애플리케이션의 실제 실행 방법을 정의한다.</p>
<pre><code class="language-ini">User=appuser
Group=appgroup</code></pre>
<p>애플리케이션을 root가 아닌 <code>appuser</code> 권한으로 실행한다.</p>
<pre><code class="language-ini">WorkingDirectory=/opt/myapp</code></pre>
<p>애플리케이션의 작업 디렉터리를 지정한다.</p>
<pre><code class="language-ini">ExecStart=/usr/bin/java -jar /opt/myapp/app.jar</code></pre>
<p>실제로 실행할 명령이다. systemd에서는 실행 파일의 절대 경로를 사용하는 것이 명확하다.</p>
<pre><code class="language-ini">EnvironmentFile=/etc/myapp/myapp.env</code></pre>
<p>환경 변수가 담긴 파일을 읽는다.</p>
<pre><code class="language-ini">Restart=on-failure
RestartSec=5</code></pre>
<p>비정상적으로 종료되면 5초 뒤 재시작한다. 설정 오류나 DB 연결 오류로 애플리케이션이 계속 실패하면 무한 재시작처럼 보일 수 있으므로 반드시 로그를 확인해야 한다.</p>
<h4 id="install"><code>[Install]</code></h4>
<p>부팅 과정의 어느 단계에서 서비스를 실행할지 정의한다.</p>
<pre><code class="language-ini">WantedBy=multi-user.target</code></pre>
<p>일반적인 서버 운영 단계에서 서비스가 실행되도록 설정한다.</p>
<h3 id="journalctl-systemd-서비스-로그-확인"><code>journalctl</code>: systemd 서비스 로그 확인</h3>
<p>systemd로 실행한 서비스의 표준 출력과 표준 오류는 보통 journal에 기록된다.</p>
<pre><code class="language-bash"># 전체 로그
journalctl -u myapp

# 실시간 로그
journalctl -u myapp -f

# 최근 100줄
journalctl -u myapp -n 100

# 최근 10분
journalctl -u myapp --since &quot;10 minutes ago&quot;

# 오늘 로그
journalctl -u myapp --since today

# 최신 로그부터 보기
journalctl -u myapp -r</code></pre>
<p>애플리케이션이 시작되지 않는다면 다음 두 명령을 먼저 확인한다.</p>
<pre><code class="language-bash">systemctl status myapp
journalctl -u myapp -n 100</code></pre>
<p><code>systemctl status</code>는 현재 상태를 요약하고, <code>journalctl</code>은 자세한 실행 기록을 보여 준다.</p>
<hr>
<h2 id="8-리눅스-네트워크-기초">8. 리눅스 네트워크 기초</h2>
<h3 id="ip-주소">IP 주소</h3>
<p>IP 주소는 네트워크에서 서버를 식별하는 주소다.</p>
<ul>
<li><code>127.0.0.1</code>: 자기 자신을 가리키는 루프백 주소</li>
<li>사설 IP: 내부 네트워크에서 사용하는 주소</li>
<li>공인 IP: 인터넷에서 접근할 수 있는 주소</li>
</ul>
<h3 id="포트">포트</h3>
<p>포트는 한 서버에서 여러 프로그램을 구분하기 위한 번호다.</p>
<pre><code class="language-text">22    SSH
80    HTTP
443   HTTPS
3306  MySQL
5432  PostgreSQL
6379  Redis
8080  애플리케이션에서 자주 사용하는 포트</code></pre>
<p>현재 열려 있는 listening 포트는 다음 명령으로 확인한다.</p>
<pre><code class="language-bash">ss -lntp</code></pre>
<ul>
<li><code>l</code>: 연결을 기다리는 listening 소켓</li>
<li><code>n</code>: 포트 번호를 숫자로 표시</li>
<li><code>t</code>: TCP 소켓</li>
<li><code>p</code>: 해당 포트를 사용하는 프로세스 표시</li>
</ul>
<h3 id="curl"><code>curl</code></h3>
<p>서버에서 HTTP 요청을 직접 보내는 도구다.</p>
<pre><code class="language-bash"># 애플리케이션 내부 접근 확인
curl http://127.0.0.1:8080

# 헬스 체크 API가 있다면
curl http://127.0.0.1:8080/health

# 응답 헤더만 확인
curl -I https://example.com

# 상세 통신 과정 확인
curl -v https://example.com</code></pre>
<p><code>curl</code> 결과를 이용하면 문제가 애플리케이션, Nginx, DNS, TLS 중 어느 범위에 있는지 좁힐 수 있다.</p>
<h3 id="dns">DNS</h3>
<p>DNS는 도메인 이름을 IP 주소로 바꾸어 준다.</p>
<pre><code class="language-text">api.example.com
        │ DNS 조회
        ▼
203.0.113.10</code></pre>
<p>DNS를 변경해도 전 세계에 즉시 반영되지 않을 수 있다. 기존 결과가 일정 시간 캐시에 남아 있을 수 있기 때문이다.</p>
<p>DNS가 잘못 설정되어 있으면 애플리케이션과 Nginx가 모두 정상이어도 사용자는 다른 서버에 접속할 수 있다.</p>
<h3 id="http와-https">HTTP와 HTTPS</h3>
<h4 id="http">HTTP</h4>
<p>클라이언트와 서버가 요청과 응답을 주고받는 규칙이다.</p>
<h4 id="https">HTTPS</h4>
<p>HTTP 통신을 TLS로 암호화한 것이다.</p>
<ul>
<li>통신 내용 암호화</li>
<li>서버가 해당 도메인의 정당한 서버인지 확인</li>
<li>통신 중 데이터 변조 방지</li>
</ul>
<p>HTTPS를 사용하려면 도메인에 맞는 TLS 인증서가 필요하다.</p>
<pre><code class="language-text">사용자 ↔ Nginx: HTTPS
Nginx ↔ 같은 서버의 애플리케이션: HTTP</code></pre>
<h3 id="http-상태-코드">HTTP 상태 코드</h3>
<table>
<thead>
<tr>
<th>상태 코드</th>
<th>일반적인 의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>200</code></td>
<td>정상 응답</td>
</tr>
<tr>
<td><code>301</code>, <code>302</code></td>
<td>다른 주소로 이동</td>
</tr>
<tr>
<td><code>400</code></td>
<td>요청 형식이 잘못됨</td>
</tr>
<tr>
<td><code>401</code></td>
<td>인증이 필요하거나 인증 실패</td>
</tr>
<tr>
<td><code>403</code></td>
<td>접근 권한 없음</td>
</tr>
<tr>
<td><code>404</code></td>
<td>해당 경로 없음</td>
</tr>
<tr>
<td><code>500</code></td>
<td>애플리케이션 내부 오류</td>
</tr>
<tr>
<td><code>502</code></td>
<td>프록시가 백엔드 애플리케이션과 정상적으로 연결하지 못함</td>
</tr>
<tr>
<td><code>503</code></td>
<td>서비스를 사용할 수 없음</td>
</tr>
<tr>
<td><code>504</code></td>
<td>백엔드 응답 시간 초과</td>
</tr>
</tbody></table>
<p>상태 코드별로 우선 확인할 부분은 다음과 같다.</p>
<ul>
<li><code>404</code>: Nginx 경로 또는 애플리케이션 라우팅 확인</li>
<li><code>500</code>: 애플리케이션 로그 확인</li>
<li><code>502</code>: 애플리케이션 실행 여부와 포트 확인</li>
<li><code>504</code>: 느린 쿼리, 외부 API, 타임아웃 확인</li>
</ul>
<hr>
<h2 id="9-ssh로-서버-접속하기">9. SSH로 서버 접속하기</h2>
<p>SSH는 원격 서버에 암호화된 연결로 접속하는 방법이다.</p>
<pre><code class="language-bash">ssh deploy@server-ip

# 개인 키를 지정한다면
ssh -i company-server-key.pem deploy@server-ip</code></pre>
<p>각 요소의 의미는 다음과 같다.</p>
<ul>
<li><code>deploy</code>: 서버의 사용자 이름</li>
<li><code>server-ip</code>: 접속할 서버 주소</li>
<li><code>-i</code>: 사용할 개인 키 파일 지정</li>
</ul>
<h3 id="공개-키와-개인-키">공개 키와 개인 키</h3>
<p>SSH 키 인증은 한 쌍의 키를 사용한다.</p>
<ul>
<li><strong>개인 키</strong>: 내 컴퓨터에 보관</li>
<li><strong>공개 키</strong>: 서버에 등록</li>
</ul>
<blockquote>
<p>개인 키는 Git 저장소에 커밋하거나 이메일·메신저로 공유하면 안 된다. 사람마다 별도의 키를 사용하고, 가능하면 서버에서 root 직접 로그인을 제한하는 편이 안전하다.</p>
</blockquote>
<hr>
<h2 id="10-실제-배포-흐름">10. 실제 배포 흐름</h2>
<p>앞에서 배운 내용을 실제 Spring Boot 배포 순서로 연결해 보자. 다만 회사마다 인프라와 배포 도구가 다르므로, 운영 서버에서 처음 보는 명령을 바로 실행하기보다 기존 배포 문서와 스크립트를 먼저 확인해야 한다.</p>
<h3 id="1단계-배포-대상과-방식-확인">1단계: 배포 대상과 방식 확인</h3>
<p>배포 전에는 다음 정보를 확인한다.</p>
<pre><code class="language-text">서버 주소
SSH 사용자
배포 경로
애플리케이션 실행 사용자
서비스 이름
사용 포트
로그 위치
설정 파일 위치
배포 전 백업 방법
DB 마이그레이션 여부
롤백 방법</code></pre>
<h3 id="2단계-애플리케이션-빌드">2단계: 애플리케이션 빌드</h3>
<p>Spring Boot 예시는 다음과 같다.</p>
<pre><code class="language-bash">./gradlew clean build</code></pre>
<p>결과 파일은 보통 다음 경로에 생성된다.</p>
<pre><code class="language-text">build/libs/myapp.jar</code></pre>
<p>실무에서는 개발자 PC보다 CI 서버에서 빌드하는 편이 재현성과 보안 측면에서 좋다.</p>
<h3 id="3단계-파일-전달">3단계: 파일 전달</h3>
<pre><code class="language-bash">scp build/libs/myapp.jar deploy@server:/opt/myapp/releases/myapp-1.2.3.jar</code></pre>
<p>파일을 버전별로 보관하면 기존 버전을 덮어쓰지 않으므로 롤백하기 쉽다.</p>
<h3 id="4단계-설정과-권한-확인">4단계: 설정과 권한 확인</h3>
<pre><code class="language-bash">ls -l /opt/myapp/releases/
ls -l /etc/myapp/myapp.env</code></pre>
<p>애플리케이션을 실행하는 사용자가 필요한 파일을 읽을 수 있는지 확인한다.</p>
<h3 id="5단계-db-마이그레이션">5단계: DB 마이그레이션</h3>
<p>스키마 변경이 있다면 Flyway나 Liquibase 같은 도구를 사용할 수 있다.</p>
<p>DB 마이그레이션은 특히 주의해야 한다. 애플리케이션만 이전 버전으로 되돌려도 DB 구조가 이미 변경되었다면 완전한 롤백이 불가능할 수 있기 때문이다.</p>
<p>가능하면 다음처럼 구버전과 신버전이 함께 호환되는 순서로 변경한다.</p>
<pre><code class="language-text">1. 기존 코드와 새 코드가 함께 사용할 수 있는 컬럼 추가
2. 새 코드 배포
3. 데이터 이전
4. 충분히 검증한 뒤 오래된 컬럼 제거</code></pre>
<h3 id="6단계-서비스-재시작">6단계: 서비스 재시작</h3>
<pre><code class="language-bash">sudo systemctl restart myapp</code></pre>
<p>재시작 직후에는 반드시 상태와 로그를 확인한다.</p>
<pre><code class="language-bash">sudo systemctl status myapp
journalctl -u myapp -n 100</code></pre>
<h3 id="7단계-서버-내부-헬스-체크">7단계: 서버 내부 헬스 체크</h3>
<pre><code class="language-bash">curl http://127.0.0.1:8080/health</code></pre>
<h3 id="8단계-외부-경로-확인">8단계: 외부 경로 확인</h3>
<pre><code class="language-bash">curl -I https://api.example.com</code></pre>
<p>홈페이지만 열리는지 확인하는 데서 끝내지 말고 핵심 기능도 확인해야 한다.</p>
<ul>
<li>로그인</li>
<li>DB 조회</li>
<li>데이터 생성</li>
<li>외부 API 연동</li>
<li>파일 업로드</li>
<li>비동기 작업</li>
</ul>
<h3 id="9단계-로그와-지표-관찰">9단계: 로그와 지표 관찰</h3>
<p>배포 직후 일정 시간 동안 다음 항목을 확인한다.</p>
<ul>
<li>오류 로그 증가 여부</li>
<li>HTTP 5xx 증가 여부</li>
<li>CPU와 메모리 사용량</li>
<li>DB 연결 상태</li>
<li>응답 시간</li>
<li>재시작 횟수</li>
</ul>
<h3 id="10단계-문제-발생-시-롤백">10단계: 문제 발생 시 롤백</h3>
<p>롤백은 이전 애플리케이션 파일을 다시 실행하는 것만으로 끝나지 않을 수 있다.</p>
<p>확인해야 할 항목은 다음과 같다.</p>
<ul>
<li>이전 버전 파일이 남아 있는가?</li>
<li>설정 파일도 이전 상태로 되돌려야 하는가?</li>
<li>DB 스키마가 이전 버전과 호환되는가?</li>
<li>배포 중 생성된 데이터는 어떻게 처리할 것인가?</li>
<li>캐시나 메시지 형식이 이전 버전과 호환되는가?</li>
</ul>
<p>롤백 방법은 장애가 발생한 뒤 만드는 것이 아니라 배포 전에 준비해야 한다.</p>
<hr>
<h2 id="11-무중단-배포의-기본-개념">11. 무중단 배포의 기본 개념</h2>
<p>단순 재시작 방식에서는 기존 애플리케이션이 종료되고 새 애플리케이션이 완전히 시작될 때까지 요청을 처리할 수 없다.</p>
<pre><code class="language-text">기존 애플리케이션 종료
        ↓
새 애플리케이션 시작
        ↓
시작 완료까지 서비스 중단</code></pre>
<p>무중단 배포에서는 보통 두 개 이상의 애플리케이션 인스턴스를 둔다.</p>
<pre><code class="language-text">             ┌─ 애플리케이션 A
사용자 ─ Nginx
             └─ 애플리케이션 B</code></pre>
<p>배포 순서는 다음과 같다.</p>
<ol>
<li>A가 요청을 처리하는 동안 B에 새 버전 배포</li>
<li>B의 헬스 체크 성공 확인</li>
<li>요청을 B로 전환</li>
<li>A를 종료하거나 새 버전으로 교체</li>
</ol>
<p>이를 구현하는 방법에는 다음과 같은 것들이 있다.</p>
<ul>
<li>Blue-Green 배포</li>
<li>Rolling 배포</li>
<li>Nginx upstream 전환</li>
<li>Docker Compose</li>
<li>Kubernetes Deployment</li>
<li>클라우드 로드 밸런서</li>
</ul>
<p>처음부터 무중단 배포를 구현하기보다, 먼저 수동 배포와 롤백 원리를 정확히 이해하는 것이 좋다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>리눅스 배포에서 중요한 것은 명령어를 외우는 것만이 아니다. 요청이 DNS, 방화벽, Nginx, 애플리케이션을 거쳐 데이터베이스까지 어떻게 이동하는지 이해해야 문제가 생겼을 때 원인을 빠르게 좁힐 수 있다.</p>
<p>처음에는 다음 세 가지 흐름부터 익혀 두면 좋다.</p>
<ol>
<li><code>systemctl status</code>와 <code>journalctl</code>로 애플리케이션 상태와 로그 확인하기</li>
<li><code>ss</code>와 <code>curl</code>로 포트와 요청 경로 확인하기</li>
<li>버전별 파일 보관과 DB 호환성을 고려해 롤백 준비하기</li>
</ol>
<p>이 기본 흐름을 이해한 뒤 CI/CD, Docker, 무중단 배포로 범위를 넓혀 가면 배포 과정이 훨씬 선명하게 보일 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] ForPets AI 도전 기능 1 - RAG 파이프라인]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-ForPets-AI-%EB%8F%84%EC%A0%84-%EA%B8%B0%EB%8A%A5-1-RAG-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-ForPets-AI-%EB%8F%84%EC%A0%84-%EA%B8%B0%EB%8A%A5-1-RAG-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8</guid>
            <pubDate>Tue, 09 Jun 2026 07:00:41 GMT</pubDate>
            <description><![CDATA[<h1 id="forpets-rag-파이프라인-구축기-리뷰-데이터를-ai-추천-근거로-활용하기">ForPets RAG 파이프라인 구축기: 리뷰 데이터를 AI 추천 근거로 활용하기</h1>
<p>ForPets 프로젝트에서 AI 도전 기능으로 <strong>RAG 파이프라인</strong>을 구현했다.</p>
<p>기존 AI 시터 추천 챗봇은 시터의 기본 정보, 리뷰 요약, 스케줄 정보를 기반으로 추천을 제공했다. 이번 작업에서는 여기서 한 단계 더 나아가, 실제 보호자 리뷰 원문을 벡터화해서 검색 가능한 지식 베이스로 만들고, 사용자의 자연어 질문과 의미적으로 유사한 리뷰를 찾아 AI 추천 응답의 근거로 활용하도록 확장했다.</p>
<p>즉, LLM이 그냥 “그럴듯하게” 추천하는 것이 아니라 실제 리뷰 데이터를 기반으로 응답하도록 만드는 것이 목표였다.</p>
<pre><code class="language-text">보호자 리뷰 데이터 조회
→ 리뷰 원문 임베딩 생성
→ Qdrant Vector DB 저장
→ 사용자 자연어 질문 임베딩
→ 유사 리뷰 시맨틱 검색
→ AI 추천 응답에 리뷰 근거 포함</code></pre>
<hr>
<h2 id="왜-rag가-필요했나">왜 RAG가 필요했나?</h2>
<p>시터 추천에서 중요한 건 단순 조건 검색만이 아니었다.</p>
<p>예를 들어 사용자는 이렇게 물어볼 수 있다.</p>
<pre><code class="language-text">분리불안 있는 말티즈를 차분하게 돌봐줄 시터 추천해줘
노령견을 천천히 산책시켜주는 시터 찾아줘
낯가림 있는 아이를 잘 기다려주는 시터 있어?</code></pre>
<p>이런 요청은 단순히 지역, 가격, 경력만으로 판단하기 어렵다. 실제 보호자 리뷰에 “분리불안”, “천천히 산책”, “억지로 만지지 않고 기다려줌” 같은 경험이 남아 있다면, 그 리뷰를 추천 근거로 활용하는 게 훨씬 설득력 있다.</p>
<p>그래서 리뷰 원문을 벡터화하고, 사용자 질문과 의미적으로 가까운 리뷰를 찾아 AI 추천에 붙이는 구조를 만들었다.</p>
<hr>
<h2 id="사용-기술">사용 기술</h2>
<p>이번 RAG 구현에는 아래 기술을 사용했다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>사용 기술</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>Embedding</td>
<td>Gemini <code>gemini-embedding-001</code></td>
<td>무료 테스트가 가능하고 리뷰 문장 임베딩에 사용 가능</td>
</tr>
<tr>
<td>Vector DB</td>
<td>Qdrant</td>
<td>Docker로 로컬 실행이 쉽고 payload 기반 출처 저장이 편함</td>
</tr>
<tr>
<td>Backend</td>
<td>Spring Boot</td>
<td>기존 ForPets 백엔드 구조 유지</td>
</tr>
<tr>
<td>HTTP Client</td>
<td>Spring <code>RestClient</code></td>
<td>Gemini API, Qdrant API 직접 호출</td>
</tr>
<tr>
<td>Similarity</td>
<td>Cosine Similarity</td>
<td>자연어 의미 유사도 비교에 적합</td>
</tr>
</tbody></table>
<hr>
<h2 id="전체-구조">전체 구조</h2>
<p>구현한 흐름은 크게 세 단계다.</p>
<pre><code class="language-text">1. 리뷰 인덱싱
   리뷰 원문 → Gemini Embedding → Qdrant 저장

2. RAG 검색
   사용자 질문 → Gemini Embedding → Qdrant 유사 리뷰 검색

3. AI 추천 챗봇 결합
   Tool Calling 추천 결과 + RAG 리뷰 근거 → 최종 응답</code></pre>
<hr>
<h2 id="1-리뷰-데이터-확인">1. 리뷰 데이터 확인</h2>
<p>RAG 인덱싱 대상은 <strong>COMPLETED 예약에 연결된 보호자 리뷰</strong>다.</p>
<p>리뷰는 실제 케어가 완료된 예약에 대해서만 작성될 수 있기 때문에, 추천 근거로 사용하기에 적합하다고 판단했다.</p>
<p>테스트용으로 시터 리뷰 데이터를 조회했다.</p>
<pre><code class="language-http">GET /api/reviews/sitters/13?page=0&amp;size=10&amp;sort=createdAt&amp;direction=desc</code></pre>
<p>응답 일부는 다음과 같다.</p>
<pre><code class="language-json">{
  &quot;id&quot;: 130,
  &quot;reservationId&quot;: 135,
  &quot;reviewerId&quot;: 3,
  &quot;revieweeId&quot;: 16,
  &quot;reviewComment&quot;: &quot;노령견이라 걱정했는데 이동 속도에 맞춰 천천히 산책해주시고 물 먹는 양도 확인해주셨습니다.&quot;,
  &quot;rating&quot;: 2
}</code></pre>
<p>이 리뷰 원문을 임베딩해서 Qdrant에 저장한다.
<img src="https://velog.velcdn.com/images/jiiim_ni/post/854a9665-978f-41f1-b79f-349b26dac492/image.png" alt=""></p>
<hr>
<h2 id="2-rag-인덱싱-구현">2. RAG 인덱싱 구현</h2>
<p>인덱싱 API는 다음과 같다.</p>
<pre><code class="language-http">POST /api/ai/rag/reviews/index</code></pre>
<p>처리 흐름은 다음과 같다.</p>
<pre><code class="language-text">1. COMPLETED 예약의 활성 리뷰 조회
2. 리뷰별 RagDocument 생성
3. Gemini Embedding API 호출
4. 리뷰 원문을 벡터로 변환
5. Qdrant collection 초기화
6. Qdrant에 vector + payload upsert
7. 성공/실패 개수 반환</code></pre>
<p>Qdrant payload에는 검색 결과의 출처를 표시하기 위해 다음 정보를 저장했다.</p>
<table>
<thead>
<tr>
<th>필드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>sourceType</td>
<td>REVIEW / REVIEW_SUMMARY</td>
</tr>
<tr>
<td>sourceId</td>
<td>원본 데이터 ID</td>
</tr>
<tr>
<td>reviewId</td>
<td>리뷰 ID</td>
</tr>
<tr>
<td>sitterId</td>
<td>시터 프로필 ID</td>
</tr>
<tr>
<td>rating</td>
<td>리뷰 평점</td>
</tr>
<tr>
<td>content</td>
<td>리뷰 원문</td>
</tr>
</tbody></table>
<p>테스트 결과는 다음과 같았다.</p>
<pre><code class="language-json">{
  &quot;success&quot;: true,
  &quot;data&quot;: {
    &quot;indexedCount&quot;: 100,
    &quot;failedCount&quot;: 30
  },
  &quot;error&quot;: null
}</code></pre>
<p>총 130개 대상 중 100개가 인덱싱되었고, 30개는 실패 처리되었다. 실패한 문서가 있어도 전체 인덱싱을 중단하지 않고 가능한 문서만 우선 저장하도록 했다.
<img src="https://velog.velcdn.com/images/jiiim_ni/post/fcf85857-276b-4ddf-8863-b8b845fb8176/image.png" alt=""></p>
<hr>
<h2 id="3-rag-검색-구현">3. RAG 검색 구현</h2>
<p>검색 API는 다음과 같다.</p>
<pre><code class="language-http">POST /api/ai/rag/search</code></pre>
<p>요청 예시:</p>
<pre><code class="language-json">{
  &quot;query&quot;: &quot;노령견을 천천히 산책시켜주는 시터&quot;
}</code></pre>
<p>처리 흐름은 다음과 같다.</p>
<pre><code class="language-text">1. 사용자 자연어 질문 수신
2. 질문을 Gemini Embedding API로 벡터화
3. Qdrant에서 cosine similarity 기반 유사 리뷰 검색
4. score_threshold 이상인 결과만 반환
5. sourceId, reviewId, sitterId, snippet, score 포함</code></pre>
<p>응답 예시:</p>
<pre><code class="language-json">{
  &quot;sourceType&quot;: &quot;REVIEW&quot;,
  &quot;sourceId&quot;: 100,
  &quot;reviewId&quot;: 100,
  &quot;sitterId&quot;: 10,
  &quot;rating&quot;: 5,
  &quot;snippet&quot;: &quot;노령견이라 걱정했는데 이동 속도에 맞춰 천천히 산책해주시고 물 먹는 양도 확인해주셨습니다.&quot;,
  &quot;score&quot;: 0.8516915
}</code></pre>
<p>“노령견을 천천히 산책시켜주는 시터”라는 질문에 대해 실제 리뷰 원문 중 “노령견”, “천천히 산책”, “물 먹는 양 확인”과 의미적으로 가까운 리뷰가 검색되는 것을 확인했다.
<img src="https://velog.velcdn.com/images/jiiim_ni/post/418b78d6-0dc2-4503-974f-900ad260e041/image.png" alt=""></p>
<hr>
<h2 id="4-ai-추천-챗봇과-rag-결합">4. AI 추천 챗봇과 RAG 결합</h2>
<p>기존 AI 추천 챗봇에도 RAG 검색 결과를 연결했다.</p>
<pre><code class="language-http">POST /api/ai/chat</code></pre>
<p>요청:</p>
<pre><code class="language-json">{
  &quot;message&quot;: &quot;분리불안 있는 말티즈를 차분하게 돌봐줄 시터 추천해줘&quot;
}</code></pre>
<p>응답에는 추천 시터 목록과 함께 RAG 검색 출처가 포함된다.</p>
<pre><code class="language-json">{
  &quot;sourceType&quot;: &quot;REVIEW&quot;,
  &quot;sourceId&quot;: 61,
  &quot;reviewId&quot;: 61,
  &quot;sitterId&quot;: 7,
  &quot;rating&quot;: 5,
  &quot;snippet&quot;: &quot;말티즈가 낯을 많이 가리는데 천천히 적응시켜주시고 사진도 자주 보내주셔서 안심됐습니다.&quot;,
  &quot;score&quot;: 0.8104445
}</code></pre>
<p>AI 답변에도 실제 리뷰 근거가 포함되도록 했다.</p>
<pre><code class="language-text">리뷰 근거: 리뷰 #61 / 시터 #7 / &quot;말티즈가 낯을 많이 가리는데 천천히 적응시켜주시고 사진도 자주 보내주셔서 안심됐습니다.&quot;</code></pre>
<p>이렇게 하면 사용자는 추천 결과가 어떤 리뷰를 근거로 만들어졌는지 확인할 수 있다.
<img src="https://velog.velcdn.com/images/jiiim_ni/post/e090482f-0cb1-4263-92a0-4223e4cc2f29/image.png" alt=""></p>
<hr>
<h2 id="5-검색-결과-없음-fallback">5. 검색 결과 없음 fallback</h2>
<p>ForPets 도메인과 관련 없는 질문도 테스트했다.</p>
<p>요청:</p>
<pre><code class="language-json">{
  &quot;query&quot;: &quot;자동차 엔진오일을 잘 교체하는 시터&quot;
}</code></pre>
<p>응답:</p>
<pre><code class="language-json">{
  &quot;success&quot;: true,
  &quot;data&quot;: {
    &quot;results&quot;: []
  },
  &quot;error&quot;: null
}</code></pre>
<p>관련 없는 질문에 대해 억지로 리뷰를 끼워 맞추지 않고 빈 결과를 반환했다. 환각 방어 관점에서 이 동작이 중요하다고 봤다.
<img src="https://velog.velcdn.com/images/jiiim_ni/post/d821d8c0-09a1-40c1-bf12-98dc67444031/image.png" alt=""></p>
<hr>
<h2 id="6-qdrant-장애-fallback">6. Qdrant 장애 fallback</h2>
<p>Qdrant 장애 상황도 고려했다.</p>
<p>RAG는 추천 품질을 높이는 보조 기능이지, 핵심 추천 기능 자체가 아니다. 따라서 Qdrant 검색에 실패해도 챗봇 전체가 죽으면 안 된다.</p>
<pre><code class="language-text">Qdrant 검색 실패
→ RAG sources 빈 리스트 반환
→ Tool Calling 기반 추천은 계속 진행</code></pre>
<p>장애 상황에서도 응답 형태는 유지되도록 처리했다.</p>
<pre><code class="language-json">{
  &quot;success&quot;: true,
  &quot;data&quot;: {
    &quot;results&quot;: []
  },
  &quot;error&quot;: null
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/1633d9c4-d77c-4260-9e2a-8c5cd75f643d/image.png" alt=""></p>
<hr>
<h2 id="트러블슈팅-1-gemini-embedding-모델-오류">트러블슈팅 1. Gemini Embedding 모델 오류</h2>
<p>처음에는 <code>text-embedding-004</code> 모델을 사용했다.</p>
<p>그런데 Gemini API 호출 시 다음 오류가 발생했다.</p>
<pre><code class="language-text">models/text-embedding-004 is not found for API version v1beta</code></pre>
<p>현재 호출 방식에서는 해당 모델을 사용할 수 없었다.</p>
<p>그래서 embedding model을 다음과 같이 변경했다.</p>
<pre><code class="language-yaml">forpets:
  ai:
    gemini:
      embedding-model: gemini-embedding-001</code></pre>
<hr>
<h2 id="트러블슈팅-2-qdrant-collection-중복-생성">트러블슈팅 2. Qdrant Collection 중복 생성</h2>
<p>인덱싱 API를 여러 번 호출하니 Qdrant에서 collection이 이미 존재한다는 오류가 발생했다.</p>
<pre><code class="language-text">Collection `forpets_reviews` already exists</code></pre>
<p>인덱싱 API는 여러 번 호출해도 안전해야 한다. 그래서 collection이 이미 존재하면 실패로 보지 않고 재사용하도록 처리했다.</p>
<pre><code class="language-text">collection 없음 → 새로 생성
collection 있음 → 재사용</code></pre>
<hr>
<h2 id="트러블슈팅-3-vector-dimension-불일치">트러블슈팅 3. Vector Dimension 불일치</h2>
<p>가장 오래 잡아먹은 문제는 벡터 차원 불일치였다.</p>
<p>Gemini <code>gemini-embedding-001</code>은 3072차원 벡터를 반환하는데, Qdrant collection은 처음에 768차원으로 생성되어 있었다.</p>
<p>그 결과 다음 오류가 발생했다.</p>
<pre><code class="language-text">Vector dimension error: expected dim: 768, got 3072</code></pre>
<p>해결 방법은 두 가지였다.</p>
<p>먼저 설정을 3072로 변경했다.</p>
<pre><code class="language-yaml">forpets:
  ai:
    rag:
      vector-size: 3072</code></pre>
<p>그리고 기존 collection이 이미 생성되어 있는 경우를 위해, collection의 vector size를 확인한 뒤 설정값과 다르면 RAG 전용 collection을 삭제 후 재생성하도록 처리했다.</p>
<pre><code class="language-text">collection 있음 + vector size 일치 → 재사용
collection 있음 + vector size 불일치 → 삭제 후 재생성</code></pre>
<p>이렇게 해서 로컬 테스트 중에도 이전 설정으로 만들어진 collection 때문에 계속 실패하는 문제를 해결했다.</p>
<hr>
<h2 id="postman-테스트-결과">Postman 테스트 결과</h2>
<p>최종적으로 Postman에서 6개 시나리오를 테스트했다.</p>
<table>
<thead>
<tr>
<th>번호</th>
<th>테스트 항목</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>리뷰 데이터 확인</td>
<td>시터별 리뷰 정상 조회</td>
</tr>
<tr>
<td>2</td>
<td>RAG 인덱싱</td>
<td><code>indexedCount=100</code>, <code>failedCount=30</code></td>
</tr>
<tr>
<td>3</td>
<td>RAG 검색</td>
<td>자연어 질문과 유사한 리뷰 검색 성공</td>
</tr>
<tr>
<td>4</td>
<td>AI 챗봇 + RAG 근거 확인</td>
<td>추천 응답에 <code>sources</code>와 리뷰 근거 포함</td>
</tr>
<tr>
<td>5</td>
<td>검색 결과 없음 fallback</td>
<td>도메인과 무관한 질문에 빈 결과 반환</td>
</tr>
<tr>
<td>6</td>
<td>Qdrant 장애 fallback</td>
<td>장애 시 빈 결과 반환, 서비스 응답 정상 유지</td>
</tr>
</tbody></table>
<hr>
<h2 id="과제-요구사항-매핑">과제 요구사항 매핑</h2>
<table>
<thead>
<tr>
<th>요구사항</th>
<th>구현 내용</th>
</tr>
</thead>
<tbody><tr>
<td>도메인 데이터를 임베딩하여 벡터 DB에 저장</td>
<td>보호자 리뷰 원문을 Gemini Embedding으로 벡터화 후 Qdrant 저장</td>
</tr>
<tr>
<td>자연어 질의 기반 시맨틱 검색</td>
<td>사용자 질문을 임베딩하여 Qdrant에서 유사 리뷰 검색</td>
</tr>
<tr>
<td>검색 결과 출처 포함</td>
<td><code>sourceId</code>, <code>reviewId</code>, <code>sitterId</code>, <code>snippet</code>, <code>score</code> 반환</td>
</tr>
<tr>
<td>유사도 임계값 설정</td>
<td><code>score-threshold: 0.7</code> 적용</td>
</tr>
<tr>
<td>검색 결과 없음 fallback</td>
<td>관련 없는 질문에 <code>results: []</code> 반환</td>
</tr>
<tr>
<td>RAG + Tool Calling 결합</td>
<td>AI 추천 챗봇 응답에 RAG <code>sources</code> 포함</td>
</tr>
<tr>
<td>장애 fallback</td>
<td>Qdrant 실패 시 빈 sources 반환, 추천 기능은 유지</td>
</tr>
</tbody></table>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 RAG 작업을 통해 ForPets의 AI 추천 기능은 단순 조건 검색 기반 추천에서 한 단계 확장되었다.</p>
<p>최종 구조는 다음과 같다.</p>
<pre><code class="language-text">리뷰 원문
→ Gemini Embedding
→ Qdrant 저장
→ 자연어 질문 임베딩
→ 유사 리뷰 검색
→ AI 추천 응답에 리뷰 근거 포함</code></pre>
<p>이제 사용자는 “분리불안 있는 말티즈”, “노령견 산책”, “낯가림 있는 아이”처럼 정확한 키워드가 아니더라도, 실제 리뷰 원문을 기반으로 더 신뢰할 수 있는 시터 추천을 받을 수 있다.</p>
<p>가장 크게 느낀 점은 RAG는 단순히 “벡터 DB 붙이기”가 아니라는 것이다. 모델의 임베딩 차원, Vector DB collection 설정, 검색 threshold, 출처 payload, 장애 fallback까지 함께 맞아야 실제 서비스 기능으로 쓸 수 있었다.</p>
<p><a href="https://documenter.getpostman.com/view/51114630/2sBXwqsBWh">Postman 테스트 링크</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] Forpets - Grafana 대시보드로 AI 메트릭 확인하기]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-Forpets-Grafana-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C%EB%A1%9C-AI-%EB%A9%94%ED%8A%B8%EB%A6%AD-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-Forpets-Grafana-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C%EB%A1%9C-AI-%EB%A9%94%ED%8A%B8%EB%A6%AD-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 05 Jun 2026 08:08:36 GMT</pubDate>
            <description><![CDATA[<h1 id="grafana-대시보드로-ai-메트릭-확인하기">Grafana 대시보드로 AI 메트릭 확인하기</h1>
<p>이번에는 ForPets 프로젝트의 AI 기능에 대해 Prometheus와 Grafana를 연결해 메트릭을 확인해보았다.</p>
<p>AI 기능은 Gemini API를 사용하고 있기 때문에 단순히 “응답이 잘 온다”에서 끝내면 안 된다고 생각했다.<br>운영 관점에서는 다음과 같은 정보를 확인할 수 있어야 한다.</p>
<ul>
<li>AI 요청이 몇 번 발생했는지</li>
<li>요청이 성공했는지, fallback으로 처리되었는지</li>
<li>응답 시간이 얼마나 걸렸는지</li>
<li>토큰을 얼마나 사용했는지</li>
<li>에러가 어떤 유형으로 발생했는지</li>
</ul>
<p>이를 위해 Spring Boot에서 Micrometer로 AI 메트릭을 기록하고, Prometheus가 이를 수집한 뒤 Grafana에서 시각화하는 방식으로 구성했다.</p>
<hr>
<h2 id="1-백엔드-실행-및-actuator-메트릭-확인">1. 백엔드 실행 및 Actuator 메트릭 확인</h2>
<p>먼저 로컬에서 백엔드를 실행했다.</p>
<pre><code class="language-bash">export GEMINI_API_KEY=나의_제미나이_키
./gradlew bootRun --args=&#39;--spring.profiles.active=local,ai&#39;</code></pre>
<p>이후 브라우저에서 Actuator Prometheus endpoint에 접속했다.</p>
<pre><code class="language-text">http://localhost:8080/actuator/prometheus</code></pre>
<p>AI 리뷰 요약 또는 AI 추천 챗봇을 한 번 실행한 뒤, <code>ai_requests_total</code>을 검색해보았다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/6abf53e4-2ad6-4737-8c31-5d1021bb608b/image.png" alt=""></p>
<p>확인된 값은 다음과 같았다.</p>
<pre><code class="language-text">ai_requests_total ... status=&quot;SUCCESS&quot; 1.0</code></pre>
<p>AI 리뷰 요약 요청이 1번 성공했다는 의미다.</p>
<pre><code class="language-text">ai_request_duration_seconds_sum ... 4.641</code></pre>
<p>AI 요청 처리에 약 4.641초가 걸렸다는 의미다.</p>
<pre><code class="language-text">ai_tokens_sum ... 745.0</code></pre>
<p>Gemini API 호출에서 총 745 tokens를 사용했다는 의미다.</p>
<p>이 단계에서 확인할 수 있었던 것은 다음과 같다.</p>
<ul>
<li>AI 요청 수 수집</li>
<li>성공/실패 status 태그 수집</li>
<li>feature 태그 수집</li>
<li>model 태그 수집</li>
<li>응답 시간 수집</li>
<li>토큰 사용량 수집</li>
</ul>
<hr>
<h2 id="2-prometheus-연결-확인">2. Prometheus 연결 확인</h2>
<p>Prometheus와 Grafana를 실행했다.</p>
<pre><code class="language-bash">docker compose up -d prometheus grafana</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/f4783fcb-d6f9-4ce2-97df-09ac1d0542ee/image.png" alt=""></p>
<p>Prometheus는 아래 주소에서 확인할 수 있다.</p>
<pre><code class="language-text">http://localhost:9090</code></pre>
<p>상단 검색창에 다음 쿼리를 입력했다.</p>
<pre><code class="language-promql">ai_requests_total</code></pre>
<p>처음에는 값이 나오지 않았다.</p>
<p>원인은 Prometheus target 설정이었다.<br>현재 백엔드는 Docker 컨테이너가 아니라 로컬에서 직접 실행 중이었기 때문에, Prometheus 컨테이너가 로컬 백엔드에 접근할 수 있도록 target을 수정해야 했다.</p>
<p><code>monitoring/prometheus.yml</code>에서 target을 아래처럼 임시로 변경했다.</p>
<pre><code class="language-yaml">targets: [&#39;host.docker.internal:8080&#39;]</code></pre>
<p>이후 Prometheus를 재시작했다.</p>
<pre><code class="language-bash">docker compose restart prometheus</code></pre>
<p>다시 아래 주소에서 target 상태를 확인했다.</p>
<pre><code class="language-text">http://localhost:9090/targets</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/1604db03-74ec-4625-9454-0efab49140fa/image.png" alt=""></p>
<p>상태가 <code>UP</code>으로 표시되면 Prometheus가 Spring Boot 애플리케이션의 메트릭을 정상적으로 수집하고 있다는 뜻이다.</p>
<p>다시 <code>ai_requests_total</code>을 검색해보면 다음과 같이 값이 잘 조회된다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/9d5f6a93-bbf5-4f9a-b55c-bf6e318068d6/image.png" alt=""></p>
<p>확인된 정보는 다음과 같다.</p>
<pre><code class="language-text">job=&quot;for-pets&quot;
instance=&quot;host.docker.internal:8080&quot;
ai_requests_total ... status=&quot;SUCCESS&quot; 1</code></pre>
<p>즉, 아래 흐름이 정상적으로 연결된 것이다.</p>
<pre><code class="language-text">Spring Boot /actuator/prometheus
→ Prometheus scrape
→ AI 메트릭 저장</code></pre>
<hr>
<h2 id="3-grafana-대시보드-구성">3. Grafana 대시보드 구성</h2>
<p>Grafana는 아래 주소로 접속했다.</p>
<pre><code class="language-text">http://localhost:3000</code></pre>
<p>기본 계정은 다음과 같다.</p>
<pre><code class="language-text">ID: admin
PW: admin</code></pre>
<p>Grafana에서 <code>AI Monitoring</code> 대시보드를 생성하고, 다음 5개의 패널을 구성했다.</p>
<hr>
<h3 id="3-1-ai-요청-수">3-1. AI 요청 수</h3>
<p>Visualization: <code>Time series</code> 또는 <code>Stat</code></p>
<pre><code class="language-promql">sum(ai_requests_total)</code></pre>
<hr>
<h3 id="3-2-ai-fallback실패율">3-2. AI Fallback/실패율</h3>
<p>Visualization: <code>Stat</code></p>
<pre><code class="language-promql">(sum(ai_requests_total{status!=&quot;SUCCESS&quot;}) or vector(0)) / sum(ai_requests_total)</code></pre>
<hr>
<h3 id="3-3-ai-평균-응답-시간">3-3. AI 평균 응답 시간</h3>
<p>Visualization: <code>Stat</code></p>
<pre><code class="language-promql">sum(ai_request_duration_seconds_sum) / sum(ai_request_duration_seconds_count)</code></pre>
<hr>
<h3 id="3-4-ai-토큰-사용량">3-4. AI 토큰 사용량</h3>
<p>Visualization: <code>Bar chart</code> 또는 <code>Time series</code></p>
<pre><code class="language-promql">sum by (feature, model) (ai_tokens_sum)</code></pre>
<hr>
<h3 id="3-5-ai-에러-타입별-발생-수">3-5. AI 에러 타입별 발생 수</h3>
<p>Visualization: <code>Bar chart</code></p>
<pre><code class="language-promql">sum by (errorType) (ai_requests_total{status!=&quot;SUCCESS&quot;})</code></pre>
<p>생성된 Grafana snapshot은 아래에서 확인할 수 있다.</p>
<pre><code class="language-text">http://localhost:3000/dashboard/snapshot/8DNFMuE79sYJXq36bPAhyP3ERB8mwNZe</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/4170c104-a352-4f6c-8edf-90960cdd8f65/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/33f4e557-dce5-41ac-9ab0-20004399b008/image.png" alt=""></p>
<hr>
<h2 id="4-기술-선택-근거">4. 기술 선택 근거</h2>
<h3 id="micrometer">Micrometer</h3>
<p>Spring Boot 애플리케이션 내부에서 메트릭을 코드 레벨로 기록하기 쉽다.<br>AI 요청 수, 응답 시간, 토큰 사용량 같은 지표를 직접 정의해 기록할 수 있었다.</p>
<h3 id="prometheus">Prometheus</h3>
<p><code>/actuator/prometheus</code> endpoint를 주기적으로 수집해서 시계열 데이터로 저장할 수 있다.<br>Spring Boot Actuator와 연동이 쉽고, Grafana와의 조합도 일반적으로 많이 사용된다.</p>
<h3 id="grafana">Grafana</h3>
<p>Prometheus에 저장된 메트릭을 대시보드로 시각화할 수 있다.<br>AI 요청 수, fallback 비율, 응답 시간, 토큰 사용량을 한 화면에서 확인할 수 있어 운영 관점에서 유용하다.</p>
<h3 id="redis-rate-limit">Redis Rate Limit</h3>
<p>AI 요약 갱신 버튼을 반복 클릭하는 경우 외부 LLM API 호출이 과도하게 발생할 수 있다.<br>Redis를 사용해 동일 사용자와 동일 시터 기준으로 요청 횟수를 제한하고, 초과 시 <code>429 Too Many Requests</code>를 반환하도록 설계했다.</p>
<hr>
<h2 id="5-장애-격리-설계">5. 장애 격리 설계</h2>
<p>AI 기능은 사용자 경험을 개선하는 보조 기능이다.<br>따라서 Gemini API 장애가 발생하더라도 시터 조회, 리뷰 조회, 예약, 결제 같은 핵심 기능에 영향을 주면 안 된다.</p>
<p>이를 위해 다음과 같이 장애 격리를 적용했다.</p>
<ul>
<li>Gemini API 실패 시 리뷰 원문 기반 fallback 요약 저장</li>
<li>Tool Calling 실패 시 서버 기본 추천 응답으로 fallback</li>
<li>Redis 장애 시 rate limit은 fail-open 처리</li>
<li>AI 사용량 로그/메트릭 저장 실패가 사용자 흐름에 전파되지 않도록 best-effort 처리</li>
</ul>
<hr>
<h2 id="6-테스트한-장애-시나리오">6. 테스트한 장애 시나리오</h2>
<p>다음 장애 시나리오를 테스트했다.</p>
<ul>
<li>AI 요약 요청 제한 초과 → <code>429 Too Many Requests</code></li>
<li>Redis 장애 → fail-open 처리</li>
<li>AI 호출 실패 → fallback 요약 저장</li>
<li>AI 응답에 금지 표현 포함 → fallback 처리</li>
<li>AI 응답 DTO 스키마 불일치 → fallback 처리</li>
<li>로그 저장 실패 → 사용자 흐름 정상 유지</li>
</ul>
<hr>
<h2 id="7-대시보드-결과">7. 대시보드 결과</h2>
<p>이번 테스트에서 확인한 결과는 다음과 같다.</p>
<pre><code class="language-text">AI 요청 수: 1건
Fallback/실패율: 0%
평균 응답 시간: 4.64초
토큰 사용량: 745 tokens</code></pre>
<p>이를 통해 AI 기능이 단순히 호출되는 것뿐만 아니라, 성공 여부와 응답 시간, 토큰 사용량까지 관측 가능한 상태가 되었음을 확인할 수 있었다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 작업을 통해 AI 기능도 일반 API처럼 운영 관점에서 관측 가능해야 한다는 것을 체감했다.</p>
<p>특히 LLM API는 외부 서비스이기 때문에 timeout, rate limit, 응답 형식 오류, 비용 증가 같은 문제가 발생할 수 있다.<br>따라서 fallback 처리뿐만 아니라 요청 수, 실패율, 응답 시간, 토큰 사용량을 함께 모니터링하는 것이 중요하다고 느꼈다.</p>
<p>이번에는 기본적인 AI 메트릭 대시보드를 구성했고, 이후에는 P95/P99 응답 시간이나 기능별 비용 추적을 더 정교하게 개선해볼 수 있을 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] 플러스 스프링 프로젝트 - 팀프로젝트 Ticket_Javara]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-%ED%94%8C%EB%9F%AC%EC%8A%A4-%EC%8A%A4%ED%94%84%EB%A7%81-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%8C%80%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-TicketJavara</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-%ED%94%8C%EB%9F%AC%EC%8A%A4-%EC%8A%A4%ED%94%84%EB%A7%81-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%8C%80%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-TicketJavara</guid>
            <pubDate>Tue, 19 May 2026 07:54:15 GMT</pubDate>
            <description><![CDATA[<h1 id="🎫-ticket_javara-팀-프로젝트-til--동시성과-트랜잭션-그리고-실서비스처럼-고민했던-3주">🎫 Ticket_Javara 팀 프로젝트 TIL — 동시성과 트랜잭션, 그리고 “실서비스처럼” 고민했던 3주</h1>
<blockquote>
<p>스파르타 내일배움캠프 Spring Plus 팀 프로젝트
공연·스포츠 티켓팅 플랫폼 <strong>Ticket_Javara(TicketFlow)</strong> 개발 회고 및 트러블슈팅 정리
역할: <strong>예매(Booking) 도메인 담당</strong> — 좌석 Hold, 동시성 제어, 결제 플로우, Mock PG 웹훅 연동</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/a8889326-2519-4813-99bf-1218a63f4f6d/image.png" alt=""></p>
<p>프로젝트 개요 및 구조를 기반으로 실제 구현하면서 겪었던 문제들과,
왜 그렇게 설계했는지에 대한 고민들을 정리해보려고 한다. </p>
<p>이번 프로젝트는 단순 CRUD보다는,
실제 티켓팅 서비스에서 발생할 수 있는 <strong>동시성·트랜잭션·캐시·배포 환경 문제</strong>를 직접 경험해본 프로젝트였다.</p>
<hr>
<h1 id="1-왜-티켓팅-서비스는-좌석-선택이-어려운가">1. 왜 티켓팅 서비스는 “좌석 선택”이 어려운가?</h1>
<p>처음에는 단순하게 생각했다.</p>
<pre><code class="language-text">좌석 선택 → 결제 → 예매 완료</code></pre>
<p>그런데 실제 티켓팅은 이런 구조로 만들면 큰 문제가 생긴다.</p>
<p>예를 들어 아이유 콘서트 오픈 직후,
1만 명이 동시에 같은 좌석을 클릭했다고 가정하면:</p>
<pre><code class="language-text">1만 명 모두 결제창 진입
→ PG 결제 완료
→ 서버에서 뒤늦게 “이미 예매된 좌석입니다”
→ 환불 9,999건 발생</code></pre>
<p>이걸 막기 위해 프로젝트에서 도입한 개념이 바로:</p>
<h1 id="✅-hold좌석-임시-점유">✅ Hold(좌석 임시 점유)</h1>
<p>이었다.</p>
<pre><code class="language-text">AVAILABLE
   ↓ 좌석 선택
ON_HOLD (5분 TTL)
   ↓ 결제 완료
CONFIRMED</code></pre>
<p>좌석을 선택하는 순간 Redis에 TTL 기반 Hold를 생성해서,
다른 사용자가 같은 좌석을 결제창까지 가져가지 못하게 막았다. </p>
<hr>
<h1 id="2-redis-분산락을-왜-사용했는가">2. Redis 분산락을 왜 사용했는가</h1>
<p>처음에는 단순하게 생각했다.</p>
<pre><code class="language-java">if (!exists) {
    save();
}</code></pre>
<p>하지만 동시 요청 상황에서는 이게 깨진다.</p>
<pre><code class="language-text">Thread A → 없음 확인
Thread B → 없음 확인
Thread A → 저장
Thread B → 저장</code></pre>
<p>결국 두 명 모두 성공하는 문제가 발생한다.</p>
<p>이 문제를 해결하기 위해
Redis의 <code>SETNX</code> 기반 분산락(Lettuce)을 사용했다. </p>
<hr>
<h1 id="3-가장-크게-배운-것--transactional과-락은-생명주기가-다르다">3. 가장 크게 배운 것 — @Transactional과 락은 생명주기가 다르다</h1>
<p>프로젝트 초반 가장 많이 헤맸던 부분이다.</p>
<p>처음에는 이렇게 구현했었다.</p>
<pre><code class="language-java">@Transactional
public void processHold() {
    lock.tryLock();

    // DB 작업

    lock.unlock();
}</code></pre>
<p>문제는 여기 있었다.</p>
<p><code>unlock()</code>이 실행되는 시점은
실제 DB COMMIT보다 빠를 수 있다는 점이었다.</p>
<p>즉:</p>
<pre><code class="language-text">락은 풀렸는데
DB 반영은 아직 안 끝난 상태</code></pre>
<p>가 발생할 수 있었다.</p>
<p>그 결과 다른 스레드가 아직 반영되지 않은 데이터를 읽어버리는 문제가 생겼다.</p>
<hr>
<h2 id="해결-방법--facade-패턴으로-분리">해결 방법 — Facade 패턴으로 분리</h2>
<p>결국 락 생명주기와 트랜잭션 생명주기를 분리했다.</p>
<pre><code class="language-java">HoldLockFacade {
    lock.tryLock()

    holdService.processHold() // @Transactional

    lock.unlock()
}</code></pre>
<p>이 구조로 바꾸면서:</p>
<ul>
<li>락은 Facade가 관리</li>
<li>실제 DB 작업은 Service가 관리</li>
</ul>
<p>하도록 역할을 분리했다. </p>
<p>이 경험을 통해 단순히 <code>@Transactional 붙이면 끝</code>이 아니라,
<strong>트랜잭션이 언제 커밋되는지까지 이해해야 한다는 걸 체감했다.</strong></p>
<hr>
<h1 id="4-react-strictmode-때문에-결제가-두-번-요청됐던-문제">4. React StrictMode 때문에 결제가 두 번 요청됐던 문제</h1>
<p>이건 프론트와 연동하면서 겪은 문제였다.</p>
<p>결제 완료 페이지 진입 시:</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
  confirmPayment()
}, [])</code></pre>
<p>이 코드가 개발 환경에서 두 번 실행됐다.</p>
<p>원인은 React StrictMode였다.</p>
<p>결과적으로:</p>
<pre><code class="language-text">토스 승인 API 2번 호출
→ ObjectOptimisticLockingFailureException 발생</code></pre>
<p>이 발생했다. </p>
<hr>
<h2 id="해결">해결</h2>
<pre><code class="language-typescript">const hasConfirmed = useRef(false)

useEffect(() =&gt; {
  if (hasConfirmed.current) return

  hasConfirmed.current = true
  confirmPayment()
}, [])</code></pre>
<p><code>useRef</code>로 최초 1회만 호출되게 막았다.</p>
<p>이 경험 이후,
“백엔드 에러라고 항상 백엔드 문제는 아니다”라는 걸 많이 느꼈다.</p>
<hr>
<h1 id="5-requires_new--detached-entity-문제">5. REQUIRES_NEW + Detached Entity 문제</h1>
<p>이번 프로젝트에서 가장 어려웠던 트랜잭션 문제였다.</p>
<p>결제 승인 이후:</p>
<pre><code class="language-java">confirmOrder()</code></pre>
<p>를 <code>REQUIRES_NEW</code>로 실행했는데,
갑자기 아래 예외가 발생했다.</p>
<pre><code class="language-text">StaleObjectStateException
ObjectOptimisticLockingFailureException</code></pre>
<p>원인은 detached entity였다.</p>
<p>외부 트랜잭션에서 생성된 엔티티를
새로운 트랜잭션에 그대로 넘기면서 충돌이 발생한 것이다. </p>
<hr>
<h2 id="해결-1">해결</h2>
<p>트랜잭션 내부에서 엔티티를 다시 조회했다.</p>
<pre><code class="language-java">Order freshOrder =
    orderRepository.findById(order.getOrderId())</code></pre>
<p>즉:</p>
<pre><code class="language-text">파라미터 엔티티 사용 X
트랜잭션 내부 재조회 O</code></pre>
<p>로 해결했다.</p>
<p>이번 경험으로:</p>
<ul>
<li>영속 상태</li>
<li>detached 상태</li>
<li>merge vs persist</li>
<li>REQUIRES_NEW 동작 방식</li>
</ul>
<p>을 실제 에러로 체감할 수 있었다.</p>
<hr>
<h1 id="6-active_booking-테이블을-따로-만든-이유">6. ACTIVE_BOOKING 테이블을 따로 만든 이유</h1>
<p>원래는 좌석 상태를 컬럼 하나로 관리하려 했다.</p>
<pre><code class="language-text">AVAILABLE
ON_HOLD
CONFIRMED</code></pre>
<p>하지만 MySQL은 Partial Index를 지원하지 않는다. </p>
<p>즉:</p>
<pre><code class="language-sql">WHERE status=&#39;CONFIRMED&#39;</code></pre>
<p>조건으로 유니크 제약을 걸 수 없었다.</p>
<p>결국 선택한 방식은:</p>
<h1 id="active_booking-별도-테이블">ACTIVE_BOOKING 별도 테이블</h1>
<pre><code class="language-sql">seat_id PRIMARY KEY</code></pre>
<p>로 설계하는 것이었다.</p>
<p>이렇게 하면:</p>
<pre><code class="language-text">동일 좌석 INSERT 자체가 실패</code></pre>
<p>하기 때문에 DB 레벨에서 중복 예매를 물리적으로 막을 수 있었다.</p>
<hr>
<h1 id="7-배포-환경에서-타이머가-즉시-만료되던-문제">7. 배포 환경에서 타이머가 즉시 만료되던 문제</h1>
<p>배포 후 테스트 중:</p>
<pre><code class="language-text">좌석 선택
→ 카운트다운 바로 종료</code></pre>
<p>되는 문제가 발생했다.</p>
<p>원인은 타임존이었다.</p>
<ul>
<li>EC2 서버: UTC</li>
<li>프론트: KST</li>
<li>expiresAt 파싱 불일치</li>
</ul>
<p>결국 9시간 차이가 발생했다. </p>
<hr>
<h2 id="해결-2">해결</h2>
<pre><code class="language-typescript">const expiresAtUtc =
  expiresAt.endsWith(&#39;Z&#39;)
    ? expiresAt
    : expiresAt + &#39;Z&#39;</code></pre>
<p>그리고 Docker에도:</p>
<pre><code class="language-yaml">TZ: Asia/Seoul</code></pre>
<p>설정을 추가했다.</p>
<p>이번 경험으로:
“로컬에서는 잘 되는데 배포에서만 안 되는 이유”
를 처음 제대로 경험했다.</p>
<hr>
<h1 id="8-bulkdatainit-때문에-데이터가-안-바뀌던-문제">8. BulkDataInit 때문에 데이터가 안 바뀌던 문제</h1>
<p>이건 꽤 오래 헤맸던 문제였다.</p>
<p>더미데이터를 수정했는데
프론트 화면에서는 계속 예전 공연 데이터만 보였다.</p>
<p>원인은:</p>
<pre><code class="language-java">if (eventCount &gt; 25) {
    return;
}</code></pre>
<p>이 중복 실행 방지 로직 때문이었다. </p>
<p>이미 DB에 데이터가 있으면
새 초기화 코드가 실행되지 않았던 것이다.</p>
<hr>
<h2 id="해결-3">해결</h2>
<pre><code class="language-sql">TRUNCATE TABLE seat;
TRUNCATE TABLE section;
TRUNCATE TABLE event;</code></pre>
<p>후 재실행했다.</p>
<p>이 과정에서:</p>
<ul>
<li>FOREIGN_KEY_CHECKS</li>
<li>TRUNCATE vs DELETE</li>
<li>부모/자식 테이블 삭제 순서</li>
</ul>
<p>까지 같이 공부하게 됐다.</p>
<hr>
<h1 id="9-redis-decr은-그-자체로-원자적이다">9. Redis DECR은 그 자체로 원자적이다</h1>
<p>쿠폰 발급 구현 당시,
처음에는 분산락을 또 걸려고 했다.</p>
<p>그런데 Redis의:</p>
<pre><code class="language-text">DECR</code></pre>
<p>연산 자체가 이미 원자적이라는 걸 알게 됐다. </p>
<p>그래서:</p>
<pre><code class="language-lua">DECR
→ 음수면 INCR 복구</code></pre>
<p>방식으로 구현했다.</p>
<p>이 경험 이후:
“무조건 락을 거는 게 좋은 건 아니다”
라는 걸 많이 배웠다.</p>
<hr>
<h1 id="10-프로젝트를-하며-가장-크게-느낀-점">10. 프로젝트를 하며 가장 크게 느낀 점</h1>
<p>이번 프로젝트는 단순히 기능 구현보다:</p>
<ul>
<li>왜 이런 구조를 선택했는가</li>
<li>왜 락을 이렇게 잡아야 하는가</li>
<li>트랜잭션은 언제 끝나는가</li>
<li>캐시는 왜 깨지는가</li>
<li>배포 환경은 왜 로컬과 다른가</li>
</ul>
<p>를 정말 많이 고민했던 프로젝트였다.</p>
<p>특히 AI 도움을 받으며 개발하면서도 느낀 건:</p>
<blockquote>
<p>“코드를 생성하는 것”과
“왜 그렇게 동작하는지 이해하는 것”은 완전히 다르다</p>
</blockquote>
<p>라는 점이었다.</p>
<p>실제로 트랜잭션과 락 문제는
원리를 이해하지 못하면 에러 원인조차 파악할 수 없었다.</p>
<hr>
<h1 id="마무리">마무리</h1>
<p>이번 Ticket_Javara 프로젝트를 통해:</p>
<ul>
<li>Redis 분산락</li>
<li>트랜잭션 전파</li>
<li>동시성 테스트</li>
<li>캐싱 전략</li>
<li>Docker/AWS 배포</li>
<li>프론트-백엔드 연동 문제</li>
</ul>
<p>를 단순 개념이 아니라,
“실제 장애를 해결하면서” 배울 수 있었다.</p>
<p>특히 예매 시스템 특성상
단순 CRUD 프로젝트보다 훨씬 많은 정합성과 Race Condition을 고려해야 했고,
그 과정에서 정말 많이 성장했다고 느낀다.</p>
<hr>
<h1 id="프로젝트를-하며-가장-크게-배운-점--문서화의-힘">프로젝트를 하며 가장 크게 배운 점 — “문서화의 힘”</h1>
<p>이번 프로젝트를 진행하면서 가장 크게 느낀 건,
코드를 작성하기 전에 얼마나 설계를 깊게 했는지가 프로젝트 전체 난이도를 크게 좌우한다는 점이었다.</p>
<p>프로젝트 초반에는 단순히 빠르게 개발부터 들어가는 게 아니라,
팀원들과 함께 SA 문서를 오래 작성했다.</p>
<p>실제로 아래와 같이 프로젝트 문서를 총 12개 이상 작성하며 설계 단계에 많은 시간을 투자했다.</p>
<ul>
<li>프로젝트 개요서</li>
<li>사용자 시나리오</li>
<li>유스케이스 명세서</li>
<li>기능 명세서</li>
<li>ERD 설계</li>
<li>API 명세서</li>
<li>화면 설계서</li>
<li>인프라 아키텍처</li>
<li>동시성 제어 설계서</li>
<li>ADR(Architecture Decision Record)</li>
<li>백엔드 패키지 구조 설계서</li>
<li>공통 에러코드 설계서</li>
</ul>
<p>특히 단순히 “문서를 만든다” 수준이 아니라:</p>
<ul>
<li>왜 Redis Lettuce를 먼저 선택하는지</li>
<li>왜 이후 Redisson으로 전환 가능한 구조를 만드는지</li>
<li>왜 Feature Flag 전략을 쓰는지</li>
<li>왜 좌석 상태를 컬럼이 아닌 ACTIVE_BOOKING 테이블로 분리하는지</li>
</ul>
<p>같은 설계 의도를 팀 차원에서 계속 문서화했다.</p>
<p>그 과정에서 느낀 건:</p>
<blockquote>
<p>좋은 프로젝트는 코드부터 시작되는 게 아니라
“왜 이렇게 설계했는가”를 팀 전체가 공유하는 것부터 시작된다는 점이었다.</p>
</blockquote>
<p>이었다.</p>
<hr>
<h1 id="개발-범위가-백엔드에서-프론트엔드까지-확장된-경험">개발 범위가 백엔드에서 프론트엔드까지 확장된 경험</h1>
<p>이번 프로젝트에서는 원래 백엔드 중심으로 참여했지만,
프로젝트 후반에는 프론트엔드 작업까지 직접 진행하게 되었다.</p>
<p>처음에는 단순히 API만 연결하면 된다고 생각했는데,
실제로는:</p>
<ul>
<li>React StrictMode로 인한 중복 요청</li>
<li>타임존 차이로 CountdownTimer 즉시 만료</li>
<li>SPA 라우팅 404 문제</li>
<li>백엔드 ENUM과 프론트 값 불일치</li>
<li>상태 관리 및 페이지 흐름 연결</li>
</ul>
<p>등 백엔드만 할 때는 경험하지 못했던 문제들을 많이 겪었다.</p>
<p>특히 프론트엔드까지 직접 붙여보면서 느낀 건:</p>
<pre><code class="language-text">“API를 만드는 것”과
“사용자 입장에서 실제 서비스를 완성하는 것”은 완전히 다르다</code></pre>
<p>는 점이었다.</p>
<p>이전에는 백엔드 기능 단위로만 사고했다면,
이번 프로젝트 이후에는:</p>
<ul>
<li>사용자 흐름</li>
<li>API 응답 구조</li>
<li>프론트 상태 변화</li>
<li>배포 환경</li>
<li>UX까지 포함한 전체 시스템 흐름</li>
</ul>
<p>을 함께 고려하게 되었다.</p>
<p>결과적으로 단순히 “백엔드 기능 구현”만 하는 수준에서,
조금 더 서비스 전체를 보는 시야가 생겼다고 느꼈다.</p>
<hr>
<h1 id="개인적으로-가장-성장했다고-느낀-부분">개인적으로 가장 성장했다고 느낀 부분</h1>
<p>이번 프로젝트 이전에는:</p>
<ul>
<li>트랜잭션 전파</li>
<li>분산락</li>
<li>동시성</li>
<li>캐싱 전략</li>
<li>배포 환경 차이</li>
<li>프론트와의 연동 문제</li>
</ul>
<p>를 개념적으로만 알고 있었다.</p>
<p>하지만 이번에는 실제로:</p>
<ul>
<li><code>REQUIRES_NEW</code></li>
<li>Redis SETNX</li>
<li>Lua Script 원자화</li>
<li>Docker 타임존 문제</li>
<li>React StrictMode</li>
<li>Feature Flag 기반 구조 설계</li>
</ul>
<p>등을 직접 부딪히며 해결해보면서,
단순 이론이 아니라 “실제 서비스에서는 왜 이런 구조를 쓰는지”를 이해하게 되었다.</p>
<p>특히 프로젝트 초반 문서 설계 경험과,
후반 프론트엔드까지 확장된 경험 덕분에:</p>
<blockquote>
<p>“기능 구현 개발자”에서
“시스템 전체 흐름을 고민하는 개발자”로 한 단계 성장했다</p>
</blockquote>
<p>고 느낀 프로젝트였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI와 함께하는 명세기반 개발]]></title>
            <link>https://velog.io/@jiiim_ni/AI%EC%99%80-%ED%95%A8%EA%BB%98%ED%95%98%EB%8A%94-%EB%AA%85%EC%84%B8%EA%B8%B0%EB%B0%98-%EA%B0%9C%EB%B0%9C</link>
            <guid>https://velog.io/@jiiim_ni/AI%EC%99%80-%ED%95%A8%EA%BB%98%ED%95%98%EB%8A%94-%EB%AA%85%EC%84%B8%EA%B8%B0%EB%B0%98-%EA%B0%9C%EB%B0%9C</guid>
            <pubDate>Tue, 19 May 2026 01:35:18 GMT</pubDate>
            <description><![CDATA[<h1 id="ai와-함께하는-명세기반-개발-spec-driven-development">AI와 함께하는 명세기반 개발 (Spec-Driven Development)</h1>
<blockquote>
<p><strong>&quot;바이브코딩 사전단계 — SA 문서를 AI와 함께 설계한다&quot;</strong></p>
<p>3주짜리 커머스 백엔드 프로젝트를 시작하기 전,
AI를 &#39;시니어 아키텍트 동료&#39;처럼 활용해 10종의 SA 문서를 설계하는 법을 배웁니다.</p>
</blockquote>
<hr>
<h2 id="pre-work">Pre-work</h2>
<h3 id="1-준비물-체크리스트">1. 준비물 체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> Claude / ChatGPT / Gemini 중 <strong>최소 1개 이상</strong> 로그인 가능한 상태 (Pro이상 유료 계정 권장)</li>
<li><input disabled="" type="checkbox"> Figma 무료 계정 (화면 설계서 목업용)</li>
<li><input disabled="" type="checkbox"> Mermaid 렌더링이 가능한 마크다운 에디터 (VS Code + Mermaid Preview 확장 추천)</li>
<li><input disabled="" type="checkbox"> PlantUML 렌더링 도구 (VS Code 확장 또는 <a href="https://www.plantuml.com/plantuml">plantuml.com</a>)</li>
<li><input disabled="" type="checkbox"> 팀별 공용 저장소 (GitHub Repository 또는 Notion 워크스페이스)</li>
</ul>
<h3 id="2-사전-숙지-자료">2. 사전 숙지 자료</h3>
<ul>
<li>2차 프로젝트 회고 (특히 &quot;설계가 약해서 구현 중에 다시 뒤집은 경험&quot;)</li>
<li>Spring Boot + JPA + MySQL + Redis 기본 개념</li>
<li>AWS EC2 / RDS / ElastiCache 이름만이라도 들어본 수준</li>
</ul>
<h3 id="3-💡-왜-이-특강이-프로젝트-1일차-오전에-배치되는가">3. 💡 &quot;왜 이 특강이 프로젝트 1일차 오전에 배치되는가&quot;</h3>
<p>3주 프로젝트의 첫 3일은 <strong>설계 기간</strong>입니다. 이 3일을 어떻게 쓰느냐가 남은 18일의 품질을 좌우합니다.
이전 기수들이 가장 자주 했던 후회는 <strong>&quot;설계 없이 코딩부터 시작했더니 2주차에 기능을 다 뒤엎었다&quot;</strong> 였습니다.
오늘 오전 특강에서 &#39;AI와 설계하는 감각&#39;을 먼저 잡고, 오후부터 팀별로 본격적인 SA 문서 작성에 들어갑니다.</p>
<hr>
<h2 id="오프닝-왜-또-설계-얘기-우리-지금껏-설계-많이-했잖아요">오프닝: &quot;왜 또 설계 얘기? 우리 지금껏 설계 많이 했잖아요&quot;</h2>
<h3 id="2차-프로젝트-회고-공감대-형성">2차 프로젝트 회고 공감대 형성</h3>
<p>&quot;여러분, 손 들어보세요. 2차 프로젝트에서 <strong>구현하다가 기능을 뒤엎은 경험</strong> 있는 사람?&quot;
대부분의 손이 올라올 겁니다. 왜 그랬을까요? 대부분 이유는 하나입니다 — <strong>설계가 없었거나, 있어도 부실했기 때문</strong>입니다.</p>
<h3 id="바이브코딩의-함정">바이브코딩의 함정</h3>
<p>지금까지 여러분은 AI에게 &quot;로그인 기능 만들어줘&quot; 같은 식으로 코드를 부탁했습니다. 이걸 <strong>바이브코딩(Vibe Coding)</strong> 이라고 부릅니다. 느낌적으로, 상황 봐가며, 수정해가며 완성하는 방식이죠.</p>
<p>바이브코딩은 <strong>작은 기능 하나</strong>를 만들 때는 기가 막히게 빠릅니다. 그런데 3주짜리, 그것도 동시성 제어·분산락·인프라 자동화까지 들어가는 프로젝트를 바이브코딩만으로 끌고 가면 반드시 다음이 터집니다.</p>
<ul>
<li>1주차: &quot;AI 최고! 다 되네!&quot;</li>
<li>2주차: &quot;어? 재고가 음수가 돼요… 왜죠?&quot;</li>
<li>3주차: &quot;아키텍처가 꼬였는데 어디부터 손대야 할지 모르겠어요&quot;</li>
</ul>
<h3 id="오늘-배울-것-ai를-비서가-아닌-시니어-아키텍트-동료처럼">오늘 배울 것: &quot;AI를 비서가 아닌 시니어 아키텍트 동료처럼&quot;</h3>
<blockquote>
<p><strong>&quot;AI에게 &#39;뭘 만들지&#39;를 명확히 말해줄 수 없다면, AI는 여러분이 원하는 걸 만들 수 없습니다.&quot;</strong></p>
</blockquote>
<p>오늘 특강의 목표는 단 하나입니다.
<strong>AI와 대화하면서 10종의 SA 문서를 만들고, 그 문서를 근거로 3주를 완주하는 법.</strong></p>
<h3 id="실무-연결--쿠팡·배민-시니어-개발자들도-이렇게-일합니다">실무 연결 — &quot;쿠팡·배민 시니어 개발자들도 이렇게 일합니다&quot;</h3>
<p>쿠팡에서 로켓배송 재고관리 시스템을 만들 때, 시니어 개발자가 가장 먼저 하는 일은 <strong>코딩이 아니라 PRD(Product Requirements Document)와 SA 문서 작성</strong>입니다. 왜냐면 재고가 음수가 되는 순간 실제로 돈이 사라지거든요.
여러분이 만들 커머스 앱도 마찬가지입니다. 오늘 배울 건 실무에서 매일 쓰는 스킬입니다.</p>
<h3 id="오늘의-흐름">오늘의 흐름</h3>
<ol>
<li>SA 문서 10종 지도 그리기 + AI에게 프로젝트 컨텍스트 주입하는 법</li>
<li>사용자 시나리오 → 유스케이스 → ERD → API 명세서 순서로 AI와 함께 설계</li>
<li>화면 설계서를 통해 프론트엔드 바이브코딩 가이드 만들기</li>
<li>인프라 아키텍처 &amp; <strong>동시성 제어 설계서(하이라이트)</strong></li>
<li>ADR 기록 + 셀프 리뷰</li>
<li>평생 쓸 5가지 프롬프트 패턴 정리</li>
</ol>
<h2 id="step-1-sa-문서-지도-그리기--ai-컨텍스트-주입">Step 1: &quot;SA 문서 지도 그리기 &amp; AI 컨텍스트 주입&quot;</h2>
<h3 id="1-1-오늘-만들-sa-문서-10종">1-1. 오늘 만들 SA 문서 10종</h3>
<p>먼저 <strong>뭘 만들지</strong>부터 알아야 합니다. 이번 프로젝트에서 3일간 작성할 문서 목록입니다.</p>
<table>
<thead>
<tr>
<th>#</th>
<th>문서</th>
<th>답하는 질문</th>
<th>산출 형태</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>프로젝트 개요서</td>
<td>우리 왜 이걸 만드나?</td>
<td>마크다운</td>
</tr>
<tr>
<td>2</td>
<td>사용자 시나리오 (페르소나 포함)</td>
<td>누가, 어떤 맥락에서 쓰나?</td>
<td>마크다운</td>
</tr>
<tr>
<td>3</td>
<td>유스케이스 명세서</td>
<td>누가 뭘 할 수 있나?</td>
<td>마크다운 + PlantUML</td>
</tr>
<tr>
<td>4</td>
<td>기능 명세서</td>
<td>각 기능은 어떻게 동작하나?</td>
<td>마크다운</td>
</tr>
<tr>
<td>5</td>
<td>ERD</td>
<td>데이터는 어떻게 연결되나?</td>
<td>Mermaid ERD</td>
</tr>
<tr>
<td>6</td>
<td>API 명세서</td>
<td>프론트랑 어떻게 대화하나?</td>
<td>마크다운 표 + OpenAPI YAML</td>
</tr>
<tr>
<td>7</td>
<td><strong>화면 설계서</strong></td>
<td>사용자는 어떤 화면을 보나?</td>
<td>와이어프레임 + <strong>Figma 목업</strong></td>
</tr>
<tr>
<td>8</td>
<td>인프라 아키텍처 다이어그램</td>
<td>어디에 어떻게 배포되나?</td>
<td>Mermaid 다이어그램</td>
</tr>
<tr>
<td>9</td>
<td><strong>동시성 제어 설계서 ⭐</strong></td>
<td>충돌은 어떻게 막나?</td>
<td>시나리오 매트릭스</td>
</tr>
<tr>
<td>10</td>
<td>ADR (Architecture Decision Record)</td>
<td>왜 이 기술을 골랐나?</td>
<td>마크다운 (결정마다 1개)</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>💡 9번 동시성 제어 설계서가 별도 문서인 이유</strong>
이번 프로젝트의 하이라이트입니다. 비관락/낙관락/분산락을 <strong>언제 뭘 쓸지</strong>를 결정하는 이 의사결정이 기능 명세서에 섞이면 묻혀버립니다. 별도 문서로 빼서 집중 관리합니다.</p>
</blockquote>
<h3 id="1-2-ai를-설계-파트너로-만드는-3단계">1-2. AI를 설계 파트너로 만드는 3단계</h3>
<p>AI를 &#39;잘 쓰는&#39; 사람과 &#39;못 쓰는&#39; 사람의 차이는 딱 하나입니다 — <strong>컨텍스트를 얼마나 잘 주입하느냐</strong>.
좋은 프롬프트의 3요소를 기억하세요.</p>
<ol>
<li><strong>역할 부여 (Role)</strong>: &quot;너는 10년차 백엔드 아키텍트야&quot;</li>
<li><strong>제약 명시 (Constraints)</strong>: 기술스택, 인원, 기간, 필수/도전 기능</li>
<li><strong>출력 포맷 지정 (Output Format)</strong>: 마크다운 표, Mermaid, PlantUML, JSON</li>
</ol>
<p>비유하자면, 신입 개발자한테 일을 시킬 때 &quot;알아서 잘해봐&quot;라고 하면 절대 원하는 결과가 안 나오잖아요? AI도 똑같습니다. 오히려 <strong>AI는 신입보다 더 자세한 가이드를 줘야</strong> 원하는 답을 줍니다.</p>
<h3 id="1-3-🎁-실습-프롬프트--프로젝트-컨텍스트-초기화">1-3. 🎁 실습 프롬프트 — 프로젝트 컨텍스트 초기화</h3>
<p>지금 바로 여러분이 쓰는 AI에 이 프롬프트를 복붙해서 대화를 시작해보세요. <strong>이게 오늘 모든 실습의 출발점</strong>입니다.</p>
<pre><code class="language-markdown">너는 10년 이상의 경력을 가진 백엔드 시니어 아키텍트야.
지금부터 나와 함께 3주짜리 커머스 백엔드 앱의 SA 문서를 설계할 거야.

## 프로젝트 컨텍스트
- 팀 구성: 백엔드 개발자 N명 (경력 3개월)
- 기간: 3주 (설계 3일 + 개발 2주 + QA/배포 1주)
- 주제: 편의점 유통기한 임박 음식 덤핑 플랫폼
- 기술 스택: Java 17, Spring Boot 3.x, Spring Data JPA, MySQL 8, Redis,
  Docker, AWS(EC2, RDS, ElastiCache, ECR), GitHub Actions
- 프론트엔드: 바이브코딩으로 처리 (화면 설계서 + Figma 목업만 준비)

## 필수 구현 기능
1. 동시성 제어 - 순간적으로 요청이 쏟아지더라도 데이터 정합성이 완벽하게 지켜져야하는 비즈니스
2. 로컬 캐싱 (Caffeine) - 인기 검색어 &amp; 검색 기능

## 도전 기능
1. Redis 분산락 (Redisson)
2. Redis 캐싱 (Cache-Aside 패턴)
3. EXPLAIN 기반 DB 인덱스 최적화
4. 웹소켓(STOMP) 기반 채팅
5. EC2 + RDS + ElastiCache + ECR + GitHub Actions 배포 자동화

## 너의 역할
- 질문을 먼저 하고, 내가 답한 뒤에 설계를 제안할 것
- 모든 의사결정에는 &quot;왜&quot;를 근거로 설명할 것
- 추측하지 말고, 모르는 건 나에게 물어볼 것
- 대안이 있을 땐 Trade-off를 비교표로 보여줄 것

## 함께 작성할 문서
| # | 문서 | 답하는 질문 | 산출 형태 |
| --- | --- | --- | --- |
| 1 | 프로젝트 개요서 | 우리 왜 이걸 만드나? | 마크다운 |
| 2 | 사용자 시나리오 (페르소나 포함) | 누가, 어떤 맥락에서 쓰나? | 마크다운 |
| 3 | 유스케이스 명세서 | 누가 뭘 할 수 있나? | 마크다운 + PlantUML |
| 4 | 기능 명세서 | 각 기능은 어떻게 동작하나? | 마크다운 |
| 5 | ERD | 데이터는 어떻게 연결되나? | Mermaid ERD |
| 6 | API 명세서 | 프론트랑 어떻게 대화하나? | 마크다운 표 + OpenAPI YAML |
| 7 | **화면 설계서** | 사용자는 어떤 화면을 보나? | 와이어프레임 + **Figma 목업** |
| 8 | 인프라 아키텍처 다이어그램 | 어디에 어떻게 배포되나? | Mermaid 다이어그램 |
| 9 | **동시성 제어 설계서 ⭐** | 충돌은 어떻게 막나? | 시나리오 매트릭스 |
| 10 | ADR (Architecture Decision Record) | 왜 이 기술을 골랐나? | 마크다운 (결정마다 1개) |

준비됐으면 &quot;준비됐습니다. 먼저 3가지만 질문드릴게요&quot;라고 답하고
가장 중요한 3가지 질문만 먼저 해줘.</code></pre>
<blockquote>
<p><strong>🔑 이 프롬프트의 핵심 장치</strong>
마지막 문장 <strong>&quot;가장 중요한 3가지 질문만 먼저 해줘&quot;</strong> 가 매직입니다. 이 한 줄이 없으면 AI는 멋대로 가정하고 답변을 던집니다. 이 한 줄이 있으면 AI는 <strong>모르는 걸 먼저 물어보는</strong> 행동 패턴으로 바뀝니다. 시니어 개발자가 신입에게 일을 받을 때처럼요.</p>
</blockquote>
<h3 id="1-4-실습-체크포인트">1-4. 실습 체크포인트</h3>
<ul>
<li><input disabled="" type="checkbox"> AI가 &quot;준비됐습니다. 먼저 3가지만 질문드릴게요&quot;로 답변했는가?</li>
<li><input disabled="" type="checkbox"> AI가 던진 3가지 질문이 구체적이고 날카로운가?</li>
<li><input disabled="" type="checkbox"> 내가 그 질문에 답변을 던졌을 때, AI가 다음 단계로 부드럽게 넘어가는가?</li>
</ul>
<h2 id="step-2-사용자-시나리오--유스케이스-설계">Step 2: &quot;사용자 시나리오 &amp; 유스케이스 설계&quot;</h2>
<h3 id="2-1-페르소나-→-시나리오-→-유스케이스-흐름">2-1. 페르소나 → 시나리오 → 유스케이스 흐름</h3>
<p>개발자들이 가장 자주 빠지는 함정이 있습니다. <strong>&quot;기능 목록 = 유스케이스&quot;</strong> 로 착각하는 것. 유스케이스는 <strong>&quot;액터의 목표&quot;</strong> 입니다. &quot;회원가입 기능&quot;은 기능이고, &quot;새로운 사용자가 회원가입을 통해 쇼핑을 시작하려 한다&quot;가 유스케이스입니다.</p>
<p>비유하자면, 레스토랑 메뉴판에 &quot;파스타&quot;라고만 쓰여있는 거랑, &quot;야근으로 지친 직장인이 혼자 와서 30분 안에 든든히 먹고 갈 수 있는 크림 파스타&quot;라고 쓰여있는 거랑 느낌이 다르잖아요? 후자가 유스케이스입니다.</p>
<h3 id="2-2-🎁-실습-프롬프트-1--페르소나-설계">2-2. 🎁 실습 프롬프트 1 — 페르소나 설계</h3>
<pre><code class="language-markdown">우리 커머스 앱의 주요 페르소나 3명을 설계해줘.
각 페르소나마다 다음 항목을 포함해줘:
- 이름, 나이, 직업
- Goal (앱에서 이루고 싶은 것)
- Pain Point (기존 커머스 앱에서 겪는 불편함)
- 주요 사용 시나리오 3가지

제약조건: 우리 필수/도전 기능과 연결될 수 있는 페르소나여야 해.
예를 들어 &#39;한정판 스니커즈 드랍을 기다리는 사용자&#39;는 동시성 제어와
자연스럽게 연결되잖아.</code></pre>
<blockquote>
<p><strong>💡 왜 &quot;기능과 연결되는 페르소나&quot;를 요구하는가?</strong>
페르소나를 위한 페르소나는 문서를 위한 문서가 됩니다. 페르소나가 동시성 제어, 분산락 같은 우리 핵심 기술과 이어져야 <strong>&quot;왜 이 기능이 필요한가&quot;</strong> 를 설명할 수 있습니다.</p>
</blockquote>
<h3 id="2-3-🎁-실습-프롬프트-2--유스케이스-명세서--다이어그램">2-3. 🎁 실습 프롬프트 2 — 유스케이스 명세서 &amp; 다이어그램</h3>
<pre><code class="language-markdown">위 페르소나를 기반으로 유스케이스 명세서를 만들어줘.
출력 형식은 다음과 같이 해줘:

## UC-001: [유스케이스 이름]
- **액터**:
- **사전조건**:
- **주요 흐름**:
  1.
  2.
- **대체 흐름**:
- **예외 흐름**:
- **사후조건**:
- **관련 필수/도전 기능**:

그리고 마지막에 PlantUML 형식의 유스케이스 다이어그램 코드도 같이 줘.</code></pre>
<h2 id="step-3-erd-대화형-설계--레드팀-검증">Step 3: &quot;ERD 대화형 설계 + 레드팀 검증&quot;</h2>
<h3 id="3-1-한-방-프롬프트가-위험한-이유">3-1. &quot;한 방 프롬프트&quot;가 위험한 이유</h3>
<p>여러분이 가장 자주 하는 실수: AI한테 &quot;커머스 앱 ERD 그려줘&quot; 한 방에 물어보는 것.
이렇게 하면 AI는 <strong>평균적인 커머스 앱의 ERD</strong>를 던져줍니다. 우리 프로젝트의 필수 기능(동시성 제어)과 도전 기능(분산락, 캐싱)이 반영되지 않은, 교과서 같은 ERD가 나옵니다.</p>
<p>그래서 우리는 <strong>대화형 설계(Conversational Design)</strong> 를 합니다. 4단계로 쪼개서, 단계마다 내가 OK 해야 다음으로 넘어가게 만듭니다.</p>
<h3 id="3-2-🎁-실습-프롬프트-1--단계별-erd-도출">3-2. 🎁 실습 프롬프트 1 — 단계별 ERD 도출</h3>
<pre><code class="language-markdown">우리 커머스 백엔드의 ERD를 설계하자.
다만 바로 답하지 말고, 다음 순서로 진행해줘:

1단계: 먼저 필요한 엔티티 목록만 뽑아줘 (설명 포함)
2단계: 내가 &quot;좋아&quot;라고 하면 엔티티 간 관계를 1:N, N:M으로 정의해줘
3단계: 각 엔티티의 속성을 정의하되, 동시성 제어가 필요한 필드
       (예: 재고 수량)에는 반드시 @Version 또는
       Pessimistic Lock 필요 여부를 주석으로 표시해줘
4단계: Mermaid ERD 코드로 최종 출력해줘

시작하자. 1단계부터.</code></pre>
<blockquote>
<p><strong>🔑 &quot;1단계부터. 내가 OK 해야 다음 단계로&quot;의 힘</strong>
이 패턴을 <strong>단계별 설계 패턴(Step-by-step Design Pattern)</strong> 이라고 부릅니다. AI가 중간 단계에서 멋대로 가정하지 않고, 사람이 각 단계를 검토할 수 있게 만드는 장치입니다. 앞으로 AI랑 협업할 때 <strong>가장 자주 쓸 패턴</strong>이에요.</p>
</blockquote>
<h3 id="3-3-🎁-실습-프롬프트-2--레드팀-검증">3-3. 🎁 실습 프롬프트 2 — 레드팀 검증</h3>
<p>AI가 만든 ERD를 그냥 쓰면 위험합니다. 왜냐면 AI는 자기가 만든 걸 <strong>자기가 칭찬</strong>하는 경향이 있거든요. 그래서 <strong>페르소나를 바꿔서 다시 물어보는 기법</strong>을 씁니다.</p>
<pre><code class="language-markdown">방금 만든 ERD를 이제 &#39;비판적인 코드리뷰어&#39; 입장에서 검토해줘.
특히 다음 관점에서 약점을 찾아줘:

1. 정규화 이슈 (중복, 종속성)
2. 동시성 이슈 가능성 (어떤 테이블이 Hot Spot이 될까?)
3. 인덱스 최적화 관점 (자주 조회될 것 같은 컬럼)
4. 확장성 이슈 (MSA로 쪼갤 때 경계는 어디?)
5. 누락된 엔티티 (실제 커머스에서 필요할 법한데 빠진 것)

각 이슈마다 &#39;왜 문제인지&#39;와 &#39;어떻게 개선할지&#39;를 같이 줘.</code></pre>
<blockquote>
<p><strong>🔑 레드팀 패턴(Red Team Pattern)</strong>
AI가 만든 걸 AI에게 다시 비판시키는 이 기법, 실제로 해보면 <strong>놀랄 정도로 날카로운</strong> 비판이 나옵니다.</p>
</blockquote>
<h3 id="3-4-실무-연결">3-4. 실무 연결</h3>
<p>쿠팡 상품팀에서 ERD를 설계할 때, 실제로 두 명의 시니어가 하나는 &#39;설계자&#39;, 하나는 &#39;비판자&#39; 역할을 나눠서 서로 역할극을 합니다. 우리는 AI가 양쪽 역할을 다 해주는 거죠.</p>
<h2 id="step-4-api-명세서--openapi-변환">Step 4: &quot;API 명세서 &amp; OpenAPI 변환&quot;</h2>
<h3 id="4-1-api-명세서가-단순-표가-아닌-이유">4-1. API 명세서가 단순 표가 아닌 이유</h3>
<p>초보 개발자들은 API 명세서를 <strong>엔드포인트 목록</strong>으로만 생각합니다. 실무에선 API 명세서가 <strong>프론트-백 사이의 계약서(Contract)</strong> 입니다. 이 문서가 바뀌면 양쪽 모두가 고통받습니다. 그래서 처음에 잘 만들어야 합니다.</p>
<h3 id="4-2-🎁-실습-프롬프트-1--restful-api-설계">4-2. 🎁 실습 프롬프트 1 — RESTful API 설계</h3>
<pre><code class="language-markdown">ERD와 유스케이스를 기반으로 REST API 명세서를 만들어줘.
출력은 다음 형식의 마크다운 표로:

| Method | Endpoint | 설명 | Request | Response | 인증 | 관련 UC |
|---|---|---|---|---|---|---|

제약사항:
- REST 원칙(자원 중심, 동사 X) 준수
- 페이징은 cursor 기반 우선 검토
- 재고 차감 같은 동시성 민감 API는 ⚠️ 표시
- 캐싱 대상 API는 💾 표시
- 인증 필요 API는 🔐 표시

그리고 각 API마다 예시 JSON Request/Response를 추가로 줘.</code></pre>
<blockquote>
<p><strong>💡 이모지 표시가 왜 중요한가?</strong>
⚠️(동시성), 💾(캐싱), 🔐(인증) 표시는 나중에 구현 단계에서 <strong>&quot;이 API는 어떤 특별한 처리가 필요한가&quot;</strong> 를 한눈에 보여줍니다. 동시성 민감 API 목록이 곧 Step 7의 동시성 설계서의 입력이 됩니다.</p>
</blockquote>
<h3 id="4-3-🎁-실습-프롬프트-2--openapi-yaml-자동-변환">4-3. 🎁 실습 프롬프트 2 — OpenAPI YAML 자동 변환</h3>
<pre><code class="language-markdown">위 API 명세서를 OpenAPI 3.0 YAML 형식으로 변환해줘.
나중에 Swagger UI에 바로 붙일 수 있도록.</code></pre>
<blockquote>
<p><strong>💡 왜 두 번 만드는가?</strong>
마크다운 표는 <strong>사람이 읽기</strong> 위한 거고, OpenAPI YAML은 <strong>도구가 읽기</strong> 위한 겁니다. 두 형식을 다 만들어두면 팀 회의에선 마크다운 표를 보고, 실제 Swagger UI에선 YAML을 쓸 수 있습니다. AI가 두 형식 간 변환을 대신 해주니까 드는 비용은 없습니다.</p>
</blockquote>
<h2 id="step-5-화면-설계서--figma-목업-연동">Step 5: &quot;화면 설계서 + Figma 목업 연동&quot;</h2>
<h3 id="5-1-백엔드-개발자가-화면-설계서를-만드는-이유">5-1. 백엔드 개발자가 화면 설계서를 만드는 이유</h3>
<p>&quot;저 백엔드 개발자인데 화면을 왜 설계해요?&quot; — 좋은 질문입니다. 그런데 사실 이 질문 자체가 흔한 오해에서 나옵니다.</p>
<blockquote>
<p><strong>좋은 백엔드 개발자는 화면을 이해하는 사람입니다.</strong>
화면을 모르면 <strong>좋은 API를 설계할 수 없기</strong> 때문이에요.</p>
</blockquote>
<p>예를 들어볼게요. 상품 상세 페이지 하나만 생각해봅시다. 이 화면엔 상품 정보, 판매자 정보, 리뷰 요약, 재고 상태, 관련 상품, 장바구니 담기 버튼이 모두 있습니다. 백엔드 개발자가 이 화면을 <strong>모른 채로</strong> API를 설계하면 어떻게 될까요?</p>
<ul>
<li><code>/products/{id}</code> 하나만 덩그러니 만들어놓고, 판매자 정보는 <code>/sellers/{id}</code>로 따로 빼고, 리뷰는 <code>/products/{id}/reviews</code>로 또 따로 빼고… 결과적으로 화면 하나 띄우려고 API를 <strong>5번 호출</strong>하게 됩니다 (N+1 문제의 프론트엔드 버전!)</li>
<li>반대로 <strong>화면을 이해하는 백엔드 개발자</strong>는 이렇게 생각합니다. &quot;이 화면이 한 번에 필요한 데이터는 뭐지?&quot; → 그래서 <code>/products/{id}/detail</code> 응답에 상품+판매자+리뷰요약+재고를 <strong>한 번에</strong> 묶어서 내려줍니다. 네트워크 왕복이 1번으로 줄어듭니다.</li>
</ul>
<p>화면 설계서가 백엔드에게 주는 3가지 선물:</p>
<ol>
<li><strong>응답 DTO 설계의 근거가 생긴다</strong> — &quot;이 화면에 뭐가 보이나&quot;가 곧 &quot;이 API 응답에 뭐가 들어가야 하나&quot;입니다. 오버페칭/언더페칭을 피할 수 있습니다.</li>
<li><strong>API 호출 순서와 조합이 보인다</strong> — 로그인→상품조회→장바구니 담기→결제 같은 흐름이 화면 이동과 1:1로 매칭됩니다. 빠진 API, 겹친 API가 드러납니다.</li>
<li><strong>엣지 케이스가 눈에 보인다</strong> — &quot;재고 0개일 때 이 버튼은 어떻게 되나?&quot;, &quot;로그인 안 된 상태에서 장바구니 누르면?&quot; 같은 질문이 화면을 보면 저절로 튀어나옵니다. 이게 전부 API 예외 처리 스펙이 됩니다.</li>
</ol>
<blockquote>
<p><strong>이번 프로젝트는 프론트엔드를 바이브코딩으로 처리합니다.</strong>
즉, 여러분이 직접 AI에게 &quot;로그인 페이지 만들어줘&quot;라고 해야 합니다.</p>
</blockquote>
<p>이렇게 한 방으로 던지면 AI는 <strong>&quot;알아서 예쁘게&quot;</strong> 만듭니다. 그 결과물이 여러분의 API와 일치할 리가 없죠. 그래서 화면 설계서가 이중으로 중요합니다. 화면 설계서가 있으면:</p>
<ul>
<li>AI에게 <strong>&quot;이 화면은 이런 API를 호출하고, 이런 데이터를 이런 레이아웃으로 보여줘&quot;</strong> 라고 명확히 지시할 수 있습니다</li>
<li>나중에 API를 수정해도 화면 설계서를 기준으로 영향도를 파악할 수 있습니다</li>
<li>Figma 목업과 연동하면 <strong>시각적 레퍼런스</strong>까지 AI에게 제공할 수 있습니다</li>
</ul>
<blockquote>
<p><strong>💡 실무 한 줄 요약</strong>
&quot;API 설계를 잘하는 백엔드 개발자는 예외 없이 화면을 잘 이해합니다. 화면을 모르면 API는 &#39;데이터 덤프&#39;가 되고, 화면을 알면 API는 &#39;사용자 경험의 일부&#39;가 됩니다.&quot;</p>
</blockquote>
<h3 id="5-2-🎁-실습-프롬프트-1--화면-목록-도출">5-2. 🎁 실습 프롬프트 1 — 화면 목록 도출</h3>
<pre><code class="language-markdown">우리 유스케이스 기준으로, 필요한 화면 목록을 모두 뽑아줘.
출력 형식:

| 화면 ID | 화면 이름 | 관련 UC | 주요 기능 | 호출 API |
|---|---|---|---|---|

그리고 각 화면마다 &#39;이 화면에 반드시 있어야 할 요소&#39;를
불렛 리스트로 정리해줘. (예: 헤더, 검색바, 상품 카드 그리드, 페이징)</code></pre>
<h3 id="5-3-🎁-실습-프롬프트-2--ascii-와이어프레임">5-3. 🎁 실습 프롬프트 2 — ASCII 와이어프레임</h3>
<pre><code class="language-markdown">위 화면 중 주요 5개에 대해 ASCII 아트 기반 와이어프레임을 그려줘.
각 영역마다 어떤 데이터가 들어가고, 어떤 인터랙션이 가능한지도 주석으로 달아줘.
백엔드 개발자가 API 설계 검증용으로 쓸 거니까 예쁠 필요는 없어. 
첫번째 화면부터 시작하자.</code></pre>
<blockquote>
<p><strong>💡 왜 ASCII 와이어프레임?</strong>
Figma는 나중에 만들 겁니다. 그 전에 <strong>텍스트 기반 와이어프레임</strong>을 먼저 만드는 이유는 (1) AI가 빠르게 생성할 수 있고, (2) 마크다운 문서에 그대로 박아넣을 수 있고, (3) API와 1:1 매핑을 검증하기 쉽기 때문입니다. &quot;이 화면에 장바구니 아이콘이 있는데 장바구니 API가 없네?&quot; 같은 걸 바로 잡아낼 수 있습니다.</p>
</blockquote>
<h3 id="5-4-🎁-실습-프롬프트-3--figma-목업용-프롬프트-생성">5-4. 🎁 실습 프롬프트 3 — Figma 목업용 프롬프트 생성</h3>
<p>ASCII 와이어프레임이 완성되면, 이제 Figma에서 실제 목업을 만들 차례입니다. Figma AI 또는 별도 디자인 AI에게 던질 프롬프트를 <strong>AI에게 만들게</strong> 합니다. 메타 프롬프팅이죠.</p>
<pre><code class="language-markdown">방금 만든 와이어프레임을 Figma에서 목업으로 만들 건데,
Figma AI에게 전달할 프롬프트를 만들어줘.

프롬프트에는:
- 화면 목적
- 필수 컴포넌트 리스트
- 컬러 톤 &amp; 무드
- 레이아웃 가이드 (데스크탑/모바일)
- 참고할 만한 기존 앱 (예: 컬리, 29CM)
가 포함되어야 해.</code></pre>
<h3 id="5-5-최종-산출물-화면-설계서-→-프론트-바이브코딩-프롬프트">5-5. 최종 산출물: &quot;화면 설계서 → 프론트 바이브코딩 프롬프트&quot;</h3>
<p>이 모든 걸 다 만들면, 나중에 프론트엔드 바이브코딩 단계에서 다음처럼 쓸 수 있습니다.</p>
<pre><code class="language-markdown">[AI에게 프론트엔드 코드 요청 시]

아래 화면 설계서를 참고해서 React 컴포넌트를 만들어줘.
- 화면 ID: S-003 (상품 상세)
- 관련 API: GET /api/products/{id}, POST /api/cart
- 와이어프레임: (ASCII 붙여넣기)
- Figma 링크: https://figma.com/...
- 디자인 톤: 29CM 스타일, 미니멀</code></pre>
<blockquote>
<p><strong>🔑 포인트</strong>: 화면 설계서가 있으면 프론트 AI 프롬프트가 <strong>&quot;알아서 해줘&quot;</strong> 에서 <strong>&quot;이 설계서대로 해줘&quot;</strong> 로 바뀝니다. 결과물 퀄리티 차이가 10배입니다.</p>
</blockquote>
<h2 id="step-6-인프라-아키텍처-다이어그램">Step 6: &quot;인프라 아키텍처 다이어그램&quot;</h2>
<h3 id="6-1-도전-기능-배포-자동화의-출발점">6-1. 도전 기능 &quot;배포 자동화&quot;의 출발점</h3>
<p>도전 기능 중에 <strong>EC2 + RDS + ElastiCache + ECR + GitHub Actions 배포 자동화</strong>가 있습니다. 이걸 구현하려면 먼저 <strong>&quot;인프라가 어떻게 생겼는지&quot;</strong> 를 그려야 합니다. 없으면 GitHub Actions 워크플로우를 작성할 때 매번 &quot;이거 어디로 보내야 하지?&quot; 에서 헤맵니다.</p>
<h3 id="6-2-🎁-실습-프롬프트--aws-인프라-설계">6-2. 🎁 실습 프롬프트 — AWS 인프라 설계</h3>
<pre><code class="language-markdown">다음 조건으로 AWS 인프라 아키텍처를 설계해줘:
- EC2 (Spring Boot 앱 서버)
- RDS (MySQL)
- ElastiCache (Redis)
- ECR (Docker 이미지 저장소)
- GitHub Actions (CI/CD)

출력:
1. Mermaid 다이어그램 코드
2. 각 컴포넌트가 왜 필요한지 한 줄 설명
3. 보안 그룹/VPC 구성 제안
4. 예상 월 비용 (프리티어 vs. 실서비스)
5. 배포 파이프라인 흐름 (GitHub push → ECR → EC2까지)</code></pre>
<h3 id="6-3-💰-비용-예측을-프롬프트에-넣는-이유">6-3. 💰 비용 예측을 프롬프트에 넣는 이유</h3>
<p>부트캠프에서 가장 많이 나오는 사고가 <strong>&quot;AWS 요금 폭탄&quot;</strong> 입니다. 처음 특강에서 AI한테 <strong>&quot;예상 월 비용을 프리티어 vs 실서비스 기준으로 알려줘&quot;</strong> 를 요청하는 습관을 들이세요. 이 한 줄이 여러분 지갑을 지켜줍니다.</p>
<h3 id="6-4-실무-연결">6-4. 실무 연결</h3>
<p>실제 스타트업에서 주니어 백엔드 개발자가 &quot;서버 아키텍처 그려줘&quot; 라는 요청을 받으면 당황합니다. 오늘 이 프롬프트 하나만 기억해둬도 실무에서 바로 씁니다.</p>
<h2 id="step-7-동시성-제어-설계서--오늘의-하이라이트-⭐">Step 7: &quot;동시성 제어 설계서 — 오늘의 하이라이트 ⭐&quot;</h2>
<h3 id="7-1-이번-프로젝트의-진짜-핵심">7-1. 이번 프로젝트의 진짜 핵심</h3>
<p><strong>필수 기능</strong>: 비관락, 낙관락, 로컬 캐싱
<strong>도전 기능</strong>: Redis 분산락, Redis 캐싱</p>
<p>이 기능들을 &quot;구현&quot;하는 건 사실 쉽습니다. 진짜 어려운 건 <strong>&quot;언제 뭘 쓸지&quot;</strong> 결정하는 겁니다. 이번 프로젝트가 여러분에게 요구하는 가장 높은 수준의 역량이 바로 이 의사결정입니다.</p>
<h3 id="7-2-🎁-실습-프롬프트-1--동시성-시나리오-도출">7-2. 🎁 실습 프롬프트 1 — 동시성 시나리오 도출</h3>
<pre><code class="language-markdown">우리 커머스 앱에서 동시성 제어가 필요한 시나리오를 모두 뽑아줘.
각 시나리오마다 다음 정보를 포함해줘:
- 시나리오 이름
- 충돌이 발생하는 조건
- 충돌 시 비즈니스 임팩트 (예: 재고 음수, 중복 결제)
- 예상 트래픽 패턴 (예: 평소 낮음, 이벤트 시 폭발)

반드시 포함할 시나리오:
- 한정 수량 상품 재고 차감
- 동일 유저의 중복 주문/결제 방지
- 선착순 쿠폰 발급
- 포인트 적립/사용
- 상품 리뷰 좋아요 카운트</code></pre>
<h3 id="7-3-🎁-실습-프롬프트-2--동시성-전략-매트릭스">7-3. 🎁 실습 프롬프트 2 — 동시성 전략 매트릭스</h3>
<pre><code class="language-markdown">위 시나리오 각각에 대해 다음 3가지 전략 중 뭐가 적합한지
Trade-off와 함께 추천해줘:

A. 비관적 락 (SELECT ... FOR UPDATE)
B. 낙관적 락 (@Version)
C. Redis 분산락 (Redisson)

출력 형식:
| 시나리오 | 추천 전략 | 이유 | 장점 | 단점 | 대안 | 재시도 정책 |

그리고 각 전략을 썼을 때 예상되는 &#39;실패 케이스&#39;도 하나씩 알려줘.
(예: 낙관락 썼을 때 OptimisticLockException 재시도 지옥 같은 것)</code></pre>
<h3 id="7-4-락-전략-선택-가이드">7-4. 락 전략 선택 가이드</h3>
<table>
<thead>
<tr>
<th>상황</th>
<th>권장 전략</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>충돌 빈도 높음 + 단일 DB 트랜잭션</td>
<td><strong>비관락</strong></td>
<td>먼저 잡아두고 나머지는 대기</td>
</tr>
<tr>
<td>충돌 빈도 낮음 + 읽기 많음</td>
<td><strong>낙관락</strong></td>
<td>락 오버헤드 없음, 충돌 시 재시도</td>
</tr>
<tr>
<td>여러 서버 인스턴스 + 공유 자원</td>
<td><strong>Redis 분산락</strong></td>
<td>DB 락으로는 서버 간 동기화 불가</td>
</tr>
<tr>
<td>단일 인스턴스 + 메모리 캐시</td>
<td><strong>로컬 캐싱</strong></td>
<td>네트워크 왕복 제거</td>
</tr>
</tbody></table>
<h3 id="7-5-🎁-실습-프롬프트-3--성능-실험-계획">7-5. 🎁 실습 프롬프트 3 — 성능 실험 계획</h3>
<pre><code class="language-markdown">위에서 정한 동시성 전략이 실제로 잘 동작하는지 증명할 실험 계획을 만들어줘.
- 실험 도구: JMeter / K6 / Locust 중 추천
- 테스트 시나리오: 동시 요청 N건 → 기대 결과
- 성공 기준 (SLA): 예) 동시 1000건 재고 차감 시 음수 발생 0건
- 측정 지표: TPS, p95 응답시간, 실패율</code></pre>
<h3 id="7-6-실무-연결--쿠팡-로켓배송-배민-쿠폰-이벤트">7-6. 실무 연결 — &quot;쿠팡 로켓배송, 배민 쿠폰 이벤트&quot;</h3>
<p>쿠팡 로켓배송 재고관리에서 재고가 음수가 되는 순간 실제로 돈이 사라집니다. 배민의 선착순 쿠폰 이벤트에서 중복 지급이 발생하면 고객 CS가 폭주합니다. 여러분이 오늘 만드는 이 문서가 바로 그런 사고를 막는 설계도입니다.</p>
<h3 id="7-7-⚠️-주의">7-7. ⚠️ 주의</h3>
<ul>
<li><strong>오해 1</strong>: &quot;분산락이 제일 좋은 거 아니에요?&quot;<ul>
<li>대답: &quot;분산락은 가장 비싸고 느립니다. 단일 인스턴스라면 비관락이 더 빠르고 안전합니다.&quot;</li>
</ul>
</li>
<li><strong>오해 2</strong>: &quot;낙관락 쓰면 재시도만 하면 되잖아요?&quot;<ul>
<li>대답: &quot;재시도 지옥이 존재합니다. 충돌 빈도가 높으면 오히려 비관락보다 느려집니다.&quot;</li>
</ul>
</li>
</ul>
<h2 id="step-8-adr-작성법--셀프-리뷰-체크리스트">Step 8: &quot;ADR 작성법 &amp; 셀프 리뷰 체크리스트&quot;</h2>
<h3 id="8-1-adr이란">8-1. ADR이란?</h3>
<p><strong>ADR (Architecture Decision Record)</strong> 은 <strong>&quot;왜 이 기술을 골랐는지&quot;</strong> 를 기록하는 문서입니다. 나중에 팀원이 &quot;이거 왜 이렇게 했어요?&quot; 라고 물어봤을 때 답할 수 있는 유일한 증거입니다. 1주일만 지나도 여러분 본인조차 왜 이렇게 결정했는지 기억 못 합니다. ADR이 없으면 <strong>똑같은 논의를 또 하게 됩니다.</strong></p>
<h3 id="8-2-🎁-adr-템플릿-프롬프트">8-2. 🎁 ADR 템플릿 프롬프트</h3>
<pre><code class="language-markdown">우리가 방금 결정한 &quot;재고 차감에 Redis 분산락을 사용한다&quot;라는
결정을 ADR 형식으로 문서화해줘.

## ADR-XXX: [결정 제목]
- **상태**: Proposed / Accepted / Deprecated
- **컨텍스트**: 왜 이 결정이 필요했는가?
- **고려한 대안**: A / B / C
- **결정**:
- **근거**:
- **결과(긍정)**:
- **결과(부정/리스크)**:
- **재검토 조건**: 언제 이 결정을 다시 봐야 하는가?</code></pre>
<blockquote>
<p><strong>💡 &quot;재검토 조건&quot;이 가장 중요한 이유</strong>
6개월 뒤 여러분 프로젝트의 트래픽이 10배 늘어났을 때, 지금 결정한 분산락 전략이 여전히 유효할까요? &quot;트래픽이 N배 되면 재검토한다&quot;를 미리 적어두면 나중에 언제 이 결정을 다시 봐야 할지 명확해집니다.</p>
</blockquote>
<h3 id="8-3-🎁-최종-셀프-리뷰-프롬프트">8-3. 🎁 최종 셀프 리뷰 프롬프트</h3>
<p>3일간 작성한 문서를 팀원들과 리뷰할 때 쓸 체크리스트입니다.</p>
<pre><code class="language-markdown">내가 작성한 SA 문서 전체를 다음 체크리스트로 검증해줘.
누락된 게 있으면 구체적으로 어떤 문서에 어떤 내용이 빠져있는지 알려줘.

## 체크리스트
- [ ] 유스케이스 ↔ API 엔드포인트 매핑이 1:1 이상 되는가?
- [ ] ERD의 모든 테이블이 최소 하나 이상의 API에서 사용되는가?
- [ ] 필수 기능(비관락/낙관락/로컬캐싱)이 설계에 반영됐는가?
- [ ] 도전 기능이 &quot;왜 필요한지&quot; ADR로 설명됐는가?
- [ ] 동시성 충돌 시나리오가 최소 5개 이상 명시됐는가?
- [ ] 실패/예외 흐름이 API 명세서에 포함됐는가?
- [ ] 인프라 다이어그램의 모든 컴포넌트가 실제로 쓰이는가?
- [ ] 화면 설계서의 모든 화면이 API와 연결되는가?</code></pre>
<h2 id="마무리-5가지-프롬프트-패턴-복습--day-안내">마무리: 5가지 프롬프트 패턴 복습 &amp; Day 안내</h2>
<h3 id="오늘-배운-평생-쓸-5가지-프롬프트-패턴">오늘 배운 &quot;평생 쓸 5가지 프롬프트 패턴&quot;</h3>
<p>오늘 특강은 SA 문서 만들기지만, 진짜로 배워야 할 건 <strong>프롬프트 패턴</strong> 입니다. 이 5가지는 SA 문서뿐 아니라 <strong>앞으로 모든 AI 협업</strong>에서 평생 쓸 수 있습니다.</p>
<pre><code>[패턴 1] 컨텍스트 주입 패턴
  → Role + Constraints + Output Format
  → Step 1의 초기화 프롬프트가 대표 사례

[패턴 2] 단계별 설계 패턴
  → &quot;1단계부터. 내가 OK 해야 다음 단계로&quot;
  → Step 3의 ERD 설계가 대표 사례

[패턴 3] 레드팀 검증 패턴
  → &quot;이제 비판적 리뷰어로 다시 봐줘&quot;
  → Step 3의 ERD 검증이 대표 사례

[패턴 4] Trade-off 매트릭스 패턴
  → &quot;대안 N개와 장단점 비교표&quot;
  → Step 7의 동시성 전략 매트릭스가 대표 사례

[패턴 5] ADR 기록 패턴
  → &quot;이 결정을 ADR 형식으로 남겨줘&quot;
  → Step 8의 ADR 템플릿이 대표 사례</code></pre><h3 id="핵심-정리--ai는-답을-주지-않는다-질문을-더-잘-하게-해준다">핵심 정리 — &quot;AI는 답을 주지 않는다, 질문을 더 잘 하게 해준다&quot;</h3>
<p>AI한테 문서 만들어달라고 했지만, 사실 <strong>AI가 던진 질문</strong>이 여러분을 더 성장시켰을 겁니다. AI한테 좋은 답변을 받으려면 좋은 질문을 해야 하고, 좋은 질문을 하려면 문제를 깊이 이해해야 합니다. 결국 AI와 협업하는 건 <strong>자기 자신의 사고를 정리하는 과정</strong>입니다.</p>
<h3 id="3일간의-sa-문서-작성-로드맵">3일간의 SA 문서 작성 로드맵</h3>
<table>
<thead>
<tr>
<th>일차</th>
<th>시간대</th>
<th>주요 작업</th>
<th>산출물</th>
</tr>
</thead>
<tbody><tr>
<td><strong>1일차 오전</strong></td>
<td><strong>특강 (개인 실습)</strong></td>
<td>Step 1~8 전 과정 따라가기</td>
<td>초안 SA 문서 세트 (개인별)</td>
</tr>
<tr>
<td><strong>1일차 오후</strong></td>
<td>팀 재진행 1차</td>
<td>개인 결과물 공유 → 팀 버전 통합</td>
<td>팀 개요서, 페르소나, 유스케이스</td>
</tr>
<tr>
<td><strong>2일차</strong></td>
<td>팀 작업</td>
<td>ERD, API 명세서, OpenAPI YAML 확정</td>
<td>팀 ERD, API 명세서</td>
</tr>
<tr>
<td><strong>3일차</strong></td>
<td>팀 작업</td>
<td>화면 설계서+Figma 목업, 인프라, 동시성 설계서, ADR</td>
<td>전체 SA 문서 세트 완성</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>💡 오전 특강 후 오후엔 팀으로!</strong>
특강 중에는 <strong>개인</strong>으로 진행하세요. 각자 프롬프트 감각을 익히는 게 먼저입니다. 오후에 팀으로 모였을 때, 각자가 만든 결과물을 비교하면서 통합하면 <strong>&quot;AI는 같은 프롬프트에도 다른 답을 준다&quot;</strong> 는 걸 실감할 수 있습니다. 이 경험 자체가 큰 학습입니다.</p>
</blockquote>
<h3 id="다음-프로젝트-day-예고">다음 프로젝트 Day 예고</h3>
<p>&quot;이제 SA 문서가 생겼습니다. 이걸 근거로 실제 코드를 씁니다. 바이브코딩이 아니라 <strong>명세기반 개발</strong>로요. &#39;이 SA 문서 참고해서 Post 엔티티 먼저 만들어줘&#39; 같은 식으로 AI한테 지시할 수 있게 됩니다. 그 감각이 오늘 특강의 진짜 목표입니다.&quot;</p>
<h2 id="튜터의-과제">튜터의 과제</h2>
<h3 id="과제-homework">과제 (Homework)</h3>
<ol>
<li><strong>[필수] 팀 SA 문서 세트 완성 (3일간)</strong><ul>
<li>섹션 10종 모두 포함: 개요서, 페르소나/시나리오, 유스케이스, 기능명세, ERD, API명세서, 화면설계서, 인프라, 동시성설계서, ADR</li>
<li>제출 형태: GitHub Repository 또는 Notion 워크스페이스</li>
<li>필수 포함: Mermaid ERD, OpenAPI YAML, Figma 목업 링크, ADR 최소 3건</li>
</ul>
</li>
<li><strong>[필수] 프롬프트 히스토리 기록</strong><ul>
<li>이번 SA 문서를 만들며 AI에게 던진 주요 프롬프트 5개 이상 + 그에 대한 AI의 답변을 별도 파일(<code>prompts.md</code>)로 기록</li>
<li>왜 그 프롬프트를 썼는지, 답변이 만족스러웠는지 코멘트 추가</li>
</ul>
</li>
<li><strong>[필수] 동시성 설계서 최소 시나리오 수</strong><ul>
<li>동시성 충돌 시나리오 <strong>최소 5개 이상</strong> 포함</li>
<li>각 시나리오마다 추천 전략 + 이유 + 실패 케이스까지 명시</li>
</ul>
</li>
<li><strong>[선택] 개인 vs 팀 결과물 비교</strong><ul>
<li>특강 중 개인이 만든 초안과 오후에 팀으로 만든 버전의 차이점을 정리</li>
<li>&quot;내가 놓친 부분&quot; / &quot;팀원이 놓친 부분&quot; / &quot;AI가 일관되게 틀린 부분&quot; 각각 1개 이상</li>
</ul>
</li>
<li><strong>[선택] 다른 AI 모델 교차 검증</strong><ul>
<li>같은 프롬프트를 Claude, ChatGPT, Gemini에 각각 던지고 답변을 비교</li>
<li>어떤 모델이 어떤 문서 유형에 강한지 본인만의 기준을 만들어보세요</li>
</ul>
</li>
</ol>
<h3 id="🤔-생각해볼-거리-토론-주제">🤔 생각해볼 거리 (토론 주제)</h3>
<ol>
<li><strong>바이브코딩 vs 명세기반 개발의 경계</strong><ul>
<li>오늘 배운 명세기반 개발이 만능일까요? 어떤 상황에서는 오히려 바이브코딩이 더 나을까요? &quot;3일간의 해커톤&quot; vs &quot;3개월짜리 실서비스&quot; 를 비교해서 논의해보세요.</li>
</ul>
</li>
<li><strong>AI가 던진 질문의 품질</strong><ul>
<li>특강 중 AI가 여러분에게 던진 질문 중 가장 날카로웠던 것과 가장 뻔했던 것을 비교해보세요. 품질 차이가 왜 났을까요? 프롬프트를 어떻게 개선하면 더 날카로운 질문을 받을 수 있을까요?</li>
</ul>
</li>
<li><strong>레드팀 패턴의 한계</strong><ul>
<li>AI가 자기가 만든 걸 AI가 비판하는 방식, 완벽할까요? AI가 미처 비판하지 못하는 blind spot은 어디일까요? 이걸 사람이 어떻게 보완할 수 있을까요?</li>
</ul>
</li>
<li><strong>ADR의 재검토 시점</strong><ul>
<li>오늘 여러분이 만든 ADR 중 &quot;재검토 조건&quot;을 가장 엄격하게 잡아야 할 결정은 무엇인가요? 왜 그렇게 생각하세요?</li>
</ul>
</li>
<li><strong>&quot;AI 없으면 설계 못 하는 개발자&quot;가 되지 않으려면?</strong><ul>
<li>AI에 너무 의존하면 본인의 설계 근력이 사라질 위험이 있습니다. AI를 쓰면서도 본인이 성장하려면 어떻게 해야 할까요? 오늘 배운 5가지 프롬프트 패턴 중 어떤 게 가장 본인 성장에 도움이 될 것 같나요?</li>
</ul>
</li>
</ol>
<blockquote>
<p><strong>마지막 한마디</strong>
오늘 여러분은 AI와 협업하는 법을 배웠지만, 진짜로 얻어가야 할 건 <strong>&quot;스스로에게 좋은 질문을 던지는 습관&quot;</strong> 입니다.
AI에게 좋은 질문을 할 수 있다는 건, 문제를 깊이 이해하고 있다는 뜻이니까요.
남은 3주 동안, AI와 같이 성장하는 개발자가 되길 바랍니다. 화이팅!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI 바이브 코딩 프롬프트 - 프론트엔드 편]]></title>
            <link>https://velog.io/@jiiim_ni/AI-%EB%B0%94%EC%9D%B4%EB%B8%8C-%EC%BD%94%EB%94%A9-%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%8E%B8</link>
            <guid>https://velog.io/@jiiim_ni/AI-%EB%B0%94%EC%9D%B4%EB%B8%8C-%EC%BD%94%EB%94%A9-%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%8E%B8</guid>
            <pubDate>Tue, 19 May 2026 01:34:35 GMT</pubDate>
            <description><![CDATA[<h1 id="프론트엔드-바이브코딩-가이드">프론트엔드 바이브코딩 가이드</h1>
<h1 id="프론트엔드-바이브코딩-프롬프트-가이드">프론트엔드 바이브코딩 프롬프트 가이드</h1>
<blockquote>
<p>백엔드 개발자를 위한 AI 활용 프론트엔드 개발 가이드
기술 스택: React + Vite + TypeScript</p>
</blockquote>
<hr>
<h2 id="1-바이브코딩이란">1. 바이브코딩이란?</h2>
<p>바이브코딩은 AI에게 자연어로 &quot;이런 느낌(vibe)으로 만들어줘&quot;라고 설명하면 코드를 생성해주는 방식의 개발을 말합니다. 여러분이 백엔드에서 Spring Boot로 API를 설계하는 것처럼, 프론트엔드도 와이어프레임과 API 명세서를 기반으로 AI에게 지시하면 됩니다.</p>
<p>쉽게 비유하면 이런 겁니다. 여러분이 인테리어 업체에 &quot;이런 분위기의 카페를 만들어주세요&quot;라고 레퍼런스 사진과 요구사항을 전달하는 것과 같아요. 레퍼런스 사진이 <strong>와이어프레임</strong>이고, 요구사항이 <strong>프롬프트</strong>입니다.</p>
<hr>
<h2 id="2-시작하기-전-준비물">2. 시작하기 전 준비물</h2>
<h3 id="2-1-프로젝트-초기-세팅">2-1. 프로젝트 초기 세팅</h3>
<p>AI에게 프로젝트를 처음 세팅해달라고 요청할 때는 아래와 같이 하면 됩니다.</p>
<pre><code>React + Vite + TypeScript 프로젝트를 생성해줘.
추가 라이브러리:
- react-router-dom (페이지 이동)
- axios (API 호출)
- styled-components 또는 tailwindcss (스타일링)

폴더 구조는 아래처럼 잡아줘:
src/
  ├── pages/        # 페이지 단위 컴포넌트
  ├── components/   # 재사용 가능한 공통 컴포넌트
  ├── api/          # API 호출 함수 모음
  ├── hooks/        # 커스텀 훅
  ├── types/        # TypeScript 타입 정의
  └── styles/       # 글로벌 스타일</code></pre><blockquote>
<p><strong>왜 폴더 구조를 먼저 잡을까?</strong>
Spring Boot에서 controller, service, repository 패키지를 나누는 것처럼, 프론트엔드도 역할별로 폴더를 나누면 AI가 코드를 생성할 때 일관된 위치에 파일을 만들어줍니다.</p>
</blockquote>
<h3 id="2-2-준비해야-할-자료">2-2. 준비해야 할 자료</h3>
<table>
<thead>
<tr>
<th>자료</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>와이어프레임</td>
<td>화면 레이아웃을 보여주는 이미지 또는 ASCII 스케치</td>
<td>Figma 캡처, 손그림, 아스키아트</td>
</tr>
<tr>
<td>화면설계서</td>
<td>각 화면의 동작을 정리한 문서</td>
<td>&quot;로그인 실패 시 에러 메시지 표시&quot;</td>
</tr>
<tr>
<td>API 명세서</td>
<td>백엔드 API의 URL, 메서드, 요청/응답 형태</td>
<td>Swagger UI, Markdown 문서</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-프롬프트-작성의-핵심-원칙">3. 프롬프트 작성의 핵심 원칙</h2>
<h3 id="원칙-1-역할을-먼저-부여하라">원칙 1: 역할을 먼저 부여하라</h3>
<p>AI에게 &quot;넌 이런 사람이야&quot;라고 알려주면 훨씬 일관된 코드를 만들어줍니다.</p>
<pre><code>너는 React + TypeScript 프론트엔드 시니어 개발자야.
나는 백엔드 개발자이고 프론트엔드는 잘 몰라.
내가 와이어프레임과 API 명세서를 줄 테니,
이걸 기반으로 React 컴포넌트를 만들어줘.

코드를 작성할 때 다음 규칙을 지켜줘:
- 함수형 컴포넌트만 사용
- TypeScript를 사용하고, 타입은 반드시 정의
- API 호출은 axios를 사용하고, src/api/ 폴더에 분리
- 스타일은 tailwindcss 사용
- 한국어 주석으로 각 코드 블록이 무엇을 하는지 설명</code></pre><blockquote>
<p><strong>비유:</strong> Spring Boot에서 <code>application.yml</code>로 프로젝트 전체 설정을 잡는 것처럼, 이 역할 부여가 프론트엔드 프로젝트의 &quot;설정 파일&quot; 역할을 합니다.</p>
</blockquote>
<h3 id="원칙-2-와이어프레임은-구체적으로-설명하라">원칙 2: 와이어프레임은 구체적으로 설명하라</h3>
<p>이미지를 첨부할 수 있으면 첨부하되, 텍스트로도 설명을 같이 넣어주는 것이 좋습니다.</p>
<p><strong>나쁜 예시:</strong></p>
<pre><code>로그인 페이지 만들어줘</code></pre><p><strong>좋은 예시:</strong></p>
<pre><code>아래 와이어프레임을 보고 로그인 페이지를 만들어줘.

[와이어프레임 이미지 첨부 또는 아스키아트]

+----------------------------------+
|           로고 이미지              |
|                                  |
|  [이메일 입력]                    |
|  [비밀번호 입력]                   |
|                                  |
|  [로그인 버튼]                    |
|                                  |
|  아직 회원이 아니신가요? [회원가입]   |
+----------------------------------+

화면 동작 설명:
- 이메일과 비밀번호를 입력하고 로그인 버튼을 클릭하면 로그인 API를 호출한다
- 로그인 성공 시 메인 페이지(/)로 이동한다
- 로그인 실패 시 입력 필드 아래에 빨간색으로 에러 메시지를 표시한다
- 이메일 형식이 올바르지 않으면 &quot;올바른 이메일 형식을 입력해주세요&quot; 메시지를 표시한다
- 회원가입 링크를 클릭하면 /signup 페이지로 이동한다</code></pre><h3 id="원칙-3-api-명세서를-정확하게-전달하라">원칙 3: API 명세서를 정확하게 전달하라</h3>
<p>백엔드 개발자인 여러분이 가장 잘 할 수 있는 부분입니다. API 명세를 정확하게 전달하면 AI가 API 연동 코드를 깔끔하게 만들어줍니다.</p>
<pre><code>이 페이지에서 사용하는 API 명세는 다음과 같아:

### 로그인 API
- URL: POST /api/v1/auth/login
- Request Body:
  {
    &quot;email&quot;: &quot;string&quot;,
    &quot;password&quot;: &quot;string&quot;
  }
- Response (200 OK):
  {
    &quot;accessToken&quot;: &quot;string&quot;,
    &quot;refreshToken&quot;: &quot;string&quot;,
    &quot;user&quot;: {
      &quot;id&quot;: &quot;number&quot;,
      &quot;email&quot;: &quot;string&quot;,
      &quot;nickname&quot;: &quot;string&quot;
    }
  }
- Response (401 Unauthorized):
  {
    &quot;code&quot;: &quot;AUTH_001&quot;,
    &quot;message&quot;: &quot;이메일 또는 비밀번호가 올바르지 않습니다&quot;
  }

accessToken은 로컬스토리지에 저장하고,
이후 API 호출 시 Authorization 헤더에 Bearer 토큰으로 포함해줘.</code></pre><blockquote>
<p><strong>Swagger를 활용하는 경우:</strong>
Swagger UI에서 API 정보를 복사해서 붙여넣거나, OpenAPI JSON 파일을 직접 제공하면 됩니다.</p>
<pre><code>첨부한 swagger.json 파일을 분석해서
이 프로젝트에서 사용할 API 호출 함수들을
src/api/ 폴더에 만들어줘.</code></pre></blockquote>
<h3 id="원칙-4-한-번에-하나의-페이지씩-요청하라">원칙 4: 한 번에 하나의 페이지씩 요청하라</h3>
<p><strong>나쁜 예시:</strong></p>
<pre><code>전체 화면 다 만들어줘</code></pre><p><strong>좋은 예시:</strong></p>
<pre><code>지금은 로그인 페이지만 만들어줘.
나머지 페이지는 이후에 하나씩 요청할게.</code></pre><blockquote>
<p><strong>비유:</strong> Spring에서 Controller를 한 번에 다 만들지 않고 하나씩 만드는 것과 같아요. AI도 한 번에 너무 많은 것을 요청하면 일관성이 떨어질 수 있습니다.</p>
</blockquote>
<hr>
<h2 id="4-단계별-프롬프트-템플릿">4. 단계별 프롬프트 템플릿</h2>
<p>프론트엔드 바이브코딩은 아래 순서로 진행하면 됩니다. 마치 Spring Boot에서 Entity → Repository → Service → Controller 순서로 만드는 것처럼, 프론트엔드도 순서가 있습니다.</p>
<h3 id="step-1-api-연동-함수-먼저-만들기--repository-계층">Step 1: API 연동 함수 먼저 만들기 (= Repository 계층)</h3>
<pre><code>다음 API 명세서를 보고 src/api/ 폴더에 API 호출 함수들을 만들어줘.

[API 명세서 붙여넣기 또는 파일 첨부]

규칙:
- axios 인스턴스를 src/api/client.ts에 만들고, baseURL과 인터셉터를 설정해줘
- 각 도메인별로 파일을 분리해줘 (auth.ts, user.ts, product.ts 등)
- 요청/응답 타입은 src/types/ 폴더에 정의해줘
- 에러 처리는 인터셉터에서 공통으로 처리해줘</code></pre><blockquote>
<p><strong>왜 API부터?</strong> Spring에서 Repository를 먼저 만들고 Service에서 호출하는 것처럼, 프론트엔드도 API 호출 함수를 먼저 만들어두면 페이지를 만들 때 가져다 쓰기만 하면 됩니다.</p>
</blockquote>
<h3 id="step-2-공통-컴포넌트-만들기--공통-유틸리티">Step 2: 공통 컴포넌트 만들기 (= 공통 유틸리티)</h3>
<pre><code>우리 프로젝트에서 공통으로 사용할 컴포넌트를 만들어줘.
src/components/ 폴더에 넣어줘.

필요한 공통 컴포넌트:
- Button: 크기(small, medium, large), 색상(primary, secondary, danger) 변경 가능
- Input: 라벨, 에러 메시지 표시 기능 포함
- Header: 로고, 네비게이션 메뉴, 로그인/로그아웃 버튼 포함
- Modal: 제목, 내용, 확인/취소 버튼이 있는 모달 팝업</code></pre><h3 id="step-3-페이지-만들기--controller-계층">Step 3: 페이지 만들기 (= Controller 계층)</h3>
<p>여기서 와이어프레임과 화면설계서를 활용합니다.</p>
<pre><code>아래 와이어프레임과 화면 동작 설명을 보고
회원가입 페이지를 만들어줘.

파일 위치: src/pages/SignupPage.tsx

[와이어프레임 이미지 첨부 또는 아스키아트]

화면 동작:
1. 이메일, 비밀번호, 비밀번호 확인, 닉네임 입력 필드가 있다
2. 비밀번호와 비밀번호 확인이 일치하지 않으면 &quot;비밀번호가 일치하지 않습니다&quot; 표시
3. 모든 필드를 입력하면 회원가입 버튼이 활성화된다
4. 회원가입 성공 시 로그인 페이지로 이동한다
5. 이메일 중복 에러(409) 발생 시 &quot;이미 사용 중인 이메일입니다&quot; 표시

사용할 API: src/api/auth.ts에 있는 signup 함수를 사용해줘
사용할 컴포넌트: src/components/에 있는 Button, Input 컴포넌트를 사용해줘</code></pre><h3 id="step-4-라우팅-설정--요청-매핑">Step 4: 라우팅 설정 (= 요청 매핑)</h3>
<pre><code>react-router-dom을 사용해서 페이지 라우팅을 설정해줘.

라우트 구조:
- / : 메인 페이지 (로그인 필요)
- /login : 로그인 페이지
- /signup : 회원가입 페이지
- /products : 상품 목록 페이지 (로그인 필요)
- /products/:id : 상품 상세 페이지 (로그인 필요)

로그인이 필요한 페이지는 accessToken이 없으면 /login으로 리다이렉트해줘.
이미 로그인한 사용자가 /login이나 /signup에 접근하면 /로 리다이렉트해줘.</code></pre><blockquote>
<p><strong>비유:</strong> Spring Security의 SecurityFilterChain에서 URL별 접근 권한을 설정하는 것과 똑같은 개념이에요!</p>
</blockquote>
<h3 id="step-5-상태-관리-연결--service-계층">Step 5: 상태 관리 연결 (= Service 계층)</h3>
<pre><code>로그인 상태를 전역으로 관리할 수 있게 해줘.
React Context API를 사용해서 AuthContext를 만들어줘.

관리할 상태:
- isLoggedIn: 로그인 여부
- user: 유저 정보 (id, email, nickname)
- login(): 로그인 함수 (토큰 저장 + 유저 정보 설정)
- logout(): 로그아웃 함수 (토큰 삭제 + 상태 초기화)

이 Context를 App 컴포넌트 최상단에 감싸줘.</code></pre><hr>
<h2 id="5-자주-쓰는-상황별-프롬프트">5. 자주 쓰는 상황별 프롬프트</h2>
<h3 id="상황-1-목록--페이징-화면">상황 1: 목록 + 페이징 화면</h3>
<pre><code>아래 와이어프레임을 보고 상품 목록 페이지를 만들어줘.

[와이어프레임]

+----------------------------------+
|  [검색창]              [검색버튼]  |
|                                  |
|  +------+  +------+  +------+   |
|  |상품1  |  |상품2  |  |상품3  |   |
|  |이미지  |  |이미지  |  |이미지  |   |
|  |이름   |  |이름   |  |이름   |   |
|  |가격   |  |가격   |  |가격   |   |
|  +------+  +------+  +------+   |
|                                  |
|       &lt; 1 2 3 4 5 &gt;              |
+----------------------------------+

사용할 API:
- GET /api/v1/products?page={page}&amp;size={size}&amp;keyword={keyword}
- Response: { content: Product[], totalPages: number, currentPage: number }

동작:
- 한 페이지에 9개씩 (3x3 그리드) 표시
- 상품 카드를 클릭하면 /products/{id}로 이동
- 검색어를 입력하고 검색하면 keyword 파라미터로 API 호출
- 페이지 번호를 클릭하면 해당 페이지로 이동</code></pre><h3 id="상황-2-폼-입력--유효성-검사">상황 2: 폼 입력 + 유효성 검사</h3>
<pre><code>아래 와이어프레임을 보고 상품 등록 페이지를 만들어줘.

[와이어프레임]

화면 동작:
- 상품명(필수, 2~50자), 가격(필수, 0 이상), 설명(선택, 최대 500자),
  이미지 업로드(최대 3장)
- 각 필드에서 포커스를 벗어날 때(onBlur) 유효성 검사를 수행
- 유효성 검사 실패 시 해당 필드 아래에 빨간색 에러 메시지 표시
- 모든 필수 필드가 유효할 때만 등록 버튼 활성화
- 등록 성공 시 상품 목록 페이지로 이동

사용할 API:
- POST /api/v1/products (multipart/form-data)</code></pre><h3 id="상황-3-실시간-데이터-채팅-알림-등">상황 3: 실시간 데이터 (채팅, 알림 등)</h3>
<pre><code>WebSocket을 사용한 실시간 채팅 페이지를 만들어줘.

WebSocket 연결 정보:
- URL: ws://localhost:8080/ws/chat
- STOMP 프로토콜 사용
- 구독: /topic/chat/{roomId}
- 발행: /app/chat/{roomId}

메시지 형태:
{
  &quot;senderId&quot;: number,
  &quot;content&quot;: string,
  &quot;sentAt&quot;: string (ISO 8601)
}

화면 동작:
- 기존 메시지는 GET /api/v1/chat/{roomId}/messages로 불러온다
- 새 메시지는 WebSocket으로 실시간 수신
- 내 메시지는 오른쪽, 상대 메시지는 왼쪽에 표시
- 메시지를 보내면 입력창이 비워지고, 스크롤이 맨 아래로 이동</code></pre><h3 id="상황-4-기존-코드-수정보완-요청">상황 4: 기존 코드 수정/보완 요청</h3>
<pre><code>방금 만든 로그인 페이지에 다음 기능을 추가해줘:

1. &quot;로그인 상태 유지&quot; 체크박스 추가
   - 체크 시: refreshToken을 localStorage에 저장
   - 미체크 시: refreshToken을 sessionStorage에 저장
2. 소셜 로그인 버튼 추가 (카카오, 구글)
   - 카카오: GET /api/v1/auth/oauth2/kakao로 리다이렉트
   - 구글: GET /api/v1/auth/oauth2/google로 리다이렉트
3. 로그인 버튼 클릭 시 로딩 스피너 표시

기존 코드 구조는 유지하면서 추가해줘.</code></pre><hr>
<h2 id="6-트러블슈팅-프롬프트">6. 트러블슈팅 프롬프트</h2>
<p>개발 중에 문제가 생기면 이렇게 물어보세요.</p>
<h3 id="에러가-발생했을-때">에러가 발생했을 때</h3>
<pre><code>아래 에러가 발생했어. 원인과 해결 방법을 알려줘.

[에러 메시지 또는 스크린샷 붙여넣기]

현재 코드 상태:
- 어떤 파일에서 발생했는지
- 어떤 동작을 하다가 발생했는지</code></pre><h3 id="화면이-예상과-다를-때">화면이 예상과 다를 때</h3>
<pre><code>로그인 페이지에서 아래 문제가 있어:
1. 입력 필드가 화면 왼쪽으로 치우쳐 있어 → 가운데 정렬해줘
2. 모바일에서 버튼이 너무 작아 → 모바일에서는 전체 너비로 변경해줘
3. 에러 메시지가 빨간색이 아니라 검정색이야 → 빨간색(#FF4444)으로 변경해줘

[현재 화면 스크린샷 첨부하면 더 좋음]</code></pre><h3 id="api-연동-문제">API 연동 문제</h3>
<pre><code>로그인 API 호출이 안 돼.
Network 탭에서 확인한 내용:
- Request URL: &lt;http://localhost:5173/api/v1/auth/login&gt;
- Status: 404

백엔드 서버는 localhost:8080에서 돌아가고 있어.
CORS 설정은 백엔드에서 해둔 상태야.
Vite 프록시 설정을 추가해줘.</code></pre><hr>
<h2 id="7-프롬프트-체크리스트">7. 프롬프트 체크리스트</h2>
<p>페이지를 요청하기 전에 아래 항목을 확인하세요.</p>
<pre><code>□ 와이어프레임 또는 화면 스케치를 준비했는가?
□ 이 화면에서 사용하는 API 명세를 정리했는가?
  - URL, Method, Request Body, Response Body, 에러 응답
□ 화면의 동작(인터랙션)을 글로 정리했는가?
  - 버튼 클릭 시 무슨 일이 일어나는지
  - 성공/실패 시 각각 어떻게 되는지
  - 페이지 이동은 어디로 하는지
□ 이 페이지에서 사용할 공통 컴포넌트가 이미 만들어져 있는가?
□ 이전에 만든 API 함수나 타입을 재사용할 수 있는가?</code></pre><hr>
<h2 id="8-claudemd-활용하기">8. <a href="http://claude.md/">CLAUDE.md</a> 활용하기</h2>
<p>클로드코드를 사용한다면 프로젝트 루트에 <code>CLAUDE.md</code> 파일을 만들어두면, Claude Code가 프로젝트 컨텍스트를 항상 기억합니다. Spring의 <code>application.yml</code>과 비슷한 역할이에요.</p>
<pre><code class="language-markdown"># CLAUDE.md

## 프로젝트 개요
이커머스 플랫폼의 프론트엔드 (React + Vite + TypeScript)

## 기술 스택
- React 18 + TypeScript
- Vite
- react-router-dom v6
- axios
- TailwindCSS

## 코딩 규칙
- 함수형 컴포넌트 + hooks만 사용
- 컴포넌트 파일명은 PascalCase (예: LoginPage.tsx)
- API 함수는 src/api/ 에 도메인별로 분리
- 타입 정의는 src/types/ 에 도메인별로 분리
- 주석은 한국어로 작성
- CSS는 TailwindCSS 유틸리티 클래스 사용

## API 서버 정보
- 개발 서버: &lt;http://localhost:8080&gt;
- Vite 프록시: /api → &lt;http://localhost:8080/api&gt;

## 폴더 구조
src/
  ├── api/          # API 호출 함수
  ├── components/   # 공통 컴포넌트
  ├── hooks/        # 커스텀 훅
  ├── pages/        # 페이지 컴포넌트
  ├── types/        # TypeScript 타입 정의
  ├── contexts/     # React Context
  └── styles/       # 글로벌 스타일</code></pre>
<hr>
<h2 id="9-실전-워크플로우-요약">9. 실전 워크플로우 요약</h2>
<p>전체 작업 흐름을 정리하면 이렇습니다.</p>
<pre><code>1단계: 프로젝트 세팅
  └─ &quot;React + Vite 프로젝트 만들어줘&quot; + CLAUDE.md 설정

2단계: API 레이어 구축 (Swagger/API 명세서 전달)
  └─ &quot;API 명세서 보고 호출 함수 만들어줘&quot;

3단계: 공통 컴포넌트 생성
  └─ &quot;Button, Input, Header 등 공통 컴포넌트 만들어줘&quot;

4단계: 페이지별 개발 (와이어프레임 + 화면설계서 전달)
  └─ &quot;이 와이어프레임 보고 ○○ 페이지 만들어줘&quot;
  └─ 한 페이지씩 순서대로 진행

5단계: 라우팅 + 인증 처리
  └─ &quot;페이지 라우팅 설정하고 로그인 필요한 페이지 보호해줘&quot;

6단계: 테스트 + 수정
  └─ 화면 확인 → 문제 있으면 스크린샷과 함께 수정 요청</code></pre><hr>
<h2 id="10-주의사항">10. 주의사항</h2>
<p><strong>하지 말 것:</strong></p>
<ul>
<li>&quot;알아서 예쁘게 만들어줘&quot; → AI가 임의로 판단하면 원하는 결과가 안 나옵니다</li>
<li>&quot;전체 페이지 다 한번에 만들어줘&quot; → 한 번에 너무 많으면 품질이 떨어집니다</li>
<li>API 명세 없이 &quot;로그인 기능 만들어줘&quot; → API 형태를 모르면 연동이 안 됩니다</li>
</ul>
<p><strong>꼭 할 것:</strong></p>
<ul>
<li>와이어프레임을 최대한 구체적으로 설명하기</li>
<li>API 명세서의 요청/응답 형태를 정확하게 전달하기</li>
<li>에러 상황(4xx, 5xx)에서의 동작도 명시하기</li>
<li>수정 요청 시 구체적으로 &quot;어디를 어떻게 바꿔달라&quot;고 말하기</li>
<li>작업 후 화면을 직접 확인하고, 문제가 있으면 스크린샷과 함께 피드백하기</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI 바이브 코딩 프롬프트 - 백엔드편]]></title>
            <link>https://velog.io/@jiiim_ni/AI-%EB%B0%94%EC%9D%B4%EB%B8%8C-%EC%BD%94%EB%94%A9-%EB%B0%B1%EC%97%94%EB%93%9C%ED%8E%B8</link>
            <guid>https://velog.io/@jiiim_ni/AI-%EB%B0%94%EC%9D%B4%EB%B8%8C-%EC%BD%94%EB%94%A9-%EB%B0%B1%EC%97%94%EB%93%9C%ED%8E%B8</guid>
            <pubDate>Tue, 19 May 2026 01:33:32 GMT</pubDate>
            <description><![CDATA[<h1 id="백엔드-바이브코딩-가이드">백엔드 바이브코딩 가이드</h1>
<p>CH5 팀 프로젝트때 티켓팅 사이트 구현했는데 
그때 튜터님이 알려주셨던 바이브 코딩 가이드 정리!
(내가 나중에 보려고)</p>
<h1 id="백엔드-바이브코딩-프롬프트-가이드ticketflow">백엔드 바이브코딩 프롬프트 가이드(TicketFlow)</h1>
<blockquote>
<p>SA 문서 10종을 기반으로 Claude Code에게 백엔드 코드를 생성시키는 프롬프트 작성 가이드
기술 스택: Java 17 + Spring Boot 3.x + JPA + MySQL 8 + Redis + Docker</p>
</blockquote>
<hr>
<h2 id="1-바이브코딩으로-백엔드를-만든다는-것">1. 바이브코딩으로 백엔드를 만든다는 것</h2>
<p>바이브코딩은 AI에게 &quot;이런 걸 만들어줘&quot;라고 자연어로 지시하면 코드를 생성하는 방식입니다. 여러분은 이미 SA 문서 10종(프로젝트 개요서, ERD, API 명세서, 기능 명세서, 동시성 제어 설계서 등)을 작성했습니다. 이 문서들이 바로 AI에게 전달할 <strong>설계도</strong>입니다.</p>
<p>비유하자면 이렇습니다. 여러분이 건축주이고, SA 문서가 설계도면이고, Claude Code가 시공업체입니다. 설계도면이 정확할수록 시공 품질이 좋아지는 것처럼, SA 문서를 얼마나 정확하게 전달하느냐가 바이브코딩의 품질을 결정합니다.</p>
<hr>
<h2 id="2-클로드코드를-사용할-경우--시작하기-전-claudemd-세팅">2. 클로드코드를 사용할 경우 ? 시작하기 전: <a href="http://claude.md/">CLAUDE.md</a> 세팅</h2>
<p>프로젝트 루트에 <code>CLAUDE.md</code>를 만들어두면 Claude Code가 프로젝트 컨텍스트를 매 대화마다 기억합니다. Spring Boot의 <code>application.yml</code>이 프로젝트 설정을 담는 것처럼, <code>CLAUDE.md</code>는 AI를 위한 프로젝트 설정 파일입니다.</p>
<pre><code class="language-markdown"># CLAUDE.md — TicketFlow Backend

## 프로젝트 개요
TicketFlow — 공연·스포츠 티켓팅 플랫폼 백엔드 (B2C)
공정한 선착순 좌석 예매 + 선착순 쿠폰 발급 + 검색 캐싱

## 기술 스택
- Java 17 + Spring Boot 3.x
- Spring Data JPA + QueryDSL
- Spring Security + JWT (AccessToken 단독, 1시간 TTL)
- MySQL 8 (InnoDB, 타임존 KST)
- Redis 7 (Lettuce — 분산락 SETNX, Hold TTL, 인기검색어 ZSet)
- Caffeine (로컬 캐시, v2 검색)
- Docker + Docker Compose

## 패키지 구조
com.ticketflow
├── global/          # 공통 설정, 예외 처리, 보안
│   ├── config/      # SecurityConfig, RedisConfig, CacheConfig
│   ├── exception/   # GlobalExceptionHandler, ErrorCode, CustomException
│   └── jwt/         # JwtTokenProvider, JwtAuthenticationFilter
├── domain/
│   ├── user/        # User 엔티티, 회원가입/로그인
│   ├── event/       # Event, Section, Seat, Venue 엔티티 + 검색
│   ├── booking/     # Order, Booking, ActiveBooking + Hold + 결제
│   ├── coupon/      # Coupon, UserCoupon + 선착순 발급
│   └── chat/        # ChatRoom, ChatMessage + WebSocket (도전)
└── infra/
    └── redis/       # RedisLockRepository, RedisCacheConfig

## 각 도메인 패키지 내부 구조 (일관되게 유지)
domain/{도메인}/
├── entity/          # JPA 엔티티
├── repository/      # Spring Data JPA Repository
├── service/         # 비즈니스 로직
├── controller/      # REST API
├── dto/
│   ├── request/     # 요청 DTO
│   └── response/    # 응답 DTO
└── exception/       # 도메인 전용 예외 (선택)

## 코딩 규칙
- 엔티티에 @Setter 사용 금지 → 비즈니스 메서드로 상태 변경
- DTO와 엔티티 분리 필수 — 컨트롤러에서 엔티티 직접 반환 금지
- 응답은 공통 응답 형식 사용: { status, code, message, data }
- 예외는 GlobalExceptionHandler에서 일괄 처리
- 서비스 메서드에 @Transactional(readOnly=true) 기본, 쓰기 메서드만 @Transactional
- 테스트: 단위 테스트(Mockito) + 동시성 테스트(ExecutorService + CyclicBarrier)
- 한국어 주석으로 핵심 비즈니스 로직 설명

## ERD 핵심 테이블
- USER (user_id PK, email UK, password, nickname, role ENUM)
- VENUE (venue_id PK, name, address) — 시드데이터 1개만, venue_id=1 고정
- EVENT (event_id PK, venue_id FK, title, category ENUM, event_date, sale_start_at, sale_end_at, round_number, status ENUM)
- SECTION (section_id PK, event_id FK, section_name, price, total_seats)
- SEAT (seat_id PK, section_id FK, row_name, col_num) — status 컬럼 없음!
- ORDER (order_id PK, user_id FK, user_coupon_id FK nullable, total/discount/final_amount, status ENUM)
- BOOKING (booking_id PK, order_id FK, user_id FK, seat_id FK, event_id FK, original_price, ticket_code UK nullable, status ENUM)
- ACTIVE_BOOKING (seat_id PK, booking_id UK) — 확정된 예약만, 중복 확정 물리적 차단
- PAYMENT (payment_id PK, order_id FK, payment_key UK, method, paid_amount, status ENUM)
- COUPON (coupon_id PK, name, discount_amount, total_quantity, remaining_quantity)
- USER_COUPON (user_coupon_id PK, user_id FK, coupon_id FK, status ENUM, version)
- CHAT_ROOM, CHAT_MESSAGE (도전 기능)

## 좌석 상태 판단 로직 (SEAT에 status 컬럼 없음!)
1. ACTIVE_BOOKING에 seat_id 존재 → CONFIRMED
2. Redis hold:{eventId}:{seatId} 키 존재 → ON_HOLD
3. 그 외 → AVAILABLE

## 동시성 제어
- 좌석 Hold: Lettuce SETNX 분산락 (lock:seat:{eventId}:{seatId}), Fail Fast
- 쿠폰 발급: Redis DECR + Lua Script 원자적 차감 (분산락 미사용)
- 쿠폰 사용: lock:user-coupon-use:{userCouponId} 분산락 + @Version 낙관적 락
- 락 획득 순서: ① 좌석 Hold 락 → ② 쿠폰 사용 락 → ③ DB 트랜잭션 (역순 해제)

## 서버 타임존
KST (UTC+9) — spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Seoul</code></pre>
<blockquote>
<p><strong>이 CLAUDE.md를 프로젝트 루트에 먼저 만들어두세요.</strong> Claude Code가 이 파일을 자동으로 읽고, 이후 모든 코드 생성에 이 컨텍스트를 반영합니다.</p>
</blockquote>
<hr>
<h2 id="3-sa-문서-→-프롬프트-매핑-전략">3. SA 문서 → 프롬프트 매핑 전략</h2>
<p>여러분이 작성한 SA 문서 10종은 각각 바이브코딩의 특정 단계에서 활용됩니다.</p>
<table>
<thead>
<tr>
<th>SA 문서</th>
<th>바이브코딩 활용 시점</th>
<th>어떻게 전달하나</th>
</tr>
</thead>
<tbody><tr>
<td>01 프로젝트 개요서</td>
<td><a href="http://claude.md/">CLAUDE.md</a> 작성</td>
<td>기술 스택, 핵심 플로우 요약</td>
</tr>
<tr>
<td>02 사용자 시나리오</td>
<td>통합 테스트 시나리오 작성</td>
<td>페르소나별 Happy/Unhappy Path</td>
</tr>
<tr>
<td>03 유스케이스 명세서</td>
<td>서비스 로직 구현 요청</td>
<td>UC별 주요/대체/예외 흐름</td>
</tr>
<tr>
<td>04 기능 명세서</td>
<td><strong>핵심!</strong> 기능 단위 구현 요청</td>
<td>FN-ID별로 하나씩 요청</td>
</tr>
<tr>
<td>05 ERD</td>
<td>엔티티 + Repository 생성</td>
<td>ERD Mermaid 또는 테이블 DDL</td>
</tr>
<tr>
<td>06 API 명세서</td>
<td>Controller + DTO 생성</td>
<td>엔드포인트별 요청/응답 스펙</td>
</tr>
<tr>
<td>07 화면 설계서</td>
<td>프론트엔드 연동 검증</td>
<td>API 응답과 화면 매핑 확인</td>
</tr>
<tr>
<td>08 인프라 아키텍처</td>
<td>Docker Compose + CI/CD 구성</td>
<td>다이어그램 + 설정 파일</td>
</tr>
<tr>
<td>09 동시성 제어 설계서</td>
<td>분산락 + 동시성 테스트 구현</td>
<td>시나리오 매트릭스 + 코드 예시</td>
</tr>
<tr>
<td>10 ADR</td>
<td>아키텍처 결정 사항 반영</td>
<td>왜 이렇게 설계했는지 근거</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-구현-순서와-단계별-프롬프트">4. 구현 순서와 단계별 프롬프트</h2>
<p>Spring Boot에서 개발할 때 보통 Entity → Repository → Service → Controller 순서로 만들죠? 바이브코딩도 마찬가지입니다. 단, <strong>인프라 세팅을 가장 먼저</strong> 합니다.</p>
<pre><code>구현 순서:

Phase 0: 프로젝트 초기 세팅 + Docker Compose     ← 인프라 아키텍처 문서
Phase 1: 공통 설정 (Security, Exception, Redis)   ← ADR + 프로젝트 개요서
Phase 2: 엔티티 + Repository                      ← ERD 문서
Phase 3: 도메인별 서비스 + 컨트롤러               ← 기능 명세서 + API 명세서
Phase 4: 동시성 제어 (분산락 + 테스트)             ← 동시성 제어 설계서
Phase 5: 캐싱 (Caffeine → Redis)                  ← 프로젝트 개요서 §6
Phase 6: 통합 테스트 + QA                          ← 사용자 시나리오</code></pre><hr>
<h3 id="phase-0-프로젝트-초기-세팅">Phase 0: 프로젝트 초기 세팅</h3>
<pre><code>Spring Boot 3.x + Java 17 프로젝트를 생성해줘.

build.gradle 의존성:
- spring-boot-starter-web
- spring-boot-starter-data-jpa
- spring-boot-starter-security
- spring-boot-starter-data-redis (Lettuce)
- spring-boot-starter-cache
- spring-boot-starter-validation
- spring-boot-starter-websocket (도전 기능용)
- com.github.ben-manes.caffeine:caffeine
- querydsl-jpa (jakarta classifier)
- jjwt-api, jjwt-impl, jjwt-jackson (io.jsonwebtoken 0.12.x)
- mysql-connector-j
- lombok
- spring-boot-starter-test
- h2 (test scope)

application.yml 설정:
- DB: jdbc:mysql://localhost:3306/ticketflow?serverTimezone=Asia/Seoul
- JPA: ddl-auto=validate, show-sql=true, time_zone=Asia/Seoul
- Redis: host=localhost, port=6379
- JWT: secret=${JWT_SECRET:local-dev-secret-key-minimum-256-bits-long}, expiration=3600000
- Server port: 8080

패키지 구조는 CLAUDE.md에 정의한 대로 만들어줘.</code></pre><p><strong>Docker Compose도 함께 요청합니다:</strong></p>
<pre><code>인프라 아키텍처 문서를 참고해서 docker-compose.yml을 만들어줘.

서비스 구성:
- mysql (MySQL 8.0, port 3306, DB명 ticketflow, root/root)
- redis (Redis 8.0, port 6379)
- 볼륨: mysql_data

app 서비스는 아직 추가하지 마. 로컬에서 IDE로 직접 실행할 거야.</code></pre><hr>
<h3 id="phase-1-공통-설정-security--exception--redis">Phase 1: 공통 설정 (Security + Exception + Redis)</h3>
<p>이 단계에서는 ADR 문서와 프로젝트 개요서를 활용합니다.</p>
<p><strong>JWT 인증:</strong></p>
<pre><code>ADR-001 결정 사항을 반영해서 JWT 인증을 구현해줘.

설계:
- AccessToken 단독 (RefreshToken 미구현, ADR-001)
- 알고리즘: HS256
- 만료: 1시간 (3600초)
- Payload: { userId, email, role }
- Spring Security 필터 체인에 JwtAuthenticationFilter 추가

구현할 클래스:
1. JwtTokenProvider
   - createToken(userId, email, role): String
   - validateToken(token): boolean
   - getUserIdFromToken(token): Long
   - getRoleFromToken(token): String

2. JwtAuthenticationFilter extends OncePerRequestFilter
   - Authorization 헤더에서 Bearer 토큰 추출
   - 유효하면 SecurityContext에 Authentication 설정
   - /api/auth/** 경로는 필터 스킵

3. SecurityConfig
   - CSRF 비활성화 (Stateless)
   - /api/auth/signup, /api/auth/login → permitAll
   - /api/admin/** → ADMIN 권한만
   - 나머지 → authenticated
   - SessionCreationPolicy.STATELESS

비고: 로그아웃은 클라이언트에서 토큰 삭제로 처리 (서버 블랙리스트 미구현)</code></pre><p><strong>공통 예외 처리:</strong></p>
<pre><code>API 명세서의 공통 에러 응답 형식을 참고해서 예외 처리를 구현해줘.

공통 에러 응답:
{
  &quot;status&quot;: 409,
  &quot;code&quot;: &quot;SEAT_ALREADY_HELD&quot;,
  &quot;message&quot;: &quot;이미 선점된 좌석입니다.&quot;,
  &quot;timestamp&quot;: &quot;2026-04-09T12:00:00&quot;
}

구현:
1. ErrorCode enum — 각 도메인별 에러 코드 정의
   - AUTH: EMAIL_DUPLICATED(409), AUTHENTICATION_FAILED(401), INVALID_CURRENT_PASSWORD(401)
   - EVENT: EVENT_NOT_FOUND(404), INVALID_EVENT_DATE(400)
   - SEAT: SEAT_ALREADY_HELD(409), SEAT_LOCK_FAILED(409), HOLD_EXPIRED(410)
   - BOOKING: BOOKING_NOT_FOUND(404), ALREADY_CANCELLED(400)
   - COUPON: COUPON_SOLD_OUT(409), COUPON_ALREADY_ISSUED(409), COUPON_EXPIRED(400)

2. CustomException extends RuntimeException — ErrorCode 포함
3. GlobalExceptionHandler (@RestControllerAdvice)
   - CustomException 처리
   - MethodArgumentNotValidException (Bean Validation 실패) 처리
   - 기타 예외 → 500 Internal Server Error</code></pre><hr>
<h3 id="phase-2-엔티티--repository">Phase 2: 엔티티 + Repository</h3>
<p>ERD 문서를 통째로 전달합니다. 이것이 가장 효과적입니다.</p>
<pre><code>아래 ERD를 보고 JPA 엔티티와 Repository를 만들어줘.

[ERD 문서(05.erd.md) 전체 내용 붙여넣기]

엔티티 규칙:
- @Getter만, @Setter 금지
- BaseEntity(createdAt, updatedAt)를 만들고 @MappedSuperclass로 상속
- ENUM은 @Enumerated(EnumType.STRING)
- 연관관계는 지연로딩(LAZY) 기본
- 양방향 매핑은 꼭 필요한 경우만 (단방향 우선)
- ACTIVE_BOOKING의 seat_id는 PK이자 UNIQUE

Repository:
- 각 엔티티별 JpaRepository 생성
- 커스텀 쿼리가 필요한 곳은 주석으로 &quot;TODO: QueryDSL&quot; 표시
- BookingRepository: findByUserIdAndStatus, findBySeatIdAndEventId
- ActiveBookingRepository: existsBySeatId, deleteBySeatId
- UserCouponRepository: existsByUserIdAndCouponId (중복 발급 방지)</code></pre><hr>
<h3 id="phase-3-도메인별-서비스--컨트롤러">Phase 3: 도메인별 서비스 + 컨트롤러</h3>
<p>여기서 기능 명세서(FN-ID)와 <strong>API 명세서</strong>를 함께 전달합니다. 한 번에 하나의 도메인씩 요청하는 것이 핵심입니다.</p>
<h3 id="3-1-회원인증-도메인">3-1. 회원/인증 도메인</h3>
<pre><code>기능 명세서의 FN-AUTH-01(회원가입), FN-AUTH-02(로그인)을 구현해줘.

[FN-AUTH-01, FN-AUTH-02 내용 붙여넣기]

API 명세:
- POST /api/auth/signup
  Request: { email, password, nickname }
  Response 201: { userId, email, nickname, createdAt }
  Error: 409 EMAIL_DUPLICATED, 400 VALIDATION_FAILED

- POST /api/auth/login
  Request: { email, password }
  Response 200: { accessToken, expiresIn, tokenType }
  Error: 401 AUTHENTICATION_FAILED

구현 순서:
1. SignupRequest, LoginRequest DTO (Bean Validation 포함)
2. AuthService — signup(), login()
3. AuthController — @PostMapping

유효성 규칙:
- email: @Email, 최대 100자
- password: 8자 이상, 영문+숫자 포함 (정규식 커스텀 검증)
- nickname: 2~20자, 특수문자 불가</code></pre><h3 id="3-2-이벤트-도메인">3-2. 이벤트 도메인</h3>
<pre><code>기능 명세서의 FN-EVT-01(이벤트 등록), FN-SRCH-01(이벤트 검색 v1)을 구현해줘.

[FN-EVT-01, FN-SRCH-01 내용 붙여넣기]

API 명세:
- POST /api/admin/events (🔐👑 ADMIN만)
  Request: { title, category, venueId, eventDate, saleStartAt, saleEndAt,
             roundNumber, description, thumbnailUrl, sections[] }
  sections: [{ sectionName, price, rowCount, colCount }]
  Response 201: { eventId, title, totalSeats, sectionsCreated }

  이벤트 등록 시 sections 배열 기반으로 SECTION + SEAT를 자동 생성해줘.
  SEAT의 row_name은 A~Z열, col_num은 1부터 colCount까지.
  예: rowCount=3, colCount=5 → A1~A5, B1~B5, C1~C5 총 15석

- GET /api/events?page=0&amp;size=20&amp;sort=eventDate,asc&amp;category=CONCERT (🔓)
  Response: Page&lt;EventSummary&gt; — 캐시 미적용 (v1)

- GET /api/events/{eventId} (🔓)
  Response: EventDetail (섹션별 잔여좌석 포함)

  잔여좌석 계산: 섹션의 전체 좌석 수 - ACTIVE_BOOKING에 존재하는 좌석 수
  (SEAT 테이블에 status 컬럼이 없으므로 ACTIVE_BOOKING JOIN으로 계산)</code></pre><h3 id="3-3-예매-도메인-핵심-동시성-주의">3-3. 예매 도메인 (핵심! 동시성 주의)</h3>
<pre><code>기능 명세서의 FN-SEAT-02(좌석 임시 점유), FN-BK-01(주문 생성),
FN-BK-02(결제 확정 웹훅)을 구현해줘.

⚠️ 이 기능은 동시성 제어가 핵심이야. 동시성 제어 설계서의 시나리오 A를 참고해줘.

[FN-SEAT-02, FN-BK-01, FN-BK-02 내용 붙여넣기]
[동시성 제어 설계서 §3 시나리오 A 내용 붙여넣기]

좌석 Hold 플로우 (프로젝트 개요서 §4-3 기준):
1. 사용자 → 좌석 선택 요청
2. 서버 → Redis 분산락 획득 (Lettuce SETNX, lock:seat:{eventId}:{seatId}, TTL 3초)
3. 서버 → ACTIVE_BOOKING에 해당 seat_id 존재 여부 확인
4. 서버 → Redis에 Hold 키 SET (hold:{eventId}:{seatId}, 값: userId, TTL 5분)
5. 서버 → holdToken 역조회 키 SET (holdToken:{uuid}, 값: &quot;{eventId}:{seatId}:{userId}&quot;, TTL 5분)
6. 서버 → user-hold-count:{userId} INCR (4 이상이면 거부)
7. 서버 → 분산락 해제 (UUID 검증 + Lua Script 원자적 삭제)
8. 200 OK { holdToken, expiresAt } 반환

API:
- POST /api/events/{eventId}/seats/{seatId}/hold (🔐⚠️)
  Response 200: { holdToken, expiresAt }
  Error: 409 SEAT_ALREADY_HELD, 409 SEAT_LOCK_FAILED, 429 HOLD_LIMIT_EXCEEDED

Redis 키 설계:
- 분산락: lock:seat:{eventId}:{seatId} (TTL 3초)
- Hold: hold:{eventId}:{seatId} → userId (TTL 300초)
- holdToken 역조회: holdToken:{uuid} → &quot;{eventId}:{seatId}:{userId}&quot; (TTL 300초)
- Hold 카운터: user-hold-count:{userId} (INCR/DECR, TTL 300초)

분산락 해제는 반드시 Lua Script로 원자적으로 처리해줘:
if redis.call(&quot;get&quot;, KEYS[1]) == ARGV[1] then
    return redis.call(&quot;del&quot;, KEYS[1])
else
    return 0
end</code></pre><h3 id="3-4-쿠폰-도메인">3-4. 쿠폰 도메인</h3>
<pre><code>기능 명세서의 FN-CPN-01(쿠폰 등록), FN-CPN-02(선착순 쿠폰 발급)을 구현해줘.

⚠️ 쿠폰 발급은 Redis DECR + Lua Script로 원자적 수량 차감! (분산락 미사용, ADR/ERD §2-8 참고)

[FN-CPN-01, FN-CPN-02 내용 붙여넣기]

API:
- POST /api/admin/coupons (🔐👑)
  쿠폰 등록 시 Redis에도 초기 재고 SET: coupon:stock:{couponId} = totalQuantity

- POST /api/coupons/{couponId}/issue (🔐⚠️)

쿠폰 발급 Lua Script:
  local stock = redis.call(&quot;DECR&quot;, KEYS[1])
  if stock &lt; 0 then
      redis.call(&quot;INCR&quot;, KEYS[1])  -- 복원
      return -1  -- 품절
  end
  return stock

발급 플로우:
1. USER_COUPON에 (userId, couponId) 중복 확인 → 이미 있으면 409
2. Lua Script로 Redis coupon:stock:{couponId} DECR
3. DECR 결과 &lt; 0 → INCR 복원 + 409 COUPON_SOLD_OUT
4. DECR 성공 → MySQL remaining_quantity UPDATE (-1)
5. USER_COUPON INSERT (status=ISSUED)
6. 201 { userCouponId, couponName, discountAmount }

비고: Redis 연결 실패 시 → MySQL SELECT ... FOR UPDATE 폴백 경로</code></pre><hr>
<h3 id="phase-4-동시성-제어-테스트">Phase 4: 동시성 제어 테스트</h3>
<p>동시성 제어 설계서를 활용하여 테스트를 작성합니다.</p>
<pre><code>동시성 제어 설계서를 참고해서 동시성 테스트를 작성해줘.

테스트 시나리오 1: 좌석 Hold Race Condition
- 100개 Thread가 동시에 같은 좌석(eventId=1, seatId=1)에 Hold 요청
- 기대 결과: 정확히 1명만 Hold 성공, 나머지 99명은 실패
- ExecutorService + CyclicBarrier 사용
- @SpringBootTest + 실제 Redis 연결 (Embedded Redis 또는 Testcontainers)

테스트 시나리오 2: 쿠폰 발급 Race Condition
- 잔여 수량 10개인 쿠폰에 500명이 동시 발급 요청
- 기대 결과: 정확히 10명만 발급 성공, remaining_quantity = 0
- Redis DECR + Lua Script 검증

테스트 시나리오 3: 예매 시 쿠폰 적용 (락 중첩)
- 좌석 Hold + 쿠폰 사용이 동시에 일어나는 상황
- 락 획득 순서(좌석→쿠폰→DB) 준수 확인

각 테스트에서 검증할 것:
- CountDownLatch로 모든 Thread 완료 대기
- 성공 횟수 == 기대값 (AtomicInteger 카운터)
- DB 최종 상태 정합성 (remaining_quantity, ACTIVE_BOOKING 수)</code></pre><hr>
<h3 id="phase-5-캐싱-caffeine-→-redis">Phase 5: 캐싱 (Caffeine → Redis)</h3>
<p>프로젝트 개요서 §6의 3단계 캐싱 진화 흐름을 따릅니다.</p>
<pre><code>프로젝트 개요서의 캐싱 3단계 진화 흐름을 구현해줘.

현재 단계: v1 (캐시 없음) → v2 (Caffeine 로컬 캐시 적용)

v2 Caffeine 적용 대상:
- 이벤트 검색 결과: TTL 5분, maximumSize 1000
- 이벤트 상세 조회: TTL 10분, maximumSize 500
- 인기 검색어: Redis ZSet 사용 (Caffeine 아님)

구현:
1. CacheConfig에 Caffeine CacheManager 설정
2. 검색 서비스에 @Cacheable(&quot;eventSearch&quot;) 적용
3. 이벤트 등록/수정/삭제 시 @CacheEvict 적용
4. v1/v2 엔드포인트는 동일 — 캐시 적용 여부만 다름

도전 기능 (Redis 캐시 전환) 준비:
- @ConditionalOnProperty(name = &quot;cache.provider&quot;, havingValue = &quot;caffeine&quot;)
- @ConditionalOnProperty(name = &quot;cache.provider&quot;, havingValue = &quot;redis&quot;)
- application.yml의 cache.provider 값만 바꾸면 Caffeine ↔ Redis 전환</code></pre><hr>
<h3 id="phase-6-인프라--cicd">Phase 6: 인프라 + CI/CD</h3>
<p>인프라 아키텍처 문서를 전달합니다.</p>
<pre><code>인프라 아키텍처 문서를 참고해서 다음을 만들어줘:

1. Dockerfile (Multi-stage build)
   - Stage 1: Gradle 빌드 (테스트 포함)
   - Stage 2: Eclipse Temurin JRE 17 기반 실행 이미지
   - 환경변수: DB_URL, REDIS_HOST, JWT_SECRET

2. GitHub Actions Workflow (.github/workflows/deploy.yml)
   - 트리거: main 브랜치 push
   - Job 1: test (./gradlew test)
   - Job 2: build-and-deploy
     - ECR 로그인 + 이미지 빌드/push (태그: $GITHUB_SHA)
     - EC2 SSH 접속 + 컨테이너 교체
   - GitHub Secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
     EC2_HOST, EC2_SSH_KEY, DB_URL, REDIS_HOST, JWT_SECRET

3. Health Check 엔드포인트
   - GET /actuator/health → 200 OK (ALB Health Check용)</code></pre><hr>
<h2 id="5-프롬프트-작성-핵심-원칙">5. 프롬프트 작성 핵심 원칙</h2>
<h3 id="원칙-1-sa-문서를-직접-첨부하거나-복붙하라">원칙 1: SA 문서를 직접 첨부하거나 복붙하라</h3>
<p><strong>나쁜 예시:</strong></p>
<pre><code>좌석 예매 기능 만들어줘</code></pre><p><strong>좋은 예시:</strong></p>
<pre><code>아래 기능 명세서(FN-BK-01)와 API 명세서를 보고 주문 생성 기능을 구현해줘.

[기능 명세서 FN-BK-01 전문 붙여넣기]
[API 명세서 POST /api/bookings 전문 붙여넣기]
[동시성 제어 설계서 시나리오 A 붙여넣기]</code></pre><blockquote>
<p>SA 문서가 곧 프롬프트입니다. 여러분이 공들여 작성한 기능 명세서, API 명세서, ERD가 가장 좋은 프롬프트 재료입니다.</p>
</blockquote>
<h3 id="원칙-2-한-번에-하나의-기능fn-id-단위로-요청하라">원칙 2: 한 번에 하나의 기능(FN-ID) 단위로 요청하라</h3>
<p><strong>나쁜 예시:</strong></p>
<pre><code>예매 도메인 전체 기능 다 만들어줘</code></pre><p><strong>좋은 예시:</strong></p>
<pre><code>FN-SEAT-02(좌석 임시 점유)만 먼저 구현해줘.
FN-BK-01(주문 생성)은 다음에 요청할게.</code></pre><h3 id="원칙-3-에러-케이스를-반드시-명시하라">원칙 3: 에러 케이스를 반드시 명시하라</h3>
<p>API 명세서에 이미 에러 케이스가 정리되어 있습니다. 반드시 함께 전달하세요.</p>
<pre><code>에러 처리:
- 409 SEAT_ALREADY_HELD: 이미 다른 사용자가 Hold 중
- 409 SEAT_LOCK_FAILED: 분산락 획득 실패 (Fail Fast)
- 429 HOLD_LIMIT_EXCEEDED: 1인 최대 Hold 4석 초과
- 410 HOLD_EXPIRED: Hold TTL 만료 후 결제 시도
- 404 EVENT_NOT_FOUND: 존재하지 않는 이벤트</code></pre><h3 id="원칙-4-왜-이렇게-설계했는지를-함께-전달하라">원칙 4: &quot;왜 이렇게 설계했는지&quot;를 함께 전달하라</h3>
<p>ADR 문서의 결정 근거를 함께 전달하면 AI가 설계 의도에 맞는 코드를 생성합니다.</p>
<pre><code>JWT는 AccessToken만 사용해 (RefreshToken 미구현).
이유: 3주 일정에서 블랙리스트 관리 복잡도가 과함.
로그아웃은 클라이언트 토큰 삭제로만 처리해.
(ADR-001 참고)</code></pre><h3 id="원칙-5-기존-코드와의-연결점을-알려줘라">원칙 5: 기존 코드와의 연결점을 알려줘라</h3>
<pre><code>좌석 Hold 서비스를 만들 때:
- 좌석 존재 확인: SeatRepository.findById() (이미 Phase 2에서 만든 것)
- 사용자 인증: SecurityContext에서 userId 추출 (Phase 1에서 만든 JwtAuthenticationFilter)
- Redis 작업: RedisTemplate&lt;String, String&gt; (Phase 1에서 설정한 RedisConfig)
- 에러 처리: CustomException + ErrorCode (Phase 1에서 만든 것)</code></pre><hr>
<h2 id="6-자주-쓰는-상황별-프롬프트">6. 자주 쓰는 상황별 프롬프트</h2>
<h3 id="상황-1-코드-리뷰-요청">상황 1: 코드 리뷰 요청</h3>
<pre><code>방금 만든 HoldService 코드를 리뷰해줘.
체크포인트:
- 분산락 해제가 finally 블록에서 보장되는지
- Lua Script로 본인 락만 삭제하는지 (UUID 검증)
- Redis 키 TTL이 설계서와 일치하는지
- 예외 발생 시 Hold 카운터(user-hold-count)가 정리되는지</code></pre><h3 id="상황-2-테스트-코드-작성">상황 2: 테스트 코드 작성</h3>
<pre><code>BookingService.confirmBooking()에 대한 테스트를 작성해줘.

Happy Path:
- Hold 유효 + 결제 성공 → BOOKING CONFIRMED + ACTIVE_BOOKING INSERT

Unhappy Path:
- Hold TTL 만료 후 웹훅 수신 → 예매 실패 처리
- ACTIVE_BOOKING INSERT 시 seat_id 중복 (DuplicateKeyException) → 롤백

동시성 테스트:
- 같은 좌석에 2명이 동시에 confirmBooking 호출 → 1명만 성공</code></pre><h3 id="상황-3-에러-디버깅">상황 3: 에러 디버깅</h3>
<pre><code>아래 에러가 발생했어:

org.springframework.dao.DataIntegrityViolationException:
could not execute statement; SQL [n/a]; constraint [PRIMARY]

상황: Hold 성공한 좌석의 결제 확정(웹훅 수신) 시 발생
코드 위치: BookingService.confirmBooking() → activeBookingRepository.save()

이건 ACTIVE_BOOKING의 seat_id PK 중복 때문인 것 같아.
2차 방어선이 작동한 건데, 왜 1차 방어선(Redis 분산락)을 통과했는지 분석해줘.</code></pre><h3 id="상황-4-시드-데이터-생성">상황 4: 시드 데이터 생성</h3>
<pre><code>프로젝트 개요서 §6-1의 데이터 볼륨 전략을 참고해서 시드 데이터를 생성해줘.

CommandLineRunner로 구현:
- Venue 1개 (KSPO돔)
- Event 5,000개 (카테고리별 균등 분배: CONCERT, MUSICAL, THEATER, SPORTS)
- 이벤트당 Section 3~6개 (랜덤)
- 섹션당 Seat 100~150석 (랜덤)
- Admin 계정 1개 (admin@ticketflow.io / admin1234)
- 테스트 유저 10명

프로파일: spring.profiles.active=seed 일 때만 실행</code></pre><hr>
<h2 id="7-팀원별-바이브코딩-가이드">7. 팀원별 바이브코딩 가이드</h2>
<p>SA 문서의 역할 분담(프로젝트 개요서 §9)에 따라 각 팀원이 요청해야 할 기능 목록입니다.</p>
<h3 id="팀원-a-회원-도메인">팀원 A (회원 도메인)</h3>
<pre><code>구현 순서:
1. FN-AUTH-01 회원가입 + FN-AUTH-02 로그인 (JWT)
2. FN-AUTH-03 내 정보 조회·수정
3. GET /api/users/me/bookings (내 예매 내역)
4. GET /api/users/me/coupons (내 쿠폰 목록)

함께 전달할 SA 문서: 기능 명세서 §1, API 명세서 §1~2, ERD USER 테이블</code></pre><h3 id="팀원-b-이벤트-도메인">팀원 B (이벤트 도메인)</h3>
<pre><code>구현 순서:
1. FN-EVT-01 이벤트 등록 (섹션+좌석 자동 생성)
2. FN-SRCH-01 검색 v1 (캐시 없음)
3. FN-SEAT-01 좌석 목록 조회 (상태 판단: ACTIVE_BOOKING + Redis)
4. FN-SRCH-02 검색 v2 (Caffeine 캐시)
5. FN-SRCH-03 인기 검색어 (Redis ZSet)

함께 전달할 SA 문서: 기능 명세서 §2~3, API 명세서 §3~4, ERD EVENT/SECTION/SEAT</code></pre><h3 id="팀원-c-예매-도메인">팀원 C (예매 도메인)</h3>
<pre><code>구현 순서:
1. FN-SEAT-02 좌석 Hold (Lettuce 분산락) ← 동시성 제어 핵심!
2. FN-BK-01 주문 생성 + Mock PG 결제 요청
3. FN-BK-02 웹훅 수신 + 결제 확정 (ACTIVE_BOOKING INSERT)
4. FN-BK-03 예매 취소 (ACTIVE_BOOKING DELETE + 쿠폰 복원)
5. 동시성 테스트 코드 (좌석 Hold 100 Thread 테스트)

함께 전달할 SA 문서: 기능 명세서 §4~5, API 명세서 §5~6, 동시성 제어 설계서 시나리오 A</code></pre><h3 id="팀원-d-프로모션-도메인">팀원 D (프로모션 도메인)</h3>
<pre><code>구현 순서:
1. FN-CPN-01 쿠폰 등록 (Admin)
2. FN-CPN-02 선착순 쿠폰 발급 (Redis DECR + Lua) ← 동시성 핵심!
3. FN-CPN-03 쿠폰 적용 (예매 플로우 내)
4. 동시성 테스트 (쿠폰 500명 동시 발급)
5. (도전) FN-CHAT-01~03 CS 채팅 WebSocket

함께 전달할 SA 문서: 기능 명세서 §6~7, API 명세서 §7~8, 동시성 제어 설계서 시나리오 B</code></pre><h3 id="팀원-e-인프라">팀원 E (인프라)</h3>
<pre><code>구현 순서:
1. Docker Compose (MySQL + Redis) ← Day 1~2 최우선!
2. Dockerfile (Multi-stage build)
3. GitHub Actions CI/CD 파이프라인
4. AWS 인프라 구성 (EC2, RDS, ElastiCache, ECR, ALB)
5. (도전) DB 인덱스 최적화 (EXPLAIN 분석)

함께 전달할 SA 문서: 인프라 아키텍처 문서 전체, ERD §3 인덱스 설계</code></pre><hr>
<h2 id="8-프롬프트-체크리스트">8. 프롬프트 체크리스트</h2>
<p>기능을 요청하기 전에 아래를 확인하세요.</p>
<pre><code>□ CLAUDE.md가 프로젝트 루트에 있는가?
□ 요청할 기능의 FN-ID를 확인했는가? (기능 명세서)
□ 해당 API의 Request/Response/Error를 정리했는가? (API 명세서)
□ 동시성 민감 기능인가? → 동시성 제어 설계서 해당 시나리오 첨부
□ 이 기능에서 사용하는 Redis 키와 TTL을 명시했는가?
□ 에러 케이스(4xx, 5xx)와 ErrorCode를 명시했는가?
□ 이전에 만든 클래스(엔티티, Repository, 서비스)를 참조하는가? → 연결점 명시
□ 테스트 코드도 함께 요청했는가?</code></pre><hr>
<h2 id="9-주의사항">9. 주의사항</h2>
<p><strong>하지 말 것:</strong></p>
<ul>
<li>&quot;예매 기능 전체 만들어줘&quot; → 한 번에 너무 많으면 품질이 떨어지고 SA 문서와 불일치 발생</li>
<li>SA 문서 없이 &quot;쿠폰 발급 만들어줘&quot; → AI가 임의 설계하면 ERD, API 명세서와 충돌</li>
<li>&quot;동시성은 알아서 처리해줘&quot; → Lettuce SETNX인지, Redisson인지, 비관적 락인지 명시 필수</li>
<li>SEAT 테이블에 status 컬럼 있다고 가정 → ERD v7.0에서 삭제됨! ACTIVE_BOOKING 기반</li>
</ul>
<p><strong>꼭 할 것:</strong></p>
<ul>
<li>SA 문서를 프롬프트의 근거 자료로 첨부하기</li>
<li>기능 명세서의 FN-ID 단위로 하나씩 요청하기</li>
<li>에러 케이스를 빠짐없이 전달하기</li>
<li>동시성 민감 기능은 Redis 키 설계 + 락 전략을 명시하기</li>
<li>코드 생성 후 동시성 테스트로 검증하기</li>
<li>ERD 변경 사항(SEAT.status 삭제, ACTIVE_BOOKING 신규 등)을 AI에게 반드시 알려주기</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] CH6 실전 - K사 서버 개발 과제]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH6-%EC%8B%A4%EC%A0%84-K%EC%82%AC-%EC%84%9C%EB%B2%84-%EA%B0%9C%EB%B0%9C-%EA%B3%BC%EC%A0%9C</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH6-%EC%8B%A4%EC%A0%84-K%EC%82%AC-%EC%84%9C%EB%B2%84-%EA%B0%9C%EB%B0%9C-%EA%B3%BC%EC%A0%9C</guid>
            <pubDate>Mon, 11 May 2026 01:05:05 GMT</pubDate>
            <description><![CDATA[<h2 id="🧭-들어가며">🧭 들어가며</h2>
<p>이번 내일배움캠프 Spring 3기 CH6 과제는 기존 과제들과 달리 요구사항이 명확하게 정의되어 있지 않았다.
채용 과제 형식으로, <strong>&quot;왜 이렇게 설계했는가&quot;</strong> 를 논리적으로 설명하는 것이 핵심이었다.</p>
<p>구현 요구사항은 아래 4가지였다.</p>
<ol>
<li>커피 메뉴 목록 조회 API</li>
<li>포인트 충전 API</li>
<li>커피 주문/결제 API</li>
<li>인기 메뉴 조회 API (최근 7일, 상위 3개)</li>
</ol>
<p>단순 CRUD가 아니라 <strong>다수 서버 환경, 동시성, 데이터 일관성</strong>을 모두 고려해야 하는 과제였다.</p>
<hr>
<h2 id="🗂️-설계-과정">🗂️ 설계 과정</h2>
<h3 id="erd-설계">ERD 설계</h3>
<p>코딩보다 설계를 먼저 했다. 테이블 구조를 잘못 잡으면 나중에 전체를 뒤집어야 하기 때문이다.</p>
<pre><code>users
- id        BIGINT PK
- point     INT

menus
- id        BIGINT PK
- name      VARCHAR
- price     INT

orders
- id           BIGINT PK
- user_id      BIGINT FK → users.id
- menu_id      BIGINT FK → menus.id
- amount       INT
- created_at   DATETIME</code></pre><p><strong>설계 포인트 1: orders.amount에 가격 스냅샷 저장</strong></p>
<p>처음에는 주문 시 menus.price를 그대로 참조하려 했다.
하지만 메뉴 가격이 나중에 변경되면 과거 주문 금액도 바뀌는 문제가 생긴다.
그래서 <strong>주문 시점의 가격을 amount 컬럼에 별도 저장</strong>하는 방식을 선택했다.</p>
<p><strong>설계 포인트 2: point_histories 테이블 생략</strong></p>
<p>포인트 충전/차감 이력 테이블도 고민했지만,
과제 범위에서는 현재 잔액만 요구하므로 users.point로 단순화했다.
실무라면 반드시 이력 테이블을 추가했을 것이다.</p>
<hr>
<h3 id="api-명세">API 명세</h3>
<table>
<thead>
<tr>
<th>Method</th>
<th>URL</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>GET</td>
<td>/api/menus</td>
<td>메뉴 목록 조회</td>
</tr>
<tr>
<td>PATCH</td>
<td>/api/users/{userId}/point</td>
<td>포인트 충전</td>
</tr>
<tr>
<td>POST</td>
<td>/api/orders</td>
<td>주문/결제</td>
</tr>
<tr>
<td>GET</td>
<td>/api/menus/popular</td>
<td>인기 메뉴 조회</td>
</tr>
</tbody></table>
<hr>
<h3 id="동시성-전략-선택">동시성 전략 선택</h3>
<p>이 과제에서 가장 많이 고민한 부분이 <strong>포인트 충전/차감 시 동시 요청 처리</strong>였다.</p>
<p>다수의 서버에서 동시에 같은 유저의 포인트를 읽고 수정하면,
각자 읽은 잔액을 기준으로 덮어씌워 실제보다 적게 반영되는 <strong>race condition</strong>이 발생할 수 있다.</p>
<p>세 가지 전략을 비교했다.</p>
<table>
<thead>
<tr>
<th>전략</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>비관적 락</td>
<td>충돌 자체를 막아 정합성 확실히 보장</td>
<td>대기 시간 발생</td>
</tr>
<tr>
<td>낙관적 락</td>
<td>충돌 없을 때 성능 좋음</td>
<td>충돌 시 재시도 로직 필요</td>
</tr>
<tr>
<td>Redis 분산락</td>
<td>다중 서버 완벽 대응</td>
<td>인프라 추가 필요</td>
</tr>
</tbody></table>
<p><strong>→ 비관적 락 선택</strong></p>
<p>포인트는 금융 데이터에 준하므로 성능보다 <strong>정합성</strong>이 최우선이라고 판단했다.
<code>SELECT ... FOR UPDATE</code>로 트랜잭션이 끝날 때까지 다른 요청을 대기시켜
동시 요청이 와도 순차적으로 처리되도록 보장했다.</p>
<p>세 가지 전략의 트레이드오프를 직접 분석하고 근거를 만드는 과정이
단순 구현보다 훨씬 많은 고민이 필요했다.</p>
<hr>
<h2 id="🏗️-프로젝트-구조">🏗️ 프로젝트 구조</h2>
<p>도메인 단위로 패키지를 구성했다. 기능이 추가되어도 관련 코드가 한 곳에 모여 있어 유지보수가 쉽다.</p>
<pre><code>src/main/java/
├── domain/
│   ├── user/
│   │   ├── controller/
│   │   ├── service/
│   │   ├── repository/
│   │   ├── entity/
│   │   └── dto/
│   ├── menu/
│   └── order/
└── global/
    ├── exception/
    └── response/</code></pre><hr>
<h2 id="💻-핵심-코드-설명">💻 핵심 코드 설명</h2>
<h3 id="1-비관적-락---userrepository">1. 비관적 락 - UserRepository</h3>
<pre><code class="language-java">// 비관적 락(PESSIMISTIC_WRITE) 적용
// 동시 요청 시 먼저 락을 획득한 트랜잭션이 끝날 때까지 나머지는 대기
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;SELECT u FROM User u WHERE u.id = :id&quot;)
Optional&lt;User&gt; findByIdWithLock(@Param(&quot;id&quot;) Long id);</code></pre>
<p>일반 <code>findById()</code>를 사용하면 동시 요청 시 동일한 잔액을 읽고 덮어씌우는 문제가 발생한다.
<code>@Lock(PESSIMISTIC_WRITE)</code>를 사용하면 DB 레벨에서 <code>SELECT ... FOR UPDATE</code>가 실행되어
트랜잭션이 끝날 때까지 다른 트랜잭션이 해당 행을 수정하지 못하게 막는다.</p>
<hr>
<h3 id="2-트랜잭션-처리---orderservice">2. 트랜잭션 처리 - OrderService</h3>
<pre><code class="language-java">@Transactional
public OrderResponseDto order(OrderRequestDto request) {
    // 비관적 락으로 유저 조회 - 동시 주문 시 포인트 차감 순차 처리 보장
    User user = userRepository.findByIdWithLock(request.getUserId())
            .orElseThrow(() -&gt; new CustomException(ErrorCode.USER_NOT_FOUND));

    Menu menu = menuRepository.findById(request.getMenuId())
            .orElseThrow(() -&gt; new CustomException(ErrorCode.MENU_NOT_FOUND));

    // 포인트 차감 - 잔액 부족 시 예외 발생 → 트랜잭션 롤백
    user.use(menu.getPrice());

    // 주문 생성 및 저장
    Order order = Order.of(user, menu);
    orderRepository.save(order);

    // 데이터 플랫폼 전송 (Mock)
    sendToDataPlatform(user.getId(), menu.getId(), order.getAmount());

    return OrderResponseDto.of(order);
}</code></pre>
<p>포인트 차감과 주문 생성을 <strong>하나의 트랜잭션</strong>으로 묶었다.
중간에 실패하면 전체가 롤백되어 &quot;포인트는 차감됐는데 주문은 생성 안 된&quot; 상황을 방지한다.</p>
<hr>
<h3 id="3-인기-메뉴-집계---orderrepository">3. 인기 메뉴 집계 - OrderRepository</h3>
<pre><code class="language-java">// 최근 7일간 메뉴별 주문 횟수 집계
@Query(&quot;SELECT o.menu.id, o.menu.name, COUNT(o) as orderCount &quot; +
       &quot;FROM Order o &quot; +
       &quot;WHERE o.createdAt &gt;= :since &quot; +
       &quot;GROUP BY o.menu.id, o.menu.name &quot; +
       &quot;ORDER BY orderCount DESC&quot;)
List&lt;Object[]&gt; findTopMenusSince(@Param(&quot;since&quot;) LocalDateTime since);</code></pre>
<p>캐싱 없이 매 요청마다 DB에서 직접 집계한다.
과제 요구사항에 <strong>&quot;메뉴별 주문 횟수가 정확해야 한다&quot;</strong> 고 명시되어 있어
정확성을 최우선으로 판단했다.</p>
<hr>
<h2 id="✅-api-테스트-postman">✅ API 테스트 (Postman)</h2>
<h3 id="메뉴-목록-조회">메뉴 목록 조회</h3>
<p><code>GET /api/menus</code> 요청 시 DB에 삽입한 메뉴 3개가 정상 응답됐다.</p>
<pre><code class="language-json">{
  &quot;success&quot;: true,
  &quot;data&quot;: [
    { &quot;id&quot;: 1, &quot;name&quot;: &quot;아메리카노&quot;, &quot;price&quot;: 2000 },
    { &quot;id&quot;: 2, &quot;name&quot;: &quot;카페라떼&quot;, &quot;price&quot;: 3000 },
    { &quot;id&quot;: 3, &quot;name&quot;: &quot;카푸치노&quot;, &quot;price&quot;: 3500 }
  ],
  &quot;message&quot;: null
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/f9ea9b1e-6c0d-43d3-902c-0b59113fe5f5/image.png" alt=""></p>
<hr>
<h3 id="포인트-충전">포인트 충전</h3>
<p><code>PATCH /api/users/1/point</code> 요청으로 10,000포인트 충전 성공.</p>
<pre><code class="language-json">{
  &quot;success&quot;: true,
  &quot;data&quot;: { &quot;userId&quot;: 1, &quot;point&quot;: 10000 },
  &quot;message&quot;: null
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/3aeefb9c-2d84-4898-bfdc-53a666487d1a/image.png" alt=""></p>
<hr>
<h3 id="주문결제">주문/결제</h3>
<p><code>POST /api/orders</code> 요청으로 아메리카노 주문 성공.
IntelliJ 콘솔에서 데이터 플랫폼 전송 로그도 확인했다.</p>
<pre><code>[DataPlatform] 주문 전송 - userId: 1, menuId: 1, amount: 2000</code></pre><pre><code class="language-json">{
  &quot;success&quot;: true,
  &quot;data&quot;: {
    &quot;orderId&quot;: 1,
    &quot;userId&quot;: 1,
    &quot;menuId&quot;: 1,
    &quot;menuName&quot;: &quot;아메리카노&quot;,
    &quot;amount&quot;: 2000
  },
  &quot;message&quot;: null
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/3acb4b5f-b46d-4157-a5fb-613bb5f78371/image.png" alt="">
<img src="https://velog.velcdn.com/images/jiiim_ni/post/9f94453a-a309-458f-93b6-39ab38ff60fe/image.png" alt=""></p>
<hr>
<h3 id="인기-메뉴-조회">인기 메뉴 조회</h3>
<p><code>GET /api/menus/popular</code> 요청으로 7일 내 주문 1건인 아메리카노 정상 응답.</p>
<pre><code class="language-json">{
  &quot;success&quot;: true,
  &quot;data&quot;: [
    { &quot;menuId&quot;: 1, &quot;menuName&quot;: &quot;아메리카노&quot;, &quot;orderCount&quot;: 1 }
  ],
  &quot;message&quot;: null
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/1d959822-df9e-442a-8dde-3093e3bab57b/image.png" alt=""></p>
<hr>
<h2 id="🔥-동시성-테스트">🔥 동시성 테스트</h2>
<p>비관적 락이 실제로 동작하는지 검증하기 위해 동시성 테스트를 작성했다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;동시에 10번 1000원 충전 시 최종 잔액은 10000원이어야 한다&quot;)
void concurrentChargeTest() throws InterruptedException {
    int threadCount = 10;
    int chargeAmount = 1000;

    // 여러 스레드가 동시에 작업할 수 있는 스레드 풀 생성
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);

    // 모든 스레드가 동시에 시작/종료될 때까지 기다리는 동기화 도구
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i &lt; threadCount; i++) {
        executorService.submit(() -&gt; {
            try {
                userService.charge(userId, new ChargeRequestDto(chargeAmount));
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();
    executorService.shutdown();

    User user = userRepository.findById(userId).orElseThrow();

    // 비관적 락 없이는 race condition으로 10000원보다 적게 충전될 수 있음
    assertThat(user.getPoint()).isEqualTo(10000);
}</code></pre>
<p><strong>테스트 결과: 1테스트 통과 ✅</strong></p>
<p>10개 스레드가 동시에 충전을 시도했지만 최종 잔액이 정확히 10,000원으로 확인됐다.
비관적 락이 정상적으로 동작해 race condition이 발생하지 않음을 증명했다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/7986d807-e5b0-40e9-81af-bb1ffa004889/image.png" alt=""></p>
<hr>
<h2 id="🛠️-트러블슈팅">🛠️ 트러블슈팅</h2>
<h3 id="1-서버-재시작마다-데이터가-사라지는-문제">1. 서버 재시작마다 데이터가 사라지는 문제</h3>
<p><strong>문제:</strong> <code>ddl-auto: create</code> 설정으로 인해 앱 실행 시마다 테이블이 새로 생성되어
직접 INSERT한 테스트 데이터가 전부 날아갔다.</p>
<p><code>ddl-auto</code> 설정 하나가 전체 테스트 흐름을 망가뜨릴 수 있다는 것을 이 과정에서 배웠다.
사소해 보이는 설정도 꼼꼼히 확인하는 습관이 중요하다.</p>
<p><strong>해결:</strong> <code>ddl-auto: update</code>로 변경. 테이블 구조만 변경하고 기존 데이터는 유지한다.</p>
<pre><code class="language-yaml"># 변경 전
ddl-auto: create

# 변경 후
ddl-auto: update</code></pre>
<hr>
<h3 id="2-mysql-접속-불가">2. MySQL 접속 불가</h3>
<p><strong>문제:</strong> <code>ERROR 2002 (HY000): Can&#39;t connect to local MySQL server through socket &#39;/tmp/mysql.sock&#39;</code></p>
<p>MySQL이 설치되지 않아 발생한 오류였다.</p>
<p><strong>해결:</strong> Homebrew로 MySQL 설치 및 서비스 시작.</p>
<pre><code class="language-bash">brew install mysql
brew services start mysql</code></pre>
<hr>
<h3 id="3-api-테스트-시-404-응답">3. API 테스트 시 404 응답</h3>
<p><strong>문제:</strong> 인기 메뉴 조회 API를 추가했는데 Postman에서 계속 404가 떴다.</p>
<p><strong>원인:</strong> 코드 수정 후 서버를 재시작하지 않아서 변경 사항이 반영되지 않았다.</p>
<p><strong>해결:</strong> IntelliJ에서 서버 재시작 후 정상 응답 확인.</p>
<hr>
<h2 id="💡-느낀-점">💡 느낀 점</h2>
<p>이번 과제를 통해 단순히 기능을 구현하는 것을 넘어서
<strong>&quot;왜 이 선택을 했는가&quot;</strong> 를 설명할 수 있어야 한다는 것을 배웠다.</p>
<p>특히 가장 어려웠던 부분은 <strong>동시성 전략 선택</strong>이었다.
비관적 락, 낙관적 락, Redis 분산락 세 가지 중 어떤 것을 선택할지
각각의 장단점과 트레이드오프를 직접 분석하고 근거를 만드는 과정이
단순 구현보다 훨씬 많은 고민을 요구했다.</p>
<p>또한 <code>ddl-auto</code> 설정 실수처럼 사소해 보이는 설정 하나가
전체 테스트 흐름을 망가뜨릴 수 있다는 것도 직접 경험하며 배웠다.</p>
<p>채용 과제 특성상 정답이 없기 때문에 설계의 논리와 트레이드오프를
명확히 설명하는 연습이 중요하다는 것을 느꼈고,
앞으로 코드를 짤 때도 <strong>&quot;왜 이렇게 짰는가&quot;</strong> 를 항상 생각하는 습관을 들여야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] CH 5 플러스 Spring 과제]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH-5-%ED%94%8C%EB%9F%AC%EC%8A%A4-Spring-%EA%B3%BC%EC%A0%9C</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH-5-%ED%94%8C%EB%9F%AC%EC%8A%A4-Spring-%EA%B3%BC%EC%A0%9C</guid>
            <pubDate>Tue, 07 Apr 2026 02:14:46 GMT</pubDate>
            <description><![CDATA[<h1 id="1-todo-저장-시-readonly-트랜잭션-오류-해결">1. Todo 저장 시 readOnly 트랜잭션 오류 해결</h1>
<h2 id="문제-상황">문제 상황</h2>
<p>할 일 저장 API를 호출했을 때 아래와 같은 에러가 발생했다.</p>
<p><code>Connection is read-only. Queries leading to data modification are not allowed</code></p>
<p>즉, 저장 API인데 실제로는 데이터베이스가 “현재 읽기 전용 상태라서 insert를 허용할 수 없다”고 판단한 것이다.</p>
<p>여기서 중요한 건, 단순히 저장이 안 된다는 사실보다 <strong>왜 저장 메서드가 읽기 전용 트랜잭션 안에서 실행되고 있는지</strong>를 찾는 것이었다.</p>
<hr>
<h2 id="원인-분석">원인 분석</h2>
<p><code>TodoService</code> 클래스에는 다음과 같이 클래스 레벨에 <code>@Transactional(readOnly = true)</code> 가 선언되어 있었다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TodoService {
    ...
}</code></pre>
<p>이 설정은 클래스 내부의 모든 메서드를 기본적으로 “조회 전용 트랜잭션”으로 동작하게 만든다.</p>
<p>그런데 <code>saveTodo()</code> 메서드는 실제로 <code>todoRepository.save()</code> 를 호출하는 <strong>쓰기 작업</strong>이다.</p>
<p>즉 구조는 이랬다.</p>
<ul>
<li>클래스 전체는 조회 전용</li>
<li>저장 메서드는 그 영향을 그대로 받음</li>
<li>저장 시점에 insert 발생</li>
<li>DB가 쓰기 작업 차단</li>
</ul>
<hr>
<h2 id="왜-이런-설정이-문제였을까">왜 이런 설정이 문제였을까</h2>
<p><code>readOnly = true</code> 는 조회 전용 작업에 적합하다.
스프링과 JPA는 이 설정을 보고 “이번 트랜잭션은 변경이 아니라 조회가 목적”이라고 해석한다.</p>
<p>이 설정의 장점은 다음과 같다.</p>
<ul>
<li>조회 의도를 명확히 드러낼 수 있다</li>
<li>불필요한 변경 감지를 줄일 수 있다</li>
<li>실수로 쓰기 작업이 섞이는 것을 방지하는 데 도움이 된다</li>
</ul>
<p>하지만 저장/수정/삭제가 필요한 메서드에 그대로 적용되면, 오히려 지금처럼 오류의 원인이 된다.</p>
<p>즉 이 문제는 단순한 문법 문제가 아니라, <strong>조회 트랜잭션과 쓰기 트랜잭션의 목적이 다르다는 점을 코드로 구분하지 못했기 때문에 발생한 문제</strong>였다.</p>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<p>클래스 전체의 조회 전용 트랜잭션은 유지하되, 저장 메서드인 <code>saveTodo()</code> 에만 일반 <code>@Transactional</code> 을 다시 선언해 쓰기 트랜잭션으로 오버라이드했다.</p>
<pre><code class="language-java">@Transactional
public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) {
    User user = User.fromAuthUser(authUser);

    String weather = weatherClient.getTodayWeather();

    Todo newTodo = new Todo(
            todoSaveRequest.getTitle(),
            todoSaveRequest.getContents(),
            weather,
            user
    );
    Todo savedTodo = todoRepository.save(newTodo);

    return new TodoSaveResponse(
            savedTodo.getId(),
            savedTodo.getTitle(),
            savedTodo.getContents(),
            weather,
            new UserResponse(user.getId(), user.getEmail())
    );
}</code></pre>
<hr>
<h2 id="테스트">테스트</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/f92ca2b4-41b9-4540-92ff-54ea2e8a9b51/image.png" alt=""></p>
<p><strong>POST /todos</strong></p>
<p>요청 예시:</p>
<pre><code class="language-json">{
  &quot;title&quot;: &quot;스프링 과제&quot;,
  &quot;contents&quot;: &quot;Transactional 오류 해결&quot;
}</code></pre>
<p>확인 포인트는 다음과 같았다.</p>
<ul>
<li>저장 요청 시 더 이상 read-only 관련 예외가 발생하지 않는지</li>
<li>정상적으로 Todo가 저장되는지</li>
<li>응답에 id, title, contents, weather, user 정보가 포함되는지</li>
</ul>
<hr>
<h1 id="3-weather--수정일-기간-기반-todo-검색-조건-추가">3. weather / 수정일 기간 기반 Todo 검색 조건 추가</h1>
<h2 id="문제-상황-1">문제 상황</h2>
<p>기존 Todo 목록 조회는 단순히 전체 목록을 수정일 기준 내림차순으로 조회하는 구조였다.
하지만 요구사항은 다음과 같았다.</p>
<ul>
<li>weather 조건으로 검색 가능해야 한다</li>
<li>수정일 기준 기간 검색이 가능해야 한다</li>
<li>각 조건은 있을 수도 있고 없을 수도 있다</li>
</ul>
<p>즉, 검색 조건이 고정된 것이 아니라 <strong>선택적으로 적용되는 동적 검색</strong> 구조가 필요했다.</p>
<hr>
<h2 id="기존-한계">기존 한계</h2>
<p>기존 컨트롤러와 서비스는 page, size만 받아 전체 목록을 조회하고 있었다.</p>
<pre><code class="language-java">@GetMapping(&quot;/todos&quot;)
public ResponseEntity&lt;Page&lt;TodoResponse&gt;&gt; getTodos(
        @RequestParam(defaultValue = &quot;1&quot;) int page,
        @RequestParam(defaultValue = &quot;10&quot;) int size
) {
    return ResponseEntity.ok(todoService.getTodos(page, size));
}</code></pre>
<p>이 구조로는 사용자가 다음과 같은 요청을 보내도 처리할 수 없었다.</p>
<ul>
<li>특정 날씨만 검색</li>
<li>특정 날짜 이후만 검색</li>
<li>특정 기간만 검색</li>
<li>weather와 기간을 동시에 검색</li>
</ul>
<hr>
<h2 id="해결-방향">해결 방향</h2>
<p>이번 문제의 핵심은 “조건이 없으면 무시하고, 값이 있으면 필터로 적용”하는 것이다.
그래서 컨트롤러에서 먼저 optional 파라미터를 받도록 바꾸고, Repository에서는 JPQL의 null 조건 패턴을 사용했다.</p>
<hr>
<h2 id="컨트롤러-수정">컨트롤러 수정</h2>
<pre><code class="language-java">@GetMapping(&quot;/todos&quot;)
public ResponseEntity&lt;Page&lt;TodoResponse&gt;&gt; getTodos(
        @RequestParam(defaultValue = &quot;1&quot;) int page,
        @RequestParam(defaultValue = &quot;10&quot;) int size,
        @RequestParam(required = false) String weather,
        @RequestParam(required = false)
        @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
        LocalDateTime modifiedAtFrom,
        @RequestParam(required = false)
        @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
        LocalDateTime modifiedAtTo
) {
    return ResponseEntity.ok(todoService.getTodos(page, size, weather, modifiedAtFrom, modifiedAtTo));
}</code></pre>
<p>여기서 <code>required = false</code> 가 핵심이다.
이렇게 해야 사용자가 값을 보내지 않아도 null로 받을 수 있고, “조건이 없는 검색”도 안전하게 처리할 수 있다.</p>
<hr>
<h2 id="repository-jpql">Repository JPQL</h2>
<pre><code class="language-java">@Query(value = &quot;SELECT t FROM Todo t &quot; +
        &quot;LEFT JOIN FETCH t.user &quot; +
        &quot;WHERE (:weather IS NULL OR t.weather = :weather) &quot; +
        &quot;AND (:modifiedAtFrom IS NULL OR t.modifiedAt &gt;= :modifiedAtFrom) &quot; +
        &quot;AND (:modifiedAtTo IS NULL OR t.modifiedAt &lt;= :modifiedAtTo) &quot; +
        &quot;ORDER BY t.modifiedAt DESC&quot;,
        countQuery = &quot;SELECT COUNT(t) FROM Todo t &quot; +
                &quot;WHERE (:weather IS NULL OR t.weather = :weather) &quot; +
                &quot;AND (:modifiedAtFrom IS NULL OR t.modifiedAt &gt;= :modifiedAtFrom) &quot; +
                &quot;AND (:modifiedAtTo IS NULL OR t.modifiedAt &lt;= :modifiedAtTo)&quot;)
Page&lt;Todo&gt; searchTodos(
        @Param(&quot;weather&quot;) String weather,
        @Param(&quot;modifiedAtFrom&quot;) LocalDateTime modifiedAtFrom,
        @Param(&quot;modifiedAtTo&quot;) LocalDateTime modifiedAtTo,
        Pageable pageable
);</code></pre>
<hr>
<h2 id="이-jpql의-핵심-원리">이 JPQL의 핵심 원리</h2>
<p>예를 들어 이 조건:</p>
<pre><code class="language-java">(:weather IS NULL OR t.weather = :weather)</code></pre>
<p>은 이렇게 해석된다.</p>
<ul>
<li>weather가 null이면 → 이 조건은 항상 true</li>
<li>weather 값이 있으면 → 해당 날씨만 조회</li>
</ul>
<p>즉 <strong>값이 없으면 조건을 무시하는 패턴</strong>이다.</p>
<p>이 방식은 날짜 조건에도 똑같이 적용된다.</p>
<hr>
<h2 id="테스트-1">테스트</h2>
<p><strong>GET /todos?page=1&amp;size=10</strong></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/3a0fe001-bd74-4c8e-8279-681786d55e27/image.png" alt=""></p>
<p><strong>GET /todos?page=1&amp;size=10&amp;weather=Sunny</strong></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/2f1ba9a9-db68-4019-b4fd-546a7e6f6c56/image.png" alt=""></p>
<p><strong>GET /todos?page=1&amp;size=10&amp;modifiedAtFrom=2026-04-01T00:00:00&amp;modifiedAtTo=2026-04-06T23:59:59</strong></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/4d736680-b5a6-4873-a51f-257058d8dfea/image.png" alt=""></p>
<p><strong>GET /todos?page=1&amp;size=10&amp;weather=Sunny&amp;modifiedAtFrom=2026-04-01T00:00:00&amp;modifiedAtTo=2026-04-06T23:59:59</strong></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/fd601b84-b4b8-453e-8e04-5e8ce480c4cb/image.png" alt=""></p>
<hr>
<h2 id="배운-점">배운 점</h2>
<p>동적 검색은 “조건이 많다”가 핵심이 아니라,
<strong>조건이 없을 때도 자연스럽게 동작해야 한다</strong> 가 핵심이라는 점을 배웠다.</p>
<p>그리고 JPQL에서도 null 조건 패턴을 적절히 활용하면 QueryDSL 없이도 꽤 유연한 검색을 구현할 수 있다는 걸 확인했다.</p>
<hr>
<h1 id="6-todo-생성-시-작성자를-담당자로-자동-등록하기---jpa-cascade">6. Todo 생성 시 작성자를 담당자로 자동 등록하기 - JPA Cascade</h1>
<h2 id="문제-상황-2">문제 상황</h2>
<p>Todo를 새로 생성할 때, 그 Todo를 만든 사용자가 담당자로 자동 등록되어야 했다.</p>
<p>코드를 확인해보니 <code>Todo</code> 생성자 안에는 이미 다음과 같은 코드가 있었다.</p>
<pre><code class="language-java">this.managers.add(new Manager(user, this));</code></pre>
<p>즉, 설계 의도 자체는 이미 존재했다.
문제는 <strong>리스트에 추가하는 것과 DB에 저장되는 것은 다르다</strong>는 점이었다.</p>
<hr>
<h2 id="원인-분석-1">원인 분석</h2>
<p>JPA에서 연관 객체를 컬렉션에 넣는다고 해서 자동으로 DB에 저장되지는 않는다.</p>
<p>다시 말해,</p>
<ul>
<li>메모리 상에서 관계를 연결하는 것</li>
<li>실제로 DB에 insert 되는 것</li>
</ul>
<p>은 다른 단계다.</p>
<p>이번 구조에서는:</p>
<ul>
<li>부모: <code>Todo</code></li>
<li>자식: <code>Manager</code></li>
</ul>
<p>였고, Todo를 저장할 때 Manager도 함께 저장되게 하려면 <strong>영속성 전이(cascade)</strong> 가 필요했다.</p>
<hr>
<h2 id="해결-방법-1">해결 방법</h2>
<p><code>Todo</code> 엔티티의 <code>managers</code> 연관관계에 <code>CascadeType.PERSIST</code> 를 추가했다.</p>
<pre><code class="language-java">@OneToMany(mappedBy = &quot;todo&quot;, cascade = CascadeType.PERSIST)
private List&lt;Manager&gt; managers = new ArrayList&lt;&gt;();</code></pre>
<p>그리고 생성자에서는 작성자를 담당자로 추가하도록 유지했다.</p>
<pre><code class="language-java">public Todo(String title, String contents, String weather, User user) {
    this.title = title;
    this.contents = contents;
    this.weather = weather;
    this.user = user;
    this.managers.add(new Manager(user, this));
}</code></pre>
<hr>
<h2 id="왜-persist를-선택했는가">왜 <code>PERSIST</code>를 선택했는가</h2>
<p>이번 요구사항의 핵심은 “Todo 생성 시 함께 저장”이다.
즉 저장 시점의 전이가 필요했기 때문에 <code>CascadeType.PERSIST</code> 가 가장 의도에 맞았다.</p>
<p><code>ALL</code> 도 사용할 수는 있지만, 그 경우 저장/수정/삭제까지 모두 전이되어 범위가 넓어진다.
이번 요구사항은 “자동 등록”이라는 생성 시점의 동작이 중심이었기 때문에 <code>PERSIST</code> 가 더 명확한 선택이었다.</p>
<hr>
<h2 id="테스트-2">테스트</h2>
<p><strong>POST /todos</strong></p>
<p>그 다음</p>
<p><strong>GET /todos/{todoId}/managers</strong></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/ba2db965-2c1e-45ec-9662-cdb28e8adf0e/image.png" alt=""></p>
<p>확인 포인트:</p>
<ul>
<li>Todo 생성 직후 managers 목록에 작성자가 포함되어 있는지</li>
<li>별도의 담당자 등록 API를 호출하지 않았는데도 자동 등록이 되었는지</li>
</ul>
<hr>
<h1 id="7-댓글-조회-시-n1-문제-해결">7. 댓글 조회 시 N+1 문제 해결</h1>
<h2 id="문제-상황-3">문제 상황</h2>
<p><code>GET /todos/{todoId}/comments</code> 호출 시 N+1 문제가 발생하고 있었다.</p>
<p>댓글 목록 조회 자체는 한 번에 수행되지만, DTO를 만드는 과정에서 각 댓글 작성자(User)를 꺼낼 때마다 추가 쿼리가 발생하는 구조였다.</p>
<hr>
<h2 id="n1이-발생하는-이유">N+1이 발생하는 이유</h2>
<p><code>Comment</code> 엔티티는 다음과 같이 <code>user</code> 를 LAZY 로딩으로 가지고 있었다.</p>
<pre><code class="language-java">@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = &quot;user_id&quot;, nullable = false)
private User user;</code></pre>
<p>그리고 서비스에서는 다음과 같이 반복문에서 <code>comment.getUser()</code> 를 호출하고 있었다.</p>
<pre><code class="language-java">for (Comment comment : commentList) {
    User user = comment.getUser();
    ...
}</code></pre>
<p>이 경우 댓글 목록을 처음 조회할 때는 <code>Comment</code> 만 가져오고,
이후 각 댓글의 <code>user</code> 가 필요해질 때마다 추가 쿼리가 발생한다.</p>
<p>즉 댓글이 10개면:</p>
<ul>
<li>댓글 조회 1번</li>
<li>사용자 조회 10번</li>
</ul>
<p>총 11번 쿼리가 나갈 수 있다.
이게 전형적인 N+1 문제다.</p>
<hr>
<h2 id="해결-방법-2">해결 방법</h2>
<p>Repository의 조회 쿼리를 단순 <code>JOIN</code> 에서 <code>JOIN FETCH</code> 로 변경했다.</p>
<p>기존:</p>
<pre><code class="language-java">@Query(&quot;SELECT c FROM Comment c JOIN c.user WHERE c.todo.id = :todoId&quot;)</code></pre>
<p>수정 후:</p>
<pre><code class="language-java">@Query(&quot;SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId&quot;)
List&lt;Comment&gt; findByTodoIdWithUser(@Param(&quot;todoId&quot;) Long todoId);</code></pre>
<hr>
<h2 id="왜-join-fetch가-핵심인가">왜 <code>JOIN FETCH</code>가 핵심인가</h2>
<p>단순 <code>JOIN</code> 은 SQL 레벨에서 조인은 일어나더라도, JPA가 연관 엔티티를 즉시 로딩된 객체로 다 채워준다는 보장이 없다.</p>
<p>반면 <code>FETCH JOIN</code> 은 <strong>연관 엔티티까지 한 번에 가져오라</strong>는 의도를 명확히 전달한다.</p>
<p>즉 이 변경 이후에는 서비스에서 <code>comment.getUser()</code> 를 호출하더라도 추가 쿼리가 발생하지 않는다.</p>
<hr>
<h2 id="테스트-3">테스트</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/ba52e931-7c8b-4819-bb20-15c034ec69c9/image.png" alt=""></p>
<p><strong>GET /todos/{todoId}/comments</strong></p>
<p>이 단계에서는 응답 자체뿐 아니라, 콘솔 SQL 로그도 함께 확인했다.</p>
<p>확인 포인트:</p>
<ul>
<li>댓글과 작성자 정보를 함께 가져오는 쿼리 1번만 실행되는지</li>
<li>댓글 수만큼 user 조회 쿼리가 추가로 발생하지 않는지</li>
</ul>
<hr>
<h2 id="배운-점-1">배운 점</h2>
<p>이번 문제는 “성능 최적화는 나중에 하는 것”이 아니라,
<strong>엔티티 연관관계를 어떻게 조회하느냐 자체가 성능과 직결된다</strong>는 걸 체감하게 해줬다.</p>
<p>LAZY 로딩은 기본 전략으로 매우 유용하지만,
“어차피 지금 반드시 함께 사용할 연관 객체”라면 fetch join을 통해 한 번에 가져오는 것이 훨씬 효율적이다.</p>
<hr>
<h1 id="8-jpql-기반-todo-단건-조회를-querydsl로-전환">8. JPQL 기반 Todo 단건 조회를 QueryDSL로 전환</h1>
<h2 id="문제-상황-4">문제 상황</h2>
<p>기존 <code>findByIdWithUser</code> 는 문자열 JPQL 기반으로 작성되어 있었다.</p>
<pre><code class="language-java">@Query(&quot;SELECT t FROM Todo t LEFT JOIN FETCH t.user WHERE t.id = :todoId&quot;)
Optional&lt;Todo&gt; findByIdWithUser(@Param(&quot;todoId&quot;) Long todoId);</code></pre>
<p>이 방식도 동작은 하지만, 문자열 기반 쿼리는 다음과 같은 한계가 있다.</p>
<ul>
<li>필드명 변경 시 컴파일 단계에서 잡히지 않는다</li>
<li>오타가 있어도 런타임에서야 문제가 드러난다</li>
<li>쿼리가 복잡해질수록 유지보수성이 떨어진다</li>
</ul>
<p>과제에서는 이를 QueryDSL로 바꾸고, 동시에 <code>user</code> 를 함께 조회해 N+1 성격의 문제도 막도록 요구했다.</p>
<hr>
<h2 id="querydsl-적용-과정">QueryDSL 적용 과정</h2>
<p>먼저 <code>build.gradle</code> 에 QueryDSL 관련 의존성을 추가하고,
<code>JPAQueryFactory</code> 를 빈으로 등록했다.</p>
<p>그리고 <code>TodoRepositoryCustom</code> 과 <code>TodoRepositoryImpl</code> 구조를 만들어 기존 JPQL 메서드를 QueryDSL 구현체로 옮겼다.</p>
<hr>
<h2 id="querydsl-구현-코드">QueryDSL 구현 코드</h2>
<pre><code class="language-java">@Override
public Optional&lt;Todo&gt; findByIdWithUser(Long todoId) {
    QTodo todo = QTodo.todo;
    QUser user = QUser.user;

    Todo result = jpaQueryFactory
            .selectFrom(todo)
            .leftJoin(todo.user, user).fetchJoin()
            .where(todo.id.eq(todoId))
            .fetchOne();

    return Optional.ofNullable(result);
}</code></pre>
<hr>
<h2 id="핵심-포인트">핵심 포인트</h2>
<p>여기서 중요한 건 QueryDSL로 바꾼 것 자체보다도,
여전히 <code>fetchJoin()</code> 을 유지했다는 점이다.</p>
<p>즉 이번 단계는 단순히 문법을 JPQL에서 QueryDSL로 옮긴 것이 아니라,</p>
<ul>
<li>타입 안전한 쿼리 작성</li>
<li>연관 엔티티 한 번에 조회</li>
</ul>
<p>이 두 가지를 동시에 만족하도록 구성한 작업이었다.</p>
<hr>
<h2 id="querydsl-적용-중-겪은-트러블슈팅">QueryDSL 적용 중 겪은 트러블슈팅</h2>
<p>처음에는 <code>QTodo</code>, <code>QUser</code> 를 찾지 못하는 문제가 발생했다.
원인은 QueryDSL Q클래스가 자동 생성되지 않았기 때문이었다.</p>
<p>이 문제를 해결하기 위해 다음을 확인했다.</p>
<ul>
<li>QueryDSL 의존성 추가 여부</li>
<li>annotation processor 설정</li>
<li><code>compileJava</code> 수행 여부</li>
<li>generated source 경로 인식 여부</li>
</ul>
<p>이 과정을 통해 QueryDSL은 단순히 코드만 작성한다고 되는 게 아니라,
<strong>빌드 설정과 코드 생성까지 함께 맞아야 동작하는 구조</strong>라는 점을 체감했다.</p>
<hr>
<h2 id="테스트-4">테스트</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/1fb01cc1-0480-4e29-8661-8947ad55eb80/image.png" alt=""></p>
<p><strong>GET /todos/{todoId}</strong></p>
<p>확인 포인트:</p>
<ul>
<li>Todo 단건 조회가 정상 동작하는지</li>
<li>연관된 user 정보가 함께 응답되는지</li>
<li>QueryDSL 적용 후 기존 기능이 깨지지 않았는지</li>
</ul>
<hr>
<h2 id="배운-점-2">배운 점</h2>
<p>QueryDSL은 “문자열 대신 자바 코드로 쿼리를 쓴다”는 점에서 유지보수성이 훨씬 높았다.
특히 복잡한 조건이나 동적 쿼리로 갈수록 JPQL보다 훨씬 확장성이 좋다는 걸 느꼈다.</p>
<hr>
<h1 id="2-user-nickname-컬럼-추가-및-jwt-claim-확장">2. User nickname 컬럼 추가 및 JWT claim 확장</h1>
<h2 id="문제-상황-5">문제 상황</h2>
<p>기존 JWT에는 email과 userRole만 들어 있었고, 프론트엔드가 사용자 닉네임을 바로 사용할 수 없는 상태였다.</p>
<p>요구사항은 다음과 같았다.</p>
<ul>
<li>User 테이블에 nickname 컬럼 추가</li>
<li>JWT 안에 nickname 포함</li>
<li>프론트엔드가 JWT에서 nickname을 꺼내 화면에 표시 가능해야 함</li>
</ul>
<hr>
<h2 id="해결-방향-1">해결 방향</h2>
<p>이 문제는 단순히 필드 하나를 추가하는 것이 아니었다.
<code>nickname</code> 이 다음 흐름 전체를 타고 전달되어야 했다.</p>
<ul>
<li>회원가입 요청</li>
<li>User 엔티티 저장</li>
<li>JWT 생성</li>
<li>JWT 파싱</li>
<li>인증 객체(AuthUser)</li>
</ul>
<p>즉, <strong>DB -&gt; JWT -&gt; 인증 흐름 전체를 연결하는 작업</strong>이었다.</p>
<hr>
<h2 id="주요-수정-포인트">주요 수정 포인트</h2>
<h3 id="user-엔티티">User 엔티티</h3>
<pre><code class="language-java">private String nickname;</code></pre>
<h3 id="signuprequest">SignupRequest</h3>
<pre><code class="language-java">@NotBlank
private String nickname;</code></pre>
<h3 id="jwtutil">JwtUtil</h3>
<pre><code class="language-java">.claim(&quot;nickname&quot;, nickname)</code></pre>
<h3 id="authservice">AuthService</h3>
<p>회원가입/로그인 시 토큰 생성 메서드에 nickname 전달</p>
<pre><code class="language-java">String bearerToken = jwtUtil.createToken(
        user.getId(),
        user.getEmail(),
        user.getNickname(),
        user.getUserRole()
);</code></pre>
<h3 id="authuser">AuthUser</h3>
<pre><code class="language-java">private final String nickname;</code></pre>
<hr>
<h2 id="트러블슈팅---서버-실행-실패">트러블슈팅 - 서버 실행 실패</h2>
<p>nickname 작업 후 서버 실행 시 다음 오류가 발생했다.</p>
<p><code>Could not resolve placeholder &#39;jwt.secret.key&#39;</code></p>
<p>처음에는 nickname 관련 수정 문제라고 생각할 수도 있었지만, 실제 원인은 전혀 달랐다.</p>
<p><code>JwtUtil</code> 에서 다음 값을 주입받고 있었는데,</p>
<pre><code class="language-java">@Value(&quot;${jwt.secret.key}&quot;)
private String secretKey;</code></pre>
<p>실행 환경에 <code>jwt.secret.key</code> 설정이 없어서 빈 생성이 실패한 것이었다.</p>
<p>즉 이번 단계의 핵심 트러블슈팅은
“내가 방금 수정한 코드가 문제일 것”이라는 추측보다,
<strong>로그에서 실제 root cause를 정확히 읽는 것</strong>이 더 중요하다는 점을 보여줬다.</p>
<hr>
<h2 id="테스트-5">테스트</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/1da3f23f-9c6f-42ee-9ac2-e34c57fe4a9a/image.png" alt=""></p>
<p><strong>POST /auth/signup</strong></p>
<p>요청 예시:</p>
<pre><code class="language-json">{
  &quot;email&quot;: &quot;test@test.com&quot;,
  &quot;password&quot;: &quot;1234&quot;,
  &quot;nickname&quot;: &quot;jimin&quot;,
  &quot;userRole&quot;: &quot;USER&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/5043a5ef-b75b-44f8-a523-fe2bfac275ef/image.png" alt=""></p>
<p><strong>POST /auth/signin</strong></p>
<p>확인 포인트:</p>
<ul>
<li>회원가입 시 nickname이 저장되는지</li>
<li>로그인 후 발급된 JWT payload에 nickname이 포함되는지</li>
<li>이후 인증이 필요한 API에서 사용자 정보 흐름이 깨지지 않는지</li>
</ul>
<hr>
<h2 id="배운-점-3">배운 점</h2>
<p>JWT는 단순히 로그인 여부만 판단하는 토큰이 아니라,
<strong>클라이언트가 자주 사용할 사용자 정보를 함께 담아 전달하는 수단</strong>이 될 수 있다.</p>
<p>다만 claim 하나를 추가하는 것만으로 끝나는 것이 아니라,
DB 저장, 토큰 생성, 토큰 파싱, 인증 객체 반영까지 모두 이어줘야 전체 흐름이 완성된다.</p>
<hr>
<h1 id="4-todo-단건-조회-실패-컨트롤러-테스트-수정">4. Todo 단건 조회 실패 컨트롤러 테스트 수정</h1>
<h2 id="문제-상황-6">문제 상황</h2>
<p><code>todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다()</code> 테스트가 실패하고 있었다.</p>
<p>문제를 확인해보니 서비스는 예외를 던지는데, 테스트는 성공 응답을 기대하고 있었다.</p>
<p>즉, 예외 상황을 검증해야 하는 테스트인데도 다음과 같이 <code>200 OK</code> 를 기대하고 있었다.</p>
<pre><code class="language-java">.andExpect(status().isOk())
.andExpect(jsonPath(&quot;$.status&quot;).value(HttpStatus.OK.name()))
.andExpect(jsonPath(&quot;$.code&quot;).value(HttpStatus.OK.value()))</code></pre>
<hr>
<h2 id="왜-이게-문제였을까">왜 이게 문제였을까</h2>
<p>컨트롤러 테스트에서 중요한 것은 “서비스 내부에서 예외가 발생했는가”만이 아니다.</p>
<p>더 중요한 건
<strong>그 예외가 Global Exception Handler를 거쳐 어떤 HTTP 응답으로 변환되는가</strong> 이다.</p>
<p>즉 이 테스트는
“Todo가 없을 때 서비스가 예외를 던진다”를 검증하는 테스트가 아니라,
“그 예외가 웹 계층에서 어떤 응답 형태로 내려가는가”를 검증해야 했다.</p>
<hr>
<h2 id="해결-방법-3">해결 방법</h2>
<p>기대값을 <code>200 OK</code> 에서 <code>400 Bad Request</code> 로 수정했다.</p>
<pre><code class="language-java">mockMvc.perform(get(&quot;/todos/{todoId}&quot;, todoId))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath(&quot;$.status&quot;).value(HttpStatus.BAD_REQUEST.name()))
        .andExpect(jsonPath(&quot;$.code&quot;).value(HttpStatus.BAD_REQUEST.value()))
        .andExpect(jsonPath(&quot;$.message&quot;).value(&quot;Todo not found&quot;));</code></pre>
<p>또한 <code>AuthUser</code> 생성자 구조가 nickname 추가로 변경되었기 때문에, 테스트 코드도 실제 구조에 맞게 함께 수정했다.</p>
<hr>
<h2 id="배운-점-4">배운 점</h2>
<p>컨트롤러 테스트는 서비스 로직 자체를 다시 검증하는 것이 아니라,
<strong>서비스 결과가 HTTP 응답으로 어떻게 표현되는지 검증하는 레이어</strong>라는 점을 다시 확인할 수 있었다.</p>
<hr>
<h1 id="5-관리자-권한-변경-api에-aop-올바르게-적용하기">5. 관리자 권한 변경 API에 AOP 올바르게 적용하기</h1>
<h2 id="문제-상황-7">문제 상황</h2>
<p>과제 요구사항은 <code>UserAdminController.changeUserRole()</code> 메서드가 실행되기 전에 관리자 접근 로그가 남아야 한다는 것이었다.</p>
<p>하지만 기존 Aspect는 전혀 다른 메서드에 걸려 있었고, 실행 시점도 잘못되어 있었다.</p>
<p>기존 코드:</p>
<pre><code class="language-java">@After(&quot;execution(* org.example.expert.domain.user.controller.UserController.getUser(..))&quot;)
public void logAfterChangeUserRole(JoinPoint joinPoint) {
    ...
}</code></pre>
<p>여기에는 두 가지 문제가 있었다.</p>
<ul>
<li>대상 메서드가 <code>UserController.getUser()</code> 였다</li>
<li>실행 시점이 <code>@After</code> 였다</li>
</ul>
<p>즉 요구사항은 “권한 변경 메서드 실행 전 로그”인데,
실제 코드는 “다른 메서드 실행 후 로그”였던 것이다.</p>
<hr>
<h2 id="해결-방법-4">해결 방법</h2>
<p>포인트컷과 실행 시점을 모두 수정했다.</p>
<pre><code class="language-java">@Before(&quot;execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))&quot;)
public void logBeforeChangeUserRole(JoinPoint joinPoint) {
    String userId = String.valueOf(request.getAttribute(&quot;userId&quot;));
    String requestUrl = request.getRequestURI();
    LocalDateTime requestTime = LocalDateTime.now();

    log.info(&quot;Admin Access Log - User ID: {}, Request Time: {}, Request URL: {}, Method: {}&quot;,
            userId, requestTime, requestUrl, joinPoint.getSignature().getName());
}</code></pre>
<hr>
<h2 id="핵심-개념">핵심 개념</h2>
<p>AOP는 두 가지가 정확해야 한다.</p>
<ul>
<li><strong>어디에 적용할 것인가</strong> → 포인트컷</li>
<li><strong>언제 실행할 것인가</strong> → Before / After / Around</li>
</ul>
<p>이번 문제는 이 둘이 모두 어긋나 있었기 때문에 의도한 동작이 전혀 일어나지 않았다.</p>
<hr>
<h2 id="테스트-6">테스트</h2>
<p><strong>PATCH /admin/users/{userId}</strong></p>
<p>요청 예시:</p>
<pre><code class="language-json">{
  &quot;userRole&quot;: &quot;ADMIN&quot;
}</code></pre>
<p>추가로 로그 콘솔 확인</p>
<p>확인 포인트:</p>
<ul>
<li><code>changeUserRole()</code> 호출 전에 로그가 찍히는지</li>
<li>request URL, userId, method가 올바르게 남는지</li>
</ul>
<hr>
<h2 id="배운-점-5">배운 점</h2>
<p>AOP는 “붙이면 된다”가 아니라,
<strong>정확한 대상과 정확한 시점을 지정해야 의도한 공통 기능이 동작한다</strong>는 걸 확실히 느꼈다.</p>
<hr>
<h1 id="9-filter--argumentresolver-구조를-spring-security로-전환">9. Filter + ArgumentResolver 구조를 Spring Security로 전환</h1>
<h2 id="문제-상황-8">문제 상황</h2>
<p>기존 인증 구조는 다음과 같았다.</p>
<ul>
<li><code>JwtFilter</code> 가 JWT를 파싱</li>
<li>request attribute에 userId, email, userRole 등을 저장</li>
<li><code>AuthUserArgumentResolver</code> 가 request에서 값을 꺼내 <code>@Auth AuthUser</code> 로 주입</li>
</ul>
<p>이 구조도 동작은 했지만, Spring Security 없이 인증/인가를 거의 수동으로 구현한 방식이었다.</p>
<p>특히 <code>/admin</code> 권한 체크도 직접 if문으로 처리하고 있었다.</p>
<hr>
<h2 id="왜-spring-security로-바꿔야-했는가">왜 Spring Security로 바꿔야 했는가</h2>
<p>과제 요구사항은 명확했다.</p>
<ul>
<li>기존 Filter / ArgumentResolver 기반 구조를 Spring Security로 전환</li>
<li>JWT 기반 인증은 유지</li>
<li>관리자 권한 기능도 유지</li>
</ul>
<p>즉 핵심은 “JWT를 버리는 것”이 아니라,
<strong>JWT 인증 흐름을 Spring Security 표준 방식 안으로 옮기는 것</strong>이었다.</p>
<hr>
<h2 id="변경-전-구조">변경 전 구조</h2>
<ul>
<li><code>JwtFilter</code></li>
<li><code>FilterConfig</code></li>
<li><code>AuthUserArgumentResolver</code></li>
<li><code>WebConfig</code></li>
<li><code>@Auth AuthUser</code></li>
</ul>
<h2 id="변경-후-구조">변경 후 구조</h2>
<ul>
<li><code>JwtAuthenticationFilter</code></li>
<li><code>SecurityConfig</code></li>
<li><code>SecurityContext</code></li>
<li><code>@AuthenticationPrincipal AuthUser</code></li>
</ul>
<hr>
<h2 id="핵심-전환-포인트">핵심 전환 포인트</h2>
<p>기존에는 request attribute를 통해 사용자 정보를 넘겼다.</p>
<p>하지만 Spring Security에서는 인증 성공 시 <code>SecurityContextHolder</code> 에 <code>Authentication</code> 을 저장하고,
컨트롤러에서는 <code>@AuthenticationPrincipal</code> 로 principal을 받아 사용한다.</p>
<p>즉 사용자 정보의 전달 통로 자체가 바뀐 것이다.</p>
<hr>
<h2 id="jwtauthenticationfilter">JwtAuthenticationFilter</h2>
<pre><code class="language-java">AuthUser authUser = new AuthUser(userId, email, nickname, userRole);

UsernamePasswordAuthenticationToken authentication =
        new UsernamePasswordAuthenticationToken(
                authUser,
                null,
                List.of(new SimpleGrantedAuthority(userRole.name()))
        );

SecurityContextHolder.getContext().setAuthentication(authentication);</code></pre>
<hr>
<h2 id="securityconfig">SecurityConfig</h2>
<pre><code class="language-java">.authorizeHttpRequests(auth -&gt; auth
        .requestMatchers(&quot;/auth/**&quot;).permitAll()
        .requestMatchers(&quot;/admin/**&quot;).hasAuthority(UserRole.ADMIN.name())
        .anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);</code></pre>
<p>이 설정을 통해</p>
<ul>
<li><code>/auth/**</code> 는 누구나 접근 가능</li>
<li><code>/admin/**</code> 는 ADMIN 권한 필요</li>
<li>나머지 요청은 인증 필요</li>
</ul>
<p>라는 정책을 Spring Security 설정으로 선언할 수 있었다.</p>
<hr>
<h2 id="컨트롤러-변경">컨트롤러 변경</h2>
<p>기존:</p>
<pre><code class="language-java">@Auth AuthUser authUser</code></pre>
<p>변경 후:</p>
<pre><code class="language-java">@AuthenticationPrincipal AuthUser authUser</code></pre>
<p>이 과정에서 기존 <code>AuthUserArgumentResolver</code>, <code>FilterConfig</code>, <code>WebConfig</code> 는 더 이상 필요 없어져 제거했다.</p>
<hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="1-spring-security로-바꿨는데-resolver를-유지하려-했던-문제">1) Spring Security로 바꿨는데 Resolver를 유지하려 했던 문제</h3>
<p>처음에는 기존 <code>AuthUserArgumentResolver</code> 를 살려두고 <code>SecurityContext</code> 와 섞어 쓰는 방향도 고민했지만,
그렇게 되면 구조가 이중화된다.</p>
<p>즉,</p>
<ul>
<li>SecurityContext를 이미 쓰는데</li>
<li>다시 Resolver로 래핑하는 불필요한 단계</li>
</ul>
<p>가 생긴다.</p>
<p>결국 가장 깔끔한 방향은 <strong>Resolver 자체를 제거하고 <code>@AuthenticationPrincipal</code> 로 통일하는 것</strong>이었다.</p>
<h3 id="2-webconfig-에서-이미-삭제한-resolver를-계속-등록하고-있던-문제">2) <code>WebConfig</code> 에서 이미 삭제한 Resolver를 계속 등록하고 있던 문제</h3>
<p><code>WebConfig</code> 가 여전히 <code>AuthUserArgumentResolver</code> 를 등록하려 해서 실행 에러가 발생했다.</p>
<p>이 문제는 예전 구조의 흔적을 완전히 제거하지 못해 생긴 문제였다.
결국 Spring Security 전환은 새 코드 추가만이 아니라,
<strong>기존 수동 인증 구조의 흔적을 함께 지우는 작업</strong>이라는 점을 배웠다.</p>
<hr>
<h2 id="테스트-7">테스트</h2>
<p>(Postman으로 테스트 한 사진 삽입)
<strong>POST /auth/signin</strong></p>
<p>토큰 발급 후</p>
<p>(Postman으로 테스트 한 사진 삽입)
<strong>POST /todos</strong>
Authorization 헤더에 Bearer 토큰 포함</p>
<p>(Postman으로 테스트 한 사진 삽입)
<strong>PATCH /admin/users/{userId}</strong>
일반 USER 토큰으로 요청한 경우 403 확인</p>
<p>(Postman으로 테스트 한 사진 삽입)
<strong>PATCH /admin/users/{userId}</strong>
ADMIN 토큰으로 요청한 경우 정상 처리 확인</p>
<hr>
<h2 id="배운-점-6">배운 점</h2>
<p>Spring Security의 핵심은 보안 라이브러리 추가가 아니었다.</p>
<p>정말 중요한 건 다음 두 가지였다.</p>
<ul>
<li>사용자 인증 정보를 request attribute가 아니라 <code>SecurityContext</code> 로 관리하는 것</li>
<li>권한 정책을 코드 곳곳의 if문이 아니라, 설정으로 선언하는 것</li>
</ul>
<p>즉 이번 전환은 단순 리팩토링이 아니라,
<strong>인증/인가 구조를 스프링 표준 방식으로 재설계한 작업</strong>에 가까웠다.</p>
<hr>
<h1 id="이번-과제를-통해-느낀-점">이번 과제를 통해 느낀 점</h1>
<p>이번 과제는 단순히 기능 개수를 채우는 과제가 아니었다.
오히려 이미 존재하는 코드를 읽고, 그 안에 숨어 있는 설계 의도와 문제점을 파악하고,
스프링이 제공하는 기능들을 “왜 이럴 때 쓰는가”까지 연결해보는 과정이었다.</p>
<p>특히 다음 개념들을 실제 문제와 함께 연결해서 이해할 수 있었다.</p>
<ul>
<li><code>@Transactional(readOnly = true)</code> 와 쓰기 트랜잭션 구분</li>
<li>JPQL의 optional 조건 검색</li>
<li>JPA Cascade를 통한 영속성 전이</li>
<li>LAZY 로딩과 fetch join, N+1 문제</li>
<li>QueryDSL의 장점과 설정 과정</li>
<li>JWT claim 확장과 인증 흐름 연결</li>
<li>컨트롤러 테스트에서 HTTP 응답 검증의 의미</li>
<li>AOP의 포인트컷과 실행 시점</li>
<li>Spring Security 기반 인증/인가 구조 전환</li>
</ul>
<p>무엇보다 가장 크게 느낀 점은,
<strong>문제를 해결하는 것과 문제를 설명할 수 있는 것은 다르다</strong>는 것이다.</p>
<p>이번 과제에서는 각 기능을 구현할 때마다
“왜 이렇게 해야 하는가”,
“이전 구조는 왜 한계가 있었는가”,
“스프링이 제공하는 기능은 어떤 원리로 동작하는가”
를 같이 정리하려고 노력했고, 그 과정이 오히려 가장 큰 공부가 되었다.</p>
<hr>
<h1 id="테스트에-사용한-api-정리">테스트에 사용한 API 정리</h1>
<h2 id="1번">1번</h2>
<ul>
<li><strong>POST /todos</strong></li>
</ul>
<h2 id="3번">3번</h2>
<ul>
<li><strong>GET /todos?page=1&amp;size=10</strong></li>
<li><strong>GET /todos?page=1&amp;size=10&amp;weather=Sunny</strong></li>
<li><strong>GET /todos?page=1&amp;size=10&amp;modifiedAtFrom=2026-04-01T00:00:00&amp;modifiedAtTo=2026-04-06T23:59:59</strong></li>
<li><strong>GET /todos?page=1&amp;size=10&amp;weather=Sunny&amp;modifiedAtFrom=2026-04-01T00:00:00&amp;modifiedAtTo=2026-04-06T23:59:59</strong></li>
</ul>
<h2 id="6번">6번</h2>
<ul>
<li><strong>POST /todos</strong></li>
<li><strong>GET /todos/{todoId}/managers</strong></li>
</ul>
<h2 id="7번">7번</h2>
<ul>
<li><strong>GET /todos/{todoId}/comments</strong></li>
</ul>
<h2 id="8번">8번</h2>
<ul>
<li><strong>GET /todos/{todoId}</strong></li>
</ul>
<h2 id="2번">2번</h2>
<ul>
<li><strong>POST /auth/signup</strong></li>
<li><strong>POST /auth/signin</strong></li>
</ul>
<h2 id="5번">5번</h2>
<ul>
<li><strong>PATCH /admin/users/{userId}</strong></li>
</ul>
<h2 id="9번">9번</h2>
<ul>
<li><p><strong>POST /auth/signin</strong></p>
</li>
<li><p><strong>POST /todos</strong></p>
</li>
<li><p><strong>PATCH /admin/users/{userId}</strong></p>
<ul>
<li>USER 토큰으로 실패</li>
<li>ADMIN 토큰으로 성공</li>
</ul>
</li>
</ul>
<hr>
<h2 id="회고">회고</h2>
<p>이번 개인 과제 기간 동안 개인 일정으로 인해 약 2일 정도 학습과 과제 진행에 집중하지 못했던 점이 아쉬움으로 남는다.
그로 인해 전체 과제를 몰아서 진행하게 되었고, 과정이 다소 촉박하게 흘러갔던 부분이 있었다.</p>
<p>하지만 그럼에도 불구하고 이번 과제를 통해 단순히 기능을 구현하는 것을 넘어,
각 기술을 왜 사용하는지, 그리고 어떤 상황에서 필요한지까지 깊이 있게 고민해볼 수 있었던 시간이었다.</p>
<p>특히 트랜잭션, JPA 연관관계, N+1 문제, QueryDSL, 그리고 Spring Security까지
실제 문제 상황과 연결하여 학습할 수 있었던 점이 의미있었다.</p>
<p>이번 경험을 통해 미리 계획하고 꾸준히 진행하는 것이 얼마나 중요한지 다시 한번 느끼게 되었고,
앞으로는 일정 관리까지 포함한 개발 습관을 개선해 나가고자 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] CH4 클라우드_아키텍처 설계 & 배포 - 도전기능]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH4-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%84%A4%EA%B3%84-%EB%B0%B0%ED%8F%AC-%EB%8F%84%EC%A0%84%EA%B8%B0%EB%8A%A5</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH4-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%84%A4%EA%B3%84-%EB%B0%B0%ED%8F%AC-%EB%8F%84%EC%A0%84%EA%B8%B0%EB%8A%A5</guid>
            <pubDate>Fri, 13 Mar 2026 09:02:15 GMT</pubDate>
            <description><![CDATA[<h1 id="☁️-lv4---docker--github-actions-cicd-구축">☁️ LV4 - Docker &amp; GitHub Actions CI/CD 구축</h1>
<p>이번 단계에서는 <strong>Spring Boot 애플리케이션을 Docker 컨테이너로 실행하고</strong>,
<strong>GitHub Actions를 이용해 코드 Push 시 자동으로 서버에 배포되는 CI/CD 파이프라인을 구축</strong>하는 것을 목표로 했다.</p>
<p>이 과정을 통해</p>
<blockquote>
<p>“로컬에서는 되는데 서버에서는 안 되는 문제”</p>
</blockquote>
<p>를 해결하고, <strong>코드 변경 → 자동 배포</strong> 환경을 만들 수 있다.</p>
<hr>
<h1 id="전체-배포-구조">전체 배포 구조</h1>
<p>이번 단계에서 구성한 배포 흐름은 다음과 같다.</p>
<pre><code class="language-text">Developer
   │
   │ git push
   ▼
GitHub Repository
   │
   ▼
GitHub Actions
   │
   │ Gradle Build
   │ Docker Image Build
   ▼
Docker Hub
   │
   │ docker pull
   ▼
AWS EC2
   │
   ▼
Docker Container 실행</code></pre>
<p>즉,</p>
<pre><code>코드 Push → 자동 빌드 → Docker 이미지 생성 → DockerHub 업로드 → EC2 자동 배포</code></pre><p>가 이루어지는 <strong>CI/CD 자동화 파이프라인</strong>이다.</p>
<hr>
<h1 id="docker-도입">Docker 도입</h1>
<p>기존에는 EC2 서버에서 다음과 같이 직접 애플리케이션을 실행했다.</p>
<pre><code class="language-bash">java -jar cloud-architecture-0.0.1-SNAPSHOT.jar</code></pre>
<p>하지만 이 방식에는 다음과 같은 문제가 있다.</p>
<ul>
<li>서버 환경(Java 버전 등)에 의존적</li>
<li>실행 환경 재현이 어려움</li>
<li>자동 배포 환경 구축이 어려움</li>
</ul>
<p>이를 해결하기 위해 <strong>Docker 컨테이너 환경으로 애플리케이션을 패키징</strong>하였다.</p>
<hr>
<h1 id="dockerfile-작성">Dockerfile 작성</h1>
<p>애플리케이션을 Docker 이미지로 만들기 위해 <code>Dockerfile</code>을 작성했다.</p>
<pre><code class="language-dockerfile">FROM eclipse-temurin:17-jre

WORKDIR /app

COPY build/libs/cloud-architecture-0.0.1-SNAPSHOT.jar app.jar

EXPOSE 8080

ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;/app/app.jar&quot;, &quot;--spring.profiles.active=prod&quot;]</code></pre>
<p>설정 설명</p>
<table>
<thead>
<tr>
<th>설정</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>eclipse-temurin:17-jre</td>
<td>Java 17 실행 환경</td>
</tr>
<tr>
<td>WORKDIR</td>
<td>컨테이너 내부 작업 디렉토리</td>
</tr>
<tr>
<td>COPY</td>
<td>빌드된 jar 파일 복사</td>
</tr>
<tr>
<td>EXPOSE 8080</td>
<td>애플리케이션 포트</td>
</tr>
<tr>
<td>ENTRYPOINT</td>
<td>Spring Boot 실행</td>
</tr>
</tbody></table>
<hr>
<h1 id="github-actions-cicd-구축">GitHub Actions CI/CD 구축</h1>
<p>코드를 push 할 때마다 자동으로 배포되도록 <strong>GitHub Actions</strong>를 이용해 CI/CD 파이프라인을 구성했다.</p>
<p>워크플로우 파일 위치</p>
<pre><code>.github/workflows/deploy.yml</code></pre><hr>
<h1 id="github-actions-workflow">GitHub Actions Workflow</h1>
<pre><code class="language-yaml">name: Deploy to EC2 with Docker

on:
  push:
    branches:
      - main

jobs:
  build-test-push-deploy:
    runs-on: ubuntu-latest

    steps:

      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17

      - name: Build with Gradle
        run: ./gradlew clean build -x test

      - name: Login to DockerHub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build Docker image
        run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/cloud-architecture .

      - name: Push Docker image
        run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/cloud-architecture

      - name: Deploy to EC2
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ${{ secrets.EC2_USER }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/cloud-architecture
            sudo docker stop cloud-architecture || true
            sudo docker rm cloud-architecture || true
            sudo docker run -d -p 8080:8080 --name cloud-architecture ${{ secrets.DOCKERHUB_USERNAME }}/cloud-architecture</code></pre>
<hr>
<h1 id="자동-배포-확인">자동 배포 확인</h1>
<p>코드를 수정 후 GitHub에 Push하면</p>
<pre><code class="language-bash">git push origin main</code></pre>
<p>GitHub Actions가 자동으로 실행된다.</p>
<hr>
<h2 id="github-actions-실행-결과">GitHub Actions 실행 결과</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/352eb621-cf70-4f83-af16-e662b704879e/image.png" alt=""></p>
<hr>
<h2 id="ec2-docker-컨테이너-확인">EC2 Docker 컨테이너 확인</h2>
<p>EC2 서버 접속 후</p>
<pre><code class="language-bash">sudo docker ps</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/e150538e-ec15-4e63-9378-647b90e3a33b/image.png" alt=""></p>
<hr>
<h2 id="서버-health-check">서버 Health Check</h2>
<pre><code>http://EC2_PUBLIC_IP:8080/actuator/health</code></pre><p>응답</p>
<pre><code class="language-json">{
  &quot;status&quot;: &quot;UP&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/d23c2442-156f-4ed2-b9d4-722d249c8747/image.png" alt=""></p>
<hr>
<h1 id="트러블슈팅">트러블슈팅</h1>
<p>이번 단계에서는 CI/CD 구축 과정에서 여러 가지 문제를 겪었고 이를 해결하는 과정이 있었다.</p>
<hr>
<h1 id="github-actions-테스트-실패">GitHub Actions 테스트 실패</h1>
<h3 id="문제">문제</h3>
<p>GitHub Actions 빌드 과정에서 다음과 같은 오류가 발생했다.</p>
<pre><code>contextLoads() FAILED
SdkClientException</code></pre><h3 id="원인">원인</h3>
<p>테스트 실행 시 <strong>AWS S3 Client 빈 생성 과정에서 자격 증명을 찾지 못해 실패</strong>했다.</p>
<p>GitHub Actions 환경에는 AWS 자격 증명이 존재하지 않기 때문이다.</p>
<h3 id="해결">해결</h3>
<p>CI 과정에서는 테스트를 제외하도록 설정했다.</p>
<pre><code class="language-yaml">run: ./gradlew clean build -x test</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/f6792c62-e19d-471e-9551-9baee5ff75a8/image.png" alt=""></p>
<hr>
<h1 id="docker-컨테이너-실행-실패">Docker 컨테이너 실행 실패</h1>
<h3 id="문제-1">문제</h3>
<p>배포 시 다음 오류가 발생했다.</p>
<pre><code>bind: address already in use</code></pre><h3 id="원인-1">원인</h3>
<p>이전에 EC2에서 실행 중이던</p>
<pre><code>java -jar cloud-architecture.jar</code></pre><p>프로세스가 <strong>8080 포트를 점유하고 있었기 때문</strong>이었다.</p>
<h3 id="해결-1">해결</h3>
<p>기존 Java 프로세스를 종료 후 Docker 컨테이너를 실행하였다.</p>
<pre><code class="language-bash">kill -9 {PID}</code></pre>
<hr>
<h1 id="결과">결과</h1>
<p>최종적으로 다음 구조의 <strong>자동 배포 환경을 구축하였다.</strong></p>
<pre><code>Git Push
 ↓
GitHub Actions
 ↓
Gradle Build
 ↓
Docker Image Build
 ↓
DockerHub Push
 ↓
EC2 Docker Pull
 ↓
컨테이너 실행</code></pre><p>이제 코드 수정 후 <strong>GitHub에 Push만 하면 자동으로 서버에 반영된다.</strong></p>
<hr>
<h1 id="느낀-점">느낀 점</h1>
<p>이번 단계에서는 단순히 애플리케이션을 배포하는 것을 넘어</p>
<ul>
<li>Docker 기반 컨테이너 환경</li>
<li>GitHub Actions CI/CD 자동화</li>
<li>AWS EC2 서버 배포</li>
</ul>
<p>를 직접 구축하면서 <strong>실제 서비스 배포 과정과 매우 유사한 경험을 할 수 있었다.</strong></p>
<p>특히 다양한 배포 오류를 해결하는 과정에서
<strong>클라우드 환경과 CI/CD 파이프라인에 대한 이해도를 크게 높일 수 있었다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] CH 4 클라우드_아키텍처 설계 & 배포 - 필수 기능]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH-4-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%84%A4%EA%B3%84-%EB%B0%B0%ED%8F%AC-%ED%95%84%EC%88%98-%EA%B8%B0%EB%8A%A5</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH-4-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%84%A4%EA%B3%84-%EB%B0%B0%ED%8F%AC-%ED%95%84%EC%88%98-%EA%B8%B0%EB%8A%A5</guid>
            <pubDate>Fri, 13 Mar 2026 04:25:43 GMT</pubDate>
            <description><![CDATA[<h1 id="전체-구조">전체 구조</h1>
<p>최종적으로 필수 과제를 끝냈을 때의 구조는 아래와 같다.</p>
<p>로컬 개발 단계에서는</p>
<ul>
<li>Spring Boot</li>
<li>H2</li>
</ul>
<p>로 빠르게 기능을 구현했고,</p>
<p>운영 배포 단계에서는</p>
<ul>
<li>EC2 -&gt; 애플리케이션 서버</li>
<li>RDS(MySQL) -&gt; 데이터 저장</li>
<li>S3 -&gt; 이미지 파일 저장</li>
<li>Parameter Store -&gt; 민감 정보 및 설정값 관리</li>
</ul>
<p>이 구조로 바뀌었다.</p>
<p>즉, 단순히 서버 한 대 안에 모든 것을 몰아넣는 게 아니라</p>
<p><strong>애플리케이션 / DB / 파일 저장소를 분리하는 방향</strong></p>
<p>으로 설계했다.</p>
<hr>
<h1 id="기술-스택">기술 스택</h1>
<ul>
<li>Java 17</li>
<li>Spring Boot</li>
<li>Spring Data JPA</li>
<li>Validation</li>
<li>H2</li>
<li>MySQL</li>
<li>Spring Boot Actuator</li>
<li>AWS EC2</li>
<li>AWS VPC</li>
<li>AWS RDS</li>
<li>AWS Systems Manager Parameter Store</li>
<li>AWS IAM Role</li>
<li>AWS S3</li>
<li>Spring Cloud AWS</li>
</ul>
<hr>
<h1 id="레벨별-진행-목표">레벨별 진행 목표</h1>
<h2 id="lv0">Lv0</h2>
<p>AWS Budget 설정으로 비용 알림 구성</p>
<h2 id="lv1">Lv1</h2>
<p>VPC / Public / Private Subnet 구성 후 EC2에 Spring Boot 애플리케이션 배포
그리고 외부에서 <code>/actuator/health</code> 로 서버 상태 확인</p>
<h2 id="lv2">Lv2</h2>
<p>RDS를 추가해서 DB를 분리하고,
DB 정보는 코드에 직접 작성하지 않고 Parameter Store에서 가져오도록 설정</p>
<h2 id="lv3">Lv3</h2>
<p>프로필 이미지를 서버 디스크가 아니라 S3에 저장하고,
Presigned URL을 통해서만 다운로드 가능하도록 구현</p>
<hr>
<h1 id="lv0---요금-폭탄-방지를-위한-aws-budget-설정">Lv0 - 요금 폭탄 방지를 위한 AWS Budget 설정</h1>
<h2 id="왜-먼저-budget부터-설정했는가">왜 먼저 Budget부터 설정했는가</h2>
<p>클라우드 실습은 기능 구현 못지않게 중요한 게 비용 관리라고 생각했다.</p>
<p>특히 AWS는 처음 사용할 때
“조금만 켜놨다고 생각했는데 과금이 쌓여버리는 상황”이 생길 수 있어서,
이번 과제에서는 기능 구현 이전에
<strong>비용 알림 체계를 먼저 만드는 것</strong>이 중요했다.</p>
<p>그래서 가장 먼저 한 작업이 AWS Budget 설정이었다.</p>
<h2 id="내가-설정한-것">내가 설정한 것</h2>
<ul>
<li>월 예산: $100</li>
<li>80% 도달 시 이메일 알림</li>
</ul>
<p>이렇게 설정해두면,
실수로 EC2나 RDS를 오래 켜두더라도 완전히 모르고 지나가는 상황을 줄일 수 있다.</p>
<h2 id="느낀-점">느낀 점</h2>
<p>이 단계는 코드가 없어서 가볍게 보일 수 있는데,
오히려 실제 운영 관점에서는 굉장히 중요한 단계라고 느꼈다.</p>
<p>개발자는 단순히 기능만 만드는 사람이 아니라,
<strong>운영 비용과 리스크까지 고려해야 한다</strong>는 걸 가장 먼저 체감한 레벨이었다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/8606867c-28f3-4974-b63c-086b5ea86070/image.png" alt=""></p>
<hr>
<h1 id="lv1---네트워크-구성과-spring-boot-애플리케이션-배포">Lv1 - 네트워크 구성과 Spring Boot 애플리케이션 배포</h1>
<p>Lv1의 목표는 한 줄로 정리하면 이거였다.</p>
<p><strong>“외부에서 접속 가능한 Spring Boot 서버를 AWS 위에 직접 띄운다.”</strong></p>
<p>이 단계에서 나는
단순히 API 구현에만 집중하는 것이 아니라
<strong>네트워크 구조와 배포 구조를 같이 이해하는 것</strong>에 초점을 맞췄다.</p>
<hr>
<h2 id="lv1에서-충족해야-했던-요구사항">Lv1에서 충족해야 했던 요구사항</h2>
<ul>
<li>VPC 설정</li>
<li>Public / Private Subnet 분리</li>
<li>Public Subnet에 EC2 생성</li>
<li>팀원 생성 API 구현</li>
<li>팀원 조회 API 구현</li>
<li>local / prod profile 분리</li>
<li>API 요청 로그 출력</li>
<li>전역 예외 처리</li>
<li>Actuator health 노출</li>
<li>EC2에 배포 후 외부 접속 확인</li>
</ul>
<hr>
<h2 id="1-spring-boot-프로젝트-구조-설계">1. Spring Boot 프로젝트 구조 설계</h2>
<p>처음 로컬에서 기능을 만들 때는 너무 복잡하게 가지 않고
기본적인 3계층 구조로 설계했다.</p>
<p>패키지 구조는 다음과 같이 잡았다.</p>
<pre><code class="language-text">kr.spartaclub.cloudarchitecture
 ┣ controller
 ┣ service
 ┣ repository
 ┣ entity
 ┣ dto
 ┣ exception
 ┣ logging
 ┗ config</code></pre>
<p>이 구조를 선택한 이유는 다음과 같다.</p>
<ul>
<li>controller -&gt; 요청/응답 처리</li>
<li>service -&gt; 비즈니스 로직</li>
<li>repository -&gt; DB 접근</li>
<li>entity -&gt; JPA 엔티티</li>
<li>dto -&gt; 요청/응답 분리</li>
<li>exception -&gt; 전역 예외 처리</li>
<li>logging -&gt; API 요청 로그 처리</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/a92aecba-3e94-4824-a73d-cf6a19dd2c04/image.png" alt=""></p>
<hr>
<h2 id="2-팀원-생성--조회-api-구현">2. 팀원 생성 / 조회 API 구현</h2>
<p>Lv1의 핵심 기능은 팀원 정보를 저장하고 조회하는 API였다.</p>
<h3 id="member-엔티티">Member 엔티티</h3>
<p>저장해야 하는 값은</p>
<ul>
<li>이름</li>
<li>나이</li>
<li>MBTI</li>
</ul>
<p>였고, 이후 Lv3에서 프로필 이미지 key를 저장해야 했기 때문에
미리 <code>profileImageKey</code> 필드도 같이 두었다.</p>
<p>이렇게 설계한 이유는
나중에 S3 기능이 들어왔을 때 엔티티를 다시 크게 수정하지 않기 위해서였다.</p>
<h3 id="api">API</h3>
<ul>
<li><code>POST /api/members</code></li>
<li><code>GET /api/members/{id}</code></li>
</ul>
<h3 id="왜-dto를-분리했는가">왜 DTO를 분리했는가</h3>
<p>엔티티를 그대로 요청/응답에 노출하면
나중에 필드가 늘어나거나 변경될 때 API 구조가 흔들릴 수 있어서</p>
<ul>
<li><code>MemberCreateRequest</code></li>
<li><code>MemberResponse</code></li>
</ul>
<p>로 분리했다.</p>
<p>이건 지금은 단순해 보여도
나중에 이미지 key나 운영용 필드가 추가될 때 훨씬 안정적이었다.</p>
<h4 id="멤버-생성">멤버 생성</h4>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/b807e5af-94d7-45c8-9c29-0b0816db0a15/image.png" alt=""></p>
<h4 id="멤버-조회">멤버 조회</h4>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/0997c1f6-8721-4a2a-bd24-02eaccc4667f/image.png" alt=""></p>
<hr>
<h2 id="3-validation을-적용한-이유">3. Validation을 적용한 이유</h2>
<p>사용자 입력은 신뢰할 수 없다고 생각해서
요청 DTO에 검증을 넣었다.</p>
<p>예를 들면</p>
<ul>
<li>이름은 비어 있으면 안 됨</li>
<li>나이는 너무 큰 값이 들어오면 안 됨</li>
<li>MBTI는 정해진 형식이어야 함</li>
</ul>
<p>이렇게 검증해두면 잘못된 데이터가 아예 서비스 로직 안쪽으로 들어오지 않아서
초반부터 데이터 무결성을 지킬 수 있다.</p>
<hr>
<h2 id="4-전역-예외-처리-구현">4. 전역 예외 처리 구현</h2>
<p>과제 요구사항에
예외 처리 + 에러 로그 출력이 있었기 때문에
<code>@RestControllerAdvice</code>를 사용해서 전역 예외 처리기를 만들었다.</p>
<p>내가 이렇게 구현한 이유는
컨트롤러마다 try-catch를 반복하는 방식보다
예외를 한 곳에서 관리하는 방식이 훨씬 유지보수하기 좋기 때문이다.</p>
<p>처리한 예외는 대표적으로</p>
<ul>
<li>존재하지 않는 멤버 조회</li>
<li>Validation 실패</li>
<li>기타 예상하지 못한 예외</li>
</ul>
<p>였다.</p>
<p>그리고 이때 <code>ERROR</code> 레벨 로그를 남기도록 해서
문제가 생겼을 때 콘솔에서 원인을 추적할 수 있도록 했다.</p>
<hr>
<h2 id="5-api-요청-로그를-따로-남긴-이유">5. API 요청 로그를 따로 남긴 이유</h2>
<p>과제 요구사항에
API 요청이 들어올 때마다 INFO 로그를 남기라고 되어 있었기 때문에
Filter를 사용해서</p>
<pre><code class="language-text">[API - LOG] POST /api/members
[API - LOG] GET /api/members/1</code></pre>
<p>형태의 로그를 남기도록 했다.</p>
<p>이렇게 한 이유는
나중에 EC2에 배포했을 때도
“정말 요청이 들어왔는지 / 어떤 경로로 들어왔는지”
를 빠르게 확인하기 위해서였다.</p>
<p>처음에는 로그를 왜 이렇게까지 따로 남겨야 하나 싶었는데,
실제로 배포 후 외부 요청이 들어왔는지 확인할 때 꽤 유용했다.</p>
<hr>
<h2 id="6-actuator를-붙인-이유">6. Actuator를 붙인 이유</h2>
<p>운영 환경에서는 애플리케이션이 “살아 있는지” 를 확인하는 게 중요하다.</p>
<p>그래서 <code>spring-boot-starter-actuator</code>를 추가하고
<code>/actuator/health</code> 를 열어두었다.</p>
<p>이걸 통해
서버가 단순히 프로세스만 떠 있는지 여부가 아니라,
<strong>애플리케이션 관점에서 정상 상태인지</strong> 확인할 수 있게 했다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/aad46e0c-0859-4434-b109-f7935e93e7c7/image.png" alt=""></p>
<hr>
<h2 id="7-local--prod-profile-분리">7. local / prod profile 분리</h2>
<p>이 부분은 Lv1에서 미리 해둔 게 정말 컸다.</p>
<p>처음에는 로컬 개발이기 때문에 H2를 사용했고,
나중에 운영 환경에서는 MySQL(RDS)를 써야 했기 때문에
설정 파일을 환경별로 분리했다.</p>
<ul>
<li><code>application.yml</code></li>
<li><code>application-local.yml</code></li>
<li><code>application-prod.yml</code></li>
</ul>
<p>이렇게 나눈 이유는
같은 프로젝트라도 환경에 따라 DB, 설정, 실행 방식이 달라지기 때문이다.</p>
<p>지금 당장 Lv1만 보면 local로 충분해 보이지만,
Lv2에서 RDS를 붙이는 순간
이 분리가 없었으면 설정이 훨씬 꼬였을 것 같다.</p>
<hr>
<h2 id="8-로컬에서-postman-테스트">8. 로컬에서 Postman 테스트</h2>
<p>이 단계에서 나는 먼저 로컬에서 기능이 완전히 되는지 검증했다.</p>
<h3 id="내가-실제로-테스트한-것">내가 실제로 테스트한 것</h3>
<ul>
<li>멤버 생성</li>
<li>멤버 조회</li>
<li>존재하지 않는 멤버 조회</li>
<li>Validation 실패</li>
<li>actuator health</li>
<li>actuator info</li>
</ul>
<p>이 과정을 거친 이유는
AWS에 올렸는데 안 될 경우
그 원인이 코드인지, 배포인지, 네트워크인지 구분이 안 되기 때문이다.</p>
<p>그래서 <strong>반드시 로컬에서 먼저 기능 검증을 끝낸 후 배포</strong>하는 방식으로 진행했다.</p>
<h4 id="유효성-검증-테스트">유효성 검증 테스트</h4>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/8971a773-fd38-4e7d-a34d-9524706574d8/image.png" alt=""></p>
<h4 id="존재하지-않는-멤버-조회">존재하지 않는 멤버 조회</h4>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/e96a7c69-cd47-4899-ae37-0d22d73c6d52/image.png" alt=""></p>
<h4 id="멤버-생성-1">멤버 생성</h4>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/3cb57892-f0ae-42be-85c7-b9c9de73ea03/image.png" alt=""></p>
<h4 id="멤버-조회-1">멤버 조회</h4>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/148ad77a-3d95-4a1f-9e98-7ef09db1a994/image.png" alt=""></p>
<hr>
<h2 id="9-vpc--subnet--ec2-구성">9. VPC / Subnet / EC2 구성</h2>
<p>이제부터는 본격적으로 AWS 인프라 구성이었다.</p>
<p>내가 구성한 구조는 다음과 같다.</p>
<ul>
<li>VPC: <code>10.0.0.0/16</code></li>
<li>Public Subnet</li>
<li>Private Subnet</li>
<li>Internet Gateway</li>
<li>Public Route Table</li>
<li>EC2는 Public Subnet에 생성</li>
</ul>
<h3 id="왜-public--private-subnet을-분리했는가">왜 Public / Private Subnet을 분리했는가</h3>
<p>처음에는 “EC2 하나만 띄우면 되지 왜 서브넷을 나누지?” 싶었는데,
과제 의도는 단순 배포가 아니라
<strong>운영 가능한 네트워크 구조를 이해하는 것</strong>이었다.</p>
<p>즉</p>
<ul>
<li>외부에서 접근 가능한 서버 -&gt; Public</li>
<li>직접 노출되면 안 되는 자원 -&gt; Private</li>
</ul>
<p>이렇게 분리하는 구조를 처음부터 갖추는 게 핵심이었다.</p>
<hr>
<h2 id="10-lv1에서-겪은-트러블슈팅">10. Lv1에서 겪은 트러블슈팅</h2>
<h3 id="트러블슈팅-1---ec2에-퍼블릭-ip가-안-붙은-문제">트러블슈팅 1 - EC2에 퍼블릭 IP가 안 붙은 문제</h3>
<p>처음 EC2를 만들었을 때 퍼블릭 IP가 없어서
외부에서 접속이 불가능했다.</p>
<p>원인을 확인해보니
EC2를 <strong>Private Subnet에 잘못 생성</strong>한 상태였다.</p>
<p>즉 나는 처음에
Public Subnet에 만들어야 하는 인스턴스를
Private Subnet에 올려버린 것이다.</p>
<h3 id="해결-방법">해결 방법</h3>
<ul>
<li>기존 인스턴스 종료</li>
<li>Public Subnet에 다시 생성</li>
<li>Auto-assign public IP 활성화 확인</li>
</ul>
<h3 id="배운-점">배운 점</h3>
<p>이 경험으로
“EC2를 만든다” 와 “인터넷에서 접근 가능한 EC2를 만든다” 는 전혀 다른 문제라는 걸 배웠다.</p>
<p><strong>Subnet의 위치가 곧 인스턴스의 역할을 결정한다</strong>는 걸 체감한 순간이었다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/dd6d95a8-2f61-4a64-bda0-c6ef799a61d7/image.png" alt=""></p>
<hr>
<h3 id="트러블슈팅-2---scp를-ec2-안에서-실행한-문제">트러블슈팅 2 - <code>scp</code>를 EC2 안에서 실행한 문제</h3>
<p>jar 파일을 EC2로 업로드할 때
처음에는 <code>scp</code> 명령어를 EC2 내부에서 실행해서 실패했다.</p>
<p>당시 에러를 보니
맥북 경로인 <code>/Users/...</code> 를 EC2가 찾지 못했다.</p>
<h3 id="왜-발생했는가">왜 발생했는가</h3>
<p><code>scp</code>는 로컬 -&gt; 원격 서버로 파일을 보내는 명령어인데,
나는 EC2에 이미 들어간 상태에서
로컬 경로를 참조하려고 했던 것이다.</p>
<p>즉
<strong>명령어를 실행하는 위치를 잘못 이해</strong>한 실수였다.</p>
<h3 id="해결-방법-1">해결 방법</h3>
<ul>
<li>EC2에서 <code>exit</code></li>
<li>맥북 로컬 터미널에서 <code>scp</code> 실행</li>
</ul>
<h3 id="배운-점-1">배운 점</h3>
<p>클라우드 작업에서는
“내가 지금 어느 환경에서 명령어를 실행하고 있는가”
를 항상 명확히 구분해야 한다는 걸 배웠다.</p>
<p>이건 작은 실수처럼 보이지만
로컬 / 서버 / AWS 콘솔을 계속 오가다 보면 생각보다 자주 헷갈린다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/5c628051-7980-4a32-abc2-1c914382d80d/image.png" alt=""></p>
<hr>
<h3 id="트러블슈팅-3-----springprofilesactivelocal-위치-오류">트러블슈팅 3 - <code>--spring.profiles.active=local</code> 위치 오류</h3>
<p>jar 실행 시</p>
<pre><code class="language-bash">java -jar --spring.profiles.active=local app.jar</code></pre>
<p>식으로 잘못 입력해서
JVM 옵션으로 인식되어 실행이 실패했다.</p>
<h3 id="해결-방법-2">해결 방법</h3>
<p>Spring 옵션은 jar 뒤에 와야 했다.</p>
<pre><code class="language-bash">java -jar app.jar --spring.profiles.active=local</code></pre>
<h3 id="배운-점-2">배운 점</h3>
<p>Java 실행 명령어에서
JVM 옵션과 애플리케이션 옵션의 위치가 다르다는 걸 확실히 알게 됐다.</p>
<hr>
<h3 id="트러블슈팅-4---외부에서-actuatorhealth-접근-시-타임아웃">트러블슈팅 4 - 외부에서 <code>/actuator/health</code> 접근 시 타임아웃</h3>
<p>EC2 내부 로그상으로는 서버가 잘 떠 있었는데,
브라우저/포스트맨에서는 타임아웃이 났다.</p>
<h3 id="원인">원인</h3>
<p>애플리케이션 문제라기보다는
<strong>EC2 보안 그룹에서 8080 포트가 제대로 열려 있지 않거나</strong>,
퍼블릭 접근 설정이 잘못된 경우였다.</p>
<h3 id="해결-방법-3">해결 방법</h3>
<ul>
<li>EC2 보안 그룹 인바운드에서 8080 허용</li>
<li>EC2 내부에서 <code>curl localhost:8080/actuator/health</code> 로 내부 응답 먼저 확인</li>
<li>내부는 정상이고 외부만 안 되므로 네트워크 설정으로 범위 축소</li>
</ul>
<h3 id="배운-점-3">배운 점</h3>
<p>이 과정에서
타임아웃이 발생했다고 해서 무조건 애플리케이션 코드를 의심하면 안 되고,
<strong>내부 응답 / 외부 응답을 분리해서 확인해야 한다</strong>는 걸 배웠다.</p>
<hr>
<h2 id="11-lv1-마무리">11. Lv1 마무리</h2>
<p>최종적으로 EC2 퍼블릭 IP를 통해</p>
<ul>
<li><code>/actuator/health</code></li>
<li>멤버 생성 API</li>
<li>멤버 조회 API</li>
</ul>
<p>모두 정상 동작하는 것을 확인했다.</p>
<p>이 시점에서
“로컬 프로젝트를 AWS 위에서 직접 실행 가능한 상태로 만든 것”
자체가 굉장히 의미 있게 느껴졌다.</p>
<p>단순히 스프링 코드를 짜는 걸 넘어서
<strong>네트워크 / 서버 / 애플리케이션이 함께 맞물려야 서비스가 동작한다</strong>는 걸 처음으로 체감했다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/32c4e187-423d-4285-9997-27f140724b9f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/586a2a97-8ed4-4376-8ded-61ee23c9f712/image.png" alt=""></p>
<hr>
<h1 id="lv2---rds-분리와-parameter-store-적용">Lv2 - RDS 분리와 Parameter Store 적용</h1>
<p>Lv1이 “애플리케이션을 EC2 위에 올리는 단계”였다면,
Lv2는 <strong>운영 환경답게 DB를 분리하고 민감 정보를 외부화하는 단계</strong>였다.</p>
<p>이 단계에서 핵심은</p>
<ul>
<li>H2 -&gt; MySQL RDS 전환</li>
<li>DB 정보를 yml에 직접 쓰지 않기</li>
<li>Parameter Store 사용</li>
<li><code>/actuator/info</code> 에 외부 설정값 표시</li>
</ul>
<p>였다.</p>
<hr>
<h2 id="lv2에서-충족해야-했던-요구사항">Lv2에서 충족해야 했던 요구사항</h2>
<ul>
<li>RDS MySQL 생성</li>
<li>EC2와 RDS 보안 그룹 체이닝</li>
<li>Parameter Store에 DB 정보 저장</li>
<li>Spring Boot가 Parameter Store 값으로 RDS 연결</li>
<li><code>/actuator/info</code> 에 team-name 출력</li>
</ul>
<hr>
<h2 id="1-왜-db를-분리해야-했는가">1. 왜 DB를 분리해야 했는가</h2>
<p>Lv1까지는 H2로 충분히 기능 검증이 가능했다.
하지만 운영 환경에서는 애플리케이션 서버와 DB가 분리되어야 한다.</p>
<p>왜냐하면 서버가 죽거나 재생성되더라도
DB는 독립적으로 살아 있어야 하기 때문이다.</p>
<p>즉, EC2 안에 DB를 같이 두는 방식은
운영 관점에서는 안정성이 떨어진다.</p>
<p>그래서 Lv2에서는
<strong>애플리케이션은 EC2, 데이터는 RDS</strong>
구조로 바꿨다.</p>
<hr>
<h2 id="2-rds-생성-과정">2. RDS 생성 과정</h2>
<p>MySQL RDS를 생성하면서
초기에 DB 이름, 계정, 비밀번호를 설정했다.</p>
<p>그리고 같은 VPC 안에 두어
EC2와 RDS가 내부적으로 통신할 수 있도록 구성했다.</p>
<hr>
<h2 id="3-lv2에서-겪은-트러블슈팅">3. Lv2에서 겪은 트러블슈팅</h2>
<h3 id="트러블슈팅-1---rds-생성-시-availability-zone-조건-미충족">트러블슈팅 1 - RDS 생성 시 Availability Zone 조건 미충족</h3>
<p>처음 RDS를 만들 때</p>
<p>“DB subnet group doesn&#39;t meet Availability Zone coverage requirement”</p>
<p>에러가 발생했다.</p>
<h3 id="원인-1">원인</h3>
<p>처음 만든 subnet들이 모두 같은 AZ에만 있었기 때문이다.</p>
<p>즉 RDS는 최소 2개 AZ를 포함하는 subnet group을 요구했는데,
나는 한 개 AZ만 가진 구조로 만들고 있었던 것이다.</p>
<h3 id="해결-방법-4">해결 방법</h3>
<ul>
<li><code>ap-northeast-2c</code> 에 subnet 추가 생성</li>
<li>DB subnet group을 서로 다른 AZ의 subnet으로 구성</li>
</ul>
<h3 id="배운-점-4">배운 점</h3>
<p>RDS는 단순히 “어디 하나에 DB 하나 띄우는 서비스”가 아니라
<strong>가용성을 전제로 설계된 관리형 서비스</strong>라는 걸 알게 되었다.</p>
<hr>
<h3 id="트러블슈팅-2---rds-접속-실패-cant-connect-to-mysql-server">트러블슈팅 2 - RDS 접속 실패 (<code>Can&#39;t connect to MySQL server</code>)</h3>
<p>EC2에서 RDS endpoint로 접속하려고 했을 때
계속 접속 오류가 났다.</p>
<h3 id="원인-2">원인</h3>
<p>처음에는 RDS 보안 그룹에
EC2의 실제 보안 그룹이 아니라
RDS 자신의 default 보안 그룹만 허용된 상태였다.</p>
<p>즉,</p>
<ul>
<li>EC2 보안 그룹</li>
<li>RDS 보안 그룹</li>
</ul>
<p>이 서로 다르지만
RDS 인바운드가 EC2 SG를 허용하지 않고 있었던 것이다.</p>
<h3 id="해결-방법-5">해결 방법</h3>
<ul>
<li>EC2가 실제로 사용하는 SG 확인</li>
<li>RDS 인바운드에서 <code>MySQL/Aurora 3306</code></li>
<li>Source를 <strong>EC2의 Security Group</strong> 으로 추가</li>
</ul>
<h3 id="배운-점-5">배운 점</h3>
<p>이 경험을 통해
<strong>Security Group Chaining</strong> 개념을 확실하게 이해하게 됐다.</p>
<p>IP 주소를 직접 허용하는 방식이 아니라
특정 보안 그룹을 가진 리소스만 통신하게 만드는 방식이
AWS에서 훨씬 일반적이고 안전하다는 걸 배웠다.</p>
<hr>
<h3 id="트러블슈팅-3---iam-role이-없어서-parameter-store-값을-못-읽은-문제">트러블슈팅 3 - IAM Role이 없어서 Parameter Store 값을 못 읽은 문제</h3>
<p>초기에는 <code>/actuator/info</code>가 계속 <code>{}</code> 만 나왔다.</p>
<p>처음에는 yml 문제인가 싶었는데,
확인해보니 EC2에 <strong>IAM Role이 없었다.</strong></p>
<p>즉 애플리케이션이 Parameter Store를 읽으려고 해도
권한 자체가 없었던 것이다.</p>
<h3 id="해결-방법-6">해결 방법</h3>
<ul>
<li>EC2용 IAM Role 생성</li>
<li><code>AmazonSSMReadOnlyAccess</code> 추가</li>
<li>EC2에 Role 연결</li>
<li>애플리케이션 재실행</li>
</ul>
<h3 id="배운-점-6">배운 점</h3>
<p>클라우드에서는
코드가 맞아도 <strong>권한이 없으면 기능이 동작하지 않는다</strong>는 걸 확실히 느꼈다.</p>
<p>특히 AWS에서는
“설정값을 읽는다”는 행위조차 IAM 권한이 필요한 API 호출이라는 점이 인상적이었다.</p>
<p>(이미지 첨부)</p>
<p>(이미지 첨부)</p>
<hr>
<h3 id="트러블슈팅-4---unknown-database-cloud_architecture">트러블슈팅 4 - Unknown database <code>cloud_architecture</code></h3>
<p>Parameter Store를 읽고, RDS 연결도 되는데
애플리케이션 실행 시</p>
<pre><code class="language-text">Unknown database &#39;cloud_architecture&#39;</code></pre>
<p>오류가 발생했다.</p>
<h3 id="원인-3">원인</h3>
<p>RDS 인스턴스는 생성되었지만,
실제 애플리케이션이 접속할 데이터베이스 스키마 <code>cloud_architecture</code> 가 생성되지 않은 상태였다.</p>
<h3 id="해결-방법-7">해결 방법</h3>
<p>EC2에서 mysql client로 RDS에 접속해서</p>
<pre><code class="language-sql">CREATE DATABASE cloud_architecture;</code></pre>
<p>를 실행했다.</p>
<h3 id="배운-점-7">배운 점</h3>
<p>RDS 인스턴스를 만들었다고 해서
애플리케이션이 사용할 스키마까지 자동으로 완성된 것은 아니라는 걸 배웠다.</p>
<p>즉,
<strong>RDS 인스턴스 생성</strong> 과
<strong>애플리케이션용 DB 준비 완료</strong> 는 다른 단계였다.</p>
<hr>
<h2 id="4-parameter-store를-적용한-이유">4. Parameter Store를 적용한 이유</h2>
<p>이 단계에서 가장 중요하게 생각한 건
DB URL, username, password 같은 민감한 값을
코드나 yml에 직접 쓰지 않는 것이었다.</p>
<p>그래서 Parameter Store에 다음 값을 저장했다.</p>
<pre><code class="language-text">/cloud/db/url
/cloud/db/username
/cloud/db/password
/cloud/team-name</code></pre>
<p>이렇게 외부화한 이유는</p>
<ul>
<li>운영환경 변경 시 코드 수정 최소화</li>
<li>민감 정보 Git 노출 방지</li>
<li>설정값 중앙 관리</li>
</ul>
<p>를 위해서였다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/f8378f6a-deb4-4ecf-827b-1920fdaaf7d9/image.png" alt=""></p>
<hr>
<h2 id="5-actuatorinfo-로-설정값-확인">5. <code>/actuator/info</code> 로 설정값 확인</h2>
<p><code>/cloud/team-name</code> 값을 Parameter Store에 저장하고,
Spring Boot에서 이를 읽어 <code>/actuator/info</code> 로 노출되게 구성했다.</p>
<p>최종적으로</p>
<pre><code class="language-json">{
  &quot;app&quot;: {
    &quot;name&quot;: &quot;cloud-architecture&quot;
  },
  &quot;team-name&quot;: &quot;sparta-cloud-team&quot;
}</code></pre>
<p>형태가 출력되는 걸 확인했다.</p>
<p>이 순간이 사실 Lv2의 완성 포인트라고 느꼈다.</p>
<p>왜냐하면 이 결과는 단순히 info endpoint가 열린 게 아니라,</p>
<ul>
<li>EC2가 IAM Role을 통해</li>
<li>Parameter Store 값을 읽고</li>
<li>Spring Boot 환경에 주입해서</li>
<li>actuator info로 노출했다</li>
</ul>
<p>는 걸 의미하기 때문이다.</p>
<h4 id="actuator-확인">actuator 확인</h4>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/ff6d1116-f1db-422e-b60a-a422ecece2d5/image.png" alt=""></p>
<hr>
<h2 id="6-lv2-마무리">6. Lv2 마무리</h2>
<p>Lv2를 끝내면서
단순히 EC2에 앱을 띄우는 수준에서 벗어나
<strong>운영용 DB와 설정값 관리 체계까지 갖춘 구조</strong>를 만들 수 있었다.</p>
<p>개인적으로는 이 레벨에서
“클라우드 환경에서는 코드보다 설정과 권한, 연결 구조가 더 중요할 수 있다”
는 걸 가장 크게 느꼈다.</p>
<hr>
<h1 id="lv3---s3-이미지-업로드와-presigned-url">Lv3 - S3 이미지 업로드와 Presigned URL</h1>
<p>Lv3의 목표는 명확했다.</p>
<p><strong>프로필 이미지를 서버 디스크가 아니라 S3에 저장하고, Presigned URL을 통해서만 조회 가능하게 만든다.</strong></p>
<p>이전까지는 텍스트 데이터 중심이었다면,
Lv3는 <strong>파일을 어떻게 저장하고 안전하게 제공할 것인가</strong> 를 다루는 단계였다.</p>
<hr>
<h2 id="lv3에서-충족해야-했던-요구사항">Lv3에서 충족해야 했던 요구사항</h2>
<ul>
<li>S3 버킷 생성</li>
<li>퍼블릭 액세스 차단 유지</li>
<li>EC2 IAM Role에 S3 권한 부여</li>
<li><code>POST /api/members/{id}/profile-image</code></li>
<li><code>GET /api/members/{id}/profile-image</code></li>
<li>Presigned URL 유효기간 7일</li>
</ul>
<hr>
<h2 id="1-왜-서버-디스크가-아니라-s3를-써야-하는가">1. 왜 서버 디스크가 아니라 S3를 써야 하는가</h2>
<p>처음에는 이미지도 서버 내부에 저장하면 되지 않을까 싶었지만,
운영 환경에서는 그 방식이 위험하다.</p>
<p>왜냐하면 EC2는 언제든 교체되거나 재배포될 수 있기 때문이다.</p>
<p>즉, 이미지 파일을 EC2 디스크에 저장하면</p>
<ul>
<li>인스턴스 교체 시 파일 유실 가능</li>
<li>여러 서버 운영 시 파일 동기화 문제</li>
<li>서버 역할과 파일 저장 역할이 섞임</li>
</ul>
<p>이런 문제가 생긴다.</p>
<p>그래서 파일은
<strong>S3 같은 전용 저장소에 분리하는 것이 맞다</strong>고 이해하고 구현했다.</p>
<hr>
<h2 id="2-s3-버킷-생성">2. S3 버킷 생성</h2>
<p>S3 버킷은 퍼블릭 액세스를 차단한 상태로 생성했다.</p>
<p>이렇게 한 이유는
이미지를 단순 public URL로 바로 노출하는 것이 아니라
필요할 때만 Presigned URL을 발급해서 접근하게 만들기 위해서다.</p>
<p>즉
“이미지는 S3에 저장하지만, 누구나 아무 때나 직접 볼 수는 없도록”
구성한 것이다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/09f920b8-3685-4e90-a2bf-4ff907e2977a/image.png" alt=""></p>
<hr>
<h2 id="3-iam-role에-s3-권한-추가">3. IAM Role에 S3 권한 추가</h2>
<p>EC2에서 S3에 업로드하려면
애플리케이션이 S3 API를 호출할 수 있어야 한다.</p>
<p>그래서 기존 EC2 IAM Role에
S3 접근 권한을 추가했다.</p>
<p>이 과정을 통해
Parameter Store뿐 아니라 S3도
<strong>Access Key를 코드에 넣는 방식이 아니라 IAM Role 기반으로 접근</strong>하게 구성했다.</p>
<p>이 방식이 더 안전하고,
운영 환경에서도 권장되는 방식이라고 느꼈다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/871a5a2d-433d-4145-ba99-371211a378b7/image.png" alt=""></p>
<hr>
<h2 id="4-프로필-이미지-업로드-api-구현">4. 프로필 이미지 업로드 API 구현</h2>
<h3 id="api-1">API</h3>
<p><code>POST /api/members/{id}/profile-image</code></p>
<p>요청은 <code>MultipartFile</code> 형태로 받고,
서비스 로직에서는</p>
<ul>
<li>멤버 존재 확인</li>
<li>이미지 파일 검증</li>
<li>S3 업로드</li>
<li>S3 object key 생성</li>
<li>DB의 <code>profileImageKey</code> 업데이트</li>
</ul>
<p>순서로 처리했다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/1c2d0db4-7eb1-470a-b103-4e73a89137d7/image.png" alt=""></p>
<h3 id="왜-전체-url이-아니라-key를-저장했는가">왜 전체 URL이 아니라 key를 저장했는가</h3>
<p>처음에는 S3 URL을 그대로 DB에 저장할 수도 있다고 생각했지만,
나중에 Presigned URL을 다시 생성하려면
버킷 + key 기반으로 처리하는 게 훨씬 깔끔했다.</p>
<p>그래서 DB에는 최종 URL이 아니라</p>
<pre><code class="language-text">members/1/uuid.jpeg</code></pre>
<p>같은 <strong>S3 key</strong> 를 저장하도록 했다.</p>
<p>이렇게 하면</p>
<ul>
<li>URL 정책이 바뀌어도 DB 구조는 유지 가능</li>
<li>Presigned URL 재발급이 쉬움</li>
<li>저장소 구조를 더 유연하게 관리 가능</li>
</ul>
<p>하다고 판단했다.</p>
<hr>
<h2 id="5-presigned-url-조회-api-구현">5. Presigned URL 조회 API 구현</h2>
<h3 id="api-2">API</h3>
<p><code>GET /api/members/{id}/profile-image</code></p>
<p>이 API는 실제 파일 데이터를 직접 반환하는 게 아니라,
S3 객체에 접근할 수 있는 <strong>서명된 URL</strong> 을 생성해서 반환한다.</p>
<p>그리고 과제 요구사항에 맞게
유효기간을 <strong>7일</strong>로 설정했다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/6f4c1f7c-d8dd-4ee9-8ec4-5188c092bb92/image.png" alt=""></p>
<h3 id="왜-presigned-url을-사용했는가">왜 Presigned URL을 사용했는가</h3>
<p>버킷은 퍼블릭 차단 상태이기 때문에
그냥 S3 주소만으로는 이미지를 볼 수 없다.</p>
<p>그래서 필요할 때만
유효기간이 있는 임시 URL을 발급해서
안전하게 다운로드하도록 만든 것이다.</p>
<p>이 방식은 다음 장점이 있다.</p>
<ul>
<li>버킷 자체는 private 유지</li>
<li>필요한 사용자만 일정 기간 접근 가능</li>
<li>파일 서버를 직접 구현하지 않아도 됨</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/12aa73e2-53c0-40ac-b667-873d96709cd9/image.png" alt=""></p>
<hr>
<h2 id="6-lv3에서-겪은-트러블슈팅">6. Lv3에서 겪은 트러블슈팅</h2>
<h3 id="트러블슈팅-1---s3presigner-심볼빈-문제">트러블슈팅 1 - <code>S3Presigner</code> 심볼/빈 문제</h3>
<p>처음 S3 기능을 붙일 때
<code>S3Presigner</code> 를 인식하지 못하고, 빈 주입도 실패했다.</p>
<h3 id="원인-4">원인</h3>
<ul>
<li>S3 starter 의존성이 없었고</li>
<li>Presigner 빈도 명시적으로 등록되지 않은 상태였다.</li>
</ul>
<h3 id="해결-방법-8">해결 방법</h3>
<ul>
<li><code>spring-cloud-aws-starter-s3</code> 추가</li>
<li><code>S3Config</code>에서 <code>S3Client</code>, <code>S3Presigner</code> 직접 빈 등록</li>
</ul>
<h3 id="배운-점-8">배운 점</h3>
<p>자동 설정이 많아도
상황에 따라서는 <strong>핵심 빈을 직접 명시적으로 등록하는 편이 더 안정적</strong>일 수 있다는 걸 배웠다.</p>
<hr>
<h3 id="트러블슈팅-2---테스트-환경에서-s3-설정값이-없어-빌드-실패">트러블슈팅 2 - 테스트 환경에서 S3 설정값이 없어 빌드 실패</h3>
<p>S3 기능 추가 후 <code>contextLoads()</code> 테스트가 실패했다.</p>
<h3 id="원인-5">원인</h3>
<p>테스트 환경에서</p>
<ul>
<li><code>cloud.aws.s3.bucket</code></li>
<li><code>cloud.aws.region.static</code></li>
</ul>
<p>같은 설정값이 없어
S3 관련 빈 생성이 실패한 것이었다.</p>
<h3 id="해결-방법-9">해결 방법</h3>
<p><code>application.yml</code> 또는 local/test 환경에
더미 값을 넣어 테스트가 실패하지 않도록 했다.</p>
<h3 id="배운-점-9">배운 점</h3>
<p>운영 환경 설정이 늘어날수록
테스트 환경도 같이 고려해야 한다는 걸 배웠다.</p>
<p>기능만 추가한다고 끝이 아니라,
<strong>테스트가 그 설정을 어떻게 읽을지도 함께 설계해야 한다</strong>는 점이 중요했다.</p>
<hr>
<h2 id="7-lv3-최종-검증">7. Lv3 최종 검증</h2>
<p>최종적으로 아래 세 가지를 모두 확인했다.</p>
<h3 id="1-프로필-이미지-업로드-성공">1) 프로필 이미지 업로드 성공</h3>
<ul>
<li><code>201 Created</code></li>
<li><code>profileImageKey</code> 반환</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/28880996-83ea-4219-988e-29ee1a74cc9d/image.png" alt=""></p>
<h3 id="2-presigned-url-조회-성공">2) Presigned URL 조회 성공</h3>
<ul>
<li><code>200 OK</code></li>
<li>URL 반환</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/e28d8f84-c575-4fec-9aca-ed2a0ef756c1/image.png" alt=""></p>
<h3 id="3-브라우저에서-이미지-실제-열림">3) 브라우저에서 이미지 실제 열림</h3>
<ul>
<li>반환된 URL을 브라우저에 붙였을 때 이미지 확인</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/a8ac7fd8-1efc-48a3-b775-0113044fd093/image.png" alt=""></p>
<p>이 과정까지 확인했기 때문에
Lv3는 단순히 API 응답만 만든 게 아니라
<strong>실제로 S3 업로드 -&gt; DB 저장 -&gt; Presigned URL 발급 -&gt; 이미지 조회</strong>
전체 흐름이 정상 동작하는 걸 검증하였다.</p>
<hr>
<h2 id="이번-과제를-통해-배운-것">이번 과제를 통해 배운 것</h2>
<h3 id="1-서버가-돌아가는-것과-운영-가능한-것은-다르다">1. 서버가 돌아가는 것과 운영 가능한 것은 다르다</h3>
<p>로컬에서 API가 되는 것과,
EC2 / RDS / S3 / IAM / Parameter Store까지 연결되어 운영 가능한 구조를 만드는 것은 전혀 다른 문제였다.</p>
<h3 id="2-클라우드에서는-권한과-네트워크가-정말-중요하다">2. 클라우드에서는 권한과 네트워크가 정말 중요하다</h3>
<p>코드가 맞아도</p>
<ul>
<li>보안 그룹이 틀리면 연결이 안 되고</li>
<li>IAM Role이 없으면 설정을 못 읽고</li>
<li>Subnet 위치가 틀리면 외부 접속이 안 된다</li>
</ul>
<p>이걸 몸으로 배웠다.</p>
<h3 id="3-데이터와-파일은-서버와-분리되어야-한다">3. 데이터와 파일은 서버와 분리되어야 한다</h3>
<p>RDS와 S3를 붙이면서
애플리케이션 서버는 “처리 담당”,
데이터와 파일은 별도 저장소 담당
이라는 구조가 왜 중요한지 이해하게 되었다.</p>
<hr>
<h1 id="마무리">마무리</h1>
<p>이번 필수 과제를 통해
Spring Boot 애플리케이션을 단순히 “개발”하는 것에서 끝나지 않고,
<strong>AWS 위에서 실제로 운영 가능한 구조로 확장하는 경험</strong>을 할 수 있었다.</p>
<p>특히</p>
<ul>
<li>EC2 배포</li>
<li>RDS 분리</li>
<li>Parameter Store 설정 관리</li>
<li>S3 업로드와 Presigned URL</li>
</ul>
<p>까지 직접 해본 경험은
이후 Docker, CI/CD, ALB, Auto Scaling 같은 도전 과제를 진행할 때도
큰 기반이 될 것 같다고 느꼈다.</p>
<p>다음 단계에서는 이 구조를 바탕으로
Docker와 CI/CD를 붙여
더 자동화된 배포 구조로 확장해볼 예정이다.</p>
<hr>
<h1 id="회고">회고</h1>
<p>처음에는 “AWS는 어렵다”는 느낌이 강했는데,
이번 과제를 하면서 느낀 건
AWS가 어려운 게 아니라 <strong>구조를 이해하지 않은 상태에서 한 번에 하려고 하면 어렵다</strong>는 것이었다.</p>
<p>레벨별로 하나씩 쌓아가면서 보니
각 서비스가 왜 존재하는지, 왜 그렇게 연결해야 하는지가 조금씩 보이기 시작했다.</p>
<h3 id="github-링크">github 링크</h3>
<p><a href="https://github.com/jiiimni/cloud-architecture/tree/main">프로젝트 github 링크</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] 클라우드 기반 백엔드 설계 - CH2 운영형 Spring Boot]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EA%B8%B0%EB%B0%98-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%84%A4%EA%B3%84-%EC%9A%B4%EC%98%81%ED%98%95-Spring-Boot</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EA%B8%B0%EB%B0%98-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%84%A4%EA%B3%84-%EC%9A%B4%EC%98%81%ED%98%95-Spring-Boot</guid>
            <pubDate>Wed, 11 Mar 2026 01:57:10 GMT</pubDate>
            <description><![CDATA[<h1 id="운영형-spring-boot">운영형 Spring Boot</h1>
<h2 id="운영-가능이란">운영 가능이란?</h2>
<hr>
<table>
<thead>
<tr>
<th>구분</th>
<th>개발 완료</th>
<th>운영 가능</th>
</tr>
</thead>
<tbody><tr>
<td>정의</td>
<td>기능이 동작함</td>
<td>배포 후에도 관리 가능</td>
</tr>
<tr>
<td>상태 확인</td>
<td>❌ &quot;돌아가는 것 같다&quot;</td>
<td>✅ 헬스체크로 확인 가능</td>
</tr>
<tr>
<td>장애 대응</td>
<td>❌ &quot;왜 안 되지?&quot;</td>
<td>✅ 로그로 원인 추적 가능</td>
</tr>
<tr>
<td>환경 대응</td>
<td>❌ &quot;내 PC에서는 됐는데&quot;</td>
<td>✅ 설정 분리로 환경별 대응</td>
</tr>
</tbody></table>
<p>단순히 개발이 완료된 상태와 운영 가능한 상태는 다름!</p>
<p>최종 목표는 코드의 완성이 아니라, <strong>운영이 가능한 상태로 만드는 것</strong></p>
<h3 id="운영형이-중요한-이유">“운영형”이 중요한 이유</h3>
<ol>
<li><strong>새벽 3시 장애 발생</strong><ul>
<li>❌ 운영 불가: &quot;서버 들어가서 확인해봐야...&quot; → 2시간 소요</li>
<li>✅ 운영 가능: 헬스체크 DOWN → 로그 확인 → 10분 내 원인 파악</li>
</ul>
</li>
<li><strong>스테이징 → 프로덕션 배포</strong><ul>
<li>❌ 운영 불가: DB 설정 하드코딩 → 프로덕션 DB 연결 실패</li>
<li>✅ 운영 가능: 환경변수로 설정 주입 → 무사히 배포</li>
</ul>
</li>
</ol>
<h2 id="운영-3요소">운영 3요소</h2>
<p>Health, Log, Config</p>
<h3 id="1-health헬스체크">1. Health(헬스체크)</h3>
<p>목적: 서버가 정상 동작 중인지 확인</p>
<ul>
<li>Up: 정상</li>
<li>Down: 문제 발생<pre><code># 헬스체크 요청
curl http://localhost:8080/actuator/health
</code></pre></li>
</ul>
<h1 id="응답-예시">응답 예시</h1>
<p>{&quot;status&quot;:&quot;UP&quot;}</p>
<pre><code>
### 2. Log(로그)
목적: 장애 발생 시 원인 추적</code></pre><p>2024-01-15 10:30:45 INFO  - Application started
2024-01-15 10:31:02 ERROR - Database connection failed
2024-01-15 10:31:02 ERROR - java.sql.SQLException: Connection refused</p>
<pre><code>
&gt; 로그를 보면 DB 연결 실패가 원인임을 알 수 있음

### Config(설정)
목적: 환경(로컬/개발/운영)별 다른 설정 적용
- local - DB 호스트: localhost / 로그 레벨: DEBUG
- prod - DB 호스트: rds.amazonaws.com / 로그 레벨: INFO

&gt; 코드 수정 없이 환경 변수로 설정

---

## Spring Boot Actuators
**Spring Boot Actuator**는 앱의 상태, 메트릭, 환경 정보를 HTTP 엔드포인트로 제공하는 라이브러리. 의존성을 추가하면 `/actuator/health`, `/actuator/info` 같은 엔드포인트가 자동으로 생성됨

#### 의존성 추가</code></pre><p>implementation &#39;org.springframework.boot:spring-boot-starter-actuator&#39;</p>
<pre><code>
### Actuator 주요 엔드포인트
| 엔드포인트 | 용도 | 민감도 | 기본 노출 |
|---|---|---|---|
| /actuator/health | 앱 상태 확인 | 🟢 낮음 | ✅ |
| /actuator/info | 앱 정보 | 🟢 낮음 | ❌ |
| /actuator/metrics | 성능 지표 | 🟡 중간 | ❌ |
| /actuator/env | 환경변수, 설정값 | 🔴 높음 | ❌ |
| /actuator/configprops | 모든 설정값 | 🔴 높음 | ❌ |
| /actuator/heapdump | JVM 메모리 덤프 | 🔴 높음 | ❌ |
| /actuator/threaddump | 스레드 정보 | 🟡 중간 | ❌ |

---

## 로그 레벨
로그 레벨(Log Level)은 로그의 심각도를 나타내는 분류. 개발 시에는 상세한 DEBUG 로그가 필요하지만, 운영 시에는 성능을 위해 INFO 이상만 출력함

| 로그 레벨 | 용도 | 예시 |
|---|---|---|
| ERROR | 에러, 예외 상황 | DB 연결 실패, NPE |
| WARN | 경고, 잠재적 문제 | 느린 쿼리, 재시도 발생 |
| INFO | 일반 정보 | 앱 시작, 요청 처리 완료 |
| DEBUG | 디버깅용 상세 정보 | 변수 값, SQL 쿼리 |
| TRACE | 매우 상세한 정보 | 메서드 진입/종료 |

&gt; Spring Boot에서는 SLF4J를 사용하여 로그를 출력함
-&gt; @Slf4j

---

## Spring Boot Profile별 설정
파일명: 활성화되는 profile

- application-local.properties: local
- application-prod.propertie : prod
- application-dev.properties: dev

---

## AWS Parameter Store
설정값을 안전하게 저장하고 관리하는 서비스

| 파라미터 이름 | Type | 용도 |
|---|---|---|
| /{app이름}/prod/DB_HOST | String | DB 엔드포인트 |
| /{app이름}/prod/DB_PORT | String | 3306 (MySQL port) |
| /{app이름}/prod/DB_NAME | String | 데이터베이스명 |
| /{app이름}/prod/DB_USERNAME | String | 사용자명 |
| /{app이름}/prod/DB_PASSWORD | String | 비밀번호 |

---

## EC2에 Spring 앱 배포 실습

### 배포를 위한 Spring 프로젝트 세팅
![](https://velog.velcdn.com/images/jiiim_ni/post/7a619a0c-1bdc-40d4-8bb4-718b6260939f/image.png)

- 의존성
- 환경변수
- JAR 빌드

---

### EC2 세팅 및 배포

#### 보안그룹
- 인바운드 규칙으로 8080포트 추가 완료

#### Java 설치
![](https://velog.velcdn.com/images/jiiim_ni/post/7b65e80e-870d-48a1-83c2-efe2748c3504/image.png)

---

### JAR 업로드</code></pre><h1 id="기본-형식">기본 형식</h1>
<p>scp -i &lt;키페어경로&gt; &lt;로컬파일&gt; &lt;사용자&gt;@<EC2-IP>:~/app.jar</p>
<h1 id="예시">예시</h1>
<p>scp -i <del>/.ssh/my-key.pem app.jar ec2-user@{EC2-IP}:</del>/app.jar</p>
<pre><code>![](https://velog.velcdn.com/images/jiiim_ni/post/e1e859f9-681e-437b-8c82-6009d1b55cb6/image.png)

---
### IAM Role 설정

#### 권한 추가

![](https://velog.velcdn.com/images/jiiim_ni/post/0b7dcd61-8e47-44fb-8287-d49af6adc3bd/image.png)

---
### 포그라운드 실행

![](https://velog.velcdn.com/images/jiiim_ni/post/90bb09e7-a177-4520-bf75-cb05b50d0d2c/image.png)
-&gt; 역할을 부여하니 잘 실행되는 모습
![](https://velog.velcdn.com/images/jiiim_ni/post/6871f6c8-71d9-4a8f-a8e3-c7d9681d8b2f/image.png)
- status: UP
---

### 백그라운드 실행
![](https://velog.velcdn.com/images/jiiim_ni/post/aafe02f3-dab3-40fa-b7e9-906dd8ac4c47/image.png)

![](https://velog.velcdn.com/images/jiiim_ni/post/cf8586db-5237-414f-9e46-8dbb659c26e6/image.png)

- status: UP

---

### Parameter Store 검증
![](https://velog.velcdn.com/images/jiiim_ni/post/a9ce0e22-9d11-4200-a914-31715b2245e3/image.png)

---

## 회고 / 알게 된 점

처음에는 **Spring Boot Actuator, 로그 설정, 환경 설정 분리** 같은 것들이 왜 필요한지 잘 이해되지 않았다.  
단순히 서버가 실행되고 API가 정상 동작하면 개발이 완료된 것이라고 생각했기 때문이다.

하지만 EC2에 직접 애플리케이션을 배포하고 실행 과정을 확인하면서  
**“개발 완료”와 “운영 가능한 상태”는 다르다는 것을 이해할 수 있었다.**

---

### Actuator의 역할

처음에는 `/actuator/health` 같은 엔드포인트가 어디에 쓰이는지 감이 오지 않았다.  
하지만 EC2에 배포한 후 직접 요청을 보내보면서 서버 상태를 확인할 수 있었다.
</code></pre><p>curl http://{EC2-IP}:8080/actuator/health</p>
<pre><code>



응답

</code></pre><p>{&quot;status&quot;:&quot;UP&quot;}</p>
<pre><code>


이 결과를 통해 **서버가 정상적으로 동작하고 있는지 외부에서 확인할 수 있다는 점**을 이해했다.

즉 단순히 서버가 실행된 것이 아니라  
**서비스 상태를 확인할 수 있는 구조가 운영 환경에서는 중요하다는 것을 알게 되었다.**

---

### 로그의 중요성

개발 단계에서는 로그를 크게 신경 쓰지 않았다.  
하지만 운영 환경에서는 문제가 발생했을 때 **로그가 원인을 파악할 수 있는 중요한 정보**가 된다.

예를 들어 다음과 같은 로그가 있다면
</code></pre><p>ERROR - Database connection failed
java.sql.SQLException: Connection refused</p>
<pre><code>
단순히 &quot;서버가 동작하지 않는다&quot;가 아니라  
**DB 연결 문제라는 원인을 빠르게 파악할 수 있다.**

그래서 운영 환경에서는 상황에 맞게 **로그 레벨을 설정하는 것이 중요하다는 것도 이해할 수 있었다.**

---

### 환경 설정 분리

개발 환경과 운영 환경은 사용하는 설정이 다르다.

예를 들어

- local → DB: localhost
- prod → DB: AWS RDS

만약 이러한 설정이 코드에 하드코딩되어 있다면  
환경이 바뀔 때마다 코드를 수정해야 하는 문제가 발생한다.

그래서

- `application-local`
- `application-dev`
- `application-prod`

같이 **Profile 기반 설정 분리**를 사용하고  
DB 계정이나 비밀번호 같은 민감한 값은 **AWS Parameter Store**를 통해 관리하는 방식이 필요하다는 것을 이해했다.

---

### 느낀 점

이번 실습을 통해 단순히 기능을 구현하는 것에서 끝나는 것이 아니라  

- 서버 상태 확인 (Health)
- 문제 원인 추적 (Log)
- 환경별 설정 관리 (Config)

이 세 가지가 있어야 **실제로 운영 가능한 애플리케이션이 된다는 것을 이해할 수 있었다.**</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] 클라우드 기반 백엔드 설계 - CH1 클라우드와 AWS 기초 + 실습]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EA%B8%B0%EB%B0%98-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%84%A4%EA%B3%84-CH1-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EC%99%80-AWS-%EA%B8%B0%EC%B4%88</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EA%B8%B0%EB%B0%98-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%84%A4%EA%B3%84-CH1-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EC%99%80-AWS-%EA%B8%B0%EC%B4%88</guid>
            <pubDate>Tue, 10 Mar 2026 01:11:09 GMT</pubDate>
            <description><![CDATA[<h1 id="클라우드">클라우드</h1>
<h2 id="웹사이트-동작-방식">웹사이트 동작 방식</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/b28e91a3-7587-4abd-bdaf-e6e26a701bf7/image.png" alt=""></p>
<h3 id="1-클라이언트client---요청하는-주체">1. 클라이언트(Client) - 요청하는 주체</h3>
<ul>
<li>정의: 서비스를 요청하는 사용자 기기나 소프트웨어</li>
<li>예시: 브라우저(크롬, 사파리 등)</li>
<li>역할: 사용자의 명령을 받아 서버에 &quot;이 작업을 처리해줘&quot;라고 요청(Request)을 보냄</li>
</ul>
<h3 id="2-네트워크network---데이터의-통로">2. 네트워크(Network) - 데이터의 통로</h3>
<ul>
<li>정의: 클라이언트와 서버가 서로 데이터를 주고받을 수 있게 연결해 주는 망</li>
<li>예시: 인터넷</li>
<li>역할: 클라이언트가 보낸 요청을 서버로 전달하고, 반대로 서버가 만든 결과물을 다시 클라이언트에게 안전하게 배달하는 역할. 이때 서로 대화가 통하도록 주로 HTTP라는 일종의 대화 규칙(Protocol, 프로토콜)을 사용</li>
</ul>
<h3 id="3-서버server---제공하는-주체">3. 서버(Server) - 제공하는 주체</h3>
<ul>
<li>정의: 클라이언트의 요청을 받아 데이터를 처리하고 결과를 되돌려주는 컴퓨터 시스템</li>
<li>예시: 네이버 서버, 게임 서버</li>
<li>역할: 24시간 깨어 있으면서 클라이언트의 요청(Request)이 오기를 기다림. 요청이 오면 필요한 정보를 찾거나 계산한 뒤, 다시 네트워크를 통해 클라이언트에게 응답(Response)을 보냄</li>
</ul>
<hr>
<h2 id="온프레미스-vs-클라우드">온프레미스 vs 클라우드</h2>
<h3 id="온프레미스on-premise">온프레미스(On-Premise)</h3>
<p>회사가 직접 서버를 구매하고, 자체 서버실에서 운영하는 방식</p>
<ul>
<li>서버 컴퓨터를 직접 구매해야함</li>
<li>설치/관리를 직접 해야함</li>
<li>전기/냉방 비용이 듬</li>
<li>네트워크 구축과 관리를 해야함</li>
</ul>
<h3 id="클라우드cloud">클라우드(Cloud)</h3>
<p>AWS, GCP, Azure 같은 업체의 서버를 빌려서 사용하는 방식</p>
<ul>
<li>수만~수십만 대의 서버가 이미 준비되어 있음</li>
<li>필요할 때 클릭 몇 번으로 서버를 할당받을 수 있음</li>
<li>필요가 없어졌다면 클릭 몇 번으로 서버를 반납할 수 있음</li>
</ul>
<blockquote>
<p>온프레미스 = 자가용
클라우드 = 렌트카</p>
</blockquote>
<hr>
<h2 id="iaaspaassaas">IaaS/Paas/Saas</h2>
<h3 id="iaas-infrastructure-as-a-service">IaaS (Infrastructure as a Service)</h3>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/fc3c7d95-da26-4a4e-ab16-b2f057364817/image.png" alt="">
인프라만 빌린다 - 서버, 네트워크, 스토리지</p>
<h3 id="paas-platform-as-a-service">PaaS (Platform as a Service)</h3>
<p>플랫폼까지 빌린다 - 런타임, 미들웨어 포함
AWS 예시: Elastic Beanstalk
AWS 제공: 서버/네트워크, 운영체제, 런타임, 오토스케일링, 로드밸런싱
내가 해야하는 것: 애플리케이션 코드 작성/배포</p>
<h3 id="saas-software-as-a-service">SaaS (Software as a Service)</h3>
<p>소프트웨어를 빌린다 - 완성된 서비스 사용
업체가 제공: 모든 것
내가 하는 것: 툴 사용</p>
<hr>
<h1 id="aws">AWS</h1>
<p>AWS는 전 세계에 분산된 데이터센터를 가지고 있고, 우리는 그 중 원하는 위치를 선택해서 사용하게 됨</p>
<h3 id="데이터-센터란">데이터 센터란?</h3>
<p>데이터 센터는 컴퓨팅 시스템 및 하드웨어 장비를 저장하는 물리적 위치.
여기에는 서버, 데이터 스토리지 드라이브 및 네트워크 장비와 같이 IT 시스템에 필요한 컴퓨팅 인프라가 포함됨
모든 회사의 디지털 데이터를 저장하는 물리적 시설
<img src="https://velog.velcdn.com/images/jiiim_ni/post/66fff2ea-c244-4c69-8928-d1ef814d1e88/image.png" alt=""></p>
<h3 id="aws-서비스-예시">AWS 서비스 예시</h3>
<ul>
<li>아무거나 올릴 수 있는 서버가 필요해: <strong>EC2</strong></li>
<li>데이터베이스가 필요해: <strong>RDS</strong></li>
<li>이미지/파일을 저장해야해: <strong>S3</strong>
<img src="https://velog.velcdn.com/images/jiiim_ni/post/ccd8b6c7-0620-4764-b55a-4bb69476e350/image.png" alt=""></li>
</ul>
<hr>
<h1 id="iam">IAM</h1>
<h2 id="userrolepolicy">User/Role/Policy</h2>
<p>IAM에는 핵심 요소 3가지가 있습니다.</p>
<h3 id="1-user">1. User</h3>
<p>&quot;사람&quot;이 AWS에 접근할 때 사용하는 계정</p>
<ul>
<li>개발자가 AWS 콘솔(웹사이트)에 로그인할 때</li>
<li>관리자가 CLI(명령줄)로 AWS를 관리할 때</li>
<li>사람이 직접 AWS를 조작하는 모든 경우</li>
</ul>
<p><strong>핵심 특징: 영구적인 자격증명(Credential)을 가짐</strong></p>
<blockquote>
<p>💡 <strong>자격증명(Credential)이란?</strong></p>
<p>본인임을 증명하는 정보 (비밀번호, 키 등)</p>
<ul>
<li>User가 가지는 자격증명 2가지<ul>
<li>비밀번호: AWS 콘솔(웹사이트) 로그인용</li>
<li>Access Key: API/CLI(명령줄) 접근용<ul>
<li>Access Key ID: 아이디 역할</li>
<li>Secret Access Key: 비밀번호 역할</li>
</ul>
</li>
</ul>
</li>
</ul>
</blockquote>
<ul>
<li>특징<ul>
<li>한 번 생성하면 삭제하기 전까지 유효 (만료 없음)</li>
<li>1인 1계정 원칙 권장</li>
</ul>
</li>
</ul>
<h3 id="2-role">2. Role</h3>
<p>&quot;서버/서비스&quot;에게 권한을 부여할 때 사용</p>
<ul>
<li>EC2 서버가 S3에 파일을 업로드할 때</li>
<li>GitHub Actions가 AWS에 배포할 때</li>
<li>서버/자동화 시스템이 AWS를 사용하는 모든 경우</li>
</ul>
<p><strong>핵심 특징: 임시로 빌려 쓰는 권한 세트</strong></p>
<h3 id="3-policy">3. Policy</h3>
<p>“어떤 작업을 허용/거부”할지 정의</p>
<p>Policy 자체를 단독으로 쓰는 것이 아님!</p>
<p>Policy를 User나 Role에 “연결”하는 형태</p>
<pre><code>{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,              ← 허용? 거부?
      &quot;Action&quot;: &quot;s3:GetObject&quot;,       ← 어떤 동작을?
      &quot;Resource&quot;: &quot;arn:aws:s3:::my-bucket/*&quot;  ← 어떤 대상에?
    }
  ]
}</code></pre><hr>
<h1 id="네트워크">네트워크</h1>
<h2 id="vpc와-vpc의-하위-요소">VPC와 VPC의 하위 요소</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/58afd834-1ae0-4adc-b339-e8489d46bdb5/image.png" alt=""></p>
<h3 id="vpcvirtual-private-cloud란">VPC(Virtual Private Cloud)란?</h3>
<p><strong>1. 정의</strong></p>
<ul>
<li>AWS 클라우드 내에 만드는 <strong>사설 네트워크</strong></li>
<li>다른 AWS 계정의 네트워크와 논리적으로 완벽히 격리되어 있음</li>
</ul>
<p><strong>2. IP 주소 범위 (IP Range) 설정</strong>
VPC를 만들 때는 이 네트워크에서 총 몇 개의 IP를 쓸 것인가를 정해야 함</p>
<h3 id="1-subnet">1. Subnet</h3>
<p><strong>1.1 정의</strong></p>
<ul>
<li>거대한 VPC 네트워크를 관리하기 편하게 <strong>작게 쪼갠 구역</strong></li>
<li>하나의 VPC 안에는 여러 개의 서브넷을 만들 수 있음</li>
</ul>
<p><strong>1.2 서브넷의 필수 규칙</strong></p>
<ul>
<li>서브넷 = 1 가용 영역 (AZ):<ul>
<li>하나의 서브넷이 여러 데이터센터(가용 영역)에 걸쳐 있을 수 없습니다. &quot;Subnet-A는 서울의 A존 데이터센터에만 존재한다&quot;는 식</li>
<li>앞서 배웠듯, 데이터센터가 여러개 있는 이유는 고가용성을 위해서라고 했듯, 마찬가지로 서브넷을 여러개 두어야하는 이유 또한 가용성 확보 때문</li>
</ul>
</li>
<li>IP 범위 쪼개기:<ul>
<li>VPC 전체 IP(<code>10.0.0.0/16</code>)를 서브넷들이 나눠서 가짐</li>
</ul>
</li>
</ul>
<h3 id="2-route-table-라우팅-테이블">2. Route Table (라우팅 테이블)</h3>
<p><strong>2.1 정의</strong></p>
<ul>
<li>네트워크 트래픽(데이터)이 &quot;어디로 가야 하는지&quot; 방향을 알려주는 <strong>표지판(규칙)</strong></li>
<li>모든 서브넷은 반드시 하나의 라우팅 테이블과 연결되어야 함</li>
</ul>
<p><strong>2.2 트래픽을 처리하는 두 가지 방식</strong></p>
<p>라우팅 테이블에는 기본적으로 두 가지 규칙이 들어감</p>
<ul>
<li>Local (내부 통신):<ul>
<li>&quot;도착지가 우리 VPC 내부(<code>10.0.x.x</code>)라면?&quot; 👉 VPC 안에서 찾아서 보내라.</li>
<li>이 규칙은 삭제할 수 없으며, VPC 내의 모든 서버끼리는 별도 설정 없이 통신이 가능</li>
</ul>
</li>
<li>External (외부 통신):<ul>
<li>&quot;도착지가 모르는 주소(<code>0.0.0.0/0</code>, 즉 인터넷)라면?&quot; 👉 인터넷 게이트웨이(정문)로 보내라.</li>
<li>이 규칙이 있느냐 없느냐가 Public/Private을 결정</li>
</ul>
</li>
</ul>
<h3 id="3-internet-gateway-igw">3. Internet Gateway (IGW)</h3>
<p><strong>3.1 정의</strong></p>
<ul>
<li>VPC와 외부 인터넷 세상을 연결해 주는 <strong>유일한</strong> 관문(하나의 VPC에는 오직 하나의 IGW).</li>
<li>VPC는 기본적으로 고립된 환경이므로, IGW가 없다면 VPC 내부 인프라에서 인터넷 접속이 불가능</li>
</ul>
<h3 id="4-public-subnet-vs-private-subnet">4. Public Subnet vs Private Subnet</h3>
<p>AWS에 실제로는 <code>Private Subnet 만들기</code>와 같은 버튼은 없음 오직 라우팅 테이블의 설정 차이로 구분됨</p>
<p><strong>4.1 Public Subnet (🌐 퍼블릭 서브넷)</strong></p>
<ul>
<li>조건: 연결된 라우팅 테이블에 IGW로 향하는 경로가 있는 서브넷.</li>
<li>라우팅 규칙:<ol>
<li><code>172.31.0.0/16</code> → <code>local</code> (VPC 내부 통신용, 자동 생성됨)</li>
<li><code>0.0.0.0/0</code> → <code>igw-xxxxxxxx</code> (인터넷 연결용, 필수)</li>
</ol>
</li>
</ul>
<p><strong>4 .2 Private Subnet (🔒 프라이빗 서브넷)</strong></p>
<ul>
<li>조건: 라우팅 테이블에 IGW로 향하는 경로가 없는 서브넷.</li>
<li>라우팅 규칙: <code>172.31.0.0/16</code> → <code>local</code> (VPC 내부 통신은 가능)</li>
</ul>
<hr>
<h1 id="ec2">EC2</h1>
<p>EC2는 AWS에서 제공하는 클라우드 컴퓨팅 서비스. 쉽게 말하면, 마음대로 세팅해볼 수 있는 AWS 컴퓨터</p>
<h3 id="인스턴스-상태">인스턴스 상태</h3>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/1944b5b2-3596-4a5c-a906-b2266f96d614/image.png" alt=""></p>
<hr>
<h1 id="vpc부터-ec2까지-실습">VPC부터 EC2까지 실습</h1>
<p>AWS에서 계정 생성 후 직접 실습한 과정 &amp; 결과</p>
<h2 id="vpc-생성-실습">VPC 생성 실습</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/5666caba-e90a-4cf0-9c81-7dcc837b6c8a/image.png" alt=""></p>
<ul>
<li>public, private 각각 2개씩 잘 되어있음</li>
</ul>
<hr>
<h2 id="ec2-생성-실습">EC2 생성 실습</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/3dacddf2-68e0-4bc2-9c02-cb0cf898657f/image.png" alt=""></p>
<ul>
<li>상태 검사 3/3개 검사 통과 결과 확인</li>
</ul>
<hr>
<h2 id="ec2-접속-실습ssh">EC2 접속 실습(SSH)</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/0cd1333f-6d93-4be6-988d-c736ea612018/image.png" alt=""></p>
<ul>
<li>처음에 이 과정에서 문제가 생겼었음(이후 트러블슈팅에 작성)</li>
<li>문제 해결 후 정상적으로 잘 되는 거 확인</li>
</ul>
<hr>
<h2 id="iam-role도-확인해보기">IAM Role도 확인해보기</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/245d535f-ae5f-43f4-a584-d8bd4e0f0d86/image.png" alt=""></p>
<ul>
<li>아직은 역할 없음</li>
</ul>
<hr>
<h2 id="ec2에서-http-서버-실행">EC2에서 HTTP 서버 실행</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/0927414a-91ea-4580-9f41-610343730722/image.png" alt=""></p>
<p>-&gt; 서버 실행이 안됨</p>
<hr>
<h2 id="보안-그룹-장애-실습">보안 그룹 장애 실습</h2>
<h3 id="인바운드-규칙-편집">인바운드 규칙 편집</h3>
<p>80포트 추가 후 다시 진행</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/35388aa5-1bb1-4f6f-8e42-3f8b4d9c4ec2/image.png" alt=""></p>
<h3 id="결과-화면">결과 화면</h3>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/f1d80ce6-f815-4270-b561-7d6ac1c4b066/image.png" alt=""></p>
<hr>
<h2 id="상태-확인-및-http-서버-종료">상태 확인 및 HTTP 서버 종료</h2>
<pre><code>free -h
df -h
top</code></pre><p>직접 실습 후 결과 확인</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/be24b077-79c8-496b-a907-b68de5a37006/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/18c9421e-5d7f-490a-b161-35ed29c636ae/image.png" alt=""></p>
<hr>
<h2 id="🚨트러블-슈팅-ec2-ssh-접속-실패-operation-timed-out--private-subnet-때문에-발생한-문제">🚨트러블 슈팅) EC2 SSH 접속 실패 (Operation timed out) — Private Subnet 때문에 발생한 문제</h2>
<p>EC2에 SSH로 접속하려고 했는데 아래와 같은 오류가 발생했다.</p>
<pre><code>ssh: connect to host 13.xxx.xxx.xxx port 22: Operation timed out</code></pre><p>또한 포트 연결 테스트를 해봐도 동일한 문제가 발생했다.</p>
<pre><code>nc -vz 13.xxx.xxx.xxx 22
→ Operation timed out</code></pre><p>처음에는 다음과 같은 문제들을 의심했다.</p>
<ul>
<li>EC2 인스턴스가 꺼져 있는 문제</li>
<li>SSH Key(.pem) 문제</li>
<li>Security Group 설정 문제</li>
<li>현재 네트워크에서 22포트 차단 문제</li>
</ul>
<p>하지만 모든 설정을 확인해도 문제는 해결되지 않았다.</p>
<hr>
<h2 id="🔎-문제-원인">🔎 문제 원인</h2>
<p>EC2 인스턴스의 <strong>Subnet 설정</strong>을 확인해보니 다음과 같이 되어 있었다.</p>
<pre><code>my-subnet-private1-ap-northeast-2a</code></pre><p>즉, <strong>Private Subnet에 EC2가 생성되어 있었다.</strong></p>
<p>AWS 네트워크 구조에서 Subnet은 크게 두 가지로 나뉜다.</p>
<table>
<thead>
<tr>
<th>Subnet 유형</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>Public Subnet</td>
<td>인터넷에서 직접 접근 가능</td>
</tr>
<tr>
<td>Private Subnet</td>
<td>외부 인터넷에서 직접 접근 불가능</td>
</tr>
</tbody></table>
<p>Private Subnet에 위치한 EC2는 다음 특징을 가진다.</p>
<ul>
<li>외부에서 직접 SSH 접속 불가</li>
<li>Public IP가 있어도 외부 접근 불가</li>
<li>내부 네트워크(VPC 내부)에서만 접근 가능</li>
</ul>
<p>따라서 아무리 Security Group에서 <strong>22번 포트를 열어도</strong> 외부에서 접근할 수 없었다.</p>
<hr>
<h2 id="💥-왜-이런-일이-발생했을까">💥 왜 이런 일이 발생했을까?</h2>
<p>EC2 생성 시 Network 설정에서 다음과 같은 선택을 했었다.</p>
<pre><code>Subnet
→ my-subnet-private1-ap-northeast-2a</code></pre><p>Public Subnet이 아닌 <strong>Private Subnet을 선택한 것이 원인이었다.</strong></p>
<hr>
<h2 id="🛠-해결-방법">🛠 해결 방법</h2>
<h3 id="ec2를-public-subnet에-다시-생성">EC2를 Public Subnet에 다시 생성</h3>
<p>새 인스턴스를 생성하면서 다음과 같이 설정했다.</p>
<pre><code>Instance Name: sparta-ec2
VPC: my-vpc
Subnet: public subnet
Auto assign public IP: Enable</code></pre><p>새로운 인스턴스(sparta-ec2) 생성 후 다시 SSH 접속을 시도했다.</p>
<pre><code>ssh -i ec2-key.pem ec2-user@퍼블릭IP</code></pre><p>이번에는 정상적으로 접속이 되었다.</p>
<hr>
<h2 id="📚-이번-문제로-배운-점">📚 이번 문제로 배운 점</h2>
<p>이번 트러블슈팅을 통해 AWS 네트워크 구조에서 중요한 개념을 이해하게 되었다.</p>
<p>1️⃣ <strong>Public Subnet</strong></p>
<ul>
<li>Internet Gateway 연결</li>
<li>외부에서 직접 접근 가능</li>
</ul>
<p>2️⃣ <strong>Private Subnet</strong></p>
<ul>
<li>외부 접근 불가</li>
<li>내부 서비스용</li>
</ul>
<p>3️⃣ <strong>Security Group이 열려 있어도 Subnet 구조에 따라 접근이 불가능할 수 있다.</strong></p>
<hr>
<h2 id="✏️-정리">✏️ 정리</h2>
<blockquote>
<p>EC2를 Private Subnet에 생성하면 외부에서 SSH 접속이 불가능하다.</p>
</blockquote>
<p>AWS를 처음 사용할 때 <strong>Security Group만 생각하고 Subnet 구조를 놓치는 경우가 많다.</strong>
이번 경험을 통해 <strong>VPC 네트워크 구조를 먼저 이해하는 것이 중요하다는 것을 알게 되었다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] CH 3 심화 Spring 코드 개선 과제 - 도전 기능]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH-3-%EC%8B%AC%ED%99%94-Spring-%EC%BD%94%EB%93%9C-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%9C-%EB%8F%84%EC%A0%84-%EA%B8%B0%EB%8A%A5</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH-3-%EC%8B%AC%ED%99%94-Spring-%EC%BD%94%EB%93%9C-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%9C-%EB%8F%84%EC%A0%84-%EA%B8%B0%EB%8A%A5</guid>
            <pubDate>Fri, 06 Mar 2026 03:17:39 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-심화-도전-기능-lv5--lv7">Spring 심화 도전 기능 (Lv5 ~ Lv7)</h1>
<p>이번 단계에서는 기존 필수 기능 위에 다음 작업을 진행했다.</p>
<ol>
<li>JWT 인증 구조 리팩토링</li>
<li>인증 책임 분리</li>
<li>ArgumentResolver 활용</li>
<li>단위 테스트 작성</li>
<li>테스트 커버리지 측정</li>
</ol>
<p>단순히 기능을 추가하는 것이 아니라 <strong>코드 구조 개선과 테스트 가능한 구조를 만드는 것</strong>에 집중했다.</p>
<hr>
<h1 id="lv5-jwt-인증-구조-개선">Lv5 JWT 인증 구조 개선</h1>
<h2 id="문제-상황">문제 상황</h2>
<p>JWT 인증 로직을 구현할 때 처음에는 대부분의 로직이 <strong>JwtFilter에 집중되어 있었다.</strong></p>
<p>기존 JwtFilter는 다음 역할을 모두 수행하고 있었다.</p>
<p>JwtFilter</p>
<ul>
<li>토큰 검증</li>
<li>Claims 추출</li>
<li>사용자 정보 request 저장</li>
<li>관리자 권한 검사</li>
</ul>
<p>즉 하나의 클래스가 너무 많은 역할을 가지고 있었다.</p>
<p>이 구조는 다음 문제를 만들 수 있다.</p>
<ol>
<li>클래스의 책임이 너무 많아짐</li>
<li>테스트가 어려워짐</li>
<li>코드 유지보수가 어려워짐</li>
</ol>
<p>이 문제는 객체지향 설계 원칙 중 하나인 <strong>SRP(Single Responsibility Principle)</strong> 를 위반하는 구조였다.</p>
<p>SRP는 <strong>하나의 클래스는 하나의 책임만 가져야 한다</strong>는 원칙이다.</p>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<p>JWT 인증 관련 로직을 <strong>JwtAuthHelper 클래스로 분리</strong>하였다.</p>
<p>구조는 다음과 같이 변경하였다.</p>
<p>기존 구조</p>
<pre><code>JwtFilter
 -&gt; validate token
 -&gt; extract claims
 -&gt; set request attribute
 -&gt; check admin role</code></pre><p>개선 구조</p>
<pre><code>JwtFilter
 -&gt; JwtAuthHelper</code></pre><p>JwtAuthHelper</p>
<pre><code>validateAndExtractClaims()
setUserAttributes()
validateAdminAccess()</code></pre><p>이렇게 인증 관련 로직을 Helper 클래스로 이동시켰다.</p>
<hr>
<h2 id="jwtauthhelper">JwtAuthHelper</h2>
<p>토큰 검증과 Claims 추출을 담당하는 메서드이다.</p>
<pre><code class="language-java">public Claims validateAndExtractClaims(HttpServletRequest request) {
    String bearerJwt = request.getHeader(&quot;Authorization&quot;);

    if (bearerJwt == null) {
        throw new IllegalArgumentException(&quot;인증 헤더가 없습니다.&quot;);
    }

    String jwt = jwtUtil.substringToken(bearerJwt);
    Claims claims = jwtUtil.extractClaims(jwt);

    if (claims == null) {
        throw new IllegalArgumentException(&quot;유효하지 않은 토큰입니다.&quot;);
    }

    return claims;
}</code></pre>
<p>이 메서드는 다음 순서로 동작한다.</p>
<p>Authorization Header 확인</p>
<ul>
<li>Bearer 토큰 분리</li>
<li>JWT Claims 추출</li>
</ul>
<p>JWT에서 Claims는 토큰에 담긴 사용자 정보를 의미한다.</p>
<p>예를 들어</p>
<pre><code>userId
email
userRole</code></pre><p>같은 정보들이 Claims에 포함된다.</p>
<hr>
<h2 id="사용자-정보-request-저장">사용자 정보 request 저장</h2>
<p>JWT에서 추출한 정보를 request attribute로 저장하였다.</p>
<pre><code class="language-java">public void setUserAttributes(HttpServletRequest request, Claims claims) {
    request.setAttribute(&quot;userId&quot;, Long.parseLong(claims.getSubject()));
    request.setAttribute(&quot;email&quot;, claims.get(&quot;email&quot;));
    request.setAttribute(&quot;userRole&quot;, claims.get(&quot;userRole&quot;));
}</code></pre>
<p>이렇게 저장한 이유는 이후 단계에서 다음 구조로 사용하기 위해서이다.</p>
<pre><code>JwtFilter
 -&gt; request attribute 저장
 -&gt; ArgumentResolver
 -&gt; Controller</code></pre><p>즉 Controller에서는 request를 직접 사용하지 않고 <strong>AuthUser 객체로 받을 수 있게 된다.</strong></p>
<hr>
<h2 id="관리자-권한-검사">관리자 권한 검사</h2>
<p>관리자 API 접근 시 권한을 검사하는 로직이다.</p>
<pre><code class="language-java">public void validateAdminAccess(String url, Claims claims) {
    UserRole userRole = UserRole.valueOf(claims.get(&quot;userRole&quot;, String.class));

    if (url.startsWith(&quot;/admin&quot;) &amp;&amp; !UserRole.ADMIN.equals(userRole)) {
        throw new IllegalArgumentException(&quot;접근 권한이 없습니다.&quot;);
    }
}</code></pre>
<p>여기서 중요한 포인트는 다음이다.</p>
<pre><code>url.startsWith(&quot;/admin&quot;)</code></pre><p>즉 관리자 API 경로는</p>
<pre><code>/admin/comments
/admin/users</code></pre><p>같이 시작하도록 설계되어 있기 때문에
이 경로를 기준으로 <strong>권한 검사를 수행하도록 구현했다.</strong></p>
<hr>
<h2 id="구조-개선-결과">구조 개선 결과</h2>
<p>기존 구조</p>
<pre><code>JwtFilter
 -&gt; 인증
 -&gt; 인가
 -&gt; 사용자 정보 처리
 -&gt; 토큰 처리</code></pre><p>개선 구조</p>
<pre><code>JwtFilter
 -&gt; JwtAuthHelper</code></pre><p>즉</p>
<p>Filter -&gt; 요청 흐름 제어
Helper -&gt; 인증 로직 처리</p>
<p>로 역할을 분리하였다.</p>
<hr>
<h1 id="lv6-코드-구조-개선-및-리팩토링">Lv6 코드 구조 개선 및 리팩토링</h1>
<h2 id="문제-인식">문제 인식</h2>
<p>기능 구현이 완료된 후 코드를 다시 확인하면서 다음 문제를 발견했다.</p>
<ol>
<li>인증 정보를 Controller에서 직접 꺼내는 코드 존재</li>
<li>request attribute 접근 코드 반복</li>
<li>테스트 작성이 어려운 구조</li>
</ol>
<p>예를 들어 기존 방식은 다음과 같았다.</p>
<pre><code class="language-java">Long userId = (Long) request.getAttribute(&quot;userId&quot;);</code></pre>
<p>이 방식은 다음 문제를 가진다.</p>
<ol>
<li>Controller가 HttpServletRequest에 의존</li>
<li>인증 정보 처리 코드가 반복됨</li>
<li>테스트 작성이 어려움</li>
</ol>
<hr>
<h2 id="해결-방법-1">해결 방법</h2>
<p>Spring에서 제공하는 <strong>HandlerMethodArgumentResolver</strong> 를 활용하였다.</p>
<p>ArgumentResolver는 Controller의 파라미터를 <strong>자동으로 생성해주는 기능</strong>이다.</p>
<p>즉 Controller에서는 request를 직접 다루지 않아도 된다.</p>
<hr>
<h2 id="authuserargumentresolver">AuthUserArgumentResolver</h2>
<pre><code class="language-java">Long userId = (Long) request.getAttribute(&quot;userId&quot;);
String email = (String) request.getAttribute(&quot;email&quot;);
UserRole userRole = UserRole.of((String) request.getAttribute(&quot;userRole&quot;));

return new AuthUser(userId, email, userRole);</code></pre>
<p>이 클래스의 역할은 다음과 같다.</p>
<p>request attribute에서 인증 정보를 가져온다
-&gt; AuthUser 객체 생성
-&gt; Controller 파라미터로 전달</p>
<hr>
<h2 id="controller-코드-개선">Controller 코드 개선</h2>
<p>기존 방식</p>
<pre><code class="language-java">Long userId = (Long) request.getAttribute(&quot;userId&quot;);</code></pre>
<p>개선 방식</p>
<pre><code class="language-java">public ResponseEntity&lt;CommentSaveResponse&gt; saveComment(
        @Auth AuthUser authUser,
        ...
)</code></pre>
<p>Controller에서는 AuthUser 객체만 사용하면 된다.</p>
<hr>
<h2 id="구조-개선-효과">구조 개선 효과</h2>
<p>Controller -&gt; 인증 로직 제거
ArgumentResolver -&gt; 인증 객체 생성
Filter -&gt; request attribute 저장</p>
<p>즉 다음 구조가 만들어졌다.</p>
<pre><code>JwtFilter
 -&gt; JwtAuthHelper
 -&gt; request attribute 저장

ArgumentResolver
 -&gt; AuthUser 객체 생성

Controller
 -&gt; AuthUser 사용</code></pre><p>이 구조는 실제 Spring Security의 인증 흐름과도 유사하다.</p>
<blockquote>
<h2 id="lv6-벨로그">Lv6 벨로그</h2>
</blockquote>
<p><a href="https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH3-%EC%BD%94%EB%93%9C-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%9C-Lv-6">Lv 6. 위 제시된 기능 이외 ‘내’가 정의한 문제와 해결 과정 - jiiimni 벨로그</a></p>
<hr>
<h1 id="lv7-테스트-코드-작성-및-커버리지-측정">Lv7 테스트 코드 작성 및 커버리지 측정</h1>
<p>이번 단계에서는 서비스 로직과 인증 로직에 대한 <strong>단위 테스트를 작성하였다.</strong></p>
<p>테스트 작성 목적은 다음과 같다.</p>
<ol>
<li>비즈니스 로직 검증</li>
<li>리팩토링 안정성 확보</li>
<li>코드 품질 향상</li>
</ol>
<hr>
<h2 id="작성한-테스트">작성한 테스트</h2>
<p>Service 테스트</p>
<pre><code>CommentServiceTest
ManagerServiceTest
AuthServiceTest
UserServiceTest</code></pre><p>Config 테스트</p>
<pre><code>JwtAuthHelperTest
GlobalExceptionHandlerTest
WebConfigTest
FilterConfigTest</code></pre><hr>
<h2 id="테스트-커버리지">테스트 커버리지</h2>
<p>IntelliJ Coverage 기준</p>
<blockquote>
<p>전체 클래스 커버리지 77%
메서드 커버리지 73%
라인 커버리지 76%
Domain 패키지 82%
Config 패키지 81%</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/6b2c4e8b-778b-4aa7-8bb0-2b65693415e3/image.png" alt=""><img src="https://velog.velcdn.com/images/jiiim_ni/post/1cd33d4b-6ac1-4bf8-93de-364ace8623bb/image.png" alt=""></p>
<hr>
<h2 id="테스트-작성-예시">테스트 작성 예시</h2>
<pre><code class="language-java">@Test
void 댓글_생성에_성공한다() {

    given(todoRepository.findById(anyLong()))
            .willReturn(Optional.of(todo));

    CommentSaveResponse response =
            commentService.saveComment(authUser, 1L, request);

    assertEquals(&quot;댓글&quot;, response.getContents());
}</code></pre>
<p>이 테스트는 다음 흐름을 검증한다.</p>
<p>Todo 존재 여부 확인
-&gt; Comment 생성
-&gt; Response 반환</p>
<hr>
<h2 id="테스트-작성하면서-알게-된-점">테스트 작성하면서 알게 된 점</h2>
<p>테스트 코드를 작성하면서 다음을 많이 느꼈다.</p>
<ol>
<li>테스트 가능한 구조가 중요하다</li>
<li>책임 분리가 되어 있어야 테스트가 쉽다</li>
<li>테스트 코드가 리팩토링 안정성을 높여준다</li>
</ol>
<p>특히 JwtFilter 구조를 리팩토링하면서 <strong>테스트 코드 작성이 훨씬 쉬워졌다.</strong></p>
<hr>
<h1 id="트러블슈팅">트러블슈팅</h1>
<h2 id="jwt-claims-테스트-오류">JWT Claims 테스트 오류</h2>
<p>테스트 코드 작성 중 다음 오류가 발생했다.</p>
<pre><code>Cannot resolve symbol Claims</code></pre><p>원인은 build.gradle 설정이었다.</p>
<p>기존 설정</p>
<pre><code>compileOnly &#39;io.jsonwebtoken:jjwt-api&#39;</code></pre><p>compileOnly는 테스트 클래스패스에 포함되지 않는다.</p>
<p>그래서 테스트 코드에서 Claims를 사용할 수 없었다.</p>
<hr>
<h2 id="해결-방법-2">해결 방법</h2>
<p>다음과 같이 수정하였다.</p>
<pre><code>implementation &#39;io.jsonwebtoken:jjwt-api&#39;</code></pre><p>이후 테스트 코드에서 Claims를 정상적으로 사용할 수 있었다.</p>
<hr>
<h1 id="회고">회고</h1>
<p>이번 도전 기능에서는 단순한 기능 구현보다 <strong>코드 구조 개선과 테스트 코드 작성 경험</strong>을 많이 할 수 있었다.</p>
<p>JWT 인증 구조 리팩토링
-&gt; 책임 분리
-&gt; ArgumentResolver 활용
-&gt; 테스트 코드 작성</p>
<p>이 과정을 통해 <strong>Spring 애플리케이션 구조를 더 깊이 이해할 수 있었다.</strong></p>
<p>또한 Filter -&gt; Helper -&gt; Resolver -&gt; Controller 구조는
Spring Security의 인증 흐름과도 유사하다는 점에서 의미 있는 경험이었다.</p>
<p>테스트 코드 작성 과정에서는 AI를 참고하여 테스트 구조와 작성 방법을 학습하였다.</p>
<p>단순히 코드를 그대로 사용하는 것이 아니라 테스트 동작 원리를 이해하고 프로젝트 구조에 맞게 수정하여 적용하였다.</p>
<p>이를 통해 단위 테스트 작성 방식과 테스트 커버리지 측정에 대한 이해도를 높일 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] CH3 코드 개선 과제 - Lv 6]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH3-%EC%BD%94%EB%93%9C-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%9C-Lv-6</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH3-%EC%BD%94%EB%93%9C-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%9C-Lv-6</guid>
            <pubDate>Fri, 06 Mar 2026 00:42:27 GMT</pubDate>
            <description><![CDATA[<h1 id="lv6-config-구조-개선-및-argumentresolver-bean-등록-문제-해결">Lv6. Config 구조 개선 및 ArgumentResolver Bean 등록 문제 해결</h1>
<h2 id="1-문제-인식-및-정의">1. 문제 인식 및 정의</h2>
<p>Lv5에서 <strong>관리자 API 로깅 기능(Interceptor + AOP)</strong>을 구현하면서 <code>WebConfig</code>를 수정하였다.
이 과정에서 <code>AuthUserArgumentResolver</code>를 <strong>생성자 주입 방식으로 관리하도록 구조를 정리</strong>하였다.</p>
<p>기존 코드에서는 <code>HandlerMethodArgumentResolver</code>를 등록할 때 아래처럼 직접 객체를 생성할 수도 있었다.</p>
<pre><code class="language-java">resolvers.add(new AuthUserArgumentResolver());</code></pre>
<p>하지만 Config 구조를 정리하면서 다음과 같이 <strong>의존성 주입(DI) 방식</strong>으로 변경하였다.</p>
<pre><code class="language-java">private final AuthUserArgumentResolver authUserArgumentResolver;</code></pre>
<p>그런데 실행 과정에서 다음과 같은 오류가 발생했다.</p>
<pre><code>Could not autowire. No beans of &#39;AuthUserArgumentResolver&#39; type found</code></pre><p><img src="https://velog.velcdn.com/images/jiiim_ni/post/1c963d20-32ac-4df6-984b-34abc3aa76a2/image.png" alt=""></p>
<p>즉, <code>WebConfig</code>에서 <code>AuthUserArgumentResolver</code>를 주입받으려고 했지만
<strong>스프링 컨테이너에 해당 클래스가 Bean으로 등록되어 있지 않아 객체를 찾지 못하는 문제</strong>였다.</p>
<p>이 문제의 원인은 다음과 같다.</p>
<ul>
<li><code>AuthUserArgumentResolver</code>가 <strong>스프링 Bean으로 등록되어 있지 않음</strong></li>
<li><code>WebMvcConfigurer</code>에서 Resolver를 등록하려면 <strong>Bean 등록 + Config 등록 두 단계가 필요</strong></li>
</ul>
<p>결국 <strong>Config 클래스와 Bean 등록 구조를 명확하게 정리할 필요</strong>가 있었다.</p>
<hr>
<h1 id="2-해결-방안">2. 해결 방안</h1>
<h2 id="2-1-의사결정-과정">2-1. 의사결정 과정</h2>
<p><code>AuthUserArgumentResolver</code>를 스프링 Bean으로 등록하는 방법은 크게 두 가지가 있었다.</p>
<h3 id="방법-1-component를-사용한-bean-등록">방법 1. <code>@Component</code>를 사용한 Bean 등록</h3>
<pre><code class="language-java">@Component
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver</code></pre>
<h3 id="방법-2-configuration에서-bean으로-직접-등록">방법 2. <code>@Configuration</code>에서 <code>@Bean</code>으로 직접 등록</h3>
<pre><code class="language-java">@Bean
public AuthUserArgumentResolver authUserArgumentResolver() {
    return new AuthUserArgumentResolver();
}</code></pre>
<p>이번 프로젝트에서는 <strong>방법 1 (<code>@Component</code>)를 선택하였다.</strong></p>
<p>선택 이유는 다음과 같다.</p>
<ul>
<li>ArgumentResolver는 여러 컨트롤러에서 재사용되는 컴포넌트</li>
<li>스프링이 직접 관리하는 Bean으로 등록하는 것이 구조적으로 더 자연스러움</li>
<li>Config 클래스의 책임을 최소화할 수 있음</li>
</ul>
<hr>
<h2 id="2-2-해결-과정">2-2. 해결 과정</h2>
<h3 id="1-authuserargumentresolver를-bean으로-등록">1) AuthUserArgumentResolver를 Bean으로 등록</h3>
<pre><code class="language-java">@Component
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {</code></pre>
<p>이렇게 하면 <strong>스프링 컨테이너가 자동으로 Bean으로 등록</strong>한다.</p>
<hr>
<h3 id="2-webconfig에서-argumentresolver-등록">2) WebConfig에서 ArgumentResolver 등록</h3>
<pre><code class="language-java">@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AuthUserArgumentResolver authUserArgumentResolver;
    private final AdminApiInterceptor adminApiInterceptor;

    @Override
    public void addArgumentResolvers(List&lt;HandlerMethodArgumentResolver&gt; resolvers) {
        resolvers.add(authUserArgumentResolver);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(adminApiInterceptor)
                .addPathPatterns(&quot;/admin/comments/*&quot;)
                .addPathPatterns(&quot;/admin/users/*&quot;);
    }
}</code></pre>
<p>이 과정을 통해</p>
<ul>
<li><code>AuthUserArgumentResolver</code>는 <strong>스프링 Bean</strong></li>
<li><code>WebConfig</code>는 <strong>Resolver를 Spring MVC에 등록하는 역할</strong></li>
</ul>
<p>로 책임이 명확하게 분리되었다.</p>
<hr>
<h1 id="3-해결-완료">3. 해결 완료</h1>
<h2 id="3-1-회고">3-1. 회고</h2>
<p>이번 문제를 통해 <strong>Spring MVC의 ArgumentResolver 동작 구조</strong>를 명확히 이해할 수 있었다.</p>
<p>특히 다음 두 가지 포인트가 중요했다.</p>
<h3 id="1-argumentresolver는-자동-등록되지-않는다">1) ArgumentResolver는 자동 등록되지 않는다</h3>
<p>단순히 클래스를 만드는 것만으로는 동작하지 않는다.</p>
<p>다음 두 단계가 필요하다.</p>
<pre><code>1. Bean 등록
2. WebMvcConfigurer.addArgumentResolvers 등록</code></pre><hr>
<h3 id="2-config-구조가-명확해야-오류를-줄일-수-있다">2) Config 구조가 명확해야 오류를 줄일 수 있다</h3>
<p>Config 클래스가 여러 개로 나뉘거나 Bean 등록이 누락되면</p>
<pre><code>Could not autowire
No beans found</code></pre><p>같은 오류가 쉽게 발생한다.</p>
<p>이번 경험을 통해</p>
<ul>
<li><strong>스프링 Bean 등록 방식</strong></li>
<li><strong>WebMvcConfigurer의 역할</strong></li>
<li><strong>ArgumentResolver 동작 방식</strong></li>
</ul>
<p>을 실제 오류 해결 과정을 통해 이해할 수 있었다.</p>
<hr>
<h2 id="3-2-전후-데이터-비교">3-2. 전후 데이터 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>개선 전</th>
<th>개선 후</th>
</tr>
</thead>
<tbody><tr>
<td>Resolver 생성</td>
<td>new로 직접 생성 가능</td>
<td>스프링 Bean으로 관리</td>
</tr>
<tr>
<td>Config 구조</td>
<td>Bean 등록 누락 가능</td>
<td>DI 기반 구조</td>
</tr>
<tr>
<td>오류 발생</td>
<td><code>No beans found</code></td>
<td>해결</td>
</tr>
<tr>
<td>유지보수</td>
<td>설정 위치 불명확</td>
<td>역할 분리</td>
</tr>
</tbody></table>
<hr>
<h1 id="lv6-jwtfilter에서-인증과-인가-책임-분리">Lv6. JwtFilter에서 인증과 인가 책임 분리</h1>
<h2 id="1-문제-인식-및-정의-1">1. 문제 인식 및 정의</h2>
<p>현재 JwtFilter는 인증, 인가, 사용자 정보 세팅, 에러 응답 생성까지 모두 담당하고 있었다.</p>
<p>하나의 클래스에 너무 많은 책임이 몰려 있어 유지보수성과 가독성이 떨어진다고 판단했다.</p>
<pre><code class="language-text">1. JWT 토큰 존재 여부 확인  
2. JWT 유효성 검증  
3. Claims 추출 및 사용자 정보 저장  
4. 관리자 권한 검증  
5. 에러 응답 생성</code></pre>
<p>즉 하나의 클래스가 <strong>인증</strong> 과 <strong>인가</strong> 그리고 <strong>예외 처리</strong>까지 담당하고 있었다.</p>
<ul>
<li>클래스의 책임이 과도하게 커짐</li>
<li>유지보수 어려움</li>
<li>테스트 어려움</li>
<li>코드 가독성 저하</li>
</ul>
<hr>
<h1 id="2-해결-방안-1">2. 해결 방안</h1>
<h2 id="2-1-의사결정-과정-1">2-1. 의사결정 과정</h2>
<p>처음에는 Filter를 인증용/인가용 두 개로 분리하는 구조도 고민했다.
하지만 현재 프로젝트 규모와 과제 범위를 고려했을 때, 먼저 JwtFilter를 얇게 만들고 JWT 처리 로직을 별도 helper로 위임하는 방식이 가장 적절하다고 판단했다.</p>
<hr>
<p>각 클래스의 역할은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>클래스</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>JwtFilter</td>
<td>요청 필터링</td>
</tr>
<tr>
<td>JwtValidator</td>
<td>JWT 검증</td>
</tr>
<tr>
<td>JwtAuthorizationChecker</td>
<td>권한 검사</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-2-해결-과정-1">2-2. 해결 과정</h2>
<p>JwtAuthHelper 클래스를 생성했다.</p>
<p>claims 추출, request attribute 저장, admin 권한 체크를 JwtAuthHelper로 이동했다.</p>
<p>JwtFilter는 요청 흐름 제어와 예외 응답만 담당하도록 수정했다.</p>
<pre><code>@RequiredArgsConstructor
public class JwtAuthHelper {

    private final JwtUtil jwtUtil;

    public Claims validateAndExtractClaims(HttpServletRequest request) {
        String bearerJwt = request.getHeader(&quot;Authorization&quot;);

        if (bearerJwt == null) {
            throw new IllegalArgumentException(&quot;인증 헤더가 없습니다.&quot;);
        }

        String jwt = jwtUtil.substringToken(bearerJwt);
        Claims claims = jwtUtil.extractClaims(jwt);

        if (claims == null) {
            throw new IllegalArgumentException(&quot;유효하지 않은 토큰입니다.&quot;);
        }

        return claims;
    }

    public void setUserAttributes(HttpServletRequest request, Claims claims) {
        request.setAttribute(&quot;userId&quot;, Long.parseLong(claims.getSubject()));
        request.setAttribute(&quot;email&quot;, claims.get(&quot;email&quot;));
        request.setAttribute(&quot;userRole&quot;, claims.get(&quot;userRole&quot;));
    }

    public void validateAdminAccess(String url, Claims claims) {
        UserRole userRole = UserRole.valueOf(claims.get(&quot;userRole&quot;, String.class));

        if (url.startsWith(&quot;/admin&quot;) &amp;&amp; !UserRole.ADMIN.equals(userRole)) {
            throw new IllegalArgumentException(&quot;접근 권한이 없습니다.&quot;);
        }
    }
}</code></pre><hr>
<h1 id="3-해결-완료-1">3. 해결 완료</h1>
<h2 id="3-1-회고-1">3-1. 회고</h2>
<p>이번 리팩토링을 통해 인증과 인가를 코드 구조 상에서 분리해서 생각하는 연습을 할 수 있었다.</p>
<p>완전히 별도 Filter로 분리하지는 않았지만, 최소한 하나의 클래스가 모든 책임을 가지는 구조는 개선할 수 있었다.</p>
<p>특히 다음 개념을 명확히 이해하게 되었다.</p>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>인증(Authentication)</td>
<td>사용자가 누구인지 확인</td>
</tr>
<tr>
<td>인가(Authorization)</td>
<td>해당 사용자가 접근 권한이 있는지 확인</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-2-전후-데이터-비교-1">3-2. 전후 데이터 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>개선 전</th>
<th>개선 후</th>
</tr>
</thead>
<tbody><tr>
<td>JwtFilter 책임</td>
<td>인증 + 인가 + 사용자 정보 세팅 + 에러 응답</td>
<td>요청 흐름 제어 + 예외 응답</td>
</tr>
<tr>
<td>JWT 처리 로직</td>
<td>JwtFilter 내부</td>
<td>JwtAuthHelper로 분리</td>
</tr>
<tr>
<td>가독성</td>
<td>낮음</td>
<td>개선</td>
</tr>
<tr>
<td>유지보수성</td>
<td>변경 지점이 많음</td>
<td>역할 분리로 개선</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] CH 3 심화 Spring 코드 개선 과제 - 필수 기능]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH-3-%EC%8B%AC%ED%99%94-Spring-%EC%BD%94%EB%93%9C-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%9C</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH-3-%EC%8B%AC%ED%99%94-Spring-%EC%BD%94%EB%93%9C-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%9C</guid>
            <pubDate>Thu, 05 Mar 2026 07:01:23 GMT</pubDate>
            <description><![CDATA[<h2 id="프로젝트-목표-요약">프로젝트 목표 요약</h2>
<ul>
<li><strong>Lv0</strong>: 애플리케이션 실행을 위한 JWT 키/DB 설정</li>
<li><strong>Lv1</strong>: <code>@Auth AuthUser</code> 파라미터 바인딩을 위한 ArgumentResolver 등록</li>
<li><strong>Lv2</strong>: 회원가입/로그인 및 비밀번호 검증 로직 리팩토링(가독성/책임 분리)</li>
<li><strong>Lv3</strong>: N+1 해결 (fetch join -&gt; EntityGraph로 교체)</li>
<li><strong>Lv4</strong>: 테스트 코드 수정 + 서비스 로직 수정으로 테스트 실패 원인 제거</li>
</ul>
<hr>
<h1 id="lv0-애플리케이션-실행-jwt-키--db-설정">Lv0. 애플리케이션 실행 (JWT 키 / DB 설정)</h1>
<h2 id="문제-상황">문제 상황</h2>
<p>처음 프로젝트 실행을 위해 <code>application.yml</code>에 JWT secret, datasource 설정이 필요했다.</p>
<p>예시:</p>
<pre><code class="language-yml">jwt:
  secret:
    key: (base64 key)

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/expert
    username: root
    password: 12345678
    driver-class-name: com.mysql.cj.jdbc.Driver

jpa:
  hibernate:
    ddl-auto: create</code></pre>
<p>실행 로그에서 Tomcat 8080, Hibernate, HikariCP 모두 정상 기동되면 Lv0은 통과로 볼 수 있다.</p>
<hr>
<h2 id="트러블슈팅-1">트러블슈팅 1)</h2>
<p>처음에는 <code>application-local.yml</code>에 DB 비밀번호/JWT 키를 그대로 넣고 커밋해버렸다.
그 결과 GitHub 커밋 diff에서 그대로 노출되는 걸 확인했다.</p>
<h3 id="내가-선택한-방식-노출-방지">내가 선택한 방식 (노출 방지)</h3>
<ul>
<li>민감 정보는 <strong>로컬 전용 파일(application-local.yml)</strong> 로 관리</li>
<li>깃에는 <strong>환경변수 placeholder</strong>만 남김</li>
<li>그리고 이미 올라간 히스토리는 <strong>git filter-repo로 정리</strong></li>
</ul>
<p><code>application.yml</code>은 이렇게 바꾸었다:</p>
<pre><code class="language-yml">jwt:
  secret:
    key: ${JWT_SECRET_KEY}

spring:
  datasource:
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}</code></pre>
<p>그리고 <code>application-local.yml</code>은 <code>.gitignore</code>에 추가해서 추적 제외.</p>
<h3 id="히스토리에서-파일-완전-제거">히스토리에서 파일 완전 제거</h3>
<p>이미 커밋 히스토리에 비밀번호가 남으면 “파일을 삭제했더라도” 과거 커밋에서 볼 수 있다.
그래서 아래처럼 히스토리에서 제거했다.</p>
<pre><code class="language-bash">git filter-repo --force --path src/main/resources/application-local.yml --invert-paths</code></pre>
<blockquote>
<p>이 과정에서 <code>origin</code> remote가 자동 제거되는 이슈가 있었고, remote를 다시 잡아야 push가 가능했다.</p>
</blockquote>
<p>(이건 다음에 다시 같은 실수 안 하려고 꼭 기록해둔다.)</p>
<hr>
<h1 id="lv1-argumentresolver-등록-authuser-주입">Lv1. ArgumentResolver 등록 (AuthUser 주입)</h1>
<h2 id="요구사항">요구사항</h2>
<p>컨트롤러에서 이렇게 받을 수 있어야 한다:</p>
<pre><code class="language-java">@PostMapping(&quot;/todos&quot;)
public ResponseEntity&lt;TodoSaveResponse&gt; saveTodo(
        @Auth AuthUser authUser,
        @Valid @RequestBody TodoSaveRequest todoSaveRequest
) { ... }</code></pre>
<p>즉, <code>@Auth</code> + <code>AuthUser</code> 조합으로 들어오면
필터에서 넣어둔 request attribute를 꺼내서 <code>AuthUser</code>로 만들어 주입해야 한다.</p>
<hr>
<h2 id="트러블슈팅-2-resolver-만들었는데-왜-동작을-안-하지">트러블슈팅 2) Resolver 만들었는데 왜 동작을 안 하지?</h2>
<h3 id="원인">원인</h3>
<p><code>HandlerMethodArgumentResolver</code>는 <strong>구현만 하면 자동으로 적용되지 않는다.</strong>
반드시 <code>WebMvcConfigurer</code>에서 등록해야 한다.</p>
<blockquote>
<p>등록을 빼먹으면 컨트롤러에서 <code>@Auth AuthUser</code>를 받을 때 바인딩이 안 되거나,
예상치 못한 에러가 발생한다.</p>
</blockquote>
<hr>
<h2 id="해결-webconfig에-resolver-등록">해결: WebConfig에 Resolver 등록</h2>
<pre><code class="language-java">@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List&lt;HandlerMethodArgumentResolver&gt; resolvers){
        resolvers.add(new AuthUserArgumentResolver());
    }
}</code></pre>
<hr>
<h2 id="authuserargumentresolver-구현-의도">AuthUserArgumentResolver 구현 의도</h2>
<pre><code class="language-java">public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null;
        boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class);

        // @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생
        if (hasAuthAnnotation != isAuthUserType) {
            throw new AuthException(&quot;@Auth와 AuthUser 타입은 함께 사용되어야 합니다.&quot;);
        }

        return hasAuthAnnotation;
    }

    @Override
    public Object resolveArgument(...) {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

        Long userId = (Long) request.getAttribute(&quot;userId&quot;);
        String email = (String) request.getAttribute(&quot;email&quot;);
        UserRole userRole = UserRole.of((String) request.getAttribute(&quot;userRole&quot;));

        return new AuthUser(userId, email, userRole);
    }
}</code></pre>
<h3 id="내가-이렇게-짠-이유">내가 이렇게 짠 이유</h3>
<ul>
<li><code>supportsParameter</code>에서 <strong>@Auth와 AuthUser를 강제 묶음 처리</strong>
-&gt; 컨트롤러 개발자가 실수로 <code>@Auth String email</code> 같은 형태로 쓰면 즉시 예외로 알려줌</li>
<li><code>resolveArgument</code>는 JwtFilter에서 저장한 attribute를 그대로 꺼내 <strong>인증 정보 전달 책임을 한 곳에 모음</strong></li>
</ul>
<hr>
<h1 id="lv2-코드-개선-리팩토링">Lv2. 코드 개선 (리팩토링)</h1>
<h2 id="1-early-return-적용-signup-로직-개선">1) Early Return 적용 (Signup 로직 개선)</h2>
<h2 id="문제">문제</h2>
<p>기존 코드에서는 조건문이 중첩되는 구조였다.</p>
<p>예를 들어 이메일 중복 체크 같은 로직에서 <strong>if 블록 안에 로직이 계속 들어가는 형태</strong>였다.</p>
<p>이 방식은 코드가 길어질수록 다음과 같은 문제가 발생한다.</p>
<ul>
<li>조건문이 깊어짐 (Nested if)</li>
<li>핵심 로직 파악이 어려움</li>
<li>가독성이 떨어짐</li>
</ul>
<hr>
<h2 id="해결-early-return-적용">해결: Early Return 적용</h2>
<p>Early Return은 <strong>조건이 만족되지 않으면 바로 return 또는 예외를 던지는 방식</strong>이다.</p>
<pre><code class="language-java">if (userRepository.existsByEmail(signupRequest.getEmail())) {
    throw new InvalidRequestException(&quot;이미 존재하는 이메일입니다.&quot;);
}</code></pre>
<p>그 다음에 정상 로직을 바로 진행한다.</p>
<pre><code class="language-java">User savedUser = userRepository.save(
        new User(
                signupRequest.getEmail(),
                encodedPassword,
                signupRequest.getUserName(),
                userRole
        )
);</code></pre>
<hr>
<h2 id="early-return을-적용한-이유">Early Return을 적용한 이유</h2>
<p>Early Return을 사용하면 다음 장점이 있다.</p>
<ul>
<li>코드 깊이가 줄어든다</li>
<li>정상 흐름이 더 잘 보인다</li>
<li>예외 케이스를 먼저 처리할 수 있다</li>
</ul>
<p>즉,</p>
<blockquote>
<p>&quot;예외 상황 먼저 처리 -&gt; 정상 로직 실행&quot;</p>
</blockquote>
<p>이라는 구조가 만들어진다.</p>
<p>이 패턴은 <strong>Spring 서비스 로직에서 많이 사용하는 패턴</strong>이다.</p>
<hr>
<h2 id="2-weatherclient-불필요한-if-else-제거">2) WeatherClient 불필요한 if-else 제거</h2>
<p>WeatherClient 코드에서 다음과 같은 구조가 있었다.</p>
<pre><code class="language-java">if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
    throw new ServerException(...);
} else {
    if (weatherArray == null || weatherArray.length == 0) {
        throw new ServerException(...);
    }
}</code></pre>
<p>여기서 문제는</p>
<ul>
<li>불필요한 else 블록</li>
<li>조건문 중첩</li>
<li>가독성 저하</li>
</ul>
<p>였다.</p>
<hr>
<h2 id="개선-방법">개선 방법</h2>
<p>불필요한 <code>else</code>를 제거하고 <strong>조건을 분리</strong>했다.</p>
<pre><code class="language-java">if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
    throw new ServerException(&quot;날씨 데이터를 가져오는데 실패했습니다.&quot;);
}

if (weatherArray == null || weatherArray.length == 0) {
    throw new ServerException(&quot;날씨 데이터가 없습니다.&quot;);
}</code></pre>
<hr>
<h2 id="이렇게-변경한-이유">이렇게 변경한 이유</h2>
<p>불필요한 else는 코드 가독성을 떨어뜨린다.</p>
<p>조건이 실패하면 바로 예외를 던지고
정상 흐름을 이어가는 방식이 더 자연스럽다.</p>
<p>이 역시 <strong>Early Fail / Guard Clause 패턴</strong>이라고 한다.</p>
<hr>
<h2 id="3-비밀번호-검증-로직을-dto-validation으로-이동">3) 비밀번호 검증 로직을 DTO Validation으로 이동</h2>
<p>기존 코드에서는 비밀번호 검증을 <strong>서비스 레이어에서 직접 수행</strong>하고 있었다.</p>
<pre><code class="language-java">if (userChangePasswordRequest.getNewPassword().length() &lt; 8 ||
        !userChangePasswordRequest.getNewPassword().matches(&quot;.*\\d.*&quot;) ||
        !userChangePasswordRequest.getNewPassword().matches(&quot;.*[A-Z].*&quot;)) {
    throw new InvalidRequestException(&quot;새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다.&quot;);
}</code></pre>
<p>이 방식은 다음 문제가 있다.</p>
<ul>
<li>서비스 로직이 길어짐</li>
<li>검증 로직이 여러 서비스에 흩어질 수 있음</li>
<li>재사용성이 떨어짐</li>
</ul>
<hr>
<h2 id="해결-dto-validation-사용">해결: DTO Validation 사용</h2>
<p>DTO에 Validation을 적용했다.</p>
<pre><code class="language-java">@NotBlank
private String newPassword;</code></pre>
<p>그리고 Controller에서</p>
<pre><code class="language-java">@Valid</code></pre>
<p>를 사용하여 자동 검증하도록 했다.</p>
<hr>
<h2 id="validation을-dto로-옮긴-이유">Validation을 DTO로 옮긴 이유</h2>
<p>Spring에서는 <strong>입력값 검증을 DTO에서 처리하는 것이 일반적인 패턴</strong>이다.</p>
<p>이렇게 하면</p>
<ul>
<li>서비스 로직이 단순해짐</li>
<li>책임 분리 가능</li>
<li>코드 재사용 가능</li>
<li>유지보수성 증가</li>
</ul>
<hr>
<h3 id="lv2-코드-개선을-통해-얻은-것">Lv2 코드 개선을 통해 얻은 것</h3>
<p>이번 리팩토링을 통해 단순히 기능 구현을 넘어서</p>
<ul>
<li><strong>가독성 있는 코드 작성 방법</strong></li>
<li><strong>서비스 로직을 단순하게 유지하는 방법</strong></li>
<li><strong>Spring Validation의 활용 방식</strong></li>
</ul>
<p>을 이해할 수 있었다.</p>
<p>특히,</p>
<blockquote>
<p>&quot;비즈니스 로직과 검증 로직을 분리하는 것&quot;</p>
</blockquote>
<p>이 서비스 레이어 설계에서 매우 중요하다는 것을 느꼈다.</p>
<hr>
<h1 id="lv3-n1-해결-fetch-join---entitygraph로-변경">Lv3. N+1 해결 (fetch join -&gt; EntityGraph로 변경)</h1>
<h2 id="문제-시나리오">문제 시나리오</h2>
<p><code>getTodos()</code>에서 <code>Todo</code> 목록 조회 후 <code>todo.getUser()</code>를 접근한다.</p>
<p><code>Todo.user</code>는 LAZY이기 때문에,
Todo N개 조회 후 user를 접근하는 순간 <strong>N번 추가 쿼리</strong>가 발생할 수 있다 -&gt; N+1.</p>
<p>기존 코드는 JPQL fetch join을 사용하고 있었다:</p>
<pre><code>public interface TodoRepository extends JpaRepository&lt;Todo, Long&gt; {

    @Query(&quot;SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC&quot;)
    Page&lt;Todo&gt; findAllByOrderByModifiedAtDesc(Pageable pageable);

    @Query(&quot;SELECT t FROM Todo t &quot; +
            &quot;LEFT JOIN FETCH t.user &quot; +
            &quot;WHERE t.id = :todoId&quot;)
    Optional&lt;Todo&gt; findByIdWithUser(@Param(&quot;todoId&quot;) Long todoId);

    int countById(Long todoId);
}</code></pre><hr>
<h2 id="요구사항-entitygraph로-동일-동작-구현">요구사항: EntityGraph로 동일 동작 구현</h2>
<p><strong>EntityGraph 방식 예시 (최종 목표 형태)</strong></p>
<pre><code class="language-java">@EntityGraph(attributePaths = {&quot;user&quot;})
Page&lt;Todo&gt; findAllByOrderByModifiedAtDesc(Pageable pageable);</code></pre>
<p>그리고 단건 조회도 동일하게:</p>
<pre><code class="language-java">@EntityGraph(attributePaths = {&quot;user&quot;})
Optional&lt;Todo&gt; findById(Long todoId);</code></pre>
<blockquote>
<p>주의: <code>@EntityGraph</code>는 “연관 엔티티를 함께 로딩”하도록 힌트를 주는 방식이고,
fetch join처럼 직접 JPQL을 쓰지 않아도 <strong>N+1을 방지</strong>할 수 있다.</p>
</blockquote>
<hr>
<h1 id="lv4-테스트-코드-수정--서비스-로직-보완">Lv4. 테스트 코드 수정 + 서비스 로직 보완</h1>
<p>Lv4는 “코드가 맞는데 테스트가 틀림 / 테스트는 맞는데 로직이 바뀌어서 깨짐” 같은 상황을 직접 다루게 했다.</p>
<hr>
<h2 id="1-passwordencodertest-수정">(1) PasswordEncoderTest 수정</h2>
<h3 id="문제-1">문제</h3>
<p><code>matches()</code> 메서드 파라미터 순서를 잘못 넣으면 테스트가 실패한다.</p>
<p>정상은:</p>
<pre><code class="language-java">boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);</code></pre>
<blockquote>
<p>encoded를 raw처럼 넣으면 당연히 비교가 안 맞는다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/60ab04fc-6f1d-4430-af84-27869a7e6597/image.png" alt=""></p>
<hr>
<h2 id="2-managerservicetest-todo-없을-때-npe가-아니라-invalidrequestexception">(2) ManagerServiceTest: Todo 없을 때 NPE가 아니라 InvalidRequestException</h2>
<h3 id="내가-고친-방향">내가 고친 방향</h3>
<ul>
<li>테스트 메서드명부터 컨텍스트에 맞게 수정
(NPE가 아니라 <code>InvalidRequestException</code>을 기대해야 함)</li>
<li>예외 타입 + 메시지를 동시에 검증하도록 <code>assertThatThrownBy</code>를 사용</li>
</ul>
<pre><code class="language-java">assertThatThrownBy(() -&gt; managerService.getManagers(todoId))
    .isInstanceOf(InvalidRequestException.class)
    .hasMessage(&quot;Todo not found&quot;);</code></pre>
<h3 id="왜-assertthatthrownby를-썼나">왜 assertThatThrownBy를 썼나?</h3>
<ul>
<li>예외 검증을 <strong>체이닝으로 한 번에</strong> 표현 가능 -&gt; 가독성 올라감</li>
<li><code>assertThrows</code>는 메시지 검증까지 하려면 예외를 변수로 받아서 추가 검증해야 함 -&gt; 의도가 길어짐</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/f6c0c6af-16f2-4486-b729-1e7a4d46c43f/image.png" alt=""></p>
<hr>
<h2 id="3-commentservicetest-todo-없을-때-던지는-예외가-다르다">(3) CommentServiceTest: Todo 없을 때 던지는 예외가 다르다</h2>
<p>테스트는 <code>ServerException</code>을 기대했지만, 실제 컨텍스트(서비스 구현)는 <code>InvalidRequestException</code>을 던질 가능성이 높다.</p>
<p>즉, <strong>테스트의 기대값(예외 타입/메시지)을 서비스 컨벤션과 맞춰야</strong> 테스트가 의미를 가진다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/0f474eb8-825e-4bf5-9d9c-36da339100bf/image.png" alt=""></p>
<hr>
<h2 id="4-핵심-트러블슈팅-todouser가-null이면-npe---서비스-로직-수정">(4) 핵심 트러블슈팅: todo.user가 null이면 NPE -&gt; 서비스 로직 수정</h2>
<h3 id="증상">증상</h3>
<p>테스트에서 일부러 <code>Todo.user = null</code>로 만들었더니, 서비스 로직에서 아래가 터졌다:</p>
<pre><code class="language-java">todo.getUser().getId()</code></pre>
<h3 id="해결">해결</h3>
<p>권한 체크 전에 <strong>null 방어 로직</strong>을 넣었다.</p>
<pre><code class="language-java">if (todo.getUser() == null) {
    throw new InvalidRequestException(&quot;일정을 생성한 유저만 담당자를 지정할 수 있습니다.&quot;);
}</code></pre>
<h3 id="내가-이렇게-처리한-이유">내가 이렇게 처리한 이유</h3>
<ul>
<li><p>user가 null이면 권한 검증 자체가 불가능함 (비교할 대상이 없음)</p>
</li>
<li><p>“NPE(런타임 오류)” 대신 “의도한 비즈니스 예외”로 바꾸면</p>
<ul>
<li>테스트도 안정화되고</li>
<li>API 사용자 입장에서도 에러 의미가 명확해진다</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/a3a43fc1-bd90-4a1d-852a-82d0bf28b0db/image.png" alt=""></p>
<hr>
<h1 id="회고-이번-과제로-얻은-것">회고: 이번 과제로 얻은 것</h1>
<ul>
<li>ArgumentResolver는 만들기보다 <strong>등록 누락이 더 흔한 실수</strong>라는 점</li>
<li>테스트는 “성공시키는 것”보다 <strong>왜 실패했는지 원인을 분해하는 과정</strong>이 훨씬 학습이 된다는 점</li>
<li>예외 처리의 목적은 “에러를 막는 것”이 아니라 <strong>의미 있는 실패를 만드는 것</strong>이라는 점
(NPE -&gt; InvalidRequestException으로 변경한 케이스)</li>
</ul>
<hr>
<h2 id="프로젝트-github-링크">프로젝트 github 링크</h2>
<p><a href="https://github.com/jiiimni/spring-advanced">CH3 프로젝트 github 링크</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] 고객 및 상품 관리 시스템 프로젝트 (이커머스 백오피스) - 관리자 관리 기능]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-%EA%B3%A0%EA%B0%9D-%EB%B0%8F-%EC%83%81%ED%92%88-%EA%B4%80%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B4%EC%BB%A4%EB%A8%B8%EC%8A%A4-%EB%B0%B1%EC%98%A4%ED%94%BC%EC%8A%A4-Day1-eoqpdtg8</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-%EA%B3%A0%EA%B0%9D-%EB%B0%8F-%EC%83%81%ED%92%88-%EA%B4%80%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B4%EC%BB%A4%EB%A8%B8%EC%8A%A4-%EB%B0%B1%EC%98%A4%ED%94%BC%EC%8A%A4-Day1-eoqpdtg8</guid>
            <pubDate>Thu, 26 Feb 2026 00:09:31 GMT</pubDate>
            <description><![CDATA[<h1 id="🧑💻-admin-관리자-관리-기능readwrite-구현-회고-검색정렬필터페이징--수정역할상태삭제">🧑‍💻 Admin 관리자 관리 기능(Read/Write) 구현 회고 (검색/정렬/필터/페이징 + 수정/역할/상태/삭제)</h1>
<p>이번 팀 프로젝트에서 내가 맡은 파트는 <strong>관리자(Admin) 도메인</strong>이었다.
초반에는 인증(세션) 기능을 먼저 구현했고, 그 다음 단계로 “관리자 관리 기능” 체크리스트(READ/WRITE)를 끝까지 완성하는 게 목표였다.</p>
<hr>
<h2 id="✅-내가-해야-했던-관리자-체크리스트">✅ 내가 해야 했던 관리자 체크리스트</h2>
<p>관리자 체크리스트에서 “Read/Write”는 아래 6개였다.</p>
<h3 id="📌-read">📌 READ</h3>
<ul>
<li>관리자 목록 조회(검색/정렬/필터/페이징)</li>
<li>관리자 상세 조회</li>
</ul>
<h3 id="📌-write">📌 WRITE</h3>
<ul>
<li>관리자 정보 수정(이름/이메일/전화)</li>
<li>관리자 역할 변경</li>
<li>관리자 상태 변경</li>
<li>관리자 삭제(Soft Delete로 전환)</li>
</ul>
<p>그리고 이 기능들은 <strong>승인/거부(approve/reject)</strong>, <strong>내 정보 조회/수정</strong>, <strong>비밀번호 변경</strong> 같은 기존 기능과도 자연스럽게 연결된다.</p>
<hr>
<h1 id="🧩-내가-만든-구조패키지역할">🧩 내가 만든 구조(패키지/역할)</h1>
<p>팀 컨벤션을 최대한 안 깨기 위해, admin 도메인은 아래처럼 정리했다.</p>
<pre><code>admin
 ┣ controller
 ┣ dto
 ┃  ┣ request
 ┃  ┣ response
 ┃  ┗ session
 ┣ entity
 ┣ repository
 ┗ service
    ┣ AdminQueryService   (조회 전용)
    ┗ AdminCommandService (변경 전용)</code></pre><h2 id="왜-query--command를-나눴을까">왜 Query / Command를 나눴을까?</h2>
<p>이건 “CQRS 맛보기” 방식이다.</p>
<ul>
<li>조회(Read)는 <strong>검색 조건/정렬/페이징</strong> 같은 “쿼리 최적화” 중심</li>
<li>변경(Write)는 <strong>비즈니스 규칙/상태 변경/검증</strong> 중심</li>
</ul>
<p>같은 서비스에 넣으면 코드가 섞여서 커지고, 팀 프로젝트에서는 충돌도 늘어난다.
그래서 아예 조회/수정 흐름을 분리했다.</p>
<hr>
<h1 id="read-구현-관리자-목록-조회--상세-조회">READ 구현: 관리자 목록 조회 + 상세 조회</h1>
<p>READ는 “관리자 목록 화면”을 만들기 위한 핵심 기능이다.
특히 백오피스는 데이터가 많아질수록 <strong>검색/필터/정렬/페이징</strong>이 중요하다.</p>
<hr>
<h2 id="1-관리자-목록-조회에서-지원한-기능">1) 관리자 목록 조회에서 지원한 기능</h2>
<h3 id="지원-파라미터">지원 파라미터</h3>
<ul>
<li>keyword: 이름/이메일 검색</li>
<li>role: 역할 필터</li>
<li>status: 상태 필터</li>
<li>Pageable: 페이징 + 정렬</li>
</ul>
<p>즉, 이런 요청이 가능하다:</p>
<ul>
<li>“운영 관리자만 보고 싶다”</li>
<li>“승인대기 상태만 보고 싶다”</li>
<li>“keyword=kim 으로 이름/이메일 검색”</li>
<li>“페이지 2번, 10개씩”</li>
<li>“createdAt desc 정렬”</li>
</ul>
<hr>
<h2 id="2-specification을-선택한-이유">2) Specification을 선택한 이유</h2>
<p>관리자 목록 조회는 조건이 계속 변한다.</p>
<ul>
<li>keyword가 있을 수도 없을 수도</li>
<li>role도 있을 수도 없을 수도</li>
<li>status도 있을 수도 없을 수도</li>
</ul>
<p>이걸 if문으로 QueryDSL 없이 처리하면:</p>
<ul>
<li>메서드가 과도하게 늘어나거나</li>
<li>조건 조합마다 쿼리를 따로 만들게 된다.</li>
</ul>
<p>그래서 <code>JpaSpecificationExecutor</code> + <code>Specification</code>으로
“조건 조합을 유연하게 붙이는 방식”을 택했다.</p>
<hr>
<h2 id="3-adminspecifications-예시">3) AdminSpecifications 예시</h2>
<h3 id="keyword-검색">keyword 검색</h3>
<ul>
<li>keyword가 없으면 null 반환 → 해당 조건은 적용 안 됨</li>
<li>keyword가 있으면 name/email like 검색</li>
</ul>
<pre><code class="language-java">public static Specification&lt;Admin&gt; keyword(String keyword) {
    if (keyword == null || keyword.isBlank()) return null;

    return (root, query, cb) -&gt; cb.or(
            cb.like(cb.lower(root.get(&quot;adminName&quot;)), &quot;%&quot; + keyword.toLowerCase() + &quot;%&quot;),
            cb.like(cb.lower(root.get(&quot;email&quot;)), &quot;%&quot; + keyword.toLowerCase() + &quot;%&quot;)
    );
}</code></pre>
<hr>
<h2 id="4-adminqueryservice-조회-전용-서비스">4) AdminQueryService: 조회 전용 서비스</h2>
<pre><code class="language-java">@Transactional(readOnly = true)
public Page&lt;AdminListResponse&gt; search(AdminSearchCondition condition, Pageable pageable) {
    Specification&lt;Admin&gt; spec = Specification.where(AdminSpecifications.keyword(condition.keyword()))
            .and(AdminSpecifications.role(condition.role()))
            .and(AdminSpecifications.status(condition.status()));

    return adminRepository.findAll(spec, pageable).map(AdminListResponse::from);
}</code></pre>
<h3 id="여기서-중요한-포인트">여기서 중요한 포인트</h3>
<p>✅ <code>@Transactional(readOnly = true)</code></p>
<ul>
<li>조회는 readOnly로 실행 → 성능/안정성 측면에서 유리</li>
<li>팀원 리뷰로 “클래스 레벨 readOnly” 적용도 반영했음</li>
</ul>
<p>✅ DTO 변환은 <code>map(AdminListResponse::from)</code></p>
<ul>
<li>Entity를 그대로 반환하지 않고 응답 DTO로 변환</li>
<li>비밀번호 같은 민감 정보 노출 방지</li>
</ul>
<hr>
<h2 id="5-controller-searchadmins">5) Controller: searchAdmins</h2>
<pre><code class="language-java">@GetMapping
public ApiResponse&lt;Page&lt;AdminListResponse&gt;&gt; searchAdmins(
        @ModelAttribute AdminSearchCondition condition,
        Pageable pageable,
        HttpSession session
) {
    requireSuperAdmin(session);
    return ApiResponse.success(HttpStatus.OK, adminQueryService.search(condition, pageable));
}</code></pre>
<h3 id="리뷰-반영-포인트-1-modelattribute">리뷰 반영 포인트 1) @ModelAttribute</h3>
<p>처음엔 <code>keyword</code>, <code>role</code>, <code>status</code>를 각각 받아서 직접 condition을 new로 만들었다.
근데 팀원 리뷰로:</p>
<blockquote>
<p>Spring MVC 바인딩을 활용하면 new로 생성하지 않아도 된다.</p>
</blockquote>
<p>그래서 <code>@ModelAttribute</code>로 자동 바인딩을 적용했다.</p>
<hr>
<h3 id="리뷰-반영-포인트-2-import-누락-문제">리뷰 반영 포인트 2) import 누락 문제</h3>
<p>내가 한 번 실수로 <code>AdminStatus</code>를 import 안 하고
풀패키지명을 그대로 적어버렸다.</p>
<pre><code class="language-java">@RequestParam com.example...AdminStatus status</code></pre>
<p>리뷰에서 바로 지적 받았고, import로 정리했다.</p>
<hr>
<h1 id="write-구현-수정역할상태삭제">WRITE 구현: 수정/역할/상태/삭제</h1>
<p>WRITE는 “관리자 관리 화면에서 버튼 누르면 실행되는 기능”이다.</p>
<ul>
<li>관리자 정보 수정 (이름/이메일/전화)</li>
<li>역할 변경</li>
<li>상태 변경</li>
<li>삭제</li>
</ul>
<hr>
<h2 id="1-관리자-정보-수정updateadmin">1) 관리자 정보 수정(updateAdmin)</h2>
<h3 id="핵심-검증-이메일-중복-처리">핵심 검증: 이메일 중복 처리</h3>
<p>수정은 가입과 달라서 <strong>기존 이메일이 그대로일 때는 중복 체크를 하면 안 됨</strong></p>
<p>그래서 조건을 이렇게 넣었다:</p>
<pre><code class="language-java">if (!admin.getEmail().equals(request.email()) &amp;&amp; adminRepository.existsByEmail(request.email())) {
    throw new CustomException(ErrorCode.ADMIN_EMAIL_DUPLICATED);
}</code></pre>
<p>✅ 이메일이 변경될 때만 중복 체크</p>
<hr>
<h2 id="2-역할-변경changerole">2) 역할 변경(changeRole)</h2>
<pre><code class="language-java">@Transactional
public void changeRole(Long adminId, AdminRole role) {
    Admin admin = findAdmin(adminId);
    admin.changeRole(role);
}</code></pre>
<ul>
<li>실제 권한 체크는 Controller에서</li>
<li>Entity는 상태 변경만 담당</li>
</ul>
<hr>
<h2 id="3-상태-변경changestatus">3) 상태 변경(changeStatus)</h2>
<pre><code class="language-java">@Transactional
public void changeStatus(Long adminId, AdminStatus status) {
    Admin admin = findAdmin(adminId);
    admin.changeStatus(status);
}</code></pre>
<hr>
<h2 id="4-삭제deleteadmin---soft-delete-전환">4) 삭제(deleteAdmin) - Soft Delete 전환</h2>
<p>처음엔 물리 삭제를 했다.</p>
<pre><code class="language-java">adminRepository.delete(admin);</code></pre>
<p>그런데 리뷰에서:</p>
<blockquote>
<p>관리자 계정은 기록 추적이 중요한데 Hard Delete는 위험하다
status를 DELETED로 바꾸는 Soft Delete가 낫지 않나요?</p>
</blockquote>
<p>그래서 Soft Delete로 변경했다.</p>
<pre><code class="language-java">public void delete() {
    this.status = AdminStatus.DELETED;
}</code></pre>
<p>서비스는:</p>
<pre><code class="language-java">@Transactional
public void deleteAdmin(Long adminId) {
    Admin admin = findAdmin(adminId);
    admin.delete();
}</code></pre>
<p>✅ DB row 삭제 X
✅ 상태로만 비활성 처리
✅ 운영/감사 로그 관점에서 안전</p>
<hr>
<h1 id="트러블슈팅리뷰-반영-정리">트러블슈팅/리뷰 반영 정리</h1>
<p>이번 단계에서 기능 구현보다
리뷰 반영으로 코드 품질을 올린 경험이 컸다.</p>
<hr>
<h2 id="1-세션-키-하드코딩-제거">1) 세션 키 하드코딩 제거</h2>
<p>처음에는 문자열로 작성했는데</p>
<pre><code class="language-java">session.getAttribute(&quot;LOGIN_SUPER_ADMIN&quot;)</code></pre>
<blockquote>
<p>리뷰로 “상수화” 권장 -&gt; <code>SessionConst</code>로 통일했다.</p>
</blockquote>
<hr>
<h2 id="2-service의-트랜잭션-전략-개선">2) Service의 트랜잭션 전략 개선</h2>
<p>팀원 리뷰:</p>
<blockquote>
<p>클래스 레벨에 readOnly=true를 걸고
수정 메서드에만 @Transactional 붙이면 누락 방지된다.</p>
</blockquote>
<p>그래서:</p>
<ul>
<li>QueryService -&gt; 클래스 레벨 readOnly</li>
<li>CommandService -&gt; 기본 readOnly + 쓰기만 @Transactional</li>
</ul>
<hr>
<h2 id="3-비밀번호-변경-검증-로직-위치-개선">3) 비밀번호 변경 검증 로직 위치 개선</h2>
<p>서비스가 검증을 다 하면
Service가 도메인 규칙을 너무 많이 알게 된다.</p>
<p>그래서 Admin 엔티티로 책임을 넘겼다.</p>
<pre><code class="language-java">public void changePasswordWithValidation(...) {
   // current mismatch
   // new confirm mismatch
   // already used password
   // encode + 저장
}</code></pre>
<blockquote>
<p>도메인 규칙은 도메인에
서비스는 흐름만 제어</p>
</blockquote>
<hr>
<h1 id="오늘-작업-요약">오늘 작업 요약</h1>
<ul>
<li><p>관리자 READ 기능 구현</p>
<ul>
<li>검색(keyword)</li>
<li>필터(role, status)</li>
<li>정렬/페이징(Pageable)</li>
<li>목록/상세 조회 API</li>
</ul>
</li>
<li><p>관리자 WRITE 기능 구현</p>
<ul>
<li>관리자 정보 수정</li>
<li>역할 변경</li>
<li>상태 변경</li>
<li>삭제(Soft Delete 전환)</li>
</ul>
</li>
<li><p>리뷰 반영</p>
<ul>
<li>import 정리, 하드코딩 제거</li>
<li>@ModelAttribute 활용</li>
<li>트랜잭션 readOnly 전략 개선</li>
<li>비밀번호 변경 로직 도메인 위임</li>
</ul>
</li>
</ul>
<hr>
<h1 id="회고-keep--problem--try">회고 (Keep / Problem / Try)</h1>
<h3 id="✅-keep-만족">✅ Keep (만족)</h3>
<ul>
<li>admin 패키지 구조를 팀 컨벤션에 맞춰 일관되게 유지</li>
<li>Query/Command 분리로 충돌 가능성 감소</li>
<li>응답 포맷(ApiResponse)과 예외 포맷(ErrorCode) 유지</li>
</ul>
<h3 id="⚠️-problem-불편">⚠️ Problem (불편)</h3>
<ul>
<li>Soft Delete 적용 후 조회 제외 정책을 더 명확하게 해야 함</li>
<li>URI prefix(<code>/admins</code> vs <code>/api/admins</code>) 통일 필요</li>
</ul>
<h3 id="🚀-try-다음-개선">🚀 Try (다음 개선)</h3>
<ul>
<li>Soft Delete 된 계정은 기본 조회에서 제외되도록 정책 강화</li>
<li>관리 기능 인터셉터/ArgumentResolver로 권한 체크 공통화</li>
<li>SuperAdmin 도메인 분리 구현 (ERD 기반)</li>
</ul>
<p><a href="https://documenter.getpostman.com/view/51133118/2sBXcGDzTV#086ea58f-0aa3-48f1-846e-e32e98d671df">https://documenter.getpostman.com/view/51133118/2sBXcGDzTV#086ea58f-0aa3-48f1-846e-e32e98d671df</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] 고객 및 상품 관리 시스템 프로젝트 (이커머스 백오피스) - API, 세션]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-%EA%B3%A0%EA%B0%9D-%EB%B0%8F-%EC%83%81%ED%92%88-%EA%B4%80%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B4%EC%BB%A4%EB%A8%B8%EC%8A%A4-%EB%B0%B1%EC%98%A4%ED%94%BC%EC%8A%A4-Day1</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-%EA%B3%A0%EA%B0%9D-%EB%B0%8F-%EC%83%81%ED%92%88-%EA%B4%80%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B4%EC%BB%A4%EB%A8%B8%EC%8A%A4-%EB%B0%B1%EC%98%A4%ED%94%BC%EC%8A%A4-Day1</guid>
            <pubDate>Fri, 20 Feb 2026 12:35:54 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-백오피스-관리자-인증세션-구현-회고--트러블슈팅-회원가입로그인로그아웃세션-설정">[Spring] 백오피스 관리자 인증(세션) 구현 회고 &amp; 트러블슈팅 (회원가입/로그인/로그아웃/세션 설정)</h1>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/41007c52-538f-493d-a034-5f0ed3fe68a7/image.png" alt=""></p>
<p>팀 프로젝트에서 “백오피스 관리자” 파트를 맡게 되면서, 필수 기능 중 <strong>세션 기반 인증(쿠키/세션)</strong>을 먼저 구현했다.</p>
<h2 id="구현한-기능-목록-필수-4개">구현한 기능 목록 (필수 4개)</h2>
<ul>
<li>관리자 회원가입 API (기본 상태: 승인대기 PENDING, 비밀번호 암호화 저장)</li>
<li>관리자 로그인 API (세션 생성 및 저장, ACTIVE만 로그인 가능)</li>
<li>관리자 로그아웃 API (세션 invalidate)</li>
<li>세션 만료 시간/쿠키 옵션(HttpOnly) 설정</li>
</ul>
<hr>
<h1 id="1-팀-컨벤션에-맞춘-패키지-구조">1. 팀 컨벤션에 맞춘 패키지 구조</h1>
<p>팀 프로젝트라서 내 마음대로 구조를 만들면 나중에 충돌이 나거나 유지보수가 어려워질 수 있다.
프로젝트 내부를 보니 도메인별로 묶는 방식이 사용되고 있었고, 공통 응답/예외 포맷도 이미 존재했다.</p>
<p>그래서 관리자 인증은 아래처럼 <strong>admin 도메인 패키지</strong>를 만들고 그 안에서 3-layer를 유지했다.</p>
<pre><code>com.example.commercepilot
 ┣ admin
 ┃ ┣ controller
 ┃ ┣ dto
 ┃ ┃ ┣ request
 ┃ ┃ ┣ response
 ┃ ┃ ┗ session
 ┃ ┣ entity
 ┃ ┣ repository
 ┃ ┣ service
 ┣ config
 ┣ exception</code></pre><h3 id="팀-공통-포맷-재사용">팀 공통 포맷 재사용</h3>
<ul>
<li><code>ApiResponse</code> (성공/실패 응답 공통)</li>
<li><code>ErrorCode</code>, <code>ErrorResponse</code></li>
<li><code>GlobalExceptionHandler</code> (CustomException + Validation 처리)</li>
</ul>
<p>팀 컨벤션을 최대한 따라가면서, 내가 구현하는 기능만 자연스럽게 추가되도록 했다.</p>
<hr>
<h1 id="2-관리자-회원가입-api-구현">2. 관리자 회원가입 API 구현</h1>
<h2 id="2-1-요구사항-핵심-정리">2-1. 요구사항 핵심 정리</h2>
<p>회원가입에서 놓치면 안 되는 포인트는 아래였다.</p>
<ul>
<li>입력값 유효성 검증 (이메일 형식, 비밀번호 최소 8자, 전화번호 010-XXXX-XXXX, role 필수 등)</li>
<li>이메일 중복 체크</li>
<li>비밀번호 암호화 저장 (BCrypt 기반 PasswordEncoder)</li>
<li>가입 시 기본 상태: <code>승인대기(PENDING)</code></li>
</ul>
<h2 id="2-2-dtovalidation-설계-이유">2-2. DTO(Validation) 설계 이유</h2>
<p>회원가입 요청 DTO는 <code>record</code>로 만들고 Bean Validation을 붙였다.
Controller에서 <code>@Valid</code>를 붙이면 Validation 실패 시 <code>MethodArgumentNotValidException</code>이 발생하고, 팀 공통 <code>GlobalExceptionHandler</code>에서 <code>ApiResponse.fail(INVALID_INPUT_VALUE, fieldErrors)</code> 형태로 내려줄 수 있다.</p>
<pre><code class="language-java">public record AdminSignupRequest(
    @NotBlank String name,
    @Email @NotBlank String email,
    @Size(min = 8) @NotBlank String password,
    @Pattern(regexp = &quot;^010-\\d{4}-\\d{4}$&quot;) @NotBlank String callNumber,
    @NotNull AdminRole role
) {}</code></pre>
<p>응답 DTO는 민감정보(비밀번호)는 절대 포함하지 않도록 최소 필드만 정의했다.</p>
<pre><code class="language-java">public record AdminSignupResponse(
    Long id,
    String name,
    String email,
    String callNumber,
    AdminRole role,
    AdminStatus status,
    LocalDateTime createdAt
) { ... }</code></pre>
<h3 id="🔎-왜-password를-응답에-안-넣는가">🔎 왜 password를 응답에 안 넣는가?</h3>
<p>리뷰에서도 실제로 나온 포인트인데, <strong>해시된 비밀번호라도 응답으로 내려주는 건 보안상 적절하지 않다.</strong>
그래서 응답 DTO에 password는 아예 포함시키지 않았다.</p>
<hr>
<h2 id="2-3-service-로직-설계-이유-commandservice">2-3. Service 로직 설계 이유 (CommandService)</h2>
<p>회원가입은 “데이터를 생성하는(쓰기)” 로직이다.
그래서 팀에서 추천하던 CQRS 맛보기 구조처럼, 쓰기 로직은 CommandService에 두고 확장 가능하도록 구성했다.</p>
<pre><code class="language-java">@Transactional
public AdminSignupResponse signup(AdminSignupRequest request) {
    if (adminRepository.existsByEmail(request.email())) {
        throw new CustomException(ErrorCode.ADMIN_EMAIL_DUPLICATED);
    }

    String encoded = passwordEncoder.encode(request.password());

    Admin admin = Admin.pending(
        request.name(),
        request.email(),
        encoded,
        request.callNumber(),
        request.role()
    );

    Admin saved = adminRepository.save(admin);
    return AdminSignupResponse.from(saved);
}</code></pre>
<h3 id="여기서-내가-신경쓴-부분">여기서 내가 신경쓴 부분</h3>
<ul>
<li>중복 이메일은 <code>existsByEmail</code>로 빠르게 체크</li>
<li>비밀번호는 <code>PasswordEncoder.encode()</code>로 저장 직전에 암호화</li>
<li>상태는 무조건 <code>PENDING</code>으로 고정 (승인 전에는 로그인 불가)</li>
</ul>
<hr>
<h1 id="3-로그인-api-구현-세션-기반-인증">3. 로그인 API 구현 (세션 기반 인증)</h1>
<h2 id="3-1-요구사항-핵심">3-1. 요구사항 핵심</h2>
<p>로그인은 단순히 이메일/비밀번호만 맞으면 끝이 아니라, <strong>계정 상태</strong>에 따라 로그인 가능 여부가 갈린다.</p>
<ul>
<li>ACTIVE만 로그인 가능</li>
<li>PENDING/REJECTED/SUSPENDED/INACTIVE는 로그인 불가 + 명확한 메시지</li>
</ul>
<p>또한 로그인 성공 시에는 서버 세션에 아래 정보를 저장해야 한다.</p>
<ul>
<li>adminId</li>
<li>email</li>
<li>role</li>
</ul>
<h2 id="3-2-세션-저장-dto--세션-키-상수">3-2. 세션 저장 DTO + 세션 키 상수</h2>
<p>세션에 저장하는 데이터는 최소한으로, 그리고 명확하게 하려고 별도 record로 뺐다.</p>
<pre><code class="language-java">public record LoginAdmin(Long adminId, String email, AdminRole role) {}</code></pre>
<p>세션 key는 오타 방지 위해 상수로 관리했다.</p>
<pre><code class="language-java">public final class SessionConst {
    public static final String LOGIN_ADMIN = &quot;LOGIN_ADMIN&quot;;
}</code></pre>
<hr>
<h2 id="3-3-로그인-service-로직-상태별-분기">3-3. 로그인 Service 로직 (상태별 분기)</h2>
<pre><code class="language-java">@Transactional(readOnly = true)
public void login(AdminLoginRequest request, HttpSession session) {
    Admin admin = adminRepository.findByEmail(request.email())
        .orElseThrow(() -&gt; new CustomException(ErrorCode.LOGIN_FAILED));

    if (!passwordEncoder.matches(request.password(), admin.getPassword())) {
        throw new CustomException(ErrorCode.LOGIN_FAILED);
    }

    validateLoginStatus(admin.getStatus());

    LoginAdmin loginAdmin = new LoginAdmin(admin.getId(), admin.getEmail(), admin.getRole());
    session.setAttribute(SessionConst.LOGIN_ADMIN, loginAdmin);
}</code></pre>
<h3 id="🔎-왜-이메일-없음--비번-틀림은-같은-에러login_failed로-처리했나">🔎 왜 이메일 없음 / 비번 틀림은 같은 에러(LOGIN_FAILED)로 처리했나?</h3>
<p>보안상 “이메일이 존재하는지”를 노출하면 안 되기 때문에 보통 로그인 실패는 동일하게 처리한다.
그래서 계정 존재 여부는 숨기고, 상태(승인대기/정지 등)는 요구사항에 맞게 명확히 분기했다.</p>
<hr>
<h1 id="4-로그아웃-api-구현">4. 로그아웃 API 구현</h1>
<p>로그아웃은 간단히 세션 무효화만 하면 된다.</p>
<pre><code class="language-java">public void logout(HttpSession session) {
    session.invalidate();
}</code></pre>
<p>컨트롤러에서는 아래처럼 응답 data 없이 성공만 내려주도록 했다.</p>
<pre><code class="language-java">@PostMapping(&quot;/logout&quot;)
public ApiResponse&lt;Void&gt; logout(HttpSession session) {
    adminAuthService.logout(session);
    return ApiResponse.success(HttpStatus.OK);
}</code></pre>
<hr>
<h1 id="5-세션-만료시간쿠키httponly-설정">5. 세션 만료시간/쿠키(HttpOnly) 설정</h1>
<p>서버에서 세션을 오래 유지할지 결정해야 한다.
필수 요구사항 예시가 24시간이었고, 세션 쿠키는 XSS 위험을 줄이기 위해 HttpOnly 옵션을 사용했다.</p>
<p><code>application.properties</code></p>
<pre><code class="language-properties">server.servlet.session.timeout=24h
server.servlet.session.cookie.http-only=true</code></pre>
<hr>
<h1 id="6-트러블슈팅">6. 트러블슈팅</h1>
<h2 id="6-1-dto-패키지-구조-때문에-import-오류가-났다">6-1. DTO 패키지 구조 때문에 “import 오류”가 났다</h2>
<p>처음에는 DTO를 <code>admin/dto</code> 아래에 바로 두고 import를 작성했는데, 팀 구조를 따라가다 보니 <code>dto/request</code>, <code>dto/response</code>로 폴더를 나눴다.</p>
<p>그 상태에서 서비스 코드가 아래처럼 작성되어 있으면:</p>
<pre><code class="language-java">import com.example.commercepilot.admin.dto.AdminSignupRequest;</code></pre>
<p>실제 DTO 경로는:</p>
<pre><code>com.example.commercepilot.admin.dto.request.AdminSignupRequest</code></pre><p>이라서 IntelliJ에서 <code>Cannot resolve symbol</code> 에러가 계속 났다.</p>
<p>해결:</p>
<ul>
<li>DTO 파일의 package 선언을 폴더 구조에 맞추고,</li>
<li>Service/Controller import도 정확히 맞춰줬다.</li>
</ul>
<hr>
<h2 id="6-2-암호화된-비밀번호를-응답에-담는-게-적절한가요-리뷰-대응">6-2. “암호화된 비밀번호를 응답에 담는 게 적절한가요?” 리뷰 대응</h2>
<p>서비스 로직에 <code>encoded</code> 변수가 보이다 보니, 리뷰어가 “응답에 password가 포함되는지”를 걱정해주셨다.</p>
<p>실제로는 <code>AdminSignupResponse</code>에 password 필드가 없고, <code>from()</code>에서도 매핑하지 않아 응답으로 나가지 않는다.</p>
<p>✅ 대응:</p>
<ul>
<li><code>AdminSignupResponse</code>에 password가 없음을 코드로 설명</li>
<li>“해시라도 응답에 포함하지 않는 것이 원칙”이라는 점을 확인</li>
</ul>
<p>(추가로 안전하게 하려면 엔티티 password에 <code>@JsonIgnore</code>를 붙여 실수로 엔티티를 반환해도 노출되지 않게 막을 수도 있다.)</p>
<hr>
<h1 id="7-회고">7. 회고</h1>
<p>이번 인증 파트를 하면서 느낀 건, 기능 자체보다 <strong>팀 컨벤션에 맞춰 구조를 자연스럽게 끼워 넣는 것</strong>이 더 중요했다는 점이다.</p>
<ul>
<li>“내가 익숙한 구조”대로 만들면 빨리 만들 수 있지만,</li>
<li>팀 프로젝트는 결국 <strong>다 같이 유지보수할 수 있는 구조</strong>가 더 가치가 크다.</li>
</ul>
<p>또한 로그인/회원가입은 실무에서도 민감한 영역이라</p>
<ul>
<li>응답에 민감정보 포함 여부</li>
<li>실패 응답 메시지 정책</li>
<li>세션 보안 옵션(HttpOnly)
같은 세부 포인트가 중요하다는 걸 체감했다.</li>
</ul>
<hr>
<h1 id="8-다음-할-일">8. 다음 할 일</h1>
<p>인증(세션) 4개를 끝낸 뒤에는 관리자 기능이 본격적으로 남아있다.</p>
<ul>
<li>관리자 승인/거부(슈퍼관리자 세션 기반)</li>
<li>내 프로필 조회/수정</li>
<li>비밀번호 변경</li>
<li>관리자 목록/검색/정렬/필터/페이징</li>
<li>관리자 상세/수정/삭제/역할 변경/상태 변경</li>
</ul>
<p>다음 글에서는 “슈퍼관리자 승인 프로세스(승인대기 -&gt; 활성)”을 구현하면서 생긴 이슈들을 정리할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] CH3 숙련 Spring 일정 관리 앱 Develop - 도전 기능]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH3-%EC%88%99%EB%A0%A8-Spring-%EC%9D%BC%EC%A0%95-%EA%B4%80%EB%A6%AC-%EC%95%B1-Develop-%EB%8F%84%EC%A0%84-%EA%B8%B0%EB%8A%A5</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH3-%EC%88%99%EB%A0%A8-Spring-%EC%9D%BC%EC%A0%95-%EA%B4%80%EB%A6%AC-%EC%95%B1-Develop-%EB%8F%84%EC%A0%84-%EA%B8%B0%EB%8A%A5</guid>
            <pubDate>Fri, 13 Feb 2026 02:08:19 GMT</pubDate>
            <description><![CDATA[<h2 id="lv5---예외처리">Lv5 - 예외처리</h2>
<p>이전 과제에서 기능은 동작했지만, 한 가지 큰 문제가 있었다.</p>
<blockquote>
<p>예외가 발생하면 대부분 400 또는 500으로만 내려갔다.</p>
</blockquote>
<ul>
<li>로그인 안 해도 400</li>
<li>존재하지 않는 일정도 400</li>
<li>이메일 중복도 500</li>
<li>Validation 실패도 기본 에러 페이지</li>
</ul>
<p>이 상태는 동작은 하지만 API답지 않은 상태였다.</p>
<p>그래서 Lv5에서는 단순 기능 추가가 아니라 <strong>API를 실무스럽게 다듬는 작업</strong>을 진행했다.</p>
<hr>
<h3 id="목표">목표</h3>
<ul>
<li>Validation 강화</li>
<li>전역 예외 처리 도입</li>
<li>HTTP 상태 코드 명확히 분리</li>
<li>에러 응답 구조 통일</li>
</ul>
<hr>
<h2 id="validation-강화-valid--dto-중심-설계">Validation 강화 (@Valid + DTO 중심 설계)</h2>
<h3 id="왜-dto에-validation을-넣었는가">왜 DTO에 Validation을 넣었는가?</h3>
<p>Validation은 Controller가 아니라 DTO에 두는 게 맞다.</p>
<ul>
<li>데이터의 규칙은 데이터 구조에 정의하는 게 자연스럽다.</li>
<li>Entity에는 비즈니스 로직만 두는 것이 좋다.</li>
<li>유지보수성이 올라간다.</li>
</ul>
<hr>
<h2 id="수정한-코드">수정한 코드</h2>
<h3 id="userrequest">UserRequest</h3>
<pre><code class="language-java">@NotBlank(message = &quot;username은 필수입니다.&quot;)
@Size(max = 4, message = &quot;username은 4글자 이내여야 합니다.&quot;)
private String username;

@Email(message = &quot;email 형식이 올바르지 않습니다.&quot;)
@NotBlank(message = &quot;email은 필수입니다.&quot;)
private String email;

@NotBlank(message = &quot;password는 필수입니다.&quot;)
@Size(min = 8, message = &quot;password는 최소 8자 이상이어야 합니다.&quot;)
private String password;</code></pre>
<hr>
<h3 id="schedulerequest">ScheduleRequest</h3>
<pre><code class="language-java">@NotBlank(message = &quot;title은 필수입니다.&quot;)
@Size(max = 10, message = &quot;title은 10글자 이내여야 합니다.&quot;)
private String title;</code></pre>
<hr>
<h3 id="controller에-valid-적용">Controller에 @Valid 적용</h3>
<pre><code class="language-java">@PostMapping
public ResponseEntity&lt;ScheduleResponse&gt; create(
        @Valid @RequestBody ScheduleRequest request,
        HttpSession session
)</code></pre>
<p>여기서 중요한 건:</p>
<blockquote>
<p>DTO에만 Validation을 붙이면 아무 일도 안 일어난다.
반드시 <code>@Valid</code>를 Controller에서 붙여야 동작한다.</p>
</blockquote>
<hr>
<h2 id="전역-예외-처리-도입-restcontrolleradvice">전역 예외 처리 도입 (@RestControllerAdvice)</h2>
<p>이전까지는 예외가 발생하면:</p>
<ul>
<li>500 Internal Server Error</li>
<li>기본 에러 응답</li>
<li>JSON 구조가 통일되지 않음</li>
</ul>
<p>그래서 전역 예외 처리 클래스를 추가했다.</p>
<hr>
<h2 id="exception-패키지-추가">exception 패키지 추가</h2>
<pre><code>exception
 ┣ CustomException
 ┣ ErrorResponse
 ┗ GlobalExceptionHandler</code></pre><hr>
<h2 id="errorresponse-구조-통일">ErrorResponse 구조 통일</h2>
<pre><code class="language-java">@Getter
public class ErrorResponse {

    private final LocalDateTime timestamp = LocalDateTime.now();
    private final int status;
    private final String message;
    private final Map&lt;String, String&gt; fieldErrors;
}</code></pre>
<p>모든 에러는 이 구조로 내려가게 만들었다.</p>
<hr>
<h2 id="globalexceptionhandler">GlobalExceptionHandler</h2>
<pre><code class="language-java">@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity&lt;ErrorResponse&gt; handleValidation(...) { ... }

    @ExceptionHandler(CustomException.class)
    public ResponseEntity&lt;ErrorResponse&gt; handleCustomException(...) { ... }

    @ExceptionHandler(Exception.class)
    public ResponseEntity&lt;ErrorResponse&gt; handleException(...) { ... }
}</code></pre>
<p>이제 어디서 예외가 터져도 이 클래스가 전부 받아서 처리한다.</p>
<hr>
<h1 id="customexception-도입-상태-코드-분리">CustomException 도입 (상태 코드 분리)</h1>
<p>이전에는:</p>
<pre><code class="language-java">throw new IllegalArgumentException(&quot;로그인이 필요합니다.&quot;);</code></pre>
<p>-&gt; 전부 400</p>
<p>이건 명확하지 않다.</p>
<p>그래서 상태 코드를 포함한 예외 클래스를 만들었다.</p>
<hr>
<h2 id="customexception">CustomException</h2>
<pre><code class="language-java">public class CustomException extends RuntimeException {

    private final HttpStatus status;

    public CustomException(HttpStatus status, String message) {
        super(message);
        this.status = status;
    }
}</code></pre>
<hr>
<h2 id="service에서-이렇게-변경">Service에서 이렇게 변경</h2>
<h3 id="로그인-필요">로그인 필요</h3>
<pre><code class="language-java">throw new CustomException(HttpStatus.UNAUTHORIZED, &quot;로그인이 필요합니다.&quot;);</code></pre>
<p>-&gt; 401</p>
<hr>
<h3 id="존재하지-않는-일정">존재하지 않는 일정</h3>
<pre><code class="language-java">.orElseThrow(() -&gt; new CustomException(
        HttpStatus.NOT_FOUND,
        &quot;해당 일정이 존재하지 않습니다.&quot;
));</code></pre>
<p>-&gt; 404</p>
<hr>
<h3 id="이메일-중복">이메일 중복</h3>
<pre><code class="language-java">if (userRepository.existsByEmail(request.getEmail())) {
    throw new CustomException(HttpStatus.CONFLICT, &quot;이미 존재하는 이메일입니다.&quot;);
}</code></pre>
<p>-&gt; 409</p>
<hr>
<h1 id="postman-실습">Postman 실습</h1>
<h3 id="유저-생성">유저 생성</h3>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/49ef90e9-c1f9-4458-92f8-45c0458bc8f7/image.png" alt=""></p>
<h3 id="validation-실패-400">Validation 실패 (400)</h3>
<pre><code class="language-json">{
  &quot;title&quot;: &quot;12345678901&quot;,
  &quot;content&quot;: &quot;내용&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/3daddf0d-0c6e-430b-8d4d-b83afadd9d9b/image.png" alt=""></p>
<h3 id="로그인-하지-않고-일정-생성-400">로그인 하지 않고 일정 생성 (400)</h3>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/539c617b-eed0-4024-a426-7acaa3954872/image.png" alt=""></p>
<hr>
<h3 id="로그인-안-함-401">로그인 안 함 (401)</h3>
<p>POST <code>/api/schedules</code></p>
<p>-&gt; 401 Unauthorized</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/2cc3a2af-2baa-4ab7-8e76-3394cb3e1122/image.png" alt=""></p>
<h3 id="로그인--이메일-비밀번호-틀림-401">로그인- 이메일, 비밀번호 틀림 (401)</h3>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/6ba13205-1db7-4ed2-af42-b742cc13d8f6/image.png" alt=""></p>
<hr>
<h3 id="존재하지-않는-일정-404">존재하지 않는 일정 (404)</h3>
<p>GET <code>/api/schedules/99999</code></p>
<p>-&gt; 404 Not Found</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/5553d8c7-fc37-4dd1-82ea-ca6c1023b4c9/image.png" alt=""></p>
<hr>
<h3 id="이메일-중복-409">이메일 중복 (409)</h3>
<p>POST <code>/api/users</code></p>
<p>같은 이메일 2번 요청</p>
<p>-&gt; 409 Conflict</p>
<h4 id="test-계정-생성">Test 계정 생성</h4>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/c540278a-57eb-478a-9444-747cd14e1aff/image.png" alt=""></p>
<h4 id="이미-존재하는-이메일입니다-409-처리">&quot;이미 존재하는 이메일입니다&quot; 409 처리</h4>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/d01cac35-ec67-42e0-a284-4636adf73a6b/image.png" alt=""></p>
<hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="이메일-중복-시-500-에러-발생">이메일 중복 시 500 에러 발생</h3>
<p>처음에는 이메일 중복 시 500이 발생했다.</p>
<p>원인은:</p>
<blockquote>
<p>DB에 UNIQUE 제약이 걸려 있어서
DataIntegrityViolationException이 터진 것</p>
</blockquote>
<p>이걸 해결하기 위해:</p>
<ul>
<li>UserRepository에 <code>existsByEmail()</code> 추가</li>
<li>저장 전에 사전 차단</li>
</ul>
<p>결과: 500 -&gt; 409 Conflict로 정상 변경</p>
<h3 id="400과-401의-차이-이해">400과 401의 차이 이해</h3>
<p>처음에는 로그인 안 한 상태도 400으로 처리했다.</p>
<p>하지만</p>
<ul>
<li>400 -&gt; 요청 형식이 잘못됨</li>
<li>401 -&gt; 인증되지 않음</li>
</ul>
<h3 id="전역-예외-처리가-왜-필요한지-깨달음">전역 예외 처리가 왜 필요한지 깨달음</h3>
<p>예외를 Controller마다 처리하면 코드가 지저분해진다.</p>
<p>전역으로 처리하니:</p>
<ul>
<li>코드가 깔끔해짐</li>
<li>에러 응답 구조 통일</li>
<li>유지보수성 증가</li>
</ul>
<hr>
<h2 id="lv6--비밀번호-암호화-적용-bcrypt">Lv6 – 비밀번호 암호화 적용 (BCrypt)</h2>
<p>Lv3에서 password 필드를 추가했지만, 사실 그때는 평문 그대로 저장하고 있었다.
기능은 동작했지만 보안 관점에서는 치명적인 구조였다.</p>
<p>이번 Lv6에서는 비밀번호를 <strong>암호화하여 저장하고</strong>,
로그인 시에는 <strong>암호화된 값과 비교</strong>하도록 수정했다.</p>
<hr>
<h3 id="왜-암호화가-필요한가">왜 암호화가 필요한가?</h3>
<p>지금까지의 구조: </p>
<pre><code class="language-java">new User(
    request.getUsername(),
    request.getEmail(),
    request.getPassword()
);</code></pre>
<p>그리고 로그인 시에는</p>
<pre><code class="language-java">if (!user.getPassword().equals(request.getPassword())) {
    ...
}</code></pre>
<p>이 방식의 문제점은</p>
<ul>
<li>DB가 털리면 비밀번호가 그대로 노출됨</li>
<li>운영 환경에서는 절대 허용되지 않는 구조</li>
<li>equals 비교는 보안적으로 취약</li>
</ul>
<p>그래서 단방향 암호화(해시) 방식으로 전환했다.</p>
<hr>
<h2 id="buildgradle에-bcrypt-추가">build.gradle에 BCrypt 추가</h2>
<pre><code class="language-gradle">implementation &#39;at.favre.lib:bcrypt:0.10.2&#39;</code></pre>
<p>Gradle을 reload한 뒤, 암호화 클래스를 추가했다.</p>
<hr>
<h2 id="passwordencoder-직접-구현">PasswordEncoder 직접 구현</h2>
<p>Spring Security를 쓰지 않고,
과제 요구사항에 맞게 직접 PasswordEncoder를 구현했다.</p>
<pre><code class="language-java">@Component
public class PasswordEncoder {

    public String encode(String rawPassword) {
        return BCrypt.withDefaults()
                .hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
    }

    public boolean matches(String rawPassword, String encodedPassword) {
        BCrypt.Result result = BCrypt.verifyer()
                .verify(rawPassword.toCharArray(), encodedPassword);

        return result.verified;
    }
}</code></pre>
<ul>
<li><code>encode()</code> -&gt; 회원가입 시 암호화</li>
<li><code>matches()</code> -&gt; 로그인 시 비교</li>
</ul>
<hr>
<h2 id="userservice-수정-회원가입-시-암호화">UserService 수정 (회원가입 시 암호화)</h2>
<p>기존 코드:</p>
<pre><code class="language-java">request.getPassword()</code></pre>
<p>수정 코드:</p>
<pre><code class="language-java">String encodedPassword = passwordEncoder.encode(request.getPassword());

User saved = userRepository.save(
        new User(
                request.getUsername(),
                request.getEmail(),
                encodedPassword
        )
);</code></pre>
<p>이제 DB에 저장되는 값은 다음과 같은 형태가 된다.</p>
<pre><code>$2a$10$g9dS9s8sK...</code></pre><p><img src="https://velog.velcdn.com/images/jiiim_ni/post/3db56cda-aff5-4db7-b4ed-d58ebd634b0e/image.png" alt=""></p>
<hr>
<h2 id="로그인-로직-수정">로그인 로직 수정</h2>
<p>이전:</p>
<pre><code class="language-java">if (!user.getPassword().equals(request.getPassword())) {</code></pre>
<p>수정:</p>
<pre><code class="language-java">if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
    throw new CustomException(HttpStatus.UNAUTHORIZED,
            &quot;이메일 또는 비밀번호가 올바르지 않습니다.&quot;);
}</code></pre>
<p>이제는 raw password vs encoded password를 안전하게 비교한다.</p>
<hr>
<h2 id="postman-실습-1">Postman 실습</h2>
<h3 id="회원가입">회원가입</h3>
<pre><code class="language-json">{
  &quot;username&quot;: &quot;lee&quot;,
  &quot;email&quot;: &quot;lee@test.com&quot;,
  &quot;password&quot;: &quot;12345678&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/40740a55-34ed-4964-9c37-00bca1fdc758/image.png" alt=""></p>
<p>DB 확인 -&gt; 암호화된 문자열 저장됨</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/623d3aaa-2c33-409e-be00-78415ad31242/image.png" alt=""></p>
<hr>
<h3 id="로그인-성공">로그인 성공</h3>
<pre><code class="language-json">{
  &quot;email&quot;: &quot;lee@test.com&quot;,
  &quot;password&quot;: &quot;12345678&quot;
}</code></pre>
<p>-&gt; 200 OK</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/79247331-59f3-49f0-af2d-0885bb8c31b2/image.png" alt=""></p>
<hr>
<h3 id="로그인-실패-틀린-비밀번호">로그인 실패 (틀린 비밀번호)</h3>
<pre><code class="language-json">{
  &quot;email&quot;: &quot;lee@test.com&quot;,
  &quot;password&quot;: &quot;11111111&quot;
}</code></pre>
<p>-&gt; 401 Unauthorized</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/73ddd676-ef26-4a26-8753-dba002b3a45a/image.png" alt=""></p>
<hr>
<h2 id="트러블슈팅-1">트러블슈팅</h2>
<h3 id="1-기존-유저-로그인-실패">1) 기존 유저 로그인 실패</h3>
<p>암호화 적용 후, 기존에 평문으로 저장된 유저는 로그인에 실패했다.</p>
<p>이유는 간단하다.</p>
<ul>
<li>DB에는 평문</li>
<li>로그인 로직은 암호화 비교</li>
</ul>
<p>그래서 테스트를 위해 새로 회원가입을 진행했다.</p>
<p>-&gt; 암호화 적용 이후 생성된 유저만 정상 로그인 가능</p>
<hr>
<h3 id="2-equals-비교의-위험성-체감">2) equals 비교의 위험성 체감</h3>
<p>단순 equals 비교는 보안적으로 매우 취약하다.</p>
<p>BCrypt는:</p>
<ul>
<li>salt 자동 적용</li>
<li>무차별 대입 공격 방지</li>
<li>단방향 해시</li>
</ul>
<p>실무에서 가장 널리 사용되는 방식이라는 점도 직접 체감했다.</p>
<hr>
<h3 id="이번-단계에서-느낀-점">이번 단계에서 느낀 점</h3>
<p>Lv5에서 예외 처리를 정리했다면,
Lv6은 보안을 신경 쓰는 단계였다.</p>
<p>단순히 기능이 되는 API가 아니라, 운영 가능한 API로 발전하는 느낌이었다.</p>
<ul>
<li>상태 코드 분리</li>
<li>전역 예외 처리</li>
<li>비밀번호 암호화</li>
</ul>
<p>이제야 비로소 백엔드 API답다는 느낌이 들었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] CH3 숙련 Spring 일정 관리 앱 Develop 필수 기능]]></title>
            <link>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH3-%EC%88%99%EB%A0%A8-Spring-%EC%9D%BC%EC%A0%95-%EA%B4%80%EB%A6%AC-%EC%95%B1-Develop-%ED%95%84%EC%88%98-%EA%B8%B0%EB%8A%A5</link>
            <guid>https://velog.io/@jiiim_ni/%EB%82%B4%EC%9D%BC%EB%B0%B0%EC%9B%80%EC%BA%A0%ED%94%84-Spring-3%EA%B8%B0-CH3-%EC%88%99%EB%A0%A8-Spring-%EC%9D%BC%EC%A0%95-%EA%B4%80%EB%A6%AC-%EC%95%B1-Develop-%ED%95%84%EC%88%98-%EA%B8%B0%EB%8A%A5</guid>
            <pubDate>Thu, 12 Feb 2026 14:10:04 GMT</pubDate>
            <description><![CDATA[<p>이번 과제는 <strong>Spring Boot + JPA + MySQL</strong>로 일정(Schedule) CRUD를 만들고,
Lv2부터는 <strong>User 연관관계</strong>, Lv4에서는 <strong>Cookie/Session 기반 로그인 인증</strong>까지 적용하는 것이 목표였다.</p>
<hr>
<h2 id="개발-환경">개발 환경</h2>
<ul>
<li>Java 17</li>
<li>Spring Boot</li>
<li>Spring Data JPA</li>
<li>MySQL</li>
<li>Validation</li>
<li>Lombok</li>
<li>Postman</li>
</ul>
<hr>
<h2 id="프로젝트-패키지-구조">프로젝트 패키지 구조</h2>
<pre><code>kr.spartaclub.develop_scheduleapp
 ┣ config
 ┣ controller
 ┣ dto
 ┣ entity
 ┣ repository
 ┗ service</code></pre><hr>
<h2 id="공통-설정-jpa-auditing-createdat--updatedat-자동">공통 설정: JPA Auditing (createdAt / updatedAt 자동)</h2>
<p>Lv1부터 일정 생성/수정 시간을 응답에 포함해야 해서 <strong>Auditing</strong>을 먼저 잡았다.</p>
<h3 id="1-jpa-auditing-활성화">1) JPA Auditing 활성화</h3>
<p><code>JpaAuditingConfig.java</code></p>
<ul>
<li><code>@EnableJpaAuditing</code> 붙이면 JPA가 엔티티 저장/수정 이벤트를 감지해서 시간을 자동으로 채워준다.</li>
</ul>
<pre><code class="language-java">@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}</code></pre>
<h3 id="2-basetimeentity">2) BaseTimeEntity</h3>
<p><code>BaseTimeEntity.java</code></p>
<ul>
<li><code>@MappedSuperclass</code> : 상속한 엔티티 테이블 컬럼으로 포함됨</li>
<li><code>@EntityListeners(AuditingEntityListener.class)</code> : auditing 이벤트 감지</li>
</ul>
<pre><code class="language-java">@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}</code></pre>
<hr>
<h1 id="lv1-일정-crud-username-기반">Lv1. 일정 CRUD (username 기반)</h1>
<p>Lv1은 유저 테이블이 없어서, 일정에 <code>username</code>을 문자열로 저장하는 방식으로 시작했다.</p>
<h3 id="lv1-요구사항요약">Lv1 요구사항(요약)</h3>
<ul>
<li><p>일정 CRUD 구현</p>
<ul>
<li>생성/전체조회/단건조회/수정/삭제</li>
</ul>
</li>
<li><p>createdAt / updatedAt 응답에 포함</p>
</li>
</ul>
<h3 id="lv1-설계-포인트">Lv1 설계 포인트</h3>
<ul>
<li>아직 User 테이블이 없으니까 <strong>작성자(username)는 문자열로 받는다</strong></li>
<li>Controller -&gt; Service -&gt; Repository 흐름으로 분리</li>
<li>엔티티는 DB 구조, DTO는 요청/응답 전용으로 분리</li>
</ul>
<h3 id="lv1에서-핵심으로-만든-파일들">Lv1에서 핵심으로 만든 파일들</h3>
<ul>
<li><code>controller/ScheduleController</code></li>
<li><code>service/ScheduleService</code></li>
<li><code>repository/ScheduleRepository</code></li>
<li><code>entity/Schedule</code>, <code>entity/BaseTimeEntity</code></li>
<li><code>dto/ScheduleRequest</code>, <code>dto/ScheduleResponse</code>, <code>dto/ScheduleUpdateRequest</code></li>
<li><code>config/JpaAuditingConfig</code></li>
</ul>
<h3 id="코드-흐름요약">코드 흐름(요약)</h3>
<ol>
<li><strong>Controller</strong>에서 요청 받음</li>
<li><strong>Service</strong>에서 비즈니스 로직 처리(저장/조회/수정/삭제)</li>
<li><strong>Repository(JPA)</strong> 로 DB 접근</li>
<li>결과를 <strong>Response DTO</strong>로 감싸서 반환</li>
</ol>
<h3 id="lv1-dtoentity-역할-분리">Lv1 DTO/Entity 역할 분리</h3>
<ul>
<li><code>ScheduleRequest</code>: 클라이언트가 보내는 값만 받음(username/title/content)</li>
<li><code>ScheduleUpdateRequest</code>: 수정에 필요한 값만 받음(title/content)</li>
<li><code>ScheduleResponse</code>: 클라이언트에게 내려줄 값만 담음(id/username/title/content/createdAt/updatedAt)</li>
</ul>
<blockquote>
<p>이 분리를 해두면, 나중에 Lv2에서 username을 없애고 userId로 바꾸는 작업이 훨씬 편해짐(실제로 그랬음).</p>
</blockquote>
<hr>
<h2 id="api-목록">API 목록</h2>
<ul>
<li>POST <code>/api/schedules</code> : 일정 생성</li>
<li>GET <code>/api/schedules</code> : 전체 조회</li>
<li>GET <code>/api/schedules/{id}</code> : 단건 조회</li>
<li>PATCH <code>/api/schedules/{id}</code> : 수정</li>
<li>DELETE <code>/api/schedules/{id}</code> : 삭제</li>
</ul>
<hr>
<h2 id="postman-검증-순서-lv1">Postman 검증 순서 (Lv1)</h2>
<h3 id="1-일정-생성">1) 일정 생성</h3>
<p>POST <code>/api/schedules</code></p>
<pre><code class="language-json">{
  &quot;username&quot;: &quot;jimin&quot;,
  &quot;title&quot;: &quot;첫 일정&quot;,
  &quot;content&quot;: &quot;CRUD 시작!&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/0e4ee01d-3314-4b60-8406-70743a89acf0/image.png" alt=""></p>
<h3 id="2-전체-조회">2) 전체 조회</h3>
<p>GET <code>/api/schedules</code></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/d403cc89-8f8f-42f7-acc0-b461d1e4095b/image.png" alt=""></p>
<h3 id="3-단건-조회">3) 단건 조회</h3>
<p>GET <code>/api/schedules/{id}</code></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/a40d838f-857b-4bfc-aeac-04c595db2b2a/image.png" alt=""></p>
<h3 id="4-수정">4) 수정</h3>
<p>PATCH <code>/api/schedules/{id}</code></p>
<pre><code class="language-json">{
  &quot;title&quot;: &quot;수정된 제목&quot;,
  &quot;content&quot;: &quot;수정된 내용&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/44eecbe5-0887-4b8a-9491-579ae57f1037/image.png" alt=""></p>
<h3 id="5-삭제">5) 삭제</h3>
<p>DELETE <code>/api/schedules/{id}</code></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/e5758e72-ce91-443a-989b-af94d2678fed/image.png" alt=""></p>
<hr>
<h1 id="lv2-user-crud--schedule-연관관계-userid-기반">Lv2. User CRUD + Schedule 연관관계 (userId 기반)</h1>
<p>Lv2부터는 일정이 username 문자열을 저장하는게 아니라,
<strong>User를 참조(FK)하는 구조</strong>로 바꿔야 한다.</p>
<ul>
<li>기존: <code>Schedule.username</code> (String)</li>
<li>변경: <code>Schedule.user</code> (ManyToOne)</li>
</ul>
<hr>
<h3 id="핵심-개념-연관관계manytoone">핵심 개념: 연관관계(ManyToOne)</h3>
<p>Schedule은 작성자(User)를 가진다.
<strong>한 명의 유저가 여러 개의 일정을 작성할 수 있으니까</strong> 관계는 이렇게 된다.</p>
<ul>
<li>User 1 : Schedule N
-&gt; Schedule 입장에서는 ManyToOne</li>
</ul>
<hr>
<h3 id="lv2-요구사항">Lv2 요구사항</h3>
<ul>
<li>User CRUD 추가</li>
<li>일정 생성 시 <strong>유저와 연관관계로 저장</strong></li>
<li>일정 응답에서 username은 <strong>User에서 가져온 값</strong>이어야 함</li>
</ul>
<h3 id="lv2에서-바뀐-점">Lv2에서 바뀐 점</h3>
<p>Schedule이 더 이상 <code>username(String)</code>을 저장하지 않고, <code>user(User)</code>를 참조하도록 변경</p>
<hr>
<h3 id="lv2-핵심-변경-1-user-엔티티-추가">Lv2 핵심 변경 1) User 엔티티 추가</h3>
<ul>
<li><code>entity/User</code> 생성</li>
<li>이메일은 unique 처리(중복 가입 방지)</li>
</ul>
<pre><code class="language-java">@Entity
@Table(name=&quot;users&quot;)
public class User extends BaseTimeEntity {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable=false, length=50)
  private String username;

  @Column(nullable=false, length=100, unique=true)
  private String email;
}</code></pre>
<hr>
<h3 id="lv2-핵심-변경-2-schedule-엔티티에-연관관계-추가">Lv2 핵심 변경 2) Schedule 엔티티에 연관관계 추가</h3>
<ul>
<li><code>Schedule.user</code> 필드 추가</li>
<li><code>@ManyToOne</code> + <code>@JoinColumn(name=&quot;user_id&quot;)</code> 로 FK 생성</li>
</ul>
<pre><code class="language-java">@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = &quot;user_id&quot;, nullable = false)
private User user;</code></pre>
<blockquote>
<p>여기서 실제 DB에 <code>user_id</code> 컬럼이 생기고, schedules 테이블이 users를 참조하게 됨.</p>
</blockquote>
<hr>
<h3 id="lv2-핵심-변경-3-schedulerequest가-userid를-받도록-변경">Lv2 핵심 변경 3) ScheduleRequest가 userId를 받도록 변경</h3>
<p>기존 Lv1:</p>
<ul>
<li><code>username</code>을 받음</li>
</ul>
<p>Lv2:</p>
<ul>
<li><code>userId</code>를 받음</li>
</ul>
<pre><code class="language-java">@NotNull(message=&quot;userId는 필수입니다.&quot;)
private Long userId;</code></pre>
<hr>
<h3 id="lv2-핵심-변경-4-service에서-userid로-user를-조회해서-schedule-저장">Lv2 핵심 변경 4) Service에서 userId로 User를 조회해서 Schedule 저장</h3>
<p><code>ScheduleService.create()</code>에서:</p>
<ol>
<li><code>userRepository.findById(userId)</code> 로 유저 존재 확인</li>
<li>그 User로 Schedule 생성 후 save</li>
</ol>
<pre><code class="language-java">User user = userRepository.findById(request.getUserId())
    .orElseThrow(() -&gt; new IllegalArgumentException(&quot;해당 유저가 존재하지 않습니다. id=&quot; + request.getUserId()));

Schedule saved = scheduleRepository.save(new Schedule(user, request.getTitle(), request.getContent()));
return new ScheduleResponse(saved);</code></pre>
<hr>
<h2 id="user-생성-api-lv2">User 생성 API (Lv2)</h2>
<p>POST <code>/api/users</code></p>
<pre><code class="language-json">{
  &quot;username&quot;: &quot;jimin&quot;,
  &quot;email&quot;: &quot;jimin@test.com&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/a4956964-56a5-4902-8179-468463067942/image.png" alt=""></p>
<hr>
<h2 id="postman-검증-순서-lv2">Postman 검증 순서 (Lv2)</h2>
<h3 id="1-유저-생성-후-id-확인">1) 유저 생성 후 id 확인</h3>
<ul>
<li>응답에서 <code>id=1</code> 같은 값 확인</li>
</ul>
<h3 id="2-일정-생성-userid로">2) 일정 생성 (userId로)</h3>
<p>POST <code>/api/schedules</code></p>
<pre><code class="language-json">{
  &quot;userId&quot;: 1,
  &quot;title&quot;: &quot;Lv2 일정&quot;,
  &quot;content&quot;: &quot;유저 연관관계로 생성&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/4f6fb006-5a5a-44c3-86f8-f5998c112694/image.png" alt=""></p>
<h3 id="3-일정-전체-조회">3) 일정 전체 조회</h3>
<p>GET <code>/api/schedules</code></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/c6ec4506-ec85-4899-9e1b-1fbcde53e589/image.png" alt=""></p>
<h3 id="4-유저명-수정-후-일정-다시-조회">4) 유저명 수정 후 일정 다시 조회</h3>
<p>PATCH <code>/api/users/1</code></p>
<pre><code class="language-json">{
  &quot;username&quot;: &quot;jimin2&quot;,
  &quot;email&quot;: &quot;jimin2@test.com&quot;
}</code></pre>
<p>그 다음 GET <code>/api/schedules</code> 다시 실행</p>
<ul>
<li>일정 응답에서 username이 <code>jimin2</code>로 바뀌어 보이면 Schedule이 username을 저장한게 아니라 User를 참조한다는 증거</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/98ea231e-66b8-41e1-b215-50eebdf38382/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/8f05b044-13f7-4cbb-9db0-1fda0d122cea/image.png" alt=""></p>
<hr>
<h1 id="lv3-유저에-password-포함--회원가입-형태-강화">Lv3. 유저에 password 포함 / 회원가입 형태 강화</h1>
<p>Lv3에서 나는 회원가입 입력에 password를 추가했다.
(이후 Lv4 로그인 구현을 위해 필요)</p>
<ul>
<li>User에 <code>password</code> 컬럼 추가</li>
<li>UserRequest DTO에 password 추가</li>
</ul>
<h3 id="lv3-요구사항">Lv3 요구사항</h3>
<ul>
<li>회원가입 형태 강화(로그인 준비 단계)</li>
</ul>
<h3 id="lv3에서-추가된-것">Lv3에서 추가된 것</h3>
<ul>
<li>User에 <code>password</code> 컬럼 추가</li>
<li>DTO(UserRequest 등)에 password 필드 추가 + Validation</li>
</ul>
<pre><code class="language-java">@Column(nullable=false)
private String password;</code></pre>
<hr>
<h3 id="정상-회원가입-테스트">정상 회원가입 테스트</h3>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/bb782b27-5e2b-41cf-b5fe-15efcdc682a9/image.png" alt=""></p>
<h3 id="비밀번호-4자리만-입력했을-경우-오류">비밀번호 4자리만 입력했을 경우: 오류</h3>
<p>-&gt; 오류메시지는 도전 기능에서 구현 예정
<img src="https://velog.velcdn.com/images/jiiim_ni/post/0510eccc-9660-4d6c-be55-b55f21981de0/image.png" alt=""></p>
<h3 id="비밀번호-입력하지-않았을-경우-오류">비밀번호 입력하지 않았을 경우: 오류</h3>
<p>-&gt; 오류메시지는 도전 기능에서 구현 예정
<img src="https://velog.velcdn.com/images/jiiim_ni/post/2d1d09cf-bb10-4ca0-b137-2c31dd719fd7/image.png" alt=""></p>
<blockquote>
<p>이 단계에서 400이 뜰 수도 있는데,
예외처리는 Lv5에서 하므로 “지금은 400만 확인되면 OK”로 진행했다.</p>
</blockquote>
<hr>
<h1 id="lv4-로그인인증---cookiesession-기반">Lv4. 로그인(인증) - Cookie/Session 기반</h1>
<p>Lv4 핵심은 <strong>세션(HttpSession)을 활용해서 로그인 상태를 유지</strong>하는 것.</p>
<h3 id="개념-정리-session--cookie">개념 정리 (Session / Cookie)</h3>
<ul>
<li>로그인 성공하면 서버가 세션을 만들고 클라이언트(Postman)에게 <code>JSESSIONID</code> 쿠키를 내려준다</li>
<li>이후 요청마다 클라이언트가 쿠키를 같이 보내면 서버는 아, 이 사용자는 로그인 되어 있구나”를 판단할 수 있다</li>
</ul>
<h3 id="lv4-요구사항">Lv4 요구사항</h3>
<ul>
<li>이메일 + 비밀번호로 로그인</li>
<li>Cookie/Session으로 로그인 상태 유지</li>
<li>필요한 API에서 세션을 활용해서 인증 체크</li>
</ul>
<hr>
<h3 id="lv4에서-추가된-파일들">Lv4에서 추가된 파일들</h3>
<ul>
<li><code>controller/AuthController</code></li>
<li><code>dto/LoginRequest</code></li>
<li><code>service/AuthService</code> 또는 <code>UserService</code>에 로그인 로직 추가</li>
</ul>
<hr>
<h3 id="lv4-핵심-구현-1-로그인-api">Lv4 핵심 구현 1) 로그인 API</h3>
<ul>
<li><p><code>POST /api/auth/login</code></p>
</li>
<li><p>email/password 검증 성공 시</p>
<ul>
<li><code>HttpSession</code>에 로그인 유저 id 저장</li>
</ul>
</li>
</ul>
<p>예시 흐름:</p>
<pre><code class="language-java">session.setAttribute(LOGIN_USER_ID, user.getId());</code></pre>
<hr>
<h3 id="lv4-핵심-구현-2-로그아웃-api">Lv4 핵심 구현 2) 로그아웃 API</h3>
<ul>
<li><code>POST /api/auth/logout</code></li>
<li>세션 무효화</li>
</ul>
<pre><code class="language-java">session.invalidate();</code></pre>
<hr>
<h3 id="lv4-핵심-구현-3-일정-생성에-세션-인증-적용">Lv4 핵심 구현 3) 일정 생성에 세션 인증 적용</h3>
<p>Lv2까지 일정 생성은 <code>userId</code>를 body로 받았는데
Lv4부터는 로그인 세션에서 userId를 꺼내서 사용하도록 변경.</p>
<ul>
<li><strong>클라이언트는 userId를 보내지 않는다</strong></li>
<li>서버가 세션에서 꺼낸 id로만 생성한다</li>
</ul>
<pre><code class="language-java">@PostMapping
public ResponseEntity&lt;ScheduleResponse&gt; create(@RequestBody ScheduleRequest request, HttpSession session) {

    Long loginUserId = (Long) session.getAttribute(AuthController.LOGIN_USER_ID);
    if (loginUserId == null) {
        throw new IllegalArgumentException(&quot;로그인이 필요합니다.&quot;);
    }

    return ResponseEntity.ok(scheduleService.create(loginUserId, request));
}</code></pre>
<hr>
<h2 id="postman-검증-순서-lv4">Postman 검증 순서 (Lv4)</h2>
<h3 id="1-회원가입-password-포함">1) 회원가입 (password 포함)</h3>
<p>POST <code>/api/users</code></p>
<pre><code class="language-json">{
  &quot;username&quot;: &quot;jimin3&quot;,
  &quot;email&quot;: &quot;jimin3@test.com&quot;,
  &quot;password&quot;: &quot;11223344&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/51298500-5362-4563-a89c-50b2f4299ea0/image.png" alt=""></p>
<h3 id="2-로그인-세션-쿠키-발급">2) 로그인 (세션 쿠키 발급)</h3>
<p>POST <code>/api/auth/login</code></p>
<pre><code class="language-json">{
  &quot;email&quot;: &quot;jimin3@test.com&quot;,
  &quot;password&quot;: &quot;11223344&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/e926a827-8036-4da3-854f-d5ddae2d723a/image.png" alt=""></p>
<p>응답 헤더에서 <code>Set-Cookie: JSESSIONID=...</code> 확인</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/f1118871-b355-4317-8b9d-254a2f0dae33/image.png" alt=""></p>
<h3 id="3-일정-생성-이제-userid-보내지-않음">3) 일정 생성 (이제 userId 보내지 않음)</h3>
<p>POST <code>/api/schedules</code></p>
<pre><code class="language-json">{
  &quot;title&quot;: &quot;세션 일정&quot;,
  &quot;content&quot;: &quot;로그인 상태로 생성&quot;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/39ea559e-f133-4c5a-8c16-1617dd523cca/image.png" alt=""></p>
<h3 id="4-로그아웃-후-일정-생성-재시도">4) 로그아웃 후 일정 생성 재시도</h3>
<p>POST <code>/api/auth/logout</code></p>
<p>그 다음 다시 POST <code>/api/schedules</code> 요청</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/cc784b61-9532-4ca0-bc35-975cb3e70625/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/95521843-5ddc-429d-9566-e958e89dd2c9/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/f34ae295-495e-4f76-936c-38f06ca14a29/image.png" alt="">
-&gt; 오류 발생하는 것을 확인할 수 있음</p>
<hr>
<h1 id="트러블슈팅-정리">트러블슈팅 정리</h1>
<h3 id="1-auditing인데-updatedat이-안-바뀌는-것처럼-보임">1) Auditing인데 updatedAt이 안 바뀌는 것처럼 보임</h3>
<h4 id="현상">현상</h4>
<p>PATCH 후 응답을 봤는데 <code>updatedAt</code>이 <code>createdAt</code>이랑 똑같이 보였다.</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/87e6be89-8253-4c10-8364-46c737ba8ce8/image.png" alt=""></p>
<h4 id="원인가능성">원인(가능성)</h4>
<ul>
<li>요청을 너무 빨리 연속으로 보내서 시간이 같은 초로 찍힘</li>
<li>혹은 update가 실제로 flush/dirty checking 되기 전에 응답을 만든 경우</li>
</ul>
<h4 id="해결확인-방법">해결/확인 방법</h4>
<ul>
<li>Postman에서 수정 요청을 <strong>몇 초 텀을 두고</strong> 다시 실행</li>
<li>DB에서 직접 확인 (select) 혹은 GET 조회로 updatedAt 변화 확인</li>
</ul>
<hr>
<h3 id="2-notblank를-longuserid에-걸어서-터짐-hv000030">2) <code>@NotBlank</code>를 Long(userId)에 걸어서 터짐 (HV000030)</h3>
<h4 id="현상-1">현상</h4>
<pre><code>No validator could be found for constraint &#39;NotBlank&#39; validating type &#39;Long&#39;</code></pre><h4 id="원인">원인</h4>
<ul>
<li><code>@NotBlank</code>는 <strong>문자열(String)</strong> 전용이다.</li>
<li>숫자(Long)에 쓰면 검증기가 없어서 예외 발생.</li>
</ul>
<h4 id="해결">해결</h4>
<ul>
<li>Long에는 <code>@NotNull</code>을 써야 한다.</li>
</ul>
<pre><code class="language-java">@NotNull(message=&quot;userId는 필수입니다.&quot;)
private Long userId;</code></pre>
<hr>
<h3 id="3-500-internal-server-error로-일정-생성이-실패">3) 500 Internal Server Error로 일정 생성이 실패</h3>
<h4 id="현상-2">현상</h4>
<p>Postman에서 일정 생성 시 500이 발생했다.</p>
<h4 id="체크했던-것">체크했던 것</h4>
<ul>
<li>userId가 실제 존재하는지 (GET /api/users)</li>
<li>DB 테이블 구조 변경이 반영되었는지 (user_id 컬럼 존재 여부)</li>
<li>Schedule 생성자가 (User, title, content)로 맞게 되어 있는지</li>
</ul>
<h4 id="결론">결론</h4>
<p>대부분 이런 500은</p>
<ul>
<li>엔티티/DTO 필드 타입 불일치</li>
<li>FK 컬럼 미생성/스키마 미반영</li>
<li>존재하지 않는 userId로 생성 시도</li>
</ul>
<p>이 셋 중 하나였다.</p>
<hr>
<h3 id="4-회원가입-시-duplicate-entry-email-unique">4) 회원가입 시 Duplicate entry (email unique)</h3>
<h4 id="현상-3">현상</h4>
<pre><code>Duplicate entry &#39;jimin2@test.com&#39; for key &#39;users...&#39;</code></pre><h4 id="원인-1">원인</h4>
<ul>
<li>User.email에 <code>unique=true</code>가 걸려있어서</li>
<li>같은 이메일로 다시 회원가입 시 DB에서 막힘</li>
</ul>
<h4 id="해결-1">해결</h4>
<ul>
<li>이메일을 다른 값으로 테스트</li>
<li>혹은 기존 유저 레코드 삭제 후 재시도</li>
</ul>
<hr>
<h3 id="회고">회고</h3>
<p>사실 지난 과제 때는 독감 때문에 제대로 구현하지 못해서 아쉬움이 많이 남았었다.
구조를 깊게 고민하기보다는 일단 돌아가게 만드는 것에 집중할 수밖에 없었고, 완성도에 대한 아쉬움이 컸다.</p>
<p>이번 과제도 시간이 넉넉하진 않았지만,
그래도 저번보다는 훨씬 신경 써서 구조를 잡고, 레벨별 요구사항을 정확히 이해하고, 단계적으로 구현해나갈 수 있었던 점이 스스로 만족스럽다.</p>
<p>특히 이번 과제를 통해 깨달은 점은</p>
<ul>
<li><p>처음 설계를 어떻게 하느냐에 따라 이후 수정 난이도가 완전히 달라진다</p>
</li>
<li><p>DTO와 Entity를 분리해두는 것이 얼마나 중요한지 체감했다</p>
</li>
<li><p>연관관계를 적용하면 단순 문자열 저장과는 차원이 다르다</p>
</li>
<li><p>세션 인증은 개념으로 알던 것과 실제 구현은 완전히 다르다</p>
</li>
<li><p>에러를 마주쳤을 때 당황하지 않고 하나씩 원인을 좁혀가는 과정이 중요하다</p>
</li>
</ul>
<p>이제 도전 기능을 통해 예외처리와 Validation까지 정리하면서
프로젝트를 더 다듬어보고 싶다.</p>
]]></description>
        </item>
    </channel>
</rss>