<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>snvlqkq.log</title>
    <link>https://snvlqkq.tistory.com/</link>
    <description>snvlqkq 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Mon, 18 May 2026 02:29:17 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>snvlqkq</managingEditor>
    <item>
      <title>Kafka 클러스터와 ISR 동작 원리</title>
      <link>https://snvlqkq.tistory.com/68</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Saga 패턴 토이 프로젝트로 EDA를 구현하던 중, &amp;quot;Kafka가 메시지를 영속화한다&amp;quot;는 말이 무슨 의미인지 본격적으로 파보게 되었습니다. 이 글은 그 과정에서 정리한 학습 노트입니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;이 글에서 다룰 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Kafka가 왜 &amp;quot;메시지 큐&amp;quot;가 아닌 &lt;strong&gt;&amp;quot;분산 로그 저장소&amp;quot;&lt;/strong&gt; 라고 불리는지&lt;/li&gt;
&lt;li&gt;ISR(In-Sync Replicas)이 어떻게 데이터 안전성을 만들어내는지&lt;/li&gt;
&lt;li&gt;실제 장애 상황에서 ISR이 어떻게 동작하는지 (인터랙티브 시뮬레이터 포함)&lt;/li&gt;
&lt;li&gt;운영 시 어떤 설정이 표준이고, 왜 그런지&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;1. Kafka는 분산 로그 저장소이다&lt;/h2&gt;
&lt;p&gt;Kafka는 메시지 큐가 아니라 &lt;strong&gt;분산 로그(distributed commit log)&lt;/strong&gt; 시스템이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;메시지를 받으면 즉시 &lt;strong&gt;디스크의 append-only 로그 파일&lt;/strong&gt;에 기록&lt;/li&gt;
&lt;li&gt;메모리(페이지 캐시)는 성능 향상용 보조, 최종 저장소는 디스크&lt;/li&gt;
&lt;li&gt;&lt;code&gt;acks&lt;/code&gt; 설정과 replication에 따라 데이터 안전성이 달라짐&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;데이터 영속화 과정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    autonumber
    participant P as Producer
    participant C as Page Cache (RAM)
    participant D as Disk

    P-&amp;gt;&amp;gt;C: write() — 페이지 캐시까지만 기록
    C--&amp;gt;&amp;gt;P: ack 반환 (이 시점엔 디스크 도달 X)
    Note over C,D: OS가 한가할 때 백그라운드로
    C-&amp;gt;&amp;gt;D: 비동기 flush&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Kafka는 메시지를 바로 영속화 하는게 아니라 페이지 캐시라는 메모리 저장소에 데이터를 저장한 후 비동기로 영속화(flush&lt;sup class=&quot;footnote&quot;&gt;&lt;a href=&quot;#footnote_68_1&quot; id=&quot;footnote_link_68_1&quot; onmouseover=&quot;tistoryFootnote.show(this, 68, 1)&quot; onmouseout=&quot;tistoryFootnote.hide(68, 1)&quot; style=&quot;color:#f9650d; font-family: Verdana, Sans-serif; display: inline;&quot;&gt;&lt;span style=&quot;display: none;&quot;&gt;[각주:&lt;/span&gt;1&lt;span style=&quot;display: none;&quot;&gt;]&lt;/span&gt;&lt;/a&gt;&lt;/sup&gt;)한다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;시스템 콜&lt;/th&gt;
&lt;th&gt;도달 지점&lt;/th&gt;
&lt;th&gt;비용&lt;/th&gt;
&lt;th&gt;Kafka 사용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;write()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;페이지 캐시 (RAM)&lt;/td&gt;
&lt;td&gt;빠름 (μs)&lt;/td&gt;
&lt;td&gt;✓ 매 메시지마다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fsync()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;디스크 강제 동기화&lt;/td&gt;
&lt;td&gt;느림 (ms)&lt;/td&gt;
&lt;td&gt;✗ 거의 안 함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;성능을 위해 매 메시지마다 &lt;code&gt;fsync&lt;/code&gt; 안 함. OS가 백그라운드로 디스크에 저장.&lt;/p&gt;
&lt;h3&gt;메시지 영속화로 인해 유실 방지가 완전히 보장되는 것은 아니다.&lt;/h3&gt;
&lt;p&gt;Kafka에 전달된 메시지는 바로 영속화를 하는건 아니기 때문에 완전 메시지 유실 방지가 되는 것은 아니다. Kafka가 아닌 서버(OS) 자체가 죽으면 페이지 캐시가 날아가 메시지 유실이 발생할 수 있다. 이를 replica로 방지하는 구조이다. 모든 서버가 죽을 가능성이 낮기 때문&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;장애 유형&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;페이지 캐시&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;디스크&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Kafka &lt;strong&gt;프로세스만&lt;/strong&gt; 재시작&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;유지 (OS가 보유)&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;유지&lt;/td&gt;
&lt;td&gt;유실 없음 ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;컨테이너&lt;/strong&gt; 재시작 (볼륨 마운트)&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;유지 (호스트 OS)&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;유지&lt;/td&gt;
&lt;td&gt;유실 없음 ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OS 자체&lt;/strong&gt; 크래시 (kernel panic)&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;휘발&lt;/strong&gt; ✗&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;유지&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;미플러시분 유실&lt;/strong&gt; ⚠&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;디스크 손상&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;휘발&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;손상 ✗&lt;/td&gt;
&lt;td&gt;유실 ✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;→ 단일 머신의 &lt;strong&gt;fsync (물리적 보장)&lt;/strong&gt; 비용을 회피하고, 분산 시스템의 &lt;strong&gt;복제 (통계적 보장)&lt;/strong&gt; 로 안전 모델을 옮긴 것이 Kafka 설계의 핵심.&lt;/p&gt;
&lt;h2&gt;2. RabbitMQ vs Kafka&lt;/h2&gt;
&lt;h3&gt;핵심 차이&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;RabbitMQ&lt;/th&gt;
&lt;th&gt;Kafka&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&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;&lt;strong&gt;옵션&lt;/strong&gt; 설정 시 가능&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;항상&lt;/strong&gt; (디자인상 강제)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;컨슈머가 ack&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;메시지 삭제&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;offset만 전진, 메시지 유지&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;strong&gt;가능&lt;/strong&gt; (offset 되감기)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다중 소비&lt;/td&gt;
&lt;td&gt;fanout exchange + 큐 N개 필요&lt;/td&gt;
&lt;td&gt;토픽 1개 + 컨슈머 그룹 N개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;저장 구조&lt;/td&gt;
&lt;td&gt;메시지별 random write&lt;/td&gt;
&lt;td&gt;append-only sequential write&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;큐 vs 토픽 모델&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;RabbitMQ — 큐 모델&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;큐 1개 + 컨슈머 N명 = 부하 분산 (각 메시지가 한 명에게만)

큐: [msg1, msg2, msg3, msg4]
   ├─→ Consumer A: msg1, msg3
   └─→ Consumer B: msg2, msg4&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;같은 메시지를 여러 곳에서 받으려면 &lt;strong&gt;fanout exchange&lt;/strong&gt;로 큐를 여러 개 만들어 사본 복제.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kafka — 로그 모델&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;토픽 1개 + 컨슈머 그룹 N개 = 각 그룹이 독립 소비 (사본 X)

Topic: [msg1, msg2, msg3, msg4]
   ├─→ Consumer Group A: offset 1, 2, 3, 4...
   ├─→ Consumer Group B: offset 1, 2, 3, 4...
   └─→ Consumer Group C: offset 1, 2, 3, 4...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;토픽에 메시지는 1개만, 각 그룹이 자기 offset 따로 관리.&lt;/p&gt;
&lt;h3&gt;비유&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RabbitMQ = 우편함&lt;/strong&gt;: 편지 도착 → 받는 사람이 가져감 → 우편함 비움&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kafka = 신문 보관소&lt;/strong&gt;: 발행되면 보관소에 쌓임 → 누구든 와서 읽을 수 있음 → 7일 후 폐기&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;왜 Kafka가 즉시 삭제 안 하는가&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;여러 컨슈머 그룹이 독립 소비&lt;/strong&gt; 가능&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;재처리(replay)&lt;/strong&gt; — 버그 수정, 새 컨슈머 추가, 장애 복구&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;append-only sequential write의 성능 이점&lt;/strong&gt; 유지&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;브로커가 컨슈머 상태를 모름&lt;/strong&gt; → 확장성&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;철학&lt;/strong&gt;: 메시지 = 사실(fact), 사실은 영원하다 (회계 장부 비유)&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;메시지 유지(Retention) 정책&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;log.retention.hours&lt;/code&gt; (기본 168 = 7일): 시간 경과 시 삭제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;log.retention.bytes&lt;/code&gt; (기본 -1 = 무제한): 크기 초과 시 오래된 것부터 삭제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cleanup.policy=compact&lt;/code&gt;: &lt;strong&gt;Log Compaction&lt;/strong&gt; — 같은 key의 최신 메시지만 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. Kafka 클러스터 구조&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;용어&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kafka&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;소프트웨어/시스템 이름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kafka 클러스터&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;브로커 여러 대의 묶음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;브로커 (Broker)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;클러스터 안의 한 노드(서버)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Topic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;논리적 메시지 분류 (cluster-wide)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Partition&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;토픽을 물리적으로 나눈 단위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Replica&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;한 partition의 사본 (한 브로커가 1개 보관)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;1120&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ciaVTS/dJMcabRvKz8/gWv8IQdcRXzYSGBg6oEnP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ciaVTS/dJMcabRvKz8/gWv8IQdcRXzYSGBg6oEnP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ciaVTS/dJMcabRvKz8/gWv8IQdcRXzYSGBg6oEnP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FciaVTS%2FdJMcabRvKz8%2FgWv8IQdcRXzYSGBg6oEnP0%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;1472&quot; height=&quot;1120&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;1120&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;같은 토픽이어도 &lt;strong&gt;partition마다 leader가 다른 브로커&lt;/strong&gt;에 존재&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;leader는 한 브로커, follower는 다른 브로커들&lt;/strong&gt;에 위치 (같은 브로커에 다 있으면 의미 없음)&lt;/li&gt;
&lt;li&gt;한 브로커는 &lt;strong&gt;여러 토픽의 partition replica&lt;/strong&gt;들을 보관&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. ISR (In-Sync Replicas)&lt;/h2&gt;
&lt;h3&gt;정의&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;현재 leader와 충분히 동기화된 replica들의 동적 집합&lt;/strong&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;quot;충분히&amp;quot;의 정확한 의미: &lt;strong&gt;&lt;code&gt;replica.lag.time.max.ms&lt;/code&gt; (기본 30초) 안에 leader의 최신 offset까지 fetch를 완료한 적이 있는가&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Leader 자기 자신은 &lt;strong&gt;항상 ISR에 포함&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;ISR은 시간에 따라 변하는 집합&lt;/li&gt;
&lt;li&gt;Follower가 30초 안에 한 번이라도 따라잡으면 ISR 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;시간 기반인 이유&lt;/h3&gt;
&lt;p&gt;옛날(0.8 이하)은 메시지 개수 기반(&lt;code&gt;replica.lag.max.messages&lt;/code&gt;)이었지만:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;트래픽 급증 시 정상 follower들도 일제히 ISR에서 빠지는 현상 발생&lt;/li&gt;
&lt;li&gt;0.9부터 시간 기반으로 변경 → &amp;quot;트래픽 얼마든 30초 안에 따라잡으면 OK&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Follower가 메시지를 복제하는 방법&lt;/h3&gt;
&lt;p&gt;Kafka는 &lt;strong&gt;pull 모델&lt;/strong&gt;이다 — leader가 push해주는 게 아니라, &lt;strong&gt;follower가 직접 leader에게 가서 메시지를 가져온다&lt;/strong&gt;. 이 가져오기 요청을 &lt;strong&gt;fetch&lt;/strong&gt;라고 부른다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    autonumber
    participant F as Follower B
    participant L as Leader

    F-&amp;gt;&amp;gt;L: fetch 요청&amp;lt;br/&amp;gt;&amp;quot;나 offset 700까지 받았어. 다음 줘&amp;quot;
    Note over L: &amp;quot;B는 700까지 받았구나&amp;quot;&amp;lt;br/&amp;gt;메모리에 기록
    L--&amp;gt;&amp;gt;F: offset 701~ 응답&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Fetch가 곧 Heartbeat&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;leader는 follower가 30초 안에 따라잡았는지(&lt;code&gt;replica.lag.time.max.ms&lt;/code&gt;) 어떻게 알까?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;일반 분산 시스템은 &lt;strong&gt;heartbeat&lt;/strong&gt;(각 노드가 &amp;quot;나 살아있어!&amp;quot;라고 주기적으로 보내는 신호)를 별도 채널로 운영한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;  정상: 두근... 두근... 두근...     (heartbeat 계속 옴)
  죽음: 두근... ...               (heartbeat 끊김 → 죽었다고 판단)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;대표적으로 ZooKeeper, Kubernetes 등이 별도 heartbeat 채널을 운영한다. 그러나 &lt;strong&gt;Kafka는 별도 heartbeat 채널 없이, 위의 fetch 요청 자체를 heartbeat로 활용&lt;/strong&gt;한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;fetch 요청이 동시에 두 역할을 한다:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;① &lt;strong&gt;진행 보고&lt;/strong&gt; — &amp;quot;나 700까지 받았어&amp;quot; → lag 측정&lt;/li&gt;
&lt;li&gt;② &lt;strong&gt;생존 신호 (heartbeat)&lt;/strong&gt; — &amp;quot;fetch 보냈다 = 살아있다&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Leader가 능동적으로 follower를 체크하지 않음. &lt;strong&gt;수동적으로 follower의 fetch를 받으며 메모리에 기록&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;5. ISR 추적은 어떻게?&lt;/h2&gt;
&lt;h3&gt;분산 추적 + 중앙 메타데이터&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1단계 — 추적 (분산):&lt;/strong&gt; 각 partition의 &lt;strong&gt;leader 브로커&lt;/strong&gt;가 자기 follower들의 lastCaughtUpTime을 메모리에 보관&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;Broker 1 (Leader of partition 0):
  → &amp;quot;partition 0의 follower B, C가 따라잡았는지&amp;quot; 추적

Broker 2 (Leader of partition 1):  
  → &amp;quot;partition 1의 follower A, C가 따라잡았는지&amp;quot; 추적&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;→ partition별로 분산되어 한 곳에 부하 안 몰림&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2단계 — 메타데이터 (중앙):&lt;/strong&gt; ISR 변경 시 leader가 &lt;strong&gt;Controller&lt;/strong&gt;에 보고&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;Leader: &amp;quot;Follower C가 30초 동안 못 따라잡음. ISR에서 제외&amp;quot;
   ↓ AlterPartition RPC
Controller: 메타데이터 로그에 기록 → 모든 브로커에 브로드캐스트&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Controller 위치&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모드&lt;/th&gt;
&lt;th&gt;메타데이터 저장 위치&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;ZooKeeper (deprecated)&lt;/td&gt;
&lt;td&gt;외부 ZooKeeper 클러스터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KRaft (현재 표준, 3.3+ GA)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Controller Quorum (3~5 브로커) + &lt;code&gt;__cluster_metadata&lt;/code&gt; 토픽&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;KRaft 모드에선 메타데이터도 결국 Kafka의 로그 메커니즘을 그대로 사용 → ZooKeeper 불필요.&lt;/p&gt;
&lt;h2&gt;6. ISR이 결정하는 두 가지&lt;/h2&gt;
&lt;h3&gt;1) 쓰기 성공 조건&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;acks=all&lt;/code&gt; + &lt;code&gt;min.insync.replicas&lt;/code&gt; 조합:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;producer가 acks=all로 메시지 발행
  ↓
