<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발.. 한놈만 팬다</title>
    <link>https://hy-ung.tistory.com/</link>
    <description>HY 's  coding study 일지   ✏️</description>
    <language>ko</language>
    <pubDate>Mon, 10 Aug 2026 10:37:52 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>h7ung</managingEditor>
    <image>
      <title>개발.. 한놈만 팬다</title>
      <url>https://tistory1.daumcdn.net/tistory/5757259/attach/80e0996c553243ad9382b3b8f2ba70c5</url>
      <link>https://hy-ung.tistory.com</link>
    </image>
    <item>
      <title>[LOOp:pak vol.4] 거부하고, 줄 세우고, 붙잡기 - 주문 대기열을 3층으로 방어하기</title>
      <link>https://hy-ung.tistory.com/219</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TL;DR &amp;mdash; 트래픽 폭증은 한 층으로 못 막는다. 누구를 들일지(Rate Limit) &amp;rarr; 얼마나 빨리 들일지(대기열) &amp;rarr; 기다리는 사람을 어떻게 붙잡을지(폴링 피드백), 세 질문에 각각 한 층씩. DB 커넥션 40개라는 바닥에서 거꾸로 설계했고, 부하 테스트로 검증했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;!-- ✏️ 편집 노트 (발행 전 삭제)
- 이미지 5장: 대화에서 각 다이어그램의 다운로드 버튼으로 SVG 저장
  ① traffic_defense_3layers_hero  ② failure_cascade_chain  ③ throughput_calc_flow(2026-07-10 재생성본 사용 — A안 반영)  ④ queue_state_transition  ⑤ measured_latency_tail_order_vs_position(부록)
  아래 ./images/... 경로는 플레이스홀더 — 블로그 업로드 경로로 교체
