<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>hey-sion.log</title>
        <link>https://velog.io/</link>
        <description>한걸음씩 나아가는 개발자</description>
        <lastBuildDate>Fri, 27 Mar 2026 08:56:11 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. hey-sion.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/hey-sion" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Kafka로 선착순 쿠폰 발급 구현하기]]></title>
            <link>https://velog.io/@hey-sion/Kafka%EB%A1%9C-%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hey-sion/Kafka%EB%A1%9C-%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 27 Mar 2026 08:56:11 GMT</pubDate>
            <description><![CDATA[<p>기존 쿠폰 발급 프로세스는 한 트랜잭션 내에서 동기적으로 처리하고 있었다.</p>
<blockquote>
<p>쿠폰 발급 요청 -&gt; 쿠폰 발급 처리 -&gt; 발급된 쿠폰 정보 반환</p>
</blockquote>
<p>일반적인 쿠폰 발급에는 이 방식이 충분히 단순하고 안정적이다.
요청 하나가 들어오면 그 안에서 모든 처리가 끝나기 때문에, 흐름도 명확하고 디버깅도 어렵지 않다.</p>
<p>하지만 “선착순”이라는 요구사항이 들어오는 순간 상황이 달라진다.
짧은 시간 안에 많은 요청이 동시에 들어오고, 정해진 수량이 빠르게 소진되는 상황을 고려해야 한다.</p>
<p>이 경우 기존의 동기 방식으로는 몇 가지 문제가 생긴다.
트래픽이 몰리는 순간 DB 쓰기 부하가 그대로 증가하고,
동시에 들어오는 요청 간 경쟁으로 인해 초과 발급을 막기 위한 복잡한 제어가 필요해진다.</p>
<p>그래서 기존 발급 프로세스는 유지하고, 
별도의 API를 두어 선착순 쿠폰 발급에 한해 처리 방식을 개선해보기로 했다.</p>
<p>핵심은 요청을 바로 처리하지 않고, 이벤트 기반으로 분리하는 것이다.</p>
<pre><code>쿠폰 발급 요청 → 이벤트 발행 → 즉시 응답
                 ↓
               Kafka
                 ↓
             Consumer 처리</code></pre><p>클라이언트에는 “요청이 접수되었다”는 사실만 빠르게 응답하고,
실제 발급 처리는 Kafka를 통해 비동기적으로 수행한다.</p>
<p>이렇게 구조를 나누면 기존 동기 처리의 단순함은 유지하면서도, 동시 요청 트래픽을 보다 안정적으로 처리할 수 있다.</p>
<h3 id="선착순-수량은-어디서-관리해야-할까">선착순 수량은 어디서 관리해야 할까?</h3>
<p>가장 먼저 고민했던 건 “수량”을 어디에 두고 카운트해야 하는가였다.</p>
<p>처음에는 쿠폰 테이블에 발급 가능 수량을 추가하는 방식을 떠올렸다.
max_quantity 같은 컬럼을 두고, 발급 시 이를 체크하는 방식이다.</p>
<p>하지만 이 접근은 좀 어색하게 느껴졌다. </p>
<p>쿠폰을 등록/수정할 때마다 수량을 함께 고려해야 하고,
이벤트가 조기 종료되거나 정책이 바뀔 경우에도 추가적인 처리가 필요해진다.
쿠폰 도메인이 점점 “이벤트 관리”까지 떠안게 되는 구조였다.</p>
<p>또 개인적으로는, 실무에서 자주 보던 <code>999999</code> 혹은 <code>9999-12-31</code> 같은 값들이 자연스럽게 떠올랐다.
실제로는 “무제한”을 의미하지만, 데이터 자체는 그 의미를 제대로 표현하지 못하는 경우다.</p>
<p>이 지점에서 한 번 더 생각하게 되었다.</p>
<p>“선착순이라는 개념이 정말 쿠폰의 속성일까?”</p>
<p>쿠폰은 할인 정책을 정의하는 도메인이다.
반면 선착순은 “이 쿠폰을 어떻게/얼마나 배포할 것인가”에 대한 전략이다.</p>
<blockquote>
<p>쿠폰 → 할인 정책
선착순 → 발급 전략</p>
</blockquote>
<p>서로 다른 책임을 가진 개념이라는 생각이 들었다.
그래서 선착순과 관련된 정보는 별도의 <code>CouponPromotion</code> 테이블로 분리했다.</p>
<p>이렇게 나누고 나니, 각 도메인이 훨씬 명확해졌다.
해당 테이블에 쿠폰아이디가 등록되어있는지 확인을 통해 선착순 발급 프로모션에 해당하는 쿠폰인지 확인가능하고, 카운팅할 수량도 바로 가져올 수 있다.</p>
<h3 id="kafka를-쓰면-선착순이-자동으로-해결될까">Kafka를 쓰면 선착순이 자동으로 해결될까?</h3>
<p>이벤트 기반 구조로 바꾸면서 처음에는
Kafka를 사용하면 자연스럽게 선착순이 보장될 것이라고 생각했다.</p>
<p>하지만 Kafka는 메시지를 순서대로 전달해줄 뿐, 요청 자체를 제한하거나 필터링하지는 않는다.</p>
<p>예를 들어 쿠폰이 100장인데 요청이 1000개 들어오면,
1000개의 요청이 그대로 Kafka에 쌓인다.</p>
<p>결국 Consumer에서는 불필요한 900개의 요청까지 모두 처리하게 되고, DB에도 그만큼의 부하가 전달된다.</p>
<p>이 문제를 해결하기 위해, 요청 앞단에서 한 번 더 걸러줄 필요가 있었다.
그래서 Redis를 함께 사용하기로 했다.
Redis를 이용해 요청을 앞단에서 한 번 더 걸러줌으로써
선착순 수량을 초과하는 요청이 아예 뒤로 넘어가지 않도록 하는 것이다.</p>
<pre><code>요청 → Redis INCR
     → 수량 이하 → Kafka 전달
     → 초과 → 즉시 실패</code></pre><p>Redis의 원자적 증가 연산을 이용해 정해진 수량을 초과하는 순간 바로 요청을 차단한다.</p>
<p>이렇게 하면 Kafka에는 필요한 요청만 들어가고, Consumer와 DB 부하를 줄인다.
무엇보다 사용자에게도 빠르게 결과를 전달할 수 있다.</p>
<h3 id="consumer에서-중요하게-고려할-점-멱등성">Consumer에서 중요하게 고려할 점: 멱등성</h3>
<p>Kafka는 특정 상황에서 동일한 메시지를 다시 전달할 수 있다.
Consumer가 처리 중 죽거나, ack 전에 문제가 생기면 같은 이벤트가 재전달될 가능성이 있다.</p>
<p>이때 아무런 대비가 없다면, 동일한 로직이 두 번 실행된다.</p>
<p>이를 방지하기 위해 event_handled 테이블을 두었다.</p>
<p>이벤트마다 고유한 eventId를 부여하고
이미 처리된 이벤트인지 확인한 뒤 처리되지 않은 경우에만 로직을 수행한다.</p>
<p>여기서 중요한 점은, 이 체크와 실제 처리 로직이 반드시 하나의 트랜잭션으로 묶여야 한다는 것이다.</p>
<blockquote>
<p>쿠폰 발급 처리 + event_handled 기록</p>
</blockquote>
<p>이 두 작업이 분리되면, 처리 후 기록 전에 장애가 발생했을 때 동일 이벤트가 다시 실행될 수 있다.
특히 쿠폰 발급의 경우 <code>중복 처리 = 초과 발급</code>으로 이어질 수 있기 때문에 더욱 주의가 필요하다.</p>
<h3 id="카운트-집계-방식에-대한-고민-증감-vs-덮어쓰기">카운트 집계 방식에 대한 고민 (증감 vs 덮어쓰기)</h3>
<p>이벤트 기반으로 구조를 바꾸면서, 좋아요나 조회수 같은 카운트 데이터를 어떻게 관리할지도 고민이 되었다.</p>
<p>가장 먼저 떠올린 방법은,
<strong>Producer에서 현재 값을 직접 계산해서 이벤트에 담아(EventPayload) 전달</strong>하는 방식이었다.</p>
<p>예를 들어 좋아요라면,</p>
<p>Producer가 SELECT COUNT(*)로 현재 좋아요 수를 조회하고 그 값을 이벤트에 포함시켜 전달한 뒤, Consumer에서는 해당 값을 그대로 덮어쓰는 방식이다.</p>
<p>이 방식은 직관적이고, 결과도 항상 “현재 상태” 기준으로 맞춰진다는 장점이 있다.
다만 구현을 구체적으로 생각해보니 몇 가지 걸리는 부분이 있었다.</p>
<p>우선 Producer가 매번 COUNT 쿼리를 수행해야 한다는 점이다.
좋아요 이벤트 하나를 처리하기 위해 집계 쿼리를 추가로 수행해야 하는 구조가 되었고,
이는 Producer의 책임이 과도하게 커지는 느낌이었다.</p>
<p>또 하나는, 이벤트의 성격과도 맞지 않는다는 생각이 들었다.
이벤트는 어떤 “상태”를 전달하기보다는,
“무슨 일이 발생했는지”를 표현하는 것이 더 자연스럽지 않을까 하는 고민이었다.</p>
<p>그래서 결론적으론 두 번째 방식인 <strong>증감 방식</strong>을 고려하게 되었다.
이 방식에서는 이벤트에 카운트 값을 포함시키지 않고, 단순히 “좋아요가 눌렸다”는 사실만 전달한다.
Consumer는 해당 이벤트를 받아, <code>like_count + 1</code> 과 같이 직접 값을 증감시킨다.</p>
<p>이유는 크게 두 가지였다.</p>
<ol>
<li><p>책임 분리
카운트 집계는 Consumer가 담당하는 것이 더 자연스럽고,
Producer는 “이벤트를 발생시키는 역할”에 집중할 수 있다.</p>
</li>
<li><p>이벤트의 의미를 더 명확하게 표현 가능
좋아요 이벤트는 “좋아요 수가 몇이다”가 아니라,
“좋아요가 눌렸다”라는 사실 자체가 더 본질에 가깝다고 생각했다.</p>
</li>
</ol>
<p>다만 이 방식은 전제가 필요하다.</p>
<p>증감 방식은 이벤트를 누적 적용하는 구조이기 때문에,
이벤트가 빠지거나 순서가 어긋나면 바로 값이 틀어지게 된다.</p>
<p>따라서 두 가지 조건이 반드시 보장되어야 했다.</p>
<ol>
<li><p>이벤트 순서 보장
Kafka의 partition key를 productId로 설정하여, 같은 상품에 대한 이벤트가 동일 파티션에 쌓이도록 했다.
이렇게 하면 하나의 파티션 내에서는 순서가 보장되기 때문에, Consumer가 순차적으로 이벤트를 처리할 수 있다.</p>
</li>
<li><p>중복 처리 방지(멱등성)
Kafka는 동일 메시지를 재전달할 수 있기 때문에, 중복 이벤트가 그대로 적용되면 카운트가 틀어질 수 있다. 이를 방지하기 위해 event_handled 테이블을 두고, 이미 처리된 eventId인지 확인한 뒤에만 로직을 수행하도록 했다.</p>
</li>
</ol>
<p>정리하면,
덮어쓰기 방식은 단순하지만 Producer 책임이 커지고
증감 방식은 이벤트 모델에 더 자연스럽지만, 순서와 멱등성이 필수다.</p>
<p>이번 구현에서는 이벤트의 성격과 책임 분리를 더 중요하게 보고,
증감 방식 + 멱등성(event_handled)을 함께 사용하는 방향을 선택했다.</p>
<h3 id="정리하며">정리하며</h3>
<p>이번 작업을 하면서 가장 크게 느낀 점은, Kafka와 같은 새로운 기술을 도입하는 것 자체보다
어떤 문제를 어떤 계층에서 해결할 것인지 판단하는 과정이 훨씬 중요하다는 것이었다.</p>
<p>또 다른 인상 깊었던 부분은,
이벤트를 “상태”가 아니라 “사실”로 다루는 관점이었다.</p>
<p>요청에 대한 처리를 주된 관심사로 생각하며 개발을 해왔었는데
“좋아요가 눌렸다”, &quot;쿠폰 발급 요청&quot; 이벤트를 쌓아가는 방식으로 바꾸면서,
데이터를 바라보는 기준 자체가 달라지는 것도 흥미로웠다.</p>
<p>아직 완벽하게 이해했다고 보기는 어렵지만,
적어도 “이벤트 기반으로 설계한다”는 것이 어떤 의미인지에 대해서는
조금은 감을 잡은 것 같다.</p>
<p>이번주도 고민하고 알아가는 모든 과정이 재밌었다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[API 응답 속도 개선하기 - cache]]></title>
            <link>https://velog.io/@hey-sion/API-%EC%9D%91%EB%8B%B5-%EC%86%8D%EB%8F%84-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-cache</link>
            <guid>https://velog.io/@hey-sion/API-%EC%9D%91%EB%8B%B5-%EC%86%8D%EB%8F%84-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-cache</guid>
            <pubDate>Fri, 13 Mar 2026 08:35:07 GMT</pubDate>
            <description><![CDATA[<h4 id="서론">서론</h4>
<p>앞선 글에서는 대량의 상품 데이터(테스트용 약 20만 건)를 조회할 때,
응답 시간을 개선하기 위한 첫 번째 방법으로 DB 인덱스 최적화를 진행했다.</p>
<p>상품 목록 조회 쿼리는 다음과 같은 조건을 가지고 있다.</p>
<blockquote>
<p>브랜드 필터 (brand_id)
노출 여부 (visibility)
삭제 여부 (deleted_at)
좋아요 수 기준 정렬 (ORDER BY like_count DESC)
페이지네이션 (LIMIT)</p>
</blockquote>
<p>이 중 좋아요 수(like_count)는 정렬에 사용되는 중요한 컬럼이다.
사용자는 보통 &quot;인기 상품&quot; 또는 &quot;좋아요순 상품&quot;을 통해 콘텐츠를 탐색하기 때문이다.</p>
<p>하지만 인덱스를 설계하는 과정에서 like_count는 최종 인덱스 후보에서 제외했다.
그 이유는 다음과 같다.</p>
<ul>
<li>좋아요 등록 / 취소는 사용자 행동에 따라 매우 자주 발생</li>
<li>like_count는 그때마다 UPDATE가 발생하는 컬럼</li>
<li>인덱스가 존재하면 인덱스 유지 비용이 함께 증가</li>
</ul>
<p>따라서, 조회 성능을 위한 인덱스를 추가하는 것이 오히려 쓰기 비용을 증가시킬 수 있다고 판단했다.</p>
<p>그렇다고 좋아요 순 상품 정렬 조회를 느린채로 둘 수는 없는 일이다.
사람들이 가장 보고 싶어하는 정보를 가장 느리게 제공할 수는 없으니까.
그럼 인덱스 없이 어떻게 빠르게 응답을 제공할 수 있을까?</p>
<p>이 문제를 해결하기 위해 <strong>캐시(Cache)</strong>를 도입해보기로 했다.</p>
<h3 id="캐시-도입-전략과-선택-이유">캐시 도입 전략과 선택 이유</h3>
<p>좋아요 순 정렬이 중요한 사용자 경험임은 틀림없지만, 
꼭 오차 없이 실시간으로 정렬되어야 하는가에 대해서는 의문이 들었다.</p>
<p>가격이나 재고처럼 즉각 반영이 필요한 데이터와 달리, 좋아요 수는 1~10분의 오차가 있어도 사용자 경험에 큰 영향이 없다고 판단했다. 완벽한 실시간성은 포기하되, 빠른 응답을 택하자.</p>
<p>좋아요 순 목록은 일정 시간 동안 캐시된 데이터를 사용하고, 
캐시가 만료되거나 존재하지 않을 때만 DB를 조회하도록 설계했다.</p>
<h4 id="왜-redis인가-caffeine-vs-redis">왜 Redis인가 (Caffeine vs Redis)</h4>
<p>Spring Cache 추상화를 사용하려면 실제 데이터를 저장할 캐시 저장소가 필요하다. 고민한 선택지는 두 가지였다.</p>
<ul>
<li>Local Cache (Caffeine): JVM 메모리에 직접 저장, 네트워크 호출 없음</li>
<li>Remote Cache (Redis): 네트워크를 통해 접근하는 외부 캐시</li>
</ul>
<p>Caffeine은 네트워크 호출이 없어 속도 자체는 더 빠르다. 하지만 멀티 인스턴스 환경에서는 서버마다 캐시가 별도로 존재하기 때문에 캐시 불일치 문제가 생긴다.</p>
<blockquote>
<p>서버 A: 상품 수정 → 로컬 캐시 evict
  서버 B: 로컬 캐시 살아있음 → TTL 만료 전까지 오래된 데이터 반환</p>
</blockquote>
<p>이러한 문제를 해결하기 위해 실무에서는 로컬 캐시 TTL을 매우 짧게 설정하기도 하지만,
캐시 일관성을 보다 단순하게 유지할 수 있다는 점에서 Redis 선택이 더 합리적이라고 판단했다.</p>
<h4 id="캐시-적용-방식">캐시 적용 방식</h4>
<p>상품 목록 조회 시 brandId, 정렬 조건, 페이지 정보를 조합한 캐시 키를 사용했다.</p>
<pre><code> @Cacheable(
      cacheNames = &quot;products&quot;,
      key = &quot;#brandId + &#39;:&#39; + #sort + &#39;:&#39; + #pageable.pageNumber + &#39;:&#39; + #pageable.pageSize&quot;
  )
  public PageResult&lt;ProductInfo&gt; getActiveProducts(Long brandId, ProductSort sort, Pageable pageable) {
      ...
  }
</code></pre><p>예를 들어 brandId=null, sort=LATEST, page=0, size=10으로 조회하면 Redis에 다음 키로 저장된다.</p>
<p><code>products::null:LATEST:0:10</code></p>
<p>동작 방식은 단순하다.</p>
<ul>
<li>캐시에 데이터가 존재하면 → Redis에서 바로 응답<ul>
<li>캐시에 데이터가 없으면 → DB 조회 후 Redis에 저장</li>
<li>최초 요청만 DB를 조회하고, 이후 동일한 조건의 요청은 대부분 캐시에서 처리된다.</li>
</ul>
</li>
</ul>
<h3 id="인덱스-없이-캐시만으로-괜찮은가">인덱스 없이 캐시만으로 괜찮은가?</h3>
<p>일부 구현에서는 캐시 미스 상황을 대비해 DB에도 인덱스를 추가하는 전략을 사용하기도 한다.</p>
<p>나는 그렇게 하지 않았다. 캐시 미스는 TTL 만료 후 해당 키에 대한 첫 번째 요청에서 발생한다. 즉, DB 조회 비용은 최초 1회에 집중되고 이후에는 캐시가 처리한다.</p>
<ul>
<li>트래픽이 적으면: 캐시 미스 자체가 드물어서 문제 없다</li>
<li>트래픽이 많으면: 첫 번째 이후 전부 캐시 히트이므로 오히려 더 유리하다</li>
</ul>
<p>좋아요 순 정렬처럼 동일한 요청이 반복되는 경우, 
완벽한 실시간성보다 빠른 응답이 더 중요하므로 이 전략이 충분히 합리적이라고 판단했다.</p>
<h3 id="개선된-응답시간-확인">개선된 응답시간 확인</h3>
<p>  Postman에서 동일한 요청을 반복 호출하여 캐시 적용 전후 응답 시간을 비교했다.</p>
<table>
<thead>
<tr>
<th>호출 순서</th>
<th align="left">상태</th>
<th align="center">상품 상세</th>
<th align="right">상품 목록</th>
</tr>
</thead>
<tbody><tr>
<td>1st</td>
<td align="left">캐시 미스</td>
<td align="center">686ms</td>
<td align="right">770ms</td>
</tr>
<tr>
<td>2nd</td>
<td align="left">캐시 히트</td>
<td align="center">219ms</td>
<td align="right">13ms</td>
</tr>
<tr>
<td>3rd</td>
<td align="left">캐시 히트</td>
<td align="center">27ms</td>
<td align="right">10ms</td>
</tr>
</tbody></table>
<p>실제로 응답시간이 대폭 감소하는걸 눈으로 확인하니 왠지모를 희열이 느껴졌다.
캐시의 힘... 잘 배웠습니다.</p>
<h3 id="더-고민해볼-점---cache-stampede">더 고민해볼 점 - Cache Stampede</h3>
<p>인덱스 없이 캐싱에만 의존하는 방식에는 분명한 트레이드오프가 존재한다.</p>
<p>빠른 응답과 인덱스 유지 비용 절감을 얻는 대신, 
좋아요 카운트가 실시간 반영되지 않아 TTL 만료 전까지는 이전 데이터가 유지될 수 있다.</p>
<p>한 가지 더 고려해야 할 문제가 있다. 
TTL 만료 직후 수백 개의 요청이 동시에 들어온다면, 그 요청이 전부 DB로 쏠리는 Cache Stampede 현상이다. 
현재는 테스트 규모이므로 허용하고 넘어갔지만, 트래픽이 커진다면 별도 대응(예: Mutex Lock, Probabilistic Early Expiration 등)이 필요하다.</p>
<h4 id="마무리">마무리</h4>
<p>고객이 더 원하는 정보는 뭘까?에 집중하고,
실시간 정확성을 포기하는 대신 빠른 응답을 선택했다.</p>
<p>고객의 관심사는 
&quot;많은 사람들에게 인기있는 상품이 무엇인 지?&quot; 
&quot;대략 얼마나 많은 사람들이 관심을 갖고 있는지?&quot;이지,
정확한 숫자가 궁금한 것이 아니라고 생각했기 때문이다.</p>
<p>그러나 완벽한 정합성이 필요한 가격/재고 같은 데이터라면? 
이 부분도 많은 고민이 필요한 부분인 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[슬로우 쿼리 개선하기 - index]]></title>
            <link>https://velog.io/@hey-sion/%EC%8A%AC%EB%A1%9C%EC%9A%B0-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-1.-index</link>
            <guid>https://velog.io/@hey-sion/%EC%8A%AC%EB%A1%9C%EC%9A%B0-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-1.-index</guid>
            <pubDate>Thu, 12 Mar 2026 06:43:32 GMT</pubDate>
            <description><![CDATA[<h4 id="서론">서론</h4>
<p>실무에서 인덱스/캐시/MV(Materialized View) 등의 성능 개선을 고민하게 되는 지점은
대부분 슬로우 쿼리 발생 시점이다.                                                   </p>
<p>이번 글에서는 상품 데이터 20만 건을 기준으로, 
브랜드 필터 + 좋아요 순 정렬 조회 쿼리의 실행 계획을 분석하고, 인덱스 최적화를 수행한 과정을 정리한다.</p>
<p>EXPLAIN은 DB 옵티마이저가 &#39;해당 쿼리를 어떤 방식으로 처리할 예정인지&#39; 보여주는 도구다.</p>
<h3 id="1-실행-계획-확인-및-분석">1. 실행 계획 확인 및 분석</h3>
<pre><code class="language-sql">// 상품 목록 조회 쿼리
SELECT * FROM products                                                                               
WHERE deleted_at IS NULL AND visibility = &#39;VISIBLE&#39; AND brand_id = 1                                                          
ORDER BY like_count DESC LIMIT 20; </code></pre>
<p>해당 쿼리로 explain 실행
<img src="https://velog.velcdn.com/images/hey-sion/post/b9e62c98-bf85-459c-8820-9a29ca11a341/image.png" alt=""></p>
<p>결과는 이런식으로 읽으면 된다.</p>
<ul>
<li><code>possible_keys = NULL</code> → 지금 쿼리에 쓸 만한 인덱스가 없음</li>
<li><code>type = ALL</code> → 풀 테이블 스캔</li>
<li><code>rows = 199176</code> 
→ MySQL 옵티마이저가 읽어야 할 것으로 예상하는 row수(현재 product 테이블 전체 row수와 비슷함)</li>
<li><code>key = NULL</code> → 사용한 인덱스 없음</li>
<li><code>filtered = 0.50%</code> → 필터링되고 남은 레코드 비율(읽은 것 대비 실제 필요한 데이터가 극히 적음)</li>
<li><code>Extra = Using where; Using filesort</code> → 조건 걸러내고, 정렬을 위해 별도 작업(비용) 필요
(위 두 개가 같이 나오면 &#39;읽기도 비효율, 정렬도 비효율&#39;인 것)</li>
</ul>
<p>특히, Extra 컬럼에 <code>Using filesort</code>가 출력되는 쿼리는 많은 부하를 일으키므로 가능하다면 쿼리를 튜닝하거나 인덱스를 생성하는 것이 좋다. 해당 코멘트는 order by 처리가 적절한 인덱스를 사용하지 못 할때만 표시된다. (Real MySQL 8.0 참고)</p>
<p>따라서, 조회 성능 개선을 위해 적절한 인덱스 설계가 필요하다는 것을 알 수 있다.</p>
<blockquote>
</blockquote>
<p>보통 실행 계획 확인시, Extra 컬럼에서 중요하게 봐야할 네 가지가 있다.</p>
<ol>
<li>Using where</li>
<li>Using index</li>
<li>Using filesort</li>
<li>Using temporary</li>
</ol>
<p>1, 2번은 위에서 살펴봤다.</p>
<p>3번 <code>Using index</code>는 인덱스만 읽어서 쿼리를 처리했다는 의미이며 보통 성능에 좋은 신호이다.
하지만, type=index이고, rows수가 크다면 index full scan일 수도 있다는 점도 알아두자.</p>
<p>4번 <code>Using temporary</code>는 쿼리 처리 과정에서 임시 테이블을 생성해 작업을 수행했다는 의미이다.
보통 GROUP BY, DISTINCT, ORDER BY 등이 적절한 인덱스를 활용하지 못할 때 발생하며, 이 경우 추가적인 메모리 사용이나 디스크 I/O가 발생할 수 있다. 따라서 Using temporary가 나타나는 경우에도 쿼리 구조나 인덱스 설계를 다시 점검해보는 것이 좋다.</p>
<p>즉, 현재 조회 쿼리는 대충 이런 흐름인 것이다:</p>
<blockquote>
<p>전체 상품 레코드를 거의 다 읽음 
→ deleted_at, visibility, brand_id로 걸러냄 
→ 남은 결과를 like_count DESC로 다시 정렬 → 20개만 잘라서 반환</p>
</blockquote>
<p>그렇다면 어떤 인덱스를 추가하면 좋을까?</p>
<h3 id="2-인덱스-후보-도출">2. 인덱스 후보 도출</h3>
<p>이 쿼리의 핵심은 <code>visibility</code>, <code>deleted_at</code>, <code>brand_id</code> 조건으로 대상을 좁힌 뒤,<br>그 결과를 <code>like_count DESC</code>로 정렬해 상위 20건만 빠르게 가져오는 것이다.</p>
<p>즉, 단순히 WHERE 조건만 처리하는 인덱스가 아니라<br>정렬 비용까지 함께 줄일 수 있는 방향으로 인덱스를 설계해야 한다.</p>
<p>우선 WHERE 절에 사용된 조건들을 보면 모두 동등 비교(=, IS NULL) 조건이다.</p>
<ul>
<li><code>brand_id = 1</code></li>
<li><code>visibility = &#39;VISIBLE&#39;</code></li>
<li><code>deleted_at IS NULL</code></li>
</ul>
<p>반면 <code>like_count DESC</code>는 정렬 조건이다.</p>
<p>일반적으로 복합 인덱스를 설계할 때는 동등 조건 컬럼 -&gt; 정렬 컬럼 순서로 배치하는 경우가 많다.
동등 조건은 인덱스 탐색 단계에서 읽어야 할 레코드 범위를 먼저 좁혀줄 수 있기 때문이다.
이렇게 범위를 줄인 뒤 정렬 컬럼을 활용하면 ORDER BY 처리 비용까지 함께 줄일 수 있다.</p>
<p><code>visibility = &#39;VISIBLE&#39;</code>, <code>deleted_at IS NULL</code>은 전체 데이터에서 대부분을 차지할 가능성이 높아 단독 조건으로는 범위를 크게 줄이지 못할 수 있다.
반면에 <code>brand_id</code>는 특정 브랜드 상품만 조회하는 경우, 조회 범위를 비교적 효과적으로 좁혀줄 가능성이 있다.</p>
<p>따라서 첫 번째 인덱스 후보로는 아래와 같은 형태를 생각해볼 수 있다.</p>
<pre><code class="language-sql">CREATE INDEX idx_products_brand_visible_deleted_like_count
ON products (brand_id, visibility, deleted_at, like_count DESC);</code></pre>
<p>Q. 인덱스 생성시 <code>DESC</code> 붙이면 뭐가 다른가?</p>
<blockquote>
<p>MySQL의 B-Tree 인덱스는 기본적으로 오름차순(ASC)으로 저장되지만, 
쿼리에서 ORDER BY ... DESC가 사용되더라도 인덱스를 역방향으로 스캔(backward index scan)할 수 있다. 따라서, 단일 정렬 컬럼의 경우 DESC 키워드를 붙인 인덱스를 별도로 생성하지 않아도 정렬을 위해 filesort가 발생하지 않을 수 있다.
MySQL 8.0부터는 INDEX(column DESC) 문법이 지원되지만, 이는 주로 복합 인덱스에서 정렬 방향이 혼합된 경우에 의미가 있다.</p>
</blockquote>
<hr>
<h4 id="2-1-인덱스-내-brand_id-위치-결정">2-1. 인덱스 내 brand_id 위치 결정</h4>
<p>그런데, 여기서 한 가지 고민이 생겼다.
상품 조회 쿼리에서 <code>brand_id</code> 파라미터는 필수가 아닌 선택 조건이다.
사실 실제 이커머스 환경을 떠올려봐도, 사용자가 상품 조회 시 처음부터 브랜드 필터를 거는 경우보다는 다음과 같은  패턴이 훨씬 자주 사용된다.</p>
<ul>
<li>(브랜드 상관없이) 전체 상품 조회</li>
<li>카테고리 필터(아우터, 상의, 신발 등)</li>
<li>정렬 옵션 변경</li>
</ul>
<p>즉, brand_id를 선두에 둔 인덱스는 브랜드 필터가 포함된 조회에는 유리하지만
브랜드 조건이 없는 조회에서는 인덱스를 아예 활용하지 못할 수 있다.</p>
<p>그렇다고 인덱스에 brand_id를 배제하기에는,</p>
<ul>
<li>어드민 상품 관리 화면</li>
<li>특정 브랜드 상품 조회
처럼 브랜드 필터가 자주 사용되는 맥락도 분명히 존재한다.</li>
</ul>
<p>그럼 선택지는 두 가지로 정리할 수 있다.</p>
<blockquote>
</blockquote>
<p>Option A. <code>brand_id</code>를 앞에 위치시켜, 브랜드 필터가 포함된 조회에서 강력하게 범위를 좁힌다.
Option B. <code>brand_id</code>를 복합 인덱스 뒤 쪽에 위치시켜, 브랜드 필터가 있을 때만 추가 활용하도록 한다.</p>
<hr>
<h4 id="2-2-brand_id를-선두에-둘-때의-트레이드오프">2-2. brand_id를 선두에 둘 때의 트레이드오프</h4>
<p>brand_id를 선두에 두면 한 가지 문제가 생긴다.</p>
<p>MySQL의 B-Tree 인덱스는 선두 컬럼부터 순서대로만 활용할 수 있기 때문에,
brand_id 조건이 없는 쿼리에서는 해당 인덱스를 사용할 수 없다.</p>
<p>그런데 중요한 점은, 상품을 조회하는 모든 쿼리에는 항상 다음 조건이 포함된다는 것이다.</p>
<pre><code class="language-sql">    WHERE deleted_at IS NULL AND visibility = &#39;VISIBLE&#39;</code></pre>
<p>사용자에게는 삭제되지 않았고, 노출 중인 상품만 반환해야 하기 때문이다. 
이건 선택적 필터가 아닌 필수 기본 필터다.</p>
<p>결국 Option A를 선택한다면, 이 기본 필터를 처리할 인덱스를 별도로 추가해야 한다.</p>
<pre><code class="language-sql">  (brand_id, deleted_at, visibility)  -- brand 필터 있는 쿼리용
  (deleted_at, visibility)            -- brand 필터 없는 쿼리용 (추가 필요)
</code></pre>
<p>반면 Option B는 인덱스 하나로 두 가지 조회 패턴을 모두 처리할 수 있다.
brand_id가 없으면 앞선 두 컬럼의 프리픽스(deleted_at, visibility)를, 있으면 세 컬럼 모두를 활용한다.
인덱스를 두 개로 늘리지 않고도 두 케이스를 모두 커버할 수 있으므로 Option B를 선택했다.</p>
<hr>
<h4 id="2-3-최종-인덱스-생성-후보">2-3. 최종 인덱스 생성 후보</h4>
<pre><code class="language-sql">    (deleted_at, visibility, brand_id)  -- brand 필터 유무 무관하게 커버
    (deleted_at, visibility, price)     -- 가격순 정렬
      (deleted_at, visibility, like_count DESC)  -- 좋아요순 정렬(생성 X)</code></pre>
<p>  결과적으로 마지막 인덱스는 추가하지 않았다.
  like_count는 좋아요/취소가 발생할 때마다 갱신되는 쓰기 빈도가 높은 컬럼이라,
  조회 성능 향상보다 인덱스 유지 비용이 더 크다고 판단했기 때문이다.</p>
<p>like_count 정렬에 대한 추가 최적화는 별도로 다룰 예정이다.</p>
<hr>
<h3 id="3-인덱스-추가-후-실행-계획-변화">3. 인덱스 추가 후 실행 계획 변화</h3>
<p>두 개의 인덱스를 추가하고, 조회 패턴별로 실행 계획이 어떻게 달라지는지 확인했다.</p>
<pre><code class="language-sql">    CREATE INDEX idx_products_deleted_visibility_brand 
        ON products (deleted_at, visibility, brand_id);

    CREATE INDEX idx_products_deleted_visibility_price 
        ON products (deleted_at, visibility, price);
</code></pre>
<h4 id="3-1-brand_id-필터--좋아요순-정렬">3-1. brand_id 필터 + 좋아요순 정렬</h4>
<p>1번 도입부와 동일한 쿼리로 실행계획을 확인해본다.</p>
<pre><code class="language-sql">
    EXPLAIN SELECT * FROM products
    WHERE deleted_at IS NULL AND visibility = &#39;VISIBLE&#39; AND brand_id = 1
    ORDER BY like_count DESC LIMIT 20;</code></pre>
<p> <img src="https://velog.velcdn.com/images/hey-sion/post/3c6ccbb6-cb53-4132-97fb-6afc699a2aca/image.png" alt=""></p>
<p>인덱스 추가 전과 비교하면:</p>
<ul>
<li><code>key = idx_products_deleted_visibility_brand</code> → 추가한 인덱스 사용</li>
<li><code>type = ref</code> → 풀스캔(ALL)에서 인덱스 탐색으로 개선</li>
<li><code>rows → 18810</code> -&gt; 이전 199176에서 약 1/10 수준으로 대폭 감소</li>
<li><code>Extra = Using index condition</code> 확인
(참고: Extra에 Using filesort가 남아있는 이유는, 앞서 인덱스 생성 단계에서 언급했던 대로 like_count를 인덱스에 포함하지 않았기 때문)</li>
</ul>
<p>개선된 조회 흐름을 정리하면 다음과 같다.</p>
<p>  인덱스 탐색
  → deleted_at IS NULL, visibility = &#39;VISIBLE&#39; 조건으로 범위 축소
  → brand_id 조건이 있다면 추가 범위 축소
  → 남은 결과를 like_count DESC로 정렬 후 20건 반환</p>
<h4 id="3-2-brand_id-없는-기본-조회">3-2. brand_id 없는 기본 조회</h4>
<p>2번에서 (deleted_at, visibility, brand_id) 인덱스 하나로 brand_id 유무와 무관하게 두 케이스를 모두 커버할 수 있다고 판단했다. 실제로 그런지 확인해보았다.</p>
<pre><code class="language-sql">  EXPLAIN SELECT * FROM products
  WHERE deleted_at IS NULL AND visibility = &#39;VISIBLE&#39;
  ORDER BY like_count DESC LIMIT 20;</code></pre>
<p><img src="https://velog.velcdn.com/images/hey-sion/post/2bfb28fe-7160-4915-966b-705247e4ae78/image.png" alt=""></p>
<ul>
<li><code>key = idx_products_deleted_visibility_brand</code> → brand_id 없이도 동일 인덱스 사용 확인</li>
<li><code>type = ref</code> -&gt; 인덱스 탐색</li>
<li><code>rows = 99546</code> -&gt; 위 brand_id 조건 18810 보다 많음</li>
</ul>
<p>(deleted_at, visibility) prefix만으로 인덱스를 활용하고 있다. brand_id 조건이 추가될수록 읽어야 할 범위가 더 좁아지며, 인덱스 하나로 두 케이스가 모두 커버됨을 확인했다.</p>
<h4 id="3-3-가격순-정렬">3-3. 가격순 정렬</h4>
<pre><code class="language-sql">  EXPLAIN SELECT * FROM products
  WHERE deleted_at IS NULL AND visibility = &#39;VISIBLE&#39;
  ORDER BY price ASC LIMIT 20;</code></pre>
<p><img src="https://velog.velcdn.com/images/hey-sion/post/5f60a1b3-371c-48ae-af75-489423265a1e/image.png" alt=""></p>
<ul>
<li><code>key = idx_products_deleted_visibility_price</code> → price 인덱스 사용 확인</li>
<li><code>Extra = Using index condition</code> → 별도 정렬 작업 없음</li>
</ul>
<p>인덱스가 (deleted_at, visibility, price) 순서로 구성되어 있어, WHERE 조건 필터 후 남은 결과가 이미 price 순으로 정렬된 상태다. 별도의 정렬 작업 없이 인덱스 순서대로 읽어 LIMIT 20을 바로 적용할 수 있다.</p>
<h4 id="마무리">마무리</h4>
<p>실행 계획 상의 주요 지표만 보더라도 인덱스 생성을 통해 조회 성능이 크게 향상되었음을 확인할 수 있다.</p>
<p>그러나 슬로우 쿼리가 발생할 때마다 인덱스를 추가하는 방식이 항상 베스트 프랙티스는 아니다.
인덱스는 특정 쿼리 하나가 아니라 서비스에서 반복적으로 사용되는 조회 패턴을 기준으로 설계해야 하고,</p>
<p>인덱스가 많아질수록 다음과 같은 비용이 함께 증가함을 인지해야 한다.</p>
<ul>
<li>쓰기 성능 저하 (INSERT, UPDATE, DELETE)</li>
<li>저장 공간 증가</li>
<li>중복 인덱스 관리 문제</li>
</ul>
<p>따라서 먼저 해당 쿼리가 얼마나 자주, 중요한 경로에서 사용되는지 확인하고, 
인덱스 추가로 얻는 이점과 유지 비용을 함께 비교해야 한다.</p>
<p>그럼에도 조회 비용이 여전히 크다면 상황에 따라 캐시(Cache), Materialized View, 별도 집계 테이블과 같은 접근 방식도 함께 고려할 수 있다. (다음 편에 계속..)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[클로드와 함께 TDD로 주문에 쿠폰 기능 붙이기]]></title>
            <link>https://velog.io/@hey-sion/%ED%81%B4%EB%A1%9C%EB%93%9C%EC%99%80-%ED%95%A8%EA%BB%98-TDD%EB%A1%9C-%EC%A3%BC%EB%AC%B8%EC%97%90-%EC%BF%A0%ED%8F%B0-%EA%B8%B0%EB%8A%A5-%EB%B6%99%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@hey-sion/%ED%81%B4%EB%A1%9C%EB%93%9C%EC%99%80-%ED%95%A8%EA%BB%98-TDD%EB%A1%9C-%EC%A3%BC%EB%AC%B8%EC%97%90-%EC%BF%A0%ED%8F%B0-%EA%B8%B0%EB%8A%A5-%EB%B6%99%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Fri, 06 Mar 2026 07:50:04 GMT</pubDate>
            <description><![CDATA[<p>오늘은 주문에 쿠폰 개념을 도입하면서 TDD를 클로드와 함께 해보았습니다. 
개발을 하면 할수록 여전히 모르는 것이 많다는 사실을 &#39;알게&#39;되어, 오늘의 포스팅은 겸손한 마음으로 존댓말을 사용합니다. <em>(...?)</em></p>
<p>모든 테스트 코드를 직접 짜면 좋겠지만, 시간 관계상(<del>귀찮기도 하고...</del>) 
저는 클로드에게 RED -&gt; GREEN -&gt; REFACTOR의 단계를 하나씩 시키고 검토하는 방식으로 개발을 하고 있는데요.
대부분의 코드 작성을 AI에게 시키지만, <strong>&#39;내가 검토한 코드만 커밋한다&#39;</strong>는 원칙은 꼭 지키고 있습니다.</p>
<h3 id="1-red-phase">1. red phase</h3>
<p>요구사항을 함께 정리하고, 문서를 검토 검토 검토..(하도록 시킨후)
제 머릿속도, 그리고 제 친구 클로드도 이쯤이면 우리의 목표를 잘 이해했다 싶을 때 
아래와 같이 요청합니다.</p>
<blockquote>
<p>&quot;쿠폰 테스트를 짜보렴! 일단 red phase로 짜주면 내가 검토할게&quot;</p>
</blockquote>
<p>이 단계에선 보통 구현 자체보다는 DisplayName을 주의깊게 보고, 
구현하려는 기능의 요구 사항들을 테스트에서 잘 커버하고 있는 지 우선적으로 살펴보는 편입니다.</p>
<p>클로드가 최고라곤 하지만 막상 시켜보면 허술한 점들이 조금씩은 보여요.
개발자로 살아남기 난이도가 점점 높아지는데,
&quot;이걸 이렇게 짜놔?&quot;싶은 순간을 만나면 저는 묘한 안도감을 느낍니다.
<em>(조금은... 아주 조금은 너보다 내가 나은 점이 있어..)</em></p>
<p>예를들면, 이런 것들입니다. </p>
<ul>
<li><p>DisplayName이 &quot;내 쿠폰 목록 조회시 보유한 쿠폰이 있으면 200OK와 쿠폰 목록을 반환한다.&quot;라고 작성된 테스트에, 실제 확인 값은 응답 데이터의 사이즈만 확인하고 있다거나</p>
<pre><code>// Claude 초안
assertThat(response.getBody().data()).hasSize(1);</code></pre></li>
<li><p>테스트 목적에는 맞지만, 머리를 많이 써야만 의도를 이해할 수 있는 코드</p>
<pre><code>// Claude 초안 (직접 계산해야 확인하려는 값의 정확도를 알 수 있음)
assertThat(response.getBody().data().discountAmount()).isEqualTo(10000L);
assertThat(response.getBody().data().finalAmount()).isEqualTo(140000L);</code></pre></li>
</ul>
<p>1번은 실제 arrange 단계에서 저장시킨 쿠폰의 아이디가 응답데이터에 있는 지 확인해야 더 정확한 검증이 되는 테스트가 되지 않겠냐고 다그치고,
2번과 같은 케이스엔 하드코딩과 같은 값을 유의미한 변수로 빼서, 보다 읽기 쉬운 코드로 변경하라고 요청하는 식입니다.</p>
<p>그렇게 함께 핑퐁을 거쳐 우선 red 스테이지를 넘어갑니다. </p>
<h3 id="2-green-phase">2. green phase</h3>
<p>이후의 green, refactor 단계는 개발자마다, 심지어 ai도 받아들이는 개념이 완벽히 동일하지는 않은 것 같아요.</p>
<blockquote>
<ul>
<li>껍데기 클래스를 만들고, 내부 구현에 return true; 만을 담더라도 무조건 통과하는 테스트를 만드는 단계</li>
<li>테스트가 통과하는 최소한의 구현 작성(하드코딩, 중복 허용, 설계 무시)</li>
<li>green + refactor 적절히 섞어서 한 번에 진행..(?) 등등..</li>
</ul>
</blockquote>
<p>저는 AI가 작성한 코드를 이해하고 넘어간다는 원칙을 지키면서 학습도 할 수 있도록 
green 단계에선 이렇게 요청합니다. </p>
<blockquote>
<p>Green 단계에서의 구현은 하드코딩/중복 허용하되, 
테스트가 보장하려는 규칙을 실제로 만족시키는 최소 로직은 넣어줘.<br>단, return true / return 0 같은 무의미한 통과는 금지! </p>
</blockquote>
<p>그리고 빠르게 작성해준 코드들을 살펴봅니다.
우선은 라인 정렬같은 부분까지 (제 기준만큼) 꼼꼼히 해주진 않기 때문에.. 코드를 예쁘게 만들면서 봅니다.
주로 요구사항에 맞지 않거나 기존 코딩 스타일과 다르게 작성된 부분이 있는 지(레이어 위반 등)를 중점적으로 확인하고, 세부적인 내부 구현은 refactor에서 확인..하려고 노력합니다. (사실 요 부분은 실제로 개발을 하다보면 경계가 좀 애매해질 때가 많은 것 같아요.)
<img src="https://velog.velcdn.com/images/hey-sion/post/e7e11fe4-9a70-4878-b8cc-ac79407b5bcb/image.png" alt=""></p>
<p>1, 2번은 잘못을 반성하며, 동의한다고 말해주었습니다.
하지만 3번은 &#39;IssuedCoupon은 독립적인 도메인이라기보다 Coupon의 발급 이력&#39;이지 않냐면서 서비스 도입은 과하지 않냐고 하네요.
<img src="https://velog.velcdn.com/images/hey-sion/post/96b9651c-66ac-4b35-abe3-0f1ec357e27d/image.png" alt=""></p>
<p>이러한 토론 과정을 통해 저도 다시 한 번 최초 설계와 요구사항을 꼼꼼히 살펴보게 됩니다.
보통 이 단계를 통해 제가 구현하고자 하는 기능의 전체적인 그림이 좀 더 선명해지는 것 같습니다.</p>
<h3 id="3-refactor-phase">3. refactor phase</h3>
<p>코드를 검토하다보면 개선해야할 로직/구조 등이 보이기 마련인데요.
크게 잘못된 방향이 아니라면, 우선 스스로 개선 방향을 간략히 생각해보고 리팩토링을 요청합니다. 
그 후 제가 생각한 방식과 다르거나, 비효율적인 로직으로 구현된 부분에 대해서 추가 질문을 던져 확인하는 식입니다. </p>
<p><img src="https://velog.velcdn.com/images/hey-sion/post/c7228217-73ed-436f-8d41-1076769d828c/image.png" alt=""></p>
<p>리팩토링 요청 후 클로드는 validation의 책임을 정리하며 private 메서드를 아래와 같이 만들었어요. 
이름부터 뭘 하는 지 모르겠는 메서드입니다.
살펴보니 쿠폰 검증, 쿠폰 사용, 할인 계산을 모두 하고 있었어요.</p>
<pre><code>   private long resolveCouponDiscount(Long issuedCouponId, Long userId, long originalAmount) {
        if (issuedCouponId == null) return 0L;
        Long couponId = issuedCouponService.use(issuedCouponId, userId);
        return couponService.calculateDiscount(couponId, originalAmount);
    }</code></pre><p>게다가 해당 메서드는 상품(product)의 재고를 차감하기 전에 먼저 호출되고 있었는데요.
이런 플로우라면 재고 부족으로 주문이 실패할 경우 쿠폰 사용 상태를 롤백해야 하는 상황이 발생할 수 있습니다.</p>
<p>심지어 마지막 calculateDiscount()에서 할인 가능 금액 계산시 최소 주문 금액 충족 여부를 체크하고 있기 때문에 
재고차감 -&gt; 쿠폰 차감까지 한 상태에서, 사실 주문 금액이 기준을 못 넘기지롱! 이 쿠폰은 못 쓴단다. 롤백하렴! 
이렇게 될 수도 있는 상태였어요.
<em>질끈 짤 검색결과...</em>
<img src="https://velog.velcdn.com/images/hey-sion/post/9e1d891a-c7a7-4fcf-bfa5-66da418029ac/image.png" alt=""></p>
<p>잠깐 &#39;직접 짜는 게 더 빠를까..?&#39;하는 유혹을 느끼기도 해요.
하지만 (<del>그럴 리 없기도 하고</del>) 애초에 &#39;명료한 프롬프트를 넘기지 않은 내 탓이다&#39;라고 반성 후, 
차분히 정리된 생각으로 설명을 해봅니다. </p>
<pre><code>작성해준 resolveCouponDiscount() 메서드를 보니 몇 가지 구조적인 고민이 생겼어.

1. 이 로직이 OrderFacade의 책임이 맞는지 의문이 들어.
2. 현재 흐름에서는 쿠폰 사용 처리가 재고 차감보다 먼저 일어나는데,
   재고 부족으로 주문이 실패하면 쿠폰 사용을 롤백해야 하는 상황이 생길 수 있어 보이네.
   사실상 유저가 실제로 보유한 쿠폰이 실제 사용처리 단계에서 예외를 발생시킬 확률보다는
   재고 부족으로 인한 예외 발생 가능성이 더 높지 않을까 싶은데?

그리고 기본적으로 상태 변경은 가능한 뒤쪽에서 수행하는 것이 안전한 구조라고 생각해.
그래서 쿠폰을 사용하는 주문 흐름은 대략 이런 플로우가 좋을 것 같아.

1. 쿠폰 사용 가능 여부 검증(유저 보유 여부, 쿠폰 상태, 최소 주문 금액 등)
2. 재고 차감
3. 쿠폰 사용 처리(동시성 충돌 시 예외)
4. 주문 생성

OrderFacade의 43라인 이후 흐름을 이 기준으로 다시 정리해보고,
각 책임이 어느 서비스에 위치하는 것이 자연스러운지도 함께 고민해줘.</code></pre><p>예상대로 클로드는 저의 말을 빠르게 정리하고, 전달한 내용 이상으로 좋은 정리를 해주었어요.
<img src="https://velog.velcdn.com/images/hey-sion/post/9d05bc54-02a6-41f6-8a20-9e150e6c5af9/image.png" alt=""></p>
<p>마지막엔 책임에 대한 질문도 놓치지 않고 덧붙여 주고요.
<img src="https://velog.velcdn.com/images/hey-sion/post/6326440a-46e7-4481-9da5-249fa2eefe14/image.png" alt=""></p>
<p>이후로도 조금 더 핑퐁을 거쳐서, 코드는 전보다 읽기 쉽고 정돈된 상태가 되었습니다.
리팩토링을 하다 보면 늘 끝이 없다고 느낍니다. 읽고 또 읽다보면.. 안 읽혀요. 그럴 땐 그만해야 합니다(?)
과제도 실무도 무엇이든 기한이 있으니, 흐름이 읽히고 책임이 크게 어긋나지 않는 수준까지 정리한 뒤 
우선 구현을 마무리하기로 했습니다. 
남은 고민들은 이후 개선 포인트로 남겨두고요. (과연?)</p>
<hr>
<p>(번외) 때로는 가볍게 의문이 드는 부분이 있으면 즉시<em>(그러나 토큰이 닳지 않을까 조마조마하며..)</em> 
물어보고 혼나기도 하며 재밌는 개발 생활을 이어가고 있습니다.</p>
<blockquote>
<ul>
<li>enum 비교는  Status.USED.equals(getStatus())가 낫지 않아?  </li>
<li>getStatus() 매번 호출보다는 변수 추출이 낫지 않겠니? now의 기준도 달라지잖아</li>
</ul>
</blockquote>
<p>enum 비교시에는 == 방식이 더 안전하다는 건 &#39;이펙티브 자바&#39; 책에도 나온다는 팩트를 근거로
생각 좀 하고 말하라고 저를 구박해주고, 합리적인 의문에는 좋은 포인트라고 칭찬 후 반영해줍니다. 
<img src="https://velog.velcdn.com/images/hey-sion/post/b596f9f8-57be-46f3-981f-0c07fdba8be1/image.png" alt=""><em>꼭.. 기억할게..순간 헷갈려서 그랬어...</em></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>AI는 빠르게 코드를 작성 해주지만,
그 코드의 의미를 이해하고 책임지는 것은 여전히 개발자의 몫입니다.
테스트에 통과하는 코드가 곧 좋은 코드는 아니라는 사실도 다시 한 번 느끼게 됩니다.</p>
<p>저는 좋아하는 일을 업으로 삼은 복 받은 사람이라 생각해왔는데요.
좋아하는 것과 잘하는 것은 다를 수 있다는 점이 늘 딜레마였습니다.</p>
<p>하지만 아직 내일 일은 모르고,
적어도 오늘은 이렇게 코드를 다듬고, 또 한 걸음 나아가는 여정이 즐거웠어요!</p>
<p>시간이 많이 흘러서 나중에 다시 이 글을 읽을 때도
<del><em>ai가 테스트에 통과하면서도 리뷰도 필요없는 품질 좋은 코드를 짜는 날이 오더라도...</em></del>
저는 여전히 오늘처럼 (고민하고 생각하는) 개발자였으면 좋겠다는 생각을 해봅니다. (제발~)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[클로드에게 꼬리물어 질문하기 - 회원가입 동시성 이슈부터 JPA flush 타이밍까지]]></title>
            <link>https://velog.io/@hey-sion/%ED%81%B4%EB%A1%9C%EB%93%9C%EC%97%90%EA%B2%8C-%EA%BC%AC%EB%A6%AC%EB%AC%BC%EC%96%B4-%EC%A7%88%EB%AC%B8%ED%95%98%EA%B8%B0-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88%EB%B6%80%ED%84%B0-JPA-flush-%ED%83%80%EC%9D%B4%EB%B0%8D%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@hey-sion/%ED%81%B4%EB%A1%9C%EB%93%9C%EC%97%90%EA%B2%8C-%EA%BC%AC%EB%A6%AC%EB%AC%BC%EC%96%B4-%EC%A7%88%EB%AC%B8%ED%95%98%EA%B8%B0-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88%EB%B6%80%ED%84%B0-JPA-flush-%ED%83%80%EC%9D%B4%EB%B0%8D%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Fri, 13 Feb 2026 08:38:28 GMT</pubDate>
            <description><![CDATA[<p>시작은 사용자의 login_id에 unique 제약을 거는 것이었다.</p>
<p>회원가입시 별도의 validator 클래스에서 중복 아이디에 대한 검증을 하고있다.
하지만 로그인 아이디에 unique 제약이 없는 상태에서 동시 요청이 오면,</p>
<blockquote>
<p>검증 → 저장 사이의 Race Condition으로 인해
동일 loginId가 중복 저장될 수 있다는 치명적인 이슈가 있다.</p>
</blockquote>
<p>따라서, login_id에 unqiue 제약은 필수적인 액션이다.
(라는 것을 처음엔 놓쳤다가 AI 코드리뷰를 통해 피드백을 받아 알게되었다...)</p>
<p><img src="https://velog.velcdn.com/images/hey-sion/post/e4b07f71-49ca-4bb2-8e44-878ad0e22b1b/image.png" alt=""></p>
<p>그동안 실무에서 친구 추천 이벤트와 같은 개발 건이 있을 때
추천인 코드에 unique 조건을 걸어 중복 생성을 방지했던 경험이 있다.
그런데 생각해보니 로그인 아이디는 이벤트보다 더 엄격히 고유값을 확인해야 하는 거였다.</p>
<p><strong>그래, 그러면 unique 걸면 끝인가?</strong>
<em>라고 생각해서 끝인 적이 있던가...</em></p>
<p>DB 컬럼에 unique 제약을 걸면 중복된 아이디는 원천 차단된다.
만약 이미 존재하는 로그인 아이디의 insert 시도가 일어나면,
<code>DataIntegrityViolationException</code>이 발생한다.</p>
<p>그럼 이 예외를 어떻게 깔끔하게 처리하고, 클라이언트에게 적절한 응답을 보낼까?
두 가지 선택지를 생각했다.</p>
<blockquote>
<p>a. 전역 handler에서 해당 exception을 잡고, 409 conflict 응답
b. try-catch 블록에서 현재 어플리케이션에서 사용하는 공통 예외로 전환 후 응답</p>
</blockquote>
<p>왠지 b가 더 깔끔할 것 같았다. 로직 내에서 예외 처리 로직을 바로 볼 수도 있고.
이미 회원가입시 로그인 아이디 중복을 검증할 때도 아래와 같이 처리하고 있었기 때문이다.</p>
<pre><code>... 생략
if (userRepository.findByLoginId(command.loginId()).isPresent()) {
    throw new CoreException(ErrorType.CONFLICT, &quot;이미 존재하는 로그인 ID입니다.&quot;);
}</code></pre><p>가벼운 마음으로 나의 친밀한 친구(클로드)에게 B로 하자고 했다.
“try-catch로 잡아서 CoreException으로 바꾸면 되지 않을까?”</p>
<p><img src="https://velog.velcdn.com/images/hey-sion/post/72fabb47-85f6-49c2-9dde-7d90be96b8b6/image.png" alt=""></p>
<p>음.. 네 뭐라고요?!
아, 당연히 save 할 때 insert query가 나간다고 생각해버렸네.
jpa.. 영속성 컨텍스트.. 다 공부하며 들어는 봤지만, 이렇게 실제 적용은 안 되는 상태였던 거구나.
허탈하면서도 레벨업할 새로운 먹이(?)를 찾아서 좋았다.
<img src="https://velog.velcdn.com/images/hey-sion/post/e4a5f7e2-3bf3-4794-92c1-e68ebeb846f1/image.png" alt=""><em>고마워 이제 블로그 쓸 수 있겠다는 뭔데... 나를 어디까지 내다보는 건데..</em></p>
<p>JPA는 write-behind 전략을 사용한다.
즉, save()를 호출한다고 해서 즉시 insert SQL이 실행되는 게 아닐 수 있다.
실제 SQL은 flush 시점에 나간다.</p>
<p>그리고 flush는 보통:</p>
<ul>
<li>트랜잭션 commit 직전  — @Transactional 메서드 종료 시</li>
<li>JPQL 쿼리 실행 직전  — 영속성 컨텍스트와 DB 동기화를 위해 자동 flush  </li>
<li>명시적으로 flush() 호출 시</li>
</ul>
<p>발생한다.</p>
<p>그러면 이제 a 선택지인,
전역 handler에서 DataIntegrityViolationException을 잡는 방식을 선택하면 될까?</p>
<p>그런데 또 하나의 의문이 들었다.</p>
<blockquote>
<p>나는 클라이언트에게
“이미 존재하는 로그인 ID입니다.” 라는 명확한 응답을 주고 싶은데,
과연 DataIntegrityViolationException은
로그인 ID의 UNIQUE 위반일 때만 발생하는 걸까?</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hey-sion/post/e0142853-1134-4591-a107-23de427bb7bc/image.png" alt=""></p>
<p>답은 아니었다.
DataIntegrityViolationException은 UNIQUE 위반뿐 아니라
NOT NULL, CHECK 등 DB 무결성 제약이 깨질 때 전부 발생할 수 있는 예외였다.</p>
<p>그렇다면 전역 핸들러에서 이 예외를 무조건
“이미 존재하는 로그인 ID입니다.”라고 응답하는 건 위험하다.
다른 제약 위반 상황에서도 동일한 메시지가 내려갈 수 있기 때문이다.</p>
<p>그래서 나는 이렇게 정리했다.</p>
<ul>
<li>정상적인 흐름에서는 findByLoginId() 검증이 먼저 중복을 잡아준다. 
이 검증은 사용자에게 구체적인 메시지를 전달하기 위한 1차 방어선이다.</li>
<li>그리고 전역 핸들러는, 동시에 요청이 겹치는 race condition 같은 예외적인 상황을 막아주는 최후의 안전망 역할을 한다. 즉 1차 방어선(Validator)이 사용자 경험을 책임지고 DB 제약은 데이터 무결성을 책임지며, 전역 핸들러는 예외적인 충돌 상황을 정리한다.</li>
</ul>
<p>완벽하게 모든 케이스를 구분해내는 구조는 아니지만,
현재 단계에서의 설계로는 충분히 합리적인 선택이라고 판단했다.</p>
<hr>
<p><strong>그래서 오늘의 내가 배운 것은?</strong></p>
<p>&quot;find~ 해서 디비에 있는 지 조회해서 막으면 되겠다.&quot;
“중복 방지하려면 unique 걸면 되지.”
“예외 터지면 try-catch로 잡으면 되지.”</p>
<p>모두 틀린 생각은 아니었지만,
질문을 하나 던질 때마다 놓치고 있는 부분들이 드러났다.</p>
<ul>
<li>검증 로직은 동시성 상황에서 안전하지 않다.</li>
<li>save()가 곧바로 insert 실행을 의미하지 않는다.</li>
<li>예외는 내가 생각한 위치에서 발생하지 않을 수 있다.</li>
<li>트랜잭션과 flush 타이밍을 이해하지 못하면, 예외 처리 전략도 정확히 세울 수 없다.</li>
</ul>
<p>단순히, &#39;아 중복아이디.. unique 걸자!&#39;에서 시작했던 이번 경험은,</p>
<blockquote>
<p>“이 예외는 언제 발생하는가?”
“내가 생각하는 책임의 위치는 정말 적절한가?”</p>
</blockquote>
<p>와 같은 질문도 스스로에게 해볼 수 있는 기회가 되었다.</p>
<p>회원가입 중복 처리라는 작은 기능이었지만,
그 안에는 동시성, 트랜잭션 경계, 영속성 컨텍스트, 예외 설계까지
여러 층위의 고민이 얽혀 있었다.</p>
<p>아직 배워야 할 것은 많지만,
그래도 이제는 코드를 작성하기 전에
이런 질문도 떠올려볼 수 있는 관점을 얻은 것 같다.</p>
<p><strong>“내가 기대하는 이 동작이 정말 그 시점에 일어날까?”</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[이번주에 클로드와 함께  개발하며 생각한 것들]]></title>
            <link>https://velog.io/@hey-sion/%EC%9D%B4%EB%B2%88%EC%A3%BC%EC%97%90-%ED%81%B4%EB%A1%9C%EB%93%9C%EC%99%80-%ED%95%A8%EA%BB%98-%EA%B0%9C%EB%B0%9C%ED%95%98%EB%A9%B0-%EC%83%9D%EA%B0%81%ED%95%9C-%EA%B2%83%EB%93%A4</link>
            <guid>https://velog.io/@hey-sion/%EC%9D%B4%EB%B2%88%EC%A3%BC%EC%97%90-%ED%81%B4%EB%A1%9C%EB%93%9C%EC%99%80-%ED%95%A8%EA%BB%98-%EA%B0%9C%EB%B0%9C%ED%95%98%EB%A9%B0-%EC%83%9D%EA%B0%81%ED%95%9C-%EA%B2%83%EB%93%A4</guid>
            <pubDate>Fri, 06 Feb 2026 06:30:07 GMT</pubDate>
            <description><![CDATA[<p><em>회원가입, 내 정보 조회, 비밀번호 변경하기 기능을 개발하면서
클로드에게 질문하고, 혼자 생각했던 내용을 정리한 기록</em></p>
<h4 id="1-tdd에서-red는-어떤-상태를-말할까">1. TDD에서 red는 어떤 상태를 말할까?</h4>
<p>예전에 언젠가 봤던 테스트 코드 강의에서 TDD는 red-green-refactor 패턴으로 하는게 정석(?)이라는 말을 들어본 기억이 났다. </p>
<p>그래, 그럼 빨간불을 볼 수 있는 <strong>테스트 코드 먼저</strong> 짜자!
회원가입 시 비밀번호를 암호화해서 저장하는 기능을 기준으로, 테스트 코드의 껍데기부터 만들어봤다.</p>
<pre><code>@Test
@DisplayName(&quot;회원가입시 비밀번호를 암호화해서 저장한다.&quot;)
void signUp_encryptsPassword() {
    // arrange

    // act

    // assert
}</code></pre><p>첫 번째 의문. 이 상태도 red인가? 위에 상태는 아무리 그래도 좀 아닌 것 같았다.</p>
<pre><code>@Test
@DisplayName(&quot;회원가입시 비밀번호를 암호화해서 저장한다.&quot;)
void signUp_encryptsPassword() {
    // arrange
        SignUpCommand command = new SignUpCommand(
                ...생략
        );

        // act
        signUpService.signUp(command);

        // assert
        User savedUser = userRepository.findByLoginId(&quot;testUser123&quot;).orElse(null);
        assertThat(savedUser).isNotNull();
        assertThat(savedUser.getPassword()).isNotEqualTo(&quot;ValidPass1!&quot;);
}</code></pre><p>두 번째 의문. 구현체들이 거의 없는 상태인데, 이걸 red라고 부를 수 있을까?</p>
<p>정리해보니, 내가 이해한 TDD에서의 red는
모든 클래스가 비어 있는 상태를 의미하는 게 아니라,</p>
<p>외부에서 호출하는 구조는 갖춰져 있지만,
테스트가 기대하는 내부 구현이 아직 존재하지 않아 실패하는 상태에 더 가까웠다.</p>
<p>즉, SignUpCommand나 signUpService 같은 진입점은 존재하되,
실제로 비밀번호를 암호화하는 로직은 아직 구현되지 않은 상태.
그 상태에서 테스트가 실패하는 것이 내가 받아들인 red였다.</p>
<h4 id="2-passwordencoder의-matches는-raw와-encoded값을-비교한다">2. PasswordEncoder의 matches는 raw와 encoded값을 비교한다.</h4>
<p>나만 몰랐던 내용일지도...
클로드에게 PasswordEncoder 사용해서 비밀번호 변경하는 기능을 구현하라고 시켰더니 아래처럼 코드를 짜주었는데,</p>
<pre><code> if (passwordEncoder.matches(command.newPassword(), user.getPassword())) {
        throw new CoreException(ErrorType.BAD_REQUEST, &quot;현재 비밀번호와 동일한 비밀번호로 변경할 수 없습니다.&quot;);
 }</code></pre><p>user에서 가져온 비밀번호는 앞서 구현한 정책에 따르면 이미 암호화된 비밀번호일텐데, 
raw 데이터를 encode해서 비교하도록 넘겨야 하는거 아닌가 하는 생각이 들었다. 바로 질문!
<img src="https://velog.velcdn.com/images/hey-sion/post/12be95a3-e494-42bb-9491-5d60c111819c/image.png" alt="">
내가 더이상 토달지 않을 거란걸 알고, 클로드가 &#39;아 그렇구나&#39;까지 미리 입력해놓은 것이 웃겼다. </p>
<h4 id="3-passwordpolicyvalidator를-static으로-두는-것에-대하여">3. PasswordPolicyValidator를 static으로 두는 것에 대하여</h4>
<p>비밀번호를 최초 생성/변경할 때는 세가지 정도의 요구사항이 있었다.</p>
<ol>
<li>8~16자의 영문/숫자/특수문자만 허용 </li>
<li>생년월일을 포함할 수 없음</li>
<li>(변경시에는) 현재 비밀번호 사용 불가</li>
</ol>
<p>사실 처음에는 회원가입 먼저 구현하면서 SignUpValidator를 생성했었고, private 메서드로 validatePassowrd()를 추가했었다. 그리고 그 메서드 내부에서 위 1,2번 사항을 검증한다.
그런데 비밀번호를 변경할 때도 비밀번호 검증이 필요하므로 별도의 클래스 분리는 불가피한 상황이 되었다.
_이래서 시간이 걸리더라도 차분히 시간을 갖고 설계를 먼저하는 것이 좋은 것 같다고 느꼈다... _</p>
<p>우선은 PasswordPolicyValidator의 validate()로 1,2번을 검증하는 로직을 이동시켰다.
그리고 3번 사항은 서비스 레이어에서 디비 조회를 통해 검증하도록 했다.</p>
<p>이제 끝?인가 하고 코드를 가만히 바라보니, 왠지 모르게 의존성 주입없이 PasswordPolicyValidator.validate() 이렇게 호출하고 싶은 충동(?)이 생겨났다. </p>
<p>나의 바보같은 질문도 한심해하지 않고 바라봐줄 클로드에게 질문을 던졌다.
어떤.. 성능.. 이런 개발자스러운 질문이 아니라 예쁠 것 같아서라니. 나 괜찮겠지?!
<img src="https://velog.velcdn.com/images/hey-sion/post/fafcf9d5-87d0-4f70-9982-f734134dddc3/image.png" alt=""></p>
<p>사실은 질문도 왠지모르게 했듯, 답변도 왠지 모르게 바꾸지 않는게 좋다고 할 줄 알았다.
그런데 static 변경에 대해 반대의견으로 말해준 내용들이 딱히 바로 납득이 되진 않았다.
<em>비밀번호 정책이 추가 혹은 변경된다면 그에 따라 검증 내용이 바뀌는 것인데, static으로 선언하는 것에 대한 답이 맞나? DB 조회가 필요한 검증은 지금도 해당 validator가 아닌 서비스 레이어에서 하고 있는데?</em></p>
<p>흠. 그래서 추가 질문. 
<img src="https://velog.velcdn.com/images/hey-sion/post/0c75c180-4db0-4e81-9701-912682b3b163/image.png" alt=""></p>
<p>&quot;응 바꿔줘&quot; 이번에도 내가 이쯤되면 바꾸라고 하겠다는걸 눈치챈 클로드이다.
변경 후 테스트를 다시 한 번 돌려서 확인했다. 성공!
하지만 찜찜함이 남았다. </p>
<p>그동안 나는 static 메서드는 클래스 로딩 시점에 메모리에 올라가고,
한 번 올라가면 내려가지 않는다는 이유로
막연히 “남용하면 메모리 낭비가 된다”는 인식을 가지고 있었다.</p>
<p>그래서 재사용성이 매우 높은 경우에만
static을 사용하는 것이 맞다고 생각해왔다.</p>
<p>이런 전제가 있었기 때문에,
“의존성 주입 없이 바로 호출할 수 있어서 코드가 예뻐 보인다”는 이유로 한
static 변경이 설계적으로 어떤 의미를 가지는지 확신할 수 없었다. 그래서 솔직하게 질문을 다시 던졌다.
<img src="https://velog.velcdn.com/images/hey-sion/post/78ca214f-682e-4052-9e36-e0cba47c1ee1/image.png" alt=""></p>
<p>중요했던 건 메모리 관점 자체보다도,
이 메서드가 어떤 성격을 가지는지 그 의도를 드러내는 일이었다.
실제로 JVM 환경에서 요청마다 객체를 생성하는 비용은 크게 문제가 되지 않는다고 한다.</p>
<p>PasswordPolicyValidator의 validate 메서드는</p>
<ul>
<li>상태를 가지지 않고</li>
<li>외부 의존성도 없으며</li>
<li>입력 값에 따라 결과만 결정되는 순수 함수에 가깝다.</li>
</ul>
<p>즉, 객체로 존재해야 할 이유가 없었다.</p>
<p>‘코드가 예뻐 보여서’라는 감각도 완전히 틀린 건 아니었지만,
그런 선택이 설계로서 의미를 가지려면 왜 그런 선택을 했는지 설명할 수 있어야 한다는 걸 배웠다.</p>
<p>아직 판단 기준이 완벽하다고 생각하지는 않는다.
그래도 이렇게 하나씩 이유를 찾아가다 보면
조금씩 더 나은 선택을 할 수 있지 않을까 싶다.</p>
<p>기죽지 말고, 정진하자!</p>
]]></description>
        </item>
    </channel>
</rss>