leader가 메시지를 디스크에 씀
  ↓
ISR 안의 모든 follower가 받을 때까지 대기
  ↓
모두 받으면 → ack 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;min.insync.replicas=2&lt;/code&gt;일 때:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ISR&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;{L, F1, F2}&lt;/td&gt;
&lt;td&gt;쓰기 OK ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{L, F1}&lt;/td&gt;
&lt;td&gt;쓰기 OK ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{L}&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;NotEnoughReplicasException&lt;/strong&gt; ❌ (쓰기 거부)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;2) 리더 선출&lt;/h3&gt;
&lt;p&gt;리더 죽으면 Controller가 &lt;strong&gt;ISR 중에서만&lt;/strong&gt; 새 리더 선출. ISR 멤버는 정의상 동기화돼 있어 데이터 손실 없음.&lt;/p&gt;
&lt;p&gt;ISR이 비어버리면(&lt;code&gt;{}&lt;/code&gt;):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;unclean.leader.election.enable=false&lt;/code&gt; (기본, 권장): 새 리더 못 뽑음 → 파티션 사용 불가, &lt;strong&gt;안전성 우선&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unclean.leader.election.enable=true&lt;/code&gt;: 비-ISR replica가 리더 → &lt;strong&gt;데이터 손실 가능&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;7. ISR 장애 시나리오&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;replication.factor=3&lt;/code&gt;, &lt;code&gt;acks=all&lt;/code&gt;, &lt;code&gt;min.insync.replicas=2&lt;/code&gt; 환경에서 시간차로 여러 장애가 발생할 때 ISR이 어떻게 변하는지 시뮬레이션:&lt;/p&gt;
&lt;h3&gt;단계별 시뮬레이션&lt;/h3&gt;
&lt;iframe src=&quot;https://kimjunyoung90.github.io/blog-assets/kafka_isr_failover_stepper.html&quot; width=&quot;100%&quot; height=&quot;640&quot; frameborder=&quot;0&quot;&gt;&lt;/iframe&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;위 시뮬레이터의 **&amp;quot;다음 →&amp;quot;** 버튼으로 5단계 시나리오를 따라가보세요. 각 단계마다 브로커 상태, ISR 멤버십, Producer 쓰기 가능 여부가 동시에 변합니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;핵심 관찰&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ISR은 동적 집합&lt;/strong&gt; — 시간에 따라 멤버가 들고 나감 (GC pause/회복으로도 변동)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;min.insync.replicas가 안전선&lt;/strong&gt; — ISR 크기가 이 값에 미달하면 즉시 쓰기 거부&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;리더 선출은 ISR 안에서만&lt;/strong&gt; — T=90s에 B1 다운 시 B3가 새 리더 (B2는 죽어있어서 선택 불가)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&amp;quot;브로커 1대 죽어도 무중단, 2대 죽으면 안전 모드&amp;quot;&lt;/strong&gt; 정책이 시간축에 그대로 드러남&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;8. 운영 표준 설정&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;# Producer
acks: all
enable.idempotence: true        # 중복 발행 방지
retries: 2147483647             # Integer.MAX_VALUE

# Topic
replication.factor: 3
min.insync.replicas: 2
unclean.leader.election.enable: false&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 조합이 의미하는 바:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;정상&lt;/strong&gt;: ISR=3, 1개 빠져도 ISR=2로 정상 동작&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;위험 신호&lt;/strong&gt;: ISR=1로 떨어지면 즉시 쓰기 거부&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&amp;quot;브로커 1대까지는 죽어도 무중단, 2대 죽으면 안전 모드 전환&amp;quot;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;처음엔 단순히 &amp;quot;Kafka는 메시지를 영속화한다&amp;quot; 정도로 알고 썼지만, 파고 들어보니 그 한 줄 뒤에 꽤 정교한 설계가 있었습니다. 이 글의 핵심을 한 문장으로 압축한다면&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;&amp;quot;Kafka는 분산 로그 저장소이고, 디스크 fsync(물리적 보장) 비용을 회피하는 대신 ISR 기반 복제(통계적 보장)로 안전성을 만들어내는 시스템&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이 한 줄이 자연스럽게 읽히면, 위의 운영 표준 설정이 왜 그렇게 생겼는지, 장애 상황에서 어떻게 동작하는지 보입니다.&lt;/p&gt;
&lt;p&gt;운영 표준 설정 한 줄 (&lt;code&gt;acks=all&lt;/code&gt;, &lt;code&gt;min.insync.replicas=2&lt;/code&gt;, &lt;code&gt;replication.factor=3&lt;/code&gt;) 을 그냥 외우는 것과, 그 설정이 어떤 시나리오에서 어떤 안전성을 만들어내는지 이해하고 쓰는 것은 큰 차이가 있다고 있습니다.&lt;/p&gt;
&lt;div class=&quot;footnotes&quot;&gt;
  &lt;ol class=&quot;footnotes&quot;&gt;
    &lt;li id=&quot;footnote_68_1&quot;&gt;메모리(페이지 캐시)에 모아둔 데이터를 한꺼번에 디스크로 쏟아 내보내는 행위 &lt;a href=&quot;#footnote_link_68_1&quot;&gt;[본문으로]&lt;/a&gt;&lt;/li&gt;
  &lt;/ol&gt;
&lt;/div&gt;</description>
      <category>Kafka</category>
      <author>snvlqkq</author>
      <guid isPermaLink="true">https://snvlqkq.tistory.com/68</guid>
      <comments>https://snvlqkq.tistory.com/68#entry68comment</comments>
      <pubDate>Sun, 26 Apr 2026 16:42:13 +0900</pubDate>
    </item>
    <item>
      <title>@Transactional의 readOnly 속성의 의미</title>
      <link>https://snvlqkq.tistory.com/67</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional 어노테이션은 readOnly 속성을 갖고 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본값 : false&lt;/li&gt;
&lt;li&gt;true 로 설정하면 &amp;rarr; &lt;b&gt;&quot;이 트랜잭션은 읽기만 하고 쓰지 않는다&quot;는 의미&lt;/b&gt; (= 읽기 전용)&lt;/li&gt;
&lt;/ul&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-ke-size=&quot;size26&quot;&gt;1. JPA / Hibernate 레벨&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA를 사용할 때 효과가 가장 크다. 핵심은 &lt;b&gt;Dirty Checking 생략&lt;/b&gt;이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;평소 동작 (&lt;code&gt;readOnly = false&lt;/code&gt;)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA는 엔티티를 조회할 때 &quot;스냅샷&quot;을 함께 저장해둔다. 트랜잭션이 끝나는 시점에 현재 엔티티와 스냅샷을 비교해, 변경된 필드가 있다면 &lt;code&gt;UPDATE&lt;/code&gt; 쿼리를 실행한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;readOnly = true&lt;/code&gt;일 때&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스냅샷 자체를 만들지 않는다 &amp;rarr; &lt;b&gt;메모리 절약&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;변경 감지(Dirty Checking) 로직을 건너뛴다 &amp;rarr; &lt;b&gt;CPU 절약&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Flush&lt;sup class=&quot;footnote&quot;&gt;&lt;a href=&quot;#footnote_67_1&quot; id=&quot;footnote_link_67_1&quot; onmouseover=&quot;tistoryFootnote.show(this, 67, 1)&quot; onmouseout=&quot;tistoryFootnote.hide(67, 1)&quot; style=&quot;color:#f9650d; font-family: Verdana, Sans-serif; display: inline;&quot;&gt;&lt;span style=&quot;display: none;&quot;&gt;[각주:&lt;/span&gt;1&lt;span style=&quot;display: none;&quot;&gt;]&lt;/span&gt;&lt;/a&gt;&lt;/sup&gt;를 수행하지 않는다&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  대량 조회 시 차이가 크다. 예를 들어 1만 건을 조회하면, 기본 설정에서는 &lt;b&gt;1만 개의 스냅샷&lt;/b&gt;이 메모리에 쌓인다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. DB 드라이버 / 커넥션 레벨&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JDBC 레벨에서 &lt;code&gt;Connection.setReadOnly(true)&lt;/code&gt;가 호출된다. 이는 DB에게 &quot;이 커넥션은 읽기 전용&quot;이라는 &lt;b&gt;힌트&lt;/b&gt;를 전달하는 역할이며, DB는 이 힌트를 바탕으로 내부 최적화를 수행할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예시: MySQL InnoDB&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InnoDB는 &lt;code&gt;readOnly&lt;/code&gt; 힌트를 받으면 &lt;b&gt;트랜잭션 ID(TRX ID)를 할당하지 않는다.&lt;/b&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;일반 트랜잭션&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;읽기 전용 트랜잭션&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TRX ID 발급&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;b&gt;X&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;언두 로그&lt;sup class=&quot;footnote&quot;&gt;&lt;a href=&quot;#footnote_67_2&quot; id=&quot;footnote_link_67_2&quot; onmouseover=&quot;tistoryFootnote.show(this, 67, 2)&quot; onmouseout=&quot;tistoryFootnote.hide(67, 2)&quot; style=&quot;color:#f9650d; font-family: Verdana, Sans-serif; display: inline;&quot;&gt;&lt;span style=&quot;display: none;&quot;&gt;[각주:&lt;/span&gt;2&lt;span style=&quot;display: none;&quot;&gt;]&lt;/span&gt;&lt;/a&gt;&lt;/sup&gt; 기록&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;b&gt;X&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MVCC 관리 대상&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;b&gt;X&lt;/b&gt;&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Read Replica 라우팅&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Master-Slave 구조에서, 읽기 전용 트랜잭션을 &lt;b&gt;Slave(Replica)로 라우팅&lt;/b&gt;하는 전략을 구현할 수 있다. Master의 부하를 줄이고 읽기 처리량을 분산시킬 수 있다.&lt;/p&gt;
&lt;div class=&quot;footnotes&quot;&gt;
  &lt;ol class=&quot;footnotes&quot;&gt;
    &lt;li id=&quot;footnote_67_1&quot;&gt;변경 사항을 실제 DB로 내보내는 작업. &lt;a href=&quot;#footnote_link_67_1&quot;&gt;[본문으로]&lt;/a&gt;&lt;/li&gt;
    &lt;li id=&quot;footnote_67_2&quot;&gt;트랜잭션이 데이터를 변경하기 이전 상태를 저장해두는 로그. 롤백과 MVCC(다른 트랜잭션이 커밋 전 데이터를 락 없이 이전 버전으로 읽는 기능)에 사용된다. &lt;a href=&quot;#footnote_link_67_2&quot;&gt;[본문으로]&lt;/a&gt;&lt;/li&gt;
  &lt;/ol&gt;
&lt;/div&gt;</description>
      <category>라이브러리&amp;amp;프레임워크/Spring</category>
      <author>snvlqkq</author>
      <guid isPermaLink="true">https://snvlqkq.tistory.com/67</guid>
      <comments>https://snvlqkq.tistory.com/67#entry67comment</comments>
      <pubDate>Tue, 21 Apr 2026 16:23:28 +0900</pubDate>
    </item>
    <item>
      <title>Java Thread 상태 종류</title>
      <link>https://snvlqkq.tistory.com/66</link>
      <description>&lt;p&gt;Java Thread는 &lt;code&gt;Thread.State&lt;/code&gt; enum으로 정의된 6가지 상태를 가진다.&lt;/p&gt;
