<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>thedev_junyoung.log</title>
        <link>https://velog.io/</link>
        <description>Onward, Always Upward - 기록은 성장의 증거</description>
        <lastBuildDate>Tue, 17 Jun 2025 06:55:42 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>thedev_junyoung.log</title>
            <url>https://velog.velcdn.com/images/thedev_junyoung/profile/9d6e0a61-670f-43b4-8e6d-34c2063f57ea/image.PNG</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. thedev_junyoung.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/thedev_junyoung" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[항해 플러스 백엔드 후기 – 10주간의 몰입을 통한 성장]]></title>
            <link>https://velog.io/@thedev_junyoung/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%EB%B0%B1%EC%97%94%EB%93%9C-%ED%9B%84%EA%B8%B0-10%EC%A3%BC%EA%B0%84%EC%9D%98-%EB%AA%B0%EC%9E%85%EC%9D%84-%ED%86%B5%ED%95%9C-%EC%84%B1%EC%9E%A5</link>
            <guid>https://velog.io/@thedev_junyoung/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%EB%B0%B1%EC%97%94%EB%93%9C-%ED%9B%84%EA%B8%B0-10%EC%A3%BC%EA%B0%84%EC%9D%98-%EB%AA%B0%EC%9E%85%EC%9D%84-%ED%86%B5%ED%95%9C-%EC%84%B1%EC%9E%A5</guid>
            <pubDate>Tue, 17 Jun 2025 06:55:42 GMT</pubDate>
            <description><![CDATA[<h1 id="🛳">🛳</h1>
<p>10주간의 항해가 끝이 났다.
그 결과, 나는...
<strong>블랙 배지를 받았다.</strong>
<img src="https://velog.velcdn.com/images/thedev_junyoung/post/6d9cb3fd-8a1b-4159-8f96-9ac7f1cdf961/image.png" alt=""></p>
<p>인증된 무언가는 없지만, <strong>스스로 정말 몰입했다고 자부할 수 있는 결과</strong>라서 의미 있고 즐거운 마무리였다.</p>
<hr>
<h2 id="📅-일주일-시간표는-이렇게-흘러간다">📅 일주일 시간표는 이렇게 흘러간다</h2>
<p>항해플러스는 단순히 과제만 주는 프로그램이 아니다.</p>
<p><strong>문제를 이해하고, 팀과 소통하며, 구조적으로 해결해나가는 주간 사이클</strong>이 있다.</p>
<p>내가 경험한 주간 루틴은 아래와 같았다.</p>
<ul>
<li><p><strong>토요일</strong>: 발제 + 팀모임 + Q&amp;A</p>
<p>  → 한 주의 주제와 과제를 처음 마주하는 시간. 발제를 기반으로 토론하고 방향을 잡는다.</p>
</li>
<li><p><strong>일요일</strong>: 쉼</p>
<p>  → 머리를 식히는 리셋의 시간.</p>
</li>
<li><p><strong>월요일</strong>: 공개 Q&amp;A</p>
<p>  → 발제를 맡은 코치님과의 실시간 질의응답. 놓쳤던 개념을 다시 짚고 정리할 수 있는 기회.</p>
</li>
<li><p><strong>화요일</strong>: 팀 멘토링</p>
<p>  → 개인적으로 가장 값졌던 시간. 질문을 우선순위대로 정리해서 가져가면, 전반적인 설계부터 디테일까지 피드백을 받을 수 있다.</p>
<p>  <strong>같은 코치님께 꾸준히 받는 게 훨씬 효율적</strong>이었다. 맥락 공유가 되니까 피드백의 깊이가 달라진다.</p>
</li>
<li><p><strong>수~목</strong>: 집중 코딩</p>
<p>  → 이제 본격적인 과제 수행. 기획부터 테스트까지 몰입하는 시간이다.</p>
</li>
<li><p><strong>금:</strong> 오전 10시 까지 과제 제출</p>
</li>
</ul>
<hr>
<h2 id="밤샘-그리고-버티는-힘">밤샘, 그리고 ‘버티는 힘’</h2>
<p>솔직히 말하면, <strong>거의 매주 목요일은 밤을 샜다.</strong>
과제 양 때문만은 아니다.
<strong>눈에 거슬리는 걸 그냥 두지 못하는 성격</strong> 때문이었다.
리팩토링, 테스트 코드, 설계의 구조적 정당성까지
내가 납득이 안 되면 끝낼 수가 없었다.
그래서 항상 계획보다 시간이 더 걸렸고, 결과적으로 더 몰입하게 됐다.
그러다 보면 종종 “이거 왜 이렇게까지 해야 해?”라는 회의감도 왔지만,
결국 <strong>이 과정을 거쳤기에 그 주차의 개념이 정말 내 것이 되었다</strong>고 느낀다.</p>
<hr>
<h2 id="항해에서-얻은-것">항해에서 얻은 것</h2>
<p>처음 항해를 선택한 이유는 단순했다.</p>
<p><strong>혼자 공부하는 것보다, 목표와 경쟁이 있는 환경에서 더 성장할 수 있다</strong>고 믿었기 때문이다.</p>
<p>그리고 실제로 그랬다.</p>
<p>항해는 <strong>‘어떻게 구현할 것인가’가 아니라 ‘왜 그렇게 설계해야 하는가’를 고민하게 만든다.</strong></p>
<ul>
<li>Redis 락을 왜 썼는가?</li>
<li>Kafka DLQ를 어떻게 설계했는가?</li>
<li>이 구조가 운영 환경에서도 안정적으로 돌아갈 수 있는가?</li>
</ul>
<p>이런 질문에 <strong>“그게 맞는 이유”를 설명할 수 있을 정도로 깊이 고민하는 훈련</strong>을 시켰다.</p>
<p>내가 생각하는 진짜 개발자란, 결국 <strong>‘말할 수 있는 사람’</strong>이라고 믿는데,</p>
<p>항해는 그 기반을 만들어준 과정이었다.</p>
<hr>
<h2 id="wil-쓰는-습관--성장을-기록하는-법">WIL 쓰는 습관 – 성장을 기록하는 법</h2>
<p>항해에서는 매주 WIL(What I Learned)을 쓰도록 권장했다.</p>
<p>처음엔 그냥 해야 하니까 썼는데, <strong>점점 이게 나만의 성장 기록</strong>이 되어갔다.</p>
<ul>
<li>어떤 개념이 헷갈렸는지</li>
<li>이번 주 설계에서 왜 그렇게 했는지</li>
<li>지금 다시 하면 어떻게 바꿀 수 있을지</li>
</ul>
<p><strong>단순한 기술 요약이 아니라, 스스로에게 질문을 던지는 시간이었고</strong>,</p>
<p>그게 항해를 10주 동안 <strong>의미 있게 이어갈 수 있었던 큰 동기</strong>가 됐다.</p>
<p>막막하거나 자신이 없을 때, 지난주 WIL을 다시 읽어보면</p>
<p>“그래도 여기까지 왔구나” 하는 힘이 생겼고,</p>
<p>돌아보니 이 습관이 <strong>개발자로서 사고력을 키워준 밑바탕</strong>이 된 것 같다.</p>
<ul>
<li>회고상을 받았다 !!!!
<img src="https://velog.velcdn.com/images/thedev_junyoung/post/84b3bf56-ab79-41bd-a891-2a4f268cde77/image.jpeg" alt=""></li>
</ul>
<hr>
<h2 id="함께-항해한-사람들-덕분에">함께 항해한 사람들 덕분에</h2>
<p>혼자였다면 절대 못 했을 고민과 시도들을,</p>
<p><strong>팀원들과 함께였기에 끝까지 밀고 나갈 수 있었다.</strong></p>
<p>특히 우리 팀은 <strong>기술뿐만 아니라 태도 면에서도 서로에게 긍정적인 자극</strong>이 되었다.</p>
<p>그리고 <strong>코치님들의 멘토링</strong>은 정말 큰 힘이 됐다.</p>
<p>단순한 기능 구현을 넘어,</p>
<p><strong>실제 서비스에서 어떤 기준으로 판단하고, 어떻게 구조를 설계해야 하는지</strong></p>
<p>날카롭고 현실적인 조언을 아낌없이 해주셨다.</p>
<p>항해를 시작할 때는 “커뮤니티? 네트워킹?” 별 생각 없었는데,</p>
<p>지금은 이 <strong>연결이야말로 가장 큰 자산</strong>이라고 느낀다.</p>
<hr>
<h2 id="✨-마무리하며">✨ 마무리하며</h2>
<p><img src="https://velog.velcdn.com/images/thedev_junyoung/post/d762e69c-ed0f-4823-9934-fb15c9069770/image.jpeg" alt=""></p>
<p>누군가가 “항해플러스 어땠어?”라고 묻는다면</p>
<p>나는 이렇게 답할 것 같다.</p>
<blockquote>
<p>“성장하고 싶어요? 그냥 하세요”</p>
</blockquote>
<hr>
<blockquote>
<p>항해플러스과정에 대해 문의사항이나 과정 진행 중에 도움이 필요하다면 언제든 연락주셔도 좋습니다.
추가로 K71MWE 코드를 입력하면 20만원 할인이 가능하다</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 9주차 WIL – 도메인 이벤트 기반 Outbox + Kafka 비동기 아키텍처 ]]></title>
            <link>https://velog.io/@thedev_junyoung/9wd-WIL</link>
            <guid>https://velog.io/@thedev_junyoung/9wd-WIL</guid>
            <pubDate>Tue, 03 Jun 2025 09:49:21 GMT</pubDate>
            <description><![CDATA[<h1 id="이번-주-목표">이번 주 목표</h1>
<hr>
<p>이번 주의 핵심 목표는 <strong>도메인 이벤트를 기점으로 Outbox + Kafka 기반의 비동기 전파 아키텍처</strong>를 직접 설계하고,</p>
<p>그 흐름을 통해 도메인 간의 협업을 결합도 낮은 구조로 구현해보는 것이었다.</p>
<p>이를 통해 다음과 같은 설계를 실험하고 검증했다:</p>
<ul>
<li>쿠폰 발급, 주문 확정, 외부 전송 등의 도메인 흐름을 상태 기반 이벤트로 분리</li>
<li>도메인에서 이벤트 등록 → ApplicationEventPublisher 를 통한 발행</li>
<li><code>@TransactionalEventListener(BEFORE_COMMIT or AFTER_COMMIT)</code> 기반 이벤트 감지</li>
<li>Outbox DB 저장 → Kafka로 전송하는 이벤트 릴레이 구조 구현</li>
<li>Kafka Consumer → 후속 도메인 처리까지 연결</li>
<li>Awaitility 기반 통합 테스트로 전체 흐름 검증</li>
</ul>
<hr>
<h2 id="문제-정의">문제 정의</h2>
<h3 id="1-동기-트랜잭션-내에서-모든-처리를-해결하려다-보니-책임이-모호해짐">1. 동기 트랜잭션 내에서 모든 처리를 해결하려다 보니 책임이 모호해짐</h3>
<p>예를 들어, 쿠폰 발급 과정에서 <strong>수량 확인 → 발급 처리 → 메시지 전송</strong>까지 한 메서드에 몰려있었고,</p>
<p>메시지 발행 실패 시 전체 트랜잭션이 롤백되는 구조는 장애 복원력 측면에서도 한계가 있었다.</p>
<h3 id="2-이벤트는-도메인에서-발생하지만-전송처리는-애플리케이션-레이어의-역할">2. 이벤트는 도메인에서 발생하지만, 전송/처리는 애플리케이션 레이어의 역할</h3>
<p>초기에는 컨트롤러나 서비스 단에서 Kafka 메시지를 직접 발행하려 했으나,</p>
<p><strong>&quot;도메인의 책임은 상태 전이&quot;</strong>,</p>
<ul>
<li>*&quot;메시지 전파는 후속 처리&quot;**라는 역할 분리가 필요하다는 결론에 도달했다.</li>
</ul>
<hr>
<h2 id="해결-전략-및-설계-방식">해결 전략 및 설계 방식</h2>
<h3 id="도메인-→-applicationevent-→-outbox-→-kafka-구조">도메인 → ApplicationEvent → Outbox → Kafka 구조</h3>
<p>도메인 객체는 상태 변화 시 <code>registerEvent()</code>를 통해 도메인 이벤트를 등록하고,</p>
<p>Application Layer에서 <code>publishEvent()</code>로 발행한다.</p>
<pre><code class="language-java">coupon.validateUsable(clock, userId); // → 내부적으로 도메인 이벤트 등록
coupon.getDomainEvents().forEach(eventPublisher::publishEvent);
coupon.clearEvents();</code></pre>
<hr>
<h3 id="전체-흐름-예시">전체 흐름 예시</h3>
<h3 id="쿠폰-발급-요청-흐름">쿠폰 발급 요청 흐름</h3>
<pre><code>[Coupon 도메인]
 - validateUsable() 내부에서 CouponIssueRequestedEvent 등록

[Application Layer]
 - @TransactionalEventListener(BEFORE_COMMIT)
   → OutboxService.saveEvent() 호출 → OutboxMessage 저장

[Infrastructure Layer]
 - ScheduledOutboxRelayRunner (1초마다 실행)
   → 저장된 메시지를 Kafka 로 전송 → 성공 시 lastProcessedId 갱신

[Kafka Consumer]
 - coupon.issue.requested 수신 → 발급 처리 실행</code></pre><h3 id="결제-성공-→-주문-상태-변경-→-외부-전송-흐름">결제 성공 → 주문 상태 변경 → 외부 전송 흐름</h3>
<pre><code>[Payment 도메인]
 - createSuccess() 시 OrderConfirmedEvent 등록

[OrderConfirmedEventHandler]
 - AFTER_COMMIT + @Async로 수신 → 주문 상태 CONFIRMED 전이

[OrderAggregate]
 - markConfirmed() → ProductSalesRankRecordedEvent, OrderExportRequestedEvent 등록

[OrderExportEventHandler]
 - AFTER_COMMIT + @Async → Kafka 전송

[Kafka Consumer]
 - order-export 수신 → 외부 연동 처리</code></pre><hr>
<h2 id="테스트-전략">테스트 전략</h2>
<h3 id="통합-테스트-검증-포인트">통합 테스트 검증 포인트</h3>
<table>
<thead>
<tr>
<th>테스트 시나리오</th>
<th>검증 내용</th>
</tr>
</thead>
<tbody><tr>
<td>쿠폰 발급 요청</td>
<td>도메인 이벤트 등록 → Outbox 저장 → Kafka 전파 → 발급 처리</td>
</tr>
<tr>
<td>주문 생성 → 재고 감소 실패</td>
<td>Kafka 메시지 전송 실패 시 OrderCanceled 이벤트 발행</td>
</tr>
<tr>
<td>결제 성공</td>
<td>주문 상태 전이 + 후속 이벤트 발행 및 Kafka 전송</td>
</tr>
<tr>
<td>주문 CONFIRMED</td>
<td>상품 랭킹 등록 + 외부 전송 이벤트 발행</td>
</tr>
</tbody></table>
<p><strong>Awaitility</strong>를 활용하여 비동기 리스너가 동작 완료될 때까지 기다리며, 로그 기반으로 실제 흐름 검증 완료.</p>
<hr>
<h2 id="인사이트">인사이트</h2>
<h3 id="도메인-이벤트는-도메인-상태-전이의-일부로만-발생해야-한다">도메인 이벤트는 도메인 상태 전이의 일부로만 발생해야 한다</h3>
<ul>
<li>단순 메시지 전송을 위한 이벤트는 Application 레벨에서만 처리하고,</li>
<li>도메인 이벤트는 <strong>도메인의 상태 변화가 명확히 발생한 경우에만</strong> 사용해야 구조가 명확해졌다.</li>
</ul>
<h3 id="kafka-도입의-기준점은-타-도메인으로의-이벤트-전파-필요성">Kafka 도입의 기준점은 ‘타 도메인으로의 이벤트 전파 필요성’</h3>
<ul>
<li><p>쿠폰 발급 같은 단일 도메인 처리라면 Redis/Stream 으로도 충분하지만,</p>
</li>
<li><p>주문 → 랭킹 → 외부 전송처럼 도메인이 여러 개로 분리되고,</p>
<p>  처리를 병렬화하거나 장애 복원성을 확보하고자 할 때 Kafka의 도입 가치가 명확해졌다.</p>
</li>
</ul>
<h3 id="outbox-패턴은-메시지-신뢰성과-장애-대응의-기초">Outbox 패턴은 메시지 신뢰성과 장애 대응의 기초</h3>
<ul>
<li><p>메시지 저장 → 별도 릴레이 스케줄러 → Kafka 전송 구조를 구현하면서,</p>
<p>  트랜잭션 일관성과 메시지 재처리 구조를 어느 정도 확보했다.</p>
</li>
<li><p>다만 실패 메시지 저장, Retry 큐, DLT(DLQ)까지는 아직 보완이 필요하다.</p>
</li>
</ul>
<hr>
<h2 id="요약-정리">요약 정리</h2>
<table>
<thead>
<tr>
<th>흐름</th>
<th>이벤트</th>
<th>리스너/처리 방식</th>
<th>목적</th>
</tr>
</thead>
<tbody><tr>
<td>쿠폰 발급 요청</td>
<td>CouponIssueRequestedEvent</td>
<td>BEFORE_COMMIT → Outbox 저장</td>
<td>Kafka로 전파 후 발급 처리</td>
</tr>
<tr>
<td>결제 성공</td>
<td>OrderConfirmedEvent</td>
<td>AFTER_COMMIT → 주문 CONFIRMED</td>
<td>후속 이벤트 트리거</td>
</tr>
<tr>
<td>주문 CONFIRMED</td>
<td>ProductSalesRankRecordedEvent, OrderExportRequestedEvent</td>
<td>AFTER_COMMIT + @Async</td>
<td>상품 랭킹, 외부 전송</td>
</tr>
<tr>
<td>메시지 전파</td>
<td>OutboxMessage</td>
<td>Scheduled Relay → Kafka</td>
<td>트랜잭션과 전파 분리</td>
</tr>
</tbody></table>
<hr>
<h2 id="회고">회고</h2>
<h3 id="성과">성과</h3>
<ul>
<li>Outbox 기반의 Kafka 연동 구조를 도메인 이벤트로 연결</li>
<li>도메인 책임(상태 전이) ↔ 후속 전파(메시징) 역할 분리</li>
<li>Kafka, ApplicationEvent, Scheduler, Consumer의 흐름을 하나의 시나리오로 통합</li>
</ul>
<h3 id="아쉬움">아쉬움</h3>
<ul>
<li>단일 애플리케이션 구조에서 Kafka를 붙이다 보니 약간의 과도함</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 백엔드8주차 회고(WIL): 도메인 이벤트 기반 트랜잭션 분리]]></title>
            <link>https://velog.io/@thedev_junyoung/8wd-WIL</link>
            <guid>https://velog.io/@thedev_junyoung/8wd-WIL</guid>
            <pubDate>Fri, 23 May 2025 05:13:39 GMT</pubDate>
            <description><![CDATA[<h2 id="이번-주-목표">이번 주 목표</h2>
<p>이번 주의 핵심 목표는 <strong>핵심 도메인의 상태 전이를 기준으로 트랜잭션 경계를 재설계하고, 이벤트 기반으로 도메인 간 협업을 비동기화하여 실시간성과 복원력을 동시에 확보하는 구조를 구현 및 검증하는 것</strong>이었다.</p>
<p>이를 위해 다음과 같은 도메인 이벤트 체계와 상태 기반 설계를 적용하고, 이벤트 실패 보상 흐름까지 포함한 구조적 실험을 진행했다:</p>
<ul>
<li><code>결제 성공 → 주문 CONFIRMED → 상품 랭킹 등록 → 외부 전송</code> 까지 이벤트 체이닝 구현</li>
<li>잔액 충전 → 잔액 이력 저장 로직을 도메인 이벤트로 분리</li>
<li>주문 확정 실패 시 <code>OrderConfirmationFailedEvent</code> 로 보상 트리거 설계</li>
<li><code>@TransactionalEventListener(AFTER_COMMIT) + @Async</code> 기반 비동기 리스닝 구조 적용</li>
<li>Awaitility 기반의 E2E 통합 테스트로 전체 이벤트 체이닝 검증</li>
</ul>
<hr>
<h2 id="문제-정의">문제 정의</h2>
<h3 id="1-결제주문-트랜잭션이-하나의-흐름에-결합되어-실패-전파가-광범위함">1. 결제/주문 트랜잭션이 하나의 흐름에 결합되어 실패 전파가 광범위함</h3>
<ul>
<li>결제 성공 후 주문 상태 변경, 상품 랭킹 기록, 외부 전송까지 모두 한 흐름에 존재</li>
<li>중간 하나라도 실패하면 전체 트랜잭션이 롤백되어 UX와 데이터 무결성 모두에 악영향</li>
</ul>
<h3 id="2-부가-로직이-핵심-로직-내부에서-처리되어-트랜잭션이-길어짐">2. 부가 로직이 핵심 로직 내부에서 처리되어 트랜잭션이 길어짐</h3>
<ul>
<li>판매량 기록이나 외부 연동 요청 같은 부가 로직이 핵심 결제/주문 트랜잭션에 포함됨</li>
<li>I/O 작업 포함 시 타임아웃, 장애 전파, 트랜잭션 병목 리스크 증가</li>
</ul>
<hr>
<h2 id="해결-전략-및-설계-방식">해결 전략 및 설계 방식</h2>
<hr>
<h3 id="🧾-step15--도메인-이벤트-기반-관심사-분리">🧾 STEP15 – 도메인 이벤트 기반 관심사 분리</h3>
<h3 id="설계-목표">설계 목표</h3>
<ul>
<li>결제, 주문, 잔액, 통계, 외부 전송 등 도메인을 <strong>상태 전이 기반</strong>으로 명확히 분리</li>
<li>후속 처리는 모두 이벤트 리스너에서 처리하여 <strong>핵심 트랜잭션은 작게</strong>, <strong>처리는 분산되도록 설계</strong></li>
</ul>
<h3 id="이벤트-체이닝-구조">이벤트 체이닝 구조</h3>
<pre><code>1. 결제 성공 시 → OrderConfirmedEvent 발행
2. 주문 CONFIRMED → ProductSalesRankRecordedEvent, OrderExportRequestedEvent 발행
3. 각 이벤트는 AFTER_COMMIT + Async 리스너에서 소비
4. 실패 시 OrderConfirmationFailedEvent 로 보상 이벤트 분리</code></pre><hr>
<h2 id="테스트-전략">테스트 전략</h2>
<h3 id="통합-테스트-구조">통합 테스트 구조</h3>
<ul>
<li><code>Awaitility</code>를 활용한 비동기 리스너 대기</li>
<li>실제 이벤트 흐름이 순차적으로 실행되는지, 비동기 리스너가 정상 동작하는지 확인</li>
</ul>
<table>
<thead>
<tr>
<th>테스트명</th>
<th>검증 포인트</th>
</tr>
</thead>
<tbody><tr>
<td><code>charge_shouldRecordBalanceHistory</code></td>
<td>잔액 충전 → 이력 저장 도메인 이벤트 분리 검증</td>
</tr>
<tr>
<td><code>should_process_payment_successfully_and_confirm_order</code></td>
<td>결제 성공 → 주문 상태 변경 → 통계/외부 전송 이벤트 체이닝 정상 동작</td>
</tr>
</tbody></table>
<p>📌 통합 테스트 실행 로그에서 도메인 이벤트 등록, 리스너 실행 로그까지 추적</p>
<hr>
<h2 id="인사이트">인사이트</h2>
<ul>
<li>핵심 도메인은 <strong>상태 전이만 책임</strong>지고, 후속 로직은 이벤트로 연결할 때 구조가 명확해진다.</li>
<li><code>@TransactionalEventListener(AFTER_COMMIT) + @Async</code> 구조는 핵심 트랜잭션과 후속 I/O 작업을 분리하면서도 흐름을 연결시켜준다.</li>
<li>이벤트가 늘어나면 복잡도도 함께 증가한다. 따라서 <strong>어떤 이벤트는 발행하지 말고 도메인 내부로 처리해야 할지 선별 기준이 필요</strong>하다.</li>
<li>실패 상황에 대비해 <code>XXXFailedEvent</code>를 별도 정의하고 로깅하는 방식은 Kafka가 없는 상황에서도 <strong>복원력을 확보하는 현실적인 대안</strong>이 될 수 있다.</li>
<li>이벤트 기반 구조는 단순히 메시지를 넘기는 수준이 아니라, <strong>도메인의 책임을 상태 전이 단위로 구조화</strong>할 수 있는 수단이기도 하다.</li>
</ul>
<hr>
<h2 id="요약-정리">요약 정리</h2>
<table>
<thead>
<tr>
<th>흐름</th>
<th>핵심 설계</th>
<th>기술</th>
</tr>
</thead>
<tbody><tr>
<td>결제 성공 → 주문 확정</td>
<td>상태 기반 이벤트 트리거</td>
<td>OrderConfirmedEvent</td>
</tr>
<tr>
<td>주문 확정 → 통계/전송</td>
<td>이벤트 체이닝</td>
<td>ProductSalesRankRecordedEvent, OrderExportRequestedEvent</td>
</tr>
<tr>
<td>실패 보상 처리</td>
<td>실패 이벤트 발행 및 로깅</td>
<td>OrderConfirmationFailedEvent</td>
</tr>
<tr>
<td>트랜잭션 경계 최소화</td>
<td>핵심만 @Transactional, 나머지는 이벤트 리스너</td>
<td>AFTER_COMMIT + @Async</td>
</tr>
<tr>
<td>통합 테스트</td>
<td>Awaitility 기반 이벤트 흐름 검증</td>
<td>E2E 시나리오</td>
</tr>
</tbody></table>
<hr>
<h2 id="지난-목표-회고">지난 목표 회고</h2>
<p>지난 주 목표는 <strong>도메인 이벤트 기반 설계 적용 및 트랜잭션 경계 분리</strong>였으며, 아래 항목을 통해 실질적으로 달성했다.</p>
<ul>
<li>모든 상태 전이 기반 도메인 이벤트 등록 완료</li>
<li>통합 테스트로 트랜잭션 내/외 경계 분리 흐름 검증 완료</li>
<li>이벤트 실패 대비 설계 (<code>OrderConfirmationFailedEvent</code>) 도입</li>
</ul>
<p>다만 Kafka나 Outbox 기반의 실제 MSA 구조에서의 전파 설계는 아직 적용하지 못했다. 실패 이벤트를 DB 테이블에 저장하거나 Retry Queue를 활용한 자동 복구 설계는 향후 과제로 남는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 백엔드 7주차 회고(WIL): 랭킹과 쿠폰, 실시간 시스템]]></title>
            <link>https://velog.io/@thedev_junyoung/7wd-WIL</link>
            <guid>https://velog.io/@thedev_junyoung/7wd-WIL</guid>
            <pubDate>Fri, 16 May 2025 02:06:23 GMT</pubDate>
            <description><![CDATA[<hr>
<h2 id="이번-주-목표">이번 주 목표</h2>
<p>이번 주의 핵심 목표는 <strong>Redis의 자료구조를 활용해 DB 부하를 줄이고, 실시간 처리 및 비동기 처리를 안정적으로 구성할 수 있는 구조를 설계하고 구현하는 것</strong>이었다.</p>
<p>이를 위해 다음 두 가지 기능을 중심으로 설계를 진행했다:</p>
<ul>
<li><strong>Redis Sorted Set 기반의 실시간 인기 상품 랭킹 시스템</strong></li>
<li><strong>Redis Stream 기반의 비동기 쿠폰 발급 시스템 (DLQ + 재시도 포함)</strong></li>
</ul>
<hr>
<h2 id="문제-정의">문제 정의</h2>
<h3 id="1-실시간-통계를-rdb에-저장하면-발생하는-성능-문제">1. 실시간 통계를 RDB에 저장하면 발생하는 성능 문제</h3>
<ul>
<li>주문 확정 시마다 DB에 인기 상품 통계를 쓰는 구조는 <strong>RDB 쓰기 부하 집중</strong> 및 <strong>락 충돌</strong> 발생 우려</li>
<li>특히 주문이 폭주하는 피크 타임에는 통계 기록만으로도 DB 병목이 생길 수 있음</li>
</ul>
<h3 id="2-선착순-쿠폰-발급에서의-race-condition">2. 선착순 쿠폰 발급에서의 Race Condition</h3>
<ul>
<li>동시에 수천 명이 요청을 보내면, <code>count &lt; total</code> 조건을 통과한 요청이 중복 발급되는 현상 발생 가능</li>
<li>단순한 동기 락으로는 <strong>병렬성 확보가 어렵고</strong>, 무조건 실패시키는 방식은 사용자 경험을 해침</li>
</ul>
<hr>
<h2 id="해결-전략-및-설계-방식">해결 전략 및 설계 방식</h2>
<hr>
<h3 id="step13--실시간-랭킹-시스템-설계-redis-sorted-set">STEP13 – 실시간 랭킹 시스템 설계 (Redis Sorted Set)</h3>
<h3 id="설계-목표">설계 목표</h3>
<ul>
<li><strong>실시간 인기 상품 통계 기록을 RDB가 아닌 Redis로 우선 처리</strong></li>
<li>기간별(일간/주간/월간) 랭킹 구분과 향후 확장성 고려</li>
</ul>
<h3 id="구조-구성">구조 구성</h3>
<pre><code>ZADD product:ranking:{date} {score} {productId}
EXPIRE product:ranking:{date} 7d
</code></pre><ul>
<li>Sorted Set을 활용하여 주문 수를 점수로 누적</li>
<li>TTL을 이용해 Redis 용량 관리 + 최신성 유지</li>
<li><code>RecordProductSalesEvent</code>를 <code>@TransactionalEventListener</code>로 처리하여 주문과 분리</li>
</ul>
<h3 id="전략-패턴-적용">전략 패턴 적용</h3>
<ul>
<li>기간별 랭킹 생성을 위해 <code>RankingStrategy</code>를 도입해 OCP 확장</li>
<li>일간/주간/월간 기준에 따라 Redis Key/날짜 생성 방식만 변경되도록 추상화</li>
</ul>
<pre><code class="language-java">public interface RankingStrategy {
    String key(ProductRankingCommand command);
    LocalDate date();
}
</code></pre>
<hr>
<h3 id="step14--비동기-쿠폰-발급-redis-stream--dlq">STEP14 – 비동기 쿠폰 발급 (Redis Stream + DLQ)</h3>
<h3 id="설계-목표-1">설계 목표</h3>
<ul>
<li>트래픽이 몰려도 <strong>발급 요청을 안전하게 수용</strong>하고, <strong>실패 시 복구 가능한 구조</strong>로 설계</li>
<li>DB가 아닌 Redis Stream으로 요청을 버퍼링 → Consumer가 순차 처리</li>
</ul>
<h3 id="전체-처리-흐름">전체 처리 흐름</h3>
<pre><code>1. Publisher: Redis Stream 에 발급 요청 저장
2. Consumer: ConsumerGroup 으로 메시지 처리
   → 중복 검사, DB 저장, ACK
   → 실패 시 DLQ로 전송
3. DLQConsumer: 실패 메시지 재시도, 3회 이상 실패 시 폐기
</code></pre><h3 id="재시도보상-설계">재시도/보상 설계</h3>
<ul>
<li>DLQ 메시지는 <code>retry:{recordId}</code> Redis 키로 재시도 횟수 관리</li>
<li><code>REQUIRES_NEW</code> 트랜잭션을 사용하여 처리 단위를 작게 분리</li>
<li>중복 발급 방지는 Redis + DB에서 이중 체크 → <strong>완벽한 멱등성 확보</strong></li>
</ul>
<hr>
<h2 id="테스트-전략">테스트 전략</h2>
<ul>
<li>Embedded Redis 기반 통합 테스트에서 <code>Awaitility</code>로 Redis Stream 메시지 처리 대기</li>
<li>Consumer 수동 호출 → 성공/실패/재시도까지 <strong>End-to-End 시나리오 검증</strong></li>
<li>발급 실패 시 DLQ → 재처리 후 DB 저장 여부까지 체크</li>
</ul>
<hr>
<h2 id="인사이트">인사이트</h2>
<ul>
<li>Redis는 단순 캐시뿐 아니라 <strong>실시간 처리 버퍼, 순위 정렬, 중복 제어 등 다양한 역할</strong>로 활용 가능</li>
<li><strong>비동기 설계에서 중요한 건 실패 복구 시나리오를 구조적으로 설계하는 것</strong></li>
<li>테스트는 &quot;정상 작동&quot;만이 아니라 <strong>실패 → 복구 → 재시도</strong> 흐름까지 고려해야 설계의 완성도가 높아짐</li>
<li>Kafka 없이도 Redis Stream만으로 충분히 <strong>트래픽 흡수 + 처리를 분리하는 설계</strong> 가능</li>
</ul>
<hr>
<h2 id="요약-정리">요약 정리</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>기술</th>
<th>목적</th>
<th>설계 포인트</th>
</tr>
</thead>
<tbody><tr>
<td>랭킹 기록</td>
<td>Redis Sorted Set</td>
<td>DB 부하 제거</td>
<td>TTL 설정, 전략 패턴</td>
</tr>
<tr>
<td>이벤트 처리</td>
<td><code>@TransactionalEventListener</code></td>
<td>트랜잭션 분리</td>
<td>주문 → 통계 비동기화</td>
</tr>
<tr>
<td>발급 큐</td>
<td>Redis Stream</td>
<td>대량 트래픽 흡수</td>
<td>Consumer Group 처리</td>
</tr>
<tr>
<td>실패 복구</td>
<td>DLQ + Retry</td>
<td>메시지 유실 방지</td>
<td>최대 3회 재시도</td>
</tr>
</tbody></table>
<hr>
<h2 id="총평">총평</h2>
<p>이번 주는 단순 기능 구현을 넘어서, <strong>&quot;운영 가능한 구조란 무엇인가&quot;에 대해 깊이 고민한 시간</strong>이었다.</p>
<p>Redis는 캐시 도구가 아니라 <strong>하나의 구조적 저장소이며 처리 흐름의 중심이 될 수 있다는 가능성</strong>을 실감했다. 특히 Redis Stream은 Kafka가 없더라도 실시간 트래픽 처리, 재시도, 실패 보완까지 충분히 커버 가능하다는 점에서 강력한 무기가 될 수 있음을 확인했다.</p>
<p>비동기 시스템은 단순히 큐에 메시지를 밀어넣는 게 아니라, <strong>실패했을 때의 복구 전략까지 함께 설계되어야 진정한 운영 가능한 구조</strong>가 된다는 사실도 체감했다. DLQ, retry count, REQUIRES_NEW 트랜잭션 분리 같은 것들이 그 고민의 결과였다.</p>
<p>또한 랭킹 시스템 구현에서 <strong>OCP를 만족하는 전략 패턴</strong>을 도입한 경험은 단지 정렬 기준의 유연성 확보가 아니라, 구조적 설계가 시스템의 유지보수성과 직결된다는 것을 보여주는 좋은 사례였다.</p>
<p>이번 주는 Redis라는 외부 인프라를 <strong>신뢰할 수 있는 일관성 보장 시스템으로 끌어올리는 실전 연습</strong>이었다. 코드뿐 아니라 아키텍처, 장애 상황, 테스트 전략까지 종합적으로 고민하고 구현한 한 주였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 백엔드 6주차 회고(WIL): 다중 인스턴스 환경에서 동시성 제어와 캐싱 최적화]]></title>
            <link>https://velog.io/@thedev_junyoung/6wd-WIL</link>
            <guid>https://velog.io/@thedev_junyoung/6wd-WIL</guid>
            <pubDate>Fri, 09 May 2025 07:05:46 GMT</pubDate>
            <description><![CDATA[<h2 id="이번-주-목표">이번 주 목표</h2>
<p>이번 주의 핵심 목표는 <strong>외부 인프라인 Redis를 활용하여, 다중 인스턴스 환경에서 발생할 수 있는 DB 병목 문제를 방지하는 구조를 설계하고 구현하는 것</strong>이었다.</p>
<p>이를 위해 적용했던 내용 중 다음 두 가지를 정리해보고자 한다.</p>
<ul>
<li><strong>Redisson 기반 분산락을 적용한 주문 생성 등 로직 리팩토링</strong></li>
<li><strong>@Cacheable(sync = true) 기반의 캐시 적용 + Pre-warming 전략 도입</strong></li>
</ul>
<hr>
<h2 id="문제-정의">문제 정의</h2>
<h3 id="1-재고-차감-로직에서의-경쟁-조건">1. 재고 차감 로직에서의 경쟁 조건</h3>
<ul>
<li>기존 구조는 단일 인스턴스, 단일 트랜잭션을 전제로 작성되어 있어 <strong>다중 인스턴스 환경에서 Race Condition 발생 가능성</strong>이 있었다.</li>
<li>예: 두 사용자가 동시에 동일 상품을 주문할 경우 재고 차감이 중복되어 음수 재고 발생</li>
</ul>
<h3 id="2-인기-상품-조회에서-발생하는-성능-병목">2. 인기 상품 조회에서 발생하는 성능 병목</h3>
<ul>
<li><code>/api/v1/products/popular</code> API는 캐시가 없을 경우 매 요청마다 DB 조회가 발생</li>
<li>트래픽 증가 시 DB 병목으로 <strong>응답 시간 급증</strong> 및 <strong>처리량 한계</strong> 발생</li>
</ul>
<hr>
<h2 id="해결-전략-및-설계-방식">해결 전략 및 설계 방식</h2>
<hr>
<h3 id="step11---redisson-기반-분산락--보상-트랜잭션-설계">STEP11 - Redisson 기반 분산락 + 보상 트랜잭션 설계</h3>
<p>기존의 주문 생성 로직은 하나의 메서드에 모든 책임이 집중되어 있었다:</p>
<pre><code class="language-java">@Transactional
public OrderResult createOrder(CreateOrderCommand command) {
    for (CreateOrderCommand.OrderItemCommand item : command.items()) {
        stockService.decrease(...);
        productService.getProductDetail(...);
        // ...
    }
    ApplyCouponResult result = couponUseCase.applyCoupon(...);
    Order order = orderService.createOrder(...);
    orderEventService.recordPaymentCompletedEvent(order);
    return OrderResult.from(order);
}</code></pre>
<p>이 구조는 다음과 같은 문제를 초래했다:</p>
<ul>
<li><strong>락과 트랜잭션 경계가 겹침</strong> → 락 보유 시간이 길어짐</li>
<li><strong>각 서비스가 비즈니스 + 트랜잭션 책임을 모두 가짐</strong> → 테스트 및 유지보수 어려움</li>
</ul>
<h3 id="리팩토링-후-구조">리팩토링 후 구조</h3>
<pre><code class="language-java">public OrderResult createOrder(CreateOrderCommand command) {
    Order order = null;
    try {
        List&lt;OrderItem&gt; orderItems = orderItemCreator.createOrderItems(command.items());
        Money discountedTotal = couponUseCase.calculateDiscountedTotal(command, orderItems);
        order = orderService.createOrder(command.userId(), orderItems, discountedTotal);
        orderEventService.recordPaymentCompletedEvent(order);
        return OrderResult.from(order);
    } catch (Exception e) {
        compensationService.compensateStock(command.items());
        if (order != null) {
            compensationService.markOrderAsFailed(order.getId());
        }
        throw e;
    }
}</code></pre>
<h3 id="핵심-리팩토링">핵심 리팩토링</h3>
<ul>
<li><code>@DistributedLock</code> 어노테이션 기반 분산락 적용</li>
<li>락 범위를 <strong>stockService.decrease()</strong> 수준으로 최소화</li>
<li>트랜잭션은 <code>@Transactional</code>로 분리, 보상은 <code>REQUIRES_NEW</code>로 안전하게 분리</li>
</ul>
<pre><code class="language-java">@DistributedLock(
    prefix = &quot;stock:decrease:&quot;,
    key = &quot;#command.productId + &#39;:&#39; + #command.size&quot;,
    waitTime = 5,
    leaseTime = 3
)
public void decrease(DecreaseStockCommand command) {
    ProductStock stock = productStockRepository.findByProductIdAndSize(...);
    if (stock.getStockQuantity() &lt; command.quantity()) throw new ...
    stock.decreaseStock(command.quantity());
    productStockRepository.save(stock);
}</code></pre>
<h3 id="보상-트랜잭션-처리ordercompensationservice">보상 트랜잭션 처리(OrderCompensationService)</h3>
<pre><code class="language-java">@Transactional(propagation = REQUIRES_NEW)
public void compensateStock(...) { ... }

@Transactional(propagation = REQUIRES_NEW)
public void markOrderAsFailed(String orderId) { ... }</code></pre>
<p>이 설계로 다음과 같은 장점을 얻었다:</p>
<ul>
<li>락 보유 시간 최소화 (딱 필요한 재고 차감 로직에만 적용)</li>
<li>각 책임이 응용 컴포넌트에 따라 명확히 분리</li>
<li>장애 발생 시 재고 보상 및 주문 상태 전이로 <strong>정합성 유지</strong></li>
</ul>
<hr>
<h3 id="step12---cache-성능-개선-보고서">STEP12 - Cache 성능 개선 보고서</h3>
<hr>
<h3 id="문제-인식">문제 인식</h3>
<ul>
<li>인기 상품 API는 <strong>조건이 거의 동일한 반복 호출이 많지만</strong>, 캐시가 없으면 DB Full Scan</li>
<li>캐시가 없을 경우 800ms 이상의 평균 응답시간</li>
<li>부하 테스트 시 DB 커넥션 10~11개까지 소모 → 병목 집중</li>
</ul>
<hr>
<h3 id="캐싱-전략-설계">캐싱 전략 설계</h3>
<ul>
<li><code>@Cacheable(sync = true)</code>로 <strong>Cache Stampede 방지</strong></li>
<li><code>&quot;popular:{days}:{limit}&quot;</code> 형태로 <strong>Key 구성</strong></li>
<li>Redis 기반 캐시 적용</li>
<li><code>@Transactional(readOnly = true)</code>로 트랜잭션 오버헤드 제거</li>
</ul>
<pre><code class="language-java">@Cacheable(
    value = &quot;popularProducts&quot;,
    key = &quot;&#39;popular:&#39; + #criteria.days() + &#39;:&#39; + #criteria.limit()&quot;,
    sync = true
)
public List&lt;PopularProductResult&gt; getPopularProducts(PopularProductCriteria criteria)</code></pre>
<hr>
<h3 id="pre-warming-전략">Pre-warming 전략</h3>
<pre><code class="language-java">@Scheduled(cron = &quot;0 0 3 * * *&quot;)
public void warmUpPopularProducts() {
    List.of(new PopularProductCriteria(3, 5))
        .forEach(productFacade::getPopularProducts);
}</code></pre>
<ul>
<li>새벽 3시에 인기 상품 캐시를 사전 로딩</li>
<li>사용자가 접근하는 시점엔 이미 캐시가 채워져 있음</li>
<li><strong>Cold Start latency 제거 + Stampede 방지 보완</strong></li>
</ul>
<hr>
<h3 id="k6--prometheus--grafana-기반-성능-측정">k6 + Prometheus + Grafana 기반 성능 측정</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>캐시 미적용</th>
<th>캐시 적용</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>평균 응답시간</td>
<td>805.4ms</td>
<td>7.17ms</td>
<td><strong>99% 감소</strong></td>
</tr>
<tr>
<td>RPS</td>
<td>24.4</td>
<td>2734</td>
<td><strong>112배 증가</strong></td>
</tr>
<tr>
<td>요청 총합</td>
<td>253</td>
<td>27,369</td>
<td><strong>108배</strong></td>
</tr>
<tr>
<td>DB 커넥션</td>
<td>10~11개</td>
<td>0에 수렴</td>
<td><strong>DB 부하 제거</strong></td>
</tr>
<tr>
<td>실패율</td>
<td>0%</td>
<td>0%</td>
<td>안정적</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/thedev_junyoung/post/7eba742c-4ae7-4b81-9054-dd554c3a113f/image.png" alt=""></p>
<p><strong>(1) 처리량 비교 - popular vs without-cache</strong></p>
<p><em>그래프 좌상단</em></p>
<ul>
<li><strong>파란색</strong>: 캐시 적용 (<code>/api/v1/products/popular</code>)</li>
<li><strong>빨간색</strong>: 캐시 미적용 (<code>/api/v1/products/popular/without-cache</code>)</li>
</ul>
<p><strong>해석</strong>:</p>
<ul>
<li>캐시 미적용 상태는 RPS가 거의 <strong>0~20 수준</strong>. 요청 처리량이 매우 낮음.</li>
<li>캐시 적용 상태는 구간에 따라 <strong>최대 4,000 RPS까지 도달</strong>.</li>
<li><strong>명백하게 캐시 사용 시 처리량이 수십~수백 배 증가</strong>. DB 병목 해소됨.</li>
<li>시간대별로 부하 주기가 있어서 피크 때 성능 차이가 극명하게 드러남.</li>
</ul>
<blockquote>
<p>즉: 캐시 미적용은 DB 병목으로 RPS 제한 발생, 캐시는 병목 없이 처리량이 상승.</p>
</blockquote>
<hr>
<p><strong>(2) 응답 시간 평균 (Latency 평균)</strong></p>
<p><em>그래프 우상단</em></p>
<ul>
<li><strong>빨간색</strong>: 캐시 미적용</li>
<li><strong>파란색</strong>: 캐시 적용</li>
</ul>
<p><strong>해석</strong>:</p>
<ul>
<li>캐시 미적용 요청은 <strong>평균 1.6~1.8초(!)</strong> → 심각한 지연.</li>
<li>캐시 적용 요청은 <strong>거의 0초(밀리초 단위)</strong> → 응답 시간 극히 짧음.</li>
<li>부하가 올라가도 캐시 적용 쪽은 응답 시간 유지, 캐시 미적용은 부하가 커질수록 지연 심화.</li>
</ul>
<blockquote>
<p>즉: 캐시 유무에 따른 latency 차이가 1,000ms 이상. 실사용자 체감으로는 &quot;느림 vs 즉시 응답&quot;.</p>
</blockquote>
<hr>
<p><strong>(3) 요청 합계 (Total Requests)</strong></p>
<p><em>그래프 좌하단</em></p>
<ul>
<li><strong>파란색</strong>: 캐시 적용</li>
<li><strong>빨간색</strong>: 캐시 미적용</li>
</ul>
<p><strong>해석</strong>:</p>
<ul>
<li>캐시 적용은 <strong>최대 260,000건 요청</strong> 소화.</li>
<li>캐시 미적용은 <strong>20,000건 이하</strong> 수준으로 정체.</li>
<li>캐시 미적용 상태는 부하가 걸릴수록 처리 가능한 요청 수가 급격히 줄어드는 현상 발생.</li>
</ul>
<blockquote>
<p>즉: 캐시 없으면 일정 트래픽 이상부터 아예 요청 처리가 불가능(서비스 한계 도달).</p>
</blockquote>
<hr>
<p><strong>(4) DB 활성 커넥션 수 (Active Connections)</strong></p>
<p><em>그래프 우하단</em></p>
<ul>
<li><strong>녹색</strong>: 활성 DB 커넥션</li>
</ul>
<p><strong>해석</strong>:</p>
<ul>
<li>캐시 미적용 요청에서만 <strong>활성 커넥션이 10~11개</strong>로 증가 → DB에 병목이 집중됨.</li>
<li>캐시 적용 요청에서는 커넥션 증가 거의 없음 → 캐시만으로 응답 처리.</li>
<li><strong>DB 풀 한계치 근처까지 활성 커넥션 사용</strong> → 초과 시 커넥션 풀 exhaust 가능.</li>
</ul>
<blockquote>
<p>즉: 캐시가 없으면 커넥션 풀 포화로 요청 대기/실패 가능성이 매우 높아짐.</p>
</blockquote>
<h3 id="성능-결론">성능 결론</h3>
<ul>
<li>처리량 증가, latency 감소, DB 부하 완화 → <strong>캐시 전략 효과 실증 완료</strong></li>
<li><code>sync = true</code> + Pre-warming 조합이 실제 트래픽 환경에서 견고하게 작동함을 검증</li>
</ul>
<hr>
<h2 id="요약-정리">요약 정리</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>기술</th>
<th>목적</th>
<th>설계 포인트</th>
</tr>
</thead>
<tbody><tr>
<td>동시성 제어</td>
<td>Redisson 기반 AOP 락</td>
<td>재고 차감 동시성 방지</td>
<td><code>@DistributedLock</code>, 트랜잭션 분리</td>
</tr>
<tr>
<td>보상 트랜잭션</td>
<td>REQUIRES_NEW</td>
<td>실패 복구</td>
<td><code>OrderCompensationService</code> 분리</td>
</tr>
<tr>
<td>캐싱</td>
<td>@Cacheable(sync = true)</td>
<td>DB 병목 완화</td>
<td>Redis + 캐시 키 전략</td>
</tr>
<tr>
<td>사전 캐시</td>
<td>Scheduled Pre-warming</td>
<td>Cold Start 방지</td>
<td>새벽 정기 로딩</td>
</tr>
</tbody></table>
<hr>
<h2 id="인사이트">인사이트</h2>
<ul>
<li>분산 환경에서의 동시성은 단순 트랜잭션만으로 방어 불가 → <strong>분산락 + 구조적 분리</strong> 필요</li>
<li>락은 <strong>보유 범위가 짧을수록 좋고</strong>, 트랜잭션과 분리해서 책임을 명확히 해야 유지보수 가능</li>
<li>캐시 전략은 단순 <code>@Cacheable</code> 선언이 아니라, <strong>TTL 관리, 키 전략, 예외 처리, preload 전략</strong>까지 포함한 종합 설계여야 한다</li>
<li>성능 개선은 <strong>“감”이 아니라 “측정 기반”으로 접근</strong>해야 확실한 개선이 가능하다</li>
</ul>
<hr>
<h2 id="총평">총평</h2>
<p>이번 주는 단순한 과제 수행을 넘어서, <strong>실제 프로덕션 환경에서 어떻게 동시성과 트래픽 이슈를 처리할 수 있을지 깊이 고민해본 시간</strong>이었다. 특히 트래픽이 급증할 때 시스템을 어떻게 안정적으로 운영할 수 있을지, 락과 트랜잭션의 경계는 어디까지 설정해야 하는지를 고민하다 보니 자연스럽게 <strong>SOLID 원칙, OOP, DDD, Testable Code</strong> 같은 개념들이 설계 관점에서 따라왔다.</p>
<p>처음엔 어려워 보였던 개념들이 사실은 &quot;각자의 책임을 명확히 나누는 유연한 구조를 만들기 위한 생각의 확장&quot;이라는 걸 체감할 수 있었다. 도메인이나 컴포넌트뿐 아니라 작은 클래스, 하나의 메서드, 심지어 파일 단위까지도 역할과 책임에 맞게 설계하는 것이 왜 중요한지 몸으로 느꼈다.</p>
<p>또한, 락과 트랜잭션 범위를 최소화하기 위한 고민 속에서 <strong>&quot;실패했을 때 어떻게 처리할 것인가?&quot;</strong>라는 질문에 자연스럽게 도달했고, 그 해답을 <strong>보상 트랜잭션</strong>이라는 형태로 도출했다. 외부 인프라에 의존하지 않고, 우선은 애플리케이션 레벨에서 <code>try-catch</code> 구조를 활용해 하나의 작업 단위를 구성하고, 실패 시에는 catch 블록에서 재고 복원, 상태 롤백과 같은 보상 로직을 명시적으로 실행했다.</p>
<p>또한 이벤트 기반 처리를 도입하여 <strong>정합성은 중요하나 락의 범위 밖으로 분리해도 되는 작업들</strong>(ex. 상태 변경, 기록 저장 등)은 <code>@TransactionalEventListener</code>를 통해 비동기적으로 처리함으로써 전체 트랜잭션의 부담을 줄이고자 했다. 현 시점에서 내가 가진 지식과 경험으로 적용할 수 있는 최선의 구조라고 판단했고, 실제로도 구조적으로 꽤 설득력 있는 결과를 도출해냈다.</p>
<p>무엇보다 인상 깊었던 점은, <strong>단순히 코드가 돌아가게 만드는 것이 아니라 “운영 가능한 구조”로 만드는 것이 진짜 개발이라는 감각</strong>을 이번 주에 확실히 체득했다는 점이다.</p>
<p>결과적으로 이번 주는 실습 내용 이상의 인사이트를 얻은 주차였다. 설령 제출한 과제가 Fail을 받더라도 전혀 아쉬움이 없을 만큼 많은 것을 고민했고, 배웠고, 구현했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java][Spring]Spring Boot 통합 테스트, 왜 @Transactional을 제거했을까?]]></title>
            <link>https://velog.io/@thedev_junyoung/spring-boot-testcontainers-transactional</link>
            <guid>https://velog.io/@thedev_junyoung/spring-boot-testcontainers-transactional</guid>
            <pubDate>Mon, 28 Apr 2025 11:57:49 GMT</pubDate>
            <description><![CDATA[<h1 id="1-들어가며">1. 들어가며</h1>
<blockquote>
<p>테스트가 테스트답기 위해서는, 운영 환경과 최대한 유사하게 만들어야 한다.</p>
</blockquote>
<p>Spring Boot 통합 테스트를 하면서 우리는 종종 <code>@Transactional</code>을 테스트 클래스에 붙이는 실수를 저지른다.</p>
<p>처음에는 빠르고 편리해보이지만, 장기적으로는 심각한 문제를 초래할 수 있다.</p>
<p>이 글에서는</p>
<ul>
<li><strong>왜</strong> 그렇게 하면 안 되는지</li>
<li><strong>무엇을</strong> 대신 사용해야 하는지</li>
<li><strong>바꾸는 과정에서 발생한 문제</strong>와 <strong>해결 방법</strong>
까지 실전 사례를 기반으로 자세히 설명한다.</li>
</ul>
<hr>
<h1 id="2-흔히-하는-실수-테스트에-transactional">2. 흔히 하는 실수: 테스트에 @Transactional</h1>
<p>테스트 클래스에 <code>@Transactional</code>을 걸면 테스트 메서드가 끝날 때 자동으로 롤백되기 때문에 <strong>DB를 깔끔하게 유지</strong>할 수 있다.</p>
<p>하지만 이 방식은 실제 운영에서 발생하는 트랜잭션 흐름과 완전히 다르다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>운영 환경</th>
<th>테스트 환경(@Transactional 사용)</th>
</tr>
</thead>
<tbody><tr>
<td>트랜잭션 시작/종료</td>
<td>메서드 단위 트랜잭션</td>
<td>클래스 전체 트랜잭션</td>
</tr>
<tr>
<td>DB 상태</td>
<td>실제 커밋/롤백 발생</td>
<td>항상 롤백으로 끝</td>
</tr>
<tr>
<td>부작용</td>
<td>정상적인 트랜잭션 흐름 검증 가능</td>
<td>트랜잭션 경계 깨짐, Lazy 로딩 이슈</td>
</tr>
</tbody></table>
<hr>
<h1 id="3-왜-문제가-되는가">3. 왜 문제가 되는가</h1>
<h2 id="31-서비스-추상화-깨짐">3.1 서비스 추상화 깨짐</h2>
<p>Spring은 서비스 계층을 통해 트랜잭션 경계를 명확히 설정한다.</p>
<blockquote>
<p>➡️ @Transactional이 테스트 전체를 감싸면, 서비스 메서드별 트랜잭션 검증이 불가능하다.</p>
</blockquote>
<hr>
<h2 id="32-트랜잭션-경계-불일치">3.2 트랜잭션 경계 불일치</h2>
<p>운영에서는 <code>트랜잭션 시작 → 작업 → 커밋</code>이 명확하지만, 테스트에서는 통째로 하나의 트랜잭션에 묶인다.</p>
<blockquote>
<p>➡️ 커밋 타이밍이 다르면, 실제 장애와 다른 결과가 나온다.</p>
</blockquote>
<hr>
<h2 id="33-프록시-초기화-실패-lazyinitializationexception">3.3 프록시 초기화 실패 (LazyInitializationException)</h2>
<p>JPA 연관관계 매핑 시, 일반적으로 <code>fetch = LAZY</code>를 사용한다.</p>
<p>그런데 세션이 이미 종료된 상태에서 프록시 객체를 접근하면 다음과 같은 에러가 터진다:</p>
<pre><code class="language-bash">failed to lazily initialize a collection of role: kr.hhplus.be.server.domain.order.Order.items: could not initialize proxy - no Session</code></pre>
<p>또는</p>
<pre><code class="language-bash">Could not initialize proxy [Coupon#3] - no session</code></pre>
<blockquote>
<p>➡️ 운영 환경에서는 커밋 후 세션이 닫히기 때문에,</p>
<p>제대로 검증 안 된 테스트는 실제 배포 후 터질 수 있다.</p>
<p>➡️ 테스트 통과하더라도, 실제 배포 시 운영에서 Lazy 로딩 에러가 발생할 수 있다.</p>
</blockquote>
<hr>
<h1 id="4-testcontainers를-도입한-이유">4. Testcontainers를 도입한 이유</h1>
<p><strong>Testcontainers</strong>를 통해</p>
<ul>
<li>실제 MySQL 컨테이너를 띄우고</li>
<li><code>init.sql</code>로 초기 스키마를 세팅하고</li>
<li>테스트마다 깨끗한 환경을 구축했다.</li>
</ul>
<p>이 방식은</p>
<blockquote>
<p>&quot;진짜 운영 환경처럼 DB 상태를 구성하고 테스트한다.&quot;
는 것을 가능하게 한다.</p>
</blockquote>
<p>결과적으로 <strong>신뢰성 있는 테스트</strong>를 만들어준다.</p>
<hr>
<h1 id="5-테스트를-바꾸는-과정에서-발생한-문제">5. 테스트를 바꾸는 과정에서 발생한 문제</h1>
<h3 id="✅-transactional을-제거한-직후">✅ <code>@Transactional</code>을 제거한 직후,</h3>
<ul>
<li>연관관계(<code>Order.items</code>, <code>CouponIssue.coupon</code>)가 LAZY 로딩인데,</li>
<li>테스트 검증 시점에는 이미 세션이 닫혀서 <strong>LazyInitializationException</strong> 발생했다.</li>
</ul>
<h3 id="✅-원인">✅ 원인</h3>
<ul>
<li>기본 JPA 조회 (<code>findById</code>, <code>findByUserIdAndCouponId</code>)가 Fetch Join을 사용하지 않았다.</li>
</ul>
<pre><code class="language-java">Order order = orderRepository.findById(order.getId()).orElseThrow();
assertThat(order.getItems()).hasSize(1); // 여기서 no session</code></pre>
<pre><code class="language-java">CouponIssue issue = couponIssueRepository.findByUserIdAndCouponId(userId, coupon.getId()).orElseThrow();
assertThat(issue.getCoupon().getCode()).isEqualTo(&quot;TESTONLY1000&quot;); // 여기서 no session</code></pre>
<hr>
<h1 id="6-문제를-해결한-방법">6. 문제를 해결한 방법</h1>
<p><strong>Fetch Join JPQL</strong>로 연관된 엔티티까지 한 번에 조회하도록 수정했다.</p>
<h3 id="수정-전">수정 전</h3>
<pre><code class="language-java">Optional&lt;Order&gt; findById(String orderId);</code></pre>
<h3 id="수정-후">수정 후</h3>
<pre><code class="language-java">@Query(&quot;SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :orderId&quot;)
Optional&lt;Order&gt; findByIdWithItems(@Param(&quot;orderId&quot;) String orderId);</code></pre>
<pre><code class="language-java">@Query(&quot;SELECT ci FROM CouponIssue ci JOIN FETCH ci.coupon WHERE ci.userId = :userId AND ci.coupon.id = :couponId&quot;)
Optional&lt;CouponIssue&gt; findByUserIdAndCouponId(@Param(&quot;userId&quot;) Long userId, @Param(&quot;couponId&quot;) Long couponId);</code></pre>
<p>➡️ 조회 시점에 연관 객체까지 초기화해서 세션 종료에도 안전하게 테스트 가능하게 만들었다.</p>
<p>이렇게 변경함으로써</p>
<ul>
<li>조회 시점에 LAZY 연관관계도 즉시 초기화</li>
<li>세션 종료와 관계없이 테스트 통과</li>
<li>운영 환경과 일치하는 트랜잭션 플로우 유지</li>
</ul>
<p>를 달성할 수 있었다.</p>
<hr>
<h1 id="7-그렇다면-언제-transactional을-써도-괜찮을까">7. 그렇다면 언제 @Transactional을 써도 괜찮을까?</h1>
<p><strong>모든 테스트에 @Transactional을 금지하는 건 아니다.</strong></p>
<p>특정 목적에 따라 <code>@Transactional</code>을 활용하는 것이 오히려 유리할 때도 있다.</p>
<h3 id="✅-단위-테스트-repository-테스트">✅ 단위 테스트 (Repository 테스트)</h3>
<ul>
<li>간단한 CRUD Repository 검증</li>
<li><strong>Repository Layer</strong> 단독 테스트할 때</li>
<li>DB 상태를 매번 정리하는 게 귀찮을 때</li>
<li>롤백 기반으로 DB 초기화를 간단하게 하고 싶을 때</li>
</ul>
<pre><code class="language-java">@DataJpaTest
@Transactional
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void saveUser() {
        User user = new User(&quot;junyoung&quot;);
        userRepository.save(user);

        assertThat(userRepository.count()).isEqualTo(1L);
        // 테스트 끝나면 자동 롤백됨
    }
}</code></pre>
<blockquote>
<p>➡️ 단순한 CRUD Repository 테스트에서는 @Transactional이 유용하다.</p>
</blockquote>
<hr>
<h3 id="✅-데이터-정리가-중요한-경우">✅ &quot;데이터 정리&quot;가 중요한 경우</h3>
<ul>
<li>테스트 데이터가 많고</li>
<li>AfterEach로 매번 delete 하는 게 복잡하거나 성능에 부담될 때</li>
</ul>
<p><code>@Transactional</code>을 걸어서 테스트 끝날 때 <strong>깔끔하게 롤백</strong>시켜주는 것도 전략이 될 수 있다.</p>
<p>단, <strong>이 경우도 통합 테스트(서비스 흐름 검증)</strong> 에는 적용하면 안 된다.</p>
<hr>
<h1 id="8-그래서-한-줄-정리">8. 그래서 한 줄 정리</h1>
<table>
<thead>
<tr>
<th>구분</th>
<th>@Transactional 사용 여부</th>
</tr>
</thead>
<tbody><tr>
<td>Repository 단위 테스트</td>
<td>✅ 써도 된다</td>
</tr>
<tr>
<td>단순 CRUD 검증</td>
<td>✅ 써도 된다</td>
</tr>
<tr>
<td>통합 테스트 (Service-DB 흐름)</td>
<td>❌ 쓰면 안 된다</td>
</tr>
<tr>
<td>트랜잭션 커밋/롤백 플로우 검증</td>
<td>❌ 쓰면 안 된다</td>
</tr>
</tbody></table>
<hr>
<h1 id="9-최종-요약">9. 최종 요약</h1>
<table>
<thead>
<tr>
<th>포인트</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>❌ 테스트 전체에 @Transactional 금지</td>
<td>트랜잭션 경계 깨짐</td>
</tr>
<tr>
<td>✅ Testcontainers 사용</td>
<td>운영과 똑같은 DB 구성</td>
</tr>
<tr>
<td>✅ 필요한 데이터만 삽입</td>
<td>필요한 경우 <code>@Sql</code> 사용 가능</td>
</tr>
<tr>
<td>✅ Fetch Join 사용</td>
<td>LazyInitializationException 해결</td>
</tr>
</tbody></table>
<hr>
<h1 id="10-마치며">10. 마치며</h1>
<blockquote>
<p>&quot;테스트는 개발자에게 가장 솔직한 피드백을 준다.&quot;</p>
</blockquote>
<p>코드를 그냥 맞추는 것이 목표가 아니다.</p>
<p>운영과 동일한 환경에서 테스트를 검증하는 것이 진짜 목표다.</p>
<p>Testcontainers, 트랜잭션 경계 유지, 연관관계 초기화 등은 모두</p>
<p><strong>장애 없는 서비스를 만드는 기본기</strong>다.</p>
<p>오늘부터, 테스트를 &quot;운영처럼&quot; 짜보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 백엔드 전반부 회고: 5주간 했던것 처럼만 하자]]></title>
            <link>https://velog.io/@thedev_junyoung/hanghae-first-half</link>
            <guid>https://velog.io/@thedev_junyoung/hanghae-first-half</guid>
            <pubDate>Sat, 26 Apr 2025 07:26:49 GMT</pubDate>
            <description><![CDATA[<h2 id="1-간단한-자기소개">1. 간단한 자기소개</h2>
<p>1년 6개월의 현업 경력이 있는 전준영입니다. 
풀스택으로 시작했지만, 지금은 백엔드 업무를 맡아서 진행하고 있고 
개인적으로 공부하는게 한계가 있다고 생각하여, 항해플러스를 시작하게 됐습니다. </p>
<h2 id="2-항해를-시작하며-꼭-해내고-싶었던-목표">2. 항해를 시작하며 꼭 해내고 싶었던 목표</h2>
<p>현업에서 테스트코드를 작성은 해봤지만 이렇게 하는 방식이 맞는지, 또한 경험해보지 못한 대용량트래픽 이라는 키워드가 제가 항해플러스를 선택했던 이유입니다.  </p>
<p>그렇기 때문에 항해과정을 통해 실제로 현업에서는 어떤식으로 테스트코드를 적절하게 작성하는지와 대규모트래픽은 어떻게 트러블 슈팅하는지를 코치님들의 멘토링과 과제수행을 통해 확실하게 짚고, </p>
<p>또한 평가시스템을 통해 현재 저의 개발능력이 얼만큼되는지를 정확하게 알아보기 위해 끝까지 ‘몰입’ 해서 <strong>All Pass</strong> 를 받고 싶습니다.</p>
<h2 id="3-전반부를-마무리하며-가장-기억에-남는-성취">3. 전반부를 마무리하며 가장 기억에 남는 성취</h2>
<p>‘토요지식회 발표’, ‘BP선정’ 이렇게 두 가지가 생각이 납니다.</p>
<p>토요지식회를 준비하면서 제가 기존에 알고있던 개념이 맞는지, 추가적인 공부와 코치님들께도 여쭤보면서 더 확실하게 알게 되고 발표도 무사히 마치게된 기억이 남고, 3주차때, 우연히 BP를 받았을 때를 생각해보면 멘토링과 공개 Q&amp;A를 통해 적극적으로 반영하려고 노력하고 새벽까지 개발을 했던 과정들이 BP로 결과로 이어졌던 기억이 크게 기억이 남습니다. </p>
<h2 id="4-반드시-이뤘으면-했는데-이루지-못한-것">4. 반드시 이뤘으면 했는데 이루지 못한 것</h2>
<p>지난 4주차 과제에서 BP를 받지 못한게 아쉽습니다. 
안정적인 운영을 위한 테스트코드 작성, 통합테스트의 범위는 어떻게 설정하고, 테스트를 통해 얻어야 될 것은 무엇인지에 대해서 고민을 많이한 한 주여서, 최대한 반영해서 코드를 작성했지만 BP를 받지 못해 아쉽습니다. </p>
<h2 id="5-내가-강화해야-할-강점-중-가장-중요하다고-생각하는-한-가지">5. 내가 강화해야 할 강점 중 가장 중요하다고 생각하는 한 가지</h2>
<p>우선 강점으로 집중력과 적극적인 피드백반영 그리고 꾸준함입니다.</p>
<p>이 강점들 중, 먼저 언급한 두 가지는 일시적이지만 ‘꾸준함’ 이 곁들여진다면, 
자연스레 성장이 따라온다고 생각해서 가장 중요하고, 제가 앞으로도 지켜야할 강점이라고 생각합니다. </p>
<h2 id="6-내가-개선해야-할-개선점-중-가장-중요하다고-생각하는-한-가지">6. 내가 개선해야 할 개선점 중 가장 중요하다고 생각하는 한 가지</h2>
<p>몰입하다보니, 정말 중요한게 무엇인지 방황하는 경우가 있습니다. </p>
<p>예를들어 동시성 처리와 관련된 테스트코드 작성할 때, 테스트성공을 위한 코드를 작성해야 되겠다는 생각으로 개발을 하다보니 논리적으로 이상한 코드를 작성하게 된 경우가 있었습니다. 이 때 느꼈던 점이, ‘중요한 걸 놓치는 경우가 있네..’ 라고 생각이 들었었고, 앞으로 항해를 진행할 때 뿐만 아니라, 다른 것을 할 때에도 지금 하고 있는 작업의 목적은 무엇인지, 다른길로 세진 않았는지에 대한 고민을 동반하면서 개발해야 되겠다고 느꼈습니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 백엔드 5주차 회고(WIL): 동시성 이슈 해결과 Finalize]]></title>
            <link>https://velog.io/@thedev_junyoung/5wd-WIL</link>
            <guid>https://velog.io/@thedev_junyoung/5wd-WIL</guid>
            <pubDate>Fri, 25 Apr 2025 06:46:59 GMT</pubDate>
            <description><![CDATA[<p>이번 주는 지난주에 작성했던 <strong>동시성 테스트를 실제로 성공시키는 것</strong>이 목표였다. 단순히 테스트가 통과하는 게 아니라, <strong>트랜잭션과 락을 제대로 이해하고 적용해서 “운영 가능한 수준”의 신뢰성을 확보하는 것</strong>이 진짜 목적이었다. 그리고 동시에, 4주간 진행해온 이커머스 프로젝트도 실제 배포 가능한 형태로 마무리하는 걸 목표로 설정했다.</p>
<hr>
<h2 id="실패의-원인을-다시-마주하다">실패의 원인을 다시 마주하다</h2>
<p>지난주까지 작성했던 동시성 테스트들은 대부분 <strong>실패하도록 설계된 테스트</strong>였다. 쿠폰이 중복 발급되거나, 재고가 음수가 되거나, 하나의 주문에 두 번 결제되는 등 <strong>의도적으로 레이스 컨디션을 유도해 문제를 확인하는 테스트</strong>였다.</p>
<p>그 테스트들을 이번 주에는 &quot;진짜 통과하게&quot; 만들기 위해 다음과 같은 문제들을 다시 정면으로 마주했다:</p>
<ul>
<li><p><strong>트랜잭션이 테스트 환경에서는 분리되어 실행되는 문제</strong></p>
<p>  → 애플리케이션에서는 <code>@Transactional</code>로 트랜잭션을 묶어도, 테스트 코드에서는 각각의 스레드가 별도 트랜잭션을 사용하는 바람에 제대로 동작하지 않았다.</p>
</li>
<li><p><strong>조회 + 수정이 한 메서드에서 수행될 때, 락이 의도대로 안 걸리는 문제</strong></p>
<p>  → <code>FOR UPDATE</code>가 실제로 적용되지 않거나, 트랜잭션 경계 밖에서 호출되는 문제가 있었다.</p>
</li>
<li><p><strong>“배포 가능한 수준”의 테스트는 어디까지 해야 하는가</strong>에 대한 고민</p>
<p>  → 통합 테스트, 서블릿 필터 테스트, 스케줄러 테스트 등은 일반적인 단위 테스트와는 다른 전략이 필요했다.</p>
</li>
</ul>
<p>이번 주는 기능을 많이 구현했다기보다는, <strong>매 순간 구조를 어떻게 바꿔야 진짜 문제가 해결되는지를 고민했던 밀도 높은 시간</strong>이었다.</p>
<hr>
<h2 id="테스트-통과가-아니라-구조를-바꿔서-통과시키는-것">“테스트 통과”가 아니라 “구조를 바꿔서 통과시키는 것”</h2>
<p>처음엔 테스트만 통과시키면 되니까 <code>decrease()</code> 메서드 안에 <code>@Transactional(REQUIRES_NEW)</code>를 박아서 강제로 트랜잭션을 분리했었다.</p>
<p><strong>지금 돌이켜보면 굉장히 부끄러운 코드다.</strong></p>
<p>왜냐하면 책임 분리는 엉망이 되고, 트랜잭션의 흐름은 꼬였고, 테스트가 통과한다고 해서 코드가 제대로 설계된 건 아니었으니까.</p>
<blockquote>
<p>테스트에 구조를 맞추는 게 아니라, 구조를 바꿔서 테스트가 자연스럽게 통과하게 해야 한다는 걸 깨달았다.</p>
</blockquote>
<p>결국 파사드 레이어에서 트랜잭션 책임을 갖도록 조정하고, <code>decrease()</code>는 순수한 비즈니스 로직을 갖도록 정리했다. 그와 함께 락 시점도 명확하게 트랜잭션 안에서 획득되도록 수정했다. 이 과정을 거치자 비로소 테스트들이 <strong>&quot;의도한 대로&quot; 통과</strong>하기 시작했다.</p>
<hr>
<h2 id="동시성-이슈가-실제로-발생할-수-있는-기능들">동시성 이슈가 실제로 발생할 수 있는 기능들</h2>
<p>이번 프로젝트에서 실제로 동시성 문제가 발생할 가능성이 있었던 주요 기능들에 대해 <strong>구체적인 문제 상황</strong>과 <strong>적용한 해결 전략</strong>을 정리했다.</p>
<hr>
<h3 id="1-잔액-충전--중복-요청-과충전-이슈">1. 잔액 충전 – <strong>중복 요청, 과충전 이슈</strong></h3>
<ul>
<li><strong>문제</strong>: 동일한 요청이 여러 번 들어올 경우, 동일한 금액이 중복으로 충전되는 현상</li>
<li><strong>해결</strong>:<ul>
<li><code>@Version</code> 필드를 사용한 <strong>Optimistic Lock</strong></li>
<li><code>@Retryable</code>로 충돌 시 자동 재시도</li>
<li><code>requestId</code> 기반 멱등성 처리로 <strong>이미 처리된 요청은 무시</strong></li>
<li>히스토리 테이블에 기록함으로써 <strong>중복 처리 방지</strong>를 강화</li>
</ul>
</li>
</ul>
<hr>
<h3 id="2-쿠폰-발급--수량-제한-초과-발급">2. 쿠폰 발급 – <strong>수량 제한 초과 발급</strong></h3>
<ul>
<li><strong>문제</strong>: 동시에 여러 사용자가 같은 쿠폰을 요청하면 남은 수량보다 많이 발급되는 문제</li>
<li><strong>해결</strong>:<ul>
<li>쿠폰 테이블에 <code>@Lock(PESSIMISTIC_WRITE)</code> 적용</li>
<li><strong>락 획득 → 중복 발급 검사 → 수량 검증 및 차감</strong>을 트랜잭션 내에서 순차적으로 처리</li>
<li><code>userId + couponId</code> 조합으로 중복 발급을 차단</li>
</ul>
</li>
</ul>
<hr>
<h3 id="3-재고-차감--동시-주문-시-음수-재고-발생">3. 재고 차감 – <strong>동시 주문 시 음수 재고 발생</strong></h3>
<ul>
<li><strong>문제</strong>: 여러 사용자가 동시에 같은 상품을 주문하면 재고보다 많이 팔리는 현상</li>
<li><strong>해결</strong>:<ul>
<li>재고 row에 <code>PESSIMISTIC_WRITE</code> 락 적용</li>
<li><code>findByProductIdAndSizeForUpdate()</code>로 락을 선점한 뒤, 재고 차감과 저장을 <strong>같은 트랜잭션 안에서 수행</strong></li>
<li>재고 부족 시 예외 발생으로 즉시 실패 처리</li>
</ul>
</li>
</ul>
<hr>
<h3 id="4-결제-요청--복수-결제-발생-가능성">4. 결제 요청 – <strong>복수 결제 발생 가능성</strong></h3>
<ul>
<li><strong>문제</strong>: 하나의 주문에 대해 결제 API가 여러 번 호출되면 결제가 중복으로 처리될 수 있음</li>
<li><strong>해결</strong>:<ul>
<li>주문 row에 <code>PESSIMISTIC_WRITE</code> 락 선점</li>
<li>결제 진행 전 주문 상태가 <code>REQUESTED</code>인지 선점 확인</li>
<li><strong>잔액 차감 → 결제 기록 저장 → 주문 상태 변경</strong>까지 하나의 트랜잭션에서 처리</li>
</ul>
</li>
</ul>
<hr>
<blockquote>
<p>이 4가지 케이스 모두 실제 테스트로 검증되었고,</p>
<p><strong>단순한 트랜잭션 선언만으로는 해결되지 않는 문제들이었기에 락 전략과 멱등성, 재시도, 트랜잭션 경계까지 모두 고려한 설계가 필요했다.</strong></p>
</blockquote>
<hr>
<h2 id="요약">요약</h2>
<table>
<thead>
<tr>
<th>기능</th>
<th>문제</th>
<th>해결 전략</th>
</tr>
</thead>
<tbody><tr>
<td>잔액 충전</td>
<td>중복 요청</td>
<td>Optimistic Lock + 멱등성 + 재시도</td>
</tr>
<tr>
<td>쿠폰 발급</td>
<td>초과 발급</td>
<td>Pessimistic Lock + 중복 발급 차단</td>
</tr>
<tr>
<td>재고 차감</td>
<td>음수 재고</td>
<td>Pessimistic Lock + 트랜잭션 내 검증</td>
</tr>
<tr>
<td>결제</td>
<td>복수 결제</td>
<td>주문 상태 선점 + 단일 트랜잭션 처리</td>
</tr>
</tbody></table>
<hr>
<h2 id="인사이트">인사이트</h2>
<ul>
<li>단순히 <code>@Transactional</code>만 붙이는 것으로는 <strong>경쟁 조건을 방지할 수 없다</strong></li>
<li><strong>락의 적용 시점</strong>, <strong>트랜잭션 경계</strong>, <strong>재시도 전략</strong>, <strong>멱등성 처리</strong>는 모두 함께 고려되어야 한다</li>
<li>테스트에서도 <strong>실제 장애를 유도해서</strong> 설계를 검증해야 구조적 결함을 잡을 수 있다</li>
</ul>
<hr>
<h2 id="알게-된-것들">알게 된 것들</h2>
<ul>
<li><code>FOR UPDATE</code>는 <strong>트랜잭션 안에서 실행되어야 의미가 있다.</strong><ul>
<li>예: <code>decrease()</code>에서 <code>product</code>를 먼저 조회하고 이어서 <code>stock</code>을 락 걸려고 할 때, 이게 <strong>다른 트랜잭션에서 조회됐다면 이미 락이 무의미해진다.</strong></li>
<li>→ 락은 트랜잭션 시작 이후, 가장 먼저 필요한 데이터에 걸어야 한다.</li>
</ul>
</li>
<li>테스트 환경에서 동시성을 검증할 때는 <code>@Transactional(propagation = NOT_SUPPORTED)</code>를 써서 <strong>트랜잭션 없이 초기 데이터를 세팅</strong>해야 한다.</li>
<li>테스트는 단순히 &quot;통과&quot;하는 게 목적이 아니라, <strong>설계가 올바르게 되어 있는지를 검증하는 도구</strong>여야 한다.</li>
</ul>
<hr>
<h2 id="부가기능-필터--인터셉터--스케줄러도-다뤄봤다">부가기능: 필터 / 인터셉터 / 스케줄러도 다뤄봤다</h2>
<ul>
<li><strong>Interceptor</strong>: 요청 헤더에 사용자 ID를 주입하는 인증 역할</li>
<li><strong>Filter</strong>: 간단한 로깅 처리</li>
<li><strong>스케줄러</strong>: 매일 0시에 만료된 쿠폰 비활성화</li>
</ul>
<p>이 기능들은 서블릿 레벨에서 작동하는 컴포넌트이기 때문에 일반적인 단위 테스트로는 검증하기 어려웠다. 그래서 <strong>MockMvc 환경에서의 흐름 테스트</strong>, 또는 <strong>스케줄러는 직접 호출 테스트</strong>처럼 별도의 전략을 고민해봤다.</p>
<p><strong>“이런 건 어떻게 테스트하지?”라는 고민 자체가 성장의 계기가 됐다.</strong></p>
<hr>
<h2 id="다음-주-목표">다음 주 목표</h2>
<p>차주에는 <strong>Redis와 Kafka를 활용한 구조 실험</strong>을 진행할 예정이다.</p>
<ul>
<li><p>Redis로 <strong>Rate Limiting이나 글로벌 락</strong> 처리</p>
</li>
<li><p>Kafka로 <strong>주문/결제/이벤트 기반 메시지 처리 구조 구성</strong></p>
</li>
<li><p>지금까지 학습한 <strong>트랜잭션 설계, 테스트 전략, 동시성 처리</strong>를 기반으로</p>
<p>  <strong>“분산 시스템에서도 안정적으로 동작하는 구조”</strong>를 직접 실험해볼 계획이다.</p>
</li>
</ul>
<blockquote>
<p>이번에는 “이걸 이렇게 하면 되지 않을까?” 하고 머릿속에서만 떠돌던 걸 실제로 구현해볼 수 있을 것 같아 기대된다.</p>
</blockquote>
<hr>
<h2 id="총평">총평</h2>
<p>이번 주는 기능 하나하나보다, <strong>그 기능을 감싸는 구조를 진짜 운영 가능한 형태로 바꿔낸 주</strong>였다.</p>
<p><strong>동시성 테스트는 결국 실패를 통해 구조를 고치는 과정</strong>이라는 걸 온몸으로 체감했고,</p>
<p>그 결과 테스트는 물론, 실제 운영에서도 견고할 수 있는 코드를 만들 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 백엔드 4주차 회고(WIL): 인프라 구현과 통합 테스트, 그리고 동시성 고민]]></title>
            <link>https://velog.io/@thedev_junyoung/%ED%95%AD%ED%95%B4-%EB%B0%B1%EC%97%94%EB%93%9C-4%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EA%B3%A0WIL-%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%ED%98%84%EA%B3%BC-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%8F%99%EC%8B%9C%EC%84%B1-%EA%B3%A0%EB%AF%BC</link>
            <guid>https://velog.io/@thedev_junyoung/%ED%95%AD%ED%95%B4-%EB%B0%B1%EC%97%94%EB%93%9C-4%EC%A3%BC%EC%B0%A8-%ED%9A%8C%EA%B3%A0WIL-%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%ED%98%84%EA%B3%BC-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%8F%99%EC%8B%9C%EC%84%B1-%EA%B3%A0%EB%AF%BC</guid>
            <pubDate>Fri, 18 Apr 2025 12:07:41 GMT</pubDate>
            <description><![CDATA[<p>지난 주차까지 도메인-애플리케이션 레이어의 유닛 테스트를 마쳤고, 이번 주는 인프라스트럭처 구현과 통합 테스트 과제를 진행했다.</p>
<p>통합 테스트는 두 개 이상의 계층이 실제로 상호작용한다는 점에서 유닛 테스트와는 성격이 달랐다. 그래서 가장 먼저 고민한 건 <strong>테스트 커버리지를 어떻게 설정할 것인가</strong>였다.</p>
<p>이미 도메인과 서비스 레이어의 유닛 테스트는 충분히 작성되어 있었기 때문에, <strong>중복되지 않도록 범위를 잘 구분</strong>해야 했다. 단순히 JPA가 제공해주는 조회 기능 같은 건 제외하고, 인프라스트럭처와 애플리케이션 레이어 간의 <strong>상호작용에 집중해서 테스트 케이스를 리스트업</strong>했다.</p>
<p>그 과정에서 파사드 계층도 테스트할 필요가 있을까? 싶은 의문이 생겼고, 실제로 몇 개 테스트 코드를 작성해 보니 결국 흐름만 체크하게 되더라. 그래서 파사드 쪽은 흐름만 확인하는 정도로 간소화하고, <strong>핵심은 서비스 ↔ 인프라스트럭처 간 유기적인 결합이 잘 작동하는가에 집중해서 테스트를 구성</strong>했다.</p>
<p>테스트 환경도 따로 분리했다. 프로덕션 DB와의 의존성을 줄이기 위해 <code>docker-compose</code>로 <strong>MySQL 컨테이너를 구성</strong>하고, <code>schema.sql</code> , <code>data.sql</code> 로 초기 스키마 및 데이터를 세팅해 테스트를 수행했다. 이 환경 덕분에 통합 테스트를 CI 환경에서도 안정적으로 돌릴 수 있었다.</p>
<hr>
<h2 id="🧵-동시성-테스트-실패로-설계를-검증하다">🧵 동시성 테스트: 실패로 설계를 검증하다</h2>
<p>이번 주에는 쿠폰 발급과 주문 생성에 대해 <strong>의도적으로 실패하는 동시성 테스트를 구성</strong>했다. 이 테스트들의 목적은 성공 여부가 아니라, <strong>경합 상황에서 데이터 정합성이 깨지는 구조적 결함을 드러내는 것</strong>이었다.</p>
<p>예를 들어, 쿠폰 발급 테스트에서는 10명의 사용자가 동시에 같은 쿠폰을 요청하고, 주문 테스트에서는 3명이 동시에 재고 10짜리 상품을 각각 5개씩 주문했다. 이런 상황에서 <strong>최대 2명만 성공해야 정상</strong>이지만, 테스트 결과에서는 초과 발급이나 재고 마이너스 같은 문제가 발생했다.</p>
<p>특히 동시성 테스트의 신뢰도를 확보하기 위해 <code>@BeforeEach</code>에서 <code>@Transactional(propagation = Propagation.NOT_SUPPORTED)</code>를 명시해 <strong>초기 데이터 세팅은 트랜잭션 없이 DB에 즉시 반영</strong>되도록 처리했다. 그래야 실제 테스트 스레드들이 트랜잭션 캐시 없이 <strong>공통된 실데이터를 읽고 쓰는 상황</strong>이 재현되기 때문이다. 이 설정 없이는 테스트 결과가 왜곡될 수 있다.</p>
<p>이번 테스트를 통해 트랜잭션 전파 방식, 데이터 커밋 타이밍, 경합 상황에서의 예외 처리 등을 전반적으로 검증할 수 있었고, 동시에 <strong>락이나 동시성 제어가 없는 상태에서는 정합성이 어떻게 무너질 수 있는지</strong>를 실증할 수 있었다. 실패는 계획된 것이었고, 그 실패를 통해 많은 걸 검증할 수 있었다.</p>
<hr>
<h2 id="🚀-인기-상품-조회-성능-병목-분석-및-최적화">🚀 인기 상품 조회 성능 병목 분석 및 최적화</h2>
<p>이번 주에는 인기 상품 조회 API(<code>/api/v1/products/popular</code>)의 성능 병목을 사전에 분석하고, 실행 계획과 인덱스 전략을 기반으로 성능을 개선하는 작업도 함께 진행했다. 단순 구현이 아니라 <strong>데이터가 많아졌을 때 실제로 병목이 발생하는 지점을 사전에 파악하고 최적화 포인트를 확보하는 게 목적</strong>이었다.</p>
<h3 id="🔍-문제-쿼리-구조">🔍 문제 쿼리 구조</h3>
<pre><code class="language-sql">SELECT *
FROM product_statistics
WHERE stat_date BETWEEN ? AND ?
ORDER BY sales_count DESC
LIMIT 10;</code></pre>
<ul>
<li><code>stat_date</code>: 날짜 범위 필터</li>
<li><code>sales_count DESC</code>: 정렬 기준</li>
<li><code>LIMIT 10</code>: 상위 N개 추출</li>
</ul>
<p>이 조합은 <strong>대용량 데이터에서 전형적인 성능 저하 구조</strong>다.</p>
<p>초기에는 인덱스 없이 실행해서 <code>Full Scan</code>, <code>filesort</code>, <code>post-filter</code> 등 병목이 모두 발생했다.</p>
<hr>
<h3 id="🛠️-해결-전략">🛠️ 해결 전략</h3>
<ol>
<li><strong>단순 인덱스 적용</strong><ul>
<li><code>stat_date</code> 기준 인덱스를 추가해 <code>Index Range Scan</code> 유도</li>
</ul>
</li>
<li><strong>복합 인덱스 적용</strong><ul>
<li><code>CREATE INDEX idx_stat_date_sales ON product_statistics(stat_date, sales_count DESC)</code></li>
<li>정렬 포함 인덱스로 <code>filesort</code> 제거 가능성 확보</li>
</ul>
</li>
<li><strong>Covering Index 확장</strong><ul>
<li>필요한 컬럼을 인덱스에 포함시켜 테이블 접근 자체 제거</li>
<li><code>CREATE INDEX idx_stat_date_sales_covering ON product_statistics(stat_date, sales_count DESC, product_id, sales_amount)</code></li>
</ul>
</li>
</ol>
<hr>
<h3 id="📊-성능-개선-결과-요약">📊 성능 개선 결과 요약</h3>
<table>
<thead>
<tr>
<th>단계</th>
<th>Access 방식</th>
<th>filesort</th>
<th>실행 시간</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 없음</td>
<td>Full Table Scan</td>
<td>✅ 발생</td>
<td>2.5ms</td>
</tr>
<tr>
<td>복합 인덱스</td>
<td>Index Range Scan</td>
<td>⚠️ 남아있음</td>
<td>2.2ms</td>
</tr>
<tr>
<td>Covering Index</td>
<td>Index Only Scan</td>
<td>⚠️ 남아있음 (매우 빠름)</td>
<td><strong>0.84ms</strong> ✅</td>
</tr>
<tr>
<td>- <code>Full Scan</code> 제거 → <code>Index Range Scan</code> 성공</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- <code>테이블 접근 제거</code>까지 성공 (Covering Index)</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- 정렬 연산(filesort)은 남아있지만, <strong>매우 빠르고 확장성 있는 구조로 개선 완료</strong></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<hr>
<h3 id="✅-느낀-점">✅ 느낀 점</h3>
<p>단순히 인덱스를 &quot;건다&quot;는 수준이 아니라, <strong>실제 데이터 분포와 쿼리 조건에 따라 어떤 필드 조합으로 인덱스를 구성해야 하는지</strong>를 직접 체감했다.</p>
<p>또한 <code>EXPLAIN ANALYZE</code>를 통해 실제 실행 계획을 보고, Row 수, 정렬 처리 방식, 실행 시간까지 분석하는 연습은 <strong>정확한 병목 지점 파악과 설계적 근거 확보</strong>에 굉장히 유효했다.</p>
<hr>
<h2 id="✅-최종-회고">✅ 최종 회고</h2>
<p>이번 주차는 구현을 넘어서서 <strong>설계된 구조가 실제 운영 환경에서 어떤 문제를 만들 수 있는지, 그 문제를 어떻게 미리 설계적으로 방어할 수 있는지에 대한 경험</strong>을 쌓을 수 있었던 시간이었다.</p>
<ul>
<li><strong>테스트는 실패를 유도할 수 있어야 하고,</strong></li>
<li><strong>인프라는 독립된 환경에서 검증 가능해야 하며,</strong></li>
<li><strong>성능은 기능 구현 이후가 아니라, 설계 단계에서 고려되어야 한다.</strong></li>
</ul>
<blockquote>
<p>다음 주는 트랜잭션 격리 수준과 락 전략을 실제 재고 차감 로직에 적용해보며, 동시성 제어 구조를 개선할 계획이다.</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 백엔드 3주차 회고(WIL)]]></title>
            <link>https://velog.io/@thedev_junyoung/3wd-WIL</link>
            <guid>https://velog.io/@thedev_junyoung/3wd-WIL</guid>
            <pubDate>Fri, 11 Apr 2025 05:33:30 GMT</pubDate>
            <description><![CDATA[<p>이번주차의 과제는 지난주에 주어진 시나리오를 분석한 것을 토대로 클린 아키텍처, 헥사고날 아키텍처 등의 구조를 기반으로 <strong>테스터블하고 객체지향적인 코드</strong>를 설계하는 것이었다.</p>
<p>이론적으로만 알고 있던 DDD, 계층 간 분리, DIP 등의 개념을 실제 코드에 녹여내는 과정이었고, 발제와 피드백을 통해 결국 이 구조들이 추구하는 본질은 <strong>&quot;도메인을 외부로부터 격리하고, 의존성을 역전시킴으로써 변경에 강한 구조를 만드는 것</strong>&quot;이라는 걸 체감할 수 있었다.</p>
<p>초기에는 레이어드 아키텍처처럼 Controller → Service → Repository 구조를 익숙하게 생각했지만, 이번 과제를 통해 <strong>Interface → Application (Facade / Service) → Domain ← Infrastructure</strong> 형태로 책임을 재정의하면서 구조에 대한 시야가 훨씬 넓어졌다.</p>
<p>헥사고날 아키텍처를 처음엔 어렵게 느꼈지만, &#39;도메인이 중심에 있고 in/out 포트를 통해 어댑터들과 통신한다’는 흐름이 결국은 도메인을 보호하기 위한 장치라는 걸 알게 되었고, 이 흐름은 결국 클린 아키텍처의 계층 분리와도 크게 다르지 않다는 걸 깨달았다.</p>
<p>다만 헥사고날의 경우 <code>in-port</code>(UseCase)와 <code>out-port</code>(Repository 등)를 반드시 인터페이스로 정의해야 한다는 규칙이 있어서 더 엄격하게 느껴지긴 했는데, 결국 중요한 건 <strong>&quot;조직에서 어떤 컨벤션으로 이 구조를 유연하게 해석하느냐&quot;</strong> 라는 점이었다. 어떤 아키텍처든 본질은 <strong>변경에 유연하고, 도메인을 중심에 두는 설계</strong>라는 점에서 같다고 생각한다.</p>
<hr>
<h2 id="1-도메인과-엔터티-분리에-대한-고민">1. 도메인과 엔터티 분리에 대한 고민</h2>
<p>초기 설계 단계에서는 클린 아키텍처 원칙에 따라 <strong>도메인 모델과 JPA 엔터티를 분리</strong>하는 방식을 고려했다. JPA 엔터티는 영속성과 밀접하게 연결된 객체이기 때문에, 이를 비즈니스 도메인으로 직접 사용하는 것은 객체지향적인 설계 원칙과는 다소 맞지 않다는 판단이었다.</p>
<p>또한, 순수한 도메인 모델을 별도로 두면 테스트 용이성과 구조적 명확성이 높아진다는 장점도 있다.하지만 실제로 적용해보니, <strong>도메인 ↔ 엔터티 매핑 단계가 추가되며 복잡도가 올라가고</strong>, JPA가 제공하는 <strong>지연 로딩, 영속성 컨텍스트, 변경 감지 등 핵심 기능들을 활용하기 어려워지는 단점</strong>도 있었다.</p>
<p>결국 이번 과제에서는 도메인과 엔터티를 분리하지 않고, <strong>동일한 객체 내에서 비즈니스 로직과 영속성을 함께 다루는 구조</strong>로 설계하기로 결정했다.상황에 따라 트레이드오프가 필요한 영역이라는 걸 경험적으로 체감할 수 있었고,이번 과제에서는 <strong>복잡도보다 실용성과 학습 목적에 맞는 구조 선택</strong>이 더 중요하다고 판단했다.</p>
<h2 id="2-전략-패턴-적용-고민---쿠폰-발급">2. 전략 패턴 적용 고민 - 쿠폰 발급</h2>
<p>이번 과제에서는 도메인 복잡도가 높은 <code>쿠폰 발급</code> 기능에 전략 패턴을 도입할 수 있을지 고민했다.</p>
<p>현재 구조에서는 <code>Coupon</code> 엔티티 내부에서 모든 유효성 검증과 상태 변경을 수행하고 있었지만,</p>
<p>쿠폰 타입별 정책이 달라질 수 있는 상황을 고려해 <strong>전략 패턴 기반 설계로 분리</strong>하면 더 유연할 것 같다는 판단이 들었다.</p>
<p>예를 들어, <code>CouponType</code> 별로 정책을 다형적으로 위임하는 방식은 아래와 같다:</p>
<pre><code class="language-java">public interface CouponIssuePolicy {
    void validateIssuable(Coupon coupon, Long userId);
}

public class LimitedCouponPolicy implements CouponIssuePolicy {
    public void validateIssuable(Coupon coupon, Long userId) {
        if (coupon.isExpired()) throw new CouponException.ExpiredException();
        if (coupon.isExhausted()) throw new CouponException.AlreadyExhaustedException();
    }
}</code></pre>
<p>또한 발급 로직 자체도 전략으로 위임할 수 있다:</p>
<pre><code class="language-java">public interface CouponIssueStrategy {
    CouponResult execute(IssueCouponCommand command);
}

@RequiredArgsConstructor
public class LimitedCouponIssueStrategy implements CouponIssueStrategy {
    private final CouponReader couponReader;
    private final CouponIssueWriter couponIssueWriter;

    @Override
    public CouponResult execute(IssueCouponCommand command) {
        Coupon coupon = couponReader.findByCode(command.couponCode());
        coupon.validateUsable();  // 정책 위임 또는 내부 처리

        if (couponIssueWriter.hasIssued(command.userId(), coupon.getId())) {
            throw new CouponException.AlreadyIssuedException(command.userId(), command.couponCode());
        }

        CouponIssue issue = couponIssueWriter.save(command.userId(), coupon);
        return CouponResult.from(issue);
    }
}</code></pre>
<h3 id="쿠폰-발급-시나리오-시퀀스-다이어그램">쿠폰 발급 시나리오 시퀀스 다이어그램</h3>
<p><img src="https://velog.velcdn.com/images/thedev_junyoung/post/9a6f15a6-fa3c-4c32-b405-f8d6e0adc557/image.png" alt=""></p>
<h2 id="3-구현-관점-정리">3. 구현 관점 정리</h2>
<ul>
<li>각 도메인은 Facade → Service → Domain 계층으로 책임을 명확히 분리</li>
<li>외부 입력은 Request/Command, 출력은 Response/Result로 구분하여 의존 역전 실현</li>
<li>복잡한 유즈케이스(ex. 결제 성공 시 주문 확정 + 이벤트 저장)는 Facade 계층에서 orchestration</li>
<li>결제 방식(Balance, External 등)은 PaymentProcessor 전략으로 분리하여 확장성 확보</li>
<li>도메인 이벤트 기반 아웃박스 구조 설계 (OrderEvent, EventRelayScheduler 등)</li>
<li>전 계층 단위 테스트 완비 (Repository 없이 mock 기반 테스트)</li>
</ul>
<p>이번 주는 정말 구현도 구현이지만, 설계를 어떻게 풀어낼지를 깊이 고민한 한 주였다. 추상적으로만 알고 있던 아키텍처 개념들이 실제 코드에서 왜 필요한지, 어떤 효과를 주는지 몸으로 체감했고, 도메인 중심 설계가 단순한 구조 설계를 넘어서 팀 전체 개발 방향성과 유지보수성에 직결된다는 걸 느낄 수 있었다. </p>
<p>다음 주에는 인프라 계층 구현, 통합 테스트, 이벤트 발행 등까지 진행할 예정인데, 이번에 다져놓은 구조를 기반으로 어떻게 확장해갈 수 있을지 기대된다. 특히 Coupon, WaitingQueue 같은 복잡한 상태 기반 도메인을 어떻게 유연하게 풀어낼지 스스로도 궁금하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JAVA][Spring]TDD를 하며 정말 중요하다고 느낀 것]]></title>
            <link>https://velog.io/@thedev_junyoung/tdd-learnings</link>
            <guid>https://velog.io/@thedev_junyoung/tdd-learnings</guid>
            <pubDate>Sun, 06 Apr 2025 16:54:58 GMT</pubDate>
            <description><![CDATA[<h3 id="💡-tdd란-무엇인가">💡 TDD란 무엇인가?</h3>
<p>TDD(Test-Driven Development)는 Kent Beck이 제안한 개발 방법론으로, <strong>&quot;테스트 코드를 먼저 작성하고, 테스트를 통과하는 코드를 구현하며, 이를 반복적으로 개선하는 개발 방식&quot;</strong>이다. </p>
<p>TDD의 기본 사이클은 다음과 같다:</p>
<ol>
<li><strong>Red</strong>: 실패하는 테스트를 먼저 작성한다 (아직 기능이 구현되지 않았기 때문)</li>
<li><strong>Green</strong>: 테스트를 통과하도록 최소한의 코드를 작성한다</li>
<li><strong>Refactor</strong>: 중복을 제거하고 구조를 개선한다</li>
</ol>
<p>이 흐름은 단순히 테스트 중심의 개발이 아니라, <strong>안정성과 리팩토링 가능성을 확보하는 개발 전략</strong>이다.</p>
<hr>
<h3 id="💡-tdd에-대한-오해와-현실">💡 TDD에 대한 오해와 현실</h3>
<p>처음엔 TDD가 어렵게 느껴졌다.
테스트 코드를 작성하는 것조차 익숙하지 않았고,
“어디까지 테스트를 해야 하지?” “테스트는 언제 해야 해?” 같은 고민이 많았다.</p>
<p>TDD를 처음 시작했을 때는 이런 생각이 들었다. </p>
<ul>
<li>이걸 왜 해야 하지?</li>
<li>테스트 코드가 너무 많아지는 건 아닌가?</li>
<li>실제 서비스 개발에 과연 실용적인가?</li>
</ul>
<p>처음엔 테스트 작성이 너무 번거롭고, 시간 낭비처럼 느껴지기도 했다. 하지만 점점 기능 단위로 분리된 테스트를 작성하고, 그 테스트를 통해 빠르게 피드백을 받다 보니 <strong>&quot;테스트는 개발의 일부이며, 궁극적으로는 유지보수 비용을 줄이기 위한 투자&quot;</strong>라는 걸 체감했다.</p>
<p>그리고 이번 과정을 통해 느낀 건 단순했다.</p>
<blockquote>
<p><strong>진짜 중요한 건 &#39;이 코드가 진짜 안전한가?&#39; 를 확인하는 것</strong></p>
</blockquote>
<hr>
<h3 id="🔁-테스트의-본질은-검증이고-리소스-최소화다">🔁 테스트의 본질은 검증이고, 리소스 최소화다</h3>
<p>이전에는 기능을 만든 후 curl, postman, 프론트 요청 등으로 일일이 확인했다.
시간도 오래 걸리고, 매번 같은 테스트를 반복하면서 실수도 생겼다.</p>
<p>하지만 지금은 다르다.</p>
<ul>
<li><strong>단위 테스트로 빠르게 확인 가능</strong></li>
<li><strong>Mock 객체로 외부 의존 없이 핵심만 테스트</strong></li>
<li><strong>애플리케이션 내에서 검증 가능 → 리소스 최소화</strong></li>
</ul>
<pre><code class="language-java">// 이전: postman → DB 확인 → 로그 확인
// 지금: 테스트 코드 한 줄 → assertThat(result).isEqualTo(...)</code></pre>
<p>그리고 무엇보다 중요한 건 &#39;빠른 검증&#39;이다. 한 기능을 만들고 그것이 잘 작동하는지 검증하기 위해 굳이 전체 애플리케이션을 실행하거나, API 호출 → DB 확인 → 로그 추적 등의 수고를 들이지 않아도 된다. 정말 중요한 기능 단위만 쏙 빼서, 빠르게 테스트할 수 있다.</p>
<p>이걸 가능하게 만들기 위해선 &quot;불필요한 의존성 제거&quot;가 핵심이었다. 테스트하려는 단위 외 모든 것들을 &quot;더미&quot;로 치환하고, 진짜 중요한 그 한 단위만 순수하게 검증하는 구조를 만들기 위해 연습했고, 고민했다.</p>
<p>첨예한 테스트 케이스를 만들기 위해선 어떤 조건에서 실패할 수 있는지를 먼저 고민해야 했다. 실패할 수 있는 조건을 설계하고, 그걸 테스트로 먼저 박아두니 기능 자체도 더 날카로워졌고, 사소한 케이스도 놓치지 않게 됐다.</p>
<hr>
<h3 id="🧪-테스트-전략에-대해-처음-명확히-구분할-수-있었다">🧪 테스트 전략에 대해 처음 명확히 구분할 수 있었다</h3>
<table>
<thead>
<tr>
<th>테스트 종류</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Unit Test</strong></td>
<td>하나의 클래스/기능만 테스트 (Mock 적극 사용)</td>
</tr>
<tr>
<td><strong>Integration Test</strong></td>
<td>여러 Bean이 얽힌 흐름 테스트 (DB, API 포함)</td>
</tr>
<tr>
<td><strong>E2E Test</strong></td>
<td>사용자의 실제 시나리오 기반 전체 흐름 검증</td>
</tr>
</tbody></table>
<p>→ 이번엔 주로 <strong>단위 테스트</strong>에 집중하며, <strong>구조를 테스트하기 좋은 형태로 리팩토링하는 경험</strong>을 했다.</p>
<hr>
<h3 id="🔍-어떤-걸-테스트해야-할까">🔍 어떤 걸 테스트해야 할까?</h3>
<blockquote>
<p>&quot;예외를 발생시킬 수 있는 모든 경우를 내가 직접 컨트롤할 수 있어야 한다.&quot;</p>
</blockquote>
<ul>
<li>정상 흐름만이 아니라, 실패 흐름도 반드시 포함</li>
<li>도메인의 <strong>핵심 상태 변화</strong>를 테스트</li>
<li>테스트를 쓰기 힘든 구조 → <strong>리팩토링의 신호</strong></li>
</ul>
<hr>
<h3 id="🧠-내가-체감한-tdd의-진짜-장점">🧠 내가 체감한 TDD의 진짜 장점</h3>
<ul>
<li>기능 구현보다 <strong>설계 중심의 사고</strong>가 먼저 일어남</li>
<li>테스트 작성이 <strong>설계와 책임 분리를 돕는 도구</strong></li>
<li>무언가를 만들었을 때, <strong>믿고 넘어갈 수 있는 안전장치</strong></li>
<li>구조를 테스트하기 쉽게 만들다 보니, <strong>자연스럽게 의존성 분리, 계층 분리, 클린 아키텍처 구조로 이어짐</strong></li>
<li>테스트 코드를 작성한다는 건, &quot;어떤 구조여야 테스트하기 쉬운가?&quot;를 끊임없이 고민하게 만들었다</li>
<li>첨예한 테스트를 작성하기 위해 책임을 명확히 분리하다 보니, 서비스 계층이 응집력 있고, 설계 자체가 클린해졌다</li>
</ul>
<hr>
<h3 id="🧭-앞으로-배워나갈-클린-아키텍처와의-연결">🧭 앞으로 배워나갈 클린 아키텍처와의 연결</h3>
<p>TDD를 통해 설계와 책임 분리에 대해 끊임없이 고민하다 보면, 자연스럽게 <strong>클린 아키텍처(Clean Architecture)</strong>의 흐름으로 이어질 수밖에 없다. </p>
<p>클린 아키텍처는 관심사의 분리, 의존성 역전, 테스트 용이성을 극대화하기 위한 구조다. 이번 과정을 통해 다음과 같은 관점을 가지게 되었다:</p>
<ul>
<li>도메인 로직과 외부 구현의 분리 → 순수한 비즈니스 로직 유지</li>
<li>Application 계층에서 유즈케이스 책임 명확히 하기</li>
<li>Interface 계층은 표현의 역할, Infra 계층은 구현의 역할에만 집중</li>
</ul>
<p>TDD는 단순히 테스트를 위한 기술이 아닌, 이런 구조를 가능하게 만들도록 우리를 <strong>좋은 방향으로 유도</strong>해주는 길잡이 역할을 한다.</p>
<hr>
<h3 id="🗯-한-줄-정리">🗯 한 줄 정리</h3>
<blockquote>
<p><strong>TDD는 결국, &#39;테스트 코드&#39;가 중요한 게 아니라 &#39;검증할 수 있는 구조&#39;를 만들도록 유도해주는 도구였다.</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JAVA][Spring]클린 아키텍처 기반 구조 설계: 선착순 쿠폰 발급 시스템 ]]></title>
            <link>https://velog.io/@thedev_junyoung/clean-architecture-coupon-system</link>
            <guid>https://velog.io/@thedev_junyoung/clean-architecture-coupon-system</guid>
            <pubDate>Sun, 06 Apr 2025 16:36:42 GMT</pubDate>
            <description><![CDATA[<p>이번에 항해 3주차를 마치면서 클린 아키텍쳐 원칙을 적용하여 <code>선착순 쿠폰 발급 기능</code>을 구현한 과정을 공유하고자 한다. 
프로젝트 구조는 레이어드 아키텍처를 기반으로 하되, 각 레이어의 책임을 명확히 분리하고 테스트 용이성을 높이기 위해 Clean Architecture의 아이디어를 접목했다.  </p>
<p>최종적으로 아래와 같은 계층을 갖는 구조로 설계했다. </p>
<h2 id="아키텍처-계층-구조"><strong>아키텍처 계층 구조</strong></h2>
<p>각 계층은 애플리케이션의 한 부분을 담당하며, 서로 명확한 경계를 갖는다. 역할을 분리함으로써 코드의 <strong>응집도</strong>(cohesion)는 높이고 <strong>결합도</strong>(coupling)는 낮추어, 변경에 유연하고 테스트하기 쉬운 구조를 얻는다. 계층별 책임은 다음과 같다:</p>
<ul>
<li><strong>Request DTO (요청 DTO)</strong>: <strong>프레젠테이션 계층</strong>의 입력 모델이다. 주로 Controller에서 사용하며, 클라이언트로부터 전달받은 요청 파라미터를 담는 간단한 데이터 객체다. 
예를 들어 HTTP 요청 본문(JSON)을 매핑하는 <code>@RequestBody</code>용 클래스인데 <strong>외부 세계의 데이터 표현을 내부로 들여오는 역할</strong>을 하고, Validation Annotation 등을 활용해 1차적인 유효성 검증을 수행할 수 있다.</li>
<li><strong>Command 객체</strong>: <strong>애플리케이션 계층</strong>에서 사용하는 <strong>유즈케이스 입력 모델이다</strong>. Request DTO를 도메인에 전달하기 적합한 형태로 변환한 객체라고 볼 수 있다. 
주로 Service에 전달되며, <strong>유즈케이스 수행에 필요한 데이터를 캡슐화한다</strong>. Command 객체에는 비즈니스 로직은 없고 순수하게 데이터와 약간의 유효성 체크 정도만 포함한다.</li>
<li><strong>Facade (파사드)</strong>: <strong>선택적인 애플리케이션 계층</strong>으로, <strong>복잡한 유즈케이스의 진입점을 단순화</strong>하는 역할을 한다. 여러 Service 호출이나 트랜잭션 관리가 필요할 경우 Facade에서 한꺼번에 처리한다. </li>
<li><em>Controller와 Service 사이의 완충지대*</em> 역할을 하여, 필요에 따라 여러 서비스를 orchestration(조율)하거나 하나의 상위 유즈케이스로 묶어준다. 단순한 경우에는 생략 가능하며, 이 레이어가 없다면 Controller가 직접 Service를 호출하도록 구현한다.</li>
<li><strong>Service (서비스)</strong>: <strong>비즈니스 로직을 담당하는 애플리케이션 계층이다</strong>. 하나의 서비스는 하나의 <strong>유즈케이스(Use Case)</strong>를 구현하며, 주로 도메인 객체를 활용하여 비즈니스 규칙을 처리한다. 
트랜잭션 경계를 정하거나, 필요한 경우 도메인 객체를 생성/조회(<code>Repository</code> 활용)하고 <strong>도메인 로직을 실행</strong>한 뒤 결과를 반환한다. <strong>서비스 레이어에서는 도메인 객체와 외부 세계(예: DB)</strong>를 중개하지만, 구체적인 입출력 형식(JSON 등)이나 UI에 대해서는 모른다.</li>
<li><strong>Domain (도메인)</strong>: <strong>핵심 비즈니스 규칙과 엔티티를 담은 계층이다</strong>. 시스템이 제공해야 하는 개념들과 그 불변 조건, 상태 변경 로직 등이 이곳에 있다. 예를 들어 Coupon 엔티티, Coupon과 관련된 도메인 서비스, 그리고 <code>CouponRepository</code>와 같은 <strong>저장소 인터페이스</strong>가 도메인에 속한다. 
다른 레이어에 전혀 의존하지 않으며, 순수 자바/비즈니스 코드로만 이루어진다. 클린 아키텍처의 핵심인 <strong>의존성 규칙</strong>(Dependency Rule)에 따라, <em>외부 계층이 도메인에 의존하고, 도메인은 어떤 것도 의존하지 않도록</em> 구성했다.</li>
</ul>
<h2 id="유즈케이스-흐름-컨트롤러부터-도메인까지">유즈케이스 흐름: 컨트롤러부터 도메인까지</h2>
<p>이제 <code>선착순 쿠폰 발급</code> 기능을 예시로 각 레이어가 어떻게 협력하는지 단계별로 알아보자. 이 기능은 &quot;특정 쿠폰 이벤트에 대해 선착순으로 제한된 수량의 쿠폰을 사용자에게 발급한다&quot;는 시나리오다.</p>
<h2 id="1-controller-요청-수신-request-dto-→-command-변환-및-응답-생성"><strong>1. Controller: 요청 수신, Request DTO → Command 변환 및 응답 생성</strong></h2>
<p>사용자가 한정 수량 쿠폰 발급 API를 호출하면, Controller가 요청을 수신한다.</p>
<p>이 프로젝트에서는 API 명세(<code>@Operation</code>, <code>@PostMapping</code>)는 <code>CouponAPI</code>라는 인터페이스에 정의되어 있고, 실제 로직은 이를 구현한 <code>CouponController</code>에서 처리한다.</p>
<pre><code class="language-java">@Tag(name = &quot;Coupon&quot;, description = &quot;쿠폰 발급 및 조회 API&quot;)
@RequestMapping(&quot;/api/v1/coupons&quot;)
public interface CouponAPI {

    @Operation(
        summary = &quot;한정 수량 쿠폰 발급&quot;,
        description = &quot;&quot;&quot;
        한정 수량 쿠폰을 사용자가 발급받는 API입니다.

        - 한정 수량 초과 시 `422 UNPROCESSABLE_ENTITY` 반환
        - 이미 발급받은 사용자는 `409 CONFLICT` 반환
        - 쿠폰이 만료되었거나 존재하지 않으면 `404 NOT_FOUND` 반환
        &quot;&quot;&quot;,
        requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
            required = true,
            description = &quot;발급받을 쿠폰 정보&quot;,
            content = @Content(schema = @Schema(implementation = CouponResponse.class))
        ),
        responses = {
            @ApiResponse(responseCode = &quot;200&quot;, description = &quot;쿠폰 발급 성공&quot;,
                content = @Content(schema = @Schema(implementation = CouponResponse.class))),
            @ApiResponse(responseCode = &quot;404&quot;, description = &quot;쿠폰이 존재하지 않거나 만료됨&quot;),
            @ApiResponse(responseCode = &quot;409&quot;, description = &quot;이미 발급받은 쿠폰&quot;),
            @ApiResponse(responseCode = &quot;422&quot;, description = &quot;발급 가능한 수량 초과&quot;)
        }
    )
    @PostMapping(&quot;/limited-issue&quot;)
    ResponseEntity&lt;CustomApiResponse&lt;CouponResponse&gt;&gt; limitedIssueCoupon(
        @Valid @RequestBody CouponRequest request
    );
}</code></pre>
<p>위와 같이 명세는 인터페이스에서 정의되며, 실제 처리는 다음과 같이 <code>CouponController</code>에서 수행된다:</p>
<pre><code class="language-java">@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/api/v1/coupons&quot;)
public class CouponController implements CouponAPI {

    private final CouponUseCase couponUseCase;

    @Override
    public ResponseEntity&lt;CustomApiResponse&lt;CouponResponse&gt;&gt; limitedIssueCoupon(
        @Valid @RequestBody CouponRequest request
    ) {
        // 1) Request DTO → Command 변환
        IssueLimitedCouponCommand command = request.toCommand();

        // 2) UseCase 실행 (Service 호출)
        CouponResult result = couponUseCase.issueLimitedCoupon(command);

        // 3) 결과 → Response DTO 변환 → 공통 응답 포맷으로 래핑
        return ResponseEntity.ok(CustomApiResponse.success(
            new CouponResponse(
                result.userCouponId(),
                result.userId(),
                result.couponType(),
                result.discountRate(),
                result.issuedAt(),
                result.expiryDate()
            )
        ));
    }
}</code></pre>
<h3 id="흐름-요약">흐름 요약</h3>
<ol>
<li><p><strong><code>CouponRequest</code> (요청 DTO)</strong></p>
<p> 클라이언트에서 전달된 JSON 요청은 <code>CouponRequest</code> 객체로 매핑된다. <code>@Valid</code>를 통해 기본적인 검증도 함께 수행된다.</p>
</li>
<li><p><strong>Command 객체 변환</strong></p>
<p> <code>CouponRequest</code>는 <code>toCommand()</code> 메서드를 통해 <code>IssueLimitedCouponCommand</code>로 변환된다. 이 객체는 애플리케이션 계층에서 사용하는 유즈케이스 입력 모델로, 불필요한 외부 의존을 제거한 <strong>순수 데이터 캡슐화 객체</strong>이다.</p>
<pre><code class="language-java"> java
 CopyEdit
 public record IssueLimitedCouponCommand(Long userId, String couponCode) { }
</code></pre>
</li>
<li><p><strong>UseCase 실행</strong></p>
<p> <code>CouponUseCase</code>는 <code>issueLimitedCoupon()</code> 메서드를 통해 도메인 계층을 호출하고 핵심 로직을 수행한다.</p>
<p> 이 과정에서 쿠폰 존재 여부, 재고, 중복 발급 여부, 만료 여부 등을 도메인 계층에서 검증하게 된다.</p>
</li>
<li><p><strong>결과 변환 및 응답</strong></p>
<p> UseCase에서 반환된 결과는 <code>CouponResult</code>라는 응용 계층 DTO로 매핑되며, 다시 <code>CouponResponse</code>로 가공된다. 마지막으로 <code>CustomApiResponse.success()</code>로 감싸져 통일된 API 응답 형태로 반환된다.</p>
</li>
</ol>
<hr>
<p>이 구조의 장점은 다음과 같다:</p>
<ul>
<li><strong>입력, 도메인, 출력 모델을 명확히 구분</strong>하여 변경에 유연함</li>
<li><strong>인터페이스로 API 명세 분리</strong> → Swagger 문서화와 구현 분리</li>
<li><strong>단일 진입점인 UseCase</strong>를 통해 비즈니스 흐름이 명확해짐</li>
<li><strong>응답 래핑을 통한 일관된 API 응답 포맷 제공</strong></li>
</ul>
<h2 id="2-usecase-유즈케이스-실행-및-도메인-호출"><strong>2. UseCase: 유즈케이스 실행 및 도메인 호출</strong></h2>
<p>이번 구조에서는 <strong>Facade를 따로 두지 않고</strong>, <code>CouponController</code>가 직접 <code>CouponUseCase</code>를 호출한다.</p>
<p><code>CouponUseCase</code>는 실제 비즈니스 흐름을 처리하는 <strong>응용 계층의 유즈케이스 인터페이스</strong>이고, 그 구현체인 <code>CouponService</code>가 주요 로직을 담당한다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class CouponService implements CouponUseCase {

    private final CouponReader couponReader;
    private final CouponIssueWriter couponIssueWriter;
    private final CouponIssueReader couponIssueReader;

    @Override
    public CouponResult issueLimitedCoupon(IssueLimitedCouponCommand command) {
        // 1) 쿠폰 조회
        Coupon coupon = couponReader.findByCode(command.couponCode());

        // 2) 쿠폰 유효성 확인 (만료, 재고 등)
        coupon.validateUsable();

        // 3) 중복 발급 여부 확인
        if (couponIssueWriter.hasIssued(command.userId(), coupon.getId())) {
            throw new CouponException.AlreadyIssuedException(command.userId(), command.couponCode());
        }

        // 4) 쿠폰 발급 이력 저장
        CouponIssue issue = couponIssueWriter.save(command.userId(), coupon);

        // 5) 응답용 Result DTO 반환
        return CouponResult.from(issue);
    }
}</code></pre>
<h3 id="흐름-요약-1">흐름 요약</h3>
<ul>
<li><strong>Reader/Writer 분리 구조</strong><ul>
<li><code>CouponReader</code>: 쿠폰 조회 전용 포트</li>
<li><code>CouponIssueWriter</code>: 발급 처리 및 중복 체크</li>
<li><code>CouponIssueReader</code>: 발급 여부 확인 (apply 시 사용)</li>
</ul>
</li>
<li><strong>도메인 규칙은 <code>Coupon</code> 엔티티에서 책임지고</strong>, 서비스는 흐름만 조율</li>
<li>결과는 도메인 객체인 <code>CouponIssue</code>를 <strong><code>CouponResult</code> 응용 DTO</strong>로 변환하여 반환</li>
</ul>
<h3 id="facade는-왜-없는가">Facade는 왜 없는가?</h3>
<p>현재는 유즈케이스 하나만 단순하게 실행하면 되므로 <strong>추가 조율(Facade)이 필요 없는 구조</strong>다.</p>
<p>알림, 포인트 차감, 이벤트 발행 등과 같은 부가 처리가 없는 경우,</p>
<p>불필요하게 Facade 레이어를 도입하지 않고 <strong>간단하고 명확한 흐름을 유지</strong>하는 것이 더 낫다.</p>
<h2 id="3-domain-핵심-비즈니스-로직과-엔티티"><strong>3. Domain: 핵심 비즈니스 로직과 엔티티</strong></h2>
<p>도메인 계층은 시스템의 <strong>핵심 비즈니스 규칙과 상태를 표현</strong>하는 영역이다.</p>
<p>이번 구조에서는 <code>Coupon</code> 엔티티가 선착순 발급 방식의 핵심 개념과 제약을 담고 있으며, 모든 비즈니스 유효성 검사와 상태 변화는 이 객체 내부에서 수행된다.</p>
<pre><code class="language-java">@Entity
@Table(name = &quot;coupon&quot;)
@Getter
@NoArgsConstructor
public class Coupon {

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

    @Column(nullable = false, unique = true)
    private String code;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private CouponType type;

    @Column(nullable = false)
    private Integer discountRate;

    @Column(nullable = false)
    private Integer totalQuantity;

    @Column(nullable = false)
    private Integer remainingQuantity;

    @Column(nullable = false)
    private LocalDateTime validFrom;

    @Column(nullable = false)
    private LocalDateTime validUntil;

    // 생성자 및 팩토리 메서드 생략...

    /**
     * 쿠폰 사용 가능 여부 검증 (만료 or 재고 소진 여부)
     */
    public void validateUsable() {
        if (isExpired()) {
            throw new CouponException.ExpiredException();
        }
        if (isExhausted()) {
            throw new CouponException.AlreadyExhaustedException();
        }
    }

    /**
     * 재고 차감 로직
     */
    public void decreaseQuantity() {
        validateUsable(); // 보호 로직 내장
        this.remainingQuantity -= 1;
    }

    /**
     * 할인 금액 계산 (정액/정률)
     */
    public Money calculateDiscount(Money orderAmount) {
        return switch (this.type) {
            case FIXED -&gt; Money.wons(this.discountRate);
            case PERCENTAGE -&gt; orderAmount.multiplyPercent(this.discountRate);
        };
    }

    private boolean isExpired() {
        return LocalDateTime.now().isAfter(this.validUntil);
    }

    private boolean isExhausted() {
        return this.remainingQuantity &lt;= 0;
    }
}</code></pre>
<h3 id="📌-주요-도메인-규칙-요약">📌 주요 도메인 규칙 요약</h3>
<ul>
<li><p><strong>쿠폰 유효성 검사</strong></p>
<p>  <code>validateUsable()</code> 메서드를 통해 쿠폰의 <strong>만료 여부</strong>와 <strong>발급 가능 수량</strong>을 확인한다.</p>
<p>  유효하지 않은 경우에는 도메인 전용 예외를 발생시켜 잘못된 사용을 방지한다.</p>
</li>
<li><p><strong>발급 수량 차감 처리</strong></p>
<p>  <code>decreaseQuantity()</code>는 정상적으로 발급이 가능할 때,</p>
<p>  <strong>남은 발급 수량을 1 줄이는 역할</strong>을 한다.</p>
<p>  단순한 수학 연산이 아니라, 쿠폰 발급의 핵심 상태 변경을 도메인 객체가 <strong>스스로 책임지고 수행</strong>하는 구조다.</p>
</li>
<li><p><strong>할인 금액 계산</strong></p>
<p>  쿠폰 타입에 따라 할인 금액 계산 방식이 달라진다.</p>
<ul>
<li><code>FIXED</code> : 정액 할인</li>
<li><code>PERCENTAGE</code> : 주문 금액의 일정 비율 할인</li>
</ul>
</li>
</ul>
<hr>
<h3 id="기존-방식과의-차이점">기존 방식과의 차이점</h3>
<p>이전 구조에서는 <code>issuedUserIds</code> 같은 메모리 내 <code>Set</code>으로 중복 여부를 관리했지만, 지금은 <strong>DB 조회 방식(<code>couponIssueWriter.hasIssued(...)</code>)</strong>을 통해 처리하고 있다.</p>
<p>또한 도메인 예외는 흩어진 개별 클래스가 아닌, <code>CouponException</code>이라는 공통 예외 클래스의 <strong>정적 내부 클래스 형태</strong>로 통일되었다.</p>
<pre><code class="language-java">public class CouponException extends RuntimeException {

    public static class AlreadyIssuedException extends CouponException {
        public AlreadyIssuedException(Long userId, String couponCode) {
            super(&quot;이미 발급된 쿠폰입니다: userId=&quot; + userId + &quot;, couponCode=&quot; + couponCode);
        }
    }

    public static class AlreadyExhaustedException extends CouponException {
        public AlreadyExhaustedException() {
            super(&quot;쿠폰 재고가 모두 소진되었습니다.&quot;);
        }
    }

    public static class ExpiredException extends CouponException {
        public ExpiredException() {
            super(&quot;쿠폰이 만료되었습니다.&quot;);
        }
    }

    public CouponException(String message) {
        super(message);
    }
}</code></pre>
<hr>
<h3 id="도메인-계층의-핵심">도메인 계층의 핵심</h3>
<ul>
<li>불변 조건(invariant)을 보장하는 <code>validateUsable()</code>, <code>decreaseQuantity()</code>는 <strong>내부 로직 외부 노출 없이 캡슐화</strong></li>
<li>외부 레이어(Service 등)는 도메인 객체의 상태를 직접 수정하지 않고, <strong>메서드 호출을 통해 규칙을 위임</strong></li>
<li>예외 상황도 도메인 객체 내에서 직접 판단하고, 적절한 도메인 예외를 던짐</li>
</ul>
<h2 id="5-응답-생성-및-반환"><strong>5. 응답 생성 및 반환</strong></h2>
<p>Service에서 처리된 결과는 컨트롤러로 반환되며,</p>
<p>컨트롤러는 이를 클라이언트에 전달할 <strong>응답 DTO(Response)</strong>로 변환한다.</p>
<p>이번 구조에서는 도메인 계층의 처리 결과를 <code>CouponIssue</code> 엔티티로 받은 뒤,</p>
<p>이를 <code>CouponResult</code>라는 <strong>응용 계층의 DTO</strong>로 변환하고,</p>
<p>마지막으로 <code>CouponResponse</code>라는 <strong>프레젠테이션 계층의 DTO</strong>로 가공해 API 응답을 생성한다.</p>
<pre><code>Domain(CouponIssue)
    → CouponResult (Application DTO)
        → CouponResponse (Response DTO)</code></pre><p>이처럼 입력과 출력 모두에서 별도의 DTO를 사용하면,</p>
<p>API 스펙이 변경되더라도 내부 도메인 로직에는 영향을 거의 주지 않기 때문에</p>
<p><strong>유지보수성과 확장성 면에서 매우 유리</strong>하다.</p>
<hr>
<h3 id="📌-계층-간-책임-분리-흐름-정리">📌 계층 간 책임 분리 흐름 정리</h3>
<pre><code>Controller
  → Request DTO (CouponRequest)
  → Command (IssueLimitedCouponCommand)
  → UseCase (CouponService)
  → Domain (Coupon, CouponIssue)
  → Result DTO (CouponResult)
  → Response DTO (CouponResponse)
  → HTTP 응답 (CustomApiResponse)</code></pre><hr>
<h3 id="책임-정리">책임 정리</h3>
<table>
<thead>
<tr>
<th>구성요소</th>
<th>책임</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Controller + Request/Response DTO</strong></td>
<td>외부와의 인터페이스, 입력/출력 명세 관리</td>
</tr>
<tr>
<td><strong>Command + UseCase</strong></td>
<td>유즈케이스 실행 흐름 제어</td>
</tr>
<tr>
<td><strong>Domain (Coupon)</strong></td>
<td>비즈니스 규칙, 상태 변경 로직 캡슐화</td>
</tr>
<tr>
<td><strong>Result DTO</strong></td>
<td>도메인 결과를 애플리케이션 계층에서 응답용으로 가공</td>
</tr>
<tr>
<td><strong>Response DTO</strong></td>
<td>클라이언트에 전달할 명확한 데이터 구조 표현</td>
</tr>
</tbody></table>
<hr>
<h3 id="핵심-인사이트">핵심 인사이트</h3>
<blockquote>
<p>API의 입력/출력과 내부 도메인 모델을 명확히 분리함으로써,</p>
<p>도메인은 외부 변화로부터 보호되고, 컨트롤러는 오직 입출력 처리에만 집중할 수 있다.</p>
</blockquote>
<p>이러한 설계 방식은 <strong>변화에 유연하고, 계층 간 책임이 명확하게 구분된 클린 아키텍처 구조</strong>를 완성시켜 준다.</p>
<h2 id="패키지-구조-예시"><strong>패키지 구조 예시</strong></h2>
<p>이번 프로젝트에서는 기능별 모듈(Context)을 중심으로 패키지를 나누고,</p>
<p>각 모듈 내부를 <strong>계층(Application / Domain / Interfaces / Infrastructure)</strong> 기준으로 구분하는 방식을 사용했다.</p>
<p>예를 들어, 쿠폰 기능의 전체 패키지 구조는 다음과 같다:</p>
<pre><code>kr.hhplus.be.server
├── application
│   └── coupon                        # [Application Layer - UseCase, Command, Result]
│       ├── ApplyCouponCommand.java
│       ├── ApplyCouponResult.java
│       ├── CouponResult.java
│       ├── CouponService.java
│       ├── CouponUseCase.java
│       └── IssueLimitedCouponCommand.java
│
├── domain
│   └── coupon                        # [Domain Layer - Entity, Repository Interface, Exception]
│       ├── Coupon.java
│       ├── CouponException.java
│       ├── CouponIssue.java
│       ├── CouponIssueReader.java
│       ├── CouponIssueWriter.java
│       ├── CouponReader.java
│       └── CouponType.java
│
├── infrastructure
│   └── coupon                        # [Infrastructure Layer - Repository 구현체]
│       ├── CouponIssueReaderImpl.java
│       ├── CouponIssueWriterImpl.java
│       └── CouponReaderImpl.java
│
└── interfaces
    └── coupon                        # [Interface Layer - Controller, DTO]
        ├── CouponAPI.java
        ├── CouponController.java
        ├── CouponRequest.java
        └── CouponResponse.java
</code></pre><hr>
<h2 id="패키징-전략-설명">패키징 전략 설명</h2>
<ul>
<li><p><strong>Context 기반 모듈화</strong></p>
<p>  <code>coupon</code>, <code>order</code>, <code>payment</code>, <code>product</code> 등 <strong>도메인 기능 단위로 폴더를 나누고</strong>, 각 폴더 안에서 계층별로 구조화한다.</p>
</li>
<li><p><strong>계층적 구분</strong></p>
<p>  각 모듈 안에서는 기능을 기준으로가 아니라, <strong>클린 아키텍처의 레이어(Application, Domain, Interfaces, Infrastructure)</strong>에 따라 파일을 나눈다.</p>
<ul>
<li><code>application</code>: 유즈케이스 구현, Command/Result DTO</li>
<li><code>domain</code>: 엔티티, 도메인 서비스, 예외, Repository 인터페이스</li>
<li><code>interfaces</code>: API 엔드포인트, Request/Response DTO</li>
<li><code>infrastructure</code>: 실제 DB 연동 구현체 (JPA 등)</li>
</ul>
</li>
<li><p><strong>의존성 방향 준수</strong></p>
<p>  <code>application</code>은 <code>domain</code>에만 의존하고,</p>
<p>  <code>interfaces</code>, <code>infrastructure</code>는 <code>application</code> 또는 <code>domain</code>에 의존하지만,</p>
<p>  <strong>그 반대는 절대 없다</strong>.</p>
<p>  → 계층 간 의존성은 항상 <strong>안쪽에서 바깥쪽으로 향하지 않는다.</strong></p>
</li>
</ul>
<hr>
<p>이 구조의 장점은 다음과 같다:</p>
<ul>
<li>관심사 분리에 따라 <strong>역할이 명확해지고</strong>, 유지보수가 쉬워짐</li>
<li>테스트 시 각 계층만 독립적으로 검증할 수 있음</li>
<li>기능별 모듈이 잘 분리되어 있어, 팀 단위 개발/배포에 유리함</li>
<li>추후 도메인 별 CQRS, 이벤트 발행 등도 무리 없이 확장 가능</li>
</ul>
<h2 id="테스트-전략-및-품질-향상">테스트 전략 및 품질 향상</h2>
<p>계층을 명확히 분리한 클린 아키텍처의 또 다른 큰 장점은 <strong>테스트 작성이 매우 쉬워진다</strong>는 점이다.</p>
<p>각 레이어는 자기 책임만을 갖도록 설계되어 있기 때문에, <strong>작은 단위로 테스트를 격리하고 집중</strong>해서 작성할 수 있다.</p>
<p>이번 쿠폰 발급 기능 구현에서는 도메인부터 서비스 레이어까지 다음과 같은 전략으로 테스트를 진행했다.</p>
<hr>
<h3 id="도메인-단위-테스트">도메인 단위 테스트</h3>
<p>도메인 계층에서는 <code>Coupon</code> 엔티티가 갖고 있는 <strong>비즈니스 로직 중심의 메서드</strong>들을 단독으로 테스트했다.</p>
<p>예를 들어, 다음과 같은 사항들을 검증했다:</p>
<ul>
<li>만료된 쿠폰은 <code>CouponException.ExpiredException</code>을 던지는지</li>
<li>발급 수량이 0인 경우 <code>CouponException.AlreadyExhaustedException</code>을 던지는지</li>
<li><code>calculateDiscount()</code>가 정해진 할인 정책에 따라 정확히 계산되는지</li>
</ul>
<p>이러한 테스트는 <strong>스프링 컨텍스트나 DB 없이</strong> 순수 자바 객체 수준에서 빠르게 실행되며,</p>
<p>비즈니스 규칙이 제대로 동작하는지를 단순하고 명확하게 검증할 수 있었다.</p>
<hr>
<h3 id="서비스-레이어-테스트-mock-기반">서비스 레이어 테스트 (Mock 기반)</h3>
<p>서비스 레이어에서는 외부 의존성을 가진 <code>CouponReader</code>, <code>CouponIssueWriter</code>, <code>CouponIssueReader</code> 등을 <strong>Mockito로 Mock 처리</strong>하여 테스트했다.</p>
<p>이렇게 하면 DB 없이도 각 포트의 동작을 자유롭게 시뮬레이션하고, 로직만을 격리하여 검증할 수 있다.</p>
<p>예를 들어, 다음과 같은 테스트 케이스를 작성했다:</p>
<pre><code class="language-java">@ExtendWith(MockitoExtension.class)
class CouponServiceTest {

    @Mock
    private CouponReader couponReader;

    @Mock
    private CouponIssueWriter couponIssueWriter;

    @Mock
    private CouponIssueReader couponIssueReader;

    @InjectMocks
    private CouponService couponService;

    private final String couponCode = &quot;TEST10&quot;;
    private final long userId = 1L;

    @Test
    @DisplayName(&quot;쿠폰 정상 발급 성공&quot;)
    void issueCoupon_success() {
        // given
        Coupon coupon = createValidCoupon();
        CouponIssue issued = new CouponIssue(userId, coupon);
        IssueLimitedCouponCommand command = new IssueLimitedCouponCommand(userId, couponCode);

        given(couponReader.findByCode(couponCode)).willReturn(coupon);
        given(couponIssueWriter.hasIssued(userId, coupon.getId())).willReturn(false);
        given(couponIssueWriter.save(userId, coupon)).willReturn(issued);

        // when
        CouponResult result = couponService.issueLimitedCoupon(command);

        // then
        assertThat(result).isNotNull();
        assertThat(result.userId()).isEqualTo(userId);
        verify(couponReader).findByCode(couponCode);
        verify(couponIssueWriter).save(userId, coupon);
    }</code></pre>
<p>이 테스트에서는 쿠폰이 정상적으로 발급되었는지,</p>
<p>그리고 <code>save()</code> 메서드가 실제로 호출되었는지를 검증한다.</p>
<p>이외에도 다음과 같은 다양한 상황별 테스트를 작성했다:</p>
<ul>
<li><strong>이미 발급된 사용자인 경우</strong> 예외 발생</li>
<li><strong>만료된 쿠폰일 경우</strong> 예외 발생</li>
<li><strong>수량이 소진된 쿠폰일 경우</strong> 예외 발생</li>
<li><strong>쿠폰을 발급받지 않은 사용자가 쿠폰을 적용하려 할 경우</strong> 예외 발생</li>
<li><strong>쿠폰 할인 정책이 정확하게 적용되는지</strong> 확인</li>
</ul>
<p>또한 테스트 코드 내에서 반복되는 쿠폰 생성 로직은 아래와 같은 픽스처 메서드로 공통화하여 재사용성을 높였다:</p>
<pre><code class="language-java">private Coupon createValidCoupon() {
    return Coupon.create(
            couponCode,
            CouponType.PERCENTAGE,
            10,
            100,
            LocalDateTime.now().minusDays(1),
            LocalDateTime.now().plusDays(1)
    );
}</code></pre>
<p>이렇게 함으로써 테스트 코드가 깔끔하고 읽기 쉬워졌으며, 새로운 테스트 케이스를 추가할 때도 부담 없이 확장할 수 있었다.</p>
<hr>
<h2 id="💡-설계-후기-및-인사이트">💡 설계 후기 및 인사이트</h2>
<p>이번 설계와 구현을 진행하면서 클린 아키텍처가 가지는 진정한 가치에 대해 체감할 수 있었다.</p>
<p>처음에는 단순히 Controller → Service → Repository 구조만으로 충분해 보였지만,</p>
<p><strong>유즈케이스 중심의 구조 분리, Command/Result 객체의 도입, 응답 DTO 계층 분리</strong>를 적용하면서</p>
<p><strong>각 계층의 책임이 더욱 명확해지고 유지보수가 쉬워졌다.</strong></p>
<p>특히 다음과 같은 점에서 큰 만족감을 느꼈다:</p>
<ul>
<li><p><strong>테스트 가능한 구조</strong></p>
<p>  도메인과 서비스가 잘 분리되어 있어, 각 계층을 <strong>Mock 또는 Stub으로 대체하며 테스트</strong>할 수 있었고,</p>
<p>  실제 DB 없이도 복잡한 시나리오를 단위 테스트 수준에서 모두 커버할 수 있었다.</p>
</li>
<li><p><strong>유연성과 확장성</strong></p>
<p>  예를 들어, 현재는 선착순 발급 방식이지만 추후 추첨 방식으로 변경되어도</p>
<p>  Domain 계층의 로직만 수정하면 되고, Controller나 Application 계층에는 영향이 거의 없다.</p>
<p>  새로운 검증 로직이 필요해도 도메인에 캡슐화하여 추가할 수 있고,</p>
<p>  기존 흐름을 깨지 않고도 유즈케이스를 확장해나갈 수 있는 구조가 갖춰져 있었다.</p>
</li>
<li><p><strong>SOLID 원칙과 설계 철학의 체화</strong></p>
<p>  단순히 “클린 아키텍처”라는 이름을 따르는 게 아니라,</p>
<p>  내부적으로는 <strong>의존성 역전(DIP)</strong>, <strong>단일 책임 원칙(SRP)</strong>, <strong>개방-폐쇄 원칙(OCP)</strong> 등의 철학이 자연스럽게 녹아들게 되었다.</p>
<p>  이로 인해 요구사항 변경에 유연하고, 테스트와 리팩토링이 쉬운 구조를 실제로 구현할 수 있었다.</p>
</li>
</ul>
<hr>
<h2 id="아키텍처-설계의-본질적인-가치"><strong>아키텍처 설계의 본질적인 가치</strong></h2>
<p>이번 주 클린 아키텍처 기반의 설계와 구현을 진행하면서 다시 한 번 느낀 것은, 헥사고날 아키텍처든 클린 아키텍처든 결국에는 <strong>SOLID 원칙을 얼마나 잘 지키는가</strong>가 핵심이라는 점이었다. 요구사항은 언제든지 변할 수 있기 때문에, 변화에 유연하게 대응할 수 있는 구조를 만드는 것이 설계의 시작이자 목적이라는 걸 체감할 수 있었다.</p>
<p>이러한 이유로 수많은 디자인 패턴과 아키텍처가 등장했고, 이들과 함께 <strong>빠른 검증을 가능하게 하는 테스터블한 코드 구조</strong>가 결합되어 왔다. 처음에는 이러한 구조가 다소 번거롭고 생산성이 떨어지는 것처럼 느껴질 수 있지만, 시간이 지남에 따라 유지보수성과 확장성이 좋아지고 결과적으로 <strong>생산성이 더욱 향상되는 구조</strong>라는 것을 직접 경험하게 되었다.</p>
<p>그리고 곰곰이 생각해보면 단순히 코드 구조나 패턴을 아는 것을 넘어서, <strong>왜 이렇게 설계해야 하는지</strong>, <strong>그 구조가 장기적으로 어떤 이점을 주는지</strong>에 대한 고민이 훨씬 더 중요하다는 생각이 든다. 정말 공부할 것도 많고, 갈수록 더 재밌어진다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 백엔드 2주차 회고(WIL)]]></title>
            <link>https://velog.io/@thedev_junyoung/hanghae-2wd-wil</link>
            <guid>https://velog.io/@thedev_junyoung/hanghae-2wd-wil</guid>
            <pubDate>Fri, 04 Apr 2025 07:46:50 GMT</pubDate>
            <description><![CDATA[<p>이번 주는 정말 말 그대로 &quot;항해&quot;라는 이름에 어울리는 한 주였다. 어디로 가야 할지, 뭘 먼저 해야 할지, 바다 위에 떠 있는 기분이었다. 이번 주 과제는 요구사항이 굉장히 러프하게 주어져서, 과연 어떤 문서를 어떤 수준으로 작성해야 하는지 판단하기가 너무 어려웠다. 혼자서 &quot;이게 맞나?&quot;를 계속 되뇌이게 되는 한 주였고, 그 덕분에 설계라는 작업 자체에 대해 아주 깊이 있게 고민해볼 수 있었다.</p>
<hr>
<p>초반에는 정말 막막했다. 학교나 학원에서도 설계 과제를 해본 적은 있었지만, 이번처럼 &quot;진짜 내가 주도해서 만들어가야 하는 상황&quot;은 또 처음이었던 것 같다. 특히나 이 과제는 내가 스스로 시나리오를 세우고, 도메인을 정의하고, 설계를 해야 하는 방식이라 더더욱 그랬다. 머릿속에 구상이 안 잡히니까 손도 안 움직이고, 문서 한 줄 쓰는 것도 망설이게 되더라. 무엇보다 ‘설계에 정답이 없다’는 사실이 마음을 더 무겁게 했던 것 같다.</p>
<hr>
<p>하지만 멘토링과 발제 자료 복기, 그리고 다른 팀원분들의 설계 방향을 참고하면서 조금씩 나만의 기준을 세워갔다. ‘정답은 없다, 내가 정답을 만드는 것이다’라는 생각으로 방향을 정했고, 그때부터 본격적으로 설계를 시작했다. 이동규 코치님 멘토링에서 ‘이벤트 스토밍’이라는 개념을 들은 게 결정적이었다. 이걸 단순히 문서 한 장으로 생각하지 않고, 도메인을 깊이 있게 탐색하고 도출하는 도구로써 받아들이게 되었다. 객체지향적인 설계를 하고 싶다는 욕심도 있었기 때문에, 도메인 모델링을 할 때 꽤 많은 시간을 들였다.</p>
<hr>
<p>동시성 문제나 외부 이벤트 처리까지도 생각하다 보니 자연스럽게 트랜잭셔널 아웃박스 패턴을 공부하게 되었고, 이 역시 설계에 반영했다. 시퀀스 다이어그램, 상태 다이어그램, 클래스 다이어그램, ERD까지 가능한 모든 시각적 자료를 활용해서 구조를 정리하려고 노력했다. 문서를 작성하는 과정 자체가 ‘정리를 위한 정리’가 아니라, 실제로 코드로 옮겨갈 기반이라는 인식을 하면서 더 몰입할 수 있었다.</p>
<hr>
<p>심화과제에서는 API 명세와 Mock API 구현을 진행했다. 지난주 과제와 이어지는 흐름이라 익숙한 부분도 있었지만, 이번에는 더 실제 서비스에 가깝게 인터페이스를 나누고 구조를 유연하게 구성하고자 노력했다. Swagger 명세를 위한 인터페이스를 따로 두고, 컨트롤러에서 implements를 통해 구현하는 방식으로 역할을 분리했다. 서비스 계층도 인터페이스를 만들고, Mock 객체를 별도로 구현해서 컨트롤러에 주입하는 형태로 구성했다.
이렇게 하면 실제 구현으로 전환할 때 유연하게 대체가 가능하다고 판단했다. 다만 이 과정에서 몇 가지 고민이 생겼다.</p>
<ul>
<li>Mock 객체는 테스트 대상인가? TDD처럼 테스트 코드를 작성해야 할까?</li>
<li>컨트롤러의 Swagger용 코드와 Mock 코드가 섞이는 게 너무 보기 싫었는데, 내가 선택한 API 인터페이스 분리 방식이 실무에서도 의미 있는 접근일까?</li>
</ul>
<p>예를 들어 쿠폰 응답 값을 Mock으로 처리할 때, 실제 도메인에서는 할인율과 타입을 Enum으로 나눠놨지만, 명세에서는 문자열로 묶어서 처리했는데, 이런 건 도메인 모델을 미리 설계해서 반영해야 하는 걸까?</p>
<p>이런 질문들이 계속 머릿속을 맴돌았고, 실제 서비스 설계를 해본 경험이 많지 않다 보니 계속 ‘이렇게 해도 될까?’라는 생각이 들었다. 하지만 그럼에도 불구하고 결국에는 기본과제 + 심화과제 + nice to have까지 모두 마무리할 수 있었고, 지금까지의 과정을 되돌아보면 굉장히 큰 성취라고 느낀다.</p>
<hr>
<p>물론 설계를 하다 보면 계속 구조가 바뀌고, 그에 따라 문서를 반복적으로 수정해야 했던 건 꽤 지치기도 했다. 처음부터 명확하게 방향이 잡혀서 일사천리로 진행되면 좋겠지만, 아직은 실력을 쌓는 단계이고, 시행착오를 겪으며 배우는 중이라고 생각한다. 완벽하게 하는 것보다, 이번 주에는 진심으로 몰입하고 성장했다는 사실이 더 중요하다고 믿는다.</p>
<hr>
<p>이번 주는 정말 여러모로 값진 시간이었다. 기획, 설계, 분석, 테스트, 문서화까지 하나의 사이클을 경험하며 확실히 시야가 넓어진 느낌이다. 다음 주는 이 흐름을 잘 이어가되, 설계나 구현을 조금 더 스마트하게, 반복을 줄이면서도 완성도 있게 다듬어보는 걸 목표로 삼고 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 백엔드 1주차 회고(WIL)]]></title>
            <link>https://velog.io/@thedev_junyoung/hanghae-1wd-WIL</link>
            <guid>https://velog.io/@thedev_junyoung/hanghae-1wd-WIL</guid>
            <pubDate>Fri, 28 Mar 2025 01:49:50 GMT</pubDate>
            <description><![CDATA[<p>항해99 첫 주차는 생각보다 빠르게 지나갔다.<br>월요일부터 본격적으로 과제를 시작했는데,   선약으로 일요일은 아무것도 못 했고, 멘토링과 Q&amp;A 등 공식 일정도 많아 실제로 과제에 집중할 수 있는 시간은 부족하게 느껴졌다.<br>결국 제출 전날 새벽까지 코드를 붙잡고 있는 내 모습을 보면서, &quot;역시 쉽지 않다&quot;는 생각이 들었다.
하지만 힘들었던 만큼 <strong>얻은 것도 컸다.</strong></p>
<hr>
<p>가장 좋았던 건, <strong>멘토링과 코치님들의 피드백</strong>이었다.<br>다른 분들의 질문을 들으며 내가 놓친 시각을 얻을 수 있었고, 코치님들의 설명은 단순한 이론이 아니라 <strong>실제 경험을 기반으로 한 살아있는 조언</strong>이었다.  덕분에 과제의 방향성이 명확해졌고, 개념 하나하나가 <strong>현실적인 감각으로 다가왔다.</strong>
예를 들어 TDD라는 개념도 처음엔 &quot;테스트를 먼저 쓴다&quot;는 정도로만 이해했지만, 막상 해보니 더 중요한 건 <strong>설계의 흐름</strong>이었다.<br>“이 로직은 어느 계층에 있어야 하지?”,<br>“이 책임은 도메인이 가져가야 할까, 서비스에서 처리해야 할까?”<br>테스트를 작성하다 보면 자연스럽게 <strong>객체지향 설계에 대한 고민</strong>으로 이어졌다.   그리고 그게 바로 TDD의 핵심이라는 걸 깨달을 수 있었다.</p>
<hr>
<p>이번 과제는 DB 없이 in-memory 환경에서 동작하는 구조였고, 여러 요청이 동시에 들어올 때 <strong>Race Condition</strong>이 발생할 수 있도록 설계되어 있었다. 지금까지 실무에서는 DB 트랜잭션으로 대부분의 동시성 문제를 넘겨왔기 때문에, <strong>직접 애플리케이션 레벨에서 동시성 문제를 해결해보는 건 처음이었다.</strong></p>
<p>결과가 들쭉날쭉하고, 로그를 추적하면서 문제의 원인을 파악하고,   락을 걸어 순서를 제어하는 과정에서<br><code>ReentrantLock</code>, <code>synchronized</code>, <code>AtomicLong</code> 등 자바의 동기화 도구들을 하나씩 찾아보고 적용했다.<br>그리고 이걸 공부하다 보니 자연스럽게 운영체제 수업에서만 보던 <strong>프로세스 동기화</strong> 개념들까지 연결되기 시작했다.</p>
<hr>
<p>무엇보다 좋았던 건, 이렇게 문제를 해결하면서  <strong>지금까지 막연하게 공부해왔던 CS 개념들이 실제로 내 코드와 연결되는 걸 경험했다는 점</strong>이다.
그동안 『컴퓨터 밑바닥의 비밀』 같은 책이나 OS 강의를 보면서 “개발자라면 알아야지”라는 생각으로 억지로 공부했지만, 항상 “이걸 도대체 어디에 써먹지?”라는 생각이 들었다. 
그런데 이번 주, <strong>실제 문제를 겪고 나니 그 개념들이 정말 필요하다는 걸 처음으로 체감했다.</strong></p>
<p>문제를 해결하다 보니 <strong>처음 듣는 개념들과 알고리즘들이 튀어나오기 시작했다.</strong><br><code>Dining Philosophers Problem</code>, <code>Readers-Writers Problem</code>, <code>Producer-Consumer Problem</code>…
처음엔 이름조차 생소했지만, 하나하나 찾아보면서  <strong>“이런 문제들이 실제 동시성 제어에서 어떤 역할을 하는가”</strong>를 이해하게 됐다.</p>
<ul>
<li><code>Dining Philosophers Problem</code>은 자원을 점유하면서 생길 수 있는 <strong>교착 상태(Deadlock)</strong> 를 설명하는 문제였고,  </li>
<li><code>Readers-Writers Problem</code>은 <strong>읽기와 쓰기 작업의 동시성 제어</strong>에 대한 접근 방식을 고민하게 만들었고,  </li>
<li><code>Producer-Consumer Problem</code>은 <strong>작업 순서 보장과 자원 공유</strong>의 어려움을 보여주는 사례였다.</li>
</ul>
<p>지금 내가 마주한 문제들이 이들과 완전히 같진 않지만, <strong>동시성을 안전하게 제어하기 위한 고민이라는 본질에서 깊이 연결</strong>된다는 걸 느낄 수 있었다.
그래서 자연스럽게 구글링도 더 깊이 하게 됐다. 단순히 테스트를 통과하기 위해서가 아니라 단순히 외우는 공부와는 차원이 다른 몰입감을 느꼈고, <strong>“아, 이게 진짜 내 공부구나”</strong> 라는 생각이 처음으로 들었다.
문제 해결 시 봤던 docs 는 다음과 같다. </p>
<ul>
<li><a href="https://docs.oracle.com/javase/tutorial/essential/concurrency/index.html">Java Concurrency - Oracle Docs</a>  </li>
<li><a href="https://docs.oracle.com/javase/tutorial/essential/concurrency/newlocks.html">Java Locks API</a>  </li>
<li><a href="https://www.geeksforgeeks.org/dining-philosopher-problem-using-semaphores/">Dining Philosophers Problem</a>  </li>
<li><a href="https://www.geeksforgeeks.org/readers-writers-problem-set-1-introduction-and-readers-preference-solution/">Readers-Writers Problem - Readers Preference</a>  </li>
<li><a href="https://www.baeldung.com/java-volatile">Java Volatile Keyword - Baeldung</a></li>
</ul>
<hr>
<p>한 가지 아쉬운 점이 있다면,<br>시간 관리를 잘 하지 못해 막판에 급하게 마무리하게 된 부분이다.<br>다음 주부터는 <strong>과제 요구사항을 더 빠르게 파악하고, 설계 흐름부터 먼저 잡는 방식으로 시간을 써보려 한다.</strong></p>
<p>그리고 궁금한 개념은 넘기지 말고, 짧게라도 정리하고 넘어가자. 멘토링이나 Q&amp;A를 듣고 나면   <strong>“그래, 맞아” 하고 끝나는 게 아니라, “이걸 내 코드에 적용하면 어떻게 될까?” 까지 고민해보는 게 진짜 내 공부</strong>라는 걸 느꼈으니까!</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해를 시작하기에 앞서..]]></title>
            <link>https://velog.io/@thedev_junyoung/%ED%95%AD%ED%95%B4%EB%A5%BC-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0%EC%97%90-%EC%95%9E%EC%84%9C</link>
            <guid>https://velog.io/@thedev_junyoung/%ED%95%AD%ED%95%B4%EB%A5%BC-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0%EC%97%90-%EC%95%9E%EC%84%9C</guid>
            <pubDate>Sat, 22 Mar 2025 06:11:21 GMT</pubDate>
            <description><![CDATA[<h2 id="현재까지의-나의-개발이야기">현재까지의 나의 개발이야기</h2>
<p>현재 회사에서는 법률 도메인을 기반으로 사용자에게 &#39;법률 서비스&#39;를 편리하게 제공해주기 위한 플랫폼을 개발하고 있다. 
사수 없이 처음부터 백엔드와 인프라구성을 도맡아서 서비스를 만들었다. 
이제 막 런칭되어, 사용자가 많이 없는 부분이 아쉽고 서비스를 만들고 나니 &#39;내가 작성한 코드가 규모있는 서비스에서 트래픽을 감당할 수 있을까?&#39;, 빠른 출시로 인해 MVP 개발 할 당시 TDD를 알고 있었지만, 그떄 당시에는 &#39;제품을 만드는 것&#39; 에만 집중해서 결국 만들고 운영을 할 수 있을 정도의 서버를 개발했다. </p>
<h2 id="이게-최선인가-고민이-시작됐다">이게 최선인가? 고민이 시작됐다</h2>
<p>곰곰히 생각해보니, &#39;내가 작성한 코드가 최선인가?&#39;, &#39;사용자 유입이 급증한다면 서버는 안정적으로 실행 할 수 있을까?&#39; 라는 고민에 빠지게 됐다. 
사수나 CTO가 없었고, 공식문서와 구글링 그리고 chatgpt 의 도움을 받아 개발을 한건데, 다른 개발자들은 요구사항 &amp; 기획이 주어졌을 떄, 어떤 논리로 접근하고 기술을 선정해서 문제를 해결하는지 궁금해졌다. </p>
<h2 id="그래서-성장을-위해서-나는">그래서 성장을 위해서 나는?</h2>
<p>개발을 시작할 땐, 어떤 것이든 &#39;구현만 하면된다&#39; 라는 생각으로 개발을 했지만, 현업에서 경험해보고 여러 개발자들과 소통하다보니까, 좋은 개발자는 단순 &#39;구현&#39; 만이 아닌 &#39;CS를 기반한 깊이있는 문제 해결&#39;을 동반한 개발해야 된다고 깨달았다. 
우연히 항해플러스에 내가 부족하다고 생각하는 부분, 현업에서 요구하는 부분이 매우 유사하다고 판단되어 나는 한 단계 성장을 위해 &#39;항해 플러스 백엔드&#39; 과정에 참여하게 됐다. </p>
<h2 id="과정이-끝나고나서의-내모습">과정이 끝나고나서의 내모습</h2>
<p>이 과정이 끝나면 나는 약 1년 6개월의 경력이 있는 개발자가 된다. 
하지만 이 과정으로 인해 TDD, Redis&amp;Kafka 를 사용한 대규모 처리 등 능숙하진 않을 지라도 어떻게 접근하고, 생각해서 문제를 해결해야하는지 확실히 알고 있는 개발자가 되어있을거라고 기대한다. </p>
<p>따라서 나의 목표는 블랙배지다. 가능할까? 미지수다. 왜냐하면 커리큘럼에 있는 내용들의 절반이상, 거의 모르는 상태이다. 
하지만 지금까지 해왔던 것 보다 조금은 더 이 과정에 몰입한다면 못할 건 없다고 생각한다. </p>
<h2 id="마지막으로">마지막으로</h2>
<p>이 과정에 임하면서 마인드셋하는 키워드는 
#몰입 #네트워킹
이렇게 두가지가 떠오른다. 
몰입하며 한주의 과제에 집중하고 해결해서 다른 개발자들과 소통을 통해 내가 부족한 게 무엇인지, 배울게 무엇인지 파악해서 성장하자. </p>
<blockquote>
<p><strong>Stay Hungry, Stay foolish</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring][FastAPI] LLM 챗봇 비용 절감: RedisSearch + pgvector 활용한 최적의 캐싱]]></title>
            <link>https://velog.io/@thedev_junyoung/SpringFastAPI-LLM-%EC%B1%97%EB%B4%87-%EB%B9%84%EC%9A%A9-%EC%A0%88%EA%B0%90-RedisSearch-pgvector-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%B5%9C%EC%A0%81%EC%9D%98-%EC%BA%90%EC%8B%B1</link>
            <guid>https://velog.io/@thedev_junyoung/SpringFastAPI-LLM-%EC%B1%97%EB%B4%87-%EB%B9%84%EC%9A%A9-%EC%A0%88%EA%B0%90-RedisSearch-pgvector-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%B5%9C%EC%A0%81%EC%9D%98-%EC%BA%90%EC%8B%B1</guid>
            <pubDate>Thu, 13 Mar 2025 05:49:50 GMT</pubDate>
            <description><![CDATA[<h3 id="인트로-llm-챗봇의-비용-문제와-캐싱-도입"><strong>인트로: LLM 챗봇의 비용 문제와 캐싱 도입</strong></h3>
<p>우리 서비스에는 LLM을 활용한 법률 챗봇이 있다.</p>
<p>법률 도메인이라는 특성상 질문 유형이 어느 정도 한정적일 것이라 예상했지만, 실제 운영 데이터를 확인해보니 <strong>유사한 질문과 답변이 중복으로 여러 개 저장되고 있었고</strong> 이로 인해 LLM 호출 비용도 꾸준히 증가하고 있었다.</p>
<p>초기 MVP 단계에서는 매번 LLM을 호출해 답변을 생성하는 방식이었지만,</p>
<p><strong>사용자 유입이 증가할 경우</strong> 비용 부담이 커지고 응답 속도도 느려질 것이 명확했다.</p>
<p>따라서, <strong>불필요한 LLM 호출을 줄이고 빠른 응답을 제공하기 위해 &quot;캐싱&quot;을 도입하기로 결정했다.</strong></p>
<p>단순한 문자열 기반의 캐싱이 아니라, <strong>질문을 벡터화하여 의미적으로 유사한 질문을 빠르게 찾아주는 방식</strong>을 선택했다.</p>
<hr>
<h2 id="구현-방법-벡터db--redissearch-기반의-다단계-검색"><strong>구현 방법: 벡터DB + RedisSearch 기반의 다단계 검색</strong></h2>
<p>이 문제를 해결하기 위해 다음과 같은 검색 전략을 설계했다.</p>
<h3 id="1단계-ai-캐싱-redissearch-활용"><strong>1단계: AI 캐싱 (RedisSearch 활용)</strong></h3>
<ul>
<li>사용자가 24시간 내 동일하거나 유사한 질문을 입력한 경우, <strong>RedisSearch에서 즉시 검색하여 캐싱된 값을 반환</strong>한다.</li>
<li><strong>임베딩 벡터를 기반으로 벡터 유사도 검색</strong>을 수행하여 비슷한 질문이 있다면 빠르게 응답.</li>
<li><strong>사용한 기술</strong>: Redis + RediSearch + mxbai-embed-large (임베딩 모델)</li>
</ul>
<h3 id="2단계-rdbms-검색-postgresql--pgvector"><strong>2단계: RDBMS 검색 (PostgreSQL + pgvector)</strong></h3>
<ul>
<li>Redis에 캐싱된 값이 없을 경우, <strong>PostgreSQL의 pgvector 확장을 활용하여 저장된 임베딩 벡터와 비교</strong>한다.</li>
<li><strong>HNSW(탐색 최적화) 기반의 벡터 검색을 활용</strong>하여 LLM 호출 없이 기존 데이터를 찾아 응답.</li>
<li><strong>사용한 기술</strong>: PostgreSQL + pgvector + HNSW Index</li>
</ul>
<h3 id="3단계-llm-호출"><strong>3단계: LLM 호출</strong></h3>
<ul>
<li><strong>Redis와 RDBMS에서 모두 유사한 질문을 찾지 못한 경우</strong>에만 <strong>LLM을 호출</strong>한다.</li>
<li>이때 새롭게 생성된 질문과 답변은 <strong>임베딩하여 Redis &amp; PostgreSQL에 저장</strong>하여, 다음 번에는 캐싱될 수 있도록 한다.</li>
<li><strong>사용한 기술</strong>: OpenAI API, Llama-3 기반 모델, FastAPI 서버</li>
</ul>
<hr>
<h2 id="기술-선택의-이유"><strong>기술 선택의 이유</strong></h2>
<p>우리는 단순한 문자열 기반 캐싱이 아니라, 의미 기반 검색을 수행해야 했다.</p>
<p>이를 위해 <strong>임베딩 벡터를 활용한 벡터DB 기반 캐싱</strong>을 설계했다.</p>
<h3 id="왜-redissearch를-사용했는가"><strong>왜 RedisSearch를 사용했는가?</strong></h3>
<ol>
<li><strong>빠른 검색 속도</strong> – 인메모리 캐싱이므로 매우 빠르게 응답 가능</li>
<li><strong>간단한 구축 가능</strong> – 대규모 벡터 스토어 대비 운영 부담이 적음</li>
<li><strong>RediSearch의 KNN(Nearest Neighbor) 검색 기능 지원</strong></li>
</ol>
<hr>
<h3 id="왜-postgresql--pgvector를-사용했는가"><strong>왜 PostgreSQL + pgvector를 사용했는가?</strong></h3>
<ol>
<li><strong>우리 서비스의 규모상 대형 벡터DB (FAISS, Pinecone 등)까지는 필요 없음</strong></li>
<li><strong>PostgreSQL의 pgvector 확장은 유지보수 부담 없이 쉽게 사용 가능</strong></li>
<li><strong>HNSW 기반 인덱스 최적화 적용 가능</strong></li>
</ol>
<hr>
<h3 id="왜-mxbai-embed-large-모델을-선택했는가"><strong>왜 <code>mxbai-embed-large</code> 모델을 선택했는가?</strong></h3>
<p>✔️ 초기에는 <code>nomic-embed-text</code>와 비교 테스트를 진행했으나,
✔️ 법률 도메인 특성상 <strong>보다 정확한 유사도 검색</strong>이 필요하여 <code>mxbai-embed-large</code> 모델을 선택했다.
✔️ 또한, 유사도 임계치를 0.9로 높게 설정하여 <strong>정확도가 높은 결과만 반환하도록 최적화</strong>했다.</p>
<hr>
<h2 id="-구현-과정">** 구현 과정**</h2>
<h3 id="-1-redissearch를-활용한-벡터-캐싱">** 1. RedisSearch를 활용한 벡터 캐싱**</h3>
<pre><code class="language-java">// 질문을 임베딩하여 Redis에 저장
public void saveAiChat(String query, String response) {
    String normalizedQuery = normalizeTextForRedis(query);
    String queryHash = hashQuery(query);

    List&lt;Double&gt; embedding = generateEmbeddingAsList(normalizedQuery);
    byte[] queryVectorBinary = convertToFloat32Binary(embedding);
    String base64Vector = Base64.getEncoder().encodeToString(queryVectorBinary);

    String key = &quot;ai_chat:&quot; + System.currentTimeMillis();
    Map&lt;String, String&gt; chatData = new HashMap&lt;&gt;();
    chatData.put(&quot;query&quot;, normalizedQuery);
    chatData.put(&quot;query_hash&quot;, queryHash);
    chatData.put(&quot;response&quot;, response);
    chatData.put(&quot;query_vector&quot;, base64Vector);

    redisModulesCommands.hset(key, chatData);
    redisModulesCommands.expire(key, 86400); // 24시간 TTL 설정
}</code></pre>
<p><strong>✔ 주요 포인트</strong></p>
<p>✔️ 질문을 임베딩하여 Redis에 저장
✔️ <code>query_vector</code> 필드를 활용한 벡터 기반 검색
✔️ MD5 해시 기반의 빠른 문자열 매칭 검색도 함께 적용</p>
<hr>
<h3 id="2-postgresql--pgvector-기반-rdbms-검색"><strong>2. PostgreSQL + pgvector 기반 RDBMS 검색</strong></h3>
<pre><code class="language-sql">CREATE TABLE llm_chat_result (
    id SERIAL PRIMARY KEY,
    query TEXT,
    response TEXT,
    query_embedding VECTOR(1024) -- 벡터 저장
);

CREATE INDEX ON llm_chat_result USING hnsw (query_embedding vector_l2_ops);</code></pre>
<p><strong>✔ 주요 포인트</strong></p>
<p>✔️ <code>gvector</code> 확장을 활용하여 벡터 저장
✔️ <code>HNSW</code> 인덱스를 적용하여 검색 최적화
✔️ 벡터 유사도 검색을 통해 기존 질문 재활용</p>
<hr>
<h3 id="3-전체-검색-흐름"><strong>3. 전체 검색 흐름</strong></h3>
<pre><code class="language-java">public String findAiChat(String query) {
    // 1. RedisSearch에서 검색
    Optional&lt;Map&lt;String, String&gt;&gt; redisResult = findSimilarByVector(query);
    if (redisResult.isPresent()) {
        return redisResult.get().get(&quot;response&quot;);
    }

    // 2. PostgreSQL에서 검색
    Optional&lt;String&gt; dbResult = findInPostgres(query);
    if (dbResult.isPresent()) {
        return dbResult.get();
    }

    // 3. LLM 호출
    return callLlmAndCache(query);
}</code></pre>
<hr>
<h2 id="결과-최적화-효과"><strong>결과: 최적화 효과</strong></h2>
<p><img src="https://velog.velcdn.com/images/thedev_junyoung/post/515e2b0e-08dd-4fea-8336-624a069c6511/image.png" alt=""></p>
<p><strong>결론:</strong></p>
<p>✔️ <strong>즉시 응답 가능</strong> (Redis 캐싱 덕분에 100ms 내 응답)
✔️ <strong>비용 절감</strong> (반복되는 질문에 대한 LLM 호출 방지)
✔️ <strong>확장성 강화</strong> (pgvector를 활용한 유사 질문 검색 최적화)</p>
<hr>
<h2 id="느낀-점--개선-방향"><strong>느낀 점 &amp; 개선 방향</strong></h2>
<p>✔️ <strong>법률 도메인 특성상 질문이 유사하게 반복되므로, 벡터 기반 캐싱이 효과적</strong>
✔️ <strong>임베딩 모델을 적절히 선택하는 것이 중요</strong> (<code>mxbai-embed-large</code> vs <code>nomic-embed-text</code>)
✔️ <strong>향후 FAISS/Pinecone 같은 대형 벡터 스토어 도입을 고려할 수 있음</strong></p>
<hr>
<h2 id="마무리"><strong>마무리</strong></h2>
<p>이번 개선 작업을 통해,</p>
<p>LLM 챗봇의 비용을 절감하고, 응답 속도를 획기적으로 줄이는 데 성공했다.</p>
<p>이제 남은 과제는 <strong>더 정밀한 임베딩 모델 선택과, 벡터DB의 확장성 테스트</strong>가 될 것이다.</p>
<p><img src="https://velog.velcdn.com/images/thedev_junyoung/post/f7bcd146-d1f0-446b-8bda-6f9ec0e3ea6c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/thedev_junyoung/post/ede5d807-2b4f-4a7a-99f6-934542964727/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/thedev_junyoung/post/59e9f7fc-0ab9-4e49-b5b4-a5179185ee6b/image.png" alt=""></p>
<p>(아니 벨로그 마크다운 왜이래.... 이모티콘을 쓰게 만드네...)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring][FastAPI]FastAPI에서 Spring으로 마이그레이션하며 배운 점]]></title>
            <link>https://velog.io/@thedev_junyoung/SpringFastAPIFastAPI%EC%97%90%EC%84%9C-Spring%EC%9C%BC%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%98%EB%A9%B0-%EB%B0%B0%EC%9A%B4-%EC%A0%90</link>
            <guid>https://velog.io/@thedev_junyoung/SpringFastAPIFastAPI%EC%97%90%EC%84%9C-Spring%EC%9C%BC%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%98%EB%A9%B0-%EB%B0%B0%EC%9A%B4-%EC%A0%90</guid>
            <pubDate>Fri, 07 Mar 2025 02:13:51 GMT</pubDate>
            <description><![CDATA[<h2 id="1-fastapi를-선택했던-이유">1. FastAPI를 선택했던 이유</h2>
<p>FastAPI는 가볍고 빠르게 API를 개발할 수 있는 프레임워크다. 비동기 I/O를 쉽게 지원하며, Pydantic을 활용한 데이터 검증과 직렬화가 편리하다. 하지만 내가 FastAPI를 사용했던 이유는 비동기 처리 때문이 아니라 <strong>단순히 사용하기 편하고 경량화된 프레임워크였기 때문</strong>이었다.</p>
<p>FastAPI를 사용하면서 API 개발 자체는 간결하고 효율적이었지만, 점점 기능이 확장되면서 더 복잡한 요구사항을 처리해야 했다. 그 과정에서 <strong>Spring으로 마이그레이션할 필요성</strong>이 생겼다.</p>
<h2 id="2-spring으로-마이그레이션하며-겪은-어려움">2. Spring으로 마이그레이션하며 겪은 어려움</h2>
<p>Spring은 개발자에게 많은 기능을 제공해주는 편리한 프레임워크지만, 처음 실무에서 접했을 때 여러 가지 어려움이 있었다. 특히 <strong>JPA 연관관계 설정과 자동 구성(Auto Configuration), 상속 관계에서 발생하는 문제</strong>에서 많은 시행착오를 겪었다.</p>
<h3 id="21-jpa-성능-최적화-fetch-join-vs-네이티브-쿼리-페이징-충돌-문제-해결">2.1 JPA 성능 최적화: Fetch Join vs 네이티브 쿼리, 페이징 충돌 문제 해결</h3>
<p>FastAPI에서는 SQLAlchemy를 사용하여 데이터 조회 최적화를 진행했지만, Spring에서는 JPA(Hibernate)를 사용하면서 여러 성능 이슈가 발생했다. 특히 <strong>연관관계를 맺은 엔티티를 조회할 때 불필요한 조인과 다량의 쿼리가 실행되는 문제</strong>를 해결하는 과정에서 중요한 경험을 얻었다.</p>
<h3 id="211-문제-상황">2.1.1 문제 상황</h3>
<ul>
<li><strong>Fetch Join의 장점과 한계</strong><ul>
<li>연관된 엔티티를 한 번의 쿼리로 가져올 수 있어 N+1 문제를 해결할 수 있음.</li>
<li>하지만 <strong>컬렉션을 포함한 Fetch Join은 페이징과 충돌</strong>하는 문제가 있음.</li>
<li><code>LIMIT</code>이나 <code>OFFSET</code>을 사용할 경우 <strong>데이터베이스 내부적으로 모든 데이터를 조회한 후 페이징을 적용</strong>하기 때문에 성능 저하 발생.</li>
</ul>
</li>
<li><strong>네이티브 쿼리 활용의 필요성</strong><ul>
<li>특정 경우에는 Fetch Join 대신 <strong>네이티브 쿼리를 활용하는 것이 성능적으로 더 유리</strong>했음.</li>
<li>복잡한 다중 조인 시, JPA의 JPQL보다 <strong>직접 SQL을 작성하여 최적화하는 것이 더 효과적</strong>.</li>
<li>인덱스 힌트 및 서브쿼리를 활용해 불필요한 조인을 제거.</li>
</ul>
</li>
</ul>
<h3 id="212-해결-방법">2.1.2 해결 방법</h3>
<h4 id="case-1-단순한-1n-조회-→-fetch-join-사용"><strong>Case 1: 단순한 1:N 조회 → Fetch Join 사용</strong></h4>
<ul>
<li><code>@EntityGraph</code> 또는 <code>JOIN FETCH</code>를 사용하여 단일 쿼리로 데이터를 가져옴.</li>
<li>ex) <code>SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id</code></li>
<li>Fetch Join은 데이터가 적고 페이징이 필요 없는 경우 가장 효과적.</li>
</ul>
<h4 id="case-2-다대다-관계--페이징-→-네이티브-쿼리-적용"><strong>Case 2: 다대다 관계 + 페이징 → 네이티브 쿼리 적용</strong></h4>
<ul>
<li>JPA의 <code>createNativeQuery</code>를 활용하여 페이징 쿼리를 직접 작성.</li>
<li>ex) <code>SELECT * FROM orders o JOIN users u ON o.user_id = u.id WHERE u.status = &#39;ACTIVE&#39; LIMIT 10 OFFSET 0</code></li>
<li>JPA의 Fetch Join을 사용하면 <strong>불필요한 모든 데이터를 조회한 후 페이징이 적용</strong>되므로, 네이티브 SQL을 사용하여 성능 개선.</li>
</ul>
<h4 id="case-3-대량-데이터-조회-최적화-→-dto-projection-적용"><strong>Case 3: 대량 데이터 조회 최적화 → DTO Projection 적용</strong></h4>
<ul>
<li>엔티티를 직접 조회하는 것이 아니라, <strong>JPQL을 활용한 DTO 매핑을 사용</strong>하여 불필요한 필드 조회를 줄임.</li>
<li>ex) <code>SELECT new com.example.dto.UserOrderDTO(u.id, u.name, o.id, o.totalPrice) FROM User u JOIN u.orders o WHERE u.status = &#39;ACTIVE&#39;</code></li>
<li>필요하지 않은 연관관계까지 불러오는 JPA 기본 동작을 차단하여 성능 최적화.</li>
</ul>
<h3 id="213-성능-개선-결과">2.1.3 성능 개선 결과</h3>
<ul>
<li><strong>Fetch Join으로 N+1 문제를 해결한 경우</strong><ul>
<li>기존: API 호출 시 평균 500ms → <strong>개선 후 120ms</strong>로 단축 (약 4배 속도 개선)</li>
<li>원인: 개별 SQL 호출이 많았던 문제 해결</li>
</ul>
</li>
<li><strong>네이티브 쿼리 적용 후 페이징 최적화한 경우</strong><ul>
<li>기존: 페이징 요청 시 평균 900ms → <strong>개선 후 250ms</strong>로 단축 (약 3.6배 개선)</li>
<li>원인: Fetch Join으로 인해 전체 데이터가 로딩된 후 페이징 적용되는 비효율적 동작을 네이티브 SQL로 변경하여 해결</li>
</ul>
</li>
<li><strong>DTO Projection을 적용한 경우</strong><ul>
<li>기존: 엔티티 조회 후 불필요한 필드 포함하여 전송 (800ms) → <strong>필요한 필드만 조회 후 300ms</strong>로 단축</li>
<li>원인: 불필요한 필드까지 로드되면서 쿼리 최적화가 되지 않던 문제 해결</li>
</ul>
</li>
</ul>
<p>이 과정을 통해 <strong>JPA의 기본 동작을 그대로 따르는 것이 항상 최적은 아니며, 상황에 맞게 Fetch Join, 네이티브 쿼리, DTO Projection을 적절히 활용해야 한다는 점을 체감했다</strong>.</p>
<h3 id="214-cqrs-패턴-적용">2.1.4 CQRS 패턴 적용</h3>
<p>이 과정에서 <strong>CQRS(Command Query Responsibility Segregation) 패턴</strong>을 알게 되었고, <strong>읽기와 쓰기를 분리하는 방식이 성능 최적화에 적절하다는 점을 체감</strong>했다.</p>
<h3 id="cqrs-패턴이란">CQRS 패턴이란?</h3>
<p>CQRS(Command Query Responsibility Segregation)은 <strong>읽기(쿼리)와 쓰기(커맨드)의 책임을 분리하는 아키텍처 패턴</strong>이다. 단일 데이터 모델을 사용하여 읽기와 쓰기를 모두 수행하는 전통적인 방식과 달리, <strong>읽기 전용 모델과 쓰기 전용 모델을 분리함으로써 성능 최적화와 확장성을 높일 수 있다</strong>. 이 패턴을 적용하면서, 복잡한 조회는 별도의 최적화된 쿼리를 활용하고, 쓰기 연산은 도메인 로직을 통해 일관성을 유지하는 접근 방식을 고려했다.</p>
<h3 id="22-상속-관계에서의-discriminator-columndtype-문제">2.2 상속 관계에서의 Discriminator Column(DTYPE) 문제</h3>
<p>JPA에서 <code>@Inheritance(strategy = InheritanceType.JOINED)</code>을 사용하여 상속 관계를 정의할 때, <code>@DiscriminatorColumn</code>을 통해 DTYPE을 명시적으로 관리했다. 그러나, <strong>필드 셰도윙(Field Shadowing) 문제로 인해 데이터가 null이 되는 문제가 발생</strong>했다.</p>
<h3 id="221-문제-상황">2.2.1. 문제 상황</h3>
<ul>
<li>부모 클래스와 자식 클래스에서 같은 이름의 변수를 정의하면, JPA가 올바르게 매핑하지 못하는 현상이 발생.</li>
<li>이를 통해 특정 필드 값이 null로 저장되거나 조회할 때 잘못된 데이터가 반환되는 이슈가 발생.</li>
<li>특히, <code>@MappedSuperclass</code>를 사용하지 않고 <strong>SINGLE_TABLE 전략을 사용할 경우 DTYPE이 예상과 다르게 동작할 가능성이 있음</strong>.</li>
</ul>
<h3 id="222-해결-방법">2.2.2. 해결 방법</h3>
<ul>
<li><strong>부모 클래스와 자식 클래스 간의 중복 필드 제거</strong>.</li>
<li>필요한 경우 <strong>DTO 변환을 통해 명확하게 데이터 매핑을 수행</strong>.</li>
<li><strong>JOINED 전략을 사용했지만, 필드 셰도윙 문제로 인해 데이터 정합성을 유지하기 어려운 경우가 발생할 수 있음. 이를 방지하기 위해 부모 클래스와 자식 클래스 간의 필드 중복을 최소화하고, 명확한 데이터 매핑을 수행해야 함.</strong>.</li>
</ul>
<p>이 과정에서 <strong>JPA의 동작 방식과 상속 매핑 전략을 더욱 깊이 이해하게 되었으며, ORM을 사용할 때 발생할 수 있는 예기치 않은 문제들에 대한 대응력을 키울 수 있었다</strong>.</p>
<h3 id="23-자동-구성auto-configuration과-제어의-역전ioc">2.3 자동 구성(Auto Configuration)과 제어의 역전(IoC)</h3>
<p>Spring은 다양한 자동 구성을 제공하여 개발자가 직접 설정하지 않아도 편리하게 사용할 수 있도록 지원한다. 하지만 이러한 자동 구성은 내부 동작을 정확히 이해하지 못하면 오히려 장애를 유발할 수 있다.</p>
<h3 id="231-문제-상황">2.3.1. 문제 상황</h3>
<ul>
<li>Spring Boot가 제공하는 <strong>ObjectMapper, Jackson 등의 자동 설정을 잘 모르고 사용하면 디버깅이 어려운 문제</strong>가 발생.</li>
<li>특정 설정을 덮어씌우는 바람에 예상과 다른 동작이 나오는 경우가 있었음.</li>
<li>디버깅 과정에서 IoC(제어의 역전, Inversion of Control)를 이해해야 했음.</li>
</ul>
<h3 id="232-해결-방법">2.3.2. 해결 방법</h3>
<ul>
<li><strong>자동 설정이 어떻게 동작하는지 명확히 이해</strong>하고, 필요한 경우 수동으로 설정 값을 지정.</li>
<li><code>@ConfigurationProperties</code>, <code>@Bean</code> 등을 활용하여 원하는 설정을 명확히 정의.</li>
<li><code>application.yml</code>에서 설정 값을 명확히 관리하여 불필요한 오버라이딩을 방지.</li>
</ul>
<p>이 과정에서 <strong>제어의 역전(IoC)이란 무엇인지 더 깊이 이해하게 되었고</strong>, Spring이 많은 것을 자동으로 해주지만 결국 <strong>개발자가 도구를 정확히 이해하고 사용해야 한다는 점을 배웠다</strong>.</p>
<h2 id="3-마이그레이션을-통해-배운-점">3. 마이그레이션을 통해 배운 점</h2>
<p>Spring으로 마이그레이션하면서 가장 크게 배운 점은 다음과 같다.</p>
<h3 id="31-fastapi는-비동기-io-때문이-아니라-경량화된-구조가-좋아서-선택했지만-대규모-서비스에서는-spring의-강력한-지원이-필요했다">3.1. FastAPI는 비동기 I/O 때문이 아니라 경량화된 구조가 좋아서 선택했지만, 대규모 서비스에서는 Spring의 강력한 지원이 필요했다.</h3>
<h3 id="32-jpa-연관관계를-잘못-설정하면-성능이-심각하게-저하될-수-있으며-이를-해결하기-위해-cqrs-패턴과-네이티브-쿼리를-활용하는-것이-효과적이었다">3.2. JPA 연관관계를 잘못 설정하면 성능이 심각하게 저하될 수 있으며, 이를 해결하기 위해 CQRS 패턴과 네이티브 쿼리를 활용하는 것이 효과적이었다.</h3>
<h3 id="33-상속-관계에서-discriminator-column을-활용할-때-필드-셰도윙-문제를-방지하기-위해-데이터-매핑을-명확하게-해야-한다">3.3. 상속 관계에서 Discriminator Column을 활용할 때, <strong>필드 셰도윙 문제를 방지하기 위해 데이터 매핑을 명확하게 해야 한다</strong>.</h3>
<h3 id="34-spring의-자동-구성은-개발을-편리하게-해주지만-내부-동작을-모르면-예상치-못한-문제가-발생할-수-있다">3.4. Spring의 자동 구성은 개발을 편리하게 해주지만, 내부 동작을 모르면 예상치 못한 문제가 발생할 수 있다.</h3>
<h3 id="35-제어의-역전ioc-개념을-체감하면서-프레임워크가-자동으로-처리해주는-것들을-정확히-이해하고-활용해야-한다는-점을-깨달았다">3.5. 제어의 역전(IoC) 개념을 체감하면서, 프레임워크가 자동으로 처리해주는 것들을 정확히 이해하고 활용해야 한다는 점을 깨달았다.</h3>
<h2 id="4-결론">4. 결론</h2>
<p>FastAPI와 Spring은 각각 장단점이 있으며, 특정 기술을 선택할 때는 <strong>그 기술이 필요한 이유를 정확히 이해하는 것이 중요하다</strong>. FastAPI는 빠른 개발과 간결한 구조가 강점이었지만, 복잡한 비즈니스 로직을 다루고 대규모 트래픽을 처리하는 데는 Spring이 더 적합했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] PostgreSQL tsvector 트러블슈팅]]></title>
            <link>https://velog.io/@thedev_junyoung/Spring-PostgreSQL-tsvector-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@thedev_junyoung/Spring-PostgreSQL-tsvector-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Tue, 31 Dec 2024 01:27:38 GMT</pubDate>
            <description><![CDATA[<h2 id="1-문제-상황">1. 문제 상황</h2>
<p>FastAPI에서 Spring으로 마이그레이션하던 중 다음과 같은 에러가 발생했다:</p>
<pre><code class="language-sql">
2024-12-31 10:08:03 WARN [http-nio-127.0.0.1-8000-exec-2] o.h.e.jdbc.spi.SqlExceptionHelper - SQL Error: 0, SQLState: 42804
2024-12-31 10:08:03 ERROR [http-nio-127.0.0.1-8000-exec-2] o.h.e.jdbc.spi.SqlExceptionHelper - ERROR: column &quot;search_vector&quot; is of type tsvector but expression is of type character varying
Hint: You will need to rewrite or cast the expression.
</code></pre>
<p>이 에러는 PostgreSQL의 <strong>tsvector</strong> 타입 컬럼(<strong>search_vector</strong>)에 <strong>character varying</strong> 타입 데이터를 직접 삽입하려 했기 때문에 발생했다. 
기존 FastAPI 애플리케이션에서는 이러한 문제가 없었으나, Spring으로 마이그레이션하면서 이슈가 드러났다.</p>
<h2 id="2-원인-분석">2. 원인 분석</h2>
<p>PostgreSQL의 <strong>search_vector</strong> 컬럼은 전문 검색(Full Text Search)을 지원하기 위해 <strong>tsvector</strong> 타입으로 설정되어 있다. 
Spring JPA는 엔티티 매핑 시 이를 일반 문자열(<strong>character varying</strong>)로 처리하려고 시도하였고, 타입 불일치로 인해 SQL 오류가 발생했다.</p>
<h2 id="3-해결-방법">3. 해결 방법</h2>
<h3 id="31-애플리케이션에서의-접근-방식">3.1. 애플리케이션에서의 접근 방식</h3>
<p>처음에는 애플리케이션 계층에서 <strong>search_vector</strong> 값을 적절히 변환하거나 삽입을 방지하려는 접근을 고려했다. 
하지만 다음과 같은 이유로 더 적합한 방식은 아니라고 판단했다:</p>
<ul>
<li>애플리케이션 계층에서 변환 로직 추가 시 코드 복잡도가 증가.</li>
<li>데이터베이스에 가까운 로직은 데이터베이스에서 처리하는 것이 유지보수성과 성능 측면에서 더 유리.</li>
</ul>
<h3 id="32-데이터베이스-트리거를-활용한-해결">3.2. 데이터베이스 트리거를 활용한 해결</h3>
<p>PostgreSQL 트리거를 사용하여 <strong>search_vector</strong> 값을 자동으로 업데이트하도록 설정했다. 
이를 통해 애플리케이션 계층에서는 <strong>search_vector</strong> 컬럼을 업데이트하지 않고도 데이터베이스가 알아서 처리하도록 만들었다.</p>
<h2 id="4-트리거-설정">4. 트리거 설정</h2>
<h3 id="41-트리거-함수-정의">4.1. 트리거 함수 정의</h3>
<pre><code class="language-sql">CREATE FUNCTION users_search_vector_trigger() RETURNS trigger AS $$
BEGIN
    NEW.search_vector := to_tsvector(&#39;english&#39;, 
        COALESCE(NEW.username, &#39;&#39;) || &#39; &#39; || 
        COALESCE(NEW.email, &#39;&#39;)
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;</code></pre>
<h3 id="42-트리거-생성">4.2. 트리거 생성</h3>
<pre><code class="language-sql">CREATE TRIGGER users_search_vector_update
    BEFORE INSERT OR UPDATE ON users
    FOR EACH ROW
EXECUTE FUNCTION users_search_vector_trigger();</code></pre>
<p>이 트리거는 <strong>users</strong> 테이블에 데이터가 삽입되거나 업데이트될 때마다 <strong>search_vector</strong> 컬럼을 자동으로 갱신한다.</p>
<h2 id="5-애플리케이션-계층-변경">5. 애플리케이션 계층 변경</h2>
<h3 id="51-search_vector-필드의-업데이트-방지">5.1. <strong>search_vector</strong> 필드의 업데이트 방지</h3>
<p>Spring JPA에서 <strong>search_vector</strong> 컬럼이 애플리케이션에서 수정되지 않도록 아래와 같이 설정한다:</p>
<pre><code class="language-sql">@Column(name = &quot;search_vector&quot;, columnDefinition = &quot;tsvector&quot;, updatable = false)
private String searchVector;
</code></pre>
<h3 id="52-업데이트-로직-수정">5.2. 업데이트 로직 수정</h3>
<pre><code class="language-java">@Transactional
public UserResponse updateUser(int userId, UserUpdate request) {
    User user = userRepository.findById(userId)
    .orElseThrow(() -&gt; new CommonException(ErrorCode.USER_NOT_FOUND));

    if (request.getPassword() != null) {
        request = UserUpdate.builder()
                ...
                ...
    }

    user.update(request);
    return userMapper.mapToDTO(user);
}</code></pre>
<p>이제 <strong>search_vector</strong> 필드는 애플리케이션 코드에서 업데이트되지 않으며, 데이터베이스에서 트리거로 자동 처리된다.</p>
<h2 id="6-이점">6. 이점</h2>
<h3 id="61-데이터-일관성-보장">6.1. 데이터 일관성 보장</h3>
<ul>
<li>모든 데이터 변경이 데이터베이스 계층에서 일관되게 처리된다.</li>
<li>애플리케이션 로직의 실수나 누락으로 인한 문제를 방지한다.</li>
</ul>
<h3 id="62-성능-최적화">6.2. 성능 최적화</h3>
<ul>
<li><strong>search_vector</strong> 갱신 로직이 데이터베이스 내부에서 처리되어 네트워크 오버헤드가 감소한다.</li>
<li>PostgreSQL의 전문 검색 인덱스(<strong>GIN</strong> 또는 <strong>GiST</strong>)와 결합해 성능을 극대화할 수 있다.</li>
</ul>
<h3 id="63-코드-단순화">6.3. 코드 단순화</h3>
<ul>
<li>애플리케이션 코드에서 불필요한 로직을 제거하여 가독성과 유지보수성이 향상된다.</li>
</ul>
<h2 id="7-결론">7. 결론</h2>
<p>이번 문제 해결 과정에서 중요한 것은 올바른 접근 방식을 선택하는 것이었다. 처음에는 Spring 애플리케이션에서 모든 것을 해결하려 했지만, 과감하게 데이터베이스 레벨의 해결책으로 방향을 전환했다.</p>
<p>PostgreSQL 트리거를 활용한 해결책은 단순히 문제를 해결하는 것을 넘어, 시스템 전체의 품질을 향상시켰다. 코드는 더 간결해졌고, 성능은 개선되었으며, 유지보수도 더 쉬워졌다.</p>
<p>이 경험은 문제를 한 걸음 뒤로 물러서서 바라보고, 빠르게 실행하며 개선하는 과정의 중요성을 다시 한 번 상기시켜 주었다. 현재 정식 출시 전이지만, 이러한 접근은 더 나은 시스템을 만들고 사용자에게 더 나은 서비스를 제공하는 기반이 될 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring]JPA 상속 관계에서 필드 shadowing 문제 해결하기]]></title>
            <link>https://velog.io/@thedev_junyoung/SpringJPA-%EC%83%81%EC%86%8D-%EA%B4%80%EA%B3%84%EC%97%90%EC%84%9C-%ED%95%84%EB%93%9C-shadowing-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@thedev_junyoung/SpringJPA-%EC%83%81%EC%86%8D-%EA%B4%80%EA%B3%84%EC%97%90%EC%84%9C-%ED%95%84%EB%93%9C-shadowing-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 22 Dec 2024 13:26:56 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>서비스의 유저 타입들은 <strong>@Inheritance(strategy = InheritanceType.JOINED)</strong>와 <strong>@DiscriminatorColumn</strong>을 사용하여 구분한다. 이때 필드를 중복 정의하면서 shadowing 문제가 발생했는데, 다음과 같은 현상이 나타났다:</p>
<ol>
<li><strong>Getter와 toString() 메서드의 불일치</strong><ul>
<li><strong>toString()</strong>에서는 부모 클래스의 <strong>firm</strong> 필드를 사용해 데이터를 출력.</li>
<li><strong>getFirm()</strong> 메서드는 자식 클래스의 <strong>firm</strong> 필드를 참조하여 <strong>null</strong>을 반환.</li>
</ul>
</li>
<li><strong>데이터 접근 불가 현상</strong><ul>
<li>리플렉션으로 <strong>firm</strong> 필드 값을 직접 확인하면 데이터가 존재하지만, getter 메서드를 호출하면 <strong>null</strong>이 반환됨.</li>
</ul>
</li>
<li><strong>Entity Graph와 LAZY 로딩 설정 문제 없음</strong><ul>
<li>데이터 접근 문제는 JPA 설정상의 문제 때문이 아니라, 필드 shadowing으로 인해 발생.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="원인-분석">원인 분석</h2>
<h3 id="1-jpa-상속-구조-검토">1. JPA 상속 구조 검토</h3>
<ul>
<li>부모 클래스(User)에는 이미 <strong>firm</strong> 필드가 정의되어 있음.</li>
<li>자식 클래스(LawyerUser)에서도 <strong>firm</strong> 필드를 중복 정의함으로써 shadowing 현상이 발생.</li>
</ul>
<h3 id="2-필드-shadowing-현상">2. 필드 shadowing 현상</h3>
<ul>
<li>JPA 엔티티에서 동일한 필드명을 부모와 자식 클래스에 정의하면 shadowing 문제가 발생한다. 이로 인해:<ul>
<li><strong>toString() 메서드</strong>는 부모 클래스의 <strong>firm</strong> 필드를 참조.</li>
<li><strong>getFirm() 메서드</strong>는 자식 클래스의 <strong>firm</strong> 필드를 참조하여 초기화되지 않은 값을 반환.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="user-클래스">User 클래스</h3>
<pre><code class="language-java">@Getter
@Entity
@Table(name = &quot;users&quot;, schema = &quot;&quot;)
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = &quot;dtype&quot;, discriminatorType = DiscriminatorType.STRING)
@DiscriminatorValue(&quot;general&quot;) // 기본값
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;id&quot;, nullable = false)
    private Integer id;
    ..
    ..

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;firm_id&quot;, nullable = true) // 소속 Firm
    private Firm firm;
}
</code></pre>
<h2 id="문제-해결">문제 해결</h2>
<h3 id="수정-전-코드">수정 전 코드</h3>
<pre><code class="language-java">
@Entity
@DiscriminatorValue(&quot;layers&quot;)
@Table(name = &quot;users_lawyer&quot;, schema = &quot;lemon&quot;)
public class LawyerUser extends User {
@Column(name = &quot;firm&quot;, length = 255)
private String firm; // 중복 정의된 필드

@Column(name = &quot;license_number&quot;, length = 100)
private String licenseNumber;

@Column(name = &quot;license_file&quot;, length = 255)
private String licenseFile;

@Column(name = &quot;firm_phone&quot;, length = 20)
private String firmPhone;
}</code></pre>
<h3 id="해결책">해결책</h3>
<ul>
<li><strong>중복 정의된 firm 필드 제거</strong>: 부모 클래스(User)에서 정의된 <strong>firm</strong> 필드를 그대로 사용하도록 변경.</li>
</ul>
<h3 id="수정-후-코드">수정 후 코드</h3>
<pre><code class="language-java">@Entity
@DiscriminatorValue(&quot;layers&quot;)
@Table(name = &quot;users_lawyer&quot;, schema = &quot;lemon&quot;)
public class LawyerUser extends User {

@Column(name = &quot;license_number&quot;, length = 100)
private String licenseNumber;

@Column(name = &quot;license_file&quot;, length = 255)
private String licenseFile;

// firm 필드 제거 - 이미 User 클래스에 정의되어 있음

@Column(name = &quot;firm_phone&quot;, length = 20)
private String firmPhone;
}</code></pre>
<hr>
<h2 id="경험을-통해-배운-점">경험을 통해 배운 점</h2>
<ol>
<li><strong>JPA 상속 관계 설계 시 주의점</strong><ul>
<li>부모 클래스의 필드를 자식 클래스에서 중복 정의하지 않도록 주의해야 한다.</li>
<li>엔티티 구조를 설계할 때, 상속 계층 구조를 꼼꼼히 검토하는 것이 중요하다.</li>
</ul>
</li>
<li><strong>디버깅의 중요성</strong><ul>
<li>처음에는 JPA 프록시나 Lombok 관련 문제로 오해했지만, 디버깅을 통해 문제의 본질을 파악할 수 있었다.</li>
<li>Reflection API를 활용해 필드 값과 실제 타입을 확인한 것이 문제 해결에 큰 도움이 되었다.</li>
</ul>
</li>
<li><strong>JPA와 객체 지향 설계 원칙의 조화</strong><ul>
<li>상속 구조를 설계할 때, 객체 지향 원칙뿐만 아니라 JPA의 동작 방식도 함께 고려해야 한다.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 문제는 단순히 코드 수정에 그치지 않고, 설계의 중요성을 다시 돌아보는 계기가 되었다. 
처음에는 원인을 찾는 데 어려움을 겪었고, 문제의 본질을 파악하기 위해 디버깅과 가설 검증을 반복하며 꽤 오랜시간이 걸렸다. 
JPA 프록시 문제나 설정 오류로 오인하며 여러 단계를 거쳤지만, 결국 중복 정의된 필드가 원인임을 발견했다.</p>
<p>문제 해결 후에는 &quot;생각보다 간단하네&quot;라는 허무함이 들었다. 그러나 이 과정을 통해 설계의 작은 실수가 데이터 접근과 동작에 얼마나 큰 영향을 미칠 수 있는지 실감했다.</p>
<hr>
<h3 id="추가-설명">추가 설명</h3>
<p>Shadowing 문제는 상위 클래스(부모)와 하위 클래스(자식) 간에 <strong>같은 이름의 필드나 변수</strong>가 정의될 때 발생하는 문제.
하위 클래스에서 같은 이름의 필드를 다시 정의하면, 이 필드가 상위 클래스의 필드를 <strong>가리게(덮어씌우게)</strong> 된다.
결과적으로, 상위 클래스의 필드에 접근하려고 해도 하위 클래스의 필드가 대신 사용된다.</p>
<p>이 현상은 JPA뿐만 아니라 일반적인 객체 지향 프로그래밍에서도 발생할 수 있으며, 
다음과 같은 부작용을 초래한다</p>
<ul>
<li><strong>혼란</strong>: 코드가 의도한 대로 동작하지 않거나, 어떤 필드가 참조되고 있는지 명확하지 않음.</li>
<li><strong>디버깅 어려움</strong>: 문제의 원인이 shadowing인지, 설정 문제인지 파악하기 어려움.</li>
<li><strong>데이터 불일치</strong>: 읽기와 쓰기가 서로 다른 필드를 참조하면서 데이터가 일관되지 않게 됨.</li>
</ul>
<p>해결 방법은 중복 정의를 피하고, 명확한 설계를 통해 하나의 필드만 사용하도록 하는 것</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring]폴더 구조 설계와 아키텍처 전환 배경]]></title>
            <link>https://velog.io/@thedev_junyoung/Spring%ED%8F%B4%EB%8D%94-%EA%B5%AC%EC%A1%B0-%EC%84%A4%EA%B3%84%EC%99%80-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%A0%84%ED%99%98-%EB%B0%B0%EA%B2%BD</link>
            <guid>https://velog.io/@thedev_junyoung/Spring%ED%8F%B4%EB%8D%94-%EA%B5%AC%EC%A1%B0-%EC%84%A4%EA%B3%84%EC%99%80-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%A0%84%ED%99%98-%EB%B0%B0%EA%B2%BD</guid>
            <pubDate>Wed, 18 Dec 2024 13:41:10 GMT</pubDate>
            <description><![CDATA[<h3 id="현재-회사의-아키텍처"><strong>현재 회사의 아키텍처</strong></h3>
<p>현재 회사의 API 서버는 <strong>FastAPI</strong>로 구현되어 있으며, AI 요청은 <strong>비동기 방식</strong>으로 처리하고 있다.
하지만 동시 요청이 늘어날 경우 <strong>Celery</strong>와 <strong>Redis</strong> 구조로도 한계가 있을 것으로 판단했다.
특히 회사의 비전이 B2C에서 B2B까지 확장되는 상황이므로, 더 커지기 전에 <strong>서버를 나누는 방향</strong>으로 구조 전환을 결정했다.
이번 전환에서 중요한 점은 <strong>빠른 개발 속도</strong>와 <strong>QA 병행</strong>이었다. 이를 위해 최대한 <strong>단순하고 직관적인 폴더 구조</strong>를 잡았다.</p>
<hr>
<h3 id="폴더-구조의-목적과-이유"><strong>폴더 구조의 목적과 이유</strong></h3>
<p>폴더 구조를 설계하면서 가장 중요하게 생각한 점은 <strong>생산성과 명확성</strong>이다.</p>
<ul>
<li><strong>생산성</strong>: 새로운 기능을 빠르게 추가하고, 유지보수가 용이해야 한다.</li>
<li><strong>명확성</strong>: 팀원들이 코드의 역할과 위치를 바로 이해할 수 있어야 한다.</li>
</ul>
<hr>
<h3 id="폴더-구조"><strong>폴더 구조</strong></h3>
<p>📁 <strong>config:</strong> 설정 관련 파일 (보안, CORS, DB 등)</p>
<p>📁 <strong>controller:</strong> API 요청 처리</p>
<p>📁 <strong>dto</strong></p>
<ul>
<li>📂 <strong>request</strong> : 요청 DTO</li>
<li>📂 <strong>response</strong> : 응답 DTO</li>
<li>📂 <strong>mapper</strong> : DTO ↔ Entity 변환</li>
</ul>
<p>📁 <strong>exception:</strong> 예외 처리</p>
<p>📁 <strong>filter:</strong> 필터 및 인터셉터</p>
<p>📁 <strong>gateway:</strong> 외부 API 연동 및 게이트웨이</p>
<p>📁 <strong>model</strong></p>
<ul>
<li>📂 <strong>entity</strong> : DB 테이블과 매핑되는 클래스</li>
<li>📂 <strong>vo</strong> : 값 객체 또는 불변 객체</li>
<li>📂 <strong>enums</strong> : 상태값 및 타입 정의</li>
</ul>
<p>📁 <strong>repository:</strong> 데이터베이스 접근</p>
<p>📁 <strong>service:</strong> 비즈니스 로직</p>
<p>📁 <strong>utils:</strong> 공통 유틸리티 클래스</p>
<p>📄 <strong>LemonApiServerSpringApplication.java:</strong> 메인 실행 클래스</p>
<hr>
<h3 id="결론"><strong>결론</strong></h3>
<p>물론 이게 <strong>최선의 구조</strong>는 아닐 수 있다. DDD, 클린 아키텍처, 헥사고날 아키텍처와 같은 패턴이 존재하지만 <strong>상황에 맞게 구조를 잡는 것</strong>이 더 중요하다고 생각한다.</p>
<p>처음부터 <strong>API 서버와 AI 서버</strong>를 나누었다면 더 깔끔했을 수 있겠지만, 당시에는 <strong>MVP가 명확하지 않았다</strong>. 중간 점검을 통해 아키텍처를 조금 더 일찍 트래킹했으면 좋았을 것 같다는 아쉬움이 남는다.</p>
<p>다만 지금의 구조는 <strong>생산성과 명확성</strong>에 집중했고, 빠르게 대응하면서도 확장 가능한 첫걸음이 되리라 믿는다. 
앞으로 상황에 맞게 조금씩 리팩토링 해봐야겠다.</p>
]]></description>
        </item>
    </channel>
</rss>