<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>기억의 파편들</title>
    <link>https://pulpul8282.tistory.com/</link>
    <description>초보 개발자의 기억의 저장소입니다!</description>
    <language>ko</language>
    <pubDate>Sun, 9 Aug 2026 23:50:22 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>추억을 백앤드하자</managingEditor>
    <image>
      <title>기억의 파편들</title>
      <url>https://tistory1.daumcdn.net/tistory/4207847/attach/032b48d7c6104ef28c6b4342c03e532f</url>
      <link>https://pulpul8282.tistory.com</link>
    </image>
    <item>
      <title>Valkey 장애 복구의 내부 원리: RDB&amp;middot;AOF&amp;middot;Replication&amp;middot;Sentinel&amp;middot;Cluster Failover</title>
      <link>https://pulpul8282.tistory.com/453</link>
      <description>&lt;div class=&quot;markdown-body&quot;&gt;
&lt;h1&gt;Valkey 장애 복구의 내부 원리: RDB·AOF·Replication·Sentinel·Cluster Failover&lt;/h1&gt;
&lt;p&gt;Valkey는 데이터를 메모리에 저장하기 때문에 빠른 읽기·쓰기 성능을 제공한다.&lt;/p&gt;
&lt;p&gt;하지만 데이터를 메모리에 보관한다는 설명만 들으면 다음과 같은 의문이 생긴다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 프로세스가 종료되면 데이터는 모두 사라지는가?
서버가 재부팅되면 메모리 데이터를 어떻게 복구하는가?
Primary가 장애 나면 Replica가 자동으로 전환되는가?
Failover가 완료되면 애플리케이션은 어떻게 새로운 Primary를 찾는가?
복제본이 있다면 데이터 유실은 발생하지 않는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Valkey의 장애 복구는 하나의 기능으로 이루어지는 것이 아니다.&lt;/p&gt;
&lt;p&gt;다음 세 가지 계층이 서로 다른 역할을 수행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Persistence
→ 프로세스 재시작 후 데이터를 디스크에서 복원

Replication
→ Primary의 데이터를 Replica에 복제

Sentinel 또는 Cluster
→ Primary 장애를 감지하고 Replica를 새로운 Primary로 승격&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, Persistence는 &lt;strong&gt;데이터 복구&lt;/strong&gt;, Replication은 &lt;strong&gt;복제본 유지&lt;/strong&gt;, Sentinel과 Cluster는 &lt;strong&gt;서비스 가용성 복구&lt;/strong&gt;를 담당한다.&lt;/p&gt;
&lt;p&gt;이 글에서는 Valkey 장애 상황을 유형별로 나누고, RDB·AOF·Replication·Sentinel·Cluster가 내부적으로 어떻게 복구를 수행하는지 정리한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 먼저 장애 유형을 구분해야 한다&lt;/h2&gt;
&lt;p&gt;Valkey 장애라고 해도 원인은 다양하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 프로세스 비정상 종료
운영체제 재부팅
서버 장비 장애
디스크 장애
Primary 노드 장애
Primary와 Replica 사이 네트워크 단절
Cluster 일부 노드 장애
데이터 오삭제 또는 논리적 장애&lt;/code&gt;&lt;/pre&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;주요 복구 수단&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;프로세스 종료&lt;/td&gt;
&lt;td&gt;RDB 또는 AOF 로딩&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버 재부팅&lt;/td&gt;
&lt;td&gt;Persistence 파일 복원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Primary 장애&lt;/td&gt;
&lt;td&gt;Sentinel 또는 Cluster Failover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;복제 연결 단절&lt;/td&gt;
&lt;td&gt;Partial 또는 Full Resynchronization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;디스크 파일 손상&lt;/td&gt;
&lt;td&gt;백업 복원, AOF 점검 도구&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 오삭제&lt;/td&gt;
&lt;td&gt;별도 백업에서 복원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cluster 노드 장애&lt;/td&gt;
&lt;td&gt;Replica 승격 및 Slot 소유권 변경&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;여기서 중요한 점은 &lt;strong&gt;고가용성과 데이터 영속성은 서로 다른 문제&lt;/strong&gt;라는 것이다.&lt;/p&gt;
&lt;p&gt;Replica가 있다고 해서 반드시 모든 데이터가 보존되는 것은 아니며, AOF가 있다고 해서 서비스가 자동으로 다른 서버로 전환되는 것도 아니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;2. Valkey 데이터는 기본적으로 메모리에 존재한다&lt;/h1&gt;
&lt;p&gt;Valkey의 실제 데이터 조회와 변경은 메모리에서 수행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Client
   ↓
Valkey Command
   ↓
Memory Dataset 변경
   ↓
Persistence 및 Replication 처리&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 다음 명령을 실행하면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SET user:1001:name &amp;quot;JY&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;우선 Valkey 프로세스의 메모리 데이터가 변경된다.&lt;/p&gt;
&lt;p&gt;이후 설정에 따라 다음 작업이 추가로 수행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;RDB
→ 특정 시점의 전체 데이터 스냅샷 생성

AOF
→ 실행된 쓰기 명령을 로그에 기록

Replication
→ 동일한 변경 명령을 Replica로 전달&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 메모리 데이터와 디스크 Persistence 파일, Replica 데이터 사이에는 짧은 시간 차이가 존재할 수 있다.&lt;/p&gt;
&lt;p&gt;이 시간 차이가 장애 시 데이터 유실 범위를 결정한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;3. RDB 기반 복구 원리&lt;/h1&gt;
&lt;p&gt;RDB는 특정 시점의 전체 데이터셋을 바이너리 파일로 저장하는 방식이다.&lt;/p&gt;
&lt;p&gt;기본 파일명은 일반적으로 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;dump.rdb&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RDB는 다음과 같이 설정할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;save 900 1
save 300 10
save 60 10000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;save 60 10000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;은 60초 동안 10,000개 이상의 키 변경이 발생하면 스냅샷을 생성한다는 의미다.&lt;/p&gt;
&lt;p&gt;Valkey는 지정된 조건을 만족하면 전체 메모리 데이터셋의 시점 복사본을 RDB 파일로 저장한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3.1 BGSAVE 내부 동작&lt;/h2&gt;
&lt;p&gt;운영 환경에서는 일반적으로 &lt;code&gt;SAVE&lt;/code&gt;가 아니라 &lt;code&gt;BGSAVE&lt;/code&gt; 방식이 사용된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;BGSAVE&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SAVE&lt;/code&gt;는 메인 프로세스가 직접 디스크 저장을 수행하기 때문에 저장이 완료될 때까지 명령 처리가 중단될 수 있다.&lt;/p&gt;
&lt;p&gt;반면 &lt;code&gt;BGSAVE&lt;/code&gt;는 자식 프로세스를 생성해 저장 작업을 수행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey Parent Process
        │
        ├─ fork()
        │
        ├─ Parent: 클라이언트 명령 계속 처리
        │
        └─ Child: 메모리 데이터를 RDB 파일로 저장&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;운영체제의 &lt;code&gt;fork()&lt;/code&gt;가 실행되면 부모와 자식 프로세스는 논리적으로 같은 메모리 내용을 가진다.&lt;/p&gt;
&lt;p&gt;하지만 전체 메모리를 즉시 복사하는 것이 아니라 &lt;strong&gt;Copy-on-Write&lt;/strong&gt; 방식으로 메모리 페이지를 공유한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;fork 직후

Parent ─┐
        ├─ 동일한 메모리 페이지 공유
Child  ─┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이후 부모 프로세스가 특정 메모리 페이지를 변경하면 해당 페이지만 복사된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;쓰기 발생

Parent → 변경된 새 페이지
Child  → fork 당시의 기존 페이지&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 자식 프로세스는 스냅샷 시작 시점의 데이터를 기준으로 RDB를 작성할 수 있고, 부모 프로세스는 계속 새로운 요청을 처리할 수 있다.&lt;/p&gt;
&lt;p&gt;다만 쓰기 트래픽이 많은 상태에서 &lt;code&gt;BGSAVE&lt;/code&gt;가 실행되면 변경되는 메모리 페이지가 많아져 Copy-on-Write 메모리 사용량이 증가할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;기존 Dataset Memory
+ 변경된 Copy-on-Write Page
+ Valkey 자체 메모리&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 RDB 저장 중 메모리 사용량과 Fork 지연을 모니터링해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3.2 RDB 파일 생성 과정&lt;/h2&gt;
&lt;p&gt;RDB 저장은 대략 다음 순서로 진행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Parent가 fork() 실행
2. Child가 메모리 데이터 순회
3. Child가 임시 RDB 파일 생성
4. 데이터 직렬화 및 디스크 기록
5. 기록 완료 후 기존 RDB 파일과 교체
6. Child 프로세스 종료&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;임시 파일을 먼저 생성하는 이유는 저장 도중 장애가 발생했을 때 기존 정상 RDB 파일이 손상되는 것을 방지하기 위해서다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;저장 성공
→ 새 RDB 파일로 원자적 교체

저장 실패
→ 기존 RDB 파일 유지&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;3.3 서버 재시작 시 RDB 복구&lt;/h2&gt;
&lt;p&gt;Valkey 프로세스가 다시 시작되면 디스크에 있는 RDB 파일을 읽어 메모리 데이터 구조를 다시 생성한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 시작
   ↓
dump.rdb 읽기
   ↓
RDB 바이너리 파싱
   ↓
Key, Value, TTL 복원
   ↓
Memory Dataset 재구성
   ↓
클라이언트 요청 수신&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RDB는 전체 데이터셋이 하나의 압축된 바이너리 파일로 저장되기 때문에 일반적으로 복원 속도가 빠른 편이다.&lt;/p&gt;
&lt;p&gt;하지만 마지막 RDB 생성 이후에 변경된 데이터는 복원할 수 없다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10:00 RDB 생성
10:01 데이터 변경
10:02 데이터 변경
10:03 서버 장애&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;재시작 시 10:00 시점의 데이터만 복원되므로 10:00 이후 변경분은 유실될 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 RDB 단독 사용은 일정 범위의 데이터 유실을 허용할 수 있는 캐시, 세션, 재생성 가능한 데이터에 적합하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;4. AOF 기반 복구 원리&lt;/h1&gt;
&lt;p&gt;AOF는 Append Only File의 약자로, Valkey가 처리한 쓰기 명령을 로그 형태로 기록한다.&lt;/p&gt;
&lt;p&gt;예를 들어 다음 명령이 실행되었다면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SET account:100:status ACTIVE
INCR account:100:login-count
EXPIRE account:100:session 3600&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AOF에는 이 변경을 재현할 수 있는 명령이 기록된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;SET account:100:status ACTIVE
INCR account:100:login-count
EXPIRE account:100:session 3600&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;서버가 재시작되면 AOF 명령을 처음부터 순서대로 재실행해 메모리 상태를 복원한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 시작
   ↓
AOF 파일 읽기
   ↓
쓰기 명령 순차 Replay
   ↓
Memory Dataset 재구성&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AOF는 Valkey 프로토콜과 동일한 형태로 쓰기 명령을 기록하고, 시작 시 해당 명령을 재생하여 데이터셋을 복원한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4.1 AOF 설정&lt;/h2&gt;
&lt;p&gt;AOF는 다음과 같이 활성화할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;appendonly yes
appendfilename &amp;quot;appendonly.aof&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Valkey가 운영체제의 디스크에 데이터를 동기화하는 정책은 &lt;code&gt;appendfsync&lt;/code&gt;로 설정한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;appendfsync always
appendfsync everysec
appendfsync no&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;always&lt;/h3&gt;
&lt;p&gt;매 쓰기 명령마다 &lt;code&gt;fsync&lt;/code&gt;를 수행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Client Write
→ AOF Buffer
→ write()
→ fsync()
→ 응답&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;데이터 유실 가능성은 가장 작지만 디스크 동기화 비용 때문에 쓰기 성능이 저하될 수 있다.&lt;/p&gt;
&lt;h3&gt;everysec&lt;/h3&gt;
&lt;p&gt;일반적으로 가장 많이 사용하는 절충안이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Client Write
→ AOF Buffer
→ write()
→ 백그라운드에서 약 1초 단위 fsync&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;성능과 내구성의 균형이 좋지만 장애 시 최근 약 1초 범위의 데이터가 유실될 수 있다. Valkey 공식 문서도 &lt;code&gt;everysec&lt;/code&gt; 정책에서 일반적으로 최대 약 1초의 쓰기 유실 가능성을 설명한다.&lt;/p&gt;
&lt;h3&gt;no&lt;/h3&gt;
&lt;p&gt;Valkey가 직접 &lt;code&gt;fsync&lt;/code&gt; 시점을 제어하지 않고 운영체제에 맡긴다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey write()
→ OS Page Cache
→ 운영체제가 적절한 시점에 Disk Flush&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;성능은 좋을 수 있지만 장애 시 유실 범위가 커질 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4.2 write와 fsync의 차이&lt;/h2&gt;
&lt;p&gt;애플리케이션에서 파일에 &lt;code&gt;write()&lt;/code&gt;했다고 해서 데이터가 실제 디스크 플래터나 SSD에 즉시 기록된 것은 아니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey Process
   ↓ write()
OS Page Cache
   ↓ fsync()
Storage Device&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;write()&lt;/code&gt;는 데이터를 운영체제의 페이지 캐시에 복사하는 수준에서 완료될 수 있다.&lt;/p&gt;
&lt;p&gt;운영체제가 비정상 종료되거나 전원이 차단되면 페이지 캐시에만 존재하던 데이터는 유실될 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fsync()&lt;/code&gt;는 운영체제에 해당 데이터를 실제 저장 장치까지 동기화하도록 요청한다.&lt;/p&gt;
&lt;p&gt;따라서 AOF 내구성은 단순히 AOF 활성화 여부가 아니라 &lt;code&gt;appendfsync&lt;/code&gt; 정책에 따라 달라진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4.3 AOF Rewrite가 필요한 이유&lt;/h2&gt;
&lt;p&gt;AOF는 모든 쓰기 명령을 계속 추가하기 때문에 시간이 지날수록 파일 크기가 증가한다.&lt;/p&gt;
&lt;p&gt;예를 들어:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SET counter 1
SET counter 2
SET counter 3
SET counter 4
SET counter 5&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;최종 상태는 다음 하나뿐이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;counter = 5&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 기존 AOF에는 이전 변경 이력이 모두 남아 있을 수 있다.&lt;/p&gt;
&lt;p&gt;AOF Rewrite는 현재 데이터셋을 만들기 위해 필요한 최소 명령만으로 새로운 AOF를 생성한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;기존 AOF

SET counter 1
SET counter 2
SET counter 3
SET counter 4
SET counter 5
Rewrite AOF

SET counter 5&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;4.4 Multi-Part AOF Rewrite 내부 동작&lt;/h2&gt;
&lt;p&gt;Valkey의 AOF Rewrite는 일반적인 로그 파일을 단순히 수정하는 방식이 아니다.&lt;/p&gt;
&lt;p&gt;Valkey는 새로운 Base AOF와 Incremental AOF를 조합하는 방식을 사용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;AOF Manifest
 ├─ Base AOF
 ├─ Incremental AOF 1
 └─ Incremental AOF 2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rewrite 과정은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Parent가 fork()
2. Child가 현재 데이터셋으로 새로운 Base AOF 작성
3. Parent는 새로운 Incremental AOF에 이후 변경 기록
4. Child의 Base AOF 작성 완료
5. Base AOF와 Incremental AOF를 Manifest에 반영
6. 기존 불필요한 AOF 파일 정리&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Valkey는 AOF Rewrite 중에도 기존 AOF 기록을 유지하며, 자식 프로세스가 새로운 Base 파일을 생성하는 동안 부모는 새 Incremental 파일에 변경분을 기록한다. Rewrite 실패 시 기존 파일과 Incremental 파일을 이용할 수 있도록 설계되어 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4.5 AOF 손상 시 복구&lt;/h2&gt;
&lt;p&gt;프로세스 강제 종료나 디스크 문제로 AOF 마지막 부분이 불완전하게 기록될 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;정상 명령
정상 명령
정상 명령
불완전하게 기록된 마지막 명령&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Valkey는 시작 시 AOF의 유효성을 확인한다.&lt;/p&gt;
&lt;p&gt;파일 끝부분의 일부 명령만 불완전한 경우 설정에 따라 잘못된 마지막 부분을 제거하고 복구할 수 있다.&lt;/p&gt;
&lt;p&gt;관리 도구로 다음 명령도 사용할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;valkey-check-aof --fix appendonly.aof&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 손상 지점 이후의 데이터는 제거될 수 있으므로 실행 전 원본 파일을 별도 백업하는 것이 안전하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cp appendonly.aof appendonly.aof.backup
valkey-check-aof --fix appendonly.aof&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AOF 손상이 파일 앞부분에서 발생했다면 손상 이후의 많은 로그를 사용할 수 없으므로 데이터 유실 범위가 커질 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;5. RDB와 AOF를 함께 사용하는 이유&lt;/h1&gt;
&lt;p&gt;운영 환경에서는 RDB와 AOF를 함께 활성화하는 구성을 고려할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;save 900 1
save 300 10
save 60 10000

appendonly yes
appendfsync everysec&lt;/code&gt;&lt;/pre&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;RDB&lt;/th&gt;
&lt;th&gt;AOF&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;/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;fsync 정책에 따라 결정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;파일 크기&lt;/td&gt;
&lt;td&gt;상대적으로 작음&lt;/td&gt;
&lt;td&gt;Rewrite 전까지 커질 수 있음&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;/tr&gt;
&lt;tr&gt;
&lt;td&gt;쓰기 부하&lt;/td&gt;
&lt;td&gt;스냅샷 시 Fork·I/O&lt;/td&gt;
&lt;td&gt;지속적 Append·fsync&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;RDB는 빠른 재시작과 백업에 유리하고, AOF는 더 작은 데이터 유실 범위를 제공한다.&lt;/p&gt;
&lt;p&gt;Valkey 공식 문서 역시 AOF만 사용하는 것보다 주기적인 RDB 스냅샷을 함께 유지하는 것이 백업, 빠른 복원, AOF 엔진 문제 대응 측면에서 유용하다고 설명한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;6. 복제는 어떻게 동작하는가&lt;/h1&gt;
&lt;p&gt;Valkey Replication은 Primary의 데이터 변경을 Replica에 복제하는 구조다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Client
   ↓ Write
Primary
   ↓ Replication Stream
Replica 1
Replica 2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Primary와 Replica 연결이 정상이라면 Primary는 다음 변경을 Replica에 명령 스트림으로 전달한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;클라이언트 쓰기
키 만료
키 Eviction
데이터를 변경하는 기타 명령&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Valkey 복제는 기본적으로 비동기 방식이다. Primary는 일반적으로 Replica가 실제 변경을 반영할 때까지 기다리지 않고 클라이언트에 성공을 응답할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6.1 Replication ID와 Offset&lt;/h2&gt;
&lt;p&gt;각 Primary는 다음 정보를 유지한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Replication ID
Replication Offset&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replication ID는 해당 데이터셋 변경 이력의 계보를 식별한다.&lt;/p&gt;
&lt;p&gt;Replication Offset은 Primary가 생성한 복제 스트림의 바이트 위치를 나타낸다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary
Replication ID: abc123
Current Offset: 1,500,000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replica도 자신이 어디까지 처리했는지 기록한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Replica
Replication ID: abc123
Processed Offset: 1,490,000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우 Replica는 Primary보다 10,000바이트 뒤처져 있는 상태다.&lt;/p&gt;
&lt;p&gt;Valkey Primary는 데이터 변경을 복제 스트림으로 생성할 때마다 Offset을 증가시키며, Replica는 자신이 처리한 위치를 추적한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;7. Replica 연결 복구: Partial Resynchronization&lt;/h1&gt;
&lt;p&gt;Primary와 Replica 사이 네트워크가 잠시 끊겼다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary Offset: 1000
Replica Offset: 900

네트워크 단절
Primary Offset: 1300
Replica Offset: 900&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replica가 다시 연결되면 자신이 알고 있는 Replication ID와 Offset을 Primary에 전달한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Replica
→ 나는 replication ID abc123의 offset 900까지 가지고 있다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Primary가 901부터 1300까지의 복제 데이터를 Backlog에 보관하고 있다면 누락된 부분만 전송한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary
→ Offset 901~1300 전달

Replica
→ 누락 데이터 적용
→ 다시 실시간 복제 상태 전환&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이것이 Partial Resynchronization이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;전체 Dataset 재전송: 불필요
누락된 Replication Stream만 전송&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;네트워크가 짧게 단절된 경우 전체 RDB 복사 없이 빠르게 복구할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7.1 Replication Backlog&lt;/h2&gt;
&lt;p&gt;Partial Resynchronization을 위해 Primary는 최근 복제 명령을 메모리의 원형 버퍼에 보관한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Replication Backlog

[old data ... recent data ... newest data]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replica의 Offset이 Backlog 범위 안에 있으면 부분 동기화가 가능하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Backlog 범위: 500~1500
Replica Offset: 900

→ Partial Sync 가능&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 Replica가 너무 오래 단절되어 필요한 Offset이 Backlog에서 사라졌다면 부분 동기화가 불가능하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Backlog 범위: 1000~2000
Replica Offset: 900

→ Offset 901~999 없음
→ Full Resynchronization 필요&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 복제 Backlog 크기는 네트워크 단절 시간과 쓰기 처리량을 고려해 설정해야 한다.&lt;/p&gt;
&lt;p&gt;대략적으로는 다음과 같이 판단할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;필요 Backlog 크기
≈ 초당 복제 데이터량 × 허용 단절 시간&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 초당 5MB의 쓰기가 발생하고 60초 단절까지 Partial Sync로 복구하려면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;5MB × 60초 = 약 300MB&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기에 트래픽 변동과 여유분을 추가해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;8. Full Resynchronization 내부 원리&lt;/h1&gt;
&lt;p&gt;Partial Sync가 불가능하면 Primary는 Replica에 전체 데이터셋을 다시 전송한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Replica가 Primary에 연결
2. Partial Sync 불가능 판단
3. Primary가 RDB 스냅샷 생성
4. RDB를 Replica에 전송
5. Replica가 기존 데이터 제거
6. Replica가 RDB 로딩
7. RDB 생성·전송 중 발생한 후속 명령 적용
8. 실시간 복제 전환&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;개념적으로는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary Memory
   ↓ Snapshot
RDB Stream
   ↓ Network
Replica
   ↓ Load
Replica Memory Dataset&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Full Sync 중에도 Primary에는 새로운 쓰기가 계속 들어올 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 Primary는 RDB 생성 이후 발생한 변경 명령을 별도로 버퍼링했다가 RDB 전송이 끝난 후 Replica에 전달한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;RDB 기준 시점
   ↓
RDB 전송 중 발생한 추가 쓰기
   ↓
후속 Replication Stream 전송&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Full Resynchronization은 CPU, 네트워크, 디스크 및 Copy-on-Write 메모리 사용량을 증가시킬 수 있다.&lt;/p&gt;
&lt;p&gt;Replica가 여러 대 동시에 Full Sync를 수행하면 Primary 부하가 급증할 수 있으므로 운영 시 주의해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;9. 복제본이 있어도 데이터가 유실될 수 있는 이유&lt;/h1&gt;
&lt;p&gt;Valkey 복제는 기본적으로 비동기다.&lt;/p&gt;
&lt;p&gt;다음 상황을 생각해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Client가 Primary에 SET 요청
2. Primary Memory에 반영
3. Primary가 Client에 성공 응답
4. Replica로 복제되기 전 Primary 장애
5. Replica가 새로운 Primary로 승격&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;새로운 Primary에는 해당 쓰기가 존재하지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Client 관점
→ 성공했다고 응답받음

새 Primary 관점
→ 해당 데이터 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, 복제본이 있다고 해서 Zero Data Loss가 자동 보장되는 것은 아니다.&lt;/p&gt;
&lt;p&gt;Valkey 공식 문서는 비동기 복제 특성상 Failover 시 승인된 쓰기가 유실될 수 있으며, &lt;code&gt;WAIT&lt;/code&gt; 명령도 유실 가능성을 낮출 뿐 강한 일관성 시스템으로 바꾸지는 않는다고 설명한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;9.1 WAIT 명령&lt;/h2&gt;
&lt;p&gt;중요한 쓰기 이후 일정 수 이상의 Replica가 복제했는지 기다릴 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SET payment:1001:status COMPLETED
WAIT 1 1000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;의미는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;최소 1개 Replica가 복제를 확인할 때까지
최대 1,000ms 대기&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 &lt;code&gt;WAIT&lt;/code&gt;도 완전한 합의 프로토콜은 아니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Replica가 쓰기를 확인
→ 아직 디스크 fsync 전일 수 있음
→ Primary와 Replica가 동시에 장애 날 수 있음&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 중요 데이터의 최종 정합성은 관계형 데이터베이스나 별도 영속 저장소에서 보장하고, Valkey는 캐시·락·빠른 상태 조회 용도로 사용하는 것이 일반적으로 안전하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;10. Sentinel 기반 자동 장애 복구&lt;/h1&gt;
&lt;p&gt;Sentinel은 Valkey Cluster를 사용하지 않는 Primary-Replica 구조에서 고가용성을 제공한다.&lt;/p&gt;
&lt;p&gt;Sentinel의 주요 역할은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Monitoring
Notification
Automatic Failover
Service Discovery&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sentinel은 Primary와 Replica 상태를 지속적으로 확인하고, Primary 장애가 확정되면 Replica 하나를 새로운 Primary로 승격한다. 또한 클라이언트가 현재 Primary 주소를 조회할 수 있는 설정 제공자 역할도 한다.&lt;/p&gt;
&lt;p&gt;일반적인 구성은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Sentinel 1 ─┐
Sentinel 2 ─┼─ Primary 감시
Sentinel 3 ─┘

Primary
 ├─ Replica 1
 └─ Replica 2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sentinel도 장애 판단에 참여하므로 일반적으로 홀수 개, 최소 3개 구성을 사용한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;11. Sentinel 장애 감지 내부 원리&lt;/h1&gt;
&lt;p&gt;Sentinel은 주기적으로 Valkey 노드에 &lt;code&gt;PING&lt;/code&gt;을 전송한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Sentinel
   ↓ PING
Primary
   ↑ PONG&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;일정 시간 동안 정상 응답을 받지 못하면 해당 Sentinel은 Primary를 주관적으로 장애 상태라고 판단한다.&lt;/p&gt;
&lt;p&gt;이를 SDOWN, Subjectively Down이라고 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Sentinel 1 관점
Primary 응답 없음
→ SDOWN&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 하나의 Sentinel 판단만으로 즉시 Failover하지는 않는다.&lt;/p&gt;
&lt;p&gt;다른 Sentinel들에게 Primary 상태를 확인한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Sentinel 1 → Primary가 장애라고 보는가?
Sentinel 2 → Yes
Sentinel 3 → Yes&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;설정된 Quorum 이상의 Sentinel이 장애에 동의하면 객관적 장애 상태로 판단한다.&lt;/p&gt;
&lt;p&gt;이를 ODOWN, Objectively Down이라고 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;다수 Sentinel의 동의
→ ODOWN
→ Failover 후보&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 다음 설정이 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;sentinel monitor mymaster 10.0.0.10 6379 2
sentinel down-after-milliseconds mymaster 5000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;의미는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary 이름: mymaster
Primary 주소: 10.0.0.10:6379
Quorum: 2
장애 판단 대기 시간: 5초&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sentinel 공식 문서의 예제에서도 &lt;code&gt;down-after-milliseconds&lt;/code&gt; 동안 &lt;code&gt;PING&lt;/code&gt; 응답이 없으면 장애 판단이 시작되고, Quorum을 기반으로 Failover가 수행된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;12. Sentinel Leader 선출&lt;/h1&gt;
&lt;p&gt;여러 Sentinel이 동시에 Failover를 실행하면 서로 다른 Replica를 Primary로 승격할 수 있다.&lt;/p&gt;
&lt;p&gt;이를 방지하기 위해 Sentinel 중 하나가 해당 Failover 작업의 Leader로 선출된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Sentinel 1 ─┐
Sentinel 2 ─┼─ 투표
Sentinel 3 ─┘
      ↓
Sentinel 2가 Failover Leader&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Leader Sentinel만 Replica 승격과 복제 토폴로지 변경을 주도한다.&lt;/p&gt;
&lt;p&gt;Failover는 대략 다음 순서로 진행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Primary ODOWN 판단
2. Sentinel Leader 선출
3. 승격할 Replica 선택
4. Replica를 새로운 Primary로 승격
5. 다른 Replica들이 새로운 Primary를 바라보도록 재설정
6. 기존 Primary가 복구되면 Replica로 전환
7. 클라이언트에 새로운 Primary 정보 제공&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h1&gt;13. 승격할 Replica는 어떻게 선택하는가&lt;/h1&gt;
&lt;p&gt;Sentinel은 아무 Replica나 무작위로 선택하지 않는다.&lt;/p&gt;
&lt;p&gt;대략적으로 다음 요소를 고려한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Replica 우선순위
Primary와의 연결 상태
최근 통신 상태
Replication Offset
복제 지연 정도&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replication Offset이 크다는 것은 기존 Primary의 변경을 더 많이 반영했다는 의미다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Replica A Offset: 150000
Replica B Offset: 149000
Replica C Offset: 120000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다른 조건이 유사하다면 더 최신 상태인 Replica가 유리하다.&lt;/p&gt;
&lt;p&gt;하지만 비동기 복제이므로 가장 최신 Replica를 선택하더라도 Primary 장애 직전의 모든 데이터가 존재한다고 보장할 수는 없다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;14. Sentinel Failover 이후 애플리케이션 연결&lt;/h1&gt;
&lt;p&gt;서버 측에서 Replica가 Primary로 승격되더라도 애플리케이션이 계속 이전 Primary 주소로 연결하면 서비스를 사용할 수 없다.&lt;/p&gt;
&lt;p&gt;따라서 클라이언트는 Sentinel을 지원해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Application
    ↓ Sentinel 조회
현재 Primary 주소 요청
    ↓
Sentinel
    ↓
10.0.0.12:6379 반환
    ↓
Application이 새 Primary 연결&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sentinel 지원 클라이언트는 보통 다음 순서로 동작한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 설정된 Sentinel 목록 중 하나에 연결
2. SENTINEL get-master-addr-by-name 실행
3. 현재 Primary 주소 획득
4. Primary 연결
5. 연결 오류 발생 시 Sentinel에 다시 조회
6. 주소가 변경되었으면 기존 Pool 폐기
7. 새로운 Primary로 Connection Pool 재생성&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sentinel 클라이언트 명세는 Primary 주소가 변경되면 기존 Pool의 연결을 종료하고 새 Primary로 다시 연결하도록 설명한다.&lt;/p&gt;
&lt;p&gt;Spring Boot에서는 개념적으로 다음과 같이 구성할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  data:
    redis:
      sentinel:
        master: mymaster
        nodes:
          - 10.0.0.21:26379
          - 10.0.0.22:26379
          - 10.0.0.23:26379
      timeout: 2s
      connect-timeout: 1s&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;애플리케이션에 Primary IP를 직접 고정하면 Sentinel Failover의 장점을 활용할 수 없다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Failover 대응에 부적합한 구성
spring:
  data:
    redis:
      host: 10.0.0.10&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h1&gt;15. Failover 중 애플리케이션에서는 무엇이 발생하는가&lt;/h1&gt;
&lt;p&gt;Failover가 즉시 완료되는 것은 아니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary 장애
→ 장애 감지 대기
→ Sentinel 간 합의
→ Leader 선출
→ Replica 승격
→ 클라이언트 재연결&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 과정에서 수초 이상의 연결 오류가 발생할 수 있다.&lt;/p&gt;
&lt;p&gt;애플리케이션에서는 다음 예외가 나타날 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Connection refused
Connection reset
Command timeout
READONLY
Connection closed&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 애플리케이션은 Failover 시간 동안의 실패를 처리해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 명령 실패
   ↓
짧은 Backoff
   ↓
새 Primary 탐색 및 재연결
   ↓
멱등성이 보장되는 명령만 제한적으로 재시도&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;모든 명령을 무조건 재시도하면 안 된다.&lt;/p&gt;
&lt;p&gt;예를 들어 클라이언트가 응답을 받기 전에 연결이 끊겼다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Client → INCR 실행 요청
Primary → 실제 INCR 처리
Primary → 응답 전 장애
Client → Timeout 발생
Client → INCR 재시도&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 값은 두 번 증가할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;원래 의도: +1
실제 결과: +2&lt;/code&gt;&lt;/pre&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;단순 재시도 안전성&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;비교적 안전&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동일 값 &lt;code&gt;SET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;비교적 안전&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;일반적으로 멱등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INCR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;중복 실행 위험&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LPUSH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;중복 삽입 위험&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;재고 차감 Lua&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;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h1&gt;16. Valkey Cluster 장애 복구&lt;/h1&gt;
&lt;p&gt;Valkey Cluster는 데이터를 여러 Primary 노드에 Hash Slot 단위로 분산한다.&lt;/p&gt;
&lt;p&gt;전체 Key Space는 16,384개의 Hash Slot으로 나뉜다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary A → Slot 0~5460
Primary B → Slot 5461~10922
Primary C → Slot 10923~16383&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 Primary는 일반적으로 하나 이상의 Replica를 가진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary A ─ Replica A1
Primary B ─ Replica B1
Primary C ─ Replica C1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cluster는 데이터 분산과 Failover를 함께 제공한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;17. Cluster 노드 장애 감지&lt;/h1&gt;
&lt;p&gt;Cluster 노드들은 Cluster Bus를 통해 서로 상태를 교환한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Node A ↔ Node B
Node A ↔ Node C
Node B ↔ Node C&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 노드는 다른 노드에 &lt;code&gt;PING&lt;/code&gt;, &lt;code&gt;PONG&lt;/code&gt; 메시지를 보내고 Gossip 정보를 교환한다.&lt;/p&gt;
&lt;p&gt;특정 노드가 응답하지 않으면 먼저 개별 노드가 장애 의심 상태로 표시한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;PFAIL
→ Possible Failure&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다수의 Primary 노드가 해당 노드의 장애에 동의하면 최종 장애 상태가 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;FAIL
→ Confirmed Failure&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replica는 자신이 복제하던 Primary가 FAIL 상태가 되면 새로운 Primary가 되기 위한 선거를 시작할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary A 장애
   ↓
Replica A1이 Failover Election 요청
   ↓
다른 Primary들의 투표
   ↓
과반수 획득
   ↓
Replica A1 → 새로운 Primary&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h1&gt;18. Cluster Failover와 Epoch&lt;/h1&gt;
&lt;p&gt;Cluster에서는 여러 노드의 구성 변경 순서를 일관되게 판단하기 위해 Epoch 개념을 사용한다.&lt;/p&gt;
&lt;p&gt;쉽게 말하면 Cluster 구성 변경의 논리적 버전 번호다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Configuration Epoch 100
→ Primary A가 Slot 0~5460 소유

Configuration Epoch 101
→ Replica A1이 새로운 Primary로 승격
→ Slot 0~5460 소유&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;더 높은 Epoch의 구성 정보가 더 최신 상태로 인정된다.&lt;/p&gt;
&lt;p&gt;이를 통해 장애 복구 후 다른 노드들이 어떤 노드가 해당 Slot의 현재 Primary인지 판단할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;19. Cluster Client의 MOVED 처리&lt;/h1&gt;
&lt;p&gt;Cluster Client는 Key의 Hash Slot을 계산해 해당 Primary로 요청을 보낸다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Key
 ↓ CRC16
Hash Slot 계산
 ↓
Slot 담당 Primary로 요청&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Failover 직후 클라이언트가 이전 Primary로 요청을 보내면 &lt;code&gt;MOVED&lt;/code&gt; 응답을 받을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;MOVED 1200 10.0.0.15:6379&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이는 다음을 의미한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Slot 1200은 이제 10.0.0.15:6379에서 처리한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cluster-aware Client는 이 응답을 받으면 Slot Map을 갱신하고 새로운 노드로 요청을 보낸다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;기존 Slot Map
Slot 1200 → Node A

MOVED 수신

갱신된 Slot Map
Slot 1200 → Node A1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Valkey Cluster는 노드가 요청을 Proxy하지 않고 &lt;code&gt;MOVED&lt;/code&gt; 또는 &lt;code&gt;ASK&lt;/code&gt; 리다이렉션을 반환하며, 클라이언트가 Slot-Node 매핑을 캐시하면 성능을 높일 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;20. Cluster가 모든 장애에서 가용한 것은 아니다&lt;/h1&gt;
&lt;p&gt;Cluster는 일부 노드 장애를 자동 복구할 수 있지만, 다음 경우에는 서비스를 계속할 수 없을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary와 해당 Replica가 동시에 장애
다수의 Primary가 동시에 장애
Primary 과반수와 통신 불가능
모든 Slot을 정상적으로 제공할 수 없는 상태&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary A + Replica A1 동시 장애
→ A가 담당한 Slot 복구 불가&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cluster-require-full-coverage yes&lt;/code&gt;인 기본적인 구성에서는 일부 Slot을 제공할 수 없으면 Cluster 전체가 요청을 거부할 수 있다.&lt;/p&gt;
&lt;p&gt;반대로 &lt;code&gt;no&lt;/code&gt;로 설정하면 정상 Slot에 대해서는 요청을 계속 받을 수 있지만 일부 Key는 접근할 수 없다.&lt;/p&gt;
&lt;p&gt;Valkey Cluster는 일부 장애와 Partition 상황에서 가용성을 제공하지만, 다수 Primary 장애 같은 더 큰 장애에서는 Cluster가 사용할 수 없는 상태가 될 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;21. Cluster도 데이터 유실 가능성이 있다&lt;/h1&gt;
&lt;p&gt;Cluster 역시 Primary-Replica 간 비동기 복제를 사용한다.&lt;/p&gt;
&lt;p&gt;따라서 다음 상황에서 쓰기 유실이 가능하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Client가 기존 Primary에 쓰기
2. Primary가 성공 응답
3. Replica 복제 전 Primary 장애
4. Replica가 새로운 Primary로 승격&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;또한 Network Partition에서 소수 측 Primary에 쓰기가 발생하고, 다수 측에서 Replica가 새로운 Primary로 승격되면 소수 측에서 처리된 쓰기가 최종적으로 사라질 수 있다.&lt;/p&gt;
&lt;p&gt;Valkey Cluster는 비동기 복제를 사용하며, Partition과 Failover의 특정 시간 구간에서 승인된 쓰기가 유실될 수 있음을 공식 명세에 명시한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;22. 분산 락은 Failover 시 더 주의해야 한다&lt;/h1&gt;
&lt;p&gt;Valkey를 분산 락 저장소로 사용할 때 Primary 장애는 특히 중요하다.&lt;/p&gt;
&lt;p&gt;다음 상황을 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 요청 A가 Primary에서 락 획득
2. 락 정보가 Replica로 복제되기 전 Primary 장애
3. Replica가 새로운 Primary로 승격
4. 새 Primary에는 락 키가 없음
5. 요청 B가 동일한 락 획득&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과적으로 A와 B가 동시에 임계 구역을 실행할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;요청 A → 기존 락 소유자로 인식
요청 B → 새 Primary에서 락 획득 성공&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 중요한 업무에서 Valkey 분산 락만으로 데이터 정합성을 보장하면 안 된다.&lt;/p&gt;
&lt;p&gt;다음 방어 계층을 함께 적용해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 분산 락
+ DB Unique Constraint
+ 조건부 UPDATE
+ 낙관적 락
+ 멱등 키
+ Fencing Token&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 Fencing Token을 사용하면 락 획득 순서마다 증가하는 번호를 발급할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;요청 A → Token 100
요청 B → Token 101&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;최종 저장소는 더 오래된 Token의 쓰기를 거절한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE resource
SET value = :value,
    fencing_token = :token
WHERE resource_id = :resourceId
  AND fencing_token &amp;lt; :token;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;요청 A가 뒤늦게 실행되더라도:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;현재 Token: 101
A의 Token: 100

100 &amp;lt; 101
→ UPDATE 0건
→ 오래된 요청 차단&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h1&gt;23. 데이터 오삭제는 자동 Failover로 복구되지 않는다&lt;/h1&gt;
&lt;p&gt;운영자가 실수로 다음 명령을 실행했다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;DEL important:key&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;또는:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;FLUSHALL&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 명령은 정상적인 쓰기 명령이므로 Replica에도 그대로 복제된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary 데이터 삭제
   ↓ Replication
Replica 데이터도 삭제&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AOF에도 삭제 명령이 기록될 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DEL important:key&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 Replication과 AOF는 논리적 오삭제를 자동으로 보호하지 못한다.&lt;/p&gt;
&lt;p&gt;이 경우 필요한 것은 별도의 백업이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;주기적 RDB 백업
원격 Storage 복사
백업 파일 보존 정책
복원 훈련&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;백업은 Valkey 서버가 사용하는 동일 디스크에만 보관해서는 안 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 서버 디스크 장애
→ 운영 데이터 손실
→ 같은 디스크의 백업도 손실&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;별도 디스크나 Object Storage에 복제해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;24. 실제 백업 복구 절차&lt;/h1&gt;
&lt;p&gt;데이터 오삭제나 파일 손상 시 일반적인 복구 절차는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 장애 시점 확인
2. 쓰기 트래픽 차단 또는 격리
3. 현재 데이터 및 Persistence 파일 별도 보존
4. 복구 대상 시점의 RDB 또는 AOF 선택
5. 별도 Valkey 인스턴스에서 먼저 복원
6. 데이터 검증
7. 운영 인스턴스 전환
8. 애플리케이션 재연결
9. 정합성 검증&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;운영 서버 파일에 바로 덮어쓰는 것보다 별도 인스턴스에서 복원 검증하는 것이 안전하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Backup File
   ↓
Recovery Valkey Instance
   ↓
Key Count 검증
업무 데이터 검증
샘플 조회
Checksum 비교
   ↓
운영 전환&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;24.1 RDB 복원 예시&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl stop valkey

cp /backup/2026-07-29/dump.rdb /var/lib/valkey/dump.rdb

chown valkey:valkey /var/lib/valkey/dump.rdb

systemctl start valkey&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;시작 후 확인한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;valkey-cli INFO persistence
valkey-cli INFO keyspace
valkey-cli DBSIZE&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;24.2 AOF 복원 예시&lt;/h2&gt;
&lt;p&gt;먼저 원본을 보존한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cp -r /var/lib/valkey/appendonlydir \
      /var/lib/valkey/appendonlydir.backup&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AOF 파일 상태를 확인한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;valkey-check-aof appendonly.aof&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;복구가 필요하다면 복사본에서 먼저 수행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;valkey-check-aof --fix appendonly.aof&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그 후 별도 인스턴스에서 로딩 결과를 검증한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;25. 애플리케이션 장애 전파를 막는 방법&lt;/h1&gt;
&lt;p&gt;Valkey 장애가 발생했을 때 애플리케이션 요청 스레드가 무한정 대기하면 장애가 전체 서비스로 전파될 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 응답 지연
   ↓
Connection Pool 대기
   ↓
HTTP 요청 스레드 점유
   ↓
Thread Pool 고갈
   ↓
전체 API 장애&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 다음 타임아웃을 명확하게 설정해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Connection Timeout
Command Timeout
Pool Max Wait
Retry Count
Retry Backoff&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예시:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  data:
    redis:
      connect-timeout: 1s
      timeout: 2s
      lettuce:
        pool:
          max-active: 16
          max-idle: 8
          min-idle: 2
          max-wait: 500ms&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;설정값은 예시일 뿐이며 실제 명령 지연, TPS, Pool 사용률과 Failover 시간을 측정해 결정해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;26. 캐시 장애와 락 장애는 실패 정책이 달라야 한다&lt;/h1&gt;
&lt;p&gt;Valkey 사용 목적에 따라 장애 대응 정책을 구분해야 한다.&lt;/p&gt;
&lt;h2&gt;캐시 조회 장애&lt;/h2&gt;
&lt;p&gt;원본 DB 조회로 우회할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey GET 실패
→ DB 조회
→ 응답&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이를 Fail-Open 방식으로 볼 수 있다.&lt;/p&gt;
&lt;p&gt;다만 Valkey 장애 시 모든 요청이 DB로 몰리는 Cache Stampede에 주의해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 장애
→ 전체 요청 DB 유입
→ DB Connection Pool 고갈
→ DB 장애&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 Circuit Breaker, Rate Limiter, Local Cache 등을 함께 고려해야 한다.&lt;/p&gt;
&lt;h2&gt;분산 락 장애&lt;/h2&gt;
&lt;p&gt;락을 획득하지 못했는데 업무를 그대로 실행하면 중복 처리가 발생할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey Lock 실패
→ 락 없이 업무 실행
→ 동시 처리 발생&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;정합성이 중요한 작업은 Fail-Closed가 더 안전하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;락 저장소 장애
→ 요청 실패 또는 재처리 큐 적재
→ 락 없이 실행하지 않음&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;세션 장애&lt;/h2&gt;
&lt;p&gt;세션을 Valkey에만 저장했다면 사용자의 재로그인이 필요할 수 있다.&lt;/p&gt;
&lt;h2&gt;메시지 처리 상태 장애&lt;/h2&gt;
&lt;p&gt;멱등 처리 상태를 Valkey에만 저장했다면 장애 후 중복 소비 가능성이 있으므로 DB Unique Constraint 등 최종 방어가 필요하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;27. 스케줄러 중복 실행 방지와 장애 복구&lt;/h1&gt;
&lt;p&gt;Valkey 분산 락으로 스케줄러 중복 실행을 막고 있다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;App 1 → 스케줄러 락 획득
App 2 → 락 실패, 실행 안 함
App 3 → 락 실패, 실행 안 함&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;App 1이 배치 중간에 종료되면 &lt;code&gt;finally&lt;/code&gt;의 락 해제가 실행되지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;락 획득
→ 10만 건 중 4만 건 처리
→ App 1 장애
→ 락 해제 실패&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이때 TTL이 있어야 일정 시간 후 다른 인스턴스가 재실행할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;락 TTL 만료
→ App 2가 락 획득
→ 배치 재실행&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 TTL은 단지 락을 해제할 뿐, 어디까지 처리했는지는 알지 못한다.&lt;/p&gt;
&lt;p&gt;따라서 다음 구조가 필요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;분산 락
→ 중복 스케줄 실행 방지

Chunk Commit
→ 롤백 범위 축소

Checkpoint
→ 마지막 성공 위치 기록

멱등 처리
→ 재실행 중 중복 방지&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Batch Job ID: DAILY-FLIGHT-20260729
Last Processed ID: 45000
Status: FAILED&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;재실행 시:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT *
FROM flight_data
WHERE id &amp;gt; 45000
ORDER BY id
FETCH FIRST 1000 ROWS ONLY;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;분산 락은 실행 주체를 하나로 제한하고, Checkpoint와 멱등성은 중간 장애 후 안전한 재실행을 보장한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;28. 모니터링해야 할 지표&lt;/h1&gt;
&lt;h2&gt;Persistence&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;rdb_last_save_time
rdb_last_bgsave_status
rdb_bgsave_in_progress
rdb_last_bgsave_time_sec

aof_enabled
aof_rewrite_in_progress
aof_last_rewrite_time_sec
aof_last_bgrewrite_status
aof_delayed_fsync&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Replication&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;role
connected_slaves
master_link_status
master_last_io_seconds_ago
master_sync_in_progress
master_repl_offset
slave_repl_offset
repl_backlog_size&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Primary와 Replica Offset 차이를 이용해 복제 지연을 판단할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Replication Lag
≈ Primary Offset - Replica Offset&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 Offset 차이는 바이트 기준이므로 업무 지연 시간과 함께 해석해야 한다.&lt;/p&gt;
&lt;h2&gt;Sentinel&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;sdown
odown
failover-start
selected-slave
promoted-slave
switch-master&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Cluster&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;cluster_state
cluster_slots_ok
cluster_slots_pfail
cluster_slots_fail
cluster_known_nodes
cluster_size&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;애플리케이션&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey command latency P95/P99
Connection failure count
Command timeout count
Retry count
Pool wait time
Pool active connections
Failover recovery time
Cache fallback count
Distributed lock failure count&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h1&gt;29. 장애 복구 테스트 시나리오&lt;/h1&gt;
&lt;p&gt;운영 전에 실제 장애 테스트를 수행해야 한다.&lt;/p&gt;
&lt;h2&gt;프로세스 종료&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kill -9 &amp;lt;valkey-pid&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;확인 항목:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;자동 재시작 여부
RDB/AOF 데이터 복원 여부
애플리케이션 재연결 시간
최근 데이터 유실 범위&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Primary 노드 종료&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl stop valkey&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;확인 항목:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Sentinel 장애 감지 시간
Replica 승격 여부
새 Primary 주소
애플리케이션 Connection Pool 재생성
Failover 중 오류 요청 수&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;네트워크 단절&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary ↔ Replica 통신 차단
Sentinel ↔ Primary 통신 차단
Cluster 노드 간 통신 차단&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;확인 항목:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Partial Sync 여부
Full Sync 발생 여부
Split-Brain 가능성
쓰기 유실 여부&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;디스크 Full&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;RDB 저장 실패
AOF Append 실패
AOF Rewrite 실패&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;확인 항목:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;쓰기 명령 허용 여부
Persistence 오류 알림
디스크 확보 후 정상화&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;데이터 오삭제&lt;/h2&gt;
&lt;p&gt;별도 복구 환경에서 백업 파일을 로딩하고 원하는 시점의 데이터 복원이 가능한지 검증한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;30. 운영 구성 예시&lt;/h1&gt;
&lt;p&gt;중요도가 중간 수준인 캐시·분산 락 환경이라면 다음 구성을 고려할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary 1대
Replica 2대
Sentinel 3대
AOF everysec
주기적 RDB
외부 Storage 백업
Application
    ↓
Sentinel-aware Client
    ↓
Current Primary
    ↓ async replication
Replica 1 / Replica 2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cluster가 필요한 경우:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary 3대
Replica 3대
총 6개 노드

Primary A ─ Replica A1
Primary B ─ Replica B1
Primary C ─ Replica C1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;단, Cluster는 단순히 고가용성만을 위해 도입하기보다 데이터 용량과 처리량을 여러 Shard로 분산해야 할 때 선택하는 것이 적절하다.&lt;/p&gt;
&lt;p&gt;단일 노드 용량으로 충분하다면 Sentinel 기반 구성이 운영과 장애 분석 측면에서 더 단순할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;31. 복구 목표를 수치로 정의해야 한다&lt;/h1&gt;
&lt;p&gt;장애 복구 설계에서는 RPO와 RTO를 먼저 정의해야 한다.&lt;/p&gt;
&lt;h2&gt;RPO&lt;/h2&gt;
&lt;p&gt;Recovery Point Objective는 장애 시 허용 가능한 데이터 유실 범위다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;RPO 0초
→ 데이터 유실을 허용하지 않음

RPO 1초
→ 최대 약 1초 데이터 유실 허용

RPO 5분
→ 최대 5분 데이터 유실 허용&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 AOF &lt;code&gt;everysec&lt;/code&gt;는 일반적으로 약 1초 수준의 RPO를 목표로 할 수 있지만, 하드웨어·운영체제·동시 장애 시나리오까지 포함한 절대적인 보장은 아니다.&lt;/p&gt;
&lt;h2&gt;RTO&lt;/h2&gt;
&lt;p&gt;Recovery Time Objective는 장애 발생 후 서비스를 복구해야 하는 목표 시간이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Primary 장애 감지: 5초
Sentinel 합의: 2초
Replica 승격: 2초
Application 재연결: 3초

예상 RTO: 약 12초&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 RTO는 설정값만으로 계산하지 말고 장애 훈련에서 측정해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;32. 실무 체크리스트&lt;/h1&gt;
&lt;h2&gt;Persistence&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;RDB 또는 AOF 활성화 여부를 결정한다.&lt;/li&gt;
&lt;li&gt;AOF &lt;code&gt;appendfsync&lt;/code&gt; 정책을 명확히 한다.&lt;/li&gt;
&lt;li&gt;RDB와 AOF를 동일 디스크에만 의존하지 않는다.&lt;/li&gt;
&lt;li&gt;백업을 외부 Storage에 보관한다.&lt;/li&gt;
&lt;li&gt;정기적으로 복원 테스트를 수행한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BGSAVE&lt;/code&gt;, AOF Rewrite 실패를 모니터링한다.&lt;/li&gt;
&lt;li&gt;디스크 여유 공간을 감시한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Replication&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Replica를 최소 1대 이상 구성한다.&lt;/li&gt;
&lt;li&gt;중요 환경에서는 2대 이상을 고려한다.&lt;/li&gt;
&lt;li&gt;Replication Lag을 모니터링한다.&lt;/li&gt;
&lt;li&gt;Backlog 크기를 쓰기량과 단절 시간 기준으로 설정한다.&lt;/li&gt;
&lt;li&gt;Full Sync 발생 빈도를 확인한다.&lt;/li&gt;
&lt;li&gt;비동기 복제의 데이터 유실 가능성을 인정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Sentinel&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Sentinel을 홀수 개로 구성한다.&lt;/li&gt;
&lt;li&gt;일반적으로 최소 3개를 사용한다.&lt;/li&gt;
&lt;li&gt;Sentinel을 동일 장애 영역에 모두 배치하지 않는다.&lt;/li&gt;
&lt;li&gt;Quorum과 장애 감지 시간을 조정한다.&lt;/li&gt;
&lt;li&gt;클라이언트가 Sentinel을 지원하는지 확인한다.&lt;/li&gt;
&lt;li&gt;Primary 주소를 애플리케이션에 고정하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Cluster&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;각 Primary에 Replica를 배치한다.&lt;/li&gt;
&lt;li&gt;Primary와 Replica를 다른 장애 영역에 배치한다.&lt;/li&gt;
&lt;li&gt;Cluster-aware Client를 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MOVED&lt;/code&gt;, &lt;code&gt;ASK&lt;/code&gt; 리다이렉션을 처리할 수 있어야 한다.&lt;/li&gt;
&lt;li&gt;Slot Coverage 정책을 확인한다.&lt;/li&gt;
&lt;li&gt;다수 Primary 장애 시 가용성 한계를 이해한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;애플리케이션&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Connection Timeout을 제한한다.&lt;/li&gt;
&lt;li&gt;Command Timeout을 제한한다.&lt;/li&gt;
&lt;li&gt;Pool 대기 시간을 무제한으로 두지 않는다.&lt;/li&gt;
&lt;li&gt;재시도 명령의 멱등성을 검토한다.&lt;/li&gt;
&lt;li&gt;캐시와 분산 락의 실패 정책을 분리한다.&lt;/li&gt;
&lt;li&gt;락 장애 시 무조건 Fail-Open하지 않는다.&lt;/li&gt;
&lt;li&gt;최종 정합성은 DB Constraint와 멱등 처리로 보호한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h1&gt;33. 결론&lt;/h1&gt;
&lt;p&gt;Valkey의 장애 복구는 다음 계층으로 이해해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;RDB
→ 특정 시점의 전체 데이터 스냅샷 복구

AOF
→ 쓰기 명령을 재생하여 최신 상태 복구

Replication
→ Primary 데이터를 Replica에 지속적으로 복제

Sentinel
→ 비 Cluster 환경에서 Primary 장애 감지와 자동 승격

Cluster
→ Shard별 Primary 장애 감지와 Replica 승격&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 어떤 구성을 사용하더라도 다음 사실은 변하지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;비동기 복제에서는 쓰기 유실 가능성이 존재한다.
Failover 중에는 일시적인 연결 오류가 발생할 수 있다.
Replication은 데이터 오삭제를 막지 못한다.
Persistence 파일도 별도 백업 없이는 완전한 복구 수단이 아니다.
분산 락은 Failover 상황에서 중복 소유자가 발생할 수 있다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 실무에서는 다음과 같이 계층적으로 대응해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. RDB와 AOF로 프로세스 재시작 복구
2. Replica로 서버 장애 대비
3. Sentinel 또는 Cluster로 자동 Failover
4. 외부 백업으로 오삭제와 디스크 장애 대비
5. 애플리케이션 Timeout과 재연결로 장애 전파 방지
6. DB Constraint와 멱등 처리로 최종 정합성 보장
7. 정기적인 장애 복구 훈련으로 실제 RPO·RTO 검증&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Valkey의 장애 복구를 단순히 “Replica가 있으므로 안전하다”라고 이해해서는 안 된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;어떤 장애에서 자동 복구되는지, 어느 시점의 데이터까지 복구되는지, Failover 과정에서 어떤 요청이 실패하거나 중복될 수 있는지를 정확히 이해해야 운영 가능한 구조를 설계할 수 있다.&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;  </description>
      <category>나의 주니어 개발 일기/Redis</category>
      <author>추억을 백앤드하자</author>
      <guid isPermaLink="true">https://pulpul8282.tistory.com/453</guid>
      <comments>https://pulpul8282.tistory.com/453#entry453comment</comments>
      <pubDate>Wed, 29 Jul 2026 10:43:07 +0900</pubDate>
    </item>
    <item>
      <title>DB의 MVCC는 어떻게 동시성을 보장하는가?</title>
      <link>https://pulpul8282.tistory.com/452</link>
      <description>&lt;div class=&quot;markdown-body&quot;&gt;
&lt;h1&gt;애플리케이션에서 DB를 사용할 때 MVCC는 어떻게 동시성을 보장하는가&lt;/h1&gt;
&lt;p&gt;애플리케이션에서 여러 요청이 동시에 같은 데이터를 조회하고 수정하는 상황은 매우 흔하다.&lt;/p&gt;
&lt;p&gt;예를 들어 다음과 같은 요청이 동시에 들어올 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;사용자 A: 주문 정보를 조회한다.
사용자 B: 같은 주문의 상태를 결제 완료로 변경한다.
사용자 C: 같은 주문을 다시 조회한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이때 데이터베이스가 모든 조회와 수정을 하나의 잠금으로 직렬화한다면 데이터 정합성은 지킬 수 있지만, 조회 요청까지 대기해야 하므로 처리량과 응답 시간이 크게 나빠진다.&lt;/p&gt;
&lt;p&gt;대부분의 상용 RDBMS는 이를 해결하기 위해 &lt;strong&gt;MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)&lt;/strong&gt;를 사용한다.&lt;/p&gt;
&lt;p&gt;MVCC의 핵심은 간단하다.&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;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;덕분에 일반적인 조회는 수정 트랜잭션이 끝날 때까지 기다리지 않고, 자신이 읽을 수 있는 과거의 커밋 버전을 조회할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. MVCC가 필요한 이유&lt;/h2&gt;
&lt;p&gt;MVCC가 없다면 데이터베이스는 읽기와 쓰기의 충돌을 주로 잠금으로 제어해야 한다.&lt;/p&gt;
&lt;p&gt;다음과 같은 데이터가 있다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;order_id = 100
status   = READY&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;트랜잭션 B가 주문 상태를 변경한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE orders
SET status = &amp;#39;PAID&amp;#39;
WHERE order_id = 100;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;아직 &lt;code&gt;COMMIT&lt;/code&gt;하지 않은 상태에서 트랜잭션 A가 같은 주문을 조회한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT status
FROM orders
WHERE order_id = 100;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이때 가능한 처리 방식은 크게 세 가지다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;트랜잭션 B가 끝날 때까지 A를 대기시킨다.&lt;/li&gt;
&lt;li&gt;B가 아직 커밋하지 않은 &lt;code&gt;PAID&lt;/code&gt;를 A에게 보여준다.&lt;/li&gt;
&lt;li&gt;A에게 변경 이전의 커밋된 값인 &lt;code&gt;READY&lt;/code&gt;를 보여준다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;두 번째 방식은 Dirty Read를 발생시킨다. 트랜잭션 B가 나중에 롤백한다면 A는 실제로 존재하지 않았던 값을 사용하게 된다.&lt;/p&gt;
&lt;p&gt;MVCC는 일반적으로 세 번째 방식을 사용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;트랜잭션 B가 보는 값: PAID
트랜잭션 A가 보는 값: READY&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Oracle은 Undo 데이터를 이용해 특정 시점의 일관된 데이터를 재구성하며, 다른 트랜잭션의 미커밋 데이터를 일반 조회에 노출하지 않는다. MySQL InnoDB 역시 Undo Log를 사용해 과거 행 버전을 구성하고 일관된 읽기를 제공한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. MVCC의 핵심 구성 요소&lt;/h2&gt;
&lt;p&gt;MVCC의 구현 방식은 DBMS마다 다르지만, 논리적으로는 다음 요소가 필요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 트랜잭션 식별자
2. 데이터의 여러 버전
3. 현재 트랜잭션이 바라보는 스냅샷
4. 어떤 버전이 보이는지 판단하는 가시성 규칙
5. 더 이상 필요 없는 과거 버전을 정리하는 작업&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이를 하나씩 살펴보자.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. 트랜잭션 ID&lt;/h2&gt;
&lt;p&gt;데이터베이스는 각각의 트랜잭션을 구분하기 위해 내부적인 트랜잭션 식별자를 사용한다.&lt;/p&gt;
&lt;p&gt;개념적으로 다음과 같은 순서로 트랜잭션이 시작되었다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Transaction 100: 시작
Transaction 101: 시작
Transaction 102: 시작&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;행이 수정될 때 DB는 해당 행이 어떤 트랜잭션에 의해 생성되거나 변경되었는지를 기록한다.&lt;/p&gt;
&lt;p&gt;MySQL InnoDB의 경우 각 레코드에 다음과 같은 숨겨진 정보를 관리한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DB_TRX_ID   : 마지막으로 행을 삽입하거나 수정한 트랜잭션 ID
DB_ROLL_PTR : 이전 버전이 기록된 Undo Log 위치
DB_ROW_ID   : 적절한 고유 인덱스가 없을 때 사용하는 내부 행 ID&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특히 &lt;code&gt;DB_ROLL_PTR&lt;/code&gt;는 현재 행에서 이전 버전으로 이동할 수 있는 연결 정보다. InnoDB는 이 포인터와 Undo Log를 이용해 필요한 과거 버전을 복원한다.&lt;/p&gt;
&lt;p&gt;개념적으로는 다음과 같은 버전 체인이 만들어진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;현재 버전
status = PAID
trx_id = 120
    |
    v
이전 버전
status = READY
trx_id = 100
    |
    v
더 이전 버전
status = CREATED
trx_id = 80&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;조회 트랜잭션은 이 체인을 따라가면서 자신에게 보이는 버전을 찾는다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. 스냅샷은 무엇인가&lt;/h2&gt;
&lt;p&gt;스냅샷은 트랜잭션 또는 SQL 문이 데이터를 읽을 때 사용하는 &lt;strong&gt;논리적인 데이터베이스 시점&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;스냅샷이 만들어질 때 데이터베이스는 대략 다음 정보를 기준으로 가시성을 판단한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 스냅샷 생성 이전에 커밋된 트랜잭션
- 현재 실행 중인 트랜잭션
- 스냅샷 이후 시작된 트랜잭션
- 자기 자신의 트랜잭션&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다음 상황을 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;T1 시작
T2 시작
T2: order 상태 READY → PAID
T1: order 조회
T2 COMMIT
T1: order 재조회&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;T1이 두 번째 조회에서 &lt;code&gt;READY&lt;/code&gt;를 볼지 &lt;code&gt;PAID&lt;/code&gt;를 볼지는 격리 수준과 DBMS의 스냅샷 정책에 따라 달라진다.&lt;/p&gt;
&lt;h3&gt;READ COMMITTED&lt;/h3&gt;
&lt;p&gt;일반적으로 SQL 문이 실행될 때마다 새로운 스냅샷을 얻는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;T1 첫 번째 SELECT  → READY
T2 COMMIT
T1 두 번째 SELECT  → PAID&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;REPEATABLE READ&lt;/h3&gt;
&lt;p&gt;일반적으로 트랜잭션에서 정해진 스냅샷을 반복해서 사용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;T1 첫 번째 SELECT  → READY
T2 COMMIT
T1 두 번째 SELECT  → READY&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL InnoDB의 &lt;code&gt;REPEATABLE READ&lt;/code&gt;에서는 일반적인 일관된 읽기가 첫 번째 일관 읽기 시점에 생성된 스냅샷을 같은 트랜잭션 동안 재사용한다. &lt;code&gt;READ COMMITTED&lt;/code&gt;에서는 각각의 일관 읽기가 새로운 스냅샷을 사용한다.&lt;/p&gt;
&lt;p&gt;Oracle의 기본 격리 수준인 &lt;code&gt;READ COMMITTED&lt;/code&gt;는 문장 단위 읽기 일관성을 제공한다. 즉, 하나의 SQL 문은 SQL 실행이 시작된 특정 시점을 기준으로 일관된 결과를 반환한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5. 행을 수정할 때 내부에서 발생하는 일&lt;/h2&gt;
&lt;p&gt;다음 SQL이 실행된다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE accounts
SET balance = 9000
WHERE account_id = 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;수정 전 값은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;account_id = 1
balance    = 10000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB 내부에서는 개념적으로 다음 작업이 수행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 수정 대상 행에 필요한 잠금을 획득한다.
2. 변경 전 값을 과거 버전 영역에 기록한다.
3. 현재 행을 새로운 값으로 변경한다.
4. 변경 트랜잭션 정보를 행에 연결한다.
5. COMMIT 또는 ROLLBACK을 기다린다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL InnoDB에서는 변경 전 정보를 Undo Log에 기록하고 현재 레코드를 수정한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;현재 레코드
balance = 9000
trx_id  = 200
roll_ptr ──────┐
               v
Undo Log
balance = 10000
trx_id  = 150&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Oracle 역시 데이터 변경 시 Undo Entry를 Undo Segment에 기록하고, 필요한 경우 Undo 데이터를 사용해 이전 시점의 블록 이미지를 재구성한다.&lt;/p&gt;
&lt;p&gt;PostgreSQL은 구조가 다르다. 기존 튜플을 Undo Log로 복원하는 방식이 아니라, &lt;code&gt;UPDATE&lt;/code&gt; 시 새로운 튜플 버전을 테이블에 생성하고 기존 버전을 남긴다.&lt;/p&gt;
&lt;p&gt;개념적으로 표현하면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;기존 튜플
balance = 10000
xmin    = 150
xmax    = 200

신규 튜플
balance = 9000
xmin    = 200
xmax    = null&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 &lt;code&gt;xmin&lt;/code&gt;은 해당 튜플을 생성한 트랜잭션을, &lt;code&gt;xmax&lt;/code&gt;는 삭제하거나 대체한 트랜잭션을 나타내는 데 사용된다. 각 트랜잭션은 자신의 스냅샷과 이 정보를 비교하여 어떤 튜플이 보이는지 판단한다.&lt;/p&gt;
&lt;p&gt;따라서 같은 MVCC라도 물리적 구현은 다르다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;DBMS&lt;/th&gt;
&lt;th&gt;과거 버전 관리 방식&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;MySQL InnoDB&lt;/td&gt;
&lt;td&gt;현재 레코드와 Undo Log를 연결&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle&lt;/td&gt;
&lt;td&gt;현재 블록과 Undo Segment를 이용해 과거 버전 재구성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;테이블에 새로운 튜플 버전을 추가하고 기존 튜플 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;6. 조회 시 어떤 버전을 선택하는가&lt;/h2&gt;
&lt;p&gt;다음 버전 체인을 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Version 3
status = COMPLETED
trx_id = 300

Version 2
status = PAID
trx_id = 200

Version 1
status = READY
trx_id = 100&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;현재 조회 트랜잭션의 스냅샷이 트랜잭션 250 시점을 기준으로 생성되었다고 하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;trx 100: 커밋 완료
trx 200: 커밋 완료
trx 300: 스냅샷 이후 변경&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;조회 과정은 개념적으로 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Version 3 확인
→ trx 300은 스냅샷 이후 버전
→ 보이지 않음

Version 2 확인
→ trx 200은 스냅샷 이전에 커밋
→ 보임

결과: PAID&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;의사 코드로 표현하면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;RowVersion findVisibleVersion(RowVersion current, Snapshot snapshot) {
    RowVersion version = current;

    while (version != null) {
        if (snapshot.isVisible(version.transactionId())) {
            return version;
        }

        version = version.previousVersion();
    }

    return null;
}&lt;/code&gt;&lt;/pre&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;DB는 항상 가장 최신 버전을 반환하는 것이 아니라, 현재 스냅샷에서 볼 수 있는 가장 최신 버전을 반환한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;7. COMMIT은 버전을 어떻게 확정하는가&lt;/h2&gt;
&lt;p&gt;애플리케이션에서 다음 코드가 실행된다고 하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void completePayment(Long orderId) {
    Order order = orderRepository.findById(orderId)
        .orElseThrow();

    order.completePayment();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spring의 &lt;code&gt;@Transactional&lt;/code&gt;은 JDBC Connection의 트랜잭션 경계를 관리한다.&lt;/p&gt;
&lt;p&gt;개념적인 실행 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;HTTP Request
    ↓
Spring TransactionInterceptor
    ↓
Connection.setAutoCommit(false)
    ↓
SELECT
    ↓
UPDATE
    ↓
Connection.commit()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB에서 &lt;code&gt;COMMIT&lt;/code&gt;이 수행되면 해당 트랜잭션의 변경 내용은 커밋된 버전으로 취급된다.&lt;/p&gt;
&lt;p&gt;그러나 커밋이 되었다고 해서 과거 버전이 즉시 삭제되는 것은 아니다.&lt;/p&gt;
&lt;p&gt;아직 과거 스냅샷을 사용하는 트랜잭션이 있을 수 있기 때문이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;T1: 오래된 스냅샷 유지
T2: 데이터를 수정하고 COMMIT
T3: 새 스냅샷으로 수정된 값 조회

T1 → 과거 버전 필요
T3 → 최신 버전 조회&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 DB는 다음 조건이 만족될 때까지 과거 버전을 보존해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;해당 과거 버전을 조회할 가능성이 있는 트랜잭션이 더 이상 존재하지 않는다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;InnoDB의 Update Undo Log는 과거 버전을 필요로 하는 스냅샷이 없어질 때까지 제거할 수 없다. PostgreSQL도 모든 활성 트랜잭션에서 더 이상 보이지 않는 오래된 튜플을 VACUUM으로 정리한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. ROLLBACK은 어떻게 동작하는가&lt;/h2&gt;
&lt;p&gt;다음 트랜잭션이 실패했다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void transfer(
    Long fromAccountId,
    Long toAccountId,
    long amount
) {
    withdraw(fromAccountId, amount);
    deposit(toAccountId, amount);

    throw new RuntimeException(&amp;quot;외부 시스템 연동 실패&amp;quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;데이터베이스는 변경 전 값을 알고 있어야 트랜잭션을 취소할 수 있다.&lt;/p&gt;
&lt;p&gt;MySQL과 Oracle 계열에서는 Undo 정보가 두 가지 용도로 사용된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 트랜잭션 롤백
2. MVCC를 위한 과거 버전 조회&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 다음 변경이 발생했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;변경 전: balance = 10000
변경 후: balance = 9000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Undo에는 이전 값이 기록된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Undo Record
balance = 10000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;롤백 시 DB는 Undo 정보를 적용해 논리적으로 변경 이전 상태로 되돌린다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;balance = 9000
    ↓ ROLLBACK
balance = 10000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 Undo Log는 단순한 장애 복구 파일이 아니라, MVCC의 일관 읽기와 트랜잭션 롤백 모두에 사용되는 핵심 구성 요소다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;9. MVCC가 있으면 락은 필요 없는가&lt;/h2&gt;
&lt;p&gt;MVCC가 조회와 수정 간 충돌을 줄여 주는 것은 맞지만, 락을 제거하지는 않는다.&lt;/p&gt;
&lt;p&gt;정확히는 다음과 같이 이해해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;일반 SELECT와 UPDATE의 충돌
→ MVCC로 상당 부분 완화

UPDATE와 UPDATE의 충돌
→ 행 잠금 필요

SELECT FOR UPDATE
→ 잠금 읽기

DDL과 DML의 충돌
→ 메타데이터 락 또는 테이블 락 필요

고유 제약조건 검사
→ 인덱스 및 잠금 조정 필요&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 두 트랜잭션이 동시에 같은 계좌를 수정하려 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE account
SET balance = balance - 1000
WHERE account_id = 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;첫 번째 트랜잭션이 행을 수정하고 커밋하지 않았다면 두 번째 &lt;code&gt;UPDATE&lt;/code&gt;는 일반적으로 같은 행의 잠금을 기다려야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;T1: account_id=1 행 잠금 획득
T1: balance 수정
T2: 같은 행 UPDATE 시도
T2: 잠금 대기
T1: COMMIT
T2: 잠금 획득 후 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MVCC의 대표적인 장점인 “읽는 작업이 쓰는 작업을 막지 않는다”는 의미가 “모든 쓰기 작업이 서로 막지 않는다”는 뜻은 아니다.&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;MVCC는 주로 읽기와 쓰기의 동시성을 높이고, 쓰기와 쓰기의 충돌은 여전히 잠금으로 제어한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;10. 일반 SELECT와 SELECT FOR UPDATE의 차이&lt;/h2&gt;
&lt;p&gt;애플리케이션 코드에서 다음 두 조회는 내부 동작이 다르다.&lt;/p&gt;
&lt;h3&gt;일반 조회&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT *
FROM account
WHERE account_id = 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;일반 조회는 보통 MVCC 스냅샷을 기준으로 과거의 커밋 버전을 읽는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;다른 트랜잭션이 해당 행 수정 중
→ 기다리지 않고 이전 커밋 버전 조회 가능&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;잠금 조회&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT *
FROM account
WHERE account_id = 1
FOR UPDATE;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SELECT FOR UPDATE&lt;/code&gt;는 데이터를 단순히 조회하는 것이 아니라 이후 수정을 전제로 현재 행을 잠근다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;다른 트랜잭션이 해당 행 수정 중
→ 잠금이 해제될 때까지 대기하거나 실패&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spring Data JPA에서는 다음과 같이 사용할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public interface AccountRepository
    extends JpaRepository&amp;lt;Account, Long&amp;gt; {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(&amp;quot;&amp;quot;&amp;quot;
        select a
        from Account a
        where a.id = :id
    &amp;quot;&amp;quot;&amp;quot;)
    Optional&amp;lt;Account&amp;gt; findByIdForUpdate(
        @Param(&amp;quot;id&amp;quot;) Long id
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;서비스에서는 하나의 트랜잭션 안에서 조회와 수정을 수행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;

    @Transactional
    public void withdraw(Long accountId, long amount) {
        Account account = accountRepository
            .findByIdForUpdate(accountId)
            .orElseThrow();

        account.withdraw(amount);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우 MVCC만으로 처리하는 것이 아니라 DB의 행 잠금을 명시적으로 사용한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;11. MVCC만으로 Lost Update를 막을 수 있는가&lt;/h2&gt;
&lt;p&gt;다음과 같은 코드가 있다고 하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void increaseStock(Long productId) {
    Product product = productRepository.findById(productId)
        .orElseThrow();

    product.increaseStock();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;초기 재고가 10이고 두 요청이 동시에 실행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;T1: stock = 10 조회
T2: stock = 10 조회

T1: stock = 11 저장
T2: stock = 11 저장&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;기대 결과는 12지만 실제 결과는 11이 될 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;T1의 변경이 T2에 의해 덮어써짐
→ Lost Update&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MVCC는 각 트랜잭션에 읽기 일관성을 제공하지만, 애플리케이션의 모든 비즈니스 동시성 문제를 자동으로 해결하지는 않는다.&lt;/p&gt;
&lt;p&gt;이 문제는 다음 방법으로 해결할 수 있다.&lt;/p&gt;
&lt;h3&gt;원자적 UPDATE&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE product
SET stock = stock + 1
WHERE product_id = ?;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB가 하나의 SQL 문으로 연산을 수행하게 만든다.&lt;/p&gt;
&lt;h3&gt;낙관적 락&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Entity
public class Product {

    @Id
    private Long id;

    @Version
    private Long version;

    private int stock;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;업데이트 SQL은 개념적으로 다음과 같이 실행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE product
SET stock = ?,
    version = version + 1
WHERE product_id = ?
  AND version = ?;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다른 트랜잭션이 먼저 수정했다면 영향받은 행 수가 0이 되고, 애플리케이션은 충돌을 감지한다.&lt;/p&gt;
&lt;h3&gt;비관적 락&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT *
FROM product
WHERE product_id = ?
FOR UPDATE;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;조회 시점부터 다른 트랜잭션의 수정을 차단한다.&lt;/p&gt;
&lt;p&gt;따라서 다음을 구분해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DB의 MVCC
→ 트랜잭션이 어떤 데이터 버전을 볼지 결정

애플리케이션 동시성 제어
→ 동시에 발생한 비즈니스 변경을 어떻게 직렬화하거나 충돌 처리할지 결정&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;12. 격리 수준과 MVCC의 관계&lt;/h2&gt;
&lt;p&gt;격리 수준은 단순히 잠금의 강도를 의미하지 않는다.&lt;/p&gt;
&lt;p&gt;MVCC 기반 DB에서는 격리 수준에 따라 &lt;strong&gt;스냅샷을 언제 생성하고 얼마나 오래 유지하는지&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;READ UNCOMMITTED&lt;/td&gt;
&lt;td&gt;DB에 따라 제한적 MVCC&lt;/td&gt;
&lt;td&gt;Dirty Read 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;READ COMMITTED&lt;/td&gt;
&lt;td&gt;SQL 문마다 새로운 스냅샷&lt;/td&gt;
&lt;td&gt;Non-repeatable Read 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REPEATABLE READ&lt;/td&gt;
&lt;td&gt;트랜잭션 동안 스냅샷 유지&lt;/td&gt;
&lt;td&gt;DB 구현에 따라 Phantom 처리 차이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SERIALIZABLE&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;h3&gt;READ COMMITTED 예시&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;T1: SELECT balance → 10000
T2: UPDATE balance = 9000
T2: COMMIT
T1: SELECT balance → 9000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 번째 SQL은 새로운 스냅샷을 사용하므로 최신 커밋 값을 볼 수 있다.&lt;/p&gt;
&lt;h3&gt;REPEATABLE READ 예시&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;T1: SELECT balance → 10000
T2: UPDATE balance = 9000
T2: COMMIT
T1: SELECT balance → 10000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;T1은 이전과 같은 스냅샷을 사용하므로 동일한 결과를 볼 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 실제 동작은 DBMS별 차이가 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Oracle의 기본 &lt;code&gt;READ COMMITTED&lt;/code&gt;는 문장 단위 읽기 일관성을 제공한다.&lt;/li&gt;
&lt;li&gt;MySQL InnoDB의 기본 &lt;code&gt;REPEATABLE READ&lt;/code&gt;는 일반적인 일관 읽기에서 트랜잭션 스냅샷을 재사용한다.&lt;/li&gt;
&lt;li&gt;PostgreSQL의 &lt;code&gt;READ COMMITTED&lt;/code&gt;는 각 SQL 명령이 명령 시작 전에 커밋된 행을 기준으로 조회한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;13. 장기 트랜잭션이 MVCC 성능을 악화시키는 이유&lt;/h2&gt;
&lt;p&gt;MVCC는 과거 버전을 보존해야 동작한다.&lt;/p&gt;
&lt;p&gt;그런데 하나의 트랜잭션이 매우 오랫동안 스냅샷을 유지하면 DB는 그 트랜잭션이 필요로 할 수 있는 과거 버전을 제거하지 못한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10:00 T1 시작
10:01~11:00 다른 트랜잭션이 대량 UPDATE/DELETE
11:00 T1 아직 종료되지 않음&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;T1이 10시 시점의 데이터를 볼 가능성이 있으므로 DB는 관련 과거 버전을 계속 보존해야 한다.&lt;/p&gt;
&lt;p&gt;이로 인해 다음 문제가 발생할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- Undo 영역 증가
- PostgreSQL Dead Tuple 증가
- Purge 또는 Vacuum 지연
- 테이블과 인덱스 팽창
- 버전 체인 탐색 비용 증가
- 디스크 I/O 증가
- 읽기 성능 저하&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL 공식 문서도 읽기 전용 트랜잭션을 포함해 트랜잭션을 정기적으로 커밋하지 않으면 필요한 Update Undo Log를 제거할 수 없어 Undo Tablespace가 커질 수 있다고 설명한다.&lt;/p&gt;
&lt;p&gt;Oracle에서는 장기 조회에 필요한 Undo 데이터가 재사용되면 &lt;code&gt;ORA-01555: snapshot too old&lt;/code&gt;가 발생할 수 있다. Oracle은 Undo를 사용해 과거 시점 데이터를 재구성하므로, 필요한 Undo가 이미 덮어써졌다면 조회 시점을 복원하지 못한다.&lt;/p&gt;
&lt;p&gt;PostgreSQL에서는 오래된 튜플 버전을 VACUUM이 정리하며, Visibility Map은 모든 활성 트랜잭션에 보이는 페이지와 동결된 튜플이 있는 페이지를 추적한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;14. 애플리케이션에서 자주 발생하는 장기 트랜잭션&lt;/h2&gt;
&lt;h3&gt;트랜잭션 안에서 외부 API 호출&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void processPayment(Long orderId) {
    Order order = orderRepository.findById(orderId)
        .orElseThrow();

    paymentClient.approve(order.getAmount());

    order.completePayment();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;외부 결제 API가 5초 걸리면 DB 트랜잭션과 커넥션도 5초 이상 유지된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DB 조회
→ 외부 API 대기
→ DB 수정
→ COMMIT&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조는 다음 문제를 일으킬 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 커넥션 점유 시간 증가
- 잠금 유지 시간 증가
- MVCC 스냅샷 유지 시간 증가
- Undo 정리 지연
- 장애 발생 시 롤백 범위 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;가능하다면 외부 호출과 DB 트랜잭션 경계를 분리해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public void processPayment(Long orderId) {
    PaymentRequest request = loadPaymentRequest(orderId);

    PaymentResult result = paymentClient.approve(
        request.amount()
    );

    completePayment(orderId, result);
}

@Transactional(readOnly = true)
public PaymentRequest loadPaymentRequest(Long orderId) {
    // 조회
}

@Transactional
public void completePayment(
    Long orderId,
    PaymentResult result
) {
    // 상태 변경
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 트랜잭션을 분리하면 원자성이 사라지므로 Outbox, 보상 트랜잭션, 멱등 처리 등의 설계가 함께 필요할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;15. 대량 배치에서 하나의 트랜잭션을 사용하면 안 되는 이유&lt;/h2&gt;
&lt;p&gt;다음과 같이 백만 건을 하나의 트랜잭션으로 처리한다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void migrate() {
    List&amp;lt;LegacyData&amp;gt; data = legacyRepository.findAll();

    for (LegacyData item : data) {
        targetRepository.save(convert(item));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조는 다음 문제를 만든다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 트랜잭션 지속 시간이 매우 길어짐
- 대량의 Undo 또는 과거 버전 생성
- 영속성 컨텍스트 메모리 증가
- 잠금 장기 유지
- 장애 시 전체 롤백
- MVCC 정리 작업 지연&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;대량 작업은 일반적으로 Chunk 단위로 나누는 것이 적절하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public void migrate() {
    int page = 0;
    int size = 1000;

    while (true) {
        List&amp;lt;LegacyData&amp;gt; batch =
            legacyRepository.findBatch(page, size);

        if (batch.isEmpty()) {
            break;
        }

        migrateChunk(batch);
        page++;
    }
}

@Transactional
public void migrateChunk(List&amp;lt;LegacyData&amp;gt; batch) {
    for (LegacyData item : batch) {
        targetRepository.save(convert(item));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처리 단위를 줄이면 다음과 같은 효과가 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 트랜잭션 수명 단축
- Undo 사용량 감소
- 잠금 범위 축소
- 실패 시 재처리 범위 제한
- 커넥션 반환 주기 단축&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;16. &lt;code&gt;@Transactional(readOnly = true)&lt;/code&gt;가 MVCC를 없애는 것은 아니다&lt;/h2&gt;
&lt;p&gt;다음과 같은 코드가 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional(readOnly = true)
public OrderResponse getOrder(Long orderId) {
    return orderRepository.findById(orderId)
        .map(OrderResponse::from)
        .orElseThrow();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;readOnly = true&lt;/code&gt;는 Spring과 JPA Provider, JDBC Driver, DBMS에 읽기 전용 의도를 전달하거나 Flush 동작을 최적화하는 데 사용될 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 조회 트랜잭션도 DB에서 스냅샷을 생성하고 유지할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;readOnly = true
≠ 트랜잭션 없음
≠ MVCC 버전 관리 영향 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특히 읽기 전용 트랜잭션이 오래 유지되면 과거 버전 정리를 지연시킬 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 읽기 전용 메서드라도 다음 작업을 트랜잭션 안에 포함하지 않는 것이 좋다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 외부 API 호출
- 대용량 파일 생성
- 긴 반복 계산
- 사용자 입력 대기
- 메시지 브로커 응답 대기&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB 조회가 끝난 뒤 필요한 데이터를 DTO로 변환해 트랜잭션 밖에서 후속 작업을 수행하는 것이 유리하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;17. DB 커넥션 풀과 MVCC의 관계&lt;/h2&gt;
&lt;p&gt;애플리케이션의 트랜잭션은 일반적으로 하나의 DB Connection에 종속된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;@Transactional 시작
→ Connection 획득
→ 스냅샷 생성
→ SQL 실행
→ COMMIT/ROLLBACK
→ Connection 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;트랜잭션이 길어지면 MVCC뿐 아니라 커넥션 풀에도 영향을 준다.&lt;/p&gt;
&lt;p&gt;예를 들어 다음과 같은 환경을 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;HikariCP maximumPoolSize = 20
각 트랜잭션 평균 점유 시간 = 5초
동시 요청 수 = 100&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;20개의 트랜잭션이 외부 API나 긴 연산으로 커넥션을 점유하면 나머지 요청은 커넥션을 얻기 위해 대기한다.&lt;/p&gt;
&lt;p&gt;따라서 MVCC 문제는 단순히 DB 내부 문제로 끝나지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;장기 트랜잭션
→ 오래된 스냅샷 유지
→ Undo/Vacuum/Purge 지연
→ DB 커넥션 장기 점유
→ Connection Pool 대기
→ 애플리케이션 응답 지연
→ 타임아웃 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실무에서는 다음 지표를 함께 확인해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;애플리케이션
- Transaction duration
- Connection acquire time
- Active connection
- Pending connection
- API latency

MySQL
- History list length
- Undo tablespace 사용량
- Purge 지연
- Lock wait

PostgreSQL
- Long-running transaction
- Dead tuple 수
- Autovacuum 진행 상태
- Transaction ID age
- Table bloat

Oracle
- Undo tablespace 사용량
- Undo retention
- Long-running query
- ORA-01555 발생 여부
- Row lock contention&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;18. DB별 MVCC 구현 차이&lt;/h2&gt;
&lt;h3&gt;MySQL InnoDB&lt;/h3&gt;
&lt;p&gt;InnoDB는 현재 레코드를 테이블에 유지하고 이전 버전을 Undo Log에서 재구성한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;현재 행
    |
DB_ROLL_PTR
    |
Undo Record
    |
이전 Undo Record&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특징은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 일반 SELECT는 Consistent Read 사용
- REPEATABLE READ에서는 같은 스냅샷을 재사용
- READ COMMITTED에서는 SQL마다 새 스냅샷 사용
- UPDATE/DELETE 간 충돌은 행 잠금 사용
- 오래된 Undo는 Purge Thread가 정리&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;InnoDB는 삭제도 즉시 물리 제거하지 않고 삭제 표시 후, 해당 이전 버전을 필요로 하는 트랜잭션이 없어지면 Purge 과정에서 정리한다.&lt;/p&gt;
&lt;h3&gt;PostgreSQL&lt;/h3&gt;
&lt;p&gt;PostgreSQL은 수정 시 기존 행을 덮어쓰지 않고 새로운 튜플을 생성한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Tuple V1: 이전 데이터
Tuple V2: 변경 데이터&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특징은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 각 튜플에 생성·삭제 트랜잭션 정보 유지
- 스냅샷과 튜플 정보를 비교해 가시성 판단
- 오래된 버전은 VACUUM이 정리
- 정리가 지연되면 Dead Tuple과 Table Bloat 증가
- Transaction ID Wraparound 방지를 위한 Freeze 필요&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PostgreSQL의 Visibility Map은 모든 활성 트랜잭션에 보이는 튜플만 포함한 페이지와 모든 튜플이 동결된 페이지를 추적하며, VACUUM 및 Index Only Scan 최적화에 활용된다.&lt;/p&gt;
&lt;h3&gt;Oracle&lt;/h3&gt;
&lt;p&gt;Oracle은 데이터 변경 시 Undo Segment에 이전 값을 기록하고, 조회 시 필요한 과거 시점의 블록을 재구성한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;현재 블록
    +
Undo Segment
    =
특정 SCN 시점의 Consistent Read Block&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특징은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- SCN 기반 읽기 일관성
- 기본 격리 수준은 READ COMMITTED
- SQL 문장 단위로 일관된 시점 보장
- Reader와 Writer가 일반적으로 서로를 차단하지 않음
- 필요한 Undo가 사라지면 Snapshot Too Old 발생 가능&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Oracle은 하나의 쿼리가 실행되는 동안 해당 쿼리 시작 시점에 일관된 결과를 제공한다. 쿼리 수행 중 다른 트랜잭션이 데이터를 커밋하더라도, 이미 실행 중인 쿼리에는 중간 변경이 섞이지 않는다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;19. 실무에서 가장 많이 오해하는 부분&lt;/h2&gt;
&lt;h3&gt;“MVCC가 있으면 동시성 문제는 자동으로 해결된다”&lt;/h3&gt;
&lt;p&gt;아니다.&lt;/p&gt;
&lt;p&gt;MVCC는 읽기 가시성을 제어하지만 다음 문제까지 자동으로 해결하지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- Lost Update
- 중복 주문
- 중복 결제
- 재고 초과 차감
- 상태 전이 충돌
- 메시지 중복 소비&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 문제들은 낙관적 락, 비관적 락, 원자적 UPDATE, Unique Constraint, 멱등 키 등의 추가 설계가 필요하다.&lt;/p&gt;
&lt;h3&gt;“일반 SELECT는 항상 최신 데이터를 읽는다”&lt;/h3&gt;
&lt;p&gt;아니다.&lt;/p&gt;
&lt;p&gt;트랜잭션의 격리 수준과 스냅샷에 따라 이전 커밋 버전을 읽을 수 있다.&lt;/p&gt;
&lt;h3&gt;“COMMIT하면 이전 버전은 즉시 삭제된다”&lt;/h3&gt;
&lt;p&gt;아니다.&lt;/p&gt;
&lt;p&gt;과거 스냅샷을 가진 트랜잭션이 존재하면 이전 버전을 계속 보존해야 한다.&lt;/p&gt;
&lt;h3&gt;“MVCC를 사용하면 잠금이 없다”&lt;/h3&gt;
&lt;p&gt;아니다.&lt;/p&gt;
&lt;p&gt;일반 조회가 쓰기를 덜 차단할 뿐, 쓰기 간 충돌과 잠금 조회에는 여전히 잠금이 사용된다.&lt;/p&gt;
&lt;h3&gt;“읽기 전용 트랜잭션은 DB에 부담이 없다”&lt;/h3&gt;
&lt;p&gt;아니다.&lt;/p&gt;
&lt;p&gt;오래된 읽기 전용 트랜잭션도 과거 버전 정리를 지연시킬 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;20. 애플리케이션 설계 시 권장 사항&lt;/h2&gt;
&lt;h3&gt;트랜잭션 범위를 짧게 유지한다&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void updateOrder(Long orderId) {
    Order order = orderRepository.findById(orderId)
        .orElseThrow();

    order.updateStatus();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB 조회와 수정에 필요한 작업만 포함한다.&lt;/p&gt;
&lt;h3&gt;외부 I/O를 트랜잭션에서 분리한다&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;피해야 할 구조

BEGIN
→ DB 조회
→ HTTP 호출
→ MQ 응답 대기
→ 파일 생성
→ DB 수정
→ COMMIT&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;대량 작업은 Chunk 단위로 커밋한다&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;100만 건 1회 트랜잭션
→ 1,000건 × 1,000회 트랜잭션&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;동시 수정 가능성이 있다면 충돌 전략을 명확히 한다&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;충돌 빈도가 낮음
→ 낙관적 락

충돌 빈도가 높고 순차 처리 필요
→ 비관적 락

단순 증감
→ 원자적 UPDATE

중복 생성 방지
→ Unique Constraint

재처리 가능 요청
→ Idempotency Key&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;격리 수준을 무조건 높이지 않는다&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SERIALIZABLE&lt;/code&gt;이 항상 더 좋은 것은 아니다.&lt;/p&gt;
&lt;p&gt;격리 수준이 높아지면 다음 비용이 증가할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 충돌 실패
- 잠금 대기
- 재시도
- 처리량 감소
- 트랜잭션 지연&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;데이터 정합성 요구사항과 허용 가능한 동시성 수준을 기준으로 선택해야 한다.&lt;/p&gt;
&lt;h3&gt;운영에서 장기 트랜잭션을 모니터링한다&lt;/h3&gt;
&lt;p&gt;단순히 Slow Query만 확인해서는 부족하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 오래 열린 트랜잭션
- Idle in Transaction
- 장시간 Connection 점유
- Lock Wait
- Undo 증가
- Vacuum/Purge 지연&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;을 함께 관찰해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;21. 전체 동작 흐름 정리&lt;/h2&gt;
&lt;p&gt;애플리케이션에서 다음 코드가 실행된다고 하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void updateOrder(Long orderId) {
    Order order = orderRepository.findById(orderId)
        .orElseThrow();

    order.complete();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB 내부 흐름은 개념적으로 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Spring이 DB Connection을 획득한다.
2. DB 트랜잭션을 시작한다.
3. SELECT 실행 시 스냅샷이 생성되거나 선택된다.
4. DB는 행의 버전 정보를 확인한다.
5. 현재 스냅샷에서 보이는 버전을 반환한다.
6. UPDATE 시 수정 대상 행의 잠금을 획득한다.
7. 변경 이전 버전을 Undo 또는 별도 튜플로 보존한다.
8. 새로운 데이터 버전을 생성한다.
9. COMMIT 시 변경 트랜잭션을 커밋 상태로 확정한다.
10. 이후 새 스냅샷을 가진 트랜잭션은 새 버전을 볼 수 있다.
11. 과거 버전을 필요로 하는 트랜잭션이 없어지면 정리 작업을 수행한다.
12. Spring이 Connection을 Pool에 반환한다.&lt;/code&gt;&lt;/pre&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;MVCC는 변경 이전의 데이터 버전을 보존하고, 각 트랜잭션의 스냅샷을 기준으로 보이는 버전을 선택함으로써 읽기 일관성과 높은 동시성을 제공한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;애플리케이션 개발자는 일반적으로 MVCC를 직접 구현하지 않는다.&lt;/p&gt;
&lt;p&gt;하지만 다음과 같은 코드를 작성하는 순간 이미 MVCC의 영향을 받고 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void process() {
    repository.findById(id);
    repository.save(entity);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;트랜잭션을 어디서 시작하고 종료하는지, 어떤 격리 수준을 사용하는지, 조회 후 외부 작업을 얼마나 오래 수행하는지에 따라 DB 내부의 스냅샷 유지 시간, Undo 사용량, Vacuum 또는 Purge 지연, 잠금 대기 시간이 달라진다.&lt;/p&gt;
&lt;p&gt;따라서 MVCC는 DBA만 알아야 하는 내부 구조가 아니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 트랜잭션 범위를 설계하는 개발자
- 동시성 문제를 해결하는 개발자
- DB 성능을 분석하는 개발자
- 대량 배치를 운영하는 개발자
- 장애 원인을 추적하는 개발자&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;라면 반드시 이해해야 하는 핵심 원리다.&lt;/p&gt;
&lt;p&gt;MVCC를 실무적으로 이해할 때 가장 중요한 것은 다음 세 문장이다.&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;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&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;MVCC는 읽기와 쓰기의 충돌을 줄이지만, 쓰기 간 충돌과 비즈니스 동시성까지 자동으로 해결하지는 않는다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&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;장기 트랜잭션은 과거 버전 정리를 막아 DB와 커넥션 풀 전체의 성능을 악화시킬 수 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;MySQL 8.0 Reference Manual, InnoDB Multi-Versioning&lt;/li&gt;
&lt;li&gt;MySQL 8.0 Reference Manual, Consistent Nonlocking Reads&lt;/li&gt;
&lt;li&gt;PostgreSQL Documentation, Concurrency Control&lt;/li&gt;
&lt;li&gt;PostgreSQL Documentation, Visibility Map&lt;/li&gt;
&lt;li&gt;Oracle Database Concepts, Data Concurrency and Consistency&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;  </description>
      <category>나의 주니어 개발 일기/DB</category>
      <author>추억을 백앤드하자</author>
      <guid isPermaLink="true">https://pulpul8282.tistory.com/452</guid>
      <comments>https://pulpul8282.tistory.com/452#entry452comment</comments>
      <pubDate>Wed, 29 Jul 2026 10:40:50 +0900</pubDate>
    </item>
    <item>
      <title>Valkey로 구현하는 분산 락: 원리부터 성능&amp;middot;커넥션&amp;middot;장애 대응까지</title>
      <link>https://pulpul8282.tistory.com/451</link>
      <description>&lt;div class=&quot;markdown-body&quot;&gt;
&lt;h1&gt;Valkey로 구현하는 분산 락: 원리부터 성능·커넥션·장애 대응까지&lt;/h1&gt;
&lt;p&gt;여러 서버 인스턴스가 동일한 데이터를 동시에 변경하는 환경에서는 애플리케이션 내부의 &lt;code&gt;synchronized&lt;/code&gt;나 &lt;code&gt;ReentrantLock&lt;/code&gt;만으로 동시성을 제어할 수 없다.&lt;/p&gt;
&lt;p&gt;예를 들어 주문 서버가 세 대 실행 중이고, 동일한 쿠폰을 동시에 발급하려는 요청이 각각 다른 서버로 들어온다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Client A → Application 1
Client B → Application 2
Client C → Application 3&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 애플리케이션의 JVM 내부 락은 자신의 프로세스 안에서만 유효하다. 따라서 Application 1이 락을 획득해도 Application 2와 Application 3은 이를 알 수 없다.&lt;/p&gt;
&lt;p&gt;이처럼 여러 프로세스나 서버가 공유하는 자원에 대해 상호 배제를 보장하기 위해 사용하는 것이 &lt;strong&gt;분산 락&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;Valkey는 메모리 기반의 빠른 명령 처리와 원자적 연산을 제공하기 때문에 분산 락 저장소로 자주 활용된다. 그러나 단순히 &lt;code&gt;SETNX&lt;/code&gt;를 호출하는 것만으로 안전한 분산 락이 완성되는 것은 아니다.&lt;/p&gt;
&lt;p&gt;실무에서는 다음 문제를 함께 고려해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;락 소유자 식별&lt;/li&gt;
&lt;li&gt;락 만료 시간&lt;/li&gt;
&lt;li&gt;락 해제의 원자성&lt;/li&gt;
&lt;li&gt;작업 시간이 TTL을 초과하는 상황&lt;/li&gt;
&lt;li&gt;재시도와 락 경합&lt;/li&gt;
&lt;li&gt;Valkey 장애와 Failover&lt;/li&gt;
&lt;li&gt;커넥션 풀과 명령 지연&lt;/li&gt;
&lt;li&gt;락을 획득했지만 DB 작업이 중복되는 상황&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 글에서는 Valkey 기반 분산 락의 동작 원리부터 Spring 환경의 구현, 성능, 커넥션 처리 및 운영 시 주의사항까지 정리한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 분산 락이 필요한 상황&lt;/h2&gt;
&lt;p&gt;분산 락은 여러 인스턴스가 동일한 자원을 동시에 변경할 가능성이 있을 때 필요하다.&lt;/p&gt;
&lt;p&gt;대표적인 사례는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;한정 수량 쿠폰 발급
재고 차감
중복 결제 방지
정산 배치 중복 실행 방지
스케줄러 중복 실행 방지
동일 사용자의 상태 변경
외부 시스템 중복 요청 제한&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 재고가 한 개 남은 상황에서 두 요청이 동시에 처리되면 다음과 같은 문제가 발생할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;요청 A: 재고 조회 → 1
요청 B: 재고 조회 → 1

요청 A: 재고 차감 → 0
요청 B: 재고 차감 → 0&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 요청 모두 재고가 존재한다고 판단했기 때문에 실제 재고보다 많은 주문이 승인될 수 있다.&lt;/p&gt;
&lt;p&gt;이를 방지하려면 동일한 상품에 대한 변경 작업이 동시에 실행되지 않도록 임계 구역을 만들어야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lock:product:100&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;먼저 락을 획득한 요청만 재고를 변경하고, 나머지 요청은 대기하거나 실패하도록 처리한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. Valkey 분산 락의 기본 원리&lt;/h2&gt;
&lt;p&gt;Valkey에서는 다음 명령으로 락을 획득할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SET lock:product:100 request-uuid NX PX 3000&lt;/code&gt;&lt;/pre&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;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lock:product:100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;락을 식별하는 키&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;request-uuid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;락 소유자를 식별하는 고유 값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;키가 존재하지 않을 때만 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PX 3000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3초 후 자동 만료&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;SET&lt;/code&gt; 명령은 &lt;code&gt;NX&lt;/code&gt;와 &lt;code&gt;PX&lt;/code&gt; 옵션을 함께 사용할 수 있으며, 조건 확인과 키 저장, TTL 설정을 하나의 명령으로 처리한다. 명령의 시간 복잡도도 &lt;code&gt;O(1)&lt;/code&gt;이다.&lt;/p&gt;
&lt;p&gt;락 키가 존재하지 않으면 다음과 같이 생성된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;SET lock:product:100 request-a NX PX 3000
→ OK&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이미 다른 요청이 락을 획득했다면 키가 존재하므로 저장되지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;SET lock:product:100 request-b NX PX 3000
→ nil&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 연산이 원자적으로 실행되기 때문에 여러 애플리케이션 인스턴스가 동시에 요청하더라도 한 요청만 락을 획득할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. 왜 SETNX와 EXPIRE를 따로 실행하면 안 되는가&lt;/h2&gt;
&lt;p&gt;다음과 같이 락 획득과 TTL 설정을 분리해서는 안 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SETNX lock:product:100 request-a
EXPIRE lock:product:100 3&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 명령 사이에서 애플리케이션이 종료될 수 있기 때문이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. SETNX 성공
2. 애플리케이션 장애
3. EXPIRE 실행 실패&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우 락 키에 TTL이 설정되지 않아 영구적으로 남을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lock:product:100 → 영구 락&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 락 생성과 TTL 설정은 반드시 하나의 &lt;code&gt;SET NX PX&lt;/code&gt; 명령으로 처리해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SET lock:product:100 request-a NX PX 3000&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;4. 락 값에는 반드시 소유자 식별자가 필요하다&lt;/h2&gt;
&lt;p&gt;락 값으로 &lt;code&gt;&amp;quot;LOCKED&amp;quot;&lt;/code&gt;와 같은 고정 문자열을 사용하면 안 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SET lock:product:100 LOCKED NX PX 3000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다음과 같은 상황을 생각해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 요청 A가 락 획득
2. 요청 A의 작업이 지연
3. 락 TTL 만료
4. 요청 B가 동일한 락 획득
5. 요청 A의 작업 완료
6. 요청 A가 락 삭제&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;요청 A가 단순히 &lt;code&gt;DEL&lt;/code&gt;을 실행하면 요청 B가 새로 획득한 락까지 삭제해 버린다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;DEL lock:product:100&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 락을 획득할 때 요청마다 고유한 값을 저장해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;String lockValue = UUID.randomUUID().toString();
SET lock:product:100 9352146e-... NX PX 3000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락을 해제할 때는 현재 저장된 값이 자신이 저장한 값과 같은 경우에만 삭제해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5. GET과 DEL을 분리하면 발생하는 경쟁 조건&lt;/h2&gt;
&lt;p&gt;다음 구현도 안전하지 않다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;String currentValue = redisTemplate.opsForValue().get(lockKey);

if (lockValue.equals(currentValue)) {
    redisTemplate.delete(lockKey);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GET&lt;/code&gt;과 &lt;code&gt;DEL&lt;/code&gt; 사이에 락이 만료될 수 있기 때문이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 요청 A가 GET으로 자신의 값 확인
2. A의 락 TTL 만료
3. 요청 B가 락 획득
4. 요청 A가 DEL 실행
5. 요청 B의 락이 삭제됨&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 값 비교와 삭제가 반드시 원자적으로 실행되어야 한다.&lt;/p&gt;
&lt;p&gt;이를 위해 Lua 스크립트를 사용할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;if valkey.call(&amp;#39;GET&amp;#39;, KEYS[1]) == ARGV[1] then
    return valkey.call(&amp;#39;DEL&amp;#39;, KEYS[1])
else
    return 0
end&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Valkey의 Lua 스크립트는 서버 내부에서 실행되므로 값 확인과 삭제를 하나의 원자적 작업으로 처리할 수 있다. 스크립트가 접근하는 키는 &lt;code&gt;KEYS&lt;/code&gt; 인자로 명시적으로 전달해야 하며, 이는 Cluster 환경에서 올바른 키 라우팅을 위해서도 필요하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. Spring Boot에서 구현하기&lt;/h2&gt;
&lt;p&gt;다음은 &lt;code&gt;StringRedisTemplate&lt;/code&gt;을 이용한 기본적인 분산 락 구현이다. Spring Data Redis의 API 명칭에는 Redis가 포함되어 있지만, Valkey가 제공하는 호환 프로토콜과 명령을 통해 사용할 수 있다.&lt;/p&gt;
&lt;h3&gt;락 획득&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
@RequiredArgsConstructor
public class ValkeyDistributedLock {

    private static final DefaultRedisScript&amp;lt;Long&amp;gt; UNLOCK_SCRIPT =
        new DefaultRedisScript&amp;lt;&amp;gt;(
            &amp;quot;&amp;quot;&amp;quot;
            if redis.call(&amp;#39;GET&amp;#39;, KEYS[1]) == ARGV[1] then
                return redis.call(&amp;#39;DEL&amp;#39;, KEYS[1])
            else
                return 0
            end
            &amp;quot;&amp;quot;&amp;quot;,
            Long.class
        );

    private final StringRedisTemplate redisTemplate;

    public boolean tryLock(
        String lockKey,
        String lockValue,
        Duration leaseTime
    ) {
        Boolean acquired = redisTemplate.opsForValue().setIfAbsent(
            lockKey,
            lockValue,
            leaseTime
        );

        return Boolean.TRUE.equals(acquired);
    }

    public boolean unlock(
        String lockKey,
        String lockValue
    ) {
        Long result = redisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(lockKey),
            lockValue
        );

        return result != null &amp;amp;&amp;amp; result == 1L;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;서비스 적용&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Service
@RequiredArgsConstructor
public class CouponService {

    private final ValkeyDistributedLock distributedLock;
    private final CouponRepository couponRepository;

    @Transactional
    public void issueCoupon(
        Long couponId,
        Long userId
    ) {
        String lockKey = &amp;quot;lock:coupon:&amp;quot; + couponId;
        String lockValue = UUID.randomUUID().toString();

        boolean acquired = distributedLock.tryLock(
            lockKey,
            lockValue,
            Duration.ofSeconds(3)
        );

        if (!acquired) {
            throw new IllegalStateException(
                &amp;quot;현재 다른 요청에서 쿠폰을 처리하고 있습니다.&amp;quot;
            );
        }

        try {
            Coupon coupon = couponRepository.findById(couponId)
                .orElseThrow();

            coupon.issueTo(userId);
        } finally {
            distributedLock.unlock(lockKey, lockValue);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락 해제는 반드시 &lt;code&gt;finally&lt;/code&gt;에서 실행해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;try {
    // 임계 구역
} finally {
    // 락 해제
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그러나 이 구현만으로 모든 문제가 해결되는 것은 아니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7. 트랜잭션 종료 전에 락을 해제하면 안 된다&lt;/h2&gt;
&lt;p&gt;Spring의 &lt;code&gt;@Transactional&lt;/code&gt;은 프록시를 통해 메서드 호출 이후 트랜잭션을 커밋한다.&lt;/p&gt;
&lt;p&gt;다음 구조를 사용하면 코드상 임계 구역이 끝났더라도 실제 DB 커밋은 아직 완료되지 않았을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void process() {
    try {
        updateDatabase();
    } finally {
        unlock();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실행 순서는 다음과 같이 될 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 락 획득
2. DB 데이터 변경
3. finally에서 락 해제
4. 메서드 반환
5. Spring 트랜잭션 커밋&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;3번과 5번 사이에 다른 요청이 락을 획득하면 아직 커밋되지 않은 데이터에 접근하거나, 동일한 업무를 다시 처리할 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 분산 락의 범위가 DB 트랜잭션 전체를 감싸도록 구성하는 것이 안전하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public void issueCoupon(Long couponId, Long userId) {
    String lockKey = &amp;quot;lock:coupon:&amp;quot; + couponId;
    String lockValue = UUID.randomUUID().toString();

    if (!distributedLock.tryLock(
        lockKey,
        lockValue,
        Duration.ofSeconds(3)
    )) {
        throw new LockAcquisitionException();
    }

    try {
        transactionTemplate.executeWithoutResult(status -&amp;gt;
            issueCouponInTransaction(couponId, userId)
        );
    } finally {
        distributedLock.unlock(lockKey, lockValue);
    }
}
private void issueCouponInTransaction(
    Long couponId,
    Long userId
) {
    Coupon coupon = couponRepository.findById(couponId)
        .orElseThrow();

    coupon.issueTo(userId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실행 순서는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 락 획득
2. 트랜잭션 시작
3. DB 변경
4. 트랜잭션 커밋
5. 락 해제&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락을 획득하는 메서드와 트랜잭션 메서드를 별도 빈으로 분리하는 방법도 사용할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. TTL은 얼마나 설정해야 하는가&lt;/h2&gt;
&lt;p&gt;TTL이 너무 짧으면 작업 도중 락이 만료될 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;락 TTL: 3초
업무 처리: 5초
0초: 요청 A 락 획득
3초: A의 락 만료
3.1초: 요청 B 락 획득
5초: 요청 A 작업 완료&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;3.1초부터 A와 B가 동시에 임계 구역을 실행하게 된다.&lt;/p&gt;
&lt;p&gt;반대로 TTL이 지나치게 길면 애플리케이션 장애 시 다른 요청이 오랜 시간 동안 자원에 접근하지 못한다.&lt;/p&gt;
&lt;p&gt;따라서 TTL은 감으로 정하기보다 실제 처리 시간의 분포를 기준으로 정해야 한다.&lt;/p&gt;
&lt;p&gt;예를 들어 다음 값을 모니터링한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;업무 처리 시간 P50: 80ms
업무 처리 시간 P95: 250ms
업무 처리 시간 P99: 800ms
최대 관측 시간: 1.8초&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우 100ms나 300ms를 TTL로 설정하는 것은 위험하다.&lt;/p&gt;
&lt;p&gt;일반적으로 다음 요소를 포함해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;TTL &amp;gt;
업무 처리 P99
+ DB 지연 여유
+ 네트워크 지연
+ GC Pause 여유
+ 장애 감지 여유&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 TTL을 길게 설정하는 것만으로는 긴 작업을 안전하게 처리할 수 없다. 작업 시간이 유동적이라면 락 연장 기능을 고려해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;9. 락 자동 연장과 Watchdog&lt;/h2&gt;
&lt;p&gt;업무 처리 시간이 일정하지 않은 경우 락을 획득한 클라이언트가 주기적으로 TTL을 연장할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;락 TTL: 9초
3초마다 TTL 연장&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;단, TTL 연장 역시 현재 락 소유자가 자신인지 확인한 뒤 실행해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;if redis.call(&amp;#39;GET&amp;#39;, KEYS[1]) == ARGV[1] then
    return redis.call(&amp;#39;PEXPIRE&amp;#39;, KEYS[1], ARGV[2])
else
    return 0
end&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락 소유권을 확인하지 않고 &lt;code&gt;PEXPIRE&lt;/code&gt;를 호출하면 다른 요청이 획득한 락을 연장할 수 있다.&lt;/p&gt;
&lt;p&gt;Watchdog를 사용할 때는 다음 문제도 고려해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;애플리케이션의 Stop-the-World GC&lt;/li&gt;
&lt;li&gt;스레드 풀 고갈&lt;/li&gt;
&lt;li&gt;네트워크 단절&lt;/li&gt;
&lt;li&gt;Valkey 응답 지연&lt;/li&gt;
&lt;li&gt;연장 스케줄러 중단&lt;/li&gt;
&lt;li&gt;애플리케이션 프로세스 정지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;따라서 Watchdog는 락 만료 문제를 완전히 제거하는 장치가 아니라, 정상적인 처리 시간이 길어지는 상황에서 락을 연장하는 보조 장치로 이해해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;10. 재시도는 즉시 반복하면 안 된다&lt;/h2&gt;
&lt;p&gt;락 획득에 실패했을 때 다음과 같이 즉시 반복하면 Valkey에 불필요한 부하를 발생시킨다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;while (!tryLock()) {
    // 즉시 재시도
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락 경합이 심할수록 모든 요청이 같은 키를 향해 &lt;code&gt;SET NX&lt;/code&gt;를 계속 호출한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;100개 요청
× 초당 1,000회 재시도
= 초당 100,000회 락 획득 시도&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 업무는 하나의 요청만 처리하면서 Valkey에는 대량의 실패 명령이 발생하는 구조가 된다.&lt;/p&gt;
&lt;p&gt;다음과 같이 최대 대기 시간과 재시도 간격을 제한해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public boolean tryLockWithRetry(
    String lockKey,
    String lockValue,
    Duration waitTime,
    Duration leaseTime
) {
    long deadline = System.nanoTime() + waitTime.toNanos();

    while (System.nanoTime() &amp;lt; deadline) {
        if (tryLock(lockKey, lockValue, leaseTime)) {
            return true;
        }

        sleepWithJitter();
    }

    return false;
}
private void sleepWithJitter() {
    long sleepMillis = ThreadLocalRandom.current()
        .nextLong(30, 80);

    try {
        Thread.sleep(sleepMillis);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new IllegalStateException(
            &amp;quot;락 대기 중 인터럽트가 발생했습니다.&amp;quot;,
            e
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;고정된 재시도 주기보다 무작위 지연인 Jitter를 추가하는 것이 좋다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;고정 주기:
요청 A → 50ms
요청 B → 50ms
요청 C → 50ms

Jitter:
요청 A → 37ms
요청 B → 58ms
요청 C → 71ms&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;모든 요청이 같은 시점에 다시 경쟁하는 Thundering Herd 현상을 완화할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;11. 락의 성능은 명령 속도만 보면 안 된다&lt;/h2&gt;
&lt;p&gt;Valkey의 &lt;code&gt;SET&lt;/code&gt;과 &lt;code&gt;DEL&lt;/code&gt;은 단순한 키 연산이므로 자체 명령 비용은 작다. 하지만 분산 락의 실제 지연 시간은 서버 내부 명령 실행 시간보다 네트워크 왕복과 경합의 영향을 더 크게 받는다.&lt;/p&gt;
&lt;p&gt;락을 한 번 처리할 때 최소 다음 통신이 발생한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;락 획득: SET NX PX
락 해제: Lua Script 또는 조건부 삭제&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 락이 없는 경우와 비교하면 최소 두 번의 Valkey 요청이 추가된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;요청 처리 시간
= 락 획득 RTT
+ 업무 처리 시간
+ 락 해제 RTT&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락 획득에 실패해 재시도까지 발생하면 요청 수는 더 증가한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;총 Valkey 명령 수
= 최초 획득 시도
+ 재시도 횟수
+ 락 해제
+ TTL 연장 횟수&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;때문에 다음과 같은 구조는 성능상 좋지 않다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;모든 주문 요청
→ 전역 락 lock:order&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;모든 주문이 하나의 키를 경합하기 때문에 전체 처리가 직렬화된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;요청 1 → 처리
요청 2 → 대기
요청 3 → 대기
요청 4 → 대기&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락 키는 가능한 한 업무 자원 단위로 세분화해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lock:order:{orderId}
lock:product:{productId}
lock:user:{userId}
lock:coupon:{couponId}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 상품별 락을 사용하면 서로 다른 상품의 주문은 병렬 처리할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lock:product:100 → 요청 A
lock:product:200 → 요청 B
lock:product:300 → 요청 C&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 락 키를 지나치게 세분화하면 하나의 업무가 여러 자원을 잠가야 하는 문제가 생길 수 있다. 이 경우 교착 상태를 방지하기 위해 락 획득 순서를 고정해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;List&amp;lt;Long&amp;gt; productIds = request.productIds().stream()
    .sorted()
    .toList();
항상 작은 ID부터 락 획득
100 → 200 → 300&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;12. 임계 구역을 짧게 유지해야 한다&lt;/h2&gt;
&lt;p&gt;분산 락 내부에 느린 작업을 포함하면 락 점유 시간이 증가한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;try {
    callExternalApi();
    uploadFile();
    sendEmail();
    updateDatabase();
} finally {
    unlock();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;외부 API 호출이나 파일 업로드는 지연 시간의 편차가 크고 장애 시 타임아웃까지 발생할 수 있다.&lt;/p&gt;
&lt;p&gt;가능하다면 락 내부에는 동시성 제어가 반드시 필요한 작업만 포함해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;ExternalResult result = callExternalApi();

try {
    updateSharedState(result);
} finally {
    unlock();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;또는 DB 상태 변경만 락 내부에서 수행하고, 알림이나 후속 작업은 Outbox 또는 MQ를 통해 분리할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;분산 락 획득
→ DB 상태 변경
→ Outbox 저장
→ 트랜잭션 커밋
→ 락 해제
→ MQ 발행
→ 알림 처리&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락 점유 시간이 짧아질수록 처리량은 증가하고 락 만료 가능성도 낮아진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;13. 커넥션을 요청마다 생성하면 안 된다&lt;/h2&gt;
&lt;p&gt;분산 락은 요청마다 최소 두 번 이상의 Valkey 명령을 실행한다. 이때 매번 TCP 커넥션을 생성하면 다음 비용이 추가된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;TCP 연결
TLS Handshake
인증
명령 전송
연결 종료&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 애플리케이션은 장기 연결 또는 커넥션 풀을 사용해야 한다.&lt;/p&gt;
&lt;p&gt;Valkey 서버는 클라이언트 연결을 non-blocking 소켓으로 처리하며 &lt;code&gt;TCP_NODELAY&lt;/code&gt;를 설정한다. 동시에 연결 가능한 클라이언트 수는 &lt;code&gt;maxclients&lt;/code&gt; 설정에 의해 제한되며, 지나치게 많은 연결은 클라이언트 버퍼 메모리와 서버 리소스를 소비할 수 있다. Valkey는 전체 클라이언트 메모리를 제한하기 위한 &lt;code&gt;maxmemory-clients&lt;/code&gt;와 클라이언트 축출 기능도 제공한다.&lt;/p&gt;
&lt;p&gt;Spring Data Redis에서 Lettuce를 사용할 경우 일반 명령은 기본적으로 thread-safe한 native connection을 공유할 수 있다. 반면 Spring Data Redis의 &lt;code&gt;LettuceConnection&lt;/code&gt; 객체 자체는 thread-safe하지 않으므로 직접 보관해 여러 스레드에서 공유해서는 안 된다. Blocking 명령과 트랜잭션 명령은 별도의 연결 또는 풀을 사용할 수 있다.&lt;/p&gt;
&lt;p&gt;일반적인 분산 락 명령은 다음과 같은 특성을 가진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;SET NX PX
GET
DEL
EVALSHA
PEXPIRE&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 명령들은 &lt;code&gt;BLPOP&lt;/code&gt;과 같은 blocking 명령이 아니므로 일반적인 Lettuce 환경에서는 공유 native connection을 활용할 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 단순히 처리량을 높이기 위해 풀 크기부터 무조건 늘리는 것은 적절하지 않다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;14. 커넥션 풀이 필요한 경우&lt;/h2&gt;
&lt;p&gt;다음과 같은 경우에는 전용 커넥션 또는 커넥션 풀을 검토할 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Blocking 명령을 함께 사용하는 경우&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MULTI&lt;/code&gt;/&lt;code&gt;EXEC&lt;/code&gt; 트랜잭션을 사용하는 경우&lt;/li&gt;
&lt;li&gt;매우 큰 Value를 읽거나 쓰는 경우&lt;/li&gt;
&lt;li&gt;Pub/Sub 트래픽이 매우 높은 경우&lt;/li&gt;
&lt;li&gt;하나의 연결에서 Head-of-line Blocking이 관측되는 경우&lt;/li&gt;
&lt;li&gt;일반 캐시 트래픽과 락 트래픽을 격리해야 하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Valkey GLIDE는 비동기 multiplex 연결을 기반으로 하며 대부분의 일반 워크로드에서 하나의 클라이언트 인스턴스를 재사용하도록 권장한다. 다만 blocking 명령, 큰 값, 높은 Pub/Sub 처리량, connection state에 영향을 미치는 명령은 별도 클라이언트를 사용하는 것이 권장된다. Cluster에서는 클라이언트 하나가 각 노드에 연결을 유지하므로 클라이언트 인스턴스를 무분별하게 늘리면 전체 연결 수가 빠르게 증가할 수 있다.&lt;/p&gt;
&lt;p&gt;예를 들어 애플리케이션 인스턴스가 20개이고 Valkey Cluster가 6개 노드로 구성되어 있다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;애플리케이션 인스턴스: 20
클라이언트 인스턴스: 애플리케이션당 10
Cluster 노드: 6

예상 연결 수:
20 × 10 × 6 = 1,200&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;클라이언트 수를 늘리는 것이 곧바로 처리량 증가를 의미하지는 않는다. 오히려 서버 연결 수와 메모리 사용량만 증가할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;15. 커넥션 풀 크기는 어떻게 산정하는가&lt;/h2&gt;
&lt;p&gt;동기식 커넥션 풀을 사용한다면 풀 크기는 동시 요청 수가 아니라 &lt;strong&gt;동시에 Valkey 커넥션을 점유하는 명령 수&lt;/strong&gt;를 기준으로 판단해야 한다.&lt;/p&gt;
&lt;p&gt;대략적인 출발점은 다음과 같이 생각할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;필요 커넥션 수
≈ 초당 Valkey 요청 수 × 평균 커넥션 점유 시간&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 다음 조건이라면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 요청 수: 5,000 TPS
평균 왕복 및 처리 시간: 2ms
5,000 × 0.002 = 10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;평균적으로 약 10개의 동시 점유가 발생한다.&lt;/p&gt;
&lt;p&gt;다만 실제 설정에서는 다음 요소를 추가로 고려해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;P95/P99 지연 시간&lt;/li&gt;
&lt;li&gt;순간 트래픽&lt;/li&gt;
&lt;li&gt;장애 시 재시도 증가&lt;/li&gt;
&lt;li&gt;Cluster 리다이렉션&lt;/li&gt;
&lt;li&gt;TLS 사용 여부&lt;/li&gt;
&lt;li&gt;서버 Failover&lt;/li&gt;
&lt;li&gt;GC Pause&lt;/li&gt;
&lt;li&gt;락 경합률&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;풀 크기를 지나치게 크게 잡으면 애플리케이션이 Valkey 장애 시 많은 요청을 동시에 밀어 넣어 오히려 장애를 확대할 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 풀 크기와 함께 다음 설정이 중요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;connection timeout
command timeout
max total
max idle
min idle
max wait
validation policy&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특히 &lt;code&gt;max wait&lt;/code&gt;를 무제한으로 두면 Valkey 장애 시 요청 스레드가 커넥션을 기다리며 계속 쌓일 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 장애
→ 커넥션 대기
→ 요청 스레드 고갈
→ HTTP 요청 적체
→ 전체 서비스 장애&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락 저장소 장애가 전체 서비스 장애로 전파되지 않도록 짧고 명확한 타임아웃과 실패 정책을 설정해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;16. 캐시와 분산 락 커넥션을 분리해야 하는가&lt;/h2&gt;
&lt;p&gt;규모가 작다면 동일한 &lt;code&gt;RedisConnectionFactory&lt;/code&gt;를 사용해도 문제가 없는 경우가 많다.&lt;/p&gt;
&lt;p&gt;그러나 다음 상황에서는 락 전용 클라이언트나 커넥션 구성을 분리할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;대용량 캐시 GET/SET
높은 Pub/Sub 트래픽
대형 Value 조회
Blocking 명령
중요한 결제·정산 락&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 캐시에서 수 MB의 값을 대량 조회하면 하나의 multiplex 연결에서 작은 락 명령이 뒤로 밀릴 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;대형 GET 응답
→ 락 SET NX 대기
→ 락 획득 지연
→ 요청 타임아웃&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우 다음처럼 역할을 분리할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Cache Valkey Client
Distributed Lock Valkey Client
Pub/Sub Valkey Client&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;반드시 Valkey 서버 자체를 분리해야 한다는 의미는 아니다. 우선 클라이언트와 커넥션을 분리하고, 실제 지연과 장애 격리 요구가 크다면 별도 Valkey 인스턴스 또는 Cluster 분리를 검토한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;17. Lua Script 성능과 EVALSHA&lt;/h2&gt;
&lt;p&gt;락 해제나 연장에는 Lua 스크립트가 사용된다.&lt;/p&gt;
&lt;p&gt;매번 &lt;code&gt;EVAL&lt;/code&gt;로 전체 스크립트 문자열을 전송하는 것보다 스크립트를 미리 로드하고 SHA 값으로 실행하는 &lt;code&gt;EVALSHA&lt;/code&gt;를 사용할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SCRIPT LOAD &amp;quot;if redis.call(...) ...&amp;quot;
EVALSHA &amp;lt;sha1&amp;gt; 1 lock:product:100 request-uuid&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Valkey 공식 문서에서도 동일한 스크립트를 반복해서 &lt;code&gt;EVAL&lt;/code&gt;로 전송하면 네트워크 대역폭과 처리 오버헤드가 발생하므로 스크립트 캐시 사용을 설명하고 있다. 스크립트는 동적으로 문자열을 생성하기보다 인자를 통해 값을 전달해야 하며, 동적 스크립트를 계속 생성하면 스크립트 캐시 메모리가 증가할 수 있다.&lt;/p&gt;
&lt;p&gt;일반적으로 Spring Data Redis의 &lt;code&gt;DefaultRedisScript&lt;/code&gt;는 스크립트 SHA를 이용한 실행을 우선 시도하고, 서버에 스크립트가 없으면 다시 로드하는 방식으로 사용할 수 있다.&lt;/p&gt;
&lt;p&gt;스크립트 자체도 짧게 유지해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;GET
값 비교
DEL&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lua 내부에서 반복문이나 다량의 키 조회를 수행하면 Valkey의 명령 처리 지연을 유발할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;18. 분산 락과 Valkey Failover의 한계&lt;/h2&gt;
&lt;p&gt;Valkey Primary와 Replica는 일반적으로 비동기 복제를 사용한다.&lt;/p&gt;
&lt;p&gt;다음 상황이 발생할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Client A가 Primary에서 락 획득
2. 락 정보가 Replica로 복제되기 전 Primary 장애
3. Replica가 새로운 Primary로 승격
4. 새로운 Primary에는 락 정보가 없음
5. Client B가 동일한 락 획득&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과적으로 A와 B가 동시에 락을 보유했다고 판단할 수 있다.&lt;/p&gt;
&lt;p&gt;Valkey Cluster 역시 비동기 복제를 사용하며, Failover 또는 네트워크 Partition 상황에서는 이미 승인된 쓰기가 손실될 수 있는 작은 시간 구간이 존재한다고 명시한다. 따라서 단일 Primary 기반 락은 정상 상태에서 상호 배제에는 유용하지만, 모든 장애 상황에서 절대적인 락 안전성을 보장하는 합의 시스템은 아니다.&lt;/p&gt;
&lt;p&gt;이 점은 결제, 정산, 재고와 같이 정확성이 중요한 도메인에서 매우 중요하다.&lt;/p&gt;
&lt;p&gt;분산 락만 믿지 말고 DB 제약 조건과 멱등 처리를 함께 적용해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Valkey 분산 락
+ DB Unique Constraint
+ 낙관적 락
+ 상태 전이 조건
+ 멱등 키&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;19. Fencing Token이 필요한 이유&lt;/h2&gt;
&lt;p&gt;락 TTL과 네트워크 지연을 고려하면 이전 락 소유자가 뒤늦게 작업을 완료하는 문제를 완전히 제거하기 어렵다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 요청 A가 락 획득
2. A가 장시간 정지
3. A의 락 만료
4. 요청 B가 락 획득
5. B가 데이터 변경
6. A가 다시 실행되어 이전 데이터를 기록&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락이 만료됐더라도 요청 A는 자신이 락을 잃었다는 사실을 모를 수 있다.&lt;/p&gt;
&lt;p&gt;이 문제를 방지하는 방법 중 하나가 &lt;strong&gt;Fencing Token&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;락을 획득할 때 증가하는 순번을 함께 발급한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;INCR lock:product:100:token
요청 A → token 41
요청 B → token 42&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;데이터를 저장하는 시스템은 이전 토큰보다 큰 토큰만 허용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;현재 저장 토큰: 42
요청 A의 토큰: 41

41 &amp;lt; 42
→ 요청 거부&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fencing Token은 락을 잃은 오래된 클라이언트가 뒤늦게 데이터를 덮어쓰는 것을 방지한다.&lt;/p&gt;
&lt;p&gt;단, 최종 저장소가 토큰을 검증할 수 있어야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE inventory
SET quantity = quantity - 1,
    fencing_token = :newToken
WHERE product_id = :productId
  AND fencing_token &amp;lt; :newToken;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;영향받은 행이 0건이면 오래된 토큰의 요청으로 판단한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;20. 분산 락이 필요하지 않을 수도 있다&lt;/h2&gt;
&lt;p&gt;분산 락은 편리하지만 시스템 복잡도와 네트워크 의존성을 높인다.&lt;/p&gt;
&lt;p&gt;DB 하나에서 데이터 변경이 끝나는 경우에는 DB 동시성 제어가 더 단순하고 강한 보장을 제공할 수 있다.&lt;/p&gt;
&lt;h3&gt;비관적 락&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT *
FROM inventory
WHERE product_id = 100
FOR UPDATE;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;낙관적 락&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE inventory
SET quantity = quantity - 1,
    version = version + 1
WHERE product_id = 100
  AND version = 7;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;조건부 갱신&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = 100
  AND quantity &amp;gt; 0;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Unique Constraint&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ALTER TABLE coupon_issue
ADD CONSTRAINT uk_coupon_user
UNIQUE (coupon_id, user_id);&lt;/code&gt;&lt;/pre&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;우선 고려&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;단일 DB row 변경&lt;/td&gt;
&lt;td&gt;조건부 UPDATE, 비관적·낙관적 락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;중복 데이터 생성 방지&lt;/td&gt;
&lt;td&gt;Unique Constraint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&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;분산 락 또는 DB 리더 선출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 API 포함 장시간 작업&lt;/td&gt;
&lt;td&gt;멱등 키, 상태 머신, Outbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&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;분산 락은 DB의 정합성 제약을 대체하는 수단이 아니라 애플리케이션 간 동시 실행을 줄이는 수단으로 사용하는 것이 안전하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;21. 락 실패 정책을 먼저 정해야 한다&lt;/h2&gt;
&lt;p&gt;락 획득 실패 시 무조건 재시도하는 것이 정답은 아니다.&lt;/p&gt;
&lt;p&gt;업무 특성에 따라 정책이 달라야 한다.&lt;/p&gt;
&lt;h3&gt;즉시 실패&lt;/h3&gt;
&lt;p&gt;중복 실행을 피하는 것이 중요하고 사용자가 다시 요청할 수 있는 경우다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;HTTP 409 Conflict
현재 처리 중입니다.&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;일정 시간 대기&lt;/h3&gt;
&lt;p&gt;짧은 임계 구역이며 잠시 대기하면 처리가 가능한 경우다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;최대 대기: 500ms
재시도 간격: 30~80ms&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;비동기 전환&lt;/h3&gt;
&lt;p&gt;요청을 즉시 처리할 필요가 없다면 MQ로 전달할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;요청
→ MQ 적재
→ Consumer가 자원 단위로 순차 처리&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;기존 결과 반환&lt;/h3&gt;
&lt;p&gt;동일한 멱등 키로 이미 처리 중이거나 완료된 경우 기존 처리 결과를 반환할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;idempotencyKey
→ PROCESSING
→ COMPLETED&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락 획득 실패를 단순한 시스템 오류로 처리하기보다 업무 상태로 설계하는 것이 좋다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;22. 모니터링해야 할 지표&lt;/h2&gt;
&lt;p&gt;분산 락은 정상 동작 여부뿐만 아니라 경합과 지연을 관찰해야 한다.&lt;/p&gt;
&lt;h3&gt;애플리케이션 지표&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lock.acquire.success
lock.acquire.failure
lock.acquire.timeout
lock.acquire.latency
lock.hold.duration
lock.retry.count
lock.unlock.failure
lock.renew.failure&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Valkey 지표&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;connected_clients
blocked_clients
rejected_connections
instantaneous_ops_per_sec
used_memory_clients
evicted_clients
keyspace_hits
keyspace_misses
latency
commandstats&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특히 다음 상황은 경고 대상으로 삼을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;락 획득 실패율 급증
락 대기 시간 P99 증가
락 점유 시간 TTL 근접
Unlock 실패 발생
TTL 연장 실패
Valkey 커넥션 대기 증가
Command Timeout 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락 획득률만 보면 안 된다.&lt;/p&gt;
&lt;p&gt;예를 들어 락 성공률이 100%라도 평균 락 점유 시간이 계속 증가하면 시스템 처리량은 감소하고 있을 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;23. 운영 체크리스트&lt;/h2&gt;
&lt;p&gt;Valkey 분산 락을 운영에 적용할 때는 최소한 다음 항목을 확인해야 한다.&lt;/p&gt;
&lt;h3&gt;락 구현&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SET NX PX&lt;/code&gt;를 한 번에 실행한다.&lt;/li&gt;
&lt;li&gt;락 값에는 요청별 고유 식별자를 저장한다.&lt;/li&gt;
&lt;li&gt;락 해제는 Lua Script로 원자적으로 처리한다.&lt;/li&gt;
&lt;li&gt;락 해제는 반드시 &lt;code&gt;finally&lt;/code&gt;에서 수행한다.&lt;/li&gt;
&lt;li&gt;트랜잭션 커밋 이후 락이 해제되도록 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;TTL&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;실제 처리 시간의 P99를 측정한다.&lt;/li&gt;
&lt;li&gt;GC와 네트워크 지연 여유를 포함한다.&lt;/li&gt;
&lt;li&gt;장시간 작업에는 TTL 연장을 검토한다.&lt;/li&gt;
&lt;li&gt;TTL 연장 시 락 소유권을 확인한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;성능&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;전역 락보다 자원 단위 락을 사용한다.&lt;/li&gt;
&lt;li&gt;임계 구역을 짧게 유지한다.&lt;/li&gt;
&lt;li&gt;외부 API와 파일 처리를 락 밖으로 분리한다.&lt;/li&gt;
&lt;li&gt;재시도에 Backoff와 Jitter를 적용한다.&lt;/li&gt;
&lt;li&gt;락 경합률과 점유 시간을 측정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;커넥션&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;요청마다 커넥션을 생성하지 않는다.&lt;/li&gt;
&lt;li&gt;공유 connection 또는 pool을 재사용한다.&lt;/li&gt;
&lt;li&gt;Blocking, Pub/Sub, 대용량 조회는 별도 연결을 검토한다.&lt;/li&gt;
&lt;li&gt;Cluster 노드 수를 고려해 전체 연결 수를 계산한다.&lt;/li&gt;
&lt;li&gt;Connection Timeout과 Command Timeout을 제한한다.&lt;/li&gt;
&lt;li&gt;커넥션 풀 대기를 무제한으로 설정하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;정합성&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;DB Unique Constraint를 함께 적용한다.&lt;/li&gt;
&lt;li&gt;중요한 상태 변경은 조건부 UPDATE를 사용한다.&lt;/li&gt;
&lt;li&gt;멱등 키를 적용한다.&lt;/li&gt;
&lt;li&gt;필요하다면 Fencing Token을 사용한다.&lt;/li&gt;
&lt;li&gt;Failover 시 락 중복 가능성을 고려한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;24. 결론&lt;/h2&gt;
&lt;p&gt;Valkey를 이용한 분산 락의 기본 구현은 단순하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SET lock-key unique-value NX PX ttl&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 운영 가능한 분산 락을 만들기 위해서는 그 이후가 더 중요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;고유한 락 소유자 값
원자적인 락 해제
적절한 TTL
락 자동 연장
제한된 재시도
경합 최소화
커넥션 재사용
명확한 타임아웃
Failover 한계 인지
DB 제약 조건과 멱등 처리&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특히 분산 락을 사용했다고 해서 비즈니스 데이터의 정합성이 자동으로 보장되는 것은 아니다.&lt;/p&gt;
&lt;p&gt;Valkey 장애, 네트워크 Partition, TTL 만료, GC Pause, DB 트랜잭션 지연과 같은 상황에서는 하나의 작업이 두 번 실행될 가능성을 완전히 배제하기 어렵다.&lt;/p&gt;
&lt;p&gt;따라서 실무에서는 다음과 같이 계층적으로 보호하는 것이 안전하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1차: Valkey 분산 락으로 동시 실행 억제
2차: DB 조건부 갱신과 Unique Constraint로 정합성 보장
3차: 멱등 키로 중복 요청 방지
4차: Fencing Token 또는 상태 머신으로 오래된 요청 차단&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Valkey 분산 락은 모든 동시성 문제를 해결하는 만능 도구가 아니다.&lt;/p&gt;
&lt;p&gt;그러나 락의 범위를 작게 유지하고, 커넥션과 재시도를 통제하며, DB 정합성 장치와 함께 사용한다면 여러 애플리케이션 인스턴스 사이의 중복 실행을 효과적으로 제어할 수 있다.&lt;/p&gt;
&lt;/div&gt;</description>
      <category>나의 주니어 개발 일기/Redis</category>
      <author>추억을 백앤드하자</author>
      <guid isPermaLink="true">https://pulpul8282.tistory.com/451</guid>
      <comments>https://pulpul8282.tistory.com/451#entry451comment</comments>
      <pubDate>Wed, 29 Jul 2026 10:25:28 +0900</pubDate>
    </item>
    <item>
      <title>DB는 장애가 발생해도 어떻게 데이터를 복구할까? Redo와 Undo의 원리</title>
      <link>https://pulpul8282.tistory.com/450</link>
      <description>&lt;div class=&quot;markdown-body&quot;&gt;
&lt;h1&gt;DB는 장애가 발생해도 어떻게 데이터를 복구할까? Redo와 Undo의 원리&lt;/h1&gt;
&lt;p&gt;애플리케이션에서 다음 트랜잭션이 정상적으로 커밋됐다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

UPDATE account
SET balance = balance - 10000
WHERE account_id = 1;

COMMIT;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;애플리케이션은 &lt;code&gt;COMMIT&lt;/code&gt; 성공 응답을 받았다. 그런데 직후 DB 서버의 전원이 내려갔다.&lt;/p&gt;
&lt;p&gt;이때 데이터 페이지가 실제 데이터 파일에 기록되기 전이었다면 어떻게 될까?&lt;/p&gt;
&lt;p&gt;반대로 아직 커밋하지 않은 트랜잭션의 변경 내용이 데이터 파일에 먼저 기록된 상태에서 DB가 종료됐다면, 해당 변경은 어떻게 제거할까?&lt;/p&gt;
&lt;p&gt;DBMS는 이 문제를 해결하기 위해 일반적으로 다음 두 종류의 복구 정보를 관리한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Redo&lt;/strong&gt;: 변경 내용을 다시 적용하기 위한 정보&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Undo&lt;/strong&gt;: 변경 전 상태로 되돌리기 위한 정보&lt;/li&gt;
&lt;/ul&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;DB 장애 복구는 장애 직전의 상태를 Redo로 재현하고,&lt;br&gt;그중 커밋되지 않은 트랜잭션을 Undo로 제거하는 과정이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;다만 실제 구현은 DBMS마다 다르다. Oracle과 MySQL InnoDB는 명시적인 Undo 구조를 사용하지만, PostgreSQL은 전통적인 의미의 Undo 로그 대신 MVCC 튜플 버전과 WAL을 중심으로 복구한다.&lt;/p&gt;
&lt;p&gt;이 글에서는 특정 DBMS에 종속되지 않는 기본 원리를 먼저 살펴보고, 이후 MySQL InnoDB와 Oracle을 기준으로 실제 동작을 설명한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 데이터는 COMMIT과 동시에 데이터 파일에 기록되지 않는다&lt;/h2&gt;
&lt;p&gt;많은 개발자가 처음에는 다음과 같이 생각한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;UPDATE 실행
→ 데이터 파일 수정
→ COMMIT&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 대부분의 상용 DBMS는 이 방식으로 동작하지 않는다.&lt;/p&gt;
&lt;p&gt;디스크의 데이터 페이지를 트랜잭션마다 직접 수정하면 랜덤 I/O가 과도하게 발생하고, 트랜잭션 응답 시간이 디스크 쓰기 성능에 크게 의존하게 된다.&lt;/p&gt;
&lt;p&gt;실제 동작은 대략 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 디스크의 데이터 페이지를 메모리 버퍼로 읽는다.
2. 메모리상의 데이터 페이지를 변경한다.
3. 변경 내용을 로그 버퍼에 기록한다.
4. COMMIT 시 필요한 로그를 디스크에 기록한다.
5. 변경된 데이터 페이지는 나중에 데이터 파일에 기록한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;메모리에서 변경됐지만 아직 데이터 파일에 기록되지 않은 페이지를 &lt;strong&gt;Dirty Page&lt;/strong&gt;라고 한다.&lt;/p&gt;
&lt;p&gt;예를 들어 계좌 잔액이 다음과 같이 변경됐다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;변경 전 잔액: 50,000원
변경 후 잔액: 40,000원&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;COMMIT 직후 DB 내부 상태는 다음과 같을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;메모리 버퍼: 40,000원
데이터 파일: 50,000원
Redo 로그: 변경 기록 존재&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, COMMIT이 성공했더라도 데이터 파일에는 아직 이전 값이 남아 있을 수 있다.&lt;/p&gt;
&lt;p&gt;그럼에도 DB가 데이터의 영속성을 보장할 수 있는 이유가 바로 &lt;strong&gt;Redo 로그와 WAL&lt;/strong&gt;이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. Redo 로그란 무엇인가&lt;/h2&gt;
&lt;p&gt;Redo 로그는 데이터에 수행된 변경을 다시 적용할 수 있도록 기록한 복구 정보다.&lt;/p&gt;
&lt;p&gt;개념적으로는 다음과 같은 정보를 가진다고 이해할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;트랜잭션 ID: TX100
페이지 ID: PAGE-10
변경 위치: account.balance
변경 내용: 50,000 → 40,000
로그 순번: LSN 1520&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 로그가 SQL 문장을 그대로 저장하는 것은 아니다.&lt;/p&gt;
&lt;p&gt;DBMS에 따라 페이지의 특정 바이트 변경, 레코드 변경, 논리적 연산 등 서로 다른 형태로 기록될 수 있다. 중요한 점은 장애 발생 후 해당 변경을 재현할 수 있어야 한다는 것이다.&lt;/p&gt;
&lt;p&gt;Oracle은 Redo 로그를 데이터베이스에 발생한 모든 변경을 기록하는 핵심 복구 구조로 설명하며, PostgreSQL 역시 WAL을 통해 데이터 파일에 반영되지 않은 변경을 장애 후 다시 적용한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. WAL: 데이터 페이지보다 로그를 먼저 기록한다&lt;/h2&gt;
&lt;p&gt;Redo 기반 복구가 성립하려면 반드시 지켜야 하는 규칙이 있다.&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;br&gt;해당 변경을 복구할 수 있는 로그가 먼저 디스크에 기록돼야 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이를 &lt;strong&gt;Write-Ahead Logging&lt;/strong&gt;, 줄여서 WAL이라고 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;잘못된 순서

데이터 페이지 디스크 기록
→ 로그 디스크 기록&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위 순서에서는 데이터 페이지 일부가 기록된 직후 장애가 발생하면, 해당 변경이 어떤 트랜잭션에서 발생했는지 또는 어떻게 복구해야 하는지 알 수 없게 된다.&lt;/p&gt;
&lt;p&gt;따라서 다음 순서를 보장해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;정상적인 WAL 순서

Redo 로그 디스크 기록
→ 데이터 페이지 디스크 기록&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;COMMIT 시에도 일반적으로 데이터 페이지 전체를 즉시 기록하는 대신, 커밋에 필요한 로그를 안정적인 저장장치에 먼저 기록한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;UPDATE 수행
    ↓
메모리의 데이터 페이지 변경
    ↓
Redo 로그 생성
    ↓
COMMIT 요청
    ↓
Redo 및 Commit 로그 Flush
    ↓
COMMIT 성공 응답
    ↓
Dirty Page는 이후 백그라운드에서 기록&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PostgreSQL 공식 문서에서도 WAL의 핵심 원칙을 데이터 파일 변경보다 로그 기록이 먼저 영구 저장돼야 한다는 것으로 설명한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. 왜 데이터 페이지가 아니라 로그를 먼저 저장할까&lt;/h2&gt;
&lt;p&gt;한 행의 컬럼 하나만 수정하더라도 DB는 일반적으로 페이지 단위로 데이터를 관리한다.&lt;/p&gt;
&lt;p&gt;예를 들어 16KB 페이지에서 몇 바이트만 변경됐다고 가정하자.&lt;/p&gt;
&lt;p&gt;데이터 파일을 즉시 저장한다면 16KB 페이지에 대한 랜덤 I/O가 발생할 수 있다.&lt;/p&gt;
&lt;p&gt;반면 로그는 변경 정보를 로그 파일의 끝에 계속 추가하는 형태로 기록할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;데이터 페이지 쓰기

- 페이지 단위
- 랜덤 I/O 발생 가능
- 여러 데이터 파일에 분산
- 트랜잭션마다 수행하면 비용이 큼&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Redo 로그 쓰기

- 로그 파일 끝에 추가
- 순차 쓰기에 가까움
- 여러 트랜잭션의 로그를 묶어 기록 가능
- Group Commit 적용 가능&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 DB는 COMMIT마다 모든 데이터 페이지를 강제로 디스크에 기록하지 않고, 비교적 효율적인 로그 쓰기로 영속성을 먼저 확보한다.&lt;/p&gt;
&lt;p&gt;이 구조는 다음 두 가지를 동시에 달성한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;COMMIT 응답 시간을 줄인다.&lt;/li&gt;
&lt;li&gt;장애가 발생해도 커밋된 데이터를 복구한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;5. Undo 로그란 무엇인가&lt;/h2&gt;
&lt;p&gt;Undo 로그는 데이터가 변경되기 전 상태를 복원할 수 있도록 유지하는 정보다.&lt;/p&gt;
&lt;p&gt;잔액이 50,000원에서 40,000원으로 변경됐다면 개념적으로 다음과 같은 정보가 필요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;트랜잭션 ID: TX100
대상 레코드: account_id = 1
변경 전 balance: 50,000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;트랜잭션이 롤백되면 Undo 정보를 이용해 변경 전 상태로 되돌린다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;현재 값: 40,000
Undo 값: 50,000

ROLLBACK
    ↓
50,000원으로 복원&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Undo는 주로 다음 목적으로 사용된다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;명시적인 &lt;code&gt;ROLLBACK&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;장애 발생 후 미커밋 트랜잭션 제거&lt;/li&gt;
&lt;li&gt;MVCC 기반 일관성 읽기&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Oracle 공식 문서는 Undo가 미커밋 트랜잭션의 롤백뿐 아니라, 다른 트랜잭션이 데이터를 변경하고 있을 때 과거 이미지를 제공하는 일관성 읽기에도 사용된다고 설명한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. UPDATE가 실행될 때 Redo와 Undo는 어떻게 생성되는가&lt;/h2&gt;
&lt;p&gt;다음 SQL이 실행된다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE account
SET balance = 40000
WHERE account_id = 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;기존 잔액은 50,000원이다.&lt;/p&gt;
&lt;p&gt;DB 내부 동작을 단순화하면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. account 레코드가 포함된 페이지를 버퍼 캐시로 읽는다.
2. 변경 전 값 50,000원을 Undo 영역에 기록한다.
3. 메모리의 balance 값을 40,000원으로 변경한다.
4. Undo 변경과 데이터 페이지 변경에 대한 Redo 정보를 생성한다.
5. COMMIT 시 필요한 Redo 로그를 디스크에 Flush한다.
6. Dirty Page는 이후 적절한 시점에 데이터 파일로 기록한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 주목해야 할 점은 &lt;strong&gt;Undo 자체도 DB가 관리하는 데이터&lt;/strong&gt;라는 것이다.&lt;/p&gt;
&lt;p&gt;예를 들어 Oracle에서는 Undo 세그먼트의 변경 역시 Redo 대상이 된다. 따라서 장애 복구 시 Redo를 적용하면 일반 데이터 변경뿐 아니라, 미커밋 트랜잭션을 되돌리는 데 필요한 Undo 정보도 함께 복구된다. Oracle 문서 역시 Roll Forward 과정에서 Undo 블록의 변경도 Redo로 복구된다고 설명한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7. 커밋된 변경이 데이터 파일에 반영되기 전에 장애가 발생한 경우&lt;/h2&gt;
&lt;p&gt;첫 번째 장애 상황을 살펴보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. balance를 50,000원에서 40,000원으로 변경
2. Redo 로그를 디스크에 기록
3. COMMIT 완료
4. 데이터 페이지는 아직 데이터 파일에 기록되지 않음
5. DB 서버 장애 발생&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;장애 직전 상태는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Redo 로그: 50,000 → 40,000 변경 기록 존재
Commit 기록: 존재
데이터 파일: 여전히 50,000
메모리 버퍼: 장애로 유실&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB가 재시작되면 Redo 로그를 읽고 변경을 다시 적용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;데이터 파일: 50,000
        ↓ REDO
복구된 상태: 40,000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이를 &lt;strong&gt;Roll Forward&lt;/strong&gt; 또는 Redo 복구라고 한다.&lt;/p&gt;
&lt;p&gt;Redo를 통해 애플리케이션이 이미 성공 응답을 받은 커밋 결과를 보존한다.&lt;/p&gt;
&lt;p&gt;이는 ACID 속성 중 &lt;strong&gt;Durability&lt;/strong&gt;, 즉 영속성을 보장하는 핵심 메커니즘이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. 커밋되지 않은 데이터가 데이터 파일에 기록될 수 있다&lt;/h2&gt;
&lt;p&gt;이번에는 반대 상황을 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN;

UPDATE account
SET balance = 40000
WHERE account_id = 1;

-- COMMIT하지 않은 상태&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;하지만 일반적인 DBMS의 버퍼 관리 정책에서는 커밋되지 않은 변경을 포함한 Dirty Page도 데이터 파일에 기록될 수 있다.&lt;/p&gt;
&lt;p&gt;그 이유는 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;버퍼 캐시 공간 확보&lt;/li&gt;
&lt;li&gt;체크포인트 수행&lt;/li&gt;
&lt;li&gt;백그라운드 Writer 동작&lt;/li&gt;
&lt;li&gt;메모리 압박&lt;/li&gt;
&lt;li&gt;페이지 Flush 정책&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;하나의 데이터 페이지에는 여러 트랜잭션이 수정한 레코드가 함께 존재할 수도 있다.&lt;/p&gt;
&lt;p&gt;따라서 특정 트랜잭션이 커밋될 때까지 해당 페이지 전체를 디스크에 기록하지 않는 방식은 버퍼 관리와 I/O 효율 측면에서 제약이 크다.&lt;/p&gt;
&lt;p&gt;이러한 정책을 일반적으로 &lt;strong&gt;Steal 정책&lt;/strong&gt;이라고 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;커밋 전 상태

Undo: 변경 전 값 50,000 존재
메모리 페이지: 40,000
데이터 파일: 40,000까지 먼저 기록될 수 있음
Commit 기록: 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 상태에서 DB가 종료되면 미커밋 데이터가 데이터 파일에 남아 있게 된다.&lt;/p&gt;
&lt;p&gt;따라서 재시작 후 Undo가 필요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;데이터 파일: 40,000
        ↓ UNDO
최종 상태: 50,000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Undo는 미완료 트랜잭션의 결과를 제거하여 ACID 속성 중 &lt;strong&gt;Atomicity&lt;/strong&gt;, 즉 원자성을 보장한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;9. 장애 복구는 왜 Redo 후 Undo 순서로 수행할까&lt;/h2&gt;
&lt;p&gt;장애 복구를 단순하게 설명할 때 다음과 같이 말하는 경우가 많다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;커밋된 트랜잭션 → Redo
미커밋 트랜잭션 → Undo&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;개념을 처음 이해하는 데는 유용하지만, ARIES 계열 복구 알고리즘의 실제 동작을 정확히 표현한 설명은 아니다.&lt;/p&gt;
&lt;p&gt;대표적인 ARIES 복구 알고리즘은 다음 순서로 진행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Analysis
→ Redo
→ Undo&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Analysis&lt;/h3&gt;
&lt;p&gt;장애 시점에 다음 정보를 재구성한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;어떤 트랜잭션이 실행 중이었는가&lt;/li&gt;
&lt;li&gt;어떤 트랜잭션이 완료됐는가&lt;/li&gt;
&lt;li&gt;어떤 페이지가 Dirty Page였는가&lt;/li&gt;
&lt;li&gt;Redo를 어디서부터 시작해야 하는가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Redo&lt;/h3&gt;
&lt;p&gt;장애 직전 DB가 수행했던 변경 이력을 다시 재현한다.&lt;/p&gt;
&lt;p&gt;여기에는 커밋된 트랜잭션뿐 아니라, 장애 시점에 아직 커밋하지 않은 트랜잭션의 변경도 포함될 수 있다.&lt;/p&gt;
&lt;h3&gt;Undo&lt;/h3&gt;
&lt;p&gt;Redo가 끝난 후, 장애 시점까지 커밋하지 못한 트랜잭션의 변경만 역순으로 제거한다.&lt;/p&gt;
&lt;p&gt;ARIES 논문은 이러한 복구 방식을 “Repeating History”로 설명한다. 즉, 먼저 장애 직전 상태를 로그를 통해 재현한 다음, 완료되지 않은 트랜잭션을 롤백한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;10. 미커밋 트랜잭션까지 Redo하는 이유&lt;/h2&gt;
&lt;p&gt;다음 상황을 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;TX1: 커밋 완료
TX2: 커밋되지 않음&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TX2가 데이터를 변경하면서 Undo 레코드도 생성했다.&lt;/p&gt;
&lt;p&gt;그런데 Undo 레코드가 포함된 페이지가 장애 전에 데이터 파일에 완전히 기록되지 않았을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;TX2 데이터 변경: 일부 디스크 반영
TX2 Undo 정보: 디스크 미반영 가능&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 상태에서 TX2의 일반 데이터 변경만 무시하고 바로 Undo하려 하면, Undo에 필요한 정보가 디스크에 존재하지 않을 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 복구 과정에서는 먼저 로그를 재생해 장애 직전 상태를 재현한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 데이터 페이지 변경 Redo
2. Undo 페이지 변경 Redo
3. 장애 직전 상태 재현
4. 미커밋 TX2 식별
5. 복구된 Undo 정보를 이용해 TX2 롤백&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, Redo의 목적은 단순히 커밋된 데이터만 살리는 것이 아니다.&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;Redo는 DB를 장애 직전의 일관된 물리 상태로 재현하고,&lt;br&gt;Undo가 정상적으로 수행될 수 있는 기반까지 복구한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;Oracle 역시 인스턴스 복구 과정에서 먼저 Redo를 적용해 커밋 및 미커밋 변경을 재현한 뒤, 미커밋 트랜잭션을 Undo하는 방식으로 설명한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;11. 실제 장애 복구 시나리오&lt;/h2&gt;
&lt;p&gt;두 개의 트랜잭션이 있다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- TX100
BEGIN;

UPDATE account
SET balance = 40000
WHERE account_id = 1;

COMMIT;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- TX200
BEGIN;

UPDATE account
SET balance = 20000
WHERE account_id = 2;

-- COMMIT 전에 장애 발생&lt;/code&gt;&lt;/pre&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;TX100&lt;/th&gt;
&lt;th&gt;TX200&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Redo 기록&lt;/td&gt;
&lt;td&gt;존재&lt;/td&gt;
&lt;td&gt;존재&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undo 기록&lt;/td&gt;
&lt;td&gt;존재&lt;/td&gt;
&lt;td&gt;존재&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commit 기록&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;/tr&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;/tbody&gt;&lt;/table&gt;
&lt;p&gt;복구 과정은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Analysis
   - TX100은 완료된 트랜잭션으로 식별
   - TX200은 미완료 트랜잭션으로 식별
   - 복구가 필요한 Dirty Page 식별

2. Redo
   - 로그 기준으로 필요한 페이지 변경 재적용
   - TX100과 TX200의 장애 직전 변경 이력 재현

3. Undo
   - 미완료 트랜잭션 TX200의 변경 제거
   - TX100의 변경은 유지&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;최종 결과는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;TX100: 커밋 결과 보존
TX200: 변경 전 상태로 복원&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;12. Redo를 다시 적용하면 데이터가 중복 변경되지 않을까&lt;/h2&gt;
&lt;p&gt;예를 들어 다음 변경을 Redo한다고 생각해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;balance = balance - 10000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 연산을 두 번 수행하면 잔액이 중복 차감될 수 있다.&lt;/p&gt;
&lt;p&gt;그러나 실제 물리적 Redo는 애플리케이션의 SQL 문장을 무조건 다시 실행하는 방식이 아니다.&lt;/p&gt;
&lt;p&gt;DB는 일반적으로 &lt;strong&gt;LSN, Log Sequence Number&lt;/strong&gt;와 페이지에 기록된 로그 순번을 비교한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Redo Record LSN: 1520
Page LSN: 1520&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;페이지가 이미 해당 변경까지 반영한 상태라면 Redo를 다시 수행하지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Page LSN &amp;gt;= Redo LSN

→ 이미 반영된 로그
→ Redo 생략&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;반대로 페이지의 LSN이 더 낮다면 아직 반영되지 않은 변경으로 판단한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Page LSN &amp;lt; Redo LSN

→ 데이터 페이지에 미반영
→ Redo 수행&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 복구 과정은 모든 로그를 무조건 중복 실행하는 것이 아니라, 데이터 페이지와 로그의 상태를 비교해 필요한 변경만 적용한다.&lt;/p&gt;
&lt;p&gt;Oracle도 SCN을 이용해 데이터 파일에 이미 반영된 변경과 추가 복구가 필요한 지점을 판단한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;13. 체크포인트는 복구 범위를 줄인다&lt;/h2&gt;
&lt;p&gt;DB가 시작될 때 로그를 처음부터 끝까지 모두 읽어야 한다면, 운영 기간이 길수록 복구 시간이 계속 증가할 것이다.&lt;/p&gt;
&lt;p&gt;이를 방지하기 위해 DB는 주기적으로 &lt;strong&gt;Checkpoint&lt;/strong&gt;를 생성한다.&lt;/p&gt;
&lt;p&gt;체크포인트는 단순히 “메모리의 모든 데이터를 디스크에 저장하는 순간”만을 의미하지 않는다. DBMS마다 구체적인 구현은 다르지만, 복구 관점에서는 다음 정보를 제공한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;어느 로그 지점부터 복구를 시작해야 하는가
어떤 Dirty Page가 남아 있는가
어떤 트랜잭션이 진행 중이었는가&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;전체 로그가 다음과 같다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;LSN 1 -------------------------------------- LSN 100000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;최근 체크포인트와 Redo 시작 지점이 LSN 95000이라면 장애 복구 시 전체 로그가 아니라 해당 지점 근처부터 확인할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;전체 로그

|--------------------------------------------------|

복구 대상

                                      |------------|&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PostgreSQL은 장애 복구 시 최신 체크포인트 레코드에서 Redo 시작 지점을 찾고, 그 이전 변경은 이미 데이터 파일에 기록된 것으로 판단한다고 설명한다.&lt;/p&gt;
&lt;p&gt;체크포인트 주기는 운영 성능과 장애 복구 시간 사이의 트레이드오프를 만든다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;체크포인트가 너무 잦은 경우

- 데이터 페이지 쓰기 증가
- 디스크 I/O 스파이크 가능
- 정상 운영 성능 저하 가능&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;체크포인트 간격이 너무 긴 경우

- 장애 시 재생할 로그 증가
- 복구 시간 증가
- 로그 보관량 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 체크포인트는 단순 설정값이 아니라 RTO와 I/O 성능에 직접 영향을 주는 운영 요소다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;14. Redo와 Undo의 차이&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;Redo&lt;/th&gt;
&lt;th&gt;Undo&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;/td&gt;
&lt;td&gt;롤백, 원자성, MVCC&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;/tr&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;ACID&lt;/td&gt;
&lt;td&gt;Durability&lt;/td&gt;
&lt;td&gt;Atomicity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대표 사용&lt;/td&gt;
&lt;td&gt;Crash Recovery&lt;/td&gt;
&lt;td&gt;Rollback, Consistent Read&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;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;15. Redo 로그와 Binary Log는 같은 것이 아니다&lt;/h2&gt;
&lt;p&gt;MySQL을 사용하는 환경에서는 Redo 로그와 Binary Log를 혼동하기 쉽다.&lt;/p&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;InnoDB Redo Log&lt;/th&gt;
&lt;th&gt;MySQL Binary Log&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;관리 계층&lt;/td&gt;
&lt;td&gt;InnoDB 스토리지 엔진&lt;/td&gt;
&lt;td&gt;MySQL 서버 계층&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;주요 목적&lt;/td&gt;
&lt;td&gt;Crash Recovery&lt;/td&gt;
&lt;td&gt;복제, CDC, PITR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기록 관점&lt;/td&gt;
&lt;td&gt;페이지·레코드 변경 중심&lt;/td&gt;
&lt;td&gt;Statement 또는 Row Event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;장애 복구&lt;/td&gt;
&lt;td&gt;InnoDB 데이터 복구&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;Replica 전파에 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Redo 로그는 DB 프로세스나 서버가 비정상 종료됐을 때 현재 데이터 파일을 일관된 상태로 복구하기 위한 로그다.&lt;/p&gt;
&lt;p&gt;Binary Log는 다음과 같은 용도로 주로 사용된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Source-Replica 복제&lt;/li&gt;
&lt;li&gt;CDC&lt;/li&gt;
&lt;li&gt;Point-in-Time Recovery&lt;/li&gt;
&lt;li&gt;변경 이벤트 추적&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;따라서 다음 두 문장은 서로 다른 의미를 가진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Redo를 이용한 복구

→ 현재 InnoDB 데이터 파일의 Crash Recovery&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Binary Log를 이용한 복구

→ 백업 시점 이후 논리 변경을 재생하는 시점 복구&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;16. Crash Recovery와 Backup Recovery도 다르다&lt;/h2&gt;
&lt;p&gt;Redo가 존재한다고 해서 디스크 자체가 손상된 상황까지 모든 데이터를 복구할 수 있는 것은 아니다.&lt;/p&gt;
&lt;p&gt;장애 유형을 구분해야 한다.&lt;/p&gt;
&lt;h3&gt;Instance 또는 Process Crash&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DB 프로세스 비정상 종료
OS 재부팅
서버 전원 장애
메모리 데이터 유실&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;데이터 파일과 Redo 로그가 정상적으로 남아 있다면 Crash Recovery를 수행할 수 있다.&lt;/p&gt;
&lt;h3&gt;Media Failure&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;디스크 손상
데이터 파일 삭제
스토리지 장애
파일 시스템 손상&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우에는 Redo만으로 원본 데이터 파일 전체를 복구할 수 없다.&lt;/p&gt;
&lt;p&gt;일반적으로 다음이 필요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Backup 복원
→ Archived Redo 또는 WAL Archive 적용
→ 필요한 시점까지 로그 재생&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Oracle의 Archived Redo Log와 PostgreSQL의 WAL Archive는 이러한 미디어 복구 및 시점 복구에 활용된다. PostgreSQL은 연속된 WAL 아카이브를 이용해 백업 이후 변경을 재생하는 Point-in-Time Recovery를 지원한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;17. MySQL InnoDB에서는 어떻게 동작할까&lt;/h2&gt;
&lt;p&gt;MySQL InnoDB에서는 다음 구성요소를 함께 이해해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Buffer Pool
Redo Log
Undo Log
Doublewrite Buffer
Binary Log
Checkpoint&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Redo Log&lt;/h3&gt;
&lt;p&gt;메모리의 Dirty Page가 데이터 파일에 기록되기 전에 장애가 발생해도 변경을 재적용할 수 있게 한다.&lt;/p&gt;
&lt;h3&gt;Undo Log&lt;/h3&gt;
&lt;p&gt;트랜잭션 롤백과 MVCC의 이전 버전 조회에 사용된다.&lt;/p&gt;
&lt;h3&gt;Doublewrite Buffer&lt;/h3&gt;
&lt;p&gt;페이지를 디스크에 기록하는 도중 장애가 발생해 페이지 일부만 저장되는 &lt;strong&gt;Torn Page&lt;/strong&gt; 문제를 방지한다.&lt;/p&gt;
&lt;h3&gt;Binary Log&lt;/h3&gt;
&lt;p&gt;복제와 Point-in-Time Recovery에 사용된다.&lt;/p&gt;
&lt;p&gt;즉, InnoDB 장애 복구를 단순히 Redo와 Undo만의 문제로 보면 부족하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Redo
→ 누락된 페이지 변경 복구

Undo
→ 미커밋 트랜잭션 롤백

Doublewrite
→ 손상되거나 부분 기록된 페이지 복구

Binary Log
→ 복제 및 시점 복구&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;18. Oracle에서는 어떻게 동작할까&lt;/h2&gt;
&lt;p&gt;Oracle은 일반적으로 다음 구조를 사용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Online Redo Log
Redo Log Buffer
Undo Segment
Archived Redo Log
DB Buffer Cache
Data File
Control File&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;장애가 발생하면 Oracle은 Online Redo Log를 이용해 데이터 파일에 반영되지 않은 변경을 Roll Forward한다.&lt;/p&gt;
&lt;p&gt;이 과정에는 커밋된 변경뿐 아니라 미커밋 변경이 포함될 수 있다.&lt;/p&gt;
&lt;p&gt;이후 Undo 정보를 이용해 미커밋 트랜잭션을 롤백한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Instance Recovery

1. Redo 적용
2. 데이터 블록을 장애 직전 상태로 복구
3. 데이터베이스 Open
4. 미커밋 트랜잭션 Undo&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Oracle은 일부 Undo 작업이 완료되기 전에 데이터베이스를 먼저 열고, 필요에 따라 백그라운드 또는 온디맨드 방식으로 트랜잭션 복구를 계속 수행할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;19. PostgreSQL에는 전통적인 Undo 로그가 없다&lt;/h2&gt;
&lt;p&gt;Redo와 Undo를 모든 DBMS에 동일하게 적용해서는 안 된다.&lt;/p&gt;
&lt;p&gt;PostgreSQL은 Oracle이나 InnoDB처럼 전통적인 별도 Undo 로그를 사용하지 않는다.&lt;/p&gt;
&lt;p&gt;PostgreSQL은 UPDATE 시 기존 튜플을 직접 덮어쓰는 대신, 새로운 튜플 버전을 생성한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;기존 Tuple

xmin = TX100
xmax = 없음
balance = 50,000&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;UPDATE 이후

기존 Tuple
xmin = TX100
xmax = TX200
balance = 50,000

새 Tuple
xmin = TX200
xmax = 없음
balance = 40,000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TX200이 커밋되지 않은 상태로 장애가 발생하면, 트랜잭션 상태를 기준으로 새 튜플은 보이지 않는 버전으로 처리된다.&lt;/p&gt;
&lt;p&gt;이후 불필요한 튜플 버전은 Vacuum 과정에서 정리된다.&lt;/p&gt;
&lt;p&gt;따라서 일반적인 원리는 다음과 같이 이해해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;공통 목적

커밋된 변경은 보존한다.
미커밋 변경은 사용자에게 보이지 않게 한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 이를 구현하는 방식은 다르다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Oracle / InnoDB

Redo + Undo 구조&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;PostgreSQL

WAL + MVCC Tuple Version + Transaction Status&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;20. 운영 환경에서 확인해야 할 설정&lt;/h2&gt;
&lt;p&gt;Redo와 Undo의 개념은 단순한 이론이 아니라 DB 운영 설정과 직접 연결된다.&lt;/p&gt;
&lt;h3&gt;로그 Flush 정책&lt;/h3&gt;
&lt;p&gt;COMMIT 시 로그를 어느 수준까지 디스크에 동기화할 것인지에 따라 성능과 데이터 안정성이 달라질 수 있다.&lt;/p&gt;
&lt;p&gt;로그를 매번 동기화하면 내구성은 높아지지만 쓰기 지연이 증가한다.&lt;/p&gt;
&lt;p&gt;반대로 비동기화 또는 주기적 Flush를 사용하면 처리량은 높아질 수 있지만, OS나 서버 장애 시 최근 커밋 데이터가 유실될 가능성이 생긴다.&lt;/p&gt;
&lt;h3&gt;Checkpoint 주기&lt;/h3&gt;
&lt;p&gt;체크포인트가 지나치게 자주 발생하면 디스크 쓰기 부하가 증가할 수 있다.&lt;/p&gt;
&lt;p&gt;반대로 너무 드물면 장애 복구 시간이 늘어난다.&lt;/p&gt;
&lt;h3&gt;Redo 로그 크기&lt;/h3&gt;
&lt;p&gt;Redo 로그 용량이 너무 작으면 Checkpoint가 자주 발생해 쓰기 성능이 저하될 수 있다.&lt;/p&gt;
&lt;p&gt;용량이 지나치게 크면 장애 복구 시 확인해야 할 로그 범위가 증가할 수 있다.&lt;/p&gt;
&lt;h3&gt;Undo 보존 기간&lt;/h3&gt;
&lt;p&gt;Undo를 너무 빨리 제거하면 장시간 실행되는 조회가 필요한 과거 버전을 읽지 못할 수 있다.&lt;/p&gt;
&lt;p&gt;Oracle에서는 대표적으로 &lt;code&gt;ORA-01555: snapshot too old&lt;/code&gt; 문제가 발생할 수 있다.&lt;/p&gt;
&lt;h3&gt;디스크 성능&lt;/h3&gt;
&lt;p&gt;쓰기 중심 DB에서는 데이터 파일뿐 아니라 로그 디바이스의 지연 시간도 중요하다.&lt;/p&gt;
&lt;p&gt;특히 COMMIT 응답 시간이 로그 Flush를 기다리는 구조라면 Redo 로그 디스크의 &lt;code&gt;fsync&lt;/code&gt; 지연이 트랜잭션 지연에 직접 반영된다.&lt;/p&gt;
&lt;p&gt;운영 환경에서는 다음 지표를 함께 확인하는 것이 좋다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Commit latency
Log flush latency
Redo generation rate
Checkpoint duration
Dirty page ratio
Buffer pool hit ratio
Disk fsync latency
Undo tablespace usage
Long-running transaction
Crash recovery duration&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;21. 흔히 하는 오해&lt;/h2&gt;
&lt;h3&gt;오해 1. COMMIT하면 데이터 파일에 바로 기록된다&lt;/h3&gt;
&lt;p&gt;COMMIT은 일반적으로 데이터 페이지 전체의 디스크 기록을 의미하지 않는다.&lt;/p&gt;
&lt;p&gt;커밋 결과를 복구할 수 있는 로그가 안정적으로 기록됐음을 의미하는 경우가 많다.&lt;/p&gt;
&lt;h3&gt;오해 2. 커밋하지 않은 데이터는 디스크에 기록되지 않는다&lt;/h3&gt;
&lt;p&gt;커밋되지 않은 변경이 포함된 Dirty Page도 데이터 파일에 기록될 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 Undo가 필요하다.&lt;/p&gt;
&lt;h3&gt;오해 3. Redo는 커밋된 트랜잭션만 재실행한다&lt;/h3&gt;
&lt;p&gt;개념적으로는 커밋된 결과를 살리기 위한 것이지만, ARIES 계열 복구에서는 장애 직전 상태를 재현하기 위해 미커밋 트랜잭션 변경까지 Redo한 후 Undo할 수 있다.&lt;/p&gt;
&lt;h3&gt;오해 4. Undo는 장애 복구에만 사용된다&lt;/h3&gt;
&lt;p&gt;Undo는 일반 롤백뿐 아니라 MVCC 일관성 읽기에도 사용된다.&lt;/p&gt;
&lt;h3&gt;오해 5. Redo 로그가 있으면 백업이 필요 없다&lt;/h3&gt;
&lt;p&gt;Redo는 현재 데이터 파일을 기준으로 한 Crash Recovery에 주로 사용된다.&lt;/p&gt;
&lt;p&gt;데이터 파일 자체가 손상되거나 삭제되면 백업과 아카이브 로그가 필요하다.&lt;/p&gt;
&lt;h3&gt;오해 6. 모든 DB가 동일한 Redo·Undo 구조를 가진다&lt;/h3&gt;
&lt;p&gt;Oracle, MySQL InnoDB, PostgreSQL은 같은 ACID 목표를 서로 다른 내부 구조로 구현한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;22. 전체 흐름 정리&lt;/h2&gt;
&lt;p&gt;하나의 UPDATE가 실행되고 장애가 복구되는 과정을 정리하면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;[정상 처리]

1. 데이터 페이지를 메모리로 읽는다.
2. 변경 전 정보를 Undo에 기록한다.
3. 메모리의 데이터 페이지를 변경한다.
4. 변경 내용을 Redo에 기록한다.
5. COMMIT 시 필요한 로그를 디스크에 Flush한다.
6. 애플리케이션에 COMMIT 성공을 반환한다.
7. Dirty Page는 이후 데이터 파일에 기록한다.&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;[장애 발생]

1. 메모리의 Dirty Page가 유실된다.
2. 데이터 파일에는 일부 변경만 존재할 수 있다.
3. 로그를 이용해 장애 시점의 상태를 분석한다.
4. 필요한 변경을 Redo한다.
5. 미커밋 트랜잭션을 Undo한다.
6. 일관된 데이터 상태로 복구한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;복구 알고리즘 관점에서는 다음 세 단계로 정리할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Analysis
→ 장애 시점의 트랜잭션과 Dirty Page 파악

Redo
→ 로그를 재생해 장애 직전 상태 재현

Undo
→ 커밋하지 않은 트랜잭션 제거&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Redo와 Undo의 핵심 목적은 명확하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Redo
→ COMMIT된 변경을 잃지 않기 위한 복구 정보

Undo
→ 완료되지 않은 변경을 제거하기 위한 복구 정보&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 실제 장애 복구를 정확히 이해하려면 한 단계 더 나아가야 한다.&lt;/p&gt;
&lt;p&gt;DB는 COMMIT할 때마다 데이터 파일을 즉시 저장하지 않는다. 대신 WAL 원칙에 따라 복구 로그를 먼저 안정적으로 기록하고, 데이터 페이지는 이후에 비동기로 저장한다.&lt;/p&gt;
&lt;p&gt;또한 커밋되지 않은 변경도 데이터 파일에 먼저 기록될 수 있기 때문에 Undo가 필요하다.&lt;/p&gt;
&lt;p&gt;대표적인 ARIES 계열 복구에서는 커밋 트랜잭션만 선택적으로 Redo하는 것이 아니라, 먼저 로그를 통해 장애 직전의 상태를 재현한 후 미완료 트랜잭션을 Undo한다.&lt;/p&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;DB는 Redo로 장애 직전까지의 변경 이력을 재현하고,&lt;br&gt;Undo로 완료되지 않은 트랜잭션의 흔적을 제거한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이 구조를 통해 DB는 애플리케이션이 성공으로 인식한 트랜잭션은 보존하고, 완료되지 않은 트랜잭션은 존재하지 않았던 것처럼 되돌릴 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Oracle Database, &lt;em&gt;What Is the Redo Log?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;Oracle Database, &lt;em&gt;What Is Undo?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;Oracle Database, &lt;em&gt;Backup and Recovery Overview&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;MySQL 8.4 Reference Manual, &lt;em&gt;The InnoDB Storage Engine&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL Documentation, &lt;em&gt;Write-Ahead Logging&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;PostgreSQL Documentation, &lt;em&gt;WAL Configuration&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;C. Mohan et al., &lt;em&gt;ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging&lt;/em&gt;, ACM TODS, 1992&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;  </description>
      <category>나의 주니어 개발 일기/DB</category>
      <category>db</category>
      <category>redo</category>
      <category>undo</category>
      <category>백업</category>
      <category>복구</category>
      <author>추억을 백앤드하자</author>
      <guid isPermaLink="true">https://pulpul8282.tistory.com/450</guid>
      <comments>https://pulpul8282.tistory.com/450#entry450comment</comments>
      <pubDate>Thu, 23 Jul 2026 00:30:38 +0900</pubDate>
    </item>
    <item>
      <title>FlatBuffers란 무엇인가: 역직렬화 없이 데이터를 읽는 고성능 직렬화 구조</title>
      <link>https://pulpul8282.tistory.com/447</link>
      <description>&lt;div class=&quot;markdown-body&quot;&gt;
&lt;h1&gt;FlatBuffers란 무엇인가: 역직렬화 없이 데이터를 읽는 고성능 직렬화 구조&lt;/h1&gt;
&lt;p&gt;API 서버, 메시지 큐, 소켓 통신 시스템을 개발하다 보면 객체를 네트워크로 전달하기 위해 직렬화가 필요하다.&lt;/p&gt;
&lt;p&gt;가장 익숙한 방법은 JSON이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;aircraftId&amp;quot;: &amp;quot;HL1234&amp;quot;,
  &amp;quot;sequence&amp;quot;: 10231,
  &amp;quot;occurredAt&amp;quot;: 1783886400000,
  &amp;quot;latitude&amp;quot;: 37.5665,
  &amp;quot;longitude&amp;quot;: 126.9780,
  &amp;quot;altitude&amp;quot;: 1200.5
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JSON은 사람이 읽기 쉽고 디버깅하기 편하다. 하지만 수신 측에서는 문자열을 파싱하고, 필드별 타입을 변환하고, 새로운 객체를 생성해야 한다.&lt;/p&gt;
&lt;p&gt;데이터량이 적을 때는 문제가 되지 않는다. 그러나 다음과 같은 환경에서는 이야기가 달라진다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;초당 수천~수만 건의 메시지를 처리하는 시스템&lt;/li&gt;
&lt;li&gt;항공기 위치처럼 작은 데이터를 지속적으로 송수신하는 시스템&lt;/li&gt;
&lt;li&gt;게임, 모바일, 임베디드처럼 메모리 할당에 민감한 환경&lt;/li&gt;
&lt;li&gt;전체 데이터 중 일부 필드만 읽어야 하는 시스템&lt;/li&gt;
&lt;li&gt;GC 발생과 지연 시간 변동을 줄여야 하는 시스템&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이러한 환경에서 고려할 수 있는 직렬화 기술이 &lt;strong&gt;FlatBuffers&lt;/strong&gt;다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. FlatBuffers란?&lt;/h2&gt;
&lt;p&gt;FlatBuffers는 Google에서 게임과 성능 민감형 애플리케이션을 위해 개발한 크로스 플랫폼 바이너리 직렬화 라이브러리다.&lt;/p&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;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;일반적인 직렬화 방식은 다음 과정을 거친다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Network Byte Array
        ↓
Binary Parsing
        ↓
Java Object 생성
        ↓
비즈니스 로직에서 사용&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FlatBuffers는 다음과 같이 동작한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Network Byte Array
        ↓
ByteBuffer를 감싼 View 생성
        ↓
필요한 필드를 원본 버퍼에서 직접 조회&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, 바이너리 전체를 Java,C,C++과 같은 프로그래밍 객체 그래프로 변환하는 별도의 unpacking 단계가 없다. FlatBuffers 공식 문서도 직렬화된 데이터를 parsing 또는 unpacking하지 않고 직접 접근할 수 있다는 점을 핵심 특성으로 설명한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. FlatBuffers의 Zero-Copy는 무엇을 의미하는가?&lt;/h2&gt;
&lt;p&gt;FlatBuffers를 설명할 때 흔히 &lt;strong&gt;Zero-Copy Serialization&lt;/strong&gt;이라는 표현을 사용한다.&lt;/p&gt;
&lt;p&gt;하지만 이 표현을 정확히 이해할 필요가 있다.&lt;/p&gt;
&lt;p&gt;FlatBuffers가 제거하는 것은 주로 다음 과정이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;byte[]
  → 필드 파싱
  → 문자열 및 객체 생성
  → DTO 생성
  → DTO 필드 접근&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FlatBuffers에서는 생성된 접근자가 &lt;code&gt;ByteBuffer&lt;/code&gt; 내부의 offset을 계산해 원하는 위치의 값을 직접 읽는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;byte[]
  → ByteBuffer
  → offset 계산
  → 필드 접근&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 다음과 같은 장점이 발생한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;역직렬화용 객체 생성 감소&lt;/li&gt;
&lt;li&gt;임시 메모리 할당 감소&lt;/li&gt;
&lt;li&gt;GC 압력 감소&lt;/li&gt;
&lt;li&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;예를 들어 다음 코드에는 이미 한 번의 복사가 포함될 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;byte[] payload = inputStream.readNBytes(length);
ByteBuffer buffer = ByteBuffer.wrap(payload);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;InputStream&lt;/code&gt;에서 &lt;code&gt;byte[]&lt;/code&gt;로 읽어 오는 과정, RabbitMQ 클라이언트가 전달하는 &lt;code&gt;byte[]&lt;/code&gt;, Netty의 &lt;code&gt;ByteBuf&lt;/code&gt;를 &lt;code&gt;byte[]&lt;/code&gt;로 변환하는 과정에서는 복사가 발생할 수 있다.&lt;/p&gt;
&lt;p&gt;또한 다음 메서드도 내부 버퍼 내용을 새로운 배열로 복사한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;byte[] payload = builder.sizedByteArray();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 FlatBuffers의 zero-copy는 다음과 같이 이해하는 것이 정확하다.&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;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;운영 환경에서 실제 복사 횟수는 네트워크 라이브러리와 애플리케이션의 버퍼 처리 방식까지 함께 분석해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. FlatBuffers가 데이터를 직접 읽을 수 있는 이유&lt;/h2&gt;
&lt;p&gt;FlatBuffer는 바이너리 내부의 데이터를 offset 기반으로 연결한다.&lt;/p&gt;
&lt;p&gt;개념적으로 다음과 같은 구조를 가진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;┌─────────────────────────────┐
│ Root Object Offset          │
├─────────────────────────────┤
│ File Identifier(Optional)   │
├─────────────────────────────┤
│ String / Vector / Table     │
├─────────────────────────────┤
│ Root Table                  │
├─────────────────────────────┤
│ VTable                      │
└─────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 객체는 다른 객체의 실제 메모리 주소 대신 버퍼 내부의 상대적인 위치인 offset을 저장한다.&lt;/p&gt;
&lt;p&gt;Table은 vtable을 통해 각 필드의 위치를 찾는다. 필드가 존재하지 않으면 스키마에 정의된 기본값을 반환한다. 이 구조 덕분에 이전 버전에서 생성한 데이터와 새로운 스키마 사이의 호환성을 제공할 수 있다.&lt;/p&gt;
&lt;p&gt;FlatBuffers의 바이너리는 little-endian을 사용하며, scalar 값은 해당 타입 크기에 맞춰 정렬된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. Table과 Struct의 차이&lt;/h2&gt;
&lt;p&gt;FlatBuffers 스키마에는 &lt;code&gt;table&lt;/code&gt;과 &lt;code&gt;struct&lt;/code&gt;라는 두 가지 주요 데이터 구조가 있다.&lt;/p&gt;
&lt;h3&gt;Table&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-flatbuffers&quot;&gt;table AircraftPosition {
  aircraftId:string;
  sequence:long;
  altitude:double;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Table은 다음 특징을 가진다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;필드를 생략할 수 있다.&lt;/li&gt;
&lt;li&gt;생략된 필드는 기본값을 반환한다.&lt;/li&gt;
&lt;li&gt;새로운 필드를 추가할 수 있다.&lt;/li&gt;
&lt;li&gt;offset과 vtable을 통해 필드에 접근한다.&lt;/li&gt;
&lt;li&gt;스키마 변경이 필요한 도메인 객체에 적합하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;대부분의 메시지 루트 객체는 Table로 정의하는 것이 안전하다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Struct&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-flatbuffers&quot;&gt;struct Position {
  latitude:double;
  longitude:double;
  altitude:double;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Struct는 다음 특징을 가진다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;모든 필드가 고정된 위치에 저장된다.&lt;/li&gt;
&lt;li&gt;필드 조회가 빠르고 저장 공간이 상대적으로 작다.&lt;/li&gt;
&lt;li&gt;필드가 항상 존재한다.&lt;/li&gt;
&lt;li&gt;한번 정의하면 필드를 추가하거나 제거할 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;FlatBuffers 공식 튜토리얼도 Struct는 조회가 빠르고 메모리를 적게 사용하지만, 정의 후 변경할 수 없으므로 진화해야 하는 데이터에는 Table을 사용하라고 안내한다.&lt;/p&gt;
&lt;p&gt;따라서 Struct는 다음과 같이 구조가 장기간 고정되는 값 객체에 적합하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;좌표&lt;/li&gt;
&lt;li&gt;RGB 색상&lt;/li&gt;
&lt;li&gt;고정 크기 벡터&lt;/li&gt;
&lt;li&gt;행렬&lt;/li&gt;
&lt;li&gt;센서 측정값 묶음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;업무 요구사항에 따라 필드가 추가될 가능성이 있다면 Table을 선택하는 것이 낫다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5. 항공기 위치 데이터를 FlatBuffers로 정의해 보자&lt;/h2&gt;
&lt;p&gt;항공기 위치 메시지를 FlatBuffers로 정의하면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-flatbuffers&quot;&gt;namespace com.example.telemetry.fbs;

enum FlightStatus:byte {
  Normal = 0,
  Warning = 1,
  Emergency = 2
}

struct Position {
  latitude:double;
  longitude:double;
  altitude:double;
}

table AircraftPosition {
  aircraftId:string;
  sequence:long;
  occurredAt:long;
  position:Position;
  heading:float;
  groundSpeed:float;
  status:FlightStatus = Normal;
}

root_type AircraftPosition;
file_identifier &amp;quot;APOS&amp;quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;스키마 파일은 일반적으로 &lt;code&gt;.fbs&lt;/code&gt; 확장자를 사용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;src/main/flatbuffers/aircraft-position.fbs&lt;/code&gt;&lt;/pre&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;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;namespace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;생성되는 코드의 패키지 또는 네임스페이스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enum&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;숫자 기반 상태값 정의&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;struct&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;구조가 고정된 인라인 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;스키마 진화가 가능한 메시지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;root_type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;버퍼의 최상위 메시지 타입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file_identifier&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;데이터 종류를 확인하기 위한 4바이트 식별자&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;FlatBuffer는 기본적으로 self-describing 포맷이 아니다. 즉, 수신자가 어떤 스키마로 읽어야 하는지 알고 있어야 한다. 필요하다면 정확히 4글자인 &lt;code&gt;file_identifier&lt;/code&gt;를 추가해 데이터 종류를 확인할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. flatc를 이용한 Java 코드 생성&lt;/h2&gt;
&lt;p&gt;FlatBuffers는 스키마 파일을 런타임에 해석하는 방식이 아니라, &lt;code&gt;flatc&lt;/code&gt; 컴파일러로 언어별 코드를 생성한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;flatc \
  --java \
  -o build/generated/flatbuffers \
  src/main/flatbuffers/aircraft-position.fbs&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실행하면 스키마에 정의된 Table, Struct, Enum에 대응하는 Java 코드가 생성된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;build/generated/flatbuffers/
└── com/example/telemetry/fbs/
    ├── AircraftPosition.java
    ├── FlightStatus.java
    └── Position.java&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;flatc&lt;/code&gt;는 Java뿐 아니라 C++, Kotlin, Go, Rust, Python, TypeScript 등 여러 언어의 코드를 생성할 수 있다. 따라서 C++ 클라이언트와 Java 서버가 동일한 &lt;code&gt;.fbs&lt;/code&gt; 스키마를 공유하는 구조도 가능하다.&lt;/p&gt;
&lt;p&gt;Java 프로젝트에는 FlatBuffers 런타임 라이브러리도 추가해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-groovy&quot;&gt;dependencies {
    implementation &amp;quot;com.google.flatbuffers:flatbuffers-java:${flatbuffersVersion}&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;운영 프로젝트에서는 &lt;code&gt;flatc&lt;/code&gt; 실행 파일 버전과 Java 런타임 라이브러리 버전을 동일하게 관리하는 것이 좋다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7. Java에서 FlatBuffer 생성하기&lt;/h2&gt;
&lt;p&gt;생성된 Java 코드를 이용해 메시지를 직렬화해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;import com.example.telemetry.fbs.AircraftPosition;
import com.example.telemetry.fbs.FlightStatus;
import com.example.telemetry.fbs.Position;
import com.google.flatbuffers.FlatBufferBuilder;

public final class AircraftPositionEncoder {

    public byte[] encode(AircraftPositionCommand command) {
        FlatBufferBuilder builder = new FlatBufferBuilder(256);

        int aircraftIdOffset =
                builder.createString(command.aircraftId());

        int positionOffset = Position.createPosition(
                builder,
                command.latitude(),
                command.longitude(),
                command.altitude()
        );

        AircraftPosition.startAircraftPosition(builder);
        AircraftPosition.addAircraftId(builder, aircraftIdOffset);
        AircraftPosition.addSequence(builder, command.sequence());
        AircraftPosition.addOccurredAt(builder, command.occurredAt());
        AircraftPosition.addPosition(builder, positionOffset);
        AircraftPosition.addHeading(builder, command.heading());
        AircraftPosition.addGroundSpeed(builder, command.groundSpeed());
        AircraftPosition.addStatus(
                builder,
                FlightStatus.Normal
        );

        int rootOffset = AircraftPosition.endAircraftPosition(builder);

        AircraftPosition.finishAircraftPositionBuffer(
                builder,
                rootOffset
        );

        return builder.sizedByteArray();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FlatBuffers Builder는 일반적인 객체 생성 코드와 사용 방식이 다르다.&lt;/p&gt;
&lt;p&gt;문자열, 하위 Table, Vector 등은 먼저 생성한 뒤 반환받은 offset을 상위 Table에 전달한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;String 생성
    ↓
하위 객체 생성
    ↓
상위 Table 생성
    ↓
Root Table 지정
    ↓
Buffer 완성&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;공식 Java 샘플에서도 &lt;code&gt;FlatBufferBuilder&lt;/code&gt;로 문자열과 Vector, Struct 등을 생성한 후 최종 Table을 구성하고 &lt;code&gt;finish()&lt;/code&gt;를 호출한다.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;sizedByteArray()&lt;/code&gt;의 복사 비용&lt;/h3&gt;
&lt;p&gt;다음 코드는 사용하기 편하지만 새로운 &lt;code&gt;byte[]&lt;/code&gt;를 만든다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;return builder.sizedByteArray();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RabbitMQ Java Client처럼 메시지 본문으로 &lt;code&gt;byte[]&lt;/code&gt;를 요구하는 API에서는 현실적으로 사용할 수 있다.&lt;/p&gt;
&lt;p&gt;반면 ByteBuffer를 직접 전달할 수 있는 환경이라면 다음 방식을 검토할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;ByteBuffer dataBuffer = builder.dataBuffer();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;단, &lt;code&gt;dataBuffer()&lt;/code&gt;가 반환한 버퍼의 유효 구간은 전체 backing array와 일치하지 않을 수 있다. 반드시 &lt;code&gt;position()&lt;/code&gt;과 &lt;code&gt;remaining()&lt;/code&gt;을 고려해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. Java에서 역직렬화 없이 읽기&lt;/h2&gt;
&lt;p&gt;수신한 &lt;code&gt;byte[]&lt;/code&gt;를 읽는 코드는 단순하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;import com.example.telemetry.fbs.AircraftPosition;

import java.nio.ByteBuffer;

public final class AircraftPositionDecoder {

    public DecodedAircraftPosition decode(byte[] payload) {
        ByteBuffer buffer = ByteBuffer.wrap(payload);

        AircraftPosition message =
                AircraftPosition.getRootAsAircraftPosition(buffer);

        return new DecodedAircraftPosition(
                message.aircraftId(),
                message.sequence(),
                message.occurredAt(),
                message.position().latitude(),
                message.position().longitude(),
                message.position().altitude(),
                message.heading(),
                message.groundSpeed(),
                message.status()
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 중요한 점은 &lt;code&gt;getRootAsAircraftPosition()&lt;/code&gt;이 전체 데이터를 Java 객체로 변환하지 않는다는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AircraftPosition&lt;/code&gt; 객체는 실질적으로 원본 &lt;code&gt;ByteBuffer&lt;/code&gt;에 접근하기 위한 View 역할을 한다.&lt;/p&gt;
&lt;p&gt;공식 Java 문서 역시 &lt;code&gt;byte[]&lt;/code&gt;를 &lt;code&gt;ByteBuffer&lt;/code&gt;로 감싼 뒤 생성된 &lt;code&gt;getRootAs...()&lt;/code&gt; 메서드에 전달해 데이터를 읽는 방식을 사용한다.&lt;/p&gt;
&lt;h3&gt;View 객체의 생명주기를 주의해야 한다&lt;/h3&gt;
&lt;p&gt;다음과 같은 코드는 위험할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;AircraftPosition message =
        AircraftPosition.getRootAsAircraftPosition(sharedBuffer);

bufferPool.release(sharedBuffer);

// 이미 반환된 버퍼를 계속 참조할 수 있다.
processAsync(message);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FlatBuffers 객체는 원본 버퍼를 참조한다.&lt;/p&gt;
&lt;p&gt;따라서 다음 조건을 지켜야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;FlatBuffers View 사용 기간
    ≤
원본 ByteBuffer의 유효 기간&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;버퍼 풀에 반환하거나 다른 메시지로 덮어쓴 뒤 FlatBuffers 접근자를 호출하면 잘못된 데이터를 읽을 수 있다.&lt;/p&gt;
&lt;p&gt;특히 Netty EventLoop에서 수신한 데이터를 다른 스레드로 넘길 때는 다음 중 하나를 선택해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;참조 카운트를 증가시켜 버퍼 생명주기를 연장&lt;/li&gt;
&lt;li&gt;필요한 값만 애플리케이션 객체로 복사&lt;/li&gt;
&lt;li&gt;메시지 전체를 별도 버퍼로 복사&lt;/li&gt;
&lt;li&gt;동일 스레드에서 처리 완료 후 버퍼 해제&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Zero-copy를 적용한다고 해서 버퍼 소유권 문제까지 사라지는 것은 아니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;9. 전체 데이터를 DTO로 변환하면 FlatBuffers의 장점이 사라질까?&lt;/h2&gt;
&lt;p&gt;수신 직후 모든 필드를 DTO로 복사한다면 FlatBuffers의 핵심 장점 중 일부는 줄어든다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;AircraftPosition message =
        AircraftPosition.getRootAsAircraftPosition(buffer);

AircraftPositionDto dto = new AircraftPositionDto(
        message.aircraftId(),
        message.sequence(),
        message.occurredAt(),
        message.position().latitude(),
        message.position().longitude(),
        message.position().altitude()
);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조에서는 결국 DTO와 문자열 객체 등이 생성된다.&lt;/p&gt;
&lt;p&gt;그렇다고 FlatBuffers 사용이 무조건 의미 없는 것은 아니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JSON 문자열 파싱 비용은 여전히 제거된다.&lt;/li&gt;
&lt;li&gt;숫자 타입 변환 비용이 줄어든다.&lt;/li&gt;
&lt;li&gt;바이너리 크기를 줄일 수 있다.&lt;/li&gt;
&lt;li&gt;다중 언어 간 타입 계약을 유지할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;하지만 FlatBuffers의 장점을 극대화하려면 필요한 필드만 읽어야 한다.&lt;/p&gt;
&lt;p&gt;예를 들어 메시지 필터링 단계에서는 다음 세 필드만 확인할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;AircraftPosition message =
        AircraftPosition.getRootAsAircraftPosition(buffer);

if (message.sequence() &amp;lt;= lastSequence) {
    return;
}

if (message.status() == FlightStatus.Emergency) {
    emergencyHandler.handle(buffer);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;전체 메시지를 객체로 복원하지 않고 sequence와 status만 확인하는 것이다.&lt;/p&gt;
&lt;p&gt;FlatBuffers는 이와 같은 &lt;strong&gt;부분 조회와 지연 조회&lt;/strong&gt;에서 가장 큰 강점을 가진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;10. FlatBuffers와 Protocol Buffers의 차이&lt;/h2&gt;
&lt;p&gt;FlatBuffers와 Protocol Buffers는 모두 스키마 기반의 바이너리 직렬화 기술이지만 최적화 방향이 다르다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;비교 항목&lt;/th&gt;
&lt;th&gt;FlatBuffers&lt;/th&gt;
&lt;th&gt;Protocol Buffers&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;/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;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wire 크기&lt;/td&gt;
&lt;td&gt;offset과 vtable로 커질 수 있음&lt;/td&gt;
&lt;td&gt;일반적으로 매우 작음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API 사용성&lt;/td&gt;
&lt;td&gt;Builder 사용이 다소 복잡함&lt;/td&gt;
&lt;td&gt;비교적 자연스러운 객체 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 변경&lt;/td&gt;
&lt;td&gt;제한적&lt;/td&gt;
&lt;td&gt;Builder를 이용한 수정이 편리함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gRPC 생태계&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;일반적인 RPC, MSA, 서비스 간 통신&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;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Protocol Buffers 역시&lt;/strong&gt; 스키마에서 생성된 코드로 바이너리를 읽고 쓰지만, &lt;strong&gt;일반적으로 바이너리를 메시지 객체로 파싱하는 과정을 거친다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;FlatBuffers가 항상 더 작거나 빠른 것은 아니다.&lt;/p&gt;
&lt;p&gt;FlatBuffers 공식 C++ 벤치마크에서도 예제 데이터 기준으로 &lt;strong&gt;디코딩 비용과 임시 메모리 사용량은 FlatBuffers가 매우 낮았지만, Wire 크기는 Protocol Buffers보다 크게 측정됐다.&lt;/strong&gt; 이 결과는 특정 C++ 데이터 구조를 기준으로 한 것이므로 실제 선택은 반드시 서비스의 실제 메시지로 검증해야 한다.&lt;/p&gt;
&lt;p&gt;실무에서는 다음 기준으로 선택할 수 있다.&lt;/p&gt;
&lt;h3&gt;Protocol Buffers가 적합한 경우&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;일반적인 마이크로서비스 RPC&lt;/li&gt;
&lt;li&gt;gRPC를 적극적으로 사용하는 시스템&lt;/li&gt;
&lt;li&gt;대부분의 필드를 매번 읽는 경우&lt;/li&gt;
&lt;li&gt;메시지 크기가 매우 중요한 경우&lt;/li&gt;
&lt;li&gt;개발 편의성과 생태계를 중시하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;FlatBuffers가 적합한 경우&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;전체 데이터 중 일부 필드만 자주 읽는 경우&lt;/li&gt;
&lt;li&gt;메시지를 여러 단계에서 필터링하거나 라우팅하는 경우&lt;/li&gt;
&lt;li&gt;C++, Java, 모바일 사이에서 동일한 바이너리를 공유하는 경우&lt;/li&gt;
&lt;li&gt;GC와 임시 객체 할당을 최소화해야 하는 경우&lt;/li&gt;
&lt;li&gt;수신 메시지를 메모리 또는 파일에 저장한 뒤 반복 조회하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;11. JSON, Protocol Buffers, FlatBuffers 비교&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;JSON&lt;/th&gt;
&lt;th&gt;Protocol Buffers&lt;/th&gt;
&lt;th&gt;FlatBuffers&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;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;/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;/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;/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;/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;/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;/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;/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;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;“JSON보다 빠른 포맷이 필요하다”는 이유만으로 FlatBuffers를 선택하는 것은 지나친 결정일 수 있다.&lt;/p&gt;
&lt;p&gt;JSON 병목이 실제로 파싱과 객체 할당에서 발생하는지 먼저 확인해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;CPU 사용률이 높은가?
GC 시간이 증가하는가?
메시지 역직렬화가 프로파일에서 큰 비중을 차지하는가?
전체 필드 중 일부만 조회하는가?
P99 지연 시간에 변동이 있는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;대부분의 일반적인 백엔드 서비스에서는 Protocol Buffers가 더 단순한 선택일 수 있다.&lt;/p&gt;
&lt;p&gt;FlatBuffers는 &lt;strong&gt;읽기 경로의 할당과 지연 시간을 극단적으로 줄여야 하는 경우&lt;/strong&gt;에 더 적합하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;12. 스키마 진화 시 반드시 지켜야 할 규칙&lt;/h2&gt;
&lt;p&gt;FlatBuffers는 이전 버전과 이후 버전 사이의 호환성을 지원하지만, 아무렇게나 스키마를 변경해도 되는 것은 아니다.&lt;/p&gt;
&lt;h3&gt;12.1 새로운 필드는 Table의 마지막에 추가한다&lt;/h3&gt;
&lt;p&gt;기존 스키마가 다음과 같다고 가정하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-flatbuffers&quot;&gt;table AircraftPosition {
  aircraftId:string;
  sequence:long;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;필드를 추가할 때는 마지막에 추가한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-flatbuffers&quot;&gt;table AircraftPosition {
  aircraftId:string;
  sequence:long;
  occurredAt:long;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;모든 필드에 명시적인 &lt;code&gt;id&lt;/code&gt;를 지정하지 않는 한 새로운 필드는 반드시 Table 정의의 끝에 추가해야 한다.&lt;/p&gt;
&lt;h3&gt;12.2 기존 필드를 삭제하지 않는다&lt;/h3&gt;
&lt;p&gt;잘못된 변경이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-flatbuffers&quot;&gt;table AircraftPosition {
  aircraftId:string;
  // sequence 필드를 삭제
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;사용하지 않는 필드는 삭제하는 대신 &lt;code&gt;deprecated&lt;/code&gt;로 표시한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-flatbuffers&quot;&gt;table AircraftPosition {
  aircraftId:string;
  sequence:long (deprecated);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;deprecated 필드는 새로운 코드에서 접근자가 생성되지 않지만, 기존 바이너리 레이아웃과의 호환성을 유지한다.&lt;/p&gt;
&lt;h3&gt;12.3 기존 필드의 기본값을 변경하지 않는다&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-flatbuffers&quot;&gt;status:FlightStatus = Normal;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 값을 나중에 다음과 같이 변경하면 구버전 데이터의 해석이 달라질 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-flatbuffers&quot;&gt;status:FlightStatus = Warning;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;바이너리에 필드가 저장되지 않은 경우 접근자는 현재 스키마의 기본값을 반환하기 때문이다.&lt;/p&gt;
&lt;h3&gt;12.4 변경 가능성이 있는 데이터는 Struct로 만들지 않는다&lt;/h3&gt;
&lt;p&gt;Struct는 필드를 추가하거나 제거할 수 없다.&lt;/p&gt;
&lt;p&gt;다음 좌표 구조에 정확도나 기준 좌표계 필드가 추가될 가능성이 있다면 Table이 더 안전할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-flatbuffers&quot;&gt;table Position {
  latitude:double;
  longitude:double;
  altitude:double;
  coordinateSystem:string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;12.5 &lt;code&gt;required&lt;/code&gt; 사용을 최소화한다&lt;/h3&gt;
&lt;p&gt;필드를 required로 지정하면 오래된 데이터에 해당 필드가 존재하지 않을 때 읽기 실패가 발생할 수 있다.&lt;/p&gt;
&lt;p&gt;호환성이 중요한 메시지는 선택 필드와 기본값을 중심으로 설계하는 것이 좋다. 공식 스키마 문서도 &lt;code&gt;required&lt;/code&gt; 속성의 추가 및 제거가 호환성을 깨뜨릴 수 있다고 경고한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;13. TCP, UDP, RabbitMQ에서 사용할 때의 주의사항&lt;/h2&gt;
&lt;p&gt;FlatBuffers는 메시지의 바이너리 구조를 정의할 뿐, 전송 프로토콜의 메시지 경계까지 해결하지 않는다.&lt;/p&gt;
&lt;h3&gt;UDP&lt;/h3&gt;
&lt;p&gt;UDP는 하나의 Datagram이 하나의 메시지 경계를 제공하므로 비교적 단순하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;UDP Datagram
┌─────────────────────────┐
│ FlatBuffers Payload     │
└─────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 MTU를 초과해 IP Fragmentation이 발생하지 않도록 메시지 크기를 관리해야 한다.&lt;/p&gt;
&lt;h3&gt;TCP&lt;/h3&gt;
&lt;p&gt;TCP는 Byte Stream이기 때문에 메시지 경계가 없다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;[length][flatbuffer][length][flatbuffer]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 4바이트 길이 필드를 추가할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;┌──────────────┬─────────────────────────┐
│ Length: 4B   │ FlatBuffers Payload     │
└──────────────┴─────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FlatBuffers 자체의 size-prefixed 형식을 사용하거나 애플리케이션 프로토콜 헤더를 정의할 수도 있다.&lt;/p&gt;
&lt;h3&gt;RabbitMQ&lt;/h3&gt;
&lt;p&gt;RabbitMQ는 메시지 단위로 본문을 전달하므로 별도의 길이 프레이밍은 필요하지 않다.&lt;/p&gt;
&lt;p&gt;대신 메시지 Header에 다음 정보를 둘 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;content-type: application/x-flatbuffers
schema-name: AircraftPosition
schema-version: 3
message-id: unique-key
occurred-at: 1783886400000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 FlatBuffers의 스키마 호환성이 유지된다면 단순히 애플리케이션 버전이 증가할 때마다 메시지 버전을 분기할 필요는 없다.&lt;/p&gt;
&lt;p&gt;버전 필드는 스키마 호환으로 해결할 수 없는 의미적 변경을 구분할 때 사용하는 것이 좋다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;14. 외부 입력은 신뢰하지 말아야 한다&lt;/h2&gt;
&lt;p&gt;FlatBuffers가 파싱 단계를 줄여 준다고 해서 수신 데이터가 항상 안전한 것은 아니다.&lt;/p&gt;
&lt;p&gt;외부 네트워크에서 들어오는 데이터에는 다음 문제가 있을 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;잘못된 Root offset&lt;/li&gt;
&lt;li&gt;버퍼 범위를 벗어난 Vector 길이&lt;/li&gt;
&lt;li&gt;손상된 문자열 offset&lt;/li&gt;
&lt;li&gt;예상하지 못한 메시지 타입&lt;/li&gt;
&lt;li&gt;지나치게 큰 Payload&lt;/li&gt;
&lt;li&gt;잘못된 Schema로 생성된 데이터&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;따라서 네트워크 경계에서는 최소한 다음 항목을 검증해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 최대 Payload 크기
2. 프로토콜 Magic 또는 File Identifier
3. 메시지 타입
4. 필수 비즈니스 필드
5. sequence 및 timestamp 범위
6. 비즈니스 값 범위&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 항공기 위치 데이터는 다음과 같이 검증할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private void validate(AircraftPosition message) {
    if (message.aircraftId() == null
            || message.aircraftId().isBlank()) {
        throw new IllegalArgumentException(&amp;quot;aircraftId is required&amp;quot;);
    }

    double latitude = message.position().latitude();
    double longitude = message.position().longitude();

    if (latitude &amp;lt; -90.0 || latitude &amp;gt; 90.0) {
        throw new IllegalArgumentException(&amp;quot;invalid latitude&amp;quot;);
    }

    if (longitude &amp;lt; -180.0 || longitude &amp;gt; 180.0) {
        throw new IllegalArgumentException(&amp;quot;invalid longitude&amp;quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;바이너리 구조 검증과 도메인 검증은 별개의 문제다.&lt;/p&gt;
&lt;p&gt;FlatBuffer 형식이 정상이라고 해서 항공기 위치나 거래 금액 같은 비즈니스 데이터까지 정상이라는 의미는 아니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;15. Spring Boot에서 계층을 어떻게 나누어야 할까?&lt;/h2&gt;
&lt;p&gt;생성된 FlatBuffers 클래스를 비즈니스 계층 전체에 노출하면 도메인 코드가 직렬화 기술에 종속된다.&lt;/p&gt;
&lt;p&gt;권장 구조는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;infrastructure
 └── flatbuffers
      ├── AircraftPositionEncoder
      ├── AircraftPositionDecoder
      └── generated
           ├── AircraftPosition
           ├── Position
           └── FlightStatus

application
 ├── AircraftPositionCommand
 └── AircraftPositionService

domain
 └── AircraftTrack&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;수신 경계에서 필요한 데이터를 애플리케이션 모델로 변환한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
public class FlatBuffersAircraftPositionMapper {

    public AircraftPositionCommand map(byte[] payload) {
        AircraftPosition message =
                AircraftPosition.getRootAsAircraftPosition(
                        ByteBuffer.wrap(payload)
                );

        return new AircraftPositionCommand(
                message.aircraftId(),
                message.sequence(),
                message.occurredAt(),
                message.position().latitude(),
                message.position().longitude(),
                message.position().altitude(),
                message.heading(),
                message.groundSpeed(),
                message.status()
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;반면 메시지를 라우팅하거나 필터링만 하는 계층에서는 FlatBuffers View를 그대로 사용할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public void route(byte[] payload) {
    AircraftPosition message =
            AircraftPosition.getRootAsAircraftPosition(
                    ByteBuffer.wrap(payload)
            );

    if (message.status() == FlightStatus.Emergency) {
        emergencyPublisher.publish(payload);
        return;
    }

    normalPublisher.publish(payload);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, 계층별 목적에 따라 전략을 나눌 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;라우팅·필터링 계층
→ 원본 FlatBuffer 직접 조회

핵심 비즈니스 계층
→ 도메인 모델로 변환

저장·재전송 계층
→ 원본 byte[] 유지&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FlatBuffers를 도입했다고 모든 계층에서 생성 코드를 직접 사용해야 하는 것은 아니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;16. 성능 테스트는 어떻게 해야 하는가?&lt;/h2&gt;
&lt;p&gt;직렬화 기술의 성능은 라이브러리 홈페이지의 벤치마크만으로 결정하면 안 된다.&lt;/p&gt;
&lt;p&gt;실제 서비스 메시지로 다음 항목을 측정해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;초당 직렬화 처리량&lt;/li&gt;
&lt;li&gt;초당 역직렬화 또는 필드 조회 처리량&lt;/li&gt;
&lt;li&gt;평균 지연 시간&lt;/li&gt;
&lt;li&gt;P95, P99 지연 시간&lt;/li&gt;
&lt;li&gt;할당된 메모리 크기&lt;/li&gt;
&lt;li&gt;GC 횟수와 시간&lt;/li&gt;
&lt;li&gt;Wire 크기&lt;/li&gt;
&lt;li&gt;전체 필드 조회와 일부 필드 조회의 차이&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;FlatBuffers는 데이터가 크고 일부만 조회할수록 상대적으로 유리할 가능성이 높다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;반대로 작은 메시지의 모든 필드를 매번 읽는 시스템에서는 Protocol Buffers와의 차이가 크지 않거나, 개발 복잡도까지 고려했을 때 오히려 손해일 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;17. FlatBuffers 도입 시 체크리스트&lt;/h2&gt;
&lt;p&gt;다음 질문에서 여러 항목에 “예”라고 답할 수 있을 때 FlatBuffers를 검토할 가치가 있다.&lt;/p&gt;
&lt;h3&gt;데이터 접근 특성&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;전체 데이터 중 일부 필드만 읽는가?&lt;/li&gt;
&lt;li&gt;동일한 바이너리를 여러 단계에서 반복 조회하는가?&lt;/li&gt;
&lt;li&gt;메시지를 역직렬화하지 않고 라우팅해야 하는가?&lt;/li&gt;
&lt;li&gt;대규모 Vector에서 특정 데이터만 조회하는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;성능 특성&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;역직렬화가 CPU 프로파일의 유의미한 비중을 차지하는가?&lt;/li&gt;
&lt;li&gt;임시 객체 생성 때문에 GC가 증가하는가?&lt;/li&gt;
&lt;li&gt;평균 처리량보다 P99 지연 시간이 중요한가?&lt;/li&gt;
&lt;li&gt;메모리 사용량이 제한된 환경인가?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;시스템 구조&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;C++, Java, 모바일 등 여러 언어가 데이터를 공유하는가?&lt;/li&gt;
&lt;li&gt;바이너리를 파일이나 메모리에 저장한 뒤 직접 조회하는가?&lt;/li&gt;
&lt;li&gt;스키마를 중앙에서 관리할 수 있는가?&lt;/li&gt;
&lt;li&gt;코드 생성 단계를 빌드 파이프라인에 포함할 수 있는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;반대로 다음 환경에서는 도입을 신중하게 판단해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;트래픽이 많지 않은 일반적인 CRUD API&lt;/li&gt;
&lt;li&gt;사람이 직접 Payload를 확인해야 하는 시스템&lt;/li&gt;
&lt;li&gt;메시지 구조가 자주 그리고 크게 변경되는 초기 서비스&lt;/li&gt;
&lt;li&gt;모든 필드를 항상 DTO로 변환하는 시스템&lt;/li&gt;
&lt;li&gt;gRPC 중심의 일반적인 마이크로서비스&lt;/li&gt;
&lt;li&gt;성능 측정 없이 단순히 JSON보다 빠른 포맷을 찾는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;18. 결론&lt;/h2&gt;
&lt;p&gt;FlatBuffers의 핵심은 단순히 “바이너리라서 빠르다”가 아니다.&lt;/p&gt;
&lt;p&gt;핵심은 다음 구조에 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;직렬화된 데이터
    ↓
별도 객체로 역직렬화하지 않음
    ↓
원본 버퍼에서 필요한 필드만 직접 조회&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조는 역직렬화 과정의 객체 생성과 임시 메모리 사용을 줄이고, 필요한 필드만 선택적으로 조회할 수 있게 한다.&lt;/p&gt;
&lt;p&gt;하지만 그 대가도 분명하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Builder API가 일반 객체 생성보다 복잡하다.&lt;/li&gt;
&lt;li&gt;버퍼 생명주기와 소유권을 관리해야 한다.&lt;/li&gt;
&lt;li&gt;바이너리를 사람이 직접 확인하기 어렵다.&lt;/li&gt;
&lt;li&gt;Protocol Buffers보다 Wire 크기가 클 수 있다.&lt;/li&gt;
&lt;li&gt;Schema 변경 규칙을 엄격하게 관리해야 한다.&lt;/li&gt;
&lt;li&gt;모든 데이터를 DTO로 복사하면 장점이 감소한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;따라서 FlatBuffers는 JSON이나 Protocol Buffers를 무조건 대체하는 기술이 아니다.&lt;/p&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;역직렬화 객체 할당과 GC가 병목이고, 수신한 데이터 전체가 아니라 일부 필드만 빠르게 조회해야 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;성능 최적화 기술은 장점만 보고 선택하는 것이 아니라, 현재 시스템의 병목과 데이터 접근 패턴에 맞춰 선택해야 한다.&lt;/p&gt;
&lt;p&gt;FlatBuffers 역시 도입 전에 실제 메시지를 이용한 벤치마크와 프로파일링이 선행되어야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;FlatBuffers 공식 Overview&lt;/li&gt;
&lt;li&gt;FlatBuffers 공식 Java Guide&lt;/li&gt;
&lt;li&gt;FlatBuffers Schema 문서&lt;/li&gt;
&lt;li&gt;FlatBuffers Schema Evolution 문서&lt;/li&gt;
&lt;li&gt;FlatBuffers Internals&lt;/li&gt;
&lt;li&gt;FlatBuffers 공식 Benchmark&lt;/li&gt;
&lt;li&gt;Protocol Buffers 공식 Overview&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;  </description>
      <category>나의 주니어 개발 일기/데이터 직렬화</category>
      <category>FlatBuffers</category>
      <category>Protobuf</category>
      <category>Protocol Buffers</category>
      <author>추억을 백앤드하자</author>
      <guid isPermaLink="true">https://pulpul8282.tistory.com/447</guid>
      <comments>https://pulpul8282.tistory.com/447#entry447comment</comments>
      <pubDate>Mon, 13 Jul 2026 15:07:42 +0900</pubDate>
    </item>
    <item>
      <title>RabbitMQ Prefetch와 Concurrency: 처리량을 높이기 전에 확인해야 할 것들</title>
      <link>https://pulpul8282.tistory.com/446</link>
      <description>&lt;div class=&quot;markdown-body&quot;&gt;
&lt;h1&gt;RabbitMQ Prefetch와 Concurrency: 처리량을 높이기 전에 확인해야 할 것들&lt;/h1&gt;
&lt;p&gt;RabbitMQ Consumer의 처리량이 부족할 때 가장 먼저 검토하는 설정은 대체로 다음 두 가지다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring.rabbitmq.listener.simple:
  concurrency: 10
  prefetch: 250&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;concurrency&lt;/code&gt;를 늘리면 여러 Consumer가 메시지를 병렬로 처리하고, &lt;code&gt;prefetch&lt;/code&gt;를 높이면 Consumer가 메시지를 미리 가져와 대기 시간을 줄일 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 두 값을 높이면 처리량도 자연스럽게 증가할 것처럼 보인다.&lt;/p&gt;
&lt;p&gt;하지만 실제 운영에서는 다음 문제가 함께 발생한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;메시지 처리 순서가 달라진다.&lt;/li&gt;
&lt;li&gt;처리 중 장애가 발생하면 메시지가 다시 전달될 수 있다.&lt;/li&gt;
&lt;li&gt;Consumer별 처리 속도 차이로 메시지가 한쪽에 몰릴 수 있다.&lt;/li&gt;
&lt;li&gt;너무 많은 메시지가 Consumer 메모리에 대기할 수 있다.&lt;/li&gt;
&lt;li&gt;DB Connection Pool이나 외부 API가 병목이 될 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;결국 &lt;code&gt;prefetch&lt;/code&gt;와 &lt;code&gt;concurrency&lt;/code&gt;는 단순한 성능 옵션이 아니다.&lt;/p&gt;
&lt;p&gt;두 설정은 RabbitMQ Consumer의 &lt;strong&gt;병렬 처리 단위&lt;/strong&gt;, &lt;strong&gt;미확인 메시지 수&lt;/strong&gt;, &lt;strong&gt;순서 보장 범위&lt;/strong&gt;, &lt;strong&gt;장애 발생 시 재처리 범위&lt;/strong&gt;를 결정하는 핵심 설정이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. Concurrency란 무엇인가&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;concurrency&lt;/code&gt;는 하나의 Listener Container가 동시에 실행할 Consumer 수를 의미한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  rabbitmq:
    listener:
      simple:
        concurrency: 10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위 설정에서는 하나의 애플리케이션 인스턴스가 해당 Queue에 대해 10개의 Consumer를 생성한다.&lt;/p&gt;
&lt;p&gt;구조를 단순화하면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;RabbitMQ Queue
     │
     ├── Consumer 1 → Listener Thread 1
     ├── Consumer 2 → Listener Thread 2
     ├── Consumer 3 → Listener Thread 3
     └── ...
         Consumer 10 → Listener Thread 10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RabbitMQ는 같은 우선순위의 여러 활성 Consumer가 존재하면 일반적으로 메시지를 Consumer들에게 분배한다. 따라서 Consumer가 여러 개라면 여러 메시지가 동시에 처리될 수 있다.&lt;/p&gt;
&lt;p&gt;예를 들어 메시지가 다음 순서로 Queue에 저장되어 있다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;M1 → M2 → M3 → M4&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Consumer가 하나라면 일반적으로 다음과 같이 처리된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer 1: M1 → M2 → M3 → M4&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Consumer가 두 개라면 메시지는 다음과 같이 나뉠 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer 1: M1 → M3
Consumer 2: M2 → M4&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 처리 시간은 메시지마다 다르기 때문에 완료 순서는 다음과 같이 달라질 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;완료 순서: M2 → M1 → M4 → M3&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, &lt;code&gt;concurrency &amp;gt; 1&lt;/code&gt;이면 Queue에 들어온 순서와 비즈니스 처리가 완료되는 순서는 같다고 보장할 수 없다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. Prefetch란 무엇인가&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;prefetch&lt;/code&gt;는 Consumer가 ACK를 보내기 전에 미리 전달받을 수 있는 메시지 수를 제한한다.&lt;/p&gt;
&lt;p&gt;RabbitMQ 공식 문서에서는 prefetch를 Consumer가 보유할 수 있는 &lt;strong&gt;미확인 상태의 메시지 수&lt;/strong&gt;, 즉 unacknowledged message의 최대 개수로 설명한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위 설정은 Consumer 하나가 ACK되지 않은 메시지를 최대 10개까지 전달받을 수 있다는 의미다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;RabbitMQ Queue
   │
   ├── Message 1 ┐
   ├── Message 2 │
   ├── Message 3 │ Consumer가 미리 확보
   └── ...       │
       Message 10┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;prefetch=1&lt;/code&gt;이라면 Consumer가 메시지 하나를 처리하고 ACK를 보낼 때까지 다음 메시지를 전달받지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Message 수신
    ↓
비즈니스 처리
    ↓
ACK
    ↓
다음 Message 수신&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;prefetch=10&lt;/code&gt;이라면 Consumer는 메시지 하나를 처리하는 동안 다음 메시지들을 미리 전달받을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer 내부

처리 중: Message 1
대기 중: Message 2 ~ Message 10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;네트워크 왕복과 메시지 전달 대기 시간을 줄일 수 있기 때문에 처리 속도가 빠르고 메시지 크기가 작다면 높은 prefetch가 처리량 향상에 도움이 된다.&lt;/p&gt;
&lt;p&gt;Spring AMQP의 Simple Listener Container는 일반적인 Consumer의 활용도를 높이기 위해 기본 prefetch 값을 250으로 사용해 왔다. 다만 메시지 처리 시간이 길거나 메시지 크기가 크고, 순서가 중요하거나 부하 분배가 중요한 경우에는 낮은 값을 사용할 것을 공식 문서에서도 권장한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. Concurrency와 Prefetch는 함께 계산해야 한다&lt;/h2&gt;
&lt;p&gt;두 설정은 독립적으로 보이지만 실제로는 함께 Consumer의 처리 구조를 결정한다.&lt;/p&gt;
&lt;p&gt;예를 들어 다음 설정을 사용한다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  rabbitmq:
    listener:
      simple:
        concurrency: 10
        prefetch: 250&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;애플리케이션 인스턴스 하나가 확보할 수 있는 최대 미확인 메시지 수는 대략 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;최대 Unacked 메시지 수
= concurrency × prefetch
= 10 × 250
= 2,500개&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;애플리케이션을 4개 인스턴스로 운영한다면 전체 Consumer가 가져갈 수 있는 미확인 메시지 수는 최대 다음 수준까지 증가할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;4 instances × 10 consumers × 250 prefetch
= 10,000 messages&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이는 Queue에 메시지가 10,000개 존재한다는 의미가 아니다.&lt;/p&gt;
&lt;p&gt;RabbitMQ Queue의 &lt;code&gt;Ready&lt;/code&gt; 상태가 아니라 각 Consumer에 전달되어 아직 ACK되지 않은 &lt;code&gt;Unacked&lt;/code&gt; 상태로 존재할 수 있다는 의미다.&lt;/p&gt;
&lt;p&gt;따라서 prefetch를 높이면 RabbitMQ 관리 화면에서 Queue의 &lt;code&gt;Ready&lt;/code&gt; 메시지는 빠르게 감소하지만, 실제 비즈니스 처리가 끝난 것은 아닐 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Ready 감소 ≠ 처리 완료
Unacked 증가 = Consumer가 가져갔지만 아직 처리 완료되지 않음&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 차이를 이해하지 못하면 Queue 적체가 해소된 것처럼 잘못 판단할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. Prefetch가 처리량을 높이는 이유&lt;/h2&gt;
&lt;p&gt;Consumer가 메시지 하나를 처리할 때마다 RabbitMQ에서 다음 메시지를 받아야 한다면 다음과 같은 대기 시간이 반복된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;메시지 수신 → 처리 → ACK → 다음 메시지 전달&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처리 시간이 짧을수록 메시지 전달과 ACK 과정에서 발생하는 네트워크 대기 비율이 상대적으로 커진다.&lt;/p&gt;
&lt;p&gt;Prefetch를 높이면 Consumer가 다음 메시지를 미리 확보하므로 처리 흐름이 끊기지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;낮은 prefetch

처리 ─ 대기 ─ 처리 ─ 대기 ─ 처리&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;높은 prefetch

처리 ─ 처리 ─ 처리 ─ 처리 ─ 처리&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특히 다음 조건에서는 prefetch 증가의 효과가 클 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;메시지 크기가 작다.&lt;/li&gt;
&lt;li&gt;메시지 처리 시간이 짧고 일정하다.&lt;/li&gt;
&lt;li&gt;네트워크 지연이 존재한다.&lt;/li&gt;
&lt;li&gt;Consumer가 CPU 또는 메모리 내 연산 중심으로 동작한다.&lt;/li&gt;
&lt;li&gt;메시지 간 순서 의존성이 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;반대로 다음 조건에서는 prefetch 증가 효과가 제한적이거나 오히려 위험할 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;메시지 처리 시간이 길다.&lt;/li&gt;
&lt;li&gt;메시지 크기가 크다.&lt;/li&gt;
&lt;li&gt;메시지마다 처리 시간 편차가 크다.&lt;/li&gt;
&lt;li&gt;DB Connection이나 외부 API 호출이 병목이다.&lt;/li&gt;
&lt;li&gt;메시지 순서가 중요하다.&lt;/li&gt;
&lt;li&gt;장애 발생 시 많은 메시지가 재전달되면 안 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;5. Prefetch가 너무 크면 발생하는 문제&lt;/h2&gt;
&lt;h3&gt;5.1 Consumer 간 불균형&lt;/h3&gt;
&lt;p&gt;Consumer A와 Consumer B가 존재하고 각각 &lt;code&gt;prefetch=100&lt;/code&gt;이라고 가정해 보자.&lt;/p&gt;
&lt;p&gt;RabbitMQ는 두 Consumer에게 메시지를 미리 전달할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer A: 100개 확보
Consumer B: 100개 확보&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그런데 Consumer A가 처리하는 메시지는 모두 단순 조회이고, Consumer B가 처리하는 메시지는 외부 API와 DB Transaction을 포함한다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer A: 메시지당 10ms
Consumer B: 메시지당 2초&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Consumer B가 확보한 메시지는 처리되지 못한 채 오랫동안 대기한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer A: 빠르게 처리 완료
Consumer B: 100개의 Unacked 메시지 장시간 보유&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Queue에 다른 Consumer가 존재하더라도 이미 Consumer B에게 전달된 메시지는 정상적으로 ACK 또는 NACK되기 전까지 다른 Consumer가 처리할 수 없다.&lt;/p&gt;
&lt;p&gt;결과적으로 높은 prefetch가 메시지를 느린 Consumer에 묶어두는 현상이 발생한다.&lt;/p&gt;
&lt;p&gt;처리 시간이 불규칙한 작업에서는 작은 prefetch가 Consumer 간 부하를 더 공정하게 분배할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;5.2 Consumer 메모리 사용량 증가&lt;/h3&gt;
&lt;p&gt;Consumer에 전달된 메시지는 애플리케이션 내부에 대기하게 된다.&lt;/p&gt;
&lt;p&gt;다음 조건이라면 메모리 사용량은 빠르게 증가한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;평균 메시지 크기: 500KB
concurrency: 10
prefetch: 250&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;메시지 본문 크기만 단순 계산해도 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;500KB × 10 × 250
= 약 1.25GB&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기에 메시지 객체, Header, 역직렬화 객체, Listener Container 내부 자료구조가 추가된다.&lt;/p&gt;
&lt;p&gt;따라서 메시지 크기가 크다면 prefetch를 처리량만 보고 높여서는 안 된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;5.3 장애 발생 시 재전달 범위 증가&lt;/h3&gt;
&lt;p&gt;Consumer가 메시지를 전달받았지만 ACK하지 않은 상태에서 Connection이나 Channel이 종료되면 RabbitMQ는 미확인 메시지를 다시 Queue로 돌려보내 다른 Consumer에 재전달할 수 있다. RabbitMQ의 Consumer ACK는 메시지가 수신 또는 처리되었음을 Broker에 알려주는 신뢰성 메커니즘이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer가 250개 확보
    ↓
100개 처리 중 애플리케이션 종료
    ↓
ACK되지 않은 메시지 재전달 가능&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;prefetch가 크면 장애 시 재전달될 수 있는 메시지 범위도 커진다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 점은 애플리케이션이 DB 처리를 완료했지만 ACK를 보내기 전에 종료되는 경우다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 메시지 수신
2. DB INSERT 완료
3. 애플리케이션 비정상 종료
4. ACK 전송 실패
5. RabbitMQ가 메시지 재전달
6. DB INSERT 재실행&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조에서는 동일한 메시지가 두 번 처리될 수 있다.&lt;/p&gt;
&lt;p&gt;RabbitMQ Consumer는 일반적으로 exactly-once 처리를 보장하지 않는다. 실무에서는 &lt;strong&gt;at-least-once delivery를 전제로 멱등성을 설계&lt;/strong&gt;해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. ACK 모드를 이해하지 않고 Prefetch를 조정하면 안 된다&lt;/h2&gt;
&lt;p&gt;Spring AMQP에서 주로 사용하는 ACK 모드는 다음과 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ACK 모드&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;code&gt;NONE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Broker가 전달 즉시 처리된 것으로 간주&lt;/td&gt;
&lt;td&gt;장애 시 메시지 유실 가능성이 큼&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AUTO&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Listener가 정상 종료되면 Container가 ACK&lt;/td&gt;
&lt;td&gt;Spring AMQP에서 일반적으로 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MANUAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;애플리케이션 코드가 직접 ACK/NACK&lt;/td&gt;
&lt;td&gt;세밀한 제어 가능, 구현 복잡도 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;여기서 주의할 점은 Spring AMQP의 &lt;code&gt;AcknowledgeMode.AUTO&lt;/code&gt;와 RabbitMQ protocol 수준의 automatic acknowledgement가 같은 의미가 아니라는 것이다.&lt;/p&gt;
&lt;p&gt;Spring의 &lt;code&gt;AUTO&lt;/code&gt;는 Listener 호출이 정상적으로 완료되면 Container가 메시지를 ACK한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@RabbitListener(queues = &amp;quot;order.queue&amp;quot;)
public void consume(OrderMessage message) {
    orderService.process(message);

    // 메서드가 정상 종료되면 Container가 ACK
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Listener에서 예외가 발생하면 ACK되지 않고 Container의 예외 처리 및 재큐잉 설정에 따라 NACK 또는 reject 처리가 수행될 수 있다.&lt;/p&gt;
&lt;p&gt;RabbitMQ protocol 수준에서 ACK를 사용하지 않는 automatic acknowledgement는 메시지가 Consumer에 전달된 즉시 성공한 것으로 간주하므로 Consumer 장애 시 메시지 유실 위험이 있다. 또한 미확인 메시지 제한이 적용되지 않아 Consumer 과부하를 유발할 수 있다. RabbitMQ는 bounded prefetch와 manual acknowledgement 조합을 일반적인 안정적 처리 방식으로 설명한다.&lt;/p&gt;
&lt;h3&gt;AUTO ACK 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: auto
        concurrency: 5
        prefetch: 20&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@RabbitListener(queues = &amp;quot;payment.queue&amp;quot;)
public void consume(PaymentMessage message) {
    paymentService.process(message);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;paymentService.process()&lt;/code&gt;가 정상 종료되면 ACK된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;MANUAL ACK 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: manual
        concurrency: 5
        prefetch: 20&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@RabbitListener(queues = &amp;quot;payment.queue&amp;quot;)
public void consume(
        PaymentMessage message,
        Channel channel,
        @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag
) throws IOException {

    try {
        paymentService.process(message);

        channel.basicAck(deliveryTag, false);
    } catch (RetryableException e) {
        channel.basicNack(deliveryTag, false, true);
    } catch (Exception e) {
        channel.basicNack(deliveryTag, false, false);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 인자의 의미는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;basicAck(deliveryTag, multiple)
basicNack(deliveryTag, multiple, requeue)&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;multiple=false&lt;/code&gt;: 해당 메시지만 처리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;multiple=true&lt;/code&gt;: 해당 delivery tag 이전의 여러 메시지를 일괄 처리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requeue=true&lt;/code&gt;: Queue에 다시 넣음&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requeue=false&lt;/code&gt;: 폐기하거나 DLX 설정에 따라 DLQ로 이동&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;RabbitMQ의 &lt;code&gt;basic.reject&lt;/code&gt;와 &lt;code&gt;basic.nack&lt;/code&gt;는 Consumer가 처리에 실패한 메시지를 재큐잉하거나 폐기하도록 지시하는 Negative Acknowledgement 메커니즘이다.&lt;/p&gt;
&lt;p&gt;실무에서는 무조건 &lt;code&gt;requeue=true&lt;/code&gt;를 사용하면 안 된다.&lt;/p&gt;
&lt;p&gt;복구 불가능한 메시지를 계속 재큐잉하면 다음과 같은 무한 반복이 발생한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;수신 → 실패 → requeue → 재수신 → 실패 → requeue&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 재시도 횟수를 제한하고 최종적으로 DLQ로 보내는 구조가 필요하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7. Concurrency를 높이면 순서가 보장되는가&lt;/h2&gt;
&lt;p&gt;결론부터 말하면 &lt;strong&gt;하나의 Queue에 여러 Consumer가 붙으면 전체 처리 순서는 보장되지 않는다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;RabbitMQ Queue는 기본적으로 FIFO 성격을 가지지만 competing consumer, prefetch, requeue, redelivery 등이 개입하면 실제 처리 완료 순서는 달라질 수 있다. RabbitMQ 공식 문서도 FIFO를 설명할 때 이러한 요인을 제외한 조건을 전제로 한다.&lt;/p&gt;
&lt;p&gt;예를 들어 항공기 위치 데이터가 다음과 같이 들어온다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Aircraft A
10:00:01 → Position 1
10:00:02 → Position 2
10:00:03 → Position 3&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;concurrency=3&lt;/code&gt;이면 다음과 같이 처리될 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer 1 → Position 1, 처리 시간 500ms
Consumer 2 → Position 2, 처리 시간 50ms
Consumer 3 → Position 3, 처리 시간 100ms&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처리 완료 순서는 다음과 같아진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Position 2 → Position 3 → Position 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;데이터베이스에 단순 UPDATE를 수행한다면 최종 상태가 과거 위치로 되돌아갈 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;정상 최종 상태: Position 3
실제 최종 상태: Position 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 데이터 안에 timestamp가 존재한다고 해서 자동으로 순서가 보장되는 것은 아니다.&lt;/p&gt;
&lt;p&gt;timestamp는 순서를 &lt;strong&gt;판단하기 위한 데이터&lt;/strong&gt;일 뿐이고, 애플리케이션이 이전 데이터와 비교해서 오래된 메시지를 거부해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. 위치 데이터의 순서 역전을 방지하는 방법&lt;/h2&gt;
&lt;p&gt;항공기나 드론 위치처럼 Entity별 순서는 중요하지만 전체 Entity 간 순서는 중요하지 않은 데이터가 많다.&lt;/p&gt;
&lt;p&gt;이 경우 전체 Queue를 단일 Consumer로 처리하면 순서는 유지하기 쉽지만 처리량이 크게 제한된다.&lt;/p&gt;
&lt;p&gt;더 적절한 방법은 &lt;strong&gt;Entity 단위 순서 보장&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h3&gt;방법 1. DB 조건부 UPDATE&lt;/h3&gt;
&lt;p&gt;메시지에 &lt;code&gt;aircraftId&lt;/code&gt;와 &lt;code&gt;eventTimestamp&lt;/code&gt;가 있다고 가정한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public record AircraftPositionMessage(
        String messageId,
        String aircraftId,
        double latitude,
        double longitude,
        Instant eventTimestamp
) {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB UPDATE 시 현재 저장된 timestamp보다 최신인 경우에만 반영한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE aircraft_position
SET latitude = :latitude,
    longitude = :longitude,
    event_timestamp = :eventTimestamp
WHERE aircraft_id = :aircraftId
  AND event_timestamp &amp;lt; :eventTimestamp;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;초기 데이터가 없을 수 있다면 PostgreSQL에서는 다음과 같이 구성할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;INSERT INTO aircraft_position (
    aircraft_id,
    latitude,
    longitude,
    event_timestamp
)
VALUES (
    :aircraftId,
    :latitude,
    :longitude,
    :eventTimestamp
)
ON CONFLICT (aircraft_id)
DO UPDATE
SET latitude = EXCLUDED.latitude,
    longitude = EXCLUDED.longitude,
    event_timestamp = EXCLUDED.event_timestamp
WHERE aircraft_position.event_timestamp &amp;lt; EXCLUDED.event_timestamp;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 방식은 메시지가 역순으로 처리되더라도 최신 상태가 과거 상태로 덮어써지는 것을 방지한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;방법 2. Entity별 Queue 분할&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;aircraftId&lt;/code&gt;를 기준으로 Queue를 분할한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;aircraftId hash % 4

Queue 0
Queue 1
Queue 2
Queue 3&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;동일한 항공기는 항상 같은 Queue로 보내고 각 Queue를 단일 Consumer로 처리한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Aircraft A → Queue 1 → Consumer 1
Aircraft B → Queue 3 → Consumer 3
Aircraft C → Queue 1 → Consumer 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 방식은 Entity별 순서를 보장하면서 Queue 간 병렬 처리가 가능하다.&lt;/p&gt;
&lt;p&gt;다만 Queue 수를 동적으로 과도하게 늘리지 말고 고정된 Partition Queue를 운용하는 것이 관리 측면에서 유리하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;방법 3. 메시지 버전 또는 Sequence 사용&lt;/h3&gt;
&lt;p&gt;발행자가 Entity별 증가하는 sequence를 부여한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;aircraftId&amp;quot;: &amp;quot;A001&amp;quot;,
  &amp;quot;sequence&amp;quot;: 1523,
  &amp;quot;eventTimestamp&amp;quot;: &amp;quot;2026-07-11T10:00:03Z&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Consumer는 마지막 처리 sequence보다 큰 메시지만 반영한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;if (message.sequence() &amp;lt;= currentSequence) {
    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;동일 timestamp가 생성될 수 있거나 발행 시스템 간 시계가 정확히 동기화되지 않는 환경에서는 timestamp만 사용하는 것보다 sequence가 더 명확하다.&lt;/p&gt;
&lt;p&gt;여러 Producer가 같은 Entity의 메시지를 발행한다면 단순한 로컬 증가값으로는 부족하다. 이 경우 Entity별 sequence 발급 주체를 단일화하거나 DB version, Redis atomic counter 등의 구조가 필요하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;9. Concurrency를 높이면 중복이 발생하는가&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;concurrency&lt;/code&gt; 자체가 메시지를 복제하지는 않는다.&lt;/p&gt;
&lt;p&gt;정상적인 상황에서 하나의 Queue에 여러 Consumer가 연결되면 하나의 메시지는 그중 하나의 Consumer에게 전달된다.&lt;/p&gt;
&lt;p&gt;하지만 다음 상황에서는 동일한 비즈니스 이벤트가 다시 처리될 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Consumer가 메시지를 수신
2. DB 처리를 완료
3. ACK 전송 전 Consumer 종료
4. RabbitMQ가 메시지를 재전달
5. 다른 Consumer가 동일 메시지를 처리&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;concurrency가 높을수록 동시에 처리 중인 메시지 수가 많아지고, prefetch가 높을수록 Consumer가 확보한 미확인 메시지 수도 많아진다.&lt;/p&gt;
&lt;p&gt;따라서 장애 시 중복 처리의 영향 범위가 커질 가능성이 있다.&lt;/p&gt;
&lt;p&gt;핵심은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Concurrency가 중복을 만드는 것은 아니다.
ACK 이전 장애와 재전달이 중복 처리 가능성을 만든다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 문제는 ACK 설정만으로 완전히 제거할 수 없다.&lt;/p&gt;
&lt;p&gt;ACK를 DB 처리 전에 보내면 메시지 유실 위험이 생긴다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ACK → DB 처리 → 장애
= 메시지 유실&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ACK를 DB 처리 후에 보내면 중복 처리 가능성이 생긴다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DB 처리 → 장애 → ACK 실패 → 재전달
= 중복 처리&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 반드시 처리되어야 하는 메시지는 일반적으로 &lt;strong&gt;DB 처리 후 ACK + 멱등 처리&lt;/strong&gt; 구조를 사용한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;10. 멱등 처리는 선택이 아니라 필수다&lt;/h2&gt;
&lt;p&gt;각 메시지에 전역적으로 유일한 &lt;code&gt;messageId&lt;/code&gt; 또는 &lt;code&gt;uniqueKey&lt;/code&gt;를 포함한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;messageId&amp;quot;: &amp;quot;9f72b152-04f3-44f0-a244-71bc95a54b30&amp;quot;,
  &amp;quot;aircraftId&amp;quot;: &amp;quot;A001&amp;quot;,
  &amp;quot;eventTimestamp&amp;quot;: &amp;quot;2026-07-11T10:00:03Z&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB에 처리 이력을 저장한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE processed_message (
    message_id VARCHAR(100) PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL
);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Consumer는 비즈니스 처리와 처리 이력 저장을 하나의 Transaction으로 묶는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Service
@RequiredArgsConstructor
public class AircraftPositionService {

    private final ProcessedMessageRepository processedMessageRepository;
    private final AircraftPositionRepository aircraftPositionRepository;

    @Transactional
    public void process(AircraftPositionMessage message) {
        if (processedMessageRepository.existsById(message.messageId())) {
            return;
        }

        aircraftPositionRepository.upsertIfNewer(
                message.aircraftId(),
                message.latitude(),
                message.longitude(),
                message.eventTimestamp()
        );

        processedMessageRepository.save(
                new ProcessedMessage(
                        message.messageId(),
                        Instant.now()
                )
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 &lt;code&gt;existsById()&lt;/code&gt; 후 &lt;code&gt;save()&lt;/code&gt; 방식만으로는 여러 Consumer가 동시에 동일 메시지를 처리할 때 경쟁 조건이 발생할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer A: existsById() → false
Consumer B: existsById() → false
Consumer A: 비즈니스 처리
Consumer B: 비즈니스 처리&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 최종 방어선은 DB의 Unique Constraint여야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void process(AircraftPositionMessage message) {
    try {
        processedMessageRepository.insert(message.messageId());
    } catch (DuplicateKeyException e) {
        return;
    }

    aircraftPositionRepository.upsertIfNewer(
            message.aircraftId(),
            message.latitude(),
            message.longitude(),
            message.eventTimestamp()
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;보다 명확하게는 다음처럼 조건부 INSERT 결과를 사용할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;INSERT INTO processed_message (
    message_id,
    processed_at
)
VALUES (
    :messageId,
    CURRENT_TIMESTAMP
)
ON CONFLICT (message_id) DO NOTHING;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;반영된 row 수가 0이면 이미 처리한 메시지다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void process(AircraftPositionMessage message) {
    int inserted = processedMessageRepository.insertIfAbsent(
            message.messageId()
    );

    if (inserted == 0) {
        return;
    }

    aircraftPositionRepository.upsertIfNewer(message);
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;11. Concurrency를 높인다고 처리량이 계속 증가하지는 않는다&lt;/h2&gt;
&lt;p&gt;Consumer의 병렬 처리 수를 늘리면 일정 구간까지 처리량이 증가한다.&lt;/p&gt;
&lt;p&gt;하지만 실제 처리량은 Consumer Thread 수만으로 결정되지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;RabbitMQ Consumer
    ↓
애플리케이션 Thread
    ↓
DB Connection Pool
    ↓
Database&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 다음과 같은 설정을 사용한다고 가정해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  rabbitmq:
    listener:
      simple:
        concurrency: 30
        prefetch: 100

  datasource:
    hikari:
      maximum-pool-size: 10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;30개의 Consumer가 동시에 DB 작업을 요청하지만 DB Connection은 10개뿐이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer Thread 30개
DB Connection 10개&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;나머지 20개 Thread는 Connection을 기다린다.&lt;/p&gt;
&lt;p&gt;이 상태에서 concurrency를 50으로 늘리면 처리량보다 다음 비용이 증가할 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DB Connection 대기 시간&lt;/li&gt;
&lt;li&gt;Thread Context Switching&lt;/li&gt;
&lt;li&gt;메모리 사용량&lt;/li&gt;
&lt;li&gt;Transaction 경합&lt;/li&gt;
&lt;li&gt;Lock 경합&lt;/li&gt;
&lt;li&gt;응답 지연&lt;/li&gt;
&lt;li&gt;ACK 지연&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;따라서 concurrency는 다음 리소스와 함께 결정해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer concurrency
≤ DB Connection Pool
≤ DB가 안정적으로 처리 가능한 동시 요청 수&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;모든 메시지가 DB를 사용한다면 보수적으로 다음과 같이 시작할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Consumer concurrency
≈ Consumer 전용 DB Connection 예산&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 HikariCP가 30개이고 HTTP 요청용 Connection을 15개 남겨야 한다면 RabbitMQ Consumer concurrency는 10~15 수준에서 시작하는 식이다.&lt;/p&gt;
&lt;p&gt;정확한 값은 공식이 아니라 부하 테스트와 운영 지표를 통해 결정해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;12. 처리량을 추정하는 기준&lt;/h2&gt;
&lt;p&gt;Consumer 하나의 평균 처리 시간이 &lt;code&gt;T&lt;/code&gt;초라면 이상적인 최대 처리량은 다음과 같이 근사할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;단일 Consumer 처리량 ≈ 1 / 평균 처리 시간&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;메시지 하나 처리에 평균 100ms가 걸리면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1 / 0.1초 = 초당 약 10건&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Consumer가 10개라면 이론적인 처리량은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10 consumers × 10 msg/s
= 약 100 msg/s&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이를 일반화하면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;예상 처리량
≈ concurrency / 평균 처리 시간(초)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 목표 처리량이 초당 500건이고 평균 처리 시간이 50ms라면 필요한 concurrency는 다음과 같이 추정할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;필요 concurrency
≈ 목표 TPS × 평균 처리 시간
≈ 500 × 0.05
≈ 25&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 실제 환경에서는 다음 요소를 반영해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;처리 시간의 p95, p99&lt;/li&gt;
&lt;li&gt;DB Connection 대기&lt;/li&gt;
&lt;li&gt;Network I/O&lt;/li&gt;
&lt;li&gt;GC Pause&lt;/li&gt;
&lt;li&gt;Lock 경합&lt;/li&gt;
&lt;li&gt;RabbitMQ 전달 지연&lt;/li&gt;
&lt;li&gt;Retry 비율&lt;/li&gt;
&lt;li&gt;외부 API Rate Limit&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;따라서 평균값만 사용하지 말고 여유 계수를 포함해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;권장 초기값
≈ 목표 TPS × p95 처리 시간 × 안전 계수&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;안전 계수는 시스템 특성에 따라 1.2~2.0 수준에서 시작해 부하 테스트로 검증할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;13. Prefetch 초기값을 정하는 기준&lt;/h2&gt;
&lt;p&gt;모든 시스템에 적용할 수 있는 하나의 최적 prefetch 값은 없다.&lt;/p&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;권장 초기 접근&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;code&gt;1~10&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;순서가 매우 중요함&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt; 또는 낮은 값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB Transaction 중심&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5~20&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 API 호출 중심&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1~10&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;짧은 CPU 연산 중심&lt;/td&gt;
&lt;td&gt;&lt;code&gt;20~100&lt;/code&gt; 이상 검토&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&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;/tr&gt;
&lt;tr&gt;
&lt;td&gt;장애 시 재처리 비용이 큼&lt;/td&gt;
&lt;td&gt;낮은 값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch ACK를 활용함&lt;/td&gt;
&lt;td&gt;Batch 크기를 고려해 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;실무적으로는 다음 순서로 조정하는 것이 안전하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. concurrency를 리소스 한도 안에서 결정
2. prefetch를 낮은 값으로 시작
3. Consumer idle time과 처리량 확인
4. prefetch를 단계적으로 증가
5. Unacked, 메모리, 처리 지연 관찰&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 다음과 같이 테스트할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;concurrency=10 고정

prefetch=1
prefetch=5
prefetch=10
prefetch=20
prefetch=50&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 단계에서 다음 지표를 비교한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;초당 처리량&lt;/li&gt;
&lt;li&gt;메시지 처리 p95/p99&lt;/li&gt;
&lt;li&gt;Queue &lt;code&gt;Ready&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Queue &lt;code&gt;Unacked&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Consumer utilization&lt;/li&gt;
&lt;li&gt;애플리케이션 Heap&lt;/li&gt;
&lt;li&gt;DB Connection active/pending&lt;/li&gt;
&lt;li&gt;실패율 및 Retry 비율&lt;/li&gt;
&lt;li&gt;재전달 메시지 수&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;14. Spring Boot 설정 예시&lt;/h2&gt;
&lt;h3&gt;고정 Consumer 수&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  rabbitmq:
    listener:
      simple:
        concurrency: 10
        max-concurrency: 10
        prefetch: 20
        acknowledge-mode: auto
        default-requeue-rejected: false&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;concurrency&lt;/code&gt;와 &lt;code&gt;max-concurrency&lt;/code&gt;를 동일하게 지정하면 고정된 Consumer 수로 운영할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;동적 Consumer 확장&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  rabbitmq:
    listener:
      simple:
        concurrency: 5
        max-concurrency: 20
        prefetch: 20&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spring의 &lt;code&gt;SimpleMessageListenerContainer&lt;/code&gt;는 최소 Consumer 수를 유지하다가 부하가 증가하면 설정된 최대 Consumer 수까지 점진적으로 확장할 수 있다.&lt;/p&gt;
&lt;p&gt;동적 확장은 트래픽 변동에 대응하기 좋지만 다음을 주의해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Consumer 증가 속도가 순간 트래픽 증가보다 느릴 수 있다.&lt;/li&gt;
&lt;li&gt;Consumer 증가와 함께 DB 동시 요청 수도 증가한다.&lt;/li&gt;
&lt;li&gt;자동 확장으로 순서 역전 가능성이 더 커질 수 있다.&lt;/li&gt;
&lt;li&gt;Kubernetes Pod Auto Scaling과 Listener 동적 확장이 동시에 작동하면 과도하게 확장될 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;운영 예측 가능성이 중요하다면 Consumer 수를 고정하고 애플리케이션 인스턴스 단위로 확장하는 방법도 고려할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Listener별 개별 설정&lt;/h3&gt;
&lt;p&gt;Queue마다 처리 특성이 다르면 전역 설정 하나를 공통 적용하지 않는 것이 좋다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Configuration
@RequiredArgsConstructor
public class RabbitListenerConfig {

    private final ConnectionFactory connectionFactory;

    @Bean
    public SimpleRabbitListenerContainerFactory positionListenerFactory() {
        SimpleRabbitListenerContainerFactory factory =
                new SimpleRabbitListenerContainerFactory();

        factory.setConnectionFactory(connectionFactory);
        factory.setConcurrentConsumers(10);
        factory.setMaxConcurrentConsumers(10);
        factory.setPrefetchCount(50);
        factory.setAcknowledgeMode(AcknowledgeMode.AUTO);

        return factory;
    }

    @Bean
    public SimpleRabbitListenerContainerFactory paymentListenerFactory() {
        SimpleRabbitListenerContainerFactory factory =
                new SimpleRabbitListenerContainerFactory();

        factory.setConnectionFactory(connectionFactory);
        factory.setConcurrentConsumers(3);
        factory.setMaxConcurrentConsumers(3);
        factory.setPrefetchCount(5);
        factory.setAcknowledgeMode(AcknowledgeMode.AUTO);

        return factory;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@RabbitListener(
        queues = &amp;quot;aircraft.position.queue&amp;quot;,
        containerFactory = &amp;quot;positionListenerFactory&amp;quot;
)
public void consumePosition(AircraftPositionMessage message) {
    positionService.process(message);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@RabbitListener(
        queues = &amp;quot;payment.queue&amp;quot;,
        containerFactory = &amp;quot;paymentListenerFactory&amp;quot;
)
public void consumePayment(PaymentMessage message) {
    paymentService.process(message);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위치 데이터는 처리 속도가 빠르므로 높은 concurrency와 prefetch를 적용하고, 결제 데이터는 중복 처리 비용과 외부 시스템 의존성이 크므로 보수적으로 설정할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;15. Retry와 DLQ를 함께 설계해야 한다&lt;/h2&gt;
&lt;p&gt;Consumer 처리 실패를 모두 같은 방식으로 다루면 안 된다.&lt;/p&gt;
&lt;p&gt;실패는 크게 두 종류로 나뉜다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;일시적 실패
- DB 순간 장애
- 외부 API Timeout
- Network 오류
- Lock Timeout&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;영구적 실패
- 잘못된 메시지 포맷
- 필수 값 누락
- 지원하지 않는 이벤트 타입
- 비즈니스 규칙 위반&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;일시적 실패는 제한된 횟수만 재시도하고, 영구적 실패는 바로 DLQ로 보내는 것이 적절하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Main Queue
    ↓
Consumer 처리 실패
    ↓
Retry Queue 또는 Application Retry
    ↓
최대 횟수 초과
    ↓
DLQ&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;default-requeue-rejected=true&lt;/code&gt; 상태에서 지속적으로 예외가 발생하면 동일 메시지가 반복 소비될 수 있으므로 운영 환경에서는 명시적인 Retry와 DLQ 정책을 구성하는 편이 안전하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: auto
        default-requeue-rejected: false
        retry:
          enabled: true
          max-attempts: 3
          initial-interval: 1000ms
          multiplier: 2
          max-interval: 5000ms&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 Application 내부 Retry는 Consumer Thread를 점유한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;처리 실패
→ 1초 대기
→ 재시도
→ 2초 대기
→ 재시도&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처리 실패가 많으면 concurrency가 충분하더라도 Listener Thread가 Retry 대기로 소진될 수 있다.&lt;/p&gt;
&lt;p&gt;긴 Retry 간격이 필요하다면 Retry Queue와 TTL, DLX를 활용해 Consumer Thread를 반환하는 구조가 더 적절하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;16. 운영에서 반드시 확인할 지표&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;prefetch&lt;/code&gt;와 &lt;code&gt;concurrency&lt;/code&gt; 튜닝은 설정 파일만 보고 판단할 수 없다.&lt;/p&gt;
&lt;p&gt;RabbitMQ에서는 최소한 다음 지표를 함께 확인해야 한다.&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messages_ready&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;아직 Consumer에 전달되지 않은 메시지&lt;/td&gt;
&lt;td&gt;지속 증가하면 소비 처리량 부족&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messages_unacknowledged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Consumer에 전달됐지만 ACK되지 않은 메시지&lt;/td&gt;
&lt;td&gt;과도하면 처리 지연 또는 높은 prefetch 의심&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;consumer_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Queue에 연결된 Consumer 수&lt;/td&gt;
&lt;td&gt;설정한 concurrency와 일치하는지 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ack rate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;초당 ACK 수&lt;/td&gt;
&lt;td&gt;실제 처리 완료 속도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deliver rate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;초당 전달 수&lt;/td&gt;
&lt;td&gt;Broker가 Consumer에 전달하는 속도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redelivered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;재전달 메시지&lt;/td&gt;
&lt;td&gt;장애, NACK, Retry 여부 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer utilization&lt;/td&gt;
&lt;td&gt;Consumer가 메시지를 받을 수 있는 상태 비율&lt;/td&gt;
&lt;td&gt;낮으면 Consumer 처리 병목 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;RabbitMQ 공식 문서에서도 Consumer와 prefetch 설정이 병목인지 판단하려면 관련 메트릭을 함께 확인할 것을 권장한다.&lt;/p&gt;
&lt;p&gt;애플리케이션에서는 다음 지표를 추가로 확인한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;rabbitmq.listener.processing.duration
rabbitmq.listener.success.count
rabbitmq.listener.failure.count
rabbitmq.listener.duplicate.count
rabbitmq.listener.outdated.count&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB Connection Pool에서는 다음을 본다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;hikaricp.connections.active
hikaricp.connections.idle
hikaricp.connections.pending
hikaricp.connections.timeout&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특히 &lt;code&gt;connections.pending&lt;/code&gt;이 지속적으로 증가한다면 RabbitMQ Consumer concurrency가 DB 처리 능력을 넘어섰을 가능성이 크다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;17. 상황별 권장 설정&lt;/h2&gt;
&lt;h3&gt;순서가 절대적으로 중요한 경우&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;concurrency: 1
prefetch: 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;가장 단순하지만 처리량이 제한된다.&lt;/p&gt;
&lt;p&gt;처리량이 필요하다면 Entity Key 기반 Queue Partitioning을 적용한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;순서가 중요하지만 timestamp로 역전 방지가 가능한 경우&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;concurrency: 5
prefetch: 10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;애플리케이션에서 다음 로직을 반드시 적용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;eventTimestamp &amp;lt;= storedTimestamp
→ 오래된 메시지로 판단
→ 상태 변경 없이 ACK&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;timestamp가 동일할 수 있다면 sequence 또는 version을 함께 사용한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;빠른 단순 이벤트 처리&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;concurrency: 10
prefetch: 50&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처리량 테스트 후 prefetch를 단계적으로 높인다.&lt;/p&gt;
&lt;p&gt;메모리와 Unacked 메시지 수를 함께 관찰해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;결제·정산·상태 변경&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;concurrency: 3
prefetch: 5
acknowledge-mode: auto
default-requeue-rejected: false&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;설정보다 다음 구조가 더 중요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Unique Message Key
+ DB Unique Constraint
+ Transaction
+ 제한된 Retry
+ DLQ
+ 운영자 재처리 도구&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;외부 API 호출 중심&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;concurrency: 외부 API 허용 동시 호출 수 이하
prefetch: 1~10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;외부 API Rate Limit과 Connection Pool을 기준으로 concurrency를 제한한다.&lt;/p&gt;
&lt;p&gt;RabbitMQ 적체를 줄이기 위해 Consumer를 무작정 늘리면 외부 시스템 장애를 확대할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;18. 실무 튜닝 순서&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;concurrency=10&lt;/code&gt;, &lt;code&gt;prefetch=250&lt;/code&gt;처럼 경험적으로 정한 값을 바로 운영에 적용하기보다 다음 순서로 검증하는 것이 좋다.&lt;/p&gt;
&lt;h3&gt;1단계. 단일 Consumer 기준 성능 측정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;concurrency: 1
prefetch: 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;측정 항목:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;평균 처리 시간
p95 처리 시간
p99 처리 시간
DB Connection 점유 시간
실패율&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2단계. Concurrency 증가&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1 → 2 → 5 → 10 → 20&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처리량이 더 이상 선형적으로 증가하지 않는 구간을 찾는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;concurrency 1  → 20 msg/s
concurrency 2  → 39 msg/s
concurrency 5  → 91 msg/s
concurrency 10 → 142 msg/s
concurrency 20 → 147 msg/s&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위 결과라면 concurrency 20은 Thread 수만 늘리고 실질적 이득이 거의 없다.&lt;/p&gt;
&lt;h3&gt;3단계. Prefetch 증가&lt;/h3&gt;
&lt;p&gt;Concurrency를 고정하고 prefetch를 조정한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1 → 5 → 10 → 20 → 50&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처리량과 함께 다음 부작용을 확인한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Unacked 증가
Heap 증가
Consumer 간 처리량 편차
장애 시 재전달량
메시지 처리 지연&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4단계. 장애 테스트&lt;/h3&gt;
&lt;p&gt;다음 상황을 의도적으로 발생시킨다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DB 처리 직후 애플리케이션 강제 종료&lt;/li&gt;
&lt;li&gt;ACK 전 Connection 종료&lt;/li&gt;
&lt;li&gt;Consumer 처리 중 RabbitMQ 재시작&lt;/li&gt;
&lt;li&gt;메시지 포맷 오류&lt;/li&gt;
&lt;li&gt;DB Timeout&lt;/li&gt;
&lt;li&gt;외부 API Timeout&lt;/li&gt;
&lt;li&gt;동일 messageId 중복 발행&lt;/li&gt;
&lt;li&gt;과거 timestamp 메시지 발행&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;정상 처리량뿐 아니라 중복, 순서 역전, 재전달이 비즈니스 데이터에 미치는 영향을 검증해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;19. 핵심 정리&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;concurrency&lt;/code&gt;는 동시에 처리할 Consumer 수다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;concurrency 증가
→ 병렬 처리 증가
→ 처리량 증가 가능
→ 순서 보장 약화
→ DB 및 외부 시스템 부하 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;prefetch&lt;/code&gt;는 Consumer 하나가 ACK 전에 확보할 수 있는 메시지 수다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;prefetch 증가
→ 메시지 전달 대기 감소
→ 처리량 증가 가능
→ Unacked 증가
→ 메모리 증가
→ Consumer 간 불균형 가능
→ 장애 시 재전달 범위 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 설정의 관계는 다음과 같이 이해할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;최대 미확인 메시지 수
≈ 애플리케이션 인스턴스 수
  × concurrency
  × prefetch&lt;/code&gt;&lt;/pre&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;RabbitMQ의 처리량은 concurrency와 prefetch를 높인다고 무한히 증가하지 않는다. Consumer 뒤에 존재하는 DB, 외부 API, Connection Pool의 처리 능력이 실제 상한을 결정한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&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;하나의 Queue에 여러 Consumer가 연결되면 메시지의 전체 처리 완료 순서는 보장되지 않는다. 순서가 중요하다면 단일 Consumer, Key 기반 Partitioning, timestamp·sequence 검증 중 하나가 필요하다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&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;ACK 이전에 장애가 발생하면 메시지는 재전달될 수 있다. 반드시 처리되어야 하는 작업은 중복 전달을 정상적인 상황으로 간주하고 Unique Key와 DB Constraint 기반 멱등 처리를 적용해야 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;따라서 RabbitMQ Consumer 튜닝에서 먼저 해야 할 질문은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;“prefetch와 concurrency를 몇으로 설정할 것인가?”&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;가 아니라 다음 질문이어야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;“이 메시지는 순서가 중요한가?”
“동일 메시지가 다시 들어와도 안전한가?”
“장애 시 어디까지 재처리될 수 있는가?”
“DB와 외부 시스템은 몇 개의 동시 요청을 감당할 수 있는가?”&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 질문에 대한 답을 먼저 정한 뒤 &lt;code&gt;prefetch&lt;/code&gt;와 &lt;code&gt;concurrency&lt;/code&gt;를 설정해야 한다.&lt;/p&gt;
&lt;/div&gt;</description>
      <category>나의 주니어 개발 일기/RabbitMQ</category>
      <author>추억을 백앤드하자</author>
      <guid isPermaLink="true">https://pulpul8282.tistory.com/446</guid>
      <comments>https://pulpul8282.tistory.com/446#entry446comment</comments>
      <pubDate>Sun, 12 Jul 2026 00:15:23 +0900</pubDate>
    </item>
    <item>
      <title>실무에서 DB Connection Pool Size는 어떻게 정해야 할까?</title>
      <link>https://pulpul8282.tistory.com/444</link>
      <description>&lt;div class=&quot;markdown-body&quot;&gt;
&lt;h1&gt;실무에서 DB Connection Pool Size는 어떻게 정해야 할까?&lt;/h1&gt;
&lt;p&gt;백엔드 서버를 운영하다 보면 Thread Pool만큼 자주 고민하게 되는 값이 있다.&lt;/p&gt;
&lt;p&gt;바로 &lt;strong&gt;DB Connection Pool Size&lt;/strong&gt;다.&lt;/p&gt;
&lt;p&gt;Java/Spring 환경에서는 보통 HikariCP를 많이 사용하고, 설정은 대략 이런 형태다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=3000&lt;/code&gt;&lt;/pre&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;“DB 커넥션을 몇 개까지 열어둘 것인가?”&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;하지만 실무에서는 이 값을 감으로 정하면 위험하다.&lt;br&gt;DB 커넥션 풀 크기는 애플리케이션 처리량, DB 서버 리소스, 쿼리 성능, 트랜잭션 길이, API 응답 시간, 장애 전파 범위까지 직접적으로 영향을 준다.&lt;/p&gt;
&lt;p&gt;이 글에서는 Spring Boot에서 많이 사용하는 &lt;strong&gt;HikariCP&lt;/strong&gt;를 기준으로 설명한다.&lt;br&gt;다만 원리는 Java뿐 아니라 Node.js, Python, Go, .NET 등 다른 언어나 DB 라이브러리에서도 동일하게 적용된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. DB Connection Pool이 필요한 이유&lt;/h2&gt;
&lt;p&gt;애플리케이션이 DB에 접근할 때마다 매번 새로운 연결을 만들면 비용이 크다.&lt;/p&gt;
&lt;p&gt;DB 연결에는 TCP 연결, 인증, 세션 생성 등의 비용이 들어간다.&lt;br&gt;그래서 커넥션 풀은 미리 DB 연결을 만들어두고 재사용한다.&lt;/p&gt;
&lt;p&gt;흐름은 대략 이렇다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 애플리케이션이 DB 작업을 시작한다.
2. Connection Pool에서 사용 가능한 Connection을 빌린다.
3. SQL을 실행한다.
4. 트랜잭션을 종료한다.
5. Connection을 Pool에 반환한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, 커넥션 풀은 DB 연결을 무한히 만드는 구조가 아니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DB Connection Pool = 제한된 DB 연결 자원을 재사용하는 장치&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;중요한 점은 커넥션을 “사용한다”는 말이 단순히 쿼리 실행 시간만 의미하지 않는다는 것이다.&lt;/p&gt;
&lt;p&gt;트랜잭션이 열려 있는 동안 커넥션은 계속 점유된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void processOrder(OrderRequest request) {
    Order order = orderRepository.save(request.toEntity());

    paymentClient.pay(request); // 외부 API 호출

    order.complete();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위 코드에서 주의할 점은 &lt;code&gt;@Transactional&lt;/code&gt; 안에서 외부 API를 호출한다는 것이다.&lt;br&gt;이 경우 DB 작업이 끝났더라도 트랜잭션이 끝나지 않았기 때문에 커넥션이 반환되지 않을 수 있다.&lt;/p&gt;
&lt;p&gt;즉, 외부 API 응답을 기다리는 동안 DB 커넥션을 붙잡고 있을 수 있다.&lt;/p&gt;
&lt;p&gt;이런 코드가 많으면 커넥션 풀을 아무리 크게 잡아도 금방 고갈된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. 라이브러리마다 기본 Connection Pool Size가 다른 이유&lt;/h2&gt;
&lt;p&gt;커넥션 풀을 보다 보면 라이브러리마다 기본값이 다르다.&lt;/p&gt;
&lt;p&gt;예를 들어 어떤 라이브러리는 기본 최대 커넥션 수가 10개이고, 어떤 라이브러리는 5개 또는 100개처럼 보일 수 있다.&lt;br&gt;또 어떤 환경은 별도 설정을 하지 않으면 커넥션 풀을 적극적으로 사용하지 않거나, 런타임/드라이버/프레임워크 조합에 따라 동작이 다르다.&lt;/p&gt;
&lt;p&gt;이 기본값이 다른 이유는 단순히 “어떤 값이 정답이기 때문”이 아니다.&lt;/p&gt;
&lt;p&gt;라이브러리마다 다음 전제가 다르기 때문이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 주 사용 환경이 웹 서버인지, 배치인지, CLI인지
- 동시 요청 모델이 스레드 기반인지, 이벤트 루프 기반인지
- DB 커넥션 생성 비용을 얼마나 크게 보는지
- 안전한 기본값을 우선하는지, 처리량을 우선하는지
- DB 서버에 과도한 연결을 만들지 않도록 보수적으로 잡는지
- 사용자가 명시적으로 튜닝하길 기대하는지&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 Spring MVC + HikariCP는 스레드 기반 서버에서 많이 사용된다.&lt;br&gt;요청 스레드가 DB 커넥션을 빌려 SQL을 실행하고 반환하는 구조가 일반적이다.&lt;/p&gt;
&lt;p&gt;반면 Node.js는 이벤트 루프 기반이고, DB 드라이버나 ORM마다 풀 동작 방식이 다르다.&lt;br&gt;Go의 &lt;code&gt;database/sql&lt;/code&gt;은 &lt;code&gt;SetMaxOpenConns&lt;/code&gt;, &lt;code&gt;SetMaxIdleConns&lt;/code&gt;로 동시 열린 커넥션 수와 idle 커넥션 수를 직접 제한한다.&lt;/p&gt;
&lt;p&gt;즉, 기본값은 라이브러리 설계자가 생각한 “안전한 출발점”일 뿐이다.&lt;/p&gt;
&lt;p&gt;실무에서는 기본값을 그대로 믿기보다 다음 기준으로 다시 산정해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 서비스 TPS
- 요청당 DB 커넥션 점유 시간
- DB 서버 max_connections
- 애플리케이션 인스턴스 수
- DB CPU/메모리/Lock 상태
- Thread Pool 크기
- Slow Query 여부&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결국 중요한 것은 특정 라이브러리의 default 값이 아니라, &lt;strong&gt;우리 서비스가 DB에 동시에 얼마만큼의 부하를 허용할 것인가&lt;/strong&gt;다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. DB Connection Pool Size를 크게 잡으면 좋은가?&lt;/h2&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;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;일부는 맞지만 항상 맞지는 않다.&lt;/p&gt;
&lt;p&gt;커넥션 풀을 너무 작게 잡으면 애플리케이션 스레드들이 커넥션을 기다리게 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Request Thread
   ↓
Connection Pool에서 대기
   ↓
connection-timeout 초과
   ↓
SQLTransientConnectionException&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;반대로 커넥션 풀을 너무 크게 잡으면 DB 서버가 감당해야 할 동시 쿼리 수가 늘어난다.&lt;/p&gt;
&lt;p&gt;그 결과 다음 문제가 발생할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- DB CPU 사용률 증가
- Lock 경합 증가
- Disk I/O 증가
- Buffer cache 효율 저하
- Context switching 증가
- 쿼리 응답 시간 증가
- 전체 처리량 저하&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, DB 커넥션 풀은 크면 클수록 좋은 값이 아니다.&lt;/p&gt;
&lt;p&gt;커넥션 풀 크기는 &lt;strong&gt;애플리케이션이 DB에 줄 수 있는 최대 동시 부하량&lt;/strong&gt;이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;maximumPoolSize = 이 애플리케이션 인스턴스가 DB에 동시에 걸 수 있는 최대 압력&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 커넥션 풀 크기를 정한다는 것은 단순히 애플리케이션 설정을 정하는 것이 아니라, DB 서버에 허용할 동시 부하량을 정하는 일이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. HikariCP의 핵심 설정&lt;/h2&gt;
&lt;p&gt;Spring Boot에서 자주 사용하는 Hikari 설정은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=3000
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;maximumPoolSize&lt;/h3&gt;
&lt;p&gt;풀에서 관리할 수 있는 최대 커넥션 수다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.maximum-pool-size=30&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;애플리케이션 인스턴스 하나가 DB에 동시에 최대 30개의 커넥션을 사용할 수 있다는 의미다.&lt;/p&gt;
&lt;p&gt;서버 인스턴스가 여러 대라면 전체 DB 커넥션 수는 곱해진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;애플리케이션 인스턴스 4대
각 인스턴스 maximumPoolSize 30

전체 최대 DB 커넥션 수 = 4 × 30 = 120&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 부분을 놓치면 운영에서 문제가 생긴다.&lt;/p&gt;
&lt;p&gt;로컬에서는 문제가 없었는데 운영에서 인스턴스를 늘린 뒤 DB 커넥션이 고갈되는 경우가 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;minimumIdle&lt;/h3&gt;
&lt;p&gt;풀에서 유지하려는 최소 유휴 커넥션 수다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.minimum-idle=10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;트래픽이 갑자기 들어왔을 때 매번 커넥션을 새로 만들지 않도록 일정 수의 유휴 커넥션을 유지한다.&lt;/p&gt;
&lt;p&gt;다만 HikariCP에서는 특별한 이유가 없다면 &lt;code&gt;minimumIdle&lt;/code&gt;을 생략하고 &lt;code&gt;maximumPoolSize&lt;/code&gt;와 동일하게 고정 풀처럼 운영하는 방식도 많이 사용한다.&lt;/p&gt;
&lt;p&gt;중요한 것은 &lt;code&gt;minimumIdle&lt;/code&gt;을 너무 크게 잡으면 유휴 상태에서도 DB 커넥션을 많이 점유한다는 것이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;connectionTimeout&lt;/h3&gt;
&lt;p&gt;커넥션 풀에서 커넥션을 빌릴 때 최대 대기 시간이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.connection-timeout=3000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;3초 안에 커넥션을 얻지 못하면 예외가 발생한다.&lt;/p&gt;
&lt;p&gt;이 값은 장애 대응에서 매우 중요하다.&lt;/p&gt;
&lt;p&gt;너무 길면 요청 스레드가 오래 묶이고, 장애가 늦게 드러난다.&lt;br&gt;너무 짧으면 순간적인 트래픽 피크에도 실패가 많이 발생할 수 있다.&lt;/p&gt;
&lt;p&gt;실무에서는 보통 2~5초 정도에서 시작하는 경우가 많다.&lt;br&gt;실시간성이 중요한 서비스라면 더 짧게 잡을 수도 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;maxLifetime&lt;/h3&gt;
&lt;p&gt;커넥션의 최대 생존 시간이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.max-lifetime=1800000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB나 네트워크 장비가 오래된 idle connection을 먼저 끊어버리는 경우가 있다.&lt;br&gt;그래서 애플리케이션 쪽에서 일정 시간 이후 커넥션을 교체하도록 설정한다.&lt;/p&gt;
&lt;p&gt;중요한 점은 DB 서버의 connection timeout보다 Hikari의 &lt;code&gt;maxLifetime&lt;/code&gt;을 짧게 잡는 것이 안전하다는 것이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5. DB Connection Pool Size 공식&lt;/h2&gt;
&lt;p&gt;Thread Pool과 마찬가지로 DB Connection Pool도 참고할 수 있는 공식이 있다.&lt;/p&gt;
&lt;p&gt;실무적으로는 다음 기준이 유용하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;필요 커넥션 수 ≈ 초당 요청 수 × 요청당 DB 커넥션 점유 시간&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;조금 더 표현하면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Connection Pool Size ≈ TPS × DB Connection Hold Time&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 중요한 것은 단순 쿼리 시간이 아니라 &lt;strong&gt;커넥션 점유 시간&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;예를 들어 다음과 같은 API가 있다고 하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;초당 요청 수: 100 TPS
요청당 DB 커넥션 점유 시간: 50ms&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;계산은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;100 × 0.05초 = 5&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이론적으로 이 API만 보면 동시에 필요한 커넥션은 약 5개다.&lt;/p&gt;
&lt;p&gt;여기에 피크 트래픽, 쿼리 지연, 트랜잭션 변동성을 고려해 여유를 둔다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;기본 필요량 5개
여유 계수 2~3배

권장 시작값: 10~15개&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;반대로 요청당 커넥션 점유 시간이 길어지면 필요한 커넥션 수는 급격히 늘어난다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;초당 요청 수: 100 TPS
요청당 DB 커넥션 점유 시간: 300ms

100 × 0.3초 = 30&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우 같은 100 TPS라도 필요한 커넥션 수는 30개가 된다.&lt;/p&gt;
&lt;p&gt;즉, 커넥션 풀 크기를 늘리기 전에 봐야 할 것은 다음이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;요청량이 많은가?
아니면 커넥션을 오래 잡고 있는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;대부분의 경우 문제는 커넥션 수가 부족한 것이 아니라, 커넥션을 너무 오래 잡고 있는 코드에 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. 커넥션 점유 시간은 어떻게 측정할까?&lt;/h2&gt;
&lt;p&gt;처음부터 정확히 알기는 어렵다.&lt;br&gt;그래서 실무에서는 모니터링과 부하 테스트로 측정한다.&lt;/p&gt;
&lt;p&gt;Spring/Hikari 환경에서는 다음 지표를 본다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;hikaricp.connections.active
hikaricp.connections.idle
hikaricp.connections.pending
hikaricp.connections.timeout
hikaricp.connections.usage
hikaricp.connections.acquire
hikaricp.connections.creation&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특히 중요한 지표는 다음이다.&lt;/p&gt;
&lt;h3&gt;active connections&lt;/h3&gt;
&lt;p&gt;현재 사용 중인 커넥션 수다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;active가 maximumPoolSize에 자주 붙어 있다
→ 커넥션 풀이 부족하거나 DB 작업이 오래 걸린다&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;pending threads&lt;/h3&gt;
&lt;p&gt;커넥션을 얻기 위해 기다리는 스레드 수다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pending이 증가한다
→ 애플리케이션 스레드가 DB 커넥션을 기다리고 있다&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;connection acquire time&lt;/h3&gt;
&lt;p&gt;커넥션을 빌리는 데 걸린 시간이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;acquire time 증가
→ 커넥션 풀 대기가 발생한다&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;connection usage time&lt;/h3&gt;
&lt;p&gt;커넥션을 빌린 뒤 반환하기까지의 시간이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;usage time 증가
→ 커넥션을 오래 잡고 있다&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 값이 커넥션 풀 튜닝에서 특히 중요하다.&lt;/p&gt;
&lt;p&gt;단순 SQL 실행 시간이 20ms인데 connection usage time이 500ms라면, 애플리케이션 코드에서 트랜잭션을 길게 잡고 있을 가능성이 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7. 부하 테스트로 산정하는 절차&lt;/h2&gt;
&lt;p&gt;실무에서는 다음 순서로 접근하는 것이 좋다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 현재 API의 DB 사용 구간을 파악한다.
2. 쿼리 시간과 트랜잭션 시간을 측정한다.
3. Hikari active/pending/usage/acquire 지표를 수집한다.
4. 목표 TPS 기준으로 필요한 커넥션 수를 계산한다.
5. 부하 테스트를 수행한다.
6. p95/p99 응답 시간, DB CPU, active connection, pending thread를 확인한다.
7. 커넥션 풀 크기와 쿼리/트랜잭션 구조를 함께 조정한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 부하 테스트 결과가 다음과 같다고 하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;목표 TPS: 300
평균 connection usage time: 40ms
p95 connection usage time: 80ms&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;평균 기준으로 계산하면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;300 × 0.04 = 12&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;p95 기준으로 계산하면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;300 × 0.08 = 24&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우 &lt;code&gt;maximumPoolSize=20~30&lt;/code&gt; 정도를 출발점으로 볼 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 DB CPU가 이미 80~90%라면 커넥션을 더 늘리는 것은 위험하다.&lt;br&gt;커넥션을 늘리면 동시 쿼리 수가 증가해 DB가 더 느려질 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. 커넥션 풀을 늘려야 하는 경우&lt;/h2&gt;
&lt;p&gt;다음 조건이 함께 보이면 커넥션 풀 증가를 검토할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- DB CPU는 여유가 있다.
- 쿼리 자체는 빠르다.
- connection acquire time이 증가한다.
- pending thread가 증가한다.
- active connection이 maximumPoolSize에 자주 도달한다.
- 애플리케이션 응답 지연이 커넥션 대기에서 발생한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DB CPU: 40%
DB slow query 없음
Hikari active: 항상 max 근처
Hikari pending: 증가
connection acquire p95: 500ms
connection usage p95: 30ms&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우는 커넥션을 빨리 쓰고 반환하고 있는데, 풀 크기가 작아 대기가 생기는 상황일 수 있다.&lt;/p&gt;
&lt;p&gt;이때는 &lt;code&gt;maximumPoolSize&lt;/code&gt;를 늘리는 것이 도움이 될 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;9. 커넥션 풀을 늘리면 안 되는 경우&lt;/h2&gt;
&lt;p&gt;반대로 다음 상황에서는 커넥션 풀을 늘리면 문제가 악화될 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- DB CPU가 이미 높다.
- Slow query가 많다.
- Lock wait가 많다.
- connection usage time이 길다.
- 트랜잭션이 길다.
- 외부 API 호출이 트랜잭션 안에 있다.
- active connection이 높고 DB 응답 시간이 같이 느려진다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DB CPU: 90%
Hikari active: max 근처
Hikari pending: 증가
connection usage p95: 2초
slow query 증가
lock wait 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 상황에서 커넥션 풀을 늘리면 DB에 더 많은 동시 쿼리가 몰린다.&lt;/p&gt;
&lt;p&gt;결과적으로:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DB 부하 증가
쿼리 응답 시간 증가
커넥션 점유 시간 증가
active connection 증가
pending thread 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;악순환이 생긴다.&lt;/p&gt;
&lt;p&gt;이때는 커넥션 풀을 늘리는 것이 아니라 다음을 봐야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 쿼리 튜닝
- 인덱스 점검
- 트랜잭션 범위 축소
- 외부 API 호출을 트랜잭션 밖으로 분리
- 배치 처리
- 캐싱
- 읽기/쓰기 분리
- DB scale-up 또는 scale-out&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;10. Thread Pool과 Connection Pool은 같이 봐야 한다&lt;/h2&gt;
&lt;p&gt;Thread Pool과 DB Connection Pool은 따로 튜닝하면 안 된다.&lt;/p&gt;
&lt;p&gt;예를 들어 다음 설정을 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.maximum-pool-size=20&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그런데 Async Executor는 이렇게 되어 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;executor.setCorePoolSize(30);
executor.setMaxPoolSize(100);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 Executor 안에서 대부분 DB 작업을 한다면 어떻게 될까?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;동시 worker thread: 최대 100개
DB connection: 최대 20개

20개는 DB 작업 수행
80개는 커넥션 대기&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조는 좋은 병렬 처리가 아니다.&lt;br&gt;대기 스레드가 늘어났을 뿐이다.&lt;/p&gt;
&lt;p&gt;따라서 DB 작업을 수행하는 Thread Pool의 크기는 DB Connection Pool과 함께 맞춰야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DB 중심 Executor maxPoolSize &amp;lt;= DB connection pool size&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;더 정확히는 특정 Executor가 전체 DB 커넥션을 독점하지 않게 해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;전체 Hikari maximumPoolSize = 30

일반 API 요청용 여유: 15~20
Async DB 작업용 max: 5~10
Batch 작업용 max: 3~5&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 나누는 것이 더 안전하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;11. 애플리케이션 인스턴스 수까지 고려해야 한다&lt;/h2&gt;
&lt;p&gt;커넥션 풀 설정에서 가장 많이 놓치는 것이 인스턴스 수다.&lt;/p&gt;
&lt;p&gt;예를 들어 운영 환경이 다음과 같다고 하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;WAS 인스턴스 6대
각 인스턴스 Hikari maximumPoolSize = 30&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그러면 DB 입장에서 최대 커넥션 수는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;6 × 30 = 180&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB의 &lt;code&gt;max_connections&lt;/code&gt;가 200이라고 해보자.&lt;br&gt;여기에 DBA 접속, 배치 서버, 모니터링, 다른 서비스 커넥션까지 들어오면 금방 한계에 도달할 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 커넥션 풀은 인스턴스 하나만 보지 말고 전체 시스템 기준으로 계산해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;전체 DB 허용 커넥션 수
- 운영/관리용 여유
- 다른 서비스 사용량
- 배치 사용량
- 모니터링 사용량
= 현재 서비스가 사용할 수 있는 커넥션 예산&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 이 예산을 인스턴스 수로 나눈다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;서비스에 허용된 전체 커넥션 수: 120
WAS 인스턴스 수: 6

인스턴스당 maximumPoolSize = 120 / 6 = 20&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, 인스턴스를 늘릴 때는 Hikari maximumPoolSize를 그대로 두면 전체 DB 커넥션 압력이 증가한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;12. 읽기/쓰기 분리 환경에서는 Pool도 분리해서 봐야 한다&lt;/h2&gt;
&lt;p&gt;Read Replica를 사용하거나 Master/Replica 구조를 사용하는 경우에는 커넥션 풀도 분리해서 봐야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;writeDataSource
- INSERT
- UPDATE
- DELETE
- 트랜잭션 처리

readDataSource
- SELECT
- 조회 API
- 통계 조회&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 풀의 크기는 다르게 잡을 수 있다.&lt;/p&gt;
&lt;p&gt;예를 들어 쓰기 DB는 부하가 민감하고 lock 영향이 크기 때문에 보수적으로 잡고, 읽기 DB는 replica 수와 조회량에 따라 별도로 산정한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;write.hikari.maximum-pool-size=20
read.hikari.maximum-pool-size=50&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 읽기 풀도 무작정 크게 잡으면 replica DB가 느려질 수 있다.&lt;/p&gt;
&lt;p&gt;읽기/쓰기 분리에서도 원칙은 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Connection Pool Size = DB에 허용할 동시 부하량&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;13. Long Transaction이 커넥션 풀을 고갈시킨다&lt;/h2&gt;
&lt;p&gt;커넥션 풀 장애의 상당수는 단순히 커넥션 수가 부족해서가 아니라 커넥션을 오래 잡고 있어서 발생한다.&lt;/p&gt;
&lt;p&gt;대표적인 패턴은 다음과 같다.&lt;/p&gt;
&lt;h3&gt;트랜잭션 안에서 외부 API 호출&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void approve(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();

    externalPaymentClient.approve(order); // 외부 API 호출

    order.approve();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;외부 API가 2초 걸리면 그동안 DB 커넥션도 같이 점유될 수 있다.&lt;/p&gt;
&lt;p&gt;개선 방향은 외부 API 호출을 트랜잭션 밖으로 분리하는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public void approve(Long id) {
    Order order = orderReader.get(id);

    externalPaymentClient.approve(order);

    orderApprovalService.approveInTransaction(id);
}

@Transactional
public void approveInTransaction(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    order.approve();
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;대량 처리 트랜잭션&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void bulkProcess(List&amp;lt;Item&amp;gt; items) {
    for (Item item : items) {
        repository.save(item);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;대량 작업을 하나의 트랜잭션으로 묶으면 커넥션 점유 시간이 길어진다.&lt;/p&gt;
&lt;p&gt;개선 방향은 chunk 단위로 나누는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10만 건 한 번에 처리
→ 1000건씩 chunk 처리&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;Open Session In View&lt;/h3&gt;
&lt;p&gt;Spring Boot에서 OSIV가 켜져 있으면 View 렌더링 시점까지 영속성 컨텍스트가 유지될 수 있다.&lt;br&gt;API 서버에서는 보통 OSIV를 끄는 방향을 선호한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.jpa.open-in-view=false&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OSIV를 끄면 Lazy Loading 문제가 드러날 수 있지만, 트랜잭션 경계를 명확히 하는 데 도움이 된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;14. 실무적인 시작값&lt;/h2&gt;
&lt;p&gt;정답은 없지만, 실무에서 출발점으로 사용할 수 있는 기준은 있다.&lt;/p&gt;
&lt;h3&gt;작은 서비스 또는 내부 시스템&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=3000&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;일반적인 Spring Boot API 서버&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=3000&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;트래픽이 있는 API 서버&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=3000&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;읽기 트래픽이 많고 DB가 충분히 받쳐주는 경우&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.datasource.hikari.maximum-pool-size=40~50
spring.datasource.hikari.minimum-idle=10~20
spring.datasource.hikari.connection-timeout=3000&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;단, 이 값들은 어디까지나 출발점이다.&lt;/p&gt;
&lt;p&gt;다음 조건을 반드시 함께 봐야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- WAS 인스턴스 수
- DB max_connections
- DB CPU
- slow query
- lock wait
- connection usage time
- connection acquire time
- pending thread
- p95/p99 API latency&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;15. 다른 언어와 라이브러리에서도 원리는 같다&lt;/h2&gt;
&lt;p&gt;HikariCP는 Java/Spring에서 많이 쓰는 구현체일 뿐이다.&lt;/p&gt;
&lt;p&gt;다른 환경에서도 본질은 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Node.js
- mysql2 pool
- pg pool
- TypeORM pool

Python
- SQLAlchemy QueuePool
- Django DB connection management

Go
- database/sql SetMaxOpenConns
- SetMaxIdleConns
- SetConnMaxLifetime

.NET
- ADO.NET connection pooling&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;라이브러리마다 기본 커넥션 풀 개수와 기본 동작은 다를 수 있다.&lt;br&gt;하지만 봐야 할 지표는 거의 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;- 최대 열린 커넥션 수
- 유휴 커넥션 수
- 커넥션 획득 대기 시간
- 커넥션 점유 시간
- 커넥션 생존 시간
- DB 서버의 최대 연결 수
- 애플리케이션 인스턴스 수&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 Go에서는 다음과 같이 설정한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;db.SetMaxOpenConns(30)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(time.Minute * 30)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Java Hikari의 &lt;code&gt;maximumPoolSize&lt;/code&gt;와 Go의 &lt;code&gt;SetMaxOpenConns&lt;/code&gt;는 비슷한 역할을 한다.&lt;/p&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;커넥션 풀 크기는 애플리케이션이 DB에 동시에 줄 수 있는 부하량을 제한하는 값이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;16. 체크리스트&lt;/h2&gt;
&lt;p&gt;DB Connection Pool Size를 정할 때 다음 질문을 확인한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. DB 서버의 max_connections는 얼마인가?
2. 현재 서비스가 사용할 수 있는 전체 커넥션 예산은 얼마인가?
3. WAS 인스턴스는 몇 대인가?
4. 인스턴스당 maximumPoolSize는 얼마인가?
5. API 요청당 DB 커넥션 점유 시간은 얼마인가?
6. 트랜잭션 안에서 외부 API를 호출하고 있지는 않은가?
7. Slow query나 lock wait가 있는가?
8. Hikari active가 max에 자주 붙는가?
9. pending thread가 증가하는가?
10. connection acquire time이 증가하는가?
11. connection usage time이 긴가?
12. DB CPU는 여유가 있는가?
13. Thread Pool 크기가 DB Pool보다 과도하게 크지는 않은가?
14. 인스턴스를 늘릴 때 전체 커넥션 수도 같이 증가하는가?
15. 라이브러리의 기본값을 그대로 사용하고 있지는 않은가?&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 질문에 답하지 않고 커넥션 풀 크기만 늘리는 것은 위험하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;17. 결론&lt;/h2&gt;
&lt;p&gt;DB Connection Pool Size는 크게 잡는다고 무조건 성능이 좋아지는 값이 아니다.&lt;/p&gt;
&lt;p&gt;작게 잡으면 애플리케이션 스레드가 커넥션을 기다리고,&lt;br&gt;크게 잡으면 DB 서버에 과도한 동시 부하가 걸릴 수 있다.&lt;/p&gt;
&lt;p&gt;또한 라이브러리마다 기본 커넥션 풀 개수가 다를 수 있지만, 그것은 각 라이브러리의 설계 철학과 사용 환경이 다르기 때문이다.&lt;br&gt;기본값은 안전한 출발점일 뿐, 우리 서비스에 맞는 정답은 아니다.&lt;/p&gt;
&lt;p&gt;핵심 원칙은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Connection Pool Size는 DB에 허용할 동시 부하량이다.

필요 커넥션 수는
TPS × Connection 점유 시간
으로 근사할 수 있다.

하지만 최종값은
DB max_connections,
WAS 인스턴스 수,
DB CPU,
Slow Query,
Lock Wait,
Hikari active/pending/usage/acquire 지표,
p95/p99 응답 시간
을 보고 정해야 한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Thread Pool과 마찬가지로 DB Connection Pool도 정답 공식이 있는 것이 아니다.&lt;/p&gt;
&lt;p&gt;공식은 초기값을 잡기 위한 기준이고,&lt;br&gt;최종 설정은 부하 테스트와 운영 모니터링을 통해 결정해야 한다.&lt;/p&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;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;커넥션 풀 크기 튜닝의 본질은 숫자를 크게 만드는 것이 아니라,&lt;br&gt;DB 커넥션이라는 제한된 자원을 짧게 사용하고 빠르게 반환하도록 시스템을 설계하는 것이다.&lt;/p&gt;
&lt;/div&gt;  </description>
      <category>나의 주니어 개발 일기/트러블슈팅</category>
      <author>추억을 백앤드하자</author>
      <guid isPermaLink="true">https://pulpul8282.tistory.com/444</guid>
      <comments>https://pulpul8282.tistory.com/444#entry444comment</comments>
      <pubDate>Mon, 6 Jul 2026 23:34:51 +0900</pubDate>
    </item>
    <item>
      <title>Thread Pool Size를 CPU 코어 수 &amp;times; 2로 잡으면 될까?</title>
      <link>https://pulpul8282.tistory.com/443</link>
      <description>&lt;div class=&quot;markdown-body&quot;&gt;
&lt;h1&gt;Thread Pool Size를 CPU 코어 수 &amp;times; 2로 잡으면 될까?&lt;/h1&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;&amp;ldquo;스레드 풀은 CPU 코어 수의 2배 정도로 잡으면 된다.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;틀린 말은 아니다.&lt;br /&gt;하지만 실무에서는 이 말만 믿고 &lt;code&gt;corePoolSize&lt;/code&gt;, &lt;code&gt;maxPoolSize&lt;/code&gt;를 정하면 위험하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스레드 풀의 크기는 단순히 CPU 코어 수만 보고 정하는 값이 아니다.&lt;br /&gt;작업의 성격, I/O 대기 시간, DB 커넥션 풀, 외부 API 처리량, 큐 적체량, 응답 시간, 장애 시 영향 범위까지 같이 봐야 한다.&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;1. 먼저 CPU 바운드와 I/O 바운드를 구분해야 한다&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;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;CPU 바운드 작업
- 계산
- 암호화
- 압축
- 대량 정렬
- 복잡한 파싱
- 좌표 계산
- 거리 계산
- 룰 평가

I/O 바운드 작업
- DB 조회/저장
- 외부 API 호출
- Redis 호출
- 파일 읽기/쓰기
- 메시지 브로커 송수신
- HTTP 통신&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CPU 바운드 작업은 스레드가 CPU를 계속 사용한다.&lt;br /&gt;반면 I/O 바운드 작업은 대부분의 시간을 외부 응답을 기다리며 보낸다.&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;2. CPU 바운드 작업은 코어 수 근처가 적절하다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CPU 연산이 많은 작업은 스레드를 많이 늘린다고 처리량이 계속 증가하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 8코어 16스레드 서버에서 무거운 계산 작업을 100개 스레드로 돌린다고 해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 동시에 CPU에서 실행될 수 있는 작업은 제한적이다.&lt;br /&gt;나머지 스레드는 스케줄링 대기 상태가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스레드가 너무 많아지면 다음 비용이 증가한다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;- 컨텍스트 스위칭 비용 증가
- CPU 캐시 효율 저하
- 스케줄링 비용 증가
- 메모리 사용량 증가
- 전체 처리량 저하&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 CPU 바운드 작업의 스레드 풀은 보통 다음 범위에서 시작하는 것이 적절하다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;corePoolSize = 물리 코어 수
maxPoolSize  = 논리 코어 수&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 서버가 8코어 16스레드라면 다음과 같이 시작할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(1000); //기준시작점: 초당 처리 가능 작업 수 &amp;times; 허용 가능한 최대 대기 시간 &amp;times; MaxPoolSize 
executor.setThreadNamePrefix(&quot;cpu-worker-&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은 &lt;code&gt;maxPoolSize&lt;/code&gt;를 50, 100으로 크게 잡는다고 해서 CPU 계산이 그만큼 빨라지는 것이 아니라는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CPU 바운드 작업에서는 스레드 수를 늘리는 것보다 알고리즘 개선, 캐싱, 데이터 구조 개선, 불필요한 반복 제거가 더 효과적일 때가 많다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. I/O 바운드 작업은 코어 수보다 크게 잡을 수 있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 I/O 바운드 작업은 CPU를 계속 사용하는 것이 아니라 대부분 외부 응답을 기다린다.&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;CPU 계산 시간: 10ms
DB/API 대기 시간: 90ms
전체 작업 시간: 100ms&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 작업은 전체 시간 중 90%를 기다리는 데 사용한다.&lt;br /&gt;이 경우 스레드를 CPU 코어 수보다 더 많이 두어도 의미가 있다.&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;적정 스레드 수 = CPU 코어 수 &amp;times; (1 + 대기 시간 / 계산 시간)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 8코어 서버에서 계산 10ms, 대기 90ms인 작업이라면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;8 &amp;times; (1 + 90 / 10)
= 8 &amp;times; 10
= 80&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론적으로는 80개 정도의 스레드도 의미가 있을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 계산식은 어디까지나 출발점이다.&lt;br /&gt;실무에서는 이론값보다 더 중요한 제한 조건이 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 진짜 상한은 downstream 자원이 결정한다&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;&amp;ldquo;I/O 작업이니까 스레드를 크게 잡으면 처리량이 늘겠지.&amp;rdquo;&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;예를 들어 Async Executor를 100개로 잡았다고 해보자.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;executor.setCorePoolSize(30);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(1000);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 DB 커넥션 풀이 20개라면 어떻게 될까?&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;동시에 DB를 사용할 수 있는 작업: 20개
나머지 작업: DB 커넥션 대기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 스레드 100개 중 상당수는 DB 커넥션을 기다리는 상태가 된다.&lt;br /&gt;이 경우 처리량이 늘어나는 것이 아니라 대기 스레드와 메모리 사용량만 증가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 I/O 바운드 작업의 스레드 풀 상한은 다음 자원과 함께 봐야 한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;- DB 커넥션 풀 크기
- HTTP connection pool 크기
- Redis connection pool
- RabbitMQ consumer/channel 수
- 외부 API rate limit
- 파일 시스템 처리량
- 메시지 브로커 처리량
- 서버 메모리&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 DB 중심 작업이라면 특정 Executor가 DB 커넥션 풀 전체를 독점하지 않도록 해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 전체 DB 커넥션 풀이 30개라면 특정 Async Executor의 maxPoolSize를 30 이상으로 잡는 것은 신중해야 한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;DB connection pool: 30
asyncExecutor maxPoolSize: 10~20&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 특정 작업군이 전체 DB 커넥션을 고갈시키지 않도록 여유를 두는 것이 안전하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Spring @Async는 논블로킹이 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에서 &lt;code&gt;@Async&lt;/code&gt;를 사용하면 호출한 스레드가 직접 작업을 수행하지 않고, 별도의 Executor 스레드 풀에 작업을 위임한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Async(&quot;taskExecutor&quot;)
public CompletableFuture&amp;lt;Void&amp;gt; processAsync() {
    repository.save(...);
    return CompletableFuture.completedFuture(null);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출한 요청 스레드는 바로 반환된다.&lt;br /&gt;하지만 &lt;code&gt;repository.save()&lt;/code&gt;는 일반적으로 JDBC 기반 블로킹 I/O다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 요청 스레드는 막히지 않지만 Async worker thread는 DB 응답을 기다리며 막힌다.&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;Request Thread      : 안 기다림
Async Worker Thread : DB 응답 대기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 이것은 비동기 실행이지만 논블로킹 I/O는 아니다.&lt;/p&gt;
&lt;pre class=&quot;julia&quot;&gt;&lt;code&gt;@Async = 작업을 다른 스레드로 넘기는 비동기 실행
Non-blocking I/O = I/O 대기 중에도 스레드를 점유하지 않는 방식&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이를 이해하지 못하면 &lt;code&gt;@Async&lt;/code&gt;를 붙였으니 성능이 좋아질 것이라고 착각하기 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@Async&lt;/code&gt;는 요청 흐름을 분리하는 데 유용하지만, 내부 작업이 블로킹이면 결국 worker thread는 대기한다.&lt;br /&gt;따라서 &lt;code&gt;@Async&lt;/code&gt;를 사용할수록 Executor 크기, 큐 용량, reject 정책, DB 커넥션 풀을 함께 설계해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. ThreadPoolTaskExecutor의 동작 순서를 이해해야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에서 자주 사용하는 &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt;는 대략 다음 순서로 동작한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. corePoolSize까지 스레드를 생성해서 작업 처리
2. corePoolSize가 모두 사용 중이면 queueCapacity에 작업 적재
3. queue가 가득 차면 maxPoolSize까지 스레드 증가
4. maxPoolSize까지 찼고 queue도 가득 차면 reject 정책 동작&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음 설정이 있다고 하자.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(1000);&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;동시 작업 1~10개       &amp;rarr; core thread로 처리
그 이후 작업           &amp;rarr; queue에 최대 1000개 적재
queue가 가득 참        &amp;rarr; maxPoolSize 30까지 증가
30개도 모두 사용 중     &amp;rarr; reject 정책 동작&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;queueCapacity&lt;/code&gt;가 너무 크면 &lt;code&gt;maxPoolSize&lt;/code&gt;까지 스레드가 잘 늘어나지 않는다.&lt;br /&gt;core thread가 모두 사용 중이어도 queue에 공간이 있으면 작업은 먼저 queue에 쌓인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 다음과 같은 설정은 기대와 다르게 동작할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;executor.setCorePoolSize(10);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(100000);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 작업이 몰려도 스레드가 100개까지 늘어나기 전에 큐에 계속 쌓일 수 있다.&lt;br /&gt;그 결과 응답 시간이 길어지고, 장애 상황에서 대량의 작업이 메모리에 적재될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;code&gt;maxPoolSize&lt;/code&gt;만 볼 것이 아니라 &lt;code&gt;queueCapacity&lt;/code&gt;까지 같이 봐야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 큐를 무작정 크게 잡으면 장애가 늦게 드러난다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큐가 크면 순간적인 트래픽 피크를 흡수할 수 있다.&lt;br /&gt;하지만 너무 큰 큐는 문제를 숨긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 처리량은 초당 100건인데 요청이 초당 1000건 들어온다고 해보자.&lt;br /&gt;큐가 크면 당장은 에러가 나지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 내부에서는 작업이 계속 밀린다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;처리량 &amp;lt; 유입량
&amp;rarr; 큐 적체
&amp;rarr; 응답 지연 증가
&amp;rarr; 메모리 사용량 증가
&amp;rarr; 장애 전파&lt;/code&gt;&lt;/pre&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;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;- 짧은 피크를 흡수할 만큼은 허용
- 지속적인 과부하는 빠르게 감지
- reject 정책으로 시스템 보호
- 모니터링으로 큐 적체를 알림&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 다음 지표를 반드시 봐야 한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;- active thread count
- pool size
- queue size
- completed task count
- rejected task count
- task execution time
- task waiting time&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-ke-size=&quot;size26&quot;&gt;8. reject 정책은 반드시 의도적으로 정해야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스레드와 큐가 모두 가득 차면 작업은 거부된다.&lt;br /&gt;이때 어떤 방식으로 처리할지 정하는 것이 reject 정책이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 다음 정책들이 있다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;AbortPolicy
- 기본 정책
- RejectedExecutionException 발생

CallerRunsPolicy
- 호출한 스레드가 직접 작업 실행
- 유입 속도를 늦추는 back pressure 효과

DiscardPolicy
- 작업을 조용히 버림

DiscardOldestPolicy
- 큐에서 가장 오래된 작업을 버리고 새 작업 적재&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 기본값인 &lt;code&gt;AbortPolicy&lt;/code&gt;를 그대로 둘 수도 있지만, 상황에 따라 &lt;code&gt;CallerRunsPolicy&lt;/code&gt;가 더 안전할 때도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 비동기 로그 저장이나 알림 전송처럼 순간 피크를 완화하고 싶다면 &lt;code&gt;CallerRunsPolicy&lt;/code&gt;를 고려할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정책은 큐가 가득 찼을 때 호출한 스레드가 직접 작업을 수행한다.&lt;br /&gt;그러면 호출 흐름이 느려지면서 자연스럽게 유입 속도가 줄어드는 효과가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 역시 모든 상황에서 정답은 아니다.&lt;br /&gt;요청 스레드가 직접 무거운 작업을 수행하게 되면 API 응답 시간이 증가할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 것은 reject가 발생했을 때의 동작을 의도적으로 설계하는 것이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. 작업 성격별로 Executor를 분리해야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 Executor에 모든 비동기 작업을 넣는 것은 위험하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음 작업들이 모두 같은 Executor를 사용한다고 해보자.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;- 좌표 계산
- DB 저장
- 외부 API 호출
- 알림 전송
- 파일 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API 지연이 발생하면 어떻게 될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API 호출 작업이 worker thread를 오래 점유한다.&lt;br /&gt;그러면 좌표 계산이나 내부 알림 같은 다른 작업까지 밀릴 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 thread pool contamination이라고 볼 수 있다.&lt;br /&gt;한 작업군의 지연이 다른 작업군까지 오염시키는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 작업 성격에 따라 Executor를 분리하는 것이 좋다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;cpuExecutor
- 계산
- 파싱
- 룰 평가

ioExecutor
- 외부 API 호출
- 파일 I/O
- Redis 호출

dbExecutor
- DB 저장/조회 보조 작업

notificationExecutor
- JMS/RabbitMQ/Kafka publish
- 알림 전송&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Bean(name = &quot;evaluationExecutor&quot;)
public Executor evaluationExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(16);
    executor.setQueueCapacity(1000);
    executor.setThreadNamePrefix(&quot;evaluation-&quot;);
    executor.initialize();
    return executor;
}

@Bean(name = &quot;notificationExecutor&quot;)
public Executor notificationExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(30);
    executor.setQueueCapacity(500);
    executor.setThreadNamePrefix(&quot;notification-&quot;);
    executor.initialize();
    return executor;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 사용처에서 명시적으로 지정한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Async(&quot;evaluationExecutor&quot;)
public CompletableFuture&amp;lt;EvaluationResult&amp;gt; evaluateAsync(Position position) {
    EvaluationResult result = evaluator.evaluate(position);
    return CompletableFuture.completedFuture(result);
}

@Async(&quot;notificationExecutor&quot;)
public CompletableFuture&amp;lt;Void&amp;gt; sendNotificationAsync(Event event) {
    notificationSender.send(event);
    return CompletableFuture.completedFuture(null);
}&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-ke-size=&quot;size26&quot;&gt;10. 실무적인 시작값&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답은 아니지만, 실무에서 출발점으로 사용할 수 있는 기준은 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CPU 바운드 Executor&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;물리 8코어 / 논리 16스레드 서버 기준

corePoolSize: 8
maxPoolSize : 16
queueCapacity: 500~1000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근거는 CPU 계산 작업은 코어 수 이상으로 늘려도 병렬성이 크게 증가하지 않기 때문이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;I/O 바운드 Executor&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;corePoolSize: 10~20
maxPoolSize : 30~50
queueCapacity: 500~1000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, DB 작업이라면 DB 커넥션 풀보다 과도하게 크게 잡지 않는다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;DB connection pool: 30
특정 dbExecutor maxPoolSize: 10~20&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;외부 API 호출 Executor&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;corePoolSize: 10
maxPoolSize : 30
queueCapacity: 300~500
timeout: 반드시 설정
circuit breaker: 필요 시 적용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API는 내 서버에서 스레드를 늘린다고 상대 서버 처리량이 늘어나지 않는다.&lt;br /&gt;오히려 장애 시 스레드가 대량으로 묶일 수 있으므로 timeout, bulkhead, circuit breaker가 중요하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;메시지 Consumer&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 브로커는 단순히 스레드 풀만 볼 것이 아니라 consumer concurrency와 prefetch를 같이 봐야 한다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;consumer concurrency: 동시에 처리할 consumer 수
prefetch: consumer가 broker에서 미리 가져올 메시지 수&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 RabbitMQ에서는 다음과 같은 형태로 처리량과 안정성을 조정할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;concurrency = 10
prefetch    = 250&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;consumer 수를 늘리면 병렬 처리량은 증가할 수 있다.&lt;br /&gt;하지만 downstream DB나 외부 API가 받쳐주지 못하면 큐 적체 위치가 브로커에서 애플리케이션 또는 DB로 이동할 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 consumer concurrency, prefetch, DB pool, 처리 시간, 큐 적체량을 함께 봐야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. maxPoolSize를 정하는 기준&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;&amp;ldquo;maxPoolSize는 몇 개가 이상적인가요?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&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;&amp;ldquo;고정값은 없고, 작업 성격과 downstream 병목 자원보다 조금 낮게 잡는 것이 원칙입니다.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구체적으로는 다음 기준을 본다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;CPU 바운드
- CPU 코어 수 또는 논리 코어 수 근처

DB 바운드
- DB 커넥션 풀보다 작게
- 특정 Executor가 전체 커넥션을 독점하지 않게

외부 API 바운드
- 외부 API rate limit 이하
- timeout 필수
- 장애 시 빠르게 실패하도록 구성

메시지 처리
- consumer concurrency
- prefetch
- ack 처리 방식
- downstream 처리량

파일 I/O
- 디스크 처리량
- 파일 크기
- 동시 파일 핸들 수&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, maxPoolSize의 근거는 단순히 CPU 코어 수가 아니라 다음 네 가지다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. CPU 코어 수
2. 작업의 CPU/I/O 비율
3. downstream 자원의 처리 한계
4. 운영 지표&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. 모니터링 없이 정한 스레드 풀 크기는 추측일 뿐이다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스레드 풀 크기는 책상에서 완벽하게 정할 수 없다.&lt;br /&gt;초기값은 설계할 수 있지만, 최종값은 운영 지표를 보고 조정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반드시 봐야 하는 지표는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Thread Pool
- active thread count
- pool size
- queue size
- completed task count
- rejected task count

Application
- API response time
- task execution time
- task waiting time
- error rate
- timeout count

System
- CPU usage
- load average
- memory usage
- GC pause
- context switching

Database
- active connection
- idle connection
- connection wait time
- slow query
- lock wait

Message Broker
- queue depth
- publish rate
- consume rate
- ack rate
- redelivery count&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음 상황이라면 스레드를 늘리는 것이 답이 아닐 수 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;CPU 사용률 90% 이상
queue 증가
응답 시간 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우는 CPU가 병목일 가능성이 높다.&lt;br /&gt;스레드를 늘리면 컨텍스트 스위칭만 증가할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 다음 상황이라면 I/O 대기가 병목일 수 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;CPU 사용률 낮음
active thread 높음
DB connection wait 증가
응답 시간 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우는 스레드 풀이 아니라 DB 커넥션 풀, 쿼리 성능, 외부 시스템 지연을 봐야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;13. 실무에서 추천하는 설정 예시&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 정답이 아니라 출발점이다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = &quot;cpuTaskExecutor&quot;)
    public Executor cpuTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix(&quot;cpu-task-&quot;);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Bean(name = &quot;ioTaskExecutor&quot;)
    public Executor ioTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(40);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix(&quot;io-task-&quot;);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Bean(name = &quot;notificationExecutor&quot;)
    public Executor notificationExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix(&quot;notification-&quot;);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용 예시는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class EvaluationService {

    @Async(&quot;cpuTaskExecutor&quot;)
    public CompletableFuture&amp;lt;EvaluationResult&amp;gt; evaluateAsync(Position position) {
        EvaluationResult result = evaluate(position);
        return CompletableFuture.completedFuture(result);
    }

    private EvaluationResult evaluate(Position position) {
        // CPU 중심 계산 로직
        return new EvaluationResult();
    }
}
@Service
@RequiredArgsConstructor
public class NotificationService {

    @Async(&quot;notificationExecutor&quot;)
    public CompletableFuture&amp;lt;Void&amp;gt; notifyAsync(Event event) {
        // JMS, RabbitMQ, 외부 API 등 I/O 중심 작업
        send(event);
        return CompletableFuture.completedFuture(null);
    }

    private void send(Event event) {
        // notification send
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 작업 성격에 따라 Executor를 분리하면 한 작업군의 지연이 전체 비동기 처리 구조를 망가뜨리는 것을 줄일 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;14. 흔한 실수&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실수 1. maxPoolSize만 크게 잡는다&lt;/h3&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;executor.setCorePoolSize(10);
executor.setMaxPoolSize(200);
executor.setQueueCapacity(100000);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정은 보기에는 처리량이 높아 보이지만, 실제로는 큐가 너무 커서 장애가 늦게 드러날 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실수 2. DB 커넥션 풀보다 worker thread를 과도하게 크게 잡는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 커넥션 풀이 20개인데 DB 작업 Executor를 100개로 잡으면 대부분의 스레드는 커넥션을 기다린다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실수 3. 모든 비동기 작업을 하나의 Executor에 넣는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API 장애가 발생했을 때 계산 작업, 알림 작업, DB 작업까지 같이 밀릴 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실수 4. timeout 없이 외부 API를 호출한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 API 지연은 worker thread 고갈로 이어질 수 있다.&lt;br /&gt;비동기 처리에서도 timeout은 필수다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실수 5. 모니터링 없이 감으로 튜닝한다&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-ke-size=&quot;size26&quot;&gt;15. 결론&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;/p&gt;
&lt;pre class=&quot;x86asm&quot;&gt;&lt;code&gt;CPU 바운드 작업
&amp;rarr; 코어 수 또는 논리 코어 수 근처

I/O 바운드 작업
&amp;rarr; 코어 수보다 크게 가능

DB 중심 작업
&amp;rarr; DB 커넥션 풀보다 과도하게 크게 잡지 않기

외부 API 작업
&amp;rarr; timeout, rate limit, circuit breaker 고려

메시지 처리
&amp;rarr; consumer concurrency, prefetch, downstream 처리량 함께 고려

공통
&amp;rarr; queueCapacity, reject 정책, 모니터링 필수&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;스레드 풀의 크기는 CPU 코어 수가 아니라, 작업이 CPU를 쓰는 시간과 기다리는 시간의 비율, 그리고 downstream 자원의 처리 한계로 결정해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 좋은 스레드 풀 설정은 큰 숫자를 넣는 것이 아니다.&lt;br /&gt;시스템이 감당할 수 있는 만큼만 병렬성을 열어두고, 과부하 상황에서는 큐와 reject 정책으로 안전하게 버티며, 운영 지표를 보고 지속적으로 조정하는 것이다.&lt;/p&gt;
&lt;/div&gt;</description>
      <category>나의 주니어 개발 일기/트러블슈팅</category>
      <author>추억을 백앤드하자</author>
      <guid isPermaLink="true">https://pulpul8282.tistory.com/443</guid>
      <comments>https://pulpul8282.tistory.com/443#entry443comment</comments>
      <pubDate>Mon, 6 Jul 2026 23:26:37 +0900</pubDate>
    </item>
    <item>
      <title>주변 지오펜스 조회 성능 개선 (like 주변 맛집 검색)</title>
      <link>https://pulpul8282.tistory.com/442</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;지오펜스 후보 조회 성능 개선&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;마주친 문제&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;드론 충돌회피 시스템에서 현재 드론 위치를 기준으로 지오펜스 침범/근접 여부를 실시간으로 판단해야 했다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;DB에는 다수의 지오펜스 데이터가 존재했고, 드론 위치 이벤트가 발생할 때마다 모든 지오펜스 Polygon과 현재 위치를 비교하는 방식은 지오펜스 개수에 비례해 연산량이 증가하는 문제가 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;초기 구조는 다음과 같은 한계를 가졌다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;현재 드론 위치 수신
        &amp;darr;
전체 지오펜스 목록 순회
        &amp;darr;
각 지오펜스 Polygon과 거리/포함 여부 계산
        &amp;darr;
O(N) 연산 반복&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;드론 위치 이벤트는 지속적으로 발생하기 때문에, 매번 전체 지오펜스를 대상으로 정밀 기하 연산을 수행하는 구조는 실시간 평가에 적합하지 않았다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;따라서 현재 위치 기준 검색 반경 내에 존재할 가능성이 있는 지오펜스 후보만 먼저 추출하고, 해당 후보에 대해서만 정밀 침범/거리 판단을 수행하는 구조가 필요했다.&lt;/span&gt;&lt;/p&gt;
&lt;div contenteditable=&quot;false&quot;&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;1차 해결: Grid Cell 기반 &lt;/span&gt;&lt;span&gt;GeofenceSpatialIndex&lt;/span&gt;&lt;span&gt; 적용&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;전체 지오펜스를 매번 순회하지 않기 위해 위경도 기반 Grid Index를 직접 구현했다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;공간을 일정 크기의 Grid Cell로 나누고, 지오펜스의 bounding box가 걸치는 모든 Cell에 해당 지오펜스를 등록했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;조회 시에는 현재 드론 위치와 검색 반경을 기준으로 검색 bounding box를 만들고, 이 box가 걸치는 Grid Cell만 순회하여 후보 지오펜스를 추출했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Grid Cell&lt;/span&gt; 생성 관련코드&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1782364177219&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 지오펜스 bounding box가 걸치는 Grid Cell 범위 계산
GeoGridKey minKey = GeoGridKey.from(
        geofence.getMinLatDeg(),
        geofence.getMinLonDeg(),
        gridSizeDeg
);

GeoGridKey maxKey = GeoGridKey.from(
        geofence.getMaxLatDeg(),
        geofence.getMaxLonDeg(),
        gridSizeDeg
);

// 지오펜스가 걸치는 모든 Grid Cell에 같은 geofence를 등록
for (int lat = minKey.getLatIndex(); lat &amp;lt;= maxKey.getLatIndex(); lat++) {
    for (int lon = minKey.getLonIndex(); lon &amp;lt;= maxKey.getLonIndex(); lon++) {
        GeoGridKey key = new GeoGridKey(lat, lon);

        index.computeIfAbsent(key, k -&amp;gt; new ArrayList&amp;lt;&amp;gt;())
                .add(geofence);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;전체 공간을 Grid Cell로 분할

+----+----+----+----+----+
|    |    |    |    |    |
+----+----+----+----+----+
|    | G1 | G1 | G1 |    |
+----+----+----+----+----+
|    | G1 | G1 | G1 |    |
+----+----+----+----+----+
|    |    |    |    |    |
+----+----+----+----+----+

G1 지오펜스가 걸치는 Cell마다 G1을 등록&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;427&quot; data-origin-height=&quot;379&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/u2uQk/dJMcaff38vu/sBxAMn83oYP1ksGZcHdLJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/u2uQk/dJMcaff38vu/sBxAMn83oYP1ksGZcHdLJK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/u2uQk/dJMcaff38vu/sBxAMn83oYP1ksGZcHdLJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fu2uQk%2FdJMcaff38vu%2FsBxAMn83oYP1ksGZcHdLJK%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;427&quot; height=&quot;379&quot; data-origin-width=&quot;427&quot; data-origin-height=&quot;379&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;조회 흐름은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;848&quot; data-origin-height=&quot;454&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/boEVXl/dJMcadWNlll/iK3AMzE0U7MBCEIakyK8b1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/boEVXl/dJMcadWNlll/iK3AMzE0U7MBCEIakyK8b1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/boEVXl/dJMcadWNlll/iK3AMzE0U7MBCEIakyK8b1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FboEVXl%2FdJMcadWNlll%2FiK3AMzE0U7MBCEIakyK8b1%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;848&quot; height=&quot;454&quot; data-origin-width=&quot;848&quot; data-origin-height=&quot;454&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;현재 위치 기준 검색 반경
        &amp;darr;
검색 반경을 감싸는 bounding box 생성
        &amp;darr;
bounding box가 걸치는 Grid Cell 계산
        &amp;darr;
해당 Cell에 등록된 지오펜스만 후보로 조회
        &amp;darr;
geofenceId 기준 중복 제거&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 방식으로 전체 지오펜스를 매번 순회하지 않고, 현재 위치 주변 후보군만 추출할 수 있었다.&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;주변 지오펜스 조회 관련 코드&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1782364241006&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SearchRange range = SearchRange.from(latDeg, lonDeg, radiusM);

GeoGridKey minKey = GeoGridKey.from(
        range.getMinLat(),
        range.getMinLon(),
        gridSizeDeg
);

GeoGridKey maxKey = GeoGridKey.from(
        range.getMaxLat(),
        range.getMaxLon(),
        gridSizeDeg
);

// 같은 지오펜스가 여러 Cell에서 조회될 수 있으므로 중복 제거
Map&amp;lt;String, GeofenceModel&amp;gt; dedup = new LinkedHashMap&amp;lt;&amp;gt;();

for (int lat = minKey.getLatIndex(); lat &amp;lt;= maxKey.getLatIndex(); lat++) {
    for (int lon = minKey.getLonIndex(); lon &amp;lt;= maxKey.getLonIndex(); lon++) {
        GeoGridKey key = new GeoGridKey(lat, lon);
        List&amp;lt;GeofenceModel&amp;gt; bucket = index.get(key);

        if (bucket == null) {
            continue;
        }

        for (GeofenceModel geofence : bucket) {
            if (intersects(geofence, range)) {
                dedup.put(geofence.getGeofenceId(), geofence);
            }
        }
    }
}

return new ArrayList&amp;lt;&amp;gt;(dedup.values());&lt;/code&gt;&lt;/pre&gt;
&lt;div contenteditable=&quot;false&quot;&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;추가로 발견한 문제&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Grid Index 방식은 후보군 축소에는 효과가 있었지만, 지오펜스가 &amp;ldquo;점&amp;rdquo;이 아니라 &amp;ldquo;면&amp;rdquo; 데이터라는 점에서 성능 한계가 발생했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;지오펜스 하나가 여러 Grid Cell에 걸치기 때문에, 하나의 지오펜스가 여러 Cell에 중복 등록되었다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;큰 지오펜스 하나가 여러 Cell에 걸치는 경우

+----+----+----+----+----+----+
| G1 | G1 | G1 | G1 | G1 | G1 |
+----+----+----+----+----+----+
| G1 | G1 | G1 | G1 | G1 | G1 |
+----+----+----+----+----+----+
| G1 | G1 | G1 | G1 | G1 | G1 |
+----+----+----+----+----+----+

G1 하나가 다수의 Cell에 반복 등록됨

ex)
cell(1,1) -&amp;gt; [G1]
cell(1,2) -&amp;gt; [G1]
cell(1,3) -&amp;gt; [G1]

cell(2,1) -&amp;gt; [G1]
cell(2,2) -&amp;gt; [G1]
cell(2,3) -&amp;gt; [G1]

cell(3,1) -&amp;gt; [G1]
cell(3,2) -&amp;gt; [G1]
cell(3,3) -&amp;gt; [G1]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Grid 크기를 작게 잡을수록 후보 조회의 오차 범위는 줄어들지만, 반대로 지오펜스 하나 등록되는 Cell 수가 급격히 증가했다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;예를 들어 위도 기준 &lt;/span&gt;&lt;span&gt;0.001도&lt;/span&gt;&lt;span&gt;는 약 111m 수준이고, 더 세밀한 Grid를 사용하면 큰 지오펜스 하나가 수백 개 이상의 Cell에 등록될 수 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이로 인해 다음과 같은 문제가 발생했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;- 지오펜스 개수가 많아질수록 인덱스 rebuild 비용 증가
- 지오펜스 면적이 클수록 하나의 지오펜스가 다수 Cell에 중복 등록
- Grid 크기를 작게 잡을수록 메모리 사용량 증가
- 등록/수정/삭제 이벤트 발생 시 전체 인덱스 재구성 비용 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;결과적으로 Grid Index는 전체 순회 문제를 완화했지만, 지오펜스 데이터가 증가하거나 큰 지오펜스가 많아질수록 인덱스 생성 비용과 메모리 사용량이 커지는 한계가 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;div contenteditable=&quot;false&quot;&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;최종 개선: JTS &lt;/span&gt;&lt;span&gt;STRtree&lt;/span&gt;&lt;span&gt; 기반 &lt;/span&gt;&lt;span&gt;GeofenceStrtreeIndex&lt;/span&gt;&lt;span&gt; 적용&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Grid 방식의 중복 등록 문제를 해결하기 위해 Java 진영의 공간 기하 라이브러리인 JTS를 검토했고, JTS에서 제공하는 &lt;/span&gt;&lt;span&gt;STRtree&lt;/span&gt;&lt;span&gt;를 적용했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;STRtree&lt;/span&gt;&lt;span&gt;는 지오펜스를 Grid Cell마다 쪼개 저장하지 않고, 각 지오펜스의 Geometry를 감싸는 Envelope, 즉 bounding box를 공간 인덱스에 등록한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;실제 지오펜스 Polygon

        /\
       /  \
      /____\

Polygon을 감싸는 Envelope

   +--------+
   |   /\   |
   |  /__\  |
   +--------+

STRtree에는 Envelope 기준으로 등록&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Grid 방식과 비교하면 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;Grid 방식
- 공간을 작은 Cell로 나눔
- 지오펜스가 걸치는 모든 Cell에 반복 등록
- 큰 지오펜스일수록 중복 등록 증가

cell(1,1) -&amp;gt; G1
cell(1,2) -&amp;gt; G1
cell(2,1) -&amp;gt; G1
cell(2,2) -&amp;gt; G1

STRtree 방식
- 지오펜스별 Envelope를 공간 인덱스에 등록
- 검색 Envelope와 겹치는 후보만 빠르게 조회
- 후보에 대해서만 정밀 기하 연산 수행

Envelope(G1) -&amp;gt; G1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;STRtree 생성 관련 코드&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1782364358481&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;STRtree newIndex = new STRtree();

for (GeofenceModel geofence : store.values()) {
    Geometry geometry = geofence.getJtsGeometry();

    if (geometry == null || geometry.isEmpty()) {
        continue;
    }

    // 지오펜스 Geometry를 감싸는 Envelope를 STRtree에 등록
    newIndex.insert(geometry.getEnvelopeInternal(), geofence);
}

newIndex.build();
activeIndex.set(newIndex);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;조회 흐름은 다음과 같이 개선했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;현재 드론 위치 수신
        &amp;darr;
현재 위치 기준 검색 Envelope 생성
        &amp;darr;
STRtree에서 검색 Envelope와 겹치는 지오펜스 후보 조회
        &amp;darr;
후보에 대해서만 JTS covers()/distance() 수행
        &amp;darr;
침범 중이거나 반경 이내인 지오펜스만 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;778&quot; data-origin-height=&quot;530&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQXkZ8/dJMcac4D3xW/UiZCh8IsdFjRYUILYjvvZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQXkZ8/dJMcac4D3xW/UiZCh8IsdFjRYUILYjvvZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQXkZ8/dJMcac4D3xW/UiZCh8IsdFjRYUILYjvvZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQXkZ8%2FdJMcac4D3xW%2FUiZCh8IsdFjRYUILYjvvZk%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;778&quot; height=&quot;530&quot; data-origin-width=&quot;778&quot; data-origin-height=&quot;530&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 id=&quot;code_1782364549207&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Envelope searchEnv = new Envelope(
        localPoint.getX() - radiusM,
        localPoint.getX() + radiusM,
        localPoint.getY() - radiusM,
        localPoint.getY() + radiusM
);

// 검색 Envelope와 겹치는 후보만 조회
List&amp;lt;GeofenceModel&amp;gt; candidates = activeIndex.get().query(searchEnv);

List&amp;lt;GeofenceModel&amp;gt; result = new ArrayList&amp;lt;&amp;gt;();

for (GeofenceModel geofence : candidates) {
    boolean invaded = geofence.getJtsGeometry().covers(point);
    double distanceM = invaded
            ? 0.0
            : geofence.getJtsGeometry().distance(point);

    if (invaded || distanceM &amp;lt;= radiusM) {
        result.add(geofence);
    }
}

return result;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이를 통해 전체 지오펜스를 매번 순회하지 않고, 공간 인덱스를 통해 1차 후보를 빠르게 줄인 뒤 정밀 계산을 수행하도록 개선했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;또한 인덱스 갱신 시 기존 Grid 방식처럼 기존 인덱스를 &lt;/span&gt;&lt;span&gt;clear()&lt;/span&gt;&lt;span&gt;한 뒤 다시 채우는 방식이 아니라, 새 &lt;/span&gt;&lt;span&gt;STRtree&lt;/span&gt;&lt;span&gt;를 별도로 완성한 뒤 &lt;/span&gt;&lt;span&gt;AtomicReference&lt;/span&gt;&lt;span&gt;로 교체하는 copy-build-swap 구조를 적용했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;기존 activeIndex 유지
        &amp;darr;
새 STRtree 생성 및 build
        &amp;darr;
build 완료 후 activeIndex swap
        &amp;darr;
조회 스레드는 항상 완성된 인덱스만 참조&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이를 통해 인덱스 rebuild 중에도 실시간 조회 로직이 비어 있거나 일부만 구성된 인덱스를 참조하지 않도록 안정성을 개선했다.&lt;/span&gt;&lt;/p&gt;
&lt;div contenteditable=&quot;false&quot;&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;결과&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Grid Cell 기반 후보 조회를 통해 전체 순회 문제를 1차적으로 해결했고, 이후 Grid 방식의 중복 등록 및 rebuild 비용 증가 한계를 JTS &lt;/span&gt;&lt;span&gt;STRtree&lt;/span&gt;&lt;span&gt; 기반 공간 인덱스로 개선했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;최종적으로 지오펜스 후보 조회 구조를 다음과 같이 변경했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;Before
전체 지오펜스 순회
또는
Grid Cell 기반 후보 조회 + 다수 Cell 중복 등록

After
STRtree Envelope 기반 후보 조회
+ 후보 대상 JTS 정밀 거리/포함 판단
+ copy-build-swap 기반 안전한 인덱스 갱신&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이를 통해 지오펜스 데이터 증가에 따른 후보 조회 비용과 인덱스 관리 부담을 줄이고, 실시간 드론 위치 평가에 적합한 구조로 개선했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;정리&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Grid&amp;nbsp;방식:&amp;nbsp;실제&amp;nbsp;운영&amp;nbsp;데이터&amp;nbsp;기준&amp;nbsp;50,000,000회까지&amp;nbsp;연산으로&amp;nbsp;인해&amp;nbsp;&amp;nbsp;rebuild&amp;nbsp;시간과&amp;nbsp;메모리&amp;nbsp;사용량&amp;nbsp;증가의&amp;nbsp;주요&amp;nbsp;원인이&amp;nbsp;되었다. &lt;br /&gt;STRtree 방식: STRtree로 변경한 뒤에는 지오펜스별 Envelope를 한 번씩 insert하는 방식으로 변경되어 약 10,000회로 연산 수치가 줄어들며 불필요한 연산 수치를 5000배 줄일 수 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>나의 주니어 개발 일기/트러블슈팅</category>
      <author>추억을 백앤드하자</author>
      <guid isPermaLink="true">https://pulpul8282.tistory.com/442</guid>
      <comments>https://pulpul8282.tistory.com/442#entry442comment</comments>
      <pubDate>Thu, 25 Jun 2026 14:20:14 +0900</pubDate>
    </item>
    <item>
      <title>로컬 LLM 기반 AI Code Reviewer 구축기 - 단순 코드 리뷰를 넘어 아키텍처 리뷰까지</title>
      <link>https://pulpul8282.tistory.com/441</link>
      <description>&lt;h1&gt;로컬 LLM 기반 AI Code Reviewer 구축기 - 단순 코드 리뷰를 넘어 아키텍처 리뷰까지&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 AI 기반 코드 리뷰 도구들이 빠르게 발전하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Copilot, Cursor, Claude Code 같은 도구들은 코드 작성뿐만 아니라 코드 리뷰까지 지원하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제 프로젝트에서 사용해보니 한 가지 아쉬운 점이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이 변경사항이 현재 프로젝트 아키텍처에 적합한가?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라는 질문에는 의외로 답변 품질이 높지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 프로젝트의 설계 원칙이나 레이어 규칙을 모르는 상태에서 PR Diff만 보고 리뷰하기 때문에 종종 일반론적인 피드백이 나왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 직접 GitHub Pull Request 이벤트를 기반으로 동작하는 AI Code Reviewer를 만들어보기로 했다.&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;PR 생성 시 자동 리뷰&lt;/li&gt;
&lt;li&gt;코드 품질 리뷰&lt;/li&gt;
&lt;li&gt;프로젝트 아키텍처 리뷰&lt;/li&gt;
&lt;li&gt;로컬 LLM 기반 운영&lt;/li&gt;
&lt;li&gt;GitHub Comment 자동 작성&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;시스템 아키텍처&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 구조는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;GitHub PR 생성

      │

      ▼

Webhook 수신

      │

      ▼

PR Diff 수집

      │

      ▼

Review Context 생성

      │
      ├── Diff
      ├── 변경 파일 전체 코드
      ├── AGENTS.md
      └── Package Tree

      ▼

Local LLM

      │
      ├── Code Review
      └── Architecture Review

      ▼

Review Merge

      ▼

GitHub Comment 작성
&lt;/code&gt;&lt;/pre&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;Spring Boot 3&lt;/li&gt;
&lt;li&gt;Spring AI&lt;/li&gt;
&lt;li&gt;Ollama&lt;/li&gt;
&lt;li&gt;Qwen3 30B&lt;/li&gt;
&lt;li&gt;GitHub Webhook&lt;/li&gt;
&lt;li&gt;GitHub REST API&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;왜 Diff만으로는 부족했을까?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 구현은 매우 단순했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Webhook으로 PR 생성 이벤트를 수신한 후 Diff를 그대로 LLM에게 전달했다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;PR Diff
   &amp;darr;
LLM
   &amp;darr;
Review
&lt;/code&gt;&lt;/pre&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Null 처리 누락&lt;/li&gt;
&lt;li&gt;예외 처리 부족&lt;/li&gt;
&lt;li&gt;테스트 코드 필요&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;하지만 아키텍처 리뷰는 이상했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;PullRequestReviewService
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부에 새로운 책임이 추가되었는데도&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;전반적으로 구조가 양호합니다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 답변이 나왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;곰곰이 생각해보니 이유는 명확했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM은 현재 변경된 몇 줄만 보고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 클래스가 원래 어떤 역할인지&lt;/li&gt;
&lt;li&gt;같은 패키지에 어떤 클래스가 있는지&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;코드 리뷰와 아키텍처 리뷰를 분리하다&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 리뷰를 두 종류로 나누었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Code Review&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;PR Diff
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검토&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;버그 가능성&lt;/li&gt;
&lt;li&gt;예외 처리&lt;/li&gt;
&lt;li&gt;성능&lt;/li&gt;
&lt;li&gt;가독성&lt;/li&gt;
&lt;li&gt;테스트 필요성&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Architecture Review&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;PR Diff
+ 변경 파일 전체 코드
+ Package Tree
+ AGENTS.md
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검토&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;계층 분리&lt;/li&gt;
&lt;li&gt;책임 분산&lt;/li&gt;
&lt;li&gt;Service 비대화&lt;/li&gt;
&lt;li&gt;외부 API 호출 위치&lt;/li&gt;
&lt;li&gt;테스트 가능성&lt;/li&gt;
&lt;li&gt;프로젝트 규칙 위반 여부&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;AGENTS.md 도입&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 효과가 좋았던 개선은 AGENTS.md였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 루트에 다음과 같은 규칙 파일을 추가했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Architecture Rules

- Controller는 HTTP 처리만 담당한다.
- Service는 유스케이스 조율만 담당한다.
- 외부 API 호출은 Client 계층에 둔다.
- Prompt 생성은 PromptBuilder에 둔다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리뷰 시 이 파일을 읽어서 프롬프트에 포함한다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;[PROJECT_RULES]
AGENTS.md

[PR_DIFF]
...

[CHANGED_FILE_SOURCE]
...

[PACKAGE_TREE]
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후부터는 단순한 코드 품질 리뷰가 아니라&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;현재 프로젝트 규칙을 위반하는지&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 판단하기 시작했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;변경 파일 전체 코드 수집&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아키텍처 리뷰에서 가장 중요한 컨텍스트였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Diff만 보면&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;+ private final GithubClient githubClient;
&lt;/code&gt;&lt;/pre&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;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;PullRequestReviewService
 ├─ Diff 조회
 ├─ LLM 호출
 ├─ Comment 작성
 └─ Prompt 생성
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처럼 책임 과다 문제를 발견할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub API를 이용해 변경 파일의 최신 코드를 수집하도록 개선했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;프로젝트 규칙을 모르는 AI는 좋은 리뷰어가 아니다&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서 얻은 가장 큰 교훈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI는 똑똑하지만 프로젝트를 모른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트가&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Controller
  &amp;darr;
Service
  &amp;darr;
Client
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조를 따르는지,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DDD를 사용하는지,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hexagonal Architecture를 사용하는지,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 좋은 AI 리뷰를 만들기 위해서는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;좋은 모델&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;좋은 컨텍스트&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;h1&gt;앞으로 개선할 내용&lt;/h1&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;GitHub Webhook&lt;/li&gt;
&lt;li&gt;PR Diff 수집&lt;/li&gt;
&lt;li&gt;Local LLM 연동&lt;/li&gt;
&lt;li&gt;Code Review&lt;/li&gt;
&lt;li&gt;Architecture Review&lt;/li&gt;
&lt;li&gt;GitHub Comment 작성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로는 다음 기능을 추가할 예정이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RAG 기반 프로젝트 컨텍스트 검색&lt;/h2&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;PR Diff
   &amp;darr;
Vector Search
   &amp;darr;
관련 코드 검색
   &amp;darr;
LLM
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트 결과 기반 리뷰&lt;/h2&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;Gradle Test
    &amp;darr;
실패 테스트
    &amp;darr;
LLM
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 히스토리 기반 리뷰&lt;/h2&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;과거 PR
과거 버그
과거 설계 결정
&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;h1&gt;마치며&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 코드 리뷰어를 만들면서 느낀 점은 하나였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 리뷰 품질은 모델 크기로 결정되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얼마나 많은 프로젝트 컨텍스트를 제공하느냐가 더 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Diff만 보는 리뷰어와&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 규칙, 전체 코드, 패키지 구조를 이해하는 리뷰어는 전혀 다른 결과를 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트는 단순히 LLM을 붙이는 것이 아니라,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;프로젝트를 이해하는 AI 리뷰어&quot;를 만드는 과정이었다.&lt;/p&gt;</description>
      <category>나의 주니어 개발 일기/트러블슈팅</category>
      <author>추억을 백앤드하자</author>
      <guid isPermaLink="true">https://pulpul8282.tistory.com/441</guid>
      <comments>https://pulpul8282.tistory.com/441#entry441comment</comments>
      <pubDate>Thu, 11 Jun 2026 09:37:05 +0900</pubDate>
    </item>
  </channel>
</rss>