&lt;h2&gt;1. NEW&lt;/h2&gt;
&lt;p&gt;Thread 객체가 생성만 된 상태. 아직 스레드가 실행 중이 아님.&lt;br&gt;명시적으로 &lt;code&gt;start()&lt;/code&gt;를 호출해야 스레드가 실행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;Thread t = new Thread(() -&amp;gt; {}); // NEW&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;스레드 실행&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;Thread t = new Thread(() -&amp;gt; {});
t.start();&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. RUNNABLE&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;start()&lt;/code&gt;가 호출되어 실행 가능한 상태. JVM 레벨에서는 실행 중이지만, OS 레벨에서는 CPU를 할당받아 실제 실행 중이거나 실행 대기 중일 수 있다. Java는 이 둘을 구분하지 않고 RUNNABLE로 통합해서 표현한다.&lt;/p&gt;
&lt;h3&gt;&amp;quot;실행 가능&amp;quot;이라는 표현을 쓰는 이유&lt;/h3&gt;
&lt;p&gt;OS 레벨에서는 Thread 상태가 더 세분화되어 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Running&lt;/strong&gt;: 현재 CPU 코어에서 실제로 명령어를 실행 중&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ready (Runnable)&lt;/strong&gt;: 실행할 준비는 끝났지만 CPU를 할당받지 못해 대기 큐에 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CPU 코어 수는 제한적인데 Thread는 수백, 수천 개일 수 있어 OS는 타임 슬라이스&lt;sup class=&quot;footnote&quot;&gt;&lt;a href=&quot;#footnote_66_1&quot; id=&quot;footnote_link_66_1&quot; onmouseover=&quot;tistoryFootnote.show(this, 66, 1)&quot; onmouseout=&quot;tistoryFootnote.hide(66, 1)&quot; style=&quot;color:#f9650d; font-family: Verdana, Sans-serif; display: inline;&quot;&gt;&lt;span style=&quot;display: none;&quot;&gt;[각주:&lt;/span&gt;1&lt;span style=&quot;display: none;&quot;&gt;]&lt;/span&gt;&lt;/a&gt;&lt;/sup&gt; 단위로 Thread들을 번갈아 CPU에 올렸다 내렸다 한다. 이 과정에서 Thread는 Running ↔ Ready 상태를 끊임없이 오간다.&lt;/p&gt;
&lt;p&gt;JVM은 이 두 상태를 모두 RUNNABLE 하나로 통합한다.&lt;/p&gt;
&lt;h2&gt;3. BLOCKED&lt;/h2&gt;
&lt;p&gt;락을 획득하기 위해 대기하는 상태. &lt;code&gt;synchronized&lt;/code&gt; 블록/메서드에 진입하려는데 다른 Thread가 이미 락을 잡고 있을 때의 상태이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;synchronized(lock) { ... } // 락 대기 시 BLOCKED&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 &amp;quot;락&amp;quot;은 정확히는 &lt;strong&gt;모니터(Monitor) 락&lt;/strong&gt;을 가리킨다. 그래서 BLOCKED가 뭔지 제대로 이해하려면 모니터 구조부터 짚고 가야 한다.&lt;/p&gt;
&lt;h3&gt;모니터(Monitor)란&lt;/h3&gt;
&lt;p&gt;모니터는 &lt;strong&gt;&amp;quot;한 번에 하나의 Thread만 접근할 수 있도록 보장하는 통제하는 장치이다.&amp;quot;&lt;/strong&gt; &lt;strong&gt;락(Lock) + 조건 변수(Condition Variable)&lt;/strong&gt; 를 하나로 묶어 객체에 내장한 형태로, Java는 이 모니터를 모든 객체에 내장하는 방식으로 구현했다. 이걸 &lt;strong&gt;intrinsic lock&lt;/strong&gt; 또는 &lt;strong&gt;monitor lock&lt;/strong&gt;이라고 부른다.&lt;/p&gt;
&lt;h3&gt;모니터 락의 구조&lt;/h3&gt;
&lt;p&gt;JVM 레벨에서 모니터는 세 부분으로 구성된다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Owner&lt;/strong&gt;: 현재 락을 소유한 Thread&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Entry Set (진입 큐)&lt;/strong&gt;: 락을 획득하려고 대기 중인 Thread들 → &lt;strong&gt;BLOCKED 상태&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wait Set (대기 큐)&lt;/strong&gt;: &lt;code&gt;wait()&lt;/code&gt;를 호출해서 조건을 기다리는 Thread들 → &lt;strong&gt;WAITING 상태&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;1062&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cfdx6g/dJMcacCQxik/p0OjckfZ5l0Cte09GRsYC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cfdx6g/dJMcacCQxik/p0OjckfZ5l0Cte09GRsYC0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cfdx6g/dJMcacCQxik/p0OjckfZ5l0Cte09GRsYC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcfdx6g%2FdJMcacCQxik%2Fp0OjckfZ5l0Cte09GRsYC0%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;1472&quot; height=&quot;1062&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;1062&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;즉 &lt;strong&gt;BLOCKED는 Entry Set에 들어가 있는 상태&lt;/strong&gt;고, Wait Set에 들어간 Thread는 WAITING으로 따로 구분된다.&lt;/p&gt;
&lt;h3&gt;ReentrantLock은 BLOCKED가 아니다&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt;은 &lt;strong&gt;모니터 락이 아니다&lt;/strong&gt;. 별도의 Lock 구현체이고, 내부적으로 &lt;code&gt;AQS(AbstractQueuedSynchronizer)&lt;/code&gt; + &lt;code&gt;LockSupport.park()&lt;/code&gt;를 쓴다. 그래서 &lt;code&gt;ReentrantLock.lock()&lt;/code&gt;으로 락을 기다리는 Thread는 BLOCKED가 아니라 &lt;strong&gt;WAITING 상태&lt;/strong&gt;로 표시된다.&lt;/p&gt;
&lt;p&gt;Thread dump 분석할 때 이 차이를 놓치면 락 경합의 원인을 엉뚱한 데서 찾게 된다.&lt;/p&gt;
&lt;h2&gt;4. WAITING&lt;/h2&gt;
&lt;p&gt;다른 Thread의 특정 작업이 완료될 때까지 무기한 대기하는 상태. 명시적으로 깨워야 RUNNABLE로 돌아온다.&lt;/p&gt;
&lt;h2&gt;5. TIMED_WAITING&lt;/h2&gt;
&lt;p&gt;지정된 시간 동안만 대기하는 상태. 시간이 지나면 자동으로 RUNNABLE로 전환된다.&lt;/p&gt;
&lt;h2&gt;6. TERMINATED&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;run()&lt;/code&gt; 메서드 실행이 완료되었거나 예외로 종료된 상태. 한 번 TERMINATED가 되면 다시 시작할 수 없다.&lt;/p&gt;
&lt;h2&gt;실무에서 자주 헷갈리는 포인트&lt;/h2&gt;
&lt;h3&gt;BLOCKED vs WAITING&lt;/h3&gt;
&lt;p&gt;BLOCKED는 synchronized 락 경합에서만 발생하고, &lt;code&gt;ReentrantLock.lock()&lt;/code&gt;에서 락을 기다릴 때는 내부적으로 &lt;code&gt;LockSupport.park()&lt;/code&gt;를 쓰기 때문에 WAITING 상태가 된다. Thread dump를 분석할 때 이 차이를 모르면 락 경합을 놓치기 쉽다.&lt;/p&gt;
&lt;h3&gt;RUNNABLE인데 실제로는 I/O 대기&lt;/h3&gt;
&lt;p&gt;소켓 read, 파일 I/O 같은 블로킹 I/O를 수행 중인 Thread는 JVM에서 RUNNABLE로 표시된다. Thread dump에서 RUNNABLE이라고 해서 반드시 CPU를 쓰고 있는 건 아니라는 점이 TPS 병목 분석할 때 중요하다.&lt;/p&gt;
&lt;h3&gt;상태 전이는 단방향이 아님&lt;/h3&gt;
&lt;p&gt;RUNNABLE ↔ BLOCKED/WAITING/TIMED_WAITING 사이를 여러 번 오갈 수 있지만, NEW → RUNNABLE과 * → TERMINATED는 단방향이다.&lt;/p&gt;
&lt;div class=&quot;footnotes&quot;&gt;
  &lt;ol class=&quot;footnotes&quot;&gt;
    &lt;li id=&quot;footnote_66_1&quot;&gt;OS 스케줄러가 하나의 Thread에게 &lt;strong&gt;CPU를 점유하도록 허용하는 시간의 단위&lt;/strong&gt; &lt;a href=&quot;#footnote_link_66_1&quot;&gt;[본문으로]&lt;/a&gt;&lt;/li&gt;
  &lt;/ol&gt;
&lt;/div&gt;</description>
      <category>언어/Java</category>
      <author>snvlqkq</author>
      <guid isPermaLink="true">https://snvlqkq.tistory.com/66</guid>
      <comments>https://snvlqkq.tistory.com/66#entry66comment</comments>
      <pubDate>Tue, 21 Apr 2026 11:13:33 +0900</pubDate>
    </item>
    <item>
      <title>루프팩 백엔드 3기 후기 &amp;mdash; &amp;quot;정답이 있다&amp;quot;는 착각이 깨진 10주</title>
      <link>https://snvlqkq.tistory.com/65</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;&amp;quot;개발에 완벽한 정답은 없다. 개발은 트레이드오프와 선택의 연속이다.&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;수강 전의 나는 개발에 &lt;strong&gt;&amp;quot;정답이 있는데 내가 아직 그걸 모를 뿐이다&amp;quot;&lt;/strong&gt;라고 생각했다.&lt;/p&gt;
