<?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>Tue, 19 May 2026 07:54:15 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[[내일배움캠프 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>
        <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-%EC%9D%B8%EC%A6%9D%EA%B3%BC-%EC%9D%B8%EA%B0%80</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-%EC%9D%B8%EC%A6%9D%EA%B3%BC-%EC%9D%B8%EA%B0%80</guid>
            <pubDate>Wed, 11 Feb 2026 12:01:23 GMT</pubDate>
            <description><![CDATA[<h2 id="인증과-인가">인증과 인가</h2>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/5d15e5ad-080d-44a8-912f-d85371c61f1d/image.png" alt=""></p>
<h3 id="인증authentication">인증(Authentication)</h3>
<p>인증은 사용자가 누구인지 확인하는 절차
시스템에 등록된 사용자인지를 증명하는 과정이라고 할 수 있음</p>
<ul>
<li>웹사이트에 아이디와 비밀번호를 입력하여 로그인하는 것</li>
<li>스마트폰 잠금을 지문이나 얼굴 인식으로 해제하는 것</li>
<li>건물 출입구에서 신분증을 제시하여 신원을 확인받는 것</li>
</ul>
<blockquote>
<p>인증 = 로그인</p>
</blockquote>
<h3 id="인가authorization">인가(Authorization)</h3>
<p>인가는 인증된 사용자가 특정 리소스나 기능에 접근할 수 있는 권한이 있는지 확인하는 절차
즉, 무엇을 할 수 있는지를 허가해 주는 과정</p>
<ul>
<li>일반 사용자는 게시글을 읽고 쓸 수 있지만, 관리자는 다른 사용자의 게시글을 삭제할 수 있는 것</li>
<li>유료 구독자만 프리미엄 콘텐츠를 볼 수 있는 것</li>
<li>회사 건물에 출입은 했지만(인증), 특정 부서 사무실이나 서버실에는 들어갈 수 없는 것(인가)</li>
</ul>
<blockquote>
<p>인가 = 권한</p>
</blockquote>
<blockquote>
<h4 id="요약-정리">요약 정리</h4>
<p>인증(Authentication) = 신원 확인(로그인)
인가(Authorization) = 권한 부여(접근 제어)
인증은 인가보다 항상 먼저 수행되어야함!</p>
</blockquote>
<hr>
<h2 id="세션과-jwt">세션과 JWT</h2>
<h3 id="세션session">세션(Session)</h3>
<p>전통적으로 많이 사용되는 방식, 서버가 사용자의 로그인 상태를 기억하는 방식</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/694d86c9-7f3b-46b5-9d7a-fc3ecb625d46/image.png" alt=""></p>
<ul>
<li><p><strong>동작 흐름</strong></p>
<ol>
<li><p>사용자 로그인: 
사용자가 아이디/비밀번호로 로그인을 시도</p>
</li>
<li><p>세션 정보 생성 및 저장: 
서버는 로그인 정보가 유효하면, 사용자를 위한 고유한 세션 ID를 생성하고 이 정보를 서버 메모리나 데이터베이스에 저장</p>
</li>
<li><p>세션 ID 전송: 
서버는 생성된 세션 ID를 클라이언트(브라우저)에게 보내고, 브라우저는 보통 이 ID를 쿠키에 저장</p>
</li>
<li><p>요청과 검증: 
이후 클라이언트는 서버에 요청을 보낼 때마다 쿠키에 담긴 세션 ID를 함께 보냄
서버는 이 세션 ID를 받아 저장된 세션 정보와 비교하여 사용자를 식별하고 요청을 처리</p>
</li>
</ol>
</li>
<li><p><strong>장점</strong></p>
<ul>
<li>서버에서 모든 세션 정보를 관리하므로 보안에 유리하고, 특정 사용자를 강제로 로그아웃시키는 등 제어가 쉬움</li>
<li>쿠키에 세션 ID만 저장하므로 클라이언트에 민감한 정보가 남지 않음</li>
</ul>
</li>
<li><p><strong>단점</strong></p>
<ul>
<li>사용자가 많아질수록 서버의 메모리나 DB 부하가 커짐</li>
<li>확장성 문제</li>
</ul>
</li>
</ul>
<h3 id="jwtjson-web-token">JWT(Json Web Token)</h3>
<p>최근 웹/앱 환경에서 널리 사용되는 방식, 서버가 로그인 상태를 저장하지 않는(stateless) 인증 방식
서버가 상태를 저장하지 않으므로 JWT 자체에 모든 인증 정보가 담겨있음</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/4a09f7d2-a52e-4491-882d-3f944ee43cec/image.png" alt=""></p>
<ul>
<li><p><strong>동작 흐름</strong></p>
<ol>
<li><p>사용자 로그인: 
사용자가 아이디/비밀번호로 로그인을 시도</p>
</li>
<li><p>토큰 생성 및 전송: 
서버는 로그인 정보가 유효하면, 사용자의 정보와 권한, 만료 시간 등을 담은 암호화된 토큰(Access Token)을 생성하여 클라이언트에게 보냄. 서버는 이 토큰을 저장하지 않음</p>
</li>
<li><p>토큰 저장: 
클라이언트는 전달받은 토큰을 로컬 스토리지나 쿠키에 저장</p>
</li>
<li><p>요청과 검증: 
이후 클라이언트는 서버에 요청을 보낼 때마다 HTTP 헤더에 토큰을 실어 보냄. 서버는 이 토큰의 서명을 검증하여 유효성을 확인하고 요청을 처리</p>
</li>
</ol>
</li>
<li><p><strong>장점</strong></p>
<ul>
<li>무상태(Stateless) 및 확장성: 
서버가 토큰을 저장하지 않으므로 서버의 부하가 줄고, 여러 서버로 확장하기 용이.
서버가 토큰을 저장하지 않아도 유저를 식별할 수 있는 이유는, JWT 자체에 모든 인증 정보가 담겨있기 때문</li>
<li>유연성: 
웹뿐만 아니라 모바일 앱 등 다양한 클라이언트 환경에서 사용하기 편리</li>
</ul>
</li>
<li><p><strong>단점</strong></p>
<ul>
<li>토큰이 탈취되면 만료될 때까지 악용될 수 있음</li>
<li>세션 ID보다 토큰의 크기가 더 큼</li>
</ul>
</li>
</ul>
<hr>
<h2 id="http-헤더">HTTP 헤더</h2>
<p>HTTP Header는 Key: Value의 형태의 여러 정보들로 구성되어 있음</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] Bean]]></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-Bean</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-Bean</guid>
            <pubDate>Tue, 10 Feb 2026 10:44:50 GMT</pubDate>
            <description><![CDATA[<h2 id="bean">Bean</h2>
<p>스프링 IoC 컨테이너가 관리하는 객체를 의미</p>
<ul>
<li>Spring 컨테이너에 의해 생성, 관리, 소멸됨</li>
<li>애플리케이션 전역에서 재사용 가능</li>
<li>기본적으로 싱글톤 스코프로 관리</li>
</ul>
<h3 id="싱글톤">싱글톤</h3>
<p>싱글톤(Singleton)은 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴
애플리케이션 전체에서 해당 클래스의 객체를 하나만 만들고, 그것을 공유해서 사용함</p>
<blockquote>
<h4 id="싱글톤을-왜-사용할까">싱글톤을 왜 사용할까?</h4>
<ul>
<li><strong>메모리 효율성</strong>: 최초 한 번만 객체를 생성하므로 메모리 낭비를 방지할 수 있습니다.</li>
<li><strong>데이터 공유와 일관성</strong>: 시스템 전반의 설정 정보나 공통 자원을 관리할 때 데이터의 일관성을 유지하기 쉽습니다.</li>
</ul>
</blockquote>
<h3 id="스프링-ioc-container">스프링 IoC Container</h3>
<ul>
<li>Bean의 생성 및 생명주기 관리</li>
<li>의존성 주입 (DI)</li>
<li>Bean 설정 정보 관리</li>
<li>Bean 간의 의존 관계 설정</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/0395c38b-7790-4049-97c4-09ff9fded90c/image.png" alt=""></p>
<hr>
<h3 id="spring에서-iocdi-vs-new">Spring에서 IoC/DI vs new</h3>
<h4 id="iocdi를-사용해야-하는-경우">IoC/DI를 사용해야 하는 경우</h4>
<p>재사용되거나 교체 가능한 비즈니스 로직/인프라</p>
<pre><code>//  ✅ Spring Bean으로 관리
@Service
public class UserService { }  // 비즈니스 로직

@Repository
public class UserRepository { }  // 데이터 접근

@Component
public class EmailSender { }  // 인프라

@Controller
public class UserController { }  // 컨트롤러</code></pre><h4 id="new를-사용해야-하는-경우">new를 사용해야 하는 경우</h4>
<p>매번 새로운 상태를 가지는 데이터 객체</p>
<pre><code>// ❌ Bean으로 만들면 안 됨!
public class User {
    private String name;
    private int age;
}

public class OrderRequest {
    private Long userId;
    private List&lt;Item&gt; items;
}

public class SearchCondition {
    private String keyword;
    private LocalDate startDate;
}</code></pre><hr>
<h3 id="어노테이션-합성">어노테이션 합성</h3>
<p>어노테이션 합성은 여러 개의 어노테이션을 조합하여 하나의 새로운 어노테이션을 만드는 것을 의미</p>
<p>스프링은 어노테이션을 분석할 때, 해당 어노테이션 위에 붙어있는 다른 어노테이션(메타 어노테이션)까지 재귀적으로 탐색하여 그 기능을 모두 적용해 줌</p>
<blockquote>
<p>어노테이션도 어노테이션을 가질 수 있음!</p>
</blockquote>
<hr>
<h3 id="스프링-bean-등록">스프링 Bean 등록</h3>
<p>자동 등록과 수동 등록 방식이 있음</p>
<h4 id="bean-자동-등록">Bean 자동 등록</h4>
<p>Spring이 @Component 클래스를 찾아서 Bean으로 등록하는 방식</p>
<ul>
<li>@ComponentScan에 설정되어있는 패키지를 기준으로 하위의 모든 @Component 클래스를 탐색하여 Bean으로 등록</li>
</ul>
<h4 id="bean-수동-등록">Bean 수동 등록</h4>
<p>Spring의 @Configuration 클래스와 @Bean 메소드를 사용하여 명시적으로 Bean을 등록하는 방식</p>
<ul>
<li>@Configuration 클래스에 정의된 @Bean 메소드의 반환 객체가 Spring IoC 컨테이너에 Bean으로 등록</li>
</ul>
<blockquote>
<p>기본적으로 자동 등로이 우선, 불가피한 경우에만 수동 등록 활용</p>
</blockquote>
<hr>
<h3 id="didependency-injection-방식-비교">DI(Dependency Injection) 방식 비교</h3>
<p>Spring에서 의존성 주입은 &#39;생성자 주입&#39;, &#39;세터 주입&#39;, &#39;필드 주입&#39; 방식으로 구현할 수 있음</p>
<h4 id="필드-주입field-injection">필드 주입(Field Injection)</h4>
<p>@Autowired를 필드에 직접 선언하여 의존성을 주입받는 방식</p>
<p>-&gt; 더 이상 사용하지 않음!
여러 문제가 있지만, 대표적으로 final 키워드 사용이 불가능하기 때문에 변수에 할당된 객체를 아래의 코드 예처럼 null 혹은 다른 객체로 바꿀 수 있어 안전하지 않음</p>
<h4 id="세터-주입-setter-injection">세터 주입 (Setter Injection)</h4>
<p>Setter 메소드를 통해 의존성을 주입받는 방식</p>
<p>-&gt; 더 이상 사용하지 않음!
필드 주입 방식과 같은 문제가 있어 안전하지 않음</p>
<h4 id="생성자-주입-constructor-injection">생성자 주입 (Constructor Injection)</h4>
<p>생성자를 통해 의존성을 주입받는 방식</p>
<p>-&gt; 권장 방식</p>
<pre><code>@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
}</code></pre><ul>
<li><code>private final MemberRepository memberRepository;</code>
<code>final</code>로 선언하여 런타임에 의존성이 변경될 위험이 없음 </li>
</ul>
<p>이를 <strong>불변성(Immutability) 보장</strong>이라고도 함</p>
<hr>
<h3 id="bean-우선-순위">Bean 우선 순위</h3>
<p>스프링 컨테이너에 똑같은 타입의 Bean이 2개 이상 등록되면 어떻게 될까?</p>
<h4 id="해결-방법-1---primary">해결 방법 1 - @Primary</h4>
<p><code>@Primary</code>는 여러 빈 중에서 우선적으로 선택될 기본(Default) 빈을 지정하는 어노테이션 
-&gt; 별다른 설정이 없다면 <code>@Primary</code>가 붙은 빈이 자동으로 주입됨</p>
<h4 id="해결-방법-2---qualifier">해결 방법 2 - @Qualifier</h4>
<p><code>@Qualifier</code>라는 이름으로 <strong>특정 빈을 직접 지정</strong>하여 주입하는 어노테이션
<code>@Primary</code>보다 우선순위가 높으며, 더 구체적인 선택이 필요할 때 사용</p>
<hr>
<h3 id="bean-스코프">Bean 스코프</h3>
<p>Bean 스코프는 스프링 빈이 얼마나 오래, 그리고 어떻게 존재할지를 정의하는 개념
기본값은 싱글톤</p>
<h4 id="싱글톤-스코프">싱글톤 스코프</h4>
<p>스프링 컨테이너가 시작될 때 단 한 번만 생성되고, 애플리케이션이 끝날 때까지 계속 재사용되는 방식
대부분의 빈은 싱글톤으로 관리되며, 메모리 효율성이 매우 좋음</p>
<h4 id="프로토타입-스코프">프로토타입 스코프</h4>
<p>@Scope(&quot;prototype&quot;)으로 지정된 빈은, 요청이 올 때마다 계속 새로운 객체를 생성하여 반환
스프링 컨테이너는 생성만 책임지고, 그 이후의 관리는 하지 않음</p>
<hr>
<h3 id="라이프사이클-콜백">라이프사이클 콜백</h3>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/93a6d01f-d401-40b8-8431-efd7f97ca9cc/image.png" alt=""></p>
<ul>
<li>스프링 빈은 생성 -&gt;  의존성 주입 -&gt; 초기화 -&gt; 사용 -&gt; 소멸 이라는 생명주기(Lifecycle)를 가짐</li>
<li>이때, 초기화와 소멸 단계에서 특정 작업을 수행하도록 콜백(Callback) 메서드를 지정할 수 있음</li>
<li>초기화 단계<ul>
<li><code>@PostConstruct</code> 어노테이션이 붙은 메서드는 빈의 생성과 모든 의존성 주입이 완료된 직후에 딱 한 번 호출됨</li>
<li>주로 주입받은 의존성을 사용하여 외부 리소스를 가져오거나, 초기 설정 값을 세팅하는 등 무거운 초기화 작업에 사용</li>
</ul>
</li>
<li>소멸 단계<ul>
<li><code>@PreDestroy</code>는 스프링 컨테이너에서 빈이 제거되기 직전에 호출</li>
<li>주로 사용하던 외부 리소스의 연결을 안전하게 종료하거나, 임시 파일 삭제 등 뒷정리(Clean-up) 작업에 사용</li>
</ul>
</li>
</ul>
<pre><code>@Component
public class MusicPlayer {

    private List&lt;String&gt; playlist = new ArrayList&lt;&gt;();

    // 초기화 콜백: 의존성 주입이 끝난 후 실행
    @PostConstruct
    public void loadPlaylist() {
        System.out.println(&quot;--- @PostConstruct 호출 ---&quot;);
        playlist.add(&quot;아이유 - 라일락&quot;);
        playlist.add(&quot;BTS - Dynamite&quot;);
        System.out.println(&quot;플레이리스트 로딩 완료!&quot;);
    }

    // 소멸 전 콜백: 빈이 사라지기 직전 실행
    @PreDestroy
    public void saveProgress() {
        System.out.println(&quot;--- @PreDestroy 호출 ---&quot;);
        System.out.println(&quot;뮤직 플레이어를 종료합니다...&quot;);
    }
}</code></pre><hr>
<h3 id="til">TIL</h3>
<p>Bean을 그냥 스프링이 만들어주는 객체라고만 알고 있었는데, 오늘 정리하면서 생명주기까지 포함한 관리 대상이라는 걸 확실히 이해했다.</p>
<p>싱글톤이 “객체 1개”라는 의미에서 끝나는 게 아니라, 같은 객체를 여러 곳에서 공유하니까 상태를 가지면 위험해질 수 있다는 점이 인상 깊었다. 그래서 Service/Repository는 보통 stateless 하게 만드는 이유가 납득됐다.</p>
<p>예전엔 new로 객체 만들면 편하다고 생각했는데, IoC/DI를 쓰면 교체(확장), 테스트, 유지보수가 쉬워지는 구조가 된다는 걸 체감했다. “편한 코드”보다 “바꾸기 쉬운 코드”가 더 중요하다는 느낌.</p>
<p>DI 방식 중 필드 주입이 위험한 이유를 “그냥 쓰지 말라고 해서”가 아니라, final 불가 -&gt; 불변성 깨짐 -&gt; 테스트/안전성 떨어짐으로 논리적으로 이해하게 됐다. 이제는 생성자 주입이 자연스럽게 기본 선택이 될 것 같다.</p>
<p>@Primary와 @Qualifier는 단순한 문법이 아니라, Bean이 여러 개일 때 스프링이 어떤 기준으로 선택할지 ‘명확한 의도’를 코드로 남기는 장치라는 걸 알게 됐다.</p>
<p>@PostConstruct, @PreDestroy는 “있으면 편한 기능” 정도로 봤는데, 실제로는 외부 리소스 초기화/정리 같은 안정성을 책임지는 포인트라서 운영 관점에서 더 중요하다는 걸 깨달았다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[내일배움캠프 Spring 3기] 3 Layer Architecture, 영속성 컨텍스트]]></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-3-Layer-Architecture-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%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-3-Layer-Architecture-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Mon, 09 Feb 2026 02:42:28 GMT</pubDate>
            <description><![CDATA[<h2 id="3-layer-architecture">3 Layer Architecture</h2>
<p>3 Layer Architecture는 소프트웨어 시스템을 세 개의 논리적 계층으로 분리하는 아키텍처</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/ffe24420-e37b-484f-8872-2d720c2a6d1f/image.png" alt=""></p>
<p><strong>3개의 레이어로 분리</strong></p>
<ol>
<li>Controller Layer (Persentation Layer라고도 함)</li>
<li>Service Layer (Business Layer, Application Layer라고도 함)</li>
<li>Repository Layer (Data Layer라고도 함)</li>
</ol>
<p><strong>3 Layer Architecture의 목적</strong></p>
<ul>
<li><strong>관심사의 분리</strong><ul>
<li>각 계층은 고유한 책임만 담당</li>
<li>변경 사항의 영향 범위 최소화</li>
</ul>
</li>
<li><strong>유지보수성 향상</strong><ul>
<li>코드의 가독성과 이해도 증가</li>
<li>독립적인 테스트 가능</li>
</ul>
</li>
<li><strong>재사용성 증대</strong><ul>
<li>각 계층을 독립적으로 재사용</li>
<li>모듈화된 설계</li>
</ul>
</li>
<li><strong>확장성 개선</strong><ul>
<li>특정 계층만 변경하여 기능 확장</li>
<li>새로운 기술 도입 용이</li>
</ul>
</li>
</ul>
<hr>
<h3 id="controller-layer">Controller Layer</h3>
<p>일반 사용자가 애플리케이션과 상호작용하는 사용자 인터페이스 및 커뮤니케이션 계층</p>
<pre><code>@RestController
public class HelloController {
        // ...
}</code></pre><p><strong>해야 할 일</strong></p>
<ul>
<li>HTTP 요청 매핑</li>
<li>요청 파라미터 검증</li>
<li>응답 데이터 변환</li>
<li>예외 처리 및 에러 응답</li>
</ul>
<p><strong>하지 말아야 할 일</strong></p>
<ul>
<li>비즈니스 로직 처리</li>
<li>데이터베이스 직접 접근</li>
<li>복잡한 데이터 변환</li>
</ul>
<hr>
<h3 id="repository-layer">Repository Layer</h3>
<p>데이터베이스와의 상호작용을 담당
실제 데이터베이스에서 데이터를 저장하거나 가져오는 계층</p>
<pre><code>public interface UserRepository extends JpaRepository&lt;User, Long&gt; {
        // ...
}</code></pre><p><strong>해야 할 일</strong></p>
<ul>
<li>CRUD 연산 구현</li>
<li>쿼리 최적화</li>
<li>데이터 매핑</li>
</ul>
<p><strong>하지 말아야 할 일</strong></p>
<ul>
<li>비즈니스 로직 처리</li>
<li>HTTP 응답 생성</li>
<li>사용자 인터페이스 관련 작업</li>
</ul>
<hr>
<blockquote>
<h3 id="레이어간-통신-규칙">레이어간 통신 규칙</h3>
<p><strong>허용되는 통신</strong></p>
</blockquote>
<ul>
<li>상위 -&gt; 하위: 상위 계층이 하위 계층을 호출</li>
<li>같은 계층: 동일 계층 내 컴포넌트 간 통신<blockquote>
</blockquote>
</li>
<li><em>금지되는 통신*</em></li>
<li>하위 -&gt; 상위: 하위 계층이 상위 계층 직접 호출</li>
<li>계층 건너뛰기<ul>
<li>잘못된 예) Controller가 Repository 직접 호출</li>
</ul>
</li>
</ul>
<hr>
<h3 id="dtodata-transfer-object">DTO(Data Transfer Object)</h3>
<p>데이터를 전달하기 위한 순수 데이터 객체</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/484eef7c-a7ff-45cb-9391-925d89307cb0/image.png" alt=""></p>
<p>스프링은 Client의 요청을 받고, 이 요청을 자바 코드로 변환하여 처리함
이때, 사용자의 Request 데이터를 자바 코드에 담아 Controller에 옮겨줄 객체가 필요한데, 이 객체가 바로 DTO</p>
<hr>
<h2 id="영속성-컨텍스트">영속성 컨텍스트</h2>
<p>영속성 컨텍스트는 엔티티를 영구 저장하는 환경을 뜻하며 애플리케이션과 데이터베이스 사이에서 객체를 보관하는 가상의 데이터베이스 같은 역할을 함
엔티티 매니저를 통해 엔티티를 저장하거나 조회하면 엔티티 매니저는 영속성 컨텍스트 한 개를 만들어 엔티티를 보관하고 관리</p>
<blockquote>
<p>JPA가 엔티티를 관리하는 임시 저장소</p>
</blockquote>
<p><strong>영속성 컨텍스트가 하는 일</strong></p>
<ol>
<li>엔티티를 임시로 보관</li>
<li>변경사항을 추적 (더티체킹)</li>
<li>데이터베이스와 동기화</li>
</ol>
<h3 id="엔티티의-생명주기">엔티티의 생명주기</h3>
<p>엔티티 생명주기(Entity Lifecycle)란 엔티티 객체가 생성되어 소멸하기까지 거치는 여러 상태의 변화 과정을 의미</p>
<p><img src="https://velog.velcdn.com/images/jiiim_ni/post/813d2e54-28df-4dbc-9ecb-f304d36b9ddf/image.png" alt=""></p>
<h4 id="비영속">비영속</h4>
<p>영속성 컨텍스트와는 아무런 관계가 없는 상태</p>
<blockquote>
<p>쉽게 말하자면, DB에 한 번도 갔다오지 않은 상태</p>
</blockquote>
<h4 id="영속">영속</h4>
<p>엔티티가 영속성 컨텍스트에 의해 관리되는 상태</p>
<blockquote>
<p>쉽게 말하자면, DB를 갔다 온 상태
트랜잭션 내에서 데이터베이스를 한 번이라도 갔다 온 엔티티를 관리</p>
</blockquote>
<hr>
<h3 id="transactional-기본">@Transactional 기본</h3>
<p><strong>트랜잭션</strong>
여러 작업을 하나의 단위로 묶어서 모두 성공하거나 모두 실패하게 하는 것</p>
<blockquote>
<p>이 특성을 원자성이라고 함</p>
</blockquote>
<ul>
<li>@Transactional의 속성<ul>
<li><code>readOnly</code> : <code>true</code> 설정 시, 읽기 전용으로 사용하여 성능을 최적화 (CUD 작업 불가)</li>
<li><code>propagation</code> : 트랜잭션 전파 규칙을 정의합니다. (e.g., <code>REQUIRED</code>, <code>REQUIRES_NEW</code>)</li>
<li><code>isolation</code> : 트랜잭션의 격리 수준을 설정하여 동시성 문제를 제어</li>
<li><code>rollbackFor</code> : 특정 예외가 발생했을 때 강제로 롤백하도록 지정</li>
</ul>
</li>
</ul>
<hr>
<h3 id="jparepository-활용">JpaRepository 활용</h3>
<p><strong>JpaRepository CRUD</strong></p>
<p>JpaRepository를 사용하면 쿼리를 작성하지 않고도 쉽게 DB에 데이터를 CRUD 할 수 있음</p>
<ul>
<li>Create: 데이터 새로 넣기 -&gt; <code>save(entity)</code></li>
<li>Read: 데이터 읽기 -&gt; <code>findById</code>, <code>findAll</code> 등</li>
<li>Update: 값 바꾸기 -&gt; (트랜잭션 안에서) 엔티티의 필드만 변경 -&gt; 커밋 시 자동 UPDATE (<strong>더티체킹</strong>)</li>
<li>Delete: 삭제 -&gt; <code>delete(entity)</code>, <code>deleteById(id)</code>, <code>deleteAll()</code></li>
</ul>
<p>이러한 메서드들을 ‘쿼리메서드’라고 함</p>
]]></description>
        </item>
    </channel>
</rss>