<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>ioh's Development</title>
    <link>https://develop-tracking.tistory.com/</link>
    <description>&amp;quot;코드는 나의 언어, 디버깅은 나의 모험!&amp;quot;

배움은 기록에서, 성장은 도전에서 시작됩니다.
이 블로그는 개발 여정을 기록하고, 성장의 발자취를 남기는 공간입니다.

삽질도 성공도 모두 소중한 경험으로,
개발의 미로를 탐험하며 얻은 보물들을 공유합니다.

다양한 관심사와 배운 것들,
그리고 주니어 개발자로서의 고민과 성장을 담아내는 블로그입니다.</description>
    <language>ko</language>
    <pubDate>Sat, 30 May 2026 11:25:12 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>ioh'sDeveloper</managingEditor>
    <image>
      <title>ioh's Development</title>
      <url>https://tistory1.daumcdn.net/tistory/4713435/attach/13908e6fd0b741c199675b715f0e5c5c</url>
      <link>https://develop-tracking.tistory.com</link>
    </image>
    <item>
      <title>외부 API 호출이 낀 트랜잭션 비관적 락이 답이 아닌 이유</title>
      <link>https://develop-tracking.tistory.com/entry/%EC%99%B8%EB%B6%80-API-%ED%98%B8%EC%B6%9C%EC%9D%B4-%EB%82%80-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EC%9D%B4-%EB%8B%B5%EC%9D%B4-%EC%95%84%EB%8B%8C-%EC%9D%B4%EC%9C%A0</link>
      <description>&lt;h2 data-heading=&quot;혹시 이런 상황, 생각해본 적 있으신가요?&quot; data-ke-size=&quot;size26&quot;&gt;혹시 이런 상황, 생각해본 적 있으신가요?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이커머스에서 결제를 처리한다고 해보자. 요구사항은 한 줄이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;결제가 완료되면 PG에 거래 확정 API를 호출하고, 주문 상태를 PAID로 업데이트해줘&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드로 옮기면 보통 이렇게 시작한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public void completePayment(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    pgClient.confirm(order.getPgKey());   // 외부 HTTP 호출
    order.markPaid();                      // DB 상태 변경
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 본 동료가 묻는다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;동시성 문제가 있을 것 같은데, 비관적 락 추가하면 어때요? SELECT ... FOR UPDATE 걸어두면 안전하지 않나요?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직관적으로는 맞는 말처럼 들린다. 락 걸면 동시 수정 못 하니까 안전해 보인다. 근데 이게 이 문제의 본질일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &quot;외부 API 호출이 끼어든 트랜잭션&quot;에서 락이 왜 적절한 도구가 아닌지, 그럼 뭘 써야 하는지 정리한 내용이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;TL;DR&quot; data-ke-size=&quot;size26&quot;&gt;TL;DR&lt;/h2&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;DB 트랜잭션은 외부 HTTP 호출을 enlist하지 않는다. @Transactional 롤백으로 PG 호출을 되돌릴 수 없다.&lt;/li&gt;
&lt;li&gt;비관적 락 + 외부 HTTP 호출 = 락 점유 시간이 HTTP 타임아웃에 따라붙는다. 가용성이 망가진다.&lt;/li&gt;
&lt;li&gt;써야 할 도구는 따로 있다: 호출 순서 설계, 멱등성, Outbox, Saga.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;1. 한 줄 요구사항이 왜 분산 트랜잭션 문제가 되는가&quot; data-ke-size=&quot;size26&quot;&gt;1. 한 줄 요구사항이 왜 분산 트랜잭션 문제가 되는가&lt;/h2&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;mermaid-diagram-2026-05-17-184341.png&quot; data-origin-width=&quot;1215&quot; data-origin-height=&quot;376&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/J1uk9/dJMcabqITzj/6JInS4zf3miQJP7dfxPhW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/J1uk9/dJMcabqITzj/6JInS4zf3miQJP7dfxPhW0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/J1uk9/dJMcabqITzj/6JInS4zf3miQJP7dfxPhW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJ1uk9%2FdJMcabqITzj%2F6JInS4zf3miQJP7dfxPhW0%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;1215&quot; height=&quot;376&quot; data-filename=&quot;mermaid-diagram-2026-05-17-184341.png&quot; data-origin-width=&quot;1215&quot; data-origin-height=&quot;376&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional이 통제하는 건 왼쪽 박스뿐이다. HTTP 화살표를 타고 넘어간 순간, 그 결과는 우리 트랜잭션의 손이 닿지 않는다.&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;우리 DB: JDBC/JPA 트랜잭션 매니저가 통제. @Transactional 롤백으로 되돌릴 수 있음&lt;/li&gt;
&lt;li&gt;외부 PG: 자기들의 트랜잭션 경계. 우리 트랜잭션에 enlist되지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &quot;enlist&quot;라는 단어가 중요하다. Java의 JTA/XA 세계에서 트랜잭션 매니저는 여러 리소스(DataSource, JMS 등)를 한 트랜잭션 아래로 묶는데, 이걸 &quot;리소스가 트랜잭션에 enlist된다&quot;고 표현한다 (javax.transaction.Transaction#enlistResource()). HTTP 클라이언트는 enlist 대상이 아니다. PG 서버는 자기네 DB에 거래를 기록했을 뿐, 우리 트랜잭션의 일부가 아니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;&amp;quot;그럼 2PC/XA를 쓰면 되지 않나요?&amp;quot;&quot; data-ke-size=&quot;size23&quot;&gt;&quot;그럼 2PC/XA를 쓰면 되지 않나요?&quot;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론적으로 맞다. 분산 트랜잭션의 정석은 2PC(Two-Phase Commit)다. 근데 현실은:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;외부 SaaS는 XA를 지원하지 않는다. PG, 메시징, 알림, 검색 인덱스, 어느 것도 XA endpoint를 노출하지 않는다&lt;/li&gt;
&lt;li&gt;지원한다 해도 운영 부담이 크다. 코디네이터 단일 장애점, blocking 특성, 성능 저하&lt;/li&gt;
&lt;li&gt;마이크로서비스 시대로 넘어오면서 2PC는 사실상 사라졌다 (Pat Helland, &quot;Life beyond Distributed Transactions&quot;, 2007)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 분산 트랜잭션을 포기한 채로 정합성을 맞춰야 한다. 모든 문제의 출발점은 여기다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;2. 실패 모드 매트릭스 &amp;mdash; 어떤 일이 벌어질 수 있나&quot; data-ke-size=&quot;size26&quot;&gt;2. 실패 모드 매트릭스 &amp;mdash; 어떤 일이 벌어질 수 있나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;실패하면 어떻게 되지?&quot;를 표 한 장으로 정리하면 문제의 전체 그림이 보인다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 114px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;DB write&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;PG API&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/td&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;1&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;성공&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;실패&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;우리: PAID / PG: 미확정&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;정산 시 불일치, CS 문의 다수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;실패&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;성공&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;우리: 주문 없음 / PG: 결제 완료&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;PG는 돈을 받았는데 우리 시스템에 주문이 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;성공&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;성공&lt;/td&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;4&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;?&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;타임아웃&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;API가 처리됐는지 미상&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;재시도 시 &lt;b&gt;중복 결제&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;5&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;성공&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;성공&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;클라이언트 응답 실패&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;사용자가 재시도 &amp;rarr; 중복 주문&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산 정합성 글들이 보통 패턴 소개로 바로 넘어가는데, 이 표를 먼저 그려보지 않으면 패턴이 와닿지 않는다. 막아야 하는 게 정확히 뭔지 모르는 상태에서 도구를 고르는 셈이라서.&lt;/p&gt;
&lt;h3 data-heading=&quot;가장 까다로운 케이스 두 개&quot; data-ke-size=&quot;size23&quot;&gt;가장 까다로운 케이스 두 개&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;케이스 2 (DB 실패 + API 성공): PG는 돈을 받았는데 우리 DB에 주문이 없다. 사용자 입장에서 &quot;결제는 됐는데 주문 내역이 없다&quot;는 상황. 환불 처리 자동화도 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;케이스 4 (타임아웃): PG가 요청을 받아 처리는 끝냈는데 우리가 응답을 못 받았다. 재시도하면 케이스 5처럼 중복이 된다. 깔끔하게 푸는 방법은 멱등성 키다. (차선책으로 거래 상태 조회 API(GET /transactions/{key}/status)로 처리 여부를 확인한 뒤 분기할 수도 있지만, 조회-처리 사이의 race를 또 신경 써야 해서 멱등성 키만큼 깔끔하지 않다.)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;3. 순진한 1차 시도들 &amp;mdash; 각 안의 정합성 구멍&quot; data-ke-size=&quot;size26&quot;&gt;3. 순진한 1차 시도들 &amp;mdash; 각 안의 정합성 구멍&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 코드로 돌아가서, 호출 순서를 어떻게 잡든 정합성 구멍이 어디에 뚫리는지 보자.&lt;/p&gt;
&lt;h3 data-heading=&quot;안 A: API 먼저 &amp;rarr; DB 나중&quot; data-ke-size=&quot;size23&quot;&gt;안 A: API 먼저 &amp;rarr; DB 나중&lt;/h3&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Transactional
public void completePayment(Long orderId) {
    pgClient.confirm(order.getPgKey());   // 1. PG 먼저
    order.markPaid();                      // 2. DB 나중
}
&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;✅ API 실패 시 DB는 손도 안 댐&lt;/li&gt;
&lt;li&gt;❌ &lt;b&gt;API 성공 후 DB 실패 시 정합 깨짐&lt;/b&gt; (케이스 2)&lt;/li&gt;
&lt;li&gt;❌ DB write 시간만큼 정합성 깨진 윈도우 존재&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;안 B: DB 먼저 커밋 &amp;rarr; API 나중&quot; data-ke-size=&quot;size23&quot;&gt;안 B: DB 먼저 커밋 &amp;rarr; API 나중&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public void updateOrder(Long orderId) {
    order.markPaid();   // DB 먼저
}

public void completePayment(Long orderId) {
    updateOrder(orderId);                   // 트랜잭션 종료
    pgClient.confirm(order.getPgKey());     // 그 다음 API
}
&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;✅ DB는 확정된 상태&lt;/li&gt;
&lt;li&gt;❌ &lt;b&gt;API 실패 시 우리만 PAID&lt;/b&gt; (케이스 1)&lt;/li&gt;
&lt;li&gt;❌ API 실패해도 DB 롤백 불가 (이미 커밋됨)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;안 B2: DB write 사이에 API 끼움&quot; data-ke-size=&quot;size23&quot;&gt;안 B2: DB write 사이에 API 끼움&lt;/h3&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;@Transactional
public void completePayment(Long orderId) {
    order.markPaid();                        // DB write 1
    pgClient.confirm(order.getPgKey());      // API
    inventoryRepo.decrement(order.itemId()); // DB write 2
    eventLogRepo.insert(...);                // DB write 3
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 까다로운 패턴이다. 안 A, B의 문제를 둘 다 갖고 있어서.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-05-17-184501.png&quot; data-origin-width=&quot;1539&quot; data-origin-height=&quot;1308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dtMbPM/dJMb990KZZA/Bxo6fZ3YqS3cWK5C4obbl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dtMbPM/dJMb990KZZA/Bxo6fZ3YqS3cWK5C4obbl1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dtMbPM/dJMb990KZZA/Bxo6fZ3YqS3cWK5C4obbl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdtMbPM%2FdJMb990KZZA%2FBxo6fZ3YqS3cWK5C4obbl1%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;1539&quot; height=&quot;1308&quot; data-filename=&quot;mermaid-diagram-2026-05-17-184501.png&quot; data-origin-width=&quot;1539&quot; data-origin-height=&quot;1308&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;API 호출 후 DB write 2/3가 실패하면 &amp;rarr; 트랜잭션 롤백 &amp;rarr; 그런데 PG는 이미 확정됨&lt;/li&gt;
&lt;li&gt;결과: PG는 거래 확정, 우리는 주문이 롤백된 상태로 남음&lt;/li&gt;
&lt;li&gt;추적도 어렵다. &quot;어디서 API 호출했지?&quot; 코드 중간 어딘가에 끼어있어서.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;안 C: 트랜잭션 내 마지막 statement로 API, 실패 시 예외 &amp;rarr; 롤백&quot; data-ke-size=&quot;size23&quot;&gt;안 C: 트랜잭션 내 마지막 statement로 API, 실패 시 예외 &amp;rarr; 롤백&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public void completePayment(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.markPaid();                        // DB write
    inventoryRepo.decrement(order.itemId()); // DB write
    eventLogRepo.insert(...);                // DB write
    
    try {
        pgClient.confirm(order.getPgKey());  // ★ 마지막 statement
    } catch (Exception e) {
        throw new PaymentConfirmException(&quot;PG 확정 실패&quot;, e); // 롤백 유도
    }
}
&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;✅ 모든 DB write 직후, 외부 호출이 마지막. API 실패 시 RuntimeException &amp;rarr; 트랜잭션 롤백 &amp;rarr; DB write 모두 무효화&lt;/li&gt;
&lt;li&gt;✅ 안 B2의 &quot;중간 끼임&quot; 문제 차단&lt;/li&gt;
&lt;li&gt;❌ 타임아웃 케이스(케이스 4)는 여전히 잔존. API가 처리됐는지 모르는 상태에서 우리는 롤백한다 &amp;rarr; PG는 확정, 우리는 무효화&lt;/li&gt;
&lt;li&gt;❌ 케이스 2 (DB 실패 + API 성공)도 발생 가능성은 줄지만 제거되지 않음. 예: API 호출 직후 JVM crash, 커밋 직전 네트워크 단절 등&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안 C가 1차 방어선으로는 합리적이다. 운영 코드에서 가장 자주 보이는 패턴이고, 단순함의 가치가 크다. 다만 이게 끝은 아니라는 게 포인트다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;4. 여기서 &amp;quot;락을 걸면 안전해지지 않나요?&amp;quot; 가 등장한다&quot; data-ke-size=&quot;size26&quot;&gt;4. 여기서 &quot;락을 걸면 안전해지지 않나요?&quot; 가 등장한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 처음 동료의 질문으로 돌아온다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&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;왜 이게 진단을 잘못 짚은 건지 보자.&lt;/p&gt;
&lt;h3 data-heading=&quot;진단 오류 1: 동시성 vs 정합성은 다른 문제다&quot; data-ke-size=&quot;size23&quot;&gt;진단 오류 1: 동시성 vs 정합성은 다른 문제다&lt;/h3&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;같은 row를 두 트랜잭션이 동시에 수정 &amp;rarr; Lost Update, Phantom Read 등&lt;/li&gt;
&lt;/ul&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;DB 상태와 PG 상태의 일치&lt;/li&gt;
&lt;li&gt;한쪽이 실패했을 때 다른 쪽을 어떻게 되돌릴 것인가&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;위 실패 모드 매트릭스(섹션 2)의 케이스 1~5 중 락으로 해결되는 게 하나라도 있는가? 없다. 케이스 1, 2, 4, 5 모두 동시성과 무관하다. 동일 사용자의 단일 요청에서도 발생한다.&lt;/p&gt;
&lt;h3 data-heading=&quot;진단 오류 2: 비관적 락 + HTTP 호출은 가용성을 갉아먹는다&quot; data-ke-size=&quot;size23&quot;&gt;진단 오류 2: 비관적 락 + HTTP 호출은 가용성을 갉아먹는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오히려 이쪽이 더 큰 문제다. SELECT ... FOR UPDATE를 트랜잭션 내에 두면, 락 점유 시간이 트랜잭션 길이와 같아진다. 트랜잭션 안에 HTTP 호출이 있다면?&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;락 점유 시간 = DB write 시간 + HTTP 호출 시간(p99 포함)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;숫자로 보자.&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;PG API 평균 응답: 200ms&lt;/li&gt;
&lt;li&gt;p99 응답: 3,000ms (3초)&lt;/li&gt;
&lt;li&gt;결제 TPS: 100&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 비관적 락을 걸면 (Little's Law, L = &amp;lambda; &amp;times; W):&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;평소 락 보유 트랜잭션 수 &amp;asymp; TPS &amp;times; 평균 락 점유 시간
                          &amp;asymp; 100 &amp;times; 0.2s = 20개

PG가 느려져 응답이 p99(3초)에 몰리는 구간에서는
락 점유 시간 W가 한꺼번에 늘어나면서 락 보유 트랜잭션 수가
순식간에 수십~수백 개까지 치솟는다.
(극단 가정으로 모든 요청이 3초씩 걸린다면 100 &amp;times; 3.0s = 300개)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 커넥션 풀이 30~50이라면? PG 지연 구간에 풀이 고갈되고, 평소엔 멀쩡하던 우리 서비스가 외부 API 한 번 흔들릴 때마다 같이 쓰러진다. 락 + HTTP 조합이 가진 구조적인 문제다.&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;PG가 한 번 느려지면 &amp;rarr; 락 점유 시간 &amp;uarr; &amp;rarr; 후속 요청 대기 &amp;uarr; &amp;rarr; 더 많은 락 점유 &amp;rarr; 연쇄 장애&lt;/li&gt;
&lt;li&gt;다른 트랜잭션이 동일 row를 만지러 오면 &amp;rarr; 락 대기 &amp;rarr; 데드락 가능성 &amp;uarr;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정합성 문제를 풀어주지도 못하면서, 외부 API의 지연을 우리 시스템 전체로 옮겨오는 셈이다.&lt;/p&gt;
&lt;h3 data-heading=&quot;진단 오류 3: 낙관적 락도 마찬가지&quot; data-ke-size=&quot;size23&quot;&gt;진단 오류 3: 낙관적 락도 마찬가지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;비관적 말고 낙관적 락은요?&quot;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;version 컬럼으로 충돌 감지 &amp;rarr; 충돌 시 재시도&lt;/li&gt;
&lt;li&gt;✅ 락 점유 시간 문제 없음&lt;/li&gt;
&lt;li&gt;❌ &lt;b&gt;여전히 외부 API enlist 못 함.&lt;/b&gt; 케이스 1~5 중 어느 것도 해결 안 됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적 락은 &quot;같은 row의 동시 수정&quot;을 검출하는 도구일 뿐이다. PG와 우리 DB의 정합성을 보장하는 것과는 무관하다.&lt;/p&gt;
&lt;h3 data-heading=&quot;그래도 동시 수정이 정말 문제라면?&quot; data-ke-size=&quot;size23&quot;&gt;그래도 동시 수정이 정말 문제라면?&lt;/h3&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;멱등성 키(Idempotency-Key)로 동일 요청 중복 차단&lt;/li&gt;
&lt;li&gt;주문 상태 머신으로 PENDING &amp;rarr; PAID 단방향 전이만 허용 (이미 PAID면 에러)&lt;/li&gt;
&lt;li&gt;필요하면 짧은 application-level mutex(Redis lock 등)로 동일 orderId만 직렬화. 단, 외부 API 호출은 mutex 밖으로 빼야 한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락은 도메인적으로 동시성 제어가 정말 필요한 곳에만, 외부 호출과 분리해서 쓴다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;5. 그럼 뭘 써야 하나 &amp;mdash; 도구 스펙트럼&quot; data-ke-size=&quot;size26&quot;&gt;5. 그럼 뭘 써야 하나 &amp;mdash; 도구 스펙트럼&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산 트랜잭션 없이 정합성을 맞추는 데 쓰이는 패턴들을 정리해보자.&lt;/p&gt;
&lt;h3 data-heading=&quot;패턴 1: 호출 순서 + 즉시 실패 전파 (안 C의 본질)&quot; data-ke-size=&quot;size23&quot;&gt;패턴 1: 호출 순서 + 즉시 실패 전파 (안 C의 본질)&lt;/h3&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;@Transactional
public void completePayment(Long orderId) {
    // 1. 모든 DB write
    order.markPaid();
    inventoryRepo.decrement(order.itemId());
    eventLogRepo.insert(...);
    
    // 2. 마지막에 외부 호출, 실패 시 예외로 롤백 유도
    try {
        pgClient.confirm(order.getPgKey());
    } catch (Exception e) {
        throw new PaymentConfirmException(e);
    }
}
&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;&lt;b&gt;언제 쓰나&lt;/b&gt;: 외부 호출이 동기적이고 응답이 빠르며, 실패 시 트랜잭션 전체를 무효화하는 게 비즈니스적으로 자연스러울 때&lt;/li&gt;
&lt;li&gt;&lt;b&gt;장점&lt;/b&gt;: 단순함. 인프라 추가 없음. 안 B2의 중간 끼임 차단&lt;/li&gt;
&lt;li&gt;&lt;b&gt;한계&lt;/b&gt;: 타임아웃 / JVM crash 케이스 잔존. 외부 호출이 길면 트랜잭션 길이 &amp;uarr;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;패턴 2: 멱등성 키 + 재시도&quot; data-ke-size=&quot;size23&quot;&gt;패턴 2: 멱등성 키 + 재시도&lt;/h3&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;String idempotencyKey = &quot;order-&quot; + orderId + &quot;-confirm&quot;;

pgClient.confirm(
    order.getPgKey(),
    idempotencyKey   // 같은 키로 재시도해도 PG는 한 번만 처리
);
&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;&lt;b&gt;언제 쓰나&lt;/b&gt;: 외부 API가 멱등성 키를 지원할 때 (Stripe, Toss, PortOne 등 모던 PG는 대부분 지원)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;장점&lt;/b&gt;: 케이스 4 (타임아웃) 해결. 재시도 안전&lt;/li&gt;
&lt;li&gt;&lt;b&gt;한계&lt;/b&gt;: 외부 API의 지원이 전제. 키 관리 / TTL 정책 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;패턴 3: Outbox 패턴&quot; data-ke-size=&quot;size23&quot;&gt;패턴 3: Outbox 패턴&lt;/h3&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Transactional
public void completePayment(Long orderId) {
    order.markPaid();
    
    // 외부 호출 대신, &quot;외부 호출이 필요하다&quot;는 메시지를 같은 트랜잭션에 INSERT
    outboxRepo.insert(new OutboxMessage(
        &quot;PG_CONFIRM&quot;,
        orderId,
        order.getPgKey()
    ));
}

// 별도 워커가 outbox를 polling/CDC로 읽어서 외부 호출
// ★ 다중 인스턴스 환경이면 SELECT ... FOR UPDATE SKIP LOCKED 또는
//    ShedLock 같은 분산 락으로 같은 메시지를 두 워커가 동시에 집지 않게 막아야 한다.
@Scheduled(fixedDelay = 1000)
public void relayOutbox() {
    outboxRepo.findUnprocessedForUpdateSkipLocked(BATCH_SIZE).forEach(msg -&amp;gt; {
        pgClient.confirm(msg.getPgKey(), msg.getIdempotencyKey());
        outboxRepo.markProcessed(msg.getId());
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-05-17-184538.png&quot; data-origin-width=&quot;2424&quot; data-origin-height=&quot;1774&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Q74Lq/dJMcahRXybq/WXAtHTW8K9LuKLjUs53DMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Q74Lq/dJMcahRXybq/WXAtHTW8K9LuKLjUs53DMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Q74Lq/dJMcahRXybq/WXAtHTW8K9LuKLjUs53DMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQ74Lq%2FdJMcahRXybq%2FWXAtHTW8K9LuKLjUs53DMk%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;2424&quot; height=&quot;1774&quot; data-filename=&quot;mermaid-diagram-2026-05-17-184538.png&quot; data-origin-width=&quot;2424&quot; data-origin-height=&quot;1774&quot;/&gt;&lt;/span&gt;&lt;/figure&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;/li&gt;
&lt;li&gt;&lt;b&gt;장점&lt;/b&gt;: 트랜잭션 내에서 외부 호출 사라짐. 메시지 발행이 DB 커밋과 원자적. 재시도 자유로움&lt;/li&gt;
&lt;li&gt;&lt;b&gt;한계&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비동기 &amp;rarr; &quot;결제 확정&quot;이 즉시가 아닌 수 초 후. UX 영향&lt;/li&gt;
&lt;li&gt;워커 운영 부담 + 다중 인스턴스 중복 처리 방지 장치 필요 (위 코드의 SKIP LOCKED)&lt;/li&gt;
&lt;li&gt;Outbox는 at-least-once delivery라, 워커 crash나 markProcessed 실패로 같은 메시지가 두 번 발행될 수 있다. 외부 API에 멱등성 키 전달이 사실상 필수인 이유다 (위 코드의 msg.getIdempotencyKey())&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;패턴 4: Saga / 보상 트랜잭션&quot; data-ke-size=&quot;size23&quot;&gt;패턴 4: Saga / 보상 트랜잭션&lt;/h3&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;mermaid-diagram-2026-05-17-184618.png&quot; data-origin-width=&quot;2025&quot; data-origin-height=&quot;878&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHxJCq/dJMcacDaQUm/crezKgKkCLQeMPK0yC7fu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHxJCq/dJMcacDaQUm/crezKgKkCLQeMPK0yC7fu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHxJCq/dJMcacDaQUm/crezKgKkCLQeMPK0yC7fu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHxJCq%2FdJMcacDaQUm%2FcrezKgKkCLQeMPK0yC7fu0%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;2025&quot; height=&quot;878&quot; data-filename=&quot;mermaid-diagram-2026-05-17-184618.png&quot; data-origin-width=&quot;2025&quot; data-origin-height=&quot;878&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 단계가 실패하면, &lt;b&gt;거기까지 진행된 단계들을 역순으로 보상&lt;/b&gt;한다. &quot;원자적 롤백&quot;이 안 되니, &quot;수동으로 되돌리는 절차&quot;를 코드로 정의해두는 것이다.&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;/li&gt;
&lt;li&gt;&lt;b&gt;장점&lt;/b&gt;: 명시적 보상 액션. 단계별 독립성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;한계&lt;/b&gt;: 복잡도 &amp;uarr;. 보상 액션 정의가 어려운 도메인(예: 이메일 발송) 존재. Saga orchestrator 운영&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;패턴 5: (참고) 2PC/XA&quot; data-ke-size=&quot;size23&quot;&gt;패턴 5: (참고) 2PC/XA&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전통적 분산 트랜잭션. 코디네이터가 모든 참가자에게 prepare &amp;rarr; commit을 조율한다.&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;: 강일관성 필수 + 모든 참가자가 XA 지원할 때 (사실상 사내 RDBMS + JMS 정도)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;현실&lt;/b&gt;: 외부 SaaS는 미지원. 마이크로서비스 시대에는 사실상 사용되지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;패턴 비교표&quot; data-ke-size=&quot;size23&quot;&gt;패턴 비교표&lt;/h3&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;b&gt;패턴&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;외부 API 의존&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;비동기 허용&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;복잡도&lt;/b&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;호출 순서 + 실패 전파&lt;/td&gt;
&lt;td&gt;트랜잭션 내 동기&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;멱등성 키&lt;/td&gt;
&lt;td&gt;키 지원 필수&lt;/td&gt;
&lt;td&gt;둘 다&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbox&lt;/td&gt;
&lt;td&gt;분리&lt;/td&gt;
&lt;td&gt;필수&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;워커 / 메시지 큐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saga&lt;/td&gt;
&lt;td&gt;분리&lt;/td&gt;
&lt;td&gt;필수&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;Orchestrator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2PC/XA&lt;/td&gt;
&lt;td&gt;XA 지원 필수&lt;/td&gt;
&lt;td&gt;불가&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;6. 결정 트리 &amp;mdash; 내 상황에 뭘 골라야 하나&quot; data-ke-size=&quot;size26&quot;&gt;6. 결정 트리 &amp;mdash; 내 상황에 뭘&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-05-17-184713.png&quot; data-origin-width=&quot;1793&quot; data-origin-height=&quot;2988&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dmwXz2/dJMcadhLcnJ/njHk1NwLgJjFw6WzOwNjb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dmwXz2/dJMcadhLcnJ/njHk1NwLgJjFw6WzOwNjb0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dmwXz2/dJMcadhLcnJ/njHk1NwLgJjFw6WzOwNjb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdmwXz2%2FdJMcadhLcnJ%2FnjHk1NwLgJjFw6WzOwNjb0%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;1793&quot; height=&quot;2988&quot; data-filename=&quot;mermaid-diagram-2026-05-17-184713.png&quot; data-origin-width=&quot;1793&quot; data-origin-height=&quot;2988&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-heading=&quot;6. 결정 트리 &amp;mdash; 내 상황에 뭘 골라야 하나&quot; 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;h3 data-heading=&quot;추가 체크 항목&quot; 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;: 결제 TPS 10 vs 1,000은 다른 세상&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실패 허용도&lt;/b&gt;: 메시징 알림 실패는 재시도 큐로, 결제 실패는 즉시 알림&lt;/li&gt;
&lt;li&gt;&lt;b&gt;외부 API SLA&lt;/b&gt;: p99 500ms 이내 보장되면 패턴 1 가능, 아니면 비동기&lt;/li&gt;
&lt;li&gt;&lt;b&gt;운영 인프라&lt;/b&gt;: Kafka / Outbox 워커 운영 가능한 조직인가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;현실적인 시작점&lt;/b&gt;: 대부분의 경우 &lt;b&gt;패턴 1 + 패턴 2 조합으로 시작&lt;/b&gt;해서, 트래픽이 늘거나 외부 API 안정성이 떨어지면 &lt;b&gt;패턴 3 (Outbox)로 진화&lt;/b&gt;한다. 처음부터 Saga까지 가는 건 오버엔지니어링인 경우가 많다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;7. 보너스: 논블로킹 환경에서는 어떻게 달라지나&quot; data-ke-size=&quot;size26&quot;&gt;7. 보너스: 논블로킹 환경에서는 어떻게 달라지나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Coroutine / WebFlux 같은 논블로킹 환경에서는 트랜잭션 경계가 어떻게 동작할까?&lt;/p&gt;
&lt;h3 data-heading=&quot;스레드와 트랜잭션 매니저의 결합&quot; data-ke-size=&quot;size23&quot;&gt;스레드와 트랜잭션 매니저의 결합&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전통적인 Spring @Transactional(= PlatformTransactionManager + JDBC/JPA)은 ThreadLocal(TransactionSynchronizationManager)에 트랜잭션 상태를 저장한다. 같은 스레드에서 실행되는 동안만 트랜잭션이 이어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 Coroutine이다. suspend 후 재개될 때 다른 스레드에서 실행될 수 있고, ThreadLocal 기반 트랜잭션 매니저는 이걸 따라가지 못한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// ⚠ PlatformTransactionManager(JDBC/JPA) 기반에서 suspend 함수와 @Transactional을
//   섞으면 트랜잭션 컨텍스트가 suspend 경계를 못 넘는다.
@Transactional
suspend fun completePayment(orderId: Long) {
    val order = orderRepository.findById(orderId)
    order.markPaid()
    pgClient.confirm(order.pgKey)   // suspend point &amp;mdash; 스레드가 바뀔 수 있음
    eventLogRepo.insert(...)        // 재개된 스레드에 트랜잭션 컨텍스트가 없을 수 있음
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덧붙이면, JPA 자체가 blocking API라 coroutine/WebFlux 스택과는 궁합이 나쁘다. reactive 환경에서는 보통 R2DBC를 쓴다.&lt;/p&gt;
&lt;h3 data-heading=&quot;R2DBC / Reactive 환경에서는 다르다&quot; data-ke-size=&quot;size23&quot;&gt;R2DBC / Reactive 환경에서는 다르다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Reactive 스택(ReactiveTransactionManager + R2DBC)에서는 트랜잭션 상태가 ThreadLocal이 아니라 Reactor Context로 전파된다. Reactor Context는 비동기 경계를 따라 흐르기 때문에 suspend 후 스레드가 바뀌어도 트랜잭션이 유지된다. 이 스택에서는 @Transactional suspend fun도 정상 동작하고, 흐름을 명시적으로 제어하고 싶으면 TransactionalOperator를 쓴다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;suspend fun completePayment(orderId: Long): Order =
    transactionalOperator.executeAndAwait {
        val order = orderRepository.findById(orderId).awaitSingle()
        order.markPaid()
        pgClient.confirm(order.pgKey).awaitSingle()
        order
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 한 가지. 트랜잭션이 비동기 경계를 잘 넘어간다는 건, 거꾸로 말하면 트랜잭션이 열린 채로 외부 HTTP 호출을 기다리는 시간도 그대로 유지된다는 뜻이다. reactive로 바꾼다고 &quot;트랜잭션 안에서 외부 API 호출&quot;이 안전해지지는 않는다. 호출 시간만큼 R2DBC 커넥션을 잡고 있는 건 똑같다.&lt;/p&gt;
&lt;h3 data-heading=&quot;락은 오히려 더 까다로워진다&quot; data-ke-size=&quot;size23&quot;&gt;락은 오히려 더 까다로워진다&lt;/h3&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;suspend 지점(외부 I/O 대기)에서도 락은 계속 잡혀 있다. I/O 대기 시간이 곧 락 점유 시간이다. 블로킹과 본질은 같은데, &quot;스레드는 안 쓰니까 괜찮다&quot;는 착각이 위험을 가린다&lt;/li&gt;
&lt;li&gt;락을 잡은 코루틴이 cancel되면 락 해제 보장이 까다로워진다. finally 블록 + 명시적 해제 + structured concurrency까지 신경 써야 한다&lt;/li&gt;
&lt;li&gt;&quot;적은 스레드로 많은 요청 처리&quot;라는 논블로킹의 장점이, 락으로 직렬화되는 구간에서는 무력화된다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 논블로킹 환경에서는 락보다 멱등성/Outbox 기반 설계가 자연스럽다. 외부 호출을 어차피 비동기로 처리할 거고, 트랜잭션 경계와 외부 호출을 분리하는 쪽이 패러다임과도 맞는다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;8. 마무리&quot; data-ke-size=&quot;size26&quot;&gt;8. 마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 질문으로 돌아가자.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&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;이제 답할 수 있다. 락은 한 시스템 내부 데이터의 일관성을 다루는 도구이고, 분산 정합성은 두 시스템 간 상태의 일치를 다루는 다른 문제다. 같은 단어(&quot;안전&quot;)로 묶여 있을 뿐, 문제도 도구도 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API 호출이 끼어든 트랜잭션에서 락을 먼저 꺼내드는 게 위험한 이유는 세 가지가 겹친다. 케이스 1~5 중 어느 것도 해결되지 않고, 락 점유 시간이 HTTP 타임아웃에 종속되며, 그 지연이 다른 트랜잭션으로 옮겨가면서 연쇄 장애를 만든다. 정합성도 못 잡고 가용성도 같이 잃는 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 써야 할 도구는 호출 순서 설계, 멱등성 키, Outbox, Saga 쪽이다. 시작은 호출 순서와 멱등성으로도 충분한 경우가 많고, 트래픽이 커지거나 외부 API가 불안정해지면 Outbox로 넘어가게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 도구 선택 이전이다. &quot;동시성 문제 같으니까 락&quot; 하고 반사적으로 넘어가지 말고, 섹션 2 같은 실패 모드 매트릭스를 한 번 그려보면 막아야 할 케이스가 보이고, 거기서부터 도구가 자연스럽게 좁혀진다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;참고 자료&quot; data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Pat Helland, &quot;Life beyond Distributed Transactions: an Apostate's Opinion&quot; (2007)&lt;/li&gt;
&lt;li&gt;Martin Kleppmann, Designing Data-Intensive Applications, Chapter 7~9 (Transactions, Distributed Trouble, Consistency)&lt;/li&gt;
&lt;li&gt;Chris Richardson, Microservices Patterns &amp;mdash; &quot;Managing transactions with sagas&quot; 챕터, &quot;Transactional Outbox / Polling Publisher / Transaction Log Tailing&quot; 패턴&lt;/li&gt;
&lt;li&gt;microservices.io &amp;mdash; &lt;a href=&quot;https://microservices.io/patterns/data/saga.html&quot; data-tooltip-position=&quot;top&quot;&gt;Saga Pattern&lt;/a&gt;, &lt;a href=&quot;https://microservices.io/patterns/data/transactional-outbox.html&quot; data-tooltip-position=&quot;top&quot;&gt;Transactional Outbox&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Stripe API Reference &amp;mdash; &lt;a href=&quot;https://stripe.com/docs/api/idempotent_requests&quot; data-tooltip-position=&quot;top&quot;&gt;Idempotent Requests&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>운영</category>
      <category>pg결제</category>
      <category>낙관적락</category>
      <category>동시성제어</category>
      <category>비관적락</category>
      <category>운영</category>
      <author>ioh'sDeveloper</author>
      <guid isPermaLink="true">https://develop-tracking.tistory.com/291</guid>
      <comments>https://develop-tracking.tistory.com/entry/%EC%99%B8%EB%B6%80-API-%ED%98%B8%EC%B6%9C%EC%9D%B4-%EB%82%80-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EC%9D%B4-%EB%8B%B5%EC%9D%B4-%EC%95%84%EB%8B%8C-%EC%9D%B4%EC%9C%A0#entry291comment</comments>
      <pubDate>Sun, 17 May 2026 18:47:55 +0900</pubDate>
    </item>
    <item>
      <title>WIL - 10주차 (10주 동안 내가 단단해진 곳은 기술만이 아니었다)</title>
      <link>https://develop-tracking.tistory.com/entry/WIL-10%EC%A3%BC%EC%B0%A8-10%EC%A3%BC-%EB%8F%99%EC%95%88-%EB%82%B4%EA%B0%80-%EB%8B%A8%EB%8B%A8%ED%95%B4%EC%A7%84-%EA%B3%B3%EC%9D%80-%EA%B8%B0%EC%88%A0%EB%A7%8C%EC%9D%B4-%EC%95%84%EB%8B%88%EC%97%88%EB%8B%A4</link>
      <description>&lt;h2 data-heading=&quot;이번 주에 새로 배운 것&quot; data-ke-size=&quot;size26&quot;&gt;이번 주에 새로 배운 것&lt;/h2&gt;
&lt;h3 data-heading=&quot;개념을 제대로 이해하면, 아키텍처가 보이기 시작한다&quot; data-ke-size=&quot;size23&quot;&gt;개념을 제대로 이해하면, 아키텍처가 보이기 시작한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주에 가장 크게 느낀 건 &quot;&lt;b&gt;개념을 정확히 아는 것이 아키텍처 역량과 연결된다&lt;/b&gt;&quot;는 거였다. 예전엔 아키텍처를 잘 짜는 사람은 뭔가 감이 좋은 사람이라고 막연히 생각했다. 그런데 10주를 돌아보니 그게 아니었다. 개념 하나를 깊이 이해하면 그 개념이 설계 선택의 근거가 되고, 근거가 쌓이면 아키텍처가 자연스럽게 나온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복합 인덱스의 B+Tree 리프 노드 정렬 방식을 이해하니까 &quot;카디널리티가 먼저가 아니라 등호 조건이 먼저&quot;라는 실제 규칙이 보였고, 트랜잭션이 ThreadLocal에 바인딩된다는 걸 직접 코드로 부딪혀보니까 비동기 콜백에서 왜 @Transactional이 안 먹히는지가 설명이 됐다. 원리를 안 채로 설계를 하는 것과 감으로 설계를 하는 것은 결과물의 단단함이 완전히 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아키텍처를 배우러 왔는데, 결국 원리를 배우는 게 아키텍처를 배우는 길이었다. 이 순서가 반대였으면 10주가 훨씬 더 얕게 지나갔을 것 같다.&lt;/p&gt;
&lt;h3 data-heading=&quot;개발자에게 기술만큼 중요한 건 소통이었다&quot; data-ke-size=&quot;size23&quot;&gt;개발자에게 기술만큼 중요한 건 소통이었다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10주 동안 기술 말고 가장 많이 훈련된 건 소통이었다. 혼자 끙끙대던 문제가 한 번의 대화로 풀린 적이 정말 많았다. 팀원들과 이야기하면서 내 사고의 막힌 부분이 드러났고, 멘토님들께 질문을 던지면서 내가 뭘 모르는지가 오히려 선명해졌다. 혼자 생각하면 같은 자리를 도는데, 사람과 이야기하면 앞으로 갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회사에서도 같은 걸 체감했다. 데드라인이 빠듯한 상황에서 리드분의 의견을 수용하면서도 놓칠 수 없는 부분을 문서로 정리해 제안했고, 프론트 개발자분들 질문에 하나하나 답하면서 팀이 앞으로 나아갈 수 있게 도왔다. 기술만 잘한다고 결과가 나오는 게 아니었다. 기술을 말로 옮기고, 상대방의 말을 받아들이고, 다시 내 언어로 돌려주는 능력이 결국 같이 일을 풀어내는 힘이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발은 혼자 하는 일이 아니라는 걸, 10주 내내 계속 확인한 셈이다.&lt;/p&gt;
&lt;h3 data-heading=&quot;모든 피드백은 재료다, 단 내 생각을 좁히지만 않으면&quot; data-ke-size=&quot;size23&quot;&gt;모든 피드백은 재료다, 단 내 생각을 좁히지만 않으면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3주차에 &quot;Blue Book 스타일(Domain Service &amp;rarr; Repository 직접 호출)&quot;을 택했을 때, 이 방식에 동의하는 의견은 많지 않았다. 솔직히 처음엔 흔들렸다. 그런데 곰곰이 생각해보니 내 선택에는 내 근거가 있었고, 다른 분들의 의견에도 각자의 근거가 있었다. 한쪽이 진리고 한쪽이 틀린 게 아니었다.&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;모든 사람의 말이 다 내 피드백이고 도움이 된다는 감각. 다만 거기서 내 생각을 좁히지 않는 것. 이 두 가지를 10주 동안 계속 연습한 것 같다.&lt;/p&gt;
&lt;h3 data-heading=&quot;회고 스터디는 앞으로도 계속 해보고 싶다&quot; data-ke-size=&quot;size23&quot;&gt;회고 스터디는 앞으로도 계속 해보고 싶다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매주 WIL을 쓰고, 다른 개발자들과 회고를 나누고, 다른 사람의 회고를 읽으면서 얻은 게 생각보다 컸다. 회고를 쓰면 그 주에 뭘 했는지가 객관화되고, 다음 주에 뭘 바꿔야 할지가 보인다. 처음엔 기록용이었는데, 지나고 나니 회고가 성장의 축이었다.&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;앞으로도 이 회고 습관은 가져가고 싶다. 혼자 쓰는 것도 좋지만, 함께 했던 회고 스터디가 훨씬 힘이 됐다. 다른 사람의 시선이 들어오면 내 회고도 더 날카로워진다. 끝나더라도 비슷한 형태로 이어갈 방법을 찾아보려 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;이런 고민이 있었어요&quot; data-ke-size=&quot;size26&quot;&gt;이런 고민이 있었어요&lt;/h2&gt;
&lt;h3 data-heading=&quot;10주 동안 지나온 길을 돌아보면&quot; data-ke-size=&quot;size23&quot;&gt;10주 동안 지나온 길을 돌아보면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1주차에는 TDD라는 과제로 시작했다. 테스트가 구현 뒤에 붙는 게 아니라 설계를 끌어내는 도구라는 걸 몸으로 배우면서, 첫 주부터 &quot;왜?&quot;를 붙이는 습관이 시작됐다. 2주차에는 유비쿼터스 언어를 먼저 정의하고 들어가니 ERD와 시퀀스 다이어그램이 같은 방향으로 따라붙었다. 용어가 설계의 출발점이 될 수 있다는 걸 이때 알았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3주차와 4주차는 도메인 모델링과 트랜잭션/동시성 제어였다. 순수 POJO를 끝까지 밀고 간 결정, 도메인별 Lock 전략을 근거 있게 고른 경험이 이 시기의 중심이었다. &quot;감수와 타협은 다르다&quot;라는 문장도 이때 생겼다. 5주차에는 읽기 성능 최적화로 넘어가면서 인덱스, 캐시, 비정규화, 파티셔닝을 원리 수준에서 파고들었고, 블로그 세 편을 이 주제로 썼다. &quot;왜?&quot;를 끝까지 던지면 글감이 자연스럽게 만들어진다는 걸 알게 된 시기다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6주차 PG 연동에서는 외부 시스템의 불확실성을 처음으로 설계에 담아봤다. UNKNOWN이라는 상태를 인정하는 것이 분산 설계의 출발점이라는 것, 서킷브레이커는 재시도 도구가 아니라 장애 격리 도구라는 것이 이때 체득됐다. 7주차는 Kafka Outbox와 EDA였다. @TransactionalEventListener 동작을 세 번 시도한 끝에 가장 단순한 답에 도달했고, 이론값 500건/초와 실측값 95건/초의 격차를 분석하면서 시스템을 진짜 이해하는 경험을 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8주차 대기열에서는 배치 크기 M을 방정식으로 역산했다. 숫자 하나에 근거가 생기니까 그 근거가 여러 설계 결정을 동시에 받쳐줬다. 이 경험이 회사 테이블 튜닝 권한을 받아내는 데까지 이어졌다는 게 개인적으로 가장 뿌듯한 순간이었다. 9주차는 Redis ZSET 랭킹이었는데, &quot;best-effort&quot;라는 단어 하나로 사고를 멈추면 안 된다는 걸 배웠다. 같은 best-effort 안에서도 over-count와 under-count는 완전히 다른 결함이다. 이 구분이 설계였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10주차 Spring Batch 랭킹에서는 정교하게 쌓아 올린 답이 틀린 전제 위에 있으면 통째로 무의미해진다는 걸 경험했다. AI와의 설계 대화도, 내 논리도, 코드를 먼저 읽지 않은 상태에서는 공중에 떠 있었던 셈이다. 10주의 마지막 주에 &quot;답을 의심하기 전에 질문을 의심하라&quot;를 배우게 된 게 의미 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10주를 쭉 이어놓고 보니, 주제는 매주 바뀌었는데 훈련된 근육은 하나였다. &quot;왜 이 선택인가&quot;를 설명할 수 있는 근육. 기술 하나를 배우는 게 아니라 이 근육을 키우는 10주였다.&lt;/p&gt;
&lt;h3 data-heading=&quot;기술적으로 나에게 남은 것&quot; data-ke-size=&quot;size23&quot;&gt;기술적으로 나에게 남은 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10주 동안 쌓인 기술 근육을 정리해보면 결국 몇 가지로 추려진다. 트랜잭션 경계를 스스로 설계할 수 있게 됐다는 것 &amp;mdash; 언제 전파를 쓰고, 어디서 REQUIRES_NEW로 분리하고, 어떤 경우에 원자적 UPDATE로 가야 하는지를 근거 있게 말할 수 있게 됐다. 동시성 제어도 더 이상 &quot;락 걸면 되겠지&quot;가 아니다. 비관적 락, 낙관적 락, 원자적 UPDATE 중에서 도메인 특성에 맞는 걸 고르는 기준이 머릿속에 자리 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽기 성능 영역에서는 복합 인덱스 컬럼 순서, 커버링 인덱스, Buffer Pool과 캐시의 상호작용, readOnly 전파와 Replication Lag까지 원리 수준에서 설명할 수 있게 됐다. 캐시 도입이 오히려 p95를 악화시키는 역설까지 경험했으니, &quot;캐시는 은탄환이 아니다&quot;라는 문장이 체화됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 시스템 연동은 6주차 PG에서 크게 배웠다. 성공/실패 이분법이 통하지 않는 세계가 있다는 것, UNKNOWN 상태를 명시적으로 설계에 담아야 한다는 것, 서킷브레이커&amp;middot;Bulkhead&amp;middot;Retry&amp;middot;Fallback이 각각 다른 관심사를 다룬다는 것. 7주차 Kafka Outbox에서는 @Transactional이 ThreadLocal에 바인딩된다는 사실이 비동기 컨텍스트에서 어떻게 깨지는지를 코드로 재현해보면서 제대로 이해했다. self-invocation 버그도 처음으로 실물로 만났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8주차 대기열에서는 &quot;감으로 정한 숫자&quot;에 근거를 대는 훈련을 했다. 커넥션 풀&amp;middot;점유 시간&amp;middot;안전 마진에서 실효 TPS를 역산하는 방법을 익히고 나니, 회사 테이블 튜닝에서 인덱스 하나를 추가할 때도 실행계획과 스캔 건수로 설명할 수 있게 됐다. 9주차와 10주차의 랭킹&amp;middot;배치 과제에서는 &quot;raw fact부터 저장&quot;, &quot;수식과 원천 데이터를 분리&quot;, &quot;전제를 먼저 의심&quot; 같은 설계 원칙이 자리 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기술들을 다 외웠다기보다, 각 주제를 파고들면서 &quot;왜 이 선택인가&quot;를 설명하는 감각이 몸에 붙은 게 진짜 자산이다. 기술은 바뀌지만, 이 감각은 다음 기술에도 똑같이 쓰인다.&lt;/p&gt;
&lt;h3 data-heading=&quot;회사에서도 같이 성장한 10주&quot; data-ke-size=&quot;size23&quot;&gt;회사에서도 같이 성장한 10주&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;루퍼스 10주 동안 회사에서도 프로젝트 두 개를 끌고 갔다. 4주차에는 보험 도메인 신규 프로젝트가 들어왔고, 기획서에 정책이 빠져 있는 부분을 AI로 선제 정리해서 기획 쪽에 제안했다. 기다리는 게 아니라 먼저 움직이니까 데드라인을 앞서갈 수 있었다. 그때부터 &quot;환경이 부족하다&quot;는 말 대신 &quot;지금 내가 뭘 할 수 있지?&quot;를 먼저 묻는 습관이 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6주차에는 손해보험 간병서비스 프로젝트에서 청구&amp;middot;지급&amp;middot;입금 세 도메인의 CRUD를 마무리했다. SFTP 연동, 입금 매칭, 상태 전이 같은 실무 로직을 구현하면서 루퍼스에서 배운 것들이 그대로 꽂혔다. &quot;&amp;plusmn;10원 오차 허용 매칭, 5영업일 후 미해소 처리&quot;라는 정책을 구현할 때, PG 연동에서 설계한 UNKNOWN 패턴과 같은 원리라는 게 보였다. 서로 다른 도메인에서 같은 원리를 발견하는 순간이 실무 감각을 가장 크게 넓혀줬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8주차에는 DBA에게 직접 설득해서 테이블 튜닝 권한을 받았다. 예전 같았으면 &quot;이건 DBA 일&quot;이라고 넘겼을 텐데, 5주차 읽기 성능 최적화를 공부하면서 쌓은 근거들이 그 제안을 가능하게 했다. 실행계획 기준 20만 건 스캔, 네이밍 불일치, 수동 DB 작업 반복 같은 구조적 문제를 감정이 아니라 수치로 꺼냈더니 논의가 시작됐다. 학습이 실무의 발언권을 만들어준 경험이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회사와 루퍼스를 병행하면서 가장 크게 느낀 건, 둘이 경쟁 관계가 아니라는 것이다. 회사에서 부딪힌 문제가 루퍼스의 과제가 됐고, 루퍼스에서 공부한 개념이 회사의 설계가 됐다. 이 사이클이 돌아가기 시작하면서 &quot;왜 이걸 따로 공부하지&quot;라는 의문이 사라졌다. 같은 근육을 양쪽에서 쓰고 있었을 뿐이다.&lt;/p&gt;
&lt;h3 data-heading=&quot;아쉬움이 남는다는 건 욕심이 생겼다는 뜻&quot; data-ke-size=&quot;size23&quot;&gt;아쉬움이 남는다는 건 욕심이 생겼다는 뜻&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매주 정말 열심히 했다고 느꼈는데, 지금 돌아보면 더 파고들 수 있었던 주제들이 떠오른다. 6주차 분산 트랜잭션은 블로그 한 편으로 마무리했는데 서킷브레이커 설계 근거까지 엮어볼 수 있었고, 8주차 n8n은 개념만 알고 레포는 못 만들었다. 10주차 가중치 공식을 DB 테이블로 분리하는 아이디어도 scope 초과라는 이유로 미뤄뒀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아쉬움이 남는다는 건 욕심이 생겼다는 거라고 누가 그랬는데, 정확히 그 상태인 것 같다. 10주 전에는 &quot;이 기술 써봤습니다&quot;까지만 말할 수 있었는데, 지금은 더 파고들지 못한 지점이 아쉬움으로 남는다. 출발선이 바뀌어 있다는 뜻이다.&lt;/p&gt;
&lt;h3 data-heading=&quot;우리 팀이 있어서 끝까지 올 수 있었다&quot; data-ke-size=&quot;size23&quot;&gt;우리 팀이 있어서 끝까지 올 수 있었다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 크게 남는 건 사람이다. 기술적으로 막혔을 때 대화로 실마리를 찾았던 순간, 다른 사고방식을 가진 사람을 보면서 내 폭을 넓혔던 순간, 서로 지칠 때 밀어주고 당겨줬던 순간. 이런 게 쌓여서 10주를 버티게 했다. 혼자 있었으면 중간에 멈췄을 고비가 여러 번 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 팀 안에서는 누가 힘들어 보이면 자연스럽게 서기를 대신해주고, 역할이 바뀌면 반대로 기대는 분위기가 있었다. 내가 지쳐있을 때 누군가 먼저 손을 내밀어줬고, 반대로 다른 사람이 힘들어 보일 때 내가 끌어주는 순간도 있었다. 이 작은 배려들이 쌓여서 10주 내내 한 사람도 놓치지 않고 같이 왔다는 게, 끝나갈수록 더 크게 느껴진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커뮤니케이션에서도 10주 내내 배웠다. 질문을 많이 하는 편이라 팀에 부담이 됐을 수도 있는데, 한 번도 귀찮은 티 없이 들어주고 같이 고민해줬다. 내가 던진 질문이 다른 사람의 생각을 자극하고, 그 사람의 답이 또 내 사고를 확장시키는 순환이 자연스럽게 돌아갔다. 혼자 생각했으면 한참 돌았을 문제들이 대화 한 번으로 풀린 경험을 반복하면서, 개발자에게 커뮤니케이션이 왜 기술만큼 중요한지를 몸으로 이해했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멘토님들과의 대화도 빼놓을 수 없다. 질문을 많이 해서 얻어간 게 정말 많다.&amp;nbsp; 서로 다른 시각을 동시에 놓고 비교하면서 &quot;정답이 하나가 아닌 영역&quot;이라는 감각을 얻었고, 3주차에 내 선택(Blue Book 스타일)을 흔들리지 않고 지킬 수 있었던 것도 이 감각 덕분이다. 질문을 두려워하지 않았던 게 가장 큰 자산이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시간을 그냥 &quot;빡세게 공부한 10주&quot;로 기억하지 않고, &quot;같이 해낸 10주&quot;로 기억하고 싶다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;앞으로 이렇게 하고 싶어요&quot; data-ke-size=&quot;size26&quot;&gt;앞으로 이렇게 하고 싶어요&lt;/h2&gt;
&lt;h3 data-heading=&quot;개념 공부는 속도가 아니라 깊이로&quot; data-ke-size=&quot;size23&quot;&gt;개념 공부는 속도가 아니라 깊이로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5주차 이후로 확인한 건, 깊이 있게 이해한 개념 하나가 얕게 훑은 열 개보다 쓰임새가 크다는 거였다. 앞으로도 새로운 주제를 만나면 &quot;왜 그렇게 되는지&quot;를 원리 수준까지 파고드는 습관을 유지하고 싶다. 블로그나 문서로 풀어낼 수 있을 만큼 이해했을 때가 진짜 이해한 거라고 스스로 기준을 세워두려 한다.&lt;/p&gt;
&lt;h3 data-heading=&quot;소통에서 머뭇거리지 않기&quot; data-ke-size=&quot;size23&quot;&gt;소통에서 머뭇거리지 않기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10주 동안 &quot;일단 말하기&quot;가 늘었지만, 여전히 더 나아지고 싶은 부분이다. 틀려도 먼저 꺼내고, 상대 관점을 먼저 물어보고, 내 근거를 같이 설명하는 습관. 개발자에게 이게 기술만큼 중요한 역량이라는 걸 10주 내내 확인했으니, 앞으로도 계속 연습할 생각이다.&lt;/p&gt;
&lt;h3 data-heading=&quot;모든 피드백은 재료, 내 생각은 안 좁히기&quot; data-ke-size=&quot;size23&quot;&gt;모든 피드백은 재료, 내 생각은 안 좁히기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;누군가의 말이 진리는 아니지만 전부 참고할 가치가 있다는 태도. 이걸 놓지 않고 싶다. 다른 시각을 듣되 거기서 내 사고를 좁히지 않고, 좋은 건 가져가고 맞지 않는 건 근거와 함께 걷어내는 방식. 이 태도가 있으면 어떤 팀에 들어가도 균형을 잃지 않을 것 같다.&lt;/p&gt;
&lt;h3 data-heading=&quot;회고 스터디는 형태를 바꿔서라도 이어가기&quot; data-ke-size=&quot;size23&quot;&gt;회고 스터디는 형태를 바꿔서라도 이어가기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;루퍼스가 끝나도 회고 습관은 계속 가져가고 싶다. 혼자 쓰는 것보다 같이 쓰고 나누는 쪽이 훨씬 효과가 좋으니, 작은 규모로라도 비슷한 스터디를 이어갈 방법을 찾아보려 한다. 나중에 이력 전체를 돌아볼 때, 회고가 쌓인 개발자와 안 쌓인 개발자의 궤적은 분명 다를 거라고 생각한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;한 줄 요약&quot; data-ke-size=&quot;size26&quot;&gt;한 줄 요약&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;10주 동안 단단해진 건 기술만이 아니라 사고 방식, 소통하는 태도, 피드백을 받아들이는 방식, 그리고 꾸준히 돌아보는 습관이었다.&lt;/b&gt; 기술은 계속 바뀌겠지만 이 근육들은 어디에 가서도 남는다. 여기까지 올 수 있었던 건 같이 고민하고 같이 버텨준 사람들 덕분이고, 그래서 이 10주가 더 뜻깊게 남는다.&lt;/p&gt;</description>
      <category>스터디/루퍼스</category>
      <category>루퍼스</category>
      <category>루퍼스 루프팩</category>
      <author>ioh'sDeveloper</author>
      <guid isPermaLink="true">https://develop-tracking.tistory.com/290</guid>
      <comments>https://develop-tracking.tistory.com/entry/WIL-10%EC%A3%BC%EC%B0%A8-10%EC%A3%BC-%EB%8F%99%EC%95%88-%EB%82%B4%EA%B0%80-%EB%8B%A8%EB%8B%A8%ED%95%B4%EC%A7%84-%EA%B3%B3%EC%9D%80-%EA%B8%B0%EC%88%A0%EB%A7%8C%EC%9D%B4-%EC%95%84%EB%8B%88%EC%97%88%EB%8B%A4#entry290comment</comments>
      <pubDate>Sun, 19 Apr 2026 21:39:41 +0900</pubDate>
    </item>
    <item>
      <title>한 번에 끝낼 작업에 청크를 선택했다: 배치 프레임워크를 잘못 고른 비용</title>
      <link>https://develop-tracking.tistory.com/entry/%ED%95%9C-%EB%B2%88%EC%97%90-%EB%81%9D%EB%82%BC-%EC%9E%91%EC%97%85%EC%97%90-%EC%B2%AD%ED%81%AC%EB%A5%BC-%EC%84%A0%ED%83%9D%ED%96%88%EB%8B%A4-%EB%B0%B0%EC%B9%98-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC%EB%A5%BC-%EC%9E%98%EB%AA%BB-%EA%B3%A0%EB%A5%B8-%EB%B9%84%EC%9A%A9</link>
      <description>&lt;p data-end=&quot;662&quot; data-start=&quot;535&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TL;DR&lt;/b&gt;&lt;br /&gt;한 번에 집계해서 교체해야 하는 작업에 청크 기반 처리를 선택하면서, 프레임워크의 장점은 살리지 못하고 복잡성만 늘어났다. 그 과정에서 도구의 형식보다 문제의 본질에 맞는 선택이 더 중요하다는 것을 배웠다.&lt;/p&gt;
&lt;h2 data-end=&quot;680&quot; data-start=&quot;664&quot; data-section-id=&quot;1yguuax&quot; data-ke-size=&quot;size26&quot;&gt;이번 글에서 다루는 문제&lt;/h2&gt;
&lt;p data-end=&quot;723&quot; data-start=&quot;682&quot; data-ke-size=&quot;size16&quot;&gt;이커머스 서비스에서 &amp;ldquo;이번 주 인기 상품 TOP 100&amp;rdquo;을 만들어야 했다.&lt;/p&gt;
&lt;p data-end=&quot;832&quot; data-start=&quot;725&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 상품을 조회하거나, 좋아요를 누르거나, 주문할 때마다 이벤트가 쌓인다. 이 이벤트를 주 단위로 모아 상품별 점수를 계산하고, 점수가 높은 순서대로 상위 100개 상품을 저장하면 된다.&lt;/p&gt;
&lt;p data-end=&quot;971&quot; data-start=&quot;834&quot; data-ke-size=&quot;size16&quot;&gt;이미 데이터베이스에는 ranking_event 테이블이 있었다.&lt;br /&gt;이 테이블에는 &amp;ldquo;어떤 상품에 어떤 행동이 언제 발생했는지&amp;rdquo;라는 원본 이벤트 데이터가 저장돼 있었다. 이 데이터를 다시 읽어서 원하는 방식으로 점수를 계산할 수 있는 구조였다.&lt;/p&gt;
&lt;p data-end=&quot;989&quot; data-start=&quot;973&quot; data-ke-size=&quot;size16&quot;&gt;해야 할 일은 아래와 같았다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1093&quot; data-start=&quot;991&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1013&quot; data-start=&quot;991&quot; data-section-id=&quot;1jndqid&quot;&gt;이번 주 이벤트를 모두 읽는다.&lt;/li&gt;
&lt;li data-end=&quot;1032&quot; data-start=&quot;1014&quot; data-section-id=&quot;1u84m76&quot;&gt;상품별 점수를 계산한다.&lt;/li&gt;
&lt;li data-end=&quot;1063&quot; data-start=&quot;1033&quot; data-section-id=&quot;1htm0th&quot;&gt;점수가 높은 순서대로 상위 100개를 뽑는다.&lt;/li&gt;
&lt;li data-end=&quot;1093&quot; data-start=&quot;1064&quot; data-section-id=&quot;rl3uk8&quot;&gt;결과를 조회용 집계 테이블에 한 번에 반영한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;1183&quot; data-start=&quot;1095&quot; data-ke-size=&quot;size16&quot;&gt;이 작업을 스프링 배치(Spring Batch)로 구현하려고 했고, 여기서 첫 번째 선택이 생겼다.&lt;br /&gt;&lt;b&gt;한 번에 처리할 것인가, 나눠서 처리할 것인가.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-end=&quot;1207&quot; data-start=&quot;1185&quot; data-section-id=&quot;fqez1x&quot; data-ke-size=&quot;size26&quot;&gt;선택지: Tasklet과 Chunk&lt;/h2&gt;
&lt;p data-end=&quot;1236&quot; data-start=&quot;1209&quot; data-ke-size=&quot;size16&quot;&gt;스프링 배치에는 대표적으로 두 가지 방식이 있다.&lt;/p&gt;
&lt;p data-end=&quot;1333&quot; data-start=&quot;1238&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Tasklet&lt;/b&gt;은 작업을 한 번에 처리하는 방식이다.&lt;br /&gt;하나의 메서드 안에서 읽기, 계산, 저장을 모두 끝낸다. SQL로 집계해서 바로 저장하는 작업에 잘 맞는다.&lt;/p&gt;
&lt;p data-end=&quot;1434&quot; data-start=&quot;1335&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Chunk&lt;/b&gt;는 데이터를 일정 단위로 나눠 처리하는 방식이다.&lt;br /&gt;정해진 개수만큼 읽고, 가공하고, 저장하는 흐름을 반복한다. 대량 데이터를 조금씩 처리해야 할 때 유리하다.&lt;/p&gt;
&lt;p data-end=&quot;1445&quot; data-start=&quot;1436&quot; data-ke-size=&quot;size16&quot;&gt;정리하면 이렇다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;구분TaskletChunk
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;1649&quot; data-start=&quot;1447&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-end=&quot;1649&quot; data-start=&quot;1486&quot;&gt;
&lt;tr data-end=&quot;1523&quot; data-start=&quot;1486&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1494&quot; data-start=&quot;1486&quot;&gt;처리 방식&lt;/td&gt;
&lt;td data-end=&quot;1504&quot; data-start=&quot;1494&quot; data-col-size=&quot;sm&quot;&gt;한 번에 처리&lt;/td&gt;
&lt;td data-end=&quot;1523&quot; data-start=&quot;1504&quot; data-col-size=&quot;sm&quot;&gt;일정 크기로 나눠 반복 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1554&quot; data-start=&quot;1524&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1531&quot; data-start=&quot;1524&quot;&gt;트랜잭션&lt;/td&gt;
&lt;td data-end=&quot;1542&quot; data-start=&quot;1531&quot; data-col-size=&quot;sm&quot;&gt;전체 작업 기준&lt;/td&gt;
&lt;td data-end=&quot;1554&quot; data-start=&quot;1542&quot; data-col-size=&quot;sm&quot;&gt;청크 단위 기준&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1604&quot; data-start=&quot;1555&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1565&quot; data-start=&quot;1555&quot;&gt;장애 시 특성&lt;/td&gt;
&lt;td data-end=&quot;1582&quot; data-start=&quot;1565&quot; data-col-size=&quot;sm&quot;&gt;전체 성공 또는 전체 롤백&lt;/td&gt;
&lt;td data-end=&quot;1604&quot; data-start=&quot;1582&quot; data-col-size=&quot;sm&quot;&gt;이미 커밋된 청크는 남을 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1649&quot; data-start=&quot;1605&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1615&quot; data-start=&quot;1605&quot;&gt;잘 맞는 작업&lt;/td&gt;
&lt;td data-end=&quot;1630&quot; data-start=&quot;1615&quot; data-col-size=&quot;sm&quot;&gt;집계, 단순 일괄 처리&lt;/td&gt;
&lt;td data-end=&quot;1649&quot; data-start=&quot;1630&quot; data-col-size=&quot;sm&quot;&gt;대량 데이터 읽기&amp;middot;변환&amp;middot;저장&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1674&quot; data-start=&quot;1651&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 이번 작업의 본질은 무엇이었을까.&lt;/p&gt;
&lt;p data-end=&quot;1770&quot; data-start=&quot;1676&quot; data-ke-size=&quot;size16&quot;&gt;이번 작업은 데이터를 조금씩 처리해서 중간 결과를 계속 저장하는 작업이 아니었다.&lt;br /&gt;&lt;b&gt;전체 이벤트를 다 읽고 나서야 최종 순위를 계산할 수 있는 집계 작업&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;1869&quot; data-start=&quot;1772&quot; data-ke-size=&quot;size16&quot;&gt;100위에 들어갈 상품이 무엇인지는 마지막 이벤트까지 모두 봐야 알 수 있다.&lt;br /&gt;즉, 이 작업은 처음부터 끝까지 본 뒤 최종 결과를 한 번에 반영하는 방식이 더 잘 맞았다.&lt;/p&gt;
&lt;p data-end=&quot;1951&quot; data-start=&quot;1871&quot; data-ke-size=&quot;size16&quot;&gt;돌이켜보면, 이 요구사항에는 &lt;b&gt;Tasklet이 더 잘 맞는 선택&lt;/b&gt;이었다.&lt;/p&gt;
&lt;h2 data-end=&quot;1974&quot; data-start=&quot;1953&quot; data-section-id=&quot;zvljp7&quot; data-ke-size=&quot;size26&quot;&gt;그런데 왜 Chunk를 선택했을까&lt;/h2&gt;
&lt;p data-end=&quot;2008&quot; data-start=&quot;1976&quot; data-ke-size=&quot;size16&quot;&gt;그럼에도 나는 Chunk를 선택했다. 이유는 두 가지였다.&lt;/p&gt;
&lt;p data-end=&quot;2091&quot; data-start=&quot;2010&quot; data-ke-size=&quot;size16&quot;&gt;첫째, 학습 목적이었다.&lt;br /&gt;청크 기반 처리 방식의 구조를 직접 구현해 보면서 읽기, 가공, 저장의 책임 분리와 처리 단위를 체감해 보고 싶었다.&lt;/p&gt;
&lt;p data-end=&quot;2217&quot; data-start=&quot;2093&quot; data-ke-size=&quot;size16&quot;&gt;둘째, 자바 로직을 재사용하고 싶었다.&lt;br /&gt;점수 계산 방식은 다른 실시간 처리 로직에서도 쓰고 있었기 때문에, SQL에 점수 계산식을 하드코딩하기보다 자바 계산기를 재사용하면 두 경로의 계산 규칙을 맞추기 쉽다고 생각했다.&lt;/p&gt;
&lt;p data-end=&quot;2283&quot; data-start=&quot;2219&quot; data-ke-size=&quot;size16&quot;&gt;선택 자체는 나름 합리적으로 보였다.&lt;br /&gt;문제는 &lt;b&gt;작업의 성격보다 도구의 학습 효과를 더 앞세웠다는 점&lt;/b&gt;이었다.&lt;/p&gt;
&lt;h2 data-end=&quot;2306&quot; data-start=&quot;2285&quot; data-section-id=&quot;1qurvlp&quot; data-ke-size=&quot;size26&quot;&gt;구현을 시작하자 바로 충돌이 났다&lt;/h2&gt;
&lt;p data-end=&quot;2334&quot; data-start=&quot;2308&quot; data-ke-size=&quot;size16&quot;&gt;청크 기반 처리 방식의 일반적인 흐름은 이렇다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2401&quot; data-start=&quot;2336&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2355&quot; data-start=&quot;2336&quot; data-section-id=&quot;1c4sj0m&quot;&gt;데이터를 일정 크기만큼 읽는다.&lt;/li&gt;
&lt;li data-end=&quot;2371&quot; data-start=&quot;2356&quot; data-section-id=&quot;14yqrxa&quot;&gt;읽은 데이터를 가공한다.&lt;/li&gt;
&lt;li data-end=&quot;2387&quot; data-start=&quot;2372&quot; data-section-id=&quot;lhuxhe&quot;&gt;가공한 결과를 저장한다.&lt;/li&gt;
&lt;li data-end=&quot;2401&quot; data-start=&quot;2388&quot; data-section-id=&quot;ppkfey&quot;&gt;이 과정을 반복한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2434&quot; data-start=&quot;2403&quot; data-ke-size=&quot;size16&quot;&gt;그런데 이번 랭킹 집계는 중간 결과가 노출되면 안 됐다.&lt;/p&gt;
&lt;p data-end=&quot;2533&quot; data-start=&quot;2436&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 1위부터 50위까지만 새 데이터로 바뀌고, 51위부터 100위는 이전 데이터가 남아 있으면 사용자는 반쯤 갱신된 랭킹을 보게 된다. 이건 허용할 수 없는 상태였다.&lt;/p&gt;
&lt;p data-end=&quot;2586&quot; data-start=&quot;2535&quot; data-ke-size=&quot;size16&quot;&gt;즉, 이 작업에는 &lt;b&gt;전부 반영되거나 아예 반영되지 않아야 하는 원자적 교체&lt;/b&gt;가 필요했다.&lt;/p&gt;
&lt;p data-end=&quot;2625&quot; data-start=&quot;2588&quot; data-ke-size=&quot;size16&quot;&gt;그래서 실제 구현은 청크 방식의 장점을 살리지 못하는 구조가 됐다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2739&quot; data-start=&quot;2627&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2649&quot; data-start=&quot;2627&quot; data-section-id=&quot;1y0lu4y&quot;&gt;읽기와 점수 계산은 청크로 처리했다.&lt;/li&gt;
&lt;li data-end=&quot;2672&quot; data-start=&quot;2650&quot; data-section-id=&quot;1h4tul0&quot;&gt;하지만 저장은 청크마다 하지 않았다.&lt;/li&gt;
&lt;li data-end=&quot;2698&quot; data-start=&quot;2673&quot; data-section-id=&quot;61cc1l&quot;&gt;각 청크 결과를 메모리에 누적해 두었다가,&lt;/li&gt;
&lt;li data-end=&quot;2739&quot; data-start=&quot;2699&quot; data-section-id=&quot;178305h&quot;&gt;작업 단계가 끝난 뒤 한 번에 삭제하고 다시 넣는 방식으로 교체했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2876&quot; data-start=&quot;2741&quot; data-ke-size=&quot;size16&quot;&gt;겉으로는 청크 방식이었지만, 실제 핵심 저장 로직은 마지막에 한 번에 실행됐다.&lt;br /&gt;즉, &lt;b&gt;형식은 Chunk였지만 결과적으로는 한 번에 처리하는 방식과 비슷하게 흘렀다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-end=&quot;2913&quot; data-start=&quot;2878&quot; data-section-id=&quot;1492cgc&quot; data-ke-size=&quot;size26&quot;&gt;첫 번째 대가: 실패 시 기존 데이터까지 사라질 수 있었다&lt;/h2&gt;
&lt;p data-end=&quot;2958&quot; data-start=&quot;2915&quot; data-ke-size=&quot;size16&quot;&gt;문제는 작업 단계가 끝난 뒤 실행되는 afterStep() 콜백에 있었다.&lt;/p&gt;
&lt;p data-end=&quot;3033&quot; data-start=&quot;2960&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 이 콜백이 작업이 성공했을 때만 실행될 것처럼 생각했다. 그런데 실제로는 &lt;b&gt;성공하든 실패하든 항상 호출되는 구조&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;3062&quot; data-start=&quot;3035&quot; data-ke-size=&quot;size16&quot;&gt;이 사실을 모른 채 구현하면 어떤 일이 생기느냐.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;3207&quot; data-start=&quot;3064&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;3085&quot; data-start=&quot;3064&quot; data-section-id=&quot;1wvfb48&quot;&gt;이벤트를 읽는 도중 실패한다.&lt;/li&gt;
&lt;li data-end=&quot;3106&quot; data-start=&quot;3086&quot; data-section-id=&quot;r139im&quot;&gt;작업 단계는 실패로 끝난다.&lt;/li&gt;
&lt;li data-end=&quot;3140&quot; data-start=&quot;3107&quot; data-section-id=&quot;r481t1&quot;&gt;그런데 afterStep()은 여전히 호출된다.&lt;/li&gt;
&lt;li data-end=&quot;3166&quot; data-start=&quot;3141&quot; data-section-id=&quot;1ijovuo&quot;&gt;여기서 기존 랭킹 데이터를 삭제한다.&lt;/li&gt;
&lt;li data-end=&quot;3207&quot; data-start=&quot;3167&quot; data-section-id=&quot;tcnmgf&quot;&gt;새로 넣을 누적 데이터는 비어 있으니 결과적으로 빈 테이블이 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;3287&quot; data-start=&quot;3209&quot; data-ke-size=&quot;size16&quot;&gt;즉, 원래라면 실패 시 기존 데이터가 그대로 남아야 하는데,&lt;br /&gt;오히려 실패 때문에 기존 정상 데이터까지 사라질 수 있는 구조가 돼 있었다.&lt;/p&gt;
&lt;p data-end=&quot;3350&quot; data-start=&quot;3289&quot; data-ke-size=&quot;size16&quot;&gt;수정 자체는 단순했다.&lt;br /&gt;작업 상태가 성공일 때만 마지막 교체 로직을 실행하도록 한 줄 가드를 넣으면 됐다.&lt;/p&gt;
&lt;p data-end=&quot;3434&quot; data-start=&quot;3352&quot; data-ke-size=&quot;size16&quot;&gt;하지만 중요한 건 코드 한 줄이 아니라,&lt;br /&gt;&lt;b&gt;청크 기반 처리의 안전성을 믿고 있었는데 실제 구조는 그 안전성을 이미 잃고 있었다는 점&lt;/b&gt;이었다.&lt;/p&gt;
&lt;h2 data-end=&quot;3463&quot; data-start=&quot;3436&quot; data-section-id=&quot;ma6xn7&quot; data-ke-size=&quot;size26&quot;&gt;두 번째 대가: 테스트가 실제 문제를 가렸다&lt;/h2&gt;
&lt;p data-end=&quot;3487&quot; data-start=&quot;3465&quot; data-ke-size=&quot;size16&quot;&gt;이 구조는 테스트에서도 문제를 만들었다.&lt;/p&gt;
&lt;p data-end=&quot;3576&quot; data-start=&quot;3489&quot; data-ke-size=&quot;size16&quot;&gt;테스트에서는 데이터베이스에 테스트 데이터를 직접 넣고 배치를 실행한 뒤 결과를 확인했다.&lt;br /&gt;그런데 운영 환경에서 데이터가 들어오는 방식은 테스트와 달랐다.&lt;/p&gt;
&lt;p data-end=&quot;3621&quot; data-start=&quot;3578&quot; data-ke-size=&quot;size16&quot;&gt;운영에서는 이벤트 수신기가 데이터를 저장하는 과정에서 두 가지 변환이 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3668&quot; data-start=&quot;3623&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3643&quot; data-start=&quot;3623&quot; data-section-id=&quot;9ocayb&quot;&gt;이벤트 타입 이름이 축약됐다.&lt;/li&gt;
&lt;li data-end=&quot;3668&quot; data-start=&quot;3644&quot; data-section-id=&quot;13qt1m7&quot;&gt;시간이 표준시 기준으로 변환돼 저장됐다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3762&quot; data-start=&quot;3670&quot; data-ke-size=&quot;size16&quot;&gt;반면 테스트에서는 이 경로를 거치지 않고 직접 데이터를 넣었다.&lt;br /&gt;그래서 테스트 환경에서는 문제가 드러나지 않았지만, 운영 환경에서는 다른 결과가 나올 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;3785&quot; data-start=&quot;3764&quot; data-ke-size=&quot;size16&quot;&gt;실제로는 이런 문제가 생길 수 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3845&quot; data-start=&quot;3787&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3816&quot; data-start=&quot;3787&quot; data-section-id=&quot;19qq9j&quot;&gt;이벤트 타입 값이 달라 점수 계산이 되지 않는다.&lt;/li&gt;
&lt;li data-end=&quot;3845&quot; data-start=&quot;3817&quot; data-section-id=&quot;yumok9&quot;&gt;시간대 차이 때문에 집계 기간 경계가 어긋난다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3886&quot; data-start=&quot;3847&quot; data-ke-size=&quot;size16&quot;&gt;즉, 테스트는 통과했지만 운영에서는 빈 랭킹이 나올 수 있는 구조였다.&lt;/p&gt;
&lt;p data-end=&quot;4030&quot; data-start=&quot;3888&quot; data-ke-size=&quot;size16&quot;&gt;이 경험을 통해 배운 건 단순히 &amp;ldquo;테스트를 더 잘 써야 한다&amp;rdquo;가 아니었다.&lt;br /&gt;&lt;b&gt;도구를 문제와 맞지 않게 비틀어 쓰면, 테스트가 검증해야 할 실제 경로도 함께 흐려진다&lt;/b&gt;는 점이었다.&lt;/p&gt;
&lt;h2 data-end=&quot;4051&quot; data-start=&quot;4032&quot; data-section-id=&quot;eekjr4&quot; data-ke-size=&quot;size26&quot;&gt;만약 Tasklet으로 갔다면&lt;/h2&gt;
&lt;p data-end=&quot;4099&quot; data-start=&quot;4053&quot; data-ke-size=&quot;size16&quot;&gt;만약 이 작업을 처음부터 Tasklet으로 구현했다면 구조는 훨씬 단순했을 것이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4165&quot; data-start=&quot;4101&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4120&quot; data-start=&quot;4101&quot; data-section-id=&quot;akrjfc&quot;&gt;집계 SQL을 한 번 실행한다.&lt;/li&gt;
&lt;li data-end=&quot;4135&quot; data-start=&quot;4121&quot; data-section-id=&quot;asoze6&quot;&gt;기존 랭킹을 교체한다.&lt;/li&gt;
&lt;li data-end=&quot;4148&quot; data-start=&quot;4136&quot; data-section-id=&quot;64ydy1&quot;&gt;성공하면 커밋한다.&lt;/li&gt;
&lt;li data-end=&quot;4165&quot; data-start=&quot;4149&quot; data-section-id=&quot;8u5qzr&quot;&gt;실패하면 전체를 롤백한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;4272&quot; data-start=&quot;4167&quot; data-ke-size=&quot;size16&quot;&gt;이 방식에서는 마지막 단계의 별도 콜백에 의존하지 않아도 된다.&lt;br /&gt;또한 저장 경로가 단순해지므로, 현재 구조에서 드러난 일부 시간대 처리 문제나 중간 저장 구조의 혼란도 줄어들 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;4291&quot; data-start=&quot;4274&quot; data-ke-size=&quot;size16&quot;&gt;물론 단점이 없는 것은 아니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4405&quot; data-start=&quot;4293&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4332&quot; data-start=&quot;4293&quot; data-section-id=&quot;3ibr7o&quot;&gt;점수 계산식이 SQL에 들어가면 자바 계산 로직과 분리될 수 있다.&lt;/li&gt;
&lt;li data-end=&quot;4364&quot; data-start=&quot;4333&quot; data-section-id=&quot;sc21vn&quot;&gt;읽기, 가공, 저장을 분리하는 학습 효과는 줄어든다.&lt;/li&gt;
&lt;li data-end=&quot;4405&quot; data-start=&quot;4365&quot; data-section-id=&quot;klzbu8&quot;&gt;데이터 규모가 매우 커지면 단일 SQL 성능을 추가로 검토해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;4464&quot; data-start=&quot;4407&quot; data-ke-size=&quot;size16&quot;&gt;그럼에도 이 요구사항 기준에서는,&lt;br /&gt;&lt;b&gt;Tasklet이 더 단순하고 더 정직한 선택&lt;/b&gt;이었다고 본다.&lt;/p&gt;
&lt;h2 data-end=&quot;4481&quot; data-start=&quot;4466&quot; data-section-id=&quot;1a12clz&quot; data-ke-size=&quot;size26&quot;&gt;이번 경험에서 배운 것&lt;/h2&gt;
&lt;h3 data-end=&quot;4508&quot; data-start=&quot;4483&quot; data-section-id=&quot;134rtx5&quot; data-ke-size=&quot;size23&quot;&gt;1. 프레임워크의 형식과 본질은 다르다&lt;/h3&gt;
&lt;p data-end=&quot;4610&quot; data-start=&quot;4510&quot; data-ke-size=&quot;size16&quot;&gt;읽기, 가공, 저장 컴포넌트를 나눠 놓는 것은 형식이다.&lt;br /&gt;하지만 청크 단위 처리의 진짜 가치는 &lt;b&gt;작은 단위로 커밋하고, 그 단위 기준으로 복구 가능성을 확보하는 데&lt;/b&gt; 있다.&lt;/p&gt;
&lt;p data-end=&quot;4670&quot; data-start=&quot;4612&quot; data-ke-size=&quot;size16&quot;&gt;형식만 가져오고 본질은 살리지 못하면,&lt;br /&gt;프레임워크를 쓴다는 이유로 오히려 안전하다고 착각할 수 있다.&lt;/p&gt;
&lt;h3 data-end=&quot;4697&quot; data-start=&quot;4672&quot; data-section-id=&quot;e5d490&quot; data-ke-size=&quot;size23&quot;&gt;2. 도구는 문제의 본질에 맞아야 한다&lt;/h3&gt;
&lt;p data-end=&quot;4759&quot; data-start=&quot;4699&quot; data-ke-size=&quot;size16&quot;&gt;이번 작업의 핵심은 대량 데이터 분산 처리보다 &lt;b&gt;최종 집계 결과를 한 번에 안전하게 교체하는 것&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;4819&quot; data-start=&quot;4761&quot; data-ke-size=&quot;size16&quot;&gt;그런데 나는 문제보다 도구의 구조를 먼저 봤고,&lt;br /&gt;그 결과 맞지 않는 도구를 억지로 끼워 맞추게 됐다.&lt;/p&gt;
&lt;h3 data-end=&quot;4857&quot; data-start=&quot;4821&quot; data-section-id=&quot;1n7305i&quot; data-ke-size=&quot;size23&quot;&gt;3. 학습을 위한 선택과 운영을 위한 선택은 구분해야 한다&lt;/h3&gt;
&lt;p data-end=&quot;4946&quot; data-start=&quot;4859&quot; data-ke-size=&quot;size16&quot;&gt;이번 경험은 많이 배웠다.&lt;br /&gt;작업 단계 종료 콜백의 동작 방식, 테스트 경로와 운영 경로의 차이, 집계 작업의 특성과 청크 처리의 한계를 실제로 체감했다.&lt;/p&gt;
&lt;p data-end=&quot;4983&quot; data-start=&quot;4948&quot; data-ke-size=&quot;size16&quot;&gt;하지만 많이 배웠다는 사실과, 좋은 선택이었다는 사실은 다르다.&lt;/p&gt;
&lt;p data-end=&quot;5057&quot; data-start=&quot;4985&quot; data-ke-size=&quot;size16&quot;&gt;학습 환경에서는 이런 우회가 가능할 수 있다.&lt;br /&gt;반면 운영 환경이라면, 더 단순하고 실패 가능성이 적은 구조를 우선했어야 했다.&lt;/p&gt;
&lt;h2 data-end=&quot;5065&quot; data-start=&quot;5059&quot; data-section-id=&quot;1h9nj85&quot; data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-end=&quot;5095&quot; data-start=&quot;5067&quot; data-ke-size=&quot;size16&quot;&gt;이번 작업을 하면서 가장 크게 느낀 건 이것이었다.&lt;/p&gt;
&lt;p data-end=&quot;5138&quot; data-start=&quot;5097&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프레임워크를 사용한다고 해서 그 장점이 자동으로 따라오지는 않는다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;5231&quot; data-start=&quot;5140&quot; data-ke-size=&quot;size16&quot;&gt;도구의 모양을 흉내 내는 것과, 그 도구가 전제하는 방식대로 문제를 푸는 것은 다르다.&lt;br /&gt;이번에는 청크라는 형식을 선택했지만, 실제로는 그 본질을 살리지 못했다.&lt;/p&gt;
&lt;p data-end=&quot;5309&quot; data-start=&quot;5233&quot; data-ke-size=&quot;size16&quot;&gt;결국 남은 것은 프레임워크를 사용했다는 만족감이 아니라,&lt;br /&gt;&lt;b&gt;문제에 맞는 도구를 고르지 않았을 때 생기는 복잡성과 그 비용&lt;/b&gt;이었다.&lt;/p&gt;</description>
      <category>운영</category>
      <author>ioh'sDeveloper</author>
      <guid isPermaLink="true">https://develop-tracking.tistory.com/289</guid>
      <comments>https://develop-tracking.tistory.com/entry/%ED%95%9C-%EB%B2%88%EC%97%90-%EB%81%9D%EB%82%BC-%EC%9E%91%EC%97%85%EC%97%90-%EC%B2%AD%ED%81%AC%EB%A5%BC-%EC%84%A0%ED%83%9D%ED%96%88%EB%8B%A4-%EB%B0%B0%EC%B9%98-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC%EB%A5%BC-%EC%9E%98%EB%AA%BB-%EA%B3%A0%EB%A5%B8-%EB%B9%84%EC%9A%A9#entry289comment</comments>
      <pubDate>Fri, 17 Apr 2026 17:25:30 +0900</pubDate>
    </item>
    <item>
      <title>루프팩 3기를 마치며 - 기술을 쓰는 개발자에서, 선택의 이유를 설명하는 개발자로</title>
      <link>https://develop-tracking.tistory.com/entry/%EB%A3%A8%ED%94%84%ED%8C%A9-3%EA%B8%B0%EB%A5%BC-%EB%A7%88%EC%B9%98%EB%A9%B0-%EA%B8%B0%EC%88%A0%EC%9D%84-%EC%93%B0%EB%8A%94-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%97%90%EC%84%9C-%EC%84%A0%ED%83%9D%EC%9D%98-%EC%9D%B4%EC%9C%A0%EB%A5%BC-%EC%84%A4%EB%AA%85%ED%95%98%EB%8A%94-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A1%9C</link>
      <description>&lt;h1 data-end=&quot;196&quot; data-start=&quot;161&quot; data-section-id=&quot;h4vh1m&quot;&gt;기술을 쓰는 개발자에서, 선택의 이유를 설명하는 개발자로&lt;/h1&gt;
&lt;h3 data-end=&quot;212&quot; data-start=&quot;197&quot; data-section-id=&quot;1r3hl5o&quot; data-ke-size=&quot;size23&quot;&gt;루프팩 3기를 마치며&lt;/h3&gt;
&lt;h2 data-end=&quot;237&quot; data-start=&quot;214&quot; data-section-id=&quot;1qcdi1f&quot; data-ke-size=&quot;size26&quot;&gt;서론. 나는 왜 다시 배우기로 했을까&lt;/h2&gt;
&lt;p data-end=&quot;405&quot; data-start=&quot;239&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;돌아보면 저는 늘 기술 가까이에 있었습니다.&lt;/b&gt;&lt;br /&gt;백엔드 개발자로서 기능을 만들고, 운영 이슈를 해결하고, 장애를 마주하고, 다시 구조를 손보는 일을 반복해 왔습니다. 실무 안에서 많은 것을 배웠고, 그만큼 익숙해진 기술도 많았습니다. 그런데 어느 순간부터 아주 선명하게 느껴지는 한계가 있었습니다.&lt;/p&gt;
&lt;p data-end=&quot;405&quot; data-start=&quot;239&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;433&quot; data-start=&quot;407&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;써본 적은 있는데, 정말 설명할 수 있는가?&amp;rdquo;&lt;/p&gt;
&lt;p data-end=&quot;648&quot; data-start=&quot;435&quot; data-ke-size=&quot;size16&quot;&gt;이직을 준비하면서 그 질문이 더 크게 다가왔습니다.&lt;/p&gt;
&lt;p data-end=&quot;648&quot; data-start=&quot;435&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;왜 이 구조를 선택했는지, 왜 이 트랜잭션 경계가 필요한지, 어떤 기준으로 동기와 비동기를 나눴는지. 막상 이런 질문 앞에 서면, 구현 경험은 분명 있는데 그것을 &lt;b&gt;설계 의도와 판단 근거의 언어&lt;/b&gt;로 풀어내는 일은 아직 부족하다고 느꼈습니다. 기술을 사용한 경험과 기술을 선택한 경험 사이에는 생각보다 큰 간극이 있었습니다.&lt;/p&gt;
&lt;p data-end=&quot;937&quot; data-start=&quot;650&quot; data-ke-size=&quot;size16&quot;&gt;그래서 저는 루프팩에 지원했습니다.&lt;br /&gt;단순히 새로운 기술을 더 배우고 싶어서가 아니었습니다. 실무에서 써 온 기술들을 다시 설계 관점으로 정리하고, 제 선택의 이유를 분명하게 설명할 수 있는 사람으로 성장하고 싶었습니다.&lt;br /&gt;특히 AI를 단순한 보조 도구가 아니라, 사고를 확장하고 작업을 분해하는 &lt;b&gt;개발 에이전트&lt;/b&gt;처럼 활용하는 방식에도 강한 관심이 있었습니다. 이제 개발자는 혼자 모든 답을 짜내는 사람이 아니라, 더 나은 질문을 만들고 더 좋은 협업 구조를 설계하는 사람이어야 한다고 생각했기 때문입니다.&lt;/p&gt;
&lt;p data-end=&quot;990&quot; data-start=&quot;939&quot; data-ke-size=&quot;size16&quot;&gt;그 마음으로 시작한 10주는,&lt;b&gt; 생각했던 것보다 훨씬 더 깊고 진하게 저를 바꾸어 놓았습니다.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-end=&quot;1021&quot; data-start=&quot;997&quot; data-section-id=&quot;1rz6979&quot; data-ke-size=&quot;size26&quot;&gt;본론. 루프팩에서 내가 다시 배운 것들&lt;/h2&gt;
&lt;h3 data-end=&quot;1047&quot; data-start=&quot;1023&quot; data-section-id=&quot;1kltskr&quot; data-ke-size=&quot;size23&quot;&gt;1. 구현보다 먼저, 왜를 묻는 습관&lt;/h3&gt;
&lt;p data-end=&quot;1201&quot; data-start=&quot;1049&quot; data-ke-size=&quot;size16&quot;&gt;루프팩에서 가장 크게 달라진 점은 기술을 바라보는 시선이었습니다.&lt;br /&gt;예전의 저는&lt;b&gt; &amp;ldquo;이 기술을 써봤다&amp;rdquo;는 설명에는 익숙했습니다.&lt;/b&gt; 하지만 이번 과정을 지나며 조금씩 &lt;b&gt;&amp;ldquo;이 상황에서 왜 이 기술을 선택했는지&amp;rdquo;, &amp;ldquo;대안은 무엇이었고 무엇을 포기했는지&amp;rdquo;를 먼저 생각&lt;/b&gt;하게 되었습니다.&lt;/p&gt;
&lt;p data-end=&quot;1337&quot; data-start=&quot;1203&quot; data-ke-size=&quot;size16&quot;&gt;같은 문제를 보더라도 이제는 바로 구현으로 들어가기보다,&lt;br /&gt;이 문제의 본질이 무엇인지, 어디까지를 동기로 묶어야 하는지, 어디서부터 비동기로 분리할 수 있는지, 이 선택이 운영과 장애 대응에 어떤 영향을 주는지를 먼저 떠올리게 되었습니다.&lt;/p&gt;
&lt;p data-end=&quot;1484&quot; data-start=&quot;1339&quot; data-ke-size=&quot;size16&quot;&gt;실무에서는 늘 문제를 해결해 왔지만, 루프팩에서는 한 걸음 더 나아가 &lt;b&gt;해결 방식의 근거를 설명하는 훈련&lt;/b&gt;을 계속하게 되었습니다. 그 과정이 생각보다 훨씬 중요했습니다. &lt;b&gt;기술은 결국 코드를 넘어 판단의 결과물이라는 사실&lt;/b&gt;을, 이번에 더 분명하게 체감했습니다.&lt;/p&gt;
&lt;h3 data-end=&quot;1522&quot; data-start=&quot;1486&quot; data-section-id=&quot;ahu34i&quot; data-ke-size=&quot;size23&quot;&gt;2. AI를 쓰는 사람이 아니라, AI와 협업하는 사람으로&lt;/h3&gt;
&lt;p data-end=&quot;1639&quot; data-start=&quot;1524&quot; data-ke-size=&quot;size16&quot;&gt;이번 과정에서 또 하나 크게 달라진 것은 AI를 대하는 태도였습니다.&lt;br /&gt;이전에도 AI를 활용하지 않았던 것은 아닙니다. 하지만 루프팩에서처럼 밀도 있게, 깊이 있게, 반복적으로 활용해 본 적은 없었습니다.&lt;/p&gt;
&lt;p data-end=&quot;1816&quot; data-start=&quot;1641&quot; data-ke-size=&quot;size16&quot;&gt;예전에는&lt;b&gt; AI를 빠르게 답을 얻기 위한 도구로 쓰는 순간&lt;/b&gt;이 더 많았다면, 지금은 조금 다릅니다.&lt;br /&gt;AI는 답을 대신 내주는 존재라기보다, 제 사고를 넓혀 주는 &lt;b&gt;협업자에 가깝습니다.&lt;/b&gt; 제가 더 좋은 질문을 던질수록 더 나은 방향으로 사고가 전개되고, 제가 놓친 관점을 다시 확인하게 해 주는 파트너처럼 느껴졌습니다.&lt;/p&gt;
&lt;p data-end=&quot;1991&quot; data-start=&quot;1818&quot; data-ke-size=&quot;size16&quot;&gt;중요했던 건 의존하지 않는 것이었습니다.&lt;br /&gt;AI를 잘 쓴다는 건 생각을 맡기는 것이 아니라, &lt;b&gt;생각을 더 정교하게 밀어붙이는 것&lt;/b&gt;에 가깝다는 걸 배웠습니다. &lt;b&gt;질문을 분해하고, 선택지를 비교하고, 트레이드오프를 언어화하고, 다시 내 관점으로 정리하는 과정 속에서 AI는 꽤 좋은 협업자&lt;/b&gt;가 될 수 있었습니다.&lt;/p&gt;
&lt;p data-end=&quot;2107&quot; data-start=&quot;1993&quot; data-ke-size=&quot;size16&quot;&gt;이 감각은 앞으로도 제 개발 방식에 오래 남을 것 같습니다.&lt;br /&gt;기술을 잘 아는 개발자에서 끝나는 것이 아니라, AI까지 포함한 작업 구조를 설계할 수 있는 개발자가 되고 싶다는 생각이 더 분명해졌습니다.&lt;/p&gt;
&lt;h3 data-end=&quot;2139&quot; data-start=&quot;2109&quot; data-section-id=&quot;kzmmxb&quot; data-ke-size=&quot;size23&quot;&gt;3. 사람과의 대화가 방향이 되어 주었던 순간들&lt;/h3&gt;
&lt;p data-end=&quot;2314&quot; data-start=&quot;2141&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기술적으로 가장 많이 남는 기억은, 의외로 혼자 고민하던 순간보다 함께 이야기하던 순간&lt;/b&gt;들입니다.&lt;br /&gt;방향을 잃고 같은 자리에서 계속 맴돌 때, 팀원들이나 함께 과정을 듣는 분들과의 대화 속에서 다시 실마리를 찾았던 경험이 많았습니다. 혼자였다면 훨씬 오래 헤맸을 고민들이, 누군가의 한마디로 정리되곤 했습니다.&lt;/p&gt;
&lt;p data-end=&quot;2527&quot; data-start=&quot;2316&quot; data-ke-size=&quot;size16&quot;&gt;무엇보다 좋았던 점은, &lt;b&gt;저와 다른 방식으로 사고하는 사람들을 가까이에서 볼 수 있었다는 것&lt;/b&gt;입니다.&lt;br /&gt;같은 문제를 전혀 다른 각도에서 바라보는 사람을 보면 처음에는 감탄하게 됩니다. 그런데 그 다음부터는 궁금해집니다. 저 사람은 왜 저 지점이 먼저 보였을까. 어떤 흐름으로 저 선택에 도달했을까. &lt;b&gt;그렇게 타인의 사고를 따라가 보려는&lt;/b&gt; &lt;b&gt;노력이 제 사고의 폭도 함께 넓혀&lt;/b&gt; 주었습니다.&lt;/p&gt;
&lt;p data-end=&quot;2729&quot; data-start=&quot;2529&quot; data-ke-size=&quot;size16&quot;&gt;그리고 &lt;b&gt;팀 안에서는 자연스럽게 서로를 받쳐 주는 시간&lt;/b&gt;들이 있었습니다.&lt;br /&gt;누군가가 지치면 다른 사람이 끌어 주고, 또 역할이 바뀌면 반대로 기대며 버텨 내는 흐름이 있었습니다. 제가 누군가에게 힘이 되었던 순간도 있었고, 반대로 제가 흔들릴 때 손을 내밀어 준 순간도 있었습니다. 지금 돌아보면 그 작은 배려들이 쌓여 이 10주를 완주하게 만든 것 같습니다.&lt;/p&gt;
&lt;h3 data-end=&quot;2759&quot; data-start=&quot;2731&quot; data-section-id=&quot;1asv64m&quot; data-ke-size=&quot;size23&quot;&gt;4. 글을 쓴다는 것은, 생각을 검증하는 일&lt;/h3&gt;
&lt;p data-end=&quot;2956&quot; data-start=&quot;2761&quot; data-ke-size=&quot;size16&quot;&gt;이번 과정에서 의외로 크게 배운 것은 &lt;b&gt;라이팅의 힘&lt;/b&gt;이었습니다.&lt;br /&gt;블로그를 꾸준히 써 왔다고 생각했지만, 이번처럼 오래 붙들고 고민하며 글을 써 본 적은 많지 않았습니다. 글을 쓰는 과정은 단순히 배운 것을 기록하는 일이 아니었습니다. 내 생각이 정말 맞는지, 논리가 비어 있지는 않은지, 설명이 충분히 설득력 있는지를 스스로 검증하는 시간이었습니다.&lt;/p&gt;
&lt;p data-end=&quot;3096&quot; data-start=&quot;2958&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PR을 쓰는 방식도 달라졌고, 생각을 문장으로 정리하는 태도도 달라졌습니다.&lt;/b&gt;&lt;br /&gt;머릿속에서는 그럴듯해 보이던 판단도, 막상 글로 쓰려 하면 근거가 빈약한 경우가 많았습니다. 반대로 글로 정리하면서 비로소 제 선택이 더 선명해지는 순간도 있었습니다.&lt;/p&gt;
&lt;p data-end=&quot;3211&quot; data-start=&quot;3098&quot; data-ke-size=&quot;size16&quot;&gt;결국 잘 쓰는 사람은, 잘 생각하는 사람이라는 말을 조금은 이해하게 되었습니다.&lt;br /&gt;&lt;b&gt;개발자에게 글쓰기는 부가적인 능력이 아니라, 사고를 구조화하는 핵심 역량 중 하나라는 걸 이번에 아주 크게 배웠습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-end=&quot;3247&quot; data-start=&quot;3213&quot; data-section-id=&quot;1owun3d&quot; data-ke-size=&quot;size23&quot;&gt;5. 아쉬움까지 포함해서, 나를 더 단단하게 만든 시간&lt;/h3&gt;
&lt;p data-end=&quot;3394&quot; data-start=&quot;3249&quot; data-ke-size=&quot;size16&quot;&gt;물론 아쉬움이 없었던 것은 아닙니다.&lt;br /&gt;&lt;b&gt;그때그때는 분명 최선을 다했고, 정말 끝까지 몰입했던 순간도 많았습니다.&lt;/b&gt; 그런데 돌아보면 조금 더 일찍 질문해 볼 걸, 조금 더 과감하게 제 생각을 꺼내 볼 걸, &lt;b&gt;조금 더 끝까지 파고들어 볼 걸 하는 아쉬움도 남습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;3529&quot; data-start=&quot;3396&quot; data-ke-size=&quot;size16&quot;&gt;회사 업무와 병행하는 일은 쉽지 않았고, 체력적으로도 정신적으로도 버거운 날이 있었습니다. 생각을 너무 오래 붙들다가 스스로 소진되는 순간도 있었습니다. 하지만 이상하게도, 그 시간들까지 지나고 나니 남는 것은 후회보다 확신에 가깝습니다.&lt;/p&gt;
&lt;p data-end=&quot;3632&quot; data-start=&quot;3531&quot; data-ke-size=&quot;size16&quot;&gt;나는 힘들어도 계속 생각하는 사람이고,&lt;br /&gt;쉽게 답을 정하기보다 끝까지 이유를 찾으려는 사람이며,&lt;br /&gt;혼자만의 정답보다 함께 더 좋은 답을 만드는 쪽으로 조금씩 이동하고 있다는 것.&lt;/p&gt;
&lt;p data-end=&quot;3665&quot; data-start=&quot;3634&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그걸 확인한 것만으로도, 이 10주는 충분히 값졌습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-end=&quot;220&quot; data-start=&quot;203&quot; data-section-id=&quot;9n129q&quot; data-ke-size=&quot;size23&quot;&gt;6. 가장 인상 깊었던 프로젝트&lt;/h3&gt;
&lt;p data-end=&quot;443&quot; data-start=&quot;222&quot; data-ke-size=&quot;size16&quot;&gt;가장 인상 깊었던 프로젝트는 &lt;b&gt;Kafka 기반 EDA 과제&lt;/b&gt;였습니다.&lt;br /&gt;이 과제가 특히 오래 기억에 남는 이유는, 단순히 메시지를 발행하고 소비하는 구현에서 끝나지 않았기 때문입니다. 메시지를 언제 발행할지, 트랜잭션 경계를 어디에 둘지, 어떤 흐름을 동기에서 비동기로 분리할지, 그리고 컨슈머가 실패했을 때 재시도와 DLQ, 보상 처리를 어떻게 설계해야 하는지까지 함께 고민해야 했습니다.&lt;/p&gt;
&lt;p data-end=&quot;605&quot; data-start=&quot;445&quot; data-ke-size=&quot;size16&quot;&gt;무엇보다 좋았던 점은, 문제를 해결하는 데서 멈추지 않고 &lt;b&gt;왜 이 구조를 선택했는지 스스로 끝까지 설명해 보게 만들었다는 점&lt;/b&gt;입니다. 구현 자체보다도 설계 의도와 트레이드오프를 더 깊게 생각하게 해 준 과제였고, 그래서 저에게는 루프팩의 강점을 가장 잘 보여준 프로젝트로 남았습니다.&lt;/p&gt;
&lt;p data-end=&quot;782&quot; data-start=&quot;607&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;루프팩을 고민하는 분이 있다면, 저는 이 과제 경험을 특히 추천하고 싶습니다.&lt;/b&gt;&lt;br /&gt;실무에서도 계속 마주치게 될 &lt;b&gt;메시징, 비동기 처리, 장애 대응, 정합성 같은 주제를 한 번에 밀도 있게 고민&lt;/b&gt;해 볼 수 있었고, 그 과정에서 단순한 기술 습득이 아니라 &lt;b&gt;설계 관점 자체를 넓히는 경험&lt;/b&gt;을 할 수 있었기 때문입니다.&lt;/p&gt;
&lt;h3 data-end=&quot;805&quot; data-start=&quot;789&quot; data-section-id=&quot;1fspsvm&quot; data-ke-size=&quot;size23&quot;&gt;7. 추천하고 싶은 글과 기록&lt;/h3&gt;
&lt;p data-end=&quot;979&quot; data-start=&quot;807&quot; data-ke-size=&quot;size16&quot;&gt;루프팩을 지나며 제가 특히 오래 붙들고 고민했던 주제들은 자연스럽게 글과 기록으로도 남게 되었습니다.&lt;br /&gt;돌아보면 이 과정은 과제를 수행하는 시간인 동시에, 제 생각을 글로 검증하는 시간이기도 했습니다. 그래서 루프팩을 통해 어떤 고민을 했는지 더 궁금하신 분이 있다면, 아래 글들도 함께 보셔도 좋겠습니다.&lt;/p&gt;
&lt;h4 data-end=&quot;1029&quot; data-start=&quot;981&quot; data-section-id=&quot;1txu7aj&quot; data-ke-size=&quot;size20&quot;&gt;&lt;a href=&quot;https://develop-tracking.tistory.com/entry/%ED%83%80%EC%9E%84%EC%95%84%EC%9B%83%EC%9D%80-%EC%8B%A4%ED%8C%A8%EA%B0%80-%EC%95%84%EB%8B%88%EB%8B%A4-%E2%80%94-%EC%99%B8%EB%B6%80-API-%EC%97%B0%EB%8F%99%EC%97%90%EC%84%9C-%EB%AA%A8%EB%A5%B4%EB%8A%94-%EC%83%81%ED%83%9C%EB%A5%BC-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%B2%95&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;1. 타임아웃은 실패가 아니다 외부 API 연동에서 모르는 상태를 다루는 법&lt;/a&gt;&lt;/h4&gt;
&lt;p data-end=&quot;1233&quot; data-start=&quot;1030&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서는 외부 PG 연동에서 &lt;b&gt;타임아웃을 곧바로 실패로 단정하면 안 된다&lt;/b&gt;는 점을 중심으로, 응답을 받지 못한 상태를 &amp;ldquo;모른다&amp;rdquo;로 다루고 UNKNOWN 상태와 대사 배치까지 포함해 설계를 풀어냈습니다. 외부 시스템 연동에서 정합성을 어떻게 바라봐야 하는지에 대한 고민이 담긴 글입니다.&lt;/p&gt;
&lt;h4 data-end=&quot;1262&quot; data-start=&quot;1235&quot; data-section-id=&quot;12ky9r7&quot; data-ke-size=&quot;size20&quot;&gt;&lt;a href=&quot;https://develop-tracking.tistory.com/entry/%EB%9D%BD%EC%9D%84-%EC%9E%98-%EA%B3%A8%EB%9E%90%EB%8A%94%EB%8D%B0-%EC%99%9C-%EB%8D%94-%EC%9C%84%ED%97%98%ED%95%B4%EC%A1%8C%EC%9D%84%EA%B9%8C&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2. 락을 잘 골랐는데 왜 더 위험해졌을까&lt;/a&gt;&lt;/h4&gt;
&lt;p data-end=&quot;1485&quot; data-start=&quot;1263&quot; data-ke-size=&quot;size16&quot;&gt;이 글은 주문 트랜잭션에서 도메인별로 락 전략을 각각 최적화했지만, 결과적으로는 &lt;b&gt;비관적 락 2개와 낙관적 락 1개가 한 트랜잭션에 공존하면서 오히려 데드락 위험과 락 보유 시간이 커졌던 경험&lt;/b&gt;을 다룹니다. 결국 &amp;ldquo;락을 잘 고르는 것&amp;rdquo;보다 &amp;ldquo;공유 자원 자체를 줄이는 것&amp;rdquo;이 더 근본적인 해법일 수 있다는 점이 인상 깊었습니다.&lt;/p&gt;
&lt;h4 data-end=&quot;1535&quot; data-start=&quot;1487&quot; data-section-id=&quot;1aart6w&quot; data-ke-size=&quot;size20&quot;&gt;&lt;a href=&quot;https://develop-tracking.tistory.com/entry/DIP%EB%A5%BC-%EB%81%9D%EA%B9%8C%EC%A7%80-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B8-%EA%B2%BD%ED%97%98-%EC%88%9C%EC%88%98-POJO-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%84%A4%EA%B3%84%EC%9D%98-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;3. DIP를 끝까지 적용해본 경험 &amp;mdash; 순수 POJO 도메인 설계의 트레이드오프&lt;/a&gt;&lt;/h4&gt;
&lt;p data-end=&quot;1767&quot; data-start=&quot;1536&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서는 &lt;b&gt;Entity-level DIP를 끝까지 밀어붙여 보면서&lt;/b&gt;, 순수 POJO 도메인을 유지하는 대신 Dirty Checking을 포기하고 명시적 save() 호출을 감수했던 경험을 정리했습니다. &amp;ldquo;과하다&amp;rdquo;는 말을 쉽게 하기보다, 실제로 끝까지 적용해 보고 무엇을 얻고 무엇을 잃는지 체감해 본 기록이라 저에게도 의미가 컸습니다.&lt;/p&gt;
&lt;h4 data-end=&quot;1789&quot; data-start=&quot;1769&quot; data-section-id=&quot;1pl45li&quot; data-ke-size=&quot;size20&quot;&gt;&lt;a href=&quot;https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/325&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;4. 대기열 시스템 구현 PR&lt;/a&gt;&lt;/h4&gt;
&lt;p data-end=&quot;2055&quot; data-start=&quot;1790&quot; data-ke-size=&quot;size16&quot;&gt;대기열 구현 PR에는 &lt;b&gt;Redis Sorted Set 기반 대기열, Lua Script를 통한 원자적 토큰 발급, CircuitBreaker와 RateLimiter를 활용한 장애 대응, 이벤트 기반 토큰 정리, 그리고 배치 크기 산정 근거&lt;/b&gt;까지 포함해 설계 의사결정을 상세히 정리해 두었습니다. 단순 구현 결과물이라기보다, 왜 이렇게 설계했는지를 문서로 설명하려고 노력했던 기록이라 더 애착이 갑니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-end=&quot;3697&quot; data-start=&quot;3672&quot; data-section-id=&quot;1vn4pko&quot; data-ke-size=&quot;size26&quot;&gt;결론. 이제 나는 무엇을 가져가려 하는가&lt;/h2&gt;
&lt;p data-end=&quot;3873&quot; data-start=&quot;3699&quot; data-ke-size=&quot;size16&quot;&gt;루프팩을 시작할 때 저는 &lt;b&gt;설계 의도와 선택의 근거를 설명할 수 있는 개발자&lt;/b&gt;가 되고 싶었습니다.&lt;br /&gt;지금의 저는 아직 완성된 사람이라고 말할 수는 없습니다. 여전히 부족한 것도 많고, 더 깊게 들어가야 할 주제도 많습니다. 다만 분명한 것은, 이제는 그 방향으로 가는 방법을 조금은 알게 되었다는 점입니다.&lt;/p&gt;
&lt;p data-end=&quot;3873&quot; data-start=&quot;3699&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4007&quot; data-start=&quot;3875&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기술을 나열하는 사람이 아니라,&lt;/b&gt;&lt;br /&gt;&lt;b&gt;왜 이 선택을 했는지 말할 수 있는 사람.&lt;/b&gt;&lt;br /&gt;&lt;b&gt;감이 아니라 근거로 설명하려는 사람.&lt;/b&gt;&lt;br /&gt;혼자 답을 만드는 데서 멈추지 않고, 질문과 대화, 라이팅과 AI 협업을 통해 더 나은 판단을 만들어 가는 사람.&lt;/p&gt;
&lt;p data-end=&quot;4007&quot; data-start=&quot;3875&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4032&quot; data-start=&quot;4009&quot; data-ke-size=&quot;size16&quot;&gt;저는 앞으로 그런 개발자가 되고 싶습니다.&lt;/p&gt;
&lt;p data-end=&quot;4170&quot; data-start=&quot;4034&quot; data-ke-size=&quot;size16&quot;&gt;그리고 무엇보다, 사람 덕분에 여기까지 올 수 있었다는 사실을 오래 기억하고 싶습니다.&lt;br /&gt;좋은 질문을 던져 준 &lt;b&gt;멘토님들&lt;/b&gt;, 함께 고민해 준 &lt;b&gt;팀원들&lt;/b&gt;, 각자의 방식으로 치열하게 몰입하던 &lt;b&gt;동료들&lt;/b&gt; 덕분에 저는 혼자서는 얻기 어려웠을 시야를 얻었습니다.&lt;/p&gt;
&lt;p data-end=&quot;4280&quot; data-start=&quot;4172&quot; data-ke-size=&quot;size16&quot;&gt;그래서 루프팩 3기는 저에게 단순한 부트캠프가 아니었습니다.&lt;br /&gt;&lt;b&gt;개발자로서의 철학을 다시 정리하게 해 준 시간이었고, 처음의 도전이 어떤 변화로 이어질 수 있는지를 몸으로 확인한 시간이었습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;4390&quot; data-start=&quot;4282&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;끝나서 아쉽지만, 이상하게도 이전보다 더 기대됩니다.&lt;/b&gt;&lt;br /&gt;이제는 조금 더 분명한 언어로 제 선택을 말할 수 있을 것 같고,&lt;br /&gt;조금 더 단단한 시선으로 다음 문제를 마주할 수 있을 것 같습니다.&lt;/p&gt;
&lt;p data-end=&quot;4488&quot; data-start=&quot;4392&quot; data-ke-size=&quot;size16&quot;&gt;늦게 시작한 것이 아쉬울 만큼, 정말 값진 시간이었습니다.&lt;br /&gt;그리고 &lt;b&gt;저는 이 10주를, 앞으로 더 좋은 개발자가 되기 위한 꽤 단단한 출발선으로 오래 기억할 것 같습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;4488&quot; data-start=&quot;4392&quot; 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-end=&quot;182&quot; data-start=&quot;156&quot; data-section-id=&quot;18kqcv6&quot; data-ke-size=&quot;size26&quot;&gt;에필로그. 결국, 사람을 남기는 시간이었다&lt;/h2&gt;
&lt;p data-end=&quot;239&quot; data-start=&quot;184&quot; data-ke-size=&quot;size16&quot;&gt;그리고 마지막으로, 꼭 남기고 싶은 이야기가 있습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-end=&quot;634&quot; data-start=&quot;374&quot; data-ke-size=&quot;size16&quot;&gt;이번 루프팩을 지나며 &lt;b&gt;저는 앞으로 어떤 개발자가 되고 싶은지도 조금 더 선명해졌습니다.&lt;/b&gt;&lt;br /&gt;&lt;b&gt;기술적으로 좋은 영향을 줄 수 있는 사람이 되고 싶고, 멘토님들처럼 누군가에게 실질적인 도움이 되는 기여를 할 수 있는 개발자가 되고 싶습니다. 동시에 이제 막 시작하는 주니어에게는 힘이 되어 주는 사람이고 싶습니다.&lt;/b&gt; 저 역시 제 나름의 개발자 철학을 만들어 가고 싶고, 이 AI 시대 안에서 &lt;b&gt;흐름에 휩쓸리는 사람이 아니라 제대로 이해하고 잘 활용하는 사람&lt;/b&gt;이 되고 싶습니다.&lt;/p&gt;
&lt;p data-end=&quot;744&quot; data-start=&quot;636&quot; data-ke-size=&quot;size16&quot;&gt;그런 의미에서 루프톡도 저에게 참 특별했습니다.&lt;br /&gt;좋은 사람들과 기술 이야기, 커리어 이야기, 고민과 응원을 함께 나눌 수 있다는 것이 얼마나 큰 힘이 되는지 다시 느끼게 해 준 자리였습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;241&quot; data-end=&quot;372&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;778&quot; data-start=&quot;746&quot; data-ke-size=&quot;size16&quot;&gt;그리고 이 자리를 빌려 감사한 분들을 꼭 남기고 싶습니다!&lt;/p&gt;
&lt;p data-end=&quot;986&quot; data-start=&quot;780&quot; data-ke-size=&quot;size16&quot;&gt; 우리 &lt;b&gt;애니 매니저님,&lt;/b&gt; 늘 뒤에서 묵묵히 지원해 주시고 챙겨 주셔서 감사했습니다.  &lt;/p&gt;
&lt;p data-end=&quot;986&quot; data-start=&quot;780&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;  항상 유익한 내용들을 알려 주시고, 편하게 웃고 장난칠 수 있는 분위기를 만들어 주셨던 &lt;b&gt;멘토님들&lt;/b&gt;!!&lt;/p&gt;
&lt;p data-end=&quot;986&quot; data-start=&quot;780&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;앨런 멘토님, 케브 멘토님, 렌 멘토님, 데빈 멘토님&lt;/b&gt;들께도 정말 감사드립니다. 쉬운 발제라고 말씀하셨지만, 듣는 사람 입장에서는 결코 가볍게 넘길 수 없는 깊이가 있었고, 그만큼 많이 배우고 많이 고민할 수 있었습니다.  &lt;/p&gt;
&lt;p data-end=&quot;986&quot; data-start=&quot;780&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1187&quot; data-start=&quot;988&quot; data-ke-size=&quot;size16&quot;&gt;  또 각자의 회사 생활로도 바쁘셨을 텐데, 언제나 우리를 챙겨 주고 도와주고, 오프라인 스터디까지 잘 이끌어 주시고, 늘 먼저 걱정해 주셨던 &lt;b&gt;엔젤분들께도&lt;/b&gt; 진심으로 감사드립니다.&lt;br /&gt;특히&lt;b&gt; 5팀 재인님&lt;/b&gt;께는 정말 큰 감사의 마음이 있습니다. 따뜻하게 챙겨 주시고, 분위기를 이끌어 주시고, 힘든 순간마다 자연스럽게 손 내밀어 주셨던 기억이 오래 남을 것 같습니다. &lt;/p&gt;
&lt;p data-end=&quot;1187&quot; data-start=&quot;988&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1364&quot; data-start=&quot;1189&quot; data-ke-size=&quot;size16&quot;&gt;  서포터분들께도 감사한 마음이 큽니다.&lt;br /&gt;새벽마다 퀴즈를 내 주시던&lt;b&gt; 준서님 (엄퀴즈)&lt;/b&gt;, 열품타와 여러 방식으로 분위기를 만들어 주신 &lt;b&gt;기현님(박박기기현현)&lt;/b&gt; &lt;/p&gt;
&lt;p data-end=&quot;1364&quot; data-start=&quot;1189&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1364&quot; data-start=&quot;1189&quot; data-ke-size=&quot;size16&quot;&gt;  늘 편안하고 재미있는 분위기로 긴장을 풀어 주신&lt;b&gt; 지웅님과 상일님&lt;/b&gt;까지&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1364&quot; data-start=&quot;1189&quot; data-ke-size=&quot;size16&quot;&gt;아!! 루프톡도 정말 재미있었고, 뜻깊었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-end=&quot;372&quot; data-start=&quot;241&quot; data-ke-size=&quot;size16&quot;&gt;단순히 프로그램의 한 코스처럼 지나간 시간이 아니라, 함께 듣는 수강생의 기쁜 일을 같이 기뻐하고, 서로의 고민을 함께 나누고, 기술 이야기로 깊게 연결될 수 있었던 시간으로 기억에 남습니다. 그래서 더 오래 마음에 남는 것 같습니다. 루프톡 덕분에 이 과정 전체가 더 즐겁고 가까운 시간으로 남을 수 있었던 건 덕분이었다고 생각합니다.&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lVkng/dJMcafTO5Su/D9VbIeYoTzVA7QYaJmMvA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lVkng/dJMcafTO5Su/D9VbIeYoTzVA7QYaJmMvA1/img.png&quot; data-origin-width=&quot;1036&quot; data-origin-height=&quot;1236&quot; data-is-animation=&quot;false&quot; data-filename=&quot;image (12).png&quot; width=&quot;500&quot; height=&quot;597&quot; style=&quot;width: 49.5882%; margin-right: 10px;&quot; data-widthpercent=&quot;50.17&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lVkng/dJMcafTO5Su/D9VbIeYoTzVA7QYaJmMvA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlVkng%2FdJMcafTO5Su%2FD9VbIeYoTzVA7QYaJmMvA1%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;1036&quot; height=&quot;1236&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVO3TS/dJMb9968jCg/7E4Di8GCrKhqIk1AIi00C0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVO3TS/dJMb9968jCg/7E4Di8GCrKhqIk1AIi00C0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;631&quot; data-origin-height=&quot;758&quot; data-filename=&quot;image (10).png&quot; style=&quot;width: 49.249%;&quot; data-widthpercent=&quot;49.83&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVO3TS/dJMb9968jCg/7E4Di8GCrKhqIk1AIi00C0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVO3TS%2FdJMb9968jCg%2F7E4Di8GCrKhqIk1AIi00C0%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;631&quot; height=&quot;758&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcHVMd/dJMcad2H3UJ/bc4BrA7ww9I9XbFtO91DN0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcHVMd/dJMcad2H3UJ/bc4BrA7ww9I9XbFtO91DN0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;526&quot; data-origin-height=&quot;594&quot; data-filename=&quot;image (14).png&quot; style=&quot;width: 32.0138%; margin-right: 10px;&quot; data-widthpercent=&quot;32.39&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcHVMd/dJMcad2H3UJ/bc4BrA7ww9I9XbFtO91DN0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcHVMd%2FdJMcad2H3UJ%2Fbc4BrA7ww9I9XbFtO91DN0%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;526&quot; height=&quot;594&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UN0XU/dJMcaciuq5w/CdosE7HEqn0irbICo8cUnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UN0XU/dJMcaciuq5w/CdosE7HEqn0irbICo8cUnk/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;554&quot; data-filename=&quot;image (11).png&quot; style=&quot;width: 66.8234%;&quot; data-widthpercent=&quot;67.61&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UN0XU/dJMcaciuq5w/CdosE7HEqn0irbICo8cUnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUN0XU%2FdJMcaciuq5w%2FCdosE7HEqn0irbICo8cUnk%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;1024&quot; height=&quot;554&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-end=&quot;372&quot; data-start=&quot;241&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1600&quot; data-start=&quot;1366&quot; data-ke-size=&quot;size16&quot;&gt;무엇보다 가장 크게 감사한 분들은 &lt;b&gt;3기 멘티분들입니다.&lt;/b&gt;&lt;br /&gt;정말 여러분과 함께한 &lt;b&gt;10주는 오래 기억에 남을 것 같습니다. 밤늦게까지 같이 고민하고, 스터디를 만들고, 오프라인에서 만나 이야기를 나누고, 노래도 듣고, 사진도 찍고, 함께 웃고 버텼던 순간들이 너무 소중했습니다.&lt;/b&gt; &lt;b&gt;실력 있는 분들과 가까이에서 이야기하며, 제가 몰랐던 관점들을 보고, 다른 사고방식을 따라가 보고, 제 사고의 폭도 많이 넓힐 수 있었습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1707&quot; data-start=&quot;1602&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;혼자였다면&lt;/b&gt; 절대 이렇게까지 깊게 생각하지 못했을 것 같습니다.&lt;br /&gt;&lt;b&gt;혼자였다면&lt;/b&gt; 금방 포기했을 고민들도, 함께였기 때문에 끝까지 붙들 수 있었습니다. 그래서 더 감사하고, 더 애정이 남습니다.&lt;/p&gt;
&lt;p data-end=&quot;1812&quot; data-start=&quot;1709&quot; data-ke-size=&quot;size16&quot;&gt;이번 루프팩은 제게 기술만 남긴 것이 아니었습니다.&lt;br /&gt;함께 고민할 수 있는 사람들, 같이 성장할 수 있는 동료들, 그리고 앞으로도 계속 연결되고 싶은 인연들을 남겨 준 시간이었습니다.&lt;/p&gt;
&lt;p data-end=&quot;1943&quot; data-start=&quot;1814&quot; data-ke-size=&quot;size16&quot;&gt;그래서&lt;b&gt; 혹시 지금 이 글을 읽으면서 루퍼스를 들을까 말까 고민하는 분이 있다면,&lt;/b&gt; 저는 분명하게 말하고 싶습니다.&lt;br /&gt;주니어 개발자든, 이미 실무 경험이 있는 시니어 개발자든, 이 과정은 충분히 값진 시간이 될 수 있다고 생각합니다.&lt;/p&gt;
&lt;p data-end=&quot;2176&quot; data-start=&quot;1945&quot; data-ke-size=&quot;size16&quot;&gt;물론 과정 안에는 분명 힘든 순간도 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;2176&quot; data-start=&quot;1945&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2176&quot; data-start=&quot;1945&quot; data-ke-size=&quot;size16&quot;&gt;하지만 &lt;b&gt;그 힘든 순간을 혼자 버티게 두지 않는 사람들이 있습니다.&lt;/b&gt; 막히는 지점이 있을 때 함께 고민해 주는 사람들, 지칠 때 공감해 주는 사람들, 그리고 혼자 끙끙 앓던 문제를 함께 이야기하며 풀어갈 수 있게 도와주는 환경이 있습니다. &lt;b&gt;멘토님들의 멘토링을 듣고, 사람들과 토론하고, 내 생각을 부딪쳐 보는 과정 안에서 정말 좋은 인사이트를 많이 얻을 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;2178&quot; data-end=&quot;2342&quot; data-ke-size=&quot;size16&quot;&gt;저 역시 이 안에서 정말 많은 것을 얻었습니다.&lt;br /&gt;기술적으로도, 사고 방식에서도, 개발자로서의 태도와 철학의 면에서도 분명히 성장할 수 있었습니다. 그래서 앞으로도 더 많은 분들이 루퍼스와 백엔드 루프팩을 통해 기술적으로 성장하고, 자기만의 개발자 철학을 만들어 갈 기회를 만나면 좋겠습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;2344&quot; data-end=&quot;2396&quot; data-ke-size=&quot;size16&quot;&gt;정말 모두 고생 많으셨습니다.&lt;br /&gt;&lt;b&gt;함께해서 즐거웠고, 많이 배웠고, 오래 기억할 것 같습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k05Pq/dJMcajhy8NV/BGf2De6JtgloTwdVaoPpo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k05Pq/dJMcajhy8NV/BGf2De6JtgloTwdVaoPpo0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;526&quot; data-origin-height=&quot;406&quot; data-filename=&quot;image (16).png&quot; style=&quot;width: 57.4418%; margin-right: 10px;&quot; data-widthpercent=&quot;58.12&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k05Pq/dJMcajhy8NV/BGf2De6JtgloTwdVaoPpo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk05Pq%2FdJMcajhy8NV%2FBGf2De6JtgloTwdVaoPpo0%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;526&quot; height=&quot;406&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/A8NEZ/dJMcadn7W2D/IzJdemzcS3P5ojEjewAUU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/A8NEZ/dJMcadn7W2D/IzJdemzcS3P5ojEjewAUU0/img.png&quot; data-filename=&quot;image (15).png&quot; data-origin-height=&quot;422&quot; data-origin-width=&quot;394&quot; data-is-animation=&quot;false&quot; style=&quot;width: 41.3954%;&quot; data-widthpercent=&quot;41.88&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/A8NEZ/dJMcadn7W2D/IzJdemzcS3P5ojEjewAUU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FA8NEZ%2FdJMcadn7W2D%2FIzJdemzcS3P5ojEjewAUU0%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;394&quot; height=&quot;422&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/twNkP/dJMcahqzClx/8yDY2dvirXLQ0Cwo5d2fLK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/twNkP/dJMcahqzClx/8yDY2dvirXLQ0Cwo5d2fLK/img.png&quot; data-filename=&quot;시작의마을.png&quot; data-is-animation=&quot;false&quot; data-origin-height=&quot;892&quot; data-origin-width=&quot;1560&quot; width=&quot;300&quot; height=&quot;172&quot; style=&quot;width: 37.9416%; margin-right: 10px;&quot; data-widthpercent=&quot;38.84&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/twNkP/dJMcahqzClx/8yDY2dvirXLQ0Cwo5d2fLK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtwNkP%2FdJMcahqzClx%2F8yDY2dvirXLQ0Cwo5d2fLK%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;1560&quot; height=&quot;892&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JgV8X/dJMcajhy9bw/E13Emd28pZXdb7Vgysjnb1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JgV8X/dJMcajhy9bw/E13Emd28pZXdb7Vgysjnb1/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;312&quot; data-origin-height=&quot;188&quot; data-filename=&quot;image (17).png&quot; style=&quot;width: 36.0041%; margin-right: 10px;&quot; data-widthpercent=&quot;36.86&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JgV8X/dJMcajhy9bw/E13Emd28pZXdb7Vgysjnb1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJgV8X%2FdJMcajhy9bw%2FE13Emd28pZXdb7Vgysjnb1%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;312&quot; height=&quot;188&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;2176&quot; data-start=&quot;1945&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-end=&quot;2396&quot; data-start=&quot;2344&quot; data-ke-style=&quot;style2&quot;&gt;최강 5팀 만세&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_Photo_2026-04-17-06-45-15.jpeg&quot; data-origin-width=&quot;1052&quot; data-origin-height=&quot;560&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bh4TsE/dJMcahqzCli/ePJ4v3DZoM1AAsXjkHG7Tk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bh4TsE/dJMcahqzCli/ePJ4v3DZoM1AAsXjkHG7Tk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bh4TsE/dJMcahqzCli/ePJ4v3DZoM1AAsXjkHG7Tk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbh4TsE%2FdJMcahqzCli%2FePJ4v3DZoM1AAsXjkHG7Tk%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;1052&quot; height=&quot;560&quot; data-filename=&quot;KakaoTalk_Photo_2026-04-17-06-45-15.jpeg&quot; data-origin-width=&quot;1052&quot; data-origin-height=&quot;560&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;1588&quot; data-origin-height=&quot;1752&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dW1tKa/dJMcaarrMu8/GoG8zFdUykNqrDeku4k2ek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dW1tKa/dJMcaarrMu8/GoG8zFdUykNqrDeku4k2ek/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dW1tKa/dJMcaarrMu8/GoG8zFdUykNqrDeku4k2ek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdW1tKa%2FdJMcaarrMu8%2FGoG8zFdUykNqrDeku4k2ek%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;1588&quot; height=&quot;1752&quot; data-origin-width=&quot;1588&quot; data-origin-height=&quot;1752&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;blockquote data-end=&quot;2471&quot; data-start=&quot;2398&quot; data-ke-style=&quot;style2&quot;&gt;그리고 이 좋은 경험을 누군가와 나누고 싶어서, 추천 코드도 함께 남깁니다.&lt;br /&gt;필요하신 분이 있다면 편하게 사용하셔도 좋겠습니다.&lt;/blockquote&gt;
&lt;h3 style=&quot;text-align: center;&quot; data-end=&quot;2471&quot; data-start=&quot;2398&quot; data-ke-size=&quot;size23&quot;&gt;추천 코드: 696ZA&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>스터디/루퍼스</category>
      <category>LOOPPAK</category>
      <category>루퍼스</category>
      <category>루퍼스부트캠프</category>
      <category>루프팩</category>
      <category>루프팩백엔드3기</category>
      <category>백엔드</category>
      <author>ioh'sDeveloper</author>
      <guid isPermaLink="true">https://develop-tracking.tistory.com/288</guid>
      <comments>https://develop-tracking.tistory.com/entry/%EB%A3%A8%ED%94%84%ED%8C%A9-3%EA%B8%B0%EB%A5%BC-%EB%A7%88%EC%B9%98%EB%A9%B0-%EA%B8%B0%EC%88%A0%EC%9D%84-%EC%93%B0%EB%8A%94-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%97%90%EC%84%9C-%EC%84%A0%ED%83%9D%EC%9D%98-%EC%9D%B4%EC%9C%A0%EB%A5%BC-%EC%84%A4%EB%AA%85%ED%95%98%EB%8A%94-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A1%9C#entry288comment</comments>
      <pubDate>Fri, 17 Apr 2026 07:54:57 +0900</pubDate>
    </item>
    <item>
      <title>테스트는 전부 통과했는데 배치가 빈 테이블을 만든 이유</title>
      <link>https://develop-tracking.tistory.com/entry/%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%8A%94-%EC%A0%84%EB%B6%80-%ED%86%B5%EA%B3%BC%ED%96%88%EB%8A%94%EB%8D%B0-%EB%B0%B0%EC%B9%98%EA%B0%80-%EB%B9%88-%ED%85%8C%EC%9D%B4%EB%B8%94%EC%9D%84-%EB%A7%8C%EB%93%A0-%EC%9D%B4%EC%9C%A0</link>
      <description>&lt;h1 data-heading=&quot;테스트는 전부 통과했는데 배치가 빈 테이블을 만든 이유 &amp;mdash; Spring Batch 실전에서 만난 3가지 함정&quot;&gt;Spring Batch 실전에서 만난 3가지 함정&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&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;Spring Batch로 주간 랭킹 집계를 만들고, 테스트 4개를 짰고, 전부 통과했다. 그런데 실제로 돌리면 결과 테이블이 매번 비어있었다. 원인을 추적하니, 테스트 자체가 3개의 치명적 결함을 구조적으로 숨기고 있었다. 이 글은 그 3가지 함정을 발견하고 수정한 기록이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;배경: 뭘 만들었나&quot; data-ke-size=&quot;size26&quot;&gt;배경: 뭘 만들었나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이커머스 서비스에서 &quot;이번 주 인기 상품 TOP 100&quot; 같은 랭킹을 보여주려면, 사용자의 행동(상품 조회, 좋아요, 주문)을 모아서 점수를 매기고, 높은 순서대로 정렬해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 &lt;b&gt;실시간 일간 랭킹&lt;/b&gt;이 있었다. 사용자가 상품을 조회하면 Kafka(메시지 큐)를 통해 이벤트가 전달되고, Consumer(이벤트 수신기)가 이를 받아 Redis라는 인메모리 저장소에 점수를 실시간으로 누적한다. &quot;오늘 인기 상품&quot;은 이걸로 충분하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Redis에는 &lt;b&gt;시간 정보가 없다&lt;/b&gt;. 점수만 쌓일 뿐, &quot;이 점수가 언제 발생했는지&quot;를 알 수 없다. 그래서 &quot;지난 주 인기 상품&quot;이나 &quot;이번 달 인기 상품&quot;처럼 &lt;b&gt;특정 기간을 기준으로 집계&lt;/b&gt;하는 건 불가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 해결하기 위해, Consumer가 Redis에 점수를 적재하면서 동시에 &lt;b&gt;DB에도 원천 이벤트를 날것 그대로 저장&lt;/b&gt;해두도록 설계했다. 이 ranking_event 테이블에는 &quot;어떤 상품에, 어떤 행동이, 언제 발생했는지&quot;가 기록되어 있다. Spring Batch가 이 테이블에서 원하는 기간의 이벤트를 꺼내 집계하고, 결과를 &lt;b&gt;조회 전용 테이블(MV, Materialized View)&lt;/b&gt;에 적재하는 구조다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;사용자 행동 &amp;rarr; Kafka &amp;rarr; Consumer &amp;rarr; DB(원천 이벤트) + Redis(실시간 점수)
                                      &amp;darr;
                               Spring Batch &amp;rarr; MV(주간/월간 TOP 100)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치를 구현하고, 통합 테스트 4개를 작성했다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 뭘 검증하나&lt;/p&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;happyPath&lt;/td&gt;
&lt;td&gt;조회&amp;times;0.1 + 좋아요&amp;times;0.2 + 주문&amp;times;0.6 가중치로 집계해서 TOP N을 뽑는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tieBreak&lt;/td&gt;
&lt;td&gt;같은 점수면 상품 ID가 작은 순서대로 랭크한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;idempotent&lt;/td&gt;
&lt;td&gt;같은 날짜로 2번 돌려도 결과가 동일하다 (멱등성)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;periodIsolation&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;4개 전부 통과. 안심하고 배치를 돌렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과 테이블이 비어있었다.&lt;/b&gt; 매번.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;함정 1: 테스트 데이터가 운영 환경과 다른 값을 넣고 있었다&quot; data-ke-size=&quot;size26&quot;&gt;함정 1: 테스트 데이터가 운영 환경과 다른 값을 넣고 있었다&lt;/h2&gt;
&lt;h3 data-heading=&quot;뭐가 문제였나&quot; data-ke-size=&quot;size23&quot;&gt;뭐가 문제였나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치가 이벤트를 읽어 점수를 계산하는 과정을 디버깅했더니, &lt;b&gt;모든 이벤트의 점수가 0점&lt;/b&gt;이었다. 0점이면 &quot;의미 없는 이벤트&quot;로 간주돼 전부 필터링된다. 결과 테이블이 빈 건 당연한 결과였다.&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;이 시스템에서 이벤트는 두 번 변환된다. Kafka에서 들어올 때는 &quot;ProductViewedEvent&quot;(상품 조회 이벤트)라는 긴 이름이지만, Consumer가 DB에 저장할 때 &quot;VIEW&quot;라는 짧은 코드로 바꿔서 넣는다:&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;Kafka 원본: &quot;ProductViewedEvent&quot;  &amp;rarr;  DB 저장: &quot;VIEW&quot;
Kafka 원본: &quot;ProductLikedEvent&quot;   &amp;rarr;  DB 저장: &quot;LIKE&quot;
Kafka 원본: &quot;OrderItemSoldEvent&quot;  &amp;rarr;  DB 저장: &quot;ORDER&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 배치의 점수 계산기(Scorer)는 &lt;b&gt;DB에 저장된 짧은 코드가 아니라, Kafka 원본 이름을 기대하고 있었다&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// 점수 계산기 &amp;mdash; 수정 전 (버그)
return switch (eventType) {
    case &quot;ProductViewedEvent&quot; -&amp;gt; 0.1;   // DB에는 &quot;VIEW&quot;가 들어있는데...
    case &quot;ProductLikedEvent&quot;  -&amp;gt; 0.2;   // DB에는 &quot;LIKE&quot;가 들어있는데...
    case &quot;OrderItemSoldEvent&quot; -&amp;gt; 0.6;   // DB에는 &quot;ORDER&quot;가 들어있는데...
    default -&amp;gt; 0.0;                      // &amp;larr; 전부 여기로 빠져서 0점
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB에서 &quot;VIEW&quot;를 읽어왔는데, 계산기는 &quot;ProductViewedEvent&quot;만 알아듣는다. 매칭 실패 &amp;rarr; 0점 &amp;rarr; 전부 필터링 &amp;rarr; 빈 테이블.&lt;/p&gt;
&lt;h3 data-heading=&quot;왜 테스트에서는 안 보였나&quot; data-ke-size=&quot;size23&quot;&gt;왜 테스트에서는 안 보였나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트는 DB에 테스트 데이터를 직접 넣는다(seed). 이때 Consumer를 거치지 않고 &lt;b&gt;직접 insert&lt;/b&gt;하는데, Kafka 원본 이름 &quot;ProductViewedEvent&quot;를 그대로 넣고 있었다:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 테스트 데이터 시드 &amp;mdash; 수정 전
seedEvent(&quot;ob-p1-v0&quot;, 1L, &quot;ProductViewedEvent&quot;, eventTime);  // &amp;larr; Kafka 원본명!
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 환경에서는 DB에 &quot;ProductViewedEvent&quot;가 들어가고, 계산기도 &quot;ProductViewedEvent&quot;를 기대하니까 매칭이 된다. 테스트는 통과한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;운영 환경&lt;/b&gt;에서는 Consumer가 &quot;VIEW&quot;로 바꿔서 저장하니까, 계산기가 매칭에 실패한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 이거다: &lt;b&gt;테스트 데이터를 만들 때 실제 운영에서 데이터가 어떤 경로를 거치는지를 재현하지 않으면, 테스트는 &quot;다른 세계&quot;를 검증하게 된다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-heading=&quot;수정&quot; data-ke-size=&quot;size23&quot;&gt;수정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계산기의 매칭 기준을 DB에 실제로 저장되는 코드(&quot;VIEW&quot;, &quot;LIKE&quot;, &quot;ORDER&quot;)로 바꾸고, 테스트 데이터도 동일하게 맞췄다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;함정 2: 시간이 9시간 밀려 있었는데 테스트에서는 안 보였다&quot; data-ke-size=&quot;size26&quot;&gt;함정 2: 시간이 9시간 밀려 있었는데 테스트에서는 안 보였다&lt;/h2&gt;
&lt;h3 data-heading=&quot;뭐가 문제였나&quot; data-ke-size=&quot;size23&quot;&gt;뭐가 문제였나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함정 1을 수정한 뒤에도, 실제 환경에서 &quot;이번 주&quot; 집계를 돌리면 &lt;b&gt;주 시작과 끝에서 이벤트가 빠지거나 다음 주 이벤트가 섞이는&lt;/b&gt; 현상이 있었다. 1~2건이 아니라 &lt;b&gt;9시간 분량&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 &lt;b&gt;시간대(timezone) 처리 경로가 달랐기 때문&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시스템에서 같은 event_time 컬럼을 두 가지 방식으로 접근한다:&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;저장할 때 (Consumer &amp;rarr; Hibernate/JPA):&lt;/b&gt; 한국 시간(KST)을 &lt;b&gt;세계 표준시(UTC)로 변환&lt;/b&gt;해서 저장한다. 예: KST 4월 13일 00:00 &amp;rarr; UTC 4월 12일 15:00으로 DB에 기록&lt;/li&gt;
&lt;li&gt;&lt;b&gt;읽을 때 (Batch Reader &amp;rarr; 순수 JDBC):&lt;/b&gt; Hibernate를 거치지 않고 직접 DB에 쿼리한다. 이때 JDBC 드라이버는 &lt;b&gt;JVM의 기본 시간대(한국 시간)&lt;/b&gt;를 사용한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 시점인데 저장할 때는 UTC로 넣고, 읽을 때는 한국 시간으로 해석하니까 &lt;b&gt;9시간 차이&lt;/b&gt;가 생긴다:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;저장: KST 4월 13일 00:00 &amp;rarr; DB에 &quot;4월 12일 15:00&quot;으로 기록 (UTC)
읽기: &quot;4월 13일 00:00 이후 이벤트를 찾아줘&quot; &amp;rarr; DB가 UTC 기준 4월 13일 00:00으로 해석
비교: DB에 있는 &quot;4월 12일 15:00&quot; &amp;lt; 쿼리의 &quot;4월 13일 00:00&quot; &amp;rarr; 이 이벤트는 누락됨!
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 &lt;b&gt;집계 기간 전체가 9시간 밀려있다.&lt;/b&gt; 데이터는 있고 정렬도 맞지만, 경계가 틀리다. 이런 결함은 눈으로 봐서는 거의 발견할 수 없다.&lt;/p&gt;
&lt;h3 data-heading=&quot;왜 테스트에서는 안 보였나&quot; data-ke-size=&quot;size23&quot;&gt;왜 테스트에서는 안 보였나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 데이터를 넣을 때도 Hibernate를 거치지 않고 &lt;b&gt;순수 JDBC로 직접 insert&lt;/b&gt;한다. 배치가 읽을 때도 순수 JDBC다. &lt;b&gt;넣는 쪽과 읽는 쪽이 같은 시간대 처리 방식을 쓰니까, 9시간 밀림이 양쪽에 동일하게 적용되어 서로 상쇄된다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;테스트:  순수 JDBC(한국 시간)로 저장 &amp;rarr; 순수 JDBC(한국 시간)로 읽기 &amp;rarr; 밀림이 상쇄 &amp;rarr; 안 보임
운영:    Hibernate(UTC)로 저장     &amp;rarr; 순수 JDBC(한국 시간)로 읽기  &amp;rarr; 밀림 발생 &amp;rarr; 9시간 어긋남
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;수정&quot; data-ke-size=&quot;size23&quot;&gt;수정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 JDBC 연결의 시간대를 UTC로 통일했다. DB 접속 URL에 serverTimezone=UTC를 추가해서, Hibernate 경로든 순수 JDBC 경로든 같은 시간대 기준으로 동작하게 만들었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;함정 3: 성공만 테스트했더니, 실패할 때 기존 데이터가 날아갔다&quot; data-ke-size=&quot;size26&quot;&gt;함정 3: 성공만 테스트했더니, 실패할 때 기존 데이터가 날아갔다&lt;/h2&gt;
&lt;h3 data-heading=&quot;뭐가 문제였나&quot; data-ke-size=&quot;size23&quot;&gt;뭐가 문제였나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함정 1, 2를 수정한 뒤, &quot;이 배치가 중간에 실패하면 어떻게 되지?&quot;를 생각해봤다. DB 연결이 끊기면? 메모리가 부족하면?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Batch의 Chunk-Oriented Processing은 원래 이런 상황에 대비하도록 설계되어 있다. 데이터를 일정 단위(chunk)로 나눠서 처리하고, 한 chunk가 끝날 때마다 커밋한다. 중간에 실패하면 &lt;b&gt;마지막으로 성공한 chunk까지는 보존&lt;/b&gt;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이번 구현은 이 설계를 따르지 못했다. 랭킹 테이블은 &quot;전부 바꾸거나, 아무것도 안 바꾸거나(all-or-nothing)&quot;가 필요하다. chunk 단위로 중간에 넣으면 사용자가 반만 갱신된 랭킹을 볼 수 있기 때문이다. 그래서 Writer는 DB를 건드리지 않고 &lt;b&gt;메모리에만 점수를 누적&lt;/b&gt;하고, Step(작업 단위)이 끝난 뒤 afterStep()이라는 콜백에서 &lt;b&gt;한 번에 삭제+삽입&lt;/b&gt;하는 구조로 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 afterStep()의 동작 방식이다. Spring Batch의 규칙상, &lt;b&gt;이 콜백은 Step이 성공하든 실패하든 항상 호출된다.&lt;/b&gt; 그런데 코드에서 Step의 성공/실패 여부를 확인하지 않고 무조건 삭제+삽입을 실행하고 있었다.&lt;/p&gt;
&lt;h3 data-heading=&quot;최악의 시나리오&quot; data-ke-size=&quot;size23&quot;&gt;최악의 시나리오&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 데이터를 읽는 중에 DB 연결이 끊김
2. Spring Batch가 이 Step을 &quot;실패&quot;로 기록
3. afterStep() 호출 &amp;mdash; 실패인데 코드가 확인하지 않음
4. 기존 정상 랭킹 100건 삭제 (DELETE)
5. 누적된 데이터가 없으니 새로 넣을 것도 없음 (INSERT 0건)
6. 결과: 랭킹 테이블 완전 소실. 빈 테이블.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면: 프레임워크는 &quot;실패하면 기존 데이터를 보존한다&quot;를 보장하려고 설계됐는데, 이 구조에서는 &lt;b&gt;&quot;실패하면 기존 데이터가 파괴된다&quot;&lt;/b&gt; &amp;mdash; 프레임워크가 보장하려던 것의 정반대.&lt;/p&gt;
&lt;h3 data-heading=&quot;왜 테스트에서는 안 보였나&quot; data-ke-size=&quot;size23&quot;&gt;왜 테스트에서는 안 보였나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 4개를 다시 보면 전부 &lt;b&gt;정상적으로 처리되는 경우&lt;/b&gt;만 검증한다. &quot;중간에 실패하면 기존 데이터는 어떻게 되는가?&quot;를 묻는 테스트가 하나도 없었다.&lt;/p&gt;
&lt;h3 data-heading=&quot;수정&quot; data-ke-size=&quot;size23&quot;&gt;수정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;afterStep() 첫 줄에 가드를 추가했다:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public ExitStatus afterStep(StepExecution stepExecution) {
    if (stepExecution.getStatus() != BatchStatus.COMPLETED) {
        log.warn(&quot;Step 실패 &amp;mdash; 기존 랭킹 보존. status={}&quot;, stepExecution.getStatus());
        return stepExecution.getExitStatus();
    }
    // Step이 성공했을 때만 삭제+삽입 실행
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1줄의 가드로, 실패 시 기존 데이터를 보존한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;세 가지 함정이 동시에 숨을 수 있었던 이유&quot; data-ke-size=&quot;size26&quot;&gt;세 가지 함정이 동시에 숨을 수 있었던 이유&lt;/h2&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;왜 숨었나 가려진 함정 테스트 vs 운영의 차이&lt;/p&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;이벤트 타입 불일치&lt;/td&gt;
&lt;td&gt;테스트는 DB에 직접 넣으니까 Consumer의 변환 과정이 빠짐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;저장과 읽기가 같은 경로를 탐&lt;/td&gt;
&lt;td&gt;시간대 9시간 밀림&lt;/td&gt;
&lt;td&gt;테스트는 둘 다 순수 JDBC라 밀림이 상쇄됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;성공 케이스만 검증&lt;/td&gt;
&lt;td&gt;실패 시 데이터 파괴&lt;/td&gt;
&lt;td&gt;정상 처리 4개, 장애 시나리오 0개&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;공통된 원인은 하나다: &lt;b&gt;테스트의 범위가 배치 모듈 안에 갇혀 있었다.&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;Consumer &amp;rarr; DB &amp;rarr; Batch Reader까지의 전체 경로를 테스트했다면 함정 1이 드러났을 것이다&lt;/li&gt;
&lt;li&gt;Hibernate로 저장하고 순수 JDBC로 읽는 경로를 재현했다면 함정 2가 드러났을 것이다&lt;/li&gt;
&lt;li&gt;실패 시나리오를 추가했다면 함정 3이 드러났을 것이다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 진짜 &quot;통합&quot;이 되려면, 모듈 경계를 넘는 약속(Consumer가 저장하는 값의 형태, 시간대 처리 방식, 프레임워크가 호출하는 생명주기)까지 범위에 포함해야 한다. 하지만 범위를 넓히면 테스트 환경 구성 비용이 올라간다. 여기에 트레이드오프가 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;내가 배운 것&quot; data-ke-size=&quot;size26&quot;&gt;내가 배운 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 많이 짜는 것보다 &lt;b&gt;&quot;이 테스트가 뭘 검증하지 못하는가&quot;&lt;/b&gt;를 아는 게 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4개의 통합 테스트를 작성했을 때, &quot;4개면 꽤 짰다&quot;는 안도감이 있었다. 정상 처리, 동점 순서, 멱등성, 기간 격리 &amp;mdash; 나쁘지 않은 커버리지였다. 하지만 이 4개가 &lt;b&gt;어떤 경로를 타는지&lt;/b&gt;, 그 경로가 &lt;b&gt;실제 운영과 같은 경로인지&lt;/b&gt;를 따져보지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 전부 통과한다는 건 &lt;b&gt;&quot;이 테스트들이 검증하는 범위 안에서는 안전하다&quot;&lt;/b&gt;라는 뜻이지, &quot;안전하다&quot;라는 뜻이 아니다. 그 차이를 체감한 건 이번이 처음이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌아보면, 이 구현에서 가장 깊이 배운 건 Spring Batch 프레임워크의 사용법이 아니라, &lt;b&gt;프레임워크를 빌려 쓸 때 형식과 본질을 구분하는 감각&lt;/b&gt;이었다. Reader/Processor/Writer를 채우는 건 형식이고, chunk 단위 커밋과 실패 복구가 본질이다. 형식만 빌리고 본질을 안 따라가면, 테스트가 가리는 곳에서 프레임워크가 보장하려던 것의 정반대가 벌어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 부족하다. 결함 3개를 만들고 나서야 발견했고, 그중 2개는 다른 관점의 코드 리뷰에서 힌트를 얻었다. 처음부터 &quot;이 테스트가 뭘 놓치고 있지?&quot;를 질문할 수 있었다면 더 좋았을 것이다. 다음에는 그렇게 시작하고 싶다.&lt;/p&gt;</description>
      <category>운영</category>
      <category>SpringBatch</category>
      <author>ioh'sDeveloper</author>
      <guid isPermaLink="true">https://develop-tracking.tistory.com/287</guid>
      <comments>https://develop-tracking.tistory.com/entry/%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%8A%94-%EC%A0%84%EB%B6%80-%ED%86%B5%EA%B3%BC%ED%96%88%EB%8A%94%EB%8D%B0-%EB%B0%B0%EC%B9%98%EA%B0%80-%EB%B9%88-%ED%85%8C%EC%9D%B4%EB%B8%94%EC%9D%84-%EB%A7%8C%EB%93%A0-%EC%9D%B4%EC%9C%A0#entry287comment</comments>
      <pubDate>Fri, 17 Apr 2026 07:29:52 +0900</pubDate>
    </item>
    <item>
      <title>WIL - 9주차 (같은 best-effort라도, 어떤 방향으로 깨지는지가 설계다)</title>
      <link>https://develop-tracking.tistory.com/entry/WIL-9%EC%A3%BC%EC%B0%A8-%EA%B0%99%EC%9D%80-best-effort%EB%9D%BC%EB%8F%84-%EC%96%B4%EB%96%A4-%EB%B0%A9%ED%96%A5%EC%9C%BC%EB%A1%9C-%EA%B9%A8%EC%A7%80%EB%8A%94%EC%A7%80%EA%B0%80-%EC%84%A4%EA%B3%84%EB%8B%A4</link>
      <description>&lt;h2 data-heading=&quot;이번 주에 새로 배운 것&quot; data-ke-size=&quot;size26&quot;&gt;이번 주에 새로 배운 것&lt;/h2&gt;
&lt;h3 data-heading=&quot;&amp;quot;best-effort니까 괜찮다&amp;quot;는 설계가 아니다&quot; data-ke-size=&quot;size23&quot;&gt;&quot;best-effort니까 괜찮다&quot;는 설계가 아니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka Consumer에서 DB에 메트릭을 적재하는 기존 파이프라인에 Redis ZSET 랭킹 점수를 추가해야 했다. try-catch로 감싸면 Redis가 죽어도 DB 트랜잭션은 안 깨진다. &quot;best-effort니까 이 정도면 충분하지 않을까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 판단이 틀렸다. 같은 best-effort인데 ZINCRBY를 TX 안에 넣느냐, TX 커밋 후에 넣느냐에 따라 결함의 방향이 달랐다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;TX 안에서 ZINCRBY &amp;rarr; TX COMMIT 실패 시 &amp;rarr; Redis에는 반영됨, DB에는 안 됨
                 &amp;rarr; 재처리 시 &amp;rarr; double increment (over-count)

TX 커밋 후 ZINCRBY &amp;rarr; COMMIT 성공 후 크래시 시 &amp;rarr; DB에는 반영됨, Redis에는 안 됨
                   &amp;rarr; 1건 영구 누락 (under-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;over-count는 인기가 아닌 상품이 랭킹에 올라가는 것이고, under-count는 인기 상품의 점수가 0.1점 낮아지는 것이다. 사용자 관점에서 무게가 다르다. 게다가 over-count는 부하에 비례해서 반복 발생하고(데드락, 커넥션 타임아웃), under-count는 commit 직후 수 ms 안에 프로세스가 죽어야 하니 극히 드물다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트에서 이미 ProductCacheManager가 동일한 afterCommit 패턴을 5곳에서 쓰고 있었다는 것도 판단을 뒷받침했다. 새로운 패턴을 도입한 게 아니라 기존 패턴을 확장한 것이다.&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;best-effort&quot;라는 단어로 사고를 멈추면 안 된다. 같은 best-effort 안에서도 over-count와 under-count는 완전히 다른 결함이다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-heading=&quot;afterCommit의 구조적 한계를 발견하고 리팩터링한 과정&quot; data-ke-size=&quot;size23&quot;&gt;afterCommit의 구조적 한계를 발견하고 리팩터링한 과정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;at-most-once를 afterCommit으로 구현했는데, 배치 리스너(MAX_POLL=3000)에서 건별로 afterCommit이 실행되니 ZINCRBY가 3000번, RTT가 3000회 발생했다. Docker 환경에서 Redis PING이 약 0.215ms였으니, 단순 계산으로 약 600ms. 동작은 했지만, 3000건을 한 번에 가져와 건별 RTT를 발생시키는 구조는 배치 처리의 이점을 거의 살리지 못했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 큰 문제는 같은 배치 안에서 동일 상품의 이벤트를 합산할 수 없다는 것이었다. afterCommit은 각 process() 호출 끝에 독립적으로 실행되므로, &quot;이 배치에 같은 상품이 또 있는지&quot;를 알 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;process()가 ProcessResult(deltas) record를 반환하도록 바꾸고, Consumer에서 Map&amp;lt;Long, Double&amp;gt;로 합산한 뒤 Pipeline으로 일괄 전송하는 구조로 리팩터링했다. RTT가 3000회에서 2회(daily + hourly)로 줄었다. 정합성 모델(at-most-once)은 유지하면서.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;정합성을 먼저 고정해두니까, 성능 최적화가 훨씬 다루기 쉬웠다.&lt;/b&gt; &quot;이 변경이 at-most-once를 깨뜨리는가?&quot;만 확인하면 됐으니까.&lt;/p&gt;
&lt;h3 data-heading=&quot;ZSET score는 역산할 수 없다 &amp;mdash; 이걸 깨닫는 데 시간이 걸렸다&quot; data-ke-size=&quot;size23&quot;&gt;ZSET score는 역산할 수 없다 이걸 깨닫는 데 시간이 걸렸다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ZSET에 점수를 쌓으면서 score = 84.7이라는 숫자를 보고 있었는데, 문득 깨달았다. 이게 조회 몇 건, 좋아요 몇 건, 주문 몇 건의 합인지 분해할 수 없다. 가중합의 최종 결과만 남아있으니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가중치를 0.6에서 0.8로 바꾸고 싶으면? 기존 데이터 재계산 불가. 주간 랭킹을 만들고 싶으면? 이벤트 타입별 분해 불가. 그리고 Kafka retention이 7일이니까, 9주차에 원천 데이터 적재를 시작하지 않으면 9주차 이벤트가 10주차 전에 사라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ranking_event 테이블을 만들면서, score_delta(0.1, 0.2 같은 계산된 값)가 아니라 raw fact(product_id, event_type, event_time)만 저장하기로 했다. delta는 가중치에 의존하지만, fact는 가중치와 무관하므로 나중에 어떤 가중치로든 재계산할 수 있다.&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;가 통하지 않는 영역이 있다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;이런 고민이 있었어요&quot; data-ke-size=&quot;size26&quot;&gt;이런 고민이 있었어요&lt;/h2&gt;
&lt;h3 data-heading=&quot;회사에서 &amp;quot;비즈니스 가치를 만드는 개발&amp;quot;이 뭔지를 숫자로 보게 됐다&quot; data-ke-size=&quot;size23&quot;&gt;회사에서 &quot;비즈니스 가치를 만드는 개발&quot;이 뭔지를 숫자로 보게 됐다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주에 회사에서 내가 담당하는 이커머스 도메인의 수익 규모를 알게 됐다. 약 60억 원. 그런데 이 60억을 운영하는 사람들 정산팀, 상품 MD, 재고관리이 쓰는 백오피스는 운영 효율 관점에서 개선 여지가 컸다.&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;8주차에 DBA를 설득해서 테이블 튜닝 권한을 받았던 것처럼, 이번에도 &lt;b&gt;리버스 엔지니어링&lt;/b&gt;으로 기존 시스템을 분석하면서 개선 포인트를 찾고 있다. 8주차에서는 &quot;20만 건 스캔을 줄이겠다&quot;는 구체적 수치로 설득했는데, 이번에도 같은 접근을 쓸 수 있을 것 같다. &quot;이 수동 작업을 자동화하면 OO팀이 하루 N시간을 아낀다&quot;는 수준으로 정량화할 수 있는 것부터 시작하려 한다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과제에서 원천 데이터 적재를 고민한 것이 여기서도 연결됐다. 회사 시스템에도 상품 조회 수, 좋아요 같은 핵심 액션 데이터가 집계된 숫자로만 존재하는 경우가 많다. &quot;조회 수 1 올리기&quot;는 간단하지만, &quot;이 상품이 언제, 어떤 유저에게 조회되었는가&quot;를 시계열로 분석하려면 원천 데이터가 필요하다. MD가 &quot;이 상품 왜 안 팔려?&quot;라고 물었을 때 숫자로 답하려면, 집계값이 아니라 원천 이벤트가 있어야 한다. 이번 주에 ZSET의 역산 불가능 한계에서 출발한 &quot;raw fact 저장&quot; 원칙이, 회사 시스템을 바라보는 렌즈가 되고 있다.&lt;/p&gt;
&lt;h3 data-heading=&quot;가중치 0.1 차이로 순위가 뒤집히는 걸 직접 확인했다&quot; data-ke-size=&quot;size23&quot;&gt;가중치 0.1 차이로 순위가 뒤집히는 걸 직접 확인했다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계 초기에 order 가중치를 0.7로 쓰려 했다. &quot;0.1 + 0.2 + 0.7 = 1.0, 깔끔하니까.&quot; 그런데 발제 원문이 0.6이어서 반례를 만들어봤다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;상품 A: 주문 10건 &amp;rarr; W=0.6이면 6.0, W=0.7이면 7.0
상품 B: 조회 7건 + 주문 9건 &amp;rarr; W=0.6이면 6.1, W=0.7이면 7.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0.6에서는 B가 이기고, 0.7에서는 동점이다. 숫자 하나로 순위가 바뀌었다. &quot;합이 1이면 깔끔하다&quot;는 심리적 편안함이지 설계 근거가 아니었다. ZINCRBY 점수는 확률이 아니니까 합이 1일 필요도 없고, unlike(-0.2)을 포함하면 이벤트 조합마다 &quot;합&quot;이 달라져서 전제 자체가 성립하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;비슷하겠지&quot;로 넘어가지 않고 반례를 만들어 확인하는 습관이 이번 주에 생겼다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;아쉬웠던 점 &amp;amp; 다음에 해보고 싶은 것&quot; data-ke-size=&quot;size26&quot;&gt;아쉬웠던 점 &amp;amp; 다음에 해보고 싶은 것&lt;/h2&gt;
&lt;h3 data-heading=&quot;정합성 요구 수준이 다른 것들은 더 일찍 분리했어야 했다&quot; data-ke-size=&quot;size23&quot;&gt;정합성 요구 수준이 다른 것들은 더 일찍 분리했어야 했다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 CatalogMetricsConsumer 안에서 metrics와 ranking을 함께 처리해도 된다고 생각했다. 둘 다 같은 이벤트를 소비하고, 같은 시점에 계산되니 한곳에 두는 편이 단순해 보였기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 Redis 타임아웃이 metrics 파이프라인까지 밀리게 되는 걸 보면서, 같은 입력을 받는다는 이유만으로 같은 경계 안에 둘 수는 없다는 걸 배웠다. 정확성이 중요한 도메인과 근사치를 허용하는 도메인은 장애의 전파 방식도, 보호해야 할 기준도 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주의 아쉬움은 구현이 틀린 것이 아니라, 이 차이를 장애를 겪고 나서야 구조로 분리했다는 점이다. 다음부터는 기능이 비슷해 보여도 먼저 &quot;이 로직은 얼마나 틀릴 수 있는가&quot;, &quot;어디까지 지연을 허용할 수 있는가&quot;를 기준으로 경계를 나누는 쪽으로 설계해보고 싶다.&lt;/p&gt;
&lt;h3 data-heading=&quot;회사 백오피스 개선을 구체화하고 싶다&quot; data-ke-size=&quot;size23&quot;&gt;회사 백오피스 개선을 구체화하고 싶다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;60억 수익을 만드는 이커머스 도메인에서, 운영 도구 개선이 비즈니스 가치로 연결되는 개선 기준이 보이기 시작했다. 다음 주에는 &quot;이 수동 작업을 자동화하면 하루 N시간 절약&quot;이라는 수준으로 정량화할 수 있는 개선 포인트를 하나 구체화해보고 싶다. 8주차에 n8n을 알게 됐으니, 이걸 활용할 수 있는 시나리오가 있는지도 같이 검토할 예정이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;KPT&quot; data-ke-size=&quot;size26&quot;&gt;KPT&lt;/h2&gt;
&lt;h3 data-heading=&quot;Keep &amp;amp; Lesson&quot; data-ke-size=&quot;size23&quot;&gt;Keep &amp;amp; Lesson&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;dual-write에서 결함 방향(over-count vs under-count)까지 따져서 at-most-once를 선택한 것. &lt;b&gt;&quot;best-effort&quot;는 전제 조건이지 설계 판단이 아니다.&lt;/b&gt; 그 안에서 결함 방향을 정하는 것이 설계다 &amp;mdash; 이건 캐시 무효화, 알림 발송 등 다른 dual-write 상황에서도 동일하게 적용 가능한 기준이다.&lt;/li&gt;
&lt;li&gt;정합성을 먼저 고정한 뒤 성능을 최적화한 것. 순서가 반대였으면 매 변경마다 정합성을 처음부터 다시 따져야 했다.&lt;/li&gt;
&lt;li&gt;ZSET 역산 불가능 한계를 인식하고, Kafka retention 7일을 의식해서 원천 데이터 적재를 선제적으로 시작한 것. &lt;b&gt;데이터는 한번 잃으면 다시 만들 수 없다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;가중치 0.6 vs 0.7의 차이를 반례로 확인한 것. 유사할 것이라 가정하지 않고, 반례 하나로 검증하는 습관.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;Problem&quot; data-ke-size=&quot;size23&quot;&gt;Problem&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Consumer 분리 판단이 늦었다. Redis 타임아웃 이슈를 겪은 후에야 장애 격리의 필요성을 인식했다&lt;/li&gt;
&lt;li&gt;같은 이벤트를 소비한다는 이유로 metrics와 ranking을 한 경계에 두었다. 정합성 요구 수준이 다른 로직은 더 일찍 분리했어야 했다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;Try&quot; data-ke-size=&quot;size23&quot;&gt;Try&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다음 과제에서는 구현 시작 전에 &quot;정확성이 필요한가, 근사치를 허용할 수 있는가, 지연을 어디까지 허용할 수 있는가&quot;를 먼저 분류하기&lt;/li&gt;
&lt;li&gt;정합성 요구 수준이 다른 로직이 한 Consumer나 한 처리 경계 안에 같이 들어가 있지 않은지 먼저 확인하기&lt;/li&gt;
&lt;li&gt;회사 백오피스 개선 포인트를 하나 정량화해서 제안하기&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;9주차 여정 요약&quot; data-ke-size=&quot;size26&quot;&gt;9주차 여정 요약&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;R7 Kafka 파이프라인 + R8 Redis 인프라 위에 랭킹 시스템 구축
    &amp;darr;
&quot;ZINCRBY 한 줄 추가하면 되지?&quot;
    &amp;rarr; dual-write 문제 발견. best-effort 안에서도 결함 방향이 다르다.
    &amp;rarr; over-count vs under-count &amp;rarr; at-most-once(afterCommit) 선택.
    &amp;darr;
&quot;afterCommit이면 끝 아닌가?&quot;
    &amp;rarr; 3000건 배치에서 건별 RTT 3000회 발생. 배치 합산 불가.
    &amp;rarr; ProcessResult + Pipeline &amp;rarr; RTT 3000&amp;rarr;2회. 정합성 유지.
    &amp;darr;
&quot;ZSET에 점수만 쌓으면 되는 거 아닌가?&quot;
    &amp;rarr; score = 84.7에서 조회/좋아요/주문 역산 불가.
    &amp;rarr; ranking_event에 raw fact 적재. 가중치 독립적 재계산 가능.
    &amp;rarr; Kafka retention 7일 &amp;rarr; 지금 안 하면 이벤트 소실.
    &amp;darr;
회사에서 60억 이커머스 도메인의 백오피스를 보며
    &amp;rarr; &quot;비즈니스 가치를 만드는 개발&quot;이 뭔지 고민 시작.
    &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;지난 8주가 문제를 더 정확히 정의하고, 숫자로 근거를 세우는 훈련이었다면 9주차는 &lt;b&gt;같은 best-effort 안에서도 어떤 결함 방향을 선택할지 판단하는 것이 설계&lt;/b&gt;라는 걸 배운 주였다. 그리고 데이터는 한번 잃으면 다시 만들 수 없다는 것. 이 두 가지가 이번 주의 핵심이다.&lt;/p&gt;</description>
      <category>스터디/루퍼스</category>
      <category>Loopers</category>
      <category>loopers 3기</category>
      <category>루퍼스</category>
      <category>루퍼스 루프팩</category>
      <category>루프팩</category>
      <category>리버스 엔지니어링</category>
      <author>ioh'sDeveloper</author>
      <guid isPermaLink="true">https://develop-tracking.tistory.com/286</guid>
      <comments>https://develop-tracking.tistory.com/entry/WIL-9%EC%A3%BC%EC%B0%A8-%EA%B0%99%EC%9D%80-best-effort%EB%9D%BC%EB%8F%84-%EC%96%B4%EB%96%A4-%EB%B0%A9%ED%96%A5%EC%9C%BC%EB%A1%9C-%EA%B9%A8%EC%A7%80%EB%8A%94%EC%A7%80%EA%B0%80-%EC%84%A4%EA%B3%84%EB%8B%A4#entry286comment</comments>
      <pubDate>Sat, 11 Apr 2026 00:40:30 +0900</pubDate>
    </item>
    <item>
      <title>best-effort니까 괜찮지 않나? Kafka 랭킹 파이프라인에 afterCommit 대신 배치 구조를 선택한 이유</title>
      <link>https://develop-tracking.tistory.com/entry/Kafka-Streamer-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%E2%80%94-afterCommit%EC%97%90%EC%84%9C-%EB%B0%B0%EC%B9%98-%EA%B5%AC%EC%A1%B0%EB%A1%9C-%EB%B0%94%EA%BE%BC-%EC%9D%B4%EC%9C%A0</link>
      <description>&lt;blockquote data-heading=&quot;best-effort니까 괜찮지 않나? &amp;mdash; Kafka 랭킹 파이프라인에 afterCommit 대신 배치 구조를 선택한 이유&quot; data-ke-style=&quot;style2&quot;&gt;best-effort니까 괜찮지 않나? Kafka 랭킹 파이프라인에 afterCommit 대신 배치 구조를 선택한 이유&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;TL;DR:&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL과 Redis에 동시에 쓰는 dual-write 문제에서, 이 랭킹 시나리오에서는 &quot;가짜 인기를 만드는 것&quot;보다 &quot;인기를 살짝 놓치는 것&quot;이 낫다고 판단해 at-most-once를 선택했다. afterCommit 패턴을 검토했지만 배치 최적화를 막는 구조적 한계를 발견하고, Consumer를 분리해 배치 수집 + Pipeline flush 구조를 설계했다. 정합성 모델은 유지하면서 Redis 네트워크 왕복을 건별 호출에서 배치 2회로 줄인 과정.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;시작: 기존 파이프라인에 ZINCRBY를 끼워 넣는 일&quot; data-ke-size=&quot;size26&quot;&gt;시작: 기존 파이프라인에 ZINCRBY 적용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에 Kafka 기반 이벤트 파이프라인을 구축해둔 상태였다. 상품 조회&amp;middot;좋아요&amp;middot;주문 이벤트를 Kafka 토픽(catalog-events-v1)으로 발행하고, Consumer가 배치로 소비해서 product_metrics 테이블에 집계하는 구조다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[Kafka] catalog-events-v1
  &amp;rarr; CatalogMetricsConsumer (배치 리스너, MAX_POLL=3000)
    &amp;rarr; CatalogMetricsProcessor.process() &amp;mdash; @Transactional
      &amp;rarr; 멱등성 체크 (event_handled)
      &amp;rarr; product_metrics upsert
      &amp;rarr; event_handled INSERT
      &amp;rarr; TX COMMIT
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 실시간 인기 상품 랭킹 기능을 추가해야 했다. Redis ZSET에 점수를 쌓는 것이 목표였고, 해야 할 일은 간단해 보였다. 기존 process() 안에서 rankingRepository.incrementScore(productId, delta)를 호출하면 된다. try-catch로 감싸면 Redis가 죽어도 DB 트랜잭션은 안 깨진다. best-effort니까 이 정도면 충분하지 않나?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 best-effort 자체가 아니었다. 같은 best-effort라도 &lt;b&gt;어떤 방향으로 실패하느냐&lt;/b&gt;에 따라, 랭킹이 왜곡되는 양상이 완전히 달랐다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;첫 번째 판단 &amp;mdash; ZINCRBY를 TX 안에 넣을 것인가, 밖에 넣을 것인가&quot; data-ke-size=&quot;size26&quot;&gt;첫 번째 판단 : ZINCRBY를 TX 안에 넣을 것인가, 밖에 넣을 것인가&lt;/h2&gt;
&lt;h3 data-heading=&quot;문제의 본질: Dual-Write&quot; data-ke-size=&quot;size23&quot;&gt;문제의 본질: Dual-Write&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CatalogMetricsProcessor.process()는 @Transactional 안에서 DB 작업을 수행한다. 여기에 Redis ZINCRBY를 추가하면, &lt;b&gt;하나의 이벤트가 MySQL과 Redis 두 스토어에 써야 하는&lt;/b&gt; 상황이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL과 Redis는 동일한 트랜잭션에 참여할 수 없다. 둘 중 하나만 성공하는 시나리오가 존재한다. 이게 &lt;b&gt;dual-write 문제&lt;/b&gt;다.&lt;/p&gt;
&lt;h3 data-heading=&quot;두 가지 선택지&quot; data-ke-size=&quot;size23&quot;&gt;두 가지 선택지&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;선택지 A: TX 내부 (try-catch 격리)

@Transactional process():
  1. 멱등성 체크
  2. DB metrics increment
  3. try { ZINCRBY } catch { log.warn }  &amp;larr; Redis 실패해도 TX 안 깨짐
  4. event_handled 저장
  5. TX COMMIT
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;선택지 B: TX 커밋 후

@Transactional process():
  1. 멱등성 체크
  2. DB metrics increment
  3. event_handled 저장
  4. TX COMMIT

커밋 확인 후:
  5. try { ZINCRBY } catch { log.warn }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘 다 Redis 실패를 try-catch로 무시한다. &quot;best-effort니까 둘 다 괜찮지 않나?&quot;라고 생각할 수 있다.&lt;/p&gt;
&lt;h3 data-heading=&quot;같은 best-effort 안에서도 결함의 방향이 다르다&quot; data-ke-size=&quot;size23&quot;&gt;같은 best-effort 안에서도 결함의 방향이 다르다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 선택지가 같은 best-effort라도 어떤 식으로 깨지는지, 코드보다 시퀀스로 비교하면 더 빠르게 보인다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-11-010738.png&quot; data-origin-width=&quot;1343&quot; data-origin-height=&quot;1574&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cnPFyv/dJMb990kflf/We8KBPRqhsKjxLGcO2QqIk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cnPFyv/dJMb990kflf/We8KBPRqhsKjxLGcO2QqIk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cnPFyv/dJMb990kflf/We8KBPRqhsKjxLGcO2QqIk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcnPFyv%2FdJMb990kflf%2FWe8KBPRqhsKjxLGcO2QqIk%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;1343&quot; height=&quot;1574&quot; data-filename=&quot;mermaid-diagram-2026-04-11-010738.png&quot; data-origin-width=&quot;1343&quot; data-origin-height=&quot;1574&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;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;실패 시나리오&amp;nbsp;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;A (TX 내부)&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;B (커밋 후)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis 다운 &amp;rarr; ZINCRBY 실패&lt;/td&gt;
&lt;td&gt;1건 누락&lt;/td&gt;
&lt;td&gt;1건 누락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ZINCRBY 성공 &amp;rarr; TX COMMIT 실패&lt;/td&gt;
&lt;td&gt;&lt;b&gt;phantom increment&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;불가능 (아직 ZINCRBY 안 함)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TX COMMIT 실패 &amp;rarr; 재처리&lt;/td&gt;
&lt;td&gt;&lt;b&gt;double increment&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;정상 (ZINCRBY 미실행)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;COMMIT 성공 &amp;rarr; 크래시 &amp;rarr; ZINCRBY 미실행&lt;/td&gt;
&lt;td&gt;불가능&lt;/td&gt;
&lt;td&gt;&lt;b&gt;1건 영구 누락&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;&lt;b&gt;phantom increment&lt;/b&gt;를 풀어서 설명하면 이렇다: Redis에는 ZINCRBY로 점수가 올라갔지만 DB TX가 롤백되면서 event_handled에 기록이 남지 않는다. Kafka offset도 커밋되지 않으므로 같은 이벤트가 재소비되고, 멱등성 체크를 통과해서 ZINCRBY가 한 번 더 실행된다. 한 건의 이벤트가 Redis 점수를 두 번 올리는 것이다.&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;A는 과잉 반영(over-count) 방향으로 실패하고, B는 누락(under-count) 방향으로 실패한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어느 쪽이 더 나은가?&lt;/p&gt;
&lt;h3 data-heading=&quot;&amp;quot;가짜 인기&amp;quot;와 &amp;quot;놓친 인기&amp;quot;&quot; data-ke-size=&quot;size23&quot;&gt;&quot;가짜 인기&quot;와 &quot;놓친 인기&quot;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹은 사용자에게 &quot;지금 뭐가 인기 있는지&quot;를 알려주는 발견(discovery) 도구다.&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;over-count (A)&lt;/b&gt;: DB에는 기록이 없는데 랭킹에는 올라가 있다. 인기가 아닌 상품이 상위에 노출된다. &amp;rarr; 사용자 신뢰 훼손&lt;/li&gt;
&lt;li&gt;&lt;b&gt;under-count (B)&lt;/b&gt;: DB에는 기록이 있는데 랭킹 점수가 살짝 낮다. 실제 인기보다 0.1점 낮을 뿐. &amp;rarr; 순위에 미미한 영향&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;없는 인기를 만드는 것&quot;이 &quot;있는 인기를 살짝 놓치는 것&quot;보다 나쁘다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-heading=&quot;발생 확률까지 따져보면&quot; 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;A의 결함(TX COMMIT 실패 후 재처리): 데드락, 커넥션 타임아웃 등 &lt;b&gt;부하에 비례해 반복적으로 발생&lt;/b&gt;. 부하가 높을수록 축적됨&lt;/li&gt;
&lt;li&gt;B의 결함(commit 직후 크래시): commit과 ZINCRBY 사이 &lt;b&gt;수 ms 안에 정확히 프로세스가 죽어야 함&lt;/b&gt;. 극히 희귀하고 반복 패턴 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복되는 결함은 축적되어 랭킹 왜곡이 점점 심해진다. 무작위적이고 드문 결함은 일간 리셋으로 자연 보정된다.&lt;/p&gt;
&lt;h3 data-heading=&quot;분산 시스템 용어로 정리하면&quot; data-ke-size=&quot;size23&quot;&gt;분산 시스템 용어로 정리하면&lt;/h3&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;의미론&lt;/td&gt;
&lt;td&gt;결함 방향&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A (TX 내부)&lt;/td&gt;
&lt;td&gt;at-least-once (Redis 관점)&lt;/td&gt;
&lt;td&gt;중복 반영 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B (커밋 후)&lt;/td&gt;
&lt;td&gt;at-most-once (Redis 관점)&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;b&gt;이 랭킹 시나리오는 일일 리셋 + 근사치 허용이라는 특성이 있어서, at-most-once(B)가 더 맞다고 판단했다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;두 번째 판단 &amp;mdash; afterCommit으로 충분한가&quot; data-ke-size=&quot;size26&quot;&gt;두 번째 판단 :afterCommit으로 충분한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;at-most-once를 결정했으니, &quot;DB 커밋 후에만 Redis에 쓴다&quot;를 어떻게 구현할 것인가. 가장 먼저 떠오른 건 TransactionSynchronization.afterCommit()이었다.&lt;/p&gt;
&lt;h3 data-heading=&quot;프로젝트에 이미 있는 afterCommit 패턴&quot; data-ke-size=&quot;size23&quot;&gt;프로젝트에 이미 있는 afterCommit 패턴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트에서는 캐시 무효화에 이미 afterCommit을 5곳에서 사용하고 있었다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// ProductCacheManager &amp;mdash; DB 커밋 후 캐시 무효화
public void registerEvictAfterCommit(Long productId) {
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                redisTemplate.delete(DETAIL_KEY_PREFIX + productId);
            }
        }
    );
}
&lt;/code&gt;&lt;/pre&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;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;기존 (캐시 무효화)&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;랭킹&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB 커밋 후 Redis DEL&lt;/td&gt;
&lt;td&gt;DB 커밋 후 Redis ZINCRBY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ProductCacheManager 4곳&lt;/td&gt;
&lt;td&gt;&amp;mdash;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OrderCacheManager 1곳&lt;/td&gt;
&lt;td&gt;&amp;mdash;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 Spring TX API, 같은 dual-write 문제, 같은 at-most-once 의미론. 랭킹에도 afterCommit을 쓰면 패턴 확장이지 새로운 도입이 아니다. 자연스러운 선택처럼 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 캐시 무효화와 랭킹 점수 적재는 호출 빈도가 근본적으로 다르다.&lt;/p&gt;
&lt;h3 data-heading=&quot;afterCommit의 구조적 한계: 배치와 맞지 않는다&quot; data-ke-size=&quot;size23&quot;&gt;afterCommit의 구조적 한계: 배치와 맞지 않는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CatalogMetricsConsumer는 배치 리스너다. 한 번에 최대 3000건을 받아서 for문으로 건건이 processor.process()를 호출한다. 여기에 afterCommit을 붙이면 이렇게 된다:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;배치 3000건 수신
&amp;rarr; for (record : records)
    &amp;rarr; processor.process()        &amp;mdash; @Transactional, DB 작업
      &amp;rarr; afterCommit 콜백 등록
    &amp;rarr; TX COMMIT
    &amp;rarr; afterCommit 실행 &amp;rarr; ZINCRBY  &amp;larr; 1건마다 Redis RTT 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;3000건이면 ZINCRBY 3000회, Redis RTT 3000회.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시 무효화는 상품 수정&amp;middot;삭제 같은 저빈도 이벤트에서 발생한다. 한 번에 수천 건씩 캐시를 날리는 일은 없다. afterCommit이 건별로 실행돼도 문제가 없다.&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;middot;좋아요&amp;middot;주문이다. 배치 한 번에 3000건이 들어온다. 건별 afterCommit은 배치의 의미를 없앤다.&lt;/p&gt;
&lt;h3 data-heading=&quot;더 큰 문제: 배치 정제가 불가능&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-heading=&quot;더 큰 문제: 배치 정제가 불가능&quot; 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;angelscript&quot;&gt;&lt;code&gt;배치 3000건 중:
  product:42에 대한 조회 이벤트 50건 &amp;rarr; delta 합산 = 50 &amp;times; 0.1 = 5.0
  product:42에 대한 좋아요 이벤트 3건 &amp;rarr; delta 합산 = 3 &amp;times; 0.2 = 0.6
  &amp;rarr; product:42에 대해 ZINCRBY 1회 (5.6)로 충분
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 afterCommit은 &lt;b&gt;각 process() 호출 끝에 독립적으로 실행&lt;/b&gt;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;process() 안에서는 &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;afterCommit은 &quot;TX 커밋 후 실행&quot;이라는 정합성은 보장하지만, &lt;b&gt;호출 단위가 이벤트 1건에 묶여 있어서 배치 최적화를 구조적으로 막는다.&lt;/b&gt; 캐시 무효화에는 맞지만, 대량 이벤트를 집계하는 랭킹에는 맞지 않았다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;해결: Consumer 분리 + 배치 수집&quot; data-ke-size=&quot;size26&quot;&gt;해결: Consumer 분리 + 배치 수집&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;afterCommit의 한계는 &quot;process() 안에서 Redis 쓰기를 결정하는 구조&quot; 자체에 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 1건 단위로 process()가 호출되는 이상, afterCommit이든 반환값이든 건별 처리에서 벗어날 수 없다.&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;랭킹 책임을 별도의 Consumer로 분리&lt;/b&gt;하는 것이었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-11-010802.png&quot; data-origin-width=&quot;653&quot; data-origin-height=&quot;2856&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmcXxj/dJMcagykgBU/s7dFKYy7o8XEPW7BpKnlHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmcXxj/dJMcagykgBU/s7dFKYy7o8XEPW7BpKnlHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmcXxj/dJMcagykgBU/s7dFKYy7o8XEPW7BpKnlHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmcXxj%2FdJMcagykgBU%2Fs7dFKYy7o8XEPW7BpKnlHK%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;300&quot; height=&quot;1312&quot; data-filename=&quot;mermaid-diagram-2026-04-11-010802.png&quot; data-origin-width=&quot;653&quot; data-origin-height=&quot;2856&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-heading=&quot;같은 토픽, 다른 Consumer Group&quot; data-ke-size=&quot;size23&quot;&gt;같은 토픽, 다른 Consumer Group&lt;/h3&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;[Kafka] catalog-events-v1
  ├─ catalog-metrics-group &amp;rarr; CatalogMetricsConsumer
  │   &amp;rarr; CatalogMetricsProcessor.process() &amp;mdash; 기존 메트릭 집계 (DB만)
  │
  └─ ranking-group &amp;rarr; RankingConsumer (신규)
      &amp;rarr; 배치 수집 + Redis flush
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 CatalogMetricsConsumer는 건드리지 않았다. 같은 토픽을 다른 Consumer Group으로 독립 소비하는 RankingConsumer를 추가했다. 메트릭 집계와 랭킹 점수 적재가 서로 영향을 주지 않는다.&lt;/p&gt;
&lt;h3 data-heading=&quot;saveAndCollectDelta: DB INSERT 성공 후에만 delta 수집&quot; data-ke-size=&quot;size23&quot;&gt;saveAndCollectDelta: DB INSERT 성공 후에만 delta 수집&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RankingConsumer의 핵심은 saveAndCollectDelta()다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// RankingConsumer.java
private void saveAndCollectDelta(String outboxId, long productId, String eventType,
                                  ZonedDateTime eventTime, double delta,
                                  Map&amp;lt;Long, Double&amp;gt; batchScores) {
    RankingEventEntity event = RankingEventEntity.of(outboxId, productId, eventType, eventTime);

    try {
        rankingEventRepository.save(event);
    } catch (DataIntegrityViolationException e) {
        log.debug(&quot;[Ranking] 중복 스킵 &amp;mdash; outboxId={}, productId={}&quot;, outboxId, productId);
        return;
    }

    // INSERT 성공 = 신규 이벤트 &amp;rarr; delta 수집
    if (delta != 0.0) {
        batchScores.merge(productId, delta, Double::sum);
    }
}
&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;ranking_event 테이블에 INSERT를 시도한다. 이 테이블의 유니크 제약이 멱등성을 보장한다.&lt;/li&gt;
&lt;li&gt;DataIntegrityViolationException이 발생하면 중복 이벤트이므로 delta를 수집하지 않고 스킵한다.&lt;/li&gt;
&lt;li&gt;INSERT가 성공하면 &amp;mdash; DB에 커밋된 이벤트만 &amp;mdash; delta를 batchScores Map에 합산한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;rankingEventRepository.save()는 Spring Data의 기본 트랜잭션으로 개별 커밋된다.&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;Consumer 메서드(consume())에는 @Transactional이 없다. 따라서 &lt;b&gt;save()가 반환되는 시점에 DB 커밋은 이미 완료&lt;/b&gt;된 상태다. delta 수집은 커밋 이후에 일어난다.&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;이 구조가 afterCommit과 같은 at-most-once 보장을 제공하면서도, delta를 반환값으로 올릴 수 있는 이유다.&lt;/p&gt;
&lt;h3 data-heading=&quot;Consumer 배치 루프&quot; data-ke-size=&quot;size23&quot;&gt;Consumer 배치 루프&lt;/h3&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// RankingConsumer.java &amp;mdash; consume()
@KafkaListener(topics = &quot;catalog-events-v1&quot;, groupId = &quot;ranking-group&quot;,
               containerFactory = &quot;BATCH_LISTENER_DEFAULT&quot;)
public void consume(List&amp;lt;ConsumerRecord&amp;lt;Object, Object&amp;gt;&amp;gt; records, Acknowledgment ack) {
    Map&amp;lt;Long, Double&amp;gt; batchScores = new HashMap&amp;lt;&amp;gt;();

    for (ConsumerRecord&amp;lt;Object, Object&amp;gt; record : records) {
        // 이벤트 파싱 &amp;rarr; saveAndCollectDelta() 호출
        // batchScores에 동일 상품 delta가 자동 합산됨
        processEvent(eventType, idempotencyKey, payload, eventTime, batchScores);
    }

    rankingScoreUpdater.flushBatch(batchScores);
    ack.acknowledge();
}
&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;핵심: batchScores는 Consumer의 배치 루프가 소유한다. 3000건의 for문이 끝난 후, 합산된 Map 하나를 flushBatch()에 넘긴다. 동일 상품에 대한 이벤트가 50건이든 100건이든, Map에서 하나의 엔트리로 합쳐진다.&lt;/p&gt;
&lt;h3 data-heading=&quot;flushBatch(): Pipeline으로 일괄 적재&quot; data-ke-size=&quot;size23&quot;&gt;flushBatch(): Pipeline으로 일괄 적재&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// RankingScoreUpdater.java
public void flushBatch(Map&amp;lt;Long, Double&amp;gt; scores) {
    if (scores.isEmpty()) return;
    try {
        String dailyKey = generateDailyKey();
        rankingRedisRepository.incrementScoreBatch(dailyKey, scores, dailyTtl);

        String hourlyKey = generateHourlyKey();
        rankingRedisRepository.incrementScoreBatch(hourlyKey, scores, hourlyTtl);
    } catch (Exception e) {
        log.warn(&quot;[Ranking] 배치 flush 실패 &amp;mdash; products={}건, best-effort 누락&quot;,
                scores.size(), e);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// RankingRedisRepository.java
public void incrementScoreBatch(String key, Map&amp;lt;Long, Double&amp;gt; scores, long ttlSeconds) {
    redisTemplate.executePipelined((RedisCallback&amp;lt;Object&amp;gt;) connection -&amp;gt; {
        byte[] rawKey = redisTemplate.getStringSerializer().serialize(key);
        for (Map.Entry&amp;lt;Long, Double&amp;gt; entry : scores.entrySet()) {
            byte[] member = redisTemplate.getStringSerializer()
                    .serialize(String.valueOf(entry.getKey()));
            connection.zSetCommands().zIncrBy(rawKey, entry.getValue(), member);
        }
        return null;
    });
    setTtlIfAbsent(key, ttlSeconds);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;성능 변화&quot; data-ke-size=&quot;size23&quot;&gt;성능 변화&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Before (afterCommit 구조를 적용했다면):
  3000건 &amp;rarr; ZINCRBY 3000회 &amp;rarr; Redis RTT 3000회

After (Consumer 분리 + 배치 수집):
  3000건 &amp;rarr; 합산 &amp;rarr; 유니크 상품 약 150건 (테스트 배치 기준) &amp;rarr; Pipeline 2회 (daily + hourly)
&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;RTT 횟수 기준으로 3000회에서 2회. Pipeline 내부에서 150개의 ZINCRBY 커맨드가 실행되지만, 이건 하나의 네트워크 왕복 안에서 처리된다. 합산 연산(Map.merge)은 메모리 내 O(N)이라 무시할 수 있는 수준이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;at-most-once는 유지되는가?&quot; data-ke-size=&quot;size26&quot;&gt;at-most-once는 유지되는가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Consumer를 분리하고 배치 수집 구조로 바꾸면서, 정합성 모델이 달라지지 않았는지 확인해야 한다.&lt;/p&gt;
&lt;h3 data-heading=&quot;실제 보장 구조&quot; data-ke-size=&quot;size23&quot;&gt;실제 보장 구조&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;정상 흐름:
  save() 성공 (DB 커밋) &amp;rarr; delta 수집 &amp;rarr; flushBatch() 성공 &amp;rarr; ack
  &amp;rarr; Redis 반영 ✓, DB 반영 ✓

save() 중복:
  save() &amp;rarr; DataIntegrityViolationException &amp;rarr; delta 미수집 &amp;rarr; Redis 미반영
  &amp;rarr; 멱등성 보장 ✓

flush 실패 (Redis 장애):
  save() 성공 &amp;rarr; delta 수집 &amp;rarr; flushBatch() 실패 &amp;rarr; 로그 경고 &amp;rarr; ack
  &amp;rarr; DB에는 기록 있음, Redis에는 없음 (under-count) ✓

배치 중간 크래시:
  records 1~500: save() 성공, delta 수집 (메모리)
  record 501: 크래시
  &amp;rarr; flushBatch() 미실행 &amp;rarr; Redis 미반영 (under-count)
  &amp;rarr; 재처리 시: records 1~500은 중복으로 스킵, 501~3000은 정상 처리
&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;over-count가 발생하지 않는다.&lt;/b&gt; &lt;br /&gt;DB INSERT(ranking_event)가 delta 수집의 게이트이고, Redis flush는 항상 INSERT 이후에 실행된다. INSERT가 성공했지만 flush 전에 실패하면 under-count, flush까지 성공하면 정확히 반영된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재처리 시에도 DataIntegrityViolationException이 중복 INSERT를 막으므로, 같은 이벤트에 대해 ZINCRBY가 두 번 실행되는 경로가 없다.&lt;/p&gt;
&lt;h3 data-heading=&quot;새로운 트레이드오프: 유실 단위&quot; data-ke-size=&quot;size23&quot;&gt;새로운 트레이드오프: 유실 단위&lt;/h3&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;유실 단위&lt;/td&gt;
&lt;td&gt;유실 시 영향&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;afterCommit (건별)&lt;/td&gt;
&lt;td&gt;이벤트 1건&lt;/td&gt;
&lt;td&gt;0.1~0.6점 누락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Consumer 분리 + 배치&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;배치 전체 (최대 3000건)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;수십~수백 점 누락&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;배치 flush가 실패하면 건별이 아니라 배치 단위로 날아간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;afterCommit 방식이었다면 3000건 중 1500건까지 성공하고 나머지만 실패하는 partial failure가 가능했겠지만, 배치 flush는 &lt;b&gt;all-or-nothing&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;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;: flush 실패는 Redis 장애(다운, 네트워크 타임아웃, 커넥션 끊김) 상황에서만 발생한다. 이 상황에서는 afterCommit 방식도 전건 실패할 가능성이 높다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복구 메커니즘&lt;/b&gt;: 랭킹은 일일 리셋된다. 배치 단위 유실이 발생해도 다음 리셋 주기에 자연 보정된다. 그리고 ranking_event 테이블에 원본이 남아 있으므로, 필요하면 DB 기반으로 점수를 재계산할 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;정리 &amp;mdash; 파이프라인 설계 과정&quot; data-ke-size=&quot;size26&quot;&gt;정리 : 파이프라인 설계 과정&lt;/h2&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;1단계: &quot;ZINCRBY 끼워 넣으면 되지?&quot;
  &amp;rarr; dual-write 문제 발견

2단계: TX 안 vs TX 밖 &amp;rarr; at-most-once 선택
  &amp;rarr; 결함의 방향(over-count vs under-count)으로 판단

3단계: afterCommit 검토 &amp;rarr; 배치 최적화를 막는 구조적 한계 발견
  &amp;rarr; Consumer 분리 + 배치 수집으로 설계

4단계: at-most-once 유지 확인 + 유실 단위 변경 인식
&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;table style=&quot;border-collapse: collapse; width: 100%; height: 95px;&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;선택&lt;/td&gt;
&lt;td&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;at-most-once&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;over-count가 under-count보다 위험&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;Consumer 분리 (ranking-group)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;afterCommit은 건별 실행으로 배치 최적화 불가&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;ranking_event INSERT + 유니크 제약&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;DataIntegrityViolationException으로 중복 차단&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;Map 합산 + Pipeline flush&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;동일 상품 delta 합산 &amp;rarr; 유니크 상품 수만큼만 ZINCRBY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Redis 쓰기 대상&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Master 전용 template&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;쓰기 연산은 반드시 Master에서&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;단순한 Redis 호출 추가가 아니라 &quot;파이프라인을 어떻게 확장할 것인가&quot;의 문제였다.&lt;/b&gt; 정합성 모델을 먼저 정하고, 구현 방식을 나중에 결정했다. 순서가 반대였으면 배치 성능을 먼저 잡고 정합성을 나중에 끼워 넣으면 구조가 훨씬 복잡해졌을 거다.&lt;br /&gt;&lt;br /&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;at-most-once라는 기준이 먼저 서 있으니까, Consumer 분리와 배치 구조를 설계할 때도 &quot;이 변경이 over-count를 만드는가?&quot;만 확인하면 됐다. 순서가 반대였다면 성능을 먼저 잡고 정합성을 나중에 끼워 넣었다면 매 변경마다 정합성을 처음부터 다시 따져야 했을 거다.&lt;/p&gt;</description>
      <category>운영/Kafka &amp;amp; MQ</category>
      <category>Kafka</category>
      <author>ioh'sDeveloper</author>
      <guid isPermaLink="true">https://develop-tracking.tistory.com/284</guid>
      <comments>https://develop-tracking.tistory.com/entry/Kafka-Streamer-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%E2%80%94-afterCommit%EC%97%90%EC%84%9C-%EB%B0%B0%EC%B9%98-%EA%B5%AC%EC%A1%B0%EB%A1%9C-%EB%B0%94%EA%BE%BC-%EC%9D%B4%EC%9C%A0#entry284comment</comments>
      <pubDate>Fri, 10 Apr 2026 02:41:50 +0900</pubDate>
    </item>
    <item>
      <title>Redis 키 설계 전략이 중요한 이유 시간의 양자화와 롱테일</title>
      <link>https://develop-tracking.tistory.com/entry/Redis-%ED%82%A4-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5%EC%9D%B4-%EC%A4%91%EC%9A%94%ED%95%9C-%EC%9D%B4%EC%9C%A0-%E2%80%94-%EC%8B%9C%EA%B0%84%EC%9D%98-%EC%96%91%EC%9E%90%ED%99%94%EC%99%80-%EB%A1%B1%ED%85%8C%EC%9D%BC</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;TL;DR&lt;span style=&quot;color: #666666; text-align: start;&quot;&gt;:&amp;nbsp;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666; text-align: start;&quot;&gt;랭킹 ZSET의 키 하나가 &quot;무엇을 측정하는가&quot;를 결정한다. 누적 키는 롱테일을 만들고, 일간 키는 콜드 스타트를 만든다. 키를 자르는 순간 정보가 손실되고, 그 손실을 carry-over와 fallback으로 메운다. 키 설계는 네이밍이 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;데이터 모델링&lt;/b&gt;&lt;span style=&quot;color: #666666; text-align: start;&quot;&gt;이다.&lt;/span&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;랭킹 키 하나의 무게&quot; data-ke-size=&quot;size26&quot;&gt;랭킹 키 하나의 무게&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이커머스 인기 상품 랭킹을 만들면서 가장 먼저 마주친 질문은 이거였다.&lt;/p&gt;
&lt;pre class=&quot;avrasm&quot;&gt;&lt;code&gt;ranking:{productId} &amp;rarr; score
&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;이 ZSET의 &lt;b&gt;키 이름을 어떻게 지을 것인가.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 네이밍 문제라고 생각했다. ranking:all이든 ranking:daily든, 어차피 ZINCRBY로 점수를 올리는 건 같으니까. 키 이름은 규칙만 맞추면 되는 거 아닌가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 생각이 틀렸다. 키 이름이 결정하는 건 &quot;어디에 저장하는가&quot;가 아니라 &lt;b&gt;&quot;무엇을 측정하는가&quot;&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;ranking:all &amp;rarr; 역대 누적 인기&lt;/li&gt;
&lt;li&gt;ranking:all:20260408 &amp;rarr; 2026년 4월 8일의 인기&lt;/li&gt;
&lt;li&gt;ranking:hourly:2026040814 &amp;rarr; 오후 2시~3시 사이의 인기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 ZSET, 같은 ZINCRBY, 같은 상품인데 키가 다르면 &lt;b&gt;&quot;인기&quot;의 의미 자체가 달라진다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;누적의 함정 &amp;mdash; 롱테일이 생기는 이유&quot; data-ke-size=&quot;size26&quot;&gt;누적의 함정 롱테일이 생기는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 키 하나에 모든 점수를 누적하는 게 가장 단순해 보였다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;ranking:all &amp;rarr; ZINCRBY 0.1 product:42  (조회)
ranking:all &amp;rarr; ZINCRBY 0.2 product:42  (좋아요)
ranking:all &amp;rarr; ZINCRBY 0.6 product:42  (주문)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이 구조를 며칠 운영하면 벌어지는 일:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Day 1~100: 상품 A가 매일 100건 조회 &amp;rarr; score 1,000
Day 101:   상품 B가 바이럴 &amp;rarr; 하루에 5,000건 조회

누적 랭킹:  상품 A (1,000) &amp;lt; 상품 B (5,000)  &amp;larr; 아직은 괜찮음

Day 1~365: 상품 A가 꾸준히 &amp;rarr; score 36,500
Day 366:   상품 C가 바이럴 &amp;rarr; 하루에 10,000건

누적 랭킹:  상품 A (36,500) &amp;gt; 상품 C (10,000)  &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;상품 C가 오늘 압도적으로 뜨거운데, 1년간 꾸준히 팔린 상품 A를 이길 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 &lt;b&gt;롱테일(Long Tail) 현상&lt;/b&gt;이라 한다 소수의 오래된 상품이 상위를 독점하고, 신상품은 아무리 화제여도 노출 기회가 없다.&lt;/p&gt;
&lt;h3 data-heading=&quot;비유하면&quot; data-ke-size=&quot;size23&quot;&gt;비유하면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;음악 차트를 생각해보자. 빌보드 Hot 100은 &lt;b&gt;주간&lt;/b&gt; 차트다. 역대 누적 차트라면 비틀즈가 아직도 1위일 거다. 그러면 이번 주에 뭐가 유행하는지 아무도 모른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;랭킹의 가치는 &quot;지금 뭐가 핫한가&quot;를 알려주는 데 있다.&lt;/b&gt; 누적은 그 가치를 파괴한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;시간을 자르다 &amp;mdash; 양자화(Quantization)&quot; data-ke-size=&quot;size26&quot;&gt;시간을 자르다 양자화(Quantization)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해법은 &lt;b&gt;시간 윈도우를 자르는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;연속적인 시간: ─────────────────────────────────&amp;rarr;
               00:00  06:00  12:00  18:00  24:00

일간 양자화:   |&amp;lt;──────── 하루 한 버킷 ──────────&amp;gt;|

시간별 양자화: |&amp;lt;─1H─&amp;gt;|&amp;lt;─1H─&amp;gt;|  ...  |&amp;lt;─1H─&amp;gt;|
               버킷 24개
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;양자화&quot;라는 단어가 거창해 보이지만, 본질은 &lt;b&gt;연속적인 시간을 불연속적인 구간으로 나누는 것&lt;/b&gt;이다. 물리학에서 빌려온 개념인데, 에너지가 연속이 아니라 특정 단위(양자)로만 존재하는 것처럼 우리의 랭킹 데이터도 &quot;오늘&quot;, &quot;어제&quot; 같은 단위로만 존재하게 만드는 거다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis 키로 표현하면:&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;ranking:all:20260408  &amp;rarr; 오늘의 인기
ranking:all:20260407  &amp;rarr; 어제의 인기
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매일 자정에 새 키가 시작된다. 모든 상품이 &lt;b&gt;동일한 출발선&lt;/b&gt;에서 경쟁한다.&lt;/p&gt;
&lt;h3 data-heading=&quot;어떤 윈도우를 선택할 것인가&quot; 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;angelscript&quot;&gt;&lt;code&gt;Tumbling Window (우리 선택):
|&amp;lt;── Day 1 ──&amp;gt;|&amp;lt;── Day 2 ──&amp;gt;|&amp;lt;── Day 3 ──&amp;gt;|
겹치지 않는 고정 구간. 경계에서 &quot;딱&quot; 리셋된다.

Sliding Window:
T=10시: |&amp;lt;── 최근 24시간 ──&amp;gt;|  (어제 10시 ~ 오늘 10시)
T=11시:   |&amp;lt;── 최근 24시간 ──&amp;gt;|  (어제 11시 ~ 오늘 11시)
매 순간 &quot;최근 N시간&quot;을 계산. 경계가 없다.

Hopping Window:
|&amp;lt;──── 2시간 ────&amp;gt;|
     |&amp;lt;──── 2시간 ────&amp;gt;|
윈도우가 겹친다. Tumbling과 Sliding의 중간.
&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;왜 Tumbling을 선택했는가:&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis ZSET의 score는 &lt;b&gt;하나의 숫자&lt;/b&gt;다. 인기 점수로 쓰고 있으면, 같은 score에 타임스탬프를 넣을 수 없다. Sliding Window를 구현하려면 개별 이벤트의 시각을 저장하고 매번 범위 조회를 해야 하는데 그러면 score를 인기 점수와 타임스탬프에 동시 사용할 수 없으므로 ZINCRBY 누적 구조를 변경해야 한다.&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;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;방식&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;구현 난이도&lt;/td&gt;
&lt;td&gt;경계 문제&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Redis ZSET 적합성&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Tumbling&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;있음 (자정 리셋)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;높음&lt;/b&gt; &amp;mdash; 일간 키 분리로 자연 구현&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sliding&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;낮음 &amp;mdash; score를 점수와 시간에 동시 사용 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hopping&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;완화&lt;/td&gt;
&lt;td&gt;중간 &amp;mdash; Kafka Streams 등 스트림 엔진에서 주로 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tumbling은 경계 문제가 있다. 23:59에 1위였던 상품이 00:00에 사라진다. 이 문제를 다음 장에서 다룬다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;자르면 깨지는 것 &amp;mdash; 콜드 스타트&quot; 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;그런데 &lt;b&gt;자르면 반드시 깨지는 것&lt;/b&gt;이 있었다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;23:59:59  상품 A는 오늘 1위. score 847.3
00:00:00  새 키 시작. 상품 A의 score: 0

사용자: &quot;인기 상품&quot; 클릭 &amp;rarr; 빈 목록
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;콜드 스타트&lt;/b&gt;&amp;nbsp;시스템에 충분한 데이터가 없는 상태. 방금 전까지 1위였던 상품이 자정을 기점으로 사라진다.&lt;/p&gt;
&lt;h3 data-heading=&quot;이중 방어: carry-over + API fallback&quot; data-ke-size=&quot;size23&quot;&gt;이중 방어: carry-over + API fallback&lt;/h3&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;&lt;b&gt;장치 1: Score Carry-Over (능동적 해결)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매일 23:50에 스케줄러가 실행된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Scheduled(cron = &quot;0 50 23 * * *&quot;, zone = &quot;Asia/Seoul&quot;)
public void carryOver() {
    // ZUNIONSTORE ranking:all:{내일} 1 ranking:all:{오늘} WEIGHTS 0.1
    rankingRedisRepository.carryOver(tomorrowKey, todayKey, 0.1, ttlSeconds);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 점수의 10%를 내일 키에 미리 복사한다. 자정이 되면 빈 목록이 아니라, 어제의 인기 상품이 낮은 점수로 대기하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;carry-over를 Lua Script로 감쌌다. 소스 키가 없으면 skip, destination에 TTL이 없으면 설정 이 전체를 &lt;b&gt;원자적으로&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;local exists = redis.call('EXISTS', KEYS[2])
if exists == 0 then return 0 end
redis.call('ZUNIONSTORE', KEYS[1], 1, KEYS[2], 'WEIGHTS', ARGV[1])
local ttl = redis.call('TTL', KEYS[1])
if ttl == -1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
return redis.call('ZCARD', KEYS[1])
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 0.1인가?&lt;/b&gt; 이건 감이 아니라 숫자로 검증했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Day 1: score 1000 (원본)
Day 2: 1000 &amp;times; 0.1     = 100
Day 3: 1000 &amp;times; 0.1&amp;sup2;    = 10
Day 4: 1000 &amp;times; 0.1&amp;sup3;    = 1
Day 5: 1000 &amp;times; 0.1⁴    = 0.1  &amp;larr; 사실상 소멸
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;지수 감쇠&lt;/b&gt; 패턴이다. 0.1이면 4~5일 내에 영향이 사라진다. 너무 크면 롱테일이 다시 나타나고, 너무 작으면 carry-over 효과가 미미하다.&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;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;weight&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Day 2&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;잔존 사실상 소멸&lt;/td&gt;
&lt;td&gt;효과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.01&lt;/td&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;td&gt;2일&lt;/td&gt;
&lt;td&gt;carry-over 의미 거의 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;0.1&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;10%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;4~5일&lt;/b&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;0.3&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;7~8일&lt;/td&gt;
&lt;td&gt;전날 영향이 오래 남음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;10일+&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;&lt;b&gt;장치 2: API Fallback (방어적 해결)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스케줄러가 실패할 수도 있다 서버 재시작, Redis 장애, 배포 타이밍. 그래서 API 레벨에서 한 번 더 방어한다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;// 오늘 키가 비어있으면 어제 키로 대체
if (totalCount == 0 &amp;amp;&amp;amp; (date == null || date.isBlank())) {
    String fallbackKey = buildFallbackKey(isHourly);
    long fallbackCount = rankingRedisRepository.getSize(fallbackKey);
    if (fallbackCount &amp;gt; 0) {
        key = fallbackKey;
        totalCount = fallbackCount;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;왜 둘 다 필요한가&quot; data-ke-size=&quot;size23&quot;&gt;왜 둘 다 필요한가&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;시나리오 1: 정상 동작
  23:50 carry-over ✅ &amp;rarr; 00:00 데이터 있음 &amp;rarr; fallback 불필요

시나리오 2: carry-over 실패
  23:50 서버 다운 &amp;rarr; 00:00 빈 키 &amp;rarr; fallback ✅ 어제 키 조회

시나리오 3: 둘 다 없으면
  00:00 빈 키 &amp;rarr; API 빈 배열 반환 &amp;rarr; 사용자 이탈 ❌
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;carry-over는 &lt;b&gt;미리 준비&lt;/b&gt;하는 것이고, fallback은 &lt;b&gt;실패했을 때 대응&lt;/b&gt;하는 것이다. 비용이 거의 없고, 서로 독립적이며, 둘 다 있으면 더 견고하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;실측: 이 설계가 메모리를 얼마나 쓰는가&quot; data-ke-size=&quot;size26&quot;&gt;실측: 이 설계가 메모리를 얼마나 쓰는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계를 마치고, &quot;상품 10만 개면 실제로 얼마나 쓸까?&quot;를 측정했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;측정 환경: Docker Redis 7.4 컨테이너, maxmemory 512MB

ZSET 1개 (10만 멤버):  9.24 MB  (멤버당 96.8 bytes)  &amp;larr; 실측
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일간 키 2개(오늘 + 어제) + 시간 키 4개가 동시에 존재하는 최악의 경우:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;전체 키 동시 존재 시:  33.71 MB  (maxmemory 512MB의 6.6%)  &amp;larr; 실측
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여유 있다.&lt;/b&gt; 상품이 100만 개로 늘어나면? 별도로 100만 멤버 ZSET도 측정했다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;100만 멤버 ZSET:  84.3 MB  (멤버당 88.3 bytes)  &amp;larr; 실측. jemalloc 최적화로 대량일수록 효율적
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;100만 상품이라도 일간 키 1개가 약 84MB. TTL 2일이면 약 168MB 이건 실측에서 역산한 추정값이다. 이 프로젝트의 Redis maxmemory(512MB) 기준으로는 운영 가능한 수준이다. 상품 수가 이보다 훨씬 많거나 Redis 메모리가 작다면 Top-N 전략(하위 멤버 주기적 제거)을 검토해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;키는 계약이다&quot; data-ke-size=&quot;size26&quot;&gt;키는 계약이다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 간과하기 쉬운 것 &amp;mdash; 키 포맷은 &lt;b&gt;시스템 구성요소 간의 계약&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트에서 랭킹 키를 읽고 쓰는 주체가 3개 있다:&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[commerce-streamer]   ZINCRBY ranking:all:20260408 0.6 42  (쓰기)
[commerce-api]        ZREVRANGE ranking:all:20260408 0 19  (읽기)
[carry-over 스케줄러] ZUNIONSTORE ranking:all:20260409 ... (쓰기+읽기)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 셋이 &lt;b&gt;같은 키 패턴&lt;/b&gt;을 사용해야만 데이터가 연결된다. 키 prefix나 날짜 포맷이 하나라도 다르면, 쓰기는 성공하는데 읽기에서 빈 결과가 나온다 에러 없이, 조용히.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 키 prefix를 코드에 하드코딩하지 않고 @ConfigurationProperties로 외부화했다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;ranking:
  key-prefix: &quot;ranking:all&quot;
  ttl-days: 2
  hourly-key-prefix: &quot;ranking:hourly&quot;
  hourly-ttl-hours: 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키가 바뀌면 양쪽이 동시에 깨진다. 설정을 공유함으로써 &lt;b&gt;한 곳을 바꾸면 양쪽이 함께 바뀌게&lt;/b&gt; 만든 거다.&lt;/p&gt;
&lt;h3 data-heading=&quot;타임존: &amp;quot;오늘&amp;quot;은 누구의 오늘인가&quot; data-ke-size=&quot;size23&quot;&gt;타임존: &quot;오늘&quot;은 누구의 오늘인가&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;LocalDate today = LocalDate.now(ZoneId.of(&quot;Asia/Seoul&quot;));
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 ZoneId.of(&quot;Asia/Seoul&quot;)이 빠지면, 서버가 UTC로 설정되어 있을 때 한국 시간 오전 8시에 &quot;오늘&quot;이 달라진다. 사용자는 &quot;오늘의 인기 상품&quot;을 눌렀는데 어제 데이터를 보게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국 대상 서비스라면 KST 기준이 자연스러운 선택이다. &lt;b&gt;사용자의 &quot;오늘&quot;과 시스템의 &quot;오늘&quot;이 일치해야 한다.&lt;/b&gt; 다만 글로벌 서비스라면 유저별 타임존이나 UTC 기준 + 클라이언트 변환을 고려해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;정리 &amp;mdash; 키 설계에서 내린 판단들&quot; data-ke-size=&quot;size26&quot;&gt;정리&amp;nbsp; 키 설계에서 내린 판단들&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;판단&lt;/td&gt;
&lt;td&gt;선택&lt;/td&gt;
&lt;td&gt;근거&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;키 단위&lt;/td&gt;
&lt;td&gt;일간 Tumbling Window&lt;/td&gt;
&lt;td&gt;ZSET score와 시간 분리, 롱테일 방지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTL&lt;/td&gt;
&lt;td&gt;2일&lt;/td&gt;
&lt;td&gt;오늘 + 어제 유지, fallback + carry-over 대비&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;타임존&lt;/td&gt;
&lt;td&gt;KST&lt;/td&gt;
&lt;td&gt;사용자의 &quot;오늘&quot;과 일치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;콜드 스타트&lt;/td&gt;
&lt;td&gt;carry-over + fallback&lt;/td&gt;
&lt;td&gt;능동 + 방어, 이중 장치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;감쇠율&lt;/td&gt;
&lt;td&gt;0.1 (지수 감쇠)&lt;/td&gt;
&lt;td&gt;4~5일 소멸, 롱테일 재발 방지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;키 공유&lt;/td&gt;
&lt;td&gt;ConfigurationProperties&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;키 하나의 이름이 &quot;무엇을 측정하는가&quot;를 결정하고, TTL이 &quot;얼마나 기억하는가&quot;를 결정하고, carry-over가 &quot;얼마나 부드럽게 전환하는가&quot;를 결정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;키 설계는 네이밍이 아니라 데이터 모델링이다.&lt;/b&gt; 키를 정하는 순간 &quot;무엇을 측정하고, 얼마나 기억하고, 무엇을 잃어도 되는가&quot;가 결정된다. 이 판단을 코드가 아니라 키 이름이 먼저 내린다.&lt;/p&gt;</description>
      <category>운영</category>
      <category>Redis</category>
      <category>양자화</category>
      <category>콜드 스타트</category>
      <author>ioh'sDeveloper</author>
      <guid isPermaLink="true">https://develop-tracking.tistory.com/283</guid>
      <comments>https://develop-tracking.tistory.com/entry/Redis-%ED%82%A4-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5%EC%9D%B4-%EC%A4%91%EC%9A%94%ED%95%9C-%EC%9D%B4%EC%9C%A0-%E2%80%94-%EC%8B%9C%EA%B0%84%EC%9D%98-%EC%96%91%EC%9E%90%ED%99%94%EC%99%80-%EB%A1%B1%ED%85%8C%EC%9D%BC#entry283comment</comments>
      <pubDate>Fri, 10 Apr 2026 02:39:59 +0900</pubDate>
    </item>
    <item>
      <title>@Transactional이 삼킨 커넥션, BCrypt가 놓아주지 않은 150ms</title>
      <link>https://develop-tracking.tistory.com/entry/Transactional%EC%9D%B4-%EC%82%BC%ED%82%A8-%EC%BB%A4%EB%84%A5%EC%85%98-BCrypt%EA%B0%80-%EB%86%93%EC%95%84%EC%A3%BC%EC%A7%80-%EC%95%8A%EC%9D%80-150ms</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional(readOnly = true)를 습관적으로 붙이면 안전한 줄 알았다. 그런데 인증 메서드 안에 BCrypt가 있으면, DB가 아무 일도 안 하는 150ms 동안 커넥션을 잡고 놓아주지 않는다. 커넥션 40개짜리 풀에서 동시 30명이 인증하면, 다른 API는 커넥션을 기다리다 죽는다. 어노테이션 한 줄 지우는 것이 성능 최적화의 전부였다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;발견: &amp;quot;readOnly면 가볍다&amp;quot;는 착각&quot; data-ke-size=&quot;size26&quot;&gt;발견: &quot;readOnly면 가볍다&quot;는 착각&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이커머스 프로젝트에서 인증 메서드를 작성하면서, 읽기 전용 메서드에는 습관적으로 @Transactional(readOnly = true)를 붙였다. 읽기 전용이면 flush도 안 하고, 스냅샷도 안 만들고, 최적화만 해주는 거라고 알고 있었으니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 부하 테스트 시나리오를 설계하면서 커넥션 풀 사용률을 계산하다가 이상한 점을 발견했다. 인증 메서드의 커넥션 점유 시간이 예상보다 훨씬 길었다. DB 쿼리는 5ms면 끝나는데, 커넥션은 155ms 동안 잡혀 있었다. &lt;b&gt;readOnly여도 커넥션은 점유한다.&lt;/b&gt; 그리고 그 점유 시간의 대부분은 DB가 아닌 BCrypt가 차지하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 실제로 문제가 되는 상황인지 확인하기 위해 코드를 추적했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;현장: 우리 코드의 인증 흐름&quot; data-ke-size=&quot;size26&quot;&gt;현장: 우리 코드의 인증 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 인증은 @AuthUser라는 커스텀 어노테이션으로 동작한다. Controller 메서드의 파라미터에 @AuthUser를 붙이면, Spring이 요청 헤더에서 아이디와 비밀번호를 꺼내 인증한 뒤 User 객체를 주입한다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// OrderController.java
@PostMapping(&quot;/api/v1/orders&quot;)
public ApiResponse&amp;lt;OrderResponse&amp;gt; createOrder(@AuthUser User user, ...) {
    // user는 이미 인증 완료된 상태
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 @AuthUser가 붙은 엔드포인트가 프로젝트 전체에 &lt;b&gt;35개 이상&lt;/b&gt;이다. 주문, 결제, 장바구니, 쿠폰, 포인트, 좋아요, 대기열 인증이 필요한 거의 모든 API.&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;// UserService.java
@Transactional(readOnly = true)  // &amp;larr; 이게 문제
public User authenticateUser(String rawLoginId, String rawPassword) {
    User user = this.userRepository.findByLoginId(rawLoginId)      // DB 조회
            .orElseThrow(() -&amp;gt; new CoreException(UserErrorType.UNAUTHORIZED));

    if (!this.passwordEncryptor.matches(rawPassword, user.getPassword())) {  // BCrypt
        throw new CoreException(UserErrorType.UNAUTHORIZED);
    }
    return user;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 줄이다. findByLoginId()로 사용자를 찾고, passwordEncryptor.matches()로 비밀번호를 비교한다. 간단해 보인다. 그래서 @Transactional(readOnly = true)를 붙여놓고 &quot;읽기 전용이니까 안전하겠지&quot;라고 넘어갔었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 두 줄의 &lt;b&gt;실행 시간&lt;/b&gt;은 전혀 다르다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;측정: 두 줄의 시간 차이&quot; 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-filename=&quot;aa.png&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;344&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZnejv/dJMcaaY87yE/v1KK0PvnQYF7Vs7ZzxmbMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZnejv/dJMcaaY87yE/v1KK0PvnQYF7Vs7ZzxmbMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZnejv/dJMcaaY87yE/v1KK0PvnQYF7Vs7ZzxmbMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZnejv%2FdJMcaaY87yE%2Fv1KK0PvnQYF7Vs7ZzxmbMk%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;3840&quot; height=&quot;344&quot; data-filename=&quot;aa.png&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;344&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;findByLoginId(): 단일 SELECT 쿼리. 인덱스가 걸려 있으니 &lt;b&gt;~1-5ms&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;BCrypt.matches(): 비밀번호 해시를 비교하는 &lt;b&gt;순수 CPU 연산&lt;/b&gt;. 하드웨어에 따라 다르지만, 일반적인 서버 환경에서 strength=10(기본값) 기준 &lt;b&gt;대략 100ms 전후&lt;/b&gt;. 고성능 CPU에서는 ~50ms, 저사양 클라우드 인스턴스에서는 ~300ms까지 걸릴 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB가 일하는 시간은 5ms. BCrypt가 CPU를 점유하는 시간은 150ms. 그런데 @Transactional 때문에 이 155ms &lt;b&gt;전체&lt;/b&gt; 동안 DB 커넥션이 잡혀 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;커넥션 효율: 5ms / 155ms = 3.2%.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;96.8%의 시간 동안 커넥션은 아무것도 하지 않으면서 풀에서 빠져 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;추적: @Transactional이 커넥션을 잡는 정확한 시점&quot; data-ke-size=&quot;size26&quot;&gt;추적: @Transactional이 커넥션을 잡는 정확한 시점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;readOnly면 커넥션을 안 잡는 것 아닌가?&quot;라는 생각이 틀린 이유를 확인하기 위해, Spring 소스 코드의 호출 스택을 따라갔다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-08-024634.png&quot; data-origin-width=&quot;3525&quot; data-origin-height=&quot;2366&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUuGgm/dJMcadBu3NQ/Lbz6P4lKqRekNlKMqSeKUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUuGgm/dJMcadBu3NQ/Lbz6P4lKqRekNlKMqSeKUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUuGgm/dJMcadBu3NQ/Lbz6P4lKqRekNlKMqSeKUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUuGgm%2FdJMcadBu3NQ%2FLbz6P4lKqRekNlKMqSeKUK%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;3525&quot; height=&quot;2366&quot; data-filename=&quot;mermaid-diagram-2026-04-08-024634.png&quot; data-origin-width=&quot;3525&quot; data-origin-height=&quot;2366&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 TransactionManager가 트랜잭션을 시작하는 시점에 있다. 이 프로젝트는 Spring Boot + JPA 구성이므로 실제 TransactionManager는 JpaTransactionManager다 (순수 JDBC 환경의 DataSourceTransactionManager와 다르다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JpaTransactionManager는 Hibernate의 &quot;delayed connection acquisition&quot;을 지원해서, 경우에 따라 첫 번째 SQL 실행 시점까지 물리 커넥션 획득을 지연할 수 있다. &lt;b&gt;하지만 readOnly=true일 때는 다르다.&lt;/b&gt; Connection.setReadOnly(true)를 JDBC 레벨에서 호출해야 하기 때문에, 트랜잭션 시작 시점에 물리 커넥션을 즉시 획득한다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;// JpaTransactionManager &amp;rarr; HibernateJpaDialect.beginTransaction() 내부 흐름
// 1. EntityManager 생성
// 2. prepareConnection=true (기본값) 이고 readOnly=true이면
//    &amp;rarr; 물리 커넥션 즉시 획득
//    &amp;rarr; DataSourceUtils.prepareConnectionForTransaction() 호출
//      &amp;rarr; Connection.setReadOnly(true)  &amp;larr; 이것 때문에 커넥션이 필요
// 3. 커넥션은 트랜잭션 종료 시점까지 유지
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;readOnly 플래그를 설정하려면 커넥션이 &lt;b&gt;이미 획득되어 있어야&lt;/b&gt; 한다. readOnly=true는 커넥션 획득을 생략하는 것이 아니라, 획득한 커넥션에 대한 힌트를 설정하는 것이다. &quot;readOnly이면 가볍다&quot;는 건 flush와 스냅샷에 대한 이야기지, 커넥션에 대한 이야기가 아니었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고: readOnly=false이고 isolation 커스터마이징이 없는 경우에는, JpaTransactionManager가 Hibernate의 delayed connection acquisition으로 커넥션 획득을 첫 SQL 시점까지 지연할 수 있다. 하지만 이번 케이스는 readOnly=true이므로 즉시 획득이 발생한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;계산: 대기열과 만나면 어떻게 되는가&quot; data-ke-size=&quot;size26&quot;&gt;계산: 대기열과 만나면 어떻게 되는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트에는 대기열 시스템이 있다. 스케줄러가 1초마다 30명씩 활성화하고, 활성화된 사용자가 주문 API를 호출한다. 주문 API에는 @AuthUser가 붙어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시나리오&lt;/b&gt;: 스케줄러가 30명에게 토큰을 발급한다. 30명이 거의 동시에 주문 API를 호출한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-08-024701.png&quot; data-origin-width=&quot;2431&quot; data-origin-height=&quot;1756&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GwGbd/dJMcahjIfiY/CyV4j9ASLj5EcVCPS1iOp1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GwGbd/dJMcahjIfiY/CyV4j9ASLj5EcVCPS1iOp1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GwGbd/dJMcahjIfiY/CyV4j9ASLj5EcVCPS1iOp1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGwGbd%2FdJMcahjIfiY%2FCyV4j9ASLj5EcVCPS1iOp1%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;2431&quot; height=&quot;1756&quot; data-filename=&quot;mermaid-diagram-2026-04-08-024701.png&quot; data-origin-width=&quot;2431&quot; data-origin-height=&quot;1756&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;프로젝트의 HikariCP 설정:&lt;/b&gt;&lt;/p&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;b&gt;설정&lt;/b&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;maximum-pool-size&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;minimum-idle&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;connection-timeout&lt;/td&gt;
&lt;td&gt;3000ms (3초)&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;@Transactional이 있을 때:&lt;/b&gt;&lt;/p&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;b&gt;항목&lt;/b&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;동시 인증 요청&lt;/td&gt;
&lt;td&gt;30개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커넥션 점유 시간&lt;/td&gt;
&lt;td&gt;~155ms (findByLoginId 5ms + BCrypt 150ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;점유 커넥션 수&lt;/td&gt;
&lt;td&gt;30개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;남는 커넥션&lt;/td&gt;
&lt;td&gt;40 - 30 = &lt;b&gt;10개&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;주문+결제+조회 처리 여유&lt;/td&gt;
&lt;td&gt;10개로 모든 걸 감당&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;@Transactional을 제거하면:&lt;/b&gt;&lt;/p&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;b&gt;항목&lt;/b&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;동시 인증 요청&lt;/td&gt;
&lt;td&gt;30개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커넥션 점유 시간&lt;/td&gt;
&lt;td&gt;~5ms (findByLoginId만)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;점유 커넥션 수&lt;/td&gt;
&lt;td&gt;거의 0 (5ms면 즉시 반환)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;남는 커넥션&lt;/td&gt;
&lt;td&gt;40개 전부 가용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BCrypt 실행 환경&lt;/td&gt;
&lt;td&gt;커넥션 없이 CPU만 사용&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;Little's Law로 검증하면:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;L = &amp;lambda; &amp;times; W  (평균 점유 = 도착률 &amp;times; 체류시간)

■ 수정 전: L = 30 req/s &amp;times; 0.155s = 4.65개 커넥션 평균 점유
■ 수정 후: L = 30 req/s &amp;times; 0.005s = 0.15개 커넥션 평균 점유

&amp;rarr; 커넥션 평균 점유가 31배 감소
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;탐구: readOnly=true가 실제로 하는 일&quot; data-ke-size=&quot;size26&quot;&gt;탐구: readOnly=true가 실제로 하는 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;readOnly가 커넥션과 무관하다면, 그럼 대체 뭘 하는 건가?&quot; 이 질문에 답하기 위해 readOnly가 동작하는 3개 레이어를 추적했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-08-024809.png&quot; data-origin-width=&quot;2575&quot; data-origin-height=&quot;1108&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dbULcP/dJMcaf7dzN9/7ma6HhHF5ULXY7Gf5JSh70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dbULcP/dJMcaf7dzN9/7ma6HhHF5ULXY7Gf5JSh70/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dbULcP/dJMcaf7dzN9/7ma6HhHF5ULXY7Gf5JSh70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbULcP%2FdJMcaf7dzN9%2F7ma6HhHF5ULXY7Gf5JSh70%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;2575&quot; height=&quot;1108&quot; data-filename=&quot;mermaid-diagram-2026-04-08-024809.png&quot; data-origin-width=&quot;2575&quot; data-origin-height=&quot;1108&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;각 최적화가 authenticateUser()에 효과가 있는지 판단했다.&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;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;최적화&lt;/td&gt;
&lt;td&gt;효과가 큰 상황&lt;/td&gt;
&lt;td&gt;&lt;b&gt;authenticateUser()에서의 효과&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스냅샷 생략&lt;/td&gt;
&lt;td&gt;대량 엔티티 조회 (수백 건 이상)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;무의미&lt;/b&gt; &amp;mdash; 이 프로젝트는 Entity-level DIP 적용. JPA Entity를 POJO로 변환 후 반환하므로 영속성 컨텍스트에 엔티티가 남지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flush 생략&lt;/td&gt;
&lt;td&gt;읽기+쓰기가 섞인 트랜잭션&lt;/td&gt;
&lt;td&gt;&lt;b&gt;무의미&lt;/b&gt; &amp;mdash; 순수 읽기 메서드. flush할 변경이 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JDBC readOnly&lt;/td&gt;
&lt;td&gt;Master-Slave 분리 환경&lt;/td&gt;
&lt;td&gt;&lt;b&gt;무의미&lt;/b&gt; &amp;mdash; 단일 DataSource 구성. Slave 라우팅 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;세 가지 최적화 모두 이 메서드에서는 실질적 이득을 주지 않는다.&lt;/b&gt; @Transactional(readOnly = true)는 커넥션만 잡고, 최적화는 제공하지 않는 상태였다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;탐구: BCrypt는 왜 이렇게 느린가&quot; data-ke-size=&quot;size26&quot;&gt;탐구: BCrypt는 왜 이렇게 느린가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이쯤에서 &quot;BCrypt가 150ms나 걸리는 게 정상인가?&quot;라는 의문이 생긴다. 결론부터 말하면, &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-filename=&quot;mermaid-diagram-2026-04-08-024845.png&quot; data-origin-width=&quot;2178&quot; data-origin-height=&quot;1986&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ngERb/dJMcaduKq5c/iKeOa878VBmGdn9v7iJeP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ngERb/dJMcaduKq5c/iKeOa878VBmGdn9v7iJeP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ngERb/dJMcaduKq5c/iKeOa878VBmGdn9v7iJeP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FngERb%2FdJMcaduKq5c%2FiKeOa878VBmGdn9v7iJeP0%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;2178&quot; height=&quot;1986&quot; data-filename=&quot;mermaid-diagram-2026-04-08-024845.png&quot; data-origin-width=&quot;2178&quot; data-origin-height=&quot;1986&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BCrypt의 시간 복잡도는 O(2^strength). 프로젝트의 BCryptPasswordEncoder는 기본 strength인 10을 사용하므로 &lt;b&gt;1,024 라운드&lt;/b&gt;의 Blowfish 키 스케줄링이 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 느린 이유는 단순하다. &lt;b&gt;brute-force 공격 방어.&lt;/b&gt; 해커가 초당 10억 개의 MD5 해시를 시도할 수 있다면, BCrypt(strength=10)로는 초당 5~10개만 시도할 수 있다. 느린 것이 목적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이 &quot;의도적 느림&quot;이 DB 커넥션과 만나면 문제가 된다. BCrypt는 &lt;b&gt;순수 CPU 연산&lt;/b&gt;이다. 네트워크도 안 타고, 디스크도 안 읽고, DB도 안 건드린다. 그냥 CPU 코어 하나를 150ms 동안 점유할 뿐이다. 그런데 @Transactional 안에 있다는 이유만으로 &lt;b&gt;DB 커넥션까지 같이 점유&lt;/b&gt;하고 있었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;해결: 어노테이션 한 줄 제거&quot; data-ke-size=&quot;size26&quot;&gt;해결: 어노테이션 한 줄 제거&lt;/h2&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// 수정 전
@Transactional(readOnly = true)
public User authenticateUser(String rawLoginId, String rawPassword) { ... }

// 수정 후
public User authenticateUser(String rawLoginId, String rawPassword) { ... }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정은 이게 전부다. 58행의 어노테이션 한 줄을 지웠다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;검증: 왜 이게 안전한가&quot; data-ke-size=&quot;size26&quot;&gt;검증: 왜 이게 안전한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어노테이션을 지우면 트랜잭션이 없어진다. &quot;DB 조회에 트랜잭션이 없어도 되는가?&quot;라는 질문이 자연스럽게 따라온다.&lt;/p&gt;
&lt;h3 data-heading=&quot;검증 1: Spring Data JPA의 암묵적 트랜잭션&quot; data-ke-size=&quot;size23&quot;&gt;검증 1: Spring Data JPA의 암묵적 트랜잭션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;findByLoginId() 호출 체인을 끝까지 따라가면:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;authenticateUser() &amp;mdash; @Transactional 없음
  &amp;rarr; UserRepository.findByLoginId() &amp;mdash; 도메인 포트 (인터페이스)
    &amp;rarr; UserRepositoryImpl.findByLoginId() &amp;mdash; 어댑터 (구현체)
      &amp;rarr; UserJpaRepository.findByLoginId() &amp;mdash; Spring Data JPA 인터페이스
        &amp;rarr; SimpleJpaRepository &amp;mdash; Spring의 기본 구현
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA의 SimpleJpaRepository 소스를 보면:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// Spring Data JPA 소스: SimpleJpaRepository.java
@Repository
@Transactional(readOnly = true)  // &amp;larr; 클래스 레벨에 이미 선언
public class SimpleJpaRepository&amp;lt;T, ID&amp;gt; implements JpaRepositoryImplementation&amp;lt;T, ID&amp;gt; {
    // findById, findAll, findByLoginId 등 모든 조회 메서드에 적용
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출자가 트랜잭션을 열지 않아도, &lt;b&gt;Spring Data JPA가 자체적으로 readOnly 트랜잭션을 열고 닫는다.&lt;/b&gt; findByLoginId() 실행 중에만 커넥션을 잡고, 반환 즉시 놓아준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-08-024914.png&quot; data-origin-width=&quot;1936&quot; data-origin-height=&quot;1658&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1NJw1/dJMcahKLmpV/T9eAZPYjNpCcnxPCXDxS8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1NJw1/dJMcahKLmpV/T9eAZPYjNpCcnxPCXDxS8k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1NJw1/dJMcahKLmpV/T9eAZPYjNpCcnxPCXDxS8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1NJw1%2FdJMcahKLmpV%2FT9eAZPYjNpCcnxPCXDxS8k%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;1936&quot; height=&quot;1658&quot; data-filename=&quot;mermaid-diagram-2026-04-08-024914.png&quot; data-origin-width=&quot;1936&quot; data-origin-height=&quot;1658&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;차이가 명확하다.&lt;/b&gt; 수정 전에는 전체 155ms 동안 커넥션을 잡았지만, 수정 후에는 5ms만 잡고 BCrypt 150ms는 커넥션 없이 실행된다.&lt;/p&gt;
&lt;h3 data-heading=&quot;검증 2: Entity-level DIP &amp;mdash; LazyInitializationException 없음&quot; data-ke-size=&quot;size23&quot;&gt;검증 2: Entity-level DIP&amp;nbsp; LazyInitializationException 없음&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 JPA 프로젝트에서 @Transactional을 지우면 위험한 이유가 있다. 트랜잭션이 끝나면 영속성 컨텍스트가 닫히고, 그 후에 Lazy Loading된 연관 엔티티에 접근하면 LazyInitializationException이 터진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이 프로젝트는 &lt;b&gt;Entity-level DIP&lt;/b&gt;를 적용하고 있다. JPA Entity와 도메인 객체가 분리되어 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Infrastructure: UserEntity (JPA Entity)
        &amp;darr; UserMapper.toDomain()
Domain: User (순수 POJO) &amp;larr; 이것을 반환
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 미세한 동작 변화가 있다. @Transactional을 제거하면 영속 상태가 달라진다.&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;: authenticateUser() 전체가 하나의 트랜잭션. findByLoginId()가 반환한 UserEntity는 &lt;b&gt;managed(영속)&lt;/b&gt; 상태에서 toDomain() 호출.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;수정 후&lt;/b&gt;: findByLoginId() 내부의 Spring Data JPA 자체 트랜잭션이 끝난 후 UserEntity는 &lt;b&gt;detached(준영속)&lt;/b&gt; 상태에서 toDomain() 호출.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 UserEntity에 Lazy Loading 연관(@OneToMany(fetch = LAZY) 등)이 있고, toDomain()에서 그 연관을 접근한다면 LazyInitializationException이 발생할 수 있다. 하지만 이 프로젝트의 UserEntity는 단순 필드만 가지고 있고, toDomain()은 필드 값 복사만 수행한다. Lazy 연관이 없으므로 detached 상태에서도 안전하다. 반환되는 User 객체는 JPA와 아무 관계 없는 순수 POJO다.&lt;/p&gt;
&lt;h3 data-heading=&quot;검증 3: 테스트 통과&quot; data-ke-size=&quot;size23&quot;&gt;검증 3: 테스트 통과&lt;/h3&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;./gradlew :apps:commerce-api:test --tests &quot;*UserService*&quot;

BUILD SUCCESSFUL in 2m 29s
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 테스트 전체 통과. 동작 변경이 없으므로 당연한 결과다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;일반화: @Transactional 범위에 들어오면 안 되는 것들&quot; data-ke-size=&quot;size26&quot;&gt;일반화: @Transactional 범위에 들어오면 안 되는 것들&lt;/h2&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;mermaid-diagram-2026-04-08-024935.png&quot; data-origin-width=&quot;1526&quot; data-origin-height=&quot;3276&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cvzUpL/dJMcah43TM7/AYpmDbvxHKX68mvEYRyxs0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cvzUpL/dJMcah43TM7/AYpmDbvxHKX68mvEYRyxs0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cvzUpL/dJMcah43TM7/AYpmDbvxHKX68mvEYRyxs0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcvzUpL%2FdJMcah43TM7%2FAYpmDbvxHKX68mvEYRyxs0%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;400&quot; height=&quot;859&quot; data-filename=&quot;mermaid-diagram-2026-04-08-024935.png&quot; data-origin-width=&quot;1526&quot; data-origin-height=&quot;3276&quot;/&gt;&lt;/span&gt;&lt;/figure&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%;&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;&lt;b&gt;예시 커넥션&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;낭비 시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU-bound 연산&lt;/td&gt;
&lt;td&gt;BCrypt, 압축, JSON 직렬화&lt;/td&gt;
&lt;td&gt;~100-500ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 API 호출&lt;/td&gt;
&lt;td&gt;PG 결제, 알림 서비스, 파일 업로드&lt;/td&gt;
&lt;td&gt;~200-5000ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동기적 대기&lt;/td&gt;
&lt;td&gt;Thread.sleep, 폴링 루프&lt;/td&gt;
&lt;td&gt;~수초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대용량 메모리 처리&lt;/td&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;이 작업들이 트랜잭션 안에 있으면, 해당 시간 동안 커넥션이 아무것도 하지 않으면서 풀에서 빠져 있다. 커넥션 풀 크기를 아무리 늘려도, 이런 구조에서는 커넥션이 &quot;일하는 시간&quot;보다 &quot;노는 시간&quot;이 길어서 처리량이 올라가지 않는다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;수정 전후 비교&quot; 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-filename=&quot;mermaid-diagram-2026-04-08-025013.png&quot; data-origin-width=&quot;1622&quot; data-origin-height=&quot;2034&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GOGTU/dJMcaco8QMq/OfSOpprrZya3T0cTwLwk0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GOGTU/dJMcaco8QMq/OfSOpprrZya3T0cTwLwk0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GOGTU/dJMcaco8QMq/OfSOpprrZya3T0cTwLwk0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGOGTU%2FdJMcaco8QMq%2FOfSOpprrZya3T0cTwLwk0k%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;1622&quot; height=&quot;2034&quot; data-filename=&quot;mermaid-diagram-2026-04-08-025013.png&quot; data-origin-width=&quot;1622&quot; data-origin-height=&quot;2034&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;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;수정 전&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;수정 후&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&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;커넥션 점유 시간&lt;/td&gt;
&lt;td&gt;~155ms&lt;/td&gt;
&lt;td&gt;~5ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;31배 단축&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커넥션 효율&lt;/td&gt;
&lt;td&gt;3.2%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동시 30명 인증 시 풀 점유&lt;/td&gt;
&lt;td&gt;30/40개 (75%)&lt;/td&gt;
&lt;td&gt;~0/40개&lt;/td&gt;
&lt;td&gt;&lt;b&gt;사실상 0%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다른 API 가용 커넥션&lt;/td&gt;
&lt;td&gt;10개&lt;/td&gt;
&lt;td&gt;40개&lt;/td&gt;
&lt;td&gt;&lt;b&gt;4배 확보&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Little's Law 평균 점유&lt;/td&gt;
&lt;td&gt;4.65개&lt;/td&gt;
&lt;td&gt;0.15개&lt;/td&gt;
&lt;td&gt;&lt;b&gt;31배 감소&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;결론: 습관이 만든 병목&quot; data-ke-size=&quot;size26&quot;&gt;결론: 습관이 만든 병목&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional(readOnly = true)는 &quot;읽기 메서드에는 무조건 붙이는 것&quot;이라고 습관적으로 생각했다. 그 습관 자체가 틀린 건 아니다. 대부분의 읽기 메서드에서는 readOnly가 최적화를 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 습관이 &lt;b&gt;BCrypt 같은 CPU-bound 연산&lt;/b&gt;과 만나면 이야기가 달라진다. readOnly의 최적화 효과(스냅샷 생략, flush 생략)는 이 메서드에서 아무 의미가 없었고, 대신 커넥션을 150ms 동안 불필요하게 잡아두는 부작용만 남았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;readOnly = true는 &quot;커넥션을 가볍게 쓴다&quot;가 아니라 &quot;커넥션을 잡되, flush만 안 한다&quot;일 뿐이다. 이 사실을 인지하는 것만으로, 코드 한 줄을 지우고 커넥션 점유를 31배 줄일 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어노테이션을 붙이기 전에 물어야 할 질문은 &quot;이 메서드가 읽기인가 쓰기인가&quot;가 아니다. &lt;b&gt;&quot;이 메서드가 커넥션을 점유하는 전체 시간 동안, DB가 실제로 일하고 있는가&quot;&lt;/b&gt;가 맞는 질문이다.&lt;/p&gt;</description>
      <category>백엔드 프레임워크/SpringBoot</category>
      <category>CPU</category>
      <category>readonly</category>
      <category>Transactional</category>
      <category>커넥션</category>
      <author>ioh'sDeveloper</author>
      <guid isPermaLink="true">https://develop-tracking.tistory.com/282</guid>
      <comments>https://develop-tracking.tistory.com/entry/Transactional%EC%9D%B4-%EC%82%BC%ED%82%A8-%EC%BB%A4%EB%84%A5%EC%85%98-BCrypt%EA%B0%80-%EB%86%93%EC%95%84%EC%A3%BC%EC%A7%80-%EC%95%8A%EC%9D%80-150ms#entry282comment</comments>
      <pubDate>Wed, 8 Apr 2026 02:51:02 +0900</pubDate>
    </item>
    <item>
      <title>SSE를 실무에 도입하면서 마주친 것들 Polling에서 SSE로, 다시 Polling으로</title>
      <link>https://develop-tracking.tistory.com/entry/SSE%EB%A5%BC-%EC%8B%A4%EB%AC%B4%EC%97%90-%EB%8F%84%EC%9E%85%ED%95%98%EB%A9%B4%EC%84%9C-%EB%A7%88%EC%A3%BC%EC%B9%9C-%EA%B2%83%EB%93%A4-Polling%EC%97%90%EC%84%9C-SSE%EB%A1%9C-%EB%8B%A4%EC%8B%9C-Polling%EC%9C%BC%EB%A1%9C</link>
      <description>&lt;h2 data-heading=&quot;처음에는 Polling이 싫었다&quot; data-ke-size=&quot;size26&quot;&gt;처음에는 Polling이 싫었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;법률 번역 플랫폼을 만들 때의 일이다. 법률 문서를 업로드하면 AI가 문단별로 번역하는 시스템이었다. 원래 구조는 단순했다. 클라이언트가 5초마다 번역 상태를 폴링하고, 서버는 DB에서 현재 진행률을 읽어서 응답한다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 원래 구조: 5초 폴링
@GetMapping(&quot;/translation/{taskId}/status&quot;)
public TranslationStatus getStatus(@PathVariable String taskId) {
    return translationRepository.findStatus(taskId);
}
&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;첫째, 5초 간격이 너무 느렸다. 번역이 문단 단위로 진행되는데, 문단 하나가 1~2초면 끝난다. 그런데 폴링이 5초라 진행률 바가 0% &amp;rarr; 30% &amp;rarr; 60% &amp;rarr; 100%처럼 뚝뚝 끊겨 보였다. 사용자 입장에서 &quot;이거 동작하고 있는 건가?&quot;라는 불안감이 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 번역이 끝난 뒤에도 폴링이 계속됐다. 100% 완료를 감지하기 전까지 불필요한 요청이 계속 서버에 찍혔다. 물론 완료 후 폴링을 멈추는 로직을 넣으면 되지만, 페이지를 안 닫고 놔두는 사용자가 있으면 여전히 빈 폴링이 날아온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 진행률의 해상도가 폴링 주기에 묶였다. 실제로는 20개 문단이 1개씩 완료되는데, 폴링 5초 사이에 3개가 완료되면 클라이언트는 중간 과정을 못 본다. 5% &amp;rarr; 20%로 점프하는 것이다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 SSE(Server-Sent Events)를 도입했다. 서버가 번역 진행률을 실시간으로 클라이언트에 밀어주는 구조다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;SSE의 기본 구조&quot; data-ke-size=&quot;size26&quot;&gt;SSE의 기본 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSE는 HTTP 위에서 동작하는 단방향 스트리밍 프로토콜이다. 서버가 클라이언트에게 이벤트를 보내는 것만 가능하고, 클라이언트가 서버에 메시지를 보내려면 별도의 HTTP 요청을 사용해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로토콜 레벨에서 보면, SSE는 특별한 것이 아니다. 일반 HTTP 응답인데, Content-Type: text/event-stream이고 응답이 끝나지 않는다. 서버가 응답 바디에 데이터를 계속 추가해나가는 것이다.&lt;/p&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: {&quot;progress&quot;: 5, &quot;paragraph&quot;: 1}\n\n
data: {&quot;progress&quot;: 10, &quot;paragraph&quot;: 2}\n\n
data: {&quot;progress&quot;: 15, &quot;paragraph&quot;: 3}\n\n
&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;각 이벤트는 data: 접두사로 시작하고, 빈 줄(\n\n)로 구분된다. 이게 전부다. HTTP 위에서 동작하기 때문에 프록시, 로드밸런서, 방화벽을 대부분 그대로 통과한다. (대부분이라고 한 이유는 뒤에서 설명한다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에서의 구현은 SseEmitter를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@GetMapping(value = &quot;/progress/{taskId}&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamProgress(@PathVariable String taskId) {
    // 5분 타임아웃. 번역이 5분 내에 끝나지 않으면 연결 종료.
    SseEmitter emitter = new SseEmitter(300_000L);

    // 이 emitter를 서비스에 등록 &amp;rarr; 번역 진행 시 이벤트 전송에 사용
    progressService.register(taskId, emitter);

    // emitter 생명주기 관리
    emitter.onCompletion(() -&amp;gt; progressService.unregister(taskId));
    emitter.onTimeout(() -&amp;gt; progressService.unregister(taskId));
    emitter.onError(e -&amp;gt; progressService.unregister(taskId));

    return emitter;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 번역 완료 콜백에서 진행률 전송
public void onParagraphTranslated(String taskId, int paragraph, int total) {
    SseEmitter emitter = emitterStore.get(taskId);
    if (emitter != null) {
        int progress = (int) ((paragraph / (double) total) * 100);
        emitter.send(SseEmitter.event()
            .name(&quot;progress&quot;)
            .data(Map.of(&quot;progress&quot;, progress, &quot;paragraph&quot;, paragraph)));
    }
}
&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;도입 직후에는 만족스러웠다. 진행률 바가 매끄럽게 올라갔다. 문단이 하나 번역될 때마다 즉시 반영됐다. 5초 폴링의 뚝뚝 끊기는 느낌이 사라졌다. 불필요한 폴링도 없어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 운영 환경에 올리고 나서 문제가 시작됐다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;실무에서 마주친 6가지 문제&quot; data-ke-size=&quot;size26&quot;&gt;실무에서 마주친 6가지 문제&lt;/h2&gt;
&lt;h3 data-heading=&quot;문제 1: 비동기 AI 서비스의 비순차 콜백&quot; data-ke-size=&quot;size23&quot;&gt;문제 1: 비동기 AI 서비스의 비순차 콜백&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;법률 문서 번역은 문단별로 AI 서비스에 요청을 보낸다. 20개 문단이 있으면 20개의 비동기 요청이 나간다. 문제는 응답 순서가 보장되지 않는다는 것이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;요청 순서: 문단 1 &amp;rarr; 문단 2 &amp;rarr; 문단 3 &amp;rarr; 문단 4 &amp;rarr; 문단 5
응답 순서: 문단 1 &amp;rarr; 문단 3 &amp;rarr; 문단 2 &amp;rarr; 문단 5 &amp;rarr; 문단 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 서비스 내부에서 각 문단의 번역 난이도가 다르기 때문이다. 짧은 문단은 빨리 끝나고 긴 문단은 오래 걸린다. 그런데 SSE로 이벤트를 보내는 쪽은 콜백이 오는 순서대로 보낸다. 결과적으로 클라이언트의 진행률 바가 이렇게 된다:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;5% &amp;rarr; 15% &amp;rarr; 10% &amp;rarr; 25% &amp;rarr; 20% &amp;rarr; ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진행률이 올라갔다가 내려가는 것이다. 기술적으로는 &quot;문단 3이 문단 2보다 먼저 완료됐다&quot;는 정확한 정보지만, 사용자 입장에서는 버그처럼 보인다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214639.png&quot; data-origin-width=&quot;1554&quot; data-origin-height=&quot;1112&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/llBdG/dJMcaaY6qNO/mUIZ16j3KIlQ7qYPj9lIHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/llBdG/dJMcaaY6qNO/mUIZ16j3KIlQ7qYPj9lIHk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/llBdG/dJMcaaY6qNO/mUIZ16j3KIlQ7qYPj9lIHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FllBdG%2FdJMcaaY6qNO%2FmUIZ16j3KIlQ7qYPj9lIHk%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;1554&quot; height=&quot;1112&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214639.png&quot; data-origin-width=&quot;1554&quot; data-origin-height=&quot;1112&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결: 서버측 최대값 추적&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 서버에서 최대 진행률을 추적하여 후퇴 방지
private final ConcurrentHashMap&amp;lt;String, AtomicInteger&amp;gt; maxProgress = new ConcurrentHashMap&amp;lt;&amp;gt;();

public void onParagraphTranslated(String taskId, int paragraph, int total) {
    int progress = (int) ((paragraph / (double) total) * 100);

    // 최대 진행률보다 클 때만 이벤트 전송
    AtomicInteger max = maxProgress.computeIfAbsent(taskId, k -&amp;gt; new AtomicInteger(0));
    int previousMax = max.getAndUpdate(current -&amp;gt; Math.max(current, progress));

    if (progress &amp;gt; previousMax) {
        SseEmitter emitter = emitterStore.get(taskId);
        if (emitter != null) {
            emitter.send(SseEmitter.event()
                .name(&quot;progress&quot;)
                .data(Map.of(&quot;progress&quot;, progress, &quot;paragraph&quot;, paragraph)));
        }
    }
    // progress &amp;lt;= previousMax 이면 이벤트를 보내지 않음 &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;이 방법은 서버에서 처리한다. 클라이언트측 재정렬 버퍼를 둘 수도 있지만, 서버에서 &quot;최대값보다 작은 진행률은 이벤트 자체를 보내지 않는&quot; 방식이 더 깔끔했다. 클라이언트는 받은 진행률이 항상 단조증가한다고 가정할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대안으로, 완료된 문단 수 자체를 보내는 방법도 있었다. &quot;5번 문단이 완료됐다&quot;가 아니라 &quot;현재까지 완료된 문단 수: 3개&quot;를 보내면 순서 문제가 자연스럽게 해결된다. 하지만 이 경우 &quot;어떤 문단이 완료됐는지&quot;의 정보가 유실된다. 우리 시스템에서는 완료된 문단을 하이라이트 하는 UI가 있었기 때문에, 문단 번호를 보내되 서버에서 최대값을 추적하는 방식을 선택했다.&lt;/p&gt;
&lt;h3 data-heading=&quot;문제 2: 중복 메시지 경로&quot; data-ke-size=&quot;size23&quot;&gt;문제 2: 중복 메시지 경로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번역 시스템의 아키텍처가 문제였다. AI 서비스의 번역 결과를 두 가지 경로로 받고 있었다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;경로 1: AI 서비스 &amp;rarr; HTTP 콜백 &amp;rarr; 서버 &amp;rarr; DB 저장 + SSE 이벤트 전송
경로 2: AI 서비스 &amp;rarr; Kafka &amp;rarr; Consumer &amp;rarr; DB 저장 + SSE 이벤트 전송
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 콜백은 즉시성을 위해, Kafka Consumer는 안정성(콜백 유실 시 보장)을 위해 둘 다 유지하고 있었다. 문제는 두 경로 모두 SSE 이벤트를 발행한다는 것이다. 같은 문단 완료 이벤트가 두 번 전송된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214706.png&quot; data-origin-width=&quot;930&quot; data-origin-height=&quot;860&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HGy9O/dJMcaipjTTk/y2WEGrCNoA4msjS6ikoqaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HGy9O/dJMcaipjTTk/y2WEGrCNoA4msjS6ikoqaK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HGy9O/dJMcaipjTTk/y2WEGrCNoA4msjS6ikoqaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHGy9O%2FdJMcaipjTTk%2Fy2WEGrCNoA4msjS6ikoqaK%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;930&quot; height=&quot;860&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214706.png&quot; data-origin-width=&quot;930&quot; data-origin-height=&quot;860&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결: 이벤트 ID 기반 중복 제거&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 이벤트 ID를 포함해서 SSE 전송
public void sendProgressEvent(String taskId, int paragraph, String eventId) {
    // 이미 보낸 이벤트인지 확인
    Boolean isNew = redisTemplate.opsForValue()
        .setIfAbsent(&quot;sse:sent:&quot; + eventId, &quot;1&quot;, Duration.ofMinutes(5));

    if (Boolean.FALSE.equals(isNew)) {
        return; // 이미 전송된 이벤트 &amp;rarr; 무시
    }

    SseEmitter emitter = emitterStore.get(taskId);
    if (emitter != null) {
        emitter.send(SseEmitter.event()
            .id(eventId) // SSE 표준의 id 필드 &amp;mdash; 재연결 시 Last-Event-ID로 사용됨
            .name(&quot;progress&quot;)
            .data(Map.of(&quot;paragraph&quot;, paragraph)));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 ID는 taskId + &quot;-&quot; + paragraph 조합으로 생성했다. 같은 문단의 완료 이벤트는 같은 ID를 가지므로, 어떤 경로로 먼저 도착하든 한 번만 전송된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSE 표준에는 id 필드가 있다. 이 필드에 이벤트 ID를 넣으면, 클라이언트가 재연결할 때 Last-Event-ID 헤더로 마지막으로 받은 이벤트 ID를 보내온다. 서버는 이 ID 이후의 이벤트만 다시 보내면 된다. 중복 제거와 재연결 복구가 하나의 메커니즘으로 해결된다.&lt;/p&gt;
&lt;h3 data-heading=&quot;문제 3: 트랜잭션 커밋 전 읽기 (Stale Read)&quot; data-ke-size=&quot;size23&quot;&gt;문제 3: 트랜잭션 커밋 전 읽기 (Stale Read)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 발견하기 어려웠다. 간헐적으로 진행률이 갱신되지 않는 현상이 있었는데, 재현이 어려웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 이랬다. 번역 결과 콜백이 들어오면 트랜잭션 안에서 DB에 번역 결과를 저장한다. 그리고 같은 트랜잭션 안에서 SSE 이벤트를 보내려고 DB에서 최신 진행률을 다시 읽는다. 문제는 이 시점에 트랜잭션이 아직 커밋되지 않았다는 것이다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 문제가 된 코드 (단순화)
@Transactional
public void handleTranslationCallback(String taskId, int paragraph, String result) {
    // 1. 번역 결과 저장 (아직 커밋 안 됨)
    translationRepository.saveParagraph(taskId, paragraph, result);

    // 2. 현재 진행률 조회 &amp;mdash; 같은 트랜잭션이므로 자신의 변경은 보인다
    //    하지만 다른 스레드의 콜백이 저장한 건 커밋 전이라 안 보일 수 있다
    int completedCount = translationRepository.countCompleted(taskId);
    int totalCount = translationRepository.countTotal(taskId);

    // 3. SSE 이벤트 전송
    sseService.sendProgress(taskId, completedCount, totalCount);
}
// 4. 메서드 종료 후 트랜잭션 커밋
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문단 2와 문단 3의 콜백이 거의 동시에 들어오면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스레드 A: 문단 2 저장 &amp;rarr; 완료 수 조회 (문단 3은 아직 스레드 B에서 커밋 안 됨) &amp;rarr; 진행률 10%&lt;/li&gt;
&lt;li&gt;스레드 B: 문단 3 저장 &amp;rarr; 완료 수 조회 (문단 2는 아직 스레드 A에서 커밋 안 됨) &amp;rarr; 진행률 10%&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 이벤트 모두 10%를 보낸다. 실제로는 두 문단이 완료되어 15%여야 하는데.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결: DB를 다시 읽지 않고 이벤트 데이터를 직접 전달&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public void handleTranslationCallback(String taskId, int paragraph, String result) {
    translationRepository.saveParagraph(taskId, paragraph, result);
    // DB를 다시 읽지 않는다. 콜백에서 받은 정보만으로 이벤트를 구성한다.
}

// @TransactionalEventListener(AFTER_COMMIT) &amp;mdash; 커밋 후에 이벤트 전송
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onParagraphSaved(ParagraphSavedEvent event) {
    // 커밋이 완료된 후에 진행률을 조회 &amp;rarr; 정확한 값
    int completedCount = translationRepository.countCompleted(event.getTaskId());
    int totalCount = translationRepository.countTotal(event.getTaskId());
    sseService.sendProgress(event.getTaskId(), completedCount, totalCount);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@TransactionalEventListener(AFTER_COMMIT)를 사용하면, 트랜잭션이 커밋된 후에 이벤트 핸들러가 실행된다. 이 시점에서 DB를 읽으면 커밋된 최신 상태를 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이것도 완벽하지는 않다. 두 트랜잭션이 거의 동시에 커밋되면, AFTER_COMMIT 핸들러가 실행되는 시점에 상대방의 커밋이 아직 완료되지 않았을 수 있다. 하지만 확률이 크게 줄고, SSE에서 약간의 진행률 지연은 치명적이지 않았다. 어차피 다음 이벤트에서 보정된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;궁극적 해결은 DB를 아예 읽지 않는 것이다. 완료된 문단 번호만 이벤트로 보내고, 클라이언트가 로컬에서 완료 수를 추적하게 한다. 서버는 &quot;문단 N 완료&quot;라는 사실(fact)만 전달하고, 진행률 계산은 클라이언트의 책임으로 둔다.&lt;/p&gt;
&lt;h3 data-heading=&quot;문제 4: 로드밸런서의 유휴 연결 종료&quot; data-ke-size=&quot;size23&quot;&gt;문제 4: 로드밸런서의 유휴 연결 종료&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 환경에서 가장 당혹스러웠던 문제다. 로컬에서는 완벽하게 동작하는데, 스테이징 환경에 올리면 SSE 연결이 가끔 죽었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 AWS ALB(Application Load Balancer)였다. ALB는 기본적으로 60초 동안 데이터가 오가지 않는 유휴(idle) 연결을 종료한다. SSE 연결에서 60초 동안 보낼 이벤트가 없으면, ALB가 &quot;이 연결은 죽었다&quot;고 판단하고 끊어버린다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214728.png&quot; data-origin-width=&quot;1741&quot; data-origin-height=&quot;1404&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qX4Fg/dJMcaduHNNE/7eOB3klyFlzCjwtJY0rcr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qX4Fg/dJMcaduHNNE/7eOB3klyFlzCjwtJY0rcr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qX4Fg/dJMcaduHNNE/7eOB3klyFlzCjwtJY0rcr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqX4Fg%2FdJMcaduHNNE%2F7eOB3klyFlzCjwtJY0rcr0%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;1741&quot; height=&quot;1404&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214728.png&quot; data-origin-width=&quot;1741&quot; data-origin-height=&quot;1404&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;문제의 심각성은, 클라이언트가 연결 종료를 감지하지 못할 수 있다는 것이다. ALB가 TCP RST를 보내지 않고 조용히 연결을 drop하면, 클라이언트의 EventSource 객체는 연결이 살아있다고 생각한다. 새 이벤트가 와야 &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;&lt;b&gt;해결: 하트비트&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 30초마다 하트비트 전송
@Scheduled(fixedRate = 30_000)
public void sendHeartbeats() {
    emitterStore.forEach((taskId, emitter) -&amp;gt; {
        try {
            // SSE 주석(comment) 형식 &amp;mdash; 클라이언트에서 이벤트로 처리되지 않음
            emitter.send(SseEmitter.event().comment(&quot;heartbeat&quot;));
        } catch (IOException e) {
            // 전송 실패 &amp;rarr; 연결이 이미 끊김 &amp;rarr; 정리
            emitterStore.remove(taskId);
        }
    });
}
&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;SSE 표준에는 주석(comment) 형식이 있다. : 으로 시작하는 줄은 클라이언트에서 무시된다. emitter.send(SseEmitter.event().comment(&quot;heartbeat&quot;))는 : heartbeat\n\n를 보내는데, 이것은 데이터가 아니라 주석이므로 클라이언트의 onmessage 핸들러를 트리거하지 않는다. 하지만 네트워크상으로는 데이터가 오간 것이므로 ALB의 유휴 타이머가 리셋된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;30초 간격을 선택한 이유: ALB 기본 유휴 타임아웃이 60초이므로, 30초마다 하트비트를 보내면 타임아웃에 걸리지 않는다. 15초는 불필요하게 잦고, 45초는 네트워크 지연을 고려하면 위험하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ALB의 유휴 타임아웃을 늘리는 것도 방법이다. AWS 콘솔에서 최대 4000초까지 설정할 수 있다. 하지만 이건 ALB 전체에 적용되므로, SSE가 아닌 일반 HTTP 요청에서 느린 클라이언트가 커넥션을 오래 잡아먹는 부작용이 생길 수 있다. 하트비트가 더 정밀한 해법이다.&lt;/p&gt;
&lt;h3 data-heading=&quot;문제 5: 클라이언트 종료 시 연결 누수&quot; data-ke-size=&quot;size23&quot;&gt;문제 5: 클라이언트 종료 시 연결 누수&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring의 SseEmitter는 생명주기 관리가 수동이다. 클라이언트가 정상적으로 연결을 종료하면 onCompletion 콜백이 호출되지만, 브라우저를 갑자기 닫거나 네트워크가 끊기면 어떻게 될까?&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;SseEmitter emitter = new SseEmitter(300_000L);

// 이 세 가지 콜백을 모두 등록해야 한다
emitter.onCompletion(() -&amp;gt; {
    log.info(&quot;SSE 연결 정상 종료: {}&quot;, taskId);
    cleanup(taskId);
});

emitter.onTimeout(() -&amp;gt; {
    log.warn(&quot;SSE 연결 타임아웃: {}&quot;, taskId);
    cleanup(taskId);
});

emitter.onError(e -&amp;gt; {
    log.error(&quot;SSE 연결 오류: {}&quot;, taskId, e);
    cleanup(taskId);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;onCompletion, onTimeout, onError &amp;mdash; 세 개 다 등록해야 안전하다. 하나라도 빠지면 특정 종료 경로에서 emitter가 정리되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이것만으로는 부족하다. 클라이언트가 TCP 연결을 깨끗하게 닫지 않으면(브라우저 강제 종료, 네트워크 단절), 서버는 연결이 끊긴 것을 즉시 알 수 없다. TCP keepalive 프로브가 실패할 때까지 기다려야 한다. 기본 TCP keepalive 시간은 OS에 따라 다르지만, 대부분 수십 초에서 수 분이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 동안 emitter는 메모리에 남아있고, 하트비트 전송을 시도할 때마다 IOException이 발생한다. IOException이 발생하면 onError가 호출되면서 정리된다. 결과적으로 하트비트가 &quot;연결 상태 감지&quot; 역할도 겸한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 하트비트가 연결 감지 + ALB 유지 두 가지 역할을 한다
emitterStore.forEach((taskId, emitter) -&amp;gt; {
    try {
        emitter.send(SseEmitter.event().comment(&quot;heartbeat&quot;));
        // 전송 성공 &amp;rarr; 연결 살아있음 &amp;rarr; ALB 타이머 리셋
    } catch (IOException e) {
        // 전송 실패 &amp;rarr; 연결 끊김 &amp;rarr; 정리
        emitterStore.remove(taskId);
        // onError 콜백도 트리거됨
    }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 운영에서 측정해보니, 하트비트 없이는 브라우저 종료 후 emitter가 최대 5분(SseEmitter 타임아웃 설정)까지 메모리에 남아 있었다. 하트비트를 30초마다 보내면 최대 30초 후에 감지되어 정리된다.&lt;/p&gt;
&lt;h3 data-heading=&quot;문제 6: 동시 연결 수의 확장성 한계&quot; data-ke-size=&quot;size23&quot;&gt;문제 6: 동시 연결 수의 확장성 한계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;법률 번역 시스템에서 SSE는 잘 동작했다. 동시 번역 세션이 최대 50개 정도였기 때문이다. 50개의 SseEmitter는 JVM 힙에서 무시할 수 있는 수준이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 대기열 시스템을 설계할 때, 같은 접근을 적용하려고 하니 숫자가 달라졌다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;법률 번역 시스템:
- 동시 번역 세션: ~50개
- SseEmitter 수: ~50개
- 메모리: 무시 가능
- 연결 수명: 번역 완료까지 (1~5분)

대기열 시스템:
- 동시 대기 사용자: 최대 10,000명
- SseEmitter 수: 최대 10,000개
- 메모리: 무시할 수 없음
- 연결 수명: 대기 완료까지 (수 분 ~ 수십 분)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SseEmitter 하나가 차지하는 리소스:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;HTTP 연결&lt;/b&gt;: 톰캣의 NIO 커넥터에서 소켓 하나를 점유한다. 기본 maxConnections는 8192다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;응답 버퍼&lt;/b&gt;: 각 emitter는 응답을 쓰기 위한 버퍼를 유지한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;타임아웃 스케줄러&lt;/b&gt;: 각 emitter에 대해 타임아웃 타이머가 등록된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;emitterStore 엔트리&lt;/b&gt;: ConcurrentHashMap의 엔트리.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나하나는 작지만, 10,000개가 되면 다르다. 특히 HTTP 연결 수가 문제다. 톰캣의 maxConnections 8192개 중 10,000개를 SSE가 잡아먹으면, 일반 API 요청을 처리할 연결이 없다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 maxConnections를 늘릴 수 있다. 하지만 연결 수가 늘어나면 OS의 파일 디스크립터 한도, 메모리, context switching 오버헤드 등 다른 병목이 따라온다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;대기열에서 SSE를 쓰면:
- 10,000 대기 사용자 &amp;rarr; 10,000 SSE 연결
- 각 연결에서 30초마다 하트비트 &amp;rarr; 초당 ~333 하트비트 이벤트
- 순번 변경 이벤트: 배치(30개)마다 &amp;rarr; 초당 ~30 이벤트
- 총 이벤트: 초당 ~363개 &amp;rarr; 관리 가능하지만...
- 문제는 이벤트가 아니라 연결 자체다

대기열에서 폴링을 쓰면:
- 10,000 대기 사용자 &amp;times; 동적 폴링 (3~10초)
- 초당 요청: 1,000 ~ 3,333 QPS
- 각 요청은 즉시 응답 후 연결 반환
- 동시 연결 수: 응답 시간이 50ms라면 &amp;rarr; 50~167개
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자가 말해준다. 폴링은 동시 연결 수가 QPS &amp;times; 응답시간으로 결정된다. SSE는 동시 연결 수가 접속 사용자 수로 결정된다. 대기열에서는 접속 사용자 수가 통제 불가능한 변수다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;SSE vs WebSocket vs Polling: 아키텍처 레벨 비교&quot; data-ke-size=&quot;size26&quot;&gt;SSE vs WebSocket vs Polling: 아키텍처 레벨 비교&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 기술을 표면적 기능이 아니라 아키텍처 특성으로 비교한다.&lt;/p&gt;
&lt;h3 data-heading=&quot;연결 모델&quot; data-ke-size=&quot;size23&quot;&gt;연결 모델&lt;/h3&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;b&gt;차원&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Polling&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;SSE&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;WebSocket&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;연결 모델&lt;/td&gt;
&lt;td&gt;요청-응답 (무상태)&lt;/td&gt;
&lt;td&gt;지속 연결 (서버&amp;rarr;클라이언트)&lt;/td&gt;
&lt;td&gt;지속 연결 (양방향)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;프로토콜&lt;/td&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;HTTP (text/event-stream)&lt;/td&gt;
&lt;td&gt;WS (HTTP에서 업그레이드)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버 메모리&lt;/td&gt;
&lt;td&gt;요청 간 없음&lt;/td&gt;
&lt;td&gt;SseEmitter / 연결당&lt;/td&gt;
&lt;td&gt;WebSocketSession / 연결당&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로드밸런서&lt;/td&gt;
&lt;td&gt;표준 HTTP 라우팅&lt;/td&gt;
&lt;td&gt;Sticky Session 또는 Connection Affinity 필요&lt;/td&gt;
&lt;td&gt;WS 인지 프록시 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자동 재연결&lt;/td&gt;
&lt;td&gt;클라이언트 제어&lt;/td&gt;
&lt;td&gt;EventSource API 내장&lt;/td&gt;
&lt;td&gt;직접 구현 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;바이너리 데이터&lt;/td&gt;
&lt;td&gt;지원 (any content-type)&lt;/td&gt;
&lt;td&gt;미지원 (텍스트 전용)&lt;/td&gt;
&lt;td&gt;지원 (binary frame)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;확장 병목&lt;/td&gt;
&lt;td&gt;요청 빈도 (QPS)&lt;/td&gt;
&lt;td&gt;동시 연결 수&lt;/td&gt;
&lt;td&gt;동시 연결 수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring 지원&lt;/td&gt;
&lt;td&gt;표준 @GetMapping&lt;/td&gt;
&lt;td&gt;SseEmitter (수동 생명주기)&lt;/td&gt;
&lt;td&gt;@MessageMapping (STOMP)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-heading=&quot;비용 모델의 차이 &amp;mdash; 핵심&quot; data-ke-size=&quot;size23&quot;&gt;비용 모델의 차이&amp;nbsp; 핵심&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;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Polling의 비용 = f(요청 빈도)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴링 간격을 조절하면 비용을 제어할 수 있다. 대기열 시스템에서 동적 폴링을 구현한 것이 이 특성을 활용한 것이다. 대기 순번이 멀면 10초 간격, 가까우면 3초 간격으로 줄인다. 비용이 서버 운영자의 통제 하에 있다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 동적 폴링 간격 &amp;mdash; 서버가 클라이언트에게 다음 폴링 시간을 지시
public QueueStatusResponse getQueueStatus(String userId) {
    Long rank = getQueueRank(userId);
    int pollInterval;
    if (rank &amp;gt; 500) {
        pollInterval = 10; // 멀리 있으면 10초
    } else if (rank &amp;gt; 100) {
        pollInterval = 5;  // 중간이면 5초
    } else {
        pollInterval = 3;  // 가까우면 3초
    }
    return new QueueStatusResponse(rank, pollInterval);
}
&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;SSE/WebSocket의 비용 = f(동시 연결 수)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시 연결 수는 제어할 수 없다. 대기열에 10,000명이 있으면 10,000개의 연결이 열린다. 대기열 크기를 제한할 수는 있지만, 그건 비즈니스 제약이지 인프라 최적화가 아니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;시나리오: 인기 상품 오픈, 50,000명 동시 대기

Polling:
- 동적 간격 적용 (평균 7초)
- QPS = 50,000 / 7 &amp;asymp; 7,143
- 응답 시간 50ms &amp;rarr; 동시 연결 &amp;asymp; 357개
- 톰캣 기본 설정으로 처리 가능

SSE:
- 동시 연결 = 50,000개
- 톰캣 maxConnections 기본값(8192) 초과
- 일반 API 요청 처리 불가
- NIO를 써도 OS 파일 디스크립터 한도 이슈
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;재연결 동작의 차이&quot; data-ke-size=&quot;size23&quot;&gt;재연결 동작의 차이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSE의 EventSource API는 자동 재연결을 내장하고 있다. 연결이 끊기면 브라우저가 자동으로 재연결을 시도한다. 서버는 retry: 필드로 재연결 간격을 지정할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;retry: 3000\n
data: {&quot;progress&quot;: 50}\n\n
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 연결이 끊기면 3초 후 자동 재연결한다. 재연결 시 Last-Event-ID 헤더로 마지막 이벤트 ID를 보내므로, 서버는 누락된 이벤트만 다시 보낼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebSocket은 자동 재연결이 없다. 연결이 끊기면 클라이언트 코드에서 재연결 로직을 직접 구현해야 한다. exponential backoff, 재연결 횟수 제한, 상태 복구 등을 모두 직접 처리해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Polling은 재연결이라는 개념 자체가 없다. 매 요청이 독립적이므로, 이전 요청이 실패해도 다음 요청은 정상 동작한다. 가장 단순하고 견고하다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214823.png&quot; data-origin-width=&quot;7101&quot; data-origin-height=&quot;488&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/q49RZ/dJMcaadKZlG/vJYEjQlH8j6iuKGrK7PRw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/q49RZ/dJMcaadKZlG/vJYEjQlH8j6iuKGrK7PRw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/q49RZ/dJMcaadKZlG/vJYEjQlH8j6iuKGrK7PRw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fq49RZ%2FdJMcaadKZlG%2FvJYEjQlH8j6iuKGrK7PRw0%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;7101&quot; height=&quot;488&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214823.png&quot; data-origin-width=&quot;7101&quot; data-origin-height=&quot;488&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;시나리오별 기술 선택&quot; data-ke-size=&quot;size26&quot;&gt;시나리오별 기술 선택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무 경험과 위의 분석을 종합한 기술 선택 가이드다.&lt;/p&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;b&gt;시나리오&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;추천&lt;/b&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;번역/변환 진행률 (소규모)&lt;/td&gt;
&lt;td&gt;SSE&lt;/td&gt;
&lt;td&gt;동시 연결 수 제한적, 서버&amp;rarr;클라이언트 단방향, 자동 재연결&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대기열 순번 (대규모)&lt;/td&gt;
&lt;td&gt;Polling (동적 간격)&lt;/td&gt;
&lt;td&gt;동시 사용자 수 통제 불가, 연결 비용이 선형 증가하면 위험&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;채팅&lt;/td&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;td&gt;양방향 통신 필수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;주식 시세&lt;/td&gt;
&lt;td&gt;WebSocket 또는 SSE&lt;/td&gt;
&lt;td&gt;빈도에 따라. 초당 수십 건이면 WebSocket, 수 건이면 SSE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;알림 벨&lt;/td&gt;
&lt;td&gt;SSE&lt;/td&gt;
&lt;td&gt;서버&amp;rarr;클라이언트 단방향, 클라이언트 메시지 불필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;파일 업로드 진행률&lt;/td&gt;
&lt;td&gt;불필요&lt;/td&gt;
&lt;td&gt;XMLHttpRequest/fetch의 progress 이벤트 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대시보드 실시간 갱신&lt;/td&gt;
&lt;td&gt;SSE 또는 Polling&lt;/td&gt;
&lt;td&gt;접속 사용자 수에 따라 결정&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-heading=&quot;판단 기준 체크리스트&quot; data-ke-size=&quot;size23&quot;&gt;판단 기준 체크리스트&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;Q1. 클라이언트에서 서버로 메시지를 보내야 하는가?
  ├─ Yes &amp;rarr; WebSocket
  └─ No &amp;rarr; Q2

Q2. 동시 접속 사용자 수가 예측 가능하고 제한적인가?
  ├─ Yes (수백 이하) &amp;rarr; SSE
  └─ No (수천~수만, 또는 예측 불가) &amp;rarr; Q3

Q3. 실시간성이 얼마나 중요한가?
  ├─ 1초 이하 지연 필요 &amp;rarr; SSE (연결 수 관리 가능한 아키텍처 필요)
  └─ 수 초 지연 허용 &amp;rarr; Polling (동적 간격)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;두 시스템의 아키텍처 비교&quot; data-ke-size=&quot;size26&quot;&gt;두 시스템의 아키텍처 비교&lt;/h2&gt;
&lt;h3 data-heading=&quot;법률 번역 시스템 (SSE)&quot; data-ke-size=&quot;size23&quot;&gt;법률 번역 시스템 (SSE)&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214857.png&quot; data-origin-width=&quot;1864&quot; data-origin-height=&quot;1206&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/U963G/dJMcajaD1hQ/x9KlncD7zx7mm6Qju4AJOk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/U963G/dJMcajaD1hQ/x9KlncD7zx7mm6Qju4AJOk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/U963G/dJMcajaD1hQ/x9KlncD7zx7mm6Qju4AJOk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FU963G%2FdJMcajaD1hQ%2Fx9KlncD7zx7mm6Qju4AJOk%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;400&quot; height=&quot;259&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214857.png&quot; data-origin-width=&quot;1864&quot; data-origin-height=&quot;1206&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동시 연결: ~50개 (활성 번역 세션 수)&lt;/li&gt;
&lt;li&gt;연결 수명: 1~5분 (번역 완료까지)&lt;/li&gt;
&lt;li&gt;이벤트 빈도: 문단당 1개, 문서당 10~30개&lt;/li&gt;
&lt;li&gt;적합 이유: 연결 수가 적고 예측 가능, 실시간 피드백이 UX에 직접 영향&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;대기열 시스템 (Polling)&quot; data-ke-size=&quot;size23&quot;&gt;대기열 시스템 (Polling)&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214915.png&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;1352&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDzr5r/dJMcaakvHCl/09NKuOagU6C6MggOSaoMfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDzr5r/dJMcaakvHCl/09NKuOagU6C6MggOSaoMfk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDzr5r/dJMcaakvHCl/09NKuOagU6C6MggOSaoMfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDzr5r%2FdJMcaakvHCl%2F09NKuOagU6C6MggOSaoMfk%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;400&quot; height=&quot;459&quot; data-filename=&quot;mermaid-diagram-2026-04-03-214915.png&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;1352&quot;/&gt;&lt;/span&gt;&lt;/figure&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;QPS: 사용자 수 / 평균 폴링 간격&lt;/li&gt;
&lt;li&gt;이벤트 빈도: 해당 없음 (클라이언트가 pull)&lt;/li&gt;
&lt;li&gt;적합 이유: 동시 사용자 수가 통제 불가, 연결 비용을 폴링 간격으로 제어 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;SSE 문제 해결 요약표&quot; data-ke-size=&quot;size26&quot;&gt;SSE 문제 해결 요약표&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;법률 번역 시스템에서 겪은 6가지 문제와 해결책을 정리한다.&lt;/p&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;b&gt;문제&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;원인&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;증상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;해결&lt;/b&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;비순차 콜백&lt;/td&gt;
&lt;td&gt;AI 서비스의 비동기 응답 순서 미보장&lt;/td&gt;
&lt;td&gt;진행률이 후퇴&lt;/td&gt;
&lt;td&gt;서버측 최대값 추적 (AtomicInteger)&lt;/td&gt;
&lt;td&gt;메모리: taskId당 int 1개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;중복 메시지&lt;/td&gt;
&lt;td&gt;콜백 + Kafka 두 경로에서 동일 이벤트 발행&lt;/td&gt;
&lt;td&gt;같은 이벤트 2번 수신&lt;/td&gt;
&lt;td&gt;이벤트 ID 기반 중복 제거 (Redis SET NX)&lt;/td&gt;
&lt;td&gt;Redis 키: 이벤트당 1개 (5분 TTL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;트랜잭션 커밋 전 읽기&lt;/td&gt;
&lt;td&gt;SSE 이벤트 시점에 트랜잭션 미커밋&lt;/td&gt;
&lt;td&gt;진행률 갱신 누락&lt;/td&gt;
&lt;td&gt;@TransactionalEventListener(AFTER_COMMIT)&lt;/td&gt;
&lt;td&gt;이벤트 지연: 커밋 후 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALB 유휴 연결 종료&lt;/td&gt;
&lt;td&gt;ALB 60초 idle timeout&lt;/td&gt;
&lt;td&gt;SSE 연결 무통보 종료&lt;/td&gt;
&lt;td&gt;30초 하트비트 (SSE comment)&lt;/td&gt;
&lt;td&gt;네트워크: 30초마다 수십 바이트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트 종료 시 누수&lt;/td&gt;
&lt;td&gt;SseEmitter 수동 생명주기 관리&lt;/td&gt;
&lt;td&gt;메모리 누수&lt;/td&gt;
&lt;td&gt;onCompletion/onTimeout/onError 3중 등록 + 하트비트 감지&lt;/td&gt;
&lt;td&gt;코드 복잡도 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동시 연결 수 확장성&lt;/td&gt;
&lt;td&gt;사용자 수 = 연결 수&lt;/td&gt;
&lt;td&gt;톰캣 maxConnections 초과&lt;/td&gt;
&lt;td&gt;대기열에서는 Polling 선택&lt;/td&gt;
&lt;td&gt;설계 변경&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;운영에서 배운 원칙들&quot; data-ke-size=&quot;size26&quot;&gt;운영에서 배운 원칙들&lt;/h2&gt;
&lt;h3 data-heading=&quot;1. SSE는 &amp;quot;더 나은 Polling&amp;quot;이 아니다 &amp;mdash; 다른 비용 모델이다&quot; data-ke-size=&quot;size23&quot;&gt;1. SSE는 &quot;더 나은 Polling&quot;이 아니다 &amp;mdash; 다른 비용 모델이다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 가장 중요한 교훈이다. SSE를 처음 도입할 때 나는 &quot;Polling의 상위호환&quot;이라고 생각했다. 실시간이니까 당연히 더 좋은 거 아닌가? 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Polling의 비용은 요청 빈도에 비례한다. 빈도는 제어 가능하다.&lt;br /&gt;SSE의 비용은 동시 연결 수에 비례한다. 동시 연결 수는 대부분의 경우 제어 불가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;제어 가능한 비용&quot;과 &quot;제어 불가능한 비용&quot; 사이의 선택이다. 동시 연결 수가 작고 예측 가능하면 SSE가 낫다. 동시 연결 수가 크거나 예측 불가능하면 Polling이 안전하다.&lt;/p&gt;
&lt;h3 data-heading=&quot;2. SseEmitter의 생명주기는 발로 짠 것처럼 느껴진다&quot; data-ke-size=&quot;size23&quot;&gt;2. SseEmitter의 생명주기는 발로 짠 것처럼 느껴진다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring의 SseEmitter는 생성, 등록, 이벤트 전송, 타임아웃, 에러, 완료의 모든 단계를 개발자가 수동으로 관리해야 한다. 자동으로 해주는 것이 거의 없다. onCompletion을 안 걸면 완료 시 정리가 안 되고, onTimeout을 안 걸면 타임아웃 시 정리가 안 되고, onError를 안 걸면 에러 시 정리가 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebFlux의 Flux&amp;lt;ServerSentEvent&amp;gt;를 쓰면 이 문제가 상당히 개선된다. 리액티브 스트림의 구독 취소가 자동으로 정리를 처리하기 때문이다. 하지만 WebFlux를 도입하는 것은 SseEmitter의 불편함을 해결하기 위한 것 치고는 너무 큰 변경이다.&lt;/p&gt;
&lt;h3 data-heading=&quot;3. 로드밸런서는 HTTP가 짧게 끝난다고 가정한다&quot; data-ke-size=&quot;size23&quot;&gt;3. 로드밸런서는 HTTP가 짧게 끝난다고 가정한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP는 &quot;요청 &amp;rarr; 응답 &amp;rarr; 끝&quot;의 프로토콜이다. 대부분의 인프라(로드밸런서, 프록시, CDN, 방화벽)가 이 가정 위에 설계되어 있다. SSE는 이 가정을 깨뜨린다. 응답이 끝나지 않으니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 SSE를 운영 환경에 도입하면, &quot;로드밸런서 유휴 타임아웃&quot;, &quot;프록시 버퍼링&quot;, &quot;CDN 캐싱&quot; 등 평소에 신경 쓸 필요 없던 인프라 설정을 하나하나 확인해야 한다. nginx의 기본 설정으로는 SSE가 동작하지 않는다(proxy_buffering off를 설정해야 한다). CloudFront 같은 CDN은 SSE를 아예 지원하지 않을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 인프라 호환성 이슈는 개발 환경에서는 발견되지 않고 스테이징이나 운영에서만 나타난다. 로컬에서는 로드밸런서가 없으니까.&lt;/p&gt;
&lt;h3 data-heading=&quot;4. 동시 연결 수가 바운디드(bounded)이고 작으면 SSE가 아름답게 동작한다&quot; data-ke-size=&quot;size23&quot;&gt;4. 동시 연결 수가 바운디드(bounded)이고 작으면 SSE가 아름답게 동작한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;법률 번역 시스템이 그랬다. 동시 번역 세션이 50개 이하로 제한되어 있었고, 각 세션이 1~5분이면 끝났다. 이런 환경에서 SSE는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실시간 진행률 업데이트로 UX를 크게 개선했다&lt;/li&gt;
&lt;li&gt;불필요한 폴링 트래픽을 제거했다&lt;/li&gt;
&lt;li&gt;EventSource의 자동 재연결로 안정성도 확보했다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;바운디드이고 작다&quot;는 조건이 핵심이다. 이 조건이 충족되면 SSE의 모든 장점을 누리면서 단점은 최소화할 수 있다.&lt;/p&gt;
&lt;h3 data-heading=&quot;5. 동시 연결이 사용자 수에 따라 선형 증가하면 Polling이 맞다&quot; data-ke-size=&quot;size23&quot;&gt;5. 동시 연결이 사용자 수에 따라 선형 증가하면 Polling이 맞다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기열 시스템이 그렇다. 대기 사용자가 100명일 수도, 50,000명일 수도 있다. 이런 상황에서 동시 연결 수가 사용자 수와 1:1로 묶이는 SSE/WebSocket은 위험하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Polling에 동적 간격을 적용하면, 비용을 서버 운영자가 제어할 수 있다. &quot;QPS가 너무 높으면 간격을 늘린다&quot;는 단순한 전략으로 인프라를 보호할 수 있다. SSE에서는 이런 제어 수단이 없다. 연결은 사용자가 열고, 사용자가 닫는다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;원칙으로 돌아오면&quot; 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;Polling은 요청 빈도를 비용의 축으로 삼는다. SSE와 WebSocket은 동시 연결 수를 비용의 축으로 삼는다. 두 축 중 어떤 것이 제어 가능한지가 기술 선택을 결정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;법률 번역 시스템에서는 동시 연결 수가 제어 가능했다 (바운디드, 소규모). 그래서 SSE가 적합했다.&lt;br /&gt;대기열 시스템에서는 동시 사용자 수가 제어 불가능했다 (언바운디드, 대규모). 그래서 Polling이 적합했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Polling에서 SSE로 갔다가 다시 Polling으로 돌아왔다&quot;는 이야기가 퇴보처럼 들릴 수 있다. 하지만 같은 Polling이 아니다. SSE의 문제를 직접 겪어봤기 때문에, 대기열에서 Polling을 선택한 것은 무지가 아니라 판단이다. &quot;왜 SSE를 안 쓰셨어요?&quot;라는 질문에 30초 안에 명확하게 대답할 수 있다. 그게 기술 선택의 깊이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 경계해야 할 것은 &quot;새로운 기술은 항상 더 낫다&quot;는 가정이다. Polling은 낡은 기술이 아니라, 특정 비용 모델에 최적화된 패턴이다. SSE는 새로운 기술이 아니라, 다른 비용 모델에 최적화된 프로토콜이다. 각자의 자리가 있다.&lt;/p&gt;</description>
      <category>운영</category>
      <category>Polling</category>
      <category>SSE</category>
      <category>websocket</category>
      <author>ioh'sDeveloper</author>
      <guid isPermaLink="true">https://develop-tracking.tistory.com/281</guid>
      <comments>https://develop-tracking.tistory.com/entry/SSE%EB%A5%BC-%EC%8B%A4%EB%AC%B4%EC%97%90-%EB%8F%84%EC%9E%85%ED%95%98%EB%A9%B4%EC%84%9C-%EB%A7%88%EC%A3%BC%EC%B9%9C-%EA%B2%83%EB%93%A4-Polling%EC%97%90%EC%84%9C-SSE%EB%A1%9C-%EB%8B%A4%EC%8B%9C-Polling%EC%9C%BC%EB%A1%9C#entry281comment</comments>
      <pubDate>Fri, 3 Apr 2026 21:50:31 +0900</pubDate>
    </item>
  </channel>
</rss>