&lt;p&gt;10주가 지나서야 알았다. 모든 것은 트레이드오프의 연속이라는 것을.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 수강 전 — 어딘가에 &amp;#39;정답&amp;#39;이 있다고 믿었다&lt;/h2&gt;
&lt;p&gt;원래도 코드를 짤 때 고민은 많이 하는 편이었다. 기능을 받으면 &amp;quot;다른 방식은 없나?&amp;quot;, &amp;quot;이게 실패하면 어떻게 되지?&amp;quot;를 먼저 생각했다. 아마 모든 개발자가 여유가 된다면 고민을 많이 할 것이다.&lt;/p&gt;
&lt;p&gt;근데 지금 돌아보면 고민의 &lt;strong&gt;방식&lt;/strong&gt;이 이상했다.&lt;/p&gt;
&lt;p&gt;문제를 만나면 가장 먼저 이런 생각을 했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;quot;이 상황에서 맞는 답이 뭐지?&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;이것도 문제가 있고 저것도 문제가 있네.. 어떻게 해야하지?&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;내가 뭘 놓치고 있는 거지?&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;어딘가에 정답이 있을 거라고 믿었다.&lt;/strong&gt; 내가 지금 확신이 없는 건, 내가 아직 그 정답을 모르기 때문이라고 생각했다. 블로그를 뒤지고, 책을 읽고, 컨퍼런스 발표를 보면서 &amp;quot;경력이 쌓이면 언젠가 이걸 다 알게 되겠지&amp;quot;라고 막연히 믿었다.&lt;/p&gt;
&lt;p&gt;그래서 기술 선택을 할 때마다 찜찜했다. &lt;strong&gt;&amp;quot;내가 모르는 더 좋은 방법이 있는 건 아닐까?&amp;quot;&lt;/strong&gt; 하는 불안이 항상 깔려있었다. 확신이 없으니 설득할 자신도 없었다. &amp;quot;책에서 이렇게 하라고 해서요&amp;quot;가 내 근거의 끝이었다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. 수강 후 — 정답이 없다는 걸 빅테크 멘토들에게 배웠다&lt;/h2&gt;
&lt;p&gt;멘토링을 받으면서 가장 충격적이었던 순간들이 있다. 내가 &amp;quot;정답이 뭐죠?&amp;quot;라는 뉘앙스로 질문하면, 멘토들이 &lt;strong&gt;한번도 &amp;quot;정답은 ..&amp;quot;라고 답하지 않았다.&lt;/strong&gt; 대신 이렇게 말했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;quot;정답은 없습니다.&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;이 상황에서는 맞을 수 있는데, 이런 상황이면 어때요?&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;그 선택의 비용은 뭐라고 생각해요?&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;처음엔 당황스러웠다. &lt;strong&gt;명확한 답을 주지 않는 게&lt;/strong&gt; 답답하기도 했다. 근데 주차가 쌓일수록, 그게 답답한 게 아니라 &lt;strong&gt;그게 진실&lt;/strong&gt;이라는 걸 받아들이게 됐다.&lt;/p&gt;
&lt;p&gt;빅테크에서 몇 년을 굴러본 사람들이 도달한 결론은 &amp;quot;정답의 목록&amp;quot;이 아니라 &lt;strong&gt;&amp;quot;정답이 없다는 것을 받아들이는 태도&amp;quot;&lt;/strong&gt;였다.&lt;/p&gt;
&lt;p&gt;그 태도가 실무에서 어떻게 드러나는지 10주 동안 배웠다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;낙관적 락과 비관적 락 중 뭐가 맞나? → &lt;strong&gt;상황에 따라 다르다.&lt;/strong&gt; 경합 빈도, 데이터 성격, 실패 비용을 따져봐야 한다.&lt;/li&gt;
&lt;li&gt;Cache-Aside가 정답인가 Write-Through가 정답인가? → &lt;strong&gt;트레이드오프다.&lt;/strong&gt; 쓰기 부담, 정합성 요구, 캐시 효율을 저울질해야 한다.&lt;/li&gt;
&lt;li&gt;서킷 브레이커 &lt;code&gt;failureRateThreshold&lt;/code&gt;는 얼마로? → &lt;strong&gt;완벽한 값은 없다.&lt;/strong&gt; 운영 환경에 적용하고, 부하 테스트를 돌리고, 지표를 보면서 계속 조정해나간다.&lt;/li&gt;
&lt;li&gt;비정규화 vs 실시간 집계? → &lt;strong&gt;서비스 성격에 달렸다.&lt;/strong&gt; 이커머스와 SNS는 답이 다르다.&lt;/li&gt;
&lt;li&gt;배치 실패 시 어떻게? → &lt;strong&gt;멱등성을 설계해야 한다.&lt;/strong&gt; DELETE+INSERT와 INSERT only의 비용이 다르다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 모든 질문에 공통된 답은 &lt;strong&gt;&amp;quot;그때그때 다르다&amp;quot;&lt;/strong&gt;였다. 그리고 그게 무책임한 답이 아니라, &lt;strong&gt;&amp;quot;여러 요소를 저울질해서 근거 있는 선택을 한다&amp;quot;&lt;/strong&gt;는 실무의 본질이라는 걸 배웠다.&lt;/p&gt;
&lt;h3&gt;달라진 건 질문의 방식이다&lt;/h3&gt;
&lt;p&gt;예전엔 문제를 만나면 &lt;strong&gt;&amp;quot;답이 뭐지?&amp;quot;&lt;/strong&gt;를 물었다. 이제는 이렇게 묻는다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;이 선택지들의 &lt;strong&gt;비용은 각각 뭔가?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;이 선택이 깨지는 &lt;strong&gt;엣지 케이스는 뭔가?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;내가 지금 &lt;strong&gt;어떤 가정 위에서&lt;/strong&gt; 이걸 선택하고 있나?&lt;/li&gt;
&lt;li&gt;이 가정이 틀어지면 어떤 대가를 치러야 하나?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;질문 자체가 바뀌니까, 고민의 끝에 남는 것도 바뀐다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;예전엔 &amp;quot;아마 이게 맞을 것이다&amp;quot;로 끝났다. 이제는 &amp;quot;이 트레이드오프를 감수하고 이걸 선택했다&amp;quot;로 끝난다. 전자는 불안을 남기고, 후자는 &lt;strong&gt;설명할 수 있는 선택을 남긴다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. 가장 인상 깊었던 프로젝트 — 5주차 &amp;quot;캐싱 전략&amp;quot;&lt;/h2&gt;
&lt;p&gt;10주차 전부 좋았지만 굳이 하나만 꼽는다면 &lt;strong&gt;5주차 캐싱 프로젝트&lt;/strong&gt;다.&lt;/p&gt;
&lt;h3&gt;&amp;quot;캐시 = 조회 빨라지는 것&amp;quot; 이게 내가 알던 전부였다&lt;/h3&gt;
&lt;p&gt;실무에서 캐싱을 직접 다뤄본 적은 없었다. 대신 혼자 관심이 생겨서 책과 블로그로 공부한 적은 있었다. 그때 내가 정리한 캐싱은 이랬다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;&amp;quot;자주 조회되는 데이터를 메모리에 올려두면 조회 속도가 빨라진다.&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;딱 이 한 줄이 전부였다. Redis를 붙이고, &lt;code&gt;@Cacheable&lt;/code&gt;을 달고, TTL을 잡으면 끝. 캐싱이 &amp;quot;도구&amp;quot;라고 생각했다. 붙이면 빨라지고, 그게 전부인.&lt;/p&gt;
&lt;h3&gt;5주차에서 마주친 &amp;quot;처음 보는 것들&amp;quot;&lt;/h3&gt;
&lt;p&gt;그런데 5주차 과제를 받고 캐싱을 제대로 다뤄보니, &lt;strong&gt;내가 공부했던 건 전체 그림의 10%도 안 됐다.&lt;/strong&gt; 한 주차 동안 처음 듣는 개념이 쏟아졌다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cache Stampede&lt;/strong&gt; — &amp;quot;TTL이 만료되는 순간 수백 개의 요청이 동시에 DB로 몰리는 현상&amp;quot;이 있다는 걸 이번에 처음 알았다. 캐시가 있을 땐 초당 1,000건을 처리하던 서비스가, 캐시가 만료되는 그 순간 DB로 1,000개 쿼리가 쏟아지면서 장애로 이어질 수 있다는 것. &lt;strong&gt;캐시의 존재 이유가 오히려 장애 원인이 되는 역설&lt;/strong&gt;이었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TTL Jitter&lt;/strong&gt; — &amp;quot;만료 시점을 랜덤하게 흩뜨린다&amp;quot;는 개념. 캐시 여러 개를 한번에 저장하면 TTL이 똑같이 만료돼서 한꺼번에 터진다. 그래서 ±10% 정도 랜덤값을 섞어서 만료 시점을 분산시킨다. &lt;strong&gt;평범하게 동작하는 시스템 뒤에 이런 디테일이 있었구나&lt;/strong&gt; 싶었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cold Start&lt;/strong&gt; — 서버 배포 직후 캐시가 비어있는 상태. 이 시점에 트래픽이 몰리면 1,000개 상품이 동시에 DB로부터 캐싱되고, 5분 뒤에 그 1,000개가 &lt;strong&gt;한꺼번에 만료된다.&lt;/strong&gt; 내가 몰랐던 시나리오였다. 혼자 공부할 땐 &amp;quot;캐시가 있으면 빠르다&amp;quot;까지만 생각했지, &amp;quot;캐시가 비어있는 순간&amp;quot;을 상상해본 적이 없었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mutex Lock + 분산 락&lt;/strong&gt; — Cache Stampede를 막기 위해 &amp;quot;하나의 요청만 DB에 가도록&amp;quot; 잠그는 기법. 단일 인스턴스면 &lt;code&gt;ReentrantLock&lt;/code&gt;, 다중 인스턴스면 Redis SETNX나 Redisson RLock을 쓴다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;캐시 저장소 장애 상황&lt;/strong&gt; — Redis가 죽으면? 지금까지 나는 &amp;quot;Redis는 그냥 동작하는 것&amp;quot;으로 취급했었다. 그런데 실제로 운영에서는 Redis가 죽을 수 있고, 그때 서비스 전체가 같이 죽지 않도록 &lt;strong&gt;Fallback 전략&lt;/strong&gt;을 미리 세워둬야 한다는 걸 배웠다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Race Condition&lt;/strong&gt; — 트랜잭션 내부에서 캐시를 삭제하면 &lt;strong&gt;A가 커밋하기 전에 B가 예전 데이터를 다시 캐싱&lt;/strong&gt;하는 문제가 생길 수 있다. 이걸 막으려고 Spring의 &lt;code&gt;@TransactionalEventListener(AFTER_COMMIT)&lt;/code&gt;을 써야 한다는 것. 캐시에 Race Condition이 있다니, 상상도 못 했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;KEYS vs SCAN&lt;/strong&gt; — &lt;code&gt;KEYS products:*&lt;/code&gt;를 쓰면 Redis 싱글 스레드가 블로킹되면서 서비스 전체가 멈춘다. &lt;code&gt;SCAN&lt;/code&gt;은 커서 방식으로 나눠서 스캔해서 블로킹이 없다. &lt;strong&gt;명령어 하나 잘못 쓰면 전체 서비스가 멈출 수 있다&lt;/strong&gt;는 걸 이번에 처음 알았다.&lt;/p&gt;
&lt;h3&gt;그래서 뭐가 그렇게 좋았냐면&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;캐싱 = 조회 빨라지는 것&amp;quot; 한 줄로 끝났던 내 이해가 5주차 한 주만에 수십 배로 확장됐다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;혼자 공부했을 때의 캐싱은 &amp;quot;동작이 잘 될 때&amp;quot;만 상정했던 캐싱이었다. 5주차에서 배운 캐싱은 &lt;strong&gt;&amp;quot;깨질 때&amp;quot;를 먼저 고민하는 캐싱&lt;/strong&gt;이었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TTL 만료 순간에 뭐가 벌어지는지&lt;/li&gt;
&lt;li&gt;캐시가 비어있을 때 뭐가 벌어지는지&lt;/li&gt;
&lt;li&gt;데이터 변경이랑 겹칠 때 뭐가 벌어지는지&lt;/li&gt;
&lt;li&gt;Redis 자체가 죽었을 때 뭐가 벌어지는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;실제 운영 환경에서 마주치는 시나리오들&amp;quot;&lt;/strong&gt;이 한 과제에 전부 들어있었다. 책만 봐서는 절대 못 배우는 것들이었다. 실무에서 사고가 터져봐야 알거나, 이런 과제로 일부러 굴려봐야 알 수 있는 것들.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;캐싱 전략의 핵심은 &amp;#39;무엇을 캐싱할 것인가&amp;#39;보다 &amp;#39;캐시가 깨질 때 어떻게 대응할 것인가&amp;#39;에 있다.&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;5주차가 끝나고 나서야 이 문장이 진짜로 이해됐다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. 강의와 멘토링, 뭐가 좋았나&lt;/h2&gt;
&lt;h3&gt;멘토링은 &amp;quot;답을 주지 않는&amp;quot; 시간이었다&lt;/h3&gt;
&lt;p&gt;이게 Loop:PAK의 핵심이다. 빅테크에서 실무를 하는 멘토들이 붙는데, 이분들이 절대 &amp;quot;이게 정답이에요&amp;quot;라고 말하지 않는다. 처음엔 답답했는데, 시간이 지나면서 &lt;strong&gt;그게 가장 값진 태도&lt;/strong&gt;라는 걸 알게 됐다.&lt;/p&gt;
&lt;p&gt;답을 주지 않는 대신 이런 질문을 돌려준다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;quot;왜 그 선택을 했어요?&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;다른 방식은 왜 안 된다고 판단했어요?&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;이 선택의 비용은 뭐라고 생각해요?&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;이 가정이 틀어지면 어떻게 될까요?&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 질문들이 &lt;strong&gt;내 판단의 빈틈을 드러나게 한다.&lt;/strong&gt; 내가 &amp;quot;이렇게 하는 게 맞죠?&amp;quot; 하고 물으러 갔다가, &lt;strong&gt;&amp;quot;아, 내가 이 관점은 생각도 안 했구나&amp;quot;&lt;/strong&gt; 하고 돌아오는 일이 10주 내내 반복됐다.&lt;/p&gt;
&lt;h3&gt;&amp;quot;혼자서는 엄두 안 나는 것들&amp;quot;을 찍어낼 수 있다&lt;/h3&gt;
&lt;p&gt;각 주차 주제들이 평소에 &amp;quot;언젠가 해봐야지&amp;quot; 하면서 미뤄왔던 것들이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spring Batch → 맨날 해봐야지 했던 것&lt;/li&gt;
&lt;li&gt;서킷 브레이커 → 실무에 도입하고 싶었지만 엄두가 안 났던 것&lt;/li&gt;
&lt;li&gt;Kafka Outbox Pattern → 책만 여러 번 읽었던 것&lt;/li&gt;
&lt;li&gt;Redis Lua Script → 이름도 처음 들어본 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;혼자 해도 되긴 한다. 근데 혼자 하면 &lt;strong&gt;&amp;quot;내가 제대로 하고 있는 건가&amp;quot;라는 불안&lt;/strong&gt;이 계속 따라붙는다. 멘토가 옆에 있으면 그 불안 없이 밀고 나갈 수 있다. 그리고 &lt;strong&gt;기한이 정해진 과제&lt;/strong&gt;라는 강제력이 &amp;quot;나중에 해야지&amp;quot; 목록을 10주 안에 찍어내게 만든다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;마무리 — 어떤 분께 추천하나&lt;/h2&gt;
&lt;p&gt;요즘은 대부분의 코드를 AI가 써준다. 그래서 &amp;quot;코드를 많이 쳐보는 것&amp;quot;은 더 이상 이 과정의 핵심이 아니다. Loop:PAK이 값진 이유는 다른 데 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;과제의 &amp;quot;고려사항&amp;quot;에 실제 운영에서 터지는 문제들이 녹아 있다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Cache Stampede, Cold Start, 캐시 저장소 장애, Race Condition, KEYS 블로킹, 배치 멱등성, 서킷 브레이커 임계값… 이런 것들은 책으로 공부한다고 와닿지 않는다. 실무에서 &lt;strong&gt;직접 사고가 나봐야&lt;/strong&gt; 몸에 박힌다. 그런데 현업 환경에 따라서는 &lt;strong&gt;그런 사고가 날 만한 규모의 트래픽이나 요구사항을 만나기 어렵다.&lt;/strong&gt; 안정적인 레거시만 유지보수하거나, 트래픽이 작거나, 기술 스택이 고정되어 있으면 &amp;quot;시장에서 요구하는 다양한 기술&amp;quot;을 실전에서 접할 기회 자체가 없다.&lt;/p&gt;
&lt;p&gt;그래서 이런 분들께 추천한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;실무 환경의 제약&lt;/strong&gt;으로 시장에서 요구하는 기술을 실전에서 다뤄볼 기회가 없었던 분&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;개인적으로는 많이 공부했는데&lt;/strong&gt;, 운영에서 터지는 문제들을 경험해본 적이 없어서 지식이 &amp;quot;이론&amp;quot;에만 머물러 있는 분&lt;/li&gt;
&lt;li&gt;캐싱, 대기열, 배치, 분산 락, 서킷 브레이커 같은 주제를 &lt;strong&gt;&amp;quot;언젠가 해봐야지&amp;quot;&lt;/strong&gt;로 미뤄온 분&lt;/li&gt;
&lt;li&gt;자기 판단에 &lt;strong&gt;확신과 설명력&lt;/strong&gt;을 갖고 싶은 분&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;나는 개인적으로 공부를 꽤 많이 한 편이라고 생각했다. 그런데도 &lt;strong&gt;실제 운영에서 발생할 수 있는 문제들이 과제의 고려사항으로 들어와 있으니까&lt;/strong&gt;, 10주 동안 &amp;quot;간접 실무 경험&amp;quot;을 한 느낌이었다. 사고가 날 만한 상황을 &lt;strong&gt;일부러 만들어서 굴려보게 하는 과제&lt;/strong&gt;는 혼자서는 설계하기 어렵다.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;10주 동안 기술적으로도 많이 배웠지만, 가장 크게 남은 건 &lt;strong&gt;세계관의 변화&lt;/strong&gt;다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;개발에 정답이 있다&amp;quot;&lt;/strong&gt;는 착각이 깨진 자리에, &lt;strong&gt;&amp;quot;개발은 트레이드오프와 선택의 연속이다&amp;quot;&lt;/strong&gt;라는 문장이 들어왔다. 이 문장을 받아들이고 나니, 기술 선택을 할 때의 불안이 사라졌다. &lt;strong&gt;&amp;quot;더 나은 답이 있는데 내가 모르는 건가&amp;quot;&lt;/strong&gt;를 걱정하는 대신, &lt;strong&gt;&amp;quot;이 선택의 비용을 내가 감수할 수 있는가&amp;quot;&lt;/strong&gt;를 묻게 됐다.&lt;/p&gt;
&lt;p&gt;그리고 이 태도는 책으로는 못 배우는 것이었다. 10주 동안 그분들 옆에서 질문을 받으면서 조금씩 몸에 밴 것 같다.&lt;/p&gt;
&lt;p&gt;그거 하나만으로도 충분히 값진 10주였다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;할인 코드&lt;/h3&gt;
&lt;p&gt;수강을 고민 중이시라면 아래 코드를 사용해주세요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;CJFZQ&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;</description>
      <author>snvlqkq</author>
      <guid isPermaLink="true">https://snvlqkq.tistory.com/65</guid>
      <comments>https://snvlqkq.tistory.com/65#entry65comment</comments>
      <pubDate>Sun, 19 Apr 2026 23:41:07 +0900</pubDate>
    </item>
    <item>
      <title>SLA, SLO, SLI 개념 정리</title>
      <link>https://snvlqkq.tistory.com/64</link>
      <description>&lt;p&gt;서비스 개발자라면 반드시 알고 있어야 하는 용어가 있다. 바로 SLA, SLO, SLI다. 다만 체계가 잡힌 조직이 아니라면, 실무에서 이 개념이 명시적으로 도입되지 않은 환경도 많다. 그래서 막상 문서에서 마주치면 &amp;quot;이게 정확히 뭘 구분하는 거지?&amp;quot; 싶다. 셋 다 &amp;quot;서비스 수준(Service Level)&amp;quot;으로 시작하지만 역할이 전혀 다르다. 하나씩 정리해본다.&lt;/p&gt;