- 실측 반영 완료(2026-07-10), 1인칭 다듬기 완료, 부록(실측 증거+재현 스크립트) 추가
- 재현 자산 2개: ~/Downloads/round8-load-test.sh(원커맨드) + round8-order-load-test.js(k6). 발행 시 저장소 scripts/ 로 옮기고 부록의 경로를 상대경로로 조정 검토
- 남은 작업: 이미지 5장 업로드, (선택) 재현 스크립트 저장소 커밋
--&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 블랙 프라이데이의 산수&amp;nbsp; - 서버를 늘려도 안 되는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시나리오는 이렇다. 평소 초당 100건이던 주문이 행사 시작 직후 &lt;b&gt;초당 10,000건&lt;/b&gt;으로 폭증한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서버가 가진 것&lt;/p&gt;
&lt;table style=&quot;height: 77px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;자원&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;크기&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;출처&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Tomcat 워커 스레드&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;200&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;(설정값) &lt;code&gt;application.yml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;DB 커넥션 풀&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;40&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;(설정값) &lt;code&gt;jpa.yml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;PG 호출 read-timeout&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;2초 (재시도 포함 최대 약 7.5초)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;(설정값) openfeign &lt;code&gt;read-timeout&lt;/code&gt; &amp;middot; resilience4j retry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10,000 req/s가 들어오면 스레드 200개가 즉시 점유된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그중 DB 커넥션을 잡은 40개 말고는 전부 커넥션 풀 앞에서 대기하고, 유저 화면은 로딩만 돌다가 타임아웃 난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 유저는 새로고침을 누른다. 트래픽이 더 는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1532&quot; data-origin-height=&quot;804&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o1Sji/dJMcaiqruss/WFBlVGiT6kIKwfdCxCCkzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o1Sji/dJMcaiqruss/WFBlVGiT6kIKwfdCxCCkzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o1Sji/dJMcaiqruss/WFBlVGiT6kIKwfdCxCCkzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo1Sji%2FdJMcaiqruss%2FWFBlVGiT6kIKwfdCxCCkzK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1532&quot; height=&quot;804&quot; data-origin-width=&quot;1532&quot; data-origin-height=&quot;804&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;서버를 10배 늘리면 되지 않나?&quot;가 안 통하는 이유&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 서버는 늘릴 수 있어도 &lt;b&gt;DB와 PG는 스케일이 제한적&lt;/b&gt;이다. 피크가 10초짜리라 오토스케일링이 반응하기 전에 터진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;b&gt;하류가 감당하는 속도만큼만 상류를 흘려보내는 것&lt;/b&gt;(back-pressure)이 본질이고, 대기열은 그 구현체다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 세 개의 질문, 세 개의 층&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;Rate Limiting vs Queuing 중 뭘 쓸까&quot;로 고민을 시작했다. 그런데 설계를 끝내고 보니 이 둘은 대결이 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답하는 질문이 다른, 서로 다른 층이었다.&lt;/b&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;층&lt;/th&gt;
&lt;th&gt;질문&lt;/th&gt;
&lt;th&gt;수단&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1층 Rate Limit&lt;/td&gt;
&lt;td&gt;&lt;b&gt;누구를&lt;/b&gt; 들일 것인가&lt;/td&gt;
&lt;td&gt;봇&amp;middot;비정상은 429로 거부&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2층 대기열&lt;/td&gt;
&lt;td&gt;&lt;b&gt;얼마나 빨리&lt;/b&gt; 들일 것인가&lt;/td&gt;
&lt;td&gt;하류 처리량만큼만 입장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3층 폴링 피드백&lt;/td&gt;
&lt;td&gt;기다리는 사람을 &lt;b&gt;어떻게 붙잡을&lt;/b&gt; 것인가&lt;/td&gt;
&lt;td&gt;순번&amp;middot;예상 대기 시간&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;거부가 맞는 트래픽이 있고(줄 세워줄 가치가 없는 봇), 거부하면 안 되는 트래픽이 있다(블프에 429를 받은 유저는 떠나거나 더 세게 새로고침한다). 그리고 줄을 세웠다면, 줄이 &lt;b&gt;보여야&lt;/b&gt; 유저가 떠나지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1532&quot; data-origin-height=&quot;904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/chHFpO/dJMcai4Zp9K/XPdc24BRLWXcA6qm94OMJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/chHFpO/dJMcai4Zp9K/XPdc24BRLWXcA6qm94OMJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/chHFpO/dJMcai4Zp9K/XPdc24BRLWXcA6qm94OMJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FchHFpO%2FdJMcai4Zp9K%2FXPdc24BRLWXcA6qm94OMJ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1532&quot; height=&quot;904&quot; data-origin-width=&quot;1532&quot; data-origin-height=&quot;904&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 라운드에 구현한 것은 2층과 3층이다. 1층은 &lt;b&gt;위치만 설계&lt;/b&gt;했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 2층 대기열 &amp;mdash; 바닥에서 거꾸로 산정하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-1. 커넥션 40개에서 시작하는 산수&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습 자료의 예시는 &quot;풀 50 &amp;rarr; 250 TPS &amp;rarr; 안전마진 175 TPS&quot;였다. 처음엔 이 숫자를 그대로 가져다 쓰려 했는데, 우리 프로젝트 설정을 열어보니 풀부터 40으로 달랐다. 예시가 아니라 우리 설정으로 다시 계산했다.&lt;/p&gt;
&lt;pre class=&quot;fix&quot;&gt;&lt;code&gt;동시 처리 가능 수 = 커넥션 풀 크기
이론 최대 TPS    = 풀 &amp;divide; 커넥션 점유 시간&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;풀 40 (설정값) &amp;divide; 주문 1건 점유 200ms = &lt;b&gt;이론 200 TPS&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;이 나눗셈의 분모는 상수가 아니다. 주문 트랜잭션 안에는 재고 차감(행 락)이 있어서, 경합이 몰리는 순간 점유 시간이 200ms에 머물러 준다는 보장이 없다. 점유가 2배로 늘면 처리량은 절반 이론 200은 &quot;가장 좋은 날&quot;의 숫자다.&lt;/li&gt;
&lt;li&gt;그럼 PG 결제 호출은? 지난 라운드에서 &lt;b&gt;일부러 트랜잭션 밖으로 분리&lt;/b&gt;해뒀다. 결제건 저장(짧은 TX) &amp;rarr; PG 호출(TX 밖) &amp;rarr; 거래 키 반영(짧은 TX). 덕분에 PG가 느려져도 커넥션 40개는 무사하다. 대신 &lt;b&gt;스레드&lt;/b&gt;가 잡힌다. read-timeout 2초에 재시도 최대 3회가 겹치면 요청 하나가 스레드를 약 7.5초 (계산: 시도 2초&amp;times;3 + 백오프 0.5초+1초)까지 붙든다. 커넥션 풀과 스레드 풀은 &lt;b&gt;서로 다른 이유로 마르는 별개의 자원&lt;/b&gt;이고, 커넥션은 대기열이, 스레드는 타임아웃과 서킷브레이커가 지킨다.&lt;/li&gt;
&lt;li&gt;그래서 이론 200을 믿지 않고 &lt;b&gt;동시 활성 상한(targetActive)을 풀의 절반인 20&lt;/b&gt;으로 잡았다. 나머지 20개는 상품 조회, 결제 콜백의 몫이다.&lt;/li&gt;
&lt;li&gt;유효 처리율 &amp;asymp; 20 &amp;divide; 0.2s &amp;asymp; &lt;b&gt;100 TPS&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1250&quot; data-origin-height=&quot;914&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nqnRi/dJMcagzoCew/U6iacaehnHwZkjhywoqdC1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nqnRi/dJMcagzoCew/U6iacaehnHwZkjhywoqdC1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nqnRi/dJMcagzoCew/U6iacaehnHwZkjhywoqdC1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnqnRi%2FdJMcagzoCew%2FU6iacaehnHwZkjhywoqdC1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1250&quot; height=&quot;914&quot; data-origin-width=&quot;1250&quot; data-origin-height=&quot;914&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;200ms는&amp;nbsp;설계&amp;nbsp;시점의&amp;nbsp;가정이었고,&amp;nbsp;부하&amp;nbsp;테스트로&amp;nbsp;검증했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커넥션 점유는 &lt;b&gt;평균 200ms, 최대 1.14초 &lt;/b&gt;평균이&amp;nbsp;가정과&amp;nbsp;거의&amp;nbsp;정확히&amp;nbsp;맞았고,&amp;nbsp;대신&amp;nbsp;꼬리가&amp;nbsp;길었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최대가 평균의 5.7배라는 것이 &quot;분모는 상수가 아니다&quot;의 실물이다. 주문 요청만 보면 서버 평균 300ms 인데, 여기엔 트랜잭션이 파사드 전체를 감싸는 바람에 BCrypt 인증(4-2에서 계속)까지 커넥션을 문 채 도는 비용이 포함돼 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실측 평균 200ms로 계산하면 유효 처리율은 20 &amp;divide; 0.20 &amp;asymp; 100 TPS로 설정해둔 값과 맞아떨어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답 수치를 박제하는 것보다 라이브로 조정할 수 있는 구조가 중요하다고 판단해서, targetActive&amp;middot;tick 주기&amp;middot;처리율은 전부 설정 프로퍼티로 빼뒀다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-2. 공정함은 자료구조 한 글자에서 나온다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기열은 Redis Sorted Set 하나다. score = 진입 시각, member = userId.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;override fun enter(userId: Long, at: Instant) {
    // ZADD NX : 이미 대기 중인 member 면 score 를 갱신하지 않아 최초 순번이 유지된다.
    redis.opsForZSet().addIfAbsent(KEY, userId.toString(), at.toEpochMilli().toDouble())
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;add&lt;/code&gt;(ZADD)와 &lt;code&gt;addIfAbsent&lt;/code&gt;(ZADD NX) 이 한 글자 차이가 공정성 정책이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;add&lt;/code&gt;였다면 유저가 불안해서 새로고침(재진입)할 때마다 score가 갱신돼 &lt;b&gt;줄 맨 뒤로 밀린다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NX라서 &quot;이미 줄 선 사람은 자리를 지킨다&quot;. 중복 진입 방지는 Set의 member 유일성이 공짜로 따라온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순번 조회(ZRANK)는 &lt;b&gt;master에서&lt;/b&gt; 읽는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 RedisTemplate이 REPLICA_PREFERRED로 잡혀 있어서, 방금 ZADD한 값이 복제 지연으로 replica에 아직 없으면 내 순번이 있다가 없다가 널뛴다(read-your-write 문제).&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-3. 한 번에 100명? 아니, 100ms마다 부족분만&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순한 스케줄러는 &quot;1초마다 100명 입장&quot; 같은 고정 발급이다. 그러면 매초 100명이 &lt;b&gt;동시에&lt;/b&gt; 주문 API를 때린다 &amp;mdash; 원래 문제의 축소판이다(Thundering Herd). 게다가 하류가 소화 중인지는 보지도 않는다. 그래서 발급을 &quot;보충&quot; 방식으로 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun admitOnce() {
    val capacity = targetActive - entryTokenService.activeCount()
    if (capacity &amp;lt;= 0) return
    waitingQueueService.poll(capacity).forEach { userId -&amp;gt; /* 토큰 발급 */ }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;100ms(설정값)마다 &lt;code&gt;targetActive &amp;minus; 현재 활성&lt;/code&gt;만큼만 꺼낸다. 새는(만료&amp;middot;소비되는) 만큼만 다시 채우는 리키버킷 &amp;mdash; 유입이 아니라 &lt;b&gt;배출 속도가 일정&lt;/b&gt;해진다. 발급량이 하류 상태(활성 토큰 수)에 물려 있는 폐쇄 루프이기도 하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-4. 토큰은 주문의 시작에 죽는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과제 문구는 &quot;주문 완료 후 토큰 삭제&quot;였고, 처음엔 문구 그대로 구현할 생각이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 같은 토큰으로 &lt;b&gt;동시에 두 요청&lt;/b&gt;이 오는 경우를 그려보니, GET &amp;rarr; 비교 &amp;rarr; DEL 세 연산 사이의 틈으로 둘 다 통과했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1토큰 = 1주문이 깨지면 targetActive 계산 전체가 무너진다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;-- KEYS[1] = entry-token:{userId}, KEYS[2] = active-tokens
if redis.call('GET', KEYS[1]) == ARGV[1] then
    redis.call('DEL', KEYS[1])
    redis.call('ZREM', KEYS[2], ARGV[2])
    return 1
else
    return 0
end&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lua로 검증과 소비를 원자화했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대가도 있다. 주문 &lt;b&gt;시작&lt;/b&gt; 시점에 토큰이 소비되니, 재고 부족으로 주문이 실패해도 토큰은 이미 죽었다(DB 트랜잭션은 롤백돼도 Redis는 롤백되지 않는다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유저는 다시 줄을 서야 한다. back-pressure 정합성과 유저 편의가 충돌하는 지점인데, 정합성 쪽을 택했다. 실패한 주문도 하류 자원을 쓴 건 사실이니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GETDEL&lt;/code&gt;을 안 쓴 이유. 값을 비교하기 전에 무조건 지우기 때문에, 틀린 토큰을 들고 온 요청이 &lt;b&gt;남의 유효한 토큰을 지울 수 있다&lt;/b&gt;.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 3층 폴링 피드백 &amp;mdash; 기다리는 사람을 붙잡기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. 왜 SSE가 아니라 폴링부터인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순번 알림은 서버&amp;rarr;클라이언트 단방향이면 충분하다. WebSocket은 애초에 과했고, 후보는 폴링과 SSE.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSE는 순번이 바뀔 때만 push하니 낭비가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 &lt;b&gt;대기 인원 &amp;times; 1 커넥션&lt;/b&gt;을 유지해야 하고, 로드밸런서 뒤라면 연결 유지 설정도 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10,000명이 기다리면 커넥션 10,000개다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 톰캣의 max-connections 8192 부터 넘는다. 폴링은 투박하지만 인프라 변경이 0이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;폴링으로 시작하고, 폴링 부하가 문제로 측정되면 그때 SSE로 전환&lt;/b&gt;하기로 했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. 폴링의 산수 &amp;mdash; Redis는 버틴다, 문제는 다른 곳&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;10,000명 &amp;times; (1회 / 2초) = &lt;b&gt;5,000 QPS&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;순번 조회는 ZRANK, skiplist 구조라 O(log N). 이 정도로 Redis는 죽지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 문제는 우리 인증 구조에서 나왔다. 이 프로젝트는 매 요청 로그인 헤더를 &lt;b&gt;BCrypt로&lt;/b&gt; 검증한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BCrypt는 설계상 일부러 느린 해시다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재보니 &lt;b&gt;1회 75ms&lt;/b&gt; 부하를 걸자 순번 조회 요청은 평균 193ms까지 늘었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ZRANK 자체는 마이크로초 단위인데, 요청 시간의 대부분이 인증이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5,000 QPS로 환산하면 초당 375 CPU-초 &lt;b&gt;CPU 375코어어치 일&lt;/b&gt; 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기열이 줄이려던 부하를, 대기열의 피드백이 만들어내는 모순.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실측이 하나 더 알려줬다. 우리 authenticate는 읽기 트랜잭션 안에 있어서(클래스 레벨 &lt;code&gt;@Transactional&lt;/code&gt;), 유저를 SELECT한 커넥션이 &lt;b&gt;BCrypt가 도는 75ms 동안 반납되지 않는다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴링이 CPU만 태우는 게 아니라, 대기열이 지키려던 커넥션 풀까지 문다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 대기열 도메인 서비스는 userId만 받도록 경계를 잘랐다(인증은 파사드에만 둔다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 솔직하게 &lt;b&gt;현재 구현도 여전히 폴링마다 BCrypt가 돈다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조만 분리해둔 상태고, 폴링 전용 경량 인증(큐 세션 토큰 등)으로 바꾸는 게 다음 개선이다. 10,000명 규모라면 Redis보다 이쪽이 먼저 병목이 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-3. position = null 의 두 가지 의미&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입장 처리는 ZPOPMIN이라, 입장한 유저는 큐에서 &lt;b&gt;사라진다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 순번 조회는 &quot;없음&quot;을 돌려주는데 이게 &quot;입장하셨습니다&quot;인지 &quot;줄 선 적이 없는데요&quot;인지 구분되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 중간 단계까지는 입장 처리된 유저도 404를 받았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 응답을 상태 기반으로 바꿨다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상태&lt;/th&gt;
&lt;th&gt;조건&lt;/th&gt;
&lt;th&gt;응답&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WAITING&lt;/td&gt;
&lt;td&gt;큐에 있음&lt;/td&gt;
&lt;td&gt;순번(1-based) + 전체 대기 + 예상 대기 시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;READY&lt;/td&gt;
&lt;td&gt;큐에 없음 + 토큰 있음&lt;/td&gt;
&lt;td&gt;&lt;b&gt;입장 토큰&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;큐에도 토큰도 없음&lt;/td&gt;
&lt;td&gt;미진입 또는 입장 시간 만료&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상 대기 시간은 &lt;code&gt;순번 &amp;divide; 처리율(초당 100)&lt;/code&gt; 어디까지나 추정값이라 &quot;약 N초&quot;로 보여준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴링 응답에 토큰을 실어주면, 클라이언트는 같은 API 하나만 반복 호출하다가 READY를 받는 순간 주문으로 넘어가면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스케줄러는 큐에서 꺼낸 &lt;b&gt;다음&lt;/b&gt; 토큰을 발급하는데(pop-then-issue), 그 틈에 발급이 실패하면 유저는 큐에도 토큰에도 없는 상태. 정확히 404로 떨어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 404를 받으면 재진입하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유실 장애의 복구 경로가 상태 설계에서 공짜로 나온 셈이고, 대신 줄은 끝에서 다시 선다. 부하 테스트에서 이 복구가 실제로 작동하는 걸 봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진입 직후 스케줄러가 먼저 꺼내가는 레이스로 전체 요청의 약 7%가 순번을 찾지 못했지만, 전부 다음 폴링에서 READY로 복구됐고 주문 실패는 0건이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1282&quot; data-origin-height=&quot;652&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/prCk1/dJMcajv9GCr/vQfEAe8izOK6VnATVIFJa0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/prCk1/dJMcajv9GCr/vQfEAe8izOK6VnATVIFJa0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/prCk1/dJMcajv9GCr/vQfEAe8izOK6VnATVIFJa0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FprCk1%2FdJMcajv9GCr%2FvQfEAe8izOK6VnATVIFJa0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1282&quot; height=&quot;652&quot; data-origin-width=&quot;1282&quot; data-origin-height=&quot;652&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-4. SSE로 넘어가는 신호&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;순번 조회 QPS가 앱 서버를 압박할 때 위에서 본 대로 Redis보다 앱(인증)이 먼저 죽는다&lt;/li&gt;
&lt;li&gt;그 전 중간 단계로 &lt;b&gt;순번 구간별 동적 폴링&lt;/b&gt;이 있다: 앞 100명은 1초, 1,000번대는 3초, 그 뒤는 5초. 폴링을 유지한 채 부하를 낮추는 선택지&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 1층 Rate Limit &amp;mdash; 위치만 설계했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직하게, 이번 라운드에서 1층은 구현하지 않았다. 하지만 어디에 서야 하는지는 정해뒀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ZADD NX가 막는 건 &quot;같은 userId의 중복 진입&quot;뿐이다. 봇, 비정상 폭주, 새로고침 폭풍은 &lt;b&gt;대기열 진입 API 자체&lt;/b&gt;를 때린다. 줄에 세워줄 가치가 있는 요청만 줄에 들어와야 줄이 공정하니, 이건 대기열 앞단에서 429로 거부할 트래픽이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 마지막 안전장치 &amp;mdash; &lt;b&gt;주문 API 자체의 rate limit&lt;/b&gt;. 대기열이 뚫리거나(버그, 장애) 토큰 발급이 과해져도 하류를 지키는 최후의 층. 대기열은 피크를 &lt;b&gt;평탄화&lt;/b&gt;할 뿐, 부하를 없애주지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 회고 &amp;mdash; 층은 서로를 지킨다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;3층(피드백)이 없으면&lt;/b&gt; 유저가 새로고침을 누르고, 그 폭풍이 1층을 때린다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;1층(거부)이 없으면&lt;/b&gt; 봇이 2층의 줄을 오염시키고, 정상 유저의 대기가 늘어난다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;2층(대기열)이 없으면&lt;/b&gt; DB 커넥션 40개가 마르고, 전부 죽는다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Rate Limiting vs Queuing&quot;, &quot;Polling vs SSE&quot;를 &lt;i&gt;뭐가 더 나은가&lt;/i&gt;로 물었을 때는 교과서 같은 답밖에 안 나왔다. &lt;b&gt;&quot;우리 시스템에서 이 층이 답해야 하는 질문이 무엇인가&quot;&lt;/b&gt; 로 바꾸고 나서야 각자의 자리가 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;*실측은 이렇게 했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본문의 (실측) 수치가 어디서 나왔는지, 검산할 수 있게 날것을 남긴다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;환경과 방법&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;머신&lt;/b&gt;: 맥북(로컬), 인프라는 Docker &amp;mdash; MySQL 8.0, Redis(master/replica), 앱은 &lt;code&gt;bootRun&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;부하&lt;/b&gt;: &lt;a href=&quot;https://k6.io&quot;&gt;k6&lt;/a&gt; &amp;mdash; VU 20 &amp;times; 60초. 각 VU가 실제 유저 플로우를 그대로 돈다: &lt;code&gt;POST /queue/enter&lt;/code&gt; &amp;rarr; &lt;code&gt;GET /queue/position&lt;/code&gt; 폴링(0.5초 간격) &amp;rarr; READY 받으면 &lt;code&gt;POST /orders&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;왜 VU 20인가&lt;/b&gt;: &lt;code&gt;targetActive&lt;/code&gt;와 같은 값. 대기열 게이트 자체가 병목이 되지 않는, &quot;20명이 계속 주문을 밀어넣는&quot; 정상 통과 상태를 재현하려는 것&lt;/li&gt;
&lt;li&gt;&lt;b&gt;재고&lt;/b&gt;: 상품 1개에 1억 개 &amp;mdash; 재고 부족이 변수가 되지 않게&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;직접 재현하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글의 숫자는 박제된 캡처가 아니다. 아래 한 줄로 다시 돌릴 수 있다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;# 사전 준비: docker, k6, 그리고 redis(6379)
docker-compose -f docker/infra-compose.yml up -d redis-master redis-readonly

# 원커맨드: 임시 MySQL &amp;rarr; 앱 기동 &amp;rarr; 유저20+상품 시드
#          &amp;rarr; 커넥션 before 스냅샷 &amp;rarr; k6 60초 &amp;rarr; after 스냅샷
./round8-load-test.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;중요한 건, 돌릴 때마다 숫자가 다르다는 점이다.&lt;/b&gt; 같은 스크립트를 두 번 돌린 결과:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;1차&lt;/th&gt;
&lt;th&gt;2차&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;주문 완료&lt;/td&gt;
&lt;td&gt;1,076건&lt;/td&gt;
&lt;td&gt;847건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커넥션 점유 평균&lt;/td&gt;
&lt;td&gt;240ms&lt;/td&gt;
&lt;td&gt;439ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커넥션 점유 최대&lt;/td&gt;
&lt;td&gt;1230ms&lt;/td&gt;
&lt;td&gt;2788ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;주문 p95&lt;/td&gt;
&lt;td&gt;700ms&lt;/td&gt;
&lt;td&gt;1030ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 단일 노드라 그 순간 머신 사정에 따라 절대치가 갈린다. 하지만 두 번 다 &lt;b&gt;같은 결론&lt;/b&gt;이 나왔다 &amp;mdash; order 꼬리가 position보다 길고, 커넥션 점유 최대가 평균의 5~6배였고, 주문 실패는 0건이었다. 재현되는 건 특정 숫자가 아니라 이 성질이다. 그래서 아래 수치는 &quot;정답&quot;이 아니라 &quot;한 번의 관측&quot;으로 읽어야 맞다(1차 실행 기준).&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;커넥션 점유는 이렇게 유도했다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주목할 지표는 API 응답 시간이 아니라 &lt;b&gt;커넥션 점유 시간&lt;/b&gt;이다(3-1의 분모). HikariCP가 &lt;code&gt;hikaricp_connections_usage_seconds&lt;/code&gt;로 정확히 이걸 내보낸다 &amp;mdash; 커넥션을 풀에서 빌린 순간부터 반납까지. 부하 전후로 &lt;code&gt;/actuator/prometheus&lt;/code&gt;(관리 포트 8081)를 두 번 찍어 그 차이를 계산했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1990&quot; data-origin-height=&quot;196&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baksSw/dJMcajiBK5f/mkgQfXNhsCM80kBNpLiwjK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baksSw/dJMcajiBK5f/mkgQfXNhsCM80kBNpLiwjK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baksSw/dJMcajiBK5f/mkgQfXNhsCM80kBNpLiwjK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaksSw%2FdJMcajiBK5f%2FmkgQfXNhsCM80kBNpLiwjK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1990&quot; height=&quot;196&quot; data-origin-width=&quot;1990&quot; data-origin-height=&quot;196&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1990&quot; data-origin-height=&quot;196&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0CVaB/dJMcag0qaRf/SqKciGT2jqP8fM596ZwFQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0CVaB/dJMcag0qaRf/SqKciGT2jqP8fM596ZwFQk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0CVaB/dJMcag0qaRf/SqKciGT2jqP8fM596ZwFQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0CVaB%2FdJMcag0qaRf%2FSqKciGT2jqP8fM596ZwFQk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1990&quot; height=&quot;196&quot; data-origin-width=&quot;1990&quot; data-origin-height=&quot;196&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 스냅샷의 뺄셈이 곧 이번 부하 구간의 실측이다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;점유 건수 = 4190 &amp;minus; 25     = 4,165 회
점유 총합 = 833.191 &amp;minus; 2.45 = 830.741 초
평균 점유 = 830.741 / 4,165 = 0.200 초 = 200ms   &amp;larr; 본문 3-1
최대 점유 = 1.137 초 = 1137ms                     &amp;larr; 꼬리&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;sum &amp;divide; count&lt;/code&gt;는 내가 손댈 수 없는 카운터 두 개의 나눗셈이다. 200ms는 그렇게 나왔다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요청 레이턴시 &amp;mdash; k6 요약 원문&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2372&quot; data-origin-height=&quot;338&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfEW3O/dJMcaiKHVeJ/G6U2Yq8pDzoo9m8wL3ueB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfEW3O/dJMcaiKHVeJ/G6U2Yq8pDzoo9m8wL3ueB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfEW3O/dJMcaiKHVeJ/G6U2Yq8pDzoo9m8wL3ueB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfEW3O%2FdJMcaiKHVeJ%2FG6U2Yq8pDzoo9m8wL3ueB1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2372&quot; height=&quot;338&quot; data-origin-width=&quot;2372&quot; data-origin-height=&quot;338&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문(order)과 순번조회(position)의 백분위를 나란히 그리면 꼬리 길이 차이가 드러난다. order의&amp;nbsp;최대치(1130ms)가&amp;nbsp;중앙값(275ms)의&amp;nbsp;4.1배 이 변동이 &quot;커넥션 점유는 상수가 아니다&quot;의 실물이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1294&quot; data-origin-height=&quot;652&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/si7Gw/dJMcacw1E62/f1I4738kmb5BGUloMEsRXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/si7Gw/dJMcacw1E62/f1I4738kmb5BGUloMEsRXk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/si7Gw/dJMcacw1E62/f1I4738kmb5BGUloMEsRXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fsi7Gw%2FdJMcacw1E62%2Ff1I4738kmb5BGUloMEsRXk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1294&quot; height=&quot;652&quot; data-origin-width=&quot;1294&quot; data-origin-height=&quot;652&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;진입/조회 상태 코드 분해 (4-3의 404 복구 실증)&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;POST /queue/enter     200: 973건   500: 103건
GET  /queue/position  200: 1358건  404: 149건
POST /orders          200: 1076건  (실패 0건)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진입 500(103건)과 조회 404(149건)는 전부 &quot;enter 직후 스케줄러가 먼저 ZPOPMIN으로 꺼내간&quot; 레이스다. 전체 요청의 약 7%가 순번을 못 찾았지만 &lt;b&gt;주문 실패는 0건&lt;/b&gt;&amp;nbsp;전부 다음 폴링에서 READY로 복구됐다. 404가 에러가 아니라 복구 경로라는 것의 실측 증거다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;BCrypt&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;BCrypt matches: 평균 75ms/회  (strength 10, 50회 측정, 무부하)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 &lt;code&gt;BCryptPasswordEncoder()&lt;/code&gt;의 strength가 10이라, 운영과 같은 조건으로 쟀다. 무부하에서 75ms인데, 4-2에서 본 대로 부하가 걸리면 순번 조회 요청 전체가 평균 227ms까지 늘어난다.&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/219</guid>
      <comments>https://hy-ung.tistory.com/219#entry219comment</comments>
      <pubDate>Fri, 10 Jul 2026 13:53:48 +0900</pubDate>
    </item>
    <item>
      <title>[LOOp:pak vol.4] 100장 한정 쿠폰에 1만 명이 동시에 요청하면?</title>
      <link>https://hy-ung.tistory.com/218</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TL;DR &amp;mdash; 선착순 쿠폰의 동시성은 &quot;락으로 막는&quot; 문제라고 생각했는데, Kafka로 요청을 한 줄로 세우면 경쟁 자체를 없앨 수 있었다. 다만 큐로 직렬화해도 그걸로 끝이 아니었다. 직렬화는 &quot;전역 수량(100장)&quot;만 지키고, &quot;1인 1장&quot;은 다른 장치(UK)가 필요했다. 결국 직렬화 + UK + 원자적 수량 감소라는 세 방어선을 조합했고, 그 대가로 발급은 비동기가 되었다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;순진하게 짜보면&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cVMJS5/dJMcaglLGKx/nMncbDpK1vte6NMhnfUGw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cVMJS5/dJMcaglLGKx/nMncbDpK1vte6NMhnfUGw1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cVMJS5/dJMcaglLGKx/nMncbDpK1vte6NMhnfUGw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcVMJS5%2FdJMcaglLGKx%2FnMncbDpK1vte6NMhnfUGw1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선착순 100장 쿠폰. 제일 먼저 떠오른 코드는 이랬다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;1. 남은 수량 조회       (remaining = 100)
2. if remaining &amp;gt; 0 &amp;rarr; 발급
3. remaining -= 1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혼자 요청하면 잘 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 1만 명이 &lt;b&gt;동시에&lt;/b&gt; 1번을 실행할 때다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다 같이 &lt;code&gt;remaining = 100&lt;/code&gt;을 읽는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다 같이 &quot;발급 가능&quot;이라고 판단한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 100장이 아니라 수천 장이 나가버린다. 흔히 말하는 race condition이고, &quot;읽고 &amp;rarr; 판단하고 &amp;rarr; 쓰는&quot; 사이에 다른 요청이 끼어들 수 있다는 게 핵심이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전통적 해법: 락, 그리고 그 비용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제의 교과서적 답은 락이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;비관적 락&lt;/b&gt; (&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;): 읽는 순간 행을 잠근다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;낙관적 락&lt;/b&gt; (version + 재시도): 충돌하면 다시 시도한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;원자적 감소&lt;/b&gt; (&lt;code&gt;UPDATE remaining = remaining - 1 WHERE remaining &amp;gt; 0&lt;/code&gt;): 조건부 UPDATE 한 방으로 읽기-쓰기를 붙인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 곰곰이 보면, 1만 명이 &lt;b&gt;같은 한 행을 놓고&lt;/b&gt; 락을 다툰다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락 대기 행렬이 생기고, 뒤에 온 요청은 앞이 끝날 때까지 기다린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순간 트래픽이 몰릴수록 지연이 튀고 DB가 힘들어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;막는&quot; 방식은 본질적으로 경쟁을 전제로 하고, 경쟁이 심할수록 비싸진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;발상의 전환: 경쟁을 &quot;없앤다&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 관점을 바꿨다. 1만 명을 싸우게 하지 말고, &lt;b&gt;한 줄로 세우면 어떨까.&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;887&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdld8D/dJMb991jscf/jwk57lvpL4S82IAeIxPWtK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdld8D/dJMb991jscf/jwk57lvpL4S82IAeIxPWtK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdld8D/dJMb991jscf/jwk57lvpL4S82IAeIxPWtK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbdld8D%2FdJMb991jscf%2Fjwk57lvpL4S82IAeIxPWtK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1774&quot; height=&quot;887&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;887&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;1만 요청 &amp;rarr; coupon-issue-requests 토픽 (key = couponId)
           &amp;rarr; 같은 couponId = 같은 파티션
           &amp;rarr; 한 컨슈머가 한 번에 하나씩 순서대로 처리
           &amp;rarr; 앞에서 100개만 발급, 나머지 거절&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka에서 같은 key는 항상 같은 파티션으로 가고, 한 파티션은 컨슈머 그룹 안에서 &lt;b&gt;오직 한 컨슈머&lt;/b&gt;만 소비한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 같은 쿠폰의 발급 요청은 &lt;b&gt;완전히 순차적&lt;/b&gt;으로 처리된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순차적이면 동시성이 없다. 동시성이 없으면 지킬 락도 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;읽고 &amp;rarr; 판단하고 &amp;rarr; 쓰는&quot; 사이에 끼어들 다른 요청이 애초에 존재하지 않기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락으로 &lt;i&gt;막는&lt;/i&gt; 대신, 파티션 직렬화로 경쟁을 &lt;i&gt;없앤&lt;/i&gt; 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덤도 있다. API는 요청을 Kafka에 넣기만 하면 되니 응답이 빨라지고, 순간 폭주는 Kafka가 버퍼로 흡수한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;반전: 직렬화만으론 부족했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;순차 처리하면 다 되는 거 아냐?&quot;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;891&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MpHlq/dJMcafHePm2/1CwiVi6LUIhOm2FLtlsMCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MpHlq/dJMcafHePm2/1CwiVi6LUIhOm2FLtlsMCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MpHlq/dJMcafHePm2/1CwiVi6LUIhOm2FLtlsMCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMpHlq%2FdJMcafHePm2%2F1CwiVi6LUIhOm2FLtlsMCk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;891&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;891&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나도 그렇게 생각했다. 그런데 한 가지 구멍이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;한 사용자가 발급 버튼을 두 번 누르면&lt;/b&gt; 어떻게 될까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 2개 만들어진다(요청 ID는 다르고, userId&amp;middot;couponId는 같다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘 다 같은 파티션에서 순차 처리되고, 각각 &quot;남은 수량 &amp;gt; 0&quot;을 통과한다. 그리고 &lt;b&gt;이 사람이 2장을 받는다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 깨달았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;직렬화는 &quot;총 몇 장 나갔나(전역 수량)&quot;를 지켜주지만, &quot;이 사람이 몇 장 받았나(사용자별 유일성)&quot;는 안 지켜준다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서를 아무리 잘 지켜도 같은 사람의 두 요청은 둘 다 정상 요청처럼 보이기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서의 문제가 아니라 중복의 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 방어선을 나눴다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;전역 수량(100장)&lt;/b&gt; &amp;rarr; 파티션 직렬화 + 원자적 수량 감소&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자별 유일성(1인 1장)&lt;/b&gt; &amp;rarr; &lt;code&gt;(user_id, coupon_id)&lt;/code&gt; 유니크 제약(UK)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;중복 요청 자체&lt;/b&gt; &amp;rarr; 발급 요청의 상태(PENDING/SUCCESS/FAILED)를 멱등 판단 근거로&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처리 로직은 이렇게 정리됐다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
fun process(message: CouponIssueMessage) {
    val request = requestRepository.findByRequestId(message.requestId) ?: return
    // 1) 멱등: 이미 처리된 요청이면 재수신되어도 다시 처리하지 않는다
    if (request.status != PENDING) return
    // 2) 중복 요청: 이미 보유 중이면 수량을 소모하지 않고 성공 처리
    if (userCouponRepository.existsByUserIdAndCouponId(message.userId, message.couponId)) {
        request.markSuccess(); return
    }
    // 3) 전역 수량: 원자적 감소. 소진 시 선착순 마감
    if (!couponService.tryIssue(message.couponId)) {
        request.markFailed(&quot;SOLD_OUT&quot;); return
    }
    userCouponRepository.save(UserCouponModel(message.userId, message.couponId))
    request.markSuccess()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;tryIssue&lt;/code&gt;는 단일 컨슈머라 사실 락이 필요 없지만, 재시작이나 파티션 구성이 바뀌는 상황까지 대비해 &lt;b&gt;원자적 조건부 UPDATE를 백스톱으로&lt;/b&gt; 남겼다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;UPDATE coupons
SET issued_quantity = issued_quantity + 1
WHERE id = :id
  AND (issuable_quantity IS NULL OR issued_quantity &amp;lt; issuable_quantity)
-- 영향받은 행 1 = 발급 성공, 0 = 소진&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 &lt;b&gt;직렬화 + UK + 원자적 감소&lt;/b&gt;가 각자 다른 제약을 담당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나로 다 하려 하지 않고, 지켜야 할 것마다 담당 장치를 따로 둔 게 이번 설계의 핵심이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공짜는 아니었다: 비동기라는 대가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발급을 Kafka 뒤로 넘기니 API는 &quot;발급됐다/안 됐다&quot;를 즉시 답할 수 없게 됐다. 대신 접수증(&lt;code&gt;requestId&lt;/code&gt;)을 즉시 주고, 사용자는 결과를 따로 조회한다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;POST /coupons/{couponId}/issue-requests   &amp;rarr; { &quot;requestId&quot;: &quot;...&quot; }   (즉시)
GET  /coupons/issue-requests/{requestId}  &amp;rarr; PENDING &amp;rarr; SUCCESS / FAILED&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠른 응답을 얻은 대신 &lt;b&gt;결과의 즉시성&lt;/b&gt;을 내줬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 잠깐 PENDING을 보고 폴링해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이게 오히려 UX를 정직하게 만들기도 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;쿠폰함에 없음&quot;만으로는 &lt;i&gt;아직 처리 중&lt;/i&gt;인지 &lt;i&gt;마감돼서 실패&lt;/i&gt;인지 구분할 수 없는데, 명시적 상태를 두니 &quot;잠시만 기다려주세요&quot;와 &quot;아쉽게 마감됐어요&quot;를 다르게 보여줄 수 있었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;검증: 정말 100장만 나가는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;말로는 안심이 안 돼서 테스트로 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선착순 100장에 300명이 동시에 발급 처리를 하도록 했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;val coupon = couponService.register(..., issuableQuantity = 100)
repeat(300) { i -&amp;gt; requestService.create(&quot;req-$i&quot;, (i + 1).toLong(), coupon.id) }

// 300명 동시 처리
val executor = Executors.newFixedThreadPool(32)
repeat(300) { i -&amp;gt;
    executor.submit { processor.process(CouponIssueMessage(&quot;req-$i&quot;, (i + 1).toLong(), coupon.id)) }
}
// ...

assertThat(success).isEqualTo(100)   // 정확히 100장
assertThat(failed).isEqualTo(200)    // 나머지는 SOLD_OUT
assertThat(reloaded.issuedQuantity).isEqualTo(100L)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확히 100장. 초과 발급 없음. 원자적 조건부 UPDATE의 행 잠금이 동시 감소를 직렬화해준 덕이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;삽질 한 스푼&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 이 테스트가 통과하지 않았다. 발급 수량은 100으로 맞는데, &lt;b&gt;요청 상태가 전부 PENDING&lt;/b&gt;으로 남아 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 수량 감소용 벌크 쿼리에 붙인 &lt;code&gt;@Modifying(clearAutomatically = true)&lt;/code&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 옵션이 영속성 컨텍스트를 통째로 비우면서, 앞서 로드해둔 요청 엔티티를 detach시켜버린 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;markSuccess()&lt;/code&gt;/&lt;code&gt;markFailed()&lt;/code&gt;가 아무 데도 반영되지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;clearAutomatically&lt;/code&gt;를 떼어내니 해결됐다. JPA의 벌크 연산과 영속성 컨텍스트의 관계를 몸으로 배운 순간이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선착순을 처음엔 &quot;얼마나 튼튼한 락을 걸까&quot;의 문제로 봤는데, 결국 &quot;어떻게 경쟁을 없앨까&quot;의 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 큐로 직렬화한 뒤에도 &quot;전역 수량&quot;과 &quot;사용자별 유일성&quot;은 다른 층위라서 서로 다른 장치가 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 Kafka라도 이번엔 순서가 생명이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(좋아요 수 집계처럼 증감만 하는 경우엔 순서가 뒤바뀌어도 결과가 같아 순서가 중요하지 않았는데, 선착순은 &quot;누가 먼저 왔나&quot;가 본질이라 정반대였다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도구가 같아도 무엇을 담느냐에 따라 요구가 이렇게 달라진다는 걸, 이번에 확실히 체감했다.&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/218</guid>
      <comments>https://hy-ung.tistory.com/218#entry218comment</comments>
      <pubDate>Fri, 3 Jul 2026 13:49:31 +0900</pubDate>
    </item>
    <item>
      <title>[LOOp:pak vol.4] 좋아요 순으로 정렬하자 서버가 하염없이 느려졌다</title>
      <link>https://hy-ung.tistory.com/217</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;발단 &amp;mdash; &quot;인기순으로 보여주세요&quot;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;상품 목록에 정렬 옵션을 추가했다. 최신순, 가격순, 그리고&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;좋아요순&lt;/b&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1781836333694&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;enum class ProductSort(val sort: Sort) {
    LATEST(Sort.by(Sort.Direction.DESC, &quot;createdAt&quot;)),
    PRICE_ASC(Sort.by(Sort.Direction.ASC, &quot;price&quot;)),
    LIKES_DESC(Sort.by(Sort.Direction.DESC, &quot;likeCount&quot;)),
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;최신순&amp;middot;가격순은 멀쩡했는데,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;좋아요순만 유독 느렸다.&lt;/b&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;데이터가 적을 땐 몰랐다가, 상품을 10만 건으로 늘리고 나서야 체감이 됐다.&lt;/span&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;여기서 &quot;처음엔 데이터가 적어서 못 느꼈다&quot;는 점이 핵심이다. 성능 문제는 모수가 커져야 드러난다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜&amp;nbsp;느릴까&amp;nbsp;&amp;mdash;&amp;nbsp;두&amp;nbsp;가지&amp;nbsp;비용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요는&amp;nbsp;원래&amp;nbsp;별도&amp;nbsp;테이블이다.&amp;nbsp;(user_id,&amp;nbsp;product_id)&amp;nbsp;조합으로&amp;nbsp;쌓인다.&amp;nbsp;그래서&amp;nbsp;&quot;좋아요&amp;nbsp;순&amp;nbsp;정렬&quot;을&amp;nbsp;순진하게&amp;nbsp;구현하면&amp;nbsp;이렇게&amp;nbsp;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781836767960&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT p.*, COUNT(l.id) AS like_count
FROM product p
LEFT JOIN likes l ON p.id = l.product_id
GROUP BY p.id
ORDER BY like_count DESC
LIMIT 20;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;여기엔 비용이 두 개나 겹친다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집계 비용 &amp;mdash; JOIN + GROUP BY로 매번 좋아요 수를 센다.&lt;br /&gt;정렬&amp;nbsp;비용&amp;nbsp;&amp;mdash;&amp;nbsp;센&amp;nbsp;값을&amp;nbsp;다시&amp;nbsp;정렬한다.&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집계는&amp;nbsp;비정규화로&amp;nbsp;없앨&amp;nbsp;수&amp;nbsp;있다.&amp;nbsp;상품&amp;nbsp;테이블에&amp;nbsp;like_count&amp;nbsp;컬럼을&amp;nbsp;두고,&amp;nbsp;좋아요&amp;nbsp;등록/취소&amp;nbsp;때&amp;nbsp;원자적으로&amp;nbsp;갱신하면&amp;nbsp;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781836826349&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 좋아요 수를 셀 필요 없이, 컬럼 하나로 정렬
@Column(name = &quot;like_count&quot;, nullable = false)
var likeCount: Long = 0L
    protected set&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781836850074&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 동시성 안전하게 증감 (음수 방지 가드 포함)
UPDATE products SET like_count = like_count + 1 WHERE id = :id AND deleted_at IS NULL;
UPDATE products SET like_count = like_count - 1 WHERE id = :id AND deleted_at IS NULL AND like_count &amp;gt; 0;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집계는&amp;nbsp;사라졌다.&amp;nbsp;그런데도&amp;nbsp;정렬은&amp;nbsp;여전히&amp;nbsp;남는다.&amp;nbsp;이번&amp;nbsp;글의&amp;nbsp;진짜&amp;nbsp;주인공은&amp;nbsp;이&amp;nbsp;정렬&amp;nbsp;비용이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;측정&amp;nbsp;환경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;추측 대신 숫자로 보기로 했다. 앱 스키마와 분리한 벤치 전용 테이블에 10만 건을 넣었다. 컬럼 값은 카디널리티가 다양하도록 분포시켰다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;- brand_id : 1~500 (카디널리티 높음)&lt;br /&gt;- status : ON_SALE 90% / SOLD_OUT 10% (카디널리티 낮음)&lt;br /&gt;- like_count : 0~9999 (다양)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;검증 쿼리는 실제 목록 조회와 같은 모양이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781837001121&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM products
WHERE deleted_at IS NULL AND status = 'ON_SALE' AND brand_id = 1
ORDER BY like_count DESC
LIMIT 20;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1차&amp;nbsp;시도&amp;nbsp;&amp;mdash;&amp;nbsp;복합&amp;nbsp;인덱스&amp;nbsp;(brand_id,&amp;nbsp;like_count)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브랜드로&amp;nbsp;거르고&amp;nbsp;좋아요로&amp;nbsp;정렬하니,&amp;nbsp;두&amp;nbsp;컬럼을&amp;nbsp;묶은&amp;nbsp;복합&amp;nbsp;인덱스가&amp;nbsp;자연스러웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781837078963&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Table(
    name = &quot;products&quot;,
    indexes = [
        Index(name = &quot;idx_products_brand_like&quot;, columnList = &quot;brand_id, like_count&quot;),
    ],
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;AS-IS&amp;nbsp;(인덱스&amp;nbsp;없음)&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2874&quot; data-origin-height=&quot;822&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bv48r6/dJMcahY60Lu/0tWwlPXYkbcoWEleh8jjf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bv48r6/dJMcahY60Lu/0tWwlPXYkbcoWEleh8jjf0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bv48r6/dJMcahY60Lu/0tWwlPXYkbcoWEleh8jjf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbv48r6%2FdJMcahY60Lu%2F0tWwlPXYkbcoWEleh8jjf0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2874&quot; height=&quot;822&quot; data-origin-width=&quot;2874&quot; data-origin-height=&quot;822&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;10만 행을 전부 읽고(&lt;/span&gt;Table scan&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;), 거기서 정렬(&lt;/span&gt;filesort&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;)한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;LIMIT 20&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;이 무색하다.&lt;/span&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;TO-BE&amp;nbsp;(복합&amp;nbsp;인덱스&amp;nbsp;적용)&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2458&quot; data-origin-height=&quot;652&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/skeka/dJMcaiXYmG4/qGgb968lYuZiWp6e2T2U7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/skeka/dJMcaiXYmG4/qGgb968lYuZiWp6e2T2U7K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/skeka/dJMcaiXYmG4/qGgb968lYuZiWp6e2T2U7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fskeka%2FdJMcaiXYmG4%2FqGgb968lYuZiWp6e2T2U7K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2458&quot; height=&quot;652&quot; data-origin-width=&quot;2458&quot; data-origin-height=&quot;652&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;filesort가&amp;nbsp;사라졌다.&amp;nbsp;인덱스가&amp;nbsp;이미&amp;nbsp;like_count&amp;nbsp;순으로&amp;nbsp;정렬돼&amp;nbsp;있으니,&amp;nbsp;거꾸로(Backward&amp;nbsp;index&amp;nbsp;scan)&amp;nbsp;20개만&amp;nbsp;읽으면&amp;nbsp;끝이다.&amp;nbsp;26.3ms&amp;nbsp;&amp;rarr;&amp;nbsp;0.09ms.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2458&quot; data-origin-height=&quot;560&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bK7Z8f/dJMcabq6e9v/ljrq67kHmnel5zARPflZNK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bK7Z8f/dJMcabq6e9v/ljrq67kHmnel5zARPflZNK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bK7Z8f/dJMcabq6e9v/ljrq67kHmnel5zARPflZNK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbK7Z8f%2FdJMcabq6e9v%2Fljrq67kHmnel5zARPflZNK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2458&quot; height=&quot;560&quot; data-origin-width=&quot;2458&quot; data-origin-height=&quot;560&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기까지는&amp;nbsp;&quot;인덱스&amp;nbsp;걸었더니&amp;nbsp;빨라졌다&quot;는&amp;nbsp;흔한&amp;nbsp;결말이다.&amp;nbsp;문제는&amp;nbsp;그&amp;nbsp;다음이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;반전&amp;nbsp;&amp;mdash;&amp;nbsp;전체&amp;nbsp;인기상품은&amp;nbsp;왜&amp;nbsp;아직도&amp;nbsp;느린가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;홈&amp;nbsp;화면엔&amp;nbsp;브랜드&amp;nbsp;구분&amp;nbsp;없는&amp;nbsp;&quot;전체&amp;nbsp;인기&amp;nbsp;상품&quot;도&amp;nbsp;있다.&amp;nbsp;즉&amp;nbsp;brand_id&amp;nbsp;조건이&amp;nbsp;빠진다.&lt;/p&gt;
&lt;pre id=&quot;code_1781843281712&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM products
WHERE deleted_at IS NULL AND status = 'ON_SALE'
ORDER BY like_count DESC
LIMIT 20;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;복합 인덱스가 있으니 당연히 빠를 줄 알았다. 그런데...&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2406&quot; data-origin-height=&quot;814&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bW1IX8/dJMcabLsGQy/ed3oMDghC6OAjqxtUudc0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bW1IX8/dJMcabLsGQy/ed3oMDghC6OAjqxtUudc0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bW1IX8/dJMcabLsGQy/ed3oMDghC6OAjqxtUudc0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbW1IX8%2FdJMcabLsGQy%2Fed3oMDghC6OAjqxtUudc0k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2406&quot; height=&quot;814&quot; data-origin-width=&quot;2406&quot; data-origin-height=&quot;814&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;다시&amp;nbsp;풀스캔에&amp;nbsp;filesort다.&amp;nbsp;이유는&amp;nbsp;복합&amp;nbsp;인덱스의&amp;nbsp;leftmost&amp;nbsp;원칙이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;(brand_id,&amp;nbsp;like_count)&amp;nbsp;인덱스는&amp;nbsp;전화번호부가&amp;nbsp;&quot;성&amp;nbsp;&amp;rarr;&amp;nbsp;이름&quot;&amp;nbsp;순으로&amp;nbsp;정렬된&amp;nbsp;것과&amp;nbsp;같다.&amp;nbsp;성(brand_id)을&amp;nbsp;알면&amp;nbsp;이름(like_count)&amp;nbsp;순으로&amp;nbsp;바로&amp;nbsp;찾을&amp;nbsp;수&amp;nbsp;있지만,&amp;nbsp;성을&amp;nbsp;모른&amp;nbsp;채&amp;nbsp;이름만으로&amp;nbsp;정렬하려&amp;nbsp;하면&amp;nbsp;전화번호부의&amp;nbsp;정렬은&amp;nbsp;아무&amp;nbsp;쓸모가&amp;nbsp;없다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;brand_id가&amp;nbsp;조건에&amp;nbsp;없으면,&amp;nbsp;그&amp;nbsp;뒤에&amp;nbsp;붙은&amp;nbsp;like_count의&amp;nbsp;정렬성은&amp;nbsp;살릴&amp;nbsp;수&amp;nbsp;없다.&amp;nbsp;복합&amp;nbsp;인덱스는&amp;nbsp;선두&amp;nbsp;컬럼이&amp;nbsp;조건에&amp;nbsp;잡혀야만&amp;nbsp;뒤&amp;nbsp;컬럼이&amp;nbsp;의미를&amp;nbsp;갖는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;2차&amp;nbsp;&amp;mdash;&amp;nbsp;단일&amp;nbsp;인덱스&amp;nbsp;(like_count)&lt;/span&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;전체&amp;nbsp;정렬을&amp;nbsp;위해선&amp;nbsp;like_count&amp;nbsp;자체가&amp;nbsp;선두인&amp;nbsp;인덱스가&amp;nbsp;필요했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781843563469&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;indexes = [
    Index(name = &quot;idx_products_brand_like&quot;, columnList = &quot;brand_id, like_count&quot;), // 브랜드별 정렬
    Index(name = &quot;idx_products_brand_price&quot;, columnList = &quot;brand_id, price&quot;),      // 브랜드별 가격순
    Index(name = &quot;idx_products_like_count&quot;,  columnList = &quot;like_count&quot;),           // 전체 인기순
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;TO-BE&amp;nbsp;(단일&amp;nbsp;like_count&amp;nbsp;인덱스)&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2306&quot; data-origin-height=&quot;774&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ch9SrB/dJMcaiKsrZt/bsiSBY7LXto2Y2D9bwEclK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ch9SrB/dJMcaiKsrZt/bsiSBY7LXto2Y2D9bwEclK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ch9SrB/dJMcaiKsrZt/bsiSBY7LXto2Y2D9bwEclK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fch9SrB%2FdJMcaiKsrZt%2FbsiSBY7LXto2Y2D9bwEclK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2306&quot; height=&quot;774&quot; data-origin-width=&quot;2306&quot; data-origin-height=&quot;774&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;41ms&amp;nbsp;&amp;rarr;&amp;nbsp;0.09ms.&amp;nbsp;이번에도&amp;nbsp;인덱스가&amp;nbsp;정렬&amp;nbsp;자체를&amp;nbsp;대체했다.&amp;nbsp;status/deleted_at&amp;nbsp;필터는&amp;nbsp;인덱스를&amp;nbsp;거꾸로&amp;nbsp;훑으며&amp;nbsp;행마다&amp;nbsp;걸러지는데,&amp;nbsp;ON_SALE이&amp;nbsp;90%라&amp;nbsp;20건을&amp;nbsp;채우기까지&amp;nbsp;읽는&amp;nbsp;행이&amp;nbsp;거의&amp;nbsp;없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리&amp;nbsp;&amp;mdash;&amp;nbsp;무엇을&amp;nbsp;배웠나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 인덱스는 &quot;컬럼&quot;이 아니라 &quot;쿼리 패턴&quot;에 건다. 같은 like_count 정렬이라도 브랜드 필터가 있느냐 없느냐에 따라 필요한 인덱스가 달랐다. 복합 인덱스 하나로 다 커버될 거란 가정이 틀렸다.&lt;br /&gt;- EXPLAIN의 Using filesort는 정렬이 인덱스를 못 탔다는 신호다. 이게 보이면 ORDER BY 컬럼이 인덱스 순서와 맞는지 의심한다.&lt;br /&gt;- 공짜는 아니다. 인덱스를 3개 두면 그만큼 쓰기(INSERT/UPDATE)가 느려지고 저장 공간을 쓴다. like_count는 좋아요마다 갱신되는 컬럼이라 더 그렇다. 그래서 &quot;전체 인기순&quot;이 정말 자주 쓰이는 화면인지 확인한 뒤에 단일 인덱스를 추가했다 &amp;mdash; 안 쓰이는 정렬을 위해 인덱스를 늘리는 건 손해다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국&amp;nbsp;성능&amp;nbsp;최적화는&amp;nbsp;&quot;빠르게&amp;nbsp;만드는&amp;nbsp;법&quot;이&amp;nbsp;아니라&amp;nbsp;&quot;어디까지&amp;nbsp;빠르게&amp;nbsp;할&amp;nbsp;가치가&amp;nbsp;있는가&quot;를&amp;nbsp;정하는&amp;nbsp;일에&amp;nbsp;가까웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/217</guid>
      <comments>https://hy-ung.tistory.com/217#entry217comment</comments>
      <pubDate>Fri, 19 Jun 2026 13:59:15 +0900</pubDate>
    </item>
    <item>
      <title>[LOOp:pak vol.4] 스투시 반팔티를 10명이 동시에 주문하면 생기는 일</title>
      <link>https://hy-ung.tistory.com/216</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;`@Transactional`로 묶었다고 동시성이 해결되는 건 아니다. 그건 원자성(A) 얘기지 격리성(I) 얘기가 아니다. 재고 5개짜리 상품에 10명이 동시에 주문을 넣는 상황을 따라가며 비관적 락, 낙관적 락, 그리고 &quot;사실 읽을 필요도 없는&quot; 원자적 UPDATE까지 저울질한 기록.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시작은&amp;nbsp;단순한&amp;nbsp;착각이었다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 로직을 짜면서 처음엔 이렇게 생각했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;재고 차감이랑 주문 생성을 @Transacional 로 묶었으니, 동시에 주문이 들어와도 알아서 잘 처리하겠지&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션이 ACID 를 보장하니깐 동시성도 트랜잭션이 잘 막아줄 것이라고 막연히 믿었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혼자 주문할 땐 이 믿음이 안깨진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;읽고 -&amp;gt; 확인하고 -&amp;gt; 줄이고 -&amp;gt; 커밋&lt;/i&gt;&amp;nbsp; 읽기와 쓰기가 딱 붙어 있어서 문제가 생길 틈이 안보인다. 검증할 상황 자체가 안오니깐.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 &quot;10명이 동시에&quot;를 그려보려고 손님을 한명 더 붙여봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;손님 A 가 재고를 읽고 아직 줄이기 전인 그 찰나에, 손님 B 도 같은 재고를 읽는다. 둘 다 &quot;재고 있음&quot;을 확인하고 통과한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 순간 알았다. &lt;i&gt;&lt;b&gt;@Transactional&lt;/b&gt;&lt;/i&gt; 이 지켜주는 건 &lt;i&gt;내 트랜잭션 안이지, 남이 끼어드는 것이 아니었다&lt;/i&gt;. 혼자일 땐 붙어 있는 것처럼 보이던 읽기와 쓰기 사이의 틈이, 두 명을 겹쳐 놓으니 그제야 드러났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 착각이 깨지고, 대안을 하나씩 저울질해간 과정을 따라간 기록이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;트랜잭션이 보장하는 것과 보장하지 않는 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 주문 하나를 쪼개봤다. 한 줄처럼 보이는 주문도 사실 여러 작업이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780811704902&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BEGIN;
SELECT stock FROM product WHERE id = 1;      -- (1) 재고 읽기
-- 애플리케이션에서 if (stock &amp;gt; 0) 체크         -- (2) 확인
UPDATE product SET stock = stock - 1 ...;     -- (3) 차감
INSERT INTO orders ...;                        -- (4) 주문 생성
COMMIT;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션은 이 1~4번을 하나로 묶어, 중간에 실패하면 통째로 되돌린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3번까지 했는데 4번에서 서버가 죽어도, 재고만 깍이고 주문은 없는 유령 상태가 안생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기 까지는 트랜잭션이 확실하게 해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ACID 를 다시 보면 이렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- A (원자성): 다 되거나 다 안되거나&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- C (일관성): 트랜잭션 전후로 DB 규칙이 안깨짐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- I (격리성): 동시에 도는 트랜잭션들이 서로 간섭하지 않은 것처럼 보이게 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- D (지속성): 커밋된 건 서버가 죽어도 살아남음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 기대한 &quot;동시 주문이 알아서 처리되는 것&quot;은 원자성이 아니라 격리성의 영역이였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;격리성은 트랜잭션을 그냥 묶는다고 강하게 보장되는 게 아니다. 이게 첫번째 깨달음이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제로 뭐가 터지는지 그려봤다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고 5개, 동시 주문 10명.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각자 위의 1~4번을 자기 트랜잭션으로 돌리다고 하고, 두명만 떼어내 타임라인을 그려봤다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;909&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CDwZo/dJMcagFPjvS/ZvCqUNOKj2KUZAAN8YmYTK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CDwZo/dJMcagFPjvS/ZvCqUNOKj2KUZAAN8YmYTK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CDwZo/dJMcagFPjvS/ZvCqUNOKj2KUZAAN8YmYTK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCDwZo%2FdJMcagFPjvS%2FZvCqUNOKj2KUZAAN8YmYTK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;909&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;909&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 t2와 t4이다. A와 B 둘다 &quot;남이 차감하기 전의 재고 5&quot;를 읽었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘다 5-1=4 를 계산했고, B 의 UPDATE 가 A 의 결과를 덮어썼다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A 가 깎은 1개가 증발한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10명으로 늘리면, 운 나쁘면 10명 전무 stock = 5 를 읽고 전부 통과해서 재고 5개짜리를 10명에게 팔아버린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Lost Update (갱신 손실) &amp;mdash; &quot;읽고 &amp;rarr; 계산하고 &amp;rarr; 쓰는(read-modify-write)&quot; 패턴에서, 읽은 값이 쓰는 시점엔 이미 낡아버려 한쪽의 &lt;br /&gt;갱신이 통째로 사라지는 현상.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서&amp;nbsp;처음&amp;nbsp;착각이&amp;nbsp;완전히&amp;nbsp;깨졌다.&amp;nbsp;위&amp;nbsp;두&amp;nbsp;트랜잭션은&amp;nbsp;각각&amp;nbsp;멀쩡히&amp;nbsp;&lt;b&gt;@Transactional&lt;/b&gt;로&amp;nbsp;묶여&amp;nbsp;있었다.&amp;nbsp;원자성도&amp;nbsp;지속성도&amp;nbsp;완벽했다.&amp;nbsp;그런데도&amp;nbsp;깨진&amp;nbsp;건&amp;nbsp;격리성이&amp;nbsp;약해서&amp;nbsp;서로의&amp;nbsp;중간&amp;nbsp;작업을&amp;nbsp;못&amp;nbsp;막았기&amp;nbsp;때문이다.&amp;nbsp;트랜잭션은&amp;nbsp;죄가&amp;nbsp;없다.&amp;nbsp;동시성은&amp;nbsp;별개의&amp;nbsp;문제다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;격리&amp;nbsp;수준을&amp;nbsp;올리면&amp;nbsp;되지&amp;nbsp;않을까&amp;nbsp;(두&amp;nbsp;번째&amp;nbsp;착각)&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;격리 수준이라는 게 있다던데, 그걸 높이면 막히는 거 아니야?&quot;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;격리 수준은 트랜잭션들이 서로 얼마나 격리되는지 정한 표이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;격리수준&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;막아주는 것&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;샐 수 있는 이상현상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;READ&amp;nbsp;UNCOMMITTED&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;(거의 없음)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Dirty&amp;nbsp;Read까지&amp;nbsp;다&amp;nbsp;샘&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;READ&amp;nbsp;COMMITTED&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Dirty&amp;nbsp;Read&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Non-repeatable&amp;nbsp;Read,&amp;nbsp;Phantom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;REPEATABLE&amp;nbsp;READ&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;+&amp;nbsp;Non-repeatable&amp;nbsp;Read&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Phantom&amp;nbsp;(표준상)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;SERIALIZABLE&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;전부&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;전부&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표만&amp;nbsp;보면&amp;nbsp;&quot;격리&amp;nbsp;수준을&amp;nbsp;더&amp;nbsp;높은&amp;nbsp;단계(REPEATABLE&amp;nbsp;READ나&amp;nbsp;SERIALIZABLE)로&amp;nbsp;올리면&amp;nbsp;lost&amp;nbsp;update도&amp;nbsp;막히겠네&quot;가&amp;nbsp;합리적인&amp;nbsp;추론이다.&amp;nbsp;그런데&amp;nbsp;막상&amp;nbsp;들여다보니&amp;nbsp;아니었다.&amp;nbsp;그&amp;nbsp;이유를&amp;nbsp;매장&amp;nbsp;상황으로&amp;nbsp;바꿔보면&amp;nbsp;분명해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스투시 매장에 재고 현황판이 하나 걸려 있고, 직원 A와 B가 각자 손님을 받는다고 하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문을 처리하는 직원이 하는 일은 두 가지다. 현황판을 본다(재고 확인), 그리고 새 숫자를 적는다(재고 차감).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;거의 동시에 손님을 받으면 이렇게 흘러간다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;957&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bV5qdI/dJMcaiKjkdk/iJn2Kv4g8FGCfYiZCTHyyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bV5qdI/dJMcaiKjkdk/iJn2Kv4g8FGCfYiZCTHyyk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bV5qdI/dJMcaiKjkdk/iJn2Kv4g8FGCfYiZCTHyyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbV5qdI%2FdJMcaiKjkdk%2FiJn2Kv4g8FGCfYiZCTHyyk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;957&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;957&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서&amp;nbsp;핵심은,&amp;nbsp;A도&amp;nbsp;B도&amp;nbsp;잘못&amp;nbsp;본&amp;nbsp;게&amp;nbsp;아니라는&amp;nbsp;점이다.&amp;nbsp;둘&amp;nbsp;다&amp;nbsp;봤을&amp;nbsp;때&amp;nbsp;진짜로&amp;nbsp;5였다.&amp;nbsp;읽기는&amp;nbsp;완벽하게&amp;nbsp;정확했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는&amp;nbsp;적기(쓰기)가&amp;nbsp;서로&amp;nbsp;덮어쓴&amp;nbsp;데&amp;nbsp;있다.&amp;nbsp;B가&amp;nbsp;적을&amp;nbsp;때&amp;nbsp;A가&amp;nbsp;이미&amp;nbsp;적어둔&amp;nbsp;4를&amp;nbsp;그냥&amp;nbsp;지워버린&amp;nbsp;것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데&amp;nbsp;격리&amp;nbsp;수준이라는&amp;nbsp;다이얼이&amp;nbsp;조절하는&amp;nbsp;건&amp;nbsp;오직&amp;nbsp;&quot;본다(읽기)&quot;뿐이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표에&amp;nbsp;있는&amp;nbsp;이상현상&amp;nbsp;세&amp;nbsp;개의&amp;nbsp;이름을&amp;nbsp;보면&amp;nbsp;전부&amp;nbsp;Dirty&amp;nbsp;Read,&amp;nbsp;Non-repeatable&amp;nbsp;Read,&amp;nbsp;Phantom&amp;nbsp;Read&amp;nbsp;&amp;mdash;&amp;nbsp;죄다&amp;nbsp;&quot;읽기&quot;다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉&amp;nbsp;격리&amp;nbsp;수준은&amp;nbsp;&quot;현황판을&amp;nbsp;볼&amp;nbsp;때&amp;nbsp;뭐가&amp;nbsp;보이게&amp;nbsp;할&amp;nbsp;거냐&quot;를&amp;nbsp;정하는&amp;nbsp;규칙이지,&amp;nbsp;&quot;적기끼리&amp;nbsp;부딪히는&amp;nbsp;것&quot;과는&amp;nbsp;다른&amp;nbsp;영역이다.&amp;nbsp;문제는&amp;nbsp;쓰기에&amp;nbsp;있는데&amp;nbsp;다이얼은&amp;nbsp;읽기만&amp;nbsp;만지니,&amp;nbsp;아무리&amp;nbsp;돌려도&amp;nbsp;안&amp;nbsp;풀린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;그럼&amp;nbsp;&quot;REPEATABLE&amp;nbsp;READ면&amp;nbsp;내가&amp;nbsp;본&amp;nbsp;값을&amp;nbsp;지켜준다던데?&quot;라는&amp;nbsp;의문이&amp;nbsp;남는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로&amp;nbsp;MySQL&amp;nbsp;InnoDB의&amp;nbsp;기본값이&amp;nbsp;REPEATABLE&amp;nbsp;READ인데도&amp;nbsp;위&amp;nbsp;상황은&amp;nbsp;그대로&amp;nbsp;일어난다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함정은&amp;nbsp;이&amp;nbsp;&quot;지켜준다&quot;의&amp;nbsp;뜻에&amp;nbsp;있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REPEATABLE&amp;nbsp;READ가&amp;nbsp;약속하는&amp;nbsp;건&amp;nbsp;&quot;A&amp;nbsp;눈에&amp;nbsp;보이는&amp;nbsp;화면을&amp;nbsp;끝까지&amp;nbsp;5로&amp;nbsp;유지해준다&quot;이지,&amp;nbsp;&quot;현황판의&amp;nbsp;진짜&amp;nbsp;내용을&amp;nbsp;지켜준다&quot;가&amp;nbsp;아니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B가&amp;nbsp;현황판을&amp;nbsp;진짜로&amp;nbsp;4로&amp;nbsp;바꿔놔도,&amp;nbsp;A&amp;nbsp;눈에는&amp;nbsp;친절하게&amp;nbsp;계속&amp;nbsp;5를&amp;nbsp;보여준다.&amp;nbsp;A를&amp;nbsp;&quot;5라고&amp;nbsp;적힌&amp;nbsp;옛날&amp;nbsp;화면&quot;에&amp;nbsp;가둬두는&amp;nbsp;셈이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;오히려&amp;nbsp;A는&amp;nbsp;&quot;내&amp;nbsp;화면은&amp;nbsp;5니까&amp;nbsp;5겠지&quot;&amp;nbsp;하고&amp;nbsp;4를&amp;nbsp;적어버린다.&amp;nbsp;읽기&amp;nbsp;일관성을&amp;nbsp;지켜주는&amp;nbsp;것과&amp;nbsp;쓰기&amp;nbsp;충돌을&amp;nbsp;막아주는&amp;nbsp;것은&amp;nbsp;전혀&amp;nbsp;다른&amp;nbsp;얘기다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;SERIALIZABLE까지&amp;nbsp;올리면&amp;nbsp;막히긴&amp;nbsp;한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;수준에선&amp;nbsp;직원이&amp;nbsp;현황판을&amp;nbsp;보는&amp;nbsp;순간부터&amp;nbsp;아예&amp;nbsp;판을&amp;nbsp;잡아버려서,&amp;nbsp;다른&amp;nbsp;직원이&amp;nbsp;동시에&amp;nbsp;보지&amp;nbsp;못하게&amp;nbsp;하기&amp;nbsp;때문이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만&amp;nbsp;이건&amp;nbsp;매장의&amp;nbsp;모든&amp;nbsp;조회를&amp;nbsp;다&amp;nbsp;줄&amp;nbsp;세우는&amp;nbsp;것과&amp;nbsp;같다.&amp;nbsp;동시성이&amp;nbsp;바닥으로&amp;nbsp;떨어지고&amp;nbsp;데드락도&amp;nbsp;잦아진다.&amp;nbsp;주문&amp;nbsp;한&amp;nbsp;줄&amp;nbsp;막자고&amp;nbsp;시스템&amp;nbsp;전체를&amp;nbsp;직렬화하는&amp;nbsp;건&amp;nbsp;과하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;두&amp;nbsp;번째&amp;nbsp;결론에&amp;nbsp;도달했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;격리&amp;nbsp;수준을&amp;nbsp;올리는&amp;nbsp;건&amp;nbsp;lost&amp;nbsp;update의&amp;nbsp;정답이&amp;nbsp;아니다.&amp;nbsp;격리&amp;nbsp;수준은&amp;nbsp;&quot;읽기의&amp;nbsp;일관성&quot;을&amp;nbsp;다루는&amp;nbsp;다이얼이고,&amp;nbsp;내&amp;nbsp;문제는&amp;nbsp;&quot;특정&amp;nbsp;쓰기가&amp;nbsp;덮어써지는&amp;nbsp;것&quot;이다.&amp;nbsp;연장이&amp;nbsp;애초에&amp;nbsp;안&amp;nbsp;맞았다.&amp;nbsp;내가&amp;nbsp;풀어야&amp;nbsp;할&amp;nbsp;건&amp;nbsp;&quot;이&amp;nbsp;한&amp;nbsp;행을&amp;nbsp;갱신하는&amp;nbsp;동안&amp;nbsp;끼어들지&amp;nbsp;못하게&amp;nbsp;하는&amp;nbsp;것&quot;이고,&amp;nbsp;그건&amp;nbsp;그&amp;nbsp;행을&amp;nbsp;콕&amp;nbsp;집어&amp;nbsp;제어하는&amp;nbsp;락&amp;nbsp;전략의&amp;nbsp;영역이다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;락 전략 1 &amp;mdash; 비관적 락: 일단 잠그고 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;비관적&amp;nbsp;락의&amp;nbsp;전제는&amp;nbsp;이렇다.&amp;nbsp;&quot;충돌은&amp;nbsp;자주&amp;nbsp;난다고&amp;nbsp;비관적으로&amp;nbsp;가정한다.&amp;nbsp;그러니&amp;nbsp;건드리기&amp;nbsp;전에&amp;nbsp;미리&amp;nbsp;잠가&amp;nbsp;남이&amp;nbsp;못&amp;nbsp;들어오게&amp;nbsp;한다.&quot;&lt;br /&gt;읽을&amp;nbsp;때부터&amp;nbsp;락을&amp;nbsp;거는&amp;nbsp;방식이다.&amp;nbsp;평범한&amp;nbsp;SELECT가&amp;nbsp;아니라&amp;nbsp;SELECT&amp;nbsp;...&amp;nbsp;FOR&amp;nbsp;UPDATE를&amp;nbsp;쓴다.&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1780813744970&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;  -- 이 행에 배타 락
-- if (stock &amp;gt; 0)
UPDATE product SET stock = stock - 1 WHERE id = 1;
INSERT INTO orders ...;
COMMIT;   -- 여기서 락 해제&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FOR&amp;nbsp;UPDATE가&amp;nbsp;붙으면&amp;nbsp;그&amp;nbsp;행에&amp;nbsp;배타&amp;nbsp;락이&amp;nbsp;걸리고,&amp;nbsp;락이&amp;nbsp;걸린&amp;nbsp;동안&amp;nbsp;다른&amp;nbsp;트랜잭션이&amp;nbsp;같은&amp;nbsp;행을&amp;nbsp;FOR&amp;nbsp;UPDATE로&amp;nbsp;읽으려&amp;nbsp;하면&amp;nbsp;앞&amp;nbsp;트랜잭션이&amp;nbsp;COMMIT할&amp;nbsp;때까지&amp;nbsp;멈춰서&amp;nbsp;기다린다.&amp;nbsp;타임라인이&amp;nbsp;이렇게&amp;nbsp;바뀐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uie5Q/dJMcahLsirG/wutYNVFdetmKLkkvaKiuwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uie5Q/dJMcahLsirG/wutYNVFdetmKLkkvaKiuwK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uie5Q/dJMcahLsirG/wutYNVFdetmKLkkvaKiuwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fuie5Q%2FdJMcahLsirG%2FwutYNVFdetmKLkkvaKiuwK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은&amp;nbsp;t4다.&amp;nbsp;B는&amp;nbsp;A가&amp;nbsp;끝날&amp;nbsp;때까지&amp;nbsp;아예&amp;nbsp;못&amp;nbsp;읽는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;B가&amp;nbsp;읽는&amp;nbsp;시점엔&amp;nbsp;이미&amp;nbsp;A가&amp;nbsp;깎은&amp;nbsp;최신값(4)을&amp;nbsp;보고,&amp;nbsp;lost&amp;nbsp;update가&amp;nbsp;원천&amp;nbsp;차단된다.&amp;nbsp;줄을&amp;nbsp;세운&amp;nbsp;것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만&amp;nbsp;대가가&amp;nbsp;있다.&amp;nbsp;한&amp;nbsp;행에&amp;nbsp;주문이&amp;nbsp;몰리면&amp;nbsp;모두&amp;nbsp;한&amp;nbsp;줄로&amp;nbsp;서서&amp;nbsp;처리량이&amp;nbsp;떨어진다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러&amp;nbsp;행을&amp;nbsp;서로&amp;nbsp;다른&amp;nbsp;순서로&amp;nbsp;잠그면&amp;nbsp;데드락이&amp;nbsp;생길&amp;nbsp;수&amp;nbsp;있어&amp;nbsp;&quot;락은&amp;nbsp;항상&amp;nbsp;같은&amp;nbsp;순서로&amp;nbsp;잡는다&quot;는&amp;nbsp;규칙이&amp;nbsp;따라온다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고&amp;nbsp;락&amp;nbsp;보유&amp;nbsp;시간은&amp;nbsp;곧&amp;nbsp;트랜잭션&amp;nbsp;길이라서,&amp;nbsp;FOR&amp;nbsp;UPDATE를&amp;nbsp;잡은&amp;nbsp;채&amp;nbsp;느린&amp;nbsp;작업(외부&amp;nbsp;API&amp;nbsp;호출&amp;nbsp;등)을&amp;nbsp;하면&amp;nbsp;줄&amp;nbsp;선&amp;nbsp;사람&amp;nbsp;전부가&amp;nbsp;그만큼&amp;nbsp;기다린다.&amp;nbsp;트랜잭션은&amp;nbsp;짧게&amp;nbsp;가져가야&amp;nbsp;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;락&amp;nbsp;전략&amp;nbsp;2&amp;nbsp;&amp;mdash;&amp;nbsp;낙관적&amp;nbsp;락:&amp;nbsp;일단&amp;nbsp;진행하고,&amp;nbsp;충돌나면&amp;nbsp;그때&amp;nbsp;처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적&amp;nbsp;락의&amp;nbsp;전제는&amp;nbsp;정반대다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;충돌은&amp;nbsp;드물다고&amp;nbsp;낙관적으로&amp;nbsp;가정한다.&amp;nbsp;미리&amp;nbsp;잠그지&amp;nbsp;말고&amp;nbsp;진행하되,&amp;nbsp;마지막에&amp;nbsp;'내가&amp;nbsp;읽은&amp;nbsp;뒤&amp;nbsp;누가&amp;nbsp;건드렸나'만&amp;nbsp;확인한다.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기서&amp;nbsp;짚고&amp;nbsp;넘어갈&amp;nbsp;게&amp;nbsp;있다.&amp;nbsp;낙관적&amp;nbsp;락은&amp;nbsp;사실&amp;nbsp;DB의&amp;nbsp;락이&amp;nbsp;아니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜&amp;nbsp;락을&amp;nbsp;거는&amp;nbsp;게&amp;nbsp;아니라&amp;nbsp;version&amp;nbsp;컬럼으로&amp;nbsp;충돌을&amp;nbsp;감지하는&amp;nbsp;애플리케이션&amp;nbsp;레벨&amp;nbsp;기법이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름에&amp;nbsp;'락'이&amp;nbsp;붙어&amp;nbsp;헷갈리기&amp;nbsp;쉬운&amp;nbsp;부분이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780813910718&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BEGIN;
SELECT stock, version FROM product WHERE id = 1;   -- 락 없이 읽음. stock=5, version=1
-- if (stock &amp;gt; 0)
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 1;     -- 내가 읽었던 version 조건
COMMIT;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은&amp;nbsp;WHERE&amp;nbsp;...&amp;nbsp;AND&amp;nbsp;version&amp;nbsp;=&amp;nbsp;1과,&amp;nbsp;이&amp;nbsp;UPDATE가&amp;nbsp;실제로&amp;nbsp;몇&amp;nbsp;행을&amp;nbsp;바꿨는지(affected&amp;nbsp;rows)&amp;nbsp;확인하는&amp;nbsp;데&amp;nbsp;있다.&lt;br /&gt;&lt;br /&gt;1이면 그 사이 아무도 안 건드린 것이다. =&amp;gt; 성공.&lt;br /&gt;0이면 누군가 먼저 version을 올린 것이다. 내가 읽은 값은 이미 낡았다. =&amp;gt; 충돌, 실패.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EUfJZ/dJMcagTklLN/eHkfgCuIt7dHjjzbzfujwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EUfJZ/dJMcagTklLN/eHkfgCuIt7dHjjzbzfujwK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EUfJZ/dJMcagTklLN/eHkfgCuIt7dHjjzbzfujwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEUfJZ%2FdJMcagTklLN%2FeHkfgCuIt7dHjjzbzfujwK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비관적&amp;nbsp;락과&amp;nbsp;결정적으로&amp;nbsp;다른&amp;nbsp;점은&amp;nbsp;B를&amp;nbsp;막지&amp;nbsp;않았다는&amp;nbsp;것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B는&amp;nbsp;자유롭게&amp;nbsp;진행했고,&amp;nbsp;마지막&amp;nbsp;UPDATE&amp;nbsp;순간에&amp;nbsp;&quot;내가&amp;nbsp;읽은&amp;nbsp;게&amp;nbsp;낡았다&quot;를&amp;nbsp;스스로&amp;nbsp;발견했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;B는&amp;nbsp;이제&amp;nbsp;다시&amp;nbsp;읽고&amp;nbsp;다시&amp;nbsp;시도하거나,&amp;nbsp;사용자에게&amp;nbsp;&quot;다시&amp;nbsp;시도해달라&quot;고&amp;nbsp;알려야&amp;nbsp;한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;충돌&amp;nbsp;후&amp;nbsp;재시도&amp;nbsp;로직이&amp;nbsp;낙관적&amp;nbsp;락의&amp;nbsp;핵심이자&amp;nbsp;부담이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780813983483&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 충돌 시 최대 3번까지 재시도
async function placeOrder(productId: number) {
  for (let attempt = 0; attempt &amp;lt; 3; attempt++) {
    const { stock, version } = await readProduct(productId);
    if (stock &amp;lt;= 0) throw new Error(&quot;품절&quot;);

    const affected = await db.query(
      `UPDATE product SET stock = stock - 1, version = version + 1
       WHERE id = ? AND version = ?`,
      [productId, version]
    );

    if (affected.rowCount === 1) return; // 성공
    await sleep(randomBackoff());        // 충돌 &amp;rarr; 잠깐 쉬고 재시도
  }
  throw new Error(&quot;주문 실패, 잠시 후 다시 시도해주세요&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락을&amp;nbsp;안&amp;nbsp;잡으니&amp;nbsp;대기가&amp;nbsp;없고,&amp;nbsp;충돌이&amp;nbsp;드문&amp;nbsp;상황에선&amp;nbsp;비관적&amp;nbsp;락보다&amp;nbsp;처리량이&amp;nbsp;높다.&amp;nbsp;데드락&amp;nbsp;걱정도&amp;nbsp;없다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만&amp;nbsp;충돌이&amp;nbsp;잦으면&amp;nbsp;이야기가&amp;nbsp;달라진다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다들&amp;nbsp;같은&amp;nbsp;version을&amp;nbsp;읽고&amp;nbsp;한&amp;nbsp;명만&amp;nbsp;성공,&amp;nbsp;나머지는&amp;nbsp;전부&amp;nbsp;실패&amp;nbsp;후&amp;nbsp;재시도,&amp;nbsp;재시도해도&amp;nbsp;또&amp;nbsp;충돌...&amp;nbsp;이런&amp;nbsp;재시도&amp;nbsp;폭풍이&amp;nbsp;일면&amp;nbsp;오히려&amp;nbsp;비관적&amp;nbsp;락보다&amp;nbsp;느려질&amp;nbsp;수&amp;nbsp;있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;둘&amp;nbsp;중&amp;nbsp;뭘&amp;nbsp;고를까&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;비관적 락&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;낙관적 락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;가정&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;충돌이 자주 난다&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;충돌이 드물다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;방식&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;미리 잠굼 (for update)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;나중에 감지 (version)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;충돌시&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;애초에 막아서 대기&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;실패 후 재시도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;진짜 DB 락인가&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;o (배타 락)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;x (앱 레벨 기법)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;유리한 상황&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;총돌 잦음 / 짧은 트랜잭션 / 꼭 성공해야 함&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;충돌 드묾 / 읽기 위주 / 대기 비용큼&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;주의점&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;대기, 데드락, 처리량 저하&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;재시도 폭풍, 재시도 로직 부담&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판단&amp;nbsp;기준을&amp;nbsp;두&amp;nbsp;개로&amp;nbsp;좁히면&amp;nbsp;이렇다.&lt;br /&gt;&lt;br /&gt;- 이 상품에 동시 주문이 진짜 몰리는가? 몰리면 비관적 락(낙관적으로 가면 재시도 지옥). 아니면 낙관적 락.&lt;br /&gt;- 충돌했을 때 &quot;다시 시도하세요&quot;가 자연스러운가? 게시글 동시 수정 같은 건 자연스럽다(낙관적에 적합). 결제나 재고처럼 조용히 정확하게 처리돼야 하는 건 대기를 감수하더라도 비관적이 안전한 경우가 많다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그런데 한정판이라면&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dIbRZN/dJMcaftj5Dh/oQ44Xdps4LGKbixiONhUd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dIbRZN/dJMcaftj5Dh/oQ44Xdps4LGKbixiONhUd0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dIbRZN/dJMcaftj5Dh/oQ44Xdps4LGKbixiONhUd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdIbRZN%2FdJMcaftj5Dh%2FoQ44Xdps4LGKbixiONhUd0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 의문이 들었다. &quot;스투시 한정판처럼 수천 명이 5개 재고에 몰리면, 비관적 락은 이들을 한 줄로 세워버리는데 &amp;mdash; 뒤쪽 사람은 끝없이 기다리는 거 아닌가?&quot;&lt;br /&gt;맞다.&amp;nbsp;그리고&amp;nbsp;사용자&amp;nbsp;경험&amp;nbsp;관점에서&amp;nbsp;최악은&amp;nbsp;&quot;실패&quot;가&amp;nbsp;아니라&amp;nbsp;&quot;언제&amp;nbsp;끝날지&amp;nbsp;모르는&amp;nbsp;대기&quot;다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런&amp;nbsp;폭발적&amp;nbsp;트래픽에서는&amp;nbsp;DB&amp;nbsp;락에&amp;nbsp;도달하기&amp;nbsp;전에&amp;nbsp;경쟁&amp;nbsp;자체를&amp;nbsp;줄이는&amp;nbsp;접근(인메모리&amp;nbsp;저장소에서&amp;nbsp;재고를&amp;nbsp;먼저&amp;nbsp;차감해&amp;nbsp;걸러내거나,&amp;nbsp;요청을&amp;nbsp;큐에&amp;nbsp;넣어&amp;nbsp;순서대로&amp;nbsp;처리하는&amp;nbsp;식)을&amp;nbsp;쓴다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은&amp;nbsp;&quot;될&amp;nbsp;사람은&amp;nbsp;빨리&amp;nbsp;되게,&amp;nbsp;안&amp;nbsp;될&amp;nbsp;사람은&amp;nbsp;빨리&amp;nbsp;알게&quot;다.&amp;nbsp;다만&amp;nbsp;이는&amp;nbsp;트랜잭션&amp;middot;락의&amp;nbsp;범위를&amp;nbsp;넘는&amp;nbsp;주제라&amp;nbsp;여기서는&amp;nbsp;방향만&amp;nbsp;짚고&amp;nbsp;넘어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마지막&amp;nbsp;의문&amp;nbsp;&amp;mdash;&amp;nbsp;재고&amp;nbsp;차감은&amp;nbsp;꼭&amp;nbsp;읽어야&amp;nbsp;할까&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비관적/낙관적을&amp;nbsp;비교하다&amp;nbsp;보니&amp;nbsp;더&amp;nbsp;근본적인&amp;nbsp;의문이&amp;nbsp;들었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;그런데 재고 차감은 애초에 읽을 필요가 있나?&quot;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lost&amp;nbsp;update의&amp;nbsp;원인은&amp;nbsp;&quot;읽고&amp;nbsp;&amp;rarr;&amp;nbsp;계산하고&amp;nbsp;&amp;rarr;&amp;nbsp;쓰는&quot;&amp;nbsp;패턴이라고&amp;nbsp;했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽기와&amp;nbsp;쓰기&amp;nbsp;사이에&amp;nbsp;틈이&amp;nbsp;생기고,&amp;nbsp;그&amp;nbsp;틈에&amp;nbsp;남이&amp;nbsp;끼어드는&amp;nbsp;게&amp;nbsp;문제였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면&amp;nbsp;그&amp;nbsp;틈&amp;nbsp;자체를&amp;nbsp;없애면&amp;nbsp;된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고&amp;nbsp;차감은&amp;nbsp;읽지&amp;nbsp;않고&amp;nbsp;DB가&amp;nbsp;원자적으로&amp;nbsp;계산하게&amp;nbsp;시킬&amp;nbsp;수&amp;nbsp;있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780814433566&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;UPDATE product
SET stock = stock - 1
WHERE id = 1 AND stock &amp;gt;= 1;   -- 재고가 1 이상일 때만 차감&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UPDATE&amp;nbsp;한&amp;nbsp;방으로&amp;nbsp;끝난다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WHERE&amp;nbsp;stock&amp;nbsp;&amp;gt;=&amp;nbsp;1&amp;nbsp;덕분에&amp;nbsp;재고가&amp;nbsp;0이면&amp;nbsp;0행이&amp;nbsp;바뀌고(품절),&amp;nbsp;UPDATE는&amp;nbsp;그&amp;nbsp;행에&amp;nbsp;자동으로&amp;nbsp;락을&amp;nbsp;잡고&amp;nbsp;원자적으로&amp;nbsp;실행되니&amp;nbsp;lost&amp;nbsp;update가&amp;nbsp;구조적으로&amp;nbsp;불가능하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽고-쓰기&amp;nbsp;사이의&amp;nbsp;틈이&amp;nbsp;없으니&amp;nbsp;끼어들&amp;nbsp;자리도&amp;nbsp;없다.&amp;nbsp;affected&amp;nbsp;rows가&amp;nbsp;0이면&amp;nbsp;품절로&amp;nbsp;처리하면&amp;nbsp;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;단순&amp;nbsp;카운터형&amp;nbsp;재고에는&amp;nbsp;이게&amp;nbsp;가장&amp;nbsp;깔끔하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만&amp;nbsp;차감&amp;nbsp;전에&amp;nbsp;복잡한&amp;nbsp;비즈니스&amp;nbsp;검증이&amp;nbsp;필요하거나,&amp;nbsp;재고&amp;nbsp;외&amp;nbsp;다른&amp;nbsp;필드도&amp;nbsp;함께&amp;nbsp;조건&amp;nbsp;검사해야&amp;nbsp;한다면&amp;nbsp;락&amp;nbsp;전략으로&amp;nbsp;돌아가야&amp;nbsp;한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;마지막&amp;nbsp;판단은&amp;nbsp;이&amp;nbsp;질문으로&amp;nbsp;귀결된다.&amp;nbsp;내&amp;nbsp;상황은&amp;nbsp;단순&amp;nbsp;카운터인가,&amp;nbsp;복잡한&amp;nbsp;검증이&amp;nbsp;필요한가.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리하며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 공부에서 가장 크게 바뀐 생각은 &quot;트랜잭션으로 묶으면 동시성이 해결된다&quot;는 믿음이 깨진 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션은 한 작업의 원자성&amp;middot;지속성을 보장하지만, 여러 작업이 동시에 부딪힐 때의 조율(격리성)은 격리 수준과 락이라는 별도의 도구로 풀어야 했다.&lt;br /&gt;&lt;br /&gt;그리고&amp;nbsp;락을&amp;nbsp;비교하다가&amp;nbsp;&quot;애초에&amp;nbsp;읽지&amp;nbsp;않으면&amp;nbsp;충돌도&amp;nbsp;없다&quot;는&amp;nbsp;데까지&amp;nbsp;와보니,&amp;nbsp;결국&amp;nbsp;정답은&amp;nbsp;하나로&amp;nbsp;고정되어&amp;nbsp;있지&amp;nbsp;않았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;충돌&amp;nbsp;빈도,&amp;nbsp;트랜잭션&amp;nbsp;길이,&amp;nbsp;검증&amp;nbsp;복잡도,&amp;nbsp;트래픽&amp;nbsp;규모에&amp;nbsp;따라&amp;nbsp;비관적&amp;nbsp;락이&amp;nbsp;맞을&amp;nbsp;때도,&amp;nbsp;낙관적&amp;nbsp;락이&amp;nbsp;맞을&amp;nbsp;때도,&amp;nbsp;락&amp;nbsp;없이&amp;nbsp;원자적&amp;nbsp;UPDATE&amp;nbsp;한&amp;nbsp;줄로&amp;nbsp;끝낼&amp;nbsp;때도&amp;nbsp;있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;글을&amp;nbsp;쓰는&amp;nbsp;지금도&amp;nbsp;&quot;내&amp;nbsp;케이스엔&amp;nbsp;뭐가&amp;nbsp;맞나&quot;는&amp;nbsp;결국&amp;nbsp;상황을&amp;nbsp;보고&amp;nbsp;판단할&amp;nbsp;문제라고&amp;nbsp;생각한다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/216</guid>
      <comments>https://hy-ung.tistory.com/216#entry216comment</comments>
      <pubDate>Sun, 7 Jun 2026 15:58:45 +0900</pubDate>
    </item>
    <item>
      <title>9.5초 &amp;rarr; 0.27초: 측정으로 시작한 API 성능 개선기</title>
      <link>https://hy-ung.tistory.com/215</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강의 목록 API 하나를 &quot;추측 대신 측정&quot;으로 파고들어&lt;br /&gt;캐싱 &amp;rarr; 구조 개선 &amp;rarr; 메모리까지 잡은 기록.&lt;br /&gt;Stack: Node.js &amp;middot; TypeScript &amp;middot; TypeORM(MySQL) &amp;middot; Redis &amp;middot; Vitest&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한눈에 보기&lt;/h2&gt;
&lt;table style=&quot;height: 167px;&quot; width=&quot;799&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;width: 190px;&quot;&gt;지표&lt;/th&gt;
&lt;th style=&quot;width: 213px;&quot; align=&quot;right&quot;&gt;Before&lt;/th&gt;
&lt;th style=&quot;width: 223px;&quot; align=&quot;right&quot;&gt;After&lt;/th&gt;
&lt;th style=&quot;width: 163px;&quot; align=&quot;center&quot;&gt;변화&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 190px;&quot;&gt;응답 시간(대형 케이스)&lt;/td&gt;
&lt;td style=&quot;width: 213px;&quot; align=&quot;right&quot;&gt;&lt;b&gt;9.5 s&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 223px;&quot; align=&quot;right&quot;&gt;&lt;b&gt;0.27 s&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot; align=&quot;center&quot;&gt;  &lt;b&gt;&amp;minus;97%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 190px;&quot;&gt;외부 인기도 API&lt;/td&gt;
&lt;td style=&quot;width: 213px;&quot; align=&quot;right&quot;&gt;7,000 ms&lt;/td&gt;
&lt;td style=&quot;width: 223px;&quot; align=&quot;right&quot;&gt;20 ms (캐시 히트)&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot; align=&quot;center&quot;&gt;  &lt;b&gt;&amp;minus;99.7%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 190px;&quot;&gt;요청당 객체 메모리&lt;/td&gt;
&lt;td style=&quot;width: 213px;&quot; align=&quot;right&quot;&gt;3,560 KB&lt;/td&gt;
&lt;td style=&quot;width: 223px;&quot; align=&quot;right&quot;&gt;53 KB&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot; align=&quot;center&quot;&gt;  &lt;b&gt;&amp;minus;98.5%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 190px;&quot;&gt;조립 객체 수&lt;/td&gt;
&lt;td style=&quot;width: 213px;&quot; align=&quot;right&quot;&gt;1,652개&lt;/td&gt;
&lt;td style=&quot;width: 223px;&quot; align=&quot;right&quot;&gt;20개&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot; align=&quot;center&quot;&gt;  &lt;b&gt;&amp;minus;98.8%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 20건을 주는 API가 매번 1,652건을 다 만들고 있었다. &amp;rarr; 20건만 만들도록 바꾼 이야기.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개선 여정 (요약)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2308&quot; data-origin-height=&quot;304&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/y95Y9/dJMb99Nml9p/667XOk7bOJ1dZzN3ro9vR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/y95Y9/dJMb99Nml9p/667XOk7bOJ1dZzN3ro9vR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/y95Y9/dJMb99Nml9p/667XOk7bOJ1dZzN3ro9vR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fy95Y9%2FdJMb99Nml9p%2F667XOk7bOJ1dZzN3ro9vR1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2308&quot; height=&quot;304&quot; data-origin-width=&quot;2308&quot; data-origin-height=&quot;304&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 측정부터 &amp;mdash; 구간별 타이밍&lt;/h2&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;const mark = (label) =&amp;gt; { marks[label] = now() - last; last = now(); };
// 각 await 뒤에 mark('단계')  &amp;rarr;  로그로 한 줄 출력&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상위 몇 구간이 대부분을 차지했다. 가장 무거워 보이는 곳부터 더 쪼개 봤다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 삽질 ① &amp;mdash; &quot;인덱스 없나?&quot; &amp;rarr; ❌&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티에 &lt;code&gt;product_id&lt;/code&gt; 인덱스 선언이 안 보여 풀스캔을 의심했다. 그런데 마이그레이션엔 이미:&lt;/p&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;UNIQUE KEY product_course_unique (product_id, course_id)  -- prefix로 product_id 커버&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;로 확인:&lt;/p&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;Index lookup on product_course (product_id=...)  actual time=..4.64  rows=1939&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;height: 80px;&quot; width=&quot;850&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;의심&lt;/th&gt;
&lt;th&gt;실제&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;인덱스 없는 풀스캔?&lt;/td&gt;
&lt;td&gt;❌ 인덱스 정상, SQL ~12ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;느린 진짜 이유&lt;/td&gt;
&lt;td&gt;✅ &lt;b&gt;1,939행을 객체로 만드는 비용&lt;/b&gt; (전송+ORM 하이드레이션)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  느린 게 SQL인지 행&amp;rarr;객체 변환인지는 전혀 다른 문제. EXPLAIN 한 줄로 갈린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 반전 &amp;mdash; 진범은 외부 인기도 API&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상품에 연결된 &lt;b&gt;전체 강의(1,652건)&lt;/b&gt; 의 인기도를 외부 API에서 가져오는데, 이 호출 하나가 응답 건수에 비례해 폭증했다  &lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;처리 건수: 1,652

인기도 API      ████████████████████████████████████  7,238 ms    83%
상품강의맵         ███  569 ms
강의 상세 조회      ██  462 ms
카테고리 조회        ▏  150 ms
점수/부착          ▏   90 ms
────────────────────────────────────────────────────
총합            ~8.7 s&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 건수에 비례(건수 &amp;uarr; &amp;rarr; 시간 &amp;uarr;). &lt;b&gt;다른 모든 구간을 합쳐도 외부 API 하나의 1/8.&lt;/b&gt; 나머지 최적화는 일단 곁가지였다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  가장 큰 레버부터. 7초짜리 외부 호출 앞에선 수십 ms 최적화는 의미가 작다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 개선 ① &amp;mdash; 외부 호출 Redis 캐싱&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API는 못 고치니 결과를 캐싱. 기존 캐시 서비스(같은 데이터 모양)를 재사용.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;const cached = await popularityCache.get(key);
const courses = cached ?? (await popularityClient.fetch(...)).data;
if (!cached) popularityCache.set(key, courses).catch(...);   // fire-and-forget&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;height: 79px;&quot; width=&quot;635&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;width: 210px;&quot;&gt;호출&lt;/th&gt;
&lt;th style=&quot;width: 211px;&quot; align=&quot;right&quot;&gt;외부 API&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&amp;nbsp;&lt;/th&gt;
&lt;th style=&quot;width: 207px;&quot; align=&quot;right&quot;&gt;전체&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 210px;&quot;&gt;1회차 (캐시 미스)&lt;/td&gt;
&lt;td style=&quot;width: 211px; text-align: center;&quot; align=&quot;right&quot;&gt;~7,000 ms&lt;/td&gt;
&lt;td style=&quot;width: 207px; text-align: center;&quot; align=&quot;right&quot;&gt;~9.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 210px;&quot;&gt;2회차 (캐시 히트)&lt;/td&gt;
&lt;td style=&quot;width: 211px; text-align: center;&quot; align=&quot;right&quot;&gt;&lt;b&gt;37 ms&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 207px; text-align: center;&quot; align=&quot;right&quot;&gt;&lt;b&gt;0.58 s&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 첫 요청은 여전히 느림(캐싱의 숙명) &amp;middot; 무효화&amp;middot;TTL은 기존 로직 재사용.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 개선 ② &amp;mdash; 페이지네이션 앞당기기 ⭐&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시 히트 0.58초의 남은 절반은 &lt;b&gt;&quot;1,652건 전부 조립 후 20건만 slice&quot;&lt;/b&gt; 구조였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;604&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dezazG/dJMcaiXOX9G/kZ3HEFZfKvqQQGKLlJ9E21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dezazG/dJMcaiXOX9G/kZ3HEFZfKvqQQGKLlJ9E21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dezazG/dJMcaiXOX9G/kZ3HEFZfKvqQQGKLlJ9E21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdezazG%2FdJMcaiXOX9G%2FkZ3HEFZfKvqQQGKLlJ9E21%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;770&quot; height=&quot;604&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;604&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 통찰:&lt;/b&gt; 정렬에 필요한 값은 &lt;code&gt;id&lt;/code&gt; &amp;middot; &lt;code&gt;popularity&lt;/code&gt; &amp;middot; &lt;code&gt;입장시각&lt;/code&gt; 셋뿐 &amp;rarr; 무거운 조립 없이 정렬 가능.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Phase A &amp;mdash; 전체를 가볍게 (정렬&amp;middot;total)
const tuples = ids.filter(visible).map(id =&amp;gt; ({ id, popularity, enteredAt }));
tuples.sort(compare);
const pageIds = tuples.slice(offset, offset + limit).map(t =&amp;gt; t.id);

// Phase B &amp;mdash; 페이지 20건만 무겁게
const [courses, categories] = await Promise.all([fetchFull(pageIds), fetchCategories(pageIds)]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;height: 96px;&quot; width=&quot;566&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구간&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Before&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;전체 1,652건 처리&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot; align=&quot;right&quot;&gt;349 ms&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot; align=&quot;right&quot;&gt;&lt;b&gt;58 ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체(캐시 히트)&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot; align=&quot;right&quot;&gt;0.58 s&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot; align=&quot;right&quot;&gt;&lt;b&gt;0.27 s&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;❓ 두 번 조회 = 중복? &amp;rarr; 아니다. A는 1,652건&amp;times;2컬럼(정렬용), B는 20건&amp;times;풀컬럼(조립용). 페이지는 A 정렬 후에야 정해져 합칠 수 없다. 겹치는 건 20행 재조회뿐.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 메모리 &amp;mdash; GC 노이즈를 피해 측정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;heapUsed&lt;/code&gt; 델타는 GC 탓에 &amp;minus;81 ~ +28MB로 요동 &amp;rarr; &lt;b&gt;결정적 지표&lt;/b&gt;(조립 배열의 JSON 바이트)로 측정.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const builtKB = JSON.stringify(builtArray).length / 1024;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Before  ████████████████████████████████████████  3,560 KB  (1,652개)
After   ▌                                              53 KB  (   20개)
                                                      &amp;asymp; 67&amp;times; &amp;darr;&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  GC 런타임에서 &quot;요청당 메모리&quot;는 heapUsed 델타보다 데이터 볼륨 프록시가 정직하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 테스트 우선 (+ 캐시 함정)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링 &lt;b&gt;전에&lt;/b&gt; &quot;응답 동일성&quot;(정렬&amp;middot;total&amp;middot;페이지) 테스트를 통과시켜 두고 시작.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 캐싱 도입 후 기존 테스트가 흔들림 &amp;mdash; &lt;b&gt;캐시 히트로 미소비된 &lt;code&gt;mockResolvedValueOnce&lt;/code&gt;가 누수&lt;/b&gt;되어 다음 테스트를 오염.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;beforeEach(async () =&amp;gt; {
  vi.restoreAllMocks();                 // 누수 spy 제거
  vi.spyOn(client, 'fetch').mockResolvedValue({ data: [...] });
  await cache.flush('popularity:cache:*');
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 스코어보드&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;응답시간   9.5s ████████████████████  &amp;rarr;  0.27s ▌        (&amp;minus;97%)
외부API    7.0s ███████████████       &amp;rarr;  0.02s ▏        (&amp;minus;99.7%)
메모리     3.5MB ██████████████        &amp;rarr;  53KB ▏        (&amp;minus;98.5%)&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 더 똑똑하게 짠 게 아니라, 꼭 필요한 만큼만 하도록 바꿨다.&lt;br /&gt;1,652건을 다 만들어 20건만 쓰던 걸 &amp;mdash; 20건만 만들도록.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>회고/우당탕 개발자 성장기</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/215</guid>
      <comments>https://hy-ung.tistory.com/215#entry215comment</comments>
      <pubDate>Fri, 5 Jun 2026 18:59:54 +0900</pubDate>
    </item>
    <item>
      <title>[LOOp:pak vol.4] 이 규칙은 도메인일까, 유스케이스일까 &amp;mdash; Service / Facade 의 진짜 분기 기준</title>
      <link>https://hy-ung.tistory.com/214</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;멈춘 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브랜드 도메인을 만들고 있었다. 요구사항은 단순했다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;어드민이 브랜드를 등록할 때, 동일한 이름의 soft-deleted 행이 있으면 새 INSERT 대신 그 행을 복구(restore)하라.&quot;&lt;br /&gt;&lt;br /&gt;설계&amp;nbsp;문서는&amp;nbsp;이&amp;nbsp;로직을&amp;nbsp;BrandAdminFacade.create&amp;nbsp;에&amp;nbsp;두라고&amp;nbsp;했다.&amp;nbsp;그대로&amp;nbsp;옮기다가&amp;nbsp;손이&amp;nbsp;멈췄다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780024691349&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BrandAdminFacade.create()
fun create(name: String): BrandInfo {
    val existing = brandRepository.findByName(name)        // &amp;larr; 왜 Facade 가 이걸 알지?
    return when {
        existing == null -&amp;gt; save(BrandModel(name))
        existing.deletedAt != null -&amp;gt; existing.restore()   // &amp;larr; 이게 진짜 &quot;어드민 흐름&quot; 인가?
        else -&amp;gt; throw CONFLICT
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔&amp;nbsp;그냥&amp;nbsp;컨벤션이&amp;nbsp;어긋난&amp;nbsp;느낌&amp;nbsp;정도라고&amp;nbsp;넘기려&amp;nbsp;했다.&amp;nbsp;설계&amp;nbsp;문서를&amp;nbsp;따랐으니&amp;nbsp;일단&amp;nbsp;진행하면&amp;nbsp;되겠지,&amp;nbsp;라고.&lt;br /&gt;&lt;br /&gt;그런데&amp;nbsp;손은&amp;nbsp;계속&amp;nbsp;멈춰&amp;nbsp;있었다.&amp;nbsp;어색함의&amp;nbsp;정체가&amp;nbsp;단순한&amp;nbsp;스타일&amp;nbsp;문제가&amp;nbsp;아닌&amp;nbsp;것&amp;nbsp;같았다.&amp;nbsp;책임&amp;nbsp;위치&amp;nbsp;자체가&amp;nbsp;어긋났다는&amp;nbsp;직감에&amp;nbsp;가까웠다.&amp;nbsp;그래서&amp;nbsp;한&amp;nbsp;단계&amp;nbsp;깊이&amp;nbsp;들어가&amp;nbsp;봤다.&lt;br /&gt;&lt;br /&gt;Service&amp;nbsp;에&amp;nbsp;둘까,&amp;nbsp;Facade&amp;nbsp;에&amp;nbsp;둘까.&amp;nbsp;답을&amp;nbsp;찾으려면&amp;nbsp;한&amp;nbsp;단계&amp;nbsp;위의&amp;nbsp;질문을&amp;nbsp;먼저&amp;nbsp;풀어야&amp;nbsp;했다.&lt;br /&gt;&lt;br /&gt;이건&amp;nbsp;도메인&amp;nbsp;규칙인가,&amp;nbsp;유스케이스인가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;두&amp;nbsp;단어의&amp;nbsp;경계&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1786&quot; data-origin-height=&quot;380&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDM9NY/dJMcahYSbp5/svtP80GuFcrWPv6hCaIKE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDM9NY/dJMcahYSbp5/svtP80GuFcrWPv6hCaIKE1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDM9NY/dJMcahYSbp5/svtP80GuFcrWPv6hCaIKE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDM9NY%2FdJMcahYSbp5%2FsvtP80GuFcrWPv6hCaIKE1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1786&quot; height=&quot;380&quot; data-origin-width=&quot;1786&quot; data-origin-height=&quot;380&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 실제 케이스에 대보면 한 가지 룰을 두고 둘 다 말이 되어 보일 때가 있다. auto-restore 가 정확히 그런 경우였다.&lt;br /&gt;&lt;br /&gt;도메인이라&amp;nbsp;보면:&amp;nbsp;&quot;Brand&amp;nbsp;의&amp;nbsp;이름은&amp;nbsp;영구&amp;nbsp;식별자다&quot;&amp;nbsp;는&amp;nbsp;Brand&amp;nbsp;개념의&amp;nbsp;본질.&lt;br /&gt;유스케이스라&amp;nbsp;보면:&amp;nbsp;&quot;어드민이&amp;nbsp;POST&amp;nbsp;로&amp;nbsp;등록&amp;nbsp;요청&amp;nbsp;시,&amp;nbsp;충돌하면&amp;nbsp;restore&amp;nbsp;한다&quot;&amp;nbsp;는&amp;nbsp;어드민의&amp;nbsp;행동&amp;nbsp;양식.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;둘&amp;nbsp;다&amp;nbsp;말이&amp;nbsp;됐다.&amp;nbsp;표&amp;nbsp;옆에&amp;nbsp;두&amp;nbsp;옵션을&amp;nbsp;놓고&amp;nbsp;한참을&amp;nbsp;갈팡질팡했다.&amp;nbsp;표가&amp;nbsp;답을&amp;nbsp;줄&amp;nbsp;거라&amp;nbsp;기대했는데,&amp;nbsp;표는&amp;nbsp;정의일&amp;nbsp;뿐&amp;nbsp;판별&amp;nbsp;기준은&amp;nbsp;아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다른&amp;nbsp;기준들도&amp;nbsp;거쳐봤다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;진입점&amp;nbsp;불변성&quot;이라는&amp;nbsp;기준에&amp;nbsp;도달하기&amp;nbsp;전에&amp;nbsp;몇&amp;nbsp;가지&amp;nbsp;다른&amp;nbsp;잣대를&amp;nbsp;시도했었다.&lt;br /&gt;&lt;br /&gt;&quot;상태가&amp;nbsp;있는가?&quot;&amp;nbsp;&amp;mdash;&amp;nbsp;auto-restore&amp;nbsp;는&amp;nbsp;상태&amp;nbsp;없는&amp;nbsp;연산이고,&amp;nbsp;Service&amp;nbsp;도&amp;nbsp;Facade&amp;nbsp;도&amp;nbsp;stateless&amp;nbsp;라&amp;nbsp;변별이&amp;nbsp;안&amp;nbsp;됐다.&lt;br /&gt;&quot;테스트하기&amp;nbsp;쉬운가?&quot;&amp;nbsp;&amp;mdash;&amp;nbsp;양쪽&amp;nbsp;모두&amp;nbsp;단위/통합으로&amp;nbsp;테스트&amp;nbsp;가능.&amp;nbsp;차이가&amp;nbsp;거의&amp;nbsp;없었다.&lt;br /&gt;&quot;재사용성이&amp;nbsp;높은가?&quot;&amp;nbsp;&amp;mdash;&amp;nbsp;다른&amp;nbsp;도메인이&amp;nbsp;부를&amp;nbsp;일이&amp;nbsp;적었다.&amp;nbsp;약한&amp;nbsp;신호.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;그러다&amp;nbsp;&quot;다른&amp;nbsp;진입점이&amp;nbsp;들어와도&amp;nbsp;같은&amp;nbsp;정책이어야&amp;nbsp;하나?&quot;&amp;nbsp;라는&amp;nbsp;질문을&amp;nbsp;던졌더니&amp;nbsp;답이&amp;nbsp;또렷해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;진입점&amp;nbsp;불변성이라는&amp;nbsp;한&amp;nbsp;줄&amp;nbsp;테스트&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;이 규칙은 진입점이 바뀌어도 같아야 하나?&quot;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기서 진입점이란 코드를 부르는 시작점 &amp;mdash; REST API, 배치 잡, 시드 스크립트, CLI, 혹은 미래에 생길지 모를 어떤 호출자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은&amp;nbsp;정책을&amp;nbsp;다른&amp;nbsp;진입점에서&amp;nbsp;호출했을&amp;nbsp;때&amp;nbsp;일관성이&amp;nbsp;유지되어야&amp;nbsp;하는지&amp;nbsp;묻는&amp;nbsp;거다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;692&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DQwCC/dJMcab5rbbw/oEEVF7h8gSbbSquz7OfRnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DQwCC/dJMcab5rbbw/oEEVF7h8gSbbSquz7OfRnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DQwCC/dJMcab5rbbw/oEEVF7h8gSbbSquz7OfRnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDQwCC%2FdJMcab5rbbw%2FoEEVF7h8gSbbSquz7OfRnk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1500&quot; height=&quot;692&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;692&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약&amp;nbsp;정책이&amp;nbsp;진입점마다&amp;nbsp;달라야&amp;nbsp;한다면&amp;nbsp;&amp;mdash;&amp;nbsp;예를&amp;nbsp;들어&amp;nbsp;어드민&amp;nbsp;화면에선&amp;nbsp;auto-restore,&amp;nbsp;배치는&amp;nbsp;무조건&amp;nbsp;CONFLICT&amp;nbsp;&amp;mdash;&amp;nbsp;이&amp;nbsp;규칙은&amp;nbsp;유스케이스의&amp;nbsp;일부다.&lt;br /&gt;&lt;br /&gt;반대로,&amp;nbsp;진입점이&amp;nbsp;늘어도&amp;nbsp;정책이&amp;nbsp;같아야&amp;nbsp;한다면&amp;nbsp;그&amp;nbsp;규칙은&amp;nbsp;진입점&amp;nbsp;위쪽&amp;nbsp;어딘가에&amp;nbsp;살아야&amp;nbsp;한다.&amp;nbsp;거기가&amp;nbsp;도메인이다.&lt;br /&gt;&lt;br /&gt;이&amp;nbsp;기준이&amp;nbsp;실용적인&amp;nbsp;이유는,&amp;nbsp;미래에&amp;nbsp;진입점이&amp;nbsp;추가될&amp;nbsp;때&amp;nbsp;일관성이&amp;nbsp;자동으로&amp;nbsp;보장되기&amp;nbsp;때문이다.&amp;nbsp;반대&amp;nbsp;상황을&amp;nbsp;그려보면&amp;nbsp;차이가&amp;nbsp;분명해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1098&quot; data-origin-height=&quot;692&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dkv0Ok/dJMcacDjG7p/Yw5glsFERjmJdeTDgYguE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dkv0Ok/dJMcacDjG7p/Yw5glsFERjmJdeTDgYguE1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dkv0Ok/dJMcacDjG7p/Yw5glsFERjmJdeTDgYguE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdkv0Ok%2FdJMcacDjG7p%2FYw5glsFERjmJdeTDgYguE1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1098&quot; height=&quot;692&quot; data-origin-width=&quot;1098&quot; data-origin-height=&quot;692&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유스케이스&amp;nbsp;레이어에&amp;nbsp;도메인&amp;nbsp;규칙을&amp;nbsp;두면,&amp;nbsp;진입점이&amp;nbsp;늘&amp;nbsp;때마다&amp;nbsp;룰이&amp;nbsp;분산된다.&amp;nbsp;누군가는&amp;nbsp;복붙하고,&amp;nbsp;누군가는&amp;nbsp;빠뜨린다.&amp;nbsp;시간이&amp;nbsp;지나면&amp;nbsp;어느&amp;nbsp;쪽이&amp;nbsp;정답인지도&amp;nbsp;모호해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;auto-restore&amp;nbsp;에&amp;nbsp;대입하기&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Q.&amp;nbsp;&quot;Brand&amp;nbsp;의&amp;nbsp;auto-restore&amp;nbsp;가&amp;nbsp;진입점&amp;nbsp;무관해야&amp;nbsp;하나?&quot;&lt;br /&gt;A.&amp;nbsp;YES&amp;nbsp;&amp;mdash;&amp;nbsp;누가&amp;nbsp;등록하든&amp;nbsp;&quot;같은&amp;nbsp;이름&amp;nbsp;=&amp;nbsp;같은&amp;nbsp;브랜드&quot;&amp;nbsp;라는&amp;nbsp;정책은&amp;nbsp;유지돼야&amp;nbsp;한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780025917845&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BrandService.register() &amp;mdash; 도메인 계층
@Transactional
fun register(name: String): BrandModel {
    val existing = brandRepository.findByNameIncludingDeleted(name)
    return when {
        existing == null -&amp;gt; brandRepository.save(BrandModel(name))
        existing.deletedAt != null -&amp;gt; existing.also { it.restore() }
        else -&amp;gt; throw CoreException(ErrorType.CONFLICT, &quot;이미 사용 중인 이름입니다.&quot;)
    }
}

// BrandAdminFacade.create() &amp;mdash; 응용 계층
fun create(name: String): BrandInfo =
    BrandInfo.from(brandService.register(name))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸&amp;nbsp;보고&amp;nbsp;솔직히&amp;nbsp;한&amp;nbsp;번&amp;nbsp;더&amp;nbsp;의심이&amp;nbsp;들었다.&amp;nbsp;&quot;Facade&amp;nbsp;가&amp;nbsp;한&amp;nbsp;줄&amp;nbsp;위임밖에&amp;nbsp;안&amp;nbsp;하면,&amp;nbsp;발제에서&amp;nbsp;까는&amp;nbsp;*Impl&amp;nbsp;안티패턴이랑&amp;nbsp;본질적으로&amp;nbsp;같은&amp;nbsp;거&amp;nbsp;아닌가?&quot;&lt;br /&gt;&lt;br /&gt;그래서&amp;nbsp;한&amp;nbsp;번&amp;nbsp;더&amp;nbsp;자문했다&amp;nbsp;&amp;mdash;&amp;nbsp;&quot;이&amp;nbsp;Facade&amp;nbsp;가&amp;nbsp;미래에도&amp;nbsp;비어&amp;nbsp;있을&amp;nbsp;게&amp;nbsp;확실한가?&quot;&amp;nbsp;답은&amp;nbsp;아니었다.&amp;nbsp;어드민&amp;nbsp;흐름엔&amp;nbsp;슬랙&amp;nbsp;알림이나&amp;nbsp;변경&amp;nbsp;이력&amp;nbsp;같은&amp;nbsp;부수효과가&amp;nbsp;들어올&amp;nbsp;자리가&amp;nbsp;필요하다.&amp;nbsp;도메인은&amp;nbsp;진입점&amp;nbsp;무관,&amp;nbsp;Facade&amp;nbsp;는&amp;nbsp;진입점&amp;nbsp;종속.&amp;nbsp;지금&amp;nbsp;비어&amp;nbsp;있어도&amp;nbsp;그&amp;nbsp;자리는&amp;nbsp;의미가&amp;nbsp;있다.&lt;br /&gt;&lt;br /&gt;비어있는&amp;nbsp;자리도&amp;nbsp;책임의&amp;nbsp;표현이라는&amp;nbsp;것.&amp;nbsp;결정&amp;nbsp;이후에&amp;nbsp;얻은&amp;nbsp;작은&amp;nbsp;깨달음이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결정&amp;nbsp;이후에도&amp;nbsp;흔들리는&amp;nbsp;것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지가&amp;nbsp;글의&amp;nbsp;답이고,&amp;nbsp;여기서부터는&amp;nbsp;정직한&amp;nbsp;자기&amp;nbsp;점검이다.&amp;nbsp;결정한&amp;nbsp;다음에도&amp;nbsp;머릿속에&amp;nbsp;남는&amp;nbsp;의심들이&amp;nbsp;있다.&lt;br /&gt;&lt;br /&gt;1.&amp;nbsp;YAGNI&amp;nbsp;위반일&amp;nbsp;수&amp;nbsp;있다.&lt;br /&gt;&quot;미래에&amp;nbsp;다른&amp;nbsp;진입점이&amp;nbsp;추가될&amp;nbsp;가능성&quot;&amp;nbsp;자체가&amp;nbsp;가설이고,&amp;nbsp;그&amp;nbsp;가설을&amp;nbsp;근거로&amp;nbsp;추상화&amp;nbsp;비용을&amp;nbsp;지금&amp;nbsp;지불한&amp;nbsp;거다.&amp;nbsp;진입점이&amp;nbsp;영영&amp;nbsp;REST&amp;nbsp;하나뿐이라면&amp;nbsp;이&amp;nbsp;결정은&amp;nbsp;과한&amp;nbsp;결정이&amp;nbsp;된다.&amp;nbsp;다만&amp;nbsp;그&amp;nbsp;추상화&amp;nbsp;비용이&amp;nbsp;한&amp;nbsp;줄짜리&amp;nbsp;위임&amp;nbsp;메서드&amp;nbsp;한&amp;nbsp;개라&amp;nbsp;부담이&amp;nbsp;작다고&amp;nbsp;봤다.&lt;br /&gt;&lt;br /&gt;2.&amp;nbsp;반대&amp;nbsp;결정도&amp;nbsp;충분히&amp;nbsp;옳을&amp;nbsp;수&amp;nbsp;있다.&lt;br /&gt;&quot;지금은&amp;nbsp;어드민만&amp;nbsp;등록한다.&amp;nbsp;다른&amp;nbsp;진입점이&amp;nbsp;생길&amp;nbsp;때&amp;nbsp;옮겨도&amp;nbsp;늦지&amp;nbsp;않다.&amp;nbsp;그때까지&amp;nbsp;Facade&amp;nbsp;안에&amp;nbsp;명시적으로&amp;nbsp;두는&amp;nbsp;게&amp;nbsp;가독성이&amp;nbsp;좋다&quot;&amp;nbsp;라는&amp;nbsp;논리도&amp;nbsp;일리가&amp;nbsp;있다.&amp;nbsp;내&amp;nbsp;결정이&amp;nbsp;맞다&amp;nbsp;가&amp;nbsp;아니라&amp;nbsp;내&amp;nbsp;기준에서&amp;nbsp;정합적이다&amp;nbsp;가&amp;nbsp;더&amp;nbsp;정확한&amp;nbsp;표현일&amp;nbsp;거다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마지막으로...&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;기준이&amp;nbsp;만능은&amp;nbsp;아니다.&amp;nbsp;위에&amp;nbsp;적은&amp;nbsp;흔들림들이&amp;nbsp;그&amp;nbsp;증거다.&lt;br /&gt;&lt;br /&gt;그럼에도&amp;nbsp;&quot;진입점이&amp;nbsp;늘어날&amp;nbsp;때&amp;nbsp;규칙이&amp;nbsp;따라가야&amp;nbsp;하는가&quot;&amp;nbsp;라는&amp;nbsp;질문은&amp;nbsp;&quot;복잡도를&amp;nbsp;어디서&amp;nbsp;흡수할&amp;nbsp;것인가&quot;&amp;nbsp;라는&amp;nbsp;더&amp;nbsp;큰&amp;nbsp;질문의&amp;nbsp;작은&amp;nbsp;버전이라고&amp;nbsp;생각한다.&amp;nbsp;도메인&amp;nbsp;위쪽에&amp;nbsp;둔&amp;nbsp;규칙은&amp;nbsp;진입점이&amp;nbsp;늘면&amp;nbsp;자동으로&amp;nbsp;적용되고,&amp;nbsp;유스케이스&amp;nbsp;쪽에&amp;nbsp;둔&amp;nbsp;규칙은&amp;nbsp;진입점이&amp;nbsp;늘면&amp;nbsp;복제된다.&amp;nbsp;시간이&amp;nbsp;갈수록&amp;nbsp;이&amp;nbsp;차이가&amp;nbsp;코드의&amp;nbsp;결을&amp;nbsp;결정한다.&lt;br /&gt;&lt;br /&gt;다음에&amp;nbsp;같은&amp;nbsp;결정&amp;nbsp;앞에&amp;nbsp;서면,&amp;nbsp;나는&amp;nbsp;다시&amp;nbsp;같은&amp;nbsp;자문을&amp;nbsp;할&amp;nbsp;것&amp;nbsp;같다.&amp;nbsp;그리고&amp;nbsp;또&amp;nbsp;잠깐은&amp;nbsp;흔들릴&amp;nbsp;것&amp;nbsp;같다.&lt;br /&gt;&lt;br /&gt;&quot;이&amp;nbsp;규칙은,&amp;nbsp;진입점이&amp;nbsp;바뀌어도&amp;nbsp;같은가?&quot;&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/214</guid>
      <comments>https://hy-ung.tistory.com/214#entry214comment</comments>
      <pubDate>Fri, 29 May 2026 12:52:40 +0900</pubDate>
    </item>
    <item>
      <title>MongoDB 쿼리에서 대시보드로: AI와 함께 만든 시청 데이터 관측 도구</title>
      <link>https://hy-ung.tistory.com/213</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;언젠가 만들고 싶다고 생각만 하던 도구가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회사에서 반복적으로 마주치는 불편함을 해결할 화면이 있으면 좋겠다고 자주 생각했지만, 막상 시작하려고 하면 데이터, 기준, 화면 구성이 한꺼번에 떠올라 쉽게 손이 가지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 있어서 시작할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의식과 업무 맥락을 설명하면 AI가 기능을 나누고 구현 순서를 잡는 데 도움을 줬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 머릿속에만 있던 내부 도구를 움직이는 웹앱으로 만들 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 결과물이&lt;span&gt;&amp;nbsp;&lt;/span&gt;Observatory다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;520&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QASMC/dJMcagZSmeB/1Qdr0udhmSDnmJTCVxZEK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QASMC/dJMcagZSmeB/1Qdr0udhmSDnmJTCVxZEK1/img.png&quot; data-alt=&quot;반복 쿼리에서 업무 대시보드로 바뀐 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QASMC/dJMcagZSmeB/1Qdr0udhmSDnmJTCVxZEK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQASMC%2FdJMcagZSmeB%2F1Qdr0udhmSDnmJTCVxZEK1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1120&quot; height=&quot;520&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;520&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;반복 쿼리에서 업무 대시보드로 바뀐 흐름&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 앱 화면 캡처는 외부 공개를 위해 실제 수치, 개인 식별 정보, 랭킹/테이블 데이터 영역을 블러 처리했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 만들었나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 회사는 교육 콘텐츠를 다루고 있고, 수강생의 시청 기록은 여러 시스템에 쌓인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영성 데이터는 RDB에, 날짜별&amp;middot;콘텐츠별 세부 기록은 MongoDB에 저장되어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 내가 자주 확인해야 하는 데이터가 대부분 MongoDB 쪽에 있었다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MongoDB Atlas에서 직접 쿼리하면 볼 수는 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 특정 기간, 그룹, 강의, 상품, 콘텐츠 기준으로 질문이 바뀔 때마다 쿼리 문법을 다시 찾아야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 원한 것은 쿼리 결과가 아니라, 업무 질문에 바로 답할 수 있는 화면이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 95px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;이전 방식&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;Observatory로 바꾼 뒤&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;MongoDB Atlas에서 직접 쿼리 작성&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;기간, 그룹, 강의, 상품, 멤버 기준으로 필터 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;결과 표를 보고 직접 흐름 해석&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;일별 추이 그래프와 랭킹으로 흐름 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;다음 질문이 생기면 다시 쿼리 작성&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;랭킹 항목 클릭으로 바로 필터 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;커리큘럼과 시청 기록을 수동 비교&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;미시청 콘텐츠와 남은 시간을 한 화면에서 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어떤 화면을 만들었나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Observatory는 대시보드에서 출발했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 7일, 14일, 30일 기준으로 총 시청시간, 활성 멤버 수, 일별 추이, 멤버그룹 Top 10을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그룹 막대를 클릭하면 해당 그룹 안의 멤버와 강의 Top 5도 이어서 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3448&quot; data-origin-height=&quot;1904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/W8K3J/dJMcaaZGD57/qQpz26DlGcnwYFf5FvsOZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/W8K3J/dJMcaaZGD57/qQpz26DlGcnwYFf5FvsOZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/W8K3J/dJMcaaZGD57/qQpz26DlGcnwYFf5FvsOZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FW8K3J%2FdJMcaaZGD57%2FqQpz26DlGcnwYFf5FvsOZk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3448&quot; height=&quot;1904&quot; data-origin-width=&quot;3448&quot; data-origin-height=&quot;1904&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;620&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Mfgja/dJMcacwsPVK/GOFmyX9xlt9QYienIstCt0/tfile.svg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Mfgja/dJMcacwsPVK/GOFmyX9xlt9QYienIstCt0/tfile.svg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Mfgja/dJMcacwsPVK/GOFmyX9xlt9QYienIstCt0/tfile.svg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMfgja%2FdJMcacwsPVK%2FGOFmyX9xlt9QYienIstCt0%2Ftfile.svg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1120&quot; height=&quot;620&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;620&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;화면확인할 수 있는 것업무에서 좋은 점&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;대시보드&lt;/td&gt;
&lt;td&gt;총 시청시간, 활성 멤버, 일별 추이, 그룹 Top 10&lt;/td&gt;
&lt;td&gt;전체 흐름을 빠르게 파악&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Members&lt;/td&gt;
&lt;td&gt;멤버별 시청 기록과 많이 시청한 멤버&lt;/td&gt;
&lt;td&gt;특정 수강생의 이용량 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MemberGroups&lt;/td&gt;
&lt;td&gt;그룹별 시청 기록과 랭킹&lt;/td&gt;
&lt;td&gt;고객사&amp;middot;조직 단위 흐름 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Courses&lt;/td&gt;
&lt;td&gt;강의별 랭킹, 멤버별 랭킹&lt;/td&gt;
&lt;td&gt;인기 강의와 많이 수강한 사람 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Products&lt;/td&gt;
&lt;td&gt;상품별 랭킹, 상품 안의 강의 랭킹&lt;/td&gt;
&lt;td&gt;패키지나 상품 단위 성과 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contents&lt;/td&gt;
&lt;td&gt;콘텐츠별 기록, 미시청 콘텐츠&lt;/td&gt;
&lt;td&gt;남은 구간과 세부 콘텐츠 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹 기능은 특히 중요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콘텐츠 회사에서 &amp;ldquo;무엇이 많이 소비되고 있는가&amp;rdquo;는 자연스럽게 궁금해지는 질문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 멤버, 멤버그룹, 강의, 상품 화면에 총 재생 시간 기준 Top 5, Top 10, Top 20 랭킹을 넣었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹은 순위를 보여주는 것으로 끝나지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목을 클릭하면 해당 멤버, 그룹, 강의, 상품으로 필터가 적용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자를 보고 궁금해진 지점을 다시 쿼리로 옮겨 적지 않아도 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;1954&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cCYjST/dJMb99NdeWl/MwaYgFa8lQUMidNcmvkAF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cCYjST/dJMb99NdeWl/MwaYgFa8lQUMidNcmvkAF1/img.png&quot; data-alt=&quot;Courses 랭킹 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cCYjST/dJMb99NdeWl/MwaYgFa8lQUMidNcmvkAF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcCYjST%2FdJMb99NdeWl%2FMwaYgFa8lQUMidNcmvkAF1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3000&quot; height=&quot;1954&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;1954&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Courses 랭킹 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 업무적인 기능: 미시청 콘텐츠&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B2B 교육 서비스에서 수강 데이터는 민감하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 누가 무엇을 봤는지의 문제가 아니라, 고객사의 교육 성과나 이수 판단과 연결될 수 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 &amp;ldquo;어느 구간을 보지 않았는가&amp;rdquo;는 자주 확인해야 하는 정보다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에는 특정 멤버, 강의, 상품 기준의 시청 기록을 조회한 뒤 커리큘럼 전체 목록과 비교해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요한 값을 잘못 넣으면 결과가 달라지고, 데이터 구조를 다시 확인해야 할 때도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;1841&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LVIXS/dJMcad28p5U/rHNtdaDj2ixivW7GczHs5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LVIXS/dJMcad28p5U/rHNtdaDj2ixivW7GczHs5k/img.png&quot; data-alt=&quot;Contents 시청기록 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LVIXS/dJMcad28p5U/rHNtdaDj2ixivW7GczHs5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLVIXS%2FdJMcad28p5U%2FrHNtdaDj2ixivW7GczHs5k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3000&quot; height=&quot;1841&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;1841&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Contents 시청기록 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SyvVF/dJMcagS9Zu9/EKkBo1yR5wiXdjKkpP0sCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SyvVF/dJMcagS9Zu9/EKkBo1yR5wiXdjKkpP0sCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SyvVF/dJMcagS9Zu9/EKkBo1yR5wiXdjKkpP0sCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSyvVF%2FdJMcagS9Zu9%2FEKkBo1yR5wiXdjKkpP0sCK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1120&quot; height=&quot;420&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 콘텐츠 화면에&lt;span&gt;&amp;nbsp;&lt;/span&gt;미시청 컨텐츠&lt;span&gt;&amp;nbsp;&lt;/span&gt;영역을 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멤버 ID, 강의 ID, 상품 ID를 입력하면 백오피스의 강의 커리큘럼과 MongoDB의 시청 기록을 비교해, 아직 보지 않은 콘텐츠와 남은 시간을 보여준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기능은 단순 편의 기능이라기보다 반복 확인을 줄이고 실수를 줄이기 위한 장치에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;민감한 데이터를 다룰수록 &amp;ldquo;빠르게 본다&amp;rdquo;만큼 &amp;ldquo;정확하게 본다&amp;rdquo;가 중요하기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현하면서 정리한 것&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Next.js 기반 내부 어드민&lt;/td&gt;
&lt;td&gt;로그인, 페이지 라우팅, 화면 구성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MongoDB aggregation&lt;/td&gt;
&lt;td&gt;시청시간 합계, 일별 추이, 랭킹 계산&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;백오피스 API 연결&lt;/td&gt;
&lt;td&gt;ID만 있는 데이터에 그룹명, 강의명, 상품명, 멤버 정보 보강&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;환경 선택&lt;/td&gt;
&lt;td&gt;DEV / QA / STAGING에 따라 API와 DB 전환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSV 다운로드&lt;/td&gt;
&lt;td&gt;필터링한 결과를 추가 분석으로 연결&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술적으로 아주 새로운 것을 만든 것은 아닐 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 나에게는 의미가 컸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트는 거창한 서비스라기보다, 내가 매일 느끼던 병목을 줄이기 위해 만든 작은 제품에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 AI 덕분에 &amp;ldquo;이런 게 있으면 좋겠다&amp;rdquo;에서 멈추지 않을 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 모든 것을 대신 만들어줬다기보다는, 내가 이미 알고 있던 불편함을 실제 도구로 바꾸는 속도를 만들어줬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Observatory를 만들면서 느낀 것은 업무 개선이 꼭 거창할 필요는 없다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복해서 검색하는 쿼리 하나, 매번 비교해야 하는 표 하나, 확인할 때마다 조심스러운 데이터 하나가 좋은 출발점이 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트는 그걸 확인한 경험이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 필요해서 만들었고, 그래서 더 현실적인 도구가 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음에도 비슷한 불편함을 만나면 예전보다 조금 더 빨리 생각할 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;이거, 화면으로 만들 수 있지 않을까?&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;div id=&quot;codex-browser-sidebar-comments-root&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;</description>
      <category>회고/우당탕 개발자 성장기</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/213</guid>
      <comments>https://hy-ung.tistory.com/213#entry213comment</comments>
      <pubDate>Fri, 22 May 2026 17:15:29 +0900</pubDate>
    </item>
    <item>
      <title>[LOOp:pak vol.4] 좋아요, 그 단순해 보이는 기능에서 결정해야 했던 것들</title>
      <link>https://hy-ung.tistory.com/212</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;좋아요 등록&amp;middot;취소는 단순한 INSERT/DELETE 같지만, 멱등성을 제대로 보장하려면 다섯 개의 결정을 거친다. DB unique 제약과 hard delete 같은 결정들이 한 트랜잭션 안에서 도메인 정합성을 어떻게 만드는지 정리한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;1. POST&lt;span&gt;&amp;nbsp;&lt;/span&gt;/products/{id}/likes&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 줄에서 시작&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;좋아요는 처음 봤을 때 단순한 기능이었다. 명세상 엔드포인트는 세 개뿐이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EAno0/dJMcadB08Os/SgMZdps8BUhrkycurKmRfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EAno0/dJMcadB08Os/SgMZdps8BUhrkycurKmRfK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EAno0/dJMcadB08Os/SgMZdps8BUhrkycurKmRfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEAno0%2FdJMcadB08Os%2FSgMZdps8BUhrkycurKmRfK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1778&quot; height=&quot;308&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;308&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(user_id, product_id)&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 행을 INSERT 하거나 DELETE 하는 일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데도 설계 문서를 쓰는 동안 결정해야 했던 것이 다섯이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멱등성, 좋아요 수의 위치, soft delete 와 unique 의 충돌,&lt;span&gt;&amp;nbsp;&lt;/span&gt;BaseEntity&lt;span&gt;&amp;nbsp;&lt;/span&gt;상속 여부, 그리고 그 모두를 묶는 hard delete 결단.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 글은 그 다섯을 거치며 본 트레이드오프를 정리한다. 정답을 안다고 말하기보다는, 그 시점에 어떤 옵션이 있었고 왜 그것을 골랐는지에 가깝다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;2. 멱등성 &amp;mdash; 같은 요청을 N 번 보내도 결과가 같으려면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요는 사용자 입장에서 &quot;한 번 누르면 끝&quot; 인 행위지만, 네트워크 위에서는 그 한 번이 두세 번 도착할 수 있다. 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;POST&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 두 번 와도 좋아요는 1개여야 하고, 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;DELETE&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 두 번 와도 결과는 &quot;없음&quot; 으로 같아야 한다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 단순하게 봤다. 애플리케이션 단에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;exists&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 번 체크하면 끝날 거라고. 문제는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;동시 요청&lt;/b&gt;이다. 두 트랜잭션이 동시에&lt;span&gt;&amp;nbsp;&lt;/span&gt;exists&lt;span&gt;&amp;nbsp;&lt;/span&gt;를&lt;span&gt;&amp;nbsp;&lt;/span&gt;false&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 받고 둘 다 INSERT 를 시도하면 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;(user_id, product_id)&lt;span&gt;&amp;nbsp;&lt;/span&gt;행이 두 개 들어가는 race condition 이 생긴다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이걸 막는 가장 단순한 방어선은 DB 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;UNIQUE&lt;span&gt;&amp;nbsp;&lt;/span&gt;제약이다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779424270088&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;CREATE UNIQUE INDEX uk_likes_user_product ON likes (user_id, product_id);&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 제약이 있으면 두 번째 INSERT 는 무결성 예외로 떨어지고, 애플리케이션은 그 예외를 잡아 &quot;이미 좋아요가 있다&quot; 로 해석하면 그만이다. 두 단계 방어선이 만들어진다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;1차 &amp;mdash; 애플리케이션&lt;span&gt;&amp;nbsp;&lt;/span&gt;exists&lt;span&gt;&amp;nbsp;&lt;/span&gt;사전 체크&lt;/b&gt;: 흔한 재요청을 SELECT 한 번으로 흡수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;2차 &amp;mdash; DB unique 제약&lt;/b&gt;: 1차에서 못 잡은 race condition 을 막는다&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;흔한 경우는 빠르게, 드문 경우는 안전하게. 흐름으로 그리면 이렇다.&lt;/p&gt;
&lt;div style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div style=&quot;background-color: #fdfdfc;&quot; data-gutter=&quot;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1790&quot; data-origin-height=&quot;2148&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBAIre/dJMcaaSWkYE/znxJhWL9W5L2VehX1XkUG1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBAIre/dJMcaaSWkYE/znxJhWL9W5L2VehX1XkUG1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBAIre/dJMcaaSWkYE/znxJhWL9W5L2VehX1XkUG1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBAIre%2FdJMcaaSWkYE%2FznxJhWL9W5L2VehX1XkUG1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1790&quot; height=&quot;2148&quot; data-origin-width=&quot;1790&quot; data-origin-height=&quot;2148&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DELETE&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;쪽은 더 단순하다. 영향 행 수가 0 이면 &quot;원래 없었음&quot; 으로 흡수한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;3. 좋아요 수, 어디에 두어야 하나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;좋아요 수는 두 곳에서 쓰인다 &amp;mdash; 상품 상세 페이지의 표시와 상품 목록의 정렬 옵션 (&lt;/span&gt;sort=likes_desc&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;).&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;정렬은 데이터 모델의 선택을 강하게 좁힌다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cBOK0c/dJMcadhPj1G/TRPICO5i700QL3Wo6KilV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cBOK0c/dJMcadhPj1G/TRPICO5i700QL3Wo6KilV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cBOK0c/dJMcadhPj1G/TRPICO5i700QL3Wo6KilV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcBOK0c%2FdJMcadhPj1G%2FTRPICO5i700QL3Wo6KilV0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1778&quot; height=&quot;308&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;308&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 ③ (매번 COUNT) 이 가장 깔끔하다고 봤다. 정규화의 교과서적 형태다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데&lt;span&gt;&amp;nbsp;&lt;/span&gt;sort=likes_desc&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 결정을 좁혔다. 모든 상품의 좋아요 수를 알아야 하는데, COUNT 를 매 상품마다 돌리면 인덱스 활용이 거의 불가능하고 좋아요가 늘어날수록 ORDER BY 가 풀스캔에 가까워진다. ② 도 비슷한 사정이지만, 우리가 보관하는 통계가&lt;span&gt;&amp;nbsp;&lt;/span&gt;like_count&lt;span&gt;&amp;nbsp;&lt;/span&gt;하나뿐인데 테이블을 하나 더 두는 건 과해 보였다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;남은 건 ① &amp;mdash;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Product.like_count&lt;span&gt;&amp;nbsp;&lt;/span&gt;컬럼. 정렬 인덱스가 본문에 그대로 걸리고, 상품 목록 조회가 단일 쿼리로 끝난다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779424422496&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;CREATE INDEX ix_products_like_count ON products (like_count);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;대신 트레이드오프가 분명하다. 인기 상품의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;like_count&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;hot row&lt;/b&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 되고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;likes&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;와의 정합성 책임이 애플리케이션으로 옮겨온다. hot row 는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;UPDATE products SET like_count = like_count + 1&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 원자적 증감으로 안전해지고, 정합성은 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;@Transactional&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;안에 묶는 것으로 보장된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;4. 취소된 좋아요가 다시 좋아요가 되는 순간 &amp;mdash; soft delete &amp;times; unique 충돌&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지는 자연스러웠다. 그런데 다른 도메인의 정책이 한 가지 걸렸다. 이 프로젝트의&lt;span&gt;&amp;nbsp;&lt;/span&gt;BaseEntity&lt;span&gt;&amp;nbsp;&lt;/span&gt;는&lt;span&gt;&amp;nbsp;&lt;/span&gt;deleted_at&lt;span&gt;&amp;nbsp;&lt;/span&gt;컬럼으로 soft delete 를 한다 &amp;mdash; Brand, Product, Order 모두 이 정책을 따른다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;좋아요도 같은 방식으로 가면 이런 상황이 만들어진다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779424461647&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[지난 주]  user=1, product=10, deleted_at=2026-05-15 09:00   &amp;larr; 취소된 행
[오늘]     user=1, product=10                                  &amp;larr; 같은 사용자가 다시 좋아요&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;새 INSERT 가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;(user_id, product_id)&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;unique 제약과 충돌한다. 좋아요는 잠깐 누르고 떼는 행위라 이 시나리오가 드물지 않다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ca5S3b/dJMcabRMOjO/kI95XK7GI8QejaIfd0FRHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ca5S3b/dJMcabRMOjO/kI95XK7GI8QejaIfd0FRHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ca5S3b/dJMcabRMOjO/kI95XK7GI8QejaIfd0FRHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fca5S3b%2FdJMcabRMOjO%2FkI95XK7GI8QejaIfd0FRHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1778&quot; height=&quot;412&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;412&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A 와 B 는 DB 차원의 영리한 트릭이지만 각자 비용이 있었다. C 는 표준적이지만 짧은 액션에 무거웠다. 남은 건 D &amp;mdash;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Like&lt;span&gt;&amp;nbsp;&lt;/span&gt;만 hard delete 로 가는 결단이었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;좋아요는 다른 도메인과 달리&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;취소가 자주 일어나고&lt;/b&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;이력 보존의 가치가 크지 않다&lt;/b&gt;. 누군가 좋아요 했다가 취소했다는 사실을 시스템이 굳이 기억할 이유가 없다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;5.&lt;span&gt;&amp;nbsp;&lt;/span&gt;Like&lt;span&gt;&amp;nbsp;&lt;/span&gt;만 hard delete &amp;mdash; 그리고&lt;span&gt;&amp;nbsp;&lt;/span&gt;BaseEntity&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 줄을 안 썼다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BaseEntity&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;id&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;createdAt&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;updatedAt&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;deletedAt&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;컬럼을 자동 관리한다. 상속하는 순간 그 엔티티는 자동으로 soft delete 의 인프라를 갖는다. 그런데&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Like&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;는 hard delete 다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;deleted_at&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;도,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;updated_at&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;도 필요 없다. 두 옵션이 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btVgak/dJMcacJ0WD8/hrz9LnCs9qtQKgfnqFSM10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btVgak/dJMcacJ0WD8/hrz9LnCs9qtQKgfnqFSM10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btVgak/dJMcacJ0WD8/hrz9LnCs9qtQKgfnqFSM10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtVgak%2FdJMcacJ0WD8%2Fhrz9LnCs9qtQKgfnqFSM10%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1778&quot; height=&quot;232&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;232&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 b 를 골랐다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;컬럼은 거짓말을 하지 않아야 한다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;deleted_at&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 있다는 건 &quot;이 엔티티는 soft delete 됩니다&quot; 라는 약속이다. 그 약속을 지키지 않는 컬럼을 남겨두는 게 더 큰 비용이라고 봤다.&lt;/p&gt;
&lt;h4 style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Brand 와 Like &amp;mdash; 같은 시스템에서 다른 정책&lt;/h4&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;흥미로운 건,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Brand 는 정반대 길을 골랐다&lt;/b&gt;는 점이다. 브랜드도 이름 unique 제약 때문에 같은 충돌을 겪는데, Brand 는 자동 restore (4 의 옵션 C) 로 풀었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsMqUl/dJMcafzWL5T/OkAtMWYn2nsdWIunojNrdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsMqUl/dJMcafzWL5T/OkAtMWYn2nsdWIunojNrdk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsMqUl/dJMcafzWL5T/OkAtMWYn2nsdWIunojNrdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsMqUl%2FdJMcafzWL5T%2FOkAtMWYn2nsdWIunojNrdk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1778&quot; height=&quot;232&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;232&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;처음에는 두 도메인이 같은 정책을 가져가는 게 &quot;일관성&quot; 이라고 봤다. 그런데 일관성에는 두 종류가 있었다 &amp;mdash; 표면 일관성 (모두 같은 패턴) 과 본질 일관성 (각자의 특성에 맞는 패턴). 다른 정책을 가져가도 &quot;왜 그렇게 선택했나&quot; 의 사고가 일관되면 그것이 본질 일관성이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #fdfdfc; color: #6e6c68; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;6. 정리 &amp;mdash; 단순한 기능에 숨어 있던 결정들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;430&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dfxkxr/dJMcadovcc2/4dOkEfhakW74ywJoKokN3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dfxkxr/dJMcadovcc2/4dOkEfhakW74ywJoKokN3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dfxkxr/dJMcadovcc2/4dOkEfhakW74ywJoKokN3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdfxkxr%2FdJMcadovcc2%2F4dOkEfhakW74ywJoKokN3K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1778&quot; height=&quot;430&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;430&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;이번에 배운 것&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요구사항 한 줄 &amp;mdash;&lt;span&gt;&amp;nbsp;&lt;/span&gt;sort=likes_desc&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 이 데이터 모델의 큰 결정을 좌우할 수 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;API 명세는 단순한 출입구 같지만, 그 안쪽 데이터의 형태를 결정한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;모든 도메인이 같은 패턴을 가져가는 게 자동으로 옳은 건 아니다. 깰 때는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;이유를 명시하고 받아들일 비용을 솔직히 적어두는 것&lt;/b&gt;이 더 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;다음 단계에서 확인할 것&lt;/h4&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;설계는 거기까지였고, 실제 구현은 다음 주의 과제다. 그때 검증할 가설이 몇 개 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Product.like_count&lt;span&gt;&amp;nbsp;&lt;/span&gt;의 hot row 경합 &amp;mdash; 원자적 UPDATE 로 충분한가, 비관/낙관 락이 필요한가&lt;/li&gt;
&lt;li&gt;existsBy&lt;span&gt;&amp;nbsp;&lt;/span&gt;+ DB unique 의 두 방어선이 동시 요청에서 어떻게 작동하는지&lt;/li&gt;
&lt;li&gt;트래픽이 커진 가상의 미래에&lt;span&gt;&amp;nbsp;&lt;/span&gt;like_count&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 비동기 집계로 분리하는 게 정말 필요한지&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 글이 만든 답들이 다음 주에 깨질 수도 있다. 그게 깨졌을 때 또 한 번 정리하는 것이 다음 글의 자리다.&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/212</guid>
      <comments>https://hy-ung.tistory.com/212#entry212comment</comments>
      <pubDate>Fri, 22 May 2026 13:49:38 +0900</pubDate>
    </item>
    <item>
      <title>[LOOp:pak vol.4] 회원 도메인을 TDD로 만들며 &amp;mdash; 1주차 회고</title>
      <link>https://hy-ung.tistory.com/211</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;루프팩 첫 주, 회원 도메인 세 가지를 TDD로 구현했다. &lt;br /&gt;잘한 것보다 어색했던 게 더 많아서, 일단 기록부터 남긴다. TDD를 막 시작한 사람, 도메인 검증을 어디 둘지 고민하는 사람에게 참고가 될지도.&lt;br /&gt;(Red 실패하는 테스트 Green 최소 구현 Refactor 코드 정리 다음 사이클)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 주에 한 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3개의 유스케이스를 TDD 사이클 한 바퀴씩 돌렸다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;회원가입&lt;/b&gt; &lt;code&gt;POST /api/v1/users&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;내 정보 조회&lt;/b&gt; &lt;code&gt;GET /api/v1/users/me&lt;/code&gt; (이름 마지막 글자 &lt;code&gt;*&lt;/code&gt; 마스킹)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비밀번호 수정&lt;/b&gt; &lt;code&gt;PATCH /api/v1/users/me/password&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 기능은 &lt;code&gt;test &amp;rarr; feat &amp;rarr; refactor&lt;/code&gt; 세 커밋으로 끊어서, 한 사이클이 어디서 끝나는지 git 그래프만 봐도 보이도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ujshQ/dJMcabYxmd5/dvi85eEQYNKV4vpW2890T1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ujshQ/dJMcabYxmd5/dvi85eEQYNKV4vpW2890T1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ujshQ/dJMcabYxmd5/dvi85eEQYNKV4vpW2890T1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FujshQ%2FdJMcabYxmd5%2Fdvi85eEQYNKV4vpW2890T1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1200&quot; height=&quot;458&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;458&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가장 곱씹었던 결정 &amp;mdash; 검증 책임의 위치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입 한 줄 짜는 데 검증이 다섯 종류가 나왔다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;loginId가 영문/숫자만인가?&lt;/li&gt;
&lt;li&gt;이름이 비어있지 않은가?&lt;/li&gt;
&lt;li&gt;이메일 형식이 맞는가?&lt;/li&gt;
&lt;li&gt;비밀번호가 정책(8~16자, 허용 문자, 생년월일 미포함)을 만족하는가?&lt;/li&gt;
&lt;li&gt;이미 가입된 loginId인가?&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &lt;code&gt;UserService&lt;/code&gt; 한 곳에 다 넣었다가, &lt;i&gt;이 다섯 개는 같은 곳에 있으면 안 될 것 같다&lt;/i&gt;는 느낌이 들었다. 그래서 다음과 같이 나눴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserModel.init 형식 검증 loginId &amp;middot; 이름 &amp;middot; 이메일 &quot;도메인 불변식&quot; 이게 깨진 객체는 존재해선 안 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserService 정책 + 컨텍스트 검증 비밀번호 규칙 &amp;middot; 중복 &amp;middot; 인증 &quot;정책은 변한다&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB/외부 의존이 필요한 검증도 여기에 Controller 입출력 매핑만 검증 책임 X &quot;얇게 유지&quot; DTO &amp;harr; 도메인 변환만 담당&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1619&quot; data-origin-height=&quot;971&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SlRQe/dJMcafzRA1D/z3qWgAIuuNLuFk25XHi631/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SlRQe/dJMcafzRA1D/z3qWgAIuuNLuFk25XHi631/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SlRQe/dJMcafzRA1D/z3qWgAIuuNLuFk25XHi631/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSlRQe%2FdJMcafzRA1D%2Fz3qWgAIuuNLuFk25XHi631%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1619&quot; height=&quot;971&quot; data-origin-width=&quot;1619&quot; data-origin-height=&quot;971&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;도메인 모델 &amp;mdash; 형식 검증&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;init {
    if (!loginId.matches(LOGIN_ID_REGEX)) throw CoreException(BAD_REQUEST, &quot;...&quot;)
    if (name.isBlank()) throw CoreException(BAD_REQUEST, &quot;...&quot;)
    if (!email.matches(EMAIL_REGEX)) throw CoreException(BAD_REQUEST, &quot;...&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;UserModel&lt;/code&gt;의 &lt;code&gt;init&lt;/code&gt; 블록에 둔 이유는 단순하다. &lt;b&gt;이 규칙이 깨진 &lt;code&gt;UserModel&lt;/code&gt; 인스턴스는 시스템 어디에도 존재해선 안 되기 때문&lt;/b&gt;이다. 도메인 객체의 불변식이라면, 그 객체의 생성 시점에 강제하는 게 가장 안전하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;도메인 서비스 &amp;mdash; 정책/컨텍스트 검증&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Transactional
fun signUp(loginId: String, rawPassword: String, ...): UserModel {
    if (userRepository.findByLoginId(loginId) != null) {
        throw CoreException(CONFLICT, &quot;이미 사용 중인 로그인 ID입니다.&quot;)
    }
    validatePassword(rawPassword, birthDate)
    val user = UserModel(...)
    return userRepository.save(user)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비밀번호 규칙은 &lt;i&gt;언제든 바뀔 수 있는 정책&lt;/i&gt;이고, 중복 체크는 &lt;i&gt;Repository가 필요한 컨텍스트 검증&lt;/i&gt;이다. 둘 다 도메인 모델 안에 넣을 수 없는 이유가 분명하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TYPE 1 형식 검증 위치 &amp;middot; Model.init 깨지면 객체 자체가 성립하지 않음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TYPE 2 정책 검증 위치 &amp;middot; Service 정책은 변하지만 모델은 변하지 않음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TYPE 3 컨텍스트 검증 위치 &amp;middot; Service DB/외부 의존이 필요한 검증&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1688&quot; data-origin-height=&quot;932&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k8yxf/dJMcafGDygv/FjgU2bmCxzZQayhgtNu2Ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k8yxf/dJMcafGDygv/FjgU2bmCxzZQayhgtNu2Ok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k8yxf/dJMcafGDygv/FjgU2bmCxzZQayhgtNu2Ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk8yxf%2FdJMcafGDygv%2FFjgU2bmCxzZQayhgtNu2Ok%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1688&quot; height=&quot;932&quot; data-origin-width=&quot;1688&quot; data-origin-height=&quot;932&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한 번 막혔던 곳 &amp;mdash; 검증 순서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 작성한 &lt;code&gt;signUp&lt;/code&gt; 흐름은 이랬다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// before
validatePassword(rawPassword, birthDate)        // 1. 비밀번호 정책 검증
if (userRepository.findByLoginId(loginId) != null) { ... }  // 2. 중복 체크
val user = UserModel(...)                       // 3. 객체 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 돌리다 보니, &lt;b&gt;이미 가입된 사용자가 잘못된 비밀번호로 다시 시도할 때&lt;/b&gt; 응답 메시지가 &lt;code&gt;&quot;비밀번호 형식 오류&quot;&lt;/code&gt; 로 뜨는 게 어색했다. 사용자 입장에서 진짜 원인은 &quot;이미 가입된 ID&quot; 인데, 그게 가려졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서를 뒤집고 나니 두 가지가 좋아졌다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// after &amp;mdash; Refactor 커밋 0dd5742
if (userRepository.findByLoginId(loginId) != null) { ... }  // 1. 중복 먼저
validatePassword(rawPassword, birthDate)        // 2. 정책 검증
val user = UserModel(...)                       // 3. 객체 생성&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;응답이 정직해졌다&lt;/b&gt; &amp;mdash; 진짜 원인이 먼저 보임.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;불필요한 정규식&amp;middot;BCrypt 비용이 줄었다&lt;/b&gt; &amp;mdash; 어차피 실패할 요청이면 가장 싼 검증부터.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fail-fast 라는 단어를 책에서만 봤는데, 직접 순서 바꾸고 테스트 돌렸을 때 &quot;아 이게 그거구나&quot; 싶었다. 5줄 순서가 전부였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서 하나 바꿨을 뿐인데 응답이 정직해지고, 불필요한 BCrypt 비용도 같이 사라졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;900&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vHIis/dJMcabc6Aiz/tUdJXAQda4W5eyydHJtyF0/tfile.svg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vHIis/dJMcabc6Aiz/tUdJXAQda4W5eyydHJtyF0/tfile.svg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vHIis/dJMcabc6Aiz/tUdJXAQda4W5eyydHJtyF0/tfile.svg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvHIis%2FdJMcabc6Aiz%2FtUdJXAQda4W5eyydHJtyF0%2Ftfile.svg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;900&quot; height=&quot;480&quot; data-origin-width=&quot;900&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;작은 추상화의 큰 효과 &amp;mdash; &lt;code&gt;PasswordEncoder&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 이렇게 썼다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class UserService(
    private val passwordEncoder: BCryptPasswordEncoder,  // 구현체에 직접 의존
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Refactor 단계에서 인터페이스로 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class UserService(
    private val passwordEncoder: PasswordEncoder,  // 인터페이스에 의존
)&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Configuration
class PasswordConfig {
    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 줄 변화처럼 보이지만 테스트 코드가 달라진다. mockk&amp;lt;PasswordEncoder&amp;gt;() 로 인코딩 결과를 통제할 수 있게 되어, 단위 테스트에서 BCrypt 연산 비용도 사라지고 &quot;이 입력에 이 결과를 반환한다&quot;고 명시적으로 지정할 수 있게 됐다. Refactor 커밋 짜면서 이 변화를 넣을 때 솔직히 처음엔 그냥 인터페이스로 바꾸는 게 뭐가 다른가 싶었는데, 테스트가 훨씬 가벼워지는 걸 보고서야 이해가 됐다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;추상화는 다형성을 위해서가 아니라, 통제 가능성을 위해서 한다&quot;는 말이 이번에 와닿았다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3316&quot; data-origin-height=&quot;1016&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cnYkC1/dJMcafs4ld1/MOXudt7DKMaqtKuZomm2F0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cnYkC1/dJMcafs4ld1/MOXudt7DKMaqtKuZomm2F0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cnYkC1/dJMcafs4ld1/MOXudt7DKMaqtKuZomm2F0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcnYkC1%2FdJMcafs4ld1%2FMOXudt7DKMaqtKuZomm2F0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3316&quot; height=&quot;1016&quot; data-origin-width=&quot;3316&quot; data-origin-height=&quot;1016&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;KPT&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Keep &amp;mdash; 페이즈 단위 커밋 분리&lt;/b&gt;&lt;br /&gt;PR 리뷰 단위가 명확해지고, &quot;왜 이 코드가 추가되었는지&quot;가 커밋 그래프로 그대로 설명됐다. Refactor 커밋만 따로 보면 &quot;이 주에 뭘 정리했는가&quot;가 한눈에 보여서, 이건 앞으로도 계속 유지하고 싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Problem &amp;mdash; Red 단계 한 커밋이 너무 컸다&lt;/b&gt;&lt;br /&gt;회원가입 Red 커밋이 +563줄. stub 프로덕션 코드를 한 번에 다 넣다 보니 &quot;테스트 1개 추가&quot;의 단위가 무너졌다. 진짜 TDD스럽게 한다면 더 잘게 쪼개야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Try &amp;mdash; 다음 사이클부턴 &quot;검증 케이스 1~2개 = Red 1커밋&quot;&lt;/b&gt;&lt;br /&gt;엔드포인트 단위가 아니라 &lt;i&gt;검증 케이스&lt;/i&gt; 단위로 끊어보고 싶다. 그리고 Refactor 단계에서 발견한 인터페이스 추상화는 별도 PR로 분리해도 될지 시도해볼 예정.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ed74Sx/dJMcajoCW5J/ykAXEBkhGZCyb0KLEebL10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ed74Sx/dJMcajoCW5J/ykAXEBkhGZCyb0KLEebL10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ed74Sx/dJMcajoCW5J/ykAXEBkhGZCyb0KLEebL10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fed74Sx%2FdJMcajoCW5J%2FykAXEBkhGZCyb0KLEebL10%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1672&quot; height=&quot;941&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1주차 끝났는데 아직 모르겠는 게 더 많다. 그래도 이번 주 테스트 하나하나 돌리면서 건진 게 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;검증 코드 한 줄을 어디 둘지 정하는 건, 결국 &quot;이 테스트가 무엇을 검증해야 하는가&quot;를 먼저 정하는 일이었다. 테스트가 흔들리면 설계가 흔들리고, 설계가 잡히면 테스트도 같이 잡혔다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/211</guid>
      <comments>https://hy-ung.tistory.com/211#entry211comment</comments>
      <pubDate>Fri, 15 May 2026 13:51:04 +0900</pubDate>
    </item>
    <item>
      <title>[항해 복귀 스터디] 2주차 설계 + 아키텍처 패턴</title>
      <link>https://hy-ung.tistory.com/210</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Mock API를 우선 제공해야 하는 필요성에 대한 이해&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock API는 데이터베이스 연결이나 백엔드 처리 없이 정적 응답만 제공합니다. 테스트 및 개발용: 실제 API가 준비되지 않았거나 불완전하거나 불안정한 경우 사용&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@khy226/msw로-모의-서버-만들기&quot;&gt;https://velog.io/@khy226/msw로-모의-서버-만들기&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770038251779&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;MSW(Mock Service Worker)로 더욱 생산적인 FE 개발하기&quot; data-og-description=&quot;MSW(Mock Service Worker)는 Service Worker를 이용해 서버를 향한 실제 네트워크 요청을 가로채서(intercept) 모의 응답 (Mocked response)를 보내주는 API Mocking 라이브러리이다.&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@khy226/msw로-모의-서버-만들기&quot; data-og-url=&quot;https://velog.io/@khy226/msw로-모의-서버-만들기&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/TRDtb/dJMb9cBDhmh/HSWeIc9IBJkqvP41WP57W1/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080,https://scrap.kakaocdn.net/dn/cZ7dNu/dJMb9iIChr5/fvYRKL4f9vuFOzcnJKYeRk/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080,https://scrap.kakaocdn.net/dn/y6vbe/dJMb9kl7Yuy/TEKNzK4MeX4zIxSBoYELYK/img.png?width=1588&amp;amp;height=1328&amp;amp;face=0_0_1588_1328&quot;&gt;&lt;a href=&quot;https://velog.io/@khy226/msw로-모의-서버-만들기&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@khy226/msw로-모의-서버-만들기&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/TRDtb/dJMb9cBDhmh/HSWeIc9IBJkqvP41WP57W1/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080,https://scrap.kakaocdn.net/dn/cZ7dNu/dJMb9iIChr5/fvYRKL4f9vuFOzcnJKYeRk/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080,https://scrap.kakaocdn.net/dn/y6vbe/dJMb9kl7Yuy/TEKNzK4MeX4zIxSBoYELYK/img.png?width=1588&amp;amp;height=1328&amp;amp;face=0_0_1588_1328');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;MSW(Mock Service Worker)로 더욱 생산적인 FE 개발하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;MSW(Mock Service Worker)는 Service Worker를 이용해 서버를 향한 실제 네트워크 요청을 가로채서(intercept) 모의 응답 (Mocked response)를 보내주는 API Mocking 라이브러리이다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://techblog.woowahan.com/20154/&quot;&gt;https://techblog.woowahan.com/20154/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770038274874&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;API 모킹으로 테스트를 더 편리하게, Mock Service GUI 소개 | 우아한형제들 기술블로그&quot; data-og-description=&quot;여러분은 프론트엔드의 API 모킹 환경을 어떤 방식으로 구축하고, 사용하고 계신가요? 이 글에서는 API 모킹 환경을 개발자 친화적으로 개선해 나가는 과정을 다루며, 2023 우아한테크콘퍼런스에&quot; data-og-host=&quot;techblog.woowahan.com&quot; data-og-source-url=&quot;https://techblog.woowahan.com/20154/&quot; data-og-url=&quot;https://techblog.woowahan.com/20154/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/7RD5e/dJMb84XTTEH/HChUa70E23APh50kgOAhEK/img.png?width=750&amp;amp;height=410&amp;amp;face=0_0_750_410&quot;&gt;&lt;a href=&quot;https://techblog.woowahan.com/20154/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://techblog.woowahan.com/20154/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/7RD5e/dJMb84XTTEH/HChUa70E23APh50kgOAhEK/img.png?width=750&amp;amp;height=410&amp;amp;face=0_0_750_410');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;API 모킹으로 테스트를 더 편리하게, Mock Service GUI 소개 | 우아한형제들 기술블로그&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;여러분은 프론트엔드의 API 모킹 환경을 어떤 방식으로 구축하고, 사용하고 계신가요? 이 글에서는 API 모킹 환경을 개발자 친화적으로 개선해 나가는 과정을 다루며, 2023 우아한테크콘퍼런스에&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;techblog.woowahan.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RESTful한 API 설계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API는 REST 아키텍처 스타일을 따르는 API를 말하고, RESTful API는 그 REST 원칙을 충실히 지켜 설계된 이상적인 API를 의미합니다. 모든 RESTful API는 REST API지만, 그 반대는 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RESTful의 주요 특징&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;자원(Resource) 중심&lt;/b&gt; : 모든 것을 자원으로 보고, URI(Uniform Resource Identifier)로 명확하게 식별합니다 (예:&amp;nbsp;/users,&amp;nbsp;/products/123).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;표준 HTTP 메서드 활용&lt;/b&gt; : 데이터 생성/조회/수정/삭제(CRUD)를 HTTP 메서드에 매핑합니다 (GET: 조회, POST: 생성, PUT/PATCH: 수정, DELETE: 삭제).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상태 비저장(Stateless)&lt;/b&gt; : 서버는 클라이언트의 이전 요청 상태를 기억하지 않으며, 각 요청은 독립적입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;표현(Representation)&lt;/b&gt; : 클라이언트와 서버는 자원의 상태를 JSON, XML 등의 형식으로 주고받습니다 (Representation of State Transfer)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;클라이언트-서버 구조&lt;/b&gt; : 클라이언트와 서버가 분리되어 독립적으로 개발 및 발전할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다양한 아키텍처 패턴에 대한 이해&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;레이어드 아키텍처&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 레이어는 자기 책임만 갖고, 아래 레이어에만 의존한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1️⃣&amp;nbsp;&lt;b&gt;Controller Layer (Presentation Layer)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTTP 요청/응답 처리&lt;/li&gt;
&lt;li&gt;입력값 검증&lt;/li&gt;
&lt;li&gt;인증/인가 처리&lt;/li&gt;
&lt;li&gt;DTO 변환&lt;/li&gt;
&lt;li&gt;비즈니스 로직 ❌&lt;/li&gt;
&lt;li&gt;DB 접근 ❌&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2️⃣&amp;nbsp;&lt;b&gt;Service Layer (Business Layer)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;핵심 비즈니스 로직&lt;/li&gt;
&lt;li&gt;트랜잭션 관리&lt;/li&gt;
&lt;li&gt;여러 Repository 조합&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3️⃣&amp;nbsp;&lt;b&gt;Repository Layer (Persistence Layer)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 접근&lt;/li&gt;
&lt;li&gt;CRUD 처리&lt;/li&gt;
&lt;li&gt;ORM / Query Builder 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4️⃣&amp;nbsp;&lt;b&gt;Domain / Model Layer (선택적)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;엔티티 정의&lt;/li&gt;
&lt;li&gt;도메인 규칙&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 48.9535%;&quot;&gt;장점&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot;&gt;단점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 48.9535%;&quot; rowspan=&quot;2&quot;&gt;유지 보수성&lt;br /&gt;한 레이어 수정 &amp;rarr; 다른 레이어 영향 최소화&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot; rowspan=&quot;2&quot;&gt;과도한 보일러플레이트 &amp;rarr; 작은 프로젝트엔 오히려 복잡&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 48.9535%;&quot; rowspan=&quot;2&quot;&gt;테스트 용이성&lt;br /&gt;service 단위 테스트 쉬우며 repository mock 가능&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot; rowspan=&quot;2&quot;&gt;Service 비대화 &amp;rarr; 도메인 단위로 service 분리 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;헥사고날 아키텍처&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비즈니스 로직은 중심에 두고, DB&amp;middot;웹&amp;middot;메시지&amp;middot;외부 API 같은 것은 전부 갈아 끼울 수 있게 만든 구조&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://mangkyu.tistory.com/396&quot;&gt;https://mangkyu.tistory.com/396&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1770038289737&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Architecture] 헥사고날 아키텍처를 통한 의미 수준과 구현 수준에 대한 이해(semantic and implementation l&quot; data-og-description=&quot;1. 헥사고날 아키텍처에 대하여[ 헥사고날 아키텍처의 도메인 엔티티(Domain Entity) ]우리는 소프트웨어를 개발할 때 어떠한 의미를 갖는 이론적 토대를 바탕으로 개발을 하게 된다. 예를 들어 우리&quot; data-og-host=&quot;mangkyu.tistory.com&quot; data-og-source-url=&quot;https://mangkyu.tistory.com/396&quot; data-og-url=&quot;https://mangkyu.tistory.com/396&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dixYxB/dJMb8WMkIfX/2AUCQYQtZTFFFGwkyoMIW0/img.png?width=800&amp;amp;height=393&amp;amp;face=0_0_800_393,https://scrap.kakaocdn.net/dn/q9U12/dJMb8SpC4sw/Kj48VPPpSseYzMKcPYSMQk/img.png?width=800&amp;amp;height=393&amp;amp;face=0_0_800_393,https://scrap.kakaocdn.net/dn/bUqJpF/dJMb8RRM3Yz/8b5YBAsE7lNGyDgkz245vK/img.png?width=956&amp;amp;height=470&amp;amp;face=0_0_956_470&quot;&gt;&lt;a href=&quot;https://mangkyu.tistory.com/396&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://mangkyu.tistory.com/396&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dixYxB/dJMb8WMkIfX/2AUCQYQtZTFFFGwkyoMIW0/img.png?width=800&amp;amp;height=393&amp;amp;face=0_0_800_393,https://scrap.kakaocdn.net/dn/q9U12/dJMb8SpC4sw/Kj48VPPpSseYzMKcPYSMQk/img.png?width=800&amp;amp;height=393&amp;amp;face=0_0_800_393,https://scrap.kakaocdn.net/dn/bUqJpF/dJMb8RRM3Yz/8b5YBAsE7lNGyDgkz245vK/img.png?width=956&amp;amp;height=470&amp;amp;face=0_0_956_470');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Architecture] 헥사고날 아키텍처를 통한 의미 수준과 구현 수준에 대한 이해(semantic and implementation l&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;1. 헥사고날 아키텍처에 대하여[ 헥사고날 아키텍처의 도메인 엔티티(Domain Entity) ]우리는 소프트웨어를 개발할 때 어떠한 의미를 갖는 이론적 토대를 바탕으로 개발을 하게 된다. 예를 들어 우리&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;mangkyu.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ivory-room.tistory.com/91&quot;&gt;https://ivory-room.tistory.com/91&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770038299744&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Architecture] 헥사고날 아키텍처(Hexagonal Architecture) (ft. 계층형 아키텍처, 클린 아키텍처, DDD)&quot; data-og-description=&quot;헥사고날 아키텍처를 설명하기전에 계층형 아키텍처와 클린 아키텍처, 그리고 도메인 주도 설계(DDD)관련하여 가볍게 짚고 넘어가야한다. 헥사고날 아키텍처는 전통 방식인 계층형 아키텍처의 &quot; data-og-host=&quot;ivory-room.tistory.com&quot; data-og-source-url=&quot;https://ivory-room.tistory.com/91&quot; data-og-url=&quot;https://ivory-room.tistory.com/91&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/c8x5tY/dJMb86OWXcw/oXs2NjoSYD4T2zXkXa1js1/img.png?width=800&amp;amp;height=500&amp;amp;face=0_0_800_500,https://scrap.kakaocdn.net/dn/nhgXF/dJMb81GSjtj/HyXZOG6AH5ZprT1LbYR3x1/img.png?width=800&amp;amp;height=500&amp;amp;face=0_0_800_500,https://scrap.kakaocdn.net/dn/KC9Pb/dJMb81fNLuH/eCWYLdXiBgT4KKCsdoPFPk/img.png?width=2000&amp;amp;height=1251&amp;amp;face=0_0_2000_1251&quot;&gt;&lt;a href=&quot;https://ivory-room.tistory.com/91&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ivory-room.tistory.com/91&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/c8x5tY/dJMb86OWXcw/oXs2NjoSYD4T2zXkXa1js1/img.png?width=800&amp;amp;height=500&amp;amp;face=0_0_800_500,https://scrap.kakaocdn.net/dn/nhgXF/dJMb81GSjtj/HyXZOG6AH5ZprT1LbYR3x1/img.png?width=800&amp;amp;height=500&amp;amp;face=0_0_800_500,https://scrap.kakaocdn.net/dn/KC9Pb/dJMb81fNLuH/eCWYLdXiBgT4KKCsdoPFPk/img.png?width=2000&amp;amp;height=1251&amp;amp;face=0_0_2000_1251');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Architecture] 헥사고날 아키텍처(Hexagonal Architecture) (ft. 계층형 아키텍처, 클린 아키텍처, DDD)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;헥사고날 아키텍처를 설명하기전에 계층형 아키텍처와 클린 아키텍처, 그리고 도메인 주도 설계(DDD)관련하여 가볍게 짚고 넘어가야한다. 헥사고날 아키텍처는 전통 방식인 계층형 아키텍처의&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ivory-room.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;클린 아키텍처&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://daryeou.tistory.com/280#google_vignette&quot;&gt;https://daryeou.tistory.com/280#google_vignette&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770038300893&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;클린 아키텍처(Clean Architecture) 개념 및 원칙&quot; data-og-description=&quot;개발이란 마치 여러 개의 기반이 되는 블록을 만들어 설계 원칙에 따라 조립하여 완성해 나아가는 과정이라고 생각합니다. 여기서 설계 원칙은 수 많은 디자인 패턴들을 의미하며, 이번 주는 아&quot; data-og-host=&quot;daryeou.tistory.com&quot; data-og-source-url=&quot;https://daryeou.tistory.com/280#google_vignette&quot; data-og-url=&quot;https://daryeou.tistory.com/280&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/tI0qQ/dJMb9jOh8J8/eb7iZWSV3RhTKUTFCxKIK1/img.jpg?width=772&amp;amp;height=567&amp;amp;face=0_0_772_567,https://scrap.kakaocdn.net/dn/FaXkM/dJMb88eVKVu/SKKgJV5MyAofsmDsx0zoAK/img.jpg?width=772&amp;amp;height=567&amp;amp;face=0_0_772_567,https://scrap.kakaocdn.net/dn/s2eyH/dJMb9iIChsr/qwOhk2xBHBxbSSvim0y060/img.png?width=1200&amp;amp;height=738&amp;amp;face=0_0_1200_738&quot;&gt;&lt;a href=&quot;https://daryeou.tistory.com/280#google_vignette&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://daryeou.tistory.com/280#google_vignette&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/tI0qQ/dJMb9jOh8J8/eb7iZWSV3RhTKUTFCxKIK1/img.jpg?width=772&amp;amp;height=567&amp;amp;face=0_0_772_567,https://scrap.kakaocdn.net/dn/FaXkM/dJMb88eVKVu/SKKgJV5MyAofsmDsx0zoAK/img.jpg?width=772&amp;amp;height=567&amp;amp;face=0_0_772_567,https://scrap.kakaocdn.net/dn/s2eyH/dJMb9iIChsr/qwOhk2xBHBxbSSvim0y060/img.png?width=1200&amp;amp;height=738&amp;amp;face=0_0_1200_738');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;클린 아키텍처(Clean Architecture) 개념 및 원칙&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;개발이란 마치 여러 개의 기반이 되는 블록을 만들어 설계 원칙에 따라 조립하여 완성해 나아가는 과정이라고 생각합니다. 여기서 설계 원칙은 수 많은 디자인 패턴들을 의미하며, 이번 주는 아&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;daryeou.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵사고날 아키텍처는 공부하면 공부할수록 너무 어려운 개념인것 같다는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어렵고 크게 와닿지 않는 개념이라고 생각했는데 &lt;a href=&quot;https://tech.kakaopay.com/post/home-hexagonal-architecture/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://tech.kakaopay.com/post/home-hexagonal-architecture/&lt;/a&gt; 이런글을 공유를 받았는데 공감이 되면서 슬픈 사실인것 같다는 생각을 했다.&lt;/p&gt;</description>
      <category>Coding/Dev Study</category>
      <author>h7ung</author>
      <guid isPermaLink="true">https://hy-ung.tistory.com/210</guid>
      <comments>https://hy-ung.tistory.com/210#entry210comment</comments>
      <pubDate>Mon, 2 Feb 2026 22:23:14 +0900</pubDate>
    </item>
  </channel>
</rss>