&lt;h2&gt;SLA — 고객과의 약속&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;SLA(Service Level Agreement)&lt;/strong&gt; 는 서비스 제공자와 고객 사이에 맺는 합의다. 쉽게 말해 &amp;quot;우리 서비스는 이 정도 품질을 약속하겠다&amp;quot;는 내용을 수치로 명시한 계약이다.&lt;/p&gt;
&lt;p&gt;예를 들면 이런 항목이 들어간다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;가용성 99.9% uptime&lt;/li&gt;
&lt;li&gt;API 응답 200ms 이내&lt;/li&gt;
&lt;li&gt;처리량 1,000 TPS&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;약속을 지키지 못하면 보상이나 패널티가 따라붙기 때문에, SLA는 외부를 향한 공식 기준이 된다.&lt;/p&gt;
&lt;h2&gt;SLO — 내부 목표&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;SLO(Service Level Objective)&lt;/strong&gt; 는 SLA를 달성하기 위한 내부 목표값이다. 즉, &amp;quot;서비스 수준 목표&amp;quot;다.&lt;/p&gt;
&lt;p&gt;여기서 포인트는 &lt;strong&gt;SLO는 보통 SLA보다 엄격하게 잡는다&lt;/strong&gt;는 점이다. 예를 들어 SLA가 가용성 99.9%라면, SLO는 99.95%로 여유를 둔다. 고객에게 약속한 선까지 가기 전에 미리 경고가 울리도록 내부 마지노선을 한 칸 위에 두는 셈이다.&lt;/p&gt;
&lt;h2&gt;SLI — 실제 측정값&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;SLI(Service Level Indicator)&lt;/strong&gt; 는 실제 측정된 지표다. 모니터링 시스템이 수집한 가용성, 응답 시간 같은 메트릭이 모두 SLI에 해당한다.&lt;/p&gt;
&lt;p&gt;SLA와 SLO가 &amp;quot;이래야 한다&amp;quot;는 기준이라면, SLI는 &amp;quot;지금 실제로는 이렇다&amp;quot;를 보여주는 숫자다.&lt;/p&gt;
&lt;h2&gt;한 줄 요약&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;용어&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;한 줄 설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;SLA&lt;/td&gt;
&lt;td&gt;외부 약속&lt;/td&gt;
&lt;td&gt;고객과 맺는 서비스 수준 계약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SLO&lt;/td&gt;
&lt;td&gt;내부 목표&lt;/td&gt;
&lt;td&gt;SLA를 지키기 위한 더 빡빡한 내부 기준&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SLI&lt;/td&gt;
&lt;td&gt;실제 값&lt;/td&gt;
&lt;td&gt;모니터링으로 수집한 현재 지표&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;SLI로 현실을 측정하고, SLO로 안전 마진을 관리하고, SLA로 고객에게 약속한다.&lt;/strong&gt;&lt;/p&gt;</description>
      <category>라이브러리&amp;amp;프레임워크/Spring</category>
      <author>snvlqkq</author>
      <guid isPermaLink="true">https://snvlqkq.tistory.com/64</guid>
      <comments>https://snvlqkq.tistory.com/64#entry64comment</comments>
      <pubDate>Sun, 19 Apr 2026 13:14:32 +0900</pubDate>
    </item>
    <item>
      <title>루프팩 10주, 돌아보기</title>
      <link>https://snvlqkq.tistory.com/63</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;10주가 끝났다. 매주 주제를 받고, 과제를 하고, 멘토링을 받고, 회고를 쓰는 사이클을 반복했다. 돌이켜보면 꽤 빠듯했는데, 그 빠듯함 덕분에 남은 것도 많다. 주차별로 뭘 배웠는지보다는, 10주 동안 내 사고방식이 어떻게 바뀌었는지를 중심으로 정리해보려 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트가 설계를 이끈다는 말의 의미&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1주차 주제는 TDD였다. Red-Green-Refactor, 테스트 더블, MockMvc. 개념 자체는 이미 알고 있던 것들이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 직접 테스트를 짜보니 생각과 달랐다. 테스트 코드를 작성하는 것보다, 테스트를 세팅하는 과정이 더 고됐다. 목(mock)을 준비하고, 의존성을 주입하고, 테스트 환경을 맞추는 데 시간이 오래 걸리는 코드가 있었다. 처음에는 &quot;테스트가 원래 이렇게 번거로운 건가?&quot; 싶었는데, 가만 보니 테스트가 어려운 게 아니라 코드가 너무 많은 걸 하고 있었다.&lt;/p&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-ke-size=&quot;size26&quot;&gt;설계라는 걸 처음 해봤다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2주차는 소프트웨어 설계, 3주차는 도메인 모델링이었다. 유비쿼터스 언어, 시퀀스 다이어그램, ERD, Entity와 VO, Domain Service.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무는 아주 빠르게 돌아간다. 설계를 제대로 해본 적이 없었다. 요구사항 받으면 바로 코드부터 짰다. 이번에 처음으로 다이어그램을 먼저 그리고, 모델을 먼저 정의하는 과정을 거쳤는데, 정답이 없어서 너무 어려웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시기에 멘토한테 들은 말이 하나 있다.&lt;/p&gt;
&lt;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;처음엔 무슨 말인가 싶었다. 그런데 Service 간 엔티티 의존 문제를 풀면서 조금 이해가 됐다. OrderService가 ProductEntity를 직접 참조해야 하는가? 4가지 해결 방안을 놓고 고민했는데, 정답이 없었다. 결국 현재 구조를 유지하되 컨벤션으로 경계를 잡는 걸 선택했다. 완벽한 답은 아니었지만, 왜 이걸 선택했는지 설명할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;자기만의 철학&quot;이란 건 거창한 게 아니라, 선택의 이유를 설명할 수 있느냐의 문제였다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;낙관적 락? 비관적 락?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4주차, 트랜잭션과 동시성 제어. 쿠폰 발급 시 재고 정합성을 보장하는 과제였다.&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;atomic UPDATE, 비관적 락, 낙관적 락 세 가지를 직접 비교해보면서 차츰 기준을 알게되었다. 획일적인 기준이 생긴 건 아니었다. 대신 각 방식을 적용했을 때 트레이드오프가 뭔지, 데이터의 성격이 어떤지, 경합 빈도가 어느 정도인지 &amp;mdash; 이런 여러 요소를 함께 따져봐야 한다는 걸 알게 됐다. 무조건적인 기준이 있는게 아니라 &quot;여러 상황들을 보고 판단해야 한다&quot;로 사고방식이 바뀐 주차였다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;캐시는 단순한 도구가 아니었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5주차, 읽기 성능 최적화. 캐싱은 이전에 개인적으로 학습한 적이 있었다. 그때의 인상은 단순했다. 조회 속도를 올려주는 도구. 붙이면 빨라지고, 그게 전부라고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 제대로 다뤄보니 전혀 달랐다. 캐시 하나를 적용하는 데에도 고민할 게 끝없이 나왔다. TTL이 만료되는 순간 수백 개의 요청이 동시에 DB로 향하는 Cache Stampede, 캐시와 DB 사이의 데이터 불일치인 Stale Data, 만료 시점이 겹치지 않도록 흩뜨리는 Jitter, 캐시 서버 자체가 죽었을 때의 장애 전략. &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-ke-size=&quot;size26&quot;&gt;정답이 아니라 판단 근거&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6주차, 결제 시스템 연동. 서킷 브레이커는 개인적으로 가장 관심 있던 주제 중 하나였다. 실무에 도입해보고 싶었는데 엄두가 안 났다. failureRateThreshold는 몇으로 잡아야 하는지, slowCallDurationThreshold는 어디까지가 적정인지. 설정값에 정답이 있을 거라고 생각했다.&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-ke-size=&quot;size26&quot;&gt;모르는 게 많았구나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7주차, Kafka와 이벤트 기반 아키텍처. 이 주차는 처음 보는 것들이 쏟아졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에 ApplicationEvent라는 게 있다는 걸 처음 알았다. 메시지 브로커를 쓸 때 메시지 유실을 방지하기 위해 Outbox Pattern이라는 걸 활용한다는 것도 처음이었다. DLQ(Dead Letter Queue)가 거창한 시스템이 아니라 하나의 토픽이라는 것도. 하나하나 알아갈 때마다 &quot;이것도 몰랐네&quot; 하는 생각이 계속 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모르는 게 많다는 걸 아는 것도 배움이라고 하던가. 7주차는 그런 주차였다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Redis 활용이란 게 이런 걸까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8주차, 블랙프라이데이 시나리오. Redis는 캐싱 용도로 쓰는 것밖에 몰랐다. 대기열이라는 개념 자체도 처음 접했고, 이걸 Redis의 Sorted Set이라는 자료구조도 처음 봤다. Lua Script도 처음이었다. Redis가 이렇게 다양하게 활용되는 도구였구나 싶었다.&lt;/p&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-ke-size=&quot;size26&quot;&gt;Sorted Set이라는 자료구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;9주차, 상품 랭킹 시스템. Redis의 Sorted Set을 활용해서 랭킹을 구현했다. Sorted Set은 score와 member로 구성되어 있고, score 기준으로 정렬된 상태를 유지한다. ZADD로 추가하고, ZINCRBY로 점수를 누적하고, ZREVRANGE로 상위 N개를 꺼내는 식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8주차에서 대기열 용도로 Sorted Set을 처음 접했을 때는 겉핥기 수준이었다. 9주차에서 랭킹을 직접 구현하면서 ZADD의 NX, XX, GT 같은 옵션들, ZINCRBY로 점수를 증분하는 방식, ZREVRANGE로 상위 랭킹을 꺼내는 흐름까지 하나씩 써보면서 Sorted Set이라는 자료구조를 좀 더 깊게 이해하게 됐다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배치는 돌리는 게 아니라 지키는 거였다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10주차, Spring Batch. 평소에 공부해보고 싶었는데 엄두를 못 내고 있었다. 과제를 통해 공부할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치를 돌리는 것 자체는 어렵지 않았다. Job을 만들고, Step을 구성하고, Reader/Processor/Writer를 연결하면 돌아간다. 어려운 건 그 다음이었다. 같은 배치가 두 번 돌면 어떻게 되는가. 중간에 실패하면 데이터는 어떤 상태로 남는가. 재실행했을 때 결과가 동일한가. 중복 실행, 멱등성, 실패 복구, 데이터 정합성. 배치를 돌리는 것보다 돌렸을 때 발생할 수 있는 여러 상황을 고려하는 게 훨씬 어려웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 DELETE+INSERT로 구현했다가 바로 문제를 만났다. chunk 단위로 재시작하는 Spring Batch의 특성과 충돌한 것이다. 중간에 실패하면 이미 삭제된 데이터를 되돌릴 수 없었다. INSERT only + 유니크 제약조건으로 바꾸고 나서야 안전하게 재실행할 수 있는 구조가 됐다. 배치는 &quot;돌리면 끝&quot;이 아니었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10주를 지나며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각보다 많은 걸 배웠다. TDD와 설계로 기초를 다지고, 동시성과 성능에서 트레이드오프를 따지는 법을 배우고, 외부 시스템 연동에서 장애 전파를 차단하고, 대기열과 배치에서 &quot;운영 관점의 사고&quot;를 연습했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 매주 주어진 과제를 소화하는 데 급급했다. 그런데 5주차쯤부터 달라졌다. 단순히 &quot;이걸 구현해야 한다&quot;가 아니라 &quot;왜 이 방식인가, 다른 방식은 안 되는가, 이 선택의 비용은 뭔가&quot;를 먼저 생각하게 됐다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술적으로 가장 많이 늘었다고 느끼는 건 트레이드오프 사고다. 비관적 락 vs 낙관적 락, 실시간 집계 vs 비정규화, DELETE+INSERT vs INSERT only. 어떤 선택이든 비용이 있고, 그 비용을 알고 선택하는 것과 모르고 선택하는 건 완전히 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 하나 더. 솔직히 말하면, 10주 동안 AI 기반으로 코드를 구현하면서 코드를 거의 보지 않았다. 게을러진 것 같다. 사람이 그렇다. 편한 쪽으로 흐른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 처음에는 배운 게 없는 것 같았고, 배워도 머릿속에 코드가 떠오르지 않았다. 하지만 되돌아보니 미래를 경험한 것 같다. 앞으로 개발 패러다임은 이런 방식으로 변화될 것이다. 인간은 코드를 작성하지 않는다. 우리는 소통할 수 있는 지식을 학습한다. 트레이드오프를 따지고, 장애 상황을 예측하고, 설계의 이유를 설명하는 것. 그 지식들이 코드 구현보다 훨씬 더 가치있게 될 것이다.&lt;/p&gt;</description>
      <author>snvlqkq</author>
      <guid isPermaLink="true">https://snvlqkq.tistory.com/63</guid>
      <comments>https://snvlqkq.tistory.com/63#entry63comment</comments>
      <pubDate>Thu, 16 Apr 2026 23:37:52 +0900</pubDate>
    </item>
    <item>
      <title>동시성 테스트 유틸, 정말 동시 시작을 보장하는가?</title>
      <link>https://snvlqkq.tistory.com/62</link>
      <description>&lt;p&gt;&lt;code&gt;CountDownLatch&lt;/code&gt;는 하나 이상의 스레드가 다른 스레드들의 작업 완료를 기다릴 수 있게 해주는 &lt;strong&gt;일회성 동기화 도구(synchronization aid)&lt;/strong&gt; 이다. &amp;quot;일회성&amp;quot;이라고 표현한 이유는 한 번 사용하면 재사용 할 수 없다는 뜻이다. 동시성 테스트에서는 스레드들의 &lt;strong&gt;동시 시작을 조율&lt;/strong&gt; 하는 용도로 활용된다.&lt;/p&gt;
&lt;h2&gt;핵심 동작 원리&lt;/h2&gt;
&lt;p&gt;생성 시 &lt;strong&gt;카운트 값&lt;/strong&gt;을 지정하고, &lt;code&gt;countDown()&lt;/code&gt; 호출 시 카운트를 1씩 감소시킨다. &lt;code&gt;await()&lt;/code&gt;을 호출한 스레드는 카운트가 0이 될 때까지 블로킹된다. 카운트가 0이 되는 순간 &lt;code&gt;await()&lt;/code&gt;에서 대기하던 모든 스레드가 실행을 재개한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;카운트가 0에 도달하면 리셋할 수 없다. &lt;/li&gt;
&lt;li&gt;카운트가 이미 0인 상태에서 &lt;code&gt;countDown()&lt;/code&gt;을 호출하면 아무 일도 일어나지 않는다.&lt;/li&gt;
&lt;li&gt;카운트가 이미 0인 상태에서 &lt;code&gt;await()&lt;/code&gt;을 호출하면 블로킹 없이 즉시 통과한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;주요 메서드&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;메서드&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;countDown()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;카운트를 1 감소. 0이 되면 대기 중인 스레드들이 깨어남&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;await()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;카운트가 0이 될 때까지 블로킹&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;await(long timeout, TimeUnit unit)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;타임아웃 지정. 시간 내 0이 되면 &lt;code&gt;true&lt;/code&gt;, 초과하면 &lt;code&gt;false&lt;/code&gt; 반환. 무한 대기 방지용으로 실무에서 권장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getCount()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;현재 남은 카운트 반환. 디버깅/로깅 용도. TOCTOU 문제로 분기 로직에 사용하면 안 됨&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;동시 시작 조율 패턴&lt;/h2&gt;
&lt;p&gt;동시성 테스트에서 여러 스레드의 동시 시작을 조율하는 패턴이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;int threadCount = 200;
CountDownLatch startSignal = new CountDownLatch(1);        // 동시 시작용
CountDownLatch doneSignal = new CountDownLatch(threadCount); // 완료 대기용
ExecutorService executor = Executors.newFixedThreadPool(threadCount);

for (int i = 0; i &amp;lt; threadCount; i++) {
    executor.submit(() -&amp;gt; {
        try {
            startSignal.await();   // 모든 스레드가 여기서 대기 동시 실행 보장
            doWork();
        } finally {
            doneSignal.countDown(); // 작업 완료 알림
        }
    });
}

startSignal.countDown();  // 카운트 1→0, 모든 스레드 동시 출발
doneSignal.await();        // 모든 작업 완료 대기
executor.shutdown();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;흐름 정리:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;executor.submit()&lt;/code&gt;은 태스크를 스레드 풀의 작업 큐에 넣고 &lt;strong&gt;즉시 반환&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;각 워커 스레드가 태스크를 꺼내 실행하면서 &lt;code&gt;startSignal.await()&lt;/code&gt;에서 블로킹&lt;/li&gt;
&lt;li&gt;for 루프 완료 후 메인 스레드가 &lt;code&gt;startSignal.countDown()&lt;/code&gt; 호출 → 카운트 0 → 전체 스레드 동시 출발&lt;/li&gt;
&lt;li&gt;각 스레드 완료 시 &lt;code&gt;doneSignal.countDown()&lt;/code&gt; 호출&lt;/li&gt;
&lt;li&gt;메인 스레드는 &lt;code&gt;doneSignal.await()&lt;/code&gt;에서 전체 완료 대기&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;동시 시작이 완벽하게 보장되지 않을 수 있다.&lt;br&gt;모든 스레드가 &lt;code&gt;startSignal.await()&lt;/code&gt;에 도달하기 전에 &lt;code&gt;startSignal.countDown()&lt;/code&gt;이 호출될 수 있다. 이 경우 뒤늦게 &lt;code&gt;await()&lt;/code&gt;을 호출한 스레드는 블로킹 없이 즉시 통과하므로, 먼저 도착한 스레드와 시작 시점 차이가 발생한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;</description>
      <category>라이브러리&amp;amp;프레임워크/Spring</category>
      <author>snvlqkq</author>
      <guid isPermaLink="true">https://snvlqkq.tistory.com/62</guid>
      <comments>https://snvlqkq.tistory.com/62#entry62comment</comments>
      <pubDate>Tue, 14 Apr 2026 12:30:18 +0900</pubDate>
    </item>
    <item>
      <title>대기열, 시스템을 보호하라 (2)</title>
      <link>https://snvlqkq.tistory.com/61</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;기본편에서 대기열을 만들고 효과를 검증했다. 하지만 50명이 동시에 입장하는 Thundering Herd, polling이 만드는 부하, Redis 장애 시 주문 불가라는 문제가 남아 있었다. 배치를 쪼개고, polling 주기를 차등 적용하고, Kafka로 우회하는 Graceful Degradation을 설계하면서 &amp;quot;대기열을 만드는 것&amp;quot;과 &amp;quot;대기열을 운영하는 것&amp;quot;은 다른 문제라는 걸 알게 됐다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;p&gt;&lt;a href=&quot;https://snvlqkq.tistory.com/60&quot; style=&quot;color: #006dd7 !important;&quot;&gt;대기열 기본편&lt;/a&gt;에서는 Redis 기반의 기본적인 대기열을 구축해봤다. 여기서는 대기열을 운영하면서 발생할 수 있는 추가적인 문제를 해결해보고자 한다.&lt;/p&gt;
&lt;h2&gt;1. Thundering Herd — 50명이 동시에 문을 열면&lt;/h2&gt;
&lt;p&gt;기본편에서 대기인원을 입장시키는 배치 사이즈는 50, 스케줄 주기를 1,000ms로 설정했다. 초당 50건의 처리량을 흘려보내는 설정이다. 그런데 한가지 주목해야하는 점은 50명의 입장 토큰이 &lt;strong&gt;한꺼번에&lt;/strong&gt; 생성된다는 점이다.&lt;/p&gt;
&lt;p&gt;1초마다 50개의 토큰이 한 번에 발급되면, 50명이 거의 동시에 실제 주문 API를 호출한다. 50건이 한 시점에 몰린다. 굳이 50명을 동시에 입장시킬 이유가 없다. 두 가지 방법으로 입장 시점을 분산시켰다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1) 배치 쪼개기&lt;/strong&gt; — 배치 사이즈를 줄이고 주기를 짧게&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;배치 사이즈&lt;/th&gt;
&lt;th&gt;주기&lt;/th&gt;
&lt;th&gt;초당 처리량&lt;/th&gt;
&lt;th&gt;피크 부하&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;변경 전&lt;/td&gt;
&lt;td&gt;50명&lt;/td&gt;
&lt;td&gt;1,000ms&lt;/td&gt;
&lt;td&gt;50명/초&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50명 동시&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;변경 후&lt;/td&gt;
&lt;td&gt;10명&lt;/td&gt;
&lt;td&gt;200ms&lt;/td&gt;
&lt;td&gt;50명/초&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10명 동시&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;초당 처리량은 동일하게 유지하면서 피크 부하만 1/5로 줄였다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2) Jitter&lt;/strong&gt; — 같은 배치 내에서도 입장 시점을 흩뿌리기&lt;/p&gt;
&lt;p&gt;배치를 쪼개도 같은 배치에 속한 10명은 여전히 동시에 입장권을 받는다. 여기에 Jitter(랜덤 지연)를 추가했다. 입장권을 발급할 때 사용자마다 &amp;quot;주문 가능 시각&amp;quot;에 랜덤한 지연을 더해 주문 시점을 흩뿌리는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// 유저별 주문 가능 시각 계산 — 랜덤 지연 추가
long now = System.currentTimeMillis();
List&amp;lt;Long&amp;gt; orderableAts = IntStream.range(0, batchSize)
        .mapToLong(i -&amp;gt; now + ThreadLocalRandom.current().nextLong(jitterMaxMs))
        .boxed()
        .toList();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;10명이 동시에 입장권을 받아도, 각자의 주문 가능 시각이 랜덤하게 달라지므로 주문 요청이 한 시점에 몰리지 않는다.&lt;/p&gt;
&lt;p&gt;부하 테스트 결과 (500명 × 10,000건):&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;주문 API 응답 시간&lt;/th&gt;
&lt;th&gt;1,000ms / 50명&lt;/th&gt;
&lt;th&gt;200ms / 10명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;129ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;79ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최대&lt;/td&gt;
&lt;td&gt;6,184ms&lt;/td&gt;
&lt;td&gt;6,840ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;에러율은 양쪽 모두 0%로 동일하지만, p95 응답 시간이 129ms → 79ms로 39% 개선되었다. 최대 응답 시간은 비슷한데, 이건 스케줄러 전환 시점 등의 일시적 지연이지 구조적 문제는 아니다. 핵심은 p95, 즉 대부분의 요청이 더 빠르게 처리된다는 것이다.&lt;/p&gt;
&lt;h2&gt;2. 대기열이 만든 또 다른 부하&lt;/h2&gt;
&lt;p&gt;대기열을 도입하면서 새로운 부하가 생겼다. 클라이언트는 자신의 대기 순번 및 입장권을 확인하기 위해 주기적으로 확인 요청을 한다. 대기자가 1,000명이고 5초 주기로 확인하면 초당 200건이다. 부하를 안전하게 흘려보내기 위한 대기열이 또 다른 부하를 만들었다.&lt;/p&gt;
&lt;p&gt;한 가지 생각해 볼게 있다. 곧 입장할 사용자와 1,000 번째 순번에 있는 사용자의 polling 주기가 동일해야 할까? 곧 입장할 사람은 자주 확인하고 아직 차례가 많이 남은 사람은 조금 널널하게 확인을 해도 된다. 순번에 따라 polling 주기를 차등 적용했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;대기 순번&lt;/th&gt;
&lt;th&gt;예상 대기 시간&lt;/th&gt;
&lt;th&gt;폴링 주기&lt;/th&gt;
&lt;th&gt;근거&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1~100번&lt;/td&gt;
&lt;td&gt;0~2초&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2초&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;곧 입장이므로 빠르게 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;101~500번&lt;/td&gt;
&lt;td&gt;2~10초&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5초&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;수 초 내 입장, 적당한 빈도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;501번~&lt;/td&gt;
&lt;td&gt;10초 이상&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10초&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;자주 조회해도 순번 변화가 체감되지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;대기자 수에 따른 부하 예측:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;대기자 수&lt;/th&gt;
&lt;th&gt;일괄 5초&lt;/th&gt;
&lt;th&gt;구간별&lt;/th&gt;
&lt;th&gt;절감률&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1,000명&lt;/td&gt;
&lt;td&gt;200건/초&lt;/td&gt;
&lt;td&gt;180건/초&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000명&lt;/td&gt;
&lt;td&gt;1,000건/초&lt;/td&gt;
&lt;td&gt;580건/초&lt;/td&gt;
&lt;td&gt;42%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;구간별 polling 주기를 차등 적용 했을 때 서버가 받는 TPS를 비교해보면 다음과 같다. 구간별 방식은 후 순위에 있는 불필요한 요청을 줄이고, 입장이 임박한 사용자의 응답성을 높인다. 대기자가 많아질수록 뒤쪽 구간에 인원이 집중되므로, 개선 효과는 점점 커진다.&lt;/p&gt;
&lt;h2&gt;3. Redis가 죽으면 주문도 죽는가&lt;/h2&gt;
&lt;p&gt;대기열이 Redis에 의존하는 이상, Redis 장애는 곧 주문 불가를 의미한다. 블랙프라이데이에 주문이 막히면 매출 타격이 크다. 우리는 Redis 장애에 대비하여 3가지 선택지를 고려해볼 수 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;주문 전면 차단&lt;/li&gt;
&lt;li&gt;주문 API 직접 접근 허용&lt;/li&gt;
&lt;li&gt;Fallback (Kafka로 임시 전환)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;정답은 없다. 사전에 협의한 방향으로 진행하면 된다. 중요한 건 &lt;strong&gt;장애가 발생한 뒤 판단하면 늦다&lt;/strong&gt;는 것이다.&lt;/p&gt;
&lt;p&gt;블랙프라이데이라는 맥락에서 주문 불가는 선택지가 아니었다. 그렇다고 대기열 없이 전부 허용하면 대기열을 둔 이유가 사라진다. 유입 제어의 주체를 Redis에서 Kafka로 전환하기로 했다.&lt;/p&gt;
&lt;h3&gt;Redis 장애 감지&lt;/h3&gt;
&lt;p&gt;QueueService에 &lt;code&gt;volatile boolean redisAvailable&lt;/code&gt; 플래그를 둔다. Redis 호출 성공 시 &lt;code&gt;true&lt;/code&gt;, 실패 시 &lt;code&gt;false&lt;/code&gt;로 갱신한다. OrderController는 이 플래그만 읽어 분기를 판단한다. Redis를 매번 체크하지 않으므로 장애 상황에서 추가 부하가 없다.&lt;/p&gt;
&lt;h3&gt;정상 흐름 vs Degraded 흐름&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;정상 흐름&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    participant C as Client
    participant S as Server
    participant R as Redis
    participant DB as DB

    C-&amp;gt;&amp;gt;S: POST /queue/enter
    S-&amp;gt;&amp;gt;R: ZADD (대기열 진입)

    loop polling
        C-&amp;gt;&amp;gt;S: GET /queue/position
        S-&amp;gt;&amp;gt;R: ZRANK (순번 조회)
        S--&amp;gt;&amp;gt;C: 순번 응답
    end

    Note over S,R: 스케줄러 → 입장권 발급
    C-&amp;gt;&amp;gt;S: POST /orders (입장권)
    S-&amp;gt;&amp;gt;R: 입장권 검증
    S-&amp;gt;&amp;gt;DB: 주문 처리
    S--&amp;gt;&amp;gt;C: 200 OK&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Degraded 흐름 (Redis 장애 시)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    participant C as Client
    participant S as Server
    participant DB as DB
    participant K as Kafka

    C-&amp;gt;&amp;gt;S: POST /queue/enter
    S--&amp;gt;&amp;gt;C: status: BYPASSED

    C-&amp;gt;&amp;gt;S: POST /orders
    S-&amp;gt;&amp;gt;DB: Outbox INSERT (PENDING)
    S--&amp;gt;&amp;gt;C: 200 (status: ACCEPTED)

    Note over DB,K: TX COMMIT 후
    DB-&amp;gt;&amp;gt;K: Kafka 발행

    Note over K,DB: Consumer 배치 처리
    K-&amp;gt;&amp;gt;DB: OrderFacade.placeOrder()&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Outbox 패턴으로 유실 방지&lt;/h3&gt;
&lt;p&gt;주문 요청을 Kafka에 직접 발행하면 DB에 기록이 남지 않아 유실 가능성이 있다. Outbox 패턴을 적용하여 &lt;code&gt;outbox_events&lt;/code&gt; 테이블에 먼저 INSERT한 뒤, 트랜잭션 커밋 후 Kafka로 발행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;OrderRequestProducer.send(command)
  → @Transactional
  → outbox_events INSERT (status=PENDING)
  → TX COMMIT
    → AFTER_COMMIT: Kafka 발행 → status=PUBLISHED
    → 실패 시: OutboxRelayScheduler(10초)가 재시도&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;200 응답 시점에 DB 레코드가 존재하므로, Kafka 발행이 실패해도 릴레이 스케줄러가 재시도를 보장한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p&gt;(1)편에서는 대기열의 기본 구조를 만들었다면, 이번 편에서는 대기열 운영 시 발생할 수 있는 문제들을 다뤘다. 피크 부하 분산, polling 최적화, 장애 대응까지. 특히 Graceful Degradation과 같이 Redis 장애 시 대체안을 미리 마련해둬야 한다. 장애가 발생한 뒤에 판단하면 늦다. 항상 장애 상황 시 대체안을 마련해 둬야 한다.&lt;/p&gt;</description>
      <category>데이터베이스</category>
      <author>snvlqkq</author>
      <guid isPermaLink="true">https://snvlqkq.tistory.com/61</guid>
      <comments>https://snvlqkq.tistory.com/61#entry61comment</comments>
      <pubDate>Sun, 12 Apr 2026 17:42:50 +0900</pubDate>
    </item>
    <item>
      <title>대기열, 시스템을 보호하라 (1)</title>
      <link>https://snvlqkq.tistory.com/60</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;한국에서 흑백 요리사라는 프로그램이 굉장히 인기였다. 요리 서바이벌 프로그램인데, 프로그램이 끝나고 나면 서바이벌에 참가한 요리사들의 식당은 항상 사람들로 넘쳐났다. 식당을 방문하기 위해서 한참 기다려야 했다. 좌석이 한정적이었기 때문이다. 식당들은 넘쳐나는 손님들을 수용하기 위해 대기자 명단을 작성해두고, 손님들이 빠져나간 만큼 수용 가능한 인원들을 입장시킨다. 우리는 시스템에서 이것을 대기열이라 부른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블랙프라이데이를 앞두고 주문 시스템에도 대기열이 필요했다. 트래픽이 폭발적으로 증가할 것으로 예상되는데, DB는 스케일 아웃이 제한적이고 오토스케일링은 극단적으로 짧고 높은 트래픽 앞에서 반응이 늦다. 시스템을 키우는 데 한계가 있다면, 방향을 바꿔야 했다. 들어오는 요청을 시스템이 처리할 수 있는 속도로 조절하는 것이다. 주문 요청을 대기열에 세워두고, 처리 가능한 만큼만 흘려보내기로 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;대기열.png&quot; data-origin-width=&quot;1212&quot; data-origin-height=&quot;652&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkXO8j/dJMcadhknxf/Ex7Jfs1MpDj0zUvm6gx3y1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkXO8j/dJMcadhknxf/Ex7Jfs1MpDj0zUvm6gx3y1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkXO8j/dJMcadhknxf/Ex7Jfs1MpDj0zUvm6gx3y1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkXO8j%2FdJMcadhknxf%2FEx7Jfs1MpDj0zUvm6gx3y1%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;1212&quot; height=&quot;652&quot; data-filename=&quot;대기열.png&quot; data-origin-width=&quot;1212&quot; data-origin-height=&quot;652&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 대기열은 어떻게 구현하나?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기열을 위해서는 대기자를 관리할 저장소가 필요했다. 대기열은 대기자들을 입장한 순서대로 관리하고 대기 순번을 지속적으로 알려줘야 한다. 대기 데이터는 주문이 완료되면 사라지는 일시적인 데이터이고, 순번 조회가 빈번하게 발생하므로 속도가 중요했다. Redis는 인메모리 기반으로 읽기/쓰기가 빠르다. Redis를 사용하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis Sorted Set을 사용하면 대기자들을 입장 순서대로 관리할 수 있다. Redis Sorted Set은 score 기반으로 데이터를 정렬 저장하고, 특정 멤버의 순위를 O(log N)으로 조회할 수 있다. 주문 요청 시각을 score로 지정하면 자연스럽게 선착순 정렬이 된다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;ZADD queue:waiting &amp;lt;timestamp&amp;gt; &amp;lt;userId&amp;gt;    -- 대기열 진입
ZRANK queue:waiting &amp;lt;userId&amp;gt;               -- 순번 조회 (O(log N))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 주의해야 하는 점은, 사용자가 불안해서 주문 버튼을 한 번 더 누르는 경우다. &lt;code&gt;ZADD&lt;/code&gt;는 기본적으로 member가 이미 있으면 score를 갱신한다. 즉, 100번째로 대기 중이던 사용자가 다시 요청하면 timestamp가 갱신되면서 순번이 맨 뒤로 밀린다. 수많은 요청을 제어하기 위해 대기열을 둔 건데, 실수로 다시 요청했다고 한참을 더 기다려야 한다면 사용자 경험에 최악이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NX&lt;/code&gt;(Not Exists) 옵션을 지정해 이미 대기자 명단에 추가된 경우 무시하도록 설정한다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;ZADD queue:waiting NX &amp;lt;timestamp&amp;gt; &amp;lt;userId&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 이제 입장하셔도 됩니다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기열에 진입했다고 바로 주문이 되는 건 아니다. 대기열은 주문 접수이고, 실제 주문은 입장권을 받은 후에야 가능하다. 입장권은 &quot;이제 주문해도 됩니다&quot;라는 허가증이다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Scheduled(fixedDelay = 1000) // 1초마다 실행
public void processQueue() {

    // 1. 대기큐에서 사용자를 꺼내고 대기자 명단에서 삭제한다.
    List&amp;lt;Long&amp;gt; userIds = queueRepository.popFromQueue(batchSize); //50

    // 2. 입장권을 생성한 후 저장한다.
    for (Long userId : userIds) {
        String token = UUID.randomUUID().toString();
        queueRepository.issueToken(userId, token);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스케줄러가 주기적으로 대기열에서 사용자를 꺼내 입장권을 발급한다. 입장권은 입장 토큰이라고 한다. 토큰은 &lt;code&gt;entry-token:{userId}&lt;/code&gt; 형태로 Redis에 저장되며, TTL을 두어 일정 시간 내에 주문하지 않으면 자동 파기된다. 입장권을 발급 받은 클라이언트는 발급받은 토큰을 갖고 실제 주문을 처리하는 주문 API를 호출하게 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 원자적 처리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드에서 이상한 점을 발견하지 못했는가? 다시 한 번 살펴보자. 만약, 대기자 명단에서 사용자를 제거한 뒤 입장권을 생성하기 전에 애플리케이션에 문제가 발생하거나, 입장권을 저장할 때 네트워크 통신 오류로 저장하지 못하면 어떻게 될까? 대기자 명단에서는 사라졌는데 입장권은 발급되지 않는다. 사용자는 대기열에도 없고, 입장권도 없는 상태가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하려면 &quot;대기열에서 꺼내기&quot;와 &quot;입장권 발급&quot;이 하나의 단위로 실행되어야 한다. Redis는 Lua 스크립트 인터프리터를 내장하고 있어, 여러 커맨드를 하나의 스크립트로 묶어 실행할 수 있다. Redis가 싱글 스레드로 동작하기 때문에, Lua 스크립트 실행 중에는 다른 커맨드가 끼어들 수 없다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;-- 1. 대기열에서 가장 먼저 온 50명을 꺼낸다 (꺼내면서 대기열에서 제거)
local count = 50
local popped = redis.call('ZPOPMIN', 'queue:waiting', count)

local results = {}

for i = 1, #popped, 2 do
    local userId = popped[i]
    local token = '미리-생성된-UUID'

    -- 2. 입장권을 저장한다 (300초 후 자동 파기)
    redis.call('SET', 'entry-token:' .. userId, token, 'EX', 300)

    table.insert(results, userId)
end

-- 3. 입장권이 발급된 사용자 목록을 반환한다
return results&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ZPOPMIN&lt;/code&gt;으로 대기열에서 사용자를 꺼내고, 바로 &lt;code&gt;SET&lt;/code&gt;으로 입장권을 저장한다. 이 과정이 하나의 Lua Script 안에서 완결되므로, 애플리케이션 장애가 중간에 개입할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 DB 트랜잭션과는 다르다. commit/rollback 개념이 없어서, 스크립트 중간에 오류가 나면 이미 실행된 커맨드는 롤백되지 않는다. 그래도 세 작업이 Redis 안에서 한 호흡에 끝나므로, 애플리케이션 장애가 중간에 끼어드는 시나리오는 방지된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lua Script를 적용하면 스케줄러 코드는 다음과 같이 변경된다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Scheduled(fixedDelay = 1000)
public void processQueue() {
    // 대기열 조회 &amp;rarr; 제거 &amp;rarr; 입장권 발급을 Lua Script로 원자적 처리
    List&amp;lt;Long&amp;gt; issuedUserIds = queueRepository.popAndIssueTokens(batchSize);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 &lt;code&gt;popFromQueue()&lt;/code&gt;와 &lt;code&gt;issueToken()&lt;/code&gt;을 각각 호출했지만, 이제 &lt;code&gt;popAndIssueTokens()&lt;/code&gt; 하나로 Lua Script를 실행한다. 애플리케이션 코드에서는 한 번의 호출로 끝나고, 원자성은 Redis가 보장한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 배치 사이즈 산출 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2번째 단계에서 일정 주기마다 대기자를 조회해 입장권을 생성한다고 했다. 그렇다면 한 번에 몇 명씩, 얼마나 자주 꺼낼지를 정해야 한다. 대기열의 존재 이유는 시스템이 감당할 수 있는 만큼만 흘려보내는 것이다. &quot;감당할 수 있는 만큼&quot;은 어떻게 결정할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 평상시 운영 지표를 통해 확인할 수 있다. 아니면, 부하 테스트를 통해 산출된 지표를 참조하면 된다. 필자는 주문 시스템의 안정적인 처리량을 TPS 50으로 지정했다. 여기서 배치 사이즈 50, 스케줄 주기 1,000ms라는 설정이 나왔다. 초당 50건의 처리량을 흘려보내는 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 대기열 적용 효과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 구현한 뒤, 정말 효과가 있는지 확인하고 싶었다. k6를 사용하여 500명의 동시 접속자가 각 20회씩 총 10,000건의 주문을 요청하는 부하 테스트를 수행했다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;대기열 없이&lt;/th&gt;
&lt;th&gt;대기열 포함&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;성공&lt;/td&gt;
&lt;td&gt;10,000건&lt;/td&gt;
&lt;td&gt;&lt;b&gt;10,000건&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패&lt;/td&gt;
&lt;td&gt;43건&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;에러율&lt;/td&gt;
&lt;td&gt;0.43%&lt;/td&gt;
&lt;td&gt;&lt;b&gt;0.00%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;주문 API 응답 시간&lt;/th&gt;
&lt;th&gt;대기열 없이&lt;/th&gt;
&lt;th&gt;대기열 포함&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;평균&lt;/td&gt;
&lt;td&gt;922ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;90ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;3,864ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;129ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최대&lt;/td&gt;
&lt;td&gt;9,959ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;6,184ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기열 없이 500명이 동시에 주문하면 43건이 실패하고, p95 응답 시간이 3.8초까지 치솟는다. 대기열을 적용하면 에러율 0%, p95가 129ms로 안정적이다. 물론 입장 토큰을 받기 전까지 대기시간이 포함되진 않아 수치가 극단적으로 다르지만 에러율을 봐야한다. 대기열이 유입량을 50명/초로 조절하면서 DB 커넥션 등 경합이 사라졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 평균 9.8초를 대기하지만, 주문 자체는 90ms에 처리되며 에러는 0건이다. &quot;기다리게 하되, 실패하지 않게 한다&quot; &amp;mdash; 대기열의 본질을 숫자로 확인할 수 있었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지가 대기열의 기본 구조다. Redis Sorted Set으로 대기열을 생성하고, Lua Script로 원자성을 확보하고, 부하 테스트로 효과를 검증했다. 하지만 이 구조에는 아직 풀지 않은 숙제들이 있다. 대기 인원 50명이 동시에 입장하면서 발생하는 부하, 대기 순번 확인을 위한 polling 부하, 그리고 Redis 자체가 죽었을 때의 문제.&amp;nbsp;이 문제들은 &lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;a style=&quot;color: #006dd7 !important;&quot; href=&quot;https://snvlqkq.tistory.com/61&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;대기열, 시스템을 보호하라 (2)&lt;/a&gt;&lt;/span&gt; &lt;span style=&quot;color: #009a87;&quot;&gt;&lt;u&gt;&lt;/u&gt;&lt;/span&gt;에서 다룬다.&lt;/p&gt;</description>
      <category>데이터베이스</category>
      <author>snvlqkq</author>
      <guid isPermaLink="true">https://snvlqkq.tistory.com/60</guid>
      <comments>https://snvlqkq.tistory.com/60#entry60comment</comments>
      <pubDate>Sun, 12 Apr 2026 17:40:26 +0900</pubDate>
    </item>
    <item>
      <title>Redis Sorted Set 명령어 정리</title>
      <link>https://snvlqkq.tistory.com/59</link>
      <description>&lt;h2&gt;Sorted Set이란?&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Sorted Set&lt;/code&gt;은 Redis 자료구조 중 하나로 하나의 키에 여러 score와 member로 구성된 데이터를 관리한다. 각 &lt;code&gt;member&lt;/code&gt;에 실수값인 &lt;code&gt;score&lt;/code&gt;를 부여하며 &lt;code&gt;score&lt;/code&gt;를 기준으로 정렬된 상태를 유지하는 집합이다. &lt;code&gt;member&lt;/code&gt;는 고유값이며 서로 다른 &lt;code&gt;member&lt;/code&gt;는 동일한 &lt;code&gt;score&lt;/code&gt;를 가질 수 있다. &lt;code&gt;score&lt;/code&gt;가 동일한 경우 &lt;code&gt;member&lt;/code&gt;의 사전순(lexicographical order)으로 정렬된다.&lt;/p&gt;
&lt;h3&gt;문법 표기법&lt;/h3&gt;
&lt;p&gt;아래 문법 설명 중 옵션 표기에 대한 설명이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;[]&lt;/code&gt;로 감싼 항목은 선택적(optional) 옵션이며 생략 가능하다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;|&lt;/code&gt;는 둘 중 하나만 선택 가능함을 의미한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;학생 성적 예시&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;key&lt;/th&gt;
&lt;th&gt;score&lt;/th&gt;
&lt;th&gt;member&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;exam_results&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;charlie&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exam_results&lt;/td&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;td&gt;bob&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exam_results&lt;/td&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;td&gt;dave&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exam_results&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;td&gt;alice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;ZADD&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;의미&lt;/strong&gt;: 멤버를 추가하거나 score를 갱신한다. 반환 값은 새로 추가된 멤버 수&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문법&lt;/strong&gt;: &lt;code&gt;ZADD key [NX|XX] [GT|LT] [CH] score member&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;옵션은 key 뒤 score 앞에 지정 가능 (&lt;code&gt;|&lt;/code&gt; 기준 하나의 옵션만 지정 가능)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NX&lt;/code&gt;(Not eXists) — 멤버가 존재하지 않을 때만 추가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;XX&lt;/code&gt;(eXists) — 멤버가 이미 존재할 때만 갱신&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GT&lt;/code&gt;(Greater Than) — 새 score가 기존보다 클 때만 갱신 (member가 존재하지 않은 경우 새로 추가)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LT&lt;/code&gt;(Less Than) — 새 score가 기존보다 작을 때만 갱신 (member가 존재하지 않은 경우 새로 추가)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CH&lt;/code&gt;(Changed) — 반환 값을 새로 추가된 멤버 수 + score가 바뀐 멤버 수로 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;예시&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-redis&quot;&gt;ZADD exam_results 85 &amp;quot;bob&amp;quot;
ZADD exam_results NX 85 &amp;quot;bob&amp;quot;
ZADD exam_results XX GT 90 &amp;quot;bob&amp;quot;
ZADD exam_results XX GT CH 90 &amp;quot;bob&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;ZINCRBY&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;의미&lt;/strong&gt;: 멤버의 score를 지정한 값만큼 증가시킨다. 멤버가 없으면 해당 score로 새로 추가한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문법&lt;/strong&gt;: &lt;code&gt;ZINCRBY key increment member&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;예시&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-redis&quot;&gt;ZADD exam_results 85 &amp;quot;bob&amp;quot;

# score 증가
ZINCRBY exam_results 10 &amp;quot;bob&amp;quot;
# 85 + 10 = 95

# 음수로 감소도 가능
ZINCRBY exam_results -20 &amp;quot;bob&amp;quot;
# 95 - 20 = 75

# 존재하지 않는 멤버 → score 0에서 시작하여 increment 적용
ZINCRBY exam_results 88 &amp;quot;alice&amp;quot;
# 0 + 88 (새로 추가)&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;ZSCORE&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;의미&lt;/strong&gt;: 멤버의 score를 반환한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문법&lt;/strong&gt;: &lt;code&gt;ZSCORE key member&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;예시&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-redis&quot;&gt;ZADD exam_results 85 &amp;quot;bob&amp;quot; 93 &amp;quot;alice&amp;quot;

ZSCORE exam_results &amp;quot;bob&amp;quot;
# &amp;quot;85&amp;quot;

# 존재하지 않는 멤버
ZSCORE exam_results &amp;quot;unknown&amp;quot;
# (nil)&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;ZCARD&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;의미&lt;/strong&gt;: Sorted Set의 전체 멤버 수를 반환한다. (CARD = Cardinality, 집합의 원소 수)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문법&lt;/strong&gt;: &lt;code&gt;ZCARD key&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;예시&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-redis&quot;&gt;ZADD exam_results 85 &amp;quot;bob&amp;quot; 93 &amp;quot;alice&amp;quot; 72 &amp;quot;charlie&amp;quot;

ZCARD exam_results
# (integer) 3&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;ZRANK&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;의미&lt;/strong&gt;: score 오름차순 기준 멤버의 순위를 반환한다. 0부터 반환한다. 멤버가 존재하지 않으면 &lt;code&gt;(nil)&lt;/code&gt;을 반환한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문법&lt;/strong&gt;: &lt;code&gt;ZRANK key member&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;예시&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-redis&quot;&gt;ZADD exam_results 72 &amp;quot;charlie&amp;quot; 85 &amp;quot;bob&amp;quot; 93 &amp;quot;alice&amp;quot;

# charlie가 score 최저 → 오름차순 0등
ZRANK exam_results &amp;quot;charlie&amp;quot;
# (integer) 0

ZRANK exam_results &amp;quot;bob&amp;quot;
# (integer) 1

ZRANK exam_results &amp;quot;alice&amp;quot;
# (integer) 2

# 존재하지 않는 멤버
ZRANK exam_results &amp;quot;unknown&amp;quot;
# (nil)&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;ZREVRANK&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;의미&lt;/strong&gt;: score 내림차순 기준 멤버의 순위를 반환한다. (REV = Reverse) 0부터 반환한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문법&lt;/strong&gt;: &lt;code&gt;ZREVRANK key member&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;예시&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-redis&quot;&gt;ZADD exam_results 72 &amp;quot;charlie&amp;quot; 85 &amp;quot;bob&amp;quot; 93 &amp;quot;alice&amp;quot;

# alice가 score 최고 → 내림차순 0등
ZREVRANK exam_results &amp;quot;alice&amp;quot;
# (integer) 0

ZREVRANK exam_results &amp;quot;bob&amp;quot;
# (integer) 1

ZREVRANK exam_results &amp;quot;charlie&amp;quot;
# (integer) 2&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;ZRANGE&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;의미&lt;/strong&gt;: score 오름차순으로 인덱스 범위의 멤버를 조회한다. stop이 -1이면 끝까지 조회. Redis 6.2부터 &lt;code&gt;BYSCORE&lt;/code&gt;, &lt;code&gt;BYLEX&lt;/code&gt;, &lt;code&gt;REV&lt;/code&gt;, &lt;code&gt;LIMIT&lt;/code&gt; 옵션이 추가되어 &lt;code&gt;ZREVRANGE&lt;/code&gt;, &lt;code&gt;ZRANGEBYSCORE&lt;/code&gt;, &lt;code&gt;ZREVRANGEBYSCORE&lt;/code&gt; 등을 대체하는 통합 명령어가 되었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문법&lt;/strong&gt;: &lt;code&gt;ZRANGE key min max [BYSCORE | BYLEX] [REV] [LIMIT offset count] [WITHSCORES]&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;BYSCORE&lt;/code&gt; — min, max를 score 범위로 해석. &lt;code&gt;+inf&lt;/code&gt;, &lt;code&gt;-inf&lt;/code&gt;로 무한대 지정 가능. &lt;code&gt;(&lt;/code&gt;를 접두어로 붙이면 해당 값 미포함(exclusive)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BYLEX&lt;/code&gt; — min, max를 사전순 범위로 해석. &lt;code&gt;[&lt;/code&gt;는 포함, &lt;code&gt;(&lt;/code&gt;는 미포함, &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;는 최대/최소&lt;/li&gt;
&lt;li&gt;&lt;code&gt;REV&lt;/code&gt; — 내림차순 정렬 (&lt;code&gt;ZREVRANGE&lt;/code&gt; 대체)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LIMIT offset count&lt;/code&gt; — &lt;code&gt;BYSCORE&lt;/code&gt; 또는 &lt;code&gt;BYLEX&lt;/code&gt;와 함께 사용. offset만큼 건너뛴 후 count개 반환&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WITHSCORES&lt;/code&gt; — 멤버와 함께 score도 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;예시&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-redis&quot;&gt;ZADD exam_results 72 &amp;quot;charlie&amp;quot; 85 &amp;quot;bob&amp;quot; 85 &amp;quot;dave&amp;quot; 93 &amp;quot;alice&amp;quot;

# 기본: 오름차순 전체 조회
ZRANGE exam_results 0 -1 WITHSCORES
# 1) &amp;quot;charlie&amp;quot;  2) &amp;quot;72&amp;quot;
# 3) &amp;quot;bob&amp;quot;      4) &amp;quot;85&amp;quot;
# 5) &amp;quot;dave&amp;quot;     6) &amp;quot;85&amp;quot;
# 7) &amp;quot;alice&amp;quot;    8) &amp;quot;93&amp;quot;

# 내림차순 전체 조회 (ZREVRANGE 대체)
ZRANGE exam_results 0 -1 REV WITHSCORES
# 1) &amp;quot;alice&amp;quot;    2) &amp;quot;93&amp;quot;
# 3) &amp;quot;dave&amp;quot;     4) &amp;quot;85&amp;quot;
# 5) &amp;quot;bob&amp;quot;      6) &amp;quot;85&amp;quot;
# 7) &amp;quot;charlie&amp;quot;  8) &amp;quot;72&amp;quot;

# score 범위 조회 (80~95점)
ZRANGE exam_results 80 95 BYSCORE WITHSCORES
# 1) &amp;quot;bob&amp;quot;    2) &amp;quot;85&amp;quot;
# 3) &amp;quot;dave&amp;quot;   4) &amp;quot;85&amp;quot;
# 5) &amp;quot;alice&amp;quot;  6) &amp;quot;93&amp;quot;

# score 범위 + exclusive (80 초과 ~ 95 미만)
ZRANGE exam_results (80 (95 BYSCORE WITHSCORES
# 1) &amp;quot;bob&amp;quot;   2) &amp;quot;85&amp;quot;
# 3) &amp;quot;dave&amp;quot;  4) &amp;quot;85&amp;quot;

# score 범위 + LIMIT (상위 2명)
ZRANGE exam_results -inf +inf BYSCORE LIMIT 0 2
# 1) &amp;quot;charlie&amp;quot;
# 2) &amp;quot;bob&amp;quot;

# 사전순 범위 조회 (score가 동일한 멤버 중 필터링)
ZRANGE exam_results [b [d BYLEX
# 1) &amp;quot;bob&amp;quot;
# 2) &amp;quot;charlie&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;ZREVRANGE&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;의미&lt;/strong&gt;: score 내림차순으로 인덱스 범위의 멤버를 조회한다. stop이 -1이면 끝까지 조회. Redis 6.2부터 deprecated되었으며 &lt;code&gt;ZRANGE&lt;/code&gt;에 &lt;code&gt;REV&lt;/code&gt; 옵션으로 대체 가능하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문법&lt;/strong&gt;: &lt;code&gt;ZREVRANGE key start stop [WITHSCORES]&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;예시&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-redis&quot;&gt;ZADD exam_results 72 &amp;quot;charlie&amp;quot; 85 &amp;quot;bob&amp;quot; 93 &amp;quot;alice&amp;quot;

# 전체 조회 (내림차순)
ZREVRANGE exam_results 0 -1 WITHSCORES
# 1) &amp;quot;alice&amp;quot;
# 2) &amp;quot;93&amp;quot;
# 3) &amp;quot;bob&amp;quot;
# 4) &amp;quot;85&amp;quot;
# 5) &amp;quot;charlie&amp;quot;
# 6) &amp;quot;72&amp;quot;

# 상위 2명만
ZREVRANGE exam_results 0 1
# 1) &amp;quot;alice&amp;quot;
# 2) &amp;quot;bob&amp;quot;

# ZRANGE REV로 대체
ZRANGE exam_results 0 -1 REV WITHSCORES&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;ZREM&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;의미&lt;/strong&gt;: 지정된 멤버를 삭제한다. (REM = Remove) 실제로 삭제된 멤버 수가 반환된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문법&lt;/strong&gt;: &lt;code&gt;ZREM key member [member ...]&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;예시&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-redis&quot;&gt;ZADD exam_results 72 &amp;quot;charlie&amp;quot; 85 &amp;quot;bob&amp;quot; 93 &amp;quot;alice&amp;quot;

# 단일 삭제
ZREM exam_results &amp;quot;charlie&amp;quot;
# (integer) 1

# 여러 멤버 삭제
ZREM exam_results &amp;quot;bob&amp;quot; &amp;quot;alice&amp;quot;
# (integer) 2

# 존재하지 않는 멤버 삭제 시도
ZREM exam_results &amp;quot;unknown&amp;quot;
# (integer) 0&lt;/code&gt;&lt;/pre&gt;</description>
      <category>데이터베이스</category>
      <author>snvlqkq</author>
      <guid isPermaLink="true">https://snvlqkq.tistory.com/59</guid>
      <comments>https://snvlqkq.tistory.com/59#entry59comment</comments>
      <pubDate>Sat, 11 Apr 2026 14:23:35 +0900</pubDate>
    </item>
  </channel>
</rss>